fix(canvas): serialize pre-Agent saves with scheduled autosave
This commit is contained in:
parent
0d44ac12c8
commit
1965ee9cc0
@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@ -5075,59 +5076,84 @@ export default function CanvasEditorLegacyPage({
|
|||||||
workflowInputs.brand,
|
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(
|
const persistCanvasState = useCallback(
|
||||||
async (reason: "autosave" | "delete" | "beforeunload") => {
|
(reason: "autosave" | "delete" | "beforeunload") => canvasSaveQueue.current!.enqueue(async () => {
|
||||||
const canvasData = buildPersistedCanvasData();
|
if (canvasPersistenceScope.current !== id) return false;
|
||||||
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
setIsSaving(true);
|
||||||
const result = await canvasService.saveCanvasState(
|
try {
|
||||||
id,
|
const canvasData = buildPersistedCanvasData();
|
||||||
canvasData,
|
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
||||||
activeCanvasWorkspaceType,
|
const result = await canvasService.saveCanvasState(
|
||||||
{
|
id,
|
||||||
baseCanvasRevision: canvasRevision,
|
canvasData,
|
||||||
baseCanvasUpdatedAt: canvasUpdatedAt,
|
activeCanvasWorkspaceType,
|
||||||
}
|
{
|
||||||
);
|
baseCanvasRevision: canvasRevision,
|
||||||
if (!result.success) {
|
baseCanvasUpdatedAt: canvasUpdatedAt,
|
||||||
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("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑");
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
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) {
|
return result.success;
|
||||||
externalCanvasConflictRef.current = false;
|
} finally {
|
||||||
externalCanvasConflictNotifiedRef.current = false;
|
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
||||||
useCanvasStore
|
|
||||||
.getState()
|
|
||||||
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
|
||||||
}
|
}
|
||||||
return result.success;
|
}),
|
||||||
},
|
|
||||||
[activeCanvasWorkspaceType, buildPersistedCanvasData, id]
|
[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(() => {
|
useEffect(() => {
|
||||||
if (isLoading || isAgentMode || isAgentOperationRefreshActive()) return;
|
if (isLoading || isAgentMode || isAgentOperationRefreshActive()) return;
|
||||||
|
|
||||||
const saveTimer = setTimeout(async () => {
|
const saveTimer = setTimeout(async () => {
|
||||||
setIsSaving(true);
|
canvasAutosaveTimer.current = null;
|
||||||
|
if (isAgentOperationRefreshActive()) return;
|
||||||
try {
|
try {
|
||||||
await persistCanvasState("autosave");
|
await persistCanvasState("autosave");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Auto save failed:", error);
|
console.error("Auto save failed:", error);
|
||||||
}
|
}
|
||||||
setIsSaving(false);
|
|
||||||
}, 3000); // 3秒自动保存
|
}, 3000); // 3秒自动保存
|
||||||
|
|
||||||
return () => clearTimeout(saveTimer);
|
canvasAutosaveTimer.current = saveTimer;
|
||||||
|
return () => {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null;
|
||||||
|
};
|
||||||
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -11077,7 +11103,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
onClose={() => setIsPanelOpen(false)}
|
onClose={() => setIsPanelOpen(false)}
|
||||||
onOpenFileList={() => setPanelMode("fileList")}
|
onOpenFileList={() => setPanelMode("fileList")}
|
||||||
onRequireLogin={openLoginDialog}
|
onRequireLogin={openLoginDialog}
|
||||||
onBeforeAgentRequest={() => persistCanvasState("autosave")}
|
onBeforeAgentRequest={persistCanvasBeforeAgent}
|
||||||
selectedAgentPresetId={
|
selectedAgentPresetId={
|
||||||
isCanvasAgentModeEnabled ? selectedAgentPresetId : null
|
isCanvasAgentModeEnabled ? selectedAgentPresetId : null
|
||||||
}
|
}
|
||||||
|
|||||||
11
lib/client/canvas-save-queue.ts
Normal file
11
lib/client/canvas-save-queue.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
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.toContain("停止后续步骤");expect(html).not.toContain("<button");
|
||||||
expect(html).not.toMatch(/<details[^>]+open/);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user