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) <noreply@anthropic.com>
This commit is contained in:
HouYunFei
2026-07-17 14:14:10 +08:00
parent e6dcd7a832
commit cf0561d12a
2 changed files with 117 additions and 60 deletions
@@ -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<void>) | null>;
type AgentBridgeParams = {
projectId: string;
title: string | undefined;
nodes: CanvasNodeData[];
connections: CanvasConnection[];
selectedNodeIds: Set<string>;
viewport: ViewportTransform;
nodesRef: MutableRefObject<CanvasNodeData[]>;
connectionsRef: MutableRefObject<CanvasConnection[]>;
selectedNodeIdsRef: MutableRefObject<Set<string>>;
viewportRef: MutableRefObject<ViewportTransform>;
generateNodeRef: GenerateNodeRef;
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
setSelectedConnectionId: Dispatch<SetStateAction<string | null>>;
setViewport: Dispatch<SetStateAction<ViewportTransform>>;
setContextMenu: Dispatch<SetStateAction<ContextMenuState | null>>;
};
/**
* 画布与本地 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<CanvasAgentSnapshot | null>(null);
const projectTitle = title || "未命名画布";
const agentSnapshot = useMemo<CanvasAgentSnapshot>(() => ({ 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<CanvasAgentOp, { type: "run_generation" }> => 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 };
}
+20 -60
View File
@@ -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<HTMLDivElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const uploadTargetRef = useRef<{ nodeId?: string; position?: Position } | null>(null);
@@ -237,7 +236,6 @@ function InfiniteCanvasPage() {
const [superResolveNodeId, setSuperResolveNodeId] = useState<string | null>(null);
const [angleNodeId, setAngleNodeId] = useState<string | null>(null);
const [previewNodeId, setPreviewNodeId] = useState<string | null>(null);
const [agentUndoSnapshot, setAgentUndoSnapshot] = useState<CanvasAgentSnapshot | null>(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<CanvasAgentSnapshot>(
() => ({ 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<CanvasAgentOp, { type: "run_generation" }> => 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<CanvasPluginAi>(() => {