feat(ai-settings): 优化 MCP 新手引导并新增连接盘点探针

This commit is contained in:
Syngnat
2026-06-08 22:42:12 +08:00
parent cc788d1b25
commit c0e29a08ba
15 changed files with 377 additions and 104 deletions

View File

@@ -40,6 +40,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_saved_connections');
expect(markup).toContain('读取当前页签');
expect(markup).toContain('inspect_active_tab');
expect(markup).toContain('盘点当前工作区');

View File

@@ -72,6 +72,11 @@ const BUILTIN_TOOL_FLOWS = [
steps: 'inspect_current_connection → get_databases / get_tables',
description: '适合先确认当前活动数据源的类型、地址、当前库和 SSH/代理状态,再继续做库表探索或连接问题排查。',
},
{
title: '盘点本地连接资产',
steps: 'inspect_saved_connections → inspect_current_connection / get_databases',
description: '适合先按关键词或类型筛出本地保存的数据源,再挑目标连接继续看当前状态或库表结构。',
},
{
title: '读取当前页签',
steps: 'inspect_active_tab → get_columns / get_indexes / execute_sql',

View File

@@ -62,18 +62,20 @@ 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('第 1 步:选择安装目标');
expect(markup).toContain('第 2 步:确认并安装');
expect(markup).toContain('未安装');
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('本机命令状态:已检测到 codex');
expect(markup).toContain('不会下载 Claude Code / Codex');
expect(markup).toContain('不会下载安装 Claude Code / Codex');
});
it('shows an already-connected label and supports prewriting config when the client command is not detected locally', () => {
@@ -124,8 +126,9 @@ describe('AIMCPClientInstallPanel', () => {
/>,
);
expect(markup).toContain('预写入 Claude Code 配置');
expect(markup).toContain('未检测命令');
expect(markup).toContain('安装到 Claude Code');
expect(markup).toContain('CLI 未检测');
expect(markup).toContain('未检测到本机 claude 命令');
expect(markup).toContain('已安装当前');
});
});

View File

