mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 10:29:52 +08:00
✨ feat(ai-mcp): 补全结构探针并优化客户端接入体验
- 新增 get_indexes、get_foreign_keys、get_triggers 内置工具与 MCP Server 对应实现 - 拆分 AI 设置中的 MCP 接入面板和服务卡片,补充参数提示与客户端状态展示 - 补齐前后端测试与真实页面验证,降低 AI 设置区域的臃肿度
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
24
frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx
Normal file
24
frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
105
frontend/src/components/ai/AIBuiltinToolsCatalog.tsx
Normal file
105
frontend/src/components/ai/AIBuiltinToolsCatalog.tsx
Normal 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;
|
||||
66
frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx
Normal file
66
frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx
Normal 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 配置');
|
||||
});
|
||||
});
|
||||
314
frontend/src/components/ai/AIMCPClientInstallPanel.tsx
Normal file
314
frontend/src/components/ai/AIMCPClientInstallPanel.tsx
Normal 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;
|
||||
41
frontend/src/components/ai/AIMCPServerCard.test.tsx
Normal file
41
frontend/src/components/ai/AIMCPServerCard.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
191
frontend/src/components/ai/AIMCPServerCard.tsx
Normal file
191
frontend/src/components/ai/AIMCPServerCard.tsx
Normal 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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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: "📝",
|
||||
|
||||
@@ -1320,3 +1320,4 @@ export namespace sync {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ type Backend interface {
|
||||
DBGetDatabases(config connection.ConnectionConfig) connection.QueryResult
|
||||
DBGetTables(config connection.ConnectionConfig, dbName string) connection.QueryResult
|
||||
DBGetColumns(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult
|
||||
DBGetIndexes(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult
|
||||
DBGetForeignKeys(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult
|
||||
DBGetTriggers(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult
|
||||
DBShowCreateTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult
|
||||
DBQueryMulti(config connection.ConnectionConfig, dbName string, query string, queryID string) connection.QueryResult
|
||||
InspectSQL(dbType string, sql string) appcore.SQLInspection
|
||||
@@ -70,6 +73,18 @@ func (b *AppBackend) DBGetColumns(config connection.ConnectionConfig, dbName str
|
||||
return b.app.DBGetColumns(config, dbName, tableName)
|
||||
}
|
||||
|
||||
func (b *AppBackend) DBGetIndexes(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||
return b.app.DBGetIndexes(config, dbName, tableName)
|
||||
}
|
||||
|
||||
func (b *AppBackend) DBGetForeignKeys(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||
return b.app.DBGetForeignKeys(config, dbName, tableName)
|
||||
}
|
||||
|
||||
func (b *AppBackend) DBGetTriggers(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||
return b.app.DBGetTriggers(config, dbName, tableName)
|
||||
}
|
||||
|
||||
func (b *AppBackend) DBShowCreateTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||
return b.app.DBShowCreateTable(config, dbName, tableName)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,21 @@ func NewServer(backend Backend) *mcp.Server {
|
||||
Description: "根据 connectionId、可选 dbName、tableName 获取字段定义。",
|
||||
}, service.GetColumns)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "get_indexes",
|
||||
Description: "根据 connectionId、可选 dbName、tableName 获取索引定义。",
|
||||
}, service.GetIndexes)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "get_foreign_keys",
|
||||
Description: "根据 connectionId、可选 dbName、tableName 获取外键关系。",
|
||||
}, service.GetForeignKeys)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "get_triggers",
|
||||
Description: "根据 connectionId、可选 dbName、tableName 获取触发器定义。",
|
||||
}, service.GetTriggers)
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
Name: "get_table_ddl",
|
||||
Description: "根据 connectionId、可选 dbName、tableName 获取建表或建视图语句。",
|
||||
|
||||
@@ -88,6 +88,27 @@ type getColumnsResult struct {
|
||||
Columns []connection.ColumnDefinition `json:"columns"`
|
||||
}
|
||||
|
||||
type getIndexesResult struct {
|
||||
ConnectionID string `json:"connectionId"`
|
||||
DBName string `json:"dbName,omitempty"`
|
||||
TableName string `json:"tableName"`
|
||||
Indexes []connection.IndexDefinition `json:"indexes"`
|
||||
}
|
||||
|
||||
type getForeignKeysResult struct {
|
||||
ConnectionID string `json:"connectionId"`
|
||||
DBName string `json:"dbName,omitempty"`
|
||||
TableName string `json:"tableName"`
|
||||
ForeignKeys []connection.ForeignKeyDefinition `json:"foreignKeys"`
|
||||
}
|
||||
|
||||
type getTriggersResult struct {
|
||||
ConnectionID string `json:"connectionId"`
|
||||
DBName string `json:"dbName,omitempty"`
|
||||
TableName string `json:"tableName"`
|
||||
Triggers []connection.TriggerDefinition `json:"triggers"`
|
||||
}
|
||||
|
||||
type getTableDDLResult struct {
|
||||
ConnectionID string `json:"connectionId"`
|
||||
DBName string `json:"dbName,omitempty"`
|
||||
@@ -241,6 +262,105 @@ func (s *Service) GetColumns(ctx context.Context, req *mcp.CallToolRequest, args
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetIndexes(ctx context.Context, req *mcp.CallToolRequest, args tableArgs) (*mcp.CallToolResult, getIndexesResult, error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
|
||||
view, errResult := s.resolveConnection(args.ConnectionID)
|
||||
if errResult != nil {
|
||||
return errResult, getIndexesResult{}, nil
|
||||
}
|
||||
|
||||
tableName := strings.TrimSpace(args.TableName)
|
||||
if tableName == "" {
|
||||
return toolError("tableName 不能为空"), getIndexesResult{}, nil
|
||||
}
|
||||
|
||||
dbName := effectiveDBName(args.DBName, view.Config)
|
||||
queryResult := s.backend.DBGetIndexes(view.Config, dbName, tableName)
|
||||
if !queryResult.Success {
|
||||
return toolError("获取索引定义失败: %s", strings.TrimSpace(queryResult.Message)), getIndexesResult{}, nil
|
||||
}
|
||||
|
||||
indexes, err := decodeIndexes(queryResult.Data)
|
||||
if err != nil {
|
||||
return toolError("解析索引定义失败: %v", err), getIndexesResult{}, nil
|
||||
}
|
||||
|
||||
return successResult(), getIndexesResult{
|
||||
ConnectionID: view.ID,
|
||||
DBName: dbName,
|
||||
TableName: tableName,
|
||||
Indexes: ensureNonNilIndexes(indexes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetForeignKeys(ctx context.Context, req *mcp.CallToolRequest, args tableArgs) (*mcp.CallToolResult, getForeignKeysResult, error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
|
||||
view, errResult := s.resolveConnection(args.ConnectionID)
|
||||
if errResult != nil {
|
||||
return errResult, getForeignKeysResult{}, nil
|
||||
}
|
||||
|
||||
tableName := strings.TrimSpace(args.TableName)
|
||||
if tableName == "" {
|
||||
return toolError("tableName 不能为空"), getForeignKeysResult{}, nil
|
||||
}
|
||||
|
||||
dbName := effectiveDBName(args.DBName, view.Config)
|
||||
queryResult := s.backend.DBGetForeignKeys(view.Config, dbName, tableName)
|
||||
if !queryResult.Success {
|
||||
return toolError("获取外键关系失败: %s", strings.TrimSpace(queryResult.Message)), getForeignKeysResult{}, nil
|
||||
}
|
||||
|
||||
foreignKeys, err := decodeForeignKeys(queryResult.Data)
|
||||
if err != nil {
|
||||
return toolError("解析外键关系失败: %v", err), getForeignKeysResult{}, nil
|
||||
}
|
||||
|
||||
return successResult(), getForeignKeysResult{
|
||||
ConnectionID: view.ID,
|
||||
DBName: dbName,
|
||||
TableName: tableName,
|
||||
ForeignKeys: ensureNonNilForeignKeys(foreignKeys),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetTriggers(ctx context.Context, req *mcp.CallToolRequest, args tableArgs) (*mcp.CallToolResult, getTriggersResult, error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
|
||||
view, errResult := s.resolveConnection(args.ConnectionID)
|
||||
if errResult != nil {
|
||||
return errResult, getTriggersResult{}, nil
|
||||
}
|
||||
|
||||
tableName := strings.TrimSpace(args.TableName)
|
||||
if tableName == "" {
|
||||
return toolError("tableName 不能为空"), getTriggersResult{}, nil
|
||||
}
|
||||
|
||||
dbName := effectiveDBName(args.DBName, view.Config)
|
||||
queryResult := s.backend.DBGetTriggers(view.Config, dbName, tableName)
|
||||
if !queryResult.Success {
|
||||
return toolError("获取触发器定义失败: %s", strings.TrimSpace(queryResult.Message)), getTriggersResult{}, nil
|
||||
}
|
||||
|
||||
triggers, err := decodeTriggers(queryResult.Data)
|
||||
if err != nil {
|
||||
return toolError("解析触发器定义失败: %v", err), getTriggersResult{}, nil
|
||||
}
|
||||
|
||||
return successResult(), getTriggersResult{
|
||||
ConnectionID: view.ID,
|
||||
DBName: dbName,
|
||||
TableName: tableName,
|
||||
Triggers: ensureNonNilTriggers(triggers),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetTableDDL(ctx context.Context, req *mcp.CallToolRequest, args tableArgs) (*mcp.CallToolResult, getTableDDLResult, error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
@@ -457,6 +577,51 @@ func decodeColumns(data interface{}) ([]connection.ColumnDefinition, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func decodeIndexes(data interface{}) ([]connection.IndexDefinition, error) {
|
||||
switch indexes := data.(type) {
|
||||
case nil:
|
||||
return []connection.IndexDefinition{}, nil
|
||||
case []connection.IndexDefinition:
|
||||
return ensureNonNilIndexes(append([]connection.IndexDefinition(nil), indexes...)), nil
|
||||
default:
|
||||
var decoded []connection.IndexDefinition
|
||||
if err := remarshal(data, &decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ensureNonNilIndexes(decoded), nil
|
||||
}
|
||||
}
|
||||
|
||||
func decodeForeignKeys(data interface{}) ([]connection.ForeignKeyDefinition, error) {
|
||||
switch foreignKeys := data.(type) {
|
||||
case nil:
|
||||
return []connection.ForeignKeyDefinition{}, nil
|
||||
case []connection.ForeignKeyDefinition:
|
||||
return ensureNonNilForeignKeys(append([]connection.ForeignKeyDefinition(nil), foreignKeys...)), nil
|
||||
default:
|
||||
var decoded []connection.ForeignKeyDefinition
|
||||
if err := remarshal(data, &decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ensureNonNilForeignKeys(decoded), nil
|
||||
}
|
||||
}
|
||||
|
||||
func decodeTriggers(data interface{}) ([]connection.TriggerDefinition, error) {
|
||||
switch triggers := data.(type) {
|
||||
case nil:
|
||||
return []connection.TriggerDefinition{}, nil
|
||||
case []connection.TriggerDefinition:
|
||||
return ensureNonNilTriggers(append([]connection.TriggerDefinition(nil), triggers...)), nil
|
||||
default:
|
||||
var decoded []connection.TriggerDefinition
|
||||
if err := remarshal(data, &decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ensureNonNilTriggers(decoded), nil
|
||||
}
|
||||
}
|
||||
|
||||
func decodeString(data interface{}) (string, error) {
|
||||
switch value := data.(type) {
|
||||
case nil:
|
||||
@@ -551,6 +716,27 @@ func ensureNonNilColumns(items []connection.ColumnDefinition) []connection.Colum
|
||||
return items
|
||||
}
|
||||
|
||||
func ensureNonNilIndexes(items []connection.IndexDefinition) []connection.IndexDefinition {
|
||||
if items == nil {
|
||||
return []connection.IndexDefinition{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func ensureNonNilForeignKeys(items []connection.ForeignKeyDefinition) []connection.ForeignKeyDefinition {
|
||||
if items == nil {
|
||||
return []connection.ForeignKeyDefinition{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func ensureNonNilTriggers(items []connection.TriggerDefinition) []connection.TriggerDefinition {
|
||||
if items == nil {
|
||||
return []connection.TriggerDefinition{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func ensureNonNilRows(items []map[string]interface{}) []map[string]interface{} {
|
||||
if items == nil {
|
||||
return []map[string]interface{}{}
|
||||
|
||||
@@ -19,6 +19,9 @@ type fakeBackend struct {
|
||||
databasesResult connection.QueryResult
|
||||
tablesResult connection.QueryResult
|
||||
columnsResult connection.QueryResult
|
||||
indexesResult connection.QueryResult
|
||||
foreignKeysResult connection.QueryResult
|
||||
triggersResult connection.QueryResult
|
||||
ddlResult connection.QueryResult
|
||||
queryResult connection.QueryResult
|
||||
inspection appcore.SQLInspection
|
||||
@@ -50,6 +53,18 @@ func (f *fakeBackend) DBGetColumns(config connection.ConnectionConfig, dbName st
|
||||
return f.columnsResult
|
||||
}
|
||||
|
||||
func (f *fakeBackend) DBGetIndexes(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||
return f.indexesResult
|
||||
}
|
||||
|
||||
func (f *fakeBackend) DBGetForeignKeys(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||
return f.foreignKeysResult
|
||||
}
|
||||
|
||||
func (f *fakeBackend) DBGetTriggers(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||
return f.triggersResult
|
||||
}
|
||||
|
||||
func (f *fakeBackend) DBShowCreateTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||
return f.ddlResult
|
||||
}
|
||||
@@ -114,6 +129,108 @@ func TestGetConnectionsReturnsSavedConnectionSummaries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIndexesReturnsIndexDefinitions(t *testing.T) {
|
||||
backend := &fakeBackend{
|
||||
editableConnection: connection.SavedConnectionView{
|
||||
ID: "mysql-main",
|
||||
Config: connection.ConnectionConfig{
|
||||
Type: "mysql",
|
||||
Database: "app",
|
||||
},
|
||||
},
|
||||
indexesResult: connection.QueryResult{
|
||||
Success: true,
|
||||
Data: []connection.IndexDefinition{
|
||||
{Name: "idx_users_email", ColumnName: "email", NonUnique: 0, SeqInIndex: 1, IndexType: "BTREE"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
service := NewService(backend)
|
||||
result, out, err := service.GetIndexes(context.Background(), nil, tableArgs{
|
||||
ConnectionID: "mysql-main",
|
||||
DBName: "app",
|
||||
TableName: "users",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetIndexes returned error: %v", err)
|
||||
}
|
||||
if result == nil || result.IsError {
|
||||
t.Fatalf("expected success result, got %#v", result)
|
||||
}
|
||||
if len(out.Indexes) != 1 || out.Indexes[0].Name != "idx_users_email" {
|
||||
t.Fatalf("unexpected indexes output: %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetForeignKeysReturnsForeignKeyDefinitions(t *testing.T) {
|
||||
backend := &fakeBackend{
|
||||
editableConnection: connection.SavedConnectionView{
|
||||
ID: "mysql-main",
|
||||
Config: connection.ConnectionConfig{
|
||||
Type: "mysql",
|
||||
Database: "app",
|
||||
},
|
||||
},
|
||||
foreignKeysResult: connection.QueryResult{
|
||||
Success: true,
|
||||
Data: []connection.ForeignKeyDefinition{
|
||||
{Name: "fk_orders_user_id", ColumnName: "user_id", RefTableName: "users", RefColumnName: "id", ConstraintName: "fk_orders_user_id"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
service := NewService(backend)
|
||||
result, out, err := service.GetForeignKeys(context.Background(), nil, tableArgs{
|
||||
ConnectionID: "mysql-main",
|
||||
DBName: "app",
|
||||
TableName: "orders",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetForeignKeys returned error: %v", err)
|
||||
}
|
||||
if result == nil || result.IsError {
|
||||
t.Fatalf("expected success result, got %#v", result)
|
||||
}
|
||||
if len(out.ForeignKeys) != 1 || out.ForeignKeys[0].RefTableName != "users" {
|
||||
t.Fatalf("unexpected foreign keys output: %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTriggersReturnsTriggerDefinitions(t *testing.T) {
|
||||
backend := &fakeBackend{
|
||||
editableConnection: connection.SavedConnectionView{
|
||||
ID: "mysql-main",
|
||||
Config: connection.ConnectionConfig{
|
||||
Type: "mysql",
|
||||
Database: "app",
|
||||
},
|
||||
},
|
||||
triggersResult: connection.QueryResult{
|
||||
Success: true,
|
||||
Data: []connection.TriggerDefinition{
|
||||
{Name: "trg_orders_audit", Timing: "AFTER", Event: "INSERT", Statement: "INSERT INTO audit_log ..."},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
service := NewService(backend)
|
||||
result, out, err := service.GetTriggers(context.Background(), nil, tableArgs{
|
||||
ConnectionID: "mysql-main",
|
||||
DBName: "app",
|
||||
TableName: "orders",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetTriggers returned error: %v", err)
|
||||
}
|
||||
if result == nil || result.IsError {
|
||||
t.Fatalf("expected success result, got %#v", result)
|
||||
}
|
||||
if len(out.Triggers) != 1 || out.Triggers[0].Name != "trg_orders_audit" {
|
||||
t.Fatalf("unexpected triggers output: %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSQLRejectsMutatingStatementsWithoutAllowMutating(t *testing.T) {
|
||||
backend := &fakeBackend{
|
||||
editableConnection: connection.SavedConnectionView{
|
||||
|
||||
Reference in New Issue
Block a user