From cf0561d12af457e4cd1ae75187ab3284386bd14b Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Fri, 17 Jul 2026 14:14:10 +0800 Subject: [PATCH] refactor(canvas-project): extract agent bridge into useAgentBridge hook Move the canvas<->local-Agent bridge (agentSnapshot memo, applyAgentOps / undoAgentOps, agentUndoSnapshot state, and the publish-to-agent-store effect) out of InfiniteCanvasPage into a useAgentBridge hook. Only applyAgentOps is consumed outside (by the plugin host); the rest is fully encapsulated. The hook receives the shared canvas state/refs/setters explicitly and keeps the exact same dependency arrays, so behavior is unchanged. project.tsx drops the now-unused agent-ops imports and the setCanvasContext selector. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pages/canvas/hooks/use-agent-bridge.ts | 97 +++++++++++++++++++ web/src/pages/canvas/project.tsx | 80 ++++----------- 2 files changed, 117 insertions(+), 60 deletions(-) create mode 100644 web/src/pages/canvas/hooks/use-agent-bridge.ts diff --git a/web/src/pages/canvas/hooks/use-agent-bridge.ts b/web/src/pages/canvas/hooks/use-agent-bridge.ts new file mode 100644 index 0000000..f858bb4 --- /dev/null +++ b/web/src/pages/canvas/hooks/use-agent-bridge.ts @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useMemo, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; + +import { useAgentStore } from "@/stores/use-agent-store"; +import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops"; +import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; +import type { CanvasConnection, CanvasNodeData, ContextMenuState, ViewportTransform } from "@/types/canvas"; + +type GenerateNodeRef = MutableRefObject<((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => Promise) | null>; + +type AgentBridgeParams = { + projectId: string; + title: string | undefined; + nodes: CanvasNodeData[]; + connections: CanvasConnection[]; + selectedNodeIds: Set; + viewport: ViewportTransform; + nodesRef: MutableRefObject; + connectionsRef: MutableRefObject; + selectedNodeIdsRef: MutableRefObject>; + viewportRef: MutableRefObject; + generateNodeRef: GenerateNodeRef; + setNodes: Dispatch>; + setConnections: Dispatch>; + setSelectedNodeIds: Dispatch>>; + setSelectedConnectionId: Dispatch>; + setViewport: Dispatch>; + setContextMenu: Dispatch>; +}; + +/** + * 画布与本地 Agent 的桥接:把当前画布快照与 apply/undo 能力发布到 agent store, + * 供本地 Codex 面板读取。除 applyAgentOps(配置节点插件宿主会用到)外均为内部实现。 + */ +export function useAgentBridge(params: AgentBridgeParams) { + const { projectId, title, nodes, connections, selectedNodeIds, viewport, nodesRef, connectionsRef, selectedNodeIdsRef, viewportRef, generateNodeRef, setNodes, setConnections, setSelectedNodeIds, setSelectedConnectionId, setViewport, setContextMenu } = + params; + const setAgentCanvasContext = useAgentStore((state) => state.setCanvasContext); + const [agentUndoSnapshot, setAgentUndoSnapshot] = useState(null); + const projectTitle = title || "未命名画布"; + + const agentSnapshot = useMemo(() => ({ projectId, title: projectTitle, nodes, connections, selectedNodeIds: Array.from(selectedNodeIds), viewport }), [connections, projectTitle, nodes, projectId, selectedNodeIds, viewport]); + const applyAgentOps = useCallback( + (ops?: CanvasAgentOp[]) => { + const safeOps = Array.isArray(ops) ? ops.filter((op) => op?.type) : []; + const before = { projectId, title: projectTitle, nodes: nodesRef.current, connections: connectionsRef.current, selectedNodeIds: Array.from(selectedNodeIdsRef.current), viewport: viewportRef.current }; + const generationOps = safeOps.filter((op): op is Extract => op.type === "run_generation" && Boolean(op.nodeId)); + const next = applyCanvasAgentOps( + before, + safeOps.filter((op) => op.type !== "run_generation"), + ); + nodesRef.current = next.nodes; + connectionsRef.current = next.connections; + selectedNodeIdsRef.current = new Set(next.selectedNodeIds); + viewportRef.current = next.viewport; + setAgentUndoSnapshot(before); + setNodes(next.nodes); + setConnections(next.connections); + setSelectedNodeIds(new Set(next.selectedNodeIds)); + setSelectedConnectionId(null); + setViewport(next.viewport); + setContextMenu(null); + if (generationOps.length) { + queueMicrotask(() => + generationOps.forEach((op) => { + const target = nodesRef.current.find((node) => node.id === op.nodeId); + const prompt = op.prompt?.trim() ? op.prompt : (target?.metadata?.composerContent ?? target?.metadata?.prompt ?? ""); + void generateNodeRef.current?.(op.nodeId, op.mode || target?.metadata?.generationMode || "image", prompt); + }), + ); + } + return { ...next, projectId, title: projectTitle }; + }, + [projectTitle, projectId], + ); + const undoAgentOps = useCallback(() => { + if (!agentUndoSnapshot) return null; + nodesRef.current = agentUndoSnapshot.nodes; + connectionsRef.current = agentUndoSnapshot.connections; + selectedNodeIdsRef.current = new Set(agentUndoSnapshot.selectedNodeIds); + viewportRef.current = agentUndoSnapshot.viewport; + setNodes(agentUndoSnapshot.nodes); + setConnections(agentUndoSnapshot.connections); + setSelectedNodeIds(new Set(agentUndoSnapshot.selectedNodeIds)); + setSelectedConnectionId(null); + setViewport(agentUndoSnapshot.viewport); + setContextMenu(null); + setAgentUndoSnapshot(null); + return { ...agentUndoSnapshot, projectId, title: projectTitle }; + }, [agentUndoSnapshot, projectTitle, projectId]); + + useEffect(() => { + setAgentCanvasContext({ snapshot: agentSnapshot, applyOps: applyAgentOps, undoOps: undoAgentOps, canUndo: Boolean(agentUndoSnapshot) }); + return () => setAgentCanvasContext(null); + }, [agentSnapshot, applyAgentOps, agentUndoSnapshot, setAgentCanvasContext, undoAgentOps]); + + return { applyAgentOps }; +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index fc6849a..ab4def5 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -40,7 +40,7 @@ import { CanvasSidePanel } from "@/components/canvas/canvas-side-panel"; import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls"; import { useAgentStore } from "@/stores/use-agent-store"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; -import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops"; +import { useAgentBridge } from "@/pages/canvas/hooks/use-agent-bridge"; import { buildNodeMentionReferences, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { applyNodeConfigPatch, audioMetadata, buildAudioGenerationMetadata, buildImageGenerationMetadata, createCanvasNode, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-node-factory"; import { findContainingGroupId, findGroupDropTarget, getConnectionTargetAnchor, isHiddenBatchChild, isHiddenBatchConnectionEndpoint, normalizeConnection, snapNodesIntoGroup } from "@/lib/canvas/canvas-node-geometry"; @@ -158,7 +158,6 @@ function InfiniteCanvasPage() { const agentPanelOpen = useAgentStore((state) => state.panelOpen); const toggleAgentPanel = useAgentStore((state) => state.togglePanel); const openAgentPanel = useAgentStore((state) => state.openPanel); - const setAgentCanvasContext = useAgentStore((state) => state.setCanvasContext); const containerRef = useRef(null); const imageInputRef = useRef(null); const uploadTargetRef = useRef<{ nodeId?: string; position?: Position } | null>(null); @@ -237,7 +236,6 @@ function InfiniteCanvasPage() { const [superResolveNodeId, setSuperResolveNodeId] = useState(null); const [angleNodeId, setAngleNodeId] = useState(null); const [previewNodeId, setPreviewNodeId] = useState(null); - const [agentUndoSnapshot, setAgentUndoSnapshot] = useState(null); const [titleEditing, setTitleEditing] = useState(false); const [titleDraft, setTitleDraft] = useState(""); const [historyState, setHistoryState] = useState({ canUndo: false, canRedo: false }); @@ -650,63 +648,25 @@ function InfiniteCanvasPage() { nodes.forEach((node) => map.set(node.id, buildNodeMentionReferences(node, nodes, connections))); return map; }, [connections, nodes]); - const agentSnapshot = useMemo( - () => ({ projectId, title: currentProject?.title || "未命名画布", nodes, connections, selectedNodeIds: Array.from(selectedNodeIds), viewport }), - [connections, currentProject?.title, nodes, projectId, selectedNodeIds, viewport], - ); - const applyAgentOps = useCallback( - (ops?: CanvasAgentOp[]) => { - const safeOps = Array.isArray(ops) ? ops.filter((op) => op?.type) : []; - const before = { projectId, title: currentProject?.title || "未命名画布", nodes: nodesRef.current, connections: connectionsRef.current, selectedNodeIds: Array.from(selectedNodeIdsRef.current), viewport: viewportRef.current }; - const generationOps = safeOps.filter((op): op is Extract => op.type === "run_generation" && Boolean(op.nodeId)); - const next = applyCanvasAgentOps( - before, - safeOps.filter((op) => op.type !== "run_generation"), - ); - nodesRef.current = next.nodes; - connectionsRef.current = next.connections; - selectedNodeIdsRef.current = new Set(next.selectedNodeIds); - viewportRef.current = next.viewport; - setAgentUndoSnapshot(before); - setNodes(next.nodes); - setConnections(next.connections); - setSelectedNodeIds(new Set(next.selectedNodeIds)); - setSelectedConnectionId(null); - setViewport(next.viewport); - setContextMenu(null); - if (generationOps.length) { - queueMicrotask(() => - generationOps.forEach((op) => { - const target = nodesRef.current.find((node) => node.id === op.nodeId); - const prompt = op.prompt?.trim() ? op.prompt : (target?.metadata?.composerContent ?? target?.metadata?.prompt ?? ""); - void generateNodeRef.current?.(op.nodeId, op.mode || target?.metadata?.generationMode || "image", prompt); - }), - ); - } - return { ...next, projectId, title: currentProject?.title || "未命名画布" }; - }, - [currentProject?.title, projectId], - ); - const undoAgentOps = useCallback(() => { - if (!agentUndoSnapshot) return null; - nodesRef.current = agentUndoSnapshot.nodes; - connectionsRef.current = agentUndoSnapshot.connections; - selectedNodeIdsRef.current = new Set(agentUndoSnapshot.selectedNodeIds); - viewportRef.current = agentUndoSnapshot.viewport; - setNodes(agentUndoSnapshot.nodes); - setConnections(agentUndoSnapshot.connections); - setSelectedNodeIds(new Set(agentUndoSnapshot.selectedNodeIds)); - setSelectedConnectionId(null); - setViewport(agentUndoSnapshot.viewport); - setContextMenu(null); - setAgentUndoSnapshot(null); - return { ...agentUndoSnapshot, projectId, title: currentProject?.title || "未命名画布" }; - }, [agentUndoSnapshot, currentProject?.title, projectId]); - - useEffect(() => { - setAgentCanvasContext({ snapshot: agentSnapshot, applyOps: applyAgentOps, undoOps: undoAgentOps, canUndo: Boolean(agentUndoSnapshot) }); - return () => setAgentCanvasContext(null); - }, [agentSnapshot, applyAgentOps, agentUndoSnapshot, setAgentCanvasContext, undoAgentOps]); + const { applyAgentOps } = useAgentBridge({ + projectId, + title: currentProject?.title, + nodes, + connections, + selectedNodeIds, + viewport, + nodesRef, + connectionsRef, + selectedNodeIdsRef, + viewportRef, + generateNodeRef, + setNodes, + setConnections, + setSelectedNodeIds, + setSelectedConnectionId, + setViewport, + setContextMenu, + }); // 提供给插件节点的宿主能力(节点无关,方法接收 nodeId) const pluginAi = useMemo(() => {