mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 18:39:54 +08:00
🐛 fix(ai-mcp): 优化外部客户端默认选中逻辑
- 抽离 MCP 客户端状态归一化与命令格式化工具 - 优先默认选中已接入但需要更新的外部客户端 - 补充状态选择与启动命令格式化测试
This commit is contained in:
@@ -15,6 +15,7 @@ import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
|
||||
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { BUILTIN_AI_TOOL_INFO } from '../utils/aiToolRegistry';
|
||||
import { EMPTY_MCP_CLIENT_STATUSES, formatMCPLaunchCommand, normalizeMCPClientStatuses, pickPreferredMCPClient } from '../utils/mcpClientInstallStatus';
|
||||
import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';
|
||||
import AISettingsMCPSection, { type MCPClientKey } from './ai/AISettingsMCPSection';
|
||||
import AISettingsSidebar, { type AISettingsSectionKey } from './ai/AISettingsSidebar';
|
||||
@@ -106,62 +107,6 @@ const EMPTY_MCP_SERVER = (seed?: Partial<AIMCPServerConfig>): AIMCPServerConfig
|
||||
};
|
||||
};
|
||||
|
||||
const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [
|
||||
{
|
||||
client: 'claude-code',
|
||||
displayName: 'Claude Code',
|
||||
installed: false,
|
||||
matchesCurrent: false,
|
||||
message: '未检测到 Claude Code 用户级 GoNavi MCP 配置',
|
||||
},
|
||||
{
|
||||
client: 'codex',
|
||||
displayName: 'Codex',
|
||||
installed: false,
|
||||
matchesCurrent: false,
|
||||
message: '未检测到 Codex 用户级 GoNavi MCP 配置',
|
||||
},
|
||||
];
|
||||
|
||||
const normalizeMCPClientStatuses = (items?: AIMCPClientInstallStatus[]): AIMCPClientInstallStatus[] => {
|
||||
const baseMap = new Map<string, AIMCPClientInstallStatus>(
|
||||
EMPTY_MCP_CLIENT_STATUSES.map((item) => [item.client, { ...item }]),
|
||||
);
|
||||
(Array.isArray(items) ? items : []).forEach((item) => {
|
||||
if (!item || !item.client) {
|
||||
return;
|
||||
}
|
||||
const base = baseMap.get(item.client) || {
|
||||
client: item.client,
|
||||
displayName: item.client,
|
||||
installed: false,
|
||||
matchesCurrent: false,
|
||||
message: '',
|
||||
};
|
||||
baseMap.set(item.client, {
|
||||
...base,
|
||||
...item,
|
||||
displayName: item.displayName || base.displayName,
|
||||
message: item.message || base.message,
|
||||
args: Array.isArray(item.args) ? item.args : (base.args || []),
|
||||
});
|
||||
});
|
||||
return (['claude-code', 'codex'] as MCPClientKey[])
|
||||
.map((client) => baseMap.get(client))
|
||||
.filter((item): item is AIMCPClientInstallStatus => Boolean(item));
|
||||
};
|
||||
|
||||
const pickPreferredMCPClient = (items: AIMCPClientInstallStatus[], current?: MCPClientKey): MCPClientKey => {
|
||||
if (current && items.some((item) => item.client === current)) {
|
||||
return current;
|
||||
}
|
||||
const pending = items.find((item) => !item.matchesCurrent);
|
||||
if (pending?.client === 'claude-code' || pending?.client === 'codex') {
|
||||
return pending.client;
|
||||
}
|
||||
return 'claude-code';
|
||||
};
|
||||
|
||||
const waitFor = (delayMs: number) => new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, delayMs);
|
||||
});
|
||||
@@ -181,25 +126,6 @@ const waitForAIService = async (attempts = 6, delayMs = 80) => {
|
||||
return readAIService();
|
||||
};
|
||||
|
||||
const quoteMCPCommandPart = (value: string): string => {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
return /[\s"]/u.test(text) ? `"${text.replace(/"/g, '\\"')}"` : text;
|
||||
};
|
||||
|
||||
const formatMCPLaunchCommand = (input?: Pick<AIMCPClientInstallStatus, 'command' | 'args'> | Pick<MCPClientInstallResult, 'command' | 'args'> | null): string => {
|
||||
const command = String(input?.command || '').trim();
|
||||
if (!command) {
|
||||
return '';
|
||||
}
|
||||
const args = Array.isArray(input?.args)
|
||||
? input.args.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
return [command, ...args].map(quoteMCPCommandPart).filter(Boolean).join(' ');
|
||||
};
|
||||
|
||||
const EMPTY_SKILL = (): AISkillConfig => ({
|
||||
id: `skill-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: '',
|
||||
|
||||
67
frontend/src/utils/mcpClientInstallStatus.test.ts
Normal file
67
frontend/src/utils/mcpClientInstallStatus.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { AIMCPClientInstallStatus } from '../types';
|
||||
import {
|
||||
EMPTY_MCP_CLIENT_STATUSES,
|
||||
formatMCPLaunchCommand,
|
||||
normalizeMCPClientStatuses,
|
||||
pickPreferredMCPClient,
|
||||
} from './mcpClientInstallStatus';
|
||||
|
||||
describe('mcpClientInstallStatus helpers', () => {
|
||||
it('fills missing clients with default placeholder statuses', () => {
|
||||
const statuses = normalizeMCPClientStatuses([
|
||||
{
|
||||
client: 'codex',
|
||||
displayName: 'Codex',
|
||||
installed: true,
|
||||
matchesCurrent: true,
|
||||
message: '已检测到 Codex 用户级 GoNavi MCP 配置,且与当前 GoNavi 安装路径一致',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(statuses).toEqual([
|
||||
EMPTY_MCP_CLIENT_STATUSES[0],
|
||||
{
|
||||
client: 'codex',
|
||||
displayName: 'Codex',
|
||||
installed: true,
|
||||
matchesCurrent: true,
|
||||
message: '已检测到 Codex 用户级 GoNavi MCP 配置,且与当前 GoNavi 安装路径一致',
|
||||
args: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers an already-installed but outdated client over a completely uninstalled one', () => {
|
||||
const statuses: AIMCPClientInstallStatus[] = [
|
||||
{
|
||||
client: 'claude-code',
|
||||
displayName: 'Claude Code',
|
||||
installed: false,
|
||||
matchesCurrent: false,
|
||||
message: '未检测到 Claude Code 用户级 GoNavi MCP 配置',
|
||||
},
|
||||
{
|
||||
client: 'codex',
|
||||
displayName: 'Codex',
|
||||
installed: true,
|
||||
matchesCurrent: false,
|
||||
message: '已检测到 Codex 中的 GoNavi MCP 记录,但与当前 GoNavi 安装路径不一致,建议更新',
|
||||
},
|
||||
];
|
||||
|
||||
expect(pickPreferredMCPClient(statuses)).toBe('codex');
|
||||
});
|
||||
|
||||
it('keeps the user-selected client when it is still present in the latest status list', () => {
|
||||
expect(pickPreferredMCPClient(EMPTY_MCP_CLIENT_STATUSES, 'codex')).toBe('codex');
|
||||
});
|
||||
|
||||
it('formats quoted launch commands for display and clipboard use', () => {
|
||||
expect(formatMCPLaunchCommand({
|
||||
command: 'C:/Program Files/GoNavi/GoNavi.exe',
|
||||
args: ['mcp-server', '--stdio'],
|
||||
})).toBe('"C:/Program Files/GoNavi/GoNavi.exe" mcp-server --stdio');
|
||||
});
|
||||
});
|
||||
112
frontend/src/utils/mcpClientInstallStatus.ts
Normal file
112
frontend/src/utils/mcpClientInstallStatus.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { AIMCPClientInstallStatus } from '../types';
|
||||
|
||||
type MCPClientKey = 'claude-code' | 'codex';
|
||||
|
||||
export const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [
|
||||
{
|
||||
client: 'claude-code',
|
||||
displayName: 'Claude Code',
|
||||
installed: false,
|
||||
matchesCurrent: false,
|
||||
message: '未检测到 Claude Code 用户级 GoNavi MCP 配置',
|
||||
},
|
||||
{
|
||||
client: 'codex',
|
||||
displayName: 'Codex',
|
||||
installed: false,
|
||||
matchesCurrent: false,
|
||||
message: '未检测到 Codex 用户级 GoNavi MCP 配置',
|
||||
},
|
||||
];
|
||||
|
||||
const MCP_CLIENT_ORDER: MCPClientKey[] = ['claude-code', 'codex'];
|
||||
|
||||
const quoteMCPCommandPart = (value: string): string => {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
return /[\s"]/u.test(text) ? `"${text.replace(/"/g, '\\"')}"` : text;
|
||||
};
|
||||
|
||||
const isActionableClient = (client: string): client is MCPClientKey =>
|
||||
client === 'claude-code' || client === 'codex';
|
||||
|
||||
const hasStatusError = (status: AIMCPClientInstallStatus): boolean =>
|
||||
/失败|异常|错误|校验失败/u.test(String(status.message || ''));
|
||||
|
||||
const getMCPClientPriority = (status: AIMCPClientInstallStatus): number => {
|
||||
if (hasStatusError(status)) {
|
||||
return 0;
|
||||
}
|
||||
if (status.installed && !status.matchesCurrent) {
|
||||
return 1;
|
||||
}
|
||||
if (status.matchesCurrent) {
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
};
|
||||
|
||||
export const normalizeMCPClientStatuses = (items?: AIMCPClientInstallStatus[]): AIMCPClientInstallStatus[] => {
|
||||
const baseMap = new Map<string, AIMCPClientInstallStatus>(
|
||||
EMPTY_MCP_CLIENT_STATUSES.map((item) => [item.client, { ...item }]),
|
||||
);
|
||||
(Array.isArray(items) ? items : []).forEach((item) => {
|
||||
if (!item || !item.client) {
|
||||
return;
|
||||
}
|
||||
const base = baseMap.get(item.client) || {
|
||||
client: item.client,
|
||||
displayName: item.client,
|
||||
installed: false,
|
||||
matchesCurrent: false,
|
||||
message: '',
|
||||
};
|
||||
baseMap.set(item.client, {
|
||||
...base,
|
||||
...item,
|
||||
displayName: item.displayName || base.displayName,
|
||||
message: item.message || base.message,
|
||||
args: Array.isArray(item.args) ? item.args : (base.args || []),
|
||||
});
|
||||
});
|
||||
return MCP_CLIENT_ORDER
|
||||
.map((client) => baseMap.get(client))
|
||||
.filter((item): item is AIMCPClientInstallStatus => Boolean(item));
|
||||
};
|
||||
|
||||
export const pickPreferredMCPClient = (
|
||||
items: AIMCPClientInstallStatus[],
|
||||
current?: MCPClientKey,
|
||||
): MCPClientKey => {
|
||||
if (current && items.some((item) => item.client === current)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const ranked = items
|
||||
.filter((item): item is AIMCPClientInstallStatus & { client: MCPClientKey } => isActionableClient(item.client))
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const priorityDiff = getMCPClientPriority(left) - getMCPClientPriority(right);
|
||||
if (priorityDiff !== 0) {
|
||||
return priorityDiff;
|
||||
}
|
||||
return MCP_CLIENT_ORDER.indexOf(left.client) - MCP_CLIENT_ORDER.indexOf(right.client);
|
||||
});
|
||||
|
||||
return ranked[0]?.client || 'claude-code';
|
||||
};
|
||||
|
||||
export const formatMCPLaunchCommand = (
|
||||
input?: Pick<AIMCPClientInstallStatus, 'command' | 'args'> | { command?: string; args?: string[] } | null,
|
||||
): string => {
|
||||
const command = String(input?.command || '').trim();
|
||||
if (!command) {
|
||||
return '';
|
||||
}
|
||||
const args = Array.isArray(input?.args)
|
||||
? input.args.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
return [command, ...args].map(quoteMCPCommandPart).filter(Boolean).join(' ');
|
||||
};
|
||||
Reference in New Issue
Block a user