Files
gongxue-base/apps/admin/src/pages/AiConfig/index.tsx

446 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<T> {
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<TestResult | null>(null);
const [modelOptions, setModelOptions] = useState<Array<{ value: string; label: string }>>([]);
const [error, setError] = useState<string | null>(null);
// Central form state — survives Step transitions when Form.Items unmount
const [formValues, setFormValues] = useState<FormValues>(DEFAULT_FORM_VALUES);
const lastProviderRef = useRef<AiProvider | null>(null);
const skipNextSyncRef = useRef(false);
const appliedConfigRef = useRef<AiConfigData | null>(null);
const {
data: config,
isLoading: configLoading,
isFetching: configFetching,
refetch: refetchConfig,
} = useQuery<AiConfigData | null>({
queryKey: ['ai', 'config'],
queryFn: async () => {
try {
const res = await api.get<ApiResponse<AiConfigData>>('/ai/config');
return validateResponse<ApiResponse<AiConfigData>>(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<string, unknown>) => 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<FormValues>, all: Partial<FormValues>) => {
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<string, unknown> = { provider };
if (baseUrl) body.baseUrl = baseUrl;
if (formKey && formKey !== '••••') body.apiKey = formKey;
const res = await api.post<FetchModelsResult>('/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<string, unknown> = {
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<string, unknown> = { 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<TestResult>('/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 (
<div className={styles.container}>
<Alert type="error" title="您没有查看 AI 配置的权限" showIcon />
</div>
);
}
if (loading) {
return (
<div className={styles.container} style={{ textAlign: 'center', paddingTop: 80 }}>
<Spin size="large" />
</div>
);
}
if (error && !config) {
return (
<div className={styles.container}>
<Alert type="error" title={error} showIcon />
</div>
);
}
const renderStepContent = () => {
switch (currentStep) {
case 0:
return (
<ProviderStep
canWrite={canWrite}
isFixedProvider={isFixedProvider}
config={config}
onProviderChange={handleProviderChange}
/>
);
case 1:
return <KeyStep canWrite={canWrite} config={config} onClearKey={handleClearKey} />;
case 2:
return (
<ModelStep
canWrite={canWrite}
config={config}
onFetchModels={handleFetchModels}
fetchingModels={fetchingModels}
modelOptions={modelOptions}
/>
);
case 3:
return (
<SaveTestStep
canWrite={canWrite}
canTest={canTest}
config={config}
currentProvider={currentProvider}
formValues={formValues}
onSave={handleSave}
saving={saving}
onTest={handleTest}
testing={testing}
testResult={testResult}
/>
);
default:
return null;
}
};
// ── Render ──
return (
<div className={styles.container}>
<div className={styles.header}>
<h2>AI </h2>
<p className={styles.headerDesc}></p>
<div className={styles.statusRow}>
<Space size="small">
<Tag color={config?.enabled ? 'green' : 'default'}>
{config?.enabled ? '已启用' : '未启用'}
</Tag>
{config?.verified && <Tag color="blue"></Tag>}
{config?.hasApiKey && (
<Tag color={sourceColor(config?.keySource || 'none')}>
: {sourceLabel(config?.keySource || 'none')}
</Tag>
)}
</Space>
</div>
</div>
<Steps
current={currentStep}
items={STEP_ITEMS}
onChange={setCurrentStep}
className={styles.steps}
size="small"
/>
<Form
form={form}
layout="vertical"
initialValues={{ timeoutMs: 30000 }}
onValuesChange={handleFormChange}
>
<div className={styles.stepContent}>{renderStepContent()}</div>
<div className={styles.stepNav}>
<Button onClick={goPrev} disabled={currentStep === 0} icon={<span></span>}>
</Button>
{currentStep < STEP_ITEMS.length - 1 ? (
<Button type="primary" onClick={goNext} icon={<span></span>}>
</Button>
) : null}
</div>
</Form>
</div>
);
};
export default AiConfigPage;