forked from wangziqi/gongxue-base
feat: add CASL authorization and AI configuration
This commit is contained in:
506
apps/admin/src/pages/AiConfig/index.tsx
Normal file
506
apps/admin/src/pages/AiConfig/index.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
App,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Select,
|
||||
Switch,
|
||||
InputNumber,
|
||||
Tag,
|
||||
Descriptions,
|
||||
Spin,
|
||||
Alert,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Space,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined,
|
||||
ApiOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
KeyOutlined,
|
||||
DeleteOutlined,
|
||||
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', '');
|
||||
await loadConfig();
|
||||
} catch (err: unknown) {
|
||||
message.error(extractErrorMessage(err, '保存失败'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, loadConfig]);
|
||||
|
||||
// ── Test connection ──
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
try {
|
||||
// Validated fields: compatible requires baseUrl
|
||||
const fieldsToValidate = ['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" icon={<DeleteOutlined />} 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}>
|
||||
<Tooltip title={!canWrite ? '当前角色无写入权限' : undefined}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!canWrite}
|
||||
>
|
||||
保存配置
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={!canTest ? '当前角色无测试权限' : undefined}>
|
||||
<Button
|
||||
icon={<ApiOutlined />}
|
||||
onClick={handleTest}
|
||||
loading={testing}
|
||||
disabled={!canTest}
|
||||
>
|
||||
测试连接
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</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;
|
||||
Reference in New Issue
Block a user