@@ -29,7 +29,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)',
};
@@ -43,13 +43,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)',
};
@@ -66,13 +66,13 @@ const resolveClientCommandName = (status: AIMCPClientInstallStatus | undefined)
const getClientDetectionTone = (status: AIMCPClientInstallStatus | undefined, darkMode: boolean) => {
if (status?.clientDetected) {
return {
label: '命令已检测',
label: 'CLI 已检测',
color: '#16a34a',
bg: darkMode ? 'rgba(34,197,94,0.18)' : 'rgba(34,197,94,0.12)',
};
}
return {
label: '未检测命令',
label: 'CLI 未检测',
color: '#d97706',
bg: darkMode ? 'rgba(245,158,11,0.18)' : 'rgba(245,158,11,0.12)',
};
@@ -82,48 +82,48 @@ const getStatusSummary = (status: AIMCPClientInstallStatus | undefined) => {
const label = status?.displayName || '这个客户端';
const messageText = String(status?.message || '');
if (status?.matchesCurrent) {
return `${label}经写入当前 GoNavi 路径,可直接把 GoNavi 当作 MCP Server 使用。`;
return `${label}安装当前 GoNavi MCP,可直接在这个客户端里调用。`;
}
if (status?.installed) {
return `${label} 已检测到旧的 GoNavi 路径,更新后会改成当前这份 GoNavi。`;
return `${label} 已检测到旧的 GoNavi 安装路径,更新后会切到当前这份 GoNavi。`;
}
if (messageText.includes('失败') || messageText.includes('异常')) {
return '状态读取异常,建议先刷新,再决定是否写入。';
return `${label} 的安装状态读取失败,建议先刷新检测。`;
}
return `${label} 还没有写入 GoNavi MCP 配置`;
return `${label} 还没有安装 GoNavi MCP。`;
};
const getClientCardDescription = (status: AIMCPClientInstallStatus | undefined) => {
const getClientOptionSummary = (status: AIMCPClientInstallStatus | undefined) => {
if (status?.matchesCurrent) {
return '当前 GoNavi 路径已经写入,可直接在这个客户端里调用。';
return '当前 GoNavi 已安装到这个客户端。';
}
if (status?.installed) {
return '检测到旧的 GoNavi 记录,建议更新当前安装路径。';
return '已发现旧配置,建议更新当前安装路径。';
}
return '还没有写入 GoNavi MCP 配置。';
if (String(status?.message || '').includes('失败') || String(status?.message || '').includes('异常')) {
return '安装状态读取异常,建议先刷新再处理。';
}
return '尚未安装到这个客户端。';
};
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}`;
}
if (status?.clientDetected === false) {
return `预写入 ${label} 配置`;
}
return `写入 ${label} 配置`;
return `安装到 ${label}`;
};
const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
@@ -144,8 +144,42 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
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 CodeCodex AI 使
<div
style={{
padding: '14px 16px',
borderRadius: 14,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(59,130,246,0.08)' : 'rgba(59,130,246,0.06)',
display: 'flex',
flexDirection: 'column',
gap: 10,
}}
>
<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
</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>
</div>
<div
@@ -158,36 +192,22 @@ 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 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 Claude Code / Codex GoNavi
</div>
<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>
<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={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}> 1 </div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
GoNavi MCP
1
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{statuses.map((status) => {
const client = status.client === 'codex' ? 'codex' : 'claude-code';
const active = selectedClient === client;
@@ -199,56 +219,62 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
type="button"
onClick={() => onSelectClient(client)}
style={{
padding: '14px 14px 12px',
padding: '14px 16px',
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,
alignItems: 'center',
justifyContent: 'space-between',
gap: 14,
textAlign: 'left',
minHeight: 138,
minHeight: 92,
transition: 'all 0.2s ease',
opacity: statusLoading ? 0.72 : 1,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', 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 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', alignItems: 'flex-end', gap: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, minWidth: 0, flex: '1 1 auto' }}>
<div
style={{
padding: '4px 10px',
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
color: tone.color,
background: tone.bg,
whiteSpace: 'nowrap',
flexShrink: 0,
}}
style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}
>
{tone.label}
<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={{
@@ -259,20 +285,19 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
color: detectionTone.color,
background: detectionTone.bg,
whiteSpace: 'nowrap',
flexShrink: 0,
alignSelf: 'flex-start',
minWidth: 76,
textAlign: 'center',
}}
>
{detectionTone.label}
</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
{getClientCardDescription(status)}
</div>
<div style={{ fontSize: 11, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
{getClientDetectionSummary(status)}
</div>
<div style={{ fontSize: 11, color: overlayTheme.mutedText, lineHeight: 1.6, maxWidth: 300 }}>
{active ? '当前已选中这个客户端。' : '点击切换到这个客户端。'}
{' '}
{getClientDetectionSummary(status)}
</div>
</button>
);
@@ -293,11 +318,11 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
2
2
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
{selectedStatus?.displayName || '客户端'}
{selectedStatus?.displayName || '客户端'}
</div>
{selectedStatus && (
<div
@@ -374,7 +399,7 @@ 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
CLI PATH
</div>
<Button
type={selectedStatus?.matchesCurrent ? 'default' : 'primary'}

View File

@@ -48,6 +48,10 @@ describe('AIMCPServerCard', () => {
expect(markup).toContain('不要写 export');
expect(markup).toContain('当前阶段只支持 stdio');
expect(markup).toContain('实际启动命令预览');
expect(markup).toContain('操作说明');
expect(markup).toContain('测试工具发现');
expect(markup).toContain('不会保存配置');
expect(markup).toContain('测试通过后,上方会显示这条服务实际发现到的工具');
expect(markup).toContain('node server.js --stdio');
});
});

View File

@@ -329,6 +329,18 @@ export const AIMCPServerCard: React.FC<AIMCPServerCardProps> = ({
</div>
</div>
)}
<div style={{ padding: '10px 12px', borderRadius: 10, border: `1px solid ${cardBorder}`, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)' }}>
<div style={{ ...labelStyle, color: overlayTheme.titleText }}></div>
<div style={{ ...hintStyle(overlayTheme.mutedText), marginTop: 4 }}>
<strong></strong>
{' '}
{' '}<strong></strong>
{' '} MCP
{serverTools.length > 0
? ' 当前上方列出的工具,就是最近一次测试成功后发现到的别名。'
: ' 建议先测试成功,再保存;测试通过后,上方会显示这条服务实际发现到的工具。'}
</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>

View File

@@ -96,7 +96,7 @@ describe('AISettingsMCPSection', () => {
);
expect(markup).toContain('把 GoNavi MCP 接入外部 AI 客户端');
expect(markup).toContain('未检测命令');
expect(markup).toContain('CLI 未检测');
expect(markup).toContain('常见启动方式模板');
expect(markup).toContain('Node 脚本');
expect(markup).toContain('新增 MCP 服务');

View File

@@ -467,6 +467,58 @@ describe('aiLocalToolExecutor', () => {
expect(result.content).toContain('"activeTabType":"query"');
});
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', {
type: 'mysql',
keyword: '订单',
}),
connections: [
{
id: 'conn-1',
name: '订单主库',
config: {
type: 'mysql',
host: '10.10.1.18',
port: 3306,
user: 'root',
database: 'crm',
useSSH: true,
ssh: {
host: '192.168.1.8',
port: 22,
user: 'ops',
},
},
},
{
id: 'conn-2',
name: '分析仓库',
config: {
type: 'postgres',
host: '10.10.1.20',
port: 5432,
user: 'analyst',
database: 'dw',
},
},
],
mcpTools: [],
toolContextMap: new Map(),
runtime: {
getDatabases: vi.fn(),
getTables: vi.fn(),
},
});
expect(result.success).toBe(true);
expect(result.content).toContain('"totalMatched":1');
expect(result.content).toContain('"typeBreakdown":{"mysql":1}');
expect(result.content).toContain('"name":"订单主库"');
expect(result.content).toContain('"useSSH":true');
expect(result.content).not.toContain('分析仓库');
});
it('blocks execute_sql when the AI safety check rejects the statement', async () => {
const query = vi.fn();
const result = await executeLocalAIToolCall({

View File

@@ -0,0 +1,118 @@
import type { SavedConnection } from '../../types';
const normalizeLimit = (input: unknown, fallback: number, max: number): number => {
const value = Math.floor(Number(input) || fallback);
if (value < 1) return 1;
if (value > max) return max;
return value;
};
const normalizeKeyword = (input: unknown): string => String(input || '').trim().toLowerCase();
const matchesKeyword = (keyword: string, fields: Array<string | undefined>): boolean => {
if (!keyword) {
return true;
}
return fields.some((field) => String(field || '').toLowerCase().includes(keyword));
};
const normalizeTypeFilter = (input: unknown): string =>
String(input || '').trim().toLowerCase();
export const buildSavedConnectionsSnapshot = (params: {
connections: SavedConnection[];
keyword?: unknown;
type?: unknown;
limit?: unknown;
}) => {
const {
connections,
keyword,
type,
limit,
} = params;
const safeKeyword = normalizeKeyword(keyword);
const safeType = normalizeTypeFilter(type);
const safeLimit = normalizeLimit(limit, 20, 100);
const filteredConnections = connections.filter((connection) => {
const config = connection.config || {};
const connectionType = String(config.type || '').trim().toLowerCase();
if (safeType && connectionType !== safeType) {
return false;
}
return matchesKeyword(safeKeyword, [
connection.id,
connection.name,
config.type,
config.host,
config.database,
config.user,
config.driver,
config.topology,
config.ssh?.host,
config.proxy?.host,
config.httpTunnel?.host,
]);
});
const visibleConnections = filteredConnections
.slice(0, safeLimit)
.map((connection) => {
const config = connection.config || {};
const includeDatabases = Array.isArray(connection.includeDatabases)
? connection.includeDatabases.filter(Boolean)
: [];
const includeRedisDatabases = Array.isArray(connection.includeRedisDatabases)
? connection.includeRedisDatabases.filter((item) => typeof item === 'number')
: [];
return {
id: connection.id,
name: connection.name,
type: config.type || '',
host: config.host || '',
port: typeof config.port === 'number' ? config.port : null,
user: config.user || '',
configuredDatabase: config.database || '',
driver: config.driver || '',
topology: config.topology || 'single',
useSSL: config.useSSL === true,
useSSH: config.useSSH === true,
sshHost: config.useSSH ? (config.ssh?.host || '') : '',
sshPort: config.useSSH && typeof config.ssh?.port === 'number' ? config.ssh.port : null,
useProxy: config.useProxy === true,
proxyType: config.useProxy ? (config.proxy?.type || '') : '',
proxyHost: config.useProxy ? (config.proxy?.host || '') : '',
proxyPort: config.useProxy && typeof config.proxy?.port === 'number' ? config.proxy.port : null,
useHttpTunnel: config.useHttpTunnel === true,
httpTunnelHost: config.useHttpTunnel ? (config.httpTunnel?.host || '') : '',
httpTunnelPort: config.useHttpTunnel && typeof config.httpTunnel?.port === 'number' ? config.httpTunnel.port : null,
hasOpaqueURI: connection.hasOpaqueURI === true,
hasOpaqueDSN: connection.hasOpaqueDSN === true,
hasConnectionParams: Boolean(String(config.connectionParams || '').trim()),
includeDatabaseCount: includeDatabases.length,
includeRedisDatabaseCount: includeRedisDatabases.length,
};
});
const typeBreakdown = filteredConnections.reduce<Record<string, number>>((acc, connection) => {
const key = String(connection.config?.type || 'unknown').trim() || 'unknown';
acc[key] = (acc[key] || 0) + 1;
return acc;
}, {});
return {
keyword: safeKeyword,
type: safeType,
limit: safeLimit,
totalMatched: filteredConnections.length,
returnedConnections: visibleConnections.length,
truncated: filteredConnections.length > visibleConnections.length,
sshEnabledCount: filteredConnections.filter((item) => item.config?.useSSH === true).length,
proxyEnabledCount: filteredConnections.filter((item) => item.config?.useProxy === true).length,
httpTunnelEnabledCount: filteredConnections.filter((item) => item.config?.useHttpTunnel === true).length,
typeBreakdown,
connections: visibleConnections,
};
};

View File

@@ -25,6 +25,7 @@ import {
buildSavedQueriesSnapshot,
buildSqlSnippetsSnapshot,
} from './aiSavedSqlInsights';
import { buildSavedConnectionsSnapshot } from './aiSavedConnectionInsights';
import {
buildActiveTabSnapshot,
buildRecentSqlLogsSnapshot,
@@ -173,6 +174,16 @@ export async function executeSnapshotInspectionToolCall(
})),
success: true,
};
case 'inspect_saved_connections':
return {
content: JSON.stringify(buildSavedConnectionsSnapshot({
connections,
keyword: args.keyword,
type: args.type,
limit: args.limit,
})),
success: true,
};
case 'inspect_active_tab':
return {
content: JSON.stringify(buildActiveTabSnapshot({
@@ -248,6 +259,7 @@ export async function executeSnapshotInspectionToolCall(
inspect_mcp_setup: '读取 MCP 配置状态失败',
inspect_ai_guidance: '读取当前 AI 提示与技能配置失败',
inspect_current_connection: '读取当前连接失败',
inspect_saved_connections: '读取本地连接清单失败',
inspect_active_tab: '读取当前活动页签失败',
inspect_workspace_tabs: '读取当前工作区页签失败',
inspect_ai_context: '读取当前 AI 上下文失败',

View File

@@ -68,7 +68,7 @@ describe('buildAISystemContextMessages', () => {
connections: [connections[0]],
tabs: [],
activeTabId: null,
availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_runtime', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_mcp_setup', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_saved_queries', 'inspect_sql_snippets', 'get_columns'],
availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_runtime', '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'],
skills,
userPromptSettings,
});
@@ -82,6 +82,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_saved_connections');
expect(joined).toContain('inspect_saved_queries');
expect(joined).toContain('inspect_sql_snippets');
expect(joined).toContain('当前连接');

View File

@@ -387,6 +387,12 @@ SELECT * FROM users WHERE status = 1;
content: '如果用户提到“当前连接”“当前数据源”“我现在连的是哪个库/地址”“这个连接走没走 SSH/代理”,优先调用 inspect_current_connection 读取当前活动连接摘要,不要凭界面或记忆猜测。',
});
}
if (availableToolNames.includes('inspect_saved_connections')) {
systemMessages.push({
role: 'system',
content: '如果用户提到“本地存了哪些连接”“帮我找 mysql / postgres / redis 连接”“哪条连接配了 SSH/代理”,优先调用 inspect_saved_connections 读取真实本地连接清单,再决定继续查看哪条连接。',
});
}
if (availableToolNames.includes('inspect_saved_queries')) {
systemMessages.push({
role: 'system',

View File

@@ -40,6 +40,7 @@ const TOOL_ACTION_LABELS: Record<string, string> = {
inspect_table_bundle: '抓取完整表结构快照',
inspect_database_bundle: '抓取数据库结构总览',
inspect_current_connection: '读取当前连接摘要',
inspect_saved_connections: '盘点本地已保存连接',
inspect_active_tab: '读取当前活动页签',
inspect_workspace_tabs: '盘点当前工作区页签',
inspect_recent_sql_logs: '回看最近 SQL 执行日志',

View File

@@ -45,6 +45,13 @@ describe('aiToolRegistry', () => {
expect(info?.tool.function.description).toContain('SSH/代理/HTTP 隧道状态');
});
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();
expect(info?.desc).toContain('已保存连接');
expect(info?.tool.function.description).toContain('本地已保存连接清单');
});
it('registers the saved-query and sql-snippet inspectors as builtin tools', () => {
const savedQueryTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_saved_queries');
const snippetTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_sql_snippets');
@@ -77,6 +84,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_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);
expect(tools.some((item) => item.function.name === 'custom_probe')).toBe(true);

View File

@@ -434,6 +434,30 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [
},
},
},
{
name: "inspect_saved_connections",
icon: "🧭",
desc: "查看本地已保存连接清单",
detail:
"可按关键词或数据库类型过滤返回本地保存的数据源列表、连接类型分布以及每条连接的地址、当前库、SSH/代理/HTTP 隧道状态。适合用户问“我本地存了哪些连接”“帮我找 mysql / postgres 连接”“哪条连接配置了 SSH”时先读真实本地连接资产。",
params: "keyword?, type?, limit?",
tool: {
type: "function",
function: {
name: "inspect_saved_connections",
description:
"读取本地已保存连接清单可按关键词和数据库类型过滤并返回每条连接的类型、地址、当前库、SSH/代理/HTTP 隧道等摘要。适用于用户提到本地保存了哪些连接、要找哪条 mysql/postgres 连接、哪条连接启用了 SSH 或代理时,先读取真实本地连接资产再回答。",
parameters: {
type: "object",
properties: {
keyword: { type: "string", description: "可选按连接名、ID、类型、主机、数据库名或 SSH/代理地址做关键词筛选" },
type: { type: "string", description: "可选,只看某种数据库类型,例如 mysql、postgres、redis、mongodb" },
limit: { type: "number", description: "可选,最多返回多少条连接,默认 20最大 100" },
},
},
},
},
},
{
name: "inspect_active_tab",
icon: "📍",