跳到主要内容

02 · act 与输入:模型的「手」

这章讲一个 ref 怎么从「文本里的编号」变成网页上一次真实的点击,以及当页面变了、ref 指向的节点不在了,系统怎么不报错地把它找回来。

1. 它要解决的小问题

模型说了 act(kind=click, ref=e5)。系统要回答两个问题:

  1. e5 现在对应哪个真实 DOM 节点?(可能页面已经动过了)
  2. 怎么对它执行一次「真实」的点击?(不是 JS .click(),而是带坐标的鼠标事件,这样才能触发那些只听真实输入的页面)

2. 一个工具,十几种动作

所有页面变更都走一个工具:act。它故意用扁平 schema(不是嵌套的判别联合),因为有些模型 provider 拒绝嵌套的 anyOf JSON Schema:

// packages/browser-mcp/src/tools/act.ts:14(act, 注释见 :12)
// Flat (not discriminated-union) schema: some providers reject nested anyOf JSON Schema.
kind: z.enum(['click','click_at','type','type_at','fill','press','hover',
'hover_at','focus','check','uncheck','select','scroll','drag','drag_at']),

注意成对的 click / click_at:前者用 ref(优先),后者用裸坐标(快照里没有该元素时的逃生口)。act 的 handler 极简——查表分发到对应小函数,然后自动附上一个 diff:

// packages/browser-mcp/src/tools/act.ts:64(handler)
const err = await runKind(args, input)
if (err) return err
response.data({ kind: args.kind })
response.includeDiff(args.page, { includeStructured: true }) // 动作后自动回 diff
return textResult(`ok (${args.kind})`)

这个 includeDiff 是 snapshot→act→diff 闭环的关键一环,详见 03 章。

3. ref → 真实元素:两级容错(本章核心)

input.click(ref) 第一步是 observer.resolveRef(ref)。这里藏着 BrowserOS 比传统自动化稳的关键:它不缓存 CSS 选择器,而是缓存「后端节点 id」,失效时回到当初铸造这个 ref 的同一棵无障碍树里按语义重查。

// packages/browser-core/src/core/observer/resolve.ts:18(resolveRefEntry)
if (await isLive(session, entry.backendNodeId)) {
return { session, backendNodeId: entry.backendNodeId } // 第一级:缓存还活着,直接用
}
const fresh = findByRoleNameNth(await fetchAxTree(session, axParams), entry)
if (fresh === undefined) {
throw new Error(`Stale ref ${entry.ref} (${entry.role} "${entry.name}"); take a new snapshot.`)
}
entry.backendNodeId = fresh // 第二级:按 (role, name, nth) 重查,并刷新缓存

两级是这样配合的:

ref e5 ──▶ ① 缓存的 backendNodeId 还活着吗?
│是 → 直接用(快路径)
│否

② 回到同一棵无障碍树,找第 nth 个 (role="button", name="Log in")
│找到 → 刷新缓存、用新节点
│找不到 → 抛「ref 陈旧,请重新快照」

为什么「用同一个数据源重查」是妙招?(resolve.ts:12 的注释点破了)铸造 ref 时用的是无障碍树的 (role, name),重查也用无障碍树的 (role, name, nth)——同源,所以匹配口径一致。nth 是「同名同角色里的第几个」,用来区分页面上 3 个都叫「删除」的按钮。这比缓存一个 div.btn-primary:nth-child(3) 选择器抗页面抖动得多:页面重排了、class 改了都不影响,只要那个语义节点还在。

而且 findByRoleNameNth 的遍历顺序刻意和渲染器一致(同样从 ROOT_ROLES 入口前序遍历,resolve.ts:54),保证「第 nth 个」和当初快照编号时数的是同一个。

4. 真实元素 → 真实点击:经 CDP 派发

拿到 backendNodeId 后,Input.clickNode 做三步:滚动进视口 → 算元素中心坐标 → 派发鼠标事件;算不出几何(隐藏/零尺寸元素)就退回合成 DOM 点击:

// packages/browser-core/src/core/input/input.ts:109(clickNode)
await scrollIntoView(session, backendNodeId)
try {
const { x, y } = await getElementCenter(session, backendNodeId)
await dispatchClick(session, x, y, mouseButton(opts.button), opts.clickCount ?? 1, 0)
return { x, y }
} catch {
await jsClick(session, backendNodeId) // 兜底:没几何就合成点击
}

dispatchClick 底层是 CDP 的 Input.dispatchMouseEvent(真实坐标的 press/release),不是 element.click()——这点对触发依赖真实输入序列的页面很重要。

填表 fill 更费心思,因为要可靠地聚焦各种自定义/shadow-DOM 输入框,并清空旧值:

// packages/browser-core/src/core/input/input.ts:175(fillNode)
await scrollIntoView(session, backendNodeId)
try {
coords = await getElementCenter(session, backendNodeId)
await dispatchClick(session, coords.x, coords.y, 'left', 1, 0) // 真实点击聚焦最可靠
} catch {
await focusElement(session, backendNodeId)
}
if (opts.clear !== false) {
await clearField(keys)
if (coords && (await getInputValue(session, backendNodeId))) {
await dispatchClick(session, coords.x, coords.y, 'left', 3, 0) // 还有残留 → 三击全选再覆盖
}
}
await typeText(keys, value)

这种「点击聚焦 → 清空 → 残留则三击全选 → 输入」的序列是踩过真实页面坑总结出来的:很多框架的输入框对程序化赋值无反应,只认真实的聚焦+按键。

5. 一个不显然的坑:鼠标和键盘走不同 session

注释里点破了一个 CDP 的硬约束(input.ts:44):鼠标/滚动事件派发在元素所在 frame 的 session 上(用该 frame 的坐标系);键盘事件却派发在页面顶层 session 上(打到焦点移到的地方)。这种不对称是为了支持 OOPIF(跨进程 iframe)——在主 frame 上它是无害的 no-op,但在跨 frame 操作时这个区分是对的。这类细节正是「驱动真实浏览器」和「写个 demo」之间的距离。

还有一层韧性:CDP 连接偶尔会瞬断,withPageSessionRetry 在遇到「session 没了」这类可重试错误时重新拿一次 page session 再试一次(input.ts:423)。

6. 关键细节 / 坑

  • 优先 ref,坐标兜底。 系统提示明确:act 用 ref 的形式优先,坐标形式(*_at)只在「元素不在快照里」时用(apps/server/src/agent/prompt.ts:306)。
  • 陈旧 ref 不一定是错误。 第一级失活、第二级能重查到,对模型完全透明——它甚至不知道节点换了 id。只有两级都失败才提示「重新快照」。
  • drag 不能跨 frame session。 源和目标必须在同一个 frame session,否则直接报错(input.ts:317)。

7. 代码地图

主题文件符号
act 工具(分发)packages/browser-mcp/src/tools/act.tsact / runKind / ACT_HANDLERS
ref 两级容错解析packages/browser-core/src/core/observer/resolve.tsresolveRefEntry / findByRoleNameNth / isLive
动作层(click/fill/type...)packages/browser-core/src/core/input/input.tsInput / clickNode / fillNode / withPageSessionRetry
几何与聚焦packages/browser-core/src/core/input/geometry.tsgetElementCenter / scrollIntoView / jsClick
CDP 事件派发packages/browser-core/src/core/input/mouse.tsdispatchClick / dispatchDrag / dispatchScroll
键盘packages/browser-core/src/core/input/keyboard.tstypeText / pressCombo / clearField