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