This commit is contained in:
@@ -44,6 +44,7 @@ export interface ImageProviderDebug {
|
|||||||
|
|
||||||
export interface ImageTaskCreateResponse {
|
export interface ImageTaskCreateResponse {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
resultUrl?: string | null;
|
||||||
providerDebug?: ImageProviderDebug;
|
providerDebug?: ImageProviderDebug;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@ export interface ImageEditInput {
|
|||||||
prompt?: string;
|
prompt?: string;
|
||||||
maskUrl?: string;
|
maskUrl?: string;
|
||||||
ratio?: string;
|
ratio?: string;
|
||||||
|
referenceUrls?: string[];
|
||||||
n?: number;
|
n?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +128,7 @@ export type ChatMessageContent =
|
|||||||
| Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>;
|
| Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>;
|
||||||
|
|
||||||
export interface ChatInput {
|
export interface ChatInput {
|
||||||
model: string;
|
model?: string;
|
||||||
messages: Array<{ role: string; content: ChatMessageContent }>;
|
messages: Array<{ role: string; content: ChatMessageContent }>;
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
|
|||||||
@@ -110,6 +110,15 @@ describe("parseImageTaskCreateResponse", () => {
|
|||||||
expect(result.providerDebug).toBeUndefined();
|
expect(result.providerDebug).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("extracts immediate image result URLs", () => {
|
||||||
|
const result = parseImageTaskCreateResponse({
|
||||||
|
taskId: "img-sync",
|
||||||
|
result_url: "https://example.com/result.png",
|
||||||
|
});
|
||||||
|
expect(result.taskId).toBe("img-sync");
|
||||||
|
expect(result.resultUrl).toBe("https://example.com/result.png");
|
||||||
|
});
|
||||||
|
|
||||||
it("tolerates snake_case providerDebug fields", () => {
|
it("tolerates snake_case providerDebug fields", () => {
|
||||||
const result = parseImageTaskCreateResponse({
|
const result = parseImageTaskCreateResponse({
|
||||||
taskId: "img-3",
|
taskId: "img-3",
|
||||||
|
|||||||
@@ -130,8 +130,13 @@ export function parseTaskCreateResponse(payload: unknown): { taskId: string } {
|
|||||||
export function parseImageTaskCreateResponse(payload: unknown): ImageTaskCreateResponse {
|
export function parseImageTaskCreateResponse(payload: unknown): ImageTaskCreateResponse {
|
||||||
const base = parseTaskCreateResponse(payload);
|
const base = parseTaskCreateResponse(payload);
|
||||||
const body = isRecord(payload) ? payload : {};
|
const body = isRecord(payload) ? payload : {};
|
||||||
|
const resultUrl = toNullableString(body.resultUrl ?? body.result_url);
|
||||||
const providerDebug = normalizeProviderDebug(body.providerDebug ?? body.provider_debug);
|
const providerDebug = normalizeProviderDebug(body.providerDebug ?? body.provider_debug);
|
||||||
return providerDebug ? { ...base, providerDebug } : base;
|
return {
|
||||||
|
...base,
|
||||||
|
resultUrl,
|
||||||
|
...(providerDebug ? { providerDebug } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -38,6 +38,39 @@ export interface SaveGenerationRecordResult {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GenerationRecord {
|
||||||
|
id: string;
|
||||||
|
clientRecordId: string;
|
||||||
|
tool: string;
|
||||||
|
mode?: string;
|
||||||
|
title: string;
|
||||||
|
status: GenerationRecordStatus;
|
||||||
|
prompt?: string;
|
||||||
|
taskIds: string[];
|
||||||
|
assets: GenerationRecordAsset[];
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
result: Record<string, unknown>;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListGenerationRecordsParams {
|
||||||
|
tool?: string;
|
||||||
|
mode?: string;
|
||||||
|
status?: GenerationRecordStatus;
|
||||||
|
q?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListGenerationRecordsResult {
|
||||||
|
items: GenerationRecord[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
// 同一 clientRecordId 的保存去重:套图主流程、backgroundTaskRunner、useGenerationTasks
|
// 同一 clientRecordId 的保存去重:套图主流程、backgroundTaskRunner、useGenerationTasks
|
||||||
// 三处都可能对同一条终态任务调用 saveGenerationRecord,SSE 重复推送 completed 时
|
// 三处都可能对同一条终态任务调用 saveGenerationRecord,SSE 重复推送 completed 时
|
||||||
// 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截,
|
// 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截,
|
||||||
@@ -185,6 +218,23 @@ export async function flushPendingGenerationRecords(): Promise<{ synced: number;
|
|||||||
return { synced, remaining: remaining.length };
|
return { synced, remaining: remaining.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listGenerationRecords(params: ListGenerationRecordsParams = {}): Promise<ListGenerationRecordsResult> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params.tool) search.set("tool", params.tool);
|
||||||
|
if (params.mode) search.set("mode", params.mode);
|
||||||
|
if (params.status) search.set("status", params.status);
|
||||||
|
if (params.q) search.set("q", params.q);
|
||||||
|
if (params.limit !== undefined) search.set("limit", String(params.limit));
|
||||||
|
if (params.offset !== undefined) search.set("offset", String(params.offset));
|
||||||
|
|
||||||
|
const suffix = search.toString();
|
||||||
|
return serverRequest<ListGenerationRecordsResult>(`ai/generation-records${suffix ? `?${suffix}` : ""}`, {
|
||||||
|
method: "GET",
|
||||||
|
maxRetries: 1,
|
||||||
|
fallbackMessage: "Failed to load generation records",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteGenerationRecordByClientId(clientRecordId: string): Promise<void> {
|
export async function deleteGenerationRecordByClientId(clientRecordId: string): Promise<void> {
|
||||||
await serverRequest<{ success: boolean }>(`ai/generation-records/by-client-id/${encodeURIComponent(clientRecordId)}`, {
|
await serverRequest<{ success: boolean }>(`ai/generation-records/by-client-id/${encodeURIComponent(clientRecordId)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel";
|
|||||||
import EcommerceClonePanel from "./panels/EcommerceClonePanel";
|
import EcommerceClonePanel from "./panels/EcommerceClonePanel";
|
||||||
import EcommerceCopywritingPanel from "./panels/EcommerceCopywritingPanel";
|
import EcommerceCopywritingPanel from "./panels/EcommerceCopywritingPanel";
|
||||||
import EcommerceOneClickVideoPanel from "./panels/EcommerceOneClickVideoPanel";
|
import EcommerceOneClickVideoPanel from "./panels/EcommerceOneClickVideoPanel";
|
||||||
import { ecommerceOssScopes, saveUnifiedEcommerceGenerationRecord, deleteEcommerceGenerationRecord } from "./ecommerceGenerationPersistence";
|
import { ecommerceOssScopes, listEcommerceGenerationHistory, saveUnifiedEcommerceGenerationRecord, deleteEcommerceGenerationRecord } from "./ecommerceGenerationPersistence";
|
||||||
import { downloadResultAsset } from "../workbench/workbenchDownload";
|
import { downloadResultAsset } from "../workbench/workbenchDownload";
|
||||||
import {
|
import {
|
||||||
defaultCloneOutput,
|
defaultCloneOutput,
|
||||||
@@ -261,6 +261,10 @@ interface ProductClonePageProps {
|
|||||||
|
|
||||||
type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed";
|
type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed";
|
||||||
type CommerceScenarioKey = "popular" | "poster" | "mainImage" | "scene" | "festival" | "model" | "background" | "retouch" | "salesVideo";
|
type CommerceScenarioKey = "popular" | "poster" | "mainImage" | "scene" | "festival" | "model" | "background" | "retouch" | "salesVideo";
|
||||||
|
type CommerceDefaultImageScenarioKey = Exclude<CommerceScenarioKey, "popular" | "salesVideo">;
|
||||||
|
type CommerceDefaultIntent =
|
||||||
|
| { kind: "image"; scenario: CommerceDefaultImageScenarioKey }
|
||||||
|
| { kind: "video"; scenario: "salesVideo" };
|
||||||
type ProductSetStatus = "idle" | "ready" | "generating" | "done" | "failed";
|
type ProductSetStatus = "idle" | "ready" | "generating" | "done" | "failed";
|
||||||
type ProductKitToolKey = "set" | "detail" | "wear" | "clone";
|
type ProductKitToolKey = "set" | "detail" | "wear" | "clone";
|
||||||
type ComposerMenuKey = "mode" | "platform" | "language" | "ratio" | "settings" | "assetLibrary" | "workMode" | "aiWrite";
|
type ComposerMenuKey = "mode" | "platform" | "language" | "ratio" | "settings" | "assetLibrary" | "workMode" | "aiWrite";
|
||||||
@@ -411,6 +415,64 @@ const commerceScenarioOutputMap: Record<Exclude<CommerceScenarioKey, "popular">,
|
|||||||
retouch: "set",
|
retouch: "set",
|
||||||
salesVideo: "video",
|
salesVideo: "video",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultCommerceIntentFallback: CommerceDefaultIntent = { kind: "image", scenario: "mainImage" };
|
||||||
|
|
||||||
|
const normalizeDefaultCommerceIntent = (value: unknown): CommerceDefaultIntent => {
|
||||||
|
if (!value || typeof value !== "object") return defaultCommerceIntentFallback;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const kind = record.kind === "video" ? "video" : "image";
|
||||||
|
const scenario = typeof record.scenario === "string" ? record.scenario : "";
|
||||||
|
if (kind === "video" || scenario === "salesVideo") return { kind: "video", scenario: "salesVideo" };
|
||||||
|
const imageScenarios: CommerceDefaultImageScenarioKey[] = ["poster", "mainImage", "scene", "festival", "model", "background", "retouch"];
|
||||||
|
return imageScenarios.includes(scenario as CommerceDefaultImageScenarioKey)
|
||||||
|
? { kind: "image", scenario: scenario as CommerceDefaultImageScenarioKey }
|
||||||
|
: defaultCommerceIntentFallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const commerceScenarioGenerationKind = (scenario: CommerceDefaultImageScenarioKey): "singleImage" | "imageEdit" =>
|
||||||
|
scenario === "background" || scenario === "retouch" ? "imageEdit" : "singleImage";
|
||||||
|
|
||||||
|
const classifyDefaultCommerceIntent = async (input: {
|
||||||
|
prompt: string;
|
||||||
|
referenceCount: number;
|
||||||
|
ratio: string;
|
||||||
|
language: string;
|
||||||
|
platform: string;
|
||||||
|
}): Promise<CommerceDefaultIntent> => {
|
||||||
|
const content = [
|
||||||
|
"Classify this ecommerce creative request. Return only compact JSON.",
|
||||||
|
'Schema: {"kind":"image"|"video","scenario":"poster"|"mainImage"|"scene"|"festival"|"model"|"background"|"retouch"|"salesVideo"}.',
|
||||||
|
"Use salesVideo for video, short-video, UGC, storyboard, or product-demo motion requests.",
|
||||||
|
"Use background for changing/replacing a product image background.",
|
||||||
|
"Use retouch for inpainting, cleanup, seamless edit, repair, or localized image modification.",
|
||||||
|
"Use model for try-on, human model, wearable, or mannequin requests.",
|
||||||
|
"Use poster for campaign posters, sale posters, banners, or marketing layouts.",
|
||||||
|
"Use scene for lifestyle/usage environment images.",
|
||||||
|
"Use festival for holiday/seasonal style images.",
|
||||||
|
"Use mainImage for product hero/main image requests or unclear image requests.",
|
||||||
|
`Prompt: ${input.prompt || "(empty)"}`,
|
||||||
|
`Reference image count: ${input.referenceCount}`,
|
||||||
|
`Platform: ${input.platform}`,
|
||||||
|
`Ratio: ${input.ratio}`,
|
||||||
|
`Language: ${input.language}`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await aiGenerationClient.chatCompletion({
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "You are a strict ecommerce creative intent classifier. Respond with JSON only." },
|
||||||
|
{ role: "user", content },
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
temperature: 0,
|
||||||
|
});
|
||||||
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||||
|
return normalizeDefaultCommerceIntent(JSON.parse(jsonMatch?.[0] || text));
|
||||||
|
} catch {
|
||||||
|
return defaultCommerceIntentFallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
const commerceScenarioTemplates: CommerceScenarioTemplate[] = [
|
const commerceScenarioTemplates: CommerceScenarioTemplate[] = [
|
||||||
{
|
{
|
||||||
id: "poster-campaign-clean",
|
id: "poster-campaign-clean",
|
||||||
@@ -1007,6 +1069,20 @@ function clampCloneVideoDuration(value: number) {
|
|||||||
return Math.min(cloneVideoDurationMax, Math.max(cloneVideoDurationMin, Math.round(value)));
|
return Math.min(cloneVideoDurationMax, Math.max(cloneVideoDurationMin, Math.round(value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeEcommerceHistoryRecords(...recordGroups: EcommerceHistoryRecord[][]): EcommerceHistoryRecord[] {
|
||||||
|
const recordsById = new Map<string, EcommerceHistoryRecord>();
|
||||||
|
for (const records of recordGroups) {
|
||||||
|
for (const record of records) {
|
||||||
|
const normalized = normalizeEcommerceHistoryRecord(record);
|
||||||
|
const existing = recordsById.get(normalized.id);
|
||||||
|
if (!existing || normalized.createdAt >= existing.createdAt || normalized.turns?.length !== existing.turns?.length) {
|
||||||
|
recordsById.set(normalized.id, normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(recordsById.values()).sort((a, b) => b.createdAt - a.createdAt).slice(0, 30);
|
||||||
|
}
|
||||||
|
|
||||||
function ProductClonePage(_props: ProductClonePageProps = {}) {
|
function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||||
const setInputRef = useRef<HTMLInputElement>(null);
|
const setInputRef = useRef<HTMLInputElement>(null);
|
||||||
const productInputRef = useRef<HTMLInputElement>(null);
|
const productInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -1110,6 +1186,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const [videoHistoryVisible, setVideoHistoryVisible] = useState(false);
|
const [videoHistoryVisible, setVideoHistoryVisible] = useState(false);
|
||||||
const [isVideoWorkspaceVisible, setIsVideoWorkspaceVisible] = useState(false);
|
const [isVideoWorkspaceVisible, setIsVideoWorkspaceVisible] = useState(false);
|
||||||
const [videoPlanTrigger, setVideoPlanTrigger] = useState(0);
|
const [videoPlanTrigger, setVideoPlanTrigger] = useState(0);
|
||||||
|
const [isDefaultIntentRouting, setIsDefaultIntentRouting] = useState(false);
|
||||||
const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null);
|
const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null);
|
||||||
const [openQuickSetSelect, setOpenQuickSetSelect] = useState<CloneBasicSelectKey | null>(null);
|
const [openQuickSetSelect, setOpenQuickSetSelect] = useState<CloneBasicSelectKey | null>(null);
|
||||||
const [visibleQuickSetSelect, setVisibleQuickSetSelect] = useState<CloneBasicSelectKey | null>(null);
|
const [visibleQuickSetSelect, setVisibleQuickSetSelect] = useState<CloneBasicSelectKey | null>(null);
|
||||||
@@ -1551,6 +1628,27 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeEcommerceHistoryRecords(ecommerceHistoryRecords);
|
writeEcommerceHistoryRecords(ecommerceHistoryRecords);
|
||||||
}, [ecommerceHistoryRecords]);
|
}, [ecommerceHistoryRecords]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void listEcommerceGenerationHistory(30)
|
||||||
|
.then((serverRecords) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setEcommerceHistoryRecords((current) => {
|
||||||
|
const mergedRecords = mergeEcommerceHistoryRecords(serverRecords, current, readEcommerceHistoryRecords());
|
||||||
|
writeEcommerceHistoryRecords(mergedRecords);
|
||||||
|
return mergedRecords;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Local history remains available when the server list endpoint is offline.
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
const [customScene, setCustomScene] = useState("");
|
const [customScene, setCustomScene] = useState("");
|
||||||
const [smartScene, setSmartScene] = useState(false);
|
const [smartScene, setSmartScene] = useState(false);
|
||||||
const [tryOnRatio, setTryOnRatio] = useState(tryOnRatioOptions[0]);
|
const [tryOnRatio, setTryOnRatio] = useState(tryOnRatioOptions[0]);
|
||||||
@@ -3510,6 +3608,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
return urls;
|
return urls;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const withStableSourceImage = (images: CloneImageItem[], sourceUrl?: string): CloneImageItem[] => {
|
||||||
|
if (!sourceUrl || !images.length) return images;
|
||||||
|
return images.map((image, index) => (index === 0 ? { ...image, src: sourceUrl } : image));
|
||||||
|
};
|
||||||
|
|
||||||
const setCountLabels: Record<CloneSetCountKey, { label: string; promptDesc: string }> = {
|
const setCountLabels: Record<CloneSetCountKey, { label: string; promptDesc: string }> = {
|
||||||
selling: { label: "卖点图", promptDesc: "selling-point infographic image highlighting core product advantages and detail close-ups" },
|
selling: { label: "卖点图", promptDesc: "selling-point infographic image highlighting core product advantages and detail close-ups" },
|
||||||
white: { label: "白底图", promptDesc: "clean white-background product photo showing the item from its best angle, studio lighting, no props" },
|
white: { label: "白底图", promptDesc: "clean white-background product photo showing the item from its best angle, studio lighting, no props" },
|
||||||
@@ -3589,6 +3692,125 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildCommerceScenarioImagePrompt = (
|
||||||
|
scenario: CommerceDefaultImageScenarioKey,
|
||||||
|
userText: string,
|
||||||
|
pPlatform: string,
|
||||||
|
pRatio: string,
|
||||||
|
pLanguage: string,
|
||||||
|
pMarket: string,
|
||||||
|
): string => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const scenarioPrompts: Record<CommerceDefaultImageScenarioKey, string> = {
|
||||||
|
poster: "Generate one ecommerce campaign poster image with clear product focus, promotional hierarchy, and polished marketing layout.",
|
||||||
|
mainImage: "Generate one high-conversion ecommerce product main image. Keep the product accurate, clear, and platform-ready.",
|
||||||
|
scene: "Generate one realistic ecommerce lifestyle scene image. Preserve the product appearance and place it in a suitable usage environment.",
|
||||||
|
festival: "Generate one ecommerce product image with a tasteful holiday or seasonal marketing style.",
|
||||||
|
model: "Generate one ecommerce model or try-on image that naturally presents the product on or near a suitable model.",
|
||||||
|
background: "Replace or rebuild the product image background. Preserve the product exactly and use the user's prompt or extra reference image as background guidance.",
|
||||||
|
retouch: "Perform a seamless ecommerce image edit. Preserve the product identity while applying the user's requested local cleanup or refinement.",
|
||||||
|
};
|
||||||
|
parts.push(scenarioPrompts[scenario]);
|
||||||
|
parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`);
|
||||||
|
parts.push("Output a single image only.");
|
||||||
|
if (userText.trim()) parts.push(`User request: ${userText.trim()}`);
|
||||||
|
return parts.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateCommerceScenarioImage = async (
|
||||||
|
scenario: CommerceDefaultImageScenarioKey,
|
||||||
|
images: CloneImageItem[],
|
||||||
|
userText: string,
|
||||||
|
pPlatform: string,
|
||||||
|
pRatio: string,
|
||||||
|
pLanguage: string,
|
||||||
|
pMarket: string,
|
||||||
|
statusFn: (status: "generating" | "done" | "idle" | "failed") => void,
|
||||||
|
resultFn: (results: CloneResult[], sourceUrl?: string) => void,
|
||||||
|
): Promise<void> => {
|
||||||
|
statusFn("generating");
|
||||||
|
try {
|
||||||
|
const uploadedUrls = await uploadCloneImages(images);
|
||||||
|
if (!uploadedUrls.length) {
|
||||||
|
statusFn("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (imageAbortRef.current.current) {
|
||||||
|
statusFn("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = buildCommerceScenarioImagePrompt(scenario, userText, pPlatform, pRatio, pLanguage, pMarket);
|
||||||
|
const stamp = Date.now();
|
||||||
|
const label = commerceScenarioOptions.find((option) => option.key === scenario)?.label || selectedCloneOutput.label;
|
||||||
|
setGenerationProgress(0);
|
||||||
|
|
||||||
|
const imageTask = scenario === "background" || scenario === "retouch"
|
||||||
|
? await aiGenerationClient.createImageEditTask({
|
||||||
|
imageUrl: uploadedUrls[0]!,
|
||||||
|
function: scenario === "background" ? "background-replace" : "retouch",
|
||||||
|
prompt,
|
||||||
|
ratio: normalizeRatioForApi(pRatio),
|
||||||
|
referenceUrls: uploadedUrls.slice(1),
|
||||||
|
})
|
||||||
|
: await aiGenerationClient.createImageTask({
|
||||||
|
prompt,
|
||||||
|
ratio: normalizeRatioForApi(pRatio),
|
||||||
|
quality: pRatio.includes("720") ? "720P" : "1080P",
|
||||||
|
gridMode: "single",
|
||||||
|
referenceUrls: uploadedUrls,
|
||||||
|
});
|
||||||
|
const { taskId } = imageTask;
|
||||||
|
const storeId = imageGen.submitTask({ title: label, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId });
|
||||||
|
|
||||||
|
const immediateResultUrl = (imageTask as { resultUrl?: string | null }).resultUrl;
|
||||||
|
let resultUrl: string | null = immediateResultUrl ?? null;
|
||||||
|
if (!resultUrl) {
|
||||||
|
trackEcommerceTask(taskId);
|
||||||
|
try {
|
||||||
|
resultUrl = await waitForTask(taskId, {
|
||||||
|
kind: "image",
|
||||||
|
abortRef: imageAbortRef.current,
|
||||||
|
onProgress: (event) => {
|
||||||
|
const sub = Math.max(0, Math.min(100, Number(event.progress) || 0));
|
||||||
|
setGenerationProgress(Math.round(Math.min(99, sub)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
untrackEcommerceTask(taskId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setGenerationProgress(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageAbortRef.current.current) {
|
||||||
|
statusFn("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultUrl) {
|
||||||
|
const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult(scenario), `ecommerce-${scenario}`);
|
||||||
|
resultFn([{ id: `scenario-${scenario}-${stamp}`, src: persistedUrl, label }], uploadedUrls[0]);
|
||||||
|
statusFn("done");
|
||||||
|
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
|
||||||
|
} else {
|
||||||
|
statusFn("failed");
|
||||||
|
imageGen.updateTask(storeId, { status: "failed", error: "No image result returned" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (imageAbortRef.current.current) {
|
||||||
|
statusFn("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (err instanceof ServerRequestError && err.status === 402) {
|
||||||
|
toast.error("余额不足,请充值后继续");
|
||||||
|
} else {
|
||||||
|
toast.error(err instanceof Error ? err.message : "生成失败");
|
||||||
|
}
|
||||||
|
statusFn("failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const generateSetImages = async (
|
const generateSetImages = async (
|
||||||
images: CloneImageItem[],
|
images: CloneImageItem[],
|
||||||
counts: Record<CloneSetCountKey, number>,
|
counts: Record<CloneSetCountKey, number>,
|
||||||
@@ -3598,7 +3820,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
pLanguage: string,
|
pLanguage: string,
|
||||||
pMarket: string,
|
pMarket: string,
|
||||||
setStatusFn: (status: "generating" | "done" | "idle" | "failed") => void,
|
setStatusFn: (status: "generating" | "done" | "idle" | "failed") => void,
|
||||||
setResultFn: (urls: string[]) => void,
|
setResultFn: (urls: string[], sourceUrl?: string) => void,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
setStatusFn("generating");
|
setStatusFn("generating");
|
||||||
try {
|
try {
|
||||||
@@ -3626,24 +3848,25 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const subPrompt = buildSetSubPrompt(countKey, i, count, pPlatform, pRatio, pLanguage, pMarket);
|
const subPrompt = buildSetSubPrompt(countKey, i, count, pPlatform, pRatio, pLanguage, pMarket);
|
||||||
const fullPrompt = userText.trim() ? `${subPrompt} Additional user requirements: ${userText.trim()}` : subPrompt;
|
const fullPrompt = userText.trim() ? `${subPrompt} Additional user requirements: ${userText.trim()}` : subPrompt;
|
||||||
|
|
||||||
const { taskId } = await aiGenerationClient.createImageTask({
|
const imageTask = await aiGenerationClient.createImageTask({
|
||||||
prompt: fullPrompt,
|
prompt: fullPrompt,
|
||||||
ratio: normalizeRatioForApi(pRatio),
|
ratio: normalizeRatioForApi(pRatio),
|
||||||
quality: pRatio.includes("720") ? "720P" : "1080P",
|
quality: pRatio.includes("720") ? "720P" : "1080P",
|
||||||
gridMode: "single",
|
gridMode: "single",
|
||||||
referenceUrls,
|
referenceUrls,
|
||||||
});
|
});
|
||||||
trackEcommerceTask(taskId);
|
const { taskId } = imageTask;
|
||||||
|
|
||||||
const storeId = imageGen.submitTask({ title: `${setCountLabels[countKey].label} ${i + 1}`, type: "image", status: "running", progress: 5, prompt: fullPrompt, sourceView: "ecommerce", taskId });
|
const storeId = imageGen.submitTask({ title: `${setCountLabels[countKey].label} ${i + 1}`, type: "image", status: "running", progress: 5, prompt: fullPrompt, sourceView: "ecommerce", taskId });
|
||||||
|
|
||||||
let resultUrl: string | null = null;
|
let resultUrl: string | null = imageTask.resultUrl ?? null;
|
||||||
|
if (!resultUrl) {
|
||||||
|
trackEcommerceTask(taskId);
|
||||||
try {
|
try {
|
||||||
resultUrl = await waitForTask(taskId, {
|
resultUrl = await waitForTask(taskId, {
|
||||||
kind: "image",
|
kind: "image",
|
||||||
abortRef: imageAbortRef.current,
|
abortRef: imageAbortRef.current,
|
||||||
onProgress: (event) => {
|
onProgress: (event) => {
|
||||||
// 整体进度 = (已完成张数 + 当前张子进度) / 总张数。
|
|
||||||
const sub = Math.max(0, Math.min(100, Number(event.progress) || 0));
|
const sub = Math.max(0, Math.min(100, Number(event.progress) || 0));
|
||||||
const overall = ((completedCount + sub / 100) / totalCount) * 100;
|
const overall = ((completedCount + sub / 100) / totalCount) * 100;
|
||||||
setGenerationProgress(Math.round(Math.min(99, overall)));
|
setGenerationProgress(Math.round(Math.min(99, overall)));
|
||||||
@@ -3652,6 +3875,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
} finally {
|
} finally {
|
||||||
untrackEcommerceTask(taskId);
|
untrackEcommerceTask(taskId);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setGenerationProgress(Math.round(Math.min(99, ((completedCount + 1) / totalCount) * 100)));
|
||||||
|
}
|
||||||
|
|
||||||
if (imageAbortRef.current.current) break;
|
if (imageAbortRef.current.current) break;
|
||||||
|
|
||||||
@@ -3672,7 +3898,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
setStatusFn("idle");
|
setStatusFn("idle");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setResultFn(generatedUrls);
|
setResultFn(generatedUrls, referenceUrls[0]);
|
||||||
setStatusFn(generatedUrls.some(Boolean) ? "done" : "failed");
|
setStatusFn(generatedUrls.some(Boolean) ? "done" : "failed");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (imageAbortRef.current.current) {
|
if (imageAbortRef.current.current) {
|
||||||
@@ -3700,7 +3926,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
pMarket: string,
|
pMarket: string,
|
||||||
tryOnOptions?: EcommerceImagePromptOptions,
|
tryOnOptions?: EcommerceImagePromptOptions,
|
||||||
statusFn?: (status: "generating" | "done" | "idle" | "failed") => void,
|
statusFn?: (status: "generating" | "done" | "idle" | "failed") => void,
|
||||||
resultFn?: (results: CloneResult[]) => void,
|
resultFn?: (results: CloneResult[], sourceUrl?: string) => void,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
statusFn?.("generating");
|
statusFn?.("generating");
|
||||||
try {
|
try {
|
||||||
@@ -3718,18 +3944,21 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
setGenerationProgress(0);
|
setGenerationProgress(0);
|
||||||
|
|
||||||
const { taskId } = await aiGenerationClient.createImageTask({
|
const imageTask = await aiGenerationClient.createImageTask({
|
||||||
prompt,
|
prompt,
|
||||||
ratio: normalizeRatioForApi(pRatio),
|
ratio: normalizeRatioForApi(pRatio),
|
||||||
quality: pRatio.includes("720") ? "720P" : "1080P",
|
quality: pRatio.includes("720") ? "720P" : "1080P",
|
||||||
gridMode: "single",
|
gridMode: "single",
|
||||||
referenceUrls,
|
referenceUrls,
|
||||||
});
|
});
|
||||||
|
const { taskId } = imageTask;
|
||||||
|
|
||||||
|
const outputLabel = cloneOutputOptions.find((option) => option.key === outputKey)?.label || selectedCloneOutput.label;
|
||||||
|
const storeId = imageGen.submitTask({ title: outputLabel, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId });
|
||||||
|
|
||||||
|
let resultUrl: string | null = imageTask.resultUrl ?? null;
|
||||||
|
if (!resultUrl) {
|
||||||
trackEcommerceTask(taskId);
|
trackEcommerceTask(taskId);
|
||||||
|
|
||||||
const storeId = imageGen.submitTask({ title: `电商${outputKey}图`, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId });
|
|
||||||
|
|
||||||
let resultUrl: string | null = null;
|
|
||||||
try {
|
try {
|
||||||
resultUrl = await waitForTask(taskId, {
|
resultUrl = await waitForTask(taskId, {
|
||||||
kind: "image",
|
kind: "image",
|
||||||
@@ -3742,6 +3971,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
} finally {
|
} finally {
|
||||||
untrackEcommerceTask(taskId);
|
untrackEcommerceTask(taskId);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setGenerationProgress(100);
|
||||||
|
}
|
||||||
|
|
||||||
if (imageAbortRef.current.current) {
|
if (imageAbortRef.current.current) {
|
||||||
statusFn?.("idle");
|
statusFn?.("idle");
|
||||||
@@ -3750,7 +3982,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
|
|
||||||
if (resultUrl) {
|
if (resultUrl) {
|
||||||
const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult(outputKey), `ecommerce-${outputKey}`);
|
const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult(outputKey), `ecommerce-${outputKey}`);
|
||||||
resultFn?.([{ id: `ecommerce-${stamp}`, src: persistedUrl, label: selectedCloneOutput.label }]);
|
resultFn?.([{ id: `ecommerce-${stamp}`, src: persistedUrl, label: outputLabel }], referenceUrls[0]);
|
||||||
statusFn?.("done");
|
statusFn?.("done");
|
||||||
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
|
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
|
||||||
} else {
|
} else {
|
||||||
@@ -3773,7 +4005,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerate = () => {
|
const handleGenerate = (defaultIntent?: CommerceDefaultIntent) => {
|
||||||
if (!canGenerate) return;
|
if (!canGenerate) return;
|
||||||
|
|
||||||
if ((appUsage?.balanceCents ?? 0) <= 0) {
|
if ((appUsage?.balanceCents ?? 0) <= 0) {
|
||||||
@@ -3781,7 +4013,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cloneOutput === "set" && cloneSetTotal > 5) {
|
const explicitImageScenario =
|
||||||
|
activeCommerceScenario && activeCommerceScenario !== "popular" && activeCommerceScenario !== "salesVideo"
|
||||||
|
? activeCommerceScenario
|
||||||
|
: null;
|
||||||
|
const routedScenario = defaultIntent?.kind === "image" ? defaultIntent.scenario : explicitImageScenario;
|
||||||
|
const effectiveOutput = routedScenario ? commerceScenarioOutputMap[routedScenario] : cloneOutput;
|
||||||
|
const shouldConfirmSetCount = !defaultIntent && activeCommerceScenario !== "popular" && effectiveOutput === "set" && cloneSetTotal > 5;
|
||||||
|
if (shouldConfirmSetCount) {
|
||||||
if (!window.confirm("将生成 " + String(cloneSetTotal) + " 张图片,可能消耗较多积分,是否继续?")) return;
|
if (!window.confirm("将生成 " + String(cloneSetTotal) + " 张图片,可能消耗较多积分,是否继续?")) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3798,7 +4037,71 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
setPreviewZoom(1);
|
setPreviewZoom(1);
|
||||||
setPreviewOffset({ x: 0, y: 0 });
|
setPreviewOffset({ x: 0, y: 0 });
|
||||||
previewOffsetRef.current = { x: 0, y: 0 };
|
previewOffsetRef.current = { x: 0, y: 0 };
|
||||||
if (cloneOutput === "set") {
|
if (defaultIntent?.kind === "video") {
|
||||||
|
handleStartVideoPlan();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routedScenario) {
|
||||||
|
const routedModeLabel = commerceScenarioOptions.find((option) => option.key === routedScenario)?.label || selectedCloneOutput.label;
|
||||||
|
const routedSettingLabel = commerceScenarioGenerationKind(routedScenario) === "imageEdit" ? "图片编辑 1张" : "单图 1张";
|
||||||
|
const routedGenerationKind = commerceScenarioGenerationKind(routedScenario);
|
||||||
|
void generateCommerceScenarioImage(
|
||||||
|
routedScenario, productImages, requirement,
|
||||||
|
platform, ratio, language, market,
|
||||||
|
(s) => {
|
||||||
|
setStatus(s as ProductCloneStatus);
|
||||||
|
if (s === "generating") {
|
||||||
|
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({
|
||||||
|
...turn,
|
||||||
|
output: effectiveOutput,
|
||||||
|
modeLabel: routedModeLabel,
|
||||||
|
settingLabel: routedSettingLabel,
|
||||||
|
generationKind: routedGenerationKind,
|
||||||
|
status: "generating",
|
||||||
|
errorMessage: undefined,
|
||||||
|
}));
|
||||||
|
} else if (s === "failed") {
|
||||||
|
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({
|
||||||
|
...turn,
|
||||||
|
output: effectiveOutput,
|
||||||
|
modeLabel: routedModeLabel,
|
||||||
|
settingLabel: routedSettingLabel,
|
||||||
|
generationKind: routedGenerationKind,
|
||||||
|
status: "failed",
|
||||||
|
errorMessage: "生成失败,请检查网络或参数后重试。",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(newResults, sourceUrl) => {
|
||||||
|
const validResults = newResults.filter((item) => item.src);
|
||||||
|
const turnProductImages = withStableSourceImage(productImages, sourceUrl);
|
||||||
|
setResults(validResults);
|
||||||
|
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({
|
||||||
|
...turn,
|
||||||
|
output: effectiveOutput,
|
||||||
|
modeLabel: routedModeLabel,
|
||||||
|
settingLabel: routedSettingLabel,
|
||||||
|
generationKind: routedGenerationKind,
|
||||||
|
status: validResults.length ? "done" : "failed",
|
||||||
|
errorMessage: validResults.length ? undefined : newResults[0]?.label || "生成未返回结果",
|
||||||
|
productImages: turnProductImages,
|
||||||
|
results: validResults,
|
||||||
|
setResultImages: [],
|
||||||
|
}));
|
||||||
|
if (validResults.length && validResults[0].src) {
|
||||||
|
upsertCanvasNode({
|
||||||
|
id: pendingTurnId,
|
||||||
|
mode: routedScenario,
|
||||||
|
sourceImage: sourceUrl || productImages[0]?.src,
|
||||||
|
results: validResults,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
lastFailedActionRef.current = () => handleGenerate(defaultIntent);
|
||||||
|
} else if (cloneOutput === "set") {
|
||||||
void generateSetImages(
|
void generateSetImages(
|
||||||
productImages, cloneSetCounts, requirement,
|
productImages, cloneSetCounts, requirement,
|
||||||
platform, ratio, language, market,
|
platform, ratio, language, market,
|
||||||
@@ -3810,14 +4113,17 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" }));
|
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(urls) => {
|
(urls, sourceUrl) => {
|
||||||
setProductSetResultImages(urls);
|
setProductSetResultImages(urls);
|
||||||
const validUrls = urls.filter(Boolean);
|
const validUrls = urls.filter(Boolean);
|
||||||
|
const stableSourceUrl = sourceUrl || (productImages[0]?.src?.startsWith("blob:") ? undefined : productImages[0]?.src);
|
||||||
|
const turnProductImages = withStableSourceImage(productImages, stableSourceUrl);
|
||||||
const resultCards = validUrls.map((src, i) => ({ id: `set-${Date.now()}-${i}`, src, label: `套图 ${i + 1}` }));
|
const resultCards = validUrls.map((src, i) => ({ id: `set-${Date.now()}-${i}`, src, label: `套图 ${i + 1}` }));
|
||||||
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({
|
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({
|
||||||
...turn,
|
...turn,
|
||||||
status: validUrls.length ? "done" : "failed",
|
status: validUrls.length ? "done" : "failed",
|
||||||
errorMessage: validUrls.length ? undefined : "生成未返回结果",
|
errorMessage: validUrls.length ? undefined : "生成未返回结果",
|
||||||
|
productImages: turnProductImages,
|
||||||
setResultImages: validUrls,
|
setResultImages: validUrls,
|
||||||
results: resultCards,
|
results: resultCards,
|
||||||
}));
|
}));
|
||||||
@@ -3825,7 +4131,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
upsertCanvasNode({
|
upsertCanvasNode({
|
||||||
id: pendingTurnId,
|
id: pendingTurnId,
|
||||||
mode: "set",
|
mode: "set",
|
||||||
sourceImage: productImages[0]?.src,
|
sourceImage: stableSourceUrl || productImages[0]?.src,
|
||||||
results: resultCards,
|
results: resultCards,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
@@ -3859,13 +4165,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" }));
|
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(newResults: CloneResult[]) => {
|
(newResults: CloneResult[], sourceUrl?: string) => {
|
||||||
const validResults = newResults.filter((item) => item.src);
|
const validResults = newResults.filter((item) => item.src);
|
||||||
|
const turnProductImages = withStableSourceImage(productImages, sourceUrl);
|
||||||
setResults(validResults);
|
setResults(validResults);
|
||||||
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({
|
updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({
|
||||||
...turn,
|
...turn,
|
||||||
status: validResults.length ? "done" : "failed",
|
status: validResults.length ? "done" : "failed",
|
||||||
errorMessage: validResults.length ? undefined : newResults[0]?.label || "生成未返回结果",
|
errorMessage: validResults.length ? undefined : newResults[0]?.label || "生成未返回结果",
|
||||||
|
productImages: turnProductImages,
|
||||||
results: validResults,
|
results: validResults,
|
||||||
setResultImages: [],
|
setResultImages: [],
|
||||||
}));
|
}));
|
||||||
@@ -3873,7 +4181,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
upsertCanvasNode({
|
upsertCanvasNode({
|
||||||
id: pendingTurnId,
|
id: pendingTurnId,
|
||||||
mode: cloneOutput,
|
mode: cloneOutput,
|
||||||
sourceImage: productImages[0]?.src,
|
sourceImage: sourceUrl || productImages[0]?.src,
|
||||||
results: validResults,
|
results: validResults,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
@@ -4460,6 +4768,42 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
referenceImageCount: record.referenceImages.length,
|
referenceImageCount: record.referenceImages.length,
|
||||||
turnCount: record.turns?.length ?? 1,
|
turnCount: record.turns?.length ?? 1,
|
||||||
latestTurnId: record.turns?.[record.turns.length - 1]?.id,
|
latestTurnId: record.turns?.[record.turns.length - 1]?.id,
|
||||||
|
modeLabel: record.modeLabel,
|
||||||
|
settingLabel: record.settingLabel,
|
||||||
|
generationKind: record.generationKind,
|
||||||
|
referenceImages: record.referenceImages.map(({ id, src, name, width, height, format, mimeType, ossKey }) => ({
|
||||||
|
id,
|
||||||
|
src,
|
||||||
|
name,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
format,
|
||||||
|
mimeType,
|
||||||
|
ossKey,
|
||||||
|
})),
|
||||||
|
turns: (record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)]).map((turn) => ({
|
||||||
|
...turn,
|
||||||
|
productImages: turn.productImages.map(({ id, src, name, width, height, format, mimeType, ossKey }) => ({
|
||||||
|
id,
|
||||||
|
src,
|
||||||
|
name,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
format,
|
||||||
|
mimeType,
|
||||||
|
ossKey,
|
||||||
|
})),
|
||||||
|
referenceImages: turn.referenceImages.map(({ id, src, name, width, height, format, mimeType, ossKey }) => ({
|
||||||
|
id,
|
||||||
|
src,
|
||||||
|
name,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
format,
|
||||||
|
mimeType,
|
||||||
|
ossKey,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
createdAt: new Date(record.createdAt).toISOString(),
|
createdAt: new Date(record.createdAt).toISOString(),
|
||||||
});
|
});
|
||||||
@@ -4494,6 +4838,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
createdAt,
|
createdAt,
|
||||||
status: turnStatus,
|
status: turnStatus,
|
||||||
output: cloneOutput,
|
output: cloneOutput,
|
||||||
|
modeLabel: undefined,
|
||||||
|
settingLabel: undefined,
|
||||||
|
generationKind: cloneOutput === "video" ? "video" : cloneOutput === "set" ? "imageSet" : "singleImage",
|
||||||
platform,
|
platform,
|
||||||
market,
|
market,
|
||||||
language,
|
language,
|
||||||
@@ -4514,6 +4861,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
status: turn.status,
|
status: turn.status,
|
||||||
errorMessage: turn.status === "failed" ? turn.errorMessage : undefined,
|
errorMessage: turn.status === "failed" ? turn.errorMessage : undefined,
|
||||||
output: turn.output,
|
output: turn.output,
|
||||||
|
modeLabel: turn.modeLabel,
|
||||||
|
settingLabel: turn.settingLabel,
|
||||||
|
generationKind: turn.generationKind,
|
||||||
platform: turn.platform,
|
platform: turn.platform,
|
||||||
market: turn.market,
|
market: turn.market,
|
||||||
language: turn.language,
|
language: turn.language,
|
||||||
@@ -4559,6 +4909,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
createdAt,
|
createdAt,
|
||||||
status: turn.status,
|
status: turn.status,
|
||||||
output: turn.output,
|
output: turn.output,
|
||||||
|
modeLabel: turn.modeLabel,
|
||||||
|
settingLabel: turn.settingLabel,
|
||||||
|
generationKind: turn.generationKind,
|
||||||
platform: turn.platform,
|
platform: turn.platform,
|
||||||
market: turn.market,
|
market: turn.market,
|
||||||
language: turn.language,
|
language: turn.language,
|
||||||
@@ -4668,7 +5021,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
items.push({
|
items.push({
|
||||||
id: turn.id,
|
id: turn.id,
|
||||||
mode: turn.output,
|
mode: turn.output,
|
||||||
sourceImage: turn.productImages[0]?.src,
|
sourceImage: turn.productImages[0]?.src?.startsWith("blob:") ? undefined : turn.productImages[0]?.src,
|
||||||
results: turnResults,
|
results: turnResults,
|
||||||
createdAt: turn.createdAt,
|
createdAt: turn.createdAt,
|
||||||
x: index * 420,
|
x: index * 420,
|
||||||
@@ -4722,7 +5075,35 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
|
|
||||||
window.setTimeout(() => setHistoryRefreshMessage(""), 3000);
|
window.setTimeout(() => setHistoryRefreshMessage(""), 3000);
|
||||||
};
|
};
|
||||||
|
const refreshEcommerceHistoryFromServer = async () => {
|
||||||
|
if (historyRefreshLockRef.current) return;
|
||||||
|
historyRefreshLockRef.current = true;
|
||||||
|
setIsHistoryRefreshing(true);
|
||||||
|
setHistoryRefreshMessage("Refreshing...");
|
||||||
|
setHistoryRefreshStamp(Date.now());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serverRecords = isAuthenticated ? await listEcommerceGenerationHistory(30) : [];
|
||||||
|
const mergedRecords = mergeEcommerceHistoryRecords(serverRecords, ecommerceHistoryRecords, readEcommerceHistoryRecords());
|
||||||
|
writeEcommerceHistoryRecords(mergedRecords);
|
||||||
|
setHistoryRefreshTick((tick) => tick + 1);
|
||||||
|
setEcommerceHistoryRecords(mergedRecords);
|
||||||
|
setHistoryRefreshMessage(mergedRecords.length ? "Synced " + String(mergedRecords.length) + " records" : "No history records");
|
||||||
|
setHistoryRefreshStamp(Date.now());
|
||||||
|
} catch {
|
||||||
|
const mergedRecords = mergeEcommerceHistoryRecords(ecommerceHistoryRecords, readEcommerceHistoryRecords());
|
||||||
|
writeEcommerceHistoryRecords(mergedRecords);
|
||||||
|
setHistoryRefreshTick((tick) => tick + 1);
|
||||||
|
setEcommerceHistoryRecords(mergedRecords);
|
||||||
|
setHistoryRefreshMessage(mergedRecords.length ? "Loaded " + String(mergedRecords.length) + " local records" : "Server history unavailable");
|
||||||
|
setHistoryRefreshStamp(Date.now());
|
||||||
|
} finally {
|
||||||
|
setIsHistoryRefreshing(false);
|
||||||
|
historyRefreshLockRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setTimeout(() => setHistoryRefreshMessage(""), 3000);
|
||||||
|
};
|
||||||
const deleteHistoryRecord = (recordId: string, event: ReactMouseEvent) => {
|
const deleteHistoryRecord = (recordId: string, event: ReactMouseEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const record = ecommerceHistoryRecords.find((r) => r.id === recordId);
|
const record = ecommerceHistoryRecords.find((r) => r.id === recordId);
|
||||||
@@ -5358,7 +5739,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const canPlanVideo = productImages.length > 0 || requirement.trim().length > 0;
|
const canPlanVideo = productImages.length > 0 || requirement.trim().length > 0;
|
||||||
const commandGenerateDisabled = cloneOutput === "video" ? false : !canGenerate;
|
const isDefaultCommandRouting = activeCommerceScenario === null || activeCommerceScenario === "popular";
|
||||||
|
const commandGenerateDisabled = isDefaultIntentRouting || (isDefaultCommandRouting ? !canPlanVideo : cloneOutput === "video" ? false : !canGenerate);
|
||||||
|
|
||||||
function handleStartVideoPlan() {
|
function handleStartVideoPlan() {
|
||||||
if (!canPlanVideo) {
|
if (!canPlanVideo) {
|
||||||
@@ -5373,11 +5755,36 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
setVideoPlanTrigger((value) => value + 1);
|
setVideoPlanTrigger((value) => value + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCommandGenerate = () => {
|
const handleCommandGenerate = async () => {
|
||||||
if (cloneOutput === "video") {
|
if (cloneOutput === "video") {
|
||||||
handleStartVideoPlan();
|
handleStartVideoPlan();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isDefaultCommandRouting) {
|
||||||
|
if (!canPlanVideo) return;
|
||||||
|
setIsDefaultIntentRouting(true);
|
||||||
|
try {
|
||||||
|
const intent = await classifyDefaultCommerceIntent({
|
||||||
|
prompt: requirement,
|
||||||
|
referenceCount: productImages.length,
|
||||||
|
ratio,
|
||||||
|
language,
|
||||||
|
platform,
|
||||||
|
});
|
||||||
|
if (intent.kind === "video") {
|
||||||
|
handleStartVideoPlan();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!canGenerate) {
|
||||||
|
toast.info("请先上传商品图");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleGenerate(intent);
|
||||||
|
} finally {
|
||||||
|
setIsDefaultIntentRouting(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
handleGenerate();
|
handleGenerate();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -5703,12 +6110,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{node.sourceImage ? (
|
|
||||||
<div className="clone-ai-source-stack">
|
<div className="clone-ai-source-stack">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="clone-ai-source-corner-action"
|
className="clone-ai-source-corner-action"
|
||||||
onClick={() => openProductSetPreview({ src: node.sourceImage!, label: "原图素材" })}
|
onClick={node.sourceImage ? () => openProductSetPreview({ src: node.sourceImage!, label: "原图素材" }) : undefined}
|
||||||
|
disabled={!node.sourceImage}
|
||||||
>
|
>
|
||||||
原图素材
|
原图素材
|
||||||
</button>
|
</button>
|
||||||
@@ -5716,12 +6123,22 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="clone-ai-main-result"
|
className="clone-ai-main-result"
|
||||||
aria-label="预览原图素材"
|
aria-label="预览原图素材"
|
||||||
onClick={() => openProductSetPreview({ src: node.sourceImage!, label: "原图素材" })}
|
onClick={node.sourceImage ? () => openProductSetPreview({ src: node.sourceImage!, label: "原图素材" }) : undefined}
|
||||||
|
disabled={!node.sourceImage}
|
||||||
>
|
>
|
||||||
<img src={node.sourceImage} alt="原图素材" />
|
{node.sourceImage ? (
|
||||||
|
<img
|
||||||
|
src={node.sourceImage}
|
||||||
|
alt="原图素材"
|
||||||
|
onError={(event) => {
|
||||||
|
event.currentTarget.style.display = "none";
|
||||||
|
event.currentTarget.parentElement?.classList.add("is-missing-source");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="clone-ai-source-missing">素材不可用</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
||||||
<div className="clone-ai-result-stack">
|
<div className="clone-ai-result-stack">
|
||||||
<span className="clone-ai-node-label">{node.mode === "set" ? "套图" : node.mode === "detail" ? "详情图" : node.mode === "model" ? "模特图" : node.mode === "hot" ? "爆款图" : node.mode}</span>
|
<span className="clone-ai-node-label">{node.mode === "set" ? "套图" : node.mode === "detail" ? "详情图" : node.mode === "model" ? "模特图" : node.mode === "hot" ? "爆款图" : node.mode}</span>
|
||||||
@@ -5978,7 +6395,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="ecom-command-submit-row">
|
<div className="ecom-command-submit-row">
|
||||||
<button type="button" className="clone-ai-send-button ecom-command-send" disabled={commandGenerateDisabled} onClick={handleCommandGenerate} aria-label={clonePrimaryLabel}>
|
<button type="button" className="clone-ai-send-button ecom-command-send" disabled={commandGenerateDisabled} onClick={handleCommandGenerate} aria-label={clonePrimaryLabel}>
|
||||||
{status === "generating" ? <LoadingOutlined /> : <PaperPlaneRight size={18} weight="fill" />}
|
{status === "generating" || isDefaultIntentRouting ? <LoadingOutlined /> : <PaperPlaneRight size={18} weight="fill" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -7817,6 +8234,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
: [buildHistoryTurnFromRecord(activeHistoryRecord)]
|
: [buildHistoryTurnFromRecord(activeHistoryRecord)]
|
||||||
: [];
|
: [];
|
||||||
const getHistoryTurnSettingLabel = (turn: EcommerceHistoryTurn) => {
|
const getHistoryTurnSettingLabel = (turn: EcommerceHistoryTurn) => {
|
||||||
|
if (turn.settingLabel) return turn.settingLabel;
|
||||||
|
if (turn.output === "set" && turn.results?.length && !turn.setResultImages?.length) {
|
||||||
|
return `单图 ${turn.results.length}张`;
|
||||||
|
}
|
||||||
if (turn.output === "set") {
|
if (turn.output === "set") {
|
||||||
const total = cloneSetCountKeys.reduce((sum, key) => sum + (turn.setCounts?.[key] ?? 0), 0);
|
const total = cloneSetCountKeys.reduce((sum, key) => sum + (turn.setCounts?.[key] ?? 0), 0);
|
||||||
return `套图 ${total || 1}张`;
|
return `套图 ${total || 1}张`;
|
||||||
@@ -7901,7 +8322,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
<div className="clone-ai-conversation-body">
|
<div className="clone-ai-conversation-body">
|
||||||
{activeConversationTurns.map((turn, index) => {
|
{activeConversationTurns.map((turn, index) => {
|
||||||
const turnResults = getTurnResults(turn);
|
const turnResults = getTurnResults(turn);
|
||||||
const outputLabel = cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label;
|
const outputLabel = turn.modeLabel || cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label;
|
||||||
const turnMeta = [
|
const turnMeta = [
|
||||||
{ label: "平台", value: turn.platform },
|
{ label: "平台", value: turn.platform },
|
||||||
{ label: "语种", value: turn.language },
|
{ label: "语种", value: turn.language },
|
||||||
@@ -7912,7 +8333,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
return (
|
return (
|
||||||
<Fragment key={turn.id}>
|
<Fragment key={turn.id}>
|
||||||
<section className={`clone-ai-chat-message clone-ai-chat-message--user${index > 0 ? " clone-ai-chat-message--followup" : ""}`}>
|
<section className={`clone-ai-chat-message clone-ai-chat-message--user${index > 0 ? " clone-ai-chat-message--followup" : ""}`}>
|
||||||
<span>{index === 0 ? "需求" : `继续生成 ${index + 1}`}</span>
|
<span>{index === 0 ? "需求" : `继续生成 ${index + 1} · ${outputLabel}`}</span>
|
||||||
<p>{turn.requirement?.trim() || "上传商品素材,描述你想生成的商品图、详情图、模特图或短视频。"}</p>
|
<p>{turn.requirement?.trim() || "上传商品素材,描述你想生成的商品图、详情图、模特图或短视频。"}</p>
|
||||||
<div className="clone-ai-chat-meta" aria-label="需求参数">
|
<div className="clone-ai-chat-meta" aria-label="需求参数">
|
||||||
{turnMeta.map((item) => (
|
{turnMeta.map((item) => (
|
||||||
@@ -7999,7 +8420,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
onToggleCollapsed={() => setIsCommandHistoryCollapsed((current) => !current)}
|
onToggleCollapsed={() => setIsCommandHistoryCollapsed((current) => !current)}
|
||||||
onCollapse={() => setIsCommandHistoryCollapsed(true)}
|
onCollapse={() => setIsCommandHistoryCollapsed(true)}
|
||||||
onNewConversation={handleNewEcommerceConversation}
|
onNewConversation={handleNewEcommerceConversation}
|
||||||
onRefresh={refreshEcommerceHistory}
|
onRefresh={refreshEcommerceHistoryFromServer}
|
||||||
onOpenRecord={openEcommerceHistoryRecord}
|
onOpenRecord={openEcommerceHistoryRecord}
|
||||||
onDeleteRecord={deleteHistoryRecord}
|
onDeleteRecord={deleteHistoryRecord}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,37 @@
|
|||||||
import {
|
import {
|
||||||
buildGenerationOssScope,
|
buildGenerationOssScope,
|
||||||
deleteGenerationRecordByClientId,
|
deleteGenerationRecordByClientId,
|
||||||
|
listGenerationRecords,
|
||||||
saveGenerationRecord,
|
saveGenerationRecord,
|
||||||
|
type GenerationRecord,
|
||||||
type GenerationRecordAsset,
|
type GenerationRecordAsset,
|
||||||
type SaveGenerationRecordInput,
|
type SaveGenerationRecordInput,
|
||||||
} from "../../api/generationRecordClient";
|
} from "../../api/generationRecordClient";
|
||||||
|
import {
|
||||||
|
defaultCloneDetailModuleIds,
|
||||||
|
defaultCloneSetCounts,
|
||||||
|
ecommerceHistoryStorageKey,
|
||||||
|
normalizeEcommerceHistoryRecord,
|
||||||
|
type CloneImageItem,
|
||||||
|
type CloneReplicateLevelKey,
|
||||||
|
type CloneResult,
|
||||||
|
type CloneSetCountKey,
|
||||||
|
type EcommerceHistoryRecord,
|
||||||
|
type EcommerceHistoryStatus,
|
||||||
|
type EcommerceHistoryTurn,
|
||||||
|
} from "./utils/clonePersistence";
|
||||||
|
import {
|
||||||
|
defaultCloneOutput,
|
||||||
|
defaultEcommercePlatform,
|
||||||
|
getPlatformDefaultLanguage,
|
||||||
|
getPlatformDefaultRatio,
|
||||||
|
marketOptions,
|
||||||
|
type CloneOutputKey,
|
||||||
|
normalizeLanguageForPlatform,
|
||||||
|
normalizeMarket,
|
||||||
|
normalizePlatform,
|
||||||
|
normalizeRatioForPlatform,
|
||||||
|
} from "./utils/platformRules";
|
||||||
|
|
||||||
export const ecommerceOssScopes = {
|
export const ecommerceOssScopes = {
|
||||||
productSource: buildGenerationOssScope(["ecommerce", "source", "product"]),
|
productSource: buildGenerationOssScope(["ecommerce", "source", "product"]),
|
||||||
@@ -68,3 +95,237 @@ export function saveUnifiedEcommerceGenerationRecord(input: EcommerceUnifiedReco
|
|||||||
export async function deleteEcommerceGenerationRecord(clientRecordId: string): Promise<void> {
|
export async function deleteEcommerceGenerationRecord(clientRecordId: string): Promise<void> {
|
||||||
await deleteGenerationRecordByClientId(clientRecordId);
|
await deleteGenerationRecordByClientId(clientRecordId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ecommerceHistoryStatuses = new Set<EcommerceHistoryStatus>(["generating", "done", "failed"]);
|
||||||
|
const cloneOutputs = new Set<CloneOutputKey>(["set", "detail", "model", "video", "hot"]);
|
||||||
|
const generationKinds = new Set<EcommerceHistoryTurn["generationKind"]>(["singleImage", "imageEdit", "imageSet", "video"]);
|
||||||
|
const replicateLevels = new Set<CloneReplicateLevelKey>(["style", "high"]);
|
||||||
|
|
||||||
|
function stringValue(value: unknown, fallback = ""): string {
|
||||||
|
return typeof value === "string" && value.trim() ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown, fallback: number): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectValue(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringArrayValue(value: unknown): string[] {
|
||||||
|
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && Boolean(item.trim())) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOutput(value: unknown): CloneOutputKey {
|
||||||
|
if (value === "short-video") return "video";
|
||||||
|
return cloneOutputs.has(value as CloneOutputKey) ? value as CloneOutputKey : defaultCloneOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(value: unknown): EcommerceHistoryStatus {
|
||||||
|
if (value === "completed") return "done";
|
||||||
|
return ecommerceHistoryStatuses.has(value as EcommerceHistoryStatus) ? value as EcommerceHistoryStatus : "done";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGenerationKind(value: unknown, output: CloneOutputKey): EcommerceHistoryTurn["generationKind"] {
|
||||||
|
if (generationKinds.has(value as EcommerceHistoryTurn["generationKind"])) return value as EcommerceHistoryTurn["generationKind"];
|
||||||
|
if (output === "video") return "video";
|
||||||
|
if (output === "set") return "imageSet";
|
||||||
|
return "singleImage";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReplicateLevel(value: unknown): CloneReplicateLevelKey {
|
||||||
|
return replicateLevels.has(value as CloneReplicateLevelKey) ? value as CloneReplicateLevelKey : "high";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSetCounts(value: unknown): Record<CloneSetCountKey, number> {
|
||||||
|
const counts = objectValue(value);
|
||||||
|
return {
|
||||||
|
selling: numberValue(counts.selling, defaultCloneSetCounts.selling),
|
||||||
|
white: numberValue(counts.white, defaultCloneSetCounts.white),
|
||||||
|
scene: numberValue(counts.scene, defaultCloneSetCounts.scene),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampValue(value: unknown, fallback: number): number {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = new Date(value).getTime();
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageFromAsset(asset: GenerationRecordAsset, index: number): CloneImageItem {
|
||||||
|
return {
|
||||||
|
id: stringValue(asset.taskId, `server-source-${index + 1}`),
|
||||||
|
src: asset.url,
|
||||||
|
name: stringValue(asset.label, `source-${index + 1}`),
|
||||||
|
ossKey: asset.ossKey || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultFromAsset(asset: GenerationRecordAsset, index: number): CloneResult {
|
||||||
|
return {
|
||||||
|
id: stringValue(asset.taskId, `server-result-${index + 1}`),
|
||||||
|
src: asset.url,
|
||||||
|
label: stringValue(asset.label, `result-${index + 1}`),
|
||||||
|
type: asset.mediaType === "video" ? "video" : "image",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHistoryImages(value: unknown, fallback: CloneImageItem[] = []): CloneImageItem[] {
|
||||||
|
if (!Array.isArray(value)) return fallback;
|
||||||
|
return value
|
||||||
|
.map((item, index): CloneImageItem | null => {
|
||||||
|
const record = objectValue(item);
|
||||||
|
const src = stringValue(record.src);
|
||||||
|
if (!src) return null;
|
||||||
|
return {
|
||||||
|
id: stringValue(record.id, `server-image-${index + 1}`),
|
||||||
|
src,
|
||||||
|
name: stringValue(record.name, `image-${index + 1}`),
|
||||||
|
width: typeof record.width === "number" ? record.width : undefined,
|
||||||
|
height: typeof record.height === "number" ? record.height : undefined,
|
||||||
|
format: typeof record.format === "string" ? record.format : undefined,
|
||||||
|
mimeType: typeof record.mimeType === "string" ? record.mimeType : undefined,
|
||||||
|
ossKey: typeof record.ossKey === "string" ? record.ossKey : undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is CloneImageItem => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHistoryResults(value: unknown, fallback: CloneResult[] = []): CloneResult[] {
|
||||||
|
if (!Array.isArray(value)) return fallback;
|
||||||
|
return value
|
||||||
|
.map((item, index): CloneResult | null => {
|
||||||
|
const record = objectValue(item);
|
||||||
|
const src = stringValue(record.src);
|
||||||
|
if (!src) return null;
|
||||||
|
return {
|
||||||
|
id: stringValue(record.id, `server-result-${index + 1}`),
|
||||||
|
src,
|
||||||
|
label: stringValue(record.label, `result-${index + 1}`),
|
||||||
|
type: record.type === "video" ? "video" : "image",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is CloneResult => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTurnFromMetadata(value: unknown, fallback: Omit<EcommerceHistoryTurn, "id" | "createdAt">, fallbackCreatedAt: number, index: number): EcommerceHistoryTurn | null {
|
||||||
|
const turn = objectValue(value);
|
||||||
|
if (!Object.keys(turn).length) return null;
|
||||||
|
const output = normalizeOutput(turn.output ?? fallback.output);
|
||||||
|
const platform = normalizePlatform(stringValue(turn.platform, fallback.platform));
|
||||||
|
const market = normalizeMarket(stringValue(turn.market, fallback.market));
|
||||||
|
const language = normalizeLanguageForPlatform(platform, market, stringValue(turn.language, fallback.language));
|
||||||
|
const ratio = normalizeRatioForPlatform(platform, stringValue(turn.ratio, fallback.ratio), output === "hot" ? undefined : output);
|
||||||
|
const results = normalizeHistoryResults(turn.results, fallback.results);
|
||||||
|
const setResultImages = stringArrayValue(turn.setResultImages).length ? stringArrayValue(turn.setResultImages) : fallback.setResultImages;
|
||||||
|
const status = normalizeStatus(turn.status ?? fallback.status);
|
||||||
|
return {
|
||||||
|
id: stringValue(turn.id, `server-turn-${index + 1}`),
|
||||||
|
createdAt: timestampValue(turn.createdAt, fallbackCreatedAt),
|
||||||
|
status,
|
||||||
|
errorMessage: status === "failed" ? stringValue(turn.errorMessage, fallback.errorMessage) : undefined,
|
||||||
|
output,
|
||||||
|
modeLabel: typeof turn.modeLabel === "string" ? turn.modeLabel : fallback.modeLabel,
|
||||||
|
settingLabel: typeof turn.settingLabel === "string" ? turn.settingLabel : fallback.settingLabel,
|
||||||
|
generationKind: normalizeGenerationKind(turn.generationKind ?? fallback.generationKind, output),
|
||||||
|
platform,
|
||||||
|
market,
|
||||||
|
language,
|
||||||
|
ratio,
|
||||||
|
requirement: stringValue(turn.requirement, fallback.requirement),
|
||||||
|
productImages: normalizeHistoryImages(turn.productImages, fallback.productImages),
|
||||||
|
results,
|
||||||
|
setResultImages,
|
||||||
|
setCounts: normalizeSetCounts(turn.setCounts ?? fallback.setCounts),
|
||||||
|
detailModules: stringArrayValue(turn.detailModules).length ? stringArrayValue(turn.detailModules) : fallback.detailModules,
|
||||||
|
modelScenes: stringArrayValue(turn.modelScenes).length ? stringArrayValue(turn.modelScenes) : fallback.modelScenes,
|
||||||
|
referenceImages: normalizeHistoryImages(turn.referenceImages, fallback.referenceImages),
|
||||||
|
replicateLevel: normalizeReplicateLevel(turn.replicateLevel ?? fallback.replicateLevel),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ecommerceHistoryRecordFromGenerationRecord(record: GenerationRecord): EcommerceHistoryRecord | null {
|
||||||
|
if (record.tool !== "ecommerce") return null;
|
||||||
|
|
||||||
|
const createdAt = timestampValue(record.createdAt, Date.now());
|
||||||
|
const output = normalizeOutput(record.mode);
|
||||||
|
const config = objectValue(record.config);
|
||||||
|
const metadata = objectValue(record.metadata);
|
||||||
|
const sourceImages = record.assets.filter((asset) => asset.role === "source").map(imageFromAsset);
|
||||||
|
const results = record.assets.filter((asset) => asset.role === "result").map(resultFromAsset);
|
||||||
|
const hasHistoryMarker = metadata.localHistoryStorageKey === ecommerceHistoryStorageKey || typeof metadata.turnCount === "number";
|
||||||
|
if (!hasHistoryMarker && record.status !== "completed") return null;
|
||||||
|
if (!hasHistoryMarker && !sourceImages.length && !results.length) return null;
|
||||||
|
const platform = normalizePlatform(stringValue(config.platform, defaultEcommercePlatform));
|
||||||
|
const market = normalizeMarket(stringValue(config.market, marketOptions[0]));
|
||||||
|
const language = normalizeLanguageForPlatform(platform, market, stringValue(config.language, getPlatformDefaultLanguage(platform, market)));
|
||||||
|
const ratio = normalizeRatioForPlatform(platform, stringValue(config.ratio, getPlatformDefaultRatio(platform, output === "hot" ? undefined : output)), output === "hot" ? undefined : output);
|
||||||
|
const setResultImages = results.filter((item) => item.type !== "video").map((item) => item.src);
|
||||||
|
const status = normalizeStatus(record.status);
|
||||||
|
const baseTurn: Omit<EcommerceHistoryTurn, "id" | "createdAt"> = {
|
||||||
|
status,
|
||||||
|
errorMessage: status === "failed" ? "生成失败" : undefined,
|
||||||
|
output,
|
||||||
|
modeLabel: typeof metadata.modeLabel === "string" ? metadata.modeLabel : undefined,
|
||||||
|
settingLabel: typeof metadata.settingLabel === "string" ? metadata.settingLabel : undefined,
|
||||||
|
generationKind: normalizeGenerationKind(metadata.generationKind, output),
|
||||||
|
platform,
|
||||||
|
market,
|
||||||
|
language,
|
||||||
|
ratio,
|
||||||
|
requirement: record.prompt ?? "",
|
||||||
|
productImages: sourceImages,
|
||||||
|
results,
|
||||||
|
setResultImages: output === "set" ? setResultImages : [],
|
||||||
|
setCounts: normalizeSetCounts(config.setCounts),
|
||||||
|
detailModules: stringArrayValue(config.detailModules).length ? stringArrayValue(config.detailModules) : defaultCloneDetailModuleIds,
|
||||||
|
modelScenes: stringArrayValue(config.modelScenes),
|
||||||
|
referenceImages: normalizeHistoryImages(metadata.referenceImages),
|
||||||
|
replicateLevel: normalizeReplicateLevel(config.replicateLevel),
|
||||||
|
};
|
||||||
|
const turns = Array.isArray(metadata.turns)
|
||||||
|
? metadata.turns
|
||||||
|
.map((turn, index) => buildTurnFromMetadata(turn, baseTurn, createdAt, index))
|
||||||
|
.filter((turn): turn is EcommerceHistoryTurn => Boolean(turn))
|
||||||
|
: [];
|
||||||
|
const latestTurn = turns[turns.length - 1] ?? { id: `${record.clientRecordId}-turn-initial`, createdAt, ...baseTurn };
|
||||||
|
|
||||||
|
return normalizeEcommerceHistoryRecord({
|
||||||
|
id: record.clientRecordId,
|
||||||
|
title: record.title || record.prompt || "生成记录",
|
||||||
|
createdAt,
|
||||||
|
status: latestTurn.status,
|
||||||
|
errorMessage: latestTurn.errorMessage,
|
||||||
|
output: latestTurn.output,
|
||||||
|
modeLabel: latestTurn.modeLabel,
|
||||||
|
settingLabel: latestTurn.settingLabel,
|
||||||
|
generationKind: latestTurn.generationKind,
|
||||||
|
platform: latestTurn.platform,
|
||||||
|
market: latestTurn.market,
|
||||||
|
language: latestTurn.language,
|
||||||
|
ratio: latestTurn.ratio,
|
||||||
|
requirement: latestTurn.requirement,
|
||||||
|
productImages: latestTurn.productImages,
|
||||||
|
results: latestTurn.results,
|
||||||
|
setResultImages: latestTurn.setResultImages,
|
||||||
|
setCounts: latestTurn.setCounts,
|
||||||
|
detailModules: latestTurn.detailModules,
|
||||||
|
modelScenes: latestTurn.modelScenes,
|
||||||
|
referenceImages: latestTurn.referenceImages,
|
||||||
|
replicateLevel: latestTurn.replicateLevel,
|
||||||
|
turns: turns.length ? turns : [latestTurn],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEcommerceGenerationHistory(limit = 30): Promise<EcommerceHistoryRecord[]> {
|
||||||
|
const payload = await listGenerationRecords({ tool: "ecommerce", limit });
|
||||||
|
return payload.items
|
||||||
|
.map(ecommerceHistoryRecordFromGenerationRecord)
|
||||||
|
.filter((record): record is EcommerceHistoryRecord => Boolean(record))
|
||||||
|
.sort((a, b) => b.createdAt - a.createdAt)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ export interface EcommerceHistoryTurn {
|
|||||||
status: EcommerceHistoryStatus;
|
status: EcommerceHistoryStatus;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
output: CloneOutputKey;
|
output: CloneOutputKey;
|
||||||
|
modeLabel?: string;
|
||||||
|
settingLabel?: string;
|
||||||
|
generationKind?: "singleImage" | "imageEdit" | "imageSet" | "video";
|
||||||
platform: string;
|
platform: string;
|
||||||
market: string;
|
market: string;
|
||||||
language: string;
|
language: string;
|
||||||
@@ -90,6 +93,9 @@ export interface EcommerceHistoryRecord {
|
|||||||
status?: EcommerceHistoryStatus;
|
status?: EcommerceHistoryStatus;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
output: CloneOutputKey;
|
output: CloneOutputKey;
|
||||||
|
modeLabel?: string;
|
||||||
|
settingLabel?: string;
|
||||||
|
generationKind?: "singleImage" | "imageEdit" | "imageSet" | "video";
|
||||||
platform: string;
|
platform: string;
|
||||||
market: string;
|
market: string;
|
||||||
language: string;
|
language: string;
|
||||||
@@ -189,6 +195,9 @@ export function buildHistoryTurnFromRecord(record: EcommerceHistoryRecord): Ecom
|
|||||||
status: record.status ?? "done",
|
status: record.status ?? "done",
|
||||||
errorMessage: record.status === "failed" ? record.errorMessage : undefined,
|
errorMessage: record.status === "failed" ? record.errorMessage : undefined,
|
||||||
output: record.output,
|
output: record.output,
|
||||||
|
modeLabel: record.modeLabel,
|
||||||
|
settingLabel: record.settingLabel,
|
||||||
|
generationKind: record.generationKind,
|
||||||
platform: record.platform,
|
platform: record.platform,
|
||||||
market: record.market,
|
market: record.market,
|
||||||
language: record.language,
|
language: record.language,
|
||||||
@@ -213,6 +222,9 @@ export function normalizeEcommerceHistoryTurn(turn: EcommerceHistoryTurn, fallba
|
|||||||
status,
|
status,
|
||||||
errorMessage: status === "failed" ? turn.errorMessage ?? fallback.errorMessage : undefined,
|
errorMessage: status === "failed" ? turn.errorMessage ?? fallback.errorMessage : undefined,
|
||||||
output: turn.output ?? fallback.output,
|
output: turn.output ?? fallback.output,
|
||||||
|
modeLabel: turn.modeLabel ?? fallback.modeLabel,
|
||||||
|
settingLabel: turn.settingLabel ?? fallback.settingLabel,
|
||||||
|
generationKind: turn.generationKind ?? fallback.generationKind,
|
||||||
platform: turn.platform ?? fallback.platform,
|
platform: turn.platform ?? fallback.platform,
|
||||||
market: turn.market ?? fallback.market,
|
market: turn.market ?? fallback.market,
|
||||||
language: turn.language ?? fallback.language,
|
language: turn.language ?? fallback.language,
|
||||||
@@ -235,6 +247,9 @@ export function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord):
|
|||||||
...record,
|
...record,
|
||||||
status,
|
status,
|
||||||
errorMessage: status === "failed" ? record.errorMessage : undefined,
|
errorMessage: status === "failed" ? record.errorMessage : undefined,
|
||||||
|
modeLabel: record.modeLabel,
|
||||||
|
settingLabel: record.settingLabel,
|
||||||
|
generationKind: record.generationKind,
|
||||||
productImages: removeFilePayloadFromImages(record.productImages),
|
productImages: removeFilePayloadFromImages(record.productImages),
|
||||||
referenceImages: removeFilePayloadFromImages(record.referenceImages ?? []),
|
referenceImages: removeFilePayloadFromImages(record.referenceImages ?? []),
|
||||||
results: record.results ?? [],
|
results: record.results ?? [],
|
||||||
|
|||||||
@@ -2992,6 +2992,32 @@
|
|||||||
box-shadow: 0 10px 26px rgba(0, 255, 136, 0.14), 0 8px 22px rgba(0, 0, 0, 0.28);
|
box-shadow: 0 10px 26px rgba(0, 255, 136, 0.14), 0 8px 22px rgba(0, 0, 0, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-clone-page[data-tool="clone"] .clone-ai-source-corner-action:disabled,
|
||||||
|
.product-clone-page[data-tool="clone"] .clone-ai-main-result:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-clone-page[data-tool="clone"] .clone-ai-source-missing {
|
||||||
|
position: static;
|
||||||
|
display: none;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(216, 222, 237, 0.72);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-clone-page[data-tool="clone"] .clone-ai-main-result:disabled .clone-ai-source-missing,
|
||||||
|
.product-clone-page[data-tool="clone"] .clone-ai-main-result.is-missing-source .clone-ai-source-missing {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button {
|
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user