feat(ai-tools): 新增能力探针并优化 MCP 接入指引

- 新增 inspect_connection_capabilities 内置探针与工具目录入口\n- 优化 MCP 外部客户端接入状态表达和重复写入保护\n- 同步调整 AI 设置相关测试与系统提示
This commit is contained in:
Syngnat
2026-06-09 00:59:25 +08:00
parent f7ed6f8e61
commit 0a229e8156
16 changed files with 394 additions and 159 deletions

View File

@@ -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('<AISettingsProvidersSection');
expect(source).toContain("import AISettingsMCPSection, { type MCPClientKey } from './ai/AISettingsMCPSection';");
expect(source).toContain("import AISettingsMCPSection from './ai/AISettingsMCPSection';");
expect(source).toContain('<AISettingsSidebar');
expect(source).toContain('<AISettingsSafetySection');
expect(source).toContain('<AISettingsContextSection');
@@ -46,10 +46,11 @@ describe('AISettingsModal edit password behavior', () => {
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()}');

View File

@@ -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('读取当前页签');

View File

@@ -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',

View File

@@ -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(
<AIMCPClientInstallPanel
statuses={[
@@ -62,20 +62,19 @@ describe('AIMCPClientInstallPanel', () => {
/>,
);
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('已接入');
});
});

View File

@@ -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<AIMCPClientInstallPanelProps> = ({
@@ -154,30 +139,22 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
gap: 10,
}}
>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}></div>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}></div>
<div style={{ fontSize: 12, color: overlayTheme.titleText, lineHeight: 1.7 }}>
GoNavi MCP GoNavi MCP Server Claude CodeCodex AI
GoNavi MCP GoNavi MCP Server Claude Code Codex AI
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{[
'只写入用户级 MCP 配置',
'不会下载安装 Claude Code / Codex',
'不会重装 GoNavi 程序',
].map((item) => (
<div
key={item}
style={{
padding: '4px 10px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: darkMode ? '#bfdbfe' : '#1d4ed8',
background: darkMode ? 'rgba(96,165,250,0.14)' : 'rgba(191,219,254,0.7)',
}}
>
{item}
</div>
))}
<div
style={{
padding: '10px 12px',
borderRadius: 12,
border: `1px solid ${darkMode ? 'rgba(96,165,250,0.18)' : 'rgba(96,165,250,0.22)'}`,
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.7)',
fontSize: 12,
color: overlayTheme.titleText,
lineHeight: 1.7,
}}
>
MCP GoNavi GoNavi
</div>
</div>
@@ -191,27 +168,21 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
flexDirection: 'column',
gap: 14,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}> GoNavi MCP AI </div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
1 GoNavi exe
</div>
>
<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 }}>
1 GoNavi MCP exe
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}> 1 </div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
1
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<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);
const detectionTone = getClientDetectionTone(status, darkMode);
return (
<button
key={status.client}
@@ -224,79 +195,57 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
background: active ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.7)'),
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 14,
flexDirection: 'column',
alignItems: 'stretch',
gap: 10,
textAlign: 'left',
minHeight: 92,
minHeight: 98,
transition: 'all 0.2s ease',
opacity: statusLoading ? 0.72 : 1,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0, flex: '1 1 auto' }}>
<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={{ display: 'flex', flexDirection: 'column', gap: 5, minWidth: 0, flex: '1 1 auto' }}>
<div
style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}
>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}>
{status.displayName}
</div>
<div
style={{
padding: '4px 10px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: tone.color,
background: tone.bg,
minWidth: 76,
textAlign: 'center',
}}
>
{tone.label}
</div>
</div>
<div style={{ fontSize: 12, color: overlayTheme.titleText, lineHeight: 1.6 }}>
{getClientOptionSummary(status)}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<div
aria-hidden
style={{
padding: '4px 10px',
width: 22,
height: 22,
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: detectionTone.color,
background: detectionTone.bg,
whiteSpace: 'nowrap',
alignSelf: 'flex-start',
minWidth: 76,
textAlign: 'center',
border: `1.5px solid ${active ? overlayTheme.selectedText : darkMode ? 'rgba(255,255,255,0.16)' : 'rgba(15,23,42,0.16)'}`,
background: active ? overlayTheme.selectedText : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{detectionTone.label}
{active ? <CheckCircleFilled style={{ color: '#fff', fontSize: 12 }} /> : null}
</div>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, minWidth: 0 }}>
{status.displayName}
</div>
</div>
<div
style={{
padding: '4px 10px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: tone.color,
background: tone.bg,
minWidth: 72,
textAlign: 'center',
}}
>
{tone.label}
</div>
</div>
<div style={{ fontSize: 11, color: overlayTheme.mutedText, lineHeight: 1.6, maxWidth: 300 }}>
{active ? '当前已选中这个客户端。' : '点击切换到这个客户端。'}
{' '}
{getClientDetectionSummary(status)}
<div style={{ fontSize: 12, color: overlayTheme.titleText, lineHeight: 1.7 }}>
{getClientOptionSummary(status)}
</div>
<div style={{ fontSize: 11, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
{active ? '当前已选中这个客户端。' : '点击后切换到这个客户端。'}
</div>
</button>
);
@@ -317,11 +266,11 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
2
{selectedStatus?.displayName || '客户端'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
{selectedStatus?.displayName || '客户端'}
{getStatusSummary(selectedStatus)}
</div>
{selectedStatus && (
<div
@@ -339,11 +288,15 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
)}
</div>
</div>
<div style={{ fontSize: 12, color: overlayTheme.titleText, lineHeight: 1.7 }}>
{getStatusSummary(selectedStatus)}
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{selectedStatus?.matchesCurrent
? '当前 GoNavi MCP 已经写入这个客户端'
: selectedStatus?.installed
? '检测到旧配置,建议更新到当前安装路径'
: '当前还没有把 GoNavi MCP 写入这个客户端'}
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{selectedStatus?.clientDetected
CLI {selectedStatus?.clientDetected
? `已检测到 ${resolveClientCommandName(selectedStatus)}`
: `未检测到 ${resolveClientCommandName(selectedStatus)},仍可先写配置`}
</div>
@@ -353,7 +306,7 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
</div>
)}
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{selectedStatus?.message || '未检测到安装状态'}
{selectedStatus?.message || '未检测到接入状态'}
</div>
{selectedStatus?.configPath && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
@@ -398,14 +351,16 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
CLI PATH
{getClientDetectionSummary(selectedStatus)}
{' '}
</div>
<Button
type={selectedStatus?.matchesCurrent ? 'default' : 'primary'}
onClick={onInstall}
loading={loading}
disabled={Boolean(selectedStatus?.matchesCurrent)}
style={{ borderRadius: 10, fontWeight: 600, minWidth: 180, height: 40 }}
style={{ borderRadius: 10, fontWeight: 600, minWidth: 192, height: 40 }}
>
{resolveActionLabel(selectedStatus)}
</Button>

View File

@@ -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 服务');

View File

@@ -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');
});
});

View File

@@ -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} 项前端能力信号`,
};
};

View File

@@ -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', {

View File

@@ -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: '读取当前工作区页签失败',

View File

@@ -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');

View File

@@ -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',

View File

@@ -41,6 +41,7 @@ const TOOL_ACTION_LABELS: Record<string, string> = {
inspect_table_bundle: '抓取完整表结构快照',
inspect_database_bundle: '抓取数据库结构总览',
inspect_current_connection: '读取当前连接摘要',
inspect_connection_capabilities: '读取当前连接能力矩阵',
inspect_saved_connections: '盘点本地已保存连接',
inspect_active_tab: '读取当前活动页签',
inspect_workspace_tabs: '盘点当前工作区页签',

View File

@@ -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 {

View File

@@ -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: "🧭",

View File

@@ -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);