首页
/ Dify 工作流前端 MultipleToolSelector:多工具列表组合的契约设计与键盘焦点恢复机制

Dify 工作流前端 MultipleToolSelector:多工具列表组合的契约设计与键盘焦点恢复机制

2026-09-06 11:50:44作者:尤峻淳Whitney

MultipleToolSelector 是 Dify 工作流(Workflow)编辑器中用于"在一个节点内选择和配置多个工具"的列表组合组件,位于 multiple-tool-selector/index.tsx。它负责工具身份(identity)、列表排序、增删更新、启用数量统计与可选的折叠状态,并在键盘用户删除工具后自动恢复焦点。读完本文,你能理解该组件的完整属性契约、按 provider_name + tool_name 去重的实现原理、"删除后焦点回到下一个 → 上一个 → 添加按钮"的焦点恢复机制,以及它与单工具选择器 ToolSelector、MCP 可用性策略之间清晰的责任边界划分。

组件定位:列表组合层而非数据源层

按照 模块 README 的定义,index.tsx 是这个模块对外暴露的"列表组合"(list composition)组件。它拥有(own)以下职责:

  • 工具身份:以 provider_nametool_name 组合作为工具的唯一标识;
  • 列表排序:列表顺序即 value 数组的顺序,组件不重排,只做顺序上的增删映射;
  • 增删更新:新增、批量新增、删除、配置修改,全部通过 onChange 向上汇报;
  • 启用数量统计:头部显示 启用数/总数(如 1/2);
  • 可选折叠状态:当 supportCollapsetrue 时,标题栏变为可点击的折叠开关。

同时,README 明确了两条"不属于本模块"的边界:

  1. MCP 工具是否可用 由 workflow 层的策略(useMCPToolAvailability)决定,列表只是消费其结果;
  2. 已安装工具的数据 由对应的查询(useAllMCPTools 等)拥有,列表只是把查询结果适配为顺序、计数与选择更新。

这种"只适配、不拥有"的设计在源码中体现得很直接:组件通过 useMCPToolAvailability 拿到 allowed 标志,通过 useAllMCPTools 拿到 MCP 工具数据,两者都只用于计算启用计数,不反向修改任何数据源。

属性契约(Props)

组件的完整属性签名定义在 index.tsx

属性 类型 必填 说明
value ToolValue[] 已选工具列表(受控),组件内部默认值 []
label string 列表标题;supportCollapse 时同时作为折叠按钮的 aria-label
onChange (value: ToolValue[]) => void 所有变更的唯一出口,父组件必须据此替换 value
nodeOutputVars NodeOutPutVar[] 上游节点输出变量,透传给 ToolSelector 用于参数引用
availableNodes Node[] 当前画布可用节点,透传给 ToolSelector
disabled boolean true 时不渲染"添加工具"按钮
required boolean 在标题旁渲染红色 *
tooltip React.ReactNode 标题旁的信息提示图标内容
supportCollapse boolean 允许折叠/展开工具列表
scope string 透传给 ToolSelector 的插件检索范围
nodeId string 当前节点 ID,透传给 ToolSelector

这里有一个值得注意的受控组件契约:README 强调"调用方必须在 onChange 之后替换 value,这样列表与待处理的焦点目标才能一起落定(settle)"。也就是说,焦点恢复依赖 value 的更新触发 useLayoutEffect,如果父组件不回填 value,删除后的焦点恢复永远不会执行。测试文件 focus-restoration.spec.tsx 中的 Harness 正是用 useState + onChange={setValue} 构造了这个闭环来验证该契约。

工具身份与去重契约

工具的"身份"由两字段组合确定,源码中有一处用于焦点索引的 key 生成:

const getToolKey = (tool: ToolValue) => `${tool.provider_name}:${tool.tool_name}`

但真正的去重发生在添加路径上。handleAddhandleAddMultiple 使用完全相同的 reduce 去重逻辑(index.tsx):

const handleAdd = (val: ToolValue) => {
  const newValue = [...value, val]
  // deduplication
  const deduplication = newValue.reduce((acc, cur) => {
    if (!acc.find(
      (item) => item.provider_name === cur.provider_name && item.tool_name === cur.tool_name,
    ))
      acc.push(cur)
    return acc
  }, [] as ToolValue[])
  // update value
  onChange(deduplication)
  setSelectorOpen(false)
}

由此可以得出 README 所说"与模块去重契约一致"的三条规则,且都有对应测试用例验证(见 index.spec.tsx 的 Deduplication Logic 分组):

  1. 同一 provider_name + tool_name 组合不会重复添加 —— 测试 should deduplicate when adding duplicate tool:已存在 new-provider/new-tool 时再次添加,onChange 收到的仍是原数组;
  2. 同名 tool_name 但不同 provider_name 允许共存 —— 测试 should allow same tool_name with different provider_name
  3. 批量添加时同样去重 —— 测试 should deduplicate multiple tools in batch add:批量加入的 batch-t1 与已有项重复时被过滤,batch-t2 正常进入列表。

