diff --git a/src/api/adVideoPlanClient.ts b/src/api/adVideoPlanClient.ts index ae700ea..2bae189 100644 --- a/src/api/adVideoPlanClient.ts +++ b/src/api/adVideoPlanClient.ts @@ -1,7 +1,8 @@ import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; 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 { platform: string; @@ -107,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( @@ -149,30 +177,43 @@ async function visionChat( ...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })), { type: "text", text }, ]; - const timeoutSignal = AbortSignal.timeout(60000); - const combinedSignal = signal - ? AbortSignal.any([signal, timeoutSignal]) - : timeoutSignal; - const res = await fetch(buildApiUrl("ai/chat"), { - method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify({ - model: VISION_MODEL, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content }, - ], - stream: false, - temperature: 0.3, - }), - signal: combinedSignal, - }); - if (!res.ok) 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("图片理解未返回有效内容"); - return out; + 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 + ? AbortSignal.any([signal, timeoutSignal]) + : timeoutSignal; + try { + 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; + } + } + throw new Error("图片理解调用失败,所有模型均不可用"); } const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`; diff --git a/src/api/aiGenerationClient.ts b/src/api/aiGenerationClient.ts index f580b5a..1335847 100644 --- a/src/api/aiGenerationClient.ts +++ b/src/api/aiGenerationClient.ts @@ -403,6 +403,24 @@ export const aiGenerationClient = { 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 }> { const res = await fetch(buildApiUrl("oss/upload-by-url"), { method: "POST", diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 1f561ba..3c500ba 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -1321,18 +1321,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }); const uploadProductImages = async (): Promise => { + const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); const urls: string[] = []; for (const item of productImages) { try { const resp = await fetch(item.src); - const blob = await resp.blob(); - const dataUrl = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(String(reader.result)); - reader.onerror = () => reject(reader.error); - reader.readAsDataURL(blob); - }); - const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType: blob.type }); + 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 { url } = await aiGenerationClient.uploadAssetBinary(blob, { name: item.name, mimeType, scope: "ecommerce-product" }); urls.push(url); } catch { // skip images that fail to upload diff --git a/src/features/ecommerce/ecommerceVideoService.ts b/src/features/ecommerce/ecommerceVideoService.ts index f1f7639..5f92e86 100644 --- a/src/features/ecommerce/ecommerceVideoService.ts +++ b/src/features/ecommerce/ecommerceVideoService.ts @@ -9,7 +9,6 @@ import { type AdVideoUserConfig, } from "../../api/adVideoPlanClient"; import { aiGenerationClient } from "../../api/aiGenerationClient"; -import { uploadAssetWithProgress } from "../../api/uploadWithProgress"; import { waitForTask } from "../../api/taskSubscription"; import { resolveVideoRequestModel } from "../../utils/resolveVideoModel"; import type { @@ -34,12 +33,18 @@ export async function runVideoPlan( onStepStart("upload"); const imageUrls: string[] = []; - for (const dataUrl of imageDataUrls) { - const result = await uploadAssetWithProgress( - { dataUrl, scope: "ecommerce-product", mimeType: "image/png" }, - { signal }, - ); - imageUrls.push(result.url); + const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); + for (const srcUrl of imageDataUrls) { + try { + 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); + } catch { + // skip images that fail to upload + } } onStepDone("upload");