From 56dabf1f7d89d490f3d387b97a3092802c067ba7 Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Wed, 3 Jun 2026 12:16:33 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=94=B5=E5=95=86=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E7=94=9F=E6=88=90=E9=93=BE=E8=B7=AF=E7=A8=B3=E5=AE=9A=E6=80=A7?= =?UTF-8?q?=20=E2=80=94=20AI=E8=B6=85=E6=97=B6/=E9=87=8D=E8=AF=95/?= =?UTF-8?q?=E6=96=AD=E7=82=B9=E7=BB=AD=E4=BC=A0=20+=20404=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=20+=20DashScope=20Key=E7=A7=BB=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/App.tsx | 13 +- src/api/adVideoPlanClient.ts | 110 +++++++++------ src/api/scriptEvalClient.ts | 6 - src/components/NotFoundPage.tsx | 24 ++++ .../ecommerce/EcommerceVideoWorkspace.tsx | 131 +++++++++++++---- .../ecommerce/ecommerceVideoKeepalive.ts | 3 + .../ecommerce/ecommerceVideoService.ts | 133 ++++++++++++------ src/features/ecommerce/ecommerceVideoTypes.ts | 13 ++ src/styles/index.css | 1 + src/styles/pages/not-found.css | 56 ++++++++ src/types.ts | 3 +- 11 files changed, 373 insertions(+), 120 deletions(-) create mode 100644 src/components/NotFoundPage.tsx create mode 100644 src/styles/pages/not-found.css diff --git a/src/App.tsx b/src/App.tsx index f301622..353238b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ import { import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway"; import { translateTaskError } from "./utils/translateTaskError"; import AppShell from "./components/AppShell"; +const NotFoundPage = lazy(() => import("./components/NotFoundPage")); import { cloneWorkflow, createBlankWorkflow } from "./data/workflows"; const AgentPage = lazy(() => import("./features/agent/AgentPage")); const AssetsPage = lazy(() => import("./features/assets/AssetsPage")); @@ -115,9 +116,10 @@ const VIEW_KEYS = new Set([ "communityCaseAdd", "report", "providerHealth", + "not-found", ]); -const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more"]); +const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "not-found"]); function normalizeViewKey(rawView: string): WebViewKey { const normalized = @@ -130,7 +132,7 @@ function normalizeViewKey(rawView: string): WebViewKey { : rawView === "community-case-add" ? "communityCaseAdd" : rawView; - return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "home"; + return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found"; } function readViewFromHash(): WebViewKey { @@ -146,7 +148,8 @@ function isWorkspaceView(view: WebViewKey): boolean { view !== "ecommerceHub" && view !== "ecommerce" && view !== "scriptTokens" && - view !== "login" + view !== "login" && + view !== "not-found" ); } @@ -1178,7 +1181,6 @@ function App() { /> ); case "home": - default: return ( handleSetView("workbench")} @@ -1190,6 +1192,9 @@ function App() { onOpenImageTool={handleOpenImageWorkbenchTool} /> ); + case "not-found": + default: + return handleSetView("home")} />; } })(); diff --git a/src/api/adVideoPlanClient.ts b/src/api/adVideoPlanClient.ts index 2bae189..f767d95 100644 --- a/src/api/adVideoPlanClient.ts +++ b/src/api/adVideoPlanClient.ts @@ -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(fn: () => Promise, signal?: AbortSignal): Promise { + 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 { - 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 = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`; diff --git a/src/api/scriptEvalClient.ts b/src/api/scriptEvalClient.ts index c4f1dc5..0516508 100644 --- a/src/api/scriptEvalClient.ts +++ b/src/api/scriptEvalClient.ts @@ -8,7 +8,6 @@ export interface ScriptEvalResult { suggestions: string[]; } -const DASHSCOPE_API_KEY = import.meta.env.VITE_DASHSCOPE_API_KEY || ""; const DASHSCOPE_ENDPOINT = "/dashscope-api/chat/completions"; const MODEL = "qwen3.7-max"; @@ -69,15 +68,10 @@ function extractJson(text: string): unknown { } export async function evaluateScript(script: string, signal?: AbortSignal): Promise { - if (!DASHSCOPE_API_KEY) { - throw new Error("DashScope API key 未配置,请在 .env.local 中设置 VITE_DASHSCOPE_API_KEY"); - } - const res = await fetch(DASHSCOPE_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${DASHSCOPE_API_KEY}`, }, body: JSON.stringify({ model: MODEL, diff --git a/src/components/NotFoundPage.tsx b/src/components/NotFoundPage.tsx new file mode 100644 index 0000000..2a09a9c --- /dev/null +++ b/src/components/NotFoundPage.tsx @@ -0,0 +1,24 @@ +import { HomeOutlined } from "@ant-design/icons"; +import { useCallback } from "react"; + +interface NotFoundPageProps { + onGoHome: () => void; +} + +function NotFoundPage({ onGoHome }: NotFoundPageProps) { + return ( +
+
+
404
+

页面未找到

+

您访问的页面不存在或已被移除。

+ +
+
+ ); +} + +export default NotFoundPage; diff --git a/src/features/ecommerce/EcommerceVideoWorkspace.tsx b/src/features/ecommerce/EcommerceVideoWorkspace.tsx index e173c28..65da77d 100644 --- a/src/features/ecommerce/EcommerceVideoWorkspace.tsx +++ b/src/features/ecommerce/EcommerceVideoWorkspace.tsx @@ -15,6 +15,7 @@ import { PLAN_STEPS_DISPLAY, type EcommerceVideoStage, type EcommerceVideoSceneTask, + type EcommerceVideoPlanProgress, type EcommerceVideoPlanResult, type PlanStep, } from "./ecommerceVideoTypes"; @@ -48,6 +49,19 @@ function mapResolutionToQuality(res: string): "720P" | "1080P" { return res.includes("720") ? "720P" : "1080P"; } +function stepCompletedFromProgress(step: PlanStep, p: EcommerceVideoPlanProgress): boolean { + switch (step) { + case "upload": return Boolean(p.imageUrls?.length); + case "analyze": return p.imageDescription !== undefined; + case "summary": return Boolean(p.summary); + case "selling": return Boolean(p.selling); + case "creative": return Boolean(p.creatives?.length); + case "storyboard": return Boolean(p.storyboard); + case "prompts": return Boolean(p.videoPrompts); + case "compliance": return Boolean(p.compliance); + } +} + export default function EcommerceVideoWorkspace({ isAuthenticated, productImageDataUrls, @@ -60,10 +74,12 @@ export default function EcommerceVideoWorkspace({ }: EcommerceVideoWorkspaceProps) { const [stage, setStage] = useState("idle"); const [planResult, setPlanResult] = useState(null); + const [planProgress, setPlanProgress] = useState(null); const [scenes, setScenes] = useState([]); const [completedSteps, setCompletedSteps] = useState([]); const [sourceImageUrls, setSourceImageUrls] = useState([]); const [currentStep, setCurrentStep] = useState(null); + const [failedStep, setFailedStep] = useState(null); const [error, setError] = useState(null); const [actionNotice, setActionNotice] = useState(null); const abortControllerRef = useRef(null); @@ -83,6 +99,7 @@ export default function EcommerceVideoWorkspace({ setStage(saved.stage); setCompletedSteps(saved.completedSteps || []); setPlanResult(saved.planResult); + setPlanProgress((saved as { planProgress?: EcommerceVideoPlanProgress | null }).planProgress || null); setScenes(saved.scenes || []); setSourceImageUrls(saved.sourceImageUrls || saved.planResult?.imageUrls || []); }, []); @@ -90,8 +107,8 @@ export default function EcommerceVideoWorkspace({ // ── Keep-alive: save state on changes ─────────────────── useEffect(() => { if (stage === "idle" || stage === "cancelled") return; - saveEcommerceVideoState({ stage, completedSteps, planResult, scenes, sourceImageUrls }); - }, [stage, completedSteps, planResult, scenes, sourceImageUrls]); + saveEcommerceVideoState({ stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls }); + }, [stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]); // ── Keep-alive: resume polling for running tasks ────────── useEffect(() => { @@ -253,40 +270,85 @@ export default function EcommerceVideoWorkspace({ // ── Phase 1: Planning ────────────────────────────────────── - const handlePlan = async () => { - if (!isAuthenticated) { onRequestLogin?.(); return; } - if (!productImageDataUrls.length && !requirement.trim()) { - setError("请先上传产品图片或填写商品说明"); return; - } + const runPlanFlow = async (resume: EcommerceVideoPlanProgress | null) => { abortControllerRef.current?.abort(); const controller = new AbortController(); abortControllerRef.current = controller; - setStage("planning"); setError(null); - setCompletedSteps([]); setCurrentStep(null); - setPlanResult(null); setScenes([]); setSourceImageUrls([]); + setStage("planning"); setError(null); setFailedStep(null); + if (!resume) { + setCompletedSteps([]); setPlanResult(null); setScenes([]); setSourceImageUrls([]); setPlanProgress(null); + } + setCurrentStep(null); + // Mutable snapshot — async handlers must persist to localStorage directly since the component may unmount + let livePlanProgress: EcommerceVideoPlanProgress = resume ? { ...resume } : {}; + let liveCompletedSteps: PlanStep[] = resume + ? ALL_STEPS.filter((s) => stepCompletedFromProgress(s, resume)) + : []; + const persist = (stageNow: EcommerceVideoStage) => { + saveEcommerceVideoState({ + stage: stageNow, + completedSteps: liveCompletedSteps, + planResult: null, + planProgress: livePlanProgress, + scenes: [], + sourceImageUrls: livePlanProgress.imageUrls || [], + }); + }; try { const result = await runVideoPlan( productImageDataUrls, requirement, buildConfig(), { onStepStart: (step) => setCurrentStep(step), - onStepDone: (step) => setCompletedSteps((prev) => [...prev, step]), - onImagesUploaded: (urls) => { setSourceImageUrls(urls); saveEcommerceVideoState({ stage: "planning", completedSteps: ["upload"], planResult: null, scenes: [], sourceImageUrls: urls }); }, + onStepDone: (step) => { + liveCompletedSteps = [...liveCompletedSteps, step]; + setCompletedSteps((prev) => [...prev, step]); + }, + onImagesUploaded: (urls) => { + setSourceImageUrls(urls); + livePlanProgress = { ...livePlanProgress, imageUrls: urls }; + persist("planning"); + }, + onPartialProgress: (progress) => { + livePlanProgress = progress; + setPlanProgress(progress); + persist("planning"); + }, + resumeFrom: resume || undefined, signal: controller.signal, }, ); const builtScenes = buildSceneTasks(result); setPlanResult(result); + setPlanProgress(null); setScenes(builtScenes); setStage("planned"); - // Persist immediately — component may be unmounted by the time React re-renders - saveEcommerceVideoState({ stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, scenes: builtScenes, sourceImageUrls: result.imageUrls }); + saveEcommerceVideoState({ stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, planProgress: null, scenes: builtScenes, sourceImageUrls: result.imageUrls }); } catch (err) { - if ((err as Error).name === "AbortError") return; - setError(err instanceof Error ? err.message : "策划失败"); + if ((err as Error).name === "AbortError" && controller.signal.aborted) return; + const message = err instanceof Error ? err.message : "策划失败"; + setError(message); + // Mark the step that was in-progress as failed so user can resume + setFailedStep((prev) => prev || currentStep); setStage("idle"); + // Persist partial progress so the user can resume after a page switch + persist("idle"); } finally { setCurrentStep(null); } }; + const handlePlan = async () => { + if (!isAuthenticated) { onRequestLogin?.(); return; } + if (!productImageDataUrls.length && !requirement.trim()) { + setError("请先上传产品图片或填写商品说明"); return; + } + await runPlanFlow(null); + }; + + const handleResumePlan = async () => { + if (!isAuthenticated) { onRequestLogin?.(); return; } + if (!planProgress) { void handlePlan(); return; } + await runPlanFlow(planProgress); + }; + // ── Phase 2: Image generation per scene ────────────────────── const handleGenerateImages = async () => { @@ -302,9 +364,12 @@ export default function EcommerceVideoWorkspace({ setScenes(next); saveEcommerceVideoState({ stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls }); }; - for (const scene of currentScenes) { + // Only redo scenes missing imageUrl — preserves successfully generated images on partial retry + const scenesToProcess = currentScenes.filter((s) => !s.imageUrl); + if (!scenesToProcess.length) { setStage("imaged"); return; } + for (const scene of scenesToProcess) { if (renderAbortRef.current.current) break; - persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s)); + persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s)); try { await renderSceneImage( { sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio }, @@ -331,8 +396,7 @@ export default function EcommerceVideoWorkspace({ const handleRenderVideos = async () => { if (!scenes.length) return; - const firstImage = scenes[0]?.imageUrl; - if (!firstImage) { setError("请先生成分镜图片"); return; } + if (!scenes.some((s) => s.imageUrl)) { setError("请先生成分镜图片"); return; } setStage("rendering"); setError(null); renderAbortRef.current = { current: false }; const quality = mapResolutionToQuality(resolution); @@ -342,10 +406,13 @@ export default function EcommerceVideoWorkspace({ setScenes(next); saveEcommerceVideoState({ stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls }); }; - for (const scene of currentScenes) { + // Only render scenes that haven't completed yet — preserves successful videos on partial retry + const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed"); + if (!scenesToProcess.length) { setStage(currentScenes.every((s) => s.status === "completed") ? "completed" : "partial_failed"); return; } + for (const scene of scenesToProcess) { if (renderAbortRef.current.current) break; if (!scene.imageUrl) continue; - persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s)); + persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s)); try { await renderScene( { sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, aspectRatio, resolution: quality }, @@ -424,26 +491,32 @@ export default function EcommerceVideoWorkspace({
{error ? {error} : null} + {stage === "idle" && planProgress && (planProgress.summary || planProgress.creatives || planProgress.storyboard) ? ( + + ) : null} {stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? ( ) : null} - {stage === "planned" ? ( + {stage === "planned" || stage === "imaged" ? ( ) : null} - {stage === "imaged" ? ( + {stage === "imaged" || (stage === "partial_failed" && imagedScenes.length > 0) ? ( ) : null} {stage === "planning" ? ( - 策划中 + {currentStep ? PLAN_STEP_LABELS[currentStep] : "策划中"} ) : null} {stage === "imaging" ? ( 生成图片中 diff --git a/src/features/ecommerce/ecommerceVideoKeepalive.ts b/src/features/ecommerce/ecommerceVideoKeepalive.ts index 8ded519..ba27d0a 100644 --- a/src/features/ecommerce/ecommerceVideoKeepalive.ts +++ b/src/features/ecommerce/ecommerceVideoKeepalive.ts @@ -1,6 +1,7 @@ import type { EcommerceVideoStage, EcommerceVideoSceneTask, + EcommerceVideoPlanProgress, EcommerceVideoPlanResult, PlanStep, } from "./ecommerceVideoTypes"; @@ -11,6 +12,7 @@ interface EcommerceVideoKeepalive { stage: EcommerceVideoStage; completedSteps: PlanStep[]; planResult: EcommerceVideoPlanResult | null; + planProgress?: EcommerceVideoPlanProgress | null; scenes: EcommerceVideoSceneTask[]; sourceImageUrls: string[]; savedAt: number; @@ -20,6 +22,7 @@ export function saveEcommerceVideoState(state: { stage: EcommerceVideoStage; completedSteps: PlanStep[]; planResult: EcommerceVideoPlanResult | null; + planProgress?: EcommerceVideoPlanProgress | null; scenes: EcommerceVideoSceneTask[]; sourceImageUrls?: string[]; }): void { diff --git a/src/features/ecommerce/ecommerceVideoService.ts b/src/features/ecommerce/ecommerceVideoService.ts index bb607fb..81703b0 100644 --- a/src/features/ecommerce/ecommerceVideoService.ts +++ b/src/features/ecommerce/ecommerceVideoService.ts @@ -12,6 +12,7 @@ import { aiGenerationClient } from "../../api/aiGenerationClient"; import { waitForTask } from "../../api/taskSubscription"; import { resolveVideoRequestModel } from "../../utils/resolveVideoModel"; import type { + EcommerceVideoPlanProgress, EcommerceVideoPlanResult, EcommerceVideoSceneTask, PlanStep, @@ -21,66 +22,118 @@ export interface PlanCallbacks { onStepStart: (step: PlanStep) => void; onStepDone: (step: PlanStep) => void; onImagesUploaded?: (urls: string[]) => void; + onPartialProgress?: (progress: EcommerceVideoPlanProgress) => void; signal?: AbortSignal; + /** Partial state from a previous run; steps with existing data are skipped. */ + resumeFrom?: EcommerceVideoPlanProgress; } +/** + * Run the full ad video planning pipeline. + * Supports resumption: if `resumeFrom` contains data for a step, that step is skipped. + * After each step, `onPartialProgress` fires so callers can persist intermediate state. + */ export async function runVideoPlan( imageDataUrls: string[], manualText: string, config: AdVideoUserConfig, callbacks: PlanCallbacks, ): Promise { - const { onStepStart, onStepDone, signal } = callbacks; + const { onStepStart, onStepDone, signal, resumeFrom = {} } = callbacks; + const progress: EcommerceVideoPlanProgress = { ...resumeFrom }; + const emit = () => callbacks.onPartialProgress?.({ ...progress }); - onStepStart("upload"); - const imageUrls: string[] = []; - 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 + // ── Step: upload ────────────────────────────────────── + if (!progress.imageUrls?.length) { + onStepStart("upload"); + const imageUrls: string[] = []; + 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 + } } + if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试"); + progress.imageUrls = imageUrls; + onStepDone("upload"); + callbacks.onImagesUploaded?.(imageUrls); + emit(); } - if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试"); - onStepDone("upload"); - callbacks.onImagesUploaded?.(imageUrls); - onStepStart("analyze"); - const imageDesc = await analyzeProductImages(imageUrls, signal); - onStepDone("analyze"); + // ── Step: analyze ───────────────────────────────────── + if (progress.imageDescription === undefined) { + onStepStart("analyze"); + progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal); + onStepDone("analyze"); + emit(); + } - onStepStart("summary"); - const summary = await buildProductSummary(imageDesc, manualText, signal); - onStepDone("summary"); + // ── Step: summary ───────────────────────────────────── + if (!progress.summary) { + onStepStart("summary"); + progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal); + onStepDone("summary"); + emit(); + } - onStepStart("selling"); - const selling = await extractSellingPoints(summary, signal); - onStepDone("selling"); + // ── Step: selling ───────────────────────────────────── + if (!progress.selling) { + onStepStart("selling"); + progress.selling = await extractSellingPoints(progress.summary, signal); + onStepDone("selling"); + emit(); + } - onStepStart("creative"); - const creatives = await generateCreativeOptions(selling, config, signal); - if (!creatives.length) throw new Error("未能生成有效的广告创意"); - onStepDone("creative"); + // ── Step: creative ──────────────────────────────────── + if (!progress.creatives?.length) { + onStepStart("creative"); + progress.creatives = await generateCreativeOptions(progress.selling, config, signal); + if (!progress.creatives.length) throw new Error("未能生成有效的广告创意"); + onStepDone("creative"); + emit(); + } - onStepStart("storyboard"); - const storyboard = await generateStoryboard(creatives[0], summary, config, signal); - onStepDone("storyboard"); + // ── Step: storyboard ────────────────────────────────── + if (!progress.storyboard) { + onStepStart("storyboard"); + progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal); + onStepDone("storyboard"); + emit(); + } - onStepStart("prompts"); - const videoPrompts = await generateVideoPrompts(storyboard, summary, signal); - onStepDone("prompts"); + // ── Step: prompts ───────────────────────────────────── + if (!progress.videoPrompts) { + onStepStart("prompts"); + progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal); + onStepDone("prompts"); + emit(); + } - onStepStart("compliance"); - const compliance = await checkCompliance(summary, selling, storyboard, signal); - onStepDone("compliance"); + // ── Step: compliance ────────────────────────────────── + if (!progress.compliance) { + onStepStart("compliance"); + progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal); + onStepDone("compliance"); + emit(); + } - return { imageUrls, summary, selling, creatives, storyboard, videoPrompts, compliance }; + return { + imageUrls: progress.imageUrls!, + imageDescription: progress.imageDescription, + summary: progress.summary!, + selling: progress.selling!, + creatives: progress.creatives!, + storyboard: progress.storyboard!, + videoPrompts: progress.videoPrompts!, + compliance: progress.compliance!, + }; } export interface RenderSceneImageInput { diff --git a/src/features/ecommerce/ecommerceVideoTypes.ts b/src/features/ecommerce/ecommerceVideoTypes.ts index 445c7e0..9d8fbd8 100644 --- a/src/features/ecommerce/ecommerceVideoTypes.ts +++ b/src/features/ecommerce/ecommerceVideoTypes.ts @@ -36,6 +36,7 @@ export interface EcommerceVideoSceneTask { export interface EcommerceVideoPlanResult { imageUrls: string[]; + imageDescription?: string; summary: ProductSummary; selling: SellingPointResult; creatives: CreativeOption[]; @@ -44,6 +45,18 @@ export interface EcommerceVideoPlanResult { compliance: ComplianceCheck; } +/** Partial plan state — used as resume input when an earlier run failed mid-flow. */ +export interface EcommerceVideoPlanProgress { + imageUrls?: string[]; + imageDescription?: string; + summary?: ProductSummary; + selling?: SellingPointResult; + creatives?: CreativeOption[]; + storyboard?: Storyboard; + videoPrompts?: VideoPrompt[]; + compliance?: ComplianceCheck; +} + export interface EcommerceVideoDelivery { planResult: EcommerceVideoPlanResult | null; scenes: EcommerceVideoSceneTask[]; diff --git a/src/styles/index.css b/src/styles/index.css index 781f5ab..e46fa73 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -29,6 +29,7 @@ @import "./pages/compliance.css"; @import "./pages/provider-health.css"; @import "./pages/legacy-pages.css"; +@import "./pages/not-found.css"; @import "./components/recharge-modal.css"; @import "./components/dropzone.css"; @import "./components/skeleton.css"; diff --git a/src/styles/pages/not-found.css b/src/styles/pages/not-found.css new file mode 100644 index 0000000..e704183 --- /dev/null +++ b/src/styles/pages/not-found.css @@ -0,0 +1,56 @@ +.not-found-page { + display: flex; + align-items: center; + justify-content: center; + min-height: calc(100vh - 60px); + padding: 48px 24px; + background: var(--app-bg, #0b0b0f); +} + +.not-found-page__content { + text-align: center; + max-width: 420px; +} + +.not-found-page__code { + font-size: 96px; + font-weight: 800; + line-height: 1; + letter-spacing: -0.03em; + color: var(--accent-teal, #2dd4bf); + margin-bottom: 12px; +} + +.not-found-page h1 { + font-size: 22px; + font-weight: 600; + color: var(--text-primary, #f1f5f9); + margin: 0 0 8px; +} + +.not-found-page p { + font-size: 14px; + color: var(--text-secondary, #94a3b8); + margin: 0 0 28px; + line-height: 1.6; +} + +.not-found-page__button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 24px; + border: 1px solid var(--border-default, #334155); + border-radius: 8px; + background: var(--surface-elevated, #1e293b); + color: var(--text-primary, #f1f5f9); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} + +.not-found-page__button:hover { + background: var(--surface-hover, #334155); + border-color: var(--accent-teal, #2dd4bf); +} diff --git a/src/types.ts b/src/types.ts index 5eb1580..4eeaf26 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,7 +26,8 @@ export type WebViewKey = | "communityReview" | "communityCaseAdd" | "report" - | "providerHealth"; + | "providerHealth" + | "not-found"; export type WebImageWorkbenchTool = "workbench" | "inpaint" | "camera";