数据截至 (上游 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:"a@b.c"},{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)。