mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 19:06:01 +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
|
└── 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
|
### 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.
|
**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).
|
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
|
## Architecture
|
||||||
|
|
||||||
### Harness / App Split
|
### Harness / App Split
|
||||||
@ -329,7 +334,7 @@ Localhost persistence deliberately reads the direct request `Host` and ignores `
|
|||||||
| Router | Endpoints |
|
| Router | Endpoints |
|
||||||
|--------|-----------|
|
|--------|-----------|
|
||||||
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
|
| **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 |
|
| **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) |
|
| **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 |
|
| **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
|
- `firecrawl/` - Web scraping via Firecrawl API
|
||||||
- `image_search/` - Image search via DuckDuckGo
|
- `image_search/` - Image search via DuckDuckGo
|
||||||
- `aio_sandbox/` - Docker-based isolation (`AioSandboxProvider`)
|
- `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.
|
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_disabled import warn_if_auth_disabled_enabled
|
||||||
from app.gateway.auth_middleware import AuthMiddleware
|
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.config import get_gateway_config
|
||||||
from app.gateway.csrf_middleware import CSRFMiddleware, get_configured_cors_origins
|
from app.gateway.csrf_middleware import CSRFMiddleware, get_configured_cors_origins
|
||||||
from app.gateway.deps import langgraph_runtime
|
from app.gateway.deps import langgraph_runtime
|
||||||
@ -16,6 +17,7 @@ from app.gateway.routers import (
|
|||||||
artifacts,
|
artifacts,
|
||||||
assistants_compat,
|
assistants_compat,
|
||||||
auth,
|
auth,
|
||||||
|
browser,
|
||||||
channel_connections,
|
channel_connections,
|
||||||
channels,
|
channels,
|
||||||
console,
|
console,
|
||||||
@ -181,6 +183,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
try:
|
try:
|
||||||
startup_config = get_app_config()
|
startup_config = get_app_config()
|
||||||
configure_logging(startup_config)
|
configure_logging(startup_config)
|
||||||
|
ensure_browser_runtime_available(startup_config)
|
||||||
logger.info("Configuration loaded successfully")
|
logger.info("Configuration loaded successfully")
|
||||||
warn_if_auth_disabled_enabled()
|
warn_if_auth_disabled_enabled()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -301,6 +304,23 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to stop scheduled task service")
|
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
|
# Drain the memory backend's pending-update buffer before the worker
|
||||||
# exits (best-effort, bounded). IM channels and the scheduler are
|
# exits (best-effort, bounded). IM channels and the scheduler are
|
||||||
# already stopped above, so no new IM/scheduler updates arrive during
|
# 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).
|
# would (review #6 on the original PR).
|
||||||
#
|
#
|
||||||
# K8s caveat: ``shutdown_flush_timeout_seconds`` must fit inside the
|
# K8s caveat: ``shutdown_flush_timeout_seconds`` must fit inside the
|
||||||
# pod's ``terminationGracePeriodSeconds`` (channel stop + this drain +
|
# pod's ``terminationGracePeriodSeconds`` (channel stop + browser
|
||||||
# buffer), set on the gateway Helm deployment -- or K8s SIGKILLs the
|
# session close + this drain + buffer), set on the gateway Helm
|
||||||
# drain mid-flight and the loss this is fixing is silently re-introduced.
|
# deployment -- or K8s SIGKILLs the drain mid-flight and the loss this
|
||||||
|
# is fixing is silently re-introduced.
|
||||||
try:
|
try:
|
||||||
app_cfg = get_app_config()
|
app_cfg = get_app_config()
|
||||||
if app_cfg.memory.enabled:
|
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
|
# Artifacts API is mounted at /api/threads/{thread_id}/artifacts
|
||||||
app.include_router(artifacts.router)
|
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
|
# Uploads API is mounted at /api/threads/{thread_id}/uploads
|
||||||
app.include_router(uploads.router)
|
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 fastapi import FastAPI, HTTPException, Request
|
||||||
from langgraph.types import Checkpointer
|
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.config.app_config import AppConfig, get_app_config
|
||||||
from deerflow.persistence.feedback import FeedbackRepository
|
from deerflow.persistence.feedback import FeedbackRepository
|
||||||
from deerflow.runtime import RunContext, RunManager, StreamBridge
|
from deerflow.runtime import RunContext, RunManager, StreamBridge
|
||||||
@ -46,14 +47,25 @@ logger = logging.getLogger(__name__)
|
|||||||
_RUN_DRAIN_TIMEOUT_SECONDS = 5.0
|
_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:
|
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.
|
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
|
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
|
runs as orphans and Worker B would kill Worker A's live runs on
|
||||||
every rolling update or scale-up.
|
every rolling update or scale-up.
|
||||||
@ -70,6 +82,9 @@ def _enforce_postgres_for_multi_worker(config: AppConfig) -> None:
|
|||||||
if workers <= 1:
|
if workers <= 1:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if _browser_tools_enabled_in_config(config):
|
||||||
|
raise SystemExit(browser_multi_worker_error(workers))
|
||||||
|
|
||||||
backend = getattr(config.database, "backend", None)
|
backend = getattr(config.database, "backend", None)
|
||||||
if backend != "postgres":
|
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.")
|
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 (
|
from . import (
|
||||||
artifacts,
|
artifacts,
|
||||||
assistants_compat,
|
assistants_compat,
|
||||||
|
browser,
|
||||||
input_polish,
|
input_polish,
|
||||||
mcp,
|
mcp,
|
||||||
models,
|
models,
|
||||||
@ -15,6 +16,7 @@ from . import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"artifacts",
|
"artifacts",
|
||||||
"assistants_compat",
|
"assistants_compat",
|
||||||
|
"browser",
|
||||||
"input_polish",
|
"input_polish",
|
||||||
"mcp",
|
"mcp",
|
||||||
"models",
|
"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 fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.gateway.browser_capability import browser_capability
|
||||||
from app.gateway.deps import get_config
|
from app.gateway.deps import get_config
|
||||||
from deerflow.config.app_config import AppConfig
|
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")
|
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):
|
class FeaturesResponse(BaseModel):
|
||||||
"""Frontend-facing feature availability flags."""
|
"""Frontend-facing feature availability flags."""
|
||||||
|
|
||||||
agents_api: AgentsApiFeature
|
agents_api: AgentsApiFeature
|
||||||
|
browser_control: BrowserControlFeature
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@ -35,6 +43,8 @@ class FeaturesResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
async def list_features(config: AppConfig = Depends(get_config)) -> FeaturesResponse:
|
async def list_features(config: AppConfig = Depends(get_config)) -> FeaturesResponse:
|
||||||
"""Return availability of optional, config-gated frontend features."""
|
"""Return availability of optional, config-gated frontend features."""
|
||||||
|
browser = browser_capability(config)
|
||||||
return FeaturesResponse(
|
return FeaturesResponse(
|
||||||
agents_api=AgentsApiFeature(enabled=config.agents_api.enabled),
|
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:
|
except Exception:
|
||||||
logger.debug("Could not delete thread_meta for %s (not critical)", sanitize_log_param(thread_id))
|
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
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -248,7 +248,7 @@ Notes:
|
|||||||
- `enabled: false` keeps background polling off by default.
|
- `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.
|
- `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.
|
- 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 thread reuse and fresh-thread-per-run execution modes.
|
||||||
- The MVP supports only `once` and `cron`.
|
- The MVP supports only `once` and `cron`.
|
||||||
- Manual trigger uses the same scheduled-task resource and run lifecycle.
|
- 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."""
|
"""Shared runtime protocol constants."""
|
||||||
|
|
||||||
DEFAULT_SKILLS_CONTAINER_PATH = "/mnt/skills"
|
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 codecs import BOM_UTF16_BE, BOM_UTF16_LE, getincrementaldecoder
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from deerflow.constants import BROWSER_FRAMES_DIRNAME
|
||||||
|
|
||||||
from .types import (
|
from .types import (
|
||||||
DiffUnavailableReason,
|
DiffUnavailableReason,
|
||||||
FileSnapshot,
|
FileSnapshot,
|
||||||
@ -21,6 +23,10 @@ EXCLUDED_DIR_NAMES = {
|
|||||||
".cache",
|
".cache",
|
||||||
".next",
|
".next",
|
||||||
".venv",
|
".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__",
|
"__pycache__",
|
||||||
"build",
|
"build",
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@ -68,6 +68,10 @@ boxlite = ["boxlite>=0.9.7"]
|
|||||||
# Agent observability (Monocle). Optional so a default install stays free of the
|
# Agent observability (Monocle). Optional so a default install stays free of the
|
||||||
# OpenTelemetry stack; only pulled in when MONOCLE_TRACING is used.
|
# OpenTelemetry stack; only pulled in when MONOCLE_TRACING is used.
|
||||||
monocle = ["monocle_apptrace>=0.8.8"]
|
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]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
|
|||||||
@ -29,6 +29,7 @@ postgres = ["deerflow-harness[postgres]"]
|
|||||||
redis = ["deerflow-harness[redis]"]
|
redis = ["deerflow-harness[redis]"]
|
||||||
discord = ["discord.py>=2.7.0"]
|
discord = ["discord.py>=2.7.0"]
|
||||||
monocle = ["deerflow-harness[monocle]"]
|
monocle = ["deerflow-harness[monocle]"]
|
||||||
|
browser = ["deerflow-harness[browser]"]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
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"]
|
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):
|
def test_detect_from_config_memory_stream_bridge_returns_no_extras(tmp_path):
|
||||||
cfg = tmp_path / "config.yaml"
|
cfg = tmp_path / "config.yaml"
|
||||||
cfg.write_text("stream_bridge:\n type: memory\n queue_maxsize: 256\n")
|
cfg.write_text("stream_bridge:\n type: memory\n queue_maxsize: 256\n")
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@ -7,10 +8,17 @@ from app.gateway.deps import get_config
|
|||||||
from app.gateway.routers import features
|
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 = FastAPI()
|
||||||
app.include_router(features.router)
|
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
|
app.dependency_overrides[get_config] = lambda: fake_config
|
||||||
return app
|
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:
|
with TestClient(_app_with_config(agents_api_enabled=True)) as client:
|
||||||
response = client.get("/api/features")
|
response = client.get("/api/features")
|
||||||
assert response.status_code == 200
|
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:
|
def test_features_reports_agents_api_disabled() -> None:
|
||||||
with TestClient(_app_with_config(agents_api_enabled=False)) as client:
|
with TestClient(_app_with_config(agents_api_enabled=False)) as client:
|
||||||
response = client.get("/api/features")
|
response = client.get("/api/features")
|
||||||
assert response.status_code == 200
|
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"})
|
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):
|
def test_make_lead_agent_fails_closed_when_skill_policy_load_fails(monkeypatch):
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
|||||||
@ -19,13 +19,15 @@ import pytest
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from app.gateway.deps import _enforce_postgres_for_multi_worker, langgraph_runtime
|
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.database_config import DatabaseConfig
|
||||||
from deerflow.config.run_ownership_config import RunOwnershipConfig
|
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
|
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))
|
_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):
|
def test_gate_rejects_multi_worker_with_sqlite(monkeypatch):
|
||||||
monkeypatch.setenv("GATEWAY_WORKERS", "2")
|
monkeypatch.setenv("GATEWAY_WORKERS", "2")
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from _router_auth_helpers import make_authed_test_app
|
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()
|
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):
|
def test_delete_thread_route_rejects_invalid_thread_id(tmp_path):
|
||||||
paths = Paths(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
|
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):
|
def test_scan_workspace_roots_can_skip_text_loading(tmp_path):
|
||||||
roots = _roots(tmp_path)
|
roots = _roots(tmp_path)
|
||||||
workspace = roots[0].host_path
|
workspace = roots[0].host_path
|
||||||
|
|||||||
31
backend/uv.lock
generated
31
backend/uv.lock
generated
@ -796,6 +796,9 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
|
browser = [
|
||||||
|
{ name = "deerflow-harness", extra = ["browser"] },
|
||||||
|
]
|
||||||
discord = [
|
discord = [
|
||||||
{ name = "discord-py" },
|
{ name = "discord-py" },
|
||||||
]
|
]
|
||||||
@ -826,6 +829,7 @@ dev = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "bcrypt", specifier = ">=4.0.0" },
|
{ name = "bcrypt", specifier = ">=4.0.0" },
|
||||||
{ name = "deerflow-harness", editable = "packages/harness" },
|
{ 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 = ["monocle"], marker = "extra == 'monocle'", editable = "packages/harness" },
|
||||||
{ name = "deerflow-harness", extras = ["postgres"], marker = "extra == 'postgres'", 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" },
|
{ 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 = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
|
||||||
{ name = "wecom-aibot-python-sdk", specifier = ">=0.1.6" },
|
{ 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]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
@ -907,6 +911,9 @@ dependencies = [
|
|||||||
boxlite = [
|
boxlite = [
|
||||||
{ name = "boxlite" },
|
{ name = "boxlite" },
|
||||||
]
|
]
|
||||||
|
browser = [
|
||||||
|
{ name = "playwright" },
|
||||||
|
]
|
||||||
monocle = [
|
monocle = [
|
||||||
{ name = "monocle-apptrace" },
|
{ name = "monocle-apptrace" },
|
||||||
]
|
]
|
||||||
@ -965,6 +972,7 @@ requires-dist = [
|
|||||||
{ name = "markdownify", specifier = ">=1.2.2" },
|
{ name = "markdownify", specifier = ">=1.2.2" },
|
||||||
{ name = "markitdown", extras = ["all", "xlsx"], specifier = ">=0.0.1a2" },
|
{ name = "markitdown", extras = ["all", "xlsx"], specifier = ">=0.0.1a2" },
|
||||||
{ name = "monocle-apptrace", marker = "extra == 'monocle'", specifier = ">=0.8.8" },
|
{ 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", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.3.3" },
|
||||||
{ name = "psycopg-pool", marker = "extra == 'postgres'", specifier = ">=3.3.0" },
|
{ name = "psycopg-pool", marker = "extra == 'postgres'", specifier = ">=3.3.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.5" },
|
{ name = "pydantic", specifier = ">=2.12.5" },
|
||||||
@ -977,7 +985,7 @@ requires-dist = [
|
|||||||
{ name = "textual", marker = "extra == 'tui'", specifier = ">=0.80" },
|
{ name = "textual", marker = "extra == 'tui'", specifier = ">=0.80" },
|
||||||
{ name = "tiktoken", specifier = ">=0.8.0" },
|
{ 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]]
|
[[package]]
|
||||||
name = "defusedxml"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
|
|||||||
@ -648,6 +648,7 @@ tool_groups:
|
|||||||
- name: file:read
|
- name: file:read
|
||||||
- name: file:write
|
- name: file:write
|
||||||
- name: bash
|
- name: bash
|
||||||
|
- name: browser
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Tools Configuration
|
# Tools Configuration
|
||||||
@ -801,6 +802,65 @@ tools:
|
|||||||
# # allow_private_addresses: false # SSRF guard: keep false in production. Set true ONLY to
|
# # allow_private_addresses: false # SSRF guard: keep false in production. Set true ONLY to
|
||||||
# # # capture internal/private targets (loopback, RFC1918, etc.)
|
# # # 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)
|
# Web fetch tool (uses Exa)
|
||||||
# NOTE: Only one web_fetch provider can be active at a time.
|
# 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.
|
# 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
|
# 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
|
# Custom API: Other endpoints under /api/threads
|
||||||
location ~ ^/api/threads {
|
location ~ ^/api/threads {
|
||||||
proxy_pass http://$gateway_upstream;
|
proxy_pass http://$gateway_upstream;
|
||||||
|
|||||||
@ -171,6 +171,28 @@ http {
|
|||||||
proxy_cache off;
|
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
|
# Custom API: Other endpoints under /api/threads
|
||||||
location ~ ^/api/threads {
|
location ~ ^/api/threads {
|
||||||
proxy_pass http://gateway;
|
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).
|
- **[#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.
|
**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 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` 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/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/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.
|
- `src/core/threads/hooks.ts` owns pre-submit upload state and thread submission.
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { toast } from "sonner";
|
|||||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||||
|
import { BrowserTrigger } from "@/components/workspace/browser-view";
|
||||||
import {
|
import {
|
||||||
ChatBox,
|
ChatBox,
|
||||||
useSpecificChatMode,
|
useSpecificChatMode,
|
||||||
@ -33,6 +34,7 @@ import { TodoList } from "@/components/workspace/todo-list";
|
|||||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||||
import { useActiveGoal } from "@/components/workspace/use-active-goal";
|
import { useActiveGoal } from "@/components/workspace/use-active-goal";
|
||||||
import { Welcome } from "@/components/workspace/welcome";
|
import { Welcome } from "@/components/workspace/welcome";
|
||||||
|
import { useBrowserControlEnabled } from "@/core/features";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
buildHumanInputResponseText,
|
buildHumanInputResponseText,
|
||||||
@ -68,6 +70,7 @@ export default function ChatPage() {
|
|||||||
const [isWelcomeMode, setIsWelcomeMode] = useState(isNewThread);
|
const [isWelcomeMode, setIsWelcomeMode] = useState(isNewThread);
|
||||||
const [settings, setSettings] = useThreadSettings(threadId);
|
const [settings, setSettings] = useThreadSettings(threadId);
|
||||||
const [localSettings, setLocalSettings] = useLocalSettings();
|
const [localSettings, setLocalSettings] = useLocalSettings();
|
||||||
|
const { enabled: browserControlEnabled } = useBrowserControlEnabled();
|
||||||
const { tokenUsageEnabled } = useModels();
|
const { tokenUsageEnabled } = useModels();
|
||||||
const threadTokenUsage = useThreadTokenUsage(
|
const threadTokenUsage = useThreadTokenUsage(
|
||||||
isNewThread || isMock ? undefined : threadId,
|
isNewThread || isMock ? undefined : threadId,
|
||||||
@ -240,6 +243,7 @@ export default function ChatPage() {
|
|||||||
? localSettings.tokenUsage.inlineMode
|
? localSettings.tokenUsage.inlineMode
|
||||||
: "off";
|
: "off";
|
||||||
const hasTodos = (thread.values.todos?.length ?? 0) > 0;
|
const hasTodos = (thread.values.todos?.length ?? 0) > 0;
|
||||||
|
const browserEnabled = !isNewThread && browserControlEnabled;
|
||||||
const { activeGoal, hasGoal, setLocalGoal } = useActiveGoal(
|
const { activeGoal, hasGoal, setLocalGoal } = useActiveGoal(
|
||||||
threadId,
|
threadId,
|
||||||
thread.values.goal,
|
thread.values.goal,
|
||||||
@ -260,7 +264,7 @@ export default function ChatPage() {
|
|||||||
context={settings.context}
|
context={settings.context}
|
||||||
isMock={isMock}
|
isMock={isMock}
|
||||||
>
|
>
|
||||||
<ChatBox threadId={threadId}>
|
<ChatBox threadId={threadId} browserEnabled={browserEnabled}>
|
||||||
<div className="relative flex size-full min-h-0 justify-between">
|
<div className="relative flex size-full min-h-0 justify-between">
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -290,6 +294,7 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<SidecarTrigger />
|
<SidecarTrigger />
|
||||||
|
{browserEnabled && <BrowserTrigger />}
|
||||||
<ExportTrigger threadId={threadId} />
|
<ExportTrigger threadId={threadId} />
|
||||||
<ArtifactTrigger />
|
<ArtifactTrigger />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||||
|
import { BrowserViewProvider } from "@/components/workspace/browser-view";
|
||||||
import { SubtasksProvider } from "@/core/tasks/context";
|
import { SubtasksProvider } from "@/core/tasks/context";
|
||||||
|
|
||||||
export function ChatProviders({ children }: { children: React.ReactNode }) {
|
export function ChatProviders({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SubtasksProvider>
|
<SubtasksProvider>
|
||||||
<ArtifactsProvider>
|
<ArtifactsProvider>
|
||||||
<PromptInputProvider>{children}</PromptInputProvider>
|
<BrowserViewProvider>
|
||||||
|
<PromptInputProvider>{children}</PromptInputProvider>
|
||||||
|
</BrowserViewProvider>
|
||||||
</ArtifactsProvider>
|
</ArtifactsProvider>
|
||||||
</SubtasksProvider>
|
</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 { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@ -20,17 +25,19 @@ import {
|
|||||||
ArtifactFileList,
|
ArtifactFileList,
|
||||||
useArtifacts,
|
useArtifacts,
|
||||||
} from "../artifacts";
|
} from "../artifacts";
|
||||||
|
import { BrowserViewPanel, useMaybeBrowserView } from "../browser-view";
|
||||||
import { useThread } from "../messages/context";
|
import { useThread } from "../messages/context";
|
||||||
import { SidecarPanel, useMaybeSidecar } from "../sidecar";
|
import { SidecarPanel, useMaybeSidecar } from "../sidecar";
|
||||||
|
|
||||||
const RIGHT_PANEL_ANIMATION_MS = 280;
|
const RIGHT_PANEL_ANIMATION_MS = 280;
|
||||||
|
|
||||||
type RightPanelKind = "sidecar" | "artifacts";
|
type RightPanelKind = "sidecar" | "artifacts" | "browser";
|
||||||
|
|
||||||
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
const ChatBox: React.FC<{
|
||||||
children,
|
children: React.ReactNode;
|
||||||
threadId,
|
threadId: string;
|
||||||
}) => {
|
browserEnabled?: boolean;
|
||||||
|
}> = ({ children, threadId, browserEnabled = true }) => {
|
||||||
const { thread } = useThread();
|
const { thread } = useThread();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@ -47,6 +54,8 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
} = useArtifacts();
|
} = useArtifacts();
|
||||||
const sidecar = useMaybeSidecar();
|
const sidecar = useMaybeSidecar();
|
||||||
const sidecarOpen = sidecar?.open ?? false;
|
const sidecarOpen = sidecar?.open ?? false;
|
||||||
|
const browserView = useMaybeBrowserView();
|
||||||
|
const browserViewOpen = browserEnabled && (browserView?.open ?? false);
|
||||||
|
|
||||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -104,9 +113,11 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
|
|
||||||
const activeRightPanel: RightPanelKind | null = sidecarOpen
|
const activeRightPanel: RightPanelKind | null = sidecarOpen
|
||||||
? "sidecar"
|
? "sidecar"
|
||||||
: artifactPanelOpen
|
: browserViewOpen
|
||||||
? "artifacts"
|
? "browser"
|
||||||
: null;
|
: artifactPanelOpen
|
||||||
|
? "artifacts"
|
||||||
|
: null;
|
||||||
const rightPanelOpen = activeRightPanel !== null;
|
const rightPanelOpen = activeRightPanel !== null;
|
||||||
const [renderedRightPanel, setRenderedRightPanel] =
|
const [renderedRightPanel, setRenderedRightPanel] =
|
||||||
useState<RightPanelKind | null>(activeRightPanel);
|
useState<RightPanelKind | null>(activeRightPanel);
|
||||||
@ -136,7 +147,16 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
}
|
}
|
||||||
}, [artifactsOpen, setArtifactsOpen, sidecarOpen]);
|
}, [artifactsOpen, setArtifactsOpen, sidecarOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!browserEnabled && browserView?.open) {
|
||||||
|
browserView.close();
|
||||||
|
}
|
||||||
|
}, [browserEnabled, browserView]);
|
||||||
|
|
||||||
const rightPanelContent = useMemo(() => {
|
const rightPanelContent = useMemo(() => {
|
||||||
|
if (renderedRightPanel === "browser") {
|
||||||
|
return <BrowserViewPanel threadId={threadId} className="size-full" />;
|
||||||
|
}
|
||||||
if (renderedRightPanel === "sidecar") {
|
if (renderedRightPanel === "sidecar") {
|
||||||
return <SidecarPanel />;
|
return <SidecarPanel />;
|
||||||
}
|
}
|
||||||
@ -208,6 +228,9 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
if (sidecarOpen) {
|
if (sidecarOpen) {
|
||||||
sidecar?.close();
|
sidecar?.close();
|
||||||
}
|
}
|
||||||
|
if (browserViewOpen) {
|
||||||
|
browserView?.close();
|
||||||
|
}
|
||||||
if (artifactsOpen) {
|
if (artifactsOpen) {
|
||||||
setArtifactsOpen(false);
|
setArtifactsOpen(false);
|
||||||
}
|
}
|
||||||
@ -219,7 +242,11 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
>
|
>
|
||||||
<SheetHeader className="sr-only">
|
<SheetHeader className="sr-only">
|
||||||
<SheetTitle>
|
<SheetTitle>
|
||||||
{renderedRightPanel === "sidecar" ? "Sidecar" : "Artifacts"}
|
{renderedRightPanel === "sidecar"
|
||||||
|
? "Sidecar"
|
||||||
|
: renderedRightPanel === "browser"
|
||||||
|
? "Browser"
|
||||||
|
: "Artifacts"}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Browse the side panel for this conversation.
|
Browse the side panel for this conversation.
|
||||||
@ -236,41 +263,79 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
<div
|
<div
|
||||||
id={`${resizableIdBase}-panels`}
|
id={`${resizableIdBase}-panels`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"[container-type:inline-size] grid size-full min-h-0 transition-[grid-template-columns] duration-[280ms] ease-out motion-reduce:transition-none",
|
"[container-type:inline-size] size-full min-h-0",
|
||||||
rightPanelOpen
|
activeRightPanel !== "browser" &&
|
||||||
? "grid-cols-[minmax(0,1fr)_1px_minmax(0,40%)]"
|
"grid transition-[grid-template-columns] duration-[280ms] ease-out motion-reduce:transition-none",
|
||||||
: "grid-cols-[minmax(0,1fr)_0px_0px]",
|
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">
|
{activeRightPanel === "browser" ? (
|
||||||
{children}
|
<ResizablePanelGroup
|
||||||
</div>
|
id={`${resizableIdBase}-group`}
|
||||||
<div
|
orientation="horizontal"
|
||||||
id={`${resizableIdBase}-separator`}
|
className="size-full min-h-0"
|
||||||
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}
|
<ResizablePanel
|
||||||
</div>
|
id={`${resizableIdBase}-chat`}
|
||||||
</aside>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,7 +6,11 @@ import {
|
|||||||
isValidElement,
|
isValidElement,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useContext,
|
useContext,
|
||||||
|
useDeferredValue,
|
||||||
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { type ClipboardSafeStreamdownProps } from "@/components/ai-elements/streamdown";
|
import { type ClipboardSafeStreamdownProps } from "@/components/ai-elements/streamdown";
|
||||||
@ -33,8 +37,125 @@ type StreamingCodeProps = ComponentProps<"code"> & {
|
|||||||
children?: ReactNode;
|
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);
|
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">) {
|
function StreamingPre({ children }: ComponentProps<"pre">) {
|
||||||
const childClassName = isValidElement<{ className?: string }>(children)
|
const childClassName = isValidElement<{ className?: string }>(children)
|
||||||
? children.props.className
|
? children.props.className
|
||||||
@ -101,9 +222,16 @@ export function MarkdownContent({
|
|||||||
remarkPlugins = streamdownPluginsWithoutRawHtml.remarkPlugins,
|
remarkPlugins = streamdownPluginsWithoutRawHtml.remarkPlugins,
|
||||||
components: componentsFromProps,
|
components: componentsFromProps,
|
||||||
}: MarkdownContentProps) {
|
}: MarkdownContentProps) {
|
||||||
|
const deferredContent = useDeferredValue(content);
|
||||||
|
const targetContent = isLoading ? deferredContent : content;
|
||||||
|
const { content: displayContent, isRevealing } = useSmoothStreamingContent(
|
||||||
|
targetContent,
|
||||||
|
isLoading,
|
||||||
|
);
|
||||||
|
const isStreamingRender = isLoading || isRevealing;
|
||||||
const normalizedContent = useMemo(
|
const normalizedContent = useMemo(
|
||||||
() => preprocessStreamdownMarkdown(content),
|
() => preprocessStreamdownMarkdown(displayContent),
|
||||||
[content],
|
[displayContent],
|
||||||
);
|
);
|
||||||
const effectiveRehypePlugins = useMemo(() => {
|
const effectiveRehypePlugins = useMemo(() => {
|
||||||
const base = streamdownPluginsWithoutRawHtml.rehypePlugins ?? [];
|
const base = streamdownPluginsWithoutRawHtml.rehypePlugins ?? [];
|
||||||
@ -115,7 +243,7 @@ export function MarkdownContent({
|
|||||||
a: createMarkdownLinkComponent(),
|
a: createMarkdownLinkComponent(),
|
||||||
...componentsFromProps,
|
...componentsFromProps,
|
||||||
};
|
};
|
||||||
if (!isLoading) {
|
if (!isStreamingRender) {
|
||||||
return baseComponents;
|
return baseComponents;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@ -123,9 +251,9 @@ export function MarkdownContent({
|
|||||||
code: componentsFromProps?.code ?? StreamingCode,
|
code: componentsFromProps?.code ?? StreamingCode,
|
||||||
pre: componentsFromProps?.pre ?? StreamingPre,
|
pre: componentsFromProps?.pre ?? StreamingPre,
|
||||||
};
|
};
|
||||||
}, [componentsFromProps, isLoading]);
|
}, [componentsFromProps, isStreamingRender]);
|
||||||
|
|
||||||
if (!content) return null;
|
if (!displayContent) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeMessageResponse
|
<SafeMessageResponse
|
||||||
@ -133,7 +261,7 @@ export function MarkdownContent({
|
|||||||
remarkPlugins={remarkPlugins}
|
remarkPlugins={remarkPlugins}
|
||||||
rehypePlugins={effectiveRehypePlugins}
|
rehypePlugins={effectiveRehypePlugins}
|
||||||
components={components}
|
components={components}
|
||||||
parseIncompleteMarkdown={isLoading}
|
parseIncompleteMarkdown={isStreamingRender}
|
||||||
>
|
>
|
||||||
{normalizedContent}
|
{normalizedContent}
|
||||||
</SafeMessageResponse>
|
</SafeMessageResponse>
|
||||||
|
|||||||
@ -9,12 +9,13 @@ import {
|
|||||||
ListTodoIcon,
|
ListTodoIcon,
|
||||||
MessageCircleQuestionMarkIcon,
|
MessageCircleQuestionMarkIcon,
|
||||||
MessageSquareTextIcon,
|
MessageSquareTextIcon,
|
||||||
|
MonitorIcon,
|
||||||
NotebookPenIcon,
|
NotebookPenIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
SquareTerminalIcon,
|
SquareTerminalIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { memo, useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChainOfThought,
|
ChainOfThought,
|
||||||
@ -25,6 +26,7 @@ import {
|
|||||||
} from "@/components/ai-elements/chain-of-thought";
|
} from "@/components/ai-elements/chain-of-thought";
|
||||||
import { CodeBlock } from "@/components/ai-elements/code-block";
|
import { CodeBlock } from "@/components/ai-elements/code-block";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { formatTokenCount } from "@/core/messages/usage";
|
import { formatTokenCount } from "@/core/messages/usage";
|
||||||
import type { TokenDebugStep } from "@/core/messages/usage-model";
|
import type { TokenDebugStep } from "@/core/messages/usage-model";
|
||||||
@ -39,24 +41,31 @@ import { env } from "@/env";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { useArtifacts } from "../artifacts";
|
import { useArtifacts } from "../artifacts";
|
||||||
|
import { useMaybeBrowserView } from "../browser-view";
|
||||||
import { FlipDisplay } from "../flip-display";
|
import { FlipDisplay } from "../flip-display";
|
||||||
import { Tooltip } from "../tooltip";
|
import { Tooltip } from "../tooltip";
|
||||||
|
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
|
|
||||||
export function MessageGroup({
|
interface MessageGroupProps {
|
||||||
className,
|
|
||||||
messages,
|
|
||||||
isLoading = false,
|
|
||||||
tokenDebugSteps = [],
|
|
||||||
showTokenDebugSummaries = false,
|
|
||||||
}: {
|
|
||||||
className?: string;
|
className?: string;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
deferBrowserPreviews?: boolean;
|
||||||
tokenDebugSteps?: TokenDebugStep[];
|
tokenDebugSteps?: TokenDebugStep[];
|
||||||
showTokenDebugSummaries?: boolean;
|
showTokenDebugSummaries?: boolean;
|
||||||
}) {
|
threadId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageGroupComponent({
|
||||||
|
className,
|
||||||
|
messages,
|
||||||
|
isLoading = false,
|
||||||
|
deferBrowserPreviews = false,
|
||||||
|
tokenDebugSteps = [],
|
||||||
|
showTokenDebugSummaries = false,
|
||||||
|
threadId,
|
||||||
|
}: MessageGroupProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [showAbove, setShowAbove] = useState(
|
const [showAbove, setShowAbove] = useState(
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||||
@ -218,8 +227,10 @@ export function MessageGroup({
|
|||||||
<ToolCall
|
<ToolCall
|
||||||
key={step.id}
|
key={step.id}
|
||||||
{...step}
|
{...step}
|
||||||
|
threadId={threadId}
|
||||||
isLast={options?.isLast}
|
isLast={options?.isLast}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
deferBrowserPreview={deferBrowserPreviews}
|
||||||
tokenDebugStep={
|
tokenDebugStep={
|
||||||
debugStep && !debugStep.sharedAttribution ? debugStep : undefined
|
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(
|
function formatDebugToken(
|
||||||
debugStep: TokenDebugStep,
|
debugStep: TokenDebugStep,
|
||||||
t: ReturnType<typeof useI18n>["t"],
|
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({
|
function ToolCall({
|
||||||
id,
|
id,
|
||||||
messageId,
|
messageId,
|
||||||
@ -453,7 +534,10 @@ function ToolCall({
|
|||||||
result,
|
result,
|
||||||
isLast = false,
|
isLast = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
deferBrowserPreview = false,
|
||||||
tokenDebugStep,
|
tokenDebugStep,
|
||||||
|
browserView,
|
||||||
|
threadId,
|
||||||
}: {
|
}: {
|
||||||
id?: string;
|
id?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
@ -462,11 +546,15 @@ function ToolCall({
|
|||||||
result?: string | Record<string, unknown>;
|
result?: string | Record<string, unknown>;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
deferBrowserPreview?: boolean;
|
||||||
tokenDebugStep?: TokenDebugStep;
|
tokenDebugStep?: TokenDebugStep;
|
||||||
|
browserView?: BrowserViewMeta;
|
||||||
|
threadId?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
|
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
|
||||||
useArtifacts();
|
useArtifacts();
|
||||||
|
const browserViewPanel = useMaybeBrowserView();
|
||||||
const tokenLabel = tokenDebugStep
|
const tokenLabel = tokenDebugStep
|
||||||
? formatDebugToken(tokenDebugStep, t)
|
? formatDebugToken(tokenDebugStep, t)
|
||||||
: null;
|
: null;
|
||||||
@ -477,7 +565,54 @@ function ToolCall({
|
|||||||
fallback
|
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;
|
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||||
if (typeof args.query === "string") {
|
if (typeof args.query === "string") {
|
||||||
label = t.toolCalls.searchOnWebFor(args.query);
|
label = t.toolCalls.searchOnWebFor(args.query);
|
||||||
@ -732,6 +867,7 @@ interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
|||||||
name: string;
|
name: string;
|
||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
result?: string;
|
result?: string;
|
||||||
|
browserView?: BrowserViewMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CoTAssistantTextStep extends GenericCoTStep<"assistantText"> {
|
interface CoTAssistantTextStep extends GenericCoTStep<"assistantText"> {
|
||||||
@ -740,6 +876,31 @@ interface CoTAssistantTextStep extends GenericCoTStep<"assistantText"> {
|
|||||||
|
|
||||||
type CoTStep = CoTAssistantTextStep | CoTReasoningStep | CoTToolCallStep;
|
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[] {
|
function convertToSteps(messages: Message[]): CoTStep[] {
|
||||||
const steps: CoTStep[] = [];
|
const steps: CoTStep[] = [];
|
||||||
for (const [messageIndex, message] of messages.entries()) {
|
for (const [messageIndex, message] of messages.entries()) {
|
||||||
@ -785,6 +946,7 @@ function convertToSteps(messages: Message[]): CoTStep[] {
|
|||||||
step.result = toolCallResult;
|
step.result = toolCallResult;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
step.browserView = findBrowserViewMeta(toolCallId, messages);
|
||||||
}
|
}
|
||||||
steps.push(step);
|
steps.push(step);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -212,14 +212,30 @@ function MessageImage({
|
|||||||
const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`);
|
const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`);
|
||||||
|
|
||||||
if (typeof src !== "string") {
|
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);
|
const url = resolveMessageImageURL(src, threadId, artifactPaths);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
<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>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -611,6 +627,8 @@ function RichFileCard({
|
|||||||
<img
|
<img
|
||||||
src={fileUrl}
|
src={fileUrl}
|
||||||
alt={file.filename}
|
alt={file.filename}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
|
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -39,6 +39,7 @@ import {
|
|||||||
} from "@/core/messages/human-input";
|
} from "@/core/messages/human-input";
|
||||||
import {
|
import {
|
||||||
buildTokenDebugSteps,
|
buildTokenDebugSteps,
|
||||||
|
type TokenDebugStep,
|
||||||
type TokenUsageInlineMode,
|
type TokenUsageInlineMode,
|
||||||
} from "@/core/messages/usage-model";
|
} from "@/core/messages/usage-model";
|
||||||
import {
|
import {
|
||||||
@ -55,6 +56,7 @@ import {
|
|||||||
hasReasoning,
|
hasReasoning,
|
||||||
isAssistantMessageGroupStreaming,
|
isAssistantMessageGroupStreaming,
|
||||||
isHiddenFromUIMessage,
|
isHiddenFromUIMessage,
|
||||||
|
type MessageGroup as ThreadMessageGroup,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
import {
|
import {
|
||||||
@ -71,6 +73,7 @@ import type { AgentThreadState } from "@/core/threads";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||||
|
import { useMaybeBrowserView } from "../browser-view";
|
||||||
import { CopyButton } from "../copy-button";
|
import { CopyButton } from "../copy-button";
|
||||||
import { useMaybeSidecar } from "../sidecar/context";
|
import { useMaybeSidecar } from "../sidecar/context";
|
||||||
import { Tooltip } from "../tooltip";
|
import { Tooltip } from "../tooltip";
|
||||||
@ -89,6 +92,101 @@ import {
|
|||||||
import { MessageListSkeleton } from "./skeleton";
|
import { MessageListSkeleton } from "./skeleton";
|
||||||
import { SubtaskCard } from "./subtask-card";
|
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;
|
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 24;
|
||||||
|
|
||||||
const LOAD_MORE_HISTORY_THROTTLE_MS = 1200;
|
const LOAD_MORE_HISTORY_THROTTLE_MS = 1200;
|
||||||
@ -273,7 +371,47 @@ export function MessageList({
|
|||||||
prevIsLoading.current = thread.isLoading;
|
prevIsLoading.current = thread.isLoading;
|
||||||
}, [thread.isLoading]);
|
}, [thread.isLoading]);
|
||||||
const messages = thread.messages;
|
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<
|
const [regeneratingMessageId, setRegeneratingMessageId] = useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
@ -302,8 +440,47 @@ export function MessageList({
|
|||||||
const turnUsageMessagesByGroupIndex =
|
const turnUsageMessagesByGroupIndex =
|
||||||
getAssistantTurnUsageMessages(groupedMessages);
|
getAssistantTurnUsageMessages(groupedMessages);
|
||||||
const tokenDebugSteps = useMemo(
|
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(
|
const streamingMessages = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -674,7 +851,7 @@ export function MessageList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokenUsageInlineMode === "step_debug" && inlineDebug) {
|
if (showTokenDebugSummaries && inlineDebug) {
|
||||||
const messageIds = new Set(
|
const messageIds = new Set(
|
||||||
debugMessageIds ??
|
debugMessageIds ??
|
||||||
messages
|
messages
|
||||||
@ -695,15 +872,22 @@ export function MessageList({
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[thread.isLoading, tokenDebugSteps, tokenUsageInlineMode],
|
[
|
||||||
|
showTokenDebugSummaries,
|
||||||
|
thread.isLoading,
|
||||||
|
tokenDebugSteps,
|
||||||
|
tokenUsageInlineMode,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const artifactPaths = useStableArtifactPaths(
|
||||||
|
extractArtifactsFromThread(thread),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (thread.isThreadLoading && messages.length === 0) {
|
if (thread.isThreadLoading && messages.length === 0) {
|
||||||
return <MessageListSkeleton />;
|
return <MessageListSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const artifactPaths = extractArtifactsFromThread(thread);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Conversation
|
<Conversation
|
||||||
@ -949,12 +1133,9 @@ export function MessageList({
|
|||||||
key={"thinking-group-" + message.id}
|
key={"thinking-group-" + message.id}
|
||||||
messages={[message]}
|
messages={[message]}
|
||||||
isLoading={groupIsLoading}
|
isLoading={groupIsLoading}
|
||||||
tokenDebugSteps={tokenDebugSteps.filter(
|
deferBrowserPreviews={thread.isLoading}
|
||||||
(step) => step.messageId === message.id,
|
tokenDebugSteps={getTokenDebugStepsForMessages([message])}
|
||||||
)}
|
showTokenDebugSummaries={showTokenDebugSummaries}
|
||||||
showTokenDebugSummaries={
|
|
||||||
tokenUsageInlineMode === "step_debug"
|
|
||||||
}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
} else if (message.id) {
|
} else if (message.id) {
|
||||||
@ -993,15 +1174,13 @@ export function MessageList({
|
|||||||
<div key={"group-" + group.id} className="w-full">
|
<div key={"group-" + group.id} className="w-full">
|
||||||
<MessageGroup
|
<MessageGroup
|
||||||
messages={group.messages}
|
messages={group.messages}
|
||||||
isLoading={thread.isLoading}
|
isLoading={groupIsLoading}
|
||||||
tokenDebugSteps={tokenDebugSteps.filter((step) =>
|
deferBrowserPreviews={thread.isLoading}
|
||||||
group.messages.some(
|
threadId={threadId}
|
||||||
(message) => message.id === step.messageId,
|
tokenDebugSteps={getTokenDebugStepsForMessages(
|
||||||
),
|
group.messages,
|
||||||
)}
|
)}
|
||||||
showTokenDebugSummaries={
|
showTokenDebugSummaries={showTokenDebugSummaries}
|
||||||
tokenUsageInlineMode === "step_debug"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{renderTokenUsage({
|
{renderTokenUsage({
|
||||||
messages: group.messages,
|
messages: group.messages,
|
||||||
|
|||||||
@ -81,10 +81,16 @@ export function RecentChatList() {
|
|||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = useInfiniteThreads();
|
} = useInfiniteThreads();
|
||||||
const threads = useMemo(
|
const threads = useMemo(() => {
|
||||||
() => infiniteThreads?.pages.flat() ?? [],
|
const seen = new Set<string>();
|
||||||
[infiniteThreads],
|
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);
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -550,7 +550,7 @@ export function SidecarPanel({ className }: { className?: string }) {
|
|||||||
: t.sidecar.noContext}
|
: t.sidecar.noContext}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasSidecarThread ? (
|
{hasSidecarThread && (
|
||||||
<Tooltip content={t.sidecar.delete}>
|
<Tooltip content={t.sidecar.delete}>
|
||||||
<Button
|
<Button
|
||||||
aria-label={t.sidecar.delete}
|
aria-label={t.sidecar.delete}
|
||||||
@ -563,22 +563,21 @@ export function SidecarPanel({ className }: { className?: string }) {
|
|||||||
<Trash2Icon />
|
<Trash2Icon />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</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>
|
</header>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { fetch } from "@/core/api/fetcher";
|
import { fetch } from "@/core/api/fetcher";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
export { fetchAgentsApiEnabled } from "@/core/features/api";
|
||||||
|
|
||||||
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
|
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}`);
|
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(
|
export async function checkAgentName(
|
||||||
name: string,
|
name: string,
|
||||||
): Promise<{ available: boolean; 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",
|
regenerate: "Regenerate",
|
||||||
branch: "Branch conversation",
|
branch: "Branch conversation",
|
||||||
showArtifacts: "Show artifacts of this conversation",
|
showArtifacts: "Show artifacts of this conversation",
|
||||||
|
browser: "Browser",
|
||||||
|
showBrowser: "Open browser panel",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Home
|
// Home
|
||||||
@ -567,6 +569,15 @@ export const enUS: Translations = {
|
|||||||
clickToViewContent: "Click to view file content",
|
clickToViewContent: "Click to view file content",
|
||||||
writeTodos: "Update to-do list",
|
writeTodos: "Update to-do list",
|
||||||
skillInstallTooltip: "Install skill and make it available to DeerFlow",
|
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: {
|
humanInput: {
|
||||||
|
|||||||
@ -42,6 +42,8 @@ export interface Translations {
|
|||||||
regenerate: string;
|
regenerate: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
showArtifacts: string;
|
showArtifacts: string;
|
||||||
|
browser: string;
|
||||||
|
showBrowser: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
home: {
|
home: {
|
||||||
@ -457,6 +459,15 @@ export interface Translations {
|
|||||||
clickToViewContent: string;
|
clickToViewContent: string;
|
||||||
writeTodos: string;
|
writeTodos: string;
|
||||||
skillInstallTooltip: string;
|
skillInstallTooltip: string;
|
||||||
|
browserNavigate: (url: string) => string;
|
||||||
|
browserNavigateGeneric: string;
|
||||||
|
browserClick: string;
|
||||||
|
browserType: string;
|
||||||
|
browserSnapshot: string;
|
||||||
|
browserGetText: string;
|
||||||
|
browserBack: string;
|
||||||
|
browserScreenshot: string;
|
||||||
|
browserClose: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
humanInput: {
|
humanInput: {
|
||||||
|
|||||||
@ -53,6 +53,8 @@ export const zhCN: Translations = {
|
|||||||
regenerate: "重新生成",
|
regenerate: "重新生成",
|
||||||
branch: "分叉",
|
branch: "分叉",
|
||||||
showArtifacts: "查看此对话的文件",
|
showArtifacts: "查看此对话的文件",
|
||||||
|
browser: "浏览器",
|
||||||
|
showBrowser: "打开浏览器面板",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Home
|
// Home
|
||||||
@ -545,6 +547,15 @@ export const zhCN: Translations = {
|
|||||||
clickToViewContent: "点击查看文件内容",
|
clickToViewContent: "点击查看文件内容",
|
||||||
writeTodos: "更新 To-do 列表",
|
writeTodos: "更新 To-do 列表",
|
||||||
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
|
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
|
||||||
|
browserNavigate: (url: string) => `在浏览器中打开 ${url}`,
|
||||||
|
browserNavigateGeneric: "在浏览器中打开页面",
|
||||||
|
browserClick: "在浏览器中点击元素",
|
||||||
|
browserType: "在浏览器中输入内容",
|
||||||
|
browserSnapshot: "在浏览器中读取页面",
|
||||||
|
browserGetText: "在浏览器中读取页面文本",
|
||||||
|
browserBack: "在浏览器中返回上一页",
|
||||||
|
browserScreenshot: "浏览器截图",
|
||||||
|
browserClose: "关闭浏览器",
|
||||||
},
|
},
|
||||||
|
|
||||||
humanInput: {
|
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;
|
--animate-fade-in-up: fade-in-up 0.15s ease-in-out forwards;
|
||||||
@keyframes fade-in-up {
|
@keyframes fade-in-up {
|
||||||
0% {
|
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_file_size: number;
|
||||||
max_total_size: number;
|
max_total_size: number;
|
||||||
};
|
};
|
||||||
|
features?: {
|
||||||
|
agentsApiEnabled?: boolean;
|
||||||
|
browserControlEnabled?: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SKILLS: MockSkill[] = [
|
const DEFAULT_SKILLS: MockSkill[] = [
|
||||||
@ -126,6 +130,16 @@ function visibleInputMessages(messages: unknown[]) {
|
|||||||
return messages.filter((message) => !isHiddenInputMessage(message));
|
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) {
|
function visibleRunInputMessages(route: Route) {
|
||||||
try {
|
try {
|
||||||
const body = route.request().postDataJSON() as {
|
const body = route.request().postDataJSON() as {
|
||||||
@ -244,6 +258,10 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
max_file_size: 50 * 1024 * 1024,
|
max_file_size: 50 * 1024 * 1024,
|
||||||
max_total_size: 100 * 1024 * 1024,
|
max_total_size: 100 * 1024 * 1024,
|
||||||
};
|
};
|
||||||
|
const featureFlags = {
|
||||||
|
agentsApiEnabled: options?.features?.agentsApiEnabled ?? true,
|
||||||
|
browserControlEnabled: options?.features?.browserControlEnabled ?? true,
|
||||||
|
};
|
||||||
|
|
||||||
const upsertThread = (thread: MockThread) => {
|
const upsertThread = (thread: MockThread) => {
|
||||||
threads = [
|
threads = [
|
||||||
@ -962,7 +980,10 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
data: (matchingThread?.messages ?? []).map((message, index) => ({
|
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,
|
seq: index + 1,
|
||||||
content: message,
|
content: message,
|
||||||
metadata: { caller: "lead_agent" },
|
metadata: { caller: "lead_agent" },
|
||||||
@ -1016,7 +1037,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
return route.fallback();
|
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
|
// enabled so existing tests exercise the normal path; tests that need the
|
||||||
// disabled state override this route after calling mockLangGraphAPI.
|
// disabled state override this route after calling mockLangGraphAPI.
|
||||||
void page.route("**/api/features", (route) => {
|
void page.route("**/api/features", (route) => {
|
||||||
@ -1024,7 +1045,10 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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();
|
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-custom-code="true"');
|
||||||
expect(html).toContain("data-streaming-code-block");
|
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", () => {
|
describe("MarkdownContent strikethrough", () => {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { Message } from "@langchain/langgraph-sdk";
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
import { describe, expect, it, rs } from "@rstest/core";
|
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 { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
import { MessageGroup } from "@/components/workspace/messages/message-group";
|
import { MessageGroup } from "@/components/workspace/messages/message-group";
|
||||||
@ -121,9 +121,55 @@ describe("MessageGroup", () => {
|
|||||||
expect(html).toContain("1 more step");
|
expect(html).toContain("1 more step");
|
||||||
expect(html).not.toContain("Check how processing groups convert messages.");
|
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(
|
return renderToStaticMarkup(
|
||||||
createElement(
|
createElement(
|
||||||
I18nContext.Provider,
|
I18nContext.Provider,
|
||||||
@ -133,7 +179,7 @@ function renderGroup(messages: Message[]) {
|
|||||||
setLocale: () => undefined,
|
setLocale: () => undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
createElement(MessageGroup, { messages }),
|
createElement(MessageGroup, { ...props, messages }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ rs.mock("@/core/config", () => ({
|
|||||||
|
|
||||||
import { fetchAgentsApiEnabled } from "@/core/agents/api";
|
import { fetchAgentsApiEnabled } from "@/core/agents/api";
|
||||||
import { fetch as fetcher } from "@/core/api/fetcher";
|
import { fetch as fetcher } from "@/core/api/fetcher";
|
||||||
|
import { fetchBrowserControlEnabled } from "@/core/features/api";
|
||||||
|
|
||||||
const mockedFetch = rs.mocked(fetcher);
|
const mockedFetch = rs.mocked(fetcher);
|
||||||
|
|
||||||
@ -45,3 +46,36 @@ describe("fetchAgentsApiEnabled", () => {
|
|||||||
await expect(fetchAgentsApiEnabled()).rejects.toThrow();
|
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
|
- database.backend == postgres -> postgres
|
||||||
- checkpointer.type == postgres -> postgres
|
- checkpointer.type == postgres -> postgres
|
||||||
- stream_bridge.type == redis -> redis
|
- stream_bridge.type == redis -> redis
|
||||||
|
- tools[].name == browser_navigate -> browser
|
||||||
- sandbox.ownership.type == redis -> redis
|
- sandbox.ownership.type == redis -> redis
|
||||||
3. Runtime environment toggles that enable optional backends:
|
3. Runtime environment toggles that enable optional backends:
|
||||||
- DEER_FLOW_STREAM_BRIDGE_REDIS_URL -> redis
|
- 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*$")
|
_SECTION_RE = re.compile(r"^([A-Za-z_][\w-]*)\s*:\s*$")
|
||||||
_INDENTED_SECTION_RE = re.compile(r"^\s+([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*$")
|
_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:
|
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
|
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]:
|
def detect_from_config(path: Path) -> list[str]:
|
||||||
try:
|
try:
|
||||||
text = path.read_text(encoding="utf-8", errors="replace")
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
@ -239,6 +265,8 @@ def detect_from_config(path: Path) -> list[str]:
|
|||||||
extras.add("redis")
|
extras.add("redis")
|
||||||
if (nested_section_value(lines, "channels.discord", "enabled") or "").lower() == "true":
|
if (nested_section_value(lines, "channels.discord", "enabled") or "").lower() == "true":
|
||||||
extras.add("discord")
|
extras.add("discord")
|
||||||
|
if tools_include_name(lines, "browser_navigate"):
|
||||||
|
extras.add("browser")
|
||||||
return sorted(extras)
|
return sorted(extras)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user