跳到主要内容

工具层与动作落地:把编号变成真实点击

30 秒导读: LLM 看完页面后,只会说一句"对 15 号元素执行 click"。本章讲的就是这句话之后 发生的下半程:一个动作名怎么在工具注册表里查到实现,编号 15 怎么经 selectorMap 还原成页面里那个真实 <button>,以及最后怎么派发一串符合 W3C 规范的合成事件,让框架 (React / antd / 富文本编辑器)真的相信"用户点了一下"。

本章聚焦"从 action 到一次真实交互"。上半程——页面怎么被抽成带编号的元素表——是 02-dom-pipeline 的事;这些动作怎么被打包成一个结构化工具塞给模型,是 04-llm-and-autofix 的事;点击时屏幕上那个跟手的光标和防误触遮罩,归 05-mask-and-multipage。本章只讲中间这段"落地"。


1. 这是什么(零基础也能懂)

先建立一个心智模型

想象你在教一个看不见屏幕、只能听你报坐标的人去操作电脑。你不能说"点那个蓝色按钮", 你只能说"点 15 号"。于是双方必须先约定两件事:

  1. 一份编号表:屏幕上每个能点的东西都被贴上号码([15]<button>提交</button>)。
  2. 一本动作手册:能做的事就那么几样——点、输、选、滚、等、问、完成。

模型(那个"看不见屏幕的人")只会输出手册里的一个动作 + 编号表里的一个号码。把这两样 翻译成浏览器里一次真实交互,就是本章的全部工作。

为什么这一步不简单

难点从来不是"调用一次 element.click()"。难点是:

  • 现代框架不信"裸点击"。 React、antd、Slate 这类框架监听的是一整套指针/键盘事件序列, 你只 .click() 一下,它的状态经常纹丝不动。
  • 编号必须精确还原。 模型说 15,你必须拿到当时那个 15 号元素;页面一变,编号表就得重建, 拿旧编号去点是灾难。
  • 落点要和真人一致。 真人点按钮上的文字时,事件其实打在最内层的 <span> 上,再冒泡上去。 合成事件也得模仿这种"命中最深元素"的行为,否则某些组件不响应。

用起来什么样

对模型而言,一次动作就是一个 JSON 片段(简化):

{ "action": { "click_element_by_index": { "index": 15 } } }
{ "action": { "input_text": { "index": 8, "text": "hello@example.com" } } }
{ "action": { "done": { "text": "已提交表单", "success": true } } }

框架拿到它,查手册、还原元素、派发事件,最后回一句人话给模型:

✅ Clicked element (提交).
✅ Input text (hello@example.com) into element (邮箱).

这句"人话"(ActionResult.message)会回流进 agent 的历史,成为模型下一步反思的素材(见 01-agent-loop)。动作的返回值不是布尔,而是给模型读的一句自然语言—— 这是本章一个反复出现的设计。


2. 顶层全景(它大概怎么转)

三层职责

从模型输出到浏览器抖动一下,数据穿过三层,每层只干一件事:

职责文件关键符号
工具注册表动作名 → 实现;参数用 zod 校验;把调用转交给 controllerpackages/core/src/tools/index.tstoolstool()
索引化派发门禁 + 编号还原成真实元素 + 组织 ActionResult 消息packages/page-controller/src/PageController.tsassertIndexedgetElementByIndex
底层合成事件按规范派发指针/鼠标/输入事件,真的动一下 DOMpackages/page-controller/src/actions.tsclickElementinputTextElement

一次点击的完整穿行

下面这张图从上到下就是一次 click_element_by_index 的生命线。怎么读:从模型出发,箭头即 调用方向;右侧是每一步"在哪个文件"。

模型输出 action 所在文件
│ { click_element_by_index:{index:15} }

┌─────────────────────────┐
│ 工具注册表 tools.get() │ core/src/tools/index.ts
│ zod 校验 index≥0 │
│ → this.pageController │
│ .clickElement(15) │
└───────────┬─────────────┘

