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
This commit is contained in:
@@ -43,15 +43,40 @@ export interface SaveGenerationRecordResult {
|
||||
// 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截,
|
||||
// 避免后端在缺少去重时插入重复记录。
|
||||
const inFlightSaves = new Map<string, Promise<SaveGenerationRecordResult>>();
|
||||
const recentlySavedAt = new Map<string, number>();
|
||||
const recentlySavedRecords = new Map<string, { savedAt: number; signature: string }>();
|
||||
const SAVE_DEDUPE_WINDOW_MS = 60_000;
|
||||
|
||||
function pruneRecentlySaved(now: number): void {
|
||||
for (const [id, savedAt] of recentlySavedAt) {
|
||||
if (now - savedAt > SAVE_DEDUPE_WINDOW_MS) recentlySavedAt.delete(id);
|
||||
for (const [id, record] of recentlySavedRecords) {
|
||||
if (now - record.savedAt > SAVE_DEDUPE_WINDOW_MS) recentlySavedRecords.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function stableJsonStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(stableJsonStringify).join(",")}]`;
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, entryValue]) => entryValue !== undefined)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJsonStringify(entryValue)}`).join(",")}}`;
|
||||
}
|
||||
|
||||
function buildSaveSignature(input: SaveGenerationRecordInput): string {
|
||||
return stableJsonStringify({
|
||||
tool: input.tool,
|
||||
mode: input.mode,
|
||||
title: input.title,
|
||||
status: input.status,
|
||||
prompt: input.prompt,
|
||||
taskIds: input.taskIds,
|
||||
assets: input.assets,
|
||||
config: input.config,
|
||||
result: input.result,
|
||||
metadata: input.metadata,
|
||||
createdAt: input.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
function readPendingRecords(): SaveGenerationRecordInput[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PENDING_RECORDS_KEY);
|
||||
@@ -78,26 +103,29 @@ export async function saveGenerationRecord(input: SaveGenerationRecordInput): Pr
|
||||
pruneRecentlySaved(now);
|
||||
|
||||
const recordId = input.clientRecordId;
|
||||
const signature = buildSaveSignature(input);
|
||||
if (recordId) {
|
||||
const inFlight = inFlightSaves.get(recordId);
|
||||
const saveKey = `${recordId}:${signature}`;
|
||||
const inFlight = inFlightSaves.get(saveKey);
|
||||
if (inFlight) return inFlight;
|
||||
const savedAt = recentlySavedAt.get(recordId);
|
||||
if (savedAt !== undefined && now - savedAt <= SAVE_DEDUPE_WINDOW_MS) {
|
||||
// 终态记录只需落库一次;窗口内的重复调用直接视为已保存。
|
||||
const savedRecord = recentlySavedRecords.get(recordId);
|
||||
if (savedRecord && savedRecord.signature === signature && now - savedRecord.savedAt <= SAVE_DEDUPE_WINDOW_MS) {
|
||||
// 相同 clientRecordId 且 payload 完全一致时才拦截;同一记录的多轮更新需要继续保存。
|
||||
return { source: "server", id: recordId };
|
||||
}
|
||||
}
|
||||
|
||||
const promise = saveGenerationRecordInternal(input);
|
||||
if (recordId) {
|
||||
inFlightSaves.set(recordId, promise);
|
||||
const saveKey = `${recordId}:${signature}`;
|
||||
inFlightSaves.set(saveKey, promise);
|
||||
void promise
|
||||
.then((result) => {
|
||||
if (result.source === "server") recentlySavedAt.set(recordId, Date.now());
|
||||
if (result.source === "server") recentlySavedRecords.set(recordId, { savedAt: Date.now(), signature });
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
inFlightSaves.delete(recordId);
|
||||
inFlightSaves.delete(saveKey);
|
||||
});
|
||||
}
|
||||
return promise;
|
||||
|
||||
Reference in New Issue
Block a user