phoenix-pc · Backend C

内置浏览器技术原理

把"内置浏览器"这件事从用户视角拆到工程实施,覆盖 CDP / WebContentsView / snapshot+ref / partition / Driver 路线,读完就能做决策。

📅 2026-05-15 🔖 内部技术评审 📂 docs/browser/iab-tech-principle.html

第一层 · 用户视角它长什么样 #

phoenix-pc 桌面 App 主窗口右侧(或独立窗口)多一块"浏览器面板"。这块面板就是个完整浏览器:

跟现有"用户的 Chrome + Phoenix 扩展"那套 backend 的核心区别:浏览器内核是 App 自己装的,用户什么都不用准备

第二层 · 5 个核心工程问题 #

把"内置浏览器"这件事分解成 5 个问题,逐个回答:

Q1 浏览器内核哪里来的? #

phoenix-pc 桌面端是 Electron。Electron = Node.js + ChromiumApp 启动时已经把 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…)。

关键事实 CDP 是"全集语言",能表达 agent 想对网页做的任何事 —— 点击 / 输入 / 滚动 / 导航 / 截图 / 读 DOM / 拦截网络 / 执行 JS。所有 agent 浏览器自动化框架本质都在发 CDP,差别只在 CDP 命令的传输路径
BackendCDP 命令传输路径谁起浏览器
A · 桌面Phoenix Relay Server (本地 WS) → Chrome 扩展 → chrome.debugger.sendCommand用户自己启动 Chrome
B · Web浏览器页面 chrome.runtime.connect → 扩展 → chrome.debugger用户自己启动 Chrome
C · 内置Electron 主进程 webContents.debugger.sendCommand 直接发给同进程内的 ChromiumApp 自己起,用户什么都不用做

C 最直接 —— 浏览器就在 App 里,主进程拿 webContents 对象就能调 .debugger.attach() + .sendCommand()完全没有跨进程通信

Q4 用户和 agent 同时用一个浏览器,会不会冲突? #

会,需要处理 3 个冲突场景:

冲突 1 · Cookie / 登录态怎么共享

用单一 partition(persist:phoenix-iab),所有 tab 共享 cookie 池。用户在 a.com 手动登录一次,agent 后续操作 a.com 自动带 cookie。

反例 每个对话一个 partition → 每个对话都要重新登录,体验很差。Codex 反编译验证用的也是单 partition(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 一点击,用户的输入被中断。两种方案:

MVP 阶段先选简单方案,后续优化时再加翻译器。

Q5 agent 怎么知道"页面长什么样、哪里能点"? #

agent 不是人,看不懂截图(多模态视觉模型可以,但贵且不准)。需要把页面变成结构化文本。phoenix-pc 现在的做法(packages/app/src/browser/runtime/actions/snapshot.ts,已经成熟):

  1. 调 CDP Accessibility.getFullAXTree() 拿可访问性树(每个节点带 backendDOMNodeId
  2. 用一个剪枝管线把它压缩成 YAML,给可交互的元素分配 [ref=eN] 编号:
- document "Bilibili 首页":
    - banner:
        - link "首页" [ref=e3]
        - searchbox [ref=e7] placeholder="搜你想搜的"
        - button "搜索" [ref=e8]
    - main:
        - heading "推荐" level=2
  1. agent 想点搜索按钮 → 说 browser_click(ref="e8")
  2. SDK 通过 ref 表反查 backendDOMNodeId → CDP DOM.getBoxModel 拿坐标 → 发 Input.dispatchMouseEvent 点击

这套 snapshot+ref+act 协议 phoenix-pc 已经有了(在 packages/app/src/browser/runtime/),而且做得比 Codex 更成熟(Codex 用的是 in-page DOM walker,phoenix-pc 用 CDP Accessibility 直接拿,更标准)。

本次实施的真正核心 不是"做这套协议",而是"让这套协议跑在 IAB 这条新链路上"。

第三层 · phoenix-pc 现状与本次新增 #

已有(不用动)

模块路径角色
Tool 公开契约packages/sdk/.../search/browser.tsLLM 看到的 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.tsbackendNodeId ↔ eN 映射
独立窗口浏览器预览apps/desktop/src/main/browser/window.ts已用 WebContentsView,已用单 partition
Chrome 扩展assets/chrome-extension/accio-browser-relay/A/B 共用,本次 C 不需要

本次新增

新模块位置干什么
EmbeddedDriverpackages/sdk/.../iab/driver.ts Backend C 的入口,对外是 SDK 调用,对内调 IabTransport
IabTransportapps/desktop/src/main/iab/transport.ts transport.call('CDP.send', ...) 翻译成 webContents.debugger.sendCommand(...)实现现有 ExtensionTransport 同款接口,让 actions 直接复用
IabPanelManagerapps/desktop/src/main/iab/panel.ts 管理 WebContentsView 面板(创建/销毁/侧边栏 vs 独立窗口切换/绑定 conversationId)
IabTabRegistryapps/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 跑通。

路线 X · 推荐

先做 IAB,并行抽 BrowserDriver

工期 2-3 周 出 alpha

  • EmbeddedDriver 直接跨包引用 packages/app/src/browser/runtime/actions/*
  • 新写 IabTransport(实现 ExtensionTransport 同款接口)
  • 背"action 实现暂时三套"的债
  • P1 阶段并行抽 BrowserDriver 接口
路线 Y

先做 P0 重构,再上 IAB

工期 4-6 周 出 alpha

  • 先抽 BrowserDriver 接口 + shared-actions/
  • 把 Backend A/B 都迁到新架构上
  • 然后 IAB 作为第三个 Driver 接入
  • 架构最干净,但重构期影响线上稳定
路线 Z

并行:两条线独立推进

工期 3-4 周 折中

  • 一条线做 IAB(临时桥接)
  • 一条线做 P0 重构
  • 合并时切换 IAB 到 BrowserDriver 接口
  • 需要协调 owner / merge 时机

node_repl 单独议题(与"内置浏览器"无强耦合)

语雀那份调研强烈推荐的"node_repl + JS SDK 范式"是 "模型调浏览器的接口风格",跟"内置浏览器有没有"无关:

收益巨大: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 件事:

  1. 路线 X / Y / Z 选哪个(决定工期长短和重构风险)
  2. node_repl 在本次方案里怎么处理(不涉及 / plan 末尾列 P3 / 一并做)

选好之后,就可以进入完整 plan 撰写。