去重是"保留先出现者"的语义:reduce 以已有 value 为基底,后到者若命中已有组合则被丢弃,因此列表中已存在的配置(settingsparameters 等)不会被新选择覆盖。

四类操作的实现

列表对每一项工具渲染一个编辑态 ToolSelectorisEditsupportEnableSwitch),组合层实现了对应的四个回调:

操作 回调 行为
添加单个 handleAdd 追加 + 去重 + 关闭选择面板
批量添加 handleAddMultiple 追加多个 + 去重(复用同一 reduce 逻辑)
删除 handleDelete(index) splice 删除,并计算焦点恢复目标
配置 handleConfigure(val, index) 原地替换第 index 项后回传

其中 handleConfigure 展示了典型的"按索引定点替换"模式:

const handleConfigure = (val: ToolValue, index: number) => {
  const newValue = [...value]
  newValue[index] = val
  onChange(newValue)
}

测试组 Configure Functionality 验证了它不会波及其他项:配置中间一项时,onChange 收到三个工具组成的完整数组,且只有中间一项的 enabled 被翻转。

键盘焦点恢复:pending target + useLayoutEffect

这是 README 第二段描述的核心机制,也是本模块最具工程细节的部分。问题场景:纯键盘用户聚焦在某项工具上,按 Enter 触发删除后,焦点元素随之卸载,浏览器会把焦点抛回 body,用户失去上下文。

组件的解法分三步(index.tsx):

第一步,删除时先算出目标顺序再回传新值:

const handleDelete = (index: number) => {
  const newValue = [...value]
  newValue.splice(index, 1)
  const nextFocusTool = value[index + 1] ?? value[index - 1]
  pendingFocusTargetRef.current = {
    toolKey: nextFocusTool ? getToolKey(nextFocusTool) : undefined,
  }
  onChange(newValue)
}

目标顺序即 README 所写:下一个工具 → 上一个工具 → 添加工具按钮value[index + 1] ?? value[index - 1] 表达的就是"下一个优先,没有则回退到上一个";删的是中间项时取后一项,删的是最后一项时 index + 1 越界取到前一项,删的是唯一一项时两者皆空、toolKeyundefined

第二步,用 key 而非索引建立焦点注册表。 渲染每一项时,通过 triggerRef 回调把原生按钮按 toolKey 存入 Map:

triggerRef={(element) => {
  if (element) toolItemTriggerByKeyRef.current.set(toolKey, element)
  else toolItemTriggerByKeyRef.current.delete(toolKey)
}}

第三步,等 value 更新后在布局阶段聚焦:

React.useLayoutEffect(() => {
  const pendingFocusTarget = pendingFocusTargetRef.current
  if (!pendingFocusTarget) return

  const focusTarget = pendingFocusTarget.toolKey
    ? toolItemTriggerByKeyRef.current.get(pendingFocusTarget.toolKey)
    : addToolButtonRef.current
  const resolvedFocusTarget = focusTarget ?? addToolButtonRef.current

  resolvedFocusTarget?.focus()
  pendingFocusTargetRef.current = null
}, [value])

几个设计要点值得注意:

  • 依赖 value 而不是在事件里同步聚焦,是因为删除后新 DOM 要等父组件回填 value 并完成重渲染才存在;
  • useLayoutEffect 而非 useEffect,保证在浏览器绘制前完成聚焦,避免可见的焦点跳动;
  • resolvedFocusTarget = focusTarget ?? addToolButtonRef.current 是双保险:即使 key 查不到(例如渲染时机异常),焦点也兜底回到添加按钮;
  • 每次消费后清空 pendingFocusTargetRef.current,避免后续无关的 value 变化(如配置项修改)误触发聚焦。

focus-restoration.spec.tsxit.each 参数化了这三种情形并逐一断言 toHaveFocus()

场景 删除位置 焦点应落在
删除中间项 index 1(共 3 项) 下一个工具 Tool 3
删除最后一项 index 1(共 2 项) 上一个工具 Tool 1
删除唯一一项 index 0(共 1 项) 添加按钮(plugin.detailPanel.toolSelector.title

README 还特别指出:ToolSelector 本身只通过 triggerRef 暴露最终的原生按钮(default trigger),不推断兄弟顺序——删除只是通过 onDelete 上报意图,"删完之后焦点去哪"是列表组合层的职责。这一分工在 ToolSelector 的 README 中得到了呼应:"Deletion only reports intent through onDelete. This module does not infer sibling order or choose a post-delete focus target"。

启用计数:MCP 工具的策略性过滤

头部 启用数/总数 的统计逻辑(index.tsx)不是简单数 enabled 字段,而是对 MCP 工具叠加了 workflow 层策略:

const enabledCount = value.filter((item) => {
  const isMCPTool = mcpTools?.find((tool) => tool.id === item.provider_name)
  if (isMCPTool) return item.enabled && isMCPToolAllowed
  return item.enabled
}).length

即:普通工具只要求 item.enabledMCP 工具provider_name 能匹配到 useAllMCPTools 返回数据中的 id)还必须满足 useMCPToolAvailability().allowedisMCPToolAllowed 最终由 workflow 的 MCP 策略上下文(MCPToolAvailabilityProviderversionSupported 等)决定。测试 State Management 分组验证了这一点:MCP 工具在 versionSupported: true 时计入(2/2),在 versionSupported: false 时不计入(1/2);Edge Cases 还覆盖了 mcpTools 数据为 undefined 时统计仍然可用的退化路径。

