跳到主要内容

数据截至 (上游 commit fd0b7e1d9ed9)

第 3 章:无障碍树快照与 uid

这章讲什么: 模型怎么"看见"页面、怎么指认某个元素,以及 uid 为什么能跨快照保持稳定。这是本项目最具特色的一层。


3.1 为什么不是 HTML、不是截图

三条路的取舍

给模型的页面表示问题
原始 HTML体积巨大,混着大量样式和框架噪音;动态页面里选择器还容易失效
截图 + 坐标需要多模态模型;坐标随窗口尺寸/滚动位置漂移;文本内容要靠 OCR
无障碍树文本 + uid结构语义化、体积小、纯文本模型也能用

本项目选第三条。工具描述里写得很直白(src/tools/snapshot.ts:15):

"Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot."

无障碍树(accessibility tree,浏览器为读屏软件生成的语义树)天然过滤掉了纯装饰节点,留下的是"按钮""链接""文本框"这类有交互意义的东西——正好是 agent 要操作的东西。

输出示例

uid=1_0 RootWebArea "Sign in"
uid=1_4 textbox "Email" focusable
uid=1_5 textbox "Password" focusable
uid=1_6 checkbox "Remember me" selectable focusable
uid=1_7 button "Sign in" focusable

模型接下来就能发 fill_form({elements:[{uid:"1_4",value:"[email protected]"},{uid:"1_5",value:"…"}]})


3.2 快照是怎么造出来的

主流程

TextSnapshot.create(src/TextSnapshot.ts:47):

① page.accessibility.snapshot({includeIframes:true, interestingOnly:!verbose})
│ ↑ 默认只要"有意思"的节点;verbose 时要全部

② 深度优先遍历,给每个节点分配 uid ── assignIds()


③ 插入 extraHandles(三方工具返回的 DOM 节点)


④ 若 DevTools 面板里选中了元素 → 解析出它的 uid


⑤ 清理映射表里这次没见到的键

uid 的两副面孔

表面上,uid 是 ${snapshotId}_${idCounter++}——第 1 次快照的第 3 个节点就是 1_3

实际上,分配逻辑先查一张复用表(src/TextSnapshot.ts:73-87):

const uniqueBackendId = `${node.loaderId}_${backendNodeId}`;
const existingMcpId = uniqueBackendNodeIdToMcpId.get(uniqueBackendId);
if (existingMcpId !== undefined) {
id = existingMcpId; // 见过这个 DOM 节点 → 沿用旧 uid
} else {
id = `${snapshotId}_${idCounter++}`;
uniqueBackendNodeIdToMcpId.set(uniqueBackendId, id);
}

这张表 uniqueBackendNodeIdToMcpId 挂在 McpPage 上(src/McpPage.ts:119),跨快照存活

为什么这很关键

看这个流程:

① take_snapshot → 登录按钮拿到 uid=1_7
② click(uid="1_7") → 页面局部更新(没导航)
③ take_snapshot → 第 2 次快照

没有复用表:登录按钮变成 2_7 —— 模型手里的 1_7 作废
有复用表 :登录按钮仍是 1_7 —— 模型可以继续用

索引键是 loaderId + backendNodeId:backendNodeId 是 CDP 给 DOM 节点的稳定编号,加上 loaderId 是为了页面重新加载后作废——新文档里的同号节点是完全不同的东西。

清理

// src/TextSnapshot.ts:141-146
for (const key of uniqueBackendNodeIdToMcpId.keys()) {
if (!seenUniqueIds.has(key)) {
uniqueBackendNodeIdToMcpId.delete(key);
}
}

这次没出现的节点从表里删掉,否则长时间操作单页应用会无限增长。代价是:一个节点消失又出现,会拿到新 uid。 这是有意的——真消失了,旧引用本来也不该有效。

一处小补丁

无障碍树里 option 节点不带 value,所以代码把它的文本当成 value 塞进去(src/TextSnapshot.ts:99-106)。这个补丁在 fill 处理下拉框时会被用到(见 3.5)。


3.3 文本格式:每一行都是省出来的

渲染规则

SnapshotFormatter.#formatNode(src/formatters/SnapshotFormatter.ts:39):

行 = 两空格×深度 + 属性用空格拼接 + (选中标记) + 换行
属性顺序 = uid → role → "name" → 其余按字母序

#getAttributes(:67)里有两个值得看的细节:

role 为 none 时输出 ignored 让模型明确知道"这个节点无语义",而不是看到一个语焉不详的 none

布尔属性有一张改名表(:152-157):

原属性输出成含义变化
disableddisableable从"当前被禁用"变成"这东西可以被禁用"
expandedexpandable"可展开"
focusedfocusable"可聚焦"
selectedselectable"可选中"

#extractedAttributes(:129-149)可以看到实际行为:属性是布尔时,只要该属性存在就输出改名后的能力标记;值为 true 时再额外输出原名。所以 focusable 表示"这是个可聚焦控件",focused 才表示"它现在有焦点"。这两条信息模型都需要。

排除表(:159-167):idrolename 已单独输出;elementHandlechildrenbackendNodeIdloaderId 是内部字段,不该进模型上下文。

两个出口

