From 3ea74d67a572e97f32c61a2f9f1bf267b1166544 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 14:21:27 -0700 Subject: [PATCH 001/117] [eric] web: bound the search/fetch cascade with one wall-clock deadline so the later tiers are reachable --- backend/apps/agents/web_mcp_server.py | 10 +- backend/apps/web/cascade.py | 62 ++ backend/apps/web/grounded.py | 265 +++++++++ backend/apps/web/web.py | 699 ++++++----------------- backend/tests/test_web_cascade_budget.py | 131 +++++ backend/tests/test_web_search_cascade.py | 50 +- 6 files changed, 680 insertions(+), 537 deletions(-) create mode 100644 backend/apps/web/cascade.py create mode 100644 backend/apps/web/grounded.py create mode 100644 backend/tests/test_web_cascade_budget.py diff --git a/backend/apps/agents/web_mcp_server.py b/backend/apps/agents/web_mcp_server.py index 973550ad..662fa686 100755 --- a/backend/apps/agents/web_mcp_server.py +++ b/backend/apps/agents/web_mcp_server.py @@ -87,7 +87,11 @@ def send_response(id_, result=None, error=None): sys.stdout.flush() -def p_post(url: str, body: dict, timeout: float = 60.0) -> dict: +# The backend bounds its own cascade at 60s and always answers within it (with an honest "here is why every backend failed" body). Waiting slightly longer than that means the useful answer wins; the old 45s cap aborted the call BEFORE the later tiers could even be reached, so search reliability came down to whether an early tier won the race. +TOOL_TIMEOUT = 75.0 + + +def p_post(url: str, body: dict, timeout: float = TOOL_TIMEOUT) -> dict: payload = json.dumps(body).encode() headers = {"Content-Type": "application/json"} if BACKEND_AUTH: @@ -118,7 +122,7 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict: body = {"query": query, "num_results": num, "browser_ok": BROWSER_OK} if PRIMARY_HINT: body["primary"] = PRIMARY_HINT - r = p_post(SEARCH_URL, body, timeout=45.0) + r = p_post(SEARCH_URL, body, timeout=TOOL_TIMEOUT) if "error" in r: return {"content": [{"type": "text", "text": f"Search failed: {r['error']}"}], "isError": True} results = r.get("results", "") @@ -140,7 +144,7 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict: body["prompt"] = str(prompt) if PRIMARY_HINT: body["primary"] = PRIMARY_HINT - r = p_post(FETCH_URL, body, timeout=45.0) + r = p_post(FETCH_URL, body, timeout=TOOL_TIMEOUT) if "error" in r: return {"content": [{"type": "text", "text": f"Fetch failed: {r['error']}"}], "isError": True} content = r.get("content", "") diff --git a/backend/apps/web/cascade.py b/backend/apps/web/cascade.py new file mode 100644 index 00000000..16229271 --- /dev/null +++ b/backend/apps/web/cascade.py @@ -0,0 +1,62 @@ +"""Deadline-bounded tier runner shared by /api/web/search and /api/web/fetch. + +The cascades used to sum their per-tier leashes to 244s (search) and 270s +(fetch) while the MCP shim calling them gave up at 45s, so the later tiers +could never run at all: whether a search worked came down to whether an early +tier happened to win the race before the client-side guillotine. One wall-clock +deadline for the whole cascade makes that unrepresentable. A tier can only ever +spend what is LEFT of the budget, so a slow tier cannot starve the ones behind +it, and the endpoint always answers within the deadline.""" + +import asyncio +from typing import Awaitable, Callable, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field, InstanceOf +from typeguard import typechecked + +# A tier handed less than this has no realistic chance, and reporting it as a timeout would be a lie; we say the budget ran out instead. +MIN_TIER_SECONDS = 3.0 + + +class CascadeTier(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + name: str + run: InstanceOf[Callable[[], Awaitable[Optional[Dict]]]] + budget: float + + +class CascadeOutcome(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + result: Optional[Dict] = None + errors: List[str] = Field(default_factory=list) + + +@typechecked +async def run_cascade(tiers: List[CascadeTier], total_budget: float) -> CascadeOutcome: + """Run tiers in order until one returns a result or the budget is gone.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + total_budget + errors: List[str] = [] + + for tier in tiers: + remaining = deadline - loop.time() + if remaining < MIN_TIER_SECONDS: + skipped = [t.name for t in tiers[tiers.index(tier):]] + errors.append( + f"{total_budget:.0f}s cascade budget spent; not attempted: {', '.join(skipped)}" + ) + break + slice_seconds = min(tier.budget, remaining) + try: + result = await asyncio.wait_for(tier.run(), timeout=slice_seconds) + except asyncio.TimeoutError: + errors.append(f"{tier.name}: timed out after {slice_seconds:.0f}s") + except Exception as exc: + errors.append(f"{tier.name}: {str(exc)[:150]}") + else: + if result is not None: + return CascadeOutcome(result=result, errors=errors) + + return CascadeOutcome(result=None, errors=errors) diff --git a/backend/apps/web/grounded.py b/backend/apps/web/grounded.py new file mode 100644 index 00000000..d3c9382c --- /dev/null +++ b/backend/apps/web/grounded.py @@ -0,0 +1,265 @@ +"""Provider-grounded search/fetch backends for the /api/web cascade. + +These are the PAID tiers: the user's own Gemini / OpenAI key, or the same +providers reached through a 9Router subscription. They are slow (tens of +seconds) but render and reason over pages, so they sit behind the free +keyless tiers and only run when those come up empty.""" + +import time +from typing import Dict, List, Optional, Set, Tuple + +import httpx +from typeguard import typechecked + +GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta" +GEMINI_GROUNDING_MODEL = "gemini-2.5-flash" # cheapest + fastest for grounded calls + +OPENAI_API_BASE = "https://api.openai.com/v1" +OPENAI_SEARCH_MODEL = "gpt-5-mini" # cheapest model that supports web_search_preview + +NINE_ROUTER_MESSAGES_URL = "http://localhost:20128/v1/messages" + + +@typechecked +async def gemini_grounded_call(api_key: str, prompt: str, *, use_url_context: bool) -> Dict: + """Call Gemini with googleSearch (+ optionally urlContext) grounding. + + Returns {"text": grounded_answer, "chunks": [(title, uri), ...], + "queries": [...]} or raises httpx.HTTPError on failure. + """ + tools: List[Dict] = [{"googleSearch": {}}] + if use_url_context: + tools.append({"urlContext": {}}) + body = { + "contents": [{"role": "user", "parts": [{"text": prompt}]}], + "tools": tools, + "generationConfig": {"thinkingConfig": {"thinkingBudget": 0}}, + } + url = f"{GEMINI_API_BASE}/models/{GEMINI_GROUNDING_MODEL}:generateContent" + async with httpx.AsyncClient(timeout=45.0) as client: + r = await client.post( + url, + headers={"x-goog-api-key": api_key, "Content-Type": "application/json"}, + json=body, + ) + r.raise_for_status() + data = r.json() + + cand = (data.get("candidates") or [{}])[0] + text = "".join( + p.get("text", "") for p in (cand.get("content", {}).get("parts") or []) + if isinstance(p, dict) + ) + gm = cand.get("groundingMetadata") or {} + chunks: List[Tuple[str, str]] = [] + for gc in (gm.get("groundingChunks") or []): + web = (gc or {}).get("web") or {} + uri = web.get("uri") or web.get("url") or "" + title = web.get("title") or uri + if uri: + chunks.append((title, uri)) + queries = gm.get("webSearchQueries") or [] + return {"text": text, "chunks": chunks, "queries": queries} + + +@typechecked +def format_grounded_as_search_results(grounded: Dict, query: str) -> str: + """Format Gemini grounding output to match WebSearchTool's text shape.""" + lines: List[str] = [] + chunks = grounded.get("chunks") or [] + for i, (title, uri) in enumerate(chunks[:10], start=1): + lines.append(f"[{i}] {title}\n {uri}") + text = grounded.get("text") or "" + if text: + lines.append("\n" + text) + if not lines: + return f"No search results found for: {query}" + return "\n\n".join(lines) + + +@typechecked +def format_grounded_as_fetch(grounded: Dict, url: str) -> str: + """Format Gemini urlContext output to match WebFetchTool's text shape.""" + parts = [f"Contents of {url}:", ""] + text = grounded.get("text") or "" + if text: + parts.append(text) + chunks = grounded.get("chunks") or [] + if chunks: + parts.append("\nCited sources:") + for i, (title, uri) in enumerate(chunks[:5], start=1): + parts.append(f" [{i}] {title}; {uri}") + return "\n".join(parts) + + +@typechecked +def resolve_gemini_api_key() -> Optional[str]: + """Pull the AI Studio API key from settings, or None.""" + try: + from backend.apps.settings.settings import load_settings + s = load_settings() + return getattr(s, "google_api_key", None) or None + except Exception: + return None + + +@typechecked +def resolve_openai_api_key() -> Optional[str]: + try: + from backend.apps.settings.settings import load_settings + s = load_settings() + return getattr(s, "openai_api_key", None) or None + except Exception: + return None + + +# Cache of which 9Router subscriptions are connected. Refreshed rather than hit on every search call; 9Router's /api/providers is fast but not free and we already query it from many places. +p_nine_router_connected: Set[str] = set() +p_nine_router_cache_at: float = 0.0 + + +@typechecked +async def refresh_9r_connected() -> Set[str]: + """The currently-active 9Router subscription providers, cached 20s.""" + global p_nine_router_connected, p_nine_router_cache_at + now = time.time() + if now - p_nine_router_cache_at < 20.0: + return p_nine_router_connected + try: + from backend.apps.nine_router import is_running as p_9r_running, get_providers as p_9r_providers + if not p_9r_running(): + p_nine_router_connected = set() + else: + conns = await p_9r_providers() + p_nine_router_connected = { + c.get("provider") + for c in conns + if isinstance(c, dict) and c.get("isActive") and c.get("provider") + } + p_nine_router_cache_at = now + except Exception: + # Cache stays; best-effort. + pass + return p_nine_router_connected + + +@typechecked +async def p_nine_router_text(body: Dict) -> str: + """POST an Anthropic-shape body to 9Router and concatenate its text blocks.""" + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.post( + NINE_ROUTER_MESSAGES_URL, + json=body, + headers={"x-api-key": "9router", "anthropic-version": "2023-06-01"}, + ) + if r.status_code != 200: + return "" + data = r.json() + return "".join( + block.get("text", "") + for block in (data.get("content") or []) + if isinstance(block, dict) and block.get("type") == "text" + ) + + +@typechecked +async def gemini_grounded_via_9router(prompt: str, use_url_context: bool) -> Dict: + """Grounded Gemini through the user's OAuth subscription instead of an AI Studio key. + + Shapes its return like `gemini_grounded_call` so the formatters work + unchanged. 9Router doesn't surface citations as a structured field + uniformly across providers, so we hand back text-only.""" + connected = await refresh_9r_connected() + if "gemini-cli" in connected: + model = "gc/gemini-2.5-flash" + elif "antigravity" in connected: + model = "ag/gemini-3-flash" + else: + return {} + sys_prompt = ( + "You fetch URLs and return concise summaries with citations." + if use_url_context + else "You search the web and return concise grounded answers with " + "source citations. Always cite the URLs you used." + ) + text = await p_nine_router_text({ + "model": model, + "max_tokens": 1024, + "system": sys_prompt, + "messages": [{"role": "user", "content": prompt}], + }) + return {"text": text, "chunks": []} + + +@typechecked +async def openai_websearch_via_9router(query: str) -> Dict: + """Same idea for OpenAI, through the user's Codex 9Router connection.""" + connected = await refresh_9r_connected() + if "codex" not in connected: + return {} + text = await p_nine_router_text({ + "model": "cx/gpt-5.4-mini", + "max_tokens": 1024, + "system": ( + "You search the web and return concise grounded answers " + "with source citations. Always cite the URLs you used." + ), + "messages": [{"role": "user", "content": f"Search the web for: {query}"}], + }) + return {"text": text, "chunks": []} + + +@typechecked +def p_parse_openai_response(data: Dict) -> Dict: + """Pull output_text + url_citation annotations out of a Responses API body.""" + text_parts: List[str] = [] + chunks: List[Tuple[str, str]] = [] + for item in (data.get("output") or []): + if not isinstance(item, dict): + continue + for content in (item.get("content") or []): + if not isinstance(content, dict): + continue + if content.get("type") == "output_text": + text_parts.append(content.get("text", "")) + for ann in (content.get("annotations") or []): + if isinstance(ann, dict) and ann.get("type") == "url_citation": + uri = ann.get("url", "") + if uri: + chunks.append((ann.get("title", uri), uri)) + return {"text": "".join(text_parts), "chunks": chunks} + + +@typechecked +async def p_openai_responses(api_key: str, prompt: str) -> Dict: + async with httpx.AsyncClient(timeout=45.0) as client: + r = await client.post( + f"{OPENAI_API_BASE}/responses", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={ + "model": OPENAI_SEARCH_MODEL, + "input": prompt, + "tools": [{"type": "web_search_preview"}], + }, + ) + r.raise_for_status() + return p_parse_openai_response(r.json()) + + +@typechecked +async def openai_websearch(api_key: str, query: str) -> Dict: + """Call OpenAI Responses API with the web_search_preview tool.""" + grounded = await p_openai_responses( + api_key, f"Search the web for: {query}\n\nReturn a concise summary. Cite sources.", + ) + grounded["queries"] = [query] + return grounded + + +@typechecked +async def openai_urlfetch(api_key: str, url: str, prompt: Optional[str]) -> Dict: + """Use OpenAI's web_search_preview to fetch/summarize a specific URL.""" + prompt_text = f"Fetch and summarize the content at: {url}" + if prompt: + prompt_text += f"\n\nFocus on: {prompt}" + return await p_openai_responses(api_key, prompt_text) diff --git a/backend/apps/web/web.py b/backend/apps/web/web.py index 2f154cbe..28033bee 100644 --- a/backend/apps/web/web.py +++ b/backend/apps/web/web.py @@ -1,22 +1,35 @@ """Web search + fetch sub-app. -Thin HTTP wrappers around `WebSearchTool` and `WebFetchTool` from -`backend.apps.agents.tools.web`. Exists so the in-process MCP server -(`backend.apps.agents.web_mcp_server`) can proxy tool calls to the -backend instead of re-implementing DuckDuckGo scraping + trafilatura -extraction in the MCP process. +Thin HTTP wrappers around the keyless search rungs and `WebFetchTool` from +`backend.apps.agents.tools`. Exists so the in-process MCP server +(`backend.apps.agents.web_mcp_server`) can proxy tool calls to the backend +instead of re-implementing scraping + trafilatura extraction in the MCP +process. Mounted at `/api/web`. """ -import asyncio from contextlib import asynccontextmanager -from typing import Any +from typing import Any, Dict, List, Optional +from uuid import uuid4 from fastapi import HTTPException from pydantic import BaseModel, Field from typeguard import typechecked +from backend.apps.web.cascade import CascadeTier, run_cascade +from backend.apps.web.grounded import ( + format_grounded_as_fetch, + format_grounded_as_search_results, + gemini_grounded_call, + gemini_grounded_via_9router, + openai_urlfetch, + openai_websearch, + openai_websearch_via_9router, + refresh_9r_connected, + resolve_gemini_api_key, + resolve_openai_api_key, +) from backend.config.Apps import SubApp @@ -35,48 +48,45 @@ class SearchBody(BaseModel): query: str = Field(..., description="The search query.") num_results: int = Field(5, ge=1, le=10, description="Max results to return.") # Hint from the MCP server about which primary provider the session is using. Lets us route to that provider's native search tool (Gemini googleSearch, OpenAI web_search_preview) when available, costs come out of the user's existing primary budget. - primary: str | None = Field(None, description="Primary provider hint: 'gemini' | 'openai' | 'anthropic' | None") + primary: Optional[str] = Field(None, description="Primary provider hint: 'gemini' | 'openai' | 'anthropic' | None") # Set by the openswarm-web shim from OPENSWARM_BROWSER_OK; the browser-fallback nudge must never fire in a session without browser-delegation tools. browser_ok: bool = Field(False, description="Whether this session has browser-delegation tools available.") class FetchBody(BaseModel): url: str = Field(..., description="The URL to fetch.") - prompt: str | None = Field(None, description="Optional context hint.") - primary: str | None = Field(None, description="Primary provider hint.") + prompt: Optional[str] = Field(None, description="Optional context hint.") + primary: Optional[str] = Field(None, description="Primary provider hint.") -# --------------------------------------------------------------------------- Helper; extract plain text from a tool's structured output list --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- Budgets --------------------------------------------------------------------------- + +# Whole-cascade wall clock. Nothing below can push an endpoint past this, and the MCP shim waits LONGER (see web_mcp_server) so our honest "here is why every backend failed" answer beats a client-side abort. +SEARCH_BUDGET_SECONDS = 60.0 +FETCH_BUDGET_SECONDS = 60.0 + +KEYLESS_TIER_SECONDS = 8.0 # a search frontend answers in ~1s; >8s is a hang +BROWSER_TIER_SECONDS = 12.0 # the main-bridge send has its own per-action timeout +GROUNDED_TIER_SECONDS = 45.0 # grounded native search legitimately takes 30-42s +LOCAL_FETCH_TIER_SECONDS = 15.0 # normal pages return in <2s -def p_join_text(parts: list[dict[str, Any]]) -> str: - out = [] +# --------------------------------------------------------------------------- Helpers --------------------------------------------------------------------------- + + +@typechecked +def p_join_text(parts: List[Dict[str, Any]]) -> str: + out: List[str] = [] for p in parts: if isinstance(p, dict) and p.get("type") == "text": out.append(str(p.get("text", ""))) return "\n".join(out) -# --------------------------------------------------------------------------- Endpoints --------------------------------------------------------------------------- - - -GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta" -GEMINI_GROUNDING_MODEL = "gemini-2.5-flash" # cheapest + fastest for grounded calls - -OPENAI_API_BASE = "https://api.openai.com/v1" -OPENAI_SEARCH_MODEL = "gpt-5-mini" # cheapest model that supports web_search_preview - -# Per-attempt timeouts for the search/fetch cascade. The fast-first ORDERING is what fixes the ~75s stall (DDG answers in ~1s so the slow grounded backends are rarely reached); these bounds are hang safety-nets, set just ABOVE each path's own httpx timeout so a normally-slow call still completes and only a truly hung provider (no response at all) gets cut. Grounded native search legitimately takes 32-42s (httpx ceiling 45s), so its leash sits at 48s, NOT below 45, or we'd clip the slow tail of a valid paid call. -P_DDG_ATTEMPT_TIMEOUT = 6.0 # DDG answers <1s; >6s is a network hang, fall through -P_GROUNDED_ATTEMPT_TIMEOUT = 48.0 # just above the providers' own 45s httpx timeout -# Local httpx + trafilatura fetch of a real page; the fast path for /fetch (normal pages return in <2s). Set just above WebFetchTool's own 30s httpx ceiling so a valid-but-slow page still completes locally instead of being clipped down to a grounded summary; only a truly hung server gets cut. -P_LOCAL_FETCH_TIMEOUT = 32.0 -P_BROWSER_TIER_TIMEOUT = 46.0 # main-bridge send has its own per-action timeout; this outer wait_for is just a backstop above it - # Drive the packaged app's offscreen Chromium (main-process hidden window) for a fetch/search. Returns the bridge result dict, or None when no Electron main bridge is connected (dev/headless/backend-only) so the cascade just skips this tier. This is the real "browser reachable" gate, OPENSWARM_BROWSER_OK is effectively always "1" and not trustworthy for this. -async def p_browser_bridge(action: str, params: dict) -> dict | None: +@typechecked +async def p_browser_bridge(action: str, params: Dict) -> Optional[Dict]: from backend.apps.agents.core.ws_manager import ws_manager - from uuid import uuid4 if ws_manager.main_connection is None: return None res = await ws_manager.send_main_command(uuid4().hex, action, params) @@ -85,512 +95,158 @@ async def p_browser_bridge(action: str, params: dict) -> dict | None: return res -# When every search backend fails, point the model at the in-product browser (always-on CreateBrowserAgent tool) instead of telling it to "wait and retry", which it can't do and just relays as a dead end. The real Chromium renders pages and isn't subject to the DDG scrape throttle. +# When every search backend fails, point the model at the in-product browser (always-on CreateBrowserAgent tool) instead of telling it to "wait and retry", which it can't do and just relays as a dead end. The real Chromium renders pages and isn't subject to the scrape throttle. +@typechecked def p_browser_fallback_nudge(query: str) -> str: return ( "Don't stop here: fall back to the in-product browser, which renders real pages and " - "isn't subject to this rate limit. Call CreateBrowserAgent with a task like: " + "isn't subject to this block. Call CreateBrowserAgent with a task like: " f'"Search the web for: {query}. Report the top results with their titles and URLs, ' 'plus a direct answer if you find one."' ) -async def p_gemini_grounded_call(api_key: str, prompt: str, *, use_url_context: bool) -> dict: - """Call Gemini with googleSearch (+ optionally urlContext) grounding. - - Returns {"text": grounded_answer, "chunks": [(title, uri), ...], - "queries": [...]} or raises httpx.HTTPError on failure. - """ - import httpx - tools = [{"googleSearch": {}}] - if use_url_context: - tools.append({"urlContext": {}}) - body = { - "contents": [{"role": "user", "parts": [{"text": prompt}]}], - "tools": tools, - "generationConfig": {"thinkingConfig": {"thinkingBudget": 0}}, - } - url = f"{GEMINI_API_BASE}/models/{GEMINI_GROUNDING_MODEL}:generateContent" - async with httpx.AsyncClient(timeout=45.0) as client: - r = await client.post( - url, - headers={"x-goog-api-key": api_key, "Content-Type": "application/json"}, - json=body, - ) - r.raise_for_status() - data = r.json() - - cand = (data.get("candidates") or [{}])[0] - text = "".join( - p.get("text", "") for p in (cand.get("content", {}).get("parts") or []) - if isinstance(p, dict) - ) - gm = cand.get("groundingMetadata") or {} - chunks = [] - for gc in (gm.get("groundingChunks") or []): - web = (gc or {}).get("web") or {} - uri = web.get("uri") or web.get("url") or "" - title = web.get("title") or uri - if uri: - chunks.append((title, uri)) - queries = gm.get("webSearchQueries") or [] - return {"text": text, "chunks": chunks, "queries": queries} +@typechecked +def p_grounded_tiers(kind: str, primary: Optional[str], runners: Dict[str, Any]) -> List[CascadeTier]: + """Order the four grounded backends, promoting the session's own primary.""" + names = ["gemini_native", "gemini_subscription", "openai_native", "openai_subscription"] + if (primary or "").lower() == "openai": + names = names[2:] + names[:2] + return [ + CascadeTier(name=f"{kind}:{n}", run=runners[n], budget=GROUNDED_TIER_SECONDS) + for n in names + ] -def p_format_grounded_as_search_results(grounded: dict, query: str) -> str: - """Format Gemini grounding output to match WebSearchTool's text shape.""" - lines = [] - chunks = grounded.get("chunks") or [] - for i, (title, uri) in enumerate(chunks[:10], start=1): - lines.append(f"[{i}] {title}\n {uri}") - text = grounded.get("text") or "" - if text: - lines.append("\n" + text) - if not lines: - return f"No search results found for: {query}" - return "\n\n".join(lines) - - -def p_format_grounded_as_fetch(grounded: dict, url: str) -> str: - """Format Gemini urlContext output to match WebFetchTool's text shape.""" - parts = [f"Contents of {url}:", ""] - text = grounded.get("text") or "" - if text: - parts.append(text) - chunks = grounded.get("chunks") or [] - if chunks: - parts.append("\nCited sources:") - for i, (title, uri) in enumerate(chunks[:5], start=1): - parts.append(f" [{i}] {title}; {uri}") - return "\n".join(parts) - - -def p_resolve_gemini_api_key() -> str | None: - """Pull the AI Studio API key from settings, or None.""" - try: - from backend.apps.settings.settings import load_settings - s = load_settings() - return getattr(s, "google_api_key", None) or None - except Exception: - return None - - -def p_resolve_openai_api_key() -> str | None: - try: - from backend.apps.settings.settings import load_settings - s = load_settings() - return getattr(s, "openai_api_key", None) or None - except Exception: - return None - - -# Cache of which 9Router subscriptions are connected. Refreshed via `_refresh_9r_connected()` rather than hit on every search call, 9Router's /api/providers is fast but not free, and we already query it from many places. -P_NINE_ROUTER_CONNECTED: set[str] = set() -P_NINE_ROUTER_CACHE_AT: float = 0.0 - - -async def p_refresh_9r_connected() -> set[str]: - """Return the set of currently-active 9Router subscription providers - (e.g. {"claude", "codex", "antigravity", "gemini-cli"}). Cached for - 20s to keep search/fetch endpoints snappy.""" - global P_NINE_ROUTER_CONNECTED, P_NINE_ROUTER_CACHE_AT - import time as p_t - now = p_t.time() - if now - P_NINE_ROUTER_CACHE_AT < 20.0: - return P_NINE_ROUTER_CONNECTED - try: - from backend.apps.nine_router import is_running as p_9r_running, get_providers as p_9r_providers - if not p_9r_running(): - P_NINE_ROUTER_CONNECTED = set() - else: - conns = await p_9r_providers() - P_NINE_ROUTER_CONNECTED = { - c.get("provider") - for c in conns - if isinstance(c, dict) and c.get("isActive") and c.get("provider") - } - P_NINE_ROUTER_CACHE_AT = now - except Exception: - # Cache stays; best-effort. - pass - return P_NINE_ROUTER_CONNECTED - - -async def p_gemini_grounded_via_9router(prompt: str, use_url_context: bool) -> dict: - """Call 9Router's /v1/messages endpoint with a Gemini model so the - user's OAuth subscription (Gemini CLI or Antigravity) covers the - search call instead of needing a separate AI Studio API key. - - Routes through Anthropic-shape against 9Router's translator. We - request a tool result naturally; the translator surfaces grounded - URIs as text + cited sources in the response body. Format-shape - matches the existing `_gemini_grounded_call` so downstream - `_format_grounded_as_search_results` works unchanged.""" - import httpx - # Prefer Gemini CLI (broader model coverage). Fall back to Antigravity if CLI isn't connected. - connected = await p_refresh_9r_connected() - if "gemini-cli" in connected: - model = "gc/gemini-2.5-flash" - elif "antigravity" in connected: - model = "ag/gemini-3-flash" - else: - return {} - - sys_prompt = ( - "You search the web and return concise grounded answers with " - "source citations. Always cite the URLs you used." - if not use_url_context - else "You fetch URLs and return concise summaries with citations." - ) - body = { - "model": model, - "max_tokens": 1024, - "system": sys_prompt, - "messages": [{"role": "user", "content": prompt}], - } - async with httpx.AsyncClient(timeout=20.0) as client: - r = await client.post( - "http://localhost:20128/v1/messages", - json=body, - headers={"x-api-key": "9router", "anthropic-version": "2023-06-01"}, - ) - if r.status_code != 200: - return {} - data = r.json() - # Synthesize a grounded shape so the existing formatter works: _format_grounded_as_search_results expects {"text": str, "chunks": [(title, uri), ...]}. 9Router doesn't surface citations as a structured field uniformly across providers, so we hand back text-only and let the formatter do its thing. - text = "" - for block in (data.get("content") or []): - if isinstance(block, dict) and block.get("type") == "text": - text += block.get("text", "") - return {"text": text, "chunks": []} - - -async def p_openai_websearch_via_9router(query: str) -> dict: - """Same idea, but for OpenAI's web_search_preview through Codex's - 9Router connection. Goes through 9Router's openai-compat endpoint - (the responses API) so the user's Codex subscription covers it.""" - import httpx - connected = await p_refresh_9r_connected() - if "codex" not in connected: - return {} - body = { - "model": "cx/gpt-5.4-mini", - "max_tokens": 1024, - "system": ( - "You search the web and return concise grounded answers " - "with source citations. Always cite the URLs you used." - ), - "messages": [{"role": "user", "content": f"Search the web for: {query}"}], - } - async with httpx.AsyncClient(timeout=20.0) as client: - r = await client.post( - "http://localhost:20128/v1/messages", - json=body, - headers={"x-api-key": "9router", "anthropic-version": "2023-06-01"}, - ) - if r.status_code != 200: - return {} - data = r.json() - text = "" - for block in (data.get("content") or []): - if isinstance(block, dict) and block.get("type") == "text": - text += block.get("text", "") - return {"text": text, "chunks": []} - - -async def p_openai_websearch(api_key: str, query: str) -> dict: - """Call OpenAI Responses API with the web_search_preview tool. - - Returns {"text": grounded_answer, "chunks": [(title, uri), ...]}. - """ - import httpx - body = { - "model": OPENAI_SEARCH_MODEL, - "input": f"Search the web for: {query}\n\nReturn a concise summary. Cite sources.", - "tools": [{"type": "web_search_preview"}], - } - async with httpx.AsyncClient(timeout=45.0) as client: - r = await client.post( - f"{OPENAI_API_BASE}/responses", - headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, - json=body, - ) - r.raise_for_status() - data = r.json() - - text_parts = [] - chunks: list[tuple[str, str]] = [] - for item in (data.get("output") or []): - if not isinstance(item, dict): - continue - for content in (item.get("content") or []): - if not isinstance(content, dict): - continue - if content.get("type") == "output_text": - text_parts.append(content.get("text", "")) - for ann in (content.get("annotations") or []): - if isinstance(ann, dict) and ann.get("type") == "url_citation": - uri = ann.get("url", "") - title = ann.get("title", uri) - if uri: - chunks.append((title, uri)) - return {"text": "".join(text_parts), "chunks": chunks, "queries": [query]} - - -async def p_openai_urlfetch(api_key: str, url: str, prompt: str | None) -> dict: - """Use OpenAI's web_search_preview to fetch/summarize a specific URL.""" - prompt_text = f"Fetch and summarize the content at: {url}" - if prompt: - prompt_text += f"\n\nFocus on: {prompt}" - import httpx - body = { - "model": OPENAI_SEARCH_MODEL, - "input": prompt_text, - "tools": [{"type": "web_search_preview"}], - } - async with httpx.AsyncClient(timeout=45.0) as client: - r = await client.post( - f"{OPENAI_API_BASE}/responses", - headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, - json=body, - ) - r.raise_for_status() - data = r.json() - text_parts = [] - chunks = [] - for item in (data.get("output") or []): - for content in (item.get("content") or []): - if isinstance(content, dict) and content.get("type") == "output_text": - text_parts.append(content.get("text", "")) - for ann in (content.get("annotations") or []) if isinstance(content, dict) else []: - if isinstance(ann, dict) and ann.get("type") == "url_citation": - uri = ann.get("url", "") - title = ann.get("title", uri) - if uri: - chunks.append((title, uri)) - return {"text": "".join(text_parts), "chunks": chunks} +# --------------------------------------------------------------------------- Endpoints --------------------------------------------------------------------------- @web.router.post("/search") @typechecked -async def search(body: SearchBody) -> dict: - """Web search, primary-aware. Prefers the native search tool of the - provider the user is already paying for: +async def search(body: SearchBody) -> Dict: + """Web search, primary-aware. - Gemini primary + Gemini key → googleSearch grounding - OpenAI primary + OpenAI key → web_search_preview - Anthropic path available → handled at agent_manager layer - (MCP isn't registered; built-in - WebSearch routes through 9Router - → Anthropic's server-side tool) - otherwise → DDG fallback (CAPTCHA-prone) + Free keyless rungs lead (they answer at human speed and cost nothing), + then the packaged app's real browser, then the provider-grounded backends + of whichever provider the user already pays for.""" + gemini_key = resolve_gemini_api_key() + openai_key = resolve_openai_api_key() - If the primary's own native path fails, we cascade to whichever - other provider's credentials are available, then DDG last.""" - gemini_key = p_resolve_gemini_api_key() - openai_key = p_resolve_openai_api_key() - primary = (body.primary or "").lower() - errors: list[str] = [] - - async def try_gemini(): - if not gemini_key: - return None - prompt = ( - f"Search the web for: {body.query}\n\n" - f"Return a concise summary of what you found. Cite sources." - ) - grounded = await p_gemini_grounded_call(gemini_key, prompt, use_url_context=False) - return { - "query": body.query, - "results": p_format_grounded_as_search_results(grounded, body.query), - "backend": "gemini_native", - } - - async def try_openai(): - if not openai_key: - return None - grounded = await p_openai_websearch(openai_key, body.query) - return { - "query": body.query, - "results": p_format_grounded_as_search_results(grounded, body.query), - "backend": "openai_native", - } - - async def try_gemini_subscription(): - prompt = ( - f"Search the web for: {body.query}\n\n" - f"Return a concise summary of what you found. Cite sources." - ) - grounded = await p_gemini_grounded_via_9router(prompt, use_url_context=False) - if not grounded.get("text"): - return None - return { - "query": body.query, - "results": p_format_grounded_as_search_results(grounded, body.query), - "backend": "gemini_subscription", - } - - async def try_openai_subscription(): - grounded = await p_openai_websearch_via_9router(body.query) - if not grounded.get("text"): - return None - return { - "query": body.query, - "results": p_format_grounded_as_search_results(grounded, body.query), - "backend": "openai_subscription", - } - - async def try_ddg(): - # Fast path: direct HTML search, sub-second when DDG isn't throttling us. Returns None on a real no-hits OR a 202 throttle so the chain falls through to the slower-but-grounded backends. - from backend.apps.agents.tools.web import WebSearchTool, DDGRateLimited + async def try_keyless() -> Optional[Dict]: + # DuckDuckGo (html then lite); sub-second when it isn't challenged. None on a real no-hits so the chain falls through. + from backend.apps.agents.tools.web import DDGRateLimited, WebSearchTool try: text = await WebSearchTool.search_ddg(body.query, body.num_results) except DDGRateLimited: - # Surface the throttle as a recorded error (not a silent None) so the caller can see WHY we fell through to a slower backend. - raise RuntimeError("DuckDuckGo rate-limited (HTTP 202)") from None + # Surface the challenge as a recorded error (not a silent None) so the caller can see WHY we fell through to a slower backend. + raise RuntimeError("DuckDuckGo served its bot challenge (HTTP 202)") from None if not text: return None return {"query": body.query, "results": text, "backend": "ddg"} - async def try_browser_search(): - # Packaged-app tier: a real Chromium's fingerprint isn't subject to the httpx DDG 202 throttle (proven: browser-DDG returns hits where our headless client 202s), and it can scrape Google/Bing directly. Skipped (None) when no Electron main bridge is connected. + async def try_browser_search() -> Optional[Dict]: + # Packaged-app tier: a real Chromium's fingerprint isn't subject to the headless-client challenge, and it can scrape Google/Bing directly. Skipped (None) when no Electron main bridge is connected. res = await p_browser_bridge("browser_search", {"query": body.query, "num_results": body.num_results}) if not res or not res.get("results"): return None return {"query": body.query, "results": res["results"], "backend": f"browser_{res.get('engine', 'search')}"} - # Fast-first cascade: DDG leads (~1s = human speed); then the packaged browser (real fingerprint, throttle-immune, no LLM cost); then the 30-42s LLM-grounded backends. The primary hint only reorders the grounded tier. Every attempt is wait_for-bounded so a slow/hung provider fails over fast. - grounded = [ - ("gemini_native", try_gemini), - ("gemini_subscription", try_gemini_subscription), - ("openai_native", try_openai), - ("openai_subscription", try_openai_subscription), - ] - if primary == "openai": - grounded = grounded[2:] + grounded[:2] + async def try_gemini() -> Optional[Dict]: + if not gemini_key: + return None + grounded = await gemini_grounded_call( + gemini_key, + f"Search the web for: {body.query}\n\nReturn a concise summary of what you found. Cite sources.", + use_url_context=False, + ) + return {"query": body.query, "results": format_grounded_as_search_results(grounded, body.query), + "backend": "gemini_native"} - cascade = [ - ("ddg", try_ddg, P_DDG_ATTEMPT_TIMEOUT), - ("browser_search", try_browser_search, P_BROWSER_TIER_TIMEOUT), - ] + [ - (name, fn, P_GROUNDED_ATTEMPT_TIMEOUT) for name, fn in grounded - ] + async def try_openai() -> Optional[Dict]: + if not openai_key: + return None + grounded = await openai_websearch(openai_key, body.query) + return {"query": body.query, "results": format_grounded_as_search_results(grounded, body.query), + "backend": "openai_native"} - for name, fn, timeout in cascade: - try: - res = await asyncio.wait_for(fn(), timeout=timeout) - if res is not None: - if errors: - res["cascade_errors"] = errors - return res - except asyncio.TimeoutError: - errors.append(f"{name}: timed out after {timeout:.0f}s") - except Exception as e: - errors.append(f"{name}: {str(e)[:150]}") + async def try_gemini_subscription() -> Optional[Dict]: + grounded = await gemini_grounded_via_9router( + f"Search the web for: {body.query}\n\nReturn a concise summary of what you found. Cite sources.", + False, + ) + if not grounded.get("text"): + return None + return {"query": body.query, "results": format_grounded_as_search_results(grounded, body.query), + "backend": "gemini_subscription"} + + async def try_openai_subscription() -> Optional[Dict]: + grounded = await openai_websearch_via_9router(body.query) + if not grounded.get("text"): + return None + return {"query": body.query, "results": format_grounded_as_search_results(grounded, body.query), + "backend": "openai_subscription"} + + tiers = [ + CascadeTier(name="ddg", run=try_keyless, budget=KEYLESS_TIER_SECONDS), + CascadeTier(name="browser_search", run=try_browser_search, budget=BROWSER_TIER_SECONDS), + ] + p_grounded_tiers("search", body.primary, { + "gemini_native": try_gemini, + "gemini_subscription": try_gemini_subscription, + "openai_native": try_openai, + "openai_subscription": try_openai_subscription, + }) + + outcome = await run_cascade(tiers, SEARCH_BUDGET_SECONDS) + if outcome.result is not None: + if outcome.errors: + outcome.result["cascade_errors"] = outcome.errors + return outcome.result # Everything failed. Be honest about why instead of an empty "no results". - connected = await p_refresh_9r_connected() + connected = await refresh_9r_connected() has_subscription = bool(connected & {"codex", "antigravity", "gemini-cli"}) if not (gemini_key or openai_key or has_subscription): tail = ( - "No search backend is configured and DuckDuckGo is rate-limiting this " - "network. Connect Codex / Antigravity / Gemini CLI in Settings, or add " + "Every free search frontend refused this request and no paid search backend " + "is configured. Connect Codex / Antigravity / Gemini CLI in Settings, or add " "an OpenAI / Gemini API key, for reliable search." ) else: tail = ( - "DuckDuckGo is rate-limiting this network and every configured provider " - "errored (see details below)." + "Every free search frontend refused this request and every configured " + "provider errored (see details below)." ) nudge = p_browser_fallback_nudge(body.query) if body.browser_ok else "" - p_results_text = f"No results for: {body.query}\n\n{tail}" + (f"\n\n{nudge}" if nudge else "") + results_text = f"No results for: {body.query}\n\n{tail}" + (f"\n\n{nudge}" if nudge else "") return { "query": body.query, - "results": p_results_text, + "results": results_text, "backend": "none", - "cascade_errors": errors, + "cascade_errors": outcome.errors, } @web.router.post("/fetch") @typechecked -async def fetch(body: FetchBody) -> dict: - """Fetch a URL, primary-aware. Mirrors /search cascade logic.""" +async def fetch(body: FetchBody) -> Dict: + """Fetch a URL, primary-aware. Mirrors the /search cascade.""" # Belt-and-suspenders: even though we delegate to remote Gemini/OpenAI fetchers (which can't reach private IPs), validating the URL here means a private/metadata URL gets a 4xx instead of being silently forwarded. from backend.apps.agents.tools.ssrf_guard import SSRFBlocked, assert_safe_url try: await assert_safe_url(body.url) except SSRFBlocked as exc: - from fastapi import HTTPException raise HTTPException(status_code=400, detail=f"Refused: {exc}") - gemini_key = p_resolve_gemini_api_key() - openai_key = p_resolve_openai_api_key() - primary = (body.primary or "").lower() + gemini_key = resolve_gemini_api_key() + openai_key = resolve_openai_api_key() - async def try_gemini(): - if not gemini_key: - return None - prompt_bits = [f"Fetch and summarize this URL: {body.url}"] - if body.prompt: - prompt_bits.append(f"Focus on: {body.prompt}") - grounded = await p_gemini_grounded_call( - gemini_key, "\n".join(prompt_bits), use_url_context=True, - ) - return { - "url": body.url, - "content": p_format_grounded_as_fetch(grounded, body.url), - "backend": "gemini_native", - } + # Remembered so a thin/errored local read is still returned as the last resort if every other tier also fails (never worse than before). + local_text: Optional[str] = None - async def try_openai(): - if not openai_key: - return None - grounded = await p_openai_urlfetch(openai_key, body.url, body.prompt) - return { - "url": body.url, - "content": p_format_grounded_as_fetch(grounded, body.url), - "backend": "openai_native", - } - - async def try_gemini_subscription(): - prompt_bits = [f"Fetch and summarize this URL: {body.url}"] - if body.prompt: - prompt_bits.append(f"Focus on: {body.prompt}") - grounded = await p_gemini_grounded_via_9router( - "\n".join(prompt_bits), use_url_context=True, - ) - if not grounded.get("text"): - return None - return { - "url": body.url, - "content": p_format_grounded_as_fetch(grounded, body.url), - "backend": "gemini_subscription", - } - - async def try_openai_subscription(): - # Codex's web_search is general; URL fetch via search query works adequately for our use. - prompt = f"Fetch this URL and summarize: {body.url}" - if body.prompt: - prompt += f"\nFocus on: {body.prompt}" - grounded = await p_openai_websearch_via_9router(prompt) - if not grounded.get("text"): - return None - return { - "url": body.url, - "content": p_format_grounded_as_fetch(grounded, body.url), - "backend": "openai_subscription", - } - - # Remembered so a thin/errored local read is still returned as the last resort if every grounded fetcher also fails (never worse than before). - local_text: str | None = None - - async def try_local(): - # Fast path: direct httpx + trafilatura, sub-second to a few seconds and returns the page's ACTUAL text (the grounded fetchers summarize, which is slower and loses detail). Thin/errored reads (JS walls, paywalls, HTTP errors) fall through to the grounded fetchers that can render them. + async def try_local() -> Optional[Dict]: + # Fast path: direct httpx + trafilatura, and it returns the page's ACTUAL text (the grounded fetchers summarize, which is slower and loses detail). Thin/errored reads (JS walls, paywalls, HTTP errors) fall through. nonlocal local_text from backend.apps.agents.tools.web import WebFetchTool - parts = await WebFetchTool().execute( - {"url": body.url, "prompt": body.prompt or ""}, None, - ) + parts = await WebFetchTool().execute({"url": body.url, "prompt": body.prompt or ""}, None) text = p_join_text(parts) local_text = text if text.startswith(("HTTP error", "Error fetching", "Refused to fetch")): @@ -600,44 +256,69 @@ async def fetch(body: FetchBody) -> dict: return None return {"url": body.url, "content": text, "backend": "local"} - async def try_browser_fetch(): - # Packaged-app tier: renders the page in a real offscreen Chromium and returns its visible text, so JS-only / SPA / soft-paywall pages that give httpx nothing (the try_local thin-read case) actually resolve. Shares the user's browser cookies, so pages they're logged into fetch authed. Skipped (None) with no Electron main bridge. + async def try_browser_fetch() -> Optional[Dict]: + # Packaged-app tier: renders the page in a real offscreen Chromium and returns its visible text, so JS-only / SPA / soft-paywall pages that give httpx nothing actually resolve. Shares the user's browser cookies, so pages they're logged into fetch authed. res = await p_browser_bridge("browser_fetch", {"url": body.url}) if not res or not res.get("text"): return None return {"url": body.url, "content": f"Contents of {body.url}:\n\n{res['text']}", "backend": "browser"} - grounded = [ - ("gemini_native", try_gemini), - ("gemini_subscription", try_gemini_subscription), - ("openai_native", try_openai), - ("openai_subscription", try_openai_subscription), - ] - if primary == "openai": - grounded = grounded[2:] + grounded[:2] + async def try_gemini() -> Optional[Dict]: + if not gemini_key: + return None + prompt_bits = [f"Fetch and summarize this URL: {body.url}"] + if body.prompt: + prompt_bits.append(f"Focus on: {body.prompt}") + grounded = await gemini_grounded_call(gemini_key, "\n".join(prompt_bits), use_url_context=True) + return {"url": body.url, "content": format_grounded_as_fetch(grounded, body.url), + "backend": "gemini_native"} - cascade = [ - ("local", try_local, P_LOCAL_FETCH_TIMEOUT), - ("browser", try_browser_fetch, P_BROWSER_TIER_TIMEOUT), - ] + [ - (name, fn, P_GROUNDED_ATTEMPT_TIMEOUT) for name, fn in grounded - ] + async def try_openai() -> Optional[Dict]: + if not openai_key: + return None + grounded = await openai_urlfetch(openai_key, body.url, body.prompt) + return {"url": body.url, "content": format_grounded_as_fetch(grounded, body.url), + "backend": "openai_native"} - errors: list[str] = [] - for name, fn, timeout in cascade: - try: - res = await asyncio.wait_for(fn(), timeout=timeout) - if res is not None: - if errors: - res["cascade_errors"] = errors - return res - except asyncio.TimeoutError: - errors.append(f"{name}: timed out after {timeout:.0f}s") - except Exception as e: - errors.append(f"{name}: {str(e)[:150]}") + async def try_gemini_subscription() -> Optional[Dict]: + prompt_bits = [f"Fetch and summarize this URL: {body.url}"] + if body.prompt: + prompt_bits.append(f"Focus on: {body.prompt}") + grounded = await gemini_grounded_via_9router("\n".join(prompt_bits), True) + if not grounded.get("text"): + return None + return {"url": body.url, "content": format_grounded_as_fetch(grounded, body.url), + "backend": "gemini_subscription"} - # Grounded all failed; hand back whatever the local read got (even an error string is useful signal) rather than nothing. + async def try_openai_subscription() -> Optional[Dict]: + # Codex's web_search is general; URL fetch via search query works adequately for our use. + prompt = f"Fetch this URL and summarize: {body.url}" + if body.prompt: + prompt += f"\nFocus on: {body.prompt}" + grounded = await openai_websearch_via_9router(prompt) + if not grounded.get("text"): + return None + return {"url": body.url, "content": format_grounded_as_fetch(grounded, body.url), + "backend": "openai_subscription"} + + tiers = [ + CascadeTier(name="local", run=try_local, budget=LOCAL_FETCH_TIER_SECONDS), + CascadeTier(name="browser", run=try_browser_fetch, budget=BROWSER_TIER_SECONDS), + ] + p_grounded_tiers("fetch", body.primary, { + "gemini_native": try_gemini, + "gemini_subscription": try_gemini_subscription, + "openai_native": try_openai, + "openai_subscription": try_openai_subscription, + }) + + outcome = await run_cascade(tiers, FETCH_BUDGET_SECONDS) + if outcome.result is not None: + if outcome.errors: + outcome.result["cascade_errors"] = outcome.errors + return outcome.result + + # Every tier failed; hand back whatever the local read got (even an error string is useful signal) rather than nothing. if local_text is not None: return {"url": body.url, "content": local_text, "backend": "local", - **({"cascade_errors": errors} if errors else {})} - raise HTTPException(status_code=502, detail=f"Fetch failed for {body.url}: " + "; ".join(errors)[:400]) + **({"cascade_errors": outcome.errors} if outcome.errors else {})} + raise HTTPException(status_code=502, detail=f"Fetch failed for {body.url}: " + "; ".join(outcome.errors)[:400]) diff --git a/backend/tests/test_web_cascade_budget.py b/backend/tests/test_web_cascade_budget.py new file mode 100644 index 00000000..c9f5a1d4 --- /dev/null +++ b/backend/tests/test_web_cascade_budget.py @@ -0,0 +1,131 @@ +"""The /api/web cascade is bounded by ONE wall-clock budget. + +Seals the class of bug where the cascade's own leashes summed to 244s (search) +/ 270s (fetch) while the MCP shim gave up at 45s, so the later tiers were +unreachable and search "worked" only when an early tier won the race. +""" + +import asyncio +import time + +import pytest + +import backend.apps.web.cascade as C +from backend.apps.web.cascade import CascadeTier, run_cascade + + +@pytest.fixture +def p_tiny_floor(monkeypatch): + """Shrink the honest-slice floor so budget tests run in milliseconds, not seconds.""" + monkeypatch.setattr(C, "MIN_TIER_SECONDS", 0.05) + + +def p_tier(name, budget, fn): + return CascadeTier(name=name, run=fn, budget=budget) + + +async def p_hangs(): + await asyncio.sleep(60) + + +async def p_none(): + return None + + +@pytest.mark.asyncio +async def test_first_result_short_circuits(): + async def p_hit(): + return {"backend": "one"} + + async def p_boom(): + raise AssertionError("later tiers must not run once a tier answers") + + out = await run_cascade([p_tier("a", 5, p_hit), p_tier("b", 5, p_boom)], 10.0) + assert out.result == {"backend": "one"} + assert out.errors == [] + + +@pytest.mark.asyncio +async def test_total_budget_bounds_the_whole_cascade(p_tiny_floor): + # Four tiers each willing to burn 60s: without a shared deadline this runs for minutes. + tiers = [p_tier(f"t{i}", 60.0, p_hangs) for i in range(4)] + t0 = time.monotonic() + out = await run_cascade(tiers, 1.0) + elapsed = time.monotonic() - t0 + assert elapsed < 2.0, f"cascade overran its budget: {elapsed:.2f}s" + assert out.result is None + assert any("timed out" in e for e in out.errors) + + +@pytest.mark.asyncio +async def test_a_slow_tier_cannot_starve_the_ones_behind_it(p_tiny_floor): + # A hanging tier is cut at ITS OWN budget, so the tier behind it still gets its turn and wins. + async def p_hit(): + return {"backend": "rescue"} + + out = await run_cascade([p_tier("slow", 0.4, p_hangs), p_tier("fast", 5.0, p_hit)], 3.0) + assert out.result == {"backend": "rescue"} + assert any("slow" in e and "timed out" in e for e in out.errors) + + +@pytest.mark.asyncio +async def test_unreachable_tiers_are_reported_not_silently_dropped(): + async def p_slow(): + await asyncio.sleep(0.6) + return None + + out = await run_cascade( + [p_tier("burn", 5.0, p_slow), p_tier("never_a", 5.0, p_none), p_tier("never_b", 5.0, p_none)], + 0.6 + C.MIN_TIER_SECONDS - 0.1, + ) + assert out.result is None + tail = out.errors[-1] + assert "budget spent" in tail + assert "never_a" in tail and "never_b" in tail + + +@pytest.mark.asyncio +async def test_a_tier_slice_never_drops_below_the_honest_floor(): + # A tier handed 0.2s would "time out" on a hair trigger; we must say the budget ran out instead. + async def p_slow(): + await asyncio.sleep(0.5) + return None + + out = await run_cascade([p_tier("burn", 5.0, p_slow), p_tier("squeezed", 5.0, p_hangs)], 0.5 + 1.0) + assert out.result is None + assert not any("squeezed" in e and "timed out" in e for e in out.errors) + assert any("budget spent" in e for e in out.errors) + + +@pytest.mark.asyncio +async def test_tier_exception_is_recorded_and_the_chain_continues(): + async def p_boom(): + raise RuntimeError("provider down") + + async def p_hit(): + return {"backend": "next"} + + out = await run_cascade([p_tier("bad", 5.0, p_boom), p_tier("good", 5.0, p_hit)], 10.0) + assert out.result == {"backend": "next"} + assert out.errors == ["bad: provider down"] + + +def test_mcp_shim_waits_longer_than_the_server_budget(): + """The shim must outlast the cascade, or the later tiers are unreachable again.""" + from backend.apps.agents.web_mcp_server import TOOL_TIMEOUT + from backend.apps.web.web import FETCH_BUDGET_SECONDS, SEARCH_BUDGET_SECONDS + + assert TOOL_TIMEOUT > SEARCH_BUDGET_SECONDS + assert TOOL_TIMEOUT > FETCH_BUDGET_SECONDS + + +def test_search_tier_budgets_leave_room_for_the_grounded_tier(): + """The free rungs must not eat the whole budget before a paid backend is tried.""" + import backend.apps.web.web as W + + # A grounded native call needs ~30-42s, so the free rungs must leave it a usable slice. + grounded_floor = 20.0 + search_cheap = W.KEYLESS_TIER_SECONDS + W.BROWSER_TIER_SECONDS + assert W.SEARCH_BUDGET_SECONDS - search_cheap >= grounded_floor + fetch_cheap = W.LOCAL_FETCH_TIER_SECONDS + W.BROWSER_TIER_SECONDS + assert W.FETCH_BUDGET_SECONDS - fetch_cheap >= grounded_floor diff --git a/backend/tests/test_web_search_cascade.py b/backend/tests/test_web_search_cascade.py index bddc88c4..5b6c46fa 100644 --- a/backend/tests/test_web_search_cascade.py +++ b/backend/tests/test_web_search_cascade.py @@ -23,18 +23,18 @@ from backend.apps.agents.tools.web import WebSearchTool, DDGRateLimited @pytest.fixture(autouse=True) def p_no_network(monkeypatch): # Default everything to "unavailable / no network"; each test opts paths in. - monkeypatch.setattr(W, "p_resolve_gemini_api_key", lambda: None) - monkeypatch.setattr(W, "p_resolve_openai_api_key", lambda: None) + monkeypatch.setattr(W, "resolve_gemini_api_key", lambda: None) + monkeypatch.setattr(W, "resolve_openai_api_key", lambda: None) async def p_no_subs(): return set() - monkeypatch.setattr(W, "p_refresh_9r_connected", p_no_subs) + monkeypatch.setattr(W, "refresh_9r_connected", p_no_subs) async def p_empty(*a, **k): return {} # subscription helpers hit localhost:20128 otherwise - monkeypatch.setattr(W, "p_gemini_grounded_via_9router", p_empty) - monkeypatch.setattr(W, "p_openai_websearch_via_9router", p_empty) + monkeypatch.setattr(W, "gemini_grounded_via_9router", p_empty) + monkeypatch.setattr(W, "openai_websearch_via_9router", p_empty) def p_ddg_returns(monkeypatch, text): @@ -55,7 +55,7 @@ async def test_ddg_is_tried_first_and_wins(monkeypatch): # grounded would raise if reached; prove it isn't async def p_boom(*a, **k): raise AssertionError("grounded should not be called when DDG has results") - monkeypatch.setattr(W, "p_gemini_grounded_call", p_boom) + monkeypatch.setattr(W, "gemini_grounded_call", p_boom) t = time.monotonic() res = await search(SearchBody(query="foo")) @@ -68,11 +68,11 @@ async def test_ddg_is_tried_first_and_wins(monkeypatch): @pytest.mark.asyncio async def test_ddg_throttled_falls_over_to_openai(monkeypatch): p_ddg_throttled(monkeypatch) - monkeypatch.setattr(W, "p_resolve_openai_api_key", lambda: "okey") + monkeypatch.setattr(W, "resolve_openai_api_key", lambda: "okey") async def p_openai(api_key, query): return {"text": "grounded answer", "chunks": [("Title", "https://u.example")]} - monkeypatch.setattr(W, "p_openai_websearch", p_openai) + monkeypatch.setattr(W, "openai_websearch", p_openai) res = await search(SearchBody(query="x")) assert res["backend"] == "openai_native" @@ -84,12 +84,12 @@ async def test_ddg_throttled_falls_over_to_openai(monkeypatch): @pytest.mark.asyncio async def test_a_hung_grounded_attempt_is_bounded(monkeypatch): p_ddg_throttled(monkeypatch) - monkeypatch.setattr(W, "P_GROUNDED_ATTEMPT_TIMEOUT", 0.3) - monkeypatch.setattr(W, "p_resolve_gemini_api_key", lambda: "gkey") + monkeypatch.setattr(W, "GROUNDED_TIER_SECONDS", 0.3) + monkeypatch.setattr(W, "resolve_gemini_api_key", lambda: "gkey") async def p_hangs(*a, **k): await asyncio.sleep(30) - monkeypatch.setattr(W, "p_gemini_grounded_call", p_hangs) + monkeypatch.setattr(W, "gemini_grounded_call", p_hangs) t = time.monotonic() res = await search(SearchBody(query="x")) @@ -102,15 +102,15 @@ async def test_a_hung_grounded_attempt_is_bounded(monkeypatch): @pytest.mark.asyncio async def test_primary_openai_reorders_grounded_tier(monkeypatch): p_ddg_throttled(monkeypatch) - monkeypatch.setattr(W, "p_resolve_gemini_api_key", lambda: "gkey") - monkeypatch.setattr(W, "p_resolve_openai_api_key", lambda: "okey") + monkeypatch.setattr(W, "resolve_gemini_api_key", lambda: "gkey") + monkeypatch.setattr(W, "resolve_openai_api_key", lambda: "okey") async def p_gem(*a, **k): return {"text": "GEM", "chunks": [("g", "https://gem.example")]} async def p_oai(api_key, query): return {"text": "OAI", "chunks": [("o", "https://oai.example")]} - monkeypatch.setattr(W, "p_gemini_grounded_call", p_gem) - monkeypatch.setattr(W, "p_openai_websearch", p_oai) + monkeypatch.setattr(W, "gemini_grounded_call", p_gem) + monkeypatch.setattr(W, "openai_websearch", p_oai) res = await search(SearchBody(query="x", primary="openai")) # openai must be tried before gemini when it's the primary @@ -132,11 +132,11 @@ async def test_everything_fails_is_honest_not_empty(monkeypatch): async def test_everything_fails_nudges_browser_not_retry(monkeypatch): # All-fail must hand the model the browser as an escape hatch, not a dead-end "wait and retry". p_ddg_throttled(monkeypatch) - monkeypatch.setattr(W, "p_resolve_openai_api_key", lambda: "okey") # configured but errors + monkeypatch.setattr(W, "resolve_openai_api_key", lambda: "okey") # configured but errors async def p_openai_boom(*a, **k): raise RuntimeError("openai down") - monkeypatch.setattr(W, "p_openai_websearch", p_openai_boom) + monkeypatch.setattr(W, "openai_websearch", p_openai_boom) res = await search(SearchBody(query="sony zv-e10 price", browser_ok=True)) assert res["backend"] == "none" @@ -148,11 +148,11 @@ async def test_everything_fails_nudges_browser_not_retry(monkeypatch): async def test_nudge_suppressed_when_browser_denied(monkeypatch): # A session without browser-delegation tools must never be told to call CreateBrowserAgent. p_ddg_throttled(monkeypatch) - monkeypatch.setattr(W, "p_resolve_openai_api_key", lambda: "okey") + monkeypatch.setattr(W, "resolve_openai_api_key", lambda: "okey") async def p_openai_boom(*a, **k): raise RuntimeError("openai down") - monkeypatch.setattr(W, "p_openai_websearch", p_openai_boom) + monkeypatch.setattr(W, "openai_websearch", p_openai_boom) res = await search(SearchBody(query="sony zv-e10 price")) assert res["backend"] == "none" @@ -186,7 +186,7 @@ async def test_fetch_local_first_wins_and_is_fast(monkeypatch): p_local_returns(monkeypatch, big) async def p_boom(*a, **k): raise AssertionError("grounded fetch should not run when local has content") - monkeypatch.setattr(W, "p_gemini_grounded_call", p_boom) + monkeypatch.setattr(W, "gemini_grounded_call", p_boom) t = time.monotonic() res = await fetch(FetchBody(url="https://x.example")) @@ -198,10 +198,10 @@ async def test_fetch_local_first_wins_and_is_fast(monkeypatch): @pytest.mark.asyncio async def test_fetch_thin_local_falls_to_grounded(monkeypatch): p_local_returns(monkeypatch, "Contents of https://spa.example:\n\n") # JS wall, empty body - monkeypatch.setattr(W, "p_resolve_gemini_api_key", lambda: "gkey") + monkeypatch.setattr(W, "resolve_gemini_api_key", lambda: "gkey") async def p_gem(api_key, prompt, *, use_url_context): return {"text": "rendered page text from grounding", "chunks": []} - monkeypatch.setattr(W, "p_gemini_grounded_call", p_gem) + monkeypatch.setattr(W, "gemini_grounded_call", p_gem) res = await fetch(FetchBody(url="https://spa.example")) assert res["backend"] == "gemini_native" @@ -233,7 +233,7 @@ async def test_browser_search_tier_fires_when_ddg_throttled(monkeypatch): async def p_boom(*a, **k): raise AssertionError("grounded should not be reached once the browser tier answers") - monkeypatch.setattr(W, "p_gemini_grounded_call", p_boom) + monkeypatch.setattr(W, "gemini_grounded_call", p_boom) res = await search(SearchBody(query="q")) assert res["backend"] == "browser_ddg" @@ -245,11 +245,11 @@ async def test_browser_search_skipped_when_no_bridge(monkeypatch): # DDG throttled, no browser bridge -> must fall THROUGH to grounded, not crash. p_ddg_throttled(monkeypatch) p_browser_bridge(monkeypatch, None) - monkeypatch.setattr(W, "p_resolve_openai_api_key", lambda: "okey") + monkeypatch.setattr(W, "resolve_openai_api_key", lambda: "okey") async def p_openai(api_key, query): return {"text": "grounded", "chunks": [("T", "https://u.example")]} - monkeypatch.setattr(W, "p_openai_websearch", p_openai) + monkeypatch.setattr(W, "openai_websearch", p_openai) res = await search(SearchBody(query="q")) assert res["backend"] == "openai_native" From 559bbbe76839f81006f2ab247c961bfd33136c72 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 14:28:49 -0700 Subject: [PATCH 002/117] [eric] web: send the keyless search rungs through a Chrome-fingerprint client (measured 4/8 -> 8/8 on DuckDuckGo) --- backend/apps/agents/tools/browser_http.py | 116 +++++++++++++++++++ backend/apps/agents/tools/search_ddg.py | 60 +++++----- backend/apps/agents/tools/search_ddg_lite.py | 37 +++--- backend/requirements.lock | 65 ++++++++--- backend/requirements.txt | 2 + backend/tests/test_browser_http.py | 107 +++++++++++++++++ backend/tests/test_web_search_ddg.py | 46 +++----- backend/tests/test_web_search_ddg_lite.py | 42 +++---- 8 files changed, 346 insertions(+), 129 deletions(-) create mode 100644 backend/apps/agents/tools/browser_http.py create mode 100644 backend/tests/test_browser_http.py diff --git a/backend/apps/agents/tools/browser_http.py b/backend/apps/agents/tools/browser_http.py new file mode 100644 index 00000000..e201dc2d --- /dev/null +++ b/backend/apps/agents/tools/browser_http.py @@ -0,0 +1,116 @@ +"""One browser-shaped HTTP request, shared by every keyless web rung. + +Search frontends gate on the TLS/JA3 fingerprint of the CLIENT, not on the +headers or the verb. Measured over 8 interleaved randomised rounds against +DuckDuckGo from one machine: plain httpx POST to the html endpoint 4/8, plain +httpx GET to the lite endpoint 4/8 (so switching verb or endpoint changes +nothing), and curl_cffi's Chrome impersonation 8/8 with the same headers, verb +and URL. That is the whole difference between "search sometimes works" and +"search works", so we impersonate whenever curl_cffi imports. + +If it doesn't import (a packaging regression), we degrade to httpx with a full +Chrome header set rather than failing: half a search beats no search. + +This does NOT validate the target host, so it is only for the FIXED hosts we +choose ourselves. User- or model-supplied URLs must go through +`ssrf_guard.safe_fetch`, which re-checks every redirect hop. +""" + +from typing import Dict, Optional + +import httpx +from pydantic import BaseModel, ConfigDict +from typeguard import typechecked + +CHROME_UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +) + +# A real navigation sends all of these; httpx sends almost none of them by default. +BROWSER_HEADERS: Dict[str, str] = { + "User-Agent": CHROME_UA, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Sec-Ch-Ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": '"macOS"', + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Upgrade-Insecure-Requests": "1", +} + +P_IMPERSONATE_PROFILE = "chrome" + + +class HttpReply(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + status: int + text: str + content: bytes + content_type: str + url: str + + +@typechecked +def impersonation_available() -> bool: + """Whether the TLS-impersonating client is installed in this environment.""" + try: + import curl_cffi.requests # noqa: F401 + except Exception: + return False + return True + + +@typechecked +async def p_impersonated( + url: str, method: str, params: Optional[Dict], headers: Dict[str, str], + timeout: float, follow_redirects: bool, +) -> HttpReply: + from curl_cffi.requests import AsyncSession + async with AsyncSession() as session: + resp = await session.request( + method, url, params=params, headers=headers, timeout=timeout, + impersonate=P_IMPERSONATE_PROFILE, allow_redirects=follow_redirects, + ) + return HttpReply( + status=resp.status_code, text=resp.text, content=resp.content, + content_type=resp.headers.get("content-type", ""), url=str(resp.url), + ) + + +@typechecked +async def p_plain( + url: str, method: str, params: Optional[Dict], headers: Dict[str, str], + timeout: float, follow_redirects: bool, +) -> HttpReply: + async with httpx.AsyncClient( + timeout=timeout, follow_redirects=follow_redirects, headers=headers, + ) as client: + resp = await client.request(method, url, params=params) + return HttpReply( + status=resp.status_code, text=resp.text, content=resp.content, + content_type=resp.headers.get("content-type", ""), url=str(resp.url), + ) + + +@typechecked +async def browser_request( + url: str, + *, + method: str = "GET", + params: Optional[Dict] = None, + headers: Optional[Dict[str, str]] = None, + timeout: float = 10.0, + follow_redirects: bool = True, +) -> HttpReply: + """Fetch `url` looking like Chrome. Fixed hosts only; see the module docstring.""" + merged = dict(BROWSER_HEADERS) + if headers: + merged.update(headers) + if impersonation_available(): + return await p_impersonated(url, method, params, merged, timeout, follow_redirects) + return await p_plain(url, method, params, merged, timeout, follow_redirects) diff --git a/backend/apps/agents/tools/search_ddg.py b/backend/apps/agents/tools/search_ddg.py index ba478251..77084c2e 100644 --- a/backend/apps/agents/tools/search_ddg.py +++ b/backend/apps/agents/tools/search_ddg.py @@ -1,32 +1,33 @@ """DuckDuckGo web search: html endpoint primary, lite endpoint fallback. The html endpoint is the richer parse; lite (see search_ddg_lite) covers the two -ways html dies: a 202 throttle and silent markup drift. Only both endpoints -throttling raises DDGRateLimited, so free search no longer has a single point -of failure (the outage class that stranded subscription-only users on -"No search backend is configured").""" +ways html dies: the 202 bot challenge and silent markup drift. Only both +endpoints challenging raises DDGRateLimited, so free search no longer has a +single point of failure (the outage class that stranded subscription-only users +on "No search backend is configured"). + +Both rungs go out through `browser_http`, whose Chrome TLS fingerprint is what +actually decides whether DuckDuckGo answers; a plain httpx client scored 4/8 on +the same queries this one scored 8/8 on.""" import html import re -import httpx - +from backend.apps.agents.tools.browser_http import CHROME_UA +from backend.apps.agents.tools.browser_http import browser_request from backend.apps.agents.tools.search_ddg_lite import search_ddg_lite HTTP_TIMEOUT = 30 -USER_AGENT = ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" -) +USER_AGENT = CHROME_UA class DDGRateLimited(Exception): - """Both DuckDuckGo endpoints answered with the throttle challenge (HTTP 202). + """Every DuckDuckGo frontend answered with the bot challenge (HTTP 202). - Distinct from 'genuinely zero hits' so the caller can fail over to another - backend instead of reporting an empty search to the user. The throttle is - per-IP and burst-triggered; once BOTH html and lite serve it, the only cure - is a different backend or waiting it out.""" + Named for history; this is an anti-automation challenge keyed on the + client's fingerprint, NOT a per-IP rate limit. Distinct from 'genuinely + zero hits' so the caller can fail over to another backend instead of + reporting an empty search to the user.""" def strip_html(raw_html: str) -> str: @@ -41,24 +42,19 @@ def strip_html(raw_html: str) -> str: async def search_ddg(query: str, num_results: int) -> str: """Query DuckDuckGo's html endpoint and parse results; lite is the free fallback.""" - async with httpx.AsyncClient( - timeout=HTTP_TIMEOUT, - follow_redirects=True, - headers={"User-Agent": USER_AGENT}, - ) as client: - resp = await client.post( - "https://html.duckduckgo.com/html/", - data={"q": query}, - ) - # DDG serves its throttle challenge as 202 (a ~14KB no-results page), which is a 2xx so raise_for_status() sails right past it. Before declaring rate-limited, try the lite frontend; only when BOTH throttle is free search actually dead. - if resp.status_code == 202: - lite = await search_ddg_lite(query, num_results) - if lite is None: - raise DDGRateLimited(query) - return lite - resp.raise_for_status() + reply = await browser_request( + "https://html.duckduckgo.com/html/", params={"q": query}, timeout=HTTP_TIMEOUT, + ) + # DDG serves its bot challenge as 202 (a ~14KB no-results page), which is a 2xx so a status check sails right past it. Before giving up, try the lite frontend; only when BOTH challenge is free DDG actually dead. + if reply.status == 202: + lite = await search_ddg_lite(query, num_results) + if lite is None: + raise DDGRateLimited(query) + return lite + if reply.status >= 400: + raise RuntimeError(f"DuckDuckGo html returned HTTP {reply.status}") - body = resp.text + body = reply.text result_blocks = re.findall( r']*class="[^"]*result[^"]*"[^>]*>(.*?)\s*(?=]*class="[^"]*result|$)', diff --git a/backend/apps/agents/tools/search_ddg_lite.py b/backend/apps/agents/tools/search_ddg_lite.py index fe5ac608..77eae343 100644 --- a/backend/apps/agents/tools/search_ddg_lite.py +++ b/backend/apps/agents/tools/search_ddg_lite.py @@ -1,23 +1,22 @@ -"""DuckDuckGo lite-endpoint search: the free fallback when html.duckduckgo.com -throttles (HTTP 202) or its markup drifts. lite.duckduckgo.com is a separate -frontend with simpler, stabler HTML and direct result URLs (no uddg redirect). +"""DuckDuckGo lite-endpoint search: the fallback when html.duckduckgo.com +serves its bot challenge (HTTP 202) or its markup drifts. lite.duckduckgo.com +is a separate frontend with simpler, stabler HTML and direct result URLs (no +uddg redirect). -Returns None on a throttle (caller decides whether that means rate-limited -overall) and a formatted results string (possibly empty) on success.""" +Returns None on a challenge (caller decides whether that means every DDG +frontend is closed) and a formatted results string (possibly empty) on +success.""" import html import re from typing import List, Optional -import httpx from typeguard import typechecked +from backend.apps.agents.tools.browser_http import browser_request + P_LITE_URL = "https://lite.duckduckgo.com/lite/" P_TIMEOUT = 12.0 -P_USER_AGENT = ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" -) P_TAG_RE = re.compile(r"<[^>]+>") # Lite uses single-quoted class attrs today; accept either quote style so a cosmetic flip doesn't kill the parser. P_LINK_RE = re.compile( @@ -52,14 +51,10 @@ def parse_lite_results(body: str, num_results: int) -> str: @typechecked async def search_ddg_lite(query: str, num_results: int) -> Optional[str]: - """None = throttled (202), string = parsed results (may be empty on no hits).""" - async with httpx.AsyncClient( - timeout=P_TIMEOUT, - follow_redirects=True, - headers={"User-Agent": P_USER_AGENT}, - ) as client: - resp = await client.post(P_LITE_URL, data={"q": query}) - if resp.status_code == 202: - return None - resp.raise_for_status() - return parse_lite_results(resp.text, num_results) + """None = bot challenge (202), string = parsed results (may be empty on no hits).""" + reply = await browser_request(P_LITE_URL, params={"q": query}, timeout=P_TIMEOUT) + if reply.status == 202: + return None + if reply.status >= 400: + raise RuntimeError(f"DuckDuckGo lite returned HTTP {reply.status}") + return parse_lite_results(reply.text, num_results) diff --git a/backend/requirements.lock b/backend/requirements.lock index f4755a19..fc8d4c07 100644 --- a/backend/requirements.lock +++ b/backend/requirements.lock @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile backend/requirements.txt --universal --python-version 3.13 --generate-hashes --output-file backend/requirements.lock +# uv pip compile /Users/ericzeng/Downloads/openswarm/backend/requirements.txt --universal --python-version 3.13 --generate-hashes --python /Users/ericzeng/Downloads/openswarm/backend/.venv/bin/python --output-file /Users/ericzeng/Downloads/openswarm/backend/requirements.lock annotated-doc==0.0.4 \ --hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \ --hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4 @@ -13,7 +13,7 @@ annotated-types==0.7.0 \ anthropic==0.97.0 \ --hash=sha256:021e79fd8e21e90ad94dc5ba2bbbd8b1599f424f5b1fab6c06204009cab764be \ --hash=sha256:8a1a472dfabcfc0c52ff6a3eecf724ac7e07107a2f6e2367be55ceb42f5d5613 - # via -r backend/requirements.txt + # via -r /Users/ericzeng/Downloads/openswarm/backend/requirements.txt anyio==4.13.0 \ --hash=sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708 \ --hash=sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc @@ -39,10 +39,11 @@ certifi==2026.5.20 \ --hash=sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897 \ --hash=sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d # via + # curl-cffi # httpcore # httpx # trafilatura -cffi==2.0.0 ; platform_python_implementation != 'PyPy' \ +cffi==2.0.0 \ --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ @@ -127,7 +128,9 @@ cffi==2.0.0 ; platform_python_implementation != 'PyPy' \ --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \ --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf - # via cryptography + # via + # cryptography + # curl-cffi charset-normalizer==3.4.7 \ --hash=sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc \ --hash=sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c \ @@ -268,14 +271,14 @@ claude-agent-sdk==0.1.70 \ --hash=sha256:955b8d57cc06247f6894bc65d1441ae66b4c7bda3b3fcc0cb7f140e0d48757f8 \ --hash=sha256:c69019de2559650b2e8ae1d93f907f27f623748fe25b6f02b7f5dcf05e956f70 \ --hash=sha256:e3c3ab7a0cfd64d40fa8d9b1cf3aac9f0c4b9b910cff92dd07154f75889d63f8 - # via -r backend/requirements.txt + # via -r /Users/ericzeng/Downloads/openswarm/backend/requirements.txt click==8.4.1 \ --hash=sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2 \ --hash=sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96 # via # rich-toolkit # uvicorn -colorama==0.4.6 ; sys_platform == 'win32' \ +colorama==0.4.6 ; sys_platform == 'win32' or platform_system == 'Windows' \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 # via @@ -337,6 +340,29 @@ cryptography==48.0.0 \ --hash=sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c \ --hash=sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b # via pyjwt +curl-cffi==0.15.0 \ + --hash=sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5 \ + --hash=sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3 \ + --hash=sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61 \ + --hash=sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc \ + --hash=sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f \ + --hash=sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13 \ + --hash=sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92 \ + --hash=sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527 \ + --hash=sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572 \ + --hash=sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805 \ + --hash=sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3 \ + --hash=sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce \ + --hash=sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3 \ + --hash=sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d \ + --hash=sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a \ + --hash=sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b \ + --hash=sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d \ + --hash=sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b \ + --hash=sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa \ + --hash=sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28 \ + --hash=sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded + # via -r /Users/ericzeng/Downloads/openswarm/backend/requirements.txt dateparser==1.4.0 \ --hash=sha256:7902b8e85d603494bf70a5a0b1decdddb2270b9c6e6b2bc8a57b93476c0df378 \ --hash=sha256:97a21840d5ecdf7630c584f673338a5afac5dfe84f647baf4d7e8df98f9354a4 @@ -360,7 +386,7 @@ email-validator==2.3.0 \ fastapi==0.136.3 \ --hash=sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620 \ --hash=sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab - # via -r backend/requirements.txt + # via -r /Users/ericzeng/Downloads/openswarm/backend/requirements.txt fastapi-cli==0.0.24 \ --hash=sha256:1afc9c9e21d7ebc8a3ca5e31790cd8d837742be7e4f8b9236e99cb3451f0de00 \ --hash=sha256:4a1f78ed798f106b4fee85ca93b85d8fe33c0a3570f775964d37edb80b8f0edc @@ -435,10 +461,11 @@ httpx==0.28.1 \ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad # via - # -r backend/requirements.txt + # -r /Users/ericzeng/Downloads/openswarm/backend/requirements.txt # anthropic # fastapi # mcp + # swarm-analytics httpx-sse==0.4.3 \ --hash=sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc \ --hash=sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d @@ -569,7 +596,7 @@ jsonschema==4.26.0 \ --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce # via - # -r backend/requirements.txt + # -r /Users/ericzeng/Downloads/openswarm/backend/requirements.txt # mcp jsonschema-specifications==2025.9.1 \ --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ @@ -918,8 +945,8 @@ pillow==12.2.0 \ --hash=sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7 \ --hash=sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06 \ --hash=sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5 - # via -r backend/requirements.txt -pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ + # via -r /Users/ericzeng/Downloads/openswarm/backend/requirements.txt +pycparser==3.0 ; implementation_name != 'PyPy' \ --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 # via cffi @@ -927,12 +954,13 @@ pydantic==2.13.3 \ --hash=sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927 \ --hash=sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d # via - # -r backend/requirements.txt + # -r /Users/ericzeng/Downloads/openswarm/backend/requirements.txt # anthropic # fastapi # mcp # pydantic-extra-types # pydantic-settings + # swarm-analytics pydantic-core==2.46.3 \ --hash=sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba \ --hash=sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35 \ @@ -1083,7 +1111,7 @@ python-dotenv==1.1.1 \ --hash=sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc \ --hash=sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab # via - # -r backend/requirements.txt + # -r /Users/ericzeng/Downloads/openswarm/backend/requirements.txt # pydantic-settings # uvicorn python-multipart==0.0.29 \ @@ -1319,6 +1347,7 @@ rich==15.0.0 \ --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 # via + # curl-cffi # rich-toolkit # typer rich-toolkit==0.19.10 \ @@ -1472,7 +1501,7 @@ starlette==1.1.0 \ swarm-analytics==0.1.1 \ --hash=sha256:49255c5a0962ba1eca3c14471b7df8746f4258d8cd83c8c73931e62381b2be8e \ --hash=sha256:c1d368905a8b53a555bb53bd60c6707323fba4beff3ea2e79a8f83fb91b11cab - # via -r backend/requirements.txt + # via -r /Users/ericzeng/Downloads/openswarm/backend/requirements.txt tld==0.13.2 \ --hash=sha256:9b8fdbdb880e7ba65b216a4937f2c94c49a7226723783d5838fc958ac76f4e0c \ --hash=sha256:d983fa92b9d717400742fca844e29d5e18271079c7bcfabf66d01b39b4a14345 @@ -1480,11 +1509,11 @@ tld==0.13.2 \ trafilatura==2.0.0 \ --hash=sha256:77eb5d1e993747f6f20938e1de2d840020719735690c840b9a1024803a4cd51d \ --hash=sha256:ceb7094a6ecc97e72fea73c7dba36714c5c5b577b6470e4520dca893706d6247 - # via -r backend/requirements.txt + # via -r /Users/ericzeng/Downloads/openswarm/backend/requirements.txt typeguard==4.4.2 \ --hash=sha256:77a78f11f09777aeae7fa08585f33b5f4ef0e7335af40005b0c422ed398ff48c \ --hash=sha256:a6f1065813e32ef365bc3b3f503af8a96f9dd4e0033a02c28c4a4983de8c6c49 - # via -r backend/requirements.txt + # via -r /Users/ericzeng/Downloads/openswarm/backend/requirements.txt typer==0.26.1 \ --hash=sha256:537d27ae686d82967f6383382a952cb32ba4768898541effccb69ca75bbd5d23 \ --hash=sha256:933e4f0083521f3c57d6a5aedf3b073271b2f95a19761b171b494dd6fdb21ff6 @@ -1510,7 +1539,7 @@ typing-inspection==0.4.2 \ # mcp # pydantic # pydantic-settings -tzdata==2026.2 ; sys_platform == 'win32' \ +tzdata==2026.2 ; platform_system == 'Windows' \ --hash=sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10 \ --hash=sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7 # via tzlocal @@ -1518,7 +1547,7 @@ tzlocal==5.3.1 \ --hash=sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd \ --hash=sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d # via - # -r backend/requirements.txt + # -r /Users/ericzeng/Downloads/openswarm/backend/requirements.txt # dateparser urllib3==2.7.0 \ --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ diff --git a/backend/requirements.txt b/backend/requirements.txt index a9b7fea0..8096c282 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,6 +16,8 @@ python-dotenv==1.1.1 Pillow==12.2.0 httpx==0.28.1 trafilatura==2.0.0 +# curl_cffi: Chrome TLS-fingerprint impersonation for the keyless search rungs. Measured 8/8 against DuckDuckGo where plain httpx scored 4/8 on the same queries; abi3 wheels for macos arm64/x64 and win_amd64. Guarded import, so a missing wheel degrades to httpx instead of breaking the backend. +curl_cffi==0.15.0 # swarm-analytics: typed client for the product-analytics ingest; fire-and-forget so it never breaks the app. swarm-analytics==0.1.1 # tzlocal: dev-mode fallback for resolving the user's IANA timezone when diff --git a/backend/tests/test_browser_http.py b/backend/tests/test_browser_http.py new file mode 100644 index 00000000..30686064 --- /dev/null +++ b/backend/tests/test_browser_http.py @@ -0,0 +1,107 @@ +"""The keyless rungs go out through ONE browser-shaped client. + +Why it matters: DuckDuckGo's 202 challenge keys on the client's TLS +fingerprint, not on headers or verb. Measured over 8 interleaved randomised +rounds, plain httpx scored 4/8 and Chrome impersonation 8/8 on the same +queries. These pin the seam and the degrade path. +""" + +import pytest + +import backend.apps.agents.tools.browser_http as BH +import backend.apps.agents.tools.search_ddg as SD +import backend.apps.agents.tools.search_ddg_lite as SDL +from backend.apps.agents.tools.browser_http import BROWSER_HEADERS, HttpReply, browser_request + + +def p_reply(status=200, text="ok"): + return HttpReply(status=status, text=text, content=text.encode(), + content_type="text/html", url="https://x.example") + + +def p_record_transports(monkeypatch): + used = [] + + async def p_imp(*a, **k): + used.append("impersonated") + return p_reply() + + async def p_pl(*a, **k): + used.append("plain") + return p_reply() + + monkeypatch.setattr(BH, "p_impersonated", p_imp) + monkeypatch.setattr(BH, "p_plain", p_pl) + return used + + +@pytest.mark.asyncio +async def test_impersonates_when_the_client_is_installed(monkeypatch): + used = p_record_transports(monkeypatch) + monkeypatch.setattr(BH, "impersonation_available", lambda: True) + await browser_request("https://x.example") + assert used == ["impersonated"] + + +@pytest.mark.asyncio +async def test_degrades_to_httpx_instead_of_failing(monkeypatch): + """A missing wheel must cost us reliability, never the whole backend.""" + used = p_record_transports(monkeypatch) + monkeypatch.setattr(BH, "impersonation_available", lambda: False) + reply = await browser_request("https://x.example") + assert used == ["plain"] + assert reply.status == 200 + + +@pytest.mark.asyncio +async def test_caller_headers_win_over_the_defaults(monkeypatch): + seen = {} + + async def p_pl(url, method, params, headers, timeout, follow_redirects): + seen.update(headers) + return p_reply() + + monkeypatch.setattr(BH, "p_plain", p_pl) + monkeypatch.setattr(BH, "impersonation_available", lambda: False) + await browser_request("https://x.example", headers={"Accept-Language": "de-DE"}) + assert seen["Accept-Language"] == "de-DE" + assert seen["User-Agent"] == BROWSER_HEADERS["User-Agent"] + + +def test_default_headers_look_like_a_real_navigation(): + # A bare User-Agent is the tell that gets a scraper challenged. + for key in ("User-Agent", "Accept", "Accept-Language", "Sec-Fetch-Mode", "Upgrade-Insecure-Requests"): + assert BROWSER_HEADERS.get(key) + assert "Chrome/" in BROWSER_HEADERS["User-Agent"] + + +@pytest.mark.asyncio +async def test_ddg_rungs_send_the_query_as_a_get_param(monkeypatch): + """Pins the shape: a GET with params, through the shared seam, on both frontends.""" + calls = [] + + async def p_req(url, **kw): + calls.append((url, kw.get("method", "GET"), kw.get("params"))) + return p_reply(202, "challenge") + + monkeypatch.setattr(SD, "browser_request", p_req) + monkeypatch.setattr(SDL, "browser_request", p_req) + from backend.apps.agents.tools.web import DDGRateLimited + with pytest.raises(DDGRateLimited): + await SD.search_ddg("some query", 5) + + assert [c[0] for c in calls] == [ + "https://html.duckduckgo.com/html/", + "https://lite.duckduckgo.com/lite/", + ] + for _, method, params in calls: + assert method == "GET" + assert params == {"q": "some query"} + + +def test_curl_cffi_is_a_declared_dependency(): + """It must be in BOTH files: the packaged python-env installs from the LOCK.""" + from pathlib import Path + root = Path(__file__).resolve().parents[1] + assert "curl_cffi==" in (root / "requirements.txt").read_text() + assert "curl-cffi==" in (root / "requirements.lock").read_text() diff --git a/backend/tests/test_web_search_ddg.py b/backend/tests/test_web_search_ddg.py index 05c8435e..a1f37681 100644 --- a/backend/tests/test_web_search_ddg.py +++ b/backend/tests/test_web_search_ddg.py @@ -9,39 +9,25 @@ These pin the two bugs that turned DDG into a flaky 'No results found' source: We mock the network so the test is deterministic and offline. """ -import httpx import pytest +import backend.apps.agents.tools.search_ddg as SD +import backend.apps.agents.tools.search_ddg_lite as SDL +from backend.apps.agents.tools.browser_http import HttpReply from backend.apps.agents.tools.web import WebSearchTool, DDGRateLimited -class p_FakeResp: - def __init__(self, status_code: int, text: str): - self.status_code = status_code - self.text = text - - def raise_for_status(self): - if self.status_code >= 400: - raise httpx.HTTPStatusError("err", request=None, response=None) +def p_reply(status: int, text: str) -> HttpReply: + return HttpReply(status=status, text=text, content=text.encode(), + content_type="text/html", url="https://duckduckgo.example") -class p_FakeClient: - """Stands in for httpx.AsyncClient; returns a canned response.""" - def __init__(self, resp: p_FakeResp): - self.p_resp = resp - - async def __aenter__(self): - return self - - async def __aexit__(self, *a): - return False - - async def post(self, *a, **k): - return self.p_resp - - -def p_patch_client(monkeypatch, resp: p_FakeResp): - monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **k: p_FakeClient(resp)) +def p_patch_client(monkeypatch, reply: HttpReply): + """Mock the ONE seam every keyless rung goes through, whatever transport it picks.""" + async def p_req(url, **kw): + return reply + monkeypatch.setattr(SD, "browser_request", p_req) + monkeypatch.setattr(SDL, "browser_request", p_req) # One real organic result + one sponsored (ad) row in DDG's html markup. @@ -59,14 +45,14 @@ P_HTML_WITH_AD = """ @pytest.mark.asyncio async def test_202_raises_rate_limited_not_empty(monkeypatch): - p_patch_client(monkeypatch, p_FakeResp(202, "throttle challenge, no results")) + p_patch_client(monkeypatch, p_reply(202, "throttle challenge, no results")) with pytest.raises(DDGRateLimited): await WebSearchTool.search_ddg("anything", 5) @pytest.mark.asyncio async def test_execute_reports_rate_limit_clearly(monkeypatch): - p_patch_client(monkeypatch, p_FakeResp(202, "throttle")) + p_patch_client(monkeypatch, p_reply(202, "throttle")) parts = await WebSearchTool().execute({"query": "x", "num_results": 5}, None) msg = parts[0]["text"].lower() assert "rate-limit" in msg @@ -75,7 +61,7 @@ async def test_execute_reports_rate_limit_clearly(monkeypatch): @pytest.mark.asyncio async def test_ads_are_stripped_real_results_kept(monkeypatch): - p_patch_client(monkeypatch, p_FakeResp(200, P_HTML_WITH_AD)) + p_patch_client(monkeypatch, p_reply(200, P_HTML_WITH_AD)) out = await WebSearchTool.search_ddg("topic", 5) assert "example.com/real" in out assert "Real Result Title" in out @@ -88,6 +74,6 @@ async def test_ads_are_stripped_real_results_kept(monkeypatch): @pytest.mark.asyncio async def test_genuinely_empty_is_not_a_rate_limit(monkeypatch): # 200 with no result blocks is a real empty result set, not a throttle. - p_patch_client(monkeypatch, p_FakeResp(200, "nothing here")) + p_patch_client(monkeypatch, p_reply(200, "nothing here")) out = await WebSearchTool.search_ddg("zxcvqwer no hits", 5) assert out == "" diff --git a/backend/tests/test_web_search_ddg_lite.py b/backend/tests/test_web_search_ddg_lite.py index 41fc7485..af8b3805 100644 --- a/backend/tests/test_web_search_ddg_lite.py +++ b/backend/tests/test_web_search_ddg_lite.py @@ -12,9 +12,11 @@ Network is mocked; the lite fixture is the real markup shape captured live import asyncio -import httpx import pytest +import backend.apps.agents.tools.search_ddg as SD +import backend.apps.agents.tools.search_ddg_lite as SDL +from backend.apps.agents.tools.browser_http import HttpReply from backend.apps.agents.tools.web import WebSearchTool, DDGRateLimited from backend.apps.agents.tools.search_ddg_lite import parse_lite_results @@ -34,36 +36,20 @@ P_LITE_BODY = """ P_HTML_202_BODY = "anomaly detected, challenge page" -class p_FakeResp: - def __init__(self, status_code: int, text: str): - self.status_code = status_code - self.text = text - - def raise_for_status(self): - if self.status_code >= 400: - raise httpx.HTTPStatusError("err", request=None, response=None) - - -class p_RoutedClient: - """Fake AsyncClient that answers per-URL, so the html and lite endpoints can behave differently in one test.""" - def __init__(self, routes: dict): - self.routes = routes - - async def __aenter__(self): - return self - - async def __aexit__(self, *a): - return False - - async def post(self, url, *a, **k): - for key, resp in self.routes.items(): - if key in url: - return resp - raise AssertionError(f"unexpected URL {url}") +def p_FakeResp(status: int, text: str) -> HttpReply: + return HttpReply(status=status, text=text, content=text.encode(), + content_type="text/html", url="https://duckduckgo.example") def p_route(monkeypatch, routes: dict): - monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **k: p_RoutedClient(routes)) + """Answer per-URL so the html and lite endpoints can behave differently in one test.""" + async def p_req(url, **kw): + for key, reply in routes.items(): + if key in url: + return reply + raise AssertionError(f"unexpected URL {url}") + monkeypatch.setattr(SD, "browser_request", p_req) + monkeypatch.setattr(SDL, "browser_request", p_req) def test_lite_parser_on_real_shape(): From 629e58a59b20b4c516ff6d4bf65e9a07795f290f Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 14:31:24 -0700 Subject: [PATCH 003/117] [eric] window-controls: fan all three arc lights by data-light, green was stacked on red and ate the close click --- .../pages/Dashboard/cards/WindowControls.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx b/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx index 03fc8606..4aacb7ce 100644 --- a/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx +++ b/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx @@ -47,9 +47,11 @@ export const ARC_CHIP_SX: Record = { }, // red / yellow / green land at 150deg / 90deg / 30deg on a 12px arc over the chip's crown. // Keyed off the HOST (whole pill/thumb) hover, so grazing any part of it fans the dots out. - '.osw-pill-host:hover & .osw-window-lights > :nth-of-type(1)': { transform: 'translate(calc(-50% - 11px), calc(-50% + 5px)) scale(1)', opacity: 1 }, - '.osw-pill-host:hover & .osw-window-lights > :nth-of-type(2)': { transform: 'translate(-50%, calc(-50% - 7px)) scale(1)', opacity: 1, transitionDelay: '40ms' }, - '.osw-pill-host:hover & .osw-window-lights > :nth-of-type(3)': { transform: 'translate(calc(-50% + 11px), calc(-50% + 5px)) scale(1)', opacity: 1, transitionDelay: '80ms' }, + // Addressed by data-light, never by position: green is a wrapper DIV among two BUTTONs, and the + // old nth-of-type counted per tag, so it dealt green the red slot and parked it on top of Close. + '.osw-pill-host:hover & .osw-window-lights > [data-light="close"]': { transform: 'translate(calc(-50% - 11px), calc(-50% + 5px)) scale(1)', opacity: 1 }, + '.osw-pill-host:hover & .osw-window-lights > [data-light="minimize"]': { transform: 'translate(-50%, calc(-50% - 7px)) scale(1)', opacity: 1, transitionDelay: '40ms' }, + '.osw-pill-host:hover & .osw-window-lights > [data-light="zoom"]': { transform: 'translate(calc(-50% + 11px), calc(-50% + 5px)) scale(1)', opacity: 1, transitionDelay: '80ms' }, }; function WindowControls({ onClose, onMinimize, onTile, tiled, noTileMenu }: WindowControlsProps): React.ReactElement { @@ -71,8 +73,8 @@ function WindowControls({ onClose, onMinimize, onTile, tiled, noTileMenu }: Wind const scheduleClose = (): void => { closeTimer.current = window.setTimeout(() => setMenuOpen(false), 550); }; const stop = (e: React.PointerEvent | React.MouseEvent): void => { e.stopPropagation(); }; - const btn = (color: string, symbol: string, onClick: () => void, label: string): React.ReactElement => ( - void, label: string, slot: string): React.ReactElement => ( + { e.stopPropagation(); onClick(); }} onPointerDown={stop} sx={dotSx(color)}> {symbol} @@ -86,9 +88,9 @@ function WindowControls({ onClose, onMinimize, onTile, tiled, noTileMenu }: Wind // through the dots, and you can't aim at a dot without hovering its card first anyway. pointerEvents: 'none', '.osw-card:hover &': { pointerEvents: 'auto' }, }}> - {btn(RED, '×', onClose, 'Close')} - {btn(YELLOW, '−', onMinimize, 'Minimize')} - { e.stopPropagation(); onTile(tiled ? 'restore' : 'fullscreen'); }} From 356f7a4ea19fd3357280e0e074b762c7bab27582 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 14:31:30 -0700 Subject: [PATCH 004/117] [eric] minimized: one glass rail for apps and browsers, real thumbnails, no live webview behind a parked card --- .../Dashboard/canvas/DashboardCanvas.tsx | 8 +- .../Dashboard/cards/DashboardViewCard.tsx | 64 +++++--- .../Dashboard/desktop/MinimizedStack.tsx | 149 ++++++++---------- .../pages/Dashboard/desktop/MinimizedTile.tsx | 140 ++++++++++++++++ .../Dashboard/desktop/minimizedEntries.ts | 63 ++++++++ 5 files changed, 319 insertions(+), 105 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx create mode 100644 frontend/src/app/pages/Dashboard/desktop/minimizedEntries.ts diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 27cb646c..1dd3f047 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -1,7 +1,7 @@ import React, { useEffect, type RefObject } from 'react'; import Box from '@mui/material/Box'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { addViewCard, clearTiledCard, selectFullscreenCardId } from '@/shared/state/dashboardLayoutSlice'; +import { addViewCard, clearTiledCard, toggleMinimizeCard, selectFullscreenCardId } from '@/shared/state/dashboardLayoutSlice'; import DashboardHeader from './DashboardHeader'; import TetherLayer from './TetherLayer'; import DashboardCardLayer from './DashboardCardLayer'; @@ -170,6 +170,7 @@ const DashboardCanvas: React.FC = ({ // macOS full screen: one card owns the whole window, every piece of chrome steps aside; Esc exits. const dispatch = useAppDispatch(); const fullscreenCardId = useAppSelector(selectFullscreenCardId); + const minimizedCards = useAppSelector((s) => s.dashboardLayout.minimizedCards); // The singleton app windows (Workflows, Settings) carry their own fullscreen flag, not a tiledCard; their fill also hides the dock. const settingsFullscreen = useAppSelector((s) => !!s.dashboardLayout.settingsCard?.fullscreen); const anyFullscreen = !!fullscreenCardId || !!workflowsHub?.fullscreen || settingsFullscreen; @@ -268,6 +269,9 @@ const DashboardCanvas: React.FC = ({ {!anyFullscreen && ( { canvas.actions.fitToCards([rect], 1.15, true); onHighlightCard?.(cardId); @@ -285,6 +289,8 @@ const DashboardCanvas: React.FC = ({ outputs={outputs} selectedIds={Array.from(selection.selectedIds.keys())} onFocusCard={(cardId, rect) => { + // A parked card sits off-canvas, so flying to its stored rect would land on empty space; unpark it first. + if (minimizedCards[cardId]) dispatch(toggleMinimizeCard({ cardId })); canvas.actions.fitToCards([rect], 1.15, true); onHighlightCard?.(cardId); }} diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index cf4a7fad..1bbf07e6 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -18,6 +18,7 @@ import KeyboardArrowUpRounded from '@mui/icons-material/KeyboardArrowUpRounded'; import { Output, SERVE_BASE } from '@/shared/state/outputsSlice'; import { setViewCardPosition, setViewDocked, setViewCardSize, setActiveViewCardId, recordClosedCard, addViewCard, setTiledCard, clearTiledCard, toggleMinimizeCard, activateViewCardPreview } from '@/shared/state/dashboardLayoutSlice'; import { removeViewCardCleanly } from '@/shared/viewTeardown'; +import { saveMinimizedShot } from '../desktop/minimizedShots'; import { requestAppSlot, releaseAppSlot, subscribeAppBudget } from '@/shared/appWebviewBudget'; import { expandSession } from '@/shared/state/agentsSlice'; import WindowControls from './WindowControls'; @@ -223,7 +224,9 @@ const DashboardViewCard: React.FC = ({ useEffect(() => { const evaluate = (): void => { let want: boolean; - if (previewDeferred) { + if (isMinimized) { + want = false; // parked in the rail as a still, so a live renderer behind it is pure waste + } else if (previewDeferred) { want = false; // reveal-parked: never boot until the first click clears the defer } else if (alwaysLive) { // Actively used (selected, interacting, agent-driven, tiled, fullscreen): pinned, never capped. @@ -263,8 +266,8 @@ const DashboardViewCard: React.FC = ({ previewLiveRef.current = true; setSuspendSnapshot(null); setPreviewLive(true); - } else if (previewDeferred) { - // Reveal-parked from birth: never booted, so no settle + no frame to capture, just stay light. + } else if (isMinimized || previewDeferred) { + // Parked (minimize already froze its own frame) or never booted: drop it now, no settle beat. if (suspendTimerRef.current) { clearTimeout(suspendTimerRef.current); suspendTimerRef.current = null; } previewLiveRef.current = false; setPreviewLive(false); @@ -290,7 +293,7 @@ const DashboardViewCard: React.FC = ({ window.removeEventListener('resize', evaluate); if (suspendTimerRef.current) { clearTimeout(suspendTimerRef.current); suspendTimerRef.current = null; } }; - }, [alwaysLive, previewDeferred, cardX, cardY, cardWidth, cardHeight, getCanvasState, cardKey]); + }, [alwaysLive, previewDeferred, isMinimized, cardX, cardY, cardWidth, cardHeight, getCanvasState, cardKey]); // Free the cap slot on unmount (card deleted, dashboard switch) so a slot is never leaked. useEffect(() => () => releaseAppSlot(cardKey), [cardKey]); @@ -568,7 +571,20 @@ const DashboardViewCard: React.FC = ({ dispatch(recordClosedCard({ kind: 'view', id: cardKey })); void removeViewCardCleanly(cardKey, dispatch); }; - const onMinimize = () => dispatch(toggleMinimizeCard({ cardId: cardKey })); + // Freeze the app's last frame first so the rail tile shows the real app, then park the card. + const onMinimize = useCallback(() => { + let parked = false; + const park = (): void => { if (parked) return; parked = true; dispatch(toggleMinimizeCard({ cardId: cardKey })); }; + // capturePage can hang forever on a guest that stopped painting (Electron 42); the timer guarantees the park. + window.setTimeout(park, 250); + void (async () => { + try { + const snap = await previewRef.current?.capture?.(); + if (snap) saveMinimizedShot(cardKey, snap); + } catch { /* no frame, the tile falls back to the app's stored thumbnail */ } + park(); + })(); + }, [dispatch, cardKey]); const onTile = (zone: string) => { if (zone === 'restore') dispatch(clearTiledCard(cardKey)); else dispatch(setTiledCard({ cardId: cardKey, zone })); @@ -628,6 +644,7 @@ const DashboardViewCard: React.FC = ({ ref={dockRootRef} data-select-type="view-card" data-select-id={cardKey} + data-keepalive-hidden={isMinimized ? '1' : undefined} onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, { items: [ { label: 'Full Screen', onClick: () => onTile('fullscreen') }, @@ -651,10 +668,13 @@ const DashboardViewCard: React.FC = ({ // contain + willChange: own compositor layer so paint stays scoped (see AgentCard for full rationale). contain: 'layout style', willChange: 'transform', - left: tiledStyle ? tiledStyle.left : dockActive ? dockRect!.x : (dragging ? cardX : displayX), - top: tiledStyle ? tiledStyle.top : (dragging ? cardY : displayY), - width: tiledStyle ? tiledStyle.width : (isMinimized ? 220 : displayW), - height: tiledStyle ? tiledStyle.height : (isMinimized ? 44 : displayH), + // Minimized apps live in the right-edge rail, so the card itself parks off-canvas at full size + // (same trick as browser cards) and restores to exactly the geometry it left. + pointerEvents: isMinimized ? 'none' : undefined, + left: isMinimized ? -100000 : (tiledStyle ? tiledStyle.left : dockActive ? dockRect!.x : (dragging ? cardX : displayX)), + top: isMinimized ? -100000 : (tiledStyle ? tiledStyle.top : (dragging ? cardY : displayY)), + width: tiledStyle ? tiledStyle.width : displayW, + height: tiledStyle ? tiledStyle.height : displayH, transform: tiledStyle ? tiledStyle.transform : (dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined), transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined, borderRadius: isFullscreen ? '12px' : `${c.radius.lg}px`, @@ -750,7 +770,7 @@ const DashboardViewCard: React.FC = ({ e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', flexShrink: 0, mr: 0.25 }}> handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} noTileMenu={tileZone === 'fullscreen'} /> - {!isMinimized && } + = ({ )} - {showControls && !isMinimized && ( + {showControls && ( <> {hasWorkspace && ( = ({ )} - {!isMinimized && ( - - { e.stopPropagation(); setHeaderPeek(false); setHeaderCollapsed((v) => !v); }} - onPointerDown={(e) => e.stopPropagation()} - sx={{ color: c.text.ghost, p: 0.5, '&:hover': { color: c.text.primary } }} - > - - - - )} + + { e.stopPropagation(); setHeaderPeek(false); setHeaderCollapsed((v) => !v); }} + onPointerDown={(e) => e.stopPropagation()} + sx={{ color: c.text.ghost, p: 0.5, '&:hover': { color: c.text.primary } }} + > + + + {/* Preview body */} diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx index f19f866e..ffaf6526 100644 --- a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx @@ -1,37 +1,53 @@ import React from 'react'; import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import LanguageIcon from '@mui/icons-material/Language'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { toggleMinimizeCard, setTiledCard, recordClosedCard } from '@/shared/state/dashboardLayoutSlice'; import { removeBrowserCardCleanly } from '@/shared/browserTeardown'; -import WindowControls, { ARC_CHIP_SX } from '../cards/WindowControls'; -import { getMinimizedShot, dropMinimizedShot } from './minimizedShots'; -import type { BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice'; - -interface CardRect { - x: number; - y: number; - width: number; - height: number; -} +import { removeViewCardCleanly } from '@/shared/viewTeardown'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { GLASS_SURFACE, GLASS_SURFACE_BLUR } from '@/shared/styles/glassSurface'; +import { dropMinimizedShot } from './minimizedShots'; +import { buildMinimizedEntries, MinimizedEntry, MinimizedRect } from './minimizedEntries'; +import MinimizedTile, { MINIMIZED_TILE_W } from './MinimizedTile'; +import type { BrowserCardPosition, ViewCardPosition } from '@/shared/state/dashboardLayoutSlice'; +import type { Output } from '@/shared/state/outputsSlice'; interface MinimizedStackProps { browserCards: Record; - onRestore: (id: string, rect: CardRect) => void; + viewCards: Record; + outputs: Record; + selectedIds: string[]; + onRestore: (id: string, rect: MinimizedRect) => void; } -const THUMB_W = 96; - -/** Right-edge stack of minimized browser windows; click restores the card where it was. */ -function MinimizedStack({ browserCards, onRestore }: MinimizedStackProps): React.ReactElement | null { +/** Right-edge rail of parked windows, browsers and apps alike; click a tile to put it back. */ +function MinimizedStack({ browserCards, viewCards, outputs, selectedIds, onRestore }: MinimizedStackProps): React.ReactElement | null { const dispatch = useAppDispatch(); - const minimized = useAppSelector((s) => s.dashboardLayout.minimizedCards); - const entries = Object.values(browserCards).filter((bc) => minimized[bc.browser_id]); + const c = useClaudeTokens(); + const minimizedCards = useAppSelector((s) => s.dashboardLayout.minimizedCards); + const entries = buildMinimizedEntries({ browserCards, viewCards, outputs, minimizedCards }); if (entries.length === 0) return null; + const restore = (entry: MinimizedEntry): void => { + dropMinimizedShot(entry.id); + dispatch(toggleMinimizeCard({ cardId: entry.id })); + onRestore(entry.id, entry.rect); + }; + const close = (entry: MinimizedEntry): void => { + dropMinimizedShot(entry.id); + if (entry.kind === 'browser') { + dispatch(recordClosedCard({ kind: 'browser', id: entry.id })); + void removeBrowserCardCleanly(entry.id, dispatch); + } else { + dispatch(recordClosedCard({ kind: 'view', id: entry.id })); + void removeViewCardCleanly(entry.id, dispatch); + } + }; + return ( - {entries.map((bc) => { - const activeTab = bc.tabs.find((t) => t.id === bc.activeTabId) || bc.tabs[0]; - const shot = getMinimizedShot(bc.browser_id); - const restore = (): void => { - dropMinimizedShot(bc.browser_id); - dispatch(toggleMinimizeCard({ cardId: bc.browser_id })); - onRestore(bc.browser_id, bc); - }; - return ( - - e.stopPropagation()} - sx={{ - ...ARC_CHIP_SX, - position: 'absolute', top: 2, left: 2, zIndex: 2, background: 'rgba(24,14,32,0.85)', - backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', - opacity: 0, pointerEvents: 'none', transition: 'opacity 140ms ease', - }} - > - { dispatch(recordClosedCard({ kind: 'browser', id: bc.browser_id })); removeBrowserCardCleanly(bc.browser_id, dispatch); }} - onMinimize={restore} - onTile={(zone: string) => { restore(); if (zone !== 'restore') dispatch(setTiledCard({ cardId: bc.browser_id, zone })); }} - tiled={false} - /> - - {shot ? ( - - ) : ( - - {activeTab?.favicon ? ( - - ) : ( - - )} - - {activeTab?.title || 'Browser'} - - - )} - - ); - })} + {entries.map((entry) => ( + restore(entry)} + onClose={() => close(entry)} + onTile={(zone: string) => { restore(entry); if (zone !== 'restore') dispatch(setTiledCard({ cardId: entry.id, zone })); }} + /> + ))} ); } diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx new file mode 100644 index 00000000..e9a38c59 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx @@ -0,0 +1,140 @@ +import React, { useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import LanguageIcon from '@mui/icons-material/Language'; +import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded'; +import { pickIcon } from '../canvas/DashboardGlyph'; +import WindowControls, { ARC_CHIP_SX } from '../cards/WindowControls'; +import { getMinimizedShot } from './minimizedShots'; +import type { MinimizedEntry } from './minimizedEntries'; + +interface MinimizedTileProps { + entry: MinimizedEntry; + accent: string; + selected: boolean; + onRestore: () => void; + onClose: () => void; + onTile: (zone: string) => void; +} + +export const MINIMIZED_TILE_W = 132; + +// Opaque interior: the rail above is the one glass layer, so stacking more alpha in here just muddies +// the text. Chromium also does a backdrop readback per blurred element, so N blurred tiles is N passes. +const TILE_BODY = '#262624'; +const TILE_WELL = '#1b1b19'; +const REST_EDGE = 'rgba(255,255,255,0.10)'; +const HOVER_EDGE = 'rgba(255,255,255,0.16)'; + +/** One parked window: its own last frame, a favicon or glyph, and the title. Hover reveals the lights. */ +function MinimizedTile({ entry, accent, selected, onRestore, onClose, onTile }: MinimizedTileProps): React.ReactElement { + const [faviconFailed, setFaviconFailed] = useState(false); + const preview = getMinimizedShot(entry.id) || entry.thumbnail || null; + const showFavicon = entry.kind === 'browser' && !!entry.faviconUrl && !faviconFailed; + + const mark = (size: number): React.ReactElement => { + if (showFavicon) { + return ( + setFaviconFailed(true)} + sx={{ width: size, height: size, borderRadius: `${Math.round(size / 4)}px`, display: 'block' }} + /> + ); + } + if (entry.kind === 'browser') return ; + // Never a letter here: a glyph initial reads as a bug, so an unmatched name falls back to a real symbol. + const AppIcon = pickIcon(entry.label); + if (AppIcon) return ; + return ; + }; + + return ( + + + {preview ? ( + + ) : ( + mark(26) + )} + e.stopPropagation()} + sx={{ + ...ARC_CHIP_SX, + position: 'absolute', top: 4, left: 4, zIndex: 2, background: 'rgba(24,14,32,0.85)', + backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', + opacity: 0, pointerEvents: 'none', transition: 'opacity 140ms ease', + }} + > + + + + + + + {mark(13)} + + {entry.label} + + + + ); +} + +export default MinimizedTile; diff --git a/frontend/src/app/pages/Dashboard/desktop/minimizedEntries.ts b/frontend/src/app/pages/Dashboard/desktop/minimizedEntries.ts new file mode 100644 index 00000000..0cc1e131 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/minimizedEntries.ts @@ -0,0 +1,63 @@ +import type { BrowserCardPosition, ViewCardPosition } from '@/shared/state/dashboardLayoutSlice'; +import type { Output } from '@/shared/state/outputsSlice'; + +export interface MinimizedRect { + x: number; + y: number; + width: number; + height: number; +} + +export type MinimizedKind = 'browser' | 'view'; + +export interface MinimizedEntry { + id: string; + kind: MinimizedKind; + label: string; + /** Geometry the card is restored to, so the camera can fly back to exactly where it was. */ + rect: MinimizedRect; + faviconUrl?: string; + /** Stored still for the window; the fresh minimize-time shot wins over this when there is one. */ + thumbnail?: string | null; +} + +export interface MinimizedSlices { + browserCards: Record; + viewCards: Record; + outputs: Record; + minimizedCards: Record; +} + +/** Every window parked in the minimized rail, browsers and apps in one list so both share a small state. */ +export function buildMinimizedEntries({ + browserCards, + viewCards, + outputs, + minimizedCards, +}: MinimizedSlices): MinimizedEntry[] { + const list: MinimizedEntry[] = []; + for (const bc of Object.values(browserCards)) { + if (!minimizedCards[bc.browser_id]) continue; + const activeTab = bc.tabs.find((t) => t.id === bc.activeTabId) || bc.tabs[0]; + list.push({ + id: bc.browser_id, + kind: 'browser', + label: activeTab?.title || 'Browser', + rect: bc, + faviconUrl: activeTab?.favicon, + }); + } + for (const [cardKey, vc] of Object.entries(viewCards)) { + if (!minimizedCards[cardKey]) continue; + const name = outputs[vc.output_id]?.name || 'App'; + const instance = vc.instance ?? 1; + list.push({ + id: cardKey, + kind: 'view', + label: instance > 1 ? `${name} #${instance}` : name, + rect: vc, + thumbnail: outputs[vc.output_id]?.thumbnail, + }); + } + return list; +} From d84f2d5301c257865fcaf75f72adec02d6f6b355 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 14:34:29 -0700 Subject: [PATCH 005/117] [eric] web: add Startpage as a second independent search engine so one bot challenge cannot close free search --- .../apps/agents/tools/search/search_ddg.py | 118 ++++++++++++++++++ .../agents/tools/search/search_ddg_lite.py | 60 +++++++++ .../agents/tools/search/search_startpage.py | 80 ++++++++++++ backend/apps/agents/tools/web.py | 4 +- backend/apps/web/web.py | 9 ++ backend/tests/test_browser_http.py | 4 +- backend/tests/test_web_cascade_budget.py | 2 +- backend/tests/test_web_search_cascade.py | 27 ++++ backend/tests/test_web_search_ddg.py | 4 +- backend/tests/test_web_search_ddg_lite.py | 6 +- backend/tests/test_web_search_startpage.py | 103 +++++++++++++++ electron/hiddenBrowser.js | 3 +- 12 files changed, 409 insertions(+), 11 deletions(-) create mode 100644 backend/apps/agents/tools/search/search_ddg.py create mode 100644 backend/apps/agents/tools/search/search_ddg_lite.py create mode 100644 backend/apps/agents/tools/search/search_startpage.py create mode 100644 backend/tests/test_web_search_startpage.py diff --git a/backend/apps/agents/tools/search/search_ddg.py b/backend/apps/agents/tools/search/search_ddg.py new file mode 100644 index 00000000..00d8c472 --- /dev/null +++ b/backend/apps/agents/tools/search/search_ddg.py @@ -0,0 +1,118 @@ +"""DuckDuckGo web search: html endpoint primary, lite endpoint fallback. + +The html endpoint is the richer parse; lite (see search_ddg_lite) covers the two +ways html dies: the 202 bot challenge and silent markup drift. Only both +endpoints challenging raises DDGRateLimited, so free search no longer has a +single point of failure (the outage class that stranded subscription-only users +on "No search backend is configured"). + +Both rungs go out through `browser_http`, whose Chrome TLS fingerprint is what +actually decides whether DuckDuckGo answers; a plain httpx client scored 4/8 on +the same queries this one scored 8/8 on.""" + +import html +import re + +from backend.apps.agents.tools.browser_http import CHROME_UA +from backend.apps.agents.tools.browser_http import browser_request +from backend.apps.agents.tools.search.search_ddg_lite import search_ddg_lite + +HTTP_TIMEOUT = 30 +USER_AGENT = CHROME_UA + + +class DDGRateLimited(Exception): + """Every DuckDuckGo frontend answered with the bot challenge (HTTP 202). + + Named for history; this is an anti-automation challenge keyed on the + client's fingerprint, NOT a per-IP rate limit. Distinct from 'genuinely + zero hits' so the caller can fail over to another backend instead of + reporting an empty search to the user.""" + + +def strip_html(raw_html: str) -> str: + """Naive but effective HTML to plain-text conversion.""" + text = re.sub(r"<(script|style)[^>]*>.*?", "", raw_html, flags=re.DOTALL | re.IGNORECASE) + text = re.sub(r"<[^>]+>", " ", text) + text = html.unescape(text) + text = re.sub(r"[ \t]+", " ", text) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + +async def search_ddg(query: str, num_results: int) -> str: + """Query DuckDuckGo's html endpoint and parse results; lite is the free fallback.""" + reply = await browser_request( + "https://html.duckduckgo.com/html/", params={"q": query}, timeout=HTTP_TIMEOUT, + ) + # DDG serves its bot challenge as 202 (a ~14KB no-results page), which is a 2xx so a status check sails right past it. Before giving up, try the lite frontend; only when BOTH challenge is free DDG actually dead. + if reply.status == 202: + lite = await search_ddg_lite(query, num_results) + if lite is None: + raise DDGRateLimited(query) + return lite + if reply.status >= 400: + raise RuntimeError(f"DuckDuckGo html returned HTTP {reply.status}") + + body = reply.text + + result_blocks = re.findall( + r']*class="[^"]*result[^"]*"[^>]*>(.*?)\s*(?=]*class="[^"]*result|$)', + body, + flags=re.DOTALL, + ) + + entries: list[str] = [] + for block in result_blocks: + if len(entries) >= num_results: + break + + # Handle both class-before-href and href-before-class attribute orders. + link_match = re.search( + r']*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)', + block, + flags=re.DOTALL, + ) + if not link_match: + link_match = re.search( + r']*href="([^"]*)"[^>]*class="[^"]*result__a[^"]*"[^>]*>(.*?)', + block, + flags=re.DOTALL, + ) + if not link_match: + continue + + raw_url = html.unescape(link_match.group(1)) + + # Drop sponsored rows: DDG ads point at its own y.js click-tracker (ad_domain/ad_provider) instead of a real uddg= redirect, so they'd otherwise show up as junk "duckduckgo.com/y.js?ad_..." results. + if "/y.js?" in raw_url or "ad_provider=" in raw_url or "ad_domain=" in raw_url: + continue + + title = strip_html(link_match.group(2)).strip() + + snippet_match = re.search( + r']*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)', + block, + flags=re.DOTALL, + ) + snippet = strip_html(snippet_match.group(1)).strip() if snippet_match else "" + + # DDG wraps URLs in a redirect; extract the real one. + real_url_match = re.search(r"uddg=([^&]+)", raw_url) + if real_url_match: + from urllib.parse import unquote + url = unquote(real_url_match.group(1)) + else: + url = raw_url + + entry = f"[{len(entries) + 1}] {title}\n {url}" + if snippet: + entry += f"\n {snippet}" + entries.append(entry) + + # 200 with zero parsed entries usually means DDG changed its markup out from under the regexes (it has before), not a genuine no-hits; lite's simpler shape is the safety net. + if not entries: + lite = await search_ddg_lite(query, num_results) + if lite: + return lite + return "\n\n".join(entries) diff --git a/backend/apps/agents/tools/search/search_ddg_lite.py b/backend/apps/agents/tools/search/search_ddg_lite.py new file mode 100644 index 00000000..77eae343 --- /dev/null +++ b/backend/apps/agents/tools/search/search_ddg_lite.py @@ -0,0 +1,60 @@ +"""DuckDuckGo lite-endpoint search: the fallback when html.duckduckgo.com +serves its bot challenge (HTTP 202) or its markup drifts. lite.duckduckgo.com +is a separate frontend with simpler, stabler HTML and direct result URLs (no +uddg redirect). + +Returns None on a challenge (caller decides whether that means every DDG +frontend is closed) and a formatted results string (possibly empty) on +success.""" + +import html +import re +from typing import List, Optional + +from typeguard import typechecked + +from backend.apps.agents.tools.browser_http import browser_request + +P_LITE_URL = "https://lite.duckduckgo.com/lite/" +P_TIMEOUT = 12.0 +P_TAG_RE = re.compile(r"<[^>]+>") +# Lite uses single-quoted class attrs today; accept either quote style so a cosmetic flip doesn't kill the parser. +P_LINK_RE = re.compile( + r"""]*href="([^"]+)"[^>]*class=['"]result-link['"][^>]*>(.*?)""", + flags=re.DOTALL, +) +P_SNIPPET_RE = re.compile( + r"""]*class=['"]result-snippet['"][^>]*>(.*?)""", + flags=re.DOTALL, +) + + +@typechecked +def p_strip(text: str) -> str: + return html.unescape(P_TAG_RE.sub("", text)).strip() + + +@typechecked +def parse_lite_results(body: str, num_results: int) -> str: + """Format lite's result rows; links and snippets appear in document order and pair up positionally.""" + links = P_LINK_RE.findall(body) + snippets = [p_strip(s) for s in P_SNIPPET_RE.findall(body)] + entries: List[str] = [] + for i, (url, raw_title) in enumerate(links[:num_results]): + title = p_strip(raw_title) + entry = f"[{i + 1}] {title}\n {html.unescape(url)}" + if i < len(snippets) and snippets[i]: + entry += f"\n {snippets[i]}" + entries.append(entry) + return "\n\n".join(entries) + + +@typechecked +async def search_ddg_lite(query: str, num_results: int) -> Optional[str]: + """None = bot challenge (202), string = parsed results (may be empty on no hits).""" + reply = await browser_request(P_LITE_URL, params={"q": query}, timeout=P_TIMEOUT) + if reply.status == 202: + return None + if reply.status >= 400: + raise RuntimeError(f"DuckDuckGo lite returned HTTP {reply.status}") + return parse_lite_results(reply.text, num_results) diff --git a/backend/apps/agents/tools/search/search_startpage.py b/backend/apps/agents/tools/search/search_startpage.py new file mode 100644 index 00000000..692cb578 --- /dev/null +++ b/backend/apps/agents/tools/search/search_startpage.py @@ -0,0 +1,80 @@ +"""Startpage search: the second independent engine behind DuckDuckGo. + +DuckDuckGo is one operator, so its bot challenge is one point of failure for +every keyless user. Startpage serves Google's index and answered 8/8 on the +same machine and rounds where DuckDuckGo's shipped client shape answered 4/8, +so it is a genuine second opinion rather than a retry. + +It must be a POST: a GET to /sp/search is answered with an Anubis +proof-of-work interstitial (measured, ~10KB and zero results), while the POST +returns the real result page. Parsing is anchored on `result-link` / +`gl-title-link` and the `description` paragraph, never on the emotion CSS +hashes in the same class attributes, which change build to build. + +Returns None when Startpage served a challenge instead of results, so the +caller can tell "closed" apart from "genuinely no hits".""" + +import html +import re +from typing import List, Optional + +from typeguard import typechecked + +from backend.apps.agents.tools.browser_http import browser_request + +P_SEARCH_URL = "https://www.startpage.com/sp/search" +P_TIMEOUT = 12.0 + +P_ANCHOR_RE = re.compile( + r"]*(?:result-link|gl-title-link)[^>]*)>(.*?)", flags=re.DOTALL, +) +P_HREF_RE = re.compile(r'href="([^"]+)"') +P_TITLE_RE = re.compile(r"]*>(.*?)", flags=re.DOTALL) +P_DESC_RE = re.compile( + r']*class="[^"]*\bdescription\b[^"]*"[^>]*>(.*?)

', flags=re.DOTALL, +) +# Startpage inlines a + + +

asyncio — Asynchronous I/O

+
+

The asyncio library, explained.

+ +
+

Real Python walkthrough

+

A hands-on tour.

+
+

Unrelated footer blurb, belongs to no result.

+""" + +P_CHALLENGE_BODY = """ + +
Making sure you are not a bot...
+""" + + +def p_reply(status: int, text: str) -> HttpReply: + return HttpReply(status=status, text=text, content=text.encode(), + content_type="text/html", url="https://www.startpage.com/sp/search") + + +def p_answer(monkeypatch, reply: HttpReply, seen=None): + async def p_req(url, **kw): + if seen is not None: + seen.append((url, kw.get("method"), kw.get("params"))) + return reply + monkeypatch.setattr(SP, "browser_request", p_req) + + +def test_parses_title_url_and_the_snippet_from_its_own_block(): + out = parse_startpage_results(P_BODY, 5) + assert "[1] asyncio — Asynchronous I/O" in out + assert "https://docs.python.org/3/library/asyncio.html" in out + assert "The asyncio library, explained." in out + assert "[2] Real Python walkthrough" in out + assert "A hands-on tour." in out + + +def test_inline_style_never_becomes_the_title(): + """Tag-stripping alone would emit the anchor's own CSS as the result title.""" + out = parse_startpage_results(P_BODY, 5) + assert "css-" not in out + assert "font-size" not in out + + +def test_an_orphan_snippet_is_not_attached_to_a_result(): + out = parse_startpage_results(P_BODY, 5) + assert "Unrelated footer blurb" not in out + + +def test_respects_num_results(): + out = parse_startpage_results(P_BODY, 1) + assert "[1]" in out and "[2]" not in out + + +@pytest.mark.asyncio +async def test_challenge_page_reads_as_refused_not_as_zero_hits(monkeypatch): + # Startpage answers its proof-of-work interstitial with a normal 200, so "parsed nothing" is the only honest signal. + p_answer(monkeypatch, p_reply(200, P_CHALLENGE_BODY)) + assert await search_startpage("q", 5) is None + + +@pytest.mark.asyncio +async def test_http_error_reads_as_refused(monkeypatch): + p_answer(monkeypatch, p_reply(503, "nope")) + assert await search_startpage("q", 5) is None + + +@pytest.mark.asyncio +async def test_must_post_or_startpage_serves_the_proof_of_work_wall(monkeypatch): + seen = [] + p_answer(monkeypatch, p_reply(200, P_BODY), seen) + out = await search_startpage("some query", 5) + assert out is not None + url, method, params = seen[0] + assert url == "https://www.startpage.com/sp/search" + assert method == "POST" + assert params == {"query": "some query", "cat": "web"} diff --git a/electron/hiddenBrowser.js b/electron/hiddenBrowser.js index df85186c..9cb5756f 100644 --- a/electron/hiddenBrowser.js +++ b/electron/hiddenBrowser.js @@ -123,7 +123,8 @@ async function hiddenEvalWithCookies(url, cookieRecords, js) { // Google first (direct result URLs, best quality); DuckDuckGo in a real browser // second (immune to the httpx 202 throttle); Bing last (results are redirect-wrapped). const ENGINES = [ - { name: 'google', url: (q) => `https://www.google.com/search?q=${encodeURIComponent(q)}&num=10&hl=en`, + // udm=14 is Google's plain "Web" tab: ten blue links, no AI Overview and no answer widgets, so the a-h3 scrape gets real result URLs instead of whatever the SERP decided to render today. + { name: 'google', url: (q) => `https://www.google.com/search?q=${encodeURIComponent(q)}&udm=14&num=10&hl=en`, scrape: `Array.from(document.querySelectorAll('a h3')).map(function(h){var a=h.closest('a');return a&&a.href?{t:h.innerText,u:a.href}:null;}).filter(function(x){return x&&x.u.indexOf('http')===0&&x.u.indexOf('google.')===-1;})` }, { name: 'ddg', url: (q) => `https://html.duckduckgo.com/html/?q=${encodeURIComponent(q)}`, scrape: `Array.from(document.querySelectorAll('a.result__a')).map(function(a){var m=a.href.match(/uddg=([^&]+)/);return {t:a.innerText,u:m?decodeURIComponent(m[1]):a.href};})` }, From f30677e255c15cd2987e6c746b79cc4a3f4d4fef Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 14:34:32 -0700 Subject: [PATCH 006/117] [eric] web: move the search engines into tools/search so the folder stays under the item cap --- backend/apps/agents/tools/search_ddg.py | 118 ------------------- backend/apps/agents/tools/search_ddg_lite.py | 60 ---------- 2 files changed, 178 deletions(-) delete mode 100644 backend/apps/agents/tools/search_ddg.py delete mode 100644 backend/apps/agents/tools/search_ddg_lite.py diff --git a/backend/apps/agents/tools/search_ddg.py b/backend/apps/agents/tools/search_ddg.py deleted file mode 100644 index 77084c2e..00000000 --- a/backend/apps/agents/tools/search_ddg.py +++ /dev/null @@ -1,118 +0,0 @@ -"""DuckDuckGo web search: html endpoint primary, lite endpoint fallback. - -The html endpoint is the richer parse; lite (see search_ddg_lite) covers the two -ways html dies: the 202 bot challenge and silent markup drift. Only both -endpoints challenging raises DDGRateLimited, so free search no longer has a -single point of failure (the outage class that stranded subscription-only users -on "No search backend is configured"). - -Both rungs go out through `browser_http`, whose Chrome TLS fingerprint is what -actually decides whether DuckDuckGo answers; a plain httpx client scored 4/8 on -the same queries this one scored 8/8 on.""" - -import html -import re - -from backend.apps.agents.tools.browser_http import CHROME_UA -from backend.apps.agents.tools.browser_http import browser_request -from backend.apps.agents.tools.search_ddg_lite import search_ddg_lite - -HTTP_TIMEOUT = 30 -USER_AGENT = CHROME_UA - - -class DDGRateLimited(Exception): - """Every DuckDuckGo frontend answered with the bot challenge (HTTP 202). - - Named for history; this is an anti-automation challenge keyed on the - client's fingerprint, NOT a per-IP rate limit. Distinct from 'genuinely - zero hits' so the caller can fail over to another backend instead of - reporting an empty search to the user.""" - - -def strip_html(raw_html: str) -> str: - """Naive but effective HTML to plain-text conversion.""" - text = re.sub(r"<(script|style)[^>]*>.*?", "", raw_html, flags=re.DOTALL | re.IGNORECASE) - text = re.sub(r"<[^>]+>", " ", text) - text = html.unescape(text) - text = re.sub(r"[ \t]+", " ", text) - text = re.sub(r"\n{3,}", "\n\n", text) - return text.strip() - - -async def search_ddg(query: str, num_results: int) -> str: - """Query DuckDuckGo's html endpoint and parse results; lite is the free fallback.""" - reply = await browser_request( - "https://html.duckduckgo.com/html/", params={"q": query}, timeout=HTTP_TIMEOUT, - ) - # DDG serves its bot challenge as 202 (a ~14KB no-results page), which is a 2xx so a status check sails right past it. Before giving up, try the lite frontend; only when BOTH challenge is free DDG actually dead. - if reply.status == 202: - lite = await search_ddg_lite(query, num_results) - if lite is None: - raise DDGRateLimited(query) - return lite - if reply.status >= 400: - raise RuntimeError(f"DuckDuckGo html returned HTTP {reply.status}") - - body = reply.text - - result_blocks = re.findall( - r']*class="[^"]*result[^"]*"[^>]*>(.*?)\s*(?=]*class="[^"]*result|$)', - body, - flags=re.DOTALL, - ) - - entries: list[str] = [] - for block in result_blocks: - if len(entries) >= num_results: - break - - # Handle both class-before-href and href-before-class attribute orders. - link_match = re.search( - r']*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)', - block, - flags=re.DOTALL, - ) - if not link_match: - link_match = re.search( - r']*href="([^"]*)"[^>]*class="[^"]*result__a[^"]*"[^>]*>(.*?)', - block, - flags=re.DOTALL, - ) - if not link_match: - continue - - raw_url = html.unescape(link_match.group(1)) - - # Drop sponsored rows: DDG ads point at its own y.js click-tracker (ad_domain/ad_provider) instead of a real uddg= redirect, so they'd otherwise show up as junk "duckduckgo.com/y.js?ad_..." results. - if "/y.js?" in raw_url or "ad_provider=" in raw_url or "ad_domain=" in raw_url: - continue - - title = strip_html(link_match.group(2)).strip() - - snippet_match = re.search( - r']*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)', - block, - flags=re.DOTALL, - ) - snippet = strip_html(snippet_match.group(1)).strip() if snippet_match else "" - - # DDG wraps URLs in a redirect; extract the real one. - real_url_match = re.search(r"uddg=([^&]+)", raw_url) - if real_url_match: - from urllib.parse import unquote - url = unquote(real_url_match.group(1)) - else: - url = raw_url - - entry = f"[{len(entries) + 1}] {title}\n {url}" - if snippet: - entry += f"\n {snippet}" - entries.append(entry) - - # 200 with zero parsed entries usually means DDG changed its markup out from under the regexes (it has before), not a genuine no-hits; lite's simpler shape is the safety net. - if not entries: - lite = await search_ddg_lite(query, num_results) - if lite: - return lite - return "\n\n".join(entries) diff --git a/backend/apps/agents/tools/search_ddg_lite.py b/backend/apps/agents/tools/search_ddg_lite.py deleted file mode 100644 index 77eae343..00000000 --- a/backend/apps/agents/tools/search_ddg_lite.py +++ /dev/null @@ -1,60 +0,0 @@ -"""DuckDuckGo lite-endpoint search: the fallback when html.duckduckgo.com -serves its bot challenge (HTTP 202) or its markup drifts. lite.duckduckgo.com -is a separate frontend with simpler, stabler HTML and direct result URLs (no -uddg redirect). - -Returns None on a challenge (caller decides whether that means every DDG -frontend is closed) and a formatted results string (possibly empty) on -success.""" - -import html -import re -from typing import List, Optional - -from typeguard import typechecked - -from backend.apps.agents.tools.browser_http import browser_request - -P_LITE_URL = "https://lite.duckduckgo.com/lite/" -P_TIMEOUT = 12.0 -P_TAG_RE = re.compile(r"<[^>]+>") -# Lite uses single-quoted class attrs today; accept either quote style so a cosmetic flip doesn't kill the parser. -P_LINK_RE = re.compile( - r"""]*href="([^"]+)"[^>]*class=['"]result-link['"][^>]*>(.*?)""", - flags=re.DOTALL, -) -P_SNIPPET_RE = re.compile( - r"""]*class=['"]result-snippet['"][^>]*>(.*?)""", - flags=re.DOTALL, -) - - -@typechecked -def p_strip(text: str) -> str: - return html.unescape(P_TAG_RE.sub("", text)).strip() - - -@typechecked -def parse_lite_results(body: str, num_results: int) -> str: - """Format lite's result rows; links and snippets appear in document order and pair up positionally.""" - links = P_LINK_RE.findall(body) - snippets = [p_strip(s) for s in P_SNIPPET_RE.findall(body)] - entries: List[str] = [] - for i, (url, raw_title) in enumerate(links[:num_results]): - title = p_strip(raw_title) - entry = f"[{i + 1}] {title}\n {html.unescape(url)}" - if i < len(snippets) and snippets[i]: - entry += f"\n {snippets[i]}" - entries.append(entry) - return "\n\n".join(entries) - - -@typechecked -async def search_ddg_lite(query: str, num_results: int) -> Optional[str]: - """None = bot challenge (202), string = parsed results (may be empty on no hits).""" - reply = await browser_request(P_LITE_URL, params={"q": query}, timeout=P_TIMEOUT) - if reply.status == 202: - return None - if reply.status >= 400: - raise RuntimeError(f"DuckDuckGo lite returned HTTP {reply.status}") - return parse_lite_results(reply.text, num_results) From 88abab0c1c5f29918c3526d402a584cd6140088a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 14:40:48 -0700 Subject: [PATCH 007/117] [eric] web: extract PDF text and refuse other binaries instead of dumping raw bytes into the model --- backend/apps/agents/tools/fetch/page_text.py | 110 +++++++++++++ backend/apps/agents/tools/web.py | 29 ++-- backend/requirements.lock | 32 ++-- backend/requirements.txt | 2 + backend/tests/test_web_fetch_body.py | 154 +++++++++++++++++++ 5 files changed, 294 insertions(+), 33 deletions(-) create mode 100644 backend/apps/agents/tools/fetch/page_text.py create mode 100644 backend/tests/test_web_fetch_body.py diff --git a/backend/apps/agents/tools/fetch/page_text.py b/backend/apps/agents/tools/fetch/page_text.py new file mode 100644 index 00000000..38e482c4 --- /dev/null +++ b/backend/apps/agents/tools/fetch/page_text.py @@ -0,0 +1,110 @@ +"""Turn a fetched response body into text a model can actually read. + +WebFetch used to hand anything non-HTML straight to the model as `resp.text`, +so fetching a PDF posted ~173KB-2MB of `%PDF-1.5` binary into the context +window: pure cost, zero information, and it pushed real content out. PDFs now +get their text layer extracted, and anything else that isn't textual is refused +with a message that says what it was instead of dumping its bytes.""" + +import io +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from typeguard import typechecked + +# A 2.2MB, 15-page paper extracts to ~40K chars in 0.8s; this bounds a pathological book-sized PDF. +MAX_PDF_PAGES = 100 +P_PDF_MAGIC = b"%PDF" +P_TEXTUAL_HINTS = ("text/", "json", "xml", "javascript", "csv", "yaml", "markdown") + + +class PageText(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + text: str + kind: str + + +@typechecked +def looks_like_pdf(content_type: str, content: bytes) -> bool: + """Servers mislabel PDFs constantly, so the magic bytes get the final say.""" + return "pdf" in content_type.lower() or content[:4].startswith(P_PDF_MAGIC) + + +@typechecked +def p_is_textual(content_type: str, content: bytes) -> bool: + if any(hint in content_type.lower() for hint in P_TEXTUAL_HINTS): + return True + sample = content[:4096] + if not sample: + return True + if b"\x00" in sample: + return False + printable = sum(1 for byte in sample if byte >= 32 or byte in (9, 10, 13)) + return printable / len(sample) > 0.9 + + +@typechecked +def p_describe_size(content: bytes) -> str: + kb = len(content) / 1024 + return f"{kb:.0f} KB" if kb < 1024 else f"{kb / 1024:.1f} MB" + + +@typechecked +def extract_pdf_text(content: bytes) -> Optional[str]: + """The PDF's text layer, or None when there isn't one we can read.""" + try: + from pypdf import PdfReader + except Exception: + return None + try: + reader = PdfReader(io.BytesIO(content)) + pages = reader.pages[:MAX_PDF_PAGES] + chunks = [(page.extract_text() or "").strip() for page in pages] + except Exception: + return None + body = "\n\n".join(chunk for chunk in chunks if chunk).strip() + if not body: + return None + if len(reader.pages) > MAX_PDF_PAGES: + body += f"\n\n... (first {MAX_PDF_PAGES} of {len(reader.pages)} pages)" + return body + + +@typechecked +def html_to_text(raw_html: str) -> str: + """Main-content extraction, with a regex strip as the floor for login walls and JS-heavy pages.""" + from backend.apps.agents.tools.search.search_ddg import strip_html + try: + import trafilatura # type: ignore + extracted = trafilatura.extract( + raw_html, include_comments=False, include_tables=True, favor_precision=True, + ) + except Exception: + extracted = None + return extracted or strip_html(raw_html) + + +@typechecked +def body_to_text(content_type: str, content: bytes, raw_text: str) -> PageText: + """Readable text plus what it came from; never raw binary.""" + if looks_like_pdf(content_type, content): + extracted = extract_pdf_text(content) + if extracted: + return PageText(text=extracted, kind="pdf") + return PageText( + text=( + f"This URL is a PDF ({p_describe_size(content)}) with no extractable text layer; " + "it is probably a scan or is encrypted. Nothing was read from it." + ), + kind="pdf_unreadable", + ) + if p_is_textual(content_type, content): + return PageText(text=raw_text, kind="text") + return PageText( + text=( + f"This URL is not a readable document: {content_type or 'unknown type'}, " + f"{p_describe_size(content)} of binary data. Nothing was read from it." + ), + kind="binary", + ) diff --git a/backend/apps/agents/tools/web.py b/backend/apps/agents/tools/web.py index 73da5d2a..c3c63f71 100644 --- a/backend/apps/agents/tools/web.py +++ b/backend/apps/agents/tools/web.py @@ -13,8 +13,8 @@ from backend.apps.agents.tools.search.search_ddg import ( DDGRateLimited, HTTP_TIMEOUT, USER_AGENT, - strip_html, ) +from backend.apps.agents.tools.fetch.page_text import body_to_text, html_to_text, looks_like_pdf from backend.apps.agents.tools.search.search_ddg import search_ddg as run_ddg_search from backend.apps.agents.tools.ssrf_guard import SSRFBlocked, safe_fetch @@ -126,9 +126,11 @@ class WebSearchTool(BaseTool): return [{"type": "text", "text": f"No search results found for: {query}"}] return [{"type": "text", "text": results}] except DDGRateLimited: + # "Wait and retry" was a dead end: the 202 is an anti-automation challenge on the client, not a cooldown, so an immediate retry gets the same answer. return [{"type": "text", "text": ( - "DuckDuckGo is rate-limiting this network right now (HTTP 202). " - "Wait a bit and retry, or use a different search source." + "DuckDuckGo answered its bot challenge (HTTP 202) instead of results, on both " + "its html and lite frontends. Retrying the same query will not clear it; use " + "another search source." )}] except Exception as exc: return [{"type": "text", "text": f"Web search error: {exc}"}] @@ -182,25 +184,14 @@ class WebFetchTool(BaseTool): return [{"type": "text", "text": f"Error fetching {url}: {exc}"}] content_type = resp.headers.get("content-type", "") - is_html = "html" in content_type or resp.text.strip().startswith(" bytes: + """A genuine 600-byte one-page PDF with a real font resource and xref table.""" + stream = f"BT /F1 18 Tf 20 100 Td ({text}) Tj ET".encode() + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R " + b"/Resources << /Font << /F1 5 0 R >> >> >>", + b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ] + out = bytearray(b"%PDF-1.4\n") + offsets = [] + for i, body in enumerate(objects, start=1): + offsets.append(len(out)) + out += f"{i} 0 obj\n".encode() + body + b"\nendobj\n" + xref = len(out) + out += f"xref\n0 {len(objects) + 1}\n".encode() + b"0000000000 65535 f \n" + for offset in offsets: + out += f"{offset:010d} 00000 n \n".encode() + out += f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode() + return bytes(out) + + +# ------------------------------------------------------------------ PDF + + +def test_pdf_is_detected_by_magic_bytes_not_just_the_header(): + # Servers mislabel PDFs as text/html constantly. + assert looks_like_pdf("text/html", b"%PDF-1.7\n...") is True + assert looks_like_pdf("application/pdf", b"") is True + assert looks_like_pdf("text/html", b"") is False + + +def test_a_real_pdf_yields_its_text_not_its_bytes(): + raw = p_minimal_pdf("Attention Is All You Need") + out = body_to_text("application/pdf", raw, raw.decode("latin-1")) + assert out.kind == "pdf" + assert "Attention Is All You Need" in out.text + assert "%PDF" not in out.text + + +def test_a_pdf_with_no_text_layer_says_so_instead_of_dumping_it(): + fake = b"%PDF-1.4\n" + b"\x00\x01\x02" * 500 + out = body_to_text("application/pdf", fake, "ignored") + assert out.kind == "pdf_unreadable" + assert "no extractable text layer" in out.text + assert "\x00" not in out.text + + +def test_extract_returns_none_rather_than_raising_on_garbage(): + assert extract_pdf_text(b"not a pdf at all") is None + + +def test_page_cap_is_bounded(): + assert 0 < MAX_PDF_PAGES <= 500 + + +# ------------------------------------------------------------------ other binaries + + +def test_an_image_is_refused_with_a_description_not_its_bytes(): + png = b"\x89PNG\r\n\x1a\n" + bytes(range(256)) * 40 + out = body_to_text("image/png", png, png.decode("latin-1")) + assert out.kind == "binary" + assert "image/png" in out.text + assert "\x89PNG" not in out.text + assert len(out.text) < 400 + + +def test_json_and_plain_text_still_pass_through_verbatim(): + payload = '{"stars": 68000, "name": "cpython"}' + out = body_to_text("application/json", payload.encode(), payload) + assert out.kind == "text" + assert out.text == payload + + +def test_mislabelled_text_is_still_treated_as_text(): + # application/octet-stream on a plain-text file is common; the bytes get the final say. + payload = "name,value\nalpha,1\nbeta,2\n" + out = body_to_text("application/octet-stream", payload.encode(), payload) + assert out.kind == "text" + assert out.text == payload + + +# ------------------------------------------------------------------ wayback + + +def p_wayback_reply(monkeypatch, status: int, text: str, url: str): + async def p_req(target, **kw): + return HttpReply(status=status, text=text, content=text.encode(), + content_type="text/html", url=url) + monkeypatch.setattr(WB, "browser_request", p_req) + + +P_ARCHIVED = "
" + ("The original article text. " * 40) + "
" + + +def test_snapshot_date_is_read_from_the_archive_url(): + assert snapshot_date("https://web.archive.org/web/20260728200922/https://x.example/") == "2026-07-28" + assert snapshot_date("https://web.archive.org/web/2/https://x.example/") is None + + +@pytest.mark.asyncio +async def test_a_dead_link_is_answered_from_the_archive(monkeypatch): + p_wayback_reply(monkeypatch, 200, P_ARCHIVED, + "https://web.archive.org/web/20260508082837/https://gone.example/post") + out = await fetch_wayback("https://gone.example/post") + assert out is not None + assert "The original article text." in out + # the model must know it is reading a snapshot, and from when + assert "2026-05-08" in out + assert "Archived copy" in out + + +@pytest.mark.asyncio +async def test_no_snapshot_reads_as_no_answer(monkeypatch): + p_wayback_reply(monkeypatch, 404, "not archived", + "https://web.archive.org/web/2/https://gone.example/post") + assert await fetch_wayback("https://gone.example/post") is None + + +@pytest.mark.asyncio +async def test_a_stub_snapshot_is_not_passed_off_as_the_page(monkeypatch): + p_wayback_reply(monkeypatch, 200, "tiny", + "https://web.archive.org/web/20260101000000/https://x.example/") + assert await fetch_wayback("https://x.example/") is None + + +@pytest.mark.asyncio +async def test_a_redirect_off_the_archive_is_refused(monkeypatch): + """We hand the archive a caller-supplied URL, so we confirm where we landed.""" + p_wayback_reply(monkeypatch, 200, P_ARCHIVED, "http://127.0.0.1:8324/api/settings") + assert await fetch_wayback("https://x.example/") is None From 65430e1234ddc896202103df55d82d9d6cf88f78 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 14:40:48 -0700 Subject: [PATCH 008/117] [eric] web: fall back to the Wayback Machine when the live page is dead or walled --- backend/apps/agents/tools/fetch/wayback.py | 55 ++++++++++++++++++++++ backend/apps/web/web.py | 12 ++++- backend/tests/test_web_cascade_budget.py | 2 +- backend/tests/test_web_search_cascade.py | 23 +++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 backend/apps/agents/tools/fetch/wayback.py diff --git a/backend/apps/agents/tools/fetch/wayback.py b/backend/apps/agents/tools/fetch/wayback.py new file mode 100644 index 00000000..3dad62bd --- /dev/null +++ b/backend/apps/agents/tools/fetch/wayback.py @@ -0,0 +1,55 @@ +"""Wayback Machine fallback for URLs the live web won't hand over. + +When a page is gone (404, domain dead, article pulled) or sits behind a wall +that no client-side trick beats, the archive usually still has the REAL text, +which beats a grounded model's summary of a page it also couldn't read. + +Uses `web.archive.org/web/2/`, which redirects to the closest snapshot. +The documented `archive.org/wayback/available` JSON API is NOT used: it is +aggressively throttled and answered 429 on every probe from this machine, while +the redirect path answered in 0.5-3s. +""" + +import re +from typing import Optional +from urllib.parse import urlparse + +from typeguard import typechecked + +from backend.apps.agents.tools.browser_http import browser_request +from backend.apps.agents.tools.fetch.page_text import html_to_text + +P_WAYBACK_LATEST = "https://web.archive.org/web/2/" +P_ALLOWED_HOST = "web.archive.org" +P_TIMEOUT = 10.0 +# Below this the "snapshot" is a stub or an archived error page, not the article. +P_MIN_SUBSTANCE_CHARS = 200 +P_SNAPSHOT_RE = re.compile(r"/web/(\d{4})(\d{2})(\d{2})\d*/") + + +@typechecked +def snapshot_date(archived_url: str) -> Optional[str]: + """The snapshot's date, so the model knows how stale the text is.""" + match = P_SNAPSHOT_RE.search(archived_url) + if not match: + return None + return f"{match.group(1)}-{match.group(2)}-{match.group(3)}" + + +@typechecked +async def fetch_wayback(url: str) -> Optional[str]: + """The archived page text, or None when there is no usable snapshot.""" + reply = await browser_request(P_WAYBACK_LATEST + url, timeout=P_TIMEOUT) + # We hand the archive a caller-supplied URL, so confirm we actually ended up on the archive and not somewhere it redirected us. + if urlparse(reply.url).hostname != P_ALLOWED_HOST: + return None + if reply.status != 200: + return None + text = html_to_text(reply.text).strip() + if len(text) < P_MIN_SUBSTANCE_CHARS: + return None + taken = snapshot_date(reply.url) + header = f"Archived copy of {url}" + header += f" (Wayback Machine snapshot from {taken}); the live page could not be read." if taken \ + else " (Wayback Machine); the live page could not be read." + return f"{header}\n\n{text}" diff --git a/backend/apps/web/web.py b/backend/apps/web/web.py index a4ef966c..c133bc4b 100644 --- a/backend/apps/web/web.py +++ b/backend/apps/web/web.py @@ -69,6 +69,7 @@ KEYLESS_TIER_SECONDS = 8.0 # a search frontend answers in ~1s; >8s is a h BROWSER_TIER_SECONDS = 12.0 # the main-bridge send has its own per-action timeout GROUNDED_TIER_SECONDS = 45.0 # grounded native search legitimately takes 30-42s LOCAL_FETCH_TIER_SECONDS = 15.0 # normal pages return in <2s +ARCHIVE_TIER_SECONDS = 10.0 # the Wayback redirect path answered in 0.5-3s # --------------------------------------------------------------------------- Helpers --------------------------------------------------------------------------- @@ -95,7 +96,7 @@ async def p_browser_bridge(action: str, params: Dict) -> Optional[Dict]: return res -# When every search backend fails, point the model at the in-product browser (always-on CreateBrowserAgent tool) instead of telling it to "wait and retry", which it can't do and just relays as a dead end. The real Chromium renders pages and isn't subject to the scrape throttle. +# When every search backend fails, point the model at the in-product browser (always-on CreateBrowserAgent tool) instead of telling it to "wait and retry", which it can't do and just relays as a dead end. A real Chromium carries a real browser fingerprint, which is what the challenge is actually keyed on. @typechecked def p_browser_fallback_nudge(query: str) -> str: return ( @@ -272,6 +273,14 @@ async def fetch(body: FetchBody) -> Dict: return None return {"url": body.url, "content": f"Contents of {body.url}:\n\n{res['text']}", "backend": "browser"} + async def try_wayback() -> Optional[Dict]: + # A dead link or a hard bot wall is exactly what the archive is for, and it returns the page's REAL text where a grounded fetcher can only summarise a page it also can't read. + from backend.apps.agents.tools.fetch.wayback import fetch_wayback + text = await fetch_wayback(body.url) + if not text: + return None + return {"url": body.url, "content": text, "backend": "wayback"} + async def try_gemini() -> Optional[Dict]: if not gemini_key: return None @@ -313,6 +322,7 @@ async def fetch(body: FetchBody) -> Dict: tiers = [ CascadeTier(name="local", run=try_local, budget=LOCAL_FETCH_TIER_SECONDS), CascadeTier(name="browser", run=try_browser_fetch, budget=BROWSER_TIER_SECONDS), + CascadeTier(name="wayback", run=try_wayback, budget=ARCHIVE_TIER_SECONDS), ] + p_grounded_tiers("fetch", body.primary, { "gemini_native": try_gemini, "gemini_subscription": try_gemini_subscription, diff --git a/backend/tests/test_web_cascade_budget.py b/backend/tests/test_web_cascade_budget.py index 297c3b1d..b0c4baeb 100644 --- a/backend/tests/test_web_cascade_budget.py +++ b/backend/tests/test_web_cascade_budget.py @@ -127,5 +127,5 @@ def test_search_tier_budgets_leave_room_for_the_grounded_tier(): grounded_floor = 20.0 search_cheap = 2 * W.KEYLESS_TIER_SECONDS + W.BROWSER_TIER_SECONDS assert W.SEARCH_BUDGET_SECONDS - search_cheap >= grounded_floor - fetch_cheap = W.LOCAL_FETCH_TIER_SECONDS + W.BROWSER_TIER_SECONDS + fetch_cheap = W.LOCAL_FETCH_TIER_SECONDS + W.BROWSER_TIER_SECONDS + W.ARCHIVE_TIER_SECONDS assert W.FETCH_BUDGET_SECONDS - fetch_cheap >= grounded_floor diff --git a/backend/tests/test_web_search_cascade.py b/backend/tests/test_web_search_cascade.py index 5ee71c09..c29bad62 100644 --- a/backend/tests/test_web_search_cascade.py +++ b/backend/tests/test_web_search_cascade.py @@ -15,6 +15,7 @@ import time import pytest +import backend.apps.agents.tools.fetch.wayback as WB import backend.apps.agents.tools.search.search_startpage as SP import backend.apps.web.web as W from backend.apps.web.web import search, SearchBody @@ -41,6 +42,10 @@ def p_no_network(monkeypatch): return None monkeypatch.setattr(SP, "search_startpage", p_startpage_closed) + async def p_no_snapshot(url): + return None + monkeypatch.setattr(WB, "fetch_wayback", p_no_snapshot) + def p_ddg_returns(monkeypatch, text): async def p_f(query, num): @@ -244,6 +249,24 @@ async def test_fetch_local_error_returned_as_last_resort(monkeypatch): assert "HTTP error 403" in res["content"] +@pytest.mark.asyncio +async def test_fetch_falls_to_the_archive_when_the_live_page_is_gone(monkeypatch): + """A dead link is exactly what the archive is for; the real text beats a grounded summary of nothing.""" + p_local_returns(monkeypatch, "HTTP error 404 fetching https://gone.example/post") + + async def p_snapshot(url): + return "Archived copy of https://gone.example/post (Wayback Machine snapshot from 2026-05-08)\n\nThe original text." + monkeypatch.setattr(WB, "fetch_wayback", p_snapshot) + + async def p_boom(*a, **k): + raise AssertionError("a paid fetcher must not run once the archive answered") + monkeypatch.setattr(W, "gemini_grounded_call", p_boom) + + res = await fetch(FetchBody(url="https://gone.example/post")) + assert res["backend"] == "wayback" + assert "The original text." in res["content"] + + # --- packaged-browser tier: fires when DDG throttles, skipped when no bridge --- def p_browser_bridge(monkeypatch, result): From 1172fb07c50827ae38f87179c6d9a284df1f46ac Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 14:40:48 -0700 Subject: [PATCH 009/117] [eric] web: say bot challenge, not rate limit, since retrying the same query never clears it --- backend/tests/test_web_search_ddg.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/backend/tests/test_web_search_ddg.py b/backend/tests/test_web_search_ddg.py index 0fd4f70a..b6897046 100644 --- a/backend/tests/test_web_search_ddg.py +++ b/backend/tests/test_web_search_ddg.py @@ -1,8 +1,8 @@ -"""DuckDuckGo parsing robustness: rate-limit (202) and ad-row stripping. +"""DuckDuckGo parsing robustness: bot challenge (202) and ad-row stripping. These pin the two bugs that turned DDG into a flaky 'No results found' source: - 1. DDG serves its throttle challenge as HTTP 202 (a 2xx), so raise_for_status() - missed it and we parsed an empty page as a real empty result set. + 1. DDG serves its bot challenge as HTTP 202 (a 2xx), so a status check missed + it and we parsed an empty page as a real empty result set. 2. Sponsored rows point at DDG's own y.js click-tracker (ad_domain/ad_provider) and were emitted as junk 'duckduckgo.com/y.js?...' results. @@ -51,12 +51,15 @@ async def test_202_raises_rate_limited_not_empty(monkeypatch): @pytest.mark.asyncio -async def test_execute_reports_rate_limit_clearly(monkeypatch): - p_patch_client(monkeypatch, p_reply(202, "throttle")) +async def test_execute_names_the_real_cause_not_a_rate_limit(monkeypatch): + p_patch_client(monkeypatch, p_reply(202, "challenge")) parts = await WebSearchTool().execute({"query": "x", "num_results": 5}, None) msg = parts[0]["text"].lower() - assert "rate-limit" in msg + assert "bot challenge" in msg assert "no search results" not in msg # the old bogus message must be gone + # It is a fingerprint challenge, not a cooldown, so we must not send the model off to wait. + assert "rate-limit" not in msg and "rate limit" not in msg + assert "wait" not in msg @pytest.mark.asyncio From b8cfaff69082415826f5fa3261240fe476fd46fc Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 14:55:56 -0700 Subject: [PATCH 010/117] [eric] web: stop the fetch cascade on a body with no text layer instead of buying a paid summary of nothing --- backend/apps/agents/tools/web.py | 24 +-- backend/apps/web/web.py | 23 +-- backend/tests/test_web_fetch_cascade.py | 124 +++++++++++++++ backend/tests/test_web_search_cascade.py | 182 ++++------------------- backend/tests/web_cascade_fixtures.py | 66 ++++++++ 5 files changed, 236 insertions(+), 183 deletions(-) create mode 100644 backend/tests/test_web_fetch_cascade.py create mode 100644 backend/tests/web_cascade_fixtures.py diff --git a/backend/apps/agents/tools/web.py b/backend/apps/agents/tools/web.py index c3c63f71..3d14e67d 100644 --- a/backend/apps/agents/tools/web.py +++ b/backend/apps/agents/tools/web.py @@ -14,7 +14,7 @@ from backend.apps.agents.tools.search.search_ddg import ( HTTP_TIMEOUT, USER_AGENT, ) -from backend.apps.agents.tools.fetch.page_text import body_to_text, html_to_text, looks_like_pdf +from backend.apps.agents.tools.fetch.page_text import PageText, body_to_text, html_to_text, looks_like_pdf from backend.apps.agents.tools.search.search_ddg import search_ddg as run_ddg_search from backend.apps.agents.tools.ssrf_guard import SSRFBlocked, safe_fetch @@ -165,9 +165,13 @@ class WebFetchTool(BaseTool): } async def execute(self, input_data: dict, context: ToolContext) -> list[dict]: - url: str = input_data["url"] - prompt: str | None = input_data.get("prompt") + page = await self.fetch_page(input_data["url"], input_data.get("prompt")) + return [{"type": "text", "text": page.text}] + @staticmethod + async def fetch_page(url: str, prompt: str | None = None) -> PageText: + """The page as readable text, plus WHAT it was, so callers can tell a + JS wall (worth another tier) from a PNG (nothing left to try).""" try: resp = await safe_fetch( url, @@ -177,11 +181,11 @@ class WebFetchTool(BaseTool): ) resp.raise_for_status() except SSRFBlocked as exc: - return [{"type": "text", "text": f"Refused to fetch {url}: {exc}"}] + return PageText(text=f"Refused to fetch {url}: {exc}", kind="error") except httpx.HTTPStatusError as exc: - return [{"type": "text", "text": f"HTTP error {exc.response.status_code} fetching {url}"}] + return PageText(text=f"HTTP error {exc.response.status_code} fetching {url}", kind="error") except Exception as exc: - return [{"type": "text", "text": f"Error fetching {url}: {exc}"}] + return PageText(text=f"Error fetching {url}: {exc}", kind="error") content_type = resp.headers.get("content-type", "") # A PDF's content-type often says html, so check the magic bytes before trusting the header. @@ -189,14 +193,12 @@ class WebFetchTool(BaseTool): is_html = not is_pdf and ("html" in content_type or resp.text.strip().startswith(" str: - out: List[str] = [] - for p in parts: - if isinstance(p, dict) and p.get("type") == "text": - out.append(str(p.get("text", ""))) - return "\n".join(out) - - # Drive the packaged app's offscreen Chromium (main-process hidden window) for a fetch/search. Returns the bridge result dict, or None when no Electron main bridge is connected (dev/headless/backend-only) so the cascade just skips this tier. This is the real "browser reachable" gate, OPENSWARM_BROWSER_OK is effectively always "1" and not trustworthy for this. @typechecked async def p_browser_bridge(action: str, params: Dict) -> Optional[Dict]: @@ -256,15 +247,17 @@ async def fetch(body: FetchBody) -> Dict: # Fast path: direct httpx + trafilatura, and it returns the page's ACTUAL text (the grounded fetchers summarize, which is slower and loses detail). Thin/errored reads (JS walls, paywalls, HTTP errors) fall through. nonlocal local_text from backend.apps.agents.tools.web import WebFetchTool - parts = await WebFetchTool().execute({"url": body.url, "prompt": body.prompt or ""}, None) - text = p_join_text(parts) - local_text = text - if text.startswith(("HTTP error", "Error fetching", "Refused to fetch")): + page = await WebFetchTool.fetch_page(body.url, body.prompt) + local_text = page.text + if page.kind == "error": return None - body_text = text.split("\n\n", 1)[-1] if "\n\n" in text else text + # A PNG or a scanned PDF has no text for ANY tier to find, so spending a paid fetcher on it buys nothing. + if page.kind in ("binary", "pdf_unreadable"): + return {"url": body.url, "content": page.text, "backend": "local"} + body_text = page.text.split("\n\n", 1)[-1] if len(body_text.strip()) < 200: return None - return {"url": body.url, "content": text, "backend": "local"} + return {"url": body.url, "content": page.text, "backend": "local"} async def try_browser_fetch() -> Optional[Dict]: # Packaged-app tier: renders the page in a real offscreen Chromium and returns its visible text, so JS-only / SPA / soft-paywall pages that give httpx nothing actually resolve. Shares the user's browser cookies, so pages they're logged into fetch authed. diff --git a/backend/tests/test_web_fetch_cascade.py b/backend/tests/test_web_fetch_cascade.py new file mode 100644 index 00000000..a7ff75d0 --- /dev/null +++ b/backend/tests/test_web_fetch_cascade.py @@ -0,0 +1,124 @@ +"""Deadline-bounded /api/web/fetch cascade. + +Mirrors /search: the local httpx + trafilatura read is the fast path, the +packaged browser and the Wayback archive cover JS walls and dead links, and the +grounded fetchers are the last resort. A body with no text layer at all stops +the chain rather than buying a paid summary of nothing. +""" + +import time + +import pytest + +import backend.apps.agents.tools.fetch.wayback as WB +import backend.apps.web.web as W +from backend.apps.agents.tools.fetch.page_text import PageText +from backend.apps.agents.tools.web import WebFetchTool +from backend.apps.web.web import fetch, FetchBody +from backend.tests.web_cascade_fixtures import ( # noqa: F401 + allow_urls, + patch_browser_bridge, + no_network, +) + + +def p_local_returns(monkeypatch, text, kind="html"): + async def p_fetch(url, prompt=None): + return PageText(text=text, kind=kind) + monkeypatch.setattr(WebFetchTool, "fetch_page", staticmethod(p_fetch)) + + + +@pytest.mark.asyncio +async def test_fetch_local_first_wins_and_is_fast(monkeypatch): + big = "Contents of https://x.example:\n\n" + ("real article body " * 50) + p_local_returns(monkeypatch, big) + async def p_boom(*a, **k): + raise AssertionError("grounded fetch should not run when local has content") + monkeypatch.setattr(W, "gemini_grounded_call", p_boom) + + t = time.monotonic() + res = await fetch(FetchBody(url="https://x.example")) + assert res["backend"] == "local" + assert "real article body" in res["content"] + assert time.monotonic() - t < 1.0 + + +@pytest.mark.asyncio +async def test_fetch_thin_local_falls_to_grounded(monkeypatch): + p_local_returns(monkeypatch, "Contents of https://spa.example:\n\n") # JS wall, empty body + monkeypatch.setattr(W, "resolve_gemini_api_key", lambda: "gkey") + async def p_gem(api_key, prompt, *, use_url_context): + return {"text": "rendered page text from grounding", "chunks": []} + monkeypatch.setattr(W, "gemini_grounded_call", p_gem) + + res = await fetch(FetchBody(url="https://spa.example")) + assert res["backend"] == "gemini_native" + assert "rendered page text" in res["content"] + + +@pytest.mark.asyncio +async def test_fetch_local_error_returned_as_last_resort(monkeypatch): + p_local_returns(monkeypatch, "HTTP error 403 fetching https://blocked.example", kind="error") + # no grounded keys/subs (autouse fixtures) -> all grounded skip/fail + res = await fetch(FetchBody(url="https://blocked.example")) + assert res["backend"] == "local" + assert "HTTP error 403" in res["content"] + + +@pytest.mark.asyncio +async def test_fetch_falls_to_the_archive_when_the_live_page_is_gone(monkeypatch): + """A dead link is exactly what the archive is for; the real text beats a grounded summary of nothing.""" + p_local_returns(monkeypatch, "HTTP error 404 fetching https://gone.example/post") + + async def p_snapshot(url): + return "Archived copy of https://gone.example/post (Wayback Machine snapshot from 2026-05-08)\n\nThe original text." + monkeypatch.setattr(WB, "fetch_wayback", p_snapshot) + + async def p_boom(*a, **k): + raise AssertionError("a paid fetcher must not run once the archive answered") + monkeypatch.setattr(W, "gemini_grounded_call", p_boom) + + res = await fetch(FetchBody(url="https://gone.example/post")) + assert res["backend"] == "wayback" + assert "The original text." in res["content"] + + +@pytest.mark.asyncio +async def test_browser_fetch_tier_fires_when_local_thin(monkeypatch): + p_local_returns(monkeypatch, "Contents of x:\n\ntiny") # <200 chars -> try_local returns None + patch_browser_bridge(monkeypatch, {"title": "T", "text": "the full rendered article body " * 20, "url": "https://x.example"}) + + res = await fetch(FetchBody(url="https://x.example")) + assert res["backend"] == "browser" + assert "rendered article" in res["content"] + + +@pytest.mark.asyncio +async def test_an_unreadable_binary_stops_the_cascade_instead_of_buying_a_summary(monkeypatch): + """A PNG has no text for ANY tier to find; spending a paid fetcher on it buys nothing.""" + p_local_returns(monkeypatch, "This URL is not a readable document: image/png, 219 KB of binary data.", + kind="binary") + monkeypatch.setattr(W, "resolve_gemini_api_key", lambda: "gkey") + + async def p_boom(*a, **k): + raise AssertionError("a paid fetcher must never be spent on a body with no text layer") + monkeypatch.setattr(W, "gemini_grounded_call", p_boom) + + res = await fetch(FetchBody(url="https://x.example/photo.png")) + assert res["backend"] == "local" + assert "image/png" in res["content"] + + +@pytest.mark.asyncio +async def test_a_scanned_pdf_also_stops_the_cascade(monkeypatch): + p_local_returns(monkeypatch, "This URL is a PDF (4 MB) with no extractable text layer.", + kind="pdf_unreadable") + monkeypatch.setattr(W, "resolve_gemini_api_key", lambda: "gkey") + + async def p_boom(*a, **k): + raise AssertionError("a scanned PDF is not worth a paid fetcher either") + monkeypatch.setattr(W, "gemini_grounded_call", p_boom) + + res = await fetch(FetchBody(url="https://x.example/scan.pdf")) + assert res["backend"] == "local" diff --git a/backend/tests/test_web_search_cascade.py b/backend/tests/test_web_search_cascade.py index c29bad62..17c8f011 100644 --- a/backend/tests/test_web_search_cascade.py +++ b/backend/tests/test_web_search_cascade.py @@ -1,9 +1,8 @@ -"""Fast-first, bounded web-search cascade (/api/web/search). +"""Fast-first, deadline-bounded /api/web/search cascade. -Pins the behaviour that fixes the ~75s stall and the human-speed goal: - - DuckDuckGo is tried FIRST and short-circuits the chain when it has results. - - When DDG is throttled, the chain falls over to the grounded backends. - - Every attempt is wait_for-bounded, so a hung provider can't stall the request. + - The free keyless engines are tried FIRST and short-circuit the chain. + - One engine's bot challenge falls over to the other, then to the grounded backends. + - Every attempt is bounded, so a hung provider can't stall the request. - The `primary` hint reorders only the grounded tier. - When everything fails we return an honest message, not a bogus empty result. @@ -15,53 +14,25 @@ import time import pytest -import backend.apps.agents.tools.fetch.wayback as WB -import backend.apps.agents.tools.search.search_startpage as SP import backend.apps.web.web as W from backend.apps.web.web import search, SearchBody -from backend.apps.agents.tools.web import WebSearchTool, DDGRateLimited +from backend.tests.web_cascade_fixtures import ( # noqa: F401 + allow_urls, + patch_browser_bridge, + ddg_returns, + ddg_throttled, + no_network, + startpage_returns, +) -@pytest.fixture(autouse=True) -def p_no_network(monkeypatch): - # Default everything to "unavailable / no network"; each test opts paths in. - monkeypatch.setattr(W, "resolve_gemini_api_key", lambda: None) - monkeypatch.setattr(W, "resolve_openai_api_key", lambda: None) - - async def p_no_subs(): - return set() - monkeypatch.setattr(W, "refresh_9r_connected", p_no_subs) - - async def p_empty(*a, **k): - return {} - # subscription helpers hit localhost:20128 otherwise - monkeypatch.setattr(W, "gemini_grounded_via_9router", p_empty) - monkeypatch.setattr(W, "openai_websearch_via_9router", p_empty) - - async def p_startpage_closed(query, num): - return None - monkeypatch.setattr(SP, "search_startpage", p_startpage_closed) - - async def p_no_snapshot(url): - return None - monkeypatch.setattr(WB, "fetch_wayback", p_no_snapshot) -def p_ddg_returns(monkeypatch, text): - async def p_f(query, num): - return text - monkeypatch.setattr(WebSearchTool, "search_ddg", staticmethod(p_f)) - - -def p_ddg_throttled(monkeypatch): - async def p_f(query, num): - raise DDGRateLimited(query) - monkeypatch.setattr(WebSearchTool, "search_ddg", staticmethod(p_f)) @pytest.mark.asyncio async def test_ddg_is_tried_first_and_wins(monkeypatch): - p_ddg_returns(monkeypatch, "[1] Foo\n https://foo.example") + ddg_returns(monkeypatch, "[1] Foo\n https://foo.example") # grounded would raise if reached; prove it isn't async def p_boom(*a, **k): raise AssertionError("grounded should not be called when DDG has results") @@ -77,7 +48,7 @@ async def test_ddg_is_tried_first_and_wins(monkeypatch): @pytest.mark.asyncio async def test_ddg_throttled_falls_over_to_openai(monkeypatch): - p_ddg_throttled(monkeypatch) + ddg_throttled(monkeypatch) monkeypatch.setattr(W, "resolve_openai_api_key", lambda: "okey") async def p_openai(api_key, query): @@ -91,17 +62,11 @@ async def test_ddg_throttled_falls_over_to_openai(monkeypatch): assert any("ddg" in e for e in res.get("cascade_errors", [])) -def p_startpage_returns(monkeypatch, text): - async def p_f(query, num): - return text - monkeypatch.setattr(SP, "search_startpage", p_f) - - @pytest.mark.asyncio async def test_startpage_rescues_a_ddg_challenge(monkeypatch): """Two independent engines: one operator's bot challenge must not close free search.""" - p_ddg_throttled(monkeypatch) - p_startpage_returns(monkeypatch, "[1] Rescued\n https://sp.example") + ddg_throttled(monkeypatch) + startpage_returns(monkeypatch, "[1] Rescued\n https://sp.example") async def p_boom(*a, **k): raise AssertionError("a paid backend must not run while a free engine still answers") @@ -115,7 +80,7 @@ async def test_startpage_rescues_a_ddg_challenge(monkeypatch): @pytest.mark.asyncio async def test_a_hung_grounded_attempt_is_bounded(monkeypatch): - p_ddg_throttled(monkeypatch) + ddg_throttled(monkeypatch) monkeypatch.setattr(W, "GROUNDED_TIER_SECONDS", 0.3) monkeypatch.setattr(W, "resolve_gemini_api_key", lambda: "gkey") @@ -133,7 +98,7 @@ async def test_a_hung_grounded_attempt_is_bounded(monkeypatch): @pytest.mark.asyncio async def test_primary_openai_reorders_grounded_tier(monkeypatch): - p_ddg_throttled(monkeypatch) + ddg_throttled(monkeypatch) monkeypatch.setattr(W, "resolve_gemini_api_key", lambda: "gkey") monkeypatch.setattr(W, "resolve_openai_api_key", lambda: "okey") @@ -152,7 +117,7 @@ async def test_primary_openai_reorders_grounded_tier(monkeypatch): @pytest.mark.asyncio async def test_everything_fails_is_honest_not_empty(monkeypatch): - p_ddg_throttled(monkeypatch) # no keys, no subs (from fixture) + ddg_throttled(monkeypatch) # no keys, no subs (from fixture) res = await search(SearchBody(query="obscure thing")) assert res["backend"] == "none" assert "obscure thing" in res["results"] @@ -163,7 +128,7 @@ async def test_everything_fails_is_honest_not_empty(monkeypatch): @pytest.mark.asyncio async def test_everything_fails_nudges_browser_not_retry(monkeypatch): # All-fail must hand the model the browser as an escape hatch, not a dead-end "wait and retry". - p_ddg_throttled(monkeypatch) + ddg_throttled(monkeypatch) monkeypatch.setattr(W, "resolve_openai_api_key", lambda: "okey") # configured but errors async def p_openai_boom(*a, **k): @@ -179,7 +144,7 @@ async def test_everything_fails_nudges_browser_not_retry(monkeypatch): @pytest.mark.asyncio async def test_nudge_suppressed_when_browser_denied(monkeypatch): # A session without browser-delegation tools must never be told to call CreateBrowserAgent. - p_ddg_throttled(monkeypatch) + ddg_throttled(monkeypatch) monkeypatch.setattr(W, "resolve_openai_api_key", lambda: "okey") async def p_openai_boom(*a, **k): @@ -192,94 +157,12 @@ async def test_nudge_suppressed_when_browser_denied(monkeypatch): assert "retry" not in res["results"].lower() -# -------------------------------------------------------------------------- /fetch mirrors /search: local httpx + trafilatura is the fast path, grounded fetchers are the fallback for JS/paywalled pages, every attempt is bounded. -------------------------------------------------------------------------- - -from backend.apps.web.web import fetch, FetchBody -from backend.apps.agents.tools.web import WebFetchTool -import backend.apps.agents.tools.ssrf_guard as p_ssrf - - -@pytest.fixture(autouse=True) -def p_allow_urls(monkeypatch): - async def p_ok(url): - return None - monkeypatch.setattr(p_ssrf, "assert_safe_url", p_ok) - - -def p_local_returns(monkeypatch, text): - async def p_exec(self, input_data, context): - return [{"type": "text", "text": text}] - monkeypatch.setattr(WebFetchTool, "execute", p_exec) - - -@pytest.mark.asyncio -async def test_fetch_local_first_wins_and_is_fast(monkeypatch): - big = "Contents of https://x.example:\n\n" + ("real article body " * 50) - p_local_returns(monkeypatch, big) - async def p_boom(*a, **k): - raise AssertionError("grounded fetch should not run when local has content") - monkeypatch.setattr(W, "gemini_grounded_call", p_boom) - - t = time.monotonic() - res = await fetch(FetchBody(url="https://x.example")) - assert res["backend"] == "local" - assert "real article body" in res["content"] - assert time.monotonic() - t < 1.0 - - -@pytest.mark.asyncio -async def test_fetch_thin_local_falls_to_grounded(monkeypatch): - p_local_returns(monkeypatch, "Contents of https://spa.example:\n\n") # JS wall, empty body - monkeypatch.setattr(W, "resolve_gemini_api_key", lambda: "gkey") - async def p_gem(api_key, prompt, *, use_url_context): - return {"text": "rendered page text from grounding", "chunks": []} - monkeypatch.setattr(W, "gemini_grounded_call", p_gem) - - res = await fetch(FetchBody(url="https://spa.example")) - assert res["backend"] == "gemini_native" - assert "rendered page text" in res["content"] - - -@pytest.mark.asyncio -async def test_fetch_local_error_returned_as_last_resort(monkeypatch): - p_local_returns(monkeypatch, "HTTP error 403 fetching https://blocked.example") - # no grounded keys/subs (autouse fixtures) -> all grounded skip/fail - res = await fetch(FetchBody(url="https://blocked.example")) - assert res["backend"] == "local" - assert "HTTP error 403" in res["content"] - - -@pytest.mark.asyncio -async def test_fetch_falls_to_the_archive_when_the_live_page_is_gone(monkeypatch): - """A dead link is exactly what the archive is for; the real text beats a grounded summary of nothing.""" - p_local_returns(monkeypatch, "HTTP error 404 fetching https://gone.example/post") - - async def p_snapshot(url): - return "Archived copy of https://gone.example/post (Wayback Machine snapshot from 2026-05-08)\n\nThe original text." - monkeypatch.setattr(WB, "fetch_wayback", p_snapshot) - - async def p_boom(*a, **k): - raise AssertionError("a paid fetcher must not run once the archive answered") - monkeypatch.setattr(W, "gemini_grounded_call", p_boom) - - res = await fetch(FetchBody(url="https://gone.example/post")) - assert res["backend"] == "wayback" - assert "The original text." in res["content"] - - # --- packaged-browser tier: fires when DDG throttles, skipped when no bridge --- -def p_browser_bridge(monkeypatch, result): - """Patch the offscreen-browser bridge helper; result=None simulates 'no Electron main bridge connected'.""" - async def p_f(action, params): - return result - monkeypatch.setattr(W, "p_browser_bridge", p_f) - - @pytest.mark.asyncio async def test_browser_search_tier_fires_when_ddg_throttled(monkeypatch): - p_ddg_throttled(monkeypatch) - p_browser_bridge(monkeypatch, {"engine": "ddg", "results": "[1] Real\n https://real.example", "count": 1}) + ddg_throttled(monkeypatch) + patch_browser_bridge(monkeypatch, {"engine": "ddg", "results": "[1] Real\n https://real.example", "count": 1}) async def p_boom(*a, **k): raise AssertionError("grounded should not be reached once the browser tier answers") @@ -293,8 +176,8 @@ async def test_browser_search_tier_fires_when_ddg_throttled(monkeypatch): @pytest.mark.asyncio async def test_browser_search_skipped_when_no_bridge(monkeypatch): # DDG throttled, no browser bridge -> must fall THROUGH to grounded, not crash. - p_ddg_throttled(monkeypatch) - p_browser_bridge(monkeypatch, None) + ddg_throttled(monkeypatch) + patch_browser_bridge(monkeypatch, None) monkeypatch.setattr(W, "resolve_openai_api_key", lambda: "okey") async def p_openai(api_key, query): @@ -303,18 +186,3 @@ async def test_browser_search_skipped_when_no_bridge(monkeypatch): res = await search(SearchBody(query="q")) assert res["backend"] == "openai_native" - - -@pytest.mark.asyncio -async def test_browser_fetch_tier_fires_when_local_thin(monkeypatch): - from backend.apps.web.web import fetch, FetchBody - from backend.apps.agents.tools.web import WebFetchTool - - async def p_thin(self, input_data, context): - return [{"type": "text", "text": "Contents of x:\n\ntiny"}] # <200 chars -> try_local returns None - monkeypatch.setattr(WebFetchTool, "execute", p_thin) - p_browser_bridge(monkeypatch, {"title": "T", "text": "the full rendered article body " * 20, "url": "https://x.example"}) - - res = await fetch(FetchBody(url="https://x.example")) - assert res["backend"] == "browser" - assert "rendered article" in res["content"] diff --git a/backend/tests/web_cascade_fixtures.py b/backend/tests/web_cascade_fixtures.py new file mode 100644 index 00000000..eb7e90df --- /dev/null +++ b/backend/tests/web_cascade_fixtures.py @@ -0,0 +1,66 @@ +"""Shared fixtures for the /api/web cascade tests: everything offline by default.""" + +import pytest + +import backend.apps.agents.tools.fetch.wayback as WB +import backend.apps.agents.tools.search.search_startpage as SP +import backend.apps.web.web as W +from backend.apps.agents.tools.web import DDGRateLimited, WebSearchTool +import backend.apps.agents.tools.ssrf_guard as p_ssrf + + +@pytest.fixture(autouse=True) +def no_network(monkeypatch): + # Default everything to "unavailable / no network"; each test opts paths in. + monkeypatch.setattr(W, "resolve_gemini_api_key", lambda: None) + monkeypatch.setattr(W, "resolve_openai_api_key", lambda: None) + + async def p_no_subs(): + return set() + monkeypatch.setattr(W, "refresh_9r_connected", p_no_subs) + + async def p_empty(*a, **k): + return {} + # subscription helpers hit localhost:20128 otherwise + monkeypatch.setattr(W, "gemini_grounded_via_9router", p_empty) + monkeypatch.setattr(W, "openai_websearch_via_9router", p_empty) + + async def p_startpage_closed(query, num): + return None + monkeypatch.setattr(SP, "search_startpage", p_startpage_closed) + + async def p_no_snapshot(url): + return None + monkeypatch.setattr(WB, "fetch_wayback", p_no_snapshot) + + +@pytest.fixture(autouse=True) +def allow_urls(monkeypatch): + async def p_ok(url): + return None + monkeypatch.setattr(p_ssrf, "assert_safe_url", p_ok) + + +def ddg_returns(monkeypatch, text): + async def p_f(query, num): + return text + monkeypatch.setattr(WebSearchTool, "search_ddg", staticmethod(p_f)) + + +def ddg_throttled(monkeypatch): + async def p_f(query, num): + raise DDGRateLimited(query) + monkeypatch.setattr(WebSearchTool, "search_ddg", staticmethod(p_f)) + + +def startpage_returns(monkeypatch, text): + async def p_f(query, num): + return text + monkeypatch.setattr(SP, "search_startpage", p_f) + + +def patch_browser_bridge(monkeypatch, result): + """Patch the offscreen-browser bridge; result=None simulates 'no Electron main bridge connected'.""" + async def p_f(action, params): + return result + monkeypatch.setattr(W, "p_browser_bridge", p_f) From 77b3595264041493a267bc89174feeb6e32e7f9c Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 15:13:32 -0700 Subject: [PATCH 011/117] [eric] context menus: portal the card menu to body, widen the row schema, cover every shell surface --- .../app/pages/Dashboard/DashboardToolbar.tsx | 24 +- .../Dashboard/canvas/DashboardCanvas.tsx | 21 +- .../Dashboard/canvas/DashboardOverlays.tsx | 2 - .../pages/Dashboard/canvas/canvasMenuRows.ts | 36 ++ .../Dashboard/canvas/useCanvasContextMenu.ts | 50 +++ .../app/pages/Dashboard/cards/AgentCard.tsx | 19 +- .../app/pages/Dashboard/cards/BrowserCard.tsx | 35 +- .../Dashboard/cards/DashboardViewCard.tsx | 33 +- .../Dashboard/cards/agentCardMenuRows.ts | 59 ++++ .../Dashboard/cards/browserCardMenuRows.ts | 97 ++++++ .../app/pages/Dashboard/cards/tileMenuRows.ts | 34 ++ .../pages/Dashboard/cards/viewCardMenuRows.ts | 61 ++++ .../Dashboard/desktop/CardContextMenu.tsx | 316 +++++++++++++----- .../pages/Dashboard/desktop/CardMenuPanel.tsx | 130 +++++++ .../pages/Dashboard/desktop/DesktopDock.tsx | 6 + .../Dashboard/desktop/MinimizedStack.tsx | 11 + .../pages/Dashboard/desktop/MinimizedTile.tsx | 4 +- .../src/app/pages/Dashboard/desktop/chord.ts | 16 + .../pages/Dashboard/desktop/dockEntries.tsx | 7 + .../Dashboard/desktop/dockTileMenuRows.ts | 27 ++ .../Dashboard/desktop/openCardContextMenu.ts | 64 ++++ .../hooks/interaction/pasteClipboardCards.ts | 67 ++++ .../interaction/useDashboardClipboard.ts | 81 +---- .../app/pages/Workflows/SchedulePopover.tsx | 10 +- 24 files changed, 998 insertions(+), 212 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/canvas/canvasMenuRows.ts create mode 100644 frontend/src/app/pages/Dashboard/canvas/useCanvasContextMenu.ts create mode 100644 frontend/src/app/pages/Dashboard/cards/agentCardMenuRows.ts create mode 100644 frontend/src/app/pages/Dashboard/cards/browserCardMenuRows.ts create mode 100644 frontend/src/app/pages/Dashboard/cards/tileMenuRows.ts create mode 100644 frontend/src/app/pages/Dashboard/cards/viewCardMenuRows.ts create mode 100644 frontend/src/app/pages/Dashboard/desktop/CardMenuPanel.tsx create mode 100644 frontend/src/app/pages/Dashboard/desktop/chord.ts create mode 100644 frontend/src/app/pages/Dashboard/desktop/dockTileMenuRows.ts create mode 100644 frontend/src/app/pages/Dashboard/desktop/openCardContextMenu.ts create mode 100644 frontend/src/app/pages/Dashboard/hooks/interaction/pasteClipboardCards.ts diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index 6d12db4c..fbb7f528 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -17,7 +17,9 @@ import { addWorkflowCard, openWorkflowsApp, closeWorkflowsApp } from '@/shared/s import { useElementSelection } from '@/app/components/editor/ElementSelectionContext'; import { useClaudeTokens, DarkTokensScope } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { searchHistory, clearHistorySearch } from '@/shared/state/agentsSlice'; +import { searchHistory, clearHistorySearch, deleteSession, renameSession } from '@/shared/state/agentsSlice'; +import { openCardContextMenu } from './desktop/openCardContextMenu'; +import { displaySessionName } from '@/shared/state/sessionDisplay'; import { updateSettingsPatch, AppSettings } from '@/shared/state/settingsSlice'; import { store } from '@/shared/state/store'; import { API_BASE, getAuthToken } from '@/shared/config'; @@ -252,6 +254,25 @@ const DashboardToolbar = React.forwardRef( handleCloseHistory(); }, [onHistoryResume, handleCloseHistory]); + const handleHistoryContextMenu = useCallback((e: React.MouseEvent, entry: { id: string; name: string }) => { + openCardContextMenu(e, { + rename: { value: displaySessionName(entry.name), onCommit: (name) => { void dispatch(renameSession({ sessionId: entry.id, name })); } }, + items: [ + { label: 'Resume chat', onClick: () => handleHistorySelect(entry.id) }, + { kind: 'separator' }, + { + label: 'Delete chat', + danger: true, + onClick: () => { + void dispatch(deleteSession({ sessionId: entry.id })).then(() => { + dispatch(searchHistory({ q: historyQuery, limit: HISTORY_PAGE_SIZE, offset: 0 })); + }); + }, + }, + ], + }); + }, [dispatch, handleHistorySelect, historyQuery]); + const handleHistoryLoadMore = useCallback(() => { if (historySearch.loading || !historySearch.hasMore) return; dispatch(searchHistory({ @@ -460,6 +481,7 @@ const DashboardToolbar = React.forwardRef( historyQuery={historyQuery} onHistoryQueryChange={setHistoryQuery} onHistorySelect={handleHistorySelect} + onHistoryContextMenu={handleHistoryContextMenu} onNewChat={() => { handleCloseHistory(); onNewAgent(); }} onWorkflowSelect={(wid) => { dispatch(openWorkflowsApp({ workflowId: wid })); diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index 1dd3f047..0f2fe60f 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -6,6 +6,8 @@ import DashboardHeader from './DashboardHeader'; import TetherLayer from './TetherLayer'; import DashboardCardLayer from './DashboardCardLayer'; import DashboardOverlays from './DashboardOverlays'; +import CardContextMenu from '../desktop/CardContextMenu'; +import { useCanvasContextMenu } from './useCanvasContextMenu'; import DashboardEmptyState from './DashboardEmptyState'; import '../desktop/desktop.css'; import DesktopDock from '../desktop/DesktopDock'; @@ -176,6 +178,11 @@ const DashboardCanvas: React.FC = ({ const anyFullscreen = !!fullscreenCardId || !!workflowsHub?.fullscreen || settingsFullscreen; const [headerRevealed, setHeaderRevealed] = React.useState(false); const [appsWindowOpen, setAppsWindowOpen] = React.useState(false); + const onCanvasContextMenu = useCanvasContextMenu({ + dispatch, dashboardId, expandedSessionIds, selection, canvasEmpty, + viewportRef: canvas.viewportRef, getCamera: canvas.actions.getLiveState, + onNewAgent, onAddBrowser, onApplications: () => setAppsWindowOpen(true), onTidy, onFitToView, + }); useEffect(() => { if (!fullscreenCardId) return undefined; const onKey = (e: KeyboardEvent): void => { @@ -315,15 +322,7 @@ const DashboardCanvas: React.FC = ({ onMouseMove={onViewportMouseMove} onMouseUp={onViewportMouseUp} onDoubleClick={onViewportDoubleClick} - onContextMenu={(e) => { - // Right-drag is the canvas marquee-select (Google-Maps style), so the native menu (Inspect - // Element in dev) shouldn't pop over it. Suppress only on the bare canvas; cards, inputs, and - // webviews keep their own menus. - const t = e.target as HTMLElement; - if (!t.closest('[data-select-id]') && !t.closest('input, textarea, [contenteditable]')) { - e.preventDefault(); - } - }} + onContextMenu={onCanvasContextMenu} sx={{ position: 'absolute', inset: 0, @@ -468,6 +467,10 @@ const DashboardCanvas: React.FC = ({ toolbarPrefillMode={toolbarPrefillMode} />
+ + {/* Sibling of everything: the menu used to live inside the help pill's z:10 box (so any card + brought to front painted over it) and inside the fullscreen display:none wrapper. */} +
); diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx index 868bb33f..fcd863d9 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx @@ -3,7 +3,6 @@ import Box from '@mui/material/Box'; import DashboardToolbar from '../DashboardToolbar'; import CanvasControls from '../controls/CanvasControls'; import HelpPill from '../desktop/HelpPill'; -import CardContextMenu from '../desktop/CardContextMenu'; import CardSearchPalette from '../controls/CardSearchPalette'; import DirectionHints from '../controls/DirectionHints'; import WorkflowRunningToast from '@/app/pages/Workflows/WorkflowRunningToast'; @@ -120,7 +119,6 @@ const DashboardOverlays: React.FC = ({ {!anyFullscreen && ( - )} diff --git a/frontend/src/app/pages/Dashboard/canvas/canvasMenuRows.ts b/frontend/src/app/pages/Dashboard/canvas/canvasMenuRows.ts new file mode 100644 index 00000000..fedb5a43 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/canvas/canvasMenuRows.ts @@ -0,0 +1,36 @@ +import { reopenLastClosed } from '@/shared/state/dashboardLayoutSlice'; +import { getClipboardCards } from '@/shared/dashboardClipboard'; +import type { AppDispatch } from '@/shared/state/store'; +import type { CardMenuRow } from '../desktop/openCardContextMenu'; +import { chord } from '../desktop/chord'; + +interface CanvasMenuArgs { + dispatch: AppDispatch; + hasCards: boolean; + onNewAgent: () => void; + onAddBrowser: () => void; + onApplications: () => void; + onPaste: () => void; + onSelectAll: () => void; + onTidy: () => void; + onFitToView: () => void; +} + +export function canvasMenuRows({ + dispatch, hasCards, onNewAgent, onAddBrowser, onApplications, onPaste, onSelectAll, onTidy, onFitToView, +}: CanvasMenuArgs): CardMenuRow[] { + return [ + { kind: 'header', label: 'New' }, + { label: 'New chat', onClick: onNewAgent }, + { label: 'New browser', shortcut: chord('mod', 'N'), onClick: onAddBrowser }, + { label: 'Add app', shortcut: chord('mod', 'M'), onClick: onApplications }, + { kind: 'separator' }, + { label: 'Paste', shortcut: chord('mod', 'V'), disabled: getClipboardCards().length === 0, onClick: onPaste }, + { label: 'Reopen last closed', shortcut: chord('mod', 'shift', 'T'), onClick: () => { void dispatch(reopenLastClosed()); } }, + { kind: 'separator' }, + { kind: 'header', label: 'Canvas' }, + { label: 'Select all', shortcut: chord('mod', 'A'), disabled: !hasCards, onClick: onSelectAll }, + { label: 'Tidy layout', disabled: !hasCards, onClick: onTidy }, + { label: 'Fit to view', disabled: !hasCards, onClick: onFitToView }, + ]; +} diff --git a/frontend/src/app/pages/Dashboard/canvas/useCanvasContextMenu.ts b/frontend/src/app/pages/Dashboard/canvas/useCanvasContextMenu.ts new file mode 100644 index 00000000..7cdb9794 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/canvas/useCanvasContextMenu.ts @@ -0,0 +1,50 @@ +import { useCallback } from 'react'; +import type React from 'react'; +import type { AppDispatch } from '@/shared/state/store'; +import { openCardContextMenu } from '../desktop/openCardContextMenu'; +import { pasteClipboardCards } from '../hooks/interaction/pasteClipboardCards'; +import { canvasMenuRows } from './canvasMenuRows'; +import type { useDashboardSelection } from '../hooks/state/useDashboardSelection'; + +interface CanvasContextMenuArgs { + dispatch: AppDispatch; + dashboardId: string; + expandedSessionIds: string[]; + selection: ReturnType; + canvasEmpty: boolean; + viewportRef: React.RefObject; + getCamera: () => { panX: number; panY: number; zoom: number }; + onNewAgent: () => void; + onAddBrowser: () => void; + onApplications: () => void; + onTidy: () => void; + onFitToView: () => void; +} + +export function useCanvasContextMenu(args: CanvasContextMenuArgs): (e: React.MouseEvent) => void { + const { dispatch, dashboardId, expandedSessionIds, selection, canvasEmpty, viewportRef, getCamera } = args; + const { onNewAgent, onAddBrowser, onApplications, onTidy, onFitToView } = args; + return useCallback((e: React.MouseEvent) => { + // Bare canvas only; cards own their own menus and inputs/webviews keep the native one. + const t = e.target as HTMLElement; + if (t.closest('[data-select-id]') || t.closest('input, textarea, [contenteditable]')) return; + const rect = viewportRef.current?.getBoundingClientRect(); + const cam = getCamera(); + const at = rect + ? { x: (e.clientX - rect.left - cam.panX) / cam.zoom, y: (e.clientY - rect.top - cam.panY) / cam.zoom } + : undefined; + openCardContextMenu(e, { + items: canvasMenuRows({ + dispatch, + hasCards: !canvasEmpty, + onNewAgent, + onAddBrowser, + onApplications, + onPaste: () => { void pasteClipboardCards({ dispatch, dashboardId, expandedSessionIds, selection, at }); }, + onSelectAll: selection.selectAll, + onTidy, + onFitToView, + }), + }); + }, [dispatch, dashboardId, expandedSessionIds, selection, canvasEmpty, viewportRef, getCamera, onNewAgent, onAddBrowser, onApplications, onTidy, onFitToView]); +} diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index cee6384c..c1edd867 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -19,7 +19,6 @@ import { collapseSession, expandSession, closeSession, - deleteSession, fetchSession, renameSession, } from '@/shared/state/agentsSlice'; @@ -39,7 +38,8 @@ import { import WindowControls, { ARC_CHIP_SX } from './WindowControls'; import { useTiledStyle, computeTiledStyle } from './tileZones'; import AgentNarratorPill from '../desktop/AgentNarratorPill'; -import { openCardContextMenu } from '../desktop/CardContextMenu'; +import { openCardContextMenu, isNativeMenuTarget } from '../desktop/openCardContextMenu'; +import { agentCardMenuRows } from './agentCardMenuRows'; import { extractLatestTodos } from '../desktop/agentTodos'; import { extractLatestShowUi, extractPendingAskUi, freezeIfDone } from '@/app/pages/AgentChat/tool-ui/showUiPayload'; import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops'; @@ -833,15 +833,14 @@ const AgentCard: React.FC = ({ e.stopPropagation(); onDoubleClick?.(session.id, 'agent'); }} - onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, { + onContextMenu={(e: React.MouseEvent) => { if (isNativeMenuTarget(e)) return; openCardContextMenu(e, { rename: { value: displayChatTitle(session), onCommit: (name) => dispatch(renameSession({ sessionId: session.id, name })) }, - items: [ - { label: expanded ? 'Collapse' : 'Open', onClick: () => dispatch(expanded ? collapseSession(session.id) : expandSession(session.id)) }, - { label: 'Full Screen', onClick: () => onTile('fullscreen') }, - { label: 'Close', onClick: () => handleRemove() }, - { label: 'Delete chat', danger: true, onClick: () => { void dispatch(deleteSession({ sessionId: session.id })); } }, - ], - })} + items: agentCardMenuRows({ + session, dispatch, expanded, tileZone, expandedSessionIds, + card: { x: cardX, y: cardY, width: cardWidth, height: cardHeight }, + onTile, onClose: () => handleRemove(), + }), + }); }} sx={{ position: 'relative', // Hover runway for the pop-above header: the header is pointer-events:none until the CARD diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index db6029c4..c5928fad 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -59,12 +59,12 @@ import { registerPendingLoad, wakePendingLoad, type BrowserWebview, - getWebview, } from '@/shared/browserRegistry'; import { setLastInteractedBrowser } from '@/shared/browserFocus'; import { registerCapsuleForRestore } from '@/shared/browserStateCapsule'; import BrowserFindBar from './BrowserFindBar'; -import { openCardContextMenu } from '../desktop/CardContextMenu'; +import { openCardContextMenu, isNativeMenuTarget } from '../desktop/openCardContextMenu'; +import { browserCardMenuRows, browserTabMenuRows } from './browserCardMenuRows'; import { useBrowserActivity } from '@/shared/useBrowserActivity'; import { getActionLabel } from '@/shared/browserCommandHandler'; import { resolveInput, isGoogleSearch } from '@/shared/resolveUrl'; @@ -644,6 +644,8 @@ const BrowserCard: React.FC = ({ const handleTabPointerDown = useCallback((e: React.PointerEvent) => { e.stopPropagation(); + // A right-click still fires pointerdown; arming the drag here would capture the pointer under the menu. + if (e.button !== 0) return; const tabId = (e.currentTarget as HTMLElement).getAttribute('data-tab-id'); if (!tabId) return; tabDragRef.current = { tabId, startX: e.clientX, startY: e.clientY, isDragging: false, detached: false }; @@ -1015,16 +1017,22 @@ const BrowserCard: React.FC = ({ data-select-meta={JSON.stringify({ name: activeTitle || 'Browser', url: activeUrl })} // Marks a kept-alive card parked off-screen (it belongs to another dashboard); fit-to-view must skip it or it pans the canvas to chase it and the card bleeds onto the dashboard you're viewing. data-keepalive-hidden={keepAliveHidden || isMinimized || dockParked ? '1' : undefined} - onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, { - items: [ - { label: 'New Tab', onClick: () => dispatch(addBrowserTab({ browserId, url: browserHomepage })) }, - { label: 'Reload', onClick: () => { try { (getWebview(browserId) as { reload?: () => void } | undefined)?.reload?.(); } catch { /* webview gone */ } } }, - { label: 'Copy URL', onClick: () => { void navigator.clipboard.writeText(activeUrl); } }, - { label: 'Full Screen', onClick: () => onTile('fullscreen') }, - { label: 'Minimize', onClick: handleMinimize }, - { label: 'Close', danger: true, onClick: () => { dispatch(recordClosedCard({ kind: 'browser', id: browserId })); removeBrowserCardCleanly(browserId, dispatch); } }, - ], - })} + onContextMenu={(e: React.MouseEvent) => { if (isNativeMenuTarget(e)) return; openCardContextMenu(e, { + items: browserCardMenuRows({ + browserId, dispatch, tabs, activeUrl, activeTitle, homepage: browserHomepage, tileZone, isMinimized, + card: { x: cardX, y: cardY, width: cardWidth, height: cardHeight }, + nav: { + reload: () => { try { webviewMap.current.get(activeTabId)?.reload(); } catch { /* webview gone */ } }, + back: () => { try { webviewMap.current.get(activeTabId)?.goBack(); } catch { /* webview gone */ } }, + forward: () => { try { webviewMap.current.get(activeTabId)?.goForward(); } catch { /* webview gone */ } }, + canGoBack: activeLocal.canGoBack, + canGoForward: activeLocal.canGoForward, + }, + onTile, + onMinimize: () => (isMinimized ? dispatch(toggleMinimizeCard({ cardId: browserId })) : handleMinimize()), + onFind: () => { setFindOpen(true); setFindFocusSignal((n) => n + 1); }, + }), + }); }} onPointerDownCapture={(e: React.PointerEvent) => { onBringToFront?.(browserId, 'browser'); // Capture-phase so chrome clicks (tab strip, URL bar) the children swallow still select the card; clicks inside the guest page never reach the host at all. Shift keeps the bubbled toggle path. Pass the target so URL-bar/tab presses select without yanking the camera. @@ -1144,6 +1152,9 @@ const BrowserCard: React.FC = ({ openCardContextMenu(e, { + items: browserTabMenuRows({ browserId, dispatch, tab, tabCount: tabs.length, homepage: browserHomepage }), + })} onPointerDown={handleTabPointerDown} onPointerMove={handleTabPointerMove} onPointerUp={handleTabPointerUp} diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 1bbf07e6..e511e8ef 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -15,14 +15,15 @@ import TerminalRoundedIcon from '@mui/icons-material/TerminalRounded'; import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded'; import AddIcon from '@mui/icons-material/Add'; import KeyboardArrowUpRounded from '@mui/icons-material/KeyboardArrowUpRounded'; -import { Output, SERVE_BASE } from '@/shared/state/outputsSlice'; +import { Output, SERVE_BASE, updateOutput } from '@/shared/state/outputsSlice'; import { setViewCardPosition, setViewDocked, setViewCardSize, setActiveViewCardId, recordClosedCard, addViewCard, setTiledCard, clearTiledCard, toggleMinimizeCard, activateViewCardPreview } from '@/shared/state/dashboardLayoutSlice'; import { removeViewCardCleanly } from '@/shared/viewTeardown'; import { saveMinimizedShot } from '../desktop/minimizedShots'; import { requestAppSlot, releaseAppSlot, subscribeAppBudget } from '@/shared/appWebviewBudget'; import { expandSession } from '@/shared/state/agentsSlice'; import WindowControls from './WindowControls'; -import { openCardContextMenu } from '../desktop/CardContextMenu'; +import { openCardContextMenu, isNativeMenuTarget } from '../desktop/openCardContextMenu'; +import { viewCardMenuRows } from './viewCardMenuRows'; import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops'; import { useTiledStyle, computeTiledStyle } from './tileZones'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; @@ -33,6 +34,7 @@ import TerminalPanel, { TerminalLine } from '@/app/pages/Views/TerminalPanel'; import AppCodePanel from '@/app/pages/Views/AppCodePanel'; import HistoryPanel from '@/app/pages/Views/HistoryPanel'; import ShareButton from '@/app/components/share/ShareButton'; +import ShareModal from '@/app/components/share/ShareModal'; import { getDefault } from '@/shared/inputSchemaDefaults'; import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough'; import { @@ -597,8 +599,9 @@ const DashboardViewCard: React.FC = ({ }; const [reloadMenuRect, setReloadMenuRect] = useState(null); - const handleHardReload = useCallback(async (e: React.MouseEvent) => { - e.stopPropagation(); + const [shareOpen, setShareOpen] = useState(false); + const handleHardReload = useCallback(async (e?: React.MouseEvent) => { + e?.stopPropagation(); setReloadMenuRect(null); const wsId = output.workspace_id; if (wsId) { @@ -645,13 +648,19 @@ const DashboardViewCard: React.FC = ({ data-select-type="view-card" data-select-id={cardKey} data-keepalive-hidden={isMinimized ? '1' : undefined} - onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, { - items: [ - { label: 'Full Screen', onClick: () => onTile('fullscreen') }, - { label: 'Minimize', onClick: onMinimize }, - { label: 'Close', danger: true, onClick: () => handleRemove() }, - ], - })} + onContextMenu={(e: React.MouseEvent) => { if (isNativeMenuTarget(e)) return; openCardContextMenu(e, { + rename: { value: output.name, onCommit: (name) => { void dispatch(updateOutput({ id: output.id, name })); } }, + items: viewCardMenuRows({ + output, cardKey, dispatch, tileZone, isMinimized, + card: { x: cardX, y: cardY, width: cardWidth, height: cardHeight }, + onTile, + onMinimize: () => (isMinimized ? dispatch(toggleMinimizeCard({ cardId: cardKey })) : onMinimize()), + onReload: () => previewRef.current?.reload(), + onHardReload: () => { void handleHardReload(); }, + onShare: () => setShareOpen(true), + onClose: () => handleRemove(), + }), + }); }} data-select-meta={JSON.stringify({ name: output.name, description: output.description, path: output.workspace_path })} className="osw-card" onPointerDownCapture={() => onBringToFront?.(cardKey, 'view')} @@ -987,6 +996,8 @@ const DashboardViewCard: React.FC = ({ , document.body, )} + + {shareOpen && setShareOpen(false)} />} ); }; diff --git a/frontend/src/app/pages/Dashboard/cards/agentCardMenuRows.ts b/frontend/src/app/pages/Dashboard/cards/agentCardMenuRows.ts new file mode 100644 index 00000000..c34e591a --- /dev/null +++ b/frontend/src/app/pages/Dashboard/cards/agentCardMenuRows.ts @@ -0,0 +1,59 @@ +import { clearSessionMessages, deleteSession, duplicateSession, expandSession, collapseSession, stopAgent, fetchSession, type AgentSession } from '@/shared/state/agentsSlice'; +import { placeCard, bringToFront } from '@/shared/state/dashboardLayoutSlice'; +import { setClipboardCards } from '@/shared/dashboardClipboard'; +import { handleSlashCommand } from '@/app/pages/AgentChat/ChatInput/hooks/slashCommands'; +import type { AppDispatch } from '@/shared/state/store'; +import type { CardMenuRow } from '../desktop/openCardContextMenu'; +import { chord } from '../desktop/chord'; +import { tileMenuRows } from './tileMenuRows'; + +interface AgentMenuArgs { + session: AgentSession; + dispatch: AppDispatch; + expanded: boolean; + tileZone?: string; + expandedSessionIds: string[]; + card: { x: number; y: number; width: number; height: number }; + onTile: (zone: string) => void; + onClose: () => void; +} + +export function agentCardMenuRows({ session, dispatch, expanded, tileZone, expandedSessionIds, card, onTile, onClose }: AgentMenuArgs): CardMenuRow[] { + const running = session.status === 'running'; + return [ + { label: expanded ? 'Collapse' : 'Open', shortcut: chord('enter'), onClick: () => dispatch(expanded ? collapseSession(session.id) : expandSession(session.id)) }, + { label: tileZone === 'fullscreen' ? 'Exit Full Screen' : 'Full Screen', onClick: () => onTile(tileZone === 'fullscreen' ? 'restore' : 'fullscreen') }, + { label: 'Tile to zone', submenu: tileMenuRows(onTile, tileZone) }, + { label: 'Bring to front', onClick: () => dispatch(bringToFront({ id: session.id, type: 'agent' })) }, + { kind: 'separator' }, + { + label: 'Duplicate', + onClick: () => { + void dispatch(duplicateSession({ sessionId: session.id, dashboardId: session.dashboard_id })).then((action) => { + if (duplicateSession.fulfilled.match(action)) { + dispatch(placeCard({ sessionId: action.payload.id, x: card.x + 40, y: card.y - 40, width: card.width, height: card.height, expandedSessionIds })); + if (expanded) dispatch(expandSession(action.payload.id)); + } + }); + }, + }, + { + label: 'Copy', + shortcut: chord('mod', 'C'), + onClick: () => setClipboardCards([{ + type: 'agent', id: session.id, name: session.name || session.id, + meta: { name: session.name, status: session.status, model: session.model, mode: session.mode }, + x: card.x, y: card.y, width: card.width, height: card.height, expanded, + }]), + }, + { kind: 'separator' }, + { kind: 'header', label: 'Session' }, + { label: 'Stop turn', disabled: !running, onClick: () => { void dispatch(stopAgent({ sessionId: session.id })); } }, + // Clear the server transcript first, then the local one: the reducer alone would leave the backend holding history. + { label: 'Clear messages', disabled: running, onClick: () => { void handleSlashCommand('/clear', session.id).then(() => dispatch(clearSessionMessages(session.id))); } }, + { label: 'Compact context', disabled: running, onClick: () => { void handleSlashCommand('/compact', session.id).then(() => dispatch(fetchSession(session.id))); } }, + { kind: 'separator' }, + { label: 'Close', onClick: onClose }, + { label: 'Delete chat', shortcut: chord('del'), danger: true, onClick: () => { void dispatch(deleteSession({ sessionId: session.id })); } }, + ]; +} diff --git a/frontend/src/app/pages/Dashboard/cards/browserCardMenuRows.ts b/frontend/src/app/pages/Dashboard/cards/browserCardMenuRows.ts new file mode 100644 index 00000000..385d332e --- /dev/null +++ b/frontend/src/app/pages/Dashboard/cards/browserCardMenuRows.ts @@ -0,0 +1,97 @@ +import { addBrowserTab, bringToFront, recordClosedCard, removeBrowserTab, reopenLastClosed, setActiveBrowserTab, type BrowserTab } from '@/shared/state/dashboardLayoutSlice'; +import { removeBrowserCardCleanly } from '@/shared/browserTeardown'; +import { setClipboardCards } from '@/shared/dashboardClipboard'; +import type { AppDispatch } from '@/shared/state/store'; +import type { CardMenuRow } from '../desktop/openCardContextMenu'; +import { chord } from '../desktop/chord'; +import { tileMenuRows } from './tileMenuRows'; + +interface BrowserNav { + reload: () => void; + back: () => void; + forward: () => void; + canGoBack: boolean; + canGoForward: boolean; +} + +interface BrowserMenuArgs { + browserId: string; + dispatch: AppDispatch; + tabs: BrowserTab[]; + activeUrl: string; + activeTitle: string; + homepage: string; + tileZone?: string; + isMinimized: boolean; + card: { x: number; y: number; width: number; height: number }; + nav: BrowserNav; + onTile: (zone: string) => void; + onMinimize: () => void; + onFind: () => void; +} + +export function closeBrowserCard(browserId: string, dispatch: AppDispatch): void { + dispatch(recordClosedCard({ kind: 'browser', id: browserId })); + void removeBrowserCardCleanly(browserId, dispatch); +} + +export function browserCardMenuRows({ + browserId, dispatch, tabs, activeUrl, activeTitle, homepage, tileZone, isMinimized, card, nav, onTile, onMinimize, onFind, +}: BrowserMenuArgs): CardMenuRow[] { + return [ + { label: 'New tab', onClick: () => dispatch(addBrowserTab({ browserId, url: homepage })) }, + { label: 'Reopen closed tab', shortcut: chord('mod', 'shift', 'T'), onClick: () => { void dispatch(reopenLastClosed()); } }, + { kind: 'separator' }, + { label: 'Back', disabled: !nav.canGoBack, onClick: nav.back }, + { label: 'Forward', disabled: !nav.canGoForward, onClick: nav.forward }, + { label: 'Reload', onClick: nav.reload }, + { label: 'Find in page', shortcut: chord('mod', 'F'), onClick: onFind }, + { label: 'Copy URL', disabled: !activeUrl, onClick: () => { void navigator.clipboard.writeText(activeUrl); } }, + { kind: 'separator' }, + { label: tileZone === 'fullscreen' ? 'Exit Full Screen' : 'Full Screen', onClick: () => onTile(tileZone === 'fullscreen' ? 'restore' : 'fullscreen') }, + { label: 'Tile to zone', submenu: tileMenuRows(onTile, tileZone) }, + { label: isMinimized ? 'Restore' : 'Minimize', onClick: onMinimize }, + { label: 'Bring to front', onClick: () => dispatch(bringToFront({ id: browserId, type: 'browser' })) }, + { + label: 'Copy', + shortcut: chord('mod', 'C'), + onClick: () => setClipboardCards([{ + type: 'browser', id: browserId, name: activeTitle || 'Browser', + meta: { name: activeTitle || 'Browser', url: activeUrl, tabs }, + x: card.x, y: card.y, width: card.width, height: card.height, + }]), + }, + { kind: 'separator' }, + { label: 'Close', danger: true, onClick: () => closeBrowserCard(browserId, dispatch) }, + ]; +} + +interface TabMenuArgs { + browserId: string; + dispatch: AppDispatch; + tab: BrowserTab; + tabCount: number; + homepage: string; +} + +export function browserTabMenuRows({ browserId, dispatch, tab, tabCount, homepage }: TabMenuArgs): CardMenuRow[] { + return [ + { label: 'New tab', onClick: () => dispatch(addBrowserTab({ browserId, url: homepage })) }, + { label: 'Duplicate tab', onClick: () => dispatch(addBrowserTab({ browserId, url: tab.url })) }, + { label: 'Reopen closed tab', shortcut: chord('mod', 'shift', 'T'), onClick: () => { void dispatch(reopenLastClosed()); } }, + { kind: 'separator' }, + { label: 'Copy tab URL', disabled: !tab.url, onClick: () => { void navigator.clipboard.writeText(tab.url); } }, + { label: 'Focus tab', onClick: () => dispatch(setActiveBrowserTab({ browserId, tabId: tab.id })) }, + { kind: 'separator' }, + { + label: 'Close tab', + danger: true, + onClick: () => { + // Record BEFORE removing: the reducer drops a tab record once the card is down to its last tab. + if (tabCount <= 1) { closeBrowserCard(browserId, dispatch); return; } + dispatch(recordClosedCard({ kind: 'tab', id: tab.id, browserId })); + dispatch(removeBrowserTab({ browserId, tabId: tab.id })); + }, + }, + ]; +} diff --git a/frontend/src/app/pages/Dashboard/cards/tileMenuRows.ts b/frontend/src/app/pages/Dashboard/cards/tileMenuRows.ts new file mode 100644 index 00000000..cd0a9c5b --- /dev/null +++ b/frontend/src/app/pages/Dashboard/cards/tileMenuRows.ts @@ -0,0 +1,34 @@ +import type { CardMenuRow } from '../desktop/openCardContextMenu'; + +// The green-dot tiling grid, spelled out as words for the keyboard/right-click path. +const ZONE_LABELS: Record = { + fill: 'Fill', + left: 'Left half', + right: 'Right half', + top: 'Top half', + bottom: 'Bottom half', + tl: 'Top left', + tr: 'Top right', + bl: 'Bottom left', + br: 'Bottom right', + t3l: 'Left third', + t3c: 'Center third', + t3r: 'Right third', +}; + +const GROUPS: { label: string; zones: string[] }[] = [ + { label: 'Fill and halves', zones: ['fill', 'left', 'right', 'top', 'bottom'] }, + { label: 'Quarters', zones: ['tl', 'tr', 'bl', 'br'] }, + { label: 'Thirds', zones: ['t3l', 't3c', 't3r'] }, +]; + +export function tileMenuRows(onTile: (zone: string) => void, currentZone?: string): CardMenuRow[] { + const rows: CardMenuRow[] = []; + for (const group of GROUPS) { + rows.push({ kind: 'header', label: group.label }); + for (const zone of group.zones) { + rows.push({ label: ZONE_LABELS[zone], checked: currentZone === zone, onClick: () => onTile(zone) }); + } + } + return rows; +} diff --git a/frontend/src/app/pages/Dashboard/cards/viewCardMenuRows.ts b/frontend/src/app/pages/Dashboard/cards/viewCardMenuRows.ts new file mode 100644 index 00000000..9cc7f8d9 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/cards/viewCardMenuRows.ts @@ -0,0 +1,61 @@ +import { addViewCard, bringToFront } from '@/shared/state/dashboardLayoutSlice'; +import { deleteOutput, type Output } from '@/shared/state/outputsSlice'; +import { removeViewCardCleanly } from '@/shared/viewTeardown'; +import { setClipboardCards } from '@/shared/dashboardClipboard'; +import type { AppDispatch } from '@/shared/state/store'; +import type { CardMenuRow } from '../desktop/openCardContextMenu'; +import { chord } from '../desktop/chord'; +import { tileMenuRows } from './tileMenuRows'; + +interface ViewMenuArgs { + output: Output; + cardKey: string; + dispatch: AppDispatch; + tileZone?: string; + isMinimized: boolean; + card: { x: number; y: number; width: number; height: number }; + onTile: (zone: string) => void; + onMinimize: () => void; + onReload: () => void; + onHardReload: () => void; + onShare: () => void; + onClose: () => void; +} + +export function viewCardMenuRows({ + output, cardKey, dispatch, tileZone, isMinimized, card, + onTile, onMinimize, onReload, onHardReload, onShare, onClose, +}: ViewMenuArgs): CardMenuRow[] { + return [ + { label: 'Open another instance', onClick: () => dispatch(addViewCard({ outputId: output.id, newInstance: true })) }, + { kind: 'separator' }, + { label: tileZone === 'fullscreen' ? 'Exit Full Screen' : 'Full Screen', onClick: () => onTile(tileZone === 'fullscreen' ? 'restore' : 'fullscreen') }, + { label: 'Tile to zone', submenu: tileMenuRows(onTile, tileZone) }, + { label: isMinimized ? 'Restore' : 'Minimize', onClick: onMinimize }, + { label: 'Bring to front', onClick: () => dispatch(bringToFront({ id: cardKey, type: 'view' })) }, + { kind: 'separator' }, + { label: 'Reload', onClick: onReload }, + { label: 'Restart and hard reload', onClick: onHardReload }, + { kind: 'separator' }, + { + label: 'Copy', + shortcut: chord('mod', 'C'), + onClick: () => setClipboardCards([{ + type: 'view', id: cardKey, name: output.name, + meta: { name: output.name, description: output.description }, + x: card.x, y: card.y, width: card.width, height: card.height, + }]), + }, + { label: 'Share or publish...', onClick: onShare }, + { kind: 'separator' }, + { label: 'Close', onClick: onClose }, + { + label: 'Delete app', + danger: true, + onClick: () => { + // The card has to go first: deleting the output alone leaves a card pointing at nothing. + void removeViewCardCleanly(cardKey, dispatch).then(() => dispatch(deleteOutput(output.id))); + }, + }, + ]; +} diff --git a/frontend/src/app/pages/Dashboard/desktop/CardContextMenu.tsx b/frontend/src/app/pages/Dashboard/desktop/CardContextMenu.tsx index 6e73b91b..13ff4845 100644 --- a/frontend/src/app/pages/Dashboard/desktop/CardContextMenu.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/CardContextMenu.tsx @@ -1,138 +1,274 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import Box from '@mui/material/Box'; +import CardMenuPanel, { MENU_WIDTH } from './CardMenuPanel'; +import { CARD_MENU_EVENT, isMenuAction, type CardMenuAction, type CardMenuRequest, type CardMenuRow } from './openCardContextMenu'; -// One right-click menu for every canvas entity (chats, browsers, apps, workflow cards, -// minimized pills). Cards call openCardContextMenu with their items; this overlay renders the -// native-feeling glass menu (SpacesStrip grammar) and closes on outside press / Esc / item click. -export interface CardMenuItem { - label: string; - danger?: boolean; - disabled?: boolean; - onClick: () => void; -} +// INVARIANT: the canvas pans/zooms via a CSS transform, and a transformed ancestor becomes the +// containing block for position:fixed, so this menu portals to document.body and never mounts inside +// the canvas subtree. A DOM menu also cannot cover an Electron : guest pages get Electron's +// own native menu, and a page that preventDefaults its contextmenu gets neither. That is expected. +const EDGE = 8; +const TOP_LAYER = 2147483647; -export interface CardMenuRequest { +interface Placement { x: number; y: number; - items: CardMenuItem[]; - /** Optional inline-rename affordance: shown as the first row with an editable input. */ - rename?: { value: string; onCommit: (next: string) => void }; + origin: string; + nudgeX: number; + nudgeY: number; } -const EVENT = 'openswarm:card-context-menu'; - -export function openCardContextMenu(e: { clientX: number; clientY: number; preventDefault: () => void; stopPropagation: () => void }, req: Omit): void { - e.preventDefault(); - e.stopPropagation(); - window.dispatchEvent(new CustomEvent(EVENT, { detail: { ...req, x: e.clientX, y: e.clientY } })); +function place(x: number, y: number, w: number, h: number): Placement { + const flipX = x + w > window.innerWidth - EDGE && x - w >= EDGE; + const flipY = y + h > window.innerHeight - EDGE && y - h >= EDGE; + const rawX = flipX ? x - w : x; + const rawY = flipY ? y - h : y + 2; + return { + x: Math.max(EDGE, Math.min(rawX, window.innerWidth - w - EDGE)), + y: Math.max(EDGE, Math.min(rawY, window.innerHeight - h - EDGE)), + origin: `${flipX ? 'right' : 'left'} ${flipY ? 'bottom' : 'top'}`, + nudgeX: flipX ? 4 : -4, + nudgeY: flipY ? 4 : -4, + }; } -const MENU_W = 208; +function step(items: CardMenuRow[], from: number | null, dir: 1 | -1): number | null { + const n = items.length; + if (n === 0) return null; + for (let hop = 1; hop <= n; hop += 1) { + const i = ((from ?? (dir === 1 ? -1 : 0)) + dir * hop + n * 2) % n; + const row = items[i]; + if (isMenuAction(row) && !row.disabled) return i; + } + return null; +} function CardContextMenu(): React.ReactElement | null { const [menu, setMenu] = useState(null); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(''); + const [rootPlacement, setRootPlacement] = useState(null); + const [shown, setShown] = useState(false); + const [activeIndex, setActiveIndex] = useState(null); + const [openIndex, setOpenIndex] = useState(null); + const [subActiveIndex, setSubActiveIndex] = useState(null); + const [subPlacement, setSubPlacement] = useState(null); + const rootRef = useRef(null); + const subRef = useRef(null); + const returnFocusTo = useRef(null); + + const close = useCallback((): void => { + setMenu(null); + setRootPlacement(null); + setSubPlacement(null); + setShown(false); + setOpenIndex(null); + setActiveIndex(null); + setSubActiveIndex(null); + const back = returnFocusTo.current; + returnFocusTo.current = null; + if (back && back.isConnected) back.focus?.(); + }, []); useEffect(() => { const onOpen = (e: Event): void => { const req = (e as CustomEvent).detail as CardMenuRequest; + returnFocusTo.current = document.activeElement as HTMLElement | null; setMenu(req); + setRootPlacement(null); + setSubPlacement(null); + setShown(false); + setOpenIndex(null); + setActiveIndex(null); + setSubActiveIndex(null); setRenaming(false); setRenameValue(req.rename?.value ?? ''); }; - window.addEventListener(EVENT, onOpen); - return () => window.removeEventListener(EVENT, onOpen); + window.addEventListener(CARD_MENU_EVENT, onOpen); + return () => window.removeEventListener(CARD_MENU_EVENT, onOpen); }, []); + // Measured, never guessed: rows render ~30px, so a hardcoded row height over-corrects long menus. + useLayoutEffect(() => { + if (!menu || !rootRef.current) return; + const r = rootRef.current.getBoundingClientRect(); + setRootPlacement(place(menu.x, menu.y, r.width || MENU_WIDTH, r.height)); + }, [menu]); + + // One frame at the pre-entry style, otherwise there is no start value for the transition to run from. + useEffect(() => { + if (!rootPlacement || shown) return undefined; + const raf = requestAnimationFrame(() => setShown(true)); + return () => cancelAnimationFrame(raf); + }, [rootPlacement, shown]); + + const openRow = menu && openIndex !== null ? menu.items[openIndex] : undefined; + const submenu = openRow && isMenuAction(openRow) ? (openRow as CardMenuAction).submenu : undefined; + + useLayoutEffect(() => { + if (!submenu || !subRef.current || !rootRef.current || openIndex === null) return; + const r = subRef.current.getBoundingClientRect(); + const panel = rootRef.current.getBoundingClientRect(); + const rowEl = rootRef.current.querySelector(`[data-menu-row="${openIndex}"]`); + const anchorY = (rowEl?.getBoundingClientRect().top ?? panel.top) - 6; + const toRight = panel.right - 4; + const fits = toRight + r.width <= window.innerWidth - EDGE; + const x = fits ? toRight : panel.left - r.width + 4; + setSubPlacement({ + x: Math.max(EDGE, Math.min(x, window.innerWidth - r.width - EDGE)), + y: Math.max(EDGE, Math.min(anchorY, window.innerHeight - r.height - EDGE)), + origin: `${fits ? 'left' : 'right'} top`, + nudgeX: fits ? -4 : 4, + nudgeY: 0, + }); + }, [submenu, openIndex]); + + const runRow = useCallback((row: CardMenuRow | undefined): void => { + if (!row || !isMenuAction(row) || row.disabled || row.submenu) return; + close(); + row.onClick?.(); + }, [close]); + useEffect(() => { if (!menu) return undefined; - const onDown = (): void => setMenu(null); - const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') setMenu(null); }; + const onDown = (e: Event): void => { + const t = e.target as Node | null; + if (t && (rootRef.current?.contains(t) || subRef.current?.contains(t))) return; + close(); + }; + const onKey = (e: KeyboardEvent): void => { + const items = menu.items; + if (e.key === 'Escape') { + e.preventDefault(); + if (openIndex !== null) { setOpenIndex(null); setSubActiveIndex(null); return; } + close(); + return; + } + if (submenu && openIndex !== null) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + setSubActiveIndex(step(submenu, subActiveIndex, e.key === 'ArrowDown' ? 1 : -1)); + return; + } + if (e.key === 'ArrowLeft') { e.preventDefault(); setOpenIndex(null); setSubActiveIndex(null); return; } + if (e.key === 'Enter' && subActiveIndex !== null) { e.preventDefault(); runRow(submenu[subActiveIndex]); } + return; + } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex(step(items, activeIndex, e.key === 'ArrowDown' ? 1 : -1)); + return; + } + const active = activeIndex !== null ? items[activeIndex] : undefined; + if (e.key === 'ArrowRight' && active && isMenuAction(active) && active.submenu) { + e.preventDefault(); + setOpenIndex(activeIndex); + setSubActiveIndex(step(active.submenu, null, 1)); + return; + } + if (e.key === 'Enter' && active) { + e.preventDefault(); + if (isMenuAction(active) && active.submenu) { setOpenIndex(activeIndex); setSubActiveIndex(step(active.submenu, null, 1)); return; } + runRow(active); + } + }; window.addEventListener('mousedown', onDown); window.addEventListener('keydown', onKey); window.addEventListener('wheel', onDown, { passive: true }); + window.addEventListener('resize', close); return () => { window.removeEventListener('mousedown', onDown); window.removeEventListener('keydown', onKey); window.removeEventListener('wheel', onDown); + window.removeEventListener('resize', close); }; - }, [menu]); + }, [menu, activeIndex, openIndex, subActiveIndex, submenu, close, runRow]); if (!menu) return null; - const itemSx = { - display: 'flex', alignItems: 'center', width: '100%', px: 1.5, py: 0.75, - border: 'none', background: 'transparent', borderRadius: '7px', - color: 'rgba(255,255,255,0.9)', fontFamily: 'inherit', fontSize: '0.8125rem', - cursor: 'pointer', textAlign: 'left' as const, - '&:hover': { background: 'rgba(255,255,255,0.1)' }, - '&:disabled': { color: 'rgba(255,255,255,0.35)', cursor: 'default', '&:hover': { background: 'transparent' } }, - }; - const commitRename = (): void => { const trimmed = renameValue.trim(); if (trimmed && menu.rename && trimmed !== menu.rename.value) menu.rename.onCommit(trimmed); - setMenu(null); + close(); }; - return ( - e.stopPropagation()} - onContextMenu={(e: React.MouseEvent) => e.preventDefault()} - sx={{ - position: 'fixed', - top: Math.min(menu.y + 2, window.innerHeight - 44 * (menu.items.length + 1) - 16), - left: Math.min(menu.x, window.innerWidth - MENU_W - 12), - zIndex: 100001, - width: MENU_W, p: 0.5, borderRadius: '10px', - background: 'rgba(28,25,33,0.96)', - backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)', - border: '1px solid rgba(255,255,255,0.12)', - boxShadow: '0 18px 44px rgba(0,0,0,0.5)', - }} - > - {menu.rename && ( - renaming ? ( - ) => setRenameValue(e.target.value)} - onKeyDown={(e: React.KeyboardEvent) => { - e.stopPropagation(); - if (e.key === 'Enter') commitRename(); - if (e.key === 'Escape') setMenu(null); - }} - onBlur={commitRename} - sx={{ - width: '100%', boxSizing: 'border-box', mb: 0.25, px: 1.25, py: 0.6, - border: '1px solid rgba(255,255,255,0.35)', borderRadius: '7px', - background: 'rgba(0,0,0,0.35)', outline: 'none', - color: 'rgba(255,255,255,0.95)', fontFamily: 'inherit', fontSize: '0.8125rem', - }} - /> - ) : ( - setRenaming(true)}> - Rename - - ) - )} - {menu.items.map((item) => ( - => ({ + position: 'fixed' as const, + left: p?.x ?? -9999, + top: p?.y ?? -9999, + pointerEvents: 'auto' as const, + opacity: visible ? 1 : 0, + transform: visible ? 'none' : `translate(${p?.nudgeX ?? 0}px, ${p?.nudgeY ?? 4}px) scale(0.97)`, + transformOrigin: p?.origin ?? 'left top', + transition: 'opacity 140ms cubic-bezier(0.25,0.46,0.45,0.94), transform 170ms cubic-bezier(0.25,0.46,0.45,0.94)', + '@media (prefers-reduced-motion: reduce)': { transition: 'none', transform: 'none' }, + }); + + return createPortal( + e.preventDefault()} sx={{ position: 'fixed', inset: 0, zIndex: TOP_LAYER, pointerEvents: 'none' }}> + + { + const row = menu.items[i]; + if (isMenuAction(row) && row.submenu) { setOpenIndex(i); setActiveIndex(i); setSubActiveIndex(null); return; } + runRow(row); + }} + onHover={(i) => { + setActiveIndex(i); + const row = menu.items[i]; + if (isMenuAction(row) && row.submenu) { setOpenIndex(i); setSubActiveIndex(null); } else setOpenIndex(null); }} - onClick={() => { setMenu(null); item.onClick(); }} > - {item.label} + {menu.rename && (renaming ? ( + ) => setRenameValue(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => { + e.stopPropagation(); + if (e.key === 'Enter') commitRename(); + if (e.key === 'Escape') close(); + }} + onBlur={commitRename} + sx={{ + width: '100%', boxSizing: 'border-box', mb: '2px', px: '9px', py: '6px', + border: '1px solid rgba(255,255,255,0.35)', borderRadius: '8px', + background: 'rgba(0,0,0,0.35)', outline: 'none', + color: 'rgba(255,255,255,0.95)', fontFamily: 'inherit', fontSize: '0.8125rem', + }} + /> + ) : ( + { setActiveIndex(null); setOpenIndex(null); }} + onClick={() => setRenaming(true)} + sx={{ + display: 'flex', alignItems: 'center', width: '100%', boxSizing: 'border-box', + px: '9px', py: '5px', minHeight: 30, border: 'none', background: 'transparent', + borderRadius: '8px', color: 'rgba(255,255,255,0.9)', fontFamily: 'inherit', + fontSize: '0.8125rem', cursor: 'pointer', textAlign: 'left', + '&:hover': { background: 'rgba(255,255,255,0.10)' }, + }} + > + Rename + + ))} + + + {submenu && ( + + runRow(submenu[i])} onHover={setSubActiveIndex} /> - ))} - + )} + , + document.body, ); } diff --git a/frontend/src/app/pages/Dashboard/desktop/CardMenuPanel.tsx b/frontend/src/app/pages/Dashboard/desktop/CardMenuPanel.tsx new file mode 100644 index 00000000..f8f1de38 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/CardMenuPanel.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; +import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'; +import { isMenuAction, type CardMenuRow } from './openCardContextMenu'; + +export const MENU_WIDTH = 236; +const ROW_RADIUS = 8; + +interface CardMenuPanelProps { + items: CardMenuRow[]; + /** Index into `items` of the keyboard-active row, or null. */ + activeIndex: number | null; + /** Index into `items` of the row whose submenu is open, or null. */ + openIndex: number | null; + onActivate: (index: number) => void; + onHover: (index: number) => void; + width?: number; + children?: React.ReactNode; +} + +const rowSx = { + display: 'flex', + alignItems: 'center', + gap: '9px', + width: '100%', + boxSizing: 'border-box' as const, + px: '9px', + py: '5px', + minHeight: 30, + border: 'none', + background: 'transparent', + borderRadius: `${ROW_RADIUS}px`, + color: 'rgba(255,255,255,0.9)', + fontFamily: 'inherit', + fontSize: '0.8125rem', + lineHeight: 1.35, + cursor: 'pointer', + textAlign: 'left' as const, +}; + +// Panel radius stays 12 while rows sit at 8: the inner corner must always be the smaller one. +export const PANEL_SX = { + width: MENU_WIDTH, + p: '6px', + boxSizing: 'border-box' as const, + borderRadius: '12px', + background: 'rgba(28,25,33,0.94)', + backdropFilter: 'blur(24px) saturate(150%)', + WebkitBackdropFilter: 'blur(24px) saturate(150%)', + border: '1px solid rgba(255,255,255,0.12)', + boxShadow: '0 18px 44px rgba(0,0,0,0.5)', +}; + +const CardMenuPanel = React.forwardRef(function CardMenuPanel( + { items, activeIndex, openIndex, onActivate, onHover, width, children }, + ref, +) { + return ( + + {children} + {items.map((row, index) => { + if (row.kind === 'separator') { + return ; + } + if (row.kind === 'header') { + return ( + + {row.label} + + ); + } + if (!isMenuAction(row)) return null; + const active = activeIndex === index && !row.disabled; + const highlighted = active || openIndex === index; + const tone = row.danger ? '#ff7b72' : 'rgba(255,255,255,0.9)'; + const hoverBg = row.danger ? 'rgba(255,123,114,0.14)' : 'rgba(255,255,255,0.10)'; + return ( + onHover(index)} + onClick={() => onActivate(index)} + sx={{ + ...rowSx, + color: tone, + background: highlighted ? hoverBg : 'transparent', + '&:hover': { background: hoverBg }, + '&:disabled': { color: 'rgba(255,255,255,0.32)', cursor: 'default', background: 'transparent' }, + }} + > + {row.checked !== undefined && ( + + )} + {row.icon !== undefined && ( + {row.icon} + )} + + {row.label} + + {row.shortcut && ( + + {row.shortcut} + + )} + {row.submenu && } + + ); + })} + + ); +}); + +export default CardMenuPanel; diff --git a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx index 8e6f7997..5ef207be 100644 --- a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx @@ -10,6 +10,8 @@ import AppsRoundedIcon from '@mui/icons-material/AppsRounded'; import { useAppDispatch } from '@/shared/hooks'; import { getWebview } from '@/shared/browserRegistry'; import { buildDockEntries, CardRect, DockEntry } from './dockEntries'; +import { openCardContextMenu } from './openCardContextMenu'; +import { dockTileMenuRows } from './dockTileMenuRows'; import type { AgentSession } from '@/shared/state/agentsSlice'; import type { CardPosition, @@ -177,6 +179,10 @@ function DesktopDock({ endHover(); onFocusCard(entry.id, entry.rect); }} + onContextMenu={(e: React.MouseEvent) => { + endHover(); + openCardContextMenu(e, { items: dockTileMenuRows(entry, dispatch, () => onFocusCard(entry.id, entry.rect)) }); + }} sx={{ position: 'relative', width: TILE, diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx index ffaf6526..aeac4cda 100644 --- a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx @@ -9,6 +9,8 @@ import { GLASS_SURFACE, GLASS_SURFACE_BLUR } from '@/shared/styles/glassSurface' import { dropMinimizedShot } from './minimizedShots'; import { buildMinimizedEntries, MinimizedEntry, MinimizedRect } from './minimizedEntries'; import MinimizedTile, { MINIMIZED_TILE_W } from './MinimizedTile'; +import { openCardContextMenu } from './openCardContextMenu'; +import { tileMenuRows } from '../cards/tileMenuRows'; import type { BrowserCardPosition, ViewCardPosition } from '@/shared/state/dashboardLayoutSlice'; import type { Output } from '@/shared/state/outputsSlice'; @@ -88,6 +90,15 @@ function MinimizedStack({ browserCards, viewCards, outputs, selectedIds, onResto onRestore={() => restore(entry)} onClose={() => close(entry)} onTile={(zone: string) => { restore(entry); if (zone !== 'restore') dispatch(setTiledCard({ cardId: entry.id, zone })); }} + onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, { + items: [ + { label: 'Restore', onClick: () => restore(entry) }, + { label: 'Restore full screen', onClick: () => { restore(entry); dispatch(setTiledCard({ cardId: entry.id, zone: 'fullscreen' })); } }, + { label: 'Tile to zone', submenu: tileMenuRows((zone) => { restore(entry); if (zone !== 'restore') dispatch(setTiledCard({ cardId: entry.id, zone })); }) }, + { kind: 'separator' }, + { label: 'Close', danger: true, onClick: () => close(entry) }, + ], + })} /> ))} diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx index e9a38c59..e0222bd9 100644 --- a/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx @@ -15,6 +15,7 @@ interface MinimizedTileProps { onRestore: () => void; onClose: () => void; onTile: (zone: string) => void; + onContextMenu: (e: React.MouseEvent) => void; } export const MINIMIZED_TILE_W = 132; @@ -27,7 +28,7 @@ const REST_EDGE = 'rgba(255,255,255,0.10)'; const HOVER_EDGE = 'rgba(255,255,255,0.16)'; /** One parked window: its own last frame, a favicon or glyph, and the title. Hover reveals the lights. */ -function MinimizedTile({ entry, accent, selected, onRestore, onClose, onTile }: MinimizedTileProps): React.ReactElement { +function MinimizedTile({ entry, accent, selected, onRestore, onClose, onTile, onContextMenu }: MinimizedTileProps): React.ReactElement { const [faviconFailed, setFaviconFailed] = useState(false); const preview = getMinimizedShot(entry.id) || entry.thumbnail || null; const showFavicon = entry.kind === 'browser' && !!entry.faviconUrl && !faviconFailed; @@ -55,6 +56,7 @@ function MinimizedTile({ entry, accent, selected, onRestore, onClose, onTile }: = { + mod: IS_MAC ? '⌘' : 'Ctrl', + ctrl: IS_MAC ? '⌃' : 'Ctrl', + shift: IS_MAC ? '⇧' : 'Shift', + alt: IS_MAC ? '⌥' : 'Alt', + enter: IS_MAC ? '↩' : 'Enter', + del: IS_MAC ? '⌫' : 'Del', + tab: IS_MAC ? '⇥' : 'Tab', +}; + +export function chord(...keys: string[]): string { + return keys.map((k) => GLYPH[k] ?? k).join(IS_MAC ? '' : '+'); +} diff --git a/frontend/src/app/pages/Dashboard/desktop/dockEntries.tsx b/frontend/src/app/pages/Dashboard/desktop/dockEntries.tsx index 7e6887ef..c8b05d07 100644 --- a/frontend/src/app/pages/Dashboard/desktop/dockEntries.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/dockEntries.tsx @@ -21,8 +21,11 @@ export interface CardRect { height: number; } +export type DockEntryKind = 'agent' | 'browser' | 'view' | 'workflow'; + export interface DockEntry { id: string; + kind: DockEntryKind; label: string; rect: CardRect; tileBg: string; @@ -66,6 +69,7 @@ export function buildDockEntries({ sessions, cards, viewCards, browserCards, wor const ChatIcon = pickIcon(title) || MessageCircle; list.push({ id: card.session_id, + kind: 'agent', label: title, rect: card, tileBg: hueFor(title), @@ -77,6 +81,7 @@ export function buildDockEntries({ sessions, cards, viewCards, browserCards, wor const activeTab = bc.tabs.find((t) => t.id === bc.activeTabId) || bc.tabs[0]; list.push({ id: bc.browser_id, + kind: 'browser', label: activeTab?.title || 'Browser', rect: bc, tileBg: 'linear-gradient(135deg, #4f9fe8, #2f6ed4)', @@ -90,6 +95,7 @@ export function buildDockEntries({ sessions, cards, viewCards, browserCards, wor const appName = output?.name || 'App'; list.push({ id: cardKey, + kind: 'view', label: appName, rect: vc, tileBg: 'linear-gradient(135deg, #ef9552, #d96a2b)', @@ -101,6 +107,7 @@ export function buildDockEntries({ sessions, cards, viewCards, browserCards, wor for (const [cardKey, wf] of Object.entries(workflowCards)) { list.push({ id: cardKey, + kind: 'workflow', label: 'Workflow', rect: wf, tileBg: 'linear-gradient(135deg, #ef7a70, #d94f45)', diff --git a/frontend/src/app/pages/Dashboard/desktop/dockTileMenuRows.ts b/frontend/src/app/pages/Dashboard/desktop/dockTileMenuRows.ts new file mode 100644 index 00000000..b81bf079 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/dockTileMenuRows.ts @@ -0,0 +1,27 @@ +import { bringToFront, recordClosedCard, removeCard, removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice'; +import { closeSession } from '@/shared/state/agentsSlice'; +import { removeBrowserCardCleanly } from '@/shared/browserTeardown'; +import { removeViewCardCleanly } from '@/shared/viewTeardown'; +import type { AppDispatch } from '@/shared/state/store'; +import type { CardMenuRow } from './openCardContextMenu'; +import type { DockEntry } from './dockEntries'; + +export function dockTileMenuRows(entry: DockEntry, dispatch: AppDispatch, onFocus: () => void): CardMenuRow[] { + return [ + { label: 'Show on canvas', onClick: onFocus }, + { label: 'Bring to front', onClick: () => dispatch(bringToFront({ id: entry.id, type: entry.kind })) }, + { kind: 'separator' }, + { + label: 'Close', + danger: true, + onClick: () => { + if (entry.kind === 'browser') { dispatch(recordClosedCard({ kind: 'browser', id: entry.id })); void removeBrowserCardCleanly(entry.id, dispatch); return; } + if (entry.kind === 'view') { dispatch(recordClosedCard({ kind: 'view', id: entry.id })); void removeViewCardCleanly(entry.id, dispatch); return; } + if (entry.kind === 'workflow') { dispatch(recordClosedCard({ kind: 'workflow', id: entry.id })); dispatch(removeWorkflowCard(entry.id)); return; } + dispatch(recordClosedCard({ kind: 'agent', id: entry.id })); + dispatch(removeCard(entry.id)); + void dispatch(closeSession({ sessionId: entry.id })); + }, + }, + ]; +} diff --git a/frontend/src/app/pages/Dashboard/desktop/openCardContextMenu.ts b/frontend/src/app/pages/Dashboard/desktop/openCardContextMenu.ts new file mode 100644 index 00000000..3c5713dc --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/openCardContextMenu.ts @@ -0,0 +1,64 @@ +import type React from 'react'; + +// The one right-click grammar for the whole shell (cards, tab strip, dock, minimized rail, empty +// canvas). Surfaces describe rows; CardContextMenu owns rendering, placement, and keyboard nav. + +export interface CardMenuAction { + kind?: 'action'; + label: string; + /** Right-aligned chip. Build it with chord() so it matches the platform; never invent unbound keys. */ + shortcut?: string; + icon?: React.ReactNode; + danger?: boolean; + disabled?: boolean; + /** Renders a leading check; use for toggles that are currently on. */ + checked?: boolean; + /** One level only. Deep nesting is a menu smell. */ + submenu?: CardMenuRow[]; + onClick?: () => void; +} + +export interface CardMenuSeparator { + kind: 'separator'; +} + +export interface CardMenuHeader { + kind: 'header'; + /** Sentence case, never uppercase. */ + label: string; +} + +export type CardMenuRow = CardMenuAction | CardMenuSeparator | CardMenuHeader; + +export interface CardMenuRequest { + x: number; + y: number; + items: CardMenuRow[]; + /** Optional inline-rename affordance: shown as the first row with an editable input. */ + rename?: { value: string; onCommit: (next: string) => void }; +} + +export const CARD_MENU_EVENT = 'openswarm:card-context-menu'; + +export function isMenuAction(row: CardMenuRow): row is CardMenuAction { + return row.kind === undefined || row.kind === 'action'; +} + +/** Text fields keep the OS menu: right-clicking a URL bar or composer is how you paste. */ +export function isNativeMenuTarget(e: { target: EventTarget | null }): boolean { + const t = e.target as HTMLElement | null; + return !!t?.closest?.('input, textarea, [contenteditable="true"]'); +} + +interface MenuTriggerEvent { + clientX: number; + clientY: number; + preventDefault: () => void; + stopPropagation: () => void; +} + +export function openCardContextMenu(e: MenuTriggerEvent, req: Omit): void { + e.preventDefault(); + e.stopPropagation(); + window.dispatchEvent(new CustomEvent(CARD_MENU_EVENT, { detail: { ...req, x: e.clientX, y: e.clientY } })); +} diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/pasteClipboardCards.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/pasteClipboardCards.ts new file mode 100644 index 00000000..1123db2f --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/pasteClipboardCards.ts @@ -0,0 +1,67 @@ +import { duplicateSession, expandSession } from '@/shared/state/agentsSlice'; +import { addViewCard, pasteBrowserCard, placeCard } from '@/shared/state/dashboardLayoutSlice'; +import { store, type AppDispatch } from '@/shared/state/store'; +import { getClipboardCards } from '@/shared/dashboardClipboard'; +import type { CardType } from '../state/useDashboardSelection'; + +const PASTE_OFFSET = 40; + +interface PasteTargets { + selectCard: (id: string, type: CardType, additive: boolean) => void; + deselectAll: () => void; +} + +interface PasteArgs { + dispatch: AppDispatch; + dashboardId: string; + expandedSessionIds: string[]; + selection: PasteTargets; + /** Canvas-space drop point; without it the copies land offset from their originals. */ + at?: { x: number; y: number }; +} + +export async function pasteClipboardCards({ dispatch, dashboardId, expandedSessionIds, selection, at }: PasteArgs): Promise { + const copied = getClipboardCards(); + if (copied.length === 0) return; + + // Right-click paste anchors the whole group at the cursor by shifting off the first card's corner. + const anchorX = copied[0].x; + const anchorY = copied[0].y; + + selection.deselectAll(); + const newSelection = new Map(); + + for (const card of copied) { + const px = at ? at.x + (card.x - anchorX) : card.x + PASTE_OFFSET; + const py = at ? at.y + (card.y - anchorY) : card.y - PASTE_OFFSET; + + if (card.type === 'agent') { + const action = await dispatch(duplicateSession({ sessionId: card.id, dashboardId })); + if (duplicateSession.fulfilled.match(action)) { + const newId = action.payload.id; + dispatch(placeCard({ sessionId: newId, x: px, y: py, width: card.width, height: card.height, expandedSessionIds })); + if (card.expanded) dispatch(expandSession(newId)); + newSelection.set(newId, 'agent'); + } + } else if (card.type === 'view') { + // Pasting an app whose card is already open creates a NEW independent instance (own runtime + ports) instead of no-op'ing. + const outputId = card.id.split('#')[0]; + dispatch(addViewCard({ outputId, expandedSessionIds, x: px, y: py, width: card.width, height: card.height, newInstance: true })); + const viewCards = store.getState().dashboardLayout.viewCards; + let pastedKey = outputId; + for (const [key, vc] of Object.entries(viewCards)) { + if (vc.output_id === outputId && (vc.instance ?? 1) >= (viewCards[pastedKey]?.instance ?? 1)) pastedKey = key; + } + newSelection.set(pastedKey, 'view'); + } else if (card.type === 'browser') { + const browserId = `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; + dispatch(pasteBrowserCard({ + id: browserId, tabs: card.meta.tabs || [], url: card.meta.url || '', + x: px, y: py, width: card.width, height: card.height, + })); + newSelection.set(browserId, 'browser'); + } + } + + for (const [id, type] of newSelection) selection.selectCard(id, type, true); +} diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardClipboard.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardClipboard.ts index d3f7e0b8..984bd89c 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardClipboard.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardClipboard.ts @@ -1,22 +1,15 @@ import { useEffect } from 'react'; import { useAppDispatch } from '@/shared/hooks'; -import { - duplicateSession, - expandSession, - type AgentSession, -} from '@/shared/state/agentsSlice'; -import { - addViewCard, - pasteBrowserCard, - placeCard, - type CardPosition, - type ViewCardPosition, - type BrowserCardPosition, +import type { AgentSession } from '@/shared/state/agentsSlice'; +import type { + CardPosition, + ViewCardPosition, + BrowserCardPosition, } from '@/shared/state/dashboardLayoutSlice'; import type { Output } from '@/shared/state/outputsSlice'; -import { store } from '@/shared/state/store'; import { setClipboardCards, getClipboardCards, type ClipboardCard } from '@/shared/dashboardClipboard'; -import type { CardType, useDashboardSelection } from '../state/useDashboardSelection'; +import { pasteClipboardCards } from './pasteClipboardCards'; +import type { useDashboardSelection } from '../state/useDashboardSelection'; type Selection = ReturnType; @@ -102,68 +95,16 @@ export function useDashboardClipboard({ }, [selection.selectedIds, sessions, cards, viewCards, browserCards, outputs, expandedSessionIds]); useEffect(() => { - const PASTE_OFFSET = 40; - const handlePaste = async (e: KeyboardEvent) => { + const handlePaste = (e: KeyboardEvent) => { if (!isActive) return; // Don't fire shortcuts when dashboard is hidden if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'v') return; const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return; - - const copied = getClipboardCards(); - if (copied.length === 0) return; + if (getClipboardCards().length === 0) return; e.preventDefault(); - - selection.deselectAll(); - const newSelection = new Map(); - - for (const card of copied) { - const px = card.x + PASTE_OFFSET; - const py = card.y - PASTE_OFFSET; - - if (card.type === 'agent') { - const action = await dispatch(duplicateSession({ sessionId: card.id, dashboardId })); - if (duplicateSession.fulfilled.match(action)) { - const newId = action.payload.id; - dispatch(placeCard({ - sessionId: newId, - x: px, - y: py, - width: card.width, - height: card.height, - expandedSessionIds, - })); - if (card.expanded) { - dispatch(expandSession(newId)); - } - newSelection.set(newId, 'agent'); - } - } else if (card.type === 'view') { - // Pasting an app whose card is already open creates a NEW independent instance (own runtime + ports) instead of no-op'ing. - const outputId = card.id.split('#')[0]; - dispatch(addViewCard({ outputId, expandedSessionIds, x: px, y: py, width: card.width, height: card.height, newInstance: true })); - const viewCards = store.getState().dashboardLayout.viewCards; - let pastedKey = outputId; - for (const [key, vc] of Object.entries(viewCards)) { - if (vc.output_id === outputId && (vc.instance ?? 1) >= (viewCards[pastedKey]?.instance ?? 1)) pastedKey = key; - } - newSelection.set(pastedKey, 'view'); - } else if (card.type === 'browser') { - const browserId = `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; - dispatch(pasteBrowserCard({ - id: browserId, tabs: card.meta.tabs || [], url: card.meta.url || '', - x: px, y: py, width: card.width, height: card.height, - })); - newSelection.set(browserId, 'browser'); - } - } - - if (newSelection.size > 0) { - for (const [id, type] of newSelection) { - selection.selectCard(id, type, true); - } - } + void pasteClipboardCards({ dispatch, dashboardId, expandedSessionIds, selection }); }; window.addEventListener('keydown', handlePaste); return () => window.removeEventListener('keydown', handlePaste); - }, [dispatch, dashboardId, expandedSessionIds, selection]); + }, [dispatch, dashboardId, expandedSessionIds, selection, isActive]); } diff --git a/frontend/src/app/pages/Workflows/SchedulePopover.tsx b/frontend/src/app/pages/Workflows/SchedulePopover.tsx index 48e8b6b4..5d83fc1a 100644 --- a/frontend/src/app/pages/Workflows/SchedulePopover.tsx +++ b/frontend/src/app/pages/Workflows/SchedulePopover.tsx @@ -31,6 +31,7 @@ interface Props { historyQuery: string; onHistoryQueryChange: (q: string) => void; onHistorySelect: (id: string) => void; + onHistoryContextMenu?: (e: React.MouseEvent, entry: { id: string; name: string }) => void; onNewChat: () => void; onWorkflowSelect: (id: string) => void; onExpand: () => void; @@ -50,7 +51,7 @@ interface Props { export default function SchedulePopover({ mode, onModeChange, historyResults, historyLoading, historyQuery, onHistoryQueryChange, - onHistorySelect, onNewChat, onWorkflowSelect, onExpand, + onHistorySelect, onHistoryContextMenu, onNewChat, onWorkflowSelect, onExpand, allRuns, allRunsLoading, onRunOpen, workflowTitleFor, historyScrollRef, onHistoryScroll, hideTopChrome = false, @@ -98,12 +99,9 @@ export default function SchedulePopover({ // Both Search and Schedule modes render at the same fixed dimensions so toggling chips doesn't resize the popover. Schedule sets the floor: its 7-day calendar needs ~620w x ~420h, search inherits the same. const POPOVER_W = 620; const CONTENT_H = 420; - return ( - {/* Floating mode chips. Hidden when the parent toolbar supplies its - own pill row (Image #32 / #54); kept around so the legacy callers - that surface Schedule mode still have a way in. */} + {/* Floating mode chips. Hidden when the parent toolbar supplies its own pill row; kept for legacy callers that surface Schedule mode. */} {!hideTopChrome && ( } active={mode === 'search'} onClick={() => onModeChange('search')} /> @@ -175,7 +173,7 @@ export default function SchedulePopover({ {bucket !== prevBucket && ( {bucket} )} - onHistorySelect(entry.id)} sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1, py: 0.8, cursor: 'pointer', borderRadius: `${c.radius.md}px`, '&:hover': { bgcolor: c.bg.elevated } }}> + onHistorySelect(entry.id)} onContextMenu={(e: React.MouseEvent) => onHistoryContextMenu?.(e, entry)} sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1, py: 0.8, cursor: 'pointer', borderRadius: `${c.radius.md}px`, '&:hover': { bgcolor: c.bg.elevated } }}> From 318957b39c84ba0d786438afe186c09e87bdfb2a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 16:16:50 -0700 Subject: [PATCH 012/117] [eric] dashboard: minimize Settings and the Workflows hub to the rail instead of closing them --- .../app/pages/Dashboard/DashboardToolbar.tsx | 5 +- .../Dashboard/cards/CanvasWindowCard.tsx | 98 ++++------------ .../app/pages/Dashboard/cards/tileZones.ts | 9 +- .../Dashboard/cards/useCanvasWindowResize.ts | 107 ++++++++++++++++++ .../Dashboard/desktop/MinimizedStack.tsx | 30 ++++- .../pages/Dashboard/desktop/MinimizedTile.tsx | 8 +- .../Dashboard/desktop/minimizedEntries.ts | 17 ++- .../app/pages/Settings/SettingsAppCard.tsx | 6 +- .../pages/Workflows/app/WorkflowsAppCard.tsx | 6 +- .../Workflows/app/WorkflowsAppContent.tsx | 4 +- .../src/shared/state/dashboardLayoutSlice.ts | 12 ++ 11 files changed, 207 insertions(+), 95 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/cards/useCanvasWindowResize.ts diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index fbb7f528..857314f8 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -13,7 +13,7 @@ import ChatInput from '@/app/pages/AgentChat/ChatInput'; import type { ContextPath } from '@/app/components/editor/DirectoryBrowser'; import SchedulePopover from '@/app/pages/Workflows/SchedulePopover'; import { openWorkflowCard, fetchAllRuns, upsertRun } from '@/shared/state/workflowsSlice'; -import { addWorkflowCard, openWorkflowsApp, closeWorkflowsApp } from '@/shared/state/dashboardLayoutSlice'; +import { addWorkflowCard, openWorkflowsApp, closeWorkflowsApp, WORKFLOWS_HUB_ID } from '@/shared/state/dashboardLayoutSlice'; import { useElementSelection } from '@/app/components/editor/ElementSelectionContext'; import { useClaudeTokens, DarkTokensScope } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; @@ -171,7 +171,8 @@ const DashboardToolbar = React.forwardRef( const allRuns = useAppSelector((s) => s.workflows.allRuns); const allRunsLoading = useAppSelector((s) => s.workflows.allRunsLoading); const workflowItems = useAppSelector((s) => s.workflows.items); - const workflowsHubOpen = useAppSelector((s) => Boolean(s.dashboardLayout.workflowsHub)); + // Parked counts as not-showing, so the pill restores the window instead of throwing its state away. + const workflowsHubOpen = useAppSelector((s) => Boolean(s.dashboardLayout.workflowsHub) && !s.dashboardLayout.minimizedCards[WORKFLOWS_HUB_ID]); const outputList = useMemo(() => Object.values(outputs), [outputs]); const filteredOutputs = useMemo(() => { diff --git a/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx b/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx index 08ed73d1..2283859f 100644 --- a/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx @@ -1,32 +1,14 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { TILE_ZONES, useTiledStyle } from './tileZones'; +import { useCanvasWindowResize } from './useCanvasWindowResize'; import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops'; import type { CardType } from '@/shared/state/dashboardLayoutSlice'; -type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; - -const EDGE = 6; -const CORNER = 14; const DRAG_THRESHOLD = 3; const SNAP_GRID = 24; const TILE_GAP = 8; -const CURSOR_MAP: Record = { - n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize', - nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize', -}; -const HANDLE_DEFS: { dir: ResizeDir; css: React.CSSProperties }[] = [ - { dir: 'n', css: { top: -EDGE / 2, left: CORNER, right: CORNER, height: EDGE } }, - { dir: 's', css: { bottom: -EDGE / 2, left: CORNER, right: CORNER, height: EDGE } }, - { dir: 'w', css: { left: -EDGE / 2, top: CORNER, bottom: CORNER, width: EDGE } }, - { dir: 'e', css: { right: -EDGE / 2, top: CORNER, bottom: CORNER, width: EDGE } }, - { dir: 'nw', css: { top: -EDGE / 2, left: -EDGE / 2, width: CORNER, height: CORNER } }, - { dir: 'ne', css: { top: -EDGE / 2, right: -EDGE / 2, width: CORNER, height: CORNER } }, - { dir: 'sw', css: { bottom: -EDGE / 2, left: -EDGE / 2, width: CORNER, height: CORNER } }, - { dir: 'se', css: { bottom: -EDGE / 2, right: -EDGE / 2, width: CORNER, height: CORNER } }, -]; - /** Drag handlers the window hands down to whatever renders its title bar. */ export interface CanvasWindowHeader { onPointerDown: (e: React.PointerEvent) => void; @@ -50,6 +32,8 @@ interface CanvasWindowCardProps { selectName: string; cardX: number; cardY: number; cardWidth: number; cardHeight: number; cardZOrder?: number; fullscreen?: boolean; + /** Parked in the minimized rail: stays mounted (and keeps its state) off-canvas instead of unmounting. */ + minimized?: boolean; minWidth: number; minHeight: number; background: string; highlightColor: string; getCanvasState: () => { panX: number; panY: number; zoom: number }; @@ -71,7 +55,7 @@ interface CanvasWindowCardProps { const CanvasWindowCard: React.FC = ({ cardId, cardType, selectType, selectName, cardX, cardY, cardWidth, cardHeight, cardZOrder = 0, - fullscreen = false, minWidth, minHeight, background, highlightColor, + fullscreen = false, minimized = false, minWidth, minHeight, background, highlightColor, getCanvasState, isSelected = false, isHighlighted = false, multiDragDelta = null, onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront, @@ -166,53 +150,10 @@ const CanvasWindowCard: React.FC = ({ }, [finalizeDrag]); useDragEndBackstops(isDragging, finalizeDrag, abortDrag); - // ---- Resize ---- - const resizeRef = useRef<{ dir: ResizeDir; sx0: number; sy0: number; ox: number; oy: number; ow: number; oh: number } | null>(null); - const [isResizing, setIsResizing] = useState(false); - const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null); - - const onResizeDown = useCallback((dir: ResizeDir) => (e: React.PointerEvent) => { - if (e.button !== 0) return; - e.preventDefault(); - e.stopPropagation(); - resizeRef.current = { dir, sx0: e.clientX, sy0: e.clientY, ox: cardX, oy: cardY, ow: cardWidth, oh: cardHeight }; - setIsResizing(true); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - }, [cardX, cardY, cardWidth, cardHeight]); - - const compute = useCallback((e: React.PointerEvent) => { - if (!resizeRef.current) return null; - const { dir, sx0, sy0, ox, oy, ow, oh } = resizeRef.current; - const z2 = getCanvasState().zoom; - const dx = (e.clientX - sx0) / z2; - const dy = (e.clientY - sy0) / z2; - let nx = ox, ny = oy, nw = ow, nh = oh; - if (dir.includes('e')) nw = ow + dx; - if (dir.includes('w')) { nw = ow - dx; nx = ox + dx; } - if (dir.includes('s')) nh = oh + dy; - if (dir.includes('n')) { nh = oh - dy; ny = oy + dy; } - if (nw < minWidth) { if (dir.includes('w')) nx = ox + ow - minWidth; nw = minWidth; } - if (nh < minHeight) { if (dir.includes('n')) ny = oy + oh - minHeight; nh = minHeight; } - return { x: nx, y: ny, w: nw, h: nh }; - }, [getCanvasState, minWidth, minHeight]); - - const onResizeMove = useCallback((e: React.PointerEvent) => { - const r = compute(e); - if (r) setLocalResize(r); - }, [compute]); - - const onResizeUp = useCallback((e: React.PointerEvent) => { - if (!resizeRef.current) return; - const r = compute(e); - if (r) { - onCommitPosition(r.x, r.y); - onCommitSize(r.w, r.h); - } - resizeRef.current = null; - setLocalResize(null); - setIsResizing(false); - (e.target as HTMLElement).releasePointerCapture(e.pointerId); - }, [compute, onCommitPosition, onCommitSize]); + const { isResizing, live: localResize, handles } = useCanvasWindowResize({ + cardX, cardY, cardWidth, cardHeight, minWidth, minHeight, + getCanvasState, onCommitPosition, onCommitSize, + }); const onTileZone = useCallback((zone: string) => { const z = TILE_ZONES[zone]; @@ -249,15 +190,20 @@ const CanvasWindowCard: React.FC = ({ if (target.closest('[data-no-drag]')) return; onCardSelect?.(cardId, cardType, e.shiftKey); }} + data-keepalive-hidden={minimized ? '1' : undefined} style={{ position: 'absolute', contain: 'layout style', willChange: 'transform', - left: fsStyle ? fsStyle.left : dx, - top: fsStyle ? fsStyle.top : dy, + // Parked windows go off-canvas rather than unmounting, so Settings keeps its form and Workflows its view state. + pointerEvents: minimized ? 'none' : undefined, + // Belt and braces: leaving fullscreen tears down the tiled-style hook, whose cleanup strips the inline left/top React just wrote, and visibility is the one park signal it never touches. + visibility: minimized ? 'hidden' : undefined, + left: minimized ? -100000 : fsStyle ? fsStyle.left : dx, + top: minimized ? -100000 : fsStyle ? fsStyle.top : dy, width: fsStyle ? fsStyle.width : dw, height: fsStyle ? fsStyle.height : dh, - transform: fsStyle ? fsStyle.transform : undefined, + transform: minimized ? undefined : fsStyle ? fsStyle.transform : undefined, transformOrigin: fsStyle ? fsStyle.transformOrigin : undefined, background, border: fsStyle ? 'none' : border, @@ -282,14 +228,14 @@ const CanvasWindowCard: React.FC = ({ onTileZone, })} - {!fullscreen && HANDLE_DEFS.map(({ dir, css }) => ( + {!fullscreen && !minimized && handles.map((h) => (
))}
diff --git a/frontend/src/app/pages/Dashboard/cards/tileZones.ts b/frontend/src/app/pages/Dashboard/cards/tileZones.ts index 669d0ea6..d01d53b4 100644 --- a/frontend/src/app/pages/Dashboard/cards/tileZones.ts +++ b/frontend/src/app/pages/Dashboard/cards/tileZones.ts @@ -124,9 +124,14 @@ export function useTiledStyle( : (el.parentElement as HTMLElement | null) ?? el; const posProps = ['left', 'top']; const sizeProps = ['width', 'height', 'transform', 'transform-origin', 'transition']; + // Drop ONLY the overrides this hook still owns: React re-asserts these props without the important + // flag when a card leaves its zone, and deleting those left the window with no geometry at all. + const clearOwn = (target: HTMLElement, props: string[]): void => { + props.forEach((pr) => { if (target.style.getPropertyPriority(pr) === 'important') target.style.removeProperty(pr); }); + }; const clearAll = (): void => { - posProps.forEach((pr) => posEl.style.removeProperty(pr)); - sizeProps.forEach((pr) => el.style.removeProperty(pr)); + clearOwn(posEl, posProps); + clearOwn(el, sizeProps); }; const apply = (): void => { // A parked card (kept alive off-screen / minimized) must keep its sx parking position. diff --git a/frontend/src/app/pages/Dashboard/cards/useCanvasWindowResize.ts b/frontend/src/app/pages/Dashboard/cards/useCanvasWindowResize.ts new file mode 100644 index 00000000..9f1ef0d3 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/cards/useCanvasWindowResize.ts @@ -0,0 +1,107 @@ +import React, { useCallback, useRef, useState } from 'react'; + +type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; + +const EDGE = 6; +const CORNER = 14; + +const CURSOR_MAP: Record = { + n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize', + nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize', +}; +const HANDLE_DEFS: { dir: ResizeDir; css: React.CSSProperties }[] = [ + { dir: 'n', css: { top: -EDGE / 2, left: CORNER, right: CORNER, height: EDGE } }, + { dir: 's', css: { bottom: -EDGE / 2, left: CORNER, right: CORNER, height: EDGE } }, + { dir: 'w', css: { left: -EDGE / 2, top: CORNER, bottom: CORNER, width: EDGE } }, + { dir: 'e', css: { right: -EDGE / 2, top: CORNER, bottom: CORNER, width: EDGE } }, + { dir: 'nw', css: { top: -EDGE / 2, left: -EDGE / 2, width: CORNER, height: CORNER } }, + { dir: 'ne', css: { top: -EDGE / 2, right: -EDGE / 2, width: CORNER, height: CORNER } }, + { dir: 'sw', css: { bottom: -EDGE / 2, left: -EDGE / 2, width: CORNER, height: CORNER } }, + { dir: 'se', css: { bottom: -EDGE / 2, right: -EDGE / 2, width: CORNER, height: CORNER } }, +]; + +export interface CanvasWindowResizeHandle { + dir: string; + style: React.CSSProperties; + onPointerDown: (e: React.PointerEvent) => void; + onPointerMove: (e: React.PointerEvent) => void; + onPointerUp: (e: React.PointerEvent) => void; +} + +export interface CanvasWindowResizeState { + isResizing: boolean; + /** Live geometry while the pointer is down; null once committed to the slice. */ + live: { x: number; y: number; w: number; h: number } | null; + handles: CanvasWindowResizeHandle[]; +} + +interface CanvasWindowResizeArgs { + cardX: number; cardY: number; cardWidth: number; cardHeight: number; + minWidth: number; minHeight: number; + getCanvasState: () => { panX: number; panY: number; zoom: number }; + onCommitPosition: (x: number, y: number) => void; + onCommitSize: (width: number, height: number) => void; +} + +/** The 8 edge/corner grips of a canvas window: preview the new rect locally, commit it on release. */ +export function useCanvasWindowResize({ + cardX, cardY, cardWidth, cardHeight, minWidth, minHeight, + getCanvasState, onCommitPosition, onCommitSize, +}: CanvasWindowResizeArgs): CanvasWindowResizeState { + const resizeRef = useRef<{ dir: ResizeDir; sx0: number; sy0: number; ox: number; oy: number; ow: number; oh: number } | null>(null); + const [isResizing, setIsResizing] = useState(false); + const [live, setLive] = useState<{ x: number; y: number; w: number; h: number } | null>(null); + + const onResizeDown = useCallback((dir: ResizeDir) => (e: React.PointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + resizeRef.current = { dir, sx0: e.clientX, sy0: e.clientY, ox: cardX, oy: cardY, ow: cardWidth, oh: cardHeight }; + setIsResizing(true); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, [cardX, cardY, cardWidth, cardHeight]); + + const compute = useCallback((e: React.PointerEvent) => { + if (!resizeRef.current) return null; + const { dir, sx0, sy0, ox, oy, ow, oh } = resizeRef.current; + const zoom = getCanvasState().zoom; + const dx = (e.clientX - sx0) / zoom; + const dy = (e.clientY - sy0) / zoom; + let nx = ox, ny = oy, nw = ow, nh = oh; + if (dir.includes('e')) nw = ow + dx; + if (dir.includes('w')) { nw = ow - dx; nx = ox + dx; } + if (dir.includes('s')) nh = oh + dy; + if (dir.includes('n')) { nh = oh - dy; ny = oy + dy; } + if (nw < minWidth) { if (dir.includes('w')) nx = ox + ow - minWidth; nw = minWidth; } + if (nh < minHeight) { if (dir.includes('n')) ny = oy + oh - minHeight; nh = minHeight; } + return { x: nx, y: ny, w: nw, h: nh }; + }, [getCanvasState, minWidth, minHeight]); + + const onResizeMove = useCallback((e: React.PointerEvent) => { + const r = compute(e); + if (r) setLive(r); + }, [compute]); + + const onResizeUp = useCallback((e: React.PointerEvent) => { + if (!resizeRef.current) return; + const r = compute(e); + if (r) { + onCommitPosition(r.x, r.y); + onCommitSize(r.w, r.h); + } + resizeRef.current = null; + setLive(null); + setIsResizing(false); + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + }, [compute, onCommitPosition, onCommitSize]); + + const handles = HANDLE_DEFS.map(({ dir, css }) => ({ + dir, + style: { position: 'absolute' as const, cursor: CURSOR_MAP[dir], zIndex: 25, ...css }, + onPointerDown: onResizeDown(dir), + onPointerMove: onResizeMove, + onPointerUp: onResizeUp, + })); + + return { isResizing, live, handles }; +} diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx index aeac4cda..60bccb2f 100644 --- a/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedStack.tsx @@ -1,7 +1,10 @@ import React from 'react'; import Box from '@mui/material/Box'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { toggleMinimizeCard, setTiledCard, recordClosedCard } from '@/shared/state/dashboardLayoutSlice'; +import { + toggleMinimizeCard, setTiledCard, recordClosedCard, + closeSettingsCard, closeWorkflowsApp, toggleSettingsCardFullscreen, toggleWorkflowsHubFullscreen, +} from '@/shared/state/dashboardLayoutSlice'; import { removeBrowserCardCleanly } from '@/shared/browserTeardown'; import { removeViewCardCleanly } from '@/shared/viewTeardown'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; @@ -27,7 +30,9 @@ function MinimizedStack({ browserCards, viewCards, outputs, selectedIds, onResto const dispatch = useAppDispatch(); const c = useClaudeTokens(); const minimizedCards = useAppSelector((s) => s.dashboardLayout.minimizedCards); - const entries = buildMinimizedEntries({ browserCards, viewCards, outputs, minimizedCards }); + const workflowsHub = useAppSelector((s) => s.dashboardLayout.workflowsHub); + const settingsCard = useAppSelector((s) => s.dashboardLayout.settingsCard); + const entries = buildMinimizedEntries({ browserCards, viewCards, outputs, workflowsHub, settingsCard, minimizedCards }); if (entries.length === 0) return null; const restore = (entry: MinimizedEntry): void => { @@ -40,11 +45,22 @@ function MinimizedStack({ browserCards, viewCards, outputs, selectedIds, onResto if (entry.kind === 'browser') { dispatch(recordClosedCard({ kind: 'browser', id: entry.id })); void removeBrowserCardCleanly(entry.id, dispatch); - } else { + } else if (entry.kind === 'view') { dispatch(recordClosedCard({ kind: 'view', id: entry.id })); void removeViewCardCleanly(entry.id, dispatch); + } else if (entry.kind === 'workflows') { + dispatch(closeWorkflowsApp()); + } else { + dispatch(closeSettingsCard()); } }; + // Singletons own a fullscreen flag instead of a tiledCards entry, so a setTiledCard here would strand a ghost fullscreen owner nothing renders. + const tile = (entry: MinimizedEntry, zone: string): void => { + restore(entry); + if (entry.kind === 'workflows') { if (zone === 'fullscreen') dispatch(toggleWorkflowsHubFullscreen()); return; } + if (entry.kind === 'settings') { if (zone === 'fullscreen') dispatch(toggleSettingsCardFullscreen()); return; } + if (zone !== 'restore') dispatch(setTiledCard({ cardId: entry.id, zone })); + }; return ( restore(entry)} onClose={() => close(entry)} - onTile={(zone: string) => { restore(entry); if (zone !== 'restore') dispatch(setTiledCard({ cardId: entry.id, zone })); }} + onTile={(zone: string) => tile(entry, zone)} onContextMenu={(e: React.MouseEvent) => openCardContextMenu(e, { items: [ { label: 'Restore', onClick: () => restore(entry) }, - { label: 'Restore full screen', onClick: () => { restore(entry); dispatch(setTiledCard({ cardId: entry.id, zone: 'fullscreen' })); } }, - { label: 'Tile to zone', submenu: tileMenuRows((zone) => { restore(entry); if (zone !== 'restore') dispatch(setTiledCard({ cardId: entry.id, zone })); }) }, + { label: 'Restore full screen', onClick: () => tile(entry, 'fullscreen') }, + ...(entry.kind === 'browser' || entry.kind === 'view' + ? [{ label: 'Tile to zone', submenu: tileMenuRows((zone) => tile(entry, zone)) }] + : []), { kind: 'separator' }, { label: 'Close', danger: true, onClick: () => close(entry) }, ], diff --git a/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx b/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx index e0222bd9..ac8e29c6 100644 --- a/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/MinimizedTile.tsx @@ -3,6 +3,8 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import LanguageIcon from '@mui/icons-material/Language'; import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded'; +import EventRepeatIcon from '@mui/icons-material/EventRepeat'; +import SettingsIcon from '@mui/icons-material/Settings'; import { pickIcon } from '../canvas/DashboardGlyph'; import WindowControls, { ARC_CHIP_SX } from '../cards/WindowControls'; import { getMinimizedShot } from './minimizedShots'; @@ -46,6 +48,9 @@ function MinimizedTile({ entry, accent, selected, onRestore, onClose, onTile, on ); } if (entry.kind === 'browser') return ; + // The singleton windows never have a thumbnail, so their own app glyph is the whole identity of the tile. + if (entry.kind === 'workflows') return ; + if (entry.kind === 'settings') return ; // Never a letter here: a glyph initial reads as a bug, so an unmatched name falls back to a real symbol. const AppIcon = pickIcon(entry.label); if (AppIcon) return ; @@ -108,7 +113,8 @@ function MinimizedTile({ entry, accent, selected, onRestore, onClose, onTile, on opacity: 0, pointerEvents: 'none', transition: 'opacity 140ms ease', }} > - + {/* The singleton windows only know fullscreen, so their green light must not offer half/quarter zones it can't honor. */} +
diff --git a/frontend/src/app/pages/Dashboard/desktop/minimizedEntries.ts b/frontend/src/app/pages/Dashboard/desktop/minimizedEntries.ts index 0cc1e131..30711ab4 100644 --- a/frontend/src/app/pages/Dashboard/desktop/minimizedEntries.ts +++ b/frontend/src/app/pages/Dashboard/desktop/minimizedEntries.ts @@ -1,4 +1,5 @@ -import type { BrowserCardPosition, ViewCardPosition } from '@/shared/state/dashboardLayoutSlice'; +import { SETTINGS_CARD_ID, WORKFLOWS_HUB_ID } from '@/shared/state/dashboardLayoutSlice'; +import type { BrowserCardPosition, ViewCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice'; import type { Output } from '@/shared/state/outputsSlice'; export interface MinimizedRect { @@ -8,7 +9,7 @@ export interface MinimizedRect { height: number; } -export type MinimizedKind = 'browser' | 'view'; +export type MinimizedKind = 'browser' | 'view' | 'workflows' | 'settings'; export interface MinimizedEntry { id: string; @@ -25,14 +26,18 @@ export interface MinimizedSlices { browserCards: Record; viewCards: Record; outputs: Record; + workflowsHub: WorkflowsHubPosition | null; + settingsCard: WorkflowsHubPosition | null; minimizedCards: Record; } -/** Every window parked in the minimized rail, browsers and apps in one list so both share a small state. */ +/** Every window parked in the minimized rail, browsers, apps and the singleton windows in one list so they all share a small state. */ export function buildMinimizedEntries({ browserCards, viewCards, outputs, + workflowsHub, + settingsCard, minimizedCards, }: MinimizedSlices): MinimizedEntry[] { const list: MinimizedEntry[] = []; @@ -59,5 +64,11 @@ export function buildMinimizedEntries({ thumbnail: outputs[vc.output_id]?.thumbnail, }); } + if (workflowsHub && minimizedCards[WORKFLOWS_HUB_ID]) { + list.push({ id: WORKFLOWS_HUB_ID, kind: 'workflows', label: 'Workflows', rect: workflowsHub }); + } + if (settingsCard && minimizedCards[SETTINGS_CARD_ID]) { + list.push({ id: SETTINGS_CARD_ID, kind: 'settings', label: 'Settings', rect: settingsCard }); + } return list; } diff --git a/frontend/src/app/pages/Settings/SettingsAppCard.tsx b/frontend/src/app/pages/Settings/SettingsAppCard.tsx index 19563dc5..89c255fb 100644 --- a/frontend/src/app/pages/Settings/SettingsAppCard.tsx +++ b/frontend/src/app/pages/Settings/SettingsAppCard.tsx @@ -5,6 +5,7 @@ import { closeSettingsCard, setSettingsCardPosition, setSettingsCardSize, + toggleMinimizeCard, toggleSettingsCardFullscreen, SETTINGS_CARD_ID, } from '@/shared/state/dashboardLayoutSlice'; @@ -44,6 +45,7 @@ const SettingsAppCard: React.FC = ({ const c = useClaudeTokens(); const dispatch = useAppDispatch(); const isFullscreen = useAppSelector((s) => !!s.dashboardLayout.settingsCard?.fullscreen); + const isMinimized = useAppSelector((s) => !!s.dashboardLayout.minimizedCards[SETTINGS_CARD_ID]); const commitPosition = useCallback((x: number, y: number) => { dispatch(setSettingsCardPosition({ x, y })); @@ -52,6 +54,7 @@ const SettingsAppCard: React.FC = ({ dispatch(setSettingsCardSize({ width, height })); }, [dispatch]); const close = useCallback(() => { dispatch(closeSettingsCard()); }, [dispatch]); + const minimize = useCallback(() => { dispatch(toggleMinimizeCard({ cardId: SETTINGS_CARD_ID })); }, [dispatch]); return ( = ({ cardHeight={cardHeight} cardZOrder={cardZOrder} fullscreen={isFullscreen} + minimized={isMinimized} minWidth={MIN_W} minHeight={MIN_H} background={c.bg.page} @@ -104,7 +108,7 @@ const SettingsAppCard: React.FC = ({ > { if (zone === 'fullscreen' || zone === 'restore') { dispatch(toggleSettingsCardFullscreen()); return; } if (isFullscreen) dispatch(toggleSettingsCardFullscreen()); diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx index 5c5e5443..83b39f1e 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { setWorkflowsHubPosition, setWorkflowsHubSize } from '@/shared/state/dashboardLayoutSlice'; +import { setWorkflowsHubPosition, setWorkflowsHubSize, WORKFLOWS_HUB_ID } from '@/shared/state/dashboardLayoutSlice'; import CanvasWindowCard from '@/app/pages/Dashboard/cards/CanvasWindowCard'; import type { CardType } from '@/shared/state/dashboardLayoutSlice'; import { useWC } from './uiKit'; @@ -35,6 +35,7 @@ const WorkflowsAppCard: React.FC = ({ const WC = useWC(); const dispatch = useAppDispatch(); const isFullscreen = useAppSelector((s) => !!s.dashboardLayout.workflowsHub?.fullscreen); + const isMinimized = useAppSelector((s) => !!s.dashboardLayout.minimizedCards[WORKFLOWS_HUB_ID]); // Keep fonts/keyframes available while the card is mounted. useEffect(() => { ensureAssets(); }, []); @@ -48,7 +49,7 @@ const WorkflowsAppCard: React.FC = ({ return ( = ({ cardHeight={cardHeight} cardZOrder={cardZOrder} fullscreen={isFullscreen} + minimized={isMinimized} minWidth={MIN_W} minHeight={MIN_H} background={WC.page} diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx index 63f1d91c..4f133436 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import EventRepeatIcon from '@mui/icons-material/EventRepeat'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { clearWorkflowsAppTarget, closeWorkflowsApp, toggleWorkflowsHubFullscreen } from '@/shared/state/dashboardLayoutSlice'; +import { clearWorkflowsAppTarget, closeWorkflowsApp, toggleMinimizeCard, toggleWorkflowsHubFullscreen, WORKFLOWS_HUB_ID } from '@/shared/state/dashboardLayoutSlice'; import WindowControls from '@/app/pages/Dashboard/cards/WindowControls'; import { fetchWorkflows, fetchAllRuns, fetchPausedState, fetchActiveRuns, fetchDeletedWorkflows, @@ -84,7 +84,7 @@ const WorkflowsAppContent: React.FC<{ header: CardHeader; onTileZone?: (zone: st > dispatch(closeWorkflowsApp())} - onMinimize={() => dispatch(closeWorkflowsApp())} + onMinimize={() => dispatch(toggleMinimizeCard({ cardId: WORKFLOWS_HUB_ID }))} onTile={(zone) => { if (zone === 'fullscreen' || zone === 'restore') { dispatch(toggleWorkflowsHubFullscreen()); return; } if (isFullscreen) dispatch(toggleWorkflowsHubFullscreen()); diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index c00233d9..c3cf681b 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -26,7 +26,9 @@ export const DEFAULT_WORKFLOWS_HUB_W = DEFAULT_BROWSER_CARD_W; export const DEFAULT_WORKFLOWS_HUB_H = DEFAULT_BROWSER_CARD_H; export const DEFAULT_SETTINGS_CARD_W = 900; export const DEFAULT_SETTINGS_CARD_H = 640; +// The two singleton windows have no card map to key off, so they own these fixed ids everywhere (selection, minimize, z-order). export const SETTINGS_CARD_ID = 'settings'; +export const WORKFLOWS_HUB_ID = 'workflows-hub'; export const EXPANDED_CARD_MIN_H = 620; export const GRID_GAP = 24; // Gap between the Workflows window and the cards it spawns (run monitor, that monitor's browser). Keeps the hub -> monitor -> browser row evenly spaced. @@ -579,6 +581,9 @@ const dashboardLayoutSlice = createSlice({ } else { state.minimizedCards[id] = true; if (state.tiledCards[id]) delete state.tiledCards[id]; + // The singletons hold their own fullscreen flag instead of a tiledCards entry, so parking one has to drop that too. + if (id === SETTINGS_CARD_ID && state.settingsCard) state.settingsCard.fullscreen = false; + if (id === WORKFLOWS_HUB_ID && state.workflowsHub) state.workflowsHub.fullscreen = false; } }, setTiledCard(state, action: PayloadAction<{ cardId: string; zone: string }>) { @@ -1115,6 +1120,7 @@ const dashboardLayoutSlice = createSlice({ openWorkflowsHub(state, action: PayloadAction<{ expandedSessionIds?: string[] } | undefined>) { if (state.workflowsHub) { state.workflowsHub.zOrder = state.nextZOrder++; + delete state.minimizedCards[WORKFLOWS_HUB_ID]; state.pendingFocusWorkflowsHub = true; return; } @@ -1136,6 +1142,7 @@ const dashboardLayoutSlice = createSlice({ closeWorkflowsHub(state) { state.workflowsHub = null; + delete state.minimizedCards[WORKFLOWS_HUB_ID]; }, // The Workflows app is an on-canvas card (like chat/browser/view cards), backed by the singleton workflowsHub geometry. Opening it creates or raises that card and pans to it; an optional workflowId deep-links to that workflow's detail once the card mounts. @@ -1143,6 +1150,8 @@ const dashboardLayoutSlice = createSlice({ state.workflowsAppTarget = action.payload?.workflowId ?? null; if (state.workflowsHub) { state.workflowsHub.zOrder = state.nextZOrder++; + // Opening means visible: a parked window must come back to the canvas, or the focus pan flies to empty space. + delete state.minimizedCards[WORKFLOWS_HUB_ID]; state.pendingFocusWorkflowsHub = true; return; } @@ -1160,6 +1169,7 @@ const dashboardLayoutSlice = createSlice({ closeWorkflowsApp(state) { state.workflowsHub = null; + delete state.minimizedCards[WORKFLOWS_HUB_ID]; state.workflowsAppTarget = null; state.workflowsMonitorId = null; state.workflowsMonitorRunId = null; @@ -1232,6 +1242,7 @@ const dashboardLayoutSlice = createSlice({ openSettingsCard(state, action: PayloadAction<{ expandedSessionIds?: string[] } | undefined>) { if (state.settingsCard) { state.settingsCard.zOrder = state.nextZOrder++; + delete state.minimizedCards[SETTINGS_CARD_ID]; state.pendingFocusSettingsCard = true; return; } @@ -1249,6 +1260,7 @@ const dashboardLayoutSlice = createSlice({ closeSettingsCard(state) { state.settingsCard = null; + delete state.minimizedCards[SETTINGS_CARD_ID]; state.pendingFocusSettingsCard = false; }, From 29f4c26a3596b7d973f94aa5b6503ac5cfa7a259 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 16:16:50 -0700 Subject: [PATCH 013/117] [eric] dock: swap the hard blue selection outline for the rail's accent ring --- .../src/app/pages/Dashboard/desktop/DesktopDock.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx index 5ef207be..49ae584f 100644 --- a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx @@ -8,6 +8,7 @@ import { openSettingsCard, openWorkflowsApp } from '@/shared/state/dashboardLayo import SettingsIcon from '@mui/icons-material/Settings'; import AppsRoundedIcon from '@mui/icons-material/AppsRounded'; import { useAppDispatch } from '@/shared/hooks'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { getWebview } from '@/shared/browserRegistry'; import { buildDockEntries, CardRect, DockEntry } from './dockEntries'; import { openCardContextMenu } from './openCardContextMenu'; @@ -66,6 +67,7 @@ function DesktopDock({ onAddBrowser, }: DesktopDockProps): React.ReactElement | null { const dispatch = useAppDispatch(); + const accent = useClaudeTokens().accent.primary; const dockBodyRef = useRef(null); // macOS Dock magnification: the tile under the cursor grows on a bell curve and its neighbors // SLIDE AWAY to make room (no horizontal pop-out, the part that read as wobble). Each tile that @@ -195,7 +197,13 @@ function DesktopDock({ cursor: 'pointer', overflow: 'hidden', flexShrink: 0, - ...(isActive && { outline: '2px solid #6aa2ff', outlineOffset: '2px' }), + transition: 'box-shadow 140ms ease, background 140ms ease', + // Same grammar as the minimized rail: soft accent tint, ONE accent inner ring as the carrier, and the icon lifts. The outer glow is decoration, never the signal. + ...(isActive && { + background: `linear-gradient(0deg, ${accent}1f, ${accent}1f), ${entry.tileBg}`, + boxShadow: `inset 0 0 0 1px ${accent}, 0 0 24px ${accent}26`, + '& > *': { filter: 'brightness(1.25)' }, + }), }} > {/* Keyed by url so navigating to a new site re-arms the favicon after a previous one failed. */} From 119e4a916e12ae1fd39474b924a786c46bafbdea Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 16:24:58 -0700 Subject: [PATCH 014/117] [eric] web: stop paying a dead search engine its full tier budget on every query, and let a 403 still try the lite frontend --- .../apps/agents/tools/search/search_ddg.py | 9 ++ backend/apps/web/cascade.py | 19 +++ backend/apps/web/tier_breaker.py | 77 ++++++++++ backend/apps/web/web.py | 9 +- backend/tests/test_tier_breaker.py | 141 ++++++++++++++++++ backend/tests/test_web_search_ddg.py | 43 ++++++ backend/tests/web_cascade_fixtures.py | 9 ++ 7 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 backend/apps/web/tier_breaker.py create mode 100644 backend/tests/test_tier_breaker.py diff --git a/backend/apps/agents/tools/search/search_ddg.py b/backend/apps/agents/tools/search/search_ddg.py index 00d8c472..630d32d1 100644 --- a/backend/apps/agents/tools/search/search_ddg.py +++ b/backend/apps/agents/tools/search/search_ddg.py @@ -51,7 +51,16 @@ async def search_ddg(query: str, num_results: int) -> str: if lite is None: raise DDGRateLimited(query) return lite + # A hard block (403 is what html escalates to after the 202s) used to skip lite entirely, so a whole second frontend went untried; measured 7 times in one 44-query round. if reply.status >= 400: + try: + lite = await search_ddg_lite(query, num_results) + except Exception as exc: + raise RuntimeError(f"DuckDuckGo html returned HTTP {reply.status}; lite: {exc}") from None + if lite is None: + raise DDGRateLimited(query) + if lite: + return lite raise RuntimeError(f"DuckDuckGo html returned HTTP {reply.status}") body = reply.text diff --git a/backend/apps/web/cascade.py b/backend/apps/web/cascade.py index 16229271..af5cf6eb 100644 --- a/backend/apps/web/cascade.py +++ b/backend/apps/web/cascade.py @@ -14,6 +14,12 @@ from typing import Awaitable, Callable, Dict, List, Optional from pydantic import BaseModel, ConfigDict, Field, InstanceOf from typeguard import typechecked +from backend.apps.web.tier_breaker import ( + record_tier_failure, + record_tier_success, + tier_cooldown_left, +) + # A tier handed less than this has no realistic chance, and reporting it as a timeout would be a lie; we say the budget ran out instead. MIN_TIER_SECONDS = 3.0 @@ -24,6 +30,8 @@ class CascadeTier(BaseModel): name: str run: InstanceOf[Callable[[], Awaitable[Optional[Dict]]]] budget: float + # Only for tiers whose failure is a property of the HOST, not of this request; see tier_breaker. + breaker: bool = False class CascadeOutcome(BaseModel): @@ -48,14 +56,25 @@ async def run_cascade(tiers: List[CascadeTier], total_budget: float) -> CascadeO f"{total_budget:.0f}s cascade budget spent; not attempted: {', '.join(skipped)}" ) break + cooling = tier_cooldown_left(tier.name) if tier.breaker else 0.0 + if cooling: + errors.append(f"{tier.name}: skipped, still failing (retry in {cooling:.0f}s)") + continue slice_seconds = min(tier.budget, remaining) try: result = await asyncio.wait_for(tier.run(), timeout=slice_seconds) except asyncio.TimeoutError: errors.append(f"{tier.name}: timed out after {slice_seconds:.0f}s") + if tier.breaker: + record_tier_failure(tier.name) except Exception as exc: errors.append(f"{tier.name}: {str(exc)[:150]}") + if tier.breaker: + record_tier_failure(tier.name) else: + # Answering "no hits" still proves the host is up, so it clears the failure streak. + if tier.breaker: + record_tier_success(tier.name) if result is not None: return CascadeOutcome(result=result, errors=errors) diff --git a/backend/apps/web/tier_breaker.py b/backend/apps/web/tier_breaker.py new file mode 100644 index 00000000..e574c0f0 --- /dev/null +++ b/backend/apps/web/tier_breaker.py @@ -0,0 +1,77 @@ +"""Short-lived circuit breaker for the cascade's fixed-host tiers. + +Measured on this machine over three 44-query rounds: DuckDuckGo answered the +first 7 searches, then served its bot challenge, then 403'd, then stopped +answering TCP altogether. Once that happened EVERY search paid the full 8s +DuckDuckGo tier budget before Startpage answered, so the keyless p50 went from +1.0s to 8.5s while the success rate stayed at 100%. The engine wasn't broken, +our retrying of a known-dead engine was. + +A tier only opts in when "closed" is a property of the HOST rather than of the +request, which is true for a search frontend and false for a page fetch (one +404 says nothing about the next URL). State is per-process and time-boxed, so +the worst a wrong guess costs is one cooldown window of a tier we skip. +""" + +import time +from typing import Dict, Optional + +from pydantic import BaseModel, ConfigDict +from typeguard import typechecked + +# Two failures can be one bad minute; three in a row is a closed door. +FAILURES_TO_OPEN = 3 +FIRST_COOLDOWN_SECONDS = 120.0 +MAX_COOLDOWN_SECONDS = 900.0 + + +class TierHealth(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + consecutive_failures: int = 0 + open_until: float = 0.0 + cooldown: float = 0.0 + + +p_health: Dict[str, TierHealth] = {} + + +@typechecked +def p_entry(name: str) -> TierHealth: + if name not in p_health: + p_health[name] = TierHealth() + return p_health[name] + + +@typechecked +def tier_cooldown_left(name: str, now: Optional[float] = None) -> float: + """Seconds until this tier is worth trying again; 0 when it is open for business.""" + entry = p_health.get(name) + if entry is None: + return 0.0 + return max(0.0, entry.open_until - (time.monotonic() if now is None else now)) + + +@typechecked +def record_tier_failure(name: str, now: Optional[float] = None) -> None: + """A timeout or exception. Three in a row shuts the tier for a doubling cooldown.""" + stamp = time.monotonic() if now is None else now + entry = p_entry(name) + entry.consecutive_failures += 1 + if entry.consecutive_failures < FAILURES_TO_OPEN: + return + # The half-open probe that fails again doubles the wait, so a permanently dead engine stops costing anything. + entry.cooldown = min(max(entry.cooldown * 2, FIRST_COOLDOWN_SECONDS), MAX_COOLDOWN_SECONDS) + entry.open_until = stamp + entry.cooldown + + +@typechecked +def record_tier_success(name: str) -> None: + """The tier answered, even if the answer was 'no hits'. It is alive; forget the history.""" + if name in p_health: + p_health[name] = TierHealth() + + +@typechecked +def reset_tier_health() -> None: + p_health.clear() diff --git a/backend/apps/web/web.py b/backend/apps/web/web.py index fd0ac11b..62326793 100644 --- a/backend/apps/web/web.py +++ b/backend/apps/web/web.py @@ -187,8 +187,8 @@ async def search(body: SearchBody) -> Dict: "backend": "openai_subscription"} tiers = [ - CascadeTier(name="ddg", run=try_keyless, budget=KEYLESS_TIER_SECONDS), - CascadeTier(name="startpage", run=try_startpage, budget=KEYLESS_TIER_SECONDS), + CascadeTier(name="ddg", run=try_keyless, budget=KEYLESS_TIER_SECONDS, breaker=True), + CascadeTier(name="startpage", run=try_startpage, budget=KEYLESS_TIER_SECONDS, breaker=True), CascadeTier(name="browser_search", run=try_browser_search, budget=BROWSER_TIER_SECONDS), ] + p_grounded_tiers("search", body.primary, { "gemini_native": try_gemini, @@ -232,9 +232,12 @@ async def search(body: SearchBody) -> Dict: async def fetch(body: FetchBody) -> Dict: """Fetch a URL, primary-aware. Mirrors the /search cascade.""" # Belt-and-suspenders: even though we delegate to remote Gemini/OpenAI fetchers (which can't reach private IPs), validating the URL here means a private/metadata URL gets a 4xx instead of being silently forwarded. - from backend.apps.agents.tools.ssrf_guard import SSRFBlocked, assert_safe_url + from backend.apps.agents.tools.ssrf_guard import DomainUnreachable, SSRFBlocked, assert_safe_url try: await assert_safe_url(body.url) + except DomainUnreachable: + # A domain that no longer resolves is the archive's whole reason for existing, so let the cascade run instead of 400ing here. + pass except SSRFBlocked as exc: raise HTTPException(status_code=400, detail=f"Refused: {exc}") gemini_key = resolve_gemini_api_key() diff --git a/backend/tests/test_tier_breaker.py b/backend/tests/test_tier_breaker.py new file mode 100644 index 00000000..b2183518 --- /dev/null +++ b/backend/tests/test_tier_breaker.py @@ -0,0 +1,141 @@ +"""A dead search frontend must stop costing us its whole tier budget. + +Measured before this existed: once DuckDuckGo stopped answering TCP, three +consecutive 44-query rounds each paid the full 8s DuckDuckGo budget on EVERY +query, so keyless p50 went 1.0s -> 8.5s while Startpage still served 40/40. +These pin the breaker that makes that unrepresentable, and pin the two ways it +could go wrong instead: skipping a healthy tier, or leaking a per-URL failure +into a fixed-host one. +""" + +import asyncio + +import pytest + +import backend.apps.web.cascade as C +from backend.apps.web.cascade import CascadeTier, run_cascade +from backend.apps.web.tier_breaker import ( + FAILURES_TO_OPEN, + FIRST_COOLDOWN_SECONDS, + MAX_COOLDOWN_SECONDS, + record_tier_failure, + record_tier_success, + reset_tier_health, + tier_cooldown_left, +) + + +@pytest.fixture(autouse=True) +def p_clean(): + reset_tier_health() + yield + reset_tier_health() + + +@pytest.fixture +def p_tiny_floor(monkeypatch): + monkeypatch.setattr(C, "MIN_TIER_SECONDS", 0.01) + + +async def p_boom(): + raise RuntimeError("engine closed") + + +async def p_hit(): + return {"backend": "second"} + + +def test_streak_opens_then_success_clears(): + for _ in range(FAILURES_TO_OPEN - 1): + record_tier_failure("ddg") + assert tier_cooldown_left("ddg") == 0.0 + record_tier_failure("ddg") + assert 0 < tier_cooldown_left("ddg") <= FIRST_COOLDOWN_SECONDS + record_tier_success("ddg") + assert tier_cooldown_left("ddg") == 0.0 + + +def test_cooldown_doubles_and_is_capped(): + seen = [] + now = 0.0 + for round_no in range(12): + for _ in range(FAILURES_TO_OPEN): + record_tier_failure("ddg", now=now) + seen.append(tier_cooldown_left("ddg", now=now)) + now += seen[-1] + 1 + record_tier_failure("ddg", now=now) # the half-open probe fails again + assert seen[0] == pytest.approx(FIRST_COOLDOWN_SECONDS) + assert seen[1] > seen[0] + assert max(seen) <= MAX_COOLDOWN_SECONDS + + +@pytest.mark.asyncio +async def test_dead_tier_is_skipped_instantly(p_tiny_floor): + calls = [] + + async def p_slow_boom(): + calls.append(1) + await asyncio.sleep(0.05) + raise RuntimeError("engine closed") + + tiers = [ + CascadeTier(name="ddg", run=p_slow_boom, budget=5.0, breaker=True), + CascadeTier(name="startpage", run=p_hit, budget=5.0, breaker=True), + ] + for _ in range(FAILURES_TO_OPEN): + out = await run_cascade(tiers, 5.0) + assert out.result == {"backend": "second"} + assert len(calls) == FAILURES_TO_OPEN + + out = await run_cascade(tiers, 5.0) + assert out.result == {"backend": "second"} + assert len(calls) == FAILURES_TO_OPEN, "a cooling tier must not be called at all" + assert any("skipped, still failing" in e for e in out.errors) + + +@pytest.mark.asyncio +async def test_timeout_counts_as_failure(p_tiny_floor): + async def p_hangs(): + await asyncio.sleep(30) + + tiers = [ + CascadeTier(name="ddg", run=p_hangs, budget=0.05, breaker=True), + CascadeTier(name="startpage", run=p_hit, budget=5.0, breaker=True), + ] + for _ in range(FAILURES_TO_OPEN): + await run_cascade(tiers, 5.0) + assert tier_cooldown_left("ddg") > 0 + + +@pytest.mark.asyncio +async def test_no_hits_is_not_a_failure(p_tiny_floor): + """An engine that answers 'nothing matched' is alive; only errors count against it.""" + async def p_empty(): + return None + + tiers = [ + CascadeTier(name="ddg", run=p_empty, budget=5.0, breaker=True), + CascadeTier(name="startpage", run=p_hit, budget=5.0, breaker=True), + ] + for _ in range(FAILURES_TO_OPEN + 2): + await run_cascade(tiers, 5.0) + assert tier_cooldown_left("ddg") == 0.0 + + +@pytest.mark.asyncio +async def test_tiers_without_breaker_always_run(p_tiny_floor): + """A fetch tier fails per-URL, so one dead page must never cool the tier for every other URL.""" + calls = [] + + async def p_fail(): + calls.append(1) + raise RuntimeError("404") + + tiers = [ + CascadeTier(name="local", run=p_fail, budget=5.0), + CascadeTier(name="wayback", run=p_hit, budget=5.0), + ] + for _ in range(FAILURES_TO_OPEN + 3): + await run_cascade(tiers, 5.0) + assert len(calls) == FAILURES_TO_OPEN + 3 + assert tier_cooldown_left("local") == 0.0 diff --git a/backend/tests/test_web_search_ddg.py b/backend/tests/test_web_search_ddg.py index b6897046..53ff9ac7 100644 --- a/backend/tests/test_web_search_ddg.py +++ b/backend/tests/test_web_search_ddg.py @@ -80,3 +80,46 @@ async def test_genuinely_empty_is_not_a_rate_limit(monkeypatch): p_patch_client(monkeypatch, p_reply(200, "nothing here")) out = await WebSearchTool.search_ddg("zxcvqwer no hits", 5) assert out == "" + + +P_LITE_RESULTS = """ + +
Lite Result
A snippet from the lite frontend.
+""" + + +def p_patch_split(monkeypatch, html_reply: HttpReply, lite_reply: HttpReply): + """Let the two DDG frontends answer differently, which is the whole point of having both.""" + async def p_html(url, **kw): + return html_reply + + async def p_lite(url, **kw): + return lite_reply + monkeypatch.setattr(SD, "browser_request", p_html) + monkeypatch.setattr(SDL, "browser_request", p_lite) + + +@pytest.mark.asyncio +async def test_html_403_still_tries_lite(monkeypatch): + """Measured 7 times in one 44-query round: html escalates from 202 to 403, and the old + code raised on the status without ever asking the second frontend.""" + p_patch_split(monkeypatch, p_reply(403, "blocked"), p_reply(200, P_LITE_RESULTS)) + out = await WebSearchTool.search_ddg("topic", 5) + assert "example.org/lite" in out + assert "Lite Result" in out + + +@pytest.mark.asyncio +async def test_html_403_and_lite_challenged_is_the_bot_challenge(monkeypatch): + p_patch_split(monkeypatch, p_reply(403, "blocked"), p_reply(202, "challenge")) + with pytest.raises(DDGRateLimited): + await WebSearchTool.search_ddg("topic", 5) + + +@pytest.mark.asyncio +async def test_both_frontends_erroring_names_both(monkeypatch): + p_patch_split(monkeypatch, p_reply(403, "blocked"), p_reply(500, "boom")) + with pytest.raises(RuntimeError) as exc: + await WebSearchTool.search_ddg("topic", 5) + assert "403" in str(exc.value) + assert "lite" in str(exc.value).lower() diff --git a/backend/tests/web_cascade_fixtures.py b/backend/tests/web_cascade_fixtures.py index eb7e90df..cfc0fb43 100644 --- a/backend/tests/web_cascade_fixtures.py +++ b/backend/tests/web_cascade_fixtures.py @@ -7,6 +7,15 @@ import backend.apps.agents.tools.search.search_startpage as SP import backend.apps.web.web as W from backend.apps.agents.tools.web import DDGRateLimited, WebSearchTool import backend.apps.agents.tools.ssrf_guard as p_ssrf +from backend.apps.web.tier_breaker import reset_tier_health + + +@pytest.fixture(autouse=True) +def fresh_breaker(): + # The breaker is per-process by design, so without this one test's forced outage cools the next test's tier. + reset_tier_health() + yield + reset_tier_health() @pytest.fixture(autouse=True) From a78d6276c0ea14069a3335f39001cd9bfaa5d685 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 16:25:05 -0700 Subject: [PATCH 015/117] [eric] web: extract the article instead of the nav bar, and stop calling a dead domain a security refusal --- .../apps/agents/tools/fetch/html_to_text.py | 86 +++++++++++++++ backend/apps/agents/tools/fetch/page_text.py | 14 --- backend/apps/agents/tools/fetch/wayback.py | 6 +- backend/apps/agents/tools/ssrf_guard.py | 52 +++++++-- backend/apps/agents/tools/web.py | 7 +- backend/tests/test_html_to_text.py | 102 ++++++++++++++++++ backend/tests/test_wayback_snapshot.py | 69 ++++++++++++ backend/tests/test_web_fetch_reachability.py | 98 +++++++++++++++++ 8 files changed, 410 insertions(+), 24 deletions(-) create mode 100644 backend/apps/agents/tools/fetch/html_to_text.py create mode 100644 backend/tests/test_html_to_text.py create mode 100644 backend/tests/test_wayback_snapshot.py create mode 100644 backend/tests/test_web_fetch_reachability.py diff --git a/backend/apps/agents/tools/fetch/html_to_text.py b/backend/apps/agents/tools/fetch/html_to_text.py new file mode 100644 index 00000000..6e4b9b28 --- /dev/null +++ b/backend/apps/agents/tools/fetch/html_to_text.py @@ -0,0 +1,86 @@ +"""Turn raw HTML into the page's main content, dropping nav / ads / footers. + +Measured side by side on 15 cached real pages (same bytes into every variant), +scored on whether the article survived and whether the chrome did: + + variant content kept boilerplate dropped + regex tag-strip 92% 20% + favor_precision 92% 90% <- what we shipped + this ladder 92% 88%* + +The headline numbers barely move because the averages hide the failures, and +the failures are the whole point. `favor_precision=True` turns off +trafilatura's own rescue path, so on allrecipes it returned NOTHING and we fell +all the way back to the regex strip: 22,030 characters of nav soup where the +plain call gets 9,697 characters of recipe. On Spiegel it kept 192 characters +of a cookie notice against 1,177 of article. + +Precision is not simply worse, which is why this is a ladder and not a flipped +flag: on The Verge the plain call collapses to 441 characters while precision +finds 6,602. So we take the plain call, and only when it comes back thin do we +spend the second pass and keep whichever found more. trafilatura's own +`baseline` (which reads JSON-LD articleBody and
) is the floor under +that, and the regex strip is the floor under everything. + +(*the 2-point boilerplate drop is the metadata header: `sitename: Wikimedia +Foundation` counts as a nav string to the scorer while being exactly the +provenance a model should see.) + +`deduplicate` is deliberately never enabled: it is backed by a process-global +LRU, so in a long-lived server the second fetch of a page returns nothing. +""" + +from typing import Optional + +from typeguard import typechecked + +# Below this an extraction has clearly missed the article, so the next rung is worth its cost. +THIN_EXTRACT_CHARS = 1000 +# Below this we have nothing at all and take any text we can get. +MIN_EXTRACT_CHARS = 200 + + +@typechecked +def p_trafilatura_extract(raw_html: str, *, favor_precision: bool) -> str: + try: + import trafilatura # type: ignore + return trafilatura.extract( + raw_html, include_comments=False, include_tables=True, + output_format="markdown", with_metadata=True, + favor_precision=favor_precision, + ) or "" + except Exception: + return "" + + +@typechecked +def p_trafilatura_floor(raw_html: str, fn_name: str) -> str: + try: + import trafilatura # type: ignore + out = getattr(trafilatura, fn_name)(raw_html) + except Exception: + return "" + # `baseline` hands back (doc, text, length); `html2txt` hands back the text. + body: Optional[str] = out[1] if isinstance(out, tuple) else out + return body or "" + + +@typechecked +def html_to_text(raw_html: str) -> str: + """The page's main content as markdown, with a title/url/date header.""" + from backend.apps.agents.tools.search.search_ddg import strip_html + + best = p_trafilatura_extract(raw_html, favor_precision=False) + if len(best) < THIN_EXTRACT_CHARS: + alt = p_trafilatura_extract(raw_html, favor_precision=True) + if len(alt) > len(best): + best = alt + if len(best) < THIN_EXTRACT_CHARS: + alt = p_trafilatura_floor(raw_html, "baseline") + if len(alt) > len(best): + best = alt + if len(best) < MIN_EXTRACT_CHARS: + alt = p_trafilatura_floor(raw_html, "html2txt") + if len(alt) > len(best): + best = alt + return best or strip_html(raw_html) diff --git a/backend/apps/agents/tools/fetch/page_text.py b/backend/apps/agents/tools/fetch/page_text.py index 38e482c4..dbcd5a04 100644 --- a/backend/apps/agents/tools/fetch/page_text.py +++ b/backend/apps/agents/tools/fetch/page_text.py @@ -71,20 +71,6 @@ def extract_pdf_text(content: bytes) -> Optional[str]: return body -@typechecked -def html_to_text(raw_html: str) -> str: - """Main-content extraction, with a regex strip as the floor for login walls and JS-heavy pages.""" - from backend.apps.agents.tools.search.search_ddg import strip_html - try: - import trafilatura # type: ignore - extracted = trafilatura.extract( - raw_html, include_comments=False, include_tables=True, favor_precision=True, - ) - except Exception: - extracted = None - return extracted or strip_html(raw_html) - - @typechecked def body_to_text(content_type: str, content: bytes, raw_text: str) -> PageText: """Readable text plus what it came from; never raw binary.""" diff --git a/backend/apps/agents/tools/fetch/wayback.py b/backend/apps/agents/tools/fetch/wayback.py index 3dad62bd..a9668994 100644 --- a/backend/apps/agents/tools/fetch/wayback.py +++ b/backend/apps/agents/tools/fetch/wayback.py @@ -17,7 +17,7 @@ from urllib.parse import urlparse from typeguard import typechecked from backend.apps.agents.tools.browser_http import browser_request -from backend.apps.agents.tools.fetch.page_text import html_to_text +from backend.apps.agents.tools.fetch.html_to_text import html_to_text P_WAYBACK_LATEST = "https://web.archive.org/web/2/" P_ALLOWED_HOST = "web.archive.org" @@ -25,6 +25,8 @@ P_TIMEOUT = 10.0 # Below this the "snapshot" is a stub or an archived error page, not the article. P_MIN_SUBSTANCE_CHARS = 200 P_SNAPSHOT_RE = re.compile(r"/web/(\d{4})(\d{2})(\d{2})\d*/") +# What the archive shows when the crawler was bounced to a login page: it is a 200 with real words, so only the wording gives it away. +P_INTERSTITIAL_MARKER = "response at crawl time" @typechecked @@ -46,7 +48,7 @@ async def fetch_wayback(url: str) -> Optional[str]: if reply.status != 200: return None text = html_to_text(reply.text).strip() - if len(text) < P_MIN_SUBSTANCE_CHARS: + if len(text) < P_MIN_SUBSTANCE_CHARS or P_INTERSTITIAL_MARKER in text: return None taken = snapshot_date(reply.url) header = f"Archived copy of {url}" diff --git a/backend/apps/agents/tools/ssrf_guard.py b/backend/apps/agents/tools/ssrf_guard.py index 1a9621e1..d5272fce 100644 --- a/backend/apps/agents/tools/ssrf_guard.py +++ b/backend/apps/agents/tools/ssrf_guard.py @@ -27,6 +27,19 @@ class SSRFBlocked(Exception): """A fetch was refused because it targets a forbidden IP range.""" +class DomainUnreachable(SSRFBlocked): + """The host has no DNS records at all: dead domain, typo, or no network. + + A subclass so every existing `except SSRFBlocked` still fails closed, but + callers that care can tell "we refused this" apart from "this doesn't + exist", which are opposite messages to show a user and have opposite + fallbacks (nothing vs the archive).""" + + +# A page we will truncate to ~250KB anyway; without this a link to a disk image buffers the whole thing into RAM. +MAX_FETCH_BYTES = 10 * 1024 * 1024 + + P_BLOCKED_V4_NETS = [ ipaddress.ip_network("10.0.0.0/8"), ipaddress.ip_network("172.16.0.0/12"), @@ -52,7 +65,7 @@ async def p_resolve_host_async(host: str) -> list[str]: try: infos = await loop.getaddrinfo(host, None) except OSError as e: - raise SSRFBlocked(f"DNS resolution failed for {host}: {e}") from e + raise DomainUnreachable(f"{host} could not be resolved (dead domain, typo, or no network): {e}") from e return list({info[4][0] for info in infos}) @@ -101,13 +114,40 @@ async def assert_safe_url(url: str) -> str: resolved = await p_resolve_host_async(host) if not resolved: - raise SSRFBlocked(f"No DNS records for {host}.") + raise DomainUnreachable(f"No DNS records for {host}.") for ip in resolved: if p_is_forbidden_ip(ip): raise SSRFBlocked(f"Host {host} resolves to forbidden IP {ip}.") return url +async def p_read_capped(client: httpx.AsyncClient, request: httpx.Request, + max_bytes: int) -> httpx.Response: + """Send `request` and buffer at most `max_bytes` of the body. + + Returns a detached Response so callers keep the plain `.content` / `.text` + interface. Content-Encoding and Content-Length are dropped because + `aiter_bytes` already yields decoded bytes and the count may be short. + """ + streamed = await client.send(request, stream=True) + chunks: list[bytes] = [] + total = 0 + try: + async for chunk in streamed.aiter_bytes(): + chunks.append(chunk) + total += len(chunk) + if total >= max_bytes: + break + finally: + await streamed.aclose() + headers = httpx.Headers( + [(k, v) for k, v in streamed.headers.multi_items() + if k.lower() not in ("content-encoding", "content-length")] + ) + return httpx.Response(status_code=streamed.status_code, headers=headers, + content=b"".join(chunks)[:max_bytes], request=request) + + async def safe_fetch( url: str, *, @@ -117,6 +157,7 @@ async def safe_fetch( max_redirects: int = 5, json_body: dict | None = None, data: dict | None = None, + max_bytes: int = MAX_FETCH_BYTES, ) -> httpx.Response: """Fetch with per-redirect SSRF re-validation. @@ -126,15 +167,14 @@ async def safe_fetch( current_url = await assert_safe_url(url) async with httpx.AsyncClient(timeout=timeout, follow_redirects=False, headers=headers or {}) as client: for _ in range(max_redirects + 1): + req_kwargs: dict = {} if method.upper() == "POST": - req_kwargs = {} if json_body is not None: req_kwargs["json"] = json_body if data is not None: req_kwargs["data"] = data - resp = await client.post(current_url, **req_kwargs) - else: - resp = await client.get(current_url) + request = client.build_request(method.upper(), current_url, **req_kwargs) + resp = await p_read_capped(client, request, max_bytes) if not (300 <= resp.status_code < 400): return resp location = resp.headers.get("location") diff --git a/backend/apps/agents/tools/web.py b/backend/apps/agents/tools/web.py index 3d14e67d..b963d14b 100644 --- a/backend/apps/agents/tools/web.py +++ b/backend/apps/agents/tools/web.py @@ -14,9 +14,10 @@ from backend.apps.agents.tools.search.search_ddg import ( HTTP_TIMEOUT, USER_AGENT, ) -from backend.apps.agents.tools.fetch.page_text import PageText, body_to_text, html_to_text, looks_like_pdf +from backend.apps.agents.tools.fetch.html_to_text import html_to_text +from backend.apps.agents.tools.fetch.page_text import PageText, body_to_text, looks_like_pdf from backend.apps.agents.tools.search.search_ddg import search_ddg as run_ddg_search -from backend.apps.agents.tools.ssrf_guard import SSRFBlocked, safe_fetch +from backend.apps.agents.tools.ssrf_guard import DomainUnreachable, SSRFBlocked, safe_fetch P_MAX_OUTPUT_BYTES = 250 * 1024 # ~250 KB covers ~95% of articles/wikis/docs. @@ -180,6 +181,8 @@ class WebFetchTool(BaseTool): timeout=HTTP_TIMEOUT, ) resp.raise_for_status() + except DomainUnreachable as exc: + return PageText(text=f"Could not reach {url}: {exc}", kind="error") except SSRFBlocked as exc: return PageText(text=f"Refused to fetch {url}: {exc}", kind="error") except httpx.HTTPStatusError as exc: diff --git a/backend/tests/test_html_to_text.py b/backend/tests/test_html_to_text.py new file mode 100644 index 00000000..e2738032 --- /dev/null +++ b/backend/tests/test_html_to_text.py @@ -0,0 +1,102 @@ +"""The extraction ladder, pinned against the failures that motivated it. + +`favor_precision=True` disables trafilatura's own rescue, so on real pages +(allrecipes, Spiegel) it returned NOTHING and we shipped the regex strip's nav +soup instead of the article. Flipping the flag off is not the fix either: on +The Verge the plain call collapses to 441 chars where precision finds 6,602. +Hence a ladder that keeps whichever rung found the most text. +""" + +import pytest + +import backend.apps.agents.tools.fetch.html_to_text as HT +from backend.apps.agents.tools.fetch.html_to_text import ( + MIN_EXTRACT_CHARS, + THIN_EXTRACT_CHARS, + html_to_text, +) + + +def p_stub(monkeypatch, plain: str, precise: str, baseline: str = "", html2txt: str = ""): + def p_extract(raw_html, *, favor_precision): + return precise if favor_precision else plain + monkeypatch.setattr(HT, "p_trafilatura_extract", p_extract) + + def p_floor(raw_html, fn_name): + return baseline if fn_name == "baseline" else html2txt + monkeypatch.setattr(HT, "p_trafilatura_floor", p_floor) + + +def test_full_plain_extraction_never_pays_for_a_second_pass(monkeypatch): + calls = [] + + def p_extract(raw_html, *, favor_precision): + calls.append(favor_precision) + return "x" * (THIN_EXTRACT_CHARS + 10) + monkeypatch.setattr(HT, "p_trafilatura_extract", p_extract) + out = html_to_text("") + assert len(out) == THIN_EXTRACT_CHARS + 10 + assert calls == [False], "a healthy extraction must not trigger the precision pass" + + +def test_thin_plain_falls_to_precision(monkeypatch): + """The Verge case: default recall collapses, precision finds the article.""" + p_stub(monkeypatch, plain="short", precise="y" * 6000) + assert len(html_to_text("")) == 6000 + + +def test_precision_returning_nothing_falls_to_baseline(monkeypatch): + """The allrecipes case: precision extracted nothing at all.""" + p_stub(monkeypatch, plain="", precise="", baseline="b" * 5000) + assert len(html_to_text("")) == 5000 + + +def test_ladder_keeps_the_fullest_rung(monkeypatch): + p_stub(monkeypatch, plain="a" * 300, precise="b" * 200, baseline="c" * 100) + assert html_to_text("") == "a" * 300 + + +def test_everything_empty_falls_back_to_regex_strip(monkeypatch): + p_stub(monkeypatch, plain="", precise="", baseline="", html2txt="") + out = html_to_text("

hello & goodbye

") + assert "hello & goodbye" in out + + +def test_html2txt_only_rescues_a_truly_empty_read(monkeypatch): + p_stub(monkeypatch, plain="", precise="", baseline="", html2txt="z" * 400) + assert len(html_to_text("")) == 400 + p_stub(monkeypatch, plain="q" * (MIN_EXTRACT_CHARS + 1), precise="", baseline="", html2txt="z" * 400) + assert html_to_text("") == "q" * (MIN_EXTRACT_CHARS + 1) + + +def test_extractor_exception_degrades_instead_of_raising(monkeypatch): + def p_boom(raw_html, *, favor_precision): + raise ValueError("lxml exploded") + monkeypatch.setattr(HT, "p_trafilatura_extract", p_boom) + with pytest.raises(ValueError): + p_boom("", favor_precision=False) + # Through the real wrapper the same failure is swallowed and the floor answers. + monkeypatch.undo() + out = html_to_text("

" + "words " * 100 + "

") + assert "words" in out + + +@pytest.mark.parametrize("markup,needle", [ + ("

" + "Real body text. " * 60 + "

", "Real body text"), + ("

" + "Docs paragraph. " * 60 + "

", "Docs paragraph"), +]) +def test_real_trafilatura_extracts_article_bodies(markup, needle): + """No mocks: the shipped library on the shipped call must find an article body.""" + assert needle in html_to_text(markup) + + +def test_real_trafilatura_drops_nav_chrome(): + markup = ( + "" + "

" + "The measured content sentence. " * 40 + "

" + "
Copyright Terms of Use Cookie Settings
" + ) + out = html_to_text(markup) + assert "measured content sentence" in out + assert "Cookie Settings" not in out + assert "Careers" not in out diff --git a/backend/tests/test_wayback_snapshot.py b/backend/tests/test_wayback_snapshot.py new file mode 100644 index 00000000..a1fae957 --- /dev/null +++ b/backend/tests/test_wayback_snapshot.py @@ -0,0 +1,69 @@ +"""The archive's redirect interstitial is not a copy of the page. + +Measured on instagram.com/nasa: the Wayback tier answered 200 and we handed +the model 225 characters reading "Got an HTTP 302 response at crawl time / +Redirecting to .../accounts/login". It passed the substance floor because the +interstitial has real words in it, so only the wording gives it away. +""" + +import pytest + +import backend.apps.agents.tools.fetch.wayback as WB +from backend.apps.agents.tools.browser_http import HttpReply +from backend.apps.agents.tools.fetch.wayback import fetch_wayback, snapshot_date + +P_ARCHIVED_URL = "https://web.archive.org/web/20260711073650/https://example.com/story" + + +def p_patch(monkeypatch, status: int, text: str, url: str = P_ARCHIVED_URL): + async def p_req(u, **kw): + return HttpReply(status=status, text=text, content=text.encode(), + content_type="text/html", url=url) + monkeypatch.setattr(WB, "browser_request", p_req) + + +P_INTERSTITIAL = ( + "

Loading...

https://www.instagram.com/nasa/

" + "

07:36:50 July 11, 2026

Got an HTTP 302 response at crawl time

" + "

Redirecting to...

https://www.instagram.com/accounts/login/?next=%2Fnasa%2F

" + "

Wayback Machine has not archived that URL beyond the redirect target given here.

" + "" +) + +P_REAL_ARTICLE = ( + "

" + "The archived article body says something real. " * 20 + + "

" +) + + +@pytest.mark.asyncio +async def test_redirect_interstitial_is_not_content(monkeypatch): + p_patch(monkeypatch, 200, P_INTERSTITIAL) + assert await fetch_wayback("https://www.instagram.com/nasa/") is None + + +@pytest.mark.asyncio +async def test_real_snapshot_still_returns_with_its_date(monkeypatch): + p_patch(monkeypatch, 200, P_REAL_ARTICLE) + out = await fetch_wayback("https://example.com/story") + assert out is not None + assert "archived article body" in out + assert "2026-07-11" in out + + +@pytest.mark.asyncio +async def test_stub_snapshot_below_the_floor_is_rejected(monkeypatch): + p_patch(monkeypatch, 200, "

Loading...

") + assert await fetch_wayback("https://example.com/story") is None + + +@pytest.mark.asyncio +async def test_offsite_redirect_is_refused(monkeypatch): + """We hand the archive a caller-supplied URL, so landing anywhere else means no answer.""" + p_patch(monkeypatch, 200, P_REAL_ARTICLE, url="https://evil.example/whatever") + assert await fetch_wayback("https://example.com/story") is None + + +def test_snapshot_date_parsing(): + assert snapshot_date(P_ARCHIVED_URL) == "2026-07-11" + assert snapshot_date("https://web.archive.org/nope") is None diff --git a/backend/tests/test_web_fetch_reachability.py b/backend/tests/test_web_fetch_reachability.py new file mode 100644 index 00000000..4db293f4 --- /dev/null +++ b/backend/tests/test_web_fetch_reachability.py @@ -0,0 +1,98 @@ +"""A dead domain is an archive lookup, not a security refusal. + +Measured: fetching a domain with no DNS records returned HTTP 400 "Refused: +DNS resolution failed", which reads like we blocked it AND short-circuited the +cascade before the Wayback tier, which exists for exactly that case. These pin +the split: unresolvable falls through, forbidden ranges still 400. +""" + +import httpx +import pytest + +import backend.apps.agents.tools.fetch.wayback as WB +import backend.apps.agents.tools.ssrf_guard as SG +from backend.apps.agents.tools.ssrf_guard import DomainUnreachable, SSRFBlocked, safe_fetch +from backend.apps.agents.tools.web import WebFetchTool +from backend.apps.web.web import FetchBody, fetch +from backend.tests.web_cascade_fixtures import * # noqa: F401,F403 + + +def p_unresolvable(monkeypatch): + async def p_dns_dead(url): + raise DomainUnreachable("nowhere.invalid could not be resolved (dead domain, typo, or no network)") + monkeypatch.setattr(SG, "assert_safe_url", p_dns_dead) + + +def test_domain_unreachable_is_an_ssrf_blocked_subclass(): + """Every existing `except SSRFBlocked` must keep failing closed on it.""" + assert issubclass(DomainUnreachable, SSRFBlocked) + + +@pytest.mark.asyncio +async def test_dead_domain_reaches_the_archive(monkeypatch): + p_unresolvable(monkeypatch) + + async def p_snapshot(url): + return "Archived copy of the dead site\n\n" + "real archived text. " * 40 + monkeypatch.setattr(WB, "fetch_wayback", p_snapshot) + + out = await fetch(FetchBody(url="https://nowhere.invalid/page")) + assert out["backend"] == "wayback" + assert "real archived text" in out["content"] + + +@pytest.mark.asyncio +async def test_dead_domain_without_a_snapshot_says_unreachable_not_refused(monkeypatch): + p_unresolvable(monkeypatch) + out = await fetch(FetchBody(url="https://nowhere.invalid/page")) + assert "Could not reach" in out["content"] + assert "Refused" not in out["content"] + + +@pytest.mark.asyncio +async def test_forbidden_range_still_gets_a_hard_refusal(monkeypatch): + from fastapi import HTTPException + + async def p_blocked(url): + raise SSRFBlocked("URL host 169.254.169.254 is in a blocked range.") + monkeypatch.setattr(SG, "assert_safe_url", p_blocked) + + with pytest.raises(HTTPException) as exc: + await fetch(FetchBody(url="http://169.254.169.254/latest/meta-data/")) + assert exc.value.status_code == 400 + assert "Refused" in str(exc.value.detail) + + +@pytest.mark.asyncio +async def test_fetch_page_reports_unreachable_as_error_kind(monkeypatch): + p_unresolvable(monkeypatch) + page = await WebFetchTool.fetch_page("https://nowhere.invalid/page") + assert page.kind == "error" + assert "Could not reach" in page.text + + +@pytest.mark.asyncio +async def test_oversized_body_is_capped_not_buffered(monkeypatch): + """A link to a disk image must not pull gigabytes into RAM before we truncate to 250KB.""" + served = {"bytes": 0} + + class p_HugeStream(httpx.AsyncByteStream): + async def __aiter__(self): + for _ in range(500): + served["bytes"] += 100_000 + yield b"x" * 100_000 + + async def aclose(self) -> None: + return None + + async def p_send(self, request, **kw): + return httpx.Response(200, headers={"content-type": "text/plain"}, stream=p_HugeStream()) + monkeypatch.setattr(httpx.AsyncClient, "send", p_send) + + async def p_ok(url): + return url + monkeypatch.setattr(SG, "assert_safe_url", p_ok) + + resp = await safe_fetch("https://example.com/huge.bin", max_bytes=250_000) + assert len(resp.content) == 250_000 + assert served["bytes"] < 50_000_000, "the stream must stop early, not download the whole file" From 37100e9b5c4c14a526aed08fde9576fad0116a17 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 16:29:24 -0700 Subject: [PATCH 016/117] [eric] web: ask the archive for the raw snapshot so we stop returning its calendar toolbar as the article --- backend/apps/agents/tools/fetch/wayback.py | 13 ++++++++++--- backend/tests/test_wayback_snapshot.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/backend/apps/agents/tools/fetch/wayback.py b/backend/apps/agents/tools/fetch/wayback.py index a9668994..4f843d4d 100644 --- a/backend/apps/agents/tools/fetch/wayback.py +++ b/backend/apps/agents/tools/fetch/wayback.py @@ -4,7 +4,14 @@ When a page is gone (404, domain dead, article pulled) or sits behind a wall that no client-side trick beats, the archive usually still has the REAL text, which beats a grounded model's summary of a page it also couldn't read. -Uses `web.archive.org/web/2/`, which redirects to the closest snapshot. +Uses `web.archive.org/web/2id_/`, which redirects to the closest snapshot +and serves the ORIGINAL bytes. Without the `id_` the archive injects its own +calendar toolbar into the page, and on a Reddit snapshot that toolbar was all +the text there was: we returned "Jun JUL Aug 30 2025 2026 2027 success fail +About this capture" as if it were the article, and it cleared the substance +floor because it is made of real words. `id_` also keeps the page's own URL in +the extracted metadata instead of stamping it `hostname: archive.org`. + The documented `archive.org/wayback/available` JSON API is NOT used: it is aggressively throttled and answered 429 on every probe from this machine, while the redirect path answered in 0.5-3s. @@ -19,12 +26,12 @@ from typeguard import typechecked from backend.apps.agents.tools.browser_http import browser_request from backend.apps.agents.tools.fetch.html_to_text import html_to_text -P_WAYBACK_LATEST = "https://web.archive.org/web/2/" +P_WAYBACK_LATEST = "https://web.archive.org/web/2id_/" P_ALLOWED_HOST = "web.archive.org" P_TIMEOUT = 10.0 # Below this the "snapshot" is a stub or an archived error page, not the article. P_MIN_SUBSTANCE_CHARS = 200 -P_SNAPSHOT_RE = re.compile(r"/web/(\d{4})(\d{2})(\d{2})\d*/") +P_SNAPSHOT_RE = re.compile(r"/web/(\d{4})(\d{2})(\d{2})\d*(?:id_)?/") # What the archive shows when the crawler was bounced to a login page: it is a 200 with real words, so only the wording gives it away. P_INTERSTITIAL_MARKER = "response at crawl time" diff --git a/backend/tests/test_wayback_snapshot.py b/backend/tests/test_wayback_snapshot.py index a1fae957..b7225a6c 100644 --- a/backend/tests/test_wayback_snapshot.py +++ b/backend/tests/test_wayback_snapshot.py @@ -67,3 +67,22 @@ async def test_offsite_redirect_is_refused(monkeypatch): def test_snapshot_date_parsing(): assert snapshot_date(P_ARCHIVED_URL) == "2026-07-11" assert snapshot_date("https://web.archive.org/nope") is None + + +def test_raw_snapshot_form_is_requested(monkeypatch): + """Without `id_` the archive injects its calendar toolbar, and on a Reddit snapshot + that toolbar WAS the whole extracted text.""" + from backend.apps.agents.tools.fetch.wayback import P_WAYBACK_LATEST + assert P_WAYBACK_LATEST.endswith("id_/") + + +def test_snapshot_date_parsing_survives_the_raw_form(): + assert snapshot_date("https://web.archive.org/web/20260727222838id_/https://x.example/") == "2026-07-27" + + +@pytest.mark.asyncio +async def test_archive_toolbar_text_alone_is_below_the_floor(monkeypatch): + toolbar = ("Jun JUL Aug 30 2025 2026 2027 success fail About this capture " + "COLLECTED BY Collection: Save Page Now TIMESTAMPS") + p_patch(monkeypatch, 200, toolbar) + assert await fetch_wayback("https://www.reddit.com/r/programming/") is None From 108ef03451b014ebc95158b16d8bbca193bc235e Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 16:35:51 -0700 Subject: [PATCH 017/117] [eric] web: let Startpage say 'nothing matched' and 'I refuse' differently so a closed engine stops costing its budget --- .../agents/tools/search/search_startpage.py | 34 ++++++++++++++----- backend/apps/web/web.py | 9 +++-- backend/tests/test_wayback_snapshot.py | 14 ++++++-- backend/tests/test_web_search_cascade.py | 27 +++++++++++++++ backend/tests/test_web_search_startpage.py | 32 ++++++++++++++--- backend/tests/web_cascade_fixtures.py | 11 ++++-- 6 files changed, 107 insertions(+), 20 deletions(-) diff --git a/backend/apps/agents/tools/search/search_startpage.py b/backend/apps/agents/tools/search/search_startpage.py index 692cb578..30dd0465 100644 --- a/backend/apps/agents/tools/search/search_startpage.py +++ b/backend/apps/agents/tools/search/search_startpage.py @@ -11,13 +11,18 @@ returns the real result page. Parsing is anchored on `result-link` / `gl-title-link` and the `description` paragraph, never on the emotion CSS hashes in the same class attributes, which change build to build. -Returns None when Startpage served a challenge instead of results, so the -caller can tell "closed" apart from "genuinely no hits".""" +Answers a `StartpageAnswer` rather than a string because "closed" and +"genuinely no hits" look identical on the wire (both are a 200 with no result +anchors) and the caller has to act on them differently: a refusal is a failed +tier that should count against the engine, an empty result set is the honest +answer to a nonsense query. Startpage names the second case itself, in an +"Uh-oh, there are no results for this search" page.""" import html import re -from typing import List, Optional +from typing import List +from pydantic import BaseModel, ConfigDict from typeguard import typechecked from backend.apps.agents.tools.browser_http import browser_request @@ -36,6 +41,15 @@ P_DESC_RE = re.compile( # Startpage inlines a