Fix/ecommerce 502 bug #3

Merged
stringadmin merged 5 commits from fix/ecommerce-502-bug into master 2026-06-02 09:01:38 +00:00
4 changed files with 126 additions and 65 deletions
+91 -50
View File
@@ -1,7 +1,8 @@
import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
const TEXT_MODEL = "qwen-max"; const TEXT_MODEL = "qwen-max";
const VISION_MODEL = "qwen3.6-plus"; const VISION_MODEL = "qwen3.7-plus";
const VISION_FALLBACK_MODEL = "qwen-vl-plus";
export interface AdVideoUserConfig { export interface AdVideoUserConfig {
platform: string; platform: string;
@@ -107,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(
@@ -149,30 +177,43 @@ async function visionChat(
...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })), ...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })),
{ type: "text", text }, { type: "text", text },
]; ];
const timeoutSignal = AbortSignal.timeout(60000); const messages = [
const combinedSignal = signal { role: "system", content: systemPrompt },
? AbortSignal.any([signal, timeoutSignal]) { role: "user", content },
: timeoutSignal; ];
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST", for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) {
headers: buildAuthHeaders(), const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
body: JSON.stringify({ const combinedSignal = signal
model: VISION_MODEL, ? AbortSignal.any([signal, timeoutSignal])
messages: [ : timeoutSignal;
{ role: "system", content: systemPrompt }, try {
{ role: "user", content }, const out = await retryOnTransient(async () => {
], const res = await fetch(buildApiUrl("ai/chat"), {
stream: false, method: "POST",
temperature: 0.3, headers: buildAuthHeaders(),
}), body: JSON.stringify({ model, messages, stream: false, temperature: 0.3 }),
signal: combinedSignal, signal: combinedSignal,
}); });
if (!res.ok) throw new Error(`图片理解调用失败 (${res.status})`); if (!res.ok) {
const payload = await res.json(); const errBody = await res.text().catch(() => "");
const out: string = if (model === VISION_MODEL && errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK");
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; throw new Error(`图片理解调用失败 (${res.status})`);
if (!out) throw new Error("图片理解未返回有效内容"); }
return out; 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;
}
}
throw new Error("图片理解调用失败,所有模型均不可用");
} }
const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`; const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`;
+18
View File
@@ -403,6 +403,24 @@ export const aiGenerationClient = {
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload response failed"); return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload response failed");
}, },
async uploadAssetBinary(blob: Blob, options?: { name?: string; mimeType?: string; scope?: string }): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
const form = new FormData();
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: authHeaders,
body: form,
});
if (!res.ok) {
await throwResponseError(res, "Binary asset upload failed");
}
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Binary asset upload response failed");
},
async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
const res = await fetch(buildApiUrl("oss/upload-by-url"), { const res = await fetch(buildApiUrl("oss/upload-by-url"), {
method: "POST", method: "POST",
+5 -8
View File
@@ -1321,18 +1321,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
}); });
const uploadProductImages = async (): Promise<string[]> => { const uploadProductImages = async (): Promise<string[]> => {
const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
const urls: string[] = []; const urls: string[] = [];
for (const item of productImages) { for (const item of productImages) {
try { try {
const resp = await fetch(item.src); const resp = await fetch(item.src);
const blob = await resp.blob(); const rawBlob = await resp.blob();
const dataUrl = await new Promise<string>((resolve, reject) => { const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png";
const reader = new FileReader(); const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
reader.onload = () => resolve(String(reader.result)); const { url } = await aiGenerationClient.uploadAssetBinary(blob, { name: item.name, mimeType, scope: "ecommerce-product" });
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType: blob.type });
urls.push(url); urls.push(url);
} catch { } catch {
// skip images that fail to upload // skip images that fail to upload
@@ -9,7 +9,6 @@ import {
type AdVideoUserConfig, type AdVideoUserConfig,
} from "../../api/adVideoPlanClient"; } from "../../api/adVideoPlanClient";
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel"; import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import type { import type {
@@ -34,12 +33,18 @@ export async function runVideoPlan(
onStepStart("upload"); onStepStart("upload");
const imageUrls: string[] = []; const imageUrls: string[] = [];
for (const dataUrl of imageDataUrls) { const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
const result = await uploadAssetWithProgress( for (const srcUrl of imageDataUrls) {
{ dataUrl, scope: "ecommerce-product", mimeType: "image/png" }, try {
{ signal }, const resp = await fetch(srcUrl);
); const rawBlob = await resp.blob();
imageUrls.push(result.url); const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png";
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const result = await aiGenerationClient.uploadAssetBinary(blob, { mimeType, scope: "ecommerce-product" });
imageUrls.push(result.url);
} catch {
// skip images that fail to upload
}
} }
onStepDone("upload"); onStepDone("upload");