Merge pull request 'feat: implement multi-turn conversation system for generation record detail with deduplication enhancement' (#18) from feat/ecommerce-record-detail-conversation-panel into main
Reviewed-on: #18
This commit was merged in pull request #18.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,43 +7804,58 @@ 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>
|
||||
{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="需求参数">
|
||||
{historyRequirementMeta.map((item) => (
|
||||
{turnMeta.map((item) => (
|
||||
<em key={item.label}>
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.value}</strong>
|
||||
</em>
|
||||
))}
|
||||
</div>
|
||||
{historyConversationImages.length ? (
|
||||
{turn.productImages.length ? (
|
||||
<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 || "商品素材"} />
|
||||
))}
|
||||
{historyConversationImages.length > 4 ? <em>+{historyConversationImages.length - 4}</em> : null}
|
||||
{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-${status}`}>
|
||||
<section className={`clone-ai-chat-message clone-ai-chat-message--assistant is-${turn.status}`}>
|
||||
<span>电商图设计师</span>
|
||||
<p>
|
||||
{status === "done" || currentResultCount > 0
|
||||
? `已生成 ${currentResultCount || results.length || productSetResultImages.filter(Boolean).length} 张结果,可在画布中拖拽、缩放和预览。`
|
||||
: status === "generating"
|
||||
? `正在为 ${platform} / ${market} 生成${selectedCloneOutput.label},结果会自动出现在中间画布。`
|
||||
: status === "failed"
|
||||
? "生成失败,请检查网络或参数后重试。"
|
||||
: "我会根据商品图、平台规则和提示词整理生成任务。"}
|
||||
{turn.status === "done" || turnResults.length
|
||||
? `已生成 ${turnResults.length} 张${outputLabel},已同步到中间画布,可拖拽、缩放和预览。`
|
||||
: turn.status === "generating"
|
||||
? `正在为 ${turn.platform} / ${turn.market} 生成${outputLabel},完成后会自动追加到画布。`
|
||||
: turn.errorMessage || "生成失败,请检查网络或参数后重试。"}
|
||||
</p>
|
||||
{status === "generating" ? (
|
||||
<EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label={`${selectedCloneOutput.label}生成`} />
|
||||
{isCurrentGeneratingTurn ? (
|
||||
<EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label={`${outputLabel}生成`} />
|
||||
) : 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="生成结果缩略图">
|
||||
{currentResultThumbs.map((item) => (
|
||||
{turnResults.slice(0, 6).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
@@ -7545,26 +7868,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
</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}
|
||||
</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">
|
||||
|
||||
@@ -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%);
|
||||
|
||||
Reference in New Issue
Block a user