fix(canvas): serialize pre-Agent saves with scheduled autosave

This commit is contained in:
134 2026-10-21 00:12:23 +08:00
parent c690c0e370
commit 0272a5a328
3 changed files with 87 additions and 35 deletions

View File

@ -1,5 +1,6 @@
"use client";
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
import {
AlertCircle,
AlertTriangle,
@ -5075,59 +5076,84 @@ export default function CanvasEditorLegacyPage({
workflowInputs.brand,
]);
const canvasSaveQueue = useRef<ReturnType<typeof createCanvasSaveQueue> | null>(null);
if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue();
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const canvasPersistenceScope = useRef(id);
canvasPersistenceScope.current = id;
const persistCanvasState = useCallback(
async (reason: "autosave" | "delete" | "beforeunload") => {
const canvasData = buildPersistedCanvasData();
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
const result = await canvasService.saveCanvasState(
id,
canvasData,
activeCanvasWorkspaceType,
{
baseCanvasRevision: canvasRevision,
baseCanvasUpdatedAt: canvasUpdatedAt,
}
);
if (!result.success) {
console.error(`[Canvas] ${reason} save failed:`, result.error);
if (result.status === 409) {
externalCanvasConflictRef.current = true;
console.warn("[Canvas] stale save revision handled without user prompt", {
reason,
canvasUpdatedAt: result.canvasUpdatedAt,
});
if (!externalCanvasConflictNotifiedRef.current) {
externalCanvasConflictNotifiedRef.current = true;
toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑");
(reason: "autosave" | "delete" | "beforeunload") => canvasSaveQueue.current!.enqueue(async () => {
if (canvasPersistenceScope.current !== id) return false;
setIsSaving(true);
try {
const canvasData = buildPersistedCanvasData();
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
const result = await canvasService.saveCanvasState(
id,
canvasData,
activeCanvasWorkspaceType,
{
baseCanvasRevision: canvasRevision,
baseCanvasUpdatedAt: canvasUpdatedAt,
}
);
if (canvasPersistenceScope.current !== id) return false;
if (!result.success) {
console.error(`[Canvas] ${reason} save failed:`, result.error);
if (result.status === 409) {
externalCanvasConflictRef.current = true;
console.warn("[Canvas] stale save revision handled without user prompt", {
reason,
canvasUpdatedAt: result.canvasUpdatedAt,
});
if (!externalCanvasConflictNotifiedRef.current) {
externalCanvasConflictNotifiedRef.current = true;
toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑");
}
}
} else if (result.data?.updatedAt) {
externalCanvasConflictRef.current = false;
externalCanvasConflictNotifiedRef.current = false;
useCanvasStore
.getState()
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
}
} else if (result.data?.updatedAt) {
externalCanvasConflictRef.current = false;
externalCanvasConflictNotifiedRef.current = false;
useCanvasStore
.getState()
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
return result.success;
} finally {
if (canvasPersistenceScope.current === id) setIsSaving(false);
}
return result.success;
},
}),
[activeCanvasWorkspaceType, buildPersistedCanvasData, id]
);
const persistCanvasBeforeAgent = useCallback(() => {
// Flush the pending local edit once instead of racing its scheduled autosave.
if (canvasAutosaveTimer.current) {
clearTimeout(canvasAutosaveTimer.current);
canvasAutosaveTimer.current = null;
}
return persistCanvasState("autosave");
}, [persistCanvasState]);
// 自动保存逻辑
useEffect(() => {
if (isLoading || isAgentMode || isAgentOperationRefreshActive()) return;
const saveTimer = setTimeout(async () => {
setIsSaving(true);
canvasAutosaveTimer.current = null;
if (isAgentOperationRefreshActive()) return;
try {
await persistCanvasState("autosave");
} catch (error) {
console.error("Auto save failed:", error);
}
setIsSaving(false);
}, 3000); // 3秒自动保存
return () => clearTimeout(saveTimer);
canvasAutosaveTimer.current = saveTimer;
return () => {
clearTimeout(saveTimer);
if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null;
};
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
useEffect(() => {
@ -11077,7 +11103,7 @@ export default function CanvasEditorLegacyPage({
onClose={() => setIsPanelOpen(false)}
onOpenFileList={() => setPanelMode("fileList")}
onRequireLogin={openLoginDialog}
onBeforeAgentRequest={() => persistCanvasState("autosave")}
onBeforeAgentRequest={persistCanvasBeforeAgent}
selectedAgentPresetId={
isCanvasAgentModeEnabled ? selectedAgentPresetId : null
}

View File

@ -0,0 +1,11 @@
/** Serialize local saves so each request reads the revision returned by the last. */
export function createCanvasSaveQueue() {
let pending: Promise<unknown> = Promise.resolve();
return {
enqueue<T>(save: () => Promise<T>): Promise<T> {
const next = pending.then(save, save);
pending = next.catch(() => undefined);
return next;
},
};
}

View File

@ -1,3 +1,4 @@
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
import { expect, test } from "@playwright/test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
@ -41,3 +42,17 @@ test("completed execution details remain expandable in saved assistant history w
expect(html).not.toContain("停止后续步骤");expect(html).not.toContain("<button");
expect(html).not.toMatch(/<details[^>]+open/);
});
test("pre-Agent flush and autosave read successive revisions instead of racing stale snapshots", async () => {
const queue = createCanvasSaveQueue();let release!: () => void;let revision = 1;let title = "before";
const gate = new Promise<void>(resolve => { release = resolve; });const sent: Array<{revision:number;title:string}> = [];
const first = queue.enqueue(async () => { sent.push({revision,title});await gate;revision=2;return true; });
const second = queue.enqueue(async () => { sent.push({revision,title});revision=3;return true; });
await Promise.resolve();expect(sent).toEqual([{revision:1,title:"before"}]);title="latest local edit";release();await Promise.all([first,second]);
expect(sent).toEqual([{revision:1,title:"before"},{revision:2,title:"latest local edit"}]);
});
test("a failed local save does not deadlock later saves or replay a failed snapshot", async () => {
const queue = createCanvasSaveQueue();let calls=0;
await expect(queue.enqueue(async()=>{calls++;throw new Error("revision conflict");})).rejects.toThrow("revision conflict");
await expect(queue.enqueue(async()=>{calls++;return true;})).resolves.toBe(true);expect(calls).toBe(2);
});