446 lines
13 KiB
TypeScript
446 lines
13 KiB
TypeScript
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;
|