feat(ai-mcp): 补全结构探针并优化客户端接入体验

- 新增 get_indexes、get_foreign_keys、get_triggers 内置工具与 MCP Server 对应实现
- 拆分 AI 设置中的 MCP 接入面板和服务卡片,补充参数提示与客户端状态展示
- 补齐前后端测试与真实页面验证,降低 AI 设置区域的臃肿度
This commit is contained in:
Syngnat
2026-06-07 22:06:24 +08:00
parent 7039eae9c7
commit eff2f7f63a
17 changed files with 1283 additions and 342 deletions

View File

@@ -28,6 +28,13 @@ describe('AIChatPanel message render isolation', () => {
expect(source).toContain('buildAvailableAIChatTools');
});
it('teaches the runtime to use deeper schema tools when analyzing structure details', () => {
expect(source).toContain('get_indexes、get_foreign_keys、get_triggers、get_table_ddl');
expect(source).toContain("case 'get_indexes':");
expect(source).toContain("case 'get_foreign_keys':");
expect(source).toContain("case 'get_triggers':");
});
it('keeps the v2 history mode sorted by the latest updated session first', () => {
expect(source).toContain('const orderedAISessions = useMemo(');
expect(source).toContain('right.updatedAt - left.updatedAt');

View File

@@ -1207,10 +1207,11 @@ ${resourcePath ? `当前资源路径:${resourcePath}` : '当前未选中具体
SQL 生成规则(极重要,必须严格遵守):
7. 【字段精确性 - 绝对红线】生成 SQL 之前,必须先调用 get_columns 获取目标表的真实字段列表。SQL 中的每一个字段名必须与 get_columns 返回的 field 字段完全一致(区分大小写)。不得自行拼凑、缩写或联想字段名(例如字段是 channel 就必须写 channel不得写成 pay_channel
8. 生成 SQL 时禁止使用 "database.table" 格式的限定前缀,只写表名本身
9. 报告结果时,连接名/ID 和数据库名必须严格来自同一个 get_tables 调用的实际参数。禁止将 A 连接的 connectionId 与 B 连接的 dbName 混搭
10. 如果有多个名称相似的数据库,请明确告诉用户目标表具体位于哪个数据库
11. 【关键】每个 SQL 代码块的第一行必须添加上下文声明注释,格式严格为:-- @context connectionId=<连接ID> dbName=<数据库名>。connectionId 和 dbName 必须来自同一个成功的 get_tables 调用(即你在该调用中传入的实际参数值)。示例:
8. 如果用户在问索引优化、联表关系、触发器副作用、约束或 DDL 细节,在 get_columns 之后继续按需调用 get_indexes、get_foreign_keys、get_triggers、get_table_ddl再给结论
9. 生成 SQL 时禁止使用 "database.table" 格式的限定前缀,只写表名本身
10. 报告结果时,连接名/ID 和数据库名必须严格来自同一个 get_tables 调用的实际参数。禁止将 A 连接的 connectionId 与 B 连接的 dbName 混搭
11. 如果有多个名称相似的数据库,请明确告诉用户目标表具体位于哪个数据库。
12. 【关键】每个 SQL 代码块的第一行必须添加上下文声明注释,格式严格为:-- @context connectionId=<连接ID> dbName=<数据库名>。connectionId 和 dbName 必须来自同一个成功的 get_tables 调用(即你在该调用中传入的实际参数值)。示例:
\`\`\`sql
-- @context connectionId=1770778676549 dbName=mkefu_test
SELECT * FROM users WHERE status = 1;
@@ -1346,6 +1347,60 @@ SELECT * FROM users WHERE status = 1;
} else { resStr = 'Connection not found'; }
break;
}
case 'get_indexes': {
const conn = useStore.getState().connections.find(c => c.id === args.connectionId);
if (conn) {
try {
const safeDbName = args.dbName ? String(args.dbName).trim() : '';
const safeTable = args.tableName ? String(args.tableName).trim() : '';
const { DBGetIndexes } = await import('../../wailsjs/go/app/App');
const indexRes = await DBGetIndexes(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeTable);
if (indexRes?.success && Array.isArray(indexRes.data)) {
resStr = JSON.stringify(indexRes.data);
success = true;
} else { resStr = indexRes?.message || 'Failed to fetch indexes'; }
} catch (e: any) {
resStr = `获取索引定义失败: ${e?.message || e}`;
}
} else { resStr = 'Connection not found'; }
break;
}
case 'get_foreign_keys': {
const conn = useStore.getState().connections.find(c => c.id === args.connectionId);
if (conn) {
try {
const safeDbName = args.dbName ? String(args.dbName).trim() : '';
const safeTable = args.tableName ? String(args.tableName).trim() : '';
const { DBGetForeignKeys } = await import('../../wailsjs/go/app/App');
const foreignKeyRes = await DBGetForeignKeys(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeTable);
if (foreignKeyRes?.success && Array.isArray(foreignKeyRes.data)) {
resStr = JSON.stringify(foreignKeyRes.data);
success = true;
} else { resStr = foreignKeyRes?.message || 'Failed to fetch foreign keys'; }
} catch (e: any) {
resStr = `获取外键关系失败: ${e?.message || e}`;
}
} else { resStr = 'Connection not found'; }
break;
}
case 'get_triggers': {
const conn = useStore.getState().connections.find(c => c.id === args.connectionId);
if (conn) {
try {
const safeDbName = args.dbName ? String(args.dbName).trim() : '';
const safeTable = args.tableName ? String(args.tableName).trim() : '';
const { DBGetTriggers } = await import('../../wailsjs/go/app/App');
const triggerRes = await DBGetTriggers(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeTable);
if (triggerRes?.success && Array.isArray(triggerRes.data)) {
resStr = JSON.stringify(triggerRes.data);
success = true;
} else { resStr = triggerRes?.message || 'Failed to fetch triggers'; }
} catch (e: any) {
resStr = `获取触发器定义失败: ${e?.message || e}`;
}
} else { resStr = 'Connection not found'; }
break;
}
case 'get_table_ddl': {
const conn = useStore.getState().connections.find(c => c.id === args.connectionId);
if (conn) {

View File

@@ -26,17 +26,22 @@ describe('AISettingsModal edit password behavior', () => {
expect(source).toContain('新增 Skill');
});
it('explains external MCP installation and renders selectable client install states', () => {
expect(source).toContain('把 GoNavi 注册成外部 AI 客户端可调用的 MCP Server');
expect(source).toContain('安装到外部客户端');
expect(source).toContain('未安装');
expect(source).toContain('需更新');
expect(source).toContain('已安装');
expect(source).toContain('刷新状态');
expect(source).toContain('复制配置路径');
expect(source).toContain('复制启动命令');
it('delegates bulky MCP and built-in tool sections to dedicated ai components', () => {
expect(source).toContain("import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';");
expect(source).toContain("import AIMCPClientInstallPanel from './ai/AIMCPClientInstallPanel';");
expect(source).toContain("import AIMCPServerCard from './ai/AIMCPServerCard';");
expect(source).toContain('<AIMCPClientInstallPanel');
expect(source).toContain('<AIMCPServerCard');
expect(source).toContain('<AIBuiltinToolsCatalog');
});
it('wires the external MCP client install panel actions back to the modal handlers', () => {
expect(source).toContain('statuses={mcpClientStatuses}');
expect(source).toContain('selectedClient={selectedMCPClient}');
expect(source).toContain('onRefreshStatus={() => void loadMCPClientStatuses()}');
expect(source).toContain('onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()}');
expect(source).toContain('onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()}');
expect(source).toContain('handleInstallSelectedMCPClient');
expect(source).toContain('无需重复安装');
});
it('waits briefly for the AI service bridge before warning and removes noisy provider debug logs', () => {

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined, ReloadOutlined, CopyOutlined } from '@ant-design/icons';
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons';
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AIMCPClientInstallStatus, AISkillConfig, AISkillScope } from '../types';
import {
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
@@ -22,6 +22,9 @@ import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { BUILTIN_AI_TOOL_INFO } from '../utils/aiToolRegistry';
import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';
import AIMCPClientInstallPanel from './ai/AIMCPClientInstallPanel';
import AIMCPServerCard from './ai/AIMCPServerCard';
interface AISettingsModalProps {
open: boolean;
onClose: () => void;
@@ -219,27 +222,6 @@ const SKILL_SCOPE_OPTIONS: Array<{ value: AISkillScope; label: string; desc: str
{ value: 'jvmDiagnostic', label: 'JVM 诊断', desc: '仅 JVM 诊断工作台启用' },
];
const parseMCPEnvText = (text: string): Record<string, string> => {
const result: Record<string, string> = {};
String(text || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => {
const index = line.indexOf('=');
if (index <= 0) return;
const key = line.slice(0, index).trim();
if (!key) return;
result[key] = line.slice(index + 1);
});
return result;
};
const stringifyMCPEnv = (env?: Record<string, string>): string =>
Object.entries(env || {})
.map(([key, value]) => `${key}=${value}`)
.join('\n');
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
const [activeProviderId, setActiveProviderId] = useState<string>('');
@@ -272,36 +254,6 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const cardHoverBg = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)';
const sectionLabelColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)';
const inputBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
const getMCPClientStatusTone = useCallback((status?: AIMCPClientInstallStatus) => {
const messageText = String(status?.message || '');
if (status?.matchesCurrent) {
return {
label: '已安装',
color: '#16a34a',
bg: darkMode ? 'rgba(34,197,94,0.18)' : 'rgba(34,197,94,0.12)',
};
}
if (status?.installed) {
return {
label: '需更新',
color: '#d97706',
bg: darkMode ? 'rgba(245,158,11,0.18)' : 'rgba(245,158,11,0.12)',
};
}
if (messageText.includes('失败') || messageText.includes('异常')) {
return {
label: '需检查',
color: '#dc2626',
bg: darkMode ? 'rgba(239,68,68,0.18)' : 'rgba(239,68,68,0.1)',
};
}
return {
label: '未安装',
color: darkMode ? 'rgba(255,255,255,0.72)' : '#64748b',
bg: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(100,116,139,0.08)',
};
}, [darkMode]);
// Hook 必须在组件顶层调用,不能在条件分支内
const watchedType = Form.useWatch('type', form);
const watchedPresetKey = Form.useWatch('presetKey', form);
@@ -1242,166 +1194,23 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const renderMCPSettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4, lineHeight: 1.7 }}>
GoNavi AI MCP Server Claude Code Codex 使 GoNavi
</div>
<div style={{
padding: '16px',
borderRadius: 14,
border: `1px solid ${cardBorder}`,
background: cardBg,
display: 'flex',
flexDirection: 'column',
gap: 14,
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}></div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
GoNavi MCP GoNavi exe
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10 }}>
{mcpClientStatuses.map((status) => {
const active = selectedMCPClient === status.client;
const tone = getMCPClientStatusTone(status);
return (
<div
key={status.client}
onClick={() => {
if (status.client === 'claude-code' || status.client === 'codex') {
setSelectedMCPClient(status.client);
}
}}
style={{
padding: '14px 14px 12px',
borderRadius: 12,
border: `1.5px solid ${active ? overlayTheme.selectedText : cardBorder}`,
background: active ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.7)'),
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: 10,
transition: 'all 0.2s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}>
{status.displayName}
</div>
<div style={{
padding: '4px 10px',
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
color: tone.color,
background: tone.bg,
whiteSpace: 'nowrap',
}}>
{tone.label}
</div>
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
{status.matchesCurrent
? '当前 GoNavi 安装路径已写入,打开客户端后可直接使用。'
: status.installed
? '检测到已有安装记录,但建议更新为当前 GoNavi 路径。'
: '当前尚未写入 GoNavi MCP 配置。'}
</div>
</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.78)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
{selectedMCPClientStatus?.displayName || '客户端'}
</div>
{selectedMCPClientStatus && (
<div style={{
padding: '3px 9px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: getMCPClientStatusTone(selectedMCPClientStatus).color,
background: getMCPClientStatusTone(selectedMCPClientStatus).bg,
}}>
{getMCPClientStatusTone(selectedMCPClientStatus).label}
</div>
)}
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{selectedMCPClientStatus?.message || '未检测到安装状态'}
</div>
{selectedMCPClientStatus?.configPath && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedMCPClientStatus.configPath}
</div>
)}
{selectedMCPClientCommandText && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedMCPClientCommandText}
</div>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button
size="small"
icon={<ReloadOutlined />}
loading={mcpClientStatusLoading}
onClick={() => void loadMCPClientStatuses()}
style={{ borderRadius: 8 }}
>
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!selectedMCPClientStatus?.configPath}
onClick={() => void handleCopySelectedMCPConfigPath()}
style={{ borderRadius: 8 }}
>
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!selectedMCPClientCommandText}
onClick={() => void handleCopySelectedMCPLaunchCommand()}
style={{ borderRadius: 8 }}
>
</Button>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
</div>
<Button
type={selectedMCPClientStatus?.matchesCurrent ? 'default' : 'primary'}
onClick={handleInstallSelectedMCPClient}
loading={loading}
disabled={Boolean(selectedMCPClientStatus?.matchesCurrent)}
style={{ borderRadius: 10, fontWeight: 600, minWidth: 176, height: 40 }}
>
{selectedMCPClientStatus?.matchesCurrent
? `${selectedMCPClientStatus.displayName} 已安装`
: selectedMCPClientStatus?.installed
? `更新到 ${selectedMCPClientStatus?.displayName || '客户端'}`
: `安装到 ${selectedMCPClientStatus?.displayName || '客户端'}`}
</Button>
</div>
</div>
<AIMCPClientInstallPanel
statuses={mcpClientStatuses}
selectedClient={selectedMCPClient}
selectedStatus={selectedMCPClientStatus}
selectedCommandText={selectedMCPClientCommandText}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
loading={loading}
statusLoading={mcpClientStatusLoading}
onSelectClient={setSelectedMCPClient}
onRefreshStatus={() => void loadMCPClientStatuses()}
onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()}
onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()}
onInstall={handleInstallSelectedMCPClient}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}> AI </div>
<Button icon={<PlusOutlined />} onClick={handleAddMCPServer} style={{ borderRadius: 10 }}> MCP </Button>
@@ -1411,81 +1220,23 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
MCP `node server.js``uvx some-mcp-server``python -m server`
</div>
)}
{mcpServers.map((server) => {
const serverTools = mcpTools.filter((tool) => tool.serverId === server.id);
return (
<div key={server.id} style={{ padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 132px', gap: 12 }}>
<Input
value={server.name}
onChange={(event) => updateMCPServerDraft(server.id, { name: event.target.value })}
placeholder="服务名称例如Filesystem / Browser / GitHub"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
<Select
value={server.enabled ? 'enabled' : 'disabled'}
onChange={(value) => updateMCPServerDraft(server.id, { enabled: value === 'enabled' })}
options={[{ label: '已启用', value: 'enabled' }, { label: '已禁用', value: 'disabled' }]}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '132px minmax(0,1fr) 132px', gap: 12 }}>
<Select
value={server.transport}
onChange={(value) => updateMCPServerDraft(server.id, { transport: value as AIMCPServerConfig['transport'] })}
options={[{ label: 'stdio', value: 'stdio' }]}
/>
<Input
value={server.command}
onChange={(event) => updateMCPServerDraft(server.id, { command: event.target.value })}
placeholder="启动命令例如node / uvx / python"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
<Input
type="number"
min={3}
max={120}
value={server.timeoutSeconds}
onChange={(event) => updateMCPServerDraft(server.id, { timeoutSeconds: Number(event.target.value) || 20 })}
placeholder="超时(秒)"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
</div>
<Select
mode="tags"
value={server.args || []}
onChange={(value) => updateMCPServerDraft(server.id, { args: value })}
placeholder="命令参数回车录入例如server.js、--stdio"
style={{ width: '100%' }}
/>
<Input.TextArea
rows={3}
value={stringifyMCPEnv(server.env)}
onChange={(event) => updateMCPServerDraft(server.id, { env: parseMCPEnvText(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)' }}
/>
{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={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={() => handleTestMCPServer(server)} loading={loading} style={{ borderRadius: 10 }}></Button>
<Button type="primary" onClick={() => handleSaveMCPServer(server)} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}></Button>
<Popconfirm title="删除这个 MCP 服务?" okText="删除" cancelText="取消" onConfirm={() => handleDeleteMCPServer(server.id)}>
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 10 }}></Button>
</Popconfirm>
</div>
</div>
);
})}
{mcpServers.map((server) => (
<AIMCPServerCard
key={server.id}
server={server}
serverTools={mcpTools.filter((tool) => tool.serverId === server.id)}
cardBg={cardBg}
cardBorder={cardBorder}
inputBg={inputBg}
darkMode={darkMode}
overlayTheme={overlayTheme}
loading={loading}
onChange={(patch) => updateMCPServerDraft(server.id, patch)}
onTest={() => handleTestMCPServer(server)}
onSave={() => handleSaveMCPServer(server)}
onDelete={() => handleDeleteMCPServer(server.id)}
/>
))}
</div>
);
@@ -1558,46 +1309,6 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
</div>
);
const renderBuiltinTools = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
AI
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, padding: '8px 12px', borderRadius: 8, background: cardBg, border: `1px solid ${cardBorder}` }}>
💡 get_connections get_databases get_tables get_columns SQL
</div>
{BUILTIN_AI_TOOL_INFO.map(tool => (
<div key={tool.name} style={{
padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg,
transition: 'all 0.2s ease',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<span style={{ fontSize: 20 }}>{tool.icon}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, fontFamily: 'var(--gn-font-mono)' }}>
{tool.name}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 2 }}>{tool.desc}</div>
</div>
</div>
<div style={{
fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.6, padding: '8px 12px',
background: darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.02)', borderRadius: 8,
}}>
{tool.detail}
</div>
<div style={{ marginTop: 8, fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, display: 'flex', alignItems: 'center', gap: 6 }}>
<ToolOutlined style={{ fontSize: 12 }} />
<span></span>
<code style={{ fontFamily: 'var(--gn-font-mono)', fontSize: 12, padding: '1px 6px', borderRadius: 4, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
{tool.params}
</code>
</div>
</div>
))}
</div>
);
const modalShellStyle = {
background: overlayTheme.shellBg, border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter,
@@ -1683,7 +1394,14 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
{activeSection === 'context' && renderContextSettings()}
{activeSection === 'mcp' && renderMCPSettings()}
{activeSection === 'skills' && renderSkillSettings()}
{activeSection === 'tools' && renderBuiltinTools()}
{activeSection === 'tools' && (
<AIBuiltinToolsCatalog
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
/>
)}
{activeSection === 'prompts' && renderPromptSettings()}
</div>
</div>

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import AIBuiltinToolsCatalog from './AIBuiltinToolsCatalog';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
describe('AIBuiltinToolsCatalog', () => {
it('renders the deep structure analysis flow and the newly added built-in tools', () => {
const markup = renderToStaticMarkup(
<AIBuiltinToolsCatalog
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
/>,
);
expect(markup).toContain('结构深挖');
expect(markup).toContain('get_indexes');
expect(markup).toContain('get_foreign_keys');
expect(markup).toContain('get_triggers');
});
});

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { ToolOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import { BUILTIN_AI_TOOL_INFO } from '../../utils/aiToolRegistry';
interface AIBuiltinToolsCatalogProps {
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
cardBg: string;
cardBorder: string;
}
const BUILTIN_TOOL_FLOWS = [
{
title: '定位表与字段',
steps: 'get_connections → get_databases → get_tables → get_columns',
description: '适合先找连接、找库、找表,再确认真实字段名后生成 SQL。',
},
{
title: '结构深挖',
steps: 'get_columns → get_indexes → get_foreign_keys → get_triggers → get_table_ddl',
description: '适合做索引优化、关系梳理、隐式副作用排查和 DDL 审查。',
},
{
title: '只读验证',
steps: 'get_columns → execute_sql',
description: '适合生成 SQL 后做小范围结果核对,仍会受 AI 安全级别控制。',
},
];
export const AIBuiltinToolsCatalog: React.FC<AIBuiltinToolsCatalogProps> = ({
darkMode,
overlayTheme,
cardBg,
cardBorder,
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
AI
</div>
<div style={{ display: 'grid', gap: 8 }}>
{BUILTIN_TOOL_FLOWS.map((flow) => (
<div
key={flow.title}
style={{
fontSize: 12,
color: overlayTheme.mutedText,
padding: '10px 12px',
borderRadius: 10,
background: cardBg,
border: `1px solid ${cardBorder}`,
}}
>
<div style={{ fontWeight: 700, color: overlayTheme.titleText }}>{flow.title}</div>
<div style={{ marginTop: 4, fontFamily: 'var(--gn-font-mono)' }}>{flow.steps}</div>
<div style={{ marginTop: 4, opacity: 0.8, lineHeight: 1.6 }}>{flow.description}</div>
</div>
))}
</div>
{BUILTIN_AI_TOOL_INFO.map((tool) => (
<div
key={tool.name}
style={{
padding: '14px 16px',
borderRadius: 14,
border: `1px solid ${cardBorder}`,
background: cardBg,
transition: 'all 0.2s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<span style={{ fontSize: 20 }}>{tool.icon}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, fontFamily: 'var(--gn-font-mono)' }}>
{tool.name}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 2 }}>{tool.desc}</div>
</div>
</div>
<div
style={{
fontSize: 13,
color: overlayTheme.mutedText,
lineHeight: 1.6,
padding: '8px 12px',
background: darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.02)',
borderRadius: 8,
}}
>
{tool.detail}
</div>
<div style={{ marginTop: 8, fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, display: 'flex', alignItems: 'center', gap: 6 }}>
<ToolOutlined style={{ fontSize: 12 }} />
<span></span>
<code style={{ fontFamily: 'var(--gn-font-mono)', fontSize: 12, padding: '1px 6px', borderRadius: 4, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
{tool.params}
</code>
</div>
</div>
))}
</div>
);
export default AIBuiltinToolsCatalog;

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import AIMCPClientInstallPanel from './AIMCPClientInstallPanel';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
describe('AIMCPClientInstallPanel', () => {
it('renders a clearer external-client selection flow instead of parallel install buttons', () => {
const markup = renderToStaticMarkup(
<AIMCPClientInstallPanel
statuses={[
{
client: 'claude-code',
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
message: '未安装到 Claude Code 用户级配置',
},
{
client: 'codex',
displayName: 'Codex',
installed: true,
matchesCurrent: false,
message: '检测到旧的 Codex 配置,建议更新',
configPath: '~/.codex/config.toml',
command: 'gonavi-mcp-server',
args: ['stdio'],
},
]}
selectedClient="codex"
selectedStatus={{
client: 'codex',
displayName: 'Codex',
installed: true,
matchesCurrent: false,
message: '检测到旧的 Codex 配置,建议更新',
configPath: '~/.codex/config.toml',
command: 'gonavi-mcp-server',
args: ['stdio'],
}}
selectedCommandText="gonavi-mcp-server stdio"
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
loading={false}
statusLoading={false}
onSelectClient={() => {}}
onRefreshStatus={() => {}}
onCopyConfigPath={() => {}}
onCopyLaunchCommand={() => {}}
onInstall={() => {}}
/>,
);
expect(markup).toContain('不是给 GoNavi 自己安装 MCP');
expect(markup).toContain('接入外部客户端');
expect(markup).toContain('目标客户端');
expect(markup).toContain('未接入');
expect(markup).toContain('需更新');
expect(markup).toContain('复制配置路径');
expect(markup).toContain('复制启动命令');
expect(markup).toContain('更新 Codex 配置');
});
});

View File

@@ -0,0 +1,314 @@
import React from 'react';
import { Button } from 'antd';
import { CheckCircleFilled, CopyOutlined, ReloadOutlined } from '@ant-design/icons';
import type { AIMCPClientInstallStatus } from '../../types';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
type MCPClientKey = 'claude-code' | 'codex';
interface AIMCPClientInstallPanelProps {
statuses: AIMCPClientInstallStatus[];
selectedClient: MCPClientKey;
selectedStatus?: AIMCPClientInstallStatus;
selectedCommandText: string;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
cardBg: string;
cardBorder: string;
loading: boolean;
statusLoading: boolean;
onSelectClient: (client: MCPClientKey) => void;
onRefreshStatus: () => void;
onCopyConfigPath: () => void;
onCopyLaunchCommand: () => void;
onInstall: () => void;
}
const getStatusTone = (status: AIMCPClientInstallStatus | undefined, darkMode: boolean) => {
const messageText = String(status?.message || '');
if (status?.matchesCurrent) {
return {
label: '已接入',
color: '#16a34a',
bg: darkMode ? 'rgba(34,197,94,0.18)' : 'rgba(34,197,94,0.12)',
};
}
if (status?.installed) {
return {
label: '需更新',
color: '#d97706',
bg: darkMode ? 'rgba(245,158,11,0.18)' : 'rgba(245,158,11,0.12)',
};
}
if (messageText.includes('失败') || messageText.includes('异常')) {
return {
label: '需检查',
color: '#dc2626',
bg: darkMode ? 'rgba(239,68,68,0.18)' : 'rgba(239,68,68,0.1)',
};
}
return {
label: '未接入',
color: darkMode ? 'rgba(255,255,255,0.72)' : '#64748b',
bg: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(100,116,139,0.08)',
};
};
const getStatusSummary = (status: AIMCPClientInstallStatus | undefined) => {
const messageText = String(status?.message || '');
if (status?.matchesCurrent) {
return '已经是当前 GoNavi 路径,无需重复接入。';
}
if (status?.installed) {
return '检测到已有 GoNavi 记录,但不是当前路径,建议更新。';
}
if (messageText.includes('失败') || messageText.includes('异常')) {
return '状态读取异常,建议刷新后再检查一次。';
}
return '当前还没有把 GoNavi MCP 写入这个客户端。';
};
const getClientCardDescription = (status: AIMCPClientInstallStatus | undefined) => {
if (status?.matchesCurrent) {
return '当前 GoNavi 路径已经写入,可直接在该客户端中调用。';
}
if (status?.installed) {
return '检测到旧的 GoNavi 记录,建议更新为当前安装路径。';
}
return '尚未写入 GoNavi MCP 配置。';
};
const resolveActionLabel = (status: AIMCPClientInstallStatus | undefined) => {
const label = status?.displayName || '客户端';
if (status?.matchesCurrent) {
return `${label} 已接入`;
}
if (status?.installed) {
return `更新 ${label} 配置`;
}
return `接入 ${label}`;
};
const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
statuses,
selectedClient,
selectedStatus,
selectedCommandText,
darkMode,
overlayTheme,
cardBg,
cardBorder,
loading,
statusLoading,
onSelectClient,
onRefreshStatus,
onCopyConfigPath,
onCopyLaunchCommand,
onInstall,
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4, lineHeight: 1.7 }}>
GoNavi MCP GoNavi MCP Server Claude Code Codex AI
</div>
<div
style={{
padding: '16px',
borderRadius: 14,
border: `1px solid ${cardBorder}`,
background: cardBg,
display: 'flex',
flexDirection: 'column',
gap: 14,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}></div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
GoNavi MCP exe
</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.78)',
fontSize: 12,
color: overlayTheme.mutedText,
lineHeight: 1.7,
}}
>
MCP GoNavi GoNavi
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10 }}>
{statuses.map((status) => {
const client = status.client === 'codex' ? 'codex' : 'claude-code';
const active = selectedClient === client;
const tone = getStatusTone(status, darkMode);
return (
<button
key={status.client}
type="button"
onClick={() => onSelectClient(client)}
style={{
padding: '14px 14px 12px',
borderRadius: 12,
border: `1.5px solid ${active ? overlayTheme.selectedText : cardBorder}`,
background: active ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.7)'),
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: 10,
textAlign: 'left',
transition: 'all 0.2s ease',
opacity: statusLoading ? 0.72 : 1,
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<div
aria-hidden
style={{
width: 18,
height: 18,
borderRadius: 999,
border: `1.5px solid ${active ? overlayTheme.selectedText : darkMode ? 'rgba(255,255,255,0.16)' : 'rgba(0,0,0,0.12)'}`,
background: active ? overlayTheme.selectedText : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{active ? <CheckCircleFilled style={{ color: '#fff', fontSize: 12 }} /> : null}
</div>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}>
{status.displayName}
</div>
</div>
<div
style={{
padding: '4px 10px',
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
color: tone.color,
background: tone.bg,
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
{tone.label}
</div>
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
{getClientCardDescription(status)}
</div>
</button>
);
})}
</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.78)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
{selectedStatus?.displayName || '客户端'}
</div>
{selectedStatus && (
<div
style={{
padding: '3px 9px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: getStatusTone(selectedStatus, darkMode).color,
background: getStatusTone(selectedStatus, darkMode).bg,
}}
>
{getStatusTone(selectedStatus, darkMode).label}
</div>
)}
</div>
<div style={{ fontSize: 12, color: overlayTheme.titleText, lineHeight: 1.7 }}>
{getStatusSummary(selectedStatus)}
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{selectedStatus?.message || '未检测到安装状态'}
</div>
{selectedStatus?.configPath && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedStatus.configPath}
</div>
)}
{selectedCommandText && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedCommandText}
</div>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button
size="small"
icon={<ReloadOutlined />}
loading={statusLoading}
onClick={onRefreshStatus}
style={{ borderRadius: 8 }}
>
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!selectedStatus?.configPath}
onClick={onCopyConfigPath}
style={{ borderRadius: 8 }}
>
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!selectedCommandText}
onClick={onCopyLaunchCommand}
style={{ borderRadius: 8 }}
>
</Button>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
</div>
<Button
type={selectedStatus?.matchesCurrent ? 'default' : 'primary'}
onClick={onInstall}
loading={loading}
disabled={Boolean(selectedStatus?.matchesCurrent)}
style={{ borderRadius: 10, fontWeight: 600, minWidth: 180, height: 40 }}
>
{resolveActionLabel(selectedStatus)}
</Button>
</div>
</div>
</div>
);
export default AIMCPClientInstallPanel;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import AIMCPServerCard from './AIMCPServerCard';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
describe('AIMCPServerCard', () => {
it('renders explicit MCP parameter hints for command, args, and env', () => {
const markup = renderToStaticMarkup(
<AIMCPServerCard
server={{
id: 'mcp-1',
name: '',
transport: 'stdio',
command: '',
args: [],
env: {},
enabled: true,
timeoutSeconds: 20,
}}
serverTools={[]}
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
inputBg="#fff"
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
loading={false}
onChange={() => {}}
onTest={() => {}}
onSave={() => {}}
onDelete={() => {}}
/>,
);
expect(markup).toContain('启动命令只填可执行程序本身');
expect(markup).toContain('每个参数单独录入一个标签');
expect(markup).toContain('每行一个 KEY=VALUE');
expect(markup).toContain('当前阶段只支持 stdio');
});
});

View File

@@ -0,0 +1,191 @@
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';
interface AIMCPServerCardProps {
server: AIMCPServerConfig;
serverTools: AIMCPToolDescriptor[];
cardBg: string;
cardBorder: string;
inputBg: string;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
loading: boolean;
onChange: (patch: Partial<AIMCPServerConfig>) => void;
onTest: () => void;
onSave: () => void;
onDelete: () => void;
}
const labelStyle: React.CSSProperties = {
fontSize: 12,
fontWeight: 700,
};
const hintStyle = (mutedText: string): React.CSSProperties => ({
fontSize: 12,
color: mutedText,
lineHeight: 1.6,
});
const MCP_COMMAND_EXAMPLES = [
'uvx mcp-server-fetch',
'node server.js --stdio',
'python -m your_mcp_server',
];
const MCPHelpBlock: React.FC<{
title: string;
description: string;
overlayTheme: OverlayWorkbenchTheme;
example?: string;
children: React.ReactNode;
}> = ({ title, description, overlayTheme, example, children }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={labelStyle}>{title}</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,
cardBg,
cardBorder,
inputBg,
darkMode,
overlayTheme,
loading,
onChange,
onTest,
onSave,
onDelete,
}) => (
<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={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 132px', gap: 12 }}>
<MCPHelpBlock title="服务名称" description="给这个 MCP 起一个你自己能识别的名字,后面 AI 工具列表里会直接显示。" overlayTheme={overlayTheme} 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}>
<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}>
<Select
value={server.transport}
onChange={(value) => onChange({ transport: value as AIMCPServerConfig['transport'] })}
options={[{ label: 'stdio', value: 'stdio' }]}
/>
</MCPHelpBlock>
<MCPHelpBlock title="启动命令" description="这里只填命令本身;如果是 node/uvx/python 这类启动器,把脚本名或模块名放到下面的参数里。" overlayTheme={overlayTheme} 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="工具发现和工具调用单次最多等多久。远端服务或启动慢的脚本可以适当调大。" overlayTheme={overlayTheme} 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}` }}
/>
</MCPHelpBlock>
</div>
<MCPHelpBlock title="命令参数" description="每个参数单独录入一个标签;命令本体不要填在这里。比如 node server.js --stdio要把 server.js 和 --stdio 分开填。" overlayTheme={overlayTheme} 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>
<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;
}, {}),
})}
placeholder={"环境变量,每行一个 KEY=VALUE例如\nOPENAI_API_KEY=...\nGITHUB_TOKEN=..."}
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)' }}
/>
</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={{ 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>
</div>
);
export default AIMCPServerCard;

View File

@@ -496,6 +496,12 @@ const AIToolCallingBlock: React.FC<{ tool_calls: AIToolCall[]; loading: boolean;
if (fname === 'get_connections') return '获取可用连接信息';
if (fname === 'get_databases') return '扫描数据库列表';
if (fname === 'get_tables') return '分析表结构信息';
if (fname === 'get_columns') return '核对真实字段定义';
if (fname === 'get_indexes') return '检查索引定义';
if (fname === 'get_foreign_keys') return '梳理外键关系';
if (fname === 'get_triggers') return '检查触发器逻辑';
if (fname === 'get_table_ddl') return '提取建表语句';
if (fname === 'execute_sql') return '执行只读 SQL 验证';
return fname;
};

View File

@@ -106,6 +106,81 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [
},
},
},
{
name: "get_indexes",
icon: "🧭",
desc: "获取指定表的索引定义",
detail:
"传入 connectionId、dbName 和 tableName返回索引名、索引列、唯一性和索引类型。AI 在做慢 SQL 分析、索引优化和执行计划推断时应优先调用。",
params: "connectionId, dbName, tableName",
tool: {
type: "function",
function: {
name: "get_indexes",
description:
"获取指定表的索引定义,包括索引名、字段顺序、唯一性和索引类型。适用于慢 SQL 分析、索引优化建议和确认现有索引覆盖情况。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
tableName: { type: "string", description: "表名" },
},
required: ["connectionId", "dbName", "tableName"],
},
},
},
},
{
name: "get_foreign_keys",
icon: "🧬",
desc: "获取指定表的外键关系",
detail:
"传入 connectionId、dbName 和 tableName返回当前表到其他表的外键映射。AI 在推断表关系、生成联表 SQL 和评审数据一致性时可直接使用。",
params: "connectionId, dbName, tableName",
tool: {
type: "function",
function: {
name: "get_foreign_keys",
description:
"获取指定表的外键关系包括本表字段、引用表、引用字段和约束名。适用于联表路径分析、ER 关系梳理和约束检查。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
tableName: { type: "string", description: "表名" },
},
required: ["connectionId", "dbName", "tableName"],
},
},
},
},
{
name: "get_triggers",
icon: "⏱️",
desc: "获取指定表的触发器定义",
detail:
"传入 connectionId、dbName 和 tableName返回触发器名、触发时机、事件类型和语句体。AI 在分析隐式写入、副作用和审计逻辑时可直接查看。",
params: "connectionId, dbName, tableName",
tool: {
type: "function",
function: {
name: "get_triggers",
description:
"获取指定表的触发器定义,包括触发时机、事件和触发语句。适用于排查隐式数据变更、审计逻辑和表级副作用。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
tableName: { type: "string", description: "表名" },
},
required: ["connectionId", "dbName", "tableName"],
},
},
},
},
{
name: "get_table_ddl",
icon: "📝",