Merge pull request 'Fix/ecommerce 502 bug' (#3) from fix/ecommerce-502-bug into master
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
@@ -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,16 +108,42 @@ 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> {
|
||||||
|
return retryOnTransient(async () => {
|
||||||
const messages: ChatMessage[] = [
|
const messages: ChatMessage[] = [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content: userContent },
|
{ role: "user", content: userContent },
|
||||||
];
|
];
|
||||||
const timeoutSignal = AbortSignal.timeout(60000);
|
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
||||||
const combinedSignal = options?.signal
|
const combinedSignal = options?.signal
|
||||||
? AbortSignal.any([options.signal, timeoutSignal])
|
? AbortSignal.any([options.signal, timeoutSignal])
|
||||||
: timeoutSignal;
|
: timeoutSignal;
|
||||||
@@ -137,6 +164,7 @@ async function chat(
|
|||||||
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
||||||
if (!content) throw new Error("模型未返回有效内容");
|
if (!content) throw new Error("模型未返回有效内容");
|
||||||
return content;
|
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 = [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
{ role: "user", content },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) {
|
||||||
|
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
||||||
const combinedSignal = signal
|
const combinedSignal = signal
|
||||||
? AbortSignal.any([signal, timeoutSignal])
|
? AbortSignal.any([signal, timeoutSignal])
|
||||||
: timeoutSignal;
|
: timeoutSignal;
|
||||||
|
try {
|
||||||
|
const out = await retryOnTransient(async () => {
|
||||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
headers: buildAuthHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ model, messages, stream: false, temperature: 0.3 }),
|
||||||
model: VISION_MODEL,
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: systemPrompt },
|
|
||||||
{ role: "user", content },
|
|
||||||
],
|
|
||||||
stream: false,
|
|
||||||
temperature: 0.3,
|
|
||||||
}),
|
|
||||||
signal: combinedSignal,
|
signal: combinedSignal,
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`图片理解调用失败 (${res.status})`);
|
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 payload = await res.json();
|
||||||
const out: string =
|
const result: string =
|
||||||
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
||||||
if (!out) throw new Error("图片理解未返回有效内容");
|
if (!result) throw new Error("图片理解未返回有效内容");
|
||||||
|
return result;
|
||||||
|
}, signal);
|
||||||
return out;
|
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 = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
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);
|
imageUrls.push(result.url);
|
||||||
|
} catch {
|
||||||
|
// skip images that fail to upload
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onStepDone("upload");
|
onStepDone("upload");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user