关于 ai-browser
ai-browser 是供 Agent 操作的可见 Electron 浏览器,通过语义无障碍树读取页面,而非截图。它提供 MCP 工具和 WebSocket JSON-RPC 接口,也允许人直接操作同一窗口。
主要功能
- 01以语义页面树替代截图
- 02导航、标签页、输入与滚动
- 03检查网络请求和下载
- 04操作结果附带效果状态
- 05提供 MCP 与 WebSocket JSON-RPC 接口
适用场景
- 01让 Agent 读取并操作网页
- 02验证浏览器操作后的页面变化
- 03由人接管可见浏览器
- 04检查请求与已下载文件
MCP 相关
MCP 服务向 MCP 客户端提供浏览器导航、页面读取、交互、标签页和网络检查等工具。它可启动 Electron,也可复用已运行的浏览器。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
工具
17收录的工具列表 · 更新于 今天
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| tab | integer | 否 | 标签页 ID(默认:活动标签页)。 |
| url | string | 是 | 要导航到的 URL。 |
详细介绍
AI Browser
中文读者请看这里:README-CN.md(3 分钟速读 + 中文安装指引)
一款真正的浏览器,唯一的操作者就是你的 Agent。
它读取页面时使用语义树,而非截图;通过可信的浏览器输入执行操作 —
而且由于它是一个普通、可见的 Electron 窗口,人类随时可以伸手操作鼠标。
一个嘈杂的真实信息流(300+ 个无障碍节点)变成了类型化结构 — 然后一次 ui.act 就切换了信息流,树也随之改变。每次调用都是通过一个 WebSocket 发送的原始 JSON-RPC 消息:无需 SDK,也无需坐标。

复现方法:npm start,然后运行 node examples/feed-tree.mjs
人类 + Agent,使用同一个浏览器、同一个套接字。Agent 操作浏览器,然后停下来等待;有人在真实窗口中点击一篇文章,Agent 在下一次读取时发现新标签页,并继续浏览人类选择的页面。无需锁定,也无需交接 API。

