feat(ai-mcp): 完善外部客户端安装链路并收紧 SQL 安全控制

- 新增 GoNavi MCP stdio server 与 Claude/Codex 用户级安装入口

- 增加安装状态检测、刷新复制能力和浏览器联调 mock

- 外部 execute_sql 对齐 GoNavi safetyLevel 并补充前端/后端验证
This commit is contained in:
Syngnat
2026-06-07 20:27:50 +08:00
parent f7d71c6c5c
commit 5b843ee25b
24 changed files with 3234 additions and 26 deletions

View File

@@ -191,6 +191,18 @@ describe('tool center menu entries', () => {
expect(appSource).toContain('该异常不一定表现为 viewport ratio drift');
});
it('captures window state on startup and lifecycle events instead of waiting only for the polling interval', () => {
expect(appSource).toContain('const scheduleWindowStateSave = (delayMs = 120) => {');
expect(appSource).toContain('if (hydrated) {');
expect(appSource).toContain('scheduleWindowStateSave(320);');
expect(appSource).toContain('const unsubscribeHydration = useStore.persist.onFinishHydration(() => {');
expect(appSource).toContain("window.addEventListener('resize', handleWindowRuntimeChange);");
expect(appSource).toContain("window.addEventListener('focus', handleWindowRuntimeChange);");
expect(appSource).toContain("window.addEventListener('pageshow', handleWindowRuntimeChange);");
expect(appSource).toContain("window.addEventListener('pagehide', handleWindowLifecycleFlush, { capture: true });");
expect(appSource).toContain("window.addEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true });");
});
it('keeps titlebar double-click on maximise while shortcuts may enter macOS fullscreen', () => {
expect(appSource).toContain('const handleTitleBarWindowToggle = async (options?: { allowMacNativeFullscreen?: boolean }) => {');
expect(appSource).toContain('const allowMacNativeFullscreen = options?.allowMacNativeFullscreen === true;');
@@ -204,6 +216,12 @@ describe('tool center menu entries', () => {
expect(appSource).toContain("window.removeEventListener('keydown', handleGlobalShortcut, true);");
});
it('skips the native mac titlebar bridge when the current runtime does not expose it', () => {
expect(appSource).toContain("const backendApp = (window as any).go?.app?.App;");
expect(appSource).toContain("if (typeof backendApp?.SetMacNativeWindowControls !== 'function') {");
expect(appSource).toContain('void safeWindowRuntimeCall(() => SetMacNativeWindowControls(useNativeMacWindowControls), undefined);');
});
it('listens for command search query-tab events and routes them through handleNewQuery', () => {
expect(appSource).toContain("window.addEventListener('gonavi:create-query-tab', handleCreateQueryTabEvent as EventListener);");
expect(appSource).toContain("window.removeEventListener('gonavi:create-query-tab', handleCreateQueryTabEvent as EventListener);");

View File

@@ -790,9 +790,15 @@ function App() {
// 定时保存窗口状态、尺寸与位置
useEffect(() => {
const SAVE_INTERVAL_MS = 2000;
let cancelled = false;
let hydrated = useStore.persist.hasHydrated();
let eventSaveTimer: number | null = null;
let lastSaved = '';
const saveWindowState = async () => {
if (cancelled || !hydrated) {
return;
}
try {
const [isFs, isMax] = await Promise.all([
safeWindowRuntimeCall(() => WindowIsFullscreen(), false),
@@ -836,8 +842,67 @@ function App() {
}
};
const timer = window.setInterval(saveWindowState, SAVE_INTERVAL_MS);
return () => window.clearInterval(timer);
const scheduleWindowStateSave = (delayMs = 120) => {
if (cancelled || !hydrated) {
return;
}
if (eventSaveTimer !== null) {
window.clearTimeout(eventSaveTimer);
}
eventSaveTimer = window.setTimeout(() => {
eventSaveTimer = null;
void saveWindowState();
}, delayMs);
};
const handleWindowRuntimeChange = () => {
scheduleWindowStateSave();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
scheduleWindowStateSave(120);
}
};
const handleWindowLifecycleFlush = () => {
void saveWindowState();
};
if (hydrated) {
scheduleWindowStateSave(320);
}
const unsubscribeHydration = useStore.persist.onFinishHydration(() => {
if (cancelled || hydrated) {
return;
}
hydrated = true;
scheduleWindowStateSave(320);
});
const timer = window.setInterval(() => {
void saveWindowState();
}, SAVE_INTERVAL_MS);
window.addEventListener('resize', handleWindowRuntimeChange);
window.addEventListener('focus', handleWindowRuntimeChange);
window.addEventListener('pageshow', handleWindowRuntimeChange);
window.addEventListener('pagehide', handleWindowLifecycleFlush, { capture: true });
window.addEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true });
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
cancelled = true;
if (eventSaveTimer !== null) {
window.clearTimeout(eventSaveTimer);
}
window.clearInterval(timer);
window.removeEventListener('resize', handleWindowRuntimeChange);
window.removeEventListener('focus', handleWindowRuntimeChange);
window.removeEventListener('pageshow', handleWindowRuntimeChange);
window.removeEventListener('pagehide', handleWindowLifecycleFlush, { capture: true });
window.removeEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true });
document.removeEventListener('visibilitychange', handleVisibilityChange);
unsubscribeHydration();
};
}, []);
useEffect(() => {
@@ -1567,12 +1632,11 @@ function App() {
if (!isStoreHydrated || !isMacRuntime) {
return;
}
try {
void SetMacNativeWindowControls(useNativeMacWindowControls).catch(() => undefined);
} catch (e) {
console.warn('Wails API: SetMacNativeWindowControls unavailable', e);
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.SetMacNativeWindowControls !== 'function') {
return;
}
void safeWindowRuntimeCall(() => SetMacNativeWindowControls(useNativeMacWindowControls), undefined);
}, [isMacRuntime, isStoreHydrated, useNativeMacWindowControls]);
useEffect(() => {

View File

@@ -18,6 +18,7 @@ describe('AISettingsModal edit password behavior', () => {
});
it('loads MCP servers and skills through the AI service', () => {
expect(source).toContain('Service.AIGetMCPClientInstallStatuses?.()');
expect(source).toContain('Service.AIGetMCPServers?.()');
expect(source).toContain('Service.AIListMCPTools?.()');
expect(source).toContain('Service.AIGetSkills?.()');
@@ -25,6 +26,26 @@ describe('AISettingsModal edit password behavior', () => {
expect(source).toContain('新增 Skill');
});
it('explains external MCP installation and renders selectable client install states', () => {
expect(source).toContain('把 GoNavi 注册成外部 AI 客户端可调用的 MCP Server');
expect(source).toContain('安装到外部客户端');
expect(source).toContain('未安装');
expect(source).toContain('需更新');
expect(source).toContain('已安装');
expect(source).toContain('刷新状态');
expect(source).toContain('复制配置路径');
expect(source).toContain('复制启动命令');
expect(source).toContain('handleInstallSelectedMCPClient');
expect(source).toContain('无需重复安装');
});
it('waits briefly for the AI service bridge before warning and removes noisy provider debug logs', () => {
expect(source).toContain('const resolveAIService = useCallback(async () => {');
expect(source).toContain('const service = await waitForAIService();');
expect(source).not.toContain("console.log('[AI] AIGetProviders result:'");
expect(source).not.toContain("console.log('[AI] AIGetActiveProvider result:'");
});
it('keeps the prefilled api key masked by default', () => {
expect(source).toContain('const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);');
expect(source).toContain('visible: primaryPasswordVisible,');

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons';
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AISkillConfig, AISkillScope } from '../types';
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined, ReloadOutlined, CopyOutlined } from '@ant-design/icons';
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AIMCPClientInstallStatus, AISkillConfig, AISkillScope } from '../types';
import {
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
@@ -30,6 +30,17 @@ interface AISettingsModalProps {
focusProviderId?: string;
}
interface MCPClientInstallResult {
success?: boolean;
client?: string;
message?: string;
configPath?: string;
command?: string;
args?: string[];
}
type MCPClientKey = 'claude-code' | 'codex';
// 预设配置:每个预设映射到后端 typeopenai/anthropic/gemini/custom并附带默认 URL 和 Model
interface ProviderPreset {
key: string;
@@ -97,6 +108,100 @@ const EMPTY_MCP_SERVER = (): AIMCPServerConfig => ({
timeoutSeconds: 20,
});
const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [
{
client: 'claude-code',
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
message: '未安装到 Claude Code 用户级配置',
},
{
client: 'codex',
displayName: 'Codex',
installed: false,
matchesCurrent: false,
message: '未安装到 Codex 用户级配置',
},
];
const normalizeMCPClientStatuses = (items?: AIMCPClientInstallStatus[]): AIMCPClientInstallStatus[] => {
const baseMap = new Map<string, AIMCPClientInstallStatus>(
EMPTY_MCP_CLIENT_STATUSES.map((item) => [item.client, { ...item }]),
);
(Array.isArray(items) ? items : []).forEach((item) => {
if (!item || !item.client) {
return;
}
const base = baseMap.get(item.client) || {
client: item.client,
displayName: item.client,
installed: false,
matchesCurrent: false,
message: '',
};
baseMap.set(item.client, {
...base,
...item,
displayName: item.displayName || base.displayName,
message: item.message || base.message,
args: Array.isArray(item.args) ? item.args : (base.args || []),
});
});
return (['claude-code', 'codex'] as MCPClientKey[])
.map((client) => baseMap.get(client))
.filter((item): item is AIMCPClientInstallStatus => Boolean(item));
};
const pickPreferredMCPClient = (items: AIMCPClientInstallStatus[], current?: MCPClientKey): MCPClientKey => {
if (current && items.some((item) => item.client === current)) {
return current;
}
const pending = items.find((item) => !item.matchesCurrent);
if (pending?.client === 'claude-code' || pending?.client === 'codex') {
return pending.client;
}
return 'claude-code';
};
const waitFor = (delayMs: number) => new Promise<void>((resolve) => {
window.setTimeout(resolve, delayMs);
});
const readAIService = () => (window as any).go?.aiservice?.Service;
const waitForAIService = async (attempts = 6, delayMs = 80) => {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const service = readAIService();
if (service) {
return service;
}
if (attempt < attempts - 1) {
await waitFor(delayMs);
}
}
return readAIService();
};
const quoteMCPCommandPart = (value: string): string => {
const text = String(value || '').trim();
if (!text) {
return '';
}
return /[\s"]/u.test(text) ? `"${text.replace(/"/g, '\\"')}"` : text;
};
const formatMCPLaunchCommand = (input?: Pick<AIMCPClientInstallStatus, 'command' | 'args'> | Pick<MCPClientInstallResult, 'command' | 'args'> | null): string => {
const command = String(input?.command || '').trim();
if (!command) {
return '';
}
const args = Array.isArray(input?.args)
? input.args.map((item) => String(item || '').trim()).filter(Boolean)
: [];
return [command, ...args].map(quoteMCPCommandPart).filter(Boolean).join(' ');
};
const EMPTY_SKILL = (): AISkillConfig => ({
id: `skill-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: '',
@@ -142,6 +247,9 @@ 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 [mcpClientStatusLoading, setMCPClientStatusLoading] = useState(false);
const [skills, setSkills] = useState<AISkillConfig[]>([]);
const [editingProvider, setEditingProvider] = useState<AIProviderConfig | null>(null);
const [isEditing, setIsEditing] = useState(false);
@@ -153,6 +261,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);
const [form] = Form.useForm();
const modalBodyRef = useRef<HTMLDivElement>(null);
const missingAIServiceWarnedRef = useRef(false);
// Modal 内部 toast 通知
const [messageApi, messageContextHolder] = antdMessage.useMessage({ getContainer: () => modalBodyRef.current || document.body });
@@ -163,6 +272,35 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const cardHoverBg = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)';
const sectionLabelColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)';
const inputBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
const getMCPClientStatusTone = useCallback((status?: AIMCPClientInstallStatus) => {
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)',
};
}, [darkMode]);
// Hook 必须在组件顶层调用,不能在条件分支内
const watchedType = Form.useWatch('type', form);
@@ -178,11 +316,71 @@ 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 resolveAIService = useCallback(async () => {
const service = await waitForAIService();
if (service) {
missingAIServiceWarnedRef.current = false;
return service;
}
if (!missingAIServiceWarnedRef.current) {
console.warn('[AI] Service not found on window.go');
missingAIServiceWarnedRef.current = true;
}
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, prev));
}
} 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);
}
}
}, [messageApi, resolveAIService]);
const copyTextToClipboard = useCallback(async (text: string, successMessage: string) => {
if (typeof navigator?.clipboard?.writeText !== 'function') {
throw new Error('当前环境不支持复制到剪贴板');
}
await navigator.clipboard.writeText(text);
void messageApi.success(successMessage);
}, [messageApi]);
const loadConfig = useCallback(async () => {
try {
const Service = (window as any).go?.aiservice?.Service;
if (!Service) { console.warn('[AI] Service not found on window.go'); return; }
const Service = await resolveAIService();
if (!Service) {
return;
}
const callOrFallback = async <T,>(loader: (() => Promise<T>) | undefined, fallback: T): Promise<T> => {
if (typeof loader !== 'function') {
return fallback;
@@ -194,7 +392,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
return fallback;
}
};
const [provRes, safeRes, ctxRes, promptsRes, userPromptsRes, mcpServersRes, mcpToolsRes, skillsRes] = await Promise.all([
const [provRes, safeRes, ctxRes, promptsRes, userPromptsRes, mcpServersRes, mcpToolsRes, skillsRes, mcpClientStatusesRes] = await Promise.all([
callOrFallback(() => Service.AIGetProviders?.(), []),
callOrFallback<AISafetyLevel>(() => Service.AIGetSafetyLevel?.(), 'readonly'),
callOrFallback<AIContextLevel>(() => Service.AIGetContextLevel?.(), 'schema_only'),
@@ -203,12 +401,11 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
callOrFallback(() => Service.AIGetMCPServers?.(), []),
callOrFallback(() => Service.AIListMCPTools?.(), []),
callOrFallback(() => Service.AIGetSkills?.(), []),
callOrFallback<AIMCPClientInstallStatus[]>(() => Service.AIGetMCPClientInstallStatuses?.(), EMPTY_MCP_CLIENT_STATUSES),
]);
console.log('[AI] AIGetProviders result:', JSON.stringify(provRes), 'isArray:', Array.isArray(provRes));
if (Array.isArray(provRes)) {
setProviders(provRes);
const activeRes = await Service.AIGetActiveProvider?.();
console.log('[AI] AIGetActiveProvider result:', activeRes);
if (activeRes) setActiveProviderId(activeRes);
}
if (safeRes) setSafetyLevel(safeRes);
@@ -223,8 +420,13 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
if (Array.isArray(mcpServersRes)) setMCPServers(mcpServersRes);
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, prev));
}
} catch (e) { console.warn('Failed to load AI config', e); }
}, []);
}, [resolveAIService]);
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
@@ -491,6 +693,63 @@ 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);
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));
};
@@ -983,8 +1242,165 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const renderMCPSettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
MCP AI `stdio` GoNavi MCP client MCP Server
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4, lineHeight: 1.7 }}>
GoNavi AI MCP Server Claude Code Codex 使 GoNavi
</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 }}>
GoNavi MCP GoNavi exe
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10 }}>
{mcpClientStatuses.map((status) => {
const active = selectedMCPClient === status.client;
const tone = getMCPClientStatusTone(status);
return (
<div
key={status.client}
onClick={() => {
if (status.client === 'claude-code' || status.client === 'codex') {
setSelectedMCPClient(status.client);
}
}}
style={{
padding: '14px 14px 12px',
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',
gap: 10,
transition: 'all 0.2s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}>
{status.displayName}
</div>
<div style={{
padding: '4px 10px',
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
color: tone.color,
background: tone.bg,
whiteSpace: 'nowrap',
}}>
{tone.label}
</div>
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
{status.matchesCurrent
? '当前 GoNavi 安装路径已写入,打开客户端后可直接使用。'
: status.installed
? '检测到已有安装记录,但建议更新为当前 GoNavi 路径。'
: '当前尚未写入 GoNavi MCP 配置。'}
</div>
</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', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText }}>
{selectedMCPClientStatus?.displayName || '客户端'}
</div>
{selectedMCPClientStatus && (
<div style={{
padding: '3px 9px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
color: getMCPClientStatusTone(selectedMCPClientStatus).color,
background: getMCPClientStatusTone(selectedMCPClientStatus).bg,
}}>
{getMCPClientStatusTone(selectedMCPClientStatus).label}
</div>
)}
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{selectedMCPClientStatus?.message || '未检测到安装状态'}
</div>
{selectedMCPClientStatus?.configPath && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedMCPClientStatus.configPath}
</div>
)}
{selectedMCPClientCommandText && (
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, fontFamily: 'var(--gn-font-mono)' }}>
{selectedMCPClientCommandText}
</div>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button
size="small"
icon={<ReloadOutlined />}
loading={mcpClientStatusLoading}
onClick={() => void loadMCPClientStatuses()}
style={{ borderRadius: 8 }}
>
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!selectedMCPClientStatus?.configPath}
onClick={() => void handleCopySelectedMCPConfigPath()}
style={{ borderRadius: 8 }}
>
</Button>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!selectedMCPClientCommandText}
onClick={() => void handleCopySelectedMCPLaunchCommand()}
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 }}>
</div>
<Button
type={selectedMCPClientStatus?.matchesCurrent ? 'default' : 'primary'}
onClick={handleInstallSelectedMCPClient}
loading={loading}
disabled={Boolean(selectedMCPClientStatus?.matchesCurrent)}
style={{ borderRadius: 10, fontWeight: 600, minWidth: 176, height: 40 }}
>
{selectedMCPClientStatus?.matchesCurrent
? `${selectedMCPClientStatus.displayName} 已安装`
: selectedMCPClientStatus?.installed
? `更新到 ${selectedMCPClientStatus?.displayName || '客户端'}`
: `安装到 ${selectedMCPClientStatus?.displayName || '客户端'}`}
</Button>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}> AI </div>
@@ -1217,7 +1633,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
>
<div ref={modalBodyRef} className="ai-settings-body" style={{ display: 'grid', gridTemplateColumns: '180px minmax(0, 1fr)', gap: 16, padding: '12px 0', height: '100%', minHeight: 0, overflow: 'hidden', alignItems: 'stretch', position: 'relative' }}>
{messageContextHolder}
<div style={{ padding: '0 12px', height: 'fit-content' }}>
<div style={{ minHeight: 0, height: '100%', overflowY: 'auto', overflowX: 'hidden', padding: '0 6px 28px 12px' }}>
<div style={{ marginBottom: 12, fontWeight: 600, color: overlayTheme.titleText }}></div>
<div style={{ display: 'grid', gap: 10 }}>
{[

View File

@@ -341,7 +341,7 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8 },
}}
destroyOnClose
destroyOnHidden
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 搜索栏 */}

View File

@@ -22,12 +22,14 @@ const resolveDevHarnessMode = (): string => {
}
};
if (typeof window !== 'undefined' && !(window as any).go) {
if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window as any).go?.aiservice?.Service)) {
const mockConnections: any[] = [];
const mockConnectionSecrets = new Map<string, any>();
const mockProviders: any[] = [];
const mockProviderSecrets = new Map<string, string>();
let mockActiveProviderId = '';
let mockAISafetyLevel = 'readonly';
let mockAIContextLevel = 'schema_only';
let mockAIUserPromptSettings: any = {
global: '',
database: '',
@@ -35,6 +37,28 @@ if (typeof window !== 'undefined' && !(window as any).go) {
jvmDiagnostic: '',
};
let mockMCPServers: any[] = [];
let mockMCPClientStatuses: any[] = [
{
client: 'claude-code',
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
message: '未安装到 Claude Code 用户级配置',
configPath: 'C:/Users/mock/.claude.json',
command: 'C:/Program Files/GoNavi/GoNavi.exe',
args: ['mcp-server'],
},
{
client: 'codex',
displayName: 'Codex',
installed: true,
matchesCurrent: false,
message: '已检测到 Codex 安装记录,但与当前 GoNavi 安装包路径不一致,建议更新安装',
configPath: 'C:/Users/mock/.codex/config.toml',
command: 'C:/Old/GoNavi.exe',
args: ['mcp-server'],
},
];
let mockSkills: any[] = [];
let mockGlobalProxy: any = { enabled: false, type: 'socks5', host: '', port: 1080, user: '', password: '', hasPassword: false };
let mockDataRootInfo: any = {
@@ -154,7 +178,7 @@ if (typeof window !== 'undefined' && !(window as any).go) {
return cloneBrowserMockValue(view);
};
(window as any).go = {
const mockGo = {
app: {
App: {
CheckUpdate: async () => ({ success: false }),
@@ -291,8 +315,8 @@ if (typeof window !== 'undefined' && !(window as any).go) {
mockActiveProviderId = id;
return null;
},
AIGetSafetyLevel: async () => 'readonly',
AIGetContextLevel: async () => 'schema_only',
AIGetSafetyLevel: async () => mockAISafetyLevel,
AIGetContextLevel: async () => mockAIContextLevel,
AIGetBuiltinPrompts: async () => ({}),
AIGetUserPromptSettings: async () => cloneBrowserMockValue(mockAIUserPromptSettings),
AISaveUserPromptSettings: async (input: any) => {
@@ -304,7 +328,48 @@ if (typeof window !== 'undefined' && !(window as any).go) {
};
return null;
},
AIGetMCPClientInstallStatuses: async () => cloneBrowserMockValue(mockMCPClientStatuses),
AIGetMCPServers: async () => cloneBrowserMockValue(mockMCPServers),
AIInstallClaudeCodeMCP: async () => {
mockMCPClientStatuses = mockMCPClientStatuses.map((item) => item.client === 'claude-code'
? {
...item,
installed: true,
matchesCurrent: true,
message: '已写入 Claude Code 用户级 MCP 配置,重启 Claude CLI 后可在 /mcp 的 User MCPs 中看到 GoNavi。',
command: 'C:/Program Files/GoNavi/GoNavi.exe',
args: ['mcp-server'],
}
: item);
return {
success: true,
client: 'claude-code',
message: '已写入 Claude Code 用户级 MCP 配置,重启 Claude CLI 后可在 /mcp 的 User MCPs 中看到 GoNavi。',
configPath: 'C:/Users/mock/.claude.json',
command: 'C:/Program Files/GoNavi/GoNavi.exe',
args: ['mcp-server'],
};
},
AIInstallCodexMCP: async () => {
mockMCPClientStatuses = mockMCPClientStatuses.map((item) => item.client === 'codex'
? {
...item,
installed: true,
matchesCurrent: true,
message: '已写入 Codex 用户级 MCP 配置,重启 Codex CLI 或桌面端后可看到 GoNavi。',
command: 'C:/Program Files/GoNavi/GoNavi.exe',
args: ['mcp-server'],
}
: item);
return {
success: true,
client: 'codex',
message: '已写入 Codex 用户级 MCP 配置,重启 Codex CLI 或桌面端后可看到 GoNavi。',
configPath: 'C:/Users/mock/.codex/config.toml',
command: 'C:/Program Files/GoNavi/GoNavi.exe',
args: ['mcp-server'],
};
},
AISaveMCPServer: async (input: any) => {
const next = {
id: String(input?.id || `mcp-${Date.now()}`),
@@ -363,11 +428,38 @@ if (typeof window !== 'undefined' && !(window as any).go) {
success: String(input?.apiKey || '').trim() !== '',
message: String(input?.apiKey || '').trim() !== '' ? '端点连通性测试成功!' : '连接测试失败: missing api key',
}),
AISetSafetyLevel: async () => null,
AISetContextLevel: async () => null,
AISetSafetyLevel: async (level: string) => {
mockAISafetyLevel = String(level || 'readonly');
return null;
},
AISetContextLevel: async (level: string) => {
mockAIContextLevel = String(level || 'schema_only');
return null;
},
},
}
};
const existingGo = (window as any).go || {};
(window as any).go = {
...mockGo,
...existingGo,
app: {
...mockGo.app,
...(existingGo.app || {}),
App: {
...mockGo.app.App,
...(existingGo.app?.App || {}),
},
},
aiservice: {
...mockGo.aiservice,
...(existingGo.aiservice || {}),
Service: {
...mockGo.aiservice.Service,
...(existingGo.aiservice?.Service || {}),
},
},
};
}
const rootNode = document.getElementById('root')!;
const devHarnessMode = import.meta.env.DEV ? resolveDevHarnessMode() : '';

View File

@@ -592,6 +592,17 @@ export interface AIMCPToolCallResult {
isError: boolean;
}
export interface AIMCPClientInstallStatus {
client: string;
displayName: string;
installed: boolean;
matchesCurrent: boolean;
message: string;
configPath?: string;
command?: string;
args?: string[];
}
export type AISkillScope = "global" | "database" | "jvm" | "jvmDiagnostic";
export interface AISkillConfig {