From c081d23cc43d3bd5ebbf0ca2039371cc3f2562fc Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 9 Jun 2026 08:04:06 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(ai-settings):=20?= =?UTF-8?q?=E6=8B=86=E5=88=86=20AI=20=E8=AE=BE=E7=BD=AE=E9=A2=84=E8=AE=BE?= =?UTF-8?q?=E4=B8=8E=E6=9C=8D=E5=8A=A1=E6=A1=A5=E6=8E=A5=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/AISettingsModal.tsx | 111 ++-------------- .../ai/aiSettingsModalConfig.test.tsx | 44 +++++++ .../components/ai/aiSettingsModalConfig.tsx | 119 ++++++++++++++++++ 3 files changed, 174 insertions(+), 100 deletions(-) create mode 100644 frontend/src/components/ai/aiSettingsModalConfig.test.tsx create mode 100644 frontend/src/components/ai/aiSettingsModalConfig.tsx 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: [], +});