♻️ refactor(ai-mcp): 拆分 MCP 服务卡片并收敛表单逻辑

This commit is contained in:
Syngnat
2026-06-09 08:49:00 +08:00
parent 86095b5bf1
commit 8529fbd9e2
4 changed files with 476 additions and 349 deletions

View File

@@ -0,0 +1,91 @@
import React from 'react';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { MCPFieldState } from '../../utils/mcpServerGuidance';
export const mcpLabelStyle: React.CSSProperties = {
fontSize: 12,
fontWeight: 700,
};
export const buildMCPHintStyle = (mutedText: string): React.CSSProperties => ({
fontSize: 12,
color: mutedText,
lineHeight: 1.6,
});
export const buildMCPFieldTone = (kind: MCPFieldState, darkMode: boolean) => {
switch (kind) {
case 'required':
return {
label: '必填',
color: '#b45309',
bg: darkMode ? 'rgba(245,158,11,0.18)' : 'rgba(245,158,11,0.12)',
};
case 'fixed':
return {
label: '固定',
color: '#2563eb',
bg: darkMode ? 'rgba(59,130,246,0.18)' : 'rgba(59,130,246,0.12)',
};
default:
return {
label: '可选',
color: '#475569',
bg: darkMode ? 'rgba(148,163,184,0.18)' : 'rgba(148,163,184,0.12)',
};
}
};
interface AIMCPHelpBlockProps {
title: string;
description: string;
overlayTheme: OverlayWorkbenchTheme;
darkMode: boolean;
fieldState: MCPFieldState;
example?: string;
children: React.ReactNode;
}
const AIMCPHelpBlock: React.FC<AIMCPHelpBlockProps> = ({
title,
description,
overlayTheme,
darkMode,
fieldState,
example,
children,
}) => {
const tone = buildMCPFieldTone(fieldState, darkMode);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={mcpLabelStyle}>{title}</div>
<span
style={{
padding: '2px 8px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: tone.color,
background: tone.bg,
}}
>
{tone.label}
</span>
</div>
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>
{description}
{example ? (
<>
{' '}<code style={{ fontFamily: 'var(--gn-font-mono)' }}>{example}</code>
</>
) : null}
</div>
{children}
</div>
);
};
export default AIMCPHelpBlock;

View File

