Files
MyGoNavi/frontend/src/components/AISettingsModal.tsx
Syngnat 6c53fb14a6 feat(ai-mcp): 补全跨表字段探针并拆分 MCP 设置区块
- 新增 get_all_columns 内置工具和 MCP server 只读探针
- MCP 服务表单增加实际启动命令预览并补强参数提示
- 抽离 AISettingsMCPSection 并补齐前后端测试
2026-06-08 07:29:52 +08:00

1391 lines
74 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { 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, AIMCPClientInstallStatus, AISkillConfig, AISkillScope } from '../types';
import {
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_MODELS,
resolveProviderPresetKey,
resolvePresetBaseURL,
resolvePresetModelSelection,
resolvePresetTransport,
} from '../utils/aiProviderPresets';
import {
PROVIDER_PRESET_CARD_BASE_STYLE,
PROVIDER_PRESET_CARD_CONTENT_STYLE,
PROVIDER_PRESET_CARD_DESCRIPTION_STYLE,
PROVIDER_PRESET_GRID_STYLE,
PROVIDER_PRESET_CARD_TITLE_STYLE,
} from '../utils/aiSettingsPresetLayout';
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 AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';
import AISettingsMCPSection, { type MCPClientKey } from './ai/AISettingsMCPSection';
interface AISettingsModalProps {
open: boolean;
onClose: () => void;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
focusProviderId?: string;
}
interface MCPClientInstallResult {
success?: boolean;
client?: string;
message?: string;
configPath?: string;
command?: string;
args?: string[];
}
// 预设配置:每个预设映射到后端 typeopenai/anthropic/gemini/custom并附带默认 URL 和 Model
interface ProviderPreset {
key: string;
label: string;
icon: React.ReactNode;
desc: string;
color: string;
backendType: AIProviderType;
fixedApiFormat?: string;
defaultBaseUrl: string;
defaultModel: string;
models: string[];
}
const PROVIDER_PRESETS: ProviderPreset[] = [
{ key: 'openai', label: 'OpenAI', icon: <ApiOutlined />, desc: 'GPT-5.4 / 5.3 系列', color: '#10b981', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o', models: [] },
{ key: 'deepseek', label: 'DeepSeek', icon: <ThunderboltOutlined />, desc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] },
{ key: 'qwen-bailian', label: '通义千问(百炼通用)', icon: <CloudOutlined />, desc: '百炼 Anthropic 兼容 / 模型从远端拉取', color: '#6366f1', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, defaultModel: '', models: [] },
{ key: 'qwen-coding-plan', label: '通义千问Coding Plan', icon: <CloudOutlined />, desc: 'Claude Code CLI 代理链路 / 使用官方支持模型清单', color: '#4f46e5', backendType: 'custom', fixedApiFormat: 'claude-cli', defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, defaultModel: '', models: QWEN_CODING_PLAN_MODELS },
{ key: 'zhipu', label: '智谱 GLM', icon: <ExperimentOutlined />, desc: 'GLM-5 / GLM-5-Turbo', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-4', models: [] },
{ key: 'moonshot', label: 'Kimi', icon: <ExperimentOutlined />, desc: 'Kimi K2.5 (Anthropic 兼容)', color: '#0d9488', backendType: 'anthropic', defaultBaseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'moonshot-v1-8k', models: [] },
{ key: 'anthropic', label: 'Claude', icon: <ExperimentOutlined />, desc: 'Claude Opus/Sonnet', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-20241022', models: [] },
{ key: 'gemini', label: 'Gemini', icon: <CloudOutlined />, desc: 'Gemini 3.1 / 2.5 系列', color: '#059669', backendType: 'gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com', defaultModel: 'gemini-2.5-flash', models: [] },
{ key: 'volcengine-ark', label: '火山方舟', icon: <CloudOutlined />, desc: 'Ark 通用推理 / 豆包模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] },
{ key: 'volcengine-coding', label: '火山 Coding Plan', icon: <CloudOutlined />, desc: 'Ark Code / Coding Plan', color: '#0284c7', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', defaultModel: '', models: [] },
{ key: 'minimax', label: 'MiniMax', icon: <ExperimentOutlined />, desc: 'M3 / M2.7 系列 (Anthropic 兼容)', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-M3', models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed'] },
{ key: 'ollama', label: 'Ollama', icon: <AppstoreOutlined />, desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] },
{ key: 'custom', label: '自定义', icon: <AppstoreOutlined />, desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] },
];
const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find(p => p.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1];
const matchProviderPreset = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>): ProviderPreset => {
const presetKey = resolveProviderPresetKey(provider, PROVIDER_PRESETS, 'custom');
return findPreset(presetKey);
};
const SAFETY_OPTIONS: { label: string; value: AISafetyLevel; desc: string; color: string; icon: string }[] = [
{ label: '只读模式', value: 'readonly', desc: 'AI 仅可执行 SELECT 等查询操作,最安全', color: '#22c55e', icon: '🔒' },
{ label: '读写模式', value: 'readwrite', desc: 'AI 可执行 INSERT/UPDATE/DELETE危险操作需二次确认', color: '#f59e0b', icon: '⚠️' },
{ label: '完全模式', value: 'full', desc: 'AI 可执行所有操作(含 DDL高危操作自动告警', color: '#ef4444', icon: '🔓' },
];
const CONTEXT_OPTIONS: { label: string; value: AIContextLevel; desc: string; icon: string }[] = [
{ label: '仅 Schema', value: 'schema_only', desc: '只传递表/列结构信息给 AI', icon: '📋' },
{ label: '含采样数据', value: 'with_samples', desc: '包含少量采样数据帮助 AI 理解数据特征', icon: '📊' },
{ label: '含查询结果', value: 'with_results', desc: '传递最近的查询结果作为上下文', icon: '📑' },
];
const EMPTY_AI_USER_PROMPT_SETTINGS: AIUserPromptSettings = {
global: '',
database: '',
jvm: '',
jvmDiagnostic: '',
};
const EMPTY_MCP_SERVER = (): AIMCPServerConfig => ({
id: `mcp-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: '',
transport: 'stdio',
command: '',
args: [],
env: {},
enabled: true,
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: '',
description: '',
systemPrompt: '',
enabled: true,
scopes: ['global'],
requiredTools: [],
});
const SKILL_SCOPE_OPTIONS: Array<{ value: AISkillScope; label: string; desc: string }> = [
{ value: 'global', label: '全局', desc: '所有 AI 会话都启用' },
{ value: 'database', label: '数据库', desc: '仅 SQL / 数据库场景启用' },
{ value: 'jvm', label: 'JVM 资源', desc: '仅 JVM 资源分析场景启用' },
{ value: 'jvmDiagnostic', label: 'JVM 诊断', desc: '仅 JVM 诊断工作台启用' },
];
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 [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);
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<'providers' | 'safety' | 'context' | 'mcp' | 'skills' | 'prompts' | 'tools'>('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 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)';
// 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 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 = 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, 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(() => 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 (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]);
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 = () => {
setMCPServers((prev) => [...prev, EMPTY_MCP_SERVER()]);
};
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 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));
};
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 fieldGroupStyle: React.CSSProperties = {
padding: '14px 16px', borderRadius: 12, border: `1px solid ${cardBorder}`,
background: cardBg, marginBottom: 12,
};
const fieldLabelStyle: React.CSSProperties = {
fontSize: 13, fontWeight: 700, textTransform: 'uppercase' as const, letterSpacing: '0.08em',
color: sectionLabelColor, marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6,
};
// ===== Provider 列表 =====
const renderProviderList = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{providers.length === 0 && (
<div style={{
textAlign: 'center', padding: '36px 20px', color: overlayTheme.mutedText, fontSize: 14,
border: `1px dashed ${cardBorder}`, borderRadius: 14, background: cardBg,
}}>
<RobotOutlined style={{ fontSize: 32, marginBottom: 12, opacity: 0.3, display: 'block' }} />
<br />
<span style={{ fontSize: 13, opacity: 0.6 }}>使 AI </span>
</div>
)}
{providers.map(p => {
const matchedPreset = matchProviderPreset(p);
const isActive = p.id === activeProviderId;
return (
<div key={p.id} onClick={() => handleSetActive(p.id)} style={{
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
border: `1.5px solid ${isActive ? overlayTheme.selectedText : cardBorder}`,
background: isActive ? overlayTheme.selectedBg : cardBg,
display: 'flex', alignItems: 'center', gap: 14,
}}>
<div style={{
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center',
background: isActive ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)'),
color: isActive ? overlayTheme.iconColor : overlayTheme.mutedText,
fontSize: 18, flexShrink: 0, transition: 'all 0.2s ease',
}}>
{matchedPreset.icon || <ApiOutlined />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
{p.name || p.type}
{isActive && <CheckOutlined style={{ color: overlayTheme.iconColor, fontSize: 13 }} />}
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, marginTop: 4, display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{matchedPreset.label}</span>
<span style={{ opacity: 0.4 }}>·</span>
<span style={{ fontFamily: 'var(--gn-font-mono)', fontSize: 12 }}>{p.model || '未选择模型'}</span>
</div>
</div>
<Space size={2}>
<Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />}
onClick={e => { e.stopPropagation(); handleEditProvider(p); }}
style={{ color: overlayTheme.mutedText }} />
</Tooltip>
<Popconfirm title="确认删除?" onConfirm={() => handleDeleteProvider(p.id)}
okButtonProps={{ danger: true }} okText="删除" cancelText="取消">
<Button type="text" size="small" icon={<DeleteOutlined />} danger
onClick={e => e.stopPropagation()} />
</Popconfirm>
</Space>
</div>
);
})}
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddProvider}
style={{ borderRadius: 12, height: 42, borderColor: darkMode ? 'rgba(255,255,255,0.12)' : undefined }}>
</Button>
</div>
);
// ===== Provider 编辑表单 =====
const renderProviderForm = () => {
const presetKeyFromForm = watchedPresetKey || (editingProvider as any)?.presetKey || 'openai';
return (
<div>
{/* 顶部返回 */}
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10 }}>
<Button size="small" onClick={resetProviderEditorSession}
style={{ borderRadius: 8 }}> </Button>
<span style={{ fontWeight: 700, fontSize: 16, color: overlayTheme.titleText }}>
{editingProvider?.id ? '编辑模型供应商' : '添加模型供应商'}
</span>
</div>
<Form form={form} layout="vertical" size="small">
{/* Provider 类型选择 - 卡片式 */}
<div style={fieldGroupStyle}>
<div style={fieldLabelStyle}>
<AppstoreOutlined style={{ fontSize: 14 }} />
</div>
<Form.Item name="presetKey" noStyle>
<div style={PROVIDER_PRESET_GRID_STYLE}>
{PROVIDER_PRESETS.map(pt => (
<div key={pt.key} onClick={() => { form.setFieldValue('presetKey', pt.key); handlePresetChange(pt.key); }}
style={{
...PROVIDER_PRESET_CARD_BASE_STYLE,
border: `1.5px solid ${presetKeyFromForm === pt.key ? overlayTheme.selectedText : 'transparent'}`,
background: presetKeyFromForm === pt.key ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
boxShadow: presetKeyFromForm === pt.key ? 'none' : (darkMode ? 'inset 0 0 0 1px rgba(255,255,255,0.028)' : 'inset 0 0 0 1px rgba(16,24,40,0.03)'),
}}>
<div style={{
color: presetKeyFromForm === pt.key ? overlayTheme.iconColor : overlayTheme.mutedText,
fontSize: 18, marginTop: 2, transition: 'all 0.2s ease', flexShrink: 0,
}}>
{pt.icon}
</div>
<div style={PROVIDER_PRESET_CARD_CONTENT_STYLE}>
<div style={{ ...PROVIDER_PRESET_CARD_TITLE_STYLE, fontSize: 13, fontWeight: 700, color: overlayTheme.titleText, lineHeight: 1.3 }}>{pt.label}</div>
<div style={{ ...PROVIDER_PRESET_CARD_DESCRIPTION_STYLE, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.4 }}>{pt.desc}</div>
</div>
</div>
))}
</div>
</Form.Item>
<Form.Item name="type" hidden><Input /></Form.Item>
</div>
{/* 基本信息 - 仅自定义/Ollama 显示 */}
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
<div style={{ ...fieldGroupStyle, marginTop: 16 }}>
<div style={fieldLabelStyle}>
<RobotOutlined style={{ fontSize: 14 }} />
</div>
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}></span>} name="name" rules={[{ required: true, message: '请输入名称' }]} style={{ marginBottom: 16 }}>
<Input placeholder="例如:我的自建 OpenAI / 专属大模型"
size="middle"
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
</Form.Item>
{presetKeyFromForm === 'custom' && (
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API </span>} name="apiFormat" style={{ marginBottom: 16 }}>
<div style={{
display: 'inline-flex', padding: 4, background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.04)',
borderRadius: 8, gap: 4
}}>
{[{ value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }, { value: 'claude-cli', label: 'Claude CLI' }].map(fmt => (
<div
key={fmt.value}
onClick={() => form.setFieldsValue({ apiFormat: fmt.value })}
style={{
padding: '6px 16px', borderRadius: 6, fontSize: 13, fontWeight: watchedApiFormat === fmt.value ? 600 : 500, cursor: 'pointer',
background: watchedApiFormat === fmt.value ? (darkMode ? '#374151' : '#ffffff') : 'transparent',
color: watchedApiFormat === fmt.value ? overlayTheme.titleText : overlayTheme.mutedText,
boxShadow: watchedApiFormat === fmt.value ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
transition: 'all 0.2s ease',
}}
>
{fmt.label}
</div>
))}
</div>
</Form.Item>
)}
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}></span>} name="models" style={{ marginBottom: 0 }}>
<Select mode="tags" size="middle" placeholder="配置指定的模型ID留空则默认去服务端拉取" style={{ width: '100%' }} />
</Form.Item>
</div>
)}
<Form.Item name="model" hidden><Input /></Form.Item>
<Form.Item name="name" hidden><Input /></Form.Item>
{/* 认证信息 */}
<div style={{ ...fieldGroupStyle, marginTop: 16 }}>
<div style={fieldLabelStyle}>
<KeyOutlined style={{ fontSize: 14 }} /> &
</div>
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>} name="apiKey" rules={[{ validator: (_, value) => { const apiKey = String(value || '').trim(); if (apiKey || editingProvider?.id) { return Promise.resolve(); } return Promise.reject(new Error('请输入 API Key')); } }]} style={{ marginBottom: 16 }}>
<Input.Password placeholder="sk-... / 你的 API Key"
size="middle"
visibilityToggle={{
visible: primaryPasswordVisible,
onVisibleChange: setPrimaryPasswordVisible,
}}
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
</Form.Item>
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Endpoint (URL)</span>} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}>
<Input placeholder={findPreset(presetKeyFromForm).defaultBaseUrl || 'https://...'}
size="middle"
suffix={<LinkOutlined style={{ color: overlayTheme.mutedText }} />}
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
</Form.Item>
)}
</div>
{/* 操作按钮 */}
<div style={{
display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12, paddingTop: 16,
borderTop: `1px solid ${cardBorder}`, paddingBottom: 24,
}}>
<Button onClick={handleTestProvider} loading={loading} style={{ borderRadius: 10 }}
icon={testStatus === 'success' ? <CheckOutlined style={{ color: '#22c55e' }} /> : undefined}>
{testStatus === 'success' ? '连接正常' : testStatus === 'error' ? '重新测试' : '测试连接'}
</Button>
<Button type="primary" onClick={handleSaveProvider} loading={loading}
style={{ borderRadius: 10, fontWeight: 600 }}>
</Button>
</div>
</Form>
</div>
);
};
// ===== 安全控制 =====
const renderSafetySettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 8 }}>
AI SQL
</div>
{SAFETY_OPTIONS.map(opt => {
const active = safetyLevel === opt.value;
return (
<div key={opt.value} onClick={() => handleSafetyChange(opt.value)} style={{
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
border: `1.5px solid ${active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.selectedText) : cardBorder}`,
background: active ? (opt.color === '#ef4444' ? `${opt.color}15` : overlayTheme.selectedBg) : cardBg,
display: 'flex', alignItems: 'flex-start', gap: 14,
}}>
<div style={{
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center', fontSize: 18, flexShrink: 0,
background: active ? (opt.color === '#ef4444' ? `${opt.color}25` : overlayTheme.iconBg) : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
color: active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.iconColor) : overlayTheme.mutedText,
transition: 'all 0.2s ease',
}}>
{opt.icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
{opt.label}
{active && <CheckOutlined style={{ color: opt.color === '#ef4444' ? opt.color : overlayTheme.iconColor, fontSize: 14 }} />}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 4, lineHeight: '1.5' }}>{opt.desc}</div>
</div>
</div>
);
})}
</div>
);
// ===== 上下文级别 =====
const renderContextSettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 8 }}>
AI
</div>
{CONTEXT_OPTIONS.map(opt => {
const active = contextLevel === opt.value;
return (
<div key={opt.value} onClick={() => handleContextChange(opt.value)} style={{
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
border: `1.5px solid ${active ? overlayTheme.selectedText : cardBorder}`,
background: active ? overlayTheme.selectedBg : cardBg,
display: 'flex', alignItems: 'flex-start', gap: 14,
}}>
<div style={{
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center', fontSize: 18, flexShrink: 0,
background: active ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
color: active ? overlayTheme.iconColor : overlayTheme.mutedText,
transition: 'all 0.2s ease',
}}>
{opt.icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
{opt.label}
{active && <CheckOutlined style={{ color: overlayTheme.iconColor, fontSize: 14 }} />}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 4, lineHeight: '1.5' }}>{opt.desc}</div>
</div>
</div>
);
})}
</div>
);
const renderPromptSettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{
padding: '14px 16px',
borderRadius: 14,
border: `1px solid ${cardBorder}`,
background: cardBg,
}}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, marginBottom: 6 }}>
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 14 }}>
system message
线
</div>
{[
{
key: 'global',
title: '全局补充提示词',
desc: '对所有 AI 会话生效,例如“先给结论”“回答保持简洁”。',
rows: 4,
},
{
key: 'database',
title: '数据库会话补充提示词',
desc: '仅数据库/SQL 场景生效,例如“生成 SQL 前必须先确认字段名”。',
rows: 5,
},
{
key: 'jvm',
title: 'JVM 资源分析补充提示词',
desc: '仅 JVM 资源浏览/分析场景生效。',
rows: 4,
},
{
key: 'jvmDiagnostic',
title: 'JVM 诊断补充提示词',
desc: '仅 JVM 诊断工作台生效,例如“先给计划,再给命令”。',
rows: 4,
},
].map((item) => (
<div key={item.key} style={{ marginTop: 14 }}>
<div style={{ fontWeight: 600, fontSize: 13, color: overlayTheme.titleText, marginBottom: 4 }}>
{item.title}
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 8 }}>
{item.desc}
</div>
<Input.TextArea
rows={item.rows}
value={userPromptSettings[item.key as keyof AIUserPromptSettings]}
onChange={(event) => setUserPromptSettings((prev) => ({
...prev,
[item.key]: event.target.value,
}))}
placeholder="留空表示不额外追加"
style={{
borderRadius: 10,
background: inputBg,
border: `1px solid ${cardBorder}`,
fontFamily: 'var(--gn-font-mono)',
resize: 'vertical',
}}
/>
</div>
))}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
<Button type="primary" onClick={handleSaveUserPromptSettings} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}>
</Button>
</div>
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
GoNavi AI
</div>
{Object.entries(builtinPrompts).map(([title, promptText]) => (
<div key={title} style={{
padding: '12px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: cardBg,
}}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<RobotOutlined style={{ color: overlayTheme.iconColor }} /> {title}
</div>
<div style={{
background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)',
padding: '10px 12px', borderRadius: 8, fontSize: 13, color: overlayTheme.mutedText,
whiteSpace: 'pre-wrap', fontFamily: 'var(--gn-font-mono)', lineHeight: 1.5,
userSelect: 'text', border: darkMode ? '1px solid rgba(255,255,255,0.03)' : '1px solid rgba(0,0,0,0.02)'
}}>
{promptText}
</div>
</div>
))}
</div>
);
const renderSkillSettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
Skill + + GitHub skill pack
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText }}> scope Skill </div>
<Button icon={<PlusOutlined />} onClick={handleAddSkill} style={{ borderRadius: 10 }}> Skill</Button>
</div>
{skills.length === 0 && (
<div style={{ padding: '18px 16px', borderRadius: 14, border: `1px dashed ${cardBorder}`, background: cardBg, color: overlayTheme.mutedText }}>
SkillJVM system prompt
</div>
)}
{skills.map((skill) => (
<div key={skill.id} style={{ padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 132px', gap: 12 }}>
<Input
value={skill.name}
onChange={(event) => updateSkillDraft(skill.id, { name: event.target.value })}
placeholder="Skill 名称例如SQL 审查 / JVM 诊断计划"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
<Select
value={skill.enabled ? 'enabled' : 'disabled'}
onChange={(value) => updateSkillDraft(skill.id, { enabled: value === 'enabled' })}
options={[{ label: '已启用', value: 'enabled' }, { label: '已禁用', value: 'disabled' }]}
/>
</div>
<Input
value={skill.description || ''}
onChange={(event) => updateSkillDraft(skill.id, { description: event.target.value })}
placeholder="给自己看的说明,例如:输出 SQL 前必须先确认字段名和风险"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }}
/>
<Select
mode="multiple"
value={skill.scopes || []}
onChange={(value) => updateSkillDraft(skill.id, { scopes: value as AISkillScope[] })}
options={SKILL_SCOPE_OPTIONS.map((option) => ({ label: `${option.label} · ${option.desc}`, value: option.value }))}
placeholder="选择这个 Skill 要作用到哪些场景"
style={{ width: '100%' }}
/>
<Select
mode="multiple"
value={skill.requiredTools || []}
onChange={(value) => updateSkillDraft(skill.id, { requiredTools: value })}
options={skillRequiredToolOptions}
placeholder="可选:声明这个 Skill 依赖哪些工具"
style={{ width: '100%' }}
/>
<Input.TextArea
rows={6}
value={skill.systemPrompt}
onChange={(event) => updateSkillDraft(skill.id, { systemPrompt: event.target.value })}
placeholder="输入这条 Skill 要追加的 system prompt。建议聚焦一个明确能力不要和全局提示词重复。"
style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)', resize: 'vertical' }}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button type="primary" onClick={() => handleSaveSkill(skill)} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}></Button>
<Popconfirm title="删除这个 Skill" okText="删除" cancelText="取消" onConfirm={() => handleDeleteSkill(skill.id)}>
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 10 }}></Button>
</Popconfirm>
</div>
</div>
))}
</div>
);
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}
<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 }}>
{[
{ key: 'providers', title: '模型供应商', description: '配置大模型接口与秘钥', icon: <ApiOutlined /> },
{ key: 'safety', title: '安全控制', description: '限制 AI 操作风险级别', icon: <SafetyCertificateOutlined /> },
{ key: 'context', title: '上下文', description: '配置携带的数据架构信息', icon: <RobotOutlined /> },
{ key: 'mcp', title: 'MCP 服务', description: '接入外部工具源', icon: <AppstoreOutlined /> },
{ key: 'skills', title: 'Skills', description: '配置可复用提示模块', icon: <ExperimentOutlined /> },
{ key: 'tools', title: '内置工具', description: '查看 AI 可调用的数据探针', icon: <ToolOutlined /> },
{ key: 'prompts', title: '内置提示词', description: '查看系统预设的底层要求', icon: <ExperimentOutlined /> },
].map((item) => {
const active = activeSection === item.key;
return (
<button
key={item.key}
type="button"
onClick={() => setActiveSection(item.key as typeof activeSection)}
style={{
textAlign: 'left',
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${active
? (darkMode ? 'rgba(255,214,102,0.3)' : 'rgba(24,144,255,0.24)')
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)')}`,
background: active
? (darkMode ? 'linear-gradient(180deg, rgba(255,214,102,0.12) 0%, rgba(255,214,102,0.06) 100%)' : 'linear-gradient(180deg, rgba(24,144,255,0.10) 0%, rgba(24,144,255,0.05) 100%)')
: (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
color: active ? (darkMode ? '#f5f7ff' : '#162033') : (darkMode ? 'rgba(255,255,255,0.82)' : '#3f4b5e'),
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 16 }}>{item.icon}</span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{item.title}</span>
</div>
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.6, color: active ? (darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(22,32,51,0.68)') : 'rgba(128,128,128,0.7)' }}>
{item.description}
</div>
</button>
);
})}
</div>
</div>
<div style={{ minWidth: 0, minHeight: 0, height: '100%', overflowY: 'auto', overflowX: 'hidden', paddingRight: 8, paddingBottom: 28 }}>
{activeSection === 'providers' && (isEditing ? renderProviderForm() : renderProviderList())}
{activeSection === 'safety' && renderSafetySettings()}
{activeSection === 'context' && renderContextSettings()}
{activeSection === 'mcp' && (
<AISettingsMCPSection
mcpClientStatuses={mcpClientStatuses}
selectedMCPClient={selectedMCPClient}
selectedMCPClientStatus={selectedMCPClientStatus}
selectedMCPClientCommandText={selectedMCPClientCommandText}
mcpServers={mcpServers}
mcpTools={mcpTools}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
inputBg={inputBg}
loading={loading}
mcpClientStatusLoading={mcpClientStatusLoading}
onSelectClient={setSelectedMCPClient}
onRefreshStatus={() => void loadMCPClientStatuses()}
onCopyConfigPath={() => void handleCopySelectedMCPConfigPath()}
onCopyLaunchCommand={() => void handleCopySelectedMCPLaunchCommand()}
onInstallSelectedClient={handleInstallSelectedMCPClient}
onAddServer={handleAddMCPServer}
onUpdateServerDraft={updateMCPServerDraft}
onTestServer={handleTestMCPServer}
onSaveServer={handleSaveMCPServer}
onDeleteServer={handleDeleteMCPServer}
/>
)}
{activeSection === 'skills' && renderSkillSettings()}
{activeSection === 'tools' && (
<AIBuiltinToolsCatalog
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
/>
)}
{activeSection === 'prompts' && renderPromptSettings()}
</div>
</div>
</Modal>
);
};
export default AISettingsModal;