mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-15 19:19:35 +08:00
✨ feat(ai-settings): 优化 MCP 新手引导并新增连接盘点探针
This commit is contained in:
@@ -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('盘点当前工作区');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('已安装当前');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 Code、Codex 这类外部 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 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>
|
||||
</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'}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 服务');
|
||||
|
||||
@@ -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({
|
||||
|
||||
118
frontend/src/components/ai/aiSavedConnectionInsights.ts
Normal file
118
frontend/src/components/ai/aiSavedConnectionInsights.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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 上下文失败',
|
||||
|
||||
@@ -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('当前连接');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 执行日志',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: "📍",
|
||||
|
||||
Reference in New Issue
Block a user