🐛 fix(ai-settings): 修正MCP环境变量录入反馈

- 抽离环境变量草稿解析工具,区分有效项和无效行
- 保留用户原始输入,避免无效行被静默吞掉
- 在 MCP 服务卡片中显示识别数量与无效行提示
- 补充环境变量解析与卡片提示测试
This commit is contained in:
Syngnat
2026-06-08 09:29:40 +08:00
parent c76b634739
commit 1a2462ef17
4 changed files with 100 additions and 19 deletions

View File

@@ -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');

View File

@@ -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<AIMCPServerCardProps> = ({
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<AIMCPServerCardProps> = ({
<MCPHelpBlock title="环境变量" description="每行一个 KEY=VALUE通常用于 API Key、工作目录、服务地址等配置不需要时可以留空。" overlayTheme={overlayTheme} example="OPENAI_API_KEY=...">
<Input.TextArea
rows={3}
value={Object.entries(server.env || {}).map(([key, value]) => `${key}=${value}`).join('\n')}
onChange={(event) => onChange({
env: event.target.value
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean)
.reduce<Record<string, string>>((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)' }}
/>
<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 && (

View File

@@ -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);
});
});

View File

@@ -0,0 +1,47 @@
export interface ParsedMCPEnvDraft {
env: Record<string, string>;
invalidLines: string[];
totalLines: number;
validLines: number;
}
export const formatMCPEnvDraft = (env?: Record<string, string>): string =>
Object.entries(env || {})
.map(([key, value]) => `${key}=${value}`)
.join('\n');
export const parseMCPEnvDraft = (input: string): ParsedMCPEnvDraft => {
const env: Record<string, string> = {};
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,
};
};