┌─────────────────────────┐
│ PageController │ page-controller/src/PageController.ts
│ ① assertIndexed() 门禁 │
│ ② getElementByIndex │ selectorMap: 15 → 真实 <button>
│ ③ await clickElement(el)│
│ ④ 组织 ActionResult 消息│
└───────────┬─────────────┘

┌─────────────────────────┐
│ actions.clickElement │ page-controller/src/actions.ts
│ 滚动进视口→移光标→ │
│ hit-test 命中最深元素→ │
│ 派发 pointer/mouse 序列 │
│ →focus→.click() │
└───────────┬─────────────┘

浏览器真实交互
│ 回传 "✅ Clicked element (提交)."

进入 agent 历史(下一步反思)

一句话:工具层管"是什么动作",PageController 管"作用在谁身上、门禁与话术",actions 管 "怎么骗过浏览器让它以为是真人"。


3. 核心原理

3.1 工具注册表:一个 Map 就是全部动作手册

它要解决的小问题: 模型说 click_element_by_index,系统怎么知道该跑哪段代码、参数合不合法?

思路: 用一个全局 Map<string, PageAgentTool>,键是动作名,值是"描述 + 参数 schema + 执行体" 三件套。注册表在 tools/index.ts:36 建立:

// tools/index.ts:36 —— 真实源码,不是示意
export const tools = new Map<string, PageAgentTool>()

每个工具用一个 tool() 工厂(tools/index.ts:28)包一层,拿到统一形状。PageAgentTool 接口 (tools/index.ts:21)长这样:

  • description —— 给模型看的说明,直接进 prompt。
  • inputSchema —— 一个 zod schema,既校验模型输出、又用来生成工具的参数描述。
  • execute —— 真正的执行体,this 被绑定成 PageAgentCore,所以体内能拿到 this.pageController

execute 还收第二个参数 ctx: ToolContext,里面只有一个 signal: AbortSignal (tools/index.ts:14)。注释写得很直白:工具必须尊重 signal 以支持协作式取消——用户点 "停",长动作要能中途退出。

九个内置动作

注册表里一共九个动作,按"要不要碰某个具体元素"分两类:

动作名参数落到哪无需元素?
donetext, success结束任务,主循环特判
waitseconds (1–10)等待,并扣掉 LLM 已耗时
ask_userquestion回调问用户
click_element_by_indexindexclickElement✖ 需编号
input_textindex, textinputText✖ 需编号
select_dropdown_optionindex, textselectOption✖ 需编号
scrolldown, num_pages, pixels?, index?scroll编号可选
scroll_horizontallyright, pixels, index?scrollHorizontally编号可选
execute_javascriptscriptexecuteJavascript✅(实验)

几处值得单独点出的巧思:

  • done 是个空壳。 它的 execute 只 return 一句 'Task completed'(tools/index.ts:47-50), 真正的收尾由主循环特判——注释明写 main loop will handle this one
  • wait 会偷时间。 它先问 getLastUpdateTime(),把"LLM 刚才思考花掉的秒数"从要等的秒数里 减掉(tools/index.ts:63-67),避免白等。
  • scroll 带编号才是正道。 无编号时滚文档,有编号时滚"那个容器或它最近的可滚祖先" (tools/index.ts:145 的 description),这是为多面板布局准备的——详见 §3.5。
  • 表格类的 scroll_horizontally 作者自己都嫌弃。 源码顶上挂着 @todo ... This tool is useless. (tools/index.ts:163),因为表格真正需要的是结构化解析而非硬滚。

动作是怎么增删的:customTools

注册表不是死的。PageAgentCore 构造时先拷一份全局表(PageAgentCore.ts:114 this.tools = new Map(tools)),再按配置增删:

