diff --git a/src/api/aiGenerationClient.ts b/src/api/aiGenerationClient.ts index bfdc128..47aaeae 100644 --- a/src/api/aiGenerationClient.ts +++ b/src/api/aiGenerationClient.ts @@ -44,6 +44,7 @@ export interface ImageProviderDebug { export interface ImageTaskCreateResponse { taskId: string; + resultUrl?: string | null; providerDebug?: ImageProviderDebug; } @@ -97,6 +98,7 @@ export interface ImageEditInput { prompt?: string; maskUrl?: string; ratio?: string; + referenceUrls?: string[]; n?: number; } @@ -126,7 +128,7 @@ export type ChatMessageContent = | Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>; export interface ChatInput { - model: string; + model?: string; messages: Array<{ role: string; content: ChatMessageContent }>; stream?: boolean; temperature?: number; diff --git a/src/api/dtoParsers.test.ts b/src/api/dtoParsers.test.ts index 6d406eb..662ec9b 100644 --- a/src/api/dtoParsers.test.ts +++ b/src/api/dtoParsers.test.ts @@ -110,6 +110,15 @@ describe("parseImageTaskCreateResponse", () => { expect(result.providerDebug).toBeUndefined(); }); + it("extracts immediate image result URLs", () => { + const result = parseImageTaskCreateResponse({ + taskId: "img-sync", + result_url: "https://example.com/result.png", + }); + expect(result.taskId).toBe("img-sync"); + expect(result.resultUrl).toBe("https://example.com/result.png"); + }); + it("tolerates snake_case providerDebug fields", () => { const result = parseImageTaskCreateResponse({ taskId: "img-3", diff --git a/src/api/dtoParsers.ts b/src/api/dtoParsers.ts index 66d3a03..ae2ff48 100644 --- a/src/api/dtoParsers.ts +++ b/src/api/dtoParsers.ts @@ -130,8 +130,13 @@ export function parseTaskCreateResponse(payload: unknown): { taskId: string } { export function parseImageTaskCreateResponse(payload: unknown): ImageTaskCreateResponse { const base = parseTaskCreateResponse(payload); const body = isRecord(payload) ? payload : {}; + const resultUrl = toNullableString(body.resultUrl ?? body.result_url); const providerDebug = normalizeProviderDebug(body.providerDebug ?? body.provider_debug); - return providerDebug ? { ...base, providerDebug } : base; + return { + ...base, + resultUrl, + ...(providerDebug ? { providerDebug } : {}), + }; } /** diff --git a/src/api/generationRecordClient.ts b/src/api/generationRecordClient.ts index 9e376d9..52060a0 100644 --- a/src/api/generationRecordClient.ts +++ b/src/api/generationRecordClient.ts @@ -38,6 +38,39 @@ export interface SaveGenerationRecordResult { id: string; } +export interface GenerationRecord { + id: string; + clientRecordId: string; + tool: string; + mode?: string; + title: string; + status: GenerationRecordStatus; + prompt?: string; + taskIds: string[]; + assets: GenerationRecordAsset[]; + config: Record; + result: Record; + metadata: Record; + createdAt: string; + updatedAt: string; +} + +export interface ListGenerationRecordsParams { + tool?: string; + mode?: string; + status?: GenerationRecordStatus; + q?: string; + limit?: number; + offset?: number; +} + +export interface ListGenerationRecordsResult { + items: GenerationRecord[]; + total: number; + limit: number; + offset: number; +} + // 同一 clientRecordId 的保存去重:套图主流程、backgroundTaskRunner、useGenerationTasks // 三处都可能对同一条终态任务调用 saveGenerationRecord,SSE 重复推送 completed 时 // 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截, @@ -185,6 +218,23 @@ export async function flushPendingGenerationRecords(): Promise<{ synced: number; return { synced, remaining: remaining.length }; } +export async function listGenerationRecords(params: ListGenerationRecordsParams = {}): Promise { + const search = new URLSearchParams(); + if (params.tool) search.set("tool", params.tool); + if (params.mode) search.set("mode", params.mode); + if (params.status) search.set("status", params.status); + if (params.q) search.set("q", params.q); + if (params.limit !== undefined) search.set("limit", String(params.limit)); + if (params.offset !== undefined) search.set("offset", String(params.offset)); + + const suffix = search.toString(); + return serverRequest(`ai/generation-records${suffix ? `?${suffix}` : ""}`, { + method: "GET", + maxRetries: 1, + fallbackMessage: "Failed to load generation records", + }); +} + export async function deleteGenerationRecordByClientId(clientRecordId: string): Promise { await serverRequest<{ success: boolean }>(`ai/generation-records/by-client-id/${encodeURIComponent(clientRecordId)}`, { method: "DELETE", diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 36bf1b3..9390eec 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -49,7 +49,7 @@ import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel"; import EcommerceClonePanel from "./panels/EcommerceClonePanel"; import EcommerceCopywritingPanel from "./panels/EcommerceCopywritingPanel"; import EcommerceOneClickVideoPanel from "./panels/EcommerceOneClickVideoPanel"; -import { ecommerceOssScopes, saveUnifiedEcommerceGenerationRecord, deleteEcommerceGenerationRecord } from "./ecommerceGenerationPersistence"; +import { ecommerceOssScopes, listEcommerceGenerationHistory, saveUnifiedEcommerceGenerationRecord, deleteEcommerceGenerationRecord } from "./ecommerceGenerationPersistence"; import { downloadResultAsset } from "../workbench/workbenchDownload"; import { defaultCloneOutput, @@ -261,6 +261,10 @@ interface ProductClonePageProps { type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed"; type CommerceScenarioKey = "popular" | "poster" | "mainImage" | "scene" | "festival" | "model" | "background" | "retouch" | "salesVideo"; +type CommerceDefaultImageScenarioKey = Exclude; +type CommerceDefaultIntent = + | { kind: "image"; scenario: CommerceDefaultImageScenarioKey } + | { kind: "video"; scenario: "salesVideo" }; type ProductSetStatus = "idle" | "ready" | "generating" | "done" | "failed"; type ProductKitToolKey = "set" | "detail" | "wear" | "clone"; type ComposerMenuKey = "mode" | "platform" | "language" | "ratio" | "settings" | "assetLibrary" | "workMode" | "aiWrite"; @@ -411,6 +415,64 @@ const commerceScenarioOutputMap: Record, retouch: "set", salesVideo: "video", }; + +const defaultCommerceIntentFallback: CommerceDefaultIntent = { kind: "image", scenario: "mainImage" }; + +const normalizeDefaultCommerceIntent = (value: unknown): CommerceDefaultIntent => { + if (!value || typeof value !== "object") return defaultCommerceIntentFallback; + const record = value as Record; + const kind = record.kind === "video" ? "video" : "image"; + const scenario = typeof record.scenario === "string" ? record.scenario : ""; + if (kind === "video" || scenario === "salesVideo") return { kind: "video", scenario: "salesVideo" }; + const imageScenarios: CommerceDefaultImageScenarioKey[] = ["poster", "mainImage", "scene", "festival", "model", "background", "retouch"]; + return imageScenarios.includes(scenario as CommerceDefaultImageScenarioKey) + ? { kind: "image", scenario: scenario as CommerceDefaultImageScenarioKey } + : defaultCommerceIntentFallback; +}; + +const commerceScenarioGenerationKind = (scenario: CommerceDefaultImageScenarioKey): "singleImage" | "imageEdit" => + scenario === "background" || scenario === "retouch" ? "imageEdit" : "singleImage"; + +const classifyDefaultCommerceIntent = async (input: { + prompt: string; + referenceCount: number; + ratio: string; + language: string; + platform: string; +}): Promise => { + const content = [ + "Classify this ecommerce creative request. Return only compact JSON.", + 'Schema: {"kind":"image"|"video","scenario":"poster"|"mainImage"|"scene"|"festival"|"model"|"background"|"retouch"|"salesVideo"}.', + "Use salesVideo for video, short-video, UGC, storyboard, or product-demo motion requests.", + "Use background for changing/replacing a product image background.", + "Use retouch for inpainting, cleanup, seamless edit, repair, or localized image modification.", + "Use model for try-on, human model, wearable, or mannequin requests.", + "Use poster for campaign posters, sale posters, banners, or marketing layouts.", + "Use scene for lifestyle/usage environment images.", + "Use festival for holiday/seasonal style images.", + "Use mainImage for product hero/main image requests or unclear image requests.", + `Prompt: ${input.prompt || "(empty)"}`, + `Reference image count: ${input.referenceCount}`, + `Platform: ${input.platform}`, + `Ratio: ${input.ratio}`, + `Language: ${input.language}`, + ].join("\n"); + + try { + const text = await aiGenerationClient.chatCompletion({ + messages: [ + { role: "system", content: "You are a strict ecommerce creative intent classifier. Respond with JSON only." }, + { role: "user", content }, + ], + stream: false, + temperature: 0, + }); + const jsonMatch = text.match(/\{[\s\S]*\}/); + return normalizeDefaultCommerceIntent(JSON.parse(jsonMatch?.[0] || text)); + } catch { + return defaultCommerceIntentFallback; + } +}; const commerceScenarioTemplates: CommerceScenarioTemplate[] = [ { id: "poster-campaign-clean", @@ -1007,6 +1069,20 @@ function clampCloneVideoDuration(value: number) { return Math.min(cloneVideoDurationMax, Math.max(cloneVideoDurationMin, Math.round(value))); } +function mergeEcommerceHistoryRecords(...recordGroups: EcommerceHistoryRecord[][]): EcommerceHistoryRecord[] { + const recordsById = new Map(); + for (const records of recordGroups) { + for (const record of records) { + const normalized = normalizeEcommerceHistoryRecord(record); + const existing = recordsById.get(normalized.id); + if (!existing || normalized.createdAt >= existing.createdAt || normalized.turns?.length !== existing.turns?.length) { + recordsById.set(normalized.id, normalized); + } + } + } + return Array.from(recordsById.values()).sort((a, b) => b.createdAt - a.createdAt).slice(0, 30); +} + function ProductClonePage(_props: ProductClonePageProps = {}) { const setInputRef = useRef(null); const productInputRef = useRef(null); @@ -1110,6 +1186,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [videoHistoryVisible, setVideoHistoryVisible] = useState(false); const [isVideoWorkspaceVisible, setIsVideoWorkspaceVisible] = useState(false); const [videoPlanTrigger, setVideoPlanTrigger] = useState(0); + const [isDefaultIntentRouting, setIsDefaultIntentRouting] = useState(false); const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState(null); const [openQuickSetSelect, setOpenQuickSetSelect] = useState(null); const [visibleQuickSetSelect, setVisibleQuickSetSelect] = useState(null); @@ -1551,6 +1628,27 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { useEffect(() => { writeEcommerceHistoryRecords(ecommerceHistoryRecords); }, [ecommerceHistoryRecords]); + + useEffect(() => { + if (!isAuthenticated) return; + let cancelled = false; + void listEcommerceGenerationHistory(30) + .then((serverRecords) => { + if (cancelled) return; + setEcommerceHistoryRecords((current) => { + const mergedRecords = mergeEcommerceHistoryRecords(serverRecords, current, readEcommerceHistoryRecords()); + writeEcommerceHistoryRecords(mergedRecords); + return mergedRecords; + }); + }) + .catch(() => { + // Local history remains available when the server list endpoint is offline. + }); + return () => { + cancelled = true; + }; + }, [isAuthenticated]); + const [customScene, setCustomScene] = useState(""); const [smartScene, setSmartScene] = useState(false); const [tryOnRatio, setTryOnRatio] = useState(tryOnRatioOptions[0]); @@ -3510,6 +3608,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return urls; }; + const withStableSourceImage = (images: CloneImageItem[], sourceUrl?: string): CloneImageItem[] => { + if (!sourceUrl || !images.length) return images; + return images.map((image, index) => (index === 0 ? { ...image, src: sourceUrl } : image)); + }; + const setCountLabels: Record = { selling: { label: "卖点图", promptDesc: "selling-point infographic image highlighting core product advantages and detail close-ups" }, white: { label: "白底图", promptDesc: "clean white-background product photo showing the item from its best angle, studio lighting, no props" }, @@ -3589,6 +3692,125 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return parts.join(" "); }; + const buildCommerceScenarioImagePrompt = ( + scenario: CommerceDefaultImageScenarioKey, + userText: string, + pPlatform: string, + pRatio: string, + pLanguage: string, + pMarket: string, + ): string => { + const parts: string[] = []; + const scenarioPrompts: Record = { + poster: "Generate one ecommerce campaign poster image with clear product focus, promotional hierarchy, and polished marketing layout.", + mainImage: "Generate one high-conversion ecommerce product main image. Keep the product accurate, clear, and platform-ready.", + scene: "Generate one realistic ecommerce lifestyle scene image. Preserve the product appearance and place it in a suitable usage environment.", + festival: "Generate one ecommerce product image with a tasteful holiday or seasonal marketing style.", + model: "Generate one ecommerce model or try-on image that naturally presents the product on or near a suitable model.", + background: "Replace or rebuild the product image background. Preserve the product exactly and use the user's prompt or extra reference image as background guidance.", + retouch: "Perform a seamless ecommerce image edit. Preserve the product identity while applying the user's requested local cleanup or refinement.", + }; + parts.push(scenarioPrompts[scenario]); + parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`); + parts.push("Output a single image only."); + if (userText.trim()) parts.push(`User request: ${userText.trim()}`); + return parts.join(" "); + }; + + const generateCommerceScenarioImage = async ( + scenario: CommerceDefaultImageScenarioKey, + images: CloneImageItem[], + userText: string, + pPlatform: string, + pRatio: string, + pLanguage: string, + pMarket: string, + statusFn: (status: "generating" | "done" | "idle" | "failed") => void, + resultFn: (results: CloneResult[], sourceUrl?: string) => void, + ): Promise => { + statusFn("generating"); + try { + const uploadedUrls = await uploadCloneImages(images); + if (!uploadedUrls.length) { + statusFn("idle"); + return; + } + if (imageAbortRef.current.current) { + statusFn("idle"); + return; + } + + const prompt = buildCommerceScenarioImagePrompt(scenario, userText, pPlatform, pRatio, pLanguage, pMarket); + const stamp = Date.now(); + const label = commerceScenarioOptions.find((option) => option.key === scenario)?.label || selectedCloneOutput.label; + setGenerationProgress(0); + + const imageTask = scenario === "background" || scenario === "retouch" + ? await aiGenerationClient.createImageEditTask({ + imageUrl: uploadedUrls[0]!, + function: scenario === "background" ? "background-replace" : "retouch", + prompt, + ratio: normalizeRatioForApi(pRatio), + referenceUrls: uploadedUrls.slice(1), + }) + : await aiGenerationClient.createImageTask({ + prompt, + ratio: normalizeRatioForApi(pRatio), + quality: pRatio.includes("720") ? "720P" : "1080P", + gridMode: "single", + referenceUrls: uploadedUrls, + }); + const { taskId } = imageTask; + const storeId = imageGen.submitTask({ title: label, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId }); + + const immediateResultUrl = (imageTask as { resultUrl?: string | null }).resultUrl; + let resultUrl: string | null = immediateResultUrl ?? null; + if (!resultUrl) { + trackEcommerceTask(taskId); + try { + resultUrl = await waitForTask(taskId, { + kind: "image", + abortRef: imageAbortRef.current, + onProgress: (event) => { + const sub = Math.max(0, Math.min(100, Number(event.progress) || 0)); + setGenerationProgress(Math.round(Math.min(99, sub))); + }, + }); + } finally { + untrackEcommerceTask(taskId); + } + } else { + setGenerationProgress(100); + } + + if (imageAbortRef.current.current) { + statusFn("idle"); + return; + } + + if (resultUrl) { + const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult(scenario), `ecommerce-${scenario}`); + resultFn([{ id: `scenario-${scenario}-${stamp}`, src: persistedUrl, label }], uploadedUrls[0]); + statusFn("done"); + imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl }); + } else { + statusFn("failed"); + imageGen.updateTask(storeId, { status: "failed", error: "No image result returned" }); + } + } catch (err) { + if (imageAbortRef.current.current) { + statusFn("idle"); + return; + } + if (err instanceof ServerRequestError && err.status === 402) { + toast.error("余额不足,请充值后继续"); + } else { + toast.error(err instanceof Error ? err.message : "生成失败"); + } + statusFn("failed"); + } + }; + const generateSetImages = async ( images: CloneImageItem[], counts: Record, @@ -3598,7 +3820,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { pLanguage: string, pMarket: string, setStatusFn: (status: "generating" | "done" | "idle" | "failed") => void, - setResultFn: (urls: string[]) => void, + setResultFn: (urls: string[], sourceUrl?: string) => void, ): Promise => { setStatusFn("generating"); try { @@ -3626,31 +3848,35 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const subPrompt = buildSetSubPrompt(countKey, i, count, pPlatform, pRatio, pLanguage, pMarket); const fullPrompt = userText.trim() ? `${subPrompt} Additional user requirements: ${userText.trim()}` : subPrompt; - const { taskId } = await aiGenerationClient.createImageTask({ + const imageTask = await aiGenerationClient.createImageTask({ prompt: fullPrompt, ratio: normalizeRatioForApi(pRatio), quality: pRatio.includes("720") ? "720P" : "1080P", gridMode: "single", referenceUrls, }); - trackEcommerceTask(taskId); + const { taskId } = imageTask; const storeId = imageGen.submitTask({ title: `${setCountLabels[countKey].label} ${i + 1}`, type: "image", status: "running", progress: 5, prompt: fullPrompt, sourceView: "ecommerce", taskId }); - let resultUrl: string | null = null; - try { - resultUrl = await waitForTask(taskId, { - kind: "image", - abortRef: imageAbortRef.current, - onProgress: (event) => { - // 整体进度 = (已完成张数 + 当前张子进度) / 总张数。 - const sub = Math.max(0, Math.min(100, Number(event.progress) || 0)); - const overall = ((completedCount + sub / 100) / totalCount) * 100; - setGenerationProgress(Math.round(Math.min(99, overall))); - }, - }); - } finally { - untrackEcommerceTask(taskId); + let resultUrl: string | null = imageTask.resultUrl ?? null; + if (!resultUrl) { + trackEcommerceTask(taskId); + try { + resultUrl = await waitForTask(taskId, { + kind: "image", + abortRef: imageAbortRef.current, + onProgress: (event) => { + const sub = Math.max(0, Math.min(100, Number(event.progress) || 0)); + const overall = ((completedCount + sub / 100) / totalCount) * 100; + setGenerationProgress(Math.round(Math.min(99, overall))); + }, + }); + } finally { + untrackEcommerceTask(taskId); + } + } else { + setGenerationProgress(Math.round(Math.min(99, ((completedCount + 1) / totalCount) * 100))); } if (imageAbortRef.current.current) break; @@ -3672,7 +3898,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setStatusFn("idle"); return; } - setResultFn(generatedUrls); + setResultFn(generatedUrls, referenceUrls[0]); setStatusFn(generatedUrls.some(Boolean) ? "done" : "failed"); } catch (err) { if (imageAbortRef.current.current) { @@ -3700,7 +3926,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { pMarket: string, tryOnOptions?: EcommerceImagePromptOptions, statusFn?: (status: "generating" | "done" | "idle" | "failed") => void, - resultFn?: (results: CloneResult[]) => void, + resultFn?: (results: CloneResult[], sourceUrl?: string) => void, ): Promise => { statusFn?.("generating"); try { @@ -3718,29 +3944,35 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const stamp = Date.now(); setGenerationProgress(0); - const { taskId } = await aiGenerationClient.createImageTask({ + const imageTask = await aiGenerationClient.createImageTask({ prompt, ratio: normalizeRatioForApi(pRatio), quality: pRatio.includes("720") ? "720P" : "1080P", gridMode: "single", referenceUrls, }); - trackEcommerceTask(taskId); + const { taskId } = imageTask; - const storeId = imageGen.submitTask({ title: `电商${outputKey}图`, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId }); + const outputLabel = cloneOutputOptions.find((option) => option.key === outputKey)?.label || selectedCloneOutput.label; + const storeId = imageGen.submitTask({ title: outputLabel, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId }); - let resultUrl: string | null = null; - try { - resultUrl = await waitForTask(taskId, { - kind: "image", - abortRef: imageAbortRef.current, - onProgress: (event) => { - const sub = Math.max(0, Math.min(100, Number(event.progress) || 0)); - setGenerationProgress(Math.round(Math.min(99, sub))); - }, - }); - } finally { - untrackEcommerceTask(taskId); + let resultUrl: string | null = imageTask.resultUrl ?? null; + if (!resultUrl) { + trackEcommerceTask(taskId); + try { + resultUrl = await waitForTask(taskId, { + kind: "image", + abortRef: imageAbortRef.current, + onProgress: (event) => { + const sub = Math.max(0, Math.min(100, Number(event.progress) || 0)); + setGenerationProgress(Math.round(Math.min(99, sub))); + }, + }); + } finally { + untrackEcommerceTask(taskId); + } + } else { + setGenerationProgress(100); } if (imageAbortRef.current.current) { @@ -3750,7 +3982,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { if (resultUrl) { const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult(outputKey), `ecommerce-${outputKey}`); - resultFn?.([{ id: `ecommerce-${stamp}`, src: persistedUrl, label: selectedCloneOutput.label }]); + resultFn?.([{ id: `ecommerce-${stamp}`, src: persistedUrl, label: outputLabel }], referenceUrls[0]); statusFn?.("done"); imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl }); } else { @@ -3773,7 +4005,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { } }; - const handleGenerate = () => { + const handleGenerate = (defaultIntent?: CommerceDefaultIntent) => { if (!canGenerate) return; if ((appUsage?.balanceCents ?? 0) <= 0) { @@ -3781,7 +4013,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return; } - if (cloneOutput === "set" && cloneSetTotal > 5) { + const explicitImageScenario = + activeCommerceScenario && activeCommerceScenario !== "popular" && activeCommerceScenario !== "salesVideo" + ? activeCommerceScenario + : null; + const routedScenario = defaultIntent?.kind === "image" ? defaultIntent.scenario : explicitImageScenario; + const effectiveOutput = routedScenario ? commerceScenarioOutputMap[routedScenario] : cloneOutput; + const shouldConfirmSetCount = !defaultIntent && activeCommerceScenario !== "popular" && effectiveOutput === "set" && cloneSetTotal > 5; + if (shouldConfirmSetCount) { if (!window.confirm("将生成 " + String(cloneSetTotal) + " 张图片,可能消耗较多积分,是否继续?")) return; } @@ -3798,7 +4037,71 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setPreviewZoom(1); setPreviewOffset({ x: 0, y: 0 }); previewOffsetRef.current = { x: 0, y: 0 }; - if (cloneOutput === "set") { + if (defaultIntent?.kind === "video") { + handleStartVideoPlan(); + return; + } + + if (routedScenario) { + const routedModeLabel = commerceScenarioOptions.find((option) => option.key === routedScenario)?.label || selectedCloneOutput.label; + const routedSettingLabel = commerceScenarioGenerationKind(routedScenario) === "imageEdit" ? "图片编辑 1张" : "单图 1张"; + const routedGenerationKind = commerceScenarioGenerationKind(routedScenario); + void generateCommerceScenarioImage( + routedScenario, productImages, requirement, + platform, ratio, language, market, + (s) => { + setStatus(s as ProductCloneStatus); + if (s === "generating") { + updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ + ...turn, + output: effectiveOutput, + modeLabel: routedModeLabel, + settingLabel: routedSettingLabel, + generationKind: routedGenerationKind, + status: "generating", + errorMessage: undefined, + })); + } else if (s === "failed") { + updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ + ...turn, + output: effectiveOutput, + modeLabel: routedModeLabel, + settingLabel: routedSettingLabel, + generationKind: routedGenerationKind, + status: "failed", + errorMessage: "生成失败,请检查网络或参数后重试。", + })); + } + }, + (newResults, sourceUrl) => { + const validResults = newResults.filter((item) => item.src); + const turnProductImages = withStableSourceImage(productImages, sourceUrl); + setResults(validResults); + updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ + ...turn, + output: effectiveOutput, + modeLabel: routedModeLabel, + settingLabel: routedSettingLabel, + generationKind: routedGenerationKind, + status: validResults.length ? "done" : "failed", + errorMessage: validResults.length ? undefined : newResults[0]?.label || "生成未返回结果", + productImages: turnProductImages, + results: validResults, + setResultImages: [], + })); + if (validResults.length && validResults[0].src) { + upsertCanvasNode({ + id: pendingTurnId, + mode: routedScenario, + sourceImage: sourceUrl || productImages[0]?.src, + results: validResults, + createdAt: Date.now(), + }); + } + }, + ); + lastFailedActionRef.current = () => handleGenerate(defaultIntent); + } else if (cloneOutput === "set") { void generateSetImages( productImages, cloneSetCounts, requirement, platform, ratio, language, market, @@ -3810,14 +4113,17 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" })); } }, - (urls) => { + (urls, sourceUrl) => { setProductSetResultImages(urls); const validUrls = urls.filter(Boolean); + const stableSourceUrl = sourceUrl || (productImages[0]?.src?.startsWith("blob:") ? undefined : productImages[0]?.src); + const turnProductImages = withStableSourceImage(productImages, stableSourceUrl); const resultCards = validUrls.map((src, i) => ({ id: `set-${Date.now()}-${i}`, src, label: `套图 ${i + 1}` })); updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: validUrls.length ? "done" : "failed", errorMessage: validUrls.length ? undefined : "生成未返回结果", + productImages: turnProductImages, setResultImages: validUrls, results: resultCards, })); @@ -3825,7 +4131,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { upsertCanvasNode({ id: pendingTurnId, mode: "set", - sourceImage: productImages[0]?.src, + sourceImage: stableSourceUrl || productImages[0]?.src, results: resultCards, createdAt: Date.now(), }); @@ -3859,13 +4165,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" })); } }, - (newResults: CloneResult[]) => { + (newResults: CloneResult[], sourceUrl?: string) => { const validResults = newResults.filter((item) => item.src); + const turnProductImages = withStableSourceImage(productImages, sourceUrl); setResults(validResults); updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: validResults.length ? "done" : "failed", errorMessage: validResults.length ? undefined : newResults[0]?.label || "生成未返回结果", + productImages: turnProductImages, results: validResults, setResultImages: [], })); @@ -3873,7 +4181,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { upsertCanvasNode({ id: pendingTurnId, mode: cloneOutput, - sourceImage: productImages[0]?.src, + sourceImage: sourceUrl || productImages[0]?.src, results: validResults, createdAt: Date.now(), }); @@ -4460,6 +4768,42 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { referenceImageCount: record.referenceImages.length, turnCount: record.turns?.length ?? 1, latestTurnId: record.turns?.[record.turns.length - 1]?.id, + modeLabel: record.modeLabel, + settingLabel: record.settingLabel, + generationKind: record.generationKind, + referenceImages: record.referenceImages.map(({ id, src, name, width, height, format, mimeType, ossKey }) => ({ + id, + src, + name, + width, + height, + format, + mimeType, + ossKey, + })), + turns: (record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)]).map((turn) => ({ + ...turn, + productImages: turn.productImages.map(({ id, src, name, width, height, format, mimeType, ossKey }) => ({ + id, + src, + name, + width, + height, + format, + mimeType, + ossKey, + })), + referenceImages: turn.referenceImages.map(({ id, src, name, width, height, format, mimeType, ossKey }) => ({ + id, + src, + name, + width, + height, + format, + mimeType, + ossKey, + })), + })), }, createdAt: new Date(record.createdAt).toISOString(), }); @@ -4494,6 +4838,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { createdAt, status: turnStatus, output: cloneOutput, + modeLabel: undefined, + settingLabel: undefined, + generationKind: cloneOutput === "video" ? "video" : cloneOutput === "set" ? "imageSet" : "singleImage", platform, market, language, @@ -4514,6 +4861,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { status: turn.status, errorMessage: turn.status === "failed" ? turn.errorMessage : undefined, output: turn.output, + modeLabel: turn.modeLabel, + settingLabel: turn.settingLabel, + generationKind: turn.generationKind, platform: turn.platform, market: turn.market, language: turn.language, @@ -4559,6 +4909,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { createdAt, status: turn.status, output: turn.output, + modeLabel: turn.modeLabel, + settingLabel: turn.settingLabel, + generationKind: turn.generationKind, platform: turn.platform, market: turn.market, language: turn.language, @@ -4668,7 +5021,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { items.push({ id: turn.id, mode: turn.output, - sourceImage: turn.productImages[0]?.src, + sourceImage: turn.productImages[0]?.src?.startsWith("blob:") ? undefined : turn.productImages[0]?.src, results: turnResults, createdAt: turn.createdAt, x: index * 420, @@ -4722,7 +5075,35 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { window.setTimeout(() => setHistoryRefreshMessage(""), 3000); }; + const refreshEcommerceHistoryFromServer = async () => { + if (historyRefreshLockRef.current) return; + historyRefreshLockRef.current = true; + setIsHistoryRefreshing(true); + setHistoryRefreshMessage("Refreshing..."); + setHistoryRefreshStamp(Date.now()); + try { + const serverRecords = isAuthenticated ? await listEcommerceGenerationHistory(30) : []; + const mergedRecords = mergeEcommerceHistoryRecords(serverRecords, ecommerceHistoryRecords, readEcommerceHistoryRecords()); + writeEcommerceHistoryRecords(mergedRecords); + setHistoryRefreshTick((tick) => tick + 1); + setEcommerceHistoryRecords(mergedRecords); + setHistoryRefreshMessage(mergedRecords.length ? "Synced " + String(mergedRecords.length) + " records" : "No history records"); + setHistoryRefreshStamp(Date.now()); + } catch { + const mergedRecords = mergeEcommerceHistoryRecords(ecommerceHistoryRecords, readEcommerceHistoryRecords()); + writeEcommerceHistoryRecords(mergedRecords); + setHistoryRefreshTick((tick) => tick + 1); + setEcommerceHistoryRecords(mergedRecords); + setHistoryRefreshMessage(mergedRecords.length ? "Loaded " + String(mergedRecords.length) + " local records" : "Server history unavailable"); + setHistoryRefreshStamp(Date.now()); + } finally { + setIsHistoryRefreshing(false); + historyRefreshLockRef.current = false; + } + + window.setTimeout(() => setHistoryRefreshMessage(""), 3000); + }; const deleteHistoryRecord = (recordId: string, event: ReactMouseEvent) => { event.stopPropagation(); const record = ecommerceHistoryRecords.find((r) => r.id === recordId); @@ -5358,7 +5739,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }; const canPlanVideo = productImages.length > 0 || requirement.trim().length > 0; - const commandGenerateDisabled = cloneOutput === "video" ? false : !canGenerate; + const isDefaultCommandRouting = activeCommerceScenario === null || activeCommerceScenario === "popular"; + const commandGenerateDisabled = isDefaultIntentRouting || (isDefaultCommandRouting ? !canPlanVideo : cloneOutput === "video" ? false : !canGenerate); function handleStartVideoPlan() { if (!canPlanVideo) { @@ -5373,11 +5755,36 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setVideoPlanTrigger((value) => value + 1); } - const handleCommandGenerate = () => { + const handleCommandGenerate = async () => { if (cloneOutput === "video") { handleStartVideoPlan(); return; } + if (isDefaultCommandRouting) { + if (!canPlanVideo) return; + setIsDefaultIntentRouting(true); + try { + const intent = await classifyDefaultCommerceIntent({ + prompt: requirement, + referenceCount: productImages.length, + ratio, + language, + platform, + }); + if (intent.kind === "video") { + handleStartVideoPlan(); + return; + } + if (!canGenerate) { + toast.info("请先上传商品图"); + return; + } + handleGenerate(intent); + } finally { + setIsDefaultIntentRouting(false); + } + return; + } handleGenerate(); }; @@ -5703,25 +6110,35 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { } }} /> - {node.sourceImage ? ( -
- - -
- ) : null} +
+ + +
@@ -7817,6 +8234,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { : [buildHistoryTurnFromRecord(activeHistoryRecord)] : []; const getHistoryTurnSettingLabel = (turn: EcommerceHistoryTurn) => { + if (turn.settingLabel) return turn.settingLabel; + if (turn.output === "set" && turn.results?.length && !turn.setResultImages?.length) { + return `单图 ${turn.results.length}张`; + } if (turn.output === "set") { const total = cloneSetCountKeys.reduce((sum, key) => sum + (turn.setCounts?.[key] ?? 0), 0); return `套图 ${total || 1}张`; @@ -7901,7 +8322,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{activeConversationTurns.map((turn, index) => { const turnResults = getTurnResults(turn); - const outputLabel = cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label; + const outputLabel = turn.modeLabel || cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label; const turnMeta = [ { label: "平台", value: turn.platform }, { label: "语种", value: turn.language }, @@ -7912,7 +8333,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return (
0 ? " clone-ai-chat-message--followup" : ""}`}> - {index === 0 ? "需求" : `继续生成 ${index + 1}`} + {index === 0 ? "需求" : `继续生成 ${index + 1} · ${outputLabel}`}

{turn.requirement?.trim() || "上传商品素材,描述你想生成的商品图、详情图、模特图或短视频。"}

{turnMeta.map((item) => ( @@ -7999,7 +8420,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { onToggleCollapsed={() => setIsCommandHistoryCollapsed((current) => !current)} onCollapse={() => setIsCommandHistoryCollapsed(true)} onNewConversation={handleNewEcommerceConversation} - onRefresh={refreshEcommerceHistory} + onRefresh={refreshEcommerceHistoryFromServer} onOpenRecord={openEcommerceHistoryRecord} onDeleteRecord={deleteHistoryRecord} /> diff --git a/src/features/ecommerce/ecommerceGenerationPersistence.ts b/src/features/ecommerce/ecommerceGenerationPersistence.ts index 19b1379..8c549f9 100644 --- a/src/features/ecommerce/ecommerceGenerationPersistence.ts +++ b/src/features/ecommerce/ecommerceGenerationPersistence.ts @@ -1,10 +1,37 @@ import { buildGenerationOssScope, deleteGenerationRecordByClientId, + listGenerationRecords, saveGenerationRecord, + type GenerationRecord, type GenerationRecordAsset, type SaveGenerationRecordInput, } from "../../api/generationRecordClient"; +import { + defaultCloneDetailModuleIds, + defaultCloneSetCounts, + ecommerceHistoryStorageKey, + normalizeEcommerceHistoryRecord, + type CloneImageItem, + type CloneReplicateLevelKey, + type CloneResult, + type CloneSetCountKey, + type EcommerceHistoryRecord, + type EcommerceHistoryStatus, + type EcommerceHistoryTurn, +} from "./utils/clonePersistence"; +import { + defaultCloneOutput, + defaultEcommercePlatform, + getPlatformDefaultLanguage, + getPlatformDefaultRatio, + marketOptions, + type CloneOutputKey, + normalizeLanguageForPlatform, + normalizeMarket, + normalizePlatform, + normalizeRatioForPlatform, +} from "./utils/platformRules"; export const ecommerceOssScopes = { productSource: buildGenerationOssScope(["ecommerce", "source", "product"]), @@ -68,3 +95,237 @@ export function saveUnifiedEcommerceGenerationRecord(input: EcommerceUnifiedReco export async function deleteEcommerceGenerationRecord(clientRecordId: string): Promise { await deleteGenerationRecordByClientId(clientRecordId); } + +const ecommerceHistoryStatuses = new Set(["generating", "done", "failed"]); +const cloneOutputs = new Set(["set", "detail", "model", "video", "hot"]); +const generationKinds = new Set(["singleImage", "imageEdit", "imageSet", "video"]); +const replicateLevels = new Set(["style", "high"]); + +function stringValue(value: unknown, fallback = ""): string { + return typeof value === "string" && value.trim() ? value : fallback; +} + +function numberValue(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function objectValue(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function stringArrayValue(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && Boolean(item.trim())) : []; +} + +function normalizeOutput(value: unknown): CloneOutputKey { + if (value === "short-video") return "video"; + return cloneOutputs.has(value as CloneOutputKey) ? value as CloneOutputKey : defaultCloneOutput; +} + +function normalizeStatus(value: unknown): EcommerceHistoryStatus { + if (value === "completed") return "done"; + return ecommerceHistoryStatuses.has(value as EcommerceHistoryStatus) ? value as EcommerceHistoryStatus : "done"; +} + +function normalizeGenerationKind(value: unknown, output: CloneOutputKey): EcommerceHistoryTurn["generationKind"] { + if (generationKinds.has(value as EcommerceHistoryTurn["generationKind"])) return value as EcommerceHistoryTurn["generationKind"]; + if (output === "video") return "video"; + if (output === "set") return "imageSet"; + return "singleImage"; +} + +function normalizeReplicateLevel(value: unknown): CloneReplicateLevelKey { + return replicateLevels.has(value as CloneReplicateLevelKey) ? value as CloneReplicateLevelKey : "high"; +} + +function normalizeSetCounts(value: unknown): Record { + const counts = objectValue(value); + return { + selling: numberValue(counts.selling, defaultCloneSetCounts.selling), + white: numberValue(counts.white, defaultCloneSetCounts.white), + scene: numberValue(counts.scene, defaultCloneSetCounts.scene), + }; +} + +function timestampValue(value: unknown, fallback: number): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = new Date(value).getTime(); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +} + +function imageFromAsset(asset: GenerationRecordAsset, index: number): CloneImageItem { + return { + id: stringValue(asset.taskId, `server-source-${index + 1}`), + src: asset.url, + name: stringValue(asset.label, `source-${index + 1}`), + ossKey: asset.ossKey || undefined, + }; +} + +function resultFromAsset(asset: GenerationRecordAsset, index: number): CloneResult { + return { + id: stringValue(asset.taskId, `server-result-${index + 1}`), + src: asset.url, + label: stringValue(asset.label, `result-${index + 1}`), + type: asset.mediaType === "video" ? "video" : "image", + }; +} + +function normalizeHistoryImages(value: unknown, fallback: CloneImageItem[] = []): CloneImageItem[] { + if (!Array.isArray(value)) return fallback; + return value + .map((item, index): CloneImageItem | null => { + const record = objectValue(item); + const src = stringValue(record.src); + if (!src) return null; + return { + id: stringValue(record.id, `server-image-${index + 1}`), + src, + name: stringValue(record.name, `image-${index + 1}`), + width: typeof record.width === "number" ? record.width : undefined, + height: typeof record.height === "number" ? record.height : undefined, + format: typeof record.format === "string" ? record.format : undefined, + mimeType: typeof record.mimeType === "string" ? record.mimeType : undefined, + ossKey: typeof record.ossKey === "string" ? record.ossKey : undefined, + }; + }) + .filter((item): item is CloneImageItem => Boolean(item)); +} + +function normalizeHistoryResults(value: unknown, fallback: CloneResult[] = []): CloneResult[] { + if (!Array.isArray(value)) return fallback; + return value + .map((item, index): CloneResult | null => { + const record = objectValue(item); + const src = stringValue(record.src); + if (!src) return null; + return { + id: stringValue(record.id, `server-result-${index + 1}`), + src, + label: stringValue(record.label, `result-${index + 1}`), + type: record.type === "video" ? "video" : "image", + }; + }) + .filter((item): item is CloneResult => Boolean(item)); +} + +function buildTurnFromMetadata(value: unknown, fallback: Omit, fallbackCreatedAt: number, index: number): EcommerceHistoryTurn | null { + const turn = objectValue(value); + if (!Object.keys(turn).length) return null; + const output = normalizeOutput(turn.output ?? fallback.output); + const platform = normalizePlatform(stringValue(turn.platform, fallback.platform)); + const market = normalizeMarket(stringValue(turn.market, fallback.market)); + const language = normalizeLanguageForPlatform(platform, market, stringValue(turn.language, fallback.language)); + const ratio = normalizeRatioForPlatform(platform, stringValue(turn.ratio, fallback.ratio), output === "hot" ? undefined : output); + const results = normalizeHistoryResults(turn.results, fallback.results); + const setResultImages = stringArrayValue(turn.setResultImages).length ? stringArrayValue(turn.setResultImages) : fallback.setResultImages; + const status = normalizeStatus(turn.status ?? fallback.status); + return { + id: stringValue(turn.id, `server-turn-${index + 1}`), + createdAt: timestampValue(turn.createdAt, fallbackCreatedAt), + status, + errorMessage: status === "failed" ? stringValue(turn.errorMessage, fallback.errorMessage) : undefined, + output, + modeLabel: typeof turn.modeLabel === "string" ? turn.modeLabel : fallback.modeLabel, + settingLabel: typeof turn.settingLabel === "string" ? turn.settingLabel : fallback.settingLabel, + generationKind: normalizeGenerationKind(turn.generationKind ?? fallback.generationKind, output), + platform, + market, + language, + ratio, + requirement: stringValue(turn.requirement, fallback.requirement), + productImages: normalizeHistoryImages(turn.productImages, fallback.productImages), + results, + setResultImages, + setCounts: normalizeSetCounts(turn.setCounts ?? fallback.setCounts), + detailModules: stringArrayValue(turn.detailModules).length ? stringArrayValue(turn.detailModules) : fallback.detailModules, + modelScenes: stringArrayValue(turn.modelScenes).length ? stringArrayValue(turn.modelScenes) : fallback.modelScenes, + referenceImages: normalizeHistoryImages(turn.referenceImages, fallback.referenceImages), + replicateLevel: normalizeReplicateLevel(turn.replicateLevel ?? fallback.replicateLevel), + }; +} + +export function ecommerceHistoryRecordFromGenerationRecord(record: GenerationRecord): EcommerceHistoryRecord | null { + if (record.tool !== "ecommerce") return null; + + const createdAt = timestampValue(record.createdAt, Date.now()); + const output = normalizeOutput(record.mode); + const config = objectValue(record.config); + const metadata = objectValue(record.metadata); + const sourceImages = record.assets.filter((asset) => asset.role === "source").map(imageFromAsset); + const results = record.assets.filter((asset) => asset.role === "result").map(resultFromAsset); + const hasHistoryMarker = metadata.localHistoryStorageKey === ecommerceHistoryStorageKey || typeof metadata.turnCount === "number"; + if (!hasHistoryMarker && record.status !== "completed") return null; + if (!hasHistoryMarker && !sourceImages.length && !results.length) return null; + const platform = normalizePlatform(stringValue(config.platform, defaultEcommercePlatform)); + const market = normalizeMarket(stringValue(config.market, marketOptions[0])); + const language = normalizeLanguageForPlatform(platform, market, stringValue(config.language, getPlatformDefaultLanguage(platform, market))); + const ratio = normalizeRatioForPlatform(platform, stringValue(config.ratio, getPlatformDefaultRatio(platform, output === "hot" ? undefined : output)), output === "hot" ? undefined : output); + const setResultImages = results.filter((item) => item.type !== "video").map((item) => item.src); + const status = normalizeStatus(record.status); + const baseTurn: Omit = { + status, + errorMessage: status === "failed" ? "生成失败" : undefined, + output, + modeLabel: typeof metadata.modeLabel === "string" ? metadata.modeLabel : undefined, + settingLabel: typeof metadata.settingLabel === "string" ? metadata.settingLabel : undefined, + generationKind: normalizeGenerationKind(metadata.generationKind, output), + platform, + market, + language, + ratio, + requirement: record.prompt ?? "", + productImages: sourceImages, + results, + setResultImages: output === "set" ? setResultImages : [], + setCounts: normalizeSetCounts(config.setCounts), + detailModules: stringArrayValue(config.detailModules).length ? stringArrayValue(config.detailModules) : defaultCloneDetailModuleIds, + modelScenes: stringArrayValue(config.modelScenes), + referenceImages: normalizeHistoryImages(metadata.referenceImages), + replicateLevel: normalizeReplicateLevel(config.replicateLevel), + }; + const turns = Array.isArray(metadata.turns) + ? metadata.turns + .map((turn, index) => buildTurnFromMetadata(turn, baseTurn, createdAt, index)) + .filter((turn): turn is EcommerceHistoryTurn => Boolean(turn)) + : []; + const latestTurn = turns[turns.length - 1] ?? { id: `${record.clientRecordId}-turn-initial`, createdAt, ...baseTurn }; + + return normalizeEcommerceHistoryRecord({ + id: record.clientRecordId, + title: record.title || record.prompt || "生成记录", + createdAt, + status: latestTurn.status, + errorMessage: latestTurn.errorMessage, + output: latestTurn.output, + modeLabel: latestTurn.modeLabel, + settingLabel: latestTurn.settingLabel, + generationKind: latestTurn.generationKind, + platform: latestTurn.platform, + market: latestTurn.market, + language: latestTurn.language, + ratio: latestTurn.ratio, + requirement: latestTurn.requirement, + productImages: latestTurn.productImages, + results: latestTurn.results, + setResultImages: latestTurn.setResultImages, + setCounts: latestTurn.setCounts, + detailModules: latestTurn.detailModules, + modelScenes: latestTurn.modelScenes, + referenceImages: latestTurn.referenceImages, + replicateLevel: latestTurn.replicateLevel, + turns: turns.length ? turns : [latestTurn], + }); +} + +export async function listEcommerceGenerationHistory(limit = 30): Promise { + const payload = await listGenerationRecords({ tool: "ecommerce", limit }); + return payload.items + .map(ecommerceHistoryRecordFromGenerationRecord) + .filter((record): record is EcommerceHistoryRecord => Boolean(record)) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit); +} diff --git a/src/features/ecommerce/utils/clonePersistence.ts b/src/features/ecommerce/utils/clonePersistence.ts index 4f5f505..8899996 100644 --- a/src/features/ecommerce/utils/clonePersistence.ts +++ b/src/features/ecommerce/utils/clonePersistence.ts @@ -68,6 +68,9 @@ export interface EcommerceHistoryTurn { status: EcommerceHistoryStatus; errorMessage?: string; output: CloneOutputKey; + modeLabel?: string; + settingLabel?: string; + generationKind?: "singleImage" | "imageEdit" | "imageSet" | "video"; platform: string; market: string; language: string; @@ -90,6 +93,9 @@ export interface EcommerceHistoryRecord { status?: EcommerceHistoryStatus; errorMessage?: string; output: CloneOutputKey; + modeLabel?: string; + settingLabel?: string; + generationKind?: "singleImage" | "imageEdit" | "imageSet" | "video"; platform: string; market: string; language: string; @@ -189,6 +195,9 @@ export function buildHistoryTurnFromRecord(record: EcommerceHistoryRecord): Ecom status: record.status ?? "done", errorMessage: record.status === "failed" ? record.errorMessage : undefined, output: record.output, + modeLabel: record.modeLabel, + settingLabel: record.settingLabel, + generationKind: record.generationKind, platform: record.platform, market: record.market, language: record.language, @@ -213,6 +222,9 @@ export function normalizeEcommerceHistoryTurn(turn: EcommerceHistoryTurn, fallba status, errorMessage: status === "failed" ? turn.errorMessage ?? fallback.errorMessage : undefined, output: turn.output ?? fallback.output, + modeLabel: turn.modeLabel ?? fallback.modeLabel, + settingLabel: turn.settingLabel ?? fallback.settingLabel, + generationKind: turn.generationKind ?? fallback.generationKind, platform: turn.platform ?? fallback.platform, market: turn.market ?? fallback.market, language: turn.language ?? fallback.language, @@ -235,6 +247,9 @@ export function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): ...record, status, errorMessage: status === "failed" ? record.errorMessage : undefined, + modeLabel: record.modeLabel, + settingLabel: record.settingLabel, + generationKind: record.generationKind, productImages: removeFilePayloadFromImages(record.productImages), referenceImages: removeFilePayloadFromImages(record.referenceImages ?? []), results: record.results ?? [], diff --git a/src/styles/pages/ecommerce.css b/src/styles/pages/ecommerce.css index 2302139..d24b130 100644 --- a/src/styles/pages/ecommerce.css +++ b/src/styles/pages/ecommerce.css @@ -2992,6 +2992,32 @@ box-shadow: 0 10px 26px rgba(0, 255, 136, 0.14), 0 8px 22px rgba(0, 0, 0, 0.28); } +.product-clone-page[data-tool="clone"] .clone-ai-source-corner-action:disabled, +.product-clone-page[data-tool="clone"] .clone-ai-main-result:disabled { + cursor: default; +} + +.product-clone-page[data-tool="clone"] .clone-ai-source-missing { + position: static; + display: none; + border: 0; + background: transparent; + color: rgba(216, 222, 237, 0.72); + font-size: 12px; + font-weight: 900; + line-height: 1.2; + white-space: normal; +} + +.product-clone-page[data-tool="clone"] .clone-ai-main-result:disabled .clone-ai-source-missing, +.product-clone-page[data-tool="clone"] .clone-ai-main-result.is-missing-source .clone-ai-source-missing { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 44px; +} + .product-clone-page[data-tool="clone"] .clone-ai-result-grid button { width: 100%; height: auto;