Merge 4993f6e: multi-turn conversation system

This commit is contained in:
2026-06-16 14:34:00 +08:00
3 changed files with 519 additions and 184 deletions
+38 -10
View File
@@ -43,15 +43,40 @@ export interface SaveGenerationRecordResult {
// 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截,
// 避免后端在缺少去重时插入重复记录。
const inFlightSaves = new Map<string, Promise<SaveGenerationRecordResult>>();
const recentlySavedAt = new Map<string, number>();
const recentlySavedRecords = new Map<string, { savedAt: number; signature: string }>();
const SAVE_DEDUPE_WINDOW_MS = 60_000;
function pruneRecentlySaved(now: number): void {
for (const [id, savedAt] of recentlySavedAt) {
if (now - savedAt > SAVE_DEDUPE_WINDOW_MS) recentlySavedAt.delete(id);
for (const [id, record] of recentlySavedRecords) {
if (now - record.savedAt > SAVE_DEDUPE_WINDOW_MS) recentlySavedRecords.delete(id);
}
}
function stableJsonStringify(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableJsonStringify).join(",")}]`;
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, entryValue]) => entryValue !== undefined)
.sort(([a], [b]) => a.localeCompare(b));
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJsonStringify(entryValue)}`).join(",")}}`;
}
function buildSaveSignature(input: SaveGenerationRecordInput): string {
return stableJsonStringify({
tool: input.tool,
mode: input.mode,
title: input.title,
status: input.status,
prompt: input.prompt,
taskIds: input.taskIds,
assets: input.assets,
config: input.config,
result: input.result,
metadata: input.metadata,
createdAt: input.createdAt,
});
}
function readPendingRecords(): SaveGenerationRecordInput[] {
try {
const raw = window.localStorage.getItem(PENDING_RECORDS_KEY);
@@ -78,26 +103,29 @@ export async function saveGenerationRecord(input: SaveGenerationRecordInput): Pr
pruneRecentlySaved(now);
const recordId = input.clientRecordId;
const signature = buildSaveSignature(input);
if (recordId) {
const inFlight = inFlightSaves.get(recordId);
const saveKey = `${recordId}:${signature}`;
const inFlight = inFlightSaves.get(saveKey);
if (inFlight) return inFlight;
const savedAt = recentlySavedAt.get(recordId);
if (savedAt !== undefined && now - savedAt <= SAVE_DEDUPE_WINDOW_MS) {
// 终态记录只需落库一次;窗口内的重复调用直接视为已保存。
const savedRecord = recentlySavedRecords.get(recordId);
if (savedRecord && savedRecord.signature === signature && now - savedRecord.savedAt <= SAVE_DEDUPE_WINDOW_MS) {
// 相同 clientRecordId 且 payload 完全一致时才拦截;同一记录的多轮更新需要继续保存。
return { source: "server", id: recordId };
}
}
const promise = saveGenerationRecordInternal(input);
if (recordId) {
inFlightSaves.set(recordId, promise);
const saveKey = `${recordId}:${signature}`;
inFlightSaves.set(saveKey, promise);
void promise
.then((result) => {
if (result.source === "server") recentlySavedAt.set(recordId, Date.now());
if (result.source === "server") recentlySavedRecords.set(recordId, { savedAt: Date.now(), signature });
})
.catch(() => undefined)
.finally(() => {
inFlightSaves.delete(recordId);
inFlightSaves.delete(saveKey);
});
}
return promise;
+480 -173
View File
@@ -25,7 +25,7 @@ import {
TableOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTypewriter } from "../../hooks/useTypewriter";
import "../../styles/pages/ecommerce.css";
@@ -363,10 +363,13 @@ interface CloneSavedSetting {
requirement: string;
}
interface EcommerceHistoryRecord {
type EcommerceHistoryStatus = "generating" | "done" | "failed";
interface EcommerceHistoryTurn {
id: string;
title: string;
createdAt: number;
status: EcommerceHistoryStatus;
errorMessage?: string;
output: CloneOutputKey;
platform: string;
market: string;
@@ -383,6 +386,29 @@ interface EcommerceHistoryRecord {
replicateLevel: CloneReplicateLevelKey;
}
interface EcommerceHistoryRecord {
id: string;
title: string;
createdAt: number;
status?: EcommerceHistoryStatus;
errorMessage?: string;
output: CloneOutputKey;
platform: string;
market: string;
language: string;
ratio: string;
requirement: string;
productImages: CloneImageItem[];
results: CloneResult[];
setResultImages: string[];
setCounts: Record<CloneSetCountKey, number>;
detailModules: string[];
modelScenes: string[];
referenceImages: CloneImageItem[];
replicateLevel: CloneReplicateLevelKey;
turns?: EcommerceHistoryTurn[];
}
interface ProductSetPreviewSelection {
src: string;
label: string;
@@ -1433,9 +1459,67 @@ function removeFilePayloadFromImages(images: CloneImageItem[]): CloneImageItem[]
}));
}
function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): EcommerceHistoryRecord {
function getTurnResults(turn: EcommerceHistoryTurn): CloneResult[] {
if (turn.results?.length) return turn.results.filter((item) => item.src);
if (turn.output !== "set") return [];
return (turn.setResultImages ?? [])
.filter(Boolean)
.map((src, index) => ({ id: `${turn.id}-set-${index}`, src, label: `套图 ${index + 1}` }));
}
function buildHistoryTurnFromRecord(record: EcommerceHistoryRecord): EcommerceHistoryTurn {
return {
id: `${record.id}-turn-initial`,
createdAt: record.createdAt,
status: record.status ?? "done",
errorMessage: record.status === "failed" ? record.errorMessage : undefined,
output: record.output,
platform: record.platform,
market: record.market,
language: record.language,
ratio: record.ratio,
requirement: record.requirement,
productImages: record.productImages ?? [],
results: record.results ?? [],
setResultImages: record.setResultImages ?? [],
setCounts: record.setCounts ?? defaultCloneSetCounts,
detailModules: record.detailModules ?? defaultCloneDetailModuleIds,
modelScenes: record.modelScenes ?? [],
referenceImages: record.referenceImages ?? [],
replicateLevel: record.replicateLevel ?? "high",
};
}
function normalizeEcommerceHistoryTurn(turn: EcommerceHistoryTurn, fallback: EcommerceHistoryRecord, index: number): EcommerceHistoryTurn {
const status = turn.status ?? fallback.status ?? "done";
return {
id: typeof turn.id === "string" && turn.id ? turn.id : `${fallback.id}-turn-${index + 1}`,
createdAt: typeof turn.createdAt === "number" ? turn.createdAt : fallback.createdAt,
status,
errorMessage: status === "failed" ? turn.errorMessage ?? fallback.errorMessage : undefined,
output: turn.output ?? fallback.output,
platform: turn.platform ?? fallback.platform,
market: turn.market ?? fallback.market,
language: turn.language ?? fallback.language,
ratio: turn.ratio ?? fallback.ratio,
requirement: turn.requirement ?? fallback.requirement,
productImages: removeFilePayloadFromImages(Array.isArray(turn.productImages) ? turn.productImages : fallback.productImages),
results: Array.isArray(turn.results) ? turn.results.filter(isCloneResult) : [],
setResultImages: Array.isArray(turn.setResultImages) ? turn.setResultImages.filter(Boolean) : [],
setCounts: turn.setCounts ?? fallback.setCounts ?? defaultCloneSetCounts,
detailModules: turn.detailModules ?? fallback.detailModules ?? defaultCloneDetailModuleIds,
modelScenes: turn.modelScenes ?? fallback.modelScenes ?? [],
referenceImages: removeFilePayloadFromImages(Array.isArray(turn.referenceImages) ? turn.referenceImages : fallback.referenceImages ?? []),
replicateLevel: turn.replicateLevel ?? fallback.replicateLevel ?? "high",
};
}
function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): EcommerceHistoryRecord {
const status = record.status ?? "done";
const baseRecord = {
...record,
status,
errorMessage: status === "failed" ? record.errorMessage : undefined,
productImages: removeFilePayloadFromImages(record.productImages),
referenceImages: removeFilePayloadFromImages(record.referenceImages ?? []),
results: record.results ?? [],
@@ -1445,6 +1529,14 @@ function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): Ecomme
modelScenes: record.modelScenes ?? [],
replicateLevel: record.replicateLevel ?? "high",
};
const rawTurns = Array.isArray(record.turns) && record.turns.length
? record.turns
: [buildHistoryTurnFromRecord(baseRecord)];
const turns = rawTurns.map((turn, index) => normalizeEcommerceHistoryTurn(turn, baseRecord, index));
return {
...baseRecord,
turns,
};
}
function readEcommerceHistoryRecords() {
@@ -1982,6 +2074,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const historyRefreshLockRef = useRef(false);
const lastSavedHistorySignatureRef = useRef("");
const imageAbortRef = useRef({ current: false });
const activeHistoryTurnIdRef = useRef<string | null>(null);
const activeEcommerceTaskIdsRef = useRef<Set<string>>(new Set());
const lastFailedActionRef = useRef<(() => void) | null>(null);
const [garmentImages, setGarmentImages] = useState<CloneImageItem[]>([]);
@@ -2113,7 +2206,25 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
});
lastFailedActionRef.current = null;
if (productSetStatus === "generating") setProductSetStatus("idle");
if (status === "generating") setStatus("idle");
if (status === "generating") {
setStatus("idle");
if (activeHistoryRecordId) {
const turnId = activeHistoryTurnIdRef.current;
if (turnId) {
updateLocalEcommerceHistoryTurn(activeHistoryRecordId, turnId, (turn) => ({
...turn,
status: "failed",
errorMessage: "已取消生成",
}));
} else {
updateLocalEcommerceHistoryRecord(activeHistoryRecordId, (record) => ({
...record,
status: "failed",
errorMessage: "已取消生成",
}));
}
}
}
if (detailStatus === "generating") setDetailStatus("idle");
if (tryOnStatus === "generating") setTryOnStatus("idle");
if (tryOnStatus === "modeling") setTryOnStatus("ready");
@@ -4118,27 +4229,50 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setIsCommandComposerCompact(true);
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
setGenerationProgress(0);
setResults([]);
setProductSetResultImages([]);
const pendingGeneration = beginEcommerceHistoryTurn();
const pendingRecordId = pendingGeneration.record.id;
const pendingTurnId = pendingGeneration.turn.id;
setPreviewZoom(1);
setPreviewOffset({ x: 0, y: 0 });
previewOffsetRef.current = { x: 0, y: 0 };
if (cloneOutput === "set") {
void generateSetImages(
productImages, cloneSetCounts, requirement,
platform, ratio, language, market,
(s) => setStatus(s as ProductCloneStatus),
(s) => {
setStatus(s as ProductCloneStatus);
if (s === "generating") {
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "generating", errorMessage: undefined }));
} else if (s === "failed") {
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" }));
}
},
(urls) => {
setProductSetResultImages(urls);
const validUrls = urls.filter(Boolean);
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 : "生成未返回结果",
setResultImages: validUrls,
results: resultCards,
}));
if (validUrls.length) {
setCanvasNodes((prev) => [...prev, {
id: `node-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
upsertCanvasNode({
id: pendingTurnId,
mode: "set",
sourceImage: productImages[0]?.src,
results: validUrls.map((src, i) => ({ id: `set-${Date.now()}-${i}`, src, label: `套图 ${i + 1}` })),
results: resultCards,
createdAt: Date.now(),
x: prev.length * 420,
y: 0,
}]);
});
}
},
);
lastFailedActionRef.current = () => handleGenerate();
} else {
const clonePromptOptions: EcommerceImagePromptOptions | undefined =
cloneOutput === "model"
@@ -4157,19 +4291,32 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
cloneOutput, productImages, requirement,
platform, ratio, language, market,
clonePromptOptions,
(s: string) => setStatus(s as ProductCloneStatus),
(s: string) => {
setStatus(s as ProductCloneStatus);
if (s === "generating") {
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "generating", errorMessage: undefined }));
} else if (s === "failed") {
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" }));
}
},
(newResults: CloneResult[]) => {
setResults(newResults);
if (newResults.length && newResults[0].src) {
setCanvasNodes((prev) => [...prev, {
id: `node-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
const validResults = newResults.filter((item) => item.src);
setResults(validResults);
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({
...turn,
status: validResults.length ? "done" : "failed",
errorMessage: validResults.length ? undefined : newResults[0]?.label || "生成未返回结果",
results: validResults,
setResultImages: [],
}));
if (validResults.length && validResults[0].src) {
upsertCanvasNode({
id: pendingTurnId,
mode: cloneOutput,
sourceImage: productImages[0]?.src,
results: newResults,
results: validResults,
createdAt: Date.now(),
x: prev.length * 420,
y: 0,
}]);
});
}
},
);
@@ -4273,6 +4420,23 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
toast.success("已从当前视图移除");
};
const upsertCanvasNode = (node: Omit<CanvasNode, "x" | "y">) => {
setCanvasNodes((current) => {
const existingIndex = current.findIndex((item) => item.id === node.id);
if (existingIndex >= 0) {
return current.map((item) => (item.id === node.id ? { ...item, ...node } : item));
}
return [
...current,
{
...node,
x: current.length * 420,
y: current.length % 2 === 0 ? 0 : 160,
},
];
});
};
const removeSelectedProductSetPreview = (preview: ProductSetPreviewSelection) => {
if (!preview.nodeId || !preview.cardId) return;
removeCanvasResult(preview.nodeId, preview.cardId);
@@ -4583,46 +4747,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
sourceImages.map((item) => item.src).join("|"),
].join("::");
const formatHistoryTime = (timestamp: number) => {
const diff = Math.max(0, Date.now() - timestamp);
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diff < minute) return "刚刚";
if (diff < hour) return String(Math.floor(diff / minute)) + " 分钟前";
if (diff < day) return String(Math.floor(diff / hour)) + " 小时前";
return String(Math.floor(diff / day)) + " 天前";
const getHistoryRecordResults = (record: EcommerceHistoryRecord) => {
const turns = record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)];
return turns.flatMap(getTurnResults);
};
const saveCurrentEcommerceHistory = () => {
const historyResults = getCurrentHistoryResults();
if (!historyResults.length) return null;
const signature = buildHistorySignature(cloneOutput, requirement, historyResults, productImages);
if (lastSavedHistorySignatureRef.current === signature && activeHistoryRecordId) return activeHistoryRecordId;
const createdAt = Date.now();
const outputLabel = cloneOutputOptions.find((option) => option.key === cloneOutput)?.label || "生成记录";
const title = requirement.trim() || outputLabel + " " + new Date(createdAt).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
const record: EcommerceHistoryRecord = {
id: crypto.randomUUID(),
title,
createdAt,
output: cloneOutput,
platform,
market,
language,
ratio,
requirement,
productImages,
results: historyResults,
setResultImages: cloneOutput === "set" ? historyResults.map((item) => item.src) : [],
setCounts: cloneSetCounts,
detailModules: selectedCloneDetailModules,
modelScenes: selectedCloneModelScenes,
referenceImages: cloneReferenceImages,
replicateLevel: cloneReplicateLevel,
};
lastSavedHistorySignatureRef.current = signature;
const persistEcommerceHistoryRecord = (record: EcommerceHistoryRecord, historyResults: CloneResult[]) => {
void saveUnifiedEcommerceGenerationRecord({
clientRecordId: record.id,
title: record.title,
@@ -4651,9 +4781,172 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
metadata: {
localHistoryStorageKey: ecommerceHistoryStorageKey,
referenceImageCount: record.referenceImages.length,
turnCount: record.turns?.length ?? 1,
latestTurnId: record.turns?.[record.turns.length - 1]?.id,
},
createdAt: new Date(record.createdAt).toISOString(),
});
};
const formatHistoryTime = (timestamp: number) => {
const diff = Math.max(0, Date.now() - timestamp);
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diff < minute) return "刚刚";
if (diff < hour) return String(Math.floor(diff / minute)) + " 分钟前";
if (diff < day) return String(Math.floor(diff / hour)) + " 小时前";
return String(Math.floor(diff / day)) + " 天前";
};
const buildEcommerceHistoryTitle = (output: CloneOutputKey, prompt: string, createdAt: number) => {
const outputLabel = cloneOutputOptions.find((option) => option.key === output)?.label || "生成记录";
return prompt.trim() || outputLabel + " " + new Date(createdAt).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
};
const updateLocalEcommerceHistoryRecord = (recordId: string, updater: (record: EcommerceHistoryRecord) => EcommerceHistoryRecord) => {
setEcommerceHistoryRecords((current) => {
const nextRecords = current.map((record) => (record.id === recordId ? normalizeEcommerceHistoryRecord(updater(record)) : record));
writeEcommerceHistoryRecords(nextRecords);
return nextRecords;
});
};
const buildCurrentEcommerceHistoryTurn = (turnId: string, createdAt: number, turnStatus: EcommerceHistoryStatus = "generating"): EcommerceHistoryTurn => ({
id: turnId,
createdAt,
status: turnStatus,
output: cloneOutput,
platform,
market,
language,
ratio,
requirement,
productImages,
results: [],
setResultImages: [],
setCounts: cloneSetCounts,
detailModules: selectedCloneDetailModules,
modelScenes: selectedCloneModelScenes,
referenceImages: cloneReferenceImages,
replicateLevel: cloneReplicateLevel,
});
const syncRecordSummaryWithTurn = (record: EcommerceHistoryRecord, turn: EcommerceHistoryTurn): EcommerceHistoryRecord => ({
...record,
status: turn.status,
errorMessage: turn.status === "failed" ? turn.errorMessage : undefined,
output: turn.output,
platform: turn.platform,
market: turn.market,
language: turn.language,
ratio: turn.ratio,
requirement: turn.requirement,
productImages: turn.productImages,
results: turn.results,
setResultImages: turn.setResultImages,
setCounts: turn.setCounts,
detailModules: turn.detailModules,
modelScenes: turn.modelScenes,
referenceImages: turn.referenceImages,
replicateLevel: turn.replicateLevel,
});
const updateLocalEcommerceHistoryTurn = (
recordId: string,
turnId: string,
updater: (turn: EcommerceHistoryTurn) => EcommerceHistoryTurn,
) => {
updateLocalEcommerceHistoryRecord(recordId, (record) => {
const turns = record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)];
let updatedTurn: EcommerceHistoryTurn | null = null;
const nextTurns = turns.map((turn) => {
if (turn.id !== turnId) return turn;
updatedTurn = normalizeEcommerceHistoryTurn(updater(turn), record, turns.indexOf(turn));
return updatedTurn;
});
return updatedTurn ? syncRecordSummaryWithTurn({ ...record, turns: nextTurns }, updatedTurn) : record;
});
};
const beginEcommerceHistoryTurn = () => {
const createdAt = Date.now();
const turn = buildCurrentEcommerceHistoryTurn(crypto.randomUUID(), createdAt);
const existingRecord = activeHistoryRecordId
? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId)
: null;
const recordId = existingRecord?.id ?? crypto.randomUUID();
const baseRecord: EcommerceHistoryRecord = existingRecord ?? {
id: recordId,
title: buildEcommerceHistoryTitle(cloneOutput, requirement, createdAt),
createdAt,
status: turn.status,
output: turn.output,
platform: turn.platform,
market: turn.market,
language: turn.language,
ratio: turn.ratio,
requirement: turn.requirement,
productImages: turn.productImages,
results: turn.results,
setResultImages: turn.setResultImages,
setCounts: turn.setCounts,
detailModules: turn.detailModules,
modelScenes: turn.modelScenes,
referenceImages: turn.referenceImages,
replicateLevel: turn.replicateLevel,
turns: [],
};
const previousTurns = baseRecord.turns?.length ? baseRecord.turns : existingRecord ? [buildHistoryTurnFromRecord(baseRecord)] : [];
const record = normalizeEcommerceHistoryRecord(syncRecordSummaryWithTurn({
...baseRecord,
turns: [...previousTurns, turn],
}, turn));
setEcommerceHistoryRecords((current) => {
const nextRecords = [record, ...current.filter((item) => item.id !== record.id)].slice(0, 30);
writeEcommerceHistoryRecords(nextRecords);
return nextRecords;
});
setActiveHistoryRecordId(record.id);
activeHistoryTurnIdRef.current = turn.id;
return { record, turn };
};
const saveCurrentEcommerceHistory = () => {
const activeRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null;
const historyResults = activeRecord?.turns?.length ? getHistoryRecordResults(activeRecord) : getCurrentHistoryResults();
if (!historyResults.length) return null;
const signature = activeRecord?.turns?.length
? buildHistorySignature(activeRecord.output, activeRecord.requirement, historyResults, activeRecord.productImages)
: buildHistorySignature(cloneOutput, requirement, historyResults, productImages);
if (lastSavedHistorySignatureRef.current === signature && activeHistoryRecordId) return activeHistoryRecordId;
const createdAt = Date.now();
const record: EcommerceHistoryRecord = activeRecord?.turns?.length
? normalizeEcommerceHistoryRecord(activeRecord)
: normalizeEcommerceHistoryRecord({
id: activeRecord?.id ?? crypto.randomUUID(),
title: activeRecord?.title ?? buildEcommerceHistoryTitle(cloneOutput, requirement, createdAt),
createdAt: activeRecord?.createdAt ?? createdAt,
status: "done",
errorMessage: undefined,
output: cloneOutput,
platform,
market,
language,
ratio,
requirement,
productImages,
results: historyResults,
setResultImages: cloneOutput === "set" ? historyResults.map((item) => item.src) : [],
setCounts: cloneSetCounts,
detailModules: selectedCloneDetailModules,
modelScenes: selectedCloneModelScenes,
referenceImages: cloneReferenceImages,
replicateLevel: cloneReplicateLevel,
});
lastSavedHistorySignatureRef.current = signature;
persistEcommerceHistoryRecord(record, historyResults);
setEcommerceHistoryRecords((current) => {
const nextRecords = [record, ...current.filter((item) => item.id !== record.id)].slice(0, 30);
writeEcommerceHistoryRecords(nextRecords);
@@ -4679,32 +4972,34 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setCloneReplicateLevel(record.replicateLevel);
setProductSetResultImages(record.setResultImages);
setResults(record.output === "set" ? [] : record.results);
setStatus("done");
setStatus((record.status ?? "done") as ProductCloneStatus);
setPreviewZoom(1);
setComposerMenu(null);
setActiveHistoryRecordId(record.id);
lastSavedHistorySignatureRef.current = buildHistorySignature(record.output, record.requirement, record.results, record.productImages);
activeHistoryTurnIdRef.current = record.status === "generating"
? record.turns?.find((turn) => turn.status === "generating")?.id ?? null
: null;
const recordResults = getHistoryRecordResults(record);
lastSavedHistorySignatureRef.current = buildHistorySignature(record.output, record.requirement, recordResults, record.productImages);
setIsCommandComposerCompact(true);
const hasResults = record.output === "set"
? record.setResultImages.some(Boolean)
: record.results.some((r) => r.src);
if (hasResults) {
const nodeResults: CloneResult[] = record.output === "set"
? record.setResultImages.filter(Boolean).map((url, i) => ({ id: `set-${i}`, src: url, label: `套图 ${i + 1}` }))
: record.results;
setCanvasNodes([{
id: record.id,
mode: record.output,
sourceImage: record.productImages[0]?.src,
results: nodeResults,
createdAt: record.createdAt,
x: 0,
y: 0,
}]);
} else {
setCanvasNodes([]);
}
const turns = record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)];
const nodes = turns.reduce<CanvasNode[]>((items, turn) => {
const turnResults = getTurnResults(turn);
if (!turnResults.length) return items;
const index = items.length;
items.push({
id: turn.id,
mode: turn.output,
sourceImage: turn.productImages[0]?.src,
results: turnResults,
createdAt: turn.createdAt,
x: index * 420,
y: index % 2 === 0 ? 0 : 160,
});
return items;
}, []);
setCanvasNodes(nodes);
setPreviewOffset({ x: 0, y: 0 });
previewOffsetRef.current = { x: 0, y: 0 };
@@ -4719,6 +5014,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setComposerMenu(null);
setIsCommandComposerCompact(false);
setActiveHistoryRecordId(null);
activeHistoryTurnIdRef.current = null;
lastSavedHistorySignatureRef.current = "";
};
@@ -4757,7 +5053,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const next = ecommerceHistoryRecords.filter((r) => r.id !== recordId);
setEcommerceHistoryRecords(next);
writeEcommerceHistoryRecords(next);
if (activeHistoryRecordId === recordId) setActiveHistoryRecordId(null);
if (activeHistoryRecordId === recordId) {
setActiveHistoryRecordId(null);
activeHistoryTurnIdRef.current = null;
}
deleteEcommerceGenerationRecord(recordId).catch(() => {});
};
@@ -7417,26 +7716,35 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const isRecordDetailWorkspace = isMainCloneWorkspace && Boolean(activeHistoryRecordId);
const currentResultCount = canvasNodes.reduce((count, node) => count + node.results.length, 0);
const activeHistoryRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null;
const currentResultThumbs = canvasNodes.flatMap((node) => node.results).slice(0, 6);
const activeHistoryImageIds = new Set((activeHistoryRecord?.productImages ?? []).map((image) => image.id));
const historyConversationImages = activeHistoryRecord?.productImages?.length ? activeHistoryRecord.productImages : productImages;
const newConversationImages = activeHistoryRecord ? productImages.filter((image) => !activeHistoryImageIds.has(image.id)) : [];
const historyRequirementText = activeHistoryRecord?.requirement?.trim() || requirement.trim();
const newRequirementText = requirement.trim() && requirement.trim() !== historyRequirementText
? requirement.trim()
: "继续上传素材,准备下一轮生成。";
const historyRequirementMeta = [
{ label: "平台", value: activeHistoryRecord?.platform || platform },
{ label: "语种", value: activeHistoryRecord?.language || language },
{ label: "比例", value: formatRatioDisplayValue(activeHistoryRecord?.ratio || ratio) },
{ label: "设置", value: composerSettingLabel },
];
const currentRequirementMeta = [
{ label: "平台", value: platform },
{ label: "语种", value: language },
{ label: "比例", value: formatRatioDisplayValue(ratio) },
{ label: "设置", value: composerSettingLabel },
];
const activeConversationTurns = activeHistoryRecord
? activeHistoryRecord.turns?.length
? activeHistoryRecord.turns
: [buildHistoryTurnFromRecord(activeHistoryRecord)]
: [];
const getHistoryTurnSettingLabel = (turn: EcommerceHistoryTurn) => {
if (turn.output === "set") {
const total = cloneSetCountKeys.reduce((sum, key) => sum + (turn.setCounts?.[key] ?? 0), 0);
return `套图 ${total || 1}`;
}
if (turn.output === "detail") return `详情 ${turn.detailModules?.length || 1}`;
if (turn.output === "model") return `模特 ${turn.modelScenes?.length || 1}`;
return cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label;
};
const restoreHistoryTurnInputs = (turn: EcommerceHistoryTurn) => {
setCloneOutput(turn.output);
setPlatform(turn.platform);
setMarket(turn.market);
setLanguage(turn.language);
setRatio(turn.ratio);
setRequirement(turn.requirement);
setProductImages(turn.productImages);
setCloneSetCounts(turn.setCounts);
setSelectedCloneDetailModules(turn.detailModules.slice(0, maxDetailModuleSelection));
setSelectedCloneModelScenes(turn.modelScenes);
setCloneReferenceImages(turn.referenceImages);
setCloneReplicateLevel(turn.replicateLevel);
toast.info("已恢复该轮参数,可继续发送");
};
return (
<section
@@ -7496,75 +7804,73 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</button>
</header>
<div className="clone-ai-conversation-body">
<section className="clone-ai-chat-message clone-ai-chat-message--user">
<span></span>
<p>{historyRequirementText || "上传商品素材,描述你想生成的商品图、详情图、模特图或短视频。"}</p>
<div className="clone-ai-chat-meta" aria-label="需求参数">
{historyRequirementMeta.map((item) => (
<em key={item.label}>
<span>{item.label}</span>
<strong>{item.value}</strong>
</em>
))}
</div>
{historyConversationImages.length ? (
<div className="clone-ai-chat-assets" aria-label="已上传素材">
{historyConversationImages.slice(0, 4).map((image) => (
<img key={image.id} src={image.src} alt={image.name || "商品素材"} />
))}
{historyConversationImages.length > 4 ? <em>+{historyConversationImages.length - 4}</em> : null}
</div>
) : null}
</section>
<section className={`clone-ai-chat-message clone-ai-chat-message--assistant is-${status}`}>
<span></span>
<p>
{status === "done" || currentResultCount > 0
? `已生成 ${currentResultCount || results.length || productSetResultImages.filter(Boolean).length} 张结果,可在画布中拖拽、缩放和预览。`
: status === "generating"
? `正在为 ${platform} / ${market} 生成${selectedCloneOutput.label},结果会自动出现在中间画布。`
: status === "failed"
? "生成失败,请检查网络或参数后重试。"
: "我会根据商品图、平台规则和提示词整理生成任务。"}
</p>
{status === "generating" ? (
<EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label={`${selectedCloneOutput.label}生成`} />
) : null}
{currentResultThumbs.length ? (
<div className="clone-ai-chat-results" aria-label="生成结果缩略图">
{currentResultThumbs.map((item) => (
<button
key={item.id}
type="button"
onClick={() => openProductSetPreview(item)}
aria-label={`预览${item.label}`}
>
<img src={item.src} alt={item.label} />
</button>
))}
</div>
) : null}
</section>
{newConversationImages.length ? (
<section className="clone-ai-chat-message clone-ai-chat-message--user clone-ai-chat-message--followup">
<span></span>
<p>{newRequirementText}</p>
<div className="clone-ai-chat-meta" aria-label="新需求参数">
{currentRequirementMeta.map((item) => (
<em key={item.label}>
<span>{item.label}</span>
<strong>{item.value}</strong>
</em>
))}
</div>
<div className="clone-ai-chat-assets" aria-label="新增素材">
{newConversationImages.slice(0, 4).map((image) => (
<img key={image.id} src={image.src} alt={image.name || "新增商品素材"} />
))}
{newConversationImages.length > 4 ? <em>+{newConversationImages.length - 4}</em> : null}
</div>
</section>
) : null}
{activeConversationTurns.map((turn, index) => {
const turnResults = getTurnResults(turn);
const outputLabel = cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label;
const turnMeta = [
{ label: "平台", value: turn.platform },
{ label: "语种", value: turn.language },
{ label: "比例", value: formatRatioDisplayValue(turn.ratio) },
{ label: "设置", value: getHistoryTurnSettingLabel(turn) },
];
const isCurrentGeneratingTurn = turn.status === "generating" && turn.id === activeHistoryTurnIdRef.current;
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>
<p>{turn.requirement?.trim() || "上传商品素材,描述你想生成的商品图、详情图、模特图或短视频。"}</p>
<div className="clone-ai-chat-meta" aria-label="需求参数">
{turnMeta.map((item) => (
<em key={item.label}>
<span>{item.label}</span>
<strong>{item.value}</strong>
</em>
))}
</div>
{turn.productImages.length ? (
<div className="clone-ai-chat-assets" aria-label="已上传素材">
{turn.productImages.slice(0, 4).map((image) => (
<img key={image.id} src={image.src} alt={image.name || "商品素材"} />
))}
{turn.productImages.length > 4 ? <em>+{turn.productImages.length - 4}</em> : null}
</div>
) : null}
</section>
<section className={`clone-ai-chat-message clone-ai-chat-message--assistant is-${turn.status}`}>
<span></span>
<p>
{turn.status === "done" || turnResults.length
? `已生成 ${turnResults.length}${outputLabel},已同步到中间画布,可拖拽、缩放和预览。`
: turn.status === "generating"
? `正在为 ${turn.platform} / ${turn.market} 生成${outputLabel},完成后会自动追加到画布。`
: turn.errorMessage || "生成失败,请检查网络或参数后重试。"}
</p>
{isCurrentGeneratingTurn ? (
<EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label={`${outputLabel}生成`} />
) : null}
{turn.status === "failed" ? (
<button type="button" className="clone-ai-retry-btn" onClick={() => restoreHistoryTurnInputs(turn)}>
<ReloadOutlined />
</button>
) : null}
{turnResults.length ? (
<div className="clone-ai-chat-results" aria-label="生成结果缩略图">
{turnResults.slice(0, 6).map((item) => (
<button
key={item.id}
type="button"
onClick={() => openProductSetPreview(item)}
aria-label={`预览${item.label}`}
>
<img src={item.src} alt={item.label} />
</button>
))}
</div>
) : null}
</section>
</Fragment>
);
})}
</div>
</aside>
<button
@@ -7628,6 +7934,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{ecommerceHistoryRecords.length ? (
ecommerceHistoryRecords.map((record) => {
const outputLabel = cloneOutputOptions.find((option) => option.key === record.output)?.label || "生成记录";
const statusLabel = record.status === "generating" ? "生成中" : record.status === "failed" ? "失败" : formatHistoryTime(record.createdAt);
return (
<div key={`${record.id}-${historyRefreshTick}`} className={`ecom-command-history__item${activeHistoryRecordId === record.id ? " is-active" : ""}`}>
<button
@@ -7636,7 +7943,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
onClick={() => openEcommerceHistoryRecord(record)}
>
<strong>{record.title}</strong>
<span>{outputLabel} · {formatHistoryTime(record.createdAt)}</span>
<span>{outputLabel} · {statusLabel}</span>
</button>
<button
type="button"
@@ -7656,7 +7963,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</nav>
</aside>
{selectedProductSetPreview ? (
{selectedProductSetPreview && typeof document !== "undefined" ? createPortal((
<div className="product-set-preview-backdrop" role="presentation" onClick={() => setSelectedProductSetPreview(null)}>
<section
className="product-set-preview-modal"
@@ -7701,7 +8008,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</div>
</section>
</div>
) : null}
), document.body) : null}
{showHostingModal ? (
<div className="product-set-hosting-backdrop" role="presentation">
+1 -1
View File
@@ -7861,7 +7861,7 @@
.product-set-preview-backdrop {
position: fixed;
inset: 0;
z-index: 100;
z-index: 4000;
display: grid;
place-items: center;
background: rgb(17 24 39 / 58%);