diff --git a/frontend/src/components/AIChatPanel.message-boundary.test.tsx b/frontend/src/components/AIChatPanel.message-boundary.test.tsx index 21e35be..556cfcd 100644 --- a/frontend/src/components/AIChatPanel.message-boundary.test.tsx +++ b/frontend/src/components/AIChatPanel.message-boundary.test.tsx @@ -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'); diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 9f94f9f..5a9bd72 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -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) { diff --git a/frontend/src/components/AISettingsModal.edit-password.test.tsx b/frontend/src/components/AISettingsModal.edit-password.test.tsx index 9ebec72..f2b00d8 100644 --- a/frontend/src/components/AISettingsModal.edit-password.test.tsx +++ b/frontend/src/components/AISettingsModal.edit-password.test.tsx @@ -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(' { + 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', () => { diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 8f1ecfa..b2fc6c7 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -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 => { - const result: Record = {}; - 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 => - Object.entries(env || {}) - .map(([key, value]) => `${key}=${value}`) - .join('\n'); - const AISettingsModal: React.FC = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => { const [providers, setProviders] = useState([]); const [activeProviderId, setActiveProviderId] = useState(''); @@ -272,36 +254,6 @@ const AISettingsModal: React.FC = ({ 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 = ({ open, onClose, darkMo const renderMCPSettings = () => (
-
- 这里的“安装到客户端”是把 GoNavi 注册成外部 AI 客户端可调用的 MCP Server,供 Claude Code 或 Codex 使用;不是 GoNavi 自己安装自己。 -
-
-
-
安装到外部客户端
-
- 先选择目标客户端,再把当前 GoNavi 安装路径写入它的用户级 MCP 配置。GoNavi 会自动处理配置文件路径,不需要你自己找本机 exe。 -
-
- -
- {mcpClientStatuses.map((status) => { - const active = selectedMCPClient === status.client; - const tone = getMCPClientStatusTone(status); - return ( -
{ - 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', - }} - > -
-
- {status.displayName} -
-
- {tone.label} -
-
-
- {status.matchesCurrent - ? '当前 GoNavi 安装路径已写入,打开客户端后可直接使用。' - : status.installed - ? '检测到已有安装记录,但建议更新为当前 GoNavi 路径。' - : '当前尚未写入 GoNavi MCP 配置。'} -
-
- ); - })} -
- -
-
-
- {selectedMCPClientStatus?.displayName || '客户端'} 状态 -
- {selectedMCPClientStatus && ( -
- {getMCPClientStatusTone(selectedMCPClientStatus).label} -
- )} -
-
- {selectedMCPClientStatus?.message || '未检测到安装状态'} -
- {selectedMCPClientStatus?.configPath && ( -
- 配置文件:{selectedMCPClientStatus.configPath} -
- )} - {selectedMCPClientCommandText && ( -
- 启动命令:{selectedMCPClientCommandText} -
- )} -
- - - -
-
- -
-
- 安装后重启对应客户端即可生效;若已经是当前路径,会直接提示无需重复安装。 -
- -
-
+ void loadMCPClientStatuses()} + onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()} + onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()} + onInstall={handleInstallSelectedMCPClient} + />
支持命令、参数、环境变量和超时,保存后会自动进入 AI 工具列表。
@@ -1411,81 +1220,23 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo 还没有 MCP 服务。常见形式是 `node server.js`、`uvx some-mcp-server`、`python -m server`。
)} - {mcpServers.map((server) => { - const serverTools = mcpTools.filter((tool) => tool.serverId === server.id); - return ( -
-
- updateMCPServerDraft(server.id, { name: event.target.value })} - placeholder="服务名称,例如:Filesystem / Browser / GitHub" - style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }} - /> - updateMCPServerDraft(server.id, { transport: value as AIMCPServerConfig['transport'] })} - options={[{ label: 'stdio', value: 'stdio' }]} - /> - updateMCPServerDraft(server.id, { command: event.target.value })} - placeholder="启动命令,例如:node / uvx / python" - style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }} - /> - updateMCPServerDraft(server.id, { timeoutSeconds: Number(event.target.value) || 20 })} - placeholder="超时(秒)" - style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }} - /> -
- onChange({ name: event.target.value })} + placeholder="服务名称,例如:Filesystem / Browser / GitHub" + style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }} + /> + + + onChange({ transport: value as AIMCPServerConfig['transport'] })} + options={[{ label: 'stdio', value: 'stdio' }]} + /> + + + onChange({ command: event.target.value })} + placeholder="启动命令,例如:node / uvx / python" + style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }} + /> + + + onChange({ timeoutSeconds: Number(event.target.value) || 20 })} + placeholder="超时(秒)" + style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }} + /> + +
+ + +