mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-15 10:59:41 +08:00
- AI 请求:增强 OpenAI 兼容接口降级逻辑,文本模型自动省略图片并在 400 场景重试 - MCP 接入:支持自定义 HTTP 服务监听地址、端口和 Authorization Bearer Token - MCP 生命周期:停止服务后保留授权信息,并将主动关闭子进程视为正常停止 - 交互优化:移除 AI 对话导出入口,支持关闭常驻状态提示并收敛设置弹窗 toast 宽度 - UI 调整:优化 AI 输入框边框、聚焦态和 Authorization 运行中只读可查看体验 - 测试覆盖:补充 OpenAI 降级、MCP HTTP、AI Header 和设置面板相关用例
842 lines
38 KiB
TypeScript
842 lines
38 KiB
TypeScript
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||
import { Modal, Form, message as antdMessage } from 'antd';
|
||
import { RobotOutlined } from '@ant-design/icons';
|
||
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AIMCPClientInstallStatus, AIMCPHTTPServerStatus, AISkillConfig } from '../types';
|
||
import {
|
||
resolvePresetBaseURL,
|
||
resolvePresetModelSelection,
|
||
resolvePresetTransport,
|
||
} from '../utils/aiProviderPresets';
|
||
import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
|
||
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
|
||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||
import { BUILTIN_AI_TOOL_INFO } from '../utils/aiToolRegistry';
|
||
import { EMPTY_MCP_CLIENT_STATUSES } from '../utils/mcpClientInstallStatus';
|
||
import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';
|
||
import AISettingsMCPSection from './ai/AISettingsMCPSection';
|
||
import type { AIMCPHTTPServerDraft } from './ai/AIMCPHTTPServerPanel';
|
||
import AISettingsSidebar, { type AISettingsSectionKey } from './ai/AISettingsSidebar';
|
||
import AISettingsSafetySection from './ai/AISettingsSafetySection';
|
||
import AISettingsContextSection from './ai/AISettingsContextSection';
|
||
import AISettingsProvidersSection from './ai/AISettingsProvidersSection';
|
||
import AISettingsPromptsSection from './ai/AISettingsPromptsSection';
|
||
import AISettingsSkillsSection from './ai/AISettingsSkillsSection';
|
||
import { useAIMCPClientInstaller } from './ai/useAIMCPClientInstaller';
|
||
import {
|
||
EMPTY_AI_USER_PROMPT_SETTINGS,
|
||
EMPTY_MCP_SERVER,
|
||
EMPTY_SKILL,
|
||
PROVIDER_PRESETS,
|
||
findPreset,
|
||
matchProviderPreset,
|
||
type ProviderPreset,
|
||
waitForAIService,
|
||
} from './ai/aiSettingsModalConfig';
|
||
interface AISettingsModalProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
darkMode: boolean;
|
||
overlayTheme: OverlayWorkbenchTheme;
|
||
focusProviderId?: string;
|
||
}
|
||
|
||
const DEFAULT_MCP_HTTP_SERVER_STATUS: AIMCPHTTPServerStatus = {
|
||
running: false,
|
||
addr: '127.0.0.1:8765',
|
||
path: '/mcp',
|
||
url: 'http://127.0.0.1:8765/mcp',
|
||
schemaOnly: true,
|
||
message: 'GoNavi MCP HTTP 服务未启动',
|
||
};
|
||
|
||
const DEFAULT_MCP_HTTP_SERVER_DRAFT: AIMCPHTTPServerDraft = {
|
||
addr: DEFAULT_MCP_HTTP_SERVER_STATUS.addr,
|
||
path: DEFAULT_MCP_HTTP_SERVER_STATUS.path,
|
||
authorizationHeader: '',
|
||
};
|
||
|
||
const buildMCPHTTPServerDraftFromStatus = (
|
||
status: AIMCPHTTPServerStatus,
|
||
fallback: AIMCPHTTPServerDraft = DEFAULT_MCP_HTTP_SERVER_DRAFT,
|
||
): AIMCPHTTPServerDraft => ({
|
||
addr: String(status.addr || fallback.addr || DEFAULT_MCP_HTTP_SERVER_STATUS.addr).trim(),
|
||
path: String(status.path || fallback.path || DEFAULT_MCP_HTTP_SERVER_STATUS.path).trim(),
|
||
authorizationHeader: String(
|
||
status.authorizationHeader ||
|
||
(status.token ? `Bearer ${status.token}` : '') ||
|
||
fallback.authorizationHeader ||
|
||
'',
|
||
).trim(),
|
||
});
|
||
|
||
const normalizeMCPHTTPAuthorizationToken = (value: string): string => {
|
||
const trimmed = String(value || '').trim();
|
||
if (!trimmed) return '';
|
||
const withoutHeaderName = trimmed.replace(/^Authorization\s*:\s*/i, '').trim();
|
||
return withoutHeaderName.replace(/^Bearer\s+/i, '').trim();
|
||
};
|
||
|
||
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
|
||
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
|
||
const [activeProviderId, setActiveProviderId] = useState<string>('');
|
||
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
|
||
const [contextLevel, setContextLevel] = useState<AIContextLevel>('schema_only');
|
||
const [mcpServers, setMCPServers] = useState<AIMCPServerConfig[]>([]);
|
||
const [mcpTools, setMCPTools] = useState<AIMCPToolDescriptor[]>([]);
|
||
const [mcpHTTPServerStatus, setMCPHTTPServerStatus] = useState<AIMCPHTTPServerStatus>(DEFAULT_MCP_HTTP_SERVER_STATUS);
|
||
const [mcpHTTPServerDraft, setMCPHTTPServerDraft] = useState<AIMCPHTTPServerDraft>(DEFAULT_MCP_HTTP_SERVER_DRAFT);
|
||
const [mcpHTTPServerLoading, setMCPHTTPServerLoading] = useState(false);
|
||
const [skills, setSkills] = useState<AISkillConfig[]>([]);
|
||
const [editingProvider, setEditingProvider] = useState<AIProviderConfig | null>(null);
|
||
const [isEditing, setIsEditing] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||
const [builtinPrompts, setBuiltinPrompts] = useState<Record<string, string>>({});
|
||
const [userPromptSettings, setUserPromptSettings] = useState<AIUserPromptSettings>(EMPTY_AI_USER_PROMPT_SETTINGS);
|
||
const [activeSection, setActiveSection] = useState<AISettingsSectionKey>('providers');
|
||
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 });
|
||
|
||
// 主题色
|
||
const cardBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
|
||
const cardBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
|
||
const cardHoverBg = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)';
|
||
const inputBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
|
||
// Hook 必须在组件顶层调用,不能在条件分支内
|
||
const watchedType = Form.useWatch('type', form);
|
||
const watchedPresetKey = Form.useWatch('presetKey', form);
|
||
const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai';
|
||
const skillRequiredToolOptions = useMemo(() => ([
|
||
...BUILTIN_AI_TOOL_INFO.map((tool) => ({
|
||
label: `${tool.name} · 内置工具`,
|
||
value: tool.name,
|
||
})),
|
||
...mcpTools.map((tool) => ({
|
||
label: `${tool.alias} · ${tool.serverName}`,
|
||
value: tool.alias,
|
||
})),
|
||
]), [mcpTools]);
|
||
|
||
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 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 {
|
||
handleCopySelectedMCPConfigPath,
|
||
handleCopySelectedMCPLaunchCommand,
|
||
handleInstallSelectedMCPClient,
|
||
handleSelectMCPClient,
|
||
loadMCPClientStatuses,
|
||
mcpClientStatusLoading,
|
||
mcpClientStatuses,
|
||
resetMCPClientSelectionTouched,
|
||
selectedMCPClient,
|
||
selectedMCPClientCommandText,
|
||
selectedMCPClientStatus,
|
||
syncMCPClientStatuses,
|
||
} = useAIMCPClientInstaller({
|
||
resolveAIService,
|
||
messageApi,
|
||
copyTextToClipboard,
|
||
onBeforeInstall: () => setLoading(true),
|
||
onAfterInstall: () => setLoading(false),
|
||
onConfigChanged: () => window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')),
|
||
});
|
||
|
||
const loadConfig = useCallback(async () => {
|
||
try {
|
||
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;
|
||
}
|
||
try {
|
||
return await loader();
|
||
} catch (error) {
|
||
console.warn('[AI] settings load fallback', error);
|
||
return fallback;
|
||
}
|
||
};
|
||
const [provRes, safeRes, ctxRes, promptsRes, userPromptsRes, mcpServersRes, mcpToolsRes, mcpHTTPServerStatusRes, skillsRes, mcpClientStatusesRes] = await Promise.all([
|
||
callOrFallback(() => Service.AIGetProviders?.(), []),
|
||
callOrFallback<AISafetyLevel>(() => Service.AIGetSafetyLevel?.(), 'readonly'),
|
||
callOrFallback<AIContextLevel>(() => Service.AIGetContextLevel?.(), 'schema_only'),
|
||
callOrFallback(() => Service.AIGetBuiltinPrompts?.(), {}),
|
||
callOrFallback(() => Service.AIGetUserPromptSettings?.(), EMPTY_AI_USER_PROMPT_SETTINGS),
|
||
callOrFallback(() => Service.AIGetMCPServers?.(), []),
|
||
callOrFallback(() => Service.AIListMCPTools?.(), []),
|
||
callOrFallback<AIMCPHTTPServerStatus>(() => Service.AIGetMCPHTTPServerStatus?.(), DEFAULT_MCP_HTTP_SERVER_STATUS),
|
||
callOrFallback(() => Service.AIGetSkills?.(), []),
|
||
callOrFallback<AIMCPClientInstallStatus[]>(() => Service.AIGetMCPClientInstallStatuses?.(), EMPTY_MCP_CLIENT_STATUSES),
|
||
]);
|
||
if (Array.isArray(provRes)) {
|
||
setProviders(provRes);
|
||
const activeRes = await Service.AIGetActiveProvider?.();
|
||
if (activeRes) setActiveProviderId(activeRes);
|
||
}
|
||
if (safeRes) setSafetyLevel(safeRes);
|
||
if (ctxRes) setContextLevel(ctxRes);
|
||
if (promptsRes) setBuiltinPrompts(promptsRes);
|
||
if (userPromptsRes) {
|
||
setUserPromptSettings({
|
||
...EMPTY_AI_USER_PROMPT_SETTINGS,
|
||
...userPromptsRes,
|
||
});
|
||
}
|
||
if (Array.isArray(mcpServersRes)) setMCPServers(mcpServersRes);
|
||
if (Array.isArray(mcpToolsRes)) setMCPTools(mcpToolsRes);
|
||
if (mcpHTTPServerStatusRes) {
|
||
const nextStatus = {
|
||
...DEFAULT_MCP_HTTP_SERVER_STATUS,
|
||
...mcpHTTPServerStatusRes,
|
||
};
|
||
setMCPHTTPServerStatus(nextStatus);
|
||
setMCPHTTPServerDraft((prev) => buildMCPHTTPServerDraftFromStatus(nextStatus, prev));
|
||
}
|
||
if (Array.isArray(skillsRes)) setSkills(skillsRes);
|
||
if (Array.isArray(mcpClientStatusesRes)) {
|
||
syncMCPClientStatuses(mcpClientStatusesRes);
|
||
}
|
||
} catch (e) { console.warn('Failed to load AI config', e); }
|
||
}, [resolveAIService, syncMCPClientStatuses]);
|
||
|
||
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
|
||
|
||
useEffect(() => {
|
||
if (open) {
|
||
resetMCPClientSelectionTouched();
|
||
}
|
||
}, [open, resetMCPClientSelectionTouched]);
|
||
|
||
useEffect(() => {
|
||
if (!open || !focusProviderId) {
|
||
return;
|
||
}
|
||
if (!providers.some((provider) => provider.id === focusProviderId)) {
|
||
return;
|
||
}
|
||
setActiveSection('providers');
|
||
setActiveProviderId(focusProviderId);
|
||
}, [focusProviderId, open, providers]);
|
||
|
||
const applyProviderEditorSession = useCallback((session: ProviderEditorSession) => {
|
||
setEditingProvider(session.editingProvider as AIProviderConfig | null);
|
||
setIsEditing(session.isEditing);
|
||
setTestStatus(session.testStatus);
|
||
setPrimaryPasswordVisible(false);
|
||
form.resetFields();
|
||
if (session.formValues) {
|
||
form.setFieldsValue(session.formValues);
|
||
}
|
||
}, [form]);
|
||
|
||
const resetProviderEditorSession = useCallback(() => {
|
||
applyProviderEditorSession(buildClosedProviderEditorSession());
|
||
}, [applyProviderEditorSession]);
|
||
|
||
const handleModalClose = useCallback(() => {
|
||
resetProviderEditorSession();
|
||
onClose();
|
||
}, [onClose, resetProviderEditorSession]);
|
||
|
||
useEffect(() => {
|
||
if (!open) {
|
||
resetProviderEditorSession();
|
||
}
|
||
}, [open, resetProviderEditorSession]);
|
||
const handleAddProvider = () => {
|
||
const preset = findPreset('openai');
|
||
applyProviderEditorSession(buildAddProviderEditorSession({
|
||
presetKey: 'openai',
|
||
presetBackendType: preset.backendType,
|
||
presetBaseUrl: preset.defaultBaseUrl,
|
||
presetModel: preset.defaultModel,
|
||
presetModels: preset.models,
|
||
apiFormat: 'openai',
|
||
}));
|
||
};
|
||
|
||
const handleEditProvider = async (p: AIProviderConfig) => {
|
||
try {
|
||
const Service = (window as any).go?.aiservice?.Service;
|
||
const editableProvider = typeof Service?.AIGetEditableProvider === 'function'
|
||
? await Service.AIGetEditableProvider(p.id)
|
||
: p;
|
||
// 尝试根据 baseUrl 和 type 推断 preset
|
||
const matchedPreset = matchProviderPreset(editableProvider);
|
||
const resolvedTransport = resolvePresetTransport({
|
||
presetBackendType: matchedPreset.backendType,
|
||
presetFixedApiFormat: matchedPreset.fixedApiFormat,
|
||
valuesApiFormat: editableProvider.apiFormat,
|
||
});
|
||
applyProviderEditorSession(buildEditProviderEditorSession({
|
||
provider: { ...editableProvider, presetKey: matchedPreset.key } as any,
|
||
formValues: {
|
||
...editableProvider,
|
||
type: resolvedTransport.type,
|
||
models: editableProvider.models || [],
|
||
presetKey: matchedPreset.key,
|
||
apiFormat: resolvedTransport.apiFormat || editableProvider.apiFormat || 'openai',
|
||
},
|
||
}));
|
||
} catch (e: any) {
|
||
void messageApi.error(e?.message || '读取供应商配置失败');
|
||
}
|
||
};
|
||
|
||
const handleDeleteProvider = async (id: string) => {
|
||
try {
|
||
const Service = (window as any).go?.aiservice?.Service;
|
||
const wasActive = id === activeProviderId;
|
||
await Service?.AIDeleteProvider?.(id);
|
||
await loadConfig();
|
||
// 合并提示:删除的是当前激活的供应商时,附带自动切换信息
|
||
if (wasActive) {
|
||
const newProviders: any[] = await Service?.AIGetProviders?.() || [];
|
||
if (newProviders.length > 0) {
|
||
const newActiveName = newProviders[0]?.name || '下一个供应商';
|
||
void messageApi.success(`已删除,自动切换到「${newActiveName}」`);
|
||
} else {
|
||
void messageApi.success('已删除');
|
||
}
|
||
} else {
|
||
void messageApi.success('已删除');
|
||
}
|
||
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
|
||
} catch (e: any) { void messageApi.error(e?.message || '删除失败'); }
|
||
};
|
||
|
||
const handleSaveProvider = async () => {
|
||
try {
|
||
const values = await form.validateFields();
|
||
setLoading(true);
|
||
const Service = (window as any).go?.aiservice?.Service;
|
||
|
||
// 构建 payload,处理 model/models 逻辑
|
||
const preset = findPreset(values.presetKey);
|
||
const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama';
|
||
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
|
||
presetKey: values.presetKey,
|
||
presetDefaultModel: preset.defaultModel,
|
||
presetModels: preset.models,
|
||
valuesModel: values.model,
|
||
customModels: values.models,
|
||
});
|
||
// 内置供应商自动使用 preset label 作为名称
|
||
const finalName = isCustomLike ? (values.name || preset.label) : preset.label;
|
||
|
||
const finalBaseUrl = resolvePresetBaseURL({
|
||
presetKey: values.presetKey,
|
||
presetDefaultBaseUrl: preset.defaultBaseUrl,
|
||
valuesBaseUrl: values.baseUrl,
|
||
});
|
||
const resolvedTransport = resolvePresetTransport({
|
||
presetBackendType: preset.backendType,
|
||
presetFixedApiFormat: preset.fixedApiFormat,
|
||
valuesApiFormat: values.apiFormat,
|
||
});
|
||
const secretDraft = resolveProviderSecretDraft({
|
||
apiKeyInput: values.apiKey,
|
||
});
|
||
const payload = {
|
||
...editingProvider,
|
||
...values,
|
||
...resolvedTransport,
|
||
name: finalName,
|
||
apiKey: secretDraft.apiKey,
|
||
hasSecret: secretDraft.hasSecret,
|
||
model: finalModel,
|
||
models: resolvedModels,
|
||
baseUrl: finalBaseUrl,
|
||
apiFormat: resolvedTransport.apiFormat,
|
||
};
|
||
// 后端 AISaveProvider 统一处理新增和更新,返回 void,失败抛异常
|
||
await Service?.AISaveProvider?.(payload);
|
||
void messageApi.success('已保存'); resetProviderEditorSession(); void loadConfig();
|
||
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
|
||
} catch (e: any) {
|
||
if (e?.errorFields) { /* antd form validation error, ignore */ }
|
||
else void messageApi.error(e?.message || '保存失败');
|
||
} finally { setLoading(false); }
|
||
};
|
||
|
||
const handleSetActive = async (id: string) => {
|
||
try {
|
||
const Service = (window as any).go?.aiservice?.Service;
|
||
await Service?.AISetActiveProvider?.(id);
|
||
setActiveProviderId(id); void messageApi.success('已切换');
|
||
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
|
||
} catch (e: any) { void messageApi.error(e?.message || '切换失败'); }
|
||
};
|
||
|
||
const handleSafetyChange = async (level: AISafetyLevel) => {
|
||
try {
|
||
const Service = (window as any).go?.aiservice?.Service;
|
||
await Service?.AISetSafetyLevel?.(level);
|
||
setSafetyLevel(level);
|
||
} catch (e) { /* ignore */ }
|
||
};
|
||
|
||
const handleContextChange = async (level: AIContextLevel) => {
|
||
try {
|
||
const Service = (window as any).go?.aiservice?.Service;
|
||
await Service?.AISetContextLevel?.(level);
|
||
setContextLevel(level);
|
||
} catch (e) { /* ignore */ }
|
||
};
|
||
|
||
const handleSaveUserPromptSettings = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const Service = (window as any).go?.aiservice?.Service;
|
||
const payload = {
|
||
global: String(userPromptSettings.global || ''),
|
||
database: String(userPromptSettings.database || ''),
|
||
jvm: String(userPromptSettings.jvm || ''),
|
||
jvmDiagnostic: String(userPromptSettings.jvmDiagnostic || ''),
|
||
};
|
||
await Service?.AISaveUserPromptSettings?.(payload);
|
||
setUserPromptSettings(payload);
|
||
void messageApi.success('自定义提示词已保存');
|
||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||
} catch (e: any) {
|
||
void messageApi.error(e?.message || '保存自定义提示词失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const updateMCPServerDraft = (id: string, patch: Partial<AIMCPServerConfig>) => {
|
||
setMCPServers((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item));
|
||
};
|
||
|
||
const handleAddMCPServer = (seed?: Partial<AIMCPServerConfig>) => {
|
||
setMCPServers((prev) => [...prev, EMPTY_MCP_SERVER(seed)]);
|
||
};
|
||
|
||
const handleSaveMCPServer = async (server: AIMCPServerConfig) => {
|
||
try {
|
||
setLoading(true);
|
||
const Service = (window as any).go?.aiservice?.Service;
|
||
await Service?.AISaveMCPServer?.(server);
|
||
await loadConfig();
|
||
void messageApi.success('MCP 服务已保存');
|
||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||
} catch (e: any) {
|
||
void messageApi.error(e?.message || '保存 MCP 服务失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteMCPServer = async (id: string) => {
|
||
try {
|
||
setLoading(true);
|
||
const Service = (window as any).go?.aiservice?.Service;
|
||
if (typeof Service?.AIDeleteMCPServer === 'function' && !String(id).startsWith('mcp-draft-')) {
|
||
await Service.AIDeleteMCPServer(id);
|
||
await loadConfig();
|
||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||
} else {
|
||
setMCPServers((prev) => prev.filter((item) => item.id !== id));
|
||
}
|
||
void messageApi.success('MCP 服务已删除');
|
||
} catch (e: any) {
|
||
void messageApi.error(e?.message || '删除 MCP 服务失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleTestMCPServer = async (server: AIMCPServerConfig) => {
|
||
try {
|
||
setLoading(true);
|
||
const Service = (window as any).go?.aiservice?.Service;
|
||
const res = await Service?.AITestMCPServer?.(server);
|
||
if (res?.success) {
|
||
void messageApi.success(res?.message || 'MCP 服务连接成功');
|
||
if (typeof Service?.AIListMCPTools === 'function') {
|
||
const nextTools = await Service.AIListMCPTools();
|
||
if (Array.isArray(nextTools)) setMCPTools(nextTools);
|
||
} else if (Array.isArray(res?.tools)) {
|
||
setMCPTools(res.tools);
|
||
}
|
||
} else {
|
||
void messageApi.error(res?.message || 'MCP 服务测试失败');
|
||
}
|
||
} catch (e: any) {
|
||
void messageApi.error(e?.message || '测试 MCP 服务失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleToggleMCPHTTPServer = async (checked: boolean) => {
|
||
try {
|
||
setMCPHTTPServerLoading(true);
|
||
const Service = await resolveAIService();
|
||
if (!Service) {
|
||
throw new Error('当前运行时暂不支持 MCP HTTP 服务控制');
|
||
}
|
||
if (checked && typeof Service.AIStartMCPHTTPServer !== 'function') {
|
||
throw new Error('当前版本暂不支持启动 MCP HTTP 服务');
|
||
}
|
||
if (!checked && typeof Service.AIStopMCPHTTPServer !== 'function') {
|
||
throw new Error('当前版本暂不支持停止 MCP HTTP 服务');
|
||
}
|
||
const nextStatus = checked
|
||
? await Service.AIStartMCPHTTPServer({
|
||
addr: mcpHTTPServerDraft.addr || DEFAULT_MCP_HTTP_SERVER_STATUS.addr,
|
||
path: mcpHTTPServerDraft.path || DEFAULT_MCP_HTTP_SERVER_STATUS.path,
|
||
token: normalizeMCPHTTPAuthorizationToken(mcpHTTPServerDraft.authorizationHeader),
|
||
schemaOnly: true,
|
||
})
|
||
: await Service.AIStopMCPHTTPServer();
|
||
if (nextStatus) {
|
||
const normalizedStatus = {
|
||
...DEFAULT_MCP_HTTP_SERVER_STATUS,
|
||
...nextStatus,
|
||
};
|
||
setMCPHTTPServerStatus(normalizedStatus);
|
||
setMCPHTTPServerDraft((prev) => buildMCPHTTPServerDraftFromStatus(normalizedStatus, prev));
|
||
}
|
||
void messageApi.success(checked ? 'GoNavi MCP HTTP 服务已启动' : 'GoNavi MCP HTTP 服务已停止');
|
||
} catch (e: any) {
|
||
void messageApi.error(e?.message || '切换 GoNavi MCP HTTP 服务失败');
|
||
} finally {
|
||
setMCPHTTPServerLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleUpdateMCPHTTPServerDraft = (patch: Partial<AIMCPHTTPServerDraft>) => {
|
||
setMCPHTTPServerDraft((prev) => ({
|
||
...prev,
|
||
...patch,
|
||
}));
|
||
};
|
||
|
||
const handleCopyMCPHTTPServerURL = async () => {
|
||
const url = String(mcpHTTPServerStatus.url || '').trim();
|
||
if (!url) {
|
||
void messageApi.error('当前没有可复制的 MCP HTTP URL');
|
||
return;
|
||
}
|
||
await copyTextToClipboard(url, 'MCP HTTP URL 已复制');
|
||
};
|
||
|
||
const handleCopyMCPHTTPServerAuthorization = async () => {
|
||
const authorizationHeader = String(mcpHTTPServerStatus.authorizationHeader || '').trim();
|
||
if (!authorizationHeader) {
|
||
void messageApi.error('请先启动 MCP HTTP 服务生成 Authorization Header');
|
||
return;
|
||
}
|
||
await copyTextToClipboard(`Authorization: ${authorizationHeader}`, 'Authorization Header 已复制');
|
||
};
|
||
|
||
const updateSkillDraft = (id: string, patch: Partial<AISkillConfig>) => {
|
||
setSkills((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item));
|
||
};
|
||
|
||
const handleAddSkill = () => {
|
||
setSkills((prev) => [...prev, EMPTY_SKILL()]);
|
||
};
|
||
|
||
const handleSaveSkill = async (skill: AISkillConfig) => {
|
||
try {
|
||
setLoading(true);
|
||
const Service = (window as any).go?.aiservice?.Service;
|
||
await Service?.AISaveSkill?.(skill);
|
||
await loadConfig();
|
||
void messageApi.success('Skill 已保存');
|
||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||
} catch (e: any) {
|
||
void messageApi.error(e?.message || '保存 Skill 失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteSkill = async (id: string) => {
|
||
try {
|
||
setLoading(true);
|
||
const Service = (window as any).go?.aiservice?.Service;
|
||
if (typeof Service?.AIDeleteSkill === 'function' && !String(id).startsWith('skill-draft-')) {
|
||
await Service.AIDeleteSkill(id);
|
||
await loadConfig();
|
||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||
} else {
|
||
setSkills((prev) => prev.filter((item) => item.id !== id));
|
||
}
|
||
void messageApi.success('Skill 已删除');
|
||
} catch (e: any) {
|
||
void messageApi.error(e?.message || '删除 Skill 失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleTestProvider = async () => {
|
||
try {
|
||
const values = await form.validateFields();
|
||
setLoading(true);
|
||
setTestStatus('idle');
|
||
const Service = (window as any).go?.aiservice?.Service;
|
||
const preset = findPreset(values.presetKey || 'openai');
|
||
const finalBaseUrl = resolvePresetBaseURL({
|
||
presetKey: values.presetKey || 'openai',
|
||
presetDefaultBaseUrl: preset.defaultBaseUrl,
|
||
valuesBaseUrl: values.baseUrl,
|
||
});
|
||
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
|
||
presetKey: values.presetKey || 'openai',
|
||
presetDefaultModel: preset.defaultModel,
|
||
presetModels: preset.models,
|
||
valuesModel: values.model,
|
||
customModels: values.models,
|
||
});
|
||
const resolvedTransport = resolvePresetTransport({
|
||
presetBackendType: preset.backendType,
|
||
presetFixedApiFormat: preset.fixedApiFormat,
|
||
valuesApiFormat: values.apiFormat,
|
||
});
|
||
const secretDraft = resolveProviderSecretDraft({
|
||
apiKeyInput: values.apiKey,
|
||
});
|
||
if (secretDraft.mode === 'clear') {
|
||
throw new Error('测试连接前请填写 API Key');
|
||
}
|
||
const res = await Service?.AITestProvider?.({
|
||
...editingProvider,
|
||
...values,
|
||
...resolvedTransport,
|
||
apiKey: secretDraft.apiKey,
|
||
hasSecret: secretDraft.hasSecret,
|
||
baseUrl: finalBaseUrl,
|
||
model: finalModel,
|
||
models: resolvedModels,
|
||
maxTokens: Number(values.maxTokens) || 4096,
|
||
temperature: Number(values.temperature) ?? 0.7,
|
||
apiFormat: resolvedTransport.apiFormat,
|
||
});
|
||
if (res?.success) { setTestStatus('success'); void messageApi.success('连接成功'); }
|
||
else { setTestStatus('error'); void messageApi.error(`测试失败: ${res?.message || '未知错误'}`); }
|
||
} catch (e: any) { setTestStatus('error'); void messageApi.error(e?.message || '测试失败'); }
|
||
finally { setLoading(false); }
|
||
};
|
||
|
||
const handlePresetChange = (presetKey: string) => {
|
||
const preset = findPreset(presetKey);
|
||
const resolvedTransport = resolvePresetTransport({
|
||
presetBackendType: preset.backendType,
|
||
presetFixedApiFormat: preset.fixedApiFormat,
|
||
valuesApiFormat: form.getFieldValue('apiFormat'),
|
||
});
|
||
form.setFieldsValue({
|
||
presetKey,
|
||
type: resolvedTransport.type,
|
||
apiFormat: resolvedTransport.apiFormat || 'openai',
|
||
baseUrl: preset.defaultBaseUrl,
|
||
model: preset.defaultModel,
|
||
});
|
||
};
|
||
|
||
const modalShellStyle = {
|
||
background: overlayTheme.shellBg, border: overlayTheme.shellBorder,
|
||
boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter,
|
||
};
|
||
|
||
return (
|
||
<Modal
|
||
title={
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||
<div style={{
|
||
width: 38, height: 38, borderRadius: 12, display: 'grid', placeItems: 'center',
|
||
background: overlayTheme.iconBg, color: overlayTheme.iconColor, fontSize: 18, flexShrink: 0,
|
||
}}>
|
||
<RobotOutlined />
|
||
</div>
|
||
<div>
|
||
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>AI 设置</div>
|
||
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
|
||
配置 AI 模型、安全级别和上下文选项
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
open={open}
|
||
onCancel={handleModalClose}
|
||
footer={null}
|
||
width={820}
|
||
styles={{
|
||
content: modalShellStyle,
|
||
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
||
body: { paddingTop: 8, height: 620, overflow: 'hidden' },
|
||
}}
|
||
>
|
||
<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}
|
||
<AISettingsSidebar
|
||
activeSection={activeSection}
|
||
darkMode={darkMode}
|
||
overlayTheme={overlayTheme}
|
||
onSelectSection={setActiveSection}
|
||
/>
|
||
<div style={{ minWidth: 0, minHeight: 0, height: '100%', overflowY: 'auto', overflowX: 'hidden', paddingRight: 8, paddingBottom: 28 }}>
|
||
{activeSection === 'providers' && (
|
||
<AISettingsProvidersSection
|
||
providers={providers}
|
||
activeProviderId={activeProviderId}
|
||
editingProvider={editingProvider}
|
||
isEditing={isEditing}
|
||
form={form}
|
||
providerPresets={PROVIDER_PRESETS}
|
||
watchedPresetKey={watchedPresetKey}
|
||
watchedApiFormat={watchedApiFormat}
|
||
loading={loading}
|
||
testStatus={testStatus}
|
||
primaryPasswordVisible={primaryPasswordVisible}
|
||
darkMode={darkMode}
|
||
overlayTheme={overlayTheme}
|
||
cardBg={cardBg}
|
||
cardBorder={cardBorder}
|
||
inputBg={inputBg}
|
||
onPrimaryPasswordVisibleChange={setPrimaryPasswordVisible}
|
||
resolveProviderPreset={matchProviderPreset}
|
||
resolvePresetByKey={findPreset}
|
||
onAddProvider={handleAddProvider}
|
||
onEditProvider={handleEditProvider}
|
||
onDeleteProvider={handleDeleteProvider}
|
||
onSetActiveProvider={handleSetActive}
|
||
onCancelEdit={resetProviderEditorSession}
|
||
onPresetChange={handlePresetChange}
|
||
onTestProvider={handleTestProvider}
|
||
onSaveProvider={handleSaveProvider}
|
||
/>
|
||
)}
|
||
{activeSection === 'safety' && (
|
||
<AISettingsSafetySection
|
||
safetyLevel={safetyLevel}
|
||
darkMode={darkMode}
|
||
overlayTheme={overlayTheme}
|
||
cardBg={cardBg}
|
||
cardBorder={cardBorder}
|
||
onChange={handleSafetyChange}
|
||
/>
|
||
)}
|
||
{activeSection === 'context' && (
|
||
<AISettingsContextSection
|
||
contextLevel={contextLevel}
|
||
darkMode={darkMode}
|
||
overlayTheme={overlayTheme}
|
||
cardBg={cardBg}
|
||
cardBorder={cardBorder}
|
||
onChange={handleContextChange}
|
||
/>
|
||
)}
|
||
{activeSection === 'mcp' && (
|
||
<AISettingsMCPSection
|
||
mcpClientStatuses={mcpClientStatuses}
|
||
selectedMCPClient={selectedMCPClient}
|
||
selectedMCPClientStatus={selectedMCPClientStatus}
|
||
selectedMCPClientCommandText={selectedMCPClientCommandText}
|
||
mcpHTTPServerStatus={mcpHTTPServerStatus}
|
||
mcpHTTPServerDraft={mcpHTTPServerDraft}
|
||
mcpServers={mcpServers}
|
||
mcpTools={mcpTools}
|
||
darkMode={darkMode}
|
||
overlayTheme={overlayTheme}
|
||
cardBg={cardBg}
|
||
cardBorder={cardBorder}
|
||
inputBg={inputBg}
|
||
loading={loading}
|
||
mcpClientStatusLoading={mcpClientStatusLoading}
|
||
mcpHTTPServerLoading={mcpHTTPServerLoading}
|
||
onUpdateHTTPServerDraft={handleUpdateMCPHTTPServerDraft}
|
||
onToggleHTTPServer={handleToggleMCPHTTPServer}
|
||
onCopyHTTPServerURL={() => void handleCopyMCPHTTPServerURL()}
|
||
onCopyHTTPServerAuthorization={() => void handleCopyMCPHTTPServerAuthorization()}
|
||
onSelectClient={handleSelectMCPClient}
|
||
onRefreshStatus={() => void loadMCPClientStatuses()}
|
||
onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()}
|
||
onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()}
|
||
onInstallSelectedClient={handleInstallSelectedMCPClient}
|
||
onAddServer={handleAddMCPServer}
|
||
onUpdateServerDraft={updateMCPServerDraft}
|
||
onTestServer={handleTestMCPServer}
|
||
onSaveServer={handleSaveMCPServer}
|
||
onDeleteServer={handleDeleteMCPServer}
|
||
/>
|
||
)}
|
||
{activeSection === 'skills' && (
|
||
<AISettingsSkillsSection
|
||
skills={skills}
|
||
skillRequiredToolOptions={skillRequiredToolOptions}
|
||
overlayTheme={overlayTheme}
|
||
cardBg={cardBg}
|
||
cardBorder={cardBorder}
|
||
inputBg={inputBg}
|
||
loading={loading}
|
||
onAddSkill={handleAddSkill}
|
||
onUpdateSkillDraft={updateSkillDraft}
|
||
onSaveSkill={handleSaveSkill}
|
||
onDeleteSkill={handleDeleteSkill}
|
||
/>
|
||
)}
|
||
{activeSection === 'tools' && (
|
||
<AIBuiltinToolsCatalog
|
||
darkMode={darkMode}
|
||
overlayTheme={overlayTheme}
|
||
cardBg={cardBg}
|
||
cardBorder={cardBorder}
|
||
/>
|
||
)}
|
||
{activeSection === 'prompts' && (
|
||
<AISettingsPromptsSection
|
||
builtinPrompts={builtinPrompts}
|
||
userPromptSettings={userPromptSettings}
|
||
overlayTheme={overlayTheme}
|
||
cardBg={cardBg}
|
||
cardBorder={cardBorder}
|
||
inputBg={inputBg}
|
||
darkMode={darkMode}
|
||
loading={loading}
|
||
onChangeUserPrompt={(key, value) => setUserPromptSettings((prev) => ({
|
||
...prev,
|
||
[key]: value,
|
||
}))}
|
||
onSave={handleSaveUserPromptSettings}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
export default AISettingsModal;
|