Refactor AI config to step-based wizard with DeepSeek defaults
Some checks failed
CI / check (pull_request) Failing after 1m38s
Some checks failed
CI / check (pull_request) Failing after 1m38s
This commit is contained in:
@@ -5,8 +5,8 @@
|
|||||||
export type AiProvider = 'OPENAI' | 'DEEPSEEK' | 'OPENAI_COMPATIBLE';
|
export type AiProvider = 'OPENAI' | 'DEEPSEEK' | 'OPENAI_COMPATIBLE';
|
||||||
|
|
||||||
export const PROVIDER_OPTIONS: { value: AiProvider; label: string }[] = [
|
export const PROVIDER_OPTIONS: { value: AiProvider; label: string }[] = [
|
||||||
{ value: 'OPENAI', label: 'OpenAI' },
|
|
||||||
{ value: 'DEEPSEEK', label: 'DeepSeek' },
|
{ value: 'DEEPSEEK', label: 'DeepSeek' },
|
||||||
|
{ value: 'OPENAI', label: 'OpenAI' },
|
||||||
{ value: 'OPENAI_COMPATIBLE', label: 'OpenAI 兼容' },
|
{ value: 'OPENAI_COMPATIBLE', label: 'OpenAI 兼容' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header h2 {
|
.header h2 {
|
||||||
@@ -24,17 +24,13 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.steps {
|
||||||
display: grid;
|
margin-bottom: 24px;
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
.stepContent {
|
||||||
.grid {
|
min-height: 320px;
|
||||||
grid-template-columns: 1fr;
|
margin-bottom: 16px;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardTitle {
|
.cardTitle {
|
||||||
@@ -42,10 +38,17 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.stepNav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
flex-wrap: wrap;
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelFetchRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.testResult {
|
.testResult {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Button,
|
Button,
|
||||||
Select,
|
Select,
|
||||||
Switch,
|
AutoComplete,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Tag,
|
Tag,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Typography,
|
Typography,
|
||||||
Space,
|
Space,
|
||||||
|
Steps,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
@@ -22,6 +23,10 @@ import {
|
|||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
KeyOutlined,
|
KeyOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
SafetyOutlined,
|
||||||
|
RobotOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
@@ -70,12 +75,49 @@ interface TestResult {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FetchModelsResult {
|
||||||
|
success: boolean;
|
||||||
|
models: Array<{ id: string }>;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: T;
|
data: T;
|
||||||
message?: string;
|
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
|
// Page Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -85,20 +127,32 @@ const AiConfigPage: React.FC = () => {
|
|||||||
const { modal } = App.useApp();
|
const { modal } = App.useApp();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [fetchingModels, setFetchingModels] = useState(false);
|
||||||
const [config, setConfig] = useState<AiConfigData | null>(null);
|
const [config, setConfig] = useState<AiConfigData | null>(null);
|
||||||
const [testResult, setTestResult] = useState<TestResult | 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);
|
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 lastProviderRef = useRef<AiProvider | null>(null);
|
||||||
|
|
||||||
const canWrite = hasPermission('ai:config:write');
|
const canWrite = hasPermission('ai:config:write');
|
||||||
const canTest = hasPermission('ai:config:test');
|
const canTest = hasPermission('ai:config:test');
|
||||||
const canRead = hasPermission('ai:config:read');
|
const canRead = hasPermission('ai:config:read');
|
||||||
|
|
||||||
// ── Load config ──
|
// ── 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 () => {
|
const loadConfig = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -106,14 +160,21 @@ const AiConfigPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const res = await api.get<ApiResponse<AiConfigData>>('/ai/config');
|
const res = await api.get<ApiResponse<AiConfigData>>('/ai/config');
|
||||||
setConfig(res.data);
|
setConfig(res.data);
|
||||||
form.setFieldsValue({
|
|
||||||
|
const initial: FormValues = {
|
||||||
provider: res.data.provider,
|
provider: res.data.provider,
|
||||||
baseUrl: res.data.baseUrl,
|
baseUrl: res.data.baseUrl,
|
||||||
defaultModel: res.data.defaultModel ?? undefined,
|
apiKey: '',
|
||||||
enabled: res.data.enabled,
|
defaultModel: res.data.defaultModel ?? '',
|
||||||
timeoutMs: res.data.timeoutMs,
|
timeoutMs: res.data.timeoutMs,
|
||||||
});
|
};
|
||||||
|
form.setFieldsValue(initial);
|
||||||
|
setFormValues(initial);
|
||||||
lastProviderRef.current = res.data.provider;
|
lastProviderRef.current = res.data.provider;
|
||||||
|
|
||||||
|
if (res.data.defaultModel) {
|
||||||
|
setModelOptions([{ value: res.data.defaultModel, label: res.data.defaultModel }]);
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(extractErrorMessage(err, '加载配置失败'));
|
setError(extractErrorMessage(err, '加载配置失败'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -121,6 +182,18 @@ const AiConfigPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [form]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
loadConfig();
|
loadConfig();
|
||||||
}, [loadConfig]);
|
}, [loadConfig]);
|
||||||
@@ -129,55 +202,85 @@ const AiConfigPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleProviderChange = useCallback(
|
const handleProviderChange = useCallback(
|
||||||
(provider: AiProvider) => {
|
(provider: AiProvider) => {
|
||||||
const currentBaseUrl = form.getFieldValue('baseUrl') || '';
|
const result = shouldAutoSwapBaseUrl(provider, formValues.baseUrl, lastProviderRef.current);
|
||||||
const result = shouldAutoSwapBaseUrl(provider, currentBaseUrl, lastProviderRef.current);
|
|
||||||
if (result.shouldSwap) {
|
if (result.shouldSwap) {
|
||||||
form.setFieldValue('baseUrl', result.baseUrl);
|
form.setFieldValue('baseUrl', result.baseUrl);
|
||||||
}
|
}
|
||||||
lastProviderRef.current = provider;
|
lastProviderRef.current = provider;
|
||||||
},
|
},
|
||||||
[form],
|
[form, formValues.baseUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentProvider = Form.useWatch('provider', form) as AiProvider | undefined;
|
const currentProvider = formValues.provider;
|
||||||
const isFixedProvider = currentProvider ? FIXED_PROVIDERS.includes(currentProvider) : false;
|
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 ──
|
// ── Save ──
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
// Validate fields (for UI error display) — actual values come from state
|
||||||
setSaving(true);
|
await form.validateFields(['provider', 'baseUrl', 'timeoutMs']);
|
||||||
|
|
||||||
// Validate baseUrl for OPENAI_COMPATIBLE
|
const { provider, baseUrl, defaultModel, apiKey, timeoutMs } = formValues;
|
||||||
if (values.provider === 'OPENAI_COMPATIBLE' && !values.baseUrl) {
|
|
||||||
|
if (provider === 'OPENAI_COMPATIBLE' && !baseUrl) {
|
||||||
message.error('OPENAI_COMPATIBLE 模式必须填写 Base URL');
|
message.error('OPENAI_COMPATIBLE 模式必须填写 Base URL');
|
||||||
setSaving(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
const resolvedBaseUrl = baseUrl || PROVIDER_DEFAULTS[provider] || '';
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
provider: values.provider,
|
provider,
|
||||||
baseUrl: values.baseUrl,
|
baseUrl: resolvedBaseUrl,
|
||||||
defaultModel: values.defaultModel || undefined,
|
defaultModel: defaultModel || undefined,
|
||||||
enabled: values.enabled,
|
enabled: true,
|
||||||
timeoutMs: values.timeoutMs,
|
timeoutMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (values.apiKey && values.apiKey !== '••••') {
|
if (apiKey && apiKey !== '••••') {
|
||||||
body.apiKey = values.apiKey;
|
body.apiKey = apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.put('/ai/config', body);
|
await api.put('/ai/config', body);
|
||||||
message.success('配置已保存');
|
message.success('配置已保存');
|
||||||
form.setFieldValue('apiKey', '');
|
form.setFieldValue('apiKey', '');
|
||||||
|
setFormValues((prev) => ({ ...prev, apiKey: '' }));
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
message.error(extractErrorMessage(err, '保存失败'));
|
message.error(extractErrorMessage(err, '保存失败'));
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload config from server (non-fatal if it fails)
|
|
||||||
try {
|
try {
|
||||||
await loadConfig();
|
await loadConfig();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -185,42 +288,30 @@ const AiConfigPage: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [form, loadConfig]);
|
}, [formValues, form, loadConfig]);
|
||||||
|
|
||||||
// ── Test connection ──
|
// ── Test connection ──
|
||||||
|
|
||||||
const handleTest = useCallback(async () => {
|
const handleTest = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
// Validated fields: compatible requires baseUrl
|
|
||||||
const fieldsToValidate = ['provider', 'timeoutMs'] as string[];
|
const fieldsToValidate = ['provider', 'timeoutMs'] as string[];
|
||||||
if (currentProvider === 'OPENAI_COMPATIBLE') {
|
if (currentProvider === 'OPENAI_COMPATIBLE') {
|
||||||
fieldsToValidate.push('baseUrl');
|
fieldsToValidate.push('baseUrl');
|
||||||
}
|
}
|
||||||
const values = await form.validateFields(fieldsToValidate);
|
await form.validateFields(fieldsToValidate);
|
||||||
|
|
||||||
setTesting(true);
|
setTesting(true);
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
const { provider, baseUrl, defaultModel, apiKey, timeoutMs } = formValues;
|
||||||
timeoutMs: values.timeoutMs,
|
const body: Record<string, unknown> = { provider, timeoutMs };
|
||||||
};
|
if (baseUrl) body.baseUrl = baseUrl;
|
||||||
|
|
||||||
// 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;
|
if (defaultModel) body.defaultModel = defaultModel;
|
||||||
|
if (apiKey && apiKey !== '••••') body.apiKey = apiKey;
|
||||||
const typedKey = form.getFieldValue('apiKey');
|
|
||||||
if (typedKey && typedKey !== '••••') {
|
|
||||||
body.apiKey = typedKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await api.post<TestResult>('/ai/config/test', body);
|
const res = await api.post<TestResult>('/ai/config/test', body);
|
||||||
setTestResult(res);
|
setTestResult(res);
|
||||||
await loadConfig();
|
await refreshConfig();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setTestResult({
|
setTestResult({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -233,7 +324,7 @@ const AiConfigPage: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setTesting(false);
|
setTesting(false);
|
||||||
}
|
}
|
||||||
}, [form, loadConfig, currentProvider]);
|
}, [formValues, form, loadConfig, currentProvider]);
|
||||||
|
|
||||||
// ── Clear key ──
|
// ── Clear key ──
|
||||||
|
|
||||||
@@ -259,6 +350,28 @@ const AiConfigPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}, [config, loadConfig, modal]);
|
}, [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 ──
|
// ── No read permission ──
|
||||||
|
|
||||||
if (!canRead) {
|
if (!canRead) {
|
||||||
@@ -285,41 +398,28 @@ const AiConfigPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render ──
|
// ── Render step content ──
|
||||||
|
|
||||||
|
const renderStepContent = () => {
|
||||||
|
switch (currentStep) {
|
||||||
|
// Step 0: Provider + Base URL + Timeout
|
||||||
|
case 0:
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<Card
|
||||||
<div className={styles.header}>
|
title={<span className={styles.cardTitle}>服务商配置</span>}
|
||||||
<h2>AI 模型配置</h2>
|
extra={<CloudServerOutlined />}
|
||||||
<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
|
<Form.Item
|
||||||
name="provider"
|
name="provider"
|
||||||
label="Provider"
|
label="Provider"
|
||||||
rules={[{ required: true, message: '请选择 Provider' }]}
|
rules={[{ required: true, message: '请选择 Provider' }]}
|
||||||
|
preserve
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
options={PROVIDER_OPTIONS}
|
options={PROVIDER_OPTIONS}
|
||||||
onChange={handleProviderChange}
|
onChange={handleProviderChange}
|
||||||
disabled={!canWrite}
|
disabled={!canWrite}
|
||||||
|
size="large"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
@@ -330,36 +430,19 @@ const AiConfigPage: React.FC = () => {
|
|||||||
{ required: true, message: '请输入 Base URL' },
|
{ required: true, message: '请输入 Base URL' },
|
||||||
{ type: 'url', message: '请输入合法的 URL' },
|
{ type: 'url', message: '请输入合法的 URL' },
|
||||||
]}
|
]}
|
||||||
|
preserve
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
placeholder={
|
placeholder={
|
||||||
config?.provider
|
config?.provider
|
||||||
? PROVIDER_DEFAULTS[config.provider]
|
? PROVIDER_DEFAULTS[config.provider]
|
||||||
: 'https://api.openai.com/v1'
|
: 'https://api.deepseek.com'
|
||||||
}
|
}
|
||||||
disabled={!canWrite || (isFixedProvider && canWrite)}
|
disabled={!canWrite || (isFixedProvider && canWrite)}
|
||||||
|
size="large"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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
|
<Form.Item
|
||||||
name="timeoutMs"
|
name="timeoutMs"
|
||||||
label="请求超时 (毫秒)"
|
label="请求超时 (毫秒)"
|
||||||
@@ -367,6 +450,7 @@ const AiConfigPage: React.FC = () => {
|
|||||||
{ required: true, message: '请输入超时时间' },
|
{ required: true, message: '请输入超时时间' },
|
||||||
{ type: 'number', min: 1000, max: 120000, message: '范围: 1000-120000' },
|
{ type: 'number', min: 1000, max: 120000, message: '范围: 1000-120000' },
|
||||||
]}
|
]}
|
||||||
|
preserve
|
||||||
>
|
>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={1000}
|
min={1000}
|
||||||
@@ -374,20 +458,25 @@ const AiConfigPage: React.FC = () => {
|
|||||||
step={1000}
|
step={1000}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
disabled={!canWrite}
|
disabled={!canWrite}
|
||||||
|
size="large"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Card>
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
{/* Right: 密钥保险库 */}
|
// Step 1: API Key
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
<Card
|
<Card
|
||||||
title={<span className={styles.cardTitle}>密钥保险库</span>}
|
title={<span className={styles.cardTitle}>密钥配置</span>}
|
||||||
extra={<KeyOutlined />}
|
extra={<SafetyOutlined />}
|
||||||
>
|
>
|
||||||
<Form.Item name="apiKey" label="API Key">
|
<Form.Item name="apiKey" label="API Key" preserve>
|
||||||
<Input.Password
|
<Input.Password
|
||||||
placeholder={config?.hasApiKey ? '已安全保存,留空则保持不变' : '请输入 API Key'}
|
placeholder={config?.hasApiKey ? '已安全保存,留空则保持不变' : '请输入 API Key'}
|
||||||
disabled={!canWrite}
|
disabled={!canWrite}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
size="large"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
@@ -429,7 +518,7 @@ const AiConfigPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.safetyNote}>
|
<div className={styles.safetyNote}>
|
||||||
API Key 使用 AES-256-GCM 加密存储,每次保存使用随机 IV。 传输层通过 HTTPS
|
API Key 使用 AES-256-GCM 加密存储,每次保存使用随机 IV。传输层通过 HTTPS
|
||||||
保护,服务端日志不记录密钥。
|
保护,服务端日志不记录密钥。
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.safetyNoteKey}>
|
<div className={styles.safetyNoteKey}>
|
||||||
@@ -437,22 +526,126 @@ const AiConfigPage: React.FC = () => {
|
|||||||
环境变量优先级高于数据库存储。
|
环境变量优先级高于数据库存储。
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
<Form.Item
|
||||||
<div className={styles.actions}>
|
name="defaultModel"
|
||||||
{canWrite ? (
|
label="默认模型"
|
||||||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>
|
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>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
{canTest ? (
|
{canTest && (
|
||||||
<Button icon={<ApiOutlined />} onClick={handleTest} loading={testing}>
|
<Button icon={<ApiOutlined />} onClick={handleTest} loading={testing} size="large">
|
||||||
测试连接
|
测试连接
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</Space>
|
||||||
</Form>
|
|
||||||
|
|
||||||
{/* Test result */}
|
{/* Test result */}
|
||||||
{testResult && (
|
{testResult && (
|
||||||
@@ -487,13 +680,74 @@ const AiConfigPage: React.FC = () => {
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Alert
|
<Alert
|
||||||
type={
|
type={
|
||||||
testResult.success ? (testResult.modelAvailable ? 'success' : 'warning') : 'error'
|
testResult.success
|
||||||
|
? testResult.modelAvailable
|
||||||
|
? 'success'
|
||||||
|
: 'warning'
|
||||||
|
: 'error'
|
||||||
}
|
}
|
||||||
title={testResult.message}
|
title={testResult.message}
|
||||||
style={{ marginTop: 8 }}
|
style={{ marginTop: 8 }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ interface StreamChoiceDelta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MAX_UPSTREAM_EVENT_BYTES = 1024 * 1024;
|
const MAX_UPSTREAM_EVENT_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
|
// Known public provider hosts — trusted even if CDN resolves to private-range IPs
|
||||||
|
const DNS_TRUSTED_HOSTS = new Set(['api.openai.com', 'api.deepseek.com']);
|
||||||
|
|
||||||
const PRIVATE_IPV4_RANGES = [
|
const PRIVATE_IPV4_RANGES = [
|
||||||
/^127\./,
|
/^127\./,
|
||||||
/^10\./,
|
/^10\./,
|
||||||
@@ -180,7 +184,9 @@ export class AiModelStreamService {
|
|||||||
const port = parsed.port ? Number(parsed.port) : isHttps ? 443 : 80;
|
const port = parsed.port ? Number(parsed.port) : isHttps ? 443 : 80;
|
||||||
lookup(parsed.hostname, { all: true, family: 0 }, (dnsError, addresses) => {
|
lookup(parsed.hostname, { all: true, family: 0 }, (dnsError, addresses) => {
|
||||||
if (dnsError || !addresses?.length) return reject(new Error('DNS 解析失败'));
|
if (dnsError || !addresses?.length) return reject(new Error('DNS 解析失败'));
|
||||||
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
|
const allowPrivate =
|
||||||
|
process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true' ||
|
||||||
|
DNS_TRUSTED_HOSTS.has(parsed.hostname);
|
||||||
if (!allowPrivate && addresses.some(({ address }) => this.isPrivateAddress(address))) {
|
if (!allowPrivate && addresses.some(({ address }) => this.isPrivateAddress(address))) {
|
||||||
return reject(new Error('域名解析到内网地址'));
|
return reject(new Error('域名解析到内网地址'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { RequirePermission } from '../auth/decorators/permission.decorator';
|
|||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { extractRequestInfo } from '../common/request-utils';
|
||||||
import { AiConfigService } from './ai-config.service';
|
import { AiConfigService } from './ai-config.service';
|
||||||
import { SaveAiConfigDto, TestAiConfigDto } from './dto/ai-config.dto';
|
import { SaveAiConfigDto, TestAiConfigDto, FetchModelsDto } from './dto/ai-config.dto';
|
||||||
|
|
||||||
interface AuthenticatedRequest {
|
interface AuthenticatedRequest {
|
||||||
user?: { id: number; username: string };
|
user?: { id: number; username: string };
|
||||||
@@ -47,7 +47,7 @@ export class AiConfigController {
|
|||||||
action: 'save',
|
action: 'save',
|
||||||
targetId: config.id,
|
targetId: config.id,
|
||||||
targetType: 'AiConfig',
|
targetType: 'AiConfig',
|
||||||
detail: `provider=${body.provider} host=${new URL(config.baseUrl).hostname} model=${body.defaultModel ? 'configured' : 'not-set'} enabled=${body.enabled ?? config.enabled}`,
|
detail: `provider=${body.provider} host=${new URL(config.baseUrl).hostname} model=${body.defaultModel ? 'configured' : 'not-set'}`,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
});
|
});
|
||||||
@@ -73,6 +73,13 @@ export class AiConfigController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('models')
|
||||||
|
@RequirePermission('ai:config:read')
|
||||||
|
async fetchModels(@Body() body: FetchModelsDto) {
|
||||||
|
const result = await this.service.fetchModels(body);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@Post('clear-key')
|
@Post('clear-key')
|
||||||
@RequirePermission('ai:config:write')
|
@RequirePermission('ai:config:write')
|
||||||
async clearKey(@Req() req: AuthenticatedRequest) {
|
async clearKey(@Req() req: AuthenticatedRequest) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class AiConfig {
|
|||||||
@Column({ name: 'singleton_key', type: 'varchar', length: 20, default: SINGLETON_KEY })
|
@Column({ name: 'singleton_key', type: 'varchar', length: 20, default: SINGLETON_KEY })
|
||||||
singletonKey: string;
|
singletonKey: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 50, default: AiProvider.OPENAI })
|
@Column({ type: 'varchar', length: 50, default: AiProvider.DEEPSEEK })
|
||||||
provider: AiProvider;
|
provider: AiProvider;
|
||||||
|
|
||||||
@Column({ name: 'base_url', type: 'varchar', length: 500, nullable: true })
|
@Column({ name: 'base_url', type: 'varchar', length: 500, nullable: true })
|
||||||
@@ -45,7 +45,7 @@ export class AiConfig {
|
|||||||
@Column({ name: 'default_model', type: 'varchar', length: 100, nullable: true })
|
@Column({ name: 'default_model', type: 'varchar', length: 100, nullable: true })
|
||||||
defaultModel: string | null;
|
defaultModel: string | null;
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: false })
|
@Column({ type: 'boolean', default: true })
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
||||||
@Column({ name: 'timeout_ms', type: 'int', default: 30000 })
|
@Column({ name: 'timeout_ms', type: 'int', default: 30000 })
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity';
|
|||||||
import {
|
import {
|
||||||
SaveAiConfigDto,
|
SaveAiConfigDto,
|
||||||
TestAiConfigDto,
|
TestAiConfigDto,
|
||||||
|
FetchModelsDto,
|
||||||
|
FetchModelsResultDto,
|
||||||
AiConfigResponseDto,
|
AiConfigResponseDto,
|
||||||
AiConfigTestResultDto,
|
AiConfigTestResultDto,
|
||||||
AiRuntimeConfig,
|
AiRuntimeConfig,
|
||||||
@@ -163,6 +165,13 @@ const PROVIDER_REQUIRED_PATHS: Partial<Record<AiProvider, string>> = {
|
|||||||
[AiProvider.DEEPSEEK]: '/',
|
[AiProvider.DEEPSEEK]: '/',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Known public provider hosts — always skip DNS private-IP check.
|
||||||
|
// Their CDN/proxy nodes may resolve to private-range IPs in certain regions.
|
||||||
|
const DNS_TRUSTED_HOSTS = new Set([
|
||||||
|
'api.openai.com',
|
||||||
|
'api.deepseek.com',
|
||||||
|
]);
|
||||||
|
|
||||||
function validateAndNormalizeBaseUrl(url: string | undefined, provider: AiProvider): string {
|
function validateAndNormalizeBaseUrl(url: string | undefined, provider: AiProvider): string {
|
||||||
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
|
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
|
||||||
|
|
||||||
@@ -243,6 +252,9 @@ async function resolveHostnames(hostname: string): Promise<{ address: string; fa
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function validateDnsNotPrivate(hostname: string): Promise<void> {
|
async function validateDnsNotPrivate(hostname: string): Promise<void> {
|
||||||
|
// Trusted public provider hosts — skip DNS check (CDN nodes may resolve to private IPs)
|
||||||
|
if (DNS_TRUSTED_HOSTS.has(hostname)) return;
|
||||||
|
|
||||||
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
|
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
|
||||||
if (allowPrivate) return;
|
if (allowPrivate) return;
|
||||||
|
|
||||||
@@ -400,9 +412,9 @@ export class AiConfigService {
|
|||||||
if (!config) {
|
if (!config) {
|
||||||
config = this.repo.create({
|
config = this.repo.create({
|
||||||
singletonKey: SINGLETON_KEY,
|
singletonKey: SINGLETON_KEY,
|
||||||
provider: AiProvider.OPENAI,
|
provider: AiProvider.DEEPSEEK,
|
||||||
baseUrl: DEFAULT_BASE_URLS[AiProvider.OPENAI],
|
baseUrl: DEFAULT_BASE_URLS[AiProvider.DEEPSEEK],
|
||||||
enabled: false,
|
enabled: true,
|
||||||
timeoutMs: 30000,
|
timeoutMs: 30000,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
@@ -420,7 +432,22 @@ export class AiConfigService {
|
|||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrate old defaults: if provider is still OPENAI (old default) and config was never
|
||||||
|
// explicitly configured (no API key, never verified), switch to DeepSeek silently.
|
||||||
|
if (
|
||||||
|
config.provider === AiProvider.OPENAI &&
|
||||||
|
config.baseUrl === DEFAULT_BASE_URLS[AiProvider.OPENAI] &&
|
||||||
|
!config.encryptedApiKey &&
|
||||||
|
!config.verified
|
||||||
|
) {
|
||||||
|
config.provider = AiProvider.DEEPSEEK;
|
||||||
|
config.baseUrl = DEFAULT_BASE_URLS[AiProvider.DEEPSEEK];
|
||||||
|
await this.repo.save(config);
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,21 +519,11 @@ export class AiConfigService {
|
|||||||
config.keyLast4 = dto.apiKey.slice(-4);
|
config.keyLast4 = dto.apiKey.slice(-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// enabled validation
|
// AI is always enabled by default — the enable switch has been removed
|
||||||
if (dto.enabled !== undefined) {
|
if (dto.enabled !== undefined) {
|
||||||
if (dto.enabled) {
|
|
||||||
const { plaintext } = this.resolveApiKey(config);
|
|
||||||
if (!plaintext) {
|
|
||||||
throw new BadRequestException('未配置 API Key,无法启用。请先保存 API Key 再启用');
|
|
||||||
}
|
|
||||||
// defaultModel is required when enabled
|
|
||||||
const effectiveDefaultModel =
|
|
||||||
dto.defaultModel !== undefined ? dto.defaultModel : config.defaultModel;
|
|
||||||
if (!effectiveDefaultModel) {
|
|
||||||
throw new BadRequestException('启用 AI 服务时必须配置默认模型');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
config.enabled = dto.enabled;
|
config.enabled = dto.enabled;
|
||||||
|
} else {
|
||||||
|
config.enabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.repo.save(config);
|
return this.repo.save(config);
|
||||||
@@ -709,6 +726,75 @@ export class AiConfigService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch available model list from the configured provider */
|
||||||
|
async fetchModels(dto?: FetchModelsDto): Promise<FetchModelsResultDto> {
|
||||||
|
const config = await this.getOrCreateConfig();
|
||||||
|
|
||||||
|
const provider = dto?.provider ?? config.provider;
|
||||||
|
const rawBaseUrl = dto?.baseUrl ?? config.baseUrl;
|
||||||
|
let baseUrl: string;
|
||||||
|
try {
|
||||||
|
baseUrl = validateAndNormalizeBaseUrl(rawBaseUrl, provider);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
|
||||||
|
return { success: false, models: [], message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS SSRF check
|
||||||
|
try {
|
||||||
|
await validateDnsNotPrivate(new URL(baseUrl).hostname);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
|
||||||
|
return { success: false, models: [], message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine API key
|
||||||
|
let apiKey: string;
|
||||||
|
if (dto?.apiKey) {
|
||||||
|
apiKey = dto.apiKey;
|
||||||
|
} else {
|
||||||
|
const { plaintext } = this.resolveApiKey(config);
|
||||||
|
if (!plaintext) {
|
||||||
|
return { success: false, models: [], message: '未配置 API Key' };
|
||||||
|
}
|
||||||
|
apiKey = plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = dto?.timeoutMs ?? config.timeoutMs;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { status, contentType, body } = await pinnedGet(
|
||||||
|
`${baseUrl}/models`,
|
||||||
|
{ Authorization: `Bearer ${apiKey}` },
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
return { success: false, models: [], message: '认证失败,请检查 API Key' };
|
||||||
|
}
|
||||||
|
if (status >= 500) {
|
||||||
|
return { success: false, models: [], message: '服务不可用' };
|
||||||
|
}
|
||||||
|
if (status >= 400) {
|
||||||
|
return { success: false, models: [], message: `服务返回错误状态 ${status}` };
|
||||||
|
}
|
||||||
|
if (!contentType || !contentType.includes('application/json')) {
|
||||||
|
return { success: false, models: [], message: '响应格式无效' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(body);
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return { success: false, models: [], message: '响应格式无效' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed as { data?: Array<{ id: string }> };
|
||||||
|
const models = Array.isArray(data?.data) ? data.data : [];
|
||||||
|
return { success: true, models };
|
||||||
|
} catch {
|
||||||
|
return { success: false, models: [], message: '获取模型列表失败,请检查配置' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-only runtime config — for future AI adapters.
|
* Server-only runtime config — for future AI adapters.
|
||||||
* Re-validates the stored base URL and DNS at runtime to guard
|
* Re-validates the stored base URL and DNS at runtime to guard
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export class SaveAiConfigDto {
|
|||||||
@IsIn(PROVIDERS)
|
@IsIn(PROVIDERS)
|
||||||
provider!: AiProvider;
|
provider!: AiProvider;
|
||||||
|
|
||||||
@ValidateIf((o: SaveAiConfigDto) => o.provider === AiProvider.OPENAI_COMPATIBLE || o.baseUrl !== undefined)
|
@ValidateIf((o: SaveAiConfigDto) => o.provider === AiProvider.OPENAI_COMPATIBLE || (o.baseUrl !== undefined && o.baseUrl !== ''))
|
||||||
@IsNotEmpty({ message: 'OPENAI_COMPATIBLE 模式必须提供 baseUrl' })
|
@IsNotEmpty({ message: 'Base URL 不能为空' })
|
||||||
@IsString()
|
@IsString()
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
|
||||||
@@ -113,4 +113,32 @@ export interface AiRuntimeConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** DTO for POST /api/ai/config/models — fetch available model list from provider */
|
||||||
|
export class FetchModelsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(PROVIDERS)
|
||||||
|
provider?: AiProvider;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
baseUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
apiKey?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1000)
|
||||||
|
@Max(120000)
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response shape for POST /api/ai/config/models */
|
||||||
|
export interface FetchModelsResultDto {
|
||||||
|
success: boolean;
|
||||||
|
models: Array<{ id: string }>;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export { DEFAULT_BASE_URLS };
|
export { DEFAULT_BASE_URLS };
|
||||||
|
|||||||
Reference in New Issue
Block a user