feat(ai-mcp): 优化外部客户端接入引导与状态识别

- 调整 Claude Code 和 Codex 的安装文案与状态标签,明确是把 GoNavi 暴露给外部客户端使用
- 优化 MCP 客户端默认选择逻辑,优先聚焦未接入或需更新的目标并避免刷新后乱跳
- 同步补齐前端 mock、后端状态文案和定向测试,确保安装区交互与状态展示一致
This commit is contained in:
Syngnat
2026-06-08 16:29:53 +08:00
parent 54f1f6970c
commit 5b8bbd672e
7 changed files with 104 additions and 45 deletions

View File

@@ -46,6 +46,11 @@ describe('AISettingsModal edit password behavior', () => {
it('wires the external MCP client install panel actions back to the modal handlers', () => {
expect(source).toContain('mcpClientStatuses={mcpClientStatuses}');
expect(source).toContain('selectedMCPClient={selectedMCPClient}');
expect(source).toContain('const [mcpClientSelectionTouched, setMCPClientSelectionTouched] = useState(false);');
expect(source).toContain('const handleSelectMCPClient = useCallback((client: MCPClientKey) => {');
expect(source).toContain('pickPreferredMCPClient(normalizedStatuses, mcpClientSelectionTouched ? prev : undefined)');
expect(source).toContain('setMCPClientSelectionTouched(true);');
expect(source).toContain('onSelectClient={handleSelectMCPClient}');
expect(source).toContain('onRefreshStatus={() => void loadMCPClientStatuses()}');
expect(source).toContain('onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()}');
expect(source).toContain('onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()}');

View File

@@ -112,14 +112,14 @@ const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
message: '未安装到 Claude Code 用户级配置',
message: '未检测到 Claude Code 用户级 GoNavi MCP 配置',
},
{
client: 'codex',
displayName: 'Codex',
installed: false,
matchesCurrent: false,
message: '未安装到 Codex 用户级配置',
message: '未检测到 Codex 用户级 GoNavi MCP 配置',
},
];
@@ -219,6 +219,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
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);
@@ -263,6 +264,10 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
() => formatMCPLaunchCommand(selectedMCPClientStatus),
[selectedMCPClientStatus],
);
const handleSelectMCPClient = useCallback((client: MCPClientKey) => {
setMCPClientSelectionTouched(true);
setSelectedMCPClient(client);
}, []);
const resolveAIService = useCallback(async () => {
const service = await waitForAIService();
@@ -291,7 +296,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
if (Array.isArray(result)) {
const normalizedStatuses = normalizeMCPClientStatuses(result);
setMCPClientStatuses(normalizedStatuses);
setSelectedMCPClient((prev) => pickPreferredMCPClient(normalizedStatuses, prev));
setSelectedMCPClient((prev) => pickPreferredMCPClient(normalizedStatuses, mcpClientSelectionTouched ? prev : undefined));
}
} catch (e: any) {
if (silent) {
@@ -304,7 +309,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
setMCPClientStatusLoading(false);
}
}
}, [messageApi, resolveAIService]);
}, [mcpClientSelectionTouched, messageApi, resolveAIService]);
const copyTextToClipboard = useCallback(async (text: string, successMessage: string) => {
if (typeof navigator?.clipboard?.writeText !== 'function') {
@@ -362,13 +367,19 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
if (Array.isArray(mcpClientStatusesRes)) {
const normalizedStatuses = normalizeMCPClientStatuses(mcpClientStatusesRes);
setMCPClientStatuses(normalizedStatuses);
setSelectedMCPClient((prev) => pickPreferredMCPClient(normalizedStatuses, prev));
setSelectedMCPClient((prev) => pickPreferredMCPClient(normalizedStatuses, mcpClientSelectionTouched ? prev : undefined));
}
} catch (e) { console.warn('Failed to load AI config', e); }
}, [resolveAIService]);
}, [mcpClientSelectionTouched, resolveAIService]);
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
useEffect(() => {
if (open) {
setMCPClientSelectionTouched(false);
}
}, [open]);
useEffect(() => {
if (!open || !focusProviderId) {
return;
@@ -641,6 +652,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
}
try {
setLoading(true);
setMCPClientSelectionTouched(true);
const Service = await resolveAIService();
let result: MCPClientInstallResult;
if (targetClient === 'codex') {
@@ -904,7 +916,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
inputBg={inputBg}
loading={loading}
mcpClientStatusLoading={mcpClientStatusLoading}
onSelectClient={setSelectedMCPClient}
onSelectClient={handleSelectMCPClient}
onRefreshStatus={() => void loadMCPClientStatuses()}
onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()}
onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()}

View File

@@ -15,14 +15,14 @@ describe('AIMCPClientInstallPanel', () => {
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
message: '未安装到 Claude Code 用户级配置',
message: '未检测到 Claude Code 用户级 GoNavi MCP 配置',
},
{
client: 'codex',
displayName: 'Codex',
installed: true,
matchesCurrent: false,
message: '检测到旧的 Codex 配置,建议更新',
message: '检测到 Codex 中的 GoNavi MCP 记录,但与当前 GoNavi 安装路径不一致,建议更新',
configPath: '~/.codex/config.toml',
command: 'gonavi-mcp-server',
args: ['stdio'],
@@ -34,7 +34,7 @@ describe('AIMCPClientInstallPanel', () => {
displayName: 'Codex',
installed: true,
matchesCurrent: false,
message: '检测到旧的 Codex 配置,建议更新',
message: '检测到 Codex 中的 GoNavi MCP 记录,但与当前 GoNavi 安装路径不一致,建议更新',
configPath: '~/.codex/config.toml',
command: 'gonavi-mcp-server',
args: ['stdio'],
@@ -54,14 +54,54 @@ describe('AIMCPClientInstallPanel', () => {
/>,
);
expect(markup).toContain('不是给 GoNavi 自己安装 MCP');
expect(markup).toContain('安装到外部 AI 客户端');
expect(markup).toContain('第 1 步:选择安装目标');
expect(markup).toContain('第 2 步:确认当前状态并安装');
expect(markup).toContain('待安装');
expect(markup).toContain('不是给 GoNavi 自己再装一个 MCP');
expect(markup).toContain('把 GoNavi MCP 接入外部 AI 客户端');
expect(markup).toContain('第 1 步:选择目标客户端');
expect(markup).toContain('第 2 步:确认状态后写入');
expect(markup).toContain('未接入');
expect(markup).toContain('需更新');
expect(markup).toContain('复制配置路径');
expect(markup).toContain('复制启动命令');
expect(markup).toContain('更新 Codex 配置');
expect(markup).toContain('不会下载 Claude Code / Codex');
});
it('shows an already-connected label when the selected client matches the current GoNavi path', () => {
const markup = renderToStaticMarkup(
<AIMCPClientInstallPanel
statuses={[
{
client: 'claude-code',
displayName: 'Claude Code',
installed: true,
matchesCurrent: true,
message: '已检测到 Claude Code 用户级 GoNavi MCP 配置,且与当前 GoNavi 安装路径一致',
},
]}
selectedClient="claude-code"
selectedStatus={{
client: 'claude-code',
displayName: 'Claude Code',
installed: true,
matchesCurrent: true,
message: '已检测到 Claude Code 用户级 GoNavi MCP 配置,且与当前 GoNavi 安装路径一致',
}}
selectedCommandText="gonavi-mcp-server stdio"
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
loading={false}
statusLoading={false}
onSelectClient={() => {}}
onRefreshStatus={() => {}}
onCopyConfigPath={() => {}}
onCopyLaunchCommand={() => {}}
onInstall={() => {}}
/>,
);
expect(markup).toContain('已接入');
expect(markup).toContain('已接入 Claude Code');
});
});

View File

@@ -29,7 +29,7 @@ const getStatusTone = (status: AIMCPClientInstallStatus | undefined, darkMode: b
const messageText = String(status?.message || '');
if (status?.matchesCurrent) {
return {
label: '已安装',
label: '已接入',
color: '#16a34a',
bg: darkMode ? 'rgba(34,197,94,0.18)' : 'rgba(34,197,94,0.12)',
};
@@ -49,24 +49,25 @@ const getStatusTone = (status: AIMCPClientInstallStatus | undefined, darkMode: b
};
}
return {
label: '待安装',
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 getStatusSummary = (status: AIMCPClientInstallStatus | undefined) => {
const label = status?.displayName || '这个客户端';
const messageText = String(status?.message || '');
if (status?.matchesCurrent) {
return '这个客户端已经安装当前 GoNavi MCP不需要再装一遍。';
return `${label} 已经写入当前 GoNavi 路径,可直接把 GoNavi 当作 MCP Server 使用。`;
}
if (status?.installed) {
return '这个客户端已经有旧配置,更新后会改成当前 GoNavi 安装路径。';
return `${label} 已检测到旧的 GoNavi 路径,更新后会改成当前这份 GoNavi`;
}
if (messageText.includes('失败') || messageText.includes('异常')) {
return '状态读取异常,建议先刷新,再决定是否安装。';
return '状态读取异常,建议先刷新,再决定是否写入。';
}
return '这个客户端还没有安装 GoNavi MCP。';
return `${label} 还没有写入 GoNavi MCP 配置。`;
};
const getClientCardDescription = (status: AIMCPClientInstallStatus | undefined) => {
@@ -82,12 +83,12 @@ const getClientCardDescription = (status: AIMCPClientInstallStatus | undefined)
const resolveActionLabel = (status: AIMCPClientInstallStatus | undefined) => {
const label = status?.displayName || '客户端';
if (status?.matchesCurrent) {
return `安装到 ${label}`;
return `接入 ${label}`;
}
if (status?.installed) {
return `更新 ${label} 配置`;
}
return `安装到 ${label}`;
return `写入 ${label} 配置`;
};
const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
@@ -109,7 +110,7 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4, lineHeight: 1.7 }}>
GoNavi MCP GoNavi MCP Server Claude CodeCodex AI 使
GoNavi MCP GoNavi MCP Server Claude CodeCodex AI 使
</div>
<div
@@ -124,9 +125,9 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}> AI </div>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}> GoNavi MCP AI </div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
1 GoNavi MCP exe
1 GoNavi MCP exe
</div>
</div>
@@ -141,14 +142,14 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
lineHeight: 1.7,
}}
>
MCP GoNavi GoNavi
MCP Claude Code / Codex GoNavi
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}> 1 </div>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}> 1 </div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10 }}>
@@ -171,6 +172,7 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
flexDirection: 'column',
gap: 10,
textAlign: 'left',
minHeight: 118,
transition: 'all 0.2s ease',
opacity: statusLoading ? 0.72 : 1,
}}
@@ -234,7 +236,7 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
2
2
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
@@ -305,7 +307,7 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
</div>
<Button
type={selectedStatus?.matchesCurrent ? 'default' : 'primary'}

