Files
MyGoNavi/frontend/src/components/ai/AIMCPServerFormPanel.tsx
Syngnat 233894f027 feat(mcp): 增强启动命令参数拆分提示
- 识别 command 字段误填整行 MCP 启动命令

- 提供一键拆分 command 和 args 的表单操作

- 补充参数提示工具与组件回归测试
2026-06-12 03:30:17 +08:00

220 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { Button, Input, Popconfirm, Select } from 'antd';
import { DeleteOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { AIMCPServerConfig, AIMCPToolDescriptor } from '../../types';
import type { ParsedMCPEnvDraft } from '../../utils/mcpEnvDraft';
import type { MCPServerDraftValidation } from '../../utils/mcpServerValidation';
import AIMCPHelpBlock, { buildMCPHintStyle, mcpLabelStyle } from './AIMCPHelpBlock';
import AIMCPArgumentHints from './AIMCPArgumentHints';
import AIMCPEnvHints from './AIMCPEnvHints';
import AIMCPServerValidationPanel from './AIMCPServerValidationPanel';
import AIMCPToolSchemaSummary from './AIMCPToolSchemaSummary';
interface AIMCPServerFormPanelProps {
server: AIMCPServerConfig;
serverTools: AIMCPToolDescriptor[];
launchPreview: string;
envDraft: string;
parsedEnvDraft: ParsedMCPEnvDraft;
validation: MCPServerDraftValidation;
cardBorder: string;
inputBg: string;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
loading: boolean;
onChange: (patch: Partial<AIMCPServerConfig>) => void;
onEnvDraftChange: (value: string) => void;
onTest: () => void;
onSave: () => void;
onDelete: () => void;
}
const AIMCPServerFormPanel: React.FC<AIMCPServerFormPanelProps> = ({
server,
serverTools,
launchPreview,
envDraft,
parsedEnvDraft,
validation,
cardBorder,
inputBg,
darkMode,
overlayTheme,
loading,
onChange,
onEnvDraftChange,
onTest,
onSave,
onDelete,
}) => (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 132px', gap: 12 }}>
<AIMCPHelpBlock title="服务名称" description="给这个 MCP 起一个你自己能识别的名字,后面 AI 工具列表里会直接显示;不要只写 server、test 这类看不出用途的名字。" overlayTheme={overlayTheme} darkMode={darkMode} fieldState="required" example="Filesystem / Browser / GitHub">
<Input
value={server.name}
onChange={(event) => onChange({ name: event.target.value })}
placeholder="服务名称例如Filesystem / Browser / GitHub"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
</AIMCPHelpBlock>
<AIMCPHelpBlock title="启用状态" description="临时不用可以先禁用,保留配置但不参与 AI 工具发现。" overlayTheme={overlayTheme} darkMode={darkMode} fieldState="optional">
<Select
value={server.enabled ? 'enabled' : 'disabled'}
onChange={(value) => onChange({ enabled: value === 'enabled' })}
options={[{ label: '已启用', value: 'enabled' }, { label: '已禁用', value: 'disabled' }]}
/>
</AIMCPHelpBlock>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '132px minmax(0,1fr) 132px', gap: 12 }}>
<AIMCPHelpBlock title="传输方式" description="当前阶段只支持 stdio表示 GoNavi 会在本机启动这个进程,并通过标准输入输出与它通信。" overlayTheme={overlayTheme} darkMode={darkMode} fieldState="fixed">
<Select
value={server.transport}
onChange={(value) => onChange({ transport: value as AIMCPServerConfig['transport'] })}
options={[{ label: 'stdio', value: 'stdio' }]}
/>
</AIMCPHelpBlock>
<AIMCPHelpBlock title="启动命令" description="这里只填命令本身;如果是 npx/node/uvx/python/docker 这类启动器,把包名、脚本名、模块名或 docker run 参数放到下面的参数里。不要把 npx -y package --stdio、node server.js --stdio 或 docker run -i image 整串都塞进这里。" overlayTheme={overlayTheme} darkMode={darkMode} fieldState="required" example="npx / node / uvx / python / docker">
<Input
value={server.command}
onChange={(event) => onChange({ command: event.target.value })}
placeholder="启动命令例如npx / node / uvx / python / docker"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
</AIMCPHelpBlock>
<AIMCPHelpBlock title="超时(秒)" description="工具发现和工具调用单次最多等多久。大多数本机工具保持默认 20 秒即可;远端服务或启动慢的脚本再调大。" overlayTheme={overlayTheme} darkMode={darkMode} fieldState="optional" example="20">
<Input
type="number"
min={3}
max={120}
value={server.timeoutSeconds}
onChange={(event) => onChange({ timeoutSeconds: Number(event.target.value) || 20 })}
placeholder="超时(秒)"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{[
{ label: '默认 20 秒', value: 20 },
{ label: '稍宽松 45 秒', value: 45 },
{ label: '慢启动 60 秒', value: 60 },
].map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange({ timeoutSeconds: option.value })}
style={{
padding: '4px 10px',
borderRadius: 999,
border: `1px solid ${cardBorder}`,
background: server.timeoutSeconds === option.value
? (darkMode ? 'rgba(59,130,246,0.18)' : 'rgba(59,130,246,0.12)')
: (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.75)'),
color: server.timeoutSeconds === option.value ? '#2563eb' : overlayTheme.mutedText,
fontSize: 12,
cursor: 'pointer',
}}
>
{option.label}
</button>
))}
</div>
</AIMCPHelpBlock>
</div>
<AIMCPHelpBlock title="命令参数" description="每个参数单独录入一个标签;命令本体不要填在这里。比如 npx -y package --stdio要把 -y、package 和 --stdio 分开填node server.js --stdio 要把 server.js 和 --stdio 分开填docker run --rm -i image 要把 run、--rm、-i 和镜像名分开填。不确定怎么拆时,优先回到上面的“完整命令”框自动拆分。" overlayTheme={overlayTheme} darkMode={darkMode} fieldState="optional" example="-y、@modelcontextprotocol/server-filesystem、--stdio、server.js、run、--rm、-i、image">
<Select
mode="tags"
value={server.args || []}
onChange={(value) => onChange({ args: value })}
placeholder="命令参数,回车录入,例如:-y、包名、--stdio"
style={{ width: '100%' }}
/>
<AIMCPArgumentHints
command={server.command}
args={server.args}
onArgsChange={(args) => onChange({ args })}
onCommandArgsChange={(command, args) => onChange({ command, args })}
cardBorder={cardBorder}
darkMode={darkMode}
overlayTheme={overlayTheme}
/>
</AIMCPHelpBlock>
{launchPreview && (
<div style={{ padding: '10px 12px', borderRadius: 10, border: `1px solid ${cardBorder}`, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)' }}>
<div style={{ ...mcpLabelStyle, color: overlayTheme.titleText }}></div>
<div style={{ ...buildMCPHintStyle(overlayTheme.mutedText), marginTop: 4 }}>
GoNavi 便
</div>
<code style={{ display: 'block', marginTop: 8, fontFamily: 'var(--gn-font-mono)', fontSize: 12, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
{launchPreview}
</code>
</div>
)}
<AIMCPHelpBlock title="环境变量" description="每行一个 KEY=VALUE通常用于 API Key、工作目录、服务地址等配置不需要时可以留空。这里会保存到本机配置并在启动 MCP 进程时作为环境变量传入;不要写 export也不要把密钥写进聊天内容。" overlayTheme={overlayTheme} darkMode={darkMode} fieldState="optional" example="OPENAI_API_KEY=...">
<Input.TextArea
rows={3}
value={envDraft}
onChange={(event) => onEnvDraftChange(event.target.value)}
placeholder={"环境变量,每行一个 KEY=VALUE例如\nOPENAI_API_KEY=...\nGITHUB_TOKEN=..."}
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)' }}
/>
<div style={{ ...buildMCPHintStyle(parsedEnvDraft.invalidLines.length > 0 ? '#d97706' : overlayTheme.mutedText) }}>
{envDraft.trim()
? parsedEnvDraft.invalidLines.length > 0
? `已识别 ${parsedEnvDraft.validLines} 条环境变量,另有 ${parsedEnvDraft.invalidLines.length} 行格式无效,本次不会保存:${parsedEnvDraft.invalidLines.slice(0, 2).join(' / ')}`
: `已识别 ${parsedEnvDraft.validLines} 条环境变量。`
: '每行都要写成 KEY=VALUE没有等号或 key 含空格的行不会保存。'}
</div>
<AIMCPEnvHints
command={server.command}
args={server.args}
env={parsedEnvDraft.env}
cardBorder={cardBorder}
darkMode={darkMode}
overlayTheme={overlayTheme}
/>
</AIMCPHelpBlock>
<AIMCPServerValidationPanel
validation={validation}
cardBorder={cardBorder}
darkMode={darkMode}
overlayTheme={overlayTheme}
/>
<AIMCPToolSchemaSummary
tools={serverTools}
cardBorder={cardBorder}
darkMode={darkMode}
overlayTheme={overlayTheme}
/>
<div style={{ padding: '10px 12px', borderRadius: 10, border: `1px solid ${cardBorder}`, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)' }}>
<div style={{ ...mcpLabelStyle, color: overlayTheme.titleText }}></div>
<div style={{ ...buildMCPHintStyle(overlayTheme.mutedText), marginTop: 4 }}>
<strong></strong>
{' '}
{' '}<strong></strong>
{' '} MCP
{serverTools.length > 0
? ' 当前上方列出的工具,就是最近一次测试成功后发现到的别名。'
: ' 建议先测试成功,再保存;测试通过后,上方会显示这条服务实际发现到的工具。'}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={onTest} loading={loading} disabled={!validation.canTest} style={{ borderRadius: 10 }}></Button>
<Button type="primary" onClick={onSave} loading={loading} disabled={!validation.canSave} style={{ borderRadius: 10, fontWeight: 600 }}></Button>
<Popconfirm title="删除这个 MCP 服务?" okText="删除" cancelText="取消" onConfirm={onDelete}>
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 10 }}></Button>
</Popconfirm>
</div>
</>
);
export default AIMCPServerFormPanel;