import { create } from "zustand"; import { persist, type PersistStorage, type StorageValue } from "zustand/middleware"; import { nanoid } from "nanoid"; import { sameCanvasContent } from "@/lib/canvas/canvas-content"; import { canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, readCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance"; import { parseCanvasStorageDocument, rebaseCanvasProjects, serializeCanvasStorageDocument, type CanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision"; import { localForageStorageForScope } from "@/lib/localforage-storage"; import { getActiveUserScope } from "@/lib/user-scope"; import { DEFAULT_CANVAS_COLOR_THEME, type CanvasBackgroundMode } from "@/lib/canvas-theme"; import type { CanvasStarterMode } from "@/lib/canvas/canvas-starter"; import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas"; import type { PrevisScene } from "@/types/previs"; import type { TimelineProject } from "@/types/timeline"; import { useSyncProgressStore, type SyncProjectProgress } from "@/stores/use-sync-progress-store"; import { recordDiagnosticEvent } from "@/services/diagnostics/client-diagnostics"; export type CanvasProject = { id: string; revision?: number; remoteContentHash?: string; projectId?: string; title: string; createdAt: string; updatedAt: string; nodes: CanvasNodeData[]; connections: CanvasConnection[]; chatSessions: CanvasAssistantSession[]; activeChatId: string | null; starterMode?: CanvasStarterMode; appearance?: CanvasAppearance; backgroundMode: CanvasBackgroundMode; showImageInfo: boolean; viewport: ViewportTransform; previsScenes: PrevisScene[]; timeline?: TimelineProject; }; type CanvasStore = { hydrated: boolean; projects: CanvasProject[]; createProject: (title?: string, projectId?: string) => string; importProject: (project: Partial) => string; openProject: (id: string) => CanvasProject | null; renameProject: (id: string, title: string) => void; deleteProjects: (ids: string[]) => void; replaceProjects: (projects: CanvasProject[]) => void; updateProject: (id: string, patch: Partial>) => void; }; const initialViewport: ViewportTransform = { x: 0, y: 0, k: 1 }; export const CANVAS_STORE_KEY = "yingce:canvas_store"; type PersistedCanvasState = Pick; type QueuedCanvasPersist = { name: string; scope: string; baseProjects: CanvasProject[]; baseRevision: number; state: PersistedCanvasState; token: number; journal?: CanvasPersistenceJournal; }; type ObservedCanvasPersist = { projects: CanvasProject[]; revision: number; savedAt?: string; }; type CanvasPersistenceJournal = { version: 1; name: string; scope: string; writerId: string; token: number; savedAt: string; baseRevision: number; baseProjects: CanvasProject[]; projects: CanvasProject[]; }; const CANVAS_PERSISTENCE_JOURNAL_KEY = "yingce:canvas_store_journal"; const CANVAS_JOURNAL_MAX_BYTES = 450_000; const canvasJournalWriterId = `tab-${nanoid(10)}`; function canvasPersistenceJournalKey(scope: string, writerId = canvasJournalWriterId) { return `${CANVAS_PERSISTENCE_JOURNAL_KEY}:user:${encodeURIComponent(scope)}:${writerId}`; } function createCanvasPersistenceJournal(scope: string, name: string, previous: CanvasProject[], next: CanvasProject[], token: number, baseRevision: number): CanvasPersistenceJournal { const before = new Map(previous.map((project) => [project.id, project])); const after = new Map(next.map((project) => [project.id, project])); const changed = new Set([...before.keys(), ...after.keys()].filter((id) => before.get(id) !== after.get(id))); return { version: 1, name, scope, writerId: canvasJournalWriterId, token, savedAt: new Date().toISOString(), baseRevision, baseProjects: previous.filter((project) => changed.has(project.id)), projects: next.filter((project) => changed.has(project.id)), }; } function readCanvasPersistenceJournals(scope: string): CanvasPersistenceJournal[] { try { if (typeof window === "undefined") return []; const storage = window.localStorage; if (!storage) return []; const prefix = canvasPersistenceJournalKey(scope, ""); const keys = new Set([canvasPersistenceJournalKey(scope)]); for (let index = 0; index < storage.length; index += 1) { const key = storage.key(index); if (key?.startsWith(prefix)) keys.add(key); } return [...keys].flatMap((key) => { const raw = storage.getItem(key); if (!raw) return []; try { const journal = JSON.parse(raw) as CanvasPersistenceJournal; return journal.version === 1 && journal.scope === scope && journal.name && typeof journal.writerId === "string" && key === canvasPersistenceJournalKey(scope, journal.writerId) && Number.isSafeInteger(journal.token) && Array.isArray(journal.projects) && Array.isArray(journal.baseProjects) ? [journal] : []; } catch { return []; } }); } catch { return []; } } function writeCanvasPersistenceJournal(journal: CanvasPersistenceJournal) { try { if (typeof window === "undefined" || !window.localStorage) return false; const serialized = JSON.stringify(journal); // Large media stays in IndexedDB. Oversized recovery records trigger an // immediate full save and remain visibly pending until that write succeeds. if (new TextEncoder().encode(serialized).byteLength > CANVAS_JOURNAL_MAX_BYTES) return false; window.localStorage.setItem(canvasPersistenceJournalKey(journal.scope, journal.writerId), serialized); return true; } catch { return false; } } function clearCanvasPersistenceJournal(scope: string, token: number, writerId = canvasJournalWriterId) { try { if (typeof window === "undefined") return; const key = canvasPersistenceJournalKey(scope, writerId); const raw = window.localStorage?.getItem(key); // A previous save must never clear edits made while IndexedDB was writing. if (raw && (JSON.parse(raw) as CanvasPersistenceJournal).token <= token) window.localStorage.removeItem(key); } catch { /* The durable receipt makes a leftover journal safe to replay. */ } } function applyCanvasPersistenceJournal(document: CanvasStorageDocument, journal: CanvasPersistenceJournal) { if ((document.journalTokens?.[journal.writerId] ?? -1) >= journal.token) return { document, conflicts: [] }; return rebaseCanvasProjects({ document, baseProjects: journal.baseProjects, localProjects: journal.projects, baseRevision: journal.baseRevision }); } async function preserveLocalRebaseConflicts(scope: string, projects: CanvasProject[], conflicts: ReturnType["conflicts"]) { if (!conflicts.length || getActiveUserScope() !== scope) return; const ids = new Set(conflicts.map((conflict) => conflict.projectId || conflict.id)); for (const project of projects.filter((item) => ids.has(item.id))) { useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "pending", pendingChanges: 1, errorKind: "conflict", message: "其他标签页也修改了此画布,未确认操作会按字段重放", }); } } function setCanvasLocalPersistenceProgress(projects: CanvasProject[], patch: Partial) { const progress = useSyncProgressStore.getState(); for (const project of projects) { const current = progress.syncingProjects[project.id]; progress.setProjectProgress(project.id, { projectId: project.id, ...(current ? {} : { phase: "local" as const }), ...patch }); } } function markCanvasLocalPersistencePending(projects: CanvasProject[], error?: string) { setCanvasLocalPersistenceProgress(projects, { localPhase: error ? "error" : "pending", localError: error, errorKind: error ? "local" : undefined, pendingWrites: 1, }); } function markCanvasLocalPersistenceSaved(projects: CanvasProject[], savedAt: string) { setCanvasLocalPersistenceProgress(projects, { localPhase: "saved", localSavedAt: savedAt, localError: undefined, errorKind: undefined, pendingWrites: 0 }); } function recordCanvasNodePersistenceCounts(before: CanvasProject[], after: CanvasProject[]) { const beforeById = new Map(before.map((project) => [project.id, project])); const afterById = new Map(after.map((project) => [project.id, project])); for (const id of new Set([...beforeById.keys(), ...afterById.keys()])) { const nodesBefore = beforeById.get(id)?.nodes.length || 0; const nodesAfter = afterById.get(id)?.nodes.length || 0; if (nodesBefore === nodesAfter) continue; recordDiagnosticEvent({ level: nodesAfter < nodesBefore ? "error" : "info", category: "action", code: nodesAfter < nodesBefore ? "canvas.persistence.nodes_decreased" : "canvas.persistence.nodes_changed", message: nodesAfter < nodesBefore ? "画布持久化后节点数量减少" : "画布持久化后节点数量变化", projectId: id, nodes_before: nodesBefore, nodes_after: nodesAfter, }); } } let suppressCanvasStorePersistence = 0; const canvasMemoryStates = new Map(); const observedCanvasPersists = new Map(); const queuedCanvasPersists = new Map(); const canvasSaveTimers = new Map>(); const canvasPersistTokens = new Map(); type CanvasGenerationPersistenceAttempt = { previousNodes?: CanvasNodeData[]; nodes?: CanvasNodeData[]; previousChatSessions?: CanvasAssistantSession[]; chatSessions?: CanvasAssistantSession[]; }; const pendingCanvasGenerationAttempts = new Map>(); type AsyncCanvasStorageLock = { request(name: string, callback: () => Promise): Promise; }; type CanvasStorageLockOptions = { requireCrossRealmLock?: boolean; }; const CANVAS_STORAGE_LOCK_PREFIX = "yingce:canvas-generation-storage-lock:"; const canvasStorageTails = new Map>(); function runWithBrowserCanvasStorageLock(scope: string, operation: () => Promise, options: CanvasStorageLockOptions) { const locks = typeof window !== "undefined" && typeof navigator !== "undefined" ? (navigator.locks as AsyncCanvasStorageLock | undefined) : undefined; const lockName = `${CANVAS_STORAGE_LOCK_PREFIX}${scope}`; if (locks) return locks.request(lockName, operation); if (options.requireCrossRealmLock && typeof window !== "undefined" && typeof document !== "undefined") { throw new Error("当前浏览器不支持跨标签存储锁,已停止画布生成持久化"); } return operation(); } /** * 串行化同一用户作用域的画布持久化,并在一次失败后允许队列继续前进。 * * `pending` 必须把当前写入的真实结果返回给调用方;只有前一个 tail 的失败被 * 转换成已处理的 void,才不会让一次旧失败永久毒化后续保存队列。 */ export function withCanvasStorePersistenceLock(scope: string, operation: () => Promise, options: CanvasStorageLockOptions = {}): Promise { const previous = canvasStorageTails.get(scope) ?? Promise.resolve(); const pending = previous.then(() => undefined, () => undefined).then(() => runWithBrowserCanvasStorageLock(scope, operation, options)); const tail = pending.then( () => undefined, () => undefined, ); canvasStorageTails.set(scope, tail); void tail.finally(() => { if (canvasStorageTails.get(scope) === tail) canvasStorageTails.delete(scope); }); return pending; } function clearCanvasSaveTimer(scope: string) { const timer = canvasSaveTimers.get(scope); if (!timer) return; clearTimeout(timer); canvasSaveTimers.delete(scope); } export function canvasStoreStorageRevision(scope: string) { return observedCanvasPersists.get(scope)?.revision ?? 0; } export function recordCanvasStorageDocument(scope: string, document: CanvasStorageDocument) { const savedAt = document.savedAt || new Date().toISOString(); observedCanvasPersists.set(scope, { projects: document.state.projects, revision: document.storageRevision, savedAt, }); if (getActiveUserScope() !== scope) return; const queued = queuedCanvasPersists.get(scope); if (queued || failedCanvasJournalRecoveries.has(scope)) { if (failedCanvasJournalRecoveries.has(scope)) return; markCanvasLocalPersistencePending(queued!.state.projects); } else { markCanvasLocalPersistenceSaved(document.state.projects, savedAt); } } export async function commitPendingCanvasStorePersistenceLocked(scope: string) { const storage = localForageStorageForScope(scope); let committed: CanvasStorageDocument | null = null; while (true) { const queued = queuedCanvasPersists.get(scope); if (!queued) return committed; const durable = parseCanvasStorageDocument(await storage.getItem(queued.name), queued.baseProjects); const rebased = rebaseCanvasProjects({ document: durable, baseProjects: queued.baseProjects, localProjects: queued.state.projects, baseRevision: queued.baseRevision, }); await preserveLocalRebaseConflicts(scope, queued.state.projects, rebased.conflicts); const savedAt = new Date().toISOString(); const savedDocument = { ...rebased.document, savedAt, journalTokens: { ...durable.journalTokens, [canvasJournalWriterId]: queued.token } }; await storage.setItem(queued.name, serializeCanvasStorageDocument(savedDocument)); committed = savedDocument; clearCanvasPersistenceJournal(scope, queued.token); recordCanvasNodePersistenceCounts(durable.state.projects, savedDocument.state.projects); recordCanvasNodePersistenceCounts(queued.state.projects, savedDocument.state.projects); const latest = queuedCanvasPersists.get(scope); if (!latest || latest.token === queued.token) { if (latest?.token === queued.token) queuedCanvasPersists.delete(scope); recordCanvasStorageDocument(scope, savedDocument); return committed; } latest.baseProjects = queued.state.projects; latest.baseRevision = savedDocument.storageRevision; recordCanvasStorageDocument(scope, savedDocument); } } async function writeQueuedCanvasPersist(scope: string, _token: number) { try { await withCanvasStorePersistenceLock(scope, () => commitPendingCanvasStorePersistenceLocked(scope)); } catch (error) { const queued = queuedCanvasPersists.get(scope); if (queued) markCanvasLocalPersistencePending(queued.state.projects, error instanceof Error ? error.message : "本地画布保存失败"); throw error; } } export function pendingCanvasStorePersistence(scope: string) { const queued = queuedCanvasPersists.get(scope); return queued ? { projects: queued.state.projects, token: queued.token } : null; } export function rebasePendingCanvasStorePersistenceAfterGenerationCommitLocked(scope: string, committed: CanvasStorageDocument) { const queued = queuedCanvasPersists.get(scope); if (!queued) return; const latestMemory = canvasMemoryStates.get(scope)?.projects ?? queued.state.projects; // Dedicated commit 已确认 generation stamp;用同 scope 最新内存重新投影队列,避免同节点普通编辑被旧 durable 快照替换。 queued.baseProjects = committed.state.projects; queued.baseRevision = committed.storageRevision; queued.state = ordinaryCanvasPersistenceState(scope, { projects: latestMemory }, committed.state.projects); } export function withCanvasStorePersistenceSuppressed(operation: () => T) { suppressCanvasStorePersistence += 1; try { return operation(); } finally { suppressCanvasStorePersistence -= 1; } } export function registerCanvasGenerationPersistenceAttempt(scope: string, projectId: string, effectKey: string, attempt: CanvasGenerationPersistenceAttempt) { const projectKey = `${scope}\0${projectId}`; const attempts = pendingCanvasGenerationAttempts.get(projectKey) ?? new Map(); attempts.set(effectKey, attempt); pendingCanvasGenerationAttempts.set(projectKey, attempts); const queued = queuedCanvasPersists.get(scope); if (queued) { const latestMemory = canvasMemoryStates.get(scope)?.projects ?? queued.state.projects; const durableProjects = observedCanvasPersists.get(scope)?.projects ?? queued.baseProjects; queued.state = ordinaryCanvasPersistenceState(scope, { projects: latestMemory }, durableProjects); } return () => { if (attempts.get(effectKey) !== attempt) return; attempts.delete(effectKey); if (!attempts.size) pendingCanvasGenerationAttempts.delete(projectKey); }; } function hasUnconfirmedGenerationKey(localKeys?: string[], durableKeys?: string[]) { return Boolean(localKeys?.some((key) => !durableKeys?.includes(key))); } function samePersistenceValue(left: unknown, right: unknown) { return left === right || JSON.stringify(left) === JSON.stringify(right); } function rollbackGenerationValue(previous: unknown, attempted: unknown, live: unknown, durable: unknown): unknown { if (samePersistenceValue(previous, attempted)) return live; if (samePersistenceValue(live, attempted)) return durable; if (!previous || !attempted || !live || !durable || Array.isArray(previous) || Array.isArray(attempted) || Array.isArray(live) || Array.isArray(durable) || typeof previous !== "object" || typeof attempted !== "object" || typeof live !== "object" || typeof durable !== "object") return live; const previousRecord = previous as Record; const attemptedRecord = attempted as Record; const liveRecord = live as Record; const durableRecord = durable as Record; const result: Record = { ...durableRecord }; for (const key of new Set([...Object.keys(previousRecord), ...Object.keys(attemptedRecord), ...Object.keys(liveRecord)])) { const previousHasKey = Object.hasOwn(previousRecord, key); const attemptedHasKey = Object.hasOwn(attemptedRecord, key); const liveHasKey = Object.hasOwn(liveRecord, key); if (!previousHasKey && !attemptedHasKey) { if (liveHasKey) result[key] = liveRecord[key]; continue; } if (!previousHasKey && attemptedHasKey) { if (!liveHasKey || samePersistenceValue(liveRecord[key], attemptedRecord[key])) delete result[key]; else result[key] = liveRecord[key]; continue; } if (previousHasKey && !attemptedHasKey) { if (liveHasKey) result[key] = liveRecord[key]; continue; } if (!liveHasKey) { delete result[key]; continue; } const value = rollbackGenerationValue(previousRecord[key], attemptedRecord[key], liveRecord[key], durableRecord[key]); if (value === undefined) delete result[key]; else result[key] = value; } return result; } function pendingGenerationAttempt(scope: string, projectId: string, effectKeys?: string[]) { const attempts = pendingCanvasGenerationAttempts.get(`${scope}\0${projectId}`); if (!attempts) return undefined; for (const effectKey of effectKeys || []) { const attempt = attempts.get(effectKey); if (attempt) return attempt; } return undefined; } function ordinaryCanvasProjectSnapshot(scope: string, project: CanvasProject, durableProject: CanvasProject | undefined) { if (!hasGenerationEffectKeys(project) && !hasGenerationEffectKeys(durableProject)) return project; const durableNodes = new Map((durableProject?.nodes || []).map((node) => [node.id, node])); const durableSessions = new Map((durableProject?.chatSessions || []).map((session) => [session.id, session])); let changed = false; let hasUnconfirmedGeneration = false; const nodes: CanvasNodeData[] = []; for (const node of project.nodes) { const durableNode = durableNodes.get(node.id); const durableKeys = durableNode?.metadata?.generationEffectKeys; const localKeys = node.metadata?.generationEffectKeys; if (durableProject && hasUnconfirmedGenerationKey(localKeys, durableKeys)) { hasUnconfirmedGeneration = true; changed = true; if (durableNode) { const attempt = pendingGenerationAttempt(scope, project.id, localKeys); const previousNode = attempt?.previousNodes?.find((candidate) => candidate.id === node.id); const attemptedNode = attempt?.nodes?.find((candidate) => candidate.id === node.id); // durableNode comes from the observed storage snapshot. Never mutate that snapshot while // rebuilding a failed generation, otherwise a later retry observes a partially rolled-back base. const rolledBack = previousNode && attemptedNode ? (rollbackGenerationValue(previousNode, attemptedNode, node, durableNode) as CanvasNodeData) : { ...durableNode, metadata: durableNode.metadata ? { ...durableNode.metadata } : undefined }; const metadata = { ...(rolledBack.metadata || {}) }; if (durableKeys?.length) metadata.generationEffectKeys = [...durableKeys]; else delete metadata.generationEffectKeys; nodes.push({ ...rolledBack, metadata }); } else { // There is no durable ancestor for a locally-created result. // Preserve it in the recovery snapshot; the effect key makes a // later replay idempotent and the user must not lose the node. nodes.push(node); } continue; } if (sameGenerationEffectKeys(localKeys, durableKeys)) { nodes.push(node); continue; } changed = true; const metadata = { ...(node.metadata || {}) }; if (durableKeys?.length) metadata.generationEffectKeys = [...durableKeys]; else delete metadata.generationEffectKeys; nodes.push({ ...node, metadata }); } const chatSessions: CanvasAssistantSession[] = []; for (const session of project.chatSessions) { const durableSession = durableSessions.get(session.id); const durableKeys = durableSession?.generationEffectKeys; if (durableProject && hasUnconfirmedGenerationKey(session.generationEffectKeys, durableKeys)) { hasUnconfirmedGeneration = true; changed = true; if (durableSession) { const attempt = pendingGenerationAttempt(scope, project.id, session.generationEffectKeys); const previousSession = attempt?.previousChatSessions?.find((candidate) => candidate.id === session.id); const attemptedSession = attempt?.chatSessions?.find((candidate) => candidate.id === session.id); const rolledBack = previousSession && attemptedSession ? (rollbackGenerationValue(previousSession, attemptedSession, session, durableSession) as CanvasAssistantSession) : { ...durableSession, generationEffectKeys: durableSession.generationEffectKeys ? [...durableSession.generationEffectKeys] : undefined }; if (durableKeys?.length) rolledBack.generationEffectKeys = [...durableKeys]; else delete rolledBack.generationEffectKeys; chatSessions.push(rolledBack); } continue; } if (sameGenerationEffectKeys(session.generationEffectKeys, durableKeys)) { chatSessions.push(session); continue; } changed = true; const nextSession = { ...session }; if (durableKeys?.length) nextSession.generationEffectKeys = [...durableKeys]; else delete nextSession.generationEffectKeys; chatSessions.push(nextSession); } if (durableProject && hasUnconfirmedGeneration) { const localConnectionIds = new Set(project.connections.map((connection) => connection.id)); return { ...project, nodes, // Connection/active-chat records carry no effect stamp provenance. Keep // local connections and append durable-only ones instead of replacing // the whole list and dropping edits made during generation. connections: [...project.connections, ...durableProject.connections.filter((connection) => !localConnectionIds.has(connection.id))], chatSessions, activeChatId: durableProject.activeChatId, }; } return changed ? { ...project, nodes, chatSessions } : project; } function hasGenerationEffectKeys(value: CanvasProject | undefined) { if (!value) return false; return value.nodes.some((node) => Boolean(node.metadata?.generationEffectKeys?.length)) || value.chatSessions.some((session) => Boolean(session.generationEffectKeys?.length)); } function sameGenerationEffectKeys(left?: readonly string[], right?: readonly string[]) { if (left === right) return true; if (!left?.length && !right?.length) return true; if (!left || !right || left.length !== right.length) return false; return left.every((value, index) => value === right[index]); } function ordinaryCanvasPersistenceState(scope: string, state: PersistedCanvasState, durableProjects: CanvasProject[]) { const durableById = new Map(durableProjects.map((project) => [project.id, project])); return { projects: state.projects.map((project) => ordinaryCanvasProjectSnapshot(scope, project, durableById.get(project.id))), }; } export function reconcileCanvasGenerationFailure(scope: string, durableProjects: CanvasProject[]) { if (getActiveUserScope() !== scope) return; const projects = ordinaryCanvasPersistenceState(scope, { projects: useCanvasStore.getState().projects }, durableProjects).projects; withCanvasStorePersistenceSuppressed(() => { useCanvasStore.setState({ projects }); }); } const failedCanvasJournalRecoveries = new Map(); async function recoverCanvasPersistenceJournalLocked(scope: string, name: string, initial: CanvasStorageDocument) { const journals = readCanvasPersistenceJournals(scope).filter((journal) => journal.name === name) .sort((left, right) => left.savedAt.localeCompare(right.savedAt) || left.writerId.localeCompare(right.writerId) || left.token - right.token); if (!journals.length) return initial; const storage = localForageStorageForScope(scope); let durable = parseCanvasStorageDocument(await storage.getItem(name), initial.state.projects); for (const journal of journals) { const rebased = applyCanvasPersistenceJournal(durable, journal); await preserveLocalRebaseConflicts(scope, journal.projects, rebased.conflicts); const saved = { ...rebased.document, savedAt: new Date().toISOString(), journalTokens: { ...rebased.document.journalTokens, [journal.writerId]: Math.max(journal.token, durable.journalTokens?.[journal.writerId] ?? 0) } }; await storage.setItem(name, serializeCanvasStorageDocument(saved)); clearCanvasPersistenceJournal(scope, journal.token, journal.writerId); recordCanvasNodePersistenceCounts(durable.state.projects, saved.state.projects); durable = saved; } failedCanvasJournalRecoveries.delete(scope); return durable; } async function recoverCanvasPersistenceJournal(scope: string, name: string, initial: CanvasStorageDocument) { try { return await withCanvasStorePersistenceLock(scope, () => recoverCanvasPersistenceJournalLocked(scope, name, initial)); } catch (error) { failedCanvasJournalRecoveries.set(scope, name); const projects = new Map(initial.state.projects.map((project) => [project.id, project])); // Keep unsaved branches visible when recovery itself cannot be persisted. // Journals stay intact; a later flush retries their original three-way merge. for (const journal of readCanvasPersistenceJournals(scope).filter((item) => item.name === name)) { if ((initial.journalTokens?.[journal.writerId] ?? -1) >= journal.token) continue; for (const project of journal.projects) projects.set(project.id, project); } const recoveredProjects = [...projects.values()]; if (getActiveUserScope() === scope) markCanvasLocalPersistencePending(recoveredProjects, error instanceof Error ? error.message : "本地恢复记录写入失败"); return { ...initial, state: { projects: recoveredProjects } }; } } const canvasStorage: PersistStorage = { getItem: async (name) => { const scope = getActiveUserScope(); const storage = localForageStorageForScope(scope); const value = await storage.getItem(name); let document = parseCanvasStorageDocument(value); document = await recoverCanvasPersistenceJournal(scope, name, document); if (!value && !document.state.projects.length) { canvasMemoryStates.set(scope, { projects: [] }); observedCanvasPersists.set(scope, { projects: [], revision: document.storageRevision }); return null; } const state = document.state as PersistedCanvasState; canvasMemoryStates.set(scope, state); recordCanvasStorageDocument(scope, document); return document as unknown as StorageValue; }, setItem: (name, value) => { const scope = getActiveUserScope(); const nextState = value.state as PersistedCanvasState; const previousState = canvasMemoryStates.get(scope); if (previousState?.projects === nextState.projects) return; canvasMemoryStates.set(scope, nextState); if (suppressCanvasStorePersistence) return; const queued = queuedCanvasPersists.get(scope); const observed = observedCanvasPersists.get(scope); const token = (canvasPersistTokens.get(scope) || 0) + 1; canvasPersistTokens.set(scope, token); const baseProjects = queued?.baseProjects ?? observed?.projects ?? previousState?.projects ?? []; const baseRevision = queued?.baseRevision ?? observed?.revision ?? 0; const queuedState = ordinaryCanvasPersistenceState(scope, nextState, observed?.projects ?? baseProjects); const journalBaseProjects = queued?.baseProjects ?? observed?.projects ?? previousState?.projects ?? []; const journal = createCanvasPersistenceJournal(scope, name, journalBaseProjects, queuedState.projects, token, baseRevision); const journalWritten = journal ? writeCanvasPersistenceJournal(journal) : false; queuedCanvasPersists.set(scope, { name, scope, baseProjects, baseRevision, state: queuedState, token, journal: journalWritten ? journal || undefined : undefined, }); markCanvasLocalPersistencePending(nextState.projects, journal && !journalWritten ? "本地快速恢复记录写入失败,正在等待完整画布保存" : undefined); clearCanvasSaveTimer(scope); const timer = setTimeout(() => { if (canvasSaveTimers.get(scope) === timer) canvasSaveTimers.delete(scope); void writeQueuedCanvasPersist(scope, token).catch((error) => { // 自动保存无法把异常返回给原始状态更新调用方,但失败队列和 journal 会保留给下一次写入或显式 flush 重试。 console.warn("画布本地持久化失败,已保留待写队列", { scope, error }); }); }, journalWritten ? 400 : 0); canvasSaveTimers.set(scope, timer); }, removeItem: (name) => { const scope = getActiveUserScope(); for (const journal of readCanvasPersistenceJournals(scope)) clearCanvasPersistenceJournal(scope, journal.token, journal.writerId); return localForageStorageForScope(scope).removeItem(name); }, }; export async function flushCanvasStorePersistence() { while (canvasStorageTails.size || queuedCanvasPersists.size || canvasSaveTimers.size) { for (const scope of [...canvasSaveTimers.keys()]) clearCanvasSaveTimer(scope); const writes = [...queuedCanvasPersists.values()].map(({ scope, token }) => writeQueuedCanvasPersist(scope, token)); if (writes.length) await Promise.all(writes); else if (canvasStorageTails.size) await Promise.all([...canvasStorageTails.values()]); } } export const useCanvasStore = create()( persist( (set, get) => ({ hydrated: false, projects: [], createProject: (title = "未命名画布", projectId) => { const now = new Date().toISOString(); const id = nanoid(); const appearanceDefault = readCanvasAppearanceDefault(); const project: CanvasProject = { id, revision: 0, projectId, title, createdAt: now, updatedAt: now, nodes: [], connections: [], chatSessions: [], activeChatId: null, appearance: appearanceDefault?.appearance ?? canvasAppearanceForTheme(DEFAULT_CANVAS_COLOR_THEME), backgroundMode: appearanceDefault?.backgroundMode || DEFAULT_CANVAS_BACKGROUND_MODE, showImageInfo: false, viewport: initialViewport, previsScenes: [], }; set((state) => ({ projects: [project, ...state.projects] })); return id; }, importProject: (source) => { const now = new Date().toISOString(); const project: CanvasProject = { id: nanoid(), revision: 0, projectId: source.projectId, title: source.title || "导入画布", createdAt: source.createdAt || now, updatedAt: now, nodes: source.nodes || [], connections: source.connections || [], chatSessions: source.chatSessions || [], activeChatId: source.activeChatId || null, starterMode: source.starterMode, appearance: source.appearance ? normalizeCanvasAppearance(source.appearance, "dark") : undefined, backgroundMode: source.backgroundMode || DEFAULT_CANVAS_BACKGROUND_MODE, showImageInfo: source.showImageInfo || false, viewport: source.viewport || initialViewport, previsScenes: source.previsScenes || [], }; set((state) => ({ projects: [project, ...state.projects] })); return project.id; }, openProject: (id) => { return get().projects.find((item) => item.id === id) || null; }, renameProject: (id, title) => set((state) => { const current = state.projects.find((project) => project.id === id); const nextTitle = title.trim() || current?.title; if (!current || current.title === nextTitle) return state; return { projects: state.projects.map((project) => project === current ? { ...project, title: nextTitle!, updatedAt: new Date().toISOString() } : project) }; }), deleteProjects: (ids) => set((state) => { const projects = state.projects.filter((project) => !ids.includes(project.id)); return { projects }; }), replaceProjects: (projects) => set({ projects }), updateProject: (id, patch) => set((state) => { const current = state.projects.find((project) => project.id === id); if (!current) return state; const next = { ...current, ...patch }; if (Object.keys(patch).every((key) => key === "viewport")) { if (samePersistenceValue(current.viewport, next.viewport)) return state; return { projects: state.projects.map((project) => project === current ? next : project) }; } const contentChanged = !sameCanvasContent(current, next); if (!contentChanged && samePersistenceValue(current.viewport, next.viewport)) return state; if (contentChanged) next.updatedAt = new Date().toISOString(); return { projects: state.projects.map((project) => project === current ? next : project) }; }), }), { name: CANVAS_STORE_KEY, storage: canvasStorage, partialize: (state) => ({ projects: state.projects, }) as StorageValue["state"], onRehydrateStorage: () => () => { useCanvasStore.setState({ hydrated: true }); }, }, ), );