2026-06-12 11:12:55 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 10:24:31 +08:00
|
|
|
|
// 同一 clientRecordId 的保存去重:套图主流程、backgroundTaskRunner、useGenerationTasks
|
|
|
|
|
|
// 三处都可能对同一条终态任务调用 saveGenerationRecord,SSE 重复推送 completed 时
|
|
|
|
|
|
// 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截,
|
|
|
|
|
|
// 避免后端在缺少去重时插入重复记录。
|
|
|
|
|
|
const inFlightSaves = new Map<string, Promise<SaveGenerationRecordResult>>();
|
2026-06-16 13:02:11 +08:00
|
|
|
|
const recentlySavedRecords = new Map<string, { savedAt: number; signature: string }>();
|
2026-06-15 10:24:31 +08:00
|
|
|
|
const SAVE_DEDUPE_WINDOW_MS = 60_000;
|
|
|
|
|
|
|
|
|
|
|
|
function pruneRecentlySaved(now: number): void {
|
2026-06-16 13:02:11 +08:00
|
|
|
|
for (const [id, record] of recentlySavedRecords) {
|
|
|
|
|
|
if (now - record.savedAt > SAVE_DEDUPE_WINDOW_MS) recentlySavedRecords.delete(id);
|
2026-06-15 10:24:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 13:02:11 +08:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 11:12:55 +08:00
|
|
|
|
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> {
|
2026-06-15 10:24:31 +08:00
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
pruneRecentlySaved(now);
|
|
|
|
|
|
|
|
|
|
|
|
const recordId = input.clientRecordId;
|
2026-06-16 13:02:11 +08:00
|
|
|
|
const signature = buildSaveSignature(input);
|
2026-06-15 10:24:31 +08:00
|
|
|
|
if (recordId) {
|
2026-06-16 13:02:11 +08:00
|
|
|
|
const saveKey = `${recordId}:${signature}`;
|
|
|
|
|
|
const inFlight = inFlightSaves.get(saveKey);
|
2026-06-15 10:24:31 +08:00
|
|
|
|
if (inFlight) return inFlight;
|
2026-06-16 13:02:11 +08:00
|
|
|
|
const savedRecord = recentlySavedRecords.get(recordId);
|
|
|
|
|
|
if (savedRecord && savedRecord.signature === signature && now - savedRecord.savedAt <= SAVE_DEDUPE_WINDOW_MS) {
|
|
|
|
|
|
// 相同 clientRecordId 且 payload 完全一致时才拦截;同一记录的多轮更新需要继续保存。
|
2026-06-15 10:24:31 +08:00
|
|
|
|
return { source: "server", id: recordId };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const promise = saveGenerationRecordInternal(input);
|
|
|
|
|
|
if (recordId) {
|
2026-06-16 13:02:11 +08:00
|
|
|
|
const saveKey = `${recordId}:${signature}`;
|
|
|
|
|
|
inFlightSaves.set(saveKey, promise);
|
2026-06-15 10:24:31 +08:00
|
|
|
|
void promise
|
|
|
|
|
|
.then((result) => {
|
2026-06-16 13:02:11 +08:00
|
|
|
|
if (result.source === "server") recentlySavedRecords.set(recordId, { savedAt: Date.now(), signature });
|
2026-06-15 10:24:31 +08:00
|
|
|
|
})
|
|
|
|
|
|
.catch(() => undefined)
|
|
|
|
|
|
.finally(() => {
|
2026-06-16 13:02:11 +08:00
|
|
|
|
inFlightSaves.delete(saveKey);
|
2026-06-15 10:24:31 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return promise;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveGenerationRecordInternal(input: SaveGenerationRecordInput): Promise<SaveGenerationRecordResult> {
|
2026-06-12 11:12:55 +08:00
|
|
|
|
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("/");
|
|
|
|
|
|
}
|