数据截至 (上游 commit a675d6d61c41)
第 3 章 · 动作怎么落到真实浏览器
这章讲什么: 模型说"点 (720, 306)",到浏览器里真的发生一次点击,中间隔着两层代码和一堆真实世界的麻烦事。这章讲这两层各自扛什么。
3.1 两层结构:环境层与控制器层
Fara15Agent._dispatch_action
│ env.left_click(720, 306) ← 传进来的已是真实视口像素
v
┌──────────────────────────────┐
│ PlaywrightEnvironment │ ← 语义层:一个动作 = 一个方法
│ - 单标签页策略 │ 决定"新开的页要不要接管"
│ - 快捷键路由 │ Ctrl+R / Alt+← / Ctrl+F 特殊处理
└──────────┬───────────────────┘
│ controller.click_coords(page, x, y)
v
┌──────────────────────────────┐
│ PlaywrightController │ ← 韧性层:让操作在烂网页上也能活
│ - 页面就绪保证 │ 每次操作前重新注入脚本、等加载
│ - 崩溃自动恢复 │ TargetClosed / 隧道错误重试
│ - 弹窗捕获、下载处理 │
└──────────┬───────────────────┘
v
Playwright API → Chromium
先划清一条边界:环境层只认真实视口像素。模型报的 1000×1000 归一坐标在 agent 层就换算完了(_proc,src/fara/agents/fara/fara15_agent.py:495-503,第 2 章 §2.4 讲过),往下这两层再没有任何坐标空间的概念。
为什么要分两层?因为语义决策(这个新标签页该不该成为 agent 的世界)和韧性处理(页面死了怎么救)是两类完全不同的关注点,混在一起会很难读。
上面还有一层抽象基类 ComputerEnvironment / BrowserEnvironment(src/fara/environments/computer.py),用 @abstractmethod 钉死了动作接口。核心动作必须实现,扩展动作(triple_click、hscroll、middle_click 等)默认抛 NotImplementedError 由子类选择性覆盖(computer.py:120-143)。
3.2 单标签页世界观
要解决的小问题
agent 只能看一张截图。用户点了个 target="_blank" 的链接,浏览器开了新标签页 —— agent 的"世界"该跟过去吗?旧的那页怎么办?
三段式解法
第一段:预防。 点击带 id 的元素前,先用 JS 把页面上所有 target="_blank" 属性摘掉(src/fara/environments/playwright/playwright_controller.py:562-570),让链接在原地打开。
第二段:捕获。 预防挡不住 window.open()。所以点击动作统一裹在 _click_with_popup_capture 里(playwright_controller.py:501-520):
# 示意,非源码
async with page.expect_event("popup", timeout=1000):
await do_click()
new_page = await popup_info.value # 抓到了 新页
await self.on_new_page(new_page) # 给新页做同样的初始化
# 1 秒内没弹窗就当没发生,返回 None
第三段:接管。 控制器只负责"抓到了",要不要换世界由环境层决定(src/fara/environments/playwright/environment.py:367-386):
抓到 new_page
│
├─ self._page = new_page 总是切过去(浏览器也是这么表现的)
│
└─ single_tab_mode ?
├─ True(默认)──> 关掉旧页,保持"只有一页"的世界观
└─ False ───────> 留着旧页,给未来的多标签动作空间用
源码注释解释得很清楚:切过去是因为真实浏览器就是会把新标签页放到前台,agent 观察到的应该和人看到的一致(environment.py:370-375)。
3.3 快捷键路由:三条岔路
模型发一个 key(keys=[...]) 动作,环境层不是直接转发,而是先看这个组合键是不是"浏览器功能键"(environment.py:479-488):
key(keys=[...])
│ 归一化:CUA 键名 → Playwright 键名,单字母转小写
v _normalize_chord (environment.py:74-78)
┌─────────────────────┐
│ 是 Ctrl+F 吗? │──是──> 注入 find_overlay.js,页内画一个假的查找框
└────────┬────────────┘
否
v
┌─────────────────────┐
│ 在浏览器功能表里吗? │──是──> 调 self.refresh() / go_back() / forward()
└────────┬────────────┘ _BROWSER_CHROME_DISPATCH (environment.py:64-70)
否
v
转发给 controller.keypress() → 真的按键给页面
为什么需要这一层路由
因为 Playwright 的 keyboard.press 只作用于页面,碰不到浏览器 chrome。你按 Ctrl+R,页面收到一个键盘事件,但浏览器不会刷新。所以这些键必 须被拦下来,翻译成对应的 Playwright API 调用。
功能键映射表只有五条(environment.py:64-70):Ctrl+R / Ctrl+Shift+R / F5 → 刷新,Alt+← → 后退,Alt+→ → 前进。
Ctrl+F 覆盖层:一个很妙的伪造
浏览器原生的查找栏同样够不着。Fara 的做法是在页面里用 JS 画一个长得像查找栏的东西(src/fara/environments/playwright/find_overlay.js)。
文件头的注释把设计意图讲得很清楚(find_overlay.js:1-5):输入框自动获得焦点,这样 agent 接下来的 type() + key(["Enter"]) 会自然流进去。也就是说,agent 不需要知道这是个假的查找栏 —— 它按人的习惯操作,行为就对了。
覆盖层支持的交互:Enter 遍历文本节点高亮所有匹配并滚到当前项、显示 N/M 计数、重复 Enter 循环前进、Esc 关闭并清除高亮(find_overlay.js:193-200)。它还给按钮加了 title 提示 —— 虽然 agent 看不到 tooltip,但这说明作者是按"给人用"的标准做的。