// PageAgentCore.ts:134-142 —— customTools 的增删语义
if (this.config.customTools) {
for (const [name, tool] of Object.entries(this.config.customTools)) {
if (tool === null) { // 传 null = 删掉这个内置动作
this.tools.delete(name)
continue
}
this.tools.set(name, tool) // 传实现 = 新增或覆盖
}
}

约定很干净:给个实现就是加/换,给个 null 就是删。类型 Record<string, PageAgentTool | null> (core/src/types.ts:49)。文档注释里的例子就是 ask_user: null // never ask user questions

还有两处运行时删除:

  • execute_javascript 默认不存在。 除非显式开 experimentalScriptExecutionTool,否则构造末尾 直接 this.tools.delete('execute_javascript')(PageAgentCore.ts:144-146)。这是一道安全闸—— 让模型在页面上跑任意 JS 风险太大,默认关。
  • 没有回调就没有 ask_user 若未设 onAskUser,ask_user 也被删掉(PageAgentCore.ts:232)。

execute_javascript 的执行体本身也很"实验":PageController.executeJavascripteval 把脚本包进一个 async (signal) => {...} 再跑(PageController.ts:386),把 signal 暴露进作用域,好让长任务能 signal.throwIfAborted()。工具层执行完还会再补一刀 signal.throwIfAborted()(tools/index.ts:194),双保险。


3.2 索引化派发:编号还原成真实元素,外加一道门禁

它要解决的小问题: 模型给的是数字 15,clickElement 需要的是 DOM 里那个 HTMLElement。 谁来做这个翻译?翻译之前要不要检查什么?

思路: PageController 持有一张 selectorMap: Map<number, InteractiveElementDomNode> (PageController.ts:68)——这就是编号表的"号码 → 元素"那一栏,由 DOM 抽取阶段(02 章)填好。 每个索引动作进来,先过门禁,再查表,再干活,最后包消息。四步固定套路。

第一步:门禁 assertIndexed

assertIndexed(PageController.ts:235-239)只做一件事:确认 updateTree() 至少跑过一次 (isIndexed === true),否则直接抛错。

// PageController.ts:235-239
private assertIndexed(): void {
if (!this.isIndexed) {
throw new Error('DOM tree not indexed yet. Can not perform actions on elements.')
}
}

为什么重要?没建过编号表就来点 15 号,selectorMap 是空的,15 指向的是上一页甚至虚无。 门禁把这种"时序错乱"挡在最前面,符合项目"可追溯、可预测 > 成功率"的取向——宁可清晰报错, 不要糊里糊涂点错。每个元素动作(click/input/select/scroll/scrollHorizontally)开头都调它。

第二步:getElementByIndex 取真身

getElementByIndex(actions.ts:23-42)从 selectorMap 取出节点,层层校验后返回真实元素:

  • 编号查不到 → 抛 No interactive element found at index N;
  • 节点没有 .ref(未绑定真实 DOM)→ 抛 does not have a reference;
  • .ref 不是 HTMLElement → 抛 is not an HTMLElement

三道校验,每道都给出能定位问题的错误信息,而不是静默返回 null 让后面 .click() 崩在 更远的地方。

第三步 + 第四步:干活并组织 ActionResult

clickElement(PageController.ts:244-269)为例,它把上面几步串起来,还顺手做了一件贴心事:

// PageController.ts:244-262 —— 节选
async clickElement(index: number): Promise<ActionResult> {
try {
this.assertIndexed()
const element = getElementByIndex(this.selectorMap, index)
const elemText = this.elementTextMap.get(index) // 编号→人类可读文字
await clickElement(element) // 交给 actions 层

if (isAnchorElement(element) && element.target === '_blank') {
return { success: true,
message: `✅ Clicked element (${elemText ?? index}). ⚠️ Link opened in a new tab.` }
}
return { success: true, message: `✅ Clicked element (${elemText ?? index}).` }
} catch (error) {
return { success: false, message: `❌ Failed to click element: ${error}` }
}
}