亲自试试:运行 npm start,然后运行 node examples/human-takeover.mjs — 显示 YOUR TURN 时,点击任意位置。
它是什么。 一个 Electron 应用,通过两种等效接口开放其标签页 — MCP 服务
(20 个 browse_* 工具)和原始 WebSocket JSON-RPC API(ws://localhost:9223,方法为 ui.*)。你的
Agent 可以导航、读取结构化语义树(role / label / value / states / actions),
并通过 data-ai-id 点击或输入。无需截图步骤,也无需 OCR:src/ 中完全没有
截图调用。
它不是什么。 它不是无头浏览器。窗口确实会打开、确实会渲染,并且始终可以交互 — 请参阅让人类介入。它也不会与你“协同驾驶”:交接 发生在 Agent 与用户之间的对话中,而不是通过浏览器 API。
安全说明。 WebSocket 服务没有身份验证 — 它监听 localhost,并信任任何连接者。 请勿将 9223 端口暴露在回环地址之外,并避免使用不可信网络。
安装
除非有特殊原因,否则请安装预构建软件包。 选项 A 是一个可双击的文件:
无需 Node.js、无需依赖树,也无需构建步骤。源代码安装(选项 C)会额外下载约 276 MB 的 node_modules
以及约 100 MB 的 Electron 引擎 — 仅当没有适用于你平台的软件包、软件包运行异常,或你确实需要从源代码运行时才使用。
选项 A — macOS 安装包(推荐)
从 GitHub Releases 下载 AI-Browser-<version>-arm64.dmg(下载约 110 MB,占用磁盘空间约 245 MB——其中大部分是随附的 Chromium 引擎,它本身就是产品)。打开 dmg,将 AI Browser 拖到 /Applications,然后启动。完成。
下载前有两点需要了解:
- 该应用未经公证。 macOS Gatekeeper 首次会拒绝打开。右键点击应用 → 打开,确认一次,之后即可正常运行。(付费的 Apple Developer 账户可以省去这一步;在此之前,这就是实际情况。)
- 仅构建了 macOS
arm64版本。 Windows 和 Linux 暂无安装包,因为本项目尚未在这两个平台上验证——请参阅兼容性。
Agent 仍通过 MCP 服务连接(见下方选项 B),并且这两个渠道可以协同工作:如果打包后的应用已经在运行,MCP 服务会在端口 9223 上找到它并复用它,而不是再启动一个 Electron 实例——无论以哪种方式启动,都只有一个浏览器和一个登录会话。因此,“用安装包提供引擎 + 用 npx 提供 MCP 桥接”也是常见的 Agent 配置。
选项 B — npx(Agent 渠道)
该包已发布为 @glittering0717/ai-browser;无需克隆:
npx -y @glittering0717/ai-browser --help
若要直接打开可见的浏览器窗口:
npx -y @glittering0717/ai-browser start
需要 Node ≥ 18 和桌面会话(这是一个 GUI 应用)。如果启动时需要单独的引擎,首次运行会准备它(约 100 MB,只需下载一次)。npm ≥ 11 默认会阻止依赖安装脚本——启动器会检测到这一点,并自行运行 Electron 的下载器,因此无需额外操作。如果从 GitHub releases 下载速度很慢或受到阻止(中国大陆网络中较常见),可以指定镜像:
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npx -y @glittering0717/ai-browser start
选项 C — 从源代码构建(备用方案)
如果你的平台没有对应的安装包、安装包无法正常使用,或者你需要修改代码,请使用此方案。这是三种方案中最繁重的一种:npm install 会拉取完整的依赖树(约 276 MB),然后还要下载 Electron 引擎(约 100 MB)。
git clone https://github.com/Glittering/ai-browser.git
cd ai-browser
npm install
如果 Electron 二进制文件下载很慢或完全失败,请使用镜像:
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm install
或者在项目根目录创建 .npmrc,将其设为默认配置:
electron_mirror=https://npmmirror.com/mirrors/electron/
在排查其他问题之前,请先确认二进制文件确实已下载到位——缺少二进制文件时,安装仍可能显示为“成功”:
ls node_modules/electron/dist
要端到端检查安装是否正常:
npm start
一个 1280×800 的窗口会打开,终端会打印 AI Browser WS server listening on ws://localhost:9223。(第一个标签页会打开默认起始页;忽略它并导航到其他页面。)
要自行构建安装包:
npm install
node scripts/build_dmg.cjs # current arch
node scripts/build_dmg.cjs --arch arm64 # or x64
输出文件会放在 dist/ 中。脚本使用 @electron/packager 和 macOS 内置的 hdiutil(不下载外部二进制文件);相关原因——包括 electron-builder 为何拒绝构建本项目——记录在 docs/DEPENDENCY_AND_DISTRIBUTION_AUDIT.md 中。
连接你的 Agent
将以下内容添加到 MCP 客户端的配置文件中——Claude Desktop 的配置文件、~/.cursor/mcp.json,或项目级 .mcp.json:
{
"mcpServers": {
"ai-browser": {
"command": "npx",
"args": ["-y", "@glittering0717/ai-browser", "mcp"]
}
}
}
可直接使用的副本位于 examples/mcp-config.json。从源码运行的用户也可以将 command: "node" 指向 /absolute/path/to/ai-browser/src/main/mcp_server.js。
你无需自行启动浏览器。 首次调用 browse_* 时,MCP 服务会探测端口 9223;如果没有程序监听该端口,就会以分离模式启动 Electron,并等待 WebSocket 服务启动,最长 30 秒(首次调用可能还需要额外时间来准备引擎)。如果你更希望所有客户端共用一个长期运行的窗口,可以启动你在选项 A 中安装的 AI Browser 应用(或运行 npx -y @glittering0717/ai-browser start,或在源码目录中运行 npm start)——再次启动只会聚焦现有窗口。
examples/AGENT_INSTRUCTIONS.md 中有一段可直接复制粘贴的提示词,你可以将其放入 CLAUDE.md、Cursor 规则或任何系统提示词中;它会介绍工具调用循环、节省令牌的参数,以及何时应询问人工。自行安装的 Agent(例如 Cline)可以阅读简短的、面向机器的 llms-install.md,而不必阅读本 README。
MCP 工具
权威来源:src/main/mcp_tools.js。共有 17 个工具——工具集刻意保持精简,因为每个工具的架构都会在每轮对话中注入 Agent 的上下文。
| 工具 | 简介 |
|---|---|
browse_navigate | 在当前标签页(或指定标签页)中加载 URL。 |
browse_get_tree | 将页面读取为语义树及页面上下文(模态框、消息、统计数据)。 |
browse_act | 在 data-ai-id 上执行 click / type(在插入点追加)/ setContent(替换全部内容)/ clear / focus / hover / scroll_to / upload / get_value(只读)/ drag / press / wheel。 |
browse_diff | 先用 snapshot 创建基准,再用 diff 获取新增 / 移除 / 更改的路径及更改前后的值。这是证明操作确实产生效果的方法。 |
browse_capabilities | 探测页面提供的功能,以及应在页面上采用的操作路径(见下文)。 |
browse_repaint | 当窗口显示过时的页面时,强制合成器重绘标签页;返回 repaint_count。 |
browse_downloads | 列出标签页的下载及其磁盘路径。下载是异步的——点击导出按钮后轮询此工具。 |
browse_canvas | 通过捕获的绘制调用读取 <canvas>:list / read / configure / capture。实验性功能——见下文的 canvas 部分。 |
browse_evaluate | 在页面上下文中运行 JS 表达式(上限为 5000 个字符,拒绝 Node 标识符)。 |
browse_scroll | 滚动页面,或将元素滚动到可视区域内。 |
browse_wait | 轮询,直到满足 button_enabled / modal_appeared / text_contains / url_contains 条件。 |
browse_tabs | 标签页操作:list / new / close / switch。switch 会设置后续调用的默认目标。 |
browse_network | 类 Chrome DevTools 的网络检查:list(按方法 / URL / 状态 / 类型筛选)、get(请求和响应标头及正文)、clear、configure。 |
browse_quit | 关闭整个浏览器并释放进程。 |
每个操作结果都会告知你的信息:effect_state
browse_act 不再让你猜测输入是否已到达页面。每个结果都会给出明确的判定:
effect_state | 含义 | retry |
|---|---|---|
committed | 已分发并由页面确认 | no |
none | 可以确定什么都没发生(没有此目标、参数错误、CDP 已断开) | safe |
unknown | 无法确定什么都没发生——超时、已分发粘贴但从未被处理、按键序列只执行了一半 | verify_first |
默认值是 unknown,而非 none:浏览器无法判断时,会如实说明。盲目重试 unknown 操作可能导致表单提交两次——先读取页面再说。unknown 结果还会附带一句话的 effect_hint,明确说明这一点。
找不到 target 时,结果会添加 stale_ref / ref_epoch / tree_epoch / ref_diagnosis,因为“没有此目标”背后可能是三种截然不同的情况:
ref_diagnosis: "stale"— 获取该 ref 后,页面已重新读取(ref_epoch<tree_epoch)。获取一个新的 ref;重试相同的 ref 仍会失败。ref_diagnosis: "never_seen"— 到目前为止,该 ref 从未出现在任何一次树读取中。重新读取也无济于事:值错误(用了可见文本而不是data-ai-id)、拼写错误,或用了另一个标签页的 ref。ref_diagnosis: "present_in_tree"— 当前树中存在该目标,但在 DOM 中找不到:它位于 iframe、shadow root 中,或是一个已分离的节点。
这三种情况都会报告 effect_state: "none"(什么都没发生)和 retry: "verify_first"——没有副作用,但重复完全相同的调用毫无意义。
证明操作确实生效:browse_diff
工具报告 success 并不代表发生了任何变化——浏览器会毫不迟疑地告诉你,点击落在了一个忽略点击的元素上,拖动已完成但应用却把节点吸附到了完全不同的位置,或者按键输入到了一个随后自行清空的字段中。每次写入操作都应使用快照/差异对进行验证:
const base = await browse_diff({ operation: "snapshot", js: "app.graph.serialize()" });
await browse_act({ action: "click", target: "e:button-25" });
const d = await browse_diff({ operation: "diff", snapshot: base.snapshot });
// d.verdict === "changed"
// d.changed_paths → [{ path: "nodes[7].widgets.steps", from: 20, to: 21, reason: "value" }]
verdict会明确给出"changed"/"unchanged",因此“什么都没发生”绝不会依赖于你可能构造错误的空数组来推断。- 每个条目都包含一个
path和两个值,便于你检查变化原因。 - 数组按索引逐项比较,而非作为集合比较:移动的节点会显示为已变化的条目,不会显示为“一个已移除 + 一个新增”(否则看起来像两个事件,而不是一次重排)。
- 会容忍浮点数噪声(
201354.48743718592与...1859不算变化),但真实的 22 单位坐标偏移会被识别为变化。 - 对于不依赖具体网站的 DOM 快照,可省略
js——快照会记录每个元素的 role / label / value / disabled / size,以及页面上的可见文本。无需针对每个网站编写 JS,纯<div>状态文本的变化也能被捕获。 - 省略
js时,如果页面在window上挂载了应用自己的数据模型,快照还会为其生成指纹。通过 canvas 渲染的应用,其 DOM 就像一张静态照片——拖动节点或编辑组件会重绘<canvas>,但元素遍历无法看到任何移动,因此仅比较 DOM 的差异会对确实发生的写入返回unchanged。ComfyUI 文档所述的写入路径(node.widgets[].value)对 DOM 的改动恰好为 0 字节。使用指纹后,同一写入会显示为model.fp(节点数量变化时还会显示model.count)。此过程为只读操作(loadGraphData是加载器,绝不会被调用),会选择实际能够读回的候选对象,而非看起来最有希望的对象;库命名空间虽然名称评分很高,却无法提供模型,因此会被跳过。 rearm: true会将基线更新为下一次差异比较的基线,因此你可以循环执行“断言没有变化”来检查多个步骤;forget: true则会将其丢弃。- 表达式抛出异常、ID 缺失或跨标签页比较,都会返回明确的错误。它们绝不会退化为空快照,否则之后的每次差异比较都会报告
unchanged——这是会导致所有验证都误报成功的唯一故障模式。
此页面该使用哪条路线?browse_capabilities
在不熟悉的网站上尝试操作前,先进行查询。一次调用会报告语义树的大小及其角色/操作直方图、画布数量、同源 iframe 及其内部大小、应用挂载在 window 上的对象(按形状自动检测:has .graph / ._nodes / getState / serialize)、应用已调用过的 JSON 端点,以及带有依据的推荐路线:
L1_structured_data— 应用暴露了自己的模型;以 JSON 格式读取和写入整个图。L2_semantic_tree— 元素足够多,可按角色/标签查找目标,并通过data-ai-id操作它们。L3_pointer_gestures— 仅用于没有结构化路径的操作(拖动节点、绘制连接、滚轮平移/缩放)。操作后务必调用browse_diff。
它还会明确指出陷阱,例如:“主体内容位于同源 iframe 中”,或“有 7 个画布,却只有 17 个语义元素——别再尝试用坐标点击画布绘制的项目,改去寻找数据模型”。
减少往返操作的说明:
browse_get_tree接受subset: "interactive",用于剔除仅用于布局的分支;接受mode: "diff",用于仅返回上次读取后新增/消失的节点;还接受ax: true,用于通过无障碍层读取(此时链接会包含url)。type会追加;setContent会替换。 过去两者行为相同(都会替换全部内容),导致无法增量编辑——插入一个微博风格的话题标签后,下一次type就会将其清除。现在,type会在插入点写入(params.at:end(默认)/start/cursor),因此可以“插入话题标签,然后继续填写正文”。要重新填充字段,请使用setContent(或先使用clear)。browse_act还接受action: "get_value"— 一种只读方式,可获取元素的完整文本。这一点很重要,因为browse_get_tree会将长值截断至 200 个字符(见下文)。- 指针手势是一等功能:
drag、press和wheel。画布/白板/可排序 UI 都基于这些功能构建,任何evaluate都无法替代它们——合成的PointerEvent无法操作使用 Pointer Capture 的框架(在 React Flow 画布上测得,节点位置的变化恰好为 0,0),因此这些操作会通过 CDP 可信输入执行。drag:可使用dx/dy、to_x/to_y或to_target(拖放到另一个元素上)。from_anchor/to_anchor(left/right/top/bottom/center)可指定抓取边缘——边缘锚点会优先使用该元素的连接手柄,但前提是该手柄确实能通过命中测试(见下文说明)。from_x/from_y可指定空白坐标点(用于平移空画布),此时不需要target。press:可使用key(Delete、Escape、ArrowRight、Enter……或"Meta+a"这样的组合键),也可使用keys指定按键序列。删除节点、撤销、取消选择——这些操作都只能通过键盘完成。wheel:使用delta_x/delta_y,并配合hold: ["Control"]缩放画布。browse_scroll无法实现此操作——它会调用window.scrollBy,而无限画布会完全忽略该调用。- 拖动结果会报告
drag_from/drag_to,以及每个坐标点上实际所在的元素(from_at_id);如果按下时落在另一个元素上,还会报告warning+start_hit_target: false——否则从视觉重叠上看起来就像“拖动不起作用”。
actions根据行为推断,而非根据role推断。 现在,如果元素计算所得的cursor是拖动光标,或者该元素带有draggable="true"/cursor: pointer/onclick,即使其角色为group,树中也会将其标记为支持drag/click。如果没有这项功能,满是role: group节点的画布会报告actions: [],Agent 完全无法判断这些节点是否可交互。
browse_network会从标签页打开时起捕获请求,包括 POST 请求正文—— 无需订阅。默认会对请求标头进行脱敏 (authorization/cookie→[REDACTED]);传入include_sensitive_headers: true即可查看。- 大多数工具都支持可选的
tab参数,用于多标签页操作;不传时则针对活动标签页。
让人类介入
不会抢占你的焦点
即使窗口处于后台,Agent 也能正常工作。CDP 可信输入
(Input.dispatchKeyEvent / Input.dispatchMouseEvent)不需要窗口处于最前端;应用已启用
disable-backgrounding-occluded-windows、disable-renderer-backgrounding
和 disable-background-timer-throttling,因此后台窗口既不会被节流,也不会丢失事件。
这对 act / get_tree / evaluate / peek / scroll 一直如此——但有两种操作过去每次调用时都会
把窗口拖到最前端:new_tab 和 set_active_tab(它们调用了
webContents.focus(),这会激活整个窗口)。此外,即使是由 Agent 启动,应用启动时也会显示窗口。两处问题现已修复:
new_tab/set_active_tab不再抢占窗口焦点。页面仍会认为自己处于焦点状态——Emulation.setFocusEmulationEnabled会让document.hasFocus()和依赖焦点的延迟加载 保持正常运行——同时键盘焦点仍留在你离开时所在的位置。- 当 Agent 通过 MCP 启动浏览器时,窗口会通过
showInactive()显示: 可见,但不会抢占焦点。当你运行npm start时,窗口仍会像往常一样显示在最前端。
因此窗口会保持可见——这正是设计目的——但 Agent 操作时不会把窗口拖到最前端。你可以继续工作。
AI_BROWSER_FOCUS=always npm start # pre-1.2 behaviour: show + moveTop + focus on every action
AI_BROWSER_FOCUS=never npm start # never change focus (the window is still shown)
默认值为 auto:保持窗口可见(如果窗口已最小化或隐藏,则将其恢复),但绝不抢占焦点。
无论取何值,窗口始终会显示——这是产品不变式。
npm run focus 会从外部验证所有这些行为(通过 macOS lsappinfo,无需辅助功能权限),并包含一个特意配置为抢占焦点的对照组——因此,如果测量方式失效,测试套件就会失败,而不是毫无意义地通过。
它还会验证更强的结论——窗口完全不需要处于前台。在另一个应用保持前台时,它会运行一组有代表性的 API 操作,并检查每项操作的实际结果:输入内容成功写入、点击只触发一次、get_tree 返回一棵树、canvas 的 getImageData 返回实际像素、network_list 能看到请求、navigate 完成——在后台停留 10 秒后,它仍能完成上述所有操作,且始终不会切换到前台。
关于
document.hasFocus()的说明:即使窗口处于后台,它也会返回true,因为这里特意启用了Emulation.setFocusEmulationEnabled——这能防止依赖焦点的延迟加载和动画停滞。因此,它不是检测“我是否处于 前台”的有效方法;测试改用外部观察器。
这一点最容易被误解,所以直说:Agent 操作浏览器,人类始终可以接管窗口。
由于这是一个真正的 Electron 窗口,任何人都可以像在 Chrome 中一样随时用鼠标和键盘点击、输入、滚动和登录,无需申请任何权限。这涵盖了 Agent 确实无法完成的操作:
- 登录 / SSO,以及 Agent 不应掌握密码的任何操作
- 扫描二维码(应用到 Web 的授权)
- 滑块和图片验证码、短信或电子邮件验证码
- 支付确认、风控拦截页面、OTP 屏幕
- 任何继续操作就意味着要伪造凭据的情形 交接流程:在浏览器中并没有这种流程。 AI Browser 一侧没有任何东西负责协调,也不需要。整个过程就是 Agent 与用户之间的对话:
- Agent 遇到无法完成的操作。
- 它会在聊天中直接说明:“请扫描浏览器窗口中的二维码,然后回复‘完成’。”
- 它会停下来等待回复,不会反复重试无法奏效的点击操作。
- 收到“完成”后,它会使用
browse_get_tree重新读取,以确认当前状态,然后继续。
请参阅 examples/with-human.mjs,了解脚本化的等效实现:它会显示提示,并通过 readline 等待按下 Enter。浏览器完全不知道发生过这些操作。
对 Agent 来说,有两个实际需要注意的事项:
- 当有人正在窗口中操作时,绝不要调用
browse_quit。 它会关闭窗口并退出进程,任何尚未解决的问题都会丢失。任务完成后再退出,不要提前退出。 - 登录状态会保留。 标签页位于
persist:ai-browser会话分区中,因此 Cookie 和会话会在重启后继续保留;通常每台机器上每个网站只需由用户介入一次。
原始 WebSocket API
MCP 是一个精简的 stdio 适配器。MCP 能实现的任何功能,都可以通过 JSON-RPC 2.0 直接从任何语言调用,地址为
ws://localhost:9223。权威来源:src/main/ws_server.js 中的方法分发逻辑。
你不必为此编写客户端。CLI 使用相同的协议:
ai-browser status # running? pid / port / tabs
ai-browser call ui.list_tabs
ai-browser call ui.get_tree '{"subset":"interactive"}'
ai-browser call ui.navigate '{"url":"https://example.com"}'
echo '{"url":"https://example.com"}' | ai-browser call ui.navigate -
call 会将原始 result 作为一行 JSON 输出(可将其传入 jq),调用成功时退出码为 0,
调用失败时为 1,命令行有误时为 2。可使用 --port、--timeout、--pretty。
因此,同一个实现有三种入口——MCP stdio(ai-browser mcp)、通过
WebSocket 传输的 JSON-RPC,以及这个 CLI。它们都不是独立的代码路径。
| 方法 | 用途 | 对应项 |
|---|---|---|
ui.get_tree | 语义树(含上下文)。参数:focusedOnly、ax、subset、mode、tab | browse_get_tree |
ui.act | params.action + params.target;每种操作的参数嵌套在 params.params 下 | browse_act |
ui.navigate | 加载 URL(params.url,可选 tab)。返回 {ok, error?}——{ok:false} 始终会附带原因。 | browse_navigate |
ui.snapshot / ui.diff | 捕获基线(params.js,可选 label),并与其进行比较(params.snapshot、rearm、forget) | browse_diff |
ui.capabilities | 探测页面:树直方图、画布、iframe、应用全局变量、端点、推荐路径 | browse_capabilities |
ui.repaint | 强制合成器重绘标签页;返回 repaint_count,以便确认确实发生了重绘 | browse_repaint |
ui.evaluate | 在页面上下文中运行 JS,使用与 MCP 相同的保护机制 | browse_evaluate |
ui.scroll | 滚动页面或元素(direction、amount、target)。仅支持文档滚动——画布应用请使用 ui.act{action:'wheel'}。 | browse_scroll |
ui.wait | 轮询,直到满足条件或超时 | browse_wait |
ui.new_tab / ui.close_tab / ui.list_tabs / ui.set_active_tab | 标签页管理(在 MCP 端,这四项合并为 browse_tabs{op}) | browse_tabs |
ui.subscribe / ui.unsubscribe | 事件订阅;使用 params.events,"*" 表示全部事件 | —(仅限 WS,见下文) |
ui.downloads | 此标签页的下载内容及其磁盘路径(state、received_bytes、dir) | browse_downloads |
ui.get_focused | 当前聚焦元素的子树 | — |
ui.peek | 悬停在折叠或仅悬停时显示的控件上,返回出现或消失的内容(revealed / hidden),但不执行操作。其 ID 是真实的 data-ai-id,可直接交给 ui.act;revertMs: 0 会让鼠标停留在原处,以便点击找到的内容 | — |
ui.network_body | 根据 URL 子字符串(url_pattern)获取响应正文 | —(已由 browse_network 取代) |
ui.quit | 平稳关闭 | browse_quit |
请注意,与 MCP 的写法有两处不同,上方的分发逻辑中都能看到:这里使用 focusedOnly
(驼峰式),而不是 focused_only;ui.act 的每种操作参数则嵌套在 params.params 下。
ui.subscribe / ui.unsubscribe 也可作为不带 id 的通知使用。
有意不将 ui.subscribe 暴露为 MCP 工具:MCP 的 stdio 路径会丢弃
服务端→客户端通知,因此 MCP 调用方即使订阅,也永远收不到任何事件。实时事件仅支持通过原始 WebSocket 获取。对于任何基于拉取的操作,请使用
browse_network。
import WebSocket from 'ws';
const ws = new WebSocket('ws://localhost:9223');
let id = 0;
const call = (method, params) => new Promise((resolve, reject) => {
const myId = ++id;
const onMsg = (raw) => {
const m = JSON.parse(raw.toString());
if (m.id === myId) { ws.off('message', onMsg); m.error ? reject(new Error(m.error.message)) : resolve(m.result); }
};
ws.on('message', onMsg);
ws.send(JSON.stringify({ jsonrpc: '2.0', id: myId, method, params }));
});
ws.on('open', async () => {
await call('ui.navigate', { url: 'https://example.com' });
const { tree } = await call('ui.get_tree', { subset: 'interactive' });
console.log(tree.role, tree.children?.length);
ws.close();
});
错误代码遵循 JSON-RPC 规范:-32700 解析错误,-32601 未知方法,-32602 参数无效
(ui.evaluate 的保护机制会返回此代码),-32603 内部错误。
## 工作原理
agent ── MCP stdio (17 browse_*) ──┐
agent ── WS JSON-RPC :9223 (ui.*) ─┤
▼
┌──────────────────────────────────────────────────┐
│ Electron main │
│ index.js window · tab strip · watchdog │
│ ws_server.js JSON-RPC routing, event stream │
│ page_manager.js tabs · id-matched IPC · CDP │
└───────────────┬──────────────────────────────────┘
│ IPC (request-id matched)
┌───────────────▼──────────────────────────────────┐
│ preload (contextIsolation:true, nodeIntegration:false)
│ bridge.cjs · extractor.cjs · actions.cjs · │
│ watcher.cjs (captcha / message / state scan) │
└───────────────┬──────────────────────────────────┘
▼
live DOM → semantic tree
读取。 返回树中的每个节点都带有 data-ai-id——如果元素有自己的 DOM id,就使用该 id;否则生成一个 e:tag-N。id 在多次读取之间保持稳定:已有 id 的元素会保留它;生成的 id 则来自单调递增计数器,因此在其他元素上方插入元素不会导致编号重新排列。(过去并非如此:旧 id 会悄无声息地指向另一个元素,操作报告成功,实际却命中了错误的元素。)导航时会重置。除此之外,还会返回 role、label、value、states 和 actions,以及一个 context 区块——其中包含打开的模态框、临时消息、必填字段提示和粗略统计数据。读取有两种路径:默认的预加载提取器,以及 ax: true 启用的无障碍层(还会为链接提供 url)。
页面首次读取时,会为元素添加 data-ai-id;这是读取执行的唯一一次写入,也正是它让后续操作能够解析目标。重复读取不会写入任何内容。 只有属性值确实发生变化时才会设置该属性,因为即使把属性设为已有值,真正的 Chromium 也会生成一条 MutationObserver 记录——在添加这项保护之前,一个包含 4000 个元素的页面每次读取会产生 4001 条记录。不断向页面自身的观察器发送消息的读取,会扰动它正在测量的对象。
写入。 操作使用的是可信浏览器输入,而非合成 JS。主进程通过 Chrome DevTools Protocol 驱动页面——点击使用 Input.dispatchMouseEvent,输入文字则逐字符使用 Input.dispatchKeyEvent(rawKeyDown → char → keyUp)——然后通过 elementFromPoint 验证点击确实命中目标(并穿透 shadow root:普通调用对 Web 组件内部的任何元素都会返回宿主元素,导致所有这类点击悄无声息地降级为合成点击)。由于事件以真实键盘和鼠标输入的形式到达,Draft.js、ProseMirror、CKEditor、Quill 和普通的 <textarea> 都能通过同一路径工作,无需针对不同框架编写分支。
局限之处。 extractor.cjs 是基于 DOM 的启发式实现,并非从零编写的规范:它能很好地处理普通页面,但可能会错误标记特殊页面。窗口会关闭硬件加速,并启用三个 Chromium 开关
(--disable-backgrounding-occluded-windows、--disable-renderer-backgrounding、
--disable-background-timer-throttling),这样 IDE 窗口覆盖在浏览器上方时,注入的输入就不会被悄无声息地丢弃。
示例
可运行脚本位于 examples/,并直接连接到 ws://localhost:9223:
| 文件 | 展示内容 |
|---|---|
mcp-config.json | 最简 MCP 客户端配置;请替换占位路径。 |
AGENT_INSTRUCTIONS.md | 可直接复制粘贴的 Agent 指令,包含人工接管协议。 |
raw-ws.mjs | 最精简的原始 WebSocket 客户端。 |
read-page.mjs | 加载一个页面并打印其中的交互元素。 |
feed-tree.mjs | 拥挤的信息流 → 带类型的结构 → 操作,全程通过原始 JSON-RPC(顶部演示)。 |
with-human.mjs | 需要人工介入时的完整流程。 |
human-takeover.mjs | Agent 暂停,你点击后,Agent 在你的页面上继续执行(第二个演示)。 |
node examples/read-page.mjs
故障排除
页面已更新,但窗口仍显示旧内容。 过去需要来回点击标签页才能解决,因为唯一能强制合成器重绘 BrowserView 的操作,是 setActiveTab 执行 removeBrowserView + addBrowserView + setBounds——导航本身不会触发重绘。现在,每个导航信号都会触发重绘:did-navigate、did-navigate-in-page(SPA 路由变更根本不会触发加载事件)、did-frame-finish-load(iframe 中的画布编辑器会在顶层文档完成加载很久之后才启动),以及窗口 show / restore / focus。这对这里很重要,因为此窗口的设计原则是绝不抢占你的焦点,所以它可能会在编辑器后方停留数分钟。如果内容仍显陈旧,ui.repaint 可按需强制重绘,并返回 repaint_count,供你确认是否确实发生了重绘。
某项操作报告成功,但没有任何变化。 有两个原因值得了解:
- 通过原始 WS 调用时,
ui.act会将每项操作的参数嵌套在params.params下——{action:"type", target:"<id>", params:{text:"…"}}。将其扁平化为{text:"…"}会传入空字符串;过去这会触发幂等短路,并在没有输入任何内容的情况下报告success。现在会明确返回错误。 - 有时操作确实没有产生任何效果(点击被覆盖层拦截,或应用撤销了更改)。这正是
browse_diff的用途——用快照和差异比较包裹写入操作,而不是轻信返回值。
某项操作始终没有返回。 所有进入页面的调用路径都有截止时间,因此即使页面主线程被阻塞(繁重的重新计算、巨大的 JSON.parse、陷入循环的第三方脚本),也不会让工具调用一直挂起。以下数据是在主线程被占用 120s 的页面上测得:
| 调用 | 行为 |
|---|---|
ui.get_tree / ui.get_focused | 5s 后报错(getTree timeout) |
ui.evaluate | 10s 后报错(CDP 5s,然后 preload 回退机制 5s) |
ui.act | 15s 后返回,结果为 success:false, timed_out:true |
ui.capabilities | 正常返回(约 15s) |
ui.list_tabs / ui.network_list | 即时返回——它们不会访问页面 |
ui.act 的时间预算会根据请求的工作量调整,因此合法的长时间操作不会被终止:type / setContent 的预算为 text.length × interval_ms,drag 的预算为 duration_ms + steps,press 的预算为 repeat × interval_ms,clear 的预算为 20s。可通过 params.params.timeout_ms 覆盖(下限 1s,上限 300s),也可通过 AI_BROWSER_ACTION_TIMEOUT_MS 全局覆盖。超时会明确警告:操作可能已部分生效——例如指针已按下,或只输入了一半字符——因此重试前应先重新读取状态,而不是重复执行相同的写入操作。
应用自动重启。 Electron 的 NetworkService 可能整体卡死:端口仍在监听,但每次导航都失败并报 ERR_FAILED,而 curl 却能正常工作。主进程中的看门狗每 30s 探测一次;连续 5 次探测均失败后,它会广播 network_wedged 并重新启动应用(MCP 客户端会自行重新连接)。每个请求的截止时间为 5s,因此真正发生卡死时,重启约在两分钟后进行。
- 更改探测目标:
AI_BROWSER_WATCHDOG_URLS=https://www.baidu.com/,https://example.com/(以逗号分隔)。默认目标为example.com和www.baidu.com;使用多个目标是有意为之——只要任意目标有响应,网络就会被视为正常,因此单个不稳定或受地理位置限制的主机不会触发重启循环。切勿只设置一个网络无法访问的目标。 - 完全关闭:
AI_BROWSER_WATCHDOG=0(也接受off/false/no)。
端口 9223 已被占用。 该端口可能由另一个实例(通常是之前会话留下的旧实例)占用,也可能由其他程序占用。你可以退出该实例,也可以让 AI Browser 改用其他端口:
AI_BROWSER_PORT=9224 npm start
确保所有客户端使用相同的端口;应用和 MCP 服务都会从 src/shared/config.js 读取 AI_BROWSER_PORT。
Electron 缺失或启动后立即失败。 约 100 MB 的引擎未能下载。使用 npm 包时,只需再次启动——启动器会自行下载引擎(网络较慢时,请设置 ELECTRON_MIRROR,参见安装)。如果是从源码运行,请检查 ls node_modules/electron/dist,并使用镜像重新运行 npm install。设置 AI_BROWSER_PORT 无济于事——这是缺少二进制文件,并非端口冲突。
输入已被接受,但没有任何反应。 通常是窗口被遮挡。上文的三个 disable-*-backgrounding 开关就是为避免这种情况而设,但如果你仍看到点击操作报告成功而界面没有变化,请将 AI Browser 窗口置于前台,然后重试。
登录状态不断丢失。 会话保存在 ~/.ai-browser 下。如果你覆盖了该路径,请注意,不同的 AI_BROWSER_USER_DATA 目录是彼此独立的会话存储——在一个目录中“已登录”,不代表在另一个目录中也“已登录”:
AI_BROWSER_USER_DATA=~/.ai-browser-work npm start
窗口底部出现红色横条。 这是主进程异常——是 AI Browser 自身的 bug,而不是你正在操作的页面出了问题。它特意不是模态对话框:在 macOS 上,原生的 dialog.showMessageBox 会进入 NSAlert 模态循环并阻塞整个主进程(实测:没有对话框时,WebSocket 调用耗时 4ms;有对话框时,8 秒无响应),这会把一个可自行恢复的 JS 异常变成“每个 RPC 都超时”。横条会显示错误消息和日志路径,进程会继续运行,你可以点击 × 将其关闭。
每个异常都会同步追加到 ~/.ai-browser/logs/error.jsonl(2MB × 4 轮转),并附上最近 20 次 WebSocket 调用,让你查看出错时正在执行什么操作。通过 API 使用 ui.errors / browse_errors 查看,也可以直接对文件运行 tail。如果有 boot 记录却没有相应的 shutdown 记录,说明上次进程是异常终止的。
如果异常发生在任何窗口创建之前,横条便无处显示,因此会回退为显示一次原生对话框(记录为 error_dialog_fallback),避免应用在无声无息中停止运行。两种提示方式都可以切换:AI_BROWSER_ERROR_BANNER=0 可禁用横条,AI_BROWSER_ERROR_DIALOG=1 可强制显示模态对话框——后者会阻塞主进程,因此仅适合刻意查看启动崩溃时使用。
元素明显位于页面上,却不在树中
编写针对特定网站的脚本之前,请按以下列表逐项排查。下意识地说“用一串
querySelector 固定定位它”,正是导致本项目自身历史中出现 142 个针对特定网站的脚本和 731 个硬编码选择器的原因,而它们几乎都没有必要。
| # | 检查项 | 方法 | 如果问题出在这里 |
|---|---|---|---|
| 1 | 是否是读取层缺口? | node tools/coverage_probe.cjs https://the.site/ | 树中缺少可点击元素 → 报告此问题;应在 src/preload/extractor.cjs 中修复,而不是写进你的脚本 |
| 2 | 是否是操作层缺口? | 节点位于树中,但 ui.act 失败或不起作用 | 元素可读取,但无法触达(iframe/OOPIF、canvas、可信输入、未挂载的输入框)→ 这同样需要在浏览器端修复 |
| 3 | 是否是时机问题? | 2–3 秒后重新读取树,或使用 wait_until | SPA 只是还没在你读取时渲染出来 |
| 4 | 是否只在悬停时存在? | ui.peek { target, revertMs: 0 } | 该交互元素是在悬停时渲染的。peek 会悬停在它上面并返回出现的内容——传入 revertMs: 0 使其保持展开,然后对返回的 id 执行 ui.act click |
| 5 | 是否没有名称? | 使用 ui.peek,或点击它并读取显示的内容 | 网站没有为纯图标按钮提供 aria-label/title/alt,就无法凭空为它命名。但它打开的面板通常有名称——因此,打开面板并读取其内容,而不是猜哪个图标是什么 |
| 6 | 现在才:自行固定定位 | 在你的脚本中使用选择器 | 将它保留在你的脚本中。不要把它写成“该网站的工作方式”——下一个网站不会适用 |
coverage_probe 统计人们所说的可点击目标——浏览器本身以手形光标(cursor:pointer)渲染的元素,统计指针开始的位置(而非每个继承该样式的后代),并报告其中有多少带有 data-ai-id:
$ node tools/coverage_probe.cjs https://www.csdn.net/
可点目标(手型边界): 257
├ 自己带 data-ai-id(直接可操作): 257 → 100%
├ 被单个交互后代盖满且它在树里(壳子,点内层等价): 0
├ 祖先歧义(同一祖先下多个手型后代,点祖先指不准): 0
└ 整条祖先链无 id(真看不见): 0
可用率(in_tree + by_desc + anc_only): 100 % | 真缺口: 0
当覆盖率低于 80% 时,它会以非零状态码退出,因此你可以将其加入 CI。2026-10-17 的测量结果(Baidu / Bilibili /
CSDN):修复点击边界问题之前为 82% / 97% / 88%,修复之后为 100% / 100% / 100%;每次 ui.get_tree 耗时约 40ms。
这三个数字采用了错误的分母。 截至 2026-10-19,该工具只统计顶层文档:如果页面主体位于跨进程 iframe 中,它会报告“4 个点击目标,100% 可用,0 个缺口”,而整个课程目录却不可见。现在它会测量可访问的每个
frame-N,并且打印无法访问的 frame 数量——分母中包含未测量区域,比测量结果错误更糟。2026-10-19 重新测量的结果:Baidu 为 100%,CSDN 为 99%(现在纳入统计的跨进程 frame 有 8 个,剩余缺口 2 个),超星课程页面为 82%,并明确列出了剩余的 2 个缺口。关于这类错误——指标悄然排除了关键部分——的复盘见docs/POSTMORTEM_FRAME_VISIBILITY.md。
这两个方面不在统计范围内,因此无需追查:如果页面没有提供可读名称(没有 alt 的纯图片链接——占 Bilibili 可点击节点的 53%),就无法在不凭空编造的情况下为元素添加标签;而 canvas 完全没有可读取的 DOM。这些是真实存在的边界,不是 bug。完整说明(包括首次测量为何偏差达 3 倍——继承的 cursor 会扩大分母)见
docs/AGENT_OPERABILITY_FEEDBACK.md §6。
测试与开发
npm test # vitest — 411 tests across 27 files
npm run smoke # Electron contract layer, offline fixture — 31 checks
npm run capabilities # text fields, canvas, page source, network, DOM editing — 54 checks
npm run richtext # rich-text editors, nested menus, forms, errors, tags, upload — 117 checks
npm run network # request log, POST bodies, headers, pagination, redaction — 41 checks
npm run value # value truncation contract + read-only get_value — 15 checks
npm run canvas # canvas draw-call capture — 13 checks
npm run modeldiff # canvas app data-model diff (the "unchanged" false green) — 12 checks
npm run audit # every advertised action must exist; wait/downloads/permissions/quit — 35 checks
npm run nav # address bar + load-failure error page (the "white screen" report) — 19 checks
npm run focus # no focus stealing + full capability while backgrounded — 32 checks
npm run interact # trusted drag / key press / wheel (pointer gestures) — 31 checks
npm run view # viewport repaint + snapshot/diff verification — 28 checks
npm run e2e:all # 一次跑完上面全部套件(每个独立端口)+ 可信汇总 — 428 checks
npm run realsites # navigate/read/act against a matrix of real sites
所有端到端测试套件都会在离线 fixture 上启动真正的 Electron,并在失败时以非零状态码退出。
它们共同覆盖了 jsdom 在结构上无法覆盖的内容:预加载完整性、严格 CSP 下的 ui.evaluate、CDP 点击命中测试、多标签页生命周期、事件扇出,以及 evaluate 的护栏。
每个测试套件都会验证自己连接的是刚刚启动的浏览器。 在运行任何断言之前,它都会调用 ui.server_info,并将返回的 pid 与它启动的子进程进行比较;如果不匹配,测试套件会以状态码 4 中止,而不是悄悄生成关于其他实例的结果。
这个漏洞是经过实测的,而非凭空猜测:当一个冒牌 WebSocket 服务占用端口时,测试套件会愉快地向它发送 ui.new_tab / ui.evaluate / ui.get_tree,却毫无察觉。复现方法:
REPRO_PORT=9799 node e2e/_repro_identity.cjs。
npm run e2e:all 还会为每个测试套件分配独立端口,因为它们共用 /tmp/ai-browser-e2e-<port> 作为配置文件目录,因此也共用 Electron 的单实例锁。
它们都需要 WS 端口空闲——请先停止正在运行的 npm start,或将它们指向其他端口:
AI_BROWSER_PORT=9333 npm run smoke
它们会在隔离的配置文件目录中启动自己的 Electron(默认为 /tmp/ai-browser-e2e-<port>),因此绝不会触碰你真正的 ~/.ai-browser 配置文件——其中保存着你已登录的会话。它们还会在退出时清理自己的 Electron,包括响应 Ctrl-C / 超时 / 崩溃时:否则,孤儿进程会占用端口和单实例锁,导致你运行 npm start 时遇到 "another AI Browser instance is already running",无法启动。
各项能力测试套件的验证内容
npm run view ——用于验证两种容易悄悄造成虚假信心的情况。
视口。 BrowserView 即使完全加载,也仍可能显示过时的像素;唯一能强制重绘的操作曾经是切换标签页——这正是以前“多点几次标签页”就能修复问题的原因。现在,每次导航信号以及窗口 show / restore / focus 时都会触发重绘。测试套件通过累计重绘计数器验证这一点,而不是通过截图:截图需要人眼判断,计数器则能证伪。它还证明了防抖不会自行触发(否则重绘循环会一直闪烁),并且重绘不会更改页面数据。
验证。 只有在正反两个方向都正确时,ui.diff 才有用,因此每条断言都有一对验证:实际发生的变化必须被报告,页面未发生变化时不得被报告。负向对照才是重点——虚构变化的 diff,或者出错后悄悄变成空内容的快照(导致之后的每次 diff 都报告“未变化”),都会让所有验证变成虚假的通过。测试套件还锁定了一种失败情形:参数层拼写错误,导致 type 报告成功,却什么也没输入。
npm run richtext ——覆盖最深入的测试套件。富文本编辑器和格式设置、两层和三层嵌套菜单、必填与选填字段(原生 required、aria-required、红色星号)、错误和警告提示(字段级、表单级、toast、模态对话框)、单选框 / 复选框 / 选择框 / 组合框、标签输入和微博风格的话题插入,以及图片上传。每项操作断言都会通过真实 DOM验证,而不仅仅依赖语义树。
它促成了产品中的两项改动:
- 点击会自行关闭的按钮时,过去会触发两次点击(派发后才进行命中测试,发现目标已消失,于是回退到第二次点击)。这会导致重复提交、重复提及。现在会先进行命中测试,再派发点击,不再回退。
- 字段级错误过去不可见——它们由没有 ARIA 角色的裸
<span>承载,并被当作布局内容清理掉。现在,这些错误会显示在context.errors/context.field_errors中,并会扫描整个文档,而不只是对话框容器内的内容。 - 过去有三种读取依赖容器的类名。红色星号只会在类名恰好包含
modal/dialog/popup/… 的容器内扫描,因此普通页面表单中的必填字段不可见;modals[].fields[]条目没有id,因此无法将某个字段的错误读取结果映射回该字段;裸<span>/<div>中的辅助文本(字符计数、格式提示)则会从语义树中清理掉,且没有其他获取途径。现在,这三种信息都位于context的顶层:required_hints会扫描整个文档,fields[]条目在modals[]和forms[]中具有相同结构(包含id,并通过真实 DOM 验证),表单旁的裸<span>文本则会进入hints——语义树本身仍会清理这些内容,因此 token 开销不变。
npm run capabilities — 测试向各种文本字段输入内容(普通输入框、password、number、textarea、contenteditable,以及 readonly / disabled / maxlength),包括中日韩输入和 1000 字符文本;读取页面源代码、捕获响应正文,以及通过 ui.evaluate 编辑页面。
npm run network — 验证可以捕获带完整请求正文的 POST 请求、默认会对标头脱敏,以及正文会分页显示,而不是被静默截断。它还固定了淘汰规则:保留的字节预算在所有标签页间全局共享,因此后台标签页悄悄占满预算时,过去会淘汰 Agent 正要在当前标签页读取的正文——请求确实已发送,正文也确实已捕获,只是被其他标签页的噪声挤出去了。现在会优先淘汰非当前标签页的内容(包括正在存储的后台正文),而 ui.network_configure 会报告 active_tab / body_bytes,这样将来发生 evicted 时就能查明原因,而不是靠猜测。
npm run modeldiff — 测试过去 ui.diff 会说谎的唯一场景。使用 canvas 渲染的应用,其 DOM 就像一张静止照片:拖动节点或编辑控件会重绘 <canvas>,但元素遍历看不到任何变化,因此仅基于 DOM 的默认快照会对明明已经发生的写入回答 unchanged——包括 ComfyUI 文档所述的写入路径 node.widgets[].value,它在 DOM 中产生的变化恰好为零字节。该测试套件在自行构建的 fixture 上验证模型指纹(从不操作他人的实时项目,因为吸附和量化会让“移回原位”无法成立):
- 添加节点 →
verdict: changed,且 diff 路径仅为model.count/model.fp——没有nodes.*,也没有text*,这能证明变化归功于模型,而不是 DOM 恰好发生了变化; - ComfyUI 场景——在节点数量不变的情况下更改一个控件值 → 仍为
changed,且具体表现为model.fp,没有model.count; - 两个方向都要验证:未发生变化的页面必须保持
unchanged,而没有应用对象的普通页面绝不能出现model字段(避免机制凭空捏造候选信号而产生误报); - 模型信号不得掩盖 DOM 变化——两者都发生变化时,都必须报告。
该测试套件固定了两个设计要点,都是从教训中总结出来的:
- 筛选依据是实际能读回什么,而不是名称看起来有多有前景。库命名空间在名称方面得分很高,却没有可返回的模型,因此返回 null 才是将其排除的原因;
- 排名取决于对象拥有的最强访问器,而读取则会在存在
serialize()时通过它进行(它会返回可安全转换为 JSON 的数据;实时集合通常存在循环引用)。把两者合并,会导致每个候选项都报告serialize,结果全部并列,而单纯的序列化器可能排在真正的模型前面——这个 bug 是通过平局处理测试发现的,而不是代码审查。
Canvas:通过绘制调用读取,而非像素
语义树只会将 <canvas> 报告为 role: canvas,不会提供更多信息——没有文本,也没有像素。browse_canvas 无需截取屏幕截图即可解决这个问题:它会向页面的主世界注入一个记录器,并包装 Canvas API,以捕获绘制调用本身。
关键在于,canvas 是由代码绘制的,因此代码已经知道自己绘制了什么。fillText 会直接提供字面字符串——canvas 内的文本无需 OCR:
// browse_canvas(operation: "read", canvas_id: "canvas:nav-…:frame-main:1")
{
"kind": "2d", "readability": "semantic",
"texts": [
{ "seq": 2, "method": "fillText", "text": "确认支付",
"bounds_canvas": { "x": 20, "y": 40, "width": 120, "height": 30 },
"style": { "font": "20px sans-serif", "fill_style": "#000000" } }
],
"regions": [ { "seq": 3, "kind": "rect", "method": "fillRect", "bounds_canvas": {…} } ]
}
记录器采用并行转接方式,绝不接管原有行为:它先调用原生 API(Reflect.apply(original, …)),然后才在自身的 try/catch 中进行记录。记录失败绝不会改变返回值、异常、this 或绘制顺序。
请注意以下两个限制:
- 钩子会在页面首次加载后安装,因此首次加载期间绘制的内容无法恢复。
browse_canvas(operation: "configure", reload: true)会在钩子就位的情况下重新加载,并提供完整历史记录。(根本原因是,在 Electron 中,对刚创建的标签页发送的 CDP 命令若使用 await 会一直挂起,因此安装必须等到did-finish-load。) - WebGL 不透明。 顶点、纹理和着色器无法还原为业务含义,文本通常也只是字形图集。此类 canvas 会标记为
opaque,而不会进行猜测。仅在这种情况下,operation: "capture"才会返回 canvas 区域的 PNG——默认关闭,需要显式调用,并且会消耗 你的 Agent 的视觉 Token。
设置 AI_BROWSER_CANVAS_HOOK=0 可完全禁用。
使用前值得了解的边界
node.value最长为 200 个字符。 这是我们的限制,不是 CDP 的限制。截断时,节点会携带value_truncated: true、value_full_length和可直接调用的value_fetch提示——使用browse_act(action: "get_value"),它是只读的,并支持分页。- 密码字段不会公开任何值——只会提供
value_sensitive: true。语义树会整体传入你的 Agent 上下文,因此不应包含机密信息。 - 每个标签页都会广播
js_error(每个事件都带有tabId),因此顺序和索引都不保证稳定。请按tabId筛选;不要依赖“第一个错误就是我关心的那个”。 - 只有真实指针能够命中的“连接手柄”才会被使用。 许多网站会将手柄渲染为
pointer-events: none+opacity: 0装饰元素(已在 rhtv 画布上验证:所有手柄均为pe:none / op:0)。以此类元素为锚点只会按下节点主体——因此解析器会使用elementFromPoint自检,并回退到元素边框的中点,同时报告via: "edge"。如果你预期会命中手柄,却看到via: "edge",这就是实际情况:该位置不支持拖动连接,网站必须通过其他方式连接节点。 ui.scroll用于滚动文档。 它会执行window.scrollBy,因此在基于画布的编辑器、地图和图表上不会产生效果。请在这些场景使用ui.act{action:'wheel'}。
npm run realsites 使用 e2e/realsites/sites.cjs 中的表格驱动,目前涵盖 8 个类别(搜索、金融、媒体、开发、电商、SPA、编辑器、市场)的 17 个网站。运行结果会输出两个数字,并且有意不将它们合并为一个:
- 行为正确率 / behaviour rate — 浏览器是否按预期运行?需要登录的网站或返回反机器人拦截页的网站,只要能正确检测到拦截页,就计为通过,因为这意味着检测有效。
- 内容成功率 / content rate — 我们是否实际读取了内容?此项仅统计树节点数高于网站
minNodes的网站;拦截页不算内容。
如果只报告一个“成功率 %”,那么正确检测到的登录拦截页就可能抬高内容成功率。运行结果还会输出语料库大小、每站运行次数、运行总数和时间戳,因此报告中的任何百分比都可以重新计算,而不必盲目相信。
布局:
src/main/ Electron main — ws_server · page_manager (tabs, CDP) · mcp_server · mcp_tools · axExtractor
src/preload/ bridge · extractor (semantic tree, data-ai-id) · actions · watcher
src/renderer/ tab bar UI
src/shared/ config (AI_BROWSER_* env) · guards (evaluate safety) · protocol · watchdog
e2e/ smoke_ws.cjs · realsites/
tests/ vitest unit suites
skills/ optional higher-level recipes built on the tools
src/shared/config.js 是端口、用户数据目录和 UA 覆盖项的唯一来源;guards.js 是 evaluate 限制的唯一来源,并在两种传输方式上以相同方式强制执行。
兼容性
| 维度 | 支持情况 |
|---|---|
| Agent | 任何 MCP stdio 客户端;也可以使用任何语言通过原始 WS 连接 |
| Node | ≥ 18 |
| OS | macOS 和 Linux — 在这些系统上开发和测试。Windows:实验性支持,未经测试。 它使用纯 Electron 和 CDP,因此应该可以构建,但尚无人运行;请自行排查 npm install 和窗口层问题。无论结果如何,都欢迎反馈。 |
| 页面框架 | React / Vue / Angular / 原生 DOM — 元素定位基于 DOM,而非特定框架 |
| 编辑器 | 通过可信输入驱动的富文本编辑器,而非使用针对各框架的代码路径 |
| 页面类型 | 静态页面和 SPA;可通过树、dom_change / state_changed 事件观察动态更新 |
贡献
欢迎提交问题和 PR,尤其是语义树未能正确处理某个页面的复现案例—— 这是本项目最有价值的错误报告。
问题、“语义树未能正确处理这个页面”的报告,以及 Agent 运行日志,最好发布到 GitHub 讨论区——本项目最有价值的反馈通常都集中在那里。
我们坚持以下原则:
- 保持核心通用。 特定网站的知识应放在
src/main和src/preload之外。 - IPC 始终按请求 ID 匹配,CDP 会话始终采用引用计数——否则标签页会泄漏。
- 不使用截图。 如果某项变更需要依赖像素才能运行,那么设计问题应在更上游解决。
evaluate始终受到保护(长度上限、不允许 Node 标识符),并且两种传输方式都要同时遵守。- 行为变更时要更新 Agent 指令。 MCP 工具列表、
ws_server.js方法和examples/AGENT_INSTRUCTIONS.md必须保持一致;文档与mcp_tools.js不一致就是一个错误。
许可证
MIT © Glittering
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。