Codex/ecommerce history sync #29

Merged
stringadmin merged 14 commits from codex/ecommerce-history-sync into main 2026-06-18 02:20:06 +00:00
4 changed files with 362 additions and 356 deletions
Showing only changes of commit 2759afa176 - Show all commits
+26 -343
View File
@@ -49,6 +49,7 @@ import EcommerceVideoHistoryPanel from "./panels/EcommerceVideoHistoryPanel";
import ProductSetHostingModal from "./panels/ProductSetHostingModal";
import ProductSetPreviewModal, { type ProductSetPreviewSelection } from "./panels/ProductSetPreviewModal";
import CommandHistorySidebar from "./panels/CommandHistorySidebar";
import WatermarkToolPage from "./panels/WatermarkToolPage";
import EcommerceDetailPanel from "./panels/EcommerceDetailPanel";
import EcommerceSetPanel from "./panels/EcommerceSetPanel";
import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel";
@@ -58,16 +59,23 @@ import { ecommerceOssScopes, saveUnifiedEcommerceGenerationRecord, deleteEcommer
import { downloadResultAsset } from "../workbench/workbenchDownload";
import type { CloneOutputKey, ProductSetOutputKey } from "./utils/platformRules";
import {
buildHistoryTurnFromRecord,
cloneLatestSettingStorageKey,
defaultCloneDetailModuleIds,
defaultCloneSetCounts,
ecommerceHistoryStorageKey,
getTurnResults,
isCloneImageItem,
isCloneResult,
isCloneSavedSetting,
isEcommerceHistoryRecord,
normalizeEcommerceHistoryRecord,
normalizeEcommerceHistoryTurn,
readCloneLatestSetting,
readEcommerceHistoryRecords,
removeFilePayloadFromImages,
writeCloneLatestSetting,
writeEcommerceHistoryRecords,
} from "./utils/clonePersistence";
import type {
CloneImageItem,
@@ -78,6 +86,9 @@ import type {
CloneSavedSetting,
CloneSetCountKey,
CloneVideoQualityKey,
EcommerceHistoryRecord,
EcommerceHistoryStatus,
EcommerceHistoryTurn,
} from "./utils/clonePersistence";
const smartCutoutColorPresets = [
@@ -296,52 +307,6 @@ interface PreviewTouchGesture {
startCenter: { x: number; y: number };
}
type EcommerceHistoryStatus = "generating" | "done" | "failed";
interface EcommerceHistoryTurn {
id: 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;
}
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 EcommerceImagePromptOptions {
gender?: string;
age?: string;
@@ -1436,127 +1401,6 @@ function notifyRejectedImages(files: File[]): File[] {
return accepted;
}
function isEcommerceHistoryRecord(item: unknown): item is EcommerceHistoryRecord {
const candidate = item as Partial<EcommerceHistoryRecord>;
return (
typeof candidate.id === "string" &&
typeof candidate.title === "string" &&
typeof candidate.createdAt === "number" &&
typeof candidate.output === "string" &&
typeof candidate.platform === "string" &&
typeof candidate.market === "string" &&
typeof candidate.language === "string" &&
typeof candidate.ratio === "string" &&
typeof candidate.requirement === "string" &&
Array.isArray(candidate.productImages) &&
candidate.productImages.every(isCloneImageItem) &&
Array.isArray(candidate.results) &&
candidate.results.every(isCloneResult)
);
}
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 ?? [],
setResultImages: record.setResultImages ?? [],
setCounts: record.setCounts ?? defaultCloneSetCounts,
detailModules: record.detailModules ?? defaultCloneDetailModuleIds,
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() {
if (typeof window === "undefined") return [];
try {
const rawValue = window.localStorage.getItem(ecommerceHistoryStorageKey);
if (!rawValue) return [];
const parsedValue: unknown = JSON.parse(rawValue);
if (!Array.isArray(parsedValue)) return [];
return parsedValue
.filter(isEcommerceHistoryRecord)
.map(normalizeEcommerceHistoryRecord)
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, 30);
} catch {
return [];
}
}
function writeEcommerceHistoryRecords(records: EcommerceHistoryRecord[]) {
if (typeof window === "undefined") return;
window.localStorage.setItem(ecommerceHistoryStorageKey, JSON.stringify(records.map(normalizeEcommerceHistoryRecord).slice(0, 30)));
}
function clampCloneVideoDuration(value: number) {
return Math.min(cloneVideoDurationMax, Math.max(cloneVideoDurationMin, Math.round(value)));
}
@@ -7112,184 +6956,23 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
);
const watermarkPreview = (
<main key="watermark" className="ecom-watermark-page ecom-tool-page-enter" aria-label="去水印">
<input
ref={watermarkInputRef}
type="file"
accept="image/*"
className="ecom-command-hidden-file"
onChange={handleWatermarkUpload}
aria-label="上传去水印图片"
/>
<aside className="ecom-watermark-side">
<header className="ecom-quick-set-panel-head ecom-watermark-panel-head">
<strong className="ecom-quick-set-page-title"></strong>
<button type="button" className="ecom-quick-set-back" onClick={closeWatermarkRemovalPage}></button>
<button type="button" className="ecom-quick-set-back" onClick={closeWatermarkRemovalPage}></button>
</header>
<p className="ecom-watermark-intro"></p>
<section className="ecom-watermark-panel">
<header>
<strong></strong>
<span>{watermarkImage ? "已上传" : "待上传"}</span>
</header>
<div
className={`ecom-watermark-upload-card${isWatermarkDragging ? " is-dragging" : ""}${watermarkImage ? " has-image" : ""}`}
role="button"
tabIndex={0}
onClick={() => watermarkInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
watermarkInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsWatermarkDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsWatermarkDragging(false)}
<WatermarkToolPage
inputRef={watermarkInputRef}
urlInputRef={watermarkUrlInputRef}
image={watermarkImage}
isDragging={isWatermarkDragging}
status={watermarkStatus}
progress={watermarkProgress}
resultUrl={watermarkResultUrl}
onUpload={handleWatermarkUpload}
onDrop={handleWatermarkDrop}
>
{watermarkImage ? (
<>
<button
type="button"
className="ecom-watermark-remove"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
removeWatermarkImage();
}}
aria-label="删除素材"
>
×
</button>
<figure>
<img src={watermarkImage.src} alt={watermarkImage.name} />
</figure>
<div>
<strong>{watermarkImage.name}</strong>
<span>{watermarkImage.format || "PNG / JPG / WebP"}</span>
</div>
</>
) : (
<>
<CloudUploadOutlined />
<strong></strong>
<span> PNG / JPG / WebP</span>
</>
)}
</div>
<div className="ecom-watermark-url-row">
<input
ref={watermarkUrlInputRef}
placeholder="粘贴图片 URL"
aria-label="粘贴图片 URL"
onKeyDown={(event) => {
if (event.key === "Enter") void handleWatermarkUrlImport();
}}
onDraggingChange={setIsWatermarkDragging}
onRemoveImage={removeWatermarkImage}
onUrlImport={handleWatermarkUrlImport}
onGenerate={handleWatermarkGenerate}
onDownload={handleWatermarkDownload}
onClose={closeWatermarkRemovalPage}
/>
<button type="button" onClick={() => void handleWatermarkUrlImport()}></button>
</div>
</section>
<section className="ecom-watermark-panel">
<strong></strong>
<p></p>
</section>
<button
type="button"
className="ecom-watermark-primary"
onClick={handleWatermarkGenerate}
disabled={!watermarkImage || watermarkStatus === "processing"}
>
{watermarkStatus === "processing" ? <LoadingOutlined /> : <FileImageOutlined />}
{watermarkStatus === "processing" ? "处理中" : "开始去水印"}
</button>
</aside>
<section className="ecom-watermark-workspace">
{!watermarkImage ? (
<div
className={`ecom-watermark-dropzone${isWatermarkDragging ? " is-dragging" : ""}`}
role="button"
tabIndex={0}
onClick={() => watermarkInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
watermarkInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsWatermarkDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsWatermarkDragging(false)}
onDrop={handleWatermarkDrop}
>
<CloudUploadOutlined />
<strong></strong>
<span> PNG / JPG / WebP</span>
</div>
) : (
<div className="ecom-watermark-grid">
<article className="ecom-watermark-preview-card">
<span></span>
<img src={watermarkImage.src} alt="原图" />
</article>
<article className="ecom-watermark-preview-card">
<span></span>
{watermarkStatus === "processing" ? (
<div className="ecom-watermark-processing" role="status" aria-live="polite">
<LoadingOutlined />
<strong></strong>
<em>AI </em>
<div className="ecom-quick-set-progress">
<div className="ecom-quick-set-progress-bar" style={{ width: `${Math.round(watermarkProgress)}%` }} />
</div>
<em className="ecom-quick-set-progress-text">{Math.round(watermarkProgress)}%</em>
</div>
) : watermarkStatus === "done" && watermarkResultUrl ? (
<>
<img src={watermarkResultUrl} alt="去水印结果" />
<button type="button" className="ecom-watermark-zoom" aria-label="查看大图">
<QuestionCircleOutlined />
</button>
</>
) : watermarkStatus === "failed" ? (
<div className="ecom-watermark-empty">
<FrownOutlined />
<strong></strong>
<em></em>
</div>
) : (
<div className="ecom-watermark-empty">
<FileImageOutlined />
<strong></strong>
<em></em>
</div>
)}
<div className="ecom-watermark-actions">
<button type="button" onClick={() => toast.success("已加入资产库")} disabled={watermarkStatus !== "done"}>
<FolderOpenOutlined />
</button>
<button type="button" onClick={handleWatermarkDownload} disabled={watermarkStatus !== "done"}>
<CloudUploadOutlined />
</button>
</div>
</article>
</div>
)}
</section>
</main>
);
const translateLanguageOptions = [
@@ -2,16 +2,10 @@ import { DeleteOutlined, MenuFoldOutlined, MenuUnfoldOutlined, ReloadOutlined }
import type { MouseEvent as ReactMouseEvent } from "react";
import type { EcommerceHistoryRecord } from "../utils/clonePersistence";
// 页面内的 EcommerceHistoryRecord 比 clonePersistence 版本更丰富(多了 status/errorMessage/turns),
// 这里用 intersection 补齐侧栏实际用到的字段,避免依赖页面的内部类型。
type HistoryRecord = EcommerceHistoryRecord & {
status?: "generating" | "done" | "failed";
};
interface CommandHistorySidebarProps {
collapsed: boolean;
showBackdrop: boolean;
records: HistoryRecord[];
records: EcommerceHistoryRecord[];
activeRecordId: string | null;
isRefreshing: boolean;
refreshMessage: string | null;
@@ -23,7 +17,7 @@ interface CommandHistorySidebarProps {
onCollapse: () => void;
onNewConversation: () => void;
onRefresh: () => void;
onOpenRecord: (record: HistoryRecord) => void;
onOpenRecord: (record: EcommerceHistoryRecord) => void;
onDeleteRecord: (recordId: string, event: ReactMouseEvent) => void;
}
@@ -0,0 +1,237 @@
import {
CloudUploadOutlined,
FileImageOutlined,
FolderOpenOutlined,
FrownOutlined,
LoadingOutlined,
QuestionCircleOutlined,
} from "@ant-design/icons";
import type { ChangeEvent, DragEvent, KeyboardEvent, RefObject } from "react";
import { toast } from "../../../components/toast/toastStore";
export interface WatermarkImageItem {
src: string;
name: string;
format: string;
}
export type WatermarkStatus = "idle" | "processing" | "done" | "failed";
interface WatermarkToolPageProps {
inputRef: RefObject<HTMLInputElement>;
urlInputRef: RefObject<HTMLInputElement>;
image: WatermarkImageItem | null;
isDragging: boolean;
status: WatermarkStatus;
progress: number;
resultUrl: string | null;
onUpload: (event: ChangeEvent<HTMLInputElement>) => void;
onDrop: (event: DragEvent<HTMLDivElement>) => void;
onDraggingChange: (dragging: boolean) => void;
onRemoveImage: () => void;
onUrlImport: () => void;
onGenerate: () => void;
onDownload: () => void;
onClose: () => void;
}
// 去水印工具页面:上传含水印图片 → AI 清理 → 预览/下载结果。
// 从 EcommercePage 的 watermarkPreview 抽出,状态与处理逻辑仍在父组件,本组件纯展示 + 回调。
export default function WatermarkToolPage({
inputRef,
urlInputRef,
image,
isDragging,
status,
progress,
resultUrl,
onUpload,
onDrop,
onDraggingChange,
onRemoveImage,
onUrlImport,
onGenerate,
onDownload,
onClose,
}: WatermarkToolPageProps) {
return (
<main key="watermark" className="ecom-watermark-page ecom-tool-page-enter" aria-label="去水印">
<input
ref={inputRef}
type="file"
accept="image/*"
className="ecom-command-hidden-file"
onChange={onUpload}
aria-label="上传去水印图片"
/>
<aside className="ecom-watermark-side">
<header className="ecom-quick-set-panel-head ecom-watermark-panel-head">
<strong className="ecom-quick-set-page-title"></strong>
<button type="button" className="ecom-quick-set-back" onClick={onClose}></button>
<button type="button" className="ecom-quick-set-back" onClick={onClose}></button>
</header>
<p className="ecom-watermark-intro"></p>
<section className="ecom-watermark-panel">
<header>
<strong></strong>
<span>{image ? "已上传" : "待上传"}</span>
</header>
<div
className={`ecom-watermark-upload-card${isDragging ? " is-dragging" : ""}${image ? " has-image" : ""}`}
role="button"
tabIndex={0}
onClick={() => inputRef.current?.click()}
onKeyDown={(event: KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
inputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
onDraggingChange(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => onDraggingChange(false)}
onDrop={onDrop}
>
{image ? (
<>
<button
type="button"
className="ecom-watermark-remove"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onRemoveImage();
}}
aria-label="删除素材"
>
×
</button>
<figure>
<img src={image.src} alt={image.name} />
</figure>
<div>
<strong>{image.name}</strong>
<span>{image.format || "PNG / JPG / WebP"}</span>
</div>
</>
) : (
<>
<CloudUploadOutlined />
<strong></strong>
<span> PNG / JPG / WebP</span>
</>
)}
</div>
<div className="ecom-watermark-url-row">
<input
ref={urlInputRef}
placeholder="粘贴图片 URL"
aria-label="粘贴图片 URL"
onKeyDown={(event) => {
if (event.key === "Enter") void onUrlImport();
}}
/>
<button type="button" onClick={() => void onUrlImport()}></button>
</div>
</section>
<section className="ecom-watermark-panel">
<strong></strong>
<p></p>
</section>
<button
type="button"
className="ecom-watermark-primary"
onClick={onGenerate}
disabled={!image || status === "processing"}
>
{status === "processing" ? <LoadingOutlined /> : <FileImageOutlined />}
{status === "processing" ? "处理中" : "开始去水印"}
</button>
</aside>
<section className="ecom-watermark-workspace">
{!image ? (
<div
className={`ecom-watermark-dropzone${isDragging ? " is-dragging" : ""}`}
role="button"
tabIndex={0}
onClick={() => inputRef.current?.click()}
onKeyDown={(event: KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
inputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
onDraggingChange(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => onDraggingChange(false)}
onDrop={onDrop}
>
<CloudUploadOutlined />
<strong></strong>
<span> PNG / JPG / WebP</span>
</div>
) : (
<div className="ecom-watermark-grid">
<article className="ecom-watermark-preview-card">
<span></span>
<img src={image.src} alt="原图" />
</article>
<article className="ecom-watermark-preview-card">
<span></span>
{status === "processing" ? (
<div className="ecom-watermark-processing" role="status" aria-live="polite">
<LoadingOutlined />
<strong></strong>
<em>AI </em>
<div className="ecom-quick-set-progress">
<div className="ecom-quick-set-progress-bar" style={{ width: `${Math.round(progress)}%` }} />
</div>
<em className="ecom-quick-set-progress-text">{Math.round(progress)}%</em>
</div>
) : status === "done" && resultUrl ? (
<>
<img src={resultUrl} alt="去水印结果" />
<button type="button" className="ecom-watermark-zoom" aria-label="查看大图">
<QuestionCircleOutlined />
</button>
</>
) : status === "failed" ? (
<div className="ecom-watermark-empty">
<FrownOutlined />
<strong></strong>
<em></em>
</div>
) : (
<div className="ecom-watermark-empty">
<FileImageOutlined />
<strong></strong>
<em></em>
</div>
)}
<div className="ecom-watermark-actions">
<button type="button" onClick={() => toast.success("已加入资产库")} disabled={status !== "done"}>
<FolderOpenOutlined />
</button>
<button type="button" onClick={onDownload} disabled={status !== "done"}>
<CloudUploadOutlined />
</button>
</div>
</article>
</div>
)}
</section>
</main>
);
}
@@ -60,10 +60,13 @@ export interface CloneSavedSetting {
requirement: string;
}
export interface EcommerceHistoryRecord {
export type EcommerceHistoryStatus = "generating" | "done" | "failed";
export interface EcommerceHistoryTurn {
id: string;
title: string;
createdAt: number;
status: EcommerceHistoryStatus;
errorMessage?: string;
output: CloneOutputKey;
platform: string;
market: string;
@@ -80,6 +83,29 @@ export interface EcommerceHistoryRecord {
replicateLevel: CloneReplicateLevelKey;
}
export 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[];
}
export const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting";
export const ecommerceHistoryStorageKey = "omniai.ecommerce.history.records";
@@ -148,9 +174,67 @@ export function removeFilePayloadFromImages(images: CloneImageItem[]): CloneImag
}));
}
export function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): EcommerceHistoryRecord {
export 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}` }));
}
export 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",
};
}
export 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",
};
}
export 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 ?? [],
@@ -160,6 +244,14 @@ export function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord):
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,
};
}
export function readCloneLatestSetting(): CloneSavedSetting | null {