三个设计点:

  1. 消息里用文字不用数字。 elementTextMap(PageController.ts:71)把 15 映射回"提交"这类 人类可读描述,回给模型的话是"点了(提交)"而非"点了(15)"——模型更好反思。
  2. 新标签页要额外提醒。 若点的是 target="_blank" 的链接,消息追加一句 ⚠️ Link opened in a new tab.,提示模型页面焦点没变(跨标签页处理见 05)。
  3. 异常不外抛,而是变成 success:false 的消息。 动作层把错误"驯化"成一句给模型读的话—— 模型看到 ❌ Failed to click element: ... 会自己想办法,而不是让整个循环崩掉。

inputText(:274)、selectOption(:296)、scroll(:318)、scrollHorizontally(:350)是同一套 "门禁→取元素→执行→包消息"模板,不再赘述。


3.3 clickElement:一次"以假乱真"的点击

它要解决的小问题: 为什么不能直接 element.click()?因为现代框架不认。它们监听的是一整套 指针与鼠标事件序列,少一环都可能不触发状态更新。

思路: 严格按 W3C Pointer Events + UI Events 规范顺序手工派发每一个事件,把真人点击的 物理过程完整重演一遍。源码顶注(actions.ts:57-63)把这个序列写死了:

pointerover / pointerenter
→ mouseover / mouseenter
→ pointerdown → mousedown
→ [focus] ← 规范里"未定义、各家不同",这里放这
→ pointerup → mouseup
→ click

命中最深元素(hit-test)

一个容易被忽略的细节:真人点"提交"按钮上的文字时,事件的 target 其实是里面那层 <span>, 而不是 <button> 本身。clickElementelementFromPoint 在点击坐标做一次 hit-test,取最深的 那个元素当 target(actions.ts:86-91):

// actions.ts:86-91 —— 命中最深元素,模仿真实浏览器
const doc = element.ownerDocument
await enablePassThrough() // 先让遮罩穿透,别挡住 hit-test
const hitTarget = doc.elementFromPoint(x, y)
await disablePassThrough()
const target =
hitTarget instanceof HTMLElement && element.contains(hitTarget) ? hitTarget : element

注意 enablePassThrough / disablePassThrough 这对调用:它们派发 PageAgent::EnablePassThrough 自定义事件,临时让视觉遮罩"透明"一下,免得 elementFromPoint 命中的是遮罩本身(遮罩机制见 05)。源码也诚实标注了这是临时兜底(actions.ts:84-85 的 TODO: 本该在 DOM 抽取阶段处理,现在可能命中黑名单里的元素)。

focus 打在原始元素,不是命中目标

事件序列里 focus 的位置很讲究。规范说 focus 时机"未定义、各家浏览器不同",所以它被放在 mousedown 之后。而且 focus 的对象是原始的可交互元素 element,不是 hit-test 命中的最深 target(actions.ts:112-115):

// actions.ts:115
element.focus({ preventScroll: true })

道理:你点 <button> 里的 <span>,真正获得焦点的是按钮(最近的可聚焦祖先),而不是那个 纯展示的 span。合成时也照此办理。

点击前后的收尾

  • 开头 blur 上一个。 每次点击先 blurLastClickedElement()(actions.ts:65),把上一个被点 元素的 hover/focus 状态清干净——派发 pointerout/leavemouseout/leave.blur() (actions.ts:46-55),避免残留状态污染下一次。
  • 模块级变量记住"上一个"。 用一个模块级 lastClickedElement(actions.ts:44)跨调用记忆。
  • 两处 waitFor 留出反应时间。 派发前后各等一小会(actions.ts:80:125),给框架的 异步渲染留空档。

3.4 inputTextElement:三条路径,专治各种"倔"输入框

它要解决的小问题: "输入框"在网页里其实有三种完全不同的东西:原生 <input>/<textarea>、 以及 contenteditable 富文本区。它们受控方式天差地别,一套代码填不进去。

