diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 2052b92..3b1b090 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -15,15 +15,16 @@ 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 { EMPTY_MCP_CLIENT_STATUSES } from '../utils/mcpClientInstallStatus'; import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog'; -import AISettingsMCPSection, { type MCPClientKey } from './ai/AISettingsMCPSection'; +import AISettingsMCPSection from './ai/AISettingsMCPSection'; import AISettingsSidebar, { type AISettingsSectionKey } from './ai/AISettingsSidebar'; import AISettingsSafetySection from './ai/AISettingsSafetySection'; import AISettingsContextSection from './ai/AISettingsContextSection'; import AISettingsProvidersSection from './ai/AISettingsProvidersSection'; import AISettingsPromptsSection from './ai/AISettingsPromptsSection'; import AISettingsSkillsSection from './ai/AISettingsSkillsSection'; +import { useAIMCPClientInstaller } from './ai/useAIMCPClientInstaller'; interface AISettingsModalProps { open: boolean; onClose: () => void; @@ -32,15 +33,6 @@ interface AISettingsModalProps { focusProviderId?: string; } -interface MCPClientInstallResult { - success?: boolean; - client?: string; - message?: string; - configPath?: string; - command?: string; - args?: string[]; -} - // 预设配置:每个预设映射到后端 type(openai/anthropic/gemini/custom)并附带默认 URL 和 Model interface ProviderPreset { key: string; @@ -143,10 +135,6 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const [contextLevel, setContextLevel] = useState('schema_only'); const [mcpServers, setMCPServers] = useState([]); const [mcpTools, setMCPTools] = useState([]); - const [mcpClientStatuses, setMCPClientStatuses] = useState(EMPTY_MCP_CLIENT_STATUSES); - const [selectedMCPClient, setSelectedMCPClient] = useState('claude-code'); - const [mcpClientSelectionTouched, setMCPClientSelectionTouched] = useState(false); - const [mcpClientStatusLoading, setMCPClientStatusLoading] = useState(false); const [skills, setSkills] = useState([]); const [editingProvider, setEditingProvider] = useState(null); const [isEditing, setIsEditing] = useState(false); @@ -182,18 +170,6 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo value: tool.alias, })), ]), [mcpTools]); - const selectedMCPClientStatus = useMemo( - () => mcpClientStatuses.find((item) => item.client === selectedMCPClient) || mcpClientStatuses[0], - [mcpClientStatuses, selectedMCPClient], - ); - const selectedMCPClientCommandText = useMemo( - () => formatMCPLaunchCommand(selectedMCPClientStatus), - [selectedMCPClientStatus], - ); - const handleSelectMCPClient = useCallback((client: MCPClientKey) => { - setMCPClientSelectionTouched(true); - setSelectedMCPClient(client); - }, []); const resolveAIService = useCallback(async () => { const service = await waitForAIService(); @@ -208,35 +184,6 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo return null; }, []); - const loadMCPClientStatuses = useCallback(async (options?: { silent?: boolean }) => { - const silent = options?.silent === true; - if (!silent) { - setMCPClientStatusLoading(true); - } - try { - const Service = await resolveAIService(); - if (typeof Service?.AIGetMCPClientInstallStatuses !== 'function') { - return; - } - const result = await Service.AIGetMCPClientInstallStatuses(); - if (Array.isArray(result)) { - const normalizedStatuses = normalizeMCPClientStatuses(result); - setMCPClientStatuses(normalizedStatuses); - setSelectedMCPClient((prev) => pickPreferredMCPClient(normalizedStatuses, mcpClientSelectionTouched ? prev : undefined)); - } - } catch (e: any) { - if (silent) { - console.warn('[AI] refresh mcp client statuses failed', e); - } else { - void messageApi.error(e?.message || '刷新客户端安装状态失败'); - } - } finally { - if (!silent) { - setMCPClientStatusLoading(false); - } - } - }, [mcpClientSelectionTouched, messageApi, resolveAIService]); - const copyTextToClipboard = useCallback(async (text: string, successMessage: string) => { if (typeof navigator?.clipboard?.writeText !== 'function') { throw new Error('当前环境不支持复制到剪贴板'); @@ -245,6 +192,28 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo void messageApi.success(successMessage); }, [messageApi]); + const { + handleCopySelectedMCPConfigPath, + handleCopySelectedMCPLaunchCommand, + handleInstallSelectedMCPClient, + handleSelectMCPClient, + loadMCPClientStatuses, + mcpClientStatusLoading, + mcpClientStatuses, + resetMCPClientSelectionTouched, + selectedMCPClient, + selectedMCPClientCommandText, + selectedMCPClientStatus, + syncMCPClientStatuses, + } = useAIMCPClientInstaller({ + resolveAIService, + messageApi, + copyTextToClipboard, + onBeforeInstall: () => setLoading(true), + onAfterInstall: () => setLoading(false), + onConfigChanged: () => window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')), + }); + const loadConfig = useCallback(async () => { try { const Service = await resolveAIService(); @@ -291,20 +260,18 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo if (Array.isArray(mcpToolsRes)) setMCPTools(mcpToolsRes); if (Array.isArray(skillsRes)) setSkills(skillsRes); if (Array.isArray(mcpClientStatusesRes)) { - const normalizedStatuses = normalizeMCPClientStatuses(mcpClientStatusesRes); - setMCPClientStatuses(normalizedStatuses); - setSelectedMCPClient((prev) => pickPreferredMCPClient(normalizedStatuses, mcpClientSelectionTouched ? prev : undefined)); + syncMCPClientStatuses(mcpClientStatusesRes); } } catch (e) { console.warn('Failed to load AI config', e); } - }, [mcpClientSelectionTouched, resolveAIService]); + }, [resolveAIService, syncMCPClientStatuses]); useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]); useEffect(() => { if (open) { - setMCPClientSelectionTouched(false); + resetMCPClientSelectionTouched(); } - }, [open]); + }, [open, resetMCPClientSelectionTouched]); useEffect(() => { if (!open || !focusProviderId) { @@ -569,64 +536,6 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo } }; - const handleInstallSelectedMCPClient = async () => { - 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,无需重复安装`); - return; - } - try { - setLoading(true); - setMCPClientSelectionTouched(true); - const Service = await resolveAIService(); - let result: MCPClientInstallResult; - if (targetClient === 'codex') { - if (typeof Service?.AIInstallCodexMCP !== 'function') { - throw new Error('当前版本暂不支持自动安装 Codex MCP'); - } - result = await Service.AIInstallCodexMCP() as MCPClientInstallResult; - } else { - if (typeof Service?.AIInstallClaudeCodeMCP !== 'function') { - throw new Error('当前版本暂不支持自动安装 Claude Code MCP'); - } - result = await Service.AIInstallClaudeCodeMCP() as MCPClientInstallResult; - } - await loadMCPClientStatuses({ silent: true }); - window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')); - void messageApi.success(result?.message || `已写入 ${targetLabel} 用户级 MCP 配置`); - } catch (e: any) { - void messageApi.error(e?.message || `安装 ${targetLabel} MCP 失败`); - } finally { - setLoading(false); - } - }; - - const handleCopySelectedMCPConfigPath = useCallback(async () => { - const configPath = String(selectedMCPClientStatus?.configPath || '').trim(); - if (!configPath) { - void messageApi.warning('当前没有可复制的配置文件路径'); - return; - } - try { - await copyTextToClipboard(configPath, '配置文件路径已复制'); - } catch (e: any) { - void messageApi.error(e?.message || '复制配置文件路径失败'); - } - }, [copyTextToClipboard, messageApi, selectedMCPClientStatus]); - - const handleCopySelectedMCPLaunchCommand = useCallback(async () => { - if (!selectedMCPClientCommandText) { - void messageApi.warning('当前没有可复制的启动命令'); - return; - } - try { - await copyTextToClipboard(selectedMCPClientCommandText, '启动命令已复制'); - } catch (e: any) { - void messageApi.error(e?.message || '复制启动命令失败'); - } - }, [copyTextToClipboard, messageApi, selectedMCPClientCommandText]); - const updateSkillDraft = (id: string, patch: Partial) => { setSkills((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item)); }; diff --git a/frontend/src/components/ai/AIMCPClientInstallPanel.tsx b/frontend/src/components/ai/AIMCPClientInstallPanel.tsx index 7bedd9a..0503f16 100644 --- a/frontend/src/components/ai/AIMCPClientInstallPanel.tsx +++ b/frontend/src/components/ai/AIMCPClientInstallPanel.tsx @@ -3,10 +3,9 @@ import { Button } from 'antd'; import { CheckCircleFilled, CopyOutlined, ReloadOutlined } from '@ant-design/icons'; import type { AIMCPClientInstallStatus } from '../../types'; +import type { MCPClientKey } from '../../utils/mcpClientInstallStatus'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; -type MCPClientKey = 'claude-code' | 'codex'; - interface AIMCPClientInstallPanelProps { statuses: AIMCPClientInstallStatus[]; selectedClient: MCPClientKey; diff --git a/frontend/src/components/ai/AISettingsMCPSection.tsx b/frontend/src/components/ai/AISettingsMCPSection.tsx index 5bb50a1..70f8024 100644 --- a/frontend/src/components/ai/AISettingsMCPSection.tsx +++ b/frontend/src/components/ai/AISettingsMCPSection.tsx @@ -3,12 +3,13 @@ import { Button } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; import type { AIMCPClientInstallStatus, AIMCPServerConfig, AIMCPToolDescriptor } from '../../types'; +import type { MCPClientKey } from '../../utils/mcpClientInstallStatus'; import { MCP_SERVER_DRAFT_TEMPLATES } from '../../utils/mcpServerTemplates'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import AIMCPClientInstallPanel from './AIMCPClientInstallPanel'; import AIMCPServerCard from './AIMCPServerCard'; -export type MCPClientKey = 'claude-code' | 'codex'; +export type { MCPClientKey } from '../../utils/mcpClientInstallStatus'; interface AISettingsMCPSectionProps { mcpClientStatuses: AIMCPClientInstallStatus[]; diff --git a/frontend/src/components/ai/useAIMCPClientInstaller.ts b/frontend/src/components/ai/useAIMCPClientInstaller.ts new file mode 100644 index 0000000..da8b2f1 --- /dev/null +++ b/frontend/src/components/ai/useAIMCPClientInstaller.ts @@ -0,0 +1,178 @@ +import { useCallback, useMemo, useState } from 'react'; + +import type { AIMCPClientInstallStatus } from '../../types'; +import { + EMPTY_MCP_CLIENT_STATUSES, + formatMCPLaunchCommand, + normalizeMCPClientStatuses, + pickPreferredMCPClient, + type MCPClientKey, +} from '../../utils/mcpClientInstallStatus'; + +interface MCPClientInstallResult { + success?: boolean; + client?: string; + message?: string; + configPath?: string; + command?: string; + args?: string[]; +} + +interface MCPClientMessageApi { + error: (content: string) => unknown; + success: (content: string) => unknown; + warning: (content: string) => unknown; +} + +interface AIMCPClientInstallerService { + AIGetMCPClientInstallStatuses?: () => Promise; + AIInstallClaudeCodeMCP?: () => Promise; + AIInstallCodexMCP?: () => Promise; +} + +interface UseAIMCPClientInstallerOptions { + copyTextToClipboard: (text: string, successMessage: string) => Promise; + messageApi: MCPClientMessageApi; + onAfterInstall?: () => void; + onBeforeInstall?: () => void; + onConfigChanged?: () => void; + resolveAIService: () => Promise; +} + +export const useAIMCPClientInstaller = ({ + copyTextToClipboard, + messageApi, + onAfterInstall, + onBeforeInstall, + onConfigChanged, + resolveAIService, +}: UseAIMCPClientInstallerOptions) => { + const [mcpClientStatuses, setMCPClientStatuses] = useState(EMPTY_MCP_CLIENT_STATUSES); + const [selectedMCPClient, setSelectedMCPClient] = useState('claude-code'); + const [mcpClientSelectionTouched, setMCPClientSelectionTouched] = useState(false); + const [mcpClientStatusLoading, setMCPClientStatusLoading] = useState(false); + + const selectedMCPClientStatus = useMemo( + () => mcpClientStatuses.find((item) => item.client === selectedMCPClient) || mcpClientStatuses[0], + [mcpClientStatuses, selectedMCPClient], + ); + const selectedMCPClientCommandText = useMemo( + () => formatMCPLaunchCommand(selectedMCPClientStatus), + [selectedMCPClientStatus], + ); + + const syncMCPClientStatuses = useCallback((items?: AIMCPClientInstallStatus[]) => { + const normalizedStatuses = normalizeMCPClientStatuses(items); + setMCPClientStatuses(normalizedStatuses); + setSelectedMCPClient((prev) => pickPreferredMCPClient(normalizedStatuses, mcpClientSelectionTouched ? prev : undefined)); + }, [mcpClientSelectionTouched]); + + const handleSelectMCPClient = useCallback((client: MCPClientKey) => { + setMCPClientSelectionTouched(true); + setSelectedMCPClient(client); + }, []); + + const resetMCPClientSelectionTouched = useCallback(() => { + setMCPClientSelectionTouched(false); + }, []); + + const loadMCPClientStatuses = useCallback(async (options?: { silent?: boolean }) => { + const silent = options?.silent === true; + if (!silent) { + setMCPClientStatusLoading(true); + } + try { + const service = await resolveAIService(); + if (typeof service?.AIGetMCPClientInstallStatuses !== 'function') { + return; + } + const result = await service.AIGetMCPClientInstallStatuses(); + if (Array.isArray(result)) { + syncMCPClientStatuses(result); + } + } catch (error: any) { + if (silent) { + console.warn('[AI] refresh mcp client statuses failed', error); + } else { + void messageApi.error(error?.message || '刷新客户端安装状态失败'); + } + } finally { + if (!silent) { + setMCPClientStatusLoading(false); + } + } + }, [messageApi, resolveAIService, syncMCPClientStatuses]); + + const handleInstallSelectedMCPClient = useCallback(async () => { + 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,无需重复安装`); + return; + } + try { + onBeforeInstall?.(); + setMCPClientSelectionTouched(true); + const service = await resolveAIService(); + let result: MCPClientInstallResult; + if (targetClient === 'codex') { + if (typeof service?.AIInstallCodexMCP !== 'function') { + throw new Error('当前版本暂不支持自动安装 Codex MCP'); + } + result = await service.AIInstallCodexMCP(); + } else { + if (typeof service?.AIInstallClaudeCodeMCP !== 'function') { + throw new Error('当前版本暂不支持自动安装 Claude Code MCP'); + } + result = await service.AIInstallClaudeCodeMCP(); + } + await loadMCPClientStatuses({ silent: true }); + onConfigChanged?.(); + void messageApi.success(result?.message || `已写入 ${targetLabel} 用户级 MCP 配置`); + } catch (error: any) { + void messageApi.error(error?.message || `安装 ${targetLabel} MCP 失败`); + } finally { + onAfterInstall?.(); + } + }, [loadMCPClientStatuses, messageApi, onAfterInstall, onBeforeInstall, onConfigChanged, resolveAIService, selectedMCPClientStatus]); + + const handleCopySelectedMCPConfigPath = useCallback(async () => { + const configPath = String(selectedMCPClientStatus?.configPath || '').trim(); + if (!configPath) { + void messageApi.warning('当前没有可复制的配置文件路径'); + return; + } + try { + await copyTextToClipboard(configPath, '配置文件路径已复制'); + } catch (error: any) { + void messageApi.error(error?.message || '复制配置文件路径失败'); + } + }, [copyTextToClipboard, messageApi, selectedMCPClientStatus]); + + const handleCopySelectedMCPLaunchCommand = useCallback(async () => { + if (!selectedMCPClientCommandText) { + void messageApi.warning('当前没有可复制的启动命令'); + return; + } + try { + await copyTextToClipboard(selectedMCPClientCommandText, '启动命令已复制'); + } catch (error: any) { + void messageApi.error(error?.message || '复制启动命令失败'); + } + }, [copyTextToClipboard, messageApi, selectedMCPClientCommandText]); + + return { + handleCopySelectedMCPConfigPath, + handleCopySelectedMCPLaunchCommand, + handleInstallSelectedMCPClient, + handleSelectMCPClient, + loadMCPClientStatuses, + mcpClientStatusLoading, + mcpClientStatuses, + resetMCPClientSelectionTouched, + selectedMCPClient, + selectedMCPClientCommandText, + selectedMCPClientStatus, + syncMCPClientStatuses, + }; +}; diff --git a/frontend/src/utils/mcpClientInstallStatus.ts b/frontend/src/utils/mcpClientInstallStatus.ts index 322c47f..6a2edba 100644 --- a/frontend/src/utils/mcpClientInstallStatus.ts +++ b/frontend/src/utils/mcpClientInstallStatus.ts @@ -1,6 +1,6 @@ import type { AIMCPClientInstallStatus } from '../types'; -type MCPClientKey = 'claude-code' | 'codex'; +export type MCPClientKey = 'claude-code' | 'codex'; export const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [ {