Fix ecommerce generation history sync
CI / verify (pull_request) Waiting to run

This commit is contained in:
2026-06-18 10:16:40 +08:00
parent da9c5c2fca
commit a2ccf290e5
8 changed files with 860 additions and 71 deletions
+490 -69
View File
@@ -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<CommerceScenarioKey, "popular" | "salesVideo">;
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<Exclude<CommerceScenarioKey, "popular">,
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<string, unknown>;
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<CommerceDefaultIntent> => {
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<string, EcommerceHistoryRecord>();
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<HTMLInputElement>(null);
const productInputRef = useRef<HTMLInputElement>(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<CloneBasicSelectKey | null>(null);
const [openQuickSetSelect, setOpenQuickSetSelect] = useState<CloneBasicSelectKey | null>(null);
const [visibleQuickSetSelect, setVisibleQuickSetSelect] = useState<CloneBasicSelectKey | null>(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<CloneSetCountKey, { label: string; promptDesc: string }> = {
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<CommerceDefaultImageScenarioKey, string> = {
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<void> => {
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<CloneSetCountKey, number>,
@@ -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<void> => {
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<void> => {
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 ? (
<div className="clone-ai-source-stack">
<button
type="button"
className="clone-ai-source-corner-action"
onClick={() => openProductSetPreview({ src: node.sourceImage!, label: "原图素材" })}
>
</button>
<button
type="button"
className="clone-ai-main-result"
aria-label="预览原图素材"
onClick={() => openProductSetPreview({ src: node.sourceImage!, label: "原图素材" })}
>
<img src={node.sourceImage} alt="原图素材" />
</button>
</div>
) : null}
<div className="clone-ai-source-stack">
<button
type="button"
className="clone-ai-source-corner-action"
onClick={node.sourceImage ? () => openProductSetPreview({ src: node.sourceImage!, label: "原图素材" }) : undefined}
disabled={!node.sourceImage}
>
</button>
<button
type="button"
className="clone-ai-main-result"
aria-label="预览原图素材"
onClick={node.sourceImage ? () => openProductSetPreview({ src: node.sourceImage!, label: "原图素材" }) : undefined}
disabled={!node.sourceImage}
>
{node.sourceImage ? (
<img
src={node.sourceImage}
alt="原图素材"
onError={(event) => {
event.currentTarget.style.display = "none";
event.currentTarget.parentElement?.classList.add("is-missing-source");
}}
/>
) : null}
<span className="clone-ai-source-missing"></span>
</button>
</div>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-result-stack">
<span className="clone-ai-node-label">{node.mode === "set" ? "套图" : node.mode === "detail" ? "详情图" : node.mode === "model" ? "模特图" : node.mode === "hot" ? "爆款图" : node.mode}</span>
@@ -5978,7 +6395,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</div>
<div className="ecom-command-submit-row">
<button type="button" className="clone-ai-send-button ecom-command-send" disabled={commandGenerateDisabled} onClick={handleCommandGenerate} aria-label={clonePrimaryLabel}>
{status === "generating" ? <LoadingOutlined /> : <PaperPlaneRight size={18} weight="fill" />}
{status === "generating" || isDefaultIntentRouting ? <LoadingOutlined /> : <PaperPlaneRight size={18} weight="fill" />}
</button>
</div>
</div>
@@ -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 = {}) {
<div className="clone-ai-conversation-body">
{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 (
<Fragment key={turn.id}>
<section className={`clone-ai-chat-message clone-ai-chat-message--user${index > 0 ? " clone-ai-chat-message--followup" : ""}`}>
<span>{index === 0 ? "需求" : `继续生成 ${index + 1}`}</span>
<span>{index === 0 ? "需求" : `继续生成 ${index + 1} · ${outputLabel}`}</span>
<p>{turn.requirement?.trim() || "上传商品素材,描述你想生成的商品图、详情图、模特图或短视频。"}</p>
<div className="clone-ai-chat-meta" aria-label="需求参数">
{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}
/>