跳到主要内容

视觉反馈与跨标签页:遮罩、Panel 与扩展

30 秒导读: 前四章讲的是 Page Agent 的核心——反思循环DOM 脱水工具与动作LLM 客户端。本章讲核心外面的两圈壳:一圈是运行时视觉反馈(自动化时盖一层遮罩、画个假光标、拦住真人的手),另一圈是跨标签页与外部控制(浏览器扩展让 agent 在多个 tab 间跳,MCP 让外部客户端遥控你的浏览器)。这两圈都不碰核心的内部机制,只是把核心包起来、复用它。

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

前面四章把一个「网页里的 GUI Agent」讲透了:它能看懂页面、想清楚下一步、点对按钮、填对表单。但那是核心引擎。真把它交到用户手里,还差两样外围能力。

  • 第一样:让人看得见、且不添乱。 agent 自动点页面时,屏幕上得有反馈——不然用户以为卡死了;同时,agent 点到一半你的鼠标别乱插一脚。这就是 SimulatorMask(模拟器遮罩):自动化期间盖一层半透明遮罩,画一个会动的"AI 光标"演示它在点哪,顺手把真人的鼠标键盘都拦在遮罩外。
  • 第二样:突破"一个页面"的边界。 核心默认只管当前这一个网页。可真实任务经常要开新标签页、跨站点跳转,甚至由另一个 AI(比如 Claude、Cursor)从外部下命令。这就是浏览器扩展里的 MultiPageAgentMCP 包干的事。

一句话直觉: 把核心引擎想成汽车的发动机。本章讲的是仪表盘 + 挡风玻璃(遮罩/Panel:让你看见、保护你的手)和换成越野底盘(扩展:同一台发动机,换个能跑多地形的底盘,就能跨 tab)。发动机内部一个零件都没改。

本章的边界(重要): 这是一章外延章。它只讲"外围如何包裹并复用核心",不重复循环怎么反思、DOM 怎么脱水、动作怎么落地、LLM 怎么容错——那些请看前四章。

2. 顶层全景(两圈壳怎么套在核心上)

先看清楚"核心"和"两圈壳"的关系。核心是 PageAgentCore(在 @page-agent/core),它只依赖 LLM 客户端和一个 PageController。两种真实产品,都是给核心换外围换出来的:

┌─────────────────────────────┐
│ PageAgentCore │ ← 核心(前四章)
│ 反思循环 · 工具 · 系统提示 │ 两种产品都复用它
└───────────┬─────────────────┘
│ 依赖一个 pageController
┌──────────────────────┼──────────────────────┐
│ │
┌──────┴───────┐ ┌────────┴────────┐
│ 单页产品 │ │ 扩展产品 │
│ PageAgent │ │ MultiPageAgent │
└──────┬───────┘ └────────┬────────┘
│ 加了两个外围 │ 换了外围
① PageController(enableMask:true) ① pageController → RemotePageController
└ 里面挂 SimulatorMask └ 经 TabsController 跨 tab 代理
② new Panel(this) ← UI 反馈 ② 注入 tabTools + 扩展版 system_prompt
③ 心跳/状态投影到 chrome.storage

怎么读这张图: 中间那个框(核心)两边都一样;差别全在下面两片"外围"。左边 PageAgent 给核心了遮罩和 Panel;右边 MultiPageAgent 把核心的 pageController 成一个跨 tab 的代理,再补上多 tab 工具。

各外围件一句话职责:

外围件属于哪圈干什么文件
SimulatorMask视觉反馈自动化时盖遮罩、画 AI 光标、拦真人输入packages/page-controller/src/mask/SimulatorMask.ts
PageAgent视觉反馈单页入口:默认开遮罩 + 挂 Panelpackages/page-agent/src/PageAgent.ts
Panel视觉反馈控制面板 UI,经 PanelAgentAdapter 与 agent 解耦packages/ui/src/panel/Panel.ts
MultiPageAgent跨页/外部扩展入口:复用核心,换成跨 tab 底盘packages/extension/src/agent/MultiPageAgent.ts
RemotePageController跨页/外部agent 侧的 DOM 代理,经消息打到远端 tabpackages/extension/src/agent/RemotePageController.ts
TabsController跨页/外部管理一组 tab:开/切/关、镜像状态packages/extension/src/agent/TabsController.ts
tabTools跨页/外部给核心注入 open/switch/close tab 三个工具packages/extension/src/agent/tabTools.ts
MCP server外部控制让外部 agent 客户端下发 execute_taskpackages/mcp/src/index.js

