diff --git a/backend/internal/app/cloud_agent_canvas_state.go b/backend/internal/app/cloud_agent_canvas_state.go index d1f4c5e19..f3f49560d 100644 --- a/backend/internal/app/cloud_agent_canvas_state.go +++ b/backend/internal/app/cloud_agent_canvas_state.go @@ -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 { diff --git a/web/src/pages/canvas/use-canvas-project-lifecycle.ts b/web/src/pages/canvas/use-canvas-project-lifecycle.ts index 5e8607ddd..1aea91979 100644 --- a/web/src/pages/canvas/use-canvas-project-lifecycle.ts +++ b/web/src/pages/canvas/use-canvas-project-lifecycle.ts @@ -96,17 +96,25 @@ export function useCanvasProjectLifecycle({ const observedContentRef = useRef(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((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) => { diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index 3cf94d1cf..943f09fac 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -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); diff --git a/web/test/canvas-remote-revision.test.ts b/web/test/canvas-remote-revision.test.ts index 852339823..f0486d840 100644 --- a/web/test/canvas-remote-revision.test.ts +++ b/web/test/canvas-remote-revision.test.ts @@ -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((resolve) => { + release = resolve; + }); + const entered = new Promise((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;