AIRI Desktop Grounding 只读 Chrome 扩展:从 DOM 观察到 macOS 桌面坐标映射的落地实现
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
本篇技术指南以 AIRI 仓库中 chrome-extension/README.md 为骨架,结合仓库源码(background.js、msg_bridge.js、content.js及computer-use-mcp服务端的 extension bridge、grounding 层)深入讲解:AIRI 如何通过一枚 Manifest V3 Chrome 扩展,以纯只读方式收集浏览器所有 frame 内的可交互元素,并把它对接进 desktop grounding 快照解析体系,最终让 AIRI Agent 用真实的 macOS 系统级输入事件(CGEvent)完成点击、输入等操作。读完本文,你将掌握这套「DOM 观察桥 + WebSocket 桥 + 桌面坐标映射」的完整链路,以及每一层的源码级实现细节。
一、这是什么:一枚「只观察、不操作」的 DOM 桥
AIRI Desktop Grounding Chrome 扩展定位为桌面 grounding 层的只读 DOM 观察桥(Read-only DOM observation bridge)。它解决的核心问题不是"怎么点击网页",而是"网页里到底有哪些东西可以点、它们在哪里"。
它的职责被明确限定为三件事:
- 从当前活动 Chrome 标签页的**所有 frame(含跨域 iframe)**中收集可交互元素(按钮、链接、输入框等);
- 上报元素的位置、ARIA role、文本与 rect 坐标;
- 把这些数据喂给 desktop grounding 的 snap resolver,用于后续的坐标映射。
与"职责"同样重要的是"不做什么"。原文档明确列出四条红线:
- ❌ 不做任何 DOM 变更(不在 DOM 元素上 click / typing / scrolling);
- ❌ 不使用
eval、new Function、chrome.scripting.executeScript; - ❌ 不发起任何外部网络请求(没有 Python bridge,没有 offscreen documents);
- ❌ 没有 popup UI。
所有用户交互由 desktop grounding executor 通过**真实的 macOS 系统级输入事件(CGEvent)**执行。也就是说,这条链路上"看"与"做"被彻底分离:扩展只负责看,动手的事交给 macOS 原生输入层。这与仓库主 README(services/computer-use-mcp/README.md)中"区分 desktop 控制与 browser DOM 控制,而不是什么都用盲点击"的设计原则完全一致。
二、三层架构:两个隔离世界之间的纯中继
原文档给出了简洁的架构图,结合源码可以还原出完整的消息流:
background.js (Service Worker) ↕ chrome.tabs.sendMessage msg_bridge.js (ISOLATED world) ↕ window.postMessage content.js (MAIN world, window.__AIRI_DG__)理解这套三层结构的关键在于 Chrome 扩展的**隔离世界(isolated worlds)**机制:
chrome.runtime.onMessage只能在ISOLATED world被接收;- 而
window.__AIRI_DG__运行在MAIN world(它需要直接访问真实 DOM,getBoundingClientRect、querySelectorAll等都依赖页面上下文); - 两个世界无法直接共享作用域,只能通过
window.postMessage通信。
于是msg_bridge.js就成了一名纯粹的"信使"(msg_bridge.js):它接收来自 background 的CU_ACTION消息,为每个请求分配reqId(__cu_req_${++seqId}),通过window.postMessage({ type: '__CU_CALL__', reqId, method, args }, '*')转发给 MAIN world 的content.js;content.js 执行完方法后回发__CU_REPLY__,bridge 再通过sendResponse把结果送回 background。如果 8 秒内没有回复,bridge 会清掉挂起请求并返回{ success: false, error: 'timeout' }。
之所以要绕这一圈,而不是让 background 直接调用__AIRI_DG__,正是两个 world 的边界决定的——这也解释了为什么 manifest 中需要注册两个 content script。
Manifest 关键配置
manifest.json 是 MV3 规范,值得逐项拆解:
{ "manifest_version": 3, "name": "AIRI Desktop Grounding Bridge", "permissions": ["activeTab", "tabs", "webNavigation"], "host_permissions": ["<all_urls>"], "background": { "service_worker": "background.js" }, "content_scripts": [ { "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle", "all_frames": true, "match_about_blank": true, "world": "MAIN" }, { "matches": ["<all_urls>"], "js": ["msg_bridge.js"], "run_at": "document_idle", "all_frames": true, "match_about_blank": true, "world": "ISOLATED" } ] }要点说明:
world: "MAIN"/"ISOLATED":同一套matches下注入两个脚本,分别落在两个世界,这是上述架构的声明式基础;all_frames: true+match_about_blank: true:保证about:blank与嵌套 iframe 内也能注入,配合webNavigation权限才能拿到完整 frame 树;- 权限最小化:只申请
activeTab、tabs、webNavigation,没有scripting权限——从权限层面就杜绝了executeScript等 DOM 注入能力,与"只读"定位互为印证(background.js 头部注释也明确记录了从原扩展剥离executeScript等命令的过程)。
三、content.js:MAIN 世界里的只读观察 API
content.js 在 MAIN world 暴露window.__AIRI_DG__命名空间(版本号1.0-airi-dg),并用 IIFE +if (window.__AIRI_DG__) return防止重复注入。
元素描述器_describeElement
这是整个观察体系的最小信息单元,返回的字段直接决定了 Agent 能看到什么:
{ tag, id, name, type, className, // 截断到 120 字符 text, // textContent 截断到 120 字符 value, // 截断到 60 字符 href, placeholder, role, disabled, checked, visible, // rect 宽高 > 0 rect: { x, y, w, h } // getBoundingClientRect() 取整 }注意visible只做最朴素的宽高判空,不涉及遮挡检测——这是成本与精度的权衡,后续由 grounding 层的置信度与去重逻辑兜底。
可交互元素收集_collectInteractiveElements
默认上限MAX_INTERACTIVE = 200,选择器覆盖:
a, button, input, textarea, select, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], [onclick], [tabindex]收集时只保留visible元素,返回按文档顺序截断到上限。
只读方法族(相对 README 表格更全)
原文档的命令表列出了 7 个命令,而从源码看__AIRI_DG__实际还提供readInputValue与getComputedStyles(连同waitForElement在 background 层实现,共 10 个)。逐一说明:
| 方法 | 行为 |
|---|---|
collectFrameDOM(opts) | 返回当前 frame 的url、title、frameName、frameOffsetInParent、bodyText(默认取 body 文本前 3000 字符,可includeText: false关闭)与interactiveElements |
collectChildFrames() | 描述当前文档内的iframe/frame外壳(index、id、name、title、src、contentUrl、rect),供 background 重建子 frame 视口偏移 |
findElement(selector) | 单元素查找,返回_describeElement描述 |
findElements(selector, max) | 多元素查找,默认上限 10 |
getClickTarget(selector) | 返回元素描述 + 中心点坐标(x = left + width/2) |
getElementAttributes(selector) | 遍历el.attributes返回全部属性(调试用) |
readInputValue(selector) | 只读 input/textarea/select 当前值;密码框自动脱敏为[redacted],超 60 字符标记valueTruncated,checkbox/radio 附带checked,select 附带selectedIndex/selectedText |
getComputedStyles(selector, properties) | 读取计算样式;不传properties时返回受控默认集(display、visibility、opacity、position、width、height、color、font-size、overflow、z-index、pointer-events、cursor 等 14 项),避免倾倒整个 CSSStyleDeclaration |
collectFrameDOM里的frameOffsetInParent很关键:顶层 frame 返回{x:0, y:0};子 frame 尝试通过window.frameElement.getBoundingClientRect()直接拿到自己在父文档中的位置,跨域访问失败时返回null(此时回退到 background 的启发式匹配,见下一节)。
四、background.js:Service Worker 里的调度中枢
background.js 承担了三类职责:WebSocket 桥接、命令路由、跨 frame 坐标还原。
WebSocket 桥与重连退避
扩展通过ws://127.0.0.1:8765直连computer-use-mcp的本地 WebSocket 服务(服务端实现见 extension-bridge.ts)。连接建立后立即发送hello握手:
const AIRI_BRIDGE_HELLO = { type: 'hello', source: 'airi-chrome-extension', version: '1.1.0', }服务端收到hello后会记录lastHello(source、version、connectedAt),作为桥接状态的一部分。断线后采用指数退避重连:从BRIDGE_RECONNECT_MIN_MS = 1000起,每次翻倍,封顶BRIDGE_RECONNECT_MAX_MS = 10000;onStartup、onInstalled与模块加载时都会主动触发ensureBridgeConnected()。
请求采用id 配对:background 为每个命令生成id,sendBridgePayload发送{id, action, ...payload},收到{id, ok, result}(或{id, ok:false, error})后按 id 匹配解决。单次命令超时SEND_CU_ACTION_TIMEOUT_MS = 8000。
命令路由:内外两个入口
handleCommand以switch(action)分发全部命令,入口有两个:
chrome.runtime.onMessage收到{ type: 'AIRI_DG_COMMAND', data }(AIRI 桌面端直接调用);{ type: 'ws-incoming', data }(兼容旧版 WebSocket 桥格式,响应通过ws-send回发)。
这解释了原文档"background.js (Service Worker)"一层的落点:它既可以是 WebSocket 服务端的对端,也可以作为 runtime 消息入口,两种通道最终都汇聚到同一套handleCommand。
所有 DOM 查询命令都经由runCUAction→sendCUAction走向chrome.tabs.sendMessage(tabId, { type: 'CU_ACTION', method, args }, { frameId }, callback),向指定 tab + frame投递;sendMessage的frameId参数与webNavigation.getAllFrames枚举出的 frame 树配合,实现"全 frame 广播"(runCUAction缺省 frameIds 时自动枚举全部 frame,Promise.all并发执行)。
跨域 iframe 的坐标还原(本文最精妙的部分)
webNavigation.getAllFrames只能给出 frame 树的父子关系与 URL,Chrome 并不暴露 iframe 在父文档中的屏幕位置(background.js 注释直言这是浏览器限制)。AIRI 的解法是两段式:
- 壳元素锚点收集:对每个目标子 frame 的父 frame 调用
collectChildFrames(即 content.js 的_collectChildFrames),拿到父文档中所有 iframe 壳的 rect; - 启发式最佳匹配:
pickBestChildAnchor用加权打分把 frame 树节点匹配回父文档中的 iframe 壳:contentUrl与子 frame URL 完全一致:+100;src一致:+90;子 URL 以 src 开头:+70;- frame
name与壳name一致:+40; - 标题一致:+15;
- 唯一子 frame 且父文档只有一个壳:+10;
- 得分 > 0 才算命中。
之后buildFrameOffsets用带缓存的递归从 frame 0(视口原点)向下逐层累加:子 frame 的绝对偏移 = 父 frame 的绝对偏移 +(本 frame 的frameOffsetInParent或匹配到的壳 rect 原点)。这样即使跨域 iframe 拿不到frameElement,也能通过 URL/name/title 的相似度推断出它在页面里的物理位置。
等待元素waitForElement
background 层实现了一个轮询式等待:以 500ms 为间隔反复调用各 frame 的findElements(selector, 1),直到任一 frame 命中或超时;超时时间被钳制在 500ms~30s 之间,剩余预算会透传给每帧的 sendMessage 超时(对应服务端WAIT_FOR_ELEMENT_BRIDGE_TIMEOUT_GRACE_MS = 1000的宽限设计),注释特别说明这样做是为了避免慢帧额外吃掉一整个 send-message 超时。
五、服务端:extension bridge 与桌面 grounding 快照
WebSocket 服务端(computer-use-mcp 侧)
extension-bridge.ts 的BrowserDomExtensionBridge监听ws://127.0.0.1:8765(默认),维护pending请求表:每个请求登记{resolve, reject, timeoutId},通过randomUUID()生成的 id 与扩展端握手。它内置SUPPORTED_ACTIONS白名单(与本文介绍的命令一一对应),对不在白名单的 action 直接拒绝,且连接断开时统一 reject 所有挂起请求。
服务端还提供clickSelector组合操作:先调getClickTarget拿到元素中心点,再把它包装成clickAt坐标命令。注意——扩展本身并不实现clickAt(它不在只读白名单内),这套坐标会在更上层交给桌面 executor 以 macOS 系统事件执行,形成"DOM 定位 + OS 事件触发"的分工。
配置通过环境变量控制(详见 services/computer-use-mcp/README.md):
| 环境变量 | 默认值 | 作用 |
|---|---|---|
COMPUTER_USE_BROWSER_DOM_BRIDGE_ENABLED | true | 开关 |
COMPUTER_USE_BROWSER_DOM_BRIDGE_HOST | 127.0.0.1 | 监听地址 |
COMPUTER_USE_BROWSER_DOM_BRIDGE_PORT | 8765 | 监听端口 |
COMPUTER_USE_BROWSER_DOM_BRIDGE_TIMEOUT_MS | 10000 | 请求超时 |
若改动了 HOST/PORT,需要用chrome.storage.local.set({ browserDomBridgeHost, browserDomBridgePort })在扩展侧同步,让 background 重连到正确的 socket。
语义适配与屏幕坐标映射
chrome-semantic-adapter.ts 把扩展上报的页面相对坐标变换为屏幕绝对坐标:
- 优先走扩展桥(
captureViaExtension,数据更丰富、无需--remote-debugging-port),失败时回退 CDP 桥; - 坐标变换:
screenX = windowBounds.x + frameOffsetX + rect.x,screenY = windowBounds.y + 88 + frameOffsetY + rect.y; - 其中
CHROME_CHROME_HEIGHT_PX = 88是对 Chrome 浏览器外壳(标签栏 + 地址栏 + 书签栏)高度的启发式估计,注释明确说明实际值随缩放、书签栏显隐而变化,v1 阶段以常量近似; - 明显越界(完全在窗口范围外)的元素被过滤;
- 同时为每个元素构建最佳 CSS selector(优先级
#id > [name] > tag[type] > tag.className),供后续 DOM 级精确操作复用; - 置信度打分:button/a/显式交互 role 为 0.95,表单控件 0.9,checkbox/radio 0.85,禁用元素降到 0.3,其余 0.7。
进入桌面 grounding 快照
desktop-grounding.ts 是聚合层:captureDesktopGrounding并行执行截图、窗口观察(observeWindows)、AX 树捕获,并在 Chrome 处于前台时并入语义数据(captureChromeSemantics),最终产出统一的DesktopGroundingSnapshot:
- 候选融合与去重:
buildTargetCandidates把chrome_dom候选排在最前(源排序chrome_dom > ax > vision > raw),当 chrome_dom 候选与 AX 候选的边界框 IoU 重叠超过70%时删除 AX 重复项(chrome_dom 信息更丰富); - 快照文本化:
formatGroundingForAgent生成对 LLM 友好的紧凑文本——前台应用、快照 id 与时间、过期警告(截图/AX/Chrome 语义超过 2s 阈值标记stale)、目标候选表([id] source role "label" @(x,y w×h) conf=0.xx),最多列 40 个候选; - 过期标记:
STALENESS_THRESHOLD_MS = 2000,超过该时长的子快照被标记,提示 Agent 该数据可能已失真。
六、开发模式安装步骤
按原文档的流程即可加载:
- 打开
chrome://extensions/; - 开启右上角Developer mode(开发者模式);
- 点击Load unpacked(加载已解压的扩展程序);
- 选择本仓库的
services/computer-use-mcp/chrome-extension/目录; - 扩展会自动注入所有页面(
<all_urls>+all_frames)。
随后启动computer-use-mcp服务(pnpm -F @proj-airi/computer-use-mcp start),扩展的 background service worker 会自动连上ws://127.0.0.1:8765并完成 hello 握手。可以用 extension-bridge.test.ts 中的测试思路做端到端自检:该测试启动真实 WebSocketServer + 客户端,先发 hello 握手,再用 mock action 验证请求 id 配对、超时拒绝、ok:false错误传播等行为。
七、安全边界与已知限制
把整条链路合起来看,安全模型是层层递进的:
- 扩展层只读:无 DOM 变更、无
eval/executeScript、无外部请求、无 popup,从权限声明到方法面双重限制; - 交互走 OS 事件:点击、输入由 macOS
CGEvent注入,且受computer-use-mcp的策略模型约束——点击/输入/滚动默认走 per-action 审批,denyApps仍拦截敏感前台应用(默认含1Password、Keychain、System Settings、Activity Monitor、AIRI自身),终端命令与应用打开/聚焦必须审批; - bridge 白名单:服务端
SUPPORTED_ACTIONS兜底,未知 action 直接报错。
已知限制(仓库 README 明示):v1 主链路仅 macOS;全局坐标被允许,因此安全边界依赖"审批 + 审计"而非严格的应用隔离;CHROME_CHROME_HEIGHT_PX = 88是启发式常量;iframe 匹配是相似度启发式而非精确几何定位。这些都意味着该扩展只解决"观察与定位"这一环,真正把页面状态变成可控、可审计、可追溯的 Agent 操作,靠的是computer-use-mcp整套执行与审批基座。
八、小结:一条可复用的"浏览器 → 桌面"接地链路
AIRI 的这枚 Chrome 扩展展示了一个值得借鉴的模式:用最小权限的只读观察桥把浏览器 DOM 变成可寻址的目标空间,再用 OS 级输入事件完成真实交互。三层架构(Service Worker / ISOLATED 中继 / MAIN 观察 API)、双 world 的 postMessage 接力、基于 frame 树的启发式坐标还原、以及扩展桥与 grounding 快照的语义适配,共同构成了一条从"网页里有什么"到"它在屏幕哪里"再到"如何以真实输入触达"的完整链路。读者可以在仓库中顺着 chrome-extension 目录逐文件对照阅读,并配合 extension-bridge.ts、chrome-semantic-adapter.ts、desktop-grounding.ts 三个服务端模块,把这条链路的每一环吃透。
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考