diff --git a/apps/admin/src/pages/AiConfig/helpers.ts b/apps/admin/src/pages/AiConfig/helpers.ts index 9aafb4b..40bc1b3 100644 --- a/apps/admin/src/pages/AiConfig/helpers.ts +++ b/apps/admin/src/pages/AiConfig/helpers.ts @@ -5,8 +5,8 @@ export type AiProvider = 'OPENAI' | 'DEEPSEEK' | 'OPENAI_COMPATIBLE'; export const PROVIDER_OPTIONS: { value: AiProvider; label: string }[] = [ - { value: 'OPENAI', label: 'OpenAI' }, { value: 'DEEPSEEK', label: 'DeepSeek' }, + { value: 'OPENAI', label: 'OpenAI' }, { value: 'OPENAI_COMPATIBLE', label: 'OpenAI 兼容' }, ]; diff --git a/apps/admin/src/pages/AiConfig/index.module.css b/apps/admin/src/pages/AiConfig/index.module.css index 8fb4693..a4caec7 100644 --- a/apps/admin/src/pages/AiConfig/index.module.css +++ b/apps/admin/src/pages/AiConfig/index.module.css @@ -1,11 +1,11 @@ .container { - max-width: 1200px; + max-width: 900px; margin: 0 auto; padding: 16px; } .header { - margin-bottom: 16px; + margin-bottom: 20px; } .header h2 { @@ -24,17 +24,13 @@ margin-top: 8px; } -.grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; - margin-bottom: 16px; +.steps { + margin-bottom: 24px; } -@media (max-width: 768px) { - .grid { - grid-template-columns: 1fr; - } +.stepContent { + min-height: 320px; + margin-bottom: 16px; } .cardTitle { @@ -42,10 +38,17 @@ font-weight: 600; } -.actions { +.stepNav { display: flex; - gap: 8px; - flex-wrap: wrap; + justify-content: space-between; + padding-top: 8px; + border-top: 1px solid #f0f0f0; +} + +.modelFetchRow { + display: flex; + align-items: center; + gap: 12px; } .testResult { diff --git a/apps/admin/src/pages/AiConfig/index.tsx b/apps/admin/src/pages/AiConfig/index.tsx index 03bbd53..809b3c1 100644 --- a/apps/admin/src/pages/AiConfig/index.tsx +++ b/apps/admin/src/pages/AiConfig/index.tsx @@ -6,7 +6,7 @@ import { Input, Button, Select, - Switch, + AutoComplete, InputNumber, Tag, Descriptions, @@ -14,6 +14,7 @@ import { Alert, Typography, Space, + Steps, } from 'antd'; import { SaveOutlined, @@ -22,6 +23,10 @@ import { CloseCircleOutlined, KeyOutlined, WarningOutlined, + ReloadOutlined, + CloudServerOutlined, + SafetyOutlined, + RobotOutlined, } from '@ant-design/icons'; import api from '../../api'; import { message } from '../../ui/app-message'; @@ -70,12 +75,49 @@ interface TestResult { message: string; } +interface FetchModelsResult { + success: boolean; + models: Array<{ id: string }>; + message?: string; +} + interface ApiResponse { success: boolean; data: T; 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 // --------------------------------------------------------------------------- @@ -85,20 +127,32 @@ const AiConfigPage: React.FC = () => { const { modal } = App.useApp(); const [form] = Form.useForm(); + const [currentStep, setCurrentStep] = useState(0); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); + const [fetchingModels, setFetchingModels] = useState(false); const [config, setConfig] = useState(null); const [testResult, setTestResult] = useState(null); + const [modelOptions, setModelOptions] = useState>([]); const [error, setError] = useState(null); + // Central form state — survives Step transitions when Form.Items unmount + const [formValues, setFormValues] = useState(DEFAULT_FORM_VALUES); + const lastProviderRef = useRef(null); const canWrite = hasPermission('ai:config:write'); const canTest = hasPermission('ai:config:test'); const canRead = hasPermission('ai:config:read'); - // ── Load config ── + // ── Sync form → state ── + + const handleFormChange = useCallback((_changed: Partial, all: Partial) => { + setFormValues((prev) => ({ ...prev, ...all })); + }, []); + + // ── Load config (full) — used on initial mount and after save ── const loadConfig = useCallback(async () => { setLoading(true); @@ -106,14 +160,21 @@ const AiConfigPage: React.FC = () => { try { const res = await api.get>('/ai/config'); setConfig(res.data); - form.setFieldsValue({ + + const initial: FormValues = { provider: res.data.provider, baseUrl: res.data.baseUrl, - defaultModel: res.data.defaultModel ?? undefined, - enabled: res.data.enabled, + apiKey: '', + defaultModel: res.data.defaultModel ?? '', timeoutMs: res.data.timeoutMs, - }); + }; + form.setFieldsValue(initial); + setFormValues(initial); lastProviderRef.current = res.data.provider; + + if (res.data.defaultModel) { + setModelOptions([{ value: res.data.defaultModel, label: res.data.defaultModel }]); + } } catch (err: unknown) { setError(extractErrorMessage(err, '加载配置失败')); } finally { @@ -121,6 +182,18 @@ const AiConfigPage: React.FC = () => { } }, [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>('/ai/config'); + setConfig(res.data); + } catch { + // silent — config display refresh is non-critical + } + }, []); + useEffect(() => { loadConfig(); }, [loadConfig]); @@ -129,55 +202,85 @@ const AiConfigPage: React.FC = () => { const handleProviderChange = useCallback( (provider: AiProvider) => { - const currentBaseUrl = form.getFieldValue('baseUrl') || ''; - const result = shouldAutoSwapBaseUrl(provider, currentBaseUrl, lastProviderRef.current); + const result = shouldAutoSwapBaseUrl(provider, formValues.baseUrl, lastProviderRef.current); if (result.shouldSwap) { form.setFieldValue('baseUrl', result.baseUrl); } lastProviderRef.current = provider; }, - [form], + [form, formValues.baseUrl], ); - const currentProvider = Form.useWatch('provider', form) as AiProvider | undefined; - const isFixedProvider = currentProvider ? FIXED_PROVIDERS.includes(currentProvider) : false; + 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 = { provider }; + if (baseUrl) body.baseUrl = baseUrl; + if (formKey && formKey !== '••••') body.apiKey = formKey; + + const res = await api.post('/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 ── const handleSave = useCallback(async () => { try { - const values = await form.validateFields(); - setSaving(true); + // Validate fields (for UI error display) — actual values come from state + await form.validateFields(['provider', 'baseUrl', 'timeoutMs']); - // Validate baseUrl for OPENAI_COMPATIBLE - if (values.provider === 'OPENAI_COMPATIBLE' && !values.baseUrl) { + const { provider, baseUrl, defaultModel, apiKey, timeoutMs } = formValues; + + if (provider === 'OPENAI_COMPATIBLE' && !baseUrl) { message.error('OPENAI_COMPATIBLE 模式必须填写 Base URL'); - setSaving(false); return; } + setSaving(true); + + const resolvedBaseUrl = baseUrl || PROVIDER_DEFAULTS[provider] || ''; + const body: Record = { - provider: values.provider, - baseUrl: values.baseUrl, - defaultModel: values.defaultModel || undefined, - enabled: values.enabled, - timeoutMs: values.timeoutMs, + provider, + baseUrl: resolvedBaseUrl, + defaultModel: defaultModel || undefined, + enabled: true, + timeoutMs, }; - if (values.apiKey && values.apiKey !== '••••') { - body.apiKey = values.apiKey; + if (apiKey && apiKey !== '••••') { + body.apiKey = apiKey; } await api.put('/ai/config', body); message.success('配置已保存'); form.setFieldValue('apiKey', ''); + setFormValues((prev) => ({ ...prev, 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) { @@ -185,42 +288,30 @@ const AiConfigPage: React.FC = () => { } finally { setSaving(false); } - }, [form, loadConfig]); + }, [formValues, 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); + await form.validateFields(fieldsToValidate); setTesting(true); setTestResult(null); - const body: Record = { - 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'); + const { provider, baseUrl, defaultModel, apiKey, timeoutMs } = formValues; + const body: Record = { provider, timeoutMs }; + if (baseUrl) body.baseUrl = baseUrl; if (defaultModel) body.defaultModel = defaultModel; - - const typedKey = form.getFieldValue('apiKey'); - if (typedKey && typedKey !== '••••') { - body.apiKey = typedKey; - } + if (apiKey && apiKey !== '••••') body.apiKey = apiKey; const res = await api.post('/ai/config/test', body); setTestResult(res); - await loadConfig(); + await refreshConfig(); } catch (err: unknown) { setTestResult({ success: false, @@ -233,7 +324,7 @@ const AiConfigPage: React.FC = () => { } finally { setTesting(false); } - }, [form, loadConfig, currentProvider]); + }, [formValues, form, loadConfig, currentProvider]); // ── Clear key ── @@ -259,6 +350,28 @@ const AiConfigPage: React.FC = () => { }); }, [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 ── if (!canRead) { @@ -285,41 +398,28 @@ const AiConfigPage: React.FC = () => { ); } - // ── Render ── + // ── Render step content ── - return ( -
-
-

AI 模型配置

-

密钥仅保存在服务器端,浏览器无法读取明文

-
- - - {config?.enabled ? '已启用' : '未启用'} - - {config?.verified && 已验证} - {config?.hasApiKey && ( - - 密钥: {sourceLabel(config?.keySource || 'none')} - - )} - -
-
- -
-
- {/* Left: 模型路由 */} - 模型路由} extra={}> + const renderStepContent = () => { + switch (currentStep) { + // Step 0: Provider + Base URL + Timeout + case 0: + return ( + 服务商配置} + extra={} + > - prev.enabled !== curr.enabled}> - {({ getFieldValue }) => { - const enabled = getFieldValue('enabled'); - return ( - - - - ); - }} - - - - - - { { required: true, message: '请输入超时时间' }, { type: 'number', min: 1000, max: 120000, message: '范围: 1000-120000' }, ]} + preserve > { step={1000} style={{ width: '100%' }} disabled={!canWrite} + size="large" /> + ); - {/* Right: 密钥保险库 */} + // Step 1: API Key + case 1: + return ( 密钥保险库} - extra={} + title={密钥配置} + extra={} > - + @@ -429,7 +518,7 @@ const AiConfigPage: React.FC = () => { )}
- API Key 使用 AES-256-GCM 加密存储,每次保存使用随机 IV。 传输层通过 HTTPS + API Key 使用 AES-256-GCM 加密存储,每次保存使用随机 IV。传输层通过 HTTPS 保护,服务端日志不记录密钥。
@@ -437,63 +526,228 @@ const AiConfigPage: React.FC = () => { 环境变量优先级高于数据库存储。
-
+ ); - {/* Actions */} -
- {canWrite ? ( - - ) : null} - {canTest ? ( - + {modelOptions.length > 0 && ( + {modelOptions.length} 个可用模型 + )} +
+ + + + option?.value?.toLowerCase().includes(inputValue.toLowerCase()) ?? false + } + /> + + + {config?.verified && ( +
+ } color="success"> + 上次验证通过 + + {config.lastTestLatencyMs != null && ( + + 延迟: {config.lastTestLatencyMs}ms + + )} +
+ )} + + ); + + // Step 3: Save & Test + case 3: + return ( + 保存并测试} + extra={} + > + + + + {PROVIDER_OPTIONS.find((o) => o.value === currentProvider)?.label ?? + currentProvider ?? + '-'} + + + + + {formValues.baseUrl || '-'} + + + + {formValues.defaultModel || '未设置'} + + + {(() => { + const hasFormKey = formValues.apiKey && formValues.apiKey !== '••••'; + if (config?.hasApiKey) { + return {config.maskedApiKey || '••••'}; + } + if (hasFormKey) { + return 已填写(未保存); + } + return 未配置; + })()} + + + {formValues.timeoutMs}ms + + + + {config?.enabled ? '已启用' : '未启用'} + + + + } + style={{ marginBottom: 16 }} + /> + + + {canWrite && ( + + )} + {canTest && ( + + )} + + + {/* Test result */} + {testResult && ( + + + + {testResult.success ? ( + testResult.modelAvailable ? ( + } color="success"> + 成功 + + ) : ( + } color="warning"> + 模型未找到 + + ) + ) : ( + } color="error"> + 失败 + + )} + + + {testResult.latencyMs != null ? `${testResult.latencyMs} ms` : '-'} + + + {testResult.modelCount != null ? testResult.modelCount : '-'} + + + {formatDateTime(testResult.testedAt)} + + + + + )} + + ); + + default: + return null; + } + }; + + // ── Render ── + + return ( +
+
+

AI 模型配置

+

密钥仅保存在服务器端,浏览器无法读取明文

+
+ + + {config?.enabled ? '已启用' : '未启用'} + + {config?.verified && 已验证} + {config?.hasApiKey && ( + + 密钥: {sourceLabel(config?.keySource || 'none')} + + )} + +
+
+ + + + +
{renderStepContent()}
+ +
+ + {currentStep < STEP_ITEMS.length - 1 ? ( + ) : null}
- - {/* Test result */} - {testResult && ( - - - - {testResult.success ? ( - testResult.modelAvailable ? ( - } color="success"> - 成功 - - ) : ( - } color="warning"> - 模型未找到 - - ) - ) : ( - } color="error"> - 失败 - - )} - - - {testResult.latencyMs != null ? `${testResult.latencyMs} ms` : '-'} - - - {testResult.modelCount != null ? testResult.modelCount : '-'} - - - {formatDateTime(testResult.testedAt)} - - - - - )}
); }; diff --git a/apps/server/src/ai-chat/ai-model-stream.service.ts b/apps/server/src/ai-chat/ai-model-stream.service.ts index 163640a..4256901 100644 --- a/apps/server/src/ai-chat/ai-model-stream.service.ts +++ b/apps/server/src/ai-chat/ai-model-stream.service.ts @@ -26,6 +26,10 @@ interface StreamChoiceDelta { } 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 = [ /^127\./, /^10\./, @@ -180,7 +184,9 @@ export class AiModelStreamService { const port = parsed.port ? Number(parsed.port) : isHttps ? 443 : 80; lookup(parsed.hostname, { all: true, family: 0 }, (dnsError, addresses) => { 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))) { return reject(new Error('域名解析到内网地址')); } diff --git a/apps/server/src/ai-config/ai-config.controller.ts b/apps/server/src/ai-config/ai-config.controller.ts index 8a38df2..13fa2c9 100644 --- a/apps/server/src/ai-config/ai-config.controller.ts +++ b/apps/server/src/ai-config/ai-config.controller.ts @@ -12,7 +12,7 @@ import { RequirePermission } from '../auth/decorators/permission.decorator'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; 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 { user?: { id: number; username: string }; @@ -47,7 +47,7 @@ export class AiConfigController { action: 'save', targetId: config.id, 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, userAgent, }); @@ -73,6 +73,13 @@ export class AiConfigController { 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') @RequirePermission('ai:config:write') async clearKey(@Req() req: AuthenticatedRequest) { diff --git a/apps/server/src/ai-config/ai-config.entity.ts b/apps/server/src/ai-config/ai-config.entity.ts index c2eb1bc..cec85e1 100644 --- a/apps/server/src/ai-config/ai-config.entity.ts +++ b/apps/server/src/ai-config/ai-config.entity.ts @@ -24,7 +24,7 @@ export class AiConfig { @Column({ name: 'singleton_key', type: 'varchar', length: 20, default: SINGLETON_KEY }) singletonKey: string; - @Column({ type: 'varchar', length: 50, default: AiProvider.OPENAI }) + @Column({ type: 'varchar', length: 50, default: AiProvider.DEEPSEEK }) provider: AiProvider; @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 }) defaultModel: string | null; - @Column({ type: 'boolean', default: false }) + @Column({ type: 'boolean', default: true }) enabled: boolean; @Column({ name: 'timeout_ms', type: 'int', default: 30000 }) diff --git a/apps/server/src/ai-config/ai-config.service.ts b/apps/server/src/ai-config/ai-config.service.ts index 8116fa1..996dee5 100644 --- a/apps/server/src/ai-config/ai-config.service.ts +++ b/apps/server/src/ai-config/ai-config.service.ts @@ -16,6 +16,8 @@ import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity'; import { SaveAiConfigDto, TestAiConfigDto, + FetchModelsDto, + FetchModelsResultDto, AiConfigResponseDto, AiConfigTestResultDto, AiRuntimeConfig, @@ -163,6 +165,13 @@ const PROVIDER_REQUIRED_PATHS: Partial> = { [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 { 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 { + // 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'; if (allowPrivate) return; @@ -400,9 +412,9 @@ export class AiConfigService { if (!config) { config = this.repo.create({ singletonKey: SINGLETON_KEY, - provider: AiProvider.OPENAI, - baseUrl: DEFAULT_BASE_URLS[AiProvider.OPENAI], - enabled: false, + provider: AiProvider.DEEPSEEK, + baseUrl: DEFAULT_BASE_URLS[AiProvider.DEEPSEEK], + enabled: true, timeoutMs: 30000, }); try { @@ -420,7 +432,22 @@ export class AiConfigService { } 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; } @@ -492,21 +519,11 @@ export class AiConfigService { 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) { - 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; + } else { + config.enabled = true; } return this.repo.save(config); @@ -709,6 +726,75 @@ export class AiConfigService { return result; } + /** Fetch available model list from the configured provider */ + async fetchModels(dto?: FetchModelsDto): Promise { + 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. * Re-validates the stored base URL and DNS at runtime to guard diff --git a/apps/server/src/ai-config/dto/ai-config.dto.ts b/apps/server/src/ai-config/dto/ai-config.dto.ts index a25583d..42b9e4f 100644 --- a/apps/server/src/ai-config/dto/ai-config.dto.ts +++ b/apps/server/src/ai-config/dto/ai-config.dto.ts @@ -24,8 +24,8 @@ export class SaveAiConfigDto { @IsIn(PROVIDERS) provider!: AiProvider; - @ValidateIf((o: SaveAiConfigDto) => o.provider === AiProvider.OPENAI_COMPATIBLE || o.baseUrl !== undefined) - @IsNotEmpty({ message: 'OPENAI_COMPATIBLE 模式必须提供 baseUrl' }) + @ValidateIf((o: SaveAiConfigDto) => o.provider === AiProvider.OPENAI_COMPATIBLE || (o.baseUrl !== undefined && o.baseUrl !== '')) + @IsNotEmpty({ message: 'Base URL 不能为空' }) @IsString() baseUrl?: string; @@ -113,4 +113,32 @@ export interface AiRuntimeConfig { 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 };