agent-browser 实时视口流(Live Streaming)协议与接入指南:WebSocket 推帧、远程输入与带宽控制
【免费下载链接】agent-browserBrowser automation CLI for AI agents项目地址: https://gitcode.com/gh_mirrors/agen/agent-browser
agent-browser 的 Live Streaming 能力让浏览器视口以 JPEG 帧的形式通过 WebSocket 实时推送,并把鼠标、键盘、触摸事件回传驱动页面,这正是远程预览(remote preview)与嵌入式 Dashboard 所连接的后端:浏览器运行在 daemon 所在之处(沙箱、容器或 CI 机器),而客户端负责渲染帧并回传点击。读完本文,你将掌握如何启用流服务、如何连接并解析服务端消息、如何发送输入与控制消息,以及如何用 push/ack 两种节奏与 per-client 帧率上限在受限链路上稳定消费实时视口。
本文以 skill-data/core/references/streaming.md 为核心,并结合 cli/src/native/stream/ 下的 Rust 源码(mod.rs、websocket.rs、cdp_loop.rs)与相关测试,给出协议层面的事实依据。
流式传输解决什么问题
agent-browser 本身是面向 AI Agent 的浏览器自动化 CLI:浏览器与 daemon 运行在同一台机器上,Agent 通过命令操作页面。Live Streaming 把这一模型扩展到"浏览器与客户端分离"的场景——浏览器运行在 daemon 所在处(一个沙箱、一个容器或一台 CI 机器),客户端通过 WebSocket 连接ws://127.0.0.1:<port>,接收视口帧并在本地渲染,同时把点击和按键发送回去。远程预览与嵌入式 Dashboard 连接的就是这个流服务。
流的核心设计目标有两个:最新的帧永远优先,以及输入不被帧的写入阻塞。前者由"每客户端只保留最新一帧、发送时才读取"保证;后者由"输入在独立任务中派发、不等浏览器回复"保证(详见下文"消息与帧率"两节)。这与 skill-data/core/SKILL.md 中"浏览器跨命令保持运行"的会话模型一致:流服务由 daemon 承载,随会话生命周期启停。
启用流服务
流服务默认即可用——文档原话是 "Streaming is always available",daemon 启动时就会绑定一个由操作系统分配的 localhost 端口。通过以下命令管理:
agent-browser stream status --json # 报告启用状态、端口、客户端数 agent-browser stream enable # 创建流服务(--port 可指定端口) agent-browser stream disable # 拆除流服务stream enable --port 9223可以固定端口;不带--port时由操作系统分配。- 重复
enable会返回 "Streaming is already enabled for this session" 错误(见 cli/src/native/actions.rs),端口参数须在 0-65535 范围内。 stream disable会关闭服务器并清理会话的.stream元数据文件(cli/src/native/actions.rs)。stream status --json返回结构化状态,源码中的字段包括enabled、port、connected(浏览器连接是否存活)、screencasting(cli/src/native/actions.rs)。务必从这里读取端口,因为 OS 分配的默认端口对每个 daemon 都不同。
AGENT_BROWSER_STREAM_PORT环境变量可以在整个 daemon 生命周期内固定端口,而无需每次传--port。daemon 启动时读取该变量,缺省为0(表示由 OS 分配),见 cli/src/native/daemon.rs。启动时若指定端口被占用,daemon 会自动回退到 OS 分配端口;而运行期stream enable命令则不会回退,端口占用会直接报错(见 cli/src/native/stream/mod.rs 中allow_port_fallback参数的语义)。
帧编码参数
帧编码参数是 daemon 级(daemon-wide)的,只在启动时读取一次:
| 变量 | 默认值 | 说明 |
|---|---|---|
AGENT_BROWSER_STREAM_QUALITY | 80 | JPEG 质量,0 到 100,越界会被钳制(clamp) |
AGENT_BROWSER_STREAM_MAX_WIDTH | 视口宽度 | 限制帧的宽度上限,不会改变页面本身尺寸 |
AGENT_BROWSER_STREAM_MAX_HEIGHT | 视口高度 | 同上 |
这些变量由ScreencastConfig::from_env()读取、parse()解析(cli/src/native/stream/mod.rs),解析规则值得注意:
quality先解析为i32,再用clamp(0, 100)钳制——CDP 对越界 quality 的行为是"接受但忽略",会让配置看起来没生效,所以这里选择主动钳制;max_width/max_height必须能解析为u32且大于 0、不超过i32::MAX,否则被丢弃、保留默认(视口尺寸)。测试覆盖了"0"、"-100"、"wide"、"4294967295"等非法输入(cli/src/native/stream/mod.rs);- 维度默认为
None是有意为之:注释明确说明max_width/max_height只是"覆盖值",None时使用会话视口,若默认成固定尺寸反而会把更大的视口全部缩小。
编码之所以固定 JPEG 且不提供格式开关,是因为frame消息本身没有 format 字段——daemon 级切换格式会让已连接的客户端"盲解"。不过文档也指出:显式的screencast_start会重新配置同一个底层 screencast,客户端中途仍可能看到格式变化,以字节嗅探(sniff the bytes)为准,不要假设格式(cli/src/native/stream/mod.rs)。
带宽量级参考(文档在 1280x720 的繁忙页面上实测):quality 80 约 54 KB/帧,quality 20 约 25 KB/帧,quality 20 且 640x360 时约 9 KB/帧。这直接决定了受限链路上的参数选择。
底层推流由 CDP 的Page.startScreencast驱动,参数为format: "jpeg"、quality、maxWidth/maxHeight(缺省用视口)、everyNthFrame: 1,见 cli/src/native/stream/cdp_loop.rs。从该源码还可以看到:只有engine == "chrome"的会话才支持 screencast(supports_screencast = is_chrome),非 Chrome 引擎(如 LightPanda)不会启动帧推流,status消息中的screencasting会是false。
连接与来源校验
连接方式极简:WebSocket 客户端直接连ws://127.0.0.1:<port>。没有订阅消息——客户端一旦接入,帧就开始自动投递(连接建立时服务器会先发送status、已知的tabs,并把最新一帧作为种子帧推给新客户端,见 cli/src/native/stream/websocket.rs)。
服务端对浏览器客户端有来源(Origin)白名单限制:
- 只允许来自
localhost、127.0.0.1、::1或file://的页面连接; - 其他任何 Origin 会在 WebSocket 升级阶段收到403(错误信息 "Origin not allowed"),需要前置代理才能访问。
实现位于握手回调中对Origin头的校验(cli/src/native/stream/websocket.rs),白名单判定逻辑is_allowed_origin在 cli/src/native/stream/mod.rs。同时,HTTP 层的敏感端点(chat、models 等)也会按同一白名单反射 CORS 头,避免 API key 被任意网页读取(cli/src/native/stream/http.rs)。e2e 测试中有一个直接用Origin: https://evil.example发起的跨源命令请求被拒绝的用例(cli/src/native/e2e_tests.rs)。
服务端消息(Server → Client)
所有消息都是带type字段的 JSON 文本。
frame:视口图像及其元数据
帧是流的主体,按"最新优先"(latest-first)投递(机制见下一节)。示例:
{ "type": "frame", "seq": 41, "data": "<base64-encoded-jpeg>", "metadata": { "deviceWidth": 1280, "deviceHeight": 720, "pageScaleFactor": 1, "offsetTop": 0, "scrollOffsetX": 0, "scrollOffsetY": 0, "timestamp": 1785038682238 } }字段含义:
| 字段 | 说明 |
|---|---|
seq | 单调递增的帧 id;ack 节奏下被回显,且跨浏览器重启保持递增 |
data | base64 编码的 JPEG 图像 |
metadata.deviceWidth/deviceHeight | 视口尺寸 |
metadata.pageScaleFactor | 页面缩放因子 |
metadata.offsetTop/scrollOffsetX/scrollOffsetY | 视口相对页面内容的位置 |
metadata.timestamp | 捕获时刻的 epoch 毫秒,Date.now() - timestamp即该帧的"年龄" |
seq的单调性由进程级原子计数器FRAME_SEQ保证(cli/src/native/stream/mod.rs),并有用例test_frame_ids_survive_a_stream_server_restart验证服务器重启后 id 仍递增(cli/src/native/stream/mod.rs)。timestamp由 CDP 的Network.TimeSinceEpoch(浮点秒)乘以 1000 转换而来,直接按整数读会得到 0(cli/src/native/stream/cdp_loop.rs 及对应测试)。
status/tabs/url/console
status:连接状态、screencasting 标志、视口尺寸、引擎、录制标志。连接时发送一次,之后每次状态变化再发。tabs:当前标签页列表,连接时(若已知)和变化时发送。url:仅在 Chrome 上发送,覆盖活动标签页主框架(main frame)的整页导航、History API 导航和 fragment 导航;子框架(child-frame)与后台标签页的导航会被忽略。实现上由Page.frameNavigated与Page.navigatedWithinDocument事件驱动,并先通过Page.getFrameTree确认主框架 id(cli/src/native/stream/cdp_loop.rs)。console:控制台事件。
通道语义差异(重要):status、tabs、url、console走有序通道——按序投递,且不会像帧那样被新消息顶替。但它们并非无条件可靠:客户端落后太多会从该通道掉队,丢失从未见过的消息。因此文档明确建议:把 console 输出当作实时流(live feed),而不是审计日志(audit log)。从源码看,有序消息走 tokiobroadcast通道(容量 64),帧走watch通道(只保留最新值)——这就是两种通道语义差异的来源(cli/src/native/stream/mod.rs)。
客户端消息(Client → Server)
客户端可发送以下消息:
{"type": "input_mouse", "eventType": "mousePressed", "x": 40, "y": 40, "button": "left", "clickCount": 1} {"type": "input_keyboard", "eventType": "keyDown", "key": "a", "text": "a"} {"type": "input_touch", "eventType": "touchStart", "touchPoints": []} {"type": "config", "maxFps": 10} {"type": "config", "pacing": "ack"} {"type": "ack", "seq": 41}输入派发的底层行为
- 输入在独立的任务中派发到浏览器,与帧投递互不阻塞——一次点击不会排队等在一帧之后。
- 事件不等浏览器回复就发出(
send_command_no_wait),所以一次点击不会卡在一串鼠标移动之后。 - 顺序保持:press 永远不会越过 move。所有派发命令共享同一 socket 互斥锁、按调用顺序发送(cli/src/native/stream/websocket.rs)。测试
test_input_dispatch_does_not_wait_for_a_cdp_reply用一个永不回复的模拟 CDP 服务器验证了"不等待回复"的行为(cli/src/native/stream/websocket.rs)。 - 鼠标、键盘、触摸输入还会重置 daemon 的空闲计时器(
idle_activity.mark()),因此被持续远程驱动的预览不会被空闲超时关掉(cli/src/native/stream/websocket.rs)。注意:只有真正派发到 CDP 的输入才算活动。
input_mouse支持的事件类型包括mouseMoved/mousePressed/mouseReleased等(默认mouseMoved),input_keyboard支持keyDown/keyUp/char(默认keyDown),input_touch支持touchStart/touchMove/touchEnd等(默认touchStart)。键盘消息中省略的可选字符串字段(key、code、text)会被整体省略而不是发null——CDP 会因 null 字符串拒绝整个命令,导致按键静默丢失(cli/src/native/stream/websocket.rs)。
config:每客户端帧率上限
config消息设置每客户端(per-client)的帧率上限:
maxFps:1 到 120,0表示不限(默认值);- 立即生效,包括放宽上限的情况(测试
test_loosening_cap_midstream_takes_effect_immediately验证了放宽后不再等待旧的节流期限,见 cli/src/native/stream/mod.rs); - 各客户端的上限互相独立,不影响其他连接;
- 大于 120 的值钳制到 120(常量
MAX_CONFIGURABLE_FPS = 120,见 cli/src/native/stream/websocket.rs);负数或非数字值被忽略,保持现有上限;两种情况都不会拒绝连接。
把设置放在 URL 上
两个设置也可以放在 URL 查询参数里,这是**唯一能覆盖连接首帧(opening frame)**的方式:
ws://127.0.0.1:<port>/?pacing=ack&maxFps=10原因很直接:种子帧在config消息到达之前就已写入。连接建立后发送的config消息仍然优先(会覆盖 URL 上的设置)。URL 解析在 cli/src/native/stream/websocket.rs 的config_from_upgrade中实现,并有测试验证 URL 设置的 ack 节奏能覆盖首帧(cli/src/native/stream/mod.rs)。
帧率与陈旧帧处理
这是整个协议最有价值的部分:应用层永远不堆积陈旧帧。
最新帧优先(latest-frame-wins)
服务器对每个客户端只保留最新一帧,并在发送时读取(watch通道的borrow_and_update)。在一帧尚未写完时产生的新帧会被跳过而不是排队,因此应用永远不会构建积压(backlog)。测试test_new_client_receives_latest_frame_only验证新客户端只会收到最新帧而非历史帧(cli/src/native/stream/mod.rs)。
push 节奏(默认)
默认的 push 节奏在此处截止:底层传输(TCP/WebSocket)仍然有序——已经被 socket 接受的帧会按序送达,所以一个停顿的客户端会先排干内核缓冲区的数据,然后写线程才会阻塞。也就是说,push 模式无法避免"停顿后恢复要消化历史帧"的问题。
ack 节奏(推荐用于受限链路)
发送{"type":"config","pacing":"ack"}后,服务器同时最多只有一帧在途,等收到{"type":"ack","seq":N}才发下一帧:
- 每一帧都带单调
seq;客户端应回显自己渲染完成的那一帧的 seq; - 在 ack 未返回期间产生的新帧互相顶替,永远不会到达 socket——所以一个停顿 10 秒的客户端恢复后拿到的是当前页面,而不是 10 秒的历史(测试
test_ack_pacing_holds_one_frame_and_skips_to_newest验证"ack 释放且只释放最新帧",见 cli/src/native/stream/mod.rs)。
ack 节奏的吞吐边界:一帧在途时,速率约等于"一次传输 + 一次确认往返",同时受带宽和延迟约束;若链路的带宽-延迟积超过单帧大小,链路就会利用不足。另有几点工程细节:
- ack 只约束一跳(one hop):路径中有代理时,应转发渲染端生成的 ack;若代理在收到帧时就本地生成 ack,帧会堆积在对端。
- ack 是累计的:确认更新的 id 覆盖所有更旧的帧。客户端乱序渲染或跳过中间 id 都不会卡死写线程(
send_if_modified保证水位只前进不后退,见 cli/src/native/stream/websocket.rs)。 - 客户端启用 ack 节奏后停止 ack,只是停止收帧;
status、tabs、url、console仍会继续流动。 - 提前 ack 未来的 id 也不会卡死写线程(测试
test_ack_ahead_of_the_stream_does_not_wedge_the_writer,cli/src/native/stream/mod.rs);切回 push 会立刻释放正在等 ack 的那帧(test_leaving_ack_pacing_releases_the_held_frame)。
两个设置如何组合
pacing限制在途量(in flight),maxFps限制速率(rate)。受限链路上的预览通常两者都要:
{"type": "config", "pacing": "ack", "maxFps": 10}或直接在 URL 上声明:ws://127.0.0.1:<port>/?pacing=ack&maxFps=10。
局限性(Limitations)
- 仅限本机(localhost only)。把流暴露到机器之外是接入方(embedder)的职责——隧道、代理或端口转发;且 Origin 白名单只适用于浏览器客户端。
- 帧是图像,不是视频编码。带宽随视口尺寸和页面活跃度增长,受限链路请用
maxFps/quality 控制速率。 - push 节奏下,服务器无法区分渲染慢与渲染快的客户端(只有传输背压这一条线索)。当这个区分很重要时,改用 ack 节奏。
相关资源
- 命令全量参考:skill-data/core/references/commands.md;快速上手:skill-data/core/SKILL.md。
- 流服务核心实现:cli/src/native/stream/mod.rs(服务器与配置)、cli/src/native/stream/websocket.rs(连接与消息处理)、cli/src/native/stream/cdp_loop.rs(CDP 事件到流消息的转换)。
- 流命令(enable/disable/status)的入口与状态字段:cli/src/native/actions.rs。
- 环境变量完整清单(含
AGENT_BROWSER_STREAM_PORT/QUALITY/MAX_WIDTH/MAX_HEIGHT):cli/src/output.rs。 - 基于该流构建的嵌入式 Dashboard 位于 packages/dashboard/,其启动与
--allowed-origins配置说明见 skill-data/core/SKILL.md。
【免费下载链接】agent-browserBrowser automation CLI for AI agents项目地址: https://gitcode.com/gh_mirrors/agen/agent-browser
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考