502 lines
16 KiB
TypeScript
502 lines
16 KiB
TypeScript
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;
|