第一圈:运行时视觉反馈

3. SimulatorMask:自动化时的"挡风玻璃"

3.1 它要解决的小问题

agent 正在替你点页面。此刻有两个矛盾的需求:

  1. 要让 DOM 上真的发生点击——否则动作落不下去。
  2. 又不能让真人的手同时乱动——你手一抖点了别处,agent 的世界模型就和真实页面对不上了。

SimulatorMask 用一个巧办法同时满足:盖一层透明遮罩层罩住整页,平时它 pointerEvents: auto 吃掉所有真人输入;需要"真的点下去"时再瞬间放行。

3.2 三件事:拦输入、画光标、自我排除

① 拦住真人的鼠标键盘。 遮罩 wrapper 上挂了一整排事件监听,click / mousedown / mouseup / mousemove / wheel / keydown / keyup 全部 stopPropagation + preventDefault——真人的操作到这层就被"吃掉",传不到底下的页面。

引用 SimulatorMask.ts:44-71(构造函数里的一排 addEventListener):真人的每一类指针/键盘事件都在遮罩层被拦截并阻止冒泡。

② 画一个会动的 AI 光标。 遮罩里有一个 #cursor 元素,用 requestAnimationFrame 每帧朝目标位置插值移动(而不是瞬移),看起来像真手在滑动。

下面这段是简化示意,非源码,演示"缓动跟随"的核心:

// 示意,非源码:每帧只走剩余距离的 20%,于是"减速滑向"目标
function moveCursorToTarget() {
const newX = currentX + (targetX - currentX) * 0.2 // 关键:0.2 的缓动系数
const newY = currentY + (targetY - currentY) * 0.2
cursor.style.left = newX + 'px'
cursor.style.top = newY + 'px'
currentX = newX; currentY = newY
requestAnimationFrame(moveCursorToTarget) // 下一帧继续逼近
}

真实实现见 SimulatorMask.ts:133-160#moveCursorToTarget——同样是 * 0.2 的缓动,并在距离 < 2px 时吸附到目标。点击时 triggerClickAnimation(SimulatorMask.ts:169-176)先移除再强制 reflow(void this.#cursor.offsetHeight)重加 class,好让 CSS 动画能重复触发。

③ 自我排除:遮罩自己别被 agent 看见。 遮罩本身也是 DOM 里的元素。如果 DOM 脱水管线把它也编号进去,agent 就会看到一个巨大的"可点元素"盖住整页——荒谬。所以遮罩 wrapper 一创建就打上自我排除标记。

引用 SimulatorMask.ts:28-29:

this.wrapper.setAttribute('data-browser-use-ignore', 'true')
this.wrapper.setAttribute('data-page-agent-ignore', 'true')

DOM 提取引擎在遍历时正是靠这两个属性把它连同子树整个跳过。见 dom/dom_tree/index.js:1487 附近:节点若 dataset.browserUseIgnore === 'true'dataset.pageAgentIgnore === 'true' 就返回 null,"跳过该节点及其子树"。这就把"视觉反馈层"和"被 agent 感知的页面"干净地隔开了。

3.3 关键矛盾:遮罩既要拦人、又要让 agent 提取 DOM

这是全章最精妙的一处。遮罩平时 pointerEvents: auto 罩住整页——可 DOM 脱水需要 document.elementFromPoint(x,y) 之类的命中测试去探页面,遮罩挡在最上层会把每次命中都测到遮罩自己身上。

解法是临时放行(pass-through):提取/命中的一瞬间把遮罩切成 pointerEvents: none,让射线穿过去打到真实元素,测完立刻切回 auto。这个切换由 PageController.updateTree() 亲自兜底。

