From 3ee77363614a6a34210d5ea3add00bbc5ca6eaff Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 4 Nov 2025 17:09:28 +0800 Subject: [PATCH] perf: ui --- .../app/home/plugins/mcp-server/MCPCardVO.ts | 22 +- .../plugins/mcp-server/MCPServerComponent.tsx | 21 +- .../mcp-server/mcp-card/MCPCardComponent.tsx | 110 ++-- .../mcp-server/mcp-form/MCPFormDialog.tsx | 530 +++++++----------- web/src/app/home/plugins/page.tsx | 12 - 5 files changed, 253 insertions(+), 442 deletions(-) diff --git a/web/src/app/home/plugins/mcp-server/MCPCardVO.ts b/web/src/app/home/plugins/mcp-server/MCPCardVO.ts index 48c9aa62..3139f2fc 100644 --- a/web/src/app/home/plugins/mcp-server/MCPCardVO.ts +++ b/web/src/app/home/plugins/mcp-server/MCPCardVO.ts @@ -1,4 +1,4 @@ -import { MCPServer, MCPServerConfig } from '@/app/infra/entities/api'; +import { MCPServer } from '@/app/infra/entities/api'; export class MCPCardVO { name: string; @@ -7,20 +7,24 @@ export class MCPCardVO { status: 'connected' | 'disconnected' | 'error' | 'disabled'; tools: number; error?: string; - config: MCPServerConfig; constructor(data: MCPServer) { this.name = data.name; this.mode = data.mode; this.enable = data.enable; - this.status = - (data.status as string) === 'enabled' ? 'connected' : data.status; - this.tools = Array.isArray(data.tools) - ? data.tools.length - : data.tools || 0; - this.error = data.error; - this.config = data.config; + // Determine status from runtime_info + if (!data.runtime_info) { + this.status = 'disconnected'; + this.tools = 0; + } else if (data.runtime_info.connected) { + this.status = 'connected'; + this.tools = data.runtime_info.tool_count || 0; + } else { + this.status = 'error'; + this.tools = 0; + this.error = data.runtime_info.error_message; + } } getStatusColor(): string { diff --git a/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx b/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx index 0a0b85a6..84e90715 100644 --- a/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx +++ b/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx @@ -9,41 +9,24 @@ import { httpClient } from '@/app/infra/http/HttpClient'; export default function MCPComponent({ onEditServer, - toolsCountCache = {}, }: { askInstallServer?: (githubURL: string) => void; onEditServer?: (serverName: string) => void; - toolsCountCache?: Record; }) { const { t } = useTranslation(); const [installedServers, setInstalledServers] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { - initData(); + fetchInstalledServers(); }, []); - useEffect(() => { - fetchInstalledServers(); - }, [toolsCountCache]); - - function initData() { - fetchInstalledServers(); - } - function fetchInstalledServers() { setLoading(true); httpClient .getMCPServers() .then((resp) => { - const servers = resp.servers.map((server) => { - const vo = new MCPCardVO(server); - - if (toolsCountCache[server.name] !== undefined) { - vo.tools = toolsCountCache[server.name]; - } - return vo; - }); + const servers = resp.servers.map((server) => new MCPCardVO(server)); setInstalledServers(servers); setLoading(false); }) diff --git a/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx b/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx index b83b83ed..3f933c2a 100644 --- a/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx +++ b/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx @@ -1,12 +1,11 @@ import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO'; import { useState, useEffect } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; -import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; import { Button } from '@/components/ui/button'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; -import { RefreshCcw, Wrench } from 'lucide-react'; +import { RefreshCcw, Wrench, Ban, AlertCircle } from 'lucide-react'; export default function MCPCardComponent({ cardVO, @@ -23,19 +22,12 @@ export default function MCPCardComponent({ const [testing, setTesting] = useState(false); const [toolsCount, setToolsCount] = useState(cardVO.tools); const [status, setStatus] = useState(cardVO.status); - const [error, setError] = useState(cardVO.error); useEffect(() => { - console.log(`[MCPCard ${cardVO.name}] Status updated:`, { - status: cardVO.status, - tools: cardVO.tools, - error: cardVO.error, - }); setStatus(cardVO.status); - setError(cardVO.error); setToolsCount(cardVO.tools); setEnabled(cardVO.enable); - }, [cardVO.name, cardVO.status, cardVO.error, cardVO.tools, cardVO.enable]); + }, [cardVO.status, cardVO.tools, cardVO.enable]); function handleEnable(checked: boolean) { setSwitchEnable(false); @@ -54,65 +46,28 @@ export default function MCPCardComponent({ } function handleTest(e: React.MouseEvent) { - e.stopPropagation(); // 阻止事件冒泡 + e.stopPropagation(); setTesting(true); + httpClient .testMCPServer(cardVO.name) .then((resp) => { const taskId = resp.task_id; - // 监控任务状态 + const interval = setInterval(() => { httpClient.getAsyncTask(taskId).then((taskResp) => { if (taskResp.runtime.done) { clearInterval(interval); + setTesting(false); + if (taskResp.runtime.exception) { toast.error(t('mcp.testFailed') + taskResp.runtime.exception); } else { - // 解析测试结果获取工具数量 - try { - const rawResult = taskResp.runtime.result as - | string - | { - status?: string; - tools_count?: number; - tools_names_lists?: string[]; - error?: string; - } - | undefined; - - if (rawResult) { - let result: { - status?: string; - tools_count?: number; - tools_names_lists?: string[]; - error?: string; - }; - - if (typeof rawResult === 'string') { - result = JSON.parse(rawResult.replace(/'/g, '"')); - } else { - result = rawResult; - } - - if (result.tools_count !== undefined) { - setToolsCount(result.tools_count); - toast.success( - t('mcp.testSuccess') + - ` - ${result.tools_count} ${t('mcp.toolsFound')}`, - ); - } else { - toast.success(t('mcp.testSuccess')); - } - } else { - toast.success(t('mcp.testSuccess')); - } - } catch (parseError) { - console.error('Failed to parse test result:', parseError); - toast.success(t('mcp.testSuccess')); - } - onRefresh(); + toast.success(t('mcp.testSuccess')); } - setTesting(false); + + // Refresh to get updated runtime_info + onRefresh(); } }); }, 1000); @@ -141,28 +96,37 @@ export default function MCPCardComponent({
-
-
-
- {cardVO.name} -
-
+
+ {cardVO.name}
- - {error && ( -
- {error} -
- )}
-
- -
- {t('mcp.toolCount', { count: toolsCount })} + {!enabled ? ( + // 未启用 - 橙色 +
+ +
+ {t('mcp.statusDisabled')} +
-
+ ) : status === 'connected' ? ( + // 连接成功 - 显示工具数量 +
+ +
+ {t('mcp.toolCount', { count: toolsCount })} +
+
+ ) : ( + // 连接失败 - 红色 +
+ +
+ {t('mcp.connectionFailed')} +
+
+ )}
diff --git a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx index 599ada5f..8fe62e83 100644 --- a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx +++ b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx @@ -13,6 +13,12 @@ import { DialogTitle, DialogFooter, } from '@/components/ui/dialog'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, +} from '@/components/ui/card'; import { Form, FormControl, @@ -32,6 +38,98 @@ import { import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { httpClient } from '@/app/infra/http/HttpClient'; +import { + MCPServerRuntimeInfo, + MCPTool, + MCPServer, +} from '@/app/infra/entities/api'; + +// Status Display Component - 只在测试中或连接失败时使用 +function StatusDisplay({ + testing, + runtimeInfo, + t, +}: { + testing: boolean; + runtimeInfo: MCPServerRuntimeInfo; + t: (key: string) => string; +}) { + if (testing) { + return ( +
+ + + + + {t('mcp.testing')} +
+ ); + } + + // 连接失败 + return ( +
+
+ + + + {t('mcp.connectionFailed')} +
+ {runtimeInfo.error_message && ( +
+ {runtimeInfo.error_message} +
+ )} +
+ ); +} + +// Tools List Component +function ToolsList({ tools }: { tools: MCPTool[] }) { + return ( +
+ {tools.map((tool, index) => ( + + + {tool.name} + {tool.description && ( + + {tool.description} + + )} + + + ))} +
+ ); +} const getFormSchema = (t: (key: string) => string) => z.object({ @@ -72,7 +170,6 @@ interface MCPFormDialogProps { isEditMode?: boolean; onSuccess?: () => void; onDelete?: () => void; - onUpdateToolsCache?: (serverName: string, toolsCount: number) => void; } export default function MCPFormDialog({ @@ -82,7 +179,6 @@ export default function MCPFormDialog({ isEditMode = false, onSuccess, onDelete, - onUpdateToolsCache, }: MCPFormDialogProps) { const { t } = useTranslation(); const formSchema = getFormSchema(t); @@ -102,11 +198,9 @@ export default function MCPFormDialog({ { key: string; type: 'string' | 'number' | 'boolean'; value: string }[] >([]); const [mcpTesting, setMcpTesting] = useState(false); - const [mcpTestStatus, setMcpTestStatus] = useState< - 'idle' | 'testing' | 'success' | 'failed' - >('idle'); - const [mcpToolNames, setMcpToolNames] = useState([]); - const [mcpTestError, setMcpTestError] = useState(''); + const [runtimeInfo, setRuntimeInfo] = useState( + null, + ); // Load server data when editing useEffect(() => { @@ -116,9 +210,7 @@ export default function MCPFormDialog({ // Reset form when creating new server form.reset(); setExtraArgs([]); - setMcpTestStatus('idle'); - setMcpToolNames([]); - setMcpTestError(''); + setRuntimeInfo(null); } }, [open, isEditMode, serverName]); @@ -145,88 +237,11 @@ export default function MCPFormDialog({ form.setValue('extra_args', headers); } - setMcpTestStatus('testing'); - setMcpToolNames([]); - setMcpTestError(''); - - try { - const res = await httpClient.testMCPServer(server.name); - if (res.task_id) { - const taskId = res.task_id; - - const interval = setInterval(() => { - httpClient - .getAsyncTask(taskId) - .then((taskResp) => { - if (taskResp.runtime && taskResp.runtime.done) { - clearInterval(interval); - - if (taskResp.runtime.exception) { - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError(taskResp.runtime.exception || '未知错误'); - } else if (taskResp.runtime.result) { - try { - let result: { - status?: string; - tools_count?: number; - tools_names_lists?: string[]; - error?: string; - }; - - const rawResult: unknown = taskResp.runtime.result; - if (typeof rawResult === 'string') { - result = JSON.parse(rawResult.replace(/'/g, '"')); - } else { - result = rawResult as typeof result; - } - - if ( - result.tools_names_lists && - result.tools_names_lists.length > 0 - ) { - setMcpTestStatus('success'); - setMcpToolNames(result.tools_names_lists); - // Update tools cache - if (onUpdateToolsCache && serverName) { - onUpdateToolsCache( - serverName, - result.tools_names_lists.length, - ); - } - } else { - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('未找到任何工具'); - } - } catch (parseError) { - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('解析测试结果失败'); - } - } else { - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('测试未返回结果'); - } - } - }) - .catch((err) => { - clearInterval(interval); - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError(err.message || '获取任务状态失败'); - }); - }, 1000); - } else { - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('未获取到任务ID'); - } - } catch (error) { - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError((error as Error).message || '测试连接时发生错误'); + // Set runtime_info from server data + if (server.runtime_info) { + setRuntimeInfo(server.runtime_info); + } else { + setRuntimeInfo(null); } } catch (error) { console.error('Failed to load server:', error); @@ -235,167 +250,103 @@ export default function MCPFormDialog({ } async function handleFormSubmit(value: z.infer) { - const extraArgsObj: Record = {}; - value.extra_args?.forEach( - (arg: { key: string; type: string; value: string }) => { - if (arg.type === 'number') { - extraArgsObj[arg.key] = Number(arg.value); - } else if (arg.type === 'boolean') { - extraArgsObj[arg.key] = arg.value === 'true'; - } else { - extraArgsObj[arg.key] = arg.value; - } - }, - ); + // Convert extra_args to headers - all values must be strings according to MCPServerExtraArgsSSE + const headers: Record = {}; + value.extra_args?.forEach((arg) => { + // Convert all values to strings to match MCPServerExtraArgsSSE.headers type + headers[arg.key] = String(arg.value); + }); try { - const serverConfig = { + const serverConfig: Omit< + MCPServer, + 'uuid' | 'created_at' | 'updated_at' | 'runtime_info' + > = { name: value.name, mode: 'sse' as const, enable: true, - url: value.url, - headers: extraArgsObj as Record, - timeout: value.timeout, - ssereadtimeout: value.ssereadtimeout, + extra_args: { + url: value.url, + headers: headers, + timeout: value.timeout, + ssereadtimeout: value.ssereadtimeout, + }, }; if (isEditMode && serverName) { await httpClient.updateMCPServer(serverName, serverConfig); toast.success(t('mcp.updateSuccess')); } else { - await httpClient.createMCPServer({ - extra_args: { - url: value.url, - headers: extraArgsObj as Record, - timeout: value.timeout, - ssereadtimeout: value.ssereadtimeout, - }, - name: value.name, - mode: 'sse' as const, - enable: true, - }); + await httpClient.createMCPServer(serverConfig); toast.success(t('mcp.createSuccess')); } - onOpenChange(false); - form.reset(); - setExtraArgs([]); - - if (onSuccess) { - onSuccess(); - } + handleDialogClose(false); + onSuccess?.(); } catch (error) { console.error('Failed to save MCP server:', error); toast.error(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')); } } - function testMcp() { + async function testMcp() { + const serverName = form.getValues('name'); setMcpTesting(true); - const extraArgsObj: Record = {}; - form - .getValues('extra_args') - ?.forEach((arg: { key: string; type: string; value: string }) => { - if (arg.type === 'number') { - extraArgsObj[arg.key] = Number(arg.value); - } else if (arg.type === 'boolean') { - extraArgsObj[arg.key] = arg.value === 'true'; - } else { - extraArgsObj[arg.key] = arg.value; - } - }); - httpClient - .testMCPServer(form.getValues('name')) - .then((res) => { - if (res.task_id) { - const taskId = res.task_id; - const interval = setInterval(() => { - httpClient - .getAsyncTask(taskId) - .then((taskResp) => { - if (taskResp.runtime && taskResp.runtime.done) { - clearInterval(interval); - setMcpTesting(false); + try { + const { task_id } = await httpClient.testMCPServer(serverName); + if (!task_id) { + throw new Error(t('mcp.noTaskId')); + } - if (taskResp.runtime.exception) { - toast.error( - t('mcp.testError') + - ': ' + - (taskResp.runtime.exception || t('mcp.unknownError')), - ); - } else if (taskResp.runtime.result) { - try { - let result: { - status?: string; - tools_count?: number; - tools_names_lists?: string[]; - error?: string; - }; + const interval = setInterval(async () => { + try { + const taskResp = await httpClient.getAsyncTask(task_id); - const rawResult: unknown = taskResp.runtime.result; - if (typeof rawResult === 'string') { - result = JSON.parse(rawResult.replace(/'/g, '"')); - } else { - result = rawResult as typeof result; - } + if (taskResp.runtime?.done) { + clearInterval(interval); + setMcpTesting(false); - if ( - result.tools_names_lists && - result.tools_names_lists.length > 0 - ) { - toast.success( - t('mcp.testSuccess') + - ' - ' + - result.tools_names_lists.length + - ' ' + - t('mcp.toolsFound'), - ); - } else { - toast.error( - t('mcp.testError') + ': ' + t('mcp.noToolsFound'), - ); - } - } catch (parseError) { - console.error('Failed to parse test result:', parseError); - toast.error( - t('mcp.testError') + ': ' + t('mcp.parseResultFailed'), - ); - } - } else { - toast.error( - t('mcp.testError') + ': ' + t('mcp.noResultReturned'), - ); - } - } - }) - .catch((err) => { - console.error('获取测试任务状态失败:', err); - clearInterval(interval); - setMcpTesting(false); - toast.error( - t('mcp.testError') + - ': ' + - (err.message || t('mcp.getTaskFailed')), - ); + if (taskResp.runtime.exception) { + const errorMsg = + taskResp.runtime.exception || t('mcp.unknownError'); + toast.error(`${t('mcp.testError')}: ${errorMsg}`); + setRuntimeInfo({ + connected: false, + error_message: errorMsg, + tool_count: 0, + tools: [], }); - }, 1000); - } else { + } else if (taskResp.runtime.result) { + await loadServerForEdit(serverName); + toast.success(t('mcp.testSuccess')); + } else { + toast.error( + `${t('mcp.testError')}: ${t('mcp.noResultReturned')}`, + ); + } + } + } catch (err) { + clearInterval(interval); setMcpTesting(false); - toast.error(t('mcp.testError') + ': ' + t('mcp.noTaskId')); + const errorMsg = (err as Error).message || t('mcp.getTaskFailed'); + toast.error(`${t('mcp.testError')}: ${errorMsg}`); } - }) - .catch((err) => { - console.error('启动测试失败:', err); - setMcpTesting(false); - toast.error( - t('mcp.testError') + ': ' + (err.message || t('mcp.unknownError')), - ); - }); + }, 1000); + } catch (err) { + setMcpTesting(false); + const errorMsg = (err as Error).message || t('mcp.unknownError'); + toast.error(`${t('mcp.testError')}: ${errorMsg}`); + } } const addExtraArg = () => { - setExtraArgs([...extraArgs, { key: '', type: 'string', value: '' }]); + const newArgs = [ + ...extraArgs, + { key: '', type: 'string' as const, value: '' }, + ]; + setExtraArgs(newArgs); + form.setValue('extra_args', newArgs); }; const removeExtraArg = (index: number) => { @@ -410,25 +361,22 @@ export default function MCPFormDialog({ value: string, ) => { const newArgs = [...extraArgs]; - newArgs[index] = { - ...newArgs[index], - [field]: value, - }; + newArgs[index] = { ...newArgs[index], [field]: value }; setExtraArgs(newArgs); form.setValue('extra_args', newArgs); }; + const handleDialogClose = (open: boolean) => { + onOpenChange(open); + if (!open) { + form.reset(); + setExtraArgs([]); + setRuntimeInfo(null); + } + }; + return ( - { - onOpenChange(open); - if (!open) { - form.reset(); - setExtraArgs([]); - } - }} - > + @@ -436,97 +384,25 @@ export default function MCPFormDialog({ - {isEditMode && ( -
- {mcpTestStatus === 'testing' && ( -
- - - - - {t('mcp.testing')} + {isEditMode && runtimeInfo && ( +
+ {/* 测试中或连接失败时显示状态 */} + {(mcpTesting || !runtimeInfo.connected) && ( +
+
)} - {mcpTestStatus === 'success' && ( -
-
- - - - - {t('mcp.connectionSuccess')} - {mcpToolNames.length}{' '} - {t('mcp.toolsFound')} - -
-
- {mcpToolNames.map((toolName, index) => ( - - {toolName} - - ))} -
-
- )} - - {mcpTestStatus === 'failed' && ( -
-
- - - - - {t('mcp.connectionFailed')} - -
- {mcpTestError && ( -
- {mcpTestError} -
- )} -
- )} + {/* 连接成功时只显示工具列表 */} + {!mcpTesting && + runtimeInfo.connected && + runtimeInfo.tools?.length > 0 && ( + + )}
)} @@ -695,11 +571,7 @@ export default function MCPFormDialog({ diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 5b3f9253..0b4f2240 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -70,11 +70,6 @@ export default function PluginConfigPage() { const [isEditMode, setIsEditMode] = useState(false); const [refreshKey, setRefreshKey] = useState(0); - // 缓存每个服务器测试后的工具数量 - const [serverToolsCache, setServerToolsCache] = useState< - Record - >({}); - useEffect(() => { const fetchPluginSystemStatus = async () => { try { @@ -404,7 +399,6 @@ export default function PluginConfigPage() { setIsEditMode(true); setMcpSSEModalOpen(true); }} - toolsCountCache={serverToolsCache} /> @@ -496,12 +490,6 @@ export default function PluginConfigPage() { onDelete={() => { setShowDeleteConfirmModal(true); }} - onUpdateToolsCache={(serverName, toolsCount) => { - setServerToolsCache((prev) => ({ - ...prev, - [serverName]: toolsCount, - })); - }} />