import React, { useEffect, useState, useCallback, useRef } from 'react'; import { App, Card, Form, Input, Button, Select, AutoComplete, InputNumber, Tag, Descriptions, Spin, Alert, Typography, Space, Steps, } from 'antd'; import { SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, WarningOutlined, ReloadOutlined, CloudServerOutlined, SafetyOutlined, RobotOutlined, } from '@ant-design/icons'; import api from '../../api'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; import type { AiProvider } from './helpers'; import { PROVIDER_OPTIONS, PROVIDER_DEFAULTS, FIXED_PROVIDERS, formatDateTime, sourceLabel, sourceColor, shouldAutoSwapBaseUrl, extractErrorMessage, } from './helpers'; import styles from './index.module.css'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface AiConfigData { id: number; provider: AiProvider; baseUrl: string; hasApiKey: boolean; hasDatabaseKey: boolean; maskedApiKey: string | null; keySource: 'database' | 'environment' | 'none'; defaultModel: string | null; enabled: boolean; timeoutMs: number; verified: boolean; lastTestedAt: string | null; lastTestLatencyMs: number | null; createdAt: string; updatedAt: string; } interface TestResult { success: boolean; latencyMs: number | null; modelCount: number | null; modelAvailable: boolean; testedAt: string; message: string; } interface FetchModelsResult { success: boolean; models: Array<{ id: string }>; message?: string; } interface ApiResponse { success: boolean; data: T; message?: string; } // --------------------------------------------------------------------------- // Form state — mirrors all form fields, survives Step unmounts // --------------------------------------------------------------------------- interface FormValues { provider: AiProvider; baseUrl: string; apiKey: string; defaultModel: string; timeoutMs: number; } const DEFAULT_FORM_VALUES: FormValues = { provider: 'DEEPSEEK', baseUrl: PROVIDER_DEFAULTS['DEEPSEEK'], apiKey: '', defaultModel: '', timeoutMs: 30000, }; // --------------------------------------------------------------------------- // Step definitions // --------------------------------------------------------------------------- const STEP_ITEMS = [ { title: '服务商', description: '选择 AI 服务商' }, { title: '密钥', description: '配置 API 密钥' }, { title: '模型', description: '获取并选择模型' }, { title: '完成', description: '保存并测试连接' }, ]; // --------------------------------------------------------------------------- // Page Component // --------------------------------------------------------------------------- const AiConfigPage: React.FC = () => { const { hasPermission } = usePermission(); const { modal } = App.useApp(); const [form] = Form.useForm(); const [currentStep, setCurrentStep] = useState(0); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); const [fetchingModels, setFetchingModels] = useState(false); const [config, setConfig] = useState(null); const [testResult, setTestResult] = useState(null); const [modelOptions, setModelOptions] = useState>([]); const [error, setError] = useState(null); // Central form state — survives Step transitions when Form.Items unmount const [formValues, setFormValues] = useState(DEFAULT_FORM_VALUES); const lastProviderRef = useRef(null); const canWrite = hasPermission('ai:config:write'); const canTest = hasPermission('ai:config:test'); const canRead = hasPermission('ai:config:read'); // ── Sync form → state ── const handleFormChange = useCallback((_changed: Partial, all: Partial) => { setFormValues((prev) => ({ ...prev, ...all })); }, []); // ── Load config (full) — used on initial mount and after save ── const loadConfig = useCallback(async () => { setLoading(true); setError(null); try { const res = await api.get>('/ai/config'); setConfig(res.data); const initial: FormValues = { provider: res.data.provider, baseUrl: res.data.baseUrl, apiKey: '', defaultModel: res.data.defaultModel ?? '', timeoutMs: res.data.timeoutMs, }; form.setFieldsValue(initial); setFormValues(initial); lastProviderRef.current = res.data.provider; if (res.data.defaultModel) { setModelOptions([{ value: res.data.defaultModel, label: res.data.defaultModel }]); } } catch (err: unknown) { setError(extractErrorMessage(err, '加载配置失败')); } finally { setLoading(false); } }, [form]); // ── Refresh config (light) — only updates the config info display, // does NOT touch form values. Used after test/fetch-models. ── const refreshConfig = useCallback(async () => { try { const res = await api.get>('/ai/config'); setConfig(res.data); } catch { // silent — config display refresh is non-critical } }, []); useEffect(() => { loadConfig(); }, [loadConfig]); // ── Provider change → swap baseUrl ── const handleProviderChange = useCallback( (provider: AiProvider) => { const result = shouldAutoSwapBaseUrl(provider, formValues.baseUrl, lastProviderRef.current); if (result.shouldSwap) { form.setFieldValue('baseUrl', result.baseUrl); } lastProviderRef.current = provider; }, [form, formValues.baseUrl], ); const currentProvider = formValues.provider; const isFixedProvider = FIXED_PROVIDERS.includes(currentProvider); // ── Fetch models from provider ── const handleFetchModels = useCallback(async () => { setFetchingModels(true); try { await form.validateFields(['provider', 'baseUrl']); const { provider, baseUrl, apiKey: formKey } = formValues; const body: Record = { provider }; if (baseUrl) body.baseUrl = baseUrl; if (formKey && formKey !== '••••') body.apiKey = formKey; const res = await api.post('/ai/config/models', body); if (res.success && res.models.length > 0) { const options = res.models.map((m) => ({ value: m.id, label: m.id })); setModelOptions(options); message.success(`获取到 ${res.models.length} 个模型`); } else { message.warning(res.message || '未获取到可用模型'); } } catch (err: unknown) { message.error(extractErrorMessage(err, '获取模型列表失败')); } finally { setFetchingModels(false); } }, [form, formValues]); // ── Save ── const handleSave = useCallback(async () => { try { // Validate fields (for UI error display) — actual values come from state await form.validateFields(['provider', 'baseUrl', 'timeoutMs']); const { provider, baseUrl, defaultModel, apiKey, timeoutMs } = formValues; if (provider === 'OPENAI_COMPATIBLE' && !baseUrl) { message.error('OPENAI_COMPATIBLE 模式必须填写 Base URL'); return; } setSaving(true); const resolvedBaseUrl = baseUrl || PROVIDER_DEFAULTS[provider] || ''; const body: Record = { provider, baseUrl: resolvedBaseUrl, defaultModel: defaultModel || undefined, enabled: true, timeoutMs, }; if (apiKey && apiKey !== '••••') { body.apiKey = apiKey; } await api.put('/ai/config', body); message.success('配置已保存'); form.setFieldValue('apiKey', ''); setFormValues((prev) => ({ ...prev, apiKey: '' })); } catch (err: unknown) { message.error(extractErrorMessage(err, '保存失败')); setSaving(false); return; } try { await loadConfig(); } catch (err: unknown) { message.warning(extractErrorMessage(err, '配置已保存,但刷新失败')); } finally { setSaving(false); } }, [formValues, form, loadConfig]); // ── Test connection ── const handleTest = useCallback(async () => { try { const fieldsToValidate = ['provider', 'timeoutMs'] as string[]; if (currentProvider === 'OPENAI_COMPATIBLE') { fieldsToValidate.push('baseUrl'); } await form.validateFields(fieldsToValidate); setTesting(true); setTestResult(null); const { provider, baseUrl, defaultModel, apiKey, timeoutMs } = formValues; const body: Record = { provider, timeoutMs }; if (baseUrl) body.baseUrl = baseUrl; if (defaultModel) body.defaultModel = defaultModel; if (apiKey && apiKey !== '••••') body.apiKey = apiKey; const res = await api.post('/ai/config/test', body); setTestResult(res); await refreshConfig(); } catch (err: unknown) { setTestResult({ success: false, latencyMs: null, modelCount: null, modelAvailable: false, testedAt: new Date().toISOString(), message: extractErrorMessage(err, '测试请求失败'), }); } finally { setTesting(false); } }, [formValues, form, loadConfig, currentProvider]); // ── Clear key ── const handleClearKey = useCallback(() => { const isEnv = config?.keySource === 'environment'; modal.confirm({ title: '确认清除密钥', content: isEnv ? '数据库中的密钥将被清除,但环境变量 AI_API_KEY 仍可使用。确定继续?' : '密钥将被永久清除,之后将无法使用 AI 功能。确定继续?', okText: '确认清除', okType: 'danger', cancelText: '取消', onOk: async () => { try { await api.post('/ai/config/clear-key'); message.success('密钥已清除'); await loadConfig(); } catch (err: unknown) { message.error(extractErrorMessage(err, '清除失败')); } }, }); }, [config, loadConfig, modal]); // ── Step navigation ── const goNext = useCallback(async () => { // Validate current step fields before moving try { if (currentStep === 0) { await form.validateFields(['provider', 'baseUrl', 'timeoutMs']); } else if (currentStep === 1) { // API key step — optional, no validation needed } else if (currentStep === 2) { await form.validateFields(['defaultModel']); } setCurrentStep((s) => Math.min(s + 1, STEP_ITEMS.length - 1)); } catch { // Validation failed — form will show errors } }, [currentStep, form]); const goPrev = useCallback(() => { setCurrentStep((s) => Math.max(s - 1, 0)); }, []); // ── No read permission ── if (!canRead) { return (
); } if (loading) { return (
); } if (error && !config) { return (
); } // ── Render step content ── const renderStepContent = () => { switch (currentStep) { // Step 0: Provider + Base URL + Timeout case 0: return ( 服务商配置} extra={} > ); // Step 1: API Key case 1: return ( 密钥配置} extra={} > {config && ( {config.hasApiKey ? ( {config.maskedApiKey || '••••'} ) : ( 未配置 )} {sourceLabel(config.keySource)} {config.keySource === 'environment' && ( 由环境变量托管,需在服务器修改 )} {formatDateTime(config.updatedAt)} )} {config?.hasDatabaseKey && canWrite && (
)} {config?.keySource === 'environment' && !config.hasDatabaseKey && (
密钥由环境变量提供,无法通过页面清除
)}
API Key 使用 AES-256-GCM 加密存储,每次保存使用随机 IV。传输层通过 HTTPS 保护,服务端日志不记录密钥。
也可通过环境变量 AI_API_KEY 注入密钥, 环境变量优先级高于数据库存储。
); // Step 2: Model selection case 2: return ( 模型选择} extra={} >
{modelOptions.length > 0 && ( {modelOptions.length} 个可用模型 )}
option?.value?.toLowerCase().includes(inputValue.toLowerCase()) ?? false } /> {config?.verified && (
} color="success"> 上次验证通过 {config.lastTestLatencyMs != null && ( 延迟: {config.lastTestLatencyMs}ms )}
)}
); // Step 3: Save & Test case 3: return ( 保存并测试} extra={} > {PROVIDER_OPTIONS.find((o) => o.value === currentProvider)?.label ?? currentProvider ?? '-'} {formValues.baseUrl || '-'} {formValues.defaultModel || '未设置'} {(() => { const hasFormKey = formValues.apiKey && formValues.apiKey !== '••••'; if (config?.hasApiKey) { return {config.maskedApiKey || '••••'}; } if (hasFormKey) { return 已填写(未保存); } return 未配置; })()} {formValues.timeoutMs}ms {config?.enabled ? '已启用' : '未启用'} } style={{ marginBottom: 16 }} /> {canWrite && ( )} {canTest && ( )} {/* Test result */} {testResult && ( {testResult.success ? ( testResult.modelAvailable ? ( } color="success"> 成功 ) : ( } color="warning"> 模型未找到 ) ) : ( } color="error"> 失败 )} {testResult.latencyMs != null ? `${testResult.latencyMs} ms` : '-'} {testResult.modelCount != null ? testResult.modelCount : '-'} {formatDateTime(testResult.testedAt)} )} ); default: return null; } }; // ── Render ── return (

AI 模型配置

密钥仅保存在服务器端,浏览器无法读取明文

{config?.enabled ? '已启用' : '未启用'} {config?.verified && 已验证} {config?.hasApiKey && ( 密钥: {sourceLabel(config?.keySource || 'none')} )}
{renderStepContent()}
{currentStep < STEP_ITEMS.length - 1 ? ( ) : null}
); }; export default AiConfigPage;