View File

@@ -47,14 +47,14 @@ describe('AISettingsMCPSection', () => {
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
message: '未安装到 Claude Code 用户级配置',
message: '未检测到 Claude Code 用户级 GoNavi MCP 配置',
},
{
client: 'codex',
displayName: 'Codex',
installed: false,
matchesCurrent: false,
message: '未安装到 Codex 用户级配置',
message: '未检测到 Codex 用户级 GoNavi MCP 配置',
},
]}
selectedMCPClient="claude-code"
@@ -63,7 +63,7 @@ describe('AISettingsMCPSection', () => {
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
message: '未安装到 Claude Code 用户级配置',
message: '未检测到 Claude Code 用户级 GoNavi MCP 配置',
}}
selectedMCPClientCommandText=""
mcpServers={[]}
@@ -88,7 +88,7 @@ describe('AISettingsMCPSection', () => {
/>,
);
expect(markup).toContain('安装到外部 AI 客户端');
expect(markup).toContain('把 GoNavi MCP 接入外部 AI 客户端');
expect(markup).toContain('常见启动方式模板');
expect(markup).toContain('Node 脚本');
expect(markup).toContain('新增 MCP 服务');

View File

@@ -43,7 +43,7 @@ if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
message: '未安装到 Claude Code 用户级配置',
message: '未检测到 Claude Code 用户级 GoNavi MCP 配置',
configPath: 'C:/Users/mock/.claude.json',
command: 'C:/Program Files/GoNavi/GoNavi.exe',
args: ['mcp-server'],
@@ -53,7 +53,7 @@ if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window
displayName: 'Codex',
installed: true,
matchesCurrent: false,
message: '已检测到 Codex 安装记录,但与当前 GoNavi 安装路径不一致,建议更新安装',
message: '已检测到 Codex 中的 GoNavi MCP 记录,但与当前 GoNavi 安装路径不一致,建议更新',
configPath: 'C:/Users/mock/.codex/config.toml',
command: 'C:/Old/GoNavi.exe',
args: ['mcp-server'],

View File

@@ -172,7 +172,7 @@ func inspectClaudeCodeMCPInstallStatus(expectedCommand string, expectedArgs []st
Client: "claude-code",
DisplayName: "Claude Code",
ConfigPath: strings.TrimSpace(configPath),
Message: "未安装到 Claude Code 用户级配置",
Message: "未检测到 Claude Code 用户级 GoNavi MCP 配置",
}
if pathErr != nil {
status.Message = fmt.Sprintf("定位 Claude Code 配置失败: %v", pathErr)
@@ -197,18 +197,18 @@ func inspectClaudeCodeMCPInstallStatus(expectedCommand string, expectedArgs []st
status.Command = strings.TrimSpace(serverConfig.Command)
status.Args = append([]string(nil), serverConfig.Args...)
if expectedErr != nil {
status.Message = fmt.Sprintf("已检测到 Claude Code 安装记录,但当前 GoNavi 安装路径校验失败:%v", expectedErr)
status.Message = fmt.Sprintf("已检测到 Claude Code 中的 GoNavi MCP 记录,但当前 GoNavi 安装路径校验失败:%v", expectedErr)
return status
}
status.MatchesCurrent = strings.EqualFold(strings.TrimSpace(serverConfig.Type), "stdio") &&
sameMCPCommand(serverConfig.Command, serverConfig.Args, expectedCommand, expectedArgs)
if status.MatchesCurrent {
status.Message = "已安装到 Claude Code 用户级配置"
status.Message = "已检测到 Claude Code 用户级 GoNavi MCP 配置,且与当前 GoNavi 安装路径一致"
return status
}
status.Message = "已检测到 Claude Code 安装记录,但与当前 GoNavi 安装路径不一致,建议更新安装"
status.Message = "已检测到 Claude Code 中的 GoNavi MCP 记录,但与当前 GoNavi 安装路径不一致,建议更新"
return status
}
@@ -218,7 +218,7 @@ func inspectCodexMCPInstallStatus(expectedCommand string, expectedArgs []string,
Client: "codex",
DisplayName: "Codex",
ConfigPath: strings.TrimSpace(configPath),
Message: "未安装到 Codex 用户级配置",
Message: "未检测到 Codex 用户级 GoNavi MCP 配置",
}
if pathErr != nil {
status.Message = fmt.Sprintf("定位 Codex 配置失败: %v", pathErr)
@@ -243,18 +243,18 @@ func inspectCodexMCPInstallStatus(expectedCommand string, expectedArgs []string,
status.Command = strings.TrimSpace(serverConfig.Command)
status.Args = append([]string(nil), serverConfig.Args...)
if expectedErr != nil {
status.Message = fmt.Sprintf("已检测到 Codex 安装记录,但当前 GoNavi 安装路径校验失败:%v", expectedErr)
status.Message = fmt.Sprintf("已检测到 Codex 中的 GoNavi MCP 记录,但当前 GoNavi 安装路径校验失败:%v", expectedErr)
return status
}
status.MatchesCurrent = sameMCPCommand(serverConfig.Command, serverConfig.Args, expectedCommand, expectedArgs) &&
(serverConfig.StartupTimeoutSec == 0 || serverConfig.StartupTimeoutSec == defaultCodexMCPStartupTimeoutSecond)
if status.MatchesCurrent {
status.Message = "已安装到 Codex 用户级配置"
status.Message = "已检测到 Codex 用户级 GoNavi MCP 配置,且与当前 GoNavi 安装路径一致"
return status
}
status.Message = "已检测到 Codex 安装记录,但与当前 GoNavi 安装路径不一致,建议更新安装"
status.Message = "已检测到 Codex 中的 GoNavi MCP 记录,但与当前 GoNavi 安装路径不一致,建议更新"
return status
}