From c76b634739aee31607ed47e19630befa72360867 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 8 Jun 2026 09:14:55 +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=E4=BE=9B=E5=BA=94=E5=95=86=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 外提供应商列表与编辑表单组件,收敛 AISettingsModal 体积 - 保留 API Key 掩码、预设卡片切换与返回编辑流 - 补充定向测试、前端构建和真实预览验证 --- .../AISettingsModal.edit-password.test.tsx | 5 +- frontend/src/components/AISettingsModal.tsx | 258 ++---------- .../ai/AISettingsProvidersSection.test.tsx | 111 +++++ .../ai/AISettingsProvidersSection.tsx | 391 ++++++++++++++++++ 4 files changed, 540 insertions(+), 225 deletions(-) create mode 100644 frontend/src/components/ai/AISettingsProvidersSection.test.tsx create mode 100644 frontend/src/components/ai/AISettingsProvidersSection.tsx diff --git a/frontend/src/components/AISettingsModal.edit-password.test.tsx b/frontend/src/components/AISettingsModal.edit-password.test.tsx index 9add7f4..7a3d349 100644 --- a/frontend/src/components/AISettingsModal.edit-password.test.tsx +++ b/frontend/src/components/AISettingsModal.edit-password.test.tsx @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; const source = readFileSync(new URL('./AISettingsModal.tsx', import.meta.url), 'utf8'); const aiChatPanelCss = readFileSync(new URL('./AIChatPanel.css', import.meta.url), 'utf8'); +const providersSectionSource = readFileSync(new URL('./ai/AISettingsProvidersSection.tsx', import.meta.url), 'utf8'); describe('AISettingsModal edit password behavior', () => { it('loads editable provider details before opening the edit modal', () => { @@ -29,9 +30,11 @@ describe('AISettingsModal edit password behavior', () => { it('delegates bulky MCP and built-in tool sections to dedicated ai components', () => { expect(source).toContain("import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';"); + expect(source).toContain("import AISettingsProvidersSection from './ai/AISettingsProvidersSection';"); expect(source).toContain("import AISettingsSidebar, { type AISettingsSectionKey } from './ai/AISettingsSidebar';"); expect(source).toContain("import AISettingsSafetySection from './ai/AISettingsSafetySection';"); expect(source).toContain("import AISettingsContextSection from './ai/AISettingsContextSection';"); + expect(source).toContain(' { it('keeps the prefilled api key masked by default', () => { expect(source).toContain('const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);'); - expect(source).toContain('visible: primaryPasswordVisible,'); + expect(providersSectionSource).toContain('visible: primaryPasswordVisible,'); }); it('does not render the clear helper block anymore', () => { diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index ace02ca..6f8f07d 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -1,6 +1,6 @@ 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, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined } from '@ant-design/icons'; +import { Modal, Form, message as antdMessage } from 'antd'; +import { ApiOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, AppstoreOutlined } from '@ant-design/icons'; import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AIMCPClientInstallStatus, AISkillConfig } from '../types'; import { QWEN_BAILIAN_ANTHROPIC_BASE_URL, @@ -11,13 +11,6 @@ import { 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'; @@ -27,6 +20,7 @@ import AISettingsMCPSection, { type MCPClientKey } from './ai/AISettingsMCPSecti 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'; interface AISettingsModalProps { @@ -246,7 +240,6 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo 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); @@ -803,219 +796,6 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo }); }; - // ---- 字段装饰器样式 ---- - 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 = () => ( -
- {providers.length === 0 && ( -
- - 暂未配置模型供应商
- 添加一个以开始使用 AI 助手 -
- )} - {providers.map(p => { - const matchedPreset = matchProviderPreset(p); - const isActive = p.id === activeProviderId; - return ( -
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, - }}> -
- {matchedPreset.icon || } -
-
-
- {p.name || p.type} - {isActive && } -
-
- {matchedPreset.label} - · - {p.model || '未选择模型'} -
-
- - -
- ); - })} - -
- ); - - // ===== Provider 编辑表单 ===== - const renderProviderForm = () => { - const presetKeyFromForm = watchedPresetKey || (editingProvider as any)?.presetKey || 'openai'; - return ( -
- {/* 顶部返回 */} -
- - - {editingProvider?.id ? '编辑模型供应商' : '添加模型供应商'} - -
- -
- {/* Provider 类型选择 - 卡片式 */} -
-
- 服务类型 -
- -
- {PROVIDER_PRESETS.map(pt => ( -
{ 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)'), - }}> -
- {pt.icon} -
-
-
{pt.label}
-
{pt.desc}
-
-
- ))} -
-
- -
- - {/* 基本信息 - 仅自定义/Ollama 显示 */} - {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && ( -
-
- 基本信息 -
- - 供应商名称} name="name" rules={[{ required: true, message: '请输入名称' }]} style={{ marginBottom: 16 }}> - - - - {presetKeyFromForm === 'custom' && ( - API 格式} name="apiFormat" style={{ marginBottom: 16 }}> -
- {[{ value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }, { value: 'claude-cli', label: 'Claude CLI' }].map(fmt => ( -
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} -
- ))} -
-
- )} - - 可用模型列表(可选配置)} name="models" style={{ marginBottom: 0 }}> - - - - {/* 认证信息 */} -
-
- 认证 & 连接 -
- API Key} 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 }}> - - - - {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && ( - API Endpoint (URL)} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}> - } - style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} /> - - )} -
- - - - {/* 操作按钮 */} -
- - -
- -
- ); - }; - const modalShellStyle = { background: overlayTheme.shellBg, border: overlayTheme.shellBorder, boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter, @@ -1058,7 +838,37 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo onSelectSection={setActiveSection} />
- {activeSection === 'providers' && (isEditing ? renderProviderForm() : renderProviderList())} + {activeSection === 'providers' && ( + + )} {activeSection === 'safety' && ( O, desc: 'GPT', defaultBaseUrl: 'https://api.openai.com/v1' }, + { key: 'custom', label: '自定义', icon: C, desc: '自定义接口', defaultBaseUrl: 'https://example.com' }, +]; + +const provider: AIProviderConfig = { + id: 'provider-1', + name: 'OpenAI', + type: 'openai', + apiKey: '', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + maxTokens: 4096, + temperature: 0.7, +}; + +const overlayTheme = buildOverlayWorkbenchTheme(false); + +describe('AISettingsProvidersSection', () => { + it('renders provider cards in list mode', () => { + const Wrap = () => { + const [form] = Form.useForm(); + return ( + {}} + resolveProviderPreset={() => ({ label: 'OpenAI', icon: O })} + resolvePresetByKey={(key) => providerPresets.find((item) => item.key === key) || providerPresets[0]} + onAddProvider={() => {}} + onEditProvider={() => {}} + onDeleteProvider={() => {}} + onSetActiveProvider={() => {}} + onCancelEdit={() => {}} + onPresetChange={() => {}} + onTestProvider={() => {}} + onSaveProvider={() => {}} + /> + ); + }; + + const markup = renderToStaticMarkup(); + expect(markup).toContain('OpenAI'); + expect(markup).toContain('gpt-4o'); + expect(markup).toContain('添加模型供应商'); + }); + + it('renders provider form in editing mode', () => { + const Wrap = () => { + const [form] = Form.useForm(); + return ( + {}} + resolveProviderPreset={() => ({ label: 'OpenAI', icon: O })} + resolvePresetByKey={(key) => providerPresets.find((item) => item.key === key) || providerPresets[0]} + onAddProvider={() => {}} + onEditProvider={() => {}} + onDeleteProvider={() => {}} + onSetActiveProvider={() => {}} + onCancelEdit={() => {}} + onPresetChange={() => {}} + onTestProvider={() => {}} + onSaveProvider={() => {}} + /> + ); + }; + + const markup = renderToStaticMarkup(); + expect(markup).toContain('编辑模型供应商'); + expect(markup).toContain('供应商名称'); + expect(markup).toContain('API Endpoint (URL)'); + expect(markup).toContain('测试连接'); + }); +}); diff --git a/frontend/src/components/ai/AISettingsProvidersSection.tsx b/frontend/src/components/ai/AISettingsProvidersSection.tsx new file mode 100644 index 0000000..81d9d01 --- /dev/null +++ b/frontend/src/components/ai/AISettingsProvidersSection.tsx @@ -0,0 +1,391 @@ +import React from 'react'; +import { Button, Form, Input, Popconfirm, Select, Space, Tooltip } from 'antd'; +import { ApiOutlined, AppstoreOutlined, CheckOutlined, DeleteOutlined, EditOutlined, KeyOutlined, LinkOutlined, PlusOutlined, RobotOutlined } from '@ant-design/icons'; +import type { FormInstance } from 'antd/es/form'; + +import type { AIProviderConfig } from '../../types'; +import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; +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'; + +export interface AISettingsProviderPresetOption { + key: string; + label: string; + icon: React.ReactNode; + desc: string; + defaultBaseUrl: string; +} + +interface MatchedProviderPreset { + label: string; + icon: React.ReactNode; +} + +interface AISettingsProvidersSectionProps { + providers: AIProviderConfig[]; + activeProviderId: string; + editingProvider: AIProviderConfig | null; + isEditing: boolean; + form: FormInstance; + providerPresets: AISettingsProviderPresetOption[]; + watchedPresetKey?: string; + watchedApiFormat?: string; + loading: boolean; + testStatus: 'idle' | 'success' | 'error'; + primaryPasswordVisible: boolean; + darkMode: boolean; + overlayTheme: OverlayWorkbenchTheme; + cardBg: string; + cardBorder: string; + inputBg: string; + onPrimaryPasswordVisibleChange: (visible: boolean) => void; + resolveProviderPreset: (provider: Pick) => MatchedProviderPreset; + resolvePresetByKey: (presetKey: string) => AISettingsProviderPresetOption; + onAddProvider: () => void; + onEditProvider: (provider: AIProviderConfig) => void; + onDeleteProvider: (id: string) => void; + onSetActiveProvider: (id: string) => void; + onCancelEdit: () => void; + onPresetChange: (presetKey: string) => void; + onTestProvider: () => void; + onSaveProvider: () => void; +} + +const fieldGroupStyle = (cardBorder: string, cardBg: string): React.CSSProperties => ({ + padding: '14px 16px', + borderRadius: 12, + border: `1px solid ${cardBorder}`, + background: cardBg, + marginBottom: 12, +}); + +const fieldLabelStyle = (sectionLabelColor: string): React.CSSProperties => ({ + fontSize: 13, + fontWeight: 700, + textTransform: 'uppercase', + letterSpacing: '0.08em', + color: sectionLabelColor, + marginBottom: 10, + display: 'flex', + alignItems: 'center', + gap: 6, +}); + +const AISettingsProvidersSection: React.FC = ({ + providers, + activeProviderId, + editingProvider, + isEditing, + form, + providerPresets, + watchedPresetKey, + watchedApiFormat, + loading, + testStatus, + primaryPasswordVisible, + darkMode, + overlayTheme, + cardBg, + cardBorder, + inputBg, + onPrimaryPasswordVisibleChange, + resolveProviderPreset, + resolvePresetByKey, + onAddProvider, + onEditProvider, + onDeleteProvider, + onSetActiveProvider, + onCancelEdit, + onPresetChange, + onTestProvider, + onSaveProvider, +}) => { + const presetKeyFromForm = watchedPresetKey || (editingProvider as (AIProviderConfig & { presetKey?: string }) | null)?.presetKey || 'openai'; + const sectionLabelColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)'; + const currentFieldGroupStyle = fieldGroupStyle(cardBorder, cardBg); + const currentFieldLabelStyle = fieldLabelStyle(sectionLabelColor); + + if (!isEditing) { + return ( +
+ {providers.length === 0 && ( +
+ + 暂未配置模型供应商 +
+ 添加一个以开始使用 AI 助手 +
+ )} + {providers.map((provider) => { + const matchedPreset = resolveProviderPreset(provider); + const isActive = provider.id === activeProviderId; + return ( +
onSetActiveProvider(provider.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, + }} + > +
+ {matchedPreset.icon || } +
+
+
+ {provider.name || provider.type} + {isActive && } +
+
+ {matchedPreset.label} + · + {provider.model || '未选择模型'} +
+
+ + +
+ ); + })} + +
+ ); + } + + return ( +
+
+ + + {editingProvider?.id ? '编辑模型供应商' : '添加模型供应商'} + +
+ +
+
+
+ 服务类型 +
+ +
+ {providerPresets.map((preset) => ( +
{ + form.setFieldValue('presetKey', preset.key); + onPresetChange(preset.key); + }} + style={{ + ...PROVIDER_PRESET_CARD_BASE_STYLE, + border: `1.5px solid ${presetKeyFromForm === preset.key ? overlayTheme.selectedText : 'transparent'}`, + background: presetKeyFromForm === preset.key ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'), + boxShadow: presetKeyFromForm === preset.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)'), + }} + > +
+ {preset.icon} +
+
+
{preset.label}
+
{preset.desc}
+
+
+ ))} +
+
+ +
+ + {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && ( +
+
+ 基本信息 +
+ + 供应商名称} name="name" rules={[{ required: true, message: '请输入名称' }]} style={{ marginBottom: 16 }}> + + + + {presetKeyFromForm === 'custom' && ( + API 格式} name="apiFormat" style={{ marginBottom: 16 }}> +
+ {[{ value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }, { value: 'claude-cli', label: 'Claude CLI' }].map((format) => ( +
form.setFieldsValue({ apiFormat: format.value })} + style={{ + padding: '6px 16px', + borderRadius: 6, + fontSize: 13, + fontWeight: watchedApiFormat === format.value ? 600 : 500, + cursor: 'pointer', + background: watchedApiFormat === format.value ? (darkMode ? '#374151' : '#ffffff') : 'transparent', + color: watchedApiFormat === format.value ? overlayTheme.titleText : overlayTheme.mutedText, + boxShadow: watchedApiFormat === format.value ? '0 1px 3px rgba(0,0,0,0.1)' : 'none', + transition: 'all 0.2s ease', + }} + > + {format.label} +
+ ))} +
+
+ )} + + 可用模型列表(可选配置)} name="models" style={{ marginBottom: 0 }}> + + + +
+
+ 认证 & 连接 +
+ API Key} + 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 }} + > + + + + {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && ( + API Endpoint (URL)} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}> + } + style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} + /> + + )} +
+ +
+ + +
+ +
+ ); +}; + +export default AISettingsProvidersSection;