chore(release): 版本发布 - publish v1.5.1
This commit is contained in:
@@ -10,6 +10,7 @@ import { nanoid } from "nanoid";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { FluidOrb } from "@/components/ui/fluid-orb";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { markdownPlainText } from "@/lib/markdown-plain-text";
|
||||
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
|
||||
@@ -159,13 +159,20 @@ 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);
|
||||
const baseline = acknowledgedProjects.get(id);
|
||||
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))) {
|
||||
if (current && (cachedDirty || !sameCanvasContent(baseline, current)) && !sameCanvasContent(current, project)) {
|
||||
// Replayed events can request a snapshot while local edits await saving.
|
||||
// An unchanged, verified ancestor is not a concurrent cloud edit.
|
||||
if (!cachedDirty && current.revision === project.revision && baseline?.revision === project.revision && sameCanvasContent(baseline, project)) {
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { phase: "pending", message: "本地修改等待保存到云端" });
|
||||
scheduleRemoteUserDataSync();
|
||||
return 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 {
|
||||
@@ -176,7 +183,7 @@ export async function refreshCanvasAfterAgent(id: string) {
|
||||
if (current) await preserveAgentConflict(current);
|
||||
throw error;
|
||||
}
|
||||
acknowledgedProjects.set(id, project);
|
||||
acknowledgedProjects.set(id, projected);
|
||||
verifiedProjects.add(id);
|
||||
useCanvasStore.setState((state) => ({ projects: [...state.projects.filter((candidate) => candidate.id !== id), projected] }));
|
||||
await flushCanvasStorePersistence();
|
||||
@@ -194,27 +201,24 @@ export async function applyAgentCanvasPatches(id: string, patches: AgentCanvasPa
|
||||
if (!baseline || !current) throw new Error("缺少画布同步基线,需要重新读取画布");
|
||||
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;
|
||||
// A rejected delta requests a snapshot through createAgentCanvasSync.
|
||||
// Only that reconciliation can distinguish stale replay from a conflict.
|
||||
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);
|
||||
}
|
||||
acknowledgedProjects.set(id, remote);
|
||||
verifiedProjects.add(id);
|
||||
|
||||
@@ -166,15 +166,59 @@ test("50 distinct media completions project once and keep the local viewport", a
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a missing Agent revision preserves the local draft and prevents automatic overwrite", async () => {
|
||||
test("a delta gap reconciles against the cloud before declaring a conflict", 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).not.toBe("conflict");
|
||||
expect(await readCanvasSyncDrafts(initial.id)).toHaveLength(0);
|
||||
await refreshCanvasAfterAgent(initial.id);
|
||||
expect(useCanvasStore.getState().projects[0]).toMatchObject(remote);
|
||||
expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("done");
|
||||
});
|
||||
|
||||
test("reconciliation after a delta gap still protects genuinely conflicting edits", async () => {
|
||||
await setup();
|
||||
useCanvasStore.setState({ projects: [{ ...initial, title: "未保存标题" }] });
|
||||
await expect(applyAgentCanvasPatches(initial.id, [{ canvasId: initial.id, baseRevision: 2, revision: 3, nodes: [], connections: [] }])).rejects.toThrow("版本不连续");
|
||||
await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("本地存在未同步编辑");
|
||||
expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("conflict");
|
||||
expect((await readCanvasSyncDrafts(initial.id))[0].project.revision).toBe(1);
|
||||
expect((await readCanvasSyncDrafts(initial.id))[0].project.title).toBe("未保存标题");
|
||||
await expect(saveRemoteUserDataNow(initial.id)).rejects.toThrow("云端画布已有更新");
|
||||
});
|
||||
|
||||
test("an identical cloud snapshot clears a stale conflict without replaying editor nodes", async () => {
|
||||
await setup(initial);
|
||||
useSyncProgressStore.getState().setProjectProgress(initial.id, { phase: "conflict", draftCount: 9 });
|
||||
let deliveries = 0;
|
||||
unsubscribe = subscribeAgentCanvasRefresh(() => { deliveries++; });
|
||||
await refreshCanvasAfterAgent(initial.id);
|
||||
expect(deliveries).toBe(0);
|
||||
expect(useSyncProgressStore.getState().syncingProjects[initial.id]).toMatchObject({ phase: "done", draftCount: 9 });
|
||||
await expect(saveRemoteUserDataNow(initial.id)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("replayed events against an unchanged cloud ancestor preserve pending local edits", async () => {
|
||||
await setup(initial);
|
||||
await refreshCanvasAfterAgent(initial.id);
|
||||
const edited = { ...initial, title: "等待保存的标题" };
|
||||
useCanvasStore.setState({ projects: [edited] });
|
||||
await refreshCanvasAfterAgent(initial.id);
|
||||
expect(useCanvasStore.getState().projects[0]).toBe(edited);
|
||||
expect(useSyncProgressStore.getState().syncingProjects[initial.id].phase).toBe("pending");
|
||||
expect(await readCanvasSyncDrafts(initial.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("an unverified dirty cache cannot become a save baseline merely because revisions match", async () => {
|
||||
await setup(initial);
|
||||
const edited = { ...initial, title: "离线标题" };
|
||||
useCanvasStore.setState({ projects: [edited] });
|
||||
await initializeRemoteUserDataSession("agent-user");
|
||||
await expect(refreshCanvasAfterAgent(initial.id)).rejects.toThrow("本地存在未同步编辑");
|
||||
expect(useCanvasStore.getState().projects[0]).toBe(edited);
|
||||
expect((await readCanvasSyncDrafts(initial.id))[0].project.title).toBe(edited.title);
|
||||
});
|
||||
|
||||
test("Agent updates merge a local title edit and the next save uses the new revision", async () => {
|
||||
await setup();
|
||||
useCanvasStore.setState({ projects: [{ ...initial, title: "本地标题" }] });
|
||||
|
||||
@@ -4,7 +4,8 @@ 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 { applyAgentCanvasPatches, refreshCanvasAfterAgent, initializeRemoteUserDataSession, installRemoteUserDataAutoSync, loadCanvasProjectForEditing, resetRemoteUserDataSync, saveRemoteUserDataNow, syncRemoteUserData } from "../src/services/user-data-sync";
|
||||
import { createAgentCanvasSync } from "../src/services/agent-canvas-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";
|
||||
@@ -135,6 +136,42 @@ test("stale viewport, no-op restore and open do not submit old content", async (
|
||||
expect(requests.filter((request) => request.method === "put")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("load latest, save and reload stay synced when Agent history replays an unversioned delta", async () => {
|
||||
remote.set("canvas", { ...canvas(), revision: 9 });
|
||||
await loadCanvasProjectForEditing("canvas", { latest: true });
|
||||
useCanvasStore.getState().renameProject("canvas", "已保存的标题");
|
||||
await saveRemoteUserDataNow("canvas");
|
||||
expect(remote.get("canvas")!.revision).toBe(10);
|
||||
|
||||
for (let reload = 0; reload < 3; reload++) {
|
||||
resetRemoteUserDataSync();
|
||||
await useCanvasStore.persist.rehydrate();
|
||||
await initializeRemoteUserDataSession(scope);
|
||||
await loadCanvasProjectForEditing("canvas");
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const reconciled = new Promise<void>((done, fail) => { resolve = done; reject = fail; });
|
||||
const sync = createAgentCanvasSync({
|
||||
canvasId: "canvas", batchMs: 0,
|
||||
applyPatches: (patches) => applyAgentCanvasPatches("canvas", patches),
|
||||
refresh: async () => { await refreshCanvasAfterAgent("canvas"); resolve(); },
|
||||
onError: reject,
|
||||
});
|
||||
try {
|
||||
sync.receive({ eventId: "historical-event", runId: "completed-run", seq: 1, type: "canvas_updated", createdAt: "2026-09-17", payload: {
|
||||
canvasId: "canvas",
|
||||
canvasPatch: { canvasId: "canvas", updatedAt: "2026-09-17", nodes: [{ before: null, after: { ...canvas().nodes[0], id: "stale-node" } }], connections: [] },
|
||||
} });
|
||||
await reconciled;
|
||||
expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done");
|
||||
await saveRemoteUserDataNow("canvas");
|
||||
expect(useCanvasStore.getState().openProject("canvas")).toMatchObject({ revision: 10, title: "已保存的标题", nodes: canvas().nodes });
|
||||
expect(await readCanvasSyncDrafts("canvas")).toHaveLength(0);
|
||||
} finally { sync.dispose(); }
|
||||
}
|
||||
expect(requests.filter((request) => request.method === "put")).toHaveLength(1);
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user