状态:已实现,位于
plugins/browser(服务端)与apps/web/src/components/browser(界面)· 目标:做到 Claude Desktop 内置浏览器的体验——Agent 和用户共用一个浏览器,用户在侧边面板里实时看 Agent 操作,随时可以接手——同时绕开 Tauri 子 webview 的层级问题。
Tauri 2 的 Window::add_child(unstable feature)会创建一个操作系统级的子窗口(Windows 上是 WebView2 的 HWND,macOS 上是 WKWebView 的 NSView)。它永远盖在主 webview 上面,HTML 的 z-index 管不到:
- 下拉菜单、Modal、Toast、命令面板,只要经过浏览器区域就会被挡住;
- macOS 和 Linux 的 WebKit 没有 CDP,Agent 只能注入 JS,截图和无障碍树都很弱;
- Web 模式(浏览器访问)、远程访问和 Android 都用不了。
所以浏览器不嵌进窗口,而是跑在服务端旁边:
| Tauri 子 webview | 本方案:服务端 Chromium + CDP 推流 | |
|---|---|---|
| 层级问题 | 有,需要隐藏/截图占位来绕开 | 没有:页面就是 DOM 里的一个 <canvas> |
| Agent 控制 | Windows 一般,mac/Linux 很弱 | 完整 CDP:无障碍树、真实输入事件、截图 |
| Web / 远程 / Android 客户端 | 不支持 | 支持(画面走已鉴权的 WebSocket) |
| 依赖 | unstable API,三个平台行为不同 | 本机 Chrome/Edge/Chromium,或插件下载的 Chrome for Testing |
flowchart LR
subgraph Server["Prometheus 服务端(Node)"]
Tools["Agent 工具<br/>browser_*"]
Core["BrowserCore<br/>进程 / 标签 / 推流 / 输入 / 下载"]
Engine["快照引擎<br/>(复用 computer-use)"]
Stream["ctx.stream<br/>plugin.event 帧"]
end
Chrome["Chromium<br/>--headless=new<br/>--remote-debugging-pipe"]
subgraph UI["应用界面(Web / 桌面 / 手机)"]
View["BrowserView<br/>canvas + 隐藏 textarea"]
end
Tools --> Core
Tools --> Engine
Core <-- "CDP(fd3/fd4 管道)" --> Chrome
Core --> Stream -- "二进制 JPEG 帧" --> View
View -- "plugin.call: input / navigate / ..." --> Core
- 一个浏览器,两种使用者:用户通过面板看画面、发输入;Agent 通过工具读无障碍树、按 ref 操作。两者看到的是同一个页面。
- 独立配置目录:
<home>/plugin-data/browser/profile,与用户日常的浏览器完全隔离;登录状态和 Cookie 在重启后保留。 - CDP 走管道(
--remote-debugging-pipe),不开调试端口,本机其他进程无法接管这个浏览器。
原来插件只有 rpc(一问一答)和 notifyChanged(只通知、不带数据)。画面需要服务端主动推送,所以 SDK 新增:
ctx.stream.publish(topic, data, { binary?: Uint8Array, droppable?: boolean });
ctx.stream.watchers(topic); // 当前有几个客户端在看
ctx.stream.onWatch((topic, count) => { ... }); // 观看人数变化协议:
- 客户端发
{ type: "plugin.watch", plugin, topic }/plugin.unwatch;服务端只把plugin.event发给订阅了该 topic 的客户端,断线重连后客户端自动重新订阅。 - 带
binary的消息走 WebSocket 二进制帧:[u32 头长度][头 JSON][原始字节](packages/protocol/src/binary.ts),不做 base64、不过 permessage-deflate。 - 背压:
droppable消息(视频帧)在客户端积压超过 2 MB 时直接跳过,慢网络只会掉帧,不会越积越多。 - 观看人数为 0 时插件停止截帧,没人看就不耗 CPU。
src/
cdp.ts CDP over pipe(NUL 分隔 JSON)、flatten 会话
chrome.ts 查找浏览器(自定义路径 → 插件下载的 → Chrome → Edge → Brave → Chromium)、启动参数、残留进程清理
install.ts 下载 Chrome for Testing(官方源 + npmmirror 镜像),纯 Node 解 zip(保留可执行位和 macOS 符号链接)
browser.ts BrowserCore:生命周期、标签、推流、用户输入、对话框、文件选择、下载、Agent 仲裁、空闲回收
tab.ts 单个页面:导航状态、对话框、file chooser、screencast、页内桥
page-script.ts 注入到隔离世界的脚本:光标、<select> 弹层、右键菜单、复制
snapshot.ts CDP 无障碍树 → computer-use 的节点模型(同源 iframe 嫁接、密码框标记)
agent.ts 工具的实现:快照 / 操作 / 差异 / 等待 / 截图 / 脚本
tools.ts 8 个运行时中立工具(ctx.tools.register)
keys.ts 按键定义与 "ctrl+shift+t" 组合键解析
settings.ts 设置校验
- 懒启动:打开面板或 Agent 第一次调用工具时才启动;默认
--headless=new。 - 自动挂接:
Target.setAutoAttach({ waitForDebuggerOnStart: true, flatten: true }),新页面(包括target=_blank和window.open)在运行前就挂上,注入脚本、UA、视口都先设置好再放行。注意被暂停的目标只有在恢复后才回答,所以初始化命令先全部发出、最后发runIfWaitingForDebugger、再一起等回复。 - 空闲回收:没人观看、Agent 不忙、超过
idleMinutes(默认 15 分钟)就关闭浏览器;下次启动恢复上次的标签(restoreTabs)。 - 服务端崩溃也不会留下孤儿进程:管道断开 Chromium 会自己退出;上次的 PID 记录在
browser.pid,启动前会清理,避免配置目录被占用导致新进程秒退。 - 「独立窗口」模式(仅本机客户端可选):以有界面的方式重启,标签原样恢复,面板继续同步显示,适合手动登录、过验证码。
- 只给当前标签开
Page.startScreencast(JPEG,画质与帧率可调,默认 70 / 30fps),通过推迟screencastFrameAck限制帧率。 - headless 下视口跟随面板尺寸(
Emulation.setDeviceMetricsOverride,含 DPR),总像素封顶 2560×1600。 - Screencast 只在画面变化时出帧,新观众加入时立刻补发最后一帧;静止页面先用
captureScreenshot出第一帧。
| 东西 | 做法 |
|---|---|
<select> 下拉 |
隔离世界脚本拦截 mousedown,把选项发给界面;界面画弹层,选中后在页面里设值并触发 input/change |
| 右键菜单 | 上报链接、图片、选中文字、是否可编辑;界面画菜单(新标签打开、复制链接、复制、粘贴、全选、前进后退刷新) |
| 鼠标光标 | 上报 cursor 样式,设置到 canvas 上 |
alert / confirm / prompt / beforeunload |
Page.javascriptDialogOpening → 界面弹卡片 → Page.handleJavaScriptDialog |
| 文件选择 | Page.setInterceptFileChooserDialog → 界面选文件 → 分片上传到服务端 → DOM.setFileInputFiles(只接受刚上传的文件) |
| 复制到剪贴板 | 页面 copy/cut 事件把文字发给界面,写入用户自己的剪贴板 |
| 下载 | Browser.setDownloadBehavior 落到下载目录,按建议文件名重命名;界面可在文件夹中显示、保存到本机、复制路径 |
页内桥用 Runtime.addBinding + 隔离世界(页面脚本看不到)。bindingCalled 需要开启 Runtime 域,而网站能探测到 Runtime 域被开启,所以只对正在被观看的那个标签开启;Agent 操作不依赖它。
界面把事件换算成页面的 CSS 坐标,按动画帧合并(连续的 mousemove 只留最后一个、wheel 累加)后批量发送:
- 鼠标、滚轮、触摸(点按 → 点击,拖动 → 滚动)走
Input.dispatchMouseEvent; - 键盘走隐藏的
<textarea>:普通按键 →dispatchKeyEvent;输入法组字过程 →Input.imeSetComposition(页面里实时显示拼音),上屏 →Input.insertText;粘贴读用户剪贴板 →insertText; - Mac 客户端按 Cmd 快捷键而浏览器跑在 Windows/Linux 上时自动换成 Ctrl;浏览器跑在 macOS 上时补上编辑命令(
selectAll、copy、undo…)。
- 禁止打开
chrome:、edge:、devtools:、chrome-extension:、javascript:等地址;地址栏里输入这些会变成搜索。 - Agent 工具受权限模式约束:
browser_snapshot、browser_screenshot、browser_wait是只读的;导航、操作、按键、脚本在默认模式下需要确认。browser_eval可在设置里关掉。 - 密码框的值不会进入快照(
password状态 + 掩码),快照文本还经过 computer-use 的密钥/验证码脱敏。 - 页面内容一律视为数据(工具提示词里明确告诉模型)。
- 去掉 headless 的自动化痕迹:不加
--enable-automation、--disable-blink-features=AutomationControlled、UA 里的HeadlessChrome改回Chrome;不伪造指纹。
| 工具 | 作用 |
|---|---|
browser_navigate |
打开网址或搜索词(newTab 可选),或 back / forward / reload / stop;等加载完,返回标题、URL 和一份简短快照 |
browser_tabs |
列出、新建、切换、关闭标签 |
browser_snapshot |
无障碍树:[e123] role "name" = "value" states;scope 展开子树、query 过滤;mode=text 读正文(offset 翻页) |
browser_act |
按 ref 执行一串操作,返回变化(与上次快照的 diff)而不是整页 |
browser_keys |
组合键(ctrl+a、shift+arrowdown)和任意文字 |
browser_screenshot |
可视区域、整页或单个元素的截图 |
browser_wait |
等文字出现/消失、URL 变化或加载完成 |
browser_eval |
在页面里执行 JS 表达式,返回 JSON |
browser_act 的操作:click、double_click、right_click、hover、focus、set_value、type、clear、toggle、select、expand、collapse、scroll、upload、accept/dismiss(ref=dialog)。
几个让 Agent 更稳的细节:
- 遮挡检测:点击前用
elementFromPoint检查目标是否被弹窗/横幅挡住,挡住就报出遮挡物,除非value="force"。 - 对话框不卡死:触发
alert的点击在对话框关闭前不会返回,所以所有输入都和「出现对话框」赛跑,出现就立即把对话框告诉 Agent。 - 新标签跟随:操作打开了新标签(
target=_blank)时,结果直接给出新标签的快照,后续默认在新标签上进行。 - 等页面安静:操作后若发生导航则等加载完,再连续读树直到两次一致(最多几次),diff 才不会漏掉刚渲染出来的内容。
- ref 稳定:ref 是 DOM 的
backendNodeId,元素活着就不变;页面跳转后旧 ref 会提示失效。 - 每个聊天记住自己用的标签;Agent 操作的标签会切到前台,用户能看到。
- Agent 的调用排队执行;执行期间面板加橙色边框,底部提示「Agent 正在操作 · 点击」,用户输入被挡住。
- 用户点「接管」:中止当前调用,之后 Agent 的浏览器工具都返回「用户已接管」,直到用户点「交还给 Agent」。
- Agent 每次点击,面板上在点击位置出现一个波纹,看得出它点了哪里。
右侧工作台新增「浏览器」标签(标题栏地球图标打开;Agent 使用浏览器时自动打开):
- 标签条(Agent 打开的标签带机器人图标、加载中转圈、中键关闭);
- 工具栏:后退 / 前进 / 刷新或停止、地址栏(HTTPS 锁)、下载、在系统浏览器打开、设置、加宽面板;
- 空白标签显示搜索页;崩溃页、启动中、未找到浏览器(可一键下载 Chrome 或填写路径)、已关闭等状态页;
- 设置:显示方式、搜索引擎、画质、帧率、代理、浏览器路径、下载目录、空闲关闭、恢复标签、允许执行脚本、清除浏览数据、关闭浏览器。
保存在 <home>/plugin-data/browser/settings.json,也可以写在 config.json → pluginSettings.browser:
| 键 | 默认 | 说明 |
|---|---|---|
executablePath |
"" |
留空自动查找 |
mode |
headless |
window 为独立窗口(仍同步到面板) |
searchUrl |
Bing | %s 为关键词 |
proxy |
"" |
http://、https://、socks5:// |
idleMinutes |
15 | 0 为不自动关闭 |
quality / maxFps |
70 / 30 | 画面质量与帧率 |
restoreTabs |
true | 启动时恢复上次的标签 |
downloadDir |
"" |
留空为插件数据目录下的 downloads |
lang |
系统语言 | Accept-Language 与浏览器界面语言 |
allowEval |
true | 是否允许 browser_eval |
远程模式(手机连电脑上的 Prometheus):浏览器跑在电脑上。手机屏幕窄(≤700 px)且是触屏时,客户端上报 mobile,插件用 Emulation.setDeviceMetricsOverride { mobile: true }、setTouchEmulationEnabled 和手机版 User-Agent 排版,网页按手机版显示;手指以真实触摸事件(Input.dispatchTouchEvent)送给页面,点按、滑动、双指缩放都由 Chromium 自己合成。设置里可以关掉(mobile)。
本机模式(服务端跑在手机上,Termux Node):下载的文件不能直接执行,所以 Chromium 作为按需运行时 chromium(见 apps/desktop/src-tauri/runtimes.json)装进应用已有的 PRoot Ubuntu 里(Chrome for Testing 的 chrome-headless-shell linux-arm64,约 125 MB,外加 apt 装的几个系统库和字体),由服务端通过 guestrun 启动:
- 启动方式与桌面不同:PRoot 里不保证能把管道 fd 传给浏览器,改用
--remote-debugging-port=0,读 profile 目录里的DevToolsActivePort得到端点,再用 WebSocket 连接(cdp.ts的connectWebSocket,其余代码不变)。端点只监听回环地址,且路径含随机口令。 - 启动参数加上
--no-sandbox --no-zygote --disable-gpu --disable-dev-shm-usage等(guest.ts)。 - 浏览器和服务端共享的目录(profile、下载、上传)都放在插件数据目录下:
guestrun会把当前目录绑定进 rootfs,路径两边一致。 - 面板在没装时显示「安装浏览器组件」,调用应用的运行时安装器(与 设置 > 运行环境 同一套,带进度);装完自动启动。设置里可以卸载。
- 多个客户端同时打开面板时,视口以最后调整尺寸的那个为准。
- 跨域 iframe(另一个渲染进程)的内容不在快照里,需要时用截图。
- headless 下没有音频;需要 DRM 的视频播不了。
- 拖放文件到页面、页面里的原生日期选择器弹层还不支持(
set_value可以直接给日期输入框赋值)。 - Linux ARM 没有 Chrome for Testing,需要用包管理器装 chromium。
- 手机本机模式的浏览器是 Chrome Headless Shell(无界面),没有「独立窗口」模式,也没有声音;它在 PRoot 里运行,比电脑慢,内存占用也更大。
npx vitest run plugins/browser/test # 单元 + 真实 Chromium 端到端(没有浏览器时自动跳过)
node plugins/browser/test/server-stream.ts # 起一个真实服务端,验证 plugin.watch、二进制帧、退订后停止推流
node plugins/browser/test/ui-smoke.ts <dir> # 起服务端 + 构建好的界面,用另一个 headless 浏览器打开应用并截图plugins/browser/test/browser.e2e.test.ts 覆盖:启动与导航、表单填写(中文、下拉、勾选、上传)与差异、遮挡检测、confirm 对话框、target=_blank 新标签、同源 iframe、按键与截图、推流与用户输入、select 弹层、接管、模式切换后标签恢复、手机视口(触摸点按与滑动、手机版 UA)。ws.e2e.test.ts 用桌面 Chrome 验证手机本机模式用的 WebSocket 连接方式(PM_BROWSER_WS=1 可让整套 e2e 都走这条路)。手机上的 PRoot 与 Chromium 没有在真机上跑过,只验证了启动参数、路径和连接逻辑。