fix(canvas): 画布同步 - 加载云端最新版时保留编辑器并防止并发内容覆盖
This commit is contained in:
@@ -364,6 +364,7 @@ func cloudAgentBatchTableState(table map[string]any, offset int, precise bool) m
|
||||
columns = defaultCloudAgentBatchReferenceColumns()
|
||||
}
|
||||
|
||||
globalPrompt := strings.TrimSpace(stringValue(table["globalPrompt"]))
|
||||
all := creationMaps(table["rows"])
|
||||
count, textLimit := 20, 240
|
||||
if precise {
|
||||
@@ -375,10 +376,14 @@ func cloudAgentBatchTableState(table map[string]any, offset int, precise bool) m
|
||||
for _, row := range all {
|
||||
rowEnabled, _ := row["enabled"].(bool)
|
||||
prompt := strings.TrimSpace(stringValue(row["prompt"]))
|
||||
effectivePrompt := globalPrompt
|
||||
if effectivePrompt == "" {
|
||||
effectivePrompt = prompt
|
||||
}
|
||||
inputs := cloudAgentBatchInputIDs(row["inputNodeIds"], len(columns))
|
||||
if rowEnabled {
|
||||
enabled++
|
||||
if prompt == "" {
|
||||
if effectivePrompt == "" {
|
||||
missingPrompt++
|
||||
}
|
||||
minimumInputs := 1
|
||||
@@ -388,7 +393,7 @@ func cloudAgentBatchTableState(table map[string]any, offset int, precise bool) m
|
||||
if len(inputs) < minimumInputs {
|
||||
missingReferences++
|
||||
}
|
||||
if prompt != "" && len(inputs) >= minimumInputs {
|
||||
if effectivePrompt != "" && len(inputs) >= minimumInputs {
|
||||
ready++
|
||||
}
|
||||
}
|
||||
@@ -419,7 +424,7 @@ func cloudAgentBatchTableState(table map[string]any, offset int, precise bool) m
|
||||
}
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return map[string]any{
|
||||
projected := map[string]any{
|
||||
"operation": operation, "concurrency": concurrency, "referenceColumns": columns,
|
||||
"rows": rows, "totalRows": len(all), "nextOffset": next, "hasMore": next > 0,
|
||||
"generationPreview": map[string]any{
|
||||
@@ -427,6 +432,13 @@ func cloudAgentBatchTableState(table map[string]any, offset int, precise bool) m
|
||||
"missingReferenceRows": missingReferences, "outputLinkedRows": outputLinked,
|
||||
},
|
||||
}
|
||||
if globalPrompt != "" {
|
||||
projected["globalPrompt"] = truncateRunes(globalPrompt, textLimit)
|
||||
if len([]rune(globalPrompt)) > textLimit {
|
||||
projected["globalPromptTruncated"] = true
|
||||
}
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
func cloudAgentBatchInputIDs(value any, limit int) []any {
|
||||
|
||||
@@ -96,17 +96,25 @@ export function useCanvasProjectLifecycle({
|
||||
const observedContentRef = useRef<CanvasHistorySnapshot | null>(null);
|
||||
const loadLatestRef = useRef(false);
|
||||
const historyRestoreRef = useRef<{ snapshotId: string; revision: number; resolve: () => void; reject: (error: unknown) => void } | null>(null);
|
||||
const pendingReloadRef = useRef<{ resolve: () => void; reject: (error: unknown) => void } | null>(null);
|
||||
const editorReadyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !sessionHydrated) return;
|
||||
let cancelled = false;
|
||||
setProjectLoaded(false);
|
||||
setLoadError("");
|
||||
// Keep load intent on the refs until this attempt finishes. React Strict
|
||||
// Mode remounts the effect; consuming the flags here would turn "load
|
||||
// latest" into a normal open and immediately recreate the conflict.
|
||||
const latest = loadLatestRef.current;
|
||||
loadLatestRef.current = false;
|
||||
const historyRestore = historyRestoreRef.current;
|
||||
historyRestoreRef.current = null;
|
||||
observedContentRef.current = null;
|
||||
const pendingReload = pendingReloadRef.current;
|
||||
const keepEditor = editorReadyRef.current && (latest || Boolean(historyRestore));
|
||||
if (!keepEditor) {
|
||||
editorReadyRef.current = false;
|
||||
setProjectLoaded(false);
|
||||
setLoadError("");
|
||||
observedContentRef.current = null;
|
||||
}
|
||||
const applyRestoredProject = (targetProject: CanvasProject) => {
|
||||
if (cancelled) return;
|
||||
const fallbackTheme = useCanvasThemeStore.getState().theme;
|
||||
@@ -142,6 +150,7 @@ export function useCanvasProjectLifecycle({
|
||||
setShowImageInfo(snapshot.showImageInfo);
|
||||
setViewport(targetProject.viewport);
|
||||
resetHistory(snapshot);
|
||||
editorReadyRef.current = true;
|
||||
setProjectLoaded(true);
|
||||
};
|
||||
|
||||
@@ -152,8 +161,11 @@ export function useCanvasProjectLifecycle({
|
||||
applyRestoredProject(cachedProject);
|
||||
}
|
||||
const loadedProject = await loadCanvasProjectForEditing(projectId, { latest, historyRestore: historyRestore || undefined, onLoad: applyRestoredProject });
|
||||
historyRestore?.resolve();
|
||||
if (cancelled) return;
|
||||
if (historyRestoreRef.current === historyRestore) {
|
||||
historyRestoreRef.current = null;
|
||||
historyRestore?.resolve();
|
||||
}
|
||||
if (!loadedProject) {
|
||||
if (!cachedProject) navigate("/canvas", { replace: true });
|
||||
return;
|
||||
@@ -173,14 +185,33 @@ export function useCanvasProjectLifecycle({
|
||||
if (!cancelled) message.warning("部分助手会话素材恢复失败,已使用项目记录继续打开");
|
||||
});
|
||||
};
|
||||
void load().catch((error) => {
|
||||
historyRestore?.reject(error);
|
||||
if (!cancelled && useSyncProgressStore.getState().syncingProjects[projectId]?.phase !== "conflict") useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "error", message: error instanceof Error ? error.message : "读取云端版本失败" });
|
||||
if (!cancelled) setLoadError(error instanceof Error ? error.message : "读取画布失败,请重试");
|
||||
});
|
||||
void load()
|
||||
.then(() => {
|
||||
if (cancelled) return;
|
||||
loadLatestRef.current = false;
|
||||
if (pendingReloadRef.current === pendingReload) {
|
||||
pendingReloadRef.current = null;
|
||||
pendingReload?.resolve();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
loadLatestRef.current = false;
|
||||
if (historyRestoreRef.current === historyRestore) {
|
||||
historyRestoreRef.current = null;
|
||||
historyRestore?.reject(error);
|
||||
}
|
||||
if (pendingReloadRef.current === pendingReload) {
|
||||
pendingReloadRef.current = null;
|
||||
pendingReload?.reject(error);
|
||||
}
|
||||
if (useSyncProgressStore.getState().syncingProjects[projectId]?.phase !== "conflict") useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "error", message: error instanceof Error ? error.message : "读取云端版本失败" });
|
||||
const detail = error instanceof Error ? error.message : "读取画布失败,请重试";
|
||||
if (keepEditor) message.error(detail);
|
||||
else setLoadError(detail);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
historyRestore?.reject(new Error("画布页面已关闭,请重新打开查看恢复结果"));
|
||||
};
|
||||
}, [hydrated, sessionHydrated, loadAttempt, message, navigate, openProject, projectId, resetHistory, setActiveChatId, setBackgroundMode, setCanvasAppearance, setChatSessions, setConnections, setNodes, setShowImageInfo, setViewport]);
|
||||
|
||||
@@ -301,8 +332,12 @@ export function useCanvasProjectLifecycle({
|
||||
|
||||
const reloadLatestCanvasProject = useCallback(async () => {
|
||||
await persistLocalEdits();
|
||||
loadLatestRef.current = true;
|
||||
setLoadAttempt((value) => value + 1);
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
pendingReloadRef.current?.reject(new Error("已有新的加载请求"));
|
||||
loadLatestRef.current = true;
|
||||
pendingReloadRef.current = { resolve, reject };
|
||||
setLoadAttempt((value) => value + 1);
|
||||
});
|
||||
}, [persistLocalEdits]);
|
||||
|
||||
const restoreCanvasProjectVersion = useCallback(async (snapshotId: string, revision: number) => {
|
||||
|
||||
@@ -47,21 +47,32 @@ export async function initializeRemoteUserDataSession(userId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function liveCanvasIfUnchanged(id: string, snapshot: CanvasProject | null | undefined) {
|
||||
const live = useCanvasStore.getState().openProject(id);
|
||||
if (live === snapshot) return live;
|
||||
if (!snapshot) return live ? undefined : null;
|
||||
return live && sameCanvasContent(live, snapshot) ? live : undefined;
|
||||
}
|
||||
|
||||
export async function loadCanvasProjectForEditing(id: string, options: { latest?: boolean; historyRestore?: { snapshotId: string; revision: number }; onLoad?: (project: CanvasProject) => void } = {}) {
|
||||
const epoch = sessionEpoch;
|
||||
const request = withRemoteUserDataSyncExclusive(async () => {
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
|
||||
requireRemoteUserDataBaseline();
|
||||
const scope = getActiveUserScope();
|
||||
const requireUnchangedLocal = (snapshot: CanvasProject | null | undefined, message: string) => {
|
||||
if (epoch !== sessionEpoch || getActiveUserScope() !== scope) throw new Error("账号已切换,请重新打开画布");
|
||||
const live = liveCanvasIfUnchanged(id, snapshot);
|
||||
if (live === undefined) throw new Error(message);
|
||||
return live;
|
||||
};
|
||||
if (options.historyRestore) {
|
||||
const local = useCanvasStore.getState().openProject(id);
|
||||
if (local) {
|
||||
const draftCount = await preserveCanvasSyncDraft(local, scope);
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { draftCount });
|
||||
}
|
||||
if (epoch !== sessionEpoch || getActiveUserScope() !== scope || useCanvasStore.getState().openProject(id) !== local) {
|
||||
throw new Error("本地内容仍在更新,请稍后再恢复历史版本");
|
||||
}
|
||||
requireUnchangedLocal(local, "本地内容仍在更新,请稍后再恢复历史版本");
|
||||
const { snapshotId, revision } = options.historyRestore;
|
||||
try {
|
||||
const saved = await restoreRemoteCanvasHistory(id, snapshotId, revision);
|
||||
@@ -95,7 +106,7 @@ export async function loadCanvasProjectForEditing(id: string, options: { latest?
|
||||
const dirty = local && (!verifiedProjects.has(id) && incrementalSession ? !cachedClean : !sameCanvasContent(acknowledgedProjects.get(id), local));
|
||||
if (dirty && !sameCanvasContent(local, remote)) {
|
||||
const draftCount = await preserveCanvasSyncDraft(local, scope);
|
||||
if (epoch !== sessionEpoch || getActiveUserScope() !== scope || useCanvasStore.getState().openProject(id) !== local) throw new Error("画布仍在更新,已保留本地内容,请稍后再加载最新版本");
|
||||
const liveAfterDraft = requireUnchangedLocal(local, "画布仍在更新,已保留本地内容,请稍后再加载最新版本");
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { draftCount });
|
||||
if (!options.latest && !options.historyRestore && local.revision !== undefined) {
|
||||
if (local.revision !== remote.revision) {
|
||||
@@ -108,16 +119,14 @@ export async function loadCanvasProjectForEditing(id: string, options: { latest?
|
||||
useSyncProgressStore.getState().setProjectProgress(id, { phase: "pending", message: "本地草稿等待保存到云端" });
|
||||
scheduleRemoteUserDataSync();
|
||||
}
|
||||
options.onLoad?.(local);
|
||||
return local;
|
||||
options.onLoad?.(liveAfterDraft || local);
|
||||
return liveAfterDraft || local;
|
||||
}
|
||||
}
|
||||
// Editing/generation may continue during the network request or draft write.
|
||||
// Never replace a newer local state which has not been backed up.
|
||||
if (epoch !== sessionEpoch || getActiveUserScope() !== scope || useCanvasStore.getState().openProject(id) !== local) {
|
||||
throw new Error("画布仍在更新,已保留本地内容,请稍后再加载最新版本");
|
||||
}
|
||||
const project = { ...remote, viewport: local?.viewport || remote.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: hash };
|
||||
// Viewport-only updates are not document edits and must not block adopting cloud content.
|
||||
const live = requireUnchangedLocal(local, "画布仍在更新,已保留本地内容,请稍后再加载最新版本");
|
||||
const project = { ...remote, viewport: live?.viewport || local?.viewport || remote.viewport || { x: 0, y: 0, k: 1 }, remoteContentHash: hash };
|
||||
// Align live editor refs synchronously before publishing the new revision.
|
||||
// A generation callback must never see old rendered nodes paired with a new revision.
|
||||
options.onLoad?.(project);
|
||||
|
||||
@@ -235,6 +235,35 @@ test("loading aligns live editor content before publishing its server revision",
|
||||
expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(2);
|
||||
});
|
||||
|
||||
test("viewport-only updates during latest load still adopt cloud content", async () => {
|
||||
remote.set("canvas", { ...addNode(canvas(), "remote-video"), revision: 2 });
|
||||
useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "local-video").nodes });
|
||||
const working = localforage.setItem;
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
localforage.setItem = (async (key: string, value: unknown) => {
|
||||
if (key.includes("sync-drafts:")) {
|
||||
started();
|
||||
await waiting;
|
||||
}
|
||||
return working(key, value);
|
||||
}) as typeof localforage.setItem;
|
||||
const loading = loadCanvasProjectForEditing("canvas", { latest: true });
|
||||
await entered;
|
||||
useCanvasStore.getState().updateProject("canvas", { viewport: { x: 40, y: 12, k: 1.5 } });
|
||||
release();
|
||||
const loaded = await loading;
|
||||
expect(loaded.nodes.at(-1)!.id).toBe("remote-video");
|
||||
expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(2);
|
||||
expect(useCanvasStore.getState().openProject("canvas")!.viewport).toEqual({ x: 40, y: 12, k: 1.5 });
|
||||
});
|
||||
|
||||
test("edits arriving during draft backup prevent replacement of the live canvas", async () => {
|
||||
useCanvasStore.getState().renameProject("canvas", "first local edit");
|
||||
const working = localforage.setItem;
|
||||
|
||||
Reference in New Issue
Block a user