mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 10:29:52 +08:00
✨ feat(mcp): 优化新增服务模板入口
- 模板入口移入一行命令快速新增面板 - 增加启动命令预览,降低 command 和 args 拆分成本 - 移除设置页重复模板区块并补充交互测试
This commit is contained in:
@@ -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 = () => {}) => (
|
||||
<AIMCPQuickAddServerPanel
|
||||
cardBg="#fff"
|
||||
cardBorder="rgba(0,0,0,0.08)"
|
||||
inputBg="#fff"
|
||||
darkMode={false}
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
onAddServer={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(
|
||||
<AIMCPQuickAddServerPanel
|
||||
cardBg="#fff"
|
||||
cardBorder="rgba(0,0,0,0.08)"
|
||||
inputBg="#fff"
|
||||
darkMode={false}
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
onAddServer={() => {}}
|
||||
/>,
|
||||
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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AIMCPQuickAddServerPanelProps> = ({
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ ...mcpLabelStyle, color: overlayTheme.titleText, fontSize: 14 }}>一行命令快速新增</div>
|
||||
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>
|
||||
README 里通常只给一整行启动命令。直接粘到这里,GoNavi 会先拆成 command、args 和 env,再生成一个可继续编辑的 MCP 草稿。
|
||||
先选最接近的模板,或直接粘贴 README 里的一整行启动命令。GoNavi 会先拆成 command、args 和 env,再生成一个可继续编辑的 MCP 草稿。
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ ...mcpLabelStyle, color: overlayTheme.titleText }}>常见启动方式模板</div>
|
||||
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>
|
||||
不确定 command 和 args 怎么拆时,直接点一个模板新增草稿;每张卡片下面展示的就是 GoNavi 实际会启动的命令预览。
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10 }}>
|
||||
{MCP_SERVER_DRAFT_TEMPLATES.map((template) => (
|
||||
<button
|
||||
key={template.key}
|
||||
type="button"
|
||||
onClick={() => 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',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 700 }}>{template.title}</div>
|
||||
<div style={{ marginTop: 4, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>{template.description}</div>
|
||||
<code style={{ display: 'block', marginTop: 8, fontFamily: 'var(--gn-font-mono)', fontSize: 12, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', color: overlayTheme.titleText }}>
|
||||
{buildMCPLaunchPreview(String(template.seed.command || ''), template.seed.args)}
|
||||
</code>
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>{template.detail}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Input.TextArea
|
||||
|
||||
@@ -6,19 +6,6 @@ import AISettingsMCPSection from './AISettingsMCPSection';
|
||||
import type { AISettingsMCPSectionProps } from './AISettingsMCPSection';
|
||||
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
|
||||
const flattenElementText = (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) => 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({
|
||||
|
||||
@@ -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<AISettingsMCPSectionProps> = ({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: 14,
|
||||
border: `1px solid ${cardBorder}`,
|
||||
background: cardBg,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}>常见启动方式模板</div>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
不确定命令和参数怎么拆时,先选一个最接近的启动方式。GoNavi 会自动带入示例值,你再改成自己的脚本名、模块名、Docker 镜像或 exe 路径即可。
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10 }}>
|
||||
{MCP_SERVER_DRAFT_TEMPLATES.map((template) => (
|
||||
<button
|
||||
key={template.key}
|
||||
type="button"
|
||||
onClick={() => 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',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 700 }}>{template.title}</div>
|
||||
<div style={{ marginTop: 4, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>{template.description}</div>
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>{template.detail}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}>支持命令、参数、环境变量和超时;不确定怎么填时先看卡片里的“字段速查”,保存后会自动进入 AI 工具列表。</div>
|
||||
<Button icon={<PlusOutlined />} onClick={() => onAddServer()} style={{ borderRadius: 10 }}>新增 MCP 服务</Button>
|
||||
|
||||
Reference in New Issue
Block a user