diff --git a/src/api/adVideoPlanClient.ts b/src/api/adVideoPlanClient.ts index fc29983..2bae189 100644 --- a/src/api/adVideoPlanClient.ts +++ b/src/api/adVideoPlanClient.ts @@ -108,36 +108,63 @@ interface ChatMessage { content: string; } +const MAX_RETRIES = 3; +const RETRY_BASE_MS = 2000; +const CHAT_TIMEOUT_MS = 120_000; // 2 minutes per AI call + +function isTransientError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const msg = err.message.toLowerCase(); + return /\b429\b/.test(msg) || msg.includes("signal timed out") || msg.includes("aborted") || msg.includes("timeout"); +} + +async function retryOnTransient(fn: () => Promise, signal?: AbortSignal): Promise { + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + return await fn(); + } catch (err) { + if (signal?.aborted) throw err; + if (attempt === MAX_RETRIES) throw err; + if (!isTransientError(err)) throw err; + const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000; + await new Promise((r) => setTimeout(r, delay)); + } + } + throw new Error("unreachable"); +} + async function chat( systemPrompt: string, userContent: string, options?: { model?: string; signal?: AbortSignal }, ): Promise { - const messages: ChatMessage[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: userContent }, - ]; - const timeoutSignal = AbortSignal.timeout(60000); - const combinedSignal = options?.signal - ? AbortSignal.any([options.signal, timeoutSignal]) - : timeoutSignal; - const res = await fetch(buildApiUrl("ai/chat"), { - method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify({ - model: options?.model ?? TEXT_MODEL, - messages, - stream: false, - temperature: 0.4, - }), - signal: combinedSignal, - }); - if (!res.ok) throw new Error(`AI 调用失败 (${res.status})`); - const payload = await res.json(); - const content: string = - payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; - if (!content) throw new Error("模型未返回有效内容"); - return content; + return retryOnTransient(async () => { + const messages: ChatMessage[] = [ + { role: "system", content: systemPrompt }, + { role: "user", content: userContent }, + ]; + const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); + const combinedSignal = options?.signal + ? AbortSignal.any([options.signal, timeoutSignal]) + : timeoutSignal; + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ + model: options?.model ?? TEXT_MODEL, + messages, + stream: false, + temperature: 0.4, + }), + signal: combinedSignal, + }); + if (!res.ok) throw new Error(`AI 调用失败 (${res.status})`); + const payload = await res.json(); + const content: string = + payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; + if (!content) throw new Error("模型未返回有效内容"); + return content; + }, options?.signal); } async function visionChat( @@ -156,28 +183,32 @@ async function visionChat( ]; for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) { - const timeoutSignal = AbortSignal.timeout(60000); + const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; try { - const res = await fetch(buildApiUrl("ai/chat"), { - method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify({ model, messages, stream: false, temperature: 0.3 }), - signal: combinedSignal, - }); - if (!res.ok) { - const errBody = await res.text().catch(() => ""); - if (model === VISION_MODEL && errBody.includes("image format")) continue; - throw new Error(`图片理解调用失败 (${res.status})`); - } - const payload = await res.json(); - const out: string = - payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; - if (!out) throw new Error("图片理解未返回有效内容"); + const out = await retryOnTransient(async () => { + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ model, messages, stream: false, temperature: 0.3 }), + signal: combinedSignal, + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + if (model === VISION_MODEL && errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK"); + throw new Error(`图片理解调用失败 (${res.status})`); + } + const payload = await res.json(); + const result: string = + payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; + if (!result) throw new Error("图片理解未返回有效内容"); + return result; + }, signal); return out; } catch (err) { + if (err instanceof Error && err.message === "IMAGE_FORMAT_FALLBACK") continue; if (model === VISION_MODEL && err instanceof Error && err.message?.includes("图片理解调用失败")) continue; throw err; } diff --git a/src/api/aiGenerationClient.ts b/src/api/aiGenerationClient.ts index 032ef9f..1335847 100644 --- a/src/api/aiGenerationClient.ts +++ b/src/api/aiGenerationClient.ts @@ -408,9 +408,11 @@ export const aiGenerationClient = { form.append("file", blob, options?.name || "upload.png"); if (options?.scope) form.append("scope", options.scope); if (options?.mimeType) form.append("mimeType", options.mimeType); + // Exclude Content-Type so browser auto-sets multipart/form-data with boundary + const { "Content-Type": _ct, ...authHeaders } = buildAuthHeaders(); const res = await fetch(buildApiUrl("oss/upload-binary"), { method: "POST", - headers: { ...buildAuthHeaders() }, + headers: authHeaders, body: form, }); if (!res.ok) {