diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index cbe8b69..2052b92 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -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 }; }; -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( - 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((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 | Pick | 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: '', diff --git a/frontend/src/utils/mcpClientInstallStatus.test.ts b/frontend/src/utils/mcpClientInstallStatus.test.ts new file mode 100644 index 0000000..9114f1e --- /dev/null +++ b/frontend/src/utils/mcpClientInstallStatus.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/utils/mcpClientInstallStatus.ts b/frontend/src/utils/mcpClientInstallStatus.ts new file mode 100644 index 0000000..bf8da9e --- /dev/null +++ b/frontend/src/utils/mcpClientInstallStatus.ts @@ -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( + 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 | { 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(' '); +};