diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx
index 3b1b090..b929528 100644
--- a/frontend/src/components/AISettingsModal.tsx
+++ b/frontend/src/components/AISettingsModal.tsx
@@ -1,12 +1,8 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Modal, Form, message as antdMessage } from 'antd';
-import { ApiOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, AppstoreOutlined } from '@ant-design/icons';
+import { RobotOutlined } from '@ant-design/icons';
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AIMCPClientInstallStatus, AISkillConfig } from '../types';
import {
- QWEN_BAILIAN_ANTHROPIC_BASE_URL,
- QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
- QWEN_CODING_PLAN_MODELS,
- resolveProviderPresetKey,
resolvePresetBaseURL,
resolvePresetModelSelection,
resolvePresetTransport,
@@ -25,6 +21,16 @@ 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;
@@ -33,101 +39,6 @@ interface AISettingsModalProps {
focusProviderId?: string;
}
-// 预设配置:每个预设映射到后端 type(openai/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: , 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: , desc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] },
- { key: 'qwen-bailian', label: '通义千问(百炼通用)', icon: , desc: '百炼 Anthropic 兼容 / 模型从远端拉取', color: '#6366f1', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, defaultModel: '', models: [] },
- { key: 'qwen-coding-plan', label: '通义千问(Coding Plan)', icon: , 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: , 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: , 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: , 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: , 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: , desc: 'Ark 通用推理 / 豆包模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] },
- { key: 'volcengine-coding', label: '火山 Coding Plan', icon: , 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: , 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: , desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] },
- { key: 'custom', label: '自定义', icon: , 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): ProviderPreset => {
- const presetKey = resolveProviderPresetKey(provider, PROVIDER_PRESETS, 'custom');
- return findPreset(presetKey);
-};
-
-const EMPTY_AI_USER_PROMPT_SETTINGS: AIUserPromptSettings = {
- global: '',
- database: '',
- jvm: '',
- jvmDiagnostic: '',
-};
-
-const EMPTY_MCP_SERVER = (seed?: Partial): AIMCPServerConfig => {
- const base: AIMCPServerConfig = {
- id: `mcp-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
- name: '',
- transport: 'stdio',
- command: '',
- args: [],
- env: {},
- enabled: true,
- timeoutSeconds: 20,
- };
- return {
- ...base,
- ...seed,
- transport: seed?.transport || base.transport,
- args: Array.isArray(seed?.args) ? seed.args : base.args,
- env: seed?.env || base.env,
- enabled: seed?.enabled ?? base.enabled,
- timeoutSeconds: seed?.timeoutSeconds || base.timeoutSeconds,
- };
-};
-
-const waitFor = (delayMs: number) => new Promise((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 EMPTY_SKILL = (): AISkillConfig => ({
- id: `skill-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
- name: '',
- description: '',
- systemPrompt: '',
- enabled: true,
- scopes: ['global'],
- requiredTools: [],
-});
-
const AISettingsModal: React.FC = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
const [providers, setProviders] = useState([]);
const [activeProviderId, setActiveProviderId] = useState('');
diff --git a/frontend/src/components/ai/aiSettingsModalConfig.test.tsx b/frontend/src/components/ai/aiSettingsModalConfig.test.tsx
new file mode 100644
index 0000000..8b6f95e
--- /dev/null
+++ b/frontend/src/components/ai/aiSettingsModalConfig.test.tsx
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+
+import { QWEN_CODING_PLAN_ANTHROPIC_BASE_URL } from '../../utils/aiProviderPresets';
+import {
+ EMPTY_MCP_SERVER,
+ EMPTY_SKILL,
+ PROVIDER_PRESETS,
+ findPreset,
+ matchProviderPreset,
+} from './aiSettingsModalConfig';
+
+describe('aiSettingsModalConfig', () => {
+ it('finds the matching preset and falls back to custom when the key is unknown', () => {
+ expect(findPreset('openai').label).toBe('OpenAI');
+ expect(findPreset('missing-preset').key).toBe('custom');
+ });
+
+ it('matches an anthropic-compatible provider back to the qwen coding plan preset', () => {
+ const preset = matchProviderPreset({
+ type: 'custom',
+ baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
+ apiFormat: 'claude-cli',
+ });
+
+ expect(preset.key).toBe('qwen-coding-plan');
+ });
+
+ it('creates MCP server drafts and skill drafts with stable defaults', () => {
+ const server = EMPTY_MCP_SERVER({ name: 'Browser', args: ['stdio'] });
+ const skill = EMPTY_SKILL();
+
+ expect(server.transport).toBe('stdio');
+ expect(server.timeoutSeconds).toBe(20);
+ expect(server.args).toEqual(['stdio']);
+ expect(skill.enabled).toBe(true);
+ expect(skill.scopes).toEqual(['global']);
+ });
+
+ it('keeps the provider preset list available for the settings modal', () => {
+ expect(PROVIDER_PRESETS.some((item) => item.key === 'codex')).toBe(false);
+ expect(PROVIDER_PRESETS.some((item) => item.key === 'openai')).toBe(true);
+ expect(PROVIDER_PRESETS.some((item) => item.key === 'custom')).toBe(true);
+ });
+});
diff --git a/frontend/src/components/ai/aiSettingsModalConfig.tsx b/frontend/src/components/ai/aiSettingsModalConfig.tsx
new file mode 100644
index 0000000..a806d61
--- /dev/null
+++ b/frontend/src/components/ai/aiSettingsModalConfig.tsx
@@ -0,0 +1,119 @@
+import React from 'react';
+import {
+ ApiOutlined,
+ AppstoreOutlined,
+ CloudOutlined,
+ ExperimentOutlined,
+ ThunderboltOutlined,
+} from '@ant-design/icons';
+
+import type {
+ AIMCPServerConfig,
+ AIProviderConfig,
+ AIProviderType,
+ AISkillConfig,
+ AIUserPromptSettings,
+} from '../../types';
+import {
+ QWEN_BAILIAN_ANTHROPIC_BASE_URL,
+ QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
+ QWEN_CODING_PLAN_MODELS,
+ resolveProviderPresetKey,
+} from '../../utils/aiProviderPresets';
+
+export interface ProviderPreset {
+ key: string;
+ label: string;
+ icon: React.ReactNode;
+ desc: string;
+ color: string;
+ backendType: AIProviderType;
+ fixedApiFormat?: string;
+ defaultBaseUrl: string;
+ defaultModel: string;
+ models: string[];
+}
+
+export const PROVIDER_PRESETS: ProviderPreset[] = [
+ { key: 'openai', label: 'OpenAI', icon: , 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: , desc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] },
+ { key: 'qwen-bailian', label: '通义千问(百炼通用)', icon: , desc: '百炼 Anthropic 兼容 / 模型从远端拉取', color: '#6366f1', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, defaultModel: '', models: [] },
+ { key: 'qwen-coding-plan', label: '通义千问(Coding Plan)', icon: , 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: , 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: , 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: , 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: , 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: , desc: 'Ark 通用推理 / 豆包模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] },
+ { key: 'volcengine-coding', label: '火山 Coding Plan', icon: , 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: , 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: , desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] },
+ { key: 'custom', label: '自定义', icon: , desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] },
+];
+
+export const findPreset = (key: string): ProviderPreset =>
+ PROVIDER_PRESETS.find((preset) => preset.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1];
+
+export const matchProviderPreset = (
+ provider: Pick,
+): ProviderPreset => {
+ const presetKey = resolveProviderPresetKey(provider, PROVIDER_PRESETS, 'custom');
+ return findPreset(presetKey);
+};
+
+export const EMPTY_AI_USER_PROMPT_SETTINGS: AIUserPromptSettings = {
+ global: '',
+ database: '',
+ jvm: '',
+ jvmDiagnostic: '',
+};
+
+export const EMPTY_MCP_SERVER = (seed?: Partial): AIMCPServerConfig => {
+ const base: AIMCPServerConfig = {
+ id: `mcp-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ name: '',
+ transport: 'stdio',
+ command: '',
+ args: [],
+ env: {},
+ enabled: true,
+ timeoutSeconds: 20,
+ };
+ return {
+ ...base,
+ ...seed,
+ transport: seed?.transport || base.transport,
+ args: Array.isArray(seed?.args) ? seed.args : base.args,
+ env: seed?.env || base.env,
+ enabled: seed?.enabled ?? base.enabled,
+ timeoutSeconds: seed?.timeoutSeconds || base.timeoutSeconds,
+ };
+};
+
+const waitFor = (delayMs: number) => new Promise((resolve) => {
+ window.setTimeout(resolve, delayMs);
+});
+
+const readAIService = () => (window as any).go?.aiservice?.Service;
+
+export 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();
+};
+
+export const EMPTY_SKILL = (): AISkillConfig => ({
+ id: `skill-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ name: '',
+ description: '',
+ systemPrompt: '',
+ enabled: true,
+ scopes: ['global'],
+ requiredTools: [],
+});