mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 20:50:58 +00:00
chore(merge): sync master into dev/4.11.x
This commit is contained in:
@@ -636,7 +636,8 @@ function AddExtensionContent() {
|
||||
const pluginDisplayName = `${githubOwner}/${githubRepo}`;
|
||||
httpClient
|
||||
.installPluginFromGithub(
|
||||
selectedAsset.download_url,
|
||||
selectedAsset.id,
|
||||
selectedRelease.id,
|
||||
githubOwner,
|
||||
githubRepo,
|
||||
selectedRelease.tag_name,
|
||||
|
||||
@@ -29,11 +29,17 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
export default function BotDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const canViewMonitoring =
|
||||
currentWorkspace?.permissions.includes('resource.view') ?? false;
|
||||
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
|
||||
|
||||
// Set breadcrumb entity name
|
||||
@@ -131,19 +137,23 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('bots.createBot')}</h1>
|
||||
<Button type="submit" form="bot-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button type="submit" form="bot-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-3xl pb-8">
|
||||
<BotForm
|
||||
initBotId={undefined}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<BotForm
|
||||
initBotId={undefined}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,6 +174,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
id="bot-enable-switch"
|
||||
checked={botEnabled}
|
||||
onCheckedChange={handleEnableToggle}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="bot-enable-switch"
|
||||
@@ -174,14 +185,16 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
form="bot-form"
|
||||
disabled={!formDirty}
|
||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="bot-form"
|
||||
disabled={!formDirty}
|
||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Horizontal Tabs */}
|
||||
@@ -197,14 +210,18 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
<Settings className="size-3.5" />
|
||||
{t('bots.configuration')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" className="gap-1.5">
|
||||
<FileText className="size-3.5" />
|
||||
{t('bots.logs')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sessions" className="gap-1.5">
|
||||
<Users className="size-3.5" />
|
||||
{t('bots.sessionMonitor.title')}
|
||||
</TabsTrigger>
|
||||
{canViewMonitoring && (
|
||||
<TabsTrigger value="logs" className="gap-1.5">
|
||||
<FileText className="size-3.5" />
|
||||
{t('bots.logs')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canViewMonitoring && (
|
||||
<TabsTrigger value="sessions" className="gap-1.5">
|
||||
<Users className="size-3.5" />
|
||||
{t('bots.sessionMonitor.title')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
{activeTab === 'sessions' && (
|
||||
<button
|
||||
@@ -239,60 +256,68 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
||||
<BotForm
|
||||
initBotId={id}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<BotForm
|
||||
initBotId={id}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{/* Card: Danger Zone */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{t('bots.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.dangerZoneDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('bots.deleteBotAction')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('bots.deleteBotHint')}
|
||||
</p>
|
||||
{canManage && (
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{t('bots.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.dangerZoneDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('bots.deleteBotAction')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('bots.deleteBotHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-1.5" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-1.5" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Logs */}
|
||||
<TabsContent
|
||||
value="logs"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<BotLogListComponent botId={id} />
|
||||
</TabsContent>
|
||||
{canViewMonitoring && (
|
||||
<TabsContent
|
||||
value="logs"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<BotLogListComponent botId={id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Tab: Sessions */}
|
||||
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
|
||||
<BotSessionMonitor ref={sessionMonitorRef} botId={id} />
|
||||
</TabsContent>
|
||||
{canViewMonitoring && (
|
||||
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
|
||||
<BotSessionMonitor ref={sessionMonitorRef} botId={id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -58,19 +58,9 @@ export default function AccountSettingsPanel({
|
||||
const handleBindSpace = async () => {
|
||||
setSpaceBindLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
toast.error(t('common.error'));
|
||||
setSpaceBindLoading(false);
|
||||
return;
|
||||
}
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
|
||||
// Pass token as state for security verification
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(
|
||||
redirectUri,
|
||||
token,
|
||||
);
|
||||
const response = await httpClient.getSpaceBindAuthorizeUrl(redirectUri);
|
||||
window.location.href = response.authorize_url;
|
||||
} catch {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
|
||||
@@ -40,12 +40,20 @@ import { PanelToolbar } from '../settings-dialog/panel-layout';
|
||||
|
||||
interface ApiKey {
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
key: string;
|
||||
description: string;
|
||||
scopes: string[];
|
||||
status: 'active' | 'revoked';
|
||||
secret_available: false;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type CreatedApiKey = Omit<ApiKey, 'secret_available'> & {
|
||||
key: string;
|
||||
secret_available: true;
|
||||
};
|
||||
|
||||
interface Webhook {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -71,7 +79,7 @@ export default function ApiIntegrationPanel({
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newKeyDescription, setNewKeyDescription] = useState('');
|
||||
const [createdKey, setCreatedKey] = useState<ApiKey | null>(null);
|
||||
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null);
|
||||
const [deleteKeyId, setDeleteKeyId] = useState<number | null>(null);
|
||||
|
||||
// Webhook state
|
||||
@@ -135,7 +143,7 @@ export default function ApiIntegrationPanel({
|
||||
const response = (await backendClient.post('/api/v1/apikeys', {
|
||||
name: newKeyName,
|
||||
description: newKeyDescription,
|
||||
})) as { key: ApiKey };
|
||||
})) as { key: CreatedApiKey };
|
||||
|
||||
setCreatedKey(response.key);
|
||||
toast.success(t('common.apiKeyCreated'));
|
||||
@@ -184,11 +192,6 @@ export default function ApiIntegrationPanel({
|
||||
copiedTimerRef.current = setTimeout(() => setCopiedKey(null), 2000);
|
||||
};
|
||||
|
||||
const maskApiKey = (key: string) => {
|
||||
if (key.length <= 8) return key;
|
||||
return `${key.substring(0, 8)}...${key.substring(key.length - 4)}`;
|
||||
};
|
||||
|
||||
// Webhook methods
|
||||
const loadWebhooks = async () => {
|
||||
setLoading(true);
|
||||
@@ -337,25 +340,12 @@ export default function ApiIntegrationPanel({
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||
{maskApiKey(item.key)}
|
||||
</code>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('common.apiKeyStoredSecurely')}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => handleCopyKey(item.key)}
|
||||
title={t('common.copyApiKey')}
|
||||
>
|
||||
{copiedKey === item.key ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
|
||||
import QrCodeLoginDialog, {
|
||||
QrLoginPlatform,
|
||||
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
|
||||
@@ -640,12 +641,9 @@ export default function DynamicFormComponent({
|
||||
// even if the user saves without modifying any field.
|
||||
// form.watch(callback) only fires on subsequent changes, not on mount.
|
||||
const formValues = form.getValues();
|
||||
const initialFinalValues = editableValueSpecs.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, object>,
|
||||
const initialFinalValues = normalizeDynamicFormValuesForSave(
|
||||
editableValueSpecs,
|
||||
formValues as Record<string, unknown>,
|
||||
);
|
||||
onSubmitRef.current?.(initialFinalValues);
|
||||
|
||||
@@ -660,12 +658,9 @@ export default function DynamicFormComponent({
|
||||
|
||||
const subscription = form.watch(() => {
|
||||
const formValues = form.getValues();
|
||||
const finalValues = editableValueSpecs.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, object>,
|
||||
const finalValues = normalizeDynamicFormValuesForSave(
|
||||
editableValueSpecs,
|
||||
formValues as Record<string, unknown>,
|
||||
);
|
||||
onSubmitRef.current?.(finalValues);
|
||||
previousInitialValues.current = finalValues as Record<string, object>;
|
||||
|
||||
@@ -228,15 +228,10 @@ export default function DynamicFormItemComponent({
|
||||
|
||||
const handleSpaceLogin = () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
toast.error(t('common.error'));
|
||||
return;
|
||||
}
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
|
||||
httpClient
|
||||
.getSpaceAuthorizeUrl(redirectUri, token)
|
||||
.getSpaceBindAuthorizeUrl(redirectUri)
|
||||
.then((response) => {
|
||||
window.location.href = response.authorize_url;
|
||||
})
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
|
||||
export type DynamicFormSaveValueSpec = Pick<
|
||||
IDynamicFormItemSchema,
|
||||
'default' | 'name' | 'type'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Build the value snapshot emitted to parent forms for persistence.
|
||||
* Only single-line string fields trim surrounding whitespace; multiline text
|
||||
* and every other dynamic form field type preserve their original values.
|
||||
*/
|
||||
export function normalizeDynamicFormValuesForSave(
|
||||
specs: readonly DynamicFormSaveValueSpec[],
|
||||
formValues: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return specs.reduce<Record<string, unknown>>((values, spec) => {
|
||||
const value = formValues[spec.name] ?? spec.default;
|
||||
values[spec.name] =
|
||||
spec.type === 'string' && typeof value === 'string'
|
||||
? value.trim()
|
||||
: value;
|
||||
return values;
|
||||
}, {});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/components/ui/form';
|
||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
|
||||
/**
|
||||
@@ -150,12 +151,9 @@ export default function N8nAuthFormComponent({
|
||||
// Emit initial form values on mount so the parent form's
|
||||
// initializedStagesRef registers this stage (matches DynamicFormComponent).
|
||||
const formValues = form.getValues();
|
||||
const initialFinalValues = itemConfigList.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
const initialFinalValues = normalizeDynamicFormValuesForSave(
|
||||
itemConfigList,
|
||||
formValues as Record<string, unknown>,
|
||||
);
|
||||
onSubmitRef.current?.(initialFinalValues);
|
||||
previousInitialValues.current = initialFinalValues as Record<
|
||||
@@ -171,12 +169,9 @@ export default function N8nAuthFormComponent({
|
||||
|
||||
// 获取完整的表单值,确保包含所有默认值
|
||||
const formValues = form.getValues();
|
||||
const finalValues = itemConfigList.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
const finalValues = normalizeDynamicFormValuesForSave(
|
||||
itemConfigList,
|
||||
formValues as Record<string, unknown>,
|
||||
);
|
||||
|
||||
onSubmitRef.current?.(finalValues);
|
||||
|
||||
@@ -4,7 +4,11 @@ import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList';
|
||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||
import { systemInfo, httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import {
|
||||
clearUserInfo,
|
||||
getCloudServiceClientSync,
|
||||
useCurrentWorkspace,
|
||||
} from '@/app/infra/http';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Moon,
|
||||
@@ -35,6 +39,7 @@ import {
|
||||
Bot,
|
||||
Workflow,
|
||||
ListTree,
|
||||
UsersRound,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
|
||||
@@ -60,6 +65,9 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { LanguageSelector } from '@/components/ui/language-selector';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import WorkspaceSwitcher, {
|
||||
OPEN_WORKSPACE_SETTINGS_EVENT,
|
||||
} from '@/app/home/components/workspace-settings/WorkspaceSwitcher';
|
||||
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
|
||||
import SettingsDialog, {
|
||||
SettingsSection,
|
||||
@@ -384,6 +392,11 @@ function NavItems({
|
||||
const sidebarData = useSidebarData();
|
||||
const { state: sidebarState, isMobile } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManageResources =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const canOperateRuntime =
|
||||
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
|
||||
// Track which entity categories have their full list expanded
|
||||
const [expandedLists, setExpandedLists] = useState<SidebarListExpansionState>(
|
||||
loadListExpansionState,
|
||||
@@ -517,6 +530,9 @@ function NavItems({
|
||||
<>
|
||||
{sectionItems.map((config) => {
|
||||
if (!isEntityCategory(config.id)) {
|
||||
if (config.id === 'add-extension' && !canManageResources) {
|
||||
return null;
|
||||
}
|
||||
// Non-entity entries (e.g. monitoring, market, mcp) render as plain links
|
||||
return (
|
||||
<SidebarMenuItem key={config.id}>
|
||||
@@ -556,7 +572,8 @@ function NavItems({
|
||||
: sidebarData[entityKey];
|
||||
const routePrefix = ENTITY_ROUTE_MAP[categoryId];
|
||||
const hasDetailPages = DETAIL_PAGE_CATEGORIES.includes(categoryId);
|
||||
const canCreate = CREATABLE_CATEGORIES.includes(categoryId);
|
||||
const canCreate =
|
||||
canManageResources && CREATABLE_CATEGORIES.includes(categoryId);
|
||||
const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(categoryId);
|
||||
const isPlugin = categoryId === 'plugins';
|
||||
const isSkill = categoryId === 'skills';
|
||||
@@ -845,6 +862,7 @@ function NavItems({
|
||||
{itemIsPluginType && !item.debug && (
|
||||
<PluginItemMenu
|
||||
item={item}
|
||||
canManage={canManageResources}
|
||||
onUpdate={() => handlePluginUpdate(item)}
|
||||
onDelete={() => handlePluginDelete(item)}
|
||||
/>
|
||||
@@ -1149,7 +1167,7 @@ function NavItems({
|
||||
<span>{t('agents.groupByKindShort')}</span>
|
||||
</button>
|
||||
)}
|
||||
{isExtensionsCategory && (
|
||||
{isExtensionsCategory && canOperateRuntime && (
|
||||
<button
|
||||
type="button"
|
||||
title={t('plugins.groupByType')}
|
||||
@@ -1440,10 +1458,12 @@ function NavItems({
|
||||
// Dropdown menu for plugin sidebar sub-items (shown on hover)
|
||||
function PluginItemMenu({
|
||||
item,
|
||||
canManage,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
item: SidebarEntityItem;
|
||||
canManage: boolean;
|
||||
onUpdate: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
@@ -1454,6 +1474,8 @@ function PluginItemMenu({
|
||||
const isGithub = item.installSource === 'github';
|
||||
const hasSourceLink = isMarketplace || isGithub;
|
||||
|
||||
if (!canManage && !hasSourceLink) return null;
|
||||
|
||||
function handleViewSource() {
|
||||
const slashIdx = item.id.indexOf('/');
|
||||
const author = slashIdx >= 0 ? item.id.substring(0, slashIdx) : '';
|
||||
@@ -1494,7 +1516,7 @@ function PluginItemMenu({
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start">
|
||||
{isMarketplace && (
|
||||
{canManage && isMarketplace && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
@@ -1523,16 +1545,18 @@ function PluginItemMenu({
|
||||
<span>{t('plugins.viewSource')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-red-600 focus:text-red-600"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trash className="size-4" />
|
||||
<span>{t('plugins.delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
{canManage && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-red-600 focus:text-red-600"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trash className="size-4" />
|
||||
<span>{t('plugins.delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
@@ -1722,6 +1746,7 @@ export default function HomeSidebar({
|
||||
useState<Record<string, boolean>>(loadSectionState);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsSection, setSettingsSection] =
|
||||
useState<SettingsSection>('models');
|
||||
@@ -1765,6 +1790,19 @@ export default function HomeSidebar({
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const openWorkspaceSettings = () => openSettings('workspace');
|
||||
window.addEventListener(
|
||||
OPEN_WORKSPACE_SETTINGS_EVENT,
|
||||
openWorkspaceSettings,
|
||||
);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
OPEN_WORKSPACE_SETTINGS_EVENT,
|
||||
openWorkspaceSettings,
|
||||
);
|
||||
});
|
||||
|
||||
function handleSettingsSectionChange(section: SettingsSection) {
|
||||
setSettingsSection(section);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
@@ -1788,10 +1826,6 @@ export default function HomeSidebar({
|
||||
|
||||
useEffect(() => {
|
||||
initSelect();
|
||||
if (!localStorage.getItem('token')) {
|
||||
localStorage.setItem('token', 'test-token');
|
||||
localStorage.setItem('userEmail', 'test@example.com');
|
||||
}
|
||||
|
||||
const storedEmail = localStorage.getItem('userEmail');
|
||||
if (storedEmail) {
|
||||
@@ -1930,6 +1964,7 @@ export default function HomeSidebar({
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
clearUserInfo();
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userEmail');
|
||||
window.location.href = '/login';
|
||||
@@ -1990,6 +2025,10 @@ export default function HomeSidebar({
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
||||
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
|
||||
</div>
|
||||
|
||||
{/* Navigation items grouped by section */}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<SidebarContent ref={navigationContentRef} className="min-h-0 pb-8">
|
||||
@@ -2058,18 +2097,20 @@ export default function HomeSidebar({
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
{/* API Integration entry */}
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => openSettings('apiIntegration')}
|
||||
tooltip={t('common.apiIntegration')}
|
||||
>
|
||||
<KeyRound className="size-4 text-blue-500" />
|
||||
<span>{t('common.apiIntegration')}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
{/* API-key management is available only to authorized Workspace roles. */}
|
||||
{currentWorkspace?.permissions.includes('api_key.manage') && (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => openSettings('apiIntegration')}
|
||||
tooltip={t('common.apiIntegration')}
|
||||
>
|
||||
<KeyRound className="size-4 text-blue-500" />
|
||||
<span>{t('common.apiIntegration')}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)}
|
||||
|
||||
{/* User menu using sidebar-07 nav-user DropdownMenu pattern */}
|
||||
<SidebarMenu>
|
||||
@@ -2158,6 +2199,15 @@ export default function HomeSidebar({
|
||||
<Settings />
|
||||
{t('account.settings')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
openSettings('workspace');
|
||||
}}
|
||||
>
|
||||
<UsersRound />
|
||||
{t('workspace.settings')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
} from './types';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { PanelBody } from '../settings-dialog/panel-layout';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import type { WorkspaceSpaceBilling } from '@/app/infra/entities/workspace';
|
||||
|
||||
interface ModelsPanelProps {
|
||||
// True when this panel is the active section and the dialog is open.
|
||||
@@ -83,10 +85,13 @@ export default function ModelsPanel({
|
||||
onBlockingChange,
|
||||
}: ModelsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('provider_secret.manage') ?? false;
|
||||
|
||||
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
||||
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
|
||||
const [spaceCredits, setSpaceCredits] = useState<number | null>(null);
|
||||
const [spaceBilling, setSpaceBilling] =
|
||||
useState<WorkspaceSpaceBilling | null>(null);
|
||||
|
||||
// Expanded providers and their models
|
||||
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(
|
||||
@@ -140,7 +145,7 @@ export default function ModelsPanel({
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
loadUserInfo();
|
||||
loadWorkspaceBilling();
|
||||
loadProviders();
|
||||
loadRequesterSupportTypes();
|
||||
}
|
||||
@@ -163,16 +168,11 @@ export default function ModelsPanel({
|
||||
}
|
||||
}, [providersLoaded, providers]);
|
||||
|
||||
async function loadUserInfo() {
|
||||
async function loadWorkspaceBilling() {
|
||||
try {
|
||||
const userInfo = await httpClient.getUserInfo();
|
||||
setAccountType(userInfo.account_type);
|
||||
if (userInfo.account_type === 'space') {
|
||||
const creditsInfo = await httpClient.getSpaceCredits();
|
||||
setSpaceCredits(creditsInfo.credits);
|
||||
}
|
||||
setSpaceBilling(await httpClient.getWorkspaceSpaceBilling());
|
||||
} catch {
|
||||
setAccountType('local');
|
||||
setSpaceBilling(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,17 +270,9 @@ export default function ModelsPanel({
|
||||
|
||||
async function handleSpaceLogin() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
toast.error(t('common.error'));
|
||||
return;
|
||||
}
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(
|
||||
redirectUri,
|
||||
token,
|
||||
);
|
||||
const response = await httpClient.getSpaceBindAuthorizeUrl(redirectUri);
|
||||
window.location.href = response.authorize_url;
|
||||
} catch {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
@@ -544,13 +536,15 @@ export default function ModelsPanel({
|
||||
<ProviderCard
|
||||
key={provider.uuid}
|
||||
provider={provider}
|
||||
canManage={canManage}
|
||||
isLangBotModels={isLangBotModels}
|
||||
supportTypes={requesterSupportTypes[provider.requester]}
|
||||
isExpanded={expandedProviders.has(provider.uuid)}
|
||||
isLoading={loadingProviders.has(provider.uuid)}
|
||||
models={providerModels[provider.uuid]}
|
||||
accountType={accountType}
|
||||
spaceCredits={spaceCredits}
|
||||
isWorkspaceOwner={currentWorkspace?.membership.role === 'owner'}
|
||||
ownerSpaceBound={spaceBilling?.owner_space_bound ?? false}
|
||||
spaceCredits={spaceBilling?.credits ?? null}
|
||||
addModelPopoverOpen={addModelPopoverOpen}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
deleteConfirmOpen={deleteConfirmOpen}
|
||||
@@ -628,10 +622,12 @@ export default function ModelsPanel({
|
||||
)
|
||||
: t('models.providerCount', { count: otherProviders.length })}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('models.addProvider')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('models.addProvider')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider List */}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { userInfo } from '@/app/infra/http';
|
||||
|
||||
interface ModelItemProps {
|
||||
model: LLMModel | EmbeddingModel;
|
||||
canManage: boolean;
|
||||
modelType: ModelType;
|
||||
isLangBotModels: boolean;
|
||||
editModelPopoverOpen: string | null;
|
||||
@@ -71,6 +72,7 @@ function convertExtraArgsToArray(extraArgs?: object): ExtraArg[] {
|
||||
|
||||
export default function ModelItem({
|
||||
model,
|
||||
canManage,
|
||||
modelType,
|
||||
isLangBotModels,
|
||||
editModelPopoverOpen,
|
||||
@@ -149,7 +151,7 @@ export default function ModelItem({
|
||||
|
||||
// Check if popover should be disabled (space models when not logged in)
|
||||
const isPopoverDisabled =
|
||||
isLangBotModels && userInfo?.account_type !== 'space';
|
||||
!canManage || (isLangBotModels && userInfo?.account_type !== 'space');
|
||||
|
||||
return (
|
||||
<Popover
|
||||
@@ -193,7 +195,7 @@ export default function ModelItem({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!isLangBotModels && (
|
||||
{canManage && !isLangBotModels && (
|
||||
<Popover
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={(open) =>
|
||||
|
||||
@@ -38,12 +38,14 @@ import AddModelPopover from './AddModelPopover';
|
||||
|
||||
interface ProviderCardProps {
|
||||
provider: ModelProvider;
|
||||
canManage: boolean;
|
||||
isLangBotModels?: boolean;
|
||||
supportTypes?: string[];
|
||||
isExpanded: boolean;
|
||||
isLoading: boolean;
|
||||
models?: ProviderModels;
|
||||
accountType: 'local' | 'space';
|
||||
isWorkspaceOwner: boolean;
|
||||
ownerSpaceBound: boolean;
|
||||
spaceCredits: number | null;
|
||||
// Popover states
|
||||
addModelPopoverOpen: string | null;
|
||||
@@ -101,12 +103,14 @@ function maskApiKey(key: string): string {
|
||||
|
||||
export default function ProviderCard({
|
||||
provider,
|
||||
canManage,
|
||||
isLangBotModels = false,
|
||||
supportTypes,
|
||||
isExpanded,
|
||||
isLoading,
|
||||
models,
|
||||
accountType,
|
||||
isWorkspaceOwner,
|
||||
ownerSpaceBound,
|
||||
spaceCredits,
|
||||
addModelPopoverOpen,
|
||||
editModelPopoverOpen,
|
||||
@@ -196,7 +200,7 @@ export default function ProviderCard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2 shrink-0">
|
||||
{isLangBotModels && accountType !== 'space' && (
|
||||
{isLangBotModels && isWorkspaceOwner && !ownerSpaceBound && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -206,33 +210,41 @@ export default function ProviderCard({
|
||||
}}
|
||||
>
|
||||
<LogIn className="h-4 w-4 mr-1" />
|
||||
{t('models.loginWithSpace')}
|
||||
{t('models.ownerMustBindSpace')}
|
||||
</Button>
|
||||
)}
|
||||
{isLangBotModels &&
|
||||
accountType === 'space' &&
|
||||
spaceCredits !== null && (
|
||||
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
|
||||
<span>
|
||||
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(
|
||||
`${systemInfo.cloud_service_url}/profile?tab=billing`,
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!isLangBotModels && (
|
||||
{isLangBotModels && ownerSpaceBound && spaceCredits !== null && (
|
||||
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
|
||||
<span>
|
||||
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(
|
||||
`${systemInfo.cloud_service_url}/profile?tab=billing`,
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('models.usesOwnerSpaceBilling')}
|
||||
</span>
|
||||
)}
|
||||
{isLangBotModels && !isWorkspaceOwner && !ownerSpaceBound && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('models.ownerMustBindSpace')}
|
||||
</span>
|
||||
)}
|
||||
{canManage && !isLangBotModels && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -317,7 +329,7 @@ export default function ProviderCard({
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{!isLangBotModels && (
|
||||
{canManage && !isLangBotModels && (
|
||||
<div className="flex items-center gap-1">
|
||||
<AddModelPopover
|
||||
isOpen={
|
||||
@@ -404,6 +416,7 @@ export default function ProviderCard({
|
||||
<ModelItem
|
||||
key={model.uuid}
|
||||
model={model}
|
||||
canManage={canManage}
|
||||
modelType="llm"
|
||||
isLangBotModels={isLangBotModels}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
@@ -441,6 +454,7 @@ export default function ProviderCard({
|
||||
<ModelItem
|
||||
key={model.uuid}
|
||||
model={model}
|
||||
canManage={canManage}
|
||||
modelType="embedding"
|
||||
isLangBotModels={isLangBotModels}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
@@ -472,6 +486,7 @@ export default function ProviderCard({
|
||||
<ModelItem
|
||||
key={model.uuid}
|
||||
model={model}
|
||||
canManage={canManage}
|
||||
modelType="rerank"
|
||||
isLangBotModels={isLangBotModels}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { KeyRound, Sparkles, Settings, HardDrive } from 'lucide-react';
|
||||
import {
|
||||
HardDrive,
|
||||
KeyRound,
|
||||
Settings,
|
||||
Sparkles,
|
||||
UsersRound,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,10 +28,13 @@ import AccountSettingsPanel from '@/app/home/components/account-settings-dialog/
|
||||
import ApiIntegrationPanel from '@/app/home/components/api-integration-dialog/ApiIntegrationPanel';
|
||||
import ModelsPanel from '@/app/home/components/models-dialog/ModelsPanel';
|
||||
import StorageAnalysisPanel from '@/app/home/components/storage-analysis-dialog/StorageAnalysisPanel';
|
||||
import WorkspaceSettingsPanel from '@/app/home/components/workspace-settings/WorkspaceSettingsPanel';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
// The set of settings sections shown in the unified dialog. The string values
|
||||
// are also reused as the ?action= query param suffix so deep links keep working.
|
||||
export type SettingsSection =
|
||||
| 'workspace'
|
||||
| 'account'
|
||||
| 'apiIntegration'
|
||||
| 'models'
|
||||
@@ -35,6 +44,7 @@ export type SettingsSection =
|
||||
// (showAccountSettings, showApiIntegrationSettings, showModelSettings,
|
||||
// showStorageAnalysis) continue to resolve to the right section.
|
||||
export const SETTINGS_ACTION_BY_SECTION: Record<SettingsSection, string> = {
|
||||
workspace: 'showWorkspaceSettings',
|
||||
account: 'showAccountSettings',
|
||||
apiIntegration: 'showApiIntegrationSettings',
|
||||
models: 'showModelSettings',
|
||||
@@ -63,6 +73,7 @@ export default function SettingsDialog({
|
||||
onSectionChange,
|
||||
}: SettingsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
// A nested modal (e.g. the provider form) can request that we ignore
|
||||
// outer-close until it is dismissed.
|
||||
const [blocking, setBlocking] = useState(false);
|
||||
@@ -76,13 +87,20 @@ export default function SettingsDialog({
|
||||
}
|
||||
}, [section, open]);
|
||||
|
||||
const navItems: {
|
||||
const allNavItems: {
|
||||
id: SettingsSection;
|
||||
label: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
id: 'workspace',
|
||||
label: t('settingsDialog.nav.workspace'),
|
||||
title: t('workspace.title'),
|
||||
description: t('workspace.description'),
|
||||
icon: <UsersRound className="size-4" />,
|
||||
},
|
||||
{
|
||||
id: 'models',
|
||||
label: t('settingsDialog.nav.models'),
|
||||
@@ -112,6 +130,27 @@ export default function SettingsDialog({
|
||||
icon: <Settings className="size-4" />,
|
||||
},
|
||||
];
|
||||
const permissions = currentWorkspace?.permissions ?? [];
|
||||
const canManageApiKeys = permissions.includes('api_key.manage');
|
||||
const canViewAudit = permissions.includes('audit.view');
|
||||
const navItems = allNavItems.filter((item) => {
|
||||
if (item.id === 'apiIntegration') {
|
||||
return canManageApiKeys;
|
||||
}
|
||||
if (item.id === 'storageAnalysis') {
|
||||
return canViewAudit;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const forbiddenSection =
|
||||
(section === 'apiIntegration' && !canManageApiKeys) ||
|
||||
(section === 'storageAnalysis' && !canViewAudit);
|
||||
if (open && forbiddenSection) {
|
||||
onSectionChange('workspace');
|
||||
}
|
||||
}, [canManageApiKeys, canViewAudit, open, section, onSectionChange]);
|
||||
|
||||
const activeItem = navItems.find((item) => item.id === section);
|
||||
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
|
||||
@@ -201,6 +240,11 @@ export default function SettingsDialog({
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{section === 'workspace' && (
|
||||
<WorkspaceSettingsPanel
|
||||
active={open && section === 'workspace'}
|
||||
/>
|
||||
)}
|
||||
{section === 'models' && (
|
||||
<ModelsPanel
|
||||
active={open && section === 'models'}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@/components/ui/item';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type {
|
||||
CurrentWorkspace,
|
||||
WorkspaceInvitation,
|
||||
WorkspaceMembership,
|
||||
WorkspaceRole,
|
||||
} from '@/app/infra/entities/workspace';
|
||||
import { backendClient, systemInfo } from '@/app/infra/http';
|
||||
import {
|
||||
PanelBody,
|
||||
PanelToolbar,
|
||||
} from '@/app/home/components/settings-dialog/panel-layout';
|
||||
|
||||
interface WorkspaceSettingsPanelProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const ASSIGNABLE_ROLES: Exclude<WorkspaceRole, 'owner'>[] = [
|
||||
'admin',
|
||||
'developer',
|
||||
'operator',
|
||||
'viewer',
|
||||
];
|
||||
|
||||
export default function WorkspaceSettingsPanel({
|
||||
active,
|
||||
}: WorkspaceSettingsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [workspaceInfo, setWorkspaceInfo] = useState<CurrentWorkspace | null>(
|
||||
null,
|
||||
);
|
||||
const [members, setMembers] = useState<WorkspaceMembership[]>([]);
|
||||
const [invitations, setInvitations] = useState<WorkspaceInvitation[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] =
|
||||
useState<Exclude<WorkspaceRole, 'owner'>>('viewer');
|
||||
const [inviteLoading, setInviteLoading] = useState(false);
|
||||
const [oneTimeInviteLink, setOneTimeInviteLink] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const permissions = useMemo(
|
||||
() => new Set(workspaceInfo?.permissions ?? []),
|
||||
[workspaceInfo],
|
||||
);
|
||||
const isCloudProjection =
|
||||
workspaceInfo?.workspace.source === 'cloud_projection';
|
||||
const canViewMembers = permissions.has('member.view');
|
||||
const canInvite = permissions.has('member.invite');
|
||||
const canUpdateMembers = permissions.has('member.update_role');
|
||||
const canRemoveMembers = permissions.has('member.remove');
|
||||
const canTransferOwner = permissions.has('owner.transfer');
|
||||
const cloudPortalURL = workspaceInfo
|
||||
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
|
||||
: '';
|
||||
|
||||
const loadWorkspace = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const current = await backendClient.getCurrentWorkspace();
|
||||
setWorkspaceInfo(current);
|
||||
|
||||
const [memberResponse, invitationResponse] = await Promise.all([
|
||||
current.permissions.includes('member.view')
|
||||
? backendClient.getWorkspaceMembers(current.workspace.uuid)
|
||||
: Promise.resolve({ members: [] }),
|
||||
current.permissions.includes('member.invite')
|
||||
? backendClient.getWorkspaceInvitations(current.workspace.uuid)
|
||||
: Promise.resolve({ invitations: [] }),
|
||||
]);
|
||||
setMembers(memberResponse.members);
|
||||
setInvitations(invitationResponse.invitations);
|
||||
} catch {
|
||||
toast.error(t('workspace.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) void loadWorkspace();
|
||||
}, [active, loadWorkspace]);
|
||||
|
||||
async function createInvitation() {
|
||||
if (!workspaceInfo || !inviteEmail.trim()) return;
|
||||
setInviteLoading(true);
|
||||
try {
|
||||
const response = await backendClient.createWorkspaceInvitation(
|
||||
workspaceInfo.workspace.uuid,
|
||||
inviteEmail.trim(),
|
||||
inviteRole,
|
||||
);
|
||||
setOneTimeInviteLink(response.link);
|
||||
setInviteEmail('');
|
||||
await loadWorkspace();
|
||||
toast.success(t(`workspace.delivery.${response.delivery.status}`));
|
||||
} catch {
|
||||
toast.error(t('workspace.invitationCreateFailed'));
|
||||
} finally {
|
||||
setInviteLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInvitationLink() {
|
||||
if (!oneTimeInviteLink) return;
|
||||
await navigator.clipboard.writeText(oneTimeInviteLink);
|
||||
toast.success(t('workspace.invitationCopied'));
|
||||
}
|
||||
|
||||
async function updateMemberRole(
|
||||
member: WorkspaceMembership,
|
||||
role: WorkspaceRole,
|
||||
) {
|
||||
if (!workspaceInfo || member.role === role) return;
|
||||
try {
|
||||
await backendClient.updateWorkspaceMemberRole(
|
||||
workspaceInfo.workspace.uuid,
|
||||
member.account_uuid,
|
||||
role,
|
||||
);
|
||||
await loadWorkspace();
|
||||
toast.success(t('workspace.memberUpdated'));
|
||||
} catch {
|
||||
toast.error(t('workspace.memberUpdateFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMember(member: WorkspaceMembership) {
|
||||
if (!workspaceInfo) return;
|
||||
if (!window.confirm(t('workspace.removeMemberConfirm'))) return;
|
||||
try {
|
||||
await backendClient.removeWorkspaceMember(
|
||||
workspaceInfo.workspace.uuid,
|
||||
member.account_uuid,
|
||||
);
|
||||
await loadWorkspace();
|
||||
toast.success(t('workspace.memberRemoved'));
|
||||
} catch {
|
||||
toast.error(t('workspace.memberRemoveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeInvitation(invitation: WorkspaceInvitation) {
|
||||
if (!workspaceInfo) return;
|
||||
try {
|
||||
await backendClient.revokeWorkspaceInvitation(
|
||||
workspaceInfo.workspace.uuid,
|
||||
invitation.uuid,
|
||||
);
|
||||
await loadWorkspace();
|
||||
toast.success(t('workspace.invitationRevoked'));
|
||||
} catch {
|
||||
toast.error(t('workspace.invitationRevokeFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !workspaceInfo) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelToolbar>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{workspaceInfo?.workspace.name ?? t('workspace.title')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
isCloudProjection
|
||||
? 'workspace.cloudManagedDescription'
|
||||
: 'workspace.ossSingletonDescription',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{workspaceInfo && (
|
||||
<Badge variant="secondary">
|
||||
{t(`workspace.roles.${workspaceInfo.membership.role}`)}
|
||||
</Badge>
|
||||
)}
|
||||
{isCloudProjection && workspaceInfo && (
|
||||
<Button asChild size="sm">
|
||||
<a href={cloudPortalURL} target="_blank" rel="noopener noreferrer">
|
||||
{t('workspace.upgradePlan')}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</PanelToolbar>
|
||||
|
||||
<PanelBody className="space-y-6">
|
||||
{canInvite && (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t('workspace.inviteMember')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('workspace.inviteDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(event) => setInviteEmail(event.target.value)}
|
||||
placeholder={t('workspace.emailPlaceholder')}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={inviteRole}
|
||||
onValueChange={(value) =>
|
||||
setInviteRole(value as Exclude<WorkspaceRole, 'owner'>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ASSIGNABLE_ROLES.map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{t(`workspace.roles.${role}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
onClick={createInvitation}
|
||||
disabled={inviteLoading || !inviteEmail.trim()}
|
||||
>
|
||||
{inviteLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<UserPlus className="size-4" />
|
||||
)}
|
||||
{t('workspace.createInvitation')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oneTimeInviteLink && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3">
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{t('workspace.oneTimeLinkWarning')}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input value={oneTimeInviteLink} readOnly />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={copyInvitationLink}
|
||||
aria-label={t('workspace.copyInvitation')}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{canViewMembers && (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t('workspace.members')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{members.map((member) => {
|
||||
const isSelf =
|
||||
member.account_uuid ===
|
||||
workspaceInfo?.membership.account_uuid;
|
||||
return (
|
||||
<Item
|
||||
key={member.uuid}
|
||||
size="sm"
|
||||
variant="muted"
|
||||
className="rounded-lg"
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
<Users className="size-4" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
{member.email}
|
||||
{isSelf && (
|
||||
<Badge variant="outline">{t('workspace.you')}</Badge>
|
||||
)}
|
||||
</ItemTitle>
|
||||
<ItemDescription>
|
||||
{t(`workspace.roles.${member.role}`)}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
{canUpdateMembers && member.role !== 'owner' && (
|
||||
<Select
|
||||
value={member.role}
|
||||
onValueChange={(role) =>
|
||||
void updateMemberRole(member, role as WorkspaceRole)
|
||||
}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ASSIGNABLE_ROLES.map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{t(`workspace.roles.${role}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{canTransferOwner && (
|
||||
<SelectItem value="owner">
|
||||
{t('workspace.transferOwnership')}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{canRemoveMembers &&
|
||||
!isSelf &&
|
||||
member.role !== 'owner' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => void removeMember(member)}
|
||||
aria-label={t('workspace.removeMember')}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{canInvite && invitations.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t('workspace.pendingInvitations')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{invitations.map((invitation) => (
|
||||
<Item
|
||||
key={invitation.uuid}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-lg"
|
||||
>
|
||||
<ItemContent>
|
||||
<ItemTitle>{invitation.normalized_email}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{t(`workspace.roles.${invitation.role}`)} ·{' '}
|
||||
{t('workspace.expiresAt', {
|
||||
date: new Date(invitation.expires_at).toLocaleString(),
|
||||
})}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => void revokeInvitation(invitation)}
|
||||
aria-label={t('workspace.revokeInvitation')}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</PanelBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Building2, Check, Settings } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
switchWorkspaceAndReload,
|
||||
useCurrentWorkspace,
|
||||
useWorkspaceBootstrap,
|
||||
} from '@/app/infra/http';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const OPEN_WORKSPACE_SETTINGS_EVENT = 'langbot:open-workspace-settings';
|
||||
|
||||
export function requestWorkspaceSettings(): void {
|
||||
window.dispatchEvent(new Event(OPEN_WORKSPACE_SETTINGS_EVENT));
|
||||
}
|
||||
|
||||
export default function WorkspaceSwitcher({
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const workspaces = useWorkspaceBootstrap();
|
||||
|
||||
if (!currentWorkspace) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('h-9 min-w-0 justify-start px-2.5 text-sm', className)}
|
||||
aria-label={t('workspace.switchWorkspace')}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0" />
|
||||
<span className="truncate">{currentWorkspace.workspace.name}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64 p-1.5">
|
||||
<DropdownMenuLabel className="px-3 py-2 text-sm">
|
||||
{t('workspace.switchWorkspace')}
|
||||
</DropdownMenuLabel>
|
||||
{workspaces.map((entry) => {
|
||||
const selected =
|
||||
entry.workspace.uuid === currentWorkspace.workspace.uuid;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={entry.workspace.uuid}
|
||||
className="min-h-11 gap-2 px-2 py-1.5"
|
||||
onClick={() => {
|
||||
if (!selected)
|
||||
void switchWorkspaceAndReload(entry.workspace.uuid);
|
||||
}}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0" />
|
||||
<span className="max-w-[7rem] min-w-0 flex-1 truncate font-medium">
|
||||
{entry.workspace.name}
|
||||
</span>
|
||||
{entry.workspace.source === 'cloud_projection' && (
|
||||
<span className="rounded-md border bg-muted px-2 py-0.5 text-[11px] font-medium uppercase text-muted-foreground">
|
||||
{entry.plan_name || t('workspace.planUnavailable')}
|
||||
</span>
|
||||
)}
|
||||
{selected && <Check className="size-4" />}
|
||||
{selected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
aria-label={t('workspace.settings')}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
requestWorkspaceSettings();
|
||||
}}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -27,11 +27,15 @@ import { KnowledgeBase } from '@/app/infra/entities/api';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { toast } from 'sonner';
|
||||
import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
export default function KBDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const { refreshKnowledgeBases, knowledgeBases, setDetailEntityName } =
|
||||
useSidebarData();
|
||||
|
||||
@@ -119,18 +123,22 @@ export default function KBDetailContent({ id }: { id: string }) {
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('knowledge.createKnowledgeBase')}
|
||||
</h1>
|
||||
<Button type="submit" form="kb-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button type="submit" form="kb-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-3xl pb-8">
|
||||
<KBForm
|
||||
initKbId={undefined}
|
||||
onNewKbCreated={handleNewKbCreated}
|
||||
onKbUpdated={handleKbUpdated}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<KBForm
|
||||
initKbId={undefined}
|
||||
onNewKbCreated={handleNewKbCreated}
|
||||
onKbUpdated={handleKbUpdated}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,14 +154,16 @@ export default function KBDetailContent({ id }: { id: string }) {
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('knowledge.editKnowledgeBase')}
|
||||
</h1>
|
||||
<Button
|
||||
type="submit"
|
||||
form="kb-form"
|
||||
disabled={!formDirty}
|
||||
className={activeTab !== 'metadata' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="kb-form"
|
||||
disabled={!formDirty}
|
||||
className={activeTab !== 'metadata' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Horizontal Tabs */}
|
||||
@@ -186,45 +196,49 @@ export default function KBDetailContent({ id }: { id: string }) {
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
||||
<KBForm
|
||||
initKbId={id}
|
||||
onNewKbCreated={handleNewKbCreated}
|
||||
onKbUpdated={handleKbUpdated}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<KBForm
|
||||
initKbId={id}
|
||||
onNewKbCreated={handleNewKbCreated}
|
||||
onKbUpdated={handleKbUpdated}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{/* Danger Zone Card */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{t('knowledge.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('knowledge.dangerZoneDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('knowledge.deleteKbAction')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('knowledge.deleteKbHint')}
|
||||
</p>
|
||||
{canManage && (
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{t('knowledge.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('knowledge.dangerZoneDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('knowledge.deleteKbAction')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('knowledge.deleteKbHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-1.5" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-1.5" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -234,11 +248,13 @@ export default function KBDetailContent({ id }: { id: string }) {
|
||||
value="documents"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<KBDoc
|
||||
kbId={id}
|
||||
ragEngineName={kbInfo?.knowledge_engine?.name}
|
||||
ragEngineCapabilities={kbInfo?.knowledge_engine?.capabilities}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<KBDoc
|
||||
kbId={id}
|
||||
ragEngineName={kbInfo?.knowledge_engine?.name}
|
||||
ragEngineCapabilities={kbInfo?.knowledge_engine?.capabilities}
|
||||
/>
|
||||
</fieldset>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
|
||||
+66
-35
@@ -14,10 +14,10 @@ import {
|
||||
} from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
import {
|
||||
userInfo,
|
||||
bootstrapWorkspaceSession,
|
||||
systemInfo,
|
||||
initializeUserInfo,
|
||||
initializeSystemInfo,
|
||||
useCurrentWorkspace,
|
||||
} from '@/app/infra/http';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -83,8 +83,6 @@ function isExtensionsRoute(pathname: string): boolean {
|
||||
}
|
||||
|
||||
const HOME_CONTENT_MAX_WIDTH = 'max-w-[1360px]';
|
||||
const BACKEND_UNAVAILABLE_RETURN_TO_STORAGE_KEY =
|
||||
'langbot_backend_unavailable_return_to';
|
||||
|
||||
export default function HomeLayout({
|
||||
children,
|
||||
@@ -93,45 +91,78 @@ export default function HomeLayout({
|
||||
}>) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const [identityReady, setIdentityReady] = useState(false);
|
||||
|
||||
// Initialize user info if not already initialized
|
||||
useEffect(() => {
|
||||
if (!userInfo) {
|
||||
initializeUserInfo();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-redirect to wizard on first visit (wizard not yet completed on this instance)
|
||||
// Resolve the instance, Account, and Workspace before mounting any
|
||||
// Workspace-owned page. The second system-info read uses the now-stable
|
||||
// selector and therefore returns the selected Workspace's wizard metadata.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const checkWizard = async () => {
|
||||
try {
|
||||
// Always re-fetch to ensure we have the latest wizard_status from backend
|
||||
await initializeSystemInfo({ throwOnError: true });
|
||||
if (!cancelled && systemInfo.wizard_status === 'none') {
|
||||
navigate('/wizard', { replace: true });
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
||||
sessionStorage.setItem(
|
||||
BACKEND_UNAVAILABLE_RETURN_TO_STORAGE_KEY,
|
||||
returnTo,
|
||||
bootstrapWorkspaceSession()
|
||||
.then(async (result) => {
|
||||
if (result.status === 'selection-required') {
|
||||
const returnTo = `${location.pathname}${location.search}`;
|
||||
navigate(
|
||||
`/workspaces/select?returnTo=${encodeURIComponent(returnTo)}`,
|
||||
{ replace: true },
|
||||
);
|
||||
navigate('/backend-unavailable', {
|
||||
replace: true,
|
||||
state: { from: returnTo },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
checkWizard();
|
||||
|
||||
if (result.status === 'unavailable') {
|
||||
throw new Error('No Workspace is available for this Account');
|
||||
}
|
||||
await initializeSystemInfo({ throwOnError: true });
|
||||
return true;
|
||||
})
|
||||
.then((ready) => {
|
||||
if (!cancelled && ready) setIdentityReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) navigate('/login', { replace: true });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [location.hash, location.pathname, location.search, navigate]);
|
||||
}, [location.pathname, location.search, navigate]);
|
||||
|
||||
// A read-only member may view resources but must not enter mutation-only
|
||||
// routes. The backend remains the authoritative authorization boundary.
|
||||
useEffect(() => {
|
||||
if (!identityReady || !currentWorkspace) return;
|
||||
if (currentWorkspace.permissions.includes('resource.manage')) return;
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const createOnlyRoute =
|
||||
location.pathname === '/home/add-extension' ||
|
||||
params.get('id') === 'new' ||
|
||||
(location.pathname === '/home/skills' &&
|
||||
params.get('action') === 'create');
|
||||
if (createOnlyRoute) {
|
||||
const fallback =
|
||||
location.pathname === '/home/add-extension'
|
||||
? '/home/extensions'
|
||||
: location.pathname;
|
||||
navigate(fallback, { replace: true });
|
||||
}
|
||||
}, [
|
||||
currentWorkspace,
|
||||
identityReady,
|
||||
location.pathname,
|
||||
location.search,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
// Auto-redirect only after the Workspace bootstrap above has loaded the
|
||||
// selected Workspace's wizard state.
|
||||
useEffect(() => {
|
||||
if (!identityReady) return;
|
||||
if (systemInfo.wizard_status === 'none') {
|
||||
navigate('/wizard', { replace: true });
|
||||
}
|
||||
}, [identityReady, navigate]);
|
||||
|
||||
if (!identityReady) return <div />;
|
||||
|
||||
return (
|
||||
<SidebarDataProvider>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataCo
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
type MCPRuntimeState = 'connected' | 'connecting' | 'error';
|
||||
type MCPConnectionState =
|
||||
@@ -39,6 +40,11 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const canOperate =
|
||||
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
|
||||
const { refreshMCPServers, mcpServers, setDetailEntityName } =
|
||||
useSidebarData();
|
||||
const server = mcpServers.find((s) => s.id === id);
|
||||
@@ -212,21 +218,25 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/home/add-extension')}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => formRef.current?.testMcp()}
|
||||
disabled={mcpTesting}
|
||||
>
|
||||
{t('common.test')}
|
||||
</Button>
|
||||
{canOperate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/home/add-extension')}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => formRef.current?.testMcp()}
|
||||
disabled={mcpTesting}
|
||||
>
|
||||
{t('common.test')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="mcp-form"
|
||||
@@ -243,15 +253,17 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<MCPForm
|
||||
ref={formRef}
|
||||
initServerName={undefined}
|
||||
layout="split"
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewServerCreated={handleNewServerCreated}
|
||||
onTestingChange={setMcpTesting}
|
||||
onSaveBlockedChange={setSaveBlockedByBox}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<MCPForm
|
||||
ref={formRef}
|
||||
initServerName={undefined}
|
||||
layout="split"
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewServerCreated={handleNewServerCreated}
|
||||
onTestingChange={setMcpTesting}
|
||||
onSaveBlockedChange={setSaveBlockedByBox}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -274,6 +286,7 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
id="mcp-enable-switch"
|
||||
checked={serverEnabled}
|
||||
onCheckedChange={handleEnableToggle}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -335,41 +348,47 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => formRef.current?.testMcp()}
|
||||
disabled={mcpTesting}
|
||||
>
|
||||
{t('common.test')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="mcp-form"
|
||||
disabled={!formDirty || saveBlockedByBox}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{canOperate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => formRef.current?.testMcp()}
|
||||
disabled={mcpTesting}
|
||||
>
|
||||
{t('common.test')}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="mcp-form"
|
||||
disabled={!formDirty || saveBlockedByBox}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<MCPForm
|
||||
ref={formRef}
|
||||
initServerName={id}
|
||||
layout="split"
|
||||
sideHeader={enableControl}
|
||||
sideFooter={editActions}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewServerCreated={handleNewServerCreated}
|
||||
onDirtyChange={setFormDirty}
|
||||
onTestingChange={setMcpTesting}
|
||||
onSaveBlockedChange={setSaveBlockedByBox}
|
||||
onRuntimeInfoChange={(runtimeInfo) =>
|
||||
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
|
||||
}
|
||||
onPersistedTestComplete={handlePersistedTestComplete}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<MCPForm
|
||||
ref={formRef}
|
||||
initServerName={id}
|
||||
layout="split"
|
||||
sideHeader={enableControl}
|
||||
sideFooter={canManage ? editActions : undefined}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewServerCreated={handleNewServerCreated}
|
||||
onDirtyChange={setFormDirty}
|
||||
onTestingChange={setMcpTesting}
|
||||
onSaveBlockedChange={setSaveBlockedByBox}
|
||||
onRuntimeInfoChange={(runtimeInfo) =>
|
||||
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
|
||||
}
|
||||
onPersistedTestComplete={handlePersistedTestComplete}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||
import { useMCPStdioPolicy } from '@/app/infra/hooks/useMCPStdioPolicy';
|
||||
|
||||
function StatusDisplay({
|
||||
testing,
|
||||
@@ -435,6 +436,10 @@ const getFormSchema = (t: TFunction) =>
|
||||
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
|
||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||
.default(30),
|
||||
tool_call_timeout_sec: z
|
||||
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
|
||||
.nonnegative({ message: t('mcp.timeoutNonNegative') })
|
||||
.default(300),
|
||||
ssereadtimeout: z
|
||||
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
|
||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||
@@ -474,6 +479,7 @@ const getFormSchema = (t: TFunction) =>
|
||||
|
||||
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
|
||||
timeout: number;
|
||||
tool_call_timeout_sec: number;
|
||||
ssereadtimeout: number;
|
||||
};
|
||||
|
||||
@@ -535,6 +541,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
tool_call_timeout_sec: 300,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
...initialDraftRef.current,
|
||||
@@ -560,11 +567,15 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
hint: boxHint,
|
||||
reason: boxReason,
|
||||
} = useBoxStatus();
|
||||
const { enabled: mcpStdioEnabled } = useMCPStdioPolicy();
|
||||
// stdio mode requires the Box sandbox at runtime. If the user picks
|
||||
// stdio while Box is disabled / unreachable, the server would refuse
|
||||
// to start anyway — block creation upfront so they aren't surprised
|
||||
// by an immediate "Connection failed" on the detail page.
|
||||
const stdioBlockedByBox = watchMode === 'stdio' && !boxAvailable;
|
||||
const stdioBlockedByPolicy = watchMode === 'stdio' && !mcpStdioEnabled;
|
||||
const stdioBlockedByBox =
|
||||
watchMode === 'stdio' && mcpStdioEnabled && !boxAvailable;
|
||||
const stdioBlocked = stdioBlockedByPolicy || stdioBlockedByBox;
|
||||
|
||||
const { isDirty } = form.formState;
|
||||
useEffect(() => {
|
||||
@@ -572,8 +583,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onSaveBlockedChange?.(stdioBlockedByBox);
|
||||
}, [stdioBlockedByBox, onSaveBlockedChange]);
|
||||
onSaveBlockedChange?.(stdioBlocked);
|
||||
}, [stdioBlocked, onSaveBlockedChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onTestingChange?.(mcpTesting);
|
||||
@@ -589,10 +600,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
testMcp: () => testMcp(),
|
||||
isTesting: mcpTesting,
|
||||
}),
|
||||
// testMcp now reads everything via form.getValues(), so it does not need
|
||||
// the latest stdioArgs/extraArgs closure — but keep mcpTesting so the
|
||||
// exposed isTesting flag stays accurate.
|
||||
[mcpTesting],
|
||||
// Form values are read through form.getValues(); policy and runtime health
|
||||
// remain closure values and must refresh the imperative handler.
|
||||
[mcpTesting, mcpStdioEnabled, boxAvailable],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -609,6 +619,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
tool_call_timeout_sec: 300,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
...initialDraftRef.current,
|
||||
@@ -687,10 +698,16 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
tool_call_timeout_sec: 300,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
};
|
||||
|
||||
if (typeof server.extra_args.tool_call_timeout_sec === 'number') {
|
||||
formValues.tool_call_timeout_sec =
|
||||
server.extra_args.tool_call_timeout_sec;
|
||||
}
|
||||
|
||||
let newExtraArgs: {
|
||||
key: string;
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
@@ -747,6 +764,10 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
|
||||
// Belt-and-suspenders: even though the Save button is disabled when
|
||||
// stdio is unselectable, intercept programmatic submits too.
|
||||
if (value.mode === 'stdio' && !mcpStdioEnabled) {
|
||||
toast.error(t('mcp.stdioDisabledByPolicy'));
|
||||
return;
|
||||
}
|
||||
if (value.mode === 'stdio' && !boxAvailable) {
|
||||
toast.error(t('mcp.stdioBlockedByBoxToast'));
|
||||
return;
|
||||
@@ -770,6 +791,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
url: value.url!,
|
||||
headers,
|
||||
timeout: value.timeout,
|
||||
tool_call_timeout_sec: value.tool_call_timeout_sec,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
@@ -786,6 +808,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
command: value.command!,
|
||||
args: value.args?.map((arg) => arg.value) || [],
|
||||
env,
|
||||
tool_call_timeout_sec: value.tool_call_timeout_sec,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -814,6 +837,16 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
|
||||
try {
|
||||
const mode = form.getValues('mode');
|
||||
if (mode === 'stdio' && !mcpStdioEnabled) {
|
||||
toast.error(t('mcp.stdioDisabledByPolicy'));
|
||||
setMcpTesting(false);
|
||||
return;
|
||||
}
|
||||
if (mode === 'stdio' && !boxAvailable) {
|
||||
toast.error(t('mcp.stdioBlockedByBoxToast'));
|
||||
setMcpTesting(false);
|
||||
return;
|
||||
}
|
||||
// Read every field via form.getValues() rather than the captured
|
||||
// `stdioArgs` / `extraArgs` state. testMcp() is invoked through an
|
||||
// imperative handle (formRef.current.testMcp()) whose closure is only
|
||||
@@ -835,6 +868,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
extraArgsData = {
|
||||
url: form.getValues('url')!,
|
||||
timeout: form.getValues('timeout'),
|
||||
tool_call_timeout_sec: form.getValues('tool_call_timeout_sec'),
|
||||
headers: Object.fromEntries(
|
||||
formExtraArgs.map((arg) => [arg.key, arg.value]),
|
||||
),
|
||||
@@ -846,6 +880,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
env: Object.fromEntries(
|
||||
formExtraArgs.map((arg) => [arg.key, arg.value]),
|
||||
),
|
||||
tool_call_timeout_sec: form.getValues('tool_call_timeout_sec'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1020,13 +1055,20 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="remote">{t('mcp.remote')}</SelectItem>
|
||||
<SelectItem value="stdio" disabled={!boxAvailable}>
|
||||
<SelectItem
|
||||
value="stdio"
|
||||
disabled={!mcpStdioEnabled || !boxAvailable}
|
||||
>
|
||||
{t('mcp.local')}
|
||||
{!boxAvailable && (
|
||||
{!mcpStdioEnabled ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({t('mcp.disabledByPolicy')})
|
||||
</span>
|
||||
) : !boxAvailable ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({t('mcp.boxRequired')})
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -1035,6 +1077,14 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
? t('mcp.localModeDescription')
|
||||
: t('mcp.remoteModeDescription')}
|
||||
</FormDescription>
|
||||
{stdioBlockedByPolicy && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
{t('mcp.stdioDisabledByPolicy')}
|
||||
</div>
|
||||
)}
|
||||
{stdioBlockedByBox && (
|
||||
<BoxUnavailableNotice
|
||||
hint={boxHint}
|
||||
@@ -1047,6 +1097,30 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tool_call_timeout_sec"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.toolCallTimeout')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="300"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('mcp.toolCallTimeoutDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchMode === 'remote' && (
|
||||
<>
|
||||
<FormField
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function SystemStatusCard({
|
||||
try {
|
||||
const [plugin, box] = await Promise.all([
|
||||
httpClient.getPluginSystemStatus().catch(() => null),
|
||||
httpClient.getBoxStatus().catch(() => null),
|
||||
httpClient.getBoxRuntimeStatus().catch(() => null),
|
||||
]);
|
||||
const sessions = box?.hidden
|
||||
? []
|
||||
|
||||
@@ -23,9 +23,13 @@ import { FeedbackStatsCards } from './components/FeedbackCard';
|
||||
import { FeedbackList } from './components/FeedbackList';
|
||||
import { buildConversationTurns } from './utils/conversationTurns';
|
||||
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
function MonitoringPageContent() {
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canExport =
|
||||
currentWorkspace?.permissions.includes('data.export') ?? false;
|
||||
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
|
||||
useMonitoringFilters();
|
||||
const { data, loading, refetch } = useMonitoringData(filterState);
|
||||
@@ -154,7 +158,7 @@ function MonitoringPageContent() {
|
||||
onTimeRangeChange={setTimeRange}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExportDropdown filterState={filterState} />
|
||||
{canExport && <ExportDropdown filterState={filterState} />}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -8,6 +8,7 @@ import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-ta
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, Bug, BarChart3 } from 'lucide-react';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
export default function PipelineDetailContent({
|
||||
id,
|
||||
@@ -19,6 +20,13 @@ export default function PipelineDetailContent({
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const canOperate =
|
||||
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
|
||||
const canViewMonitoring =
|
||||
currentWorkspace?.permissions.includes('resource.view') ?? false;
|
||||
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
||||
|
||||
// Set breadcrumb entity name
|
||||
@@ -54,23 +62,27 @@ export default function PipelineDetailContent({
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('pipelines.createPipeline')}
|
||||
</h1>
|
||||
<Button type="submit" form="pipeline-form" disabled={formSaving}>
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button type="submit" form="pipeline-form" disabled={formSaving}>
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<PipelineFormComponent
|
||||
pipelineId={undefined}
|
||||
isEditMode={false}
|
||||
disableForm={false}
|
||||
showButtons={false}
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
onDeletePipeline={() => {}}
|
||||
onSavingChange={setFormSaving}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<PipelineFormComponent
|
||||
pipelineId={undefined}
|
||||
isEditMode={false}
|
||||
disableForm={!canManage}
|
||||
showButtons={false}
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
onDeletePipeline={() => {}}
|
||||
onSavingChange={setFormSaving}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,14 +100,16 @@ export default function PipelineDetailContent({
|
||||
{/* Sticky Header: title + save button */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('pipelines.editPipeline')}</h1>
|
||||
<Button
|
||||
type="submit"
|
||||
form="pipeline-form"
|
||||
disabled={!formDirty || formSaving}
|
||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="pipeline-form"
|
||||
disabled={!formDirty || formSaving}
|
||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Horizontal Tabs */}
|
||||
@@ -110,21 +124,25 @@ export default function PipelineDetailContent({
|
||||
<Settings className="size-3.5" />
|
||||
{t('pipelines.configuration')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="debug" className="gap-1.5">
|
||||
<Bug className="size-3.5" />
|
||||
{t('pipelines.debugChat')}
|
||||
{activeTab === 'debug' && (
|
||||
<span
|
||||
className={`inline-block size-2 rounded-full ${
|
||||
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="monitoring" className="gap-1.5">
|
||||
<BarChart3 className="size-3.5" />
|
||||
{t('pipelines.monitoring.title')}
|
||||
</TabsTrigger>
|
||||
{canOperate && (
|
||||
<TabsTrigger value="debug" className="gap-1.5">
|
||||
<Bug className="size-3.5" />
|
||||
{t('pipelines.debugChat')}
|
||||
{activeTab === 'debug' && (
|
||||
<span
|
||||
className={`inline-block size-2 rounded-full ${
|
||||
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canViewMonitoring && (
|
||||
<TabsTrigger value="monitoring" className="gap-1.5">
|
||||
<BarChart3 className="size-3.5" />
|
||||
{t('pipelines.monitoring.title')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{/* Tab: Configuration */}
|
||||
@@ -132,42 +150,48 @@ export default function PipelineDetailContent({
|
||||
value="config"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<PipelineFormComponent
|
||||
pipelineId={id}
|
||||
isEditMode={true}
|
||||
disableForm={false}
|
||||
showButtons={false}
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
onDeletePipeline={handleDeletePipeline}
|
||||
onCancel={() => navigate(routeBase)}
|
||||
onDirtyChange={setFormDirty}
|
||||
onSavingChange={setFormSaving}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<PipelineFormComponent
|
||||
pipelineId={id}
|
||||
isEditMode={true}
|
||||
disableForm={!canManage}
|
||||
showButtons={false}
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
onDeletePipeline={handleDeletePipeline}
|
||||
onCancel={() => navigate(routeBase)}
|
||||
onDirtyChange={setFormDirty}
|
||||
onSavingChange={setFormSaving}
|
||||
/>
|
||||
</fieldset>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Debug */}
|
||||
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
|
||||
<DebugDialog
|
||||
open={activeTab === 'debug'}
|
||||
pipelineId={id}
|
||||
isEmbedded={true}
|
||||
onConnectionStatusChange={setIsWebSocketConnected}
|
||||
/>
|
||||
</TabsContent>
|
||||
{canOperate && (
|
||||
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
|
||||
<DebugDialog
|
||||
open={activeTab === 'debug'}
|
||||
pipelineId={id}
|
||||
isEmbedded={true}
|
||||
onConnectionStatusChange={setIsWebSocketConnected}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Tab: Monitoring */}
|
||||
<TabsContent
|
||||
value="monitoring"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<PipelineMonitoringTab
|
||||
pipelineId={id}
|
||||
onNavigateToMonitoring={() => {
|
||||
navigate('/home/monitoring');
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
{canViewMonitoring && (
|
||||
<TabsContent
|
||||
value="monitoring"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<PipelineMonitoringTab
|
||||
pipelineId={id}
|
||||
onNavigateToMonitoring={() => {
|
||||
navigate('/home/monitoring');
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -152,7 +152,7 @@ function MarketPageContent({
|
||||
// Per-format extension counts shown next to the type filter options.
|
||||
const [typeCounts, setTypeCounts] = useState<Record<string, number>>({});
|
||||
const [sortOption, setSortOption] = useState<string>(
|
||||
() => loadMarketFilters().sortOption ?? 'install_count_desc',
|
||||
() => loadMarketFilters().sortOption ?? 'hot_score_desc',
|
||||
);
|
||||
|
||||
// Persist filter conditions so they survive navigation / reload.
|
||||
@@ -179,6 +179,12 @@ function MarketPageContent({
|
||||
|
||||
// 排序选项
|
||||
const sortOptions: SortOption[] = [
|
||||
{
|
||||
value: 'hot_score_desc',
|
||||
label: t('market.sort.hottest'),
|
||||
sortBy: 'hot_score',
|
||||
sortOrder: 'DESC',
|
||||
},
|
||||
{
|
||||
value: 'created_at_desc',
|
||||
label: t('market.sort.recentlyAdded'),
|
||||
@@ -241,7 +247,7 @@ function MarketPageContent({
|
||||
const option = sortOptions.find((opt) => opt.value === sortOption);
|
||||
return option
|
||||
? { sortBy: option.sortBy, sortOrder: option.sortOrder }
|
||||
: { sortBy: 'install_count', sortOrder: 'DESC' };
|
||||
: { sortBy: 'hot_score', sortOrder: 'DESC' };
|
||||
}, [sortOption]);
|
||||
|
||||
// 将API响应转换为VO对象
|
||||
@@ -263,6 +269,7 @@ function MarketPageContent({
|
||||
description:
|
||||
extractI18nObject(plugin.description) || t('market.noDescription'),
|
||||
installCount: plugin.install_count || 0,
|
||||
likeCount: plugin.like_count || 0,
|
||||
iconURL,
|
||||
githubURL: plugin.repository,
|
||||
version: plugin.latest_version,
|
||||
|
||||
@@ -40,6 +40,7 @@ function pluginToVO(
|
||||
description:
|
||||
extractI18nObject(plugin.description) || t('market.noDescription'),
|
||||
installCount: plugin.install_count,
|
||||
likeCount: plugin.like_count || 0,
|
||||
iconURL,
|
||||
githubURL: plugin.repository,
|
||||
version: plugin.latest_version,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import FingerprintJS, { type LoadOptions } from '@fingerprintjs/fingerprintjs';
|
||||
import { getCloudServiceClient } from '@/app/infra/http';
|
||||
|
||||
const MARKETPLACE_LIKE_CHANGED_EVENT = 'langbot-marketplace-like-changed';
|
||||
|
||||
export interface MarketplaceLikeChange {
|
||||
key: string;
|
||||
liked: boolean;
|
||||
likeCount: number;
|
||||
}
|
||||
|
||||
let fingerprintPromise: Promise<string> | undefined;
|
||||
let likedKeysPromise: Promise<Set<string>> | undefined;
|
||||
|
||||
export function marketplaceExtensionKey(
|
||||
type: string | undefined,
|
||||
author: string,
|
||||
name: string,
|
||||
): string {
|
||||
return `${type || 'plugin'}:${author}/${name}`;
|
||||
}
|
||||
|
||||
export function getMarketplaceFingerprint(): Promise<string> {
|
||||
if (!fingerprintPromise) {
|
||||
fingerprintPromise = FingerprintJS.load({
|
||||
monitoring: false,
|
||||
} as LoadOptions & { monitoring: boolean })
|
||||
.then((agent) => agent.get())
|
||||
.then((result) => result.visitorId)
|
||||
.catch((error) => {
|
||||
fingerprintPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return fingerprintPromise;
|
||||
}
|
||||
|
||||
async function loadLikedKeys(): Promise<Set<string>> {
|
||||
if (!likedKeysPromise) {
|
||||
likedKeysPromise = Promise.all([
|
||||
getMarketplaceFingerprint(),
|
||||
getCloudServiceClient(),
|
||||
])
|
||||
.then(([fingerprint, client]) =>
|
||||
client.getMarketplaceLikedExtensions(fingerprint),
|
||||
)
|
||||
.then((data) => {
|
||||
const keys = new Set<string>();
|
||||
for (const ref of data.extensions || []) {
|
||||
keys.add(`${ref.type}:${ref.extension_id}`);
|
||||
}
|
||||
return keys;
|
||||
})
|
||||
.catch((error) => {
|
||||
likedKeysPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return likedKeysPromise;
|
||||
}
|
||||
|
||||
export async function getMarketplaceExtensionLiked(
|
||||
type: string | undefined,
|
||||
author: string,
|
||||
name: string,
|
||||
): Promise<boolean> {
|
||||
const likedKeys = await loadLikedKeys();
|
||||
return likedKeys.has(marketplaceExtensionKey(type, author, name));
|
||||
}
|
||||
|
||||
export async function toggleMarketplaceExtensionLike(
|
||||
type: string | undefined,
|
||||
author: string,
|
||||
name: string,
|
||||
liked: boolean,
|
||||
): Promise<MarketplaceLikeChange> {
|
||||
const extensionType = type || 'plugin';
|
||||
const [fingerprint, likedKeys, client] = await Promise.all([
|
||||
getMarketplaceFingerprint(),
|
||||
loadLikedKeys(),
|
||||
getCloudServiceClient(),
|
||||
]);
|
||||
const result = await client.setMarketplaceExtensionLike(
|
||||
extensionType,
|
||||
author,
|
||||
name,
|
||||
fingerprint,
|
||||
liked,
|
||||
);
|
||||
const key = marketplaceExtensionKey(extensionType, author, name);
|
||||
if (result.liked) {
|
||||
likedKeys.add(key);
|
||||
} else {
|
||||
likedKeys.delete(key);
|
||||
}
|
||||
const change: MarketplaceLikeChange = {
|
||||
key,
|
||||
liked: result.liked,
|
||||
likeCount: result.like_count,
|
||||
};
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<MarketplaceLikeChange>(MARKETPLACE_LIKE_CHANGED_EVENT, {
|
||||
detail: change,
|
||||
}),
|
||||
);
|
||||
return change;
|
||||
}
|
||||
|
||||
export function subscribeMarketplaceLikeChanges(
|
||||
listener: (change: MarketplaceLikeChange) => void,
|
||||
): () => void {
|
||||
const handler = (event: Event) => {
|
||||
listener((event as CustomEvent<MarketplaceLikeChange>).detail);
|
||||
};
|
||||
window.addEventListener(MARKETPLACE_LIKE_CHANGED_EVENT, handler);
|
||||
return () =>
|
||||
window.removeEventListener(MARKETPLACE_LIKE_CHANGED_EVENT, handler);
|
||||
}
|
||||
+89
-2
@@ -3,7 +3,7 @@ import { useRef, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PluginComponentList from '../PluginComponentList';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Info, Package, ExternalLink } from 'lucide-react';
|
||||
import { Info, Package, ExternalLink, Heart, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
getMarketplaceExtensionLiked,
|
||||
marketplaceExtensionKey,
|
||||
subscribeMarketplaceLikeChanges,
|
||||
toggleMarketplaceExtensionLike,
|
||||
} from '../marketplace-likes';
|
||||
|
||||
export default function PluginMarketCardComponent({
|
||||
cardVO,
|
||||
@@ -25,6 +32,9 @@ export default function PluginMarketCardComponent({
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleTags, setVisibleTags] = useState(2);
|
||||
const [iconFailed, setIconFailed] = useState(!cardVO.iconURL);
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(cardVO.likeCount);
|
||||
const [isLiking, setIsLiking] = useState(false);
|
||||
|
||||
const pluginDetailUrl = `https://space.langbot.app/market/${cardVO.author}/${cardVO.pluginName}`;
|
||||
|
||||
@@ -52,6 +62,37 @@ export default function PluginMarketCardComponent({
|
||||
setIconFailed(!cardVO.iconURL);
|
||||
}, [cardVO.iconURL]);
|
||||
|
||||
useEffect(() => {
|
||||
setLikeCount(cardVO.likeCount);
|
||||
}, [cardVO.likeCount]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getMarketplaceExtensionLiked(cardVO.type, cardVO.author, cardVO.pluginName)
|
||||
.then((value) => {
|
||||
if (!cancelled) setLiked(value);
|
||||
})
|
||||
.catch(() => {
|
||||
// The count still renders if the viewer-state request is unavailable.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cardVO.author, cardVO.pluginName, cardVO.type]);
|
||||
|
||||
useEffect(() => {
|
||||
const key = marketplaceExtensionKey(
|
||||
cardVO.type,
|
||||
cardVO.author,
|
||||
cardVO.pluginName,
|
||||
);
|
||||
return subscribeMarketplaceLikeChanges((change) => {
|
||||
if (change.key !== key) return;
|
||||
setLiked(change.liked);
|
||||
setLikeCount(change.likeCount);
|
||||
});
|
||||
}, [cardVO.author, cardVO.pluginName, cardVO.type]);
|
||||
|
||||
useEffect(() => {
|
||||
const tags = cardVO.tags;
|
||||
if (!bottomRef.current || !tags || tags.length === 0) return;
|
||||
@@ -89,6 +130,29 @@ export default function PluginMarketCardComponent({
|
||||
onInstall?.(cardVO);
|
||||
};
|
||||
|
||||
const handleLikeClick = async (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (isLiking) return;
|
||||
setIsLiking(true);
|
||||
try {
|
||||
const change = await toggleMarketplaceExtensionLike(
|
||||
cardVO.type,
|
||||
cardVO.author,
|
||||
cardVO.pluginName,
|
||||
!liked,
|
||||
);
|
||||
setLiked(change.liked);
|
||||
setLikeCount(change.likeCount);
|
||||
} catch {
|
||||
toast.error(t('market.likeFailed'));
|
||||
} finally {
|
||||
setIsLiking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
@@ -97,7 +161,10 @@ export default function PluginMarketCardComponent({
|
||||
className="w-[100%] h-[10rem] cursor-pointer bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] transition-shadow duration-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)] relative"
|
||||
onClick={handleInstallClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
if (
|
||||
event.target === event.currentTarget &&
|
||||
(event.key === 'Enter' || event.key === ' ')
|
||||
) {
|
||||
event.preventDefault();
|
||||
handleInstallClick();
|
||||
}
|
||||
@@ -177,6 +244,26 @@ export default function PluginMarketCardComponent({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-start justify-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={t(liked ? 'market.unlike' : 'market.like')}
|
||||
aria-label={t(liked ? 'market.unlike' : 'market.like')}
|
||||
aria-pressed={liked}
|
||||
disabled={isLiking}
|
||||
className="h-7 min-w-7 gap-1 rounded-md px-1.5 text-muted-foreground hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-950/30"
|
||||
onClick={handleLikeClick}
|
||||
>
|
||||
{isLiking ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Heart
|
||||
className={`h-3.5 w-3.5 ${liked ? 'fill-red-500 text-red-500' : ''}`}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs tabular-nums">{likeCount}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
+3
@@ -5,6 +5,7 @@ export interface IPluginMarketCardVO {
|
||||
label: string;
|
||||
description: string;
|
||||
installCount: number;
|
||||
likeCount?: number;
|
||||
iconURL: string;
|
||||
githubURL: string;
|
||||
version: string;
|
||||
@@ -22,6 +23,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
|
||||
iconURL: string;
|
||||
githubURL: string;
|
||||
installCount: number;
|
||||
likeCount: number;
|
||||
version: string;
|
||||
components?: Record<string, number>;
|
||||
tags?: string[];
|
||||
@@ -35,6 +37,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
|
||||
this.iconURL = prop.iconURL;
|
||||
this.githubURL = prop.githubURL;
|
||||
this.installCount = prop.installCount;
|
||||
this.likeCount = prop.likeCount ?? 0;
|
||||
this.pluginId = prop.pluginId;
|
||||
this.version = prop.version;
|
||||
this.components = prop.components;
|
||||
|
||||
@@ -24,11 +24,15 @@ import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
|
||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||
import { Sparkles, Trash2 } from 'lucide-react';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
export default function SkillDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const { refreshSkills, skills, setDetailEntityName } = useSidebarData();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const skill = skills.find((item) => item.id === id);
|
||||
@@ -88,14 +92,16 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
form="skill-form"
|
||||
className="shrink-0"
|
||||
disabled={!boxAvailable}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="skill-form"
|
||||
className="shrink-0"
|
||||
disabled={!boxAvailable}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!boxAvailable && (
|
||||
@@ -105,13 +111,17 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<SkillForm
|
||||
key="new-skill"
|
||||
initSkillName={undefined}
|
||||
layout="split"
|
||||
onNewSkillCreated={(skillName) => handleImportedSkills([skillName])}
|
||||
onSkillUpdated={() => {}}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<SkillForm
|
||||
key="new-skill"
|
||||
initSkillName={undefined}
|
||||
layout="split"
|
||||
onNewSkillCreated={(skillName) =>
|
||||
handleImportedSkills([skillName])
|
||||
}
|
||||
onSkillUpdated={() => {}}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -133,16 +143,18 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
||||
{t('skills.deleteConfirmation')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Trash2 className="mr-1.5 size-4" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Trash2 className="mr-1.5 size-4" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -185,14 +197,18 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<SkillForm
|
||||
key={id}
|
||||
initSkillName={id}
|
||||
layout="split"
|
||||
sideFooter={editActions}
|
||||
onNewSkillCreated={(skillName) => handleImportedSkills([skillName])}
|
||||
onSkillUpdated={handleSkillUpdated}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<SkillForm
|
||||
key={id}
|
||||
initSkillName={id}
|
||||
layout="split"
|
||||
sideFooter={canManage ? editActions : undefined}
|
||||
onNewSkillCreated={(skillName) =>
|
||||
handleImportedSkills([skillName])
|
||||
}
|
||||
onSkillUpdated={handleSkillUpdated}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,9 +7,13 @@ import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
@@ -61,7 +65,11 @@ export default function SkillsPage() {
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" form="skill-form" disabled={!boxAvailable}>
|
||||
<Button
|
||||
type="submit"
|
||||
form="skill-form"
|
||||
disabled={!boxAvailable || !canManage}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -72,13 +80,15 @@ export default function SkillsPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">
|
||||
<SkillForm
|
||||
key="new-skill"
|
||||
initSkillName={undefined}
|
||||
layout="split"
|
||||
onNewSkillCreated={handleCreatedSkill}
|
||||
onSkillUpdated={() => {}}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<SkillForm
|
||||
key="new-skill"
|
||||
initSkillName={undefined}
|
||||
layout="split"
|
||||
onNewSkillCreated={handleCreatedSkill}
|
||||
onSkillUpdated={() => {}}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user