diff --git a/web/src/lib/canvas/agent-canvas-patch.ts b/web/src/lib/canvas/agent-canvas-patch.ts index d9329f531..0a222ee60 100644 --- a/web/src/lib/canvas/agent-canvas-patch.ts +++ b/web/src/lib/canvas/agent-canvas-patch.ts @@ -24,6 +24,26 @@ function equal(a: unknown, b: unknown): boolean { return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, index) => equal(item, b[index])); } +function identifiedArray(value: unknown): value is Record[] { + return Array.isArray(value) && value.length > 0 && value.every((item) => record(item) && typeof item.id === "string" && item.id.length > 0); +} + +function mergeIdentifiedArray(current: unknown, before: unknown, after: unknown): unknown[] | undefined { + const values = [current, before, after]; + if (!values.every((value) => Array.isArray(value))) return undefined; + const arrays = values as unknown[][]; + if (!arrays.some((items) => items.length > 0) || !arrays.every((items) => items.length === 0 || identifiedArray(items))) return undefined; + + const maps = arrays.map((items) => new Map(items.map((item) => [(item as Record).id as string, item as Record]))); + const ids = [...new Set(arrays.flatMap((items) => items.map((item) => (item as Record).id as string)))]; + const merged: unknown[] = []; + for (const id of ids) { + const value = mergeThreeWayValue(maps[0].get(id), maps[1].get(id), maps[2].get(id)); + if (value !== undefined) merged.push(value); + } + return merged; +} + // Three-way merge: untouched local fields (e.g. a drag or prompt edit) survive. // A concurrently changed field or a deleted node is a conflict, never an overwrite. export function mergeThreeWayValue(current: unknown, before: unknown, after: unknown): unknown { @@ -33,12 +53,23 @@ export function mergeThreeWayValue(current: unknown, before: unknown, after: unk const next = { ...current }; for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) { if (["__proto__", "constructor", "prototype"].includes(key)) throw new Error("无效的画布增量字段"); - const value = mergeThreeWayValue(current[key], before[key], after[key]); + let value: unknown; + try { + value = mergeThreeWayValue(current[key], before[key], after[key]); + } catch (error) { + // Scene timestamps are persistence bookkeeping emitted for every + // Previs mutation, not user-editable content. If both sides + // touched only that timestamp, the server snapshot wins. + if (key !== "updatedAt") throw error; + value = after[key]; + } if (value === undefined) delete next[key]; else next[key] = value; } return next; } + const mergedArray = mergeIdentifiedArray(current, before, after); + if (mergedArray !== undefined) return mergedArray; throw new Error("Agent 画布增量与本地内容冲突,需要校准;已保留本地编辑"); } diff --git a/web/test/agent-canvas-patch.test.ts b/web/test/agent-canvas-patch.test.ts index 50121b076..e2fea2e7c 100644 --- a/web/test/agent-canvas-patch.test.ts +++ b/web/test/agent-canvas-patch.test.ts @@ -68,12 +68,60 @@ test("task progress ahead of the server checkpoint still accepts terminal state" 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"); +test("full Previs refresh merges scene bookkeeping and preserves the local viewport", () => { + const scene = { + id: "scene-1", + version: 1 as const, + title: "镜头", + background: "#111111", + environmentIntensity: 0.7, + environment: { mode: "color" as const }, + gridVisible: true, + objects: [], + cameras: [], + lights: [], + shots: [], + activeShotId: "shot-1", + createdAt: "2026-09-13T00:00:00Z", + updatedAt: "2026-09-13T00:00:00Z", + }; + const previous = { ...project, previsScenes: [scene] }; + const incoming = { + ...previous, + revision: 2, + updatedAt: "2026-09-13T12:00:00Z", + previsScenes: [{ ...scene, updatedAt: "2026-09-13T12:00:00Z", objects: [{ id: "actor-1", name: "Agent 演员" }] }], + }; + const local = { + ...previous, + viewport: { x: -480, y: -220, k: 0.72 }, + previsScenes: [{ ...scene, updatedAt: "2026-09-13T11:59:00Z" }], + }; + const merged = mergeAgentCanvasEditor(previous, incoming, local.nodes, local.connections, local); + expect(merged.previsScenes[0].objects).toEqual(incoming.previsScenes[0].objects); + expect(merged.previsScenes[0].updatedAt).toBe(incoming.previsScenes[0].updatedAt); + expect(merged.viewport).toEqual(local.viewport); +}); + +test("full Previs refresh still rejects a same-field scene edit", () => { + const scene = { + id: "scene-1", + version: 1 as const, + title: "镜头", + background: "#111111", + environmentIntensity: 0.7, + environment: { mode: "color" as const }, + gridVisible: true, + objects: [], + cameras: [], + lights: [], + shots: [], + activeShotId: "shot-1", + createdAt: "2026-09-13T00:00:00Z", + updatedAt: "2026-09-13T00:00:00Z", + }; + const previous = { ...project, previsScenes: [scene] }; + const incoming = { ...previous, revision: 2, previsScenes: [{ ...scene, title: "云端镜头", updatedAt: "2026-09-13T12:00:00Z" }] }; + const local = { ...previous, previsScenes: [{ ...scene, title: "本地镜头", updatedAt: "2026-09-13T11:59:00Z" }] }; + expect(() => mergeAgentCanvasEditor(previous, incoming, local.nodes, local.connections, local)).toThrow("冲突"); }); diff --git a/web/test/agent-canvas-refresh.test.ts b/web/test/agent-canvas-refresh.test.ts index bd9a6b68c..0ea15e55c 100644 --- a/web/test/agent-canvas-refresh.test.ts +++ b/web/test/agent-canvas-refresh.test.ts @@ -113,6 +113,42 @@ test("automatic focus and panning do not block Agent updates or reset the local expect(useCanvasStore.getState().projects[0].viewport).toEqual(viewport); }); +test("refresh-only Previs mutations do not conflict with an editor-only viewport change", async () => { + const scene = { + id: "scene-1", + version: 1 as const, + title: "镜头", + background: "#111111", + environmentIntensity: 0.7, + environment: { mode: "color" as const }, + gridVisible: true, + objects: [], + cameras: [], + lights: [], + shots: [], + activeShotId: "shot-1", + createdAt: "2026-09-13T00:00:00Z", + updatedAt: "2026-09-13T00:00:00Z", + }; + const local = { ...initial, previsScenes: [scene] }; + local.remoteContentHash = await canvasContentHash(local); + const remoteWithPrevis = { + ...local, + revision: 2, + previsScenes: [{ ...scene, updatedAt: "2026-09-13T12:00:00Z", objects: [{ id: "actor-1", name: "Agent 演员" }] }], + }; + useCanvasStore.setState({ projects: [local] }); + await initializeRemoteUserDataSession("agent-user"); + const mock = spyOn(http, "get").mockResolvedValue({ project: remoteWithPrevis }); + restore = () => mock.mockRestore(); + const viewport = { x: -640, y: -280, k: 0.68 }; + useCanvasStore.setState({ projects: [{ ...local, viewport, updatedAt: "2026-09-13T12:01:00Z" }] }); + await refreshCanvasAfterAgent(local.id); + expect(useCanvasStore.getState().projects[0].viewport).toEqual(viewport); + expect(useCanvasStore.getState().projects[0].previsScenes).toEqual(remoteWithPrevis.previsScenes); + expect(useSyncProgressStore.getState().syncingProjects[local.id].phase).toBe("done"); +}); + test("replayed completion events do not reapply unchanged canvas nodes", async () => { await setup(); let deliveries = 0;