Files
MyGoNavi/frontend/src/components/ai/useAIMCPClientInstaller.ts
2026-06-22 15:12:42 +08:00

209 lines
8.6 KiB
TypeScript

import { useCallback, useMemo, useState } from 'react';
import type { AIMCPClientInstallStatus } from '../../types';
import {
buildRemoteMCPClientGuide,
buildRemoteMCPClientQuickStart,
EMPTY_MCP_CLIENT_STATUSES,
formatMCPLaunchCommand,
isRemoteMCPClientStatus,
normalizeMCPClientStatuses,
pickPreferredMCPClient,
type MCPClientKey,
} from '../../utils/mcpClientInstallStatus';
import {
translateMCPClientInstallCopy,
type MCPClientInstallTranslator,
} from './mcpClientInstallPanelState';
interface MCPClientInstallResult {
success?: boolean;
client?: 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>;
translate?: MCPClientInstallTranslator;
}
export const useAIMCPClientInstaller = ({
copyTextToClipboard,
messageApi,
onAfterInstall,
onBeforeInstall,
onConfigChanged,
resolveAIService,
translate,
}: 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(
() => isRemoteMCPClientStatus(selectedMCPClientStatus)
? buildRemoteMCPClientQuickStart(selectedMCPClientStatus).launchCommand
: formatMCPLaunchCommand(selectedMCPClientStatus),
[selectedMCPClientStatus],
);
const copy = useCallback((
key: string,
fallback: string,
params?: Record<string, string | number | boolean | null | undefined>,
) => translateMCPClientInstallCopy(translate, key, fallback, params), [translate]);
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 || copy('ai_chat.mcp_client.install.message.refresh_failed', 'Failed to refresh client installation status'));
}
} finally {
if (!silent) {
setMCPClientStatusLoading(false);
}
}
}, [copy, messageApi, resolveAIService, syncMCPClientStatuses]);
const handleInstallSelectedMCPClient = useCallback(async () => {
const remoteClient = isRemoteMCPClientStatus(selectedMCPClientStatus);
const targetClient = selectedMCPClientStatus?.client === 'codex' ? 'codex' : 'claude-code';
const targetLabel = selectedMCPClientStatus?.displayName || (targetClient === 'codex' ? 'Codex' : 'Claude Code');
if (remoteClient) {
try {
onBeforeInstall?.();
setMCPClientSelectionTouched(true);
await copyTextToClipboard(
buildRemoteMCPClientGuide(selectedMCPClientStatus),
copy('ai_chat.mcp_client.install.message.remote_guide_copied', '{{label}} remote connection guide copied', { label: targetLabel }),
);
} catch (error: any) {
void messageApi.error(error?.message || copy('ai_chat.mcp_client.install.message.remote_guide_copy_failed', 'Failed to copy {{label}} remote connection guide', { label: targetLabel }));
} finally {
onAfterInstall?.();
}
return;
}
if (selectedMCPClientStatus?.matchesCurrent) {
void messageApi.success(copy('ai_chat.mcp_client.install.message.already_connected', '{{label}} is already connected to current GoNavi MCP. No repeated write is needed.', { label: targetLabel }));
return;
}
try {
onBeforeInstall?.();
setMCPClientSelectionTouched(true);
const service = await resolveAIService();
if (targetClient === 'codex') {
if (typeof service?.AIInstallCodexMCP !== 'function') {
throw new Error(copy('ai_chat.mcp_client.install.message.codex_not_supported', 'This version does not support automatic Codex MCP installation yet'));
}
await service.AIInstallCodexMCP();
} else {
if (typeof service?.AIInstallClaudeCodeMCP !== 'function') {
throw new Error(copy('ai_chat.mcp_client.install.message.claude_not_supported', 'This version does not support automatic Claude Code MCP installation yet'));
}
await service.AIInstallClaudeCodeMCP();
}
await loadMCPClientStatuses({ silent: true });
onConfigChanged?.();
void messageApi.success(copy('ai_chat.mcp_client.install.message.install_success', 'Wrote {{label}} user-level MCP config', { label: targetLabel }));
} catch (error: any) {
void messageApi.error(error?.message || copy('ai_chat.mcp_client.install.message.install_failed', 'Failed to install {{label}} MCP', { label: targetLabel }));
} finally {
onAfterInstall?.();
}
}, [copy, copyTextToClipboard, loadMCPClientStatuses, messageApi, onAfterInstall, onBeforeInstall, onConfigChanged, resolveAIService, selectedMCPClientStatus]);
const handleCopySelectedMCPConfigPath = useCallback(async () => {
const configPath = String(selectedMCPClientStatus?.configPath || '').trim();
if (!configPath) {
void messageApi.warning(copy('ai_chat.mcp_client.install.message.config_path_missing', 'No config file path is available to copy'));
return;
}
try {
await copyTextToClipboard(configPath, copy('ai_chat.mcp_client.install.message.config_path_copied', 'Config file path copied'));
} catch (error: any) {
void messageApi.error(error?.message || copy('ai_chat.mcp_client.install.message.config_path_copy_failed', 'Failed to copy config file path'));
}
}, [copy, copyTextToClipboard, messageApi, selectedMCPClientStatus]);
const handleCopySelectedMCPLaunchCommand = useCallback(async () => {
if (!selectedMCPClientCommandText) {
void messageApi.warning(copy('ai_chat.mcp_client.install.message.launch_command_missing', 'No launch command is available to copy'));
return;
}
try {
await copyTextToClipboard(selectedMCPClientCommandText, copy('ai_chat.mcp_client.install.message.launch_command_copied', 'Launch command copied'));
} catch (error: any) {
void messageApi.error(error?.message || copy('ai_chat.mcp_client.install.message.launch_command_copy_failed', 'Failed to copy launch command'));
}
}, [copy, copyTextToClipboard, messageApi, selectedMCPClientCommandText]);
return {
handleCopySelectedMCPConfigPath,
handleCopySelectedMCPLaunchCommand,
handleInstallSelectedMCPClient,
handleSelectMCPClient,
loadMCPClientStatuses,
mcpClientStatusLoading,
mcpClientStatuses,
resetMCPClientSelectionTouched,
selectedMCPClient,
selectedMCPClientCommandText,
selectedMCPClientStatus,
syncMCPClientStatuses,
};
};