Files
gongxue-base/apps/admin/src/pages/AiConfig/index.tsx
xiong 525bbd1a56
Some checks failed
CI / check (pull_request) Failing after 2m42s
Add seed data for 2025 summer school schedule
Drop unused KeyOutlined import and refactor init.sql to drop all tables
safely
2026-07-24 15:28:19 +08:00

755 lines
24 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 {
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<T> {
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<AiConfigData | null>(null);
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 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 }));
}, []);
// ── Load config (full) — used on initial mount and after save ──
const loadConfig = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await api.get<ApiResponse<AiConfigData>>('/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<ApiResponse<AiConfigData>>('/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<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(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<string, unknown> = {
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<string, unknown> = { provider, timeoutMs };
if (baseUrl) body.baseUrl = baseUrl;
if (defaultModel) body.defaultModel = defaultModel;
if (apiKey && apiKey !== '••••') body.apiKey = apiKey;
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: 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 (
<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>
);
}
// ── Render step content ──
const renderStepContent = () => {
switch (currentStep) {
// Step 0: Provider + Base URL + Timeout
case 0:
return (
<Card
title={<span className={styles.cardTitle}></span>}
extra={<CloudServerOutlined />}
>
<Form.Item
name="provider"
label="Provider"
rules={[{ required: true, message: '请选择 Provider' }]}
preserve
>
<Select
options={PROVIDER_OPTIONS}
onChange={handleProviderChange}
disabled={!canWrite}
size="large"
/>
</Form.Item>
<Form.Item
name="baseUrl"
label="Base URL"
rules={[
{ required: true, message: '请输入 Base URL' },
{ type: 'url', message: '请输入合法的 URL' },
]}
preserve
>
<Input
placeholder={
config?.provider
? PROVIDER_DEFAULTS[config.provider]
: 'https://api.deepseek.com'
}
disabled={!canWrite || (isFixedProvider && canWrite)}
size="large"
/>
</Form.Item>
<Form.Item
name="timeoutMs"
label="请求超时 (毫秒)"
rules={[
{ required: true, message: '请输入超时时间' },
{ type: 'number', min: 1000, max: 120000, message: '范围: 1000-120000' },
]}
preserve
>
<InputNumber
min={1000}
max={120000}
step={1000}
style={{ width: '100%' }}
disabled={!canWrite}
size="large"
/>
</Form.Item>
</Card>
);
// Step 1: API Key
case 1:
return (
<Card
title={<span className={styles.cardTitle}></span>}
extra={<SafetyOutlined />}
>
<Form.Item name="apiKey" label="API Key" preserve>
<Input.Password
placeholder={config?.hasApiKey ? '已安全保存,留空则保持不变' : '请输入 API Key'}
disabled={!canWrite}
autoComplete="new-password"
size="large"
/>
</Form.Item>
{config && (
<Descriptions column={1} size="small" style={{ marginBottom: 12 }}>
<Descriptions.Item label="状态">
{config.hasApiKey ? (
<Tag color="green">{config.maskedApiKey || '••••'}</Tag>
) : (
<Tag color="default"></Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="来源">
<Tag color={sourceColor(config.keySource)}>{sourceLabel(config.keySource)}</Tag>
{config.keySource === 'environment' && (
<span style={{ marginLeft: 8, fontSize: 12, color: '#999' }}>
</span>
)}
</Descriptions.Item>
<Descriptions.Item label="最后更新">
{formatDateTime(config.updatedAt)}
</Descriptions.Item>
</Descriptions>
)}
{config?.hasDatabaseKey && canWrite && (
<div style={{ marginBottom: 8 }}>
<Button danger size="small" onClick={handleClearKey}>
</Button>
</div>
)}
{config?.keySource === 'environment' && !config.hasDatabaseKey && (
<div style={{ marginBottom: 8, fontSize: 12, color: '#999' }}>
</div>
)}
<div className={styles.safetyNote}>
API Key 使 AES-256-GCM 使 IV HTTPS
</div>
<div className={styles.safetyNoteKey}>
<Typography.Text code>AI_API_KEY</Typography.Text>
</div>
</Card>
);
// Step 2: Model selection
case 2:
return (
<Card
title={<span className={styles.cardTitle}></span>}
extra={<RobotOutlined />}
>
<div className={styles.modelFetchRow}>
<Button
icon={<ReloadOutlined />}
onClick={handleFetchModels}
loading={fetchingModels}
disabled={!canWrite}
>
</Button>
{modelOptions.length > 0 && (
<Tag color="blue">{modelOptions.length} </Tag>
)}
</div>
<Form.Item
name="defaultModel"
label="默认模型"
rules={[{ required: true, message: '请选择或输入默认模型' }]}
style={{ marginTop: 16 }}
preserve
>
<AutoComplete
options={modelOptions}
placeholder="选择或输入模型名称,如 deepseek-chat, gpt-4"
disabled={!canWrite}
size="large"
filterOption={(inputValue, option) =>
option?.value?.toLowerCase().includes(inputValue.toLowerCase()) ?? false
}
/>
</Form.Item>
{config?.verified && (
<div style={{ marginTop: 8 }}>
<Tag icon={<CheckCircleOutlined />} color="success">
</Tag>
{config.lastTestLatencyMs != null && (
<span style={{ marginLeft: 8, fontSize: 12, color: '#999' }}>
: {config.lastTestLatencyMs}ms
</span>
)}
</div>
)}
</Card>
);
// Step 3: Save & Test
case 3:
return (
<Card
title={<span className={styles.cardTitle}></span>}
extra={<CheckCircleOutlined />}
>
<Alert
type="info"
message="配置预览"
description={
<Descriptions column={1} size="small" style={{ marginTop: 8 }}>
<Descriptions.Item label="服务商">
<Tag color="blue">
{PROVIDER_OPTIONS.find((o) => o.value === currentProvider)?.label ??
currentProvider ??
'-'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Base URL">
<Typography.Text code>
{formValues.baseUrl || '-'}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="默认模型">
<Tag>{formValues.defaultModel || '未设置'}</Tag>
</Descriptions.Item>
<Descriptions.Item label="密钥">
{(() => {
const hasFormKey = formValues.apiKey && formValues.apiKey !== '••••';
if (config?.hasApiKey) {
return <Tag color="green">{config.maskedApiKey || '••••'}</Tag>;
}
if (hasFormKey) {
return <Tag color="blue"></Tag>;
}
return <Tag color="red"></Tag>;
})()}
</Descriptions.Item>
<Descriptions.Item label="超时">
{formValues.timeoutMs}ms
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={config?.enabled ? 'green' : 'default'}>
{config?.enabled ? '已启用' : '未启用'}
</Tag>
</Descriptions.Item>
</Descriptions>
}
style={{ marginBottom: 16 }}
/>
<Space>
{canWrite && (
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving} size="large">
</Button>
)}
{canTest && (
<Button icon={<ApiOutlined />} onClick={handleTest} loading={testing} size="large">
</Button>
)}
</Space>
{/* Test result */}
{testResult && (
<Card size="small" className={styles.testResult}>
<Descriptions column={{ xs: 1, sm: 2 }} size="small">
<Descriptions.Item label="结果">
{testResult.success ? (
testResult.modelAvailable ? (
<Tag icon={<CheckCircleOutlined />} color="success">
</Tag>
) : (
<Tag icon={<WarningOutlined />} color="warning">
</Tag>
)
) : (
<Tag icon={<CloseCircleOutlined />} color="error">
</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="延迟">
{testResult.latencyMs != null ? `${testResult.latencyMs} ms` : '-'}
</Descriptions.Item>
<Descriptions.Item label="模型数量">
{testResult.modelCount != null ? testResult.modelCount : '-'}
</Descriptions.Item>
<Descriptions.Item label="测试时间">
{formatDateTime(testResult.testedAt)}
</Descriptions.Item>
</Descriptions>
<Alert
type={
testResult.success
? testResult.modelAvailable
? 'success'
: 'warning'
: 'error'
}
title={testResult.message}
style={{ marginTop: 8 }}
/>
</Card>
)}
</Card>
);
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;