mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-26 16:07:53 +00:00
feat(browser): add agentic browser control (#4187)
* feat(browser): add agentic browser control
* fix(frontend): format browser view changes
* fix(browser): keep browser optional and isolate sidecar layout
* fix(browser): address PR review security and IME findings
- Nginx: add a browser-stream WebSocket location before the generic
/api/threads regex so Live upgrades instead of downgrading to HTTP
(both nginx.conf and nginx.local.conf).
- Ownership: require an existing owned thread for the WS stream and REST
navigate, and tear down the browser session on thread deletion so a
later caller cannot reuse a retained page/cookies by guessing the id.
- SSRF: enforce the URL policy at the browser request boundary via a
context-level route guard covering redirects, popups, iframes, and
subresources (skipped for CDP-attached Chrome).
- IME: skip key forwarding while a composition is active so confirming a
CJK candidate with Enter no longer submits the remote page form.
Adds regression tests for the request guard, session teardown on delete,
and the composing-Enter key decision.
* fix(frontend): smooth streaming in long tool threads
* Revert "fix(frontend): smooth streaming in long tool threads"
This reverts commit f0462516eabe77f138d4027ea1c714fb226683cf.
* fix(browser): address review security and lifecycle findings
- Reject cross-origin WebSocket upgrades on the live browser stream
(Origin allow-list reuse of CORS/same-origin helpers) to close a
WS-CSRF hole, and fail closed when the ownership store is absent.
- Warn when a CDP-attached session runs with the SSRF request guard
off, and drop the unreachable CDP screencast teardown dead code.
- Read browser session launch config from a single canonical source
(browser_navigate) so it is deterministic regardless of call order.
- Bound per-thread Chromium accumulation with idle-timeout eviction
and an LRU max-sessions cap.
- Reset the Live reconnect counter on a successful open so the stream
can't permanently stall after the cumulative attempt cap.
* fix(frontend): reduce long tool thread render stalls
Reuse stable historical message groups during streaming, defer heavy Markdown and browser previews, and lazy-decode message images.
* fix(browser): keep live control responsive during continuous input
Why: Manual browser control felt laggy — a physical click ran the remote
Playwright click three times and each non-move input synchronously awaited a
JPEG screenshot, so events queued behind capture (queue wait up to ~237ms).
The first async attempt used a trailing-edge debounce, which froze the visible
page until a wheel/keyboard gesture stopped ("scroll finishes, then it jumps").
What:
- Frontend forwards one `click` per physical click instead of also emitting
`down`/`up`, so the remote page is not clicked twice per gesture.
- Backend detaches live-frame capture from input dispatch: non-move actions
start a rate-limited background refresh loop (leading frame + bounded cadence)
that keeps emitting frames while input continues and never blocks dispatch.
- Add regression tests: input dispatch no longer awaits the screenshot, rapid
inputs coalesce, and continuous input keeps refreshing before it stops.
Scenarios: Verified in the live Browser panel — a single click completes in
~57ms (was blocked behind a 171ms capture), and a 1.14s sustained wheel gesture
renders ~7 frames throughout the scroll instead of one frame after it ends.
* fix(browser): harden worker and session lifecycle
* fix(browser): address latest review feedback
* fix(frontend): preserve optimistic new-chat message
* test(e2e): preserve mocked message run ids
* fix(browser): address capability review feedback
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
97ca7f88cf
commit
fa496c0c8d
14
README.md
14
README.md
@ -778,6 +778,20 @@ This is the difference between a chatbot with tool access and an agent with an a
|
||||
└── outputs/ ← final deliverables
|
||||
```
|
||||
|
||||
### Agentic Browser Control
|
||||
|
||||
Reading a page is not the same as *using* one. Alongside the read-only `web_fetch` and `web_capture` tools, DeerFlow ships an optional agentic browser tool group that keeps a live, per-conversation browser session so the agent can actually operate a page — navigate, read the interactive elements, click, type, submit forms, and follow multi-step flows on JavaScript-heavy sites.
|
||||
|
||||
Each action returns a fresh snapshot of the page's interactive elements, each addressed by a stable `[ref]` number, so the agent acts on what it just observed instead of guessing selectors. Outbound URLs are SSRF-screened by default. It is powered by Playwright and shipped as an optional extra so the core install stays lean:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv sync --extra browser
|
||||
uv run playwright install chromium
|
||||
```
|
||||
|
||||
Then uncomment the `group: browser` tool entries in `config.yaml` (`browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_get_text`, `browser_back`, `browser_screenshot`, `browser_close`). `make dev` / Docker startup detects an enabled `browser_navigate` tool and preserves the `browser` extra on dependency syncs. The Gateway fails startup if browser control is configured but Playwright is missing, and `/api/features` hides the Browser UI unless the backend can actually serve it. Keep `headless: true` and `allow_private_addresses: false` for anything but local, trusted debugging. Attaching to an existing Chrome with `cdp_url` cannot enforce DeerFlow's subresource/redirect SSRF guard and therefore fails closed unless `allow_unguarded_cdp: true` explicitly acknowledges that risk; use it only with a trusted local browser. Browser sessions are process-local; keep `GATEWAY_WORKERS=1` while this tool group is enabled because ordinary uvicorn worker dispatch does not provide thread affinity.
|
||||
|
||||
### Context Engineering
|
||||
|
||||
**Isolated Sub-Agent Context**: Each sub-agent runs in its own isolated context. This means that the sub-agent will not be able to see the context of the main agent or other sub-agents. This is important to ensure that the sub-agent is able to focus on the task at hand and not be distracted by the context of the main agent or other sub-agents.
|
||||
|
||||
@ -163,6 +163,11 @@ Boundary check (harness → app import firewall):
|
||||
|
||||
CI runs these regression tests for every pull request via [.github/workflows/backend-unit-tests.yml](../.github/workflows/backend-unit-tests.yml).
|
||||
|
||||
Agentic browser sessions are process-local. The Gateway startup safety gate rejects
|
||||
`GATEWAY_WORKERS > 1` when `browser_navigate` is configured, because ordinary
|
||||
uvicorn worker dispatch does not provide thread affinity for browser tools, REST
|
||||
navigation, and the Live WebSocket.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Harness / App Split
|
||||
@ -329,7 +334,7 @@ Localhost persistence deliberately reads the direct request `Host` and ignores `
|
||||
| Router | Endpoints |
|
||||
|--------|-----------|
|
||||
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
|
||||
| **Features** (`/api/features`) | `GET /` - report config-gated feature availability (currently `agents_api.enabled`) for frontend UI gating |
|
||||
| **Features** (`/api/features`) | `GET /` - report config-gated feature availability (`agents_api.enabled`, `browser_control.enabled`) for frontend UI gating |
|
||||
| **Console** (`/api/console`) | Read-only cross-thread observability for the current user (the data layer for an operations dashboard or external monitoring): `GET /stats` - headline counters (runs/threads/agents/tokens/cost); `GET /runs` - paginated run history joined with thread titles (per-run cost); `GET /usage` - zero-filled daily token series + per-model breakdown with spend. Queries `runs`/`threads_meta` directly as a reporting layer (no new `RunStore` methods); requires a SQL database backend — returns 503 on `database.backend: memory`. Real-cost estimation reads optional `models[*].pricing` (`currency`, `input_per_million`, `output_per_million`, `input_cache_hit_per_million`; `ModelConfig` is `extra="allow"`, so no schema change) and prices each run from its `token_usage_by_model` input/output split. Pricing is **cache-aware**: `RunJournal` accumulates prompt-cache hits from `usage_metadata.input_token_details.cache_read` into a sparse `cache_read_tokens` bucket key (also threaded through `SubagentTokenCollector` → `record_external_llm_usage_records`), and cache-hit input tokens are billed at `input_cache_hit_per_million` (omitted → billed at the miss price, a conservative upper bound). Legacy rows fall back to run-level totals at `model_name`; unpriced models yield `cost: null` and cost fields are null when no pricing is configured |
|
||||
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - update config (saves to extensions_config.json) |
|
||||
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`); `POST /reload` - admin-only process-local prompt-cache invalidation after trusted external filesystem changes |
|
||||
@ -454,6 +459,8 @@ Scheduled-task runtime note:
|
||||
- `firecrawl/` - Web scraping via Firecrawl API
|
||||
- `image_search/` - Image search via DuckDuckGo
|
||||
- `aio_sandbox/` - Docker-based isolation (`AioSandboxProvider`)
|
||||
- `browser_automation/` - Agentic browser control (stateful `navigate → observe → click/type` loop) via Playwright, distinct from the read-only `web_fetch`/`web_capture` tools. Tools: `browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_get_text`, `browser_back`, `browser_screenshot`, `browser_close` (config `group: browser`). A process-local `BrowserSessionManager` owns one private, loop-affine Playwright event-loop thread (same pattern as the BoxLite provider) so a per-thread browser session survives across turns regardless of the caller's loop (Gateway / TUI / test). Each action returns a fresh page snapshot whose interactive elements are addressed by a stable numeric `[ref]` index (stamped as `data-df-ref` during snapshot), so the model acts on what it just observed instead of holding stale handles or guessing selectors. URLs are SSRF-screened via the shared `validate_public_http_url` (opt-out `allow_private_addresses` only for intentional internal targets). CDP attachment cannot install the request guard on an existing Chrome context, so `cdp_url` fails closed unless the operator explicitly sets `allow_unguarded_cdp: true` for a trusted local browser. Browser REST/Live access also requires an exact non-NULL thread owner, rather than the general legacy shared-thread policy, because retained pages may contain authenticated state. Session admission is a hard `max_sessions` cap: pinned Live/operation sessions are never evicted, and a new thread is rejected when no unpinned session can be closed; one Live viewer owns a session at a time. Optional dependency: `cd backend && uv sync --extra browser && uv run playwright install chromium`; `scripts/detect_uv_extras.py` preserves the extra when `config.yaml` enables `browser_navigate`, and Gateway startup fails fast if configured browser control cannot import Playwright. Tests: `tests/test_browser_automation.py` (mocked tools + a real-Chromium integration test guarded by `importorskip`); `tests/manual_browser_live_check.py` is a manual DeepSeek-driven end-to-end check (not collected by pytest).
|
||||
Live UI input dispatch is kept independent from JPEG capture: non-move actions start a rate-limited background refresh loop, so pointer, wheel, or keyboard input stays responsive while continuous gestures still produce frames throughout the interaction.
|
||||
|
||||
Additional providers also live here (`boxlite`, `brave`, `browserless`, `crawl4ai`, `ddg_search`, `e2b_sandbox`, `exa`, `fastcrw`, `groundroute`, `infoquest`, `searxng`, `serper`); see each subpackage for specifics.
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.gateway.auth_disabled import warn_if_auth_disabled_enabled
|
||||
from app.gateway.auth_middleware import AuthMiddleware
|
||||
from app.gateway.browser_capability import ensure_browser_runtime_available
|
||||
from app.gateway.config import get_gateway_config
|
||||
from app.gateway.csrf_middleware import CSRFMiddleware, get_configured_cors_origins
|
||||
from app.gateway.deps import langgraph_runtime
|
||||
@ -16,6 +17,7 @@ from app.gateway.routers import (
|
||||
artifacts,
|
||||
assistants_compat,
|
||||
auth,
|
||||
browser,
|
||||
channel_connections,
|
||||
channels,
|
||||
console,
|
||||
@ -181,6 +183,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
try:
|
||||
startup_config = get_app_config()
|
||||
configure_logging(startup_config)
|
||||
ensure_browser_runtime_available(startup_config)
|
||||
logger.info("Configuration loaded successfully")
|
||||
warn_if_auth_disabled_enabled()
|
||||
except Exception as e:
|
||||
@ -301,6 +304,23 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
except Exception:
|
||||
logger.exception("Failed to stop scheduled task service")
|
||||
|
||||
try:
|
||||
from deerflow.community.browser_automation import get_browser_session_manager
|
||||
|
||||
closed = await asyncio.wait_for(
|
||||
get_browser_session_manager().close_all_sessions(),
|
||||
timeout=_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
|
||||
)
|
||||
if closed:
|
||||
logger.info("Closed %d browser session(s)", closed)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"Browser session shutdown exceeded %.1fs; proceeding with worker exit.",
|
||||
_SHUTDOWN_HOOK_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to close browser sessions")
|
||||
|
||||
# Drain the memory backend's pending-update buffer before the worker
|
||||
# exits (best-effort, bounded). IM channels and the scheduler are
|
||||
# already stopped above, so no new IM/scheduler updates arrive during
|
||||
@ -317,9 +337,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# would (review #6 on the original PR).
|
||||
#
|
||||
# K8s caveat: ``shutdown_flush_timeout_seconds`` must fit inside the
|
||||
# pod's ``terminationGracePeriodSeconds`` (channel stop + this drain +
|
||||
# buffer), set on the gateway Helm deployment -- or K8s SIGKILLs the
|
||||
# drain mid-flight and the loss this is fixing is silently re-introduced.
|
||||
# pod's ``terminationGracePeriodSeconds`` (channel stop + browser
|
||||
# session close + this drain + buffer), set on the gateway Helm
|
||||
# deployment -- or K8s SIGKILLs the drain mid-flight and the loss this
|
||||
# is fixing is silently re-introduced.
|
||||
try:
|
||||
app_cfg = get_app_config()
|
||||
if app_cfg.memory.enabled:
|
||||
@ -487,6 +508,9 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
|
||||
# Artifacts API is mounted at /api/threads/{thread_id}/artifacts
|
||||
app.include_router(artifacts.router)
|
||||
|
||||
# Browser API is mounted at /api/threads/{thread_id}/browser
|
||||
app.include_router(browser.router)
|
||||
|
||||
# Uploads API is mounted at /api/threads/{thread_id}/uploads
|
||||
app.include_router(uploads.router)
|
||||
|
||||
|
||||
72
backend/app/gateway/browser_capability.py
Normal file
72
backend/app/gateway/browser_capability.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""Shared browser-control capability checks for Gateway surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from deerflow.community.browser_automation.session import browser_multi_worker_error
|
||||
from deerflow.config.app_config import AppConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserCapability:
|
||||
"""Frontend/API availability for the agentic browser control surface."""
|
||||
|
||||
configured: bool
|
||||
available: bool
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
def _tool_config(config: AppConfig) -> Any | None:
|
||||
get_tool_config = getattr(config, "get_tool_config", None)
|
||||
if callable(get_tool_config) and callable(getattr(type(config), "get_tool_config", None)):
|
||||
return get_tool_config("browser_navigate")
|
||||
return next(
|
||||
(tool for tool in (getattr(config, "tools", None) or []) if getattr(tool, "name", None) == "browser_navigate"),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _tool_extra(tool_cfg: Any) -> dict[str, Any]:
|
||||
extra = getattr(tool_cfg, "model_extra", None)
|
||||
return extra if isinstance(extra, dict) else {}
|
||||
|
||||
|
||||
def browser_capability(config: AppConfig) -> BrowserCapability:
|
||||
"""Return whether browser control can actually serve frontend requests."""
|
||||
|
||||
tool_cfg = _tool_config(config)
|
||||
if tool_cfg is None:
|
||||
return BrowserCapability(configured=False, available=False, reason="browser_navigate is not configured")
|
||||
|
||||
worker_error = browser_multi_worker_error()
|
||||
if worker_error is not None:
|
||||
return BrowserCapability(configured=True, available=False, reason=worker_error)
|
||||
|
||||
extra = _tool_extra(tool_cfg)
|
||||
cdp_url = extra.get("cdp_url")
|
||||
if isinstance(cdp_url, str) and cdp_url.strip() and extra.get("allow_unguarded_cdp") is not True:
|
||||
return BrowserCapability(
|
||||
configured=True,
|
||||
available=False,
|
||||
reason="cdp_url requires allow_unguarded_cdp: true because DeerFlow cannot enforce the SSRF request guard on a CDP-attached browser",
|
||||
)
|
||||
|
||||
if importlib.util.find_spec("playwright") is None or importlib.util.find_spec("playwright.async_api") is None:
|
||||
return BrowserCapability(
|
||||
configured=True,
|
||||
available=False,
|
||||
reason="Playwright is not installed; install the backend browser extra and run `playwright install chromium`",
|
||||
)
|
||||
|
||||
return BrowserCapability(configured=True, available=True)
|
||||
|
||||
|
||||
def ensure_browser_runtime_available(config: AppConfig) -> None:
|
||||
"""Fail startup when browser control is configured but cannot run."""
|
||||
|
||||
capability = browser_capability(config)
|
||||
if capability.configured and not capability.available:
|
||||
raise RuntimeError(capability.reason or "Browser automation is not available")
|
||||
@ -27,6 +27,7 @@ from typing import TYPE_CHECKING, TypeVar, cast
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from langgraph.types import Checkpointer
|
||||
|
||||
from deerflow.community.browser_automation.session import browser_multi_worker_error
|
||||
from deerflow.config.app_config import AppConfig, get_app_config
|
||||
from deerflow.persistence.feedback import FeedbackRepository
|
||||
from deerflow.runtime import RunContext, RunManager, StreamBridge
|
||||
@ -46,14 +47,25 @@ logger = logging.getLogger(__name__)
|
||||
_RUN_DRAIN_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
def _browser_tools_enabled_in_config(config: AppConfig) -> bool:
|
||||
"""Return whether process-local agentic browser sessions are configured."""
|
||||
get_tool_config = getattr(config, "get_tool_config", None)
|
||||
if callable(get_tool_config):
|
||||
return get_tool_config("browser_navigate") is not None
|
||||
return any(getattr(tool, "name", None) == "browser_navigate" for tool in (getattr(config, "tools", None) or []))
|
||||
|
||||
|
||||
def _enforce_postgres_for_multi_worker(config: AppConfig) -> None:
|
||||
"""Refuse to start when GATEWAY_WORKERS > 1 and safety preconditions are not met.
|
||||
"""Refuse unsafe multi-worker configurations before persistence starts.
|
||||
|
||||
Two checks (both must pass for multi-worker):
|
||||
Three checks (all must pass for multi-worker):
|
||||
|
||||
1. The DB backend must be Postgres — SQLite write-locks cannot support
|
||||
1. Process-local browser sessions must be disabled. Browser tools keep
|
||||
Chromium and Playwright objects in one worker's memory, while ordinary
|
||||
uvicorn dispatch provides no thread-id affinity.
|
||||
2. The DB backend must be Postgres — SQLite write-locks cannot support
|
||||
concurrent multi-process access.
|
||||
2. ``run_ownership.heartbeat_enabled`` must be True — without heartbeat,
|
||||
3. ``run_ownership.heartbeat_enabled`` must be True — without heartbeat,
|
||||
every run has a NULL lease, so reconciliation treats all inflight
|
||||
runs as orphans and Worker B would kill Worker A's live runs on
|
||||
every rolling update or scale-up.
|
||||
@ -70,6 +82,9 @@ def _enforce_postgres_for_multi_worker(config: AppConfig) -> None:
|
||||
if workers <= 1:
|
||||
return
|
||||
|
||||
if _browser_tools_enabled_in_config(config):
|
||||
raise SystemExit(browser_multi_worker_error(workers))
|
||||
|
||||
backend = getattr(config.database, "backend", None)
|
||||
if backend != "postgres":
|
||||
raise SystemExit(f"GATEWAY_WORKERS={workers} requires database.backend='postgres', but database.backend is '{backend}'. SQLite cannot support concurrent multi-process access. Set GATEWAY_WORKERS=1 or switch to Postgres.")
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from . import (
|
||||
artifacts,
|
||||
assistants_compat,
|
||||
browser,
|
||||
input_polish,
|
||||
mcp,
|
||||
models,
|
||||
@ -15,6 +16,7 @@ from . import (
|
||||
__all__ = [
|
||||
"artifacts",
|
||||
"assistants_compat",
|
||||
"browser",
|
||||
"input_polish",
|
||||
"mcp",
|
||||
"models",
|
||||
|
||||
467
backend/app/gateway/routers/browser.py
Normal file
467
backend/app/gateway/routers/browser.py
Normal file
@ -0,0 +1,467 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.authz import require_permission
|
||||
from app.gateway.browser_capability import browser_capability
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.runtime.user_context import get_effective_user_id, reset_current_user, set_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["browser"])
|
||||
|
||||
|
||||
class BrowserNavigateRequest(BaseModel):
|
||||
url: str = Field(..., description="The http(s) URL to open in the thread's live browser session")
|
||||
|
||||
|
||||
class BrowserNavigateResponse(BaseModel):
|
||||
screenshot: str | None = Field(default=None, description="Virtual artifact path of the captured screenshot")
|
||||
url: str = Field(..., description="The resolved URL after navigation")
|
||||
title: str = Field(default="", description="The page title after navigation")
|
||||
|
||||
|
||||
def _normalize_browser_seed_url(url: str | None) -> str:
|
||||
return (url or "").split("#", 1)[0].rstrip("/")
|
||||
|
||||
|
||||
def _should_apply_browser_seed(current: str | None, seed: str | None) -> bool:
|
||||
if not seed:
|
||||
return False
|
||||
if not current or current == "about:blank":
|
||||
return True
|
||||
return _normalize_browser_seed_url(current) != _normalize_browser_seed_url(seed)
|
||||
|
||||
|
||||
def _browser_tools_enabled() -> bool:
|
||||
"""Whether the browser tools are turned on in config.
|
||||
|
||||
The live browser HTTP/WS endpoints are an opt-in surface: they must only be
|
||||
reachable when the operator has enabled the ``browser_navigate`` tool in
|
||||
``config.yaml``. Merely having Playwright importable (it may be preinstalled
|
||||
in a base image) is not sufficient — otherwise the endpoints would expose
|
||||
server-side browser control the operator never turned on.
|
||||
"""
|
||||
from deerflow.config import get_app_config
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
capability = browser_capability(get_app_config())
|
||||
if capability.configured and not capability.available and capability.reason:
|
||||
logger.error("browser automation disabled: %s", capability.reason)
|
||||
return capability.available
|
||||
return False
|
||||
|
||||
|
||||
async def _browser_thread_owned_by(thread_store, thread_id: str, user_id: str) -> bool:
|
||||
"""Require an explicit owner for browser control's high-trust surface.
|
||||
|
||||
General thread reads retain compatibility with legacy ``user_id=NULL``
|
||||
rows. A retained browser may contain authenticated cookies and page data,
|
||||
so REST navigation and the Live socket deliberately use a stricter policy:
|
||||
only an existing row whose owner exactly matches may drive it.
|
||||
"""
|
||||
record = await thread_store.get(thread_id, user_id=user_id)
|
||||
return record is not None and record.get("user_id") == user_id
|
||||
|
||||
|
||||
@router.post(
|
||||
"/threads/{thread_id}/browser/navigate",
|
||||
response_model=BrowserNavigateResponse,
|
||||
summary="Navigate The Live Browser Session",
|
||||
description="Steer the thread's live browser session to a URL from the UI and capture a screenshot.",
|
||||
)
|
||||
@require_permission("threads", "write", owner_check=True, require_existing=True)
|
||||
async def navigate_browser(thread_id: str, body: BrowserNavigateRequest, request: Request) -> BrowserNavigateResponse:
|
||||
user_id = str(request.state.auth.user.id)
|
||||
thread_store = getattr(request.app.state, "thread_store", None)
|
||||
if thread_store is None or not await _browser_thread_owned_by(thread_store, thread_id, user_id):
|
||||
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
|
||||
|
||||
if not _browser_tools_enabled():
|
||||
raise HTTPException(status_code=404, detail="Browser automation is not enabled")
|
||||
|
||||
try:
|
||||
from deerflow.community.browser_automation import navigate_and_capture, redact_browser_url
|
||||
except ImportError as exc: # Playwright is an optional dependency.
|
||||
raise HTTPException(status_code=501, detail="Browser automation is not available") from exc
|
||||
|
||||
url = body.url.strip()
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="URL is required")
|
||||
|
||||
outputs_path = get_paths().sandbox_outputs_dir(thread_id, user_id=get_effective_user_id())
|
||||
try:
|
||||
result = await navigate_and_capture(thread_id=thread_id, url=url, outputs_path=outputs_path)
|
||||
except ValueError as exc:
|
||||
# SSRF / URL validation failure.
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Browser navigate failed: thread_id=%s url=%s err_type=%s",
|
||||
thread_id,
|
||||
redact_browser_url(url),
|
||||
type(exc).__name__,
|
||||
)
|
||||
raise HTTPException(status_code=502, detail="Browser navigation failed") from exc
|
||||
|
||||
return BrowserNavigateResponse(**result)
|
||||
|
||||
|
||||
async def _authenticate_ws(websocket: WebSocket):
|
||||
"""Resolve the user for a WebSocket, honoring auth-disabled mode.
|
||||
|
||||
WebSocket upgrades bypass ``AuthMiddleware`` (a BaseHTTPMiddleware), so we
|
||||
replicate the minimal cookie → user resolution here. Returns the user or
|
||||
``None`` when unauthenticated.
|
||||
"""
|
||||
from app.gateway.auth import decode_token
|
||||
from app.gateway.auth.errors import TokenError
|
||||
from app.gateway.auth_disabled import get_auth_disabled_user, is_auth_disabled
|
||||
from app.gateway.deps import get_local_provider
|
||||
|
||||
access_token = websocket.cookies.get("access_token")
|
||||
if access_token:
|
||||
payload = decode_token(access_token)
|
||||
if not isinstance(payload, TokenError):
|
||||
provider = get_local_provider()
|
||||
user = await provider.get_user(payload.sub)
|
||||
if user is not None and user.token_version == payload.ver:
|
||||
return user
|
||||
if is_auth_disabled():
|
||||
return get_auth_disabled_user()
|
||||
return None
|
||||
|
||||
|
||||
def _ws_origin_allowed(websocket: WebSocket) -> bool:
|
||||
"""Reject cross-origin WebSocket upgrades (WS-CSRF defense).
|
||||
|
||||
WS upgrades bypass ``CSRFMiddleware`` (also a BaseHTTPMiddleware), so a
|
||||
cross-origin page could otherwise open this socket riding the victim's
|
||||
cookie and both observe frames and drive their authenticated browser. The
|
||||
``Origin`` header is browser-controlled but always sent on cross-origin
|
||||
upgrades, so validating it is a standard, cheap mitigation.
|
||||
|
||||
Allow when: no ``Origin`` (non-browser clients such as native ws/tests do
|
||||
not send it), the origin is an explicitly configured CORS origin, or it is
|
||||
same-origin with the upgrade target's host. The WS scheme (ws/wss) differs
|
||||
from the page scheme (http/https), so same-origin compares host[:port].
|
||||
"""
|
||||
from app.gateway.csrf_middleware import (
|
||||
_first_header_value,
|
||||
_normalize_origin,
|
||||
get_configured_cors_origins,
|
||||
)
|
||||
|
||||
origin = websocket.headers.get("origin")
|
||||
if not origin:
|
||||
return True
|
||||
|
||||
normalized = _normalize_origin(origin)
|
||||
if normalized is None:
|
||||
return False
|
||||
if normalized in get_configured_cors_origins():
|
||||
return True
|
||||
|
||||
target_host = _first_header_value(websocket.headers.get("x-forwarded-host")) or websocket.headers.get("host")
|
||||
if target_host:
|
||||
normalized_host = normalized.split("://", 1)[-1]
|
||||
if normalized_host == target_host.strip().lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@router.websocket("/threads/{thread_id}/browser/stream")
|
||||
async def browser_stream(websocket: WebSocket, thread_id: str) -> None:
|
||||
"""Bidirectional live browser stream.
|
||||
|
||||
Server → client: JSON ``{"type":"frame","data":"<base64 jpeg>"}`` frames
|
||||
captured via CDP screencast. Client → server: input events (click, move,
|
||||
down, up, wheel, key, text, navigate) that drive the live page.
|
||||
"""
|
||||
user = await _authenticate_ws(websocket)
|
||||
if user is None:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
if not _ws_origin_allowed(websocket):
|
||||
# Cross-origin upgrade — reject before touching any session (WS-CSRF).
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
thread_store = getattr(websocket.app.state, "thread_store", None)
|
||||
if thread_store is None:
|
||||
# Fail closed: the live stream drives a real browser (cookies,
|
||||
# logged-in pages), so if the ownership store can't be resolved we must
|
||||
# deny rather than let any authenticated caller attach to any thread's
|
||||
# retained session.
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
# Browser control is stricter than ordinary legacy-thread access: NULL-owner
|
||||
# rows are not shared because a retained page may expose cookies or account
|
||||
# data from a previous authenticated browser session.
|
||||
if not await _browser_thread_owned_by(thread_store, thread_id, str(user.id)):
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
if not _browser_tools_enabled():
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
try:
|
||||
from deerflow.community.browser_automation import (
|
||||
BrowserLiveViewerError,
|
||||
BrowserSessionCapacityError,
|
||||
get_browser_session_manager,
|
||||
validate_browser_url,
|
||||
)
|
||||
except ImportError:
|
||||
await websocket.close(code=4501)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
token = set_current_user(user)
|
||||
loop = asyncio.get_running_loop()
|
||||
frame_queue: asyncio.Queue[str] = asyncio.Queue(maxsize=4)
|
||||
send_lock = asyncio.Lock()
|
||||
input_event = asyncio.Event()
|
||||
input_queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=64)
|
||||
pending_move: dict | None = None
|
||||
pending_wheel: dict | None = None
|
||||
|
||||
async def _send_payload(payload: dict) -> None:
|
||||
async with send_lock:
|
||||
await websocket.send_text(json.dumps(payload))
|
||||
|
||||
def _on_frame(data: str) -> None:
|
||||
# Invoked on the private Playwright loop; hop to this loop and drop the
|
||||
# oldest frame when the client can't keep up (screencast is lossy).
|
||||
def _enqueue() -> None:
|
||||
if frame_queue.full():
|
||||
with contextlib.suppress(asyncio.QueueEmpty):
|
||||
frame_queue.get_nowait()
|
||||
with contextlib.suppress(asyncio.QueueFull):
|
||||
frame_queue.put_nowait(data)
|
||||
|
||||
loop.call_soon_threadsafe(_enqueue)
|
||||
|
||||
# Match the tool's session config (headless/viewport/cdp_url) so the live
|
||||
# stream reuses the same session the agent drives — including CDP-attach to
|
||||
# the user's real Chrome when configured.
|
||||
from deerflow.config import get_app_config
|
||||
|
||||
tool_cfg = get_app_config().get_tool_config("browser_navigate")
|
||||
extra = (tool_cfg.model_extra or {}) if tool_cfg is not None else {}
|
||||
|
||||
def _cfg_int(key: str, default: int) -> int:
|
||||
value = extra.get(key)
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) else default
|
||||
|
||||
def _cfg_bool(key: str, default: bool) -> bool:
|
||||
value = extra.get(key)
|
||||
return value if isinstance(value, bool) else default
|
||||
|
||||
def _cfg_str(key: str) -> str | None:
|
||||
value = extra.get(key)
|
||||
return value.strip() or None if isinstance(value, str) else None
|
||||
|
||||
manager = get_browser_session_manager()
|
||||
try:
|
||||
session_lease = manager.acquire_session(
|
||||
thread_id,
|
||||
headless=_cfg_bool("headless", True),
|
||||
timeout_ms=_cfg_int("timeout_ms", 30000),
|
||||
viewport={"width": _cfg_int("viewport_width", 1280), "height": _cfg_int("viewport_height", 720)},
|
||||
cdp_url=_cfg_str("cdp_url"),
|
||||
allow_unguarded_cdp=_cfg_bool("allow_unguarded_cdp", False),
|
||||
url_guard=validate_browser_url,
|
||||
)
|
||||
session = session_lease.__enter__()
|
||||
except BrowserSessionCapacityError:
|
||||
await websocket.close(code=4429)
|
||||
reset_current_user(token)
|
||||
return
|
||||
except Exception:
|
||||
await websocket.close(code=4501)
|
||||
reset_current_user(token)
|
||||
return
|
||||
|
||||
async def _pump_frames() -> None:
|
||||
while True:
|
||||
data = await frame_queue.get()
|
||||
await _send_payload({"type": "frame", "data": data})
|
||||
|
||||
async def _send_url() -> None:
|
||||
# Report the page's real URL so the client's address bar reflects the
|
||||
# actual location after navigations, redirects, and history moves — not
|
||||
# the optimistic value the user typed.
|
||||
with contextlib.suppress(Exception):
|
||||
url = await session.current_url()
|
||||
if url:
|
||||
await _send_payload({"type": "url", "url": url})
|
||||
|
||||
async def _send_tabs() -> None:
|
||||
with contextlib.suppress(Exception):
|
||||
tabs = await session.tabs()
|
||||
await _send_payload(
|
||||
{
|
||||
"type": "tabs",
|
||||
"tabs": [
|
||||
{
|
||||
"index": tab.index,
|
||||
"title": tab.title,
|
||||
"url": tab.url,
|
||||
"active": tab.active,
|
||||
}
|
||||
for tab in tabs
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
async def _poll_location() -> None:
|
||||
# The agent drives the same session through its tools (browser_navigate /
|
||||
# click / type), which do not flow through this socket's input handler, so
|
||||
# those location changes would otherwise never reach the address bar/tabs.
|
||||
# Tool actions push their own settled Live frame after the inline
|
||||
# screenshot is captured; this poll only keeps URL/tabs metadata in sync.
|
||||
# Avoid screenshotting here, because opening a Live panel already primes a
|
||||
# frame and GitHub-style SPAs can generate many URL/render transitions.
|
||||
last_url: str | None = None
|
||||
while True:
|
||||
await asyncio.sleep(1.0)
|
||||
with contextlib.suppress(Exception):
|
||||
url = await session.current_url()
|
||||
if url and url != last_url:
|
||||
last_url = url
|
||||
await _send_payload({"type": "url", "url": url})
|
||||
await _send_tabs()
|
||||
|
||||
def _queue_input(event: dict) -> None:
|
||||
nonlocal pending_move, pending_wheel
|
||||
etype = event.get("type")
|
||||
if etype == "move":
|
||||
pending_move = event
|
||||
elif etype == "wheel":
|
||||
if pending_wheel is None:
|
||||
pending_wheel = event
|
||||
else:
|
||||
pending_wheel = {
|
||||
**event,
|
||||
"dx": float(pending_wheel.get("dx", 0)) + float(event.get("dx", 0)),
|
||||
"dy": float(pending_wheel.get("dy", 0)) + float(event.get("dy", 0)),
|
||||
}
|
||||
else:
|
||||
pending_move = None
|
||||
pending_wheel = None
|
||||
if input_queue.full():
|
||||
with contextlib.suppress(asyncio.QueueEmpty):
|
||||
input_queue.get_nowait()
|
||||
with contextlib.suppress(asyncio.QueueFull):
|
||||
input_queue.put_nowait(event)
|
||||
input_event.set()
|
||||
|
||||
def _has_pending_input() -> bool:
|
||||
return pending_move is not None or pending_wheel is not None or not input_queue.empty()
|
||||
|
||||
def _take_input() -> dict | None:
|
||||
nonlocal pending_move, pending_wheel
|
||||
if not input_queue.empty():
|
||||
return input_queue.get_nowait()
|
||||
if pending_wheel is not None:
|
||||
event = pending_wheel
|
||||
pending_wheel = None
|
||||
return event
|
||||
if pending_move is not None:
|
||||
event = pending_move
|
||||
pending_move = None
|
||||
return event
|
||||
return None
|
||||
|
||||
async def _read_inputs() -> None:
|
||||
while True:
|
||||
try:
|
||||
raw = await websocket.receive_text()
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
return
|
||||
try:
|
||||
event = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if isinstance(event, dict):
|
||||
_queue_input(event)
|
||||
|
||||
async def _process_inputs() -> None:
|
||||
while True:
|
||||
await input_event.wait()
|
||||
while True:
|
||||
event = _take_input()
|
||||
if event is None:
|
||||
input_event.clear()
|
||||
if _has_pending_input():
|
||||
input_event.set()
|
||||
break
|
||||
if event.get("type") == "navigate":
|
||||
# SSRF-screen client-driven navigations with the same policy
|
||||
# the agent tools enforce; reject rather than dispatch.
|
||||
url = event.get("url")
|
||||
reason = validate_browser_url(url) if isinstance(url, str) else "Error: invalid navigation URL"
|
||||
if reason is not None:
|
||||
await _send_payload({"type": "nav_rejected", "url": url, "message": reason})
|
||||
continue
|
||||
try:
|
||||
await session.dispatch_input(event)
|
||||
except Exception as exc:
|
||||
logger.warning("browser stream input failed: %s", exc)
|
||||
else:
|
||||
# A location may have changed — resync the client's URL bar.
|
||||
if event.get("type") in {"navigate", "back", "forward", "click", "activate_tab"}:
|
||||
await _send_url()
|
||||
await _send_tabs()
|
||||
|
||||
pump_task = asyncio.create_task(_pump_frames())
|
||||
input_task: asyncio.Task | None = None
|
||||
reader_task: asyncio.Task | None = None
|
||||
poll_task: asyncio.Task | None = None
|
||||
try:
|
||||
# Seed the live page from the latest browser_view URL. A thread can have
|
||||
# a stale browser session from an earlier panel/live attempt; if that
|
||||
# page differs from the latest visible browser artifact, align Live with
|
||||
# what the user expects instead of requiring an off/on reconnect.
|
||||
seed = websocket.query_params.get("seed")
|
||||
if seed and validate_browser_url(seed) is None:
|
||||
with contextlib.suppress(Exception):
|
||||
current = await session.current_url()
|
||||
if _should_apply_browser_seed(current, seed):
|
||||
await session.navigate(seed)
|
||||
try:
|
||||
await session.start_screencast(_on_frame)
|
||||
except BrowserLiveViewerError:
|
||||
await websocket.close(code=4409)
|
||||
return
|
||||
await _send_url()
|
||||
await _send_tabs()
|
||||
input_task = asyncio.create_task(_process_inputs())
|
||||
reader_task = asyncio.create_task(_read_inputs())
|
||||
poll_task = asyncio.create_task(_poll_location())
|
||||
await reader_task
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.exception("browser stream error: thread_id=%s err=%s", thread_id, exc)
|
||||
finally:
|
||||
pump_task.cancel()
|
||||
if input_task is not None:
|
||||
input_task.cancel()
|
||||
if reader_task is not None:
|
||||
reader_task.cancel()
|
||||
if poll_task is not None:
|
||||
poll_task.cancel()
|
||||
with contextlib.suppress(Exception):
|
||||
await session.stop_screencast(_on_frame)
|
||||
session_lease.__exit__(None, None, None)
|
||||
reset_current_user(token)
|
||||
@ -9,6 +9,7 @@ on the next request without a restart (config hot-reload boundary).
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.browser_capability import browser_capability
|
||||
from app.gateway.deps import get_config
|
||||
from deerflow.config.app_config import AppConfig
|
||||
|
||||
@ -21,10 +22,17 @@ class AgentsApiFeature(BaseModel):
|
||||
enabled: bool = Field(..., description="Whether the agents_api routes are exposed over HTTP")
|
||||
|
||||
|
||||
class BrowserControlFeature(BaseModel):
|
||||
"""Availability of live agentic browser control."""
|
||||
|
||||
enabled: bool = Field(..., description="Whether the live browser routes and UI are available")
|
||||
|
||||
|
||||
class FeaturesResponse(BaseModel):
|
||||
"""Frontend-facing feature availability flags."""
|
||||
|
||||
agents_api: AgentsApiFeature
|
||||
browser_control: BrowserControlFeature
|
||||
|
||||
|
||||
@router.get(
|
||||
@ -35,6 +43,8 @@ class FeaturesResponse(BaseModel):
|
||||
)
|
||||
async def list_features(config: AppConfig = Depends(get_config)) -> FeaturesResponse:
|
||||
"""Return availability of optional, config-gated frontend features."""
|
||||
browser = browser_capability(config)
|
||||
return FeaturesResponse(
|
||||
agents_api=AgentsApiFeature(enabled=config.agents_api.enabled),
|
||||
browser_control=BrowserControlFeature(enabled=browser.available),
|
||||
)
|
||||
|
||||
@ -501,6 +501,18 @@ async def delete_thread_data(thread_id: str, request: Request) -> ThreadDeleteRe
|
||||
except Exception:
|
||||
logger.debug("Could not delete thread_meta for %s (not critical)", sanitize_log_param(thread_id))
|
||||
|
||||
# Tear down any live browser session (best-effort). Sessions are keyed only
|
||||
# by thread_id, so leaving one alive after the owner deletes the thread lets
|
||||
# a later caller who guesses the id reuse the retained page/cookies.
|
||||
try:
|
||||
from deerflow.community.browser_automation import get_browser_session_manager
|
||||
|
||||
await get_browser_session_manager().close_session(thread_id)
|
||||
except ImportError:
|
||||
pass # Playwright is an optional dependency.
|
||||
except Exception:
|
||||
logger.debug("Could not close browser session for %s (not critical)", sanitize_log_param(thread_id))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@ -248,7 +248,7 @@ Notes:
|
||||
- `enabled: false` keeps background polling off by default.
|
||||
- `max_concurrent_runs` is a global cap on active scheduled runs (queued/running run rows); each poll cycle claims only into the remaining budget, so long runs accumulating across cycles cannot exceed it.
|
||||
- All scheduler fields are restart-required; edits need a Gateway restart.
|
||||
- Multi-worker deployments (`GATEWAY_WORKERS > 1`) must use the Postgres database backend. SQLite silently ignores row-level locks, so multiple workers can double-fire the same task.
|
||||
- Multi-worker deployments (`GATEWAY_WORKERS > 1`) must use the Postgres database backend. SQLite silently ignores row-level locks, so multiple workers can double-fire the same task. The process-local agentic browser tool group is incompatible with multiple Gateway workers; keep `GATEWAY_WORKERS=1` while `browser_navigate` is enabled. Browser control also requires the backend `browser` extra (`cd backend && uv sync --extra browser && uv run playwright install chromium`); startup detects enabled browser config and fails fast when Playwright is missing, and `/api/features` reports `browser_control.enabled=false` until the runtime is available.
|
||||
- The MVP supports thread reuse and fresh-thread-per-run execution modes.
|
||||
- The MVP supports only `once` and `cron`.
|
||||
- Manual trigger uses the same scheduled-task resource and run lifecycle.
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
from .session import (
|
||||
BrowserLiveViewerError,
|
||||
BrowserSession,
|
||||
BrowserSessionCapacityError,
|
||||
BrowserSessionManager,
|
||||
BrowserTab,
|
||||
PageSnapshot,
|
||||
SnapshotElement,
|
||||
browser_multi_worker_error,
|
||||
get_browser_session_manager,
|
||||
redact_browser_url,
|
||||
reset_browser_session_manager,
|
||||
)
|
||||
from .tools import (
|
||||
browser_back_tool,
|
||||
browser_click_tool,
|
||||
browser_close_tool,
|
||||
browser_get_text_tool,
|
||||
browser_navigate_tool,
|
||||
browser_screenshot_tool,
|
||||
browser_snapshot_tool,
|
||||
browser_type_tool,
|
||||
navigate_and_capture,
|
||||
validate_browser_url,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BrowserSession",
|
||||
"BrowserSessionCapacityError",
|
||||
"BrowserSessionManager",
|
||||
"BrowserTab",
|
||||
"BrowserLiveViewerError",
|
||||
"PageSnapshot",
|
||||
"SnapshotElement",
|
||||
"browser_multi_worker_error",
|
||||
"browser_back_tool",
|
||||
"browser_click_tool",
|
||||
"browser_close_tool",
|
||||
"browser_get_text_tool",
|
||||
"browser_navigate_tool",
|
||||
"browser_screenshot_tool",
|
||||
"browser_snapshot_tool",
|
||||
"browser_type_tool",
|
||||
"get_browser_session_manager",
|
||||
"navigate_and_capture",
|
||||
"redact_browser_url",
|
||||
"reset_browser_session_manager",
|
||||
"validate_browser_url",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,448 @@
|
||||
"""Agentic browser tools — a stateful navigate → observe → act loop.
|
||||
|
||||
Unlike the read-only ``web_fetch`` / ``web_capture`` tools, these keep a live
|
||||
per-thread browser session (see :mod:`.session`) so the agent can click, type,
|
||||
submit forms, and follow multi-step flows on JavaScript-heavy or authenticated
|
||||
pages. Every action returns a fresh page snapshot whose interactive elements are
|
||||
addressed by a stable ``[ref]`` index, so the model acts on what it just
|
||||
observed instead of guessing selectors.
|
||||
|
||||
All URLs are SSRF-screened with the shared :func:`validate_public_http_url`
|
||||
helper (opt-out only for intentional internal targets).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from langchain.tools import InjectedToolCallId, tool
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.types import Command
|
||||
|
||||
from deerflow.community.url_safety import resolve_host_addresses as _resolve_host_addresses
|
||||
from deerflow.community.url_safety import validate_public_http_url
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
|
||||
from deerflow.constants import BROWSER_FRAMES_DIRNAME
|
||||
from deerflow.tools.types import Runtime
|
||||
|
||||
from .session import BrowserSession, BrowserSessionManager, PageSnapshot, get_browser_session_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_OUTPUTS_VIRTUAL_PREFIX = f"{VIRTUAL_PATH_PREFIX}/outputs"
|
||||
# Auto-captured per-step screenshots are live progress feedback (shown in the
|
||||
# browser panel + inline thumbnails), not deliverables. Keep them in a hidden
|
||||
# subdir so the workspace-changes review does not list them as file changes.
|
||||
# The dir name is a shared constant so the scanner's ignore list cannot drift.
|
||||
_BROWSER_FRAMES_DIRNAME = BROWSER_FRAMES_DIRNAME
|
||||
_FRAMES_VIRTUAL_PREFIX = f"{_OUTPUTS_VIRTUAL_PREFIX}/{_BROWSER_FRAMES_DIRNAME}"
|
||||
_SAFE_FILENAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
|
||||
def _get_tool_config(tool_name: str) -> dict:
|
||||
config = get_app_config().get_tool_config(tool_name)
|
||||
if config is None:
|
||||
return {}
|
||||
return config.model_extra or {}
|
||||
|
||||
|
||||
def _as_bool(value: object, default: bool) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if lowered in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _as_int(value: object, default: int) -> int:
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return int(value.strip())
|
||||
except ValueError:
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def _thread_id(runtime: Runtime) -> str | None:
|
||||
return runtime.context.get("thread_id") if runtime.context else None
|
||||
|
||||
|
||||
def _as_str(value: object) -> str | None:
|
||||
if isinstance(value, str):
|
||||
trimmed = value.strip()
|
||||
return trimmed or None
|
||||
return None
|
||||
|
||||
|
||||
class _SessionLease:
|
||||
"""Context manager that keeps a process-local browser session pinned."""
|
||||
|
||||
def __init__(self, manager: BrowserSessionManager, thread_id: str | None, session: BrowserSession) -> None:
|
||||
self._manager = manager
|
||||
self._thread_id = thread_id
|
||||
self.session = session
|
||||
|
||||
def __enter__(self) -> BrowserSession:
|
||||
return self.session
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
||||
self._manager.release_session(self._thread_id, self.session)
|
||||
|
||||
|
||||
def _resolve_session(runtime: Runtime, tool_name: str) -> _SessionLease:
|
||||
# Launch config (headless/viewport/timeout/cdp_url) is read from a single
|
||||
# canonical source — always ``browser_navigate`` — regardless of which tool
|
||||
# first creates the session. ``get_session`` caches per thread and ignores
|
||||
# these params for later callers, so keying launch config off the calling
|
||||
# tool made it "first tool to run wins": a ``headless: false`` set only on
|
||||
# ``browser_navigate`` was silently dropped if another tool (or the live WS)
|
||||
# initialized the session first. ``tool_name`` is retained for callers that
|
||||
# read their own non-launch config (e.g. ``browser_get_text``'s max_chars).
|
||||
del tool_name
|
||||
cfg = _get_tool_config("browser_navigate")
|
||||
headless = _as_bool(cfg.get("headless"), True)
|
||||
timeout_ms = _as_int(cfg.get("timeout_ms"), 30000)
|
||||
width = _as_int(cfg.get("viewport_width"), 1280)
|
||||
height = _as_int(cfg.get("viewport_height"), 720)
|
||||
cdp_url = _as_str(cfg.get("cdp_url"))
|
||||
manager = get_browser_session_manager()
|
||||
thread_id = _thread_id(runtime)
|
||||
session = manager.get_session(
|
||||
thread_id,
|
||||
headless=headless,
|
||||
timeout_ms=timeout_ms,
|
||||
viewport={"width": width, "height": height},
|
||||
cdp_url=cdp_url,
|
||||
allow_unguarded_cdp=_as_bool(cfg.get("allow_unguarded_cdp"), False),
|
||||
url_guard=validate_browser_url,
|
||||
pin=True,
|
||||
)
|
||||
return _SessionLease(manager, thread_id, session)
|
||||
|
||||
|
||||
def validate_browser_url(url: str, *, tool_name: str = "browser_navigate") -> str | None:
|
||||
"""SSRF-screen a browser navigation URL using the tool's config policy.
|
||||
|
||||
Returns an ``"Error: ..."`` string when the URL must be rejected, or ``None``
|
||||
when navigation may proceed. Shared by the agent tools and the Gateway live
|
||||
stream so every path that can steer the browser enforces the same allow/deny
|
||||
policy (``allow_private_addresses`` from the ``browser_navigate`` tool config).
|
||||
"""
|
||||
cfg = _get_tool_config(tool_name)
|
||||
allow_private = _as_bool(cfg.get("allow_private_addresses"), False)
|
||||
return validate_public_http_url(
|
||||
url,
|
||||
allow_private_addresses=allow_private,
|
||||
action="browse",
|
||||
resolver=_resolve_host_addresses,
|
||||
)
|
||||
|
||||
|
||||
def _validate_url(tool_name: str, url: str) -> str | None:
|
||||
return validate_browser_url(url, tool_name=tool_name)
|
||||
|
||||
|
||||
def _snapshot_message(snapshot: PageSnapshot, prefix: str = "") -> str:
|
||||
body = snapshot.render()
|
||||
return f"{prefix}\n\n{body}" if prefix else body
|
||||
|
||||
|
||||
def _tool_message(content: str, tool_call_id: str) -> Command:
|
||||
return Command(update={"messages": [ToolMessage(content, tool_call_id=tool_call_id)]})
|
||||
|
||||
|
||||
def _step_screenshot_name(action: str) -> str:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S-%f")
|
||||
safe = _SAFE_FILENAME_RE.sub("_", action).strip("._-") or "step"
|
||||
return f"browser-{safe}-{stamp}.png"
|
||||
|
||||
|
||||
async def _capture_step_screenshot(runtime: Runtime, session: BrowserSession, action: str) -> str | None:
|
||||
"""Best-effort per-action screenshot saved as hidden live-progress feedback.
|
||||
|
||||
Returns the artifact virtual path (under the hidden ``.browser-frames`` dir so
|
||||
it stays out of the workspace-changes review), or ``None`` when outputs are
|
||||
unavailable or capture fails — a failed capture must never break the action.
|
||||
"""
|
||||
outputs_path = _thread_outputs_path(runtime)
|
||||
if isinstance(outputs_path, str):
|
||||
return None
|
||||
try:
|
||||
content = await session.screenshot_bytes(full_page=False)
|
||||
name = _step_screenshot_name(action)
|
||||
frames_dir = outputs_path / _BROWSER_FRAMES_DIRNAME
|
||||
final_name = await asyncio.to_thread(_write_screenshot, frames_dir, name, content)
|
||||
with contextlib.suppress(Exception):
|
||||
session.schedule_live_frames()
|
||||
return f"{_FRAMES_VIRTUAL_PREFIX}/{final_name}"
|
||||
except Exception as e:
|
||||
logger.warning(f"browser step screenshot failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _snapshot_command(
|
||||
runtime: Runtime,
|
||||
session: BrowserSession,
|
||||
snapshot: PageSnapshot,
|
||||
tool_call_id: str,
|
||||
prefix: str,
|
||||
screenshot_path: str | None,
|
||||
) -> Command:
|
||||
"""Build a ToolMessage carrying the text snapshot plus an inline screenshot.
|
||||
|
||||
The screenshot rides both as a thread ``artifacts`` entry (so it opens in the
|
||||
artifacts side panel) and on ``ToolMessage.additional_kwargs.browser_view``
|
||||
(so the chat can render an inline thumbnail per browser step).
|
||||
"""
|
||||
text = _snapshot_message(snapshot, prefix)
|
||||
additional_kwargs: dict = {}
|
||||
update: dict = {}
|
||||
if screenshot_path:
|
||||
additional_kwargs["browser_view"] = {"screenshot": screenshot_path, "url": snapshot.url, "title": snapshot.title}
|
||||
update["artifacts"] = [screenshot_path]
|
||||
update["messages"] = [ToolMessage(text, tool_call_id=tool_call_id, additional_kwargs=additional_kwargs)]
|
||||
return Command(update=update)
|
||||
|
||||
|
||||
async def navigate_and_capture(*, thread_id: str | None, url: str, outputs_path: Path) -> dict:
|
||||
"""Drive the per-thread browser session to *url* and capture a screenshot.
|
||||
|
||||
Used by the Gateway browser router so a user can steer the live session from
|
||||
the UI URL bar. Shares the same per-thread session, SSRF policy, and
|
||||
screenshot pipeline as :func:`browser_navigate_tool`.
|
||||
|
||||
Returns ``{"screenshot": virtual_path|None, "url": str, "title": str}``.
|
||||
Raises :class:`ValueError` when the URL fails SSRF validation.
|
||||
"""
|
||||
url_error = _validate_url("browser_navigate", url)
|
||||
if url_error:
|
||||
raise ValueError(url_error)
|
||||
cfg = _get_tool_config("browser_navigate")
|
||||
manager = get_browser_session_manager()
|
||||
with manager.acquire_session(
|
||||
thread_id,
|
||||
headless=_as_bool(cfg.get("headless"), True),
|
||||
timeout_ms=_as_int(cfg.get("timeout_ms"), 30000),
|
||||
viewport={"width": _as_int(cfg.get("viewport_width"), 1280), "height": _as_int(cfg.get("viewport_height"), 720)},
|
||||
cdp_url=_as_str(cfg.get("cdp_url")),
|
||||
allow_unguarded_cdp=_as_bool(cfg.get("allow_unguarded_cdp"), False),
|
||||
url_guard=validate_browser_url,
|
||||
) as session:
|
||||
snapshot = await session.navigate(url)
|
||||
screenshot_path: str | None = None
|
||||
try:
|
||||
content = await session.screenshot_bytes(full_page=False)
|
||||
name = _step_screenshot_name("navigate")
|
||||
frames_dir = outputs_path / _BROWSER_FRAMES_DIRNAME
|
||||
final_name = await asyncio.to_thread(_write_screenshot, frames_dir, name, content)
|
||||
screenshot_path = f"{_FRAMES_VIRTUAL_PREFIX}/{final_name}"
|
||||
except Exception as e:
|
||||
logger.warning(f"browser gateway navigate screenshot failed: {e}")
|
||||
return {"screenshot": screenshot_path, "url": snapshot.url, "title": snapshot.title}
|
||||
|
||||
|
||||
@tool("browser_navigate", parse_docstring=True)
|
||||
async def browser_navigate_tool(runtime: Runtime, url: str, tool_call_id: Annotated[str, InjectedToolCallId]) -> Command:
|
||||
"""Open a URL in a live browser session and return the page's interactive elements.
|
||||
|
||||
Use this to START a browsing flow. Unlike web_fetch (read-only), this keeps a
|
||||
stateful browser so you can then click and type on the page. The result lists
|
||||
interactive elements as ``[ref] role: name`` — use those ``[ref]`` numbers with
|
||||
browser_click and browser_type. The session persists across tool calls for this
|
||||
conversation until browser_close. Every navigate/click/type step is
|
||||
auto-captured as a screenshot the user can see, so you do not need to call
|
||||
browser_screenshot just to show progress.
|
||||
URLs must include the scheme, e.g. https://example.com.
|
||||
|
||||
Args:
|
||||
url: The http(s) URL to open.
|
||||
"""
|
||||
try:
|
||||
url_error = _validate_url("browser_navigate", url)
|
||||
if url_error:
|
||||
return _tool_message(url_error, tool_call_id)
|
||||
with _resolve_session(runtime, "browser_navigate") as session:
|
||||
snapshot = await session.navigate(url)
|
||||
screenshot = await _capture_step_screenshot(runtime, session, "navigate")
|
||||
return _snapshot_command(runtime, session, snapshot, tool_call_id, f"Navigated to {url}.", screenshot)
|
||||
except Exception as e:
|
||||
logger.error(f"browser_navigate failed: {e}")
|
||||
return _tool_message(f"Error: browser navigation failed: {e}", tool_call_id)
|
||||
|
||||
|
||||
@tool("browser_snapshot")
|
||||
async def browser_snapshot_tool(runtime: Runtime, tool_call_id: Annotated[str, InjectedToolCallId]) -> Command:
|
||||
"""Re-read the current page's interactive elements without acting. Use this to refresh the [ref] element list after the page changed on its own (e.g. async content loaded) or when you are unsure of the current state."""
|
||||
try:
|
||||
with _resolve_session(runtime, "browser_snapshot") as session:
|
||||
snapshot = await session.snapshot()
|
||||
screenshot = await _capture_step_screenshot(runtime, session, "snapshot")
|
||||
return _snapshot_command(runtime, session, snapshot, tool_call_id, "", screenshot)
|
||||
except Exception as e:
|
||||
logger.error(f"browser_snapshot failed: {e}")
|
||||
return _tool_message(f"Error: browser snapshot failed: {e}", tool_call_id)
|
||||
|
||||
|
||||
@tool("browser_click", parse_docstring=True)
|
||||
async def browser_click_tool(runtime: Runtime, ref: int, tool_call_id: Annotated[str, InjectedToolCallId]) -> Command:
|
||||
"""Click an interactive element by its ``[ref]`` number from the latest snapshot.
|
||||
|
||||
The ref comes from the numbered element list returned by browser_navigate,
|
||||
browser_snapshot, browser_click, or browser_type. Returns the updated page
|
||||
snapshot after the click (with new ``[ref]`` numbers).
|
||||
|
||||
Args:
|
||||
ref: The element reference number to click.
|
||||
"""
|
||||
try:
|
||||
with _resolve_session(runtime, "browser_click") as session:
|
||||
snapshot = await session.click(ref)
|
||||
screenshot = await _capture_step_screenshot(runtime, session, "click")
|
||||
return _snapshot_command(runtime, session, snapshot, tool_call_id, f"Clicked element [{ref}].", screenshot)
|
||||
except Exception as e:
|
||||
logger.error(f"browser_click failed: {e}")
|
||||
return _tool_message(f"Error: could not click element [{ref}]: {e}", tool_call_id)
|
||||
|
||||
|
||||
@tool("browser_type", parse_docstring=True)
|
||||
async def browser_type_tool(
|
||||
runtime: Runtime,
|
||||
ref: int,
|
||||
text: str,
|
||||
tool_call_id: Annotated[str, InjectedToolCallId],
|
||||
submit: bool = False,
|
||||
) -> Command:
|
||||
"""Type text into an input/textarea element by its ``[ref]`` number.
|
||||
|
||||
Fills the field identified by ref. Set submit=true to press Enter afterward
|
||||
(e.g. to run a search or submit a form). Returns the updated page snapshot.
|
||||
|
||||
Args:
|
||||
ref: The element reference number of the input to type into.
|
||||
text: The text to enter.
|
||||
submit: When true, press Enter after typing to submit.
|
||||
"""
|
||||
try:
|
||||
with _resolve_session(runtime, "browser_type") as session:
|
||||
snapshot = await session.type_text(ref, text, submit=submit)
|
||||
action = f"Typed into element [{ref}] and submitted." if submit else f"Typed into element [{ref}]."
|
||||
screenshot = await _capture_step_screenshot(runtime, session, "type")
|
||||
return _snapshot_command(runtime, session, snapshot, tool_call_id, action, screenshot)
|
||||
except Exception as e:
|
||||
logger.error(f"browser_type failed: {e}")
|
||||
return _tool_message(f"Error: could not type into element [{ref}]: {e}", tool_call_id)
|
||||
|
||||
|
||||
@tool("browser_get_text")
|
||||
async def browser_get_text_tool(runtime: Runtime, tool_call_id: Annotated[str, InjectedToolCallId]) -> Command:
|
||||
"""Read the visible text content of the current page. Use this to extract readable text after navigating/interacting, e.g. to quote results or summarize content. Output is truncated for large pages."""
|
||||
try:
|
||||
with _resolve_session(runtime, "browser_get_text") as session:
|
||||
cfg = _get_tool_config("browser_get_text")
|
||||
max_chars = _as_int(cfg.get("max_chars"), 8000)
|
||||
text = await session.get_text(max_chars=max_chars)
|
||||
return _tool_message(text or "(page has no visible text)", tool_call_id)
|
||||
except Exception as e:
|
||||
logger.error(f"browser_get_text failed: {e}")
|
||||
return _tool_message(f"Error: could not read page text: {e}", tool_call_id)
|
||||
|
||||
|
||||
@tool("browser_back")
|
||||
async def browser_back_tool(runtime: Runtime, tool_call_id: Annotated[str, InjectedToolCallId]) -> Command:
|
||||
"""Go back to the previous page in the browser session's history."""
|
||||
try:
|
||||
with _resolve_session(runtime, "browser_back") as session:
|
||||
snapshot = await session.back()
|
||||
screenshot = await _capture_step_screenshot(runtime, session, "back")
|
||||
return _snapshot_command(runtime, session, snapshot, tool_call_id, "Went back.", screenshot)
|
||||
except Exception as e:
|
||||
logger.error(f"browser_back failed: {e}")
|
||||
return _tool_message(f"Error: could not go back: {e}", tool_call_id)
|
||||
|
||||
|
||||
def _safe_screenshot_name(filename: str | None) -> str:
|
||||
if filename:
|
||||
stem = Path(filename).stem or "browser-capture"
|
||||
else:
|
||||
stem = f"browser-capture-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}"
|
||||
safe = _SAFE_FILENAME_RE.sub("_", stem).strip("._-") or "browser-capture"
|
||||
return f"{safe[:100]}.png"
|
||||
|
||||
|
||||
def _thread_outputs_path(runtime: Runtime) -> Path | str:
|
||||
if runtime.state is None:
|
||||
return "Error: Thread runtime state is not available"
|
||||
thread_data = runtime.state.get("thread_data") or {}
|
||||
outputs_path = thread_data.get("outputs_path")
|
||||
if not outputs_path:
|
||||
return "Error: Thread outputs path is not available"
|
||||
return Path(outputs_path)
|
||||
|
||||
|
||||
def _write_screenshot(outputs_path: Path, name: str, content: bytes) -> str:
|
||||
outputs_path.mkdir(parents=True, exist_ok=True)
|
||||
(outputs_path / name).write_bytes(content)
|
||||
return name
|
||||
|
||||
|
||||
@tool("browser_screenshot", parse_docstring=True)
|
||||
async def browser_screenshot_tool(
|
||||
runtime: Runtime,
|
||||
tool_call_id: Annotated[str, InjectedToolCallId],
|
||||
filename: str | None = None,
|
||||
full_page: bool = False,
|
||||
) -> Command:
|
||||
"""Capture a screenshot of the current browser page and save it as an artifact.
|
||||
|
||||
Use this for visual evidence of the current interactive session state (after
|
||||
clicking/typing), which web_capture cannot provide because it renders a fresh,
|
||||
stateless page load.
|
||||
|
||||
Args:
|
||||
filename: Optional output filename (extension is forced to .png).
|
||||
full_page: Capture the full scrollable page instead of just the viewport.
|
||||
"""
|
||||
try:
|
||||
outputs_path = _thread_outputs_path(runtime)
|
||||
if isinstance(outputs_path, str):
|
||||
return _tool_message(outputs_path, tool_call_id)
|
||||
with _resolve_session(runtime, "browser_screenshot") as session:
|
||||
content = await session.screenshot_bytes(full_page=full_page)
|
||||
name = _safe_screenshot_name(filename)
|
||||
final_name = await asyncio.to_thread(_write_screenshot, outputs_path, name, content)
|
||||
virtual_path = f"{_OUTPUTS_VIRTUAL_PREFIX}/{final_name}"
|
||||
return Command(
|
||||
update={
|
||||
"artifacts": [virtual_path],
|
||||
"messages": [ToolMessage(f"Saved browser screenshot: {virtual_path}", tool_call_id=tool_call_id)],
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"browser_screenshot failed: {e}")
|
||||
return _tool_message(f"Error: could not capture screenshot: {e}", tool_call_id)
|
||||
|
||||
|
||||
@tool("browser_close")
|
||||
async def browser_close_tool(runtime: Runtime, tool_call_id: Annotated[str, InjectedToolCallId]) -> Command:
|
||||
"""Close the current browser session and free its resources. Call this when done with the browsing flow; a later browser_navigate starts a fresh session."""
|
||||
try:
|
||||
manager = get_browser_session_manager()
|
||||
closed = await manager.close_session(_thread_id(runtime))
|
||||
msg = "Browser session closed." if closed else "No active browser session to close."
|
||||
return _tool_message(msg, tool_call_id)
|
||||
except Exception as e:
|
||||
logger.error(f"browser_close failed: {e}")
|
||||
return _tool_message(f"Error: could not close browser session: {e}", tool_call_id)
|
||||
@ -1,3 +1,10 @@
|
||||
"""Shared runtime protocol constants."""
|
||||
|
||||
DEFAULT_SKILLS_CONTAINER_PATH = "/mnt/skills"
|
||||
|
||||
# Hidden subdirectory (under a thread's outputs dir) that holds the browser
|
||||
# tools' per-step screenshots. These are transient live-progress frames, not
|
||||
# deliverables, so the workspace-changes scanner excludes this directory. Both
|
||||
# the browser tools (which write here) and the scanner (which ignores it) import
|
||||
# this single source of truth so the name cannot drift between them.
|
||||
BROWSER_FRAMES_DIRNAME = ".browser-frames"
|
||||
|
||||
@ -6,6 +6,8 @@ import os
|
||||
from codecs import BOM_UTF16_BE, BOM_UTF16_LE, getincrementaldecoder
|
||||
from pathlib import Path
|
||||
|
||||
from deerflow.constants import BROWSER_FRAMES_DIRNAME
|
||||
|
||||
from .types import (
|
||||
DiffUnavailableReason,
|
||||
FileSnapshot,
|
||||
@ -21,6 +23,10 @@ EXCLUDED_DIR_NAMES = {
|
||||
".cache",
|
||||
".next",
|
||||
".venv",
|
||||
# Transient per-step browser screenshots: live progress feedback surfaced in
|
||||
# the browser panel + inline thumbnails, not workspace deliverables. Shared
|
||||
# constant with the browser tools so the name cannot drift out of sync.
|
||||
BROWSER_FRAMES_DIRNAME,
|
||||
"__pycache__",
|
||||
"build",
|
||||
"dist",
|
||||
|
||||
@ -68,6 +68,10 @@ boxlite = ["boxlite>=0.9.7"]
|
||||
# Agent observability (Monocle). Optional so a default install stays free of the
|
||||
# OpenTelemetry stack; only pulled in when MONOCLE_TRACING is used.
|
||||
monocle = ["monocle_apptrace>=0.8.8"]
|
||||
# Agentic browser control (browser_navigate/click/type/... tool group). Optional
|
||||
# so the core harness install stays lean; import is lazy inside the private
|
||||
# Playwright loop. After install, run `playwright install chromium` once.
|
||||
browser = ["playwright>=1.40"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@ -29,6 +29,7 @@ postgres = ["deerflow-harness[postgres]"]
|
||||
redis = ["deerflow-harness[redis]"]
|
||||
discord = ["discord.py>=2.7.0"]
|
||||
monocle = ["deerflow-harness[monocle]"]
|
||||
browser = ["deerflow-harness[browser]"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
171
backend/tests/manual_browser_live_check.py
Normal file
171
backend/tests/manual_browser_live_check.py
Normal file
@ -0,0 +1,171 @@
|
||||
"""Live end-to-end verification for the agentic browser tools.
|
||||
|
||||
NOT a unit test. Run manually with DEEPSEEK_API_KEY in the environment:
|
||||
|
||||
DEEPSEEK_API_KEY=sk-... PYTHONPATH=. uv run python tests/manual_browser_live_check.py
|
||||
|
||||
It:
|
||||
1. serves a tiny local HTML form,
|
||||
2. builds an isolated DeerFlow config (DeepSeek model + browser tool group),
|
||||
3. runs a real agent turn that must navigate, type, submit, and read the result,
|
||||
4. asserts the agent-visible tool trace shows the browser loop actually ran.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
FORM_PAGE = """<!doctype html><html><head><title>DeerFlow Browser Test</title></head>
|
||||
<body>
|
||||
<h1>Sign-in demo</h1>
|
||||
<form method="GET" action="/welcome">
|
||||
<input type="text" name="username" placeholder="Username">
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def _welcome_page(username: str) -> str:
|
||||
return f"""<!doctype html><html><head><title>Welcome</title></head>
|
||||
<body><h1>Welcome, {username}!</h1><p>SECRET-TOKEN-4917</p></body></html>"""
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args): # silence server logs
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path.startswith("/welcome"):
|
||||
qs = parse_qs(parsed.query)
|
||||
username = (qs.get("username") or ["friend"])[0]
|
||||
body = _welcome_page(username)
|
||||
else:
|
||||
body = FORM_PAGE
|
||||
data = body.encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
|
||||
def _start_server() -> tuple[HTTPServer, int]:
|
||||
server = HTTPServer(("127.0.0.1", 0), _Handler)
|
||||
port = server.server_address[1]
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return server, port
|
||||
|
||||
|
||||
def _write_config(tmp: Path) -> Path:
|
||||
config = f"""
|
||||
config_version: 1
|
||||
data_dir: {tmp / "data"}
|
||||
|
||||
models:
|
||||
- name: deepseek-chat
|
||||
display_name: DeepSeek Chat
|
||||
use: deerflow.models.patched_deepseek:PatchedChatDeepSeek
|
||||
model: deepseek-chat
|
||||
api_key: $DEEPSEEK_API_KEY
|
||||
timeout: 120.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
|
||||
sandbox:
|
||||
use: deerflow.sandbox.local:LocalSandboxProvider
|
||||
|
||||
tool_groups:
|
||||
- name: browser
|
||||
|
||||
tools:
|
||||
- name: browser_navigate
|
||||
group: browser
|
||||
use: deerflow.community.browser_automation.tools:browser_navigate_tool
|
||||
headless: true
|
||||
allow_private_addresses: true
|
||||
- name: browser_snapshot
|
||||
group: browser
|
||||
use: deerflow.community.browser_automation.tools:browser_snapshot_tool
|
||||
- name: browser_click
|
||||
group: browser
|
||||
use: deerflow.community.browser_automation.tools:browser_click_tool
|
||||
- name: browser_type
|
||||
group: browser
|
||||
use: deerflow.community.browser_automation.tools:browser_type_tool
|
||||
- name: browser_get_text
|
||||
group: browser
|
||||
use: deerflow.community.browser_automation.tools:browser_get_text_tool
|
||||
- name: browser_close
|
||||
group: browser
|
||||
use: deerflow.community.browser_automation.tools:browser_close_tool
|
||||
|
||||
memory:
|
||||
enabled: false
|
||||
|
||||
title:
|
||||
enabled: false
|
||||
"""
|
||||
path = tmp / "config.yaml"
|
||||
path.write_text(config)
|
||||
return path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not os.environ.get("DEEPSEEK_API_KEY"):
|
||||
print("SKIP: DEEPSEEK_API_KEY not set")
|
||||
return 0
|
||||
|
||||
server, port = _start_server()
|
||||
base = f"http://127.0.0.1:{port}/"
|
||||
tmpdir = Path(tempfile.mkdtemp(prefix="deerflow-browser-live-"))
|
||||
try:
|
||||
from deerflow.client import DeerFlowClient
|
||||
|
||||
config_path = _write_config(tmpdir)
|
||||
# Make config resolution deterministic: get_available_tools() re-resolves
|
||||
# via get_app_config(), which would otherwise pick up a project-root
|
||||
# config.yaml. DEER_FLOW_CONFIG_PATH is resolution priority #2.
|
||||
os.environ["DEER_FLOW_CONFIG_PATH"] = str(config_path)
|
||||
client = DeerFlowClient(config_path=str(config_path))
|
||||
|
||||
prompt = (
|
||||
f"Use the browser tools to complete this task. "
|
||||
f"1) Navigate to {base} 2) type the username 'deerbot' into the username field "
|
||||
f"3) click the Sign in button 4) read the resulting page's text. "
|
||||
f"Then tell me the exact SECRET token shown on the welcome page."
|
||||
)
|
||||
|
||||
tool_calls: list[str] = []
|
||||
final_text = ""
|
||||
for event in client.stream(prompt, thread_id="browser-live-check"):
|
||||
if event.type == "messages-tuple":
|
||||
data = event.data or {}
|
||||
if data.get("type") == "tool":
|
||||
name = data.get("name") or ""
|
||||
if name.startswith("browser_"):
|
||||
tool_calls.append(name)
|
||||
elif data.get("type") == "ai":
|
||||
final_text += data.get("content") or ""
|
||||
|
||||
print("Browser tool calls observed:", tool_calls)
|
||||
print("Final answer:\n", final_text.strip()[:1000])
|
||||
|
||||
assert "browser_navigate" in tool_calls, "agent never navigated"
|
||||
assert any(t in tool_calls for t in ("browser_type", "browser_click")), "agent never interacted"
|
||||
assert "SECRET-TOKEN-4917" in final_text, "agent did not read the post-submit page content"
|
||||
print("\nLIVE CHECK PASSED")
|
||||
return 0
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
1066
backend/tests/test_browser_automation.py
Normal file
1066
backend/tests/test_browser_automation.py
Normal file
File diff suppressed because it is too large
Load Diff
271
backend/tests/test_browser_router.py
Normal file
271
backend/tests/test_browser_router.py
Normal file
@ -0,0 +1,271 @@
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from _router_auth_helpers import make_authed_test_app
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.gateway.auth.models import User
|
||||
from app.gateway.routers import browser as browser_router
|
||||
from app.gateway.routers.browser import _should_apply_browser_seed, _ws_origin_allowed
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
"""Minimal stand-in exposing only the headers ``_ws_origin_allowed`` reads."""
|
||||
|
||||
def __init__(self, headers: dict[str, str]):
|
||||
self.headers = {k.lower(): v for k, v in headers.items()}
|
||||
|
||||
|
||||
def _user(user_id: str = "browser-user") -> SimpleNamespace:
|
||||
return SimpleNamespace(id=user_id)
|
||||
|
||||
|
||||
def _browser_ws_app(thread_store=...):
|
||||
app = FastAPI()
|
||||
app.include_router(browser_router.router)
|
||||
if thread_store is not ...:
|
||||
app.state.thread_store = thread_store
|
||||
return app
|
||||
|
||||
|
||||
def _expect_ws_close(app: FastAPI, code: int, *, headers: dict[str, str] | None = None) -> None:
|
||||
with TestClient(app) as client:
|
||||
with pytest.raises(WebSocketDisconnect) as exc_info:
|
||||
with client.websocket_connect("/api/threads/thread-1/browser/stream", headers=headers or {}):
|
||||
pass
|
||||
assert exc_info.value.code == code
|
||||
|
||||
|
||||
def test_browser_stream_closes_4401_when_unauthenticated():
|
||||
app = _browser_ws_app()
|
||||
with patch.object(browser_router, "_authenticate_ws", AsyncMock(return_value=None)):
|
||||
_expect_ws_close(app, 4401)
|
||||
|
||||
|
||||
def test_browser_stream_closes_4403_for_cross_origin_upgrade():
|
||||
app = _browser_ws_app()
|
||||
with patch.object(browser_router, "_authenticate_ws", AsyncMock(return_value=_user())):
|
||||
_expect_ws_close(app, 4403, headers={"origin": "https://evil.example.com"})
|
||||
|
||||
|
||||
def test_browser_stream_closes_4404_when_thread_store_missing():
|
||||
app = _browser_ws_app()
|
||||
with patch.object(browser_router, "_authenticate_ws", AsyncMock(return_value=_user())):
|
||||
_expect_ws_close(app, 4404)
|
||||
|
||||
|
||||
def test_browser_stream_rejects_legacy_null_owner_thread():
|
||||
store = MagicMock()
|
||||
store.check_access = AsyncMock(return_value=True)
|
||||
store.get = AsyncMock(return_value={"thread_id": "thread-1", "user_id": None})
|
||||
app = _browser_ws_app(store)
|
||||
with (
|
||||
patch.object(browser_router, "_authenticate_ws", AsyncMock(return_value=_user())),
|
||||
patch.object(browser_router, "_browser_tools_enabled", return_value=False),
|
||||
):
|
||||
_expect_ws_close(app, 4404)
|
||||
store.get.assert_awaited_once_with("thread-1", user_id="browser-user")
|
||||
|
||||
|
||||
def test_browser_stream_closes_4404_when_tools_disabled_for_owned_thread():
|
||||
store = MagicMock()
|
||||
store.check_access = AsyncMock(return_value=True)
|
||||
store.get = AsyncMock(return_value={"thread_id": "thread-1", "user_id": "browser-user"})
|
||||
app = _browser_ws_app(store)
|
||||
with (
|
||||
patch.object(browser_router, "_authenticate_ws", AsyncMock(return_value=_user())),
|
||||
patch.object(browser_router, "_browser_tools_enabled", return_value=False),
|
||||
):
|
||||
_expect_ws_close(app, 4404)
|
||||
|
||||
|
||||
def test_browser_stream_closes_4501_when_browser_runtime_unavailable():
|
||||
store = MagicMock()
|
||||
store.check_access = AsyncMock(return_value=True)
|
||||
store.get = AsyncMock(return_value={"thread_id": "thread-1", "user_id": "browser-user"})
|
||||
app = _browser_ws_app(store)
|
||||
real_import = __import__
|
||||
|
||||
def fail_browser_runtime_import(name, globals=None, locals=None, fromlist=(), level=0):
|
||||
if name == "deerflow.community.browser_automation" and "get_browser_session_manager" in fromlist:
|
||||
raise ImportError("browser runtime unavailable")
|
||||
return real_import(name, globals, locals, fromlist, level)
|
||||
|
||||
with (
|
||||
patch.object(browser_router, "_authenticate_ws", AsyncMock(return_value=_user())),
|
||||
patch.object(browser_router, "_browser_tools_enabled", return_value=True),
|
||||
patch("builtins.__import__", side_effect=fail_browser_runtime_import),
|
||||
):
|
||||
_expect_ws_close(app, 4501)
|
||||
|
||||
|
||||
def test_browser_navigate_rejects_legacy_null_owner_thread():
|
||||
user = User(email="browser@example.com", password_hash="x", system_role="user")
|
||||
app = make_authed_test_app(user_factory=lambda: user)
|
||||
app.include_router(browser_router.router)
|
||||
app.state.thread_store.get = AsyncMock(return_value={"thread_id": "thread-1", "user_id": None})
|
||||
|
||||
with (
|
||||
patch.object(browser_router, "_browser_tools_enabled", return_value=True),
|
||||
patch("deerflow.community.browser_automation.navigate_and_capture", new=AsyncMock()),
|
||||
):
|
||||
response = TestClient(app).post(
|
||||
"/api/threads/thread-1/browser/navigate",
|
||||
json={"url": "https://example.com"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
app.state.thread_store.get.assert_awaited_once_with("thread-1", user_id=str(user.id))
|
||||
|
||||
|
||||
def test_browser_tools_disabled_when_cdp_risk_not_explicitly_accepted():
|
||||
tool_cfg = SimpleNamespace(name="browser_navigate", model_extra={"cdp_url": "http://127.0.0.1:9222"})
|
||||
app_config = SimpleNamespace(tools=[tool_cfg])
|
||||
|
||||
with (
|
||||
patch("deerflow.config.get_app_config", return_value=app_config),
|
||||
patch("app.gateway.browser_capability.browser_multi_worker_error", return_value=None),
|
||||
patch("app.gateway.browser_capability.importlib.util.find_spec", return_value=object()),
|
||||
):
|
||||
assert browser_router._browser_tools_enabled() is False
|
||||
|
||||
|
||||
def test_browser_tools_enabled_when_cdp_risk_explicitly_accepted():
|
||||
tool_cfg = SimpleNamespace(
|
||||
name="browser_navigate",
|
||||
model_extra={"cdp_url": "http://127.0.0.1:9222", "allow_unguarded_cdp": True},
|
||||
)
|
||||
app_config = SimpleNamespace(tools=[tool_cfg])
|
||||
|
||||
with (
|
||||
patch("deerflow.config.get_app_config", return_value=app_config),
|
||||
patch("app.gateway.browser_capability.browser_multi_worker_error", return_value=None),
|
||||
patch("app.gateway.browser_capability.importlib.util.find_spec", return_value=object()),
|
||||
):
|
||||
assert browser_router._browser_tools_enabled() is True
|
||||
|
||||
|
||||
def test_browser_navigate_redacts_failure_url_from_logs_and_response(caplog):
|
||||
user = User(email="browser@example.com", password_hash="x", system_role="user")
|
||||
app = make_authed_test_app(user_factory=lambda: user)
|
||||
app.include_router(browser_router.router)
|
||||
app.state.thread_store.get = AsyncMock(return_value={"thread_id": "thread-1", "user_id": str(user.id)})
|
||||
failing_url = "https://example.com/callback?code=secret#fragment"
|
||||
|
||||
caplog.set_level(logging.ERROR, logger="app.gateway.routers.browser")
|
||||
with (
|
||||
patch.object(browser_router, "_browser_tools_enabled", return_value=True),
|
||||
patch(
|
||||
"deerflow.community.browser_automation.navigate_and_capture",
|
||||
new=AsyncMock(side_effect=RuntimeError(f"timed out opening {failing_url}")),
|
||||
),
|
||||
):
|
||||
response = TestClient(app).post(
|
||||
"/api/threads/thread-1/browser/navigate",
|
||||
json={"url": failing_url},
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
assert response.json() == {"detail": "Browser navigation failed"}
|
||||
assert "https://example.com/callback" in caplog.text
|
||||
assert "code=secret" not in caplog.text
|
||||
assert "fragment" not in caplog.text
|
||||
assert "secret" not in response.text
|
||||
|
||||
|
||||
def test_browser_stream_seed_applies_to_blank_page():
|
||||
assert _should_apply_browser_seed("about:blank", "https://github.com/bytedance/deer-flow")
|
||||
|
||||
|
||||
def test_browser_stream_seed_applies_when_current_url_differs():
|
||||
assert _should_apply_browser_seed(
|
||||
"https://docs.byteplus.com/en/docs/InfoQuest/What_is_Info_Quest",
|
||||
"https://github.com/bytedance/deer-flow",
|
||||
)
|
||||
|
||||
|
||||
def test_browser_stream_seed_ignores_hash_and_trailing_slash_for_same_page():
|
||||
assert not _should_apply_browser_seed(
|
||||
"https://github.com/bytedance/deer-flow/#readme",
|
||||
"https://github.com/bytedance/deer-flow/",
|
||||
)
|
||||
|
||||
|
||||
def test_ws_origin_allowed_without_origin_header():
|
||||
# Native ws clients / tests do not send Origin — allow them.
|
||||
assert _ws_origin_allowed(_FakeWebSocket({"host": "app.example.com"})) is True
|
||||
|
||||
|
||||
def test_ws_origin_allowed_same_origin_host():
|
||||
# Browser page scheme (https) differs from the ws scheme, so same-origin
|
||||
# compares host[:port] against the upgrade target Host.
|
||||
ws = _FakeWebSocket({"origin": "https://app.example.com", "host": "app.example.com"})
|
||||
assert _ws_origin_allowed(ws) is True
|
||||
|
||||
|
||||
def test_ws_origin_allowed_rejects_cross_origin():
|
||||
ws = _FakeWebSocket({"origin": "https://evil.example.com", "host": "app.example.com"})
|
||||
assert _ws_origin_allowed(ws) is False
|
||||
|
||||
|
||||
def test_ws_origin_allowed_rejects_malformed_origin():
|
||||
ws = _FakeWebSocket({"origin": "not-a-url", "host": "app.example.com"})
|
||||
assert _ws_origin_allowed(ws) is False
|
||||
|
||||
|
||||
def test_ws_origin_allowed_honors_configured_cors_origin(monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_CORS_ORIGINS", "https://console.example.com")
|
||||
ws = _FakeWebSocket({"origin": "https://console.example.com", "host": "gateway.internal"})
|
||||
assert _ws_origin_allowed(ws) is True
|
||||
|
||||
|
||||
def test_ws_origin_allowed_honors_forwarded_host():
|
||||
# Behind a proxy the real upgrade target is X-Forwarded-Host, not Host.
|
||||
ws = _FakeWebSocket(
|
||||
{
|
||||
"origin": "https://app.example.com",
|
||||
"host": "gateway.internal",
|
||||
"x-forwarded-host": "app.example.com",
|
||||
},
|
||||
)
|
||||
assert _ws_origin_allowed(ws) is True
|
||||
|
||||
|
||||
def test_browser_frames_dirname_shared_between_tools_and_scanner():
|
||||
"""The screenshots dir name must stay identical in the writer and the scanner.
|
||||
|
||||
Both sides import the single ``BROWSER_FRAMES_DIRNAME`` constant; this locks
|
||||
that they resolve to the same value so the workspace-changes ignore cannot
|
||||
silently drift away from where the browser tools write frames.
|
||||
"""
|
||||
from deerflow.community.browser_automation import tools as browser_tools
|
||||
from deerflow.constants import BROWSER_FRAMES_DIRNAME
|
||||
from deerflow.workspace_changes.scanner import EXCLUDED_DIR_NAMES
|
||||
|
||||
assert browser_tools._BROWSER_FRAMES_DIRNAME == BROWSER_FRAMES_DIRNAME
|
||||
assert BROWSER_FRAMES_DIRNAME in EXCLUDED_DIR_NAMES
|
||||
|
||||
|
||||
def test_validate_browser_url_rejects_private_and_non_http(monkeypatch):
|
||||
"""WS seed / navigate events reuse the same SSRF policy as the agent tools.
|
||||
|
||||
With no ``allow_private_addresses`` override the shared validator must reject
|
||||
loopback / metadata / non-http targets, so the live stream cannot be steered
|
||||
at internal infrastructure.
|
||||
"""
|
||||
from deerflow.community.browser_automation import tools as browser_tools
|
||||
from deerflow.community.browser_automation import validate_browser_url
|
||||
|
||||
# Isolate from any local config.yaml that may set allow_private_addresses.
|
||||
monkeypatch.setattr(browser_tools, "_get_tool_config", lambda _tool_name: {})
|
||||
|
||||
assert validate_browser_url("http://169.254.169.254/latest/meta-data/") is not None
|
||||
assert validate_browser_url("http://127.0.0.1:8001/") is not None
|
||||
assert validate_browser_url("file:///etc/passwd") is not None
|
||||
assert validate_browser_url("ftp://example.com") is not None
|
||||
# A normal public URL passes (returns None = allowed).
|
||||
assert validate_browser_url("https://github.com/bytedance/deer-flow") is None
|
||||
@ -149,6 +149,22 @@ def test_detect_from_config_redis_via_stream_bridge(tmp_path):
|
||||
assert detect.detect_from_config(cfg) == ["redis"]
|
||||
|
||||
|
||||
def test_detect_from_config_browser_via_browser_navigate_tool(tmp_path):
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text(
|
||||
"tools:\n - name: browser_navigate\n group: browser\n use: deerflow.community.browser_automation.tools:browser_navigate_tool\n",
|
||||
)
|
||||
assert detect.detect_from_config(cfg) == ["browser"]
|
||||
|
||||
|
||||
def test_detect_from_config_ignores_commented_browser_tool(tmp_path):
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text(
|
||||
"tools:\n # - name: browser_navigate\n # group: browser\n - name: web_fetch\n group: web\n",
|
||||
)
|
||||
assert detect.detect_from_config(cfg) == []
|
||||
|
||||
|
||||
def test_detect_from_config_memory_stream_bridge_returns_no_extras(tmp_path):
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("stream_bridge:\n type: memory\n queue_maxsize: 256\n")
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
@ -7,10 +8,17 @@ from app.gateway.deps import get_config
|
||||
from app.gateway.routers import features
|
||||
|
||||
|
||||
def _app_with_config(*, agents_api_enabled: bool) -> FastAPI:
|
||||
def _app_with_config(*, agents_api_enabled: bool, browser_enabled: bool = False, browser_extra: dict | None = None) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(features.router)
|
||||
fake_config = SimpleNamespace(agents_api=SimpleNamespace(enabled=agents_api_enabled))
|
||||
tools = (
|
||||
[
|
||||
SimpleNamespace(name="browser_navigate", model_extra=browser_extra or {}),
|
||||
]
|
||||
if browser_enabled
|
||||
else []
|
||||
)
|
||||
fake_config = SimpleNamespace(agents_api=SimpleNamespace(enabled=agents_api_enabled), tools=tools)
|
||||
app.dependency_overrides[get_config] = lambda: fake_config
|
||||
return app
|
||||
|
||||
@ -19,11 +27,47 @@ def test_features_reports_agents_api_enabled() -> None:
|
||||
with TestClient(_app_with_config(agents_api_enabled=True)) as client:
|
||||
response = client.get("/api/features")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"agents_api": {"enabled": True}}
|
||||
assert response.json() == {"agents_api": {"enabled": True}, "browser_control": {"enabled": False}}
|
||||
|
||||
|
||||
def test_features_reports_agents_api_disabled() -> None:
|
||||
with TestClient(_app_with_config(agents_api_enabled=False)) as client:
|
||||
response = client.get("/api/features")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"agents_api": {"enabled": False}}
|
||||
assert response.json() == {"agents_api": {"enabled": False}, "browser_control": {"enabled": False}}
|
||||
|
||||
|
||||
def test_features_reports_browser_control_enabled_when_configured_and_runtime_available() -> None:
|
||||
with (
|
||||
patch("app.gateway.browser_capability.importlib.util.find_spec", return_value=object()),
|
||||
TestClient(_app_with_config(agents_api_enabled=True, browser_enabled=True)) as client,
|
||||
):
|
||||
response = client.get("/api/features")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["browser_control"] == {"enabled": True}
|
||||
|
||||
|
||||
def test_features_reports_browser_control_disabled_when_runtime_missing() -> None:
|
||||
with (
|
||||
patch("app.gateway.browser_capability.importlib.util.find_spec", return_value=None),
|
||||
TestClient(_app_with_config(agents_api_enabled=True, browser_enabled=True)) as client,
|
||||
):
|
||||
response = client.get("/api/features")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["browser_control"] == {"enabled": False}
|
||||
|
||||
|
||||
def test_features_reports_browser_control_disabled_for_unguarded_cdp() -> None:
|
||||
with (
|
||||
patch("app.gateway.browser_capability.importlib.util.find_spec", return_value=object()),
|
||||
TestClient(
|
||||
_app_with_config(
|
||||
agents_api_enabled=True,
|
||||
browser_enabled=True,
|
||||
browser_extra={"cdp_url": "http://127.0.0.1:9222"},
|
||||
),
|
||||
) as client,
|
||||
):
|
||||
response = client.get("/api/features")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["browser_control"] == {"enabled": False}
|
||||
|
||||
@ -429,6 +429,47 @@ def test_make_lead_agent_passive_empty_skill_policy_preserves_mcp_and_other_tool
|
||||
assert captured_deferred_setups[0].deferred_names == frozenset({"lightrag_query"})
|
||||
|
||||
|
||||
def test_default_lead_agent_does_not_apply_installed_skill_allowlists(monkeypatch):
|
||||
"""Installed skills are discoverable but not active for ordinary default chat.
|
||||
|
||||
A public skill with ``allowed-tools`` must not globally hide configured
|
||||
tools like ``browser_navigate`` before the user has selected a specific
|
||||
skill-owned workflow.
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from deerflow.agents.lead_agent import agent as lead_agent_module
|
||||
|
||||
monkeypatch.setattr(lead_agent_module, "_resolve_model_name", lambda x=None, **kwargs: "default-model")
|
||||
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: "model")
|
||||
monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda *args, **kwargs: [])
|
||||
monkeypatch.setattr(lead_agent_module, "apply_prompt_template", lambda **kwargs: "mock_prompt")
|
||||
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
|
||||
monkeypatch.setattr(
|
||||
lead_agent_module,
|
||||
"_load_enabled_available_skills",
|
||||
lambda available_skills, *, app_config, user_id=None: [_make_skill("skill-reviewer", ["review_skill_package"])],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deerflow.tools.get_available_tools",
|
||||
lambda **kwargs: [NamedTool("bash"), NamedTool("browser_navigate"), NamedTool("review_skill_package")],
|
||||
)
|
||||
|
||||
mock_app_config = MagicMock()
|
||||
mock_app_config.get_model_config.return_value = SimpleNamespace(supports_thinking=False, supports_vision=False)
|
||||
mock_app_config.tool_search.enabled = True
|
||||
mock_app_config.skills.container_path = "/mnt/skills"
|
||||
mock_app_config.skills.deferred_discovery = True
|
||||
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: mock_app_config)
|
||||
|
||||
agent_kwargs = lead_agent_module.make_lead_agent({"configurable": {}})
|
||||
|
||||
tool_names = [tool.name for tool in agent_kwargs["tools"]]
|
||||
assert "browser_navigate" in tool_names
|
||||
assert "bash" in tool_names
|
||||
assert "describe_skill" in tool_names
|
||||
|
||||
|
||||
def test_make_lead_agent_fails_closed_when_skill_policy_load_fails(monkeypatch):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
@ -19,13 +19,15 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.gateway.deps import _enforce_postgres_for_multi_worker, langgraph_runtime
|
||||
from app.gateway.routers.browser import _browser_tools_enabled
|
||||
from deerflow.config.database_config import DatabaseConfig
|
||||
from deerflow.config.run_ownership_config import RunOwnershipConfig
|
||||
|
||||
|
||||
def _config_with_backend(backend: str, *, heartbeat_enabled: bool | None = None) -> SimpleNamespace:
|
||||
def _config_with_backend(backend: str, *, heartbeat_enabled: bool | None = None, browser_enabled: bool = False) -> SimpleNamespace:
|
||||
run_ownership = RunOwnershipConfig(heartbeat_enabled=heartbeat_enabled) if heartbeat_enabled is not None else None
|
||||
return SimpleNamespace(database=DatabaseConfig(backend=backend), run_ownership=run_ownership)
|
||||
tools = [SimpleNamespace(name="browser_navigate")] if browser_enabled else []
|
||||
return SimpleNamespace(database=DatabaseConfig(backend=backend), run_ownership=run_ownership, tools=tools)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -52,6 +54,25 @@ def test_gate_allows_multi_worker_with_postgres_and_heartbeat(monkeypatch):
|
||||
_enforce_postgres_for_multi_worker(_config_with_backend("postgres", heartbeat_enabled=True))
|
||||
|
||||
|
||||
def test_gate_rejects_process_local_browser_with_multi_worker(monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_WORKERS", "2")
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_enforce_postgres_for_multi_worker(
|
||||
_config_with_backend("postgres", heartbeat_enabled=True, browser_enabled=True),
|
||||
)
|
||||
msg = str(exc_info.value)
|
||||
assert "process-local" in msg
|
||||
assert "GATEWAY_WORKERS=1" in msg
|
||||
|
||||
|
||||
def test_runtime_browser_surface_stays_disabled_after_incompatible_hot_reload(monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_WORKERS", "2")
|
||||
live_config = SimpleNamespace(tools=[SimpleNamespace(name="browser_navigate", model_extra={})])
|
||||
|
||||
with patch("deerflow.config.get_app_config", return_value=live_config):
|
||||
assert _browser_tools_enabled() is False
|
||||
|
||||
|
||||
def test_gate_rejects_multi_worker_with_sqlite(monkeypatch):
|
||||
monkeypatch.setenv("GATEWAY_WORKERS", "2")
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from _router_auth_helpers import make_authed_test_app
|
||||
@ -158,6 +158,29 @@ def test_delete_thread_route_cleans_thread_directory(tmp_path):
|
||||
assert not thread_dir.exists()
|
||||
|
||||
|
||||
def test_delete_thread_route_closes_browser_session(tmp_path):
|
||||
"""Deleting a thread tears down its live browser session so a later caller
|
||||
who reuses the id cannot inherit the retained page/cookies."""
|
||||
paths = Paths(tmp_path)
|
||||
|
||||
app = make_authed_test_app()
|
||||
app.include_router(threads.router)
|
||||
|
||||
manager = SimpleNamespace(close_session=AsyncMock(return_value=True))
|
||||
with (
|
||||
patch("app.gateway.routers.threads.get_paths", return_value=paths),
|
||||
patch(
|
||||
"deerflow.community.browser_automation.get_browser_session_manager",
|
||||
return_value=manager,
|
||||
),
|
||||
):
|
||||
with TestClient(app) as client:
|
||||
response = client.delete("/api/threads/thread-browser")
|
||||
|
||||
assert response.status_code == 200
|
||||
manager.close_session.assert_awaited_once_with("thread-browser")
|
||||
|
||||
|
||||
def test_delete_thread_route_rejects_invalid_thread_id(tmp_path):
|
||||
paths = Paths(tmp_path)
|
||||
|
||||
|
||||
@ -198,6 +198,20 @@ def test_scan_workspace_roots_skips_excluded_directories(tmp_path):
|
||||
assert "/mnt/user-data/workspace/node_modules/ignored.js" not in snapshot.files
|
||||
|
||||
|
||||
def test_scan_workspace_roots_skips_browser_frames(tmp_path):
|
||||
roots = _roots(tmp_path)
|
||||
outputs = roots[1].host_path
|
||||
(outputs / "report.md").write_text("keep", encoding="utf-8")
|
||||
frames = outputs / ".browser-frames"
|
||||
frames.mkdir()
|
||||
(frames / "browser-navigate-1.png").write_bytes(b"\x89PNG\r\n\x1a\nshot")
|
||||
|
||||
snapshot = scan_workspace_roots(roots)
|
||||
|
||||
assert "/mnt/user-data/outputs/report.md" in snapshot.files
|
||||
assert "/mnt/user-data/outputs/.browser-frames/browser-navigate-1.png" not in snapshot.files
|
||||
|
||||
|
||||
def test_scan_workspace_roots_can_skip_text_loading(tmp_path):
|
||||
roots = _roots(tmp_path)
|
||||
workspace = roots[0].host_path
|
||||
|
||||
31
backend/uv.lock
generated
31
backend/uv.lock
generated
@ -796,6 +796,9 @@ dependencies = [
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
browser = [
|
||||
{ name = "deerflow-harness", extra = ["browser"] },
|
||||
]
|
||||
discord = [
|
||||
{ name = "discord-py" },
|
||||
]
|
||||
@ -826,6 +829,7 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "bcrypt", specifier = ">=4.0.0" },
|
||||
{ name = "deerflow-harness", editable = "packages/harness" },
|
||||
{ name = "deerflow-harness", extras = ["browser"], marker = "extra == 'browser'", editable = "packages/harness" },
|
||||
{ name = "deerflow-harness", extras = ["monocle"], marker = "extra == 'monocle'", editable = "packages/harness" },
|
||||
{ name = "deerflow-harness", extras = ["postgres"], marker = "extra == 'postgres'", editable = "packages/harness" },
|
||||
{ name = "deerflow-harness", extras = ["redis"], marker = "extra == 'redis'", editable = "packages/harness" },
|
||||
@ -846,7 +850,7 @@ requires-dist = [
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
|
||||
{ name = "wecom-aibot-python-sdk", specifier = ">=0.1.6" },
|
||||
]
|
||||
provides-extras = ["postgres", "redis", "discord", "monocle"]
|
||||
provides-extras = ["postgres", "redis", "discord", "monocle", "browser"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
@ -907,6 +911,9 @@ dependencies = [
|
||||
boxlite = [
|
||||
{ name = "boxlite" },
|
||||
]
|
||||
browser = [
|
||||
{ name = "playwright" },
|
||||
]
|
||||
monocle = [
|
||||
{ name = "monocle-apptrace" },
|
||||
]
|
||||
@ -965,6 +972,7 @@ requires-dist = [
|
||||
{ name = "markdownify", specifier = ">=1.2.2" },
|
||||
{ name = "markitdown", extras = ["all", "xlsx"], specifier = ">=0.0.1a2" },
|
||||
{ name = "monocle-apptrace", marker = "extra == 'monocle'", specifier = ">=0.8.8" },
|
||||
{ name = "playwright", marker = "extra == 'browser'", specifier = ">=1.40" },
|
||||
{ name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.3.3" },
|
||||
{ name = "psycopg-pool", marker = "extra == 'postgres'", specifier = ">=3.3.0" },
|
||||
{ name = "pydantic", specifier = ">=2.12.5" },
|
||||
@ -977,7 +985,7 @@ requires-dist = [
|
||||
{ name = "textual", marker = "extra == 'tui'", specifier = ">=0.80" },
|
||||
{ name = "tiktoken", specifier = ">=0.8.0" },
|
||||
]
|
||||
provides-extras = ["tui", "groundroute", "ollama", "postgres", "redis", "pymupdf", "boxlite", "monocle"]
|
||||
provides-extras = ["tui", "groundroute", "ollama", "postgres", "redis", "pymupdf", "boxlite", "monocle", "browser"]
|
||||
|
||||
[[package]]
|
||||
name = "defusedxml"
|
||||
@ -3220,6 +3228,25 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.61.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet" },
|
||||
{ name = "pyee" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/44/ee/31e4e0db36588b817a10b299a0285082545fde7d36543c2abe498bb3d61a/playwright-1.61.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:ff138c3a604f69911e9d42fd036e55c2a171e5616edf04c1e7f60a2a285540b0", size = 43421877, upload-time = "2026-06-29T10:32:48.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/35/71395dd3ecc798965be4a3ef8c443217d4abca168e7cb34536304f9489e6/playwright-1.61.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009588c2a7e499bc5a8b425b61fa65490968bbda9cd69e0cf2cff10f8304659a", size = 42205016, upload-time = "2026-06-29T10:32:52.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/44/323164cf5cd1647bdefce76ffce27651aadb959d089b48f53ea40918276e/playwright-1.61.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:9f7de4536088d12037c13a52b7ea34b59270b78926bb56935070597ffac6b1af", size = 43421884, upload-time = "2026-06-29T10:32:55.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/f8/a35bf179e4ba2522c1893635094a64e407572547bd61528820fc0abc87fe/playwright-1.61.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:54f3b39f6eab832e33458c1dd7da0b5682aedab3b09ae731b5c59fa12fd2024e", size = 47421381, upload-time = "2026-06-29T10:32:59.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/eb/e3f922348ec17c315f98c463f72faa1181a1c3de0bfe31a8d2edf6561723/playwright-1.61.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93454322ade8c11d5d6c211bfd91bdfb9ffb4810e3e026371bcbc4bec1b7ee4c", size = 47120545, upload-time = "2026-06-29T10:33:03.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/a6/5be4e52b40a9c0c8a073e7c5b0785c05cf5a9ea8f8a7b5b260e32d970342/playwright-1.61.0-py3-none-win32.whl", hash = "sha256:372d55a6f1248fa1dd47599686980cb8fb5bbe6fcda59eab793eb657c11d8a9b", size = 37844841, upload-time = "2026-06-29T10:33:07.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/fd/2b78036e5fbe9d5f5645bbe08a1eac7160c51243c0093963edbcf67c35d9/playwright-1.61.0-py3-none-win_amd64.whl", hash = "sha256:35c6cc4589a5d00964a59d7b3e59641e0aac0c02f15479a7af77d20f6bc79597", size = 37844846, upload-time = "2026-06-29T10:33:10.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/0d/1b0f3c4ee4eb0514bc805b5c2f9a223e5b6de4f11a926f5235d51d0fc81b/playwright-1.61.0-py3-none-win_arm64.whl", hash = "sha256:e9fcbffcf557a8620fdedd92491eb59a32d18e23d6f3b4f6214b952be324fe51", size = 33955127, upload-time = "2026-06-29T10:33:14.008Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
|
||||
@ -648,6 +648,7 @@ tool_groups:
|
||||
- name: file:read
|
||||
- name: file:write
|
||||
- name: bash
|
||||
- name: browser
|
||||
|
||||
# ============================================================================
|
||||
# Tools Configuration
|
||||
@ -801,6 +802,65 @@ tools:
|
||||
# # allow_private_addresses: false # SSRF guard: keep false in production. Set true ONLY to
|
||||
# # # capture internal/private targets (loopback, RFC1918, etc.)
|
||||
|
||||
# Agentic browser control (stateful navigate → observe → click/type loop).
|
||||
# Unlike web_fetch/web_capture (read-only, stateless), these tools keep a live
|
||||
# per-thread Playwright browser so the agent can interact with JavaScript-heavy
|
||||
# pages, fill forms, and follow multi-step flows. Elements are addressed by the
|
||||
# numeric [ref] index returned in each page snapshot.
|
||||
#
|
||||
# Requires the optional dependency, installed once per environment:
|
||||
# cd backend && uv sync --extra browser && uv run playwright install chromium
|
||||
# make dev / Docker startup auto-detect an active browser_navigate entry and
|
||||
# preserve the browser extra during dependency sync.
|
||||
#
|
||||
# Uncomment the whole block to enable. Launch and URL-policy settings are read
|
||||
# canonically from browser_navigate and shared by every browser tool + Live.
|
||||
# - name: browser_navigate
|
||||
# group: browser
|
||||
# use: deerflow.community.browser_automation.tools:browser_navigate_tool
|
||||
# headless: true # Set false only for local, trusted debugging
|
||||
# timeout_ms: 30000 # Per-action navigation/interaction timeout
|
||||
# viewport_width: 1280
|
||||
# viewport_height: 720
|
||||
# # allow_private_addresses: false # SSRF guard: keep false in production
|
||||
# # cdp_url: http://127.0.0.1:9222 # Attach to YOUR running Chrome (Codex-style)
|
||||
# # allow_unguarded_cdp: false # REQUIRED true with cdp_url: attaching to an
|
||||
# # # existing Chrome prevents DeerFlow from
|
||||
# # # enforcing its subresource/redirect SSRF guard.
|
||||
# # # instead of launching a private headless one.
|
||||
# # # Start Chrome with --remote-debugging-port=9222
|
||||
# # # so you watch the agent drive your real browser
|
||||
# # # with your real login sessions. Local/trusted only;
|
||||
# # # never enable for an untrusted CDP endpoint.
|
||||
# # # Chrome must be version-matched to the bundled
|
||||
# # # Playwright; a much newer Chrome can reject the
|
||||
# # # CDP handshake. If so, use headless: false to launch
|
||||
# # # Playwright's own visible browser instead.
|
||||
# # Multi-worker note: browser sessions live in one Gateway worker's memory.
|
||||
# # Keep GATEWAY_WORKERS=1 while this tool group is enabled.
|
||||
# - name: browser_snapshot
|
||||
# group: browser
|
||||
# use: deerflow.community.browser_automation.tools:browser_snapshot_tool
|
||||
# - name: browser_click
|
||||
# group: browser
|
||||
# use: deerflow.community.browser_automation.tools:browser_click_tool
|
||||
# - name: browser_type
|
||||
# group: browser
|
||||
# use: deerflow.community.browser_automation.tools:browser_type_tool
|
||||
# - name: browser_get_text
|
||||
# group: browser
|
||||
# use: deerflow.community.browser_automation.tools:browser_get_text_tool
|
||||
# max_chars: 8000 # Truncation cap for browser_get_text output
|
||||
# - name: browser_back
|
||||
# group: browser
|
||||
# use: deerflow.community.browser_automation.tools:browser_back_tool
|
||||
# - name: browser_screenshot
|
||||
# group: browser
|
||||
# use: deerflow.community.browser_automation.tools:browser_screenshot_tool
|
||||
# - name: browser_close
|
||||
# group: browser
|
||||
# use: deerflow.community.browser_automation.tools:browser_close_tool
|
||||
|
||||
# Web fetch tool (uses Exa)
|
||||
# NOTE: Only one web_fetch provider can be active at a time.
|
||||
# Comment out the Jina AI web_fetch entry below before enabling this one.
|
||||
|
||||
@ -168,6 +168,26 @@ http {
|
||||
# Disable response buffering to avoid permission errors
|
||||
}
|
||||
|
||||
# Live browser stream is a WebSocket upgrade. It must be matched before
|
||||
# the generic /api/threads regex below (which omits Upgrade/Connection
|
||||
# forwarding and would downgrade it to plain HTTP).
|
||||
location ~ ^/api/threads/[^/]+/browser/stream {
|
||||
proxy_pass http://$gateway_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Long-lived Live stream: keep the upgraded connection open.
|
||||
proxy_connect_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
# Custom API: Other endpoints under /api/threads
|
||||
location ~ ^/api/threads {
|
||||
proxy_pass http://$gateway_upstream;
|
||||
|
||||
@ -171,6 +171,28 @@ http {
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
# Live browser stream is a WebSocket upgrade. It must be matched before
|
||||
# the generic /api/threads regex below (which omits Upgrade/Connection
|
||||
# forwarding and would downgrade it to plain HTTP).
|
||||
location ~ ^/api/threads/[^/]+/browser/stream {
|
||||
proxy_pass http://gateway;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Long-lived Live stream: keep the upgraded connection open.
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_connect_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
# Custom API: Other endpoints under /api/threads
|
||||
location ~ ^/api/threads {
|
||||
proxy_pass http://gateway;
|
||||
|
||||
@ -529,5 +529,3 @@ A search of `bytedance/deer-flow` confirms **no RBAC implementation exists**; RB
|
||||
- **[#3929](https://github.com/bytedance/deer-flow/issues/3929)** (open) - sandbox NodePort->ClusterIP (same author family of security hardening RFCs).
|
||||
|
||||
**Net takeaway:** the upstream has spent real effort plumbing identity into the guardrail execution point (#3665, #3839) and has explicitly deferred the RBAC policy brain. The two-layer design in this RFC is the natural next step the prior work points at - not a competing or redundant proposal.
|
||||
|
||||
|
||||
|
||||
@ -93,8 +93,10 @@ Tool-calling AI messages can contain user-visible text as well as `tool_calls`.
|
||||
|
||||
- `src/app/workspace/chats/[thread_id]/page.tsx` owns composer busy-state wiring.
|
||||
- `src/app/workspace/chats/[thread_id]/page.tsx` owns branch-from-turn submission and navigation; sidecar `MessageList` instances do not receive the branch action.
|
||||
- `src/app/workspace/chats/[thread_id]/page.tsx` gates the Workspace Browser trigger and browser right panel on `/api/features -> browser_control.enabled`; default/failed feature discovery hides the browser control so optional backend installs do not show a dead Live socket.
|
||||
- `src/app/workspace/chats/[thread_id]/page.tsx` and `src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx` own active-goal display state for their composer overlays.
|
||||
- `src/components/workspace/messages/message-list.tsx` owns human-input card answered/latest/pending gating; entry pages only translate a submitted card response into `sendMessage` calls.
|
||||
- `src/components/workspace/browser-view/browser-view-panel.tsx` forwards each physical pointer click as one `click` input; do not also emit `down`/`up` for the same gesture because the remote Playwright click would run twice.
|
||||
- `src/core/threads/hooks.ts` owns pre-submit upload state and thread submission.
|
||||
|
||||
## Code Style
|
||||
|
||||
@ -7,6 +7,7 @@ import { toast } from "sonner";
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { BrowserTrigger } from "@/components/workspace/browser-view";
|
||||
import {
|
||||
ChatBox,
|
||||
useSpecificChatMode,
|
||||
@ -33,6 +34,7 @@ import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { useActiveGoal } from "@/components/workspace/use-active-goal";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { useBrowserControlEnabled } from "@/core/features";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
buildHumanInputResponseText,
|
||||
@ -68,6 +70,7 @@ export default function ChatPage() {
|
||||
const [isWelcomeMode, setIsWelcomeMode] = useState(isNewThread);
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const [localSettings, setLocalSettings] = useLocalSettings();
|
||||
const { enabled: browserControlEnabled } = useBrowserControlEnabled();
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
const threadTokenUsage = useThreadTokenUsage(
|
||||
isNewThread || isMock ? undefined : threadId,
|
||||
@ -240,6 +243,7 @@ export default function ChatPage() {
|
||||
? localSettings.tokenUsage.inlineMode
|
||||
: "off";
|
||||
const hasTodos = (thread.values.todos?.length ?? 0) > 0;
|
||||
const browserEnabled = !isNewThread && browserControlEnabled;
|
||||
const { activeGoal, hasGoal, setLocalGoal } = useActiveGoal(
|
||||
threadId,
|
||||
thread.values.goal,
|
||||
@ -260,7 +264,7 @@ export default function ChatPage() {
|
||||
context={settings.context}
|
||||
isMock={isMock}
|
||||
>
|
||||
<ChatBox threadId={threadId}>
|
||||
<ChatBox threadId={threadId} browserEnabled={browserEnabled}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
@ -290,6 +294,7 @@ export default function ChatPage() {
|
||||
}
|
||||
/>
|
||||
<SidecarTrigger />
|
||||
{browserEnabled && <BrowserTrigger />}
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
|
||||
@ -2,13 +2,16 @@
|
||||
|
||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { BrowserViewProvider } from "@/components/workspace/browser-view";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
|
||||
export function ChatProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<ArtifactsProvider>
|
||||
<PromptInputProvider>{children}</PromptInputProvider>
|
||||
<BrowserViewProvider>
|
||||
<PromptInputProvider>{children}</PromptInputProvider>
|
||||
</BrowserViewProvider>
|
||||
</ArtifactsProvider>
|
||||
</SubtasksProvider>
|
||||
);
|
||||
|
||||
55
frontend/src/components/workspace/browser-view/api.ts
Normal file
55
frontend/src/components/workspace/browser-view/api.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { throwGatewayApiError } from "@/core/api/errors";
|
||||
import { fetch } from "@/core/api/fetcher";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface BrowserNavigateResult {
|
||||
screenshot: string | null;
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export async function navigateBrowser(
|
||||
threadId: string,
|
||||
url: string,
|
||||
): Promise<BrowserNavigateResult> {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/browser/navigate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
await throwGatewayApiError(
|
||||
response,
|
||||
`Failed to navigate browser: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the WebSocket URL for the live browser stream.
|
||||
*
|
||||
* Uses the configured backend base URL when present (split-origin dev/prod),
|
||||
* otherwise falls back to the current same-origin host (nginx proxies the
|
||||
* upgrade in the unified deployment).
|
||||
*
|
||||
* When ``seedUrl`` is provided, the server aligns the live page to it when the
|
||||
* current page is blank or points at a different URL — so reconnecting to a
|
||||
* stale session lands on the page the user expects instead of a white
|
||||
* about:blank screen or a leftover page. The seed is SSRF-screened server-side.
|
||||
*/
|
||||
export function browserStreamURL(threadId: string, seedUrl?: string): string {
|
||||
const base = getBackendBaseURL();
|
||||
const origin =
|
||||
base && base.length > 0
|
||||
? base
|
||||
: typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "";
|
||||
const wsOrigin = origin.replace(/^http/i, "ws");
|
||||
const query = seedUrl ? `?seed=${encodeURIComponent(seedUrl)}` : "";
|
||||
return `${wsOrigin}/api/threads/${encodeURIComponent(threadId)}/browser/stream${query}`;
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import { MonitorIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import { useMaybeSidecar } from "../sidecar/context";
|
||||
|
||||
import { useMaybeBrowserView } from "./context";
|
||||
|
||||
export const BrowserTrigger = () => {
|
||||
const { t } = useI18n();
|
||||
const browserView = useMaybeBrowserView();
|
||||
const sidecar = useMaybeSidecar();
|
||||
|
||||
if (!browserView) {
|
||||
return null;
|
||||
}
|
||||
const browserVisible = browserView.open && !sidecar?.open;
|
||||
const label = browserVisible ? t.common.close : t.common.showBrowser;
|
||||
|
||||
return (
|
||||
<Tooltip content={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
data-testid="browser-trigger"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant={browserVisible ? "secondary" : "ghost"}
|
||||
onClick={() => {
|
||||
if (browserVisible) {
|
||||
browserView.close();
|
||||
return;
|
||||
}
|
||||
sidecar?.close();
|
||||
browserView.openPanel();
|
||||
}}
|
||||
>
|
||||
<MonitorIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,462 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
GlobeIcon,
|
||||
Loader2Icon,
|
||||
MonitorIcon,
|
||||
RadioIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { navigateBrowser } from "./api";
|
||||
import { useMaybeBrowserView } from "./context";
|
||||
import { decideBrowserKeyInput } from "./keyboard";
|
||||
import { type BrowserInputEvent, useBrowserStream } from "./use-browser-stream";
|
||||
|
||||
export function BrowserViewPanel({
|
||||
threadId,
|
||||
className,
|
||||
}: {
|
||||
threadId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const browserView = useMaybeBrowserView();
|
||||
const frame = browserView?.latestFrame ?? null;
|
||||
const imageUrl = frame
|
||||
? resolveArtifactURL(frame.screenshot, threadId)
|
||||
: null;
|
||||
|
||||
const [urlInput, setUrlInput] = useState("");
|
||||
const [navigating, setNavigating] = useState(false);
|
||||
const [live, setLive] = useState(true);
|
||||
const [lastLiveUrl, setLastLiveUrl] = useState<string | null>(null);
|
||||
const [liveFallback, setLiveFallback] = useState<{
|
||||
frameUrl: string;
|
||||
url?: string;
|
||||
} | null>(null);
|
||||
|
||||
const streamSeedUrl = lastLiveUrl ?? liveFallback?.url ?? frame?.url;
|
||||
const handleNavRejected = useCallback(
|
||||
(url: string | undefined, message: string | undefined) => {
|
||||
setNavigating(false);
|
||||
toast.error(
|
||||
message?.replace(/^Error:\s*/i, "") ??
|
||||
`Cannot open ${url ?? "that URL"}`,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const { status, frameUrl, liveUrl, sendInput } = useBrowserStream(
|
||||
threadId,
|
||||
live,
|
||||
streamSeedUrl,
|
||||
handleNavRejected,
|
||||
);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const surfaceRef = useRef<HTMLImageElement | null>(null);
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const staticScreenshotRef = useRef<string | null>(null);
|
||||
// True while the user is actively editing the URL bar, so background live-URL
|
||||
// reports do not clobber a half-typed address the user has not submitted yet.
|
||||
const urlEditingRef = useRef(false);
|
||||
// Handle for the live-navigate spinner timeout so it can be cleared on
|
||||
// unmount / re-navigation instead of firing setState after teardown.
|
||||
const navSpinnerTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (navSpinnerTimerRef.current !== null) {
|
||||
window.clearTimeout(navSpinnerTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLastLiveUrl(null);
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (frame?.url && !urlInput && !liveUrl) {
|
||||
setUrlInput(frame.url);
|
||||
}
|
||||
}, [frame?.url, liveUrl, urlInput]);
|
||||
|
||||
// Keep the URL bar in sync with the latest visible browser state while not
|
||||
// live. If the user leaves Live mode, the last streamed frame becomes the
|
||||
// static baseline instead of falling back to an older artifact screenshot.
|
||||
useEffect(() => {
|
||||
if (!live) {
|
||||
const url = liveFallback?.url ?? frame?.url;
|
||||
if (url) {
|
||||
setUrlInput(url);
|
||||
}
|
||||
}
|
||||
}, [frame?.url, live, liveFallback?.url]);
|
||||
|
||||
useEffect(() => {
|
||||
const screenshot = frame?.screenshot ?? null;
|
||||
if (screenshot && screenshot !== staticScreenshotRef.current) {
|
||||
staticScreenshotRef.current = screenshot;
|
||||
setLiveFallback(null);
|
||||
}
|
||||
}, [frame?.screenshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (frameUrl) {
|
||||
setLiveFallback((prev) => ({
|
||||
frameUrl,
|
||||
url: liveUrl ?? prev?.url ?? (urlInput || frame?.url),
|
||||
}));
|
||||
}
|
||||
}, [frame?.url, frameUrl, liveUrl, urlInput]);
|
||||
|
||||
// In live mode the server reports the page's real URL (after redirects and
|
||||
// history moves). Reconcile the address bar + device-shell label + persisted
|
||||
// frame so the top URL never goes stale after navigation.
|
||||
useEffect(() => {
|
||||
if (live && liveUrl) {
|
||||
setLastLiveUrl(liveUrl);
|
||||
if (!urlEditingRef.current) {
|
||||
setUrlInput(liveUrl === "about:blank" ? "" : liveUrl);
|
||||
}
|
||||
setNavigating(false);
|
||||
setLiveFallback((prev) => (prev ? { ...prev, url: liveUrl } : prev));
|
||||
}
|
||||
}, [liveUrl, live]);
|
||||
|
||||
const handleNavigate = async () => {
|
||||
const target = urlInput.trim();
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const normalized = /^https?:\/\//i.test(target)
|
||||
? target
|
||||
: `https://${target}`;
|
||||
|
||||
// In live mode, steer the streamed page directly over the socket. The
|
||||
// screencast streams continuously, so there is no single "done" frame to
|
||||
// key off — show the reload spinner for a brief window so the user gets
|
||||
// clear feedback that the navigation actually took. Also sync the URL
|
||||
// everywhere immediately (URL bar, device-shell label, persisted frame).
|
||||
if (live) {
|
||||
sendInput({ type: "navigate", url: normalized });
|
||||
setUrlInput(normalized);
|
||||
urlEditingRef.current = false;
|
||||
setLiveFallback((prev) => (prev ? { ...prev, url: normalized } : prev));
|
||||
setNavigating(true);
|
||||
if (navSpinnerTimerRef.current !== null) {
|
||||
window.clearTimeout(navSpinnerTimerRef.current);
|
||||
}
|
||||
navSpinnerTimerRef.current = window.setTimeout(() => {
|
||||
navSpinnerTimerRef.current = null;
|
||||
setNavigating(false);
|
||||
}, 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigating) {
|
||||
return;
|
||||
}
|
||||
setNavigating(true);
|
||||
try {
|
||||
const result = await navigateBrowser(threadId, normalized);
|
||||
if (result.screenshot) {
|
||||
setLiveFallback(null);
|
||||
browserView?.pushFrame({
|
||||
screenshot: result.screenshot,
|
||||
url: result.url,
|
||||
title: result.title,
|
||||
});
|
||||
} else {
|
||||
setUrlInput(result.url);
|
||||
toast.warning("Navigated, but no screenshot could be captured.");
|
||||
}
|
||||
browserView?.openPanel();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setNavigating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedPoint = useCallback(
|
||||
(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
options?: { fallbackToCenter?: boolean },
|
||||
): { nx: number; ny: number } | null => {
|
||||
const el = surfaceRef.current;
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
const natW = el.naturalWidth;
|
||||
const natH = el.naturalHeight;
|
||||
if (!rect.width || !rect.height || !natW || !natH) {
|
||||
return null;
|
||||
}
|
||||
// The frame is drawn with object-contain: scaled to fit and centered, so
|
||||
// there are letterbox bars whenever the panel and remote viewport differ
|
||||
// in aspect. Map the pointer against the actual content box.
|
||||
const scale = Math.min(rect.width / natW, rect.height / natH);
|
||||
const contentW = natW * scale;
|
||||
const contentH = natH * scale;
|
||||
const offX = (rect.width - contentW) / 2;
|
||||
const offY = (rect.height - contentH) / 2;
|
||||
const px = clientX - rect.left - offX;
|
||||
const py = clientY - rect.top - offY;
|
||||
if (px < 0 || py < 0 || px > contentW || py > contentH) {
|
||||
if (options?.fallbackToCenter) {
|
||||
return { nx: 0.5, ny: 0.5 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return { nx: px / contentW, ny: py / contentH };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const forwardMouse = (
|
||||
type: Extract<BrowserInputEvent, { nx: number }>["type"],
|
||||
event: React.MouseEvent<HTMLImageElement>,
|
||||
) => {
|
||||
if (!live) {
|
||||
return;
|
||||
}
|
||||
const point = normalizedPoint(event.clientX, event.clientY);
|
||||
if (point) {
|
||||
sendInput({ type, ...point });
|
||||
}
|
||||
};
|
||||
|
||||
const liveActive = live && status === "open";
|
||||
const liveConnecting = live && status === "connecting";
|
||||
const displayUrl = live
|
||||
? (frameUrl ?? liveFallback?.frameUrl ?? imageUrl)
|
||||
: (liveFallback?.frameUrl ?? imageUrl);
|
||||
|
||||
// React registers onWheel as a passive listener, so preventDefault() there is
|
||||
// ignored and the wheel scrolls the host chat page. Bind a native, non-passive
|
||||
// listener on the always-mounted stage (the <img> may not exist yet when live
|
||||
// first opens) so scrolling stays captured inside the remote page.
|
||||
useEffect(() => {
|
||||
const el = stageRef.current;
|
||||
if (!el || !liveActive) {
|
||||
return;
|
||||
}
|
||||
let wheelFrame: number | null = null;
|
||||
let pendingDx = 0;
|
||||
let pendingDy = 0;
|
||||
let pendingPoint: { nx: number; ny: number } | null = null;
|
||||
|
||||
const browserWheelDelta = (pixels: number) => {
|
||||
return Math.abs(pixels) < 0.25 ? 0 : pixels * 2;
|
||||
};
|
||||
|
||||
const flushWheel = () => {
|
||||
wheelFrame = null;
|
||||
const dx = browserWheelDelta(pendingDx);
|
||||
const dy = browserWheelDelta(pendingDy);
|
||||
const point = pendingPoint;
|
||||
pendingDx = 0;
|
||||
pendingDy = 0;
|
||||
pendingPoint = null;
|
||||
if (dx || dy) {
|
||||
sendInput({ type: "wheel", dx, dy, ...(point ?? {}) });
|
||||
}
|
||||
};
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// Normalize deltaMode (line/page → pixels), then batch one animation
|
||||
// frame of deltas. A small 2x gain keeps touchpad gestures responsive
|
||||
// without returning to the jumpy large-step behavior.
|
||||
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 800 : 1;
|
||||
pendingDx += event.deltaX * unit;
|
||||
pendingDy += event.deltaY * unit;
|
||||
const point = normalizedPoint(event.clientX, event.clientY, {
|
||||
fallbackToCenter: true,
|
||||
});
|
||||
if (point) {
|
||||
pendingPoint = point;
|
||||
}
|
||||
wheelFrame ??= window.requestAnimationFrame(flushWheel);
|
||||
};
|
||||
el.addEventListener("wheel", onWheel, {
|
||||
capture: true,
|
||||
passive: false,
|
||||
});
|
||||
return () => {
|
||||
el.removeEventListener("wheel", onWheel, { capture: true });
|
||||
if (wheelFrame !== null) {
|
||||
window.cancelAnimationFrame(wheelFrame);
|
||||
}
|
||||
};
|
||||
}, [liveActive, normalizedPoint, sendInput]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={cn(
|
||||
"bg-background relative flex flex-col",
|
||||
"size-full",
|
||||
className,
|
||||
)}
|
||||
tabIndex={live ? 0 : undefined}
|
||||
onKeyDown={(event) => {
|
||||
const target = event.target;
|
||||
const editableTarget =
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
(target instanceof HTMLElement && target.isContentEditable);
|
||||
const input = decideBrowserKeyInput({
|
||||
live,
|
||||
editableTarget,
|
||||
composing: isIMEComposing(event),
|
||||
key: event.key,
|
||||
metaKey: event.metaKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
});
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
sendInput(input);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<header className="flex shrink-0 items-center gap-2 border-b px-3 py-2">
|
||||
<MonitorIcon className="size-4 shrink-0" />
|
||||
<span className="shrink-0 text-sm font-medium">Browser</span>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="shrink-0"
|
||||
disabled={!live}
|
||||
onClick={() => sendInput({ type: "back" })}
|
||||
title="Back"
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="shrink-0"
|
||||
disabled={!live}
|
||||
onClick={() => sendInput({ type: "forward" })}
|
||||
title="Forward"
|
||||
>
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<form
|
||||
className="relative flex min-w-0 flex-1 items-center"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleNavigate();
|
||||
}}
|
||||
>
|
||||
<GlobeIcon className="text-muted-foreground pointer-events-none absolute left-2 size-3.5" />
|
||||
<Input
|
||||
value={urlInput}
|
||||
onChange={(event) => {
|
||||
urlEditingRef.current = true;
|
||||
setUrlInput(event.target.value);
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
urlEditingRef.current = true;
|
||||
event.currentTarget.select();
|
||||
}}
|
||||
onBlur={() => {
|
||||
urlEditingRef.current = false;
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
// Keep browser select-all/copy/cut/paste working inside the URL
|
||||
// bar; stop the panel + global shortcut handlers from swallowing.
|
||||
event.stopPropagation();
|
||||
}}
|
||||
placeholder="Enter a URL and press Enter"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
{navigating && (
|
||||
<Loader2Icon className="text-muted-foreground absolute right-2 size-3.5 animate-spin" />
|
||||
)}
|
||||
</form>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={live ? "default" : "ghost"}
|
||||
className="shrink-0 gap-1"
|
||||
onClick={() => setLive((prev) => !prev)}
|
||||
title={live ? "Stop live control" : "Take live control"}
|
||||
>
|
||||
<RadioIcon className="size-3.5" />
|
||||
{live ? (status === "open" ? "Live" : "…") : "Live"}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
setLive(false);
|
||||
browserView?.close();
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</header>
|
||||
<main className="relative flex min-h-0 grow flex-col overflow-hidden bg-neutral-950">
|
||||
<div
|
||||
ref={stageRef}
|
||||
className="relative min-h-0 grow bg-neutral-900"
|
||||
onMouseDown={() => {
|
||||
panelRef.current?.focus({ preventScroll: true });
|
||||
}}
|
||||
>
|
||||
{displayUrl ? (
|
||||
<img
|
||||
ref={surfaceRef}
|
||||
className="absolute inset-0 h-full w-full cursor-default object-contain object-center"
|
||||
src={displayUrl}
|
||||
alt={frame?.title ?? "Browser view"}
|
||||
draggable={false}
|
||||
onClick={(event) => forwardMouse("click", event)}
|
||||
/>
|
||||
) : (
|
||||
<ConversationEmptyState
|
||||
className="absolute inset-0 m-auto h-fit"
|
||||
icon={<MonitorIcon />}
|
||||
title={
|
||||
live ? "Connecting to live browser…" : "No browser activity yet"
|
||||
}
|
||||
description={
|
||||
live
|
||||
? "Waiting for the first live frame."
|
||||
: "Enter a URL above or let the agent browse — the live view will appear here."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(navigating || liveConnecting) && displayUrl && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/40 backdrop-blur-[1px]">
|
||||
<Loader2Icon className="text-muted-foreground size-8 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
frontend/src/components/workspace/browser-view/context.tsx
Normal file
76
frontend/src/components/workspace/browser-view/context.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
export interface BrowserViewFrame {
|
||||
screenshot: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
interface BrowserViewContextValue {
|
||||
open: boolean;
|
||||
latestFrame: BrowserViewFrame | null;
|
||||
pushFrame: (frame: BrowserViewFrame) => void;
|
||||
openPanel: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const BrowserViewContext = createContext<BrowserViewContextValue | null>(null);
|
||||
|
||||
export function BrowserViewProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [latestFrame, setLatestFrame] = useState<BrowserViewFrame | null>(null);
|
||||
|
||||
const pushFrame = useCallback((frame: BrowserViewFrame) => {
|
||||
setLatestFrame((prev) => {
|
||||
const sameFrame =
|
||||
prev?.screenshot === frame.screenshot &&
|
||||
prev?.url === frame.url &&
|
||||
prev?.title === frame.title &&
|
||||
prev?.action === frame.action;
|
||||
if (sameFrame) {
|
||||
return prev;
|
||||
}
|
||||
if (prev?.screenshot !== frame.screenshot) {
|
||||
// A new browser frame arrived — surface the panel automatically.
|
||||
setOpen(true);
|
||||
}
|
||||
return frame;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openPanel = useCallback(() => setOpen(true), []);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ open, latestFrame, pushFrame, openPanel, close }),
|
||||
[open, latestFrame, pushFrame, openPanel, close],
|
||||
);
|
||||
|
||||
return (
|
||||
<BrowserViewContext.Provider value={value}>
|
||||
{children}
|
||||
</BrowserViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMaybeBrowserView(): BrowserViewContextValue | null {
|
||||
return useContext(BrowserViewContext);
|
||||
}
|
||||
|
||||
export function useBrowserView(): BrowserViewContextValue {
|
||||
const ctx = useContext(BrowserViewContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useBrowserView must be used within a BrowserViewProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
19
frontend/src/components/workspace/browser-view/index.ts
Normal file
19
frontend/src/components/workspace/browser-view/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export { BrowserViewPanel } from "./browser-view-panel";
|
||||
export { BrowserTrigger } from "./browser-trigger";
|
||||
export {
|
||||
navigateBrowser,
|
||||
browserStreamURL,
|
||||
type BrowserNavigateResult,
|
||||
} from "./api";
|
||||
export {
|
||||
useBrowserStream,
|
||||
type BrowserTab,
|
||||
type BrowserInputEvent,
|
||||
type BrowserStreamStatus,
|
||||
} from "./use-browser-stream";
|
||||
export {
|
||||
BrowserViewProvider,
|
||||
useBrowserView,
|
||||
useMaybeBrowserView,
|
||||
type BrowserViewFrame,
|
||||
} from "./context";
|
||||
55
frontend/src/components/workspace/browser-view/keyboard.ts
Normal file
55
frontend/src/components/workspace/browser-view/keyboard.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import type { BrowserInputEvent } from "./use-browser-stream";
|
||||
|
||||
// Named keys we forward to the remote page as key presses (everything else that
|
||||
// is a single printable char is forwarded as text). Chosen to cover editing and
|
||||
// navigation without leaking browser-level shortcuts.
|
||||
export const FORWARDED_NAMED_KEYS = [
|
||||
"Enter",
|
||||
"Backspace",
|
||||
"Tab",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"Escape",
|
||||
"Delete",
|
||||
] as const;
|
||||
|
||||
export interface BrowserKeyContext {
|
||||
/** Panel is in Live (interactive) mode. */
|
||||
live: boolean;
|
||||
/** Focus is on an editable element (URL bar etc.) — keep it local. */
|
||||
editableTarget: boolean;
|
||||
/** An IME composition is active — the keystroke belongs to the composer. */
|
||||
composing: boolean;
|
||||
key: string;
|
||||
metaKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide how a keydown maps to a remote browser input, or ``null`` to ignore it.
|
||||
*
|
||||
* Pure so the forwarding policy (including the IME-composition guard that must
|
||||
* swallow a composing Enter) can be unit-tested without a DOM.
|
||||
*/
|
||||
export function decideBrowserKeyInput(
|
||||
ctx: BrowserKeyContext,
|
||||
): BrowserInputEvent | null {
|
||||
if (!ctx.live || ctx.editableTarget || ctx.composing) {
|
||||
return null;
|
||||
}
|
||||
if ((ctx.ctrlKey || ctx.metaKey) && ctx.key.length === 1) {
|
||||
return {
|
||||
type: "key",
|
||||
key: `${ctx.metaKey ? "Meta" : "Control"}+${ctx.key.toUpperCase()}`,
|
||||
};
|
||||
}
|
||||
if (ctx.key.length === 1 && !ctx.metaKey && !ctx.ctrlKey) {
|
||||
return { type: "text", text: ctx.key };
|
||||
}
|
||||
if ((FORWARDED_NAMED_KEYS as readonly string[]).includes(ctx.key)) {
|
||||
return { type: "key", key: ctx.key };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { browserStreamURL } from "./api";
|
||||
|
||||
export interface BrowserTab {
|
||||
index: number;
|
||||
title: string;
|
||||
url: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export type BrowserInputEvent =
|
||||
| { type: "click"; nx: number; ny: number }
|
||||
| { type: "move"; nx: number; ny: number }
|
||||
| { type: "down"; nx: number; ny: number }
|
||||
| { type: "up"; nx: number; ny: number }
|
||||
| { type: "wheel"; dx: number; dy: number; nx?: number; ny?: number }
|
||||
| { type: "key"; key: string }
|
||||
| { type: "text"; text: string }
|
||||
| { type: "navigate"; url: string }
|
||||
| { type: "back" }
|
||||
| { type: "forward" }
|
||||
| { type: "activate_tab"; index: number };
|
||||
|
||||
export type BrowserStreamStatus = "idle" | "connecting" | "open" | "closed";
|
||||
|
||||
const RECONNECT_BASE_DELAY_MS = 800;
|
||||
const RECONNECT_MAX_DELAY_MS = 10_000;
|
||||
const RECONNECT_MAX_ATTEMPTS = 6;
|
||||
|
||||
function normalizeSeedUrl(url: string | null | undefined): string {
|
||||
return (url ?? "").split("#", 1)[0]?.replace(/\/+$/, "") ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage a live browser screencast WebSocket.
|
||||
*
|
||||
* When ``enabled`` is true, opens the stream, exposes the latest JPEG frame as
|
||||
* a data URL, and returns a ``sendInput`` callback that forwards user input to
|
||||
* the live page. Closes and cleans up when disabled or unmounted.
|
||||
*
|
||||
* ``seedUrl`` is only read when a connection is first established (via a ref, so
|
||||
* it is NOT a reconnect trigger). A separate effect steers an already-open live
|
||||
* page toward a changed seed with an in-band ``navigate`` event, so ordinary
|
||||
* navigations no longer tear down and rebuild the socket.
|
||||
*/
|
||||
export function useBrowserStream(
|
||||
threadId: string,
|
||||
enabled: boolean,
|
||||
seedUrl?: string,
|
||||
onNavRejected?: (
|
||||
url: string | undefined,
|
||||
message: string | undefined,
|
||||
) => void,
|
||||
) {
|
||||
const [status, setStatus] = useState<BrowserStreamStatus>("idle");
|
||||
const [frameUrl, setFrameUrl] = useState<string | null>(null);
|
||||
const [liveUrl, setLiveUrl] = useState<string | null>(null);
|
||||
const [tabs, setTabs] = useState<BrowserTab[]>([]);
|
||||
const [connectionAttempt, setConnectionAttempt] = useState(0);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const pendingNavigateRef = useRef<Extract<
|
||||
BrowserInputEvent,
|
||||
{ type: "navigate" }
|
||||
> | null>(null);
|
||||
// Read the seed at connect time only; it must not be a reconnect dependency.
|
||||
const seedRef = useRef(seedUrl);
|
||||
seedRef.current = seedUrl;
|
||||
// Latest live page URL reported by the server, used to decide whether an
|
||||
// open stream already shows the seed target (avoids redundant navigations).
|
||||
const liveUrlRef = useRef<string | null>(null);
|
||||
const onNavRejectedRef = useRef(onNavRejected);
|
||||
onNavRejectedRef.current = onNavRejected;
|
||||
|
||||
const sendInput = useCallback((event: BrowserInputEvent) => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify(event));
|
||||
return true;
|
||||
}
|
||||
// URL bar submissions are user intent and must not be lost during the
|
||||
// short Live connection window right after opening the panel.
|
||||
if (event.type === "navigate") {
|
||||
pendingNavigateRef.current = event;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
pendingNavigateRef.current = null;
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
return;
|
||||
}
|
||||
setConnectionAttempt(0);
|
||||
setFrameUrl(null);
|
||||
setLiveUrl(null);
|
||||
setTabs([]);
|
||||
liveUrlRef.current = null;
|
||||
}, [enabled, threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setStatus("idle");
|
||||
liveUrlRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let closedByEffect = false;
|
||||
let reconnectTimer: number | null = null;
|
||||
setStatus("connecting");
|
||||
// browserStreamURL treats empty/undefined seed identically (no seed param),
|
||||
// so the raw ref value is fine here. Record the seed optimistically so the
|
||||
// "steer to seed" effect below does not fire a duplicate navigate right
|
||||
// after open (the server already aligns the page to the connect-time seed).
|
||||
liveUrlRef.current = seedRef.current ?? null;
|
||||
const socket = new WebSocket(browserStreamURL(threadId, seedRef.current));
|
||||
socketRef.current = socket;
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (closedByEffect || !enabled) {
|
||||
return;
|
||||
}
|
||||
if (reconnectTimer !== null) {
|
||||
return;
|
||||
}
|
||||
// Exponential backoff with a ceiling + attempt cap so a server that keeps
|
||||
// rejecting the upgrade cannot pin the client in a tight reconnect loop.
|
||||
if (connectionAttempt >= RECONNECT_MAX_ATTEMPTS) {
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(
|
||||
RECONNECT_BASE_DELAY_MS * 2 ** connectionAttempt,
|
||||
RECONNECT_MAX_DELAY_MS,
|
||||
);
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
setConnectionAttempt((attempt) => attempt + 1);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
socket.onopen = () => {
|
||||
const pendingNavigate = pendingNavigateRef.current;
|
||||
if (pendingNavigate) {
|
||||
socket.send(JSON.stringify(pendingNavigate));
|
||||
pendingNavigateRef.current = null;
|
||||
}
|
||||
// Reset the reconnect budget on a successful open. Without this the
|
||||
// cumulative attempt counter never returns to 0 while the panel stays
|
||||
// mounted, so after RECONNECT_MAX_ATTEMPTS total reconnects — even across
|
||||
// many healthy connections — scheduleReconnect would bail forever and
|
||||
// Live would go permanently dead until the panel is toggled off/on.
|
||||
setConnectionAttempt(0);
|
||||
setStatus("open");
|
||||
};
|
||||
socket.onmessage = async (message) => {
|
||||
try {
|
||||
const raw =
|
||||
typeof message.data === "string"
|
||||
? message.data
|
||||
: message.data instanceof Blob
|
||||
? await message.data.text()
|
||||
: message.data instanceof ArrayBuffer
|
||||
? new TextDecoder().decode(message.data)
|
||||
: String(message.data);
|
||||
// The message may resolve after cleanup (async Blob/ArrayBuffer decode);
|
||||
// do not write state for a socket the effect already tore down.
|
||||
if (closedByEffect) {
|
||||
return;
|
||||
}
|
||||
const payload = JSON.parse(raw) as {
|
||||
type?: string;
|
||||
data?: string;
|
||||
url?: string;
|
||||
message?: string;
|
||||
tabs?: BrowserTab[];
|
||||
};
|
||||
if (payload.type === "frame" && payload.data) {
|
||||
setFrameUrl(`data:image/jpeg;base64,${payload.data}`);
|
||||
} else if (payload.type === "url" && payload.url) {
|
||||
liveUrlRef.current = payload.url;
|
||||
setLiveUrl(payload.url);
|
||||
} else if (payload.type === "tabs" && Array.isArray(payload.tabs)) {
|
||||
setTabs(payload.tabs);
|
||||
} else if (payload.type === "nav_rejected") {
|
||||
onNavRejectedRef.current?.(payload.url, payload.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Ignoring malformed browser stream message", error);
|
||||
}
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (!closedByEffect) {
|
||||
setStatus("closed");
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {
|
||||
if (!closedByEffect) {
|
||||
setStatus("closed");
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
closedByEffect = true;
|
||||
if (reconnectTimer !== null) {
|
||||
window.clearTimeout(reconnectTimer);
|
||||
}
|
||||
socketRef.current = null;
|
||||
socket.close();
|
||||
};
|
||||
}, [connectionAttempt, enabled, threadId]);
|
||||
|
||||
// Steer an already-open stream toward a changed seed in-band instead of
|
||||
// rebuilding the socket. Only navigates when the live page differs from the
|
||||
// seed target, so redirects/history moves the server already reflects do not
|
||||
// cause a redundant navigation loop.
|
||||
useEffect(() => {
|
||||
if (!enabled || status !== "open") {
|
||||
return;
|
||||
}
|
||||
const target = seedUrl?.trim();
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
if (normalizeSeedUrl(target) === normalizeSeedUrl(liveUrlRef.current)) {
|
||||
return;
|
||||
}
|
||||
sendInput({ type: "navigate", url: target });
|
||||
}, [enabled, seedUrl, sendInput, status]);
|
||||
|
||||
return { status, frameUrl, liveUrl, tabs, sendInput };
|
||||
}
|
||||
@ -4,6 +4,11 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@ -20,17 +25,19 @@ import {
|
||||
ArtifactFileList,
|
||||
useArtifacts,
|
||||
} from "../artifacts";
|
||||
import { BrowserViewPanel, useMaybeBrowserView } from "../browser-view";
|
||||
import { useThread } from "../messages/context";
|
||||
import { SidecarPanel, useMaybeSidecar } from "../sidecar";
|
||||
|
||||
const RIGHT_PANEL_ANIMATION_MS = 280;
|
||||
|
||||
type RightPanelKind = "sidecar" | "artifacts";
|
||||
type RightPanelKind = "sidecar" | "artifacts" | "browser";
|
||||
|
||||
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
children,
|
||||
threadId,
|
||||
}) => {
|
||||
const ChatBox: React.FC<{
|
||||
children: React.ReactNode;
|
||||
threadId: string;
|
||||
browserEnabled?: boolean;
|
||||
}> = ({ children, threadId, browserEnabled = true }) => {
|
||||
const { thread } = useThread();
|
||||
const isMobile = useIsMobile();
|
||||
const pathname = usePathname();
|
||||
@ -47,6 +54,8 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
} = useArtifacts();
|
||||
const sidecar = useMaybeSidecar();
|
||||
const sidecarOpen = sidecar?.open ?? false;
|
||||
const browserView = useMaybeBrowserView();
|
||||
const browserViewOpen = browserEnabled && (browserView?.open ?? false);
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
useEffect(() => {
|
||||
@ -104,9 +113,11 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
|
||||
const activeRightPanel: RightPanelKind | null = sidecarOpen
|
||||
? "sidecar"
|
||||
: artifactPanelOpen
|
||||
? "artifacts"
|
||||
: null;
|
||||
: browserViewOpen
|
||||
? "browser"
|
||||
: artifactPanelOpen
|
||||
? "artifacts"
|
||||
: null;
|
||||
const rightPanelOpen = activeRightPanel !== null;
|
||||
const [renderedRightPanel, setRenderedRightPanel] =
|
||||
useState<RightPanelKind | null>(activeRightPanel);
|
||||
@ -136,7 +147,16 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
}
|
||||
}, [artifactsOpen, setArtifactsOpen, sidecarOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!browserEnabled && browserView?.open) {
|
||||
browserView.close();
|
||||
}
|
||||
}, [browserEnabled, browserView]);
|
||||
|
||||
const rightPanelContent = useMemo(() => {
|
||||
if (renderedRightPanel === "browser") {
|
||||
return <BrowserViewPanel threadId={threadId} className="size-full" />;
|
||||
}
|
||||
if (renderedRightPanel === "sidecar") {
|
||||
return <SidecarPanel />;
|
||||
}
|
||||
@ -208,6 +228,9 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
if (sidecarOpen) {
|
||||
sidecar?.close();
|
||||
}
|
||||
if (browserViewOpen) {
|
||||
browserView?.close();
|
||||
}
|
||||
if (artifactsOpen) {
|
||||
setArtifactsOpen(false);
|
||||
}
|
||||
@ -219,7 +242,11 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>
|
||||
{renderedRightPanel === "sidecar" ? "Sidecar" : "Artifacts"}
|
||||
{renderedRightPanel === "sidecar"
|
||||
? "Sidecar"
|
||||
: renderedRightPanel === "browser"
|
||||
? "Browser"
|
||||
: "Artifacts"}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Browse the side panel for this conversation.
|
||||
@ -236,41 +263,79 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
<div
|
||||
id={`${resizableIdBase}-panels`}
|
||||
className={cn(
|
||||
"[container-type:inline-size] grid size-full min-h-0 transition-[grid-template-columns] duration-[280ms] ease-out motion-reduce:transition-none",
|
||||
rightPanelOpen
|
||||
? "grid-cols-[minmax(0,1fr)_1px_minmax(0,40%)]"
|
||||
: "grid-cols-[minmax(0,1fr)_0px_0px]",
|
||||
"[container-type:inline-size] size-full min-h-0",
|
||||
activeRightPanel !== "browser" &&
|
||||
"grid transition-[grid-template-columns] duration-[280ms] ease-out motion-reduce:transition-none",
|
||||
activeRightPanel !== "browser" &&
|
||||
(rightPanelOpen
|
||||
? "grid-cols-[minmax(0,1fr)_1px_minmax(0,40%)]"
|
||||
: "grid-cols-[minmax(0,1fr)_0px_0px]"),
|
||||
)}
|
||||
>
|
||||
<div className="relative min-h-0 min-w-0" id="chat">
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
id={`${resizableIdBase}-separator`}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-border opacity-33 transition-opacity duration-200 ease-out motion-reduce:transition-none",
|
||||
!rightPanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
<aside
|
||||
aria-hidden={!rightPanelOpen}
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden transition-opacity duration-[280ms] ease-out motion-reduce:transition-none",
|
||||
!rightPanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
id="artifacts"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto h-full w-[40cqw] transition-opacity duration-[280ms] ease-out motion-reduce:transition-none",
|
||||
renderedRightPanel === "sidecar" ? "p-0" : "p-4",
|
||||
rightPanelOpen ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
{activeRightPanel === "browser" ? (
|
||||
<ResizablePanelGroup
|
||||
id={`${resizableIdBase}-group`}
|
||||
orientation="horizontal"
|
||||
className="size-full min-h-0"
|
||||
>
|
||||
{rightPanelContent}
|
||||
</div>
|
||||
</aside>
|
||||
<ResizablePanel
|
||||
id={`${resizableIdBase}-chat`}
|
||||
minSize="30%"
|
||||
className="relative min-h-0 min-w-0"
|
||||
>
|
||||
<div className="relative size-full min-h-0 min-w-0" id="chat">
|
||||
{children}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel
|
||||
id={`${resizableIdBase}-side`}
|
||||
defaultSize="40%"
|
||||
minSize="20%"
|
||||
maxSize="75%"
|
||||
className="min-h-0 min-w-0"
|
||||
>
|
||||
<aside
|
||||
className="size-full min-h-0 min-w-0 overflow-hidden p-0"
|
||||
id="artifacts"
|
||||
>
|
||||
{rightPanelContent}
|
||||
</aside>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative min-h-0 min-w-0" id="chat">
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
id={`${resizableIdBase}-separator`}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-border opacity-33 transition-opacity duration-200 ease-out motion-reduce:transition-none",
|
||||
!rightPanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
<aside
|
||||
aria-hidden={!rightPanelOpen}
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-hidden transition-opacity duration-[280ms] ease-out motion-reduce:transition-none",
|
||||
!rightPanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
id="artifacts"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto h-full w-[40cqw] transition-opacity duration-[280ms] ease-out motion-reduce:transition-none",
|
||||
renderedRightPanel === "sidecar" ? "p-0" : "p-4",
|
||||
rightPanelOpen ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{rightPanelContent}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -6,7 +6,11 @@ import {
|
||||
isValidElement,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { type ClipboardSafeStreamdownProps } from "@/components/ai-elements/streamdown";
|
||||
@ -33,8 +37,125 @@ type StreamingCodeProps = ComponentProps<"code"> & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const SMOOTH_REVEAL_MIN_DELTA = 80;
|
||||
const SMOOTH_REVEAL_MIN_CHARS_PER_FRAME = 8;
|
||||
const SMOOTH_REVEAL_DURATION_MS = 300;
|
||||
|
||||
const StreamingCodeBlockContext = createContext(false);
|
||||
|
||||
function useSmoothStreamingContent(content: string, isLoading: boolean) {
|
||||
const initialContent =
|
||||
isLoading && content.length >= SMOOTH_REVEAL_MIN_DELTA ? "" : content;
|
||||
const [displayContent, setDisplayContent] = useState(initialContent);
|
||||
const displayContentRef = useRef(initialContent);
|
||||
const targetContentRef = useRef(content);
|
||||
const sawLoadingRef = useRef(isLoading);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
sawLoadingRef.current = true;
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
targetContentRef.current = content;
|
||||
|
||||
const current = displayContentRef.current;
|
||||
const delta = content.length - current.length;
|
||||
const shouldSmoothReveal =
|
||||
delta >= SMOOTH_REVEAL_MIN_DELTA &&
|
||||
content.startsWith(current) &&
|
||||
(isLoading || sawLoadingRef.current);
|
||||
|
||||
if (!shouldSmoothReveal) {
|
||||
if (current !== content) {
|
||||
displayContentRef.current = content;
|
||||
setDisplayContent(content);
|
||||
}
|
||||
if (!isLoading) {
|
||||
sawLoadingRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let frame: number | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let generation = 0;
|
||||
const startedAt = performance.now();
|
||||
const startLength = current.length;
|
||||
|
||||
const tick = (now: number, scheduledGeneration: number) => {
|
||||
if (cancelled || scheduledGeneration !== generation) {
|
||||
return;
|
||||
}
|
||||
generation += 1;
|
||||
if (frame !== null) {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = null;
|
||||
}
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
const target = targetContentRef.current;
|
||||
const latest = displayContentRef.current;
|
||||
if (!target.startsWith(latest) || latest.length >= target.length) {
|
||||
if (!isLoading) {
|
||||
sawLoadingRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = Math.min(
|
||||
1,
|
||||
(now - startedAt) / SMOOTH_REVEAL_DURATION_MS,
|
||||
);
|
||||
const elapsedLength = startLength + Math.ceil(delta * progress);
|
||||
const nextLength = Math.max(
|
||||
latest.length + SMOOTH_REVEAL_MIN_CHARS_PER_FRAME,
|
||||
elapsedLength,
|
||||
);
|
||||
const next = target.slice(0, nextLength);
|
||||
displayContentRef.current = next;
|
||||
setDisplayContent(next);
|
||||
|
||||
if (next.length < target.length) {
|
||||
scheduleTick();
|
||||
} else if (!isLoading) {
|
||||
sawLoadingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleTick = () => {
|
||||
const scheduledGeneration = ++generation;
|
||||
frame = requestAnimationFrame((now) => tick(now, scheduledGeneration));
|
||||
timer = setTimeout(
|
||||
() => tick(performance.now(), scheduledGeneration),
|
||||
50,
|
||||
);
|
||||
};
|
||||
|
||||
scheduleTick();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
generation += 1;
|
||||
if (frame !== null) {
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [content, isLoading]);
|
||||
|
||||
return {
|
||||
content: displayContent,
|
||||
isRevealing: displayContent !== content,
|
||||
};
|
||||
}
|
||||
|
||||
function StreamingPre({ children }: ComponentProps<"pre">) {
|
||||
const childClassName = isValidElement<{ className?: string }>(children)
|
||||
? children.props.className
|
||||
@ -101,9 +222,16 @@ export function MarkdownContent({
|
||||
remarkPlugins = streamdownPluginsWithoutRawHtml.remarkPlugins,
|
||||
components: componentsFromProps,
|
||||
}: MarkdownContentProps) {
|
||||
const deferredContent = useDeferredValue(content);
|
||||
const targetContent = isLoading ? deferredContent : content;
|
||||
const { content: displayContent, isRevealing } = useSmoothStreamingContent(
|
||||
targetContent,
|
||||
isLoading,
|
||||
);
|
||||
const isStreamingRender = isLoading || isRevealing;
|
||||
const normalizedContent = useMemo(
|
||||
() => preprocessStreamdownMarkdown(content),
|
||||
[content],
|
||||
() => preprocessStreamdownMarkdown(displayContent),
|
||||
[displayContent],
|
||||
);
|
||||
const effectiveRehypePlugins = useMemo(() => {
|
||||
const base = streamdownPluginsWithoutRawHtml.rehypePlugins ?? [];
|
||||
@ -115,7 +243,7 @@ export function MarkdownContent({
|
||||
a: createMarkdownLinkComponent(),
|
||||
...componentsFromProps,
|
||||
};
|
||||
if (!isLoading) {
|
||||
if (!isStreamingRender) {
|
||||
return baseComponents;
|
||||
}
|
||||
return {
|
||||
@ -123,9 +251,9 @@ export function MarkdownContent({
|
||||
code: componentsFromProps?.code ?? StreamingCode,
|
||||
pre: componentsFromProps?.pre ?? StreamingPre,
|
||||
};
|
||||
}, [componentsFromProps, isLoading]);
|
||||
}, [componentsFromProps, isStreamingRender]);
|
||||
|
||||
if (!content) return null;
|
||||
if (!displayContent) return null;
|
||||
|
||||
return (
|
||||
<SafeMessageResponse
|
||||
@ -133,7 +261,7 @@ export function MarkdownContent({
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={effectiveRehypePlugins}
|
||||
components={components}
|
||||
parseIncompleteMarkdown={isLoading}
|
||||
parseIncompleteMarkdown={isStreamingRender}
|
||||
>
|
||||
{normalizedContent}
|
||||
</SafeMessageResponse>
|
||||
|
||||
@ -9,12 +9,13 @@ import {
|
||||
ListTodoIcon,
|
||||
MessageCircleQuestionMarkIcon,
|
||||
MessageSquareTextIcon,
|
||||
MonitorIcon,
|
||||
NotebookPenIcon,
|
||||
SearchIcon,
|
||||
SquareTerminalIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
ChainOfThought,
|
||||
@ -25,6 +26,7 @@ import {
|
||||
} from "@/components/ai-elements/chain-of-thought";
|
||||
import { CodeBlock } from "@/components/ai-elements/code-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { formatTokenCount } from "@/core/messages/usage";
|
||||
import type { TokenDebugStep } from "@/core/messages/usage-model";
|
||||
@ -39,24 +41,31 @@ import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useArtifacts } from "../artifacts";
|
||||
import { useMaybeBrowserView } from "../browser-view";
|
||||
import { FlipDisplay } from "../flip-display";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
|
||||
export function MessageGroup({
|
||||
className,
|
||||
messages,
|
||||
isLoading = false,
|
||||
tokenDebugSteps = [],
|
||||
showTokenDebugSummaries = false,
|
||||
}: {
|
||||
interface MessageGroupProps {
|
||||
className?: string;
|
||||
messages: Message[];
|
||||
isLoading?: boolean;
|
||||
deferBrowserPreviews?: boolean;
|
||||
tokenDebugSteps?: TokenDebugStep[];
|
||||
showTokenDebugSummaries?: boolean;
|
||||
}) {
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
function MessageGroupComponent({
|
||||
className,
|
||||
messages,
|
||||
isLoading = false,
|
||||
deferBrowserPreviews = false,
|
||||
tokenDebugSteps = [],
|
||||
showTokenDebugSummaries = false,
|
||||
threadId,
|
||||
}: MessageGroupProps) {
|
||||
const { t } = useI18n();
|
||||
const [showAbove, setShowAbove] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
@ -218,8 +227,10 @@ export function MessageGroup({
|
||||
<ToolCall
|
||||
key={step.id}
|
||||
{...step}
|
||||
threadId={threadId}
|
||||
isLast={options?.isLast}
|
||||
isLoading={isLoading}
|
||||
deferBrowserPreview={deferBrowserPreviews}
|
||||
tokenDebugStep={
|
||||
debugStep && !debugStep.sharedAttribution ? debugStep : undefined
|
||||
}
|
||||
@ -391,6 +402,47 @@ export function MessageGroup({
|
||||
);
|
||||
}
|
||||
|
||||
export const MessageGroup = memo(
|
||||
MessageGroupComponent,
|
||||
areMessageGroupPropsEqual,
|
||||
);
|
||||
MessageGroup.displayName = "MessageGroup";
|
||||
|
||||
function areMessageGroupPropsEqual(
|
||||
previous: MessageGroupProps,
|
||||
next: MessageGroupProps,
|
||||
): boolean {
|
||||
if (next.isLoading) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
previous.className === next.className &&
|
||||
Boolean(previous.isLoading) === Boolean(next.isLoading) &&
|
||||
Boolean(previous.deferBrowserPreviews) ===
|
||||
Boolean(next.deferBrowserPreviews) &&
|
||||
Boolean(previous.showTokenDebugSummaries) ===
|
||||
Boolean(next.showTokenDebugSummaries) &&
|
||||
previous.threadId === next.threadId &&
|
||||
sameReferences(previous.messages, next.messages) &&
|
||||
sameReferences(previous.tokenDebugSteps, next.tokenDebugSteps)
|
||||
);
|
||||
}
|
||||
|
||||
function sameReferences<T>(
|
||||
previous: readonly T[] | undefined,
|
||||
next: readonly T[] | undefined,
|
||||
): boolean {
|
||||
if (previous === next) {
|
||||
return true;
|
||||
}
|
||||
const previousItems = previous ?? [];
|
||||
const nextItems = next ?? [];
|
||||
return (
|
||||
previousItems.length === nextItems.length &&
|
||||
previousItems.every((item, index) => item === nextItems[index])
|
||||
);
|
||||
}
|
||||
|
||||
function formatDebugToken(
|
||||
debugStep: TokenDebugStep,
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
@ -445,6 +497,35 @@ function DebugStepLabel({
|
||||
);
|
||||
}
|
||||
|
||||
function browserToolLabel(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
): string {
|
||||
switch (name) {
|
||||
case "browser_navigate":
|
||||
return typeof args.url === "string"
|
||||
? t.toolCalls.browserNavigate(args.url)
|
||||
: t.toolCalls.browserNavigateGeneric;
|
||||
case "browser_click":
|
||||
return t.toolCalls.browserClick;
|
||||
case "browser_type":
|
||||
return t.toolCalls.browserType;
|
||||
case "browser_snapshot":
|
||||
return t.toolCalls.browserSnapshot;
|
||||
case "browser_get_text":
|
||||
return t.toolCalls.browserGetText;
|
||||
case "browser_back":
|
||||
return t.toolCalls.browserBack;
|
||||
case "browser_screenshot":
|
||||
return t.toolCalls.browserScreenshot;
|
||||
case "browser_close":
|
||||
return t.toolCalls.browserClose;
|
||||
default:
|
||||
return t.toolCalls.useTool(name);
|
||||
}
|
||||
}
|
||||
|
||||
function ToolCall({
|
||||
id,
|
||||
messageId,
|
||||
@ -453,7 +534,10 @@ function ToolCall({
|
||||
result,
|
||||
isLast = false,
|
||||
isLoading = false,
|
||||
deferBrowserPreview = false,
|
||||
tokenDebugStep,
|
||||
browserView,
|
||||
threadId,
|
||||
}: {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
@ -462,11 +546,15 @@ function ToolCall({
|
||||
result?: string | Record<string, unknown>;
|
||||
isLast?: boolean;
|
||||
isLoading?: boolean;
|
||||
deferBrowserPreview?: boolean;
|
||||
tokenDebugStep?: TokenDebugStep;
|
||||
browserView?: BrowserViewMeta;
|
||||
threadId?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
|
||||
useArtifacts();
|
||||
const browserViewPanel = useMaybeBrowserView();
|
||||
const tokenLabel = tokenDebugStep
|
||||
? formatDebugToken(tokenDebugStep, t)
|
||||
: null;
|
||||
@ -477,7 +565,54 @@ function ToolCall({
|
||||
fallback
|
||||
);
|
||||
|
||||
if (name === "web_search") {
|
||||
if (name.startsWith("browser_")) {
|
||||
const shot = browserView?.screenshot;
|
||||
const previewUrl =
|
||||
shot && threadId ? resolveArtifactURL(shot, threadId) : undefined;
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
label={resolveLabel(browserToolLabel(name, args, t))}
|
||||
icon={MonitorIcon}
|
||||
>
|
||||
{previewUrl && !deferBrowserPreview && (
|
||||
<button
|
||||
type="button"
|
||||
className="border-border mt-1 block w-full max-w-md cursor-pointer overflow-hidden rounded-lg border"
|
||||
onClick={() => {
|
||||
if (!shot) {
|
||||
return;
|
||||
}
|
||||
if (browserViewPanel) {
|
||||
browserViewPanel.pushFrame({
|
||||
screenshot: shot,
|
||||
url: browserView?.url,
|
||||
title: browserView?.title,
|
||||
});
|
||||
browserViewPanel.openPanel();
|
||||
} else {
|
||||
select(shot);
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
className="w-full object-contain"
|
||||
src={previewUrl}
|
||||
alt={browserView?.title ?? "browser view"}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{browserView?.url && (
|
||||
<div className="text-muted-foreground bg-muted/40 truncate px-2 py-1 text-left text-[11px]">
|
||||
{browserView.url}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "web_search") {
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
if (typeof args.query === "string") {
|
||||
label = t.toolCalls.searchOnWebFor(args.query);
|
||||
@ -732,6 +867,7 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: string;
|
||||
browserView?: BrowserViewMeta;
|
||||
}
|
||||
|
||||
interface CoTAssistantTextStep extends GenericCoTStep<"assistantText"> {
|
||||
@ -740,6 +876,31 @@ interface CoTAssistantTextStep extends GenericCoTStep<"assistantText"> {
|
||||
|
||||
type CoTStep = CoTAssistantTextStep | CoTReasoningStep | CoTToolCallStep;
|
||||
|
||||
interface BrowserViewMeta {
|
||||
screenshot: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
function findBrowserViewMeta(
|
||||
toolCallId: string,
|
||||
messages: Message[],
|
||||
): BrowserViewMeta | undefined {
|
||||
for (const message of messages) {
|
||||
if (message.type === "tool" && message.tool_call_id === toolCallId) {
|
||||
const meta = (
|
||||
message.additional_kwargs as
|
||||
| { browser_view?: BrowserViewMeta }
|
||||
| undefined
|
||||
)?.browser_view;
|
||||
if (meta && typeof meta.screenshot === "string") {
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
const steps: CoTStep[] = [];
|
||||
for (const [messageIndex, message] of messages.entries()) {
|
||||
@ -785,6 +946,7 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
||||
step.result = toolCallResult;
|
||||
}
|
||||
}
|
||||
step.browserView = findBrowserViewMeta(toolCallId, messages);
|
||||
}
|
||||
steps.push(step);
|
||||
}
|
||||
|
||||
@ -212,14 +212,30 @@ function MessageImage({
|
||||
const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`);
|
||||
|
||||
if (typeof src !== "string") {
|
||||
return <img className={imgClassName} src={src} alt={alt} {...props} />;
|
||||
return (
|
||||
<img
|
||||
className={imgClassName}
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const url = resolveMessageImageURL(src, threadId, artifactPaths);
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
<img className={imgClassName} src={url} alt={alt} {...props} />
|
||||
<img
|
||||
className={imgClassName}
|
||||
src={url}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
{...props}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@ -611,6 +627,8 @@ function RichFileCard({
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={file.filename}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
</a>
|
||||
|
||||
@ -39,6 +39,7 @@ import {
|
||||
} from "@/core/messages/human-input";
|
||||
import {
|
||||
buildTokenDebugSteps,
|
||||
type TokenDebugStep,
|
||||
type TokenUsageInlineMode,
|
||||
} from "@/core/messages/usage-model";
|
||||
import {
|
||||
@ -55,6 +56,7 @@ import {
|
||||
hasReasoning,
|
||||
isAssistantMessageGroupStreaming,
|
||||
isHiddenFromUIMessage,
|
||||
type MessageGroup as ThreadMessageGroup,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import {
|
||||
@ -71,6 +73,7 @@ import type { AgentThreadState } from "@/core/threads";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||
import { useMaybeBrowserView } from "../browser-view";
|
||||
import { CopyButton } from "../copy-button";
|
||||
import { useMaybeSidecar } from "../sidecar/context";
|
||||
import { Tooltip } from "../tooltip";
|
||||
@ -89,6 +92,101 @@ import {
|
||||
import { MessageListSkeleton } from "./skeleton";
|
||||
import { SubtaskCard } from "./subtask-card";
|
||||
|
||||
const EMPTY_TOKEN_DEBUG_STEPS: TokenDebugStep[] = [];
|
||||
const EMPTY_ARTIFACT_PATHS: readonly string[] = [];
|
||||
|
||||
function messageStableKey(message: Message) {
|
||||
if (
|
||||
message.type === "tool" &&
|
||||
typeof message.tool_call_id === "string" &&
|
||||
message.tool_call_id.length > 0
|
||||
) {
|
||||
return `tool:${message.tool_call_id}`;
|
||||
}
|
||||
if (typeof message.id === "string" && message.id.length > 0) {
|
||||
return `message:${message.id}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sameMessageIdentity(previous: Message, next: Message) {
|
||||
if (previous === next) {
|
||||
return true;
|
||||
}
|
||||
if (previous.type !== next.type) {
|
||||
return false;
|
||||
}
|
||||
const previousKey = messageStableKey(previous);
|
||||
const nextKey = messageStableKey(next);
|
||||
return previousKey !== null && previousKey === nextKey;
|
||||
}
|
||||
|
||||
function canReuseMessageGroup(
|
||||
previous: ThreadMessageGroup | undefined,
|
||||
next: ThreadMessageGroup,
|
||||
): previous is ThreadMessageGroup {
|
||||
if (
|
||||
!previous ||
|
||||
previous.id !== next.id ||
|
||||
previous.type !== next.type ||
|
||||
previous.messages.length !== next.messages.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return previous.messages.every(
|
||||
(message, index) =>
|
||||
next.messages[index] !== undefined &&
|
||||
sameMessageIdentity(message, next.messages[index]),
|
||||
);
|
||||
}
|
||||
|
||||
function sameStrings(previous: readonly string[], next: readonly string[]) {
|
||||
return (
|
||||
previous.length === next.length &&
|
||||
previous.every((value, index) => value === next[index])
|
||||
);
|
||||
}
|
||||
|
||||
function useStableArtifactPaths(paths: readonly string[] | undefined) {
|
||||
const previousPathsRef = useRef<readonly string[]>(EMPTY_ARTIFACT_PATHS);
|
||||
return useMemo(() => {
|
||||
const nextPaths = paths ?? EMPTY_ARTIFACT_PATHS;
|
||||
const previousPaths = previousPathsRef.current;
|
||||
if (sameStrings(previousPaths, nextPaths)) {
|
||||
return previousPaths;
|
||||
}
|
||||
previousPathsRef.current = nextPaths;
|
||||
return nextPaths;
|
||||
}, [paths]);
|
||||
}
|
||||
|
||||
function useStableMessageGroups(
|
||||
messages: Message[],
|
||||
isLoading: boolean,
|
||||
): ThreadMessageGroup[] {
|
||||
const previousGroupsRef = useRef<ThreadMessageGroup[]>([]);
|
||||
const previousIsLoadingRef = useRef(false);
|
||||
return useMemo(() => {
|
||||
const nextGroups = getMessageGroups(messages);
|
||||
const previousGroups = previousGroupsRef.current;
|
||||
const activeGroupIndex =
|
||||
isLoading || previousIsLoadingRef.current ? nextGroups.length - 1 : -1;
|
||||
const stableGroups = nextGroups.map((group, index) => {
|
||||
// Keep the actively streaming group fresh even if the SDK mutates a
|
||||
// message object in place while appending token content.
|
||||
if (index === activeGroupIndex) {
|
||||
return group;
|
||||
}
|
||||
return canReuseMessageGroup(previousGroups[index], group)
|
||||
? previousGroups[index]
|
||||
: group;
|
||||
});
|
||||
previousGroupsRef.current = stableGroups;
|
||||
previousIsLoadingRef.current = isLoading;
|
||||
return stableGroups;
|
||||
}, [isLoading, messages]);
|
||||
}
|
||||
|
||||
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 24;
|
||||
|
||||
const LOAD_MORE_HISTORY_THROTTLE_MS = 1200;
|
||||
@ -273,7 +371,47 @@ export function MessageList({
|
||||
prevIsLoading.current = thread.isLoading;
|
||||
}, [thread.isLoading]);
|
||||
const messages = thread.messages;
|
||||
const groupedMessages = getMessageGroups(messages);
|
||||
const browserView = useMaybeBrowserView();
|
||||
const pushBrowserFrame = browserView?.pushFrame;
|
||||
const messageCount = messages.length;
|
||||
useEffect(() => {
|
||||
// Only the primary chat surface drives the shared browser panel. The
|
||||
// sidecar renders a different thread's messages against the same
|
||||
// BrowserViewProvider; pushing its frames would make the panel resolve
|
||||
// another thread's screenshot with the primary threadId (404 / wrong page).
|
||||
if (sidecarSurface || !pushBrowserFrame) {
|
||||
return;
|
||||
}
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (message?.type !== "tool") {
|
||||
continue;
|
||||
}
|
||||
const meta = (
|
||||
message.additional_kwargs as
|
||||
| {
|
||||
browser_view?: {
|
||||
screenshot?: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
)?.browser_view;
|
||||
if (meta && typeof meta.screenshot === "string") {
|
||||
pushBrowserFrame({
|
||||
screenshot: meta.screenshot,
|
||||
url: meta.url,
|
||||
title: meta.title,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
// messages is intentionally read (not a dep) so token updates do not
|
||||
// repeatedly scan long history looking for the last browser frame.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messageCount, pushBrowserFrame, sidecarSurface]);
|
||||
const groupedMessages = useStableMessageGroups(messages, thread.isLoading);
|
||||
const [regeneratingMessageId, setRegeneratingMessageId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
@ -302,8 +440,47 @@ export function MessageList({
|
||||
const turnUsageMessagesByGroupIndex =
|
||||
getAssistantTurnUsageMessages(groupedMessages);
|
||||
const tokenDebugSteps = useMemo(
|
||||
() => buildTokenDebugSteps(messages, t),
|
||||
[messages, t],
|
||||
() =>
|
||||
tokenUsageInlineMode === "step_debug"
|
||||
? buildTokenDebugSteps(messages, t)
|
||||
: EMPTY_TOKEN_DEBUG_STEPS,
|
||||
[messages, t, tokenUsageInlineMode],
|
||||
);
|
||||
const showTokenDebugSummaries = tokenUsageInlineMode === "step_debug";
|
||||
const tokenDebugStepsByMessageId = useMemo(() => {
|
||||
const stepsByMessageId = new Map<string, TokenDebugStep[]>();
|
||||
for (const step of tokenDebugSteps) {
|
||||
const messageId = step.messageId;
|
||||
if (!messageId) {
|
||||
continue;
|
||||
}
|
||||
const steps = stepsByMessageId.get(messageId);
|
||||
if (steps) {
|
||||
steps.push(step);
|
||||
} else {
|
||||
stepsByMessageId.set(messageId, [step]);
|
||||
}
|
||||
}
|
||||
return stepsByMessageId;
|
||||
}, [tokenDebugSteps]);
|
||||
const getTokenDebugStepsForMessages = useCallback(
|
||||
(groupMessages: Message[]) => {
|
||||
if (!showTokenDebugSummaries) {
|
||||
return EMPTY_TOKEN_DEBUG_STEPS;
|
||||
}
|
||||
const steps: TokenDebugStep[] = [];
|
||||
for (const message of groupMessages) {
|
||||
if (!message.id) {
|
||||
continue;
|
||||
}
|
||||
const matched = tokenDebugStepsByMessageId.get(message.id);
|
||||
if (matched) {
|
||||
steps.push(...matched);
|
||||
}
|
||||
}
|
||||
return steps;
|
||||
},
|
||||
[showTokenDebugSummaries, tokenDebugStepsByMessageId],
|
||||
);
|
||||
const streamingMessages = useMemo(
|
||||
() =>
|
||||
@ -674,7 +851,7 @@ export function MessageList({
|
||||
);
|
||||
}
|
||||
|
||||
if (tokenUsageInlineMode === "step_debug" && inlineDebug) {
|
||||
if (showTokenDebugSummaries && inlineDebug) {
|
||||
const messageIds = new Set(
|
||||
debugMessageIds ??
|
||||
messages
|
||||
@ -695,15 +872,22 @@ export function MessageList({
|
||||
|
||||
return null;
|
||||
},
|
||||
[thread.isLoading, tokenDebugSteps, tokenUsageInlineMode],
|
||||
[
|
||||
showTokenDebugSummaries,
|
||||
thread.isLoading,
|
||||
tokenDebugSteps,
|
||||
tokenUsageInlineMode,
|
||||
],
|
||||
);
|
||||
|
||||
const artifactPaths = useStableArtifactPaths(
|
||||
extractArtifactsFromThread(thread),
|
||||
);
|
||||
|
||||
if (thread.isThreadLoading && messages.length === 0) {
|
||||
return <MessageListSkeleton />;
|
||||
}
|
||||
|
||||
const artifactPaths = extractArtifactsFromThread(thread);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Conversation
|
||||
@ -949,12 +1133,9 @@ export function MessageList({
|
||||
key={"thinking-group-" + message.id}
|
||||
messages={[message]}
|
||||
isLoading={groupIsLoading}
|
||||
tokenDebugSteps={tokenDebugSteps.filter(
|
||||
(step) => step.messageId === message.id,
|
||||
)}
|
||||
showTokenDebugSummaries={
|
||||
tokenUsageInlineMode === "step_debug"
|
||||
}
|
||||
deferBrowserPreviews={thread.isLoading}
|
||||
tokenDebugSteps={getTokenDebugStepsForMessages([message])}
|
||||
showTokenDebugSummaries={showTokenDebugSummaries}
|
||||
/>,
|
||||
);
|
||||
} else if (message.id) {
|
||||
@ -993,15 +1174,13 @@ export function MessageList({
|
||||
<div key={"group-" + group.id} className="w-full">
|
||||
<MessageGroup
|
||||
messages={group.messages}
|
||||
isLoading={thread.isLoading}
|
||||
tokenDebugSteps={tokenDebugSteps.filter((step) =>
|
||||
group.messages.some(
|
||||
(message) => message.id === step.messageId,
|
||||
),
|
||||
isLoading={groupIsLoading}
|
||||
deferBrowserPreviews={thread.isLoading}
|
||||
threadId={threadId}
|
||||
tokenDebugSteps={getTokenDebugStepsForMessages(
|
||||
group.messages,
|
||||
)}
|
||||
showTokenDebugSummaries={
|
||||
tokenUsageInlineMode === "step_debug"
|
||||
}
|
||||
showTokenDebugSummaries={showTokenDebugSummaries}
|
||||
/>
|
||||
{renderTokenUsage({
|
||||
messages: group.messages,
|
||||
|
||||
@ -81,10 +81,16 @@ export function RecentChatList() {
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteThreads();
|
||||
const threads = useMemo(
|
||||
() => infiniteThreads?.pages.flat() ?? [],
|
||||
[infiniteThreads],
|
||||
);
|
||||
const threads = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return (infiniteThreads?.pages.flat() ?? []).filter((thread) => {
|
||||
if (seen.has(thread.thread_id)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(thread.thread_id);
|
||||
return true;
|
||||
});
|
||||
}, [infiniteThreads]);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
|
||||
@ -550,7 +550,7 @@ export function SidecarPanel({ className }: { className?: string }) {
|
||||
: t.sidecar.noContext}
|
||||
</div>
|
||||
</div>
|
||||
{hasSidecarThread ? (
|
||||
{hasSidecarThread && (
|
||||
<Tooltip content={t.sidecar.delete}>
|
||||
<Button
|
||||
aria-label={t.sidecar.delete}
|
||||
@ -563,22 +563,21 @@ export function SidecarPanel({ className }: { className?: string }) {
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
// No conversation yet — nothing to delete, so this just discards the
|
||||
// draft and closes the panel. A plain X (no confirm) keeps it light.
|
||||
<Tooltip content={t.common.close}>
|
||||
<Button
|
||||
aria-label={t.common.close}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
data-testid="sidecar-close-button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => discardDraftAndClose()}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={t.common.close}>
|
||||
<Button
|
||||
aria-label={t.common.close}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
data-testid="sidecar-close-button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
hasSidecarThread ? sidecar.close() : discardDraftAndClose()
|
||||
}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { fetch } from "@/core/api/fetcher";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
export { fetchAgentsApiEnabled } from "@/core/features/api";
|
||||
|
||||
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
|
||||
@ -87,19 +88,6 @@ export async function deleteAgent(name: string): Promise<void> {
|
||||
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
|
||||
}
|
||||
|
||||
interface FeaturesResponse {
|
||||
agents_api: { enabled: boolean };
|
||||
}
|
||||
|
||||
export async function fetchAgentsApiEnabled(): Promise<boolean> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/features`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load features: ${res.statusText}`);
|
||||
}
|
||||
const data = (await res.json()) as FeaturesResponse;
|
||||
return data.agents_api.enabled;
|
||||
}
|
||||
|
||||
export async function checkAgentName(
|
||||
name: string,
|
||||
): Promise<{ available: boolean; name: string }> {
|
||||
|
||||
23
frontend/src/core/features/api.ts
Normal file
23
frontend/src/core/features/api.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { fetch } from "@/core/api/fetcher";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface FeaturesResponse {
|
||||
agents_api: { enabled: boolean };
|
||||
browser_control?: { enabled: boolean };
|
||||
}
|
||||
|
||||
export async function fetchFeatures(): Promise<FeaturesResponse> {
|
||||
const res = await fetch(`${getBackendBaseURL()}/api/features`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load features: ${res.statusText}`);
|
||||
}
|
||||
return (await res.json()) as FeaturesResponse;
|
||||
}
|
||||
|
||||
export async function fetchAgentsApiEnabled(): Promise<boolean> {
|
||||
return (await fetchFeatures()).agents_api.enabled;
|
||||
}
|
||||
|
||||
export async function fetchBrowserControlEnabled(): Promise<boolean> {
|
||||
return (await fetchFeatures()).browser_control?.enabled ?? false;
|
||||
}
|
||||
18
frontend/src/core/features/hooks.ts
Normal file
18
frontend/src/core/features/hooks.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { fetchBrowserControlEnabled } from "./api";
|
||||
|
||||
export function useBrowserControlEnabled() {
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: ["features", "browser_control"],
|
||||
queryFn: () => fetchBrowserControlEnabled(),
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return {
|
||||
enabled: data ?? false,
|
||||
isLoading: isPending,
|
||||
};
|
||||
}
|
||||
2
frontend/src/core/features/index.ts
Normal file
2
frontend/src/core/features/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./api";
|
||||
export * from "./hooks";
|
||||
@ -53,6 +53,8 @@ export const enUS: Translations = {
|
||||
regenerate: "Regenerate",
|
||||
branch: "Branch conversation",
|
||||
showArtifacts: "Show artifacts of this conversation",
|
||||
browser: "Browser",
|
||||
showBrowser: "Open browser panel",
|
||||
},
|
||||
|
||||
// Home
|
||||
@ -567,6 +569,15 @@ export const enUS: Translations = {
|
||||
clickToViewContent: "Click to view file content",
|
||||
writeTodos: "Update to-do list",
|
||||
skillInstallTooltip: "Install skill and make it available to DeerFlow",
|
||||
browserNavigate: (url: string) => `Open ${url} in browser`,
|
||||
browserNavigateGeneric: "Open page in browser",
|
||||
browserClick: "Click element in browser",
|
||||
browserType: "Type into browser field",
|
||||
browserSnapshot: "Read page in browser",
|
||||
browserGetText: "Read page text in browser",
|
||||
browserBack: "Go back in browser",
|
||||
browserScreenshot: "Capture browser screenshot",
|
||||
browserClose: "Close browser",
|
||||
},
|
||||
|
||||
humanInput: {
|
||||
|
||||
@ -42,6 +42,8 @@ export interface Translations {
|
||||
regenerate: string;
|
||||
branch: string;
|
||||
showArtifacts: string;
|
||||
browser: string;
|
||||
showBrowser: string;
|
||||
};
|
||||
|
||||
home: {
|
||||
@ -457,6 +459,15 @@ export interface Translations {
|
||||
clickToViewContent: string;
|
||||
writeTodos: string;
|
||||
skillInstallTooltip: string;
|
||||
browserNavigate: (url: string) => string;
|
||||
browserNavigateGeneric: string;
|
||||
browserClick: string;
|
||||
browserType: string;
|
||||
browserSnapshot: string;
|
||||
browserGetText: string;
|
||||
browserBack: string;
|
||||
browserScreenshot: string;
|
||||
browserClose: string;
|
||||
};
|
||||
|
||||
humanInput: {
|
||||
|
||||
@ -53,6 +53,8 @@ export const zhCN: Translations = {
|
||||
regenerate: "重新生成",
|
||||
branch: "分叉",
|
||||
showArtifacts: "查看此对话的文件",
|
||||
browser: "浏览器",
|
||||
showBrowser: "打开浏览器面板",
|
||||
},
|
||||
|
||||
// Home
|
||||
@ -545,6 +547,15 @@ export const zhCN: Translations = {
|
||||
clickToViewContent: "点击查看文件内容",
|
||||
writeTodos: "更新 To-do 列表",
|
||||
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
|
||||
browserNavigate: (url: string) => `在浏览器中打开 ${url}`,
|
||||
browserNavigateGeneric: "在浏览器中打开页面",
|
||||
browserClick: "在浏览器中点击元素",
|
||||
browserType: "在浏览器中输入内容",
|
||||
browserSnapshot: "在浏览器中读取页面",
|
||||
browserGetText: "在浏览器中读取页面文本",
|
||||
browserBack: "在浏览器中返回上一页",
|
||||
browserScreenshot: "浏览器截图",
|
||||
browserClose: "关闭浏览器",
|
||||
},
|
||||
|
||||
humanInput: {
|
||||
|
||||
@ -85,6 +85,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
--animate-loading-bar: loading-bar 1s ease-in-out infinite;
|
||||
@keyframes loading-bar {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
|
||||
--animate-fade-in-up: fade-in-up 0.15s ease-in-out forwards;
|
||||
@keyframes fade-in-up {
|
||||
0% {
|
||||
|
||||
39
frontend/tests/e2e/browser-feature.spec.ts
Normal file
39
frontend/tests/e2e/browser-feature.spec.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { MOCK_THREAD_ID, mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
test.describe("Browser feature flag", () => {
|
||||
test("shows browser trigger only when browser_control is enabled", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [{ thread_id: MOCK_THREAD_ID, title: "Browser Enabled" }],
|
||||
});
|
||||
|
||||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||
|
||||
await expect(page.getByTestId("browser-trigger")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("hides browser trigger when browser_control is disabled", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [{ thread_id: MOCK_THREAD_ID, title: "Browser Disabled" }],
|
||||
features: { browserControlEnabled: false },
|
||||
});
|
||||
|
||||
const featuresResponse = page.waitForResponse((response) =>
|
||||
response.url().includes("/api/features"),
|
||||
);
|
||||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||
const features = (await (await featuresResponse).json()) as {
|
||||
browser_control?: { enabled?: boolean };
|
||||
};
|
||||
expect(features.browser_control?.enabled).toBe(false);
|
||||
|
||||
await expect(page.getByTestId("browser-trigger")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@ -87,6 +87,10 @@ export type MockAPIOptions = {
|
||||
max_file_size: number;
|
||||
max_total_size: number;
|
||||
};
|
||||
features?: {
|
||||
agentsApiEnabled?: boolean;
|
||||
browserControlEnabled?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_SKILLS: MockSkill[] = [
|
||||
@ -126,6 +130,16 @@ function visibleInputMessages(messages: unknown[]) {
|
||||
return messages.filter((message) => !isHiddenInputMessage(message));
|
||||
}
|
||||
|
||||
function mockMessageRunId(message: unknown, fallback: string) {
|
||||
if (typeof message === "object" && message !== null) {
|
||||
const runId = Reflect.get(message, "run_id");
|
||||
if (typeof runId === "string" && runId.length > 0) {
|
||||
return runId;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function visibleRunInputMessages(route: Route) {
|
||||
try {
|
||||
const body = route.request().postDataJSON() as {
|
||||
@ -244,6 +258,10 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
max_file_size: 50 * 1024 * 1024,
|
||||
max_total_size: 100 * 1024 * 1024,
|
||||
};
|
||||
const featureFlags = {
|
||||
agentsApiEnabled: options?.features?.agentsApiEnabled ?? true,
|
||||
browserControlEnabled: options?.features?.browserControlEnabled ?? true,
|
||||
};
|
||||
|
||||
const upsertThread = (thread: MockThread) => {
|
||||
threads = [
|
||||
@ -962,7 +980,10 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: (matchingThread?.messages ?? []).map((message, index) => ({
|
||||
run_id: `run-${matchingThread?.thread_id ?? "unknown"}`,
|
||||
run_id: mockMessageRunId(
|
||||
message,
|
||||
`run-${matchingThread?.thread_id ?? "unknown"}`,
|
||||
),
|
||||
seq: index + 1,
|
||||
content: message,
|
||||
metadata: { caller: "lead_agent" },
|
||||
@ -1016,7 +1037,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
// Feature flags — frontend gates UI (e.g. agents) on these. Default to
|
||||
// Feature flags — frontend gates UI (e.g. agents/browser) on these. Default to
|
||||
// enabled so existing tests exercise the normal path; tests that need the
|
||||
// disabled state override this route after calling mockLangGraphAPI.
|
||||
void page.route("**/api/features", (route) => {
|
||||
@ -1024,7 +1045,10 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ agents_api: { enabled: true } }),
|
||||
body: JSON.stringify({
|
||||
agents_api: { enabled: featureFlags.agentsApiEnabled },
|
||||
browser_control: { enabled: featureFlags.browserControlEnabled },
|
||||
}),
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "@rstest/core";
|
||||
|
||||
import { decideBrowserKeyInput } from "@/components/workspace/browser-view/keyboard";
|
||||
|
||||
function ctx(
|
||||
overrides: Partial<Parameters<typeof decideBrowserKeyInput>[0]> = {},
|
||||
) {
|
||||
return {
|
||||
live: true,
|
||||
editableTarget: false,
|
||||
composing: false,
|
||||
key: "a",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("decideBrowserKeyInput", () => {
|
||||
it("forwards printable chars as text", () => {
|
||||
expect(decideBrowserKeyInput(ctx({ key: "a" }))).toEqual({
|
||||
type: "text",
|
||||
text: "a",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards named keys as key presses", () => {
|
||||
expect(decideBrowserKeyInput(ctx({ key: "Enter" }))).toEqual({
|
||||
type: "key",
|
||||
key: "Enter",
|
||||
});
|
||||
expect(decideBrowserKeyInput(ctx({ key: "ArrowLeft" }))).toEqual({
|
||||
type: "key",
|
||||
key: "ArrowLeft",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards modifier combos as a normalized key chord", () => {
|
||||
expect(decideBrowserKeyInput(ctx({ key: "c", metaKey: true }))).toEqual({
|
||||
type: "key",
|
||||
key: "Meta+C",
|
||||
});
|
||||
expect(decideBrowserKeyInput(ctx({ key: "a", ctrlKey: true }))).toEqual({
|
||||
type: "key",
|
||||
key: "Control+A",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not forward while an IME composition is active", () => {
|
||||
// A CJK candidate confirmed with Enter must not submit the remote page.
|
||||
expect(
|
||||
decideBrowserKeyInput(ctx({ key: "Enter", composing: true })),
|
||||
).toBeNull();
|
||||
expect(
|
||||
decideBrowserKeyInput(ctx({ key: "a", composing: true })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores keys when not live or focus is on an editable target", () => {
|
||||
expect(
|
||||
decideBrowserKeyInput(ctx({ key: "Enter", live: false })),
|
||||
).toBeNull();
|
||||
expect(
|
||||
decideBrowserKeyInput(ctx({ key: "Enter", editableTarget: true })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores unmapped named keys", () => {
|
||||
expect(decideBrowserKeyInput(ctx({ key: "F5" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -82,6 +82,13 @@ describe("MarkdownContent streaming code blocks", () => {
|
||||
expect(html).toContain('data-custom-code="true"');
|
||||
expect(html).toContain("data-streaming-code-block");
|
||||
});
|
||||
|
||||
it("does not paint an initial large streaming chunk all at once", () => {
|
||||
const content = "x".repeat(120);
|
||||
|
||||
expect(renderMarkdown(content, true)).not.toContain(content);
|
||||
expect(renderMarkdown(content, false)).toContain(content);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MarkdownContent strikethrough", () => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { describe, expect, it, rs } from "@rstest/core";
|
||||
import { createElement } from "react";
|
||||
import { createElement, type ComponentProps } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { MessageGroup } from "@/components/workspace/messages/message-group";
|
||||
@ -121,9 +121,55 @@ describe("MessageGroup", () => {
|
||||
expect(html).toContain("1 more step");
|
||||
expect(html).not.toContain("Check how processing groups convert messages.");
|
||||
});
|
||||
|
||||
it("defers browser screenshot previews while the thread is loading", () => {
|
||||
const messages = [
|
||||
{
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call-1",
|
||||
name: "browser_navigate",
|
||||
args: { url: "https://github.com/bytedance/deer-flow" },
|
||||
},
|
||||
],
|
||||
} as Message,
|
||||
{
|
||||
id: "tool-1",
|
||||
type: "tool",
|
||||
name: "browser_navigate",
|
||||
tool_call_id: "call-1",
|
||||
content: "Opened",
|
||||
additional_kwargs: {
|
||||
browser_view: {
|
||||
screenshot: "/mnt/user-data/outputs/browser.png",
|
||||
url: "https://github.com/bytedance/deer-flow",
|
||||
},
|
||||
},
|
||||
} as Message,
|
||||
];
|
||||
|
||||
const visibleHtml = renderGroup(messages, {
|
||||
threadId: "thread-1",
|
||||
deferBrowserPreviews: false,
|
||||
});
|
||||
const deferredHtml = renderGroup(messages, {
|
||||
threadId: "thread-1",
|
||||
deferBrowserPreviews: true,
|
||||
});
|
||||
|
||||
expect(visibleHtml).toContain("<img");
|
||||
expect(visibleHtml).toContain('decoding="async"');
|
||||
expect(deferredHtml).not.toContain("<img");
|
||||
});
|
||||
});
|
||||
|
||||
function renderGroup(messages: Message[]) {
|
||||
function renderGroup(
|
||||
messages: Message[],
|
||||
props: Omit<ComponentProps<typeof MessageGroup>, "messages"> = {},
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(
|
||||
I18nContext.Provider,
|
||||
@ -133,7 +179,7 @@ function renderGroup(messages: Message[]) {
|
||||
setLocale: () => undefined,
|
||||
},
|
||||
},
|
||||
createElement(MessageGroup, { messages }),
|
||||
createElement(MessageGroup, { ...props, messages }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ rs.mock("@/core/config", () => ({
|
||||
|
||||
import { fetchAgentsApiEnabled } from "@/core/agents/api";
|
||||
import { fetch as fetcher } from "@/core/api/fetcher";
|
||||
import { fetchBrowserControlEnabled } from "@/core/features/api";
|
||||
|
||||
const mockedFetch = rs.mocked(fetcher);
|
||||
|
||||
@ -45,3 +46,36 @@ describe("fetchAgentsApiEnabled", () => {
|
||||
await expect(fetchAgentsApiEnabled()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchBrowserControlEnabled", () => {
|
||||
test("returns true when backend reports browser_control enabled", async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, {
|
||||
agents_api: { enabled: true },
|
||||
browser_control: { enabled: true },
|
||||
}),
|
||||
);
|
||||
await expect(fetchBrowserControlEnabled()).resolves.toBe(true);
|
||||
expect(mockedFetch).toHaveBeenCalledWith("/api/features");
|
||||
});
|
||||
|
||||
test("returns false when browser_control is disabled or omitted", async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, {
|
||||
agents_api: { enabled: true },
|
||||
browser_control: { enabled: false },
|
||||
}),
|
||||
);
|
||||
await expect(fetchBrowserControlEnabled()).resolves.toBe(false);
|
||||
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, { agents_api: { enabled: true } }),
|
||||
);
|
||||
await expect(fetchBrowserControlEnabled()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test("throws when the features request fails", async () => {
|
||||
mockedFetch.mockResolvedValueOnce(jsonResponse(500, {}));
|
||||
await expect(fetchBrowserControlEnabled()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@ -11,6 +11,7 @@ Order of resolution:
|
||||
- database.backend == postgres -> postgres
|
||||
- checkpointer.type == postgres -> postgres
|
||||
- stream_bridge.type == redis -> redis
|
||||
- tools[].name == browser_navigate -> browser
|
||||
- sandbox.ownership.type == redis -> redis
|
||||
3. Runtime environment toggles that enable optional backends:
|
||||
- DEER_FLOW_STREAM_BRIDGE_REDIS_URL -> redis
|
||||
@ -76,6 +77,7 @@ def find_config_file() -> Path | None:
|
||||
_SECTION_RE = re.compile(r"^([A-Za-z_][\w-]*)\s*:\s*$")
|
||||
_INDENTED_SECTION_RE = re.compile(r"^\s+([A-Za-z_][\w-]*)\s*:\s*$")
|
||||
_KEY_RE = re.compile(r"^\s+([A-Za-z_][\w-]*)\s*:\s*(\S.*?)\s*$")
|
||||
_LIST_ITEM_NAME_RE = re.compile(r"^\s*-\s+name\s*:\s*(\S.*?)\s*$")
|
||||
|
||||
|
||||
def _strip_comment(line: str) -> str:
|
||||
@ -222,6 +224,30 @@ def nested_section_value(lines: list[str], section_path: str, key: str) -> str |
|
||||
return None
|
||||
|
||||
|
||||
def tools_include_name(lines: list[str], tool_name: str) -> bool:
|
||||
"""Return True when the top-level tools list has an active item name."""
|
||||
inside = False
|
||||
for raw in lines:
|
||||
line = _strip_comment(raw)
|
||||
if not line.strip():
|
||||
continue
|
||||
sect_match = _SECTION_RE.match(line)
|
||||
if sect_match:
|
||||
inside = sect_match.group(1) == "tools"
|
||||
continue
|
||||
if not inside:
|
||||
continue
|
||||
stripped = line.lstrip()
|
||||
indent = len(line) - len(stripped)
|
||||
if indent == 0:
|
||||
inside = False
|
||||
continue
|
||||
name_match = _LIST_ITEM_NAME_RE.match(line)
|
||||
if name_match and _unquote(name_match.group(1).strip()) == tool_name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def detect_from_config(path: Path) -> list[str]:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
@ -239,6 +265,8 @@ def detect_from_config(path: Path) -> list[str]:
|
||||
extras.add("redis")
|
||||
if (nested_section_value(lines, "channels.discord", "enabled") or "").lower() == "true":
|
||||
extras.add("discord")
|
||||
if tools_include_name(lines, "browser_navigate"):
|
||||
extras.add("browser")
|
||||
return sorted(extras)
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user