引用 PageController.ts:179-214 updateTree():提取前 this.mask.wrapper.style.pointerEvents = 'none',dom.getFlatTree(...) 跑完后再 = 'auto' 恢复拦截。这样"提取 DOM"和"拦住真人"就不打架了。

同一个放行在单次点击的命中测试里也用到。actions.ts:87-89:点击落地前先 enablePassThrough(),doc.elementFromPoint(x,y) 拿到最深命中元素,再 disablePassThrough()。这两个函数并不直接改样式,而是派发自定义事件解耦(见下)。

3.4 解耦:动作层不认识遮罩,只发事件

注意一个刻意的设计:干活的 actions.ts(点击/滚动)和 SimulatorMask 互不 import。动作层只往 window 派发自定义事件,遮罩自己监听。

actions.ts / PageController window 事件总线 SimulatorMask
movePointerToElement(el,x,y) ──派发──▶ 'PageAgent::MovePointerTo' ──监听──▶ setCursorPosition
clickPointer() ──派发──▶ 'PageAgent::ClickPointer' ──监听──▶ triggerClickAnimation
enablePassThrough() ──派发──▶ 'PageAgent::EnablePassThrough'──▶ pointerEvents='none'
disablePassThrough() ──派发──▶ 'PageAgent::DisablePassThrough'─▶ pointerEvents='auto'

派发端见 packages/page-controller/src/utils/index.ts:58-80(四个 dispatchEvent);监听端见 SimulatorMask.ts:85-102(四个 window.addEventListener)。源码注释点破动机:"Mask should be isolated from the rest of the code. Global events are easier to manage and cleanup."(SimulatorMask.ts:82-83)。遮罩因此是一块可插拔、可整体 dispose 的独立件。

4. PageAgent:把遮罩和 Panel 默认装上

核心 PageAgentCore无 UI、无遮罩的裸引擎。面向普通用户的 PageAgent 只做一件事:给核心配齐外围默认值。整个类不到 30 行。

引用 PageAgent.ts:13-28:

export class PageAgent extends PageAgentCore {
panel: Panel
constructor(config: PageAgentConfig) {
const pageController = new PageController({
...config,
enableMask: config.enableMask ?? true, // ① 默认开遮罩
})
super({ ...config, pageController })
this.panel = new Panel(this, { // ② 默认挂 Panel
language: config.language,
promptForNextTask: config.promptForNextTask,
})
}
}

两处默认值就是"外围壳"的全部:

  • enableMask ?? true —— 单页产品默认开遮罩PageController 收到后在构造时 if (config.enableMask) this.initMask(),用动态 import 异步加载 SimulatorMask(见 PageController.ts:96,102-108;动态 import 是为了避免在 Node 环境加载 CSS)。
  • new Panel(this, ...) —— 把 agent 自己传给 Panel。Panel 由此监听 agent 事件、渲染历史与状态。

遮罩的开关时机由核心循环兜底,而非 UI:PageAgentCore 在任务开跑时 await suppress(() => this.pageController.showMask())(PageAgentCore.ts:245),任务收尾时 hideMask()(PageAgentCore.ts:370)。suppress(...) 表示"遮罩失败也不该拖垮任务"——反馈是锦上添花,不是主干。

5. Panel:通过 PanelAgentAdapter 与核心解耦

Panel 是那块浮在页面角落的控制面板(状态灯、历史卡片、输入框、停止按钮)。它属于独立的 @page-agent/ui 包,刻意不 import PageAgent

它靠一个窄接口 PanelAgentAdapter 反向依赖 agent:Panel 只要求对方"是个 EventTarget,有 status/history/task,能 execute/stop/dispose",别的一概不知。

引用 packages/ui/src/panel/types.ts:30 PanelAgentAdapter:它 extends EventTarget,声明 statuslastResulthistorytask、可选的 onAskUser 回调,以及 execute/stop/dispose 三个方法。Panel 的构造函数 Panel.ts:65 只吃这个接口类型的 agent,通过 addEventListener('statuschange' | 'historychange' | 'activity' | 'dispose') 订阅更新(Panel.ts:84-87)。

