fix: 电商视频生成链路稳定性 — AI超时/重试/断点续传 + 404页面 + DashScope Key移除
- adVideoPlanClient: 模型级联降级(qwen-max→plus→turbo), 5xx/网络错误可重试, 超时延长至180s, 错误信息包含上游响应体 - 服务端ai/chat: 超时60s→120s, AbortError返回504(非500), PM2已热重载 - EcommerceVideoWorkspace: 策划失败后支持从断点继续(保留已完成步骤的中间产物), 分镜图/视频生成仅重做失败场景 - scriptEvalClient: 移除客户端DASHSCOPE_API_KEY引用(Nginx代理注入) - NotFoundPage: 未知路由显示404页面(替代兜底跳首页) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
||||
|
||||
const TEXT_MODEL = "qwen-max";
|
||||
const VISION_MODEL = "qwen3.7-plus";
|
||||
const VISION_FALLBACK_MODEL = "qwen-vl-plus";
|
||||
const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"];
|
||||
const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"];
|
||||
|
||||
export interface AdVideoUserConfig {
|
||||
platform: string;
|
||||
@@ -110,27 +109,41 @@ interface ChatMessage {
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_BASE_MS = 2000;
|
||||
const CHAT_TIMEOUT_MS = 120_000; // 2 minutes per AI call
|
||||
const CHAT_TIMEOUT_MS = 180_000; // 3 minutes per AI call (server times out at 120s + network slack)
|
||||
|
||||
// 5xx, 429, network failures, timeouts, and AbortError-from-timeout are all retryable
|
||||
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");
|
||||
if (/\b(429|500|502|503|504|520|521|522|524)\b/.test(msg)) return true;
|
||||
if (msg.includes("signal timed out") || msg.includes("timeout")) return true;
|
||||
if (msg.includes("failed to fetch") || msg.includes("networkerror") || msg.includes("network error")) return true;
|
||||
if (msg.includes("ai 调用失败") || msg.includes("图片理解调用失败")) return true; // generic upstream failures
|
||||
return false;
|
||||
}
|
||||
|
||||
async function retryOnTransient<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (signal?.aborted) throw err;
|
||||
// External AbortError caused by our timeoutSignal — retryable
|
||||
if (err instanceof Error && err.name === "AbortError" && !signal?.aborted) {
|
||||
if (attempt === MAX_RETRIES) throw err;
|
||||
const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000;
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
continue;
|
||||
}
|
||||
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");
|
||||
throw lastErr instanceof Error ? lastErr : new Error("AI 调用失败:已重试多次");
|
||||
}
|
||||
|
||||
async function chat(
|
||||
@@ -138,33 +151,45 @@ async function chat(
|
||||
userContent: string,
|
||||
options?: { model?: string; signal?: AbortSignal },
|
||||
): Promise<string> {
|
||||
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);
|
||||
const candidateModels = options?.model ? [options.model] : TEXT_MODELS;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const model of candidateModels) {
|
||||
try {
|
||||
return await 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, messages, stream: false, temperature: 0.4 }),
|
||||
signal: combinedSignal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => "");
|
||||
throw new Error(`AI 调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
|
||||
}
|
||||
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);
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (options?.signal?.aborted) throw lastError;
|
||||
// If user pinned a specific model, don't fall back to others
|
||||
if (options?.model) throw lastError;
|
||||
// Try next model in fallback chain
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error("所有候选模型均不可用");
|
||||
}
|
||||
|
||||
async function visionChat(
|
||||
@@ -182,7 +207,8 @@ async function visionChat(
|
||||
{ role: "user", content },
|
||||
];
|
||||
|
||||
for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) {
|
||||
let lastError: Error | null = null;
|
||||
for (const model of VISION_MODELS) {
|
||||
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
||||
const combinedSignal = signal
|
||||
? AbortSignal.any([signal, timeoutSignal])
|
||||
@@ -197,8 +223,8 @@ async function visionChat(
|
||||
});
|
||||
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})`);
|
||||
if (errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK");
|
||||
throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
|
||||
}
|
||||
const payload = await res.json();
|
||||
const result: string =
|
||||
@@ -208,12 +234,16 @@ async function visionChat(
|
||||
}, 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;
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (signal?.aborted) throw lastError;
|
||||
// Continue trying next vision model on transient failures, image format errors, or upstream errors
|
||||
if (lastError.message === "IMAGE_FORMAT_FALLBACK") continue;
|
||||
if (lastError.message.includes("图片理解调用失败")) continue;
|
||||
if (isTransientError(lastError)) continue;
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
throw new Error("图片理解调用失败,所有模型均不可用");
|
||||
throw lastError ?? new Error("图片理解调用失败,所有模型均不可用");
|
||||
}
|
||||
|
||||
const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`;
|
||||
|
||||
Reference in New Issue
Block a user