From 34a8b040c85bab81f289c777a988d582e5c441fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 18:20:58 +0000 Subject: [PATCH 01/10] [P1] structured error envelopes + request IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every error response now uses one canonical JSON shape from the new services/errors.py: {"error": {"code", "message", "hint", "request_id"}} - services/errors.py: error_envelope() + code_for_status() (stable machine-readable slugs like "not_found" / "validation_error" / "internal_error"). - app.py: exception handlers for HTTPException, RequestValidationError, and a catch-all Exception handler (500s no longer surface as bare tracebacks). The legacy top-level "detail" field is preserved so the frontend and existing callers keep working. - Request-ID middleware: honors a client-supplied X-Request-ID or mints a uuid, echoes it in the X-Request-ID response header on every response, embeds it in error envelopes, and stamps it onto all log records via a contextvar-backed logging filter ([rid=...] in the log format). - Converted the silent broad excepts on user-facing chat/request paths to logged warnings (MCP OAuth token substitution, shell plot auto-capture, write_file checkpoint, verification fallback + audit-log append, verification screenshot provider). Tool results keep their existing {"error": ...} contract with the model — envelopes apply to HTTP responses and (next commit) SSE error events. - tests/test_api.py: TestErrorEnvelopes covering 404 / unknown-route 404 / 422 / 400 envelope shape, legacy detail preservation, request-id header echo on success and error, and a forced internal error. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012jUkqbwroQmYRAdFDsp5r9 --- app.py | 133 +++++++++++++++++++++++++++++++++++++++++---- services/errors.py | 59 ++++++++++++++++++++ tests/test_api.py | 78 ++++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 11 deletions(-) create mode 100644 services/errors.py diff --git a/app.py b/app.py index 0fb12c9..3dc277a 100644 --- a/app.py +++ b/app.py @@ -8,6 +8,7 @@ import asyncio import base64 +import contextvars import hashlib import io import ipaddress @@ -29,11 +30,15 @@ import httpx import yaml from fastapi import FastAPI, HTTPException, UploadFile, File, Request -from fastapi.responses import FileResponse, StreamingResponse, Response +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel +from starlette.exceptions import HTTPException as StarletteHTTPException import verification as _verification +from services.errors import code_for_status, error_envelope ROOT = Path(__file__).parent.resolve() DATA_DIR = ROOT / "data" @@ -69,11 +74,26 @@ for d in (DATA_DIR, SKILLS_DIR, UPLOADS_DIR, CHECKPOINTS_DIR, TASKS_DIR, WORKSPACE_DIR): d.mkdir(parents=True, exist_ok=True) +# Per-request correlation id. Set by the request-id middleware for the +# duration of each HTTP request (tasks spawned inside a request inherit it), +# echoed back in the X-Request-ID response header, embedded in error +# envelopes, and stamped onto every log record via _RequestIdFilter. +_request_id_ctx: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-") + + +class _RequestIdFilter(logging.Filter): + """Stamp the current request id onto every log record.""" + + def filter(self, record: logging.LogRecord) -> bool: + record.request_id = _request_id_ctx.get() + return True + + _LOG_DIR = ROOT / "logs" _LOG_DIR.mkdir(parents=True, exist_ok=True) logging.basicConfig( level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + format="%(asctime)s [%(levelname)s] %(name)s [rid=%(request_id)s]: %(message)s", handlers=[ logging.handlers.RotatingFileHandler( _LOG_DIR / "betterwebui.log", @@ -84,6 +104,8 @@ logging.StreamHandler(), ], ) +for _handler in logging.getLogger().handlers: + _handler.addFilter(_RequestIdFilter()) logger = logging.getLogger("betterwebui") @@ -795,8 +817,13 @@ async def reconcile(self) -> None: tok = _get_oauth_tok(provider, DATA_DIR) if tok and tok.get("access_token"): env[k] = str(v).replace(placeholder, tok["access_token"]) - except Exception: - pass + except Exception as exc: + # Non-fatal: the server starts without the substituted token, + # but the user should be able to see why their OAuth-backed + # MCP server is misbehaving. + logging.getLogger("betterwebui.mcp").warning( + "OAuth token substitution failed for MCP server '%s': %s", name, exc, + ) client = MCPStdioClient( name=name, command=s.get("command", ""), @@ -2001,8 +2028,10 @@ async def execute_tool(call: dict, config: dict, send_event, mode: str = "approv result["mime"] = "image/png" result["data_b64"] = base64.b64encode(_img_path.read_bytes()).decode("ascii") _img_path.unlink() - except Exception: - pass + except Exception as exc: + # Non-fatal: the shell result still goes back to the chat, + # just without the inline plot attachment. + logger.warning("Auto-capture of plot %s failed: %s", _img_path, exc) break return result @@ -2085,7 +2114,11 @@ async def execute_tool(call: dict, config: dict, send_event, mode: str = "approv checkpoint_id = _checkpoint_file( wid, filename, dest.read_bytes() ) - except Exception: + except Exception as exc: + # Non-fatal: the write proceeds, it just won't have an Undo + # checkpoint. Log so a user asking "where did Undo go?" has + # a trail. + logger.warning("Checkpoint of %s failed before write: %s", filename, exc) checkpoint_id = None # Write to disk @@ -2527,6 +2560,75 @@ async def lifespan(app: FastAPI): app = FastAPI(title="BetterWebUI", lifespan=lifespan) +# --------------------------------------------------------------------------- +# Request IDs + structured error envelopes +# +# Every request gets a correlation id (client-supplied X-Request-ID is +# honored, otherwise a fresh uuid). It is echoed in the X-Request-ID response +# header, tagged onto log records via _request_id_ctx, and embedded in every +# error envelope so a user-visible failure can be matched to server logs. +# +# Error responses use the canonical envelope from services/errors.py: +# {"error": {"code", "message", "hint", "request_id"}} +# The legacy top-level "detail" field is preserved for backward compatibility +# (the frontend and older callers read it). +# --------------------------------------------------------------------------- + +@app.middleware("http") +async def _request_id_middleware(request: Request, call_next): + rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex + request.state.request_id = rid + token = _request_id_ctx.set(rid) + try: + response = await call_next(request) + finally: + _request_id_ctx.reset(token) + response.headers["X-Request-ID"] = rid + return response + + +def _request_id_of(request: Request) -> str: + rid = getattr(request.state, "request_id", None) + return rid or _request_id_ctx.get() + + +@app.exception_handler(StarletteHTTPException) +async def _http_exception_handler(request: Request, exc: StarletteHTTPException): + rid = _request_id_of(request) + detail = exc.detail + message = detail if isinstance(detail, str) else json.dumps(jsonable_encoder(detail)) + body = error_envelope(code_for_status(exc.status_code), message, request_id=rid) + body["detail"] = jsonable_encoder(detail) + headers = {**(exc.headers or {}), "X-Request-ID": rid} + return JSONResponse(body, status_code=exc.status_code, headers=headers) + + +@app.exception_handler(RequestValidationError) +async def _validation_exception_handler(request: Request, exc: RequestValidationError): + rid = _request_id_of(request) + errors = jsonable_encoder(exc.errors()) + first = errors[0] if errors else {} + loc = ".".join(str(p) for p in first.get("loc", [])) + hint = f"Check '{loc}': {first.get('msg', '')}" if loc else None + body = error_envelope("validation_error", "Request validation failed.", hint=hint, request_id=rid) + body["detail"] = errors + return JSONResponse(body, status_code=422, headers={"X-Request-ID": rid}) + + +@app.exception_handler(Exception) +async def _unhandled_exception_handler(request: Request, exc: Exception): + rid = _request_id_of(request) + logger.exception("Unhandled error on %s %s", request.method, request.url.path) + body = error_envelope( + "internal_error", + f"{type(exc).__name__}: {exc}", + hint="Try again; if this keeps happening check the server logs.", + request_id=rid, + ) + body["detail"] = body["error"]["message"] + return JSONResponse(body, status_code=500, headers={"X-Request-ID": rid}) + + @app.get("/") async def index(): return FileResponse(ROOT / "static" / "index.html") @@ -4334,7 +4436,10 @@ async def _screenshot_provider(): return shot["image_b64"] if isinstance(shot, dict) and shot.get("data_b64"): return shot["data_b64"] - except Exception: + except Exception as exc: + # Optional capability — verification proceeds without + # a screenshot, but leave a trace for debugging. + logger.debug("Verification screenshot unavailable: %s", exc) return None return None @@ -4351,7 +4456,13 @@ async def _screenshot_provider(): chat_complete=chat_complete, screenshot_provider=_screenshot_provider, ) - except Exception: + except Exception as exc: + # Verification is best-effort: fall back to the unverified + # result rather than failing the whole turn, but say so. + logger.warning( + "Verification of tool '%s' crashed (%s: %s); using unverified result.", + call["tool"], type(exc).__name__, exc, + ) result, vtrace = first_result, None # Emit tool_result first so the UI's checkpoint cache is @@ -4373,8 +4484,8 @@ async def _screenshot_provider(): "tool": call["tool"], "trace": vtrace.to_dict(), }) + "\n") - except Exception: - pass + except Exception as exc: + logger.warning("Could not append verification audit log for %s: %s", cid, exc) # Auto-engage consensus when the judge fails repeatedly on # the same turn — surfaced via a notice, then we recompute. diff --git a/services/errors.py b/services/errors.py new file mode 100644 index 0000000..322d08a --- /dev/null +++ b/services/errors.py @@ -0,0 +1,59 @@ +""" +services/errors.py — structured error envelopes. + +Every error the server hands to a client — HTTP error responses and SSE +`error` events alike — uses one consistent JSON shape: + + {"error": {"code": ..., "message": ..., "hint": ..., "request_id": ...}} + +`code` is a stable machine-readable slug (e.g. "not_found", "internal_error") +so the frontend can branch on it without regex-matching prose; `message` is +human-readable; `hint` is an optional actionable suggestion; `request_id` +correlates the response with server log lines. +""" +from __future__ import annotations + +from typing import Optional + +# Stable slugs for the HTTP status codes this app actually raises. +_STATUS_CODES: dict[int, str] = { + 400: "bad_request", + 401: "unauthorized", + 403: "forbidden", + 404: "not_found", + 405: "method_not_allowed", + 409: "conflict", + 413: "payload_too_large", + 422: "validation_error", + 429: "rate_limited", + 500: "internal_error", + 502: "upstream_error", + 503: "service_unavailable", + 504: "upstream_timeout", +} + + +def code_for_status(status_code: int) -> str: + """Map an HTTP status code to a stable machine-readable error code.""" + slug = _STATUS_CODES.get(status_code) + if slug: + return slug + return "internal_error" if status_code >= 500 else "http_error" + + +def error_envelope( + code: str, + message: str, + hint: Optional[str] = None, + request_id: Optional[str] = None, +) -> dict: + """Return the canonical error body. All keys are always present so + clients never need existence checks — absent values are null.""" + return { + "error": { + "code": code, + "message": message, + "hint": hint, + "request_id": request_id, + } + } diff --git a/tests/test_api.py b/tests/test_api.py index bb52cfc..a003387 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -916,3 +916,81 @@ def test_delete_path_in_result_is_relative(self, isolated_dirs): assert result.get("path") == "target.txt" import os assert not os.path.isabs(result.get("path", "")) + + +# =========================================================================== +# Structured error envelopes + request IDs +# =========================================================================== + +class TestErrorEnvelopes: + """Every error response carries {"error": {code, message, hint, + request_id}} plus the legacy top-level "detail", and every response — + success or failure — echoes an X-Request-ID header.""" + + ENVELOPE_KEYS = {"code", "message", "hint", "request_id"} + + def test_404_returns_envelope(self, client): + r = client.get("/api/skills/does-not-exist") + assert r.status_code == 404 + body = r.json() + assert set(body["error"].keys()) == self.ENVELOPE_KEYS + assert body["error"]["code"] == "not_found" + assert body["error"]["message"] + # Legacy shape is preserved for older callers. + assert body["detail"] == body["error"]["message"] + assert r.headers["X-Request-ID"] == body["error"]["request_id"] + + def test_unknown_route_404_returns_envelope(self, client): + r = client.get("/api/definitely/not/a/route") + assert r.status_code == 404 + body = r.json() + assert body["error"]["code"] == "not_found" + assert "X-Request-ID" in r.headers + + def test_validation_error_envelope(self, client): + # PromptIn requires "name" and "content". + r = client.post("/api/system-prompts", json={"bogus": True}) + assert r.status_code == 422 + body = r.json() + assert set(body["error"].keys()) == self.ENVELOPE_KEYS + assert body["error"]["code"] == "validation_error" + assert body["error"]["hint"] # points at the first offending field + # FastAPI's standard list-of-errors detail is preserved. + assert isinstance(body["detail"], list) and body["detail"] + assert r.headers["X-Request-ID"] == body["error"]["request_id"] + + def test_400_returns_envelope(self, client): + # /api/chat without configured base_url/api_key raises HTTPException(400). + r = client.post("/api/chat", json={"messages": [{"role": "user", "content": "hi"}]}) + assert r.status_code == 400 + body = r.json() + assert body["error"]["code"] == "bad_request" + assert body["error"]["message"] == body["detail"] + + def test_success_response_carries_request_id_header(self, client): + r = client.get("/api/health") + assert r.status_code == 200 + assert r.headers.get("X-Request-ID") + + def test_client_supplied_request_id_is_echoed(self, client): + r = client.get("/api/health", headers={"X-Request-ID": "rid-from-client-123"}) + assert r.headers["X-Request-ID"] == "rid-from-client-123" + + def test_forced_internal_error_returns_envelope(self, isolated_dirs): + """A crash inside a route handler produces a 500 envelope (catch-all + Exception handler), not a bare traceback response.""" + from unittest.mock import AsyncMock, patch + from fastapi.testclient import TestClient + import app as app_module + + with patch.object(app_module.mcp_manager, "status", side_effect=RuntimeError("boom")): + with patch("app.mcp_manager.reconcile", new_callable=AsyncMock): + with TestClient(app_module.app, raise_server_exceptions=False) as c: + r = c.get("/api/health") + assert r.status_code == 500 + body = r.json() + assert set(body["error"].keys()) == self.ENVELOPE_KEYS + assert body["error"]["code"] == "internal_error" + assert "boom" in body["error"]["message"] + assert body["error"]["hint"] + assert r.headers["X-Request-ID"] == body["error"]["request_id"] From 834e7d77347be41d4c47997d33fe5da5ccf26707 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 18:25:45 +0000 Subject: [PATCH 02/10] [P1] SSE error events + keepalive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streaming endpoints no longer end silently when the upstream dies, and idle streams stay alive through proxies. - services/sse_proxy.py: proxy_sse() now emits an `event: error` SSE message carrying the canonical envelope when the upstream generator raises mid-stream (the `_done` sentinel is reserved for clean completion), and yields `: keepalive` comment lines every ~15s while the upstream is idle. Non-dict JSON payloads are wrapped as {"raw": ...} instead of crashing the stream. - services/routes.py: the CLK/AutoGUI stream endpoints pass the request id through so mid-stream error envelopes are correlatable. - app.py /api/chat: the run_loop error events now carry the envelope ({"message": ..., "error": {code, message, hint, request_id}} — the legacy "message" field is kept for older clients and the e2e SSE helpers), failures are logged server-side, and the event stream emits `: keepalive` comments whenever the queue is idle for 15s (model thinking, long tool runs, approval dialogs). - static/app.js: the chat `error` SSE handler reads the structured envelope (message + hint + request-id ref) for the transcript entry and additionally surfaces the failure via the existing flash() toast. Keepalive comment lines are already ignored by consumeSSE (no data:). - tests/test_services.py: TestProxySSE (clean stream, mid-stream and immediate upstream failure envelopes, keepalive comments) and TestChatSSEErrors (internal error envelope, HTTPException status→code mapping, chat keepalive comments). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012jUkqbwroQmYRAdFDsp5r9 --- app.py | 32 ++++++++- services/routes.py | 10 +-- services/sse_proxy.py | 72 +++++++++++++++---- static/app.js | 12 +++- tests/test_services.py | 159 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 262 insertions(+), 23 deletions(-) diff --git a/app.py b/app.py index 3dc277a..c734061 100644 --- a/app.py +++ b/app.py @@ -39,6 +39,7 @@ import verification as _verification from services.errors import code_for_status, error_envelope +from services.sse_proxy import KEEPALIVE_INTERVAL as _sse_keepalive_interval ROOT = Path(__file__).parent.resolve() DATA_DIR = ROOT / "data" @@ -4323,6 +4324,7 @@ async def chat(req: ChatRequest, request: Request): effective_mode = req.mode or (workspace or {}).get("mode") or cfg.get("chat_mode", "approve-each") queue: asyncio.Queue = asyncio.Queue() + request_id = _request_id_of(request) # Preserve the existing plan when resuming a conversation _existing_conv = load_conversations().get("conversations", {}).get(cid, {}) current_task_plan: list = _existing_conv.get("task_plan", []) @@ -4534,9 +4536,26 @@ async def _screenshot_provider(): "task_plan": current_task_plan, }) except HTTPException as exc: - await send_event("error", {"message": str(exc.detail)}) + detail = exc.detail if isinstance(exc.detail, str) else json.dumps(jsonable_encoder(exc.detail)) + logger.warning("Chat turn failed (%s): %s", exc.status_code, detail) + # "message" is the legacy field the frontend/e2e helpers read; + # "error" carries the canonical envelope (see services/errors.py). + await send_event("error", { + "message": detail, + **error_envelope(code_for_status(exc.status_code), detail, request_id=request_id), + }) except Exception as exc: - await send_event("error", {"message": f"{type(exc).__name__}: {exc}"}) + logger.exception("Chat turn crashed") + message = f"{type(exc).__name__}: {exc}" + await send_event("error", { + "message": message, + **error_envelope( + "internal_error", + message, + hint="Try again; if this keeps happening check the server logs.", + request_id=request_id, + ), + }) finally: await queue.put(None) @@ -4545,7 +4564,14 @@ async def _screenshot_provider(): async def event_stream() -> AsyncGenerator[bytes, None]: try: while True: - item = await queue.get() + try: + item = await asyncio.wait_for(queue.get(), timeout=_sse_keepalive_interval) + except asyncio.TimeoutError: + # Nothing to say yet (model thinking, tool running, or an + # approval dialog waiting on the user) — emit an SSE + # comment so proxies/browsers don't drop the connection. + yield b": keepalive\n\n" + continue if item is None: break yield f"event: {item['event']}\ndata: {json.dumps(item['data'])}\n\n".encode("utf-8") diff --git a/services/routes.py b/services/routes.py index 3530649..4869f13 100644 --- a/services/routes.py +++ b/services/routes.py @@ -7,7 +7,7 @@ from __future__ import annotations import httpx -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request from fastapi.responses import StreamingResponse from .clients import get_clk_client, get_autogui_client, get_osso_client @@ -102,11 +102,11 @@ async def clk_get_task(task_id: str): raise _unreachable("clk", e) from e @app.get("/api/services/clk/research/{task_id}/stream") - async def clk_stream_task(task_id: str): + async def clk_stream_task(task_id: str, request: Request): _require_enabled("clk") client = get_clk_client() return StreamingResponse( - proxy_sse(client.stream_task(task_id)), + proxy_sse(client.stream_task(task_id), request_id=getattr(request.state, "request_id", None)), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @@ -160,11 +160,11 @@ async def autogui_get_task(task_id: str): raise _unreachable("autogui", e) from e @app.get("/api/services/autogui/task/{task_id}/stream") - async def autogui_stream_task(task_id: str): + async def autogui_stream_task(task_id: str, request: Request): _require_enabled("autogui") client = get_autogui_client() return StreamingResponse( - proxy_sse(client.stream_task(task_id)), + proxy_sse(client.stream_task(task_id), request_id=getattr(request.state, "request_id", None)), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) diff --git a/services/sse_proxy.py b/services/sse_proxy.py index a69ec5a..c3ccb57 100644 --- a/services/sse_proxy.py +++ b/services/sse_proxy.py @@ -1,19 +1,65 @@ +import asyncio import json -from typing import AsyncGenerator, AsyncIterator +from typing import AsyncGenerator, AsyncIterator, Optional +from .errors import error_envelope -async def proxy_sse(upstream_gen: AsyncIterator[str]) -> AsyncGenerator[str, None]: +# How long the upstream may stay silent before we emit an SSE comment line so +# intermediaries (reverse proxies, browsers) don't drop the idle connection. +KEEPALIVE_INTERVAL = 15.0 + + +async def proxy_sse( + upstream_gen: AsyncIterator[str], + keepalive_interval: float = KEEPALIVE_INTERVAL, + request_id: Optional[str] = None, +) -> AsyncGenerator[str, None]: """ - Takes an async generator of raw JSON strings (already stripped of 'data: ' prefix) - and yields properly formatted SSE chunks. + Takes an async generator of raw JSON strings (already stripped of 'data: ' + prefix) and yields properly formatted SSE chunks. + + - While the upstream is idle for longer than `keepalive_interval` seconds, + a `: keepalive` comment line is emitted to hold the connection open. + - If the upstream fails mid-stream, an `event: error` message carrying the + canonical error envelope is emitted instead of silently ending the + stream (the `_done` sentinel is only sent on clean completion). """ seq = 0 - async for raw in upstream_gen: - try: - data = json.loads(raw) - except Exception: - data = {"raw": raw} - data["_seq"] = seq - seq += 1 - yield f"data: {json.dumps(data)}\n\n" - yield 'data: {"_done": true}\n\n' + iterator = upstream_gen.__aiter__() + pending: Optional[asyncio.Task] = None + try: + while True: + if pending is None: + pending = asyncio.ensure_future(iterator.__anext__()) + done, _ = await asyncio.wait({pending}, timeout=keepalive_interval) + if not done: + # Upstream still working — don't cancel it, just reassure the + # client. Comment lines are ignored by SSE parsers. + yield ": keepalive\n\n" + continue + task, pending = pending, None + try: + raw = task.result() + except StopAsyncIteration: + break + except Exception as exc: + yield "event: error\ndata: " + json.dumps(error_envelope( + "upstream_error", + f"Upstream stream failed: {type(exc).__name__}: {exc}", + hint="The backing service may have stopped mid-task. Check that it is running, then retry.", + request_id=request_id, + )) + "\n\n" + return + try: + data = json.loads(raw) + except Exception: + data = {"raw": raw} + if not isinstance(data, dict): + data = {"raw": data} + data["_seq"] = seq + seq += 1 + yield f"data: {json.dumps(data)}\n\n" + yield 'data: {"_done": true}\n\n' + finally: + if pending is not None: + pending.cancel() diff --git a/static/app.js b/static/app.js index b81a51a..e90fbaf 100644 --- a/static/app.js +++ b/static/app.js @@ -2767,10 +2767,18 @@ async function send() { return; } if (event === "error") { - const human = friendlyError({ message: data.message || "" }, "running that step"); - const sysMsg = { role: "system-event", content: human }; + // The server sends a structured envelope in data.error + // ({code, message, hint, request_id}); data.message is the legacy + // plain-text field kept for older servers. + const env = data.error || {}; + const human = friendlyError({ message: env.message || data.message || "" }, "running that step"); + const parts = [human]; + if (env.hint) parts.push(env.hint); + if (env.request_id) parts.push(`(ref: ${env.request_id})`); + const sysMsg = { role: "system-event", content: parts.join(" ") }; state.messages.push(sysMsg); appendMessage(sysMsg); + flash(human, "warn"); return; } }); diff --git a/tests/test_services.py b/tests/test_services.py index 289d981..5a0037b 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -926,3 +926,162 @@ def test_timeout_also_returns_503(self, app_client): r = app_client.get("/api/services/clk/research/task-123") assert r.status_code == 503 assert "could not be reached" in r.json()["detail"] + + +# =========================================================================== +# SSE proxy — error events + keepalive +# =========================================================================== + +import json + + +class TestProxySSE: + def _collect(self, gen): + async def _run(): + return [chunk async for chunk in gen] + return run(_run()) + + def test_clean_stream_sequences_and_done_sentinel(self): + from services.sse_proxy import proxy_sse + + async def upstream(): + yield '{"a": 1}' + yield 'not-json' + + chunks = self._collect(proxy_sse(upstream())) + assert json.loads(chunks[0][len("data: "):]) == {"a": 1, "_seq": 0} + assert json.loads(chunks[1][len("data: "):]) == {"raw": "not-json", "_seq": 1} + assert chunks[-1] == 'data: {"_done": true}\n\n' + + def test_upstream_failure_emits_error_event(self): + from services.sse_proxy import proxy_sse + + async def upstream(): + yield '{"a": 1}' + raise RuntimeError("kaboom") + + chunks = self._collect(proxy_sse(upstream(), request_id="rid-42")) + # The good chunk still went through… + assert json.loads(chunks[0][len("data: "):]) == {"a": 1, "_seq": 0} + # …then an explicit error event, not a silent end. + error_chunks = [c for c in chunks if c.startswith("event: error\ndata: ")] + assert len(error_chunks) == 1 + payload = json.loads(error_chunks[0].split("\ndata: ", 1)[1]) + assert payload["error"]["code"] == "upstream_error" + assert "kaboom" in payload["error"]["message"] + assert payload["error"]["hint"] + assert payload["error"]["request_id"] == "rid-42" + # No _done sentinel after a failure. + assert not any("_done" in c for c in chunks) + + def test_immediate_upstream_failure_emits_error_event(self): + from services.sse_proxy import proxy_sse + + async def upstream(): + raise RuntimeError("dead on arrival") + yield # pragma: no cover — makes this an async generator + + chunks = self._collect(proxy_sse(upstream())) + assert len(chunks) == 1 + assert chunks[0].startswith("event: error\ndata: ") + payload = json.loads(chunks[0].split("\ndata: ", 1)[1]) + assert "dead on arrival" in payload["error"]["message"] + + def test_keepalive_comment_emitted_while_idle(self): + from services.sse_proxy import proxy_sse + + async def slow_upstream(): + await asyncio.sleep(0.3) + yield '{"x": 1}' + + chunks = self._collect(proxy_sse(slow_upstream(), keepalive_interval=0.05)) + keepalives = [c for c in chunks if c == ": keepalive\n\n"] + assert keepalives # at least one comment while the upstream was idle + # Keepalives must not consume sequence numbers. + assert json.loads(chunks[-2][len("data: "):]) == {"x": 1, "_seq": 0} + assert chunks[-1] == 'data: {"_done": true}\n\n' + + +# =========================================================================== +# /api/chat SSE — structured error events +# =========================================================================== + +class TestChatSSEErrors: + def _configure(self, client): + r = client.post("/api/config", json={ + "base_url": "http://localhost:3000", + "api_key": "test-key", + "default_model": "test-model", + }) + assert r.status_code == 200 + + @staticmethod + def _parse_sse(text: str) -> list[tuple[str, dict]]: + events = [] + for block in text.split("\n\n"): + if not block.strip() or block.startswith(":"): + continue + name, data = "message", "" + for line in block.split("\n"): + if line.startswith("event:"): + name = line[len("event:"):].strip() + elif line.startswith("data:"): + data += line[len("data:"):].strip() + if data: + events.append((name, json.loads(data))) + return events + + def test_upstream_failure_mid_chat_emits_error_envelope(self, client): + self._configure(client) + with patch("app.chat_complete", AsyncMock(side_effect=RuntimeError("model exploded"))): + r = client.post("/api/chat", json={ + "messages": [{"role": "user", "content": "hi"}], + "mode": "trusted", + }) + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/event-stream") + events = self._parse_sse(r.text) + errors = [d for (name, d) in events if name == "error"] + assert len(errors) == 1 + err = errors[0] + # Legacy field kept for older clients… + assert "model exploded" in err["message"] + # …plus the canonical envelope. + assert err["error"]["code"] == "internal_error" + assert "model exploded" in err["error"]["message"] + assert err["error"]["request_id"] == r.headers["X-Request-ID"] + + def test_http_exception_mid_chat_maps_status_to_code(self, client): + from fastapi import HTTPException + self._configure(client) + with patch("app.chat_complete", AsyncMock(side_effect=HTTPException(502, "upstream said no"))): + r = client.post("/api/chat", json={ + "messages": [{"role": "user", "content": "hi"}], + "mode": "trusted", + }) + assert r.status_code == 200 + events = self._parse_sse(r.text) + errors = [d for (name, d) in events if name == "error"] + assert len(errors) == 1 + assert errors[0]["error"]["code"] == "upstream_error" + assert errors[0]["message"] == "upstream said no" + + def test_keepalive_comments_while_chat_is_idle(self, client): + self._configure(client) + + async def slow_complete(*args, **kwargs): + await asyncio.sleep(0.3) + return "ok", {} + + with patch("app._sse_keepalive_interval", 0.05), \ + patch("app.chat_complete", AsyncMock(side_effect=slow_complete)): + r = client.post("/api/chat", json={ + "messages": [{"role": "user", "content": "hi"}], + "mode": "trusted", + }) + assert r.status_code == 200 + # Comment lines were emitted while the model was "thinking"… + assert ": keepalive\n\n" in r.text + # …and they didn't disturb the normal event flow. + events = self._parse_sse(r.text) + assert any(name == "done" for name, _ in events) From cc4bfd8e1632f93f58d325f7fe6ba2c4b4275a01 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 19:12:48 +0000 Subject: [PATCH 03/10] [P2] ruff + mypy gates (backend) - Add pyproject.toml configuring ruff (E, F, W, I; E501 exempted since the existing style favours long lines) and mypy (lenient globally with ignore_missing_imports; services/ fully typed via per-module overrides). - Fix all ruff findings mechanically: import sorting (I001), one-line multiple imports (E401), mid-file import moved to top (test_services.py), ambiguous variable name l -> lite (E741), and two intentional late imports marked noqa: E402 with justification. - Fully annotate services/: return types on all route handlers and the stream_task async generators, typed dicts in health.py/clients.py, and typed _PROVIDER_META plus a variable rename in oauth.py to fix an Optional reuse. No behavior change. - CI lint job: replace the pyflakes-only step with ruff check + mypy services/ (pinned versions, requirements installed so mypy sees real fastapi/httpx types). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012jUkqbwroQmYRAdFDsp5r9 --- .github/workflows/ci.yml | 11 +++++--- app.py | 45 +++++++++++++++++-------------- pyproject.toml | 54 ++++++++++++++++++++++++++++++++++++++ scripts/mock-server.py | 4 ++- services/clients.py | 10 ++++--- services/health.py | 8 +++--- services/oauth.py | 10 +++---- services/routes.py | 48 ++++++++++++++++----------------- tests/test_api.py | 6 +++-- tests/test_services.py | 27 ++++++++++++++----- tests/test_verification.py | 3 +-- verification.py | 2 +- 12 files changed, 157 insertions(+), 71 deletions(-) create mode 100644 pyproject.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3de5f2e..f176947 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -171,10 +171,15 @@ jobs: cache: pip - name: Install linters - run: pip install pyflakes python-frontmatter + # requirements.txt is installed so mypy sees the real (typed) fastapi / + # httpx packages instead of treating them as Any via ignore_missing_imports. + run: pip install ruff==0.6.9 mypy==1.19.1 python-frontmatter -r requirements.txt - - name: pyflakes (syntax / undefined names) - run: python -m pyflakes app.py services/ tests/ scripts/setup_wizard.py + - name: ruff (style, imports, pyflakes) + run: ruff check . + + - name: mypy (services/ is fully typed; config in pyproject.toml) + run: mypy services/ - name: Check HTML is valid UTF-8 run: python3 -c "open('static/index.html', encoding='utf-8').read(); print('index.html OK')" diff --git a/app.py b/app.py index c734061..6cad734 100644 --- a/app.py +++ b/app.py @@ -13,6 +13,8 @@ import io import ipaddress import json +import logging +import logging.handlers import os import platform import re @@ -20,8 +22,6 @@ import time import uuid import zipfile -import logging -import logging.handlers from contextlib import asynccontextmanager from pathlib import Path from typing import Any, AsyncGenerator, Optional @@ -29,10 +29,10 @@ import aiofiles import httpx import yaml -from fastapi import FastAPI, HTTPException, UploadFile, File, Request +from fastapi import FastAPI, File, HTTPException, Request, UploadFile from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError -from fastapi.responses import FileResponse, JSONResponse, StreamingResponse, Response +from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from starlette.exceptions import HTTPException as StarletteHTTPException @@ -2284,9 +2284,10 @@ async def execute_tool(call: dict, config: dict, send_event, mode: str = "approv # ── Service tool calls ──────────────────────────────────────────────────── if tool == "clk_research": - from services.clients import get_clk_client - from services import state as svc_state import httpx as _httpx + + from services import state as svc_state + from services.clients import get_clk_client if not svc_state.is_enabled("clk"): return {"error": "CognitiveLoopKernel is disabled. Enable it in Settings > Services."} command = args.get("command", "run") @@ -2316,9 +2317,10 @@ async def execute_tool(call: dict, config: dict, send_event, mode: str = "approv return {"error": f"CognitiveLoopKernel is enabled but could not be reached. ({e})"} if tool == "autogui_task": - from services.clients import get_autogui_client - from services import state as svc_state import httpx as _httpx + + from services import state as svc_state + from services.clients import get_autogui_client if not svc_state.is_enabled("autogui"): return {"error": "AutoGUI is disabled. Enable it in Settings > Services."} task_desc = args.get("task") or "" @@ -2346,9 +2348,10 @@ async def execute_tool(call: dict, config: dict, send_event, mode: str = "approv return {"error": f"AutoGUI is enabled but could not be reached. ({e})"} if tool == "screen_windows": - from services.clients import get_osso_client - from services import state as svc_state import httpx as _httpx + + from services import state as svc_state + from services.clients import get_osso_client if not svc_state.is_enabled("osso"): return {"error": "OSScreenObserver is disabled. Enable it in Settings > Services."} try: @@ -2357,9 +2360,10 @@ async def execute_tool(call: dict, config: dict, send_event, mode: str = "approv return {"error": f"OSScreenObserver is enabled but could not be reached. ({e})"} if tool == "screen_description": - from services.clients import get_osso_client - from services import state as svc_state import httpx as _httpx + + from services import state as svc_state + from services.clients import get_osso_client if not svc_state.is_enabled("osso"): return {"error": "OSScreenObserver is disabled. Enable it in Settings > Services."} try: @@ -2371,9 +2375,10 @@ async def execute_tool(call: dict, config: dict, send_event, mode: str = "approv return {"error": f"OSScreenObserver is enabled but could not be reached. ({e})"} if tool == "screen_screenshot": - from services.clients import get_osso_client - from services import state as svc_state import httpx as _httpx + + from services import state as svc_state + from services.clients import get_osso_client if not svc_state.is_enabled("osso"): return {"error": "OSScreenObserver is disabled. Enable it in Settings > Services."} try: @@ -2382,9 +2387,10 @@ async def execute_tool(call: dict, config: dict, send_event, mode: str = "approv return {"error": f"OSScreenObserver is enabled but could not be reached. ({e})"} if tool == "screen_action": - from services.clients import get_osso_client - from services import state as svc_state import httpx as _httpx + + from services import state as svc_state + from services.clients import get_osso_client if not svc_state.is_enabled("osso"): return {"error": "OSScreenObserver is disabled. Enable it in Settings > Services."} action_type = args.get("action", "") @@ -2754,8 +2760,8 @@ async def recommend_model(use_case: str = "general"): if m: return {"recommendation": m, "reason": f"This model handles complex {use_case} tasks well."} else: - for l in light: - m = next((x for x in models if l in x["id"].lower()), None) + for lite in light: + m = next((x for x in models if lite in x["id"].lower()), None) if m: return {"recommendation": m, "reason": f"This efficient model works great for {use_case}."} return {"recommendation": models[0], "reason": "Using the first available model."} @@ -4583,7 +4589,8 @@ async def event_stream() -> AsyncGenerator[bytes, None]: # ─── Services integration ──────────────────────────────────────────────────── -from services.routes import register_routes as _register_service_routes +# Imported late on purpose: routes need the fully-initialised `app` above. +from services.routes import register_routes as _register_service_routes # noqa: E402 _register_service_routes(app) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1febdc0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,54 @@ +# Tooling configuration for BetterWebUI quality gates. +# NOTE: this file intentionally has no [project]/[build-system] tables — the app +# is not packaged; this exists only to configure ruff and mypy. + +# --------------------------------------------------------------------------- +# ruff — style, imports, pyflakes +# --------------------------------------------------------------------------- +[tool.ruff] +line-length = 120 +target-version = "py310" # matches the minimum Python in the CI matrix +extend-exclude = [ + # Sibling-service submodules — never lint these from this repo. + "AutoGUI", + "CognitiveLoopKernel", + "OSScreenObserver", + # Runtime state / user content directories. + "data", + "workspace", + "logs", + # Node-based Playwright suite (no Python). + "tests/playwright", +] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors (includes E722 bare-except) + "F", # pyflakes + "W", # pycodestyle warnings + "I", # isort import ordering +] +ignore = [ + # E501 (line too long): the existing codebase deliberately favours long + # single lines (some run past 200 cols). Enforcing a wrap limit now would + # mean a mass reformat, so line length is not gated. line-length above is + # still used by ruff as a soft reference for other rules. + "E501", +] + +# --------------------------------------------------------------------------- +# mypy — lenient globally, strict for services/ +# --------------------------------------------------------------------------- +[tool.mypy] +# Lenient global baseline: third-party libs without stubs are not our problem. +ignore_missing_imports = true +# The CI gate runs `mypy services/` (see .github/workflows/ci.yml); app.py is +# legacy-typed and is not gated yet. + +[[tool.mypy.overrides]] +# services/ is fully typed: every function must have complete annotations and +# bodies are checked even in older un-annotated helpers. +module = "services.*" +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true diff --git a/scripts/mock-server.py b/scripts/mock-server.py index db224a3..332d177 100644 --- a/scripts/mock-server.py +++ b/scripts/mock-server.py @@ -17,6 +17,7 @@ import json import threading import time + import uvicorn from fastapi import FastAPI from fastapi.responses import StreamingResponse @@ -227,7 +228,8 @@ def _run(app, port): t.start() # Wait for all servers to come up - import urllib.request, urllib.error + import urllib.error + import urllib.request for name, url in [ ("OpenWebUI", "http://localhost:11000/health"), ("CLK", "http://localhost:8001/api/healthz"), diff --git a/services/clients.py b/services/clients.py index aa50f68..9246455 100644 --- a/services/clients.py +++ b/services/clients.py @@ -1,8 +1,10 @@ import json import logging +from collections.abc import AsyncIterator import httpx -from .registry import get_services, ServiceEndpoint + +from .registry import ServiceEndpoint, get_services logger = logging.getLogger("betterwebui.services.clients") @@ -37,7 +39,7 @@ async def get_task(self, task_id: str) -> dict: r = await c.get(f"/api/research/{task_id}") return r.json() - async def stream_task(self, task_id: str): + async def stream_task(self, task_id: str) -> AsyncIterator[str]: """Async generator yielding raw SSE lines from CLK.""" ep = get_services()["clk"] async with httpx.AsyncClient(base_url=ep.base_url, timeout=ep.timeout) as c: @@ -80,7 +82,7 @@ async def get_task(self, task_id: str) -> dict: r = await c.get(f"/api/task/{task_id}") return r.json() - async def stream_task(self, task_id: str): + async def stream_task(self, task_id: str) -> AsyncIterator[str]: ep = get_services()["autogui"] async with httpx.AsyncClient(base_url=ep.base_url, timeout=ep.timeout) as c: async with c.stream("GET", f"/api/task/{task_id}/stream") as r: @@ -138,7 +140,7 @@ async def windows(self) -> dict: return r.json() async def description(self, window_index: int | None = None, mode: str = "accessibility") -> dict: - params = {"mode": mode} + params: dict[str, str | int] = {"mode": mode} if window_index is not None: params["window_index"] = window_index async with self._client() as c: diff --git a/services/health.py b/services/health.py index 6f38b53..0efbef6 100644 --- a/services/health.py +++ b/services/health.py @@ -1,17 +1,19 @@ import asyncio -from .clients import get_clk_client, get_autogui_client, get_osso_client +from typing import Any + from . import state as svc_state +from .clients import ServiceClient, get_autogui_client, get_clk_client, get_osso_client async def check_all_services() -> dict: - results = {} + results: dict[str, dict[str, Any]] = {} clients = { "clk": get_clk_client(), "autogui": get_autogui_client(), "osso": get_osso_client(), } - async def check(name, client): + async def check(name: str, client: ServiceClient) -> None: if not svc_state.is_enabled(name): results[name] = {"ok": True, "enabled": False} return diff --git a/services/oauth.py b/services/oauth.py index db421a6..d23e791 100644 --- a/services/oauth.py +++ b/services/oauth.py @@ -23,7 +23,7 @@ import urllib.parse import uuid from pathlib import Path -from typing import Optional +from typing import Any, Optional import httpx @@ -37,7 +37,7 @@ # Provider configs # --------------------------------------------------------------------------- -_PROVIDER_META = { +_PROVIDER_META: dict[str, dict[str, Any]] = { "google": { "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", "token_url": "https://oauth2.googleapis.com/token", @@ -253,9 +253,9 @@ async def _handle_callback_connection( if error or not code or not state: _send_http(writer, 400, _html_page("Connection failed", f"OAuth error: {error or 'missing code'}")) if state in _pending: - entry = _pending.pop(state) - if not entry["future"].done(): - entry["future"].set_exception(RuntimeError(f"OAuth error: {error}")) + failed = _pending.pop(state) + if not failed["future"].done(): + failed["future"].set_exception(RuntimeError(f"OAuth error: {error}")) return entry = _pending.get(state) diff --git a/services/routes.py b/services/routes.py index 4869f13..3ef5c88 100644 --- a/services/routes.py +++ b/services/routes.py @@ -10,10 +10,10 @@ from fastapi import FastAPI, HTTPException, Request from fastapi.responses import StreamingResponse -from .clients import get_clk_client, get_autogui_client, get_osso_client +from . import state as svc_state +from .clients import get_autogui_client, get_clk_client, get_osso_client from .health import check_all_services from .sse_proxy import proxy_sse -from . import state as svc_state _VALID_SERVICE_NAMES = frozenset({"clk", "autogui", "osso"}) @@ -40,7 +40,7 @@ def register_routes(app: FastAPI) -> None: # noqa: C901 # ── Health ──────────────────────────────────────────────── @app.get("/api/services/health") - async def services_health(): + async def services_health() -> dict: results = await check_all_services() all_ok = all(v["ok"] for v in results.values()) return {"ok": all_ok, "services": results} @@ -48,20 +48,20 @@ async def services_health(): # ── Enable / Disable ───────────────────────────────────── @app.get("/api/services/status") - async def services_status(): + async def services_status() -> dict: """Return the enabled/disabled state for each service.""" enabled_map = svc_state.get_all() return {"services": {name: {"enabled": enabled} for name, enabled in enabled_map.items()}} @app.post("/api/services/{name}/enable") - async def enable_service(name: str): + async def enable_service(name: str) -> dict: if name not in _VALID_SERVICE_NAMES: raise HTTPException(status_code=404, detail=f"Unknown service: {name}") svc_state.set_enabled(name, True) return {"ok": True, "service": name, "enabled": True} @app.post("/api/services/{name}/disable") - async def disable_service(name: str): + async def disable_service(name: str) -> dict: if name not in _VALID_SERVICE_NAMES: raise HTTPException(status_code=404, detail=f"Unknown service: {name}") svc_state.set_enabled(name, False) @@ -70,7 +70,7 @@ async def disable_service(name: str): # ── CognitiveLoopKernel ────────────────────────────────────── @app.get("/api/services/clk/workflows") - async def clk_list_workflows(): + async def clk_list_workflows() -> dict: _require_enabled("clk") client = get_clk_client() try: @@ -79,7 +79,7 @@ async def clk_list_workflows(): raise _unreachable("clk", e) from e @app.post("/api/services/clk/research") - async def clk_start_research(body: dict): + async def clk_start_research(body: dict) -> dict: _require_enabled("clk") client = get_clk_client() try: @@ -93,7 +93,7 @@ async def clk_start_research(body: dict): raise _unreachable("clk", e) from e @app.get("/api/services/clk/research/{task_id}") - async def clk_get_task(task_id: str): + async def clk_get_task(task_id: str) -> dict: _require_enabled("clk") client = get_clk_client() try: @@ -102,7 +102,7 @@ async def clk_get_task(task_id: str): raise _unreachable("clk", e) from e @app.get("/api/services/clk/research/{task_id}/stream") - async def clk_stream_task(task_id: str, request: Request): + async def clk_stream_task(task_id: str, request: Request) -> StreamingResponse: _require_enabled("clk") client = get_clk_client() return StreamingResponse( @@ -112,7 +112,7 @@ async def clk_stream_task(task_id: str, request: Request): ) @app.get("/api/services/clk/research/{task_id}/artifacts") - async def clk_list_artifacts(task_id: str): + async def clk_list_artifacts(task_id: str) -> dict: _require_enabled("clk") client = get_clk_client() try: @@ -121,7 +121,7 @@ async def clk_list_artifacts(task_id: str): raise _unreachable("clk", e) from e @app.post("/api/services/clk/research/{task_id}/cancel") - async def clk_cancel_task(task_id: str): + async def clk_cancel_task(task_id: str) -> dict: _require_enabled("clk") client = get_clk_client() try: @@ -132,7 +132,7 @@ async def clk_cancel_task(task_id: str): # ── AutoGUI ─────────────────────────────────────────────── @app.post("/api/services/autogui/task") - async def autogui_start_task(body: dict): + async def autogui_start_task(body: dict) -> dict: _require_enabled("autogui") client = get_autogui_client() try: @@ -151,7 +151,7 @@ async def autogui_start_task(body: dict): raise _unreachable("autogui", e) from e @app.get("/api/services/autogui/task/{task_id}") - async def autogui_get_task(task_id: str): + async def autogui_get_task(task_id: str) -> dict: _require_enabled("autogui") client = get_autogui_client() try: @@ -160,7 +160,7 @@ async def autogui_get_task(task_id: str): raise _unreachable("autogui", e) from e @app.get("/api/services/autogui/task/{task_id}/stream") - async def autogui_stream_task(task_id: str, request: Request): + async def autogui_stream_task(task_id: str, request: Request) -> StreamingResponse: _require_enabled("autogui") client = get_autogui_client() return StreamingResponse( @@ -170,7 +170,7 @@ async def autogui_stream_task(task_id: str, request: Request): ) @app.post("/api/services/autogui/task/{task_id}/cancel") - async def autogui_cancel_task(task_id: str): + async def autogui_cancel_task(task_id: str) -> dict: _require_enabled("autogui") client = get_autogui_client() try: @@ -179,7 +179,7 @@ async def autogui_cancel_task(task_id: str): raise _unreachable("autogui", e) from e @app.get("/api/services/autogui/tools") - async def autogui_list_tools(): + async def autogui_list_tools() -> dict: _require_enabled("autogui") client = get_autogui_client() try: @@ -190,7 +190,7 @@ async def autogui_list_tools(): # ── OSScreenObserver ─────────────────────────────────────── @app.get("/api/services/osso/windows") - async def osso_windows(): + async def osso_windows() -> dict: _require_enabled("osso") client = get_osso_client() try: @@ -199,7 +199,7 @@ async def osso_windows(): raise _unreachable("osso", e) from e @app.get("/api/services/osso/description") - async def osso_description(window_index: int | None = None, mode: str = "accessibility"): + async def osso_description(window_index: int | None = None, mode: str = "accessibility") -> dict: _require_enabled("osso") client = get_osso_client() try: @@ -211,7 +211,7 @@ async def osso_description(window_index: int | None = None, mode: str = "accessi raise _unreachable("osso", e) from e @app.get("/api/services/osso/structure") - async def osso_structure(window_index: int | None = None): + async def osso_structure(window_index: int | None = None) -> dict: _require_enabled("osso") client = get_osso_client() try: @@ -220,7 +220,7 @@ async def osso_structure(window_index: int | None = None): raise _unreachable("osso", e) from e @app.get("/api/services/osso/screenshot") - async def osso_screenshot(window_index: int | None = None): + async def osso_screenshot(window_index: int | None = None) -> dict: _require_enabled("osso") client = get_osso_client() try: @@ -229,7 +229,7 @@ async def osso_screenshot(window_index: int | None = None): raise _unreachable("osso", e) from e @app.post("/api/services/osso/action") - async def osso_action(body: dict): + async def osso_action(body: dict) -> dict: _require_enabled("osso") client = get_osso_client() try: @@ -238,7 +238,7 @@ async def osso_action(body: dict): raise _unreachable("osso", e) from e @app.get("/api/services/osso/capabilities") - async def osso_capabilities(): + async def osso_capabilities() -> dict: _require_enabled("osso") client = get_osso_client() try: @@ -249,7 +249,7 @@ async def osso_capabilities(): # ── LLM Tool Specs ────────────────────────────────────────────── @app.get("/api/services/tools") - async def services_tool_specs(): + async def services_tool_specs() -> dict: """Return OpenAI function-calling tool specs for all integrated services.""" return { "tools": [ diff --git a/tests/test_api.py b/tests/test_api.py index a003387..7d1554e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -25,8 +25,7 @@ import json import zipfile -from tests.conftest import make_skill, make_prompt, make_workspace, seed_conversation - +from tests.conftest import make_prompt, make_skill, make_workspace, seed_conversation # =========================================================================== # Config @@ -843,6 +842,7 @@ def _setup_workspace(self, isolated_dirs, filename="target.txt", content="delete def _run_tool(self, call, mode="trusted"): import asyncio + import app as app_module events = [] @@ -980,7 +980,9 @@ def test_forced_internal_error_returns_envelope(self, isolated_dirs): """A crash inside a route handler produces a 500 envelope (catch-all Exception handler), not a bare traceback response.""" from unittest.mock import AsyncMock, patch + from fastapi.testclient import TestClient + import app as app_module with patch.object(app_module.mcp_manager, "status", side_effect=RuntimeError("boom")): diff --git a/tests/test_services.py b/tests/test_services.py index 5a0037b..f26053e 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio +import json from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -73,20 +74,26 @@ def test_osso_endpoint_fields(self): def test_env_override_clk(self, monkeypatch): monkeypatch.setenv("CLK_BASE_URL", "http://my-clk:9999") - import importlib, services.registry as reg + import importlib + + import services.registry as reg importlib.reload(reg) svcs = reg.get_services() assert svcs["clk"].base_url == "http://my-clk:9999" def test_env_override_autogui(self, monkeypatch): monkeypatch.setenv("AUTOGUI_BASE_URL", "http://my-autogui:7777") - import importlib, services.registry as reg + import importlib + + import services.registry as reg importlib.reload(reg) assert reg.get_services()["autogui"].base_url == "http://my-autogui:7777" def test_env_override_osso(self, monkeypatch): monkeypatch.setenv("OSSO_BASE_URL", "http://my-osso:6666") - import importlib, services.registry as reg + import importlib + + import services.registry as reg importlib.reload(reg) assert reg.get_services()["osso"].base_url == "http://my-osso:6666" @@ -97,8 +104,8 @@ def test_env_override_osso(self, monkeypatch): class TestServiceClientHealth: def test_health_returns_json(self): - from services.registry import get_services from services.clients import ServiceClient + from services.registry import get_services ep = get_services()["clk"] client = ServiceClient(ep) @@ -112,8 +119,8 @@ def test_health_returns_json(self): assert result == {"status": "ok"} def test_health_calls_health_path(self): - from services.registry import get_services from services.clients import ServiceClient + from services.registry import get_services ep = get_services()["clk"] client = ServiceClient(ep) @@ -355,6 +362,7 @@ async def fake_health(self): with patch("services.clients.ServiceClient.health", new=fake_health), \ patch("services.state.is_enabled", return_value=True): import importlib + import services.health as health_mod importlib.reload(health_mod) results = run(health_mod.check_all_services()) @@ -369,6 +377,7 @@ async def fake_health(self): with patch("services.clients.ServiceClient.health", new=fake_health), \ patch("services.state.is_enabled", return_value=True): import importlib + import services.health as health_mod importlib.reload(health_mod) results = run(health_mod.check_all_services()) @@ -385,6 +394,7 @@ async def fake_health(self): with patch("services.clients.ServiceClient.health", new=fake_health), \ patch("services.state.is_enabled", return_value=True): import importlib + import services.health as health_mod importlib.reload(health_mod) results = run(health_mod.check_all_services()) @@ -407,6 +417,7 @@ class TestServicesRoutes: def app_client(self): from fastapi import FastAPI from fastapi.testclient import TestClient + from services.routes import register_routes app = FastAPI() @@ -692,6 +703,7 @@ class TestServiceEnableDisable: def app_client(self): from fastapi import FastAPI from fastapi.testclient import TestClient + from services.routes import register_routes app = FastAPI() @@ -845,6 +857,7 @@ async def fake_health(self): with patch("services.state.is_enabled", side_effect=fake_is_enabled), \ patch("services.clients.ServiceClient.health", new=fake_health): import importlib + import services.health as hmod importlib.reload(hmod) results = run(hmod.check_all_services()) @@ -858,6 +871,7 @@ async def fake_health(self): def test_all_disabled_health_still_ok(self): with patch("services.state.is_enabled", return_value=False): import importlib + import services.health as hmod importlib.reload(hmod) results = run(hmod.check_all_services()) @@ -874,6 +888,7 @@ class TestGracefulDegradation: def app_client(self): from fastapi import FastAPI from fastapi.testclient import TestClient + from services.routes import register_routes app = FastAPI() @@ -932,8 +947,6 @@ def test_timeout_also_returns_503(self, app_client): # SSE proxy — error events + keepalive # =========================================================================== -import json - class TestProxySSE: def _collect(self, gen): diff --git a/tests/test_verification.py b/tests/test_verification.py index 11e337c..886bffb 100644 --- a/tests/test_verification.py +++ b/tests/test_verification.py @@ -20,8 +20,7 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -import verification as v - +import verification as v # noqa: E402 (import requires the sys.path setup above) # --------------------------------------------------------------------------- # Helpers diff --git a/verification.py b/verification.py index bb80baf..c258887 100644 --- a/verification.py +++ b/verification.py @@ -25,7 +25,7 @@ import logging import re import time -from dataclasses import dataclass, field, asdict +from dataclasses import asdict, dataclass, field from typing import Any, Awaitable, Callable, Optional log = logging.getLogger("betterwebui.verification") From 78e02a94e2e51fd37a97d8a2d953497bc7549931 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 19:20:11 +0000 Subject: [PATCH 04/10] [P2] eslint + prettier for static/ + first JS unit tests - Add self-contained eslint flat config (eslint.config.js, no plugin deps so `npx eslint` works with zero node_modules) targeting genuine errors only; .prettierrc documents the existing style (2-space, double quotes, semis) and is intentionally not gated to avoid a mass reformat. - Fix the only lint findings: prefer-const on pendingWorkspaceFiles, plus two missing browser globals declared in config (Element, ImageCapture). - Extract 5 pure helpers from app.js into new static/lib.js: escape, humanLabelForTool, friendlyError, fillTemplate, and parseSSEBlock (SSE block parsing pulled out of consumeSSE). lib.js loads before browser-store.js/app.js via a classic script tag (zero-build preserved) and has a module.exports guard so node can require it. - Add tests/js/lib.test.js: 16 unit tests runnable with `node --test "tests/js/*.test.js"` (plain node test runner). - CI: new js-quality job (Node 22) running eslint + node --test; lint job file checks now cover static/lib.js. - tests/test_frontend.py updated for the extraction (helpers asserted in lib.js, load-order test, no-duplication test): suite now 379 tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012jUkqbwroQmYRAdFDsp5r9 --- .github/workflows/ci.yml | 27 +++++++- .prettierrc | 11 ++++ eslint.config.js | 136 +++++++++++++++++++++++++++++++++++++++ static/app.js | 82 ++--------------------- static/index.html | 2 + static/lib.js | 107 ++++++++++++++++++++++++++++++ tests/js/lib.test.js | 109 +++++++++++++++++++++++++++++++ tests/test_frontend.py | 31 ++++++++- 8 files changed, 424 insertions(+), 81 deletions(-) create mode 100644 .prettierrc create mode 100644 eslint.config.js create mode 100644 static/lib.js create mode 100644 tests/js/lib.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f176947..0029946 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,7 +185,9 @@ jobs: run: python3 -c "open('static/index.html', encoding='utf-8').read(); print('index.html OK')" - name: Check JS is valid UTF-8 - run: python3 -c "open('static/app.js', encoding='utf-8').read(); print('app.js OK')" + run: | + python3 -c "open('static/app.js', encoding='utf-8').read(); print('app.js OK')" + python3 -c "open('static/lib.js', encoding='utf-8').read(); print('lib.js OK')" - name: Check CSS is valid UTF-8 run: python3 -c "open('static/style.css', encoding='utf-8').read(); print('style.css OK')" @@ -198,7 +200,7 @@ jobs: - name: Check required files exist run: | - for f in app.py requirements.txt static/index.html static/app.js static/style.css deploy/start.sh deploy/start.bat; do + for f in app.py requirements.txt static/index.html static/app.js static/lib.js static/style.css deploy/start.sh deploy/start.bat; do [ -f "$f" ] || (echo "Missing: $f" && exit 1) echo "OK $f" done @@ -224,6 +226,27 @@ jobs: print(f"All {len(list(Path('skills').glob('*.md')))} skill(s) valid.") EOF + # --------------------------------------------------------------------------- + # Frontend JS: eslint + node unit tests (zero-build; no node_modules needed) + # --------------------------------------------------------------------------- + js-quality: + name: JS lint + unit tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node 22 + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: eslint (flat config, self-contained — no plugins) + run: npx --yes eslint@10.1.0 static/ tests/js/ eslint.config.js + + - name: JS unit tests (node --test) + run: node --test "tests/js/*.test.js" + # --------------------------------------------------------------------------- # Docker build check # --------------------------------------------------------------------------- diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..d2524ca --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..19223ac --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,136 @@ +// ESLint flat config for the zero-build frontend (static/*.js) and the Node +// unit tests (tests/js/). Deliberately self-contained: no plugin or preset +// requires, so it runs with any eslint >= 9 (e.g. `npx eslint`) without a +// package.json / node_modules at the repo root. +// +// Rule selection targets genuine errors (undefined/unused names, duplicate +// keys, unreachable code) — NOT reformatting. Style lives in .prettierrc and +// is intentionally not gated so the existing hand-written layout stays put. +"use strict"; + +const browserGlobals = { + // DOM / BOM + window: "readonly", + document: "readonly", + navigator: "readonly", + location: "readonly", + history: "readonly", + localStorage: "readonly", + sessionStorage: "readonly", + indexedDB: "readonly", + fetch: "readonly", + console: "readonly", + alert: "readonly", + confirm: "readonly", + prompt: "readonly", + atob: "readonly", + btoa: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + setInterval: "readonly", + clearInterval: "readonly", + requestAnimationFrame: "readonly", + getComputedStyle: "readonly", + crypto: "readonly", + performance: "readonly", + // Constructors + Blob: "readonly", + File: "readonly", + FileReader: "readonly", + FormData: "readonly", + URL: "readonly", + URLSearchParams: "readonly", + Image: "readonly", + Audio: "readonly", + AbortController: "readonly", + TextDecoder: "readonly", + TextEncoder: "readonly", + MutationObserver: "readonly", + ResizeObserver: "readonly", + IntersectionObserver: "readonly", + CustomEvent: "readonly", + Event: "readonly", + KeyboardEvent: "readonly", + Element: "readonly", + ImageCapture: "readonly", + Notification: "readonly", + // Speech APIs (voice input / read-aloud) + SpeechRecognition: "readonly", + webkitSpeechRecognition: "readonly", + speechSynthesis: "readonly", + SpeechSynthesisUtterance: "readonly", + // CDN-loaded libraries (see static/index.html) + marked: "readonly", + DOMPurify: "readonly", + katex: "readonly", + renderMathInElement: "readonly", + // CommonJS export guard in lib.js / browser-store.js + module: "readonly", +}; + +// Globals our own classic scripts define for each other (load order in +// index.html: lib.js -> browser-store.js -> app.js). +const appGlobals = { + // static/lib.js + escape: "readonly", + humanLabelForTool: "readonly", + friendlyError: "readonly", + fillTemplate: "readonly", + parseSSEBlock: "readonly", + // static/browser-store.js + bws: "readonly", +}; + +const errorRules = { + "no-undef": "error", + "no-unused-vars": [ + "error", + // args/caught errors stay callback-shaped in this codebase; only flag + // genuinely dead variables and functions. + { vars: "all", args: "none", caughtErrors: "none" }, + ], + "no-redeclare": "error", + "no-dupe-keys": "error", + "no-dupe-args": "error", + "no-dupe-else-if": "error", + "no-duplicate-case": "error", + "no-unreachable": "error", + "no-unsafe-negation": "error", + "no-compare-neg-zero": "error", + "no-cond-assign": ["error", "except-parens"], + "no-constant-condition": ["error", { checkLoops: false }], // while(true) SSE reader loops + "no-self-assign": "error", + "no-sparse-arrays": "error", + "use-isnan": "error", + "valid-typeof": "error", + "no-var": "error", + "prefer-const": ["error", { destructuring: "all" }], + eqeqeq: ["error", "smart"], +}; + +module.exports = [ + { + files: ["static/*.js"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "script", + globals: { ...browserGlobals, ...appGlobals }, + }, + rules: errorRules, + }, + { + files: ["tests/js/**/*.js", "eslint.config.js"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "commonjs", + globals: { + require: "readonly", + module: "writable", + __dirname: "readonly", + console: "readonly", + process: "readonly", + }, + }, + rules: errorRules, + }, +]; diff --git a/static/app.js b/static/app.js index e90fbaf..8d8133f 100644 --- a/static/app.js +++ b/static/app.js @@ -52,72 +52,8 @@ async function api(path, opts = {}) { return res.json(); } -function escape(s) { - return String(s ?? "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -// Map technical errors to language a non-technical user can act on. -// `raw` is the raw error (Error object or string); `context` is an -// optional short description ("uploading a file", "loading models"). -// Map raw tool names to verbs a non-technical user understands. -function humanLabelForTool(tool) { - const map = { - execute_shell: "Running a command", - cli_call: "Running a CLI shortcut", - write_file: "Writing a file", - delete_file: "Deleting a file", - read_file: "Reading a file", - generate_image: "Drawing an image", - generate_audio: "Generating speech", - web_search: "Searching the web", - mcp_call: "Calling a connected service", - autogui_task: "Controlling the desktop", - clk_research: "Researching", - screen_action: "Acting on the screen", - update_task_plan: "Updating the plan", - load_skill: "Loading a skill", - }; - return map[tool] || tool; -} - -function friendlyError(raw, context) { - const text = (raw && (raw.message || raw.body || raw.toString())) || ""; - const status = raw && raw.status; - const ctx = context ? ` while ${context}` : ""; - if (status === 401 || /unauthor/i.test(text)) { - return `Your API key isn't being accepted${ctx}. Open Settings → Connection to update it.`; - } - if (status === 403) { - return `You don't have permission for that${ctx}. Check Settings → Connection.`; - } - if (status === 404) { - return `Not found${ctx}. The item may have been deleted or moved.`; - } - if (status === 413 || /too large|payload/i.test(text)) { - return `That file is too large${ctx}. Try a smaller one.`; - } - if (status === 429 || /rate limit|too many/i.test(text)) { - return `The server is rate-limiting requests${ctx}. Wait a moment and try again.`; - } - if (/timeout|timed out|took too long/i.test(text)) { - return `The server took too long to respond${ctx}. Try again, or pick a smaller task.`; - } - if (/network|failed to fetch|ECONN|ENOTFOUND/i.test(text)) { - return `Couldn't reach the server${ctx}. Check your internet connection.`; - } - if (/invalid data|broken|undecodable/i.test(text)) { - return `The result came back broken${ctx}. Click "Try again" or rephrase.`; - } - if (status >= 500) { - return `The server hit an error${ctx}. Try again in a moment.`; - } - // Last resort: trim and de-jargon the raw text. - return text.replace(/^\d+:\s*/, "").slice(0, 200) || `Something went wrong${ctx}.`; -} +// escape(), humanLabelForTool(), friendlyError(), fillTemplate() and +// parseSSEBlock() live in static/lib.js (pure helpers, unit-tested with node). // --------------------------------------------------------------------------- // Local-download helpers @@ -915,7 +851,7 @@ function openWorkspaceDialog(workspace) { ], }); - let pendingWorkspaceFiles = [...(w.files || [])]; + const pendingWorkspaceFiles = [...(w.files || [])]; const renderFiles = () => { $("#dlg-files").innerHTML = pendingWorkspaceFiles .map((f, i) => `
${escape(f.filename)} ${escape(f.content_type || "")}
`) @@ -1171,10 +1107,6 @@ function openMcpFieldsDialog(reg) { }); } -function fillTemplate(tpl, values) { - return String(tpl).replace(/\{(\w+)\}/g, (_, k) => values[k] ?? ""); -} - function openMcpCustomDialog() { showDialog({ title: "Custom MCP server", @@ -2903,13 +2835,7 @@ async function consumeSSE(res, onEvent) { while ((idx = buf.indexOf("\n\n")) !== -1) { const block = buf.slice(0, idx); buf = buf.slice(idx + 2); - const lines = block.split("\n"); - let eventName = "message"; - let dataStr = ""; - for (const ln of lines) { - if (ln.startsWith("event:")) eventName = ln.slice(6).trim(); - else if (ln.startsWith("data:")) dataStr += ln.slice(5).trim(); - } + const { event: eventName, data: dataStr } = parseSSEBlock(block); if (!dataStr) continue; try { await onEvent(eventName, JSON.parse(dataStr)); diff --git a/static/index.html b/static/index.html index 520e130..e6462a7 100644 --- a/static/index.html +++ b/static/index.html @@ -520,6 +520,8 @@

Annotate image

crossorigin="anonymous" > + + diff --git a/static/lib.js b/static/lib.js new file mode 100644 index 0000000..4d11f16 --- /dev/null +++ b/static/lib.js @@ -0,0 +1,107 @@ +// BetterWebUI pure helpers. No DOM access, no fetch, no app state — every +// function here is a pure(ish) function of its arguments, so they can be +// unit-tested with `node --test tests/js/` without a browser or bundler. +// +// Loaded as a classic - + - + + diff --git a/static/js/api.js b/static/js/api.js new file mode 100644 index 0000000..644aed5 --- /dev/null +++ b/static/js/api.js @@ -0,0 +1,66 @@ +// api.js — Backend API fetch wrappers and local-download helpers. +// Split out of the former static/app.js (Phase 3); logic unchanged. +import { state } from "./state.js"; + +// --------------------------------------------------------------------------- +// API helpers +// --------------------------------------------------------------------------- + +export async function api(path, opts = {}) { + const res = await fetch(path, { + headers: opts.json ? { "Content-Type": "application/json" } : {}, + ...opts, + body: opts.json ? JSON.stringify(opts.json) : opts.body, + }); + if (!res.ok) { + const text = await res.text(); + const err = new Error(`${res.status}: ${text}`); + err.status = res.status; + err.body = text; + throw err; + } + return res.json(); +} + +// escape(), humanLabelForTool(), friendlyError(), fillTemplate() and +// parseSSEBlock() live in static/lib.js (pure helpers, unit-tested with node). + +// --------------------------------------------------------------------------- +// Local-download helpers +// --------------------------------------------------------------------------- + +export function b64ToBlob(b64, mime) { + const bin = atob(b64); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return new Blob([arr], { type: mime || "application/octet-stream" }); +} + +export function storeFile(blob, filename, mime) { + const url = URL.createObjectURL(blob); + state.fileStore[filename] = { url, mime: mime || blob.type || "application/octet-stream", filename }; + return url; +} + +export async function fileToContentEntry(file) { + const isText = + file.type.startsWith("text/") || + /\.(md|markdown|csv|tsv|json|ya?ml|log|txt|py|js|ts|tsx|jsx|html|css|java|c|cpp|h|sh|tex|bib)$/i.test(file.name); + const entry = { + filename: file.name, + content_type: file.type || "application/octet-stream", + size: file.size, + }; + if (isText) { + entry.content = await file.text(); + } else { + const buf = await file.arrayBuffer(); + let bin = ""; + const bytes = new Uint8Array(buf); + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + entry.data_b64 = btoa(bin); + } + return entry; +} + +// --------------------------------------------------------------------------- diff --git a/static/js/bundles_memories.js b/static/js/bundles_memories.js new file mode 100644 index 0000000..69319bd --- /dev/null +++ b/static/js/bundles_memories.js @@ -0,0 +1,266 @@ +// bundles_memories.js — IndexedDB-backed file bundles and user memories (via the bws global from browser-store.js). +// Split out of the former static/app.js (Phase 3); logic unchanged. +import { api } from "./api.js"; +import { closeDialog, flash, showDialog } from "./dialogs.js"; +import { $, state } from "./state.js"; + +// File bundles (IndexedDB-backed; mounted bundles ride per-message attachments) +// --------------------------------------------------------------------------- + +state.mountedBundleIds = state.mountedBundleIds || []; + +export async function renderBundleList() { + if (!window.bws) return; + const ul = $("#bundle-list"); + if (!ul) return; + let bundles; + try { bundles = await bws.bundleList(); } + catch (e) { ul.innerHTML = `
  • Couldn't read browser storage: ${escape(e.message || e)}
  • `; return; } + if (!bundles.length) { + ul.innerHTML = '
  • No bundles yet. Click "+ New file bundle" to create one.
  • '; + } else { + ul.innerHTML = ""; + for (const b of bundles) { + const li = document.createElement("li"); + li.className = "bundle-item"; + const mounted = state.mountedBundleIds.includes(b.id); + li.innerHTML = ` +
    + ${escape(b.name)} + ${b.file_count} file(s) · ${(b.total_bytes/1024).toFixed(0)} KB +
    + ${b.description ? `
    ${escape(b.description)}
    ` : ""} +
    + + + +
    + `; + li.querySelector('[data-action="mount"]').addEventListener("change", (e) => { + toggleBundleMount(b.id, e.target.checked); + }); + li.querySelector('[data-action="open"]').onclick = () => openBundleDialog(b.id); + li.querySelector('[data-action="delete"]').onclick = async () => { + if (!confirm(`Delete bundle "${b.name}" and all its files?`)) return; + await bws.bundleDelete(b.id); + state.mountedBundleIds = state.mountedBundleIds.filter((id) => id !== b.id); + renderBundleList(); + renderMountedBundleChip(); + }; + ul.appendChild(li); + } + } + // Show storage quota + const quota = await bws.storageEstimate(); + const quotaEl = $("#bundles-quota"); + if (quotaEl && quota) { + const usedMb = (quota.usage / (1024 * 1024)).toFixed(1); + const totalMb = (quota.quota / (1024 * 1024)).toFixed(0); + quotaEl.textContent = ` Storage: ${usedMb} MB used of ~${totalMb} MB.`; + } +} + +export function toggleBundleMount(bundleId, mount) { + if (mount) { + if (!state.mountedBundleIds.includes(bundleId)) state.mountedBundleIds.push(bundleId); + } else { + state.mountedBundleIds = state.mountedBundleIds.filter((id) => id !== bundleId); + } + renderMountedBundleChip(); +} + +export async function renderMountedBundleChip() { + const wrap = $("#mounted-bundles-chip"); + if (!wrap) return; + if (!state.mountedBundleIds.length) { + wrap.hidden = true; + wrap.innerHTML = ""; + return; + } + const bundles = await bws.bundleList(); + const mounted = bundles.filter((b) => state.mountedBundleIds.includes(b.id)); + if (!mounted.length) { + wrap.hidden = true; + return; + } + wrap.hidden = false; + wrap.innerHTML = `📎 ${mounted.length} bundle(s) mounted: ${mounted.map((b) => escape(b.name)).join(", ")}`; +} + +export async function openNewBundleDialog() { + const name = prompt("Bundle name?", "Untitled bundle"); + if (!name) return; + const desc = prompt("Short description? (optional)", "") || ""; + const b = await bws.bundleCreate(name, desc); + flash(`Created bundle "${b.name}".`, "good"); + renderBundleList(); +} + +export async function openBundleDialog(bundleId) { + const files = await bws.bundleFiles(bundleId); + const bundles = await bws.bundleList(); + const b = bundles.find((x) => x.id === bundleId); + if (!b) return; + const list = files.map((f) => + `
  • ${escape(f.filename)} (${(f.size/1024).toFixed(0)} KB) +
  • ` + ).join(""); + showDialog({ + title: `${b.name} — ${files.length} file(s)`, + body: ` +

    ${escape(b.description || "")}

    + +
      ${list}
    + `, + actions: [{ label: "Done", role: "close" }], + }); + setTimeout(() => { + document.getElementById("bundle-add-input").addEventListener("change", async (e) => { + for (const f of e.target.files) { + try { await bws.bundleAddFile(bundleId, f); } + catch (err) { flash(friendlyError(err, "adding the file"), "bad"); } + } + closeDialog(); + openBundleDialog(bundleId); + }); + document.querySelectorAll(".btn-remove").forEach((btn) => { + btn.onclick = async () => { + await bws.bundleRemoveFile(btn.dataset.fid); + closeDialog(); + openBundleDialog(bundleId); + }; + }); + }, 50); +} + +// Upload mounted bundles to the transient store and return attachment records. +export async function gatherMountedBundleAttachments() { + if (!state.mountedBundleIds.length || !window.bws) return []; + const chatId = state.currentConversationId || "anon"; + const out = []; + for (const bundleId of state.mountedBundleIds) { + let files; + try { files = await bws.bundleFiles(bundleId); } catch (_) { continue; } + for (const f of files) { + try { + const fd = new FormData(); + fd.append("file", f.blob, f.filename); + const res = await fetch(`/api/uploads/transient?chat_id=${encodeURIComponent(chatId)}`, { + method: "POST", + body: fd, + }); + if (!res.ok) continue; + const a = await res.json(); + out.push({ url: a.url, filename: a.filename, content_type: a.content_type }); + } catch (_) { continue; } + } + } + return out; +} + +// --------------------------------------------------------------------------- +// User memories (IndexedDB-backed; injected via system prompt) +// --------------------------------------------------------------------------- + +state.memoryPaused = false; + +export async function renderMemoryList() { + if (!window.bws) return; + const ul = $("#memory-list"); + if (!ul) return; + const mems = await bws.memoryList(); + if (!mems.length) { + ul.innerHTML = '
  • No memories yet. Add one yourself or wait for BetterWebUI to suggest some.
  • '; + return; + } + ul.innerHTML = ""; + for (const m of mems) { + const li = document.createElement("li"); + li.className = "memory-item mem-" + (m.source || "user"); + li.innerHTML = ` + +
    + ${m.source === "auto_extracted_pending" + ? `` + : ""} + +
    `; + li.querySelector('[data-action="toggle"]').addEventListener("change", async (e) => { + await bws.memoryUpdate(m.id, { enabled: e.target.checked }); + }); + const acceptBtn = li.querySelector('[data-action="accept"]'); + if (acceptBtn) { + acceptBtn.onclick = async () => { + await bws.memoryUpdate(m.id, { source: "auto_extracted_accepted", enabled: true }); + renderMemoryList(); + updateMemoryBell(); + }; + } + li.querySelector('[data-action="delete"]').onclick = async () => { + await bws.memoryDelete(m.id); + renderMemoryList(); + updateMemoryBell(); + }; + ul.appendChild(li); + } +} + +export async function addMemoryFromPrompt() { + const text = prompt("What should I remember about you?"); + if (!text) return; + try { + await bws.memoryAdd({ text, category: "preference", source: "user_added", enabled: true }); + flash("I'll remember that.", "good"); + renderMemoryList(); + } catch (e) { + flash(friendlyError(e, "saving the memory"), "bad"); + } +} + +export async function updateMemoryBell() { + const bell = $("#memory-bell"); + if (!bell || !window.bws) return; + const pending = await bws.memoryPendingCount(); + if (pending > 0) { + bell.hidden = false; + bell.textContent = `🔔 ${pending}`; + bell.title = `${pending} memory candidate(s) pending`; + } else { + bell.hidden = true; + } +} + +export async function suggestMemoryCandidates(userMessage, assistantMessage) { + if (state.memoryPaused || !window.bws) return; + let data; + try { + data = await api("/api/memory/extract", { + method: "POST", + json: { user_message: userMessage, assistant_message: assistantMessage }, + }); + } catch (_) { return; } + const candidates = (data && data.candidates) || []; + for (const c of candidates) { + try { + await bws.memoryAdd({ + text: c.text, + category: c.category || "other", + source: "auto_extracted_pending", + enabled: false, + }); + } catch (_) { continue; } + } + if (candidates.length) { + updateMemoryBell(); + flash(`${candidates.length} memory suggestion(s) ready in the Memory tab.`, "info"); + } +} + +// --------------------------------------------------------------------------- diff --git a/static/js/chat.js b/static/js/chat.js new file mode 100644 index 0000000..7df9880 --- /dev/null +++ b/static/js/chat.js @@ -0,0 +1,364 @@ +// chat.js — Send pipeline and the /api/chat SSE consumption loop (parseSSEBlock itself lives in lib.js). +// Split out of the former static/app.js (Phase 3); logic unchanged. +import { api, b64ToBlob, storeFile } from "./api.js"; +import { gatherMountedBundleAttachments, suggestMemoryCandidates } from "./bundles_memories.js"; +import { renderAttachments } from "./composer.js"; +import { loadConversations } from "./conversations.js"; +import { askApproval, flash, handleFileRequest } from "./dialogs.js"; +import { refreshFileTree, renderPlan, setPlanPaneVisible } from "./panels.js"; +import { appendMessage, renderMessages, renderVerificationTrace } from "./render.js"; +import { $, state } from "./state.js"; + +// Send + SSE chat loop +// --------------------------------------------------------------------------- + +export async function send() { + if (state.busy) return; + const text = $("#composer-input").value.trim(); + if (!text && !state.attachments.length) return; + + let attachments = state.attachments.slice(); + const ws = state.workspaces.find((w) => w.id === state.config?.active_workspace_id); + if (ws && state.messages.length === 0 && Array.isArray(ws.files)) { + attachments = [...ws.files, ...attachments]; + } + + const userMsg = { role: "user", content: text, attachments }; + state.messages.push(userMsg); + appendMessage(userMsg); + $("#composer-input").value = ""; + state.attachments = []; + renderAttachments(); + + const model = $("#chat-model-select").value || ws?.default_model || state.config?.default_model; + if (!model) { + flash("Pick a model first (top of chat or Settings).", "warn"); + return; + } + + const chatMode = $("#mode-select")?.value || state.config?.chat_mode || "approve-each"; + + state.busy = true; + const sendBtn = $("#send-btn"); + sendBtn.disabled = true; + sendBtn.innerHTML = ''; + + // Show a Stop button next to the send button while the request is in flight. + let stopBtn = $("#stop-btn"); + if (!stopBtn) { + stopBtn = document.createElement("button"); + stopBtn.id = "stop-btn"; + stopBtn.type = "button"; + stopBtn.textContent = "Stop"; + stopBtn.className = "stop-btn"; + sendBtn.parentElement.insertBefore(stopBtn, sendBtn.nextSibling); + } + stopBtn.hidden = false; + stopBtn.onclick = () => { + try { state.sendAbortController?.abort(); } catch (_) {} + flash("Stopped.", "info"); + }; + + const placeholder = { role: "assistant", content: "", _placeholder: true }; + state.messages.push(placeholder); + appendMessage(placeholder); + const placeholderEl = $("#messages").lastElementChild; + + // Track active subagent cards for current turn + const subagentSummaries = []; + + try { + const sendable = state.messages.filter( + (m) => !m._placeholder && (m.role === "user" || m.role === "assistant"), + ); + const visionToggle = $("#toggle-vision"); + const webToggle = $("#toggle-websearch"); + const useVision = !!(visionToggle && visionToggle.checked); + const webMode = (webToggle && webToggle.value) || "off"; + + // Collect user memories (if enabled in browser storage and not paused) + let userMemories = []; + if (window.bws && !state.memoryPaused) { + try { + userMemories = await bws.memoryEnabledTexts(state.config?.active_workspace_id || ""); + } catch (_) { userMemories = []; } + } + // Upload mounted file bundles transiently for this turn + let bundleAttachments = []; + try { bundleAttachments = await gatherMountedBundleAttachments(); } catch (_) { bundleAttachments = []; } + + state.sendAbortController = new AbortController(); + const res = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + conversation_id: state.currentConversationId, + messages: sendable, + model, + mode: chatMode, + use_vision: useVision, + web_search_mode: webMode, + user_memories: userMemories, + bundle_attachments: bundleAttachments, + }), + signal: state.sendAbortController.signal, + }); + if (!res.ok) { + const t = await res.text(); + throw new Error(`${res.status}: ${t}`); + } + placeholderEl.remove(); + state.messages = state.messages.filter((m) => !m._placeholder); + + await consumeSSE(res, async (event, data) => { + if (event === "assistant_text") { + const telemetry = data.telemetry || null; + const msg = { role: "assistant", content: data.text, telemetry, subagents: subagentSummaries.slice() }; + state.messages.push(msg); + appendMessage(msg); + if (telemetry) showTelemetryLine(telemetry); + return; + } + if (event === "task_plan") { + state.taskPlan = data.items || []; + renderPlan(); + // Auto-show the plan pane when we get a plan + if (state.taskPlan.length && !state.planPaneVisible) setPlanPaneVisible(true); + return; + } + if (event === "subagent_start") { + const sysMsg = { role: "system-event", content: `↪ Starting ${data.count} ${data.kind} subagent${data.count !== 1 ? "s" : ""}…` }; + state.messages.push(sysMsg); + appendMessage(sysMsg); + return; + } + if (event === "subagent_result") { + subagentSummaries.push({ kind: data.kind, combined: data.combined }); + const sysMsg = { role: "system-event", content: `✓ Subagent (${data.kind}, ${data.count} result${data.count !== 1 ? "s" : ""}) done.` }; + state.messages.push(sysMsg); + appendMessage(sysMsg); + return; + } + if (event === "approval_request") { + const result = await askApproval(data); + await api("/api/approve", { + method: "POST", + json: { + approval_id: data.approval_id, + approved: result.approved !== undefined ? result.approved : result, + trust_session: result.trust_session, + command: result.command, + }, + }); + return; + } + if (event === "file_request") { + await handleFileRequest(data); + return; + } + if (event === "tool_running") { + const human = humanLabelForTool(data.tool); + const sysMsg = { + role: "system-event", + content: data.command ? `${human}: ${data.command}` : human, + }; + state.messages.push(sysMsg); + appendMessage(sysMsg); + return; + } + if (event === "notice") { + flash(data.message); + return; + } + if (event === "tool_call") return; + if (event === "verification") { + renderVerificationTrace(data); + return; + } + if (event === "tool_result") { + await handleToolResult(data); + return; + } + if (event === "done") { + state.currentConversationId = data.conversation_id; + if (Array.isArray(data.messages)) { + state.messages = data.messages; + renderMessages(); + } + if (data.task_plan) { + state.taskPlan = data.task_plan; + renderPlan(); + } + await loadConversations(); + // Trigger memory extraction on the last user+assistant pair. + const lastAssistant = [...state.messages].reverse().find((m) => m.role === "assistant" && m.content); + const lastUser = [...state.messages].reverse().find((m) => m.role === "user" && m.content); + if (lastAssistant && lastUser) { + suggestMemoryCandidates(lastUser.content, lastAssistant.content); + } + // Cache a one-line summary for the resume surface (uses the title + first user message) + if (state.currentConversationId && !state.messages.some((m) => m._hasSummary)) { + const firstUser = state.messages.find((m) => m.role === "user" && m.content); + if (firstUser) { + const rawSummary = (typeof firstUser.content === "string" + ? firstUser.content + : "").slice(0, 120).replace(/\n/g, " ").trim(); + if (rawSummary) { + api(`/api/conversations/${state.currentConversationId}/summary`, { + method: "POST", + json: { summary: rawSummary }, + }).catch(() => {}); + } + } + } + return; + } + if (event === "error") { + // The server sends a structured envelope in data.error + // ({code, message, hint, request_id}); data.message is the legacy + // plain-text field kept for older servers. + const env = data.error || {}; + const human = friendlyError({ message: env.message || data.message || "" }, "running that step"); + const parts = [human]; + if (env.hint) parts.push(env.hint); + if (env.request_id) parts.push(`(ref: ${env.request_id})`); + const sysMsg = { role: "system-event", content: parts.join(" ") }; + state.messages.push(sysMsg); + appendMessage(sysMsg); + flash(human, "warn"); + return; + } + }); + } catch (e) { + placeholderEl?.remove(); + state.messages = state.messages.filter((m) => !m._placeholder); + flash(friendlyError(e, "sending the message"), "warn"); + } finally { + state.busy = false; + state.sendAbortController = null; + const btn = $("#send-btn"); + btn.disabled = false; + btn.textContent = "Send"; + const sb = $("#stop-btn"); if (sb) sb.hidden = true; + } +} + +export function showTelemetryLine(t) { + const el = $("#telemetry-line"); + if (!el) return; + el.hidden = false; + el.textContent = `${t.tokens_in ?? "?"}→${t.tokens_out ?? "?"}t · ${t.elapsed_ms ?? "?"}ms`; + clearTimeout(el._timer); + el._timer = setTimeout(() => { el.hidden = true; }, 8000); +} + +export async function handleToolResult(data) { + const r = data.result || {}; + + // Stash checkpoint info from the most recent write_file so the + // verification card (which arrives moments later) can render an + // "Undo" button without re-plumbing the trace. + if (data.tool === "write_file" && r && r.checkpoint_id && r.filename) { + state.lastWriteCheckpoint = { filename: r.filename, checkpoint_id: r.checkpoint_id }; + } + + // write_file result: backend sends data_b64 only on failure (so the user + // can still recover the bytes via download). On success, the file is on + // disk at project_root and the file-tree pane shows it — no SSE-bloating + // base64 in that path. + const isWriteResult = data.tool === "write_file" && r.filename; + if (isWriteResult || (r.data_b64 && r.filename)) { + const mime = r.mime || "application/octet-stream"; + const label = + mime.startsWith("image/") ? "Image" : + mime.startsWith("audio/") ? "Audio" : + mime.startsWith("video/") ? "Video" : "File"; + const attachments = []; + let content; + if (r.data_b64) { + const blob = b64ToBlob(r.data_b64, mime); + const url = storeFile(blob, r.filename, mime); + attachments.push({ url, content_type: mime, filename: r.filename }); + content = `${label} ready: ${r.filename}`; + if (r.write_error) { + content += `\n⚠️ On-disk write failed: ${r.write_error}. ` + + `You can still download the generated file from the link below.`; + } + } else { + // Successful write with no inlined bytes — point the user at the file + // tree where the saved file now lives. + content = `${label} saved: ${r.filename} (open from the Files pane to view).`; + } + const sysMsg = { role: "tool", content, attachments }; + state.messages.push(sysMsg); + appendMessage(sysMsg); + // Refresh file tree only when the write actually succeeded + if (state.filesPaneVisible && !r.write_error) refreshFileTree(); + return; + } + + if (data.tool === "execute_shell" || data.tool === "cli_call") { + const text = + `Exit ${r.exit_code} (${r.shell || ""}, ${r.duration_ms || 0}ms)\n` + + `--- stdout ---\n${r.stdout || ""}\n` + + (r.stderr ? `--- stderr ---\n${r.stderr}\n` : ""); + const sysMsg = { role: "tool", content: text }; + state.messages.push(sysMsg); + appendMessage(sysMsg); + return; + } + + if (data.tool === "delete_file") { + const content = r.error + ? `Delete failed: ${r.error}` + : `Deleted: ${r.deleted}`; + const sysMsg = { role: "tool", content }; + state.messages.push(sysMsg); + appendMessage(sysMsg); + if (!r.error && state.filesPaneVisible) refreshFileTree(); + return; + } + + if (data.tool === "read_file") { + if (r.error) { + const sysMsg = { role: "system-event", content: r.error }; + state.messages.push(sysMsg); + appendMessage(sysMsg); + return; + } + const lines = (r.files || []).map((f) => `${f.filename} (${f.content_type || "?"}, ${f.size || 0}B)`); + const sysMsg = { role: "tool", content: `Read ${lines.length} file(s):\n${lines.join("\n")}` }; + state.messages.push(sysMsg); + appendMessage(sysMsg); + return; + } + + const sysMsg = { role: "tool", content: JSON.stringify(r, null, 2).slice(0, 3000) }; + state.messages.push(sysMsg); + appendMessage(sysMsg); +} + +export async function consumeSSE(res, onEvent) { + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let idx; + while ((idx = buf.indexOf("\n\n")) !== -1) { + const block = buf.slice(0, idx); + buf = buf.slice(idx + 2); + const { event: eventName, data: dataStr } = parseSSEBlock(block); + if (!dataStr) continue; + try { + await onEvent(eventName, JSON.parse(dataStr)); + } catch (e) { + console.error("SSE handler error", e); + } + } + } +} + +// --------------------------------------------------------------------------- diff --git a/static/js/composer.js b/static/js/composer.js new file mode 100644 index 0000000..9024fc9 --- /dev/null +++ b/static/js/composer.js @@ -0,0 +1,370 @@ +// composer.js — Composer attachments, the image-annotation modal, and voice input. +// Split out of the former static/app.js (Phase 3); logic unchanged. +import { flash } from "./dialogs.js"; +import { $, state } from "./state.js"; + +// Composer / attachments +// --------------------------------------------------------------------------- + +export async function attachFile(file) { + const fd = new FormData(); + fd.append("file", file); + const res = await fetch("/api/upload", { method: "POST", body: fd }); + if (!res.ok) { + const err = new Error(`${res.status}: ${await res.text()}`); + err.status = res.status; + flash(friendlyError(err, "uploading the file"), "warn"); + return; + } + const a = await res.json(); + state.attachments.push(a); + renderAttachments(); +} + +// --------------------------------------------------------------------------- +// Image annotation modal +// --------------------------------------------------------------------------- + +export const _anno = { + attachIdx: -1, + tool: "freehand", + color: "#ff3b30", + size: 3, + strokes: [], + drawing: false, + startX: 0, + startY: 0, + img: null, + offscreen: null, // offscreen canvas with base image only +}; + +export function openAnnotationModal(attachIdx) { + const a = state.attachments[attachIdx]; + if (!a) return; + const modal = $("#annotation-modal"); + if (!modal) return; + _anno.attachIdx = attachIdx; + _anno.strokes = []; + _anno.img = null; + _anno.offscreen = null; + // Load the image + const imgEl = new Image(); + imgEl.onload = () => { + _anno.img = imgEl; + const canvas = $("#annotation-canvas"); + canvas.width = imgEl.naturalWidth; + canvas.height = imgEl.naturalHeight; + const offscreen = document.createElement("canvas"); + offscreen.width = imgEl.naturalWidth; + offscreen.height = imgEl.naturalHeight; + offscreen.getContext("2d").drawImage(imgEl, 0, 0); + _anno.offscreen = offscreen; + _annoRedraw(canvas); + modal.hidden = false; + }; + imgEl.onerror = () => flash("Could not load image for annotation.", "warn"); + if (a.url) { + imgEl.src = a.url; + } else if (a._blob) { + imgEl.src = URL.createObjectURL(a._blob); + } else { + flash("Image data not available for annotation.", "warn"); + return; + } +} + +export function _annoRedraw(canvas) { + const ctx = canvas.getContext("2d"); + if (_anno.offscreen) ctx.drawImage(_anno.offscreen, 0, 0); + for (const s of _anno.strokes) _annoDrawStroke(ctx, s, false); +} + +export function _annoDrawStroke(ctx, s, preview) { + ctx.save(); + ctx.strokeStyle = s.color; + ctx.lineWidth = s.size; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + if (s.tool === "freehand" && s.points && s.points.length > 1) { + ctx.beginPath(); + ctx.moveTo(s.points[0].x, s.points[0].y); + for (let i = 1; i < s.points.length; i++) ctx.lineTo(s.points[i].x, s.points[i].y); + ctx.stroke(); + } else if (s.tool === "rect" && !preview) { + ctx.beginPath(); + ctx.strokeRect(s.x, s.y, s.w, s.h); + } else if (s.tool === "rect" && preview) { + ctx.beginPath(); + ctx.strokeRect(s.x, s.y, s.w, s.h); + } else if (s.tool === "arrow") { + const { x, y, x2, y2 } = s; + const angle = Math.atan2(y2 - y, x2 - x); + const headLen = Math.max(12, s.size * 4); + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x2, y2); + ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI / 6), y2 - headLen * Math.sin(angle - Math.PI / 6)); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6)); + ctx.stroke(); + } + ctx.restore(); +} + +export function _annoCanvasPos(canvas, e) { + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const src = e.touches ? e.touches[0] : e; + return { + x: (src.clientX - rect.left) * scaleX, + y: (src.clientY - rect.top) * scaleY, + }; +} + +export function _initAnnotationCanvas() { + const canvas = $("#annotation-canvas"); + if (!canvas || canvas._annoInited) return; + canvas._annoInited = true; + + const onStart = (e) => { + if (!_anno.img) return; + e.preventDefault(); + _anno.drawing = true; + const pos = _annoCanvasPos(canvas, e); + _anno.startX = pos.x; + _anno.startY = pos.y; + if (_anno.tool === "freehand") { + _anno.strokes.push({ tool: "freehand", color: _anno.color, size: _anno.size, points: [pos] }); + } + }; + const onMove = (e) => { + if (!_anno.drawing || !_anno.img) return; + e.preventDefault(); + const pos = _annoCanvasPos(canvas, e); + const ctx = canvas.getContext("2d"); + _annoRedraw(canvas); + if (_anno.tool === "freehand") { + const stroke = _anno.strokes[_anno.strokes.length - 1]; + stroke.points.push(pos); + _annoDrawStroke(ctx, stroke, true); + } else if (_anno.tool === "rect") { + _annoDrawStroke(ctx, { + tool: "rect", color: _anno.color, size: _anno.size, + x: _anno.startX, y: _anno.startY, + w: pos.x - _anno.startX, h: pos.y - _anno.startY, + }, true); + } else if (_anno.tool === "arrow") { + _annoDrawStroke(ctx, { + tool: "arrow", color: _anno.color, size: _anno.size, + x: _anno.startX, y: _anno.startY, x2: pos.x, y2: pos.y, + }, true); + } + }; + const onEnd = (e) => { + if (!_anno.drawing || !_anno.img) return; + _anno.drawing = false; + const pos = e.changedTouches + ? _annoCanvasPos(canvas, { clientX: e.changedTouches[0].clientX, clientY: e.changedTouches[0].clientY }) + : _annoCanvasPos(canvas, e); + if (_anno.tool === "rect") { + _anno.strokes.push({ + tool: "rect", color: _anno.color, size: _anno.size, + x: _anno.startX, y: _anno.startY, + w: pos.x - _anno.startX, h: pos.y - _anno.startY, + }); + } else if (_anno.tool === "arrow") { + _anno.strokes.push({ + tool: "arrow", color: _anno.color, size: _anno.size, + x: _anno.startX, y: _anno.startY, x2: pos.x, y2: pos.y, + }); + } + _annoRedraw(canvas); + }; + + canvas.addEventListener("mousedown", onStart); + canvas.addEventListener("mousemove", onMove); + canvas.addEventListener("mouseup", onEnd); + canvas.addEventListener("touchstart", onStart, { passive: false }); + canvas.addEventListener("touchmove", onMove, { passive: false }); + canvas.addEventListener("touchend", onEnd); +} + +export async function _applyAnnotation() { + const canvas = $("#annotation-canvas"); + const a = state.attachments[_anno.attachIdx]; + if (!canvas || !a) return; + const blob = await new Promise((res) => canvas.toBlob(res, "image/png")); + if (!blob) { flash("Could not export annotated image.", "warn"); return; } + // Upload to server + const form = new FormData(); + const name = (a.filename || "annotated").replace(/\.[^.]+$/, "") + "_annotated.png"; + form.append("file", blob, name); + try { + const resp = await fetch("/api/upload", { method: "POST", body: form }); + if (!resp.ok) throw new Error(await resp.text()); + const data = await resp.json(); + // Replace the original attachment with the annotated one + state.attachments[_anno.attachIdx] = { + url: data.url, + filename: data.filename || name, + content_type: "image/png", + }; + renderAttachments(); + flash("Annotated image attached.", "good"); + } catch (err) { + flash(friendlyError(err, "uploading annotated image"), "warn"); + } + $("#annotation-modal").hidden = true; +} + +export function _initAnnotationModal() { + _initAnnotationCanvas(); + document.querySelectorAll(".anno-tool-btn").forEach((btn) => { + btn.addEventListener("click", () => { + _anno.tool = btn.dataset.tool; + document.querySelectorAll(".anno-tool-btn").forEach((b) => b.classList.toggle("active", b === btn)); + }); + }); + $("#anno-color")?.addEventListener("input", (e) => { _anno.color = e.target.value; }); + $("#anno-size")?.addEventListener("input", (e) => { _anno.size = +e.target.value; }); + $("#anno-undo-btn")?.addEventListener("click", () => { + _anno.strokes.pop(); + _annoRedraw($("#annotation-canvas")); + }); + $("#anno-clear-btn")?.addEventListener("click", () => { + _anno.strokes = []; + _annoRedraw($("#annotation-canvas")); + }); + $("#anno-apply-btn")?.addEventListener("click", _applyAnnotation); + $("#anno-cancel-btn")?.addEventListener("click", () => { $("#annotation-modal").hidden = true; }); +} + +export async function captureScreenshot() { + let stream; + try { + stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); + } catch (e) { + flash(friendlyError(e, "capturing the screen"), "warn"); + return; + } + try { + const track = stream.getVideoTracks()[0]; + // Briefly wait so the browser has time to render the first frame. + await new Promise((r) => setTimeout(r, 200)); + const settings = track.getSettings(); + const w = settings.width || 1280; + const h = settings.height || 720; + // ImageCapture is the most reliable path in Chromium; fall back to a + // video-element draw for Firefox. + let blob; + if (typeof ImageCapture !== "undefined") { + const cap = new ImageCapture(track); + const bitmap = await cap.grabFrame(); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; canvas.height = bitmap.height; + canvas.getContext("2d").drawImage(bitmap, 0, 0); + blob = await new Promise((r) => canvas.toBlob(r, "image/png")); + } else { + const video = document.createElement("video"); + video.srcObject = stream; + await video.play(); + const canvas = document.createElement("canvas"); + canvas.width = w; canvas.height = h; + canvas.getContext("2d").drawImage(video, 0, 0, w, h); + blob = await new Promise((r) => canvas.toBlob(r, "image/png")); + } + if (!blob) { + flash("Screenshot capture produced no image.", "warn"); + return; + } + const file = new File([blob], `screenshot-${Date.now()}.png`, { type: "image/png" }); + await attachFile(file); + const vis = $("#toggle-vision"); if (vis) vis.checked = true; + flash("Screenshot attached.", "good"); + } finally { + stream.getTracks().forEach((t) => t.stop()); + } +} + +export function renderAttachments() { + const wrap = $("#attachments-preview"); + wrap.innerHTML = ""; + state.attachments.forEach((a, i) => { + const span = document.createElement("span"); + span.className = "pill"; + const isImage = a.content_type && a.content_type.startsWith("image/"); + const annoBtn = isImage + ? ` ` + : ""; + span.innerHTML = `${annoBtn}${escape(a.filename)} `; + span.querySelector(`button[data-rm]`).onclick = () => { + state.attachments.splice(i, 1); + renderAttachments(); + }; + const annoOpenBtn = span.querySelector(".anno-open-btn"); + if (annoOpenBtn) { + annoOpenBtn.onclick = () => openAnnotationModal(i); + } + wrap.appendChild(span); + }); +} + +// --------------------------------------------------------------------------- +// Voice input (SpeechRecognition) +// --------------------------------------------------------------------------- + +export let recognition = null; + +export function initMic() { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + if (!SpeechRecognition) { + const btn = $("#mic-btn"); + if (btn) { btn.hidden = true; } + return; + } + recognition = new SpeechRecognition(); + recognition.continuous = false; + recognition.interimResults = true; + recognition.lang = navigator.language || "en-US"; + + recognition.onresult = (e) => { + let transcript = ""; + for (let i = e.resultIndex; i < e.results.length; i++) { + transcript += e.results[i][0].transcript; + } + const input = $("#composer-input"); + if (input) input.value = transcript; + }; + + recognition.onend = () => { + state.micListening = false; + const btn = $("#mic-btn"); + if (btn) { btn.classList.remove("listening"); btn.setAttribute("aria-pressed", "false"); } + }; + + recognition.onerror = (e) => { + state.micListening = false; + const btn = $("#mic-btn"); + if (btn) { btn.classList.remove("listening"); btn.setAttribute("aria-pressed", "false"); } + if (e.error !== "no-speech") flash("Microphone error: " + e.error, "warn"); + }; +} + +export function toggleMic() { + if (!recognition) { + // Fallback: proxy to OpenWebUI transcription + flash("Voice input is not supported in this browser.", "warn"); + return; + } + const btn = $("#mic-btn"); + if (state.micListening) { + recognition.stop(); + } else { + recognition.start(); + state.micListening = true; + if (btn) { btn.classList.add("listening"); btn.setAttribute("aria-pressed", "true"); } + } +} + +// --------------------------------------------------------------------------- diff --git a/static/js/conversations.js b/static/js/conversations.js new file mode 100644 index 0000000..63d09d4 --- /dev/null +++ b/static/js/conversations.js @@ -0,0 +1,134 @@ +// conversations.js — Conversation list, search, pin, tag, and fork. +// Split out of the former static/app.js (Phase 3); logic unchanged. +import { api } from "./api.js"; +import { renderAttachments } from "./composer.js"; +import { flash } from "./dialogs.js"; +import { renderPlan } from "./panels.js"; +import { renderMessages } from "./render.js"; +import { $, state } from "./state.js"; + +// Conversations: search, pin, tag, fork +// --------------------------------------------------------------------------- + +export async function loadConversations() { + const data = await api("/api/conversations"); + state.conversations = data.conversations || []; + renderConversationList(); +} + +export function renderConversationList() { + const ul = $("#conversation-list"); + ul.innerHTML = ""; + const q = state.convSearchQuery.trim(); + + // When server-side search results are available, render them instead + if (q && state.convSearchResults !== null) { + const results = state.convSearchResults; + if (!results.length) { + ul.innerHTML = ``; + return; + } + for (const r of results) { + const li = document.createElement("li"); + if (r.id === state.currentConversationId) li.classList.add("active"); + li.innerHTML = ` +
    +
    ${escape(r.title || "Untitled")}
    + ${r.snippet ? `
    …${escape(r.snippet)}…
    ` : ""} +
    `; + li.onclick = () => openConversation(r.id); + ul.appendChild(li); + } + return; + } + + // Default: all conversations, client-side tag/title filter + let convs = state.conversations; + if (q) { + const ql = q.toLowerCase(); + convs = convs.filter((c) => + (c.title || "").toLowerCase().includes(ql) || + (c.tags || []).some((t) => t.toLowerCase().includes(ql)) + ); + } + if (!convs.length) { + ul.innerHTML = ``; + return; + } + // Pinned first + convs = [...convs.filter((c) => c.pinned), ...convs.filter((c) => !c.pinned)]; + for (const c of convs) { + const li = document.createElement("li"); + if (c.id === state.currentConversationId) li.classList.add("active"); + const tags = (c.tags || []).map((t) => `${escape(t)}`).join(""); + li.innerHTML = ` +
    +
    ${escape(c.title || "Untitled")}
    +
    + ${c.pinned ? '📌' : ""} + ${tags} +
    +
    +
    + + + +
    `; + li.onclick = (e) => { + const btn = e.target instanceof Element ? e.target.closest("button") : null; + if (btn?.dataset.action === "delete") { e.stopPropagation(); deleteConversation(c.id); return; } + if (btn?.dataset.action === "pin") { e.stopPropagation(); pinConversation(c.id, !c.pinned); return; } + if (btn?.dataset.action === "fork") { e.stopPropagation(); forkConversation(c.id); return; } + openConversation(c.id); + }; + ul.appendChild(li); + } +} + +export async function openConversation(id) { + const conv = await api(`/api/conversations/${id}`); + state.currentConversationId = id; + state.messages = conv.messages || []; + state.taskPlan = conv.task_plan || []; + renderMessages(); + renderPlan(); + renderConversationList(); +} + +export async function deleteConversation(id) { + if (!confirm("Delete this conversation?")) return; + await api(`/api/conversations/${id}`, { method: "DELETE" }); + if (state.currentConversationId === id) newChat(); + await loadConversations(); +} + +export async function pinConversation(id, pin) { + await api(`/api/conversations/${id}/pin`, { method: "POST", json: { pinned: pin } }); + await loadConversations(); +} + +export async function forkConversation(id) { + const conv = await api(`/api/conversations/${id}`); + const msgCount = (conv.messages || []).length; + const forkAt = msgCount > 1 ? msgCount - 1 : msgCount; + const forked = await api(`/api/conversations/${id}/fork`, { + method: "POST", + json: { fork_at: forkAt }, + }); + await loadConversations(); + await openConversation(forked.id); + flash("Forked into a new conversation.", "good"); +} + +export function newChat() { + state.currentConversationId = null; + state.messages = []; + state.attachments = []; + state.taskPlan = []; + renderMessages(); + renderAttachments(); + renderPlan(); + renderConversationList(); +} + +// --------------------------------------------------------------------------- diff --git a/static/js/dialogs.js b/static/js/dialogs.js new file mode 100644 index 0000000..de66c51 --- /dev/null +++ b/static/js/dialogs.js @@ -0,0 +1,288 @@ +// dialogs.js — Modal dialogs: tool approval (with explain/trust), file-request picker, and the generic focus-trapped dialog. +// Split out of the former static/app.js (Phase 3); logic unchanged. +import { api, fileToContentEntry } from "./api.js"; +import { $, state } from "./state.js"; + +// Approval dialog (shell + write) — with explain expander + trust session +// --------------------------------------------------------------------------- + +export async function askApproval(req) { + // write_file: use the diff modal for a proper before/after view + if (req.tool === "write_file") { + return new Promise(async (resolve) => { + const modal = document.getElementById("diff-modal"); + const pathEl = document.getElementById("diff-modal-path"); + const contentEl = document.getElementById("diff-modal-content"); + const acceptBtn = document.getElementById("diff-accept-btn"); + const rejectBtn = document.getElementById("diff-reject-btn"); + if (!modal || !acceptBtn || !rejectBtn) { + resolve({ approved: confirm(`Save file "${req.filename}"?`) }); + return; + } + if (pathEl) pathEl.textContent = req.dest_path || req.filename; + + // Try to load existing content for diff + let oldHtml = "(new file)"; + try { + const existing = await api(`/api/project/file?path=${encodeURIComponent(req.filename)}&include_content=true`); + if (existing.is_binary) { + oldHtml = `(binary file, ${existing.size ?? "?"} bytes — preview not available)`; + } else { + oldHtml = `
    ${escape(existing.content.slice(0, 3000))}
    `; + } + } catch { /* file doesn't exist yet */ } + + if (contentEl) { + contentEl.innerHTML = ` +
    +
    Before${oldHtml}
    +
    After (${req.byte_count} bytes)
    ${escape(req.preview || "")}
    +
    `; + } + // Remember the previously focused element so we can restore focus on close + const previousFocus = document.activeElement; + modal.hidden = false; + acceptBtn.focus(); + // Trap focus inside the diff modal while it's open, matching the + // accessibility behavior of showDialog(). + modal.addEventListener("keydown", trapFocus); + const cleanup = () => { + modal.removeEventListener("keydown", trapFocus); + modal.hidden = true; + state.pendingDialogCancel = null; + if (previousFocus && typeof previousFocus.focus === "function") { + try { previousFocus.focus(); } catch (_) { /* ignore */ } + } + }; + acceptBtn.onclick = () => { cleanup(); resolve({ approved: true }); }; + rejectBtn.onclick = () => { cleanup(); resolve({ approved: false }); }; + // Escape (via global handler) cancels with deny + state.pendingDialogCancel = () => { cleanup(); resolve({ approved: false }); }; + }); + } + + return new Promise((resolve) => { + let title, body; + if (req.tool === "execute_shell") { + title = `Run a ${req.shell} command?`; + body = ` +
    Caution. The assistant wants to run a command on your computer. Read it carefully before approving.
    + ${req.reason ? `

    Why: ${escape(req.reason)}

    ` : ""} +

    Command:

    +
    ${escape(req.command)}
    +
    + Explain this in plain English +
    Loading explanation…
    +
    + + `; + } else if (req.tool === "delete_file") { + title = "Delete a file?"; + body = ` +
    This cannot be undone. The file will be permanently deleted from your workspace.
    + ${req.reason ? `

    Why: ${escape(req.reason)}

    ` : ""} +

    File: ${escape(req.filename)}

    +

    ${escape(req.dest_path || "")}

    + `; + } else { + title = `Allow ${req.tool}?`; + body = `
    ${escape(JSON.stringify(req, null, 2))}
    `; + } + + showDialog({ + title, + body, + actions: [ + { + label: "Deny", + action: () => { + closeDialog(); + state.pendingDialogCancel = null; + resolve({ approved: false }); + }, + }, + { + label: "Approve", + primary: true, + action: async () => { + const trustCb = document.getElementById("trust-session-cb"); + const trustSession = trustCb ? trustCb.checked : false; + // Don't call /api/session/trust here: /api/approve already handles + // trust_session+command atomically, so an early call would trust + // the command even if the approve request later fails. + closeDialog(); + state.pendingDialogCancel = null; + resolve({ approved: true, trust_session: trustSession, command: req.command }); + }, + }, + ], + }); + // Escape cancels with deny so the backend isn't left waiting + state.pendingDialogCancel = () => { + closeDialog(); + resolve({ approved: false }); + }; + + // Wire explain-details toggle. The expander is open by default so + // non-technical users see the explanation without having to know + // to click; we kick the fetch immediately when the dialog opens. + setTimeout(async () => { + const det = document.getElementById("explain-details"); + if (!det || req.tool !== "execute_shell") return; + let explained = false; + const runExplain = async () => { + if (explained) return; + explained = true; + const bodyEl = document.getElementById("explain-body"); + try { + const data = await api("/api/explain-command", { + method: "POST", + json: { command: req.command }, + }); + if (bodyEl) bodyEl.textContent = data.explanation || "No explanation available."; + } catch (e) { + if (bodyEl) bodyEl.textContent = friendlyError(e, "explaining the command"); + } + }; + det.addEventListener("toggle", () => { + if (det.open) runExplain(); + }); + if (det.open) runExplain(); + }, 50); + }); +} + +// --------------------------------------------------------------------------- + +// File-request dialog +// --------------------------------------------------------------------------- + +export async function handleFileRequest(req) { + const filesPicked = await new Promise((resolve) => { + showDialog({ + title: "The assistant would like to read a file", + body: ` +

    ${escape(req.purpose || "Read file(s) from your computer.")}

    +

    Files stay on your computer. The assistant only sees the contents you choose to share.

    + +
    + `, + actions: [ + { + label: "Skip", + action: () => { + closeDialog(); + state.pendingDialogCancel = null; + resolve([]); + }, + }, + ], + }); + const input = $("#file-pick-input"); + const preview = $("#file-pick-preview"); + input.onchange = async () => { + const fs = Array.from(input.files || []); + if (!fs.length) return; + preview.innerHTML = `

    Reading ${fs.length} file${fs.length === 1 ? "" : "s"}…

    `; + const entries = await Promise.all(fs.map(fileToContentEntry)); + closeDialog(); + state.pendingDialogCancel = null; + resolve(entries); + }; + // Escape resolves as "skipped" so the backend isn't left waiting + state.pendingDialogCancel = () => { + closeDialog(); + resolve([]); + }; + }); + + await api("/api/file-response", { + method: "POST", + json: { request_id: req.request_id, files: filesPicked }, + }); +} + +// --------------------------------------------------------------------------- +// Generic dialog (with focus trap) +// --------------------------------------------------------------------------- + +export function showDialog({ title, body, actions, wide }) { + closeDialog(); + const root = $("#dialog-root"); + const wrap = document.createElement("div"); + wrap.className = "dialog-backdrop"; + wrap.setAttribute("role", "alertdialog"); + wrap.setAttribute("aria-modal", "true"); + wrap.setAttribute("aria-label", title); + wrap.innerHTML = ` +
    +

    ${escape(title)}

    +
    ${body}
    +
    +
    `; + const actionsEl = wrap.querySelector(".dialog-actions"); + for (const a of actions) { + const btn = document.createElement("button"); + btn.textContent = a.label; + if (a.primary) btn.classList.add("primary"); + btn.onclick = () => { + if (a.action === "cancel") closeDialog(); + else if (typeof a.action === "function") a.action(); + }; + actionsEl.appendChild(btn); + } + root.appendChild(wrap); + // Focus first button + const firstBtn = wrap.querySelector("button"); + if (firstBtn) firstBtn.focus(); + // Trap focus inside dialog + wrap.addEventListener("keydown", trapFocus); +} + +export function trapFocus(e) { + if (e.key !== "Tab") return; + const focusable = Array.from(e.currentTarget.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + )).filter((el) => !el.disabled && el.offsetParent !== null); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { last.focus(); e.preventDefault(); } + } else { + if (document.activeElement === last) { first.focus(); e.preventDefault(); } + } +} + +export function closeDialog() { + $("#dialog-root").innerHTML = ""; +} + +export function flash(msg, level = "info") { + let host = document.getElementById("toast-root"); + if (!host) { + host = document.createElement("div"); + host.id = "toast-root"; + document.body.appendChild(host); + } + const t = document.createElement("div"); + t.className = `toast ${level}`; + t.textContent = msg; + host.appendChild(t); + t.offsetHeight; + t.classList.add("visible"); + setTimeout(() => { + t.classList.remove("visible"); + setTimeout(() => t.remove(), 250); + }, 3500); +} + +// --------------------------------------------------------------------------- diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..4bdb30e --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,371 @@ +// main.js — Entry point: global keyboard shortcuts, tab wiring, and init(). Loaded via