s12 - 自定义节点
s11 的画布上,节点只是些带颜色的方块。拖来拖去挺好看,但每个方块里面是空的——你没法告诉 LLM 节点该用哪个模型,也没法告诉 Tool 节点该读哪个文件。 这一章要做的事情,是让每个节点变成真正可配置的。
这一章要解决什么问题
s11 搭好了画布,你可以在上面拖拽节点、连线。但节点的 data 字段是写死的——Start 永远叫 "Start",LLM 永远用 "deepseek-chat",Tool 永远是 "read_file"。
一个不能配置的节点,跟一张图片没区别。你需要的是:点击节点,弹出配置面板,改参数,节点立刻更新。
这是工作流编辑器和普通画图工具的核心区别。Draw.io 里的方块只是视觉元素,你画一个圆写上 "API" 它也不会真的调 API。但 Agent 工作流里的节点不一样——LLM 节点存着模型名、prompt、温度,这些参数最终会变成真正的 API 调用。
这一章的目标:让每个节点有自己的数据结构,点击节点能在右侧面板编辑,改动实时反映到节点上。
核心概念:节点的 data 字段
React Flow 里,每个节点长这样:
const node = {
id: "1",
type: "llm", // 用哪个组件渲染
position: { x: 250, y: 150 }, // 在画布上的位置
data: { // 你的自定义数据
label: "LLM",
model: "deepseek-chat",
systemPrompt: "你是一个有帮助的助手。",
temperature: 0.7,
},
};type 决定用哪个 React 组件来画这个节点。data 是你塞进去的任何数据——React Flow 不管里面是什么,它只是帮你存着、帮你传给组件。
组件通过 data prop 拿到这些数据:
function LLMNode({ data }: NodeProps) {
return <div>{data.model}</div>; // 渲染模型名
}关键理解:data 是你的地盘。 React Flow 不关心里面有什么,它只做两件事:把 data 传给组件渲染,把 data 存在节点对象里。你往 data 里放什么、怎么用,完全由你决定。
定义数据类型
先搞清楚每个节点需要存什么。新建 app/types/nodeTypes.ts:
export type ModelId = "deepseek-chat" | "gpt-4o" | "claude-3-5-sonnet";
export interface LLMNodeData {
label: string;
model: ModelId;
systemPrompt: string;
temperature: number;
}
export interface ToolNodeData {
label: string;
toolName: "read_file" | "list_files" | "search_files";
args: Record<string, string>;
}
export interface ConditionNodeData {
label: string;
variable: string;
operator: "==" | "!=" | ">" | "<" | "contains" | "exists";
value: string;
}每个节点类型有自己的接口。这样做有两个好处:
- 写代码时有自动补全。 输入
data.就能看到这个节点有哪些字段。 - 改数据时有类型检查。 往
temperature里塞字符串,TypeScript 会拦住你。
还有一个 getDefaultData 函数,创建新节点时用:
export function getDefaultData(type: NodeDataType) {
switch (type) {
case "llm":
return {
label: "LLM",
model: "deepseek-chat",
systemPrompt: "你是一个有帮助的助手。",
temperature: 0.7,
};
case "tool":
return {
label: "Tool",
toolName: "read_file",
args: { path: "" },
};
// ...
}
}这样每个新节点都有合理的默认值,不用用户从零填起。
节点组件:显示摘要
每个节点组件做的事情变了——不再只是显示一个标题,而是把关键参数以摘要形式展示出来。
以 LLMNode 为例:
function LLMNode({ data }: NodeProps & { data: LLMNodeData }) {
const model = MODELS.find((m) => m.id === data.model);
return (
<div className="rounded-xl border-2 border-purple-700 bg-zinc-900 px-4 py-3 min-w-[200px]">
<div className="flex items-center gap-2 mb-2">
<span className="text-purple-400 text-lg">🧠</span>
<span className="text-purple-300 font-semibold text-sm">{data.label}</span>
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-center gap-1.5">
<span className="text-zinc-500">模型</span>
<span className="text-zinc-300 bg-zinc-800 px-1.5 py-0.5 rounded">
{model?.label ?? data.model}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-zinc-500">温度</span>
<span className="text-zinc-300">{data.temperature}</span>
</div>
{data.systemPrompt && (
<div className="text-zinc-500 truncate">
prompt: {data.systemPrompt.slice(0, 30)}...
</div>
)}
</div>
</div>
);
}注意几点:
- 摘要而非全貌。 节点空间有限,只显示最关键的信息。prompt 太长就截断,用
title属性存完整内容,鼠标悬停能看到。 - 信息密度。 用小字号、紧凑行距,在有限空间里塞进最多有用信息。
- 视觉区分。 每种节点类型有自己的颜色——Start 绿色、LLM 紫色、Tool 琥珀色、Condition 黄色、End 红色。
新增节点:Condition
s11 有四种节点。这一章新增一种:Condition 节点,用来做分支。
输入
↓
条件判断:变量 == 值?
├── True → 左边的节点
└── False → 右边的节点这是工作流的核心能力之一。没有条件分支,工作流只能是一条直线——每个任务都走同样的路径。有了条件,你可以根据上一步的结果决定下一步做什么。
Condition 节点有两个输出 Handle:
<Handle type="source" position={Position.Bottom} id="true" />
<Handle type="source" position={Position.Bottom} id="false" />注意 id 参数。一个节点可以有多个同方向的 Handle,用 id 区分。连线时,Edge 对象会记录连的是哪个 Handle:
const edge = {
id: "e1",
source: "condition-node",
sourceHandle: "true", // 连的是 true 输出
target: "next-node",
};Handle:连接点
说到 Handle,这里展开讲一下。
Handle 是节点上的连接点。每个 Handle 有两个基本属性:
- type:
"source"(输出)或"target"(输入) - position:在节点的哪个方向。
Top、Bottom、Left、Right
一般规则是:从上往下流的图,输入在上,输出在下。
<Handle type="target" position={Position.Top} /> {/* 输入 */}
<Handle type="source" position={Position.Bottom} /> {/* 输出 */}Handle 还可以带 id,用于一个节点有多个同类 Handle 的情况(比如 Condition 的 true/false)。不指定 id 时默认是 "null"。
右侧面板:编辑表单
节点只显示摘要。完整的编辑在右侧面板。
当用户点击节点时,Canvas 记录选中的节点 ID,把对应的节点对象传给 SidePanel:
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null;
// ...
<SidePanel selectedNode={selectedNode} />SidePanel 根据节点类型渲染不同的表单:
function SidePanel({ selectedNode }: SidePanelProps) {
if (!selectedNode) {
return <div className="text-zinc-600">点击节点查看配置</div>;
}
const type = selectedNode.type;
const data = selectedNode.data;
function update(patch: Record<string, unknown>) {
setNodes((nds) =>
nds.map((n) =>
n.id === selectedNode.id ? { ...n, data: { ...n.data, ...patch } } : n
)
);
}
return (
<div>
{/* 通用字段 */}
<input value={data.label} onChange={(e) => update({ label: e.target.value })} />
{/* LLM 专属 */}
{type === "llm" && (
<>
<select value={data.model} onChange={(e) => update({ model: e.target.value })}>
{MODELS.map((m) => <option key={m.id}>{m.label}</option>)}
</select>
<textarea value={data.systemPrompt} onChange={(e) => update({ systemPrompt: e.target.value })} />
<input type="range" min="0" max="2" step="0.1" value={data.temperature} />
</>
)}
{/* Tool 专属 */}
{type === "tool" && (
<>
<select value={data.toolName}>{/* 工具列表 */}</select>
{/* 参数编辑器 */}
</>
)}
</div>
);
}关键在 update 函数。它用 setNodes 更新节点列表,找到当前节点,把新数据合并进去。React Flow 检测到节点数据变了,自动重新渲染对应的节点组件。
这就是完整的数据流:
用户在面板改参数
→ update() 调用 setNodes
→ React Flow 更新节点数据
→ 节点组件重新渲染,显示新摘要没有自定义事件,没有状态管理库,没有 context。就是一个 useState 加一个 setNodes。
表单生成模式
你可能注意到了,SidePanel 里的表单是手写的——每种节点类型对应的字段都是硬编码的。这是刻意的。
你当然可以搞一套通用表单生成器,根据 JSON Schema 自动渲染表单字段。但那种方案有三个问题:
- 复杂的字段需要特殊 UI。 温度用滑块、prompt 用多行文本框、模型用下拉菜单——不同类型需要不同的控件。
- 字段之间有联动。 Tool 节点换工具时,参数列表要跟着变。自动生成器很难处理这种依赖。
- 调试困难。 表单行为不对的时候,你需要看的不是你的代码,而是一层抽象。
手写看起来重复,但每个字段的行为完全透明。改起来直接改就行,不用绕过一层抽象。
试试改改
1. 给 LLM 节点加一个 max_tokens 字段
在 LLMNodeData 里加 maxTokens: number,SidePanel 里加一个数字输入框,节点摘要里显示出来。
2. 给 Tool 节点加动态参数
现在 Tool 的参数是固定的。试试让用户在面板里点"添加参数",动态往 args 里加新字段。
3. 给 Condition 节点加嵌套条件
支持 AND / OR 组合多个条件。想想数据结构怎么变——ConditionNodeData 里加一个 children?: ConditionNodeData[]?
4. 连线时做类型检查
不是所有节点都能连到所有节点。比如 End 节点不应该有输出。在 onConnect 里加检查,拒绝不合法的连线。
教学边界
这一章只做一件事:让节点可配置。
不涉及:
- 工作流的执行逻辑(节点怎么跑起来)
- 变量在节点之间的传递
- 节点的验证(比如 LLM 节点没填 prompt 就不能跑)
- 节点的持久化(刷新页面就没了)
这些都在后面的章节里。现在你只需要记住一件事:
节点的
data字段是你的数据容器。你定义它长什么样、怎么编辑、怎么展示——React Flow 只负责帮你存着、帮你传给组件。
一句话记住
画布上的节点是壳,data 里的配置才是肉。壳决定长什么样,肉决定做什么事。