mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-07 11:01:33 +08:00
✨ feat(ai-mcp): 完善外部客户端安装链路并收紧 SQL 安全控制
- 新增 GoNavi MCP stdio server 与 Claude/Codex 用户级安装入口 - 增加安装状态检测、刷新复制能力和浏览器联调 mock - 外部 execute_sql 对齐 GoNavi safetyLevel 并补充前端/后端验证
This commit is contained in:
@@ -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);");
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,');
|
||||
|
||||
@@ -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';
|
||||
|
||||
// 预设配置:每个预设映射到后端 type(openai/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 }}>
|
||||
{[
|
||||
|
||||
@@ -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 }}>
|
||||
{/* 搜索栏 */}
|
||||
|
||||
@@ -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() : '';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user