mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-15 19:19:35 +08:00
✨ feat(ai-tools): 新增能力探针并优化 MCP 接入指引
- 新增 inspect_connection_capabilities 内置探针与工具目录入口\n- 优化 MCP 外部客户端接入状态表达和重复写入保护\n- 同步调整 AI 设置相关测试与系统提示
This commit is contained in:
@@ -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()}');
|
||||
|
||||
@@ -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('读取当前页签');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('已接入');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 Code、Codex 这类外部 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>
|
||||
|
||||
@@ -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 服务');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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} 项前端能力信号`,
|
||||
};
|
||||
};
|
||||
@@ -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', {
|
||||
|
||||
@@ -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: '读取当前工作区页签失败',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '盘点当前工作区页签',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: "🧭",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user