Dify 工作流前端 MultipleToolSelector:多工具列表组合的契约设计与键盘焦点恢复机制
MultipleToolSelector 是 Dify 工作流(Workflow)编辑器中用于"在一个节点内选择和配置多个工具"的列表组合组件,位于 multiple-tool-selector/index.tsx。它负责工具身份(identity)、列表排序、增删更新、启用数量统计与可选的折叠状态,并在键盘用户删除工具后自动恢复焦点。读完本文,你能理解该组件的完整属性契约、按 provider_name + tool_name 去重的实现原理、"删除后焦点回到下一个 → 上一个 → 添加按钮"的焦点恢复机制,以及它与单工具选择器 ToolSelector、MCP 可用性策略之间清晰的责任边界划分。
组件定位:列表组合层而非数据源层
按照 模块 README 的定义,index.tsx 是这个模块对外暴露的"列表组合"(list composition)组件。它拥有(own)以下职责:
- 工具身份:以
provider_name与tool_name组合作为工具的唯一标识; - 列表排序:列表顺序即
value数组的顺序,组件不重排,只做顺序上的增删映射; - 增删更新:新增、批量新增、删除、配置修改,全部通过
onChange向上汇报; - 启用数量统计:头部显示
启用数/总数(如1/2); - 可选折叠状态:当
supportCollapse为true时,标题栏变为可点击的折叠开关。
同时,README 明确了两条"不属于本模块"的边界:
- MCP 工具是否可用 由 workflow 层的策略(
useMCPToolAvailability)决定,列表只是消费其结果; - 已安装工具的数据 由对应的查询(
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}`
但真正的去重发生在添加路径上。handleAdd 与 handleAddMultiple 使用完全相同的 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 分组):
- 同一
provider_name + tool_name组合不会重复添加 —— 测试should deduplicate when adding duplicate tool:已存在new-provider/new-tool时再次添加,onChange收到的仍是原数组; - 同名
tool_name但不同provider_name允许共存 —— 测试should allow same tool_name with different provider_name; - 批量添加时同样去重 —— 测试
should deduplicate multiple tools in batch add:批量加入的batch-t1与已有项重复时被过滤,batch-t2正常进入列表。
去重是"保留先出现者"的语义:reduce 以已有 value 为基底,后到者若命中已有组合则被丢弃,因此列表中已存在的配置(settings、parameters 等)不会被新选择覆盖。
四类操作的实现
列表对每一项工具渲染一个编辑态 ToolSelector(isEdit、supportEnableSwitch),组合层实现了对应的四个回调:
| 操作 | 回调 | 行为 |
|---|---|---|
| 添加单个 | 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 越界取到前一项,删的是唯一一项时两者皆空、toolKey 为 undefined。
第二步,用 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.tsx 用 it.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.enabled;MCP 工具(provider_name 能匹配到 useAllMCPTools 返回数据中的 id)还必须满足 useMCPToolAvailability().allowed。isMCPToolAllowed 最终由 workflow 的 MCP 策略上下文(MCPToolAvailabilityProvider 的 versionSupported 等)决定。测试 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:
supportCollapse为true时,标题渲染为CollapsibleTrigger(一个带aria-label={label}的按钮,箭头图标在折叠时rotate-270),否则渲染纯文本标题;- 有工具时,标题右侧显示
enabledCount/value.length与appDebug.agent.tools.enabled文案(英文为 "Enabled",见 app-debug.json)以及竖向分隔线; disabled为false时渲染"添加工具"的IconButton(i-ri-add-line图标,aria-label为plugin.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.tsx 的 TriggerProps 联合类型):
- 添加按钮位置:传入自定义
trigger(即上面的IconButton),并走controlledState/onControlledStateChange控制面板开合——这条路径中 trigger 元素及其 ref 的归属完全在调用方; - 列表项位置:不传
trigger,而是传triggerRef,组件内部渲染默认的ToolTrigger/ToolItem,triggerRef总是解析到最终的原生按钮——焦点恢复正是建立在这条路径上。
README 对这一点有明确表述:"ToolSelector only exposes the final default trigger through triggerRef; it does not infer sibling order"。两种 trigger 模式在类型层面互斥(trigger 与 triggerRef 不能同时提供),从编译期杜绝了 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 Cases:
value为空、mcpTools为undefined、enabled缺失、nodeOutputVars/availableNodes为空、nodeId未提供等退化路径; - Accessibility:纯键盘打开添加面板、有选中项时渲染分隔线。
测试通过 vi.mock 将 useAllMCPTools 与 ToolSelector 替换为受控替身,并用真实(非 mock)的 MCPToolAvailabilityProvider 验证策略联动,使断言聚焦于组合层逻辑本身。
小结
MultipleToolSelector 虽然只有约 240 行,但体现了一套清晰的前端职责分层:
- 身份与去重契约——
provider_name + tool_name组合键贯穿 key 生成、去重与焦点索引; - 受控状态契约——组件不持有工具列表,所有变更经
onChange回流,焦点恢复机制依赖父组件及时回填value; - 职责边界——MCP 可用性归 workflow 策略,工具数据归查询层,单工具交互归
ToolSelector,列表层只做顺序、计数与组合; - 无障碍优先——删除后的焦点恢复目标顺序(下一个 → 上一个 → 添加按钮)有专门参数化测试守护。
如需进一步阅读,可从 multiple-tool-selector/index.tsx、focus-restoration.spec.tsx 与 ToolSelector 模块 入手,对照本文的契约描述逐行验证。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00