From 1a2462ef1748d9fc7244198f8c52d2661a53243f Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 8 Jun 2026 09:29:40 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(ai-settings):=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3MCP=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=E5=BD=95?= =?UTF-8?q?=E5=85=A5=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽离环境变量草稿解析工具,区分有效项和无效行 - 保留用户原始输入,避免无效行被静默吞掉 - 在 MCP 服务卡片中显示识别数量与无效行提示 - 补充环境变量解析与卡片提示测试 --- .../components/ai/AIMCPServerCard.test.tsx | 1 + .../src/components/ai/AIMCPServerCard.tsx | 40 ++++++++-------- frontend/src/utils/mcpEnvDraft.test.ts | 31 ++++++++++++ frontend/src/utils/mcpEnvDraft.ts | 47 +++++++++++++++++++ 4 files changed, 100 insertions(+), 19 deletions(-) create mode 100644 frontend/src/utils/mcpEnvDraft.test.ts create mode 100644 frontend/src/utils/mcpEnvDraft.ts diff --git a/frontend/src/components/ai/AIMCPServerCard.test.tsx b/frontend/src/components/ai/AIMCPServerCard.test.tsx index 8d30a13..9cf344a 100644 --- a/frontend/src/components/ai/AIMCPServerCard.test.tsx +++ b/frontend/src/components/ai/AIMCPServerCard.test.tsx @@ -38,6 +38,7 @@ describe('AIMCPServerCard', () => { expect(markup).toContain('自动拆分到下方字段'); expect(markup).toContain('每个参数单独录入一个标签'); expect(markup).toContain('每行一个 KEY=VALUE'); + expect(markup).toContain('没有等号或 key 含空格的行不会保存'); expect(markup).toContain('当前阶段只支持 stdio'); expect(markup).toContain('实际启动命令预览'); expect(markup).toContain('node server.js --stdio'); diff --git a/frontend/src/components/ai/AIMCPServerCard.tsx b/frontend/src/components/ai/AIMCPServerCard.tsx index f268321..55ccf89 100644 --- a/frontend/src/components/ai/AIMCPServerCard.tsx +++ b/frontend/src/components/ai/AIMCPServerCard.tsx @@ -5,6 +5,7 @@ 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'; interface AIMCPServerCardProps { server: AIMCPServerConfig; @@ -88,13 +89,20 @@ export const AIMCPServerCard: React.FC = ({ onDelete, }) => { const [rawCommandDraft, setRawCommandDraft] = React.useState(''); + const [envDraft, setEnvDraft] = React.useState(() => formatMCPEnvDraft(server.env)); const launchPreview = formatLaunchPreview(server.command, server.args); const parsedCommandDraft = parseMCPCommandDraft(rawCommandDraft); + const parsedEnvDraft = parseMCPEnvDraft(envDraft); + + React.useEffect(() => { + setEnvDraft(formatMCPEnvDraft(server.env)); + }, [server.id]); const handleApplyCommandDraft = () => { if (!parsedCommandDraft.ok || !parsedCommandDraft.draft) { return; } + setEnvDraft(formatMCPEnvDraft(parsedCommandDraft.draft.env)); onChange({ command: parsedCommandDraft.draft.command, args: parsedCommandDraft.draft.args, @@ -211,28 +219,22 @@ export const AIMCPServerCard: React.FC = ({ `${key}=${value}`).join('\n')} - onChange={(event) => onChange({ - env: event.target.value - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter(Boolean) - .reduce>((acc, line) => { - const separatorIndex = line.indexOf('='); - if (separatorIndex <= 0) { - return acc; - } - const key = line.slice(0, separatorIndex).trim(); - if (!key) { - return acc; - } - acc[key] = line.slice(separatorIndex + 1); - return acc; - }, {}), - })} + 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)' }} /> +
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 含空格的行不会保存。'} +
{serverTools.length > 0 && ( diff --git a/frontend/src/utils/mcpEnvDraft.test.ts b/frontend/src/utils/mcpEnvDraft.test.ts new file mode 100644 index 0000000..8efac16 --- /dev/null +++ b/frontend/src/utils/mcpEnvDraft.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { formatMCPEnvDraft, parseMCPEnvDraft } from './mcpEnvDraft'; + +describe('mcpEnvDraft helpers', () => { + it('formats env objects into editable KEY=VALUE lines', () => { + expect(formatMCPEnvDraft({ + OPENAI_API_KEY: 'abc', + BASE_URL: 'https://example.com', + })).toBe('OPENAI_API_KEY=abc\nBASE_URL=https://example.com'); + }); + + it('parses valid env lines and preserves invalid ones for warning', () => { + const result = parseMCPEnvDraft([ + 'OPENAI_API_KEY=abc', + 'BAD LINE', + 'HAS SPACE =wrong', + 'EMPTY_VALUE=', + 'BASE_URL=https://example.com?a=1', + ].join('\n')); + + expect(result.env).toEqual({ + OPENAI_API_KEY: 'abc', + EMPTY_VALUE: '', + BASE_URL: 'https://example.com?a=1', + }); + expect(result.validLines).toBe(3); + expect(result.invalidLines).toEqual(['BAD LINE', 'HAS SPACE =wrong']); + expect(result.totalLines).toBe(5); + }); +}); diff --git a/frontend/src/utils/mcpEnvDraft.ts b/frontend/src/utils/mcpEnvDraft.ts new file mode 100644 index 0000000..ac79683 --- /dev/null +++ b/frontend/src/utils/mcpEnvDraft.ts @@ -0,0 +1,47 @@ +export interface ParsedMCPEnvDraft { + env: Record; + invalidLines: string[]; + totalLines: number; + validLines: number; +} + +export const formatMCPEnvDraft = (env?: Record): string => + Object.entries(env || {}) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + +export const parseMCPEnvDraft = (input: string): ParsedMCPEnvDraft => { + const env: Record = {}; + const invalidLines: string[] = []; + let totalLines = 0; + let validLines = 0; + + String(input || '') + .split(/\r?\n/u) + .map((line) => line.trim()) + .forEach((line) => { + if (!line) { + return; + } + totalLines += 1; + const separatorIndex = line.indexOf('='); + if (separatorIndex <= 0) { + invalidLines.push(line); + return; + } + const key = line.slice(0, separatorIndex).trim(); + if (!key || /\s/u.test(key)) { + invalidLines.push(line); + return; + } + env[key] = line.slice(separatorIndex + 1); + validLines += 1; + }); + + return { + env, + invalidLines, + totalLines, + validLines, + }; +};