这个解耦的收益,在下一节的扩展产品里立刻兑现:

单页 PageAgent扩展 MultiPageAgent
UI 反馈内置 Panel(DOM 浮层)扩展自己的 React 侧边栏 UI
二者关系都只需满足 PanelAgentAdapter / 事件契约换一套 UI 不用碰核心

i18n 也解耦:Panel 自带 I18n,语言由 config.language 传入(Panel.ts:68),UI 文案不写死在核心里。核心只产出结构化的历史/状态,"给人看的话术"留在 UI 层。


第二圈:跨标签页与外部控制

6. MultiPageAgent:同一个核心,换越野底盘

浏览器扩展要解决单页 agent 的天花板:任务经常跨多个标签页(开新 tab 查资料、切回来填表)。MultiPageAgent 的做法不是重写核心,而是给核心换三样外围

引用 MultiPageAgent.ts:24-99。构造时它组装:

// MultiPageAgent.ts:26-29(节选)
const tabsController = new TabsController() // 管一组 tab
const pageController = new RemotePageController(tabsController) // DOM 操作改走远端
const customTools = createTabTools(tabsController) // 多注入 3 个 tab 工具

然后调 super({...}) 时把它们塞进核心配置:

换的东西传给核心的字段作用
远端 DOM 控制器pageController: RemotePageController核心以为在操作"当前页",其实被代理到远端 tab
多 tab 工具customTools(open/switch/close_tab)agent 多了跨 tab 的手
扩展版系统提示customSystemPromptsystem_prompt.md?raw 载入,并按浏览器语言替换默认工作语言

核心的循环、反思、工具调度一行没改——它照常 getBrowserState → 想 → 调工具,只是"页面"和"工具集"被悄悄换了实现。这正是外延章的主旨:复用核心,替换外围

MultiPageAgent 还用核心暴露的生命周期钩子接管多 tab 时序(MultiPageAgent.ts:62-72):

  • onBeforeTask:tabsController.init(task, ...) 建立要跟踪的 tab 集。
  • onBeforeStep:每步先 syncTabs() 拉最新 tab 状态,再 waitUntilTabLoaded(currentTabId) 确保当前页加载完再让 agent 观察。

6.1 心跳/状态投影:跨上下文的"我还活着"

扩展里 agent 可能跑在侧边栏,而遮罩要显示在内容脚本里——两个上下文不共享内存,还随时可能被浏览器回收。MultiPageAgent 用一种最终一致的投影解决:把 agent 状态写进 chrome.storage,内容脚本去轮询

引用 MultiPageAgent.ts:85-98。agent 每次 statuschange:running 时起一个 1 秒一次的 agentHeartbeat 定时器,并写 isAgentRunning;停时清掉。源码注释直白说明为何要心跳:侧边栏被用户关闭时 unload 事件不可靠、Service Worker 生命周期难预测,所以"用心跳当兜底"(MultiPageAgent.ts:42-51)。

内容脚本这头(RemotePageController.content.ts:30-58)每 500ms 读一次 storage:只有 isAgentRunning && 心跳在 2 秒内新鲜 && currentTabId === 自己的 tabId 时才 showMask(),否则 hideMask()。于是"哪个 tab 该盖遮罩"完全由投影出来的状态驱动,不靠脆弱的长连接。

7. RemotePageController:把 DOM 操作代理到远端 tab

单页里 PageController 直接操作 document。扩展里内容脚本才碰得到目标页面的 DOM,而 agent 在另一个上下文。RemotePageController 就是 agent 侧的替身:接口和 PageController 一样,但每个方法都打成一条消息发给 Service Worker,由它转交目标 tab 的内容脚本执行。

agent 侧(侧边栏/内容脚本) Service Worker 目标 tab 的内容脚本
RemotePageController (background) 真 PageController
clickElement(i) ──sendMessage──▶ PAGE_CONTROL 路由 ──▶ pc.clickElement(i) 真点
updateTree() ──sendMessage──▶ targetTabId 转发 ──▶ pc.updateTree() 真提取
getBrowserState ──sendMessage──▶ ... ──▶ 返回该 tab 的页面文本

