数据截至 (上游 commit d02db1ee7c41)
工具层与动作落地:把编号变成真实点击
30 秒导读: LLM 看完页面后,只会说一句"对 15 号元素执行 click"。本章讲的就是这句话之后 发生的下半程:一个动作名怎么在工具注册表里查到实现,编号
15怎么经selectorMap还原成页面里那个真实<button>,以及最后怎么派发一串符合 W3C 规范的合成事件,让框架 (React / antd / 富文本编辑器)真的相信"用户点了一下"。
本章聚焦"从 action 到一次真实交互"。上半程——页面怎么被抽成带编号的元素表——是 02-dom-pipeline 的事;这些动作怎么被打包成一个结构化工具塞给模型,是 04-llm-and-autofix 的事;点击时屏幕上那个跟手的光标和防误触遮罩,归 05-mask-and-multipage。本章只讲中间这段"落地"。
1. 这是什么(零基础也能懂)
先建立一个心智模型
想象你在教一个看不见屏幕、只能听你报坐标的人去操作电脑。你不能说"点那个蓝色按钮", 你只能说"点 15 号"。于是双方必须先约定两件事:
- 一份编号表:屏幕上每个能点的东西都被贴上号码(
[15]<button>提交</button>)。 - 一本动作手册:能做的事就那么几样——点、输、选、滚、等、问、完成。
模型(那个"看不见屏幕的人")只会输出手册里的一个动作 + 编号表里的一个号码。把这两样 翻译成浏览器里一次真实交互,就是本章的全部工作。
为什么这一步不简单
难点从来不是"调用一次 element.click()"。难点是:
- 现代框架不信"裸点击"。 React、antd、Slate 这类框架监听的是一整套指针/键盘事件序列,
你只
.click()一下,它的状态经常纹丝不动。 - 编号必须精确还原。 模型说 15,你必须拿到当时那个 15 号元素;页面一变,编号表就得重建, 拿旧编号去点是灾难。
- 落点要和真人一致。 真人点按钮上的文字时,事件其实打在最内层的
<span>上,再冒泡上去。 合成事件也得模仿这种"命中最深元素"的行为,否则某些组件不响应。
用起来什么样
对模型而言,一次动作就是一个 JSON 片段(简化):
{ "action": { "click_element_by_index": { "index": 15 } } }
{ "action": { "input_text": { "index": 8, "text": "[email protected]" } } }
{ "action": { "done": { "text": "已提交表单", "success": true } } }
框架拿到它,查手册、还原元素、派发事件,最后回一句人话给模型:
✅ Clicked element (提交).
✅ Input text ([email protected]) into element (邮箱).
这句"人话"(ActionResult.message)会回流进 agent 的历史,成为模型下一步反思的素材(见
01-agent-loop)。动作的返回值不是布尔,而是给模型读的一句自然语言——
这是本章一个反复出现的设计。
2. 顶层全景(它大概怎么转)
三层职责
从模型输出到浏览器抖动一下,数据穿过三层,每层只干一件事:
| 层 | 职责 | 文件 | 关键符号 |
|---|---|---|---|
| 工具注册表 | 动作名 → 实现;参数用 zod 校验;把调用转交给 controller | packages/core/src/tools/index.ts | tools、tool() |
| 索引化派发 | 门禁 + 编号还原成真实元素 + 组织 ActionResult 消息 | packages/page-controller/src/PageController.ts | assertIndexed、getElementByIndex |
| 底层合成事件 | 按规范派发指针/鼠标/输入事件,真的动一下 DOM | packages/page-controller/src/actions.ts | clickElement、inputTextElement |