diff --git a/frontend/src/components/AISettingsModal.edit-password.test.tsx b/frontend/src/components/AISettingsModal.edit-password.test.tsx index ad58361..3d86800 100644 --- a/frontend/src/components/AISettingsModal.edit-password.test.tsx +++ b/frontend/src/components/AISettingsModal.edit-password.test.tsx @@ -14,7 +14,8 @@ describe('AISettingsModal edit password behavior', () => { expect(source).toContain("callOrFallback(() => Service.AIGetUserPromptSettings?.(), EMPTY_AI_USER_PROMPT_SETTINGS)"); expect(source).toContain('await Service?.AISaveUserPromptSettings?.(payload);'); expect(source).toContain("window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'))"); - expect(source).toContain('保存自定义提示词'); + expect(source).toContain("import AISettingsPromptsSection from './ai/AISettingsPromptsSection';"); + expect(source).toContain(' { diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 354eb9d..a418269 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -24,6 +24,7 @@ import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { BUILTIN_AI_TOOL_INFO } from '../utils/aiToolRegistry'; import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog'; import AISettingsMCPSection, { type MCPClientKey } from './ai/AISettingsMCPSection'; +import AISettingsPromptsSection from './ai/AISettingsPromptsSection'; import AISettingsSkillsSection from './ai/AISettingsSkillsSection'; interface AISettingsModalProps { open: boolean; @@ -1096,104 +1097,6 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo ); - const renderPromptSettings = () => ( -
-
-
- 用户级自定义提示词 -
-
- 这里的内容会在系统内置提示词之后,以 system message 的形式追加注入。 - 适合放你的个人风格偏好、输出约束、团队规范。涉及安全红线时,系统规则仍然优先。 -
- - {[ - { - key: 'global', - title: '全局补充提示词', - desc: '对所有 AI 会话生效,例如“先给结论”“回答保持简洁”。', - rows: 4, - }, - { - key: 'database', - title: '数据库会话补充提示词', - desc: '仅数据库/SQL 场景生效,例如“生成 SQL 前必须先确认字段名”。', - rows: 5, - }, - { - key: 'jvm', - title: 'JVM 资源分析补充提示词', - desc: '仅 JVM 资源浏览/分析场景生效。', - rows: 4, - }, - { - key: 'jvmDiagnostic', - title: 'JVM 诊断补充提示词', - desc: '仅 JVM 诊断工作台生效,例如“先给计划,再给命令”。', - rows: 4, - }, - ].map((item) => ( -
-
- {item.title} -
-
- {item.desc} -
- setUserPromptSettings((prev) => ({ - ...prev, - [item.key]: event.target.value, - }))} - placeholder="留空表示不额外追加" - style={{ - borderRadius: 10, - background: inputBg, - border: `1px solid ${cardBorder}`, - fontFamily: 'var(--gn-font-mono)', - resize: 'vertical', - }} - /> -
- ))} - -
- -
-
- -
- 以下为当前版本 GoNavi 预设的底层 AI 提示词(只读)。它们会先于上面的用户级提示词注入到对应场景的请求上下文中。 -
- {Object.entries(builtinPrompts).map(([title, promptText]) => ( -
-
- {title} -
-
- {promptText} -
-
- ))} -
- ); - const modalShellStyle = { background: overlayTheme.shellBg, border: overlayTheme.shellBorder, boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter, @@ -1327,7 +1230,23 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo cardBorder={cardBorder} /> )} - {activeSection === 'prompts' && renderPromptSettings()} + {activeSection === 'prompts' && ( + setUserPromptSettings((prev) => ({ + ...prev, + [key]: value, + }))} + onSave={handleSaveUserPromptSettings} + /> + )} diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx index 613199c..cc6e6d1 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx @@ -22,6 +22,8 @@ describe('AIBuiltinToolsCatalog', () => { expect(markup).toContain('get_indexes'); expect(markup).toContain('get_foreign_keys'); expect(markup).toContain('get_triggers'); + expect(markup).toContain('一键结构快照'); + expect(markup).toContain('inspect_table_bundle'); expect(markup).toContain('理解样例数据'); expect(markup).toContain('preview_table_rows'); }); diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx index 9faf6c3..7e6d1d5 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx @@ -27,6 +27,11 @@ const BUILTIN_TOOL_FLOWS = [ steps: 'get_columns → get_indexes → get_foreign_keys → get_triggers → get_table_ddl', description: '适合做索引优化、关系梳理、隐式副作用排查和 DDL 审查。', }, + { + title: '一键结构快照', + steps: 'inspect_table_bundle', + description: '适合一次带回字段、索引、外键、触发器和 DDL;必要时还能附带样例行,减少来回调用。', + }, { title: '理解样例数据', steps: 'get_columns → preview_table_rows', diff --git a/frontend/src/components/ai/AIMCPServerCard.test.tsx b/frontend/src/components/ai/AIMCPServerCard.test.tsx index e255354..8d30a13 100644 --- a/frontend/src/components/ai/AIMCPServerCard.test.tsx +++ b/frontend/src/components/ai/AIMCPServerCard.test.tsx @@ -34,6 +34,8 @@ describe('AIMCPServerCard', () => { ); expect(markup).toContain('启动命令只填可执行程序本身'); + expect(markup).toContain('直接粘贴完整命令'); + expect(markup).toContain('自动拆分到下方字段'); expect(markup).toContain('每个参数单独录入一个标签'); expect(markup).toContain('每行一个 KEY=VALUE'); expect(markup).toContain('当前阶段只支持 stdio'); diff --git a/frontend/src/components/ai/AIMCPServerCard.tsx b/frontend/src/components/ai/AIMCPServerCard.tsx index ba3098d..f268321 100644 --- a/frontend/src/components/ai/AIMCPServerCard.tsx +++ b/frontend/src/components/ai/AIMCPServerCard.tsx @@ -4,6 +4,7 @@ import { DeleteOutlined } from '@ant-design/icons'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import type { AIMCPServerConfig, AIMCPToolDescriptor } from '../../types'; +import { parseMCPCommandDraft } from '../../utils/mcpCommandDraft'; interface AIMCPServerCardProps { server: AIMCPServerConfig; @@ -86,7 +87,20 @@ export const AIMCPServerCard: React.FC = ({ onSave, onDelete, }) => { + const [rawCommandDraft, setRawCommandDraft] = React.useState(''); const launchPreview = formatLaunchPreview(server.command, server.args); + const parsedCommandDraft = parseMCPCommandDraft(rawCommandDraft); + + const handleApplyCommandDraft = () => { + if (!parsedCommandDraft.ok || !parsedCommandDraft.draft) { + return; + } + onChange({ + command: parsedCommandDraft.draft.command, + args: parsedCommandDraft.draft.args, + env: parsedCommandDraft.draft.env, + }); + }; return (
@@ -99,6 +113,32 @@ export const AIMCPServerCard: React.FC = ({
+
+
只有一条完整命令?
+
+ 直接粘贴完整命令,GoNavi 会自动拆成“启动命令 / 命令参数 / 环境变量”三块,适合你只拿到 README 里的一整行示例时快速录入。 +
+ setRawCommandDraft(event.target.value)} + placeholder={"直接粘贴完整命令,例如:\nOPENAI_API_KEY=... uvx mcp-server-fetch --stdio"} + style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)' }} + /> +
+
+ {rawCommandDraft.trim() + ? parsedCommandDraft.ok && parsedCommandDraft.draft + ? `将解析为:命令 ${parsedCommandDraft.draft.command},参数 ${parsedCommandDraft.draft.args.length} 个,环境变量 ${Object.keys(parsedCommandDraft.draft.env).length} 个。` + : parsedCommandDraft.error + : '支持带引号路径、带空格参数,以及命令前缀的 KEY=VALUE 环境变量。'} +
+ +
+
+
{ + it('renders editable user prompts and readonly builtin prompt blocks after extraction', () => { + const markup = renderToStaticMarkup( + {}} + onSave={() => {}} + />, + ); + + expect(markup).toContain('用户级自定义提示词'); + expect(markup).toContain('全局补充提示词'); + expect(markup).toContain('保存自定义提示词'); + expect(markup).toContain('数据库'); + expect(markup).toContain('生成 SQL 前必须先确认字段名'); + }); +}); diff --git a/frontend/src/components/ai/AISettingsPromptsSection.tsx b/frontend/src/components/ai/AISettingsPromptsSection.tsx new file mode 100644 index 0000000..2a1c2a3 --- /dev/null +++ b/frontend/src/components/ai/AISettingsPromptsSection.tsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { Button, Input } from 'antd'; +import { RobotOutlined } from '@ant-design/icons'; + +import type { AIUserPromptSettings } from '../../types'; +import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; + +interface AISettingsPromptsSectionProps { + builtinPrompts: Record; + userPromptSettings: AIUserPromptSettings; + overlayTheme: OverlayWorkbenchTheme; + cardBg: string; + cardBorder: string; + inputBg: string; + darkMode: boolean; + loading: boolean; + onChangeUserPrompt: (key: keyof AIUserPromptSettings, value: string) => void; + onSave: () => void; +} + +const USER_PROMPT_FIELDS: Array<{ + key: keyof AIUserPromptSettings; + title: string; + desc: string; + rows: number; +}> = [ + { + key: 'global', + title: '全局补充提示词', + desc: '对所有 AI 会话生效,例如“先给结论”“回答保持简洁”。', + rows: 4, + }, + { + key: 'database', + title: '数据库会话补充提示词', + desc: '仅数据库/SQL 场景生效,例如“生成 SQL 前必须先确认字段名”。', + rows: 5, + }, + { + key: 'jvm', + title: 'JVM 资源分析补充提示词', + desc: '仅 JVM 资源浏览/分析场景生效。', + rows: 4, + }, + { + key: 'jvmDiagnostic', + title: 'JVM 诊断补充提示词', + desc: '仅 JVM 诊断工作台生效,例如“先给计划,再给命令”。', + rows: 4, + }, +]; + +const AISettingsPromptsSection: React.FC = ({ + builtinPrompts, + userPromptSettings, + overlayTheme, + cardBg, + cardBorder, + inputBg, + darkMode, + loading, + onChangeUserPrompt, + onSave, +}) => ( +
+
+
+ 用户级自定义提示词 +
+
+ 这里的内容会在系统内置提示词之后,以 system message 的形式追加注入。 + 适合放你的个人风格偏好、输出约束、团队规范。涉及安全红线时,系统规则仍然优先。 +
+ + {USER_PROMPT_FIELDS.map((item) => ( +
+
+ {item.title} +
+
+ {item.desc} +
+ onChangeUserPrompt(item.key, event.target.value)} + placeholder="留空表示不额外追加" + style={{ + borderRadius: 10, + background: inputBg, + border: `1px solid ${cardBorder}`, + fontFamily: 'var(--gn-font-mono)', + resize: 'vertical', + }} + /> +
+ ))} + +
+ +
+
+ +
+ 以下为当前版本 GoNavi 预设的底层 AI 提示词(只读)。它们会先于上面的用户级提示词注入到对应场景的请求上下文中。 +
+ {Object.entries(builtinPrompts).map(([title, promptText]) => ( +
+
+ {title} +
+
+ {promptText} +
+
+ ))} +
+); + +export default AISettingsPromptsSection; diff --git a/frontend/src/components/ai/aiLocalToolExecutor.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.test.ts index 2b96b39..333dd43 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.test.ts @@ -193,4 +193,55 @@ describe('aiLocalToolExecutor', () => { expect(result.content).toContain('"status":"paid"'); expect(result.content).toContain('"rowCount":2'); }); + + it('returns a full table snapshot bundle with optional sample rows in one tool call', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_table_bundle', { + connectionId: 'conn-1', + dbName: 'crm', + tableName: 'orders', + includeSampleRows: true, + sampleLimit: 2, + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + getColumns: vi.fn().mockResolvedValue({ + success: true, + data: [{ Field: 'id', Type: 'bigint', Null: 'NO', Comment: '主键' }], + }), + getIndexes: vi.fn().mockResolvedValue({ + success: true, + data: [{ keyName: 'PRIMARY', seqInIndex: 1 }], + }), + getForeignKeys: vi.fn().mockResolvedValue({ + success: true, + data: [{ columnName: 'user_id', refTable: 'users' }], + }), + getTriggers: vi.fn().mockResolvedValue({ + success: true, + data: [{ triggerName: 'orders_bi' }], + }), + showCreateTable: vi.fn().mockResolvedValue({ + success: true, + data: [{ ddl: 'CREATE TABLE orders (...)' }], + }), + query: vi.fn().mockResolvedValue({ + success: true, + data: [{ id: 1, status: 'paid' }, { id: 2, status: 'pending' }], + }), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"tableName":"orders"'); + expect(result.content).toContain('"field":"id"'); + expect(result.content).toContain('"keyName":"PRIMARY"'); + expect(result.content).toContain('"triggerName":"orders_bi"'); + expect(result.content).toContain('"sampleRows"'); + expect(result.content).toContain('"status":"paid"'); + }); }); diff --git a/frontend/src/components/ai/aiLocalToolExecutor.ts b/frontend/src/components/ai/aiLocalToolExecutor.ts index 67eb4ce..878e0ef 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.ts @@ -123,6 +123,17 @@ const normalizePreviewLimit = (input: unknown): number => { return value; }; +const buildPreviewSQLForTable = (connection: SavedConnection, tableName: string, limit: number): string => { + const dbType = String(connection.config?.type || '').trim(); + return buildPaginatedSelectSQL( + dbType, + `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)}`, + '', + limit, + 0, + ); +}; + export async function executeLocalAIToolCall({ toolCall, connections, @@ -339,6 +350,109 @@ export async function executeLocalAIToolCall({ } break; } + case 'inspect_table_bundle': { + const connection = findConnection(connections, args.connectionId); + if (!connection) { + content = 'Connection not found'; + break; + } + try { + const safeDbName = args.dbName ? String(args.dbName).trim() : ''; + const safeTable = args.tableName ? String(args.tableName).trim() : ''; + if (!safeTable) { + content = 'tableName 不能为空'; + break; + } + const includeSampleRows = args.includeSampleRows === true; + const sampleLimit = normalizePreviewLimit(args.sampleLimit ?? 10); + const rpcConfig = buildRpcConnectionConfig(connection.config) as any; + const results = await Promise.allSettled([ + mergedRuntime.getColumns(rpcConfig, safeDbName, safeTable), + mergedRuntime.getIndexes(rpcConfig, safeDbName, safeTable), + mergedRuntime.getForeignKeys(rpcConfig, safeDbName, safeTable), + mergedRuntime.getTriggers(rpcConfig, safeDbName, safeTable), + resolveAITableSchemaToolResult({ + tableName: safeTable, + fetchDDL: () => mergedRuntime.showCreateTable(rpcConfig, safeDbName, safeTable), + fetchColumns: () => mergedRuntime.getColumns(rpcConfig, safeDbName, safeTable), + }), + includeSampleRows + ? mergedRuntime.query(rpcConfig, safeDbName, buildPreviewSQLForTable(connection, safeTable, sampleLimit)) + : Promise.resolve(undefined), + ]); + + const warnings: string[] = []; + const columnsResult = results[0]; + const indexesResult = results[1]; + const foreignKeysResult = results[2]; + const triggersResult = results[3]; + const ddlResult = results[4]; + const sampleRowsResult = results[5]; + + const payload: Record = { + dbName: safeDbName, + tableName: safeTable, + columns: [], + indexes: [], + foreignKeys: [], + triggers: [], + ddl: '', + }; + + if (columnsResult.status === 'fulfilled' && columnsResult.value?.success && Array.isArray(columnsResult.value.data)) { + payload.columns = normalizeColumns(columnsResult.value.data); + } else { + warnings.push(`字段列表获取失败:${columnsResult.status === 'fulfilled' ? (columnsResult.value?.message || '未知错误') : String(columnsResult.reason)}`); + } + + if (indexesResult.status === 'fulfilled' && indexesResult.value?.success && Array.isArray(indexesResult.value.data)) { + payload.indexes = indexesResult.value.data; + } else { + warnings.push(`索引定义获取失败:${indexesResult.status === 'fulfilled' ? (indexesResult.value?.message || '未知错误') : String(indexesResult.reason)}`); + } + + if (foreignKeysResult.status === 'fulfilled' && foreignKeysResult.value?.success && Array.isArray(foreignKeysResult.value.data)) { + payload.foreignKeys = foreignKeysResult.value.data; + } else { + warnings.push(`外键关系获取失败:${foreignKeysResult.status === 'fulfilled' ? (foreignKeysResult.value?.message || '未知错误') : String(foreignKeysResult.reason)}`); + } + + if (triggersResult.status === 'fulfilled' && triggersResult.value?.success && Array.isArray(triggersResult.value.data)) { + payload.triggers = triggersResult.value.data; + } else { + warnings.push(`触发器获取失败:${triggersResult.status === 'fulfilled' ? (triggersResult.value?.message || '未知错误') : String(triggersResult.reason)}`); + } + + if (ddlResult.status === 'fulfilled' && ddlResult.value?.success) { + payload.ddl = ddlResult.value.content; + } else { + warnings.push(`DDL 获取失败:${ddlResult.status === 'fulfilled' ? (ddlResult.value?.content || '未知错误') : String(ddlResult.reason)}`); + } + + if (includeSampleRows) { + if (sampleRowsResult.status === 'fulfilled' && sampleRowsResult.value?.success) { + const rows = Array.isArray(sampleRowsResult.value.data) ? sampleRowsResult.value.data : []; + payload.sampleRows = { + limit: sampleLimit, + rowCount: rows.length, + rows: rows.slice(0, sampleLimit), + }; + } else { + warnings.push(`样例数据获取失败:${sampleRowsResult.status === 'fulfilled' ? (sampleRowsResult.value?.message || '未知错误') : String(sampleRowsResult.reason)}`); + } + } + + if (warnings.length > 0) { + payload.warnings = warnings; + } + + content = JSON.stringify(payload); + success = true; + } catch (error: any) { + content = `获取表结构快照失败: ${error?.message || error}`; + } + break; + } case 'preview_table_rows': { const connection = findConnection(connections, args.connectionId); if (!connection) { @@ -353,14 +467,7 @@ export async function executeLocalAIToolCall({ break; } const safeLimit = normalizePreviewLimit(args.limit); - const dbType = String(connection.config?.type || '').trim(); - const previewSQL = buildPaginatedSelectSQL( - dbType, - `SELECT * FROM ${quoteQualifiedIdent(dbType, safeTable)}`, - '', - safeLimit, - 0, - ); + const previewSQL = buildPreviewSQLForTable(connection, safeTable, safeLimit); const result = await mergedRuntime.query(buildRpcConnectionConfig(connection.config) as any, safeDbName, previewSQL); if (result?.success) { const rows = Array.isArray(result.data) ? result.data : []; diff --git a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx index dbcc2fa..d698dfe 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx @@ -31,6 +31,7 @@ const TOOL_ACTION_LABELS: Record = { get_foreign_keys: '梳理外键关系', get_triggers: '检查触发器逻辑', get_table_ddl: '提取建表语句', + inspect_table_bundle: '抓取完整表结构快照', execute_sql: '执行只读 SQL 验证', }; diff --git a/frontend/src/utils/aiToolRegistry.ts b/frontend/src/utils/aiToolRegistry.ts index 04a885e..88efee0 100644 --- a/frontend/src/utils/aiToolRegistry.ts +++ b/frontend/src/utils/aiToolRegistry.ts @@ -255,6 +255,33 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [ }, }, }, + { + name: "inspect_table_bundle", + icon: "🧰", + desc: "一次抓取指定表的结构快照", + detail: + "传入 connectionId、dbName 和 tableName,返回字段、索引、外键、触发器和 DDL;还可以附带前几行样例数据。适合在写 SQL、评审表设计或排查副作用前先做完整摸底。", + params: "connectionId, dbName, tableName, includeSampleRows?, sampleLimit?", + tool: { + type: "function", + function: { + name: "inspect_table_bundle", + description: + "一次性获取指定表的结构快照,返回字段、索引、外键、触发器、DDL,以及可选样例数据。适用于做完整表设计摸底、快速理解表关系和降低模型多次往返调用。", + parameters: { + type: "object", + properties: { + connectionId: { type: "string", description: "连接ID" }, + dbName: { type: "string", description: "数据库名" }, + tableName: { type: "string", description: "表名" }, + includeSampleRows: { type: "boolean", description: "可选,是否附带前几行样例数据" }, + sampleLimit: { type: "number", description: "可选,样例行数,默认 10,最大 100" }, + }, + required: ["connectionId", "dbName", "tableName"], + }, + }, + }, + }, { name: "execute_sql", icon: "▶️", diff --git a/frontend/src/utils/mcpCommandDraft.test.ts b/frontend/src/utils/mcpCommandDraft.test.ts new file mode 100644 index 0000000..58a7103 --- /dev/null +++ b/frontend/src/utils/mcpCommandDraft.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { parseMCPCommandDraft, splitShellLikeCommand } from './mcpCommandDraft'; + +describe('mcpCommandDraft helpers', () => { + it('splits quoted command lines and leading env assignments into dedicated fields', () => { + const result = parseMCPCommandDraft('OPENAI_API_KEY="abc 123" "C:\\Program Files\\GoNavi\\gonavi-mcp-server.exe" stdio --port 8811'); + + expect(result).toEqual({ + ok: true, + draft: { + command: 'C:\\Program Files\\GoNavi\\gonavi-mcp-server.exe', + args: ['stdio', '--port', '8811'], + env: { + OPENAI_API_KEY: 'abc 123', + }, + }, + }); + }); + + it('keeps python module style launches as command plus independent args', () => { + const result = parseMCPCommandDraft('PYTHONPATH=./tools python -m my_mcp_server --stdio'); + + expect(result.ok).toBe(true); + expect(result.draft).toEqual({ + command: 'python', + args: ['-m', 'my_mcp_server', '--stdio'], + env: { + PYTHONPATH: './tools', + }, + }); + }); + + it('reports unclosed quotes instead of producing a broken parse', () => { + expect(splitShellLikeCommand('uvx "broken command')).toEqual({ + tokens: ['uvx'], + error: '命令中存在未闭合的引号,请检查后重试。', + }); + }); +}); diff --git a/frontend/src/utils/mcpCommandDraft.ts b/frontend/src/utils/mcpCommandDraft.ts new file mode 100644 index 0000000..c36b978 --- /dev/null +++ b/frontend/src/utils/mcpCommandDraft.ts @@ -0,0 +1,123 @@ +export interface ParsedMCPCommandDraft { + command: string; + args: string[]; + env: Record; +} + +export interface ParseMCPCommandDraftResult { + ok: boolean; + draft?: ParsedMCPCommandDraft; + error?: string; +} + +const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=.*/u; + +const pushToken = (tokens: string[], current: string) => { + if (current) { + tokens.push(current); + } +}; + +export const splitShellLikeCommand = (input: string): { tokens: string[]; error?: string } => { + const text = String(input || '').trim(); + if (!text) { + return { tokens: [] }; + } + + const tokens: string[] = []; + let current = ''; + let quoteMode: '"' | "'" | null = null; + + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + + if (quoteMode) { + if (char === quoteMode) { + quoteMode = null; + continue; + } + if (char === '\\' && quoteMode === '"' && index + 1 < text.length) { + const nextChar = text[index + 1]; + if (nextChar === '"' || nextChar === '\\') { + current += nextChar; + index += 1; + continue; + } + } + current += char; + continue; + } + + if (char === '"' || char === "'") { + quoteMode = char; + continue; + } + + if (/\s/u.test(char)) { + pushToken(tokens, current); + current = ''; + continue; + } + + if (char === '\\' && index + 1 < text.length) { + const nextChar = text[index + 1]; + if (/\s/u.test(nextChar) || nextChar === '"' || nextChar === "'" || nextChar === '\\') { + current += nextChar; + index += 1; + continue; + } + } + + current += char; + } + + if (quoteMode) { + return { + tokens, + error: '命令中存在未闭合的引号,请检查后重试。', + }; + } + + pushToken(tokens, current); + return { tokens }; +}; + +export const parseMCPCommandDraft = (input: string): ParseMCPCommandDraftResult => { + const { tokens, error } = splitShellLikeCommand(input); + if (error) { + return { ok: false, error }; + } + if (tokens.length === 0) { + return { ok: false, error: '请先粘贴完整命令。' }; + } + + const env: Record = {}; + let commandIndex = 0; + + while (commandIndex < tokens.length && ENV_ASSIGNMENT_RE.test(tokens[commandIndex])) { + const token = tokens[commandIndex]; + const separatorIndex = token.indexOf('='); + const key = token.slice(0, separatorIndex).trim(); + if (key) { + env[key] = token.slice(separatorIndex + 1); + } + commandIndex += 1; + } + + const command = String(tokens[commandIndex] || '').trim(); + if (!command) { + return { + ok: false, + error: '没有解析出启动命令,请至少提供可执行程序名。', + }; + } + + return { + ok: true, + draft: { + command, + args: tokens.slice(commandIndex + 1), + env, + }, + }; +};