Skip to content

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 里,每个节点长这样:

typescript
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 拿到这些数据:

tsx
function LLMNode({ data }: NodeProps) {
  return <div>{data.model}</div>;  // 渲染模型名
}

关键理解:data 是你的地盘。 React Flow 不关心里面有什么,它只做两件事:把 data 传给组件渲染,把 data 存在节点对象里。你往 data 里放什么、怎么用,完全由你决定。

定义数据类型

先搞清楚每个节点需要存什么。新建 app/types/nodeTypes.ts

typescript
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;
}

每个节点类型有自己的接口。这样做有两个好处:

  1. 写代码时有自动补全。 输入 data. 就能看到这个节点有哪些字段。
  2. 改数据时有类型检查。temperature 里塞字符串,TypeScript 会拦住你。

还有一个 getDefaultData 函数,创建新节点时用:

typescript
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 为例:

tsx
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:

tsx
<Handle type="source" position={Position.Bottom} id="true" />
<Handle type="source" position={Position.Bottom} id="false" />

注意 id 参数。一个节点可以有多个同方向的 Handle,用 id 区分。连线时,Edge 对象会记录连的是哪个 Handle:

typescript
const edge = {
  id: "e1",
  source: "condition-node",
  sourceHandle: "true",   // 连的是 true 输出
  target: "next-node",
};

Handle:连接点

说到 Handle,这里展开讲一下。

Handle 是节点上的连接点。每个 Handle 有两个基本属性:

  • type"source"(输出)或 "target"(输入)
  • position:在节点的哪个方向。TopBottomLeftRight

一般规则是:从上往下流的图,输入在上,输出在下。

tsx
<Handle type="target" position={Position.Top} />   {/* 输入 */}
<Handle type="source" position={Position.Bottom} />  {/* 输出 */}

Handle 还可以带 id,用于一个节点有多个同类 Handle 的情况(比如 Condition 的 true/false)。不指定 id 时默认是 "null"

右侧面板:编辑表单

节点只显示摘要。完整的编辑在右侧面板。

当用户点击节点时,Canvas 记录选中的节点 ID,把对应的节点对象传给 SidePanel

tsx
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null;

// ...
<SidePanel selectedNode={selectedNode} />

SidePanel 根据节点类型渲染不同的表单:

tsx
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 自动渲染表单字段。但那种方案有三个问题:

  1. 复杂的字段需要特殊 UI。 温度用滑块、prompt 用多行文本框、模型用下拉菜单——不同类型需要不同的控件。
  2. 字段之间有联动。 Tool 节点换工具时,参数列表要跟着变。自动生成器很难处理这种依赖。
  3. 调试困难。 表单行为不对的时候,你需要看的不是你的代码,而是一层抽象。

手写看起来重复,但每个字段的行为完全透明。改起来直接改就行,不用绕过一层抽象。

试试改改

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 里的配置才是肉。壳决定长什么样,肉决定做什么事。