From ae11bce8b67f9a36cc2a8c4b95234e2dd4c0b3fb Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Fri, 15 May 2026 14:41:23 +0800 Subject: [PATCH] feat: polish extension detail pages --- src/langbot/pkg/provider/tools/loaders/mcp.py | 1 + .../provider/test_mcp_box_integration.py | 30 + .../components/home-sidebar/HomeSidebar.tsx | 28 +- web/src/app/home/mcp/MCPDetailContent.tsx | 186 ++--- .../home/mcp/components/mcp-form/MCPForm.tsx | 683 +++++++++++------- .../app/home/plugins/PluginDetailContent.tsx | 304 ++++++-- .../plugin-form/PluginForm.tsx | 99 +-- .../app/home/skills/SkillDetailContent.tsx | 141 ++-- .../components/skill-form/SkillForm.tsx | 346 ++++++--- web/src/app/home/skills/page.tsx | 21 +- web/src/i18n/locales/en-US.ts | 4 + web/src/i18n/locales/es-ES.ts | 4 + web/src/i18n/locales/ja-JP.ts | 4 + web/src/i18n/locales/ru-RU.ts | 7 +- web/src/i18n/locales/th-TH.ts | 4 + web/src/i18n/locales/vi-VN.ts | 7 +- web/src/i18n/locales/zh-Hans.ts | 4 + web/src/i18n/locales/zh-Hant.ts | 4 + 18 files changed, 1232 insertions(+), 645 deletions(-) diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index f1da1bce..31bca2e5 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -314,6 +314,7 @@ class RuntimeMCPSession: { 'name': tool.name, 'description': tool.description, + 'parameters': tool.parameters, } for tool in self.get_tools() ], diff --git a/tests/unit_tests/provider/test_mcp_box_integration.py b/tests/unit_tests/provider/test_mcp_box_integration.py index 273f0f79..df079518 100644 --- a/tests/unit_tests/provider/test_mcp_box_integration.py +++ b/tests/unit_tests/provider/test_mcp_box_integration.py @@ -513,6 +513,36 @@ class TestGetRuntimeInfoDict: assert info['status'] == 'connecting' assert 'box_session_id' not in info + def test_runtime_tools_include_parameters(self, mcp_module): + s = _make_session( + mcp_module, + { + 'name': 'test', + 'uuid': 'test-uuid', + 'mode': 'sse', + 'command': 'python', + 'args': [], + }, + ) + s.functions = [ + SimpleNamespace( + name='create-service', + description='Create a service', + parameters={ + 'type': 'object', + 'properties': { + 'project_id': {'type': 'string'}, + }, + 'required': ['project_id'], + }, + ) + ] + + info = s.get_runtime_info_dict() + + assert info['tools'][0]['parameters']['properties']['project_id']['type'] == 'string' + assert info['tools'][0]['parameters']['required'] == ['project_id'] + def test_stdio_session_includes_box_info(self, mcp_module): ap = _make_ap() ap.box_service.available = True diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 39a4754f..d759147c 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -233,6 +233,27 @@ function mcpStatusColor(item: SidebarEntityItem): string { } } +function MCPStatusIcon({ + item, + borderClass, +}: { + item: SidebarEntityItem; + borderClass: string; +}) { + return ( + + + + + ); +} + // Plugin operation type enum enum PluginOperationType { DELETE = 'DELETE', @@ -514,7 +535,7 @@ function NavItems({ }} > {item.extensionType === 'mcp' ? ( - + ) : item.extensionType === 'skill' ? ( ) : item.emoji ? ( @@ -574,7 +595,10 @@ function NavItems({ }} > {item.extensionType === 'mcp' ? ( - + ) : item.extensionType === 'skill' ? ( ) : item.emoji ? ( diff --git a/web/src/app/home/mcp/MCPDetailContent.tsx b/web/src/app/home/mcp/MCPDetailContent.tsx index f085a297..96d5c955 100644 --- a/web/src/app/home/mcp/MCPDetailContent.tsx +++ b/web/src/app/home/mcp/MCPDetailContent.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; import { Card, CardContent, @@ -23,7 +24,7 @@ import type { MCPFormHandle } from '@/app/home/mcp/components/mcp-form/MCPForm'; import { httpClient, systemInfo } from '@/app/infra/http/HttpClient'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useTranslation } from 'react-i18next'; -import { Trash2 } from 'lucide-react'; +import { Server, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; export default function MCPDetailContent({ id }: { id: string }) { @@ -32,19 +33,18 @@ export default function MCPDetailContent({ id }: { id: string }) { const { t } = useTranslation(); const { refreshMCPServers, mcpServers, setDetailEntityName } = useSidebarData(); + const server = mcpServers.find((s) => s.id === id); + const displayName = (server?.name ?? id).replace(/__/g, '/'); // Set breadcrumb entity name useEffect(() => { if (isCreateMode) { setDetailEntityName(t('mcp.createServer')); } else { - const server = mcpServers.find((s) => s.id === id); - // Convert __ back to / for display (since / is used as separator in stored names) - const displayName = (server?.name ?? id).replace(/__/g, '/'); setDetailEntityName(displayName); } return () => setDetailEntityName(null); - }, [id, isCreateMode, mcpServers, setDetailEntityName, t]); + }, [displayName, isCreateMode, setDetailEntityName, t]); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -144,10 +144,17 @@ export default function MCPDetailContent({ id }: { id: string }) { if (isCreateMode) { return (
- {/* Header */} -
-

