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

502 lines
16 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,
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<T> {
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<AiConfigData | null>(null);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [error, setError] = useState<string | null>(null);
const lastProviderRef = useRef<AiProvider | null>(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<ApiResponse<AiConfigData>>('/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<string, unknown> = {
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<string, unknown> = {
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<TestResult>('/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 (
<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 ──
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>
<Form form={form} layout="vertical" initialValues={{ timeoutMs: 30000, enabled: false }}>
<div className={styles.grid}>
{/* Left: 模型路由 */}
<Card title={<span className={styles.cardTitle}></span>} extra={<ApiOutlined />}>
<Form.Item
name="provider"
label="Provider"
rules={[{ required: true, message: '请选择 Provider' }]}
>
<Select
options={PROVIDER_OPTIONS}
onChange={handleProviderChange}
disabled={!canWrite}
/>
</Form.Item>
<Form.Item
name="baseUrl"
label="Base URL"
rules={[
{ required: true, message: '请输入 Base URL' },
{ type: 'url', message: '请输入合法的 URL' },
]}
>
<Input
placeholder={
config?.provider
? PROVIDER_DEFAULTS[config.provider]
: 'https://api.openai.com/v1'
}
disabled={!canWrite || (isFixedProvider && canWrite)}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, curr) => prev.enabled !== curr.enabled}>
{({ getFieldValue }) => {
const enabled = getFieldValue('enabled');
return (
<Form.Item
name="defaultModel"
label="默认模型"
rules={enabled ? [{ required: true, message: '启用时默认模型为必填项' }] : []}
>
<Input placeholder="例如: gpt-4, deepseek-chat" disabled={!canWrite} />
</Form.Item>
);
}}
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch disabled={!canWrite} />
</Form.Item>
<Form.Item
name="timeoutMs"
label="请求超时 (毫秒)"
rules={[
{ required: true, message: '请输入超时时间' },
{ type: 'number', min: 1000, max: 120000, message: '范围: 1000-120000' },
]}
>
<InputNumber
min={1000}
max={120000}
step={1000}
style={{ width: '100%' }}
disabled={!canWrite}
/>
</Form.Item>
</Card>
{/* Right: 密钥保险库 */}
<Card
title={<span className={styles.cardTitle}></span>}
extra={<KeyOutlined />}
>
<Form.Item name="apiKey" label="API Key">
<Input.Password
placeholder={config?.hasApiKey ? '已安全保存,留空则保持不变' : '请输入 API Key'}
disabled={!canWrite}
autoComplete="new-password"
/>
</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>
</div>
{/* Actions */}
<div className={styles.actions}>
{canWrite ? (
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>
</Button>
) : null}
{canTest ? (
<Button icon={<ApiOutlined />} onClick={handleTest} loading={testing}>
</Button>
) : null}
</div>
</Form>
{/* 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>
)}
</div>
);
};
export default AiConfigPage;