import React, { useEffect, useState, useCallback, useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; import { aiConfigEnvelopeSchema } from '../../api/schemas'; import { App, Alert, Button, Form, Space, Spin, Steps, Tag } from 'antd'; import api from '../../api'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; import type { AiProvider } from './helpers'; import { PROVIDER_DEFAULTS, FIXED_PROVIDERS, sourceLabel, sourceColor, shouldAutoSwapBaseUrl, } from './helpers'; import { getErrorMessage } from '../../utils/error'; import { ProviderStep, KeyStep, ModelStep, SaveTestStep, } from './AiConfigSteps'; import type { AiConfigData, FormValues, TestResult } from './AiConfigSteps'; import styles from './index.module.css'; interface ApiResponse { success: boolean; data: T; message?: string; } interface FetchModelsResult { success: boolean; models: Array<{ id: string }>; message?: string; } const DEFAULT_FORM_VALUES: FormValues = { provider: 'DEEPSEEK', baseUrl: PROVIDER_DEFAULTS['DEEPSEEK'], apiKey: '', defaultModel: '', timeoutMs: 30000, supportsVision: false, reasoningEffort: '', }; const STEP_ITEMS = [ { title: '服务商', description: '选择 AI 服务商' }, { title: '密钥', description: '配置 API 密钥' }, { title: '模型', description: '获取并选择模型' }, { title: '完成', description: '保存并测试连接' }, ]; const AiConfigPage: React.FC = () => { const { hasPermission } = usePermission(); const { modal } = App.useApp(); const [form] = Form.useForm(); const [currentStep, setCurrentStep] = useState(0); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); const [fetchingModels, setFetchingModels] = useState(false); 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 skipNextSyncRef = useRef(false); const appliedConfigRef = useRef(null); const { data: config, isLoading: configLoading, isFetching: configFetching, refetch: refetchConfig, } = useQuery({ queryKey: ['ai', 'config'], queryFn: async () => { try { const res = await api.get>('/ai/config'); return validateResponse>(aiConfigEnvelopeSchema, res).data; } catch (err: unknown) { setError(getErrorMessage(err, '加载配置失败')); return null; } }, }); const loading = configLoading || configFetching; const refreshConfig = useCallback(() => { skipNextSyncRef.current = true; return refetchConfig(); }, [refetchConfig]); const saveMutation = useApiMutation( async (body: Record) => api.put('/ai/config', body), { invalidate: [['ai', 'config']] }, ); const clearKeyMutation = useApiMutation( async () => api.post('/ai/config/clear-key'), { invalidate: [['ai', 'config']] }, ); 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 })); }, [], ); // 配置数据到位后同步进表单(antd Form 属于外部系统); // refreshConfig(测试/拉模型后)只刷新展示,不覆盖用户表单输入。 useEffect(() => { if (!config) return; if (skipNextSyncRef.current) { skipNextSyncRef.current = false; appliedConfigRef.current = config; return; } if (appliedConfigRef.current === config) return; appliedConfigRef.current = config; const initial: FormValues = { provider: config.provider, baseUrl: config.baseUrl, apiKey: '', defaultModel: config.defaultModel ?? '', timeoutMs: config.timeoutMs, supportsVision: config.supportsVision, reasoningEffort: config.reasoningEffort ?? '', }; form.setFieldsValue(initial); setFormValues(initial); lastProviderRef.current = config.provider; if (config.defaultModel) { setModelOptions([{ value: config.defaultModel, label: config.defaultModel }]); } setError(null); }, [config, form]); // ── 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(getErrorMessage(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, supportsVision, reasoningEffort, } = 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, supportsVision, timeoutMs, reasoningEffort: reasoningEffort || null, }; if (apiKey && apiKey !== '••••') { body.apiKey = apiKey; } await saveMutation.mutateAsync(body); message.success('配置已保存'); form.setFieldValue('apiKey', ''); setFormValues((prev) => ({ ...prev, apiKey: '' })); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } }, [formValues, form, saveMutation]); // ── 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, reasoningEffort } = formValues; const body: Record = { provider, timeoutMs }; if (baseUrl) body.baseUrl = baseUrl; if (defaultModel) body.defaultModel = defaultModel; if (apiKey && apiKey !== '••••') body.apiKey = apiKey; if (reasoningEffort) body.reasoningEffort = reasoningEffort; 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: getErrorMessage(err, '测试请求失败'), }); } finally { setTesting(false); } }, [formValues, form, 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 clearKeyMutation.mutateAsync(); message.success('密钥已清除'); } catch { // 错误提示由 useApiMutation 统一处理 } }, }); }, [config, modal]); // ── Step navigation ── const goNext = useCallback(async () => { try { if (currentStep === 0) { await form.validateFields(['provider', 'baseUrl', 'timeoutMs']); } 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 (
); } const renderStepContent = () => { switch (currentStep) { case 0: return ( ); case 1: return ; case 2: return ( ); case 3: return ( ); 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;