@@ -1,20 +1,12 @@
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 { parseMCPCommandDraft } from '../../utils/mcpCommandDraft';
import { formatMCPEnvDraft, parseMCPEnvDraft } from '../../utils/mcpEnvDraft';
import {
MCP_COMMAND_EXAMPLES,
MCP_COMMAND_PARSE_EXAMPLE,
MCP_FIELD_GUIDES,
MCP_SERVER_FILL_STEPS,
buildMCPLaunchPreview,
type MCPFieldState,
} from '../../utils/mcpServerGuidance';
import AIMCPCommandDraftPreview from './AIMCPCommandDraftPreview';
import { buildMCPLaunchPreview } from '../../utils/mcpServerGuidance';
import AIMCPServerFormPanel from './AIMCPServerFormPanel';
import AIMCPServerGuidePanel from './AIMCPServerGuidePanel';
interface AIMCPServerCardProps {
server: AIMCPServerConfig;
@@ -31,80 +23,6 @@ interface AIMCPServerCardProps {
onDelete: () => void;
}
const labelStyle: React.CSSProperties = {
fontSize: 12,
fontWeight: 700,
};
const hintStyle = (mutedText: string): React.CSSProperties => ({
fontSize: 12,
color: mutedText,
lineHeight: 1.6,
});
const buildFieldTone = (kind: MCPFieldState, darkMode: boolean) => {
switch (kind) {
case 'required':
return {
label: '必填',
color: '#b45309',
bg: darkMode ? 'rgba(245,158,11,0.18)' : 'rgba(245,158,11,0.12)',
};
case 'fixed':
return {
label: '固定',
color: '#2563eb',
bg: darkMode ? 'rgba(59,130,246,0.18)' : 'rgba(59,130,246,0.12)',
};
default:
return {
label: '可选',
color: '#475569',
bg: darkMode ? 'rgba(148,163,184,0.18)' : 'rgba(148,163,184,0.12)',
};
}
};
const MCPHelpBlock: React.FC<{
title: string;
description: string;
overlayTheme: OverlayWorkbenchTheme;
darkMode: boolean;
fieldState: MCPFieldState;
example?: string;
children: React.ReactNode;
}> = ({ title, description, overlayTheme, darkMode, fieldState, example, children }) => {
const tone = buildFieldTone(fieldState, darkMode);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={labelStyle}>{title}</div>
<span
style={{
padding: '2px 8px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: tone.color,
background: tone.bg,
}}
>
{tone.label}
</span>
</div>
<div style={hintStyle(overlayTheme.mutedText)}>
{description}
{example ? (
<>
{' '}<code style={{ fontFamily: 'var(--gn-font-mono)' }}>{example}</code>
</>
) : null}
</div>
{children}
</div>
);
};
export const AIMCPServerCard: React.FC<AIMCPServerCardProps> = ({
server,
serverTools,
@@ -141,272 +59,40 @@ export const AIMCPServerCard: React.FC<AIMCPServerCardProps> = ({
});
};
const handleEnvDraftChange = (nextValue: string) => {
setEnvDraft(nextValue);
onChange({ env: parseMCPEnvDraft(nextValue).env });
};
return (
<div style={{ padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ padding: '10px 12px', borderRadius: 10, border: `1px dashed ${cardBorder}`, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.7)' }}>
<div style={{ ...labelStyle, color: overlayTheme.titleText }}></div>
<div style={{ ...hintStyle(overlayTheme.mutedText), marginTop: 4 }}>
{' '}
<code style={{ fontFamily: 'var(--gn-font-mono)' }}>{MCP_COMMAND_EXAMPLES.join(' / ')}</code>
</div>
</div>
<div style={{ padding: '12px 14px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ ...labelStyle, color: overlayTheme.titleText }}></div>
<div style={hintStyle(overlayTheme.mutedText)}>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{MCP_SERVER_FILL_STEPS.map((item) => (
<span
key={item.step}
style={{
padding: '4px 10px',
borderRadius: 999,
fontSize: 12,
color: overlayTheme.titleText,
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(15,23,42,0.05)',
}}
>
{item.step}. {item.title}
</span>
))}
</div>
</div>
<div style={{ padding: '12px 14px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)', display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ ...labelStyle, color: overlayTheme.titleText }}></div>
<div style={hintStyle(overlayTheme.mutedText)}>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', gap: 10 }}>
{MCP_FIELD_GUIDES.map((item) => {
const tone = buildFieldTone(item.fieldState, darkMode);
return (
<div
key={item.key}
style={{
padding: '10px 12px',
borderRadius: 10,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.78)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: overlayTheme.titleText }}>{item.title}</div>
<span
style={{
padding: '2px 8px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: tone.color,
background: tone.bg,
}}
>
{tone.label}
</span>
</div>
<div style={{ fontSize: 12, lineHeight: 1.6, color: overlayTheme.titleText }}>{item.summary}</div>
<div style={hintStyle(overlayTheme.mutedText)}>{item.detail}</div>
{item.example ? (
<div style={hintStyle(overlayTheme.mutedText)}>
{' '}
<code style={{ fontFamily: 'var(--gn-font-mono)' }}>{item.example}</code>
</div>
) : null}
</div>
);
})}
</div>
</div>
<div style={{ padding: '12px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ ...labelStyle, color: overlayTheme.titleText }}></div>
<div style={hintStyle(overlayTheme.mutedText)}>
GoNavi / / README
</div>
<Input.TextArea
rows={2}
value={rawCommandDraft}
onChange={(event) => setRawCommandDraft(event.target.value)}
placeholder={`直接粘贴完整命令,例如:\n${MCP_COMMAND_PARSE_EXAMPLE}`}
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)' }}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ ...hintStyle(parsedCommandDraft.ok ? overlayTheme.mutedText : '#dc2626') }}>
{rawCommandDraft.trim()
? parsedCommandDraft.ok && parsedCommandDraft.draft
? `将解析为:命令 ${parsedCommandDraft.draft.command},参数 ${parsedCommandDraft.draft.args.length} 个,环境变量 ${Object.keys(parsedCommandDraft.draft.env).length} 个。`
: parsedCommandDraft.error
: '支持带引号路径、带空格参数,以及命令前缀的 KEY=VALUE 环境变量。'}
</div>
<Button onClick={handleApplyCommandDraft} disabled={!parsedCommandDraft.ok} style={{ borderRadius: 10 }}>
</Button>
</div>
{parsedCommandDraft.ok && parsedCommandDraft.draft && rawCommandDraft.trim() && (
<AIMCPCommandDraftPreview
draft={parsedCommandDraft.draft}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBorder={cardBorder}
/>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 132px', gap: 12 }}>
<MCPHelpBlock 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}` }}
/>
</MCPHelpBlock>
<MCPHelpBlock 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' }]}
/>
</MCPHelpBlock>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '132px minmax(0,1fr) 132px', gap: 12 }}>
<MCPHelpBlock 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' }]}
/>
</MCPHelpBlock>
<MCPHelpBlock title="启动命令" description="这里只填命令本身;如果是 node/uvx/python 这类启动器,把脚本名或模块名放到下面的参数里。不要把 node server.js --stdio 整串都塞进这里。" overlayTheme={overlayTheme} darkMode={darkMode} fieldState="required" example="node / uvx / python">
<Input
value={server.command}
onChange={(event) => onChange({ command: event.target.value })}
placeholder="启动命令例如node / uvx / python"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
</MCPHelpBlock>
<MCPHelpBlock 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>
</MCPHelpBlock>
</div>
<MCPHelpBlock title="命令参数" description="每个参数单独录入一个标签;命令本体不要填在这里。比如 node server.js --stdio要把 server.js 和 --stdio 分开填。不确定怎么拆时,优先回到上面的“完整命令”框自动拆分。" overlayTheme={overlayTheme} darkMode={darkMode} fieldState="optional" example="server.js、--stdio、-m、your_mcp_server">
<Select
mode="tags"
value={server.args || []}
onChange={(value) => onChange({ args: value })}
placeholder="命令参数回车录入例如server.js、--stdio"
style={{ width: '100%' }}
/>
</MCPHelpBlock>
{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={{ ...labelStyle, color: overlayTheme.titleText }}></div>
<div style={{ ...hintStyle(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>
)}
<MCPHelpBlock title="环境变量" description="每行一个 KEY=VALUE通常用于 API Key、工作目录、服务地址等配置不需要时可以留空。这里只填变量本身不要写 export也不要把它和启动命令混成一整行。" overlayTheme={overlayTheme} darkMode={darkMode} fieldState="optional" example="OPENAI_API_KEY=...">
<Input.TextArea
rows={3}
value={envDraft}
onChange={(event) => {
const nextValue = event.target.value;
setEnvDraft(nextValue);
onChange({ env: parseMCPEnvDraft(nextValue).env });
}}
placeholder={"环境变量,每行一个 KEY=VALUE例如\nOPENAI_API_KEY=...\nGITHUB_TOKEN=..."}
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)' }}
/>
<div style={{ ...hintStyle(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>
</MCPHelpBlock>
{serverTools.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: overlayTheme.titleText }}></div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{serverTools.map((tool) => (
<span key={tool.alias} style={{ padding: '4px 8px', borderRadius: 999, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)', fontSize: 12, color: overlayTheme.mutedText }}>
{tool.alias}
</span>
))}
</div>
</div>
)}
<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={{ ...labelStyle, color: overlayTheme.titleText }}></div>
<div style={{ ...hintStyle(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} style={{ borderRadius: 10 }}></Button>
<Button type="primary" onClick={onSave} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}></Button>
<Popconfirm title="删除这个 MCP 服务?" okText="删除" cancelText="取消" onConfirm={onDelete}>
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 10 }}></Button>
</Popconfirm>
</div>
<AIMCPServerGuidePanel
cardBorder={cardBorder}
inputBg={inputBg}
darkMode={darkMode}
overlayTheme={overlayTheme}
rawCommandDraft={rawCommandDraft}
parsedCommandDraft={parsedCommandDraft}
onApplyCommandDraft={handleApplyCommandDraft}
onRawCommandDraftChange={setRawCommandDraft}
/>
<AIMCPServerFormPanel
server={server}
serverTools={serverTools}
launchPreview={launchPreview}
envDraft={envDraft}
parsedEnvDraft={parsedEnvDraft}
cardBorder={cardBorder}
inputBg={inputBg}
darkMode={darkMode}
overlayTheme={overlayTheme}
loading={loading}
onChange={onChange}
onEnvDraftChange={handleEnvDraftChange}
onTest={onTest}
onSave={onSave}
onDelete={onDelete}
/>
</div>
);
};

View File

@@ -0,0 +1,194 @@
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 AIMCPHelpBlock, { buildMCPHintStyle, mcpLabelStyle } from './AIMCPHelpBlock';
interface AIMCPServerFormPanelProps {
server: AIMCPServerConfig;
serverTools: AIMCPToolDescriptor[];
launchPreview: string;
envDraft: string;
parsedEnvDraft: ParsedMCPEnvDraft;
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,
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="这里只填命令本身;如果是 node/uvx/python 这类启动器,把脚本名或模块名放到下面的参数里。不要把 node server.js --stdio 整串都塞进这里。" overlayTheme={overlayTheme} darkMode={darkMode} fieldState="required" example="node / uvx / python">
<Input
value={server.command}
onChange={(event) => onChange({ command: event.target.value })}
placeholder="启动命令例如node / uvx / python"
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="每个参数单独录入一个标签;命令本体不要填在这里。比如 node server.js --stdio要把 server.js 和 --stdio 分开填。不确定怎么拆时,优先回到上面的“完整命令”框自动拆分。" overlayTheme={overlayTheme} darkMode={darkMode} fieldState="optional" example="server.js、--stdio、-m、your_mcp_server">
<Select
mode="tags"
value={server.args || []}
onChange={(value) => onChange({ args: value })}
placeholder="命令参数回车录入例如server.js、--stdio"
style={{ width: '100%' }}
/>
</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、工作目录、服务地址等配置不需要时可以留空。这里只填变量本身不要写 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>
</AIMCPHelpBlock>
{serverTools.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: overlayTheme.titleText }}></div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{serverTools.map((tool) => (
<span key={tool.alias} style={{ padding: '4px 8px', borderRadius: 999, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)', fontSize: 12, color: overlayTheme.mutedText }}>
{tool.alias}
</span>
))}
</div>
</div>
)}
<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} style={{ borderRadius: 10 }}></Button>
<Button type="primary" onClick={onSave} loading={loading} 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;

View File

@@ -0,0 +1,156 @@
import React from 'react';
import { Button, Input } from 'antd';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { ParseMCPCommandDraftResult } from '../../utils/mcpCommandDraft';
import {
MCP_COMMAND_EXAMPLES,
MCP_COMMAND_PARSE_EXAMPLE,
MCP_FIELD_GUIDES,
MCP_SERVER_FILL_STEPS,
} from '../../utils/mcpServerGuidance';
import AIMCPCommandDraftPreview from './AIMCPCommandDraftPreview';
import { buildMCPFieldTone, buildMCPHintStyle, mcpLabelStyle } from './AIMCPHelpBlock';
interface AIMCPServerGuidePanelProps {
cardBorder: string;
inputBg: string;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
rawCommandDraft: string;
parsedCommandDraft: ParseMCPCommandDraftResult;
onApplyCommandDraft: () => void;
onRawCommandDraftChange: (value: string) => void;
}
const AIMCPServerGuidePanel: React.FC<AIMCPServerGuidePanelProps> = ({
cardBorder,
inputBg,
darkMode,
overlayTheme,
rawCommandDraft,
parsedCommandDraft,
onApplyCommandDraft,
onRawCommandDraftChange,
}) => (
<>
<div style={{ padding: '10px 12px', borderRadius: 10, border: `1px dashed ${cardBorder}`, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.7)' }}>
<div style={{ ...mcpLabelStyle, color: overlayTheme.titleText }}></div>
<div style={{ ...buildMCPHintStyle(overlayTheme.mutedText), marginTop: 4 }}>
{' '}
<code style={{ fontFamily: 'var(--gn-font-mono)' }}>{MCP_COMMAND_EXAMPLES.join(' / ')}</code>
</div>
</div>
<div style={{ padding: '12px 14px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ ...mcpLabelStyle, color: overlayTheme.titleText }}></div>
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{MCP_SERVER_FILL_STEPS.map((item) => (
<span
key={item.step}
style={{
padding: '4px 10px',
borderRadius: 999,
fontSize: 12,
color: overlayTheme.titleText,
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(15,23,42,0.05)',
}}
>
{item.step}. {item.title}
</span>
))}
</div>
</div>
<div style={{ padding: '12px 14px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)', display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ ...mcpLabelStyle, color: overlayTheme.titleText }}></div>
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', gap: 10 }}>
{MCP_FIELD_GUIDES.map((item) => {
const tone = buildMCPFieldTone(item.fieldState, darkMode);
return (
<div
key={item.key}
style={{
padding: '10px 12px',
borderRadius: 10,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.78)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: overlayTheme.titleText }}>{item.title}</div>
<span
style={{
padding: '2px 8px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: tone.color,
background: tone.bg,
}}
>
{tone.label}
</span>
</div>
<div style={{ fontSize: 12, lineHeight: 1.6, color: overlayTheme.titleText }}>{item.summary}</div>
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>{item.detail}</div>
{item.example ? (
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>
{' '}
<code style={{ fontFamily: 'var(--gn-font-mono)' }}>{item.example}</code>
</div>
) : null}
</div>
);
})}
</div>
</div>
<div style={{ padding: '12px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ ...mcpLabelStyle, color: overlayTheme.titleText }}></div>
<div style={buildMCPHintStyle(overlayTheme.mutedText)}>
GoNavi / / README
</div>
<Input.TextArea
rows={2}
value={rawCommandDraft}
onChange={(event) => onRawCommandDraftChange(event.target.value)}
placeholder={`直接粘贴完整命令,例如:\n${MCP_COMMAND_PARSE_EXAMPLE}`}
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)' }}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ ...buildMCPHintStyle(parsedCommandDraft.ok ? overlayTheme.mutedText : '#dc2626') }}>
{rawCommandDraft.trim()
? parsedCommandDraft.ok && parsedCommandDraft.draft
? `将解析为:命令 ${parsedCommandDraft.draft.command},参数 ${parsedCommandDraft.draft.args.length} 个,环境变量 ${Object.keys(parsedCommandDraft.draft.env).length} 个。`
: parsedCommandDraft.error
: '支持带引号路径、带空格参数,以及命令前缀的 KEY=VALUE 环境变量。'}
</div>
<Button onClick={onApplyCommandDraft} disabled={!parsedCommandDraft.ok} style={{ borderRadius: 10 }}>
</Button>
</div>
{parsedCommandDraft.ok && parsedCommandDraft.draft && rawCommandDraft.trim() && (
<AIMCPCommandDraftPreview
draft={parsedCommandDraft.draft}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBorder={cardBorder}
/>
)}
</div>
</>
);
export default AIMCPServerGuidePanel;