Files
MyGoNavi/frontend/src/components/ai/AIMCPClientInstallPanel.tsx
Syngnat 0a229e8156 feat(ai-tools): 新增能力探针并优化 MCP 接入指引
- 新增 inspect_connection_capabilities 内置探针与工具目录入口\n- 优化 MCP 外部客户端接入状态表达和重复写入保护\n- 同步调整 AI 设置相关测试与系统提示
2026-06-09 00:59:25 +08:00

373 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
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';
interface AIMCPClientInstallPanelProps {
statuses: AIMCPClientInstallStatus[];
selectedClient: MCPClientKey;
selectedStatus?: AIMCPClientInstallStatus;
selectedCommandText: string;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
cardBg: string;
cardBorder: string;
loading: boolean;
statusLoading: boolean;
onSelectClient: (client: MCPClientKey) => void;
onRefreshStatus: () => void;
onCopyConfigPath: () => void;
onCopyLaunchCommand: () => void;
onInstall: () => void;
}
const getStatusTone = (status: AIMCPClientInstallStatus | undefined, darkMode: boolean) => {
const messageText = String(status?.message || '');
if (status?.matchesCurrent) {
return {
label: '已接入',
color: '#16a34a',
bg: darkMode ? 'rgba(34,197,94,0.18)' : 'rgba(34,197,94,0.12)',
};
}
if (status?.installed) {
return {
label: '需更新',
color: '#d97706',
bg: darkMode ? 'rgba(245,158,11,0.18)' : 'rgba(245,158,11,0.12)',
};
}
if (messageText.includes('失败') || messageText.includes('异常')) {
return {
label: '状态异常',
color: '#dc2626',
bg: darkMode ? 'rgba(239,68,68,0.18)' : 'rgba(239,68,68,0.1)',
};
}
return {
label: '未接入',
color: darkMode ? 'rgba(255,255,255,0.72)' : '#64748b',
bg: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(100,116,139,0.08)',
};
};
const resolveClientCommandName = (status: AIMCPClientInstallStatus | undefined) => {
const command = String(status?.clientCommand || '').trim();
if (command) {
return command;
}
return status?.client === 'codex' ? 'codex' : 'claude';
};
const getStatusSummary = (status: AIMCPClientInstallStatus | undefined) => {
const label = status?.displayName || '这个客户端';
const messageText = String(status?.message || '');
if (status?.matchesCurrent) {
return `${label} 已经接入当前这份 GoNavi MCP可直接在这个客户端里调用。`;
}
if (status?.installed) {
return `${label} 里已经有旧的 GoNavi 记录,更新后会切到当前这份 GoNavi。`;
}
if (messageText.includes('失败') || messageText.includes('异常')) {
return `${label} 的接入状态读取失败,建议先刷新检测。`;
}
return `${label} 还没有写入 GoNavi MCP 配置。`;
};
const getClientOptionSummary = (status: AIMCPClientInstallStatus | undefined) => {
if (status?.matchesCurrent) {
return '当前 GoNavi 已经接入到这个客户端。';
}
if (status?.installed) {
return '检测到旧的 GoNavi 记录,建议更新为当前安装路径。';
}
if (String(status?.message || '').includes('失败') || String(status?.message || '').includes('异常')) {
return '接入状态读取异常,建议先刷新再处理。';
}
return '尚未写入 GoNavi MCP 配置。';
};
const getClientDetectionSummary = (status: AIMCPClientInstallStatus | undefined) => {
const label = status?.displayName || '这个客户端';
const commandName = resolveClientCommandName(status);
if (status?.clientDetected) {
return `已检测到本机 ${commandName} 命令,写入配置后重启 ${label} 即可验证。`;
}
return `未检测到本机 ${commandName} 命令;如果 CLI 还没加入 PATH也可以先写入 ${label} 配置,稍后再重启验证。`;
};
const resolveActionLabel = (status: AIMCPClientInstallStatus | undefined) => {
const label = status?.displayName || '客户端';
if (status?.matchesCurrent) {
return `已接入 ${label}`;
}
if (status?.installed) {
return `更新 ${label} 配置`;
}
return `接入到 ${label}`;
};
const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
statuses,
selectedClient,
selectedStatus,
selectedCommandText,
darkMode,
overlayTheme,
cardBg,
cardBorder,
loading,
statusLoading,
onSelectClient,
onRefreshStatus,
onCopyConfigPath,
onCopyLaunchCommand,
onInstall,
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div
style={{
padding: '14px 16px',
borderRadius: 14,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(59,130,246,0.08)' : 'rgba(59,130,246,0.06)',
display: 'flex',
flexDirection: 'column',
gap: 10,
}}
>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}></div>
<div style={{ fontSize: 12, color: overlayTheme.titleText, lineHeight: 1.7 }}>
GoNavi MCP GoNavi MCP Server Claude Code Codex AI
</div>
<div
style={{
padding: '10px 12px',
borderRadius: 12,
border: `1px solid ${darkMode ? 'rgba(96,165,250,0.18)' : 'rgba(96,165,250,0.22)'}`,
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.7)',
fontSize: 12,
color: overlayTheme.titleText,
lineHeight: 1.7,
}}
>
MCP GoNavi GoNavi
</div>
</div>
<div
style={{
padding: '16px',
borderRadius: 14,
border: `1px solid ${cardBorder}`,
background: cardBg,
display: 'flex',
flexDirection: 'column',
gap: 14,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}></div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
1 GoNavi MCP exe
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10 }}>
{statuses.map((status) => {
const client = status.client === 'codex' ? 'codex' : 'claude-code';
const active = selectedClient === client;
const tone = getStatusTone(status, darkMode);
return (
<button
key={status.client}
type="button"
onClick={() => onSelectClient(client)}
style={{
padding: '14px 16px',
borderRadius: 12,
border: `1.5px solid ${active ? overlayTheme.selectedText : cardBorder}`,
background: active ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.7)'),
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: 10,
textAlign: 'left',
minHeight: 98,
transition: 'all 0.2s ease',
opacity: statusLoading ? 0.72 : 1,
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<div
aria-hidden
style={{
width: 22,
height: 22,
borderRadius: 999,
border: `1.5px solid ${active ? overlayTheme.selectedText : darkMode ? 'rgba(255,255,255,0.16)' : 'rgba(15,23,42,0.16)'}`,
background: active ? overlayTheme.selectedText : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{active ? <CheckCircleFilled style={{ color: '#fff', fontSize: 12 }} /> : null}
</div>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, minWidth: 0 }}>
{status.displayName}
</div>
</div>
<div
style={{
padding: '4px 10px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: tone.color,
background: tone.bg,
minWidth: 72,
textAlign: 'center',
}}
>
{tone.label}
</div>
</div>
<div style={{ fontSize: 12, color: overlayTheme.titleText, lineHeight: 1.7 }}>
{getClientOptionSummary(status)}
</div>
<div style={{ fontSize: 11, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
{active ? '当前已选中这个客户端。' : '点击后切换到这个客户端。'}
</div>
</button>
);
})}
</div>
</div>
<div
style={{
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${cardBorder}`,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.78)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
{selectedStatus?.displayName || '客户端'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
{getStatusSummary(selectedStatus)}
</div>
{selectedStatus && (
<div
style={{
padding: '3px 9px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: getStatusTone(selectedStatus, darkMode).color,
background: getStatusTone(selectedStatus, darkMode).bg,
}}
>
{getStatusTone(selectedStatus, darkMode).label}
</div>
)}
</div>
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{selectedStatus?.matchesCurrent
? '当前 GoNavi MCP 已经写入这个客户端'
: selectedStatus?.installed
? '检测到旧配置,建议更新到当前安装路径'
: '当前还没有把 GoNavi MCP 写入这个客户端'}
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
CLI {selectedStatus?.clientDetected
? `已检测到 ${resolveClientCommandName(selectedStatus)}`
: `未检测到 ${resolveClientCommandName(selectedStatus)},仍可先写配置`}
</div>
{selectedStatus?.clientPath && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedStatus.clientPath}
</div>
)}
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{selectedStatus?.message || '未检测到接入状态'}
</div>
{selectedStatus?.configPath && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedStatus.configPath}
</div>
)}
{selectedCommandText && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedCommandText}
</div>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button
size="small"
icon={<ReloadOutlined />}
loading={statusLoading}
onClick={onRefreshStatus}
style={{ borderRadius: 8 }}
>
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!selectedStatus?.configPath}
onClick={onCopyConfigPath}
style={{ borderRadius: 8 }}
>
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!selectedCommandText}
onClick={onCopyLaunchCommand}
style={{ borderRadius: 8 }}
>
</Button>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
{getClientDetectionSummary(selectedStatus)}
{' '}
</div>
<Button
type={selectedStatus?.matchesCurrent ? 'default' : 'primary'}
onClick={onInstall}
loading={loading}
disabled={Boolean(selectedStatus?.matchesCurrent)}
style={{ borderRadius: 10, fontWeight: 600, minWidth: 192, height: 40 }}
>
{resolveActionLabel(selectedStatus)}
</Button>
</div>
</div>
</div>
);
export default AIMCPClientInstallPanel;