From 69fb0fca2aa3405a5a8154d572e763c165ff9441 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:06:14 +0200 Subject: [PATCH] Smooth browser viewer rendering and frame push Render live browser screencast frames through a canvas/ImageBitmap path while keeping the existing image/data-url path for snapshots and fallback rendering. Attach WebSocket streams as runtime screencast consumers so delivered frames are pushed to the server loop and acknowledged after emit, removing the per-frame read_screencast_frame runtime round trip. --- plugins/_browser/AGENTS.md | 3 + plugins/_browser/api/ws_browser.py | 51 +++++---- plugins/_browser/helpers/runtime.py | 33 +++++- plugins/_browser/webui/browser-panel.html | 11 +- plugins/_browser/webui/browser-store.js | 119 +++++++++++++++++--- tests/test_browser_agent_regressions.py | 125 ++++++++++++++++++++-- 6 files changed, 296 insertions(+), 46 deletions(-) diff --git a/plugins/_browser/AGENTS.md b/plugins/_browser/AGENTS.md index e80bbe016..6a434bbd5 100644 --- a/plugins/_browser/AGENTS.md +++ b/plugins/_browser/AGENTS.md @@ -19,6 +19,8 @@ - Preserve Playwright lifecycle cleanup and WebSocket viewer compatibility across regular host browsers and Electron WebContentsView embedding. - Keep the WebUI Browser inside its own modal/canvas affordance; do not replace it with page-level navigation. - Default the visible WebUI Browser to live CDP screencast for responsiveness. Keep lightweight CDP/DOM state snapshots as the fallback transport. +- Paint live screencast frames through the Browser panel canvas/ImageBitmap path when available; keep the ``/data URL path for snapshots and fallback rendering. +- Push internal screencast frames from the runtime to the WebSocket consumer after subscription; keep `read/pop_screencast_frame` as fallback/tooling APIs, not the WebUI hot path. - Keep Browser viewer frame transport capability-negotiated: updated clients may request binary/slim screencast frames, while older clients must keep the base64/full-metadata fallback. Do not let the WebUI advertise binary frames unless its Socket.IO client reconstructs attachments as real `Blob`, `ArrayBuffer`, or typed-array values. - Keep narrow WebUI Browser controls usable by grouping navigation with Annotate/settings above a full-width address bar. - Browser URL-intent handling must only claim web URL schemes and leave custom Agent Zero schemes to their owning surfaces. @@ -36,6 +38,7 @@ ## Verification - Smoke-test browser launch, navigation, DOM capture, and WebUI viewer after runtime changes. +- For viewer render-path changes, verify the live Browser panel paints a screencast frame on canvas with `frameSrc` empty and snapshots still falling back to the image path. - Run browser prompt/skill regression tests after changing browser prompt or Browser plugin skills. ## Child DOX Index diff --git a/plugins/_browser/api/ws_browser.py b/plugins/_browser/api/ws_browser.py index 827e91df4..566ec10b8 100644 --- a/plugins/_browser/api/ws_browser.py +++ b/plugins/_browser/api/ws_browser.py @@ -451,7 +451,37 @@ class WsBrowser(WsHandler): last_state_refresh = 0.0 last_state_signature = self._state_signature(active_id, browsers) frame_sequence = 0 + server_loop = asyncio.get_running_loop() + stop_event = asyncio.Event() + + async def emit_frame(frame: dict[str, Any]) -> None: + nonlocal frame_sequence + try: + frame_sequence += 1 + payload = self._frame_payload( + frame, + context_id=context_id, + viewer_id=viewer_id, + browser_id=active_id, + sequence=frame_sequence, + binary_frames=binary_frames, + ) + if not slim_frames: + payload["browsers"] = browsers + payload["state"] = state + await self._emit_to_connected_viewer(sid, "browser_viewer_frame", payload) + except BaseException: + stop_event.set() + raise + + def frame_consumer(frame: dict[str, Any]): + return asyncio.run_coroutine_threadsafe(emit_frame(frame), server_loop) + + await runtime.call("attach_screencast_consumer", stream_id, frame_consumer) + while True: + if stop_event.is_set(): + break now = time.monotonic() if now - last_state_refresh >= FRAME_STATE_REFRESH_SECONDS: listing = await runtime.call("list") @@ -475,29 +505,10 @@ class WsBrowser(WsHandler): last_state_refresh = now try: - frame = await runtime.call( - "read_screencast_frame", - stream_id, - timeout=FRAME_READ_TIMEOUT_SECONDS, - ) - except KeyError: + await asyncio.wait_for(stop_event.wait(), timeout=FRAME_READ_TIMEOUT_SECONDS) break except TimeoutError: continue - - frame_sequence += 1 - payload = self._frame_payload( - frame, - context_id=context_id, - viewer_id=viewer_id, - browser_id=active_id, - sequence=frame_sequence, - binary_frames=binary_frames, - ) - if not slim_frames: - payload["browsers"] = browsers - payload["state"] = state - await self._emit_to_connected_viewer(sid, "browser_viewer_frame", payload) except asyncio.CancelledError: raise except Exception: diff --git a/plugins/_browser/helpers/runtime.py b/plugins/_browser/helpers/runtime.py index 94524071d..6031a7f5f 100644 --- a/plugins/_browser/helpers/runtime.py +++ b/plugins/_browser/helpers/runtime.py @@ -317,6 +317,7 @@ class _BrowserScreencast: self.browser_id = browser_id self.session = session self.mime = mime + self.frame_consumer: Any | None = None self.queue = asyncio.Queue(maxsize=1) self.stopped = False self._ack_tasks: set[asyncio.Task] = set() @@ -398,6 +399,12 @@ class _BrowserScreencast: raise RuntimeError("Browser screencast stopped.") return frame + async def attach_consumer(self, frame_consumer: Any) -> None: + self.frame_consumer = frame_consumer + frame = await self.pop_frame() + if frame: + await self._deliver_frame(frame) + async def stop(self) -> None: if self.stopped: return @@ -423,6 +430,7 @@ class _BrowserScreencast: task.add_done_callback(self._ack_tasks.discard) async def _handle_frame(self, params: dict[str, Any]) -> None: + stop_after_ack = False try: data = params.get("data") or "" if data: @@ -432,7 +440,7 @@ class _BrowserScreencast: metadata["jpegWidth"], metadata["jpegHeight"] = size metadata["expectedWidth"] = self._expected_width metadata["expectedHeight"] = self._expected_height - self._queue_latest( + await self._deliver_frame( { "browser_id": self.browser_id, "mime": self.mime, @@ -440,6 +448,13 @@ class _BrowserScreencast: "metadata": metadata, } ) + except asyncio.CancelledError: + stop_after_ack = True + except Exception: + if self.frame_consumer: + stop_after_ack = True + else: + raise finally: session_id = params.get("sessionId") if session_id is not None and not self.stopped: @@ -448,6 +463,16 @@ class _BrowserScreencast: "Page.screencastFrameAck", {"sessionId": int(session_id)}, ) + if stop_after_ack: + self.stopped = True + + async def _deliver_frame(self, frame: dict[str, Any]) -> None: + if not self.frame_consumer: + self._queue_latest(frame) + return + future = self.frame_consumer(frame) + if future is not None: + await asyncio.wrap_future(future) def _queue_latest(self, frame: dict[str, Any]) -> None: self._drop_queued_frames() @@ -1669,6 +1694,12 @@ class _BrowserRuntimeCore: raise KeyError("Browser screencast is not active.") return await screencast.pop_frame() + async def attach_screencast_consumer(self, stream_id: str, frame_consumer: Any) -> None: + screencast = self.screencasts.get(str(stream_id or "")) + if not screencast: + raise KeyError("Browser screencast is not active.") + await screencast.attach_consumer(frame_consumer) + async def stop_screencast(self, stream_id: str) -> None: screencast = self.screencasts.pop(str(stream_id or ""), None) if screencast: diff --git a/plugins/_browser/webui/browser-panel.html b/plugins/_browser/webui/browser-panel.html index 5692dd063..1abcde86f 100644 --- a/plugins/_browser/webui/browser-panel.html +++ b/plugins/_browser/webui/browser-panel.html @@ -187,12 +187,17 @@
+ -