fix(canvas): 画布版本 - 合并 PR #528 防止旧内容覆盖并支持历史恢复

将画布 revision CAS 与历史快照接到 schema v23,保留当前顶栏积分弹窗和已有菜单入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-18 10:40:23 +08:00
69 changed files with 3571 additions and 293 deletions

View File

@@ -1,9 +1,11 @@
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import type { CanvasConnection, CanvasNodeData } from "@/types/canvas";
type Change<T> = { before: T | null; after: T };
type Change<T> = { before: T | null; after: T | null };
export type AgentCanvasPatch = {
canvasId: string;
baseRevision?: number;
revision?: number;
updatedAt: string;
nodes: Change<CanvasNodeData>[];
connections: Change<CanvasConnection>[];
@@ -44,8 +46,11 @@ function mergeItems<T extends { id: string }>(items: T[], changes: Change<T>[]):
if (!changes.length) return items;
const next = new Map(items.map((item) => [item.id, item]));
for (const { before, after } of changes) {
if (!after?.id || (before && before.id !== after.id)) throw new Error("无效的画布增量节点");
next.set(after.id, mergeValue(next.get(after.id) ?? null, before, after) as T);
const id = after?.id || before?.id;
if (!id || (before && after && before.id !== after.id)) throw new Error("无效的画布增量节点");
const merged = mergeValue(next.get(id) ?? null, before, after) as T | null;
if (merged === null) next.delete(id);
else next.set(id, merged);
}
const result = [...next.values()];
return result.length === items.length && result.every((item, index) => item === items[index]) ? items : result;
@@ -55,6 +60,7 @@ export function applyAgentCanvasPatch(project: CanvasProject, patch: AgentCanvas
if (patch.canvasId !== project.id || !Array.isArray(patch.nodes) || !Array.isArray(patch.connections)) throw new Error("画布增量不属于当前画布或格式无效");
const byId = new Map(project.nodes.map((node) => [node.id, node]));
const nodeChanges = patch.nodes.map((change) => {
if (!change.after) return change;
const current = byId.get(change.after.id);
const beforeTask = change.before?.metadata?.taskId;
const afterTask = change.after.metadata?.taskId;
@@ -81,8 +87,10 @@ export function mergeAgentCanvasEditor(previous: CanvasProject, incoming: Canvas
const changes = <T extends { id: string }>(before: T[], after: T[]): Change<T>[] => {
const byId = new Map(before.map((item) => [item.id, item]));
const afterIds = new Set(after.map((item) => item.id));
if (before.some((item) => !afterIds.has(item.id))) throw new Error("画布节点已删除,请先同步本地编辑");
return after.filter((item) => !equal(item, byId.get(item.id))).map((item) => ({ before: byId.get(item.id) ?? null, after: item }));
return [
...after.filter((item) => !equal(item, byId.get(item.id))).map((item) => ({ before: byId.get(item.id) ?? null, after: item })),
...before.filter((item) => !afterIds.has(item.id)).map((item) => ({ before: item, after: null })),
];
};
return applyAgentCanvasPatch({ ...previous, nodes, connections }, {
canvasId: previous.id,

View File

@@ -0,0 +1,26 @@
import { canonicalize } from "json-canonicalize";
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
// These fields describe local viewing / synchronization, not an edit to the document.
export function canvasContentSnapshot(project: CanvasProject) {
const { viewport: _viewport, updatedAt: _updatedAt, revision: _revision, remoteContentHash: _hash, ...content } = project;
return { ...content, projectId: content.projectId || undefined };
}
export function sameCanvasContent(left: CanvasProject | undefined, right: CanvasProject | undefined) {
if (left === right) return true;
if (!left || !right) return false;
const a = canvasContentSnapshot(left) as Record<string, unknown>;
const b = canvasContentSnapshot(right) as Record<string, unknown>;
return [...new Set([...Object.keys(a), ...Object.keys(b)])].every((key) => a[key] === b[key] || canonicalize(a[key]) === canonicalize(b[key]));
}
export async function canvasContentHash(project: CanvasProject) {
const serialized = canonicalize(canvasContentSnapshot(project));
// LAN HTTP deployments may lack Web Crypto. Keep an exact baseline there;
// a lossy checksum could incorrectly discard an unsaved draft during login.
if (!globalThis.crypto?.subtle) return `json:${serialized}`;
const bytes = new TextEncoder().encode(serialized);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
}

View File

@@ -8,7 +8,7 @@ import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { loadCanvasDrawing, loadCanvasDrawingPreview, loadCanvasDrawingRender } from "@/lib/canvas/canvas-drawing-storage";
import type { CanvasDrawingExport } from "@/types/canvas-export";
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "画布") {
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "画布", options: { includeLocalDrawings?: boolean } = {}) {
const zipFiles: { name: string; data: BlobPart }[] = [];
const exportedProjects = await Promise.all(
projects.map(async (project) => {
@@ -22,7 +22,7 @@ export async function exportCanvasProjects(projects: CanvasProject[], fileName =
zipFiles.push({ name: path, data: blob });
}),
);
const drawingDocuments = (await Promise.all(project.nodes.filter((node) => node.type === "drawing" && node.metadata?.drawingId).map(async (node): Promise<CanvasDrawingExport | null> => {
const drawingDocuments = (await Promise.all(project.nodes.filter((node) => options.includeLocalDrawings !== false && node.type === "drawing" && node.metadata?.drawingId).map(async (node): Promise<CanvasDrawingExport | null> => {
const drawingId = node.metadata?.drawingId;
if (!drawingId) return null;
const [saved, preview, render] = await Promise.all([

View File

@@ -1,6 +1,7 @@
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData } from "@/types/canvas";
import { normalizeAssetCategory } from "@/lib/asset-category";
import { sameCanvasContent } from "@/lib/canvas/canvas-content";
export type CanvasStorageTombstones = {
projects: Record<string, number>;
@@ -302,6 +303,12 @@ function mergeSessions(input: {
}
function mergeProject(base: CanvasProject | undefined, local: CanvasProject, durable: CanvasProject, document: CanvasStorageDocument, baseRevision: number, nextRevision: number, conflicts: CanvasStorageConflict[]) {
if (local.revision !== durable.revision) {
// Never attach another tab's newer server revision to this tab's stale edits.
// Preserve the whole edited branch; the server will reject its stale revision.
if (local.revision === base?.revision && sameCanvasContent(base, local)) return { ...durable, viewport: local.viewport };
return local;
}
const common = {
projectId: local.id,
tombstones: document.tombstones,