diff --git a/frontend/src/components/ai/AIMCPQuickAddServerPanel.test.tsx b/frontend/src/components/ai/AIMCPQuickAddServerPanel.test.tsx index 1d5c835..9ae8e16 100644 --- a/frontend/src/components/ai/AIMCPQuickAddServerPanel.test.tsx +++ b/frontend/src/components/ai/AIMCPQuickAddServerPanel.test.tsx @@ -1,28 +1,109 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import AIMCPQuickAddServerPanel from './AIMCPQuickAddServerPanel'; +vi.mock('antd', async () => { + const React = await import('react'); + return { + Input: { + TextArea: (props: any) => React.createElement('textarea', props), + }, + Button: ({ icon, children, ...props }: any) => React.createElement('button', props, icon, children), + }; +}); + +vi.mock('@ant-design/icons', async () => { + const React = await import('react'); + return { + PlusOutlined: () => React.createElement('span', { 'data-testid': 'plus-icon' }), + }; +}); + +const buildQuickAddPanel = (onAddServer = () => {}) => ( + +); + +const flattenRendererText = (node: any): string => { + if (node == null || typeof node === 'boolean') { + return ''; + } + if (typeof node === 'string' || typeof node === 'number') { + return String(node); + } + if (Array.isArray(node)) { + return node.map((item) => flattenRendererText(item)).join(''); + } + return flattenRendererText(node.children ?? node.props?.children); +}; + +const findTemplateButton = (renderer: ReactTestRenderer, label: string) => { + const matches = renderer.root.findAll( + (node) => node.type === 'button' && flattenRendererText(node).includes(label), + ); + expect(matches.length).toBe(1); + return matches[0]; +}; + describe('AIMCPQuickAddServerPanel', () => { it('renders a top-level full-command entry for creating MCP drafts', () => { const markup = renderToStaticMarkup( - {}} - />, + buildQuickAddPanel(), ); expect(markup).toContain('一行命令快速新增'); - expect(markup).toContain('README 里通常只给一整行启动命令'); + expect(markup).toContain('先选最接近的模板'); expect(markup).toContain('command、args 和 env'); + expect(markup).toContain('常见启动方式模板'); + expect(markup).toContain('npx 包'); + expect(markup).toContain('npx -y @modelcontextprotocol/server-filesystem --stdio'); + expect(markup).toContain('Docker 镜像'); + expect(markup).toContain('docker run --rm -i mcp/server-fetch:latest'); expect(markup).toContain('粘贴完整命令'); expect(markup).toContain('$env:GITHUB_TOKEN=...; uvx mcp-server-github --stdio'); expect(markup).toContain('解析并新增草稿'); }); + + it('seeds a new npx MCP draft from the quick-add template', async () => { + const onAddServer = vi.fn(); + let renderer!: ReactTestRenderer; + + await act(async () => { + renderer = create(buildQuickAddPanel(onAddServer)); + }); + + findTemplateButton(renderer, 'npx 包').props.onClick(); + + expect(onAddServer).toHaveBeenCalledWith(expect.objectContaining({ + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '--stdio'], + })); + }); + + it('seeds a docker MCP draft from the quick-add template', async () => { + const onAddServer = vi.fn(); + let renderer!: ReactTestRenderer; + + await act(async () => { + renderer = create(buildQuickAddPanel(onAddServer)); + }); + + findTemplateButton(renderer, 'Docker 镜像').props.onClick(); + + expect(onAddServer).toHaveBeenCalledWith(expect.objectContaining({ + command: 'docker', + args: ['run', '--rm', '-i', 'mcp/server-fetch:latest'], + timeoutSeconds: 45, + })); + }); }); diff --git a/frontend/src/components/ai/AIMCPQuickAddServerPanel.tsx b/frontend/src/components/ai/AIMCPQuickAddServerPanel.tsx index c6275c2..b67723e 100644 --- a/frontend/src/components/ai/AIMCPQuickAddServerPanel.tsx +++ b/frontend/src/components/ai/AIMCPQuickAddServerPanel.tsx @@ -7,8 +7,12 @@ import { parseMCPCommandDraft, type ParseMCPCommandDraftResult, } from '../../utils/mcpCommandDraft'; -import { MCP_COMMAND_PARSE_EXAMPLE } from '../../utils/mcpServerGuidance'; +import { + buildMCPLaunchPreview, + MCP_COMMAND_PARSE_EXAMPLE, +} from '../../utils/mcpServerGuidance'; import { buildMCPQuickAddServerSeed } from '../../utils/mcpServerDraftSeed'; +import { MCP_SERVER_DRAFT_TEMPLATES } from '../../utils/mcpServerTemplates'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import AIMCPCommandDraftPreview from './AIMCPCommandDraftPreview'; import { buildMCPHintStyle, mcpLabelStyle } from './AIMCPHelpBlock'; @@ -75,7 +79,38 @@ const AIMCPQuickAddServerPanel: React.FC = ({ 一行命令快速新增 - README 里通常只给一整行启动命令。直接粘到这里,GoNavi 会先拆成 command、args 和 env,再生成一个可继续编辑的 MCP 草稿。 + 先选最接近的模板,或直接粘贴 README 里的一整行启动命令。GoNavi 会先拆成 command、args 和 env,再生成一个可继续编辑的 MCP 草稿。 + + + + 常见启动方式模板 + + 不确定 command 和 args 怎么拆时,直接点一个模板新增草稿;每张卡片下面展示的就是 GoNavi 实际会启动的命令预览。 + + + {MCP_SERVER_DRAFT_TEMPLATES.map((template) => ( + onAddServer(template.seed)} + style={{ + textAlign: 'left', + padding: '12px 13px', + borderRadius: 12, + border: `1px solid ${cardBorder}`, + background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)', + color: overlayTheme.titleText, + cursor: 'pointer', + }} + > + {template.title} + {template.description} + + {buildMCPLaunchPreview(String(template.seed.command || ''), template.seed.args)} + + {template.detail} + + ))} { - if (node == null || typeof node === 'boolean') { - return ''; - } - if (typeof node === 'string' || typeof node === 'number') { - return String(node); - } - if (Array.isArray(node)) { - return node.map((item) => flattenElementText(item)).join(''); - } - return flattenElementText(node.props?.children); -}; - const findElement = (node: any, predicate: (element: any) => boolean): any => { if (node == null || typeof node === 'boolean' || typeof node === 'string' || typeof node === 'number') { return null; @@ -118,7 +105,7 @@ describe('AISettingsMCPSection', () => { expect(markup).toContain('接入外部客户端'); expect(markup).toContain('尚未把当前 GoNavi MCP 接入到这里'); expect(markup).toContain('一行命令快速新增'); - expect(markup).toContain('README 里通常只给一整行启动命令'); + expect(markup).toContain('先选最接近的模板'); expect(markup).toContain('解析并新增草稿'); expect(markup).toContain('新增 MCP 参数速查'); expect(markup).toContain('command'); @@ -206,49 +193,6 @@ describe('AISettingsMCPSection', () => { expect(markup).toContain('未声明 inputSchema'); }); - it('seeds a new draft when a launch template is selected', () => { - const onAddServer = vi.fn(); - const tree = AISettingsMCPSection(buildMCPSectionProps({ - mcpClientStatuses: [], - selectedMCPClientStatus: undefined, - onAddServer, - })); - - const npxTemplateButton = findElement( - tree, - (node) => node.type === 'button' && flattenElementText(node.props?.children).includes('npx 包'), - ); - expect(npxTemplateButton).toBeTruthy(); - npxTemplateButton.props.onClick(); - - expect(onAddServer).toHaveBeenCalledWith(expect.objectContaining({ - command: 'npx', - args: ['-y', '@modelcontextprotocol/server-filesystem', '--stdio'], - })); - }); - - it('seeds a docker MCP draft with interactive stdio args', () => { - const onAddServer = vi.fn(); - const tree = AISettingsMCPSection(buildMCPSectionProps({ - mcpClientStatuses: [], - selectedMCPClientStatus: undefined, - onAddServer, - })); - - const dockerTemplateButton = findElement( - tree, - (node) => node.type === 'button' && flattenElementText(node.props?.children).includes('Docker 镜像'), - ); - expect(dockerTemplateButton).toBeTruthy(); - dockerTemplateButton.props.onClick(); - - expect(onAddServer).toHaveBeenCalledWith(expect.objectContaining({ - command: 'docker', - args: ['run', '--rm', '-i', 'mcp/server-fetch:latest'], - timeoutSeconds: 45, - })); - }); - it('toggles the in-app MCP HTTP service from the switch panel', () => { const onToggleHTTPServer = vi.fn(); const tree = AISettingsMCPSection(buildMCPSectionProps({ diff --git a/frontend/src/components/ai/AISettingsMCPSection.tsx b/frontend/src/components/ai/AISettingsMCPSection.tsx index 2540653..f5164e4 100644 --- a/frontend/src/components/ai/AISettingsMCPSection.tsx +++ b/frontend/src/components/ai/AISettingsMCPSection.tsx @@ -5,7 +5,6 @@ import { PlusOutlined } from '@ant-design/icons'; import type { AIMCPClientInstallStatus, AIMCPHTTPServerStatus, AIMCPServerConfig, AIMCPToolDescriptor } from '../../types'; import type { MCPClientKey } from '../../utils/mcpClientInstallStatus'; import { MCP_FIELD_GUIDES } from '../../utils/mcpServerGuidance'; -import { MCP_SERVER_DRAFT_TEMPLATES } from '../../utils/mcpServerTemplates'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import AIMCPClientInstallPanel from './AIMCPClientInstallPanel'; import AIMCPFieldGuideCard from './AIMCPFieldGuideCard'; @@ -141,44 +140,6 @@ const AISettingsMCPSection: React.FC = ({ ))} - - 常见启动方式模板 - - 不确定命令和参数怎么拆时,先选一个最接近的启动方式。GoNavi 会自动带入示例值,你再改成自己的脚本名、模块名、Docker 镜像或 exe 路径即可。 - - - {MCP_SERVER_DRAFT_TEMPLATES.map((template) => ( - onAddServer(template.seed)} - style={{ - textAlign: 'left', - padding: '12px 13px', - borderRadius: 12, - border: `1px solid ${cardBorder}`, - background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)', - color: overlayTheme.titleText, - cursor: 'pointer', - }} - > - {template.title} - {template.description} - {template.detail} - - ))} - - 支持命令、参数、环境变量和超时;不确定怎么填时先看卡片里的“字段速查”,保存后会自动进入 AI 工具列表。 } onClick={() => onAddServer()} style={{ borderRadius: 10 }}>新增 MCP 服务
+ {buildMCPLaunchPreview(String(template.seed.command || ''), template.seed.args)} +