Skip to content

s11 - 可视化画布

前十章你一直在写代码——用 Python 定义工具、用 React 搭界面。但有一种能力,代码不太好表达:当你想让 Agent 按"先想、再搜、再想、再答"的顺序工作时,写一堆 if-else 是可以的,但改起来很痛苦。 这一章要做的事情:在浏览器里搭一个画布,用拖拽的方式定义 Agent 的工作流。

这一章要解决什么问题

Phase 2 的聊天产品能对话、能流式输出、能调工具。但它有一个根本限制:每次对话都是一个线性过程。你问一句,模型回一句,中间调几个工具,然后结束。

如果任务更复杂呢?比如:

  1. 用户提问
  2. 模型判断需要搜索
  3. 搜索工具返回结果
  4. 模型基于搜索结果再判断:信息不够,再搜一次
  5. 拿到足够的信息后,生成最终回答

这种"有条件的多步骤流程",用对话模式很难控制。你不知道模型什么时候会决定再搜一次,也不知道它会在哪一步停下来。

你需要的是工作流编排——预先定义好步骤和条件,让 Agent 按你设计的路线走。

这一章先做最基础的部分:一个可视化的画布,上面有可拖拽的节点、可连接的连线。这是工作流编辑器的地基。

为什么用 React Flow

画布+节点+连线,本质上是一个图(graph)的可视化。你可以自己用 Canvas API 画,但要处理拖拽、缩放、连线路径计算、节点对齐……每一样都是坑。

React Flow(现在叫 @xyflow/react)是 React 生态里做这件事的标准库。它处理了所有底层的交互逻辑,你只需要关心两件事:

  1. 节点长什么样(自定义组件)
  2. 节点之间怎么连(数据结构)

安装

bash
npm install @xyflow/react

这是这一章唯一新增的依赖。

React Flow 的核心概念

React Flow 的世界观很简单:一切都是节点和连线。

节点(Node)
├── id: 唯一标识
├── type: 用哪个组件渲染
├── position: 在画布上的位置 { x, y }
└── data: 传给组件的自定义数据

连线(Edge)
├── id: 唯一标识
├── source: 从哪个节点出发
└── target: 连到哪个节点

你需要维护两个数组:nodesedges。React Flow 提供 useNodesStateuseEdgesState 两个 hook,帮你管理这两个数组的增删改查。

节点怎么画,由 nodeTypes 决定——一个从 type 名到 React 组件的映射。你写一个组件,注册到 nodeTypes 里,React Flow 就会用它来渲染对应类型的节点。

代码

自定义节点

四种节点,四种颜色,四种图标。每种节点都是一个 React 组件,接收 data 和其他 props。

StartNode——绿色,工作流入口,只有一个输出口:

tsx
// app/components/nodes/StartNode.tsx
export default function StartNode({ data }: NodeProps) {
  return (
    <div className="bg-emerald-600 text-white rounded-xl px-5 py-3 min-w-[140px]">
      <div className="flex items-center gap-2">
        <span>▶</span>
        <span className="font-semibold text-sm">{data.label || "开始"}</span>
      </div>
      <Handle type="source" position={Position.Bottom} />
    </div>
  );
}

LLMNode——蓝色,调用大模型,上下各一个接口(进→出):

tsx
// app/components/nodes/LLMNode.tsx
export default function LLMNode({ data }: NodeProps) {
  return (
    <div className="bg-blue-600 text-white rounded-xl px-5 py-3 min-w-[180px]">
      <Handle type="target" position={Position.Top} />
      <div className="flex items-center gap-2 mb-2">
        <span>🧠</span>
        <span className="font-semibold text-sm">{data.label || "LLM"}</span>
      </div>
      <select value={data.model} className="...">
        <option value="deepseek-chat">DeepSeek V3</option>
        <option value="gpt-4o">GPT-4o</option>
        <option value="claude-sonnet-4-20250514">Claude Sonnet 4</option>
      </select>
      <Handle type="source" position={Position.Bottom} />
    </div>
  );
}

ToolNode——橙色,调用外部工具:

