fix: upload-binary Content-Type fix, 429/timeout retry, 120s timeout - Remove Content-Type: application/json from uploadAssetBinary FormData request - Add retryOnTransient for 429 + timeout + signal timed out errors - Increase AI chat timeout from 60s to 120s per call - Apply retry logic to both chat() and visionChat()

This commit is contained in:
2026-06-02 16:58:59 +08:00
parent 9504f8ee87
commit 94c1453c9b
2 changed files with 75 additions and 42 deletions
+72 -41
View File
@@ -108,36 +108,63 @@ interface ChatMessage {
content: string; 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<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
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( async function chat(
systemPrompt: string, systemPrompt: string,
userContent: string, userContent: string,
options?: { model?: string; signal?: AbortSignal }, options?: { model?: string; signal?: AbortSignal },
): Promise<string> { ): Promise<string> {
const messages: ChatMessage[] = [ return retryOnTransient(async () => {
{ role: "system", content: systemPrompt }, const messages: ChatMessage[] = [
{ role: "user", content: userContent }, { role: "system", content: systemPrompt },
]; { role: "user", content: userContent },
const timeoutSignal = AbortSignal.timeout(60000); ];
const combinedSignal = options?.signal const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
? AbortSignal.any([options.signal, timeoutSignal]) const combinedSignal = options?.signal
: timeoutSignal; ? AbortSignal.any([options.signal, timeoutSignal])
const res = await fetch(buildApiUrl("ai/chat"), { : timeoutSignal;
method: "POST", const res = await fetch(buildApiUrl("ai/chat"), {
headers: buildAuthHeaders(), method: "POST",
body: JSON.stringify({ headers: buildAuthHeaders(),
model: options?.model ?? TEXT_MODEL, body: JSON.stringify({
messages, model: options?.model ?? TEXT_MODEL,
stream: false, messages,
temperature: 0.4, stream: false,
}), temperature: 0.4,
signal: combinedSignal, }),
}); signal: combinedSignal,
if (!res.ok) throw new Error(`AI 调用失败 (${res.status})`); });
const payload = await res.json(); if (!res.ok) throw new Error(`AI 调用失败 (${res.status})`);
const content: string = const payload = await res.json();
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; const content: string =
if (!content) throw new Error("模型未返回有效内容"); payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
return content; if (!content) throw new Error("模型未返回有效内容");
return content;
}, options?.signal);
} }
async function visionChat( async function visionChat(
@@ -156,28 +183,32 @@ async function visionChat(
]; ];
for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) { for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) {
const timeoutSignal = AbortSignal.timeout(60000); const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = signal const combinedSignal = signal
? AbortSignal.any([signal, timeoutSignal]) ? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal; : timeoutSignal;
try { try {
const res = await fetch(buildApiUrl("ai/chat"), { const out = await retryOnTransient(async () => {
method: "POST", const res = await fetch(buildApiUrl("ai/chat"), {
headers: buildAuthHeaders(), method: "POST",
body: JSON.stringify({ model, messages, stream: false, temperature: 0.3 }), headers: buildAuthHeaders(),
signal: combinedSignal, body: JSON.stringify({ model, messages, stream: false, temperature: 0.3 }),
}); signal: combinedSignal,
if (!res.ok) { });
const errBody = await res.text().catch(() => ""); if (!res.ok) {
if (model === VISION_MODEL && errBody.includes("image format")) continue; const errBody = await res.text().catch(() => "");
throw new Error(`图片理解调用失败 (${res.status})`); 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 out: string = const payload = await res.json();
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; const result: string =
if (!out) throw new Error("图片理解未返回有效内容"); payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!result) throw new Error("图片理解未返回有效内容");
return result;
}, signal);
return out; return out;
} catch (err) { } catch (err) {
if (err instanceof Error && err.message === "IMAGE_FORMAT_FALLBACK") continue;
if (model === VISION_MODEL && err instanceof Error && err.message?.includes("图片理解调用失败")) continue; if (model === VISION_MODEL && err instanceof Error && err.message?.includes("图片理解调用失败")) continue;
throw err; throw err;
} }
+3 -1
View File
@@ -408,9 +408,11 @@ export const aiGenerationClient = {
form.append("file", blob, options?.name || "upload.png"); form.append("file", blob, options?.name || "upload.png");
if (options?.scope) form.append("scope", options.scope); if (options?.scope) form.append("scope", options.scope);
if (options?.mimeType) form.append("mimeType", options.mimeType); 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"), { const res = await fetch(buildApiUrl("oss/upload-binary"), {
method: "POST", method: "POST",
headers: { ...buildAuthHeaders() }, headers: authHeaders,
body: form, body: form,
}); });
if (!res.ok) { if (!res.ok) {