diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3de5f2e..cfd59f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,13 +78,19 @@ jobs: - name: Verify static JS served run: | - STATUS=$(curl -so /dev/null -w "%{http_code}" http://127.0.0.1:8765/static/app.js) - [ "$STATUS" = "200" ] || (echo "Expected 200, got $STATUS" && exit 1) + for f in lib.js browser-store.js $(cd static/js && ls *.js | sed 's|^|js/|'); do + STATUS=$(curl -so /dev/null -w "%{http_code}" "http://127.0.0.1:8765/static/$f") + [ "$STATUS" = "200" ] || (echo "Expected 200 for $f, got $STATUS" && exit 1) + echo "OK /static/$f" + done - name: Verify static CSS served run: | - STATUS=$(curl -so /dev/null -w "%{http_code}" http://127.0.0.1:8765/static/style.css) - [ "$STATUS" = "200" ] || (echo "Expected 200, got $STATUS" && exit 1) + for f in style.css $(cd static/css && ls *.css | sed 's|^|css/|'); do + STATUS=$(curl -so /dev/null -w "%{http_code}" "http://127.0.0.1:8765/static/$f") + [ "$STATUS" = "200" ] || (echo "Expected 200 for $f, got $STATUS" && exit 1) + echo "OK /static/$f" + done - name: Config API responds run: curl -sf http://127.0.0.1:8765/api/config | python3 -m json.tool @@ -171,29 +177,56 @@ 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. + # types-* stubs are needed because the services./routers. overrides check + # untyped defs, which surfaces import-untyped for yaml (services/storage.py) + # and aiofiles (routers/uploads.py) unless their stubs are installed. + run: pip install ruff==0.6.9 mypy==1.19.1 python-frontmatter types-PyYAML types-aiofiles -r requirements.txt + + - name: ruff (style, imports, pyflakes) + run: ruff check . - - name: pyflakes (syntax / undefined names) - run: python -m pyflakes app.py services/ tests/ scripts/setup_wizard.py + - name: mypy (services/ and routers/ are fully typed; config in pyproject.toml) + run: mypy services/ routers/ - name: Check HTML is valid UTF-8 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/lib.js', encoding='utf-8').read(); print('lib.js OK')" + python3 -c "open('static/browser-store.js', encoding='utf-8').read(); print('browser-store.js OK')" + python3 - <<'PYEOF' + from pathlib import Path + files = sorted(Path('static/js').glob('*.js')) + assert files, 'no modules found under static/js/' + for p in files: + p.read_text(encoding='utf-8') + print(f'{p} OK') + PYEOF - name: Check CSS is valid UTF-8 - run: python3 -c "open('static/style.css', encoding='utf-8').read(); print('style.css OK')" + run: | + python3 - <<'PYEOF' + from pathlib import Path + files = [Path('static/style.css')] + sorted(Path('static/css').glob('*.css')) + assert len(files) >= 6, 'expected the split stylesheets under static/css/' + for p in files: + p.read_text(encoding='utf-8') + print(f'{p} OK') + PYEOF - name: Verify no Python syntax errors run: | python3 -m py_compile app.py && echo "app.py compiles OK" - python3 -m py_compile services/registry.py services/clients.py services/health.py services/routes.py && echo "services/ compiles OK" + python3 -m py_compile services/*.py && echo "services/ compiles OK" + python3 -m py_compile routers/*.py && echo "routers/ compiles OK" python3 -m py_compile scripts/setup_wizard.py && echo "scripts/setup_wizard.py compiles OK" - 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/js/main.js static/lib.js static/css/base.css static/css/layout.css static/css/chat.css static/css/components.css static/css/overlays.css deploy/start.sh deploy/start.bat; do [ -f "$f" ] || (echo "Missing: $f" && exit 1) echo "OK $f" done @@ -219,6 +252,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/Dockerfile b/Dockerfile index f843b45..760b264 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ COPY app.py . COPY verification.py . COPY scheduler.py . COPY services/ services/ +COPY routers/ routers/ COPY static/ static/ COPY skills/ skills/ diff --git a/app.py b/app.py index 0fb12c9..174b6f7 100644 --- a/app.py +++ b/app.py @@ -4,4525 +4,383 @@ visible task plans, file-tree/diff/checkpoints, plan mode, subagents, workspace bundles, conversation search/pinning/forking, per-turn telemetry, onboarding wizard, and accessibility features. + +This module is the composition root: FastAPI init, middleware, exception +handlers, lifespan, static mounts, and router registration. Domain logic +lives in services/ and route handlers in routers/ (Phase 3 decomposition). + +Backwards compatibility: the test suite (and any external caller) may import +helpers from `app` or monkeypatch module attributes like `app.DATA_DIR` and +`app.chat_complete`. Plain re-exports below keep `from app import X` working; +the module-class swap at the bottom forwards attribute *writes* for the +monkeypatched names to the services module that now owns them, so patching +`app.X` still changes the value every handler reads at call time. """ import asyncio -import base64 -import hashlib -import io -import ipaddress import json -import os -import platform -import re -import shutil -import time -import uuid -import zipfile import logging import logging.handlers +import os +import sys +import types +import uuid from contextlib import asynccontextmanager -from pathlib import Path -from typing import Any, AsyncGenerator, Optional +from typing import Any, AsyncGenerator -import aiofiles -import httpx -import yaml -from fastapi import FastAPI, HTTPException, UploadFile, File, Request -from fastapi.responses import FileResponse, StreamingResponse, Response +from fastapi import FastAPI, Request +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel - -import verification as _verification - -ROOT = Path(__file__).parent.resolve() -DATA_DIR = ROOT / "data" -SKILLS_DIR = ROOT / "skills" - -# When BWUI_TEST_MODE=1 the server trims its system prompt and caps model -# output so the test suite can complete in reasonable CI time without a GPU. -_TEST_MODE = os.environ.get("BWUI_TEST_MODE") == "1" -_TEST_MAX_TOKENS = int(os.environ.get("BWUI_TEST_MAX_TOKENS", "30")) - -# Runtime-toggleable mock for UI tests (only active when _TEST_MODE=1). -# Enabled via POST /api/test/mock-chat so the e2e tests (which use a real -# model) can share the same container without being affected. -_mock_chat_enabled: bool = False -_mock_chat_text: str = "Mock response." -UPLOADS_DIR = DATA_DIR / "uploads" -CHECKPOINTS_DIR = DATA_DIR / "checkpoints" -TASKS_DIR = DATA_DIR / "tasks" -CONFIG_PATH = DATA_DIR / "config.json" -PROMPTS_PATH = DATA_DIR / "system_prompts.json" -CONVERSATIONS_PATH = DATA_DIR / "conversations.json" -WORKSPACES_PATH = DATA_DIR / "workspaces.json" -MCP_PATH = DATA_DIR / "mcp_servers.json" -CLI_PATH = DATA_DIR / "cli_tools.json" -BRANDING_PATH = DATA_DIR / "branding.json" -SCHEDULED_TASKS_PATH = DATA_DIR / "scheduled_tasks.json" - -# WORKSPACE_DIR is the default directory for shell execution and file I/O. -# Set via the WORKSPACE_DIR environment variable (Docker mounts a host folder -# here). Falls back to a local "workspace/" subfolder when running without Docker. -WORKSPACE_DIR = Path(os.environ.get("WORKSPACE_DIR", str(ROOT / "workspace"))) - -for d in (DATA_DIR, SKILLS_DIR, UPLOADS_DIR, CHECKPOINTS_DIR, TASKS_DIR, WORKSPACE_DIR): - d.mkdir(parents=True, exist_ok=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", - handlers=[ - logging.handlers.RotatingFileHandler( - _LOG_DIR / "betterwebui.log", - maxBytes=10 * 1024 * 1024, - backupCount=3, - encoding="utf-8", - ), - logging.StreamHandler(), - ], +from starlette.exceptions import HTTPException as StarletteHTTPException + +from routers import ALL_ROUTERS +from services import llm as _llm_module +from services import sse_proxy as _sse_proxy_module +from services import storage as _storage_module +from services import transient as _transient_module + +# --------------------------------------------------------------------------- +# Re-exports (import compatibility). Everything below used to be defined in +# this file; tests and external callers import them from `app`. The objects +# are re-exported by identity, so in-place mutation (approvals.__init__(), +# _session_trusted_commands.clear(), ApprovalState.wait patching, ...) is +# seen by the handlers too. Names that tests REBIND (setattr / mock.patch) +# are deliberately NOT re-exported here — they are served by the forwarding +# properties installed at the bottom of this file instead. +# --------------------------------------------------------------------------- +from services.catalog import ( # noqa: F401 + CLI_REGISTRY, + ENDPOINT_PROFILES, + MCP_REGISTRY, + ONBOARDING_TEMPLATES, ) -logger = logging.getLogger("betterwebui") - - -# --------------------------------------------------------------------------- -# Frontmatter parsing (avoids dependency on specific python-frontmatter API) -# --------------------------------------------------------------------------- - -class _FrontmatterPost: - def __init__(self, meta: dict, content: str) -> None: - self._meta = meta - self.content = content - - def get(self, key: str, default=None): - return self._meta.get(key, default) - - -def _load_frontmatter(path: Path) -> _FrontmatterPost: - text = path.read_text(encoding="utf-8") - lines = text.splitlines(keepends=True) - if not lines or lines[0].rstrip("\r\n") != "---": - return _FrontmatterPost({}, text) - end_idx = None - for i in range(1, len(lines)): - if lines[i].rstrip("\r\n") == "---": - end_idx = i - break - if end_idx is None: - return _FrontmatterPost({}, text) - front_text = "".join(lines[1:end_idx]) - content = "".join(lines[end_idx + 1:]) - try: - raw = yaml.safe_load(front_text) - except yaml.YAMLError: - raw = None - meta = raw if isinstance(raw, dict) else {} - return _FrontmatterPost(meta, content) - - -# --------------------------------------------------------------------------- -# Persistence helpers -# --------------------------------------------------------------------------- - -def load_json(path: Path, default: Any) -> Any: - if not path.exists(): - return default - try: - return json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError: - return default - - -def save_json(path: Path, data: Any) -> None: - path.write_text(json.dumps(data, indent=2), encoding="utf-8") - - -def load_config() -> dict: - return load_json( - CONFIG_PATH, - { - "base_url": "http://localhost:3000", - "api_key": "", - "default_model": "", - "image_model": "", - "tts_voice": "alloy", - "active_prompt_id": "", - "active_skills": [], - "active_workspace_id": "", - "auto_approve_safe": True, - "shell_enabled": True, - "consensus_runs": 1, - "api_profile": None, - "chat_mode": "approve-each", - "onboarding_done": False, - "display": {}, - "verification": { - "enabled": True, - "mode": "validators_only", - "retries": 1, - "judge_model": "", - "judge_confidence_threshold": 0.7, - "tools": { - "generate_image": True, - "generate_audio": True, - "autogui_task": True, - "execute_shell": False, - "write_file": True, - "mcp_call": False, - }, - }, - "web_search": { - "provider": "", # "tavily" | "brave" | "serpapi" | "custom" | "" - "api_key": "", - "custom_url": "", - }, - }, - ) - +from services.errors import code_for_status, error_envelope +from services.llm import ( # noqa: F401 + CONTEXT_TOKEN_LIMIT, + active_profile, + discover_profile, + fetch_models, + normalize_base_url, + to_openai_messages, + trim_to_context, +) +from services.mcp import MCPManager, MCPStdioClient, mcp_manager # noqa: F401 +from services.prompt_builder import ( # noqa: F401 + PLAN_MODE_BLOCK, + RENDERING_PROTOCOL, + TOOL_PROTOCOL, + build_system_prompt, + extract_tool_call, + resolve_active_workspace, +) +from services.request_ctx import RequestIdFilter, request_id_ctx, request_id_of +from services.scheduled import ( # noqa: F401 + _emit_scheduled_notification, + _run_scheduled_task, + _scheduled_notifications, +) +from services.session import ( # noqa: F401 + ApprovalState, + FileResponseStore, + _command_explanation_cache, + _is_local_caller, + _require_local_caller, + _session_trusted_commands, + approvals, + file_responses, +) +from services.skills import ( # noqa: F401 + _lint_cli, + _lint_mcp, + _lint_skills, + list_skill_files, + load_skill_content, +) +from services.storage import ( # noqa: F401 + ROOT, + _FrontmatterPost, + _load_frontmatter, + load_cli_tools, + load_config, + load_conversations, + load_json, + load_mcp_servers, + load_prompts, + load_workspaces, + save_conversation, + save_json, +) +from services.tools import ( # noqa: F401 + _checkpoint_file, + _get_checkpoint, + _list_checkpoints, + _resolve_project_root, + _resolve_project_root_info, + _should_skip_approval, + _slug, + call_fetch_url, + call_openwebui_audio, + call_openwebui_image, + call_web_search, + detect_shell, + execute_tool, + run_shell, + run_subagent_loop, + validate_image_bytes, +) +from services.transient import _sweep_transient_uploads, _transient_root # noqa: F401 # --------------------------------------------------------------------------- -# OpenWebUI endpoint discovery +# Logging # --------------------------------------------------------------------------- -ENDPOINT_PROFILES: list[dict] = [ - { - "name": "openwebui", - "label": "OpenWebUI native", - "models": "/api/models", - "chat": "/api/chat/completions", - "images": "/api/v1/images/generations", - "audio": "/api/v1/audio/speech", - "transcribe": "/api/v1/audio/transcriptions", - }, - { - "name": "openwebui-openai", - "label": "OpenWebUI OpenAI proxy", - "models": "/openai/v1/models", - "chat": "/openai/v1/chat/completions", - "images": "/openai/v1/images/generations", - "audio": "/openai/v1/audio/speech", - "transcribe": "/openai/v1/audio/transcriptions", - }, - { - "name": "openai-v1", - "label": "OpenAI-compatible (/v1)", - "models": "/v1/models", - "chat": "/v1/chat/completions", - "images": "/v1/images/generations", - "audio": "/v1/audio/speech", - "transcribe": "/v1/audio/transcriptions", - }, - { - "name": "api-v1", - "label": "API v1 (/api/v1)", - "models": "/api/v1/models", - "chat": "/api/v1/chat/completions", - "images": "/api/v1/images/generations", - "audio": "/api/v1/audio/speech", - "transcribe": "/api/v1/audio/transcriptions", - }, -] - - -def normalize_base_url(url: str) -> str: - if not url: - return "" - url = url.strip().rstrip("/") - for suffix in ("/api/v1", "/openai/v1", "/api", "/v1", "/openai"): - if url.endswith(suffix): - url = url[: -len(suffix)] - break - return url.rstrip("/") - - -async def discover_profile(base: str, api_key: str) -> Optional[dict]: - if not base: - return None - headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} - async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: - for profile in ENDPOINT_PROFILES: - try: - resp = await client.get(f"{base}{profile['models']}", headers=headers) - except httpx.HTTPError: - continue - if resp.status_code != 200: - continue - try: - body = resp.json() - except (json.JSONDecodeError, ValueError): - continue - raw = body.get("data") if isinstance(body, dict) else body - if isinstance(raw, list) and raw: - return profile - return None - - -def active_profile(config: dict) -> dict: - profile = config.get("api_profile") - if isinstance(profile, dict) and "models" in profile: - return profile - return ENDPOINT_PROFILES[0] - - -def load_prompts() -> dict: - return load_json( - PROMPTS_PATH, - { - "prompts": [ - { - "id": "default", - "name": "Helpful Assistant", - "content": ( - "You are a helpful, friendly assistant for a faculty " - "member in higher education. Be clear, concise, and " - "patient. When asked to do something on their computer, " - "use available tools." - ), - } - ] - }, - ) - - -def load_conversations() -> dict: - return load_json(CONVERSATIONS_PATH, {"conversations": {}}) - - -def load_workspaces() -> dict: - return load_json(WORKSPACES_PATH, {"workspaces": []}) - - -def load_mcp_servers() -> dict: - return load_json(MCP_PATH, {"servers": []}) - - -def load_cli_tools() -> dict: - return load_json(CLI_PATH, {"tools": []}) +_LOG_DIR = ROOT / "logs" -# --------------------------------------------------------------------------- -# MCP server registry -# --------------------------------------------------------------------------- +def _configure_logging() -> None: + """Configure process-wide logging exactly once, at import time. -MCP_REGISTRY: list[dict] = [ - { - "id": "filesystem", - "name": "Filesystem", - "description": "Read and write files within a chosen directory.", - "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem", - "command": "npx", - "args_template": ["-y", "@modelcontextprotocol/server-filesystem", "{root_path}"], - "fields": [ - {"name": "root_path", "label": "Root directory the assistant may access", "type": "path"} - ], - "requires": "Node.js (npm/npx).", - }, - { - "id": "github", - "name": "GitHub", - "description": "Browse repositories, search code, manage issues and pull requests.", - "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/github", - "command": "npx", - "args_template": ["-y", "@modelcontextprotocol/server-github"], - "env_template": {"GITHUB_PERSONAL_ACCESS_TOKEN": "{token}"}, - "fields": [ - {"name": "token", "label": "GitHub personal access token", "type": "password"} - ], - "requires": "Node.js (npm/npx) and a GitHub PAT.", - }, - { - "id": "fetch", - "name": "Fetch", - "description": "Retrieve and convert web pages into structured text the assistant can read.", - "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch", - "command": "uvx", - "args_template": ["mcp-server-fetch"], - "fields": [], - "requires": "Python with uv installed (https://docs.astral.sh/uv/).", - }, - { - "id": "brave-search", - "name": "Brave Search", - "description": "Search the web via Brave's API.", - "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/brave-search", - "command": "npx", - "args_template": ["-y", "@modelcontextprotocol/server-brave-search"], - "env_template": {"BRAVE_API_KEY": "{api_key}"}, - "fields": [ - {"name": "api_key", "label": "Brave API key", "type": "password"} - ], - "requires": "Node.js plus a Brave Search API key.", - }, - { - "id": "memory", - "name": "Memory", - "description": "A persistent knowledge graph the assistant can read from and write to across chats.", - "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory", - "command": "npx", - "args_template": ["-y", "@modelcontextprotocol/server-memory"], - "fields": [], - "requires": "Node.js (npm/npx).", - }, - { - "id": "git", - "name": "Git", - "description": "Read and search a local Git repository's history and contents.", - "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/git", - "command": "uvx", - "args_template": ["mcp-server-git", "--repository", "{repo_path}"], - "fields": [ - {"name": "repo_path", "label": "Path to a Git repository", "type": "path"} - ], - "requires": "Python with uv installed.", - }, - { - "id": "sequential-thinking", - "name": "Sequential Thinking", - "description": "Lets the assistant break problems into stepped thoughts before answering.", - "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking", - "command": "npx", - "args_template": ["-y", "@modelcontextprotocol/server-sequential-thinking"], - "fields": [], - "requires": "Node.js (npm/npx).", - }, - { - "id": "time", - "name": "Time", - "description": "Provides accurate current time and timezone conversion.", - "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/time", - "command": "uvx", - "args_template": ["mcp-server-time"], - "fields": [], - "requires": "Python with uv installed.", - }, - # ---- Cloud services (community-maintained MCP servers) ---- - { - "id": "gdrive", - "name": "Google Drive", - "description": "Browse, search, and read files from Google Drive.", - "homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/gdrive", - "command": "npx", - "args_template": ["-y", "@modelcontextprotocol/server-gdrive"], - "env_template": { - "GDRIVE_CREDENTIALS_PATH": "{credentials_path}", - }, - "fields": [ - {"name": "credentials_path", "label": "Path to gcp-oauth.keys.json", "type": "path"}, - ], - "requires": "Node.js plus a Google Cloud OAuth credentials JSON. Run the server once interactively to mint a refresh token.", - "category": "cloud", - }, - { - "id": "google-workspace", - "name": "Google Workspace", - "description": "Read Gmail, manage Google Calendar events, and search Drive in one server.", - "homepage": "https://github.com/taylorwilsdon/google_workspace_mcp", - "command": "uvx", - "args_template": ["google-workspace-mcp"], - "env_template": { - "GOOGLE_OAUTH_CLIENT_ID": "{client_id}", - "GOOGLE_OAUTH_CLIENT_SECRET": "{client_secret}", - }, - "fields": [ - {"name": "client_id", "label": "Google OAuth client ID", "type": "text"}, - {"name": "client_secret", "label": "Google OAuth client secret", "type": "password"}, - ], - "requires": "Python with uv installed plus a Google Cloud OAuth client. Follow the server's README for the consent-screen setup.", - "category": "cloud", - }, - { - "id": "microsoft-graph", - "name": "Microsoft 365 (Graph)", - "description": "Outlook mail, calendar, OneDrive, SharePoint, and Teams via Microsoft Graph.", - "homepage": "https://github.com/softeria/ms-365-mcp-server", - "command": "npx", - "args_template": ["-y", "@softeria/ms-365-mcp-server"], - "env_template": { - "MS365_MCP_CLIENT_ID": "{client_id}", - "MS365_MCP_TENANT_ID": "{tenant_id}", - }, - "fields": [ - {"name": "client_id", "label": "Azure AD app client ID", "type": "text"}, - {"name": "tenant_id", "label": "Tenant ID (or 'common')", "type": "text"}, - ], - "requires": "Node.js plus an Azure AD app registration with Microsoft Graph delegated permissions.", - "category": "cloud", - }, - { - "id": "slack", - "name": "Slack", - "description": "Read channels, post messages, search history.", - "homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/slack", - "command": "npx", - "args_template": ["-y", "@modelcontextprotocol/server-slack"], - "env_template": { - "SLACK_BOT_TOKEN": "{bot_token}", - "SLACK_TEAM_ID": "{team_id}", - }, - "fields": [ - {"name": "bot_token", "label": "Slack bot token (xoxb-...)", "type": "password"}, - {"name": "team_id", "label": "Slack team / workspace ID", "type": "text"}, - ], - "requires": "Node.js plus a Slack app installed in your workspace with the required scopes.", - "category": "cloud", - }, - { - "id": "notion", - "name": "Notion", - "description": "Search, read, and update Notion pages and databases.", - "homepage": "https://github.com/makenotion/notion-mcp-server", - "command": "npx", - "args_template": ["-y", "@notionhq/notion-mcp-server"], - "env_template": { - "OPENAPI_MCP_HEADERS": "{headers_json}", - }, - "fields": [ - {"name": "headers_json", "label": "Headers JSON (e.g. {\"Authorization\":\"Bearer ntn_...\",\"Notion-Version\":\"2022-06-28\"})", "type": "password"}, - ], - "requires": "Node.js plus a Notion integration token with workspace access.", - "category": "cloud", - }, - { - "id": "linear", - "name": "Linear", - "description": "Browse and update Linear issues, projects, and cycles.", - "homepage": "https://github.com/jerhadf/linear-mcp-server", - "command": "npx", - "args_template": ["-y", "linear-mcp-server"], - "env_template": { - "LINEAR_API_KEY": "{api_key}", - }, - "fields": [ - {"name": "api_key", "label": "Linear personal API key", "type": "password"}, - ], - "requires": "Node.js plus a Linear API key from Settings → API.", - "category": "cloud", - }, - { - "id": "asana", - "name": "Asana", - "description": "Read and update Asana tasks, projects, and workspaces.", - "homepage": "https://github.com/cristip73/mcp-server-asana", - "command": "npx", - "args_template": ["-y", "@cristip73/mcp-server-asana"], - "env_template": { - "ASANA_ACCESS_TOKEN": "{access_token}", - }, - "fields": [ - {"name": "access_token", "label": "Asana personal access token", "type": "password"}, - ], - "requires": "Node.js plus an Asana personal access token from My Settings → Apps.", - "category": "cloud", - }, - { - "id": "jira", - "name": "Jira", - "description": "Search, read, and update Jira issues.", - "homepage": "https://github.com/sooperset/mcp-atlassian", - "command": "uvx", - "args_template": ["mcp-atlassian"], - "env_template": { - "JIRA_URL": "{jira_url}", - "JIRA_USERNAME": "{username}", - "JIRA_API_TOKEN": "{api_token}", - }, - "fields": [ - {"name": "jira_url", "label": "Jira base URL (e.g. https://acme.atlassian.net)", "type": "text"}, - {"name": "username", "label": "Atlassian account email", "type": "text"}, - {"name": "api_token", "label": "Atlassian API token", "type": "password"}, + - Level comes from BWUI_LOG_LEVEL (default INFO) so operators can turn on + DEBUG without code changes. + - Two handlers: a rotating file under logs/ and stderr. + - The request-id filter is attached to every root handler, so the rid= + field is stamped consistently on all records — request-path records get + the middleware-assigned id (background tasks log rid=- since they run + outside any request context). + All betterwebui.* loggers propagate to root; none add their own handlers. + """ + _LOG_DIR.mkdir(parents=True, exist_ok=True) + level_name = os.environ.get("BWUI_LOG_LEVEL", "INFO").upper() + level = getattr(logging, level_name, None) + if not isinstance(level, int): + level = logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(name)s [rid=%(request_id)s]: %(message)s", + handlers=[ + logging.handlers.RotatingFileHandler( + _LOG_DIR / "betterwebui.log", + maxBytes=10 * 1024 * 1024, + backupCount=3, + encoding="utf-8", + ), + logging.StreamHandler(), ], - "requires": "Python with uv installed plus an Atlassian API token.", - "category": "cloud", - }, -] - - -# --------------------------------------------------------------------------- -# CLI shortcuts registry -# --------------------------------------------------------------------------- - -CLI_REGISTRY: list[dict] = [ - { - "id": "git", - "name": "git", - "description": "Version control. Show status, diff, log, and history.", - "command_template": "git {args}", - "examples": ["git status", "git log --oneline -20", "git diff"], - }, - { - "id": "gh", - "name": "GitHub CLI", - "description": "Operate on GitHub repos, PRs, and issues from the terminal.", - "command_template": "gh {args}", - "examples": ["gh pr list", "gh issue view 123"], - }, - { - "id": "pandoc", - "name": "pandoc", - "description": "Convert documents between formats — markdown, docx, pdf, html, latex.", - "command_template": "pandoc {args}", - "examples": ["pandoc input.md -o output.docx", "pandoc paper.docx -o paper.pdf"], - }, - { - "id": "ffmpeg", - "name": "ffmpeg", - "description": "Convert and process audio/video files.", - "command_template": "ffmpeg {args}", - "examples": ["ffmpeg -i talk.mov -vn talk.mp3"], - }, - { - "id": "yt-dlp", - "name": "yt-dlp", - "description": "Download videos and audio from sites like YouTube, Vimeo, etc.", - "command_template": "yt-dlp {args}", - "examples": ["yt-dlp -x --audio-format mp3 'URL'"], - }, - { - "id": "sqlite3", - "name": "sqlite3", - "description": "Inspect and query SQLite databases.", - "command_template": "sqlite3 {args}", - "examples": ["sqlite3 grades.db '.tables'"], - }, - { - "id": "rg", - "name": "ripgrep", - "description": "Fast recursive search through text files.", - "command_template": "rg {args}", - "examples": ["rg 'TODO' src/"], - }, - { - "id": "curl", - "name": "curl", - "description": "Fetch URLs from the web.", - "command_template": "curl {args}", - "examples": ["curl -fsSL https://example.com"], - }, -] - -# Onboarding workspace templates (use-case presets) -ONBOARDING_TEMPLATES: list[dict] = [ - { - "id": "grading", - "name": "Grading", - "description": "Grade and give feedback on student work.", - "system_prompt": ( - "You are a grading assistant for a higher-ed instructor. " - "Be constructive, specific, and aligned with the rubric provided. " - "Use load_skill to load the grading-rubric skill when grading." - ), - "skills": ["grading-rubric"], - "cli": [], - "mcp": [], - }, - { - "id": "research", - "name": "Research", - "description": "Find sources, summarize papers, manage citations.", - "system_prompt": ( - "You are a research assistant for an academic. Help find, summarize, " - "and cite sources. Use load_skill for research-citations." - ), - "skills": ["research-citations"], - "cli": [], - "mcp": ["fetch", "brave-search"], - }, - { - "id": "course-prep", - "name": "Course Prep", - "description": "Write syllabi, slides, lecture notes, and assignments.", - "system_prompt": ( - "You are a course-preparation assistant. Help draft syllabi, " - "lesson plans, slides, and assignments." - ), - "skills": [], - "cli": ["pandoc"], - "mcp": [], - }, - { - "id": "writing", - "name": "Writing", - "description": "Draft, edit, and polish academic or professional writing.", - "system_prompt": ( - "You are a writing coach and editor. Help draft, revise, " - "and polish documents." - ), - "skills": [], - "cli": ["pandoc"], - "mcp": [], - }, - { - "id": "coding", - "name": "Coding", - "description": "Write, debug, and explain code.", - "system_prompt": ( - "You are a coding assistant. Help write, debug, and explain code. " - "Use the computer-helper skill for running commands on the user's machine." - ), - "skills": ["computer-helper"], - "cli": ["git", "rg"], - "mcp": ["filesystem", "git"], - }, -] - - -# --------------------------------------------------------------------------- -# MCP stdio client -# --------------------------------------------------------------------------- - -class MCPStdioClient: - def __init__(self, name: str, command: str, args: list[str], env: dict | None = None): - self.name = name - self.command = command - self.args = list(args or []) - self.env = dict(env or {}) - self.proc: Optional[asyncio.subprocess.Process] = None - self.tools: list[dict] = [] - self._next_id = 0 - self._read_lock = asyncio.Lock() - self.error: Optional[str] = None - - async def start(self, timeout: float = 30.0) -> None: - env = {**os.environ, **self.env} - try: - self.proc = await asyncio.create_subprocess_exec( - self.command, - *self.args, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=env, - ) - except FileNotFoundError as exc: - self.error = f"Command not found: {self.command}. {exc}" - return - try: - await asyncio.wait_for(self._handshake(), timeout=timeout) - except asyncio.TimeoutError: - self.error = "MCP server did not respond to initialize within 30s." - await self.stop() - except Exception as exc: - self.error = f"MCP handshake failed: {exc}" - await self.stop() - - async def _handshake(self) -> None: - await self._call("initialize", { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "BetterWebUI", "version": "0.2"}, - }) - await self._notify("notifications/initialized", {}) - result = await self._call("tools/list", {}) - self.tools = result.get("tools", []) if isinstance(result, dict) else [] - - async def _send(self, message: dict) -> None: - if not self.proc or not self.proc.stdin: - raise RuntimeError("MCP server is not running.") - line = (json.dumps(message) + "\n").encode("utf-8") - self.proc.stdin.write(line) - await self.proc.stdin.drain() - - async def _read_one(self) -> dict: - async with self._read_lock: - assert self.proc and self.proc.stdout - while True: - line = await self.proc.stdout.readline() - if not line: - raise RuntimeError("MCP server closed unexpectedly.") - try: - return json.loads(line) - except json.JSONDecodeError: - continue - - async def _call(self, method: str, params: dict) -> dict: - self._next_id += 1 - req_id = self._next_id - await self._send({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}) - while True: - msg = await self._read_one() - if msg.get("id") == req_id: - if "error" in msg: - err = msg["error"] - raise RuntimeError(err.get("message") if isinstance(err, dict) else str(err)) - return msg.get("result") or {} - - async def _notify(self, method: str, params: dict) -> None: - await self._send({"jsonrpc": "2.0", "method": method, "params": params}) - - async def call_tool(self, tool_name: str, arguments: dict) -> dict: - return await self._call("tools/call", {"name": tool_name, "arguments": arguments}) - - async def stop(self) -> None: - if not self.proc: - return - try: - self.proc.terminate() - try: - await asyncio.wait_for(self.proc.wait(), timeout=3) - except asyncio.TimeoutError: - self.proc.kill() - await self.proc.wait() - except ProcessLookupError: - pass - self.proc = None - - -class MCPManager: - def __init__(self) -> None: - self.clients: dict[str, MCPStdioClient] = {} - - async def reconcile(self) -> None: - cfg = load_mcp_servers() - wanted = {s["name"]: s for s in cfg.get("servers", []) if s.get("enabled", True)} - for name in list(self.clients): - if name not in wanted: - await self.clients[name].stop() - del self.clients[name] - for name, s in wanted.items(): - if name in self.clients: - continue - env = dict(s.get("env") or {}) - # Substitute OAuth access tokens: {oauth.google.access_token} etc. - try: - from services.oauth import get_oauth_token as _get_oauth_tok - for k, v in list(env.items()): - if "{oauth." in str(v): - for provider in ("google", "microsoft"): - placeholder = f"{{oauth.{provider}.access_token}}" - if placeholder in str(v): - 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 - client = MCPStdioClient( - name=name, - command=s.get("command", ""), - args=s.get("args", []), - env=env, - ) - await client.start() - self.clients[name] = client - - def status(self) -> list[dict]: - out = [] - for name, client in self.clients.items(): - out.append({ - "name": name, - "running": client.proc is not None and client.error is None, - "error": client.error, - "tool_count": len(client.tools), - "tools": [ - {"name": t.get("name"), "description": t.get("description", "")} - for t in client.tools - ], - }) - return out - - def list_all_tools(self, allowed_servers: Optional[list[str]] = None) -> list[dict]: - out = [] - for name, client in self.clients.items(): - if allowed_servers is not None and name not in allowed_servers: - continue - for t in client.tools: - out.append({ - "server": name, - "name": t.get("name"), - "description": t.get("description", ""), - }) - return out - - async def call(self, server_name: str, tool_name: str, args: dict) -> dict: - client = self.clients.get(server_name) - if not client: - return {"error": f"MCP server '{server_name}' is not running."} - if client.error: - return {"error": f"MCP server '{server_name}' error: {client.error}"} - try: - result = await client.call_tool(tool_name, args) - except Exception as exc: - return {"error": f"MCP call failed: {exc}"} - return result if isinstance(result, dict) else {"result": result} - - -mcp_manager = MCPManager() - -# --------------------------------------------------------------------------- -# Session-level in-memory stores -# --------------------------------------------------------------------------- - -# Commands trusted for the duration of this server session -_session_trusted_commands: set[str] = set() - -# Explanation cache keyed by command hash -_command_explanation_cache: dict[str, str] = {} - -# --------------------------------------------------------------------------- -# Skill loading -# --------------------------------------------------------------------------- - -def list_skill_files() -> list[dict]: - skills = [] - for path in sorted(SKILLS_DIR.glob("*.md")): - try: - post = _load_frontmatter(path) - skills.append({ - "id": path.stem, - "name": post.get("name", path.stem), - "description": post.get("description", ""), - "filename": path.name, - }) - except Exception as exc: - skills.append({ - "id": path.stem, - "name": path.stem, - "description": f"(could not parse: {exc})", - "filename": path.name, - }) - return skills - - -def load_skill_content(skill_id: str) -> Optional[dict]: - path = SKILLS_DIR / f"{skill_id}.md" - if not path.exists(): - return None - post = _load_frontmatter(path) - return { - "id": skill_id, - "name": post.get("name", skill_id), - "description": post.get("description", ""), - "content": post.content, - } - - -def _lint_skills() -> list[dict]: - issues = [] - for path in sorted(SKILLS_DIR.glob("*.md")): - try: - post = _load_frontmatter(path) - if not post.get("name"): - issues.append({"type": "skill", "id": path.stem, "issue": "Missing 'name' in frontmatter"}) - if not post.get("description"): - issues.append({"type": "skill", "id": path.stem, "issue": "Missing 'description' in frontmatter"}) - except Exception as exc: - issues.append({"type": "skill", "id": path.stem, "issue": f"Parse error: {exc}"}) - return issues - - -def _lint_mcp() -> list[dict]: - issues = [] - # Suggest what a missing binary usually means so the user knows how to fix it - hint_by_bin = { - "npx": "install Node.js (provides npx)", - "node": "install Node.js", - "uvx": "install uv (provides uvx)", - "uv": "install uv", - } - for s in load_mcp_servers().get("servers", []): - if not s.get("command"): - issues.append({"type": "mcp", "id": s.get("name", "?"), "issue": "Missing 'command'"}) - continue - cmd = s.get("command", "") - # Validate the exact configured binary, not an alternative - if cmd and not shutil.which(cmd): - hint = hint_by_bin.get(cmd) - msg = f"'{cmd}' not found on PATH" - if hint: - msg += f" — {hint}" - issues.append({"type": "mcp", "id": s["name"], "issue": msg}) - return issues - - -def _lint_cli() -> list[dict]: - issues = [] - for c in load_cli_tools().get("tools", []): - if "{args}" not in c.get("command_template", ""): - issues.append({"type": "cli", "id": c.get("id", "?"), "issue": "command_template does not contain {args}"}) - return issues - - -# --------------------------------------------------------------------------- -# Tool definitions and system-prompt builders -# --------------------------------------------------------------------------- - -TOOL_PROTOCOL = """ -You have tools. To call a tool, output exactly one fenced JSON block on its -own lines like this: - -```tool -{"tool": "TOOL_NAME", "args": {...}} -``` - -After the tool runs, the result is added to the conversation and you continue. -Output at most one tool call per assistant turn. Speak naturally to the user -before and after tool calls. Never invent tool output — wait for the result. - -Available tools: - -- update_task_plan: update the visible task plan the user sees in the right-hand - panel. Call this when starting ANY multi-step task (to lay out the steps) and - after each step (to tick items done, mark in_progress, or flag blocked). - ALWAYS start a complex task by calling this first. - Args: {"items": [{"id": "step-1", "title": "Step description", - "status": "pending|in_progress|done|blocked", "note": "optional detail"}]}. - -- spawn_subagent: run up to 3 read-only parallel sub-tasks to research, - compare, or explore. Useful for "compare these rubrics", "research these - topics", "check these files". The main conversation pauses until all - subagents finish, then you get a combined summary. - Args: {"kind": "explore|compare", "prompt": "what to investigate", - "items": ["item1", "item2"] (optional for compare)}. - -- execute_shell: run a shell command on the user's computer. The host OS is - detected automatically (bash on Linux/macOS, PowerShell on Windows). USER - APPROVAL IS REQUIRED before the command runs — if denied, you'll see an - error and should ask the user what they'd prefer. Args: {"command": "...", - "reason": "short explanation of why this command is needed"}. - GRAPH / PLOT CONVENTION: if you run a Python script that generates a plot, - save it to /tmp/bwui_plot.png (e.g. plt.savefig('/tmp/bwui_plot.png', - bbox_inches='tight')) instead of plt.show(). The image is then - automatically captured and displayed inline in the chat. - -- read_file: read file(s) chosen by the user. The user is shown a file - picker — you do NOT specify a path. The result is the chosen file(s)' name, - type, and content. Args: {"reason": "why you need to read", "accept": "*", - "multiple": false}. Use accept="image/*" or "text/*,.md,.csv" to filter. - -- write_file: write a file to the workspace project folder (REQUIRES - APPROVAL unless mode is "trusted"). The file lands at - / — falling back to the server's WORKSPACE_DIR - when the workspace has no project_root configured. Any pre-existing - file at that path is snapshotted into checkpoints before being - overwritten, so the user can revert from the UI. On success the file - is visible in the Files pane; data_b64 is only returned when the - on-disk write fails so the user can still retrieve the content. - Args: {"filename": "name.ext", "content": - "...", "mime": "text/plain"}. - -- delete_file: permanently delete a file from the workspace project folder - (REQUIRES APPROVAL — the user must confirm before the file is removed). - Args: {"filename": "name.ext", "reason": "why this file should be deleted"}. - -- load_skill: load the full content of a named skill so you can follow its - instructions. Args: {"skill_id": "..."}. Use this when a listed skill - matches the user's request. - -- generate_image: create an image via OpenWebUI's image endpoint. The image - is sent to the user and downloaded to their computer automatically. Args: - {"prompt": "description", "size": "1024x1024"}. - -- generate_audio: text-to-speech via OpenWebUI. The audio is sent to the - user and downloaded automatically. Args: {"text": "...", "voice": "alloy"}. - -- mcp_call: call a tool from a connected MCP server (only available if - servers are configured and running). Args: {"server": "server_name", - "name": "tool_name", "arguments": {...}}. - -- cli_call: run one of the user's pre-registered CLI shortcuts. Routes - through execute_shell with approval (unless the shortcut has always-allow - policy). Args: {"id": "shortcut_id", "args": "command-line arguments"}. - -- web_search: search the public web. Use only when the user has enabled - web search for this turn (the system prompt will say so). Args: - {"query": "...", "max_results": 5}. Returns a list of - {title, url, snippet} items. - -- fetch_url: download and extract the readable text content of a web page. - Useful after web_search to read the full article. Requires user approval - unless chat mode is trusted. Args: {"url": "https://..."}. - Returns {url, title, text, word_count} or {error: "..."}. -""".strip() - -PLAN_MODE_BLOCK = """ -⚠️ PLAN MODE IS ACTIVE. - -You may ONLY call: update_task_plan, read_file, load_skill. -Do NOT call any side-effecting tool: execute_shell, write_file, -generate_image, generate_audio, cli_call, mcp_call. - -Your job in plan mode is to: -1. Use update_task_plan to lay out a complete step-by-step plan. -2. Explain your approach clearly in plain English. -3. Tell the user to switch to "Approve-each" mode (the chip in the chat header) - when they are ready to execute. - -Do NOT execute anything — plan only. -""".strip() - -RENDERING_PROTOCOL = r""" -The user sees your replies rendered as Markdown with LaTeX/KaTeX for math. -This is not a hint — it's how the UI works. Plain-text "math" like -"x^2 + (b/a)x = -c/a" displays literally and looks wrong. Always wrap -mathematics in LaTeX delimiters and use real LaTeX commands. - -Markdown: - Headings: ##, ### (use ### for sub-sections inside replies) - Emphasis: **bold**, *italic* - Code: `inline`, ```python\nfenced\n``` - Lists: - bullet OR 1. numbered - Quotes: > a quote - Links: [text](https://example.com) - Tables: | header | header | - |--------|--------| - | a | b | - -Math — REQUIRED for any equation, expression, fraction, exponent, root, -sum, integral, matrix, or set-builder. Choose: - Inline: $...$ $\\(...\\)$ - Display: $$...$$ \\[...\\] - -Use real LaTeX commands. WRONG vs RIGHT: - - WRONG: x^2 + (b/a)x = -c/a - RIGHT: $x^{2} + \frac{b}{a}\,x = -\frac{c}{a}$ - - WRONG: sqrt(b^2 - 4ac) - RIGHT: $\sqrt{b^{2} - 4ac}$ - - WRONG: (-b +/- sqrt(b^2 - 4ac)) / (2a) - RIGHT: $$x = \frac{-b \pm \sqrt{b^{2} - 4ac}}{2a}$$ - - WRONG: sum from i=1 to n of i^2 - RIGHT: $\sum_{i=1}^{n} i^{2}$ - - WRONG: integral from 0 to 1 of x^2 dx - RIGHT: $\int_{0}^{1} x^{2}\,dx$ - -Common LaTeX you should know: - Fractions \frac{num}{den} Roots \sqrt{x}, \sqrt[n]{x} - Exponents x^{n} (always brace multi-character exponents) - Subscripts x_{i} - Operators \pm \mp \cdot \times \div \ast - Relations \leq \geq \neq \approx \equiv \rightarrow \Leftrightarrow - Greek \alpha \beta \gamma \delta \epsilon \pi \sigma \theta \phi \omega - Sets \mathbb{R} \mathbb{N} \mathbb{Z} \emptyset \in \notin \subset - Logic \forall \exists \neg \land \lor \Rightarrow - Calculus \int \sum \prod \lim \partial \nabla \infty - Spacing \, (thin space) \; (thick) \quad \qquad - -Aligned multi-line equations: -$$ -\begin{aligned} -ax^{2} + bx + c &= 0 \\ -x &= \frac{-b \pm \sqrt{b^{2} - 4ac}}{2a} -\end{aligned} -$$ - -Matrices: -$$ -A = \begin{pmatrix} 1 & 2 \\ 3 & 4 \end{pmatrix} -$$ - -When in doubt: lean toward wrapping it in $...$. Never use ASCII math -shortcuts (^, /, sqrt(), <=, !=, sum, int) in user-visible prose. -""".strip() - - -def resolve_active_workspace(config: dict) -> Optional[dict]: - wid = config.get("active_workspace_id") - if not wid: - return None - data = load_workspaces() - return next((w for w in data["workspaces"] if w["id"] == wid), None) - - -def build_system_prompt( - config: dict, - prompts: dict, - mode: str = "approve-each", - *, - user_memories: Optional[list[str]] = None, - use_vision: bool = False, - web_search_mode: str = "off", -) -> str: - parts: list[str] = [] - workspace = resolve_active_workspace(config) - - # 1. The system prompt itself - prompt_id = (workspace or {}).get("system_prompt_id") or config.get("active_prompt_id") or "default" - chosen = next( - (p for p in prompts["prompts"] if p["id"] == prompt_id), - prompts["prompts"][0] if prompts["prompts"] else None, - ) - if chosen: - parts.append(chosen["content"]) - - if workspace: - parts.append( - f"Active workspace: {workspace['name']}." - + (f" {workspace['description']}" if workspace.get("description") else "") - ) - - # User memories — durable preferences/facts/constraints stored client-side - # in the browser and injected here on every turn. Subject to context trim. - if user_memories: - cleaned = [m.strip() for m in user_memories if isinstance(m, str) and m.strip()] - if cleaned: - parts.append( - "Things to remember about the user:\n" - + "\n".join(f"- {m}" for m in cleaned[:50]) # hard cap so a runaway list can't blow the budget - ) - - # Per-turn capability hints - if use_vision: - parts.append( - "The user has explicitly asked you to USE VISION on this turn. " - "If any images are attached, analyse them in detail and incorporate " - "what you see into your reply." - ) - if web_search_mode == "required": - parts.append( - "The user requires web search on this turn. You MUST call the " - "web_search tool before answering so your reply reflects current " - "information." - ) - elif web_search_mode == "if_needed": - parts.append( - "If answering accurately requires current or specialised information " - "you don't have, call the web_search tool first." - ) - - # Plan mode block (injected before other tools if active) - effective_mode = mode or (workspace or {}).get("mode") or config.get("chat_mode", "approve-each") - if effective_mode == "plan": - parts.append(PLAN_MODE_BLOCK) - - # Response style - parts.append( - "Always attempt a complete, useful response to the user's request before " - "asking clarifying questions. If something is ambiguous, make a reasonable " - "assumption, state it briefly, and proceed. Save any follow-up questions for " - "the end of your reply, after the substantive response." ) + for handler in logging.getLogger().handlers: + handler.addFilter(RequestIdFilter()) - # Rendering rules - parts.append(RENDERING_PROTOCOL) - - # In test mode skip the tool-protocol block and all tool / service listings. - # The basic prompt + memories above are sufficient for outcome assertions; - # omitting ~1 k tokens of tool instructions cuts inference time by ~40 %. - if _TEST_MODE: - return "\n\n".join(parts) - - # 2. Available skills - if workspace: - active_skill_ids = workspace.get("active_skills") or [] - else: - active_skill_ids = config.get("active_skills") or [] - available_skills = list_skill_files() - if available_skills: - listing = "\n".join( - f"- {s['id']}: {s['description']}" - for s in available_skills - if not active_skill_ids or s["id"] in active_skill_ids - ) - if listing: - parts.append("Skills you may invoke via load_skill (id: when to use):\n" + listing) - - # 3. MCP tools - allowed_servers = (workspace or {}).get("active_mcp_servers") - mcp_tools = mcp_manager.list_all_tools(allowed_servers=allowed_servers) - if mcp_tools: - listing = "\n".join( - f"- {t['server']}.{t['name']}: {t['description']}" for t in mcp_tools - ) - parts.append("MCP tools available via mcp_call (server.name: description):\n" + listing) - - # 4. CLI shortcuts - cli_data = load_cli_tools() - cli_ids = (workspace or {}).get("active_cli_tools") - cli_listing = [] - for c in cli_data.get("tools", []): - if cli_ids is not None and c["id"] not in cli_ids: - continue - policy = c.get("approval_policy", "ask") - policy_note = " [always-allowed]" if policy == "always" else "" - cli_listing.append( - f"- {c['id']} ({c.get('name', c['id'])}): {c.get('description', '')} " - f"[template: {c.get('command_template', '')}]{policy_note}" - ) - if cli_listing: - parts.append( - "CLI shortcuts available via cli_call (id: description [template]):\n" - + "\n".join(cli_listing) - ) - - parts.append(f"Detected operating system: {platform.system()} ({platform.platform()}).") - parts.append(TOOL_PROTOCOL) - - # 5. Integrated services — only advertised when enabled - from services import state as svc_state - service_lines: list[str] = [] - if svc_state.is_enabled("autogui"): - service_lines.append( - "- autogui_task: drive the desktop GUI (move the mouse, click, type, " - "open apps, work in any window — including Notepad on Windows or any " - "other native app) via AutoGUI's ReAct loop. PREFER this over " - "execute_shell when the user asks to operate a GUI application. " - "Args: {\"task\": \"natural-language description of what to do\", " - "\"dry_run\": false}." - ) - if svc_state.is_enabled("osso"): - service_lines.append( - "- screen_windows: list every open window on the user's desktop. " - "Args: {}." - ) - service_lines.append( - "- screen_description: describe a window's contents via accessibility " - "tree or vision. Args: {\"window_index\": 0, \"mode\": " - "\"accessibility|vision\"}." - ) - service_lines.append( - "- screen_screenshot: capture a screenshot of a window. " - "Args: {\"window_index\": 0}." - ) - service_lines.append( - "- screen_action: perform a precise screen action (click, type, key " - "press). REQUIRES APPROVAL. Args: {\"action\": \"click|type|key\", " - "\"x\": 0, \"y\": 0, \"text\": \"text-to-type-or-key-name\"}." - ) - if svc_state.is_enabled("clk"): - service_lines.append( - "- clk_research: start a CognitiveLoopKernel research / reasoning " - "workflow for deep multi-step analysis. REQUIRES APPROVAL. " - "Args: {\"command\": \"run\", \"workflow\": \"optional workflow name\", " - "\"args\": [], \"workspace_id\": \"optional\"}." - ) - if service_lines and effective_mode != "plan": - parts.append( - "Integrated services available as tools (call them like any other " - "tool via the ```tool block). These extend what you can do beyond " - "execute_shell:\n" + "\n".join(service_lines) - ) - return "\n\n".join(parts) - - -# --------------------------------------------------------------------------- -# Tool-call parsing -# --------------------------------------------------------------------------- - -def extract_tool_call(text: str) -> Optional[dict]: - marker = "```tool" - start = text.find(marker) - if start == -1: - return None - body_start = text.find("\n", start) + 1 - end = text.find("```", body_start) - if end == -1: - return None - body = text[body_start:end].strip() - try: - call = json.loads(body) - except json.JSONDecodeError: - return None - if not isinstance(call, dict) or "tool" not in call: - return None - raw_args = call.get("args", {}) or {} - if not isinstance(raw_args, dict): - raw_args = {} - # Some models omit the "args" wrapper and place fields at the top level. - # Merge any unknown top-level keys into args so the tool handler sees them. - if not raw_args: - raw_args = {k: v for k, v in call.items() if k not in ("tool", "args")} - return { - "tool": call["tool"], - "args": raw_args, - "raw_block": text[start : end + 3], - } +_configure_logging() +logger = logging.getLogger("betterwebui") # --------------------------------------------------------------------------- -# Approval / file-response state +# Lifespan: MCP reconcile, transient-upload sweeping, scheduler # --------------------------------------------------------------------------- -class ApprovalState: - def __init__(self) -> None: - self.events: dict[str, asyncio.Event] = {} - self.results: dict[str, bool] = {} - - def new(self) -> str: - aid = uuid.uuid4().hex - self.events[aid] = asyncio.Event() - return aid +_transient_sweep_task: "asyncio.Task | None" = None +_scheduler_task: "asyncio.Task | None" = None - async def wait(self, aid: str, timeout: float = 600.0) -> bool: - try: - await asyncio.wait_for(self.events[aid].wait(), timeout=timeout) - except asyncio.TimeoutError: - return False - return self.results.get(aid, False) - - def resolve(self, aid: str, approved: bool) -> bool: - if aid not in self.events: - return False - self.results[aid] = approved - self.events[aid].set() - return True - - -approvals = ApprovalState() - -class FileResponseStore: - def __init__(self) -> None: - self.events: dict[str, asyncio.Event] = {} - self.results: dict[str, list[dict]] = {} - - def new(self) -> str: - rid = uuid.uuid4().hex - self.events[rid] = asyncio.Event() - return rid - - async def wait(self, rid: str, timeout: float = 600.0) -> list[dict]: +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + global _transient_sweep_task, _scheduler_task + # ── startup ──────────────────────────────────────────────────────────── + try: + await mcp_manager.reconcile() + except Exception: + # Startup continues without MCP servers; each server can still be + # (re)started later from the UI. + logging.getLogger("betterwebui.mcp").exception("MCP startup reconcile failed") + # One sweep at boot so test fixtures get a clean state. + try: + _sweep_transient_uploads() + except Exception as exc: + logging.getLogger("betterwebui.uploads").warning("Boot-time upload sweep failed: %s", exc) + _transient_sweep_task = asyncio.create_task(_transient_module._transient_sweep_loop()) + try: + from scheduler import start_scheduler + _scheduler_task = asyncio.create_task(start_scheduler( + tasks_path=_storage_module.SCHEDULED_TASKS_PATH, + run_callback=_run_scheduled_task, + send_notification=_emit_scheduled_notification, + )) + except Exception as exc: + logging.getLogger("betterwebui.scheduler").warning("Scheduler failed to start: %s", exc) + yield + # ── shutdown ─────────────────────────────────────────────────────────── + if _transient_sweep_task is not None: + _transient_sweep_task.cancel() + if _scheduler_task is not None: + _scheduler_task.cancel() + for name, client in list(mcp_manager.clients.items()): try: - await asyncio.wait_for(self.events[rid].wait(), timeout=timeout) - except asyncio.TimeoutError: - return [] - return self.results.get(rid, []) - - def resolve(self, rid: str, files: list[dict]) -> bool: - if rid not in self.events: - return False - self.results[rid] = files - self.events[rid].set() - return True + await client.stop() + except Exception as exc: + logging.getLogger("betterwebui.mcp").warning("Shutdown of MCP server '%s' failed: %s", name, exc) -file_responses = FileResponseStore() +app = FastAPI(title="BetterWebUI", lifespan=lifespan) # --------------------------------------------------------------------------- -# Shell / OS helpers +# 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 services.request_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). # --------------------------------------------------------------------------- -def detect_shell() -> tuple[list[str], str]: - if platform.system() == "Windows": - if shutil.which("pwsh"): - return (["pwsh", "-NoProfile", "-Command"], "PowerShell") - return (["powershell", "-NoProfile", "-Command"], "PowerShell") - return (["bash", "-lc"], "bash") - - -async def run_shell(command: str, timeout: int = 120, cwd: Optional[str] = None) -> dict: - argv_prefix, shell_name = detect_shell() - argv = argv_prefix + [command] - started = time.time() - effective_cwd = cwd or str(WORKSPACE_DIR) - # Validate cwd up front so a misconfigured workspace project_root produces - # a clear error instead of being reported as "Shell not available". - cwd_path = Path(effective_cwd) - if not cwd_path.exists() or not cwd_path.is_dir(): - return { - "shell": shell_name, - "exit_code": -1, - "stdout": "", - "stderr": f"Working directory does not exist: {effective_cwd}", - "duration_ms": int((time.time() - started) * 1000), - } +@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: - proc = await asyncio.create_subprocess_exec( - *argv, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=effective_cwd, - ) - try: - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) - except asyncio.TimeoutError: - proc.kill() - await proc.wait() - return { - "shell": shell_name, - "exit_code": -1, - "stdout": "", - "stderr": f"Command timed out after {timeout}s.", - "duration_ms": int((time.time() - started) * 1000), - } - return { - "shell": shell_name, - "exit_code": proc.returncode, - "stdout": (stdout or b"").decode("utf-8", errors="replace")[:20000], - "stderr": (stderr or b"").decode("utf-8", errors="replace")[:8000], - "duration_ms": int((time.time() - started) * 1000), - } - except FileNotFoundError as exc: - return { - "shell": shell_name, - "exit_code": -1, - "stdout": "", - "stderr": f"Shell not available: {exc}", - "duration_ms": int((time.time() - started) * 1000), - } - - -def _slug(text: str, fallback: str = "image") -> str: - out = "".join(c if c.isalnum() or c in "-_" else "-" for c in (text or "")).strip("-") - return (out or fallback)[:48] + response = await call_next(request) + finally: + request_id_ctx.reset(token) + response.headers["X-Request-ID"] = rid + return response + + +@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}) # --------------------------------------------------------------------------- -# Checkpoint helpers (project file versioning) +# Index, static mounts, and routers # --------------------------------------------------------------------------- -def _ckpt_key(filename: str) -> str: - """Collision-resistant directory key for a checkpointed filename. - - _slug() collapses punctuation and casing, so distinct filenames could share - a slug and mix their histories. Use a hash of the normalized relative path - instead — full content (history mixing risk gone) and bounded length. - """ - norm = (filename or "file").strip().replace("\\", "/") - return hashlib.sha1(norm.encode("utf-8")).hexdigest()[:16] - - -def _checkpoint_file(workspace_id: str, filename: str, content: bytes) -> str: - """Save a checkpoint snapshot of raw bytes. Returns the checkpoint id. +@app.get("/") +async def index(): + return FileResponse(ROOT / "static" / "index.html") - Stores as `.bin` so binary files round-trip without UTF-8 replacement. - Legacy `.txt` snapshots from earlier versions are still readable by - _get_checkpoint and _list_checkpoints. - """ - ckpt_dir = CHECKPOINTS_DIR / workspace_id / _ckpt_key(filename) - ckpt_dir.mkdir(parents=True, exist_ok=True) - ckpt_id = f"{int(time.time())}_{uuid.uuid4().hex[:6]}" - ckpt_path = ckpt_dir / f"{ckpt_id}.bin" - ckpt_path.write_bytes(content) - return ckpt_id +app.mount("/static", StaticFiles(directory=ROOT / "static"), name="static") +app.mount("/uploads", StaticFiles(directory=_storage_module.UPLOADS_DIR), name="uploads") -def _list_checkpoints(workspace_id: str, filename: str) -> list[dict]: - ckpt_dir = CHECKPOINTS_DIR / workspace_id / _ckpt_key(filename) - if not ckpt_dir.exists(): - return [] - # Sort by mtime (descending) so the .bin and legacy .txt eras interleave - # correctly when both exist for the same filename. - files = list(ckpt_dir.glob("*.bin")) + list(ckpt_dir.glob("*.txt")) - out = [] - for p in sorted(files, key=lambda p: p.stat().st_mtime, reverse=True)[:20]: - parts = p.stem.split("_", 1) - ts = int(parts[0]) if parts else 0 - out.append({"id": p.stem, "filename": filename, "saved_at": ts}) - return out +for _router in ALL_ROUTERS: + app.include_router(_router) +# ─── Services integration ──────────────────────────────────────────────────── -def _get_checkpoint(workspace_id: str, filename: str, ckpt_id: str) -> Optional[bytes]: - """Return the raw checkpoint bytes, or None if the checkpoint is missing. +# Imported late on purpose: routes need the fully-initialised `app` above. +from services.routes import register_routes as _register_service_routes # noqa: E402 - Reads `.bin` first; falls back to legacy `.txt` (UTF-8) for snapshots taken - before checkpoints became binary-safe. - """ - base = CHECKPOINTS_DIR / workspace_id / _ckpt_key(filename) - bin_path = base / f"{ckpt_id}.bin" - if bin_path.exists(): - return bin_path.read_bytes() - txt_path = base / f"{ckpt_id}.txt" - if txt_path.exists(): - return txt_path.read_bytes() - return None +_register_service_routes(app) # --------------------------------------------------------------------------- -# OpenWebUI proxy helpers -# --------------------------------------------------------------------------- - -def _sniff_image_mime(raw: bytes) -> Optional[str]: - """Magic-byte sniff. Returns canonical image mime or None for unrecognised bytes.""" - if len(raw) < 12: - return None - if raw.startswith(b"\x89PNG\r\n\x1a\n"): - return "image/png" - if raw[:3] == b"\xff\xd8\xff": - return "image/jpeg" - if raw[:6] in (b"GIF87a", b"GIF89a"): - return "image/gif" - if raw[:4] == b"RIFF" and raw[8:12] == b"WEBP": - return "image/webp" - if raw[:2] == b"BM": - return "image/bmp" - return None - - -def validate_image_bytes(raw: bytes, min_bytes: int = 64) -> tuple[bool, str, Optional[str]]: - """Return (ok, reason, sniffed_mime). Cheap deterministic check used to - detect broken image renders before they reach the UI as broken-link icons.""" - if not raw: - return False, "Empty image payload.", None - if len(raw) < min_bytes: - return False, f"Image payload too small ({len(raw)} bytes).", None - mime = _sniff_image_mime(raw) - if mime is None: - return False, "Image bytes do not match any known image format.", None - return True, "", mime - - -async def call_openwebui_image(prompt: str, size: str, config: dict) -> dict: - base = normalize_base_url(config["base_url"]) - profile = active_profile(config) - headers = {"Authorization": f"Bearer {config['api_key']}"} - payload = {"prompt": prompt, "n": 1, "size": size} - if config.get("image_model"): - payload["model"] = config["image_model"] - async with httpx.AsyncClient(timeout=240.0) as client: - resp = await client.post(f"{base}{profile['images']}", json=payload, headers=headers) - if resp.status_code != 200: - raise HTTPException(status_code=resp.status_code, detail=f"Image generation failed: {resp.text[:500]}") - body = resp.json() - if isinstance(body, list): - item = body[0] if body else {} - else: - item = (body.get("data") or [{}])[0] if isinstance(body, dict) else {} - filename = f"{_slug(prompt)}-{uuid.uuid4().hex[:6]}.png" - if "b64_json" in item: - try: - raw = base64.b64decode(item["b64_json"], validate=False) - except Exception as exc: - return {"error": f"Image generation returned undecodable base64: {exc}", "prompt": prompt} - ok, reason, sniffed = validate_image_bytes(raw) - if not ok: - return {"error": f"Image generation returned invalid data: {reason}", "prompt": prompt} - return { - "filename": filename, - "mime": sniffed or "image/png", - "data_b64": item["b64_json"], - "prompt": prompt, - } - if "url" in item: - async with httpx.AsyncClient(timeout=180.0) as client: - img_resp = await client.get(item["url"]) - if img_resp.status_code != 200: - return {"error": f"Could not fetch generated image at {item['url']} (HTTP {img_resp.status_code})."} - ok, reason, sniffed = validate_image_bytes(img_resp.content) - if not ok: - return {"error": f"Image generation returned invalid data: {reason}", "prompt": prompt} - return { - "filename": filename, - "mime": sniffed or img_resp.headers.get("content-type", "image/png"), - "data_b64": base64.b64encode(img_resp.content).decode("ascii"), - "prompt": prompt, - "source_url": item["url"], - } - return {"raw": body, "error": "Image generation response had neither b64_json nor url."} - - -async def call_web_search(query: str, max_results: int, config: dict) -> dict: - """Dispatch to the configured web-search provider. Returns a dict with - keys: query, provider, results=[{title, url, snippet}], or {error: ...}. - """ - web = (config or {}).get("web_search") or {} - provider = (web.get("provider") or "").lower() - api_key = web.get("api_key") or "" - if not provider: - return {"error": "Web search is not configured. Settings → Connection → Web search."} - - async with httpx.AsyncClient(timeout=20.0) as client: - if provider == "tavily": - if not api_key: - return {"error": "Tavily requires an API key (Settings → Connection → Web search)."} - resp = await client.post( - "https://api.tavily.com/search", - json={ - "api_key": api_key, - "query": query, - "max_results": max_results, - "search_depth": "basic", - }, - ) - if resp.status_code != 200: - return {"error": f"Tavily search failed ({resp.status_code}): {resp.text[:300]}"} - body = resp.json() - results = [ - {"title": r.get("title", ""), "url": r.get("url", ""), "snippet": r.get("content", "")} - for r in (body.get("results") or [])[:max_results] - ] - return {"query": query, "provider": "tavily", "results": results} - - if provider == "brave": - if not api_key: - return {"error": "Brave Search requires an API key."} - resp = await client.get( - "https://api.search.brave.com/res/v1/web/search", - params={"q": query, "count": max_results}, - headers={"X-Subscription-Token": api_key, "Accept": "application/json"}, - ) - if resp.status_code != 200: - return {"error": f"Brave search failed ({resp.status_code}): {resp.text[:300]}"} - body = resp.json() - results = [ - {"title": r.get("title", ""), "url": r.get("url", ""), "snippet": r.get("description", "")} - for r in ((body.get("web") or {}).get("results") or [])[:max_results] - ] - return {"query": query, "provider": "brave", "results": results} - - if provider == "serpapi": - if not api_key: - return {"error": "SerpAPI requires an API key."} - resp = await client.get( - "https://serpapi.com/search.json", - params={"q": query, "engine": "google", "num": max_results, "api_key": api_key}, - ) - if resp.status_code != 200: - return {"error": f"SerpAPI search failed ({resp.status_code}): {resp.text[:300]}"} - body = resp.json() - results = [ - {"title": r.get("title", ""), "url": r.get("link", ""), "snippet": r.get("snippet", "")} - for r in (body.get("organic_results") or [])[:max_results] - ] - return {"query": query, "provider": "serpapi", "results": results} - - if provider == "custom": - url = web.get("custom_url") or "" - if not url: - return {"error": "Custom web search needs a 'custom_url' in settings."} - headers = {} - if api_key: - headers["Authorization"] = f"Bearer {api_key}" - resp = await client.post(url, json={"query": query, "max_results": max_results}, headers=headers) - if resp.status_code != 200: - return {"error": f"Custom search failed ({resp.status_code}): {resp.text[:300]}"} - try: - body = resp.json() - except Exception: - return {"error": "Custom search returned non-JSON."} - results = body.get("results") if isinstance(body, dict) else body - if not isinstance(results, list): - return {"error": "Custom search did not return a 'results' list."} - return {"query": query, "provider": "custom", "results": results[:max_results]} - - return {"error": f"Unknown web_search provider: {provider}"} - - -_HTML_TAG_RE = re.compile(r"<[^>]+>") -_WHITESPACE_RE = re.compile(r"\s+") -_HTML_ENTITIES = {"&": "&", "<": "<", ">": ">", """: '"', "'": "'", "'": "'", " ": " "} - - -def _strip_html(raw: str) -> tuple[str, str]: - """Return (title, body_text) extracted from HTML. Falls back to raw text.""" - title = "" - title_m = re.search(r"]*>(.*?)", raw, re.IGNORECASE | re.DOTALL) - if title_m: - title = title_m.group(1).strip() - # Remove script/style blocks - raw = re.sub(r"<(script|style)[^>]*>.*?", " ", raw, flags=re.IGNORECASE | re.DOTALL) - # Remove all other tags - text = _HTML_TAG_RE.sub(" ", raw) - # Decode common HTML entities - for ent, ch in _HTML_ENTITIES.items(): - text = text.replace(ent, ch) - text = _WHITESPACE_RE.sub(" ", text).strip() - # Clean title entities too - for ent, ch in _HTML_ENTITIES.items(): - title = title.replace(ent, ch) - title = _WHITESPACE_RE.sub(" ", title).strip() - return title, text - - -async def call_fetch_url(url: str) -> dict: - """Fetch a URL and return extracted readable text.""" - parsed = None - try: - from urllib.parse import urlparse - parsed = urlparse(url) - if parsed.scheme not in ("http", "https"): - return {"error": f"fetch_url only supports http/https URLs (got '{parsed.scheme}')."} - except Exception: - return {"error": "Invalid URL."} - try: - async with httpx.AsyncClient( - timeout=20.0, - follow_redirects=True, - headers={"User-Agent": "Mozilla/5.0 BetterWebUI/1.0"}, - ) as client: - resp = await client.get(url) - if resp.status_code >= 400: - return {"error": f"Server returned {resp.status_code} for {url}."} - ct = resp.headers.get("content-type", "") - if "html" in ct: - title, text = _strip_html(resp.text) - else: - title, text = "", resp.text - max_chars = 12000 - truncated = len(text) > max_chars - return { - "url": url, - "title": title, - "text": text[:max_chars] + (" …[truncated]" if truncated else ""), - "word_count": len(text.split()), - } - except Exception as exc: - return {"error": f"Failed to fetch {url}: {exc}"} - - -async def call_openwebui_audio(text: str, voice: str, config: dict) -> dict: - base = normalize_base_url(config["base_url"]) - profile = active_profile(config) - headers = {"Authorization": f"Bearer {config['api_key']}"} - payload = {"input": text, "voice": voice or "alloy", "model": "tts-1"} - async with httpx.AsyncClient(timeout=240.0) as client: - resp = await client.post(f"{base}{profile['audio']}", json=payload, headers=headers) - if resp.status_code != 200: - raise HTTPException(status_code=502, detail=f"Audio generation failed (upstream {resp.status_code}): {resp.text[:500]}") - filename = f"{_slug(text, 'speech')}-{uuid.uuid4().hex[:6]}.mp3" - return { - "filename": filename, - "mime": "audio/mpeg", - "data_b64": base64.b64encode(resp.content).decode("ascii"), - "voice": voice, - } - - -async def chat_complete(messages: list, model: str, config: dict, chat_id: str = "") -> tuple[str, dict]: - """Returns (text, usage_dict).""" - if _TEST_MODE and _mock_chat_enabled: - last_user = next( - (m.get("content", "") for m in reversed(messages) if m.get("role") == "user"), "" - ) - if isinstance(last_user, list): - last_user = " ".join( - p.get("text", "") for p in last_user if isinstance(p, dict) and p.get("type") == "text" - ) - if "fenced markdown code block" in last_user or ("Reply with exactly" in last_user and "```" in last_user): - text = "```\nhello\n```" - elif "LaTeX" in last_user and ("$E" in last_user or "mc^2" in last_user): - text = "$E = mc^2$" - else: - text = _mock_chat_text - return text, {"prompt_tokens": 1, "completion_tokens": len(text.split()), "total_tokens": len(text.split()) + 1, "elapsed_ms": 10} - base = normalize_base_url(config["base_url"]) - profile = active_profile(config) - headers = {"Authorization": f"Bearer {config.get('api_key', '')}"} - payload: dict = {"model": model, "messages": messages, "stream": False} - if chat_id and profile.get("name") == "openwebui": - payload["chat_id"] = chat_id - if _TEST_MODE: - payload["max_tokens"] = _TEST_MAX_TOKENS - t0 = time.time() - async with httpx.AsyncClient(timeout=300.0, follow_redirects=True) as client: - resp = await client.post(f"{base}{profile['chat']}", json=payload, headers=headers) - elapsed_ms = int((time.time() - t0) * 1000) - if resp.status_code != 200: - raise HTTPException( - status_code=resp.status_code, - detail=f"Chat call failed ({profile['name']}): {resp.text[:500]}", - ) - try: - body = resp.json() - except (json.JSONDecodeError, ValueError): - raise HTTPException(status_code=502, detail="Chat endpoint returned non-JSON.") - try: - text = body["choices"][0]["message"]["content"] or "" - except (KeyError, IndexError): - text = json.dumps(body) - usage = body.get("usage") or {} - usage["elapsed_ms"] = elapsed_ms - return text, usage - - -# --------------------------------------------------------------------------- -# Subagent execution -# --------------------------------------------------------------------------- - -_SUBAGENT_TOOL_PROTOCOL = """ -You have one tool. To call it, output exactly one fenced JSON block on its own lines: - -```tool -{"tool": "load_skill", "args": {"skill_id": "..."}} -``` - -Available tools: -- load_skill: load the full content of a named skill. Args: {"skill_id": "..."}. - -Output at most one tool call per turn. Never invent tool output — wait for the result. -""".strip() - - -async def run_subagent_loop( - prompt: str, model: str, config: dict, max_steps: int = 4 -) -> str: - """Run a read-only sub-loop. Returns final assistant text.""" - sub_system = ( - "You are a read-only research subagent. You may call load_skill only. " - "Do NOT call execute_shell, write_file, generate_image, generate_audio, " - "cli_call, mcp_call, or read_file. Summarize from your existing context. " - "Produce a concise summary of your findings when done.\n\n" - + _SUBAGENT_TOOL_PROTOCOL - + "\n\n" - + RENDERING_PROTOCOL - ) - history = [{"role": "user", "content": prompt}] - # Subagents are strictly read-only: mcp_call is blocked entirely because we - # cannot statically guarantee any given MCP tool is side-effect-free. - blocked_tools = ( - "execute_shell", "write_file", "generate_image", - "generate_audio", "cli_call", "mcp_call", - ) - for _ in range(max_steps): - messages = [{"role": "system", "content": sub_system}] + history - text, _ = await chat_complete(messages, model, config) - history.append({"role": "assistant", "content": text}) - call = extract_tool_call(text) - if not call: - return text - # Only allow read-only tools - if call["tool"] in blocked_tools: - history.append({ - "role": "user", - "content": f"[Tool '{call['tool']}' blocked — subagents are read-only]" - }) - continue - if call["tool"] == "read_file": - result = {"error": "Subagents cannot use the file picker. Summarize from context instead."} - elif call["tool"] == "load_skill": - skill = load_skill_content(call["args"].get("skill_id", "")) - result = skill or {"error": "Skill not found."} - else: - result = {"error": f"Unknown tool: {call['tool']}"} - history.append({ - "role": "user", - "content": f"[Tool '{call['tool']}' result]\n```json\n{json.dumps(result, indent=2)[:4000]}\n```" - }) - # Return last assistant turn - for m in reversed(history): - if m["role"] == "assistant": - return m["content"] - return "(subagent produced no output)" - - -# --------------------------------------------------------------------------- -# Permission / approval helpers -# --------------------------------------------------------------------------- - -def _should_skip_approval(command: str, cli_id: Optional[str], config: dict) -> bool: - """Return True if this command/CLI can skip the approval dialog.""" - if command in _session_trusted_commands: - return True - if cli_id: - cli_data = load_cli_tools() - cli = next((c for c in cli_data.get("tools", []) if c["id"] == cli_id), None) - if cli and cli.get("approval_policy") == "always": - return True - workspace = resolve_active_workspace(config) - if workspace and workspace.get("shell_approval_policy") == "always": - return True - return False - - -# --------------------------------------------------------------------------- -# Tool execution -# --------------------------------------------------------------------------- - -async def execute_tool(call: dict, config: dict, send_event, mode: str = "approve-each", model: str = "") -> dict: - tool = call["tool"] - args = call["args"] - - # Plan mode: block side-effecting tools (and spawn_subagent, which can - # transitively read files/skills but otherwise consumes context budget - # before any execution has been approved — matches PLAN_MODE_BLOCK's - # "ONLY call update_task_plan/read_file/load_skill" contract). - if mode == "plan" and tool in ( - "execute_shell", "write_file", "generate_image", - "generate_audio", "cli_call", "mcp_call", "spawn_subagent", - ): - return {"error": f"Tool '{tool}' is blocked in plan mode. Switch to Approve-each to execute."} - - if tool == "update_task_plan": - items = args.get("items", []) - await send_event("task_plan", {"items": items}) - return {"ok": True, "items_count": len(items), "items": items} - - if tool == "spawn_subagent": - kind = args.get("kind", "explore") - prompt = args.get("prompt", "") - items = args.get("items") or [] - model = model or config.get("default_model", "") - if not model: - return {"error": "No model available — cannot spawn subagents."} - - # Build per-subagent prompts - if items and kind == "compare": - subprompts = [ - f"Investigate and summarize: {item}\n\nContext: {prompt}" - for item in items[:3] - ] - else: - subprompts = [prompt] - - await send_event("subagent_start", {"kind": kind, "count": len(subprompts)}) - results = await asyncio.gather( - *[run_subagent_loop(sp, model, config) for sp in subprompts], - return_exceptions=True, - ) - texts = [] - for i, r in enumerate(results): - item_label = items[i] if i < len(items) else f"Task {i+1}" - if isinstance(r, Exception): - texts.append(f"**{item_label}**: Error — {r}") - else: - texts.append(f"**{item_label}**:\n{r}") - combined = "\n\n---\n\n".join(texts) - await send_event("subagent_result", {"kind": kind, "count": len(subprompts), "combined": combined}) - return {"kind": kind, "results_count": len(subprompts), "combined": combined} - - if tool == "execute_shell": - if not config.get("shell_enabled", True): - return {"error": "Shell execution is disabled in settings."} - command = args.get("command", "").strip() - reason = args.get("reason", "") - if not command: - return {"error": "No command provided."} - - if mode == "trusted" or _should_skip_approval(command, None, config): - await send_event("tool_running", {"tool": "execute_shell", "command": command, "auto_approved": True}) - else: - aid = approvals.new() - await send_event("approval_request", { - "approval_id": aid, - "tool": "execute_shell", - "command": command, - "reason": reason, - "shell": detect_shell()[1], - }) - approved = await approvals.wait(aid) - if not approved: - return {"error": "User denied this command."} - await send_event("tool_running", {"tool": "execute_shell", "command": command}) - - workspace = resolve_active_workspace(config) - shell_cwd = _resolve_project_root(workspace) - result = await run_shell(command, cwd=shell_cwd) - # Auto-capture plot - for _img_path in (Path("/tmp/bwui_plot.png"), ROOT / "bwui_plot.png"): - if _img_path.exists(): - try: - result["filename"] = "plot.png" - result["mime"] = "image/png" - result["data_b64"] = base64.b64encode(_img_path.read_bytes()).decode("ascii") - _img_path.unlink() - except Exception: - pass - break - return result - - if tool == "read_file": - rid = file_responses.new() - await send_event("file_request", { - "request_id": rid, - "purpose": args.get("reason") or args.get("purpose") or "read", - "accept": args.get("accept", "*/*"), - "multiple": bool(args.get("multiple", False)), - }) - files = await file_responses.wait(rid) - if not files: - return {"error": "User cancelled the file picker (no files chosen)."} - out_files = [] - for f in files: - entry = { - "filename": f.get("filename", "file"), - "content_type": f.get("content_type", ""), - "size": f.get("size", 0), - } - if f.get("content") is not None: - entry["content"] = (f.get("content") or "")[:80000] - elif f.get("data_b64"): - b64 = f["data_b64"] - entry["data_b64"] = b64[:200_000] - entry["truncated"] = len(b64) > 200_000 - out_files.append(entry) - return {"files": out_files} - - if tool == "write_file": - filename = (args.get("filename") or args.get("path") or "file.txt").strip() - filename = Path(filename).name or "file.txt" - content = args.get("content", "") - mime = args.get("mime", "text/plain") - if not isinstance(content, str): - content = str(content) - # Cap write size so a single tool call can't blow up SSE/JSON payloads - # (base64 inflates by ~33% on top of UTF-8 encoding). - _MAX_WRITE_BYTES = 5 * 1024 * 1024 - content_bytes_len = len(content.encode("utf-8")) - if content_bytes_len > _MAX_WRITE_BYTES: - return {"error": f"write_file payload too large ({content_bytes_len} bytes; max {_MAX_WRITE_BYTES})."} - - # Determine the write directory: workspace project_root → WORKSPACE_DIR - workspace = resolve_active_workspace(config) - project_root = _resolve_project_root(workspace) - dest = Path(project_root) / filename - - if mode != "trusted": - aid = approvals.new() - await send_event("approval_request", { - "approval_id": aid, - "tool": "write_file", - "filename": filename, - "mime": mime, - "preview": content[:1000], - "byte_count": len(content.encode("utf-8")), - "dest_path": filename, - }) - approved = await approvals.wait(aid) - if not approved: - return {"error": "User denied this file write."} - - # Snapshot the existing file only after approval, so denied requests - # don't leave junk checkpoints behind on disk. Skip the snapshot - # entirely if the existing file is larger than the checkpoint cap - # (2 MB) — checkpoints are an undo helper, not a backup system, and - # reading multi-hundred-MB files into memory just to checkpoint is - # worse than degrading gracefully. - _MAX_CHECKPOINT_BYTES = 2 * 1024 * 1024 - checkpoint_id = None - if dest.exists(): - wid = (workspace or {}).get("id", "default") - try: - existing_size = dest.stat().st_size - if existing_size <= _MAX_CHECKPOINT_BYTES: - # Read raw bytes so binary files round-trip through - # checkpoint/revert without lossy UTF-8 replacement. - checkpoint_id = _checkpoint_file( - wid, filename, dest.read_bytes() - ) - except Exception: - checkpoint_id = None - - # Write to disk - write_error = None - try: - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_text(content, encoding="utf-8") - except Exception as exc: - write_error = str(exc) - - # Only inline data_b64 on the failure path. When the on-disk write - # succeeded, the file is already at / and - # the user can open it from the file-tree pane — we'd just bloat - # every SSE event with up to 5 MB of base64 (≈6.7 MB JSON) for no - # gain, and that's fragile behind common reverse proxies. - # When the write failed (write_error), we still inline so the user - # can recover the generated bytes via the chat download link. - result = { - "filename": filename, - "dest_path": filename, - "mime": mime, - "bytes_written": content_bytes_len, - "checkpoint_id": checkpoint_id, - } - if write_error: - result["write_error"] = write_error - result["data_b64"] = base64.b64encode(content.encode("utf-8")).decode("ascii") - return result - - if tool == "delete_file": - filename = (args.get("filename") or args.get("path") or "").strip() - filename = Path(filename).name - if not filename: - return {"error": "delete_file requires a 'filename' argument."} - workspace = resolve_active_workspace(config) - project_root = _resolve_project_root(workspace) - dest = Path(project_root) / filename - if not dest.exists(): - return {"error": f"File '{filename}' not found in the workspace."} - if not dest.is_file(): - return {"error": f"'{filename}' is not a regular file and cannot be deleted with this tool."} - - if mode != "trusted": - aid = approvals.new() - await send_event("approval_request", { - "approval_id": aid, - "tool": "delete_file", - "filename": filename, - "dest_path": filename, - "reason": args.get("reason", ""), - }) - approved = await approvals.wait(aid) - if not approved: - return {"error": "User denied file deletion."} - - try: - dest.unlink() - except OSError as exc: - return {"error": f"Could not delete '{filename}': {exc}"} - return {"deleted": filename, "path": filename} - - if tool == "load_skill": - skill_id = args.get("skill_id", "") - skill = load_skill_content(skill_id) - if not skill: - return {"error": f"Skill '{skill_id}' not found."} - return skill - - if tool == "generate_image": - try: - return await call_openwebui_image(args.get("prompt", ""), args.get("size", "1024x1024"), config) - except HTTPException as exc: - return {"error": exc.detail} - - if tool == "generate_audio": - try: - return await call_openwebui_audio( - args.get("text", ""), - args.get("voice") or config.get("tts_voice", "alloy"), - config, - ) - except HTTPException as exc: - return {"error": exc.detail} - - if tool == "mcp_call": - server = args.get("server", "") - name = args.get("name", "") - arguments = args.get("arguments") or {} - if not server or not name: - return {"error": "mcp_call requires both 'server' and 'name'."} - return await mcp_manager.call(server, name, arguments) - - if tool == "web_search": - query = (args.get("query") or "").strip() - if not query: - return {"error": "web_search requires a 'query' argument."} - try: - max_results = int(args.get("max_results") or 5) - except Exception: - max_results = 5 - max_results = max(1, min(10, max_results)) - try: - return await call_web_search(query, max_results, config) - except HTTPException as exc: - return {"error": exc.detail} - - if tool == "fetch_url": - url = (args.get("url") or "").strip() - if not url: - return {"error": "fetch_url requires a 'url' argument."} - if mode != "trusted": - aid = approvals.new() - await send_event("approval_request", { - "approval_id": aid, - "tool": "fetch_url", - "command": f"Fetch: {url}", - "reason": "The assistant wants to download the contents of a web page.", - "shell": "", - }) - approved = await approvals.wait(aid) - if not approved: - return {"error": "User denied fetch_url."} - await send_event("tool_running", {"tool": "fetch_url", "url": url}) - return await call_fetch_url(url) - - if tool == "cli_call": - if not config.get("shell_enabled", True): - return {"error": "Shell execution is disabled in settings."} - cli_id = args.get("id", "") - cli_args = args.get("args", "") - cli_data = load_cli_tools() - cli = next((c for c in cli_data.get("tools", []) if c["id"] == cli_id), None) - if not cli: - return {"error": f"CLI shortcut '{cli_id}' is not configured."} - template = cli.get("command_template", "{args}") - command = template.replace("{args}", cli_args) - workspace = resolve_active_workspace(config) - - if mode == "trusted" or _should_skip_approval(command, cli_id, config): - await send_event("tool_running", {"tool": "cli_call", "command": command, "auto_approved": True}) - else: - aid = approvals.new() - await send_event("approval_request", { - "approval_id": aid, - "tool": "execute_shell", - "command": command, - "reason": f"CLI shortcut '{cli_id}': {cli.get('description', '')}", - "shell": detect_shell()[1], - "cli_id": cli_id, - }) - approved = await approvals.wait(aid) - if not approved: - return {"error": "User denied this command."} - await send_event("tool_running", {"tool": "cli_call", "command": command}) - - # Run CLI shortcuts from the workspace project_root, mirroring - # execute_shell so commands that assume they run inside the project - # folder (e.g., pandoc on input/*.md) behave consistently. - cli_cwd = _resolve_project_root(workspace) - return await run_shell(command, cwd=cli_cwd) - - # ── 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 - if not svc_state.is_enabled("clk"): - return {"error": "CognitiveLoopKernel is disabled. Enable it in Settings > Services."} - command = args.get("command", "run") - workflow = args.get("workflow", "") - summary = f"CLK research — workflow: {workflow or 'default'}, command: {command}" - if mode != "trusted": - aid = approvals.new() - await send_event("approval_request", { - "approval_id": aid, - "tool": "clk_research", - "command": summary, - "reason": "CognitiveLoopKernel will start a research task.", - }) - approved = await approvals.wait(aid) - if not approved: - return {"error": "User denied CognitiveLoopKernel research task."} - await send_event("tool_running", {"tool": "clk_research", "command": summary}) - try: - client = get_clk_client() - return await client.start_research( - command=command, - args=args.get("args", []), - workspace_id=args.get("workspace_id"), - workflow=workflow or None, - ) - except (_httpx.ConnectError, _httpx.TimeoutException, _httpx.TransportError) as e: - 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 - if not svc_state.is_enabled("autogui"): - return {"error": "AutoGUI is disabled. Enable it in Settings > Services."} - task_desc = args.get("task") or "" - if not task_desc.strip(): - return {"error": "autogui_task requires a non-empty 'task' argument. " - "Please call the tool again with {\"task\": \"description of what to do\", \"dry_run\": false}."} - dry_run = args.get("dry_run") or False - summary = f"AutoGUI task: {task_desc[:120]}" + (" [dry run]" if dry_run else "") - if mode != "trusted": - aid = approvals.new() - await send_event("approval_request", { - "approval_id": aid, - "tool": "autogui_task", - "command": summary, - "reason": "AutoGUI will control the desktop GUI to complete this task.", - }) - approved = await approvals.wait(aid) - if not approved: - return {"error": "User denied AutoGUI desktop task."} - await send_event("tool_running", {"tool": "autogui_task", "command": summary}) - try: - client = get_autogui_client() - return await client.start_task(task=task_desc, model=model or None, dry_run=dry_run) - except (_httpx.ConnectError, _httpx.TimeoutException, _httpx.TransportError) as e: - 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 - if not svc_state.is_enabled("osso"): - return {"error": "OSScreenObserver is disabled. Enable it in Settings > Services."} - try: - return await get_osso_client().windows() - except (_httpx.ConnectError, _httpx.TimeoutException, _httpx.TransportError) as e: - 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 - if not svc_state.is_enabled("osso"): - return {"error": "OSScreenObserver is disabled. Enable it in Settings > Services."} - try: - return await get_osso_client().description( - window_index=args.get("window_index"), - mode=args.get("mode", "accessibility"), - ) - except (_httpx.ConnectError, _httpx.TimeoutException, _httpx.TransportError) as e: - 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 - if not svc_state.is_enabled("osso"): - return {"error": "OSScreenObserver is disabled. Enable it in Settings > Services."} - try: - return await get_osso_client().screenshot(window_index=args.get("window_index")) - except (_httpx.ConnectError, _httpx.TimeoutException, _httpx.TransportError) as e: - 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 - if not svc_state.is_enabled("osso"): - return {"error": "OSScreenObserver is disabled. Enable it in Settings > Services."} - action_type = args.get("action", "") - summary = f"screen_{action_type}" + (f" at ({args.get('x')}, {args.get('y')})" if "x" in args else "") - if mode != "trusted": - aid = approvals.new() - await send_event("approval_request", { - "approval_id": aid, - "tool": "screen_action", - "command": summary, - "reason": "OSScreenObserver will perform an action on the screen.", - }) - approved = await approvals.wait(aid) - if not approved: - return {"error": "User denied screen action."} - await send_event("tool_running", {"tool": "screen_action", "command": summary}) - try: - return await get_osso_client().action(args) - except (_httpx.ConnectError, _httpx.TimeoutException, _httpx.TransportError) as e: - return {"error": f"OSScreenObserver is enabled but could not be reached. ({e})"} - - return {"error": f"Unknown tool: {tool}"} - - -# --------------------------------------------------------------------------- -# Context-window management -# --------------------------------------------------------------------------- - -CONTEXT_TOKEN_LIMIT = 32_000 -_CONTEXT_CHAR_BUDGET = int(CONTEXT_TOKEN_LIMIT * 0.80 * 4) - - -def _count_chars(messages: list) -> int: - total = 0 - for m in messages: - c = m.get("content", "") - if isinstance(c, str): - total += len(c) - elif isinstance(c, list): - for part in c: - if isinstance(part, dict) and part.get("type") == "text": - total += len(part.get("text", "")) - return total - - -def trim_to_context(history: list, system_prompt: str) -> tuple[list, int]: - budget = max(_CONTEXT_CHAR_BUDGET - len(system_prompt), 4_000) - total = _count_chars(history) - if total <= budget: - return history, 0 - trimmed = list(history) - n_dropped = 0 - while total > budget and len(trimmed) > 2: - removed = trimmed.pop(0) - total -= _count_chars([removed]) - n_dropped += 1 - while trimmed and trimmed[0].get("role") != "user": - total -= _count_chars([trimmed[0]]) - trimmed.pop(0) - n_dropped += 1 - return trimmed, n_dropped - - -# --------------------------------------------------------------------------- -# OpenWebUI model listing -# --------------------------------------------------------------------------- - -async def fetch_models(config: dict) -> list[dict]: - base = normalize_base_url(config["base_url"]) - if not base: - raise HTTPException(400, "Set the OpenWebUI URL first.") - api_key = config.get("api_key", "") - profile = config.get("api_profile") - if not profile: - profile = await discover_profile(base, api_key) - if not profile: - raise HTTPException( - status_code=502, - detail="Couldn't find a working API endpoint at that URL.", - ) - config["api_profile"] = profile - config["base_url"] = base - save_json(CONFIG_PATH, config) - - headers = {"Authorization": f"Bearer {api_key}"} - async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: - try: - resp = await client.get(f"{base}{profile['models']}", headers=headers) - except httpx.HTTPError as exc: - raise HTTPException(status_code=502, detail=f"Cannot reach OpenWebUI: {exc}") - - if resp.status_code != 200: - new_profile = await discover_profile(base, api_key) - if new_profile and new_profile != profile: - config["api_profile"] = new_profile - save_json(CONFIG_PATH, config) - return await fetch_models(config) - raise HTTPException( - status_code=resp.status_code, - detail=f"OpenWebUI returned {resp.status_code}: {resp.text[:300]}", - ) - try: - body = resp.json() - except (json.JSONDecodeError, ValueError): - raise HTTPException(status_code=502, detail="Got a non-JSON response from the models endpoint.") - raw = body.get("data") if isinstance(body, dict) else body - out = [] - for m in raw or []: - mid = m.get("id") or m.get("name") - if not mid: - continue - out.append({"id": mid, "name": m.get("name") or mid}) - return out - - -# --------------------------------------------------------------------------- -# FastAPI app -# --------------------------------------------------------------------------- - -_transient_sweep_task: Optional[asyncio.Task] = None -_scheduler_task: Optional[asyncio.Task] = None - - -async def _transient_sweep_loop() -> None: - """Background loop: sweep stale transient uploads every hour.""" - while True: - try: - removed = _sweep_transient_uploads() - if removed: - logging.getLogger("betterwebui.uploads").info( - "Swept %d stale transient upload directories.", removed, - ) - except Exception as exc: - logging.getLogger("betterwebui.uploads").warning("Sweep failed: %s", exc) - await asyncio.sleep(3600) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - global _transient_sweep_task, _scheduler_task - # ── startup ──────────────────────────────────────────────────────────── - try: - await mcp_manager.reconcile() - except Exception as exc: - print(f"[BetterWebUI] MCP startup error: {exc}") - # One sweep at boot so test fixtures get a clean state. - try: - _sweep_transient_uploads() - except Exception: - pass - _transient_sweep_task = asyncio.create_task(_transient_sweep_loop()) - try: - from scheduler import start_scheduler - _scheduler_task = asyncio.create_task(start_scheduler( - tasks_path=SCHEDULED_TASKS_PATH, - run_callback=_run_scheduled_task, - send_notification=_emit_scheduled_notification, - )) - except Exception as exc: - logging.getLogger("betterwebui.scheduler").warning("Scheduler failed to start: %s", exc) - yield - # ── shutdown ─────────────────────────────────────────────────────────── - if _transient_sweep_task is not None: - _transient_sweep_task.cancel() - if _scheduler_task is not None: - _scheduler_task.cancel() - for client in list(mcp_manager.clients.values()): - try: - await client.stop() - except Exception: - pass - - -app = FastAPI(title="BetterWebUI", lifespan=lifespan) - - -@app.get("/") -async def index(): - return FileResponse(ROOT / "static" / "index.html") - - -app.mount("/static", StaticFiles(directory=ROOT / "static"), name="static") -app.mount("/uploads", StaticFiles(directory=UPLOADS_DIR), name="uploads") - - -# --- Settings --- - -class ConfigPatch(BaseModel): - base_url: Optional[str] = None - api_key: Optional[str] = None - default_model: Optional[str] = None - image_model: Optional[str] = None - tts_voice: Optional[str] = None - active_prompt_id: Optional[str] = None - active_skills: Optional[list[str]] = None - active_workspace_id: Optional[str] = None - auto_approve_safe: Optional[bool] = None - shell_enabled: Optional[bool] = None - consensus_runs: Optional[int] = None - chat_mode: Optional[str] = None - onboarding_done: Optional[bool] = None - display: Optional[dict] = None - verification: Optional[dict] = None - web_search: Optional[dict] = None - - -def _public_config(cfg: dict, include_paths: bool = False) -> dict: - safe = dict(cfg) - safe["api_key_set"] = bool(safe.get("api_key")) - safe["api_key"] = "" - profile = safe.get("api_profile") - if isinstance(profile, dict): - safe["api_profile_label"] = profile.get("label", profile.get("name", "")) - else: - safe["api_profile_label"] = "" - # workspace_dir is the absolute server path; only return it to local - # callers (UI hint) so a network-exposed server doesn't leak server - # filesystem layout in every config response. - if include_paths: - safe["workspace_dir"] = str(Path(WORKSPACE_DIR).resolve()) - return safe - - -def _is_local_caller(request: Request) -> bool: - try: - _require_local_caller(request) - return True - except HTTPException: - return False - - -@app.get("/api/config") -async def get_config(request: Request): - return _public_config(load_config(), include_paths=_is_local_caller(request)) - - -@app.post("/api/config") -async def set_config(patch: ConfigPatch, request: Request): - cfg = load_config() - payload = patch.model_dump(exclude_none=True) - url_changed = False - key_changed = False - for k, v in payload.items(): - if k == "base_url": - new_url = normalize_base_url(v) - if new_url != cfg.get("base_url"): - url_changed = True - cfg[k] = new_url - elif k == "api_key": - if v != cfg.get("api_key"): - key_changed = True - cfg[k] = v - else: - cfg[k] = v - if url_changed or key_changed: - cfg["api_profile"] = None - if cfg.get("base_url") and cfg.get("api_key") and not cfg.get("api_profile"): - try: - profile = await discover_profile(cfg["base_url"], cfg["api_key"]) - if profile: - cfg["api_profile"] = profile - except Exception: - pass - save_json(CONFIG_PATH, cfg) - return _public_config(cfg, include_paths=_is_local_caller(request)) - - -# --- Models --- - -@app.get("/api/models") -async def get_models(): - cfg = load_config() - if not cfg.get("api_key") or not cfg.get("base_url"): - return {"models": [], "error": "Set your OpenWebUI URL and API key first."} - try: - models = await fetch_models(cfg) - except HTTPException as exc: - return {"models": [], "error": str(exc.detail)} - return {"models": models} - - -@app.get("/api/recommend-model") -async def recommend_model(use_case: str = "general"): - cfg = load_config() - try: - models = await fetch_models(cfg) - except HTTPException: - return {"recommendation": None} - if not models: - return {"recommendation": None} - # Simple heuristic: prefer models with "gpt-4" or "claude" in name for complex tasks, - # smaller models for grading/simple tasks - heavy = ["gpt-4", "claude-opus", "claude-3-5", "llama-3.3", "mixtral-8x22"] - light = ["gpt-3.5", "claude-haiku", "llama-3.1-8b", "phi", "mistral-7b"] - if use_case in ("research", "coding", "writing"): - for h in heavy: - m = next((x for x in models if h in x["id"].lower()), None) - 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) - 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."} - - -# --- System prompts --- - -class PromptIn(BaseModel): - id: Optional[str] = None - name: str - content: str - - -@app.get("/api/system-prompts") -async def list_prompts(): - return load_prompts() - - -@app.post("/api/system-prompts") -async def upsert_prompt(p: PromptIn): - data = load_prompts() - pid = p.id or p.name.lower().replace(" ", "-") - existing = next((x for x in data["prompts"] if x["id"] == pid), None) - if existing: - existing["name"] = p.name - existing["content"] = p.content - else: - data["prompts"].append({"id": pid, "name": p.name, "content": p.content}) - save_json(PROMPTS_PATH, data) - return {"id": pid} - - -@app.delete("/api/system-prompts/{prompt_id}") -async def delete_prompt(prompt_id: str): - data = load_prompts() - data["prompts"] = [x for x in data["prompts"] if x["id"] != prompt_id] - save_json(PROMPTS_PATH, data) - return {"ok": True} - - -# --- Skills --- - -@app.get("/api/skills") -async def list_skills(): - return {"skills": list_skill_files()} - - -@app.get("/api/skills/{skill_id}") -async def get_skill(skill_id: str): - skill = load_skill_content(skill_id) - if not skill: - raise HTTPException(404, "Skill not found") - return skill - - -@app.post("/api/skills/upload") -async def upload_skill(file: UploadFile = File(...)): - if not file.filename.endswith(".md"): - raise HTTPException(400, "Skills must be .md files with frontmatter.") - safe_name = Path(file.filename).name - dest = SKILLS_DIR / safe_name - content = await file.read() - dest.write_bytes(content) - return {"id": dest.stem, "filename": safe_name} - - -class SkillIn(BaseModel): - id: str - name: str - description: str - content: str - - -@app.post("/api/skills") -async def create_skill(s: SkillIn): - safe_id = "".join(c for c in s.id if c.isalnum() or c in "-_").strip("-_") or "skill" - body = f"---\nname: {s.name}\ndescription: {s.description}\n---\n\n{s.content}\n" - (SKILLS_DIR / f"{safe_id}.md").write_text(body, encoding="utf-8") - return {"id": safe_id} - - -@app.delete("/api/skills/{skill_id}") -async def delete_skill(skill_id: str): - path = SKILLS_DIR / f"{skill_id}.md" - if path.exists(): - path.unlink() - return {"ok": True} - - -# --- Approvals --- - -class ApprovalIn(BaseModel): - approval_id: str - approved: bool - trust_session: Optional[bool] = False - command: Optional[str] = None - - -@app.post("/api/approve") -async def approve(a: ApprovalIn, request: Request): - # Approving an in-flight tool call can release shell/file-write side - # effects, so restrict the endpoint to local callers up front (matching - # /api/project/* and /api/session/trust). Remote clients can't approve - # tool calls without the operator's local browser. - _require_local_caller(request) - ok = approvals.resolve(a.approval_id, a.approved) - if not ok: - raise HTTPException(404, "Unknown approval id") - if a.approved and a.trust_session and a.command: - _session_trusted_commands.add(a.command) - return {"ok": True} - - -class SessionTrustIn(BaseModel): - command: str - - -_DOCKER_BRIDGE_RANGE = ipaddress.ip_network("172.16.0.0/12") - - -def _require_local_caller(request: Request) -> None: - """Reject requests that don't come from a local client. - - Session-trust state and the project-file endpoints can affect on-disk - state, so they shouldn't be reachable from arbitrary remote hosts when - the server is bound to 0.0.0.0. Default: loopback only. Docker bridge - and broader LAN access are opt-in via env vars: - BETTERWEBUI_ALLOW_DOCKER=true → also accept 172.16.0.0/12 (the - Docker bridge range). docker-compose.yml - sets this by default so the - containerized UI just works. - BETTERWEBUI_ALLOW_LAN=true → accept any RFC1918 private IP - BETTERWEBUI_REQUIRE_LOCAL=strict → loopback only (overrides both above) - - The narrower default protects bare-metal deployments on real LANs that - happen to use 172.16/12 — they no longer leak local-only endpoints to - LAN peers just because the network's CIDR overlaps Docker's range. - """ - client_host = (request.client.host if request.client else "") or "" - if client_host in ("127.0.0.1", "::1", "localhost", "testclient"): - return - mode = os.environ.get("BETTERWEBUI_REQUIRE_LOCAL", "").lower() - if mode == "strict": - raise HTTPException(403, "This endpoint is limited to local callers.") - try: - addr = ipaddress.ip_address(client_host) - except ValueError: - raise HTTPException(403, "This endpoint is limited to local callers.") - if addr.is_loopback: - return - # Docker bridge range is opt-in (default off) so a bare-metal install on - # a real 172.16/12 LAN doesn't accidentally expose local-only endpoints. - allow_docker = os.environ.get("BETTERWEBUI_ALLOW_DOCKER", "").lower() in ("1", "true", "yes") - if allow_docker and isinstance(addr, ipaddress.IPv4Address) and addr in _DOCKER_BRIDGE_RANGE: - return - # Broader LAN access is also opt-in. - allow_lan = os.environ.get("BETTERWEBUI_ALLOW_LAN", "").lower() in ("1", "true", "yes") - if allow_lan and addr.is_private: - return - raise HTTPException(403, "This endpoint is limited to local callers.") - - -@app.post("/api/session/trust") -async def session_trust(t: SessionTrustIn, request: Request): - _require_local_caller(request) - _session_trusted_commands.add(t.command) - return {"ok": True, "trusted_count": len(_session_trusted_commands)} - - -@app.get("/api/session/trust") -async def list_session_trust(request: Request): - _require_local_caller(request) - return {"commands": list(_session_trusted_commands)} - - -@app.delete("/api/session/trust") -async def clear_session_trust(request: Request): - _require_local_caller(request) - _session_trusted_commands.clear() - return {"ok": True} - - -# --- File-picker responses --- - -class FileResponseIn(BaseModel): - request_id: str - files: Optional[list] = None - action: Optional[str] = None - - -@app.post("/api/file-response") -async def post_file_response(r: FileResponseIn): - ok = file_responses.resolve(r.request_id, r.files or []) - if not ok: - raise HTTPException(404, "Unknown file request id") - return {"ok": True} - - -# --- Workspaces --- - -class WorkspaceIn(BaseModel): - id: Optional[str] = None - name: str - description: Optional[str] = "" - system_prompt_id: Optional[str] = None - active_skills: Optional[list[str]] = None - active_mcp_servers: Optional[list[str]] = None - active_cli_tools: Optional[list[str]] = None - files: Optional[list[dict]] = None - default_model: Optional[str] = None - project_root: Optional[str] = None - mode: Optional[str] = None - shell_approval_policy: Optional[str] = None - - -@app.get("/api/workspaces") -async def list_workspaces_endpoint(request: Request): - _require_local_caller(request) - return load_workspaces() - - -@app.get("/api/workspaces/{wid}") -async def get_workspace(request: Request, wid: str): - _require_local_caller(request) - data = load_workspaces() - w = next((x for x in data["workspaces"] if x["id"] == wid), None) - if not w: - raise HTTPException(404, "Workspace not found") - return w - - -@app.post("/api/workspaces") -async def upsert_workspace(w: WorkspaceIn, request: Request): - _require_local_caller(request) - # Reject project_root values that escape WORKSPACE_DIR up front so the user - # gets actionable feedback. Relative paths are resolved against - # WORKSPACE_DIR (e.g., "my-project" → "/my-project") rather - # than the server process CWD. _resolve_project_root still clamps as - # defense-in-depth. - if w.project_root: - base = Path(WORKSPACE_DIR).resolve() - candidate_path = Path(w.project_root) - if not candidate_path.is_absolute(): - candidate_path = base / candidate_path - try: - normalized = candidate_path.resolve() - normalized.relative_to(base) - except (ValueError, OSError): - # Don't include the resolved base path in the error message: this - # endpoint is gated by _require_local_caller, but defense-in-depth - # is cheap and we still avoid serializing absolute filesystem - # paths in any HTTP response body. - raise HTTPException( - 400, - "project_root must be inside the configured workspace directory.", - ) - # Create the directory if it doesn't exist yet so /api/project/tree - # and execute_shell can use it immediately without "no such directory" - # surprises. The path is already proven safe (under WORKSPACE_DIR). - try: - normalized.mkdir(parents=True, exist_ok=True) - except OSError as exc: - raise HTTPException( - 400, - f"Could not create project_root: {exc}", - ) - if not normalized.is_dir(): - raise HTTPException( - 400, - "project_root exists but is not a directory.", - ) - # Persist the normalized absolute path so downstream code uses a - # consistent value regardless of what the user typed. - w.project_root = str(normalized) - data = load_workspaces() - wid = w.id or "".join(c for c in w.name.lower() if c.isalnum() or c in "-_ ").strip().replace(" ", "-") or uuid.uuid4().hex[:8] - payload = w.model_dump(exclude_none=True) - payload["id"] = wid - payload.setdefault("active_skills", []) - payload.setdefault("active_mcp_servers", []) - payload.setdefault("active_cli_tools", []) - payload.setdefault("files", []) - payload["updated_at"] = int(time.time()) - existing_idx = next((i for i, x in enumerate(data["workspaces"]) if x["id"] == wid), None) - if existing_idx is not None: - data["workspaces"][existing_idx] = {**data["workspaces"][existing_idx], **payload} - else: - payload["created_at"] = payload["updated_at"] - data["workspaces"].append(payload) - save_json(WORKSPACES_PATH, data) - return {"id": wid} - - -@app.delete("/api/workspaces/{wid}") -async def delete_workspace(request: Request, wid: str): - _require_local_caller(request) - data = load_workspaces() - data["workspaces"] = [x for x in data["workspaces"] if x["id"] != wid] - save_json(WORKSPACES_PATH, data) - cfg = load_config() - if cfg.get("active_workspace_id") == wid: - cfg["active_workspace_id"] = "" - save_json(CONFIG_PATH, cfg) - return {"ok": True} - - -@app.get("/api/workspaces/{wid}/export") -async def export_workspace(request: Request, wid: str): - _require_local_caller(request) - data = load_workspaces() - w = next((x for x in data["workspaces"] if x["id"] == wid), None) - if not w: - raise HTTPException(404, "Workspace not found") - prompts = load_prompts() - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: - # Manifest - manifest = { - "version": "1", - "name": w["name"], - "description": w.get("description", ""), - "exported_at": int(time.time()), - "active_skills": w.get("active_skills", []), - "active_mcp_servers": w.get("active_mcp_servers", []), - "active_cli_tools": w.get("active_cli_tools", []), - "mode": w.get("mode", "approve-each"), - } - zf.writestr("manifest.json", json.dumps(manifest, indent=2)) - # System prompt - pid = w.get("system_prompt_id") - if pid: - p = next((x for x in prompts["prompts"] if x["id"] == pid), None) - if p: - zf.writestr("system_prompt.json", json.dumps(p, indent=2)) - # Skills - for sid in w.get("active_skills", []): - skill = load_skill_content(sid) - if skill: - path = SKILLS_DIR / f"{sid}.md" - if path.exists(): - zf.write(path, f"skills/{sid}.md") - # MCP stubs (no secrets) - mcp_data = load_mcp_servers() - mcp_stubs = [] - for sname in w.get("active_mcp_servers", []): - s = next((x for x in mcp_data.get("servers", []) if x["name"] == sname), None) - if s: - stub = {k: v for k, v in s.items() if k not in ("env",)} - stub["env_keys"] = list((s.get("env") or {}).keys()) - mcp_stubs.append(stub) - zf.writestr("mcp_servers.json", json.dumps({"servers": mcp_stubs}, indent=2)) - # CLI tools - cli_data = load_cli_tools() - cli_items = [c for c in cli_data.get("tools", []) if c["id"] in w.get("active_cli_tools", [])] - zf.writestr("cli_tools.json", json.dumps({"tools": cli_items}, indent=2)) - # Bundle manifest (filenames + hashes provided by client via query param) - # The actual file bytes are NOT included — the manifest is just metadata so - # the recipient knows which bundles existed and can provide the files themselves. - bundle_manifest = w.get("bundle_manifest") - if bundle_manifest and isinstance(bundle_manifest, list): - zf.writestr("bundle_manifest.json", json.dumps(bundle_manifest, indent=2)) - buf.seek(0) - safe_name = _slug(w["name"], "workspace") - return Response( - content=buf.read(), - media_type="application/zip", - headers={"Content-Disposition": f'attachment; filename="{safe_name}.bwui"'}, - ) - - -@app.post("/api/workspaces/{wid}/bundle-manifest") -async def set_workspace_bundle_manifest(request: Request, wid: str): - """Client posts bundle metadata (filenames + hashes) to be included in exports.""" - _require_local_caller(request) - body = await request.json() - manifest = body.get("manifest") if isinstance(body, dict) else None - if not isinstance(manifest, list): - raise HTTPException(400, "Expected {'manifest': [...]} body.") - data = load_workspaces() - w = next((x for x in data["workspaces"] if x["id"] == wid), None) - if not w: - raise HTTPException(404, "Workspace not found") - safe_manifest = [] - for entry in manifest[:200]: - if not isinstance(entry, dict): - continue - safe_manifest.append({ - "bundle_id": str(entry.get("bundle_id", ""))[:64], - "name": str(entry.get("name", ""))[:128], - "files": [ - {"filename": str(f.get("filename", ""))[:256], "sha256": str(f.get("sha256", ""))[:64]} - for f in (entry.get("files") or [])[:500] - if isinstance(f, dict) - ], - }) - idx = next((i for i, x in enumerate(data["workspaces"]) if x["id"] == wid), None) - data["workspaces"][idx]["bundle_manifest"] = safe_manifest - save_json(WORKSPACES_PATH, data) - return {"ok": True, "bundle_count": len(safe_manifest)} - - -_MAX_BUNDLE_BYTES = 10 * 1024 * 1024 # 10 MB compressed -_MAX_MEMBER_BYTES = 2 * 1024 * 1024 # 2 MB per uncompressed member -_MAX_BUNDLE_MEMBERS = 500 # cap member count to bound iteration cost -_MAX_BUNDLE_TOTAL_BYTES = 50 * 1024 * 1024 # cap total uncompressed bytes (zip-bomb guard) - - -@app.post("/api/workspaces/import") -async def import_workspace(request: Request, file: UploadFile = File(...)): - _require_local_caller(request) - content = await file.read(_MAX_BUNDLE_BYTES + 1) - if len(content) > _MAX_BUNDLE_BYTES: - raise HTTPException(413, "Workspace bundle too large (max 10 MB).") - try: - buf = io.BytesIO(content) - with zipfile.ZipFile(buf, "r") as zf: - infos = zf.infolist() - if len(infos) > _MAX_BUNDLE_MEMBERS: - raise HTTPException(413, f"Bundle has too many entries (max {_MAX_BUNDLE_MEMBERS}).") - total_uncompressed = 0 - for info in infos: - if info.file_size > _MAX_MEMBER_BYTES: - raise HTTPException(413, f"Bundle member '{info.filename}' exceeds 2 MB limit.") - total_uncompressed += info.file_size - if total_uncompressed > _MAX_BUNDLE_TOTAL_BYTES: - raise HTTPException(413, f"Bundle uncompressed total exceeds {_MAX_BUNDLE_TOTAL_BYTES} bytes.") - names = zf.namelist() - manifest = json.loads(zf.read("manifest.json")) - # Import system prompt if present - prompt_id = None - if "system_prompt.json" in names: - p = json.loads(zf.read("system_prompt.json")) - p_data = load_prompts() - existing = next((x for x in p_data["prompts"] if x["id"] == p["id"]), None) - if not existing: - p_data["prompts"].append(p) - save_json(PROMPTS_PATH, p_data) - prompt_id = p["id"] - # Import skills. Skip any skill whose target filename already - # exists in SKILLS_DIR so a bundle can't silently overwrite a - # user's local skill with the same name. The names of the - # skipped files come back in the response so the UI can show - # the user what wasn't imported. - imported_skills: list[str] = [] - skipped_skills: list[str] = [] - for name in names: - if name.startswith("skills/") and name.endswith(".md"): - skill_bytes = zf.read(name) - dest = SKILLS_DIR / Path(name).name - if dest.exists(): - skipped_skills.append(dest.name) - continue - dest.write_bytes(skill_bytes) - imported_skills.append(dest.name) - # Import CLI tools - if "cli_tools.json" in names: - imported_cli = json.loads(zf.read("cli_tools.json")) - cli_data = load_cli_tools() - existing_ids = {c["id"] for c in cli_data.get("tools", [])} - for c in imported_cli.get("tools", []): - if c["id"] not in existing_ids: - cli_data["tools"].append(c) - save_json(CLI_PATH, cli_data) - # Import MCP server stubs (safe fields only — env keys noted but not restored) - if "mcp_servers.json" in names: - imported_mcp = json.loads(zf.read("mcp_servers.json")) - mcp_data = load_mcp_servers() - existing_names = {s["name"] for s in mcp_data.get("servers", [])} - for s in imported_mcp.get("servers", []): - if s.get("name") and s["name"] not in existing_names: - # env_keys is informational; don't restore actual env values - stub = {k: v for k, v in s.items() if k != "env_keys"} - mcp_data["servers"].append(stub) - save_json(MCP_PATH, mcp_data) - # Create the workspace - wid = uuid.uuid4().hex[:8] - ws_data = load_workspaces() - ws_data["workspaces"].append({ - "id": wid, - "name": manifest.get("name", "Imported Workspace"), - "description": manifest.get("description", ""), - "system_prompt_id": prompt_id, - "active_skills": manifest.get("active_skills", []), - "active_mcp_servers": manifest.get("active_mcp_servers", []), - "active_cli_tools": manifest.get("active_cli_tools", []), - "files": [], - "mode": manifest.get("mode", "approve-each"), - "created_at": int(time.time()), - "updated_at": int(time.time()), - }) - save_json(WORKSPACES_PATH, ws_data) - return { - "id": wid, - "name": manifest.get("name"), - "imported_skills": imported_skills, - "skipped_skills": skipped_skills, - } - except HTTPException: - raise - except Exception as exc: - raise HTTPException(400, f"Invalid workspace bundle: {exc}") - - -# --- Onboarding --- - -@app.get("/api/onboarding/templates") -async def onboarding_templates(): - return {"templates": ONBOARDING_TEMPLATES} - - -class OnboardingCompleteIn(BaseModel): - template_id: Optional[str] = None - workspace_name: Optional[str] = None - - -@app.post("/api/onboarding/complete") -async def onboarding_complete(body: OnboardingCompleteIn): - cfg = load_config() - - # If a template was requested, validate it before flipping onboarding_done - # so a bad template_id can't permanently skip the wizard. - if body.template_id: - tmpl = next((t for t in ONBOARDING_TEMPLATES if t["id"] == body.template_id), None) - if not tmpl: - raise HTTPException(400, f"Unknown onboarding template '{body.template_id}'.") - ws_data = load_workspaces() - wid = uuid.uuid4().hex[:8] - ws_name = body.workspace_name or tmpl["name"] - # Upsert system prompt - p_data = load_prompts() - pid = f"onboarding-{tmpl['id']}" - if not any(x["id"] == pid for x in p_data["prompts"]): - p_data["prompts"].append({"id": pid, "name": ws_name, "content": tmpl["system_prompt"]}) - save_json(PROMPTS_PATH, p_data) - ws_data["workspaces"].append({ - "id": wid, - "name": ws_name, - "description": tmpl["description"], - "system_prompt_id": pid, - "active_skills": tmpl.get("skills", []), - "active_mcp_servers": tmpl.get("mcp", []), - "active_cli_tools": tmpl.get("cli", []), - "files": [], - "mode": "approve-each", - "created_at": int(time.time()), - "updated_at": int(time.time()), - }) - save_json(WORKSPACES_PATH, ws_data) - # Set as active and only now flip onboarding_done, so partial failures - # above re-raise (caller can retry) instead of locking out the wizard. - cfg["active_workspace_id"] = wid - cfg["onboarding_done"] = True - save_json(CONFIG_PATH, cfg) - return {"ok": True, "workspace_id": wid, "workspace_name": ws_name} - - cfg["onboarding_done"] = True - save_json(CONFIG_PATH, cfg) - return {"ok": True} - - -# --- Project (file tree + checkpoints) --- - -def _resolve_project_root(workspace: Optional[dict]) -> str: - """Resolve a workspace's project root, restricting it to live under - WORKSPACE_DIR. This prevents an unauthenticated caller (via /api/workspaces) - from pointing a workspace at '/' or another sensitive directory and using - the project file APIs to browse the host filesystem.""" - root, _clamped = _resolve_project_root_info(workspace) - return root - - -def _resolve_project_root_info(workspace: Optional[dict]) -> tuple[str, bool]: - """Same resolution as _resolve_project_root but also reports whether the - stored project_root had to be clamped to WORKSPACE_DIR. - - Returns (effective_root, clamped). `clamped=True` means the caller set an - out-of-bounds project_root that we silently coerced — useful for UI hints - that distinguish "no project root configured" from "configured but invalid" - even when the user intentionally pointed project_root at WORKSPACE_DIR - itself (in which case clamped is False). - """ - requested = (workspace or {}).get("project_root") - base = Path(WORKSPACE_DIR).resolve() - if not requested: - return str(base), False - try: - candidate = Path(requested) - # Resolve relative paths against WORKSPACE_DIR (not the process CWD), - # matching the validation logic in upsert_workspace. - if not candidate.is_absolute(): - candidate = base / candidate - candidate = candidate.resolve() - candidate.relative_to(base) - return str(candidate), False - except (ValueError, OSError): - return str(base), True - - -@app.get("/api/project/tree") -async def project_tree(request: Request, path: str = ""): - _require_local_caller(request) - cfg = load_config() - workspace = resolve_active_workspace(cfg) - root, clamped = _resolve_project_root_info(workspace) - root_path = Path(root).resolve() - - # Determine which subdirectory to list (support lazy directory expansion) - if path: - target = (root_path / path).resolve() - try: - target.relative_to(root_path) - except ValueError: - raise HTTPException(403, "Path outside project root.") - else: - target = root_path - - if not target.exists(): - raise HTTPException(404, "Path not found.") - if not target.is_dir(): - raise HTTPException(400, f"'{path or '.'}' is not a directory.") - - entries = [] - try: - for p in sorted(target.iterdir()): - if p.name.startswith("."): - continue - # Skip symlinks entirely so a link inside the project root that - # points outside doesn't leak the target's metadata (size/mtime) - # in the listing. /api/project/file already blocks reading them. - if p.is_symlink(): - continue - rel = str(p.relative_to(root_path)) - # Use lstat to be explicit about not following links; the - # is_symlink() check above already filters them, but lstat keeps - # the metadata accurate for the entry we list. - st = p.lstat() - if p.is_dir(): - entries.append({"type": "dir", "name": p.name, "path": rel}) - else: - entries.append({ - "type": "file", - "name": p.name, - "path": rel, - "size": st.st_size, - "modified_at": int(st.st_mtime), - "ext": p.suffix.lower(), - }) - except Exception as exc: - raise HTTPException(500, f"Could not list directory: {exc}") - # Don't leak the absolute filesystem root to the client; the frontend - # only needs the relative entries to render and request further paths. - # Two flags so the UI can show three distinct states: - # * project_root_set=false → no value configured - # * project_root_set=false, project_root_clamped → invalid value, fell - # back to the safe base - # * project_root_set=true → configured and honored - has_value = bool((workspace or {}).get("project_root")) - project_root_set = has_value and not clamped - project_root_clamped = has_value and clamped - return { - "entries": entries, - "project_root_set": project_root_set, - "project_root_clamped": project_root_clamped, - } - - -_MAX_PROJECT_FILE_BYTES = 1 * 1024 * 1024 # 1 MB cap for /api/project/file - - -@app.get("/api/project/file") -async def project_file(request: Request, path: str, include_content: bool = False): - _require_local_caller(request) - cfg = load_config() - workspace = resolve_active_workspace(cfg) - root = _resolve_project_root(workspace) - full = Path(root) / path - try: - full.resolve().relative_to(Path(root).resolve()) - except ValueError: - raise HTTPException(403, "Path outside project root.") - if not full.exists(): - raise HTTPException(404, "File not found.") - if not full.is_file(): - raise HTTPException(400, "Path is not a file.") - size = full.stat().st_size - truncated = size > _MAX_PROJECT_FILE_BYTES - content = None - is_binary = False - if include_content: - try: - # Read cap+1 bytes so we can detect truncation from the read itself - # (not just from stat, which can lie on streaming filesystems or fifos). - with full.open("rb") as fh: - raw = fh.read(_MAX_PROJECT_FILE_BYTES + 1) - truncated = len(raw) > _MAX_PROJECT_FILE_BYTES - if truncated: - raw = raw[:_MAX_PROJECT_FILE_BYTES] - # NUL byte heuristic + strict UTF-8 decode: anything that fails either - # check is treated as binary so the diff modal's binary guard works. - if b"\x00" in raw: - content = base64.b64encode(raw).decode("ascii") - is_binary = True - else: - try: - content = raw.decode("utf-8", errors="strict") - is_binary = False - except UnicodeDecodeError: - content = base64.b64encode(raw).decode("ascii") - is_binary = True - except Exception as exc: - raise HTTPException(500, f"Could not read file: {exc}") - else: - # Metadata-only path: sniff a small header to flag binary so the UI can - # decide whether to do a follow-up include_content=true fetch. This - # avoids reading the full 1 MB body when the caller only wants metadata. - try: - with full.open("rb") as fh: - header = fh.read(4096) - if b"\x00" in header: - is_binary = True - else: - try: - header.decode("utf-8", errors="strict") - is_binary = False - except UnicodeDecodeError: - is_binary = True - except Exception: - # If even the header read fails, default to "looks binary" so the - # UI shows the preview-not-available branch instead of attempting - # a follow-up content fetch that will likely also fail. - is_binary = True - # The frontend treats binary files as "preview not available" and never - # reads the bytes, so omit the base64 content by default to keep responses - # small. Callers that genuinely need the bytes can pass include_content=true. - payload = { - "path": path, - "is_binary": is_binary, - "size": size, - "modified_at": int(full.stat().st_mtime), - "truncated": truncated, - } - # include_content gates the body for both text and binary so the frontend - # can do a cheap metadata-only first request before deciding to fetch the - # bytes. Defaults to false (set in the route signature). - if include_content: - payload["content"] = content - return payload - - -@app.get("/api/project/checkpoints") -async def list_project_checkpoints(request: Request, filename: Optional[str] = None): - _require_local_caller(request) - if not filename: - return {"checkpoints": []} - cfg = load_config() - workspace = resolve_active_workspace(cfg) - wid = (workspace or {}).get("id", "default") - return {"checkpoints": _list_checkpoints(wid, filename)} - - -class RevertIn(BaseModel): - filename: str - checkpoint_id: str - - -@app.post("/api/project/revert") -async def revert_project_file(r: RevertIn, request: Request): - _require_local_caller(request) - cfg = load_config() - workspace = resolve_active_workspace(cfg) - wid = (workspace or {}).get("id", "default") - content = _get_checkpoint(wid, r.filename, r.checkpoint_id) - if content is None: - raise HTTPException(404, "Checkpoint not found.") - root = _resolve_project_root(workspace) - dest = Path(root) / r.filename - # Reject path traversal: ensure dest stays under the resolved project root - try: - dest.resolve().relative_to(Path(root).resolve()) - except ValueError: - raise HTTPException(403, "Path outside project root.") - try: - dest.parent.mkdir(parents=True, exist_ok=True) - # content is raw bytes — binary checkpoints round-trip without loss. - dest.write_bytes(content) - except Exception as exc: - raise HTTPException(500, f"Could not write file: {exc}") - return {"ok": True, "filename": r.filename, "bytes": len(content)} - - -# --- MCP servers --- - -class MCPServerIn(BaseModel): - name: str - command: str - args: Optional[list[str]] = None - env: Optional[dict] = None - description: Optional[str] = "" - enabled: Optional[bool] = True - - -@app.get("/api/mcp/registry") -async def mcp_registry(): - return {"registry": MCP_REGISTRY} - - -@app.get("/api/mcp/servers") -async def list_mcp_servers_endpoint(): - cfg = load_mcp_servers() - status_by_name = {s["name"]: s for s in mcp_manager.status()} - out = [] - for s in cfg.get("servers", []): - st = status_by_name.get(s["name"]) - out.append({ - **s, - "running": (st or {}).get("running", False), - "error": (st or {}).get("error"), - "tool_count": (st or {}).get("tool_count", 0), - "tools": (st or {}).get("tools", []), - }) - return {"servers": out} - - -@app.post("/api/mcp/servers") -async def upsert_mcp_server(s: MCPServerIn): - data = load_mcp_servers() - payload = s.model_dump(exclude_none=True) - existing = next((i for i, x in enumerate(data["servers"]) if x["name"] == s.name), None) - if existing is not None: - data["servers"][existing] = {**data["servers"][existing], **payload} - else: - data["servers"].append(payload) - save_json(MCP_PATH, data) - await mcp_manager.reconcile() - return {"name": s.name} - - -@app.delete("/api/mcp/servers/{name}") -async def delete_mcp_server(name: str): - data = load_mcp_servers() - data["servers"] = [x for x in data["servers"] if x["name"] != name] - save_json(MCP_PATH, data) - await mcp_manager.reconcile() - return {"ok": True} - - -@app.post("/api/mcp/reconcile") -async def mcp_reconcile_endpoint(): - await mcp_manager.reconcile() - return {"servers": mcp_manager.status()} - - -# --- CLI shortcuts --- - -class CliToolIn(BaseModel): - id: str - name: str - description: Optional[str] = "" - command_template: str - examples: Optional[list[str]] = None - approval_policy: Optional[str] = "ask" - - -@app.get("/api/cli/registry") -async def cli_registry(): - return {"registry": CLI_REGISTRY} - - -@app.get("/api/cli/tools") -async def list_cli_tools_endpoint(): - return load_cli_tools() - - -@app.post("/api/cli/tools") -async def upsert_cli_tool(t: CliToolIn): - data = load_cli_tools() - payload = t.model_dump(exclude_none=True) - existing = next((i for i, x in enumerate(data["tools"]) if x["id"] == t.id), None) - if existing is not None: - data["tools"][existing] = {**data["tools"][existing], **payload} - else: - data["tools"].append(payload) - save_json(CLI_PATH, data) - return {"id": t.id} - - -@app.delete("/api/cli/tools/{tid}") -async def delete_cli_tool(tid: str): - data = load_cli_tools() - data["tools"] = [x for x in data["tools"] if x["id"] != tid] - save_json(CLI_PATH, data) - return {"ok": True} - - -# --- File uploads --- - -@app.post("/api/upload") -async def upload_file(file: UploadFile = File(...)): - safe_name = f"{uuid.uuid4().hex}_{Path(file.filename).name}" - dest = UPLOADS_DIR / safe_name - async with aiofiles.open(dest, "wb") as f: - while chunk := await file.read(1024 * 64): - await f.write(chunk) - return {"url": f"/uploads/{safe_name}", "filename": file.filename, "content_type": file.content_type} - - -# --- Transient uploads (per-chat, TTL-swept). Used for file bundles that -# live in browser IndexedDB and are streamed up only for the duration of -# the chat turn — keeps sensitive bytes off the server long-term. - -_TRANSIENT_TTL_SECONDS = 24 * 3600 - - -def _transient_root() -> Path: - """Resolve the transient-uploads directory lazily from the current - UPLOADS_DIR. Lazy resolution lets test fixtures rebind UPLOADS_DIR - without these endpoints pointing at the stale module-load value.""" - root = UPLOADS_DIR / "transient" - root.mkdir(parents=True, exist_ok=True) - return root - - -def _sweep_transient_uploads() -> int: - """Delete transient-upload chat directories older than the TTL. - Returns the count of directories removed. Safe to call on a timer.""" - cutoff = time.time() - _TRANSIENT_TTL_SECONDS - removed = 0 - try: - for chat_dir in _transient_root().iterdir(): - if not chat_dir.is_dir(): - continue - try: - if chat_dir.stat().st_mtime < cutoff: - shutil.rmtree(chat_dir, ignore_errors=True) - removed += 1 - except Exception: - continue - except FileNotFoundError: - pass - return removed - - -@app.post("/api/uploads/transient") -async def upload_transient_file(request: Request, file: UploadFile = File(...)): - """Accept a file scoped to a single chat. Files older than the TTL - are swept automatically. The caller passes chat_id as a query param.""" - _require_local_caller(request) - chat_id = request.query_params.get("chat_id") or "anon" - # Sanitize chat_id: alphanumeric / dash / underscore only. - chat_id = re.sub(r"[^A-Za-z0-9_-]+", "_", chat_id)[:64].strip("._-") or "anon" - chat_dir = _transient_root() / chat_id - chat_dir.mkdir(parents=True, exist_ok=True) - safe_name = f"{uuid.uuid4().hex}_{Path(file.filename or 'file').name}" - dest = chat_dir / safe_name - async with aiofiles.open(dest, "wb") as f: - while chunk := await file.read(1024 * 64): - await f.write(chunk) - return { - "url": f"/uploads/transient/{chat_id}/{safe_name}", - "filename": file.filename, - "content_type": file.content_type, - } - - -@app.delete("/api/uploads/transient/{chat_id}") -async def delete_transient_chat(chat_id: str, request: Request): - _require_local_caller(request) - chat_id = re.sub(r"[^A-Za-z0-9_-]+", "_", chat_id)[:64].strip("._-") - if not chat_id: - raise HTTPException(400, "Invalid chat_id.") - chat_dir = _transient_root() / chat_id - if chat_dir.exists(): - shutil.rmtree(chat_dir, ignore_errors=True) - return {"ok": True, "chat_id": chat_id} - - -# --- Scheduled tasks --- - -# Queue of pending scheduled-task notifications. The /api/scheduled-tasks/notifications/stream -# SSE endpoint drains this and pushes to the browser; once delivered the -# in-memory list is cleared. We persist nothing — recently-missed -# notifications can still be read from each task's history field. -_scheduled_notifications: list[dict] = [] - - -async def _emit_scheduled_notification(task: dict, result: dict) -> None: - _scheduled_notifications.append({ - "id": task.get("id"), - "name": task.get("name"), - "ok": bool(result.get("ok", True)), - "summary": (result.get("summary") or "")[:500], - "ts": time.time(), - }) - - -async def _run_scheduled_task(task: dict) -> dict: - """Execute a scheduled task by running its prompt through the same code - path as /api/chat. Returns {ok, summary}.""" - cfg = load_config() - if not cfg.get("api_key") or not cfg.get("base_url"): - return {"ok": False, "summary": "BetterWebUI is not connected to a backend."} - # Honour the task's workspace if it specifies one. - if task.get("workspace_id"): - cfg = dict(cfg) - cfg["active_workspace_id"] = task["workspace_id"] - prompts = load_prompts() - model = cfg.get("default_model") or "" - if not model: - return {"ok": False, "summary": "No default model configured."} - system_prompt = build_system_prompt(cfg, prompts, mode="trusted") - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": (task.get("prompt") or "").strip() or "(no prompt)"}, - ] - try: - text, _usage = await chat_complete(messages, model, cfg) - except Exception as exc: - return {"ok": False, "summary": f"LLM call failed: {exc}"} - return {"ok": True, "summary": (text or "").strip()[:1000]} - - -class ScheduledTaskIn(BaseModel): - id: Optional[str] = None - name: str - prompt: str - workspace_id: Optional[str] = "" - schedule: dict - enabled: Optional[bool] = True - - -@app.get("/api/scheduled-tasks") -async def list_scheduled_tasks(request: Request): - _require_local_caller(request) - from scheduler import list_tasks - return {"tasks": list_tasks(SCHEDULED_TASKS_PATH)} - - -@app.post("/api/scheduled-tasks") -async def create_or_update_scheduled_task(body: ScheduledTaskIn, request: Request): - _require_local_caller(request) - from scheduler import upsert_task - task = body.model_dump() - if not task.get("id"): - task["id"] = uuid.uuid4().hex - task.setdefault("history", []) - task.setdefault("last_run_at", None) - return upsert_task(SCHEDULED_TASKS_PATH, task) - - -@app.delete("/api/scheduled-tasks/{task_id}") -async def delete_scheduled_task(task_id: str, request: Request): - _require_local_caller(request) - from scheduler import delete_task - ok = delete_task(SCHEDULED_TASKS_PATH, task_id) - if not ok: - raise HTTPException(404, "Task not found.") - return {"ok": True} - - -@app.get("/api/verification/{chat_id}") -async def get_verification_log(chat_id: str, request: Request): - """Return verification trace entries for a chat (one per tool call).""" - _require_local_caller(request) - chat_id = re.sub(r"[^A-Za-z0-9_-]+", "_", chat_id)[:128].strip("._-") - if not chat_id: - raise HTTPException(400, "Invalid chat_id.") - path = DATA_DIR / "verification" / f"{chat_id}.jsonl" - if not path.exists(): - return {"entries": []} - entries: list[dict] = [] - try: - with open(path, "r", encoding="utf-8") as fh: - for line in fh: - line = line.strip() - if not line: - continue - try: - entries.append(json.loads(line)) - except Exception: - continue - except Exception as exc: - raise HTTPException(500, f"Could not read verification log: {exc}") - return {"entries": entries} - - -@app.get("/api/scheduled-tasks/notifications") -async def drain_scheduled_notifications(request: Request): - """Poll-style: returns and clears pending notifications. The frontend - polls this on a short interval rather than holding an SSE open.""" - _require_local_caller(request) - pending = list(_scheduled_notifications) - _scheduled_notifications.clear() - return {"notifications": pending} - - -# --- Memory extraction (client-side stored, server only synthesizes). --- - -class MemoryExtractIn(BaseModel): - user_message: str - assistant_message: str - model: Optional[str] = None - - -@app.post("/api/memory/extract") -async def memory_extract(body: MemoryExtractIn, request: Request): - _require_local_caller(request) - cfg = load_config() - model = body.model or cfg.get("default_model") or "" - if not model or not cfg.get("api_key") or not cfg.get("base_url"): - return {"candidates": []} - user_msg = (body.user_message or "")[:4000] - assistant_msg = (body.assistant_message or "")[:2000] - extraction_prompt = ( - "Examine this single user message and identify any DURABLE preferences, " - "facts, or constraints the user revealed that would help in future chats. " - "Examples of good memories: 'User is vegetarian', 'User prefers Python', " - "'User's company is named Acme'. Skip ephemeral things like a question " - "they just asked or a one-off task.\n\n" - f"User message:\n{user_msg}\n\n" - f"Assistant reply (for context):\n{assistant_msg}\n\n" - "Respond with JSON ONLY in this exact shape: " - '{"candidates": [{"text": "User ...", "category": "preference|fact|constraint|other"}]} ' - "or {\"candidates\": []} if nothing notable." - ) - messages = [ - {"role": "system", "content": "You are a careful assistant that returns JSON only."}, - {"role": "user", "content": extraction_prompt}, - ] - try: - text, _usage = await chat_complete(messages, model, cfg) - except Exception as exc: - return {"candidates": [], "error": str(exc)[:200]} - parsed = _verification._safe_json_parse(text) - if not isinstance(parsed, dict): - return {"candidates": []} - raw_candidates = parsed.get("candidates") if isinstance(parsed.get("candidates"), list) else [] - cleaned: list[dict] = [] - for c in raw_candidates[:5]: - if not isinstance(c, dict): - continue - t = (c.get("text") or "").strip() - if not t or len(t) > 280: - continue - cat = (c.get("category") or "other").strip().lower() - if cat not in {"preference", "fact", "constraint", "other"}: - cat = "other" - cleaned.append({"text": t, "category": cat}) - return {"candidates": cleaned} - - -# --- OAuth helper endpoints --- - -@app.get("/api/oauth/status/{provider}") -async def oauth_status(provider: str, request: Request): - _require_local_caller(request) - from services.oauth import get_oauth_status - return get_oauth_status(provider, DATA_DIR) - - -@app.post("/api/oauth/connect/{provider}") -async def oauth_connect(provider: str, request: Request): - """Return an authorization URL for the user to open in their browser.""" - _require_local_caller(request) - cfg = load_config() - try: - from services.oauth import start_oauth_flow - auth_url = await start_oauth_flow(provider, cfg, DATA_DIR) - return {"auth_url": auth_url} - except ValueError as exc: - raise HTTPException(400, str(exc)) - except Exception as exc: - raise HTTPException(500, f"OAuth connect failed: {exc}") - - -@app.delete("/api/oauth/disconnect/{provider}") -async def oauth_disconnect(provider: str, request: Request): - """Remove stored OAuth token.""" - _require_local_caller(request) - from services.oauth import revoke_oauth_token - removed = revoke_oauth_token(provider, DATA_DIR) - return {"removed": removed} - - -# --- Voice transcription --- - -_MAX_TRANSCRIBE_BYTES = 25 * 1024 * 1024 # 25 MB cap for /api/transcribe uploads - - -@app.post("/api/transcribe") -async def transcribe_audio(request: Request, file: UploadFile = File(...)): - # Proxies user-API-key requests to the backend, so restrict to local - # callers (matches /api/tts and /api/explain-command). - _require_local_caller(request) - cfg = load_config() - if not cfg.get("api_key") or not cfg.get("base_url"): - raise HTTPException(400, "Set your OpenWebUI URL and API key first.") - profile = active_profile(cfg) - base = normalize_base_url(cfg["base_url"]) - transcribe_url = f"{base}{profile.get('transcribe', '/api/v1/audio/transcriptions')}" - headers = {"Authorization": f"Bearer {cfg['api_key']}"} - audio_bytes = await file.read(_MAX_TRANSCRIBE_BYTES + 1) - if len(audio_bytes) > _MAX_TRANSCRIBE_BYTES: - raise HTTPException(413, "Audio upload too large (max 25 MB).") - async with httpx.AsyncClient(timeout=60.0) as client: - try: - resp = await client.post( - transcribe_url, - headers=headers, - files={"file": (file.filename or "audio.webm", audio_bytes, file.content_type or "audio/webm")}, - data={"model": "whisper-1"}, - ) - except httpx.HTTPError as exc: - raise HTTPException(502, f"Transcription request failed: {exc}") - if resp.status_code != 200: - raise HTTPException(resp.status_code, f"Transcription failed: {resp.text[:300]}") - try: - body = resp.json() - return {"text": body.get("text", "")} - except Exception: - return {"text": resp.text} - - -# --- Text-to-speech (read-aloud) --- - -class TtsIn(BaseModel): - text: str - voice: Optional[str] = None - - -@app.post("/api/tts") -async def tts_endpoint(body: TtsIn, request: Request): - _require_local_caller(request) - cfg = load_config() - if not cfg.get("api_key") or not cfg.get("base_url"): - raise HTTPException(400, "Set your OpenWebUI URL and API key first.") - voice = body.voice or cfg.get("tts_voice", "alloy") - result = await call_openwebui_audio(body.text[:4096], voice, cfg) - audio_bytes = base64.b64decode(result["data_b64"]) - return Response(content=audio_bytes, media_type="audio/mpeg") - - -# --- Command explanation --- - -class ExplainCommandIn(BaseModel): - command: str - - -@app.post("/api/explain-command") -async def explain_command(body: ExplainCommandIn, request: Request): - _require_local_caller(request) - cmd = body.command.strip() - if not cmd: - raise HTTPException(400, "Command is required.") - key = hashlib.md5(cmd.encode()).hexdigest()[:16] - if key in _command_explanation_cache: - return {"explanation": _command_explanation_cache[key], "cached": True} - cfg = load_config() - model = cfg.get("default_model", "") - if not model: - return {"explanation": "No model configured — cannot explain commands."} - messages = [ - {"role": "system", "content": "You are a plain-English explainer for shell commands. Keep your explanation to one or two sentences that a non-technical person can understand. Do not include any code or technical jargon."}, - {"role": "user", "content": f"Explain this command:\n\n{cmd}"}, - ] - try: - text, _ = await chat_complete(messages, model, cfg) - _command_explanation_cache[key] = text - return {"explanation": text} - except Exception as exc: - return {"explanation": f"Could not explain: {exc}"} - - -# --- Conversations --- - -@app.get("/api/conversations") -async def list_conversations(request: Request): - _require_local_caller(request) - data = load_conversations() - summary = [] - for cid, conv in data["conversations"].items(): - summary.append({ - "id": cid, - "title": conv.get("title", "Untitled"), - "updated_at": conv.get("updated_at", 0), - "pinned": conv.get("pinned", False), - "workspace_id": conv.get("workspace_id", ""), - "tags": conv.get("tags", []), - }) - summary.sort(key=lambda x: (not x["pinned"], -x["updated_at"])) - return {"conversations": summary} - - -@app.get("/api/conversations/recent") -async def recent_conversations(request: Request, limit: int = 3): - """Return the most-recently-updated conversations with their one-line summaries.""" - _require_local_caller(request) - data = load_conversations() - convs = sorted( - [{"id": cid, **conv} for cid, conv in data["conversations"].items()], - key=lambda x: -x.get("updated_at", 0), - )[:max(1, min(10, limit))] - return {"recent": [ - { - "id": c["id"], - "title": c.get("title", "Untitled"), - "updated_at": c.get("updated_at", 0), - "summary": c.get("summary", ""), - "message_count": len(c.get("messages", [])), - } - for c in convs - ]} - - -@app.post("/api/conversations/{cid}/summary") -async def set_conversation_summary(request: Request, cid: str): - """Store a one-line summary for a conversation (generated client-side or by the LLM).""" - _require_local_caller(request) - try: - body = await request.json() - except Exception: - body = {} - summary = str(body.get("summary", ""))[:300].strip() - data = load_conversations() - conv = data["conversations"].get(cid) - if not conv: - raise HTTPException(404, "Not found") - conv["summary"] = summary - save_json(CONVERSATIONS_PATH, data) - return {"ok": True} - - -@app.get("/api/conversations/search") -async def search_conversations(request: Request, q: str = ""): - _require_local_caller(request) - data = load_conversations() - q_lower = q.lower().strip() - results = [] - for cid, conv in data["conversations"].items(): - if not q_lower: - results.append({"id": cid, "title": conv.get("title", ""), "updated_at": conv.get("updated_at", 0)}) - continue - title = conv.get("title", "").lower() - msgs_text = " ".join( - m.get("content", "") for m in conv.get("messages", []) if isinstance(m.get("content"), str) - ).lower() - if q_lower in title or q_lower in msgs_text: - # Find first matching snippet - idx = msgs_text.find(q_lower) - snippet = "" - if idx != -1: - raw_text = " ".join( - m.get("content", "") for m in conv.get("messages", []) if isinstance(m.get("content"), str) - ) - snippet = raw_text[max(0, idx - 40) : idx + 80] - results.append({ - "id": cid, - "title": conv.get("title", ""), - "updated_at": conv.get("updated_at", 0), - "snippet": snippet, - }) - results.sort(key=lambda x: -x["updated_at"]) - return {"results": results[:50]} - - -@app.get("/api/conversations/{cid}") -async def get_conversation(request: Request, cid: str): - _require_local_caller(request) - data = load_conversations() - conv = data["conversations"].get(cid) - if not conv: - raise HTTPException(404, "Not found") - return conv - - -@app.delete("/api/conversations/{cid}") -async def delete_conversation(request: Request, cid: str): - _require_local_caller(request) - data = load_conversations() - data["conversations"].pop(cid, None) - save_json(CONVERSATIONS_PATH, data) - return {"ok": True} - - -class PinIn(BaseModel): - pinned: bool - - -@app.post("/api/conversations/{cid}/pin") -async def pin_conversation(request: Request, cid: str, body: PinIn): - _require_local_caller(request) - data = load_conversations() - conv = data["conversations"].get(cid) - if not conv: - raise HTTPException(404, "Not found") - conv["pinned"] = body.pinned - save_json(CONVERSATIONS_PATH, data) - return {"ok": True} - - -class TagIn(BaseModel): - tags: list[str] - - -@app.post("/api/conversations/{cid}/tags") -async def tag_conversation(request: Request, cid: str, body: TagIn): - _require_local_caller(request) - data = load_conversations() - conv = data["conversations"].get(cid) - if not conv: - raise HTTPException(404, "Not found") - conv["tags"] = body.tags - save_json(CONVERSATIONS_PATH, data) - return {"ok": True} - - -class ForkIn(BaseModel): - fork_at: Optional[int] = None # index sent by the JS client - from_message_index: Optional[int] = None # alias kept for back-compat - title: Optional[str] = None - - -@app.post("/api/conversations/{cid}/fork") -async def fork_conversation(request: Request, cid: str, body: ForkIn): - _require_local_caller(request) - data = load_conversations() - conv = data["conversations"].get(cid) - if not conv: - raise HTTPException(404, "Not found") - messages = conv.get("messages", []) - if body.fork_at is not None: - idx = body.fork_at - elif body.from_message_index is not None: - idx = body.from_message_index - else: - idx = len(messages) - 1 - idx = max(0, min(idx, len(messages) - 1)) - forked_messages = messages[: idx + 1] - new_cid = uuid.uuid4().hex - title = body.title or f"{conv.get('title', 'Conversation')} (fork)" - # Copy schema-shaped fields from the source so the forked conversation - # matches what save_conversation/load_conversations produce elsewhere. - # Forks start unpinned with the parent's tags, workspace, and current - # task plan snapshot — but as a brand-new conversation otherwise. - now = int(time.time()) - data["conversations"][new_cid] = { - "id": new_cid, - "title": title, - "messages": forked_messages, - "parent_id": cid, - "pinned": False, - "tags": list(conv.get("tags", [])), - "task_plan": list(conv.get("task_plan", [])), - "workspace_id": conv.get("workspace_id", ""), - "updated_at": now, - "created_at": now, - } - save_json(CONVERSATIONS_PATH, data) - return {"id": new_cid, "title": title} - - -def save_conversation(cid: str, title: str, messages: list, task_plan: Optional[list] = None, workspace_id: str = "") -> None: - data = load_conversations() - existing = data["conversations"].get(cid, {}) - data["conversations"][cid] = { - **existing, - "id": cid, - "title": title, - "messages": messages, - "task_plan": task_plan or [], - "workspace_id": workspace_id, - "updated_at": int(time.time()), - } - if "created_at" not in data["conversations"][cid]: - data["conversations"][cid]["created_at"] = int(time.time()) - save_json(CONVERSATIONS_PATH, data) - - -# --- Linting --- - -@app.get("/api/lint") -async def lint(): - skill_issues = _lint_skills() - mcp_issues = _lint_mcp() - cli_issues = _lint_cli() - all_issues = skill_issues + mcp_issues + cli_issues - return { - "ok": len(all_issues) == 0, - "issues": all_issues, - "skills": [i["issue"] for i in skill_issues], - "mcp": [i["issue"] for i in mcp_issues], - "cli": [i["issue"] for i in cli_issues], - } - - -# --- Branding --- - -@app.get("/api/branding") -async def get_branding(): - return load_json(BRANDING_PATH, {"logo": None, "primary_color": None, "welcome": None, "institution": None}) - - -# --- Chat (the main loop) --- - -class ChatRequest(BaseModel): - conversation_id: Optional[str] = None - messages: list - model: Optional[str] = None - title: Optional[str] = None - mode: Optional[str] = None - # Per-turn capability switches set by the composer toggles. - use_vision: Optional[bool] = None - web_search_mode: Optional[str] = None # "off" | "if_needed" | "required" - user_memories: Optional[list[str]] = None - bundle_attachments: Optional[list[dict]] = None - - -_VALID_ROLES = {"system", "user", "assistant", "function", "tool", "developer"} - - -def to_openai_messages(history: list, system_prompt: str) -> list: - out = [{"role": "system", "content": system_prompt}] - for m in history: - role = m.get("role", "user") - if role not in _VALID_ROLES: - continue - if role == "tool" and not m.get("tool_call_id"): - role = "user" - content = f"[Tool result]\n{m.get('content') or ''}" - out.append({"role": role, "content": content}) - continue - content = m.get("content") or "" - attachments = m.get("attachments") or [] - if attachments and role == "user": - parts = [{"type": "text", "text": content}] if content else [] - for a in attachments: - ctype = a.get("content_type") or "" - url = a.get("url") or "" - if ctype.startswith("image/"): - parts.append({"type": "image_url", "image_url": {"url": url}}) - else: - parts.append({"type": "text", "text": f"[Attachment: {a.get('filename') or url}]"}) - out.append({"role": role, "content": parts}) - else: - out.append({"role": role, "content": content}) - return out - - -@app.post("/api/chat") -async def chat(req: ChatRequest, request: Request): - # /api/chat can drive side-effecting tools (execute_shell, write_file, - # cli_call) which only require an /api/approve from the same operator. - # Restricting both endpoints to local callers means a network-exposed - # server can't be used to ride the operator's approval pipeline. - _require_local_caller(request) - cfg = load_config() - if not cfg.get("api_key") or not cfg.get("base_url"): - raise HTTPException(400, "Set your OpenWebUI URL and API key first.") - model = req.model or cfg.get("default_model") - if not model: - raise HTTPException(400, "Pick a model first.") - prompts = load_prompts() - cid = req.conversation_id or uuid.uuid4().hex - workspace = resolve_active_workspace(cfg) - workspace_id = (workspace or {}).get("id", "") - # Precedence: per-request mode → workspace.mode → config.chat_mode - effective_mode = req.mode or (workspace or {}).get("mode") or cfg.get("chat_mode", "approve-each") - - queue: asyncio.Queue = asyncio.Queue() - # 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", []) - - async def send_event(event: str, data: dict) -> None: - await queue.put({"event": event, "data": data}) - - async def run_loop() -> None: - nonlocal current_task_plan - history = [] - for m in req.messages: - if not isinstance(m, dict) or m.get("role") not in {"user", "assistant"}: - continue - if m.get("content") is None: - m = {**m, "content": ""} - history.append(m) - - # Merge bundle_attachments from mounted file workspaces into the most - # recent user message so the model has access without the user having - # to re-attach. We splice rather than replace so per-message attachments - # the user added in the composer survive. - if req.bundle_attachments and history: - last_user_idx = next( - (i for i in range(len(history) - 1, -1, -1) if history[i].get("role") == "user"), - None, - ) - if last_user_idx is not None: - msg = dict(history[last_user_idx]) - existing = list(msg.get("attachments") or []) - extras = [a for a in req.bundle_attachments if isinstance(a, dict) and a.get("url")] - msg["attachments"] = existing + extras - history[last_user_idx] = msg - - system_prompt = build_system_prompt( - cfg, prompts, effective_mode, - user_memories=req.user_memories, - use_vision=bool(req.use_vision), - web_search_mode=(req.web_search_mode or "off"), - ) - try: - for _step in range(12): # higher cap for subagent-heavy tasks - history, n_dropped = trim_to_context(history, system_prompt) - if n_dropped: - await send_event("notice", {"message": f"Context trimmed: {n_dropped} older message(s) removed."}) - openai_messages = to_openai_messages(history, system_prompt) - consensus_runs = max(1, min(10, cfg.get("consensus_runs", 1))) - await send_event("status", {"message": "Thinking…"}) - if consensus_runs > 1: - raw_responses = await asyncio.gather( - *[chat_complete(openai_messages, model, cfg, chat_id=f"{cid}-{i}") for i in range(consensus_runs)], - return_exceptions=True, - ) - valid = [(r[0], r[1]) for r in raw_responses if isinstance(r, tuple)] - if len(valid) < 2: - text, usage = valid[0] if valid else ("", {}) - else: - numbered = "\n\n".join(f"Response {i+1}:\n{r[0]}" for i, r in enumerate(valid)) - synthesis_messages = list(openai_messages) + [{ - "role": "user", - "content": ( - f"The preceding query was answered independently {len(valid)} times. " - "Synthesize the responses into a single unified reply. " - "Favor content where the responses agree.\n\n" + numbered - ), - }] - text, usage = await chat_complete(synthesis_messages, model, cfg, chat_id=cid) - else: - text, usage = await chat_complete(openai_messages, model, cfg, chat_id=cid) - - # Emit telemetry badge - tokens_in = usage.get("prompt_tokens", 0) - tokens_out = usage.get("completion_tokens", 0) - elapsed = usage.get("elapsed_ms", 0) - await send_event("assistant_text", { - "text": text, - "delta": text, # SSE-reader alias used by integration tests - "telemetry": { - "tokens_in": tokens_in, - "tokens_out": tokens_out, - "elapsed_ms": elapsed, - }, - }) - history.append({"role": "assistant", "content": text}) - - call = extract_tool_call(text) - if not call: - break - - await send_event("tool_call", {"tool": call["tool"], "args": call["args"]}) - - # Capture the user's most recent message as the goal for the - # verification judge. Falls back to empty string if absent. - user_goal_for_verif = "" - for _m in reversed(history): - if _m.get("role") == "user": - user_goal_for_verif = (_m.get("content") or "")[:2000] - break - - async def _execute_with_args(args_override: dict): - new_call = {"tool": call["tool"], "args": args_override} - return await execute_tool(new_call, cfg, send_event, effective_mode, model) - - async def _screenshot_provider(): - try: - from services import state as _svc_state - if not _svc_state.is_enabled("osso"): - return None - from services.clients import get_osso_client - shot = await get_osso_client().screenshot() - if isinstance(shot, dict) and shot.get("image_b64"): - return shot["image_b64"] - if isinstance(shot, dict) and shot.get("data_b64"): - return shot["data_b64"] - except Exception: - return None - return None - - first_result = await execute_tool(call, cfg, send_event, effective_mode, model) - - try: - result, vtrace = await _verification.verify_and_maybe_retry( - tool=call["tool"], - args=call["args"], - result=first_result, - goal=user_goal_for_verif, - config=cfg, - execute_again=_execute_with_args, - chat_complete=chat_complete, - screenshot_provider=_screenshot_provider, - ) - except Exception: - result, vtrace = first_result, None - - # Emit tool_result first so the UI's checkpoint cache is - # populated before the verification card (which may want to - # render an Undo button) arrives. - await send_event("tool_result", {"tool": call["tool"], "result": result}) - - if vtrace is not None and vtrace.events: - await send_event("verification", vtrace.to_dict()) - # Append one JSONL line per verification decision so - # power users / debugging can audit after the fact. - try: - _verif_log_dir = DATA_DIR / "verification" - _verif_log_dir.mkdir(parents=True, exist_ok=True) - with open(_verif_log_dir / f"{cid}.jsonl", "a", encoding="utf-8") as _vf: - _vf.write(json.dumps({ - "ts": time.time(), - "chat_id": cid, - "tool": call["tool"], - "trace": vtrace.to_dict(), - }) + "\n") - except Exception: - pass - - # Auto-engage consensus when the judge fails repeatedly on - # the same turn — surfaced via a notice, then we recompute. - if ( - vtrace is not None - and not vtrace.final_ok - and cfg.get("verification", {}).get("mode") == "validators_and_judge" - and cfg.get("consensus_runs", 1) <= 1 - ): - await send_event("notice", { - "message": "I wasn't confident in that result. I'll double-check on the next turn.", - }) - - # Persist task plan updates - if call["tool"] == "update_task_plan": - current_task_plan = result.get("items", call["args"].get("items", [])) - - result_for_model = dict(result) if isinstance(result, dict) else result - if isinstance(result_for_model, dict): - if "data_b64" in result_for_model: - size = len(result_for_model["data_b64"]) - result_for_model["data_b64"] = f"<{size} chars of base64 omitted; sent to user>" - if "files" in result_for_model and isinstance(result_for_model["files"], list): - for f in result_for_model["files"]: - if isinstance(f, dict) and "data_b64" in f: - size = len(f["data_b64"]) - f["data_b64"] = f"<{size} chars of base64 omitted>" - if "combined" in result_for_model and len(str(result_for_model.get("combined", ""))) > 8000: - result_for_model["combined"] = result_for_model["combined"][:8000] + "… [truncated]" - history.append({ - "role": "user", - "content": ( - f"[Tool '{call['tool']}' result]\n" - f"```json\n{json.dumps(result_for_model, indent=2)[:8000]}\n```" - ), - }) - - title = req.title or ( - history[0]["content"][:60] if history and history[0].get("content") else "Conversation" - ) - save_conversation(cid, title, history, current_task_plan, workspace_id) - await send_event("done", { - "_done": True, - "conversation_id": cid, - "messages": history, - "task_plan": current_task_plan, - }) - except HTTPException as exc: - await send_event("error", {"message": str(exc.detail)}) - except Exception as exc: - await send_event("error", {"message": f"{type(exc).__name__}: {exc}"}) - finally: - await queue.put(None) - - task = asyncio.create_task(run_loop()) - - async def event_stream() -> AsyncGenerator[bytes, None]: - try: - while True: - item = await queue.get() - if item is None: - break - yield f"event: {item['event']}\ndata: {json.dumps(item['data'])}\n\n".encode("utf-8") - finally: - task.cancel() - - return StreamingResponse(event_stream(), media_type="text/event-stream") - - -# ─── Services integration ──────────────────────────────────────────────────── - -from services.routes import register_routes as _register_service_routes - -_register_service_routes(app) - - -# --- Test-only reset endpoint --- -# Gated behind BWUI_TEST_MODE=1 so it never appears in production. Used by the -# Playwright UI suite to wipe persistent state between specs without restarting -# the server. - -@app.post("/api/test/reset") -async def test_reset(): - if os.environ.get("BWUI_TEST_MODE") != "1": - from fastapi import HTTPException - raise HTTPException(status_code=404, detail="Not Found") - wiped = [] - for path in (CONVERSATIONS_PATH, WORKSPACES_PATH, PROMPTS_PATH, - MCP_PATH, CLI_PATH): - if path.exists(): - try: - path.unlink() - wiped.append(path.name) - except OSError: - pass - # Reset onboarding_done in config WITHOUT deleting config.json — deleting it - # would race with parallel tests' ensureConfigured() that just set up - # base_url + api_key, leaving them with a stripped config mid-test. - if CONFIG_PATH.exists(): - try: - cfg = load_config() - if cfg.get("onboarding_done"): - cfg["onboarding_done"] = False - save_json(CONFIG_PATH, cfg) - wiped.append("onboarding_done") - except Exception: - pass - _session_trusted_commands.clear() - _command_explanation_cache.clear() - return {"ok": True, "wiped": wiped} - - -@app.post("/api/test/mock-chat") -async def test_mock_chat(request: Request): - """Toggle the chat mock on/off at runtime. Only available when BWUI_TEST_MODE=1. - - Body: {"enabled": true, "response": "optional custom text"} - Enabling makes chat_complete() return the canned response instantly so UI - tests exercise rendering/flow without waiting for a real model. - """ - global _mock_chat_enabled, _mock_chat_text - if os.environ.get("BWUI_TEST_MODE") != "1": - raise HTTPException(status_code=404, detail="Not Found") - body = await request.json() - _mock_chat_enabled = bool(body.get("enabled", True)) - if "response" in body: - _mock_chat_text = str(body["response"]) - return {"mock_chat": _mock_chat_enabled, "response": _mock_chat_text} - - -# --- Health --- - -@app.get("/api/health") -async def health(): - mcp_status = mcp_manager.status() - lint_issues = _lint_skills() + _lint_mcp() + _lint_cli() - return { - "ok": True, - "platform": platform.system(), - "shell": detect_shell()[1], - "skills": len(list_skill_files()), - "workspaces": len(load_workspaces()["workspaces"]), - "mcp_servers": len(mcp_status), - "mcp_running": sum(1 for s in mcp_status if s.get("running")), - "cli_tools": len(load_cli_tools()["tools"]), - "lint_issues": len(lint_issues), - "session_trusted_commands": len(_session_trusted_commands), - } +# Monkeypatch-compatible attribute forwarding. +# +# The test suite rebinds a handful of names on THIS module — +# monkeypatch.setattr(app_module, "DATA_DIR", tmp), plain assignment +# (app_module.WORKSPACE_DIR = ws), and mock.patch("app.chat_complete", ...). +# Those values now live in services modules, and every handler reads them +# from there via module-attribute access at call time. To keep the patches +# effective, this module's class is swapped for a ModuleType subclass whose +# data-descriptor properties forward get/set/delete for each such name to +# the owning services module. +# +# The delete path exists for mock.patch: on __exit__ for a non-__dict__ +# attribute it calls delattr() and then, only if the attribute no longer +# exists, restores the saved original via setattr(). fdel therefore parks a +# sentinel that makes fget raise AttributeError so that restore fires. +# --------------------------------------------------------------------------- + +_DELETED_SENTINEL = object() + + +def _forwarded_property(owner: types.ModuleType, attr: str) -> property: + def fget(_mod: types.ModuleType) -> Any: + value = getattr(owner, attr) + if value is _DELETED_SENTINEL: + raise AttributeError(attr) + return value + + def fset(_mod: types.ModuleType, value: Any) -> None: + setattr(owner, attr, value) + + def fdel(_mod: types.ModuleType) -> None: + setattr(owner, attr, _DELETED_SENTINEL) + + return property(fget, fset, fdel) + + +class _CompatForwardingModule(types.ModuleType): + """`app` module with property-forwarded legacy attributes.""" + + +_FORWARDED_ATTRS: dict[str, tuple[types.ModuleType, str]] = { + # Path constants (rebindable per-test via tests/conftest.py). + "DATA_DIR": (_storage_module, "DATA_DIR"), + "SKILLS_DIR": (_storage_module, "SKILLS_DIR"), + "WORKSPACE_DIR": (_storage_module, "WORKSPACE_DIR"), + "UPLOADS_DIR": (_storage_module, "UPLOADS_DIR"), + "CHECKPOINTS_DIR": (_storage_module, "CHECKPOINTS_DIR"), + "TASKS_DIR": (_storage_module, "TASKS_DIR"), + "CONFIG_PATH": (_storage_module, "CONFIG_PATH"), + "PROMPTS_PATH": (_storage_module, "PROMPTS_PATH"), + "CONVERSATIONS_PATH": (_storage_module, "CONVERSATIONS_PATH"), + "WORKSPACES_PATH": (_storage_module, "WORKSPACES_PATH"), + "MCP_PATH": (_storage_module, "MCP_PATH"), + "CLI_PATH": (_storage_module, "CLI_PATH"), + "BRANDING_PATH": (_storage_module, "BRANDING_PATH"), + "SCHEDULED_TASKS_PATH": (_storage_module, "SCHEDULED_TASKS_PATH"), + # The chat model call (mock.patch'ed by SSE/memory/scheduler tests). + "chat_complete": (_llm_module, "chat_complete"), + # SSE keepalive cadence (mock.patch'ed by the keepalive test). + "_sse_keepalive_interval": (_sse_proxy_module, "KEEPALIVE_INTERVAL"), +} + +for _public_name, (_owner_module, _owner_attr) in _FORWARDED_ATTRS.items(): + setattr(_CompatForwardingModule, _public_name, _forwarded_property(_owner_module, _owner_attr)) + +sys.modules[__name__].__class__ = _CompatForwardingModule if __name__ == "__main__": diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..67c8d93 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,154 @@ +// 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 = [ + { + // Classic scripts: lib.js (pure helpers, also require()-able from Node + // tests) and browser-store.js (defines the `bws` global). Loaded before + // the module graph so their globals are visible inside every module. + files: ["static/*.js"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "script", + globals: { ...browserGlobals, ...appGlobals }, + }, + rules: errorRules, + }, + { + // Native ES modules (zero-build). Split out of the former static/app.js. + files: ["static/js/**/*.js"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { ...browserGlobals, ...appGlobals }, + }, + rules: { + ...errorRules, + // Imported bindings are read-only live views; assigning to one is a + // runtime TypeError, so catch it statically. + "no-import-assign": "error", + }, + }, + { + 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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4c311b8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,62 @@ +# 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 + +[[tool.mypy.overrides]] +# routers/ is held to the same standard as services/: complete annotations +# on every handler, bodies checked. +module = "routers.*" +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true diff --git a/routers/__init__.py b/routers/__init__.py new file mode 100644 index 0000000..4e8a4b1 --- /dev/null +++ b/routers/__init__.py @@ -0,0 +1,35 @@ +"""FastAPI routers for BetterWebUI, one module per domain (Phase 3). + +app.py (the composition root) includes each router in ALL_ROUTERS order. +No paths overlap across routers, so the order only mirrors the original +app.py declaration order for readability. +""" +from . import ( + admin, + chat, + conversations, + mcp_cli, + oauth_routes, + onboarding, + project, + scheduled, + session_trust, + skills_prompts, + uploads, + workspaces, +) + +ALL_ROUTERS = [ + admin.router, + skills_prompts.router, + session_trust.router, + workspaces.router, + onboarding.router, + project.router, + mcp_cli.router, + uploads.router, + scheduled.router, + oauth_routes.router, + conversations.router, + chat.router, +] diff --git a/routers/admin.py b/routers/admin.py new file mode 100644 index 0000000..29198bc --- /dev/null +++ b/routers/admin.py @@ -0,0 +1,256 @@ +"""Admin/diagnostic routes: config, models, lint, branding, health, explain-command, memory extraction. + +Extracted from app.py (Phase 3). Route paths, request/response shapes, and +behavior are unchanged. Handlers reach shared state through the services +package (module-attribute access for anything tests monkeypatch via app.py's +forwarding properties). +""" +from __future__ import annotations + +import hashlib +import platform +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +import verification as _verification +from services import llm, mcp, session, skills, storage, tools + +router = APIRouter() + +# --- Settings --- + +class ConfigPatch(BaseModel): + base_url: Optional[str] = None + api_key: Optional[str] = None + default_model: Optional[str] = None + image_model: Optional[str] = None + tts_voice: Optional[str] = None + active_prompt_id: Optional[str] = None + active_skills: Optional[list[str]] = None + active_workspace_id: Optional[str] = None + auto_approve_safe: Optional[bool] = None + shell_enabled: Optional[bool] = None + consensus_runs: Optional[int] = None + chat_mode: Optional[str] = None + onboarding_done: Optional[bool] = None + display: Optional[dict] = None + verification: Optional[dict] = None + web_search: Optional[dict] = None + + +def _public_config(cfg: dict, include_paths: bool = False) -> dict: + safe = dict(cfg) + safe["api_key_set"] = bool(safe.get("api_key")) + safe["api_key"] = "" + profile = safe.get("api_profile") + if isinstance(profile, dict): + safe["api_profile_label"] = profile.get("label", profile.get("name", "")) + else: + safe["api_profile_label"] = "" + # workspace_dir is the absolute server path; only return it to local + # callers (UI hint) so a network-exposed server doesn't leak server + # filesystem layout in every config response. + if include_paths: + safe["workspace_dir"] = str(Path(storage.WORKSPACE_DIR).resolve()) + return safe +@router.get("/api/config") +async def get_config(request: Request) -> dict: + return _public_config(storage.load_config(), include_paths=session._is_local_caller(request)) + + +@router.post("/api/config") +async def set_config(patch: ConfigPatch, request: Request) -> dict: + cfg = storage.load_config() + payload = patch.model_dump(exclude_none=True) + url_changed = False + key_changed = False + for k, v in payload.items(): + if k == "base_url": + new_url = llm.normalize_base_url(v) + if new_url != cfg.get("base_url"): + url_changed = True + cfg[k] = new_url + elif k == "api_key": + if v != cfg.get("api_key"): + key_changed = True + cfg[k] = v + else: + cfg[k] = v + if url_changed or key_changed: + cfg["api_profile"] = None + if cfg.get("base_url") and cfg.get("api_key") and not cfg.get("api_profile"): + try: + profile = await llm.discover_profile(cfg["base_url"], cfg["api_key"]) + if profile: + cfg["api_profile"] = profile + except Exception: + pass + storage.save_json(storage.CONFIG_PATH, cfg) + return _public_config(cfg, include_paths=session._is_local_caller(request)) + + +# --- Models --- + +@router.get("/api/models") +async def get_models() -> dict: + cfg = storage.load_config() + if not cfg.get("api_key") or not cfg.get("base_url"): + return {"models": [], "error": "Set your OpenWebUI URL and API key first."} + try: + models = await llm.fetch_models(cfg) + except HTTPException as exc: + return {"models": [], "error": str(exc.detail)} + return {"models": models} + + +@router.get("/api/recommend-model") +async def recommend_model(use_case: str = "general") -> dict: + cfg = storage.load_config() + try: + models = await llm.fetch_models(cfg) + except HTTPException: + return {"recommendation": None} + if not models: + return {"recommendation": None} + # Simple heuristic: prefer models with "gpt-4" or "claude" in name for complex tasks, + # smaller models for grading/simple tasks + heavy = ["gpt-4", "claude-opus", "claude-3-5", "llama-3.3", "mixtral-8x22"] + light = ["gpt-3.5", "claude-haiku", "llama-3.1-8b", "phi", "mistral-7b"] + if use_case in ("research", "coding", "writing"): + for h in heavy: + m = next((x for x in models if h in x["id"].lower()), None) + if m: + return {"recommendation": m, "reason": f"This model handles complex {use_case} tasks well."} + else: + 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."} +# --- Linting --- + +@router.get("/api/lint") +async def lint() -> dict: + skill_issues = skills._lint_skills() + mcp_issues = skills._lint_mcp() + cli_issues = skills._lint_cli() + all_issues = skill_issues + mcp_issues + cli_issues + return { + "ok": len(all_issues) == 0, + "issues": all_issues, + "skills": [i["issue"] for i in skill_issues], + "mcp": [i["issue"] for i in mcp_issues], + "cli": [i["issue"] for i in cli_issues], + } + + +# --- Branding --- + +@router.get("/api/branding") +async def get_branding() -> dict: + return storage.load_json(storage.BRANDING_PATH, {"logo": None, "primary_color": None, "welcome": None, "institution": None}) +# --- Command explanation --- + +class ExplainCommandIn(BaseModel): + command: str + + +@router.post("/api/explain-command") +async def explain_command(body: ExplainCommandIn, request: Request) -> dict: + session._require_local_caller(request) + cmd = body.command.strip() + if not cmd: + raise HTTPException(400, "Command is required.") + key = hashlib.md5(cmd.encode()).hexdigest()[:16] + if key in session._command_explanation_cache: + return {"explanation": session._command_explanation_cache[key], "cached": True} + cfg = storage.load_config() + model = cfg.get("default_model", "") + if not model: + return {"explanation": "No model configured — cannot explain commands."} + messages = [ + {"role": "system", "content": "You are a plain-English explainer for shell commands. Keep your explanation to one or two sentences that a non-technical person can understand. Do not include any code or technical jargon."}, + {"role": "user", "content": f"Explain this command:\n\n{cmd}"}, + ] + try: + text, _ = await llm.chat_complete(messages, model, cfg) + session._command_explanation_cache[key] = text + return {"explanation": text} + except Exception as exc: + return {"explanation": f"Could not explain: {exc}"} +# --- Memory extraction (client-side stored, server only synthesizes). --- + +class MemoryExtractIn(BaseModel): + user_message: str + assistant_message: str + model: Optional[str] = None + + +@router.post("/api/memory/extract") +async def memory_extract(body: MemoryExtractIn, request: Request) -> dict: + session._require_local_caller(request) + cfg = storage.load_config() + model = body.model or cfg.get("default_model") or "" + if not model or not cfg.get("api_key") or not cfg.get("base_url"): + return {"candidates": []} + user_msg = (body.user_message or "")[:4000] + assistant_msg = (body.assistant_message or "")[:2000] + extraction_prompt = ( + "Examine this single user message and identify any DURABLE preferences, " + "facts, or constraints the user revealed that would help in future chats. " + "Examples of good memories: 'User is vegetarian', 'User prefers Python', " + "'User's company is named Acme'. Skip ephemeral things like a question " + "they just asked or a one-off task.\n\n" + f"User message:\n{user_msg}\n\n" + f"Assistant reply (for context):\n{assistant_msg}\n\n" + "Respond with JSON ONLY in this exact shape: " + '{"candidates": [{"text": "User ...", "category": "preference|fact|constraint|other"}]} ' + "or {\"candidates\": []} if nothing notable." + ) + messages = [ + {"role": "system", "content": "You are a careful assistant that returns JSON only."}, + {"role": "user", "content": extraction_prompt}, + ] + try: + text, _usage = await llm.chat_complete(messages, model, cfg) + except Exception as exc: + return {"candidates": [], "error": str(exc)[:200]} + parsed = _verification._safe_json_parse(text) + if not isinstance(parsed, dict): + return {"candidates": []} + raw_candidates = parsed.get("candidates") + if not isinstance(raw_candidates, list): + raw_candidates = [] + cleaned: list[dict] = [] + for c in raw_candidates[:5]: + if not isinstance(c, dict): + continue + t = (c.get("text") or "").strip() + if not t or len(t) > 280: + continue + cat = (c.get("category") or "other").strip().lower() + if cat not in {"preference", "fact", "constraint", "other"}: + cat = "other" + cleaned.append({"text": t, "category": cat}) + return {"candidates": cleaned} +# --- Health --- + +@router.get("/api/health") +async def health() -> dict: + mcp_status = mcp.mcp_manager.status() + lint_issues = skills._lint_skills() + skills._lint_mcp() + skills._lint_cli() + return { + "ok": True, + "platform": platform.system(), + "shell": tools.detect_shell()[1], + "skills": len(skills.list_skill_files()), + "workspaces": len(storage.load_workspaces()["workspaces"]), + "mcp_servers": len(mcp_status), + "mcp_running": sum(1 for s in mcp_status if s.get("running")), + "cli_tools": len(storage.load_cli_tools()["tools"]), + "lint_issues": len(lint_issues), + "session_trusted_commands": len(session._session_trusted_commands), + } diff --git a/routers/chat.py b/routers/chat.py new file mode 100644 index 0000000..a2a21f5 --- /dev/null +++ b/routers/chat.py @@ -0,0 +1,371 @@ +"""The main /api/chat SSE loop plus the BWUI_TEST_MODE-only test endpoints. + +Extracted from app.py (Phase 3). Route paths, request/response shapes, and +behavior are unchanged. Handlers reach shared state through the services +package (module-attribute access for anything tests monkeypatch via app.py's +forwarding properties). +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +import time +import uuid +from typing import AsyncGenerator, Optional + +from fastapi import APIRouter, HTTPException, Request +from fastapi.encoders import jsonable_encoder +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +import verification as _verification +from services import llm, prompt_builder, session, sse_proxy, storage, tools +from services.errors import code_for_status, error_envelope +from services.request_ctx import request_id_of + +logger = logging.getLogger("betterwebui.chat") + +router = APIRouter() + +# --- Chat (the main loop) --- + +class ChatRequest(BaseModel): + conversation_id: Optional[str] = None + messages: list + model: Optional[str] = None + title: Optional[str] = None + mode: Optional[str] = None + # Per-turn capability switches set by the composer toggles. + use_vision: Optional[bool] = None + web_search_mode: Optional[str] = None # "off" | "if_needed" | "required" + user_memories: Optional[list[str]] = None + bundle_attachments: Optional[list[dict]] = None +@router.post("/api/chat") +async def chat(req: ChatRequest, request: Request) -> StreamingResponse: + # /api/chat can drive side-effecting tools (execute_shell, write_file, + # cli_call) which only require an /api/approve from the same operator. + # Restricting both endpoints to local callers means a network-exposed + # server can't be used to ride the operator's approval pipeline. + session._require_local_caller(request) + cfg = storage.load_config() + if not cfg.get("api_key") or not cfg.get("base_url"): + raise HTTPException(400, "Set your OpenWebUI URL and API key first.") + model = req.model or cfg.get("default_model") + if not model: + raise HTTPException(400, "Pick a model first.") + prompts = storage.load_prompts() + cid = req.conversation_id or uuid.uuid4().hex + workspace = prompt_builder.resolve_active_workspace(cfg) + workspace_id = (workspace or {}).get("id", "") + # Precedence: per-request mode → workspace.mode → config.chat_mode + 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 = storage.load_conversations().get("conversations", {}).get(cid, {}) + current_task_plan: list = _existing_conv.get("task_plan", []) + + async def send_event(event: str, data: dict) -> None: + await queue.put({"event": event, "data": data}) + + async def run_loop() -> None: + nonlocal current_task_plan + history = [] + for m in req.messages: + if not isinstance(m, dict) or m.get("role") not in {"user", "assistant"}: + continue + if m.get("content") is None: + m = {**m, "content": ""} + history.append(m) + + # Merge bundle_attachments from mounted file workspaces into the most + # recent user message so the model has access without the user having + # to re-attach. We splice rather than replace so per-message attachments + # the user added in the composer survive. + if req.bundle_attachments and history: + last_user_idx = next( + (i for i in range(len(history) - 1, -1, -1) if history[i].get("role") == "user"), + None, + ) + if last_user_idx is not None: + msg = dict(history[last_user_idx]) + existing = list(msg.get("attachments") or []) + extras = [a for a in req.bundle_attachments if isinstance(a, dict) and a.get("url")] + msg["attachments"] = existing + extras + history[last_user_idx] = msg + + system_prompt = prompt_builder.build_system_prompt( + cfg, prompts, effective_mode, + user_memories=req.user_memories, + use_vision=bool(req.use_vision), + web_search_mode=(req.web_search_mode or "off"), + ) + try: + for _step in range(12): # higher cap for subagent-heavy tasks + history, n_dropped = llm.trim_to_context(history, system_prompt) + if n_dropped: + await send_event("notice", {"message": f"Context trimmed: {n_dropped} older message(s) removed."}) + openai_messages = llm.to_openai_messages(history, system_prompt) + consensus_runs = max(1, min(10, cfg.get("consensus_runs", 1))) + await send_event("status", {"message": "Thinking…"}) + if consensus_runs > 1: + raw_responses = await asyncio.gather( + *[llm.chat_complete(openai_messages, model, cfg, chat_id=f"{cid}-{i}") for i in range(consensus_runs)], + return_exceptions=True, + ) + valid = [(r[0], r[1]) for r in raw_responses if isinstance(r, tuple)] + if len(valid) < 2: + text, usage = valid[0] if valid else ("", {}) + else: + numbered = "\n\n".join(f"Response {i+1}:\n{r[0]}" for i, r in enumerate(valid)) + synthesis_messages = list(openai_messages) + [{ + "role": "user", + "content": ( + f"The preceding query was answered independently {len(valid)} times. " + "Synthesize the responses into a single unified reply. " + "Favor content where the responses agree.\n\n" + numbered + ), + }] + text, usage = await llm.chat_complete(synthesis_messages, model, cfg, chat_id=cid) + else: + text, usage = await llm.chat_complete(openai_messages, model, cfg, chat_id=cid) + + # Emit telemetry badge + tokens_in = usage.get("prompt_tokens", 0) + tokens_out = usage.get("completion_tokens", 0) + elapsed = usage.get("elapsed_ms", 0) + await send_event("assistant_text", { + "text": text, + "delta": text, # SSE-reader alias used by integration tests + "telemetry": { + "tokens_in": tokens_in, + "tokens_out": tokens_out, + "elapsed_ms": elapsed, + }, + }) + history.append({"role": "assistant", "content": text}) + + call = prompt_builder.extract_tool_call(text) + if not call: + break + + await send_event("tool_call", {"tool": call["tool"], "args": call["args"]}) + + # Capture the user's most recent message as the goal for the + # verification judge. Falls back to empty string if absent. + user_goal_for_verif = "" + for _m in reversed(history): + if _m.get("role") == "user": + user_goal_for_verif = (_m.get("content") or "")[:2000] + break + + async def _execute_with_args(args_override: dict) -> dict: + new_call = {"tool": call["tool"], "args": args_override} + return await tools.execute_tool(new_call, cfg, send_event, effective_mode, model) + + async def _screenshot_provider() -> Optional[str]: + try: + from services import state as _svc_state + if not _svc_state.is_enabled("osso"): + return None + from services.clients import get_osso_client + shot = await get_osso_client().screenshot() + if isinstance(shot, dict) and shot.get("image_b64"): + return shot["image_b64"] + if isinstance(shot, dict) and shot.get("data_b64"): + return shot["data_b64"] + 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 + + first_result = await tools.execute_tool(call, cfg, send_event, effective_mode, model) + + try: + result, vtrace = await _verification.verify_and_maybe_retry( + tool=call["tool"], + args=call["args"], + result=first_result, + goal=user_goal_for_verif, + config=cfg, + execute_again=_execute_with_args, + chat_complete=llm.chat_complete, + screenshot_provider=_screenshot_provider, + ) + 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 + # populated before the verification card (which may want to + # render an Undo button) arrives. + await send_event("tool_result", {"tool": call["tool"], "result": result}) + + if vtrace is not None and vtrace.events: + await send_event("verification", vtrace.to_dict()) + # Append one JSONL line per verification decision so + # power users / debugging can audit after the fact. + try: + _verif_log_dir = storage.DATA_DIR / "verification" + _verif_log_dir.mkdir(parents=True, exist_ok=True) + with open(_verif_log_dir / f"{cid}.jsonl", "a", encoding="utf-8") as _vf: + _vf.write(json.dumps({ + "ts": time.time(), + "chat_id": cid, + "tool": call["tool"], + "trace": vtrace.to_dict(), + }) + "\n") + 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. + if ( + vtrace is not None + and not vtrace.final_ok + and cfg.get("verification", {}).get("mode") == "validators_and_judge" + and cfg.get("consensus_runs", 1) <= 1 + ): + await send_event("notice", { + "message": "I wasn't confident in that result. I'll double-check on the next turn.", + }) + + # Persist task plan updates + if call["tool"] == "update_task_plan": + current_task_plan = result.get("items", call["args"].get("items", [])) + + result_for_model = dict(result) if isinstance(result, dict) else result + if isinstance(result_for_model, dict): + if "data_b64" in result_for_model: + size = len(result_for_model["data_b64"]) + result_for_model["data_b64"] = f"<{size} chars of base64 omitted; sent to user>" + if "files" in result_for_model and isinstance(result_for_model["files"], list): + for f in result_for_model["files"]: + if isinstance(f, dict) and "data_b64" in f: + size = len(f["data_b64"]) + f["data_b64"] = f"<{size} chars of base64 omitted>" + if "combined" in result_for_model and len(str(result_for_model.get("combined", ""))) > 8000: + result_for_model["combined"] = result_for_model["combined"][:8000] + "… [truncated]" + history.append({ + "role": "user", + "content": ( + f"[Tool '{call['tool']}' result]\n" + f"```json\n{json.dumps(result_for_model, indent=2)[:8000]}\n```" + ), + }) + + title = req.title or ( + history[0]["content"][:60] if history and history[0].get("content") else "Conversation" + ) + storage.save_conversation(cid, title, history, current_task_plan, workspace_id) + await send_event("done", { + "_done": True, + "conversation_id": cid, + "messages": history, + "task_plan": current_task_plan, + }) + except HTTPException as exc: + 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: + 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) + + task = asyncio.create_task(run_loop()) + + async def event_stream() -> AsyncGenerator[bytes, None]: + try: + while True: + try: + item = await asyncio.wait_for(queue.get(), timeout=sse_proxy.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") + finally: + task.cancel() + + return StreamingResponse(event_stream(), media_type="text/event-stream") +# --- Test-only reset endpoint --- +# Gated behind BWUI_TEST_MODE=1 so it never appears in production. Used by the +# Playwright UI suite to wipe persistent state between specs without restarting +# the server. + +@router.post("/api/test/reset") +async def test_reset() -> dict: + if os.environ.get("BWUI_TEST_MODE") != "1": + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Not Found") + wiped = [] + for path in (storage.CONVERSATIONS_PATH, storage.WORKSPACES_PATH, storage.PROMPTS_PATH, + storage.MCP_PATH, storage.CLI_PATH): + if path.exists(): + try: + path.unlink() + wiped.append(path.name) + except OSError: + pass + # Reset onboarding_done in config WITHOUT deleting config.json — deleting it + # would race with parallel tests' ensureConfigured() that just set up + # base_url + api_key, leaving them with a stripped config mid-test. + if storage.CONFIG_PATH.exists(): + try: + cfg = storage.load_config() + if cfg.get("onboarding_done"): + cfg["onboarding_done"] = False + storage.save_json(storage.CONFIG_PATH, cfg) + wiped.append("onboarding_done") + except Exception: + pass + session._session_trusted_commands.clear() + session._command_explanation_cache.clear() + return {"ok": True, "wiped": wiped} + + +@router.post("/api/test/mock-chat") +async def test_mock_chat(request: Request) -> dict: + """Toggle the chat mock on/off at runtime. Only available when BWUI_TEST_MODE=1. + + Body: {"enabled": true, "response": "optional custom text"} + Enabling makes llm.chat_complete() return the canned response instantly so UI + tests exercise rendering/flow without waiting for a real model. + """ + if os.environ.get("BWUI_TEST_MODE") != "1": + raise HTTPException(status_code=404, detail="Not Found") + body = await request.json() + llm._mock_chat_enabled = bool(body.get("enabled", True)) + if "response" in body: + llm._mock_chat_text = str(body["response"]) + return {"mock_chat": llm._mock_chat_enabled, "response": llm._mock_chat_text} diff --git a/routers/conversations.py b/routers/conversations.py new file mode 100644 index 0000000..2f3a065 --- /dev/null +++ b/routers/conversations.py @@ -0,0 +1,206 @@ +"""Conversation listing, search, summaries, pin/tag/fork, and deletion. + +Extracted from app.py (Phase 3). Route paths, request/response shapes, and +behavior are unchanged. Handlers reach shared state through the services +package (module-attribute access for anything tests monkeypatch via app.py's +forwarding properties). +""" +from __future__ import annotations + +import time +import uuid +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from services import session, storage + +router = APIRouter() + +# --- Conversations --- + +@router.get("/api/conversations") +async def list_conversations(request: Request) -> dict: + session._require_local_caller(request) + data = storage.load_conversations() + summary = [] + for cid, conv in data["conversations"].items(): + summary.append({ + "id": cid, + "title": conv.get("title", "Untitled"), + "updated_at": conv.get("updated_at", 0), + "pinned": conv.get("pinned", False), + "workspace_id": conv.get("workspace_id", ""), + "tags": conv.get("tags", []), + }) + summary.sort(key=lambda x: (not x["pinned"], -x["updated_at"])) + return {"conversations": summary} + + +@router.get("/api/conversations/recent") +async def recent_conversations(request: Request, limit: int = 3) -> dict: + """Return the most-recently-updated conversations with their one-line summaries.""" + session._require_local_caller(request) + data = storage.load_conversations() + convs = sorted( + [{"id": cid, **conv} for cid, conv in data["conversations"].items()], + key=lambda x: -x.get("updated_at", 0), + )[:max(1, min(10, limit))] + return {"recent": [ + { + "id": c["id"], + "title": c.get("title", "Untitled"), + "updated_at": c.get("updated_at", 0), + "summary": c.get("summary", ""), + "message_count": len(c.get("messages", [])), + } + for c in convs + ]} + + +@router.post("/api/conversations/{cid}/summary") +async def set_conversation_summary(request: Request, cid: str) -> dict: + """Store a one-line summary for a conversation (generated client-side or by the LLM).""" + session._require_local_caller(request) + try: + body = await request.json() + except Exception: + body = {} + summary = str(body.get("summary", ""))[:300].strip() + data = storage.load_conversations() + conv = data["conversations"].get(cid) + if not conv: + raise HTTPException(404, "Not found") + conv["summary"] = summary + storage.save_json(storage.CONVERSATIONS_PATH, data) + return {"ok": True} + + +@router.get("/api/conversations/search") +async def search_conversations(request: Request, q: str = "") -> dict: + session._require_local_caller(request) + data = storage.load_conversations() + q_lower = q.lower().strip() + results = [] + for cid, conv in data["conversations"].items(): + if not q_lower: + results.append({"id": cid, "title": conv.get("title", ""), "updated_at": conv.get("updated_at", 0)}) + continue + title = conv.get("title", "").lower() + msgs_text = " ".join( + m.get("content", "") for m in conv.get("messages", []) if isinstance(m.get("content"), str) + ).lower() + if q_lower in title or q_lower in msgs_text: + # Find first matching snippet + idx = msgs_text.find(q_lower) + snippet = "" + if idx != -1: + raw_text = " ".join( + m.get("content", "") for m in conv.get("messages", []) if isinstance(m.get("content"), str) + ) + snippet = raw_text[max(0, idx - 40) : idx + 80] + results.append({ + "id": cid, + "title": conv.get("title", ""), + "updated_at": conv.get("updated_at", 0), + "snippet": snippet, + }) + results.sort(key=lambda x: -x["updated_at"]) + return {"results": results[:50]} + + +@router.get("/api/conversations/{cid}") +async def get_conversation(request: Request, cid: str) -> dict: + session._require_local_caller(request) + data = storage.load_conversations() + conv = data["conversations"].get(cid) + if not conv: + raise HTTPException(404, "Not found") + return conv + + +@router.delete("/api/conversations/{cid}") +async def delete_conversation(request: Request, cid: str) -> dict: + session._require_local_caller(request) + data = storage.load_conversations() + data["conversations"].pop(cid, None) + storage.save_json(storage.CONVERSATIONS_PATH, data) + return {"ok": True} + + +class PinIn(BaseModel): + pinned: bool + + +@router.post("/api/conversations/{cid}/pin") +async def pin_conversation(request: Request, cid: str, body: PinIn) -> dict: + session._require_local_caller(request) + data = storage.load_conversations() + conv = data["conversations"].get(cid) + if not conv: + raise HTTPException(404, "Not found") + conv["pinned"] = body.pinned + storage.save_json(storage.CONVERSATIONS_PATH, data) + return {"ok": True} + + +class TagIn(BaseModel): + tags: list[str] + + +@router.post("/api/conversations/{cid}/tags") +async def tag_conversation(request: Request, cid: str, body: TagIn) -> dict: + session._require_local_caller(request) + data = storage.load_conversations() + conv = data["conversations"].get(cid) + if not conv: + raise HTTPException(404, "Not found") + conv["tags"] = body.tags + storage.save_json(storage.CONVERSATIONS_PATH, data) + return {"ok": True} + + +class ForkIn(BaseModel): + fork_at: Optional[int] = None # index sent by the JS client + from_message_index: Optional[int] = None # alias kept for back-compat + title: Optional[str] = None + + +@router.post("/api/conversations/{cid}/fork") +async def fork_conversation(request: Request, cid: str, body: ForkIn) -> dict: + session._require_local_caller(request) + data = storage.load_conversations() + conv = data["conversations"].get(cid) + if not conv: + raise HTTPException(404, "Not found") + messages = conv.get("messages", []) + if body.fork_at is not None: + idx = body.fork_at + elif body.from_message_index is not None: + idx = body.from_message_index + else: + idx = len(messages) - 1 + idx = max(0, min(idx, len(messages) - 1)) + forked_messages = messages[: idx + 1] + new_cid = uuid.uuid4().hex + title = body.title or f"{conv.get('title', 'Conversation')} (fork)" + # Copy schema-shaped fields from the source so the forked conversation + # matches what save_conversation/load_conversations produce elsewhere. + # Forks start unpinned with the parent's tags, workspace, and current + # task plan snapshot — but as a brand-new conversation otherwise. + now = int(time.time()) + data["conversations"][new_cid] = { + "id": new_cid, + "title": title, + "messages": forked_messages, + "parent_id": cid, + "pinned": False, + "tags": list(conv.get("tags", [])), + "task_plan": list(conv.get("task_plan", [])), + "workspace_id": conv.get("workspace_id", ""), + "updated_at": now, + "created_at": now, + } + storage.save_json(storage.CONVERSATIONS_PATH, data) + return {"id": new_cid, "title": title} diff --git a/routers/mcp_cli.py b/routers/mcp_cli.py new file mode 100644 index 0000000..e038e84 --- /dev/null +++ b/routers/mcp_cli.py @@ -0,0 +1,120 @@ +"""MCP server and CLI shortcut configuration routes. + +Extracted from app.py (Phase 3). Route paths, request/response shapes, and +behavior are unchanged. Handlers reach shared state through the services +package (module-attribute access for anything tests monkeypatch via app.py's +forwarding properties). +""" +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter +from pydantic import BaseModel + +from services import catalog, mcp, storage + +router = APIRouter() + +# --- MCP servers --- + +class MCPServerIn(BaseModel): + name: str + command: str + args: Optional[list[str]] = None + env: Optional[dict] = None + description: Optional[str] = "" + enabled: Optional[bool] = True + + +@router.get("/api/mcp/registry") +async def mcp_registry() -> dict: + return {"registry": catalog.MCP_REGISTRY} + + +@router.get("/api/mcp/servers") +async def list_mcp_servers_endpoint() -> dict: + cfg = storage.load_mcp_servers() + status_by_name = {s["name"]: s for s in mcp.mcp_manager.status()} + out = [] + for s in cfg.get("servers", []): + st = status_by_name.get(s["name"]) + out.append({ + **s, + "running": (st or {}).get("running", False), + "error": (st or {}).get("error"), + "tool_count": (st or {}).get("tool_count", 0), + "tools": (st or {}).get("tools", []), + }) + return {"servers": out} + + +@router.post("/api/mcp/servers") +async def upsert_mcp_server(s: MCPServerIn) -> dict: + data = storage.load_mcp_servers() + payload = s.model_dump(exclude_none=True) + existing = next((i for i, x in enumerate(data["servers"]) if x["name"] == s.name), None) + if existing is not None: + data["servers"][existing] = {**data["servers"][existing], **payload} + else: + data["servers"].append(payload) + storage.save_json(storage.MCP_PATH, data) + await mcp.mcp_manager.reconcile() + return {"name": s.name} + + +@router.delete("/api/mcp/servers/{name}") +async def delete_mcp_server(name: str) -> dict: + data = storage.load_mcp_servers() + data["servers"] = [x for x in data["servers"] if x["name"] != name] + storage.save_json(storage.MCP_PATH, data) + await mcp.mcp_manager.reconcile() + return {"ok": True} + + +@router.post("/api/mcp/reconcile") +async def mcp_reconcile_endpoint() -> dict: + await mcp.mcp_manager.reconcile() + return {"servers": mcp.mcp_manager.status()} + + +# --- CLI shortcuts --- + +class CliToolIn(BaseModel): + id: str + name: str + description: Optional[str] = "" + command_template: str + examples: Optional[list[str]] = None + approval_policy: Optional[str] = "ask" + + +@router.get("/api/cli/registry") +async def cli_registry() -> dict: + return {"registry": catalog.CLI_REGISTRY} + + +@router.get("/api/cli/tools") +async def list_cli_tools_endpoint() -> dict: + return storage.load_cli_tools() + + +@router.post("/api/cli/tools") +async def upsert_cli_tool(t: CliToolIn) -> dict: + data = storage.load_cli_tools() + payload = t.model_dump(exclude_none=True) + existing = next((i for i, x in enumerate(data["tools"]) if x["id"] == t.id), None) + if existing is not None: + data["tools"][existing] = {**data["tools"][existing], **payload} + else: + data["tools"].append(payload) + storage.save_json(storage.CLI_PATH, data) + return {"id": t.id} + + +@router.delete("/api/cli/tools/{tid}") +async def delete_cli_tool(tid: str) -> dict: + data = storage.load_cli_tools() + data["tools"] = [x for x in data["tools"] if x["id"] != tid] + storage.save_json(storage.CLI_PATH, data) + return {"ok": True} diff --git a/routers/oauth_routes.py b/routers/oauth_routes.py new file mode 100644 index 0000000..9057510 --- /dev/null +++ b/routers/oauth_routes.py @@ -0,0 +1,46 @@ +"""OAuth provider status/connect/disconnect routes. + +Extracted from app.py (Phase 3). Route paths, request/response shapes, and +behavior are unchanged. Handlers reach shared state through the services +package (module-attribute access for anything tests monkeypatch via app.py's +forwarding properties). +""" +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request + +from services import session, storage + +router = APIRouter() + +# --- OAuth helper endpoints --- + +@router.get("/api/oauth/status/{provider}") +async def oauth_status(provider: str, request: Request) -> dict: + session._require_local_caller(request) + from services.oauth import get_oauth_status + return get_oauth_status(provider, storage.DATA_DIR) + + +@router.post("/api/oauth/connect/{provider}") +async def oauth_connect(provider: str, request: Request) -> dict: + """Return an authorization URL for the user to open in their browser.""" + session._require_local_caller(request) + cfg = storage.load_config() + try: + from services.oauth import start_oauth_flow + auth_url = await start_oauth_flow(provider, cfg, storage.DATA_DIR) + return {"auth_url": auth_url} + except ValueError as exc: + raise HTTPException(400, str(exc)) + except Exception as exc: + raise HTTPException(500, f"OAuth connect failed: {exc}") + + +@router.delete("/api/oauth/disconnect/{provider}") +async def oauth_disconnect(provider: str, request: Request) -> dict: + """Remove stored OAuth token.""" + session._require_local_caller(request) + from services.oauth import revoke_oauth_token + removed = revoke_oauth_token(provider, storage.DATA_DIR) + return {"removed": removed} diff --git a/routers/onboarding.py b/routers/onboarding.py new file mode 100644 index 0000000..c7ccc88 --- /dev/null +++ b/routers/onboarding.py @@ -0,0 +1,75 @@ +"""Onboarding wizard templates and completion. + +Extracted from app.py (Phase 3). Route paths, request/response shapes, and +behavior are unchanged. Handlers reach shared state through the services +package (module-attribute access for anything tests monkeypatch via app.py's +forwarding properties). +""" +from __future__ import annotations + +import time +import uuid +from typing import Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from services import catalog, storage + +router = APIRouter() + +# --- Onboarding --- + +@router.get("/api/onboarding/templates") +async def onboarding_templates() -> dict: + return {"templates": catalog.ONBOARDING_TEMPLATES} + + +class OnboardingCompleteIn(BaseModel): + template_id: Optional[str] = None + workspace_name: Optional[str] = None + + +@router.post("/api/onboarding/complete") +async def onboarding_complete(body: OnboardingCompleteIn) -> dict: + cfg = storage.load_config() + + # If a template was requested, validate it before flipping onboarding_done + # so a bad template_id can't permanently skip the wizard. + if body.template_id: + tmpl = next((t for t in catalog.ONBOARDING_TEMPLATES if t["id"] == body.template_id), None) + if not tmpl: + raise HTTPException(400, f"Unknown onboarding template '{body.template_id}'.") + ws_data = storage.load_workspaces() + wid = uuid.uuid4().hex[:8] + ws_name = body.workspace_name or tmpl["name"] + # Upsert system prompt + p_data = storage.load_prompts() + pid = f"onboarding-{tmpl['id']}" + if not any(x["id"] == pid for x in p_data["prompts"]): + p_data["prompts"].append({"id": pid, "name": ws_name, "content": tmpl["system_prompt"]}) + storage.save_json(storage.PROMPTS_PATH, p_data) + ws_data["workspaces"].append({ + "id": wid, + "name": ws_name, + "description": tmpl["description"], + "system_prompt_id": pid, + "active_skills": tmpl.get("skills", []), + "active_mcp_servers": tmpl.get("mcp", []), + "active_cli_tools": tmpl.get("cli", []), + "files": [], + "mode": "approve-each", + "created_at": int(time.time()), + "updated_at": int(time.time()), + }) + storage.save_json(storage.WORKSPACES_PATH, ws_data) + # Set as active and only now flip onboarding_done, so partial failures + # above re-raise (caller can retry) instead of locking out the wizard. + cfg["active_workspace_id"] = wid + cfg["onboarding_done"] = True + storage.save_json(storage.CONFIG_PATH, cfg) + return {"ok": True, "workspace_id": wid, "workspace_name": ws_name} + + cfg["onboarding_done"] = True + storage.save_json(storage.CONFIG_PATH, cfg) + return {"ok": True} diff --git a/routers/project.py b/routers/project.py new file mode 100644 index 0000000..a341227 --- /dev/null +++ b/routers/project.py @@ -0,0 +1,211 @@ +"""Project file tree, file metadata/content, checkpoints, and revert. + +Extracted from app.py (Phase 3). Route paths, request/response shapes, and +behavior are unchanged. Handlers reach shared state through the services +package (module-attribute access for anything tests monkeypatch via app.py's +forwarding properties). +""" +from __future__ import annotations + +import base64 +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from services import prompt_builder, session, storage, tools + +router = APIRouter() + +# --- Project (file tree + checkpoints) --- +@router.get("/api/project/tree") +async def project_tree(request: Request, path: str = "") -> dict: + session._require_local_caller(request) + cfg = storage.load_config() + workspace = prompt_builder.resolve_active_workspace(cfg) + root, clamped = tools._resolve_project_root_info(workspace) + root_path = Path(root).resolve() + + # Determine which subdirectory to list (support lazy directory expansion) + if path: + target = (root_path / path).resolve() + try: + target.relative_to(root_path) + except ValueError: + raise HTTPException(403, "Path outside project root.") + else: + target = root_path + + if not target.exists(): + raise HTTPException(404, "Path not found.") + if not target.is_dir(): + raise HTTPException(400, f"'{path or '.'}' is not a directory.") + + entries: list[dict] = [] + try: + for p in sorted(target.iterdir()): + if p.name.startswith("."): + continue + # Skip symlinks entirely so a link inside the project root that + # points outside doesn't leak the target's metadata (size/mtime) + # in the listing. /api/project/file already blocks reading them. + if p.is_symlink(): + continue + rel = str(p.relative_to(root_path)) + # Use lstat to be explicit about not following links; the + # is_symlink() check above already filters them, but lstat keeps + # the metadata accurate for the entry we list. + st = p.lstat() + if p.is_dir(): + entries.append({"type": "dir", "name": p.name, "path": rel}) + else: + entries.append({ + "type": "file", + "name": p.name, + "path": rel, + "size": st.st_size, + "modified_at": int(st.st_mtime), + "ext": p.suffix.lower(), + }) + except Exception as exc: + raise HTTPException(500, f"Could not list directory: {exc}") + # Don't leak the absolute filesystem root to the client; the frontend + # only needs the relative entries to render and request further paths. + # Two flags so the UI can show three distinct states: + # * project_root_set=false → no value configured + # * project_root_set=false, project_root_clamped → invalid value, fell + # back to the safe base + # * project_root_set=true → configured and honored + has_value = bool((workspace or {}).get("project_root")) + project_root_set = has_value and not clamped + project_root_clamped = has_value and clamped + return { + "entries": entries, + "project_root_set": project_root_set, + "project_root_clamped": project_root_clamped, + } + + +_MAX_PROJECT_FILE_BYTES = 1 * 1024 * 1024 # 1 MB cap for /api/project/file + + +@router.get("/api/project/file") +async def project_file(request: Request, path: str, include_content: bool = False) -> dict: + session._require_local_caller(request) + cfg = storage.load_config() + workspace = prompt_builder.resolve_active_workspace(cfg) + root = tools._resolve_project_root(workspace) + full = Path(root) / path + try: + full.resolve().relative_to(Path(root).resolve()) + except ValueError: + raise HTTPException(403, "Path outside project root.") + if not full.exists(): + raise HTTPException(404, "File not found.") + if not full.is_file(): + raise HTTPException(400, "Path is not a file.") + size = full.stat().st_size + truncated = size > _MAX_PROJECT_FILE_BYTES + content = None + is_binary = False + if include_content: + try: + # Read cap+1 bytes so we can detect truncation from the read itself + # (not just from stat, which can lie on streaming filesystems or fifos). + with full.open("rb") as fh: + raw = fh.read(_MAX_PROJECT_FILE_BYTES + 1) + truncated = len(raw) > _MAX_PROJECT_FILE_BYTES + if truncated: + raw = raw[:_MAX_PROJECT_FILE_BYTES] + # NUL byte heuristic + strict UTF-8 decode: anything that fails either + # check is treated as binary so the diff modal's binary guard works. + if b"\x00" in raw: + content = base64.b64encode(raw).decode("ascii") + is_binary = True + else: + try: + content = raw.decode("utf-8", errors="strict") + is_binary = False + except UnicodeDecodeError: + content = base64.b64encode(raw).decode("ascii") + is_binary = True + except Exception as exc: + raise HTTPException(500, f"Could not read file: {exc}") + else: + # Metadata-only path: sniff a small header to flag binary so the UI can + # decide whether to do a follow-up include_content=true fetch. This + # avoids reading the full 1 MB body when the caller only wants metadata. + try: + with full.open("rb") as fh: + header = fh.read(4096) + if b"\x00" in header: + is_binary = True + else: + try: + header.decode("utf-8", errors="strict") + is_binary = False + except UnicodeDecodeError: + is_binary = True + except Exception: + # If even the header read fails, default to "looks binary" so the + # UI shows the preview-not-available branch instead of attempting + # a follow-up content fetch that will likely also fail. + is_binary = True + # The frontend treats binary files as "preview not available" and never + # reads the bytes, so omit the base64 content by default to keep responses + # small. Callers that genuinely need the bytes can pass include_content=true. + payload = { + "path": path, + "is_binary": is_binary, + "size": size, + "modified_at": int(full.stat().st_mtime), + "truncated": truncated, + } + # include_content gates the body for both text and binary so the frontend + # can do a cheap metadata-only first request before deciding to fetch the + # bytes. Defaults to false (set in the route signature). + if include_content: + payload["content"] = content + return payload + + +@router.get("/api/project/checkpoints") +async def list_project_checkpoints(request: Request, filename: Optional[str] = None) -> dict: + session._require_local_caller(request) + if not filename: + return {"checkpoints": []} + cfg = storage.load_config() + workspace = prompt_builder.resolve_active_workspace(cfg) + wid = (workspace or {}).get("id", "default") + return {"checkpoints": tools._list_checkpoints(wid, filename)} + + +class RevertIn(BaseModel): + filename: str + checkpoint_id: str + + +@router.post("/api/project/revert") +async def revert_project_file(r: RevertIn, request: Request) -> dict: + session._require_local_caller(request) + cfg = storage.load_config() + workspace = prompt_builder.resolve_active_workspace(cfg) + wid = (workspace or {}).get("id", "default") + content = tools._get_checkpoint(wid, r.filename, r.checkpoint_id) + if content is None: + raise HTTPException(404, "Checkpoint not found.") + root = tools._resolve_project_root(workspace) + dest = Path(root) / r.filename + # Reject path traversal: ensure dest stays under the resolved project root + try: + dest.resolve().relative_to(Path(root).resolve()) + except ValueError: + raise HTTPException(403, "Path outside project root.") + try: + dest.parent.mkdir(parents=True, exist_ok=True) + # content is raw bytes — binary checkpoints round-trip without loss. + dest.write_bytes(content) + except Exception as exc: + raise HTTPException(500, f"Could not write file: {exc}") + return {"ok": True, "filename": r.filename, "bytes": len(content)} diff --git a/routers/scheduled.py b/routers/scheduled.py new file mode 100644 index 0000000..dce06f6 --- /dev/null +++ b/routers/scheduled.py @@ -0,0 +1,94 @@ +"""Scheduled task CRUD, notifications drain, and verification logs. + +Extracted from app.py (Phase 3). Route paths, request/response shapes, and +behavior are unchanged. Handlers reach shared state through the services +package (module-attribute access for anything tests monkeypatch via app.py's +forwarding properties). +""" +from __future__ import annotations + +import json +import re +import uuid +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from services import scheduled as scheduled_svc +from services import session, storage + +router = APIRouter() + +class ScheduledTaskIn(BaseModel): + id: Optional[str] = None + name: str + prompt: str + workspace_id: Optional[str] = "" + schedule: dict + enabled: Optional[bool] = True + + +@router.get("/api/scheduled-tasks") +async def list_scheduled_tasks(request: Request) -> dict: + session._require_local_caller(request) + from scheduler import list_tasks + return {"tasks": list_tasks(storage.SCHEDULED_TASKS_PATH)} + + +@router.post("/api/scheduled-tasks") +async def create_or_update_scheduled_task(body: ScheduledTaskIn, request: Request) -> dict: + session._require_local_caller(request) + from scheduler import upsert_task + task = body.model_dump() + if not task.get("id"): + task["id"] = uuid.uuid4().hex + task.setdefault("history", []) + task.setdefault("last_run_at", None) + return upsert_task(storage.SCHEDULED_TASKS_PATH, task) + + +@router.delete("/api/scheduled-tasks/{task_id}") +async def delete_scheduled_task(task_id: str, request: Request) -> dict: + session._require_local_caller(request) + from scheduler import delete_task + ok = delete_task(storage.SCHEDULED_TASKS_PATH, task_id) + if not ok: + raise HTTPException(404, "Task not found.") + return {"ok": True} + + +@router.get("/api/verification/{chat_id}") +async def get_verification_log(chat_id: str, request: Request) -> dict: + """Return verification trace entries for a chat (one per tool call).""" + session._require_local_caller(request) + chat_id = re.sub(r"[^A-Za-z0-9_-]+", "_", chat_id)[:128].strip("._-") + if not chat_id: + raise HTTPException(400, "Invalid chat_id.") + path = storage.DATA_DIR / "verification" / f"{chat_id}.jsonl" + if not path.exists(): + return {"entries": []} + entries: list[dict] = [] + try: + with open(path, "r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except Exception: + continue + except Exception as exc: + raise HTTPException(500, f"Could not read verification log: {exc}") + return {"entries": entries} + + +@router.get("/api/scheduled-tasks/notifications") +async def drain_scheduled_notifications(request: Request) -> dict: + """Poll-style: returns and clears pending notifications. The frontend + polls this on a short interval rather than holding an SSE open.""" + session._require_local_caller(request) + pending = list(scheduled_svc._scheduled_notifications) + scheduled_svc._scheduled_notifications.clear() + return {"notifications": pending} diff --git a/routers/session_trust.py b/routers/session_trust.py new file mode 100644 index 0000000..ba78da4 --- /dev/null +++ b/routers/session_trust.py @@ -0,0 +1,81 @@ +"""Approval resolution, session-trusted commands, and file-picker responses. + +Extracted from app.py (Phase 3). Route paths, request/response shapes, and +behavior are unchanged. Handlers reach shared state through the services +package (module-attribute access for anything tests monkeypatch via app.py's +forwarding properties). +""" +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from services import session + +router = APIRouter() + +# --- Approvals --- + +class ApprovalIn(BaseModel): + approval_id: str + approved: bool + trust_session: Optional[bool] = False + command: Optional[str] = None + + +@router.post("/api/approve") +async def approve(a: ApprovalIn, request: Request) -> dict: + # Approving an in-flight tool call can release shell/file-write side + # effects, so restrict the endpoint to local callers up front (matching + # /api/project/* and /api/session/trust). Remote clients can't approve + # tool calls without the operator's local browser. + session._require_local_caller(request) + ok = session.approvals.resolve(a.approval_id, a.approved) + if not ok: + raise HTTPException(404, "Unknown approval id") + if a.approved and a.trust_session and a.command: + session._session_trusted_commands.add(a.command) + return {"ok": True} +class SessionTrustIn(BaseModel): + command: str +@router.post("/api/session/trust") +async def session_trust(t: SessionTrustIn, request: Request) -> dict: + session._require_local_caller(request) + session._session_trusted_commands.add(t.command) + return {"ok": True, "trusted_count": len(session._session_trusted_commands)} + + +@router.get("/api/session/trust") +async def list_session_trust(request: Request) -> dict: + session._require_local_caller(request) + return {"commands": list(session._session_trusted_commands)} + + +@router.delete("/api/session/trust") +async def clear_session_trust(request: Request) -> dict: + session._require_local_caller(request) + session._session_trusted_commands.clear() + return {"ok": True} + + +# --- File-picker responses --- + +class FileResponseIn(BaseModel): + request_id: str + files: Optional[list] = None + action: Optional[str] = None + + +@router.post("/api/file-response") +async def post_file_response(r: FileResponseIn, request: Request) -> dict: + # Resolving an in-flight file-picker request can influence the tool gate, + # so restrict this endpoint to local callers (matching /api/approve and + # /api/session/trust). A remote host must not be able to answer file + # requests destined for the operator's local browser. + session._require_local_caller(request) + ok = session.file_responses.resolve(r.request_id, r.files or []) + if not ok: + raise HTTPException(404, "Unknown file request id") + return {"ok": True} diff --git a/routers/skills_prompts.py b/routers/skills_prompts.py new file mode 100644 index 0000000..6d748fd --- /dev/null +++ b/routers/skills_prompts.py @@ -0,0 +1,101 @@ +"""System prompt and skill CRUD routes. + +Extracted from app.py (Phase 3). Route paths, request/response shapes, and +behavior are unchanged. Handlers reach shared state through the services +package (module-attribute access for anything tests monkeypatch via app.py's +forwarding properties). +""" +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, File, HTTPException, UploadFile +from pydantic import BaseModel + +from services import skills, storage + +router = APIRouter() + +# --- System prompts --- + +class PromptIn(BaseModel): + id: Optional[str] = None + name: str + content: str + + +@router.get("/api/system-prompts") +async def list_prompts() -> dict: + return storage.load_prompts() + + +@router.post("/api/system-prompts") +async def upsert_prompt(p: PromptIn) -> dict: + data = storage.load_prompts() + pid = p.id or p.name.lower().replace(" ", "-") + existing = next((x for x in data["prompts"] if x["id"] == pid), None) + if existing: + existing["name"] = p.name + existing["content"] = p.content + else: + data["prompts"].append({"id": pid, "name": p.name, "content": p.content}) + storage.save_json(storage.PROMPTS_PATH, data) + return {"id": pid} + + +@router.delete("/api/system-prompts/{prompt_id}") +async def delete_prompt(prompt_id: str) -> dict: + data = storage.load_prompts() + data["prompts"] = [x for x in data["prompts"] if x["id"] != prompt_id] + storage.save_json(storage.PROMPTS_PATH, data) + return {"ok": True} + + +# --- Skills --- + +@router.get("/api/skills") +async def list_skills() -> dict: + return {"skills": skills.list_skill_files()} + + +@router.get("/api/skills/{skill_id}") +async def get_skill(skill_id: str) -> dict: + skill = skills.load_skill_content(skill_id) + if not skill: + raise HTTPException(404, "Skill not found") + return skill + + +@router.post("/api/skills/upload") +async def upload_skill(file: UploadFile = File(...)) -> dict: + if not file.filename or not file.filename.endswith(".md"): + raise HTTPException(400, "Skills must be .md files with frontmatter.") + safe_name = Path(file.filename).name + dest = storage.SKILLS_DIR / safe_name + content = await file.read() + dest.write_bytes(content) + return {"id": dest.stem, "filename": safe_name} + + +class SkillIn(BaseModel): + id: str + name: str + description: str + content: str + + +@router.post("/api/skills") +async def create_skill(s: SkillIn) -> dict: + safe_id = "".join(c for c in s.id if c.isalnum() or c in "-_").strip("-_") or "skill" + body = f"---\nname: {s.name}\ndescription: {s.description}\n---\n\n{s.content}\n" + (storage.SKILLS_DIR / f"{safe_id}.md").write_text(body, encoding="utf-8") + return {"id": safe_id} + + +@router.delete("/api/skills/{skill_id}") +async def delete_skill(skill_id: str) -> dict: + path = storage.SKILLS_DIR / f"{skill_id}.md" + if path.exists(): + path.unlink() + return {"ok": True} diff --git a/routers/uploads.py b/routers/uploads.py new file mode 100644 index 0000000..c5dc1e6 --- /dev/null +++ b/routers/uploads.py @@ -0,0 +1,126 @@ +"""File uploads (persistent + transient), voice transcription, and TTS. + +Extracted from app.py (Phase 3). Route paths, request/response shapes, and +behavior are unchanged. Handlers reach shared state through the services +package (module-attribute access for anything tests monkeypatch via app.py's +forwarding properties). +""" +from __future__ import annotations + +import base64 +import re +import shutil +import uuid +from pathlib import Path +from typing import Optional + +import aiofiles +import httpx +from fastapi import APIRouter, File, HTTPException, Request, UploadFile +from fastapi.responses import Response +from pydantic import BaseModel + +from services import llm, session, storage, tools, transient + +router = APIRouter() + +# --- File uploads --- + +@router.post("/api/upload") +async def upload_file(file: UploadFile = File(...)) -> dict: + safe_name = f"{uuid.uuid4().hex}_{Path(file.filename or 'file').name}" + dest = storage.UPLOADS_DIR / safe_name + async with aiofiles.open(dest, "wb") as f: + while chunk := await file.read(1024 * 64): + await f.write(chunk) + return {"url": f"/uploads/{safe_name}", "filename": file.filename, "content_type": file.content_type} + + +@router.post("/api/uploads/transient") +async def upload_transient_file(request: Request, file: UploadFile = File(...)) -> dict: + """Accept a file scoped to a single chat. Files older than the TTL + are swept automatically. The caller passes chat_id as a query param.""" + session._require_local_caller(request) + chat_id = request.query_params.get("chat_id") or "anon" + # Sanitize chat_id: alphanumeric / dash / underscore only. + chat_id = re.sub(r"[^A-Za-z0-9_-]+", "_", chat_id)[:64].strip("._-") or "anon" + chat_dir = transient._transient_root() / chat_id + chat_dir.mkdir(parents=True, exist_ok=True) + safe_name = f"{uuid.uuid4().hex}_{Path(file.filename or 'file').name}" + dest = chat_dir / safe_name + async with aiofiles.open(dest, "wb") as f: + while chunk := await file.read(1024 * 64): + await f.write(chunk) + return { + "url": f"/uploads/transient/{chat_id}/{safe_name}", + "filename": file.filename, + "content_type": file.content_type, + } + + +@router.delete("/api/uploads/transient/{chat_id}") +async def delete_transient_chat(chat_id: str, request: Request) -> dict: + session._require_local_caller(request) + chat_id = re.sub(r"[^A-Za-z0-9_-]+", "_", chat_id)[:64].strip("._-") + if not chat_id: + raise HTTPException(400, "Invalid chat_id.") + chat_dir = transient._transient_root() / chat_id + if chat_dir.exists(): + shutil.rmtree(chat_dir, ignore_errors=True) + return {"ok": True, "chat_id": chat_id} +# --- Voice transcription --- + +_MAX_TRANSCRIBE_BYTES = 25 * 1024 * 1024 # 25 MB cap for /api/transcribe uploads + + +@router.post("/api/transcribe") +async def transcribe_audio(request: Request, file: UploadFile = File(...)) -> dict: + # Proxies user-API-key requests to the backend, so restrict to local + # callers (matches /api/tts and /api/explain-command). + session._require_local_caller(request) + cfg = storage.load_config() + if not cfg.get("api_key") or not cfg.get("base_url"): + raise HTTPException(400, "Set your OpenWebUI URL and API key first.") + profile = llm.active_profile(cfg) + base = llm.normalize_base_url(cfg["base_url"]) + transcribe_url = f"{base}{profile.get('transcribe', '/api/v1/audio/transcriptions')}" + headers = {"Authorization": f"Bearer {cfg['api_key']}"} + audio_bytes = await file.read(_MAX_TRANSCRIBE_BYTES + 1) + if len(audio_bytes) > _MAX_TRANSCRIBE_BYTES: + raise HTTPException(413, "Audio upload too large (max 25 MB).") + async with httpx.AsyncClient(timeout=60.0) as client: + try: + resp = await client.post( + transcribe_url, + headers=headers, + files={"file": (file.filename or "audio.webm", audio_bytes, file.content_type or "audio/webm")}, + data={"model": "whisper-1"}, + ) + except httpx.HTTPError as exc: + raise HTTPException(502, f"Transcription request failed: {exc}") + if resp.status_code != 200: + raise HTTPException(resp.status_code, f"Transcription failed: {resp.text[:300]}") + try: + body = resp.json() + return {"text": body.get("text", "")} + except Exception: + return {"text": resp.text} + + +# --- Text-to-speech (read-aloud) --- + +class TtsIn(BaseModel): + text: str + voice: Optional[str] = None + + +@router.post("/api/tts") +async def tts_endpoint(body: TtsIn, request: Request) -> Response: + session._require_local_caller(request) + cfg = storage.load_config() + if not cfg.get("api_key") or not cfg.get("base_url"): + raise HTTPException(400, "Set your OpenWebUI URL and API key first.") + voice = body.voice or cfg.get("tts_voice", "alloy") + result = await tools.call_openwebui_audio(body.text[:4096], voice, cfg) + audio_bytes = base64.b64decode(result["data_b64"]) + return Response(content=audio_bytes, media_type="audio/mpeg") diff --git a/routers/workspaces.py b/routers/workspaces.py new file mode 100644 index 0000000..386ea04 --- /dev/null +++ b/routers/workspaces.py @@ -0,0 +1,347 @@ +"""Workspace CRUD, export/import bundles, and bundle manifests. + +Extracted from app.py (Phase 3). Route paths, request/response shapes, and +behavior are unchanged. Handlers reach shared state through the services +package (module-attribute access for anything tests monkeypatch via app.py's +forwarding properties). +""" +from __future__ import annotations + +import io +import json +import re +import time +import uuid +import zipfile +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, File, HTTPException, Request, UploadFile +from fastapi.responses import Response +from pydantic import BaseModel + +from services import session, skills, storage, tools + +router = APIRouter() + +# Workspace IDs end up in URLs and are embedded into HTML attributes on the +# frontend, so a client-supplied id must be constrained to a conservative +# allowlist to prevent HTML/attribute injection or malformed URLs. This pattern +# is a superset of everything the server itself generates: the name-derived slug +# keeps alnum + "-"/"_", and the uuid fallback is uuid4().hex[:8] (lowercase hex). +_WORKSPACE_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") + +# --- Workspaces --- + +class WorkspaceIn(BaseModel): + id: Optional[str] = None + name: str + description: Optional[str] = "" + system_prompt_id: Optional[str] = None + active_skills: Optional[list[str]] = None + active_mcp_servers: Optional[list[str]] = None + active_cli_tools: Optional[list[str]] = None + files: Optional[list[dict]] = None + default_model: Optional[str] = None + project_root: Optional[str] = None + mode: Optional[str] = None + shell_approval_policy: Optional[str] = None + + +@router.get("/api/workspaces") +async def list_workspaces_endpoint(request: Request) -> dict: + session._require_local_caller(request) + return storage.load_workspaces() + + +@router.get("/api/workspaces/{wid}") +async def get_workspace(request: Request, wid: str) -> dict: + session._require_local_caller(request) + data = storage.load_workspaces() + w = next((x for x in data["workspaces"] if x["id"] == wid), None) + if not w: + raise HTTPException(404, "Workspace not found") + return w + + +@router.post("/api/workspaces") +async def upsert_workspace(w: WorkspaceIn, request: Request) -> dict: + session._require_local_caller(request) + # Workspace IDs are client-supplied and flow into URLs and HTML attributes + # on the frontend, so validate any explicitly-supplied id against a + # conservative allowlist to block HTML/attribute injection and malformed + # URLs. Server-generated slugs/uuids (the `w.id or ...` fallback below) + # already stay within this character set, so legitimate creates/updates are + # unaffected; only a client-provided id can trip this check. + if w.id is not None and not _WORKSPACE_ID_RE.match(w.id): + raise HTTPException( + 400, + "Workspace id must be 1-64 characters of letters, digits, '-' or '_'.", + ) + # Reject project_root values that escape storage.WORKSPACE_DIR up front so the user + # gets actionable feedback. Relative paths are resolved against + # storage.WORKSPACE_DIR (e.g., "my-project" → "/my-project") rather + # than the server process CWD. _resolve_project_root still clamps as + # defense-in-depth. + if w.project_root: + base = Path(storage.WORKSPACE_DIR).resolve() + candidate_path = Path(w.project_root) + if not candidate_path.is_absolute(): + candidate_path = base / candidate_path + try: + normalized = candidate_path.resolve() + normalized.relative_to(base) + except (ValueError, OSError): + # Don't include the resolved base path in the error message: this + # endpoint is gated by _require_local_caller, but defense-in-depth + # is cheap and we still avoid serializing absolute filesystem + # paths in any HTTP response body. + raise HTTPException( + 400, + "project_root must be inside the configured workspace directory.", + ) + # Create the directory if it doesn't exist yet so /api/project/tree + # and execute_shell can use it immediately without "no such directory" + # surprises. The path is already proven safe (under storage.WORKSPACE_DIR). + try: + normalized.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise HTTPException( + 400, + f"Could not create project_root: {exc}", + ) + if not normalized.is_dir(): + raise HTTPException( + 400, + "project_root exists but is not a directory.", + ) + # Persist the normalized absolute path so downstream code uses a + # consistent value regardless of what the user typed. + w.project_root = str(normalized) + data = storage.load_workspaces() + wid = w.id or "".join(c for c in w.name.lower() if c.isalnum() or c in "-_ ").strip().replace(" ", "-") or uuid.uuid4().hex[:8] + payload = w.model_dump(exclude_none=True) + payload["id"] = wid + payload.setdefault("active_skills", []) + payload.setdefault("active_mcp_servers", []) + payload.setdefault("active_cli_tools", []) + payload.setdefault("files", []) + payload["updated_at"] = int(time.time()) + existing_idx = next((i for i, x in enumerate(data["workspaces"]) if x["id"] == wid), None) + if existing_idx is not None: + data["workspaces"][existing_idx] = {**data["workspaces"][existing_idx], **payload} + else: + payload["created_at"] = payload["updated_at"] + data["workspaces"].append(payload) + storage.save_json(storage.WORKSPACES_PATH, data) + return {"id": wid} + + +@router.delete("/api/workspaces/{wid}") +async def delete_workspace(request: Request, wid: str) -> dict: + session._require_local_caller(request) + data = storage.load_workspaces() + data["workspaces"] = [x for x in data["workspaces"] if x["id"] != wid] + storage.save_json(storage.WORKSPACES_PATH, data) + cfg = storage.load_config() + if cfg.get("active_workspace_id") == wid: + cfg["active_workspace_id"] = "" + storage.save_json(storage.CONFIG_PATH, cfg) + return {"ok": True} + + +@router.get("/api/workspaces/{wid}/export") +async def export_workspace(request: Request, wid: str) -> Response: + session._require_local_caller(request) + data = storage.load_workspaces() + w = next((x for x in data["workspaces"] if x["id"] == wid), None) + if not w: + raise HTTPException(404, "Workspace not found") + prompts = storage.load_prompts() + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: + # Manifest + manifest = { + "version": "1", + "name": w["name"], + "description": w.get("description", ""), + "exported_at": int(time.time()), + "active_skills": w.get("active_skills", []), + "active_mcp_servers": w.get("active_mcp_servers", []), + "active_cli_tools": w.get("active_cli_tools", []), + "mode": w.get("mode", "approve-each"), + } + zf.writestr("manifest.json", json.dumps(manifest, indent=2)) + # System prompt + pid = w.get("system_prompt_id") + if pid: + p = next((x for x in prompts["prompts"] if x["id"] == pid), None) + if p: + zf.writestr("system_prompt.json", json.dumps(p, indent=2)) + # Skills + for sid in w.get("active_skills", []): + skill = skills.load_skill_content(sid) + if skill: + path = storage.SKILLS_DIR / f"{sid}.md" + if path.exists(): + zf.write(path, f"skills/{sid}.md") + # MCP stubs (no secrets) + mcp_data = storage.load_mcp_servers() + mcp_stubs = [] + for sname in w.get("active_mcp_servers", []): + s = next((x for x in mcp_data.get("servers", []) if x["name"] == sname), None) + if s: + stub = {k: v for k, v in s.items() if k not in ("env",)} + stub["env_keys"] = list((s.get("env") or {}).keys()) + mcp_stubs.append(stub) + zf.writestr("mcp_servers.json", json.dumps({"servers": mcp_stubs}, indent=2)) + # CLI tools + cli_data = storage.load_cli_tools() + cli_items = [c for c in cli_data.get("tools", []) if c["id"] in w.get("active_cli_tools", [])] + zf.writestr("cli_tools.json", json.dumps({"tools": cli_items}, indent=2)) + # Bundle manifest (filenames + hashes provided by client via query param) + # The actual file bytes are NOT included — the manifest is just metadata so + # the recipient knows which bundles existed and can provide the files themselves. + bundle_manifest = w.get("bundle_manifest") + if bundle_manifest and isinstance(bundle_manifest, list): + zf.writestr("bundle_manifest.json", json.dumps(bundle_manifest, indent=2)) + buf.seek(0) + safe_name = tools._slug(w["name"], "workspace") + return Response( + content=buf.read(), + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{safe_name}.bwui"'}, + ) + + +@router.post("/api/workspaces/{wid}/bundle-manifest") +async def set_workspace_bundle_manifest(request: Request, wid: str) -> dict: + """Client posts bundle metadata (filenames + hashes) to be included in exports.""" + session._require_local_caller(request) + body = await request.json() + manifest = body.get("manifest") if isinstance(body, dict) else None + if not isinstance(manifest, list): + raise HTTPException(400, "Expected {'manifest': [...]} body.") + data = storage.load_workspaces() + w = next((x for x in data["workspaces"] if x["id"] == wid), None) + if not w: + raise HTTPException(404, "Workspace not found") + safe_manifest = [] + for entry in manifest[:200]: + if not isinstance(entry, dict): + continue + safe_manifest.append({ + "bundle_id": str(entry.get("bundle_id", ""))[:64], + "name": str(entry.get("name", ""))[:128], + "files": [ + {"filename": str(f.get("filename", ""))[:256], "sha256": str(f.get("sha256", ""))[:64]} + for f in (entry.get("files") or [])[:500] + if isinstance(f, dict) + ], + }) + idx = next((i for i, x in enumerate(data["workspaces"]) if x["id"] == wid), None) + data["workspaces"][idx]["bundle_manifest"] = safe_manifest + storage.save_json(storage.WORKSPACES_PATH, data) + return {"ok": True, "bundle_count": len(safe_manifest)} + + +_MAX_BUNDLE_BYTES = 10 * 1024 * 1024 # 10 MB compressed +_MAX_MEMBER_BYTES = 2 * 1024 * 1024 # 2 MB per uncompressed member +_MAX_BUNDLE_MEMBERS = 500 # cap member count to bound iteration cost +_MAX_BUNDLE_TOTAL_BYTES = 50 * 1024 * 1024 # cap total uncompressed bytes (zip-bomb guard) + + +@router.post("/api/workspaces/import") +async def import_workspace(request: Request, file: UploadFile = File(...)) -> dict: + session._require_local_caller(request) + content = await file.read(_MAX_BUNDLE_BYTES + 1) + if len(content) > _MAX_BUNDLE_BYTES: + raise HTTPException(413, "Workspace bundle too large (max 10 MB).") + try: + buf = io.BytesIO(content) + with zipfile.ZipFile(buf, "r") as zf: + infos = zf.infolist() + if len(infos) > _MAX_BUNDLE_MEMBERS: + raise HTTPException(413, f"Bundle has too many entries (max {_MAX_BUNDLE_MEMBERS}).") + total_uncompressed = 0 + for info in infos: + if info.file_size > _MAX_MEMBER_BYTES: + raise HTTPException(413, f"Bundle member '{info.filename}' exceeds 2 MB limit.") + total_uncompressed += info.file_size + if total_uncompressed > _MAX_BUNDLE_TOTAL_BYTES: + raise HTTPException(413, f"Bundle uncompressed total exceeds {_MAX_BUNDLE_TOTAL_BYTES} bytes.") + names = zf.namelist() + manifest = json.loads(zf.read("manifest.json")) + # Import system prompt if present + prompt_id = None + if "system_prompt.json" in names: + p = json.loads(zf.read("system_prompt.json")) + p_data = storage.load_prompts() + existing = next((x for x in p_data["prompts"] if x["id"] == p["id"]), None) + if not existing: + p_data["prompts"].append(p) + storage.save_json(storage.PROMPTS_PATH, p_data) + prompt_id = p["id"] + # Import skills. Skip any skill whose target filename already + # exists in storage.SKILLS_DIR so a bundle can't silently overwrite a + # user's local skill with the same name. The names of the + # skipped files come back in the response so the UI can show + # the user what wasn't imported. + imported_skills: list[str] = [] + skipped_skills: list[str] = [] + for name in names: + if name.startswith("skills/") and name.endswith(".md"): + skill_bytes = zf.read(name) + dest = storage.SKILLS_DIR / Path(name).name + if dest.exists(): + skipped_skills.append(dest.name) + continue + dest.write_bytes(skill_bytes) + imported_skills.append(dest.name) + # Import CLI tools + if "cli_tools.json" in names: + imported_cli = json.loads(zf.read("cli_tools.json")) + cli_data = storage.load_cli_tools() + existing_ids = {c["id"] for c in cli_data.get("tools", [])} + for c in imported_cli.get("tools", []): + if c["id"] not in existing_ids: + cli_data["tools"].append(c) + storage.save_json(storage.CLI_PATH, cli_data) + # Import MCP server stubs (safe fields only — env keys noted but not restored) + if "mcp_servers.json" in names: + imported_mcp = json.loads(zf.read("mcp_servers.json")) + mcp_data = storage.load_mcp_servers() + existing_names = {s["name"] for s in mcp_data.get("servers", [])} + for s in imported_mcp.get("servers", []): + if s.get("name") and s["name"] not in existing_names: + # env_keys is informational; don't restore actual env values + stub = {k: v for k, v in s.items() if k != "env_keys"} + mcp_data["servers"].append(stub) + storage.save_json(storage.MCP_PATH, mcp_data) + # Create the workspace + wid = uuid.uuid4().hex[:8] + ws_data = storage.load_workspaces() + ws_data["workspaces"].append({ + "id": wid, + "name": manifest.get("name", "Imported Workspace"), + "description": manifest.get("description", ""), + "system_prompt_id": prompt_id, + "active_skills": manifest.get("active_skills", []), + "active_mcp_servers": manifest.get("active_mcp_servers", []), + "active_cli_tools": manifest.get("active_cli_tools", []), + "files": [], + "mode": manifest.get("mode", "approve-each"), + "created_at": int(time.time()), + "updated_at": int(time.time()), + }) + storage.save_json(storage.WORKSPACES_PATH, ws_data) + return { + "id": wid, + "name": manifest.get("name"), + "imported_skills": imported_skills, + "skipped_skills": skipped_skills, + } + except HTTPException: + raise + except Exception as exc: + raise HTTPException(400, f"Invalid workspace bundle: {exc}") diff --git a/scheduler.py b/scheduler.py index ad22675..2c71dc3 100644 --- a/scheduler.py +++ b/scheduler.py @@ -128,6 +128,25 @@ def _append_history(task: dict, entry: dict, cap: int = 10) -> None: task["history"] = h[-cap:] +# Serialises read-modify-write cycles on the tasks file. Invariants: +# +# 1. The CRUD helpers at the bottom of this module (list_tasks / upsert_task +# / delete_task / get_task) are synchronous and await-free, and every +# route handler in app.py is async — so the event loop already runs each +# helper atomically with respect to every other coroutine. They therefore +# do not need this lock WHILE THAT STAYS TRUE. If one of them ever awaits +# between its _read_tasks() and _write_tasks() (e.g. aiofiles), it must +# take _TASKS_LOCK around the whole read-modify-write. +# +# 2. The scheduler tick DOES await (run_callback can run for minutes) +# between reading the task list and persisting a task's outcome. Request +# handlers can add/edit/delete tasks during that window, so the tick must +# never write back its pre-await snapshot — it re-reads the file and +# merges the outcome into the live task by id, under this lock (see +# _scheduler_tick). +_TASKS_LOCK = asyncio.Lock() + + async def start_scheduler( tasks_path: Path, run_callback: Callable[[dict], Awaitable[dict]], @@ -143,8 +162,8 @@ async def start_scheduler( except asyncio.CancelledError: log.info("Scheduler stopping.") raise - except Exception as exc: - log.warning("Scheduler tick failed: %s", exc) + except Exception: + log.exception("Scheduler tick failed") await asyncio.sleep(poll_seconds) @@ -153,24 +172,38 @@ async def _scheduler_tick( run_callback: Callable[[dict], Awaitable[dict]], send_notification: Callable[[dict, dict], Awaitable[None]], ) -> None: - tasks = _read_tasks(tasks_path) - if not tasks: - return - now = _now() - dirty = False - for task in tasks: - if not task.get("enabled", True): - continue - nxt = task.get("next_run_at") - if nxt is None: - _refresh_next_run(task) + # Phase 1 — decide what is due. Read, backfill missing next_run_at, and + # persist the backfill in one locked section with no awaits inside, so + # concurrent CRUD from request handlers cannot interleave with it. + async with _TASKS_LOCK: + tasks = _read_tasks(tasks_path) + if not tasks: + return + now = _now() + due: list[dict] = [] + dirty = False + for task in tasks: + if not task.get("enabled", True): + continue nxt = task.get("next_run_at") - dirty = True if nxt is None: + _refresh_next_run(task) + nxt = task.get("next_run_at") + dirty = True + if nxt is None: + continue + if float(nxt) > now: continue - if float(nxt) > now: - continue - # Fire it. + due.append(task) + if dirty: + _write_tasks(tasks_path, tasks) + + # Phase 2 — fire each due task, then persist its outcome. run_callback can + # take minutes, and /api/scheduled-tasks handlers may have added, edited, + # or deleted tasks in the meantime; writing back the pre-run snapshot + # would silently revert those changes. So after each run we RE-READ the + # file and merge the outcome into the live version of the task by id. + for task in due: log.info("Firing scheduled task %s (%s).", task.get("id"), task.get("name")) try: result = await run_callback(task) @@ -180,21 +213,28 @@ async def _scheduler_tick( ok = False summary = f"Run failed: {exc}" result = {"ok": False, "summary": summary} - task["last_run_at"] = now - _append_history(task, {"ts": now, "ok": ok, "summary": summary}) - sched = task.get("schedule") or {} - if sched.get("kind") == "once": - task["enabled"] = False - task["next_run_at"] = None - else: - _refresh_next_run(task) - dirty = True + + async with _TASKS_LOCK: + current = _read_tasks(tasks_path) + live = next((t for t in current if t.get("id") == task.get("id")), None) + if live is None: + # Deleted while it ran — do not resurrect it. + log.info("Scheduled task %s was deleted mid-run; outcome not persisted.", task.get("id")) + else: + live["last_run_at"] = now + _append_history(live, {"ts": now, "ok": ok, "summary": summary}) + sched = live.get("schedule") or {} + if sched.get("kind") == "once": + live["enabled"] = False + live["next_run_at"] = None + else: + _refresh_next_run(live) + _write_tasks(tasks_path, current) + try: await send_notification(task, result) except Exception as exc: log.warning("Notification for %s failed: %s", task.get("id"), exc) - if dirty: - _write_tasks(tasks_path, tasks) # CRUD helpers used by app.py endpoints 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/catalog.py b/services/catalog.py new file mode 100644 index 0000000..619791d --- /dev/null +++ b/services/catalog.py @@ -0,0 +1,406 @@ +"""Static registries: endpoint profiles, MCP/CLI catalogs, onboarding templates. + +Extracted verbatim from app.py (Phase 3). Pure data — no I/O, no state. +""" + +ENDPOINT_PROFILES: list[dict] = [ + { + "name": "openwebui", + "label": "OpenWebUI native", + "models": "/api/models", + "chat": "/api/chat/completions", + "images": "/api/v1/images/generations", + "audio": "/api/v1/audio/speech", + "transcribe": "/api/v1/audio/transcriptions", + }, + { + "name": "openwebui-openai", + "label": "OpenWebUI OpenAI proxy", + "models": "/openai/v1/models", + "chat": "/openai/v1/chat/completions", + "images": "/openai/v1/images/generations", + "audio": "/openai/v1/audio/speech", + "transcribe": "/openai/v1/audio/transcriptions", + }, + { + "name": "openai-v1", + "label": "OpenAI-compatible (/v1)", + "models": "/v1/models", + "chat": "/v1/chat/completions", + "images": "/v1/images/generations", + "audio": "/v1/audio/speech", + "transcribe": "/v1/audio/transcriptions", + }, + { + "name": "api-v1", + "label": "API v1 (/api/v1)", + "models": "/api/v1/models", + "chat": "/api/v1/chat/completions", + "images": "/api/v1/images/generations", + "audio": "/api/v1/audio/speech", + "transcribe": "/api/v1/audio/transcriptions", + }, +] + +# --------------------------------------------------------------------------- +# MCP server registry +# --------------------------------------------------------------------------- + +MCP_REGISTRY: list[dict] = [ + { + "id": "filesystem", + "name": "Filesystem", + "description": "Read and write files within a chosen directory.", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem", + "command": "npx", + "args_template": ["-y", "@modelcontextprotocol/server-filesystem", "{root_path}"], + "fields": [ + {"name": "root_path", "label": "Root directory the assistant may access", "type": "path"} + ], + "requires": "Node.js (npm/npx).", + }, + { + "id": "github", + "name": "GitHub", + "description": "Browse repositories, search code, manage issues and pull requests.", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/github", + "command": "npx", + "args_template": ["-y", "@modelcontextprotocol/server-github"], + "env_template": {"GITHUB_PERSONAL_ACCESS_TOKEN": "{token}"}, + "fields": [ + {"name": "token", "label": "GitHub personal access token", "type": "password"} + ], + "requires": "Node.js (npm/npx) and a GitHub PAT.", + }, + { + "id": "fetch", + "name": "Fetch", + "description": "Retrieve and convert web pages into structured text the assistant can read.", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch", + "command": "uvx", + "args_template": ["mcp-server-fetch"], + "fields": [], + "requires": "Python with uv installed (https://docs.astral.sh/uv/).", + }, + { + "id": "brave-search", + "name": "Brave Search", + "description": "Search the web via Brave's API.", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/brave-search", + "command": "npx", + "args_template": ["-y", "@modelcontextprotocol/server-brave-search"], + "env_template": {"BRAVE_API_KEY": "{api_key}"}, + "fields": [ + {"name": "api_key", "label": "Brave API key", "type": "password"} + ], + "requires": "Node.js plus a Brave Search API key.", + }, + { + "id": "memory", + "name": "Memory", + "description": "A persistent knowledge graph the assistant can read from and write to across chats.", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory", + "command": "npx", + "args_template": ["-y", "@modelcontextprotocol/server-memory"], + "fields": [], + "requires": "Node.js (npm/npx).", + }, + { + "id": "git", + "name": "Git", + "description": "Read and search a local Git repository's history and contents.", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/git", + "command": "uvx", + "args_template": ["mcp-server-git", "--repository", "{repo_path}"], + "fields": [ + {"name": "repo_path", "label": "Path to a Git repository", "type": "path"} + ], + "requires": "Python with uv installed.", + }, + { + "id": "sequential-thinking", + "name": "Sequential Thinking", + "description": "Lets the assistant break problems into stepped thoughts before answering.", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking", + "command": "npx", + "args_template": ["-y", "@modelcontextprotocol/server-sequential-thinking"], + "fields": [], + "requires": "Node.js (npm/npx).", + }, + { + "id": "time", + "name": "Time", + "description": "Provides accurate current time and timezone conversion.", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/time", + "command": "uvx", + "args_template": ["mcp-server-time"], + "fields": [], + "requires": "Python with uv installed.", + }, + # ---- Cloud services (community-maintained MCP servers) ---- + { + "id": "gdrive", + "name": "Google Drive", + "description": "Browse, search, and read files from Google Drive.", + "homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/gdrive", + "command": "npx", + "args_template": ["-y", "@modelcontextprotocol/server-gdrive"], + "env_template": { + "GDRIVE_CREDENTIALS_PATH": "{credentials_path}", + }, + "fields": [ + {"name": "credentials_path", "label": "Path to gcp-oauth.keys.json", "type": "path"}, + ], + "requires": "Node.js plus a Google Cloud OAuth credentials JSON. Run the server once interactively to mint a refresh token.", + "category": "cloud", + }, + { + "id": "google-workspace", + "name": "Google Workspace", + "description": "Read Gmail, manage Google Calendar events, and search Drive in one server.", + "homepage": "https://github.com/taylorwilsdon/google_workspace_mcp", + "command": "uvx", + "args_template": ["google-workspace-mcp"], + "env_template": { + "GOOGLE_OAUTH_CLIENT_ID": "{client_id}", + "GOOGLE_OAUTH_CLIENT_SECRET": "{client_secret}", + }, + "fields": [ + {"name": "client_id", "label": "Google OAuth client ID", "type": "text"}, + {"name": "client_secret", "label": "Google OAuth client secret", "type": "password"}, + ], + "requires": "Python with uv installed plus a Google Cloud OAuth client. Follow the server's README for the consent-screen setup.", + "category": "cloud", + }, + { + "id": "microsoft-graph", + "name": "Microsoft 365 (Graph)", + "description": "Outlook mail, calendar, OneDrive, SharePoint, and Teams via Microsoft Graph.", + "homepage": "https://github.com/softeria/ms-365-mcp-server", + "command": "npx", + "args_template": ["-y", "@softeria/ms-365-mcp-server"], + "env_template": { + "MS365_MCP_CLIENT_ID": "{client_id}", + "MS365_MCP_TENANT_ID": "{tenant_id}", + }, + "fields": [ + {"name": "client_id", "label": "Azure AD app client ID", "type": "text"}, + {"name": "tenant_id", "label": "Tenant ID (or 'common')", "type": "text"}, + ], + "requires": "Node.js plus an Azure AD app registration with Microsoft Graph delegated permissions.", + "category": "cloud", + }, + { + "id": "slack", + "name": "Slack", + "description": "Read channels, post messages, search history.", + "homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/slack", + "command": "npx", + "args_template": ["-y", "@modelcontextprotocol/server-slack"], + "env_template": { + "SLACK_BOT_TOKEN": "{bot_token}", + "SLACK_TEAM_ID": "{team_id}", + }, + "fields": [ + {"name": "bot_token", "label": "Slack bot token (xoxb-...)", "type": "password"}, + {"name": "team_id", "label": "Slack team / workspace ID", "type": "text"}, + ], + "requires": "Node.js plus a Slack app installed in your workspace with the required scopes.", + "category": "cloud", + }, + { + "id": "notion", + "name": "Notion", + "description": "Search, read, and update Notion pages and databases.", + "homepage": "https://github.com/makenotion/notion-mcp-server", + "command": "npx", + "args_template": ["-y", "@notionhq/notion-mcp-server"], + "env_template": { + "OPENAPI_MCP_HEADERS": "{headers_json}", + }, + "fields": [ + {"name": "headers_json", "label": "Headers JSON (e.g. {\"Authorization\":\"Bearer ntn_...\",\"Notion-Version\":\"2022-06-28\"})", "type": "password"}, + ], + "requires": "Node.js plus a Notion integration token with workspace access.", + "category": "cloud", + }, + { + "id": "linear", + "name": "Linear", + "description": "Browse and update Linear issues, projects, and cycles.", + "homepage": "https://github.com/jerhadf/linear-mcp-server", + "command": "npx", + "args_template": ["-y", "linear-mcp-server"], + "env_template": { + "LINEAR_API_KEY": "{api_key}", + }, + "fields": [ + {"name": "api_key", "label": "Linear personal API key", "type": "password"}, + ], + "requires": "Node.js plus a Linear API key from Settings → API.", + "category": "cloud", + }, + { + "id": "asana", + "name": "Asana", + "description": "Read and update Asana tasks, projects, and workspaces.", + "homepage": "https://github.com/cristip73/mcp-server-asana", + "command": "npx", + "args_template": ["-y", "@cristip73/mcp-server-asana"], + "env_template": { + "ASANA_ACCESS_TOKEN": "{access_token}", + }, + "fields": [ + {"name": "access_token", "label": "Asana personal access token", "type": "password"}, + ], + "requires": "Node.js plus an Asana personal access token from My Settings → Apps.", + "category": "cloud", + }, + { + "id": "jira", + "name": "Jira", + "description": "Search, read, and update Jira issues.", + "homepage": "https://github.com/sooperset/mcp-atlassian", + "command": "uvx", + "args_template": ["mcp-atlassian"], + "env_template": { + "JIRA_URL": "{jira_url}", + "JIRA_USERNAME": "{username}", + "JIRA_API_TOKEN": "{api_token}", + }, + "fields": [ + {"name": "jira_url", "label": "Jira base URL (e.g. https://acme.atlassian.net)", "type": "text"}, + {"name": "username", "label": "Atlassian account email", "type": "text"}, + {"name": "api_token", "label": "Atlassian API token", "type": "password"}, + ], + "requires": "Python with uv installed plus an Atlassian API token.", + "category": "cloud", + }, +] + +# --------------------------------------------------------------------------- +# CLI shortcuts registry +# --------------------------------------------------------------------------- + +CLI_REGISTRY: list[dict] = [ + { + "id": "git", + "name": "git", + "description": "Version control. Show status, diff, log, and history.", + "command_template": "git {args}", + "examples": ["git status", "git log --oneline -20", "git diff"], + }, + { + "id": "gh", + "name": "GitHub CLI", + "description": "Operate on GitHub repos, PRs, and issues from the terminal.", + "command_template": "gh {args}", + "examples": ["gh pr list", "gh issue view 123"], + }, + { + "id": "pandoc", + "name": "pandoc", + "description": "Convert documents between formats — markdown, docx, pdf, html, latex.", + "command_template": "pandoc {args}", + "examples": ["pandoc input.md -o output.docx", "pandoc paper.docx -o paper.pdf"], + }, + { + "id": "ffmpeg", + "name": "ffmpeg", + "description": "Convert and process audio/video files.", + "command_template": "ffmpeg {args}", + "examples": ["ffmpeg -i talk.mov -vn talk.mp3"], + }, + { + "id": "yt-dlp", + "name": "yt-dlp", + "description": "Download videos and audio from sites like YouTube, Vimeo, etc.", + "command_template": "yt-dlp {args}", + "examples": ["yt-dlp -x --audio-format mp3 'URL'"], + }, + { + "id": "sqlite3", + "name": "sqlite3", + "description": "Inspect and query SQLite databases.", + "command_template": "sqlite3 {args}", + "examples": ["sqlite3 grades.db '.tables'"], + }, + { + "id": "rg", + "name": "ripgrep", + "description": "Fast recursive search through text files.", + "command_template": "rg {args}", + "examples": ["rg 'TODO' src/"], + }, + { + "id": "curl", + "name": "curl", + "description": "Fetch URLs from the web.", + "command_template": "curl {args}", + "examples": ["curl -fsSL https://example.com"], + }, +] +# Onboarding workspace templates (use-case presets) +ONBOARDING_TEMPLATES: list[dict] = [ + { + "id": "grading", + "name": "Grading", + "description": "Grade and give feedback on student work.", + "system_prompt": ( + "You are a grading assistant for a higher-ed instructor. " + "Be constructive, specific, and aligned with the rubric provided. " + "Use load_skill to load the grading-rubric skill when grading." + ), + "skills": ["grading-rubric"], + "cli": [], + "mcp": [], + }, + { + "id": "research", + "name": "Research", + "description": "Find sources, summarize papers, manage citations.", + "system_prompt": ( + "You are a research assistant for an academic. Help find, summarize, " + "and cite sources. Use load_skill for research-citations." + ), + "skills": ["research-citations"], + "cli": [], + "mcp": ["fetch", "brave-search"], + }, + { + "id": "course-prep", + "name": "Course Prep", + "description": "Write syllabi, slides, lecture notes, and assignments.", + "system_prompt": ( + "You are a course-preparation assistant. Help draft syllabi, " + "lesson plans, slides, and assignments." + ), + "skills": [], + "cli": ["pandoc"], + "mcp": [], + }, + { + "id": "writing", + "name": "Writing", + "description": "Draft, edit, and polish academic or professional writing.", + "system_prompt": ( + "You are a writing coach and editor. Help draft, revise, " + "and polish documents." + ), + "skills": [], + "cli": ["pandoc"], + "mcp": [], + }, + { + "id": "coding", + "name": "Coding", + "description": "Write, debug, and explain code.", + "system_prompt": ( + "You are a coding assistant. Help write, debug, and explain code. " + "Use the computer-helper skill for running commands on the user's machine." + ), + "skills": ["computer-helper"], + "cli": ["git", "rg"], + "mcp": ["filesystem", "git"], + }, +] 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/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/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/llm.py b/services/llm.py new file mode 100644 index 0000000..aed0a75 --- /dev/null +++ b/services/llm.py @@ -0,0 +1,238 @@ +"""Model I/O: chat completion, endpoint discovery, model listing, and +message shaping (Phase 3 extraction from app.py). + +`chat_complete` is the canonical implementation; app.py exposes a forwarding +property so `patch("app.chat_complete", ...)` rebinds the name *here*, which +is why every caller in services/ and routers/ must invoke it as +``llm.chat_complete(...)`` (module attribute access at call time) rather than +importing the function object directly. +""" +from __future__ import annotations + +import json +import os +import time +from typing import Optional + +import httpx +from fastapi import HTTPException + +from . import storage +from .catalog import ENDPOINT_PROFILES +from .storage import save_json + +# When BWUI_TEST_MODE=1 the server trims its system prompt and caps model +# output so the test suite can complete in reasonable CI time without a GPU. +_TEST_MODE = os.environ.get("BWUI_TEST_MODE") == "1" +_TEST_MAX_TOKENS = int(os.environ.get("BWUI_TEST_MAX_TOKENS", "30")) + +# Runtime-toggleable mock for UI tests (only active when _TEST_MODE=1). +# Enabled via POST /api/test/mock-chat so the e2e tests (which use a real +# model) can share the same container without being affected. +_mock_chat_enabled: bool = False +_mock_chat_text: str = "Mock response." + + +def normalize_base_url(url: str) -> str: + if not url: + return "" + url = url.strip().rstrip("/") + for suffix in ("/api/v1", "/openai/v1", "/api", "/v1", "/openai"): + if url.endswith(suffix): + url = url[: -len(suffix)] + break + return url.rstrip("/") + + +async def discover_profile(base: str, api_key: str) -> Optional[dict]: + if not base: + return None + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: + for profile in ENDPOINT_PROFILES: + try: + resp = await client.get(f"{base}{profile['models']}", headers=headers) + except httpx.HTTPError: + continue + if resp.status_code != 200: + continue + try: + body = resp.json() + except (json.JSONDecodeError, ValueError): + continue + raw = body.get("data") if isinstance(body, dict) else body + if isinstance(raw, list) and raw: + return profile + return None + + +def active_profile(config: dict) -> dict: + profile = config.get("api_profile") + if isinstance(profile, dict) and "models" in profile: + return profile + return ENDPOINT_PROFILES[0] + + +async def chat_complete(messages: list, model: str, config: dict, chat_id: str = "") -> tuple[str, dict]: + """Returns (text, usage_dict).""" + if _TEST_MODE and _mock_chat_enabled: + last_user = next( + (m.get("content", "") for m in reversed(messages) if m.get("role") == "user"), "" + ) + if isinstance(last_user, list): + last_user = " ".join( + p.get("text", "") for p in last_user if isinstance(p, dict) and p.get("type") == "text" + ) + if "fenced markdown code block" in last_user or ("Reply with exactly" in last_user and "```" in last_user): + text = "```\nhello\n```" + elif "LaTeX" in last_user and ("$E" in last_user or "mc^2" in last_user): + text = "$E = mc^2$" + else: + text = _mock_chat_text + return text, {"prompt_tokens": 1, "completion_tokens": len(text.split()), "total_tokens": len(text.split()) + 1, "elapsed_ms": 10} + base = normalize_base_url(config["base_url"]) + profile = active_profile(config) + headers = {"Authorization": f"Bearer {config.get('api_key', '')}"} + payload: dict = {"model": model, "messages": messages, "stream": False} + if chat_id and profile.get("name") == "openwebui": + payload["chat_id"] = chat_id + if _TEST_MODE: + payload["max_tokens"] = _TEST_MAX_TOKENS + t0 = time.time() + async with httpx.AsyncClient(timeout=300.0, follow_redirects=True) as client: + resp = await client.post(f"{base}{profile['chat']}", json=payload, headers=headers) + elapsed_ms = int((time.time() - t0) * 1000) + if resp.status_code != 200: + raise HTTPException( + status_code=resp.status_code, + detail=f"Chat call failed ({profile['name']}): {resp.text[:500]}", + ) + try: + body = resp.json() + except (json.JSONDecodeError, ValueError): + raise HTTPException(status_code=502, detail="Chat endpoint returned non-JSON.") + try: + text = body["choices"][0]["message"]["content"] or "" + except (KeyError, IndexError): + text = json.dumps(body) + usage = body.get("usage") or {} + usage["elapsed_ms"] = elapsed_ms + return text, usage +# --------------------------------------------------------------------------- +# Context-window management +# --------------------------------------------------------------------------- + +CONTEXT_TOKEN_LIMIT = 32_000 +_CONTEXT_CHAR_BUDGET = int(CONTEXT_TOKEN_LIMIT * 0.80 * 4) + + +def _count_chars(messages: list) -> int: + total = 0 + for m in messages: + c = m.get("content", "") + if isinstance(c, str): + total += len(c) + elif isinstance(c, list): + for part in c: + if isinstance(part, dict) and part.get("type") == "text": + total += len(part.get("text", "")) + return total + + +def trim_to_context(history: list, system_prompt: str) -> tuple[list, int]: + budget = max(_CONTEXT_CHAR_BUDGET - len(system_prompt), 4_000) + total = _count_chars(history) + if total <= budget: + return history, 0 + trimmed = list(history) + n_dropped = 0 + while total > budget and len(trimmed) > 2: + removed = trimmed.pop(0) + total -= _count_chars([removed]) + n_dropped += 1 + while trimmed and trimmed[0].get("role") != "user": + total -= _count_chars([trimmed[0]]) + trimmed.pop(0) + n_dropped += 1 + return trimmed, n_dropped +# --------------------------------------------------------------------------- +# OpenWebUI model listing +# --------------------------------------------------------------------------- + +async def fetch_models(config: dict) -> list[dict]: + base = normalize_base_url(config["base_url"]) + if not base: + raise HTTPException(400, "Set the OpenWebUI URL first.") + api_key = config.get("api_key", "") + profile = config.get("api_profile") + if not profile: + profile = await discover_profile(base, api_key) + if not profile: + raise HTTPException( + status_code=502, + detail="Couldn't find a working API endpoint at that URL.", + ) + config["api_profile"] = profile + config["base_url"] = base + save_json(storage.CONFIG_PATH, config) + + headers = {"Authorization": f"Bearer {api_key}"} + async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: + try: + resp = await client.get(f"{base}{profile['models']}", headers=headers) + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"Cannot reach OpenWebUI: {exc}") + + if resp.status_code != 200: + new_profile = await discover_profile(base, api_key) + if new_profile and new_profile != profile: + config["api_profile"] = new_profile + save_json(storage.CONFIG_PATH, config) + return await fetch_models(config) + raise HTTPException( + status_code=resp.status_code, + detail=f"OpenWebUI returned {resp.status_code}: {resp.text[:300]}", + ) + try: + body = resp.json() + except (json.JSONDecodeError, ValueError): + raise HTTPException(status_code=502, detail="Got a non-JSON response from the models endpoint.") + raw = body.get("data") if isinstance(body, dict) else body + out = [] + for m in raw or []: + mid = m.get("id") or m.get("name") + if not mid: + continue + out.append({"id": mid, "name": m.get("name") or mid}) + return out + + +_VALID_ROLES = {"system", "user", "assistant", "function", "tool", "developer"} + + +def to_openai_messages(history: list, system_prompt: str) -> list: + out: list[dict] = [{"role": "system", "content": system_prompt}] + for m in history: + role = m.get("role", "user") + if role not in _VALID_ROLES: + continue + if role == "tool" and not m.get("tool_call_id"): + role = "user" + content = f"[Tool result]\n{m.get('content') or ''}" + out.append({"role": role, "content": content}) + continue + content = m.get("content") or "" + attachments = m.get("attachments") or [] + if attachments and role == "user": + parts: list[dict] = [{"type": "text", "text": content}] if content else [] + for a in attachments: + ctype = a.get("content_type") or "" + url = a.get("url") or "" + if ctype.startswith("image/"): + parts.append({"type": "image_url", "image_url": {"url": url}}) + else: + parts.append({"type": "text", "text": f"[Attachment: {a.get('filename') or url}]"}) + out.append({"role": role, "content": parts}) + else: + out.append({"role": role, "content": content}) + return out diff --git a/services/mcp.py b/services/mcp.py new file mode 100644 index 0000000..0f9e26c --- /dev/null +++ b/services/mcp.py @@ -0,0 +1,199 @@ +"""MCP stdio client and lifecycle manager (Phase 3 extraction from app.py). + +`mcp_manager` is the shared singleton; app.py re-exports it by identity so +`patch("app.mcp_manager.reconcile")` in the test suite keeps patching the +same object the routes use. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +from typing import Optional + +from . import storage +from .storage import load_mcp_servers + + +class MCPStdioClient: + def __init__(self, name: str, command: str, args: list[str], env: dict | None = None): + self.name = name + self.command = command + self.args = list(args or []) + self.env = dict(env or {}) + self.proc: Optional[asyncio.subprocess.Process] = None + self.tools: list[dict] = [] + self._next_id = 0 + self._read_lock = asyncio.Lock() + self.error: Optional[str] = None + + async def start(self, timeout: float = 30.0) -> None: + env = {**os.environ, **self.env} + try: + self.proc = await asyncio.create_subprocess_exec( + self.command, + *self.args, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + ) + except FileNotFoundError as exc: + self.error = f"Command not found: {self.command}. {exc}" + return + try: + await asyncio.wait_for(self._handshake(), timeout=timeout) + except asyncio.TimeoutError: + self.error = "MCP server did not respond to initialize within 30s." + await self.stop() + except Exception as exc: + self.error = f"MCP handshake failed: {exc}" + await self.stop() + + async def _handshake(self) -> None: + await self._call("initialize", { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "BetterWebUI", "version": "0.2"}, + }) + await self._notify("notifications/initialized", {}) + result = await self._call("tools/list", {}) + self.tools = result.get("tools", []) if isinstance(result, dict) else [] + + async def _send(self, message: dict) -> None: + if not self.proc or not self.proc.stdin: + raise RuntimeError("MCP server is not running.") + line = (json.dumps(message) + "\n").encode("utf-8") + self.proc.stdin.write(line) + await self.proc.stdin.drain() + + async def _read_one(self) -> dict: + async with self._read_lock: + assert self.proc and self.proc.stdout + while True: + line = await self.proc.stdout.readline() + if not line: + raise RuntimeError("MCP server closed unexpectedly.") + try: + return json.loads(line) + except json.JSONDecodeError: + continue + + async def _call(self, method: str, params: dict) -> dict: + self._next_id += 1 + req_id = self._next_id + await self._send({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}) + while True: + msg = await self._read_one() + if msg.get("id") == req_id: + if "error" in msg: + err = msg["error"] + raise RuntimeError(err.get("message") if isinstance(err, dict) else str(err)) + return msg.get("result") or {} + + async def _notify(self, method: str, params: dict) -> None: + await self._send({"jsonrpc": "2.0", "method": method, "params": params}) + + async def call_tool(self, tool_name: str, arguments: dict) -> dict: + return await self._call("tools/call", {"name": tool_name, "arguments": arguments}) + + async def stop(self) -> None: + if not self.proc: + return + try: + self.proc.terminate() + try: + await asyncio.wait_for(self.proc.wait(), timeout=3) + except asyncio.TimeoutError: + self.proc.kill() + await self.proc.wait() + except ProcessLookupError: + pass + self.proc = None + + +class MCPManager: + def __init__(self) -> None: + self.clients: dict[str, MCPStdioClient] = {} + + async def reconcile(self) -> None: + cfg = load_mcp_servers() + wanted = {s["name"]: s for s in cfg.get("servers", []) if s.get("enabled", True)} + for name in list(self.clients): + if name not in wanted: + await self.clients[name].stop() + del self.clients[name] + for name, s in wanted.items(): + if name in self.clients: + continue + env = dict(s.get("env") or {}) + # Substitute OAuth access tokens: {oauth.google.access_token} etc. + try: + from services.oauth import get_oauth_token as _get_oauth_tok + for k, v in list(env.items()): + if "{oauth." in str(v): + for provider in ("google", "microsoft"): + placeholder = f"{{oauth.{provider}.access_token}}" + if placeholder in str(v): + tok = _get_oauth_tok(provider, storage.DATA_DIR) + if tok and tok.get("access_token"): + env[k] = str(v).replace(placeholder, tok["access_token"]) + 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", ""), + args=s.get("args", []), + env=env, + ) + await client.start() + self.clients[name] = client + + def status(self) -> list[dict]: + out = [] + for name, client in self.clients.items(): + out.append({ + "name": name, + "running": client.proc is not None and client.error is None, + "error": client.error, + "tool_count": len(client.tools), + "tools": [ + {"name": t.get("name"), "description": t.get("description", "")} + for t in client.tools + ], + }) + return out + + def list_all_tools(self, allowed_servers: Optional[list[str]] = None) -> list[dict]: + out = [] + for name, client in self.clients.items(): + if allowed_servers is not None and name not in allowed_servers: + continue + for t in client.tools: + out.append({ + "server": name, + "name": t.get("name"), + "description": t.get("description", ""), + }) + return out + + async def call(self, server_name: str, tool_name: str, args: dict) -> dict: + client = self.clients.get(server_name) + if not client: + return {"error": f"MCP server '{server_name}' is not running."} + if client.error: + return {"error": f"MCP server '{server_name}' error: {client.error}"} + try: + result = await client.call_tool(tool_name, args) + except Exception as exc: + return {"error": f"MCP call failed: {exc}"} + return result if isinstance(result, dict) else {"result": result} + + +mcp_manager = MCPManager() 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/prompt_builder.py b/services/prompt_builder.py new file mode 100644 index 0000000..189e768 --- /dev/null +++ b/services/prompt_builder.py @@ -0,0 +1,403 @@ +"""Tool protocol blocks, system-prompt assembly, and tool-call parsing +(Phase 3 extraction from app.py). +""" +from __future__ import annotations + +import json +import platform +from typing import Optional + +from . import llm +from . import state as svc_state +from .mcp import mcp_manager +from .skills import list_skill_files +from .storage import load_cli_tools, load_workspaces + +# --------------------------------------------------------------------------- +# Tool definitions and system-prompt builders +# --------------------------------------------------------------------------- + +TOOL_PROTOCOL = """ +You have tools. To call a tool, output exactly one fenced JSON block on its +own lines like this: + +```tool +{"tool": "TOOL_NAME", "args": {...}} +``` + +After the tool runs, the result is added to the conversation and you continue. +Output at most one tool call per assistant turn. Speak naturally to the user +before and after tool calls. Never invent tool output — wait for the result. + +Available tools: + +- update_task_plan: update the visible task plan the user sees in the right-hand + panel. Call this when starting ANY multi-step task (to lay out the steps) and + after each step (to tick items done, mark in_progress, or flag blocked). + ALWAYS start a complex task by calling this first. + Args: {"items": [{"id": "step-1", "title": "Step description", + "status": "pending|in_progress|done|blocked", "note": "optional detail"}]}. + +- spawn_subagent: run up to 3 read-only parallel sub-tasks to research, + compare, or explore. Useful for "compare these rubrics", "research these + topics", "check these files". The main conversation pauses until all + subagents finish, then you get a combined summary. + Args: {"kind": "explore|compare", "prompt": "what to investigate", + "items": ["item1", "item2"] (optional for compare)}. + +- execute_shell: run a shell command on the user's computer. The host OS is + detected automatically (bash on Linux/macOS, PowerShell on Windows). USER + APPROVAL IS REQUIRED before the command runs — if denied, you'll see an + error and should ask the user what they'd prefer. Args: {"command": "...", + "reason": "short explanation of why this command is needed"}. + GRAPH / PLOT CONVENTION: if you run a Python script that generates a plot, + save it to /tmp/bwui_plot.png (e.g. plt.savefig('/tmp/bwui_plot.png', + bbox_inches='tight')) instead of plt.show(). The image is then + automatically captured and displayed inline in the chat. + +- read_file: read file(s) chosen by the user. The user is shown a file + picker — you do NOT specify a path. The result is the chosen file(s)' name, + type, and content. Args: {"reason": "why you need to read", "accept": "*", + "multiple": false}. Use accept="image/*" or "text/*,.md,.csv" to filter. + +- write_file: write a file to the workspace project folder (REQUIRES + APPROVAL unless mode is "trusted"). The file lands at + / — falling back to the server's WORKSPACE_DIR + when the workspace has no project_root configured. Any pre-existing + file at that path is snapshotted into checkpoints before being + overwritten, so the user can revert from the UI. On success the file + is visible in the Files pane; data_b64 is only returned when the + on-disk write fails so the user can still retrieve the content. + Args: {"filename": "name.ext", "content": + "...", "mime": "text/plain"}. + +- delete_file: permanently delete a file from the workspace project folder + (REQUIRES APPROVAL — the user must confirm before the file is removed). + Args: {"filename": "name.ext", "reason": "why this file should be deleted"}. + +- load_skill: load the full content of a named skill so you can follow its + instructions. Args: {"skill_id": "..."}. Use this when a listed skill + matches the user's request. + +- generate_image: create an image via OpenWebUI's image endpoint. The image + is sent to the user and downloaded to their computer automatically. Args: + {"prompt": "description", "size": "1024x1024"}. + +- generate_audio: text-to-speech via OpenWebUI. The audio is sent to the + user and downloaded automatically. Args: {"text": "...", "voice": "alloy"}. + +- mcp_call: call a tool from a connected MCP server (only available if + servers are configured and running). Args: {"server": "server_name", + "name": "tool_name", "arguments": {...}}. + +- cli_call: run one of the user's pre-registered CLI shortcuts. Routes + through execute_shell with approval (unless the shortcut has always-allow + policy). Args: {"id": "shortcut_id", "args": "command-line arguments"}. + +- web_search: search the public web. Use only when the user has enabled + web search for this turn (the system prompt will say so). Args: + {"query": "...", "max_results": 5}. Returns a list of + {title, url, snippet} items. + +- fetch_url: download and extract the readable text content of a web page. + Useful after web_search to read the full article. Requires user approval + unless chat mode is trusted. Args: {"url": "https://..."}. + Returns {url, title, text, word_count} or {error: "..."}. +""".strip() + +PLAN_MODE_BLOCK = """ +⚠️ PLAN MODE IS ACTIVE. + +You may ONLY call: update_task_plan, read_file, load_skill. +Do NOT call any side-effecting tool: execute_shell, write_file, +generate_image, generate_audio, cli_call, mcp_call. + +Your job in plan mode is to: +1. Use update_task_plan to lay out a complete step-by-step plan. +2. Explain your approach clearly in plain English. +3. Tell the user to switch to "Approve-each" mode (the chip in the chat header) + when they are ready to execute. + +Do NOT execute anything — plan only. +""".strip() + +RENDERING_PROTOCOL = r""" +The user sees your replies rendered as Markdown with LaTeX/KaTeX for math. +This is not a hint — it's how the UI works. Plain-text "math" like +"x^2 + (b/a)x = -c/a" displays literally and looks wrong. Always wrap +mathematics in LaTeX delimiters and use real LaTeX commands. + +Markdown: + Headings: ##, ### (use ### for sub-sections inside replies) + Emphasis: **bold**, *italic* + Code: `inline`, ```python\nfenced\n``` + Lists: - bullet OR 1. numbered + Quotes: > a quote + Links: [text](https://example.com) + Tables: | header | header | + |--------|--------| + | a | b | + +Math — REQUIRED for any equation, expression, fraction, exponent, root, +sum, integral, matrix, or set-builder. Choose: + Inline: $...$ $\\(...\\)$ + Display: $$...$$ \\[...\\] + +Use real LaTeX commands. WRONG vs RIGHT: + + WRONG: x^2 + (b/a)x = -c/a + RIGHT: $x^{2} + \frac{b}{a}\,x = -\frac{c}{a}$ + + WRONG: sqrt(b^2 - 4ac) + RIGHT: $\sqrt{b^{2} - 4ac}$ + + WRONG: (-b +/- sqrt(b^2 - 4ac)) / (2a) + RIGHT: $$x = \frac{-b \pm \sqrt{b^{2} - 4ac}}{2a}$$ + + WRONG: sum from i=1 to n of i^2 + RIGHT: $\sum_{i=1}^{n} i^{2}$ + + WRONG: integral from 0 to 1 of x^2 dx + RIGHT: $\int_{0}^{1} x^{2}\,dx$ + +Common LaTeX you should know: + Fractions \frac{num}{den} Roots \sqrt{x}, \sqrt[n]{x} + Exponents x^{n} (always brace multi-character exponents) + Subscripts x_{i} + Operators \pm \mp \cdot \times \div \ast + Relations \leq \geq \neq \approx \equiv \rightarrow \Leftrightarrow + Greek \alpha \beta \gamma \delta \epsilon \pi \sigma \theta \phi \omega + Sets \mathbb{R} \mathbb{N} \mathbb{Z} \emptyset \in \notin \subset + Logic \forall \exists \neg \land \lor \Rightarrow + Calculus \int \sum \prod \lim \partial \nabla \infty + Spacing \, (thin space) \; (thick) \quad \qquad + +Aligned multi-line equations: +$$ +\begin{aligned} +ax^{2} + bx + c &= 0 \\ +x &= \frac{-b \pm \sqrt{b^{2} - 4ac}}{2a} +\end{aligned} +$$ + +Matrices: +$$ +A = \begin{pmatrix} 1 & 2 \\ 3 & 4 \end{pmatrix} +$$ + +When in doubt: lean toward wrapping it in $...$. Never use ASCII math +shortcuts (^, /, sqrt(), <=, !=, sum, int) in user-visible prose. +""".strip() + + +def resolve_active_workspace(config: dict) -> Optional[dict]: + wid = config.get("active_workspace_id") + if not wid: + return None + data = load_workspaces() + return next((w for w in data["workspaces"] if w["id"] == wid), None) + + +def build_system_prompt( + config: dict, + prompts: dict, + mode: str = "approve-each", + *, + user_memories: Optional[list[str]] = None, + use_vision: bool = False, + web_search_mode: str = "off", +) -> str: + parts: list[str] = [] + workspace = resolve_active_workspace(config) + + # 1. The system prompt itself + prompt_id = (workspace or {}).get("system_prompt_id") or config.get("active_prompt_id") or "default" + chosen = next( + (p for p in prompts["prompts"] if p["id"] == prompt_id), + prompts["prompts"][0] if prompts["prompts"] else None, + ) + if chosen: + parts.append(chosen["content"]) + + if workspace: + parts.append( + f"Active workspace: {workspace['name']}." + + (f" {workspace['description']}" if workspace.get("description") else "") + ) + + # User memories — durable preferences/facts/constraints stored client-side + # in the browser and injected here on every turn. Subject to context trim. + if user_memories: + cleaned = [m.strip() for m in user_memories if isinstance(m, str) and m.strip()] + if cleaned: + parts.append( + "Things to remember about the user:\n" + + "\n".join(f"- {m}" for m in cleaned[:50]) # hard cap so a runaway list can't blow the budget + ) + + # Per-turn capability hints + if use_vision: + parts.append( + "The user has explicitly asked you to USE VISION on this turn. " + "If any images are attached, analyse them in detail and incorporate " + "what you see into your reply." + ) + if web_search_mode == "required": + parts.append( + "The user requires web search on this turn. You MUST call the " + "web_search tool before answering so your reply reflects current " + "information." + ) + elif web_search_mode == "if_needed": + parts.append( + "If answering accurately requires current or specialised information " + "you don't have, call the web_search tool first." + ) + + # Plan mode block (injected before other tools if active) + effective_mode = mode or (workspace or {}).get("mode") or config.get("chat_mode", "approve-each") + if effective_mode == "plan": + parts.append(PLAN_MODE_BLOCK) + + # Response style + parts.append( + "Always attempt a complete, useful response to the user's request before " + "asking clarifying questions. If something is ambiguous, make a reasonable " + "assumption, state it briefly, and proceed. Save any follow-up questions for " + "the end of your reply, after the substantive response." + ) + + # Rendering rules + parts.append(RENDERING_PROTOCOL) + + # In test mode skip the tool-protocol block and all tool / service listings. + # The basic prompt + memories above are sufficient for outcome assertions; + # omitting ~1 k tokens of tool instructions cuts inference time by ~40 %. + if llm._TEST_MODE: + return "\n\n".join(parts) + + # 2. Available skills + if workspace: + active_skill_ids = workspace.get("active_skills") or [] + else: + active_skill_ids = config.get("active_skills") or [] + available_skills = list_skill_files() + if available_skills: + listing = "\n".join( + f"- {s['id']}: {s['description']}" + for s in available_skills + if not active_skill_ids or s["id"] in active_skill_ids + ) + if listing: + parts.append("Skills you may invoke via load_skill (id: when to use):\n" + listing) + + # 3. MCP tools + allowed_servers = (workspace or {}).get("active_mcp_servers") + mcp_tools = mcp_manager.list_all_tools(allowed_servers=allowed_servers) + if mcp_tools: + listing = "\n".join( + f"- {t['server']}.{t['name']}: {t['description']}" for t in mcp_tools + ) + parts.append("MCP tools available via mcp_call (server.name: description):\n" + listing) + + # 4. CLI shortcuts + cli_data = load_cli_tools() + cli_ids = (workspace or {}).get("active_cli_tools") + cli_listing = [] + for c in cli_data.get("tools", []): + if cli_ids is not None and c["id"] not in cli_ids: + continue + policy = c.get("approval_policy", "ask") + policy_note = " [always-allowed]" if policy == "always" else "" + cli_listing.append( + f"- {c['id']} ({c.get('name', c['id'])}): {c.get('description', '')} " + f"[template: {c.get('command_template', '')}]{policy_note}" + ) + if cli_listing: + parts.append( + "CLI shortcuts available via cli_call (id: description [template]):\n" + + "\n".join(cli_listing) + ) + + parts.append(f"Detected operating system: {platform.system()} ({platform.platform()}).") + parts.append(TOOL_PROTOCOL) + + # 5. Integrated services — only advertised when enabled + service_lines: list[str] = [] + if svc_state.is_enabled("autogui"): + service_lines.append( + "- autogui_task: drive the desktop GUI (move the mouse, click, type, " + "open apps, work in any window — including Notepad on Windows or any " + "other native app) via AutoGUI's ReAct loop. PREFER this over " + "execute_shell when the user asks to operate a GUI application. " + "Args: {\"task\": \"natural-language description of what to do\", " + "\"dry_run\": false}." + ) + if svc_state.is_enabled("osso"): + service_lines.append( + "- screen_windows: list every open window on the user's desktop. " + "Args: {}." + ) + service_lines.append( + "- screen_description: describe a window's contents via accessibility " + "tree or vision. Args: {\"window_index\": 0, \"mode\": " + "\"accessibility|vision\"}." + ) + service_lines.append( + "- screen_screenshot: capture a screenshot of a window. " + "Args: {\"window_index\": 0}." + ) + service_lines.append( + "- screen_action: perform a precise screen action (click, type, key " + "press). REQUIRES APPROVAL. Args: {\"action\": \"click|type|key\", " + "\"x\": 0, \"y\": 0, \"text\": \"text-to-type-or-key-name\"}." + ) + if svc_state.is_enabled("clk"): + service_lines.append( + "- clk_research: start a CognitiveLoopKernel research / reasoning " + "workflow for deep multi-step analysis. REQUIRES APPROVAL. " + "Args: {\"command\": \"run\", \"workflow\": \"optional workflow name\", " + "\"args\": [], \"workspace_id\": \"optional\"}." + ) + if service_lines and effective_mode != "plan": + parts.append( + "Integrated services available as tools (call them like any other " + "tool via the ```tool block). These extend what you can do beyond " + "execute_shell:\n" + "\n".join(service_lines) + ) + + return "\n\n".join(parts) + + +# --------------------------------------------------------------------------- +# Tool-call parsing +# --------------------------------------------------------------------------- + +def extract_tool_call(text: str) -> Optional[dict]: + marker = "```tool" + start = text.find(marker) + if start == -1: + return None + body_start = text.find("\n", start) + 1 + end = text.find("```", body_start) + if end == -1: + return None + body = text[body_start:end].strip() + try: + call = json.loads(body) + except json.JSONDecodeError: + return None + if not isinstance(call, dict) or "tool" not in call: + return None + raw_args = call.get("args", {}) or {} + if not isinstance(raw_args, dict): + raw_args = {} + # Some models omit the "args" wrapper and place fields at the top level. + # Merge any unknown top-level keys into args so the tool handler sees them. + if not raw_args: + raw_args = {k: v for k, v in call.items() if k not in ("tool", "args")} + return { + "tool": call["tool"], + "args": raw_args, + "raw_block": text[start : end + 3], + } diff --git a/services/request_ctx.py b/services/request_ctx.py new file mode 100644 index 0000000..8ea32ec --- /dev/null +++ b/services/request_ctx.py @@ -0,0 +1,29 @@ +"""Per-request correlation id plumbing (Phase 3 extraction from app.py). + +The contextvar is set by app.py's 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. +""" +from __future__ import annotations + +import contextvars +import logging + +from fastapi import Request + +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 + + +def request_id_of(request: Request) -> str: + rid = getattr(request.state, "request_id", None) + return rid or request_id_ctx.get() + diff --git a/services/routes.py b/services/routes.py index 3530649..3ef5c88 100644 --- a/services/routes.py +++ b/services/routes.py @@ -7,13 +7,13 @@ 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 +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,17 +102,17 @@ 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) -> StreamingResponse: _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"}, ) @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,17 +160,17 @@ 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) -> StreamingResponse: _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"}, ) @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/services/scheduled.py b/services/scheduled.py new file mode 100644 index 0000000..1cb9514 --- /dev/null +++ b/services/scheduled.py @@ -0,0 +1,56 @@ +"""Scheduled-task execution and the in-memory notification queue +(Phase 3 extraction from app.py). The scheduler loop itself lives in +scheduler.py; app.py's lifespan wires _run_scheduled_task and +_emit_scheduled_notification into it. +""" +from __future__ import annotations + +import time + +from . import llm +from .prompt_builder import build_system_prompt +from .storage import load_config, load_prompts + +# --- Scheduled tasks --- + +# Queue of pending scheduled-task notifications. The /api/scheduled-tasks/notifications/stream +# SSE endpoint drains this and pushes to the browser; once delivered the +# in-memory list is cleared. We persist nothing — recently-missed +# notifications can still be read from each task's history field. +_scheduled_notifications: list[dict] = [] + + +async def _emit_scheduled_notification(task: dict, result: dict) -> None: + _scheduled_notifications.append({ + "id": task.get("id"), + "name": task.get("name"), + "ok": bool(result.get("ok", True)), + "summary": (result.get("summary") or "")[:500], + "ts": time.time(), + }) + + +async def _run_scheduled_task(task: dict) -> dict: + """Execute a scheduled task by running its prompt through the same code + path as /api/chat. Returns {ok, summary}.""" + cfg = load_config() + if not cfg.get("api_key") or not cfg.get("base_url"): + return {"ok": False, "summary": "BetterWebUI is not connected to a backend."} + # Honour the task's workspace if it specifies one. + if task.get("workspace_id"): + cfg = dict(cfg) + cfg["active_workspace_id"] = task["workspace_id"] + prompts = load_prompts() + model = cfg.get("default_model") or "" + if not model: + return {"ok": False, "summary": "No default model configured."} + system_prompt = build_system_prompt(cfg, prompts, mode="trusted") + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": (task.get("prompt") or "").strip() or "(no prompt)"}, + ] + try: + text, _usage = await llm.chat_complete(messages, model, cfg) + except Exception as exc: + return {"ok": False, "summary": f"LLM call failed: {exc}"} + return {"ok": True, "summary": (text or "").strip()[:1000]} diff --git a/services/session.py b/services/session.py new file mode 100644 index 0000000..50c1e63 --- /dev/null +++ b/services/session.py @@ -0,0 +1,134 @@ +"""In-memory per-server-session state and the local-caller gate. + +Extracted from app.py (Phase 3). The objects here (approvals, file_responses, +_session_trusted_commands, _command_explanation_cache) are shared singletons: +app.py re-exports them by identity so existing `app_module.approvals` style +test access keeps working. They are only ever mutated in place, never rebound. +""" +from __future__ import annotations + +import asyncio +import ipaddress +import os +import uuid + +from fastapi import HTTPException, Request + +# --------------------------------------------------------------------------- +# Session-level in-memory stores +# --------------------------------------------------------------------------- + +# Commands trusted for the duration of this server session +_session_trusted_commands: set[str] = set() + +# Explanation cache keyed by command hash +_command_explanation_cache: dict[str, str] = {} +# --------------------------------------------------------------------------- +# Approval / file-response state +# --------------------------------------------------------------------------- + +class ApprovalState: + def __init__(self) -> None: + self.events: dict[str, asyncio.Event] = {} + self.results: dict[str, bool] = {} + + def new(self) -> str: + aid = uuid.uuid4().hex + self.events[aid] = asyncio.Event() + return aid + + async def wait(self, aid: str, timeout: float = 600.0) -> bool: + try: + await asyncio.wait_for(self.events[aid].wait(), timeout=timeout) + except asyncio.TimeoutError: + return False + return self.results.get(aid, False) + + def resolve(self, aid: str, approved: bool) -> bool: + if aid not in self.events: + return False + self.results[aid] = approved + self.events[aid].set() + return True + + +approvals = ApprovalState() + + +class FileResponseStore: + def __init__(self) -> None: + self.events: dict[str, asyncio.Event] = {} + self.results: dict[str, list[dict]] = {} + + def new(self) -> str: + rid = uuid.uuid4().hex + self.events[rid] = asyncio.Event() + return rid + + async def wait(self, rid: str, timeout: float = 600.0) -> list[dict]: + try: + await asyncio.wait_for(self.events[rid].wait(), timeout=timeout) + except asyncio.TimeoutError: + return [] + return self.results.get(rid, []) + + def resolve(self, rid: str, files: list[dict]) -> bool: + if rid not in self.events: + return False + self.results[rid] = files + self.events[rid].set() + return True + + +file_responses = FileResponseStore() +_DOCKER_BRIDGE_RANGE = ipaddress.ip_network("172.16.0.0/12") + + +def _require_local_caller(request: Request) -> None: + """Reject requests that don't come from a local client. + + Session-trust state and the project-file endpoints can affect on-disk + state, so they shouldn't be reachable from arbitrary remote hosts when + the server is bound to 0.0.0.0. Default: loopback only. Docker bridge + and broader LAN access are opt-in via env vars: + BETTERWEBUI_ALLOW_DOCKER=true → also accept 172.16.0.0/12 (the + Docker bridge range). docker-compose.yml + sets this by default so the + containerized UI just works. + BETTERWEBUI_ALLOW_LAN=true → accept any RFC1918 private IP + BETTERWEBUI_REQUIRE_LOCAL=strict → loopback only (overrides both above) + + The narrower default protects bare-metal deployments on real LANs that + happen to use 172.16/12 — they no longer leak local-only endpoints to + LAN peers just because the network's CIDR overlaps Docker's range. + """ + client_host = (request.client.host if request.client else "") or "" + if client_host in ("127.0.0.1", "::1", "localhost", "testclient"): + return + mode = os.environ.get("BETTERWEBUI_REQUIRE_LOCAL", "").lower() + if mode == "strict": + raise HTTPException(403, "This endpoint is limited to local callers.") + try: + addr = ipaddress.ip_address(client_host) + except ValueError: + raise HTTPException(403, "This endpoint is limited to local callers.") + if addr.is_loopback: + return + # Docker bridge range is opt-in (default off) so a bare-metal install on + # a real 172.16/12 LAN doesn't accidentally expose local-only endpoints. + allow_docker = os.environ.get("BETTERWEBUI_ALLOW_DOCKER", "").lower() in ("1", "true", "yes") + if allow_docker and isinstance(addr, ipaddress.IPv4Address) and addr in _DOCKER_BRIDGE_RANGE: + return + # Broader LAN access is also opt-in. + allow_lan = os.environ.get("BETTERWEBUI_ALLOW_LAN", "").lower() in ("1", "true", "yes") + if allow_lan and addr.is_private: + return + raise HTTPException(403, "This endpoint is limited to local callers.") + + +def _is_local_caller(request: Request) -> bool: + try: + _require_local_caller(request) + return True + except HTTPException: + return False diff --git a/services/skills.py b/services/skills.py new file mode 100644 index 0000000..5edbed3 --- /dev/null +++ b/services/skills.py @@ -0,0 +1,93 @@ +"""Skill-file loading and configuration lints (Phase 3 extraction from app.py). + +SKILLS_DIR is read via ``storage.SKILLS_DIR`` attribute access so test +fixtures that rebind the path (through app.py's forwarding properties) are +honored at call time. +""" +from __future__ import annotations + +import shutil +from typing import Optional + +from . import storage +from .storage import _load_frontmatter, load_cli_tools, load_mcp_servers + + +def list_skill_files() -> list[dict]: + skills = [] + for path in sorted(storage.SKILLS_DIR.glob("*.md")): + try: + post = _load_frontmatter(path) + skills.append({ + "id": path.stem, + "name": post.get("name", path.stem), + "description": post.get("description", ""), + "filename": path.name, + }) + except Exception as exc: + skills.append({ + "id": path.stem, + "name": path.stem, + "description": f"(could not parse: {exc})", + "filename": path.name, + }) + return skills + + +def load_skill_content(skill_id: str) -> Optional[dict]: + path = storage.SKILLS_DIR / f"{skill_id}.md" + if not path.exists(): + return None + post = _load_frontmatter(path) + return { + "id": skill_id, + "name": post.get("name", skill_id), + "description": post.get("description", ""), + "content": post.content, + } + + +def _lint_skills() -> list[dict]: + issues = [] + for path in sorted(storage.SKILLS_DIR.glob("*.md")): + try: + post = _load_frontmatter(path) + if not post.get("name"): + issues.append({"type": "skill", "id": path.stem, "issue": "Missing 'name' in frontmatter"}) + if not post.get("description"): + issues.append({"type": "skill", "id": path.stem, "issue": "Missing 'description' in frontmatter"}) + except Exception as exc: + issues.append({"type": "skill", "id": path.stem, "issue": f"Parse error: {exc}"}) + return issues + + +def _lint_mcp() -> list[dict]: + issues = [] + # Suggest what a missing binary usually means so the user knows how to fix it + hint_by_bin = { + "npx": "install Node.js (provides npx)", + "node": "install Node.js", + "uvx": "install uv (provides uvx)", + "uv": "install uv", + } + for s in load_mcp_servers().get("servers", []): + if not s.get("command"): + issues.append({"type": "mcp", "id": s.get("name", "?"), "issue": "Missing 'command'"}) + continue + cmd = s.get("command", "") + # Validate the exact configured binary, not an alternative + if cmd and not shutil.which(cmd): + hint = hint_by_bin.get(cmd) + msg = f"'{cmd}' not found on PATH" + if hint: + msg += f" — {hint}" + issues.append({"type": "mcp", "id": s["name"], "issue": msg}) + return issues + + +def _lint_cli() -> list[dict]: + issues = [] + for c in load_cli_tools().get("tools", []): + if "{args}" not in c.get("command_template", ""): + issues.append({"type": "cli", "id": c.get("id", "?"), "issue": "command_template does not contain {args}"}) + return issues 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/services/state.py b/services/state.py index 53a4dbe..3ad5c82 100644 --- a/services/state.py +++ b/services/state.py @@ -1,4 +1,21 @@ -"""Persist enabled/disabled state for each integrated service.""" +"""Persist enabled/disabled state for each integrated service. + +Concurrency invariants (audited, no lock required today): + +- Every helper here is synchronous and await-free, and all callers — the + /api/services/* route handlers in services/routes.py + app.py's chat tool + paths, and the background health probes spawned via asyncio.gather in + services/health.py — are coroutines on the single event loop. Each + load -> mutate -> save cycle therefore runs to completion without another + coroutine interleaving, which is what makes the read-modify-write in + set_enabled() safe without a lock. +- If any helper ever awaits between _load() and _save() (or the file I/O is + moved to a thread/executor), wrap the read-modify-write in an + asyncio.Lock — see scheduler._TASKS_LOCK for the pattern. +- The state file is only written through _save(); background tasks only + *read* (services/health.py checks is_enabled), so readers can at worst see + a value that is one write stale, never a torn in-memory structure. +""" from __future__ import annotations import json diff --git a/services/storage.py b/services/storage.py new file mode 100644 index 0000000..5441fce --- /dev/null +++ b/services/storage.py @@ -0,0 +1,188 @@ +"""Filesystem paths and JSON persistence for BetterWebUI. + +Extracted from app.py (Phase 3). The path constants below are the canonical +copies: app.py exposes forwarding properties (see app.py's module-class swap) +so that test fixtures which monkeypatch `app.DATA_DIR` etc. transparently +rebind the values here. For the same reason, code in *other* modules must +read these constants via attribute access (``storage.DATA_DIR``), never via +``from services.storage import DATA_DIR`` — a from-import would freeze the +value at import time and miss the rebind. +""" +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from typing import Any, Optional + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +DATA_DIR = ROOT / "data" +SKILLS_DIR = ROOT / "skills" + +UPLOADS_DIR = DATA_DIR / "uploads" +CHECKPOINTS_DIR = DATA_DIR / "checkpoints" +TASKS_DIR = DATA_DIR / "tasks" +CONFIG_PATH = DATA_DIR / "config.json" +PROMPTS_PATH = DATA_DIR / "system_prompts.json" +CONVERSATIONS_PATH = DATA_DIR / "conversations.json" +WORKSPACES_PATH = DATA_DIR / "workspaces.json" +MCP_PATH = DATA_DIR / "mcp_servers.json" +CLI_PATH = DATA_DIR / "cli_tools.json" +BRANDING_PATH = DATA_DIR / "branding.json" +SCHEDULED_TASKS_PATH = DATA_DIR / "scheduled_tasks.json" + +# WORKSPACE_DIR is the default directory for shell execution and file I/O. +# Set via the WORKSPACE_DIR environment variable (Docker mounts a host folder +# here). Falls back to a local "workspace/" subfolder when running without Docker. +WORKSPACE_DIR = Path(os.environ.get("WORKSPACE_DIR", str(ROOT / "workspace"))) + +for d in (DATA_DIR, SKILLS_DIR, UPLOADS_DIR, CHECKPOINTS_DIR, TASKS_DIR, WORKSPACE_DIR): + d.mkdir(parents=True, exist_ok=True) +# --------------------------------------------------------------------------- +# Frontmatter parsing (avoids dependency on specific python-frontmatter API) +# --------------------------------------------------------------------------- + +class _FrontmatterPost: + def __init__(self, meta: dict, content: str) -> None: + self._meta = meta + self.content = content + + def get(self, key: str, default: Any = None) -> Any: + return self._meta.get(key, default) + + +def _load_frontmatter(path: Path) -> _FrontmatterPost: + text = path.read_text(encoding="utf-8") + lines = text.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return _FrontmatterPost({}, text) + end_idx = None + for i in range(1, len(lines)): + if lines[i].rstrip("\r\n") == "---": + end_idx = i + break + if end_idx is None: + return _FrontmatterPost({}, text) + front_text = "".join(lines[1:end_idx]) + content = "".join(lines[end_idx + 1:]) + try: + raw = yaml.safe_load(front_text) + except yaml.YAMLError: + raw = None + meta = raw if isinstance(raw, dict) else {} + return _FrontmatterPost(meta, content) + + +# --------------------------------------------------------------------------- +# Persistence helpers +# --------------------------------------------------------------------------- + +def load_json(path: Path, default: Any) -> Any: + if not path.exists(): + return default + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return default + + +def save_json(path: Path, data: Any) -> None: + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +def load_config() -> dict: + return load_json( + CONFIG_PATH, + { + "base_url": "http://localhost:3000", + "api_key": "", + "default_model": "", + "image_model": "", + "tts_voice": "alloy", + "active_prompt_id": "", + "active_skills": [], + "active_workspace_id": "", + "auto_approve_safe": True, + "shell_enabled": True, + "consensus_runs": 1, + "api_profile": None, + "chat_mode": "approve-each", + "onboarding_done": False, + "display": {}, + "verification": { + "enabled": True, + "mode": "validators_only", + "retries": 1, + "judge_model": "", + "judge_confidence_threshold": 0.7, + "tools": { + "generate_image": True, + "generate_audio": True, + "autogui_task": True, + "execute_shell": False, + "write_file": True, + "mcp_call": False, + }, + }, + "web_search": { + "provider": "", # "tavily" | "brave" | "serpapi" | "custom" | "" + "api_key": "", + "custom_url": "", + }, + }, + ) + + +def load_prompts() -> dict: + return load_json( + PROMPTS_PATH, + { + "prompts": [ + { + "id": "default", + "name": "Helpful Assistant", + "content": ( + "You are a helpful, friendly assistant for a faculty " + "member in higher education. Be clear, concise, and " + "patient. When asked to do something on their computer, " + "use available tools." + ), + } + ] + }, + ) + + +def load_conversations() -> dict: + return load_json(CONVERSATIONS_PATH, {"conversations": {}}) + + +def load_workspaces() -> dict: + return load_json(WORKSPACES_PATH, {"workspaces": []}) + + +def load_mcp_servers() -> dict: + return load_json(MCP_PATH, {"servers": []}) + + +def load_cli_tools() -> dict: + return load_json(CLI_PATH, {"tools": []}) + +def save_conversation(cid: str, title: str, messages: list, task_plan: Optional[list] = None, workspace_id: str = "") -> None: + data = load_conversations() + existing = data["conversations"].get(cid, {}) + data["conversations"][cid] = { + **existing, + "id": cid, + "title": title, + "messages": messages, + "task_plan": task_plan or [], + "workspace_id": workspace_id, + "updated_at": int(time.time()), + } + if "created_at" not in data["conversations"][cid]: + data["conversations"][cid]["created_at"] = int(time.time()) + save_json(CONVERSATIONS_PATH, data) diff --git a/services/tools.py b/services/tools.py new file mode 100644 index 0000000..6f68685 --- /dev/null +++ b/services/tools.py @@ -0,0 +1,1019 @@ +"""Tool execution: shell, file checkpoints, media/web tool calls, the +read-only subagent loop, and the execute_tool dispatcher +(Phase 3 extraction from app.py). + +Path constants are read via ``storage.`` attribute access and the chat +model via ``llm.chat_complete`` so test fixtures that rebind them through +app.py's forwarding properties are honored at call time. +""" +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +import logging +import platform +import re +import shutil +import time +import uuid +from pathlib import Path +from typing import Any, Awaitable, Callable, Optional + +import httpx +from fastapi import HTTPException + +from . import llm, storage +from .llm import active_profile, normalize_base_url +from .mcp import mcp_manager +from .prompt_builder import RENDERING_PROTOCOL, extract_tool_call, resolve_active_workspace +from .session import _session_trusted_commands, approvals, file_responses +from .skills import load_skill_content +from .storage import load_cli_tools + +logger = logging.getLogger("betterwebui.tools") + +# --------------------------------------------------------------------------- +# Shell / OS helpers +# --------------------------------------------------------------------------- + +def detect_shell() -> tuple[list[str], str]: + if platform.system() == "Windows": + if shutil.which("pwsh"): + return (["pwsh", "-NoProfile", "-Command"], "PowerShell") + return (["powershell", "-NoProfile", "-Command"], "PowerShell") + return (["bash", "-lc"], "bash") + + +async def run_shell(command: str, timeout: int = 120, cwd: Optional[str] = None) -> dict: + argv_prefix, shell_name = detect_shell() + argv = argv_prefix + [command] + started = time.time() + effective_cwd = cwd or str(storage.WORKSPACE_DIR) + # Validate cwd up front so a misconfigured workspace project_root produces + # a clear error instead of being reported as "Shell not available". + cwd_path = Path(effective_cwd) + if not cwd_path.exists() or not cwd_path.is_dir(): + return { + "shell": shell_name, + "exit_code": -1, + "stdout": "", + "stderr": f"Working directory does not exist: {effective_cwd}", + "duration_ms": int((time.time() - started) * 1000), + } + try: + proc = await asyncio.create_subprocess_exec( + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=effective_cwd, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return { + "shell": shell_name, + "exit_code": -1, + "stdout": "", + "stderr": f"Command timed out after {timeout}s.", + "duration_ms": int((time.time() - started) * 1000), + } + return { + "shell": shell_name, + "exit_code": proc.returncode, + "stdout": (stdout or b"").decode("utf-8", errors="replace")[:20000], + "stderr": (stderr or b"").decode("utf-8", errors="replace")[:8000], + "duration_ms": int((time.time() - started) * 1000), + } + except FileNotFoundError as exc: + return { + "shell": shell_name, + "exit_code": -1, + "stdout": "", + "stderr": f"Shell not available: {exc}", + "duration_ms": int((time.time() - started) * 1000), + } + + +def _slug(text: str, fallback: str = "image") -> str: + out = "".join(c if c.isalnum() or c in "-_" else "-" for c in (text or "")).strip("-") + return (out or fallback)[:48] + + +# --------------------------------------------------------------------------- +# Checkpoint helpers (project file versioning) +# --------------------------------------------------------------------------- + +def _ckpt_key(filename: str) -> str: + """Collision-resistant directory key for a checkpointed filename. + + _slug() collapses punctuation and casing, so distinct filenames could share + a slug and mix their histories. Use a hash of the normalized relative path + instead — full content (history mixing risk gone) and bounded length. + """ + norm = (filename or "file").strip().replace("\\", "/") + return hashlib.sha1(norm.encode("utf-8")).hexdigest()[:16] + + +def _checkpoint_file(workspace_id: str, filename: str, content: bytes) -> str: + """Save a checkpoint snapshot of raw bytes. Returns the checkpoint id. + + Stores as `.bin` so binary files round-trip without UTF-8 replacement. + Legacy `.txt` snapshots from earlier versions are still readable by + _get_checkpoint and _list_checkpoints. + """ + ckpt_dir = storage.CHECKPOINTS_DIR / workspace_id / _ckpt_key(filename) + ckpt_dir.mkdir(parents=True, exist_ok=True) + ckpt_id = f"{int(time.time())}_{uuid.uuid4().hex[:6]}" + ckpt_path = ckpt_dir / f"{ckpt_id}.bin" + ckpt_path.write_bytes(content) + return ckpt_id + + +def _list_checkpoints(workspace_id: str, filename: str) -> list[dict]: + ckpt_dir = storage.CHECKPOINTS_DIR / workspace_id / _ckpt_key(filename) + if not ckpt_dir.exists(): + return [] + # Sort by mtime (descending) so the .bin and legacy .txt eras interleave + # correctly when both exist for the same filename. + files = list(ckpt_dir.glob("*.bin")) + list(ckpt_dir.glob("*.txt")) + out = [] + for p in sorted(files, key=lambda p: p.stat().st_mtime, reverse=True)[:20]: + parts = p.stem.split("_", 1) + ts = int(parts[0]) if parts else 0 + out.append({"id": p.stem, "filename": filename, "saved_at": ts}) + return out + + +def _get_checkpoint(workspace_id: str, filename: str, ckpt_id: str) -> Optional[bytes]: + """Return the raw checkpoint bytes, or None if the checkpoint is missing. + + Reads `.bin` first; falls back to legacy `.txt` (UTF-8) for snapshots taken + before checkpoints became binary-safe. + """ + base = storage.CHECKPOINTS_DIR / workspace_id / _ckpt_key(filename) + bin_path = base / f"{ckpt_id}.bin" + if bin_path.exists(): + return bin_path.read_bytes() + txt_path = base / f"{ckpt_id}.txt" + if txt_path.exists(): + return txt_path.read_bytes() + return None + + +# --------------------------------------------------------------------------- +# OpenWebUI proxy helpers +# --------------------------------------------------------------------------- + +def _sniff_image_mime(raw: bytes) -> Optional[str]: + """Magic-byte sniff. Returns canonical image mime or None for unrecognised bytes.""" + if len(raw) < 12: + return None + if raw.startswith(b"\x89PNG\r\n\x1a\n"): + return "image/png" + if raw[:3] == b"\xff\xd8\xff": + return "image/jpeg" + if raw[:6] in (b"GIF87a", b"GIF89a"): + return "image/gif" + if raw[:4] == b"RIFF" and raw[8:12] == b"WEBP": + return "image/webp" + if raw[:2] == b"BM": + return "image/bmp" + return None + + +def validate_image_bytes(raw: bytes, min_bytes: int = 64) -> tuple[bool, str, Optional[str]]: + """Return (ok, reason, sniffed_mime). Cheap deterministic check used to + detect broken image renders before they reach the UI as broken-link icons.""" + if not raw: + return False, "Empty image payload.", None + if len(raw) < min_bytes: + return False, f"Image payload too small ({len(raw)} bytes).", None + mime = _sniff_image_mime(raw) + if mime is None: + return False, "Image bytes do not match any known image format.", None + return True, "", mime + + +async def call_openwebui_image(prompt: str, size: str, config: dict) -> dict: + base = normalize_base_url(config["base_url"]) + profile = active_profile(config) + headers = {"Authorization": f"Bearer {config['api_key']}"} + payload = {"prompt": prompt, "n": 1, "size": size} + if config.get("image_model"): + payload["model"] = config["image_model"] + async with httpx.AsyncClient(timeout=240.0) as client: + resp = await client.post(f"{base}{profile['images']}", json=payload, headers=headers) + if resp.status_code != 200: + raise HTTPException(status_code=resp.status_code, detail=f"Image generation failed: {resp.text[:500]}") + body = resp.json() + if isinstance(body, list): + item = body[0] if body else {} + else: + item = (body.get("data") or [{}])[0] if isinstance(body, dict) else {} + filename = f"{_slug(prompt)}-{uuid.uuid4().hex[:6]}.png" + if "b64_json" in item: + try: + raw = base64.b64decode(item["b64_json"], validate=False) + except Exception as exc: + return {"error": f"Image generation returned undecodable base64: {exc}", "prompt": prompt} + ok, reason, sniffed = validate_image_bytes(raw) + if not ok: + return {"error": f"Image generation returned invalid data: {reason}", "prompt": prompt} + return { + "filename": filename, + "mime": sniffed or "image/png", + "data_b64": item["b64_json"], + "prompt": prompt, + } + if "url" in item: + async with httpx.AsyncClient(timeout=180.0) as client: + img_resp = await client.get(item["url"]) + if img_resp.status_code != 200: + return {"error": f"Could not fetch generated image at {item['url']} (HTTP {img_resp.status_code})."} + ok, reason, sniffed = validate_image_bytes(img_resp.content) + if not ok: + return {"error": f"Image generation returned invalid data: {reason}", "prompt": prompt} + return { + "filename": filename, + "mime": sniffed or img_resp.headers.get("content-type", "image/png"), + "data_b64": base64.b64encode(img_resp.content).decode("ascii"), + "prompt": prompt, + "source_url": item["url"], + } + return {"raw": body, "error": "Image generation response had neither b64_json nor url."} + + +async def call_web_search(query: str, max_results: int, config: dict) -> dict: + """Dispatch to the configured web-search provider. Returns a dict with + keys: query, provider, results=[{title, url, snippet}], or {error: ...}. + """ + web = (config or {}).get("web_search") or {} + provider = (web.get("provider") or "").lower() + api_key = web.get("api_key") or "" + if not provider: + return {"error": "Web search is not configured. Settings → Connection → Web search."} + + async with httpx.AsyncClient(timeout=20.0) as client: + if provider == "tavily": + if not api_key: + return {"error": "Tavily requires an API key (Settings → Connection → Web search)."} + resp = await client.post( + "https://api.tavily.com/search", + json={ + "api_key": api_key, + "query": query, + "max_results": max_results, + "search_depth": "basic", + }, + ) + if resp.status_code != 200: + return {"error": f"Tavily search failed ({resp.status_code}): {resp.text[:300]}"} + body = resp.json() + results = [ + {"title": r.get("title", ""), "url": r.get("url", ""), "snippet": r.get("content", "")} + for r in (body.get("results") or [])[:max_results] + ] + return {"query": query, "provider": "tavily", "results": results} + + if provider == "brave": + if not api_key: + return {"error": "Brave Search requires an API key."} + resp = await client.get( + "https://api.search.brave.com/res/v1/web/search", + params={"q": query, "count": max_results}, + headers={"X-Subscription-Token": api_key, "Accept": "application/json"}, + ) + if resp.status_code != 200: + return {"error": f"Brave search failed ({resp.status_code}): {resp.text[:300]}"} + body = resp.json() + results = [ + {"title": r.get("title", ""), "url": r.get("url", ""), "snippet": r.get("description", "")} + for r in ((body.get("web") or {}).get("results") or [])[:max_results] + ] + return {"query": query, "provider": "brave", "results": results} + + if provider == "serpapi": + if not api_key: + return {"error": "SerpAPI requires an API key."} + resp = await client.get( + "https://serpapi.com/search.json", + params={"q": query, "engine": "google", "num": max_results, "api_key": api_key}, + ) + if resp.status_code != 200: + return {"error": f"SerpAPI search failed ({resp.status_code}): {resp.text[:300]}"} + body = resp.json() + results = [ + {"title": r.get("title", ""), "url": r.get("link", ""), "snippet": r.get("snippet", "")} + for r in (body.get("organic_results") or [])[:max_results] + ] + return {"query": query, "provider": "serpapi", "results": results} + + if provider == "custom": + url = web.get("custom_url") or "" + if not url: + return {"error": "Custom web search needs a 'custom_url' in settings."} + headers = {} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + resp = await client.post(url, json={"query": query, "max_results": max_results}, headers=headers) + if resp.status_code != 200: + return {"error": f"Custom search failed ({resp.status_code}): {resp.text[:300]}"} + try: + body = resp.json() + except Exception: + return {"error": "Custom search returned non-JSON."} + custom_results = body.get("results") if isinstance(body, dict) else body + if not isinstance(custom_results, list): + return {"error": "Custom search did not return a 'results' list."} + return {"query": query, "provider": "custom", "results": custom_results[:max_results]} + + return {"error": f"Unknown web_search provider: {provider}"} + + +_HTML_TAG_RE = re.compile(r"<[^>]+>") +_WHITESPACE_RE = re.compile(r"\s+") +_HTML_ENTITIES = {"&": "&", "<": "<", ">": ">", """: '"', "'": "'", "'": "'", " ": " "} + + +def _strip_html(raw: str) -> tuple[str, str]: + """Return (title, body_text) extracted from HTML. Falls back to raw text.""" + title = "" + title_m = re.search(r"]*>(.*?)", raw, re.IGNORECASE | re.DOTALL) + if title_m: + title = title_m.group(1).strip() + # Remove script/style blocks + raw = re.sub(r"<(script|style)[^>]*>.*?", " ", raw, flags=re.IGNORECASE | re.DOTALL) + # Remove all other tags + text = _HTML_TAG_RE.sub(" ", raw) + # Decode common HTML entities + for ent, ch in _HTML_ENTITIES.items(): + text = text.replace(ent, ch) + text = _WHITESPACE_RE.sub(" ", text).strip() + # Clean title entities too + for ent, ch in _HTML_ENTITIES.items(): + title = title.replace(ent, ch) + title = _WHITESPACE_RE.sub(" ", title).strip() + return title, text + + +async def call_fetch_url(url: str) -> dict: + """Fetch a URL and return extracted readable text.""" + parsed = None + try: + from urllib.parse import urlparse + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + return {"error": f"fetch_url only supports http/https URLs (got '{parsed.scheme}')."} + except Exception: + return {"error": "Invalid URL."} + try: + async with httpx.AsyncClient( + timeout=20.0, + follow_redirects=True, + headers={"User-Agent": "Mozilla/5.0 BetterWebUI/1.0"}, + ) as client: + resp = await client.get(url) + if resp.status_code >= 400: + return {"error": f"Server returned {resp.status_code} for {url}."} + ct = resp.headers.get("content-type", "") + if "html" in ct: + title, text = _strip_html(resp.text) + else: + title, text = "", resp.text + max_chars = 12000 + truncated = len(text) > max_chars + return { + "url": url, + "title": title, + "text": text[:max_chars] + (" …[truncated]" if truncated else ""), + "word_count": len(text.split()), + } + except Exception as exc: + return {"error": f"Failed to fetch {url}: {exc}"} + + +async def call_openwebui_audio(text: str, voice: str, config: dict) -> dict: + base = normalize_base_url(config["base_url"]) + profile = active_profile(config) + headers = {"Authorization": f"Bearer {config['api_key']}"} + payload = {"input": text, "voice": voice or "alloy", "model": "tts-1"} + async with httpx.AsyncClient(timeout=240.0) as client: + resp = await client.post(f"{base}{profile['audio']}", json=payload, headers=headers) + if resp.status_code != 200: + raise HTTPException(status_code=502, detail=f"Audio generation failed (upstream {resp.status_code}): {resp.text[:500]}") + filename = f"{_slug(text, 'speech')}-{uuid.uuid4().hex[:6]}.mp3" + return { + "filename": filename, + "mime": "audio/mpeg", + "data_b64": base64.b64encode(resp.content).decode("ascii"), + "voice": voice, + } +# --------------------------------------------------------------------------- +# Subagent execution +# --------------------------------------------------------------------------- + +_SUBAGENT_TOOL_PROTOCOL = """ +You have one tool. To call it, output exactly one fenced JSON block on its own lines: + +```tool +{"tool": "load_skill", "args": {"skill_id": "..."}} +``` + +Available tools: +- load_skill: load the full content of a named skill. Args: {"skill_id": "..."}. + +Output at most one tool call per turn. Never invent tool output — wait for the result. +""".strip() + + +async def run_subagent_loop( + prompt: str, model: str, config: dict, max_steps: int = 4 +) -> str: + """Run a read-only sub-loop. Returns final assistant text.""" + sub_system = ( + "You are a read-only research subagent. You may call load_skill only. " + "Do NOT call execute_shell, write_file, generate_image, generate_audio, " + "cli_call, mcp_call, or read_file. Summarize from your existing context. " + "Produce a concise summary of your findings when done.\n\n" + + _SUBAGENT_TOOL_PROTOCOL + + "\n\n" + + RENDERING_PROTOCOL + ) + history = [{"role": "user", "content": prompt}] + # Subagents are strictly read-only: mcp_call is blocked entirely because we + # cannot statically guarantee any given MCP tool is side-effect-free. + blocked_tools = ( + "execute_shell", "write_file", "generate_image", + "generate_audio", "cli_call", "mcp_call", + ) + for _ in range(max_steps): + messages = [{"role": "system", "content": sub_system}] + history + text, _ = await llm.chat_complete(messages, model, config) + history.append({"role": "assistant", "content": text}) + call = extract_tool_call(text) + if not call: + return text + # Only allow read-only tools + if call["tool"] in blocked_tools: + history.append({ + "role": "user", + "content": f"[Tool '{call['tool']}' blocked — subagents are read-only]" + }) + continue + if call["tool"] == "read_file": + result = {"error": "Subagents cannot use the file picker. Summarize from context instead."} + elif call["tool"] == "load_skill": + skill = load_skill_content(call["args"].get("skill_id", "")) + result = skill or {"error": "Skill not found."} + else: + result = {"error": f"Unknown tool: {call['tool']}"} + history.append({ + "role": "user", + "content": f"[Tool '{call['tool']}' result]\n```json\n{json.dumps(result, indent=2)[:4000]}\n```" + }) + # Return last assistant turn + for m in reversed(history): + if m["role"] == "assistant": + return m["content"] + return "(subagent produced no output)" + + +# --------------------------------------------------------------------------- +# Permission / approval helpers +# --------------------------------------------------------------------------- + +def _should_skip_approval(command: str, cli_id: Optional[str], config: dict) -> bool: + """Return True if this command/CLI can skip the approval dialog.""" + if command in _session_trusted_commands: + return True + if cli_id: + cli_data = load_cli_tools() + cli = next((c for c in cli_data.get("tools", []) if c["id"] == cli_id), None) + if cli and cli.get("approval_policy") == "always": + return True + workspace = resolve_active_workspace(config) + if workspace and workspace.get("shell_approval_policy") == "always": + return True + return False + + +# --------------------------------------------------------------------------- +# Tool execution +# --------------------------------------------------------------------------- + +async def execute_tool( + call: dict, + config: dict, + send_event: Callable[[str, dict], Awaitable[Any]], + mode: str = "approve-each", + model: str = "", +) -> dict: + tool = call["tool"] + args = call["args"] + + # Plan mode: block side-effecting tools (and spawn_subagent, which can + # transitively read files/skills but otherwise consumes context budget + # before any execution has been approved — matches PLAN_MODE_BLOCK's + # "ONLY call update_task_plan/read_file/load_skill" contract). + if mode == "plan" and tool in ( + "execute_shell", "write_file", "generate_image", + "generate_audio", "cli_call", "mcp_call", "spawn_subagent", + ): + return {"error": f"Tool '{tool}' is blocked in plan mode. Switch to Approve-each to execute."} + + if tool == "update_task_plan": + items = args.get("items", []) + await send_event("task_plan", {"items": items}) + return {"ok": True, "items_count": len(items), "items": items} + + if tool == "spawn_subagent": + kind = args.get("kind", "explore") + prompt = args.get("prompt", "") + items = args.get("items") or [] + model = model or config.get("default_model", "") + if not model: + return {"error": "No model available — cannot spawn subagents."} + + # Build per-subagent prompts + if items and kind == "compare": + subprompts = [ + f"Investigate and summarize: {item}\n\nContext: {prompt}" + for item in items[:3] + ] + else: + subprompts = [prompt] + + await send_event("subagent_start", {"kind": kind, "count": len(subprompts)}) + results = await asyncio.gather( + *[run_subagent_loop(sp, model, config) for sp in subprompts], + return_exceptions=True, + ) + texts = [] + for i, r in enumerate(results): + item_label = items[i] if i < len(items) else f"Task {i+1}" + if isinstance(r, Exception): + texts.append(f"**{item_label}**: Error — {r}") + else: + texts.append(f"**{item_label}**:\n{r}") + combined = "\n\n---\n\n".join(texts) + await send_event("subagent_result", {"kind": kind, "count": len(subprompts), "combined": combined}) + return {"kind": kind, "results_count": len(subprompts), "combined": combined} + + if tool == "execute_shell": + if not config.get("shell_enabled", True): + return {"error": "Shell execution is disabled in settings."} + command = args.get("command", "").strip() + reason = args.get("reason", "") + if not command: + return {"error": "No command provided."} + + if mode == "trusted" or _should_skip_approval(command, None, config): + await send_event("tool_running", {"tool": "execute_shell", "command": command, "auto_approved": True}) + else: + aid = approvals.new() + await send_event("approval_request", { + "approval_id": aid, + "tool": "execute_shell", + "command": command, + "reason": reason, + "shell": detect_shell()[1], + }) + approved = await approvals.wait(aid) + if not approved: + return {"error": "User denied this command."} + await send_event("tool_running", {"tool": "execute_shell", "command": command}) + + workspace = resolve_active_workspace(config) + shell_cwd = _resolve_project_root(workspace) + result = await run_shell(command, cwd=shell_cwd) + # Auto-capture plot + for _img_path in (Path("/tmp/bwui_plot.png"), storage.ROOT / "bwui_plot.png"): + if _img_path.exists(): + try: + result["filename"] = "plot.png" + result["mime"] = "image/png" + result["data_b64"] = base64.b64encode(_img_path.read_bytes()).decode("ascii") + _img_path.unlink() + 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 + + if tool == "read_file": + rid = file_responses.new() + await send_event("file_request", { + "request_id": rid, + "purpose": args.get("reason") or args.get("purpose") or "read", + "accept": args.get("accept", "*/*"), + "multiple": bool(args.get("multiple", False)), + }) + files = await file_responses.wait(rid) + if not files: + return {"error": "User cancelled the file picker (no files chosen)."} + out_files = [] + for f in files: + entry = { + "filename": f.get("filename", "file"), + "content_type": f.get("content_type", ""), + "size": f.get("size", 0), + } + if f.get("content") is not None: + entry["content"] = (f.get("content") or "")[:80000] + elif f.get("data_b64"): + b64 = f["data_b64"] + entry["data_b64"] = b64[:200_000] + entry["truncated"] = len(b64) > 200_000 + out_files.append(entry) + return {"files": out_files} + + if tool == "write_file": + filename = (args.get("filename") or args.get("path") or "file.txt").strip() + filename = Path(filename).name or "file.txt" + content = args.get("content", "") + mime = args.get("mime", "text/plain") + if not isinstance(content, str): + content = str(content) + # Cap write size so a single tool call can't blow up SSE/JSON payloads + # (base64 inflates by ~33% on top of UTF-8 encoding). + _MAX_WRITE_BYTES = 5 * 1024 * 1024 + content_bytes_len = len(content.encode("utf-8")) + if content_bytes_len > _MAX_WRITE_BYTES: + return {"error": f"write_file payload too large ({content_bytes_len} bytes; max {_MAX_WRITE_BYTES})."} + + # Determine the write directory: workspace project_root → WORKSPACE_DIR + workspace = resolve_active_workspace(config) + project_root = _resolve_project_root(workspace) + dest = Path(project_root) / filename + + if mode != "trusted": + aid = approvals.new() + await send_event("approval_request", { + "approval_id": aid, + "tool": "write_file", + "filename": filename, + "mime": mime, + "preview": content[:1000], + "byte_count": len(content.encode("utf-8")), + "dest_path": filename, + }) + approved = await approvals.wait(aid) + if not approved: + return {"error": "User denied this file write."} + + # Snapshot the existing file only after approval, so denied requests + # don't leave junk checkpoints behind on disk. Skip the snapshot + # entirely if the existing file is larger than the checkpoint cap + # (2 MB) — checkpoints are an undo helper, not a backup system, and + # reading multi-hundred-MB files into memory just to checkpoint is + # worse than degrading gracefully. + _MAX_CHECKPOINT_BYTES = 2 * 1024 * 1024 + checkpoint_id = None + if dest.exists(): + wid = (workspace or {}).get("id", "default") + try: + existing_size = dest.stat().st_size + if existing_size <= _MAX_CHECKPOINT_BYTES: + # Read raw bytes so binary files round-trip through + # checkpoint/revert without lossy UTF-8 replacement. + checkpoint_id = _checkpoint_file( + wid, filename, dest.read_bytes() + ) + 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 + write_error = None + try: + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(content, encoding="utf-8") + except Exception as exc: + write_error = str(exc) + + # Only inline data_b64 on the failure path. When the on-disk write + # succeeded, the file is already at / and + # the user can open it from the file-tree pane — we'd just bloat + # every SSE event with up to 5 MB of base64 (≈6.7 MB JSON) for no + # gain, and that's fragile behind common reverse proxies. + # When the write failed (write_error), we still inline so the user + # can recover the generated bytes via the chat download link. + result = { + "filename": filename, + "dest_path": filename, + "mime": mime, + "bytes_written": content_bytes_len, + "checkpoint_id": checkpoint_id, + } + if write_error: + result["write_error"] = write_error + result["data_b64"] = base64.b64encode(content.encode("utf-8")).decode("ascii") + return result + + if tool == "delete_file": + filename = (args.get("filename") or args.get("path") or "").strip() + filename = Path(filename).name + if not filename: + return {"error": "delete_file requires a 'filename' argument."} + workspace = resolve_active_workspace(config) + project_root = _resolve_project_root(workspace) + dest = Path(project_root) / filename + if not dest.exists(): + return {"error": f"File '{filename}' not found in the workspace."} + if not dest.is_file(): + return {"error": f"'{filename}' is not a regular file and cannot be deleted with this tool."} + + if mode != "trusted": + aid = approvals.new() + await send_event("approval_request", { + "approval_id": aid, + "tool": "delete_file", + "filename": filename, + "dest_path": filename, + "reason": args.get("reason", ""), + }) + approved = await approvals.wait(aid) + if not approved: + return {"error": "User denied file deletion."} + + try: + dest.unlink() + except OSError as exc: + return {"error": f"Could not delete '{filename}': {exc}"} + return {"deleted": filename, "path": filename} + + if tool == "load_skill": + skill_id = args.get("skill_id", "") + skill = load_skill_content(skill_id) + if not skill: + return {"error": f"Skill '{skill_id}' not found."} + return skill + + if tool == "generate_image": + try: + return await call_openwebui_image(args.get("prompt", ""), args.get("size", "1024x1024"), config) + except HTTPException as exc: + return {"error": exc.detail} + + if tool == "generate_audio": + try: + return await call_openwebui_audio( + args.get("text", ""), + args.get("voice") or config.get("tts_voice", "alloy"), + config, + ) + except HTTPException as exc: + return {"error": exc.detail} + + if tool == "mcp_call": + server = args.get("server", "") + name = args.get("name", "") + arguments = args.get("arguments") or {} + if not server or not name: + return {"error": "mcp_call requires both 'server' and 'name'."} + return await mcp_manager.call(server, name, arguments) + + if tool == "web_search": + query = (args.get("query") or "").strip() + if not query: + return {"error": "web_search requires a 'query' argument."} + try: + max_results = int(args.get("max_results") or 5) + except Exception: + max_results = 5 + max_results = max(1, min(10, max_results)) + try: + return await call_web_search(query, max_results, config) + except HTTPException as exc: + return {"error": exc.detail} + + if tool == "fetch_url": + url = (args.get("url") or "").strip() + if not url: + return {"error": "fetch_url requires a 'url' argument."} + if mode != "trusted": + aid = approvals.new() + await send_event("approval_request", { + "approval_id": aid, + "tool": "fetch_url", + "command": f"Fetch: {url}", + "reason": "The assistant wants to download the contents of a web page.", + "shell": "", + }) + approved = await approvals.wait(aid) + if not approved: + return {"error": "User denied fetch_url."} + await send_event("tool_running", {"tool": "fetch_url", "url": url}) + return await call_fetch_url(url) + + if tool == "cli_call": + if not config.get("shell_enabled", True): + return {"error": "Shell execution is disabled in settings."} + cli_id = args.get("id", "") + cli_args = args.get("args", "") + cli_data = load_cli_tools() + cli = next((c for c in cli_data.get("tools", []) if c["id"] == cli_id), None) + if not cli: + return {"error": f"CLI shortcut '{cli_id}' is not configured."} + template = cli.get("command_template", "{args}") + command = template.replace("{args}", cli_args) + workspace = resolve_active_workspace(config) + + if mode == "trusted" or _should_skip_approval(command, cli_id, config): + await send_event("tool_running", {"tool": "cli_call", "command": command, "auto_approved": True}) + else: + aid = approvals.new() + await send_event("approval_request", { + "approval_id": aid, + "tool": "execute_shell", + "command": command, + "reason": f"CLI shortcut '{cli_id}': {cli.get('description', '')}", + "shell": detect_shell()[1], + "cli_id": cli_id, + }) + approved = await approvals.wait(aid) + if not approved: + return {"error": "User denied this command."} + await send_event("tool_running", {"tool": "cli_call", "command": command}) + + # Run CLI shortcuts from the workspace project_root, mirroring + # execute_shell so commands that assume they run inside the project + # folder (e.g., pandoc on input/*.md) behave consistently. + cli_cwd = _resolve_project_root(workspace) + return await run_shell(command, cwd=cli_cwd) + + # ── Service tool calls ──────────────────────────────────────────────────── + + if tool == "clk_research": + 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") + workflow = args.get("workflow", "") + summary = f"CLK research — workflow: {workflow or 'default'}, command: {command}" + if mode != "trusted": + aid = approvals.new() + await send_event("approval_request", { + "approval_id": aid, + "tool": "clk_research", + "command": summary, + "reason": "CognitiveLoopKernel will start a research task.", + }) + approved = await approvals.wait(aid) + if not approved: + return {"error": "User denied CognitiveLoopKernel research task."} + await send_event("tool_running", {"tool": "clk_research", "command": summary}) + try: + client = get_clk_client() + return await client.start_research( + command=command, + args=args.get("args", []), + workspace_id=args.get("workspace_id"), + workflow=workflow or None, + ) + except (_httpx.ConnectError, _httpx.TimeoutException, _httpx.TransportError) as e: + return {"error": f"CognitiveLoopKernel is enabled but could not be reached. ({e})"} + + if tool == "autogui_task": + 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 "" + if not task_desc.strip(): + return {"error": "autogui_task requires a non-empty 'task' argument. " + "Please call the tool again with {\"task\": \"description of what to do\", \"dry_run\": false}."} + dry_run = args.get("dry_run") or False + summary = f"AutoGUI task: {task_desc[:120]}" + (" [dry run]" if dry_run else "") + if mode != "trusted": + aid = approvals.new() + await send_event("approval_request", { + "approval_id": aid, + "tool": "autogui_task", + "command": summary, + "reason": "AutoGUI will control the desktop GUI to complete this task.", + }) + approved = await approvals.wait(aid) + if not approved: + return {"error": "User denied AutoGUI desktop task."} + await send_event("tool_running", {"tool": "autogui_task", "command": summary}) + try: + agui_client = get_autogui_client() + return await agui_client.start_task(task=task_desc, model=model or None, dry_run=dry_run) + except (_httpx.ConnectError, _httpx.TimeoutException, _httpx.TransportError) as e: + return {"error": f"AutoGUI is enabled but could not be reached. ({e})"} + + if tool == "screen_windows": + 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: + return await get_osso_client().windows() + except (_httpx.ConnectError, _httpx.TimeoutException, _httpx.TransportError) as e: + return {"error": f"OSScreenObserver is enabled but could not be reached. ({e})"} + + if tool == "screen_description": + 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: + return await get_osso_client().description( + window_index=args.get("window_index"), + mode=args.get("mode", "accessibility"), + ) + except (_httpx.ConnectError, _httpx.TimeoutException, _httpx.TransportError) as e: + return {"error": f"OSScreenObserver is enabled but could not be reached. ({e})"} + + if tool == "screen_screenshot": + 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: + return await get_osso_client().screenshot(window_index=args.get("window_index")) + except (_httpx.ConnectError, _httpx.TimeoutException, _httpx.TransportError) as e: + return {"error": f"OSScreenObserver is enabled but could not be reached. ({e})"} + + if tool == "screen_action": + 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", "") + summary = f"screen_{action_type}" + (f" at ({args.get('x')}, {args.get('y')})" if "x" in args else "") + if mode != "trusted": + aid = approvals.new() + await send_event("approval_request", { + "approval_id": aid, + "tool": "screen_action", + "command": summary, + "reason": "OSScreenObserver will perform an action on the screen.", + }) + approved = await approvals.wait(aid) + if not approved: + return {"error": "User denied screen action."} + await send_event("tool_running", {"tool": "screen_action", "command": summary}) + try: + return await get_osso_client().action(args) + except (_httpx.ConnectError, _httpx.TimeoutException, _httpx.TransportError) as e: + return {"error": f"OSScreenObserver is enabled but could not be reached. ({e})"} + + return {"error": f"Unknown tool: {tool}"} + +def _resolve_project_root(workspace: Optional[dict]) -> str: + """Resolve a workspace's project root, restricting it to live under + WORKSPACE_DIR. This prevents an unauthenticated caller (via /api/workspaces) + from pointing a workspace at '/' or another sensitive directory and using + the project file APIs to browse the host filesystem.""" + root, _clamped = _resolve_project_root_info(workspace) + return root + + +def _resolve_project_root_info(workspace: Optional[dict]) -> tuple[str, bool]: + """Same resolution as _resolve_project_root but also reports whether the + stored project_root had to be clamped to WORKSPACE_DIR. + + Returns (effective_root, clamped). `clamped=True` means the caller set an + out-of-bounds project_root that we silently coerced — useful for UI hints + that distinguish "no project root configured" from "configured but invalid" + even when the user intentionally pointed project_root at WORKSPACE_DIR + itself (in which case clamped is False). + """ + requested = (workspace or {}).get("project_root") + base = Path(storage.WORKSPACE_DIR).resolve() + if not requested: + return str(base), False + try: + candidate = Path(requested) + # Resolve relative paths against WORKSPACE_DIR (not the process CWD), + # matching the validation logic in upsert_workspace. + if not candidate.is_absolute(): + candidate = base / candidate + candidate = candidate.resolve() + candidate.relative_to(base) + return str(candidate), False + except (ValueError, OSError): + return str(base), True diff --git a/services/transient.py b/services/transient.py new file mode 100644 index 0000000..364746f --- /dev/null +++ b/services/transient.py @@ -0,0 +1,61 @@ +"""Transient (per-chat, TTL-swept) upload storage helpers +(Phase 3 extraction from app.py). +""" +from __future__ import annotations + +import asyncio +import logging +import shutil +import time +from pathlib import Path + +from . import storage + +# --- Transient uploads (per-chat, TTL-swept). Used for file bundles that +# live in browser IndexedDB and are streamed up only for the duration of +# the chat turn — keeps sensitive bytes off the server long-term. + +_TRANSIENT_TTL_SECONDS = 24 * 3600 + + +def _transient_root() -> Path: + """Resolve the transient-uploads directory lazily from the current + UPLOADS_DIR. Lazy resolution lets test fixtures rebind UPLOADS_DIR + without these endpoints pointing at the stale module-load value.""" + root = storage.UPLOADS_DIR / "transient" + root.mkdir(parents=True, exist_ok=True) + return root + + +def _sweep_transient_uploads() -> int: + """Delete transient-upload chat directories older than the TTL. + Returns the count of directories removed. Safe to call on a timer.""" + cutoff = time.time() - _TRANSIENT_TTL_SECONDS + removed = 0 + try: + for chat_dir in _transient_root().iterdir(): + if not chat_dir.is_dir(): + continue + try: + if chat_dir.stat().st_mtime < cutoff: + shutil.rmtree(chat_dir, ignore_errors=True) + removed += 1 + except Exception: + continue + except FileNotFoundError: + pass + return removed + + +async def _transient_sweep_loop() -> None: + """Background loop: sweep stale transient uploads every hour.""" + while True: + try: + removed = _sweep_transient_uploads() + if removed: + logging.getLogger("betterwebui.uploads").info( + "Swept %d stale transient upload directories.", removed, + ) + except Exception as exc: + logging.getLogger("betterwebui.uploads").warning("Sweep failed: %s", exc) + await asyncio.sleep(3600) diff --git a/static/app.js b/static/app.js index b81a51a..f1c0813 100644 --- a/static/app.js +++ b/static/app.js @@ -1,3915 +1,12 @@ -// BetterWebUI client. Single-file vanilla JS — no build step. - -const $ = (sel) => document.querySelector(sel); -const $$ = (sel) => Array.from(document.querySelectorAll(sel)); - -const state = { - config: null, - models: [], - prompts: [], - skills: [], - conversations: [], - workspaces: [], - mcpServers: [], - mcpRegistry: [], - cliTools: [], - cliRegistry: [], - currentConversationId: null, - messages: [], - attachments: [], - busy: false, - fileStore: {}, - taskPlan: [], // current plan items from backend - convSearchQuery: "", // conversation search filter - convSearchResults: null, // server-side full-text search results (null = not active) - micListening: false, // voice input state - rightRailVisible: false, - planPaneVisible: false, - filesPaneVisible: false, - // last-turn telemetry - lastTelemetry: null, - // Callback invoked by global Escape to cancel a pending modal (askApproval / handleFileRequest / diff) - pendingDialogCancel: null, -}; - -// --------------------------------------------------------------------------- -// API helpers -// --------------------------------------------------------------------------- - -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(); -} - -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}.`; -} - -// --------------------------------------------------------------------------- -// Local-download helpers -// --------------------------------------------------------------------------- - -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" }); -} - -function storeFile(blob, filename, mime) { - const url = URL.createObjectURL(blob); - state.fileStore[filename] = { url, mime: mime || blob.type || "application/octet-stream", filename }; - return url; -} - -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; -} - -// --------------------------------------------------------------------------- -// Markdown + KaTeX rendering -// --------------------------------------------------------------------------- - -const MATH_STASH_OPEN = ""; -const MATH_STASH_CLOSE = ""; -const MATH_STASH_RE = new RegExp(MATH_STASH_OPEN + "(\\d+)" + MATH_STASH_CLOSE, "g"); - -function renderMarkdownWithMath(text) { - text = String(text || "").replace(/```tool[\s\S]*?```/g, ""); - const stash = []; - const stashOne = (s) => { - stash.push(s); - return MATH_STASH_OPEN + (stash.length - 1) + MATH_STASH_CLOSE; - }; - text = text - .replace(/\$\$[\s\S]+?\$\$/g, stashOne) - .replace(/\\\[[\s\S]+?\\\]/g, stashOne) - .replace(/\\\([\s\S]+?\\\)/g, stashOne) - .replace(/(?"); - } - if (window.DOMPurify) { - html = DOMPurify.sanitize(html, { ADD_ATTR: ["target", "rel"] }); - } - html = html.replace(MATH_STASH_RE, (_, i) => escape(stash[+i])); - return html; -} - -function renderMathIn(el) { - if (!window.renderMathInElement || !el) return; - try { - renderMathInElement(el, { - delimiters: [ - { left: "$$", right: "$$", display: true }, - { left: "\\[", right: "\\]", display: true }, - { left: "\\(", right: "\\)", display: false }, - { left: "$", right: "$", display: false }, - ], - throwOnError: false, - strict: "ignore", - }); - } catch (e) { - console.warn("KaTeX render error:", e); - } -} - -// --------------------------------------------------------------------------- -// Display settings -// --------------------------------------------------------------------------- - -const _FONT_SIZE_VALUES = ["sm", "md", "lg", "xl"]; -const _LINE_HEIGHT_VALUES = ["normal", "relaxed", "loose"]; - -function applyDisplaySettings(display) { - const body = document.body; - // Validate persisted values against an allowlist; classList tokens cannot - // contain whitespace, so a corrupted config value would otherwise throw. - const fontSize = _FONT_SIZE_VALUES.includes(display.font_size) ? display.font_size : "md"; - const lineHeight = _LINE_HEIGHT_VALUES.includes(display.line_height) ? display.line_height : "normal"; - // Font size - body.classList.remove("font-sm", "font-md", "font-lg", "font-xl"); - body.classList.add("font-" + fontSize); - // Line height - body.classList.remove("lh-normal", "lh-relaxed", "lh-loose"); - body.classList.add("lh-" + lineHeight); - // Dyslexic font - body.classList.toggle("dyslexic-font", !!display.dyslexic_font); - // High contrast - body.classList.toggle("high-contrast", !!display.high_contrast); - // Reduce motion - body.classList.toggle("reduce-motion", !!display.reduce_motion); -} - -function loadDisplaySettingsUI(display) { - if (!display) return; - const fs = $("#cfg-font-size"); - const lh = $("#cfg-line-height"); - const dy = $("#cfg-dyslexic"); - const hc = $("#cfg-high-contrast"); - const rm = $("#cfg-reduce-motion"); - // Clamp persisted values to the supported set so the select UI doesn't - // end up blank when config holds an older/unexpected value while - // applyDisplaySettings has already coerced the applied class to the default. - const fontSize = _FONT_SIZE_VALUES.includes(display.font_size) ? display.font_size : "md"; - const lineHeight = _LINE_HEIGHT_VALUES.includes(display.line_height) ? display.line_height : "normal"; - if (fs) fs.value = fontSize; - if (lh) lh.value = lineHeight; - if (dy) dy.checked = !!display.dyslexic_font; - if (hc) hc.checked = !!display.high_contrast; - if (rm) rm.checked = !!display.reduce_motion; -} - -async function saveDisplay() { - const display = { - font_size: $("#cfg-font-size").value, - line_height: $("#cfg-line-height").value, - dyslexic_font: $("#cfg-dyslexic").checked, - high_contrast: $("#cfg-high-contrast").checked, - reduce_motion: $("#cfg-reduce-motion").checked, - }; - await api("/api/config", { method: "POST", json: { display } }); - applyDisplaySettings(display); - flash("Display settings saved.", "good"); -} - -// --------------------------------------------------------------------------- -// Settings tab -// --------------------------------------------------------------------------- - -async function loadConfig() { - state.config = await api("/api/config"); - $("#cfg-base-url").value = state.config.base_url || ""; - $("#cfg-api-key-status").textContent = state.config.api_key_set - ? "API key is set (enter a new one to replace it)" - : "Not set"; - $("#cfg-image-model").value = state.config.image_model || ""; - $("#cfg-tts-voice").value = state.config.tts_voice || "alloy"; - // Snap the consensus value to one of the three select options (1, 3, or 5). - const cr = state.config.consensus_runs ?? 1; - const snap = cr <= 1 ? "1" : (cr <= 3 ? "3" : "5"); - $("#cfg-consensus-runs").value = snap; - $("#cfg-shell-enabled").checked = state.config.shell_enabled !== false; - const ws = state.config.web_search || {}; - const wsProvider = $("#cfg-websearch-provider"); if (wsProvider) wsProvider.value = ws.provider || ""; - const wsCustomWrap = $("#cfg-websearch-custom-wrap"); - if (wsCustomWrap) wsCustomWrap.hidden = (ws.provider !== "custom"); - const wsCustom = $("#cfg-websearch-custom"); if (wsCustom) wsCustom.value = ws.custom_url || ""; - const ver = state.config.verification || {}; - const verMode = $("#cfg-verification-mode"); if (verMode) verMode.value = ver.mode || "validators_only"; - const verRetries = $("#cfg-verification-retries"); if (verRetries) verRetries.value = ver.retries ?? 1; - // Mode select: prefer the active workspace's mode (if any) so the - // workspace-scoped setting actually takes effect; fall back to the - // global config mode, then to "approve-each". - const ms = $("#mode-select"); - if (ms) { - const activeWsId = state.config.active_workspace_id; - const activeWs = activeWsId && state.workspaces - ? state.workspaces.find((w) => w.id === activeWsId) - : null; - ms.value = (activeWs && activeWs.mode) || state.config.chat_mode || "approve-each"; - } - // Display - loadDisplaySettingsUI(state.config.display || {}); - applyDisplaySettings(state.config.display || {}); - renderConnectionStatus(state.config); - await loadHealth(); - await loadServicesStatus(); -} - -function renderConnectionStatus(cfg) { - const el = $("#connection-status"); - if (!el) return; - el.className = "status-line"; - if (cfg.api_profile_label) { - el.textContent = `Connected via ${cfg.api_profile_label}.`; - el.classList.add("good"); - } else if (cfg.api_key_set && cfg.base_url) { - el.textContent = "Saved — but no working API endpoint was detected at that URL."; - el.classList.add("warn"); - } else { - el.textContent = "Not yet connected."; - } -} - -async function loadHealth() { - try { - const h = await api("/api/health"); - $("#about-info").innerHTML = - `Detected OS: ${h.platform} · Shell: ${h.shell}
` + - `${h.skills} skill(s) · ${h.workspaces} workspace(s) · ` + - `${h.mcp_running}/${h.mcp_servers} MCP server(s) running · ${h.cli_tools} CLI shortcut(s)`; - } catch (e) { - $("#about-info").textContent = "Health check failed."; - } -} - -async function loadServicesStatus() { - try { - const s = await api("/api/services/status"); - const map = s.services || {}; - ["clk", "autogui", "osso"].forEach((name) => { - const el = $(`#svc-${name}-enabled`); - if (el) el.checked = map[name]?.enabled !== false; - }); - } catch (_) { /* services module not running — ignore */ } -} - -async function toggleService(name, enabled) { - const statusEl = $("#services-toggle-status"); - try { - await api(`/api/services/${name}/${enabled ? "enable" : "disable"}`, { method: "POST" }); - if (statusEl) { - statusEl.textContent = `${name} ${enabled ? "enabled" : "disabled"}.`; - statusEl.className = "status-line good"; - setTimeout(() => { statusEl.textContent = ""; statusEl.className = "status-line"; }, 2500); - } - } catch (e) { - if (statusEl) { - statusEl.textContent = `Failed to update ${name}: ${e.message}`; - statusEl.className = "status-line bad"; - } - // Revert the checkbox - const el = $(`#svc-${name}-enabled`); - if (el) el.checked = !enabled; - } -} - -async function saveConnection() { - const baseUrl = $("#cfg-base-url").value.trim(); - const apiKey = $("#cfg-api-key").value.trim(); - const patch = { base_url: baseUrl }; - if (apiKey) patch.api_key = apiKey; - const el = $("#connection-status"); - el.className = "status-line"; - el.textContent = "Testing…"; - const result = await api("/api/config", { method: "POST", json: patch }); - $("#cfg-api-key").value = ""; - state.config = result; - $("#cfg-base-url").value = result.base_url || ""; - $("#cfg-api-key-status").textContent = result.api_key_set - ? "API key is set (enter a new one to replace it)" - : "Not set"; - renderConnectionStatus(result); - await refreshModels(); -} - -async function saveDefaults() { - const patch = { - default_model: $("#cfg-default-model").value || null, - image_model: $("#cfg-image-model").value.trim() || "", - tts_voice: $("#cfg-tts-voice").value.trim() || "alloy", - consensus_runs: Math.min(10, Math.max(1, parseInt($("#cfg-consensus-runs").value, 10) || 1)), - shell_enabled: $("#cfg-shell-enabled").checked, - }; - await api("/api/config", { method: "POST", json: patch }); - await loadConfig(); - await refreshModels(); - flash("Defaults saved.", "good"); -} - -async function saveWebSearch() { - const provider = $("#cfg-websearch-provider").value || ""; - const apiKey = $("#cfg-websearch-key").value.trim(); - const customUrl = $("#cfg-websearch-custom").value.trim(); - const patch = { - web_search: { - provider, - ...(apiKey ? { api_key: apiKey } : {}), - custom_url: customUrl, - }, - }; - const el = $("#websearch-status"); - try { - await api("/api/config", { method: "POST", json: patch }); - if (el) { - el.textContent = provider ? `Saved. Web search will use ${provider}.` : "Saved. Web search is disabled."; - el.className = "status-line good"; - } - $("#cfg-websearch-key").value = ""; - await loadConfig(); - } catch (e) { - if (el) { - el.textContent = friendlyError(e, "saving web search settings"); - el.className = "status-line bad"; - } - } -} - -async function saveVerification() { - const mode = $("#cfg-verification-mode").value || "validators_only"; - const retries = Math.min(3, Math.max(0, parseInt($("#cfg-verification-retries").value, 10) || 1)); - const enabled = (mode !== "off"); - const patch = { - verification: { - ...(state.config.verification || {}), - enabled, - mode, - retries, - }, - }; - const el = $("#verification-status"); - try { - await api("/api/config", { method: "POST", json: patch }); - if (el) { - el.textContent = "Verification settings saved."; - el.className = "status-line good"; - } - await loadConfig(); - } catch (e) { - if (el) { - el.textContent = friendlyError(e, "saving verification settings"); - el.className = "status-line bad"; - } - } -} - -// --------------------------------------------------------------------------- -// Models -// --------------------------------------------------------------------------- - -async function refreshModels() { - const data = await api("/api/models"); - state.models = data.models || []; - if (data.error) flash(data.error, "warn"); - populateModelSelects(); -} - -function modelOptionLabel(m) { - if (!m.name || m.name === m.id) return m.id; - return `${m.name} (${m.id})`; -} - -function populateModelSelects() { - const optionHtml = - '' + - state.models.map((m) => ``).join(""); - $("#cfg-default-model").innerHTML = optionHtml; - $("#cfg-default-model").value = state.config?.default_model || ""; - $("#chat-model-select").innerHTML = optionHtml; - $("#chat-model-select").value = state.config?.default_model || ""; -} - -// --------------------------------------------------------------------------- -// Prompts -// --------------------------------------------------------------------------- - -async function loadPrompts() { - const data = await api("/api/system-prompts"); - state.prompts = data.prompts || []; - renderPromptList(); -} - -function renderPromptList() { - const ul = $("#prompt-list"); - ul.innerHTML = ""; - for (const p of state.prompts) { - const li = document.createElement("li"); - li.innerHTML = ` -
-
${escape(p.name)}
-
${escape(p.content.slice(0, 80))}${p.content.length > 80 ? "…" : ""}
-
-
- - -
`; - ul.appendChild(li); - } - ul.onclick = (e) => { - const btn = e.target.closest("button"); - if (!btn) return; - const p = state.prompts.find((x) => x.id === btn.dataset.id); - if (btn.dataset.action === "edit") openPromptDialog(p); - if (btn.dataset.action === "delete") deletePrompt(p.id); - }; -} - -function openPromptDialog(prompt) { - const isNew = !prompt; - const p = prompt || { name: "", content: "" }; - showDialog({ - title: isNew ? "New system prompt" : `Edit: ${p.name}`, - body: ` - - - `, - actions: [ - { label: "Cancel", action: "cancel" }, - { - label: "Save", - primary: true, - action: async () => { - const body = { - id: prompt?.id, - name: $("#dlg-name").value.trim(), - content: $("#dlg-content").value, - }; - if (!body.name) return; - await api("/api/system-prompts", { method: "POST", json: body }); - await loadPrompts(); - closeDialog(); - }, - }, - ], - }); -} - -async function deletePrompt(id) { - if (!confirm("Delete this prompt?")) return; - await api(`/api/system-prompts/${id}`, { method: "DELETE" }); - await loadPrompts(); -} - -// --------------------------------------------------------------------------- -// Skills -// --------------------------------------------------------------------------- - -async function loadSkills() { - const data = await api("/api/skills"); - state.skills = data.skills || []; - renderSkillList(); - await loadLintWarnings(); -} - -function renderSkillList() { - const ul = $("#skill-list"); - ul.innerHTML = ""; - for (const s of state.skills) { - const li = document.createElement("li"); - li.innerHTML = ` -
-
${escape(s.name)} (${escape(s.id)})
-
${escape(s.description || "no description")}
-
-
- - -
`; - ul.appendChild(li); - } - ul.onclick = async (e) => { - const btn = e.target.closest("button"); - if (!btn) return; - if (btn.dataset.action === "view") { - const skill = await api(`/api/skills/${btn.dataset.id}`); - showDialog({ - title: skill.name, - body: `

${escape(skill.description)}

-
${escape(skill.content)}
`, - actions: [{ label: "Close", action: "cancel" }], - }); - } - if (btn.dataset.action === "delete") { - if (!confirm("Delete this skill?")) return; - await api(`/api/skills/${btn.dataset.id}`, { method: "DELETE" }); - await loadSkills(); - } - }; -} - -async function loadLintWarnings() { - try { - const lint = await api("/api/lint"); - renderLintSection("skill-lint-warnings", lint.skills || []); - renderLintSection("mcp-lint-warnings", lint.mcp || []); - renderLintSection("cli-lint-warnings", lint.cli || []); - } catch (e) { /* lint endpoint may not exist yet */ } -} - -function renderLintSection(elId, warnings) { - const el = document.getElementById(elId); - if (!el) return; - if (!warnings.length) { el.hidden = true; return; } - el.hidden = false; - el.innerHTML = `⚠ Issues found:
    ${warnings.map((w) => `
  • ${escape(w)}
  • `).join("")}
`; -} - -function openNewSkillDialog() { - showDialog({ - title: "New skill", - body: ` - - - - - `, - actions: [ - { label: "Cancel", action: "cancel" }, - { - label: "Save", - primary: true, - action: async () => { - const body = { - id: $("#dlg-id").value.trim(), - name: $("#dlg-name").value.trim(), - description: $("#dlg-desc").value.trim(), - content: $("#dlg-content").value, - }; - if (!body.id || !body.name) return; - await api("/api/skills", { method: "POST", json: body }); - await loadSkills(); - closeDialog(); - }, - }, - ], - }); -} - -async function uploadSkill(file) { - const fd = new FormData(); - fd.append("file", file); - const res = await fetch("/api/skills/upload", { method: "POST", body: fd }); - if (!res.ok) { - flash("Upload failed: " + (await res.text()), "warn"); - return; - } - await loadSkills(); -} - -// --------------------------------------------------------------------------- -// Workspaces -// --------------------------------------------------------------------------- - -async function loadWorkspaces() { - const data = await api("/api/workspaces"); - state.workspaces = data.workspaces || []; - renderWorkspaceList(); - populateWorkspaceSelect(); -} - -function renderWorkspaceList() { - const ul = $("#workspace-list"); - ul.innerHTML = ""; - if (!state.workspaces.length) { - ul.innerHTML = `

No workspaces yet. Create one to bundle a system prompt, skills, MCP servers, and CLI shortcuts together.

`; - return; - } - for (const w of state.workspaces) { - const isActive = state.config?.active_workspace_id === w.id; - const li = document.createElement("li"); - li.classList.toggle("active", isActive); - li.innerHTML = ` -
-
${escape(w.name)} ${isActive ? 'active' : ""}
-
${escape(w.description || "—")}
-
- ${w.active_skills?.length || 0} skill(s) · - ${w.active_mcp_servers?.length || 0} MCP · - ${w.active_cli_tools?.length || 0} CLI · - ${w.files?.length || 0} file(s) -
-
-
- - - - -
`; - ul.appendChild(li); - } - ul.onclick = async (e) => { - const btn = e.target.closest("button"); - if (!btn) return; - const id = btn.dataset.id; - if (btn.dataset.action === "activate") return activateWorkspace(id); - if (btn.dataset.action === "edit") return openWorkspaceDialog(state.workspaces.find((x) => x.id === id)); - if (btn.dataset.action === "export") return exportWorkspace(id); - if (btn.dataset.action === "delete") { - if (!confirm("Delete this workspace?")) return; - await api(`/api/workspaces/${id}`, { method: "DELETE" }); - await loadWorkspaces(); - await loadConfig(); - } - }; -} - -async function exportWorkspace(id) { - const w = state.workspaces.find((x) => x.id === id); - // Push bundle manifest so the .bwui export contains file-group metadata - if (window.bws && w?.bundle_ids?.length) { - try { - const bundles = await bws.bundleList(); - const manifest = await Promise.all( - (w.bundle_ids || []).map(async (bid) => { - const bundle = bundles.find((b) => b.id === bid); - if (!bundle) return null; - const files = await bws.bundleFiles(bid); - return { - bundle_id: bid, - name: bundle.name || bid, - files: files.map((f) => ({ filename: f.filename, sha256: "" })), - }; - }) - ); - const validManifest = manifest.filter(Boolean); - if (validManifest.length) { - await api(`/api/workspaces/${encodeURIComponent(id)}/bundle-manifest`, { - method: "POST", - json: { manifest: validManifest }, - }).catch(() => {}); - } - } catch (_) {} - } - const res = await fetch(`/api/workspaces/${encodeURIComponent(id)}/export`); - if (!res.ok) { flash("Export failed.", "warn"); return; } - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${(w?.name || id).replace(/\s+/g, "_")}.bwui`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - flash("Workspace exported.", "good"); -} - -async function importWorkspace(file) { - const fd = new FormData(); - fd.append("file", file); - const res = await fetch("/api/workspaces/import", { method: "POST", body: fd }); - if (!res.ok) { flash("Import failed: " + (await res.text()), "warn"); return; } - const data = await res.json(); - await loadWorkspaces(); - flash(`Workspace "${data.name}" imported.`, "good"); -} - -function populateWorkspaceSelect() { - const sel = $("#workspace-select"); - sel.innerHTML = - '' + - state.workspaces.map((w) => ``).join(""); - sel.value = state.config?.active_workspace_id || ""; - const label = $("#active-workspace-label"); - const active = state.workspaces.find((w) => w.id === state.config?.active_workspace_id); - label.textContent = active ? active.name : ""; -} - -async function activateWorkspace(id) { - await api("/api/config", { - method: "POST", - json: { active_workspace_id: id || "" }, - }); - // Optimistically update state so populateWorkspaceSelect() (called inside - // loadWorkspaces()) sees the new active ID before loadConfig() round-trips. - if (state.config) state.config.active_workspace_id = id || ""; - // Refresh workspaces before config so loadConfig's mode-select lookup - // can find the new active workspace's stored mode. - await loadWorkspaces(); - await loadConfig(); - newChat(); - // Refresh file tree for the new workspace - if (state.filesPaneVisible) refreshFileTree(); -} - -function openWorkspaceDialog(workspace) { - const isNew = !workspace; - const w = workspace || { - id: "", - name: "", - description: "", - system_prompt_id: "", - active_skills: [], - active_mcp_servers: [], - active_cli_tools: [], - files: [], - default_model: "", - project_root: "", - mode: "approve-each", - }; - const skillsList = state.skills - .map( - (s) => ``, - ) - .join(""); - const mcpList = state.mcpServers - .map( - (m) => ``, - ) - .join(""); - const cliList = state.cliTools - .map( - (c) => ``, - ) - .join(""); - const promptOptions = state.prompts - .map((p) => ``) - .join(""); - const filesPreview = (w.files || []) - .map((f, i) => `
${escape(f.filename)} ${escape(f.content_type || "")}
`) - .join(""); - - showDialog({ - title: isNew ? "New workspace" : `Edit: ${w.name}`, - wide: true, - body: ` - - - - - - -

Skills available in this workspace

-
${skillsList || '

No skills yet.

'}
- -

MCP servers

-
${mcpList || '

No MCP servers configured. Add them under Tools.

'}
- -

CLI shortcuts

-
${cliList || '

No CLI shortcuts configured. Add them under Tools.

'}
- -

Persistent files

-

Files added here travel with every chat in this workspace.

-
${filesPreview}
- - `, - actions: [ - { label: "Cancel", action: "cancel" }, - { - label: "Save", - primary: true, - action: async () => { - const body = { - id: w.id || undefined, - name: $("#dlg-name").value.trim(), - description: $("#dlg-desc").value.trim(), - system_prompt_id: $("#dlg-prompt").value || null, - default_model: $("#dlg-model").value || null, - project_root: $("#dlg-project-root").value.trim() || null, - active_skills: collectChecked("skill"), - active_mcp_servers: collectChecked("mcp"), - active_cli_tools: collectChecked("cli"), - files: pendingWorkspaceFiles, - }; - if (!body.name) return; - const res = await api("/api/workspaces", { method: "POST", json: body }); - await loadWorkspaces(); - if (isNew) await activateWorkspace(res.id); - closeDialog(); - }, - }, - ], - }); - - let pendingWorkspaceFiles = [...(w.files || [])]; - const renderFiles = () => { - $("#dlg-files").innerHTML = pendingWorkspaceFiles - .map((f, i) => `
${escape(f.filename)} ${escape(f.content_type || "")}
`) - .join(""); - $("#dlg-files").querySelectorAll("[data-remove-file]").forEach((btn) => { - btn.onclick = () => { - pendingWorkspaceFiles.splice(+btn.dataset.removeFile, 1); - renderFiles(); - }; - }); - }; - renderFiles(); - $("#dlg-add-file").onchange = async (e) => { - for (const file of e.target.files) { - const fd = new FormData(); - fd.append("file", file); - const res = await fetch("/api/upload", { method: "POST", body: fd }); - if (res.ok) { - const a = await res.json(); - pendingWorkspaceFiles.push(a); - } - } - renderFiles(); - e.target.value = ""; - }; -} - -function collectChecked(kind) { - return Array.from(document.querySelectorAll(`[data-kind="${kind}"]`)) - .filter((el) => el.checked) - .map((el) => el.dataset.id); -} - -// --------------------------------------------------------------------------- -// MCP servers (Tools tab) -// --------------------------------------------------------------------------- - -async function loadMcp() { - const [servers, registry] = await Promise.all([ - api("/api/mcp/servers"), - api("/api/mcp/registry"), - ]); - state.mcpServers = servers.servers || []; - state.mcpRegistry = registry.registry || []; - renderMcpServers(); -} - -function renderMcpServers() { - const ul = $("#mcp-server-list"); - ul.innerHTML = ""; - if (!state.mcpServers.length) { - ul.innerHTML = `

No MCP servers configured.

`; - return; - } - for (const s of state.mcpServers) { - const li = document.createElement("li"); - const dot = s.running ? "good" : (s.error ? "warn" : "muted"); - li.innerHTML = ` -
-
${escape(s.name)} - ${s.running ? `(${s.tool_count} tool${s.tool_count === 1 ? "" : "s"})` : ""} -
-
${escape(s.description || "")}${ - s.error ? ` ${escape(s.error)}` : "" - }
-
-
- - -
`; - ul.appendChild(li); - } - ul.onclick = async (e) => { - const btn = e.target.closest("button"); - if (!btn) return; - const name = btn.dataset.name; - if (btn.dataset.action === "delete") { - if (!confirm(`Remove MCP server '${name}'?`)) return; - await api(`/api/mcp/servers/${encodeURIComponent(name)}`, { method: "DELETE" }); - await loadMcp(); - } - if (btn.dataset.action === "tools") { - const s = state.mcpServers.find((x) => x.name === name); - const tools = (s?.tools || []).map((t) => `
  • ${escape(t.name)}${escape(t.description || "")}
  • `).join(""); - showDialog({ - title: `${name} — exposed tools`, - body: tools ? `
      ${tools}
    ` : `

    No tools (server may still be starting, or in error).

    `, - actions: [{ label: "Close", action: "cancel" }], - }); - } - }; -} - -const _CLOUD_SERVICE_META = { - "google": { icon: "📱", label: "Google", desc: "Calendar, Gmail, Drive" }, - "microsoft": { icon: "📋", label: "Microsoft 365", desc: "Outlook, Teams, OneDrive" }, -}; - -async function renderCloudServices() { - const wrap = $("#cloud-services-list"); - if (!wrap) return; - wrap.innerHTML = ""; - const grid = document.createElement("div"); - grid.className = "cloud-services-grid"; - for (const [provider, meta] of Object.entries(_CLOUD_SERVICE_META)) { - const card = document.createElement("div"); - card.className = "cloud-service-card"; - let statusHtml = 'Not connected'; - let btnHtml = ``; - try { - const status = await api(`/api/oauth/status/${provider}`); - if (status.connected) { - const label = status.expired ? "Token expired" : "Connected"; - const cls = status.expired ? "expired" : "connected"; - const emailPart = status.email ? ` ${escape(status.email)}` : ""; - statusHtml = `${label}${emailPart}`; - btnHtml = ` - `; - } - } catch (_) { /* not connected */ } - card.innerHTML = ` -
    - ${meta.icon} -
    -
    ${meta.label}
    -
    ${meta.desc}
    -
    -
    -
    ${statusHtml}
    -
    ${btnHtml}
    `; - card.addEventListener("click", async (e) => { - const btn = e.target.closest("button[data-action]"); - if (!btn) return; - const action = btn.dataset.action; - const prov = btn.dataset.provider; - if (action === "disconnect") { - await api(`/api/oauth/disconnect/${prov}`, { method: "DELETE" }); - renderCloudServices(); - return; - } - if (action === "connect" || action === "reconnect") { - try { - const resp = await api(`/api/oauth/connect/${prov}`, { method: "POST" }); - if (resp.auth_url) { - window.open(resp.auth_url, "_blank"); - flash(`Sign in to ${_CLOUD_SERVICE_META[prov]?.label || prov} in the opened tab, then return here.`, "info"); - // Poll for completion - let tries = 0; - const poll = setInterval(async () => { - tries++; - if (tries > 60) { clearInterval(poll); return; } - try { - const st = await api(`/api/oauth/status/${prov}`); - if (st.connected && !st.expired) { - clearInterval(poll); - flash(`Connected to ${_CLOUD_SERVICE_META[prov]?.label || prov}!`, "good"); - renderCloudServices(); - } - } catch (_) {} - }, 2000); - } - } catch (err) { - flash(friendlyError(err, `connecting to ${_CLOUD_SERVICE_META[prov]?.label || prov}`), "warn"); - } - } - }); - grid.appendChild(card); - } - wrap.appendChild(grid); -} - -function openMcpRegistryDialog() { - const items = state.mcpRegistry - .map( - (r, i) => ` -
    -

    ${escape(r.name)}

    -

    ${escape(r.description)}

    -

    Requires: ${escape(r.requires || "—")}

    - -
    `, - ) - .join(""); - showDialog({ - title: "Add an MCP server", - wide: true, - body: `
    ${items}
    `, - actions: [{ label: "Close", action: "cancel" }], - }); - document.querySelectorAll(".registry-card button").forEach((btn) => { - btn.onclick = () => { - const r = state.mcpRegistry[+btn.dataset.i]; - openMcpFieldsDialog(r); - }; - }); -} - -function openMcpFieldsDialog(reg) { - const fields = reg.fields || []; - const fieldHtml = - fields - .map( - (f) => ` - `, - ) - .join("") || `

    No additional configuration needed.

    `; - showDialog({ - title: `Add: ${reg.name}`, - body: ` -

    ${escape(reg.description)}

    -

    Requires: ${escape(reg.requires || "—")}

    - ${fieldHtml} - - `, - actions: [ - { label: "Cancel", action: "cancel" }, - { - label: "Add", - primary: true, - action: async () => { - const values = {}; - for (const f of fields) { - values[f.name] = $(`#dlg-f-${f.name}`).value.trim(); - if (!values[f.name]) { - flash(`${f.label} is required.`, "warn"); - return; - } - } - const args = (reg.args_template || []).map((a) => fillTemplate(a, values)); - const env = Object.fromEntries( - Object.entries(reg.env_template || {}).map(([k, v]) => [k, fillTemplate(v, values)]), - ); - const body = { - name: $("#dlg-name").value.trim() || reg.id, - command: reg.command, - args, - env, - description: reg.description, - enabled: true, - }; - await api("/api/mcp/servers", { method: "POST", json: body }); - await loadMcp(); - closeDialog(); - flash("MCP server added — bringing it up may take a moment.", "good"); - }, - }, - ], - }); -} - -function fillTemplate(tpl, values) { - return String(tpl).replace(/\{(\w+)\}/g, (_, k) => values[k] ?? ""); -} - -function openMcpCustomDialog() { - showDialog({ - title: "Custom MCP server", - body: ` - - - - - - `, - actions: [ - { label: "Cancel", action: "cancel" }, - { - label: "Add", - primary: true, - action: async () => { - const env = {}; - for (const line of $("#dlg-env").value.split("\n")) { - const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/); - if (m) env[m[1]] = m[2]; - } - const body = { - name: $("#dlg-name").value.trim(), - command: $("#dlg-cmd").value.trim(), - args: $("#dlg-args").value.split("\n").map((s) => s.trim()).filter(Boolean), - env, - description: $("#dlg-desc").value.trim(), - enabled: true, - }; - if (!body.name || !body.command) return; - await api("/api/mcp/servers", { method: "POST", json: body }); - await loadMcp(); - closeDialog(); - }, - }, - ], - }); -} - -// --------------------------------------------------------------------------- -// CLI shortcuts (Tools tab) -// --------------------------------------------------------------------------- - -async function loadCli() { - const [tools, registry] = await Promise.all([ - api("/api/cli/tools"), - api("/api/cli/registry"), - ]); - state.cliTools = tools.tools || []; - state.cliRegistry = registry.registry || []; - renderCliTools(); -} - -function renderCliTools() { - const ul = $("#cli-tool-list"); - ul.innerHTML = ""; - if (!state.cliTools.length) { - ul.innerHTML = `

    No CLI shortcuts yet.

    `; - return; - } - for (const c of state.cliTools) { - const li = document.createElement("li"); - li.innerHTML = ` -
    -
    ${escape(c.name)} (${escape(c.id)})
    -
    ${escape(c.description || "")}
    -
    ${escape(c.command_template || "")}
    -
    -
    - -
    `; - ul.appendChild(li); - } - ul.onclick = async (e) => { - const btn = e.target.closest("button"); - if (!btn) return; - if (btn.dataset.action === "delete") { - await api(`/api/cli/tools/${encodeURIComponent(btn.dataset.id)}`, { method: "DELETE" }); - await loadCli(); - } - }; -} - -function openCliRegistryDialog() { - const items = state.cliRegistry - .map( - (r, i) => ` -
    -

    ${escape(r.name)}

    -

    ${escape(r.description)}

    -

    ${escape(r.command_template)}

    - -
    `, - ) - .join(""); - showDialog({ - title: "Add a CLI shortcut", - wide: true, - body: `
    ${items}
    `, - actions: [{ label: "Close", action: "cancel" }], - }); - document.querySelectorAll(".registry-card button").forEach((btn) => { - btn.onclick = async () => { - const r = state.cliRegistry[+btn.dataset.i]; - await api("/api/cli/tools", { method: "POST", json: r }); - await loadCli(); - closeDialog(); - }; - }); -} - -function openCliCustomDialog() { - showDialog({ - title: "Custom CLI shortcut", - body: ` - - - - - `, - actions: [ - { label: "Cancel", action: "cancel" }, - { - label: "Add", - primary: true, - action: async () => { - const body = { - id: $("#dlg-id").value.trim(), - name: $("#dlg-name").value.trim(), - description: $("#dlg-desc").value.trim(), - command_template: $("#dlg-cmd").value.trim(), - }; - if (!body.id || !body.name || !body.command_template) return; - await api("/api/cli/tools", { method: "POST", json: body }); - await loadCli(); - closeDialog(); - }, - }, - ], - }); -} - -// --------------------------------------------------------------------------- -// Conversations: search, pin, tag, fork -// --------------------------------------------------------------------------- - -async function loadConversations() { - const data = await api("/api/conversations"); - state.conversations = data.conversations || []; - renderConversationList(); -} - -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); - } -} - -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(); -} - -async function deleteConversation(id) { - if (!confirm("Delete this conversation?")) return; - await api(`/api/conversations/${id}`, { method: "DELETE" }); - if (state.currentConversationId === id) newChat(); - await loadConversations(); -} - -async function pinConversation(id, pin) { - await api(`/api/conversations/${id}/pin`, { method: "POST", json: { pinned: pin } }); - await loadConversations(); -} - -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"); -} - -function newChat() { - state.currentConversationId = null; - state.messages = []; - state.attachments = []; - state.taskPlan = []; - renderMessages(); - renderAttachments(); - renderPlan(); - renderConversationList(); -} - -// --------------------------------------------------------------------------- -// Messages rendering -// --------------------------------------------------------------------------- - -function renderMessages() { - const container = $("#messages"); - container.innerHTML = ""; - if (!state.messages.length) { - const ws = state.workspaces.find((w) => w.id === state.config?.active_workspace_id); - const wsLine = ws - ? `Working in ${escape(ws.name)}. Conversations here use its system prompt, skills, and tools.` - : "Pick a workspace from the top of this chat, or just begin."; - container.innerHTML = ` -
    -
    §
    -

    Begin where you are.

    -

    Ask a question, paste a draft, or describe a task. The assistant can run commands, read your files, and create images or audio — always with your approval.

    -

    ${wsLine}

    -
    -

    First time here? Open Settings to enter your OpenWebUI URL and API key.

    -
    `; - renderRecentChatsResume(container); - return; - } - for (const m of state.messages) { - appendMessage(m); - } -} - -async function renderRecentChatsResume(container) { - if (!state.config?.api_key_set) return; - try { - const data = await api("/api/conversations/recent?limit=3"); - const recent = (data.recent || []).filter((c) => c.message_count > 0); - if (!recent.length) return; - const section = document.createElement("div"); - section.className = "resume-section"; - const heading = document.createElement("p"); - heading.className = "hint"; - heading.textContent = "Pick up where you left off:"; - section.appendChild(heading); - const grid = document.createElement("div"); - grid.className = "resume-grid"; - for (const c of recent) { - const card = document.createElement("button"); - card.className = "resume-card"; - card.type = "button"; - const when = _relativeTime(c.updated_at); - card.innerHTML = `${escape(c.title || "Untitled")} - ${escape(when)} · ${c.message_count} message${c.message_count === 1 ? "" : "s"} - ${c.summary ? `${escape(c.summary)}` : ""}`; - card.onclick = () => openConversation(c.id); - grid.appendChild(card); - } - section.appendChild(grid); - container.appendChild(section); - } catch (_) { /* non-critical */ } -} - -function _relativeTime(ts) { - if (!ts) return ""; - const diff = Math.floor(Date.now() / 1000) - ts; - if (diff < 60) return "just now"; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; - if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; - return new Date(ts * 1000).toLocaleDateString(); -} - -function renderVerificationTrace(trace) { - const container = $("#messages"); - if (!container || !trace) return; - const card = document.createElement("div"); - card.className = "verification-card " + (trace.final_ok ? "vc-ok" : "vc-fail"); - card.setAttribute("role", "status"); - const icon = trace.final_ok ? "✓" : "⚠"; - const summary = document.createElement("div"); - summary.className = "verification-summary"; - summary.innerHTML = `${icon}${ - trace.final_ok ? "Verified" : "Verification failed" - } · ${escape(trace.tool)}${ - trace.final_attempt > 1 ? ` · attempt ${trace.final_attempt}` : "" - }`; - card.appendChild(summary); - - // Undo button for write_file when we have a checkpoint id from the - // most recent tool_result. The verification trace doesn't carry the - // checkpoint, but the tool_result event preceded it; we look up the - // last tool_result for this tool from the in-memory event log. - if (trace.tool === "write_file" && state.lastWriteCheckpoint) { - const undo = document.createElement("button"); - undo.type = "button"; - undo.className = "vc-undo-btn"; - undo.textContent = "Undo this write"; - const { filename, checkpoint_id } = state.lastWriteCheckpoint; - undo.onclick = async () => { - undo.disabled = true; - undo.textContent = "Reverting…"; - try { - await api("/api/project/revert", { - method: "POST", - json: { filename, checkpoint_id }, - }); - undo.textContent = "Reverted ✓"; - flash(`Reverted ${filename} to the previous version.`, "good"); - } catch (e) { - undo.disabled = false; - undo.textContent = "Undo this write"; - flash(friendlyError(e, "reverting the file"), "bad"); - } - }; - card.appendChild(undo); - } - - if (Array.isArray(trace.events) && trace.events.length) { - const det = document.createElement("details"); - det.className = "verification-details"; - const sumEl = document.createElement("summary"); - sumEl.textContent = "Details"; - det.appendChild(sumEl); - const ul = document.createElement("ul"); - for (const ev of trace.events) { - const li = document.createElement("li"); - const okMark = ev.ok ? "✓" : "✗"; - const conf = ev.extras && typeof ev.extras.confidence === "number" - ? ` (${Math.round(ev.extras.confidence * 100)}% confidence)` - : ""; - li.textContent = `${okMark} [${ev.kind}] ${ev.detail || ""}${conf}`; - ul.appendChild(li); - } - det.appendChild(ul); - card.appendChild(det); - } - container.appendChild(card); - container.scrollTop = container.scrollHeight; -} - -function renderBrokenImagePlaceholder(a, m) { - const card = document.createElement("div"); - card.className = "broken-image-card"; - card.setAttribute("role", "alert"); - const label = document.createElement("div"); - label.className = "broken-image-label"; - label.textContent = "Image didn't load"; - const detail = document.createElement("div"); - detail.className = "broken-image-detail"; - const promptHint = (a && a.filename) ? a.filename : ""; - detail.textContent = promptHint - ? `The file "${promptHint}" couldn't be displayed.` - : "The image came back broken."; - const actions = document.createElement("div"); - actions.className = "broken-image-actions"; - const retryBtn = document.createElement("button"); - retryBtn.type = "button"; - retryBtn.className = "broken-image-retry"; - retryBtn.textContent = "Try again"; - retryBtn.onclick = () => { - const composer = $("#composer-input"); - if (!composer) return; - const seed = (m && m.content) || (promptHint ? `Regenerate this image: ${promptHint}` : "Regenerate the previous image"); - composer.value = seed; - composer.focus(); - flash("Edit the prompt and send to try again.", "info"); - }; - actions.appendChild(retryBtn); - if (a && a.url) { - const openBtn = document.createElement("a"); - openBtn.href = a.url; - openBtn.target = "_blank"; - openBtn.rel = "noopener"; - openBtn.className = "broken-image-open"; - openBtn.textContent = "Open raw"; - actions.appendChild(openBtn); - } - card.appendChild(label); - card.appendChild(detail); - card.appendChild(actions); - return card; -} - -function appendMessage(m) { - const container = $("#messages"); - const tpl = $("#message-template").content.cloneNode(true); - const wrap = tpl.querySelector(".message"); - wrap.classList.add(m.role); - if (m._placeholder) wrap.classList.add("typing"); - const isToolResult = - m.role === "tool" || (m.role === "user" && (m.content || "").startsWith("[Tool")); - if (isToolResult) { - wrap.classList.add("tool"); - tpl.querySelector(".role").textContent = "Tool result"; - } else { - tpl.querySelector(".role").textContent = - m.role === "assistant" ? "Assistant" : m.role === "user" ? "You" : m.role; - } - - // Per-message action buttons (read-aloud, why, try-again, fork) - if (m.role === "assistant" && !m._placeholder) { - const roleEl = tpl.querySelector(".role"); - const acts = document.createElement("div"); - acts.className = "message-actions"; - acts.innerHTML = ` - - - - `; - if (m.telemetry) { - acts.innerHTML += `${m.telemetry.tokens_in ?? "?"}→${m.telemetry.tokens_out ?? "?"}t · ${m.telemetry.elapsed_ms ?? "?"}ms`; - } - roleEl.appendChild(acts); - } - - const content = tpl.querySelector(".content"); - if (m._placeholder) { - content.innerHTML = - ''; - } else if (m.role === "assistant") { - // Render the main answer first, then append subagent cards below as - // collapsible "supporting research" sections. Reads top-down: final - // synthesis, then the parallel research that informed it. - const mainText = m.content || ""; - content.innerHTML = renderMarkdownWithMath(mainText); - if (m.subagents?.length) { - for (const sa of m.subagents) { - content.appendChild(buildSubagentCard(sa)); - } - } - } else if (isToolResult) { - content.textContent = m.content || ""; - } else { - content.innerHTML = renderMarkdownWithMath(m.content || ""); - } - - let effectiveAttachments = m.attachments || []; - if (isToolResult && m.role === "user" && !effectiveAttachments.length) { - const fm = (m.content || "").match(/"filename":\s*"([^"]+)"/); - if (fm) { - const stored = state.fileStore[fm[1]]; - if (stored) effectiveAttachments = [{ url: stored.url, content_type: stored.mime, filename: stored.filename }]; - } - } - - if (effectiveAttachments.length) { - const att = document.createElement("div"); - att.className = "attachments"; - for (const a of effectiveAttachments) { - const ct = a.content_type || ""; - if (ct.startsWith("image/")) { - const img = document.createElement("img"); - img.src = a.url; - img.alt = a.filename || ""; - img.onerror = () => { - img.replaceWith(renderBrokenImagePlaceholder(a, m)); - }; - att.appendChild(img); - } else if (ct.startsWith("audio/")) { - const audio = document.createElement("audio"); - audio.controls = true; - audio.src = a.url; - att.appendChild(audio); - } else if (ct.startsWith("video/")) { - const video = document.createElement("video"); - video.controls = true; - video.src = a.url; - video.style.maxWidth = "100%"; - att.appendChild(video); - } else { - const span = document.createElement("span"); - span.className = "file-pill"; - span.textContent = a.filename || a.url; - att.appendChild(span); - } - if (a.url && a.filename) { - const dl = document.createElement("a"); - dl.href = a.url; - dl.download = a.filename; - dl.className = "download-link"; - dl.textContent = "Download"; - att.appendChild(dl); - } - } - content.appendChild(att); - } - - container.appendChild(tpl); - const newEl = container.lastElementChild; - - // Wire read-aloud button - const readBtn = newEl?.querySelector(".read-aloud-btn"); - if (readBtn) { - readBtn.onclick = () => readAloud(newEl, m.content || "", readBtn); - } - const whyBtn = newEl?.querySelector(".why-btn"); - if (whyBtn) { - whyBtn.onclick = () => askWhy(m.content || ""); - } - const retryBtn = newEl?.querySelector(".retry-btn"); - if (retryBtn) { - retryBtn.onclick = () => offerRetryVariant(m); - } - - if (newEl && m.role === "assistant") renderMathIn(newEl); - container.scrollTop = container.scrollHeight; - return newEl; -} - -function askWhy(originalAnswer) { - const composer = $("#composer-input"); - if (!composer) return; - composer.value = - "In one or two paragraphs, explain how you arrived at your last answer, what assumptions you made, and what you're least sure about."; - composer.focus(); - flash("Press Enter to ask for an explanation.", "info"); -} - -function offerRetryVariant(m) { - showDialog({ - title: "Try this another way", - body: ` -

    Pick how you'd like the assistant to retry the previous answer:

    - - - - - `, - actions: [{ label: "Cancel", role: "close" }], - }); - setTimeout(() => { - document.querySelectorAll(".retry-variant").forEach((btn) => { - btn.onclick = () => { - const variant = btn.dataset.variant; - const prompts = { - shorter: "Rewrite your previous answer in three sentences or fewer.", - simpler: "Rewrite your previous answer in language a 12-year-old would understand. Keep it accurate.", - concrete: "Rewrite your previous answer with a specific, concrete example showing the key idea in action.", - formal: "Rewrite your previous answer in a more formal tone, suitable for a professional document.", - }; - const composer = $("#composer-input"); - if (composer) { - composer.value = prompts[variant]; - closeDialog(); - send(); - } - }; - }); - }, 50); -} - -function buildSubagentCard(sa) { - const card = document.createElement("div"); - card.className = "subagent-card"; - const collapsed = document.createElement("details"); - const summary = document.createElement("summary"); - summary.className = "subagent-header"; - summary.innerHTML = `${escape(sa.kind || "subagent")} subagent result`; - const body = document.createElement("div"); - body.className = "subagent-body"; - body.textContent = sa.combined || "(no result)"; - collapsed.appendChild(summary); - collapsed.appendChild(body); - card.appendChild(collapsed); - return card; -} - -// --------------------------------------------------------------------------- -// Read aloud -// --------------------------------------------------------------------------- - -async function readAloud(msgEl, text, btn) { - if (btn.classList.contains("reading")) { - // Toggle off: stop any playing audio in this message and revoke its blob URL - const audio = msgEl.querySelector("audio[data-tts]"); - if (audio) { - audio.pause(); - const src = audio.src; - audio.remove(); - if (src && src.startsWith("blob:")) URL.revokeObjectURL(src); - } - btn.classList.remove("reading"); - btn.title = "Read aloud"; - return; - } - btn.classList.add("reading"); - btn.title = "Stop reading"; - try { - const res = await fetch("/api/tts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: text.slice(0, 4096) }), - }); - if (!res.ok) throw new Error(await res.text()); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const audio = document.createElement("audio"); - audio.dataset.tts = "1"; - audio.autoplay = true; - audio.src = url; - audio.onended = () => { - audio.remove(); - URL.revokeObjectURL(url); - btn.classList.remove("reading"); - btn.title = "Read aloud"; - }; - msgEl.querySelector(".content").appendChild(audio); - } catch (e) { - btn.classList.remove("reading"); - btn.title = "Read aloud"; - flash("Read aloud failed: " + e.message, "warn"); - } -} - -// --------------------------------------------------------------------------- -// Composer / attachments -// --------------------------------------------------------------------------- - -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 -// --------------------------------------------------------------------------- - -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 -}; - -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; - } -} - -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); -} - -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(); -} - -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, - }; -} - -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); -} - -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; -} - -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; }); -} - -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()); - } -} - -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) -// --------------------------------------------------------------------------- - -let recognition = null; - -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"); - }; -} - -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"); } - } -} - -// --------------------------------------------------------------------------- -// Task plan pane -// --------------------------------------------------------------------------- - -const _PLAN_STATUSES = ["pending", "in_progress", "done", "blocked"]; - -function renderPlan() { - const list = $("#plan-list"); - if (!list) return; - list.innerHTML = ""; - if (!state.taskPlan.length) { - list.innerHTML = `
  • No plan yet.
  • `; - return; - } - const icons = { pending: "○", in_progress: "◉", done: "✓", blocked: "⚠" }; - for (const item of state.taskPlan) { - // Clamp item.status to the known set — values come from model output and - // could otherwise inject whitespace or extra tokens into className. - const status = _PLAN_STATUSES.includes(item.status) ? item.status : "pending"; - const li = document.createElement("li"); - li.className = `plan-item ${status}`; - li.innerHTML = ` - ${icons[status]} - - ${escape(item.title || "")} - ${item.note ? `
    ${escape(item.note)}
    ` : ""} -
    `; - list.appendChild(li); - } -} - -function setRightRailVisible(show) { - state.rightRailVisible = show; - const rail = $("#right-rail"); - if (!rail) return; - rail.hidden = !show; -} - -function setPlanPaneVisible(show) { - state.planPaneVisible = show; - const pane = $("#plan-pane"); - if (!pane) return; - pane.hidden = !show; - const btn = $("#toggle-plan-btn"); - if (btn) btn.setAttribute("aria-pressed", show ? "true" : "false"); - updateRightRailVisibility(); -} - -function setFilesPaneVisible(show) { - state.filesPaneVisible = show; - const pane = $("#files-pane"); - if (!pane) return; - pane.hidden = !show; - const btn = $("#toggle-files-btn"); - if (btn) btn.setAttribute("aria-pressed", show ? "true" : "false"); - if (show) refreshFileTree(); - updateRightRailVisibility(); -} - -function updateRightRailVisibility() { - setRightRailVisible(state.planPaneVisible || state.filesPaneVisible); -} - -// --------------------------------------------------------------------------- -// File tree pane -// --------------------------------------------------------------------------- - -async function refreshFileTree() { - const hint = $("#file-tree-hint"); - const ul = $("#file-tree"); - try { - const data = await api("/api/project/tree"); - // Three distinct states surfaced by the backend: - // project_root_clamped=true → configured but invalid (silently fell back) - // project_root_set=false → not configured yet - // project_root_set=true → configured and honored (hide hint) - if (hint) { - if (data.project_root_clamped) { - hint.hidden = false; - hint.textContent = "This workspace's project root is invalid (outside the workspace directory). Update it in the workspace settings."; - } else if (data.project_root_set === false) { - hint.hidden = false; - hint.textContent = "No project root set for this workspace. Open the workspace settings to point it at a folder."; - } else { - hint.hidden = true; - } - } - renderFileTree(ul, data.entries || []); - } catch (e) { - if (ul) ul.innerHTML = ""; - if (hint) { - hint.hidden = false; - const status = e && e.status ? e.status : 0; - if (status === 404 || status === 403) { - // The endpoint exists but the workspace has no usable project_root — - // guide the user toward the workspace settings. - hint.textContent = "No project root set for this workspace. Open the workspace settings to point it at a folder."; - } else if (status >= 500) { - hint.textContent = `Couldn't load file tree (server error ${status}). Try again, or check the server logs.`; - } else if (status === 0) { - hint.textContent = "Couldn't reach the server. Check your network connection and try again."; - } else { - hint.textContent = `Couldn't load file tree (HTTP ${status}).`; - } - } - } -} - -function renderFileTree(ul, entries) { - ul.innerHTML = ""; - for (const entry of entries) { - const li = document.createElement("li"); - if (entry.type === "dir") { - li.innerHTML = `
    📁${escape(entry.name)}
      `; - const sub = li.querySelector("ul"); - const details = li.querySelector("details"); - details.addEventListener("toggle", async () => { - // Use a data-loaded flag so empty directories aren't refetched on - // every expand (children.length === 0 stays true for empty results). - // Only mark loaded on success so a transient failure stays retryable. - if (details.open && details.dataset.loaded !== "1") { - try { - const data = await api(`/api/project/tree?path=${encodeURIComponent(entry.path)}`); - renderFileTree(sub, data.entries || []); - details.dataset.loaded = "1"; - } catch (e) { /* silent — leave unloaded so next expand retries */ } - } - }); - } else { - li.innerHTML = `
      📄${escape(entry.name)}
      `; - li.querySelector(".file-tree-item").onclick = () => openProjectFile(entry.path); - li.querySelector(".file-tree-item").onkeydown = (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); // stop Space from scrolling the page - e.stopPropagation(); - openProjectFile(entry.path); - } - }; - } - ul.appendChild(li); - } -} - -async function openProjectFile(path) { - const ws = state.workspaces.find((w) => w.id === state.config?.active_workspace_id); - try { - // First fetch metadata only — for binary files we never display the bytes, - // so save bandwidth by skipping the second fetch. For text files do a - // second request with include_content=true to populate the preview. - const wsParam = `workspace_id=${encodeURIComponent(ws?.id || "")}`; - const meta = await api(`/api/project/file?${wsParam}&path=${encodeURIComponent(path)}`); - const name = path.split("/").pop() || path; - let body; - if (meta.is_binary) { - const sizeStr = meta.size != null ? `${meta.size} bytes` : "unknown size"; - const truncated = meta.truncated ? " (truncated)" : ""; - body = `

      Binary file (${sizeStr})${truncated} — preview not available.

      `; - } else { - const data = await api(`/api/project/file?${wsParam}&path=${encodeURIComponent(path)}&include_content=true`); - // The full read can decode differently than the 4 KB header sniff - // (e.g., a NUL byte later in the file). Re-check is_binary on the - // second response before rendering the content as text. - if (data.is_binary) { - const sizeStr = data.size != null ? `${data.size} bytes` : "unknown size"; - const truncated = data.truncated ? " (truncated)" : ""; - body = `

      Binary file (${sizeStr})${truncated} — preview not available.

      `; - } else { - const truncated = data.truncated - ? `

      File was truncated to the first 1 MB for preview.

      ` : ""; - body = `${truncated}
      ${escape(data.content || "")}
      `; - } - } - showDialog({ - title: name, - wide: true, - body, - actions: [{ label: "Close", action: "cancel" }], - }); - } catch (e) { - flash("Could not open file.", "warn"); - } -} - -// --------------------------------------------------------------------------- -// Approval dialog (shell + write) — with explain expander + trust session -// --------------------------------------------------------------------------- - -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); - }); -} - -// --------------------------------------------------------------------------- -// Send + SSE chat loop -// --------------------------------------------------------------------------- - -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") { - const human = friendlyError({ message: data.message || "" }, "running that step"); - const sysMsg = { role: "system-event", content: human }; - state.messages.push(sysMsg); - appendMessage(sysMsg); - 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; - } -} - -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); -} - -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); -} - -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 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(); - } - if (!dataStr) continue; - try { - await onEvent(eventName, JSON.parse(dataStr)); - } catch (e) { - console.error("SSE handler error", e); - } - } - } -} - -// --------------------------------------------------------------------------- -// File-request dialog -// --------------------------------------------------------------------------- - -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) -// --------------------------------------------------------------------------- - -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); -} - -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(); } - } -} - -function closeDialog() { - $("#dialog-root").innerHTML = ""; -} - -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); -} - -// --------------------------------------------------------------------------- -// Onboarding wizard -// --------------------------------------------------------------------------- - -async function checkOnboarding() { - const cfg = state.config; - if (cfg?.onboarding_done) return; - // Show wizard - const overlay = $("#onboarding-overlay"); - if (overlay) { - overlay.hidden = false; - overlay.addEventListener("keydown", trapFocus); - const firstFocusable = overlay.querySelector( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - ); - firstFocusable?.focus(); - } - // Load use-case templates - try { - const data = await api("/api/onboarding/templates"); - renderUseCaseGrid(data.templates || []); - } catch (e) { /* endpoint may not exist */ } -} - -function renderUseCaseGrid(templates) { - const grid = $("#use-case-grid"); - if (!grid) return; - grid.innerHTML = ""; - const icons = { grading: "📝", research: "🔬", "course-prep": "📚", writing: "✍️", coding: "💻" }; - for (const t of templates) { - const card = document.createElement("div"); - card.className = "use-case-card"; - card.setAttribute("role", "option"); - card.setAttribute("tabindex", "0"); - card.setAttribute("aria-selected", "false"); - card.dataset.id = t.id; - card.innerHTML = `${icons[t.id] || "📋"}${escape(t.name)}`; - card.onclick = () => { - grid.querySelectorAll(".use-case-card").forEach((c) => { - c.classList.remove("selected"); - c.setAttribute("aria-selected", "false"); - }); - card.classList.add("selected"); - card.setAttribute("aria-selected", "true"); - const btn = $("#ob-usecase-btn"); - if (btn) { btn.disabled = false; btn.dataset.useCase = t.id; } - }; - card.onkeydown = (e) => { - if (e.key === "Enter" || e.key === " ") { - // Space scrolls the page by default — stop that and let it select - // the card the same way Enter does (matches file-tree behavior). - e.preventDefault(); - e.stopPropagation(); - card.click(); - } - }; - grid.appendChild(card); - } -} - -async function onboardingConnect() { - const url = $("#ob-url")?.value.trim(); - const key = $("#ob-key")?.value.trim(); - if (!url || !key) { flash("Enter a URL and API key.", "warn"); return; } - const status = $("#ob-status"); - if (status) status.textContent = "Testing…"; - try { - const result = await api("/api/config", { method: "POST", json: { base_url: url, api_key: key } }); - if (!result.api_profile_label) { - if (status) { status.textContent = "Could not connect. Check the URL and key."; status.className = "status-line warn"; } - return; - } - state.config = result; - if (status) { status.textContent = `Connected via ${result.api_profile_label}.`; status.className = "status-line good"; } - await refreshModels(); - // Move to step 2 - setTimeout(() => { - $("#onboarding-step-1").hidden = true; - $("#onboarding-step-2").hidden = false; - }, 600); - } catch (e) { - if (status) { status.textContent = "Error: " + e.message; status.className = "status-line warn"; } - } -} - -async function onboardingComplete(useCaseId) { - try { - const data = await api("/api/onboarding/complete", { method: "POST", json: { template_id: useCaseId } }); - const msg = $("#ob-done-msg"); - if (msg) msg.textContent = `Your "${data.workspace_name || useCaseId}" workspace has been created. Click "Start chatting" to begin.`; - $("#onboarding-step-2").hidden = true; - $("#onboarding-step-3").hidden = false; - await loadConfig(); - await loadWorkspaces(); - await loadPrompts(); - await loadSkills(); - } catch (e) { - flash("Onboarding error: " + e.message, "warn"); - } -} - -function onboardingFinish() { - const overlay = $("#onboarding-overlay"); - if (overlay) { - overlay.hidden = true; - overlay.removeEventListener("keydown", trapFocus); - } - // Return focus to the composer so keyboard users can start typing immediately - $("#user-input")?.focus(); - flash("Welcome to BetterWebUI!", "good"); -} - -// --------------------------------------------------------------------------- -// Keyboard shortcuts -// --------------------------------------------------------------------------- - -let _gKeyPending = false; -let _gKeyTimer = null; - -let _shortcutPriorFocus = null; - -function openShortcutSheet() { - const sheet = $("#shortcut-sheet"); - if (!sheet || !sheet.hidden) return; - _shortcutPriorFocus = document.activeElement; - sheet.hidden = false; - sheet.addEventListener("keydown", trapFocus); - const firstFocusable = sheet.querySelector( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - ); - firstFocusable?.focus(); -} - -function closeShortcutSheet() { - const sheet = $("#shortcut-sheet"); - if (!sheet || sheet.hidden) return; - sheet.hidden = true; - sheet.removeEventListener("keydown", trapFocus); - _shortcutPriorFocus?.focus(); - _shortcutPriorFocus = null; -} - -function handleGlobalKey(e) { - // Don't intercept when typing in inputs — with two exceptions: - // - Ctrl/Cmd+Enter in textareas still sends the message - // - Escape always falls through so it can close approval dialogs, - // diff modals, and the file picker even when an input has focus - // (without this the Esc cancel/deny path becomes unreachable for - // keyboard users mid-form). - const tag = document.activeElement?.tagName?.toLowerCase(); - if (tag === "input" || tag === "textarea" || tag === "select") { - if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && tag === "textarea") { - e.preventDefault(); - send(); - return; - } - if (e.key !== "Escape") return; - } - - // Escape closes dialogs and modals - if (e.key === "Escape") { - // If a modal is awaiting user resolution (approval / file-pick / diff), cancel it - // explicitly so the pending Promise resolves and the backend isn't left waiting. - if (typeof state.pendingDialogCancel === "function") { - const cancel = state.pendingDialogCancel; - state.pendingDialogCancel = null; - try { cancel(); } catch (_) { /* ignore */ } - } else { - closeDialog(); - } - closeShortcutSheet(); - const diff = $("#diff-modal"); if (diff && !diff.hidden) diff.hidden = true; - const onboarding = $("#onboarding-overlay"); if (onboarding && !onboarding.hidden) onboarding.hidden = true; - // Return focus to the composer for keyboard users - const composer = $("#composer-input"); - if (composer) composer.focus(); - return; - } - - // ? toggles shortcut sheet - if (e.key === "?") { - const sheet = $("#shortcut-sheet"); - if (sheet?.hidden) openShortcutSheet(); else closeShortcutSheet(); - return; - } - - // N = new chat - if (e.key === "n" || e.key === "N") { newChat(); return; } - - // P = toggle plan pane - if (e.key === "p" || e.key === "P") { setPlanPaneVisible(!state.planPaneVisible); return; } - - // F = toggle files pane - if (e.key === "f" || e.key === "F") { setFilesPaneVisible(!state.filesPaneVisible); return; } - - // G-chord navigation - if (e.key === "g" || e.key === "G") { - _gKeyPending = true; - clearTimeout(_gKeyTimer); - _gKeyTimer = setTimeout(() => { _gKeyPending = false; }, 1000); - return; - } - if (_gKeyPending) { - _gKeyPending = false; - clearTimeout(_gKeyTimer); - const chordMap = { c: "chats", w: "workspaces", s: "skills", t: "tools", x: "settings", p: "prompts" }; - const target = chordMap[e.key.toLowerCase()]; - if (target) switchTab(target); - return; - } -} - -function switchTab(tabName) { - $$(".tab").forEach((b) => { - const active = b.dataset.tab === tabName; - b.classList.toggle("active", active); - b.setAttribute("aria-selected", active ? "true" : "false"); - }); - $$(".tab-panel").forEach((p) => p.classList.remove("active")); - const panel = $(`#tab-${tabName}`); - if (panel) panel.classList.add("active"); - // Lazy-load tab content the first time the user visits. - if (tabName === "files") renderBundleList().catch(() => {}); - if (tabName === "memory") renderMemoryList().catch(() => {}); - if (tabName === "scheduled") renderScheduledList().catch(() => {}); - if (tabName === "tools") renderCloudServices().catch(() => {}); -} - -// --------------------------------------------------------------------------- -// File bundles (IndexedDB-backed; mounted bundles ride per-message attachments) -// --------------------------------------------------------------------------- - -state.mountedBundleIds = state.mountedBundleIds || []; - -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.`; - } -} - -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(); -} - -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(", ")}`; -} - -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(); -} - -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. -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; - -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); - } -} - -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"); - } -} - -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; - } -} - -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"); - } -} - -// --------------------------------------------------------------------------- -// Scheduled tasks -// --------------------------------------------------------------------------- - -async function renderScheduledList() { - const ul = $("#scheduled-list"); - if (!ul) return; - let tasks; - try { - const data = await api("/api/scheduled-tasks"); - tasks = data.tasks || []; - } catch (e) { - ul.innerHTML = `
    • ${escape(friendlyError(e, "loading scheduled tasks"))}
    • `; - return; - } - if (!tasks.length) { - ul.innerHTML = '
    • No scheduled tasks. Click "+ Schedule a task" to create one.
    • '; - return; - } - ul.innerHTML = ""; - for (const t of tasks) { - const li = document.createElement("li"); - li.className = "scheduled-item"; - const nextStr = t.next_run_at - ? new Date(t.next_run_at * 1000).toLocaleString() - : "—"; - li.innerHTML = ` -
      - ${escape(t.name)} - ${t.enabled ? "On" : "Paused"} · next: ${escape(nextStr)} -
      -
      ${escape((t.prompt || "").slice(0, 200))}
      -
      - - -
      - `; - li.querySelector('[data-action="toggle"]').onclick = async () => { - await api("/api/scheduled-tasks", { method: "POST", json: { ...t, enabled: !t.enabled } }); - renderScheduledList(); - }; - li.querySelector('[data-action="delete"]').onclick = async () => { - if (!confirm(`Delete scheduled task "${t.name}"?`)) return; - await api(`/api/scheduled-tasks/${encodeURIComponent(t.id)}`, { method: "DELETE" }); - renderScheduledList(); - }; - ul.appendChild(li); - } -} - -function openNewScheduledDialog() { - showDialog({ - title: "Schedule a task", - body: ` - - - - -
      - - -
      - - `, - actions: [ - { label: "Cancel", role: "close" }, - { label: "Create", role: "primary", onClick: async () => { - const name = document.getElementById("sch-name").value.trim(); - const promptText = document.getElementById("sch-prompt").value.trim(); - const kind = document.getElementById("sch-kind").value; - if (!name || !promptText) { flash("Name and prompt are required.", "warn"); return; } - let schedule; - if (kind === "once") { - const at = document.getElementById("sch-at").value; - if (!at) { flash("Pick a date/time.", "warn"); return; } - schedule = { kind: "once", at_iso: new Date(at).toISOString() }; - } else if (kind === "interval") { - const every = Math.max(60, parseInt(document.getElementById("sch-every").value, 10) || 3600); - schedule = { kind: "interval", every_seconds: every }; - } else { - const t = document.getElementById("sch-time").value || "09:00"; - const [h, m] = t.split(":").map((x) => parseInt(x, 10)); - const days = []; - document.querySelectorAll(".day-chip input").forEach((cb) => { - if (cb.checked) days.push(parseInt(cb.dataset.d, 10)); - }); - schedule = { kind: "cron-lite", hour: h, minute: m, weekdays: days }; - } - await api("/api/scheduled-tasks", { - method: "POST", - json: { name, prompt: promptText, schedule, enabled: true, workspace_id: state.config?.active_workspace_id || "" }, - }); - closeDialog(); - renderScheduledList(); - flash(`Scheduled "${name}".`, "good"); - } }, - ], - }); - setTimeout(() => { - const kindSel = document.getElementById("sch-kind"); - const onChange = () => { - document.getElementById("sch-once-wrap").hidden = (kindSel.value !== "once"); - document.getElementById("sch-cron-wrap").hidden = (kindSel.value !== "cron-lite"); - document.getElementById("sch-interval-wrap").hidden = (kindSel.value !== "interval"); - }; - kindSel.addEventListener("change", onChange); - onChange(); - }, 50); -} - -// Poll for scheduled-task notifications every 30s. -async function pollScheduledNotifications() { - try { - const data = await api("/api/scheduled-tasks/notifications"); - for (const n of (data.notifications || [])) { - const icon = n.ok ? "✓" : "⚠"; - flash(`${icon} ${n.name}: ${(n.summary || "").slice(0, 200)}`, n.ok ? "good" : "warn"); - if ("Notification" in window && Notification.permission === "granted") { - try { new Notification(`BetterWebUI · ${n.name}`, { body: (n.summary || "").slice(0, 200) }); } - catch (_) {} - } - } - } catch (_) {} -} - -// --------------------------------------------------------------------------- -// Tabs and wiring -// --------------------------------------------------------------------------- - -function wireTabs() { - $$(".tab").forEach((btn) => - btn.addEventListener("click", () => { - $$(".tab").forEach((b) => { - b.classList.remove("active"); - b.setAttribute("aria-selected", "false"); - }); - $$(".tab-panel").forEach((p) => p.classList.remove("active")); - btn.classList.add("active"); - btn.setAttribute("aria-selected", "true"); - $(`#tab-${btn.dataset.tab}`).classList.add("active"); - }), - ); -} - -function wireEvents() { - // Core chat - $("#new-chat-btn").onclick = newChat; - $("#send-btn").onclick = send; - $("#composer-input").addEventListener("keydown", (e) => { - // Plain Enter in the textarea sends. Ctrl/Cmd+Enter is handled by - // handleGlobalKey() — if we also handled it here, send() would fire - // twice (once per listener). Don't duplicate that branch. - if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) { - e.preventDefault(); - send(); - } - }); - $("#composer-input").addEventListener("paste", async (e) => { - const items = e.clipboardData && e.clipboardData.items; - if (!items) return; - for (const it of items) { - if (it.kind === "file" && it.type.startsWith("image/")) { - e.preventDefault(); - const f = it.getAsFile(); - if (!f) continue; - const named = new File([f], f.name || `pasted-${Date.now()}.png`, { type: f.type }); - await attachFile(named); - const vis = $("#toggle-vision"); if (vis) vis.checked = true; - flash("Pasted image attached.", "good"); - return; - } - } - }); - $("#attach-input").addEventListener("change", (e) => { - const f = e.target.files[0]; - if (f) { - attachFile(f); - if (f.type && f.type.startsWith("image/")) { - const vis = $("#toggle-vision"); if (vis) vis.checked = true; - } - } - e.target.value = ""; - }); - - // Screenshot capture (Chromium / Firefox; hidden on Safari which lacks getDisplayMedia) - const screenshotBtn = $("#screenshot-btn"); - if (screenshotBtn) { - if (navigator.mediaDevices && typeof navigator.mediaDevices.getDisplayMedia === "function") { - screenshotBtn.hidden = false; - screenshotBtn.addEventListener("click", captureScreenshot); - } else { - screenshotBtn.hidden = true; - } - } - - // Mic - $("#mic-btn")?.addEventListener("click", toggleMic); - - // Settings - $("#save-connection").onclick = saveConnection; - $("#save-defaults").onclick = saveDefaults; - $("#save-display")?.addEventListener("click", saveDisplay); - $("#save-websearch")?.addEventListener("click", saveWebSearch); - $("#save-verification")?.addEventListener("click", saveVerification); - $("#cfg-websearch-provider")?.addEventListener("change", (e) => { - const wrap = $("#cfg-websearch-custom-wrap"); - if (wrap) wrap.hidden = (e.target.value !== "custom"); - }); - - // Services enable/disable - ["clk", "autogui", "osso"].forEach((name) => { - $(`#svc-${name}-enabled`)?.addEventListener("change", (e) => { - toggleService(name, e.target.checked); - }); - }); - - // Prompts - $("#new-prompt-btn").onclick = () => openPromptDialog(null); - - // Skills - $("#new-skill-btn").onclick = openNewSkillDialog; - $("#upload-skill").addEventListener("change", (e) => { - const f = e.target.files[0]; - if (f) uploadSkill(f); - e.target.value = ""; - }); - - // File bundles, memories, scheduled tasks - $("#new-bundle-btn")?.addEventListener("click", openNewBundleDialog); - $("#new-memory-btn")?.addEventListener("click", addMemoryFromPrompt); - $("#memory-pause-toggle")?.addEventListener("change", (e) => { - state.memoryPaused = !!e.target.checked; - }); - $("#new-scheduled-btn")?.addEventListener("click", openNewScheduledDialog); - $("#memory-bell")?.addEventListener("click", () => switchTab("memory")); - - // Workspaces - $("#new-workspace-btn").onclick = () => openWorkspaceDialog(null); - $("#workspace-select").onchange = (e) => activateWorkspace(e.target.value); - $("#import-workspace-btn")?.addEventListener("click", () => { - const input = document.createElement("input"); - input.type = "file"; - input.accept = ".bwui"; - input.onchange = (e) => { if (e.target.files[0]) importWorkspace(e.target.files[0]); }; - input.click(); - }); - - // MCP / CLI - $("#mcp-from-registry-btn").onclick = openMcpRegistryDialog; - $("#mcp-custom-btn").onclick = openMcpCustomDialog; - $("#cli-from-registry-btn").onclick = openCliRegistryDialog; - $("#cli-custom-btn").onclick = openCliCustomDialog; - - // Right-rail toggles - $("#toggle-plan-btn")?.addEventListener("click", () => setPlanPaneVisible(!state.planPaneVisible)); - $("#toggle-files-btn")?.addEventListener("click", () => setFilesPaneVisible(!state.filesPaneVisible)); - $("#plan-pane-close")?.addEventListener("click", () => setPlanPaneVisible(false)); - $("#files-pane-close")?.addEventListener("click", () => setFilesPaneVisible(false)); - - // Conversation search (debounced, uses server-side full-text search) - let _searchTimer = null; - $("#search-toggle-btn")?.addEventListener("click", () => { - const wrap = $("#conv-search-wrap"); - if (!wrap) return; - wrap.hidden = !wrap.hidden; - if (!wrap.hidden) $("#conv-search")?.focus(); - else { - state.convSearchQuery = ""; - state.convSearchResults = null; - renderConversationList(); - } - }); - $("#conv-search")?.addEventListener("input", (e) => { - const q = e.target.value; - state.convSearchQuery = q; - clearTimeout(_searchTimer); - if (!q.trim()) { - state.convSearchResults = null; - renderConversationList(); - return; - } - _searchTimer = setTimeout(async () => { - try { - const data = await api(`/api/conversations/search?q=${encodeURIComponent(q)}`); - state.convSearchResults = data.results || []; - renderConversationList(); - } catch (_) {} - }, 250); - }); - - // Mode select — persist per-workspace when a workspace is active, otherwise globally - $("#mode-select")?.addEventListener("change", async (e) => { - const mode = e.target.value; - const activeWsId = state.config?.active_workspace_id; - try { - if (activeWsId) { - const ws = await api(`/api/workspaces/${activeWsId}`); - await api("/api/workspaces", { method: "POST", json: { ...ws, mode } }); - } else { - await api("/api/config", { method: "POST", json: { chat_mode: mode } }); - } - } catch (_) { /* non-critical */ } - }); - - // Keyboard shortcuts modal - $("#shortcut-help-btn")?.addEventListener("click", openShortcutSheet); - $("#shortcut-sheet")?.querySelector(".modal-close")?.addEventListener("click", closeShortcutSheet); - - // Global keyboard shortcuts - document.addEventListener("keydown", handleGlobalKey); - - // Onboarding wizard buttons - $("#ob-connect-btn")?.addEventListener("click", onboardingConnect); - $("#ob-back-btn")?.addEventListener("click", () => { - $("#onboarding-step-2").hidden = true; - $("#onboarding-step-1").hidden = false; - }); - $("#ob-usecase-btn")?.addEventListener("click", (e) => { - const id = e.target.dataset.useCase; - if (id) onboardingComplete(id); - }); - $("#ob-finish-btn")?.addEventListener("click", onboardingFinish); -} - -// --------------------------------------------------------------------------- -// Init -// --------------------------------------------------------------------------- - -async function init() { - wireTabs(); - wireEvents(); - initMic(); - _initAnnotationModal(); - // Load workspaces before config so loadConfig's #mode-select lookup can - // see the active workspace's stored mode on first paint; otherwise the - // select would fall back to config.chat_mode until the next config - // refresh. - await loadWorkspaces(); - await loadConfig(); - await Promise.all([ - refreshModels(), - loadPrompts(), - loadSkills(), - loadMcp(), - loadCli(), - loadConversations(), - ]); - populateWorkspaceSelect(); - newChat(); - // Memory bell + scheduled notification poll - updateMemoryBell().catch(() => {}); - setInterval(() => { try { pollScheduledNotifications(); } catch (_) {} }, 30000); - pollScheduledNotifications(); - // Request notification permission once, non-blocking. - if ("Notification" in window && Notification.permission === "default") { - setTimeout(() => { try { Notification.requestPermission(); } catch (_) {} }, 3000); - } - // Check if onboarding is needed - await checkOnboarding(); -} - -init().catch((e) => { - document.body.innerHTML = `
      Init failed: ${escape(e.message)}\n\n${escape(e.stack || "")}
      `; -}); +// static/app.js — retired entry point (Phase 3). +// +// The client was decomposed into native ES modules under /static/js/ +// (entry: /static/js/main.js), which index.html now loads via +// + + - + + 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