Files
ttstd-infinite-canvas/web/src/stores/canvas/use-canvas-store.ts
T
ddcat666 080989ad5f fix(canvas): 画布同步 - 版本冲突先核对并合并,本地保存与云端状态分开展示
本地持久化记录保存时间和日志令牌,同步面板区分本地与云端,不再用提示打断编辑。冲突时先尝试合并,无法合并才保留草稿并暂停自动提交;未登录时生成结果留在本地,登录后再继续上传。
2026-10-09 17:17:38 +08:00

753 lines
37 KiB
TypeScript

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<CanvasProject>) => 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<Pick<CanvasProject, "projectId" | "nodes" | "connections" | "chatSessions" | "activeChatId" | "starterMode" | "appearance" | "backgroundMode" | "showImageInfo" | "viewport" | "previsScenes" | "timeline">>) => void;
};
const initialViewport: ViewportTransform = { x: 0, y: 0, k: 1 };
export const CANVAS_STORE_KEY = "yingce:canvas_store";
type PersistedCanvasState = Pick<CanvasStore, "projects">;
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<typeof rebaseCanvasProjects>["conflicts"]) {
if (!conflicts.length) return;
const ids = new Set(conflicts.map((conflict) => conflict.projectId || conflict.id));
const { preserveCanvasSyncDraftLocked } = await import("@/services/canvas-sync-drafts");
for (const project of projects.filter((item) => ids.has(item.id))) {
const draftCount = await preserveCanvasSyncDraftLocked(project, scope);
if (getActiveUserScope() === scope) useSyncProgressStore.getState().setProjectProgress(project.id, {
phase: "conflict", errorKind: "conflict", draftCount,
message: "其他标签页也修改了此画布,本地版本已保留为草稿",
});
}
}
function setCanvasLocalPersistenceProgress(projects: CanvasProject[], patch: Partial<SyncProjectProgress>) {
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<string, PersistedCanvasState>();
const observedCanvasPersists = new Map<string, ObservedCanvasPersist>();
const queuedCanvasPersists = new Map<string, QueuedCanvasPersist>();
const canvasSaveTimers = new Map<string, ReturnType<typeof setTimeout>>();
const canvasPersistTokens = new Map<string, number>();
type CanvasGenerationPersistenceAttempt = {
previousNodes?: CanvasNodeData[];
nodes?: CanvasNodeData[];
previousChatSessions?: CanvasAssistantSession[];
chatSessions?: CanvasAssistantSession[];
};
const pendingCanvasGenerationAttempts = new Map<string, Map<string, CanvasGenerationPersistenceAttempt>>();
type AsyncCanvasStorageLock = {
request<T>(name: string, callback: () => Promise<T>): Promise<T>;
};
type CanvasStorageLockOptions = {
requireCrossRealmLock?: boolean;
};
const CANVAS_STORAGE_LOCK_PREFIX = "yingce:canvas-generation-storage-lock:";
const canvasStorageTails = new Map<string, Promise<void>>();
function runWithBrowserCanvasStorageLock<T>(scope: string, operation: () => Promise<T>, 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<T>(scope: string, operation: () => Promise<T>, options: CanvasStorageLockOptions = {}): Promise<T> {
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<T>(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<string, CanvasGenerationPersistenceAttempt>();
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<string, unknown>;
const attemptedRecord = attempted as Record<string, unknown>;
const liveRecord = live as Record<string, unknown>;
const durableRecord = durable as Record<string, unknown>;
const result: Record<string, unknown> = { ...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 });
}
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) {
return {
...project,
nodes,
// Connection/active-chat records carry no effect stamp provenance, so keep them fail-closed while any generation entity is unconfirmed.
connections: durableProject.connections,
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<string, string>();
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<CanvasStore> = {
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<CanvasStore>;
},
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<CanvasStore>()(
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<CanvasStore>["state"],
onRehydrateStorage: () => () => {
useCanvasStore.setState({ hydrated: true });
},
},
),
);