import React, { useEffect, useState, useCallback, useRef } from 'react'; import { App, Card, Form, Input, Button, Select, Switch, InputNumber, Tag, Descriptions, Spin, Alert, Typography, Space, } from 'antd'; import { SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, KeyOutlined, WarningOutlined, } 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 ApiResponse { success: boolean; data: T; message?: string; } // --------------------------------------------------------------------------- // Page Component // --------------------------------------------------------------------------- const AiConfigPage: React.FC = () => { const { hasPermission } = usePermission(); const { modal } = App.useApp(); const [form] = Form.useForm(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); const [config, setConfig] = useState(null); const [testResult, setTestResult] = useState(null); const [error, setError] = useState(null); const lastProviderRef = useRef(null); const canWrite = hasPermission('ai:config:write'); const canTest = hasPermission('ai:config:test'); const canRead = hasPermission('ai:config:read'); // ── Load config ── const loadConfig = useCallback(async () => { setLoading(true); setError(null); try { const res = await api.get>('/ai/config'); setConfig(res.data); form.setFieldsValue({ provider: res.data.provider, baseUrl: res.data.baseUrl, defaultModel: res.data.defaultModel ?? undefined, enabled: res.data.enabled, timeoutMs: res.data.timeoutMs, }); lastProviderRef.current = res.data.provider; } catch (err: unknown) { setError(extractErrorMessage(err, '加载配置失败')); } finally { setLoading(false); } }, [form]); useEffect(() => { loadConfig(); }, [loadConfig]); // ── Provider change → swap baseUrl ── const handleProviderChange = useCallback( (provider: AiProvider) => { const currentBaseUrl = form.getFieldValue('baseUrl') || ''; const result = shouldAutoSwapBaseUrl(provider, currentBaseUrl, lastProviderRef.current); if (result.shouldSwap) { form.setFieldValue('baseUrl', result.baseUrl); } lastProviderRef.current = provider; }, [form], ); const currentProvider = Form.useWatch('provider', form) as AiProvider | undefined; const isFixedProvider = currentProvider ? FIXED_PROVIDERS.includes(currentProvider) : false; // ── Save ── const handleSave = useCallback(async () => { try { const values = await form.validateFields(); setSaving(true); // Validate baseUrl for OPENAI_COMPATIBLE if (values.provider === 'OPENAI_COMPATIBLE' && !values.baseUrl) { message.error('OPENAI_COMPATIBLE 模式必须填写 Base URL'); setSaving(false); return; } const body: Record = { provider: values.provider, baseUrl: values.baseUrl, defaultModel: values.defaultModel || undefined, enabled: values.enabled, timeoutMs: values.timeoutMs, }; if (values.apiKey && values.apiKey !== '••••') { body.apiKey = values.apiKey; } await api.put('/ai/config', body); message.success('配置已保存'); form.setFieldValue('apiKey', ''); } catch (err: unknown) { message.error(extractErrorMessage(err, '保存失败')); setSaving(false); return; } // Reload config from server (non-fatal if it fails) try { await loadConfig(); } catch (err: unknown) { message.warning(extractErrorMessage(err, '配置已保存,但刷新失败')); } finally { setSaving(false); } }, [form, loadConfig]); // ── Test connection ── const handleTest = useCallback(async () => { try { // Validated fields: compatible requires baseUrl const fieldsToValidate = ['provider', 'timeoutMs'] as string[]; if (currentProvider === 'OPENAI_COMPATIBLE') { fieldsToValidate.push('baseUrl'); } const values = await form.validateFields(fieldsToValidate); setTesting(true); setTestResult(null); const body: Record = { timeoutMs: values.timeoutMs, }; // Always send provider if form has it if (currentProvider) body.provider = currentProvider; if (values.baseUrl) body.baseUrl = values.baseUrl; // Include defaultModel so backend checks target model const defaultModel = form.getFieldValue('defaultModel'); if (defaultModel) body.defaultModel = defaultModel; const typedKey = form.getFieldValue('apiKey'); if (typedKey && typedKey !== '••••') { body.apiKey = typedKey; } const res = await api.post('/ai/config/test', body); setTestResult(res); await loadConfig(); } catch (err: unknown) { setTestResult({ success: false, latencyMs: null, modelCount: null, modelAvailable: false, testedAt: new Date().toISOString(), message: extractErrorMessage(err, '测试请求失败'), }); } finally { setTesting(false); } }, [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]); // ── No read permission ── if (!canRead) { return (
); } if (loading) { return (
); } if (error && !config) { return (
); } // ── Render ── return (

AI 模型配置

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

{config?.enabled ? '已启用' : '未启用'} {config?.verified && 已验证} {config?.hasApiKey && ( 密钥: {sourceLabel(config?.keySource || 'none')} )}
{/* Left: 模型路由 */} 模型路由} extra={}> prev.enabled !== curr.enabled}> {({ getFieldValue }) => { const enabled = getFieldValue('enabled'); return ( ); }} {/* Right: 密钥保险库 */} 密钥保险库} 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 注入密钥, 环境变量优先级高于数据库存储。
{/* Actions */}
{canWrite ? ( ) : null} {canTest ? ( ) : null}
{/* 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)} )}
); }; export default AiConfigPage;