s11 - 可视化画布
前十章你一直在写代码——用 Python 定义工具、用 React 搭界面。但有一种能力,代码不太好表达:当你想让 Agent 按"先想、再搜、再想、再答"的顺序工作时,写一堆 if-else 是可以的,但改起来很痛苦。 这一章要做的事情:在浏览器里搭一个画布,用拖拽的方式定义 Agent 的工作流。
这一章要解决什么问题
Phase 2 的聊天产品能对话、能流式输出、能调工具。但它有一个根本限制:每次对话都是一个线性过程。你问一句,模型回一句,中间调几个工具,然后结束。
如果任务更复杂呢?比如:
- 用户提问
- 模型判断需要搜索
- 搜索工具返回结果
- 模型基于搜索结果再判断:信息不够,再搜一次
- 拿到足够的信息后,生成最终回答
这种"有条件的多步骤流程",用对话模式很难控制。你不知道模型什么时候会决定再搜一次,也不知道它会在哪一步停下来。
你需要的是工作流编排——预先定义好步骤和条件,让 Agent 按你设计的路线走。
这一章先做最基础的部分:一个可视化的画布,上面有可拖拽的节点、可连接的连线。这是工作流编辑器的地基。
为什么用 React Flow
画布+节点+连线,本质上是一个图(graph)的可视化。你可以自己用 Canvas API 画,但要处理拖拽、缩放、连线路径计算、节点对齐……每一样都是坑。
React Flow(现在叫 @xyflow/react)是 React 生态里做这件事的标准库。它处理了所有底层的交互逻辑,你只需要关心两件事:
- 节点长什么样(自定义组件)
- 节点之间怎么连(数据结构)
安装
npm install @xyflow/react这是这一章唯一新增的依赖。
React Flow 的核心概念
React Flow 的世界观很简单:一切都是节点和连线。
节点(Node)
├── id: 唯一标识
├── type: 用哪个组件渲染
├── position: 在画布上的位置 { x, y }
└── data: 传给组件的自定义数据
连线(Edge)
├── id: 唯一标识
├── source: 从哪个节点出发
└── target: 连到哪个节点你需要维护两个数组:nodes 和 edges。React Flow 提供 useNodesState 和 useEdgesState 两个 hook,帮你管理这两个数组的增删改查。
节点怎么画,由 nodeTypes 决定——一个从 type 名到 React 组件的映射。你写一个组件,注册到 nodeTypes 里,React Flow 就会用它来渲染对应类型的节点。
代码
自定义节点
四种节点,四种颜色,四种图标。每种节点都是一个 React 组件,接收 data 和其他 props。
StartNode——绿色,工作流入口,只有一个输出口:
// 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——蓝色,调用大模型,上下各一个接口(进→出):
// 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——橙色,调用外部工具:
// 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——红色,工作流出口,只有一个输入口:
// 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,它来画:
// 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 组件里把它们串起来:
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>
);
}侧面板
点击节点时,右侧弹出一个面板,显示节点的详细信息:
// 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>
);
}主页面
把画布和侧面板拼起来:
// 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>
);
}侧面板只在选中节点时出现,点画布空白区域或点关闭按钮就消失。
运行
cd code/s11
npm install
npm run dev打开 http://localhost:3000,你会看到:
- 五个节点排成一列:开始 → 理解意图 → 搜索工具 → 生成回答 → 结束
- 节点之间有连线,首尾两条是动画流动的
- 可以拖拽任意节点,连线会跟着走
- 从一个节点的底部圆点拖到另一个节点的顶部圆点,可以创建新连线
- 点击节点,右侧弹出详情面板
- 左下角有缩放控件,右下角有小地图
发生了什么
拆开看,核心就三层:
数据层(nodes + edges)
↓ useNodesState / useEdgesState 自动管理
渲染层(ReactFlow 组件)
↓ nodeTypes 映射到自定义组件
交互层(拖拽、连线、点击)
↓ 回调函数通知你的代码useNodesState 和 useEdgesState 是 React Flow 提供的 hook。它们做的事情跟 useState 一样——返回 [值, 更新函数]——但内置了节点移动、删除、连线增删等常用操作的处理逻辑。你不需要自己写 onNodesChange 的实现,React Flow 帮你处理了。
onConnect 是用户从一个 Handle 拉线到另一个 Handle 时触发的回调。addEdge 是 React Flow 提供的工具函数,把新的连线加到已有的 edges 数组里。
nodeTypes 是一个映射:告诉 React Flow,遇到 type: "llm" 的节点时,用 LLMNode 组件去渲染。你写的组件就是一个普通的 React 组件,React Flow 会把 data、selected、id 等信息通过 props 传给你。
试着改改
1. 加一个新的节点类型
比如一个 ConditionNode(条件判断节点),黄色菱形,根据条件决定走哪条路:
const nodeTypes = {
...existingTypes,
condition: ConditionNode,
};然后在 initialNodes 里加一个试试。菱形用 CSS transform: rotate(45) 就能做。
2. 给节点加"删除"功能
在 SidePanel 里加一个删除按钮。删除节点时,也要删掉所有连着它的边:
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 里检查 source 和 target 的类型,不合法就 return。
4. 自动布局
初始节点的位置是手写的坐标。试试安装 dagre 或 elkjs,实现自动布局——节点会自动排列成树形或层级结构,不用手动调位置。
教学边界
这一章只做一件事:搭一个可视化的节点画布。
不涉及:
- 工作流的执行引擎(节点连好了,但还不能"跑")
- 节点之间的数据传递(上一个节点的输出怎么变成下一个节点的输入)
- 条件分支和循环
- 工作流的保存和加载
- 与后端 Agent 的对接
这些都在后面的章节里。现在你只需要理解:工作流 = 节点 + 连线。节点是步骤,连线是顺序。
一句话记住
工作流编排的起点,是一个画布——上面有节点、有连线,你能用鼠标拖拽出 Agent 的执行路线。