From 7371cf60ce55e89f008ce0668b33bd162d6db841 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Mon, 11 May 2026 17:47:08 +0800 Subject: [PATCH] Stabilize report AI requests --- server/src/ai/ai.service.test.ts | 75 ++++++++++++++++++++++++- server/src/ai/ai.service.ts | 67 +++++++++++++++++++++- server/src/demo/demo-defaults.ts | 2 +- server/src/settings/settings.service.ts | 2 +- src/pages/ReportEditor.tsx | 5 +- src/pages/SystemSettings.tsx | 2 +- src/types.ts | 2 +- 7 files changed, 146 insertions(+), 9 deletions(-) diff --git a/server/src/ai/ai.service.test.ts b/server/src/ai/ai.service.test.ts index 4a8dbbb..663c072 100644 --- a/server/src/ai/ai.service.test.ts +++ b/server/src/ai/ai.service.test.ts @@ -15,13 +15,13 @@ const actor: SafeUser = { updatedAt: new Date().toISOString(), }; -const createService = (modelName = 'moonshot-v1') => { +const createService = (modelName = 'moonshot-v1', endpoint = 'https://provider.example/v1') => { const settingsService = { getSystemSettings: vi.fn().mockResolvedValue({ activeAiProvider: 'kimi', aiProviders: { kimi: { - endpoint: 'https://provider.example/v1', + endpoint, apiKey: 'test-key', modelName, }, @@ -34,6 +34,7 @@ const createService = (modelName = 'moonshot-v1') => { describe('AiService', () => { const originalRetryDelays = process.env.AI_PROVIDER_RETRY_DELAYS_MS; + const originalProviderTimeout = process.env.AI_PROVIDER_TIMEOUT_MS; beforeEach(() => { process.env.AI_PROVIDER_RETRY_DELAYS_MS = '0,0'; @@ -46,6 +47,11 @@ describe('AiService', () => { } else { process.env.AI_PROVIDER_RETRY_DELAYS_MS = originalRetryDelays; } + if (originalProviderTimeout === undefined) { + delete process.env.AI_PROVIDER_TIMEOUT_MS; + } else { + process.env.AI_PROVIDER_TIMEOUT_MS = originalProviderTimeout; + } }); it('retries transient provider overloads and then returns a completion', async () => { @@ -104,4 +110,69 @@ describe('AiService', () => { expect(requestBody).not.toHaveProperty('presence_penalty'); expect(requestBody).not.toHaveProperty('frequency_penalty'); }); + + it('uses the faster Moonshot text model for Kimi K2 text-only report prompts', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: '{"reply":"已完善"}' } }] }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + + await createService('kimi-k2.6', 'https://api.moonshot.cn/v1').chat(actor, { + messages: [{ role: 'user', content: '请继续完善手术步骤' }], + temperature: 0.3, + }); + + const requestBody = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)); + expect(requestBody).toMatchObject({ + messages: [{ role: 'user', content: '请继续完善手术步骤' }], + model: 'moonshot-v1-32k', + temperature: 0.3, + }); + }); + + it('keeps an image-capable Kimi model for image prompts and removes unsupported sampling options', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ choices: [{ message: { content: '{"reply":"已分析图片"}' } }] }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + + await createService('moonshot-v1-32k', 'https://api.moonshot.cn/v1').chat(actor, { + messages: [ + { + role: 'user', + content: [ + { type: 'image_url', image_url: { url: 'data:image/png;base64,abc' } }, + { type: 'text', text: '请分析图片' }, + ], + }, + ], + temperature: 0.3, + }); + + const requestBody = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)); + expect(requestBody.model).toBe('kimi-k2.6'); + expect(requestBody).not.toHaveProperty('temperature'); + }); + + it('turns slow provider responses into a structured timeout before the public gateway times out', async () => { + process.env.AI_PROVIDER_TIMEOUT_MS = '1'; + const fetchMock = vi.fn((_url: string, init?: RequestInit) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + const error = new Error('This operation was aborted'); + error.name = 'AbortError'; + reject(error); + }); + })); + vi.stubGlobal('fetch', fetchMock); + + await expect(createService().chat(actor, { + messages: [{ role: 'user', content: '请完善报告内容' }], + })).rejects.toMatchObject({ + response: expect.objectContaining({ + code: 'AI_PROVIDER_TIMEOUT', + message: expect.stringContaining('AI 服务响应超时'), + }), + status: 504, + }); + }); }); diff --git a/server/src/ai/ai.service.ts b/server/src/ai/ai.service.ts index ce722d2..6519cc6 100644 --- a/server/src/ai/ai.service.ts +++ b/server/src/ai/ai.service.ts @@ -11,6 +11,9 @@ interface AiProvider { const RETRYABLE_PROVIDER_STATUSES = new Set([429, 500, 502, 503, 504]); const DEFAULT_RETRY_DELAYS_MS = [600, 1200]; +const DEFAULT_PROVIDER_TIMEOUT_MS = 45_000; +const DEFAULT_KIMI_TEXT_MODEL = 'moonshot-v1-32k'; +const DEFAULT_KIMI_VISION_MODEL = 'kimi-k2.6'; @Injectable() export class AiService { @@ -52,9 +55,10 @@ export class AiService { const provider = await this.getActiveProvider(actor); const input = result.data; + const model = this.selectModel(provider, input); const payload = this.normalizeProviderPayload({ ...input, - model: provider.modelName || input.model, + model, }); const response = await this.fetchProviderWithRetry(`${provider.endpoint}/chat/completions`, { @@ -111,6 +115,45 @@ export class AiService { return normalized; } + private selectModel(provider: AiProvider, input: Record) { + const configuredModel = provider.modelName || (typeof input.model === 'string' ? input.model : ''); + if (!this.isMoonshotProvider(provider)) return configuredModel; + + const hasImages = this.hasImageInput(input.messages); + if (hasImages && !this.supportsImageInput(configuredModel)) { + return process.env.AI_KIMI_VISION_MODEL || DEFAULT_KIMI_VISION_MODEL; + } + if (!hasImages && /^kimi-k2(?:[.-]|$)/i.test(configuredModel)) { + return process.env.AI_KIMI_TEXT_MODEL || DEFAULT_KIMI_TEXT_MODEL; + } + return configuredModel; + } + + private isMoonshotProvider(provider: AiProvider) { + return /moonshot\.cn/i.test(provider.endpoint); + } + + private supportsImageInput(model: string) { + return /vision|kimi-k2(?:[.-]|$)/i.test(model); + } + + private hasImageInput(messages: unknown) { + if (!Array.isArray(messages)) return false; + return messages.some((message) => { + if (typeof message !== 'object' || message === null || !('content' in message)) return false; + return this.hasImageContent((message as { content?: unknown }).content); + }); + } + + private hasImageContent(content: unknown): boolean { + if (!Array.isArray(content)) return false; + return content.some((part) => ( + typeof part === 'object' && + part !== null && + ('image_url' in part || (part as { type?: unknown }).type === 'image_url') + )); + } + private async parseProviderResponse(response: Response) { const text = await response.text(); if (!text) return null; @@ -122,10 +165,24 @@ export class AiService { } private async fetchProvider(url: string, init: RequestInit) { + const timeoutMs = this.providerTimeoutMs(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); try { - return await fetch(url, init); + return await fetch(url, { ...init, signal: controller.signal }); } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new HttpException( + { + code: 'AI_PROVIDER_TIMEOUT', + message: `AI 服务响应超时(${Math.round(timeoutMs / 1000)}秒),请稍后重试或缩短报告上下文。`, + }, + 504, + ); + } throw new BadRequestException(`AI 服务连接失败:${error instanceof Error ? error.message : String(error)}`); + } finally { + clearTimeout(timeout); } } @@ -170,6 +227,7 @@ export class AiService { if (status === 429 && /overloaded/i.test(providerType)) return 'AI_PROVIDER_OVERLOADED'; if (status === 429) return 'AI_PROVIDER_RATE_LIMITED'; + if (status === 504) return 'AI_PROVIDER_TIMEOUT'; if (status >= 500) return 'AI_PROVIDER_UNAVAILABLE'; return 'AI_PROVIDER_ERROR'; } @@ -189,6 +247,11 @@ export class AiService { .filter((value) => Number.isFinite(value) && value >= 0); } + private providerTimeoutMs() { + const value = Number(process.env.AI_PROVIDER_TIMEOUT_MS); + return Number.isFinite(value) && value > 0 ? value : DEFAULT_PROVIDER_TIMEOUT_MS; + } + private sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/server/src/demo/demo-defaults.ts b/server/src/demo/demo-defaults.ts index 5f6aa2f..a6eee03 100644 --- a/server/src/demo/demo-defaults.ts +++ b/server/src/demo/demo-defaults.ts @@ -20,7 +20,7 @@ export const DEMO_SYSTEM_SETTINGS = { kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: DEMO_AI_API_KEY, - modelName: 'kimi-k2.6', + modelName: 'moonshot-v1-32k', }, deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' }, openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' }, diff --git a/server/src/settings/settings.service.ts b/server/src/settings/settings.service.ts index 9462eb8..dccc9bb 100644 --- a/server/src/settings/settings.service.ts +++ b/server/src/settings/settings.service.ts @@ -13,7 +13,7 @@ import { PrismaService } from '../prisma/prisma.service.js'; import { systemSettingsSchema, type SystemSettingsInput } from './settings.schemas.js'; const DEFAULT_AI_PROVIDERS = { - kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: DEMO_SYSTEM_SETTINGS.aiProviders.kimi.apiKey, modelName: 'kimi-k2.6' }, + kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: DEMO_SYSTEM_SETTINGS.aiProviders.kimi.apiKey, modelName: 'moonshot-v1-32k' }, deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' }, openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' }, custom: { endpoint: '', apiKey: '', modelName: '' }, diff --git a/src/pages/ReportEditor.tsx b/src/pages/ReportEditor.tsx index 221a96a..232509f 100644 --- a/src/pages/ReportEditor.tsx +++ b/src/pages/ReportEditor.tsx @@ -53,6 +53,9 @@ const getAiErrorMessage = (error: unknown) => { if (error.status === 429 || error.code === 'AI_PROVIDER_OVERLOADED' || error.code === 'AI_PROVIDER_RATE_LIMITED') { return 'AI 服务当前繁忙或请求过多,请稍后重试。'; } + if (error.status === 504 || error.code === 'AI_PROVIDER_TIMEOUT') { + return 'AI 服务响应超时,请稍后重试,或缩短报告上下文后再试。'; + } if (error.status >= 500 || error.code === 'AI_PROVIDER_UNAVAILABLE') { return 'AI 服务暂时不可用,请稍后重试或切换其他模型。'; } @@ -1331,7 +1334,7 @@ export default function ReportEditor() { try { const settings = storage.get('systemSettings', {} as SystemSettings); const provider = settings.aiProviders?.[settings.activeAiProvider || 'kimi']; - const modelName = provider?.modelName || 'kimi-k2.6'; + const modelName = provider?.modelName || 'moonshot-v1-32k'; let actualTargetId = aiTargetRegion; if (aiModifyEnabled && actualTargetId === 'none') { const availableRegions = checkAiRegions(); diff --git a/src/pages/SystemSettings.tsx b/src/pages/SystemSettings.tsx index 6351786..2032e0e 100644 --- a/src/pages/SystemSettings.tsx +++ b/src/pages/SystemSettings.tsx @@ -96,7 +96,7 @@ export default function SystemSettings() { providers.kimi = { endpoint: (savedSettings as any).kimiApiEndpoint || providers.kimi.endpoint, apiKey: (savedSettings as any).kimiApiKey || '', - modelName: 'kimi-k2.6' + modelName: 'moonshot-v1-32k' }; } savedSettings.aiProviders = providers; diff --git a/src/types.ts b/src/types.ts index 719984c..f4c78bd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -112,7 +112,7 @@ export interface SystemSettings { } export const DEFAULT_AI_PROVIDERS: Record = { - kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: '', modelName: 'kimi-k2.6' }, + kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: '', modelName: 'moonshot-v1-32k' }, deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' }, openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' }, custom: { endpoint: '', apiKey: '', modelName: '' }