+
} onClick={createAndEnter}>
新建画布
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index 5656afe49..25be33087 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -100,6 +100,9 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard
import { CanvasOperationChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
import { cancelGenerationTask } from "@/services/api/task-center";
+import { CanvasSyncStatus } from "./canvas-sync-status";
+import { CanvasVersionHistory, useCanvasVersionHistory } from "./canvas-version-history";
+import { CanvasVersionPreview } from "./canvas-version-preview";
import { CanvasTopBar } from "./canvas-project-top-bar";
import { LibTVImportDialog } from "./components/libtv-import-dialog";
import { TapNowImportDialog } from "./components/tapnow-import-dialog";
@@ -333,17 +336,9 @@ function InfiniteCanvasPage() {
const [titleDraft, setTitleDraft] = useState("");
const [shortcutRequestNonce, setShortcutRequestNonce] = useState(0);
const [cinematicAgentEntry, setCinematicAgentEntry] = useState(false);
- const { assistantOpen, closeAgent, openAgent } = useCanvasAssistantVisibility();
+ const { assistantOpen, closeAgent, openAgent: openAssistant } = useCanvasAssistantVisibility();
const agentMentionReferences = useMemo(() => buildCanvasAgentMentionReferences(nodes), [nodes]);
- const sendSelectionToAgent = useCallback((nodeId?: string) => {
- const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current);
- const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference));
- if (!references.length) return;
- setAgentPrefillPrompt(`${references.map(canvasResourceMentionToken).join(" ")} `);
- openAgent();
- setContextMenu(null);
- }, [agentMentionReferences, openAgent]);
const { tasks: activeTasks } = useCanvasActiveTasks(projectId, projectLoaded);
const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode();
const [focusDockRevealed, setFocusDockRevealed] = useState(false);
@@ -427,7 +422,7 @@ function InfiniteCanvasPage() {
[cleanupAssetImages, getHistoryCleanupContext],
);
- const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, saveCanvasProject, forceSaveCanvasProject, updateProject } = useCanvasProjectLifecycle({
+ const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, reloadLatestCanvasProject, restoreCanvasProjectVersion, saveCanvasProject, forceSaveCanvasProject, updateProject } = useCanvasProjectLifecycle({
projectId,
projectLoaded,
nodes,
@@ -440,6 +435,8 @@ function InfiniteCanvasPage() {
viewport,
nodesRef,
connectionsRef,
+ chatSessionsRef,
+ activeChatIdRef,
viewportRef,
historyPausedRef,
setNodes,
@@ -456,13 +453,24 @@ function InfiniteCanvasPage() {
cleanupCanvasFiles,
});
- // 强制覆盖会改写云端版本并重绑媒体素材关联,必须让用户显式确认 destructive 语义。
+ const versions = useCanvasVersionHistory(projectId, restoreCanvasProjectVersion);
+ const openVersions = () => { closeAgent(); setVersionCompareRootId(null); versions.show(); };
+ const openAgent = useCallback(() => { versions.close(); openAssistant(); }, [versions.close, openAssistant]);
+
+ const sendSelectionToAgent = useCallback((nodeId?: string) => {
+ const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current);
+ const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference));
+ if (!references.length) return;
+ setAgentPrefillPrompt(`${references.map(canvasResourceMentionToken).join(" ")} `);
+ openAgent();
+ setContextMenu(null);
+ }, [agentMentionReferences, openAgent]);
+ // 修复素材关联仍遵守当前画布版本,不能替用户确认覆盖云端的新内容。
const confirmForceSaveCanvas = useCallback(() => {
modal.confirm({
- title: "用本地内容强制覆盖云端?",
- content: "将把当前本地画布保存并覆盖云端版本,同时自动修复画布媒体与素材库的绑定(缺少素材记录时会按节点新建)。云端尚未同步到本地的改动会被覆盖。",
- okText: "强制覆盖保存",
- okButtonProps: { danger: true },
+ title: "修复素材关联并保存?",
+ content: "核对画布媒体与素材库的关联,补齐缺失素材后保存。若云端已有新版本,会保留本地草稿并提示加载最新版。",
+ okText: "修复并保存",
cancelText: "取消",
onOk: () => forceSaveCanvasProject(),
});
@@ -478,7 +486,7 @@ function InfiniteCanvasPage() {
connectionsRef.current = nextConnections;
setNodes(nextNodes);
setConnections(nextConnections);
- const saved = await saveCanvasProject();
+ const saved = await saveCanvasProject({ requireRemote: false });
if (!saved) {
nodesRef.current = previousNodes;
connectionsRef.current = previousConnections;
@@ -499,7 +507,7 @@ function InfiniteCanvasPage() {
connectionsRef.current = nextConnections;
setNodes(nextNodes);
setConnections(nextConnections);
- const saved = await saveCanvasProject();
+ const saved = await saveCanvasProject({ requireRemote: false });
if (!saved) {
nodesRef.current = previousNodes;
connectionsRef.current = previousConnections;
@@ -1628,6 +1636,7 @@ function InfiniteCanvasPage() {
}, [clearCanvasFiles, deselectCanvas, message, nodesRef, projectId, setEmotionNodeId]);
useCanvasKeyboard({
+ enabled: projectLoaded && !versions.preview,
nodesRef,
selectedNodeIdsRef,
selectedConnectionId,
@@ -2341,13 +2350,17 @@ function InfiniteCanvasPage() {
跳转到画布主内容
- {!focusMode && shortDramaEnabled && currentProject?.projectId ? (
+ {!focusMode && !versions.preview && shortDramaEnabled && currentProject?.projectId ? (
openProjectAssets()} />
) : null}
-
+
+
{!focusMode ? (
}
+ versionsOpen={versions.open}
+ onToggleVersions={() => { closeAgent(); setVersionCompareRootId(null); versions.toggle(); }}
title={currentProject?.title || "未命名画布"}
titleDraft={titleDraft}
isTitleEditing={titleEditing}
@@ -2418,6 +2431,7 @@ function InfiniteCanvasPage() {
}
+ versionsOpen={versions.open}
+ onToggleVersions={() => { closeAgent(); versions.toggle(); }}
dockRevealed={focusDockRevealed}
zoomPercent={viewport.k}
onToggleDock={() => setFocusDockRevealed((value) => !value)}
@@ -2593,10 +2610,12 @@ function InfiniteCanvasPage() {
) : null}
+
{
if (!nodesRef.current.some((node) => node.id === nodeId)) { message.info("该节点已删除或尚未同步到画布"); return; }
focusCanvasNode(nodeId);
}} />
+
{angleNode?.metadata?.content ? (
@@ -3104,7 +3123,10 @@ function InfiniteCanvasPage() {
onInsertFolder={projectAssetScope === "canvas" ? handleProjectFolderInsert : undefined}
/>
+ {versions.preview ?
: null}
+
+
>
);
diff --git a/web/src/pages/canvas/use-canvas-keyboard.ts b/web/src/pages/canvas/use-canvas-keyboard.ts
index 42c20efdd..e5625c844 100644
--- a/web/src/pages/canvas/use-canvas-keyboard.ts
+++ b/web/src/pages/canvas/use-canvas-keyboard.ts
@@ -3,6 +3,7 @@ import { useEffect, type Dispatch, type SetStateAction } from "react";
import type { CanvasNodeData, ContextMenuState } from "@/types/canvas";
type UseCanvasKeyboardOptions = {
+ enabled?: boolean;
nodesRef: { current: CanvasNodeData[] };
selectedNodeIdsRef: { current: Set
};
selectedConnectionId: string | null;
@@ -49,6 +50,7 @@ export function hasCanvasTextSelection(selection: TextSelectionLike | null | und
}
export function useCanvasKeyboard({
+ enabled = true,
nodesRef,
selectedNodeIdsRef,
selectedConnectionId,
@@ -84,6 +86,7 @@ export function useCanvasKeyboard({
beginBatchConnection,
}: UseCanvasKeyboardOptions) {
useEffect(() => {
+ if (!enabled) return;
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest(".canvas-node-toolbar, .canvas-node-toolbar-menu")) return;
@@ -219,5 +222,5 @@ export function useCanvasKeyboard({
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("paste", handlePaste, true);
};
- }, [beginBatchConnection, cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, onOpenSearch, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]);
+ }, [enabled, beginBatchConnection, cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, onOpenSearch, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]);
}
diff --git a/web/src/pages/canvas/use-canvas-project-lifecycle.ts b/web/src/pages/canvas/use-canvas-project-lifecycle.ts
index 485dbcdc8..5e8607ddd 100644
--- a/web/src/pages/canvas/use-canvas-project-lifecycle.ts
+++ b/web/src/pages/canvas/use-canvas-project-lifecycle.ts
@@ -1,5 +1,5 @@
import { mergeAgentCanvasEditor } from "@/lib/canvas/agent-canvas-patch";
-import { startTransition, useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
+import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { App } from "antd";
import { useNavigate } from "react-router";
@@ -9,9 +9,11 @@ import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps";
import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
import { listAddedSkills, type Skill } from "@/services/api/skills";
-import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, forceOverwriteRemoteCanvasSync, loadCanvasProjectForEditing, localSavedRemotePendingMessage, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync";
+import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, forceOverwriteRemoteCanvasSync, loadCanvasProjectForEditing, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync";
import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { useCanvasThemeStore } from "@/stores/canvas/use-canvas-theme-store";
+import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
+import { readCanvasSyncDrafts } from "@/services/canvas-sync-drafts";
import { useUserStore } from "@/stores/use-user-store";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
import type { CanvasHistorySnapshot } from "./use-canvas-history";
@@ -29,6 +31,8 @@ type UseCanvasProjectLifecycleOptions = {
viewport: ViewportTransform;
nodesRef: MutableRefObject;
connectionsRef: MutableRefObject;
+ chatSessionsRef: MutableRefObject;
+ activeChatIdRef: MutableRefObject;
viewportRef: MutableRefObject;
historyPausedRef: MutableRefObject;
setNodes: Dispatch>;
@@ -58,6 +62,8 @@ export function useCanvasProjectLifecycle({
viewport,
nodesRef,
connectionsRef,
+ chatSessionsRef,
+ activeChatIdRef,
viewportRef,
historyPausedRef,
setNodes,
@@ -87,11 +93,20 @@ export function useCanvasProjectLifecycle({
const [agentCreatedNodes, setAgentCreatedNodes] = useState<{ projectId: string; nodes: CanvasNodeData[] } | null>(null);
const viewportSaveTimerRef = useRef | null>(null);
+ const observedContentRef = useRef(null);
+ const loadLatestRef = useRef(false);
+ const historyRestoreRef = useRef<{ snapshotId: string; revision: number; resolve: () => void; reject: (error: unknown) => void } | null>(null);
+
useEffect(() => {
if (!hydrated || !sessionHydrated) return;
let cancelled = false;
setProjectLoaded(false);
setLoadError("");
+ const latest = loadLatestRef.current;
+ loadLatestRef.current = false;
+ const historyRestore = historyRestoreRef.current;
+ historyRestoreRef.current = null;
+ observedContentRef.current = null;
const applyRestoredProject = (targetProject: CanvasProject) => {
if (cancelled) return;
const fallbackTheme = useCanvasThemeStore.getState().theme;
@@ -111,6 +126,9 @@ export function useCanvasProjectLifecycle({
backgroundMode: targetProject.backgroundMode || DEFAULT_CANVAS_BACKGROUND_MODE,
showImageInfo: targetProject.showImageInfo || false,
};
+ observedContentRef.current = snapshot;
+ chatSessionsRef.current = snapshot.chatSessions;
+ activeChatIdRef.current = snapshot.activeChatId;
nodesRef.current = snapshot.nodes;
connectionsRef.current = snapshot.connections;
viewportRef.current = targetProject.viewport;
@@ -129,33 +147,40 @@ export function useCanvasProjectLifecycle({
const load = async () => {
const cachedProject = useCanvasStore.getState().projects.find((p) => p.id === projectId);
- if (cachedProject && cachedProject.nodes?.length) {
+ if (!latest && !historyRestore && cachedProject && cachedProject.nodes?.length) {
// 本地已有该画布的持久化缓存:先以本地数据秒开渲染,彻底消除白屏与等待
applyRestoredProject(cachedProject);
}
- const loadedProject = await loadCanvasProjectForEditing(projectId);
+ const loadedProject = await loadCanvasProjectForEditing(projectId, { latest, historyRestore: historyRestore || undefined, onLoad: applyRestoredProject });
+ historyRestore?.resolve();
if (cancelled) return;
if (!loadedProject) {
if (!cachedProject) navigate("/canvas", { replace: true });
return;
}
const project = useCanvasStore.getState().projects.find((p) => p.id === projectId) || loadedProject;
- applyRestoredProject(project);
// 画布媒体由节点自己的视口观察器按需加载;打开时遍历并解析全部节点会让大画布形成 N+1 资源读取。
void hydrateAssistantImages(project.chatSessions || [])
.then((hydratedSessions) => {
- if (!cancelled) setChatSessions((current) => mergeHydratedSessions(current, hydratedSessions));
+ if (!cancelled) setChatSessions((current) => {
+ const merged = mergeHydratedSessions(current, hydratedSessions);
+ if (observedContentRef.current?.chatSessions === current) observedContentRef.current = { ...observedContentRef.current, chatSessions: merged };
+ return merged;
+ });
})
.catch(() => {
if (!cancelled) message.warning("部分助手会话素材恢复失败,已使用项目记录继续打开");
});
};
void load().catch((error) => {
+ historyRestore?.reject(error);
+ if (!cancelled && useSyncProgressStore.getState().syncingProjects[projectId]?.phase !== "conflict") useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "error", message: error instanceof Error ? error.message : "读取云端版本失败" });
if (!cancelled) setLoadError(error instanceof Error ? error.message : "读取画布失败,请重试");
});
return () => {
cancelled = true;
+ historyRestore?.reject(new Error("画布页面已关闭,请重新打开查看恢复结果"));
};
}, [hydrated, sessionHydrated, loadAttempt, message, navigate, openProject, projectId, resetHistory, setActiveChatId, setBackgroundMode, setCanvasAppearance, setChatSessions, setConnections, setNodes, setShowImageInfo, setViewport]);
@@ -179,6 +204,13 @@ export function useCanvasProjectLifecycle({
// Merge only server-changed fields so dragging/editing other nodes can
// continue while Agent media tasks complete. Same-field conflicts fail.
const merged = previous ? mergeAgentCanvasEditor(previous, project, nodesRef.current, connectionsRef.current) : project;
+ if (observedContentRef.current) {
+ const observed = observedContentRef.current;
+ // Advance only the observed server fields; edits in live refs still
+ // differ from this baseline and must be persisted by the effect below.
+ const baseline = previous ? mergeAgentCanvasEditor(previous, project, observed.nodes, observed.connections) : project;
+ observedContentRef.current = { ...observed, nodes: baseline.nodes, connections: baseline.connections };
+ }
nodesRef.current = merged.nodes;
connectionsRef.current = merged.connections;
setNodes(merged.nodes);
@@ -190,6 +222,9 @@ export function useCanvasProjectLifecycle({
useEffect(() => {
if (!projectLoaded || historyPausedRef.current) return;
+ const snapshot = { nodes, connections, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo };
+ if (!observedContentRef.current || JSON.stringify(observedContentRef.current) === JSON.stringify(snapshot)) return;
+ observedContentRef.current = snapshot;
const patch = { nodes, connections, chatSessions, activeChatId, appearance: canvasAppearance, backgroundMode, showImageInfo };
const stored = useCanvasStore.getState().projects.find((project) => project.id === projectId);
// 远端结果投影到编辑器不是一次本地编辑,避免改写时间戳并触发反向保存。
@@ -223,8 +258,11 @@ export function useCanvasProjectLifecycle({
}, [message, navigate]);
const deleteCurrentProject = useCallback(async () => {
- const drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []);
+ let drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []);
try {
+ const drafts = await readCanvasSyncDrafts(projectId);
+ const preserved = new Set(drafts.flatMap((draft) => draft.project.nodes.flatMap((node) => node.metadata?.drawingId ? [node.metadata.drawingId] : [])));
+ drawingIds = drawingIds.filter((id) => !preserved.has(id));
await deleteCanvasProjectsWithRemoteSync([projectId]);
} catch (error) {
message.error(error instanceof Error ? `删除画布失败:${error.message}` : "删除画布失败,请稍后重试");
@@ -242,8 +280,9 @@ export function useCanvasProjectLifecycle({
renameProject(projectId, title);
}, [projectId, renameProject]);
- const persistCanvasSnapshot = useCallback(async (): Promise => {
- try {
+ const persistLocalEdits = useCallback(async () => {
+ const snapshot = { nodes: nodesRef.current, connections: connectionsRef.current, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo };
+ if (observedContentRef.current && JSON.stringify(observedContentRef.current) !== JSON.stringify(snapshot)) {
updateProject(projectId, {
nodes: nodesRef.current,
connections: connectionsRef.current,
@@ -253,38 +292,58 @@ export function useCanvasProjectLifecycle({
backgroundMode,
showImageInfo,
viewport: viewportRef.current,
- directorScenes: currentProject?.directorScenes || [],
});
- await flushCanvasStorePersistence();
- return true;
+ observedContentRef.current = snapshot;
+ }
+ updateProject(projectId, { viewport: viewportRef.current });
+ await flushCanvasStorePersistence();
+ }, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
+
+ const reloadLatestCanvasProject = useCallback(async () => {
+ await persistLocalEdits();
+ loadLatestRef.current = true;
+ setLoadAttempt((value) => value + 1);
+ }, [persistLocalEdits]);
+
+ const restoreCanvasProjectVersion = useCallback(async (snapshotId: string, revision: number) => {
+ await persistLocalEdits();
+ return new Promise((resolve, reject) => {
+ historyRestoreRef.current?.reject(new Error("已有新的恢复请求"));
+ historyRestoreRef.current = { snapshotId, revision, resolve, reject };
+ setLoadAttempt((value) => value + 1);
+ });
+ }, [persistLocalEdits]);
+
+ const saveCanvasProject = useCallback(async (options: { requireRemote?: boolean } = {}): Promise => {
+ try {
+ await persistLocalEdits();
} catch {
message.error("画布保存失败,请稍后重试");
return false;
}
- }, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
-
- const saveCanvasProject = useCallback(async (): Promise => {
- if (!(await persistCanvasSnapshot())) return false;
try {
- await saveRemoteUserDataNow();
- message.success("画布布局和位置已保存");
+ await saveRemoteUserDataNow(projectId);
+ message.success("画布已保存到云端");
} catch (error) {
- // 本地 IndexedDB 已写入;云端失败只排队重试,不能把导入方的画布状态回滚。
- message.warning(localSavedRemotePendingMessage("本地画布布局已保存", error));
+ const detail = error instanceof Error ? error.message : "未知错误";
+ message.warning(`本地画布布局已保存,云端同步失败:${detail}`);
+ // Imports can retain their durable local result; sharing requires cloud success.
+ return options.requireRemote === false;
}
return true;
- }, [message, persistCanvasSnapshot]);
+ }, [message, persistLocalEdits, projectId]);
const forceSaveCanvasProject = useCallback(async (): Promise => {
- if (!(await persistCanvasSnapshot())) return false;
+ try { await persistLocalEdits(); } catch { message.error("本地保存失败,请重试"); return false; }
try {
const result = await forceOverwriteRemoteCanvasSync();
- message.success(result.reboundNodes > 0 ? `已用本地内容覆盖云端,并修复 ${result.reboundNodes} 处媒体与素材的绑定` : "已用本地内容覆盖云端画布");
+ message.success(result.reboundNodes > 0 ? `已保存,并修复 ${result.reboundNodes} 处媒体与素材的绑定` : "素材关联已核对,画布已保存");
} catch (error) {
- message.error(`强制覆盖保存失败:${error instanceof Error ? error.message : "未知错误"}`);
+ message.error(`修复并保存失败:${error instanceof Error ? error.message : "未知错误"}`);
+ return false;
}
return true;
- }, [message, persistCanvasSnapshot]);
+ }, [message, persistLocalEdits]);
const clearCanvasFiles = useCallback(() => {
cleanupCanvasFiles({ projectId, nodes: [], chatSessions: [] });
@@ -300,6 +359,8 @@ export function useCanvasProjectLifecycle({
currentProject,
deleteCurrentProject,
renameCurrentProject,
+ reloadLatestCanvasProject,
+ restoreCanvasProjectVersion,
saveCanvasProject,
forceSaveCanvasProject,
updateProject,
diff --git a/web/src/services/agent-canvas-sync.ts b/web/src/services/agent-canvas-sync.ts
index 6ce5d7ff5..40c12898c 100644
--- a/web/src/services/agent-canvas-sync.ts
+++ b/web/src/services/agent-canvas-sync.ts
@@ -53,7 +53,7 @@ export function createAgentCanvasSync(options: Options) {
if (patch && patch.canvasId === options.canvasId) {
supportsPatches = true;
patches.push(patch);
- } else if (!supportsPatches && (event.type === "canvas_updated" || event.type === "generation_task_created" || event.type === "tool_failed" || event.type === "run_failed" || (event.type === "tool_completed" && ["canvas_apply_ops", "generate_media"].includes(String(event.payload.toolName))))) {
+ } else if (event.type === "canvas_updated" || event.type === "canvas_undone" || (!supportsPatches && (event.type === "generation_task_created" || event.type === "tool_failed" || event.type === "run_failed" || (event.type === "tool_completed" && ["canvas_apply_ops", "generate_media"].includes(String(event.payload.toolName)))))) {
needsRefresh = true;
}
schedule();
diff --git a/web/src/services/api/user-data.ts b/web/src/services/api/user-data.ts
index c39a7c45a..7c4b155e9 100644
--- a/web/src/services/api/user-data.ts
+++ b/web/src/services/api/user-data.ts
@@ -10,6 +10,7 @@ export type RemoteUserDataSummary = {
title: string;
createdAt: string;
updatedAt: string;
+ revision?: number;
};
export type AssetFolder = {
@@ -36,7 +37,7 @@ export type RemoteUserDataSnapshot = {
projects: CanvasProject[];
};
-export type CanvasLibrarySummary = Pick & {
+export type CanvasLibrarySummary = Pick & {
nodeCount: number;
previewNodes: CanvasProject["nodes"];
};
@@ -117,9 +118,35 @@ export function getRemoteCanvasProject(id: string) {
}
export function upsertRemoteCanvasProject(project: CanvasProject) {
- return http.put<{ project: RemoteUserDataSummary }>(`/canvas-projects/${encodeURIComponent(project.id)}`, { project });
+ const { viewport: _viewport, remoteContentHash: _hash, ...content } = project;
+ return http.put<{ project: RemoteUserDataSummary & { revision: number } }>(`/canvas-projects/${encodeURIComponent(project.id)}`, { project: content });
}
export function deleteRemoteCanvasProject(id: string) {
return http.delete<{ id: string }>(`/canvas-projects/${encodeURIComponent(id)}`);
}
+
+export type CanvasHistoryEntry = {
+ id: string;
+ canvasId: string;
+ revision: number;
+ title: string;
+ nodeCount: number;
+ connectionCount: number;
+ payloadBytes: number;
+ reason: "automatic" | "before_restore";
+ createdAt: string;
+ contentUpdatedAt: string;
+};
+
+export function listCanvasHistory(id: string, signal?: AbortSignal) {
+ return http.get<{ snapshots: CanvasHistoryEntry[]; currentRevision: number }>(`/canvas-projects/${encodeURIComponent(id)}/history`, { signal });
+}
+
+export function getCanvasHistoryEntry(id: string, snapshotId: string, signal?: AbortSignal) {
+ return http.get<{ snapshot: CanvasHistoryEntry; project: CanvasProject }>(`/canvas-projects/${encodeURIComponent(id)}/history/${encodeURIComponent(snapshotId)}`, { signal });
+}
+
+export function restoreRemoteCanvasHistory(id: string, snapshotId: string, revision: number) {
+ return http.post<{ project: RemoteUserDataSummary & { revision: number } }>(`/canvas-projects/${encodeURIComponent(id)}/history/${encodeURIComponent(snapshotId)}/restore`, { revision });
+}
diff --git a/web/src/services/canvas-sync-drafts.ts b/web/src/services/canvas-sync-drafts.ts
new file mode 100644
index 000000000..352242248
--- /dev/null
+++ b/web/src/services/canvas-sync-drafts.ts
@@ -0,0 +1,40 @@
+import { nanoid } from "nanoid";
+import { localForageStorageForScope } from "@/lib/localforage-storage";
+import { getActiveUserScope } from "@/lib/user-scope";
+import { sameCanvasContent } from "@/lib/canvas/canvas-content";
+import { withCanvasStorePersistenceLock, type CanvasProject } from "@/stores/canvas/use-canvas-store";
+import { withGenerationArtifactCommitLock } from "@/services/generation-asset-repository";
+
+export type CanvasSyncDraft = { id: string; savedAt: string; project: CanvasProject };
+const draftKey = (projectId: string) => `infinite-canvas:sync-drafts:${projectId}`;
+const draftIndexKey = "infinite-canvas:sync-draft-index";
+
+export async function readAllCanvasSyncDrafts(scope = getActiveUserScope()) {
+ const raw = await localForageStorageForScope(scope).getItem(draftIndexKey);
+ const ids: string[] = raw ? JSON.parse(raw) : [];
+ return (await Promise.all(ids.map((id) => readCanvasSyncDrafts(id, scope)))).flat();
+}
+
+export async function readCanvasSyncDrafts(projectId: string, scope = getActiveUserScope()): Promise {
+ const raw = await localForageStorageForScope(scope).getItem(draftKey(projectId));
+ return raw ? JSON.parse(raw) : [];
+}
+
+export async function preserveCanvasSyncDraft(project: CanvasProject, scope = getActiveUserScope()) {
+ // A separate record survives replacement of the active canvas and a browser reload.
+ const snapshot = structuredClone(project);
+ return withGenerationArtifactCommitLock(scope, () =>
+ withCanvasStorePersistenceLock(scope, async () => {
+ const drafts = await readCanvasSyncDrafts(snapshot.id, scope);
+ if (!drafts.some((draft) => sameCanvasContent(draft.project, snapshot))) {
+ const storage = localForageStorageForScope(scope);
+ const rawIndex = await storage.getItem(draftIndexKey);
+ const ids: string[] = rawIndex ? JSON.parse(rawIndex) : [];
+ if (!ids.includes(snapshot.id)) await storage.setItem(draftIndexKey, JSON.stringify([...ids, snapshot.id]));
+ drafts.push({ id: nanoid(), savedAt: new Date().toISOString(), project: snapshot });
+ await storage.setItem(draftKey(snapshot.id), JSON.stringify(drafts));
+ }
+ return drafts.length;
+ }),
+ );
+}
diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts
index d617abda4..3cf94d1cf 100644
--- a/web/src/services/user-data-sync.ts
+++ b/web/src/services/user-data-sync.ts
@@ -1,6 +1,10 @@
import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage";
-import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
+import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, restoreRemoteCanvasHistory, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
+import { ApiError } from "@/services/api/request";
+import { canvasContentHash, sameCanvasContent } from "@/lib/canvas/canvas-content";
+import { getActiveUserScope } from "@/lib/user-scope";
+import { preserveCanvasSyncDraft, readCanvasSyncDrafts } from "@/services/canvas-sync-drafts";
import { appQueryClient } from "@/lib/query-client";
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { parseAssetRecordList } from "@/lib/asset-record";
@@ -13,7 +17,6 @@ import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
import { repairMissingCanvasAssets, collectCanvasMediaAssetIds, rebindInconsistentCanvasAssets, type CanvasAssetRebindResult } from "@/services/canvas-asset-repair";
import { canvasNodeToAsset } from "@/lib/canvas/canvas-node-asset";
-import { sameAgentCanvasContent } from "@/lib/canvas/agent-canvas-snapshot";
import { applyAgentCanvasPatch, type AgentCanvasPatch } from "@/lib/canvas/agent-canvas-patch";
let activeRemoteUserId = "";
@@ -44,34 +47,89 @@ export async function initializeRemoteUserDataSession(userId: string) {
});
}
-export async function loadCanvasProjectForEditing(id: string) {
- const pending = remoteProjectLoadPromises.get(id);
- if (pending) return pending;
+export async function loadCanvasProjectForEditing(id: string, options: { latest?: boolean; historyRestore?: { snapshotId: string; revision: number }; onLoad?: (project: CanvasProject) => void } = {}) {
const epoch = sessionEpoch;
const request = withRemoteUserDataSyncExclusive(async () => {
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
- const local = useCanvasStore.getState().projects.find((project) => project.id === id);
- if (!activeRemoteUserId || verifiedProjects.has(id)) return local;
- const { project } = await getRemoteCanvasProject(id);
- await loadReferencedAssets(collectAssetIds(project));
- const current = useCanvasStore.getState().projects.find((candidate) => candidate.id === id);
- if (current && !sameEntitySnapshot(acknowledgedProjects.get(id), current)) {
- // 当前页面已经开始编辑本地缓存时,远端详情只建立新的冲突基线;
- // 保留当前画布内容,后续保存由当前打开的画布显式覆盖远端版本,
- // 避免旧缓存被永久卡在“自动重试但永远冲突”的状态。
- acknowledgedProjects.set(id, project);
- verifiedProjects.add(id);
- return current;
+ requireRemoteUserDataBaseline();
+ const scope = getActiveUserScope();
+ if (options.historyRestore) {
+ const local = useCanvasStore.getState().openProject(id);
+ if (local) {
+ const draftCount = await preserveCanvasSyncDraft(local, scope);
+ useSyncProgressStore.getState().setProjectProgress(id, { draftCount });
+ }
+ if (epoch !== sessionEpoch || getActiveUserScope() !== scope || useCanvasStore.getState().openProject(id) !== local) {
+ throw new Error("本地内容仍在更新,请稍后再恢复历史版本");
+ }
+ const { snapshotId, revision } = options.historyRestore;
+ try {
+ const saved = await restoreRemoteCanvasHistory(id, snapshotId, revision);
+ if (epoch !== sessionEpoch || getActiveUserScope() !== scope) throw new Error("账号已切换,请重新打开画布查看恢复结果");
+ if (saved.project.revision !== revision + 1) throw new Error("恢复结果的版本无效,请重新核对云端内容");
+ } catch (error) {
+ if (epoch === sessionEpoch && getActiveUserScope() === scope && error instanceof ApiError && (error.status === 409 || error.status === 428)) {
+ useSyncProgressStore.getState().setProjectProgress(id, { phase: "conflict", message: error.message });
+ }
+ throw error;
+ }
}
+ let remote: CanvasProject;
+ try {
+ remote = (await getRemoteCanvasProject(id)).project;
+ } catch (error) {
+ if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
+ const local = useCanvasStore.getState().openProject(id);
+ if (error instanceof ApiError && error.status === 404 && local?.revision === 0) {
+ options.onLoad?.(local);
+ return local;
+ }
+ throw error;
+ }
+ if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
+ await loadReferencedAssets(collectAssetIds(remote));
+ const hash = await canvasContentHash(remote);
+ if (epoch !== sessionEpoch || getActiveUserScope() !== scope) throw new Error("账号已切换,请重新打开画布");
+ const local = useCanvasStore.getState().openProject(id);
+ const cachedClean = local?.remoteContentHash && local.remoteContentHash === await canvasContentHash(local);
+ const dirty = local && (!verifiedProjects.has(id) && incrementalSession ? !cachedClean : !sameCanvasContent(acknowledgedProjects.get(id), local));
+ if (dirty && !sameCanvasContent(local, remote)) {
+ const draftCount = await preserveCanvasSyncDraft(local, scope);
+ if (epoch !== sessionEpoch || getActiveUserScope() !== scope || useCanvasStore.getState().openProject(id) !== local) throw new Error("画布仍在更新,已保留本地内容,请稍后再加载最新版本");
+ useSyncProgressStore.getState().setProjectProgress(id, { draftCount });
+ if (!options.latest && !options.historyRestore && local.revision !== undefined) {
+ if (local.revision !== remote.revision) {
+ useSyncProgressStore.getState().setProjectProgress(id, { phase: "conflict", message: "云端画布已有更新,本地修改已保留为草稿" });
+ } else {
+ // A cached draft is not an acknowledged cloud save. Once its
+ // ancestor is verified, retain it as pending against that content.
+ acknowledgedProjects.set(id, { ...remote, remoteContentHash: hash });
+ verifiedProjects.add(id);
+ useSyncProgressStore.getState().setProjectProgress(id, { phase: "pending", message: "本地草稿等待保存到云端" });
+ scheduleRemoteUserDataSync();
+ }
+ options.onLoad?.(local);
+ return local;
+ }
+ }
+ // Editing/generation may continue during the network request or draft write.
+ // Never replace a newer local state which has not been backed up.
+ if (epoch !== sessionEpoch || getActiveUserScope() !== scope || useCanvasStore.getState().openProject(id) !== local) {
+ throw new Error("画布仍在更新,已保留本地内容,请稍后再加载最新版本");
+ }
+ const project = { ...remote, viewport: local?.viewport || remote.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: hash };
+ // Align live editor refs synchronously before publishing the new revision.
+ // A generation callback must never see old rendered nodes paired with a new revision.
+ options.onLoad?.(project);
acknowledgedProjects.set(id, project);
verifiedProjects.add(id);
- useCanvasStore.setState((state) => ({ projects: [...state.projects.filter((candidate) => candidate.id !== id), project] }));
+ useCanvasStore.setState((state) => ({ projects: local ? state.projects.map((item) => item.id === id ? project : item) : [...state.projects, project] }));
+ await flushCanvasStorePersistence();
+ useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", message: "已加载云端最新版本" });
return project;
});
remoteProjectLoadPromises.set(id, request);
- const clearPending = () => {
- if (remoteProjectLoadPromises.get(id) === request) remoteProjectLoadPromises.delete(id);
- };
+ const clearPending = () => { if (remoteProjectLoadPromises.get(id) === request) remoteProjectLoadPromises.delete(id); };
void request.then(clearPending, clearPending);
return request;
}
@@ -92,13 +150,28 @@ export async function refreshCanvasAfterAgent(id: string) {
const { project } = await getRemoteCanvasProject(id);
if (epoch !== sessionEpoch) throw new Error("账号已切换");
const current = useCanvasStore.getState().projects.find((candidate) => candidate.id === id);
- if (current && !sameAgentCanvasContent(acknowledgedProjects.get(id), current)) throw new Error("Agent 已更新服务端画布,但本地存在未同步编辑。已保留本地内容,请处理同步冲突后刷新。");
- if (current && sameEntitySnapshot(current, project)) return current;
- const projected = current ? { ...project, viewport: current.viewport } : project;
- for (const listener of agentCanvasListeners) listener(projected, current);
+ const cachedDirty = current && incrementalSession && !verifiedProjects.has(id) && current.remoteContentHash !== await canvasContentHash(current);
+ if (epoch !== sessionEpoch) throw new Error("账号已切换");
+ if (current && (cachedDirty || !sameCanvasContent(acknowledgedProjects.get(id), current))) {
+ await preserveAgentConflict(current);
+ throw new Error("Agent 已更新服务端画布,但本地存在未同步编辑。已保留本地草稿,请加载云端最新版本。");
+ }
+ if (current && current.revision === project.revision && sameCanvasContent(current, project)) return current;
+ const projected = { ...project, viewport: current?.viewport || project.viewport, remoteContentHash: await canvasContentHash(project) };
+ if (epoch !== sessionEpoch || useCanvasStore.getState().openProject(id) !== (current || null)) throw new Error("画布仍在更新,请重新同步 Agent 结果");
+ try {
+ if (!sameCanvasContent(current, projected)) {
+ for (const listener of agentCanvasListeners) listener(projected, current);
+ }
+ } catch (error) {
+ if (current) await preserveAgentConflict(current);
+ throw error;
+ }
acknowledgedProjects.set(id, project);
verifiedProjects.add(id);
useCanvasStore.setState((state) => ({ projects: [...state.projects.filter((candidate) => candidate.id !== id), projected] }));
+ await flushCanvasStorePersistence();
+ useSyncProgressStore.getState().setProjectProgress(id, { phase: "done", message: "已同步 Agent 画布结果" });
return projected;
});
}
@@ -110,19 +183,46 @@ export async function applyAgentCanvasPatches(id: string, patches: AgentCanvasPa
const baseline = acknowledgedProjects.get(id);
const current = useCanvasStore.getState().projects.find((project) => project.id === id);
if (!baseline || !current) throw new Error("缺少画布同步基线,需要重新读取画布");
- const remote = patches.reduce(applyAgentCanvasPatch, baseline);
- const projected = patches.reduce(applyAgentCanvasPatch, current);
- if (projected !== current) {
- for (const listener of agentCanvasListeners) listener(projected, current);
+ let remote = baseline;
+ let projected = current;
+ try {
+ if (incrementalSession && !verifiedProjects.has(id) && baseline.remoteContentHash !== await canvasContentHash(baseline)) throw new Error("本地缓存尚未核对,请加载云端最新版本");
+ if (current.revision !== baseline.revision || !Number.isSafeInteger(baseline.revision)) throw new Error("画布同步基线已变化");
+ for (const patch of patches) {
+ if (!Number.isSafeInteger(patch.revision) || !Number.isSafeInteger(patch.baseRevision)) throw new Error("Agent 增量缺少版本,请重新读取画布");
+ if (patch.revision! <= remote.revision!) continue;
+ if (patch.baseRevision !== remote.revision || patch.revision !== patch.baseRevision! + 1) throw new Error("Agent 画布增量版本不连续,请重新读取画布");
+ remote = { ...applyAgentCanvasPatch(remote, patch), revision: patch.revision };
+ projected = { ...applyAgentCanvasPatch(projected, patch), revision: patch.revision };
+ }
+ if (projected === current) return current;
+ const hash = await canvasContentHash(remote);
+ if (epoch !== sessionEpoch || useCanvasStore.getState().openProject(id) !== current) throw new Error("画布仍在更新,请重新同步 Agent 结果");
+ remote = { ...remote, remoteContentHash: hash };
+ projected = { ...projected, remoteContentHash: hash };
+ if (!sameCanvasContent(projected, current)) {
+ for (const listener of agentCanvasListeners) listener(projected, current);
+ }
+ } catch (error) {
+ if (epoch === sessionEpoch) await preserveAgentConflict(useCanvasStore.getState().openProject(id) || current);
+ throw error;
}
acknowledgedProjects.set(id, remote);
verifiedProjects.add(id);
if (projected === current) return current;
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => project.id === id ? projected : project) }));
+ await flushCanvasStorePersistence();
+ useSyncProgressStore.getState().setProjectProgress(id, { phase: sameCanvasContent(remote, projected) ? "done" : "pending", message: "已同步 Agent 画布结果" });
return projected;
});
}
+async function preserveAgentConflict(project: CanvasProject) {
+ useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "conflict", message: "云端画布已有更新,请保留草稿并加载最新版本" });
+ const draftCount = await preserveCanvasSyncDraft(project);
+ useSyncProgressStore.getState().setProjectProgress(project.id, { draftCount });
+}
+
async function waitForRemoteProjectLoads() {
const pending = [...remoteProjectLoadPromises.values()];
if (pending.length) await Promise.all(pending);
@@ -184,7 +284,6 @@ const LOCAL_STORAGE_KEY_PATTERN = /^(image|video|audio|file|video-reference|audi
export async function syncRemoteUserData(userId?: string | null) {
// 登录/切换账号时,服务端快照建立新的远端基线;本地 IndexedDB 只负责首屏缓存,
// 不能把服务端已经删除或当前用户无权访问的实体重新补回去。后续增量保存必须基于这份基线做冲突校验。
- let repairedCanvasAssets = false;
await withRemoteUserDataSyncExclusive(async () => {
incrementalSession = false;
activeRemoteUserId = userId || "";
@@ -199,30 +298,49 @@ export async function syncRemoteUserData(userId?: string | null) {
// 登录只拉一次聚合快照。摘要列表再逐条请求详情会把 N 条数据放大成 2N+2 个请求,
// 并且会在登录阶段同时触发大量媒体解析,任何一项失败都会污染登录结果。
const snapshot = await getRemoteUserDataSnapshot();
- // 登录时服务端是实体真相。浏览器 IndexedDB 只作为首屏缓存,不能把服务端已删除的记录补回去。
- // 这里只替换结构化记录,不在登录阶段解析图片/视频/音频 URL;媒体由实际使用方按需解析。
- const snapshotAssets = parseAssetRecordList(snapshot.assets);
- useCanvasStore.getState().replaceProjects(snapshot.projects);
- useAssetStore.getState().replaceAssets(snapshotAssets);
- const repair = repairMissingCanvasAssets();
- repairedCanvasAssets = repair.createdAssets > 0 || repair.updatedProjects > 0;
+ const localProjects = useCanvasStore.getState().projects;
+ const remoteById = new Map(snapshot.projects.map((project) => [project.id, project]));
+ // Archive unsynced work before replacing the active cache, including deleted remote canvases.
+ for (const local of localProjects) {
+ const clean = local.remoteContentHash && local.remoteContentHash === await canvasContentHash(local);
+ if (!clean && !sameCanvasContent(local, remoteById.get(local.id))) await preserveCanvasSyncDraft(local);
+ }
+ const projects = await Promise.all(snapshot.projects.map(async (project) => {
+ const local = localProjects.find((item) => item.id === project.id);
+ const draftCount = (await readCanvasSyncDrafts(project.id)).length;
+ useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "done", draftCount, message: "已保存到云端" });
+ return { ...project, viewport: local?.viewport || project.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: await canvasContentHash(project) };
+ }));
+ if (useCanvasStore.getState().projects !== localProjects) throw new Error("本地画布仍在更新,已保留本地内容,请重新同步");
+ useCanvasStore.getState().replaceProjects(projects);
+ useAssetStore.getState().replaceAssets(parseAssetRecordList(snapshot.assets));
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
- acknowledgedProjects = new Map(snapshot.projects.map((project) => [project.id, project]));
- acknowledgedAssets = new Map(snapshotAssets.map((asset) => [asset.id, asset]));
+ acknowledgedProjects = new Map(projects.map((project) => [project.id, project]));
+ acknowledgedAssets = new Map(parseAssetRecordList(snapshot.assets).map((asset) => [asset.id, asset]));
remoteUserDataPhase = "ready";
} catch (error) {
remoteUserDataPhase = "failed";
throw error;
}
});
- if (repairedCanvasAssets) await saveRemoteUserDataNow();
}
export function installRemoteUserDataAutoSync() {
if (subscriptionsInstalled) return;
subscriptionsInstalled = true;
useCanvasStore.subscribe((state, previous) => {
- if (state.projects !== previous.projects) scheduleRemoteUserDataSync();
+ if (state.projects === previous.projects) return;
+ const before = new Map(previous.projects.map((project) => [project.id, project]));
+ const changed = state.projects.filter((project) => !sameCanvasContent(before.get(project.id), project));
+ if (!changed.length && state.projects.length === previous.projects.length) return;
+ if (remoteUserDataPhase === "ready") {
+ for (const project of changed) {
+ if (sameCanvasContent(acknowledgedProjects.get(project.id), project)) continue;
+ if (useSyncProgressStore.getState().syncingProjects[project.id]?.phase === "conflict") continue;
+ useSyncProgressStore.getState().setProjectProgress(project.id, { phase: "pending", message: "有修改等待保存" });
+ }
+ }
+ scheduleRemoteUserDataSync();
});
useAssetStore.subscribe((state, previous) => {
if (state.assets !== previous.assets) scheduleRemoteUserDataSync();
@@ -281,6 +399,9 @@ export function scheduleRemoteUserDataSync() {
export function formatLocalSavedRemotePending(localAction: string, error: unknown): string {
const detail = error instanceof Error && error.message.trim() ? error.message.trim() : "未知错误";
+ if (error instanceof ApiError && (error.status === 409 || error.status === 428)) {
+ return `${localAction},云端同步已暂停:${detail}。请保留草稿并加载最新版本。`;
+ }
return `${localAction},云端同步失败:${detail}。将自动重试。`;
}
@@ -295,7 +416,7 @@ export async function createCanvasProjectWithRemoteSync(title: string, projectId
if (initialContent) useCanvasStore.getState().updateProject(id, initialContent);
if (!activeRemoteUserId) return { id, syncError: new Error("尚未建立云端同步会话") };
try {
- await saveRemoteUserDataNow();
+ await saveRemoteUserDataNow(id);
return { id };
} catch (syncError) {
scheduleRemoteUserDataSync();
@@ -436,35 +557,44 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
});
}
-export async function saveRemoteUserDataNow(options: { force?: boolean } = {}) {
- // 这是远端写入的总闸门:只有 phase=ready 且已建立 acknowledged 基线时才能提交。
- // 本地 Zustand/localForage 写成功不等于服务端写成功,任何同步异常都必须继续抛出给调用方。
+export async function saveRemoteUserDataNow(input?: string | readonly string[] | { force?: boolean }) {
+ const projectId = typeof input === "string" || Array.isArray(input) ? input as string | readonly string[] : undefined;
+ const options = input && typeof input === "object" && !Array.isArray(input) ? input as { force?: boolean } : {};
const epoch = sessionEpoch;
- if (!activeRemoteUserId) return;
+ if (!activeRemoteUserId) throw new Error("尚未建立云端同步会话,本地内容尚未保存到云端");
requireRemoteUserDataBaseline();
- // 画布先用本地缓存秒开时,远端详情校验可能仍在进行;写入必须等待校验结果。
+ const assertNoConflict = () => {
+ const ids = projectId === undefined ? useCanvasStore.getState().projects.map((project) => project.id)
+ : typeof projectId === "string" ? [projectId] : projectId;
+ if (ids.some((id) => useSyncProgressStore.getState().syncingProjects[id]?.phase === "conflict")) {
+ throw new ApiError("云端画布已有更新,请保留本地草稿并加载最新版本", { status: 409 });
+ }
+ };
+ if (projectId !== undefined) assertNoConflict();
await waitForRemoteProjectLoads();
if (syncPromise) {
syncQueued = true;
- return syncPromise;
+ await syncPromise;
+ assertNoConflict();
+ return;
}
syncPromise = withRemoteUserDataSyncExclusive(async () => {
- if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存");
requireRemoteUserDataBaseline();
+ if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存");
await drainRemoteUserDataChanges(options);
});
try {
await syncPromise;
+ assertNoConflict();
} finally {
syncPromise = null;
+ if (syncQueued) scheduleRemoteUserDataSync();
}
}
/**
- * 强制覆盖保存:以本地内容为准修复画布媒体与素材的绑定(重绑到引用同一资源的素材,
- * 缺失则按节点新建),再走「素材先于画布」的整体推送覆盖云端。服务端画布不变式只认
- * 「节点资源被其 assetId 对应素材引用」,重绑后的本地画布可以在不破坏该不变式的前提下
- * 覆盖远端。素材远端版本冲突时采纳远端为基线继续覆盖,避免显式覆盖被冲突检测卡死。
+ * 修复媒体与素材的绑定,再按素材先于画布的顺序保存。
+ * 素材修复可采用远端素材基线;画布始终携带原始 revision,不能绕过版本校验。
*/
export async function forceOverwriteRemoteCanvasSync(): Promise {
const epoch = sessionEpoch;
@@ -472,7 +602,7 @@ export async function forceOverwriteRemoteCanvasSync(): Promise {
- if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止强制覆盖");
+ if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存");
requireRemoteUserDataBaseline();
const projects = useCanvasStore.getState().projects;
// 服务端素材记录是 guard 实际校验的事实;本地缓存可能落后,须先取回再判定绑定一致性。
@@ -503,26 +633,15 @@ async function drainRemoteUserDataChanges(options: { force?: boolean } = {}) {
async function saveRemoteUserDataBatch(uploaded: Map, options: { force?: boolean } = {}) {
// 中央兜底:任何调用方只要把持久媒体写进画布,提交前都会先补齐素材记录与 assetId。
// 页面级入口仍主动入库,以便立即反馈;这里负责阻止遗漏入口形成远端幽灵资源。
- const changedProjectIds = new Set(useCanvasStore.getState().projects.filter((project) => !sameEntitySnapshot(acknowledgedProjects.get(project.id), project)).map((project) => project.id));
- repairMissingCanvasAssets(incrementalSession ? changedProjectIds : undefined, incrementalSession);
+ const changedProjectIds = new Set(useCanvasStore.getState().projects.filter((project) => !sameCanvasContent(acknowledgedProjects.get(project.id), project)).map((project) => project.id));
+ repairMissingCanvasAssets(changedProjectIds, incrementalSession);
const currentProjects = useCanvasStore.getState().projects;
const currentAssets = useAssetStore.getState().assets;
- const dirtyProjects = currentProjects.filter((project) => !sameEntitySnapshot(acknowledgedProjects.get(project.id), project));
+ const dirtyProjects = currentProjects.filter((project) => !sameCanvasContent(acknowledgedProjects.get(project.id), project) && useSyncProgressStore.getState().syncingProjects[project.id]?.phase !== "conflict");
const dirtyAssets = currentAssets.filter((asset) => !sameEntitySnapshot(acknowledgedAssets.get(asset.id), asset));
if (!dirtyProjects.length && !dirtyAssets.length) return;
if (incrementalSession) {
- for (const source of dirtyProjects) {
- const baseline = acknowledgedProjects.get(source.id);
- if (!baseline || verifiedProjects.has(source.id)) continue;
- const { project } = await getRemoteCanvasProject(source.id);
- if (Date.parse(project.updatedAt) !== Date.parse(baseline.updatedAt)) {
- // 以远端当前版本作为新的校验基线,继续提交当前打开画布的完整快照。
- // 这是画布编辑态的显式覆盖策略,避免资产同步被旧缓存冲突永久阻塞。
- acknowledgedProjects.set(source.id, project);
- }
- verifiedProjects.add(source.id);
- }
for (const source of dirtyAssets) {
const baseline = acknowledgedAssets.get(source.id);
if (!baseline || verifiedAssets.has(source.id)) continue;
@@ -546,24 +665,27 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: {
acknowledgedAssets.set(source.id, source);
verifiedAssets.add(source.id);
}
+ const errors: unknown[] = [];
for (const source of dirtyProjects) {
const keysToUpload = collectLocalMediaKeys(source);
const total = keysToUpload.length;
- if (total > 0) {
- useSyncProgressStore.getState().setProjectProgress(source.id, {
+ useSyncProgressStore.getState().setProjectProgress(source.id, {
projectId: source.id,
total,
completed: 0,
- phase: "uploading",
- message: "正在同步媒体至云端",
+ phase: total > 0 ? "uploading" : "saving",
+ message: total > 0 ? "正在同步媒体至云端" : "正在保存画布",
});
- }
const onMediaUploaded = () => {
if (total > 0) {
useSyncProgressStore.getState().incrementProjectCompleted(source.id);
}
};
try {
+ if (!Number.isSafeInteger(source.revision) || source.revision! < 0) {
+ throw new ApiError("缺少画布版本,请保留草稿并加载云端最新版本", { status: 428 });
+ }
+ const hash = await canvasContentHash(source);
const remotePayload = await ensureRemoteResourceReferences(source, uploaded, onMediaUploaded);
if (total > 0) {
useSyncProgressStore.getState().setProjectProgress(source.id, {
@@ -571,20 +693,45 @@ async function saveRemoteUserDataBatch(uploaded: Map, options: {
message: "正在保存画布结构",
});
}
- await upsertRemoteCanvasProject(sanitizeCanvasProjectForRemoteSync(remotePayload));
- acknowledgedProjects.set(source.id, source);
- verifiedProjects.add(source.id);
- if (total > 0) useSyncProgressStore.getState().setProjectProgress(source.id, null);
- } catch (error) {
- if (total > 0) {
- useSyncProgressStore.getState().setProjectProgress(source.id, {
- phase: "error",
- message: error instanceof Error ? error.message : "云端同步失败,等待重试",
- });
+ const { project: saved } = await upsertRemoteCanvasProject(sanitizeCanvasProjectForRemoteSync(remotePayload));
+ if (!Number.isSafeInteger(saved.revision) || saved.revision !== source.revision! + 1) {
+ throw new ApiError("服务端未返回有效画布版本,请加载云端最新版本", { status: 409 });
}
- throw error;
+ const current = useCanvasStore.getState().openProject(source.id);
+ if (current && current.revision !== source.revision) {
+ throw new ApiError("画布基线已变化,请加载云端最新版本", { status: 409 });
+ }
+ const acknowledged = { ...source, revision: saved.revision, remoteContentHash: hash };
+ acknowledgedProjects.set(source.id, acknowledged);
+ verifiedProjects.add(source.id);
+ if (current) {
+ useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => project === current
+ ? { ...project, revision: saved.revision, remoteContentHash: hash } : project) }));
+ }
+ await flushCanvasStorePersistence();
+ const pending = !sameCanvasContent(source, useCanvasStore.getState().openProject(source.id) || undefined);
+ useSyncProgressStore.getState().setProjectProgress(source.id, { phase: pending ? "pending" : "done", message: pending ? "有新修改等待保存" : "已保存到云端" });
+ if (pending) syncQueued = true;
+ } catch (error) {
+ const conflict = error instanceof ApiError && (error.status === 409 || error.status === 428);
+ useSyncProgressStore.getState().setProjectProgress(source.id, {
+ phase: conflict ? "conflict" : "error",
+ message: error instanceof Error ? error.message : "云端同步失败,等待重试",
+ });
+ if (conflict) {
+ try {
+ const current = useCanvasStore.getState().openProject(source.id) || source;
+ const draftCount = await preserveCanvasSyncDraft(current);
+ useSyncProgressStore.getState().setProjectProgress(source.id, { draftCount });
+ } catch (draftError) {
+ useSyncProgressStore.getState().setProjectProgress(source.id, { message: "本地草稿保存失败,请勿关闭页面;请先下载草稿" });
+ errors.push(draftError);
+ }
+ }
+ errors.push(error);
}
}
+ if (errors.length) throw errors[0];
if (dirtyProjects.length) void appQueryClient.invalidateQueries({ queryKey: ["canvas-library"] });
}
diff --git a/web/src/stores/canvas/use-canvas-store.ts b/web/src/stores/canvas/use-canvas-store.ts
index dcb7afd43..dddd4c422 100644
--- a/web/src/stores/canvas/use-canvas-store.ts
+++ b/web/src/stores/canvas/use-canvas-store.ts
@@ -2,6 +2,7 @@ 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 { 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";
@@ -14,6 +15,8 @@ import type { TimelineProject } from "@/types/timeline";
export type CanvasProject = {
id: string;
+ revision?: number;
+ remoteContentHash?: string;
projectId?: string;
title: string;
createdAt: string;
@@ -448,6 +451,7 @@ export const useCanvasStore = create()(
const appearanceDefault = readCanvasAppearanceDefault();
const project: CanvasProject = {
id,
+ revision: 0,
projectId,
title,
createdAt: now,
@@ -469,6 +473,7 @@ export const useCanvasStore = create()(
const now = new Date().toISOString();
const project: CanvasProject = {
id: nanoid(),
+ revision: 0,
projectId: source.projectId,
title: source.title || "导入画布",
createdAt: source.createdAt || now,
@@ -490,20 +495,27 @@ export const useCanvasStore = create()(
openProject: (id) => {
return get().projects.find((item) => item.id === id) || null;
},
- renameProject: (id, title) =>
- set((state) => ({
- projects: state.projects.map((project) => (project.id === id ? { ...project, title: title.trim() || project.title, updatedAt: new Date().toISOString() } : project)),
- })),
+ 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) => ({
- projects: state.projects.map((project) => (project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project)),
- })),
+ updateProject: (id, patch) => set((state) => {
+ const current = state.projects.find((project) => project.id === id);
+ if (!current) return state;
+ const next = { ...current, ...patch };
+ 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,
diff --git a/web/src/stores/use-asset-store.ts b/web/src/stores/use-asset-store.ts
index 46236ab85..9bcf5a85f 100644
--- a/web/src/stores/use-asset-store.ts
+++ b/web/src/stores/use-asset-store.ts
@@ -13,6 +13,7 @@ import { cleanupUnusedImages, collectImageStorageKeys, resolveImageUrl, uploadIm
import { cleanupUnusedMedia, collectMediaStorageKeys, resolveMediaUrl } from "@/services/file-storage";
import { flushGenerationAssetStorageLocks, insertOrReturnGenerationAsset, withGenerationArtifactCommitLock, withGenerationAssetStorageLock } from "@/services/generation-asset-repository";
import { CANVAS_STORE_KEY, commitPendingCanvasStorePersistenceLocked, pendingCanvasStorePersistence, withCanvasStorePersistenceLock } from "@/stores/canvas/use-canvas-store";
+import { readAllCanvasSyncDrafts } from "@/services/canvas-sync-drafts";
export type AssetKind = "text" | "image" | "video" | "audio" | "model" | "entity";
export type { AssetCategory } from "@/lib/asset-category";
@@ -414,6 +415,7 @@ export const useAssetStore = create()(
await new Promise((resolve, reject) => {
window.setTimeout(() =>
withGenerationArtifactCommitLock(scope, async () => {
+ const syncDrafts = await readAllCanvasSyncDrafts(scope);
// 固定锁序:artifact -> Canvas(释放)-> Asset,避免跨 store 锁重入。
const canvasProjects = await withCanvasStorePersistenceLock(scope, async () => {
await commitPendingCanvasStorePersistenceLocked(scope);
@@ -423,7 +425,7 @@ export const useAssetStore = create()(
await withGenerationAssetStorageLock(scope, async () => {
await commitPendingAssetStorePersistenceLocked(scope);
const durableAssets = (await readPersistedAssetDocumentForScope(scope)).state.assets;
- const references = { projects: canvasProjects, assets: durableAssets };
+ const references = { projects: canvasProjects, assets: durableAssets, syncDrafts };
const imageKeys = new Set([...frozenExtraImageKeys, ...collectImageStorageKeys(references)]);
const mediaKeys = new Set([...frozenExtraMediaKeys, ...collectMediaStorageKeys(references)]);
await cleanupUnusedImages(
diff --git a/web/src/stores/use-sync-progress-store.ts b/web/src/stores/use-sync-progress-store.ts
index f4e366b7a..9c336cc57 100644
--- a/web/src/stores/use-sync-progress-store.ts
+++ b/web/src/stores/use-sync-progress-store.ts
@@ -4,8 +4,9 @@ export type SyncProjectProgress = {
projectId: string;
total: number;
completed: number;
- phase: "uploading" | "saving" | "done" | "error";
+ phase: "pending" | "uploading" | "saving" | "done" | "error" | "conflict";
message?: string;
+ draftCount?: number;
};
type SyncProgressStore = {
@@ -55,7 +56,7 @@ export const useSyncProgressStore = create((set, get) => ({
clearAll: () => set({ syncingProjects: {} }),
isAnySyncing: () => {
const list = Object.values(get().syncingProjects);
- return list.some((item) => item.phase === "uploading" || item.phase === "saving");
+ return list.some((item) => item.phase !== "done");
},
}));
diff --git a/web/test/agent-canvas-patch.test.ts b/web/test/agent-canvas-patch.test.ts
index 860875d3b..50121b076 100644
--- a/web/test/agent-canvas-patch.test.ts
+++ b/web/test/agent-canvas-patch.test.ts
@@ -67,3 +67,13 @@ test("task progress ahead of the server checkpoint still accepts terminal state"
expect(result.nodes[0].metadata?.taskStatus).toBe("failed");
expect(isCanvasNodeGenerating(result.nodes[0])).toBe(false);
});
+
+test("full Agent refresh removes unchanged nodes but preserves conflicting local edits", () => {
+ const remaining = { ...node, id: "remaining", metadata: {} };
+ const previous = { ...project, nodes: [node, remaining] };
+ const incoming = { ...previous, nodes: [remaining] };
+ expect(mergeAgentCanvasEditor(previous, incoming, previous.nodes, []).nodes).toEqual([remaining]);
+ const local = { ...node, title: "Unsaved local title" };
+ expect(() => mergeAgentCanvasEditor(previous, incoming, [local, remaining], [])).toThrow("冲突");
+ expect(local.title).toBe("Unsaved local title");
+});
diff --git a/web/test/agent-canvas-refresh.test.ts b/web/test/agent-canvas-refresh.test.ts
index 90c32653c..fa64acf4c 100644
--- a/web/test/agent-canvas-refresh.test.ts
+++ b/web/test/agent-canvas-refresh.test.ts
@@ -1,16 +1,42 @@
-import { afterEach, expect, spyOn, test } from "bun:test";
+import { afterEach, beforeEach, expect, spyOn, test } from "bun:test";
+import localforage from "localforage";
+import { canvasContentHash } from "@/lib/canvas/canvas-content";
+import { readCanvasSyncDrafts } from "@/services/canvas-sync-drafts";
+import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { http } from "@/services/api/request";
-import { applyAgentCanvasPatches, initializeRemoteUserDataSession, refreshCanvasAfterAgent, resetRemoteUserDataSync, subscribeAgentCanvasRefresh } from "@/services/user-data-sync";
-import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
+import { applyAgentCanvasPatches, initializeRemoteUserDataSession, refreshCanvasAfterAgent, resetRemoteUserDataSync, subscribeAgentCanvasRefresh, saveRemoteUserDataNow } from "@/services/user-data-sync";
+import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { CanvasNodeType } from "@/types/canvas";
-const initial: CanvasProject = { id: "agent-test", title: "测试", createdAt: "2026-01-01", updatedAt: "2026-01-01", nodes: [], connections: [], chatSessions: [], activeChatId: null, viewport: { x: 0, y: 0, k: 1 } };
-const remote: CanvasProject = { ...initial, nodes: [{ id: "text-1", type: CanvasNodeType.Text, title: "剧本", position: { x: 20, y: 20 }, width: 320, height: 240, metadata: { content: "正文" } }] };
+const initial: CanvasProject = { id: "agent-test", revision: 1, title: "测试", createdAt: "2026-01-01", updatedAt: "2026-01-01", nodes: [], connections: [], chatSessions: [], activeChatId: null, viewport: { x: 0, y: 0, k: 1 } };
+const remote: CanvasProject = { ...initial, revision: 2, nodes: [{ id: "text-1", type: CanvasNodeType.Text, title: "剧本", position: { x: 20, y: 20 }, width: 320, height: 240, metadata: { content: "正文" } }] };
let unsubscribe = () => {};
let restore = () => {};
-afterEach(() => { unsubscribe(); restore(); resetRemoteUserDataSync(); });
+const originalGet = localforage.getItem;
+const originalSet = localforage.setItem;
+const originalWindow = globalThis.window;
+const indexed = new Map();
+beforeEach(() => {
+ resetRemoteUserDataSync();
+ Object.defineProperty(globalThis, "window", { configurable: true, value: {
+ setTimeout: () => 1, clearTimeout: () => {},
+ localStorage: { getItem: () => "agent-user", setItem: () => {} },
+ } });
+ indexed.clear();
+ localforage.getItem = (async (key: string) => indexed.get(key) ?? null) as typeof localforage.getItem;
+ localforage.setItem = (async (key: string, value: unknown) => { indexed.set(key, value); return value; }) as typeof localforage.setItem;
+});
+afterEach(async () => {
+ unsubscribe(); restore(); resetRemoteUserDataSync();
+ await flushCanvasStorePersistence();
+ localforage.getItem = originalGet;
+ localforage.setItem = originalSet;
+ if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
+ else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
+});
async function setup(project: CanvasProject = remote) {
+ initial.remoteContentHash = await canvasContentHash(initial);
useCanvasStore.setState({ projects: [initial] });
await initializeRemoteUserDataSession("agent-user");
const mock = spyOn(http, "get").mockResolvedValue({ project });
@@ -37,7 +63,7 @@ test("unsaved editor conflicts preserve store and acknowledged baseline", async
expect(useCanvasStore.getState().projects[0]).toEqual(initial);
unsubscribe();
await refreshCanvasAfterAgent(initial.id);
- expect(useCanvasStore.getState().projects[0]).toEqual(remote);
+ expect(useCanvasStore.getState().projects[0]).toMatchObject(remote);
});
test("dirty persisted edits are not replaced or delivered to the editor", async () => {
@@ -80,7 +106,7 @@ test("media admission and completion refresh the editor with references and resu
await refreshCanvasAfterAgent(initial.id);
expect(editor.connections).toEqual(project.connections);
expect(editor.nodes.find((node) => node.id === "video-1")?.metadata?.referenceNodeIds).toEqual(["image-1"]);
- const complete: CanvasProject = { ...project, nodes: project.nodes.map((node) => node.id === "video-1" ? { ...node, metadata: { ...node.metadata, status: "success", storageKey: "resource:video-result" } } : node) };
+ const complete: CanvasProject = { ...project, revision: 3, nodes: project.nodes.map((node) => node.id === "video-1" ? { ...node, metadata: { ...node.metadata, status: "success", storageKey: "resource:video-result" } } : node) };
mock.mockResolvedValue({ project: complete });
await refreshCanvasAfterAgent(initial.id);
expect(editor.nodes.find((node) => node.id === "video-1")?.metadata?.storageKey).toBe("resource:video-result");
@@ -92,7 +118,7 @@ test("incremental batches update the editor once without full canvas requests",
const get = await setup();
let deliveries = 0;
unsubscribe = subscribeAgentCanvasRefresh(() => { deliveries++; });
- const patch = { canvasId: initial.id, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] };
+ const patch = { canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] };
await applyAgentCanvasPatches(initial.id, [patch, patch]);
expect(get).not.toHaveBeenCalled();
expect(deliveries).toBe(1);
@@ -101,18 +127,18 @@ test("incremental batches update the editor once without full canvas requests",
test("already projected deltas still advance the remote baseline", async () => {
await setup();
- useCanvasStore.setState({ projects: [remote] });
+ useCanvasStore.setState({ projects: [{ ...remote, revision: 1 }] });
let deliveries = 0;
unsubscribe = subscribeAgentCanvasRefresh(() => { deliveries++; });
- await applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] }]);
+ await applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] }]);
expect(deliveries).toBe(0);
- await expect(refreshCanvasAfterAgent(initial.id)).resolves.toEqual(remote);
+ await expect(refreshCanvasAfterAgent(initial.id)).resolves.toMatchObject(remote);
expect(deliveries).toBe(0);
});
test("editor rejection keeps delta projection atomic and retryable", async () => {
await setup();
- const patch = { canvasId: initial.id, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] };
+ const patch = { canvasId: initial.id, baseRevision: 1, revision: 2, updatedAt: remote.updatedAt, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] };
unsubscribe = subscribeAgentCanvasRefresh(() => { throw new Error("编辑冲突"); });
await expect(applyAgentCanvasPatches(initial.id, [patch])).rejects.toThrow("编辑冲突");
expect(useCanvasStore.getState().projects[0]).toEqual(initial);
@@ -127,10 +153,11 @@ test("50 distinct media completions project once and keep the local viewport", a
const nodes = Array.from({ length: 50 }, (_, index) => ({ id: `video-${index}`, title: `动画${index}`, type: CanvasNodeType.Video, position: { x: index * 400, y: 100 }, width: 320, height: 180, metadata: { status: "loading" as const, taskId: `task-${index}`, taskStatus: "running" } }));
const pending = { ...initial, nodes };
useCanvasStore.setState({ projects: [pending] });
+ pending.remoteContentHash = await canvasContentHash(pending);
await initializeRemoteUserDataSession("agent-user");
let deliveries = 0;
unsubscribe = subscribeAgentCanvasRefresh(() => { deliveries++; });
- await applyAgentCanvasPatches(initial.id, nodes.map((before, index) => ({ canvasId: initial.id, updatedAt: initial.updatedAt, nodes: [{ before, after: { ...before, metadata: { ...before.metadata, status: index % 2 ? "error" as const : "success" as const, taskStatus: index % 2 ? "failed" : "succeeded" } } }], connections: [] })));
+ await applyAgentCanvasPatches(initial.id, nodes.map((before, index) => ({ canvasId: initial.id, baseRevision: index + 1, revision: index + 2, updatedAt: initial.updatedAt, nodes: [{ before, after: { ...before, metadata: { ...before.metadata, status: index % 2 ? "error" as const : "success" as const, taskStatus: index % 2 ? "failed" : "succeeded" } } }], connections: [] })));
const result = useCanvasStore.getState().projects[0];
expect(result.nodes.filter((node) => node.metadata?.status === "success")).toHaveLength(25);
expect(result.nodes.filter((node) => node.metadata?.status === "error")).toHaveLength(25);
@@ -138,3 +165,35 @@ test("50 distinct media completions project once and keep the local viewport", a
expect(deliveries).toBe(1);
expect(get).not.toHaveBeenCalled();
});
+
+test("a missing Agent revision preserves the local draft and prevents automatic overwrite", async () => {
+ await setup();
+ await expect(applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, baseRevision: 2, revision: 3, nodes: [], connections: [] }])).rejects.toThrow("版本不连续");
+ expect(useCanvasStore.getState().projects[0].revision).toBe(1);
+ expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("conflict");
+ expect((await readCanvasSyncDrafts(initial.id))[0].project.revision).toBe(1);
+ await expect(saveRemoteUserDataNow(initial.id)).rejects.toThrow("云端画布已有更新");
+});
+
+test("Agent updates merge a local title edit and the next save uses the new revision", async () => {
+ await setup();
+ useCanvasStore.setState({ projects: [{ ...initial, title: "本地标题" }] });
+ const patch = { canvasId: initial.id, baseRevision: 1, revision: 2, nodes: remote.nodes.map((after) => ({ before: null, after })), connections: [] };
+ await applyAgentCanvasPatches(initial.id, [patch]);
+ const merged = useCanvasStore.getState().projects[0];
+ expect(merged.title).toBe("本地标题");
+ expect(merged.revision).toBe(2);
+ expect(merged.nodes).toEqual(remote.nodes);
+ const put = spyOn(http, "put").mockImplementation(async (_url, body) => {
+ const project = (body as { project: CanvasProject }).project;
+ expect(project.revision).toBe(2);
+ return { project: { ...project, revision: 3 } } as never;
+ });
+ try {
+ await saveRemoteUserDataNow(initial.id);
+ expect(put).toHaveBeenCalledTimes(1);
+ await applyAgentCanvasPatches(initial.id, [patch]);
+ expect(useCanvasStore.getState().projects[0].revision).toBe(3);
+ expect(useCanvasStore.getState().projects[0].title).toBe("本地标题");
+ } finally { put.mockRestore(); }
+});
diff --git a/web/test/agent-canvas-sync.test.ts b/web/test/agent-canvas-sync.test.ts
index 9f92d0b69..5e291c90d 100644
--- a/web/test/agent-canvas-sync.test.ts
+++ b/web/test/agent-canvas-sync.test.ts
@@ -71,3 +71,14 @@ test("fallback snapshots obey the configured minimum refresh interval", async ()
expect(times).toHaveLength(2);
expect(times[1] - times[0]).toBeGreaterThanOrEqual(100);
});
+
+test("a full invalidation and undo still refresh after delta support was detected", async () => {
+ const refresh = mock(async () => {});
+ const sync = createAgentCanvasSync({ canvasId: "canvas", applyPatches: async () => {}, refresh, onError: () => {}, batchMs: 0, refreshIntervalMs: 0 });
+ sync.receive(delta); await sleep();
+ sync.receive(event("canvas_updated", { canvasId: "canvas", requiresRefresh: true })); await sleep();
+ expect(refresh).toHaveBeenCalledTimes(1);
+ sync.receive(event("canvas_undone", { canvasId: "canvas" })); await sleep();
+ expect(refresh).toHaveBeenCalledTimes(2);
+ sync.dispose();
+});
diff --git a/web/test/canvas-remote-revision.test.ts b/web/test/canvas-remote-revision.test.ts
new file mode 100644
index 000000000..852339823
--- /dev/null
+++ b/web/test/canvas-remote-revision.test.ts
@@ -0,0 +1,335 @@
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import localforage from "localforage";
+import { apiClient } from "../src/services/api/request";
+import { canvasContentHash } from "../src/lib/canvas/canvas-content";
+import { rebaseCanvasProjects, parseCanvasStorageDocument } from "../src/lib/canvas/canvas-storage-revision";
+import { readCanvasSyncDrafts } from "../src/services/canvas-sync-drafts";
+import { initializeRemoteUserDataSession, installRemoteUserDataAutoSync, loadCanvasProjectForEditing, resetRemoteUserDataSync, saveRemoteUserDataNow, syncRemoteUserData } from "../src/services/user-data-sync";
+import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "../src/stores/canvas/use-canvas-store";
+import { flushAssetStorePersistence, useAssetStore } from "../src/stores/use-asset-store";
+import { useSyncProgressStore } from "../src/stores/use-sync-progress-store";
+import { CanvasNodeType } from "../src/types/canvas";
+
+const originalWindow = globalThis.window;
+const originalAdapter = apiClient.defaults.adapter;
+const originalGet = localforage.getItem;
+const originalSet = localforage.setItem;
+const indexed = new Map();
+let scope = "";
+let sequence = 0;
+let autoSave: (() => void) | undefined;
+let remote = new Map();
+let requests: Array<{ method: string; id: string; project?: CanvasProject }> = [];
+let beforePut: (() => Promise) | undefined;
+
+function canvas(id = "canvas"): CanvasProject {
+ return {
+ id,
+ revision: 1,
+ title: id,
+ createdAt: "2026-09-01T00:00:00Z",
+ updatedAt: "2026-09-01T00:00:00Z",
+ nodes: [{ id: "old-image", type: CanvasNodeType.Image, title: "image", position: { x: 0, y: 0 }, width: 100, height: 100 }],
+ connections: [],
+ chatSessions: [],
+ activeChatId: null,
+ backgroundMode: "dots",
+ showImageInfo: false,
+ viewport: { x: 0, y: 0, k: 1 },
+ directorScenes: [],
+ };
+}
+
+function addNode(project: CanvasProject, id: string) {
+ return { ...project, nodes: [...project.nodes, { ...project.nodes[0], id, type: CanvasNodeType.Video }] };
+}
+
+beforeEach(async () => {
+ resetRemoteUserDataSync();
+ scope = `canvas-revision-test-${++sequence}`;
+ indexed.clear();
+ requests = [];
+ autoSave = undefined;
+ beforePut = undefined;
+ remote = new Map([
+ ["canvas", canvas()],
+ ["other", canvas("other")],
+ ]);
+ Object.defineProperty(globalThis, "window", {
+ configurable: true,
+ value: {
+ setTimeout: (callback: () => void) => {
+ autoSave = callback;
+ return 1;
+ },
+ clearTimeout: () => {
+ autoSave = undefined;
+ },
+ localStorage: { getItem: () => scope, setItem: () => undefined },
+ },
+ });
+ localforage.getItem = (async (key: string) => indexed.get(key) ?? null) as typeof localforage.getItem;
+ localforage.setItem = (async (key: string, value: string) => {
+ indexed.set(key, value);
+ return value;
+ }) as typeof localforage.setItem;
+ apiClient.defaults.adapter = async (config) => {
+ const method = config.method || "get";
+ const id = String(config.url).split("/").at(-1)!;
+ const body = config.data ? JSON.parse(config.data) : undefined;
+ requests.push({ method, id, project: body?.project });
+ let data: unknown;
+ let status = 200;
+ if (id === "snapshot") data = { projects: structuredClone([...remote.values()]), assets: [] };
+ else if (id === "restore" && method === "post") {
+ const current = remote.get("canvas")!;
+ if (body.revision !== current.revision) status = 409;
+ else {
+ const saved = { ...canvas(), title: "historical content", revision: current.revision! + 1 };
+ remote.set("canvas", saved);
+ data = { project: saved };
+ }
+ } else if (method === "put") {
+ await beforePut?.();
+ const project = body.project as CanvasProject;
+ const current = remote.get(id);
+ if (project.revision !== (current?.revision ?? 0)) status = 409;
+ else {
+ const saved = { ...structuredClone(project), revision: project.revision! + 1 };
+ remote.set(id, saved);
+ data = { project: saved };
+ }
+ } else data = { project: structuredClone(remote.get(id)) };
+ return { config, status, statusText: "", headers: {}, data: { code: status === 200 ? 0 : status, data, msg: status === 409 ? "版本冲突" : "ok" } };
+ };
+ useCanvasStore.setState({ projects: [] });
+ useAssetStore.setState({ assets: [] });
+ installRemoteUserDataAutoSync();
+ await syncRemoteUserData(scope);
+});
+
+afterEach(async () => {
+ resetRemoteUserDataSync();
+ await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
+ apiClient.defaults.adapter = originalAdapter;
+ localforage.getItem = originalGet;
+ localforage.setItem = originalSet;
+ if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
+ else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
+});
+
+test("stale viewport, no-op restore and open do not submit old content", async () => {
+ const old = useCanvasStore.getState().openProject("canvas")!;
+ remote.set("canvas", { ...addNode(canvas(), "afternoon-video"), revision: 2 });
+ useCanvasStore.getState().updateProject("canvas", { viewport: { x: 100, y: 40, k: 2 } });
+ useCanvasStore.getState().updateProject("canvas", { nodes: structuredClone(old.nodes), connections: [] });
+ expect(autoSave).toBeUndefined();
+ expect(useCanvasStore.getState().openProject("canvas")!.updatedAt).toBe(old.updatedAt);
+ await flushCanvasStorePersistence();
+ await saveRemoteUserDataNow();
+ const latest = await loadCanvasProjectForEditing("canvas");
+ expect(latest.nodes.map((node) => node.id)).toEqual(["old-image", "afternoon-video"]);
+ expect(latest.revision).toBe(2);
+ expect(latest.viewport).toEqual({ x: 100, y: 40, k: 2 });
+ await saveRemoteUserDataNow();
+ expect(requests.filter((request) => request.method === "put")).toHaveLength(0);
+});
+
+test("a conflict preserves drafts, stops retries and does not block another canvas", async () => {
+ remote.set("canvas", { ...addNode(canvas(), "remote-video"), revision: 2 });
+ useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "local-video").nodes });
+ useCanvasStore.getState().renameProject("other", "other edit");
+ await expect(saveRemoteUserDataNow()).rejects.toThrow("版本冲突");
+ expect(remote.get("canvas")!.nodes.map((node) => node.id)).toEqual(["old-image", "remote-video"]);
+ expect(remote.get("other")!.title).toBe("other edit");
+ expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("conflict");
+ expect((await readCanvasSyncDrafts("canvas"))[0].project.nodes.at(-1)!.id).toBe("local-video");
+ useCanvasStore.getState().renameProject("canvas", "more local edits");
+ await expect(saveRemoteUserDataNow()).rejects.toThrow("云端画布已有更新");
+ useCanvasStore.getState().renameProject("other", "still saves independently");
+ await saveRemoteUserDataNow("other");
+ expect(remote.get("other")!.title).toBe("still saves independently");
+ await expect(saveRemoteUserDataNow(["canvas", "other"])).rejects.toThrow("云端画布已有更新");
+ expect(requests.filter((request) => request.method === "put" && request.id === "canvas")).toHaveLength(1);
+ await expect(saveRemoteUserDataNow("canvas")).rejects.toThrow("云端画布已有更新");
+ await loadCanvasProjectForEditing("canvas", { latest: true });
+ expect(useCanvasStore.getState().openProject("canvas")!.nodes.at(-1)!.id).toBe("remote-video");
+ expect((await readCanvasSyncDrafts("canvas")).some((draft) => draft.project.title === "more local edits")).toBe(true);
+ resetRemoteUserDataSync();
+ await useCanvasStore.persist.rehydrate();
+ await syncRemoteUserData(scope);
+ expect(useSyncProgressStore.getState().syncingProjects.canvas.draftCount).toBe(2);
+});
+
+test("edits during a save retain the right ancestor revision and are saved next", async () => {
+ let release!: () => void;
+ let started!: () => void;
+ const waiting = new Promise((resolve) => {
+ release = resolve;
+ });
+ const entered = new Promise((resolve) => {
+ started = resolve;
+ });
+ beforePut = async () => {
+ started();
+ await waiting;
+ };
+ useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "first-video").nodes });
+ const saving = saveRemoteUserDataNow();
+ await entered;
+ useCanvasStore.getState().updateProject("canvas", { nodes: addNode(useCanvasStore.getState().openProject("canvas")!, "second-video").nodes });
+ release();
+ await saving;
+ expect(requests.filter((request) => request.method === "put").map((request) => request.project!.revision)).toEqual([1, 2]);
+ expect(remote.get("canvas")!.revision).toBe(3);
+ expect(remote.get("canvas")!.nodes.map((node) => node.id)).toEqual(["old-image", "first-video", "second-video"]);
+ expect(useCanvasStore.getState().openProject("canvas")!.remoteContentHash).toBe(await canvasContentHash(useCanvasStore.getState().openProject("canvas")!));
+ expect(requests.filter((request) => request.method === "put").every((request) => !("viewport" in request.project!) && !("remoteContentHash" in request.project!))).toBe(true);
+});
+
+test("login archives unsynced changes before replacing cache and keeps user scopes separate", async () => {
+ useCanvasStore.getState().renameProject("canvas", "unsaved draft");
+ await flushCanvasStorePersistence();
+ resetRemoteUserDataSync();
+ await useCanvasStore.persist.rehydrate();
+ await syncRemoteUserData(scope);
+ expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("canvas");
+ expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("unsaved draft");
+ expect(await readCanvasSyncDrafts("canvas", "another-user")).toEqual([]);
+ expect(requests.some((request) => request.method === "put")).toBe(false);
+});
+
+test("failed draft storage prevents replacement by the latest server content", async () => {
+ const working = localforage.setItem;
+ useCanvasStore.getState().renameProject("canvas", "must survive");
+ localforage.setItem = (async (key: string, value: unknown) => {
+ if (key.includes("sync-drafts")) throw new Error("disk full");
+ return working(key, value);
+ }) as typeof localforage.setItem;
+ await expect(loadCanvasProjectForEditing("canvas", { latest: true })).rejects.toThrow("disk full");
+ expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("must survive");
+});
+
+test("local storage rebase cannot pair old edits with another tab's newer revision", () => {
+ const base = canvas();
+ const local = addNode(base, "unsaved-local");
+ const durable = { ...addNode(base, "remote-video"), revision: 2 };
+ const document = parseCanvasStorageDocument(null, [durable]);
+ const rebased = rebaseCanvasProjects({ document, baseProjects: [base], localProjects: [local], baseRevision: 0 }).document.state.projects[0];
+ expect(rebased.revision).toBe(1);
+ expect(rebased.nodes.at(-1)!.id).toBe("unsaved-local");
+});
+
+test("loading aligns live editor content before publishing its server revision", async () => {
+ remote.set("canvas", { ...addNode(canvas(), "new-cloud-node"), revision: 2 });
+ let applied = false;
+ await loadCanvasProjectForEditing("canvas", {
+ onLoad: (project) => {
+ expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(1);
+ expect(project.revision).toBe(2);
+ expect(project.nodes.at(-1)!.id).toBe("new-cloud-node");
+ applied = true;
+ },
+ });
+ expect(applied).toBe(true);
+ expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(2);
+});
+
+test("edits arriving during draft backup prevent replacement of the live canvas", async () => {
+ useCanvasStore.getState().renameProject("canvas", "first local edit");
+ const working = localforage.setItem;
+ let release!: () => void;
+ let started!: () => void;
+ const waiting = new Promise((resolve) => {
+ release = resolve;
+ });
+ const entered = new Promise((resolve) => {
+ started = resolve;
+ });
+ localforage.setItem = (async (key: string, value: unknown) => {
+ if (key.includes("sync-drafts:")) {
+ started();
+ await waiting;
+ }
+ return working(key, value);
+ }) as typeof localforage.setItem;
+ const loading = loadCanvasProjectForEditing("canvas", { latest: true });
+ await entered;
+ useCanvasStore.getState().renameProject("canvas", "second local edit");
+ release();
+ await expect(loading).rejects.toThrow("画布仍在更新");
+ expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("second local edit");
+});
+
+test("unknown revision cannot borrow the latest revision to submit stale content", async () => {
+ const local = useCanvasStore.getState().openProject("canvas")!;
+ useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === local.id ? { ...local, revision: undefined, title: "legacy draft" } : project)) }));
+ await expect(saveRemoteUserDataNow()).rejects.toThrow("缺少画布版本");
+ expect(requests.filter((request) => request.method === "put")).toHaveLength(0);
+ expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("conflict");
+ expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("legacy draft");
+});
+
+test("HTTP environments without Web Crypto still distinguish unsaved content", async () => {
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto")!;
+ Object.defineProperty(globalThis, "crypto", { configurable: true, value: undefined });
+ try {
+ const baseline = await canvasContentHash(canvas());
+ expect(baseline).toBe(await canvasContentHash({ ...canvas(), viewport: { x: 100, y: 0, k: 2 } }));
+ expect(baseline).not.toBe(await canvasContentHash(addNode(canvas(), "new-video")));
+ } finally {
+ Object.defineProperty(globalThis, "crypto", descriptor);
+ }
+});
+
+test("history restore preserves the draft and installs a new revision without an extra content PUT", async () => {
+ useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "unsaved-video").nodes });
+ let applied = false;
+ const restored = await loadCanvasProjectForEditing("canvas", {
+ historyRestore: { snapshotId: "historical", revision: 1 },
+ onLoad: (project) => {
+ expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(1);
+ expect(project.revision).toBe(2);
+ applied = true;
+ },
+ });
+ expect(applied).toBe(true);
+ expect(restored.title).toBe("historical content");
+ expect((await readCanvasSyncDrafts("canvas")).some((draft) => draft.project.nodes.some((node) => node.id === "unsaved-video"))).toBe(true);
+ await saveRemoteUserDataNow("canvas");
+ expect(requests.filter((request) => request.method === "post")).toHaveLength(1);
+ expect(requests.filter((request) => request.method === "put")).toHaveLength(0);
+});
+
+test("a stale history restore preserves both current cloud content and local edits", async () => {
+ remote.set("canvas", { ...addNode(canvas(), "new-cloud-video"), revision: 2 });
+ useCanvasStore.getState().renameProject("canvas", "local draft");
+ await expect(loadCanvasProjectForEditing("canvas", { historyRestore: { snapshotId: "historical", revision: 1 } })).rejects.toThrow("版本冲突");
+ expect(remote.get("canvas")!.nodes.at(-1)!.id).toBe("new-cloud-video");
+ expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("local draft");
+ expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("local draft");
+});
+
+test("reopened dirty cache with the same ancestor stays pending until actually saved", async () => {
+ useCanvasStore.getState().renameProject("canvas", "offline draft");
+ await flushCanvasStorePersistence();
+ await initializeRemoteUserDataSession(scope);
+ const loaded = await loadCanvasProjectForEditing("canvas");
+ expect(loaded.title).toBe("offline draft");
+ expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("pending");
+ expect(remote.get("canvas")!.title).toBe("canvas");
+ await saveRemoteUserDataNow("canvas");
+ expect(remote.get("canvas")!.title).toBe("offline draft");
+ expect(remote.get("canvas")!.revision).toBe(2);
+});
+
+test("failed draft backup prevents a history restore request", async () => {
+ const working = localforage.setItem;
+ localforage.setItem = (async (key: string, value: unknown) => {
+ if (key.includes("sync-drafts:")) throw new Error("disk full");
+ return working(key, value);
+ }) as typeof localforage.setItem;
+ await expect(loadCanvasProjectForEditing("canvas", { historyRestore: { snapshotId: "historical", revision: 1 } })).rejects.toThrow("disk full");
+ expect(requests.some((request) => request.method === "post")).toBe(false);
+ expect(remote.get("canvas")!.revision).toBe(1);
+});