tsx
// app/components/nodes/ToolNode.tsx
export default function ToolNode({ data }: NodeProps) {
  return (
    <div className="bg-orange-600 text-white rounded-xl px-5 py-3 min-w-[140px]">
      <Handle type="target" position={Position.Top} />
      <div className="flex items-center gap-2">
        <span>🔧</span>
        <span className="font-semibold text-sm">{data.label || "工具"}</span>
      </div>
      {data.toolName && (
        <div className="text-xs text-orange-200 mt-1">{data.toolName}</div>
      )}
      <Handle type="source" position={Position.Bottom} />
    </div>
  );
}

EndNode——红色,工作流出口,只有一个输入口:

tsx
// app/components/nodes/EndNode.tsx
export default function EndNode({ data }: NodeProps) {
  return (
    <div className="bg-red-600 text-white rounded-xl px-5 py-3 min-w-[140px]">
      <Handle type="target" position={Position.Top} />
      <div className="flex items-center gap-2">
        <span>⏹</span>
        <span className="font-semibold text-sm">{data.label || "结束"}</span>
      </div>
    </div>
  );
}

注意 Handle 组件——它就是节点上的那个小圆点。type="source" 是输出口(可以拉线出去),type="target" 是输入口(可以连线进来)。一个节点可以有多个 Handle。

Canvas 组件

把节点和连线交给 React Flow,它来画:

tsx
// app/components/Canvas.tsx
const nodeTypes = {
  start: StartNode,
  llm: LLMNode,
  tool: ToolNode,
  end: EndNode,
};

const initialNodes: Node[] = [
  { id: "start-1", type: "start", position: { x: 250, y: 50 },
    data: { label: "开始" } },
  { id: "llm-1", type: "llm", position: { x: 230, y: 200 },
    data: { label: "理解意图", model: "deepseek-chat" } },
  { id: "tool-1", type: "tool", position: { x: 100, y: 380 },
    data: { label: "搜索工具", toolName: "web_search" } },
  { id: "llm-2", type: "llm", position: { x: 230, y: 530 },
    data: { label: "生成回答", model: "deepseek-chat" } },
  { id: "end-1", type: "end", position: { x: 250, y: 700 },
    data: { label: "结束" } },
];

const initialEdges: Edge[] = [
  { id: "e1", source: "start-1", target: "llm-1", animated: true },
  { id: "e2", source: "llm-1", target: "tool-1" },
  { id: "e3", source: "tool-1", target: "llm-2" },
  { id: "e4", source: "llm-2", target: "end-1", animated: true },
];

然后在 ReactFlow 组件里把它们串起来:

tsx
export default function Canvas({ onSelectNode }: CanvasProps) {
  const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);

  const onConnect: OnConnect = useCallback(
    (connection) => {
      setEdges((eds) => addEdge({ ...connection, animated: true }, eds));
    },
    [setEdges]
  );

  return (
    <ReactFlow
      nodes={nodes}
      edges={edges}
      onNodesChange={onNodesChange}
      onEdgesChange={onEdgesChange}
      onConnect={onConnect}
      onNodeClick={(_, node) => onSelectNode(node)}
      onPaneClick={() => onSelectNode(null)}
      nodeTypes={nodeTypes}
      fitView
    >
      <Background />
      <Controls />
      <MiniMap />
    </ReactFlow>
  );
}

侧面板

点击节点时,右侧弹出一个面板,显示节点的详细信息:

tsx
// app/components/SidePanel.tsx
const NODE_META = {
  start: { icon: "▶", description: "工作流的入口。每个工作流只能有一个开始节点。" },
  llm:   { icon: "🧠", description: "调用大语言模型处理输入,生成输出。" },
  tool:  { icon: "🔧", description: "调用外部工具获取信息。" },
  end:   { icon: "⏹", description: "工作流的出口。执行到这里时,流程结束。" },
};