enabled 缺失时按 falsy 处理,测试 should handle tools with missing enabled property 断言此时计数为 0/1

折叠面板与头部布局

组件外层是一个 Collapsible(来自 @langgenius/dify-ui/collapsible),展开状态 toolsOpen 初始为 true

  • supportCollapsetrue 时,标题渲染为 CollapsibleTrigger(一个带 aria-label={label} 的按钮,箭头图标在折叠时 rotate-270),否则渲染纯文本标题;
  • 有工具时,标题右侧显示 enabledCount/value.lengthappDebug.agent.tools.enabled 文案(英文为 "Enabled",见 app-debug.json)以及竖向分隔线;
  • disabledfalse 时渲染"添加工具"的 IconButtoni-ri-add-line 图标,aria-labelplugin.detailPanel.toolSelector.title,英文文案 "Add tool",见 plugin.json);点击它会同时展开折叠面板(setToolsOpen(true))并初始化面板显示状态;
  • 空列表时,面板内渲染空态提示 plugin.detailPanel.toolSelector.empty(英文为 "Click the '+' button to add tools. You can add multiple tools.")。

测试覆盖了这些交互:折叠时按 Enter 键切换 aria-expanded 并隐藏列表;supportCollapse: false 时点击标题不产生任何折叠效果;折叠状态下点击添加按钮会自动展开面板。

与 ToolSelector 的组合关系:双 trigger 模式

列表对 ToolSelector 的使用恰好覆盖了两条不同的 trigger 路径(见 tool-selector/index.tsxTriggerProps 联合类型):

  • 添加按钮位置:传入自定义 trigger(即上面的 IconButton),并走 controlledState / onControlledStateChange 控制面板开合——这条路径中 trigger 元素及其 ref 的归属完全在调用方;
  • 列表项位置:不传 trigger,而是传 triggerRef,组件内部渲染默认的 ToolTrigger / ToolItemtriggerRef 总是解析到最终的原生按钮——焦点恢复正是建立在这条路径上。

README 对这一点有明确表述:"ToolSelector only exposes the final default trigger through triggerRef; it does not infer sibling order"。两种 trigger 模式在类型层面互斥(triggertriggerRef 不能同时提供),从编译期杜绝了 ref 归属歧义。

ToolSelector 侧则把 Popover 生命周期、工具表单、授权(ToolAuthorizationSection)、设置(ToolSettingsPanel)与删除动作的接线全部内聚在自己的实现中,组合层只通过 onSelect(配置)与 onDelete(删除意图)与之通信。

测试体系概览

index.spec.tsx 是一个相当完整的组件级测试(约 100 个用例断言),按分组覆盖了 README 宣称的每一项契约:

  • Rendering:标签、必填星号、空态、选中项数量、添加按钮的 disabled 行为、启用计数展示;
  • Collapse Functionality:折叠不可用时的非交互标题、键盘切换折叠、折叠中点击添加自动展开;
  • State Management:普通计数、MCP 工具计数(支持/不支持版本两态)、添加面板开合状态管理;
  • User Interactions / Deduplication Logic / Delete / Configure:四类回调的 onChange 载荷断言,包括中间项删除后数组内容正确(tool-0 + tool-2)、配置只影响目标索引等;
  • Edge Casesvalue 为空、mcpToolsundefinedenabled 缺失、nodeOutputVars/availableNodes 为空、nodeId 未提供等退化路径;
  • Accessibility:纯键盘打开添加面板、有选中项时渲染分隔线。

测试通过 vi.mockuseAllMCPToolsToolSelector 替换为受控替身,并用真实(非 mock)的 MCPToolAvailabilityProvider 验证策略联动,使断言聚焦于组合层逻辑本身。

小结

MultipleToolSelector 虽然只有约 240 行,但体现了一套清晰的前端职责分层:

  1. 身份与去重契约——provider_name + tool_name 组合键贯穿 key 生成、去重与焦点索引;
  2. 受控状态契约——组件不持有工具列表,所有变更经 onChange 回流,焦点恢复机制依赖父组件及时回填 value
  3. 职责边界——MCP 可用性归 workflow 策略,工具数据归查询层,单工具交互归 ToolSelector,列表层只做顺序、计数与组合;
  4. 无障碍优先——删除后的焦点恢复目标顺序(下一个 → 上一个 → 添加按钮)有专门参数化测试守护。

如需进一步阅读,可从 multiple-tool-selector/index.tsxfocus-restoration.spec.tsxToolSelector 模块 入手,对照本文的契约描述逐行验证。

登录后查看全文
热门项目推荐
相关项目推荐