♻️ refactor(ai-settings): 抽离 MCP 客户端安装状态逻辑

This commit is contained in:
Syngnat
2026-06-08 23:18:12 +08:00
parent 7ac9b89b7d
commit 1284c8c8ef
5 changed files with 211 additions and 124 deletions

View File

@@ -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[];
}
// 预设配置:每个预设映射到后端 typeopenai/anthropic/gemini/custom并附带默认 URL 和 Model
interface ProviderPreset {
key: string;
@@ -143,10 +135,6 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const [contextLevel, setContextLevel] = useState<AIContextLevel>('schema_only');
const [mcpServers, setMCPServers] = useState<AIMCPServerConfig[]>([]);
const [mcpTools, setMCPTools] = useState<AIMCPToolDescriptor[]>([]);
const [mcpClientStatuses, setMCPClientStatuses] = useState<AIMCPClientInstallStatus[]>(EMPTY_MCP_CLIENT_STATUSES);
const [selectedMCPClient, setSelectedMCPClient] = useState<MCPClientKey>('claude-code');
const [mcpClientSelectionTouched, setMCPClientSelectionTouched] = useState(false);
const [mcpClientStatusLoading, setMCPClientStatusLoading] = useState(false);
const [skills, setSkills] = useState<AISkillConfig[]>([]);
const [editingProvider, setEditingProvider] = useState<AIProviderConfig | null>(null);
const [isEditing, setIsEditing] = useState(false);
@@ -182,18 +170,6 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ 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<AISettingsModalProps> = ({ 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<AISettingsModalProps> = ({ 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<AISettingsModalProps> = ({ 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<AISettingsModalProps> = ({ 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<AISkillConfig>) => {
setSkills((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item));
};

View File

@@ -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;

View File

@@ -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[];

View File

@@ -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<AIMCPClientInstallStatus[]>;
AIInstallClaudeCodeMCP?: () => Promise<MCPClientInstallResult>;
AIInstallCodexMCP?: () => Promise<MCPClientInstallResult>;
}
interface UseAIMCPClientInstallerOptions {
copyTextToClipboard: (text: string, successMessage: string) => Promise<void>;
messageApi: MCPClientMessageApi;
onAfterInstall?: () => void;
onBeforeInstall?: () => void;
onConfigChanged?: () => void;
resolveAIService: () => Promise<AIMCPClientInstallerService | null>;
}
export const useAIMCPClientInstaller = ({
copyTextToClipboard,
messageApi,
onAfterInstall,
onBeforeInstall,
onConfigChanged,
resolveAIService,
}: UseAIMCPClientInstallerOptions) => {
const [mcpClientStatuses, setMCPClientStatuses] = useState<AIMCPClientInstallStatus[]>(EMPTY_MCP_CLIENT_STATUSES);
const [selectedMCPClient, setSelectedMCPClient] = useState<MCPClientKey>('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,
};
};

View File

@@ -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[] = [
{