From 0a229e8156c6ec575544ccbdd553c5b800e02480 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 9 Jun 2026 00:59:25 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ai-tools):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=83=BD=E5=8A=9B=E6=8E=A2=E9=92=88=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20MCP=20=E6=8E=A5=E5=85=A5=E6=8C=87=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 inspect_connection_capabilities 内置探针与工具目录入口\n- 优化 MCP 外部客户端接入状态表达和重复写入保护\n- 同步调整 AI 设置相关测试与系统提示 --- .../AISettingsModal.edit-password.test.tsx | 11 +- .../ai/AIBuiltinToolsCatalog.test.tsx | 2 + .../components/ai/AIBuiltinToolsCatalog.tsx | 5 + .../ai/AIMCPClientInstallPanel.test.tsx | 29 ++- .../components/ai/AIMCPClientInstallPanel.tsx | 225 +++++++----------- .../ai/AISettingsMCPSection.test.tsx | 4 +- .../aiConnectionCapabilitiesInsights.test.ts | 82 +++++++ .../ai/aiConnectionCapabilitiesInsights.ts | 97 ++++++++ .../components/ai/aiLocalToolExecutor.test.ts | 35 +++ .../ai/aiSnapshotInspectionToolExecutor.ts | 13 + .../ai/aiSystemContextMessages.test.ts | 3 +- .../components/ai/aiSystemContextMessages.ts | 14 ++ .../messageBubble/AIMessageStatusBlocks.tsx | 1 + .../components/ai/useAIMCPClientInstaller.ts | 2 +- frontend/src/utils/aiBuiltinToolInfo.ts | 22 ++ frontend/src/utils/aiToolRegistry.test.ts | 8 + 16 files changed, 394 insertions(+), 159 deletions(-) create mode 100644 frontend/src/components/ai/aiConnectionCapabilitiesInsights.test.ts create mode 100644 frontend/src/components/ai/aiConnectionCapabilitiesInsights.ts diff --git a/frontend/src/components/AISettingsModal.edit-password.test.tsx b/frontend/src/components/AISettingsModal.edit-password.test.tsx index abff6a9..0e82fa2 100644 --- a/frontend/src/components/AISettingsModal.edit-password.test.tsx +++ b/frontend/src/components/AISettingsModal.edit-password.test.tsx @@ -35,7 +35,7 @@ describe('AISettingsModal edit password behavior', () => { expect(source).toContain("import AISettingsSafetySection from './ai/AISettingsSafetySection';"); expect(source).toContain("import AISettingsContextSection from './ai/AISettingsContextSection';"); expect(source).toContain(' { it('wires the external MCP client install panel actions back to the modal handlers', () => { expect(source).toContain('mcpClientStatuses={mcpClientStatuses}'); expect(source).toContain('selectedMCPClient={selectedMCPClient}'); - expect(source).toContain('const [mcpClientSelectionTouched, setMCPClientSelectionTouched] = useState(false);'); - expect(source).toContain('const handleSelectMCPClient = useCallback((client: MCPClientKey) => {'); - expect(source).toContain('pickPreferredMCPClient(normalizedStatuses, mcpClientSelectionTouched ? prev : undefined)'); - expect(source).toContain('setMCPClientSelectionTouched(true);'); + expect(source).toContain("import { useAIMCPClientInstaller } from './ai/useAIMCPClientInstaller';"); + expect(source).toContain('} = useAIMCPClientInstaller({'); + expect(source).toContain('handleSelectMCPClient,'); + expect(source).toContain('loadMCPClientStatuses,'); + expect(source).toContain('selectedMCPClientStatus,'); expect(source).toContain('onSelectClient={handleSelectMCPClient}'); expect(source).toContain('onRefreshStatus={() => void loadMCPClientStatuses()}'); expect(source).toContain('onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()}'); diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx index 7586dc6..e66d0e3 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx @@ -42,6 +42,8 @@ describe('AIBuiltinToolsCatalog', () => { expect(markup).toContain('inspect_ai_context'); expect(markup).toContain('查看当前连接'); expect(markup).toContain('inspect_current_connection'); + expect(markup).toContain('核对数据源能力边界'); + expect(markup).toContain('inspect_connection_capabilities'); expect(markup).toContain('盘点本地连接资产'); expect(markup).toContain('inspect_saved_connections'); expect(markup).toContain('读取当前页签'); diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx index eb90870..ab2fc7b 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx @@ -77,6 +77,11 @@ const BUILTIN_TOOL_FLOWS = [ steps: 'inspect_current_connection → get_databases / get_tables', description: '适合先确认当前活动数据源的类型、地址、当前库和 SSH/代理状态,再继续做库表探索或连接问题排查。', }, + { + title: '核对数据源能力边界', + steps: 'inspect_connection_capabilities → inspect_current_connection', + description: '适合先确认当前连接到底支不支持建库、删库、结果编辑、SQL 导出或近似计数,再解释为什么某些按钮没出现或某类操作只能只读。', + }, { title: '盘点本地连接资产', steps: 'inspect_saved_connections → inspect_current_connection / get_databases', diff --git a/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx b/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx index ac4e30f..7a124fc 100644 --- a/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx +++ b/frontend/src/components/ai/AIMCPClientInstallPanel.test.tsx @@ -6,7 +6,7 @@ import AIMCPClientInstallPanel from './AIMCPClientInstallPanel'; import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; describe('AIMCPClientInstallPanel', () => { - it('renders a clearer external-client selection flow instead of parallel install buttons', () => { + it('renders a clearer external-client selection flow with one selected target and one action button', () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain('安装到外部客户端'); - expect(markup).toContain('不是给 GoNavi 自己再装一个 MCP'); - expect(markup).toContain('把 GoNavi MCP 接入外部 AI 客户端'); - expect(markup).toContain('第 1 步:选择安装目标'); - expect(markup).toContain('第 2 步:确认并安装'); - expect(markup).toContain('未安装'); + expect(markup).toContain('接入外部客户端'); + expect(markup).toContain('不是给 GoNavi 自己安装 MCP'); + expect(markup).toContain('选择接入目标客户端'); + expect(markup).toContain('目标客户端'); + expect(markup).toContain('只会修改你选中的客户端用户级 MCP 配置'); + expect(markup).toContain('未接入'); expect(markup).toContain('需更新'); - expect(markup).toContain('CLI 已检测'); expect(markup).toContain('复制配置路径'); expect(markup).toContain('复制启动命令'); - expect(markup).toContain('更新到 Codex'); - expect(markup).toContain('当前目标:Codex'); - expect(markup).toContain('本机命令状态:已检测到 codex'); - expect(markup).toContain('不会下载安装 Claude Code / Codex'); + expect(markup).toContain('更新 Codex 配置'); + expect(markup).toContain('Codex 状态'); + expect(markup).toContain('CLI 检测:已检测到 codex'); + expect(markup).toContain('点击后切换到这个客户端'); }); it('shows an already-connected label and supports prewriting config when the client command is not detected locally', () => { @@ -126,9 +125,9 @@ describe('AIMCPClientInstallPanel', () => { />, ); - expect(markup).toContain('安装到 Claude Code'); - expect(markup).toContain('CLI 未检测'); + expect(markup).toContain('接入到 Claude Code'); + expect(markup).toContain('CLI 检测:未检测到 claude'); expect(markup).toContain('未检测到本机 claude 命令'); - expect(markup).toContain('已安装当前'); + expect(markup).toContain('已接入'); }); }); diff --git a/frontend/src/components/ai/AIMCPClientInstallPanel.tsx b/frontend/src/components/ai/AIMCPClientInstallPanel.tsx index 0503f16..78b82c9 100644 --- a/frontend/src/components/ai/AIMCPClientInstallPanel.tsx +++ b/frontend/src/components/ai/AIMCPClientInstallPanel.tsx @@ -28,7 +28,7 @@ const getStatusTone = (status: AIMCPClientInstallStatus | undefined, darkMode: b const messageText = String(status?.message || ''); if (status?.matchesCurrent) { return { - label: '已安装当前', + label: '已接入', color: '#16a34a', bg: darkMode ? 'rgba(34,197,94,0.18)' : 'rgba(34,197,94,0.12)', }; @@ -42,13 +42,13 @@ const getStatusTone = (status: AIMCPClientInstallStatus | undefined, darkMode: b } if (messageText.includes('失败') || messageText.includes('异常')) { return { - label: '读取异常', + label: '状态异常', color: '#dc2626', bg: darkMode ? 'rgba(239,68,68,0.18)' : 'rgba(239,68,68,0.1)', }; } return { - label: '未安装', + label: '未接入', color: darkMode ? 'rgba(255,255,255,0.72)' : '#64748b', bg: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(100,116,139,0.08)', }; @@ -62,67 +62,52 @@ const resolveClientCommandName = (status: AIMCPClientInstallStatus | undefined) return status?.client === 'codex' ? 'codex' : 'claude'; }; -const getClientDetectionTone = (status: AIMCPClientInstallStatus | undefined, darkMode: boolean) => { - if (status?.clientDetected) { - return { - label: 'CLI 已检测', - color: '#16a34a', - bg: darkMode ? 'rgba(34,197,94,0.18)' : 'rgba(34,197,94,0.12)', - }; - } - return { - label: 'CLI 未检测', - color: '#d97706', - bg: darkMode ? 'rgba(245,158,11,0.18)' : 'rgba(245,158,11,0.12)', - }; -}; - const getStatusSummary = (status: AIMCPClientInstallStatus | undefined) => { const label = status?.displayName || '这个客户端'; const messageText = String(status?.message || ''); if (status?.matchesCurrent) { - return `${label} 已安装当前 GoNavi MCP,可直接在这个客户端里调用。`; + return `${label} 已经接入当前这份 GoNavi MCP,可直接在这个客户端里调用。`; } if (status?.installed) { - return `${label} 已检测到旧的 GoNavi 安装路径,更新后会切到当前这份 GoNavi。`; + return `${label} 里已经有旧的 GoNavi 记录,更新后会切到当前这份 GoNavi。`; } if (messageText.includes('失败') || messageText.includes('异常')) { - return `${label} 的安装状态读取失败,建议先刷新检测。`; + return `${label} 的接入状态读取失败,建议先刷新检测。`; } - return `${label} 还没有安装 GoNavi MCP。`; + return `${label} 还没有写入 GoNavi MCP 配置。`; }; const getClientOptionSummary = (status: AIMCPClientInstallStatus | undefined) => { if (status?.matchesCurrent) { - return '当前 GoNavi 已安装到这个客户端。'; + return '当前 GoNavi 已经接入到这个客户端。'; } if (status?.installed) { - return '已发现旧配置,建议更新到当前安装路径。'; + return '检测到旧的 GoNavi 记录,建议更新为当前安装路径。'; } if (String(status?.message || '').includes('失败') || String(status?.message || '').includes('异常')) { - return '安装状态读取异常,建议先刷新再处理。'; + return '接入状态读取异常,建议先刷新再处理。'; } - return '尚未安装到这个客户端。'; + return '尚未写入 GoNavi MCP 配置。'; }; const getClientDetectionSummary = (status: AIMCPClientInstallStatus | undefined) => { const label = status?.displayName || '这个客户端'; const commandName = resolveClientCommandName(status); if (status?.clientDetected) { - return `已检测到本机 ${commandName} 命令,安装后重启 ${label} 即可验证。`; + return `已检测到本机 ${commandName} 命令,写入配置后重启 ${label} 即可验证。`; } - return `未检测到本机 ${commandName} 命令;如果 CLI 还没加入 PATH,也可以先安装到 ${label},稍后再重启验证。`; + return `未检测到本机 ${commandName} 命令;如果 CLI 还没加入 PATH,也可以先写入 ${label} 配置,稍后再重启验证。`; }; const resolveActionLabel = (status: AIMCPClientInstallStatus | undefined) => { const label = status?.displayName || '客户端'; if (status?.matchesCurrent) { - return `已安装到 ${label}`; + return `已接入 ${label}`; } if (status?.installed) { - return `更新到 ${label}`; + return `更新 ${label} 配置`; } - return `安装到 ${label}`; + return `接入到 ${label}`; }; const AIMCPClientInstallPanel: React.FC = ({ @@ -154,30 +139,22 @@ const AIMCPClientInstallPanel: React.FC = ({ gap: 10, }} > -
安装到外部客户端
+
接入外部客户端
- 这里的“安装”不是给 GoNavi 自己再装一个 MCP,而是把 GoNavi 的 MCP Server 配置写入 Claude Code、Codex 这类外部 AI 客户端。 + 这里不是给 GoNavi 自己安装 MCP,而是把 GoNavi 作为 MCP Server 接入 Claude Code 或 Codex 这类外部 AI 客户端。
-
- {[ - '只写入用户级 MCP 配置', - '不会下载安装 Claude Code / Codex', - '不会重装 GoNavi 程序', - ].map((item) => ( -
- {item} -
- ))} +
+ 只会修改你选中的客户端用户级 MCP 配置,不会安装新的 GoNavi,也不会替换 GoNavi 自己的程序文件。
@@ -191,27 +168,21 @@ const AIMCPClientInstallPanel: React.FC = ({ flexDirection: 'column', gap: 14, }} - > -
-
把 GoNavi MCP 接入外部 AI 客户端
-
- 先选 1 个安装目标,再执行安装或更新。GoNavi 会自动写入当前安装路径,不需要你自己找本机 exe,也不用手改配置。 -
+ > +
+
选择接入目标客户端
+
+ 选择 1 个客户端后再执行接入或更新。GoNavi 会自动把当前安装路径写入它的用户级 MCP 配置文件,不需要你自己找本机 exe 或手动改配置。
+
-
-
第 1 步:选择安装目标
-
- 这里只会处理你当前选中的 1 个外部客户端,并显示它是否已经安装过、是否需要更新。 -
-
-
+
目标客户端
+
{statuses.map((status) => { const client = status.client === 'codex' ? 'codex' : 'claude-code'; const active = selectedClient === client; const tone = getStatusTone(status, darkMode); - const detectionTone = getClientDetectionTone(status, darkMode); return ( ); @@ -317,11 +266,11 @@ const AIMCPClientInstallPanel: React.FC = ({ >
- 第 2 步:确认并安装 + {selectedStatus?.displayName || '客户端'} 状态
- 当前目标:{selectedStatus?.displayName || '客户端'} + {getStatusSummary(selectedStatus)}
{selectedStatus && (
= ({ )}
-
- {getStatusSummary(selectedStatus)} +
+ 当前状态:{selectedStatus?.matchesCurrent + ? '当前 GoNavi MCP 已经写入这个客户端' + : selectedStatus?.installed + ? '检测到旧配置,建议更新到当前安装路径' + : '当前还没有把 GoNavi MCP 写入这个客户端'}
- 本机命令状态:{selectedStatus?.clientDetected + CLI 检测:{selectedStatus?.clientDetected ? `已检测到 ${resolveClientCommandName(selectedStatus)}` : `未检测到 ${resolveClientCommandName(selectedStatus)},仍可先写配置`}
@@ -353,7 +306,7 @@ const AIMCPClientInstallPanel: React.FC = ({
)}
- 检测结果:{selectedStatus?.message || '未检测到安装状态'} + 检测结果:{selectedStatus?.message || '未检测到接入状态'}
{selectedStatus?.configPath && (
@@ -398,14 +351,16 @@ const AIMCPClientInstallPanel: React.FC = ({
- 命令未检测到时也可以先安装到目标客户端;后续装好 CLI 或把命令加入 PATH 后,重启对应客户端即可生效。已经是当前配置时按钮会自动禁用,避免重复安装。 + {getClientDetectionSummary(selectedStatus)} + {' '} + 已经是当前配置时按钮会自动禁用,避免重复写入。
diff --git a/frontend/src/components/ai/AISettingsMCPSection.test.tsx b/frontend/src/components/ai/AISettingsMCPSection.test.tsx index e831626..2af0b69 100644 --- a/frontend/src/components/ai/AISettingsMCPSection.test.tsx +++ b/frontend/src/components/ai/AISettingsMCPSection.test.tsx @@ -95,8 +95,8 @@ describe('AISettingsMCPSection', () => { />, ); - expect(markup).toContain('把 GoNavi MCP 接入外部 AI 客户端'); - expect(markup).toContain('CLI 未检测'); + expect(markup).toContain('选择接入目标客户端'); + expect(markup).toContain('接入到 Claude Code'); expect(markup).toContain('常见启动方式模板'); expect(markup).toContain('Node 脚本'); expect(markup).toContain('新增 MCP 服务'); diff --git a/frontend/src/components/ai/aiConnectionCapabilitiesInsights.test.ts b/frontend/src/components/ai/aiConnectionCapabilitiesInsights.test.ts new file mode 100644 index 0000000..1e7a65f --- /dev/null +++ b/frontend/src/components/ai/aiConnectionCapabilitiesInsights.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import type { SavedConnection, TabData } from '../../types'; +import { buildConnectionCapabilitiesSnapshot } from './aiConnectionCapabilitiesInsights'; + +describe('aiConnectionCapabilitiesInsights', () => { + it('builds the current connection capability snapshot from active context', () => { + const connections: SavedConnection[] = [{ + id: 'conn-1', + name: '订单主库', + config: { + type: 'mysql', + host: '127.0.0.1', + port: 3306, + user: 'root', + }, + }]; + + const snapshot = buildConnectionCapabilitiesSnapshot({ + activeContext: { + connectionId: 'conn-1', + dbName: 'crm', + }, + connections, + }); + + if (!snapshot.hasConnection || !snapshot.capabilities) { + throw new Error('expected active connection snapshot'); + } + + expect(snapshot.hasConnection).toBe(true); + expect(snapshot.resolvedFrom).toBe('activeContext'); + expect(snapshot.connectionName).toBe('订单主库'); + expect(snapshot.resolvedType).toBe('mysql'); + expect(snapshot.capabilities.supportsQueryEditor).toBe(true); + expect(snapshot.capabilities.supportsCreateDatabase).toBe(true); + expect(snapshot.capabilities.supportsRenameDatabase).toBe(false); + expect(snapshot.restrictions).toContain('rename_database_hidden'); + }); + + it('supports inspecting an explicit saved connection and resolves Oracle-like OceanBase capabilities', () => { + const connections: SavedConnection[] = [{ + id: 'conn-ob', + name: 'OceanBase Oracle 租户', + config: { + type: 'custom', + driver: 'oceanbase', + oceanBaseProtocol: 'oracle', + host: '10.0.0.18', + port: 2881, + user: 'sys', + }, + }]; + const tabs: TabData[] = [{ + id: 'tab-1', + title: '示例页签', + type: 'query', + connectionId: 'conn-ob', + dbName: 'SYS', + query: 'select 1', + }]; + + const snapshot = buildConnectionCapabilitiesSnapshot({ + connectionId: 'conn-ob', + tabs, + activeTabId: 'tab-1', + connections, + }); + + if (!snapshot.hasConnection || !snapshot.capabilities) { + throw new Error('expected explicit connection snapshot'); + } + + expect(snapshot.hasConnection).toBe(true); + expect(snapshot.resolvedFrom).toBe('explicit'); + expect(snapshot.resolvedType).toBe('oracle'); + expect(snapshot.capabilities.supportsCreateDatabase).toBe(false); + expect(snapshot.capabilities.supportsDropDatabase).toBe(false); + expect(snapshot.restrictions).toContain('create_database_hidden'); + expect(snapshot.restrictions).toContain('drop_database_hidden'); + }); +}); diff --git a/frontend/src/components/ai/aiConnectionCapabilitiesInsights.ts b/frontend/src/components/ai/aiConnectionCapabilitiesInsights.ts new file mode 100644 index 0000000..fc0fefc --- /dev/null +++ b/frontend/src/components/ai/aiConnectionCapabilitiesInsights.ts @@ -0,0 +1,97 @@ +import type { SavedConnection, TabData } from '../../types'; +import { + getDataSourceCapabilities, + resolveDataSourceType, +} from '../../utils/dataSourceCapabilities'; + +export const buildConnectionCapabilitiesSnapshot = (params: { + connectionId?: string | null; + activeContext?: { connectionId: string; dbName?: string } | null; + tabs?: TabData[]; + activeTabId?: string | null; + connections: SavedConnection[]; +}) => { + const { + connectionId, + activeContext = null, + tabs = [], + activeTabId = null, + connections, + } = params; + + const trimmedExplicitConnectionId = String(connectionId || '').trim(); + const activeTab = tabs.find((tab) => tab.id === activeTabId); + const fallbackConnectionId = String( + trimmedExplicitConnectionId + || activeContext?.connectionId + || activeTab?.connectionId + || '', + ).trim(); + + if (!fallbackConnectionId) { + return { + hasConnection: false, + message: '当前没有可用于能力分析的连接', + }; + } + + const connection = connections.find((item) => item.id === fallbackConnectionId); + if (!connection) { + return { + hasConnection: false, + connectionId: fallbackConnectionId, + message: '目标连接在本地缓存中不存在', + }; + } + + const resolvedType = resolveDataSourceType(connection.config); + const capabilities = getDataSourceCapabilities(connection.config); + const supportedActions = [ + capabilities.supportsQueryEditor ? 'query_editor' : '', + capabilities.supportsSqlQueryExport ? 'sql_query_export' : '', + capabilities.supportsCopyInsert ? 'copy_insert' : '', + capabilities.supportsCreateDatabase ? 'create_database' : '', + capabilities.supportsRenameDatabase ? 'rename_database' : '', + capabilities.supportsDropDatabase ? 'drop_database' : '', + capabilities.supportsApproximateTableCount ? 'approximate_table_count' : '', + capabilities.supportsApproximateTotalPages ? 'approximate_total_pages' : '', + ].filter(Boolean); + const restrictions = [ + !capabilities.supportsQueryEditor ? 'query_editor_disabled' : '', + capabilities.forceReadOnlyQueryResult ? 'force_readonly_query_result' : '', + capabilities.preferManualTotalCount ? 'prefer_manual_total_count' : '', + !capabilities.supportsCreateDatabase ? 'create_database_hidden' : '', + !capabilities.supportsRenameDatabase ? 'rename_database_hidden' : '', + !capabilities.supportsDropDatabase ? 'drop_database_hidden' : '', + ].filter(Boolean); + + const uiHints = [ + capabilities.forceReadOnlyQueryResult + ? '当前数据源的查询结果默认按只读方式展示,不提供直接编辑结果集。' + : '当前数据源的查询结果在满足定位条件时可进入编辑路径。', + capabilities.preferManualTotalCount + ? '结果总数优先走手动统计或延迟统计,避免直接依赖快速总数。' + : '结果总数可以优先使用常规统计路径。', + capabilities.supportsApproximateTableCount + ? '表浏览场景允许显示近似行数,减少大表统计开销。' + : '表浏览场景默认不使用近似行数。', + ]; + + return { + hasConnection: true, + resolvedFrom: trimmedExplicitConnectionId + ? 'explicit' + : (activeContext?.connectionId ? 'activeContext' : 'activeTab'), + connectionId: connection.id, + connectionName: connection.name, + configuredType: connection.config?.type || '', + resolvedType, + driver: connection.config?.driver || '', + oceanBaseProtocol: connection.config?.oceanBaseProtocol || '', + capabilities, + supportedActions, + restrictions, + uiHints, + message: `当前连接 ${connection.name} (${resolvedType || connection.config?.type || 'unknown'}) 已解析出 ${supportedActions.length} 项前端能力信号`, + }; +}; diff --git a/frontend/src/components/ai/aiLocalToolExecutor.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.test.ts index 259053d..18b960d 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.test.ts @@ -518,6 +518,41 @@ describe('aiLocalToolExecutor', () => { expect(result.content).toContain('"activeTabType":"query"'); }); + it('returns the current connection capability snapshot so the model can inspect supported UI actions', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_connection_capabilities', {}), + connections: [{ + id: 'conn-1', + name: '分析库', + config: { + type: 'clickhouse', + host: '10.10.1.30', + port: 8123, + user: 'default', + database: 'analytics', + }, + }], + activeContext: { + connectionId: 'conn-1', + dbName: 'analytics', + }, + mcpTools: [], + toolContextMap: new Map(), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"connectionName":"分析库"'); + expect(result.content).toContain('"resolvedType":"clickhouse"'); + expect(result.content).toContain('"supportsCreateDatabase":true'); + expect(result.content).toContain('"supportsRenameDatabase":false'); + expect(result.content).toContain('"forceReadOnlyQueryResult":true'); + expect(result.content).toContain('force_readonly_query_result'); + }); + it('returns the local saved connections snapshot so the model can find matching data sources by type or keyword', async () => { const result = await executeLocalAIToolCall({ toolCall: buildToolCall('inspect_saved_connections', { diff --git a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts index d44e948..dabd844 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts @@ -15,6 +15,7 @@ import type { import type { SqlLog } from '../../store'; import { BUILTIN_AI_TOOL_INFO } from '../../utils/aiToolRegistry'; import { buildAIContextSnapshot } from './aiContextInsights'; +import { buildConnectionCapabilitiesSnapshot } from './aiConnectionCapabilitiesInsights'; import { buildCurrentConnectionSnapshot } from './aiConnectionInsights'; import { buildMCPSetupSnapshot } from './aiMCPInsights'; import { buildAIGuidanceSnapshot } from './aiPromptInsights'; @@ -190,6 +191,17 @@ export async function executeSnapshotInspectionToolCall( })), success: true, }; + case 'inspect_connection_capabilities': + return { + content: JSON.stringify(buildConnectionCapabilitiesSnapshot({ + connectionId: args.connectionId, + activeContext, + tabs, + activeTabId, + connections, + })), + success: true, + }; case 'inspect_saved_connections': return { content: JSON.stringify(buildSavedConnectionsSnapshot({ @@ -276,6 +288,7 @@ export async function executeSnapshotInspectionToolCall( inspect_mcp_setup: '读取 MCP 配置状态失败', inspect_ai_guidance: '读取当前 AI 提示与技能配置失败', inspect_current_connection: '读取当前连接失败', + inspect_connection_capabilities: '读取当前连接能力矩阵失败', inspect_saved_connections: '读取本地连接清单失败', inspect_active_tab: '读取当前活动页签失败', inspect_workspace_tabs: '读取当前工作区页签失败', diff --git a/frontend/src/components/ai/aiSystemContextMessages.test.ts b/frontend/src/components/ai/aiSystemContextMessages.test.ts index da4ec15..613316e 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.test.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.test.ts @@ -68,7 +68,7 @@ describe('buildAISystemContextMessages', () => { connections: [connections[0]], tabs: [], activeTabId: null, - availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_mcp_setup', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_saved_connections', 'inspect_saved_queries', 'inspect_sql_snippets', 'get_columns'], + availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_mcp_setup', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_connection_capabilities', 'inspect_saved_connections', 'inspect_saved_queries', 'inspect_sql_snippets', 'get_columns'], skills, userPromptSettings, }); @@ -83,6 +83,7 @@ describe('buildAISystemContextMessages', () => { expect(joined).toContain('inspect_ai_guidance 读取真实提示与技能配置'); expect(joined).toContain('inspect_ai_context 读取当前挂载的表结构上下文'); expect(joined).toContain('inspect_current_connection'); + expect(joined).toContain('inspect_connection_capabilities'); expect(joined).toContain('inspect_saved_connections'); expect(joined).toContain('inspect_saved_queries'); expect(joined).toContain('inspect_sql_snippets'); diff --git a/frontend/src/components/ai/aiSystemContextMessages.ts b/frontend/src/components/ai/aiSystemContextMessages.ts index 1b180cf..1cd38b6 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.ts @@ -173,6 +173,19 @@ const appendAIGuidanceInspectionGuidance = ( }); }; +const appendConnectionCapabilityInspectionGuidance = ( + messages: AISystemContextMessage[], + availableToolNames: string[], +) => { + if (!availableToolNames.includes('inspect_connection_capabilities')) { + return; + } + messages.push({ + role: 'system', + content: '如果用户提到“为什么这里不能建库/删库/改库名”“为什么结果不能编辑”“这个数据源支持哪些前端动作”,优先调用 inspect_connection_capabilities 读取真实连接能力矩阵,不要凭数据库常识或记忆猜测。', + }); +}; + const resolveDatabaseDisplayType = (config: ConnectionConfig | undefined): string => { const dbType = config?.type || 'unknown'; return dbType === 'diros' ? 'Doris' : dbType.charAt(0).toUpperCase() + dbType.slice(1); @@ -403,6 +416,7 @@ SELECT * FROM users WHERE status = 1; content: '如果用户提到“当前连接”“当前数据源”“我现在连的是哪个库/地址”“这个连接走没走 SSH/代理”,优先调用 inspect_current_connection 读取当前活动连接摘要,不要凭界面或记忆猜测。', }); } + appendConnectionCapabilityInspectionGuidance(systemMessages, availableToolNames); if (availableToolNames.includes('inspect_saved_connections')) { systemMessages.push({ role: 'system', diff --git a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx index e381fdc..d3fe592 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx @@ -41,6 +41,7 @@ const TOOL_ACTION_LABELS: Record = { inspect_table_bundle: '抓取完整表结构快照', inspect_database_bundle: '抓取数据库结构总览', inspect_current_connection: '读取当前连接摘要', + inspect_connection_capabilities: '读取当前连接能力矩阵', inspect_saved_connections: '盘点本地已保存连接', inspect_active_tab: '读取当前活动页签', inspect_workspace_tabs: '盘点当前工作区页签', diff --git a/frontend/src/components/ai/useAIMCPClientInstaller.ts b/frontend/src/components/ai/useAIMCPClientInstaller.ts index da8b2f1..3ffd229 100644 --- a/frontend/src/components/ai/useAIMCPClientInstaller.ts +++ b/frontend/src/components/ai/useAIMCPClientInstaller.ts @@ -107,7 +107,7 @@ export const useAIMCPClientInstaller = ({ const targetClient = selectedMCPClientStatus?.client === 'codex' ? 'codex' : 'claude-code'; const targetLabel = selectedMCPClientStatus?.displayName || (targetClient === 'codex' ? 'Codex' : 'Claude Code'); if (selectedMCPClientStatus?.matchesCurrent) { - void messageApi.success(`${targetLabel} 已安装当前 GoNavi MCP,无需重复安装`); + void messageApi.success(`${targetLabel} 已接入当前 GoNavi MCP,无需重复写入`); return; } try { diff --git a/frontend/src/utils/aiBuiltinToolInfo.ts b/frontend/src/utils/aiBuiltinToolInfo.ts index a889f82..a6e6618 100644 --- a/frontend/src/utils/aiBuiltinToolInfo.ts +++ b/frontend/src/utils/aiBuiltinToolInfo.ts @@ -449,6 +449,28 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [ }, }, }, + { + name: "inspect_connection_capabilities", + icon: "🧱", + desc: "查看当前连接支持哪些前端能力", + detail: + "返回当前或指定连接的数据源能力矩阵,包括是否支持查询编辑器、SQL 导出、复制 INSERT、新建/重命名/删除数据库、结果是否强制只读,以及是否倾向手动总数或近似计数。适合用户问“为什么这里不能建库/删库”“这个数据源为什么结果不能编辑”“这个类型支持哪些操作”时,先读取真实能力边界。", + params: "connectionId?(默认取当前活动连接)", + tool: { + type: "function", + function: { + name: "inspect_connection_capabilities", + description: + "读取当前活动连接或指定 saved connection 的前端能力矩阵,包括是否支持查询编辑器、SQL 导出、复制 INSERT、新建/重命名/删除数据库、结果是否强制只读,以及是否适合手动总数或近似计数。适用于用户提到当前连接为什么不能建库、为什么结果集不能编辑、某种数据库类型到底支持哪些前端动作时,先读取真实能力配置,避免模型凭经验猜测。", + parameters: { + type: "object", + properties: { + connectionId: { type: "string", description: "可选,指定要查看的连接 ID;不传时默认读取当前活动连接" }, + }, + }, + }, + }, + }, { name: "inspect_saved_connections", icon: "🧭", diff --git a/frontend/src/utils/aiToolRegistry.test.ts b/frontend/src/utils/aiToolRegistry.test.ts index e35b0f9..bf5d86c 100644 --- a/frontend/src/utils/aiToolRegistry.test.ts +++ b/frontend/src/utils/aiToolRegistry.test.ts @@ -52,6 +52,13 @@ describe('aiToolRegistry', () => { expect(info?.tool.function.description).toContain('SSH/代理/HTTP 隧道状态'); }); + it('registers the connection-capability inspector as a builtin tool', () => { + const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_connection_capabilities'); + expect(info).toBeTruthy(); + expect(info?.desc).toContain('前端能力'); + expect(info?.tool.function.description).toContain('结果是否强制只读'); + }); + it('registers the saved-connections inspector as a builtin tool', () => { const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_saved_connections'); expect(info).toBeTruthy(); @@ -92,6 +99,7 @@ describe('aiToolRegistry', () => { expect(tools.some((item) => item.function.name === 'inspect_mcp_setup')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_ai_guidance')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_current_connection')).toBe(true); + expect(tools.some((item) => item.function.name === 'inspect_connection_capabilities')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_saved_connections')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_saved_queries')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_sql_snippets')).toBe(true);