Files
omniai-ds-code-package/src/api/generationRecordClient.ts
T
ludan 4993f6eeec feat: implement multi-turn conversation system for generation record detail with deduplication enhancement
- generationRecordClient.ts: Enhance save deduplication with payload signature — replace simple recordId-based dedup with stableJsonStringify-based signature comparison; same recordId + same signature skips save, changed payload proceeds; add buildSaveSignature covering tool/mode/title/status/prompt/taskIds/assets/config/result/metadata; store signature alongside savedAt in recentlySavedRecords map for per-turn save accuracy
- EcommercePage.tsx: Introduce EcommerceHistoryTurn interface and multi-turn conversation architecture —
  - Add EcommerceHistoryTurn with full generation context (status/output/platform/market/language/ratio/requirement/images/results/counts/modules/scenes/replicateLevel); EcommerceHistoryRecord gains status/errorMessage/turns[] fields
  - beginEcommerceHistoryTurn() — start a new generation turn, create or append to record, persist to localStorage immediately
  - updateLocalEcommerceHistoryTurn() — real-time turn status sync (generating→done/failed) with record summary mirroring via syncRecordSummaryWithTurn()
  - restoreHistoryTurnInputs() — one-click parameter restoration from failed turns for retry
  - upsertCanvasNode() — insert or update canvas node by ID (dedup by turnId), alternating row layout (x: index*420, y: 0 or 160)
  - Generate flow wired to turns: status callbacks update turn state; cancel sets turn to failed; results written to turn.results
  - Record detail conversation panel refactored from single-message to per-turn iteration — each turn renders user message (requirement + meta + assets) and assistant message (status-aware text + progress bar during generation + result thumbnails); failed turns show "恢复参数" retry button; generating turn shows EcommerceProgressBar
  - openEcommerceHistoryRecord() loads all turns as canvas nodes with distributed positions; preserves generating turn tracking via activeHistoryTurnIdRef
  - History list items display status label (生成中/失败/time)
  - Product set preview backdrop moved to createPortal(document.body) with z-index 4000
- pages/ecommerce.css: Bump product-set-preview-backdrop z-index from 100 to 4000 for Portal rendering layer
2026-06-16 13:02:11 +08:00

