内置浏览器技术原理
把"内置浏览器"这件事从用户视角拆到工程实施,覆盖 CDP / WebContentsView / snapshot+ref / partition / Driver 路线,读完就能做决策。
第一层 · 用户视角它长什么样 #
phoenix-pc 桌面 App 主窗口右侧(或独立窗口)多一块"浏览器面板"。这块面板就是个完整浏览器:
- 用户能输入 URL、点链接、填表单、看视频,跟用 Chrome 没区别
- 同时 agent 也能"操作"这个浏览器:自己输 URL、自己点按钮、自己截图、自己读页面内容
- 用户不用装 Chrome,不用装扩展,Linux 用户也能用
跟现有"用户的 Chrome + Phoenix 扩展"那套 backend 的核心区别:浏览器内核是 App 自己装的,用户什么都不用准备。
第二层 · 5 个核心工程问题 #
把"内置浏览器"这件事分解成 5 个问题,逐个回答:
Q1 浏览器内核哪里来的? #
phoenix-pc 桌面端是 Electron。Electron = Node.js + Chromium。App 启动时已经把 Chromium 整个跑起来了,每个窗口都是 Chromium 渲染的。所以浏览器内核已经在那儿了,不用额外装。
不需要打包额外的 Chrome;不需要 puppeteer 起一个独立 Chromium 进程;不需要任何外部依赖。
Q2 那块"浏览器面板"用什么 API 嵌? #
Electron 有 3 种把"网页"嵌进窗口的方式:
| 方式 | 状态 | phoenix-pc 现状 |
|---|---|---|
<webview> HTML 标签 | 老 API,已 deprecated 但还能用 | 没用 |
BrowserView | 老 API,已 deprecated | 没用 |
WebContentsView | 新 API,官方推荐 | 已经在用(apps/desktop/src/main/browser/window.ts 的独立窗口预览功能) |
phoenix-pc 已经在用 WebContentsView,本次直接沿用。一行代码就拿到一个浏览器面板:
import { WebContentsView } from 'electron'
const browserPanel = new WebContentsView({
webPreferences: {
partition: 'persist:phoenix-iab', // cookie/storage 隔离桶
sandbox: true, // 安全
contextIsolation: true,
}
})
mainWindow.contentView.addChildView(browserPanel)
browserPanel.setBounds({ x: 800, y: 0, width: 600, height: 800 }) // 放右侧
browserPanel.webContents.loadURL('https://example.com')
这就完成了"在主窗口右侧嵌一个浏览器"。用户就能用了。
Q3 agent 怎么"控制"这个浏览器面板? #
WebContentsView 暴露 webContents 对象,它有个属性叫 .debugger,这就是 Chrome DevTools Protocol(CDP)的入口。
// 第一次连接(一个面板调用一次就够)
browserPanel.webContents.debugger.attach('1.3')
// 之后所有"控制"都是发 CDP 命令
await browserPanel.webContents.debugger.sendCommand('Page.navigate', {
url: 'https://example.com'
})
await browserPanel.webContents.debugger.sendCommand('Page.captureScreenshot')
// → { data: 'iVBORw0KGgo...' } (PNG base64)
await browserPanel.webContents.debugger.sendCommand('Input.dispatchMouseEvent', {
type: 'mousePressed',
x: 540, y: 128,
button: 'left',
clickCount: 1
})
CDP 是什么:Chrome 官方调试协议。Chrome DevTools 调试网页用的就是它,Puppeteer / Playwright 底层调用的也是它。是个 JSON-RPC,命令分"域"组织(Page、Input、DOM、Runtime、Network、Accessibility…)。
| Backend | CDP 命令传输路径 | 谁起浏览器 |
|---|---|---|
| A · 桌面 | Phoenix Relay Server (本地 WS) → Chrome 扩展 → chrome.debugger.sendCommand | 用户自己启动 Chrome |
| B · Web | 浏览器页面 chrome.runtime.connect → 扩展 → chrome.debugger | 用户自己启动 Chrome |
| C · 内置 | Electron 主进程 webContents.debugger.sendCommand 直接发给同进程内的 Chromium | App 自己起,用户什么都不用做 |
C 最直接 —— 浏览器就在 App 里,主进程拿 webContents 对象就能调 .debugger.attach() + .sendCommand(),完全没有跨进程通信。
Q4 用户和 agent 同时用一个浏览器,会不会冲突? #
会,需要处理 3 个冲突场景:
冲突 1 · Cookie / 登录态怎么共享
用单一 partition(persist:phoenix-iab),所有 tab 共享 cookie 池。用户在 a.com 手动登录一次,agent 后续操作 a.com 自动带 cookie。
persist:codex-browser-app-route:app)。
冲突 2 · tab 怎么管理
用户开的 tab + agent 开的 tab 共存。需要一个 tab 注册表(tabIdsByPageKey / BrowserTabRegistry),每个 tab 有 owner 标识(user/agent)。agent 操作 tab 时只允许操作"agent owned"的 tab,避免误操作用户正在看的页面。
冲突 3 · 焦点抢占
Input.dispatchMouseEvent 会让 owner BrowserWindow 失焦 —— 即用户在主聊天框打字时 agent 一点击,用户的输入被中断。两种方案:
- 直接发 CDP
Input.*(简单,但有失焦问题)—— phoenix-pc 现有tier0-actions/act.ts走这个 - 把
Input.*翻译成在页面里执行的 DOM 事件(element.dispatchEvent(new MouseEvent(...))),通过webContents.executeJavaScript()注入 —— Codex IAB 走这个,因为它非常关心保 owner 持焦
MVP 阶段先选简单方案,后续优化时再加翻译器。
Q5 agent 怎么知道"页面长什么样、哪里能点"? #
agent 不是人,看不懂截图(多模态视觉模型可以,但贵且不准)。需要把页面变成结构化文本。phoenix-pc 现在的做法(packages/app/src/browser/runtime/actions/snapshot.ts,已经成熟):
- 调 CDP
Accessibility.getFullAXTree()拿可访问性树(每个节点带backendDOMNodeId) - 用一个剪枝管线把它压缩成 YAML,给可交互的元素分配
[ref=eN]编号:
- document "Bilibili 首页":
- banner:
- link "首页" [ref=e3]
- searchbox [ref=e7] placeholder="搜你想搜的"
- button "搜索" [ref=e8]
- main:
- heading "推荐" level=2
- agent 想点搜索按钮 → 说
browser_click(ref="e8") - SDK 通过 ref 表反查
backendDOMNodeId→ CDPDOM.getBoxModel拿坐标 → 发Input.dispatchMouseEvent点击
这套 snapshot+ref+act 协议 phoenix-pc 已经有了(在 packages/app/src/browser/runtime/),而且做得比 Codex 更成熟(Codex 用的是 in-page DOM walker,phoenix-pc 用 CDP Accessibility 直接拿,更标准)。
第三层 · phoenix-pc 现状与本次新增 #
已有(不用动)
| 模块 | 路径 | 角色 |
|---|---|---|
| Tool 公开契约 | packages/sdk/.../search/browser.ts | LLM 看到的 18 个 tool(browser_navigate / browser_click / browser_snapshot…) |
| Action 实现 | packages/app/src/browser/runtime/actions/* | snapshot/act/screenshot/scroll/navigate/tabs/console/errors/requests 等 9 个 |
| Transport 接口 | extension-transport.ts | 定义 transport.call('CDP.send', { targetId, cdpMethod, cdpParams }) 这种调用形态 |
| ref 注册表 | ref-registry.ts | backendNodeId ↔ eN 映射 |
| 独立窗口浏览器预览 | apps/desktop/src/main/browser/window.ts | 已用 WebContentsView,已用单 partition |
| Chrome 扩展 | assets/chrome-extension/accio-browser-relay/ | A/B 共用,本次 C 不需要 |
本次新增
| 新模块 | 位置 | 干什么 |
|---|---|---|
| EmbeddedDriver | packages/sdk/.../iab/driver.ts 新 | Backend C 的入口,对外是 SDK 调用,对内调 IabTransport |
| IabTransport | apps/desktop/src/main/iab/transport.ts 新 | 把 transport.call('CDP.send', ...) 翻译成 webContents.debugger.sendCommand(...)。实现现有 ExtensionTransport 同款接口,让 actions 直接复用 |
| IabPanelManager | apps/desktop/src/main/iab/panel.ts 新 | 管理 WebContentsView 面板(创建/销毁/侧边栏 vs 独立窗口切换/绑定 conversationId) |
| IabTabRegistry | apps/desktop/src/main/iab/registry.ts 新 | 多 tab 管理(每个 tab 一个 WebContentsView,记 owner 是 user 还是 agent) |
| IPC 通道 | apps/desktop/src/main/iab/ipc.ts 新 | renderer ↔ main,承载 iab:open / iab:close / iab:cdp-call / iab:tab-list |
| browser.ts 路由 | 修改 packages/sdk/.../search/browser.ts 改 | 增加 toolImplementation = 'iab' 分支,路由到 EmbeddedDriver |
核心数据流
LLM: browser_click({ref: "e7"})
│
▼ packages/sdk/.../browser.ts
if (impl === 'iab') driver = EmbeddedDriver
else if (impl === 'app') driver = WebDriver
else driver = DesktopDriver
│
▼
driver.act({ref: "e7"}) ← 这个方法的实现复用 packages/app/src/browser/runtime/actions/act.ts
│
▼ act.ts 调 transport.call('CDP.send', { cdpMethod: 'DOM.getBoxModel', ... })
│
▼ 这里 transport 是 IabTransport(新写)
IabTransport 通过 Electron IPC 把请求发到 main 进程
│
▼
main 进程 IabBackend 收到,调
webContents.debugger.sendCommand('DOM.getBoxModel', ...)
│
▼
Chromium 内核返回结果 → 沿原路返回给 act.ts → 再算坐标发
webContents.debugger.sendCommand('Input.dispatchMouseEvent', ...)
│
▼
点击完成
act.ts 不知道也不关心自己跑在哪个 backend 上 —— 它只跟 transport 接口对话。这就是为什么写一个 IabTransport 就能复用整套 actions。
第四层 · 本次实施的 6 个关键技术决策 #
| # | 决策项 | 建议方案 |
|---|---|---|
| 1 | actions 复用方式 | 跨包引用 packages/app/src/browser/runtime/actions/*(路线 X 思路)or 先抽 shared-actions/(路线 Y 思路)or 临时桥接(路线 Z 思路) |
| 2 | UI 形态 | 已选 侧边栏 + 独立窗口可切:默认嵌主窗口右侧,用户可一键 detach 成独立 BrowserWindow |
| 3 | Input 抢焦点处理 | MVP:直接发 CDP Input.*(沿用现有 act.ts)P1:加 in-page JS 翻译器保焦点(参考 Codex gP()) |
| 4 | 多 tab 管理 | MVP:单 tab(agent 操作的就是当前显示的那一个) P1:多 tab + tab bar UI + agent 控制 tab 切换 |
| 5 | 会话隔离 | 已选 单 partition persist:phoenix-iab,所有对话共享 cookie;不按 conversationId 分 partition |
| 6 | 外部 CLI 接入 | 已选 不做 native pipe / peer auth,所有 agent 都跑在桌面端 App 内,纯 Electron IPC 通信 |
第五层 · 路线选项 #
路线 X / Y / Z 的差别只在 "和现有 A/B 的解耦时机",最终目标都是让 IAB 跑通。
先做 IAB,并行抽 BrowserDriver
工期 2-3 周 出 alpha
- EmbeddedDriver 直接跨包引用
packages/app/src/browser/runtime/actions/* - 新写
IabTransport(实现 ExtensionTransport 同款接口) - 背"action 实现暂时三套"的债
- P1 阶段并行抽
BrowserDriver接口
先做 P0 重构,再上 IAB
工期 4-6 周 出 alpha
- 先抽
BrowserDriver接口 +shared-actions/ - 把 Backend A/B 都迁到新架构上
- 然后 IAB 作为第三个 Driver 接入
- 架构最干净,但重构期影响线上稳定
并行:两条线独立推进
工期 3-4 周 折中
- 一条线做 IAB(临时桥接)
- 一条线做 P0 重构
- 合并时切换 IAB 到 BrowserDriver 接口
- 需要协调 owner / merge 时机
node_repl 单独议题(与"内置浏览器"无强耦合)
语雀那份调研强烈推荐的"node_repl + JS SDK 范式"是 "模型调浏览器的接口风格",跟"内置浏览器有没有"无关:
- 当前:模型调
browser_navigatebrowser_click等 18 个 tool,每次操作一次 LLM 往返,做一个登录流程要 8 次 - node_repl:模型一次写一段 JS,里面用
await tab.click(...) await tab.fill(...),一次 LLM 往返做一长串操作
收益巨大:LLM 调用降 50-70%,token 降 40%+,端到端耗时降 50%+。但要做:runtime(vm.runInContext)+ SDK 适配(Codex 风格 tab.playwright.getByRole)+ SKILL.md 翻译 + 灰度路由。建议作为单独立项,本次方案不展开。
总结一张图 #
现状 本次目标
───── ────
新增 ── EmbeddedDriver(C)
Backend A (Desktop) ───┐ Backend A ──┐
└─ Playwright │ Backend B ──┼─ 都暴露 BrowserDriver 接口(路线 Y/Z 才做)
│ EmbeddedDriver ──┘
Backend B (Web) ───────┤
└─ tier0-actions │ actions 实现 ── 三方复用(路线 Y/Z 目标)
│ partition ── 单一 persist:phoenix-iab
✗ 没有 Backend C │ UI 形态 ── 侧边栏 + 独立窗口可切
│ tab 管理 ── MVP 单 tab,P1 多 tab
Linux 用户用不了 ✗ CLI 外部接入── MVP 不做
新用户必须装扩展 ✗ comment 标注── MVP 不做
node_repl ── 单独议题
最后要敲定的事 #
看完上面的技术原理,需要敲定的就 2 件事:
- 路线 X / Y / Z 选哪个(决定工期长短和重构风险)
- node_repl 在本次方案里怎么处理(不涉及 / plan 末尾列 P3 / 一并做)
选好之后,就可以进入完整 plan 撰写。