思路: inputTextElement(actions.ts:131)先 clickElement 聚焦,再按元素类型分叉。 先看简单的一支。

原生输入框:绕过 React 的"受控"劫持

<input>/<textarea>,你若直接 element.value = text,React 这类框架会察觉不到——因为 React 在原型上覆写了 value 的 setter 来做受控。破解办法是从元素原型链上取出原生的 value setter,再 .call 到元素上(actions.ts:221):

// actions.ts:221 —— 拿原生 setter 绕过框架劫持
getNativeValueSetter(element as HTMLInputElement | HTMLTextAreaElement).call(element, text)

getNativeValueSetter(utils/index.ts:39-43)的实现就是 Object.getOwnPropertyDescriptor( Object.getPrototypeOf(element), 'value').set——从原型拿描述符,是 iframe-safe 的(注释点明 @note for React)。设完值再派发一次 input 事件(actions.ts:226),React 就"看见"了。

contenteditable:Plan A 合成事件,Plan B execCommand 兜底

富文本区没有 value,内容是子节点。这里用两段式策略(actions.ts:139-213),源码注释把 适用/失效范围列得很清楚:

Plan A —— 派发合成 beforeinput/input 事件序列(actions.ts:148-191):

  • 先派发一组 deleteContent(清空),再派发一组 insertText(插入),中间夹着对 element.innerText 的真实修改。
  • 关键在尊重 beforeinput 的可取消性:代码用 if (element.dispatchEvent(new InputEvent( 'beforeinput', {cancelable:true, ...}))) 包住,只有事件没被 preventDefault 才动 DOM——这正是 规范期望的行为。
  • 适用:React contenteditable、Quill;失效:Slate.js 等无视合成事件的编辑器。

验证 + Plan B(actions.ts:194-213):Plan A 做完立刻自检——