引用 RemotePageController.ts:26(class)与 :145-164 remoteCallDomAction:每个 DOM 动作都封成 { type:'PAGE_CONTROL', action, targetTabId: this.currentTabId, payload }chrome.runtime.sendMessage 发出。updateTreegetBrowserStateclickElement 等各自是薄封装(:89-99:58-87:113-118)。

几个"代理"特有的细节:

  • 点击后强制等待。 clickElement 发完消息后 await 1 秒,"以防页面导航,等它开始加载"(RemotePageController.ts:113-118)——跨上下文拿不到同步的导航回调,只能保守等。
  • 遮罩方法在这头是空操作。 showMask/hideMask/dispose 全是空实现,注释 "Managed by content script via storage polling."(:138-143)——因为遮罩由第 6.1 节的 storage 轮询驱动,不归 agent 侧管。
  • execute_javascript 故意不实现。 注释:"AbortSignal cannot cross context"(:136),所以扩展版核心也把 experimentalScriptExecutionTool 关掉(MultiPageAgent.ts:57)。
  • 受限页面守门。 isContentScriptAllowed(url)(:175-192)挡掉 chrome://file://view-source: 等注入不了内容脚本的页;命中就直接回一句"去 open_new_tab 打开一个网页先"。

8. TabsController:管理一组标签页

RemotePageController 只回答"当前是哪个 tab、往它发消息";"当前 tab 是谁、还有哪些 tab"TabsController 管。它同样不直接调 chrome API,而是发 TAB_CONTROL 消息给 Service Worker,自己在内存里维护一份 tab 镜像。

引用 TabsController.ts:38(class)。核心职责:

能力方法要点
建立跟踪集init (:52)抓当前 active tab,可选把初始 tab 收进一个标签组(染色、命名 PageAgent(task))
开新页openNewTab (:132)开 tab → 设为 current → 加入标签组 → 等加载完
切/关switchToTab/closeTab (:169/:182)关不掉"初始 tab"(:189 显式拒绝)
汇报给 LLMsummarizeTabs (:278)把 tab 列表渲染成 markdown 表格塞进 browser state 的 header
侦测变化syncTabs (:320)每步拉一次窗口 tab 列表,增删对齐镜像

一个刻意的架构取向:拉,不推。 syncTabs 主动去"拉"当前窗口的 tab 快照来对齐,而不是让后台"推"事件过来。源码注释点破:"Pulling is better than pushing. Long-lived ports are stateful troublemakers."(TabsController.ts:316-319)。它还顺手模拟"像用户一样跟随":如果发现 agent 新开了 tab,就自动把 current 切到最新那个(:361-373)。

summarizeTabs 产出的 tab 表被 RemotePageController.getBrowserState() 拼到页面内容前面(RemotePageController.ts:81-82),于是 agent 每步都"看得见"自己手上有哪些 tab、当前在哪个——这就是它敢调 switch_to_tab 的信息来源。

9. tabTools:给核心多长三只手

agent 怎么"知道"能开关 tab?靠给核心的 customTools 注入三个工具。createTabTools(tabsController) 返回 open_new_tab / switch_to_tab / close_tab,每个都是 { description, inputSchema(zod), execute } 三件套,execute 里直接调对应的 TabsController 方法并把成败包成给 LLM 看的字符串。

引用 tabTools.ts:24 createTabTools。这些工具和第 3 章讲的核心工具走同一条工具通道——核心不区分"内置工具"还是"扩展注入的工具",一视同仁地调度。所以"跨 tab"对核心而言只是多了几个普通工具,循环逻辑无需感知。

10. MCP:让外部 agent 客户端遥控浏览器

最外一层:让别的 AGENT 客户端(Claude Desktop、Cursor 等 MCP 宿主)来驱动你的浏览器。packages/mcp 是一个 stdio 上的 MCP server。

引用 packages/mcp/src/index.js:35-96。它注册三个工具给外部客户端:

