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; } export interface SaveGenerationRecordInput { clientRecordId: string; tool: string; mode?: string; title: string; status: GenerationRecordStatus; prompt?: string; taskIds?: string[]; assets?: GenerationRecordAsset[]; config?: Record; result?: Record; metadata?: Record; createdAt?: string; updatedAt?: string; } export interface SaveGenerationRecordResult { source: "server" | "local"; id: string; } // 同一 clientRecordId 的保存去重:套图主流程、backgroundTaskRunner、useGenerationTasks // 三处都可能对同一条终态任务调用 saveGenerationRecord,SSE 重复推送 completed 时 // 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截, // 避免后端在缺少去重时插入重复记录。 const inFlightSaves = new Map>(); const recentlySavedRecords = new Map(); 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) .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 { 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 { 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 { 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 { return parts .map((part) => String(part ?? "").trim().toLowerCase()) .filter(Boolean) .map((part) => part.replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "")) .filter(Boolean) .join("/"); }