toString() 出文本给 content,toJSON()(:35)出嵌套对象给 structuredContent。同一棵树,两种消费者(见第 4 章)。


3.4 uid 怎么变回一个真实元素

解析链

uid "1_7"
│ McpPage.getElementByUid() src/McpPage.ts:639
├─ 没有 textSnapshot? → "No snapshot found for page N. Use take_snapshot…"
├─ 表里没这个 uid? → "Element uid \"1_7\" not found on page N."

TextSnapshotNode
│ #resolveElementHandle() src/McpPage.ts:652
├─ node.elementHandle() 失败/为空
│ → "Element with uid 1_7 no longer exists on the page."(带 cause)

ElementHandle → Locator → 点击/填写/截图

三种失败各有各的话术,分别对应"还没截过快照""uid 编错了""元素已经没了"。模型看到哪一句就知道该做什么。

动作侧的统一错误

输入类工具还包了一层 handleActionError(src/tools/input.ts:36):

Failed to interact with the element with uid X. The element did not become interactive within the configured timeout.

这句话点明了 Puppeteer Locator 的语义:它不是拿到就点,而是等到元素可见、稳定、可点了才点。超时说明元素一直没进入可交互状态。


3.5 快照数据怎么被动作复用

快照里的信息不只用来定位,还用来决定动作策略

下拉框:两条不同的路

fillFormElement(src/tools/input.ts:241)先看无障碍树:

aXNode.role === 'combobox' 且有 option 子节点?

├─ 是 → selectOption() src/tools/input.ts:210
│ 按 child.name 匹配用户给的文本,
│ 再从子节点的 ElementHandle 上读真正的 value 去 fill

└─ 否 → 是 checkbox/radio/switch?
├─ 是 → 只接受 "true"/"false",否则报错说清楚
└─ 否 → 普通 fill,超时按 5ms/字符 加长

为什么要绕这一圈: 无障碍树里 option 只有显示文本没有 value,而 <select> 要按 value 选。所以先用文本在树里找到那个 option,再通过它的 ElementHandle 拿真 value(:219-227)。

还有一条更短的路:click 一个 role=option 的节点时,selectNativeSelectOption(:46)会在页面里往上找 <select> 祖先,直接 fill 它的 value——对原生下拉框,点 option 其实等于选值,不该真去模拟点击。它还谨慎地排除了 multipledisabled、以及父 optgroup 被禁用的情况。

填长文本的超时

// src/tools/input.ts:273-276
const timeoutPerChar = 10; // ms
const fillTimeout = page.pptrPage.getDefaultTimeout() + value.length * timeoutPerChar;

填 2000 字符就多给 20 秒。固定超时在长文本上必然假性失败。


3.6 两个进阶用法

DevTools 面板里选中的元素

如果用户在 Elements 面板里点了某个节点,快照会把它标出来:

uid=1_7 button "Sign in" focusable [selected in the DevTools Elements panel]

链路是:McpPage.getDevToolsData()(src/McpPage.ts:674)在 DevTools 页面里 evaluate,导入 DevTools 自己的 UI.Context 读出当前 flavor 里的 DOMNode,拿到 backendNodeId;再由 TextSnapshot.resolveCdpElementId(src/TextSnapshot.ts:151)在树里搜出对应 uid。

这就是"人在 DevTools 里指一下,agent 就知道你说的是哪个元素" ——人机协作的一个具体接口。

如果选中的元素不在精简版树里,格式化器会主动提示去拿 verbose 快照(src/formatters/SnapshotFormatter.ts:22-29)。

extraHandles:把树外的节点插进树里

页面自带的第三方开发者工具(第 7 章)可能返回一个 DOM 元素,而它未必在无障碍树里。insertExtraNodes(src/TextSnapshot.ts:173)负责把它挂进去:

① 拿到 handle 的 backendNodeId,构造一个节点(role 用标签名)
② 沿 parentElement 逐级向上,找第一个已经在树里的祖先
③ 用 CDP DOM.describeNode(depth:-1, pierce:true) 取该节点的全部后代 id
④ 把祖先节点里属于这些后代的孩子,搬到新节点下面(重新挂父)
⑤ 把新节点插在"第一个被搬走的孩子"原来的位置

第 4、5 步是为了保持树的结构正确:插进去的节点如果本来就是某些已有节点的祖先,那些节点必须变成它的孩子,而不是和它并列。


3.7 代码地图

主题文件路径符号名
快照构建与 uid 分配src/TextSnapshot.tsTextSnapshotcreateassignIdsresolveCdpElementIdinsertExtraNodes
uid 复用表src/McpPage.tsuniqueBackendNodeIdToMcpIdextraHandles
文本/JSON 渲染src/formatters/SnapshotFormatter.tsSnapshotFormatter#formatNodebooleanPropertyMapexcludedAttributes
uid → 元素src/McpPage.tsgetElementByUid#resolveElementHandlegetAXNodeByUid
快照工具src/tools/snapshot.tstakeSnapshotwaitFor
依赖快照的动作src/tools/input.tsclickfillFormElementselectOptionselectNativeSelectOptionhandleActionError
DevTools 选中元素src/McpPage.tsgetDevToolsDatagetDevToolsPage