DeepChat Agent 浏览器画中画:从后台渲染宿主到 NativeKit 原生 PiP 面板的实现解析
【免费下载链接】deepchat🐬DeepChat - A smart assistant that connects powerful AI to your personal world项目地址: https://gitcode.com/GitHub_Trending/dee/deepchat
本文围绕 DeepChat 的 Agent 浏览器预览规格(docs/features/agent-browser-pip/spec.md)展开,讲解当 Agent 会话在后台操作网页时,DeepChat 如何用「单一实时页面 + 后台渲染宿主 + 原生只读 PiP 面板」三者协作,让用户在不打开侧边栏的情况下看到 Agent 正在访问的页面。读完后你可以理解页面 reparent 的约束模型、捕获帧的序列化管线、原生覆盖层的进程级所有权仲裁,以及面板交接(panel handoff)与 run 级关闭的完整生命周期规则,并能对照源码定位到每个契约的落点。
背景:为什么需要一套独立的 Agent 浏览器呈现层
DeepChat 内置了名为 YoBrowser 的嵌入式浏览器能力,Agent 可以通过load_url、cdp_send等工具直接导航页面并执行 CDP 命令。当 Agent 在后台默默操作网页时,用户在聊天窗口里是「盲操作」的:看不到 Agent 正在加载哪个页面、滚动到哪、点击了哪里。PiP(Picture-in-Picture)预览正是为此设计的呈现层:
- 右侧面板保持关闭时,符合条件的 Agent 会话可以弹出一个原生只读预览,展示 Agent 页面的实时画面;
- 如果右侧已经打开了任何面板(或原生能力不可用),Agent 的浏览器动作会走既有的 Browser 侧边栏,而不是强行弹出 PiP;
- 预览是只读图像,不是第二个浏览器、更不是页面的另一个原生父级;打开面板就是把同一个实时页面「搬」进面板。
规格文档明确当前状态:单页 Agent 浏览器预览与面板交接已实现;可见多标签条、Fit desktop 和完整响应式/展开页面工作流被推迟到 docs/features/agent-browser-pip/plan.md 与 docs/features/agent-browser-pip/tasks.md 中继续跟踪;原生呈现层的平台交互与性能验证仍是开放项。原生的窗口拖拽、工作区钳制与 z-order 由 NativeKit 定义,见 docs/architecture/nativekit-agent-browser-pip/spec.md。此外,Computer Use 快照 与 Browser 共享同一份「进程全局呈现所有权」,但两者的功能契约并不合并——Browser 仍然独立拥有自己的页面、捕获循环、面板交接和 run 级关闭。
组件职责图:谁拥有什么
规格中的「Current Ownership」一节把职责切得很干净,源码逐条对得上:
| 组件 | 路径 | 职责 |
|---|---|---|
YoBrowserPresenter | src/main/desktop/browser/YoBrowserPresenter.ts | 主进程侧唯一持有者:每个聊天会话一份SessionBrowserState,包含实时WebContentsView、页面封装、owner(agent/user)、Agent run 标识、放置信息、预览宿主、捕获状态 |
YoBrowserToolHandler | src/main/desktop/browser/YoBrowserToolHandler.ts | 接受来自 Agent 执行侧的 run 标识并转发给 presenter;工具事件携带 owner/source 与 run 标识 |
ChatSidePanel.vue | src/renderer/src/components/sidepanel/ChatSidePanel.vue | 为「显式用户导航」或「面板已打开」打开 Browser;单独的 Agent open 请求不会强行打开关闭中的面板 |
BrowserPanel.vue | src/renderer/src/components/sidepanel/BrowserPanel.vue | 提供稳定的可见 bounds,通过类型化客户端管理页面附着 |
AgentBrowserPiP.vue | src/renderer/src/components/browser/AgentBrowserPiP.vue | 推导当前会话/工作 run/窗口/面板资格,并对预览模式请求做合并(coalescing) |
AgentPreviewCoordinator | src/main/desktop/preview/AgentPreviewCoordinator.ts | 拥有跨 Browser 与 Computer Use 的「唯一可见原生呈现」;NativeKit 拥有拖拽、显示工作区钳制与原生 z-order;渲染进程只收到来源特定的动作与 surface 状态,原生图像帧不经过渲染进程 IPC |
yoBrowserSession.ts | src/main/desktop/browser/yoBrowserSession.ts | 从捆绑运行时推导桌面 Chromium 用户代理(process.versions.chrome),窄页面 bounds 不会把浏览器切换为移动端/触摸语义 |
关键设计点是:当前公开状态是每会话一个页面,而不是推迟中的多标签工作区模型。SessionBrowserState在 YoBrowserPresenter.ts 中定义,字段包含previewHost(后台BaseWindow)、previewMode(三种预览模式)、previewSurface(native-overlay/renderer-canvas/none)、previewEpoch、previewClaimSequence等——后两者正是下文「epoch 失效」与「进程级 claim」机制的载体。
实时页面与后台渲染宿主:一个页面,两种父级
这是整个方案的骨架。面板关闭期间,Agent 页面停在一个无焦点的后台渲染宿主BaseWindow中;该宿主只为 Chromium 提供显示表面,透明、位于屏幕外,且不接收任何用户输入。后台页面的 viewport 固定为1280 × 800,与预览图尺寸无关:
Background render host +-- Agent WebContentsView at 1280 x 800 +-- capture -> resize/encode -> NativeKit read-only panel Chat BrowserWindow +-- trusted chat renderer +-- Browser panel: the same Agent WebContentsView when visible约束是「每页视图的原生父级数 <= 1」:
panel => 父级是聊天宿主 contentView render-host => 父级是后台宿主 contentView(1280 x 800) detached => 无原生父级打开面板时,是把这同一个页面视图 reparent 进可见面板,URL、DOM、滚动位置、cookie、可聚焦页面状态、CDP 身份全部原样保留——不重新导航、不复制状态、不新建 CDP target。父级切换与捕获状态迁移都归主进程所有,切换规则是「先移除旧父级再加新父级」,并用当前 epoch 使过期的异步捕获工作失效。
ensurePreviewHost的实现印证了这些约束(YoBrowserPresenter.ts):
host = new BaseWindow({ x: -10000, y: -10000, // 屏幕外 width: PREVIEW_VIEWPORT.width, // 1280 height: PREVIEW_VIEWPORT.height, // 800 show: !isMac, opacity: 0, focusable: false, skipTaskbar: true, hiddenInMissionControl: true, frame: false, transparent: true }) host.setIgnoreMouseEvents(true)值得注意的一个平台细节写在源码注释里:macOS 上隐藏宿主再「解除节流」会破坏捕获(引用了 Electron PR 行为),所以 macOS 走host.showInactive()保持帧活跃,而其它平台在宿主隐藏时才调用setBackgroundThrottling(false)。宿主closed事件里会同步释放 claim、复位previewMode为stopped、递增previewEpoch并恢复背景节流——这就是「主进程不等渲染进程清理就处理宿主销毁」的落地方式。
预览模式、资格与捕获管线
预览把「捕获」与「页面渲染」解耦为三种模式,setPreviewMode(YoBrowserPresenter.ts)是它们的唯一入口:
capturing:维护渲染宿主,在资格满足时发布有界帧;rendering:页面继续对 Agent 工具可用,但抑制预览捕获(例如用户正要点「在面板中打开」);stopped:停止捕获并按页面生命周期释放预览宿主。
资格条件(全部满足才捕获):页面归当前 Agent run 所有、会话处于工作(working)状态、拥有前台的聊天窗口、右侧面板关闭、当前 run 未被用户关闭,并且持有当前进程级预览 claim。刷新图像不会从另一个预览来源(如 Computer Use)手中抢走所有权。
「run 相关性」规则也很明确:Agent run 在第一次浏览器动作之后才变得相关——run 一启动就展示空 PiP 是不允许的;权限/提问暂停不是终态结果,暂停、恢复、取消、被顶替(supersession)全程 run 标识与会话状态必须保持一致。
捕获常量集中在 YoBrowserPresenter.ts,是规格「有界捕获」条款的直接落地:
const PREVIEW_VIEWPORT = { width: 1280, height: 800 } // 后台页面 viewport const PREVIEW_FRAME = { width: 400, height: 250 } // 预览输出尺寸 const PREVIEW_ACTIVE_INTERVAL_MS = 500 // 活跃期节奏 const PREVIEW_IDLE_INTERVAL_MS = 2000 // 空闲期节奏 const PREVIEW_MAX_BYTES = 512 * 1024 // 编码输出上限 512 KiBcapturePreviewFrame(YoBrowserPresenter.ts)的单轮流程是:
- 校验
previewMode === 'capturing'、epoch 未变、run 与 claim 仍是当前值、页面未销毁,任一失败直接放弃本轮; capturePage(1280×800 区域, { stayHidden: true })抓帧 →resize(400×250)→toJPEG(72);- 仅当 JPEG 字节数 ≤ 512 KiB 才推送;推给
native-overlay走previewCoordinator.present,推给renderer-canvas走渲染进程 IPC 事件browser.preview.frame; - 整轮结束后才安排下一轮 tick,活跃(页面加载中或处于 1.5 秒活动爆发期)用 500ms,否则用 2000ms。
由此实现规格里的三条硬约束:同一时刻最多一个在途捕获(previewCapture单槽位 +finally里才安排下一个 tick);不合格或已停止的目标不做空闲捕获(下一轮调度前再次isCurrent校验);编码超界的帧被丢弃而不是截断。epoch 机制则保证任何「捕获/换父/停捕」发生时,先前在途的异步帧要么因 epoch 不匹配被丢弃、要么因isCurrent校验失败被拒收——这正是「用当前 epoch 使过期异步捕获工作失效」的实现。
渲染进程侧的 AgentBrowserPiP.vue 负责资格推导与模式合并:eligible组合了requiresRendering(working + owner=agent + 有 run 标识 + 页面已初始化)、窗口聚焦、面板关闭、未关闭当前 run 四个条件(AgentBrowserPiP.vue);previewMode计算属性把「面板打开 / 页面已可见 / 不合格」映射为rendering,把「合格且非紧凑」映射为capturing。所有模式请求并不逐条透传,而是写进pendingPreviewRequest由drainPreviewRequests串行合并发出(AgentBrowserPiP.vue)——这就是规格中「coalesces preview-mode requests」的落点。会话切换时会把旧会话的stopped请求发出,且任何帧解码前的身份/epoch/序列号校验(frameDecodeVersion、latestFrameSequence)确保切换到别的会话时,绝不会短暂暴露上一个会话的帧、URL 或标题。
进程级呈现所有权:AgentPreviewCoordinator
AgentPreviewCoordinator(src/main/desktop/preview/AgentPreviewCoordinator.ts)是 Browser 与 Computer Use 共享的原生呈现仲裁器,封装 NativeKit 的 overlay(@zerob13/nativekit)。其目标身份AgentPreviewTarget是六元组:source / windowId / sessionId / runId / epoch / claimSequence——任何一维不匹配,帧推送就被isCurrent拒收。
核心机制有四个:
- claim(所有权声明):
claim()按会话发号(nextClaimSequence),同一会话的新 run 顶替旧 run 的 claim;matchesClaim要求 claim 的 source、run、序号三方一致。「刷新图像不抢所有权」由这条链路保证。 - dismissedRuns(run 级关闭):
dismiss()把sessionId -> runId记入dismissedRuns,该 run 之后的任何prepare都直接返回none;用户关闭 PiP 只压制当前 run,不关页面、不取消工具、不中断 run。 - 平台门控:
isPublishedTarget(AgentPreviewCoordinator.ts)仅放行darwin:arm64 / darwin:x64 / win32:x64 / linux:arm64 / linux:x64,其余平台标记unavailable并记录unsupported_platform,随后同一激活路径退回 Browser 侧面板。 - 宿主同步:overlay 通过
attachHost绑定聊天窗口(锚定在窗口后缘,偏移 16px,AgentPreviewCoordinator.ts);窗口 focus/blur/show/hide/minimize/move/resize/closed 事件驱动「模糊即隐藏、恢复即重新同步 bounds」(50ms 防抖),显示器增删/度量变化触发重新钳制。
面板工具栏由协调器统一配置(AgentPreviewCoordinator.ts):browser 来源提供Open in side panel(control idopen-panel,触发activate动作)与Close(触发dismiss)两个按钮;Computer Use 来源只有Close。双触发通道是:overlay 的activate(宿主区域被点)与control事件,都经emitAction派发到对应来源注册的 handler——Browser 侧的 handler 是 YoBrowserPresenter.ts 里的handleNativePreviewAction,它在处理前先做 windowId/sessionId/runId/epoch/claimSequence 五元组校验,随后停止捕获、把模式切到rendering、发browser.preview.action事件让渲染进程打开面板。
坐标与性能预算方面,协调器还携带一组常量(AgentPreviewCoordinator.ts):PREVIEW_MAX_EDGE = 360(overlay 尺寸上限)、HOST_ANCHOR_OFFSET = 16、HOST_SYNC_DELAY_MS = 50、SLOW_PUSH_WARNING_MS = 25(单次pushImage超过 25ms 会打一条限频告警,60 秒内最多一条)。帧推送以presentationId(agent-preview:browser:<windowId>:<sessionId>)保持稳定身份,图像替换不重置用户拖拽过的原点——窗口移动/缩放同步与图像替换是两条独立路径。
生命周期:事件—呈现—页面行为对照表
规格给出的完整生命周期矩阵是验收契约的核心,此处完整保留:
| 事件 | 呈现行为 | 页面行为 |
|---|---|---|
| 面板打开时首个 Agent 动作 | 不显示 PiP;激活 Browser | 在面板 bounds 稳定后附着同一页面 |
| 面板关闭时的首个合格 Agent 动作 | 仅在拿到有效帧后显示 | 页面留在渲染宿主中 |
| 原生能力不可用 | 请求 Browser 侧面板激活 | 通过常规面板路径附着同一页面 |
| Browser 面板打开 | 隐藏原生预览并停止捕获 | reparent 进面板 |
| 用户从 Browser 切回 Workspace | 面板保持打开期间不显示 PiP | 按活动 run 需要 detach 或保留后台渲染 |
| 合格 run 中面板关闭 | 显示前先推一帧当前帧 | reparent 回渲染宿主 |
| PiP 关闭 | 压制当前 run 并停止捕获 | 保留页面与活动工具 |
| 原生「在面板中打开」或双击 | 聚焦宿主窗口并请求 Browser | 面板 bounds 稳定后 reparent |
| 宿主失焦/隐藏/最小化 | 同步隐藏 | 仅当 Agent 工作需要时才保留渲染 |
| 合格宿主恢复前台 | 重新评估所有权并显示当前帧 | 不重新加载页面 |
| run 进入终态 | 停止捕获并移除呈现 | 释放渲染宿主,保持常规页面生命周期 |
| 会话/页面销毁或宿主关闭 | 移除呈现与目标 | 释放所拥有的页面/宿主资源 |
| 应用退出 | 只停一次原生 overlay | 走既有幂等的 presenter 清理(shutdown销毁全部会话浏览器并注销 preview handler) |
「首帧先于可见」规则在两侧都有落点:协调器present()内只有在desiredVisible && isHostEligible时才setVisible(true);而捕获失败时保留上一张同目标有效图并在下一个有界 tick 重试,绝不闪空图或切目标。
失败处理与安全约束
规格对失败与安全的条款,在源码中逐条可查:
- 初始化/宿主附着失败 ⇒ 进程级禁用原生能力并打开 Browser 面板。
disableNative(toolbar_update_failed/host_attach_failed路径)置unavailable = true并stopNative();一次性「坏帧」不会改道到renderer-canvas——renderer-canvas只对其既有调用方可用,不是失败自动降级项(YoBrowserPresenter.ts 的openBrowserPanelForUnavailablePreview只在!isAvailable()时激活面板)。 - 拿不到稳定面板 bounds ⇒ 保留/恢复预览并报告可恢复的交接失败,不重载页面、不重复附着。
- 远端页面失败走既有 Browser 错误行为与工具错误(
YoBrowserUnavailableError,见 src/main/desktop/browser/YoBrowserToolHandler.ts),预览失败不打断正常 Agent 操作。 - 执行隔离:页面保持在既有 sandbox/session 中执行(
sandbox: true+ 独立分区persist:yo-browser,yoBrowserSession.ts);原生呈现层只拿到缩放后的 JPEG 图像,渲染进程或远端内容拿不到任何 NativeKit 对象或原生句柄。 - 帧只在内存中:不进入日志、缓存、持久化或磁盘文件;日志只有有界的生命周期/性能元数据(如
slow frame presentation的durationMs),不含图像字节、页面内容或密钥。 YoBrowserOverlayWindow与 PiP 是两层:前者仍是可见面板上的 Agent 活动图层(点击/滚动/键入的位置标注),独立于 PiP 存在。- 诚实性条款:源可见性、帧时序、原生移动与打包合成器行为有待平台 QA;配置了捕获间隔不等于对帧率的测量承诺。
安全配置层面,独立分区会话还会拒绝所有权限请求(摄像头、地理位置等一律callback(false)),并用webRequest.onBeforeSendHeaders强制覆盖 UA——UA 来自process.versions.chrome的桌面形态(macOS/Windows/Linux 各有对应字符串),这解释了规格「窄页面 bounds 不切换移动/触摸语义」:整条链路上不存在按视口宽窄切换 UA 的逻辑。
推迟的多标签工作区契约
规格把「可见标签条 + 展开页面适配」明确排除在已交付的单页产品之外,但完整保留了其验收前置条件,供后续实现对照:
标签身份与归属:只有当主进程所有者、类型化路由、渲染端调用方与生命周期测试一起迁移时,才允许用「每会话标签集合 + 选中标签身份」替换单页状态;用户标签与主 Agent 自动化标签严格区分,不把用户标签静默变成 Agent 标签;同一会话复用一个主 Agent 标签跨 URL 加载与 run,不为每次load_url建标签;精确 run 标识要贯穿 immediate/batch/resumed/deferred 工具路径;关闭标签只销毁其页面并向并发操作返回可恢复的 page-closed 结果,关 PiP 不关标签;驻留/驱逐需要实测内存证据,禁止臆造的闲置驱逐或重启持久化。
响应式 chrome:以容器宽度(非应用视口)为准——≥640px 支撑完整标签/导航行,480–639px 用紧凑标签与溢出控件,更窄时保留地址栏与键盘可达命令;420px 面板必须可用。标签标题截断、标签滚动而非挤压、地址栏可收缩但不裁切导航命令:
Expanded panel +----------------------------------------------------------------+ | [User tab] [Agent tab *] [+] | | [Back] [Forward] [Reload] [URL ] [Fit] [Expand] | +----------------------------------------------------------------+ Compact panel +----------------------------------------------+ | [User] [Agent *] ... | | [Back] [Reload] [URL ] [...]| +----------------------------------------------+页面呈现与展开:响应式渲染用真实原生内容 bounds、默认 100% 缩放,不按横向溢出静默缩放,更不假装窄表面是移动浏览器;Fit desktop是每标签的显式选择,必须在证明布局/媒体查询、可读性、用户输入、CDP 坐标、截图尺寸、每标签隔离与跨导航/reparent 的确定性复位之后才可用——同源 Chromium zoom 行为不构成「每标签 fit」的证明,坐标证明通过之前 Fit desktop 保持不可用;Expand复用既有侧面板/全屏外壳占据聊天内容区,配明确的 Restore chat 动作与焦点恢复,不新建窗口、页面或 CDP target。
回归保护与未决验证
回归测试的落点由规格指定且仓库中均存在:test/main/desktop/browser/YoBrowserPresenter.test.ts(presenter 的捕获/换父/生命周期)、test/main/desktop/preview/AgentPreviewCoordinator.test.ts(按来源的预览协调器)、test/renderer/components/AgentBrowserPiP.test.ts(渲染端 PiP 资格与合并)。实现必须持续保护的属性:页面/CDP 身份保持、来源/run 隔离、有界捕获、渲染端拿不到原生帧载荷、稳定原生位置、安全面板交接、run 级关闭、确定性拆除。
规格同时诚实列出了未决验证面:取消/失败/顶替与暂停/恢复路径;会话/路由/多窗口竞态;宿主 show/hide/minimize/restore;缩放/显示比例变化;页面与宿主崩溃;用户与 Agent 并发操作;物理平台的焦点、z-order、拖拽、键盘与无障碍行为。Windows/Linux 打包检查、受支持 macOS 检查、原生移动/帧延迟测量在证据记录前保持开放;任何实现变更仍须通过仓库格式、i18n、lint、typecheck 与相关聚焦测试。
非目标(明确不做)
- 原生视频 PiP、
documentPictureInPicture或全帧率共享纹理视频管线; - 多个同时可见 PiP,或仅由用户操作的浮动页面;
- 向远端页面转发 PiP 输入,或让渲染进程持有远端 WebContents;
- 隐藏页面的自动缩放、移动端 UA 切换、未测量的标签驱逐;
- 持久化 PiP 几何、通用化独立浏览器标签架构、公开预览框架。
小结
DeepChat 的 Agent 浏览器 PiP 是一套「契约先行、单页现实、原生呈现」的分层设计:YoBrowserPresenter以每会话一份的SessionBrowserState保证页面身份唯一与父级数 ≤1;捕获管线用固定 viewport、有界 JPEG、单在途 tick 与 epoch 校验把帧流约束在可验证的预算内;AgentPreviewCoordinator用 claim + epoch + dismissedRuns 的六元组身份仲裁进程内唯一可见的原生呈现,并让 Browser 与 Computer Use 共享所有权而不合并契约。规格文档、NativeKit PiP 架构契约 与三个测试文件共同构成了从产品行为到实现证据的完整闭环。
【免费下载链接】deepchat🐬DeepChat - A smart assistant that connects powerful AI to your personal world项目地址: https://gitcode.com/GitHub_Trending/dee/deepchat
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考