diff --git a/web/src/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog.tsx b/web/src/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog.tsx new file mode 100644 index 00000000..cf40a20c --- /dev/null +++ b/web/src/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { httpClient } from '@/app/infra/http/HttpClient'; + +interface MCPDeleteConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + serverName: string | null; + onSuccess?: () => void; +} + +export default function MCPDeleteConfirmDialog({ + open, + onOpenChange, + serverName, + onSuccess, +}: MCPDeleteConfirmDialogProps) { + const { t } = useTranslation(); + + async function handleDelete() { + if (!serverName) return; + + try { + await httpClient.deleteMCPServer(serverName); + toast.success(t('mcp.deleteSuccess')); + + onOpenChange(false); + + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error('Failed to delete server:', error); + toast.error(t('mcp.deleteFailed')); + } + } + + return ( + + + + {t('mcp.confirmDeleteTitle')} + + {t('mcp.confirmDeleteServer')} + + onOpenChange(false)}> + {t('common.cancel')} + + + {t('common.confirm')} + + + + + ); +} 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 new file mode 100644 index 00000000..599ada5f --- /dev/null +++ b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx @@ -0,0 +1,713 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Resolver, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { httpClient } from '@/app/infra/http/HttpClient'; + +const getFormSchema = (t: (key: string) => string) => + z.object({ + name: z + .string({ required_error: t('mcp.nameRequired') }) + .min(1, { message: t('mcp.nameRequired') }), + timeout: z + .number({ invalid_type_error: t('mcp.timeoutMustBeNumber') }) + .positive({ message: t('mcp.timeoutMustBePositive') }) + .default(30), + ssereadtimeout: z + .number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') }) + .positive({ message: t('mcp.timeoutMustBePositive') }) + .default(300), + url: z + .string({ required_error: t('mcp.urlRequired') }) + .min(1, { message: t('mcp.urlRequired') }), + extra_args: z + .array( + z.object({ + key: z.string(), + type: z.enum(['string', 'number', 'boolean']), + value: z.string(), + }), + ) + .optional(), + }); + +type FormValues = z.infer> & { + timeout: number; + ssereadtimeout: number; +}; + +interface MCPFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + serverName?: string | null; + isEditMode?: boolean; + onSuccess?: () => void; + onDelete?: () => void; + onUpdateToolsCache?: (serverName: string, toolsCount: number) => void; +} + +export default function MCPFormDialog({ + open, + onOpenChange, + serverName, + isEditMode = false, + onSuccess, + onDelete, + onUpdateToolsCache, +}: MCPFormDialogProps) { + const { t } = useTranslation(); + const formSchema = getFormSchema(t); + + const form = useForm({ + resolver: zodResolver(formSchema) as unknown as Resolver, + defaultValues: { + name: '', + url: '', + timeout: 30, + ssereadtimeout: 300, + extra_args: [], + }, + }); + + const [extraArgs, setExtraArgs] = useState< + { 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(''); + + // Load server data when editing + useEffect(() => { + if (open && isEditMode && serverName) { + loadServerForEdit(serverName); + } else if (open && !isEditMode) { + // Reset form when creating new server + form.reset(); + setExtraArgs([]); + setMcpTestStatus('idle'); + setMcpToolNames([]); + setMcpTestError(''); + } + }, [open, isEditMode, serverName]); + + async function loadServerForEdit(serverName: string) { + try { + const resp = await httpClient.getMCPServer(serverName); + const server = resp.server ?? resp; + + const extraArgs = server.extra_args; + form.setValue('name', server.name); + form.setValue('url', extraArgs.url); + form.setValue('timeout', extraArgs.timeout); + form.setValue('ssereadtimeout', extraArgs.ssereadtimeout); + + if (extraArgs.headers) { + const headers = Object.entries(extraArgs.headers).map( + ([key, value]) => ({ + key, + type: 'string' as const, + value: String(value), + }), + ); + setExtraArgs(headers); + 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 || '测试连接时发生错误'); + } + } catch (error) { + console.error('Failed to load server:', error); + toast.error(t('mcp.loadFailed')); + } + } + + 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; + } + }, + ); + + try { + const serverConfig = { + name: value.name, + mode: 'sse' as const, + enable: true, + url: value.url, + headers: extraArgsObj as Record, + 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, + }); + toast.success(t('mcp.createSuccess')); + } + + onOpenChange(false); + form.reset(); + setExtraArgs([]); + + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error('Failed to save MCP server:', error); + toast.error(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')); + } + } + + function testMcp() { + 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); + + 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 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 + ) { + 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')), + ); + }); + }, 1000); + } else { + setMcpTesting(false); + toast.error(t('mcp.testError') + ': ' + t('mcp.noTaskId')); + } + }) + .catch((err) => { + console.error('启动测试失败:', err); + setMcpTesting(false); + toast.error( + t('mcp.testError') + ': ' + (err.message || t('mcp.unknownError')), + ); + }); + } + + const addExtraArg = () => { + setExtraArgs([...extraArgs, { key: '', type: 'string', value: '' }]); + }; + + const removeExtraArg = (index: number) => { + const newArgs = extraArgs.filter((_, i) => i !== index); + setExtraArgs(newArgs); + form.setValue('extra_args', newArgs); + }; + + const updateExtraArg = ( + index: number, + field: 'key' | 'type' | 'value', + value: string, + ) => { + const newArgs = [...extraArgs]; + newArgs[index] = { + ...newArgs[index], + [field]: value, + }; + setExtraArgs(newArgs); + form.setValue('extra_args', newArgs); + }; + + return ( + { + onOpenChange(open); + if (!open) { + form.reset(); + setExtraArgs([]); + } + }} + > + + + + {isEditMode ? t('mcp.editServer') : t('mcp.createServer')} + + + + {isEditMode && ( + + {mcpTestStatus === 'testing' && ( + + + + + + {t('mcp.testing')} + + )} + + {mcpTestStatus === 'success' && ( + + + + + + + {t('mcp.connectionSuccess')} - {mcpToolNames.length}{' '} + {t('mcp.toolsFound')} + + + + {mcpToolNames.map((toolName, index) => ( + + {toolName} + + ))} + + + )} + + {mcpTestStatus === 'failed' && ( + + + + + + + {t('mcp.connectionFailed')} + + + {mcpTestError && ( + + {mcpTestError} + + )} + + )} + + )} + + + + + ( + + {t('mcp.name')} + + + + + + )} + /> + + ( + + {t('mcp.url')} + + + + + + )} + /> + + ( + + {t('mcp.timeout')} + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + + ( + + {t('mcp.sseTimeout')} + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + + + {t('models.extraParameters')} + + {extraArgs.map((arg, index) => ( + + + updateExtraArg(index, 'key', e.target.value) + } + /> + + updateExtraArg(index, 'type', value) + } + > + + + + + + {t('models.string')} + + + {t('models.number')} + + + {t('models.boolean')} + + + + + updateExtraArg(index, 'value', e.target.value) + } + /> + removeExtraArg(index)} + > + + + + + + ))} + + {t('models.addParameter')} + + + + {t('mcp.extraParametersDescription')} + + + + + + {isEditMode && onDelete && ( + + {t('common.delete')} + + )} + + + {isEditMode ? t('common.save') : t('common.submit')} + + + testMcp()} + disabled={mcpTesting} + > + {t('common.test')} + + + { + onOpenChange(false); + form.reset(); + setExtraArgs([]); + }} + > + {t('common.cancel')} + + + + + + + + ); +} diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index d29c1a86..5b3f9253 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -3,9 +3,10 @@ import PluginInstalledComponent, { PluginInstalledComponentRef, } from '@/app/home/plugins/plugin-installed/PluginInstalledComponent'; import MarketPage from '@/app/home/plugins/plugin-market/PluginMarketComponent'; -// import PluginSortDialog from '@/app/home/plugins/plugin-sort/PluginSortDialog'; import MCPServerComponent from '@/app/home/plugins/mcp-server/MCPServerComponent'; +import MCPFormDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPFormDialog'; +import MCPDeleteConfirmDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog'; import styles from './plugins.module.css'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; @@ -38,27 +39,6 @@ import { useTranslation } from 'react-i18next'; import { PluginV4 } from '@/app/infra/entities/plugin'; import { systemInfo } from '@/app/infra/http/HttpClient'; import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; -import { - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectItem, -} from '@/components/ui/select'; - -import { Resolver, useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { DialogDescription } from '@radix-ui/react-dialog'; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form'; enum PluginInstallStatus { WAIT_INPUT = 'wait_input', @@ -83,76 +63,18 @@ export default function PluginConfigPage() { useState(null); const [statusLoading, setStatusLoading] = useState(true); const fileInputRef = useRef(null); - const addExtraArg = () => { - setExtraArgs([...extraArgs, { key: '', type: 'string', value: '' }]); - }; - const removeExtraArg = (index: number) => { - const newArgs = extraArgs.filter((_, i) => i !== index); - setExtraArgs(newArgs); - form.setValue('extra_args', newArgs); - }; - const getFormSchema = (t: (key: string) => string) => - z.object({ - name: z - .string({ required_error: t('mcp.nameRequired') }) - .min(1, { message: t('mcp.nameRequired') }), - timeout: z - .number({ invalid_type_error: t('mcp.timeoutMustBeNumber') }) - .positive({ message: t('mcp.timeoutMustBePositive') }) - .default(30), - ssereadtimeout: z - .number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') }) - .positive({ message: t('mcp.timeoutMustBePositive') }) - .default(300), - url: z - .string({ required_error: t('mcp.urlRequired') }) - .min(1, { message: t('mcp.urlRequired') }), - extra_args: z - .array( - z.object({ - key: z.string(), - type: z.enum(['string', 'number', 'boolean']), - value: z.string(), - }), - ) - .optional(), - }); - - const formSchema = getFormSchema(t); - - type FormValues = z.infer & { - timeout: number; - ssereadtimeout: number; - }; - - const form = useForm({ - resolver: zodResolver(formSchema) as unknown as Resolver, - defaultValues: { - name: '', - url: '', - timeout: 30, - ssereadtimeout: 300, - extra_args: [], - }, - }); - - const [extraArgs, setExtraArgs] = useState< - { key: string; type: 'string' | 'number' | 'boolean'; value: string }[] - >([]); - const updateExtraArg = ( - index: number, - field: 'key' | 'type' | 'value', - value: string, - ) => { - const newArgs = [...extraArgs]; - newArgs[index] = { - ...newArgs[index], - [field]: value, - }; - setExtraArgs(newArgs); - form.setValue('extra_args', newArgs); - }; const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); + const [editingServerName, setEditingServerName] = useState( + null, + ); + const [isEditMode, setIsEditMode] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + + // 缓存每个服务器测试后的工具数量 + const [serverToolsCache, setServerToolsCache] = useState< + Record + >({}); + useEffect(() => { const fetchPluginSystemStatus = async () => { try { @@ -169,22 +91,18 @@ export default function PluginConfigPage() { fetchPluginSystemStatus(); }, [t]); - //这个是旧版本的测试github url,下面重写了一个新版本的watchTask函数,用来检测Mcp + function watchTask(taskId: number) { let alreadySuccess = false; - console.log('taskId:', taskId); - // 每秒拉取一次任务状态 const interval = setInterval(() => { httpClient.getAsyncTask(taskId).then((resp) => { - console.log('task status:', resp); if (resp.runtime.done) { clearInterval(interval); if (resp.runtime.exception) { setInstallError(resp.runtime.exception); setPluginInstallStatus(PluginInstallStatus.ERROR); } else { - // success if (!alreadySuccess) { toast.success(t('plugins.installSuccess')); alreadySuccess = true; @@ -199,124 +117,6 @@ export default function PluginConfigPage() { } const pluginInstalledRef = useRef(null); - const [mcpTesting, setMcpTesting] = useState(false); - const [editingServerName, setEditingServerName] = useState( - null, - ); - const [isEditMode, setIsEditMode] = useState(false); - const [refreshKey, setRefreshKey] = useState(0); - - // MCP测试结果状态 - const [mcpTestStatus, setMcpTestStatus] = useState< - 'idle' | 'testing' | 'success' | 'failed' - >('idle'); - const [mcpToolNames, setMcpToolNames] = useState([]); - const [mcpTestError, setMcpTestError] = useState(''); - - // 缓存每个服务器测试后的工具数量 - const [serverToolsCache, setServerToolsCache] = useState< - Record - >({}); - - // 强制清理 body 样式以修复 Dialog 关闭后点击失效的问题 - useEffect(() => { - console.log('[Dialog Debug] States:', { - mcpSSEModalOpen, - modalOpen, - showDeleteConfirmModal, - }); - - if (!mcpSSEModalOpen && !modalOpen && !showDeleteConfirmModal) { - const cleanup = () => { - document.body.style.removeProperty('pointer-events'); - document.body.style.removeProperty('overflow'); - - if (document.body.style.pointerEvents === 'none') { - document.body.style.pointerEvents = ''; - } - if (document.body.style.overflow === 'hidden') { - document.body.style.overflow = ''; - } - - console.log( - '[Dialog Debug] After cleanup - body.style.pointerEvents:', - document.body.style.pointerEvents, - ); - console.log( - '[Dialog Debug] After cleanup - body.style.overflow:', - document.body.style.overflow, - ); - - // 检查计算后的样式 - const computedStyle = window.getComputedStyle(document.body); - console.log( - '[Dialog Debug] Computed pointerEvents:', - computedStyle.pointerEvents, - ); - }; - - // 多次清理以确保覆盖 Radix 的设置 - cleanup(); - const timer1 = setTimeout(cleanup, 0); - const timer2 = setTimeout(cleanup, 50); - const timer3 = setTimeout(cleanup, 100); - const timer4 = setTimeout(cleanup, 200); - const timer5 = setTimeout(cleanup, 300); - - return () => { - clearTimeout(timer1); - clearTimeout(timer2); - clearTimeout(timer3); - clearTimeout(timer4); - clearTimeout(timer5); - }; - } - }, [mcpSSEModalOpen, modalOpen, showDeleteConfirmModal]); - - useEffect(() => { - const interval = setInterval(() => { - if (!mcpSSEModalOpen && !modalOpen && !showDeleteConfirmModal) { - if (document.body.style.pointerEvents === 'none') { - console.log( - '[Global Cleanup] Found stale pointer-events, cleaning...', - ); - document.body.style.removeProperty('pointer-events'); - document.body.style.pointerEvents = ''; - } - } - }, 500); - - return () => clearInterval(interval); - }, [mcpSSEModalOpen, modalOpen, showDeleteConfirmModal]); - - // MutationObserver:监视 body 的 style 变化 - useEffect(() => { - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if ( - mutation.type === 'attributes' && - mutation.attributeName === 'style' - ) { - if (!mcpSSEModalOpen && !modalOpen && !showDeleteConfirmModal) { - if (document.body.style.pointerEvents === 'none') { - console.log( - '[MutationObserver] Detected pointer-events being set to none, reverting...', - ); - document.body.style.removeProperty('pointer-events'); - document.body.style.pointerEvents = ''; - } - } - } - }); - }); - - observer.observe(document.body, { - attributes: true, - attributeFilter: ['style'], - }); - - return () => observer.disconnect(); - }, [mcpSSEModalOpen, modalOpen, showDeleteConfirmModal]); function handleModalConfirm() { installPlugin(installSource, installInfo as Record); @@ -365,340 +165,6 @@ export default function PluginConfigPage() { [watchTask], ); - async function deleteMCPServer() { - if (!editingServerName) return; - - try { - await httpClient.deleteMCPServer(editingServerName); - toast.success(t('mcp.deleteSuccess')); - - // 关闭所有对话框 - setShowDeleteConfirmModal(false); - setMcpSSEModalOpen(false); - - // 重置状态 - form.reset(); - setExtraArgs([]); - setEditingServerName(null); - setIsEditMode(false); - - // 刷新服务器列表 - setRefreshKey((prev) => prev + 1); - } catch (error) { - console.error('Failed to delete server:', error); - toast.error(t('mcp.deleteFailed')); - } - } - - async function loadServerForEdit(serverName: string) { - try { - const resp = await httpClient.getMCPServer(serverName); - const server = resp.server ?? resp; - console.log('Loaded server for edit:', server); - - const extraArgs = server.extra_args; - form.setValue('name', server.name); - form.setValue('url', extraArgs.url); - form.setValue('timeout', extraArgs.timeout); - form.setValue('ssereadtimeout', extraArgs.ssereadtimeout); - - if (extraArgs.headers) { - const headers = Object.entries(extraArgs.headers).map( - ([key, value]) => ({ - key, - type: 'string' as const, - value: String(value), - }), - ); - setExtraArgs(headers); - form.setValue('extra_args', headers); - } - - setMcpTestStatus('testing'); - setMcpToolNames([]); - setMcpTestError(''); - - setEditingServerName(serverName); - setIsEditMode(true); - setMcpSSEModalOpen(true); - - 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) => { - console.log('Task response:', taskResp); - - if (taskResp.runtime && taskResp.runtime.done) { - clearInterval(interval); - - console.log('Task completed. Runtime:', taskResp.runtime); - console.log('Result:', taskResp.runtime.result); - console.log('Exception:', taskResp.runtime.exception); - - if (taskResp.runtime.exception) { - console.log('Test failed with 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') { - console.log('Result is string, parsing...'); - result = JSON.parse(rawResult.replace(/'/g, '"')); - } else { - result = rawResult as typeof result; - } - - console.log('Parsed result:', result); - console.log( - 'tools_names_lists:', - result.tools_names_lists, - ); - console.log( - 'tools_names_lists length:', - result.tools_names_lists?.length, - ); - - if ( - result.tools_names_lists && - result.tools_names_lists.length > 0 - ) { - console.log( - 'Test success with', - result.tools_names_lists.length, - 'tools', - ); - setMcpTestStatus('success'); - setMcpToolNames(result.tools_names_lists); - // 保存工具数量到缓存 - setServerToolsCache((prev) => ({ - ...prev, - [server.name]: result.tools_names_lists!.length, - })); - } else { - console.log('Test failed: no tools found'); - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('未找到任何工具'); - } - } catch (parseError) { - console.error('Failed to parse result:', parseError); - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('解析测试结果失败'); - } - } else { - // 没结果 - console.log('Test failed: no result'); - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('测试未返回结果'); - } - } - }) - .catch((err) => { - console.error('获取任务状态失败:', err); - clearInterval(interval); - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError(err.message || '获取任务状态失败'); - }); - }, 1000); - } else { - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError('未获取到任务ID'); - } - } catch (error) { - console.error('Failed to test server:', error); - setMcpTestStatus('failed'); - setMcpToolNames([]); - setMcpTestError((error as Error).message || '测试连接时发生错误'); - } - } catch (error) { - console.error('Failed to load server:', error); - toast.error(t('mcp.loadFailed')); - } - } - - 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; - } - }, - ); - - try { - // 构造符合 MCPServerConfig 类型的数据 - const serverConfig = { - name: value.name, - mode: 'sse' as const, - enable: true, - url: value.url, - headers: extraArgsObj as Record, - timeout: value.timeout, - ssereadtimeout: value.ssereadtimeout, - }; - - if (isEditMode && editingServerName) { - await httpClient.updateMCPServer(editingServerName, 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, - }); - toast.success(t('mcp.createSuccess')); - } - - setMcpSSEModalOpen(false); - - form.reset(); - setExtraArgs([]); - setEditingServerName(null); - setIsEditMode(false); - - setRefreshKey((prev) => prev + 1); - } catch (error) { - console.error('Failed to save MCP server:', error); - toast.error(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')); - } - } - - function testMcp() { - 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) => { - console.log(res); - if (res.task_id) { - const taskId = res.task_id; - - const interval = setInterval(() => { - httpClient - .getAsyncTask(taskId) - .then((taskResp) => { - console.log('Test task response:', taskResp); - - if (taskResp.runtime && taskResp.runtime.done) { - clearInterval(interval); - setMcpTesting(false); - - 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 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 - ) { - 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')), - ); - }); - }, 1000); - } else { - setMcpTesting(false); - toast.error(t('mcp.testError') + ': ' + t('mcp.noTaskId')); - } - }) - .catch((err) => { - console.error('启动测试失败:', err); - setMcpTesting(false); - toast.error( - t('mcp.testError') + ': ' + (err.message || t('mcp.unknownError')), - ); - }); - } - const validateFileType = (file: File): boolean => { const allowedExtensions = ['.lbpkg', '.zip']; const fileName = file.name.toLowerCase(); @@ -884,8 +350,6 @@ export default function PluginConfigPage() { setActiveTab('mcp-servers'); setIsEditMode(false); setEditingServerName(null); - form.reset(); - setExtraArgs([]); setMcpSSEModalOpen(true); }} > @@ -936,7 +400,9 @@ export default function PluginConfigPage() { { - loadServerForEdit(serverName); + setEditingServerName(serverName); + setIsEditMode(true); + setMcpSSEModalOpen(true); }} toolsCountCache={serverToolsCache} /> @@ -1004,7 +470,6 @@ export default function PluginConfigPage() { - {/* 拖拽提示覆盖层 */} {isDragOver && ( @@ -1018,337 +483,38 @@ export default function PluginConfigPage() { )} - - - - - {t('mcp.confirmDeleteTitle')} - - - {t('mcp.confirmDeleteServer')} - - - { - deleteMCPServer(); - setShowDeleteConfirmModal(false); - }} - > - {t('common.confirm')} - - - - + { + setEditingServerName(null); + setIsEditMode(false); + setRefreshKey((prev) => prev + 1); + }} + onDelete={() => { + setShowDeleteConfirmModal(true); + }} + onUpdateToolsCache={(serverName, toolsCount) => { + setServerToolsCache((prev) => ({ + ...prev, + [serverName]: toolsCount, + })); + }} + /> - { - setMcpSSEModalOpen(open); - if (!open) { - // 关闭对话框时重置编辑状态 - setIsEditMode(false); - setEditingServerName(null); - form.reset(); - setExtraArgs([]); - } - }} - > - - - - {isEditMode ? t('mcp.editServer') : t('mcp.createServer')} - - - - {/* 测试结果显示区域 - 仅在编辑模式显示 */} - {isEditMode && ( - - {mcpTestStatus === 'testing' && ( - - - - - - {t('mcp.testing')} - - )} - - {mcpTestStatus === 'success' && ( - - - - - - - {t('mcp.connectionSuccess')} - {mcpToolNames.length}{' '} - {t('mcp.toolsFound')} - - - - {mcpToolNames.map((toolName, index) => ( - - {toolName} - - ))} - - - )} - - {mcpTestStatus === 'failed' && ( - - - - - - - {t('mcp.connectionFailed')} - - - {mcpTestError && ( - - {mcpTestError} - - )} - - )} - - )} - - - - - ( - - {t('mcp.name')} - - - - - - )} - /> - - ( - - {t('mcp.url')} - - - - - - )} - /> - - ( - - {t('mcp.timeout')} - - - field.onChange(Number(e.target.value)) - } - /> - - - - )} - /> - - ( - - {t('mcp.sseTimeout')} - - - field.onChange(Number(e.target.value)) - } - /> - - - - )} - /> - - - {t('models.extraParameters')} - - {extraArgs.map((arg, index) => ( - - - updateExtraArg(index, 'key', e.target.value) - } - /> - - updateExtraArg(index, 'type', value) - } - > - - - - - - {t('models.string')} - - - {t('models.number')} - - - {t('models.boolean')} - - - - - updateExtraArg(index, 'value', e.target.value) - } - /> - removeExtraArg(index)} - > - - - - - - ))} - - {t('models.addParameter')} - - - - {t('mcp.extraParametersDescription')} - - - - - - {isEditMode && ( - setShowDeleteConfirmModal(true)} - > - {t('common.delete')} - - )} - - - {isEditMode ? t('common.save') : t('common.submit')} - - - testMcp()} - disabled={mcpTesting} - > - {t('common.test')} - - - { - setMcpSSEModalOpen(false); - form.reset(); - setExtraArgs([]); - setIsEditMode(false); - setEditingServerName(null); - }} - > - {t('common.cancel')} - - - - - - - - + { + setMcpSSEModalOpen(false); + setEditingServerName(null); + setIsEditMode(false); + setRefreshKey((prev) => prev + 1); + }} + /> ); }