MCP 工具作用
execute_task (:37)收一段自然语言任务,交给浏览器里的扩展执行,回传结果
get_status (:72)报告 hub 连接/忙碌状态
stop_task (:87)发停止信号

它自己不跑 agent,而是通过 HubBridge(packages/mcp/src/hub-bridge.js)搭一个 HTTP + WebSocket 桥:HTTP 端提供一个 launcher 页面触发扩展打开 hub tab,WS 端把 execute_task 命令送进扩展、把结果传回来(hub-bridge.js:15-46)。也就是说,真正干活的还是扩展里那个复用了核心的 MultiPageAgent——MCP 只是最外圈的遥控器

端到端串起来看:

外部 AGENT 客户端 ──MCP(stdio)──▶ page-agent MCP server ──WS──▶ 扩展 hub

MultiPageAgent(复用核心)
│ RemotePageController

真实浏览器多个 tab

11. 边界与局限(诚实)

  • execute_javascript 在扩展里不可用。AbortSignal 跨不了上下文,扩展版关掉脚本执行工具(MultiPageAgent.ts:57RemotePageController.ts:136)。单页版则可用。
  • 遮罩不是安全边界。 它靠一层 DOM 浮层 + 事件拦截"劝退"真人输入,并非不可穿透;data-*-ignore 也只是约定,页面若也用同名属性会被一并跳过。
  • 状态投影是"最终一致",有窗口期。 心跳 1 秒一跳、内容脚本 500ms 轮询、心跳 2 秒算过期(RemotePageController.content.ts:30-38),所以遮罩显示/消失可能有几百毫秒延迟——这是刻意用轮询换"不依赖脆弱长连接"的取舍。
  • 多 tab 有跟踪边界。 受限页面(chrome://file:// 等)进不了跟踪集(isContentScriptAllowed);experimentalIncludeAllTabs 是实验开关,默认只跟初始 tab + agent 自己开的 tab。
  • 本章不覆盖核心内部。 循环反思、DOM 脱水、动作落地、LLM 容错的机制,请分别看 01/02/03/04

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

主题文件路径符号名
遮罩:拦输入 + 画光标 + 自我排除packages/page-controller/src/mask/SimulatorMask.tsSimulatorMask#moveCursorToTargettriggerClickAnimation
提取时临时放行遮罩packages/page-controller/src/PageController.tsupdateTree(pointerEvents 切换)
遮罩开关与懒加载packages/page-controller/src/PageController.tsinitMaskshowMaskhideMask
命中测试时放行packages/page-controller/src/actions.tsclickElementenablePassThrough/disablePassThrough
遮罩事件派发端packages/page-controller/src/utils/index.tsmovePointerToElementclickPointerenablePassThrough
DOM 提取跳过 ignore 节点packages/page-controller/src/dom/dom_tree/index.jsbrowserUseIgnore/pageAgentIgnore 判断(:1487)
单页入口:默认遮罩 + Panelpackages/page-agent/src/PageAgent.tsPageAgent
核心开关遮罩时机packages/core/src/PageAgentCore.tsshowMask(:245)、hideMask(:370)
Panel 与 agent 解耦接口packages/ui/src/panel/types.tsPanelAgentAdapter
Panel 主体packages/ui/src/panel/Panel.tsPanel
扩展入口:复用核心换底盘packages/extension/src/agent/MultiPageAgent.tsMultiPageAgent、心跳投影(statuschange)
DOM 操作跨 tab 代理packages/extension/src/agent/RemotePageController.tsRemotePageControllerremoteCallDomActionisContentScriptAllowed
内容脚本:轮询驱动遮罩packages/extension/src/agent/RemotePageController.content.tsinitPageController
标签页集合管理packages/extension/src/agent/TabsController.tsTabsControllersyncTabssummarizeTabsopenNewTab
注入多 tab 工具packages/extension/src/agent/tabTools.tscreateTabTools
MCP 外部控制packages/mcp/src/index.jsexecute_taskget_statusstop_task
MCP↔扩展桥packages/mcp/src/hub-bridge.jsHubBridge