export default function SidePanel({ node, onClose }: SidePanelProps) {
  const meta = NODE_META[node.type];
  return (
    <div className="w-80 bg-zinc-900 border-l h-full">
      <div className="flex justify-between p-4">
        <span>{meta.icon} {node.data.label}</span>
        <button onClick={onClose}>✕</button>
      </div>
      <div className="p-4">
        <div>节点 ID: {node.id}</div>
        <div>类型: {node.type}</div>
        <div>说明: {meta.description}</div>
        {/* 遍历显示 data 里的所有字段 */}
      </div>
    </div>
  );
}

主页面

把画布和侧面板拼起来:

tsx
// app/page.tsx
export default function Home() {
  const [selectedNode, setSelectedNode] = useState<Node | null>(null);

  return (
    <div className="flex h-screen bg-zinc-950">
      <div className="flex-1">
        <Canvas onSelectNode={setSelectedNode} />
      </div>
      {selectedNode && (
        <SidePanel node={selectedNode} onClose={() => setSelectedNode(null)} />
      )}
    </div>
  );
}

侧面板只在选中节点时出现,点画布空白区域或点关闭按钮就消失。

运行

bash
cd code/s11
npm install
npm run dev

打开 http://localhost:3000,你会看到:

  1. 五个节点排成一列:开始 → 理解意图 → 搜索工具 → 生成回答 → 结束
  2. 节点之间有连线,首尾两条是动画流动的
  3. 可以拖拽任意节点,连线会跟着走
  4. 从一个节点的底部圆点拖到另一个节点的顶部圆点,可以创建新连线
  5. 点击节点,右侧弹出详情面板
  6. 左下角有缩放控件,右下角有小地图

发生了什么

拆开看,核心就三层:

数据层(nodes + edges)
  ↓ useNodesState / useEdgesState 自动管理
渲染层(ReactFlow 组件)
  ↓ nodeTypes 映射到自定义组件
交互层(拖拽、连线、点击)
  ↓ 回调函数通知你的代码

useNodesStateuseEdgesState 是 React Flow 提供的 hook。它们做的事情跟 useState 一样——返回 [值, 更新函数]——但内置了节点移动、删除、连线增删等常用操作的处理逻辑。你不需要自己写 onNodesChange 的实现,React Flow 帮你处理了。

onConnect 是用户从一个 Handle 拉线到另一个 Handle 时触发的回调。addEdge 是 React Flow 提供的工具函数,把新的连线加到已有的 edges 数组里。

nodeTypes 是一个映射:告诉 React Flow,遇到 type: "llm" 的节点时,用 LLMNode 组件去渲染。你写的组件就是一个普通的 React 组件,React Flow 会把 dataselectedid 等信息通过 props 传给你。

试着改改

1. 加一个新的节点类型

比如一个 ConditionNode(条件判断节点),黄色菱形,根据条件决定走哪条路:

tsx
const nodeTypes = {
  ...existingTypes,
  condition: ConditionNode,
};

然后在 initialNodes 里加一个试试。菱形用 CSS transform: rotate(45) 就能做。

2. 给节点加"删除"功能

在 SidePanel 里加一个删除按钮。删除节点时,也要删掉所有连着它的边:

tsx
function deleteNode(nodeId: string) {
  setNodes((nds) => nds.filter((n) => n.id !== nodeId));
  setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId));
}

3. 限制连线规则

现在任何两个 Handle 之间都能连线。你可以加规则:比如"开始节点只能连出去"、"结束节点只能连进来"。在 onConnect 里检查 sourcetarget 的类型,不合法就 return。

4. 自动布局

初始节点的位置是手写的坐标。试试安装 dagreelkjs,实现自动布局——节点会自动排列成树形或层级结构,不用手动调位置。

教学边界

这一章只做一件事:搭一个可视化的节点画布。

不涉及:

  • 工作流的执行引擎(节点连好了,但还不能"跑")
  • 节点之间的数据传递(上一个节点的输出怎么变成下一个节点的输入)
  • 条件分支和循环
  • 工作流的保存和加载
  • 与后端 Agent 的对接

这些都在后面的章节里。现在你只需要理解:工作流 = 节点 + 连线。节点是步骤,连线是顺序。

一句话记住

工作流编排的起点,是一个画布——上面有节点、有连线,你能用鼠标拖拽出 Agent 的执行路线。