204 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
import { serverRequest } from "./serverConnection";
const PENDING_RECORDS_KEY = "omniai:generation-records.pending";
const MAX_PENDING_RECORDS = 80;
export type GenerationRecordStatus = "queued" | "running" | "completed" | "failed" | "cancelled";
export interface GenerationRecordAsset {
role: "source" | "reference" | "intermediate" | "result" | "thumbnail";
mediaType: "image" | "video" | "text" | "asset" | string;
url: string;
ossKey?: string | null;
scope?: string;
label?: string;
taskId?: string | null;
metadata?: Record<string, unknown>;
}
export interface SaveGenerationRecordInput {
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 SaveGenerationRecordResult {
source: "server" | "local";
id: string;
}
// 同一 clientRecordId 的保存去重:套图主流程、backgroundTaskRunner、useGenerationTasks
// 三处都可能对同一条终态任务调用 saveGenerationRecordSSE 重复推送 completed 时
// 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截,
// 避免后端在缺少去重时插入重复记录。
const inFlightSaves = new Map<string, Promise<SaveGenerationRecordResult>>();
const recentlySavedRecords = new Map<string, { savedAt: number; signature: string }>();
const SAVE_DEDUPE_WINDOW_MS = 60_000;
function pruneRecentlySaved(now: number): void {
for (const [id, record] of recentlySavedRecords) {
if (now - record.savedAt > SAVE_DEDUPE_WINDOW_MS) recentlySavedRecords.delete(id);
}
}
function stableJsonStringify(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableJsonStringify).join(",")}]`;
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, entryValue]) => entryValue !== undefined)
.sort(([a], [b]) => a.localeCompare(b));
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJsonStringify(entryValue)}`).join(",")}}`;
}
function buildSaveSignature(input: SaveGenerationRecordInput): string {
return stableJsonStringify({
tool: input.tool,
mode: input.mode,
title: input.title,
status: input.status,
prompt: input.prompt,
taskIds: input.taskIds,
assets: input.assets,
config: input.config,
result: input.result,
metadata: input.metadata,
createdAt: input.createdAt,
});
}
function readPendingRecords(): SaveGenerationRecordInput[] {
try {
const raw = window.localStorage.getItem(PENDING_RECORDS_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((item): item is SaveGenerationRecordInput => Boolean(item?.clientRecordId)) : [];
} catch {
return [];
}
}
function writePendingRecord(input: SaveGenerationRecordInput): void {
try {
const records = readPendingRecords();
const next = [input, ...records.filter((item) => item.clientRecordId !== input.clientRecordId)].slice(0, MAX_PENDING_RECORDS);
window.localStorage.setItem(PENDING_RECORDS_KEY, JSON.stringify(next));
} catch {
// Ignore storage quota failures; generation itself must not be blocked by history persistence.
}
}
export async function saveGenerationRecord(input: SaveGenerationRecordInput): Promise<SaveGenerationRecordResult> {
const now = Date.now();
pruneRecentlySaved(now);
const recordId = input.clientRecordId;
const signature = buildSaveSignature(input);
if (recordId) {
const saveKey = `${recordId}:${signature}`;
const inFlight = inFlightSaves.get(saveKey);
if (inFlight) return inFlight;
const savedRecord = recentlySavedRecords.get(recordId);
if (savedRecord && savedRecord.signature === signature && now - savedRecord.savedAt <= SAVE_DEDUPE_WINDOW_MS) {
// 相同 clientRecordId 且 payload 完全一致时才拦截;同一记录的多轮更新需要继续保存。
return { source: "server", id: recordId };
}
}
const promise = saveGenerationRecordInternal(input);
if (recordId) {
const saveKey = `${recordId}:${signature}`;
inFlightSaves.set(saveKey, promise);
void promise
.then((result) => {
if (result.source === "server") recentlySavedRecords.set(recordId, { savedAt: Date.now(), signature });
})
.catch(() => undefined)
.finally(() => {
inFlightSaves.delete(saveKey);
});
}
return promise;
}
async function saveGenerationRecordInternal(input: SaveGenerationRecordInput): Promise<SaveGenerationRecordResult> {
try {
const response = await serverRequest<{ id?: string | number }>("ai/generation-records", {
method: "POST",
body: input,
maxRetries: 0,
fallbackMessage: "Failed to save generation record",
});
return { source: "server", id: String(response.id ?? input.clientRecordId) };
} catch (error) {
if (!isOptionalApiRouteMissing(error)) {
// Keep a local recovery copy even when the route exists but the save fails.
writePendingRecord(input);
return { source: "local", id: input.clientRecordId };
}
writePendingRecord(input);
return { source: "local", id: input.clientRecordId };
}
}
export async function flushPendingGenerationRecords(): Promise<{ synced: number; remaining: number }> {
const pending = readPendingRecords();
if (!pending.length) return { synced: 0, remaining: 0 };
const remaining: SaveGenerationRecordInput[] = [];
let synced = 0;
for (const record of pending.slice().reverse()) {
try {
await serverRequest<{ id?: string | number }>("ai/generation-records", {
method: "POST",
body: record,
maxRetries: 0,
fallbackMessage: "Failed to sync generation record",
});
synced += 1;
} catch {
remaining.unshift(record);
}
}
try {
if (remaining.length) {
window.localStorage.setItem(PENDING_RECORDS_KEY, JSON.stringify(remaining.slice(0, MAX_PENDING_RECORDS)));
} else {
window.localStorage.removeItem(PENDING_RECORDS_KEY);
}
} catch {
// Keep runtime generation unaffected if browser storage is unavailable.
}
return { synced, remaining: remaining.length };
}
export async function deleteGenerationRecordByClientId(clientRecordId: string): Promise<void> {
await serverRequest<{ success: boolean }>(`ai/generation-records/by-client-id/${encodeURIComponent(clientRecordId)}`, {
method: "DELETE",
maxRetries: 0,
fallbackMessage: "Failed to delete generation record",
});
}
export function buildGenerationOssScope(parts: Array<string | number | null | undefined>): string {
return parts
.map((part) => String(part ?? "").trim().toLowerCase())
.filter(Boolean)
.map((part) => part.replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""))
.filter(Boolean)
.join("/");
}