{t('mcp.createServer')}

-
+
+
+

+ {t('mcp.createServer')} +

+ + + {t('mcp.title')} + +
+
- {/* Content */} -
-
- -
+
+
); } + const enableControl = enableLoaded && ( + + + {t('common.enable')} + + +
+ + +
+
+
+ ); + + const editActions = ( + + + + {t('mcp.dangerZone')} + + {t('mcp.dangerZoneDescription')} + + +
+
+

{t('mcp.deleteMCPAction')}

+

+ {t('mcp.deleteMCPHint')} +

+
+ +
+
+
+ ); + // ==================== Edit Mode ==================== return ( <>
- {/* Header: title + enable switch + save button */} -
-
-

{t('mcp.editServer')}

- {enableLoaded && ( -
- - -
- )} +
+
+
+

{displayName}

+ + + {t('mcp.title')} + +
-
+
- {/* Content */} -
-
- - - {/* Card: Danger Zone */} - - - - {t('mcp.dangerZone')} - - - {t('mcp.dangerZoneDescription')} - - - -
-
-

- {t('mcp.deleteMCPAction')} -

-

- {t('mcp.deleteMCPHint')} -

-
- -
-
-
-
+
+
diff --git a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx index 2051081a..127dc4db 100644 --- a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx +++ b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx @@ -1,4 +1,5 @@ import React, { + type ReactNode, useState, useEffect, useRef, @@ -6,7 +7,8 @@ import React, { useImperativeHandle, } from 'react'; import { useTranslation } from 'react-i18next'; -import { Loader2, XCircle, Trash2 } from 'lucide-react'; +import type { TFunction } from 'i18next'; +import { Braces, Loader2, Trash2, Wrench, XCircle } from 'lucide-react'; import { Resolver, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -29,6 +31,14 @@ import { } from '@/components/ui/select'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; import { httpClient } from '@/app/infra/http/HttpClient'; import { MCPServerRuntimeInfo, @@ -41,7 +51,6 @@ import { } from '@/app/infra/entities/api'; import { CustomApiError } from '@/app/infra/entities/common'; -// Status display for test / connecting / error states function StatusDisplay({ testing, runtimeInfo, @@ -49,12 +58,12 @@ function StatusDisplay({ }: { testing: boolean; runtimeInfo: MCPServerRuntimeInfo; - t: (key: string) => string; + t: TFunction; }) { if (testing) { return (
- + {t('mcp.testing')}
); @@ -63,7 +72,7 @@ function StatusDisplay({ if (runtimeInfo.status === MCPSessionStatus.CONNECTING) { return (
- + {t('mcp.connecting')}
); @@ -72,11 +81,11 @@ function StatusDisplay({ return (
- + {t('mcp.connectionFailed')}
{runtimeInfo.error_message && ( -
+
{runtimeInfo.error_message}
)} @@ -84,25 +93,181 @@ function StatusDisplay({ ); } -// Tools list component -function ToolsList({ tools }: { tools: MCPTool[] }) { +type ToolParameter = { + name: string; + type?: string; + description?: string; + required?: boolean; +}; + +function getToolParameters(parameters?: object): ToolParameter[] { + if (!parameters || typeof parameters !== 'object') return []; + + const schema = parameters as { + properties?: Record< + string, + { type?: string; description?: string; title?: string } + >; + required?: string[]; + }; + + if (schema.properties && typeof schema.properties === 'object') { + const required = new Set(schema.required ?? []); + return Object.entries(schema.properties).map(([name, parameter]) => ({ + name, + type: parameter?.type, + description: parameter?.description || parameter?.title, + required: required.has(name), + })); + } + + return Object.keys(parameters).map((name) => ({ name })); +} + +function ToolsList({ tools, t }: { tools: MCPTool[]; t: TFunction }) { return ( -
- {tools.map((tool, index) => ( -
-
{tool.name}
- {tool.description && ( -
- {tool.description} +
+ {tools.map((tool, index) => { + const parameters = getToolParameters(tool.parameters); + const visibleParameters = parameters.slice(0, 4); + const hiddenParameterCount = + parameters.length - visibleParameters.length; + + return ( +
+
+
+ +
+
+
+
+ + {tool.name} + + + #{index + 1} + +
+

+ {tool.description || t('market.noDescription')} +

+
+ +
+
+ + + {t('mcp.parameterCount', { + count: parameters.length, + })} + +
+ + {visibleParameters.length > 0 ? ( +
+ {visibleParameters.map((parameter) => ( + + + {parameter.name} + + {parameter.type && ( + + {parameter.type} + + )} + {parameter.required && ( + * + )} + + ))} + {hiddenParameterCount > 0 && ( + + +{hiddenParameterCount} + + )} +
+ ) : ( +
+ {t('mcp.noParameters')} +
+ )} +
+
- )} -
- ))} +
+ ); + })}
); } -const getFormSchema = (t: (key: string) => string) => +function RuntimePanel({ + isEditMode, + mcpTesting, + runtimeInfo, + t, +}: { + isEditMode: boolean; + mcpTesting: boolean; + runtimeInfo: MCPServerRuntimeInfo | null; + t: TFunction; +}) { + if (!isEditMode || !runtimeInfo) { + return ( +
+ {t('mcp.noToolsFound')} +
+ ); + } + + const isConnected = + !mcpTesting && runtimeInfo.status === MCPSessionStatus.CONNECTED; + const tools = runtimeInfo.tools || []; + + return ( +
+
+
+

{t('mcp.title')}

+

+ {isConnected + ? t('mcp.toolCount', { count: tools.length }) + : t('mcp.connectionFailedStatus')} +

+
+ {isConnected && ( + + {t('mcp.toolCount', { count: tools.length })} + + )} +
+ + {!isConnected && ( +
+ +
+ )} + + {isConnected && tools.length > 0 && } + + {isConnected && tools.length === 0 && ( +
+ {t('mcp.noToolsFound')} +
+ )} +
+ ); +} + +const getFormSchema = (t: TFunction) => z .object({ name: z @@ -165,9 +330,11 @@ interface MCPFormProps { onDraftChange?: (draft: MCPFormDraft) => void; onDirtyChange?: (dirty: boolean) => void; onTestingChange?: (testing: boolean) => void; + layout?: 'stacked' | 'split'; + sideHeader?: ReactNode; + sideFooter?: ReactNode; } -// Handle exposed to parent via ref export interface MCPFormHandle { testMcp: () => void; isTesting: boolean; @@ -182,6 +349,9 @@ const MCPForm = forwardRef(function MCPForm( onDraftChange, onDirtyChange, onTestingChange, + layout = 'stacked', + sideHeader, + sideFooter, }, ref, ) { @@ -205,9 +375,7 @@ const MCPForm = forwardRef(function MCPForm( }, }); - // Track whether initial data loading is complete (to avoid marking form dirty) const isInitializing = useRef(true); - const [extraArgs, setExtraArgs] = useState< { key: string; type: 'string' | 'number' | 'boolean'; value: string }[] >([]); @@ -217,21 +385,17 @@ const MCPForm = forwardRef(function MCPForm( null, ); const pollingIntervalRef = useRef(null); - const watchMode = form.watch('mode'); - // Notify parent when dirty state changes const { isDirty } = form.formState; useEffect(() => { onDirtyChange?.(isDirty); }, [isDirty, onDirtyChange]); - // Notify parent when testing state changes useEffect(() => { onTestingChange?.(mcpTesting); }, [mcpTesting, onTestingChange]); - // Expose test action and testing state to parent useImperativeHandle( ref, () => ({ @@ -241,7 +405,6 @@ const MCPForm = forwardRef(function MCPForm( [mcpTesting], ); - // Load server data useEffect(() => { isInitializing.current = true; if (isEditMode && initServerName) { @@ -288,7 +451,6 @@ const MCPForm = forwardRef(function MCPForm( return () => subscription.unsubscribe(); }, [form, isEditMode, onDraftChange, extraArgs, stdioArgs]); - // Poll for updates when runtime_info status is CONNECTING useEffect(() => { if ( !isEditMode || @@ -323,7 +485,7 @@ const MCPForm = forwardRef(function MCPForm( const server = resp.server ?? resp; const formValues: FormValues = { - name: server.name.replace(/__/g, '/'), // Convert __ back to / for display + name: server.name.replace(/__/g, '/'), mode: server.mode, url: '', command: '', @@ -379,15 +541,8 @@ const MCPForm = forwardRef(function MCPForm( setExtraArgs(newExtraArgs); setStdioArgs(newStdioArgs); - - // Use form.reset so isDirty stays false after initial load form.reset(formValues); - - if (server.runtime_info) { - setRuntimeInfo(server.runtime_info); - } else { - setRuntimeInfo(null); - } + setRuntimeInfo(server.runtime_info ?? null); } catch (error) { console.error('Failed to load server:', error); toast.error(t('mcp.loadFailed')); @@ -411,7 +566,7 @@ const MCPForm = forwardRef(function MCPForm( enable: true, extra_args: { url: value.url!, - headers: headers, + headers, timeout: value.timeout, ssereadtimeout: value.ssereadtimeout, }, @@ -423,7 +578,7 @@ const MCPForm = forwardRef(function MCPForm( enable: true, extra_args: { url: value.url!, - headers: headers, + headers, timeout: value.timeout, }, }; @@ -433,7 +588,6 @@ const MCPForm = forwardRef(function MCPForm( value.extra_args?.forEach((arg) => { env[arg.key] = String(arg.value); }); - const args = value.args?.map((arg) => arg.value) || []; serverConfig = { name: value.name, @@ -441,8 +595,8 @@ const MCPForm = forwardRef(function MCPForm( enable: true, extra_args: { command: value.command!, - args: args, - env: env, + args: value.args?.map((arg) => arg.value) || [], + env, }, }; } @@ -450,7 +604,6 @@ const MCPForm = forwardRef(function MCPForm( if (isEditMode && initServerName) { await httpClient.updateMCPServer(initServerName, serverConfig); toast.success(t('mcp.updateSuccess')); - // Reset dirty baseline to current values form.reset(form.getValues()); onFormSubmit(); } else { @@ -504,7 +657,7 @@ const MCPForm = forwardRef(function MCPForm( const { task_id } = await httpClient.testMCPServer('_', { name: form.getValues('name'), - mode: mode, + mode, enable: true, extra_args: extraArgsData, } as MCPServer); @@ -604,121 +757,108 @@ const MCPForm = forwardRef(function MCPForm( form.setValue('args', newArgs, { shouldDirty: !isInitializing.current }); }; - return ( -
- - {/* Runtime info: status + tools (edit mode only) */} - {isEditMode && runtimeInfo && ( -
-

{t('mcp.title')}

- {(mcpTesting || - runtimeInfo.status !== MCPSessionStatus.CONNECTED) && ( -
- -
- )} - - {!mcpTesting && - runtimeInfo.status === MCPSessionStatus.CONNECTED && - runtimeInfo.tools?.length > 0 && ( - <> -
- {t('mcp.toolCount', { - count: runtimeInfo.tools?.length || 0, - })} -
- - - )} -
- )} - - {/* Server configuration */} -
- {isEditMode && ( -

{t('mcp.editServer')}

+ const configSection = ( + + + + {isEditMode ? t('mcp.editServer') : t('mcp.createServer')} + + {t('mcp.extraParametersDescription')} + + + ( + + + {t('mcp.name')} + * + + + + + + )} - ( - - - {t('mcp.name')} - * - + /> + + ( + + {t('mcp.serverMode')} + + + + - - - )} - /> + + {t('mcp.http')} + {t('mcp.stdio')} + {t('mcp.sse')} + + + + + )} + /> - ( - - {t('mcp.serverMode')} - - - {t('mcp.http')} - {t('mcp.stdio')} - {t('mcp.sse')} - - - - - )} - /> + + + )} + /> - {(watchMode === 'sse' || watchMode === 'http') && ( - <> + ( + + {t('mcp.timeout')} + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + + {watchMode === 'sse' && ( ( - - {t('mcp.url')} - * - - - - - - - )} - /> - - ( - - {t('mcp.timeout')} + {t('mcp.sseTimeout')} field.onChange(Number(e.target.value))} /> @@ -727,126 +867,147 @@ const MCPForm = forwardRef(function MCPForm( )} /> + )} + + )} - {watchMode === 'sse' && ( - ( - - {t('mcp.sseTimeout')} - - - field.onChange(Number(e.target.value)) - } - /> - - - - )} - /> + {watchMode === 'stdio' && ( + <> + ( + + + {t('mcp.command')} + * + + + + + + )} - - )} + /> - {watchMode === 'stdio' && ( - <> - ( - - - {t('mcp.command')} - * - - - - - - - )} - /> + + {t('mcp.args')} +
+ {stdioArgs.map((arg, index) => ( +
+ updateStdioArg(index, e.target.value)} + /> + +
+ ))} + +
+
+ + )} - - {t('mcp.args')} -
- {stdioArgs.map((arg, index) => ( -
- updateStdioArg(index, e.target.value)} - /> - -
- ))} - -
-
- - )} - - - + + + {watchMode === 'sse' || watchMode === 'http' + ? t('mcp.headers') + : t('mcp.env')} + +
+ {extraArgs.map((arg, index) => ( +
+ updateExtraArg(index, 'key', e.target.value)} + /> + + updateExtraArg(index, 'value', e.target.value) + } + /> + +
+ ))} + -
- ))} - -
- - {t('mcp.extraParametersDescription')} - - - - + ? t('mcp.addHeader') + : t('mcp.addEnvVar')} + +
+ + {t('mcp.extraParametersDescription')} + + + + + + ); + + const runtimePanel = ( + + ); + + if (layout === 'split') { + return ( + + +
+ {sideHeader} + {configSection} + {sideFooter} +
+
+
+ {runtimePanel} +
+ + + ); + } + + return ( +
+ + {sideHeader} + {runtimePanel} + {configSection} + {sideFooter}
); diff --git a/web/src/app/home/plugins/PluginDetailContent.tsx b/web/src/app/home/plugins/PluginDetailContent.tsx index 30d1f8bb..10ba7adc 100644 --- a/web/src/app/home/plugins/PluginDetailContent.tsx +++ b/web/src/app/home/plugins/PluginDetailContent.tsx @@ -1,10 +1,34 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm'; import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme'; +import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/ui/badge'; -import { Bug } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { Plugin } from '@/app/infra/entities/plugin'; +import { extractI18nObject } from '@/i18n/I18nProvider'; +import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask'; +import { Bug, Puzzle, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; /** * Plugin detail page content. @@ -12,7 +36,11 @@ import { Bug } from 'lucide-react'; */ export default function PluginDetailContent({ id }: { id: string }) { const { t } = useTranslation(); + const navigate = useNavigate(); const { plugins, setDetailEntityName, refreshPlugins } = useSidebarData(); + const [pluginInfo, setPluginInfo] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [deleteData, setDeleteData] = useState(false); // Parse "author/name" composite key const slashIndex = id.indexOf('/'); @@ -20,6 +48,23 @@ export default function PluginDetailContent({ id }: { id: string }) { const pluginName = slashIndex >= 0 ? id.substring(slashIndex + 1) : id; const plugin = plugins.find((p) => p.id === id); + const title = + pluginInfo?.manifest.manifest.metadata.label && + extractI18nObject(pluginInfo.manifest.manifest.metadata.label) + ? extractI18nObject(pluginInfo.manifest.manifest.metadata.label) + : plugin?.name || `${pluginAuthor}/${pluginName}`; + const description = pluginInfo?.manifest.manifest.metadata.description + ? extractI18nObject(pluginInfo.manifest.manifest.metadata.description) + : plugin?.description; + + const asyncTask = useAsyncTask({ + onSuccess: () => { + toast.success(t('plugins.deleteSuccess')); + setShowDeleteConfirm(false); + void refreshPlugins(); + navigate('/home/extensions'); + }, + }); // Set breadcrumb entity name useEffect(() => { @@ -27,6 +72,18 @@ export default function PluginDetailContent({ id }: { id: string }) { return () => setDetailEntityName(null); }, [plugin, pluginAuthor, pluginName, setDetailEntityName]); + useEffect(() => { + let cancelled = false; + httpClient.getPlugin(pluginAuthor, pluginName).then((res) => { + if (!cancelled) { + setPluginInfo(res.plugin); + } + }); + return () => { + cancelled = true; + }; + }, [pluginAuthor, pluginName]); + function handleFormSubmit(timeout?: number) { if (timeout) { setTimeout(() => { @@ -37,60 +94,199 @@ export default function PluginDetailContent({ id }: { id: string }) { } } + function executeDelete() { + httpClient + .removePlugin(pluginAuthor, pluginName, deleteData) + .then((res) => { + asyncTask.startTask(res.task_id); + }) + .catch((error) => { + toast.error(t('plugins.deleteError') + error.message); + }); + } + + const sourceBadge = plugin?.debug ? ( + + + {t('plugins.debugging')} + + ) : plugin?.installSource === 'github' ? ( + + {t('plugins.fromGithub')} + + ) : plugin?.installSource === 'local' ? ( + + {t('plugins.fromLocal')} + + ) : plugin?.installSource === 'marketplace' ? ( + + {t('plugins.fromMarketplace')} + + ) : null; + + const componentBadges = pluginInfo && ( + >( + (acc, component) => { + const kind = component.manifest.manifest.kind; + acc[kind] = (acc[kind] ?? 0) + 1; + return acc; + }, + {}, + )} + showComponentName + showTitle={false} + useBadge + t={t} + /> + ); + + const dangerZone = ( + + + + {t('plugins.dangerZone')} + + {t('plugins.dangerZoneDescription')} + + +
+
+

{t('plugins.deletePlugin')}

+

+ {t('plugins.confirmDeletePlugin', { + author: pluginAuthor, + name: pluginName, + })} +

+
+ +
+
+
+ ); + return ( -
-
-

- {pluginAuthor}/{pluginName} -

- {plugin?.debug ? ( - - - {t('plugins.debugging')} - - ) : plugin?.installSource === 'github' ? ( - - {t('plugins.fromGithub')} - - ) : plugin?.installSource === 'local' ? ( - - {t('plugins.fromLocal')} - - ) : plugin?.installSource === 'marketplace' ? ( - - {t('plugins.fromMarketplace')} - - ) : null} + <> +
+
+
+

{title}

+ + + {t('market.typePlugin')} + + {sourceBadge} + {componentBadges} +
+ {description && ( +

+ {description} +

+ )} +
+ +
+
+ + {dangerZone} +
+
+
+ +
+
-
- {/* Left side - Config */} -
- -
- {/* Divider */} -
- {/* Right side - Readme */} -
- -
-
-
+ + + + {t('plugins.deleteConfirm')} + + {asyncTask.status === AsyncTaskStatus.RUNNING + ? t('plugins.deleting') + : t('plugins.confirmDeletePlugin', { + author: pluginAuthor, + name: pluginName, + })} + + + {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && ( +
+ setDeleteData(checked === true)} + /> + +
+ )} + {asyncTask.status === AsyncTaskStatus.ERROR && ( +
{asyncTask.error}
+ )} + + {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && ( + + )} + {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && ( + + )} + {asyncTask.status === AsyncTaskStatus.RUNNING && ( + + )} + {asyncTask.status === AsyncTaskStatus.ERROR && ( + + )} + +
+
+ ); } diff --git a/web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx b/web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx index 0d5eaed2..64248b1b 100644 --- a/web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/plugin-form/PluginForm.tsx @@ -4,10 +4,16 @@ import { Plugin } from '@/app/infra/entities/plugin'; import { httpClient } from '@/app/infra/http/HttpClient'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; import { toast } from 'sonner'; -import { extractI18nObject } from '@/i18n/I18nProvider'; import { useTranslation } from 'react-i18next'; -import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList'; export default function PluginForm({ pluginAuthor, @@ -137,65 +143,34 @@ export default function PluginForm({ } return ( -
-
-
- {extractI18nObject(pluginInfo.manifest.manifest.metadata.label)} -
-
- {extractI18nObject( - pluginInfo.manifest.manifest.metadata.description ?? { - en_US: '', - zh_Hans: '', - }, +
+ + + {t('plugins.pluginConfig')} + {t('plugins.saveConfig')} + + + {pluginInfo.manifest.manifest.spec.config.length > 0 ? ( + } + onSubmit={(values) => { + // 只保存表单值的引用,不触发状态更新 + currentFormValues.current = values; + }} + onFileUploaded={(fileKey) => { + // 追踪上传的文件 + uploadedFileKeys.current.add(fileKey); + }} + /> + ) : ( +
+ {t('plugins.pluginNoConfig')} +
)} -
- -
- { - const componentKindCount: Record = {}; - for (const component of pluginInfo.components) { - const kind = component.manifest.manifest.kind; - if (componentKindCount[kind]) { - componentKindCount[kind]++; - } else { - componentKindCount[kind] = 1; - } - } - return componentKindCount; - })()} - showComponentName={true} - showTitle={false} - useBadge={true} - t={t} - /> -
- + {pluginInfo.manifest.manifest.spec.config.length > 0 && ( - } - onSubmit={(values) => { - // 只保存表单值的引用,不触发状态更新 - currentFormValues.current = values; - }} - onFileUploaded={(fileKey) => { - // 追踪上传的文件 - uploadedFileKeys.current.add(fileKey); - }} - /> - )} - {pluginInfo.manifest.manifest.spec.config.length === 0 && ( -
- {t('plugins.pluginNoConfig')} -
- )} -
- - {pluginInfo.manifest.manifest.spec.config.length > 0 && ( -
-
+ -
-
- )} + + )} +
); } diff --git a/web/src/app/home/skills/SkillDetailContent.tsx b/web/src/app/home/skills/SkillDetailContent.tsx index 3284d967..c8c4d386 100644 --- a/web/src/app/home/skills/SkillDetailContent.tsx +++ b/web/src/app/home/skills/SkillDetailContent.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { Card, CardContent, @@ -20,6 +21,7 @@ import { import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { httpClient } from '@/app/infra/http/HttpClient'; import SkillForm from '@/app/home/skills/components/skill-form/SkillForm'; +import { Sparkles, Trash2 } from 'lucide-react'; export default function SkillDetailContent({ id }: { id: string }) { const isCreateMode = id === 'new'; @@ -27,16 +29,16 @@ export default function SkillDetailContent({ id }: { id: string }) { const { t } = useTranslation(); const { refreshSkills, skills, setDetailEntityName } = useSidebarData(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const skill = skills.find((item) => item.id === id); useEffect(() => { if (isCreateMode) { setDetailEntityName(t('skills.createSkill')); } else { - const skill = skills.find((item) => item.id === id); setDetailEntityName(skill?.name ?? id); } return () => setDetailEntityName(null); - }, [id, isCreateMode, setDetailEntityName, skills, t]); + }, [id, isCreateMode, setDetailEntityName, skill, t]); function handleImportedSkills(skillNames: string[]) { void refreshSkills(); @@ -67,78 +69,101 @@ export default function SkillDetailContent({ id }: { id: string }) { if (isCreateMode) { return (
-
-

{t('skills.createSkill')}

-
-
-
- - handleImportedSkills([skillName]) - } - onSkillUpdated={() => {}} - /> -
+
+ handleImportedSkills([skillName])} + onSkillUpdated={() => {}} + />
); } + const editActions = ( + + + + {t('skills.dangerZone')} + + {t('skills.dangerZoneDescription')} + + +
+
+

{t('skills.delete')}

+

+ {t('skills.deleteConfirmation')} +

+
+ +
+
+
+ ); + return ( <>
-
-

{t('skills.editSkill')}

-
-
-
- - handleImportedSkills([skillName]) - } - onSkillUpdated={handleSkillUpdated} - /> - - - - - {t('skills.dangerZone')} - - - {t('skills.dangerZoneDescription')} - - - -
-
-

{t('common.delete')}

-

- {t('skills.deleteConfirmation')} -

-
- -
-
-
-
+
+ handleImportedSkills([skillName])} + onSkillUpdated={handleSkillUpdated} + />
diff --git a/web/src/app/home/skills/components/skill-form/SkillForm.tsx b/web/src/app/home/skills/components/skill-form/SkillForm.tsx index 199b4bca..66d44e69 100644 --- a/web/src/app/home/skills/components/skill-form/SkillForm.tsx +++ b/web/src/app/home/skills/components/skill-form/SkillForm.tsx @@ -1,10 +1,28 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { + type FormEvent, + type ReactNode, + useCallback, + useEffect, + forwardRef, + useImperativeHandle, + useRef, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; -import { FolderSearch, ChevronDown, ChevronRight, File, Folder, FolderOpen, RefreshCw } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + FolderSearch, + ChevronDown, + ChevronRight, + File, + Folder, + FolderOpen, + RefreshCw, +} from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { Skill } from '@/app/infra/entities/api'; import { toast } from 'sonner'; @@ -15,6 +33,8 @@ interface SkillFormProps { onNewSkillCreated: (skillName: string) => void; onSkillUpdated: (skillName: string) => void; onDraftChange?: (draft: SkillFormDraft) => void; + layout?: 'stacked' | 'split'; + sideFooter?: ReactNode; } export interface SkillFormDraft { @@ -30,27 +50,42 @@ interface FileEntry { size: number | null; } -interface DirectoryContent { - path: string; - entries: FileEntry[]; +interface FileTreeProps { + skillName: string; + selectedFile?: string | null; + onFileSelect: (path: string, content: string) => void; + onLoadingChange?: (loading: boolean) => void; +} + +export interface FileTreeHandle { + refresh: () => void; loading: boolean; } -interface FileTreeProps { - skillName: string; - onFileSelect: (path: string, content: string) => void; +function getFileName(path: string) { + return path.split('/').pop() || path; } -function FileTree({ skillName, onFileSelect }: FileTreeProps) { +const FileTree = forwardRef(function FileTree( + { skillName, selectedFile, onFileSelect, onLoadingChange }, + ref, +) { const { t } = useTranslation(); const [rootEntries, setRootEntries] = useState([]); const [expandedDirs, setExpandedDirs] = useState>(new Set()); - const [dirContents, setDirContents] = useState>(new Map()); + const [dirContents, setDirContents] = useState>( + new Map(), + ); const [loading, setLoading] = useState(false); const [selectedPath, setSelectedPath] = useState(null); + useEffect(() => { + setSelectedPath(selectedFile ?? null); + }, [selectedFile]); + const loadRootFiles = useCallback(async () => { setLoading(true); + onLoadingChange?.(true); try { const result = await httpClient.listSkillFiles(skillName, '.'); setRootEntries(result.entries); @@ -59,27 +94,31 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) { toast.error(t('skills.loadFilesError') + String(error)); } finally { setLoading(false); + onLoadingChange?.(false); } - }, [skillName, t]); + }, [skillName, t, onLoadingChange]); - const loadDirFiles = useCallback(async (dirPath: string) => { - setDirContents(prev => { - const newMap = new Map(prev); - newMap.set(dirPath, []); // Clear while loading - return newMap; - }); - try { - const result = await httpClient.listSkillFiles(skillName, dirPath); - setDirContents(prev => { + const loadDirFiles = useCallback( + async (dirPath: string) => { + setDirContents((prev) => { const newMap = new Map(prev); - newMap.set(dirPath, result.entries); + newMap.set(dirPath, []); // Clear while loading return newMap; }); - } catch (error) { - console.error('Failed to load directory files:', error); - toast.error(t('skills.loadFilesError') + String(error)); - } - }, [skillName, t]); + try { + const result = await httpClient.listSkillFiles(skillName, dirPath); + setDirContents((prev) => { + const newMap = new Map(prev); + newMap.set(dirPath, result.entries); + return newMap; + }); + } catch (error) { + console.error('Failed to load directory files:', error); + toast.error(t('skills.loadFilesError') + String(error)); + } + }, + [skillName, t], + ); useEffect(() => { if (skillName) { @@ -87,6 +126,15 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) { } }, [skillName, loadRootFiles]); + useImperativeHandle( + ref, + () => ({ + refresh: loadRootFiles, + loading, + }), + [loadRootFiles, loading], + ); + const toggleDir = async (dirPath: string) => { const newExpanded = new Set(expandedDirs); if (newExpanded.has(dirPath)) { @@ -110,7 +158,10 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) { } }; - const renderEntry = (entry: FileEntry, depth: number = 0): React.ReactNode => { + const renderEntry = ( + entry: FileEntry, + depth: number = 0, + ): React.ReactNode => { const isExpanded = expandedDirs.has(entry.path); const isSelected = selectedPath === entry.path; @@ -121,7 +172,9 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) { isSelected ? 'bg-muted' : '' }`} style={{ paddingLeft: `${depth * 12 + 8}px` }} - onClick={() => entry.is_dir ? toggleDir(entry.path) : handleFileClick(entry.path)} + onClick={() => + entry.is_dir ? toggleDir(entry.path) : handleFileClick(entry.path) + } > {entry.is_dir ? ( <> @@ -141,14 +194,18 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) { )} {entry.name} {!entry.is_dir && entry.size !== null && ( - - {entry.size > 1024 ? `${Math.round(entry.size / 1024)}KB` : `${entry.size}B`} + + {entry.size > 1024 + ? `${Math.round(entry.size / 1024)}KB` + : `${entry.size}B`} )}
{entry.is_dir && isExpanded && (
- {(dirContents.get(entry.path) || []).map((child) => renderEntry(child, depth + 1))} + {(dirContents.get(entry.path) || []).map((child) => + renderEntry(child, depth + 1), + )}
)}
@@ -156,19 +213,8 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) { }; return ( -
-
- {t('skills.files')} - -
-
+
+
{rootEntries.length === 0 && !loading && (
{t('skills.noFiles')} @@ -178,7 +224,7 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) {
); -} +}); const emptySkillDraft: SkillFormDraft = { skill: { @@ -197,6 +243,8 @@ export default function SkillForm({ onNewSkillCreated, onSkillUpdated, onDraftChange, + layout = 'stacked', + sideFooter, }: SkillFormProps) { const { t } = useTranslation(); const initialDraftRef = useRef(initialDraft ?? emptySkillDraft); @@ -209,12 +257,15 @@ export default function SkillForm({ ); const [selectedFile, setSelectedFile] = useState(null); const [fileContent, setFileContent] = useState(''); + const fileTreeRef = useRef(null); + const [fileTreeLoading, setFileTreeLoading] = useState(false); const loadSkill = useCallback( async (skillName: string) => { try { const resp = await httpClient.getSkill(skillName); setSkill(resp.skill); + setSelectedFile('SKILL.md'); setFileContent(resp.skill.instructions || ''); } catch (error) { console.error('Failed to load skill:', error); @@ -229,13 +280,18 @@ export default function SkillForm({ loadSkill(initSkillName); return; } + setSelectedFile(initialDraftRef.current.selectedFile ?? null); setSkill(initialDraftRef.current.skill); setShowAdvanced(initialDraftRef.current.showAdvanced); }, [initSkillName, loadSkill]); useEffect(() => { if (initSkillName) return; - onDraftChange?.({ skill, showAdvanced, selectedFile: selectedFile || undefined }); + onDraftChange?.({ + skill, + showAdvanced, + selectedFile: selectedFile || undefined, + }); }, [initSkillName, onDraftChange, skill, showAdvanced, selectedFile]); async function scanDirectory() { @@ -294,7 +350,7 @@ export default function SkillForm({ } }; - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); if (!skill.name?.trim()) { @@ -335,8 +391,8 @@ export default function SkillForm({ } }; - return ( -
+ const metadataFields = ( + <>
+ + ); - {/* File tree for existing skills */} + const fileTreeSection = ( + <> {initSkillName && (
- +
)} + + ); -
+ const instructionEditor = (showLabel = true) => ( +
+ {showLabel && ( -