♻️ refactor(ai-settings): 拆分供应商配置视图组件

- 外提供应商列表与编辑表单组件,收敛 AISettingsModal 体积

- 保留 API Key 掩码、预设卡片切换与返回编辑流

- 补充定向测试、前端构建和真实预览验证
This commit is contained in:
Syngnat
2026-06-08 09:14:55 +08:00
parent fc88e21811
commit c76b634739
4 changed files with 540 additions and 225 deletions

View File

@@ -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('<AISettingsProvidersSection');
expect(source).toContain("import AISettingsMCPSection, { type MCPClientKey } from './ai/AISettingsMCPSection';");
expect(source).toContain('<AISettingsSidebar');
expect(source).toContain('<AISettingsSafetySection');
@@ -58,7 +61,7 @@ describe('AISettingsModal edit password behavior', () => {
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', () => {

View File

@@ -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<AISettingsModalProps> = ({ 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<AISettingsModalProps> = ({ 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 = () => (
<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 modalShellStyle = {
background: overlayTheme.shellBg, border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter,
@@ -1058,7 +838,37 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
onSelectSection={setActiveSection}
/>
<div style={{ minWidth: 0, minHeight: 0, height: '100%', overflowY: 'auto', overflowX: 'hidden', paddingRight: 8, paddingBottom: 28 }}>
{activeSection === 'providers' && (isEditing ? renderProviderForm() : renderProviderList())}
{activeSection === 'providers' && (
<AISettingsProvidersSection
providers={providers}
activeProviderId={activeProviderId}
editingProvider={editingProvider}
isEditing={isEditing}
form={form}
providerPresets={PROVIDER_PRESETS}
watchedPresetKey={watchedPresetKey}
watchedApiFormat={watchedApiFormat}
loading={loading}
testStatus={testStatus}
primaryPasswordVisible={primaryPasswordVisible}
darkMode={darkMode}
overlayTheme={overlayTheme}
cardBg={cardBg}
cardBorder={cardBorder}
inputBg={inputBg}
onPrimaryPasswordVisibleChange={setPrimaryPasswordVisible}
resolveProviderPreset={matchProviderPreset}
resolvePresetByKey={findPreset}
onAddProvider={handleAddProvider}
onEditProvider={handleEditProvider}
onDeleteProvider={handleDeleteProvider}
onSetActiveProvider={handleSetActive}
onCancelEdit={resetProviderEditorSession}
onPresetChange={handlePresetChange}
onTestProvider={handleTestProvider}
onSaveProvider={handleSaveProvider}
/>
)}
{activeSection === 'safety' && (
<AISettingsSafetySection
safetyLevel={safetyLevel}

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { Form } from 'antd';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import type { AIProviderConfig } from '../../types';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import AISettingsProvidersSection from './AISettingsProvidersSection';
const providerPresets = [
{ key: 'openai', label: 'OpenAI', icon: <span>O</span>, desc: 'GPT', defaultBaseUrl: 'https://api.openai.com/v1' },
{ key: 'custom', label: '自定义', icon: <span>C</span>, 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 (
<AISettingsProvidersSection
providers={[provider]}
activeProviderId="provider-1"
editingProvider={null}
isEditing={false}
form={form}
providerPresets={providerPresets}
loading={false}
testStatus="idle"
primaryPasswordVisible={false}
darkMode={false}
overlayTheme={overlayTheme}
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
inputBg="#fff"
onPrimaryPasswordVisibleChange={() => {}}
resolveProviderPreset={() => ({ label: 'OpenAI', icon: <span>O</span> })}
resolvePresetByKey={(key) => providerPresets.find((item) => item.key === key) || providerPresets[0]}
onAddProvider={() => {}}
onEditProvider={() => {}}
onDeleteProvider={() => {}}
onSetActiveProvider={() => {}}
onCancelEdit={() => {}}
onPresetChange={() => {}}
onTestProvider={() => {}}
onSaveProvider={() => {}}
/>
);
};
const markup = renderToStaticMarkup(<Wrap />);
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 (
<AISettingsProvidersSection
providers={[provider]}
activeProviderId="provider-1"
editingProvider={provider}
isEditing
form={form}
providerPresets={providerPresets}
watchedPresetKey="custom"
watchedApiFormat="openai"
loading={false}
testStatus="idle"
primaryPasswordVisible={false}
darkMode={false}
overlayTheme={overlayTheme}
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
inputBg="#fff"
onPrimaryPasswordVisibleChange={() => {}}
resolveProviderPreset={() => ({ label: 'OpenAI', icon: <span>O</span> })}
resolvePresetByKey={(key) => providerPresets.find((item) => item.key === key) || providerPresets[0]}
onAddProvider={() => {}}
onEditProvider={() => {}}
onDeleteProvider={() => {}}
onSetActiveProvider={() => {}}
onCancelEdit={() => {}}
onPresetChange={() => {}}
onTestProvider={() => {}}
onSaveProvider={() => {}}
/>
);
};
const markup = renderToStaticMarkup(<Wrap />);
expect(markup).toContain('编辑模型供应商');
expect(markup).toContain('供应商名称');
expect(markup).toContain('API Endpoint (URL)');
expect(markup).toContain('测试连接');
});
});

View File

@@ -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<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>) => 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<AISettingsProvidersSectionProps> = ({
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 (
<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((provider) => {
const matchedPreset = resolveProviderPreset(provider);
const isActive = provider.id === activeProviderId;
return (
<div
key={provider.id}
onClick={() => 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,
}}
>
<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 }}>
{provider.name || provider.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 }}>{provider.model || '未选择模型'}</span>
</div>
</div>
<Space size={2}>
<Tooltip title="编辑">
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={(event) => {
event.stopPropagation();
onEditProvider(provider);
}}
style={{ color: overlayTheme.mutedText }}
/>
</Tooltip>
<Popconfirm
title="确认删除?"
onConfirm={() => onDeleteProvider(provider.id)}
okButtonProps={{ danger: true }}
okText="删除"
cancelText="取消"
>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
danger
onClick={(event) => event.stopPropagation()}
/>
</Popconfirm>
</Space>
</div>
);
})}
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={onAddProvider}
style={{ borderRadius: 12, height: 42, borderColor: darkMode ? 'rgba(255,255,255,0.12)' : undefined }}
>
</Button>
</div>
);
}
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10 }}>
<Button size="small" onClick={onCancelEdit} style={{ borderRadius: 8 }}> </Button>
<span style={{ fontWeight: 700, fontSize: 16, color: overlayTheme.titleText }}>
{editingProvider?.id ? '编辑模型供应商' : '添加模型供应商'}
</span>
</div>
<Form form={form} layout="vertical" size="small">
<div style={currentFieldGroupStyle}>
<div style={currentFieldLabelStyle}>
<AppstoreOutlined style={{ fontSize: 14 }} />
</div>
<Form.Item name="presetKey" noStyle>
<div style={PROVIDER_PRESET_GRID_STYLE}>
{providerPresets.map((preset) => (
<div
key={preset.key}
onClick={() => {
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)'),
}}
>
<div style={{
color: presetKeyFromForm === preset.key ? overlayTheme.iconColor : overlayTheme.mutedText,
fontSize: 18,
marginTop: 2,
transition: 'all 0.2s ease',
flexShrink: 0,
}}>
{preset.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 }}>{preset.label}</div>
<div style={{ ...PROVIDER_PRESET_CARD_DESCRIPTION_STYLE, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.4 }}>{preset.desc}</div>
</div>
</div>
))}
</div>
</Form.Item>
<Form.Item name="type" hidden><Input /></Form.Item>
</div>
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
<div style={{ ...currentFieldGroupStyle, marginTop: 16 }}>
<div style={currentFieldLabelStyle}>
<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((format) => (
<div
key={format.value}
onClick={() => 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}
</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={{ ...currentFieldGroupStyle, marginTop: 16 }}>
<div style={currentFieldLabelStyle}>
<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: onPrimaryPasswordVisibleChange,
}}
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={resolvePresetByKey(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={onTestProvider}
loading={loading}
style={{ borderRadius: 10 }}
icon={testStatus === 'success' ? <CheckOutlined style={{ color: '#22c55e' }} /> : undefined}
>
{testStatus === 'success' ? '连接正常' : testStatus === 'error' ? '重新测试' : '测试连接'}
</Button>
<Button type="primary" onClick={onSaveProvider} loading={loading} style={{ borderRadius: 10, fontWeight: 600 }}>
</Button>
</div>
</Form>
</div>
);
};
export default AISettingsProvidersSection;