// actions.ts:194-196
const planASucceeded = element.innerText.trim() === text.trim()
if (!planASucceeded) {
// Plan B: execCommand 兜底(已废弃但广泛支持)

若文字没真的进去,退到 Plan B:用 Selection + Range 全选,再 execCommand('delete') + execCommand('insertText', ...)(actions.ts:204-212)。这条路虽然 API 已废弃,但接入浏览器 原生 undo 栈、被多数富文本编辑器原生处理,兼容 Quill / Slate / react contenteditable。

最后无论走哪条,都派发 changeblur() 触发校验(actions.ts:216-219)。作者也标了两个 已知打不动的硬骨头:Monaco/CodeMirror(需直接拿 JS 实例)、Draft.js(对合成事件无反应且 已停维护)——诚实划出边界。


3.5 select 与 scroll:一个直白,一个满是启发式

selectOptionElement:按可见文字选

selectOptionElement(actions.ts:238-254)最朴素:遍历 <select> 的 options,按 textContent 去空白后精确匹配你要的文字(actions.ts:244),命中就设 value 再派发 change。找不到就抛 Option with text "..." not found。注释老实承认:browser-use 那版能处理 <menu> 等复杂结构,这里是简化版(actions.ts:235@todo)。

scrollVertically:逐级回溯找"真能滚的那层"

滚动是本章最"脏"的一块,因为"该滚谁"在真实页面里出奇地难判定。scrollVertically (actions.ts:275)分两种情形:

情形一:给了 index(推荐)。 从该元素出发逐级向上回溯找能滚的祖先,最多 10 层 (actions.ts:286 while (currentElement && attempts < 10))。每层判两个条件:

  • overflowYauto/scroll/overlay(或有非 auto 的 scrollbar 相关样式);
  • scrollHeight > clientHeight(内容确实溢出)。

命中就真的改 scrollTop,并验证真的滚动了(actualScrollDelta > 0.5,actions.ts:311)—— 滚不动就继续往上找。这里还有个软化:每次只滚请求量的三分之一(scrollAmount = dy / 3, actions.ts:298),让滚动更"温和"、不容易一步冲过头。

情形二:没给 index(降级启发式)。 这条路作者自己标了 @deprecated(actions.ts:345-347): 从 document.activeElement 往上找可滚容器,找不到就全局 querySelectorAll('*') 扫一遍 (actions.ts:361),再退到 document.scrollingElement。注释直言这在多面板布局里不可靠,应当 引导 LLM 改用带 index 的滚动。这解释了为什么 §3.1 里 scroll 工具的描述反复强调"传 index 滚 特定区域"。

scrollHorizontally(actions.ts:419)是同一套逻辑的横向镜像,连 @deprecated 注释都一样。

滚动动作的返回值同样是给模型读的话,且区分了状态:✅ Scrolled page by Npx.⚠️ Already at the bottom ...Reached the bottom of the page. 等(actions.ts:376-386),让模型 知道"到底了别再滚"。


4. 深入实现:合成事件为什么要借道自定义事件

有个细节值得单独看:actions 层从不直接操作那个"跟手的光标",而是派发 window 级自定义事件, 让别的模块去响应。比如移动光标(utils/index.ts:58-68):

// utils/index.ts:58-67 —— 动作层只"广播意图",不碰 UI
export async function movePointerToElement(element: HTMLElement, x: number, y: number) {
const offset = getIframeOffset(element) // iframe 内元素要换算到顶层坐标
window.dispatchEvent(
new CustomEvent('PageAgent::MovePointerTo', {
detail: { x: x + offset.x, y: y + offset.y },
})
)
await waitFor(0.3)
}

同理还有 clickPointer 派发 PageAgent::ClickPointer(utils/index.ts:70)、enablePassThrough 派发 PageAgent::EnablePassThrough(:74)。

为什么这么绕? 因为 page-controller 的设计目标是不依赖 UI、可在单测里跑(见包边界)。 动作逻辑只负责"广播现在要移到哪、要点了、暂时让开",至于有没有可视光标去响应这些事件, 是 SimulatorMask 的自由(它订阅这些事件——详见 05)。没有遮罩时, 这些事件没人听,动作照样完成;有遮罩时,才有跟手光标和防误触。这种事件解耦让"真实交互"与 "视觉反馈"彻底分家。

getIframeOffset(utils/index.ts:28-33)则处理 iframe 内元素的坐标换算——把子 frame 的 getBoundingClientRect().left/top 加到坐标上,才能在顶层 frame 里画对光标位置。


5. React / antd 补丁:给框架"消消毒"

它要解决的小问题: React 常把大量事件监听器挂在根节点上,导致 DOM 抽取时把整个 #root 误判成"可交互元素",污染编号表。

思路: 在编号表建立前,先给这些框架根节点打个"别当我可交互"的标记。patchReact (patches/react.ts:4-12)干的就是这事:

// patches/react.ts:4-12 —— 给常见 React 根节点上"免打扰"标记
export function patchReact(pageController: PageController) {
const reactRootElements = document.querySelectorAll(
'[data-reactroot], [data-reactid], [data-react-checksum], #root, #app, ' +
'[id^="root-"], [id^="app-"], #adex-wrapper, #adex-root'
)
for (const element of reactRootElements) {
element.setAttribute('data-page-agent-not-interactive', 'true')
}
}

这个 data-page-agent-not-interactive 属性正是编号表阶段的黑名单来源:updateTree 会把所有带 该属性的元素并进 interactiveBlacklist(PageController.ts:186-189),从而不给它们编号。 patchReactPageController 构造函数里被调用(PageController.ts:94),一次性打标。

antd 补丁目前是半成品(诚实标注)。 patches/antd.tspatchAntd(:14)想在 beforeUpdate 时把 antd "div 包 input" 的 Select 结构里那个隐藏 input 的信息"提上来",但 fixAntdSelect(:9)目前只 querySelectorAll('input[role="combobox"]') 拿到列表、循环体被 注释掉,等于空转;而且 patchAntd 在整个仓库里未被任何地方调用(grep 全仓只此一处 定义)。所以现状是:React 补丁真在生效,antd 补丁是留的坑。 别被它误导以为 antd 已被特殊 处理。


6. 巧妙之处(可带走的技术)

  • 动作返回"人话"而非布尔。 每个 ActionResult.message 都是给模型读的自然语言,带 emoji 状态 与元素文字(PageController.ts:255-261)。这让 ReAct 循环的"观察"环节天然可读。
  • 异常驯化成消息。 元素动作全程 try/catch,把错误变成 ❌ ... 消息回给模型自我纠偏,而非 让循环崩溃(PageController.ts:263-268)——契合"让错误可见、可行动"的项目哲学。
  • 原生 value setter 绕过 React 受控。 从原型链取 setter 是对付受控组件的经典手法 (utils/index.ts:39-43)。
  • contenteditable 先合成事件、失败再 execCommand,且带自检。 planASucceeded 那一步验证 (actions.ts:194)是两段式兜底能站住的关键。
  • hit-test 命中最深元素 + focus 打原始元素。 一个模仿真人事件目标,一个模仿真人焦点归属, 两者分开处理(actions.ts:86-91:115)。
  • 安全默认关。 execute_javascript 默认从表里删掉(PageAgentCore.ts:144-146),要显式开实验 开关才有——把"能跑任意 JS"这种高风险能力放在门后。

7. 边界与局限(源码自己承认的)

局限位置说明
无 index 的滚动不可靠actions.ts:345-347@deprecated 启发式,多面板布局易找错容器,应引导用 index
scroll_horizontally 对表格无用tools/index.ts:163@todo ... useless,表格需要专门的结构化解析
部分富文本编辑器打不动actions.ts:141-145Monaco/CodeMirror 需 JS 实例;Draft.js 无反应且停维护
hit-test 可能命中黑名单元素actions.ts:84-85TODO 临时兜底,本应在 DOM 抽取阶段解决
antd 补丁未接线patches/antd.tspatchAntd/fixAntdSelect 定义了但全仓无人调用,且循环体注释掉
execute_javascriptevalPageController.ts:386实验能力,默认关闭
未实现的动作tools/index.ts:200-202send_keys / upload_file / extract_structured_data 仅列 @todo

8. 代码地图(导航索引)

主题文件符号
工具注册表 / 工厂packages/core/src/tools/index.tstoolstoolPageAgentToolToolContext
customTools 增删 / 实验开关packages/core/src/PageAgentCore.tscustomTools 循环、experimentalScriptExecutionTool 删除
动作执行入口(绑 this、查表、abort)packages/core/src/PageAgentCore.tstool.execute.bind(this)signal.throwIfAborted
门禁packages/page-controller/src/PageController.tsassertIndexedisIndexed
编号→元素packages/page-controller/src/actions.tsgetElementByIndexselectorMap
索引派发 + ActionResultpackages/page-controller/src/PageController.tsclickElementinputTextselectOptionscroll
合成点击packages/page-controller/src/actions.tsclickElementblurLastClickedElement
文本输入(三路径)packages/page-controller/src/actions.tsinputTextElementgetNativeValueSetter
下拉选择packages/page-controller/src/actions.tsselectOptionElement
滚动启发式packages/page-controller/src/actions.tsscrollVerticallyscrollHorizontally
自定义事件桥packages/page-controller/src/utils/index.tsmovePointerToElementclickPointerenablePassThrough
React / antd 补丁packages/page-controller/src/patches/react.tsantd.tspatchReactpatchAntd(未接线)

相邻章节: 上游 DOM 编号表 → 02-dom-pipeline;动作怎么打包给模型 → 04-llm-and-autofix;光标与遮罩、跨标签页 → 05-mask-and-multipage;整体 ReAct 循环 → 01-agent-loop