commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 Author: pewdiepie-archdaemon Date: Sun May 31 23:58:26 2026 +0900 Odysseus v1.0 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..97f8580 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +venv/ +.venv/ +node_modules/ +services/node_modules/ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +dist/ +build/ +.env +/data/ +/logs/ +.git/ +.claude/ +.playwright-mcp/ +.pytest_cache/ +.vscode/ +.idea/ +dev-docs/ +*.db +*.sqlite +*.sqlite3 +/reports/ +/research_data/ +tasks/ +_scratch/ +compound.config.json +search_analytics.json +**/search_analytics.json +tests/ +api/ +*.log +*.error.log +chart.png +timetree*.png +*_signin_page.png +*_calendar_view.png +.gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..294f28f --- /dev/null +++ b/.env.example @@ -0,0 +1,102 @@ +# Odysseus UI — Environment Configuration +# Copy this file to .env and fill in your values. + +# ============================================================ +# LLM Configuration +# ============================================================ + +# Primary LLM host (default: localhost) +LLM_HOST=localhost + +# Additional LLM hosts, comma-separated (for model discovery) +# LLM_HOSTS=llm-host.local:8000,backup-llm.local:8001 + +# OpenAI API key (only needed if using OpenAI models). +# Do not commit real keys. Keep this commented until needed. +# OPENAI_API_KEY=your_openai_api_key_here + +# Research service LLM endpoint +# RESEARCH_LLM_ENDPOINT=http://localhost:8000/v1/chat/completions + +# ============================================================ +# Search & Web +# ============================================================ + +# SearXNG instance URL (self-hosted, for web search). +# Docker Compose overrides this to http://searxng:8080 for in-network access. +SEARXNG_INSTANCE=http://localhost:8080 + +# ============================================================ +# Database +# ============================================================ + +# SQLite database path (default: sqlite:///./data/app.db) +# DATABASE_URL=sqlite:///./data/app.db + +# ============================================================ +# Auth & Security +# ============================================================ + +# Enable authentication (default: true) +# AUTH_ENABLED=true + +# Development-only auth bypass for loopback requests. +# Keep false for Docker, LAN, reverse proxy, and any shared deployment. +# LOCALHOST_BYPASS=false + +# Optional: pre-seed the first admin password during setup. +# Do not commit a real password. +# ODYSSEUS_ADMIN_PASSWORD=change_me_before_first_boot + +# CORS allowed origins (default: localhost-only; restrict to your public origin in production) +# ALLOWED_ORIGINS=http://localhost:7000,http://localhost:8000 + +# ============================================================ +# ChromaDB (vector store) +# ============================================================ + +# ChromaDB service host. +# Manual host run: localhost:8100 when using `docker run -p 8100:8000 chromadb/chroma`. +# Docker Compose overrides these to chromadb:8000 for in-network access. +# CHROMADB_HOST=localhost +# CHROMADB_PORT=8100 + +# ============================================================ +# RAG / Embeddings +# ============================================================ + +# Embedding API endpoint (OpenAI-compatible /v1/embeddings) +# Default: http://{LLM_HOST}:11434/v1/embeddings (ollama) +# EMBEDDING_URL=http://localhost:11434/v1/embeddings + +# Embedding model name (must be available at the endpoint above) +# EMBEDDING_MODEL=all-minilm:l6-v2 + +# Local fallback embedding model (used when no HTTP embedding API is available) +# Uses fastembed (ONNX) — downloads model on first run (~50MB) +# FASTEMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2 +# FASTEMBED_CACHE_PATH= # defaults to ~/.cache/fastembed + +# ============================================================ +# Misc +# ============================================================ + +# Cleanup interval in hours (default: 24) +# CLEANUP_INTERVAL_HOURS=24 + +# In-process email pollers (default: on). Set to 0 if you're driving +# polling from cron / systemd via `scripts/odysseus-mail poll-scheduled` +# and `scripts/odysseus-mail poll-summary`, otherwise both schedulers +# race on the same SQLite. +# ODYSSEUS_INPROCESS_POLLERS=1 + +# In-process scheduled-task runner (default: on). Set to 0 to let an +# external driver fire scheduled tasks. Calendar reminders are +# frontend-driven (polling /api/notes from the browser) so no gate is +# needed there. +# ODYSSEUS_INPROCESS_TASKS=1 + +# Host used by the built-in "run_script" scheduled-task action. +# Empty/local/localhost runs scripts on the app host. Set to an SSH host alias +# if you intentionally want scheduled scripts to run remotely. +# ODYSSEUS_SCRIPT_HOST=localhost diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3349982 --- /dev/null +++ b/.gitignore @@ -0,0 +1,83 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +dist/ +build/ +!static/js/editor/build/ +venv/ +.venv/ +*.egg + +# Environment +.env +!.env.example + +# Data — all user data stays local +data/ +!services/hwfit/data/ +!services/hwfit/data/hf_models.json +logs/ +*.log +*.db +*.sqlite +*.sqlite3 + +# Node +node_modules/ +services/node_modules/ +services/data/ + +# IDE / Editor +.aider* +.claude/ +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Test capture artifacts (browser session dumps may contain personal content) +.playwright-mcp/ + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +*.cache +cache/ +output.txt.txt + +# Media (uploaded/generated) +*.jpg +*.jpeg +*.png +*.gif +*.bmp +*.webp +*.tiff +*.pdf + +# …except shipped demo assets in docs/ that the README links to. +!docs/*.jpg +!docs/*.jpeg +!docs/*.png +!docs/*.gif +!docs/*.webp + +# Reports and temp files +reports/ +tasks/ +scripts/compound/*.json +research_data/ +**/search_analytics.json + +# Internal dev/review notes — not for public repo +dev-docs/ + +# Local config +compound.config.json +*.error.log +_scratch/ diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md new file mode 100644 index 0000000..c4079e6 --- /dev/null +++ b/ACKNOWLEDGMENTS.md @@ -0,0 +1,168 @@ +# Acknowledgments + +Odysseus stands on the shoulders of a lot of open-source work. This file +credits the projects whose code, assets, or designs are included in or +adapted by this repository, and notes their licenses. + +If you believe something here is mis-attributed or missing, please open an +issue — it will be corrected promptly. + +--- + +## Adapted / borrowed code + +Portions of this project were adapted from other open-source repositories. +Their original authors retain copyright over the adapted portions, under the +licenses noted below. + +The sources below are under permissive licenses (MIT / Apache-2.0), which permit +this use as long as their original copyright and license notices are preserved. +The full license texts are kept in [`licenses/`](licenses/). + +- **[opencode](https://github.com/anomalyco/opencode)** — open-source AI coding + agent (originally [opencode-ai/opencode](https://github.com/opencode-ai/opencode), + archived Sep 2025; now maintained at `anomalyco/opencode`). Copyright © the + opencode authors. **MIT License.** Adapted for agent-loop / tool-execution + patterns and UI concepts. +- **[llmfit](https://github.com/AlexsJones/llmfit)** by **Alex Jones** — the + engine behind the Cookbook's model download / serve / "What Fits?" feature. + Copyright © Alex Jones. **MIT License.** Adapted in `services/hwfit/` + (hardware detection, quant-aware fit scoring, model catalog), + `routes/cookbook_*.py`, `routes/hwfit_routes.py`, `static/js/cookbook*.js`, + and `scripts/odysseus-cookbook`. +- **[Tongyi DeepResearch](https://github.com/Alibaba-NLP/DeepResearch)** by + **Alibaba-NLP / Tongyi Lab** — the multi-step deep-research agent pipeline. + Copyright © Alibaba-NLP / Tongyi Lab. **Apache-2.0.** Adapted for Odysseus's + Deep Research feature (`api/research_*.py`, `routes/research_routes.py`, + `services/search/`). Full text in + [`licenses/DeepResearch-Apache-2.0.txt`](licenses/DeepResearch-Apache-2.0.txt). + +--- + +## Bundled via Docker Compose + +These services are pulled as images by the project's `docker-compose.yml` +and run alongside Odysseus on `docker compose up`. They are not modified — +just composed. + +| Service | Image | Purpose | License | +|---|---|---|---| +| [SearXNG](https://github.com/searxng/searxng) | `searxng/searxng:latest` | Default metasearch backend | AGPL-3.0 | +| [ChromaDB](https://github.com/chroma-core/chroma) | `chromadb/chroma:latest` | Vector store for memory / RAG | Apache-2.0 | +| [ntfy](https://github.com/binwiederhier/ntfy) | `binwiederhier/ntfy` | Push notifications (self-hosted reminders) | Apache-2.0 / GPL-2.0 | + +## Bundled front-end libraries + +Vendored in `static/lib/` and served directly: + +| Library | Purpose | License | +|---|---|---| +| [highlight.js](https://github.com/highlightjs/highlight.js) v11.9.0 | Code syntax highlighting | BSD-3-Clause | +| [SheetJS / xlsx](https://github.com/SheetJS/sheetjs) (`xlsx.full.min.js`) | Spreadsheet (`.xlsx`) read/write | Apache-2.0 | +| [docx](https://github.com/dolanmiu/docx) (`docx.umd.min.js`) | Generate `.docx` documents | MIT | +| [mammoth.js](https://github.com/mwilliamson/mammoth.js) | Convert `.docx` → HTML | BSD-2-Clause | +| [html2pdf.js](https://github.com/eKoopmans/html2pdf.js) | HTML → PDF export (bundles jsPDF + html2canvas) | MIT | +| [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT | +| [html2canvas](https://github.com/niklasvh/html2canvas) (bundled in html2pdf) | DOM → canvas rasterization | MIT | +| [node-qrcode](https://github.com/soldair/node-qrcode) (`qrcode.min.js`) | QR-code rendering (2FA setup) | MIT | + +## Front-end libraries loaded at runtime (CDN) + +Referenced from `cdn.jsdelivr.net` / `cdnjs.cloudflare.com` at runtime — not vendored: + +| Library | Purpose | License | +|---|---|---| +| [KaTeX](https://github.com/KaTeX/KaTeX) 0.16.22 | Math typesetting | MIT | +| [Mermaid](https://github.com/mermaid-js/mermaid) 11 | Diagrams from text | MIT | +| [Pyodide](https://github.com/pyodide/pyodide) 0.27.5 | In-browser Python runtime | MPL-2.0 | +| [PDFObject](https://github.com/pipwerks/PDFObject) 2.1.1 | Inline PDF embedding | MIT | + +## Fonts + +Bundled in `static/fonts/`: + +| Font | License | Author | +|---|---|---| +| [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors | +| [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson | +| [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois | + +## Python dependencies + +Core (`requirements.txt`) and optional (`requirements-optional.txt`): + +| Package | License | +|---|---| +| FastAPI | MIT | +| Uvicorn | BSD-3-Clause | +| python-multipart | Apache-2.0 | +| python-dotenv | BSD-3-Clause | +| HTTPX | BSD-3-Clause | +| Pydantic / pydantic-settings | MIT | +| SQLAlchemy | MIT | +| pypdf | BSD-3-Clause | +| BeautifulSoup4 | MIT | +| charset-normalizer | MIT | +| NumPy | BSD-3-Clause | +| ChromaDB (chromadb-client) | Apache-2.0 | +| fastembed | Apache-2.0 | +| youtube-transcript-api | MIT | +| markdown | BSD-3-Clause | +| icalendar | BSD-2-Clause | +| caldav | GPL-3.0-or-later OR Apache-2.0 (dual; used under Apache-2.0) | +| cryptography | Apache-2.0 / BSD-3-Clause | +| bcrypt | Apache-2.0 | +| MCP (Model Context Protocol SDK) | MIT | +| pyotp | MIT | +| qrcode\[pil] | BSD-3-Clause | +| croniter | MIT | +| pytest / pytest-asyncio | MIT / Apache-2.0 | +| duckduckgo-search (optional) | MIT | +| **PyMuPDF** *(optional — form-filling only)* | **AGPL-3.0** — see note below | + +## Companion services (interoperated with, not bundled) + +Odysseus talks to these over the network/API. They are **not** distributed +with this project; their licenses do not bind this codebase, but they deserve +credit: + +- [Ollama](https://github.com/ollama/ollama) — local model serving (MIT) +- [Radicale](https://github.com/Kozea/Radicale) — CardDAV/CalDAV server (GPL-3.0) +- [Dovecot](https://www.dovecot.org/) — IMAP server +- [isync / mbsync](https://isync.sourceforge.io/) — IMAP mailbox sync (GPL-2.0) +- [tmux](https://github.com/tmux/tmux) — terminal multiplexer; Cookbook shells out to it on Linux/macOS for background model downloads and serves (ISC) +- [OpenSSH](https://www.openssh.com/) (`ssh`, `ssh-keygen`, `ssh-copy-id`) — Cookbook shells out to it to manage remote model servers and provision keys (BSD-style permissive) +- Model/API providers: Anthropic, OpenAI, Google (Gemini), DuckDuckGo + +--- + +### License-compatibility notes (for the repo's own LICENSE choice) + +The **core ships fully permissive** (MIT-compatible), so the two copyleft +concerns from earlier are resolved: + +- **PDF text extraction** now uses **`pypdf`** (BSD-3-Clause) and **encoding + detection** uses **`charset-normalizer`** (MIT). chardet (LGPL-2.1) has been + removed entirely. +- **PyMuPDF (AGPL-3.0)** is no longer a core dependency. It is **optional** and + used *only* by the PDF form-filling feature (`src/pdf_forms.py` and the form + endpoints in `routes/document_routes.py`), lazy-imported and listed in + `requirements-optional.txt`. The MIT core runs without it. If you choose to + install it, AGPL's network clause then applies to *that feature* for your + deployment (Artifex also sells a commercial PyMuPDF license that lifts this). +- **`caldav`** (Python lib) is **dual-licensed GPL-3.0-or-later OR Apache-2.0**. + Odysseus uses it under **Apache-2.0**, which is permissive and MIT-compatible. + +--- + +## Thanks to + +Most of Odysseus's code was written *with* AI models, not just by a human. +The project would not exist without them — credit where credit is due: + +- **gpt-oss-120b** — the legend that kicked this project off. +- **Qwen3-235B** +- **DeepSeek V3.1 · DeepSeek V4 Pro · DeepSeek V4 Flash** +- **Claude** (Anthropic) +- **Codex** (OpenAI) +- Friends, for helping me debug. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ab08291 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +FROM python:3.12-slim + +# System deps. tmux is required by Cookbook for background downloads/serves. +# openssh-client is required for Cookbook remote server tests, setup, probes, +# downloads, and serves from Docker installs. +# git/cmake are required when Cookbook builds llama.cpp on first llama.cpp +# launch inside Docker. +# nodejs/npm provide npx for the optional built-in Browser MCP server. +# gosu lets the entrypoint drop privileges cleanly so signals still reach +# uvicorn directly (no extra shell layer like `su`/`sudo` would add). +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + curl \ + git \ + nodejs \ + npm \ + tmux \ + openssh-client \ + gosu \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install Python deps first (layer cache) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy app code +COPY . . + +# Create data directory (mount a volume here for persistence) +RUN mkdir -p data logs + +# Entrypoint that drops to PUID/PGID (default 1000:1000) and repairs +# ownership on the bind-mounted /app/data and /app/logs. Without this, +# the container runs as root and writes root-owned files into host +# bind mounts — any later non-root run (or a host user trying to +# update them) silently fails on EPERM, breaking skill extraction, +# prefs persistence, mail attachments, etc. +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +EXPOSE 7000 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7000"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7087e2d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Odysseus Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c680a08 --- /dev/null +++ b/README.md @@ -0,0 +1,217 @@ +# Odysseus +─────────────────────────────────────────────── + ⊹ ࣪ ˖ ૮( ˶ᵔ ᵕ ᵔ˶ )っ Odysseus vers. 1.0 +─────────────────────────────────────────────── + +![Odysseus](docs/odysseus.jpg) + +A self-hosted AI workspace -- meant to be the self-hosted version of the UI experience you get from ChatGPT and Claude. But with more jank and fun. Running on your own hardware, with your own data -- local-first, privacy-first, and no trojan. + +> Fun fact: a chunk of Odysseus was built **from a phone** -- mobile shells (Termux), the PWA install, and on-device agents. So "works on mobile" isn't an afterthought, it's where a lot of it actually happened. + +## Features + - **Chat** -- chat with any local model or API; adding them is super simple.
 vLLM · llama.cpp · Ollama · OpenRouter · OpenAI + - **Agent** -- hand it tools and let it run the whole task itself.
 built on [opencode](https://github.com/anomalyco/opencode) · MCP · web · files · shell · skills · memory + - **Cookbook** -- Scans your hardware, recommends models, click to download and serve.. easy!
 built on [llmfit](https://github.com/AlexsJones/llmfit) · VRAM-aware · GGUF / FP8 / AWQ · fit scoring · vLLM / llama.cpp serving + - **Deep Research** -- multi-step runs that gather, read, and synthesize sources into a nice visual report.
 adapted from [Tongyi DeepResearch](https://github.com/Alibaba-NLP/DeepResearch) + - **Compare** -- a fun tool to compare models side by side. Test completely blind, no bias!
 multi-model · blind test · synthesis + - **Documents** -- YOU write the text, AI is there to assist, not the opposite.
 multi-tab editor · markdown · HTML · CSV · syntax highlighting · AI edits · suggestions + - **Memory / Skills** -- Persistent memory and skills, your agent evolves over time as it better understands you and your tasks!
 ChromaDB · fastembed (ONNX) · vector + keyword retrieval · import/export + - **Email** -- IMAP/SMTP inbox with AI triage built in: urgency reminders, auto-tag, auto-summary, auto-reply drafts, auto-spam.
 IMAP · SMTP · per-account routing · CalDAV-aware + - **Notes & Tasks** -- Quick notes with reminders, a todo list, and scheduled tasks the agent can act on.
 note pings · checklist · cron-style tasks · ntfy / browser / email channels + - **Calendar** -- Local-first calendar with CalDAV sync to Radicale / Nextcloud / Apple / Fastmail.
 CalDAV pull · .ics import/export · per-calendar colors · agent-aware + - **Works on mobile** -- looks and runs great on your phone, not just desktop.
 responsive · installable (PWA) · touch gestures + - **Extras** -- more to explore, happy if you give it a go!
 image editor · theme editor · file uploads (vision + PDF) · web search · presets · sessions · 2FA + +## Demo +A full, hover-to-play tour lives on the landing page (`docs/index.html`). A few looks: + +### Chat & Agents +![Chat & Agents](docs/chat.gif) +### Deep Research +![Deep Research](docs/research.gif) +### Compare +![Compare](docs/compare.gif) +### Documents +![Documents](docs/document.gif) +### Notes & Tasks +![Notes & Tasks](docs/notes.gif) + +## Quick Start + +Defaults work out of the box — clone, run, configure inside the app. +Open the **Settings** panel after first login to point Odysseus at your LLM +server, search provider, email account, etc. Only touch `.env` if you need +to override deployment-level things like `AUTH_ENABLED`, `DATABASE_URL`, +or pre-seed `ODYSSEUS_ADMIN_PASSWORD` (otherwise an initial password is +generated and printed on first boot). + +### Option 1: Docker (recommended) +```bash +git clone +cd odysseus +cp .env.example .env # optional, but recommended for explicit defaults +docker compose up -d --build +``` +Compose starts Odysseus, ChromaDB, SearXNG, and ntfy. First run does a full +image build. Open `http://localhost:7000` after the containers are healthy. + +Cookbook remote servers use an Odysseus-owned SSH key from `./data/ssh` +inside Docker. In **Cookbook -> Settings -> Servers**, generate/copy the +public key and add it to the remote server's `~/.ssh/authorized_keys`. +After generating the key, you can also install it from the host with: +```bash +ssh-copy-id -i data/ssh/id_ed25519.pub user@server +``` +Cookbook local downloads are stored in `./data/huggingface`, mounted as +`~/.cache/huggingface` inside the Odysseus container. + +Useful checks: +```bash +docker compose ps +docker compose logs --tail=120 odysseus +docker compose logs odysseus | grep -E 'ChromaDB|MemoryVectorStore|DEGRADED' +docker compose exec odysseus python -c "from services.hwfit.models import get_models; print(len(get_models()))" +``` + +Expected vector-memory startup lines in Docker: +```text +ChromaDB connected: chromadb:8000 +MemoryVectorStore initialized +``` + +The Cookbook model catalog check should print a non-zero count. If it prints +`0`, rebuild the Odysseus image with `docker compose build --no-cache odysseus`. + +### Option 2: Manual install — Linux / macOS +**Requirements:** Python 3.11+. On Linux/Termux, Cookbook also requires `tmux` +for background model downloads and serves. + +Install system packages first: +```bash +# Debian/Ubuntu +sudo apt install tmux + +# Arch +sudo pacman -S tmux + +# Fedora +sudo dnf install tmux +``` + +Then install Odysseus: +```bash +git clone +cd odysseus +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +python setup.py # creates data dirs and prints an initial admin password +uvicorn app:app --host 0.0.0.0 --port 7000 +``` + +### Option 3: Manual install — Windows (PowerShell) +```powershell +git clone +cd odysseus +python -m venv venv +venv\Scripts\Activate.ps1 +pip install -r requirements.txt +python setup.py +uvicorn app:app --host 0.0.0.0 --port 7000 +``` + +Open `http://localhost:7000`, log in with the generated admin password, +and configure everything else inside **Settings**. + +## Security Notes +Odysseus is a self-hosted workspace with powerful local tools: shell access, file uploads, model downloads, web research, email/calendar integrations, and API tokens. Treat it like an admin console. + +- Keep `AUTH_ENABLED=true` for any network-accessible deployment. +- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy. +- Keep `data/`, `.env`, logs, databases, and uploaded/generated media out of Git. They are ignored by default. +- Review `data/auth.json` after first boot: disable open signup unless you intentionally want it, make only your own account admin, and keep demo/test accounts non-admin. +- Non-admin users do not get shell/Python/file read/write by default, and admin-only routes/tools such as MCP management, API tokens, webhooks, model/cookbook serving, backup/vault, and app settings are admin-gated. Other features are controlled by per-user privileges, so review each user's privileges before exposing a deployment. +- Rotate any API keys or tokens that were ever pasted into a shared chat, demo, screenshot, or log. +- If you enable API tokens or webhooks, create separate tokens per integration and delete unused ones. +- Prefer binding manual development runs to `127.0.0.1`; bind to `0.0.0.0` only when you intentionally want LAN/reverse-proxy access. +- Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged. + +### Putting it behind HTTPS +Odysseus serves plain HTTP on its port. That's fine for `localhost` and trusted LAN/VPN use, but browsers will warn ("Password fields present on an insecure page") and the login + API tokens travel in cleartext. For anything reachable outside your machine — including a Tailscale IP shared with other devices — put a TLS-terminating reverse proxy in front. + +Shortest path with [Caddy](https://caddyserver.com/) (auto-renews Let's Encrypt certs): + +```caddy +odysseus.example.com { + reverse_proxy localhost:7000 +} +``` + +For a LAN-only Tailscale deployment, Caddy + [tailscale-cert](https://caddyserver.com/docs/caddyfile/options#auto-https) or the built-in MagicDNS HTTPS feature both work. nginx/Traefik configs are similar — proxy `localhost:7000`, terminate TLS at the proxy. Once that's in place, the browser warning goes away and your login is encrypted. + +## Contributing +Help is welcome. The best entry points are fresh-install testing, provider setup +bugs, mobile/editor polish, docs, and small focused refactors. See +[ROADMAP.md](ROADMAP.md) for the current help-wanted list. + +## Configuration +Most setup is done inside the app with `/setup` or **Settings**. Use `.env` +for deployment-level defaults and secrets you want present before first boot. +Key settings: + +| Variable | Default | Description | +|---|---|---| +| `LLM_HOST` | `localhost` | Your LLM server (e.g. `llm-host.local:8000`) | +| `LLM_HOSTS` | -- | Comma-separated list for model discovery | +| `OPENAI_API_KEY` | -- | Optional OpenAI key. Prefer adding providers in the app unless pre-seeding. | +| `SEARXNG_INSTANCE` | `http://localhost:8080` | SearXNG URL. Docker overrides this to `http://searxng:8080`. | +| `AUTH_ENABLED` | `true` | Enable/disable login | +| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. | +| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string | +| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. | +| `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. | +| `EMBEDDING_URL` | -- | OpenAI-compatible embeddings endpoint | + +### Bundled services +Docker Compose includes these by default: + + - **ChromaDB** → vector store for semantic memory. In Docker, Odysseus connects to `chromadb:8000`; from the host it is exposed as `localhost:8100`. + - **SearXNG** → meta search for web search. In Docker, Odysseus connects to `searxng:8080`; from the host it is exposed only on `127.0.0.1:8080`. + - **ntfy** → local notification service, exposed as `localhost:8091`. + +### Optional external services + - **Ollama** → local LLM server -- [ollama.ai](https://ollama.ai) + +## Architecture +``` +app.py # FastAPI entry point +core/ auth, database, middleware, constants +src/ llm_core, agent_loop, agent_tools, chat_processor, search/ +routes/ chat, session, document, memory, model … endpoints +services/ docs, memory, search, hwfit (Cookbook) … +static/ index.html + app.js + style.css + js/ (modular front-end) +docs/ landing page (index.html) + preview clips +``` + +## Data +All user data lives in `data/` (gitignored): `app.db` (sessions, messages, documents), +`memory.json`, `presets.json`, `uploads/`, `personal_docs/`, `chroma/`, `settings.json`. + +## License +MIT -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md). + +``` + | + ||| + ||||| + | | | ||||||| + )_) )_) )_) ~|~ + )___))___))___)\ | + )____)____)_____)\\| + _____|____|____|_____\\\__ + \ / + ~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~ + ~^~ all aboard! ~^~ + ~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~ +``` diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..aa79c30 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,45 @@ +# Roadmap / Help Wanted + +Odysseus is on a voyage, but not home yet. It works great for me (lol), but this is ship is moving fast and feedback/help would be appreciated! (I dont know what I'm doing hlep). + +If you see weird CSS, strange layout behavior, or a suspiciously murky corner of +the codebase, you are probably right to stay away. + +## High Priority + +- SQUASH BUGS +- Fresh Docker install smoke tests on Linux, macOS, and Windows!! + +- Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden. +- Self-host troubleshooting cookbook. Document the weird 30-second fixes that otherwise become 30-minute searches: Dovecot cleartext auth for local stacks, ntfy Android Instant Delivery for non-ntfy.sh servers, clipboard limits on plain-HTTP Tailscale URLs, Radicale collection URLs, and similar traps. +- Cookbook reliability on other computers. This is probably the area most likely to need work across different machines, GPUs, drivers, shells, and Python environments. +- Tile/window management correctness. I had to brute force my way a bit here, I'm aware, popups, dropdowns, and fixed-position UI inside transformed modals can land in the wrong place. +- Esc button, it's small but a lot of windows that arent still close on esc and alot of them doesnt. +- Skill audit, how does your model respond to skill injection, does it follow? Does its parsing miss? +- Better degraded-state reporting for ChromaDB, SearXNG, email, ntfy, and provider probes. +- Provider setup/probing audit for Anthropic, Gemini, Groq, xAI, OpenRouter, OpenAI, and DeepSeek. + +## Refactor Targets +- CSS cleanup. `static/style.css` basically Calypso's island atm. +- Tour core helper. The onboarding tours have too much copy-pasted scaffolding; promote a shared `tour-core.js` helper before adding more tours. +- Mobile media override discoverability. A lot of "CSS did not move" bugs are mobile `@media` overrides of the same selector; comments or linting around desktop/mobile paired rules would help. +- Dead code pass for old routes, stale feature flags, and unused UI states. + +## Frontend + +- Mobile gallery/editor polish. Easier to launch/download inpaint model or any missing pieces. +- Accessibility pass: keyboard navigation, focus states, contrast, reduced motion. +- Improve empty states and error messages on fresh installs. +- Tighten first-run setup, hints, and tours so they do not repeat or fight each other. +- Vendor CDN assets eventually for a more fully self-hosted/offline mode. + +## Backend + +- More tests around endpoint probing and provider setup. +- Better task scheduler defaults and visibility. +- Backup/restore guide and helper flow for `data/`. +- Security hardening around admin-only tools and clear docs for their risk. + +## Not The Focus Right Now + +I prob shouldnt add more themes. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2cca34b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,36 @@ +# Security Policy + +Odysseus is a self-hosted AI workspace with privileged local capabilities. Please do not run it as a public, unauthenticated service. + +## Supported Versions + +Security fixes are handled on the default branch until formal releases are cut. + +## Deployment Guidance + +- Keep `AUTH_ENABLED=true`. +- Use HTTPS when exposing the app beyond localhost. +- Put the app behind a trusted reverse proxy or private network. +- Protect `.env`, `data/`, logs, uploaded files, generated media, and database files. +- Disable open signup unless you intentionally want new accounts. +- Keep demo/test users non-admin, and remove them entirely on serious deployments. +- Give admin accounts strong passwords and enable 2FA where possible. +- Leave high-risk agent tools restricted to admins: shell, Python, file read/write, email send/read, MCP, app API, task/skill/memory management, settings, tokens, and model serving. +- Rotate API keys, webhook secrets, and Odysseus API tokens if they appear in logs, screenshots, demos, or shared chats. +- Treat shell, model-serving, MCP, email, calendar, and vault features as privileged admin functionality. + +## Publishing A Fork + +Before pushing a public fork, run: + +```bash +git status --short +git check-ignore -v .env data/auth.json data/app.db logs/compound.log odysseus.db +git grep -n -I -E "(sk-[A-Za-z0-9_-]{20,}|xox[baprs]-|AIza[0-9A-Za-z_-]{20,}|Bearer [A-Za-z0-9._~+/-]{20,})" -- . ':!static/lib/**' ':!package-lock.json' +``` + +Only `.env.example`, docs, source, tests, and static assets should be committed. Never commit live `data/` contents, local databases, uploaded files, generated media, logs, backups, API keys, password hashes, or personal documents. + +## Reporting + +Please report vulnerabilities privately via GitHub security advisories if available, or by opening a minimal issue that does not disclose exploit details. diff --git a/app.py b/app.py new file mode 100644 index 0000000..af53f9c --- /dev/null +++ b/app.py @@ -0,0 +1,957 @@ +# app.py — slim orchestrator +from dotenv import load_dotenv +load_dotenv() +import os +import uuid + +import asyncio +import logging +from datetime import datetime +from typing import Dict + +from fastapi import FastAPI, Request, HTTPException +from fastapi.responses import JSONResponse, FileResponse, HTMLResponse +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware + +# Core imports +from core.constants import ( + BASE_DIR, STATIC_DIR, SESSIONS_FILE, + REQUEST_TIMEOUT, OPENAI_API_KEY, +) +from core.database import SessionLocal, ApiToken +from core.middleware import SecurityHeadersMiddleware +from core.auth import AuthManager +from core.exceptions import ( + SessionNotFoundError, InvalidFileUploadError, + LLMServiceError, WebSearchError, +) + +import bcrypt as _bcrypt + +from src.app_helpers import abs_join +from starlette.responses import RedirectResponse + +# ========= LOGGING ========= +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', +) +logger = logging.getLogger(__name__) + +# ========= APP ========= +app = FastAPI( + title="AI Chat Application", + description="Comprehensive AI chat with memory, research, and multi-modal capabilities", + version="1.0.0", +) + +# ========= CORS ========= +allowed_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost,http://127.0.0.1").split(",") +app.add_middleware( + CORSMiddleware, + allow_origins=allowed_origins, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE"], + allow_headers=["*"], +) + +# ========= SECURITY HEADERS MIDDLEWARE ========= +app.add_middleware(SecurityHeadersMiddleware) + + +# ========= REQUEST TIMEOUT (FALLBACK FOR HUNG HANDLERS) ========= +# If a single request takes longer than REQUEST_HARD_TIMEOUT, abort it and +# return 504 instead of holding the event loop hostage. Whitelisted paths +# (streaming, long-running shell exec, research) are exempt because they +# legitimately stay open. Without this, a single hung subprocess.run or +# missing-timeout httpx call locks up the entire server for everyone. +import asyncio as _asyncio +from starlette.middleware.base import BaseHTTPMiddleware as _BaseHTTPMiddleware +from starlette.responses import JSONResponse as _JSONResponse + +REQUEST_HARD_TIMEOUT = float(os.getenv("REQUEST_HARD_TIMEOUT", "45")) +_TIMEOUT_EXEMPT_PREFIXES = ( + "/api/chat", # streaming + "/api/shell/stream", # SSE + "/api/research", # multi-minute jobs + "/api/model/download", # tmux setup may run pip installs + "/api/model/probe", # SSE; iterates models with up to 8s timeout each + "/api/model-endpoints", # /probe sub-route also iterates models + "/api/cookbook/setup", # remote pacman/apt installs + "/api/upload", # large files + "/api/image", # diffusion proxies (inpaint/harmonize/upscale/etc.) — own 120s httpx timeout +) + + +class _RequestTimeoutMiddleware(_BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + path = request.url.path or "" + if any(path.startswith(p) for p in _TIMEOUT_EXEMPT_PREFIXES): + return await call_next(request) + try: + return await _asyncio.wait_for(call_next(request), timeout=REQUEST_HARD_TIMEOUT) + except _asyncio.TimeoutError: + return _JSONResponse( + {"detail": f"Request exceeded {REQUEST_HARD_TIMEOUT:.0f}s timeout"}, + status_code=504, + ) + + +app.add_middleware(_RequestTimeoutMiddleware) + +# ========= AUTH ========= +from routes.auth_routes import setup_auth_routes, SESSION_COOKIE + +auth_manager = AuthManager() +app.state.auth_manager = auth_manager +AUTH_ENABLED = os.getenv("AUTH_ENABLED", "true").lower() != "false" +LOCALHOST_BYPASS = os.getenv("LOCALHOST_BYPASS", "false").lower() == "true" + +if AUTH_ENABLED: + AUTH_EXEMPT_EXACT = { + "/api/auth/setup", + "/api/auth/signup", + "/api/auth/login", + "/api/auth/logout", + "/api/auth/status", + "/api/auth/features", + "/api/auth/settings", + "/api/auth/integrations/presets", + "/api/health", + "/api/version", + "/login", + } + AUTH_EXEMPT_PREFIXES = ["/static"] + + def _is_auth_exempt(path: str) -> bool: + return path in AUTH_EXEMPT_EXACT or any(path.startswith(p) for p in AUTH_EXEMPT_PREFIXES) + + # In-memory token cache: prefix → list[(token_id, token_hash, owner, scopes)]. The DB + # query was running on every API-bearer request and scanning bcrypt + # checks linearly. With this cache, we hit the DB only when the cache + # version bumps (token created/revoked) — see _token_cache_invalidate + # in app.state, called by routes/api_token_routes. + _token_cache: dict = {} + _token_cache_lock = _asyncio.Lock() + _token_cache_dirty = True + + def _token_cache_invalidate(): + nonlocal_dict = app.state.__dict__ + nonlocal_dict["_token_cache_dirty"] = True + app.state.invalidate_token_cache = _token_cache_invalidate + app.state._token_cache = _token_cache + app.state._token_cache_dirty = True + + def _refresh_token_cache(): + """Rebuild the prefix→[(id,hash)] map from the DB.""" + from collections import defaultdict + new_map = defaultdict(list) + db = SessionLocal() + try: + rows = db.query(ApiToken).filter(ApiToken.is_active == True).all() + for r in rows: + scopes = [s.strip() for s in (getattr(r, "scopes", "") or "chat").split(",") if s.strip()] + new_map[r.token_prefix].append((r.id, r.token_hash, getattr(r, "owner", None), scopes)) + finally: + db.close() + _token_cache.clear() + _token_cache.update(new_map) + app.state._token_cache_dirty = False + + class AuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + path = request.url.path + if _is_auth_exempt(path): + return await call_next(request) + # In-process internal-tool token bypass. Used by the agent + # tool layer when it HTTP-loopbacks to admin-gated routes + # (no admin cookie available in that context). Restricted to + # loopback clients + matching token to keep it locked down. + try: + from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN as _ITT + _hdr = request.headers.get(INTERNAL_TOOL_HEADER) + _client_host = request.client.host if request.client else None + if _hdr and _hdr == _ITT and _client_host in ("127.0.0.1", "::1"): + # Impersonation: when the agent's loopback call sets + # X-Odysseus-Owner, attribute the request to that + # user so notes/calendar/etc. land in their account + # instead of being owned by "internal-tool" (which + # made the agent's POSTs invisible to the user that + # asked for them). + _impersonate = (request.headers.get("X-Odysseus-Owner") or "").strip() + request.state.current_user = _impersonate or "internal-tool" + request.state.api_token = False + return await call_next(request) + except Exception: + pass + # Allow localhost requests (internal service calls from heartbeats etc.) + # Disable with LOCALHOST_BYPASS=false when exposing via reverse proxy / Tailscale Funnel + if LOCALHOST_BYPASS: + client_host = request.client.host if request.client else None + if client_host in ("127.0.0.1", "::1"): + return await call_next(request) + if not auth_manager.is_configured: + # No users yet — redirect to login for first-time setup + if not path.startswith("/api/"): + return RedirectResponse(url="/login", status_code=302) + return JSONResponse(status_code=401, content={"error": "Setup required"}) + + # --- Bearer token auth (API tokens for external integrations) --- + auth_header = request.headers.get("authorization", "") + if auth_header.startswith("Bearer ody_"): + raw_token = auth_header[7:] + # Sanity check: tokens are "ody_" + 43 chars of base64 + if len(raw_token) < 12 or len(raw_token) > 100: + return JSONResponse(status_code=401, content={"error": "Invalid API token"}) + prefix = raw_token[:8] + try: + if app.state._token_cache_dirty: + async with _token_cache_lock: + if app.state._token_cache_dirty: + await _asyncio.to_thread(_refresh_token_cache) + candidates = list(_token_cache.get(prefix, ())) + matched_id = None + matched_owner = None + matched_scopes = [] + for tid, thash, owner, scopes in candidates: + if _bcrypt.checkpw(raw_token.encode(), thash.encode()): + matched_id = tid + matched_owner = owner + matched_scopes = scopes or [] + break + if matched_id: + # Update last_used_at off the hot path. Doing it + # inline used to keep the request open across an + # extra commit; do it fire-and-forget instead. + async def _touch_last_used(tid: str): + def _do(): + _db = SessionLocal() + try: + _db.query(ApiToken).filter(ApiToken.id == tid).update( + {"last_used_at": datetime.utcnow()} + ) + _db.commit() + finally: + _db.close() + try: + await _asyncio.to_thread(_do) + except Exception: + pass + _asyncio.create_task(_touch_last_used(matched_id)) + # Keep bearer-token callers out of normal cookie/user + # routes. API-aware routes can read api_token_owner. + request.state.current_user = "api" + request.state.api_token = True + request.state.api_token_id = matched_id + request.state.api_token_owner = matched_owner + request.state.api_token_scopes = matched_scopes + return await call_next(request) + except Exception: + logger.warning("API token auth error", exc_info=False) + # Invalid bearer token — reject immediately + return JSONResponse(status_code=401, content={"error": "Invalid API token"}) + + # --- Cookie-based session auth --- + token = request.cookies.get(SESSION_COOKIE) + if not auth_manager.validate_token(token): + if path.startswith("/api/"): + return JSONResponse(status_code=401, content={"error": "Not authenticated"}) + return RedirectResponse(url="/login", status_code=302) + + # Attach current username to request state for downstream routes + request.state.current_user = auth_manager.get_username_for_token(token) + request.state.api_token = False + return await call_next(request) + + app.add_middleware(AuthMiddleware) + logger.info("Auth middleware enabled (AUTH_ENABLED=true)") +else: + logger.info("Auth middleware disabled (set AUTH_ENABLED=true to enable)") + +# ========= STATIC FILES ========= +os.makedirs(STATIC_DIR, exist_ok=True) + + +class _RevalidatingStatic(StaticFiles): + """Serve static assets normally, but force the browser to REVALIDATE + source files (.js/.css/.html) on every load instead of serving a stale + copy from disk cache. The app ships raw ES modules with no build step or + versioned URLs, so browsers were caching modules across deploys — a code + change wouldn't appear without a manual hard-refresh. `no-cache` keeps the + cached bytes but requires a conditional request; unchanged files still + return a cheap 304 (ETag/Last-Modified are preserved).""" + + async def get_response(self, path, scope): + resp = await super().get_response(path, scope) + if path.endswith((".js", ".css", ".html")): + resp.headers["Cache-Control"] = "no-cache" + return resp + + +app.mount("/static", _RevalidatingStatic(directory="static"), name="static") + +# ========= GENERATED IMAGES ========= +@app.get("/api/generated-image/{filename}") +async def serve_generated_image(filename: str, request: Request): + """Serve generated images from the data directory.""" + from pathlib import Path + import re + if not re.match(r'^[a-f0-9]{8,64}\.(png|jpg|jpeg|webp|gif|mp4|mov|webm|mkv|m4v)$', filename): + raise HTTPException(status_code=400, detail="Invalid filename") + img_path = Path("data/generated_images") / filename + if not img_path.exists(): + raise HTTPException(status_code=404, detail="Image not found") + # SECURITY: filename is the only key, so anyone who knows / guesses a + # 12-hex content hash could pull another user's image bytes. Require + # auth and verify ownership via the gallery row (when one exists). + try: + from src.auth_helpers import get_current_user + from core.database import SessionLocal as _SL, GalleryImage as _GI + _user = get_current_user(request) + if _user: + _db = _SL() + try: + _row = _db.query(_GI).filter(_GI.filename == filename).first() + # Generated-but-not-yet-imported images have no row → allow. + # Row exists with a different owner → 404 (don't confirm existence). + if _row is not None and _row.owner and _row.owner != _user: + raise HTTPException(status_code=404, detail="Image not found") + finally: + _db.close() + except HTTPException: + raise + except Exception: + pass + ext = filename.rsplit('.', 1)[-1].lower() + mime = { + "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", + "webp": "image/webp", "gif": "image/gif", + "mp4": "video/mp4", "mov": "video/quicktime", "webm": "video/webm", + "mkv": "video/x-matroska", "m4v": "video/mp4", + }.get(ext, "application/octet-stream") + # Generated-image filenames are content hashes → the bytes for a given + # filename never change. Cache them hard so the gallery doesn't + # re-download every full-size image each time it's opened. `immutable` + # tells the browser it never needs to revalidate within the max-age. + return FileResponse( + str(img_path), + media_type=mime, + headers={"Cache-Control": "public, max-age=31536000, immutable"}, + ) + +# ========= YOUTUBE INIT ========= +from services.youtube import init_youtube +init_youtube() + +# ========= RAG (vector document RAG — DISABLED) ========= +# VectorRAG (ChromaDB-backed personal-document semantic search) is unused +# (0 directories ever indexed) and its chromadb 1.4.1 / pydantic 2.12 client +# can't even instantiate — it threw at init and cost ~30s of startup waiting on +# the embedding probe. Disabled. All callers already guard on rag_available / +# `if rag_manager`, so personal-doc routes degrade cleanly. +rag_manager = None +rag_available = False +logger.info("Vector document RAG disabled (unused)") + +# ========= IMPORT CONFIG ========= +from src.config import config + +# ========= COMPONENT INITIALIZATION ========= +from src.app_initializer import initialize_managers + +components = initialize_managers(BASE_DIR, rag_manager) + +session_manager = components["session_manager"] +from src.assistant_log import set_session_manager as _set_asst_sm +_set_asst_sm(session_manager) +memory_manager = components["memory_manager"] +memory_vector = components.get("memory_vector") +upload_handler = components["upload_handler"] +personal_docs_mgr = components["personal_docs_manager"] +api_key_manager = components["api_key_manager"] +preset_manager = components["preset_manager"] +chat_processor = components["chat_processor"] +research_handler = components["research_handler"] +chat_handler = components["chat_handler"] +model_discovery = components["model_discovery"] +skills_manager = components["skills_manager"] + +# TTS +from services.tts import get_tts_service + +tts_service = get_tts_service() +logger.info("TTS service initialized (provider managed via admin settings)") + +# ========= EXCEPTION HANDLERS ========= +@app.exception_handler(SessionNotFoundError) +async def session_not_found_handler(request: Request, exc: SessionNotFoundError): + return JSONResponse(status_code=404, content={"error": "SESSION_NOT_FOUND", "message": str(exc)}) + +@app.exception_handler(InvalidFileUploadError) +async def invalid_file_upload_handler(request: Request, exc: InvalidFileUploadError): + return JSONResponse(status_code=400, content={"error": "INVALID_FILE_UPLOAD", "message": str(exc)}) + +@app.exception_handler(LLMServiceError) +async def llm_service_error_handler(request: Request, exc: LLMServiceError): + return JSONResponse(status_code=502, content={"error": "LLM_SERVICE_ERROR", "message": str(exc)}) + +@app.exception_handler(WebSearchError) +async def web_search_error_handler(request: Request, exc: WebSearchError): + return JSONResponse(status_code=502, content={"error": "WEB_SEARCH_ERROR", "message": str(exc)}) + +# ========= WEBHOOK MANAGER ========= +from src.webhook_manager import WebhookManager + +webhook_manager = WebhookManager(api_key_manager=api_key_manager) + +# ========= INCLUDE ROUTERS ========= + +# Auth +auth_router = setup_auth_routes(auth_manager) +app.include_router(auth_router) + +# Uploads +from routes.upload_routes import setup_upload_routes +upload_router, upload_cleanup_func = setup_upload_routes(upload_handler) +app.include_router(upload_router) +upload_cleanup_task = None + +# Emoji SVG proxy (same-origin, lazy-cached Twemoji) — lets the chat render +# emojis as flat SVG instead of system color glyphs. +from routes.emoji_routes import setup_emoji_routes +app.include_router(setup_emoji_routes()) + +# Sessions +from routes.session_routes import setup_session_routes +session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE} +app.include_router(setup_session_routes(session_manager, session_config, webhook_manager=webhook_manager)) + +# Admin Danger Zone wipes (Settings → System → Danger Zone) +from routes.admin_wipe_routes import setup_admin_wipe_routes +app.include_router(setup_admin_wipe_routes(session_manager)) + +# Memory +from routes.memory_routes import setup_memory_routes +app.include_router(setup_memory_routes(memory_manager, session_manager, memory_vector=memory_vector)) +from routes.skills_routes import setup_skills_routes +app.include_router(setup_skills_routes(skills_manager)) + +# Chat +from routes.chat_routes import setup_chat_routes +app.include_router(setup_chat_routes( + session_manager, chat_handler, chat_processor, + memory_manager, research_handler, upload_handler, + memory_vector=memory_vector, + webhook_manager=webhook_manager, + skills_manager=skills_manager, +)) + +# Research (background deep-research tasks) +from routes.research_routes import setup_research_routes +app.include_router(setup_research_routes(research_handler, session_manager=session_manager)) + +# History +from routes.history_routes import setup_history_routes +app.include_router(setup_history_routes(session_manager)) + +# Search +from routes.search_routes import setup_search_routes +app.include_router(setup_search_routes(config)) + +# Presets +from routes.preset_routes import setup_preset_routes +app.include_router(setup_preset_routes(preset_manager)) + +# Diagnostics +from routes.diagnostics_routes import setup_diagnostics_routes +app.include_router(setup_diagnostics_routes(rag_manager, rag_available, research_handler)) + +# Cleanup +from routes.cleanup_routes import setup_cleanup_routes +app.include_router(setup_cleanup_routes(session_manager)) + +# Personal docs +from routes.personal_routes import setup_personal_routes +app.include_router(setup_personal_routes(personal_docs_mgr, rag_manager, rag_available)) + +# Embedding model management +from routes.embedding_routes import setup_embedding_routes +app.include_router(setup_embedding_routes()) + +# Models +from routes.model_routes import setup_model_routes +app.include_router(setup_model_routes(model_discovery)) + +# TTS +from routes.tts_routes import setup_tts_routes +app.include_router(setup_tts_routes(tts_service)) + +# STT +from services.stt import get_stt_service +stt_service = get_stt_service() +from routes.stt_routes import setup_stt_routes +app.include_router(setup_stt_routes(stt_service)) +logger.info("STT service initialized (provider managed via settings)") + +# Documents (artifacts/canvas) +from routes.document_routes import setup_document_routes +app.include_router(setup_document_routes(session_manager, upload_handler)) + +# Signatures (reusable image stamps) +from routes.signature_routes import setup_signature_routes +app.include_router(setup_signature_routes()) + +# Gallery (image library) +from routes.gallery_routes import setup_gallery_routes +app.include_router(setup_gallery_routes()) + +# Persisted image-editor drafts (server-backed projects) +from routes.editor_draft_routes import setup_editor_draft_routes +app.include_router(setup_editor_draft_routes()) + +# Scheduled tasks + event bus +from src.task_scheduler import TaskScheduler +task_scheduler = TaskScheduler(session_manager) +from src.event_bus import set_task_scheduler +set_task_scheduler(task_scheduler) +from routes.task_routes import setup_task_routes +app.include_router(setup_task_routes(task_scheduler)) + +from routes.assistant_routes import setup_assistant_routes +app.include_router(setup_assistant_routes(task_scheduler)) + +# Calendar (CalDAV) +from routes.calendar_routes import setup_calendar_routes +app.include_router(setup_calendar_routes()) + +# Shell (user-facing command execution) +from routes.shell_routes import setup_shell_routes +app.include_router(setup_shell_routes()) + +# Cookbook (model download/serve/cache, cookbook state sync) +from routes.cookbook_routes import setup_cookbook_routes +app.include_router(setup_cookbook_routes()) + +# Hardware model fitting (cookbook "What Fits?" tab) +from routes.hwfit_routes import setup_hwfit_routes +app.include_router(setup_hwfit_routes()) + +# Model A/B Comparison +from routes.compare_routes import setup_compare_routes +app.include_router(setup_compare_routes(session_manager)) + +# User Preferences +from routes.prefs_routes import setup_prefs_routes +app.include_router(setup_prefs_routes()) + +# Backup (export/import user data) +from routes.backup_routes import setup_backup_routes +app.include_router(setup_backup_routes(memory_manager, preset_manager, skills_manager)) + +from routes.font_routes import setup_font_routes +app.include_router(setup_font_routes()) + + +# MCP (Model Context Protocol) +from src.mcp_manager import McpManager +from src.agent_tools import set_mcp_manager +from routes.mcp_routes import setup_mcp_routes + +mcp_manager = McpManager() +set_mcp_manager(mcp_manager) +app.include_router(setup_mcp_routes(mcp_manager)) +logger.info("MCP routes initialized") + +# AI Interaction tools (debates, pipelines, self-managing AI, UI control) +from src.ai_interaction import set_session_manager as set_ai_session_manager, set_memory_manager as set_ai_memory_manager, set_rag_manager as set_ai_rag_manager +set_ai_session_manager(session_manager) +set_ai_memory_manager(memory_manager, memory_vector) +set_ai_rag_manager(rag_manager, personal_docs_mgr) +logger.info("AI interaction tools initialized (session, memory, RAG, UI control)") + +# Webhooks +from routes.webhook_routes import setup_webhook_routes +app.include_router(setup_webhook_routes(webhook_manager, auth_manager, session_manager, api_key_manager)) + +# API Tokens +from routes.api_token_routes import setup_api_token_routes +app.include_router(setup_api_token_routes()) + +logger.info("Webhook & API token routes initialized") + +# Notes (Google Keep-style notes/todos) +from routes.note_routes import setup_note_routes +app.include_router(setup_note_routes(task_scheduler)) + +# Email +from routes.email_routes import setup_email_routes +app.include_router(setup_email_routes()) + +from routes.vault_routes import setup_vault_routes +app.include_router(setup_vault_routes()) + +# Contacts (CardDAV) +from routes.contacts_routes import setup_contacts_routes +app.include_router(setup_contacts_routes()) + +# ========= ROUTES (kept in app.py) ========= + +def _serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse: + """Read an HTML file and inject the CSP nonce into inline + + + diff --git a/docs/notes.gif b/docs/notes.gif new file mode 100644 index 0000000..891ec2e Binary files /dev/null and b/docs/notes.gif differ diff --git a/docs/notes.webm b/docs/notes.webm new file mode 100644 index 0000000..3bf38d7 Binary files /dev/null and b/docs/notes.webm differ diff --git a/docs/odysseus.jpg b/docs/odysseus.jpg new file mode 100644 index 0000000..982a00f Binary files /dev/null and b/docs/odysseus.jpg differ diff --git a/docs/research.gif b/docs/research.gif new file mode 100644 index 0000000..b817eeb Binary files /dev/null and b/docs/research.gif differ diff --git a/docs/research.webm b/docs/research.webm new file mode 100644 index 0000000..faebafd Binary files /dev/null and b/docs/research.webm differ diff --git a/docs/theme.webm b/docs/theme.webm new file mode 100644 index 0000000..db18b41 Binary files /dev/null and b/docs/theme.webm differ diff --git a/install-service.sh b/install-service.sh new file mode 100644 index 0000000..e3cfe6e --- /dev/null +++ b/install-service.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SERVICE_FILE="$SCRIPT_DIR/odysseus-ui.service" + +if [ ! -f "$SERVICE_FILE" ]; then + echo "Error: odysseus-ui.service not found in $SCRIPT_DIR" + exit 1 +fi + +echo "Installing Odysseus UI service..." +echo "Make sure you've edited odysseus-ui.service with your username and paths first!" +echo "" + +sudo cp "$SERVICE_FILE" /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable odysseus-ui +sudo systemctl start odysseus-ui +sudo systemctl status odysseus-ui diff --git a/licenses/DeepResearch-Apache-2.0.txt b/licenses/DeepResearch-Apache-2.0.txt new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/licenses/DeepResearch-Apache-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/licenses/llmfit-MIT-LICENSE.txt b/licenses/llmfit-MIT-LICENSE.txt new file mode 100644 index 0000000..b60c5f6 --- /dev/null +++ b/licenses/llmfit-MIT-LICENSE.txt @@ -0,0 +1,25 @@ +llmfit — https://github.com/AlexsJones/llmfit +Adapted in: services/hwfit/, routes/cookbook_*.py, routes/hwfit_routes.py, +static/js/cookbook*.js, scripts/odysseus-cookbook + +MIT License + +Copyright (c) 2026 Alex Jones + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/licenses/opencode-MIT-LICENSE.txt b/licenses/opencode-MIT-LICENSE.txt new file mode 100644 index 0000000..d2eac4b --- /dev/null +++ b/licenses/opencode-MIT-LICENSE.txt @@ -0,0 +1,25 @@ +opencode — https://github.com/anomalyco/opencode +(originally https://github.com/opencode-ai/opencode, archived Sep 2025) +Adapted for: agent-loop / tool-execution patterns and UI concepts + +MIT License + +Copyright (c) 2025 opencode + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mcp_servers/__init__.py b/mcp_servers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp_servers/_common.py b/mcp_servers/_common.py new file mode 100644 index 0000000..641c852 --- /dev/null +++ b/mcp_servers/_common.py @@ -0,0 +1,18 @@ +""" +_common.py + +Shared constants and helpers for built-in MCP servers. +""" + +MAX_OUTPUT_CHARS = 10_000 +MAX_READ_CHARS = 20_000 +SHELL_TIMEOUT = 60 +PYTHON_TIMEOUT = 30 +SEARCH_TIMEOUT = 30 + + +def truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str: + """Truncate text to *limit* characters with a suffix note.""" + if len(text) > limit: + return text[:limit] + f"\n... (truncated, {len(text)} chars total)" + return text diff --git a/mcp_servers/email_server.py b/mcp_servers/email_server.py new file mode 100644 index 0000000..f5b89ee --- /dev/null +++ b/mcp_servers/email_server.py @@ -0,0 +1,1591 @@ +""" +email_server.py + +MCP server exposing email tools: list unread/unresponded emails, +read email content, and draft replies as email documents. +Connects to local Dovecot IMAP and reads from the AI summary cache. +""" + +import asyncio +import imaplib +import smtplib +import email +import email.header +import email.utils +from email.message import EmailMessage +import re +import html +import json +import sqlite3 +import sys +import os +import os.path +from pathlib import Path +from datetime import datetime, timedelta + +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import Tool, TextContent + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +server = Server("email") +EMAIL_SOCKET_TIMEOUT = float(os.environ.get("EMAIL_SOCKET_TIMEOUT", "20")) +DATA_DIR = Path(__file__).resolve().parent.parent / "data" + + +def _b(value) -> bytes: + return str(value).encode() + + +def _uid_fetch_rows(data) -> list: + return [d for d in (data or []) if isinstance(d, bytes) and b"UID " in d] + +# ── Config ── +# Multi-account aware. Accounts live in data/app.db :: email_accounts. +# Callers can pass `account=` (match by name, user, or id) to pick a specific +# inbox; None resolves to the default row. Falls back to env vars / settings.json +# flat keys when no DB row matches (legacy single-account behaviour). + +_ACCOUNT_CACHE: dict = {} # key = normalized account selector -> config dict + + +def _clean_header_value(value) -> str: + """EmailMessage rejects CR/LF in assigned header values; unfold safely.""" + if value is None: + return "" + return re.sub(r"[\r\n]+[ \t]*", " ", str(value)).strip() + + +def _db_path() -> Path: + return DATA_DIR / "app.db" + + +def _list_accounts_raw() -> list: + """Return list of dicts from the email_accounts table. Empty list if table + missing or empty. Never raises.""" + path = _db_path() + if not path.exists(): + return [] + try: + conn = sqlite3.connect(str(path)) + conn.row_factory = sqlite3.Row + rows = conn.execute(""" + SELECT id, name, is_default, enabled, + imap_host, imap_port, imap_user, imap_password, imap_starttls, + smtp_host, smtp_port, smtp_user, smtp_password, from_address + FROM email_accounts WHERE enabled = 1 + ORDER BY is_default DESC, created_at ASC + """).fetchall() + conn.close() + return [dict(r) for r in rows] + except sqlite3.OperationalError: + return [] + except Exception: + return [] + + +def _resolve_account(selector: str | None) -> dict | None: + """Given a selector (None = default, or a name/user/id string), return the + matching row or None. Matching is case-insensitive substring on name + + imap_user + from_address, plus exact id match.""" + rows = _list_accounts_raw() + if not rows: + return None + if not selector: + for r in rows: + if r.get("is_default"): + return r + return rows[0] + sel = selector.strip().lower() + # Exact id match first + for r in rows: + if r["id"] == selector: + return r + for r in rows: + fields = [r.get("name") or "", r.get("imap_user") or "", r.get("from_address") or ""] + if any(sel in (f or "").lower() for f in fields): + return r + try: + from difflib import get_close_matches + candidates = [] + by_candidate = {} + for r in rows: + for field in (r.get("name"), r.get("imap_user"), r.get("from_address")): + if field: + val = str(field).lower() + candidates.append(val) + by_candidate[val] = r + close = get_close_matches(sel, candidates, n=1, cutoff=0.72) + if close: + return by_candidate.get(close[0]) + except Exception: + pass + return None + + +def _load_config(account: str | None = None) -> dict: + """Return the full config dict for the requested account (or default). + + Resolution order per-field: + 1. email_accounts row (selected by `account` or default) + 2. env vars + settings.json flat keys (legacy) + 3. hardcoded fallbacks (localhost:31143 etc.) + """ + cache_key = (account or "").strip().lower() or "__default__" + if cache_key in _ACCOUNT_CACHE: + return _ACCOUNT_CACHE[cache_key] + + cfg = { + "imap_host": os.environ.get("IMAP_HOST", "localhost"), + "imap_port": int(os.environ.get("IMAP_PORT", "31143")), + "imap_user": os.environ.get("IMAP_USER", ""), + "imap_password": os.environ.get("IMAP_PASSWORD", ""), + "imap_ssl": os.environ.get("IMAP_SSL", "false").lower() == "true", + "imap_starttls": os.environ.get("IMAP_STARTTLS", "true").lower() == "true", + "smtp_host": os.environ.get("SMTP_HOST", ""), + "smtp_port": int(os.environ.get("SMTP_PORT", "465")), + "smtp_user": os.environ.get("SMTP_USER", ""), + "smtp_password": os.environ.get("SMTP_PASSWORD", ""), + "smtp_starttls": os.environ.get("SMTP_STARTTLS", "false").lower() == "true", + "smtp_ssl": os.environ.get("SMTP_SSL", "true").lower() == "true", + "from_address": os.environ.get("EMAIL_FROM", ""), + "archive_folder": os.environ.get("ARCHIVE_FOLDER", "Archive"), + "trash_folder": os.environ.get("TRASH_FOLDER", "Trash"), + "cache_db": os.environ.get( + "EMAIL_CACHE_DB", + str(DATA_DIR / "email_cache.db"), + ), + "account_id": None, + "account_name": None, + } + + rows = _list_accounts_raw() + row = _resolve_account(account) + if account and rows and not row: + available = ", ".join( + f"{r.get('name') or r.get('imap_user')} <{r.get('imap_user') or r.get('from_address') or '?'}>" + for r in rows + ) + raise ValueError(f"Email account not found for selector {account!r}. Available accounts: {available}") + if row: + cfg["account_id"] = row["id"] + cfg["account_name"] = row["name"] + cfg["imap_host"] = row["imap_host"] or cfg["imap_host"] + cfg["imap_port"] = int(row["imap_port"] or cfg["imap_port"]) + cfg["imap_user"] = row["imap_user"] or cfg["imap_user"] + # Passwords in email_accounts are stored encrypted via + # src.secret_storage.encrypt — decrypt before handing to IMAP + # (same path email_helpers.py:369 uses). Falling back to the raw + # ciphertext is what produced AUTHENTICATIONFAILED previously. + try: + from src.secret_storage import decrypt as _decrypt + except Exception: + _decrypt = lambda v: v # noqa: E731 + cfg["imap_password"] = _decrypt(row["imap_password"]) if row["imap_password"] else cfg["imap_password"] + cfg["imap_starttls"] = bool(row["imap_starttls"]) + # The email_accounts table stores STARTTLS but not an explicit IMAP SSL + # flag. Port 993 is implicit TLS for IMAP providers like Gmail. + cfg["imap_ssl"] = int(cfg["imap_port"]) == 993 and not cfg["imap_starttls"] + cfg["smtp_host"] = row["smtp_host"] or cfg["smtp_host"] + cfg["smtp_port"] = int(row["smtp_port"] or cfg["smtp_port"]) + cfg["smtp_user"] = row["smtp_user"] or cfg["smtp_user"] + cfg["smtp_password"] = _decrypt(row["smtp_password"]) if row["smtp_password"] else cfg["smtp_password"] + cfg["from_address"] = row["from_address"] or row["imap_user"] or cfg["from_address"] + else: + # Legacy fallback: settings.json flat keys + try: + settings_path = Path(__file__).resolve().parent.parent / "data" / "settings.json" + if settings_path.exists(): + settings = json.loads(settings_path.read_text()) + for key in ( + "imap_host", "imap_port", "imap_user", "imap_password", + "smtp_host", "smtp_port", "smtp_user", "smtp_password", + "from_address", "archive_folder", "trash_folder", + ): + if settings.get(key) not in (None, ""): + cfg[key] = int(settings[key]) if key.endswith("_port") else settings[key] + except Exception: + pass + + if not cfg["from_address"]: + cfg["from_address"] = cfg["imap_user"] + + _ACCOUNT_CACHE[cache_key] = cfg + return cfg + + +# ── IMAP helpers ── + + +def _imap_connect(account: str | None = None): + """Connect to IMAP server, returns logged-in connection. account selects + the mailbox (None = default).""" + cfg = _load_config(account) + if cfg["imap_ssl"]: + conn = imaplib.IMAP4_SSL( + cfg["imap_host"], + cfg["imap_port"], + timeout=EMAIL_SOCKET_TIMEOUT, + ) + else: + conn = imaplib.IMAP4( + cfg["imap_host"], + cfg["imap_port"], + timeout=EMAIL_SOCKET_TIMEOUT, + ) + if cfg["imap_starttls"]: + conn.starttls() + if getattr(conn, "sock", None): + conn.sock.settimeout(EMAIL_SOCKET_TIMEOUT) + conn.login(cfg["imap_user"], cfg["imap_password"]) + return conn + + +def _detect_sent_folder(conn): + """Find the account's Sent folder name; fall back to 'Sent'.""" + candidates = ("Sent", "[Gmail]/Sent Mail", "Sent Mail", "Sent Items", "INBOX.Sent") + try: + status, folders = conn.list() + if status != "OK" or not folders: + return "Sent" + names = [] + for f in folders: + decoded = f.decode() if isinstance(f, bytes) else str(f) + m = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded) + if m: + names.append(m.group(1) or m.group(2)) + for f in folders: + decoded = f.decode() if isinstance(f, bytes) else str(f) + if r"\Sent" in decoded: + m = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded) + if m: + return m.group(1) or m.group(2) + for c in candidates: + if c in names: + return c + except Exception: + pass + return "Sent" + + +def _folder_name_from_list_line(line) -> str | None: + decoded = line.decode() if isinstance(line, bytes) else str(line) + m = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded) + if not m: + return None + return m.group(1) or m.group(2) + + +def _list_folder_lines(conn) -> list: + try: + status, folders = conn.list() + if status != "OK" or not folders: + return [] + return folders + except Exception: + return [] + + +def _resolve_folder(conn, preferred: str, role: str) -> str: + """Resolve provider-specific folder names like Gmail's [Gmail]/Trash.""" + folders = _list_folder_lines(conn) + names = [name for name in (_folder_name_from_list_line(f) for f in folders) if name] + if preferred and preferred in names: + return preferred + + role_flags = { + "trash": ("\\Trash",), + "archive": ("\\Archive", "\\All"), + "junk": ("\\Junk",), + }.get(role, ()) + for f in folders: + decoded = f.decode() if isinstance(f, bytes) else str(f) + if any(flag in decoded for flag in role_flags): + name = _folder_name_from_list_line(f) + if name: + return name + + candidates = { + "trash": ("Trash", "[Gmail]/Trash", "[Google Mail]/Trash", "Bin", "Deleted Messages", "Deleted Items"), + "archive": ("Archive", "Archives", "[Gmail]/All Mail", "[Google Mail]/All Mail"), + "junk": ("Junk", "Spam", "[Gmail]/Spam", "[Google Mail]/Spam"), + }.get(role, ()) + lower_map = {n.lower(): n for n in names} + for candidate in candidates: + if candidate.lower() in lower_map: + return lower_map[candidate.lower()] + return preferred + + +def _folder_role_from_name(name: str) -> str: + lower = (name or "").lower() + if "trash" in lower or "bin" in lower or "deleted" in lower: + return "trash" + if "junk" in lower or "spam" in lower: + return "junk" + if "archive" in lower or "all mail" in lower: + return "archive" + return "" + + +def _decode_header(raw): + """Decode MIME encoded header.""" + if not raw: + return "" + parts = email.header.decode_header(raw) + decoded = [] + for data, charset in parts: + if isinstance(data, bytes): + decoded.append(data.decode(charset or "utf-8", errors="replace")) + else: + decoded.append(data) + return " ".join(decoded) + + +def _extract_text(msg): + """Extract plain text body from email message.""" + if msg.is_multipart(): + text_parts = [] + for part in msg.walk(): + ct = part.get_content_type() + cd = str(part.get("Content-Disposition", "")) + if ct == "text/plain" and "attachment" not in cd: + payload = part.get_payload(decode=True) + if payload: + charset = part.get_content_charset() or "utf-8" + text_parts.append(payload.decode(charset, errors="replace")) + elif ct == "text/html" and not text_parts and "attachment" not in cd: + payload = part.get_payload(decode=True) + if payload: + charset = part.get_content_charset() or "utf-8" + raw_html = payload.decode(charset, errors="replace") + text = re.sub(r"", "\n", raw_html, flags=re.I) + text = re.sub(r"<[^>]+>", "", text) + text = html.unescape(text) + text_parts.append(text.strip()) + return "\n".join(text_parts) + else: + payload = msg.get_payload(decode=True) + if payload: + charset = msg.get_content_charset() or "utf-8" + return payload.decode(charset, errors="replace") + return "" + + +def _get_cached_summaries(): + """Read pre-computed summaries from SQLite cache.""" + cfg = _load_config() + db_path = cfg["cache_db"] + if not os.path.exists(db_path): + return {} + try: + conn = sqlite3.connect(db_path) + rows = conn.execute( + "SELECT subject, sender, summary, suggested_reply FROM email_ai" + ).fetchall() + conn.close() + result = {} + for subj, sender, summary, reply in rows: + result[subj] = {"sender": sender, "summary": summary, "reply": reply} + return result + except Exception: + return {} + + +# ── Tool implementations ── + + +def _list_emails(folder="INBOX", max_results=20, unresponded_only=False, + unread_only=False, account=None): + """List emails newest-first. By default returns the latest messages, + including read mail, so it matches normal inbox UI expectations. + Pass unread_only=True and/or unresponded_only=True for attention scans. + account selects mailbox (None = default). + """ + conn = _imap_connect(account) + select_status, _ = conn.select(folder, readonly=True) + if select_status != "OK": + conn.logout() + raise ValueError(f"IMAP folder not found: {folder}") + + if unread_only and unresponded_only: + status, data = conn.uid("SEARCH", None, "(UNSEEN UNANSWERED)") + elif unread_only: + status, data = conn.uid("SEARCH", None, "(UNSEEN)") + else: + # Include read too — IMAP search "ALL" returns the entire folder + status, data = conn.uid("SEARCH", None, "ALL") + + if status != "OK" or not data[0]: + conn.logout() + return [] + + uid_list = list(reversed(data[0].split()))[:max_results] + cache = _get_cached_summaries() + results = [] + + for uid in uid_list: + try: + status, msg_data = conn.uid("FETCH", uid, "(RFC822.HEADER)") + if status != "OK": + continue + raw_header = msg_data[0][1] + msg = email.message_from_bytes(raw_header) + + subject = _decode_header(msg.get("Subject", "(no subject)")) + sender = _decode_header(msg.get("From", "unknown")) + date_str = msg.get("Date", "") + message_id = msg.get("Message-ID", "") + + # Parse sender name + sender_name, sender_addr = email.utils.parseaddr(sender) + sender_display = sender_name or sender_addr + + # Check cache for summary + cached = cache.get(subject, {}) + summary = cached.get("summary", "") + + results.append({ + "uid": uid.decode(), + "message_id": message_id, + "subject": subject, + "from": sender_display, + "from_address": sender_addr, + "date": date_str, + "summary": summary, + }) + except Exception: + continue + + conn.logout() + return results + + +def _result_sort_time(result: dict) -> datetime: + try: + parsed = email.utils.parsedate_to_datetime(result.get("date") or "") + if parsed: + if parsed.tzinfo: + parsed = parsed.astimezone().replace(tzinfo=None) + return parsed + except Exception: + pass + return datetime.min + + +def _list_emails_across_accounts(folder="INBOX", max_results=20, + unresponded_only=False, unread_only=False): + rows = _list_accounts_raw() + combined = [] + errors = [] + for row in rows: + account_selector = row.get("id") or row.get("name") or row.get("imap_user") + account_name = row.get("name") or row.get("imap_user") or row.get("id") or "unknown" + account_email = row.get("imap_user") or row.get("from_address") or "" + try: + account_results = _list_emails( + folder=folder, + max_results=max_results, + unresponded_only=unresponded_only, + unread_only=unread_only, + account=account_selector, + ) + for item in account_results: + item["_account"] = account_name + item["_account_email"] = account_email + item["_account_id"] = row.get("id") + combined.extend(account_results) + except Exception as exc: + errors.append(f"{account_name} ({account_email}): {exc}") + combined.sort(key=_result_sort_time, reverse=True) + return combined[:max_results], errors + + +def _search_emails(query, folders=None, max_results=20, account=None): + """IMAP-search emails by free-text query. Matches FROM, SUBJECT, and + body TEXT. Walks multiple folders so older threads outside INBOX + (Sent/Archive) are still findable. Returns the same shape as + _list_emails plus an `_folder` tag.""" + if not query or not str(query).strip(): + return [] + q = str(query).replace("\\", "\\\\").replace('"', '\\"') + # Mail clients commonly use OR FROM/SUBJECT/TEXT to match either field. + # IMAP SEARCH OR is binary, so we nest it. + search_cmd = f'(OR OR FROM "{q}" SUBJECT "{q}" TEXT "{q}")' + if folders is None: + folders = ["INBOX", "Sent", "Archive"] + cache = _get_cached_summaries() + out = [] + conn = _imap_connect(account) + touched = [] + try: + for folder in folders: + try: + status, _ = conn.select(folder, readonly=True) + if status != "OK": + continue + status, data = conn.uid("SEARCH", None, search_cmd) + if status != "OK" or not data or not data[0]: + continue + uid_list = list(reversed(data[0].split()))[:max_results] + for uid in uid_list: + try: + status, msg_data = conn.uid("FETCH", uid, "(RFC822.HEADER)") + if status != "OK": + continue + raw_header = msg_data[0][1] + msg = email.message_from_bytes(raw_header) + subject = _decode_header(msg.get("Subject", "(no subject)")) + sender = _decode_header(msg.get("From", "unknown")) + date_str = msg.get("Date", "") + message_id = msg.get("Message-ID", "") + to_str = _decode_header(msg.get("To", "")) + cc_str = _decode_header(msg.get("Cc", "")) + sender_name, sender_addr = email.utils.parseaddr(sender) + sender_display = sender_name or sender_addr + cached = cache.get(subject, {}) + out.append({ + "uid": uid.decode(), + "message_id": message_id, + "subject": subject, + "from": sender_display, + "from_address": sender_addr, + "to": to_str, + "cc": cc_str, + "date": date_str, + "_folder": folder, + "summary": cached.get("summary", ""), + }) + except Exception: + continue + except Exception: + continue + finally: + try: conn.logout() + except Exception: pass + # Cap total across folders. + return out[: max_results * len(folders)] + + +def _list_attachments_from_msg(msg): + """Return attachment metadata.""" + if not msg.is_multipart(): + return [] + attachments = [] + idx = 0 + for part in msg.walk(): + if part.is_multipart(): + continue + cd = str(part.get("Content-Disposition", "")) + ct = part.get_content_type() + if ct in ("text/plain", "text/html") and "attachment" not in cd: + continue + filename = part.get_filename() + if filename: + filename = _decode_header(filename) + else: + filename = f"attachment_{idx}" + payload = part.get_payload(decode=True) + size = len(payload) if payload else 0 + attachments.append({ + "index": idx, + "filename": filename, + "content_type": ct, + "size": size, + }) + idx += 1 + return attachments + + +def _extract_attachment_to_disk(msg, index, target_dir): + """Extract a specific attachment to disk.""" + if not msg.is_multipart(): + return None + idx = 0 + for part in msg.walk(): + if part.is_multipart(): + continue + cd = str(part.get("Content-Disposition", "")) + ct = part.get_content_type() + if ct in ("text/plain", "text/html") and "attachment" not in cd: + continue + if idx == index: + filename = part.get_filename() + if filename: + filename = _decode_header(filename) + else: + filename = f"attachment_{idx}" + safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() + payload = part.get_payload(decode=True) + if not payload: + return None + os.makedirs(target_dir, exist_ok=True) + filepath = os.path.join(target_dir, safe_name) + with open(filepath, "wb") as f: + f.write(payload) + return filepath + idx += 1 + return None + + +def _read_email(uid=None, message_id=None, folder="INBOX", account=None): + """Read full email content by UID or message-ID. account = mailbox selector.""" + cfg = _load_config(account) + conn = _imap_connect(account) + conn.select(folder, readonly=True) + + if message_id and not uid: + status, data = conn.uid("SEARCH", None, f'(HEADER Message-ID "{message_id}")') + if status != "OK" or not data[0]: + conn.logout() + return {"error": f"Email not found with Message-ID: {message_id}"} + uid = data[0].split()[-1] + + if not uid: + conn.logout() + return {"error": "No UID or Message-ID provided"} + + status, msg_data = conn.uid("FETCH", _b(uid), "(RFC822)") + if status != "OK": + conn.logout() + return {"error": f"Failed to fetch email UID {uid}"} + if not msg_data or not msg_data[0] or not isinstance(msg_data[0], tuple) or len(msg_data[0]) < 2: + conn.logout() + return {"error": f"Email not found with UID {uid}"} + + raw = msg_data[0][1] + msg = email.message_from_bytes(raw) + + subject = _decode_header(msg.get("Subject", "(no subject)")) + sender = _decode_header(msg.get("From", "unknown")) + date_str = msg.get("Date", "") + message_id_header = msg.get("Message-ID", "") + body = _extract_text(msg) + attachments = _list_attachments_from_msg(msg) + + sender_name, sender_addr = email.utils.parseaddr(sender) + + conn.logout() + return { + "uid": uid.decode() if isinstance(uid, bytes) else str(uid), + "account": cfg.get("account_name") or cfg.get("imap_user") or "default", + "account_email": cfg.get("imap_user") or cfg.get("from_address") or "", + "account_id": cfg.get("account_id"), + "message_id": message_id_header, + "subject": subject, + "from": sender_name or sender_addr, + "from_address": sender_addr, + "date": date_str, + "body": body[:8000], + "attachments": attachments, + } + + +def _read_email_across_accounts(uid=None, message_id=None, folder="INBOX"): + rows = _list_accounts_raw() + matches = [] + errors = [] + for row in rows: + account_selector = row.get("id") or row.get("name") or row.get("imap_user") + account_name = row.get("name") or row.get("imap_user") or row.get("id") or "unknown" + account_email = row.get("imap_user") or row.get("from_address") or "" + result = _read_email( + uid=uid, + message_id=message_id, + folder=folder, + account=account_selector, + ) + if "error" in result: + errors.append(f"{account_name} <{account_email}>: {result['error']}") + continue + matches.append(result) + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + accounts = ", ".join( + f"{m.get('account')} <{m.get('account_email')}>" for m in matches + ) + return { + "error": ( + f"UID {uid or message_id} exists in multiple accounts: {accounts}. " + "Call read_email again with the account name/email." + ) + } + return {"error": f"Email not found in any configured account. Checked: {'; '.join(errors)}"} + + +def _smtp_ready(cfg: dict) -> bool: + return bool(cfg.get("smtp_host") and cfg.get("smtp_user") and cfg.get("smtp_password")) + + +def _resolve_send_config(account=None): + cfg = _load_config(account) + if _smtp_ready(cfg): + return account, cfg + if account: + raise ValueError(f"Email account {cfg.get('account_name') or account} has no SMTP configured") + for row in _list_accounts_raw(): + selector = row.get("id") or row.get("name") or row.get("imap_user") + trial = _load_config(selector) + if _smtp_ready(trial): + return selector, trial + raise ValueError("No SMTP-capable email account configured") + + +def _smtp_connect(account=None, cfg=None): + """Connect to SMTP server, returns logged-in connection.""" + cfg = cfg or _load_config(account) + if not _smtp_ready(cfg): + raise ValueError(f"Email account {cfg.get('account_name') or account or 'default'} has no SMTP configured") + port = int(cfg.get("smtp_port") or 465) + # Account rows only store host/port, not the legacy env-level smtp_ssl + # toggle. Infer the conventional TLS mode from the port so MCP tools match + # the web send path: 465 = implicit SSL, 587 = STARTTLS. + if port == 587: + conn = smtplib.SMTP( + cfg["smtp_host"], + port, + timeout=EMAIL_SOCKET_TIMEOUT, + ) + conn.starttls() + elif cfg.get("smtp_ssl", True): + conn = smtplib.SMTP_SSL( + cfg["smtp_host"], + port, + timeout=EMAIL_SOCKET_TIMEOUT, + ) + else: + conn = smtplib.SMTP( + cfg["smtp_host"], + port, + timeout=EMAIL_SOCKET_TIMEOUT, + ) + if cfg["smtp_starttls"]: + conn.starttls() + if cfg["smtp_user"] and cfg["smtp_password"]: + conn.login(cfg["smtp_user"], cfg["smtp_password"]) + return conn + + +def _send_email(to, subject, body, in_reply_to=None, references=None, cc=None, bcc=None, account=None): + """Send an email via SMTP. Returns dict with status.""" + send_account, cfg = _resolve_send_config(account) + msg = EmailMessage() + msg["From"] = _clean_header_value(cfg["from_address"]) + msg["To"] = _clean_header_value(to if isinstance(to, str) else ", ".join(to)) + msg["Subject"] = _clean_header_value(subject) + if cc: + msg["Cc"] = _clean_header_value(cc if isinstance(cc, str) else ", ".join(cc)) + if in_reply_to: + msg["In-Reply-To"] = _clean_header_value(in_reply_to) + if references: + msg["References"] = _clean_header_value(references if isinstance(references, str) else " ".join(references)) + if "Date" not in msg: + msg["Date"] = email.utils.formatdate(localtime=True) + if "Message-ID" not in msg: + msg["Message-ID"] = email.utils.make_msgid() + msg.set_content(body) + + recipients = [] + if isinstance(to, str): + recipients.extend([a.strip() for a in to.split(",") if a.strip()]) + else: + recipients.extend(to) + if cc: + recipients.extend([a.strip() for a in cc.split(",")] if isinstance(cc, str) else cc) + if bcc: + recipients.extend([a.strip() for a in bcc.split(",")] if isinstance(bcc, str) else bcc) + + conn = _smtp_connect(send_account, cfg=cfg) + try: + conn.send_message(msg, from_addr=cfg["from_address"], to_addrs=recipients) + finally: + conn.quit() + + sent_folder = None + sent_uid = None + try: + imap = _imap_connect(send_account) + try: + sent_folder = _detect_sent_folder(imap) + append_st, append_data = imap.append(sent_folder, "\\Seen", None, msg.as_bytes()) + if append_st == "OK" and append_data: + m = re.search(rb"APPENDUID\s+\d+\s+(\d+)", append_data[0] or b"") + if m: + sent_uid = m.group(1).decode("ascii", errors="ignore") + finally: + imap.logout() + except Exception: + # Delivery already succeeded; Sent-copy failure should not turn a sent + # message into a hard failure for the user. + pass + + return { + "sent": True, + "to": recipients, + "subject": subject, + "account": cfg.get("account_name"), + "account_id": cfg.get("account_id"), + "sent_folder": sent_folder, + "sent_uid": sent_uid, + "message_id": msg.get("Message-ID", ""), + } + + +def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None): + """Reply to an existing email by UID. Threads via In-Reply-To/References.""" + conn = _imap_connect(account) + conn.select(folder, readonly=True) + status, msg_data = conn.uid("FETCH", _b(uid), "(RFC822)") + conn.logout() + if status != "OK" or not msg_data or not msg_data[0]: + return {"error": f"Failed to fetch email UID {uid}"} + raw = msg_data[0][1] + orig = email.message_from_bytes(raw) + + orig_subject = _decode_header(orig.get("Subject", "")) + reply_subject = orig_subject if orig_subject.lower().startswith("re:") else f"Re: {orig_subject}" + orig_message_id = orig.get("Message-ID", "") + orig_references = orig.get("References", "") + new_references = (orig_references + " " + orig_message_id).strip() if orig_references else orig_message_id + + sender = _decode_header(orig.get("From", "")) + _, sender_addr = email.utils.parseaddr(sender) + to_addrs = sender_addr + + cc = None + if reply_all: + cc_addrs = [] + for header_name in ("To", "Cc"): + for _, addr in email.utils.getaddresses([orig.get(header_name, "")]): + if addr and addr != sender_addr: + cc_addrs.append(addr) + if cc_addrs: + cc = ", ".join(cc_addrs) + + return _send_email( + to=to_addrs, + subject=reply_subject, + body=body, + in_reply_to=orig_message_id, + references=new_references, + cc=cc, + account=account, + ) + + +def _set_flag(uid, folder, flag, add=True, account=None): + """Add or remove an IMAP flag (e.g. \\Seen, \\Answered, \\Deleted).""" + conn = _imap_connect(account) + conn.select(folder) + op = "+FLAGS" if add else "-FLAGS" + try: + status, data = conn.uid("STORE", _b(uid), op, flag) + if add and flag == "\\Deleted": + conn.expunge() + return status == "OK" and bool(data and data[0]) + except Exception: + return False + finally: + conn.logout() + + +def _bulk_set_flag(uids, folder, flag, add=True, account=None): + """Add/remove an IMAP flag on MANY messages in one connection. + `uids` is a list; we issue a single STORE over the comma-joined set + (IMAP supports message-set syntax). Returns count attempted.""" + if not uids: + return 0 + conn = _imap_connect(account) + touched = [] + try: + conn.select(folder) + op = "+FLAGS" if add else "-FLAGS" + msg_set = ",".join(str(u) for u in uids) + try: + status, data = conn.uid("FETCH", _b(msg_set), "(UID)") + except Exception: + return 0 + touched = _uid_fetch_rows(data) + if status != "OK" or not touched: + return 0 + status, data = conn.uid("STORE", _b(msg_set), op, flag) + if add and flag == "\\Deleted": + conn.expunge() + if status != "OK": + return 0 + finally: + conn.logout() + return len(touched) + + +def _bulk_move(uids, source_folder, dest_folder, account=None, role: str = ""): + """Move MANY messages between folders in one connection.""" + if not uids: + return 0 + conn = _imap_connect(account) + moved = 0 + try: + conn.select(source_folder) + dest_folder = _resolve_folder(conn, dest_folder, role or _folder_role_from_name(dest_folder)) + msg_set = ",".join(str(u) for u in uids) + try: + status, data = conn.uid("FETCH", _b(msg_set), "(UID)") + except Exception: + return 0 + existing = _uid_fetch_rows(data) + if not existing: + return 0 + moved = len(existing) + status, _ = conn.uid("MOVE", _b(msg_set), dest_folder) + if status != "OK": + # Fallback: UID copy + flag-delete + expunge + status, _ = conn.uid("COPY", _b(msg_set), dest_folder) + if status != "OK": + return 0 + status, _ = conn.uid("STORE", _b(msg_set), "+FLAGS", "\\Deleted") + if status != "OK": + return 0 + conn.expunge() + finally: + conn.logout() + return moved + + +def _search_uids(folder="INBOX", criteria="UNSEEN", account=None): + """Return a list of UIDs matching an IMAP search (e.g. UNSEEN, + ALL, ANSWERED). Used to resolve selectors like all_unread → uids.""" + conn = _imap_connect(account) + try: + conn.select(folder, readonly=True) + status, data = conn.uid("SEARCH", None, criteria) + if status != "OK" or not data or not data[0]: + return [] + return data[0].split() + finally: + conn.logout() + + +def _move_message(uid, source_folder, dest_folder, account=None, role: str = ""): + """Move a message between folders. Tries IMAP MOVE, falls back to copy+delete.""" + conn = _imap_connect(account) + conn.select(source_folder) + try: + dest_folder = _resolve_folder(conn, dest_folder, role or _folder_role_from_name(dest_folder)) + try: + status, data = conn.uid("FETCH", _b(uid), "(UID)") + except Exception: + return False + existing = _uid_fetch_rows(data) + if status != "OK" or not existing: + return False + status, _ = conn.uid("MOVE", _b(uid), dest_folder) + if status == "OK": + return True + # Fallback: UID copy + delete + status, _ = conn.uid("COPY", _b(uid), dest_folder) + if status != "OK": + return False + status, _ = conn.uid("STORE", _b(uid), "+FLAGS", "\\Deleted") + if status != "OK": + return False + conn.expunge() + ok = True + finally: + conn.logout() + return ok + + +def _delete_email(uid, folder="INBOX", permanent=False, account=None): + """Delete an email. By default moves to Trash; permanent=True expunges.""" + cfg = _load_config(account) + if permanent: + return _set_flag(uid, folder, "\\Deleted", add=True, account=account) + return _move_message(uid, folder, cfg["trash_folder"], account=account, role="trash") + + +def _archive_email(uid, folder="INBOX", account=None): + """Move an email to the archive folder.""" + cfg = _load_config(account) + return _move_message(uid, folder, cfg["archive_folder"], account=account, role="archive") + + +def _download_attachment(uid, index, folder="INBOX", account=None): + """Extract a specific attachment to disk and return its local path.""" + conn = _imap_connect(account) + conn.select(folder, readonly=True) + status, msg_data = conn.uid("FETCH", _b(uid), "(RFC822)") + conn.logout() + if status != "OK": + return {"error": f"Failed to fetch email UID {uid}"} + raw = msg_data[0][1] + msg = email.message_from_bytes(raw) + + target_dir = DATA_DIR / "mail-attachments" / f"{folder}_{uid}" + filepath = _extract_attachment_to_disk(msg, index, target_dir) + if not filepath: + return {"error": f"Attachment index {index} not found"} + size = os.path.getsize(filepath) + return {"path": filepath, "filename": os.path.basename(filepath), "size": size} + + +# ── MCP Tool Registration ── + + +@server.list_tools() +async def list_tools() -> list[Tool]: + # The user may have multiple IMAP accounts configured. Every tool accepts an + # optional `account` param — match by name (e.g. "work"), email address, + # or account id. Leave it out to use the default account. + ACCOUNT_PROP = { + "account": { + "type": "string", + "description": "Which email account to use (name, email, or id). " + "Omit to use the default account. Use list_email_accounts to discover available accounts.", + }, + } + return [ + Tool( + name="list_email_accounts", + description=( + "List the email accounts configured in Odysseus. Returns each account's " + "name, email address, and whether it's the default. Use this first when " + "the user asks about a specific inbox by name (e.g. 'check work')." + ), + inputSchema={"type": "object", "properties": {}, "required": []}, + ), + Tool( + name="list_emails", + description=( + "List unread or unresponded emails from the inbox. " + "Returns subject, sender, date, and cached AI summary for each. " + "Use this to check what emails need attention. " + "Pass `account` to scan a non-default mailbox." + ), + inputSchema={ + "type": "object", + "properties": { + "folder": { + "type": "string", + "description": "IMAP folder to check (default: INBOX)", + "default": "INBOX", + }, + "max_results": { + "type": "integer", + "description": "Maximum number of emails to return (default: 20)", + "default": 20, + }, + "unresponded_only": { + "type": "boolean", + "description": "Only show emails without replies (default: false)", + "default": False, + }, + "unread_only": { + "type": "boolean", + "description": "Only show unread emails. Default false so latest/all inbox requests match normal mail clients.", + "default": False, + }, + **ACCOUNT_PROP, + }, + "required": [], + }, + ), + Tool( + name="download_attachment", + description=( + "Download an email attachment to the local disk so you can read it. " + "Returns the local file path which you can then read with read_file. " + "Use this when you need to review a document, spreadsheet, or other " + "file attached to an email." + ), + inputSchema={ + "type": "object", + "properties": { + "uid": {"type": "string", "description": "Email UID from list_emails"}, + "index": {"type": "integer", "description": "Attachment index (from read_email's attachments list)"}, + "folder": {"type": "string", "description": "IMAP folder (default: INBOX)", "default": "INBOX"}, + **ACCOUNT_PROP, + }, + "required": ["uid", "index"], + }, + ), + Tool( + name="send_email", + description=( + "Send a new email via SMTP. Provide recipient(s), subject, and body. " + "For replying to an existing thread, use reply_to_email instead. " + "Pass `account` to send from a non-default mailbox." + ), + inputSchema={ + "type": "object", + "properties": { + "to": {"type": "string", "description": "Recipient email address(es), comma-separated"}, + "subject": {"type": "string", "description": "Email subject line"}, + "body": {"type": "string", "description": "Plain text body"}, + "cc": {"type": "string", "description": "CC address(es), comma-separated (optional)"}, + "bcc": {"type": "string", "description": "BCC address(es), comma-separated (optional)"}, + **ACCOUNT_PROP, + }, + "required": ["to", "subject", "body"], + }, + ), + Tool( + name="reply_to_email", + description=( + "Reply to an existing email by UID. Automatically threads the reply with " + "In-Reply-To and References headers, prefixes 'Re:' on the subject, and " + "uses the original sender as the recipient. Set reply_all=true to also CC " + "the original To/Cc recipients. For follow-up 'reply ...' requests, use " + "the exact UID from the latest list_emails/read_email result; never invent UID 1." + ), + inputSchema={ + "type": "object", + "properties": { + "uid": {"type": "string", "description": "Exact Email UID from list_emails/read_email; never invent UID 1"}, + "body": {"type": "string", "description": "Reply body text"}, + "folder": {"type": "string", "description": "IMAP folder (default: INBOX)", "default": "INBOX"}, + "reply_all": {"type": "boolean", "description": "Reply to all recipients (default: false)", "default": False}, + **ACCOUNT_PROP, + }, + "required": ["uid", "body"], + }, + ), + Tool( + name="archive_email", + description="Move an email out of the inbox into the Archive folder. Use after handling an email you want to keep but no longer need in the inbox.", + inputSchema={ + "type": "object", + "properties": { + "uid": {"type": "string", "description": "Email UID from list_emails"}, + "folder": {"type": "string", "description": "Source folder (default: INBOX)", "default": "INBOX"}, + **ACCOUNT_PROP, + }, + "required": ["uid"], + }, + ), + Tool( + name="delete_email", + description="Delete an email. By default moves it to the Trash folder; pass permanent=true to expunge immediately.", + inputSchema={ + "type": "object", + "properties": { + "uid": {"type": "string", "description": "Email UID from list_emails"}, + "folder": {"type": "string", "description": "Source folder (default: INBOX)", "default": "INBOX"}, + "permanent": {"type": "boolean", "description": "Hard-delete instead of move to Trash", "default": False}, + **ACCOUNT_PROP, + }, + "required": ["uid"], + }, + ), + Tool( + name="mark_email_read", + description="Mark an email as read (\\Seen flag) or unread (read=false).", + inputSchema={ + "type": "object", + "properties": { + "uid": {"type": "string", "description": "Email UID"}, + "folder": {"type": "string", "description": "IMAP folder", "default": "INBOX"}, + "read": {"type": "boolean", "description": "True to mark read, false to mark unread", "default": True}, + **ACCOUNT_PROP, + }, + "required": ["uid"], + }, + ), + Tool( + name="bulk_email", + description=( + "Perform one action on MANY emails at once — the efficient way to " + "'mark all as read', 'archive these', 'delete all spam', etc. Select " + "messages either by an explicit `uids` list OR by `all_unread: true` " + "(operates on every unread message in the folder). Far better than " + "calling mark_email_read / archive_email once per message." + ), + inputSchema={ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["mark_read", "mark_unread", "archive", "delete", "junk"], + "description": "What to do to every selected message.", + }, + "uids": { + "type": "array", + "items": {"type": "string"}, + "description": "Explicit list of UIDs. Omit if using all_unread.", + }, + "all_unread": { + "type": "boolean", + "description": "Operate on ALL unread messages in the folder (ignores uids).", + "default": False, + }, + "folder": {"type": "string", "description": "IMAP folder", "default": "INBOX"}, + "permanent": {"type": "boolean", "description": "For delete: expunge instead of moving to Trash.", "default": False}, + **ACCOUNT_PROP, + }, + "required": ["action"], + }, + ), + Tool( + name="search_emails", + description=( + "Search emails by free-text query (sender, subject, or body). " + "Walks INBOX + Sent + Archive by default so older threads are findable, " + "not just recent unread. Use this whenever the user names a person or " + "topic that isn't in the most recent inbox slice — e.g. 'Sara Sotheby's', " + "'invoice from EY', 'last email about the property'. Returns matching " + "emails with their UIDs so you can read_email or reply_to_email." + ), + inputSchema={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Free-text query. Matches FROM, SUBJECT, and body TEXT.", + }, + "folders": { + "type": "array", + "items": {"type": "string"}, + "description": "Folders to search (default: INBOX, Sent, Archive)", + }, + "max_results": { + "type": "integer", + "description": "Max results per folder (default: 20)", + "default": 20, + }, + **ACCOUNT_PROP, + }, + "required": ["query"], + }, + ), + Tool( + name="read_email", + description=( + "Read the full content of a specific email. " + "Provide either the UID (from list_emails) or a Message-ID. " + "Returns the subject, sender, date, and full body text." + ), + inputSchema={ + "type": "object", + "properties": { + "uid": { + "type": "string", + "description": "Email UID from list_emails results", + }, + "message_id": { + "type": "string", + "description": "RFC Message-ID header value", + }, + "folder": { + "type": "string", + "description": "IMAP folder (default: INBOX)", + "default": "INBOX", + }, + **ACCOUNT_PROP, + }, + "required": [], + }, + ), + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict) -> list[TextContent]: + try: + if name == "list_email_accounts": + rows = _list_accounts_raw() + if not rows: + return [TextContent(type="text", text="No email accounts configured. Legacy single-account mode active.")] + lines = [f"Found {len(rows)} email account(s):\n"] + for r in rows: + star = " (default)" if r.get("is_default") else "" + lines.append( + f"- **{r['name']}**{star}\n" + f" email: {r.get('imap_user') or r.get('from_address') or '(unknown)'}\n" + f" id: {r['id']}" + ) + return [TextContent(type="text", text="\n".join(lines))] + + acct = arguments.get("account") # consumed by all email ops + + if name == "list_emails": + max_results = arguments.get("max_results", arguments.get("limit", 20)) + unresponded_only = arguments.get("unresponded_only", False) + unread_only = arguments.get("unread_only", False) + # Build a header note so the LLM always knows which account was hit + # AND what other accounts exist. Prevents "I can see emails" → + # user: "I have 2 inboxes" → "which one?" loop. + all_accounts = _list_accounts_raw() + header_lines = [] + errors = [] + if len(all_accounts) >= 2 and not acct: + results, errors = _list_emails_across_accounts( + folder=arguments.get("folder", "INBOX"), + max_results=max_results, + unresponded_only=unresponded_only, + unread_only=unread_only, + ) + account_names = [ + f"{a.get('name') or a.get('imap_user')} <{a.get('imap_user') or a.get('from_address') or '?'}>" + for a in all_accounts + ] + header_lines.append( + f"[EMAIL ACCOUNT CONTEXT: No `account` was provided, so this result is merged across configured accounts: " + f"{', '.join(account_names)}. Each row includes its source account.]\n" + ) + else: + results = _list_emails( + folder=arguments.get("folder", "INBOX"), + max_results=max_results, + unresponded_only=unresponded_only, + unread_only=unread_only, + account=acct, + ) + active_cfg = _load_config(acct) + if active_cfg.get("account_name") or active_cfg.get("imap_user"): + for item in results: + item["_account"] = active_cfg.get("account_name") or active_cfg.get("imap_user") or "default" + item["_account_email"] = active_cfg.get("imap_user") or "" + + if len(all_accounts) >= 2 and acct: + active_cfg = _load_config(acct) + active_name = active_cfg.get("account_name") or "default" + active_email = active_cfg.get("imap_user") or "" + other = [ + f"{a['name']} <{a.get('imap_user') or a.get('from_address') or '?'}>" + for a in all_accounts + if a['id'] != active_cfg.get("account_id") + ] + header_lines.append( + f"[EMAIL ACCOUNT CONTEXT: This result is ONLY from account `{active_name}` ({active_email}). " + f"Other configured accounts: {', '.join(other)}. " + f"If the user asks for Gmail/another inbox, call list_emails again with `account` set to that account name or email.]\n" + ) + if errors: + header_lines.append("[EMAIL ACCOUNT ERRORS: " + "; ".join(errors) + "]\n") + + if not results: + msg = "No unread/unresponded emails found." + if header_lines: + msg = "\n".join(header_lines) + msg + return [TextContent(type="text", text=msg)] + + lines = header_lines + [f"Found {len(results)} email(s):\n"] + for i, em in enumerate(results, 1): + line = f"{i}. **{em['subject']}**\n From: {em['from']} ({em['from_address']})\n Date: {em['date']}\n UID: {em['uid']}" + if em.get("_account"): + account_label = em.get("_account") + if em.get("_account_email"): + account_label += f" <{em['_account_email']}>" + line += f"\n Account: {account_label}" + if em.get("summary"): + line += f"\n Summary: {em['summary']}" + lines.append(line) + return [TextContent(type="text", text="\n\n".join(lines))] + + elif name == "download_attachment": + uid = arguments.get("uid") + index = arguments.get("index") + folder = arguments.get("folder", "INBOX") + if uid is None or index is None: + return [TextContent(type="text", text="Error: uid and index are required")] + result = _download_attachment(uid, index, folder, account=acct) + if "error" in result: + return [TextContent(type="text", text=f"Error: {result['error']}")] + text = ( + f"Attachment downloaded to: `{result['path']}`\n" + f"Filename: {result['filename']}\n" + f"Size: {result['size']} bytes\n\n" + f"You can now read this file using the read_file tool." + ) + return [TextContent(type="text", text=text)] + + elif name == "search_emails": + q = arguments.get("query", "") + folders = arguments.get("folders") or None + max_results = arguments.get("max_results", 20) + try: + hits = _search_emails(q, folders=folders, max_results=max_results, account=acct) + except Exception as e: + return [TextContent(type="text", text=f"Search failed: {e}")] + if not hits: + return [TextContent(type="text", text=f'No emails matched "{q}".')] + lines = [f'Found {len(hits)} email(s) matching "{q}":\n'] + for i, em in enumerate(hits, 1): + lines.append( + f"{i}. **{em['subject']}**\n" + f" From: {em['from']} ({em['from_address']})\n" + f" Date: {em['date']}\n" + f" Folder: {em.get('_folder', 'INBOX')}\n" + f" UID: {em['uid']}" + ) + if em.get('to'): + lines.append(f" To: {em['to']}") + if em.get('summary'): + lines.append(f" Summary: {em['summary']}") + return [TextContent(type="text", text="\n".join(lines))] + + elif name == "read_email": + all_accounts = _list_accounts_raw() + if len(all_accounts) >= 2 and not acct: + result = _read_email_across_accounts( + uid=arguments.get("uid"), + message_id=arguments.get("message_id"), + folder=arguments.get("folder", "INBOX"), + ) + else: + result = _read_email( + uid=arguments.get("uid"), + message_id=arguments.get("message_id"), + folder=arguments.get("folder", "INBOX"), + account=acct, + ) + if "error" in result: + return [TextContent(type="text", text=f"Error: {result['error']}")] + + text = ( + f"**Subject:** {result['subject']}\n" + f"**From:** {result['from']} ({result['from_address']})\n" + f"**Date:** {result['date']}\n" + f"**UID:** {result['uid']}\n" + f"**Account:** {result.get('account', 'default')} ({result.get('account_email', '')})\n" + f"**Message-ID:** {result['message_id']}\n" + ) + if result.get('attachments'): + text += f"\n**Attachments ({len(result['attachments'])}):**\n" + for a in result['attachments']: + size_kb = a['size'] // 1024 + text += f" - [{a['index']}] {a['filename']} ({a['content_type']}, {size_kb}KB)\n" + text += "\n_Use `download_attachment` with the UID and index to download._\n" + text += f"\n---\n\n{result['body']}" + return [TextContent(type="text", text=text)] + + elif name == "send_email": + to = arguments.get("to") + subject = arguments.get("subject") + body = arguments.get("body") + if not to or not subject or body is None: + return [TextContent(type="text", text="Error: to, subject, and body are required")] + result = _send_email( + to=to, + subject=subject, + body=body, + cc=arguments.get("cc"), + bcc=arguments.get("bcc"), + account=acct, + ) + acct_note = f" (from {result['account']})" if result.get("account") else "" + return [TextContent(type="text", text=f"Sent email to {result['to']} with subject '{result['subject']}'{acct_note}.")] + + elif name == "reply_to_email": + uid = arguments.get("uid") + body = arguments.get("body") + if not uid or body is None: + return [TextContent(type="text", text="Error: uid and body are required")] + result = _reply_to_email( + uid=uid, + body=body, + folder=arguments.get("folder", "INBOX"), + reply_all=bool(arguments.get("reply_all", False)), + account=acct, + ) + if "error" in result: + return [TextContent(type="text", text=f"Error: {result['error']}")] + # Mark original as answered + try: + _set_flag(uid, arguments.get("folder", "INBOX"), "\\Answered", add=True, account=acct) + except Exception: + pass + return [TextContent(type="text", text=f"Replied to UID {uid}: '{result['subject']}' → {result['to']}")] + + elif name == "archive_email": + uid = arguments.get("uid") + if not uid: + return [TextContent(type="text", text="Error: uid is required")] + ok = _archive_email(uid, arguments.get("folder", "INBOX"), account=acct) + return [TextContent(type="text", text=f"{'Archived' if ok else 'Failed to archive'} UID {uid}")] + + elif name == "delete_email": + uid = arguments.get("uid") + if not uid: + return [TextContent(type="text", text="Error: uid is required")] + ok = _delete_email( + uid, + arguments.get("folder", "INBOX"), + permanent=bool(arguments.get("permanent", False)), + account=acct, + ) + return [TextContent(type="text", text=f"{'Deleted' if ok else 'Failed to delete'} UID {uid}")] + + elif name == "mark_email_read": + uid = arguments.get("uid") + if not uid: + return [TextContent(type="text", text="Error: uid is required")] + read = bool(arguments.get("read", True)) + ok = _set_flag(uid, arguments.get("folder", "INBOX"), "\\Seen", add=read, account=acct) + state = "read" if read else "unread" + return [TextContent(type="text", text=f"{'Marked' if ok else 'Failed to mark'} UID {uid} as {state}")] + + elif name == "bulk_email": + action = arguments.get("action", "") + folder = arguments.get("folder", "INBOX") + all_unread = bool(arguments.get("all_unread", False)) + uids = arguments.get("uids") or [] + if all_unread: + uids = _search_uids(folder, "UNSEEN", account=acct) + if not uids: + return [TextContent(type="text", text="No messages selected (pass uids or all_unread=true).")] + requested_n = len(uids) + changed_n = 0 + try: + if action == "mark_read": + changed_n = _bulk_set_flag(uids, folder, "\\Seen", add=True, account=acct) + verb = "marked read" + elif action == "mark_unread": + changed_n = _bulk_set_flag(uids, folder, "\\Seen", add=False, account=acct) + verb = "marked unread" + elif action == "archive": + cfg = _load_config(acct) + changed_n = _bulk_move(uids, folder, cfg["archive_folder"], account=acct, role="archive") + verb = "archived" + elif action == "junk": + cfg = _load_config(acct) + junk_folder = cfg.get("junk_folder") or "Junk" + changed_n = _bulk_move(uids, folder, junk_folder, account=acct, role="junk") + verb = "moved to Junk" + elif action == "delete": + permanent = bool(arguments.get("permanent", False)) + if permanent: + changed_n = _bulk_set_flag(uids, folder, "\\Deleted", add=True, account=acct) + verb = "permanently deleted" + else: + cfg = _load_config(acct) + changed_n = _bulk_move(uids, folder, cfg["trash_folder"], account=acct, role="trash") + verb = "moved to Trash" + else: + return [TextContent(type="text", text=f"Unknown bulk action: {action!r}. Use mark_read/mark_unread/archive/delete/junk.")] + except Exception as e: + return [TextContent(type="text", text=f"Bulk {action} failed after partial work: {e}")] + if changed_n <= 0: + return [TextContent(type="text", text=f"No matching UIDs found in {folder}; 0 of {requested_n} email(s) {verb}.")] + suffix = "" if changed_n == requested_n else f" ({changed_n} of {requested_n} requested UIDs matched)" + return [TextContent(type="text", text=f"Done — {changed_n} email(s) {verb}{suffix}.")] + + else: + return [TextContent(type="text", text=f"Unknown tool: {name}")] + + except Exception as e: + return [TextContent(type="text", text=f"Error: {e}")] + + +# ── Main ── + +async def run(): + async with stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, write_stream, server.create_initialization_options() + ) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/mcp_servers/image_gen_server.py b/mcp_servers/image_gen_server.py new file mode 100644 index 0000000..872ccd6 --- /dev/null +++ b/mcp_servers/image_gen_server.py @@ -0,0 +1,166 @@ +""" +image_gen_server.py + +MCP server exposing image generation via OpenAI-compatible APIs. +""" + +import asyncio +import base64 +import sys +import uuid +from pathlib import Path + +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import Tool, TextContent + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +server = Server("image_gen") + + +@server.list_tools() +async def list_tools() -> list[Tool]: + return [ + Tool( + name="generate_image", + description="Generate an image using an image-capable model (e.g. gpt-image-1)", + inputSchema={ + "type": "object", + "properties": { + "prompt": {"type": "string", "description": "Image description prompt"}, + "model": {"type": "string", "description": "Model name (auto-detects if omitted)"}, + "size": {"type": "string", "description": "Image size (default 1024x1024)"}, + "quality": {"type": "string", "description": "Quality: low, medium, high, auto (default medium)"}, + }, + "required": ["prompt"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict) -> list[TextContent]: + if name != "generate_image": + return [TextContent(type="text", text=f"Unknown tool: {name}")] + + prompt = arguments.get("prompt", "") + model_spec = arguments.get("model", "") + size = arguments.get("size", "1024x1024") + quality = arguments.get("quality", "medium") + + if not prompt: + return [TextContent(type="text", text="Error: Image prompt is required")] + + try: + import httpx + from src.settings import load_settings, get_setting + from src.ai_interaction import _resolve_model + + if not get_setting("image_gen_enabled", True): + return [TextContent(type="text", text="Error: Image generation is disabled by the administrator.")] + + _settings = load_settings() + + if not model_spec: + model_spec = _settings.get("image_model", "") + if quality == "medium" and _settings.get("image_quality"): + quality = _settings["image_quality"] + + # Auto-detect best available image model + if not model_spec: + for candidate in ("gpt-image-1.5", "gpt-image-1", "dall-e-3"): + try: + _resolve_model(candidate) + model_spec = candidate + break + except ValueError: + continue + if not model_spec: + return [TextContent(type="text", text="Error: No image model found. Configure one in Admin.")] + + url, model_id, headers = _resolve_model(model_spec) + + is_gpt_image = "gpt-image" in model_id.lower() + base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/") + images_url = base_url + "/images/generations" + + valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"} + valid_dalle3_sizes = {"1024x1024", "1024x1792", "1792x1024"} + if is_gpt_image and size not in valid_gpt_sizes: + size = "1024x1024" + elif not is_gpt_image and size not in valid_dalle3_sizes: + size = "1024x1024" + + payload = {"model": model_id, "prompt": prompt, "n": 1, "size": size} + if is_gpt_image: + payload["quality"] = quality if quality in ("low", "medium", "high", "auto") else "medium" + + async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)) as client: + resp = await client.post(images_url, json=payload, headers=headers) + + if resp.status_code != 200: + error_text = resp.text[:500] + try: + err_json = resp.json() + error_text = err_json.get("error", {}).get("message", error_text) if isinstance(err_json.get("error"), dict) else str(err_json.get("error", error_text)) + except Exception: + pass + return [TextContent(type="text", text=f"Error: Image generation failed ({resp.status_code}): {error_text}")] + + data = resp.json() + images = data.get("data", []) + if not images: + return [TextContent(type="text", text="Error: No images returned from API")] + + img = images[0] + image_url = None + + if img.get("b64_json"): + img_dir = Path("data/generated_images") + img_dir.mkdir(parents=True, exist_ok=True) + filename = f"{uuid.uuid4().hex[:12]}.png" + img_path = img_dir / filename + img_path.write_bytes(base64.b64decode(img["b64_json"])) + image_url = f"/api/generated-image/{filename}" + + # Save to gallery + try: + from src.database import SessionLocal, GalleryImage + db = SessionLocal() + db.add(GalleryImage( + id=str(uuid.uuid4()), + filename=filename, + prompt=prompt, + model=model_id, + size=size, + quality=payload.get("quality", "medium"), + )) + db.commit() + db.close() + except Exception: + pass + + elif img.get("url"): + image_url = img["url"] + else: + return [TextContent(type="text", text="Error: Unexpected image API response format")] + + result = f"Generated image for: {prompt[:100]}\nimage_url: {image_url}\nmodel: {model_id}\nsize: {size}" + return [TextContent(type="text", text=result)] + + except httpx.TimeoutException: + return [TextContent(type="text", text="Error: Image generation timed out (300s)")] + except ValueError as e: + return [TextContent(type="text", text=f"Error: {e}")] + except Exception as e: + return [TextContent(type="text", text=f"Error: {e}")] + + +async def run(): + async with stdio_server() as (read_stream, write_stream): + await server.run(read_stream, write_stream, server.create_initialization_options()) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/mcp_servers/memory_server.py b/mcp_servers/memory_server.py new file mode 100644 index 0000000..c2812e1 --- /dev/null +++ b/mcp_servers/memory_server.py @@ -0,0 +1,208 @@ +""" +memory_server.py + +MCP server exposing memory management (list, add, edit, delete, search). +Imports MemoryManager and MemoryVectorStore from the Odysseus codebase. +""" + +import asyncio +import sys +import time +from pathlib import Path + +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import Tool, TextContent + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +server = Server("memory") + +# Late-initialized managers (set during first tool call) +_memory_manager = None +_memory_vector = None +_initialized = False + + +def _ensure_init(): + """Lazy-init memory managers on first use.""" + global _memory_manager, _memory_vector, _initialized + if _initialized: + return + _initialized = True + + from src.constants import DATA_DIR + from src.memory import MemoryManager + _memory_manager = MemoryManager(DATA_DIR) + + try: + from src.memory_vector import MemoryVectorStore + _memory_vector = MemoryVectorStore(DATA_DIR) + if not _memory_vector.healthy: + _memory_vector = None + except Exception: + _memory_vector = None + + +@server.list_tools() +async def list_tools() -> list[Tool]: + return [ + Tool( + name="manage_memory", + description="Manage the user's memory system: list, add, edit, delete, or search memories.", + inputSchema={ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["list", "add", "edit", "delete", "search"], + "description": "The action to perform", + }, + "text": {"type": "string", "description": "Memory text (add/edit) or search query (search)"}, + "memory_id": {"type": "string", "description": "Memory ID (edit/delete)"}, + "category": { + "type": "string", + "enum": ["fact", "event", "contact", "preference"], + "description": "Memory category (add/list filter)", + }, + }, + "required": ["action"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict) -> list[TextContent]: + if name != "manage_memory": + return [TextContent(type="text", text=f"Unknown tool: {name}")] + + _ensure_init() + if not _memory_manager: + return [TextContent(type="text", text="Error: Memory manager not available")] + + action = arguments.get("action", "") + + if action == "list": + category_filter = arguments.get("category", "") + memories = _memory_manager.load() + if category_filter: + memories = [m for m in memories if m.get("category", "").lower() == category_filter.lower()] + if not memories: + msg = "No memories found" + if category_filter: + msg += f" in category '{category_filter}'" + return [TextContent(type="text", text=msg + ".")] + lines = [f"Found {len(memories)} memory entries:\n"] + for m in memories[:100]: + cat = m.get("category", "fact") + mid = m.get("id", "?")[:8] + text = m.get("text", "") + if len(text) > 150: + text = text[:150] + "..." + lines.append(f"- [{cat}] `{mid}` — {text}") + if len(memories) > 100: + lines.append(f"... and {len(memories) - 100} more") + return [TextContent(type="text", text="\n".join(lines))] + + elif action == "add": + text = arguments.get("text", "") + category = arguments.get("category", "fact") + if not text: + return [TextContent(type="text", text="Error: Memory text cannot be empty")] + entry = _memory_manager.add_entry(text, source="ai_agent", category=category) + memories = _memory_manager.load_all() + memories.append(entry) + _memory_manager.save(memories) + if _memory_vector and _memory_vector.healthy: + try: + _memory_vector.add(entry["id"], text) + except Exception: + pass + return [TextContent(type="text", text=f"Memory added: [{category}] {text} (id: {entry['id'][:8]})")] + + elif action == "edit": + memory_id = arguments.get("memory_id", "") + new_text = arguments.get("text", "") + if not memory_id or not new_text: + return [TextContent(type="text", text="Error: edit needs memory_id and text")] + memories = _memory_manager.load_all() + found = False + full_id = None + for m in memories: + if m.get("id", "").startswith(memory_id): + m["text"] = new_text + m["timestamp"] = int(time.time()) + found = True + full_id = m["id"] + break + if not found: + return [TextContent(type="text", text=f"Error: Memory '{memory_id}' not found")] + _memory_manager.save(memories) + if _memory_vector and _memory_vector.healthy and full_id: + try: + _memory_vector.remove(full_id) + _memory_vector.add(full_id, new_text) + except Exception: + pass + return [TextContent(type="text", text=f"Memory updated: {new_text}")] + + elif action == "delete": + memory_id = arguments.get("memory_id", "") + if not memory_id: + return [TextContent(type="text", text="Error: delete needs memory_id")] + memories = _memory_manager.load_all() + full_id = None + deleted_text = "" + deleted_category = "" + for m in memories: + if m.get("id", "").startswith(memory_id): + full_id = m["id"] + deleted_text = m.get("text", "") + deleted_category = m.get("category", "") + break + original_len = len(memories) + memories = [m for m in memories if not m.get("id", "").startswith(memory_id)] + if len(memories) == original_len: + return [TextContent(type="text", text=f"Error: Memory '{memory_id}' not found")] + _memory_manager.save(memories) + if _memory_vector and _memory_vector.healthy and full_id: + try: + _memory_vector.remove(full_id) + except Exception: + pass + cat = f"[{deleted_category}] " if deleted_category else "" + snippet = deleted_text if len(deleted_text) <= 120 else deleted_text[:117] + "..." + return [TextContent(type="text", text=f"Memory deleted: {cat}{snippet} (id: {memory_id})")] + + elif action == "search": + query = arguments.get("text", "") + if not query: + return [TextContent(type="text", text="Error: search needs text (query)")] + memories = _memory_manager.load() + if hasattr(_memory_manager, 'get_relevant_memories'): + results = _memory_manager.get_relevant_memories(query, memories, threshold=0.05, max_items=20) + else: + query_lower = query.lower() + results = [m for m in memories if query_lower in m.get("text", "").lower()][:20] + if not results: + return [TextContent(type="text", text=f"No memories found matching '{query}'.")] + lines = [f"Found {len(results)} matching memories:\n"] + for m in results: + cat = m.get("category", "fact") + mid = m.get("id", "?")[:8] + text = m.get("text", "") + lines.append(f"- [{cat}] `{mid}` — {text}") + return [TextContent(type="text", text="\n".join(lines))] + + else: + return [TextContent(type="text", text=f"Error: Unknown action '{action}'. Use: list, add, edit, delete, search")] + + +async def run(): + async with stdio_server() as (read_stream, write_stream): + await server.run(read_stream, write_stream, server.create_initialization_options()) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/mcp_servers/rag_server.py b/mcp_servers/rag_server.py new file mode 100644 index 0000000..2d50b4b --- /dev/null +++ b/mcp_servers/rag_server.py @@ -0,0 +1,144 @@ +""" +rag_server.py + +MCP server exposing RAG document management (list, add_directory, remove_directory). +""" + +import asyncio +import os +import sys +from pathlib import Path + +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import Tool, TextContent + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +server = Server("rag") + +_rag_manager = None +_personal_docs_manager = None +_initialized = False + + +def _ensure_init(): + """Lazy-init RAG managers on first use.""" + global _rag_manager, _personal_docs_manager, _initialized + if _initialized: + return + _initialized = True + + try: + from src.rag_singleton import get_rag_manager + _rag_manager = get_rag_manager() + except Exception: + pass + + try: + from src.constants import PERSONAL_DIR + from src.personal_docs import PersonalDocsManager + _personal_docs_manager = PersonalDocsManager(PERSONAL_DIR, _rag_manager) + except Exception: + pass + + +@server.list_tools() +async def list_tools() -> list[Tool]: + return [ + Tool( + name="manage_rag", + description="Manage RAG indexed documents. List indexed files, add directories, or remove directories.", + inputSchema={ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["list", "add_directory", "remove_directory"], + "description": "The action to perform", + }, + "directory": {"type": "string", "description": "Directory path (for add/remove)"}, + }, + "required": ["action"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict) -> list[TextContent]: + if name != "manage_rag": + return [TextContent(type="text", text=f"Unknown tool: {name}")] + + _ensure_init() + action = arguments.get("action", "") + + if action == "list": + if not _personal_docs_manager: + return [TextContent(type="text", text="Personal docs manager not available. RAG may not be configured.")] + try: + files = getattr(_personal_docs_manager, 'index', None) or [] + dirs = [] + if hasattr(_personal_docs_manager, 'get_indexed_directories'): + dirs = _personal_docs_manager.get_indexed_directories() + + lines = [] + if dirs: + lines.append(f"**Indexed directories ({len(dirs)}):**") + for d in dirs: + lines.append(f" - `{d}`") + if files: + lines.append(f"\n**Indexed files ({len(files)}):**") + for f in files[:50]: + fname = f.get("name", str(f)) if isinstance(f, dict) else str(f) + lines.append(f" - {fname}") + if len(files) > 50: + lines.append(f" ... and {len(files) - 50} more") + if not lines: + return [TextContent(type="text", text="No files or directories indexed in RAG.")] + return [TextContent(type="text", text="\n".join(lines))] + except Exception as e: + return [TextContent(type="text", text=f"Error: {e}")] + + elif action == "add_directory": + directory = arguments.get("directory", "").strip() + if not directory: + return [TextContent(type="text", text="Error: add_directory needs a directory path")] + directory = os.path.expanduser(directory) + if not os.path.isdir(directory): + return [TextContent(type="text", text=f"Error: Directory not found: {directory}")] + if not _rag_manager: + return [TextContent(type="text", text="Error: RAG manager not available")] + try: + result = _rag_manager.index_personal_documents(directory) + indexed = result.get("indexed_count", 0) if isinstance(result, dict) else 0 + return [TextContent(type="text", text=f"Directory '{directory}' added to RAG index ({indexed} chunks indexed)")] + except Exception as e: + return [TextContent(type="text", text=f"Error: Failed to index directory: {e}")] + + elif action == "remove_directory": + directory = arguments.get("directory", "").strip() + if not directory: + return [TextContent(type="text", text="Error: remove_directory needs a directory path")] + if not _personal_docs_manager: + return [TextContent(type="text", text="Error: Personal docs manager not available")] + try: + if hasattr(_personal_docs_manager, 'remove_directory'): + _personal_docs_manager.remove_directory(directory) + if _rag_manager and hasattr(_rag_manager, 'remove_directory'): + _rag_manager.remove_directory(directory) + return [TextContent(type="text", text=f"Directory '{directory}' removed from RAG index")] + except Exception as e: + return [TextContent(type="text", text=f"Error: Failed to remove directory: {e}")] + + else: + return [TextContent(type="text", text=f"Error: Unknown action '{action}'. Use: list, add_directory, remove_directory")] + + +async def run(): + async with stdio_server() as (read_stream, write_stream): + await server.run(read_stream, write_stream, server.create_initialization_options()) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/odysseus-ui.service b/odysseus-ui.service new file mode 100644 index 0000000..fea4363 --- /dev/null +++ b/odysseus-ui.service @@ -0,0 +1,18 @@ +# Copy to /etc/systemd/system/odysseus-ui.service +# Edit User, WorkingDirectory, and ExecStart paths to match your setup +[Unit] +Description=Odysseus UI +After=network.target + +[Service] +Type=simple +# CHANGE THESE to match your user and install path: +User=YOURUSER +WorkingDirectory=/home/YOURUSER/odysseus-ui +ExecStart=/home/YOURUSER/odysseus-ui/venv/bin/uvicorn app:app --port 8000 --host 0.0.0.0 +Restart=always +RestartSec=3 +EnvironmentFile=-/home/YOURUSER/odysseus-ui/.env + +[Install] +WantedBy=multi-user.target diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..80eac7e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,93 @@ +{ + "name": "odysseus-ui", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@anthropic-ai/sdk": "^0.98.0" + }, + "devDependencies": { + "@antithesishq/bombadil": "^0.3.2" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.98.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.98.0.tgz", + "integrity": "sha512-N7aXtCvC5g6T1Y4V29lJjceu/zTkVkIZF0jdBvagr0TRFHuKeImffalGWEfqZKrvjH+IQbzJWw6TmSmUzrlMgg==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@antithesishq/bombadil": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.3.2.tgz", + "integrity": "sha512-ATy1w9ZY5gbny1H8DFc7rxZitT7DLLLFDiGcRZe+8TQiUrV5tLO+IJGOVNNLp3RpCqjZqSsxGiKoQsx31ipV1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c14f9ab --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "devDependencies": { + "@antithesishq/bombadil": "^0.3.2" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.98.0" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..116b137 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/requirements-optional.txt b/requirements-optional.txt new file mode 100644 index 0000000..72d9f7e --- /dev/null +++ b/requirements-optional.txt @@ -0,0 +1,17 @@ +# Optional dependencies — install only if you use the corresponding feature. +# The app handles their absence gracefully (clear error message on first use). +# +# Note: chromadb-client + fastembed moved to requirements.txt — RAG, semantic +# memory, and tool selection are core paths, so they ship by default now. + +# DuckDuckGo as a search provider option. +# Install if you want DDG in the search-provider dropdown. +# Alternatives: SearXNG, Brave, Tavily, Serper, Google PSE. +duckduckgo-search + +# PDF form-filling feature (fillable AcroForm detection, field extraction, +# value/annotation/signature stamping, page rendering for the form overlay). +# NOTE: PyMuPDF is AGPL-3.0. Installing it brings AGPL obligations for a +# network-served app — see ACKNOWLEDGMENTS.md. The MIT core (PDF *text* +# extraction via pypdf) works without it; this only unlocks form-filling. +PyMuPDF diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1bf1e9b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,37 @@ +fastapi +uvicorn +python-multipart +python-dotenv +httpx +pydantic +pydantic-settings +SQLAlchemy +pypdf +beautifulsoup4 +charset-normalizer +numpy +# Vector store + local embeddings for RAG, semantic memory, and tool +# selection. Used on core agent paths, so installed by default — the app +# still degrades to keyword fallback if they're ever missing. +# chromadb-client is the lightweight HTTP client (talks to a standalone +# ChromaDB service); fastembed runs local ONNX embeddings. +chromadb-client +fastembed +youtube-transcript-api +# Markdown rendering for research reports (src/visual_report.py). +# Imported at module-top so it's a hard core dep, not optional. +markdown +# Calendar .ics import/export (routes/calendar_routes.py). +icalendar +# CalDAV sync (src/caldav_sync.py). Handles PROPFIND discovery + REPORT +# fetch across Radicale, Nextcloud, Apple, Fastmail; we'd be reinventing +# the protocol without it. +caldav +cryptography +bcrypt +mcp +pyotp +qrcode[pil] +croniter +pytest +pytest-asyncio diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routes/admin_wipe_routes.py b/routes/admin_wipe_routes.py new file mode 100644 index 0000000..89d8ed0 --- /dev/null +++ b/routes/admin_wipe_routes.py @@ -0,0 +1,174 @@ +"""Admin Danger Zone — per-category wipes. + +Each endpoint is admin-only and truncates exactly one domain so the +user can selectively reset memory / skills / notes / etc. without +nuking everything. The catch-all `chats` endpoint mirrors the +existing /api/sessions/all so the Danger Zone speaks one URL pattern. + +URL shape: DELETE /api/admin/wipe/{kind} +Kinds: chats, memory, skills, notes, tasks, documents, gallery, calendar. +""" + +import json +import logging +import os +import shutil +from fastapi import APIRouter, HTTPException, Request + +from core.middleware import require_admin +from core.database import ( + SessionLocal, + Session as DbSession, + ChatMessage as DbChatMessage, + Memory, + Note, + ScheduledTask, + TaskRun, + Document, + DocumentVersion, + GalleryImage, + CalendarEvent, + CalendarCal, +) +from src.constants import DATA_DIR + +logger = logging.getLogger(__name__) + + +def _wipe_memory_files(): + """Blank memory.json + drop the per-owner tidy-state sidecar so the + next audit doesn't try to diff against gone memories.""" + for name in ("memory.json", "memory_tidy_state.json"): + p = os.path.join(DATA_DIR, name) + if not os.path.exists(p): + continue + try: + if name == "memory.json": + with open(p, "w") as f: + json.dump([], f) + else: + os.remove(p) + except OSError as e: + logger.warning(f"Could not reset {name}: {e}") + + +def _rmtree_quiet(path: str): + """rmtree that doesn't crash if the path doesn't exist.""" + if os.path.isdir(path): + try: + shutil.rmtree(path) + except OSError as e: + logger.warning(f"Could not remove {path}: {e}") + + +def setup_admin_wipe_routes(session_manager): + """The session_manager is passed in so we can also clear its + in-memory cache when wiping chats — without it the DB is empty + but the next /api/sessions returns stale entries.""" + router = APIRouter(prefix="/api/admin") + + @router.delete("/wipe/{kind}") + def wipe(kind: str, request: Request): + require_admin(request) + kind = (kind or "").strip().lower() + + db = SessionLocal() + try: + if kind == "chats": + count = db.query(DbSession).count() + db.query(DbChatMessage).delete() + db.query(DbSession).delete() + db.commit() + try: + session_manager.sessions.clear() + except Exception: + pass + return {"status": "deleted", "kind": kind, "count": count} + + if kind == "memory": + count = db.query(Memory).count() + db.query(Memory).delete() + db.commit() + _wipe_memory_files() + # Drop the vector store too so semantic search doesn't + # return ghosts. Lazy import — chromadb may not be + # initialised in every deployment. + try: + from src.memory_vector import get_memory_vector_store + mv = get_memory_vector_store() + if mv and hasattr(mv, "clear"): + mv.clear() + except Exception as e: + logger.info(f"Memory vector clear skipped: {e}") + return {"status": "deleted", "kind": kind, "count": count} + + if kind == "skills": + # Skills live as SKILL.md files under data/skills/. Drop + # the entire directory; the SkillsManager re-creates the + # tree on next write. + skills_dir = os.path.join(DATA_DIR, "skills") + count = 0 + if os.path.isdir(skills_dir): + # Count SKILL.md files for the response — quick walk. + for _, _, files in os.walk(skills_dir): + count += sum(1 for f in files if f == "SKILL.md") + _rmtree_quiet(skills_dir) + # Legacy fallback file + legacy = os.path.join(DATA_DIR, "skills.json") + if os.path.exists(legacy): + try: + os.remove(legacy) + except OSError: + pass + return {"status": "deleted", "kind": kind, "count": count} + + if kind == "notes": + count = db.query(Note).count() + db.query(Note).delete() + db.commit() + return {"status": "deleted", "kind": kind, "count": count} + + if kind == "tasks": + # TaskRun rows reference tasks via FK — clear them first. + db.query(TaskRun).delete() + count = db.query(ScheduledTask).count() + db.query(ScheduledTask).delete() + db.commit() + return {"status": "deleted", "kind": kind, "count": count} + + if kind == "documents": + # DocumentVersion FKs Document — clear children first. + db.query(DocumentVersion).delete() + count = db.query(Document).count() + db.query(Document).delete() + db.commit() + return {"status": "deleted", "kind": kind, "count": count} + + if kind == "gallery": + count = db.query(GalleryImage).count() + db.query(GalleryImage).delete() + db.commit() + # Also drop the upload dir so disk doesn't keep orphans. + _rmtree_quiet(os.path.join(DATA_DIR, "gallery")) + _rmtree_quiet(os.path.join(DATA_DIR, "gallery_uploads")) + return {"status": "deleted", "kind": kind, "count": count} + + if kind == "calendar": + # Events FK calendars — clear children first, then both. + db.query(CalendarEvent).delete() + count = db.query(CalendarCal).count() + db.query(CalendarCal).delete() + db.commit() + return {"status": "deleted", "kind": kind, "count": count} + + raise HTTPException(400, f"Unknown wipe kind: {kind!r}") + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.exception(f"Wipe {kind} failed") + raise HTTPException(500, f"Wipe {kind} failed: {e}") + finally: + db.close() + + return router diff --git a/routes/api_token_routes.py b/routes/api_token_routes.py new file mode 100644 index 0000000..ba412a4 --- /dev/null +++ b/routes/api_token_routes.py @@ -0,0 +1,91 @@ +"""API Token management routes — /api/tokens/*.""" + +import secrets +import uuid + +import bcrypt +from fastapi import APIRouter, HTTPException, Request, Form + +from core.database import get_db_session, ApiToken +from core.middleware import require_admin +from src.auth_helpers import get_current_user + +MAX_NAME_LEN = 100 +DEFAULT_SCOPES = "chat" + + +def setup_api_token_routes() -> APIRouter: + router = APIRouter(prefix="/api", tags=["api_tokens"]) + + @router.get("/tokens") + def list_tokens(request: Request): + require_admin(request) + with get_db_session() as db: + tokens = db.query(ApiToken).all() + return [ + { + "id": t.id, + "name": t.name, + "owner": getattr(t, "owner", None), + "token_prefix": t.token_prefix, + "scopes": [s.strip() for s in (getattr(t, "scopes", "") or DEFAULT_SCOPES).split(",") if s.strip()], + "is_active": t.is_active, + "last_used_at": t.last_used_at.isoformat() if t.last_used_at else None, + "created_at": t.created_at.isoformat() if t.created_at else None, + } + for t in tokens + ] + + def _invalidate_cache(request: Request): + """Tell the auth middleware its cached token map is stale.""" + try: + invalidator = getattr(request.app.state, "invalidate_token_cache", None) + if invalidator: + invalidator() + except Exception: + pass + + @router.post("/tokens") + def create_token(request: Request, name: str = Form("")): + require_admin(request) + name = name.strip()[:MAX_NAME_LEN] + if not name: + raise HTTPException(400, "Token name is required") + owner = get_current_user(request) + + raw_token = "ody_" + secrets.token_urlsafe(32) + token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode() + token_id = str(uuid.uuid4())[:8] + + with get_db_session() as db: + db.add(ApiToken( + id=token_id, + owner=owner, + name=name, + token_hash=token_hash, + token_prefix=raw_token[:8], + scopes=DEFAULT_SCOPES, + is_active=True, + )) + _invalidate_cache(request) + + return { + "id": token_id, + "name": name, + "owner": owner, + "token": raw_token, + "token_prefix": raw_token[:8], + "scopes": DEFAULT_SCOPES.split(","), + } + + @router.delete("/tokens/{token_id}") + def delete_token(request: Request, token_id: str): + require_admin(request) + with get_db_session() as db: + deleted = db.query(ApiToken).filter(ApiToken.id == token_id).delete() + if not deleted: + raise HTTPException(404, "Token not found") + _invalidate_cache(request) + return {"status": "deleted"} + + return router diff --git a/routes/assistant_routes.py b/routes/assistant_routes.py new file mode 100644 index 0000000..17c5016 --- /dev/null +++ b/routes/assistant_routes.py @@ -0,0 +1,325 @@ +"""Personal assistant routes — resolve the per-user singleton, read/write +its settings, and list its scheduled check-in tasks. + +The personal assistant is just a specially-flagged CrewMember that owns one +pinned Session and three daily ScheduledTasks ("Morning/Midday/Evening +check-in"). Everything about it is user-editable: name, personality, model, +enabled tools, timezone, and the three check-in times/prompts/enabled flags. +""" + +import json +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from core.database import SessionLocal, CrewMember, ScheduledTask +from src.auth_helpers import get_current_user +from src.task_scheduler import compute_next_run + + +class CheckInUpdate(BaseModel): + id: str # ScheduledTask.id + name: Optional[str] = None + scheduled_time: Optional[str] = None # "HH:MM" + prompt: Optional[str] = None + enabled: Optional[bool] = None # maps to status "active"/"paused" + + +class AssistantSettingsUpdate(BaseModel): + name: Optional[str] = None + avatar: Optional[str] = None + personality: Optional[str] = None + model: Optional[str] = None + endpoint_url: Optional[str] = None + enabled_tools: Optional[list[str]] = None + allow_autonomous_email: Optional[bool] = None # convenience toggle + timezone: Optional[str] = None + check_ins: Optional[list[CheckInUpdate]] = None + + +_EMAIL_TOOLS = {"send_email", "reply_to_email"} + + +def _crew_to_dict(c: CrewMember) -> dict: + try: + tools = json.loads(c.enabled_tools) if c.enabled_tools else [] + except Exception: + tools = [] + return { + "id": c.id, + "name": c.name, + "avatar": c.avatar, + "personality": c.personality, + "model": c.model, + "endpoint_url": c.endpoint_url, + "greeting": c.greeting, + "enabled_tools": tools, + "session_id": c.session_id, + "is_default_assistant": bool(c.is_default_assistant), + "timezone": c.timezone, + "allow_autonomous_email": any(t in _EMAIL_TOOLS for t in tools), + } + + +def _task_to_checkin_dict(t: ScheduledTask) -> dict: + return { + "id": t.id, + "name": t.name, + "scheduled_time": t.scheduled_time, + "prompt": t.prompt, + "enabled": (t.status or "active") == "active", + "next_run": t.next_run.isoformat() + "Z" if t.next_run else None, + "last_run": t.last_run.isoformat() + "Z" if t.last_run else None, + "run_count": t.run_count or 0, + } + + +def setup_assistant_routes(task_scheduler) -> APIRouter: + router = APIRouter(prefix="/api/assistant", tags=["assistant"]) + + def _owner(request: Request) -> str: + owner = get_current_user(request) + if not owner: + raise HTTPException(status_code=401, detail="Not authenticated") + return owner + + # Synthetic / non-human owners that should NEVER get an assistant + + # check-in tasks seeded. Hitting any /assistant route under one of these + # used to seed a full CrewMember + Morning/Midday/Evening tasks under that + # owner, which then double-fired alongside the real user's check-ins. + _SYNTHETIC_OWNERS = frozenset({"internal-tool", "api", "demo", "system", ""}) + + async def _get_or_create(owner: str) -> CrewMember: + """Return the per-owner assistant CrewMember, creating it on demand.""" + if not owner or owner in _SYNTHETIC_OWNERS: + raise HTTPException(status_code=400, detail=f"Cannot seed assistant for {owner!r}") + db = SessionLocal() + try: + crew = db.query(CrewMember).filter( + CrewMember.owner == owner, + CrewMember.is_default_assistant == True, # noqa: E712 + ).first() + if crew: + return crew + finally: + db.close() + # Seed lazily. This is the same code the startup hook runs for each + # user — safe to call again, it's idempotent. + await task_scheduler.ensure_assistant_defaults(owner) + db = SessionLocal() + try: + crew = db.query(CrewMember).filter( + CrewMember.owner == owner, + CrewMember.is_default_assistant == True, # noqa: E712 + ).first() + return crew + finally: + db.close() + + @router.get("/session") + async def get_assistant_session(request: Request): + """Resolve (or lazily create) the pinned Assistant session for this user.""" + owner = _owner(request) + crew = await _get_or_create(owner) + if not crew or not crew.session_id: + raise HTTPException(status_code=500, detail="Assistant session could not be resolved") + return { + "session_id": crew.session_id, + "crew_member_id": crew.id, + "name": crew.name, + } + + @router.get("/settings") + async def get_assistant_settings(request: Request): + """Return CrewMember fields + the three check-in task rows + task IDs for logs.""" + owner = _owner(request) + crew = await _get_or_create(owner) + if not crew: + raise HTTPException(status_code=500, detail="Assistant not available") + db = SessionLocal() + try: + tasks = db.query(ScheduledTask).filter( + ScheduledTask.owner == owner, + ScheduledTask.crew_member_id == crew.id, + ).order_by(ScheduledTask.scheduled_time.asc()).all() + return { + "crew": _crew_to_dict(crew), + "check_ins": [_task_to_checkin_dict(t) for t in tasks], + "task_ids": [t.id for t in tasks], + } + finally: + db.close() + + @router.patch("/settings") + async def update_assistant_settings(payload: AssistantSettingsUpdate, request: Request): + """Update CrewMember fields and/or check-in tasks in one call.""" + owner = _owner(request) + crew = await _get_or_create(owner) + if not crew: + raise HTTPException(status_code=500, detail="Assistant not available") + + db = SessionLocal() + try: + crew_db = db.query(CrewMember).filter(CrewMember.id == crew.id).first() + if not crew_db: + raise HTTPException(status_code=404, detail="Assistant not found") + + # Update CrewMember fields. + if payload.name is not None: + crew_db.name = payload.name.strip() or crew_db.name + if payload.avatar is not None: + crew_db.avatar = payload.avatar + if payload.personality is not None: + crew_db.personality = payload.personality + if payload.model is not None: + crew_db.model = payload.model or None + if payload.endpoint_url is not None: + crew_db.endpoint_url = payload.endpoint_url or None + if payload.timezone is not None: + crew_db.timezone = payload.timezone or None + + # Tool list: either explicit list, or implicit toggle. + if payload.enabled_tools is not None: + crew_db.enabled_tools = json.dumps(payload.enabled_tools) + if payload.allow_autonomous_email is not None: + try: + existing = json.loads(crew_db.enabled_tools) if crew_db.enabled_tools else [] + except Exception: + existing = [] + if payload.allow_autonomous_email: + for t in ("send_email", "reply_to_email"): + if t not in existing: + existing.append(t) + else: + existing = [t for t in existing if t not in _EMAIL_TOOLS] + crew_db.enabled_tools = json.dumps(existing) + + crew_db.updated_at = datetime.utcnow() + + # Update check-in tasks. + if payload.check_ins: + now_utc = datetime.utcnow() + tz_name = crew_db.timezone or None + for ci in payload.check_ins: + task = db.query(ScheduledTask).filter( + ScheduledTask.id == ci.id, + ScheduledTask.owner == owner, + ScheduledTask.crew_member_id == crew_db.id, + ).first() + if not task: + continue + if ci.name is not None: + task.name = ci.name.strip() or task.name + time_changed = False + if ci.scheduled_time is not None and ci.scheduled_time != task.scheduled_time: + task.scheduled_time = ci.scheduled_time + time_changed = True + if ci.prompt is not None: + task.prompt = ci.prompt + if ci.enabled is not None: + task.status = "active" if ci.enabled else "paused" + if time_changed or ci.enabled is True: + task.next_run = compute_next_run( + task.schedule or "daily", + task.scheduled_time, + task.scheduled_day, + task.scheduled_date, + after=now_utc, + cron_expression=task.cron_expression, + tz_name=tz_name, + ) + task.updated_at = datetime.utcnow() + + # Timezone change also shifts the NEXT run of all check-ins even if + # the user didn't touch the time fields. + if payload.timezone is not None: + now_utc = datetime.utcnow() + tz_name = crew_db.timezone or None + tasks = db.query(ScheduledTask).filter( + ScheduledTask.owner == owner, + ScheduledTask.crew_member_id == crew_db.id, + ).all() + for t in tasks: + if t.schedule and t.scheduled_time: + t.next_run = compute_next_run( + t.schedule, t.scheduled_time, t.scheduled_day, t.scheduled_date, + after=now_utc, cron_expression=t.cron_expression, tz_name=tz_name, + ) + + db.commit() + + # Re-read crew_db + tasks to return the fresh state. + crew_out = db.query(CrewMember).filter(CrewMember.id == crew.id).first() + tasks_out = db.query(ScheduledTask).filter( + ScheduledTask.owner == owner, + ScheduledTask.crew_member_id == crew.id, + ).order_by(ScheduledTask.scheduled_time.asc()).all() + return { + "crew": _crew_to_dict(crew_out), + "check_ins": [_task_to_checkin_dict(t) for t in tasks_out], + "task_ids": [t.id for t in tasks_out], + } + finally: + db.close() + + @router.post("/run/{task_id}") + async def run_check_in_now(task_id: str, request: Request): + """Trigger one of the assistant's check-ins immediately (manual test).""" + owner = _owner(request) + db = SessionLocal() + try: + task = db.query(ScheduledTask).filter( + ScheduledTask.id == task_id, + ScheduledTask.owner == owner, + ).first() + if not task: + raise HTTPException(status_code=404, detail="Task not found") + crew = db.query(CrewMember).filter( + CrewMember.id == task.crew_member_id, + CrewMember.is_default_assistant == True, # noqa: E712 + ).first() + if not crew: + raise HTTPException(status_code=400, detail="Not an assistant task") + finally: + db.close() + started = await task_scheduler.run_task_now(task_id) + return {"started": bool(started)} + + @router.get("/run-status/{task_id}") + async def run_status(task_id: str, request: Request): + """Check whether the most recent run of a task has finished.""" + from core.database import TaskRun, ScheduledTask + user = _owner(request) + db = SessionLocal() + try: + # SECURITY: 404 if the task doesn't belong to this user — without + # this any authenticated user could poll the status of any task_id. + task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first() + if not task: + raise HTTPException(404, "Task not found") + if user and task.owner != user: + raise HTTPException(404, "Task not found") + run = db.query(TaskRun).filter( + TaskRun.task_id == task_id, + ).order_by(TaskRun.started_at.desc()).first() + if not run: + return {"status": "unknown"} + if run.status == "running": + return {"status": "running"} + return {"status": "done", "result_status": run.status} + finally: + db.close() + + @router.get("/available-timezones") + async def list_timezones(): + """Return the IANA tz name list used to populate the settings dropdown.""" + try: + from zoneinfo import available_timezones + zones = sorted(available_timezones()) + except Exception: + zones = ["UTC"] + return {"timezones": zones} + + return router diff --git a/routes/auth_routes.py b/routes/auth_routes.py new file mode 100644 index 0000000..3af7b4a --- /dev/null +++ b/routes/auth_routes.py @@ -0,0 +1,502 @@ +"""Authentication routes — login, logout, signup, status, user management.""" + +from fastapi import APIRouter, Request, Response, HTTPException +from pydantic import BaseModel +from typing import Optional +import logging +import os + +from core.auth import AuthManager +from src.rate_limiter import RateLimiter +from src.settings import ( + load_settings as _load_settings, + save_settings as _save_settings, + load_features as _load_features, + save_features as _save_features, + DEFAULT_SETTINGS, +) +from src.integrations import ( + load_integrations, + add_integration, + update_integration, + delete_integration, + get_integration, + execute_api_call, + INTEGRATION_PRESETS, + migrate_from_settings, +) + +logger = logging.getLogger(__name__) + + +class LoginRequest(BaseModel): + username: str + password: str + remember: bool = True + totp_code: Optional[str] = None + + +class SetupRequest(BaseModel): + username: str + password: str + + +class SignupRequest(BaseModel): + username: str + password: str + + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + + +class CreateUserRequest(BaseModel): + username: str + password: str + is_admin: bool = False + + +class DeleteUserRequest(BaseModel): + username: str + + +SESSION_COOKIE = "odysseus_session" + + +def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: + router = APIRouter(prefix="/api/auth", tags=["auth"]) + + _login_limiter = RateLimiter(max_requests=15, window_seconds=60) + _signup_limiter = RateLimiter(max_requests=3, window_seconds=300) + _setup_limiter = RateLimiter(max_requests=3, window_seconds=300) + + def _get_current_user(request: Request) -> Optional[str]: + token = request.cookies.get(SESSION_COOKIE) + return auth_manager.get_username_for_token(token) + + @router.post("/setup") + async def first_run_setup(body: SetupRequest, request: Request): + """Create initial admin account. Only works if no accounts exist.""" + if not _setup_limiter.check(request.client.host): + raise HTTPException(429, "Too many requests — try again later") + if auth_manager.is_configured: + raise HTTPException(400, "Already configured") + if len(body.password) < 8: + raise HTTPException(400, "Password must be at least 8 characters") + ok = auth_manager.setup(body.username, body.password) + if not ok: + raise HTTPException(500, "Setup failed") + return {"ok": True, "message": "Admin account created"} + + @router.post("/signup") + async def signup(body: SignupRequest, request: Request): + """Create a new user account. Only works if signup is enabled by admin.""" + if not _signup_limiter.check(request.client.host): + raise HTTPException(429, "Too many requests — try again later") + if not auth_manager.is_configured: + raise HTTPException(400, "Run setup first") + if not auth_manager.signup_enabled: + raise HTTPException(403, "Registration is disabled. Ask an admin for an account.") + if len(body.password) < 8: + raise HTTPException(400, "Password must be at least 8 characters") + if len(body.username.strip()) < 1: + raise HTTPException(400, "Username is required") + ok = auth_manager.create_user(body.username, body.password, is_admin=False) + if not ok: + raise HTTPException(409, "Username already taken") + return {"ok": True, "message": "Account created"} + + @router.post("/login") + async def login(body: LoginRequest, request: Request, response: Response): + if not _login_limiter.check(request.client.host): + raise HTTPException(429, "Too many requests — try again later") + # Verify password first + username = body.username.strip().lower() + if not auth_manager.verify_password(username, body.password): + raise HTTPException(401, "Invalid credentials") + # Check 2FA if enabled + if auth_manager.totp_enabled(username): + if not body.totp_code: + # Password OK but need TOTP — tell client to show code input + return {"ok": False, "requires_totp": True, "username": username} + if not auth_manager.totp_verify(username, body.totp_code): + raise HTTPException(401, "Invalid 2FA code") + # All checks passed — create session + token = auth_manager.create_session(username, body.password) + if not token: + raise HTTPException(401, "Invalid credentials") + cookie_kwargs = dict( + key=SESSION_COOKIE, + value=token, + httponly=True, + samesite="lax", + secure=os.getenv("SECURE_COOKIES", "false").lower() == "true", + path="/", + ) + if body.remember: + cookie_kwargs["max_age"] = 60 * 60 * 24 * 7 # 7 days + response.set_cookie(**cookie_kwargs) + return {"ok": True, "username": username} + + @router.post("/logout") + async def logout(request: Request, response: Response): + token = request.cookies.get(SESSION_COOKIE) + if token: + auth_manager.revoke_token(token) + response.delete_cookie(SESSION_COOKIE, path="/") + return {"ok": True} + + @router.get("/status") + async def auth_status(request: Request): + token = request.cookies.get(SESSION_COOKIE) + result = auth_manager.status(token) + result["signup_enabled"] = auth_manager.signup_enabled + # Include the caller's effective privileges so the frontend can + # hide / dim UI controls the user isn't allowed to use. Admins get + # ADMIN_PRIVILEGES (everything on), regular users get their stored + # set merged with DEFAULT_PRIVILEGES. + try: + u = result.get("username") + if u: + result["privileges"] = auth_manager.get_privileges(u) + except Exception: + pass + return result + + @router.post("/change-password") + async def change_password(body: ChangePasswordRequest, request: Request): + user = _get_current_user(request) + if not user: + raise HTTPException(401, "Not authenticated") + if len(body.new_password) < 8: + raise HTTPException(400, "Password must be at least 8 characters") + ok = auth_manager.change_password(user, body.current_password, body.new_password) + if not ok: + raise HTTPException(400, "Current password is incorrect") + return {"ok": True} + + # ------------------------------------------------------------------ + # Two-factor authentication + # ------------------------------------------------------------------ + + @router.post("/2fa/setup") + async def totp_setup(request: Request): + """Generate a TOTP secret and return the QR code URI.""" + user = _get_current_user(request) + if not user: + raise HTTPException(401, "Not authenticated") + if auth_manager.totp_enabled(user): + raise HTTPException(400, "2FA is already enabled") + secret = auth_manager.totp_generate_secret(user) + if not secret: + raise HTTPException(500, "Failed to generate secret") + uri = auth_manager.totp_get_provisioning_uri(user, secret) + # Generate QR code as base64 PNG + import qrcode, io, base64 + qr = qrcode.make(uri, box_size=6, border=2) + buf = io.BytesIO() + qr.save(buf, format="PNG") + qr_b64 = base64.b64encode(buf.getvalue()).decode("ascii") + return {"secret": secret, "uri": uri, "qr_code": f"data:image/png;base64,{qr_b64}"} + + class TotpVerifyRequest(BaseModel): + code: str + + @router.post("/2fa/confirm") + async def totp_confirm(body: TotpVerifyRequest, request: Request): + """Verify a TOTP code to confirm 2FA setup. Returns backup codes.""" + user = _get_current_user(request) + if not user: + raise HTTPException(401, "Not authenticated") + if not auth_manager.totp_confirm_enable(user, body.code): + raise HTTPException(400, "Invalid code — try again") + backup = auth_manager.users.get(user, {}).get("totp_backup_codes", []) + return {"ok": True, "backup_codes": backup} + + class TotpDisableRequest(BaseModel): + password: str + + @router.post("/2fa/disable") + async def totp_disable(body: TotpDisableRequest, request: Request): + """Disable 2FA. Requires password confirmation.""" + user = _get_current_user(request) + if not user: + raise HTTPException(401, "Not authenticated") + if not auth_manager.totp_disable(user, body.password): + raise HTTPException(400, "Invalid password") + return {"ok": True} + + @router.get("/2fa/status") + async def totp_status(request: Request): + """Check if 2FA is enabled for the current user.""" + user = _get_current_user(request) + if not user: + raise HTTPException(401, "Not authenticated") + return {"enabled": auth_manager.totp_enabled(user)} + + # Admin-only routes + @router.get("/users") + async def list_users(request: Request): + user = _get_current_user(request) + if not user or not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + return {"users": auth_manager.list_users()} + + @router.post("/users") + async def admin_create_user(body: CreateUserRequest, request: Request): + user = _get_current_user(request) + if not user or not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + if len(body.password) < 8: + raise HTTPException(400, "Password must be at least 8 characters") + ok = auth_manager.create_user(body.username, body.password, body.is_admin) + if not ok: + raise HTTPException(409, "Username already taken") + return {"ok": True} + + @router.put("/users/{username}/privileges") + async def update_user_privileges(username: str, request: Request): + user = _get_current_user(request) + if not user or not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + body = await request.json() + ok = auth_manager.set_privileges(username, body) + if not ok: + raise HTTPException(404, "User not found or is admin") + return {"ok": True, "privileges": auth_manager.get_privileges(username)} + + @router.post("/signup-toggle") + async def toggle_signup(request: Request): + """Toggle open registration on/off. Admin only.""" + user = _get_current_user(request) + if not user or not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + auth_manager.signup_enabled = not auth_manager.signup_enabled + return {"ok": True, "signup_enabled": auth_manager.signup_enabled} + + @router.delete("/users") + async def admin_delete_user(body: DeleteUserRequest, request: Request): + user = _get_current_user(request) + if not user or not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + ok = auth_manager.delete_user(body.username, user) + if not ok: + raise HTTPException(400, "Cannot delete user") + return {"ok": True} + + # ---- Feature visibility (admin-managed) ---- + + @router.get("/features") + async def get_features(): + """Public: returns which UI features are enabled.""" + return _load_features() + + @router.post("/features") + async def set_features(request: Request): + """Admin only: update feature toggles.""" + user = _get_current_user(request) + if not user or not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + body = await request.json() + current = _load_features() + for key in current: + if key in body and isinstance(body[key], bool): + current[key] = body[key] + _save_features(current) + return current + + # ---- App settings (admin-managed) ---- + + _SECRET_KEY_PATTERNS = ("_api_key", "_password", "_secret", "_token", "_key") + + def _is_secret_key(name: str) -> bool: + n = (name or "").lower() + if n in ("google_pse_cx",): # public identifier, not a secret + return False + return any(n.endswith(p) or n == p.lstrip("_") for p in _SECRET_KEY_PATTERNS) + + def _scrub_settings(settings: dict) -> dict: + """Return a copy of settings with secret-shaped values masked. + + Frontend reads /settings without auth for things like keybinds + TTS + prefs. Secrets (search-provider keys, IMAP/SMTP passwords) must NOT + be exposed to non-admin callers. + """ + scrubbed = {} + for k, v in (settings or {}).items(): + if _is_secret_key(k) and isinstance(v, str) and v: + scrubbed[k] = "" # presence preserved, value blanked + else: + scrubbed[k] = v + return scrubbed + + @router.get("/settings") + async def get_settings(request: Request): + """Returns app settings. Admins get the full set; non-admins get + a scrubbed copy with secret keys blanked. The frontend uses this + for keybinds + TTS prefs, so it stays callable without admin.""" + user = _get_current_user(request) + settings = _load_settings() + if user and auth_manager.is_admin(user): + return settings + return _scrub_settings(settings) + + @router.post("/settings") + async def set_settings(request: Request): + """Admin only: update app settings.""" + user = _get_current_user(request) + if not user or not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + body = await request.json() + current = _load_settings() + for key in DEFAULT_SETTINGS: + if key in body: + current[key] = body[key] + _save_settings(current) + return current + + # ---- Integrations CRUD ---- + + # Run migration on startup + migrate_from_settings() + + @router.get("/integrations") + async def list_integrations_route(request: Request): + """List all integrations (admin only, keys masked).""" + user = _get_current_user(request) + if not user or not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + items = load_integrations() + # Mask API keys for frontend display + safe = [] + for item in items: + copy = dict(item) + if copy.get("api_key"): + copy["api_key"] = copy["api_key"][:4] + "****" + safe.append(copy) + return {"integrations": safe} + + @router.get("/integrations/presets") + async def list_presets(): + """List available integration presets.""" + return {"presets": {k: {kk: vv for kk, vv in v.items() if kk != "api_key"} for k, v in INTEGRATION_PRESETS.items()}} + + @router.post("/integrations") + async def create_integration(request: Request): + """Create a new integration (admin only).""" + user = _get_current_user(request) + if not user or not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + body = await request.json() + item = add_integration(body) + return {"ok": True, "integration": item} + + @router.put("/integrations/{integration_id}") + async def update_integration_route(integration_id: str, request: Request): + """Update an existing integration (admin only).""" + user = _get_current_user(request) + if not user or not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + body = await request.json() + item = update_integration(integration_id, body) + if not item: + raise HTTPException(404, "Integration not found") + return {"ok": True, "integration": item} + + @router.delete("/integrations/{integration_id}") + async def delete_integration_route(integration_id: str, request: Request): + """Delete an integration (admin only).""" + user = _get_current_user(request) + if not user or not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + ok = delete_integration(integration_id) + if not ok: + raise HTTPException(404, "Integration not found") + return {"ok": True} + + @router.post("/integrations/{integration_id}/test") + async def test_integration_route(integration_id: str, request: Request): + """Test connectivity to an integration (admin only).""" + user = _get_current_user(request) + if not user or not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + integ = get_integration(integration_id) + if not integ: + raise HTTPException(404, "Integration not found") + preset = (integ.get("preset") or integ.get("name", "")).lower() + + # ntfy is special: a GET / proves the server is reachable but + # publishes nothing, so the user has no way to know whether + # subscribers will actually receive notifications. Instead, do + # the real thing — POST a one-line "connectivity test" message + # to the topic the Reminders panel is configured to use. If the + # subscriber app is wired up correctly, this is what the green + # checkmark + a phone ping confirms together. + if preset == "ntfy": + import httpx + from urllib.parse import urlparse + # Strip any path/query the user accidentally pasted in the + # base URL (e.g. `http://host:8091/odysseus`) — otherwise + # the topic gets appended after the path and we publish to + # `/odysseus/odysseus` (which ntfy 404s on). ntfy itself + # only ever serves from the root. + raw_base = (integ.get("base_url") or "").strip() + parsed = urlparse(raw_base) + base = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else raw_base.rstrip("/") + settings = _load_settings() + topic = (settings.get("reminder_ntfy_topic") or "reminders").strip() or "reminders" + full_url = f"{base}/{topic}" + api_key = integ.get("api_key", "") + auth_type = (integ.get("auth_type") or "none").lower() + headers = { + "Title": "Odysseus connectivity test", + "Tags": "white_check_mark", + "Priority": "default", + } + if api_key: + if auth_type == "bearer": + headers["Authorization"] = f"Bearer {api_key}" + elif auth_type == "header": + headers[integ.get("auth_header") or "Authorization"] = api_key + try: + async with httpx.AsyncClient(timeout=8.0) as client: + r = await client.post( + full_url, + content="Connectivity test from Odysseus. If you see this on your phone, ntfy is wired up correctly.", + headers=headers, + ) + if r.is_success: + # Tell the user EXACTLY where it went and what to + # subscribe to on their phone, so they can match + # without guesswork. The doubled-topic / wrong-host + # mistakes are easier to spot when the actual URL + # is right there in the success line. + return { + "ok": True, + "message": ( + f"Sent to {full_url} — on your ntfy app, " + f"subscribe to topic \"{topic}\" with server " + f"\"{base}\" (or paste the full URL: {full_url})." + ), + } + return {"ok": False, "message": f"ntfy returned HTTP {r.status_code} from {full_url}: {r.text[:200]}"} + except Exception as e: + return {"ok": False, "message": f"ntfy publish to {full_url} failed: {e}"[:300]} + + # All other presets: GET against a known health endpoint. + # Fall back to detecting from name if preset is missing. + health_paths = { + "miniflux": "/v1/me", + "gitea": "/api/v1/version", + "linkding": "/api/tags/", + "homeassistant": "/api/", + "home assistant": "/api/", + } + path = health_paths.get(preset, "/") + result = await execute_api_call(integration_id, "GET", path) + if result.get("exit_code", 1) == 0: + return {"ok": True, "message": "Connection successful"} + return {"ok": False, "message": (result.get("error") or "Connection failed")[:300]} + + return router diff --git a/routes/backup_routes.py b/routes/backup_routes.py new file mode 100644 index 0000000..b165fcc --- /dev/null +++ b/routes/backup_routes.py @@ -0,0 +1,157 @@ +"""Backup routes — export/import user data (memories, presets, settings, skills, preferences).""" + +import json +import logging +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Request, Response +from core.middleware import require_admin +from src.auth_helpers import get_current_user +from src.settings import load_settings, save_settings, load_features, save_features + +logger = logging.getLogger(__name__) + + +def setup_backup_routes(memory_manager, preset_manager, skills_manager) -> APIRouter: + router = APIRouter(tags=["backup"]) + + @router.get("/api/export") + async def export_data(request: Request): + """Export all user data as a downloadable JSON file.""" + require_admin(request) + user = get_current_user(request) + + # Memories (filtered by owner when auth is enabled) + memories = memory_manager.load(owner=user) + + # Presets (shared across users — export all) + presets = preset_manager.get_all() + + # Skills (filtered by owner when auth is enabled) + skills = skills_manager.load(owner=user) + + # Settings + settings = load_settings() + + # Feature flags + features = load_features() + + # User preferences + from routes.prefs_routes import _load_for_user + preferences = _load_for_user(user) + + export_data = { + "version": 1, + "exported_at": datetime.now().isoformat(), + "exported_by": user, + "memories": memories, + "presets": presets, + "skills": skills, + "settings": settings, + "features": features, + "preferences": preferences, + } + + filename = f"odysseus_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + return Response( + content=json.dumps(export_data, indent=2, ensure_ascii=False), + media_type="application/json", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + @router.post("/api/import") + async def import_data(request: Request): + """Import user data from a previously exported JSON file. Merges with existing data.""" + require_admin(request) + user = get_current_user(request) + try: + body = await request.json() + except Exception: + raise HTTPException(400, "Invalid JSON") + + if not isinstance(body, dict): + raise HTTPException(400, "Expected a JSON object") + + imported = [] + + # ── Memories ── + if "memories" in body and isinstance(body["memories"], list): + existing = memory_manager.load_all() + existing_texts = {e.get("text", "").strip().lower() for e in existing} + added = 0 + for mem in body["memories"]: + if not isinstance(mem, dict) or not mem.get("text"): + continue + if mem["text"].strip().lower() in existing_texts: + continue # skip duplicates + # Assign owner when auth is enabled + if user and not mem.get("owner"): + mem["owner"] = user + existing.append(mem) + existing_texts.add(mem["text"].strip().lower()) + added += 1 + memory_manager.save(existing) + imported.append(f"{added} memories") + + # ── Skills ── + if "skills" in body and isinstance(body["skills"], list): + existing = skills_manager.load_all() + existing_ids = {s.get("id") for s in existing} + existing_titles = {s.get("title", "").strip().lower() for s in existing} + added = 0 + for skill in body["skills"]: + if not isinstance(skill, dict) or not skill.get("title"): + continue + # Skip if same id or same title already exists + if skill.get("id") in existing_ids: + continue + if skill["title"].strip().lower() in existing_titles: + continue + if user and not skill.get("owner"): + skill["owner"] = user + existing.append(skill) + existing_ids.add(skill.get("id")) + existing_titles.add(skill["title"].strip().lower()) + added += 1 + skills_manager.save(existing) + imported.append(f"{added} skills") + + # ── Presets ── + if "presets" in body and isinstance(body["presets"], dict): + current = preset_manager.get_all() + for key, value in body["presets"].items(): + if isinstance(value, dict): + current[key] = value + elif isinstance(value, list): + current[key] = value + preset_manager.save(current) + imported.append("presets") + + # ── Settings ── + if "settings" in body and isinstance(body["settings"], dict): + current = load_settings() + current.update(body["settings"]) + save_settings(current) + imported.append("settings") + + # ── Features ── + if "features" in body and isinstance(body["features"], dict): + current = load_features() + current.update(body["features"]) + save_features(current) + imported.append("features") + + # ── Preferences ── + if "preferences" in body and isinstance(body["preferences"], dict): + from routes.prefs_routes import _load_for_user, _save_for_user + current = _load_for_user(user) + current.update(body["preferences"]) + _save_for_user(user, current) + imported.append("preferences") + + if not imported: + return {"ok": False, "message": "No recognized data found in the file"} + + return {"ok": True, "imported": imported, "message": f"Imported: {', '.join(imported)}"} + + return router diff --git a/routes/calendar_routes.py b/routes/calendar_routes.py new file mode 100644 index 0000000..284e6c6 --- /dev/null +++ b/routes/calendar_routes.py @@ -0,0 +1,1071 @@ +"""Calendar routes — local SQLite-backed calendar CRUD.""" + +import logging +import uuid +from datetime import datetime, date, timedelta +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request, UploadFile, File +from pydantic import BaseModel + +from core.database import SessionLocal, CalendarCal, CalendarEvent +from src.auth_helpers import get_current_user + +logger = logging.getLogger(__name__) + +# Single-user fallback identity. Used only when: +# 1. The app is configured for single-user (no auth middleware), AND +# 2. The request didn't resolve to an authenticated user. +# Override at deploy time via `ODYSSEUS_FALLBACK_OWNER` env var. In a real +# multi-user install set `ODYSSEUS_SINGLE_USER=0` so unauthenticated requests +# are rejected instead of silently writing to this address. +import os as _os +FALLBACK_OWNER = _os.environ.get("ODYSSEUS_FALLBACK_OWNER", "owner@localhost") +_SINGLE_USER_MODE = _os.environ.get("ODYSSEUS_SINGLE_USER", "1") != "0" + + +def _require_user(request: Request) -> str: + """Return the authenticated user. In multi-user mode an unauthenticated + request raises 401; in single-user mode it falls through to + FALLBACK_OWNER. Prevents the silent cross-user data write that would + happen if a request slipped past auth middleware in a real deployment.""" + u = get_current_user(request) + if u: + return u + if _SINGLE_USER_MODE: + return FALLBACK_OWNER + raise HTTPException(401, "Authentication required") + + +def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal: + cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first() + if not cal: + raise HTTPException(404, "Calendar not found") + # Tighten the legacy null-owner gate (v2 review HIGH-12): if the + # caller is authenticated AND the calendar's owner is null OR + # belongs to a different user, treat it as not-found. The previous + # rule (`if cal.owner and cal.owner != owner`) silently allowed any + # authenticated user to read/edit any calendar with owner=None. + if owner and (cal.owner is None or cal.owner != owner): + raise HTTPException(404, "Calendar not found") + return cal + + +def _get_or_404_event(db, uid: str, owner: str) -> CalendarEvent: + ev = db.query(CalendarEvent).join(CalendarCal).filter(CalendarEvent.uid == uid).first() + if not ev: + raise HTTPException(404, "Event not found") + cal = ev.calendar + if owner and cal and (cal.owner is None or cal.owner != owner): + raise HTTPException(404, "Event not found") + return ev + +# ── Pydantic models ── + +class EventCreate(BaseModel): + summary: str + dtstart: str # ISO 8601 + dtend: Optional[str] = None + all_day: bool = False + description: str = "" + location: str = "" + calendar_href: Optional[str] = None # calendar id + rrule: Optional[str] = None + color: Optional[str] = None # per-event color override + + +class EventUpdate(BaseModel): + summary: Optional[str] = None + dtstart: Optional[str] = None + dtend: Optional[str] = None + all_day: Optional[bool] = None + description: Optional[str] = None + location: Optional[str] = None + rrule: Optional[str] = None + color: Optional[str] = None + + +# ── Helpers ── + +def _ensure_default_calendar(db, owner: str = None) -> CalendarCal: + """Create default calendar if none exist for this owner.""" + owner = owner or FALLBACK_OWNER + cal = db.query(CalendarCal).filter(CalendarCal.owner == owner).first() + if not cal: + cal = CalendarCal( + id=str(uuid.uuid4()), + owner=owner, + name="Personal", + color="#5b8abf", + source="local", + ) + db.add(cal) + db.commit() + db.refresh(cal) + return cal + + +# Per-request user UTC offset (in minutes east of UTC). chat_routes sets this +# from the `X-Tz-Offset` header so naive natural-language times the LLM +# emits ("today at 9pm") are parsed in the USER's timezone, not the server's +# clock. None = unknown, fall back to legacy server-local behavior. +from contextvars import ContextVar +_USER_TZ_OFFSET_MIN: ContextVar = ContextVar("user_tz_offset_min", default=None) + + +def set_user_tz_offset(offset_min): + """Set the current user's UTC offset for this async context.""" + try: + v = int(offset_min) + except (TypeError, ValueError): + return + _USER_TZ_OFFSET_MIN.set(v) + + +def get_user_tz_offset(): + """Read the current user's UTC offset (minutes east of UTC), or None.""" + return _USER_TZ_OFFSET_MIN.get() + + +def parse_due_for_user(s: str) -> str: + """Parse a due-date string emitted by the LLM / agent in the USER's tz. + + Returns an ISO 8601 string with explicit offset (e.g. "2026-05-13T21:00:00+09:00") + so downstream consumers preserve the absolute moment. Falls back to the + legacy naive ISO when no user offset is set. + + Handles three input shapes: + - Tz-aware ISO ("...Z" or "...+09:00") → returned as ISO with offset. + - Naive ISO ("2026-05-13T21:00:00") → attach the user's offset. + - Natural-language ("today at 9pm", "tomorrow 14:00", "in 2 hours") → + evaluated against the user's local "now" instead of the server's, + then ISO-with-offset. + """ + from datetime import timezone as _tz, timedelta as _td + offset = get_user_tz_offset() + s = (s or "").strip() + if not s: + return s + + # Tz-aware ISO short-circuit — preserve as-is. + try: + _s2 = s.replace("Z", "+00:00") if s.endswith("Z") else s + parsed = datetime.fromisoformat(_s2) + if parsed.tzinfo is not None: + return parsed.isoformat() + except ValueError: + parsed = None + + if offset is None: + # No user tz known — preserve legacy behavior (naive server-local). + return _parse_dt(s).isoformat() + + user_tz = _tz(_td(minutes=offset)) + + # Naive ISO → tag with user tz. + if parsed is not None and parsed.tzinfo is None: + return parsed.replace(tzinfo=user_tz).isoformat() + + # Natural language — evaluate against user's "now". + server_now_utc = datetime.now(_tz.utc) + user_now = server_now_utc.astimezone(user_tz) + # Patch datetime.now() inside _parse_dt by leveraging the user's clock: + # we re-implement the small natural-language phrases here against user_now + # so the result is naturally in the user's tz. + import re as _re + lower = s.lower().strip() + + def _parse_time(t): + m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE) + if not m: return None + h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower() + if ampm == "pm" and h < 12: h += 12 + elif ampm == "am" and h == 12: h = 0 + if not (0 <= h < 24 and 0 <= mn < 60): return None + return h, mn + + today = user_now.replace(hour=0, minute=0, second=0, microsecond=0) + + m = _re.match(r'^(today|tonight|tomorrow|tmrw|yesterday)(?:\s+at)?\s*(.*)$', lower) + if m: + word, rest = m.group(1), m.group(2).strip() + base = today + if word in ("tomorrow", "tmrw"): base = today + _td(days=1) + elif word == "yesterday": base = today - _td(days=1) + if not rest: + return base.isoformat() + t = _parse_time(rest) + if t is not None: + return base.replace(hour=t[0], minute=t[1]).isoformat() + + m = _re.match(r'^in\s+(\d+)\s*(hour|hr|minute|min|day)s?\s*$', lower) + if m: + n = int(m.group(1)); unit = m.group(2) + if unit in ("hour", "hr"): return (user_now + _td(hours=n)).isoformat() + if unit in ("minute", "min"): return (user_now + _td(minutes=n)).isoformat() + if unit == "day": return (user_now + _td(days=n)).isoformat() + + t = _parse_time(lower) + if t is not None: + return today.replace(hour=t[0], minute=t[1]).isoformat() + + # Last resort: dateutil. Trust it but apply user tz if it returned naive. + try: + from dateutil import parser as _du + parsed2 = _du.parse(s) + if parsed2.tzinfo is None: + parsed2 = parsed2.replace(tzinfo=user_tz) + return parsed2.isoformat() + except Exception: + # Final fallback: legacy parser, naive. + return _parse_dt(s).isoformat() + + +def _parse_dt_pair(s: str): + """Parse a date/datetime string and return ``(datetime, is_utc)``. + + is_utc is True iff the input carried explicit timezone info (Z, +HH:MM, + -HH:MM); the returned datetime is naive UTC. Otherwise the datetime is + naive-local (legacy behavior). DB column is naive — callers that care + about tz semantics should set ``CalendarEvent.is_utc`` accordingly. + """ + from datetime import timezone as _tz + s = (s or "").strip() + if not s: + raise ValueError("empty datetime string") + try: + if len(s) == 10: + return datetime.fromisoformat(s), False + _s2 = s.replace("Z", "+00:00") if s.endswith("Z") else s + parsed = datetime.fromisoformat(_s2) + if parsed.tzinfo is not None: + return parsed.astimezone(_tz.utc).replace(tzinfo=None), True + return parsed, False + except ValueError: + return _parse_dt(s), False + + +def _parse_dt(s: str) -> datetime: + """Parse a date/datetime string. + + Strict ISO first (cheapest path; this is what most callers pass). On + failure, fall through a small natural-language parser that handles the + phrasings LLMs commonly emit when given prompts like "1pm tomorrow": + - today/tomorrow/yesterday [at] HH(:MM)? (am/pm)? + - next [at] HH(:MM)? (am/pm)? + - in N hour(s)/minute(s)/day(s) + - bare time today: "1pm", "13:00" + - YYYY-MM-DD optionally followed by time + Anything still unparsed falls to dateutil.parser, which handles most + other absolute formats. Local-naive datetimes returned to match the + DB schema (CalendarEvent.dtstart is naive). + """ + import re as _re + s = (s or "").strip() + if not s: + raise ValueError("empty datetime string") + # Fast path: strict ISO + try: + if len(s) == 10: + return datetime.fromisoformat(s) + _s2 = s.replace("Z", "+00:00") if s.endswith("Z") else s + parsed = datetime.fromisoformat(_s2) + # Strip tz for the legacy callers — they expect naive. Real tz + # handling lives in _parse_dt_pair. + if parsed.tzinfo is not None: + from datetime import timezone as _tz + return parsed.astimezone(_tz.utc).replace(tzinfo=None) + return parsed + except ValueError: + pass + + now = datetime.now() + today = now.replace(hour=0, minute=0, second=0, microsecond=0) + lower = s.lower().strip() + + def _parse_time(t: str): + """Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None.""" + m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE) + if not m: + return None + h = int(m.group(1)) + mn = int(m.group(2) or 0) + ampm = (m.group(3) or "").lower() + if ampm == "pm" and h < 12: + h += 12 + elif ampm == "am" and h == 12: + h = 0 + if not (0 <= h < 24 and 0 <= mn < 60): + return None + return h, mn + + # today/tomorrow/yesterday [at] TIME + m = _re.match(r'^(today|tomorrow|tmrw|yesterday)(?:\s+at)?\s*(.*)$', lower) + if m: + word, rest = m.group(1), m.group(2).strip() + base = today + if word in ("tomorrow", "tmrw"): + base = today + timedelta(days=1) + elif word == "yesterday": + base = today - timedelta(days=1) + if not rest: + return base + t = _parse_time(rest) + if t is not None: + return base.replace(hour=t[0], minute=t[1]) + + # next [at] TIME + weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] + m = _re.match(r'^next\s+(\w+)(?:\s+at)?\s*(.*)$', lower) + if m and m.group(1) in weekdays: + target_dow = weekdays.index(m.group(1)) + days = (target_dow - today.weekday()) % 7 or 7 + base = today + timedelta(days=days) + rest = m.group(2).strip() + if not rest: + return base + t = _parse_time(rest) + if t is not None: + return base.replace(hour=t[0], minute=t[1]) + + # in N hours/minutes/days + m = _re.match(r'^in\s+(\d+)\s*(hour|hr|minute|min|day)s?\s*$', lower) + if m: + n = int(m.group(1)) + unit = m.group(2) + if unit in ("hour", "hr"): + return now + timedelta(hours=n) + if unit in ("minute", "min"): + return now + timedelta(minutes=n) + if unit == "day": + return now + timedelta(days=n) + + # Bare time → today at that time + t = _parse_time(lower) + if t is not None: + return today.replace(hour=t[0], minute=t[1]) + + # Last resort: dateutil's fuzzy parser + try: + from dateutil import parser as _du + return _du.parse(s) + except Exception: + raise ValueError(f"could not parse datetime: {s!r}") + + +def _event_to_dict(ev: CalendarEvent) -> dict: + """Convert a CalendarEvent model to the API dict format. + + Timed events whose stored datetimes represent UTC (is_utc=True) are + serialized with a trailing `Z` so the frontend `new Date()` interprets + them as absolute UTC and renders in the user's current local time. Legacy + rows without the flag are emitted as naive ISO (read as local) to avoid + silently shifting existing events. + """ + if ev.all_day: + start_str = ev.dtstart.strftime("%Y-%m-%d") + end_str = ev.dtend.strftime("%Y-%m-%d") + else: + suffix = "Z" if getattr(ev, "is_utc", False) else "" + start_str = ev.dtstart.isoformat() + suffix + end_str = ev.dtend.isoformat() + suffix + return { + "uid": ev.uid, + "summary": ev.summary or "", + "dtstart": start_str, + "dtend": end_str, + "all_day": ev.all_day, + "is_utc": bool(getattr(ev, "is_utc", False)), + "description": ev.description or "", + "location": ev.location or "", + "rrule": ev.rrule or "", + "calendar": ev.calendar.name if ev.calendar else "", + "calendar_href": ev.calendar_id, + "color": ev.color or (ev.calendar.color if ev.calendar else ""), + "event_type": getattr(ev, "event_type", None), + "importance": getattr(ev, "importance", None) or "normal", + } + + +# ── Routes ── + +def setup_calendar_routes() -> APIRouter: + router = APIRouter(prefix="/api/calendar", tags=["calendar"]) + + # CalDAV connect form (Integrations → Calendar). Storage is local + # SQLite; sync (src/caldav_sync.py) pulls remote events into it on + # calendar open and periodically via the scheduler. + @router.get("/config") + async def get_config(request: Request): + owner = _require_user(request) + from routes.prefs_routes import _load_for_user + cfg = (_load_for_user(owner) or {}).get("caldav", {}) or {} + # Surface url+username but never hand the password back to the + # client — saved-state UI shouldn't leak the credential. + return { + "url": cfg.get("url", "") or "", + "username": cfg.get("username", "") or "", + "password": "", + "has_password": bool(cfg.get("password")), + "local": not bool(cfg.get("url")), + } + + @router.post("/config") + async def save_config(request: Request): + owner = _require_user(request) + from routes.prefs_routes import _load_for_user, _save_for_user + try: + body = await request.json() + except Exception: + body = {} + prefs = _load_for_user(owner) or {} + cfg = dict(prefs.get("caldav") or {}) + # Empty url => clear the whole entry (treat as "remove integration"). + if not (body.get("url") or "").strip(): + prefs.pop("caldav", None) + _save_for_user(owner, prefs) + return {"ok": True, "cleared": True} + cfg["url"] = body.get("url", "").strip() + cfg["username"] = (body.get("username") or "").strip() + # Preserve the stored password when the client sends an empty + # one (edit form re-submitted without re-typing the password). + if body.get("password"): + cfg["password"] = body["password"] + prefs["caldav"] = cfg + _save_for_user(owner, prefs) + return {"ok": True} + + @router.post("/test") + async def test_connection(request: Request): + """Actually probe the configured CalDAV server with a PROPFIND + request (the same handshake every CalDAV client uses). Accepts + an optional {url, username, password} body so the user can test + a configuration BEFORE saving it; falls back to the stored + creds otherwise. Returns {ok, error?} with a useful message on + failure (status code, auth issue, network error).""" + owner = _require_user(request) + try: + body = await request.json() + except Exception: + body = {} + url = (body.get("url") or "").strip() + user = (body.get("username") or "").strip() + pw = body.get("password") or "" + if not (url and user and pw): + # Fall back to saved settings for this user. + from routes.prefs_routes import _load_for_user + cfg = (_load_for_user(owner) or {}).get("caldav", {}) or {} + url = url or (cfg.get("url") or "") + user = user or (cfg.get("username") or "") + pw = pw or (cfg.get("password") or "") + if not (url and user and pw): + return {"ok": False, "error": "Missing URL, username, or password"} + import httpx + propfind_body = ( + '\n' + '' + '' + ) + try: + async with httpx.AsyncClient(timeout=8.0, follow_redirects=True) as cx: + r = await cx.request( + "PROPFIND", url, + auth=(user, pw), + headers={"Depth": "0", "Content-Type": "application/xml"}, + content=propfind_body, + ) + # 207 = Multi-Status — standard CalDAV success. 200 also + # acceptable. Anything else (401/403/404/5xx) means trouble. + if r.status_code in (200, 207): + return {"ok": True} + if r.status_code == 401: + return {"ok": False, "error": "Auth failed — check username/password"} + if r.status_code == 403: + return {"ok": False, "error": "Forbidden — user can't access that URL"} + if r.status_code == 404: + return {"ok": False, "error": "Not found — check the URL path"} + return {"ok": False, "error": f"HTTP {r.status_code}"} + except httpx.ConnectError as e: + return {"ok": False, "error": f"Connection refused: {e}"[:200]} + except httpx.TimeoutException: + return {"ok": False, "error": "Connection timed out"} + except Exception as e: + return {"ok": False, "error": str(e)[:200]} + + @router.post("/sync") + async def sync_caldav_endpoint(request: Request): + """Pull events from the configured CalDAV server into local DB. + Returns counts + any per-calendar errors. Called by the frontend + on calendar open and by the periodic scheduler loop.""" + owner = _require_user(request) + from src.caldav_sync import sync_caldav + return await sync_caldav(owner) + + @router.get("/calendars") + async def list_calendars(request: Request): + owner = _require_user(request) + db = SessionLocal() + try: + _ensure_default_calendar(db, owner) + cals = db.query(CalendarCal).filter(CalendarCal.owner == owner).all() + return {"calendars": [ + {"name": c.name, "href": c.id, "color": c.color} + for c in cals + ]} + except HTTPException: + raise + except Exception as e: + logger.error("Failed to list calendars: %s", e) + raise HTTPException(500, "Failed to list calendars") + finally: + db.close() + + @router.get("/events") + async def list_events(request: Request, start: str, end: str, calendar: str = ""): + owner = _require_user(request) + try: + start_dt = _parse_dt(start) + end_dt = _parse_dt(end) + except ValueError: + # A malformed range (e.g. a stray "NaN-NaN-NaN" from the client) + # shouldn't spam the user with an error notification on every poll — + # just log it and return no events for this window. + logger.warning("list_events: unparseable range start=%r end=%r", start, end) + return {"events": []} + db = SessionLocal() + try: + # Scope events to calendars owned by the caller. + q = db.query(CalendarEvent).join(CalendarCal).filter( + CalendarEvent.dtstart < end_dt, + CalendarEvent.dtend > start_dt, + CalendarEvent.status != "cancelled", + CalendarCal.owner == owner, + ) + if calendar: + q = q.filter( + (CalendarEvent.calendar_id == calendar) | + (CalendarCal.name == calendar) + ) + events = q.order_by(CalendarEvent.dtstart).all() + return {"events": [_event_to_dict(e) for e in events]} + except HTTPException: + raise + except Exception as e: + logger.error("Failed to list events: %s", e) + raise HTTPException(500, "Failed to list events") + finally: + db.close() + + @router.post("/events") + async def create_event(request: Request, data: EventCreate): + owner = _require_user(request) + db = SessionLocal() + try: + cal = None + if data.calendar_href: + cal = db.query(CalendarCal).filter(CalendarCal.id == data.calendar_href).first() + # Reject calendars that aren't owned by the caller. The + # previous `if cal and cal.owner and ...` check silently + # passed null-owner (legacy) rows, letting any authenticated + # user write events into them. Same null-owner gate as + # `_get_or_404_calendar`. + if cal and (cal.owner is None or cal.owner != owner): + raise HTTPException(404, "Calendar not found") + if not cal: + cal = _ensure_default_calendar(db, owner) + + uid = str(uuid.uuid4()) + # Use the tz-detecting parser so events posted with an offset + # (e.g. "2026-05-13T10:00:00+09:00" or "...Z") get stored as UTC + # and flagged for proper Z-suffix on read-back. + dtstart, _is_utc = _parse_dt_pair(data.dtstart) + if data.dtend: + dtend, _end_utc = _parse_dt_pair(data.dtend) + # If start was tz-aware but end was naive (or vice-versa), + # trust whichever flag is True — they should match. + _is_utc = _is_utc or _end_utc + elif data.all_day: + dtend = dtstart + timedelta(days=1) + else: + dtend = dtstart + timedelta(hours=1) + + ev = CalendarEvent( + uid=uid, + calendar_id=cal.id, + summary=data.summary, + description=data.description, + location=data.location, + dtstart=dtstart, + dtend=dtend, + all_day=data.all_day, + is_utc=_is_utc and not data.all_day, + rrule=data.rrule or "", + color=data.color or None, + ) + db.add(ev) + db.commit() + return {"ok": True, "uid": uid} + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error("Failed to create event: %s", e) + raise HTTPException(500, "Failed to create event") + finally: + db.close() + + @router.put("/events/{uid}") + async def update_event(request: Request, uid: str, data: EventUpdate): + owner = _require_user(request) + db = SessionLocal() + try: + ev = _get_or_404_event(db, uid, owner) + if data.summary is not None: + ev.summary = data.summary + if data.description is not None: + ev.description = data.description + if data.location is not None: + ev.location = data.location + if data.dtstart is not None: + ev.dtstart, _s_utc = _parse_dt_pair(data.dtstart) + # When the incoming payload carries tz info, mark the row as + # UTC-stored so the serializer adds Z. Don't flip the flag + # off if start arrives naive but end was UTC — only escalate. + if _s_utc: + ev.is_utc = True + if data.dtend is not None: + ev.dtend, _e_utc = _parse_dt_pair(data.dtend) + if _e_utc: + ev.is_utc = True + if data.all_day is not None: + ev.all_day = data.all_day + if data.all_day: + ev.is_utc = False # all-day stays date-only + if data.rrule is not None: + ev.rrule = data.rrule + if data.color is not None: + ev.color = data.color if data.color else None + db.commit() + return {"ok": True} + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error("Failed to update event: %s", e) + raise HTTPException(500, "Failed to update event") + finally: + db.close() + + @router.delete("/events/{uid}") + async def delete_event(request: Request, uid: str): + owner = _require_user(request) + db = SessionLocal() + try: + ev = _get_or_404_event(db, uid, owner) + db.delete(ev) + db.commit() + return {"ok": True} + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error("Failed to delete event: %s", e) + raise HTTPException(500, "Failed to delete event") + finally: + db.close() + + @router.post("/calendars") + async def create_calendar(request: Request, name: str = "Imported", color: str = "#5b8abf"): + owner = _require_user(request) + db = SessionLocal() + try: + cal = CalendarCal( + id=str(uuid.uuid4()), + owner=owner, + name=name, + color=color, + source="local", + ) + db.add(cal) + db.commit() + return {"ok": True, "id": cal.id, "name": cal.name, "color": cal.color} + except Exception as e: + db.rollback() + logger.error("Failed to create calendar: %s", e) + raise HTTPException(500, "Failed to create calendar") + finally: + db.close() + + @router.put("/calendars/{cal_id}") + async def update_calendar(request: Request, cal_id: str, name: str = None, color: str = None): + owner = _require_user(request) + db = SessionLocal() + try: + cal = _get_or_404_calendar(db, cal_id, owner) + if name is not None: + cal.name = name + if color is not None: + cal.color = color + db.commit() + return {"ok": True} + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error("Failed to update calendar: %s", e) + raise HTTPException(500, "Failed to update calendar") + finally: + db.close() + + @router.delete("/calendars/{cal_id}") + async def delete_calendar(request: Request, cal_id: str): + owner = _require_user(request) + db = SessionLocal() + try: + cal = _get_or_404_calendar(db, cal_id, owner) + db.query(CalendarEvent).filter(CalendarEvent.calendar_id == cal_id).delete() + db.delete(cal) + db.commit() + return {"ok": True} + except HTTPException: + raise + except Exception as e: + db.rollback() + return {"error": str(e)} + finally: + db.close() + + # 10 MB hard cap on ICS upload. Loading the whole file into memory is + # unavoidable with python-icalendar, so an unbounded upload would OOM. + _ICS_MAX_BYTES = 10 * 1024 * 1024 + + @router.post("/import") + async def import_ics(request: Request, file: UploadFile = File(...), calendar_name: str = ""): + """Import events from an .ics file (scoped to caller's account).""" + from icalendar import Calendar as iCal + + owner = _require_user(request) + db = SessionLocal() + try: + content = await file.read() + if len(content) > _ICS_MAX_BYTES: + raise HTTPException(413, f"ICS file too large (max {_ICS_MAX_BYTES // (1024*1024)} MB)") + try: + cal_data = iCal.from_ical(content) + except Exception as e: + raise HTTPException(400, f"Invalid ICS file: {e}") + + # Sanitize display name — length cap + strip control chars + raw_name = calendar_name.strip() or (file.filename or "").replace(".ics", "").replace("_", " ").strip() or "Imported" + cal_display = "".join(c for c in raw_name if c.isprintable())[:120] or "Imported" + + target_cal = db.query(CalendarCal).filter( + CalendarCal.name == cal_display, + CalendarCal.owner == owner, + ).first() + if not target_cal: + target_cal = CalendarCal( + id=str(uuid.uuid4()), + owner=owner, + name=cal_display, + color="#7c4dff", + source="import", + ) + db.add(target_cal) + db.commit() + db.refresh(target_cal) + + imported = skipped = 0 + for comp in cal_data.walk(): + if comp.name != "VEVENT": + continue + # Generate a fresh uid for each import. The old code reused + # the VEVENT uid from the file, which leaked across users: + # a uid present on ANY user's calendar caused this user's + # row to be silently skipped (and enabled enumeration). + # Using a fresh uuid scopes uniqueness per-row. + uid_val = str(uuid.uuid4()) + dtstart = comp.get("dtstart") + if not dtstart: + skipped += 1 + continue + + # Dedup INSIDE this user's target calendar only — same + # source-uid + same dtstart in the same target = duplicate. + source_uid = str(comp.get("uid", "")) or None + if source_uid: + src_dtstart = dtstart.dt + naive_src = src_dtstart.replace(tzinfo=None) if hasattr(src_dtstart, 'tzinfo') and src_dtstart.tzinfo else src_dtstart + existing = ( + db.query(CalendarEvent) + .filter( + CalendarEvent.calendar_id == target_cal.id, + CalendarEvent.dtstart == naive_src, + CalendarEvent.summary == str(comp.get("summary", "")), + ) + .first() + ) + if existing: + skipped += 1 + continue + + dt_val = dtstart.dt + all_day = isinstance(dt_val, date) and not isinstance(dt_val, datetime) + # For timed events, preserve the source timezone by converting + # to UTC before stripping tzinfo (DB stores naive). We mark + # the row with is_utc=True so the serializer adds the Z + # suffix on output — without this, the frontend would parse + # the naive ISO as the user's CURRENT local, which is exactly + # the bug where imported events fire reminders at wrong times. + from datetime import timezone as _tz + row_is_utc = False + if all_day: + start_dt = datetime(dt_val.year, dt_val.month, dt_val.day) + dtend = comp.get("dtend") + end_dt = datetime(dtend.dt.year, dtend.dt.month, dtend.dt.day) if dtend else start_dt + timedelta(days=1) + else: + if hasattr(dt_val, 'tzinfo') and dt_val.tzinfo is not None: + start_dt = dt_val.astimezone(_tz.utc).replace(tzinfo=None) + row_is_utc = True + else: + start_dt = dt_val + dtend = comp.get("dtend") + if dtend: + d_end = dtend.dt + if hasattr(d_end, 'tzinfo') and d_end.tzinfo is not None: + end_dt = d_end.astimezone(_tz.utc).replace(tzinfo=None) + else: + end_dt = d_end + else: + end_dt = start_dt + timedelta(hours=1) + + ev = CalendarEvent( + uid=uid_val, + calendar_id=target_cal.id, + summary=str(comp.get("summary", "")), + description=str(comp.get("description", "")), + location=str(comp.get("location", "")), + dtstart=start_dt, + dtend=end_dt, + all_day=all_day, + is_utc=row_is_utc, + rrule=(comp.get("rrule").to_ical().decode() if comp.get("rrule") else ""), + ) + db.add(ev) + imported += 1 + + db.commit() + return { + "ok": True, + "imported": imported, + "skipped": skipped, + "calendar": cal_display, + "calendar_id": target_cal.id, + } + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error("Failed to import ICS: %s", e) + raise HTTPException(500, "Failed to import ICS") + finally: + db.close() + + @router.get("/export/{cal_id}") + async def export_ics(request: Request, cal_id: str): + """Export a calendar as .ics file.""" + from fastapi.responses import Response + + owner = _require_user(request) + db = SessionLocal() + try: + cal = _get_or_404_calendar(db, cal_id, owner) + events = db.query(CalendarEvent).filter( + CalendarEvent.calendar_id == cal_id, + CalendarEvent.status != "cancelled", + ).all() + + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Odysseus//Calendar//EN", + f"X-WR-CALNAME:{cal.name}", + ] + for ev in events: + lines.append("BEGIN:VEVENT") + lines.append(f"UID:{ev.uid}") + lines.append(f"SUMMARY:{ev.summary or ''}") + if ev.all_day: + lines.append(f"DTSTART;VALUE=DATE:{ev.dtstart.strftime('%Y%m%d')}") + lines.append(f"DTEND;VALUE=DATE:{ev.dtend.strftime('%Y%m%d')}") + else: + lines.append(f"DTSTART:{ev.dtstart.strftime('%Y%m%dT%H%M%S')}") + lines.append(f"DTEND:{ev.dtend.strftime('%Y%m%dT%H%M%S')}") + if ev.description: + lines.append(f"DESCRIPTION:{ev.description.replace(chr(10), '\\n')}") + if ev.location: + lines.append(f"LOCATION:{ev.location}") + if ev.rrule: + lines.append(f"RRULE:{ev.rrule}") + lines.append("END:VEVENT") + lines.append("END:VCALENDAR") + + ics_data = "\r\n".join(lines) + safe_name = cal.name.replace(" ", "_").replace("/", "_") + return Response( + content=ics_data, + media_type="text/calendar", + headers={"Content-Disposition": f'attachment; filename="{safe_name}.ics"'}, + ) + except HTTPException: + raise + except Exception as e: + logger.error("Failed to export ICS: %s", e) + raise HTTPException(500, "Failed to export ICS") + finally: + db.close() + + @router.post("/quick-parse") + async def quick_parse(request: Request): + """Parse a natural-language event description into structured fields. + + Input: {"text": "lunch with sara friday 1pm downtown", "tz": "America/New_York"} + Output: {"ok": true, "event": {"summary", "dtstart", "dtend", + "all_day", "location", "description"}, "confidence": 0.0-1.0} + + Anchored on the server's current date/time so phrases like + "tomorrow", "next Tuesday", "in 30 minutes" resolve correctly. + Uses the "utility" endpoint (small / fast model) to keep latency low. + """ + _require_user(request) + from src.endpoint_resolver import resolve_endpoint + from src.llm_core import llm_call_async + from src.text_helpers import strip_think + import json as _json + import re as _re + + body = await request.json() + text = (body.get("text") or "").strip() + if not text: + raise HTTPException(400, "text is required") + tz_hint = (body.get("tz") or "").strip() + + url, model, headers = resolve_endpoint("utility") + if not url: + url, model, headers = resolve_endpoint("default") + if not url or not model: + return {"ok": False, "error": "No LLM endpoint configured"} + + now = datetime.now() + now_iso = now.strftime("%Y-%m-%dT%H:%M:%S") + # The model gets only the schema it needs to fill out; we re-validate + # everything client-side too. + system_prompt = ( + "You are a calendar event parser. Read the user's one-line " + "description and emit STRICT JSON describing the event. " + f"Today is {now.strftime('%A, %Y-%m-%d')} ({now_iso}). " + + (f"User timezone: {tz_hint}. " if tz_hint else "") + + "Resolve relative dates (\"tomorrow\", \"friday\", \"next monday\", " + "\"in 30 minutes\") against today. Default duration is 60 minutes " + "when no end time is given. If the text mentions a date with no " + "time, treat it as an all-day event.\n\n" + "Output ONLY this JSON shape, nothing else:\n" + "{\n" + ' "summary": "",\n' + ' "dtstart": "",\n' + ' "dtend": "",\n' + ' "all_day": ,\n' + ' "location": "",\n' + ' "description": "",\n' + ' "confidence": <0.0-1.0>\n' + "}\n" + "For all-day events use \"YYYY-MM-DD\" (no time) for both fields." + ) + + try: + raw = await llm_call_async( + url=url, model=model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": text}, + ], + headers=headers, + temperature=0.0, + max_tokens=512, + timeout=20, + ) + except Exception as e: + return {"ok": False, "error": f"LLM call failed: {e}"} + + cleaned = strip_think(raw or "", prose=False, prompt_echo=True) + cleaned = _re.sub(r"^```(?:json)?\s*|\s*```$", "", cleaned, flags=_re.MULTILINE).strip() + m = _re.search(r"\{[\s\S]*\}", cleaned) + if not m: + return {"ok": False, "error": "Could not extract JSON", "raw": cleaned[:400]} + try: + parsed = _json.loads(m.group()) + except Exception as e: + return {"ok": False, "error": f"Invalid JSON: {e}", "raw": cleaned[:400]} + + # Light validation / defaults so the frontend can trust the shape. + summary = (parsed.get("summary") or text)[:200] + # Strip stale relative/absolute time tokens that the LLM (or the + # user's raw input) sometimes leaks into the summary — these + # would otherwise be displayed verbatim in reminder notifications + # that fire much later, when "in 29 min" is no longer true. The + # actual timing lives in dtstart/dtend. + summary = _re.sub(r'\bin\s+\d+\s*(min|minute|hour|hr|day)s?\b', '', summary, flags=_re.IGNORECASE) + summary = _re.sub(r'\(\s*\d{1,2}:\d{2}\s*\)', '', summary) + summary = _re.sub(r'\b\d{1,2}(:\d{2})?\s*(am|pm)\b', '', summary, flags=_re.IGNORECASE) + summary = _re.sub(r'\s+@\s+(?=\d)', ' ', summary) # drop "@" when right before a time + summary = _re.sub(r'\s+', ' ', summary).strip(' -—,@') + all_day = bool(parsed.get("all_day")) + dtstart = (parsed.get("dtstart") or "").strip() + dtend = (parsed.get("dtend") or "").strip() + # Force naive-local on LLM output. The model is anchored on the + # user's local "now" via the system prompt, so its emitted + # datetime is already meant to be the user's wall-clock time. + # Some models append `Z` or a tz offset anyway, which would + # make `_parse_dt_pair` flag the row as UTC and shift the + # displayed time forward by the user's tz offset. Strip any + # trailing tz marker so the time is stored exactly as the LLM + # wrote it. + def _strip_tz(s): + if not s: + return s + s = s.strip() + # Strip "Z" + if s.endswith('Z') or s.endswith('z'): + s = s[:-1] + # Strip "+HH:MM" / "-HH:MM" if it followed a T-time + s = _re.sub(r'[+-]\d{2}:?\d{2}$', '', s) + return s + dtstart = _strip_tz(dtstart) + dtend = _strip_tz(dtend) + if not dtstart: + return {"ok": False, "error": "Model did not produce a start time", "raw": cleaned[:400]} + if not dtend: + # Auto-fill +60 min for timed events; +0 for all-day (single-day). + try: + if all_day: + dtend = dtstart + else: + dt = datetime.fromisoformat(dtstart) + dtend = (dt + timedelta(minutes=60)).strftime("%Y-%m-%dT%H:%M:00") + except Exception: + dtend = dtstart + + return { + "ok": True, + "event": { + "summary": summary, + "dtstart": dtstart, + "dtend": dtend, + "all_day": all_day, + "location": (parsed.get("location") or "").strip()[:200], + "description": (parsed.get("description") or "").strip()[:2000], + }, + "confidence": float(parsed.get("confidence", 0.7) or 0.7), + } + + return router diff --git a/routes/chat_helpers.py b/routes/chat_helpers.py new file mode 100644 index 0000000..ce2e0cf --- /dev/null +++ b/routes/chat_helpers.py @@ -0,0 +1,802 @@ +"""Shared helpers for chat routes — context building, post-response tasks, auth resolution.""" + +import asyncio +import json +import logging +import re +from dataclasses import dataclass, field +from typing import Any, Optional + +from core.models import ChatMessage +from core.database import SessionLocal +from core.database import Session as DBSession, ModelEndpoint +from src.llm_core import normalize_model_id +from src.context_compactor import maybe_compact, trim_for_context +from src.auth_helpers import get_current_user +from src.prompt_security import untrusted_context_message +from routes.prefs_routes import _load_for_user as load_prefs_for_user + +from fastapi import HTTPException + +logger = logging.getLogger(__name__) + + +# ── Data containers ────────────────────────────────────────────────────── # + +@dataclass +class PresetInfo: + """Extracted preset parameters.""" + temperature: Optional[float] + max_tokens: Optional[int] + system_prompt: Optional[str] + character_name: Optional[str] + + +@dataclass +class PreprocessedMessage: + """Result of chat_handler.preprocess_message.""" + enhanced_message: str + user_content: Any # str or list (multimodal) + text_for_context: str + youtube_transcripts: list + attachment_meta: list + + +@dataclass +class ChatContext: + """Everything needed to call the LLM after context-building.""" + preface: list + rag_sources: list + web_sources: list + used_memories: list + messages: list + context_length: int + was_compacted: bool + user: Optional[str] + uprefs: dict + preset: PresetInfo + preprocessed: PreprocessedMessage + # Documents auto-created server-side during preprocess (e.g. when an + # attached fillable PDF gets rendered into a markdown editor doc). + # The chat route emits a doc_update SSE event for each before streaming + # begins, so the editor pane switches to the new doc immediately. + auto_opened_docs: list = field(default_factory=list) + + +# ── Helpers ────────────────────────────────────────────────────────────── # + +def _enforce_chat_privileges(request, sess) -> None: + """Apply the per-user privilege gates (allowed_models + max_messages_per_day) + that both /api/chat and /api/chat_stream must enforce BEFORE any LLM work. + + Raises HTTPException(403) if the session's model is not in the user's + allowlist, or HTTPException(429) if the user has hit their daily message + cap. No-op for unauthenticated callers or when auth_manager is absent + (single-user mode). Admins receive ADMIN_PRIVILEGES from get_privileges, + which means empty allowed_models / zero cap → no-op for them. + """ + try: + user = get_current_user(request) + except Exception: + user = None + if not user: + return + auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None) + if not auth_manager: + return + + privs = auth_manager.get_privileges(user) or {} + allowed = privs.get("allowed_models") or [] + if allowed and sess.model and sess.model not in allowed: + raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.") + + cap = int(privs.get("max_messages_per_day") or 0) + if cap <= 0: + return + + from datetime import datetime as _dt, timedelta as _td + from core.database import Session as _DbSess, ChatMessage as _Cm + db = SessionLocal() + try: + count = ( + db.query(_Cm) + .join(_DbSess, _Cm.session_id == _DbSess.id) + .filter(_DbSess.owner == user, + _Cm.role == "user", + _Cm.timestamp >= _dt.utcnow() - _td(days=1)) + .count() + ) + finally: + db.close() + if count >= cap: + raise HTTPException(429, f"Daily message limit reached ({cap}). Try again in 24 hours.") + + +def needs_auto_name(name: str) -> bool: + """Check if a session still has its default/placeholder name.""" + if not name: + return True + if name.startswith("Chat:") or name == "Chat": + return True + # Default frontend name: "modelname HH:MM:SS AM/PM" + if re.match(r'^.+ \d{1,2}:\d{2}:\d{2}\s*(AM|PM)$', name): + return True + return False + + +async def auto_name_session(session_manager, sess): + """Generate a short title for a session from its first user message.""" + try: + from src.llm_core import llm_call_async + from src.task_endpoint import resolve_task_endpoint + + # Find first user message + first_msg = "" + for msg in sess.history: + if msg.role == "user": + content = msg.content + if isinstance(content, list): + content = next( + (i.get("text", "") for i in content if isinstance(i, dict) and i.get("type") == "text"), + "", + ) + first_msg = str(content)[:500] + break + + if not first_msg: + return + + t_url, t_model, t_headers = resolve_task_endpoint( + sess.endpoint_url, sess.model, sess.headers, + ) + + # max_tokens big enough that reasoning models (Minimax M2, + # DeepSeek R1, QwQ, etc.) have headroom for + # plus the actual title — 200 used to clip them mid-reasoning + # so strip_think left an empty string and no rename happened. + # Timeout matches: 60s gives slow local reasoners room to finish. + title = await llm_call_async( + t_url, + t_model, + [ + {"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation — just the title."}, + {"role": "user", "content": first_msg}, + ], + temperature=0.3, + max_tokens=4096, + headers=t_headers, + timeout=60, + ) + + title = title.strip().strip('"\'').strip() + # Strip / blocks (closed, dangling, or stray tags) + # via the central helper. + from src.text_helpers import strip_think + title = strip_think(title, prose=False, prompt_echo=False) + if title and len(title) < 80: + session_manager.update_session_name(sess.id, title) + logger.info(f"Auto-named session {sess.id}: {title}") + + except Exception as e: + import traceback + logger.error(f"Auto-name failed for {sess.id}: {e}\n{traceback.format_exc()}") + + +def try_fallback_endpoint(sess, session_id: str) -> dict | None: + """Find an alternative working endpoint when the current one fails. + + Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None. + """ + import requests as _req + from src.endpoint_resolver import build_chat_url, build_headers, normalize_base + + current_url = sess.endpoint_url or "" + db = SessionLocal() + try: + endpoints = db.query(ModelEndpoint).filter( + ModelEndpoint.is_enabled == True + ).all() + finally: + db.close() + + for ep in endpoints: + base = normalize_base(ep.base_url) + # Skip current endpoint + if current_url and base in current_url: + continue + # Quick ping + ping_url = base + "/models" + headers = {} + if ep.api_key: + headers["Authorization"] = f"Bearer {ep.api_key}" + try: + r = _req.get(ping_url, headers=headers, timeout=5) + r.raise_for_status() + data = r.json() + models = [m.get("id") for m in (data.get("data") or []) if m.get("id")] + if not models: + continue + # Found a working endpoint — update session + new_model = models[0] + chat_url = build_chat_url(base) + new_headers = build_headers(ep.api_key, base) + + sess.model = new_model + sess.endpoint_url = chat_url + sess.headers = new_headers + + # Persist + _db = SessionLocal() + try: + _db.query(DBSession).filter(DBSession.id == session_id).update({ + "model": new_model, + "endpoint_url": chat_url, + "headers": json.dumps(new_headers), + }) + _db.commit() + finally: + _db.close() + + logger.info(f"Fallback: switched session {session_id} from {current_url} to {ep.name} ({new_model})") + return { + "model": new_model, + "endpoint_url": chat_url, + "endpoint_name": ep.name, + } + except Exception: + continue + + return None + + +def extract_preset(chat_handler, preset_id) -> PresetInfo: + """Extract preset parameters via chat_handler.""" + temperature, max_tokens, system_prompt, char_name = ( + chat_handler.validate_and_extract_preset(preset_id) + ) + return PresetInfo( + temperature=temperature, + max_tokens=max_tokens, + system_prompt=system_prompt, + character_name=char_name, + ) + + +async def preprocess( + chat_handler, message, att_ids, sess, + auto_opened_docs: Optional[list] = None, +) -> PreprocessedMessage: + """Run chat_handler.preprocess_message and wrap the result.""" + enhanced, user_content, text_ctx, yt_transcripts, att_meta = ( + await chat_handler.preprocess_message( + message, att_ids, sess, auto_opened_docs=auto_opened_docs + ) + ) + return PreprocessedMessage( + enhanced_message=enhanced, + user_content=user_content, + text_for_context=text_ctx, + youtube_transcripts=yt_transcripts, + attachment_meta=att_meta, + ) + + +def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, incognito: bool = False): + """Add user message to session history and update session name. + In incognito mode, still add to in-memory history (for conversation context) + but skip session name update (which would persist).""" + user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None + sess.add_message(ChatMessage("user", preprocessed.user_content, metadata=user_meta)) + if not incognito: + chat_handler.update_session_name_if_needed(sess, preprocessed.text_for_context) + + +def fire_message_event(request, webhook_manager, session_id: str, sess, message: str, compare_mode: bool = False): + """Fire webhook and event_bus events for a new user message.""" + if webhook_manager and not compare_mode: + asyncio.create_task(webhook_manager.fire("chat.message", { + "session_id": session_id, "model": sess.model, "message": message[:2000], + })) + from src.event_bus import fire_event + user = get_current_user(request) + fire_event("message_sent", user) + + +def resolve_session_auth(sess, session_id: str): + """Ensure session has auth headers — resolve from endpoint DB if missing.""" + has_auth = sess.headers and isinstance(sess.headers, dict) and any( + k.lower() in ('authorization', 'x-api-key') for k in sess.headers + ) + if has_auth: + return + + try: + from src.endpoint_resolver import build_headers + db = SessionLocal() + try: + domain = sess.endpoint_url.split("//")[1].split("/")[0] if "//" in sess.endpoint_url else "" + if domain: + ep = db.query(ModelEndpoint).filter(ModelEndpoint.base_url.contains(domain)).first() + if ep and ep.api_key: + sess.headers = build_headers(ep.api_key, ep.base_url) + db.query(DBSession).filter(DBSession.id == session_id).update( + {"headers": json.dumps(sess.headers)} + ) + db.commit() + logger.info(f"Resolved and persisted auth headers for session {session_id} from endpoint {ep.name}") + finally: + db.close() + except Exception as e: + logger.warning(f"Failed to resolve session headers: {e}") + + +async def build_chat_context( + sess, + request, + chat_handler, + chat_processor, + message: str, + session_id: str, + preset_id=None, + att_ids: list = None, + use_web=None, + use_rag=None, + use_research=None, + time_filter=None, + incognito: bool = False, + no_memory: bool = False, + search_context: str = None, + compare_mode: bool = False, + webhook_manager=None, + use_enhanced_message: bool = False, + agent_mode: bool = False, +) -> ChatContext: + """Build the full context (preface + messages) for an LLM call. + + This is the shared logic between /chat and /chat_stream — preset extraction, + message preprocessing, memory/RAG/web injection, compaction, normalization. + """ + # Preset + preset = extract_preset(chat_handler, preset_id) + + # Preprocess message (CoT, YouTube, VL images, build content). The + # auto_opened_docs collector captures any docs created server-side + # (e.g. fillable PDF → markdown editor doc) so the chat route can + # announce them to the frontend before streaming. + auto_opened_docs: list = [] + preprocessed = await preprocess( + chat_handler, message, att_ids or [], sess, + auto_opened_docs=auto_opened_docs, + ) + + # Add user message to history + add_user_message(sess, chat_handler, preprocessed, incognito=incognito) + + # Fire events + if not incognito: + fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode) + + # Resolve user prefs + user = get_current_user(request) + uprefs = load_prefs_for_user(user) + + # Memory enabled? + mem_enabled = not incognito and not no_memory and uprefs.get("memory_enabled", True) + # Skills injection respects its own enable toggle (mirrors memory_enabled). + # When off, the "Available skills" index is not added to the prompt. + skills_enabled = not incognito and uprefs.get("skills_enabled", True) + logger.debug( + "Memory enabled=%s for user=%s (incognito=%s, no_memory=%s, pref=%s)", + mem_enabled, user, incognito, no_memory, uprefs.get("memory_enabled", "NOT_SET"), + ) + + # Use RAG? + use_rag_val = (str(use_rag).lower() != "false") if use_rag is not None else True + if incognito: + use_rag_val = False + + # If pre-fetched search context was provided (compare mode), skip live web search + skip_web = bool(search_context) + + # Build context preface + # The stream path uses enhanced_message (with CoT/preprocessing applied), + # the sync path uses text_for_context. + _ctx_msg = preprocessed.enhanced_message if use_enhanced_message else preprocessed.text_for_context + _preface_kwargs = dict( + message=_ctx_msg, + session=sess, + use_web=use_web and not skip_web, + use_memory=mem_enabled, + time_filter=time_filter, + preset_system_prompt=preset.system_prompt, + owner=user, + character_name=preset.character_name, + agent_mode=agent_mode, + incognito=incognito, + use_skills=skills_enabled, + ) + if use_rag is not None: + _preface_kwargs["use_rag"] = use_rag_val + preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs) + + # Capture used memories immediately + used_memories = getattr(chat_processor, '_last_used_memories', []) + + # Inject pre-fetched search context (compare mode) + if search_context: + preface.append(untrusted_context_message("prefetched search context", search_context)) + + # YouTube transcripts + for transcript in preprocessed.youtube_transcripts: + preface.append(untrusted_context_message("youtube transcript", transcript)) + + # Normalize model ID + norm = normalize_model_id(sess.endpoint_url, sess.model) + if norm: + sess.model = norm + + # Build messages + messages = preface + sess.get_context_messages() + + # Auto-compact + messages, context_length, was_compacted = await maybe_compact( + sess, sess.endpoint_url, sess.model, messages, sess.headers, + ) + messages = trim_for_context(messages, context_length) + + return ChatContext( + preface=preface, + rag_sources=rag_sources, + web_sources=web_sources, + used_memories=used_memories, + messages=messages, + context_length=context_length, + was_compacted=was_compacted, + user=user, + uprefs=uprefs, + preset=preset, + preprocessed=preprocessed, + auto_opened_docs=auto_opened_docs, + ) + + +def accumulate_token_usage(session_id: str, metrics: dict): + """Add input/output token counts to the session's running totals.""" + in_t = metrics.get("input_tokens", 0) + out_t = metrics.get("output_tokens", 0) + if not (in_t or out_t): + return + db = SessionLocal() + try: + db_s = db.query(DBSession).filter(DBSession.id == session_id).first() + if db_s: + db_s.total_input_tokens = (db_s.total_input_tokens or 0) + in_t + db_s.total_output_tokens = (db_s.total_output_tokens or 0) + out_t + db.commit() + except Exception: + db.rollback() + finally: + db.close() + + +def _normalize_thinking(text: str) -> str: + """Wrap inline thinking patterns in tags so they persist on reload. + + Handles: + - "Thinking Process:" (Qwen3.5) + - Gemma-style inline reasoning ("The user said/asked...", "I should/need to...") + - Garbled tags (reasoning before the tag, unclosed tags) + """ + import re + if not text: + return text + reasoning_prefix_re = re.compile( + r'^\s*(?:thinking(?:\s+process)?\s*:|the user |i need |i should |i will |they are |the question |i can )', + re.IGNORECASE, + ) + thinking_prefix_re = re.compile(r'^thinking(?:\s+process)?\s*:\s*', re.IGNORECASE) + + # Handle garbled tags: reasoning text followed by as separator + # e.g. "The user said...I should respond.\nHey! What's up?" + garbled = re.match( + r'^([\s\S]+?)\n*\s*([\s\S]*?)(?:)?\s*$', + text, re.IGNORECASE + ) + if garbled: + before = garbled.group(1).strip() + after = garbled.group(2).strip() + # Only treat as garbled if the part before looks like reasoning + reasoning_starts = ( + 'The user ', 'I need ', 'I should ', 'I will ', + 'They are ', 'The question ', 'I can ', + 'Thinking Process', 'Thinking:', + ) + stripped_before = before.lstrip() + if any(stripped_before.startswith(p) for p in reasoning_starts) or reasoning_prefix_re.match(stripped_before): + # Strip "Thinking:" prefix from the thinking content + stripped_before = thinking_prefix_re.sub('', stripped_before) + return '' + stripped_before + '\n' + after + + if '' + think + '' + text[m.end()-2:] + # Fallback: find last non-indented paragraph as reply + parts = text.split('\n\n') + for i in range(len(parts) - 1, 0, -1): + line = parts[i].strip() + if line and not re.match(r'^[\d*\-\s(]', line) and len(line) > 5: + think = thinking_prefix_re.sub('', '\n\n'.join(parts[:i])).strip() + reply = '\n\n'.join(parts[i:]) + return '' + think + '\n\n' + reply + # Last resort: look for a quoted final response inside the thinking + # Qwen often drafts the reply as "Option: ..." or * "reply text" + last_quote = re.findall(r'["\u201c]([^"\u201d]{10,})["\u201d]', text) + if last_quote: + reply = last_quote[-1].strip() + think = thinking_prefix_re.sub('', text).strip() + return '' + think + '\n\n' + reply + # Truly no reply found + think = thinking_prefix_re.sub('', text).strip() + return '' + think + '' + + # Gemma-style: starts with reasoning ("The user", "I need", "I should", etc.) + stripped_text = text.lstrip() + first_line = stripped_text.split('\n')[0].strip() + reasoning_starts = ( + 'The user ', 'I need ', 'I should ', 'I will ', + 'They are ', 'The question ', 'I can ', + ) + reply_starts = ( + 'Hey', 'Hi ', 'Hi!', 'Hello', 'Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', + 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', + 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be", + ) + if any(first_line.startswith(p) for p in reasoning_starts): + # Try line-by-line split first + lines = stripped_text.split('\n') + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped: + continue + if i > 0 and any(stripped.startswith(p) for p in reply_starts): + think = '\n'.join(lines[:i]) + reply = '\n'.join(lines[i:]) + return '' + think + '\n' + reply + + # Try within-line split — model mashed thinking + reply on one line + # Look for reply pattern after a period or sentence end + for p in reply_starts: + # Match: "...reasoning text.Reply text" or "...reasoning text. Reply text" + pattern = r'([.!?])\s*(' + re.escape(p) + r')' + m = re.search(pattern, stripped_text) + if m and m.start() > 20: # at least 20 chars of reasoning before + think = stripped_text[:m.start() + 1] # include the period + reply = stripped_text[m.start() + 1:].lstrip() + return '' + think + '\n' + reply + + # Last resort: find last non-reasoning line + for i in range(len(lines) - 1, 0, -1): + stripped = lines[i].strip() + if stripped and not any(stripped.startswith(p) for p in reasoning_starts) and not stripped.startswith('*') and len(stripped) > 3: + think = '\n'.join(lines[:i]) + reply = '\n'.join(lines[i:]) + return '' + think + '\n' + reply + + return text + + +def _extract_thinking_meta(text: str) -> dict | None: + """Extract thinking content into metadata, return {thinking, reply, time} or None.""" + import re + if not text: + return None + + # Check for tags (native or injected) + time_match = re.search(r'([\s\S]*?)\s*([\s\S]*)', clean, re.IGNORECASE) + if think_match: + thinking = think_match.group(1).strip() + reply = think_match.group(2).strip() + # Only strip the thinking out into metadata when there's an actual reply + # left over. If reply is empty (model hit max_tokens inside , or + # the turn was reasoning-only), keep the raw text as content — otherwise + # the saved message has empty content and the bubble looks blank on + # reload. The renderer's processWithThinking still extracts the + # block visually at display time, so nothing changes for the normal case. + if thinking and reply: + return {"thinking": thinking, "reply": reply, "time": think_time} + + # Detect Thinking Process: or Gemma-style reasoning + normalized = _normalize_thinking(text) + if '' in normalized: + think_match2 = re.match(r'^[\s]*([\s\S]*?)\s*([\s\S]*)', normalized, re.IGNORECASE) + if think_match2: + thinking = think_match2.group(1).strip() + reply = think_match2.group(2).strip() + if thinking and reply: + return {"thinking": thinking, "reply": reply, "time": think_time} + + return None + + +def clean_thinking_for_save(content: str, metadata: dict | None = None) -> tuple[str, dict]: + """Extract thinking from content into metadata. Use for save paths that bypass save_assistant_response.""" + md = dict(metadata) if metadata else {} + info = _extract_thinking_meta(content) + if info: + md["thinking"] = info["thinking"] + if info.get("time"): + md["thinking_time"] = info["time"] + return info["reply"], md + return content, md + + +def save_assistant_response( + sess, + session_manager, + session_id: str, + full_response: str, + last_metrics: dict | None, + *, + character_name: str = None, + web_sources: list = None, + rag_sources: list = None, + research_sources: list = None, + used_memories: list = None, + do_research: bool = False, + tool_events: list = None, + incognito: bool = False, +): + """Add assistant response to session history. In incognito mode, keeps in-memory context but skips DB persistence.""" + md = dict(last_metrics) if last_metrics else {} + md["model"] = sess.model + if character_name: + md["character_name"] = character_name + if web_sources: + md["web_sources"] = web_sources + if rag_sources: + md["rag_sources"] = rag_sources + if research_sources: + md["research_sources"] = research_sources + if used_memories: + md["memories_used"] = used_memories + if do_research and not research_sources: + md["research_clarification"] = True + if tool_events: + md["tool_events"] = tool_events + + # Extract thinking into metadata (don't pollute message content with tags) + _think_info = _extract_thinking_meta(full_response) + if _think_info: + md["thinking"] = _think_info["thinking"] + md["thinking_time"] = _think_info.get("time") + _content = _think_info["reply"] + else: + _content = full_response + sess.add_message(ChatMessage("assistant", _content, metadata=md)) + + if not incognito: + from core.database import update_session_last_accessed + update_session_last_accessed(session_id) + session_manager.save_sessions() + + # Return the persisted message's DB id so the stream can wire it onto the + # freshly-rendered bubble — lets the user edit/delete a just-streamed reply + # without reloading. Incognito returns None: those messages are ephemeral, + # so we don't hand out an edit/delete handle for them. + if incognito: + return None + try: + _last = sess.history[-1] + _meta = getattr(_last, "metadata", None) + if isinstance(_meta, dict): + return _meta.get("_db_id") + except (IndexError, AttributeError): + pass + return None + + +def run_post_response_tasks( + sess, + session_manager, + session_id: str, + message: str, + full_response: str, + last_metrics: dict | None, + uprefs: dict, + memory_manager, + memory_vector, + webhook_manager, + *, + incognito: bool = False, + compare_mode: bool = False, + character_name: str = None, + agent_rounds: int = 0, + agent_tool_calls: int = 0, + skills_manager=None, + owner: str = None, + extract_skills: bool = True, +): + """Fire background tasks after a completed response: memory extraction, webhooks, auto-name, skill extraction.""" + # Memory extraction — only every 4th message pair to avoid excess LLM calls + _msg_count = len(sess.history) if hasattr(sess, 'history') else 0 + _should_extract = (_msg_count >= 4) and (_msg_count % 4 == 0) + if not incognito and not compare_mode and _should_extract and uprefs.get("auto_memory", True): + from services.memory.memory_extractor import extract_and_store + from src.task_endpoint import resolve_task_endpoint + t_url, t_model, t_headers = resolve_task_endpoint( + sess.endpoint_url, sess.model, sess.headers, + ) + asyncio.create_task(extract_and_store( + sess, memory_manager, memory_vector, + t_url, t_model, t_headers, + )) + + # Skill extraction from complex agent runs. Only when the user actually + # chose agent mode — not a chat we auto-escalated for a notes/calendar + # intent, and never in incognito/compare. + auto_skills_enabled = bool(uprefs.get("auto_skills", True)) + # Quiet by default — full gate/dispatch/start trace runs at DEBUG so + # users can re-enable diagnostics with LOG_LEVEL=DEBUG when something + # silently breaks. INFO-level only shows the outcome inside + # maybe_extract_skill (Auto-extracted / dropped / failed). + logger.debug( + "[skill-extract] gate: extract_skills=%s auto_skills=%s incognito=%s " + "compare=%s rounds=%d tools=%d skills_manager=%s", + extract_skills, auto_skills_enabled, incognito, compare_mode, + agent_rounds, agent_tool_calls, "set" if skills_manager else "MISSING", + ) + if ( + extract_skills + and auto_skills_enabled + and not incognito + and not compare_mode + and (agent_rounds >= 2 or agent_tool_calls >= 2) + ): + if skills_manager is None: + logger.warning( + "[skill-extract] gate PASSED but skills_manager is None — " + "extraction skipped. (Bug: caller didn't pass skills_manager.)" + ) + else: + from services.memory.skill_extractor import maybe_extract_skill + from src.task_endpoint import resolve_task_endpoint + s_url, s_model, s_headers = resolve_task_endpoint( + sess.endpoint_url, sess.model, sess.headers, + ) + logger.debug("[skill-extract] dispatching extractor (model=%s)", s_model) + asyncio.create_task(maybe_extract_skill( + sess, skills_manager, + s_url, s_model, s_headers, + agent_rounds, agent_tool_calls, + owner=owner, + )) + + # Token accumulation + if last_metrics: + accumulate_token_usage(session_id, last_metrics) + + # Webhook + if webhook_manager and not compare_mode: + asyncio.create_task(webhook_manager.fire("chat.completed", { + "session_id": session_id, "model": sess.model, + "user_message": message, "response": full_response[:2000], + })) + + # Auto-name + if needs_auto_name(sess.name): + asyncio.create_task(auto_name_session(session_manager, sess)) diff --git a/routes/chat_routes.py b/routes/chat_routes.py new file mode 100644 index 0000000..4e1edbc --- /dev/null +++ b/routes/chat_routes.py @@ -0,0 +1,1114 @@ +"""Chat routes — /api/chat, /api/chat_stream, /api/inject_context, /api/search.""" + +import asyncio +import json +import time +import logging +from typing import Dict, Any, AsyncGenerator, List + +from fastapi import APIRouter, Request, HTTPException, Form, Query +from fastapi.responses import StreamingResponse +from pydantic import ValidationError + +from core.models import ChatMessage +from src.request_models import ChatRequest +from src.llm_core import llm_call_async, stream_llm, stream_llm_with_fallback +from src.agent_loop import stream_agent_loop +from src import agent_runs +from src.model_context import estimate_tokens +from src.chat_helpers import coerce_message_and_session +from src.prompt_security import untrusted_context_message +from core.exceptions import SessionNotFoundError +from src.auth_helpers import get_current_user +from routes.session_routes import _verify_session_owner +from core.database import SessionLocal +from core.database import Session as DBSession, ChatMessage as DBChatMessage +from core.database import Document as DBDocument, ModelEndpoint +from routes.research_routes import _resolve_research_endpoint +from routes.chat_helpers import ( + resolve_session_auth, + build_chat_context, + save_assistant_response, + run_post_response_tasks, + clean_thinking_for_save, + _enforce_chat_privileges, +) + +logger = logging.getLogger(__name__) + +# Track active streams for partial-save safety net +_active_streams: Dict[str, dict] = {} + + +def _stream_set(session_id: str, **fields) -> None: + """Update fields on the active-stream entry for `session_id`, or + no-op if the entry has already been popped. Using .get() avoids a + KeyError race between `if x in d` and `d[x]["k"] = v` if a sibling + finally pops the key in between (which becomes possible the moment + a coroutine cancellation reaches an inner cleanup before the + outermost cleanup runs).""" + rec = _active_streams.get(session_id) + if rec is None: + return + rec.update(fields) + + +import re as _re +# Phrases that clearly signal the user wants to create a todo / reminder / +# calendar event. When any of these hit in plain chat mode we silently +# escalate to the agent loop so manage_notes / manage_calendar are in scope. +_TOOL_INTENT_PATTERNS = [ + _re.compile(r"\bremind\s+me\b", _re.I), + _re.compile(r"\badd\s+(a\s+|an\s+)?(todo|task|reminder)\b", _re.I), + _re.compile(r"\b(create|schedule|book)\s+(a\s+|an\s+)?(event|meeting|appointment|reminder|call)\b", _re.I), + _re.compile(r"\bput\s+.+\bon\s+(my\s+)?calendar\b", _re.I), + _re.compile(r"\b(todo|reminder)\s*:", _re.I), + _re.compile(r"\bmake\s+(a\s+|an\s+)?(note|todo|reminder)\b", _re.I), + # Email intent — "write/send/email/message [someone]", "write hi to X" + _re.compile(r"\b(write|send)\s+.{1,30}\bto\s+\w+", _re.I), + _re.compile(r"\b(send|write|reply)\s+(an?\s+)?(email|message|mail)\b", _re.I), + _re.compile(r"\b(email|message)\s+\w+\b", _re.I), + _re.compile(r"\bcheck\s+(my\s+)?(email|inbox|mail)\b", _re.I), + _re.compile(r"\bunread\s+(email|mail)s?\b", _re.I), + # Shell / remote-host intent — covers the deepseek "can you ssh into X" + # case. We escalate to agent so `bash` is available; the model can still + # decide it doesn't need to actually run anything. + _re.compile(r"\bssh\s+(in)?to\b", _re.I), + _re.compile(r"\bssh\s+\w+", _re.I), + _re.compile(r"\b(run|execute)\s+.{1,40}\bon\s+\w+", _re.I), + _re.compile(r"\b(can|could|please|would)\s+you\s+(run|execute|exec)\b", _re.I), + _re.compile(r"\b(deploy|build|install|restart|reboot|kill|tail|grep|cat|ls|cd|cp|mv|rm)\b\s+\S+", _re.I), + _re.compile(r"\b(check|see)\s+(if|whether|what)\s+.{1,40}\b(running|process|service|port|file|exists?)\b", _re.I), +] + +def _message_needs_tools(text: str) -> bool: + if not text: + return False + return any(p.search(text) for p in _TOOL_INTENT_PATTERNS) + + +def setup_chat_routes( + session_manager, + chat_handler, + chat_processor, + memory_manager, + research_handler, + upload_handler, + memory_vector=None, + webhook_manager=None, + skills_manager=None, +) -> APIRouter: + router = APIRouter(tags=["chat"]) + + # ------------------------------------------------------------------ # + # POST /api/chat (non-streaming) + # ------------------------------------------------------------------ # + @router.post("/api/chat", response_model=Dict[str, str]) + async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, str]: + message = chat_request.message + session = chat_request.session + att_ids = chat_request.attachments or [] + use_web = chat_request.use_web + use_research = chat_request.use_research + time_filter = chat_request.time_filter + preset_id = chat_request.preset_id + + # Verify the caller owns this session before loading it. + # Without this, any authenticated user can post into another user's chat. + _verify_session_owner(request, session) + + try: + sess = session_manager.get_session(session) + except KeyError: + raise HTTPException(404, f"Session '{session}' not found") + + # Same allowed_models + daily-cap gate as chat_stream (mirror so the + # non-streaming path can't be used to bypass). + _enforce_chat_privileges(request, sess) + + # Inline memory command + memory_response = await chat_handler.handle_memory_command(sess, message) + if memory_response: + return {"response": memory_response} + + # Build shared context (preset, preprocess, preface, compact) + ctx = await build_chat_context( + sess, request, chat_handler, chat_processor, + message=message, + session_id=session, + preset_id=preset_id, + att_ids=att_ids, + use_web=use_web, + time_filter=time_filter, + webhook_manager=webhook_manager, + ) + + # Research injection + if use_research: + try: + _r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess) + research_ctx = await research_handler.call_research_service( + message, _r_ep, _r_model, llm_headers=_r_headers + ) + ctx.messages.insert( + len(ctx.preface), + untrusted_context_message("research context", research_ctx), + ) + except Exception as e: + logger.error(f"Research failed: {e}") + + reply = await llm_call_async( + sess.endpoint_url, + sess.model, + ctx.messages, + headers=sess.headers, + temperature=ctx.preset.temperature, + max_tokens=ctx.preset.max_tokens, + prompt_type=preset_id, + ) + _clean_reply, _clean_md = clean_thinking_for_save(reply, {"model": sess.model}) + sess.add_message(ChatMessage("assistant", _clean_reply, metadata=_clean_md)) + + from core.database import update_session_last_accessed + update_session_last_accessed(session) + session_manager.save_sessions() + + # Background tasks (memory, webhook, auto-name) + run_post_response_tasks( + sess, session_manager, session, message, reply, None, + ctx.uprefs, memory_manager, memory_vector, webhook_manager, + character_name=ctx.preset.character_name, + owner=ctx.user, + ) + + return {"response": reply} + + # ------------------------------------------------------------------ # + # POST /api/chat_stream + # ------------------------------------------------------------------ # + @router.post("/api/chat_stream") + async def chat_stream(request: Request) -> StreamingResponse: + body = None + try: + if request.headers.get("content-type", "").startswith("application/json"): + try: + body = await request.json() + except json.JSONDecodeError as e: + raise HTTPException(400, f"Invalid JSON: {e}") + except HTTPException: + raise + except Exception as e: + raise HTTPException(400, f"Request parsing error: {e}") + + # Stash the user's UTC offset (in minutes east of UTC) from the + # frontend so tools like manage_notes interpret natural-language + # times in the USER's tz, not the server's. See calendar_routes. + try: + _tz_hdr = request.headers.get("x-tz-offset") + if _tz_hdr is not None: + from routes.calendar_routes import set_user_tz_offset + set_user_tz_offset(_tz_hdr) + except Exception: + pass + + form_data = await request.form() + message = form_data.get("message") + session = form_data.get("session") + attachments = form_data.get("attachments") + use_web = form_data.get("use_web") + use_research = form_data.get("use_research") + time_filter = form_data.get("time_filter") + preset_id = form_data.get("preset_id") + allow_bash = form_data.get("allow_bash") + allow_web_search = form_data.get("allow_web_search") + use_rag = form_data.get("use_rag") + search_context = form_data.get("search_context") # pre-fetched web search results (compare mode) + compare_mode = str(form_data.get("compare_mode", "")).lower() == "true" + incognito = str(form_data.get("incognito", "")).lower() == "true" + chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent' + # Did the USER explicitly pick agent mode? (vs. us auto-escalating + # below). Skill extraction should only learn from real agent sessions, + # not chats we quietly promoted for a notes/calendar intent. + user_requested_agent = (chat_mode == "agent") + # Intent auto-escalation: if the user is clearly asking the assistant + # to create a todo, reminder, or calendar event, promote chat → agent + # for this turn so the LLM has access to manage_notes / manage_calendar. + # This is a LIGHT promotion — see the disabled_tools block below, which + # withholds shell/code/file tools so the model doesn't try to `bash` + # its way through a plain chat request (and fail, especially with the + # shell disabled). + auto_escalated = False + if chat_mode == "chat" and isinstance(message, str) and _message_needs_tools(message): + chat_mode = "agent" + auto_escalated = True + logger.info("chat→agent auto-escalation: message matched tool-intent pattern") + active_doc_id = form_data.get("active_doc_id", "").strip() + logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}") + + try: + # Attachment-only sends: skip the message-required check when the + # user has attached one or more files (the attachment IS the action). + _has_atts = ( + bool(body and isinstance(body.get("attachments"), list) and body["attachments"]) + or bool(form_data.get("attachments")) + ) + message, session = coerce_message_and_session( + body, message, session, session_manager, allow_empty=_has_atts, + ) + # Verify ownership AFTER coerce (which may resolve a default session) + # but BEFORE loading. Prevents cross-user session hijack. + _verify_session_owner(request, session) + sess = session_manager.get_session(session) + except SessionNotFoundError as e: + raise HTTPException(404, str(e)) + except (ValueError, ValidationError): + raise HTTPException(400, "Invalid request parameters") + + # ------------------------------------------------------------------ # + # Privilege gates that must fire BEFORE any LLM work / token spend. + # 1. allowed_models — reject if session.model isn't in the user's + # configured allowlist (empty list = "no restriction"). + # 2. max_messages_per_day — count user-role ChatMessage rows owned + # by this user in the last UTC day; 429 if at/over the cap. + # Admins always have full privileges via get_privileges (returns + # ADMIN_PRIVILEGES wholesale) so this is a no-op for them. + _enforce_chat_privileges(request, sess) + + # Ensure session has auth headers + resolve_session_auth(sess, session) + + # Check for research_pending BEFORE mode persist overwrites it + do_research = str(use_research).lower() == "true" + if not do_research: + try: + _mode_db = SessionLocal() + _db_mode = _mode_db.query(DBSession.mode).filter(DBSession.id == session).scalar() + _mode_db.close() + if _db_mode == 'research_pending': + do_research = True + logger.info(f"Session {session} in research_pending — auto-triggering research") + except Exception: + pass + + # Persist session mode (research > agent > chat) + _effective_mode = 'research' if do_research else (chat_mode or 'chat') + if _effective_mode in ('agent', 'research', 'chat'): + try: + _mdb = SessionLocal() + _mdb.query(DBSession).filter(DBSession.id == session).update({"mode": _effective_mode}) + _mdb.commit() + _mdb.close() + except Exception as _me: + logger.warning("Failed to persist session mode: %s", _me) + + att_ids = [] + if body and isinstance(body.get("attachments"), list): + att_ids = [str(x) for x in body["attachments"]] + elif attachments: + try: + att_ids = [str(x) for x in json.loads(attachments)] + except Exception: + pass + + no_memory = str(form_data.get("no_memory", "")).lower() == "true" + + # Build shared context (stream path uses enhanced_message for context preface) + ctx = await build_chat_context( + sess, request, chat_handler, chat_processor, + message=message, + session_id=session, + preset_id=preset_id, + att_ids=att_ids, + use_web=use_web, + use_rag=use_rag, + time_filter=time_filter, + incognito=incognito, + no_memory=no_memory, + search_context=search_context, + compare_mode=compare_mode, + webhook_manager=webhook_manager, + use_enhanced_message=True, + # Skills index only ships when the model can actually call + # manage_skills (agent mode). In plain chat or incognito the + # index would be useless / unwanted noise. + agent_mode=(chat_mode == "agent"), + ) + + _research_flags = {"do": do_research} # Mutable container for generator scope + + # Query active document — prefer explicit ID from frontend, fall back to session lookup + active_doc = None + _doc_db = SessionLocal() + try: + if active_doc_id: + logger.info(f"[doc-inject] active_doc_id from frontend: {active_doc_id}") + active_doc = _doc_db.query(DBDocument).filter( + DBDocument.id == active_doc_id, + ).first() + if active_doc: + logger.info(f"[doc-inject] found by ID: title={active_doc.title!r}, lang={active_doc.language!r}, is_active={active_doc.is_active}, content_len={len(active_doc.current_content or '')}") + else: + logger.warning(f"[doc-inject] NOT FOUND by ID {active_doc_id}") + if not active_doc: + active_doc = _doc_db.query(DBDocument).filter( + DBDocument.session_id == session, + DBDocument.is_active == True + ).order_by(DBDocument.updated_at.desc()).first() + if active_doc: + logger.info(f"[doc-inject] found by session fallback: title={active_doc.title!r}") + # Last resort: the document the agent itself just created/edited + # (tracked in-memory by the tool layer). This rescues docs that + # got orphaned from their session (session_id NULL) — otherwise + # neither lookup above can associate them with this conversation, + # so the agent never sees what it just wrote. Guarded so we never + # leak a doc that belongs to a DIFFERENT session. + if not active_doc: + try: + from src.tool_implementations import get_active_document + _mem_id = get_active_document() + if _mem_id: + cand = _doc_db.query(DBDocument).filter(DBDocument.id == _mem_id).first() + if cand and (not cand.session_id or cand.session_id == session): + active_doc = cand + logger.info(f"[doc-inject] found by in-memory active id: title={active_doc.title!r} (session_id={cand.session_id!r})") + except Exception as _e: + logger.debug(f"[doc-inject] in-memory fallback failed: {_e}") + if not active_doc: + logger.info(f"[doc-inject] no active doc for session {session}") + if active_doc: + _doc_db.expunge(active_doc) + except Exception as e: + logger.warning(f"Failed to query active document: {e}") + finally: + _doc_db.close() + + # Build disabled-tools set from frontend toggles + user privileges + disabled_tools = set() + if str(allow_bash).lower() != "true": + disabled_tools.add("bash") + if str(allow_web_search).lower() != "true": + disabled_tools.add("web_search") + + # Nobody/incognito mode: deny tools that would expose the user's + # persistent memory, past chats, or other identity-linked data. + if incognito: + disabled_tools.update({ + "manage_memory", # persistent memory store + "search_chats", # past chat history + "manage_skills", # skill presets tied to user + }) + + # Enforce per-user privileges + _privs = {} + _user = ctx.user + if _user and hasattr(request.app.state, 'auth_manager') and request.app.state.auth_manager: + _privs = request.app.state.auth_manager.get_privileges(_user) + if _privs: + if not _privs.get("can_use_bash", True): + disabled_tools.update({"bash", "python", "read_file", "write_file"}) + if not _privs.get("can_use_browser", True): + disabled_tools.add("builtin_browser") + if not _privs.get("can_use_documents", True): + disabled_tools.update({"create_document", "edit_document", "update_document", "suggest_document"}) + if not _privs.get("can_generate_images", True): + disabled_tools.add("generate_image") + if not _privs.get("can_manage_memory", True): + disabled_tools.update({"manage_memory", "manage_skills"}) + if not _privs.get("can_use_research", True): + _research_flags["do"] = False + if not _privs.get("can_use_agent", True): + _effective_mode = 'chat' + chat_mode = 'chat' + # Global admin disabled tools + from src.settings import get_setting + _global_disabled = get_setting("disabled_tools", []) + if _global_disabled and isinstance(_global_disabled, list): + disabled_tools.update(_global_disabled) + + # Light auto-escalation: the user is in chat mode and just expressed a + # notes/calendar/email intent. Grant the relevant managers but withhold + # the heavy "do things on the computer" tools — otherwise the model + # tries to shell out for a request that never needed it, then fails + # (and looks broken when the shell is disabled). + if auto_escalated: + disabled_tools.update({ + "bash", "python", "read_file", "write_file", "builtin_browser", + }) + + # Disable document tools in compare sessions — they break the pane UI + if sess.name and sess.name.startswith("[CMP]"): + disabled_tools.update({"create_document", "edit_document", "update_document"}) + + # Compare mode: disable tools based on compare type + if compare_mode: + _compare_strip = { + "create_document", "edit_document", "update_document", + "chat_with_model", "create_session", "list_sessions", + "send_to_session", + "pipeline", "manage_session", "manage_memory", "list_models", + "generate_image", "ui_control", + } + disabled_tools.update(_compare_strip) + # In chat mode compare, disable ALL agent tools (no bash, python, file ops) + if chat_mode == 'chat': + disabled_tools.update({"bash", "python", "read_file", "write_file", "web_search", "search_chats", "manage_tasks"}) + + async def stream_with_save() -> AsyncGenerator[str, None]: + # _effective_mode is read-only here; closure captures it from + # the outer scope. (Was `nonlocal` but never reassigned.) + research_sources = None + web_sources = ctx.web_sources + + # Register active stream for partial-save safety net + _active_streams[session] = {"status": "streaming", "partial": "", "query": message, "is_research": do_research, "mode": _effective_mode} + + if ctx.preprocessed.attachment_meta: + yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n" + + # Announce any docs auto-created during preprocess (e.g. fillable + # PDF → editable markdown) so the editor pane switches to them + # before the model starts streaming. + for _opened in ctx.auto_opened_docs: + yield ( + f'data: {json.dumps({"type": "doc_update", **_opened})}\n\n' + ) + + if ctx.rag_sources: + yield f"data: {json.dumps({'type': 'rag_sources', 'data': ctx.rag_sources})}\n\n" + + if web_sources: + yield f"data: {json.dumps({'type': 'web_sources', 'data': web_sources})}\n\n" + + # Emit which memories were injected into context (captured before stream) + if ctx.used_memories: + yield f"data: {json.dumps({'type': 'memories_used', 'data': ctx.used_memories})}\n\n" + + # Run research as a background task (survives page refresh) + if do_research and _research_flags["do"]: + _r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess) + _auth_keys = list(_r_headers.keys()) if _r_headers else [] + logger.info(f"Research endpoint resolved: model={_r_model}, endpoint={_r_ep}, auth_keys={_auth_keys}, sess_headers_keys={list(sess.headers.keys()) if isinstance(sess.headers, dict) else type(sess.headers)}") + + # Clarification round: only for very short/vague queries on first research message. + # Skip in compare mode — each pane is a fresh session, so every one would + # ask clarifying questions and the user would have to answer each pane + # separately, breaking the parallel comparison. + _prior_json = research_handler._get_session_json(session) + _history_len = len(sess.history) if hasattr(sess, 'history') else 0 + _is_first_research = not _prior_json and _history_len <= 2 and not compare_mode + + if _is_first_research: + logger.info(f"First research message — asking clarifying questions for: {message[:60]}") + yield f'data: {json.dumps({"type": "model_info", "model": sess.model, "suffix": "Research"})}\n\n' + # Set DB mode to research_pending so the NEXT message auto-triggers research + try: + _pdb = SessionLocal() + _pdb.query(DBSession).filter(DBSession.id == session).update({"mode": "research_pending"}) + _pdb.commit() + _pdb.close() + except Exception as _pe: + logger.warning(f"Failed to set research_pending: {_pe}") + ctx.messages.insert(0, {"role": "system", "content": + "The user wants to start deep web research. Before searching, ask 2-3 brief " + "clarifying questions to understand exactly what they want to know. For example: " + "what aspects matter most, are they comparing to something, what's their context " + "(moving, traveling, curiosity). Be conversational. Keep it short." + }) + _skip_research = True + else: + _skip_research = False + + if not _skip_research: + # Phase 2: Start actual research + def _on_research_done(_sid, _result, _sources, _findings): + """Persist research to DB when background task finishes.""" + if incognito: + return + try: + _s = session_manager.get_session(_sid) + if not _s: + logger.warning(f"Session {_sid} expired before research completed") + return + _md = {"research": True, "model": _s.model} + if _sources: + _md["research_sources"] = _sources + if _findings: + _md["research_findings"] = _findings + _clean_res, _md = clean_thinking_for_save(_result, _md) + _s.add_message(ChatMessage("assistant", _clean_res, metadata=_md)) + session_manager.save_sessions() + logger.info(f"Research result persisted to DB for session {_sid}") + except Exception as _e: + logger.error(f"Failed to persist research to DB: {_e}") + + # Check for prior research to continue from + _prior_report = "" + _prior_findings = None + _prior_urls = None + _prior_json = research_handler._get_session_json(session) + if _prior_json: + _prior_report = _prior_json.get("raw_report", "") + _prior_findings = _prior_json.get("raw_findings") + _src_urls = {s.get("url", "") for s in (_prior_json.get("sources") or []) if s.get("url")} + _prior_urls = _src_urls if _src_urls else None + if _prior_report: + logger.info(f"Continuing research for session {session} with {len(_src_urls)} prior URLs") + + # Synthesize conversation into a focused research query + _research_query = await research_handler.synthesize_query( + sess, message, _r_ep, _r_model, _r_headers, + ) + logger.info(f"Research query: {_research_query[:120]}") + + research_handler.start_research( + session, _research_query, _r_ep, _r_model, + llm_headers=_r_headers, + prior_report=_prior_report, + prior_findings=_prior_findings, + prior_urls=_prior_urls, + on_complete=_on_research_done, + ) + + _heartbeat_counter = 0 + _last_progress = {} + _sent_avg = False + while True: + status = research_handler.get_status(session) + if not status or status["status"] != "running": + break + progress = status.get("progress", {}) + if progress and progress != _last_progress: + _last_progress = progress + if not _sent_avg: + _sent_avg = True + progress = dict(progress) + progress["started_at"] = status.get("started_at") + avg = status.get("avg_duration") + if avg: + progress["avg_duration"] = avg + yield f"data: {json.dumps({'type': 'research_progress', 'data': progress})}\n\n" + _heartbeat_counter = 0 + else: + _heartbeat_counter += 1 + yield f": heartbeat {_heartbeat_counter}\n\n" + await asyncio.sleep(1.0) + + research_sources = research_handler.get_sources(session) + if research_sources: + yield f"data: {json.dumps({'type': 'research_sources', 'data': research_sources})}\n\n" + + research_findings = research_handler.get_raw_findings(session) + if research_findings: + yield f"data: {json.dumps({'type': 'research_findings', 'data': research_findings})}\n\n" + + # Signal frontend to fetch and render the research result + yield f"data: {json.dumps({'type': 'research_done', 'data': {'session_id': session}})}\n\n" + yield "data: [DONE]\n\n" + research_handler.clear_result(session) + _stream_set(session, status="done") + _active_streams.pop(session, None) + return + + messages = ctx.messages + + # Auto-compact notification + if ctx.was_compacted: + yield f"data: {json.dumps({'type': 'compacted', 'context_length': ctx.context_length})}\n\n" + + full_response = "" + last_metrics = None + + # Configured fallback chain for the default chat model. Tried in + # order if the session's primary model fails before producing + # output. Resolved once per request. + try: + from src.endpoint_resolver import resolve_chat_fallback_candidates + _fallback_candidates = resolve_chat_fallback_candidates() + except Exception: + _fallback_candidates = [] + + # Send model name early so the frontend can show it during streaming + _model_suffix = "Research" if do_research else None + _model_info = {"type": "model_info", "model": sess.model} + if _model_suffix: + _model_info["suffix"] = _model_suffix + if ctx.preset.character_name: + _model_info["character_name"] = ctx.preset.character_name + yield f'data: {json.dumps(_model_info)}\n\n' + + # Detect image models and route directly to image generation + _IMAGE_MODEL_PREFIXES = ("gpt-image", "dall-e", "chatgpt-image") + _is_image_model = any(sess.model.lower().startswith(p) for p in _IMAGE_MODEL_PREFIXES) + + # Also check if the endpoint is registered as an image-type endpoint + if not _is_image_model: + try: + from src.endpoint_resolver import normalize_base as _nb + _ep_base = _nb(sess.endpoint_url) + _db = SessionLocal() + try: + _is_image_model = _db.query(ModelEndpoint).filter( + ModelEndpoint.model_type == "image", + ModelEndpoint.is_enabled == True, + ModelEndpoint.base_url.contains(_ep_base.split("://")[-1].split("/")[0]), + ).first() is not None + finally: + _db.close() + except Exception: + pass + + if _is_image_model: + from src.settings import get_setting + if not get_setting("image_gen_enabled", True): + yield f'data: {json.dumps({"delta": "Image generation is disabled by the administrator."})}\n\n' + yield "data: [DONE]\n\n" + _active_streams.pop(session, None) + return + from src.ai_interaction import do_generate_image + _user_msg = message or "" + yield f'data: {json.dumps({"type": "tool_start", "tool": "generate_image", "command": _user_msg[:100]})}\n\n' + yield ": heartbeat\n\n" + _img_result = await do_generate_image(f"{_user_msg}\n{sess.model}", session) + _img_output = _img_result.get("results", _img_result.get("error", "")) + _img_tool_data = {"type": "tool_output", "tool": "generate_image", "command": _user_msg[:100], "output": _img_output, "exit_code": 0 if "error" not in _img_result else 1} + for _k in ("image_url", "image_id", "image_prompt", "image_model", "image_size", "image_quality"): + if _k in _img_result: + _img_tool_data[_k] = _img_result[_k] + yield f'data: {json.dumps(_img_tool_data)}\n\n' + _desc = _img_result.get("results", _img_result.get("error", "Image generation complete")) + full_response = _desc + yield f'data: {json.dumps({"delta": _desc})}\n\n' + # Save to session history + if not incognito: + _ev = {"round": 1, "tool": "generate_image", "command": _user_msg[:100], "output": _img_output, "exit_code": 0 if "error" not in _img_result else 1} + for _ek in ("image_url", "image_id", "image_prompt", "image_model", "image_size", "image_quality"): + if _img_result.get(_ek): + _ev[_ek] = _img_result[_ek] + sess.add_message(ChatMessage("assistant", full_response, metadata={"tool_events": [_ev], "model": sess.model})) + session_manager.save_sessions() + yield f'data: {json.dumps({"type": "metrics", "data": {"total_time": 0}})}\n\n' + yield "data: [DONE]\n\n" + _active_streams.pop(session, None) + return + elif chat_mode == "chat": + _chat_start = time.time() + # ── Chat mode: call stream_llm directly, NO tools, NO document access ── + try: + _chat_candidates = [(sess.endpoint_url, sess.model, sess.headers)] + _fallback_candidates + async for chunk in stream_llm_with_fallback( + _chat_candidates, + messages, + temperature=ctx.preset.temperature, + # Respect the preset; 0/unset = let the server decide (no + # cap), matching agent mode. The old hard 4096 fallback + # truncated reasoning models mid- — they'd burn the + # whole budget thinking and never emit the answer (seen in + # Compare on heavy generation prompts). + max_tokens=ctx.preset.max_tokens, + prompt_type=preset_id, + tools=None, + ): + if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): + try: + data = json.loads(chunk[6:]) + if "delta" in data: + full_response += data["delta"] + _stream_set(session, partial=full_response) + yield chunk + elif data.get("type") == "usage": + last_metrics = data.get("data", {}) + last_metrics["model"] = sess.model + if ctx.context_length and last_metrics.get("input_tokens"): + pct = min(round((last_metrics["input_tokens"] / ctx.context_length) * 100, 1), 100.0) + last_metrics["context_percent"] = pct + last_metrics["context_length"] = ctx.context_length + yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n' + except json.JSONDecodeError: + yield chunk + elif chunk.startswith("event: error"): + logger.warning(f"Stream error for {sess.model} on {sess.endpoint_url}: {chunk!r}") + yield chunk + elif chunk.startswith("event: "): + yield chunk + elif chunk == "data: [DONE]\n\n": + # Generate fallback metrics if LLM didn't send usage + if not last_metrics and full_response: + _elapsed = time.time() - _chat_start + _est_in = estimate_tokens(messages) + _est_out = len(full_response) // 4 + _tps = round(_est_out / _elapsed, 2) if _elapsed > 0 else 0 + _ctx_pct = min(round((_est_in / ctx.context_length) * 100, 1), 100.0) if ctx.context_length else 0 + last_metrics = { + "response_time": round(_elapsed, 2), + "input_tokens": _est_in, + "output_tokens": _est_out, + "tokens_per_second": _tps, + "context_percent": _ctx_pct, + "context_length": ctx.context_length, + "model": sess.model, + "usage_source": "estimated", + } + yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n' + if full_response: + _saved_id = save_assistant_response( + sess, session_manager, session, full_response, last_metrics, + character_name=ctx.preset.character_name, + web_sources=web_sources, + rag_sources=ctx.rag_sources, + research_sources=research_sources, + used_memories=ctx.used_memories, + do_research=do_research, + incognito=incognito, + ) + if _saved_id: + yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n' + run_post_response_tasks( + sess, session_manager, session, message, full_response, + last_metrics, ctx.uprefs, memory_manager, memory_vector, webhook_manager, + incognito=incognito, compare_mode=compare_mode, + character_name=ctx.preset.character_name, + owner=_user, + ) + _stream_set(session, status="done") + yield chunk + except (asyncio.CancelledError, GeneratorExit): + if full_response: + logger.info("Client disconnected mid-stream (chat mode) for session %s, saving partial (%d chars)", session, len(full_response)) + _stopped_content, _stopped_md = clean_thinking_for_save(full_response, {"stopped": True, "model": sess.model}) + sess.add_message(ChatMessage("assistant", _stopped_content, metadata=_stopped_md)) + if not incognito: + session_manager.save_sessions() + raise + finally: + _active_streams.pop(session, None) + else: + # ── Agent mode: full agent loop with tools ── + _agent_rounds = 0 + _agent_tool_calls = 0 + try: + from src.settings import get_setting + _tool_budget = int(get_setting("agent_max_tool_calls", 0)) + + async for chunk in stream_agent_loop( + sess.endpoint_url, + sess.model, + messages, + headers=sess.headers, + temperature=ctx.preset.temperature, + max_tokens=ctx.preset.max_tokens, + prompt_type=preset_id, + max_tool_calls=_tool_budget, + context_length=ctx.context_length, + active_document=active_doc, + session_id=session, + disabled_tools=disabled_tools if disabled_tools else None, + owner=_user, + fallbacks=_fallback_candidates, + ): + if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): + try: + data = json.loads(chunk[6:]) + if "delta" in data: + full_response += data["delta"] + _stream_set(session, partial=full_response) + yield chunk + elif data.get("type") == "web_sources": + web_sources = data.get("data", []) + yield chunk + elif data.get("type") in ( + "tool_start", "tool_output", "agent_step", + "doc_stream_open", "doc_stream_delta", + "doc_update", "doc_suggestions", "ui_control", + ): + if data.get("type") == "agent_step": + _agent_rounds = max(_agent_rounds, data.get("round", 1)) + elif data.get("type") == "tool_start": + _agent_tool_calls += 1 + yield chunk + elif data.get("type") == "metrics": + last_metrics = data.get("data", {}) + last_metrics["model"] = sess.model + yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n' + except json.JSONDecodeError: + yield chunk + elif chunk.startswith("event: "): + yield chunk + elif chunk == "data: [DONE]\n\n": + if full_response: + _saved_id = save_assistant_response( + sess, session_manager, session, full_response, last_metrics, + character_name=ctx.preset.character_name, + web_sources=web_sources, + rag_sources=ctx.rag_sources, + used_memories=ctx.used_memories, + incognito=incognito, + ) + if _saved_id: + yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n' + run_post_response_tasks( + sess, session_manager, session, message, full_response, + last_metrics, ctx.uprefs, memory_manager, memory_vector, webhook_manager, + incognito=incognito, compare_mode=compare_mode, + character_name=ctx.preset.character_name, + agent_rounds=_agent_rounds, + agent_tool_calls=_agent_tool_calls, + skills_manager=skills_manager, + owner=_user, + extract_skills=user_requested_agent, + ) + _stream_set(session, status="done") + yield chunk + except (asyncio.CancelledError, GeneratorExit): + # Client disconnected — save partial response. Wrap + # the save in its own try so an exception inside + # add_message / save_sessions doesn't mask the + # original CancelledError (which prevented the + # outer finally from running and left _active_streams + # with a stale entry). + try: + if full_response: + logger.info("Client disconnected mid-stream for session %s, saving partial response (%d chars)", session, len(full_response)) + _stopped_content2, _stopped_md2 = clean_thinking_for_save(full_response, {"stopped": True, "model": sess.model}) + sess.add_message(ChatMessage("assistant", _stopped_content2, metadata=_stopped_md2)) + if not incognito: + session_manager.save_sessions() + except Exception: + logger.exception("Failed to save partial response on disconnect (session %s)", session) + raise + finally: + _active_streams.pop(session, None) + + async def _safe_stream() -> AsyncGenerator[str, None]: + """Wrapper that guarantees _active_streams cleanup even if stream_with_save + raises before reaching a mode-specific finally block.""" + try: + async for chunk in stream_with_save(): + yield chunk + finally: + _active_streams.pop(session, None) + + # Run the stream as a DETACHED background task so it survives the client + # closing the tab / navigating away (true terminal-agent behavior). The + # SSE response just subscribes (replay buffered output + live); dropping + # the SSE only removes a subscriber — the run keeps going and saves the + # assistant message on completion regardless. Reconnect via /api/chat/resume. + agent_runs.start(session, _safe_stream()) + return StreamingResponse(agent_runs.subscribe(session), media_type="text/event-stream") + + # ------------------------------------------------------------------ # + # GET /api/chat/resume — reconnect to a detached run that's still going + # (e.g. after reopening a session whose agent kept running in the background) + # ------------------------------------------------------------------ # + @router.get("/api/chat/resume/{session_id}") + async def chat_resume(request: Request, session_id: str) -> StreamingResponse: + _verify_session_owner(request, session_id) + if not agent_runs.is_active(session_id): + raise HTTPException(404, "No active run for this session") + return StreamingResponse(agent_runs.subscribe(session_id), media_type="text/event-stream") + + # ------------------------------------------------------------------ # + # POST /api/chat/stop — cancel a detached run (Stop button). Closing the SSE + # no longer stops it (it's detached), so the Stop button must call this. + # ------------------------------------------------------------------ # + @router.post("/api/chat/stop/{session_id}") + async def chat_stop(request: Request, session_id: str) -> Dict[str, Any]: + _verify_session_owner(request, session_id) + stopped = agent_runs.stop(session_id) + return {"stopped": stopped} + + # ------------------------------------------------------------------ # + # GET /api/chat/stream_status — check if a stream is active for a session + # ------------------------------------------------------------------ # + @router.get("/api/chat/stream_status/{session_id}") + async def chat_stream_status(request: Request, session_id: str) -> Dict[str, Any]: + _verify_session_owner(request, session_id) + # A detached run can still be going even if _active_streams was popped; + # report it as active so the client knows to reconnect via /resume. + if session_id not in _active_streams: + if agent_runs.is_active(session_id): + return {"status": "streaming", "detached": True} + raise HTTPException(404, "No active stream for this session") + return _active_streams[session_id] + + # ------------------------------------------------------------------ # + # POST /api/inject_context + # ------------------------------------------------------------------ # + @router.post("/api/inject_context/{session_id}") + async def inject_context(request: Request, session_id: str, context: str = Form(...)) -> Dict[str, str]: + _verify_session_owner(request, session_id) + try: + sess = session_manager.get_session(session_id) + msg = untrusted_context_message("injected research context", f"Research Context: {context}") + sess.add_message(ChatMessage(msg["role"], msg["content"], metadata=msg.get("metadata"))) + session_manager.save_sessions() + return {"status": "context_injected"} + except KeyError: + raise HTTPException(404, "Session not found") + + # ------------------------------------------------------------------ # + # GET /api/search — search across chat messages + # ------------------------------------------------------------------ # + @router.get("/api/search") + async def search_messages( + request: Request, + q: str = Query("", min_length=0), + limit: int = Query(20, ge=1, le=100), + ) -> List[Dict[str, Any]]: + if not q or not q.strip(): + return [] + + _user = get_current_user(request) + query_term = q.strip() + db = SessionLocal() + try: + base_q = ( + db.query(DBChatMessage, DBSession.name) + .join(DBSession, DBChatMessage.session_id == DBSession.id) + .filter( + DBSession.archived == False, + DBChatMessage.content.ilike(f"%{query_term}%"), + DBChatMessage.role.in_(["user", "assistant"]), + ) + ) + if _user: + base_q = base_q.filter(DBSession.owner == _user) + rows = base_q.order_by(DBChatMessage.timestamp.desc()).limit(limit).all() + + results = [] + for msg, session_name in rows: + content = msg.content or "" + lower_content = content.lower() + idx = lower_content.find(query_term.lower()) + if idx == -1: + snippet = content[:120] + else: + start = max(0, idx - 50) + end = min(len(content), idx + len(query_term) + 50) + snippet = ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "") + + results.append({ + "session_id": msg.session_id, + "session_name": session_name or "Untitled", + "role": msg.role, + "content_snippet": snippet, + "timestamp": msg.timestamp.isoformat() if msg.timestamp else None, + }) + + return results + finally: + db.close() + + # ------------------------------------------------------------------ # + # POST /api/rewrite — lightweight rewrite of last AI message (no tools) + # ------------------------------------------------------------------ # + @router.post("/api/rewrite") + async def rewrite_message(request: Request) -> StreamingResponse: + """Rewrite the last AI message with an instruction (shorter/simpler/etc). + + Unlike the full chat pipeline, this does NOT run the agent loop or tools. + It just asks the LLM to rewrite the given text. + """ + try: + body = await request.json() + except Exception: + raise HTTPException(400, "Invalid JSON") + + session_id = body.get("session_id") + original_text = body.get("original_text", "") + instruction = body.get("instruction", "") + + if not session_id or not original_text or not instruction: + raise HTTPException(400, "session_id, original_text, and instruction are required") + + _verify_session_owner(request, session_id) + + try: + sess = session_manager.get_session(session_id) + except (KeyError, SessionNotFoundError): + raise HTTPException(404, "Session not found") + + messages = [ + {"role": "system", "content": ( + "You are rewriting a previous response. Follow the instruction exactly. " + "Output ONLY the rewritten text — no preamble, no explanation, no meta-commentary. " + "Preserve any formatting (markdown, code blocks, lists) from the original." + )}, + {"role": "user", "content": ( + f"Here is the original response:\n\n{original_text}\n\n" + f"Instruction: {instruction}" + )}, + ] + + async def stream_rewrite() -> AsyncGenerator[str, None]: + full_response = "" + try: + async for chunk in stream_llm( + sess.endpoint_url, + sess.model, + messages, + headers=sess.headers, + temperature=0.7, + # 0 = let the server decide (no cap). A hardcoded 4096 made + # local reasoning models (Qwen3 / R1) burn the whole budget + # inside and emit no rewrite — the bubble just hung + # on "Rewriting...". Same fix as the chat max_tokens cap. + max_tokens=0, + tools=None, + ): + if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): + try: + data = json.loads(chunk[6:]) + if "delta" in data: + # Forward the chunk (so the client can show a + # thinking indicator) but DON'T fold reasoning + # tokens into the saved rewrite — only real + # content. reasoning_content arrives flagged + # with thinking:true. + if not data.get("thinking"): + full_response += data["delta"] + yield chunk + except json.JSONDecodeError: + yield chunk + elif chunk.startswith("event: "): + yield chunk + elif chunk == "data: [DONE]\n\n": + # Update the last assistant message in session history. + # Strip reasoning-model blocks so the persisted + # rewrite is just the rewritten text, not its scratchpad. + from src.research_utils import strip_thinking + full_response = strip_thinking(full_response).strip() or full_response + if full_response: + for msg in reversed(sess.history): + if (isinstance(msg, ChatMessage) and msg.role == 'assistant') or \ + (isinstance(msg, dict) and msg.get('role') == 'assistant'): + if isinstance(msg, ChatMessage): + msg.content = full_response + else: + msg['content'] = full_response + break + # Update in DB too + db = SessionLocal() + try: + db_msg = ( + db.query(DBChatMessage) + .filter(DBChatMessage.session_id == session_id, DBChatMessage.role == 'assistant') + .order_by(DBChatMessage.created_at.desc()) + .first() + ) + if db_msg: + db_msg.content = full_response + db.commit() + except Exception as e: + logger.warning("Failed to update rewritten message in DB: %s", e) + db.rollback() + finally: + db.close() + session_manager.save_sessions() + yield chunk + except Exception as e: + logger.error("Rewrite stream error: %s", e) + yield f'event: error\ndata: {json.dumps({"error": str(e), "status": 500})}\n\n' + + return StreamingResponse(stream_rewrite(), media_type="text/event-stream") + + return router diff --git a/routes/cleanup_routes.py b/routes/cleanup_routes.py new file mode 100644 index 0000000..ce1b63b --- /dev/null +++ b/routes/cleanup_routes.py @@ -0,0 +1,60 @@ +# routes/cleanup_routes.py +"""Routes for cleanup operations.""" +import logging +from fastapi import APIRouter, HTTPException, Request +from src.cleanup_service import get_cleanup_preview, cleanup_sessions +from src.auth_helpers import get_current_user + +logger = logging.getLogger(__name__) + +def setup_cleanup_routes(session_manager): + """ + Setup cleanup-related routes. + + Args: + session_manager: SessionManager instance + + Returns: + APIRouter instance with cleanup routes + """ + router = APIRouter(prefix="/api/cleanup") + + @router.get("/preview") + async def cleanup_preview(request: Request): + """ + Preview what would be cleaned up without making any changes. + + Returns: + JSON response with lists of sessions that would be archived/deleted and estimated space savings + """ + user = get_current_user(request) + try: + preview = await get_cleanup_preview(owner=user) + return preview + except Exception as e: + logger.error(f"Cleanup preview failed: {e}") + raise HTTPException(500, "Cleanup preview generation failed") + + @router.post("") + async def cleanup_endpoint(request: Request): + """ + Perform cleanup operations: + 1. Archive inactive sessions (not accessed for 7 days) + 2. Delete old sessions (archived, not important, not accessed for 14+ days, with fewer than 10 messages) + + Returns: + JSON response with counts of deleted and archived sessions, and space freed + """ + user = get_current_user(request) + try: + archived_count, deleted_count, space_freed_mb = await cleanup_sessions(session_manager, owner=user) + return { + "archived_count": archived_count, + "deleted_count": deleted_count, + "space_freed_mb": round(space_freed_mb, 2) + } + except Exception as e: + logger.error(f"Cleanup failed: {e}") + raise HTTPException(500, "Cleanup operation failed") + + return router diff --git a/routes/compare_routes.py b/routes/compare_routes.py new file mode 100644 index 0000000..18b2165 --- /dev/null +++ b/routes/compare_routes.py @@ -0,0 +1,246 @@ +# routes/compare_routes.py +"""Model A/B comparison routes.""" +import json +import uuid +import random +from datetime import datetime +from fastapi import APIRouter, Form, HTTPException, Request +from typing import List +from pydantic import BaseModel +import logging + +from core.database import Comparison, SessionLocal +from core.session_manager import SessionManager +from src.auth_helpers import get_current_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/compare", tags=["compare"]) + + +class RecordVoteRequest(BaseModel): + prompt: str + models: List[str] + winner: str # model name or "tie" + is_blind: bool = True + + +def setup_compare_routes(session_manager: SessionManager): + """Setup comparison routes.""" + + @router.post("/start") + def start_comparison( + request: Request, + prompt: str = Form(...), + model_a: str = Form(...), + model_b: str = Form(...), + endpoint_a: str = Form(...), + endpoint_b: str = Form(...), + is_blind: str = Form("true"), + ): + """Create two ephemeral sessions and a comparison record. + + Returns the comparison ID and the two session IDs so the client + can fire two independent SSE streams to /api/chat_stream. + """ + comp_id = str(uuid.uuid4()) + sid_a = str(uuid.uuid4()) + sid_b = str(uuid.uuid4()) + + # Create ephemeral sessions (prefixed [CMP]) + for sid, model, endpoint in [(sid_a, model_a, endpoint_a), (sid_b, model_b, endpoint_b)]: + user = getattr(request.state, 'current_user', None) + session_manager.create_session( + session_id=sid, + name=f"[CMP] {model.split('/')[-1]}", + endpoint_url=endpoint, + model=model, + rag=False, + owner=user, + ) + # Copy API key from endpoint config + db = SessionLocal() + try: + from core.database import ModelEndpoint + # Find matching endpoint by URL + ep = db.query(ModelEndpoint).filter( + ModelEndpoint.base_url == endpoint.replace('/chat/completions', '') + ).first() + if ep and ep.api_key: + s = session_manager.sessions.get(sid) + if s: + s.headers = {"Authorization": f"Bearer {ep.api_key}"} + finally: + db.close() + + # Blind mapping: randomly assign left/right + blind = str(is_blind).lower() == "true" + if blind: + mapping = {"left": "a", "right": "b"} + if random.random() > 0.5: + mapping = {"left": "b", "right": "a"} + else: + mapping = {"left": "a", "right": "b"} + + # Store comparison record + db = SessionLocal() + try: + comp = Comparison( + id=comp_id, + prompt=prompt, + model_a=model_a, + model_b=model_b, + endpoint_a=endpoint_a, + endpoint_b=endpoint_b, + is_blind=blind, + blind_mapping=json.dumps(mapping), + owner=user, + ) + db.add(comp) + db.commit() + finally: + db.close() + + # Map session IDs to left/right based on blind mapping + session_left = sid_a if mapping["left"] == "a" else sid_b + session_right = sid_a if mapping["right"] == "a" else sid_b + + return { + "id": comp_id, + "session_left": session_left, + "session_right": session_right, + "model_left": model_a if mapping["left"] == "a" else model_b, + "model_right": model_a if mapping["right"] == "a" else model_b, + "is_blind": blind, + "mapping": mapping, + } + + @router.post("/{comp_id}/vote") + def vote_comparison( + request: Request, + comp_id: str, + winner: str = Form(...), # "left", "right", or "tie" + ): + """Record the user's vote and reveal model names if blind.""" + user = get_current_user(request) + db = SessionLocal() + try: + comp = db.query(Comparison).filter(Comparison.id == comp_id).first() + if not comp: + raise HTTPException(404, "Comparison not found") + # SECURITY: strict ownership — null-owner Comparisons were + # accessible to every user. + if user and comp.owner != user: + raise HTTPException(404, "Comparison not found") + if comp.winner: + raise HTTPException(400, "Already voted") + + mapping = json.loads(comp.blind_mapping) if comp.blind_mapping else {"left": "a", "right": "b"} + + if winner == "tie": + comp.winner = "tie" + elif winner == "left": + comp.winner = mapping["left"] + elif winner == "right": + comp.winner = mapping["right"] + else: + raise HTTPException(400, "winner must be 'left', 'right', or 'tie'") + + comp.voted_at = datetime.utcnow() + db.commit() + + return { + "winner": comp.winner, + "model_a": comp.model_a, + "model_b": comp.model_b, + "revealed": { + "left": comp.model_a if mapping["left"] == "a" else comp.model_b, + "right": comp.model_a if mapping["right"] == "a" else comp.model_b, + }, + } + finally: + db.close() + + @router.post("/record") + def record_comparison(request: Request, body: RecordVoteRequest): + """Lightweight endpoint to record a comparison vote from the frontend.""" + user = get_current_user(request) + comp_id = str(uuid.uuid4()) + + model_a = body.models[0] if len(body.models) > 0 else "" + model_b = body.models[1] if len(body.models) > 1 else "" + + # For N>2 models, store the full list as JSON in blind_mapping + if len(body.models) > 2: + blind_mapping = json.dumps({"models": body.models}) + else: + blind_mapping = None + + db = SessionLocal() + try: + comp = Comparison( + id=comp_id, + prompt=body.prompt[:500], + model_a=model_a, + model_b=model_b, + endpoint_a="", + endpoint_b="", + winner=body.winner, + is_blind=body.is_blind, + blind_mapping=blind_mapping, + voted_at=datetime.utcnow(), + owner=user, + ) + db.add(comp) + db.commit() + finally: + db.close() + + return {"status": "ok", "id": comp_id} + + @router.get("/history") + def list_comparisons(request: Request): + """List past comparisons.""" + user = get_current_user(request) + db = SessionLocal() + try: + q = db.query(Comparison) + if user: + q = q.filter(Comparison.owner == user) + comps = q.order_by(Comparison.created_at.desc()).limit(50).all() + return [ + { + "id": c.id, + "prompt": c.prompt[:100], + "model_a": c.model_a, + "model_b": c.model_b, + "winner": c.winner, + "is_blind": c.is_blind, + "voted_at": c.voted_at.isoformat() if c.voted_at else None, + "created_at": c.created_at.isoformat() if c.created_at else None, + } + for c in comps + ] + finally: + db.close() + + @router.delete("/{comp_id}") + def delete_comparison(request: Request, comp_id: str): + """Delete a comparison and its ephemeral sessions.""" + user = get_current_user(request) + db = SessionLocal() + try: + comp = db.query(Comparison).filter(Comparison.id == comp_id).first() + if not comp: + raise HTTPException(404, "Comparison not found") + # SECURITY: strict ownership — null-owner Comparisons were + # accessible to every user. + if user and comp.owner != user: + raise HTTPException(404, "Comparison not found") + db.delete(comp) + db.commit() + return {"status": "deleted"} + finally: + db.close() + + return router diff --git a/routes/contacts_routes.py b/routes/contacts_routes.py new file mode 100644 index 0000000..4d55959 --- /dev/null +++ b/routes/contacts_routes.py @@ -0,0 +1,783 @@ +""" +contacts_routes.py + +CardDAV contacts integration. Reads from local Radicale, supports +search and adding new contacts. +""" + +import re +import logging +import uuid +import json +import csv +import io +import httpx +from pathlib import Path +from datetime import datetime +from fastapi import APIRouter, Query, Depends, Response +from typing import List, Dict, Optional + +from src.auth_helpers import require_user +from core.middleware import require_admin + +logger = logging.getLogger(__name__) + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +SETTINGS_FILE = DATA_DIR / "settings.json" +LOCAL_CONTACTS_FILE = DATA_DIR / "contacts.json" + + +def _load_settings(): + if SETTINGS_FILE.exists(): + return json.loads(SETTINGS_FILE.read_text()) + return {} + + +def _save_settings(settings): + from core.atomic_io import atomic_write_json + atomic_write_json(str(SETTINGS_FILE), settings, indent=2) + + +def _get_carddav_config(): + import os + settings = _load_settings() + return { + "url": settings.get("carddav_url", os.environ.get("CARDDAV_URL", "")), + "username": settings.get("carddav_username", os.environ.get("CARDDAV_USERNAME", "")), + "password": settings.get("carddav_password", os.environ.get("CARDDAV_PASSWORD", "")), + } + + +def _carddav_configured(cfg: Optional[Dict] = None) -> bool: + cfg = cfg or _get_carddav_config() + return bool((cfg.get("url") or "").strip()) + + +def _normalize_contact(contact: Dict) -> Dict: + emails = [] + for e in contact.get("emails") or ([] if not contact.get("email") else [contact.get("email")]): + e = str(e or "").strip() + if e and e not in emails: + emails.append(e) + phones = [] + for p in contact.get("phones") or ([] if not contact.get("phone") else [contact.get("phone")]): + p = str(p or "").strip() + if p and p not in phones: + phones.append(p) + name = str(contact.get("name") or "").strip() + if not name and emails: + name = emails[0].split("@")[0] + return { + "uid": str(contact.get("uid") or uuid.uuid4()), + "name": name, + "emails": emails, + "phones": phones, + } + + +def _load_local_contacts() -> List[Dict]: + try: + if not LOCAL_CONTACTS_FILE.exists(): + return [] + data = json.loads(LOCAL_CONTACTS_FILE.read_text()) + rows = data.get("contacts", data) if isinstance(data, dict) else data + return [_normalize_contact(c) for c in (rows or []) if isinstance(c, dict)] + except Exception as e: + logger.error(f"Failed to load local contacts: {e}") + return [] + + +def _save_local_contacts(contacts: List[Dict]) -> None: + from core.atomic_io import atomic_write_json + DATA_DIR.mkdir(parents=True, exist_ok=True) + atomic_write_json(str(LOCAL_CONTACTS_FILE), {"contacts": [_normalize_contact(c) for c in contacts]}, indent=2) + _contact_cache["contacts"] = [_normalize_contact(c) for c in contacts] + _contact_cache["fetched_at"] = datetime.utcnow() + + +# ── vCard parsing ── + +def _vunesc(value: str) -> str: + """Reverse _vesc() — turn escaped vCard text back into the raw value. + Order matters: handle \\n/\\, /\\; first, backslash-unescape last.""" + if not value: + return value + out = [] + i = 0 + while i < len(value): + ch = value[i] + if ch == "\\" and i + 1 < len(value): + nxt = value[i + 1] + if nxt in ("n", "N"): + out.append("\n") + elif nxt in (",", ";", "\\"): + out.append(nxt) + else: + out.append(nxt) + i += 2 + else: + out.append(ch) + i += 1 + return "".join(out) + + +def _parse_vcards(text: str) -> List[Dict]: + """Parse a stream of vCards into dicts with name, email, phone.""" + contacts = [] + for block in re.split(r"BEGIN:VCARD", text): + if not block.strip(): + continue + contact = {"name": "", "emails": [], "phones": [], "uid": ""} + for line in block.split("\n"): + line = line.strip() + if line.startswith("FN:") or line.startswith("FN;"): + contact["name"] = _vunesc(line.split(":", 1)[1]) if ":" in line else "" + elif line.startswith("EMAIL"): + # Handle EMAIL:foo@bar OR EMAIL;TYPE=...:foo@bar OR EMAIL;PREF=1:foo@bar + if ":" in line: + email_addr = _vunesc(line.split(":", 1)[1]) + if email_addr and email_addr not in contact["emails"]: + contact["emails"].append(email_addr) + elif line.startswith("TEL"): + if ":" in line: + phone = _vunesc(line.split(":", 1)[1]) + if phone and phone not in contact["phones"]: + contact["phones"].append(phone) + elif line.startswith("UID:"): + contact["uid"] = _vunesc(line[4:]) + if contact["name"] or contact["emails"]: + contacts.append(contact) + return contacts + + +def _vesc(value: str) -> str: + """Escape a vCard property VALUE per RFC 6350 §3.4: backslash, comma, + semicolon, and newlines. Without this, a name like 'Sekisui House,Ltd' + or any value containing a newline produces a malformed vCard (broken + N/FN fields) or could inject arbitrary properties.""" + return ( + (value or "") + .replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("\r", "") + .replace(",", "\\,") + .replace(";", "\\;") + ) + + +def _build_vcard(name: str, email: str, uid: Optional[str] = None, + emails: Optional[List[str]] = None, + phones: Optional[List[str]] = None) -> str: + """Build a vCard. Accepts either a single `email` (legacy callers) or + full `emails`/`phones` lists (edit path). The first email is marked + PREF=1. All values are RFC-6350-escaped.""" + if not uid: + uid = str(uuid.uuid4()) + # Normalize email lists — `email` arg is a convenience for single-email + # creation; `emails` (if given) is authoritative. + email_list = [e.strip() for e in (emails if emails is not None else ([email] if email else [])) if e and e.strip()] + phone_list = [p.strip() for p in (phones or []) if p and p.strip()] + # Try to split name into first/last + parts = name.strip().split() + if len(parts) >= 2: + first = parts[0] + last = " ".join(parts[1:]) + else: + first = name + last = "" + # N field is structured (5 components separated by ';') — escape each + # component individually so a comma in the name doesn't split it. + n_field = f"{_vesc(last)};{_vesc(first)};;;" + lines = [ + "BEGIN:VCARD", + "VERSION:4.0", + f"UID:{_vesc(uid)}", + f"FN:{_vesc(name)}", + f"N:{n_field}", + ] + for i, em in enumerate(email_list): + # First email is the preferred one. + lines.append(f"EMAIL;PREF=1:{_vesc(em)}" if i == 0 else f"EMAIL:{_vesc(em)}") + for ph in phone_list: + lines.append(f"TEL:{_vesc(ph)}") + lines.append("END:VCARD") + return "\r\n".join(lines) + "\r\n" + + +# ── In-memory cache ── + +_contact_cache = {"contacts": [], "fetched_at": None} + + +def _abs_url(href: str) -> str: + """Combine a multistatus (an absolute path like + /user/contacts/x.vcf) with the configured CardDAV server origin so we + get a fully-qualified URL to PUT/DELETE. If href is already absolute + (http...), return it as-is.""" + from urllib.parse import urlparse, urlunparse + if href.startswith("http://") or href.startswith("https://"): + return href + cfg = _get_carddav_config() + p = urlparse(cfg["url"]) + return urlunparse((p.scheme, p.netloc, href, "", "", "")) + + +# CardDAV REPORT body — pull every card's etag + raw vCard in ONE request, +# alongside the resource href. Lets us map each contact's UID to the real +# server resource path (which is NOT always .vcf for contacts created +# by other clients). +_ADDRESSBOOK_QUERY = ( + '' + '' + '' + '' + '' +) + + +def _fetch_via_report(cfg, auth): + """Try a CardDAV REPORT addressbook-query — returns contacts WITH an + `href` field, or None if the server doesn't support it / errors.""" + from defusedxml import ElementTree as ET + try: + r = httpx.request( + "REPORT", cfg["url"], + content=_ADDRESSBOOK_QUERY.encode("utf-8"), + headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"}, + auth=auth, timeout=10, + ) + if r.status_code not in (207, 200): + return None + root = ET.fromstring(r.text) + ns = {"D": "DAV:", "C": "urn:ietf:params:xml:ns:carddav"} + out = [] + for resp in root.findall("D:response", ns): + href_el = resp.find("D:href", ns) + data_el = resp.find(".//C:address-data", ns) + if href_el is None or data_el is None or not (data_el.text or "").strip(): + continue + parsed = _parse_vcards(data_el.text) + if not parsed: + continue + c = parsed[0] + c["href"] = href_el.text.strip() + out.append(c) + # If the REPORT parsed to ZERO contacts, don't trust it — some + # CardDAV servers treat an empty as "match nothing" and + # return a valid-but-empty 207. Return None so the caller falls + # back to the plain GET (which lists everything). A genuinely empty + # address book just costs one extra GET that also returns nothing. + if not out: + return None + return out + except Exception as e: + logger.warning(f"CardDAV REPORT failed, falling back to GET: {e}") + return None + + +def _fetch_contacts(force=False): + """Fetch all contacts. Uses CardDAV when configured, otherwise local JSON.""" + if not force and _contact_cache["fetched_at"]: + age = (datetime.utcnow() - _contact_cache["fetched_at"]).total_seconds() + if age < 60: + return _contact_cache["contacts"] + + cfg = _get_carddav_config() + if not _carddav_configured(cfg): + contacts = _load_local_contacts() + _contact_cache["contacts"] = contacts + _contact_cache["fetched_at"] = datetime.utcnow() + return contacts + + try: + auth = None + if cfg["username"]: + auth = (cfg["username"], cfg["password"]) + # Preferred path: REPORT gives us hrefs for reliable edit/delete. + contacts = _fetch_via_report(cfg, auth) + if contacts is None: + # Fallback: plain GET, concatenated vCards, no hrefs. + r = httpx.get(cfg["url"], auth=auth, timeout=10) + if r.status_code != 200: + logger.warning(f"CardDAV returned {r.status_code}") + return _contact_cache["contacts"] + contacts = _parse_vcards(r.text) + _contact_cache["contacts"] = contacts + _contact_cache["fetched_at"] = datetime.utcnow() + return contacts + except Exception as e: + logger.error(f"Failed to fetch contacts: {e}") + return _contact_cache["contacts"] + + +def _resolve_resource_url(uid: str) -> str: + """Map a contact UID to its real CardDAV resource URL. Uses the href + captured during fetch when available (handles contacts whose filename + != UID); falls back to the .vcf guess for app-created contacts or + when no href is known.""" + def _lookup(): + for c in _contact_cache.get("contacts", []): + if c.get("uid") == uid and c.get("href"): + return _abs_url(c["href"]) + return None + found = _lookup() + if found: + return found + # Not in cache (or no href) — refresh once and retry before guessing. + try: + _fetch_contacts(force=True) + except Exception: + pass + return _lookup() or _vcard_url(uid) + + +def _create_contact(name: str, email: str) -> bool: + """Add a new contact via CardDAV or local contacts.""" + cfg = _get_carddav_config() + if not _carddav_configured(cfg): + contacts = _load_local_contacts() + email_l = (email or "").strip().lower() + for c in contacts: + if email_l and email_l in [e.lower() for e in c.get("emails", [])]: + return True + contacts.append(_normalize_contact({"name": name, "emails": [email]})) + _save_local_contacts(contacts) + return True + + contact_uid = str(uuid.uuid4()) + vcard = _build_vcard(name, email, contact_uid) + url = cfg["url"].rstrip("/") + "/" + contact_uid + ".vcf" + try: + auth = None + if cfg["username"]: + auth = (cfg["username"], cfg["password"]) + r = httpx.put( + url, + data=vcard.encode("utf-8"), + headers={"Content-Type": "text/vcard; charset=utf-8"}, + auth=auth, + timeout=10, + ) + if r.status_code in (200, 201, 204): + # Invalidate cache + _contact_cache["fetched_at"] = None + return True + logger.warning(f"CardDAV PUT returned {r.status_code}: {r.text[:200]}") + return False + except Exception as e: + logger.error(f"Failed to create contact: {e}") + return False + + +def _vcard_url(uid: str) -> str: + """The CardDAV resource URL for a given contact UID. The uid is URL- + encoded so a value containing '/', '..' or other path chars can't + escape the collection and target an arbitrary CardDAV resource.""" + from urllib.parse import quote + cfg = _get_carddav_config() + return cfg["url"].rstrip("/") + "/" + quote(uid, safe="") + ".vcf" + + +def _import_vcards(text: str) -> Dict: + """Import a (possibly multi-card) .vcf blob. Each card is PUT to the + CardDAV server PRESERVING its full original content (ADR/ORG/photo/ + etc.) — we don't rebuild it, just ensure it has VERSION + UID and + normalize line endings. Returns {imported, failed, total}.""" + from urllib.parse import quote + cfg = _get_carddav_config() + if not cfg.get("url"): + parsed = _parse_vcards(text) + contacts = _load_local_contacts() + existing = { + e.lower() + for c in contacts + for e in (c.get("emails") or []) + if e + } + imported = 0 + for c in parsed: + emails = [e for e in (c.get("emails") or []) if e] + if emails and any(e.lower() in existing for e in emails): + continue + contacts.append(_normalize_contact(c)) + for e in emails: + existing.add(e.lower()) + imported += 1 + if imported: + _save_local_contacts(contacts) + return {"imported": imported, "failed": 0, "total": len(parsed)} + auth = (cfg["username"], cfg["password"]) if cfg["username"] else None + # Split into individual cards. re.split drops the BEGIN line, so we + # re-add it. Normalize CRLF. + raw = (text or "").replace("\r\n", "\n").replace("\r", "\n") + blocks = [] + for chunk in raw.split("BEGIN:VCARD"): + chunk = chunk.strip() + if not chunk: + continue + # Trim anything after END:VCARD (defensive). + end = chunk.upper().find("END:VCARD") + body = chunk[: end + len("END:VCARD")] if end != -1 else chunk + blocks.append("BEGIN:VCARD\n" + body) + imported = 0 + failed = 0 + for block in blocks: + # Extract or assign a UID. + m = re.search(r"^UID:(.+)$", block, re.MULTILINE) + uid = (m.group(1).strip() if m else "") or str(uuid.uuid4()) + if not m: + # Inject a UID right after the VERSION line (or after BEGIN). + if re.search(r"^VERSION:", block, re.MULTILINE): + block = re.sub(r"(^VERSION:.*$)", r"\1\nUID:" + uid, block, count=1, flags=re.MULTILINE) + else: + block = block.replace("BEGIN:VCARD", f"BEGIN:VCARD\nVERSION:4.0\nUID:{uid}", 1) + elif not re.search(r"^VERSION:", block, re.MULTILINE): + block = block.replace("BEGIN:VCARD", "BEGIN:VCARD\nVERSION:4.0", 1) + vcard = block.replace("\n", "\r\n") + "\r\n" + url = cfg["url"].rstrip("/") + "/" + quote(uid, safe="") + ".vcf" + try: + r = httpx.put( + url, data=vcard.encode("utf-8"), + headers={"Content-Type": "text/vcard; charset=utf-8"}, + auth=auth, timeout=15, + ) + if r.status_code in (200, 201, 204): + imported += 1 + else: + failed += 1 + logger.warning(f"Import PUT {uid} returned {r.status_code}: {r.text[:120]}") + except Exception as e: + failed += 1 + logger.error(f"Import PUT {uid} failed: {e}") + if imported: + _contact_cache["fetched_at"] = None + return {"imported": imported, "failed": failed, "total": len(blocks)} + + +def _import_csv_contacts(text: str) -> Dict: + """Import contacts from CSV. Supports common headers: + name/full_name/display_name, email/email_address/e-mail, phone/tel. + Falls back to first columns as name,email,phone when no headers exist.""" + raw = (text or "").strip() + if not raw: + return {"imported": 0, "failed": 0, "total": 0, "error": "No CSV data found"} + + try: + sample = raw[:2048] + dialect = csv.Sniffer().sniff(sample) + except Exception: + dialect = csv.excel + + stream = io.StringIO(raw) + try: + has_header = csv.Sniffer().has_header(raw[:2048]) + except Exception: + has_header = True + + rows = [] + if has_header: + reader = csv.DictReader(stream, dialect=dialect) + for row in reader: + lowered = {str(k or "").strip().lower(): (v or "").strip() for k, v in row.items()} + name = ( + lowered.get("name") or lowered.get("full name") or lowered.get("full_name") + or lowered.get("display name") or lowered.get("display_name") + or lowered.get("fn") or "" + ) + email = ( + lowered.get("email") or lowered.get("email address") + or lowered.get("email_address") or lowered.get("e-mail") + or lowered.get("mail") or "" + ) + phone = lowered.get("phone") or lowered.get("telephone") or lowered.get("tel") or "" + rows.append((name, email, phone)) + else: + stream.seek(0) + reader = csv.reader(stream, dialect=dialect) + for row in reader: + cols = [(c or "").strip() for c in row] + if not any(cols): + continue + rows.append(( + cols[0] if len(cols) > 0 else "", + cols[1] if len(cols) > 1 else "", + cols[2] if len(cols) > 2 else "", + )) + + imported = 0 + failed = 0 + total = 0 + existing_emails = { + e.lower() + for c in _fetch_contacts() + for e in (c.get("emails") or []) + if e + } + for name, email, phone in rows: + email = (email or "").strip() + name = (name or "").strip() or (email.split("@")[0] if email else "") + if not email: + continue + total += 1 + if email.lower() in existing_emails: + continue + ok = _create_contact(name, email) + if ok: + imported += 1 + existing_emails.add(email.lower()) + # If the CSV had a phone number, rewrite the just-created row + # through the richer update path so phone lands in CardDAV too. + if phone: + try: + contacts = _fetch_contacts(force=True) + created = next((c for c in contacts if email.lower() in [e.lower() for e in c.get("emails", [])]), None) + if created and created.get("uid"): + _update_contact(created["uid"], name, [email], [phone]) + except Exception: + pass + else: + failed += 1 + + if imported: + _contact_cache["fetched_at"] = None + return {"imported": imported, "failed": failed, "total": total} + + +def _contacts_to_vcf(contacts: List[Dict]) -> str: + return "".join( + _build_vcard( + c.get("name") or ((c.get("emails") or [""])[0].split("@")[0] if c.get("emails") else "Contact"), + "", + uid=c.get("uid") or str(uuid.uuid4()), + emails=c.get("emails") or [], + phones=c.get("phones") or [], + ) + for c in contacts + ) + + +def _contacts_to_csv(contacts: List[Dict]) -> str: + out = io.StringIO() + writer = csv.writer(out) + writer.writerow(["name", "email", "phone"]) + for c in contacts: + emails = c.get("emails") or [""] + phones = c.get("phones") or [""] + max_len = max(len(emails), len(phones), 1) + for i in range(max_len): + writer.writerow([ + c.get("name") or "", + emails[i] if i < len(emails) else "", + phones[i] if i < len(phones) else "", + ]) + return out.getvalue() + + +def _update_contact(uid: str, name: str, emails: List[str], phones: List[str]) -> bool: + """Rewrite an existing contact via CardDAV or local contacts.""" + cfg = _get_carddav_config() + if not _carddav_configured(cfg): + contacts = _load_local_contacts() + found = False + out = [] + for c in contacts: + if c.get("uid") == uid: + out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones})) + found = True + else: + out.append(c) + if not found: + out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones})) + _save_local_contacts(out) + return True + + vcard = _build_vcard(name, "", uid=uid, emails=emails, phones=phones) + # Use the real resource href (handles externally-created contacts whose + # filename != UID); falls back to the .vcf guess. + url = _resolve_resource_url(uid) + try: + auth = (cfg["username"], cfg["password"]) if cfg["username"] else None + r = httpx.put( + url, + data=vcard.encode("utf-8"), + headers={"Content-Type": "text/vcard; charset=utf-8"}, + auth=auth, + timeout=10, + ) + if r.status_code in (200, 201, 204): + _contact_cache["fetched_at"] = None + return True + logger.warning(f"CardDAV update PUT returned {r.status_code}: {r.text[:200]}") + return False + except Exception as e: + logger.error(f"Failed to update contact: {e}") + return False + + +def _delete_contact(uid: str) -> bool: + """Delete a contact via CardDAV or local contacts.""" + cfg = _get_carddav_config() + if not _carddav_configured(cfg): + contacts = _load_local_contacts() + remaining = [c for c in contacts if c.get("uid") != uid] + _save_local_contacts(remaining) + return True + + url = _resolve_resource_url(uid) + try: + auth = (cfg["username"], cfg["password"]) if cfg["username"] else None + r = httpx.delete(url, auth=auth, timeout=10) + if r.status_code in (200, 204): + _contact_cache["fetched_at"] = None + return True + if r.status_code == 404: + # Resource not found at the resolved URL. With href resolution + # this should be rare (genuinely already deleted). Invalidate + # the cache and report success so the UI doesn't keep a ghost. + logger.info(f"CardDAV DELETE 404 for {uid} — treating as already gone") + _contact_cache["fetched_at"] = None + return True + logger.warning(f"CardDAV DELETE returned {r.status_code}: {r.text[:200]}") + return False + except Exception as e: + logger.error(f"Failed to delete contact: {e}") + return False + + +# ── Routes ── + +def setup_contacts_routes(): + router = APIRouter(prefix="/api/contacts", tags=["contacts"]) + + @router.get("/list") + async def list_contacts(_admin: str = Depends(require_admin)): + """List all contacts.""" + contacts = _fetch_contacts() + return {"contacts": contacts, "count": len(contacts)} + + @router.get("/search") + async def search_contacts(q: str = Query(""), _admin: str = Depends(require_admin)): + """Search contacts by name or email. Returns up to 10 matches.""" + contacts = _fetch_contacts() + if not q: + return {"results": []} + q_lower = q.lower() + results = [] + for c in contacts: + if q_lower in c["name"].lower(): + results.append(c) + continue + for em in c["emails"]: + if q_lower in em.lower(): + results.append(c) + break + return {"results": results[:10]} + + @router.post("/add") + async def add_contact(data: dict, _admin: str = Depends(require_admin)): + """Add a new contact.""" + name = data.get("name", "").strip() + email = data.get("email", "").strip() + if not email: + return {"success": False, "error": "Email required"} + # Check if already exists + contacts = _fetch_contacts() + for c in contacts: + if email.lower() in [e.lower() for e in c["emails"]]: + return {"success": True, "message": "Already exists", "contact": c} + if not name: + name = email.split("@")[0] + ok = _create_contact(name, email) + return {"success": ok} + + @router.post("/import") + async def import_vcf(data: dict, _admin: str = Depends(require_admin)): + """Import contacts from .vcf or CSV. Body: {"vcf": "..."} or {"csv": "..."}.""" + text = data.get("vcf") or data.get("text") or "" + csv_text = data.get("csv") or "" + if text.strip(): + if "BEGIN:VCARD" not in text.upper(): + return {"success": False, "error": "No vCard data found"} + result = _import_vcards(text) + elif csv_text.strip(): + result = _import_csv_contacts(csv_text) + else: + return {"success": False, "error": "No contact data found"} + result["success"] = result.get("imported", 0) > 0 + return result + + @router.get("/export") + async def export_contacts( + format: str = Query("vcf", pattern="^(vcf|csv)$"), + _admin: str = Depends(require_admin), + ): + """Export all contacts as vCard or CSV.""" + contacts = _fetch_contacts(force=True) + if format == "csv": + content = _contacts_to_csv(contacts) + media_type = "text/csv; charset=utf-8" + filename = "odysseus-contacts.csv" + else: + content = _contacts_to_vcf(contacts) + media_type = "text/vcard; charset=utf-8" + filename = "odysseus-contacts.vcf" + return Response( + content=content, + media_type=media_type, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @router.get("/config") + async def get_config(_admin: str = Depends(require_admin)): + cfg = _get_carddav_config() + # Mask password + if cfg["password"]: + cfg["password"] = "***" + return cfg + + @router.put("/config") + async def update_config(data: dict, _admin: str = Depends(require_admin)): + settings = _load_settings() + for key in ("carddav_url", "carddav_username", "carddav_password"): + if key in data: + settings[key] = data[key] + _save_settings(settings) + # Force re-fetch + _contact_cache["fetched_at"] = None + return {"success": True} + + @router.delete("/clear") + async def clear_contacts(_admin: str = Depends(require_admin)): + """Clear all local contacts. If CardDAV is configured, only clears the local fallback cache.""" + _save_local_contacts([]) + return {"success": True} + + # NOTE: the /{uid} routes are declared LAST so the literal paths above + # (/list, /search, /add, /config) win — otherwise PUT /config would + # match PUT /{uid} with uid="config". + @router.put("/{uid}") + async def edit_contact(uid: str, data: dict, _admin: str = Depends(require_admin)): + """Edit an existing contact — name / emails / phones.""" + name = (data.get("name") or "").strip() + emails = data.get("emails") + phones = data.get("phones") + if emails is None and data.get("email"): + emails = [data["email"]] + emails = [e.strip() for e in (emails or []) if e and e.strip()] + phones = [p.strip() for p in (phones or []) if p and p.strip()] + if not name and not emails: + return {"success": False, "error": "Name or email required"} + if not name and emails: + name = emails[0].split("@")[0] + ok = _update_contact(uid, name, emails, phones) + return {"success": ok} + + @router.delete("/{uid}") + async def delete_contact(uid: str, _admin: str = Depends(require_admin)): + """Delete a contact by UID.""" + if not uid: + return {"success": False, "error": "UID required"} + ok = _delete_contact(uid) + return {"success": ok} + + return router diff --git a/routes/cookbook_helpers.py b/routes/cookbook_helpers.py new file mode 100644 index 0000000..97ef2ca --- /dev/null +++ b/routes/cookbook_helpers.py @@ -0,0 +1,340 @@ +"""cookbook_helpers.py — validators + small helpers shared by the cookbook routes. +Extracted from cookbook_routes.py; the routes module imports the symbols it needs.""" + +import logging +import os +import re +import shlex + +from fastapi import HTTPException +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + + +# HuggingFace repo IDs are /, both alphanumerics plus ._- +# Rejecting anything else up front closes off shell-interpolation vectors. +_REPO_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$") +# Include pattern is a glob: allow typical safe glyphs only. +_INCLUDE_RE = re.compile(r"^[A-Za-z0-9._\-*?/\[\]]+$") +# Remote host: user@host (optionally with :port-free hostname parts). +_REMOTE_HOST_RE = re.compile(r"^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+$") +# HF tokens and API tokens are url-safe base64-like. +_TOKEN_RE = re.compile(r"^[A-Za-z0-9._~+/=-]+$") +# Session IDs we mint look like "cookbook-deadbeef" or "serve-deadbeef". +# Anything beyond plain alphanumerics + dash + underscore could break out +# of the shell/PowerShell contexts the value lands in. +_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") +_SSH_PORT_RE = re.compile(r"^\d{1,5}$") +_GPU_LIST_RE = re.compile(r"^\d+(?:,\d+)*$") +# A download target directory. Absolute or ~-relative path; safe path glyphs +# only (no quotes, shell metacharacters, or spaces) since it lands in a shell +# command. A leading ~ is expanded to $HOME at command-build time. +_LOCAL_DIR_RE = re.compile(r"^~?/[A-Za-z0-9._/-]*$|^~$") + + +def _validate_repo_id(v: str | None) -> str: + if not v or not _REPO_ID_RE.match(v): + raise HTTPException(400, "Invalid repo_id — must be / using [A-Za-z0-9._-]") + return v + + +def _validate_include(v: str | None) -> str | None: + if v is None or v == "": + return None + if not _INCLUDE_RE.match(v): + raise HTTPException(400, "Invalid include pattern") + return v + + +def _validate_remote_host(v: str | None) -> str | None: + if v is None or v == "": + return None + if not _REMOTE_HOST_RE.match(v): + raise HTTPException(400, "Invalid remote_host — must be user@host, no SSH option syntax") + return v + + +def _validate_token(v: str | None) -> str | None: + if v is None or v == "": + return None + if not _TOKEN_RE.match(v): + raise HTTPException(400, "Invalid token characters") + return v + + +def _validate_local_dir(v: str | None) -> str | None: + if v is None or v == "": + return None + v = v.rstrip("/") or "/" + if not _LOCAL_DIR_RE.match(v): + raise HTTPException(400, "Invalid local_dir — must be an absolute or ~ path with no spaces or shell metacharacters") + return v + + +def _validate_ssh_port(v: str | None) -> str | None: + if v is None or v == "": + return None + if not _SSH_PORT_RE.fullmatch(str(v)): + raise HTTPException(400, "Invalid ssh_port") + port = int(v) + if port < 1 or port > 65535: + raise HTTPException(400, "Invalid ssh_port") + return str(port) + + +def _validate_gpus(v: str | None) -> str | None: + if v is None or v == "": + return None + if not _GPU_LIST_RE.fullmatch(str(v)): + raise HTTPException(400, "Invalid gpus — expected comma-separated GPU indexes") + return str(v) + + +def _shell_path(p: str) -> str: + """Render a validated path for a double-quoted shell context, expanding a + leading ~ to $HOME (single quotes wouldn't expand it). Safe because + _validate_local_dir already restricts the charset.""" + if p == "~": + return '"$HOME"' + if p.startswith("~/"): + return '"$HOME/' + p[2:] + '"' + return '"' + p + '"' + + +def _ps_squote(v: str) -> str: + """Escape a value for PowerShell single-quoted string interpolation. + Belt-and-suspenders on top of _validate_token's regex — if the regex + is ever loosened, this still keeps the heredoc shell-safe.""" + return v.replace("'", "''") + + +def _bash_squote(v: str) -> str: + """Escape a value for bash/sh single-quoted string interpolation.""" + return v.replace("'", "'\\''") + + +# Allow-list of binaries permitted as the leading token of `req.cmd` for /api/model/serve. +# Anything else is rejected before the cmd is interpolated into a tmux/PowerShell wrapper. +_SERVE_CMD_ALLOWLIST = { + "vllm", "llama-server", "llama_server", "llama.cpp", "ollama", + "python", "python3", + "sglang", "lmdeploy", + "node", "npx", +} + + +# The llama.cpp GGUF launcher (static/js/cookbook.js) emits a fixed-shape +# prelude that resolves the cached .gguf on the target host before serving: +# MODEL_FILE=$( { find …; find …; } | head -1 ) && { [ -n "$MODEL_FILE" ] && \ +# [ -f "$MODEL_FILE" ]; } || { echo "ERROR…"; exit 1; } && || +# That legitimately needs $(...)/&&/||, so we recognise this exact shape and +# validate the serve binaries it guards rather than rejecting it wholesale. +_GGUF_PRELUDE_RE = re.compile( + r'^MODEL_FILE=\$\([^\n]*?\)\s*&&\s*\{[^{}]*\}\s*\|\|\s*\{[^{}]*\}\s*&&\s*' +) + + +def _check_serve_binary(seg: str) -> None: + """Validate that a single command segment starts with an allowlisted binary + (after skipping leading env-var assignments like `CUDA_VISIBLE_DEVICES=0`).""" + try: + tokens = shlex.split(seg) if seg.strip() else [] + except ValueError: + raise HTTPException(400, "Invalid cmd — could not parse") + if not tokens: + return + env_re = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") + first = next((t for t in tokens if not env_re.match(t)), "") + base = os.path.basename(first) + if base not in _SERVE_CMD_ALLOWLIST: + raise HTTPException( + 400, + f"cmd binary '{base or '(empty)'}' is not allowed. Must start with one of: " + f"{', '.join(sorted(_SERVE_CMD_ALLOWLIST))}", + ) + + +def _validate_serve_cmd(v: str | None) -> str | None: + """Reject serve commands that aren't in the allowlist or contain shell metachars. + + `req.cmd` is dropped verbatim into a bash/PowerShell wrapper script and + executed in a tmux session. Without this gate, an admin (or anyone in the + pre-fix world) could pass arbitrary shell payloads. + + Leading env-var assignments (e.g. `CUDA_VISIBLE_DEVICES=0 python3 ...`) + are stripped before checking the binary — several of our cmd builders + prepend them, and they shouldn't trip the allowlist. + """ + if v is None or v == "": + return None + # Collapse backslash-newline line continuations into single spaces. Serve + # commands (vLLM especially) are routinely pasted multi-line with trailing + # `\` — that's a safe shell/shlex continuation, so the command stays ONE + # logical invocation and the leading-token allowlist below still governs. + v = re.sub(r"\\[ \t]*\r?\n[ \t]*", " ", v).strip() + # Backticks and raw newlines are never legitimate here. + if any(c in v for c in ("`", "\n", "\r")): + raise HTTPException(400, "Invalid characters in cmd") + # Known GGUF launcher prelude → validate the serve invocation(s) it guards. + m = _GGUF_PRELUDE_RE.match(v) + if m: + rest = v[m.end():] + # rest is `[ENV=…] python3 -m llama_cpp.server … || [ENV=…] llama-server …` + for part in rest.split("||"): + _check_serve_binary(part.strip()) + return v + # Otherwise: a single invocation — no shell metacharacters allowed. + # (`$(` was the original intent; bare `$` is fine for shell-safe paths.) + if any(c in v for c in (";", "&&", "||", "$(")): + raise HTTPException(400, "Invalid characters in cmd") + _check_serve_binary(v) + return v + + +class ModelDownloadRequest(BaseModel): + repo_id: str + include: str | None = None # glob pattern e.g. "*Q4_K_M*" + hf_token: str | None = None + env_prefix: str | None = None # e.g. "source ~/venv/bin/activate" + remote_host: str | None = None # e.g. "gpu-box" — run download on this host via SSH + ssh_port: str | None = None # e.g. "8022" for Termux + platform: str | None = None # "linux", "termux", or "windows" + local_dir: str | None = None # base dir to download into (a per-model subfolder is created under it); None = default HF cache + disable_hf_transfer: bool = False # skip the Rust hf_transfer downloader — slower but far more reliable on large files (used by retries) + + +class ServeRequest(BaseModel): + repo_id: str + cmd: str + remote_host: str | None = None + ssh_port: str | None = None + env_prefix: str | None = None + hf_token: str | None = None + gpus: str | None = None + platform: str | None = None # "linux", "termux", or "windows" + + +def _parse_serve_phase(snapshot: str, task_type: str = "serve") -> dict: + """Parse a tmux snapshot of a serve task into structured phase info. + + Single source of truth for serve task status detection. Returns: + { "phase": str, "status": "ready"|"running"|"", "tps": float|None, + "reqs": int|None, "pct": int|None } + """ + import re + if task_type != "serve" or not snapshot: + return {} + # Strip newlines so tmux line-wrapping doesn't break regex matching + flat = re.sub(r'\s+', ' ', snapshot) + + load_matches = re.findall(r'Loading safetensors.*?(\d+)%', flat) + # Prefer "Downloading (incomplete total...)" (real aggregate bytes) over + # "Fetching N files" (whole-file count, lags with hf_transfer's chunked pulls). + downloading_matches = re.findall(r'Downloading.*?(\d+)%', flat) + fetching_matches = re.findall(r'Fetching.*?(\d+)%', flat) + dl_matches = downloading_matches if downloading_matches else fetching_matches + # Match "Avg generation throughput: X tokens/s, Running: N reqs" (with line-wrap tolerance) + tps_matches = re.findall( + r'(?:Avg )?generation throughput:\s*([\d.]+)\s*tokens/s.*?Running:\s*(\d+)\s*reqs', + flat, + ) + + # Check throughput FIRST — the throughput log line contains "GPU KV cache usage" + # which would otherwise false-match the warmup check + if tps_matches: + tps_str, reqs_str = tps_matches[-1] + tps = float(tps_str) + reqs = int(reqs_str) + return { + "phase": f"{tps_str} tok/s" if reqs > 0 else "idle", + "status": "ready", + "tps": tps, + "reqs": reqs, + } + if "Application startup complete" in flat: + return {"phase": "ready", "status": "ready"} + # HTTP access logs (e.g. GET /v1/models 200 OK) mean the server is up and serving + if re.search(r'(?:GET|POST)\s+/[^\s]*\s+HTTP/[\d.]+"\s*\d{3}', flat): + return {"phase": "idle", "status": "ready"} + if "Loading weights took" in flat: + return {"phase": "initializing", "status": "running"} + # "GPU KV cache" alone (during allocation) — not "GPU KV cache usage" (runtime log) + if "GPU KV cache" in flat and "GPU KV cache usage" not in flat: + return {"phase": "warming up", "status": "running"} + if load_matches: + pct = int(load_matches[-1]) + return {"phase": f"loading {pct}%", "status": "running", "pct": pct} + if dl_matches: + pct = int(dl_matches[-1]) + return {"phase": f"downloading {pct}%", "status": "running", "pct": pct} + return {} + + +def _ssh(host, cmd, port=None): + """Build SSH command string with optional port.""" + pf = f"-p {port} " if port and port != "22" else "" + return f"ssh {pf}{host} '{cmd}'" + + +def _safe_env_prefix(ep: str | None) -> str | None: + """Rewrite a `source ` env_prefix so it no-ops if the path is missing. + Prevents `line N: : No such file or directory` errors when a serve + task is launched against a host that doesn't have the expected venv. + + Also rewrites leading `~/` → `$HOME/` so the path expands inside double + quotes (bash only tilde-expands unquoted tokens at word start).""" + if not ep: + return ep + import shlex + try: + parts = shlex.split(ep, posix=True) + except ValueError: + raise HTTPException(400, "Invalid env_prefix") + if len(parts) != 2 or parts[0] not in {"source", "."}: + # Bash conda activation emitted by the frontend: + # eval "$(conda shell.bash hook)" && conda activate ENV + m = re.fullmatch(r'eval "\$\(conda shell\.bash hook\)" && conda activate (.+)', ep) + if m: + env = m.group(1).strip() + try: + env_parts = shlex.split(env, posix=True) + except ValueError: + raise HTTPException(400, "Invalid env_prefix") + if len(env_parts) != 1: + raise HTTPException(400, "Invalid env_prefix") + return 'eval "$(conda shell.bash hook)" && conda activate ' + shlex.quote(env_parts[0]) + + # Plain conda activation, used by Windows/PowerShell and some manual callers. + if len(parts) == 3 and parts[0] == "conda" and parts[1] == "activate": + return "conda activate " + shlex.quote(parts[2]) + + # PowerShell venv activation emitted by the frontend: + # & 'C:\path\Scripts\Activate.ps1' + if len(parts) == 2 and parts[0] == "&": + path = parts[1] + if any(c in path for c in "\r\n;&|`$<>"): + raise HTTPException(400, "Invalid env_prefix") + return "& '" + path.replace("'", "''") + "'" + + raise HTTPException(400, "Invalid env_prefix") + path = parts[1] + if any(c in path for c in "\r\n;&|`$<>"): + raise HTTPException(400, "Invalid env_prefix") + # Replace a leading "~/" with "$HOME/" so it survives quoting + if path.startswith("~/"): + path = "$HOME/" + path[2:] + elif path == "~": + path = "$HOME" + path = path.replace('"', '\\"') + return f'[ -f "{path}" ] && source "{path}" || true' + + +def _ssh_ps(host, script_path, port=None): + """Build SSH command to run a PowerShell script on a Windows remote.""" + pf = f"-p {port} " if port and port != "22" else "" + return f'ssh {pf}{host} "powershell -ExecutionPolicy Bypass -File {script_path}"' + + +# Windows session dir — stored in user's temp on the remote +WIN_SESSION_DIR = "$env:TEMP\\\\odysseus-sessions" diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py new file mode 100644 index 0000000..9ba054b --- /dev/null +++ b/routes/cookbook_routes.py @@ -0,0 +1,1728 @@ +"""Cookbook routes — model download, serve, cache scanning, and cookbook state sync.""" + +import asyncio +import json +import logging +import os +import re +import shlex +import shutil +import uuid +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request, Depends + +from src.auth_helpers import require_user +from pydantic import BaseModel + +from core.middleware import require_admin +from routes.shell_routes import TMUX_LOG_DIR + +logger = logging.getLogger(__name__) + +from routes.cookbook_helpers import ( + _SSH_PORT_RE, _REMOTE_HOST_RE, _SESSION_ID_RE, + _validate_repo_id, _validate_include, _validate_remote_host, _validate_token, + _validate_local_dir, _validate_ssh_port, _validate_gpus, _shell_path, + _ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase, + _safe_env_prefix, + ModelDownloadRequest, ServeRequest, +) + +_HF_TOKEN_STATUS_SNIPPET = ( + 'if [ -n "$HF_TOKEN" ]; then ' + 'echo "[odysseus] HF token: applied"; ' + 'else ' + 'echo "[odysseus] HF token: NOT SET — gated/private models will be denied. ' + 'Add one in Odysseus Settings -> Cookbook -> HuggingFace Token."; ' + 'fi' +) + +def setup_cookbook_routes() -> APIRouter: + router = APIRouter(tags=["cookbook"]) + _cookbook_state_path = Path(os.environ.get("DATA_DIR", "data")) / "cookbook_state.json" + + def _mask_secret(value: str) -> str: + if not value: + return "" + if len(value) <= 8: + return "stored" + return f"{value[:4]}...{value[-4:]}" + + def _decrypt_secret(value: str | None) -> str: + if not value: + return "" + from src.secret_storage import decrypt + return decrypt(value) + + def _encrypt_secret(value: str) -> str: + from src.secret_storage import encrypt + return encrypt(value) + + def _strip_task_secrets(state): + tasks = state.get("tasks") if isinstance(state, dict) else None + if isinstance(tasks, list): + for task in tasks: + if isinstance(task, dict) and isinstance(task.get("payload"), dict): + task["payload"].pop("hf_token", None) + return state + + def _diagnose_serve_output(text: str) -> dict | None: + """Server-side mirror of the Cookbook UI's common serve diagnoses. + + The browser uses cookbook-diagnosis.js for clickable fixes. This gives + the agent/tool path the same structured signal so it can retry with an + adjusted command instead of guessing from raw tmux output. + """ + if not text: + return None + tail = text[-6000:] + patterns = [ + ( + r"No available memory for the cache blocks|Available KV cache memory:.*-", + "No GPU memory left for KV cache after loading model.", + [ + {"label": "retry with GPU memory utilization 0.95", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.95"}, + {"label": "retry with context 2048", "op": "replace", "flag": "--max-model-len", "value": "2048"}, + ], + ), + ( + r"CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory|warming up sampler|max_num_seqs.*gpu_memory_utilization", + "GPU ran out of memory during startup or warmup.", + [ + {"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"}, + {"label": "retry with GPU memory utilization 0.80", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.80"}, + {"label": "retry with --enforce-eager", "op": "append", "arg": "--enforce-eager"}, + ], + ), + ( + r"not divisib|must be divisible|attention heads.*divisible", + "Tensor parallel size is incompatible with the model.", + [ + {"label": "retry with tensor parallel size 1", "op": "replace", "flag": "--tensor-parallel-size", "value": "1"}, + {"label": "retry with tensor parallel size 2", "op": "replace", "flag": "--tensor-parallel-size", "value": "2"}, + ], + ), + ( + r"KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context", + "Context length is too large for available GPU memory.", + [ + {"label": "retry with context 8192", "op": "replace", "flag": "--max-model-len", "value": "8192"}, + {"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"}, + ], + ), + ( + r"enable-auto-tool-choice requires --tool-call-parser", + "Auto tool choice requires an explicit tool call parser.", + [{"label": "retry with Hermes tool parser", "op": "append", "arg": "--tool-call-parser hermes"}], + ), + ( + r"Please pass.*trust.remote.code=True|contains custom code which must be executed to correctly load|does not recognize this architecture|model type.*but Transformers does not", + "Model requires custom code or newer model support.", + [{"label": "retry with --trust-remote-code", "op": "append", "arg": "--trust-remote-code"}], + ), + ( + r"Address already in use|bind.*address.*in use", + "Port is already in use.", + [{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}], + ), + ( + r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid", + "No GPUs are visible to the serve process.", + [{"label": "clear Cookbook GPU selection or choose available GPUs", "op": "settings", "field": "gpus", "value": ""}], + ), + ( + r"vllm.*command not found|No module named vllm|ERROR: vLLM is not installed", + "vLLM is not installed or not in PATH on this server.", + [{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}], + ), + ( + r"sglang.*command not found|No module named sglang|SGLang is not installed", + "SGLang is not installed or not in PATH on this server.", + [{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}], + ), + ( + r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'|git: command not found|cmake: command not found", + "llama.cpp / llama-cpp-python dependencies are missing.", + [{"label": "install llama.cpp dependencies or llama-cpp-python[server]", "op": "dependency", "package": "llama-cpp-python[server]"}], + ), + ( + r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review", + "Model access is gated or unauthorized.", + [{"label": "set HF token and request model access on HuggingFace", "op": "manual"}], + ), + ] + for pattern, message, suggestions in patterns: + if re.search(pattern, tail, re.I): + return {"message": message, "suggestions": suggestions} + if re.search(r"Traceback \(most recent call last\)", tail, re.I) and not re.search( + r"Application startup complete|GET /v1/|Uvicorn running on", tail, re.I + ): + return { + "message": "Python traceback detected during serve startup.", + "suggestions": [{"label": "inspect traceback and retry with adjusted backend/settings", "op": "manual"}], + } + return None + + def _state_for_client(state): + """Return cookbook state without raw secrets for browser clients.""" + _strip_task_secrets(state) + env = state.get("env") if isinstance(state, dict) else None + if isinstance(env, dict): + token = _decrypt_secret(env.get("hfToken")) + env.pop("hfToken", None) + env["hfTokenConfigured"] = bool(token) + env["hfTokenMasked"] = _mask_secret(token) + return state + + def _state_for_storage(state, on_disk=None): + """Encrypt cookbook secrets before writing state to disk.""" + _strip_task_secrets(state) + env = state.get("env") if isinstance(state, dict) else None + disk_env = on_disk.get("env") if isinstance(on_disk, dict) and isinstance(on_disk.get("env"), dict) else {} + if isinstance(env, dict): + incoming = env.get("hfToken") + if incoming: + _validate_token(incoming) + env["hfToken"] = _encrypt_secret(incoming) + elif disk_env.get("hfToken"): + env["hfToken"] = disk_env["hfToken"] + else: + env.pop("hfToken", None) + env.pop("hfTokenMasked", None) + env.pop("hfTokenConfigured", None) + return state + + def _load_stored_hf_token() -> str: + if not _cookbook_state_path.exists(): + return "" + try: + state = json.loads(_cookbook_state_path.read_text()) + env = state.get("env") if isinstance(state, dict) else {} + return _decrypt_secret(env.get("hfToken") if isinstance(env, dict) else "") + except Exception: + return "" + + def _cookbook_ssh_dir() -> Path: + app_ssh = Path("/app/.ssh") + if Path("/app").exists(): + return app_ssh + return Path.home() / ".ssh" + + def _cookbook_ssh_key_path() -> Path: + return _cookbook_ssh_dir() / "id_ed25519" + + def _read_cookbook_public_key() -> str: + pub = _cookbook_ssh_key_path().with_suffix(".pub") + if not pub.exists(): + return "" + return pub.read_text(encoding="utf-8", errors="replace").strip() + + @router.get("/api/cookbook/ssh-key") + async def get_cookbook_ssh_key(request: Request): + require_admin(request) + public_key = _read_cookbook_public_key() + return { + "configured": bool(public_key), + "public_key": public_key, + } + + @router.post("/api/cookbook/ssh-key") + async def generate_cookbook_ssh_key(request: Request): + require_admin(request) + ssh_dir = _cookbook_ssh_dir() + key_path = _cookbook_ssh_key_path() + ssh_dir.mkdir(parents=True, exist_ok=True) + try: + os.chmod(ssh_dir, 0o700) + except Exception: + pass + if not key_path.exists(): + proc = await asyncio.create_subprocess_exec( + "ssh-keygen", "-t", "ed25519", "-N", "", "-C", "odysseus-cookbook", "-f", str(key_path), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + detail = (stderr or stdout).decode("utf-8", errors="replace").strip()[-500:] + return {"ok": False, "error": detail or "Failed to generate SSH key"} + try: + os.chmod(key_path, 0o600) + os.chmod(key_path.with_suffix(".pub"), 0o644) + except Exception: + pass + return {"ok": True, "public_key": _read_cookbook_public_key()} + + def _user_shell_path_bootstrap() -> list[str]: + return [ + 'ODYSSEUS_USER_SHELL="${SHELL:-}"', + 'if [ -n "$ODYSSEUS_USER_SHELL" ] && [ -x "$ODYSSEUS_USER_SHELL" ]; then', + ' ODYSSEUS_USER_PATH="$("$ODYSSEUS_USER_SHELL" -ic \'printf "__ODYSSEUS_PATH__%s\\n" "$PATH"\' 2>/dev/null | sed -n \'s/^__ODYSSEUS_PATH__//p\' | tail -n 1 || true)"', + ' if [ -n "$ODYSSEUS_USER_PATH" ]; then export PATH="$ODYSSEUS_USER_PATH:$PATH"; fi', + 'fi', + ] + + def _needs_binary(cmd: str, binary: str) -> bool: + return bool(re.search(rf"(^|[\s;&|()]){re.escape(binary)}($|[\s;&|()])", cmd or "")) + + def _missing_binary_message(binary: str, target: str) -> str: + if binary == "tmux": + return ( + f"tmux is required for Cookbook background downloads/serves on {target}. " + "Install it with your OS package manager, or run Cookbook server setup for that server." + ) + if binary == "docker": + return ( + f"Docker is required by this Cookbook launch command on {target}, but the docker CLI was not found. " + "Install Docker and make sure this user can run `docker`, then retry." + ) + return f"{binary} is required on {target}, but it was not found." + + async def _remote_binary_available(remote: str, ssh_port: str | None, binary: str, *, windows: bool = False) -> bool: + _port = ssh_port or "" + _pf = ["-p", _port] if _port and _port != "22" else [] + if windows: + check = f"powershell -NoProfile -Command \"if (Get-Command {binary} -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 127 }}\"" + else: + check = f"command -v {shlex.quote(binary)} >/dev/null 2>&1" + try: + proc = await asyncio.create_subprocess_exec( + "ssh", "-o", "ConnectTimeout=6", "-o", "StrictHostKeyChecking=no", + *_pf, remote, check, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await asyncio.wait_for(proc.communicate(), timeout=10) + return proc.returncode == 0 + except Exception: + return False + + async def _binary_available(binary: str, remote: str | None, ssh_port: str | None, *, windows: bool = False) -> bool: + if remote: + return await _remote_binary_available(remote, ssh_port, binary, windows=windows) + return shutil.which(binary) is not None + + @router.post("/api/model/download") + async def model_download(request: Request, req: ModelDownloadRequest): + """Download a HuggingFace model in a tmux session. + Uses `hf download` CLI directly — runs in tmux via `script -qc` + for real TTY progress, streams ANSI-stripped output via log file.""" + require_admin(request) + # Defence-in-depth: even though this endpoint is admin-gated, refuse + # values that would land in shell contexts with metacharacters. + _validate_repo_id(req.repo_id) + _validate_include(req.include) + _validate_remote_host(req.remote_host) + req.ssh_port = _validate_ssh_port(req.ssh_port) + req.local_dir = _validate_local_dir(req.local_dir) + req.hf_token = req.hf_token or _load_stored_hf_token() + _validate_token(req.hf_token) + TMUX_LOG_DIR.mkdir(parents=True, exist_ok=True) + session_id = f"cookbook-{uuid.uuid4().hex[:8]}" + wrapper_script = TMUX_LOG_DIR / f"{session_id}.sh" + + # When a download directory is set, target a per-model subfolder under it + # (/) so the flat-directory cache scan lists it as its own + # model. Without it, hf/snapshot_download falls back to the HF cache. + _dl_short = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id + _dl_base = (req.local_dir.rstrip("/") + "/" + _dl_short) if req.local_dir else None + _dl_shell = _shell_path(_dl_base) if _dl_base else None # for hf CLI / bash + _dl_pyarg = (", local_dir=os.path.expanduser(" + repr(_dl_base) + ")") if _dl_base else "" + + # Build the hf download command. Redirection to suppress the interactive + # "update available? [Y/n]" prompt is added per-platform further down + # (< /dev/null on bash, $null | on PowerShell). + hf_cmd = f"hf download {req.repo_id}" + if req.include: + hf_cmd += f" --include '{req.include}'" + if _dl_shell: + hf_cmd += f" --local-dir {_dl_shell}" + + # Build the shell wrapper — runs hf download directly in tmux (which is a TTY) + # No script/tee needed — we'll use tmux capture-pane to read output + lines = ["#!/bin/bash"] + lines.extend(_user_shell_path_bootstrap()) + if req.hf_token: + lines.append(f"export HF_TOKEN='{_bash_squote(req.hf_token)}'") + # Ensure pip-user scripts (e.g. hf CLI installed via --user) are on PATH + lines.append('export PATH="$HOME/.local/bin:$PATH"') + # Best-effort install hf CLI (always). hf_transfer (Rust parallel downloader) + # is fast but flaky on large files — it tends to crash near the end at high + # throughput. Retries set disable_hf_transfer to fall back to the plain, + # slower-but-reliable downloader (resumes cleanly from the .incomplete files). + lines.append("command -v hf >/dev/null 2>&1 || pip install --user --break-system-packages -q -U huggingface_hub 2>/dev/null || pip install -q -U huggingface_hub 2>/dev/null") + if req.disable_hf_transfer: + lines.append("export HF_HUB_ENABLE_HF_TRANSFER=0") + lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=4") + else: + lines.append("python3 -c 'import hf_transfer' 2>/dev/null || pip install --user --break-system-packages -q hf_transfer 2>/dev/null || pip install -q hf_transfer 2>/dev/null") + lines.append("python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") + lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=8") + + remote = req.remote_host # None for local + is_windows = req.platform == "windows" + logger.info(f"Download request: repo={req.repo_id}, remote={remote}, ssh_port={req.ssh_port}, platform={req.platform}") + + if not is_windows and not await _binary_available("tmux", remote, req.ssh_port): + return { + "ok": False, + "error": _missing_binary_message("tmux", remote or "local server"), + "session_id": session_id, + } + + if remote and is_windows: + # ── Windows remote: generate .ps1 runner, use Start-Process for background ── + remote_runner = f".{session_id}_run.ps1" + ps_lines = [] + ps_lines.append('$sessionDir = "$env:TEMP\\odysseus-sessions"') + ps_lines.append('New-Item -ItemType Directory -Force -Path $sessionDir | Out-Null') + if req.hf_token: + ps_lines.append(f"$env:HF_TOKEN = '{_ps_squote(req.hf_token)}'") + if req.env_prefix: + ps_lines.append(_safe_env_prefix(req.env_prefix)) + # Try hf CLI, fall back to Python huggingface_hub, then auto-install + ps_lines.append('try {{') + ps_lines.append(' $hfPath = Get-Command hf -ErrorAction SilentlyContinue') + ps_lines.append(' if ($hfPath) {{') + # Pipe $null to stdin to suppress interactive "update available? [Y/n]" prompt + ps_lines.append(f' $null | {hf_cmd}') + ps_lines.append(' }} else {{') + ps_lines.append(' python -c "import huggingface_hub" 2>$null') + ps_lines.append(' if ($LASTEXITCODE -eq 0) {{') + ps_lines.append(' Write-Host "hf CLI not found, using Python huggingface_hub..."') + ps_lines.append(' python -m pip install -q hf_transfer 2>$null') + ps_lines.append(' $env:HF_HUB_ENABLE_HF_TRANSFER = "1"') + ps_lines.append(f" python -c \"import os; from huggingface_hub import snapshot_download; snapshot_download('{req.repo_id}'{_dl_pyarg}, max_workers=8)\"") + ps_lines.append(' }} else {{') + ps_lines.append(' Write-Host "Installing huggingface-hub..."') + ps_lines.append(' python -m pip install -q huggingface-hub hf_transfer') + ps_lines.append(' $env:HF_HUB_ENABLE_HF_TRANSFER = "1"') + ps_lines.append(f" python -c \"import os; from huggingface_hub import snapshot_download; snapshot_download('{req.repo_id}'{_dl_pyarg}, max_workers=8)\"") + ps_lines.append(' }}') + ps_lines.append(' }}') + ps_lines.append(' if ($LASTEXITCODE -eq 0) {{ Write-Host ""; Write-Host "DOWNLOAD_OK" }}') + ps_lines.append(' else {{ Write-Host ""; Write-Host "DOWNLOAD_FAILED (exit $LASTEXITCODE)" }}') + ps_lines.append('}} catch {{') + ps_lines.append(' Write-Host ""; Write-Host "DOWNLOAD_FAILED ($_)"') + ps_lines.append('}}') + ps_lines.append(f'Remove-Item -Force "$HOME\\{remote_runner}" -ErrorAction SilentlyContinue') + runner_path = TMUX_LOG_DIR / f"{session_id}_run.ps1" + runner_path.write_text("\r\n".join(ps_lines) + "\r\n") + + # scp the .ps1 script, then launch it as a detached process with log + pid files + _port = req.ssh_port + _Pf = f"-P {_port} " if _port and _port != "22" else "" + _pf = f"-p {_port} " if _port and _port != "22" else "" + # Start-Process creates a fully detached process that survives SSH disconnect + launch_ps = ( + "$sd = \\\"$env:TEMP\\odysseus-sessions\\\"; " + f"Start-Process powershell -ArgumentList '-ExecutionPolicy','Bypass','-File','$HOME\\{remote_runner}' " + f"-RedirectStandardOutput \\\"$sd\\{session_id}.log\\\" " + f"-RedirectStandardError \\\"$sd\\{session_id}.err.log\\\" " + f"-NoNewWindow -PassThru | ForEach-Object {{ $_.Id | Out-File \\\"$sd\\{session_id}.pid\\\" }}" + ) + setup_cmd = ( + f"scp -O {_Pf}-q '{runner_path}' {remote}:{remote_runner} && " + f'ssh {_pf}{remote} "powershell -Command \\"{launch_ps}\\""' + ) + + elif remote: + # ── Linux/Termux remote: create tmux session ON the remote host ── + remote_runner = f".{session_id}_run.sh" + runner_lines = ["#!/bin/bash"] + runner_lines.extend(_user_shell_path_bootstrap()) + runner_lines.append("# Auto-detect environment") + runner_lines.append("deactivate 2>/dev/null; hash -r") + if req.hf_token: + runner_lines.append(f"export HF_TOKEN='{_bash_squote(req.hf_token)}'") + if req.env_prefix: + runner_lines.append(_safe_env_prefix(req.env_prefix)) + else: + # Fallback: find a venv with hf CLI, or install huggingface-hub + runner_lines.append( + 'for p in ~/vllm-env ~/venv ~/.venv; do ' + 'if [ -f "$p/bin/activate" ]; then source "$p/bin/activate"; break; fi; ' + 'done' + ) + # Ensure pip-user scripts (e.g. hf CLI installed via --user) are on PATH + runner_lines.append('export PATH="$HOME/.local/bin:$PATH"') + # Install hf CLI + hf_transfer best-effort so future runs get the fast path. + # Use --break-system-packages on PEP-668 systems (Arch, newer Debian) so it doesn't bail. + runner_lines.append("command -v hf >/dev/null 2>&1 || pip install --user --break-system-packages -q -U huggingface_hub 2>/dev/null || pip install -q -U huggingface_hub 2>/dev/null") + runner_lines.append("python3 -c 'import hf_transfer' 2>/dev/null || pip install --user --break-system-packages -q hf_transfer 2>/dev/null || pip install -q hf_transfer 2>/dev/null") + runner_lines.append("python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") + runner_lines.append("export HF_HUB_DOWNLOAD_MAX_WORKERS=8") + # Surface whether the HF token actually reached THIS server, so a gated + # download's "not authorized" failure can be told apart from a missing + # token (the token is masked — we only print applied / not-set). + runner_lines.append(_HF_TOKEN_STATUS_SNIPPET) + # Try hf CLI first, fall back to Python huggingface_hub, then auto-install + runner_lines.append('if command -v hf &>/dev/null; then') + # < /dev/null suppresses interactive "update available? [Y/n]" prompt + runner_lines.append(f' {hf_cmd} < /dev/null') + runner_lines.append('elif python3 -c "import huggingface_hub" 2>/dev/null; then') + runner_lines.append(' echo "hf CLI not found, using Python huggingface_hub..."') + runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers=8)"') + runner_lines.append('else') + runner_lines.append(' echo "Installing huggingface-hub and dependencies..."') + runner_lines.append(' pip install --no-deps -q huggingface-hub 2>/dev/null') + runner_lines.append(' pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests hf_transfer 2>/dev/null') + runner_lines.append(" python3 -c 'import hf_transfer' 2>/dev/null && export HF_HUB_ENABLE_HF_TRANSFER=1") + runner_lines.append(f' python3 -c "import os; from huggingface_hub import snapshot_download; snapshot_download(\'{req.repo_id}\'{_dl_pyarg}, max_workers=8)"') + runner_lines.append('fi') + runner_lines.append('if [ $? -eq 0 ]; then echo ""; echo "DOWNLOAD_OK"; else echo ""; echo "DOWNLOAD_FAILED (exit $?)"; fi') + runner_lines.append(f"rm -f {remote_runner}") + runner_lines.append('exec "${SHELL:-/bin/bash}"') + runner_path = TMUX_LOG_DIR / f"{session_id}_run.sh" + runner_path.write_text("\n".join(runner_lines) + "\n") + runner_path.chmod(0o755) + + # scp the runner script, then create tmux session on the remote + _port = req.ssh_port + _pf = f"-P {_port} " if _port and _port != "22" else "" + _spf = f"-p {_port} " if _port and _port != "22" else "" + setup_cmd = ( + f"scp -O {_pf}-q '{runner_path}' {remote}:{remote_runner} && " + f"ssh {_spf}{remote} 'chmod +x {remote_runner} && tmux new-session -d -s {session_id} \"./{remote_runner}\"'" + ) + else: + # Local: run hf download in a local tmux session + if req.env_prefix: + lines.append(_safe_env_prefix(req.env_prefix)) + else: + lines.append("deactivate 2>/dev/null; hash -r") + # Show whether the HF token reached this run (masked) — tells a gated + # "not authorized" failure apart from a missing token. + lines.append(_HF_TOKEN_STATUS_SNIPPET) + # < /dev/null suppresses interactive "update available? [Y/n]" prompt + lines.append(f"{hf_cmd} < /dev/null") + lines.append('if [ $? -eq 0 ]; then echo ""; echo "DOWNLOAD_OK"; else echo ""; echo "DOWNLOAD_FAILED (exit $?)"; fi') + lines.append(f"rm -f '{wrapper_script}'") + lines.append('exec "${SHELL:-/bin/bash}"') + wrapper_script.write_text("\n".join(lines) + "\n") + wrapper_script.chmod(0o755) + setup_cmd = f"tmux new-session -d -s {session_id} {shlex.quote(str(wrapper_script))}" + + logger.info(f"Model download: {req.repo_id} (include={req.include}, session={session_id}, remote={remote})") + logger.info(f"Download setup_cmd: {setup_cmd}") + + proc = await asyncio.create_subprocess_shell( + setup_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await proc.wait() + + if proc.returncode != 0: + stderr = (await proc.stderr.read()).decode(errors="replace") + logger.error(f"Download failed (rc={proc.returncode}): {stderr}") + return {"ok": False, "error": stderr, "session_id": session_id} + + # Log to assistant + try: + from src.assistant_log import log_to_assistant + from src.auth_helpers import get_current_user + owner = get_current_user(request) + log_to_assistant( + owner, + f"Started downloading {req.repo_id} to {remote or 'local'}", + category="Download", + ) + except Exception: + pass + + return {"ok": True, "session_id": session_id, "remote": remote or "local"} + + @router.get("/api/model/cached") + async def model_cached(request: Request, host: str | None = None, model_dir: str | None = None, ssh_port: str | None = None, platform: str | None = None): + """List cached models. Scans HF cache + optional model directory.""" + require_admin(request) + # Validate shell-bound inputs, matching the sibling list_gpus endpoint — + # `host`/`ssh_port` are interpolated into an ssh command below, so an + # unvalidated value (e.g. "x'; rm -rf ~ #") would be command injection. + host = _validate_remote_host(host) + if ssh_port is not None and ssh_port != "" and not _SSH_PORT_RE.fullmatch(ssh_port): + raise HTTPException(400, "Invalid ssh_port") + TMUX_LOG_DIR.mkdir(parents=True, exist_ok=True) + + paths_code = "import json, os\n" + paths_code += "models = []\n" + paths_code += "seen = set()\n" + paths_code += "BLOCKED_ROOTS = ('/sys', '/proc', '/dev', '/run', '/var/run')\n" + paths_code += "def safe_path(p):\n" + paths_code += " try:\n" + paths_code += " rp = os.path.realpath(os.path.expanduser(p))\n" + paths_code += " return not any(rp == b or rp.startswith(b + os.sep) for b in BLOCKED_ROOTS)\n" + paths_code += " except Exception:\n" + paths_code += " return False\n" + paths_code += "def safe_walk(top):\n" + paths_code += " if not safe_path(top): return\n" + paths_code += " for root, dirs, fns in os.walk(top, followlinks=False):\n" + paths_code += " dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(root, d)) and safe_path(os.path.join(root, d))]\n" + paths_code += " yield root, dirs, fns\n" + # Scan HF cache format (models-- directories with blobs/) + paths_code += "def scan_hf(cache):\n" + paths_code += " if not os.path.isdir(cache): return\n" + paths_code += " for d in sorted(os.listdir(cache)):\n" + paths_code += " if not d.startswith('models--'): continue\n" + paths_code += " rid = d.replace('models--','').replace('--','/')\n" + paths_code += " if rid in seen: continue\n" + paths_code += " seen.add(rid)\n" + paths_code += " blobs = os.path.join(cache, d, 'blobs')\n" + paths_code += " sz, nf, ic = 0, 0, False\n" + paths_code += " if os.path.isdir(blobs):\n" + paths_code += " for f in os.scandir(blobs):\n" + paths_code += " if f.is_file(): nf += 1; sz += f.stat().st_size\n" + paths_code += " if f.name.endswith('.incomplete'): ic = True\n" + paths_code += " # Check if it's an LLM (has config.json with model_type) vs diffusion (has model_index.json)\n" + paths_code += " snap = os.path.join(cache, d, 'snapshots')\n" + paths_code += " is_diffusion = False; is_gguf = False\n" + paths_code += " if os.path.isdir(snap):\n" + paths_code += " for sd in os.listdir(snap):\n" + paths_code += " sf = os.path.join(snap, sd)\n" + paths_code += " if not os.path.isdir(sf): continue\n" + paths_code += " if os.path.exists(os.path.join(sf, 'model_index.json')): is_diffusion = True\n" + paths_code += " try:\n" + paths_code += " if any(x.endswith('.gguf') for x in os.listdir(sf)): is_gguf = True\n" + paths_code += " except Exception: pass\n" + paths_code += " models.append({'repo_id':rid,'size_bytes':sz,'nb_files':nf,'has_incomplete':ic,'path':cache,'is_diffusion':is_diffusion,'is_gguf':is_gguf})\n" + # Scan plain directory (each subdirectory = a model if it has model files) + paths_code += "def scan_dir(p):\n" + paths_code += " if not os.path.isdir(p) or not safe_path(p): return\n" + paths_code += " for d in sorted(os.listdir(p)):\n" + paths_code += " if d.startswith('.'): continue\n" + paths_code += " fp = os.path.join(p, d)\n" + paths_code += " if not os.path.isdir(fp) or os.path.islink(fp) or not safe_path(fp): continue\n" + paths_code += " if d in seen: continue\n" + paths_code += " # Check if it looks like a model (has config.json, safetensors, bin, or gguf)\n" + paths_code += " is_model = False; is_gguf = False\n" + paths_code += " for root, dirs, fns in safe_walk(fp):\n" + paths_code += " for fn in fns:\n" + paths_code += " if fn.endswith('.gguf'): is_gguf = True; is_model = True\n" + paths_code += " elif fn == 'config.json' or fn.endswith('.safetensors') or fn.endswith('.bin'): is_model = True\n" + paths_code += " if is_model: break\n" + paths_code += " if not is_model: continue\n" + paths_code += " seen.add(d)\n" + paths_code += " sz, nf = 0, 0\n" + paths_code += " for dp, _, fns in safe_walk(fp):\n" + paths_code += " for fn in fns:\n" + paths_code += " try: nf += 1; sz += os.path.getsize(os.path.join(dp, fn))\n" + paths_code += " except Exception: pass\n" + paths_code += " is_diff = os.path.exists(os.path.join(fp, 'model_index.json'))\n" + paths_code += " models.append({'repo_id':d,'size_bytes':sz,'nb_files':nf,'has_incomplete':False,'path':p,'is_local_dir':True,'is_diffusion':is_diff,'is_gguf':is_gguf})\n" + # Always scan HF cache + paths_code += "scan_hf(os.path.expanduser('~/.cache/huggingface/hub'))\n" + # Also scan custom model dirs (comma-separated) if specified + if model_dir: + for d in model_dir.split(','): + d = d.strip() + if d and d != '~/.cache/huggingface/hub': + # repr() encodes the dir as a properly-escaped Python string + # literal. The old f"...'{d}'..." broke out of the quotes on + # any `'` in the value, injecting arbitrary Python that then + # ran locally or over ssh. + paths_code += f"scan_dir(os.path.expanduser({d!r}))\n" + paths_code += "print(json.dumps(models))\n" + + scan_py = TMUX_LOG_DIR / "scan_cache.py" + scan_py.write_text(paths_code) + + if host: + _pf = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else "" + if platform == "windows": + # Windows: use 'python' and pipe via stdin with double-quote wrapping + cmd = f'ssh {_pf}{host} "python -" < \'{scan_py}\'' + else: + cmd = f"ssh {_pf}{host} 'python3 -' < '{scan_py}'" + else: + cmd = f"python3 '{scan_py}'" + + proc = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(Path.home()), + ) + stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=60) + + models = [] + try: + raw = json.loads(stdout_b.decode(errors="replace").strip()) + for m in raw: + size_gb = m["size_bytes"] / (1024 ** 3) + if size_gb >= 1: + size_str = f"{size_gb:.1f} GB" + else: + size_str = f"{m['size_bytes'] / (1024**2):.0f} MB" + entry = { + "repo_id": m["repo_id"], + "size": size_str, + "nb_files": m["nb_files"], + "has_incomplete": m["has_incomplete"], + "status": "downloading" if m["has_incomplete"] else "ready", + "path": m.get("path", ""), + "is_diffusion": m.get("is_diffusion", False), + } + if m.get("is_local_dir"): + entry["is_local_dir"] = True + models.append(entry) + except Exception as e: + logger.warning(f"Failed to parse cached models: {e}") + logger.warning(f"stderr: {stderr_b.decode(errors='replace')[:500]}") + + return {"models": models, "host": host or "local"} + + def _auto_register_image_endpoint(req: ServeRequest, remote: str | None) -> str | None: + """Register a diffusion model as an image endpoint so it appears in the model selector.""" + import re + from core.database import SessionLocal, ModelEndpoint + + # Parse port from command (--port NNNN), default 8100 for diffusion_server + port_match = re.search(r'--port\s+(\d+)', req.cmd) + port = int(port_match.group(1)) if port_match else 8100 + + # Determine host + if remote: + # SSH alias — use as hostname (Tailscale resolves it later) + host = remote.split("@")[-1] if "@" in remote else remote + else: + host = "localhost" + + base_url = f"http://{host}:{port}/v1" + + # Friendly display name from repo_id + short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id + display_name = f"{short_name} (image)" + + db = SessionLocal() + try: + # Check for existing endpoint with same base_url — update it + existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first() + if existing: + existing.is_enabled = True + existing.model_type = "image" + existing.name = display_name + db.commit() + logger.info(f"Updated existing image endpoint: {base_url}") + return existing.id + + ep_id = f"img-{uuid.uuid4().hex[:8]}" + ep = ModelEndpoint( + id=ep_id, + name=display_name, + base_url=base_url, + api_key=None, + is_enabled=True, + model_type="image", + ) + db.add(ep) + db.commit() + logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}") + return ep_id + except Exception as e: + logger.error(f"Failed to auto-register image endpoint: {e}") + db.rollback() + return None + finally: + db.close() + + @router.post("/api/model/serve") + async def model_serve(request: Request, req: ServeRequest): + """Launch a model server in a tmux session (or PowerShell background process on Windows). + + `repo_id` is dual-purpose: a HuggingFace repo (`/`) for + model-serve commands, OR a bare pip package name when the cmd is a + `python -m pip install …`. We only enforce the strict HF format on + the model paths. + """ + require_admin(request) + # Defence-in-depth: reject values that could break out of shell contexts. + _validate_remote_host(req.remote_host) + req.ssh_port = _validate_ssh_port(req.ssh_port) + req.gpus = _validate_gpus(req.gpus) + req.hf_token = req.hf_token or _load_stored_hf_token() + _validate_token(req.hf_token) + # Normalize away backslash-newline continuations (multi-line pasted + # serve commands) so the cleaned single-line command is what gets + # written into the runner script and used for engine auto-detection. + # `_validate_serve_cmd` returns None for empty input; coerce to "" so the + # many downstream `"engine" in req.cmd` membership checks can't hit + # `TypeError: argument of type 'NoneType'` (a 500 instead of a clean 400). + req.cmd = _validate_serve_cmd(req.cmd) or "" + is_pip_install = bool(req.cmd and "pip install" in req.cmd) + if is_pip_install: + # PEP-508-style package spec — letters, digits, `.-_` for the + # name; `[` `]` for extras; `<>=!~,` for version specifiers. + # v2 review HIGH-14: tightened from the previous regex which + # also allowed spaces and `+`, both of which can be abused to + # introduce extra shell tokens once interpolated into the + # serve command. We now use `re.fullmatch` and drop space/`+`. + if not req.repo_id or not re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9._\-\[\]<>=!,~]{0,200}", req.repo_id + ): + raise HTTPException(400, "Invalid pip package name") + else: + _validate_repo_id(req.repo_id) + TMUX_LOG_DIR.mkdir(parents=True, exist_ok=True) + session_id = f"serve-{uuid.uuid4().hex[:8]}" + remote = req.remote_host + is_windows = req.platform == "windows" + + if not is_windows and not await _binary_available("tmux", remote, req.ssh_port): + return { + "ok": False, + "error": _missing_binary_message("tmux", remote or "local server"), + "session_id": session_id, + } + if _needs_binary(req.cmd, "docker") and not await _binary_available("docker", remote, req.ssh_port, windows=is_windows): + return { + "ok": False, + "error": _missing_binary_message("docker", remote or "local server"), + "session_id": session_id, + } + + if is_windows and remote: + # ── Windows remote: generate .ps1 serve runner ── + remote_runner = f".{session_id}_run.ps1" + ps_lines = [] + ps_lines.append('$sessionDir = "$env:TEMP\\odysseus-sessions"') + ps_lines.append('New-Item -ItemType Directory -Force -Path $sessionDir | Out-Null') + if req.hf_token: + ps_lines.append(f"$env:HF_TOKEN = '{_ps_squote(req.hf_token)}'") + if req.gpus: + ps_lines.append(f"$env:CUDA_VISIBLE_DEVICES = '{req.gpus}'") + if req.env_prefix: + ps_lines.append(_safe_env_prefix(req.env_prefix)) + # Auto-install ollama if the command uses it + if "ollama" in req.cmd: + ps_lines.append('# Check if ollama is available') + ps_lines.append('if (-not (Get-Command ollama -ErrorAction SilentlyContinue)) {') + ps_lines.append(' Write-Host "Ollama not found. Please install from https://ollama.com/download/windows"') + ps_lines.append(' exit 1') + ps_lines.append('}') + elif "llama_cpp" in req.cmd or "llama-server" in req.cmd: + ps_lines.append('# Auto-install llama-cpp-python if missing') + ps_lines.append('try { python -c "import llama_cpp" 2>$null } catch {}') + ps_lines.append('if ($LASTEXITCODE -ne 0) {') + ps_lines.append(' Write-Host "Installing llama-cpp-python..."') + ps_lines.append(' python -m pip install llama-cpp-python[server]') + ps_lines.append('}') + elif "vllm" in req.cmd: + ps_lines.append('Write-Host "ERROR: vLLM is not supported on Windows. Use Ollama or llama.cpp instead."') + ps_lines.append('exit 1') + ps_lines.append(req.cmd) + ps_lines.append('Write-Host ""') + ps_lines.append('Write-Host "=== Process exited with code $LASTEXITCODE ==="') + runner_path = TMUX_LOG_DIR / f"{session_id}_run.ps1" + runner_path.write_text("\r\n".join(ps_lines) + "\r\n") + + _port = req.ssh_port + _Pf = f"-P {_port} " if _port and _port != "22" else "" + _pf = f"-p {_port} " if _port and _port != "22" else "" + launch_ps = ( + "$sd = \\\"$env:TEMP\\odysseus-sessions\\\"; " + f"Start-Process powershell -ArgumentList '-ExecutionPolicy','Bypass','-File','$HOME\\{remote_runner}' " + f"-RedirectStandardOutput \\\"$sd\\{session_id}.log\\\" " + f"-RedirectStandardError \\\"$sd\\{session_id}.err.log\\\" " + f"-NoNewWindow -PassThru | ForEach-Object {{ $_.Id | Out-File \\\"$sd\\{session_id}.pid\\\" }}" + ) + setup_cmd = ( + f"scp -O {_Pf}-q '{runner_path}' {remote}:{remote_runner} && " + f'ssh {_pf}{remote} "powershell -Command \\"{launch_ps}\\""' + ) + else: + # ── Linux/Termux: bash + tmux (existing flow) ── + runner_lines = ["#!/bin/bash"] + runner_lines.extend(_user_shell_path_bootstrap()) + runner_lines.append("export FLASHINFER_DISABLE_VERSION_CHECK=1") + if req.hf_token: + runner_lines.append(f"export HF_TOKEN='{_bash_squote(req.hf_token)}'") + if req.gpus: + runner_lines.append(f"export CUDA_VISIBLE_DEVICES='{req.gpus}'") + if req.env_prefix: + runner_lines.append(_safe_env_prefix(req.env_prefix)) + else: + runner_lines.append("deactivate 2>/dev/null; hash -r") + # Show whether the HF token reached this server (masked) — a gated + # model vLLM has to download will be denied without it. + runner_lines.append(_HF_TOKEN_STATUS_SNIPPET) + # Auto-install inference engine if missing + if "llama_cpp" in req.cmd or "llama-server" in req.cmd: + # Prefer the NATIVE llama-server binary — its minja templating + # renders modern GGUF chat templates that the Python bindings' + # Jinja2 rejects (do_tojson ensure_ascii). Build it once from + # source if missing; keep llama-cpp-python only as a fallback. + runner_lines.append('# Ensure a llama.cpp server (prefer native llama-server)') + runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:$PATH"') + runner_lines.append('if [ -d /data/data/com.termux ]; then') + runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).') + runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then') + runner_lines.append(' pkg install -y cmake 2>/dev/null') + runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null') + runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install llama-cpp-python --no-build-isolation --no-cache-dir 2>&1 || true') + runner_lines.append(' fi') + runner_lines.append('elif ! command -v llama-server &>/dev/null; then') + runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."') + runner_lines.append(' mkdir -p ~/bin') + runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp') + # GPU build if CUDA is present; fall back to a plain (CPU) build. + runner_lines.append(' cd ~/llama.cpp && { cmake -B build -DGGML_CUDA=ON 2>/dev/null || cmake -B build; } \\') + runner_lines.append(' && cmake --build build -j"$(nproc)" --target llama-server \\') + runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server') + runner_lines.append(' # If the native build failed, fall back to the Python bindings.') + runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then') + runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."') + runner_lines.append(' pip install --user --break-system-packages -q llama-cpp-python 2>/dev/null || pip install -q llama-cpp-python 2>/dev/null || true') + runner_lines.append(' fi') + runner_lines.append('fi') + elif "vllm serve" in req.cmd: + # Put ~/.local/bin on PATH first — without a venv, vllm installs + # there via --user and the non-login serve shell otherwise can't + # find the `vllm` CLI ("command not found"). Mirrors llama.cpp above. + runner_lines.append('export PATH="$HOME/.local/bin:$PATH"') + runner_lines.append('if ! command -v vllm &>/dev/null; then') + runner_lines.append(' echo "ERROR: vLLM is not installed. Open Cookbook -> Dependencies and install vllm on this server, then launch again."') + runner_lines.append(' exit 127') + runner_lines.append('fi') + elif "sglang.launch_server" in req.cmd: + runner_lines.append('export PATH="$HOME/.local/bin:$PATH"') + runner_lines.append('if ! python3 -c "import sglang" 2>/dev/null; then') + runner_lines.append(' echo "ERROR: SGLang is not installed. Open Cookbook -> Dependencies and install sglang on this server, then launch again."') + runner_lines.append(' exit 127') + runner_lines.append('fi') + + runner_lines.append(req.cmd) + # Keep shell open after exit so user can see errors + runner_lines.append('echo ""; echo "=== Process exited with code $? ==="; exec "${SHELL:-/bin/bash}"') + + runner_path = TMUX_LOG_DIR / f"{session_id}_run.sh" + runner_path.write_text("\n".join(runner_lines) + "\n") + runner_path.chmod(0o755) + + if remote: + remote_runner = f".{session_id}_run.sh" + # If command references scripts/, scp those too + scp_extras = "" + _port = req.ssh_port + _Pf = f"-P {_port} " if _port and _port != "22" else "" + _pf = f"-p {_port} " if _port and _port != "22" else "" + if "scripts/diffusion_server.py" in req.cmd: + from core.constants import BASE_DIR + diff_script = Path(BASE_DIR) / "scripts" / "diffusion_server.py" + if diff_script.exists(): + scp_extras = f"scp -O {_Pf}-q '{diff_script}' {remote}:.diffusion_server.py && " + runner_path.write_text( + runner_path.read_text().replace( + "scripts/diffusion_server.py", ".diffusion_server.py" + ) + ) + setup_cmd = ( + f"{scp_extras}" + f"scp -O {_Pf}-q '{runner_path}' {remote}:{remote_runner} && " + f"ssh {_pf}{remote} 'chmod +x {remote_runner} && tmux new-session -d -s {session_id} \"./{remote_runner}\"'" + ) + else: + setup_cmd = f"tmux new-session -d -s {session_id} {shlex.quote(str(runner_path))}" + + proc = await asyncio.create_subprocess_shell( + setup_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await proc.wait() + + if proc.returncode != 0: + stderr = (await proc.stderr.read()).decode(errors="replace") + return {"ok": False, "error": stderr, "session_id": session_id} + + # Auto-register as model endpoint if serving a diffusion model + endpoint_id = None + is_diffusion = "diffusion_server.py" in req.cmd + if is_diffusion: + endpoint_id = _auto_register_image_endpoint(req, remote) + + # Log to assistant + try: + from src.assistant_log import log_to_assistant + from src.auth_helpers import get_current_user + owner = get_current_user(request) + short = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id + log_to_assistant( + owner, + f"Started serving {short} on {remote or 'local'}", + category="Serve", + ) + except Exception: + pass + + return {"ok": True, "session_id": session_id, "remote": remote or "local", + "endpoint_id": endpoint_id} + + # ── Server setup (install deps on remote) ── + + class SetupRequest(BaseModel): + host: str + ssh_port: str | None = None + + @router.post("/api/cookbook/setup") + async def server_setup(request: Request, req: SetupRequest): + """Install required dependencies on a remote server via SSH.""" + require_admin(request) + host = _validate_remote_host(req.host) + if not host: + raise HTTPException(400, "host is required") + port = req.ssh_port + if port is not None and port != "" and not re.fullmatch(r"\d{1,5}", port): + raise HTTPException(400, "Invalid ssh_port") + pf = f"-p {port} " if port and port != "22" else "" + + # Detect platform: Windows first (echo %OS% → Windows_NT), then Termux, then Linux + detect_cmd = f'ssh {pf}{host} "echo %OS%"' + platform = "linux" + try: + proc = await asyncio.create_subprocess_shell( + detect_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10) + out = stdout.decode().strip() + if "Windows_NT" in out: + platform = "windows" + else: + # Check for Termux + detect_cmd2 = f"ssh {pf}{host} 'test -d /data/data/com.termux && echo termux || echo linux'" + proc2 = await asyncio.create_subprocess_shell( + detect_cmd2, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout2, _ = await asyncio.wait_for(proc2.communicate(), timeout=10) + platform = stdout2.decode().strip() + except Exception: + platform = "linux" + + if platform == "windows": + # Windows setup: ensure Python + pip + huggingface-hub via PowerShell + # Also create the session directory for background tasks + setup_script = ( + 'powershell -Command "' + "New-Item -ItemType Directory -Force -Path $env:TEMP\\odysseus-sessions | Out-Null; " + "try { python --version } catch { Write-Host 'ERROR: Python not found — install from python.org'; exit 1 }; " + "python -m pip install -q huggingface-hub 2>$null; " + "python -c \\\"from huggingface_hub import snapshot_download; print('OK')\\\"" + '"' + ) + cmd = f'ssh {pf}{host} {setup_script}' + elif platform == "termux": + setup_script = ( + "pkg install -y python tmux 2>/dev/null; " + "pip install --no-deps -q huggingface-hub 2>/dev/null; " + "pip install -q filelock fsspec packaging pyyaml tqdm typer httpx requests 2>/dev/null; " + "python3 -c 'from huggingface_hub import snapshot_download; print(\"OK\")'" + ) + cmd = f"ssh {pf}{host} '{setup_script}'" + else: + # Linux: auto-install tmux (via whichever package manager is available) + # and huggingface_hub + hf_transfer (falling back to --user/--break-system-packages + # on PEP-668 locked distros like Arch / newer Debian). + setup_script = ( + # Install tmux if missing — try common package managers; skip if no sudo + "if ! command -v tmux >/dev/null 2>&1; then " + " if command -v apt-get >/dev/null 2>&1; then sudo -n apt-get install -y tmux 2>/dev/null; " + " elif command -v pacman >/dev/null 2>&1; then sudo -n pacman -S --noconfirm tmux 2>/dev/null; " + " elif command -v dnf >/dev/null 2>&1; then sudo -n dnf install -y tmux 2>/dev/null; " + " elif command -v apk >/dev/null 2>&1; then sudo -n apk add --no-interactive tmux 2>/dev/null; " + " elif command -v zypper >/dev/null 2>&1; then sudo -n zypper --non-interactive install tmux 2>/dev/null; " + " fi; " + "fi; " + "command -v tmux >/dev/null 2>&1 || echo 'WARNING: tmux missing and auto-install failed (need passwordless sudo). Install manually.'; " + # Install Python bits. Try system install first; fall back to --user --break-system-packages on PEP 668 systems. + "pip install -q huggingface_hub hf_transfer 2>/dev/null || " + "pip install --user --break-system-packages -q huggingface_hub hf_transfer 2>/dev/null || " + "pip3 install --user --break-system-packages -q huggingface_hub hf_transfer 2>/dev/null; " + "python3 -c 'from huggingface_hub import snapshot_download; print(\"OK\")'" + ) + cmd = f"ssh {pf}{host} '{setup_script}'" + + try: + proc = await asyncio.create_subprocess_shell( + cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120) + output = stdout.decode() + stderr.decode() + ok = "OK" in output + return {"ok": ok, "output": output.strip(), "platform": platform} + except asyncio.TimeoutError: + return {"ok": False, "error": "Setup timed out (120s)", "platform": platform} + except Exception as e: + return {"ok": False, "error": str(e), "platform": platform} + + # ── GPU availability probe ── + + async def _run_nvidia_smi(query: str, host: str | None, ssh_port: str | None, timeout: int = 8): + """Run nvidia-smi locally or over SSH. Returns (stdout, error_or_None).""" + if host: + pf = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else "" + cmd = f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no {pf}{host} '{query}'" + proc = await asyncio.create_subprocess_shell( + cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + else: + proc = await asyncio.create_subprocess_exec( + *shlex.split(query), + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + return None, "nvidia-smi timed out" + if proc.returncode != 0: + err = (stderr.decode("utf-8", errors="replace") or "").strip()[:200] + return None, err or "nvidia-smi failed" + return stdout.decode("utf-8", errors="replace"), None + + async def _run_gpu_shell(cmd_text: str, host: str | None, ssh_port: str | None, timeout: int = 8): + """Run a small GPU probe shell command locally or over SSH.""" + if host: + pf = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else "" + quoted_cmd = shlex.quote(cmd_text) + remote_cmd = ( + f"if command -v sh >/dev/null 2>&1; then sh -lc {quoted_cmd}; " + f"elif command -v bash >/dev/null 2>&1; then bash -lc {quoted_cmd}; " + f"elif command -v zsh >/dev/null 2>&1; then zsh -lc {quoted_cmd}; " + "else echo 'No POSIX shell found for GPU probe' >&2; exit 127; fi" + ) + cmd = f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no {pf}{host} {shlex.quote(remote_cmd)}" + proc = await asyncio.create_subprocess_shell( + cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + else: + proc = await asyncio.create_subprocess_shell( + cmd_text, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + return None, "GPU probe timed out" + if proc.returncode != 0: + err = (stderr.decode("utf-8", errors="replace") or "").strip()[:200] + return None, err or f"GPU probe failed ({proc.returncode})" + return stdout.decode("utf-8", errors="replace"), None + + async def _gpu_read_file(path: str, host: str | None, ssh_port: str | None) -> str | None: + out, err = await _run_gpu_shell(f"cat {shlex.quote(path)} 2>/dev/null", host, ssh_port, timeout=4) + if err is not None or out is None: + return None + return out.strip() + + async def _probe_gpu_device_processes(host: str | None, ssh_port: str | None) -> list[dict]: + pid_cmd = ( + "{ command -v lsof >/dev/null 2>&1 && " + "lsof -w -t /dev/kfd /dev/dri/renderD* 2>/dev/null || true; " + "command -v fuser >/dev/null 2>&1 && " + "fuser /dev/kfd /dev/dri/renderD* 2>/dev/null || true; } " + "| tr ' ' '\\n' | sed '/^[0-9][0-9]*$/!d' | sort -n -u" + ) + out, err = await _run_gpu_shell(pid_cmd, host, ssh_port, timeout=5) + if err is not None or not out: + return [] + processes = [] + seen = set() + for raw in out.splitlines(): + try: + pid = int(raw.strip()) + except ValueError: + continue + if pid in seen: + continue + seen.add(pid) + name_out, _ = await _run_gpu_shell(f"ps -p {pid} -o comm= 2>/dev/null", host, ssh_port, timeout=3) + name = (name_out or "").strip().splitlines()[0] if (name_out or "").strip() else "process" + processes.append({"pid": pid, "name": name[:80], "used_mb": 0}) + return processes + + async def _probe_amd_sysfs(host: str | None, ssh_port: str | None) -> list[dict]: + out, err = await _run_gpu_shell("ls -1 /sys/class/drm 2>/dev/null", host, ssh_port, timeout=4) + if err is not None or not out: + return [] + gpus = [] + for entry in out.split(): + if not entry.startswith("card") or "-" in entry: + continue + base = f"/sys/class/drm/{entry}/device" + vendor = await _gpu_read_file(f"{base}/vendor", host, ssh_port) + if vendor != "0x1002": + continue + vram_raw = await _gpu_read_file(f"{base}/mem_info_vram_total", host, ssh_port) + vis_raw = await _gpu_read_file(f"{base}/mem_info_vis_vram_total", host, ssh_port) + gtt_raw = await _gpu_read_file(f"{base}/mem_info_gtt_total", host, ssh_port) + vram_bytes = int(vram_raw) if vram_raw and vram_raw.isdigit() else 0 + vis_bytes = int(vis_raw) if vis_raw and vis_raw.isdigit() else 0 + gtt_bytes = int(gtt_raw) if gtt_raw and gtt_raw.isdigit() else 0 + total_bytes = max(vram_bytes, vis_bytes) + used_attr = "mem_info_vis_vram_used" if vis_bytes and vis_bytes >= vram_bytes else "mem_info_vram_used" + unified = bool(vis_bytes and vis_bytes >= vram_bytes) + if total_bytes <= 0: + total_bytes = gtt_bytes + used_attr = "mem_info_gtt_used" + unified = True + if total_bytes <= 0: + continue + used_raw = await _gpu_read_file(f"{base}/{used_attr}", host, ssh_port) + used_bytes = int(used_raw) if used_raw and used_raw.isdigit() else 0 + name = await _gpu_read_file(f"{base}/product_name", host, ssh_port) + if not name: + device = await _gpu_read_file(f"{base}/device", host, ssh_port) + name = f"AMD GPU {device or entry}" + total_mb = max(0, int(total_bytes / (1024 * 1024))) + used_mb = max(0, min(total_mb, int(used_bytes / (1024 * 1024)))) + free_mb = max(0, total_mb - used_mb) + gpus.append({ + "index": len(gpus), "name": name, "uuid": entry, + "free_mb": free_mb, "total_mb": total_mb, "used_mb": used_mb, + "util_pct": 0, "busy": bool(total_mb and (free_mb / total_mb) < 0.85), + "processes": [], "backend": "rocm", "source": "amd-sysfs", + "unified_memory": unified, + }) + if gpus: + processes = await _probe_gpu_device_processes(host, ssh_port) + if processes: + gpus[0]["processes"] = processes + gpus[0]["busy"] = True + return gpus + + @router.get("/api/cookbook/gpus") + async def list_gpus(request: Request, host: str | None = None, ssh_port: str | None = None): + """Probe GPU memory/process state locally or via SSH. + + Probe order: + 1. NVIDIA via nvidia-smi + 2. AMD/ROCm and unified-memory APUs via /sys/class/drm + 3. Generic GPU device holders via /dev/kfd and /dev/dri/renderD* + + Returned shape: + { "ok": True, "gpus": [ + {"index": 0, "name": "...", "free_mb": int, "total_mb": int, + "used_mb": int, "util_pct": int, "busy": bool, + "uuid": "GPU-...", + "processes": [{"pid": int, "name": str, "used_mb": int}, ...] + }, ... + ]} + `busy` is True when free_mb/total_mb < 0.5. + """ + require_admin(request) + host = _validate_remote_host(host) + if ssh_port is not None and ssh_port != "" and not _SSH_PORT_RE.fullmatch(ssh_port): + raise HTTPException(400, "Invalid ssh_port") + gpu_query = "nvidia-smi --query-gpu=index,name,memory.free,memory.total,memory.used,utilization.gpu,uuid --format=csv,noheader,nounits" + nvidia_error = None + try: + gpu_out, err = await _run_nvidia_smi(gpu_query, host, ssh_port) + if err is not None: + nvidia_error = err + gpu_out = "" + except FileNotFoundError: + nvidia_error = "nvidia-smi not found" + gpu_out = "" + except Exception as e: + nvidia_error = str(e)[:200] + gpu_out = "" + + gpus = [] + uuid_to_idx: dict[str, int] = {} + for line in (gpu_out or "").strip().splitlines(): + parts = [p.strip() for p in line.split(",")] + if len(parts) < 7: + continue + try: + idx = int(parts[0]) + name = parts[1] + free_mb = int(float(parts[2])) + total_mb = int(float(parts[3])) + used_mb = int(float(parts[4])) + util_pct = int(float(parts[5])) + gpu_uuid = parts[6] + except (ValueError, IndexError): + continue + busy = total_mb > 0 and (free_mb / total_mb) < 0.5 + uuid_to_idx[gpu_uuid] = idx + gpus.append({ + "index": idx, "name": name, "uuid": gpu_uuid, + "free_mb": free_mb, "total_mb": total_mb, + "used_mb": used_mb, "util_pct": util_pct, + "busy": busy, "processes": [], + }) + + # Best-effort process listing — skip silently if it fails + proc_query = "nvidia-smi --query-compute-apps=pid,gpu_uuid,process_name,used_memory --format=csv,noheader,nounits" + try: + proc_out, proc_err = await _run_nvidia_smi(proc_query, host, ssh_port, timeout=5) + if proc_err is None and proc_out: + gpus_by_idx = {g["index"]: g for g in gpus} + for line in proc_out.strip().splitlines(): + parts = [p.strip() for p in line.split(",")] + if len(parts) < 4: + continue + try: + pid = int(parts[0]) + pname = parts[2] + pmem = int(float(parts[3])) + except (ValueError, IndexError): + continue + idx = uuid_to_idx.get(parts[1]) + if idx is None or idx not in gpus_by_idx: + continue + gpus_by_idx[idx]["processes"].append({ + "pid": pid, "name": pname, "used_mb": pmem, + }) + except Exception: + pass + + if gpus: + return {"ok": True, "gpus": gpus, "backend": "cuda", "source": "nvidia-smi"} + + amd_gpus = await _probe_amd_sysfs(host, ssh_port) + if amd_gpus: + return { + "ok": True, + "gpus": amd_gpus, + "backend": "rocm", + "source": "amd-sysfs", + "fallback_from": "nvidia-smi", + "nvidia_error": nvidia_error, + } + + processes = await _probe_gpu_device_processes(host, ssh_port) + if processes: + return { + "ok": True, + "gpus": [{ + "index": 0, "name": "GPU device holders", "uuid": "dev-dri", + "free_mb": 0, "total_mb": 0, "used_mb": 0, "util_pct": 0, + "busy": True, "processes": processes, + "backend": "generic", "source": "gpu-devices", + }], + "backend": "generic", + "source": "gpu-devices", + "fallback_from": "nvidia-smi", + "nvidia_error": nvidia_error, + } + + return {"ok": False, "error": nvidia_error or "No GPU memory probe available", "gpus": []} + + class KillPidRequest(BaseModel): + pid: int + host: str | None = None + ssh_port: str | None = None + signal: str = "TERM" # TERM (graceful) or KILL (force) + + @router.post("/api/cookbook/kill-pid") + async def kill_pid(request: Request, req: KillPidRequest): + """Kill a PID that's holding GPU memory. + + Admin-gated. Validates PID is positive int, signal is TERM/KILL, and + forbids low PIDs (<100) to avoid accidentally signalling init/system + daemons. Uses `kill - ` locally or over SSH. + """ + require_admin(request) + if req.pid < 100: + raise HTTPException(400, f"Refusing to signal PID {req.pid} (<100, likely system process)") + sig = (req.signal or "TERM").upper() + if sig not in ("TERM", "KILL", "INT"): + raise HTTPException(400, "signal must be TERM, KILL, or INT") + host = _validate_remote_host(req.host) + if req.ssh_port and not _SSH_PORT_RE.fullmatch(req.ssh_port): + raise HTTPException(400, "Invalid ssh_port") + kill_cmd = f"kill -{sig} {req.pid}" + try: + if host: + pf = f"-p {req.ssh_port} " if req.ssh_port and req.ssh_port != "22" else "" + cmd = f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no {pf}{host} '{kill_cmd}'" + proc = await asyncio.create_subprocess_shell( + cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + else: + proc = await asyncio.create_subprocess_exec( + "kill", f"-{sig}", str(req.pid), + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=5) + if proc.returncode != 0: + err = (stderr.decode("utf-8", errors="replace") or "").strip()[:200] + return {"ok": False, "error": err or f"kill returned {proc.returncode}"} + return {"ok": True, "pid": req.pid, "signal": sig} + except asyncio.TimeoutError: + return {"ok": False, "error": "kill command timed out"} + except Exception as e: + return {"ok": False, "error": str(e)[:200]} + + # ── Cookbook state persistence (cross-device sync) ── + + @router.get("/api/cookbook/state") + async def get_cookbook_state(request: Request): + """Load saved cookbook state (tasks, servers, presets, settings).""" + require_admin(request) + if _cookbook_state_path.exists(): + try: + return _state_for_client(json.loads(_cookbook_state_path.read_text())) + except Exception: + return {} + return {} + + @router.post("/api/cookbook/state") + async def save_cookbook_state(request: Request): + """Save cookbook state for cross-device sync. + + Admin-gated because cookbook state is read back into shell-quoting + contexts when polling tmux session status (see status handler). + + Merge guard: the UI debounces a `_syncToServer` POST every few + seconds with whatever localStorage has. The agent's tool layer + writes server-side tasks (e.g. `download_model` registering a + task). Without a merge, every UI sync wipes the agent's recent + additions. We preserve any on-disk task that the incoming body + omits but was added in the last RACE_WINDOW seconds — that's a + race, not an intentional delete. + """ + require_admin(request) + RACE_WINDOW_MS = 60_000 + try: + from core.atomic_io import atomic_write_json + data = await request.json() + if not isinstance(data, dict): + data = {} + try: + if _cookbook_state_path.exists(): + on_disk = json.loads(_cookbook_state_path.read_text()) + else: + on_disk = {} + except Exception: + on_disk = {} + # Anti-wipe guard for env servers. The UI debounces a + # sync of whatever is in memory; if it fires before the state has + # hydrated from GET /state (a load-time race) or during a render + # glitch, `env.servers` would be empty and silently overwrite the + # saved servers on disk. Never let an empty/absent incoming + # env.servers clobber a populated on-disk one — preserve the disk + # values while still accepting the rest of the incoming env. + disk_env = on_disk.get("env") if isinstance(on_disk, dict) and isinstance(on_disk.get("env"), dict) else None + if disk_env: + inc_env = data.get("env") if isinstance(data.get("env"), dict) else None + if inc_env is None: + data["env"] = disk_env + logger.warning("cookbook state POST: incoming body had no env; preserved on-disk env (anti-wipe guard)") + elif disk_env.get("servers") and not inc_env.get("servers"): + inc_env["servers"] = disk_env["servers"] + logger.warning("cookbook state POST: incoming env.servers empty; preserved on-disk servers (anti-wipe guard)") + + disk_tasks = on_disk.get("tasks") or [] if isinstance(on_disk, dict) else [] + incoming_tasks = data.get("tasks") if isinstance(data.get("tasks"), list) else [] + incoming_ids = {t.get("sessionId") for t in incoming_tasks if isinstance(t, dict) and t.get("sessionId")} + import time as _t + now_ms = int(_t.time() * 1000) + preserved = [] + for t in disk_tasks: + if not isinstance(t, dict): + continue + sid = t.get("sessionId") + if not sid or sid in incoming_ids: + continue # client's version wins + ts = t.get("ts") or 0 + if isinstance(ts, (int, float)) and (now_ms - ts) <= RACE_WINDOW_MS: + preserved.append(t) + if preserved: + logger.info(f"cookbook state POST: preserving {len(preserved)} recent task(s) " + f"not in incoming body (race guard): " + f"{[t.get('sessionId') for t in preserved]}") + data["tasks"] = incoming_tasks + preserved + atomic_write_json(str(_cookbook_state_path), _state_for_storage(data, on_disk), indent=2) + return {"ok": True, "preserved": len(preserved)} + except Exception as e: + return {"ok": False, "error": str(e)} + + @router.get("/api/cookbook/hf-latest") + async def hf_latest(vram_gb: float = 0, limit: int = 10, pipeline: str = "text-generation", owner: str = Depends(require_user)): + """Fetch latest HuggingFace models, filtered by what fits in available VRAM. + + vram_gb: total available VRAM in GB. 0 = no filter (return everything). + limit: how many models to return (default 10). + pipeline: HF pipeline_tag filter (text-generation, text-to-image, etc.). + """ + import re + import httpx + + # Fetch a larger pool so we have enough to filter from (we drop ~80%) + pool_size = max(limit * 15, 100) + url = ( + "https://huggingface.co/api/models" + f"?sort=trendingScore&direction=-1&limit={pool_size}&filter={pipeline}" + ) + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(url) + if resp.status_code != 200: + return {"models": [], "error": f"HF API HTTP {resp.status_code}"} + raw = resp.json() + except Exception as e: + return {"models": [], "error": str(e)} + + # Estimate VRAM from the model id. Looks for patterns like "7B", "70B", "1.5B" etc. + # Returns approx VRAM in GB at fp16 (params*2). Caller adjusts for quant. + def _est_vram_fp16(repo_id: str) -> float | None: + m = re.search(r'[-_/](\d+(?:\.\d+)?)\s*[Bb](?![a-zA-Z])', repo_id) + if not m: + return None + params_b = float(m.group(1)) + return params_b * 2.0 # fp16 baseline + + # Detect quantization from repo_id / tags. Returns a multiplier on fp16 size. + def _quant_factor(repo_id: str, tags: list) -> float: + text = (repo_id + " " + " ".join(tags or [])).lower() + if "fp4" in text or "nf4" in text or "int4" in text or "4bit" in text or "q4" in text or "awq" in text or "gptq" in text: + return 0.25 + if "int8" in text or "8bit" in text or "q8" in text or "fp8" in text: + return 0.5 + if "bf16" in text or "fp16" in text: + return 1.0 + return 1.0 # default fp16 + + # Exclude adapters, LoRAs, datasets, GGUF-only repos, and other non-runnable artifacts + EXCLUDE_TAG_SUBSTRINGS = ( + "lora", "adapter", "peft", "qlora", + "dataset", "embeddings", + "merge", "control-lora", + "diffusion-lora", "stable-diffusion-lora", + "text-classification", "token-classification", + "feature-extraction", "sentence-similarity", + ) + EXCLUDE_NAME_SUBSTRINGS = ( + "lora", "adapter", "peft", "qlora", + "embedding", "embed-", + "dataset", + ) + + def _is_excluded(repo_id: str, tags: list) -> bool: + text = repo_id.lower() + for s in EXCLUDE_NAME_SUBSTRINGS: + if s in text: + return True + tag_text = " ".join(t.lower() for t in (tags or [])) + for s in EXCLUDE_TAG_SUBSTRINGS: + if s in tag_text: + return True + return False + + out = [] + for entry in raw: + repo_id = entry.get("modelId") or entry.get("id") or "" + if not repo_id: + continue + tags = entry.get("tags") or [] + pipeline_tag = entry.get("pipeline_tag") or "" + + # Hard filter: only the requested pipeline (HF's filter param is loose) + if pipeline and pipeline_tag and pipeline_tag != pipeline: + continue + # Skip adapters, LoRAs, datasets, etc. + if _is_excluded(repo_id, tags): + continue + + est_fp16 = _est_vram_fp16(repo_id) + quant_mult = _quant_factor(repo_id, tags) + est_vram = (est_fp16 * quant_mult) if est_fp16 else None + # Add 30% headroom for KV cache, activations, etc. + needed_vram = (est_vram * 1.3) if est_vram else None + + if vram_gb > 0 and needed_vram is not None and needed_vram > vram_gb: + continue + # Skip if no size info — without a size we can't tell if it's a real + # full-weight model or a tiny adapter, so we'd rather drop it + if est_vram is None: + continue + + out.append({ + "repo_id": repo_id, + "downloads": entry.get("downloads", 0), + "likes": entry.get("likes", 0), + "createdAt": entry.get("createdAt", ""), + "tags": tags[:5], # trim + "pipeline_tag": pipeline_tag, + "est_vram_gb": round(est_vram, 1) if est_vram else None, + "needed_vram_gb": round(needed_vram, 1) if needed_vram else None, + }) + if len(out) >= limit: + break + + return {"models": out} + + @router.get("/api/cookbook/tasks/status") + async def cookbook_tasks_status(request: Request): + """Check status of all active cookbook tmux sessions. + + Critical: every subprocess.run inside this handler is a sync blocking + call that — when this was a plain async def — froze the entire server + event loop. Now the whole body runs in a worker thread via + asyncio.to_thread so other requests stay responsive.""" + require_admin(request) + return await asyncio.to_thread(_cookbook_tasks_status_sync) + + def _cookbook_tasks_status_sync(): + import subprocess + + # Load saved tasks from cookbook state + tasks = [] + if _cookbook_state_path.exists(): + try: + state = json.loads(_cookbook_state_path.read_text()) + saved_tasks = state.get("tasks", []) + if isinstance(saved_tasks, list): + tasks = saved_tasks + elif isinstance(saved_tasks, dict): + tasks = list(saved_tasks.values()) + except Exception: + pass + + results = [] + for task in tasks: + session_id = task.get("sessionId", "") + if not session_id: + continue + remote = task.get("remoteHost", "") + task_type = task.get("type", "download") # "download" or "serve" + # Field name varies depending on whether the task was added + # via the download flow (`repoId`), the serve flow (`modelId`), + # or the UI-side serve preset (which uses `name` + `payload.repo_id`). + _payload = task.get("payload") or {} + model = ( + task.get("modelId") + or task.get("repoId") + or task.get("name") + or _payload.get("repo_id") + or _payload.get("modelId") + or "" + ) + task_platform = task.get("platform", "") + + # Check if session is alive + capture output + _tport = task.get("sshPort", "") + # Defense-in-depth: cookbook state is admin-writable but the values + # land in shell-interpolated commands below. Reject anything that + # isn't a benign session-id / hostname / port. + if not _SESSION_ID_RE.match(session_id): + logger.warning(f"Skipping task with unsafe session_id: {session_id!r}") + continue + if remote and not _REMOTE_HOST_RE.match(remote): + logger.warning(f"Skipping task with unsafe remoteHost: {remote!r}") + continue + if _tport and not _SSH_PORT_RE.match(str(_tport)): + logger.warning(f"Skipping task with unsafe sshPort: {_tport!r}") + continue + if task_platform == "windows" and remote: + # Windows: check PID file + Get-Process, read log tail + sd = "$env:TEMP\\odysseus-sessions" + ssh_base = ["ssh"] + if _tport and _tport != "22": + ssh_base.extend(["-p", str(_tport)]) + check_cmd = ssh_base + [ + remote, + "powershell", + "-Command", + f"$pid = Get-Content \"{sd}\\{session_id}.pid\" -ErrorAction SilentlyContinue; " + "if ($pid) {{ Get-Process -Id $pid -ErrorAction SilentlyContinue | Out-Null; if ($?) {{ exit 0 }} else {{ exit 1 }} }} else {{ exit 1 }}" + ] + capture_cmd = ssh_base + [ + remote, + "powershell", + "-Command", + f"Get-Content \"{sd}\\{session_id}.log\" -Tail 10 -ErrorAction SilentlyContinue", + ] + elif remote: + ssh_base = ["ssh"] + if _tport and _tport != "22": + ssh_base.extend(["-p", str(_tport)]) + check_cmd = ssh_base + [remote, "tmux", "has-session", "-t", session_id] + capture_cmd = ssh_base + [remote, "tmux", "capture-pane", "-t", session_id, "-p", "-S", "-50"] + else: + check_cmd = ["tmux", "has-session", "-t", session_id] + capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-50"] + + try: + alive = subprocess.run(check_cmd, timeout=10, capture_output=True) + is_alive = alive.returncode == 0 + except Exception: + is_alive = False + + # Capture last lines for progress. Prefer the "Downloading" line + # (real aggregate bytes) over "Fetching N files" (whole-file count that + # lags with hf_transfer). Falls back to the true last line otherwise. + progress_text = "" + full_snapshot = "" + if is_alive: + try: + cap = subprocess.run(capture_cmd, timeout=10, capture_output=True, text=True) + if cap.returncode == 0: + full_snapshot = cap.stdout.strip() + lines = [l.strip() for l in full_snapshot.split('\n') if l.strip()] + downloading_lines = [l for l in lines if l.startswith("Downloading")] + if downloading_lines: + progress_text = downloading_lines[-1] + elif lines: + progress_text = lines[-1] + except Exception: + pass + + # Determine status + status = "unknown" + if is_alive: + lower = full_snapshot.lower() + has_exit = "=== process exited with code" in lower + has_error = "error" in lower or "failed" in lower or "traceback" in lower + if has_exit and task_type == "serve": + # Serve tasks that exit are always errors — they should run indefinitely + status = "error" + elif has_exit and "unrecognized arguments" in lower: + status = "error" + elif has_error and not ("application startup complete" in lower): + status = "error" + elif task_type == "download" and ("100%" in full_snapshot or "DOWNLOAD_OK" in full_snapshot): + # Only download tasks treat 100% as "completed". + # Serve tasks log 100%|██████| during inference progress + # (diffusion sampling, etc.) — that's "running", not done. + status = "completed" + elif "application startup complete" in lower: + status = "ready" + else: + status = "running" + else: + # Session is dead — check if it completed or crashed + status = "stopped" + + # Parse structured phase info — single source of truth for the UI + phase_info = _parse_serve_phase(full_snapshot, task_type) if (task_type == "serve" and status == "running" and full_snapshot) else {} + if phase_info.get("status") == "ready": + status = "ready" + serve_phase = phase_info.get("phase", "") + diagnosis = _diagnose_serve_output(full_snapshot) if task_type == "serve" and full_snapshot else None + if diagnosis and status in {"running", "unknown", "stopped"}: + status = "error" + output_tail = "\n".join(full_snapshot.splitlines()[-12:]) if full_snapshot else "" + + results.append({ + "session_id": session_id, + "type": task_type, + "model": model.split("/")[-1] if "/" in model else model, + "status": status, + "progress": serve_phase if task_type == "serve" else progress_text[:120], + "phase": serve_phase, + "diagnosis": diagnosis, + "output_tail": output_tail, + "cmd": _payload.get("_cmd") or "", + "tps": phase_info.get("tps"), + "reqs": phase_info.get("reqs"), + "pct": phase_info.get("pct"), + "remote": remote or "local", + }) + + return {"tasks": results} + + return router diff --git a/routes/diagnostics_routes.py b/routes/diagnostics_routes.py new file mode 100644 index 0000000..8f3a915 --- /dev/null +++ b/routes/diagnostics_routes.py @@ -0,0 +1,71 @@ +"""Diagnostics routes — /api/db/stats, /api/rag/stats, /api/test/youtube, /api/test-research.""" + +import logging +from typing import Dict, Any + +from fastapi import APIRouter, HTTPException, Form + +from services.youtube.youtube_handler import extract_youtube_id, extract_transcript_async +from core.constants import DEFAULT_HOST + +logger = logging.getLogger(__name__) + + +def setup_diagnostics_routes( + rag_manager, + rag_available: bool, + research_handler, +) -> APIRouter: + router = APIRouter(tags=["diagnostics"]) + + @router.get("/api/db/stats") + async def get_database_stats() -> Dict[str, Any]: + try: + from core.database import get_detailed_stats + return get_detailed_stats() + except Exception as e: + logger.error(f"DB stats error: {e}") + raise HTTPException(500, "Failed to retrieve database statistics") + + @router.get("/api/rag/stats") + async def get_rag_stats() -> Dict[str, Any]: + if rag_available and rag_manager: + return rag_manager.get_stats() + return {"error": "RAG system not available"} + + @router.get("/api/test/youtube") + async def test_youtube(url: str) -> Dict[str, Any]: + try: + video_id = extract_youtube_id(url) + if not video_id: + return {"error": "Invalid YouTube URL"} + + data = await extract_transcript_async(url, video_id) + return { + "video_id": video_id, + "transcript_success": data.get("success", False), + "transcript_length": len(data.get("transcript", "")) if data.get("success") else 0, + "transcript_preview": (data.get("transcript", "")[:500] + "...") + if data.get("success") and len(data.get("transcript", "")) > 500 + else data.get("transcript", ""), + "error": data.get("error") if not data.get("success") else None, + } + except Exception as e: + return {"error": str(e)} + + @router.post("/api/test-research") + async def test_research(query: str = Form("What is machine learning?")) -> Dict[str, Any]: + try: + endpoint = f"http://{DEFAULT_HOST}:8000/v1/chat/completions" + model = "gpt-oss-120b" + result = await research_handler.call_research_service(query, endpoint, model) + return { + "status": "success", + "query": query, + "result_preview": result[:200] + "..." if len(result) > 200 else result, + "result_length": len(result), + } + except Exception as e: + return {"status": "error", "error": str(e), "query": query} + + return router diff --git a/routes/document_helpers.py b/routes/document_helpers.py new file mode 100644 index 0000000..b60ad94 --- /dev/null +++ b/routes/document_helpers.py @@ -0,0 +1,198 @@ +"""document_helpers.py — Pydantic models, doc serializers, owner gating, file-locator helpers shared with document_routes.py.""" + +"""Document routes — CRUD for living documents with version history.""" + +import logging +from typing import Dict, Any, Optional + +from fastapi import HTTPException +from pydantic import BaseModel + +from core.database import Document, DocumentVersion +from core.database import Session as DbSession + +logger = logging.getLogger(__name__) + + +# ---- Request schemas ---- + +class DocumentCreate(BaseModel): + session_id: Optional[str] = None + title: str = "Untitled" + language: Optional[str] = None + content: str = "" + +class DocumentUpdate(BaseModel): + content: str + summary: Optional[str] = None + +class DocumentPatch(BaseModel): + title: Optional[str] = None + language: Optional[str] = None + session_id: Optional[str] = None # link/unlink document to a session + + +# ---- Helpers ---- + +def _doc_to_dict(doc: Document) -> Dict[str, Any]: + return { + "id": doc.id, + "session_id": doc.session_id, + "title": doc.title, + "language": doc.language, + "current_content": doc.current_content, + "version_count": doc.version_count, + "is_active": doc.is_active, + "archived": bool(getattr(doc, "archived", False)), + "created_at": (doc.created_at.isoformat() + "Z") if doc.created_at else None, + "updated_at": (doc.updated_at.isoformat() + "Z") if doc.updated_at else None, + # Source-email provenance (set when doc was created from an email + # attachment) — drives the "Send signed reply" menu item. + "source_email_uid": getattr(doc, "source_email_uid", None), + "source_email_folder": getattr(doc, "source_email_folder", None), + "source_email_account_id": getattr(doc, "source_email_account_id", None), + "source_email_message_id": getattr(doc, "source_email_message_id", None), + } + +def _version_to_dict(v: DocumentVersion) -> Dict[str, Any]: + return { + "id": v.id, + "document_id": v.document_id, + "version_number": v.version_number, + "content": v.content, + "summary": v.summary, + "source": v.source, + "created_at": v.created_at.isoformat() if v.created_at else None, + } + + +def _verify_doc_owner(db, doc: Document, user: str): + """Verify `user` owns this document. Raise 404 if not. + + Documents now carry their own `owner` column, so a doc whose session + was deleted (session_id → NULL) can still prove ownership and stay + openable / cloneable. We trust that column first and only fall back to + the session join for any not-yet-backfilled legacy row. + """ + if user is None: + raise HTTPException(403, "Authentication required") + if doc.owner is not None: + if doc.owner != user: + raise HTTPException(404, "Document not found") + return + # Legacy fallback: derive ownership from the linked session. + if not doc.session_id: + raise HTTPException(404, "Document not found") + session = db.query(DbSession).filter(DbSession.id == doc.session_id).first() + if not session or session.owner != user: + raise HTTPException(404, "Document not found") + + +def _owner_session_filter(q, user): + """Restrict a documents query to those owned by `user`. + + Documents now carry their own `owner` column (backfilled at boot from + the linked session, or assigned to the admin user for legacy/orphaned + docs). We filter on that directly rather than on a session join, so a + document whose session was deleted (session_id → NULL) still shows up + for its owner instead of silently vanishing from the Library + search. + + The owner backfill runs in init_db before the app serves requests, so + by the time this filter is live there are no NULL-owner rows to leak; + we therefore match the owner strictly.""" + if user is None: + return q.filter(False) + return q.filter(Document.owner == user) + + + +def _slug(name: str) -> str: + """Filesystem-friendly version of a document title. + + Whitespace becomes underscores; other unsafe punctuation is dropped. + Preserves letters, digits, dot, hyphen, underscore. Idempotent. + """ + import re as _re + s = (name or "").strip() + # Drop the trailing extension if the title happens to include one + s = _re.sub(r'\.pdf$', '', s, flags=_re.IGNORECASE) + s = _re.sub(r'\s+', '_', s) + s = _re.sub(r'[^A-Za-z0-9._-]', '', s) + s = _re.sub(r'_+', '_', s).strip('_') + return s or "form" + + +# DPI scale for the interactive PDF view. ~150 DPI (2x of 72 PDF user-units). +_PDF_RENDER_SCALE = 2.0 + + +def _locate_upload(upload_dir: str, file_id: str): + """Find an upload by its filename ID. + + Lookup order: + 1. Direct hit at `upload_dir/file_id` (very small deployments). + 2. The `uploads.json` index that `UploadHandler.save_upload` maintains — + maps file_hash → metadata containing the full path. O(1) once loaded. + 3. Fallback: `os.walk` the date-bucketed tree. Slow on large stores; + only triggers for legacy uploads recorded before the index existed. + + `followlinks=False` keeps a stray symlink loop in `data/uploads/` from + spinning the walker into infinite recursion. + """ + import os + import json as _json + direct = os.path.join(upload_dir, file_id) + if os.path.exists(direct): + return direct + # O(1) via uploads.json + try: + idx_path = os.path.join(upload_dir, "uploads.json") + if os.path.exists(idx_path): + with open(idx_path, "r") as f: + idx = _json.load(f) + for meta in (idx.values() if isinstance(idx, dict) else []): + if meta.get("id") == file_id: + p = meta.get("path") + if p and os.path.exists(p): + return p + except Exception: + pass + for root, _dirs, files in os.walk(upload_dir, followlinks=False): + if file_id in files: + return os.path.join(root, file_id) + return None + + +def _derive_title(content: str) -> str: + """Derive a title from document content.""" + import re + text = content.strip() + if not text: + return "Untitled" + + # Markdown header + md = re.match(r'^#{1,3}\s+(.+)', text, re.MULTILINE) + if md: + title = md.group(1).strip() + if len(title) > 50: + title = title[:48] + "…" + return title + + # HTML heading + html = re.search(r']*>([^<]+)', text, re.IGNORECASE) + if html: + title = html.group(1).strip() + if len(title) > 50: + title = title[:48] + "…" + return title + + # First non-empty line (if short enough) + for line in text.split('\n'): + line = line.strip() + if line and 2 <= len(line) <= 60: + title = re.sub(r'[:#*`]+$', '', line).strip() + if title and len(title) > 50: + title = title[:48] + "…" + return title or "Untitled" + + return "Untitled" diff --git a/routes/document_routes.py b/routes/document_routes.py new file mode 100644 index 0000000..94b331d --- /dev/null +++ b/routes/document_routes.py @@ -0,0 +1,1643 @@ +"""Document routes — CRUD for living documents with version history.""" + +import uuid +import logging +from datetime import datetime, timezone +from typing import Dict, Any, List, Optional + +from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Form + +from sqlalchemy import func +from core.database import SessionLocal, Document, DocumentVersion +from core.database import Session as DbSession +from src.auth_helpers import get_current_user + +logger = logging.getLogger(__name__) + + + +from routes.document_helpers import ( + DocumentCreate, DocumentUpdate, DocumentPatch, + _doc_to_dict, _version_to_dict, + _verify_doc_owner, _owner_session_filter, + _slug, _locate_upload, _derive_title, + _PDF_RENDER_SCALE, +) + +def setup_document_routes(session_manager, upload_handler=None) -> APIRouter: + router = APIRouter(tags=["documents"]) + + # ---- POST /api/document ---- + @router.post("/api/document") + async def create_document(request: Request, req: DocumentCreate) -> Dict[str, Any]: + from src.auth_helpers import require_privilege + user = require_privilege(request, "can_use_documents") + db = SessionLocal() + try: + # session_id is optional: a doc can be a session-less "library" doc + # (e.g. files imported from the library) — session_id is nullable and + # the doc is owner-stamped, so it lives in the library on its own. + session = None + if req.session_id: + session = db.query(DbSession).filter(DbSession.id == req.session_id).first() + if not session: + raise HTTPException(404, "Session not found") + # Match the lenient ownership model the rest of the app uses + # (see _owner_filter): only block when an AUTHENTICATED user is + # writing into a DIFFERENT user's session. In single-user / + # unconfigured / localhost-bypass mode the middleware leaves + # current_user unset (None), and those sessions are already + # served freely everywhere else. + if user and session.owner and session.owner != user: + raise HTTPException(403, "Cannot create document in another user's session") + + doc_id = str(uuid.uuid4()) + ver_id = str(uuid.uuid4()) + + # If no language was supplied (e.g. cloning a doc whose language + # was never set), detect it from the content rather than storing + # NULL — which made the editor fall back to plain text. Defaults + # to markdown for prose. + language = req.language + if not language: + from src.tool_implementations import _looks_like_email_document, _sniff_doc_language + language = _sniff_doc_language(req.content) + else: + from src.tool_implementations import _looks_like_email_document + if _looks_like_email_document(req.content, req.title): + language = "email" + + doc = Document( + id=doc_id, + session_id=req.session_id, + title=req.title, + language=language, + current_content=req.content, + version_count=1, + is_active=True, + # Stamp ownership directly so the doc survives its session + # being deleted. Fall back to the session's owner when the + # request is unauthenticated (single-user / localhost bypass). + owner=user or (session.owner if session else None), + ) + ver = DocumentVersion( + id=ver_id, + document_id=doc_id, + version_number=1, + content=req.content, + summary="Initial version", + source="user", + ) + db.add(doc) + db.add(ver) + db.commit() + db.refresh(doc) + try: + from src.event_bus import fire_event + fire_event("document_created", doc.owner) + except Exception: + logger.debug("document_created event dispatch failed", exc_info=True) + return _doc_to_dict(doc) + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"Failed to create document: {e}") + raise HTTPException(500, f"Failed to create document: {e}") + finally: + db.close() + + # ---- POST /api/documents/import-pdf ---- + @router.post("/api/documents/import-pdf") + async def import_pdf( + request: Request, + file: UploadFile = File(...), + session_id: Optional[str] = Form(None), + ) -> Dict[str, Any]: + """Upload a PDF and create the matching Document. + + Detects AcroForm fields — if any, creates a form-backed markdown doc + (clickable inputs in the PDF view). Otherwise creates a plain PDF doc + with a `pdf_source` marker so the viewer renders the pages without + overlays. + """ + from src.constants import UPLOAD_DIR + from src.pdf_forms import has_form_fields, extract_fields + from src.pdf_form_doc import ( + save_field_sidecar, + create_form_markdown_document, + create_plain_pdf_document, + ) + from src.document_processor import _process_pdf + import os + + user = get_current_user(request) + + # session_id is optional — a library import isn't tied to a chat. When + # given, validate it; otherwise the PDF becomes a session-less library + # doc (the doc creators below already handle a missing session). + if session_id: + db = SessionLocal() + try: + sess = db.query(DbSession).filter(DbSession.id == session_id).first() + if not sess: + raise HTTPException(404, "Session not found") + if user and sess.owner and sess.owner != user: + raise HTTPException(403, "Cannot import into another user's session") + finally: + db.close() + + if upload_handler is None: + raise HTTPException(500, "Upload handler not configured") + + client_ip = request.client.host if request.client else "unknown" + try: + meta = upload_handler.save_upload(file, client_ip, owner=user) + except HTTPException: + raise + except Exception as e: + logger.error(f"PDF import save_upload failed: {e}") + raise HTTPException(500, f"Upload failed: {e}") + + upload_id = meta["id"] + pdf_path = _locate_upload(UPLOAD_DIR, upload_id) + if not pdf_path: + raise HTTPException(500, "Saved PDF could not be located") + + title = os.path.splitext(meta.get("original_name") or meta.get("name") or upload_id)[0] + try: + body_text = _process_pdf(pdf_path).lstrip("\n[PDF content]:").strip() + except Exception: + body_text = None + + is_form = False + try: + is_form = has_form_fields(pdf_path) + except Exception as e: + logger.warning(f"has_form_fields failed for {pdf_path}: {e}") + + if is_form: + fields = extract_fields(pdf_path) + save_field_sidecar(pdf_path, fields) + doc_id = create_form_markdown_document( + session_id=session_id, + fields=fields, + upload_id=upload_id, + title=title, + intro_text=body_text, + ) + else: + doc_id = create_plain_pdf_document( + session_id=session_id, + upload_id=upload_id, + title=title, + body_text=body_text, + ) + + if not doc_id: + raise HTTPException(500, "Failed to create document for PDF") + + db = SessionLocal() + try: + doc = db.query(Document).filter(Document.id == doc_id).first() + if not doc: + raise HTTPException(500, "Created document not found") + # The PDF doc creators stamp owner from the session only; a + # session-less library import leaves owner NULL, which the Library's + # owner filter then hides. Stamp the requesting user so it shows. + if not doc.owner and user: + doc.owner = user + db.commit() + db.refresh(doc) + return _doc_to_dict(doc) + finally: + db.close() + + # ---- GET /api/documents/library ---- + @router.get("/api/documents/library") + async def documents_library( + request: Request, + search: Optional[str] = Query(None), + language: Optional[str] = Query(None), + sort: str = Query("recent"), + offset: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=50), + archived: bool = Query(False), + ) -> Dict[str, Any]: + user = get_current_user(request) + db = SessionLocal() + try: + from sqlalchemy import or_ + # Archived view shows ONLY archived docs; the default view excludes + # them (NULL = legacy rows that predate the column = not archived). + _arch_cond = (Document.archived == True) if archived else or_( + Document.archived == False, Document.archived.is_(None)) + # Language facet counts (owner-filtered) + lang_q = ( + db.query(Document.language, func.count(Document.id)) + .outerjoin(DbSession, Document.session_id == DbSession.id) + .filter(Document.is_active == True).filter(_arch_cond) + ) + lang_q = _owner_session_filter(lang_q, user) + lang_rows = lang_q.group_by(Document.language).all() + languages = {lang or "text": cnt for lang, cnt in lang_rows} + + # Session count (owner-filtered) + sc_q = ( + db.query(func.count(func.distinct(Document.session_id))) + .outerjoin(DbSession, Document.session_id == DbSession.id) + .filter(Document.is_active == True).filter(_arch_cond) + ) + sc_q = _owner_session_filter(sc_q, user) + session_count = sc_q.scalar() + + # Base query + q = ( + db.query(Document, DbSession.name) + .outerjoin(DbSession, Document.session_id == DbSession.id) + .filter(Document.is_active == True).filter(_arch_cond) + ) + q = _owner_session_filter(q, user) + + # Search filter — split on whitespace and require EACH term to + # match (title OR content). A single `%foo bar%` LIKE only matched + # the exact adjacent phrase, so any multi-word query with a space + # silently returned nothing. Per-term AND makes "machine learning" + # match docs containing both words regardless of position/order. + if search: + for tok in search.split(): + term = f"%{tok}%" + q = q.filter( + Document.title.ilike(term) | Document.current_content.ilike(term) + ) + + # Language filter + if language: + if language == "text": + q = q.filter((Document.language == None) | (Document.language == "text")) + else: + q = q.filter(Document.language == language) + + # Total before pagination + total = q.count() + + # Sorting + if sort == "oldest": + q = q.order_by(Document.created_at.asc()) + elif sort == "edits": + q = q.order_by(Document.version_count.desc()) + elif sort == "alpha": + q = q.order_by(Document.title.asc()) + else: # recent + q = q.order_by(Document.updated_at.desc()) + + rows = q.offset(offset).limit(limit).all() + + documents = [] + for doc, session_name in rows: + documents.append({ + "id": doc.id, + "session_id": doc.session_id, + "session_name": session_name, + "title": doc.title, + "language": doc.language or "text", + "preview": (doc.current_content or "")[:500], + "version_count": doc.version_count, + "created_at": (doc.created_at.isoformat() + "Z") if doc.created_at else None, + "updated_at": (doc.updated_at.isoformat() + "Z") if doc.updated_at else None, + }) + + return { + "documents": documents, + "total": total, + "languages": languages, + "session_count": session_count, + } + except Exception as e: + logger.error(f"Failed to fetch document library: {e}") + raise HTTPException(500, f"Failed to fetch document library: {e}") + finally: + db.close() + + # ---- GET /api/documents/{session_id} ---- + @router.get("/api/documents/{session_id}") + async def list_documents(request: Request, session_id: str) -> List[Dict[str, Any]]: + user = get_current_user(request) + db = SessionLocal() + try: + if not user: + raise HTTPException(403, "Authentication required") + session = db.query(DbSession).filter(DbSession.id == session_id).first() + # v2 review HIGH-9: raise 403 explicitly when the caller + # can't see this session, instead of returning [] which the + # UI treats identically to "no docs" and silently masks + # auth failures. + if not session: + raise HTTPException(404, "Session not found") + if user and session.owner and session.owner != user: + raise HTTPException(403, "Access denied") + docs = db.query(Document).filter( + Document.session_id == session_id + ).order_by(Document.created_at.desc()).all() + return [_doc_to_dict(d) for d in docs] + finally: + db.close() + + # ---- GET /api/document/{doc_id} ---- + @router.get("/api/document/{doc_id}") + async def get_document(request: Request, doc_id: str) -> Dict[str, Any]: + user = get_current_user(request) + db = SessionLocal() + try: + doc = db.query(Document).filter(Document.id == doc_id).first() + if not doc: + raise HTTPException(404, "Document not found") + _verify_doc_owner(db, doc, user) + return _doc_to_dict(doc) + finally: + db.close() + + # ---- POST /api/document/{doc_id}/archive — soft-archive / restore ---- + @router.post("/api/document/{doc_id}/archive") + async def archive_document(request: Request, doc_id: str, archived: bool = Query(True)) -> Dict[str, Any]: + user = get_current_user(request) + db = SessionLocal() + try: + doc = db.query(Document).filter(Document.id == doc_id).first() + if not doc: + raise HTTPException(404, "Document not found") + _verify_doc_owner(db, doc, user) + doc.archived = bool(archived) + db.commit() + return {"ok": True, "id": doc_id, "archived": doc.archived} + finally: + db.close() + + # ---- POST /api/document/{doc_id}/extract-pdf-text ---- + @router.post("/api/document/{doc_id}/extract-pdf-text") + async def extract_pdf_text(request: Request, doc_id: str) -> Dict[str, Any]: + """Re-run pypdf+VL text extraction against the PDF linked to this doc + and merge the result into the doc's markdown content. Idempotent — the + existing body (everything below the title heading) is replaced. + + Lets the AI see PDF contents for old docs that were imported before + text extraction was wired, plus for scanned/image-only PDFs where the + VL model picks up text the basic pypdf path missed.""" + import re + from src.constants import UPLOAD_DIR + from src.document_processor import _process_pdf + + user = get_current_user(request) + db = SessionLocal() + try: + doc = db.query(Document).filter(Document.id == doc_id).first() + if not doc: + raise HTTPException(404, "Document not found") + _verify_doc_owner(db, doc, user) + + content = doc.current_content or "" + m = re.search(r'\s*\n+#[^\n]*\n+)', re.MULTILINE) + head_match = head_re.match(content) + head = head_match.group(1) if head_match else (content.splitlines()[0] + "\n\n# " + (doc.title or "PDF") + "\n\n") + doc.current_content = head + body_text.strip() + "\n" + doc.version_count = (doc.version_count or 1) + 1 + db.add(DocumentVersion( + id=str(__import__("uuid").uuid4()), + document_id=doc_id, + version_number=doc.version_count, + content=doc.current_content, + summary="PDF text re-extracted (OCR)", + source="ocr", + )) + db.commit() + return {"ok": True, "id": doc_id, "extracted": True, "chars": len(body_text)} + finally: + db.close() + + # ---- POST /api/documents/export-zip — bundle selected docs into a .zip ---- + @router.post("/api/documents/export-zip") + async def documents_export_zip(request: Request): + """Zip the selected documents (each as a text file with the right + extension) — mirrors the gallery's bulk download-zip so multi-export + is one file instead of a blocked flood of individual downloads.""" + user = get_current_user(request) + try: + data = await request.json() + except Exception: + data = {} + ids = data.get("ids") or [] + if not ids: + raise HTTPException(400, "No documents specified") + _ext = { + "javascript": ".js", "python": ".py", "html": ".html", "css": ".css", + "markdown": ".md", "json": ".json", "yaml": ".yml", "bash": ".sh", + "sql": ".sql", "rust": ".rs", "go": ".go", "java": ".java", "c": ".c", + "cpp": ".cpp", "typescript": ".ts", "ruby": ".rb", "php": ".php", + "text": ".txt", "xml": ".xml", "toml": ".toml", "ini": ".ini", + } + db = SessionLocal() + try: + import io + import re + import zipfile + from fastapi import Response + docs = db.query(Document).filter(Document.id.in_(ids)).all() + buf = io.BytesIO() + used = set() + wrote = 0 + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for doc in docs: + try: + _verify_doc_owner(db, doc, user) + except HTTPException: + continue # skip docs the user doesn't own + ext = _ext.get(doc.language or "text", ".txt") + base = (doc.title or "document").strip() or "document" + base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or doc.id + name = base if "." in base else base + ext + i = 1 + while name in used: + name = f"{base}-{i}" + ("" if "." in base else ext) + i += 1 + used.add(name) + zf.writestr(name, doc.current_content or "") + wrote += 1 + if not wrote: + raise HTTPException(404, "No documents found") + return Response( + content=buf.getvalue(), + media_type="application/zip", + headers={"Content-Disposition": 'attachment; filename="documents.zip"'}, + ) + finally: + db.close() + + # ---- PUT /api/document/{doc_id} — user manual edit ---- + # Coalesce window: if the last user version was saved within this many + # seconds, update it in-place (user is still actively editing). + # Once the gap exceeds this, the next save creates a new version. + VERSION_COALESCE_SECONDS = 60 + + @router.put("/api/document/{doc_id}") + async def update_document(request: Request, doc_id: str, req: DocumentUpdate) -> Dict[str, Any]: + user = get_current_user(request) + db = SessionLocal() + try: + doc = db.query(Document).filter(Document.id == doc_id).first() + if not doc: + raise HTTPException(404, "Document not found") + _verify_doc_owner(db, doc, user) + + # Skip if content is identical + if doc.current_content == req.content: + return _doc_to_dict(doc) + + # Check if we can coalesce with the latest version + latest_ver = db.query(DocumentVersion).filter( + DocumentVersion.document_id == doc_id, + ).order_by(DocumentVersion.version_number.desc()).first() + + now = datetime.now(timezone.utc) + coalesced = False + if latest_ver and latest_ver.source == "user": + ver_time = latest_ver.created_at + if ver_time.tzinfo is None: + ver_time = ver_time.replace(tzinfo=timezone.utc) + age = (now - ver_time).total_seconds() + if age < VERSION_COALESCE_SECONDS: + # Update the existing version in-place + latest_ver.content = req.content + latest_ver.created_at = now + if req.summary: + latest_ver.summary = req.summary + coalesced = True + + if not coalesced: + new_ver = doc.version_count + 1 + ver = DocumentVersion( + id=str(uuid.uuid4()), + document_id=doc_id, + version_number=new_ver, + content=req.content, + summary=req.summary or "Manual edit", + source="user", + ) + doc.version_count = new_ver + db.add(ver) + + doc.current_content = req.content + db.commit() + db.refresh(doc) + return _doc_to_dict(doc) + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(500, f"Failed to update document: {e}") + finally: + db.close() + + # ---- PATCH /api/document/{doc_id} — metadata only ---- + @router.patch("/api/document/{doc_id}") + async def patch_document(request: Request, doc_id: str, req: DocumentPatch) -> Dict[str, Any]: + user = get_current_user(request) + db = SessionLocal() + try: + doc = db.query(Document).filter(Document.id == doc_id).first() + if not doc: + raise HTTPException(404, "Document not found") + _verify_doc_owner(db, doc, user) + if req.title is not None: + doc.title = req.title + if req.language is not None: + doc.language = req.language + if req.session_id is not None: + # Empty string = unlink from session + doc.session_id = req.session_id if req.session_id else None + db.commit() + db.refresh(doc) + return _doc_to_dict(doc) + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + # ---- DELETE /api/document/{doc_id} — soft delete ---- + @router.delete("/api/document/{doc_id}") + async def delete_document(request: Request, doc_id: str) -> Dict[str, str]: + user = get_current_user(request) + db = SessionLocal() + try: + doc = db.query(Document).filter(Document.id == doc_id).first() + if not doc: + raise HTTPException(404, "Document not found") + _verify_doc_owner(db, doc, user) + doc.is_active = False + db.commit() + return {"status": "deleted", "id": doc_id} + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + # ---- GET /api/document/{doc_id}/versions ---- + @router.get("/api/document/{doc_id}/versions") + async def list_versions(request: Request, doc_id: str) -> List[Dict[str, Any]]: + user = get_current_user(request) + db = SessionLocal() + try: + # Verify ownership before listing versions + doc = db.query(Document).filter(Document.id == doc_id).first() + if doc: + _verify_doc_owner(db, doc, user) + versions = db.query(DocumentVersion).filter( + DocumentVersion.document_id == doc_id + ).order_by(DocumentVersion.version_number.desc()).all() + return [{ + "id": v.id, + "version_number": v.version_number, + "content": v.content, + "summary": v.summary, + "source": v.source, + "created_at": v.created_at.isoformat() if v.created_at else None, + } for v in versions] + finally: + db.close() + + # ---- GET /api/document/{doc_id}/version/{num} ---- + @router.get("/api/document/{doc_id}/version/{num}") + async def get_version(request: Request, doc_id: str, num: int) -> Dict[str, Any]: + user = get_current_user(request) + db = SessionLocal() + try: + # Verify ownership + doc = db.query(Document).filter(Document.id == doc_id).first() + if doc: + _verify_doc_owner(db, doc, user) + ver = db.query(DocumentVersion).filter( + DocumentVersion.document_id == doc_id, + DocumentVersion.version_number == num, + ).first() + if not ver: + raise HTTPException(404, "Version not found") + return _version_to_dict(ver) + finally: + db.close() + + # ---- POST /api/document/{doc_id}/restore/{num} ---- + @router.post("/api/document/{doc_id}/restore/{num}") + async def restore_version(request: Request, doc_id: str, num: int) -> Dict[str, Any]: + user = get_current_user(request) + db = SessionLocal() + try: + doc = db.query(Document).filter(Document.id == doc_id).first() + if not doc: + raise HTTPException(404, "Document not found") + _verify_doc_owner(db, doc, user) + + old_ver = db.query(DocumentVersion).filter( + DocumentVersion.document_id == doc_id, + DocumentVersion.version_number == num, + ).first() + if not old_ver: + raise HTTPException(404, "Version not found") + + new_ver_num = doc.version_count + 1 + ver = DocumentVersion( + id=str(uuid.uuid4()), + document_id=doc_id, + version_number=new_ver_num, + content=old_ver.content, + summary=f"Restored from v{num}", + source="user", + ) + doc.current_content = old_ver.content + doc.version_count = new_ver_num + db.add(ver) + db.commit() + db.refresh(doc) + return _doc_to_dict(doc) + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + # ---- POST /api/documents/tidy — clean up broken/empty documents ---- + @router.post("/api/documents/tidy") + async def tidy_documents(request: Request) -> Dict[str, Any]: + """Fix empty titles and remove broken/empty documents (user's docs only).""" + user = get_current_user(request) + db = SessionLocal() + try: + q = ( + db.query(Document) + .outerjoin(DbSession, Document.session_id == DbSession.id) + .filter(Document.is_active == True) + .filter((Document.archived == False) | (Document.archived.is_(None))) + ) + q = _owner_session_filter(q, user) + docs = q.all() + fixed_titles = 0 + deleted = 0 + + # Same junk-detection logic as the scheduled tidy_documents + # action (src/document_actions.py). Keep these two in sync. + import re as _re + from src.document_actions import _JUNK_TITLES + + to_delete = [] + for doc in docs: + content = (doc.current_content or "").strip() + title_raw = (doc.title or "").strip() + title = title_raw.lower() + + # Strip markdown noise to get a "real" character count + stripped = _re.sub(r"^#{1,6}\s+", "", content, flags=_re.MULTILINE) + stripped = _re.sub(r"[*_`>\-=]+", "", stripped) + stripped = _re.sub(r"\s+", " ", stripped).strip() + real_len = len(stripped) + + # Detect email-scaffold stubs: "To: \nSubject: \n---\n" style + # bodies with nothing typed in. Stub = every meaningful line + # is a header label (To:/From:/Subject:/...) with no real + # value (blank, "empty", "(empty)", "-", "none", "n/a"). + _is_email_stub = False + _HEADER_RE = _re.compile(r"^(to|from|cc|bcc|subject|reply-to):\s*(.*)$", _re.I) + _PLACEHOLDER_VALS = {"", "empty", "(empty)", "-", "—", "none", "n/a", "na", "tbd"} + if title in ("new email", "new mail", "new message") or doc.language == "email": + body_lines = [ln.strip() for ln in content.split("\n") + if ln.strip() and ln.strip() != "---"] + def _is_filler(ln): + m = _HEADER_RE.match(ln) + if not m: + return False + val = (m.group(2) or "").strip().lower() + return val in _PLACEHOLDER_VALS + has_real_body = any(not _is_filler(ln) for ln in body_lines) + if body_lines and not has_real_body: + _is_email_stub = True + + # Hard-delete obviously empty / junk documents + if not content or content in ("", "# Untitled"): + to_delete.append(doc); deleted += 1; continue + if _is_email_stub: + to_delete.append(doc); deleted += 1; continue + if title in _JUNK_TITLES: + to_delete.append(doc); deleted += 1; continue + if real_len < 30: + to_delete.append(doc); deleted += 1; continue + if "\n" not in content and real_len < 50: + to_delete.append(doc); deleted += 1; continue + + # Fix empty or placeholder titles on survivors + if not title_raw or title_raw == "Untitled": + new_title = _derive_title(content) + if new_title and new_title != "Untitled": + doc.title = new_title + fixed_titles += 1 + + for doc in to_delete: + db.delete(doc) + + # Also clean up inactive empty docs from previous soft-deletes + inactive_q = ( + db.query(Document) + .outerjoin(DbSession, Document.session_id == DbSession.id) + .filter(Document.is_active == False) + .filter((Document.current_content == None) | (Document.current_content == "")) + ) + inactive_q = _owner_session_filter(inactive_q, user) + inactive_docs = inactive_q.all() + for doc in inactive_docs: + db.delete(doc) + deleted += len(inactive_docs) + + db.commit() + return { + "fixed_titles": fixed_titles, + "deleted": deleted, + "message": f"Fixed {fixed_titles} title{'s' if fixed_titles != 1 else ''}, removed {deleted} empty document{'s' if deleted != 1 else ''}", + } + except Exception as e: + db.rollback() + logger.error(f"Document tidy failed: {e}") + raise HTTPException(500, f"Tidy failed: {e}") + finally: + db.close() + + # ---- POST /api/documents/ai-tidy — AI-powered cleanup of junk/test documents ---- + @router.post("/api/documents/ai-tidy") + async def ai_tidy_documents(request: Request) -> Dict[str, Any]: + """Use AI to judge if documents are junk/test/accidental, then delete them. + Caches verdicts so previously-reviewed docs are skipped.""" + from src.task_endpoint import resolve_task_endpoint + from src.endpoint_resolver import resolve_endpoint + from src.llm_core import llm_call_async + + user = get_current_user(request) + url, model, headers = resolve_task_endpoint() + if not url or not model: + # Fall back to default endpoint + url, model, headers = resolve_endpoint("default") + if not url or not model: + raise HTTPException(500, "No endpoint configured for AI tidy") + + db = SessionLocal() + try: + q = ( + db.query(Document) + .outerjoin(DbSession, Document.session_id == DbSession.id) + .filter(Document.is_active == True) + .filter((Document.archived == False) | (Document.archived.is_(None))) + ) + q = _owner_session_filter(q, user) + docs = q.all() + + # Only review docs that haven't been reviewed yet + to_review = [d for d in docs if not d.tidy_verdict] + if not to_review: + return {"deleted": 0, "reviewed": 0, "message": "All documents already reviewed"} + + # Build a batch prompt — review up to 30 at a time + batch = to_review[:30] + doc_list = [] + for i, doc in enumerate(batch): + preview = (doc.current_content or "")[:300].strip() + doc_list.append(f"[{i}] title=\"{doc.title}\" lang={doc.language or 'text'} content_preview=\"{preview}\"") + + prompt = ( + "You are a document library cleaner. For each document below, decide if it is JUNK " + "(test, accidental, placeholder, empty-ish, tool-test, throwaway) or KEEP (real content worth saving).\n\n" + "Respond with ONLY a JSON array of verdicts, one per document, like: [\"junk\",\"keep\",\"junk\",...]\n" + "No explanation, no markdown, just the JSON array.\n\n" + + "\n".join(doc_list) + ) + + response = await llm_call_async( + url, model, + [{"role": "system", "content": "You classify documents as junk or keep. Respond only with a JSON array."}, + {"role": "user", "content": prompt}], + temperature=0.1, + max_tokens=200, + headers=headers, + timeout=30, + ) + + # Parse verdicts + import re + match = re.search(r'\[.*?\]', response, re.DOTALL) + if not match: + raise HTTPException(500, "AI returned invalid response") + + import json as _json + verdicts = _json.loads(match.group()) + + deleted = 0 + reviewed = 0 + for i, doc in enumerate(batch): + if i >= len(verdicts): + break + verdict = verdicts[i].lower().strip() + if verdict == "junk": + doc.tidy_verdict = "junk" + db.delete(doc) + deleted += 1 + else: + doc.tidy_verdict = "keep" + reviewed += 1 + + db.commit() + return { + "deleted": deleted, + "reviewed": reviewed, + "remaining": len(to_review) - len(batch), + "message": f"Reviewed {reviewed}, removed {deleted} junk document{'s' if deleted != 1 else ''}", + } + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"AI tidy failed: {e}") + raise HTTPException(500, f"AI tidy failed: {e}") + finally: + db.close() + + # ---- POST /api/document/{doc_id}/export-pdf/preview ---- + @router.post("/api/document/{doc_id}/export-pdf/preview") + async def export_pdf_preview(doc_id: str, request: Request) -> Dict[str, Any]: + """Return the field-value mapping that would be written to the PDF. + + Frontend shows this in a confirmation modal so the user can spot/fix + any wrong values before triggering the actual download. + """ + from src.pdf_form_doc import find_source_upload_id, parse_markdown_to_values, load_field_sidecar + from src.constants import UPLOAD_DIR + + user = get_current_user(request) + db = SessionLocal() + try: + doc = db.query(Document).filter(Document.id == doc_id).first() + if not doc: + raise HTTPException(404, "Document not found") + _verify_doc_owner(db, doc, user) + + upload_id = find_source_upload_id(doc.current_content or "") + if not upload_id: + raise HTTPException(400, "Document is not linked to a source PDF") + + pdf_path = _locate_upload(UPLOAD_DIR, upload_id) + if not pdf_path: + raise HTTPException(404, f"Source PDF {upload_id} not found in uploads") + + fields = load_field_sidecar(pdf_path) + if not fields: + raise HTTPException(404, "Field schema sidecar missing for source PDF") + + values = parse_markdown_to_values(doc.current_content or "") + field_meta = {f["name"]: f for f in fields} + + preview = [] + for name, current in values.items(): + meta = field_meta.get(name) + if not meta: + continue + preview.append({ + "name": name, + "label": meta.get("label") or name, + "type": meta.get("type"), + "options": meta.get("options") or [], + "page": meta.get("page"), + "value": current, + }) + + unknown = [ + name for name in values + if name not in field_meta + ] + return { + "doc_id": doc_id, + "upload_id": upload_id, + "fields": preview, + "unknown_fields": unknown, + "total": len(fields), + "filled": sum(1 for p in preview if p["value"] not in ("", False, None)), + } + finally: + db.close() + + # ---- GET /api/document/{doc_id}/render-pages ---- + @router.get("/api/document/{doc_id}/render-pages") + async def render_pages(doc_id: str, request: Request) -> Dict[str, Any]: + """Return per-page metadata for the interactive PDF view. + + Each page entry has its rendered-image dimensions (matching what + /page/{n}.png returns at the same DPI) plus the list of form fields + on that page with their rects translated to image-pixel coordinates. + Frontend overlays HTML form controls at those positions. + """ + from src.pdf_form_doc import find_source_upload_id, parse_markdown_to_values, load_field_sidecar + from src.constants import UPLOAD_DIR + import fitz + + user = get_current_user(request) + db = SessionLocal() + try: + doc = db.query(Document).filter(Document.id == doc_id).first() + if not doc: + raise HTTPException(404, "Document not found") + _verify_doc_owner(db, doc, user) + upload_id = find_source_upload_id(doc.current_content or "") + if not upload_id: + raise HTTPException(400, "Document is not linked to a source PDF") + pdf_path = _locate_upload(UPLOAD_DIR, upload_id) + if not pdf_path: + raise HTTPException(404, f"Source PDF {upload_id} not found") + + schema = load_field_sidecar(pdf_path) or [] + values = parse_markdown_to_values(doc.current_content or "") + + # Group fields by page + by_page: Dict[int, list] = {} + for f in schema: + by_page.setdefault(f["page"], []).append(f) + + scale = _PDF_RENDER_SCALE + pdf_doc = fitz.open(pdf_path) + try: + pages_out = [] + for page_index in range(pdf_doc.page_count): + page = pdf_doc[page_index] + page_no = page_index + 1 + pw, ph = page.rect.width, page.rect.height + img_w = int(pw * scale) + img_h = int(ph * scale) + fields_out = [] + for f in by_page.get(page_no, []): + x0, y0, x1, y1 = f["rect"] + fields_out.append({ + "name": f["name"], + "type": f["type"], + "label": f.get("label") or "", + "options": f.get("options") or [], + "value": values.get(f["name"], f.get("value", "")), + "rect_px": [ + int(x0 * scale), int(y0 * scale), + int(x1 * scale), int(y1 * scale), + ], + }) + pages_out.append({ + "page": page_no, + "width": img_w, + "height": img_h, + "fields": fields_out, + }) + return {"doc_id": doc_id, "scale": scale, "pages": pages_out} + finally: + pdf_doc.close() + finally: + db.close() + + # ---- GET /api/document/{doc_id}/page/{n}.png ---- + @router.get("/api/document/{doc_id}/page/{page_no}.png") + async def render_page_png(doc_id: str, page_no: int, request: Request): + """Render one page of the source PDF as a PNG (no values stamped — the + frontend overlays HTML form inputs on top).""" + from fastapi.responses import Response + from src.pdf_form_doc import find_source_upload_id + from src.constants import UPLOAD_DIR + import fitz + + user = get_current_user(request) + db = SessionLocal() + try: + doc = db.query(Document).filter(Document.id == doc_id).first() + if not doc: + raise HTTPException(404, "Document not found") + _verify_doc_owner(db, doc, user) + upload_id = find_source_upload_id(doc.current_content or "") + if not upload_id: + raise HTTPException(400, "Document is not linked to a source PDF") + pdf_path = _locate_upload(UPLOAD_DIR, upload_id) + if not pdf_path: + raise HTTPException(404, "Source PDF not found") + finally: + db.close() + + pdf_doc = fitz.open(pdf_path) + try: + if page_no < 1 or page_no > pdf_doc.page_count: + raise HTTPException(404, "Page out of range") + page = pdf_doc[page_no - 1] + mat = fitz.Matrix(_PDF_RENDER_SCALE, _PDF_RENDER_SCALE) + pix = page.get_pixmap(matrix=mat, alpha=False) + png_bytes = pix.tobytes("png") + return Response( + content=png_bytes, + media_type="image/png", + headers={"Cache-Control": "public, max-age=3600"}, + ) + finally: + pdf_doc.close() + + # ---- POST /api/document/{doc_id}/ai-fill-annotations ---- + @router.post("/api/document/{doc_id}/ai-fill-annotations") + async def ai_fill_annotations(doc_id: str, request: Request) -> Dict[str, Any]: + """Ask a vision-capable LLM to locate fillable areas on a flat PDF and + propose annotation values for each, given a free-form user instruction. + + Returns a list of annotations: [{page, x, y, w, h, value}] where x/y/w/h + are page-percentages (0–100) — same coordinate system as the freeform + annotations the frontend already renders. + """ + import base64 + import json + import fitz + from src.pdf_form_doc import find_source_upload_id + from src.constants import UPLOAD_DIR + from src.document_processor import _resolve_vl_model, _load_vl_settings + from src.llm_core import llm_call_async + + body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {} + instruction = (body or {}).get("instruction", "").strip() + if not instruction: + raise HTTPException(400, "instruction is required") + + user = get_current_user(request) + db = SessionLocal() + try: + doc = db.query(Document).filter(Document.id == doc_id).first() + if not doc: + raise HTTPException(404, "Document not found") + _verify_doc_owner(db, doc, user) + upload_id = find_source_upload_id(doc.current_content or "") + if not upload_id: + raise HTTPException(400, "Document is not linked to a source PDF") + pdf_path = _locate_upload(UPLOAD_DIR, upload_id) + if not pdf_path: + raise HTTPException(404, "Source PDF not found") + finally: + db.close() + + # Resolve VL model (admin-configured or auto-detected vision-capable) + settings = _load_vl_settings() + vl_model = settings.get("vision_model", "") + try: + url, model_id, headers = _resolve_vl_model(vl_model) + except Exception as e: + raise HTTPException(503, f"No vision model available: {e}") + + system_prompt = ( + "You analyze rendered PDF page images and propose values to fill in. " + "For each blank line, box, underscore, or labeled space on the page that " + "should be filled given the user's instruction, output one annotation. " + "Coordinates are percentages (0-100) of the page width/height with the " + "origin at top-left. Width/height should match the visible blank box. " + "Return ONLY a JSON array, no prose, no markdown fences. Each entry: " + '{"x": number, "y": number, "w": number, "h": number, "value": string}. ' + "If a region should not be filled, omit it. If nothing should be filled, " + "return []." + ) + + all_annotations = [] + pdf_doc = fitz.open(pdf_path) + try: + for page_index in range(pdf_doc.page_count): + page = pdf_doc[page_index] + mat = fitz.Matrix(_PDF_RENDER_SCALE, _PDF_RENDER_SCALE) + pix = page.get_pixmap(matrix=mat, alpha=False) + png_bytes = pix.tobytes("png") + b64 = base64.b64encode(png_bytes).decode("ascii") + + messages = [ + {"role": "system", "content": system_prompt}, + { + "role": "user", + "content": [ + { + "type": "text", + "text": ( + f"User instruction:\n{instruction}\n\n" + f"This is page {page_index + 1} of {pdf_doc.page_count}. " + "Return JSON array of annotations to add to this page." + ), + }, + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{b64}"}, + }, + ], + }, + ] + try: + raw = await llm_call_async( + url, model_id, messages, + temperature=0.1, max_tokens=2000, headers=headers, + ) + except Exception as e: + logger.error(f"VL call failed on page {page_index + 1}: {e}") + continue + + raw = (raw or "").strip() + if raw.startswith("```"): + raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip() + try: + parsed = json.loads(raw) + except Exception: + logger.warning(f"AI fill: page {page_index + 1} returned non-JSON: {raw[:200]}") + continue + if not isinstance(parsed, list): + continue + for item in parsed: + if not isinstance(item, dict): + continue + try: + x = float(item.get("x", 0)) + y = float(item.get("y", 0)) + w = float(item.get("w", 0)) + h = float(item.get("h", 0)) + value = str(item.get("value", "") or "") + except Exception: + continue + # Clamp + reject zero-size entries + if w <= 0.5 or h <= 0.3: + continue + x = max(0.0, min(99.0, x)) + y = max(0.0, min(99.0, y)) + w = max(0.5, min(100.0 - x, w)) + h = max(0.3, min(100.0 - y, h)) + if not value.strip(): + continue + all_annotations.append({ + "page": page_index + 1, + "x": round(x, 2), + "y": round(y, 2), + "w": round(w, 2), + "h": round(h, 2), + "value": value, + }) + finally: + pdf_doc.close() + + return {"annotations": all_annotations} + + # ---- GET /api/document/{doc_id}/render-pdf ---- + @router.get("/api/document/{doc_id}/render-pdf") + async def render_pdf(doc_id: str, request: Request): + """Inline PDF preview filled with the current markdown values. + + Same plumbing as the export route, but no signature stamping and + served inline (Content-Disposition: inline) so the browser can + embed it in an iframe. Cache-busted by the caller via query string. + """ + import base64 + import os + import tempfile + from fastapi.responses import FileResponse + from starlette.background import BackgroundTask + from src.pdf_form_doc import find_source_upload_id, parse_markdown_to_values, parse_markdown_annotations + from src.pdf_forms import fill_fields, stamp_annotations + from src.constants import UPLOAD_DIR + from core.database import Signature + + # Track temp files for this request so they get unlinked AFTER + # the response is fully sent (BackgroundTask runs post-send). + _to_unlink: list[str] = [] + def _cleanup_temps(): + for _p in _to_unlink: + try: + os.unlink(_p) + except FileNotFoundError: + pass + except Exception as _e: + logger.warning(f"Could not unlink temp PDF {_p}: {_e}") + + user = get_current_user(request) + db = SessionLocal() + try: + doc = db.query(Document).filter(Document.id == doc_id).first() + if not doc: + raise HTTPException(404, "Document not found") + _verify_doc_owner(db, doc, user) + upload_id = find_source_upload_id(doc.current_content or "") + if not upload_id: + raise HTTPException(400, "Document is not linked to a source PDF") + pdf_path = _locate_upload(UPLOAD_DIR, upload_id) + if not pdf_path: + raise HTTPException(404, f"Source PDF {upload_id} not found") + + values = parse_markdown_to_values(doc.current_content or "") + out_path = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False).name + _to_unlink.append(out_path) + try: + fill_fields(pdf_path, out_path, values) + except Exception as e: + logger.error(f"render_pdf fill_fields failed for {doc_id}: {e}") + _cleanup_temps() + raise HTTPException(500, f"PDF render failed: {e}") + + annotations = parse_markdown_annotations(doc.current_content or "") + if annotations: + ann_sig_ids = [ + a["value"][len("signature:"):].strip() + for a in annotations + if a.get("kind") == "signature" + and isinstance(a.get("value"), str) + and a["value"].startswith("signature:") + ] + ann_signature_pngs: dict[str, bytes] = {} + if ann_sig_ids: + # SECURITY: filter by owner so a caller can't reference + # someone else's signature ID from doc markdown and have + # it stamped/exported. + _sig_q = db.query(Signature).filter(Signature.id.in_(ann_sig_ids)) + if user: + _sig_q = _sig_q.filter(Signature.owner == user) + sig_rows = _sig_q.all() + for s in sig_rows: + try: + ann_signature_pngs[s.id] = base64.b64decode(s.data_png) + except Exception as e: + logger.warning(f"Bad annotation signature data for {s.id}: {e}") + annotated_path = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False).name + _to_unlink.append(annotated_path) + try: + stamp_annotations(out_path, annotated_path, annotations, ann_signature_pngs) + out_path = annotated_path + except Exception as e: + logger.error(f"stamp_annotations (render) failed for {doc_id}: {e}") + + return FileResponse( + out_path, + media_type="application/pdf", + headers={"Content-Disposition": "inline"}, + background=BackgroundTask(_cleanup_temps), + ) + finally: + db.close() + + # ---- GET /api/document/{doc_id}/export-pdf ---- + @router.get("/api/document/{doc_id}/export-pdf") + async def export_pdf(doc_id: str, request: Request): + """Stream the filled PDF for download. + + Reads field values and signature selections from the markdown — there + is no separate confirmation step. Signature fields contain their + chosen signature ID encoded as `signature:` in the value. + """ + import base64 + import os + import tempfile + from fastapi.responses import FileResponse + from starlette.background import BackgroundTask + from src.pdf_form_doc import find_source_upload_id, parse_markdown_to_values, load_field_sidecar, parse_markdown_annotations + from src.pdf_forms import fill_fields, stamp_signatures, stamp_annotations + from src.constants import UPLOAD_DIR + from core.database import Signature + + _to_unlink: list[str] = [] + def _cleanup_temps(): + for _p in _to_unlink: + try: + os.unlink(_p) + except FileNotFoundError: + pass + except Exception as _e: + logger.warning(f"Could not unlink temp PDF {_p}: {_e}") + + user = get_current_user(request) + db = SessionLocal() + try: + doc = db.query(Document).filter(Document.id == doc_id).first() + if not doc: + raise HTTPException(404, "Document not found") + _verify_doc_owner(db, doc, user) + + upload_id = find_source_upload_id(doc.current_content or "") + if not upload_id: + raise HTTPException(400, "Document is not linked to a source PDF") + + pdf_path = _locate_upload(UPLOAD_DIR, upload_id) + if not pdf_path: + raise HTTPException(404, f"Source PDF {upload_id} not found in uploads") + + schema = load_field_sidecar(pdf_path) or [] + sig_field_names = {f["name"] for f in schema if f.get("type") == "signature"} + + all_values = parse_markdown_to_values(doc.current_content or "") + # Split: signature fields go to stamps, everything else to fill_fields + text_values: dict = {} + sig_ids: dict[str, str] = {} + for name, raw in all_values.items(): + if name in sig_field_names and isinstance(raw, str) and raw.startswith("signature:"): + sig_ids[name] = raw[len("signature:"):].strip() + elif name not in sig_field_names: + text_values[name] = raw + + stamps: dict = {} + if sig_ids: + # SECURITY: filter by owner — same reason as render_pdf. + _sig_q2 = db.query(Signature).filter(Signature.id.in_(list(sig_ids.values()))) + if user: + _sig_q2 = _sig_q2.filter(Signature.owner == user) + rows = _sig_q2.all() + by_id = {s.id: s for s in rows} + for field_name, sid in sig_ids.items(): + s = by_id.get(sid) + if not s: + continue + try: + stamps[field_name] = base64.b64decode(s.data_png) + except Exception as e: + logger.warning(f"Bad signature data for {sid}: {e}") + + filled_path = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False).name + _to_unlink.append(filled_path) + try: + fill_fields(pdf_path, filled_path, text_values) + except Exception as e: + logger.error(f"fill_fields failed for doc {doc_id}: {e}") + _cleanup_temps() + raise HTTPException(500, f"PDF fill failed: {e}") + + out_path = filled_path + if stamps: + stamped_path = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False).name + _to_unlink.append(stamped_path) + try: + stamp_signatures(filled_path, stamped_path, stamps) + out_path = stamped_path + except Exception as e: + logger.error(f"stamp_signatures failed for doc {doc_id}: {e}") + + # Burn freeform annotations (Text/Check/Sign drops) on top. + annotations = parse_markdown_annotations(doc.current_content or "") + if annotations: + # Resolve any signature annotations to their PNG bytes. + ann_sig_ids = [ + a["value"][len("signature:"):].strip() + for a in annotations + if a.get("kind") == "signature" + and isinstance(a.get("value"), str) + and a["value"].startswith("signature:") + ] + ann_signature_pngs: dict[str, bytes] = {} + if ann_sig_ids: + # SECURITY: filter by owner so a caller can't reference + # someone else's signature ID from doc markdown and have + # it stamped/exported. + _sig_q = db.query(Signature).filter(Signature.id.in_(ann_sig_ids)) + if user: + _sig_q = _sig_q.filter(Signature.owner == user) + sig_rows = _sig_q.all() + for s in sig_rows: + try: + ann_signature_pngs[s.id] = base64.b64decode(s.data_png) + except Exception as e: + logger.warning(f"Bad annotation signature data for {s.id}: {e}") + annotated_path = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False).name + _to_unlink.append(annotated_path) + try: + stamp_annotations(out_path, annotated_path, annotations, ann_signature_pngs) + out_path = annotated_path + except Exception as e: + logger.error(f"stamp_annotations failed for doc {doc_id}: {e}") + + download_name = _slug(doc.title or "form") + "_annotated.pdf" + return FileResponse( + out_path, + media_type="application/pdf", + filename=download_name, + background=BackgroundTask(_cleanup_temps), + ) + finally: + db.close() + + # ---- POST /api/document/{doc_id}/prepare-signed-reply ---- + @router.post("/api/document/{doc_id}/prepare-signed-reply") + async def prepare_signed_reply(doc_id: str, request: Request): + """Bake the current PDF state (form fields + signature stamps + + annotations) into a flattened PDF, drop it in COMPOSE_UPLOADS_DIR + and return the reply context (To/Subject/threading headers) so the + frontend can open a reply draft with this attachment pre-loaded. + + Requires the document to have source_email_* metadata (set when the + doc was created via /api/email/attachment-as-doc). Otherwise 400. + """ + import base64 + import tempfile + import shutil + import uuid as _uuid + import email as _email_mod + from src.pdf_form_doc import ( + find_source_upload_id, parse_markdown_to_values, + load_field_sidecar, parse_markdown_annotations, + ) + from src.pdf_forms import fill_fields, stamp_signatures, stamp_annotations + from src.constants import UPLOAD_DIR + from core.database import Signature + # COMPOSE_UPLOADS_DIR lives in email_routes — re-derive here so we + # don't import from a routes file (cycle-prone). Same env override + # as email_routes (ODYSSEUS_MAIL_ATTACHMENTS_DIR). + from pathlib import Path as _Path + import os as _os + _DATA_DIR = _Path(__file__).resolve().parent.parent / "data" + _BASE = _os.environ.get("ODYSSEUS_MAIL_ATTACHMENTS_DIR", str(_DATA_DIR / "mail-attachments")) + _COMPOSE_DIR = _Path(_BASE) / "_compose" + _COMPOSE_DIR.mkdir(parents=True, exist_ok=True) + + user = get_current_user(request) + db = SessionLocal() + try: + doc = db.query(Document).filter(Document.id == doc_id).first() + if not doc: + raise HTTPException(404, "Document not found") + _verify_doc_owner(db, doc, user) + + if not (doc.source_email_uid and doc.source_email_folder): + raise HTTPException(400, "Document has no source email — cannot reply") + + # 1) Build the flattened PDF (same pipeline as export_pdf) + upload_id = find_source_upload_id(doc.current_content or "") + if not upload_id: + raise HTTPException(400, "Document is not linked to a source PDF") + pdf_path = _locate_upload(UPLOAD_DIR, upload_id) + if not pdf_path: + raise HTTPException(404, f"Source PDF {upload_id} not found") + + schema = load_field_sidecar(pdf_path) or [] + sig_field_names = {f["name"] for f in schema if f.get("type") == "signature"} + all_values = parse_markdown_to_values(doc.current_content or "") + text_values: dict = {} + sig_ids: dict[str, str] = {} + for name, raw in all_values.items(): + if name in sig_field_names and isinstance(raw, str) and raw.startswith("signature:"): + sig_ids[name] = raw[len("signature:"):].strip() + elif name not in sig_field_names: + text_values[name] = raw + + stamps: dict = {} + if sig_ids: + # SECURITY: filter by owner — same reason as render_pdf. + _sig_q2 = db.query(Signature).filter(Signature.id.in_(list(sig_ids.values()))) + if user: + _sig_q2 = _sig_q2.filter(Signature.owner == user) + rows = _sig_q2.all() + by_id = {s.id: s for s in rows} + for fname, sid in sig_ids.items(): + s = by_id.get(sid) + if not s: + continue + try: + stamps[fname] = base64.b64decode(s.data_png) + except Exception: + pass + + import os + _to_unlink: list[str] = [] + filled_path = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False).name + _to_unlink.append(filled_path) + fill_fields(pdf_path, filled_path, text_values) + out_path = filled_path + if stamps: + stamped_path = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False).name + _to_unlink.append(stamped_path) + try: + stamp_signatures(filled_path, stamped_path, stamps) + out_path = stamped_path + except Exception as e: + logger.warning(f"stamp_signatures failed for {doc_id}: {e}") + + annotations = parse_markdown_annotations(doc.current_content or "") + if annotations: + ann_sig_ids = [ + a["value"][len("signature:"):].strip() + for a in annotations + if a.get("kind") == "signature" + and isinstance(a.get("value"), str) + and a["value"].startswith("signature:") + ] + ann_signature_pngs: dict[str, bytes] = {} + if ann_sig_ids: + # SECURITY: filter by owner so a caller can't reference + # someone else's signature ID from doc markdown and have + # it stamped/exported. + _sig_q = db.query(Signature).filter(Signature.id.in_(ann_sig_ids)) + if user: + _sig_q = _sig_q.filter(Signature.owner == user) + sig_rows = _sig_q.all() + for s in sig_rows: + try: + ann_signature_pngs[s.id] = base64.b64decode(s.data_png) + except Exception: + pass + annotated_path = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False).name + _to_unlink.append(annotated_path) + try: + stamp_annotations(out_path, annotated_path, annotations, ann_signature_pngs) + out_path = annotated_path + except Exception as e: + logger.warning(f"stamp_annotations failed for {doc_id}: {e}") + + # 2) Move/copy into COMPOSE_UPLOADS_DIR with the token format + # `_` that /api/email/send expects. + filename = _slug(doc.title or "signed") + "_signed.pdf" + token = f"{_uuid.uuid4().hex}_{filename}" + dest = _COMPOSE_DIR / token + shutil.copyfile(out_path, str(dest)) + # Unlink the intermediate temp PDFs now that they've been + # copied into COMPOSE_UPLOADS_DIR. + for _p in _to_unlink: + try: + os.unlink(_p) + except FileNotFoundError: + pass + except Exception as _e: + logger.warning(f"Could not unlink temp PDF {_p}: {_e}") + + # 3) Fetch the source email's headers so we can build a clean reply + # context (To/Subject/In-Reply-To/References). + try: + from routes.email_routes import _imap, _decode_header + except Exception: + _imap = None + _decode_header = lambda x: x or "" + + to_addr = "" + from_name = "" + subject = "" + in_reply_to = doc.source_email_message_id or "" + references = in_reply_to + if _imap: + try: + with _imap(doc.source_email_account_id or None) as conn: + conn.select(doc.source_email_folder, readonly=True) + status, data = conn.fetch(doc.source_email_uid.encode(), "(RFC822.HEADER)") + if status == "OK" and data and data[0]: + raw_hdr = data[0][1] + m = _email_mod.message_from_bytes(raw_hdr) + sender = _decode_header(m.get("From", "")) + from_name, to_addr = _email_mod.utils.parseaddr(sender) + if not to_addr: + to_addr = sender + subject = _decode_header(m.get("Subject", "") or "") + if subject and not subject.lower().startswith("re:"): + subject = "Re: " + subject + msg_refs = (m.get("References") or "").strip() + msg_in_reply = (m.get("Message-ID") or "").strip() or in_reply_to + in_reply_to = msg_in_reply + references = (msg_refs + " " + msg_in_reply).strip() if msg_refs else msg_in_reply + except Exception as e: + logger.warning(f"prepare-signed-reply header fetch failed: {e}") + + return { + "ok": True, + "attachment": { + "token": token, + "filename": filename, + "size": dest.stat().st_size, + }, + "reply": { + "to": to_addr, + "to_name": from_name, + "subject": subject, + "in_reply_to": in_reply_to, + "references": references, + "account_id": doc.source_email_account_id or None, + "source_uid": doc.source_email_uid, + "source_folder": doc.source_email_folder, + "source_message_id": doc.source_email_message_id, + }, + } + finally: + db.close() + + return router diff --git a/routes/editor_draft_routes.py b/routes/editor_draft_routes.py new file mode 100644 index 0000000..3c28439 --- /dev/null +++ b/routes/editor_draft_routes.py @@ -0,0 +1,184 @@ +"""Editor draft routes — persisted in-progress gallery-editor sessions. + +The gallery editor (image canvas) lets users layer edits on top of a +photo (or a blank canvas). Persisting those layered sessions to the +server makes them survive cache clears and roams across devices — +unlike the legacy per-image localStorage drafts. + +Each draft carries: + - id — opaque uuid (the client never sees gallery-image ids + as draft ids, so blank-canvas drafts work too) + - source_image_id (nullable) — back-pointer for "this draft started as + an edit of GalleryImage X" + - payload — full JSON snapshot (layers as base64 PNG dataURLs, + offsets, opacities, etc.) the editor knows how to + rehydrate + - thumbnail — small data URL for the landing-list grid +""" + +import json +import logging +import uuid +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from core.database import EditorDraft, SessionLocal +from src.auth_helpers import get_current_user + +logger = logging.getLogger(__name__) + + +class DraftCreate(BaseModel): + name: Optional[str] = None + source_image_id: Optional[str] = None + width: Optional[int] = None + height: Optional[int] = None + payload: Dict[str, Any] + thumbnail: Optional[str] = None + + +class DraftUpdate(BaseModel): + name: Optional[str] = None + width: Optional[int] = None + height: Optional[int] = None + payload: Optional[Dict[str, Any]] = None + thumbnail: Optional[str] = None + + +def _owns(d: EditorDraft, user: Optional[str]) -> bool: + if user is None: + return True + return (d.owner or None) == user + + +def _summary(d: EditorDraft) -> Dict[str, Any]: + """List-view representation — omits the bulky payload.""" + return { + "id": d.id, + "name": d.name or "Untitled", + "source_image_id": d.source_image_id, + "width": d.width, + "height": d.height, + "thumbnail": d.thumbnail, + "created_at": d.created_at.isoformat() if d.created_at else None, + "updated_at": d.updated_at.isoformat() if d.updated_at else None, + } + + +def setup_editor_draft_routes() -> APIRouter: + router = APIRouter(tags=["editor-drafts"]) + + @router.get("/api/editor-drafts") + async def list_drafts(request: Request) -> Dict[str, List[Dict[str, Any]]]: + user = get_current_user(request) + db = SessionLocal() + try: + q = db.query(EditorDraft).filter(EditorDraft.is_active == True) + if user is not None: + q = q.filter(EditorDraft.owner == user) + rows = q.order_by(EditorDraft.updated_at.desc()).limit(200).all() + return {"drafts": [_summary(d) for d in rows]} + finally: + db.close() + + @router.get("/api/editor-drafts/{draft_id}") + async def get_draft(request: Request, draft_id: str) -> Dict[str, Any]: + user = get_current_user(request) + db = SessionLocal() + try: + d = db.query(EditorDraft).filter( + EditorDraft.id == draft_id, EditorDraft.is_active == True + ).first() + if not d or not _owns(d, user): + raise HTTPException(404, "Draft not found") + try: + payload = json.loads(d.payload) if d.payload else {} + except Exception: + payload = {} + return { + **_summary(d), + "payload": payload, + } + finally: + db.close() + + @router.post("/api/editor-drafts") + async def create_draft(request: Request, body: DraftCreate) -> Dict[str, Any]: + user = get_current_user(request) + db = SessionLocal() + try: + d = EditorDraft( + id=str(uuid.uuid4()), + owner=user, + name=(body.name or "Untitled")[:200], + source_image_id=body.source_image_id, + width=body.width, + height=body.height, + payload=json.dumps(body.payload or {}), + thumbnail=body.thumbnail, + ) + db.add(d) + db.commit() + db.refresh(d) + return _summary(d) + except Exception as e: + db.rollback() + logger.warning(f"editor-draft create failed: {e}") + raise HTTPException(500, "Could not save draft") + finally: + db.close() + + @router.put("/api/editor-drafts/{draft_id}") + async def update_draft(request: Request, draft_id: str, body: DraftUpdate) -> Dict[str, Any]: + user = get_current_user(request) + db = SessionLocal() + try: + d = db.query(EditorDraft).filter( + EditorDraft.id == draft_id, EditorDraft.is_active == True + ).first() + if not d or not _owns(d, user): + raise HTTPException(404, "Draft not found") + if body.name is not None: + d.name = body.name[:200] + if body.width is not None: + d.width = body.width + if body.height is not None: + d.height = body.height + if body.payload is not None: + d.payload = json.dumps(body.payload) + if body.thumbnail is not None: + d.thumbnail = body.thumbnail + db.commit() + db.refresh(d) + return _summary(d) + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.warning(f"editor-draft update failed: {e}") + raise HTTPException(500, "Could not update draft") + finally: + db.close() + + @router.delete("/api/editor-drafts/{draft_id}") + async def delete_draft(request: Request, draft_id: str) -> Dict[str, str]: + user = get_current_user(request) + db = SessionLocal() + try: + d = db.query(EditorDraft).filter(EditorDraft.id == draft_id).first() + if not d or not _owns(d, user): + raise HTTPException(404, "Draft not found") + d.is_active = False + db.commit() + return {"status": "deleted", "id": draft_id} + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + return router diff --git a/routes/email_helpers.py b/routes/email_helpers.py new file mode 100644 index 0000000..4be3118 --- /dev/null +++ b/routes/email_helpers.py @@ -0,0 +1,1274 @@ +""" +email_helpers.py + +Lower-level helpers used by both `email_routes.py` (the FastAPI route file) +and `email_pollers.py` (the background loops): + + - auth dependencies (require_owner / require_user / _assert_owns_account) + - account config + settings persistence (`_get_email_config`, `_list_email_accounts`) + - IMAP connection helpers (`_imap_connect`, `_imap`, folder detection) + - message parsing (`_decode_header`, `_extract_html/text`, attachment helpers) + - sender context retrieval for the AI-summary / AI-reply pipelines + - Pydantic models, shared constants, scheduled-DB bootstrap +""" + +import os +import imaplib +import smtplib +import email as email_mod +import email.header +import email.utils +import json +import re +import html +import logging +from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase +from email import encoders +import mimetypes +from pathlib import Path + +from fastapi import Query, HTTPException, Request +from pydantic import BaseModel +from typing import Optional, List + +from src.auth_helpers import get_current_user +from src.secret_storage import decrypt as _decrypt + +logger = logging.getLogger(__name__) + + +def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message: str | bytes, timeout: int = 30) -> None: + """Send through SMTP using the conventional TLS mode for the configured port. + + Account settings only store host/port today. Port 465 is implicit TLS + (SMTP_SSL); port 587 is plain SMTP upgraded with STARTTLS. Using SSL + directly against 587 raises the classic "[SSL: WRONG_VERSION_NUMBER]" + error even when credentials are correct. + """ + host = cfg["smtp_host"] + port = int(cfg.get("smtp_port") or 465) + user = cfg.get("smtp_user") or "" + password = cfg.get("smtp_password") or "" + if port == 587: + with smtplib.SMTP(host, port, timeout=timeout) as smtp: + smtp.starttls() + if user and password: + smtp.login(user, password) + smtp.sendmail(from_addr, recipients, message) + return + with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp: + if user and password: + smtp.login(user, password) + smtp.sendmail(from_addr, recipients, message) + + +def _strip_think(text: str) -> str: + """Email-flavored think strip — thin wrapper over the central helper. + + Email AI features get the prose-strip extension because their outputs + are short LLM-only generations (replies, summaries, calendar extraction, + urgency, classification, writing-style) where untagged reasoning leaks + are common. The central helper only runs the prose-strip when an actual + `` tag was present in the input, so legit user content is safe. + """ + if not text: + return "" + from src.text_helpers import strip_think as _central, _THINK_CLOSED_RE, _THINK_OPEN_RE, _THINK_TAG_RE + had_think = bool(_THINK_CLOSED_RE.search(text) or _THINK_OPEN_RE.search(text) or _THINK_TAG_RE.search(text)) + return _central(text, prose=had_think, prompt_echo=True) + + +import re as _re_reply +# Accept REPLY / SUMMARY / OUTPUT as the opening fence so the same extractor +# serves replies and summaries (any fenced final-output block). +_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>>", _re_reply.I) +_REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>>", _re_reply.I) + + +def _extract_reply(text: str) -> str: + """Pull the final email reply out of a model response. + + Positive extraction beats blocklist stripping: the model is asked to fence + its reply in <<>> ... <<>> markers, so we keep ONLY that region + and ignore whatever reasoning came before/after it. Deterministic, and it + can never clip a legit reply that merely opens reflectively. + + Fallbacks when the markers are absent (older/weaker models): we just run the + usual think-strip on the whole text — strictly no worse than before. A + second think-strip pass always runs on the extracted body too, in case the + model also reasoned *inside* the markers. + """ + if not text: + return "" + t = text + m = _REPLY_OPEN_RE.search(t) + if m: + rest = t[m.end():] + c = _REPLY_CLOSE_RE.search(rest) + t = rest[:c.start()] if c else rest + # Drop any stray/duplicate marker tokens, then strip think markup. + t = _REPLY_OPEN_RE.sub("", t) + t = _REPLY_CLOSE_RE.sub("", t) + return _strip_think(t).strip() + + +def _apply_email_style_mechanics(text: str) -> str: + """Enforce deterministic writing-style mechanics that models often miss.""" + if not text: + return "" + return ( + text.replace("—", "--") + .replace("–", "--") + .replace("’", "'") + .replace("‘", "'") + ) + + +def _require_auth(request: Request) -> str: + """Defense-in-depth: reject unauthenticated callers even if upstream + middleware was bypassed (e.g. localhost-bypass, SSRF from a sibling + service). Mirrors core.middleware.require_admin's resolution path. + + v2 review HIGH-13: previously fell open whenever auth_manager wasn't + `is_configured`, exposing IMAP creds and SMTP send to any network + caller on a half-configured deploy. Now: anonymous callers in + unconfigured mode are only honoured if they're coming from + localhost; everyone else gets 401. + """ + u = get_current_user(request) + if u: + return u + auth_mgr = getattr(request.app.state, "auth_manager", None) + if auth_mgr is not None and getattr(auth_mgr, "is_configured", False): + raise HTTPException(401, "Not authenticated") + # Unconfigured / first-run mode: only allow loopback callers. Public + # network traffic must authenticate even before auth is set up. + client = getattr(request, "client", None) + host = (client.host if client else "") or "" + if host in ("127.0.0.1", "::1", "localhost"): + return "" + raise HTTPException(401, "Not authenticated") + + +def require_owner(request: Request, account_id: str | None = Query(None)) -> str: + """FastAPI dependency: authenticate the caller and, if `account_id` is in + the query string, assert ownership. Returns the resolved owner ("" in + unconfigured single-user mode). Routes whose `account_id` lives in the + request body or path must still call `_assert_owns_account(body_id, owner)` + explicitly. Use `require_user` (no Query read) for path-param routes.""" + owner = _require_auth(request) + if account_id: + _assert_owns_account(account_id, owner) + return owner + + +def require_user(request: Request) -> str: + """Auth-only dependency for routes where `account_id` is a path param + or absent. Avoids `require_owner`'s Query collision with path params.""" + return _require_auth(request) + + +def _assert_owns_account(account_id: str, owner: str) -> None: + """Reject requests that name an `account_id` belonging to another user. + Previously the account lookup in `_get_email_config` filtered only on + `id == account_id`, letting a multi-user deploy enumerate / operate + against any other user's IMAP/SMTP mailbox. Call this *before* opening + the IMAP connection or reading creds. `owner == ""` is the unconfigured / + single-user case — accept any account.""" + if not account_id or not owner: + return + try: + from core.database import SessionLocal as _SL, EmailAccount as _EA + db = _SL() + try: + row = db.query(_EA).filter(_EA.id == account_id).first() + if row is None: + raise HTTPException(404, "Account not found") + if row.owner and row.owner != owner: + # Treat as 404 (not 403) so we don't leak existence. + raise HTTPException(404, "Account not found") + finally: + db.close() + except HTTPException: + raise + except Exception as e: + # Fail closed — a DB hiccup must not let cross-tenant access slip + # through. 503 tells the caller to retry; logs preserve detail. + logger.error(f"Account-owner check failed: {e}") + raise HTTPException(503, "Account check failed") + +def _q(name: str) -> str: + """Quote an IMAP mailbox name. Defensive: escapes `\\` and `"` and wraps + in double quotes so user-supplied folder names with spaces or quotes can't + confuse `SELECT` / `COPY`. imaplib already rejects CRLF, but quoting also + handles `[Gmail]/Sent Mail`-style names that need wrapping anyway.""" + return '"' + (name or "").replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def _attach_compose_uploads(outer: MIMEMultipart, tokens) -> None: + """Read each staged upload token, build a MIMEBase part, and attach to + `outer`. Tokens are sanitized via Path(token).name to prevent traversal. + Missing files are skipped silently. Used by /send, scheduled delivery, + and the agent send pipeline.""" + if not tokens: + return + for token in tokens: + safe_token = Path(token).name + path = COMPOSE_UPLOADS_DIR / safe_token + if not path.exists(): + logger.warning(f"Attachment token not found: {safe_token}") + continue + ctype, encoding = mimetypes.guess_type(str(path)) + if ctype is None or encoding is not None: + ctype = "application/octet-stream" + maintype, subtype = ctype.split("/", 1) + with open(path, "rb") as f: + part = MIMEBase(maintype, subtype) + part.set_payload(f.read()) + encoders.encode_base64(part) + # Token format: "_" + original_name = safe_token.split("_", 1)[1] if "_" in safe_token else safe_token + part.add_header("Content-Disposition", "attachment", filename=original_name) + outer.attach(part) + + +def _cleanup_compose_uploads(tokens) -> None: + """Best-effort unlink of staged uploads after delivery (or failure).""" + if not tokens: + return + for token in tokens: + try: + (COMPOSE_UPLOADS_DIR / Path(token).name).unlink(missing_ok=True) + except Exception: + pass + + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +SETTINGS_FILE = DATA_DIR / "settings.json" +# Override at deploy time via ODYSSEUS_MAIL_ATTACHMENTS_DIR. Defaults to a +# subdir of the install's data/ tree so the app works out-of-the-box without +# a hardcoded /home// path. +ATTACHMENTS_DIR = Path(os.environ.get("ODYSSEUS_MAIL_ATTACHMENTS_DIR", str(DATA_DIR / "mail-attachments"))) +ATTACHMENTS_DIR.mkdir(parents=True, exist_ok=True) +COMPOSE_UPLOADS_DIR = ATTACHMENTS_DIR / "_compose" +COMPOSE_UPLOADS_DIR.mkdir(parents=True, exist_ok=True) +SCHEDULED_DB = DATA_DIR / "scheduled_emails.db" + + +def _init_scheduled_db(): + import sqlite3 + conn = sqlite3.connect(SCHEDULED_DB) + conn.execute(""" + CREATE TABLE IF NOT EXISTS scheduled_emails ( + id TEXT PRIMARY KEY, + to_addr TEXT NOT NULL, + cc TEXT, + bcc TEXT, + subject TEXT, + body TEXT NOT NULL, + in_reply_to TEXT, + references_hdr TEXT, + attachments TEXT, + send_at TEXT NOT NULL, + created_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + error TEXT + ) + """) + # Email summary cache (keyed by Message-ID) + conn.execute(""" + CREATE TABLE IF NOT EXISTS email_summaries ( + message_id TEXT PRIMARY KEY, + uid TEXT, + folder TEXT, + subject TEXT, + sender TEXT, + summary TEXT NOT NULL, + model_used TEXT, + created_at TEXT NOT NULL + ) + """) + # Email AI reply cache (pre-generated draft replies) + conn.execute(""" + CREATE TABLE IF NOT EXISTS email_ai_replies ( + message_id TEXT PRIMARY KEY, + uid TEXT, + folder TEXT, + reply TEXT NOT NULL, + model_used TEXT, + created_at TEXT NOT NULL + ) + """) + # Email tags / spam classification cache. SECURITY: keyed by + # (message_id, owner) because Message-IDs are GLOBAL (a newsletter goes + # to many users with the same Message-ID). Without owner-scoping, a + # tag-write for user A's row clobbered user B's row and surfaced A's + # UID in B's `tag:urgent` IMAP filter (review C2). + conn.execute(""" + CREATE TABLE IF NOT EXISTS email_tags ( + message_id TEXT, + owner TEXT DEFAULT '', + uid TEXT, + folder TEXT, + subject TEXT, + sender TEXT, + tags TEXT, + spam_verdict INTEGER DEFAULT 0, + spam_reason TEXT, + moved_to TEXT, + model_used TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (message_id, owner) + ) + """) + # Backfill migration: older installs created the table with + # message_id as a bare PK and no owner column. Add the column + + # promote it into the PK by rebuild-copy-swap (SQLite can't ALTER PK). + try: + _cols = [r[1] for r in conn.execute("PRAGMA table_info(email_tags)")] + if "owner" not in _cols: + # Add the column first so reads/writes don't break mid-migration. + conn.execute("ALTER TABLE email_tags ADD COLUMN owner TEXT DEFAULT ''") + # Rebuild with composite PK. Existing rows get owner='' (legacy + # single-user); the urgency scanner will overwrite as it + # re-classifies. No data loss. + conn.execute(""" + CREATE TABLE IF NOT EXISTS email_tags__new ( + message_id TEXT, + owner TEXT DEFAULT '', + uid TEXT, folder TEXT, subject TEXT, sender TEXT, + tags TEXT, spam_verdict INTEGER DEFAULT 0, + spam_reason TEXT, moved_to TEXT, model_used TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (message_id, owner) + ) + """) + conn.execute(""" + INSERT OR IGNORE INTO email_tags__new + (message_id, owner, uid, folder, subject, sender, tags, + spam_verdict, spam_reason, moved_to, model_used, created_at) + SELECT message_id, COALESCE(owner, ''), uid, folder, subject, + sender, tags, spam_verdict, spam_reason, moved_to, + model_used, created_at + FROM email_tags + """) + conn.execute("DROP TABLE email_tags") + conn.execute("ALTER TABLE email_tags__new RENAME TO email_tags") + except Exception as _mig_e: + # Best-effort — log via the module logger if available + import logging as _lg + _lg.getLogger(__name__).warning(f"email_tags owner-migration skipped: {_mig_e}") + conn.execute(""" + CREATE TABLE IF NOT EXISTS email_calendar_extractions ( + message_id TEXT PRIMARY KEY, + uid TEXT, + events_created INTEGER DEFAULT 0, + created_at TEXT NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS email_urgency_alerts ( + message_id TEXT PRIMARY KEY, + uid TEXT, + folder TEXT, + subject TEXT, + sender TEXT, + urgency TEXT, + reason TEXT, + alerted INTEGER DEFAULT 0, + created_at TEXT NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS email_event_seen ( + owner TEXT NOT NULL, + account_key TEXT NOT NULL, + folder TEXT NOT NULL, + message_key TEXT NOT NULL, + first_seen_at TEXT NOT NULL, + PRIMARY KEY (owner, account_key, folder, message_key) + ) + """) + # Boundary cache — LLM-detected sig/quote start positions in the body. + # Stored as char offsets (-1 = no boundary found). Once cached, the + # client uses these to fold without ever re-calling the LLM. + conn.execute(""" + CREATE TABLE IF NOT EXISTS email_boundaries ( + message_id TEXT PRIMARY KEY, + uid TEXT, + folder TEXT, + sig_start INTEGER, + quote_start INTEGER, + model_used TEXT, + created_at TEXT NOT NULL + ) + """) + # Lazy migration: add account_id column to scheduled_emails if missing + try: + cols = [r[1] for r in conn.execute("PRAGMA table_info(scheduled_emails)").fetchall()] + if "account_id" not in cols: + conn.execute("ALTER TABLE scheduled_emails ADD COLUMN account_id TEXT") + if "odysseus_kind" not in cols: + conn.execute("ALTER TABLE scheduled_emails ADD COLUMN odysseus_kind TEXT") + except Exception: + pass + # Lazy migration: add turns_json to email_boundaries for server-side + # thread parsing cache (talon-style precomputed reply chain). + try: + cols = [r[1] for r in conn.execute("PRAGMA table_info(email_boundaries)").fetchall()] + if "turns_json" not in cols: + conn.execute("ALTER TABLE email_boundaries ADD COLUMN turns_json TEXT") + except Exception: + pass + # Per-sender signature cache. Populated by `learn_sender_signatures` + # action: the LLM extracts the common trailing block across N emails + # from each sender; the renderer folds it consistently for every + # future email from that address. + conn.execute(""" + CREATE TABLE IF NOT EXISTS sender_signatures ( + from_address TEXT PRIMARY KEY, + signature_text TEXT, + sample_count INTEGER, + last_built_at TEXT NOT NULL, + model_used TEXT, + source TEXT + ) + """) + conn.commit() + conn.close() + + +_init_scheduled_db() + + +def _load_settings(): + if SETTINGS_FILE.exists(): + return json.loads(SETTINGS_FILE.read_text()) + return {} + + +def _save_settings(settings): + from core.atomic_io import atomic_write_json + atomic_write_json(str(SETTINGS_FILE), settings, indent=2) + + +def _get_email_config(account_id: str | None = None, owner: str = "") -> dict: + """Return IMAP/SMTP config as a dict. + + Resolution order: + 1. If account_id given → that specific EmailAccount row. + 2. Else → the row with is_default=True (scoped to `owner` when given). + 3. Else → the first enabled row (scoped to `owner` when given). + 4. Else → legacy flat keys in data/settings.json (kept for envs + where the migration hasn't run yet or accounts table is empty). + 5. Else → env vars (SMTP_HOST / IMAP_HOST / ...). + + Returned dict always has the same shape as before; an `account_id` key is + added so callers can stamp derivative records (email_ai_replies etc.). + + SECURITY: without `owner`, the fallback queries (is_default, first-enabled) + don't filter by user — so on a multi-user deploy a brand-new account would + inherit whoever else's IMAP/SMTP creds happened to be the default. Pass + `owner` from the route's auth dependency to scope the lookup. + """ + import os + from core.database import SessionLocal as _SL, EmailAccount as _EA + + def _owner_or_matching_legacy_account(query): + if not owner: + return query + from sqlalchemy import and_, or_ + unowned = or_(_EA.owner == None, _EA.owner == "") # noqa: E711 + same_mailbox = or_(_EA.imap_user == owner, _EA.from_address == owner) + return query.filter(or_(_EA.owner == owner, and_(unowned, same_mailbox))) + + resolved_id = None + row = None + try: + db = _SL() + try: + if account_id: + row = db.query(_EA).filter(_EA.id == account_id, _EA.enabled == True).first() # noqa: E712 + # If the resolved row belongs to a different owner, treat as + # not-found rather than silently serving it. This is a defense + # in depth — `require_owner` already calls `_assert_owns_account` + # for query-param account_ids, but other callers (cookbook + # rules, scheduled poller) may not. + if row is not None and owner and row.owner and row.owner != owner: + row = None + # Fallback path — restrict to this owner's accounts so we don't + # leak another user's default mailbox to an unconfigured user. + if row is None: + q = db.query(_EA).filter(_EA.is_default == True, _EA.enabled == True) # noqa: E712 + q = _owner_or_matching_legacy_account(q) + row = q.first() + if row is None: + q = db.query(_EA).filter(_EA.enabled == True) # noqa: E712 + q = _owner_or_matching_legacy_account(q) + row = q.order_by(_EA.created_at.asc()).first() + if row is not None: + resolved_id = row.id + cfg = { + "account_id": row.id, + "account_name": row.name, + "smtp_host": row.smtp_host or "", + "smtp_port": int(row.smtp_port or 465), + "smtp_user": row.smtp_user or "", + "smtp_password": _decrypt(row.smtp_password or ""), + "imap_host": row.imap_host or "", + "imap_port": int(row.imap_port or 993), + "imap_user": row.imap_user or "", + "imap_password": _decrypt(row.imap_password or ""), + "imap_starttls": bool(row.imap_starttls), + "from_address": row.from_address or row.imap_user or "", + } + if not (cfg["smtp_host"] and cfg["smtp_user"] and cfg["smtp_password"]): + logger.warning(f"SMTP not configured for account {row.name!r}") + if not (cfg["imap_host"] and cfg["imap_user"] and cfg["imap_password"]): + logger.warning(f"IMAP not configured for account {row.name!r}") + return cfg + finally: + db.close() + except Exception as e: + logger.debug(f"email_accounts lookup failed, falling back to settings.json: {e}") + + # Legacy fallback — flat keys in settings.json / env vars + settings = _load_settings() + cfg = { + "account_id": resolved_id, + "account_name": "legacy", + "smtp_host": settings.get("smtp_host", os.environ.get("SMTP_HOST", "")), + "smtp_port": int(settings.get("smtp_port", os.environ.get("SMTP_PORT", "465")) or 465), + "smtp_user": settings.get("smtp_user", os.environ.get("SMTP_USER", "")), + "smtp_password": settings.get("smtp_password", os.environ.get("SMTP_PASSWORD", "")), + "imap_host": settings.get("imap_host", os.environ.get("IMAP_HOST", "")), + "imap_port": int(settings.get("imap_port", os.environ.get("IMAP_PORT", "993")) or 993), + "imap_user": settings.get("imap_user", os.environ.get("IMAP_USER", "")), + "imap_password": settings.get("imap_password", os.environ.get("IMAP_PASSWORD", "")), + "imap_starttls": settings.get("imap_starttls", True), + "from_address": settings.get("email_from", os.environ.get("EMAIL_FROM", "")), + } + if not (cfg["smtp_host"] and cfg["smtp_user"] and cfg["smtp_password"]): + logger.warning("SMTP not configured — add an Email Account in Settings or set env vars") + if not (cfg["imap_host"] and cfg["imap_user"] and cfg["imap_password"]): + logger.warning("IMAP not configured — add an Email Account in Settings or set env vars") + return cfg + + +def _list_email_accounts() -> list[dict]: + """Return all enabled accounts in creation order. Used by background loops + that iterate over every account (auto-summarize, urgency, etc.).""" + from core.database import SessionLocal as _SL, EmailAccount as _EA + try: + db = _SL() + try: + rows = ( + db.query(_EA) + .filter(_EA.enabled == True) # noqa: E712 + .order_by(_EA.is_default.desc(), _EA.created_at.asc()) + .all() + ) + return [_get_email_config(r.id) for r in rows] + finally: + db.close() + except Exception as e: + logger.debug(f"_list_email_accounts failed, returning [default]: {e}") + return [_get_email_config()] + + +# ── IMAP helpers ── + +_IMAP_TIMEOUT_SECONDS = 15 + +def _imap_connect(account_id: str | None = None, owner: str = ""): + # SECURITY: passing `owner` scopes the fallback config lookup so a brand + # new user doesn't get connected against another user's default mailbox + # when they have no account configured. + cfg = _get_email_config(account_id, owner=owner) + # Connection mode: + # STARTTLS on → plain + upgrade + # STARTTLS off + port 993 → implicit SSL (IMAPS) + # STARTTLS off + any other port → plain (local Dovecot, custom ports) + # The last branch is critical: previously this fell into IMAP4_SSL + # for any non-STARTTLS port, which would fail the TLS handshake on + # plain local servers (Dovecot on 31143, etc.). + if cfg.get("imap_starttls"): + conn = imaplib.IMAP4(cfg["imap_host"], cfg["imap_port"], timeout=_IMAP_TIMEOUT_SECONDS) + conn.starttls() + elif int(cfg.get("imap_port") or 993) == 993: + conn = imaplib.IMAP4_SSL(cfg["imap_host"], cfg["imap_port"], timeout=_IMAP_TIMEOUT_SECONDS) + else: + conn = imaplib.IMAP4(cfg["imap_host"], cfg["imap_port"], timeout=_IMAP_TIMEOUT_SECONDS) + try: + conn.sock.settimeout(_IMAP_TIMEOUT_SECONDS) + except Exception: + pass + conn.login(cfg["imap_user"], cfg["imap_password"]) + return conn + + +from contextlib import contextmanager + + +# Filled in by setup_email_routes() once its closure-scoped pool helpers are +# defined. Keyed so we can swap them out in tests. +_POOL_HOOKS: dict = {"connect": None, "release": None} + + +@contextmanager +def _imap(account_id: str | None = None, owner: str = ""): + """IMAP connection scoped to a `with` block. + + Uses the connection pool when available so we don't pay the + TCP+TLS+LOGIN handshake (~30-100ms with Dovecot) on every request. + Falls back to a fresh connect+logout pair before `setup_email_routes()` + has run (e.g. background pollers spinning up early). + + SECURITY: `owner` flows through `_imap_connect` → `_get_email_config` + so the fallback config lookup (when `account_id` is missing) is scoped + to this user's accounts. + """ + pool_connect = _POOL_HOOKS.get("connect") + pool_release = _POOL_HOOKS.get("release") + if pool_connect and pool_release: + # SECURITY: forward owner so the pool slot is per-user and the + # fresh-connection fallback runs through a scoped config lookup. + try: + conn, _reused = pool_connect(account_id, owner=owner) + except TypeError: + # Older hook signature without owner — fall back transparently. + conn, _reused = pool_connect(account_id) + ok = True + try: + yield conn + except Exception: + ok = False + raise + finally: + try: + try: + pool_release(account_id, conn, ok=ok, owner=owner) + except TypeError: + pool_release(account_id, conn, ok=ok) + except Exception: + pass + return + # Fallback: plain connect+logout. Used pre-setup or in tests. + conn = _imap_connect(account_id, owner=owner) + try: + yield conn + finally: + try: + conn.logout() + except Exception: + pass + + +def _decode_header(raw): + if not raw: + return "" + parts = email.header.decode_header(raw) + decoded = [] + for data, charset in parts: + if isinstance(data, bytes): + decoded.append(data.decode(charset or "utf-8", errors="replace")) + else: + decoded.append(data) + return " ".join(decoded) + + +def _detect_sent_folder(conn): + """Find the server's Sent folder name. Returns 'Sent' if nothing matches. + + Different IMAP servers expose the sent folder under different names: + Dovecot/typical: "Sent" + Gmail: "[Gmail]/Sent Mail" + Outlook/EWS: "Sent Items" + Some hosts: "INBOX.Sent" + """ + candidates = ("Sent", "[Gmail]/Sent Mail", "Sent Mail", "Sent Items", "INBOX.Sent") + try: + status, folders = conn.list() + if status != "OK" or not folders: + return "Sent" + names = [] + for f in folders: + decoded = f.decode() if isinstance(f, bytes) else str(f) + m = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded) + if m: + names.append(m.group(1) or m.group(2)) + # Prefer \Sent flag in LIST response if present. + for f in folders: + decoded = f.decode() if isinstance(f, bytes) else str(f) + if r"\Sent" in decoded: + m = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded) + if m: + return m.group(1) or m.group(2) + for c in candidates: + if c in names: + return c + except Exception: + pass + return "Sent" + + +def _detect_drafts_folder(conn): + """Find the server's Drafts folder name. Gmail usually exposes + "[Gmail]/Drafts"; other servers often use "Drafts".""" + candidates = ("Drafts", "[Gmail]/Drafts", "Draft", "INBOX.Drafts") + try: + status, folders = conn.list() + if status != "OK" or not folders: + return "Drafts" + names = [] + for f in folders: + decoded = f.decode() if isinstance(f, bytes) else str(f) + m = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded) + if m: + names.append(m.group(1) or m.group(2)) + for f in folders: + decoded = f.decode() if isinstance(f, bytes) else str(f) + if r"\Drafts" in decoded or r"\Draft" in decoded: + m = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded) + if m: + return m.group(1) or m.group(2) + for c in candidates: + if c in names: + return c + except Exception: + pass + return "Drafts" + + +def _detect_spam_folder(conn): + """Find the server's Junk/Spam folder name, if any.""" + try: + status, folders = conn.list() + if status != "OK" or not folders: + return None + preferred = None + fallback = None + for f in folders: + decoded = f.decode() if isinstance(f, bytes) else str(f) + m = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded) + if not m: + continue + name = m.group(1) or m.group(2) + if r"\Junk" in decoded: + preferred = name + break + low = name.lower() + if low in ("junk", "spam", "junk mail", "junk e-mail") or low.endswith("/junk") or low.endswith("/spam"): + fallback = fallback or name + return preferred or fallback + except Exception: + return None + + +def _imap_move(uid, dest, src="INBOX"): + """Move a single IMAP UID from src folder to dest. Returns True on success.""" + try: + c = _imap_connect() + c.select(_q(src)) + status, _ = c.copy(uid, _q(dest)) + if status != "OK": + c.logout() + return False + c.store(uid, "+FLAGS", "\\Deleted") + c.expunge() + c.logout() + return True + except Exception as e: + logger.warning(f"IMAP move {uid} → {dest} failed: {e}") + return False + + +def _extract_attachment_text(msg, max_chars: int = 6000) -> str: + """Pull readable text out of an email's attachments — PDF (via PyMuPDF), + plain text, markdown, csv, log. Caps total at `max_chars`. Returns a + formatted string with `[Attachment: filename]\\n` blocks + separated by `---`. Empty string if there's nothing useful. + + Used by the summarize/reply pipeline so an email like "see attached + invoice" produces a summary that actually references the invoice. + """ + if not msg or not msg.is_multipart(): + return "" + out_parts: list[str] = [] + total = 0 + import os as _os + import tempfile as _tempfile + for part in msg.walk(): + if part.is_multipart(): + continue + cd = str(part.get("Content-Disposition", "")) + ct = (part.get_content_type() or "").lower() + if ct in ("text/plain", "text/html") and "attachment" not in cd.lower(): + continue + filename = part.get_filename() or "" + if filename: + try: + filename = _decode_header(filename) + except Exception: + pass + fname_lower = (filename or "").lower() + payload = part.get_payload(decode=True) + if not payload: + continue + # Cap per-attachment size to avoid huge PDFs blowing the budget. + if len(payload) > 2_000_000: + continue + text = "" + try: + if ct == "application/pdf" or fname_lower.endswith(".pdf"): + tmp = _tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) + try: + tmp.write(payload) + tmp.close() + from src.personal_docs import extract_pdf_text + text = extract_pdf_text(tmp.name) or "" + finally: + try: + _os.unlink(tmp.name) + except Exception: + pass + elif ct.startswith("text/") or fname_lower.endswith((".txt", ".md", ".csv", ".log", ".json")): + text = payload.decode("utf-8", errors="replace") + except Exception as e: + logger.debug(f"attachment-text extract failed for {filename}: {e}") + continue + text = (text or "").strip() + if not text: + continue + remaining = max_chars - total + if remaining <= 0: + break + snippet = text[:remaining] + out_parts.append(f"[Attachment: {filename or 'file'}]\n{snippet}") + total += len(snippet) + if total >= max_chars: + break + return "\n\n---\n\n".join(out_parts) + + +def _list_attachments_from_msg(msg): + """Return a list of attachment metadata from an email message.""" + attachments = [] + if not msg.is_multipart(): + return attachments + idx = 0 + for part in msg.walk(): + if part.is_multipart(): + continue + cd = str(part.get("Content-Disposition", "")) + ct = part.get_content_type() + # Skip text/html body parts (only consider real attachments) + if ct in ("text/plain", "text/html") and "attachment" not in cd: + continue + filename = part.get_filename() + if filename: + filename = _decode_header(filename) + else: + # Inline images, etc. - generate a name + ext = ct.split("/")[-1] if "/" in ct else "bin" + filename = f"attachment_{idx}.{ext}" + payload = part.get_payload(decode=True) + size = len(payload) if payload else 0 + attachments.append({ + "index": idx, + "filename": filename, + "content_type": ct, + "size": size, + "is_inline": "inline" in cd.lower(), + }) + idx += 1 + return attachments + + +def _extract_attachment_to_disk(msg, index, target_dir): + """Extract a specific attachment to disk and return the file path.""" + if not msg.is_multipart(): + return None + idx = 0 + for part in msg.walk(): + if part.is_multipart(): + continue + cd = str(part.get("Content-Disposition", "")) + ct = part.get_content_type() + if ct in ("text/plain", "text/html") and "attachment" not in cd: + continue + if idx == index: + filename = part.get_filename() + if filename: + filename = _decode_header(filename) + else: + ext = ct.split("/")[-1] if "/" in ct else "bin" + filename = f"attachment_{idx}.{ext}" + # Sanitize + safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() + payload = part.get_payload(decode=True) + if not payload: + return None + target_dir.mkdir(parents=True, exist_ok=True) + filepath = target_dir / safe_name + with open(filepath, "wb") as f: + f.write(payload) + return filepath + idx += 1 + return None + + +def _extract_html(msg): + """Extract raw HTML body from an email message, if present.""" + if msg.is_multipart(): + for part in msg.walk(): + ct = part.get_content_type() + cd = str(part.get("Content-Disposition", "")) + if ct == "text/html" and "attachment" not in cd: + payload = part.get_payload(decode=True) + if payload: + charset = part.get_content_charset() or "utf-8" + return payload.decode(charset, errors="replace") + elif msg.get_content_type() == "text/html": + payload = msg.get_payload(decode=True) + if payload: + charset = msg.get_content_charset() or "utf-8" + return payload.decode(charset, errors="replace") + return None + + +def _extract_text(msg): + if msg.is_multipart(): + text_parts = [] + for part in msg.walk(): + ct = part.get_content_type() + cd = str(part.get("Content-Disposition", "")) + if ct == "text/plain" and "attachment" not in cd: + payload = part.get_payload(decode=True) + if payload: + charset = part.get_content_charset() or "utf-8" + text_parts.append(payload.decode(charset, errors="replace")) + elif ct == "text/html" and not text_parts and "attachment" not in cd: + payload = part.get_payload(decode=True) + if payload: + charset = part.get_content_charset() or "utf-8" + raw_html = payload.decode(charset, errors="replace") + text = re.sub(r"", "\n", raw_html, flags=re.I) + text = re.sub(r"<[^>]+>", "", text) + text = html.unescape(text) + text_parts.append(text.strip()) + return "\n".join(text_parts) + else: + payload = msg.get_payload(decode=True) + if payload: + charset = msg.get_content_charset() or "utf-8" + return payload.decode(charset, errors="replace") + return "" + + +def _fetch_sender_thread_context(sender_addr: str, + exclude_uid: str = "", + exclude_folder: str = "INBOX", + limit: int = 3, + max_chars_per_email: int = 1500, + max_attachment_chars: int = 4000) -> str: + """Pull the last N emails from `sender_addr` (across common folders), + extract their body snippets + attachment text, and return one formatted + block ready to be glued into an LLM system prompt as "REFERENCED MATERIAL". + + Returns empty string if nothing useful was found. Never raises. + + Used by the AI reply path so a follow-up like "regarding question 3 of the + document you sent" can actually quote that document instead of pretending. + """ + if not sender_addr: + return "" + sender_addr = sender_addr.strip().lower() + if not sender_addr: + return "" + + blocks: list[str] = [] + seen_uids: set[tuple[str, str]] = set() # (folder, uid) + if exclude_uid: + seen_uids.add((exclude_folder or "INBOX", str(exclude_uid))) + + try: + conn = _imap_connect() + except Exception as e: + logger.warning(f"sender-thread-context: imap connect failed: {e}") + return "" + + try: + for folder in ["INBOX", "Sent", "Archive", "Drafts"]: + if len(blocks) >= limit: + break + try: + st_sel, _ = conn.select(_q(folder), readonly=True) + if st_sel != "OK": + continue + except Exception: + continue + try: + addr_escaped = sender_addr.replace('"', '\\"') + status, sdata = conn.search(None, f'(FROM "{addr_escaped}")') + if status != "OK" or not sdata or not sdata[0]: + continue + uids = sdata[0].split() + # Most recent first. + uids = list(reversed(uids)) + except Exception: + continue + + for raw_uid in uids: + if len(blocks) >= limit: + break + uid = raw_uid.decode() if isinstance(raw_uid, bytes) else str(raw_uid) + key = (folder, uid) + if key in seen_uids: + continue + seen_uids.add(key) + + try: + st_f, msg_data = conn.fetch(raw_uid, "(RFC822)") + if st_f != "OK" or not msg_data: + continue + raw_bytes = None + for part in msg_data: + if isinstance(part, tuple) and len(part) >= 2 and part[1]: + raw_bytes = part[1] + break + if not raw_bytes: + continue + msg = email_mod.message_from_bytes(raw_bytes) + except Exception as e: + logger.debug(f"sender-thread-context fetch fail uid={uid}: {e}") + continue + + try: + subj = _decode_header(msg.get("Subject", "(no subject)")) + date_hdr = msg.get("Date", "") + body_text = (_extract_text(msg) or "").strip() + body_text = re.sub(r"\n{3,}", "\n\n", body_text) + if len(body_text) > max_chars_per_email: + body_text = body_text[:max_chars_per_email].rstrip() + "…" + atts_text = _extract_attachment_text(msg, max_chars=max_attachment_chars) + except Exception as e: + logger.debug(f"sender-thread-context parse fail uid={uid}: {e}") + continue + + if not body_text and not atts_text: + continue + + lines = [f"— {folder} · {date_hdr} · Subject: {subj}"] + if body_text: + lines.append(body_text) + if atts_text: + lines.append(atts_text) + blocks.append("\n".join(lines)) + finally: + try: conn.close() + except Exception: pass + try: conn.logout() + except Exception: pass + + if not blocks: + return "" + return "\n\n=====\n\n".join(blocks) + + +def _pre_retrieve_context(body: str, sender: str) -> tuple: + """Extract key terms from an incoming email and search past emails + contacts. + + Returns (context_snippets, terms_list). Best-effort; never raises. + + Sec note: this is called from the auto-reply path. An attacker who can + craft an inbound email's content to contain Capitalized words matching + private context (legal/medical names, project codenames) can coerce the + LLM reply to quote that context back in the auto-reply. To narrow the + blast radius: + - require terms ≥ 5 chars (was 4), + - require multiword for an unknown sender, + - cap to 3 terms (was 4), + - skip entirely for senders with no prior contact / no past mail. + """ + STOPWORDS = {"dear", "hello", "hi", "hey", "thanks", "thank", "regards", + "best", "kind", "sincerely", "cheers", "the", "this", "that", + "from", "subject", "re", "fwd", "yours", "my", "our", "your"} + context_snippets = [] + terms_list = [] + try: + # ── Known-sender check: only retrieve context for senders we already + # have a relationship with. New / cold senders get an empty context. + sender_addr = email.utils.parseaddr(sender or "")[1].lower() + is_known = False + try: + from routes.contacts_routes import _fetch_contacts + for c in _fetch_contacts() or []: + if (c.get("email") or "").lower() == sender_addr: + is_known = True + break + except Exception: + pass + if not is_known and sender_addr: + try: + with _imap() as _ck: + _ck.select("INBOX", readonly=True) + st_known, dk = _ck.search(None, f'(FROM "{sender_addr}")') + if st_known == "OK" and dk and dk[0]: + is_known = True + except Exception: + pass + if not is_known: + logger.info(f"Pre-retrieval skipped — unknown sender {sender_addr}") + return [], [] + + seen = set() + multiword = [] + singleword = [] + for m in re.finditer(r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})\b", body or ""): + term = m.group(1).strip() + key = term.lower() + if key in seen: + continue + first = term.split()[0].lower() + if first in STOPWORDS: + continue + if len(term) < 5: + continue + seen.add(key) + (multiword if " " in term else singleword).append(term) + sender_name_clean = _decode_header(sender or "").split("<")[0].strip().lower() + # Multiword terms are far less likely to collide with unrelated context + # than single capitalized words. Prefer them; only fall back to + # singletons when we don't have enough multiwords. + ranked = [t for t in (multiword + singleword) if t.lower() != sender_name_clean] + terms_list = ranked[:3] + logger.info(f"Pre-retrieval terms={terms_list}") + + if not terms_list: + return context_snippets, terms_list + + try: + ctx_conn = _imap_connect() + for folder in ["INBOX", "Sent", "Archive", "Drafts"]: + try: + st_sel, _sd = ctx_conn.select(_q(folder), readonly=True) + if st_sel != "OK": + continue + except Exception: + continue + for term in terms_list: + try: + safe_term = term.replace('"', '').replace('\\', '') + st, data2 = ctx_conn.search(None, "TEXT", f'"{safe_term}"') + if st != "OK" or not data2 or not data2[0]: + continue + all_hits = data2[0].split() + hit_uids = all_hits[-2:] + logger.info(f" [{folder}] term={term!r} hits={len(all_hits)}") + for huid in hit_uids: + try: + st2, hd = ctx_conn.fetch(huid, "(RFC822)") + if st2 != "OK" or not hd or not hd[0]: + continue + hmsg = email_mod.message_from_bytes(hd[0][1]) + hsubj = _decode_header(hmsg.get("Subject", "")) + hfrom = _decode_header(hmsg.get("From", "")) + hdate = hmsg.get("Date", "") + hbody = _extract_text(hmsg)[:600] + context_snippets.append( + f"[{folder} match for \"{term}\"]\nFrom: {hfrom}\nDate: {hdate}\nSubject: {hsubj}\n{hbody}" + ) + except Exception: + continue + except Exception as _e: + logger.warning(f" search {folder} {term!r} failed: {_e}") + continue + try: + ctx_conn.logout() + except Exception: + pass + except Exception as _e: + logger.warning(f"IMAP context search failed: {_e}") + + try: + from routes.contacts_routes import _fetch_contacts + all_contacts = _fetch_contacts() + for term in terms_list: + t_lower = term.lower() + matches = [c for c in all_contacts + if t_lower in (c.get("name") or "").lower() + or t_lower in (c.get("email") or "").lower()] + for c in matches[:2]: + parts = [f"Name: {c.get('name','')}"] + if c.get("email"): + parts.append(f"Email: {c['email']}") + if c.get("phone"): + parts.append(f"Phone: {c['phone']}") + context_snippets.append(f"[Contact match for \"{term}\"] " + ", ".join(parts)) + except Exception: + pass + except Exception as e: + logger.warning(f"Pre-retrieval failed: {e}") + logger.info(f"Pre-retrieval snippets={len(context_snippets)}") + return context_snippets, terms_list + + +_EMAIL_REPLY_SYS_PROMPT_BASE = ( + "You are drafting an email reply. Write only the reply body, no subject line, " + "and no extra commentary. The saved WRITING STYLE below outranks generic tone guidance. " + "If the saved style says to use a greeting/sign-off, include them. For English replies, " + "default to 'Hi [Name]' rather than 'Hey'. Be direct and concise. Match the tone of the " + "original email without violating the saved style.\n\n" + "MECHANICAL STYLE RULES — CRITICAL: Never use an em dash or en dash; use -- instead. " + "Never use curly apostrophes; write I'm, don't, we'll with straight '. Do not start " + "with 'Hey' unless the saved style explicitly requests it.\n\n" + "IDENTITY RULE — CRITICAL: write as the user/mailbox owner only. NEVER sign as, " + "speak as, or imply you are the recipient, original sender, quoted sender, spouse, " + "assistant, company, or any third party. Do not copy a name from the quoted thread " + "into the sign-off. If a writing style below names a signature, use only that " + "signature; otherwise omit the sign-off.\n\n" + "CRITICAL RULE: NEVER invent facts, names, dates, phone numbers, emails, addresses, " + "or any specifics not explicitly present in the RELEVANT CONTEXT section below or " + "the original email itself. If the sender asks for information you don't have in " + "the context, say plainly that you don't have it on hand — do NOT guess or fabricate. " + "Do not promise to 'look it up' or 'get back to you soon' as a way to pad the reply. " + "If you have no real information to offer, write a short honest reply (2-4 sentences max).\n\n" + "OUTPUT FORMAT — IMPORTANT: Put ONLY the final email reply between these exact markers, " + "each on its own line:\n" + "<<>>\n" + "(the reply body goes here)\n" + "<<>>\n" + "Any reasoning, planning, or notes-to-self must come BEFORE the <<>> marker " + "(ideally wrapped in ...). Only the text between <<>> and <<>> " + "is sent as the email — nothing else is shown to anyone." +) + + +# ── Request models ── + +class SendEmailRequest(BaseModel): + to: str + cc: Optional[str] = None + bcc: Optional[str] = None + subject: str + body: str + # WYSIWYG compose sends the rendered HTML here; the server sanitizes it and + # uses it for the text/html part (body stays the plain-text fallback). When + # absent, the server renders markdown from `body` instead. + body_html: Optional[str] = None + in_reply_to: Optional[str] = None + references: Optional[str] = None + # List of uploaded attachment tokens (filenames in COMPOSE_UPLOADS_DIR) + attachments: Optional[List[str]] = None + # Which account to send from. None = default account. + account_id: Optional[str] = None + # Internal marker for Odysseus-generated mail (e.g. reminder, scheduled). + odysseus_kind: Optional[str] = None + # If true, /send waits for SMTP + Sent append and returns the sent UID. + wait_for_delivery: bool = False + + +class ExtractStyleRequest(BaseModel): + sample_count: Optional[int] = 20 diff --git a/routes/email_pollers.py b/routes/email_pollers.py new file mode 100644 index 0000000..ac21d52 --- /dev/null +++ b/routes/email_pollers.py @@ -0,0 +1,1006 @@ +""" +email_pollers.py + +Background loops that periodically scan IMAP and act on mail: + + - `_auto_summarize_pass` / `_auto_summarize_pass_single` — daily/hourly + summary + AI-reply + spam-classification pass over recently received mail. + - `_auto_summarize_poller` — driver that wakes the pass on a 30-min cadence. + - `_scheduled_email_poller` — polls the `scheduled_emails` SQLite for + due rows and delivers them via SMTP. + - `_start_poller` — entry point called once at app startup; spawns both + pollers + handles the deferred-start trick when the event loop is not + yet running. + +Pure helpers live in `email_helpers.py`. Routes themselves live in +`email_routes.py`. +""" + +import email as email_mod +import email.utils # the `email` binding is referenced as email.utils.parseaddr inside the pass +import smtplib +import json +import re +import html +import logging +from datetime import datetime + +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +from src.llm_core import llm_call_async + +from routes.email_helpers import ( + _strip_think, _extract_reply, _apply_email_style_mechanics, _load_settings, _save_settings, _get_email_config, + _send_smtp_message, + _imap_connect, _imap, _decode_header, + _detect_sent_folder, _detect_spam_folder, _imap_move, + _extract_attachment_text, _extract_text, + _pre_retrieve_context, + _attach_compose_uploads, _cleanup_compose_uploads, _q, + SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE, +) + +logger = logging.getLogger(__name__) + + +# ── Routes ── + +async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = True, + do_tag: bool = False, do_spam: bool = False, + do_calendar: bool = False, + days_back: int = 1) -> str: + """One iteration of the email scan. Temporarily flips settings flags + so the existing background-loop logic runs exactly once for the requested ops.""" + settings = _load_settings() + prev = {k: settings.get(k, False) for k in + ("email_auto_summarize", "email_auto_reply", "email_auto_tag", + "email_auto_spam", "email_auto_calendar")} + settings["email_auto_summarize"] = bool(do_summary) + settings["email_auto_reply"] = bool(do_reply) + settings["email_auto_tag"] = bool(do_tag) + settings["email_auto_spam"] = bool(do_spam) + settings["email_auto_calendar"] = bool(do_calendar) + _save_settings(settings) + try: + return await _auto_summarize_pass(days_back=days_back) + finally: + s2 = _load_settings() + for k, v in prev.items(): + s2[k] = v + _save_settings(s2) + + +async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None) -> str: + """Single pass of the auto-summarize/reply scan. + + When account_id is None, iterates over every enabled account in + email_accounts and runs one pass per account, concatenating the results. + """ + # Multi-account fan-out: if the caller didn't pick an account, hit them all. + if account_id is None: + try: + from core.database import SessionLocal as _SL, EmailAccount as _EA + db = _SL() + try: + rows = ( + db.query(_EA) + .filter(_EA.enabled == True) # noqa: E712 + .order_by(_EA.is_default.desc(), _EA.created_at.asc()) + .all() + ) + ids = [r.id for r in rows] + names = {r.id: r.name for r in rows} + finally: + db.close() + except Exception: + ids = [] + names = {} + if len(ids) <= 1: + # Single-account (or zero rows — fallback to legacy settings.json lookup) + return await _auto_summarize_pass_single(days_back=days_back, account_id=(ids[0] if ids else None)) + outs = [] + for aid in ids: + try: + result = await _auto_summarize_pass_single(days_back=days_back, account_id=aid) + outs.append(f"[{names.get(aid, aid[:8])}] {result}") + except Exception as e: + logger.warning(f"auto-summarize pass failed for account {aid}: {e}") + outs.append(f"[{names.get(aid, aid[:8])}] error: {e}") + return "\n".join(outs) + return await _auto_summarize_pass_single(days_back=days_back, account_id=account_id) + + +async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None) -> str: + """Single pass of the auto-summarize/reply scan for ONE account. + Reads current settings flags.""" + import asyncio + import sqlite3 as _sql3 + import requests as _req + from src.endpoint_resolver import resolve_endpoint + from src.llm_core import _uses_max_completion_tokens + + settings = _load_settings() + auto_sum = settings.get("email_auto_summarize", False) + auto_reply = settings.get("email_auto_reply", False) + auto_tag = settings.get("email_auto_tag", False) + auto_spam = settings.get("email_auto_spam", False) + auto_cal = settings.get("email_auto_calendar", False) + if not auto_sum and not auto_reply and not auto_tag and not auto_spam and not auto_cal: + return "Nothing to do" + + try: + conn = _imap_connect(account_id) + from datetime import timedelta as _td + since = (datetime.utcnow() - _td(days=max(1, days_back))).strftime("%d-%b-%Y") + # uid_list now carries (folder, uid) tuples — for calendar extraction we + # also scan Sent so the LLM sees confirmation/cancellation replies the user wrote. + uid_list = [] + folders_to_scan = ["INBOX"] + if auto_cal: + for sent_name in ("Sent", "INBOX/Sent", "Sent Items", "[Gmail]/Sent Mail"): + try: + st, _ = conn.select(sent_name, readonly=True) + if st == "OK": + folders_to_scan.append(sent_name) + break + except Exception: + continue + for folder in folders_to_scan: + try: + conn.select(_q(folder), readonly=True) + status, data = conn.search(None, f'(SINCE {since})') + if status == "OK" and data[0]: + for u in data[0].split()[-30:]: + uid_list.append((folder, u)) + except Exception as _e: + logger.warning(f"Folder {folder} scan failed: {_e}") + # Re-select INBOX as default for downstream code + conn.select("INBOX", readonly=True) + if not uid_list: + conn.logout() + return "No recent emails" + + _c = _sql3.connect(SCHEDULED_DB) + _sum_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_summaries").fetchall()} + _reply_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_ai_replies").fetchall()} + _tag_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_tags").fetchall()} if (auto_tag or auto_spam) else set() + _cal_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_calendar_extractions").fetchall()} if auto_cal else set() + # Urgency is handled by the built-in `check_email_urgency` task. Keep + # this legacy poller path disabled so users don't get two independent + # urgent-email systems. + auto_urgent = False + _urgent_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_urgency_alerts").fetchall()} if auto_urgent else set() + _c.close() + + # Hoist the self-address lookup OUT of the per-email loop — fetching + # this per-iteration was making big inbox scans crawl. Used by the + # urgency self-loop check below. + try: + _self_self_addr = (_get_email_config(account_id).get("from_address") or "").strip().lower() + except Exception: + _self_self_addr = "" + + spam_folder = _detect_spam_folder(conn) if auto_spam else None + if auto_spam and not spam_folder: + logger.warning("Auto-spam enabled but no Junk/Spam folder detected — will classify but not move") + + url, model, headers = resolve_endpoint("utility") + if not url: + url, model, headers = resolve_endpoint("default") + if not url or not model: + conn.logout() + return "No model configured" + + writing_style = settings.get("email_writing_style", "") + processed = 0 + already_cached = 0 + too_short = 0 + no_msgid = 0 + examined = 0 + _events_created = 0 + _current_folder = "INBOX" + for _entry in uid_list: + if processed >= 10: + break + # entry can be either a bare UID (legacy callers) or (folder, uid) tuple (new code) + if isinstance(_entry, tuple): + _folder, uid = _entry + else: + _folder, uid = "INBOX", _entry + try: + if _folder != _current_folder: + conn.select(_q(_folder), readonly=True) + _current_folder = _folder + st, msg_data = conn.fetch(uid, "(RFC822)") + if st != "OK": + continue + examined += 1 + raw = msg_data[0][1] + msg = email_mod.message_from_bytes(raw) + message_id = msg.get("Message-ID", "").strip() + if not message_id: + # Include folder+UID so each message gets a unique synth ID + import hashlib as _hl + uid_str = uid.decode() if isinstance(uid, bytes) else str(uid) + seed = f"{_folder}|{uid_str}|{msg.get('From','')}|{msg.get('Date','')}|{msg.get('Subject','')}" + message_id = f"" + no_msgid += 1 + need_sum = auto_sum and message_id not in _sum_existing + need_reply = auto_reply and message_id not in _reply_existing + need_class = (auto_tag or auto_spam) and message_id not in _tag_existing + need_cal = bool(settings.get("email_auto_calendar", False)) and message_id not in _cal_existing + # Only check urgency on INBOX (received mail), not Sent + # Skip messages that are themselves urgency alerts, or that + # we sent to ourselves — otherwise the alert loop re-flags + # its own output and the subject stacks "[HIGH] [HIGH] …". + _subj_raw = _decode_header(msg.get("Subject", "") or "") + _from_raw = _decode_header(msg.get("From", "") or "") + _is_alert_echo = bool(re.match(r'^\s*(\[(HIGH|CRITICAL|MEDIUM|LOW)\]\s*)+', _subj_raw, re.IGNORECASE)) + # Parse the From header into ("name", "addr@host") so a + # display-name containing the self addr doesn't false-positive + # (e.g. someone forging a Reply-To with our address as the + # display name). parseaddr returns ("", "") on garbage input. + try: + _, _from_addr_only = email.utils.parseaddr(_from_raw) + except Exception: + _from_addr_only = "" + _is_self_mail = bool(_self_self_addr) and _from_addr_only.lower() == _self_self_addr + need_urgent = (auto_urgent and message_id not in _urgent_existing + and not _folder.lower().startswith("sent") + and "sent" not in _folder.lower() + and not _is_alert_echo + and not _is_self_mail) + if not need_sum and not need_reply and not need_class and not need_cal and not need_urgent: + already_cached += 1 + continue + subject = _decode_header(msg.get("Subject", "")) + sender = _decode_header(msg.get("From", "")) + body = _extract_text(msg) + # Pull text out of any PDFs / text attachments and append to + # the body so summaries / replies can actually reason about + # the contents (e.g. "your invoice arrived" produces a + # summary that references the invoice line items). + att_text = "" + if need_sum or need_reply: + try: + att_text = _extract_attachment_text(msg, max_chars=6000) + except Exception as _ae: + logger.debug(f"attachment text extraction failed for uid={uid}: {_ae}") + # No threshold for calendar — even "see you tmrw 5pm" matters. + # Summary/reply/classify still need ≥100 chars to be worth the LLM cost. + # If body is short but attachments have content, treat it as enough. + if need_cal: + if not body: + body = subject # at minimum send the subject line + elif (not body or len(body) < 100) and not att_text: + too_short += 1 + continue + # Augmented body sent to the LLM: original body + attachment text. + body_for_llm = body + if att_text: + body_for_llm = (body or "") + "\n\n--- ATTACHMENTS ---\n\n" + att_text + + req_headers = {"Content-Type": "application/json"} + if headers: + req_headers.update(headers) + + if need_sum: + tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens" + payload = { + "model": model, + "messages": [ + {"role": "system", "content": "You are an email summarizer. Format: 1-3 short bullet points (use '- '). Cover: main point, action items, deadlines. If the email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR CONTENTS — pull out invoice totals, deadlines, key clauses, any concrete numbers/dates in PDFs/docs, and reflect them in the bullets. Be terse.\n\nOUTPUT FORMAT: Put ONLY the bullet points between these exact markers, each on its own line:\n<<>>\n- ...\n<<>>\nAny reasoning or planning must come BEFORE <<>> (ideally inside ...). Only the text between the markers is kept."}, + {"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\n---\n\nSummarize the email. Output the bullets between <<>> and <<>>."}, + ], + tok_key: 16384, + "temperature": 0.3, + "stream": False, + } + try: + # Use to_thread so this sync HTTP call doesn't freeze + # the entire event loop while the LLM thinks (240s). + resp = await asyncio.to_thread( + _req.post, url, json=payload, headers=req_headers, timeout=240 + ) + if resp.ok: + rdata = resp.json() + m = (rdata.get("choices") or [{}])[0].get("message", {}) + summary = (m.get("content") or "").strip() + summary = _extract_reply(summary) + if not summary: + rc = (m.get("reasoning_content") or "").strip() + bullets = [ln.strip() for ln in rc.split("\n") if re.match(r"^[-•*]\s+|^\d+[.)]\s+", ln.strip())] + summary = "\n".join(bullets) if bullets else "" + if summary: + _c = _sql3.connect(SCHEDULED_DB) + _c.execute(""" + INSERT OR REPLACE INTO email_summaries + (message_id, uid, folder, subject, sender, summary, model_used, created_at) + VALUES (?, ?, 'INBOX', ?, ?, ?, ?, ?) + """, (message_id, uid.decode(), subject, sender, summary, model, datetime.utcnow().isoformat())) + _c.commit() + _c.close() + _sum_existing.add(message_id) + except Exception as e: + logger.warning(f"Auto-summary {uid} failed: {e}") + + if need_reply: + context_snippets, _terms = _pre_retrieve_context(body, sender) + sys_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE + if att_text: + sys_prompt += "\n\nThe email has attachments (PDFs / docs) — their contents follow the body marked '--- ATTACHMENTS ---'. Reference them in your reply when relevant (e.g. acknowledge the invoice/contract, address specific clauses or amounts)." + if writing_style: + sys_prompt += f"\n\nWRITING STYLE TO MATCH:\n{writing_style}" + if context_snippets: + sys_prompt += "\n\nRELEVANT CONTEXT FROM PAST EMAILS AND CONTACTS:\n" + "\n\n---\n\n".join(context_snippets[:5]) + try: + reply = await llm_call_async( + url=url, model=model, + messages=[ + {"role": "system", "content": sys_prompt}, + {"role": "user", "content": f"Original email:\nFrom: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\nDraft a reply. Return only the reply body text."}, + ], + temperature=0.7, max_tokens=16384, + headers=req_headers, timeout=240, + ) + reply = _apply_email_style_mechanics(_extract_reply(reply or "")) + if reply: + _c = _sql3.connect(SCHEDULED_DB) + _c.execute(""" + INSERT OR REPLACE INTO email_ai_replies + (message_id, uid, folder, reply, model_used, created_at) + VALUES (?, ?, 'INBOX', ?, ?, ?) + """, (message_id, uid.decode(), reply, model, datetime.utcnow().isoformat())) + _c.commit() + _c.close() + _reply_existing.add(message_id) + except Exception as e: + logger.warning(f"Auto-reply {uid} failed: {e}") + + # ── Calendar event extraction (independent of reply drafting) ── + if need_cal: + _cal_run_count = 0 + try: + # Pull a snapshot of upcoming events so the LLM can decide + # create vs update vs cancel based on what already exists. + from core.database import SessionLocal as _SL, CalendarEvent as _CE + _existing_summary = [] + try: + _db = _SL() + try: + from datetime import timedelta as _td2 + _horizon = datetime.utcnow() + _td2(days=60) + _evs = _db.query(_CE).filter( + _CE.dtstart >= datetime.utcnow(), + _CE.dtstart <= _horizon, + _CE.status != "cancelled", + ).order_by(_CE.dtstart).limit(40).all() + for _e in _evs: + _existing_summary.append({ + "uid": _e.uid, + "title": _e.summary or "", + "start": _e.dtstart.isoformat() if _e.dtstart else "", + }) + finally: + _db.close() + except Exception: + pass + existing_json = json.dumps(_existing_summary) + is_sent = _folder.lower().startswith("sent") or "sent" in _folder.lower() + cal_extract = await llm_call_async( + url=url, model=model, + messages=[ + {"role": "system", "content": ( + "You are a calendar assistant. The user receives emails AND sends replies " + "that may propose, confirm, change, or cancel events. " + "Decide what calendar operations are needed.\n\n" + "Return ONLY a JSON array. Each item has:\n" + ' "action": "create" | "update" | "cancel" | "noop"\n' + ' "uid": (only for update/cancel — use a uid from EXISTING_EVENTS below)\n' + ' "title": short descriptive title with WHO or WHAT (e.g. "Call with Sam", "Flight to Berlin", "Hotel check-in", "Dinner reservation")\n' + ' "date": ISO 8601 like "2026-04-25T14:00:00" (best guess if vague)\n' + ' "end_date": ISO 8601 or null\n' + ' "location": the MOST useful location — see types below.\n' + ' "description": 2-5 lines with context. Always include identifiers that will help the user later.\n\n' + "LOCATION by event type:\n" + "- Virtual meeting (Teams/Zoom/Meet/Webex): the full join URL.\n" + "- Flight: the departure airport code (e.g. 'NRT' or 'Narita Airport Terminal 1').\n" + "- Hotel: the hotel address or name + city.\n" + "- Restaurant/venue: the physical address if known, else the name.\n" + "- Train/bus: the station name.\n" + "- Medical/dental: the clinic name + address.\n" + "- Delivery: leave blank or 'Home address'.\n" + "- If no clear location, leave blank.\n\n" + "DESCRIPTION by event type — always preserve verbatim:\n" + "- Virtual meeting: meeting ID, passcode, phone dial-in.\n" + "- Flight: flight number, airline, confirmation/booking code, terminal, gate, seat.\n" + "- Hotel: confirmation number, check-in/check-out times, phone, room type.\n" + "- Restaurant: reservation name, party size, phone, booking reference.\n" + "- Train/bus: carrier, reservation code, platform, seat/car.\n" + "- Medical: doctor name, clinic phone, insurance details, prep notes.\n" + "- Concert/show: ticket URL, venue, seat, performer.\n" + "- Delivery: tracking number, carrier name, tracking URL.\n\n" + "Rules:\n" + "- If the email confirms / changes time of an event already in EXISTING_EVENTS, return action=update with that event's uid.\n" + "- If the email cancels a known event, return action=cancel with the uid.\n" + "- Otherwise, action=create with full details.\n" + "- PRESERVE identifiers (flight numbers, confirmation codes, tracking numbers, meeting IDs, passcodes, phone numbers) verbatim — do NOT paraphrase or drop them.\n" + "- If no event-related content at all, return [].\n" + "- No markdown fences, no prose, just the JSON array." + )}, + {"role": "user", "content": ( + f"EXISTING_EVENTS (next 60 days): {existing_json}\n\n" + f"EMAIL_FOLDER: {_folder} ({'sent by user' if is_sent else 'received'})\n" + f"From: {sender}\nSubject: {subject}\nDate: {msg.get('Date','')}\n\n" + f"{body[:4000]}" + )}, + ], + temperature=0.1, max_tokens=16384, + headers=req_headers, timeout=180, + ) + _raw_original = cal_extract or "" + cal_extract = _strip_think(_raw_original) + cal_extract = re.sub(r"^```(?:json)?\s*|\s*```$", "", cal_extract, flags=re.MULTILINE).strip() + if not cal_extract and _raw_original: + matches = list(re.finditer(r'\[\s*\{[^[\]]*?"action"[^[\]]*?\}\s*(?:,\s*\{[^[\]]*?\}\s*)*\]', _raw_original, re.DOTALL)) + if matches: + cal_extract = matches[-1].group() + logger.info(f"[cal-extract] uid={uid.decode() if isinstance(uid, bytes) else uid} folder={_folder} subj={subject[:50]!r} raw_len={len(cal_extract)} orig_len={len(_raw_original)} raw={cal_extract[:800]!r}") + jm = re.search(r'\[.*\]', cal_extract, re.DOTALL) + if jm: + try: + ops = json.loads(jm.group()) + logger.info(f"[cal-extract] parsed {len(ops)} op(s)") + if isinstance(ops, list) and ops: + from src.tool_implementations import do_manage_calendar + for op in ops[:3]: + action = (op.get("action") or "").lower() + if action == "noop": + continue + if action == "cancel": + cuid = op.get("uid") + if not cuid: + continue + r = await do_manage_calendar(json.dumps({"action": "delete_event", "uid": cuid})) + if r.get("exit_code", 0) == 0: + logger.info(f"[cal-extract] Cancelled event uid={cuid}") + _cal_run_count += 1 + else: + logger.warning(f"[cal-extract] cancel failed: {r.get('error')}") + elif action == "update": + cuid = op.get("uid") + if not cuid or not op.get("date"): + continue + args = {"action": "update_event", "uid": cuid, "dtstart": op["date"]} + if op.get("end_date"): args["dtend"] = op["end_date"] + if op.get("title"): args["summary"] = op["title"] + if op.get("description"): + args["description"] = f"[Updated from email] {op['description']} (from: {sender})" + r = await do_manage_calendar(json.dumps(args)) + if r.get("exit_code", 0) == 0: + logger.info(f"[cal-extract] Updated event uid={cuid} → {op.get('title')} {op['date']}") + _cal_run_count += 1 + else: + logger.warning(f"[cal-extract] update failed: {r.get('error')}") + else: # create (default) + if not op.get("title") or not op.get("date"): + continue + # Default duration: 1 hour if no end_date + _dtend = op.get("end_date") + if not _dtend: + try: + from datetime import timedelta as _td3 + _start_dt = datetime.fromisoformat(op["date"].replace("Z", "")) + _dtend = (_start_dt + _td3(hours=1)).isoformat() + except Exception: + _dtend = op["date"] + # Heuristic fallback: extract common details even if the LLM missed them + _loc = (op.get("location") or "").strip() + _base_desc = op.get("description", "") + _desc_parts = [f"[Auto-added from email] {_base_desc} (from: {sender})"] + try: + import re as _re + # 1) Virtual meeting links + _mtg_re = _re.compile(r"https?://(?:teams\.microsoft\.com|(?:[a-z0-9-]+\.)?zoom\.us|meet\.google\.com|(?:[a-z0-9-]+\.)?webex\.com|meet\.jit\.si)/[^\s]+", _re.I) + _mtg_links = _mtg_re.findall(body or "") + if _mtg_links and not _loc: + _loc = _mtg_links[0] + + # 2) Tracking URLs (delivery) + _track_re = _re.compile(r"https?://(?:www\.)?(?:amazon\.(?:com|co\.jp|co\.uk)/(?:gp/your-account/order|progress-tracker)|track\.[a-z0-9-]+\.(?:com|jp)|[a-z0-9-]*\.fedex\.com|[a-z0-9-]*\.ups\.com|[a-z0-9-]*\.dhl\.com|trackings\.post\.japanpost\.jp)[^\s]*", _re.I) + _track_links = _track_re.findall(body or "") + + _extra = [] + # 3) Identifiers: meeting ID, passcode, dial-in, confirmation, tracking, flight, gate, seat, PNR + _id_patterns = [ + r"(?:Meeting|会議)\s*ID[::]?\s*[\d\s]+", + r"(?:Passcode|パスコード|Password)[::]?\s*\S+", + r"Dial[-\s]?in[::]?\s*\+?[\d\s\-\(\)]+", + r"(?:Confirmation|Booking|Reservation|予約|確認)\s*(?:Number|Code|#|番号)[::]?\s*[A-Z0-9\-]+", + r"(?:Tracking|追跡)\s*(?:Number|Code|#)?[::]?\s*[A-Z0-9]{8,}", + r"(?:Flight|便)[::]?\s*[A-Z]{2}\s?\d{2,4}", + r"(?:Gate|ゲート)[::]?\s*[A-Z]?\d+", + r"(?:Seat|座席)[::]?\s*\d{1,3}[A-Z]?", + r"(?:Terminal|ターミナル)[::]?\s*\w+", + r"(?:PNR|Record\s*Locator)[::]?\s*[A-Z0-9]{6}", + r"(?:Check[-\s]?in|チェックイン)[::]?\s*\S+.*?(?:\d{1,2}:\d{2}|\d{4}-\d{2}-\d{2})", + ] + for _pat in _id_patterns: + for m in _re.finditer(_pat, body or "", _re.I): + snippet = m.group(0).strip() + if snippet and snippet not in _base_desc and snippet not in _extra: + _extra.append(snippet) + + # 4) Phone numbers + _phone_re = _re.compile(r"(?:Phone|Tel|TEL|電話)[::]?\s*(\+?[\d\s\-\(\)]{8,20})", _re.I) + for m in _phone_re.finditer(body or ""): + phone = m.group(0).strip() + if phone not in _base_desc and phone not in _extra: + _extra.append(phone) + + if _extra: + _desc_parts.append("\n".join(_extra)) + # Include extra virtual meeting URLs in description + for _lnk in _mtg_links[1:]: + _desc_parts.append(_lnk) + # Include tracking URLs in description (and use as location fallback for deliveries) + for _lnk in _track_links: + _desc_parts.append(_lnk) + except Exception: + pass + cal_args = json.dumps({ + "action": "create_event", + "summary": op["title"], + "dtstart": op["date"], + "dtend": _dtend, + "location": _loc, + "description": "\n\n".join(filter(None, _desc_parts)), + }) + r = await do_manage_calendar(cal_args) + if r.get("exit_code", 0) == 0: + logger.info(f"[cal-extract] Created event: {op['title']} on {op['date']}") + _events_created += 1 + _cal_run_count += 1 + else: + logger.warning(f"[cal-extract] create failed: {r.get('error')} args={cal_args[:200]}") + except Exception as je: + logger.warning(f"[cal-extract] JSON parse failed: {je} on raw={cal_extract[:200]!r}") + except Exception as e: + logger.warning(f"[cal-extract] Meeting extraction LLM call failed for uid={uid}: {e}") + # Record we processed this email so we don't re-LLM next run + try: + _cc = _sql3.connect(SCHEDULED_DB) + _cc.execute( + "INSERT OR REPLACE INTO email_calendar_extractions " + "(message_id, uid, events_created, created_at) VALUES (?, ?, ?, ?)", + (message_id, uid.decode() if isinstance(uid, bytes) else str(uid), + _cal_run_count, datetime.utcnow().isoformat()) + ) + _cc.commit() + _cc.close() + _cal_existing.add(message_id) + except Exception as ce: + logger.debug(f"Could not cache calendar extraction: {ce}") + + if need_urgent: + try: + urg_sys = ( + "You are triaging incoming email for URGENCY only. " + "Return ONLY a JSON object: {\"urgency\": \"critical\"|\"high\"|\"medium\"|\"low\"|\"none\", \"reason\": \"one sentence\"}.\n\n" + "Urgency levels:\n" + "- critical: action required within 24 hours or financial/legal penalty/security risk. " + "Examples: payment due today/tomorrow, security breach, court summons, flight cancellation, " + "wire transfer request, document must be signed today.\n" + "- high: action required within 3 days, or important stakeholder waiting on the user.\n" + "- medium: reply/action expected this week.\n" + "- low: routine communication, newsletter, notification.\n" + "- none: not actionable (promotional, automated, already handled).\n\n" + "IGNORE marketing urgency ('Limited time offer!'), newsletter clickbait, " + "and phishing-style fake urgency. Real urgency comes from people the user " + "actually does business with. Be strict — only mark critical/high when genuinely needed." + ) + tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens" + payload = { + "model": model, + "messages": [ + {"role": "system", "content": urg_sys}, + {"role": "user", "content": ( + f"From: {sender}\nSubject: {subject}\nDate: {msg.get('Date','')}\n\n" + f"{body[:3000]}" + )}, + ], + "temperature": 0, + tok_key: 200, + } + urg_raw = await llm_call_async( + url=url, model=model, messages=payload["messages"], + temperature=0, max_tokens=200, headers=req_headers, timeout=60, + ) + urg_raw = _strip_think(urg_raw or "") + urg_raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", urg_raw, flags=re.MULTILINE).strip() + jm = re.search(r'\{.*\}', urg_raw, re.DOTALL) + if jm: + urg_obj = json.loads(jm.group()) + urgency = (urg_obj.get("urgency") or "none").lower() + reason = urg_obj.get("reason") or "" + logger.info(f"[urgency] uid={uid} level={urgency} reason={reason[:80]}") + + # Record immediately so we don't re-alert + try: + _uc = _sql3.connect(SCHEDULED_DB) + _uc.execute( + "INSERT OR REPLACE INTO email_urgency_alerts " + "(message_id, uid, folder, subject, sender, urgency, reason, alerted, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (message_id, uid.decode() if isinstance(uid, bytes) else str(uid), + _folder, subject, sender, urgency, reason, + 1 if urgency in ("critical", "high") else 0, + datetime.utcnow().isoformat()) + ) + _uc.commit() + _uc.close() + _urgent_existing.add(message_id) + except Exception as ue: + logger.debug(f"Could not cache urgency: {ue}") + + # Send alert email immediately if critical or high + if urgency in ("critical", "high"): + try: + cfg = _get_email_config(account_id) + to_addr = cfg["from_address"] # self-email + + # Deep-link to open the original email in Odysseus (if public URL is configured). + # Hash format `#email=FOLDER:UID` is handled by static/js/emailInbox.js:_maybeOpenFromHash. + from src.settings import load_settings as _ls + _pub = (_ls().get("app_public_url") or "").rstrip("/") + uid_str = uid.decode() if isinstance(uid, bytes) else str(uid) + from urllib.parse import quote as _q + open_url = f"{_pub}/#email={_q(_folder, safe='')}:{uid_str}" if _pub else "" + + alert_subject = f"[{urgency.upper()}] {subject}" + alert_body = ( + f"Your AI assistant flagged this email as {urgency.upper()} urgency.\n\n" + f"Reason: {reason}\n\n" + + (f"Open in Odysseus: {open_url}\n\n" if open_url else "") + + f"---\n" + f"From: {sender}\n" + f"Subject: {subject}\n" + f"Date: {msg.get('Date','')}\n\n" + f"{body[:800]}" + + ("..." if len(body or "") > 800 else "") + ) + # HTML alternative with a clickable "Open in Odysseus" button + import html as _h + body_excerpt = _h.escape((body or "")[:800]) + open_html = ( + f'

' + 'Open in Odysseus

' + ) if open_url else "" + alert_html = ( + f'
' + f'

{urgency.upper()} urgency — your AI assistant flagged this email.

' + f'

Reason: {_h.escape(reason)}

' + f'{open_html}' + f'
' + f'

' + f'From: {_h.escape(sender)}
' + f'Subject: {_h.escape(subject)}
' + f'Date: {_h.escape(msg.get("Date",""))}' + f'

' + f'
{body_excerpt}'
+                                        + ("..." if len(body or "") > 800 else "")
+                                        + "
" + ) + + outer_alert = MIMEMultipart("alternative") + outer_alert["From"] = cfg["from_address"] + outer_alert["To"] = to_addr + outer_alert["Subject"] = alert_subject + outer_alert["Date"] = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000") + outer_alert["X-Priority"] = "1" + outer_alert["Importance"] = "high" + outer_alert.attach(MIMEText(alert_body, "plain", "utf-8")) + outer_alert.attach(MIMEText(alert_html, "html", "utf-8")) + _send_smtp_message(cfg, cfg["from_address"], [to_addr], outer_alert.as_string()) + logger.info(f"[urgency] Sent {urgency} alert email for: {subject!r}") + except Exception as alert_err: + logger.error(f"[urgency] Failed to send alert email: {alert_err}") + except Exception as e: + logger.warning(f"[urgency] Check failed for uid={uid}: {e}") + + if need_class: + try: + class_sys = ( + "Classify the email. Return ONLY a JSON object, no prose, no markdown fences. " + "Schema: {\"tags\": [\"tag1\"], \"spam\": false, \"reason\": \"short\"}. " + "Pick 1-2 tags from: work, personal, finance, bills, receipt, travel, " + "newsletter, promo, notification, security, social, shopping, calendar.\n\n" + "Set spam=true for ANY of:\n" + "- Phishing, scams, chain mail, deceptive offers\n" + "- Marketing/promotional blasts (\"special offer\", \"limited time\", discount codes)\n" + "- Generic monthly/weekly newsletters from businesses (bank updates, service updates, industry digests)\n" + "- Bulk announcements with no personal action required\n" + "- Cold sales outreach\n\n" + "NOT spam:\n" + "- Actual receipts/invoices/bills addressed to the user\n" + "- Security alerts about the user's own accounts (login, password reset)\n" + "- Shipping notifications for orders the user placed\n" + "- Direct personal correspondence\n" + "- Booking confirmations\n" + "- Calendar invites / meeting links\n\n" + "If it's a mass-mailed generic update with no personal CTA, mark spam=true even if from a legitimate service. " + "Reason should be 5-10 words." + ) + tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens" + payload = { + "model": model, + "messages": [ + {"role": "system", "content": class_sys}, + {"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body[:4000]}"}, + ], + tok_key: 512, + "temperature": 0.1, + "stream": False, + } + # to_thread keeps the event loop responsive during the LLM call + resp = await asyncio.to_thread( + _req.post, url, json=payload, headers=req_headers, timeout=120 + ) + if not resp.ok: + logger.warning(f"Auto-classify {uid.decode()} HTTP {resp.status_code}: {resp.text[:200]}") + else: + rdata = resp.json() + m = (rdata.get("choices") or [{}])[0].get("message", {}) + raw_out = (m.get("content") or "").strip() + raw_out = _strip_think(raw_out) + raw_out = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw_out, flags=re.MULTILINE).strip() + jm = re.search(r'\{.*\}', raw_out, re.DOTALL) + parsed = None + if jm: + try: + parsed = json.loads(jm.group(0)) + except Exception: + parsed = None + if parsed is not None: + _ALLOWED_TAGS = {"work","personal","finance","bills","receipt","travel", + "newsletter","marketing","notification","security","social", + "shopping","calendar"} + raw_tags = parsed.get("tags") or [] + if isinstance(raw_tags, str): + raw_tags = [raw_tags] + tags = [t.strip().lower().replace("_", "-") for t in raw_tags if isinstance(t, str)] + tags = ["marketing" if t == "promo" else t for t in tags] + tags = [t for t in tags if t in _ALLOWED_TAGS][:2] + is_spam = bool(parsed.get("spam")) + spam_reason = str(parsed.get("reason") or "")[:200] + + moved_to = "" + if is_spam and auto_spam and spam_folder: + if _imap_move(uid, spam_folder): + moved_to = spam_folder + logger.info(f"Auto-spam moved uid={uid.decode()} to {spam_folder}: {spam_reason}") + + _c = _sql3.connect(SCHEDULED_DB) + _c.execute(""" + INSERT OR REPLACE INTO email_tags + (message_id, uid, folder, subject, sender, tags, spam_verdict, + spam_reason, moved_to, model_used, created_at) + VALUES (?, ?, 'INBOX', ?, ?, ?, ?, ?, ?, ?, ?) + """, (message_id, uid.decode(), subject, sender, + json.dumps(tags), 1 if is_spam else 0, + spam_reason, moved_to, model, datetime.utcnow().isoformat())) + _c.commit() + _c.close() + _tag_existing.add(message_id) + except Exception as e: + logger.warning(f"Auto-classify {uid} failed: {e}") + + processed += 1 + await asyncio.sleep(1) + except Exception as e: + logger.warning(f"Auto-process {uid} failed: {e}") + continue + + conn.logout() + if processed > 0: + logger.info(f"Auto-processed {processed} new email(s) for summary/reply/classify") + # Build a clear status message + ops = [] + if auto_sum: ops.append("summary") + if auto_reply: ops.append("reply") + if auto_tag: ops.append("tag") + if auto_spam: ops.append("spam") + ops_label = "/".join(ops) or "none" + parts = [f"Scanned {len(uid_list)} email(s) ({ops_label})"] + if processed: + parts.append(f"processed {processed} new") + if already_cached: + parts.append(f"{already_cached} already cached") + if too_short: + parts.append(f"{too_short} too short to process") + if no_msgid: + parts.append(f"{no_msgid} missing Message-ID") + if _events_created: + parts.append(f"created {_events_created} calendar event(s)") + if processed == 0 and already_cached == 0 and too_short == 0: + parts.append("nothing to do") + return " · ".join(parts) + except Exception as e: + logger.warning(f"Auto-summarize pass error: {e}") + return f"Error: {e}" + + +async def _auto_summarize_poller(): + """Background loop kept for backward compatibility — calls _auto_summarize_pass every 60s. + Newer setups should use scheduled tasks instead (summarize_emails, draft_email_replies).""" + import asyncio as _asyncio + while True: + try: + await _asyncio.sleep(1800) + await _auto_summarize_pass() + except Exception as e: + logger.error(f"Auto-summarize poller crash: {e}") + + +def _scheduled_poll_once() -> dict: + """One pass of the scheduled-email queue: pick up any rows whose + `send_at` is past, deliver via SMTP, append to Sent, update status. + Returns a small summary dict — useful for the CLI wrapper. Safe to + invoke from a cron job (single-shot) or the long-running poller. + """ + import sqlite3 + sent = [] + failed = [] + try: + now_iso = datetime.utcnow().isoformat() + conn = sqlite3.connect(SCHEDULED_DB) + cols = [row[1] for row in conn.execute("PRAGMA table_info(scheduled_emails)").fetchall()] + kind_expr = "odysseus_kind" if "odysseus_kind" in cols else "'scheduled' AS odysseus_kind" + rows = conn.execute(f""" + SELECT id, to_addr, cc, bcc, subject, body, in_reply_to, references_hdr, attachments, account_id, {kind_expr} + FROM scheduled_emails + WHERE status = 'pending' AND send_at <= ? + """, (now_iso,)).fetchall() + conn.close() + + for r in rows: + sid = r[0] + try: + attachments = json.loads(r[8] or "[]") + row_account_id = r[9] if len(r) > 9 else None + odysseus_kind = r[10] if len(r) > 10 else "scheduled" + cfg = _get_email_config(row_account_id) + has_atts = bool(attachments) + if has_atts: + outer = MIMEMultipart("mixed") + body_container = MIMEMultipart("alternative") + else: + outer = MIMEMultipart("alternative") + body_container = outer + outer["From"] = cfg["from_address"] + outer["To"] = r[1] + if r[2]: + outer["Cc"] = r[2] + outer["Subject"] = r[4] or "" + outer["Date"] = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000") + outer["X-Odysseus-Origin"] = "odysseus-ui" + outer["X-Odysseus-Kind"] = re.sub(r"[^A-Za-z0-9_.-]", "-", odysseus_kind or "scheduled")[:64] + outer["X-Odysseus-Ref"] = sid + if r[6]: + outer["In-Reply-To"] = r[6] + if r[7]: + outer["References"] = r[7] + body_container.attach(MIMEText(r[5] or "", "plain", "utf-8")) + html_body = html.escape(r[5] or "").replace("\n", "
\n") + body_container.attach(MIMEText(f"{html_body}", "html", "utf-8")) + if has_atts: + outer.attach(body_container) + _attach_compose_uploads(outer, attachments) + recipients = [a.strip() for a in (r[1] or "").split(",") if a.strip()] + if r[2]: + recipients.extend([a.strip() for a in r[2].split(",") if a.strip()]) + if r[3]: + recipients.extend([a.strip() for a in r[3].split(",") if a.strip()]) + + _send_smtp_message(cfg, cfg["from_address"], recipients, outer.as_string()) + + # Append to local Sent folder + try: + with _imap() as imap: + sent_folder = _detect_sent_folder(imap) + imap.append(sent_folder, "\\Seen", None, outer.as_bytes()) + except Exception as e: + logger.warning(f"Failed to append scheduled {sid} to Sent: {e}") + + _cleanup_compose_uploads(attachments) + + conn2 = sqlite3.connect(SCHEDULED_DB) + conn2.execute("UPDATE scheduled_emails SET status='sent' WHERE id=?", (sid,)) + conn2.commit() + conn2.close() + logger.info(f"Sent scheduled email {sid}") + sent.append(sid) + except Exception as e: + logger.error(f"Failed to send scheduled {sid}: {e}") + conn2 = sqlite3.connect(SCHEDULED_DB) + conn2.execute("UPDATE scheduled_emails SET status='failed', error=? WHERE id=?", (str(e), sid)) + conn2.commit() + conn2.close() + failed.append({"id": sid, "error": str(e)}) + except Exception as e: + logger.error(f"Scheduled poller error: {e}") + return {"sent": sent, "failed": failed, "error": str(e)} + return {"sent": sent, "failed": failed} + + +async def _scheduled_email_poller(): + """Background task that checks for due scheduled emails every 30 + seconds. Each tick delegates to `_scheduled_poll_once`, which is + also exposed via the `odysseus-mail poll-scheduled` CLI for + cron-driven deployments.""" + import asyncio + + while True: + try: + await asyncio.sleep(30) + await asyncio.to_thread(_scheduled_poll_once) + except Exception as e: + logger.error(f"Scheduled poller error: {e}") + + +_poller_task = None +_summarize_task = None + +def _inprocess_pollers_enabled() -> bool: + """Honour `ODYSSEUS_INPROCESS_POLLERS` — set to `0`/`false`/`no`/`off` + to disable the asyncio tasks so a cron / systemd-timer setup driving + `odysseus-mail poll-scheduled` is the sole external driver. The legacy + auto-summary/reply poller no longer starts here; scheduled Tasks own that + work so Email settings are only feature gates, not a second scheduler.""" + import os + raw = os.environ.get("ODYSSEUS_INPROCESS_POLLERS", "1").strip().lower() + return raw not in ("0", "false", "no", "off", "") + + +def _start_poller(): + """Start background pollers. Called at module load; if no event loop is + running yet (common at import time), defer via a first-request hook. + + Skipped entirely when `ODYSSEUS_INPROCESS_POLLERS=0` — use that when + you're driving polling from cron / systemd to avoid two copies of + `_scheduled_poll_once` racing on the same SQLite.""" + if not _inprocess_pollers_enabled(): + logger.info( + "In-process email pollers disabled (ODYSSEUS_INPROCESS_POLLERS=0); " + "drive `odysseus-mail poll-scheduled` externally." + ) + return + import asyncio + + def _launch(): + global _poller_task, _summarize_task + loop = asyncio.get_running_loop() + if _poller_task is None: + _poller_task = loop.create_task(_scheduled_email_poller()) + logger.info("Started scheduled email poller") + _summarize_task = None + + try: + _launch() + except RuntimeError: + # No running loop yet (import-time call). Retry on first request + # by registering a one-shot startup coroutine. + import threading + _started = threading.Event() + + async def _deferred_start(): + if _started.is_set(): + return + _started.set() + _launch() + + # Store for the router lifespan / first-request hook + _start_poller._deferred = _deferred_start diff --git a/routes/email_routes.py b/routes/email_routes.py new file mode 100644 index 0000000..f452651 --- /dev/null +++ b/routes/email_routes.py @@ -0,0 +1,3038 @@ +""" +email_routes.py + +FastAPI route handlers for the email feature. All non-route logic +(IMAP connection helpers, message parsing, account config, the +auto-summarize + scheduled-email pollers, Pydantic models) lives in: + + routes/email_helpers.py — synchronous helpers + models + constants + routes/email_pollers.py — background loops, started by `_start_poller` + +Importing from the helpers module brings in everything those route +handlers need. The split is mechanical — no behavior change. +""" + +import asyncio +import sqlite3 as _sql3 +import email as email_mod +import email.header +import email.utils +import imaplib +import smtplib +import json +import re +import html +from html.parser import HTMLParser as _HTMLParser +import logging +import uuid +from datetime import datetime +from pathlib import Path + +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request +from fastapi.responses import FileResponse + +from src.llm_core import llm_call_async + +from routes.email_helpers import ( + _strip_think, _extract_reply, _apply_email_style_mechanics, require_owner, require_user, _assert_owns_account, + _q, _attach_compose_uploads, _cleanup_compose_uploads, + _load_settings, _save_settings, _get_email_config, + _send_smtp_message, + _imap_connect, _imap, _decode_header, _detect_sent_folder, _detect_drafts_folder, + _extract_attachment_text, _list_attachments_from_msg, + _extract_attachment_to_disk, _extract_html, _extract_text, + _fetch_sender_thread_context, _pre_retrieve_context, + _EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS, + SendEmailRequest, ExtractStyleRequest, + ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB, +) +from routes.email_pollers import _start_poller + +logger = logging.getLogger(__name__) + +ODYSSEUS_MAIL_ORIGIN = "odysseus-ui" + + +def _email_tag_owner_aliases(account_id: str | None, owner: str = "") -> list[str]: + aliases = [owner or ""] + try: + from core.database import SessionLocal as _SL, EmailAccount as _EA + db = _SL() + try: + resolved_account_id = account_id + if not resolved_account_id: + try: + cfg = _get_email_config(None, owner=owner) + resolved_account_id = cfg.get("account_id") or None + aliases.extend([ + cfg.get("imap_user") or "", + cfg.get("smtp_user") or "", + cfg.get("from_address") or "", + ]) + except Exception: + resolved_account_id = None + row = db.get(_EA, resolved_account_id) if resolved_account_id else None + if row: + aliases.extend([row.owner or "", row.imap_user or "", row.from_address or ""]) + finally: + db.close() + except Exception: + pass + out = [] + for a in aliases: + a = (a or "").strip() + if a not in out: + out.append(a) + return out or [""] + + +def _record_email_received_events(owner: str, account_id: str | None, folder: str, emails: list[dict]): + """Baseline inbox messages, then fire `email_received` for new arrivals.""" + if not owner or (folder or "INBOX").upper() != "INBOX" or not emails: + return + try: + from src.event_bus import fire_event + account_key = (account_id or "default").strip() or "default" + now = datetime.utcnow().isoformat() + "Z" + keys = [] + for e in emails: + key = (e.get("message_id") or e.get("uid") or "").strip() + if key and key not in keys: + keys.append(key) + if not keys: + return + + conn = _sql3.connect(SCHEDULED_DB) + try: + conn.execute( + "CREATE TABLE IF NOT EXISTS email_event_seen (" + "owner TEXT NOT NULL, account_key TEXT NOT NULL, folder TEXT NOT NULL, " + "message_key TEXT NOT NULL, first_seen_at TEXT NOT NULL, " + "PRIMARY KEY (owner, account_key, folder, message_key))" + ) + count = conn.execute( + "SELECT COUNT(*) FROM email_event_seen WHERE owner=? AND account_key=? AND folder=?", + (owner, account_key, folder), + ).fetchone()[0] + existing = set() + if count: + placeholders = ",".join("?" * len(keys)) + rows = conn.execute( + f"SELECT message_key FROM email_event_seen " + f"WHERE owner=? AND account_key=? AND folder=? AND message_key IN ({placeholders})", + (owner, account_key, folder, *keys), + ).fetchall() + existing = {r[0] for r in rows} + new_keys = [k for k in keys if k not in existing] + conn.executemany( + "INSERT OR IGNORE INTO email_event_seen " + "(owner, account_key, folder, message_key, first_seen_at) VALUES (?, ?, ?, ?, ?)", + [(owner, account_key, folder, k, now) for k in keys], + ) + conn.commit() + finally: + conn.close() + + if count and new_keys: + for _ in new_keys[:50]: + fire_event("email_received", owner) + logger.info("Fired email_received for %d new message(s)", min(len(new_keys), 50)) + except Exception: + logger.debug("email_received event detection skipped", exc_info=True) + + +def _folder_name_from_list_line(line) -> str | None: + decoded = line.decode() if isinstance(line, bytes) else str(line) + match = re.search(r'"([^"]*)"\s*$|(\S+)\s*$', decoded) + if not match: + return None + return match.group(1) or match.group(2) + + +def _list_imap_folders(conn) -> tuple[list, list[str]]: + try: + status, folders = conn.list() + if status != "OK" or not folders: + return [], [] + names = [name for name in (_folder_name_from_list_line(f) for f in folders) if name] + return folders, names + except Exception: + return [], [] + + +def _resolve_mail_folder(conn, preferred: str, role: str = "") -> str: + """Resolve provider-specific names such as Gmail's [Gmail]/Bin/Spam.""" + folders, names = _list_imap_folders(conn) + if preferred and preferred in names: + return preferred + role_flags = { + "trash": ("\\Trash",), + "archive": ("\\Archive", "\\All"), + "junk": ("\\Junk",), + }.get(role, ()) + for f in folders: + decoded = f.decode() if isinstance(f, bytes) else str(f) + if any(flag in decoded for flag in role_flags): + name = _folder_name_from_list_line(f) + if name: + return name + candidates = { + "trash": ("Trash", "[Gmail]/Trash", "[Google Mail]/Trash", "Bin", "[Gmail]/Bin", "Deleted Messages", "Deleted Items"), + "archive": ("Archive", "Archives", "[Gmail]/All Mail", "[Google Mail]/All Mail", "All Mail"), + "junk": ("Junk", "Spam", "[Gmail]/Spam", "[Google Mail]/Spam"), + }.get(role, ()) + lower_map = {n.lower(): n for n in names} + for candidate in candidates: + found = lower_map.get(candidate.lower()) + if found: + return found + return preferred + + +def _folder_role_from_name(name: str) -> str: + lower = (name or "").lower() + if "trash" in lower or "bin" in lower or "deleted" in lower: + return "trash" + if "spam" in lower or "junk" in lower: + return "junk" + if "archive" in lower or "all mail" in lower: + return "archive" + return "" + + +def _uid_bytes(uid: str | bytes) -> bytes: + return uid if isinstance(uid, bytes) else str(uid).encode() + + +def _uid_exists(conn, uid: str) -> bool: + try: + status, data = conn.uid("FETCH", _uid_bytes(uid), "(UID)") + if status != "OK": + return False + for part in data or []: + meta = part[0] if isinstance(part, tuple) else part + meta_b = meta if isinstance(meta, bytes) else str(meta).encode() + if re.search(rb"\bUID\s+\d+\b", meta_b): + return True + return False + except Exception: + return False + + +def _smtp_ready(cfg: dict) -> bool: + return bool(cfg.get("smtp_host") and cfg.get("smtp_user") and cfg.get("smtp_password")) + + +def _resolve_send_config(account_id: str | None = None, owner: str = "") -> dict: + """Resolve an account for outbound SMTP. + + If the caller explicitly picked an account, use only that account and + return a clear error when it cannot send. If no account was picked and + the default is receive-only, fall back to the first SMTP-capable account + owned by the same user. + """ + cfg = _get_email_config(account_id, owner=owner) + if _smtp_ready(cfg): + return cfg + if account_id: + raise ValueError(f"Email account {cfg.get('account_name') or account_id} has no SMTP configured") + try: + from core.database import SessionLocal as _SL, EmailAccount as _EA + from sqlalchemy import and_, or_ + db = _SL() + try: + q = db.query(_EA).filter(_EA.enabled == True) # noqa: E712 + if owner: + unowned = or_(_EA.owner == None, _EA.owner == "") # noqa: E711 + same_mailbox = or_(_EA.imap_user == owner, _EA.from_address == owner) + q = q.filter(or_(_EA.owner == owner, and_(unowned, same_mailbox))) + for row in q.order_by(_EA.is_default.desc(), _EA.created_at.asc()).all(): + trial = _get_email_config(account_id=row.id, owner=owner) + if _smtp_ready(trial): + return trial + finally: + db.close() + except Exception as e: + logger.debug(f"SMTP-capable account fallback failed: {e}") + raise ValueError("No SMTP-capable email account configured") + + +def _store_email_flag(conn, uid: str, flag: str, add: bool = True) -> bool: + op = "+FLAGS" if add else "-FLAGS" + if _uid_exists(conn, uid): + status, _ = conn.uid("STORE", _uid_bytes(uid), op, flag) + else: + status, _ = conn.store(_uid_bytes(uid), op, flag) + return status == "OK" + + +def _move_email_message(conn, uid: str, dest: str, role: str = "") -> bool: + dest = _resolve_mail_folder(conn, dest, role or _folder_role_from_name(dest)) + if _uid_exists(conn, uid): + status, _ = conn.uid("MOVE", _uid_bytes(uid), _q(dest)) + if status == "OK": + return True + status, _ = conn.uid("COPY", _uid_bytes(uid), _q(dest)) + if status != "OK": + return False + status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Deleted") + else: + status, _ = conn.copy(_uid_bytes(uid), _q(dest)) + if status != "OK": + return False + status, _ = conn.store(_uid_bytes(uid), "+FLAGS", "\\Deleted") + if status == "OK": + conn.expunge() + return True + return False + + +def _apply_odysseus_headers(msg, kind: str | None = None, ref_id: str | None = None): + msg["X-Odysseus-Origin"] = ODYSSEUS_MAIL_ORIGIN + if kind: + msg["X-Odysseus-Kind"] = re.sub(r"[^A-Za-z0-9_.-]", "-", kind)[:64] + if ref_id: + msg["X-Odysseus-Ref"] = re.sub(r"[^A-Za-z0-9_.:-]", "-", ref_id)[:128] + + +def _md_to_email_html(text: str) -> str: + """Render the compose markdown body to a SAFE HTML fragment for the email's + text/html part. Everything is HTML-escaped FIRST (so a pasted + + +""" + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def _category_css(category: Optional[str]) -> str: + if not category: + return "" + # Per-category palette overrides — applied BEFORE the structural rules so + # everything that reads --accent / --aurora-* automatically retints. The + # default (no category) keeps the warm terracotta defined in :root. + palettes = """ +/* ── Category palettes ─────────────────────────────────── + Override the accent + aurora vars per category so each report + type has a distinct visual identity. */ +body.category-product { + --accent: #2a8a8c; + --accent-light: #4ab0b2; + --accent-bg: rgba(42,138,140,0.07); + --aurora-a: rgba(42,138,140,0.11); + --aurora-b: rgba(201,149,46,0.06); + --aurora-c: rgba(64,98,128,0.06); +} +body.category-comparison { + --accent: #7a4cb8; + --accent-light: #9d76d0; + --accent-bg: rgba(122,76,184,0.07); + --aurora-a: rgba(122,76,184,0.11); + --aurora-b: rgba(184,84,58,0.05); + --aurora-c: rgba(64,98,128,0.07); +} +body.category-howto { + --accent: #3d8a3d; + --accent-light: #62b162; + --accent-bg: rgba(61,138,61,0.07); + --aurora-a: rgba(61,138,61,0.11); + --aurora-b: rgba(201,149,46,0.07); + --aurora-c: rgba(42,138,140,0.05); +} +body.category-landscape { + --accent: #b88a2e; + --accent-light: #d4a955; + --accent-bg: rgba(184,138,46,0.08); + --aurora-a: rgba(184,138,46,0.13); + --aurora-b: rgba(184,84,58,0.06); + --aurora-c: rgba(122,76,184,0.05); +} +@media (prefers-color-scheme: dark) { + body.category-product { + --accent: #5cc8cb; --accent-light: #8fdde0; + --accent-bg: rgba(92,200,203,0.10); + --aurora-a: rgba(92,200,203,0.13); + --aurora-b: rgba(232,192,90,0.07); + --aurora-c: rgba(125,180,224,0.08); + } + body.category-comparison { + --accent: #b896e8; --accent-light: #d0b8f0; + --accent-bg: rgba(184,150,232,0.10); + --aurora-a: rgba(184,150,232,0.13); + --aurora-b: rgba(232,143,115,0.06); + --aurora-c: rgba(125,180,224,0.08); + } + body.category-howto { + --accent: #82c882; --accent-light: #a8dba8; + --accent-bg: rgba(130,200,130,0.09); + --aurora-a: rgba(130,200,130,0.12); + --aurora-b: rgba(232,192,90,0.07); + --aurora-c: rgba(92,200,203,0.07); + } + body.category-landscape { + --accent: #e6c069; --accent-light: #f0d390; + --accent-bg: rgba(230,192,105,0.10); + --aurora-a: rgba(230,192,105,0.15); + --aurora-b: rgba(232,143,115,0.07); + --aurora-c: rgba(184,150,232,0.06); + } +} + +/* ── Per-category font pairings ─────────────────────── + Body font shifts between serif (long-form categories) and sans + (practical/data categories) so each report reads as a different + publication, not just a re-tinted version of the same template. */ + +/* Long-form: literary serif for both display and body */ +body:not([class*="category-"]), +body.category-landscape { + --font-body: 'Source Serif 4', 'Iowan Old Style', Georgia, serif; +} + +/* Comparison: analytical serif display + clean sans body */ +body.category-comparison { + --font-display: 'Playfair Display', Georgia, serif; + --font-body: 'Inter', system-ui, sans-serif; +} + +/* How-to: friendly geometric sans, top to bottom */ +body.category-howto { + --font-display: 'Manrope', system-ui, sans-serif; + --font-body: 'Inter', system-ui, sans-serif; +} + +/* Product: techy/engineery — IBM Plex Sans display + Inter body */ +body.category-product { + --font-display: 'IBM Plex Sans', system-ui, sans-serif; + --font-body: 'Inter', system-ui, sans-serif; +} + +/* Source Serif sits visually larger than Inter at the same px — pull it + back one notch for the categories that use it as body so line length + and rhythm stay comparable across categories. */ +body:not([class*="category-"]) body, /* no-op selector, kept for clarity */ +body.category-landscape { font-size: 16.5px; } + +/* Drop cap looks bad on geometric sans — kill it for those categories */ +body.category-product .content > p:first-of-type::first-letter, +body.category-howto .content > p:first-of-type::first-letter, +body.category-comparison .content > p:first-of-type::first-letter, +body.category-product .content > h2:first-child + p::first-letter, +body.category-howto .content > h2:first-child + p::first-letter, +body.category-comparison .content > h2:first-child + p::first-letter { + font-size: 1em; float: none; margin: 0; color: inherit; + font-family: inherit; font-weight: inherit; +} + +/* ── Per-category background effects ─────────────── + Each category overrides body::before so the page reads as a + distinctly-textured surface. Aurora stays the default. */ + +/* Product → blueprint grid that slowly pans */ +body.category-product::before { + background: + linear-gradient(to right, var(--aurora-a) 1px, transparent 1px), + linear-gradient(to bottom, var(--aurora-a) 1px, transparent 1px), + radial-gradient(70vw 60vh at 50% 50%, var(--aurora-a) 0%, transparent 75%); + background-size: 56px 56px, 56px 56px, 100% 100%; + filter: none; + animation: cat-grid-pan 60s linear infinite; +} +@keyframes cat-grid-pan { + to { background-position: 56px 56px, 56px 56px, 0 0; } +} + +/* Comparison → dot grid + slow opacity pulse */ +body.category-comparison::before { + background: + radial-gradient(circle, var(--aurora-a) 1.4px, transparent 1.8px), + radial-gradient(60vw 55vh at 25% 25%, var(--aurora-b) 0%, transparent 65%), + radial-gradient(60vw 55vh at 75% 75%, var(--aurora-c) 0%, transparent 65%); + background-size: 26px 26px, 100% 100%, 100% 100%; + filter: none; + animation: cat-dot-pulse 14s ease-in-out infinite alternate; +} +@keyframes cat-dot-pulse { + from { opacity: 0.65; } + to { opacity: 1; } +} + +/* How-to → flat surface with a very subtle vignette. Drop the flow-lines + pattern — it competes visually with the step number rails on the + right-hand side of each H2. The reading should feel like an O'Reilly + procedure: clean, scannable, no decoration in the way. */ +body.category-howto::before { + background: + radial-gradient(70vw 70vh at 50% 0%, var(--aurora-a) 0%, transparent 60%), + radial-gradient(50vw 50vh at 50% 100%, var(--aurora-b) 0%, transparent 65%); + filter: blur(40px); + animation: none; +} + +/* Landscape → horizontal horizon bands that slowly shift sideways */ +body.category-landscape::before { + background: + linear-gradient( + 180deg, + transparent 0%, + var(--aurora-a) 22%, + transparent 35%, + var(--aurora-b) 55%, + transparent 68%, + var(--aurora-c) 85%, + transparent 100% + ); + background-size: 100% 200%; + filter: blur(40px); + animation: cat-horizon-drift 36s ease-in-out infinite alternate; +} +@keyframes cat-horizon-drift { + 0% { background-position: 0 0; } + 100% { background-position: 0 100%; } +} + +@media (prefers-reduced-motion: reduce) { + body.category-product::before, + body.category-comparison::before, + body.category-howto::before, + body.category-landscape::before { + animation: none; + } +} + +/* ───────────────────────────────────────────────────── + PER-CATEGORY STRUCTURAL TREATMENTS + Each category gets distinctive structural CSS so the page + reads as a different publication — not just retinted. + ───────────────────────────────────────────────────── */ + +/* ── HOWTO: O'Reilly-style numbered procedure ─────── */ +body.category-howto .content { counter-reset: howto-step; } +body.category-howto .content h2 { + counter-increment: howto-step; + display: flex; align-items: center; gap: 14px; + border-bottom: none; + padding-left: 0; + margin-top: 3.5rem; +} +body.category-howto .content h2::before { + content: counter(howto-step); + display: inline-flex; align-items: center; justify-content: center; + flex-shrink: 0; + width: 40px; height: 40px; + border-radius: 12px; + background: var(--accent); + color: #fff; + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 700; + letter-spacing: 0; + box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent); +} +/* Step body gets a colored left rail so you can scan "this is step 1's stuff" */ +body.category-howto .content h2 ~ p, +body.category-howto .content h2 ~ ul, +body.category-howto .content h2 ~ ol, +body.category-howto .content h2 ~ pre, +body.category-howto .content h2 ~ blockquote { + border-left: 2px solid color-mix(in srgb, var(--accent) 25%, transparent); + padding-left: 1rem; + margin-left: 4px; +} +body.category-howto .content h2:has(+ *) ~ h2 ~ * { border-left: none; padding-left: 0; margin-left: 0; } +/* Terminal-style code blocks — green $ prompt, monospaced, dark surface */ +body.category-howto .content pre { + background: #1a1a1e; + color: #d4e4d4; + border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); + border-radius: 8px; + position: relative; + padding-left: 2.6rem; +} +body.category-howto .content pre::before { + content: '$'; + position: absolute; + left: 1.1rem; top: 1.15rem; + color: var(--accent); + font-family: var(--font-mono); + font-weight: 700; + font-size: 0.86rem; + opacity: 0.85; +} +body.category-howto .content pre code { color: inherit; } + +/* ── LANDSCAPE: editorial briefing with H3 player cards ─ */ +body.category-landscape .content h3 { + /* Each H3 in landscape = a "player" in the field — give it a card frame */ + margin-top: 2.5rem; + padding: 14px 18px 4px; + border-left: 3px solid var(--accent); + background: color-mix(in srgb, var(--accent) 4%, transparent); + border-radius: 0 8px 8px 0; + font-family: var(--font-display); + font-size: 1.18rem; +} +body.category-landscape .content h3 + p { + margin-top: 0; + padding: 0 18px 14px; + background: color-mix(in srgb, var(--accent) 4%, transparent); + border-left: 3px solid var(--accent); + margin-left: 0; + border-radius: 0 0 8px 0; +} +/* Pull-quote treatment for any standalone blockquote */ +body.category-landscape .content blockquote { + font-size: 1.2rem; + line-height: 1.5; + max-width: 90%; + margin: 2rem auto; + text-align: center; + border-left: none; + border-top: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); + background: transparent; + border-radius: 0; + padding: 1.5rem 1rem; + font-style: italic; +} +body.category-landscape .content blockquote::before { + display: none; +} + +/* ── COMPARISON: lab-report tables with winner badges ─ */ +body.category-comparison .content { + font-feature-settings: 'tnum' on, 'ss01'; /* tabular numerals for tables */ +} +body.category-comparison .content table { + font-size: 0.92rem; + box-shadow: 0 6px 20px rgba(0,0,0,0.06); +} +body.category-comparison .content th { + background: color-mix(in srgb, var(--accent) 18%, var(--bg-surface)); + color: var(--text); + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.72rem; + font-weight: 700; +} +body.category-comparison .content td:first-child { + font-weight: 600; + background: color-mix(in srgb, var(--accent) 6%, transparent); +} +/* The first H3 inside a comparison report often names the recommended pick */ +body.category-comparison .content h3:first-of-type::after { + content: 'Pick'; + display: inline-block; + margin-left: 10px; + padding: 2px 10px; + background: var(--accent); + color: #fff; + font-family: var(--font-body); + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + border-radius: 999px; + vertical-align: middle; +} + +/* ── PRODUCT: spec-sheet cards for each H3 ─────────── */ +body.category-product .content h3 { + /* Each product gets a spec-card frame — bordered, slight bg lift */ + margin-top: 2.4rem; + padding: 16px 18px; + border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border)); + background: var(--bg-surface); + border-radius: 10px; + display: flex; align-items: baseline; gap: 10px; + font-family: var(--font-display); + letter-spacing: -0.01em; + box-shadow: 0 2px 8px rgba(0,0,0,0.04); +} +body.category-product .content h3::after { + /* small "spec" tag on each product heading */ + content: 'SPEC'; + margin-left: auto; + font-family: var(--font-body); + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.18em; + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); + padding: 3px 8px; + border-radius: 4px; +} +body.category-product .content h3 + p, +body.category-product .content h3 + ul, +body.category-product .content h3 + table { + margin-top: 0.8rem; + padding-left: 4px; +} +""" + styles = { + "product": """ +/* Product category */ +.category-product .content h3 { + display:flex; align-items:baseline; gap:8px; + border-bottom:1px solid var(--border); padding-bottom:6px; +} +.category-product .content table { + width:100%; border-collapse:collapse; margin:1.2em 0; font-size:0.92em; +} +.category-product .content table th { + background:var(--accent); color:#fff; padding:8px 12px; text-align:left; +} +.category-product .content table td { padding:8px 12px; border-bottom:1px solid var(--border); } +.category-product .content table tr:nth-child(even) td { background:var(--bg-surface); } +.category-product .content ul { columns:2; column-gap:2em; } +@media (max-width:600px) { .category-product .content ul { columns:1; } } +.category-product .content a[href*="amazon"], +.category-product .content a[href*="ebay"], +.category-product .content a[href*="shop"], +.category-product .content a[href*="buy"] { + display:inline-block; padding:3px 10px; border-radius:4px; + background:var(--accent); color:#fff; text-decoration:none; font-size:0.85em; margin:2px 4px; +} +.quick-links-bar { + display:flex; flex-wrap:wrap; gap:6px; padding:12px 0; margin-bottom:12px; + border-bottom:1px solid var(--border); +} +.quick-link { + padding:5px 12px; border-radius:16px; font-size:0.82em; text-decoration:none; + border:1px solid var(--border); color:var(--text); transition:all 0.15s; + white-space:nowrap; +} +.quick-link:hover { + background:var(--accent); color:#fff; border-color:var(--accent); +} +""", + "comparison": """ +/* Comparison category */ +.category-comparison .content table { + width:100%; border-collapse:collapse; margin:1.2em 0; +} +.category-comparison .content table th { + background:var(--accent); color:#fff; padding:10px 14px; + text-align:center; font-weight:600; position:sticky; top:0; +} +.category-comparison .content table td { + padding:10px 14px; border-bottom:1px solid var(--border); text-align:center; +} +.category-comparison .content table tr:nth-child(even) td { background:var(--bg-surface); } +.category-comparison .content table td:first-child { + text-align:left; font-weight:500; background:color-mix(in srgb, var(--accent) 8%, transparent); +} +.category-comparison .content table td.cmp-pos { + color:#2e7d32; font-weight:600; + background:color-mix(in srgb, #4caf50 10%, transparent); +} +.category-comparison .content table td.cmp-neg { + color:#c62828; font-weight:600; + background:color-mix(in srgb, #f44336 8%, transparent); +} +.category-comparison .content table td.cmp-mid { + color:#e68a00; + background:color-mix(in srgb, #ffa726 8%, transparent); +} +.category-comparison .content h2 ~ p strong:first-child { + display:inline-block; padding:2px 8px; border-radius:3px; + background:color-mix(in srgb, var(--accent) 15%, transparent); font-size:0.9em; +} +""", + "howto": """ +/* How-to category */ +.category-howto .content h2 { + counter-increment:step-counter; +} +.category-howto .content h2::before { + content:counter(step-counter); + display:inline-flex; align-items:center; justify-content:center; + width:28px; height:28px; border-radius:50%; + background:var(--accent); color:#fff; font-size:0.8em; font-weight:700; + margin-right:10px; flex-shrink:0; +} +.category-howto .content { counter-reset:step-counter; } +.category-howto .content blockquote { + border-left:3px solid var(--accent); background:color-mix(in srgb, var(--accent) 8%, transparent); + padding:12px 16px; border-radius:0 6px 6px 0; margin:1em 0; +} +.category-howto .content blockquote strong:first-child { + display:inline-block; margin-bottom:4px; text-transform:uppercase; + font-size:0.82em; letter-spacing:0.5px; +} +.category-howto .content h2#quick-guide + ol, +.category-howto .content h2#quick-guide ~ ol:first-of-type { + background:color-mix(in srgb, var(--accent) 8%, transparent); + border:1px solid color-mix(in srgb, var(--accent) 20%, transparent); + border-radius:8px; padding:14px 14px 14px 32px; font-size:0.95em; line-height:1.8; +} +.category-howto .content h2#quick-guide { + counter-increment:none; +} +.category-howto .content h2#quick-guide::before { + content:'\\26A1'; background:none; width:auto; height:auto; margin-right:6px; +} +""", + "landscape": """ +/* Landscape category */ +.category-landscape .content h3 { + display:flex; align-items:center; gap:8px; + padding:8px 0; border-bottom:1px solid var(--border); +} +.category-landscape .content table { + width:100%; border-collapse:collapse; margin:1em 0; font-size:0.92em; +} +.category-landscape .content table th { + background:var(--accent); color:#fff; padding:8px 12px; text-align:left; +} +.category-landscape .content table td { padding:8px 12px; border-bottom:1px solid var(--border); } +.category-landscape .content table tr:nth-child(even) td { background:var(--bg-surface); } +.category-landscape .content blockquote { + border-left:3px solid var(--gold, #d4a73a); + background:color-mix(in srgb, var(--gold, #d4a73a) 8%, transparent); + padding:10px 14px; border-radius:0 6px 6px 0; +} +""", + "factcheck": """ +/* Fact-check category */ +.category-factcheck .hero { + background:linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); +} +.category-factcheck .content h2:first-of-type { + font-size:1.4em; text-align:center; padding:16px 0; border:none; + background:color-mix(in srgb, var(--accent) 8%, transparent); + border-radius:8px; margin:1em 0; +} +.category-factcheck .content blockquote { + position:relative; padding-left:20px; +} +.category-factcheck .content h2 ~ h3 { + padding:6px 10px; border-radius:4px; + border-left:3px solid var(--accent); +} +.category-factcheck .content strong:only-child { + display:inline-block; padding:4px 12px; border-radius:4px; + font-size:1.1em; +} +""", + } + # Always emit the per-category palette block when ANY category is set — + # it contains body.category-X scoped rules so it only re-skins the page + # for the matching category. The legacy `styles[category]` block adds + # structural CSS specific to that one type. + return palettes + styles.get(category, "") + + +_GENERIC_HEADINGS = { + "report", "deep research report", "research", + "executive summary", "summary", "tl;dr", + "introduction", "overview", "abstract", + "findings", "key findings", "results", + "conclusion", "conclusions", "table of contents", +} + + +def _extract_report_title(markdown_text: str, fallback: str): + """Pull a real title from the report's first heading rather than reusing + the raw user query. Returns (title, markdown_with_title_stripped). + + Falls back to the query when no heading is present. Skips generic + placeholders ("Executive Summary", "Introduction", etc.) and tries the + next heading. If the chosen title was the report's own top heading, that + heading is removed from the markdown so it doesn't duplicate the hero h1. + """ + if not markdown_text: + return fallback, markdown_text + + # Walk through headings (h1 first, then h2 anywhere) and use the first + # non-generic one. Track the chosen match so we can strip it from the body. + candidates = [] + for level, pattern in ((1, r'^# +(.+?)\s*$'), (2, r'^## +(.+?)\s*$')): + for m in re.finditer(pattern, markdown_text, re.MULTILINE): + cand = m.group(1).strip().rstrip('#').strip() + if cand and cand.lower() not in _GENERIC_HEADINGS: + candidates.append((level, m, cand)) + + # Prefer h1 over h2; among same-level, prefer the earliest. + candidates.sort(key=lambda t: (t[0], t[1].start())) + if candidates: + _level, match, title = candidates[0] + stripped = markdown_text[:match.start()] + markdown_text[match.end():] + return title, stripped.lstrip() + return fallback, markdown_text + + +def generate_visual_report( + question: str, + report_markdown: str, + sources: Optional[List[Dict]] = None, + stats: Optional[Dict] = None, + category: Optional[str] = None, + session_id: Optional[str] = None, + hidden_images: Optional[List[str]] = None, +) -> str: + sources = sources or [] + stats = stats or {} + hidden_images_set = set(hidden_images or []) + + # Strip thinking artifacts + report_markdown = strip_thinking(report_markdown) + + # Use the report's first heading as the title (synthesized by the LLM) + # rather than the raw user query. Fall back to the query if absent. + synthesized, report_markdown = _extract_report_title(report_markdown, question) + title_text = synthesized[:120] + ("..." if len(synthesized) > 120 else "") + + # Promote bold-only lines to ## headings if no markdown headings exist + if not re.search(r'^#{2,3}\s+', report_markdown, re.MULTILINE): + report_markdown = re.sub( + r'^\*\*([^*]+)\*\*\s*$', + lambda m: f'## {m.group(1).strip()}', + report_markdown, + flags=re.MULTILINE, + ) + + report_html = _md_to_html(report_markdown) + + # Add id anchors to h2/h3 for TOC linking + headings = _extract_headings(report_markdown) + for h in headings: + tag = f"h{h['level']}" + pattern = rf'(<{tag}>)(.*?{re.escape(html.escape(h["text"]))}.*?)' + replacement = rf'<{tag} id="{h["slug"]}">\2' + report_html = re.sub(pattern, replacement, report_html, count=1) + + # Collect all OG images from sources (skip icons, tiny images, known junk) + _IMAGE_BLOCKLIST = { + "cdn.shopify.com/s/files/1/0179/4388/7926/files/icon.png", + } + _seen_images = set() + all_images = [] + for s in sources: + img = s.get("image", "") + if (img and img.startswith("https://") + and img not in _seen_images + and img not in hidden_images_set + and not img.endswith((".svg", ".ico", ".gif")) + and not any(b in img for b in _IMAGE_BLOCKLIST) + and "/icon" not in img.lower() + and "/logo" not in img.lower() + and "/favicon" not in img.lower()): + _seen_images.add(img) + all_images.append(img) + + # Hero image = first available. data-img-url drives the per-image hide + # button rendered by the script at the bottom of the page. + hero_image_html = "" + if all_images: + hero_url = html.escape(all_images[0]) + hero_image_html = ( + f'
' + f'' + f'{_IMG_OVERLAY_BTNS}' + f'
' + ) + + # Product quick-links bar + if category == "product" and headings: + product_headings = [h for h in headings if h["level"] == 3] + if product_headings: + pills = " ".join( + f'{html.escape(h["text"][:40])}' + for h in product_headings + ) + report_html = f'\n' + report_html + + # Inject remaining images between sections. Whatever isn't placed (hero + # took [0], sections took the next `consumed`) becomes the spare pool the + # reroll button draws from to swap out an irrelevant image in-page. + section_pool = all_images[1:] + report_html, _consumed = _inject_images(report_html, section_pool) + spare_images = section_pool[_consumed:] + + # Build TOC + toc_lines = [] + for h in headings: + depth_class = f"depth-{h['level']}" + toc_lines.append( + f'{html.escape(h["text"])}' + ) + toc_html = "\n ".join(toc_lines) if toc_lines else "" + + # Build stats bar + stat_items = [] + for key, label in [("Duration", "Duration"), ("Rounds", "Rounds"), ("Queries", "Queries"), ("URLs", "URLs Analyzed"), ("Model", "Model"), ("Search", "Search")]: + val = stats.get(key) + if val is not None: + stat_items.append( + f'
{html.escape(str(val))} {html.escape(label)}
' + ) + stats_html = "\n ".join(stat_items) + + # Build sources panel — compact collapsible list + sources_html = "" + if sources: + items = [] + for i, s in enumerate(sources, 1): + url = s.get("url", "") + title = html.escape(s.get("title", "") or url) + domain = "" + try: + domain = urlparse(url).hostname or "" + if domain.startswith("www."): + domain = domain[4:] + except Exception: + domain = url + items.append( + f'' + f'{i}.' + f'{title}' + f'{html.escape(domain)}' + f'' + ) + sources_html = ( + '
\n' + '
\n' + f'Sources ({len(sources)})\n' + '
\n' + + "\n".join(items) + + "\n
\n
\n
" + ) + + timestamp = datetime.now().strftime("%B %d, %Y at %H:%M") + + # Build description for OG/meta tags (first 160 chars of plain text) + desc_text = re.sub(r'[#*_\[\]()]', '', report_markdown)[:160].strip() + og_image_meta = "" + if all_images: + og_image_meta = f'' + + chat_cta_html = "" + if session_id: + chat_cta_html = ( + '
' + '' + '
Opens a new chat with this report as context.
' + '
' + ) + + # "Restore hidden images" toolbar button — only render if there are any + # hidden images on this research AND we have a session_id (needed for + # the POST endpoint). + restore_btn_html = "" + if session_id and hidden_images_set: + restore_btn_html = ( + '' + ) + + return _TEMPLATE.format( + title=html.escape(title_text), + description=html.escape(desc_text), + og_image_meta=og_image_meta, + question_html=html.escape(synthesized), + hero_image_html=hero_image_html, + stats_html=stats_html, + toc_html=toc_html, + report_html=report_html, + sources_html=sources_html, + chat_cta_html=chat_cta_html, + restore_btn_html=restore_btn_html, + timestamp=timestamp, + category_css=_category_css(category), + body_class=f"category-{category}" if category else "", + session_id_js=json_dumps_str(session_id or ""), + spare_images_js=_json_for_script(spare_images), + ) + + +def _json_for_script(value) -> str: + """JSON-encode a value safe to embed inside a ' would terminate the script element early. + Escape the closing slash to keep the inline JSON inert as HTML. + """ + return json.dumps(value).replace(" str: + """JSON-encode a string so it's safe to embed inside a + + + + + + + + + + + + + + + + + + + + +
+
▁▂▃
+
+ + + + + + + +
+ + + + +
+
+ + + + +
+ + + + + + + + + + + + + + + +
+ + +
+ + + +
+
+ +
Odysseus Chat
Rename
Copy Chat
PDF
Save to Documents
+
+
Odysseus
+
Welcome, type /setup to get started.
+
+ + +
+ +
+ + +
+ + + + + + + + +
+
+ + + +
+ + +
+
+
+ + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/js/MODULE_SUMMARY.md b/static/js/MODULE_SUMMARY.md new file mode 100644 index 0000000..a5f63cf --- /dev/null +++ b/static/js/MODULE_SUMMARY.md @@ -0,0 +1,116 @@ +# Module Organization Summary + +## Purpose +This document describes what each JavaScript module is responsible for. + +--- + +## Core Modules (in static/js/) + +### 1. **ui.js** +- UI helper functions and utilities +- Toast notifications (`showToast`, `showError`) +- Element getter (`el()`) +- Clipboard operations (`copyToClipboard`) +- Scroll management (`scrollHistory`, `setAutoScroll`) +- Auto-resize textarea +- Debounce utility + +### 2. **markdown.js** +- Markdown processing and rendering +- Convert markdown to HTML (`mdToHtml`) +- Code block handling with syntax highlighting +- Content rendering for message arrays +- Text cleanup (`squashOutsideCode`) + +### 3. **session.js** +- Session/chat management +- Create, load, delete, switch sessions +- Session history loading +- Direct chat creation with models +- Session renaming + +### 4. **memory.js** +- AI memory management +- Load, add, edit, delete memories +- Memory search/filtering +- Memory UI rendering +- Memory count updates + +### 5. **fileHandler.js** +- File attachment handling +- File picker dialog +- File upload to server +- Attachment strip rendering +- Pending files management +- File preview/removal + +### 6. **voiceRecorder.js** +- Voice recording functionality +- Start/stop recording +- Audio file creation +- Microphone permission handling +- Recording UI updates + +### 7. **models.js** +- Model scanning and display +- Local model discovery (ports 8000-8010) +- Provider management (OpenAI) +- Model selection UI + +### 8. **rag.js** +- RAG (Retrieval Augmented Generation) management +- Load personal documents +- Add directories to RAG +- Display included files/directories + +### 9. **presets.js** +- Conversation preset management +- Load, save, activate presets +- Custom preset configuration +- Temperature, tokens, system prompt settings + +### 10. **search.js** +- Web search settings +- Provider selection (DuckDuckGo, Brave, SearXNG) +- API key management +- Save/load search configuration + +### 11. **chat.js** ⭐ (The Big One) +- Main chat functionality +- Message handling (`addMessage`) +- Chat submission (`handleChatSubmit`) +- Streaming response handling +- Performance metrics display +- Abort request management +- Loading states and error handling + +--- + +## Main Application File + +### **app.js** +- Application initialization +- Event listener setup +- Drag & drop handlers +- Keyboard shortcuts +- Module initialization +- Global configuration (API_BASE) +- Coordinates all modules together + +--- + +## Dependency Order (Load Order in HTML) +```html + + + + + + + + + + + + diff --git a/static/js/admin.js b/static/js/admin.js new file mode 100644 index 0000000..8ae57ce --- /dev/null +++ b/static/js/admin.js @@ -0,0 +1,1834 @@ +// static/js/admin.js — Admin panel module (ES6) +// Admin-only: users, endpoints, MCP, RAG, embeddings, tokens, webhooks, features + +import uiModule from './ui.js'; +import settingsModule from './settings.js'; +import { providerLogo } from './providers.js'; + +let initialized = false; +let modalEl = null; +// When the user adds an endpoint, store its id so the next render of +// the endpoints list can flash a glow on that row. Cleared once the +// animation fires. +let _recentlyAddedEpId = null; + +function el(id) { return document.getElementById(id); } +function esc(s) { return uiModule.esc(s); } + +/* ═══════════════════════════════════════════ + USERS TAB + ═══════════════════════════════════════════ */ +const PRIV_LABELS = { + can_use_agent: 'Agent mode', + can_use_browser: 'Browser automation', + can_use_bash: 'Shell / Python / Files', + can_use_documents: 'Document editor', + can_use_research: 'Deep research', + can_generate_images: 'Image generation', + can_manage_memory: 'Memory & skills', +}; + +async function loadUsers() { + const list = el('adm-userList'); + try { + const res = await fetch('/api/auth/users', { credentials: 'same-origin' }); + if (res.status === 401 || res.status === 403) { list.innerHTML = '
Access denied
'; return; } + const data = await res.json(); + if (!data.users || data.users.length === 0) { list.innerHTML = '
No users found
'; return; } + list.innerHTML = ''; + data.users.forEach(u => { + const row = document.createElement('div'); + row.className = 'admin-user-row'; + + // Header: name + badges + delete + const header = document.createElement('div'); + header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;cursor:pointer;padding:4px 0;'; + const initial = u.username.charAt(0).toUpperCase(); + header.innerHTML = ` + +
+ ${u.is_admin ? '' : ``} + ${u.is_admin ? '' : ''} +
+ `; + row.appendChild(header); + + // Privileges panel (hidden by default, not for admins) + if (!u.is_admin) { + const privPanel = document.createElement('div'); + privPanel.className = 'admin-priv-panel hidden'; + privPanel.style.cssText = 'padding:8px 0 4px;border-top:1px solid var(--border);margin-top:8px;'; + + // Boolean toggles + let html = '
Features
'; + for (const [key, label] of Object.entries(PRIV_LABELS)) { + const checked = u.privileges && u.privileges[key] ? 'checked' : ''; + html += `
+ ${label} + +
`; + } + // Rate limit + html += '
Limits
'; + const maxMsg = (u.privileges && u.privileges.max_messages_per_day) || 0; + html += `
+
+ Daily message limit +
0 = no limit
+
+ +
`; + // Allowed models — checkbox list + const allowedSet = new Set((u.privileges && u.privileges.allowed_models) || []); + const allEmpty = allowedSet.size === 0; + html += `
+
+ Allowed models +
+ All + None +
+
+
${allEmpty ? 'All models allowed (no restrictions)' : allowedSet.size + ' model(s) allowed'}
+
+ Loading models... +
+
`; + privPanel.innerHTML = html; + row.appendChild(privPanel); + + // Toggle panel visibility + rotate chevron + load models + let _modelsLoaded = false; + header.addEventListener('click', (e) => { + if (e.target.closest('.admin-btn-delete')) return; + privPanel.classList.toggle('hidden'); + const chevron = header.querySelector('.admin-user-chevron'); + if (chevron) { + const isOpen = !privPanel.classList.contains('hidden'); + chevron.style.transform = isOpen ? 'rotate(180deg)' : ''; + chevron.style.opacity = isOpen ? '0.7' : '0.3'; + } + // Load models list on first expand + if (!_modelsLoaded && !privPanel.classList.contains('hidden')) { + _modelsLoaded = true; + _loadModelsForUser(u.username, allowedSet, privPanel); + } + }); + + // Wire privilege changes (boolean + number inputs, not model checkboxes) + privPanel.querySelectorAll('[data-priv]').forEach(input => { + const handler = async () => { + const username = input.dataset.user; + const key = input.dataset.priv; + let value; + if (input.type === 'checkbox') value = input.checked; + else if (input.type === 'number') value = parseInt(input.value) || 0; + else value = input.value; + try { + await fetch(`/api/auth/users/${encodeURIComponent(username)}/privileges`, { + method: 'PUT', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [key]: value }), + }); + } catch (e) { uiModule.showError('Failed to update privilege'); } + }; + if (input.type === 'checkbox') input.addEventListener('change', handler); + else input.addEventListener('change', handler); + }); + } + + // Delete button + const delBtn = row.querySelector('[data-adm-del-user]'); + if (delBtn) { + delBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + const username = delBtn.dataset.admDelUser; + if (!await uiModule.styledConfirm(`Remove user "${username}"?`, { confirmText: 'Remove', danger: true })) return; + const res = await fetch('/api/auth/users', { method: 'DELETE', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) }); + if (res.ok) loadUsers(); + else uiModule.showError('Failed to delete user'); + }); + } + + list.appendChild(row); + }); + } catch (e) { list.innerHTML = '
Failed to load users
'; } +} + +async function _loadModelsForUser(username, allowedSet, privPanel) { + const listEl = privPanel.querySelector(`.priv-models-list[data-user="${username}"]`); + if (!listEl) return; + try { + const res = await fetch('/api/models', { credentials: 'same-origin' }); + const data = await res.json(); + const allModels = []; + (data.items || []).forEach(item => { + if (item.offline) return; + (item.models || []).forEach(mid => { + allModels.push({ mid, epName: item.endpoint_name || '', display: mid.split('/').pop() }); + }); + }); + if (!allModels.length) { + listEl.innerHTML = 'No models available'; + return; + } + const allEmpty = allowedSet.size === 0; + listEl.innerHTML = allModels.map(m => { + const checked = allEmpty || allowedSet.has(m.mid) ? 'checked' : ''; + return ``; + }).join(''); + + // Save on change + function _saveModels() { + const checked = []; + listEl.querySelectorAll('.priv-model-cb').forEach(cb => { + if (cb.checked) checked.push(cb.dataset.mid); + }); + // If all are checked, send empty array (= no restrictions) + const value = checked.length === allModels.length ? [] : checked; + const hint = privPanel.querySelector('.priv-models-list[data-user]')?.previousElementSibling?.querySelector('div[style*="opacity"]'); + if (hint) hint.textContent = value.length === 0 ? 'All models allowed (no restrictions)' : value.length + ' model(s) allowed'; + fetch(`/api/auth/users/${encodeURIComponent(username)}/privileges`, { + method: 'PUT', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ allowed_models: value }), + }).catch(() => {}); + } + listEl.querySelectorAll('.priv-model-cb').forEach(cb => cb.addEventListener('change', _saveModels)); + + // All / None buttons + privPanel.querySelector(`.priv-models-all[data-user="${username}"]`)?.addEventListener('click', (e) => { + e.preventDefault(); + listEl.querySelectorAll('.priv-model-cb').forEach(cb => cb.checked = true); + _saveModels(); + }); + privPanel.querySelector(`.priv-models-none[data-user="${username}"]`)?.addEventListener('click', (e) => { + e.preventDefault(); + listEl.querySelectorAll('.priv-model-cb').forEach(cb => cb.checked = false); + _saveModels(); + }); + } catch (e) { + listEl.innerHTML = 'Failed to load models'; + } +} + +function initSignupToggle() { + const toggle = el('adm-signupToggle'); + fetch('/api/auth/status', { credentials: 'same-origin' }) + .then(r => r.json()) + .then(d => { toggle.checked = !!d.signup_enabled; }) + .catch(e => console.warn('Auth status fetch failed:', e)); + toggle.addEventListener('change', async () => { + try { + const res = await fetch('/api/auth/signup-toggle', { method: 'POST', credentials: 'same-origin' }); + const data = await res.json(); + toggle.checked = data.signup_enabled; + } catch (e) { toggle.checked = !toggle.checked; } + }); +} + +function initAddUser() { + el('adm-addBtn').addEventListener('click', async () => { + const msg = el('adm-addMsg'); + msg.textContent = ''; msg.className = ''; + const username = el('adm-newUsername').value.trim(); + const password = el('adm-newPassword').value; + const is_admin = el('adm-newIsAdmin').checked; + if (!username) { msg.textContent = 'Username required'; msg.className = 'admin-error'; return; } + if (password.length < 8) { msg.textContent = 'Password must be at least 8 characters'; msg.className = 'admin-error'; return; } + el('adm-addBtn').disabled = true; + try { + const res = await fetch('/api/auth/users', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, is_admin }) }); + const data = await res.json(); + if (res.ok) { msg.textContent = 'User created'; msg.className = 'admin-success'; el('adm-newUsername').value = ''; el('adm-newPassword').value = ''; el('adm-newIsAdmin').checked = false; loadUsers(); } + else { msg.textContent = data.detail || 'Failed'; msg.className = 'admin-error'; } + } catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; } + el('adm-addBtn').disabled = false; + }); +} + +/* ═══════════════════════════════════════════ + SERVICES TAB — Endpoints + ═══════════════════════════════════════════ */ +function _isLocalEndpoint(url) { + if (!url) return false; + try { + const u = new URL(url); + const h = u.hostname.toLowerCase(); + if (h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0') return true; + if (h.endsWith('.local')) return true; + if (/^10\./.test(h)) return true; + if (/^192\.168\./.test(h)) return true; + if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(h)) return true; + // Tailscale CGNAT range (100.64.0.0/10 → 100.64.x–100.127.x). Servers + // found via "Scan for Servers" come back as tailnet IPs, which are still + // your own machines, so group them under Local rather than API. + if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h)) return true; + // Single-label hostnames are LAN by convention. + if (!h.includes('.')) return true; + return false; + } catch { return false; } +} + +async function loadEndpoints() { + const listLocal = el('adm-epList-local'); + const listApi = el('adm-epList-api'); + // Fallback to the legacy single list if the split containers don't exist + // (older HTML or third-party embedding). + const listLegacy = el('adm-epList'); + // Refresh model picker so new endpoints show up in chat + if (window.modelsModule && window.modelsModule.refreshModels) { + window.modelsModule.refreshModels(true); + setTimeout(() => { + if (window.sessionModule && window.sessionModule.updateModelPicker) { + window.sessionModule.updateModelPicker(); + } + }, 1500); + } + try { + const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' }); + // Treat a non-OK response (e.g. 401/403 for non-admins, or backend + // returning an error envelope) the same as "no endpoints yet": show the + // empty state, not "Failed to load". The user just installed the app — + // there's literally nothing to load, so the error read as broken UI. + let data = []; + if (res.ok) { + try { data = await res.json(); } catch { data = []; } + } + if (!Array.isArray(data) || data.length === 0) { + const empty = '
No endpoints configured
'; + if (listLocal) listLocal.innerHTML = empty; + if (listApi) listApi.innerHTML = '
None
'; + if (listLegacy) listLegacy.innerHTML = empty; + return; + } + const rowHtml = data.map(ep => { + const visibleCount = ep.models.length; + const totalCount = visibleCount + (ep.hidden_count || 0); + // `ep.models` is the *visible* set — when every model is hidden it's + // empty, but we still need to render the expand panel so the user can + // un-hide them. Gate on the total instead. + const hasModels = ep.online && totalCount > 0; + const statusBadge = ep.online + ? `${visibleCount}/${totalCount} models enabled` + : 'offline'; + const justAddedClass = (_recentlyAddedEpId && String(ep.id) === _recentlyAddedEpId) ? ' adm-ep-just-added' : ''; + return ` +
+
+ +
+ + + ${hasModels ? '' : ''} +
+
+
${esc(ep.base_url)}${_isLocalEndpoint(ep.base_url) ? `` : ''}${ep.has_key ? ' (key set)' : ''}
+ ${hasModels ? `` : ''} +
`; + }); + // Partition rows into Local vs API for the split sections. + // Subsections without any rows are hidden entirely (heading + all) + // so empty groups don't take up vertical real estate. + const _renderInto = (container, indices) => { + if (!container) return; + const section = container.closest('.adm-ep-section'); + if (!indices.length) { + if (section) section.style.display = 'none'; + container.innerHTML = ''; + return; + } + if (section) section.style.display = ''; + container.innerHTML = indices.map(i => rowHtml[i]).join(''); + }; + const localIdx = [], apiIdx = []; + data.forEach((ep, i) => (_isLocalEndpoint(ep.base_url) ? localIdx : apiIdx).push(i)); + // Sort each section: enabled endpoints first, disabled at the bottom. + // Preserve original order within each group via stable sort. + const _sortByEnabled = (a, b) => Number(!!data[b].is_enabled) - Number(!!data[a].is_enabled); + localIdx.sort(_sortByEnabled); + apiIdx.sort(_sortByEnabled); + _renderInto(listLocal, localIdx); + _renderInto(listApi, apiIdx); + if (listLegacy) listLegacy.innerHTML = rowHtml.join(''); + // Iterate matching nodes across both containers. + const queryAll = (sel) => { + const out = []; + [listLocal, listApi, listLegacy].forEach(c => { + if (c) c.querySelectorAll(sel).forEach(n => out.push(n)); + }); + return out; + }; + queryAll('[data-adm-toggle-ep]').forEach(btn => { + btn.addEventListener('click', async (e) => { e.stopPropagation(); await fetch(`/api/model-endpoints/${btn.dataset.admToggleEp}`, { method: 'PATCH' }); loadEndpoints(); }); + }); + queryAll('[data-adm-copy-url]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const url = btn.dataset.admCopyUrl || ''; + if (!url) return; + uiModule.copyToClipboard(url).then(() => { + // Brief icon swap to a checkmark so the user gets feedback that + // the copy actually happened. Reverts after ~1.4s. + const prev = btn.innerHTML; + btn.innerHTML = ''; + btn.style.opacity = '1'; + setTimeout(() => { btn.innerHTML = prev; btn.style.opacity = ''; }, 1400); + }).catch(() => {}); + }); + }); + queryAll('[data-adm-del-ep]').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + var epId = btn.dataset.admDelEp; + var isOffline = btn.dataset.admEpOnline === '0'; + // Offline endpoints are already broken — skip the confirm dialog + // entirely and delete immediately. The optimistic UI removal makes + // the action feel instant. + if (!isOffline) { + var deps = []; + try { + var depRes = await fetch('/api/model-endpoints/' + epId + '/dependents', { credentials: 'same-origin' }); + var depData = await depRes.json(); + deps = depData.dependents || []; + } catch (e) { /* proceed without warning */ } + var msg = 'Delete this endpoint?'; + if (deps.length) { + msg += '\n\nThe following settings use this endpoint and will be reset:\n— ' + deps.join('\n— '); + } + if (!await uiModule.styledConfirm(msg, { confirmText: 'Delete', danger: true })) return; + } + // Optimistic: remove from UI immediately + const row = btn.closest('[data-adm-ep-id]'); + if (row) row.remove(); + fetch('/api/model-endpoints/' + epId, { method: 'DELETE' }).then(() => loadEndpoints()).catch(() => loadEndpoints()); + }); + }); + // Clear the just-added marker now that the row has been rendered + // with the animation class — keeps the glow from re-firing on every + // subsequent loadEndpoints() call (e.g. when toggling a model). + if (_recentlyAddedEpId) _recentlyAddedEpId = null; + // Models expand/collapse (click anywhere on card) + queryAll('[data-adm-ep-id]').forEach(row => { + const header = row.querySelector('[data-adm-ep-header]'); + if (!header) return; + let _modelsLoaded = false; + row.style.cursor = 'pointer'; + row.addEventListener('click', async (e) => { + // Don't let interactions inside the expanded panel re-fire the + // expand/collapse handler — the search box was getting closed + // because clicking it bubbled up to here. + if (e.target.closest('.admin-btn-sm, .admin-btn-delete, .mcp-tools-list, .mcp-tools-header, .mcp-tools-search, input, label')) return; + const epId = header.dataset.admEpHeader; + const panel = row.querySelector(`[data-adm-ep-models-panel="${epId}"]`); + if (!panel) return; + panel.classList.toggle('hidden'); + const chevron = row.querySelector('.admin-user-chevron'); + const isOpen = !panel.classList.contains('hidden'); + if (chevron) { + chevron.style.transform = isOpen ? 'rotate(180deg)' : ''; + chevron.style.opacity = isOpen ? '0.7' : '0.3'; + } + if (!_modelsLoaded && isOpen) { + _modelsLoaded = true; + // Our shared whirlpool spinner (consistent with the rest of the app). + panel.innerHTML = ''; + let _modelsSpin = null; + const _ld = document.createElement('span'); + _ld.style.cssText = 'opacity:0.55;font-size:11px;display:inline-flex;align-items:center;gap:8px;'; + _ld.appendChild(document.createTextNode('Loading models…')); + try { + const _sp = (await import('./spinner.js')).default; + _modelsSpin = _sp.createWhirlpool(14); + _modelsSpin.element.style.cssText = 'width:14px;height:14px;margin:0;display:inline-block;'; + _ld.appendChild(_modelsSpin.element); + } catch (_) {} + panel.appendChild(_ld); + const _stopSpin = () => { try { _modelsSpin && _modelsSpin.stop(); } catch (_) {} }; + try { + const res = await fetch(`/api/model-endpoints/${epId}/models`, { credentials: 'same-origin' }); + const models = await res.json(); + _stopSpin(); + if (!models.length) { panel.innerHTML = 'No models'; return; } + const hiddenSet = new Set(models.filter(m => m.is_hidden).map(m => m.id)); + const showSearch = models.length >= 8; + panel.innerHTML = `
+ Models + + ${models.length - hiddenSet.size}/${models.length} enabled + All + None + +
${showSearch ? `` : ''}
` + models.map(m => + `` + ).join('') + '
'; + const filterRows = (q) => { + const needle = q.trim().toLowerCase(); + panel.querySelectorAll('[data-ep-model-row]').forEach(row => { + row.style.display = (!needle || row.dataset.search.includes(needle)) ? '' : 'none'; + }); + }; + panel.querySelector(`[data-ep-search="${epId}"]`)?.addEventListener('input', (e) => filterRows(e.target.value)); + panel.querySelector(`[data-ep-select-all="${epId}"]`)?.addEventListener('click', (e) => { + e.preventDefault(); + panel.querySelectorAll('[data-ep-model-row]').forEach(row => { + if (row.style.display !== 'none') row.querySelector('input[type=checkbox]').checked = true; + }); + _saveEpModelState(epId, panel); + }); + panel.querySelector(`[data-ep-select-none="${epId}"]`)?.addEventListener('click', (e) => { + e.preventDefault(); + panel.querySelectorAll('[data-ep-model-row]').forEach(row => { + if (row.style.display !== 'none') row.querySelector('input[type=checkbox]').checked = false; + }); + _saveEpModelState(epId, panel); + }); + panel.querySelectorAll('input[type=checkbox]').forEach(cb => { + cb.addEventListener('change', () => _saveEpModelState(epId, panel)); + }); + } catch (e) { _stopSpin(); panel.innerHTML = 'Failed to load models'; } + } + }); + }); + } catch (e) { + const err = '
Failed to load
'; + [listLocal, listApi, listLegacy].forEach(c => { if (c) c.innerHTML = err; }); + } +} + +async function _saveEpModelState(epId, panel) { + const hidden = []; + panel.querySelectorAll('input[type=checkbox]').forEach(cb => { + if (!cb.checked) hidden.push(cb.dataset.epModelId); + }); + const total = panel.querySelectorAll('input[type=checkbox]').length; + try { + await fetch(`/api/model-endpoints/${epId}/models`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ hidden }), + }); + const countLabel = panel.querySelector('.mcp-tools-count'); + if (countLabel) countLabel.textContent = `${total - hidden.length}/${total} enabled`; + const row = panel.closest('[data-adm-ep-id]'); + if (row) { + const badge = row.querySelector('.admin-badge'); + if (badge && !badge.classList.contains('admin-badge-off')) badge.textContent = `${total - hidden.length}/${total} models enabled`; + } + } catch (e) { /* silent */ } +} + +function initEndpointForm() { + const provider = el('adm-epProvider'); + const urlInput = el('adm-epUrl'); + + // Custom provider picker — mirrors the (now hidden) + + + + + + '; + } + list.innerHTML = html; + + // Prevent toggle clicks from expanding/collapsing + list.querySelectorAll('.admin-tool-cat-right').forEach(span => { + span.addEventListener('click', e => e.stopPropagation()); + }); + + // Wire category expand/collapse + list.querySelectorAll('[data-tool-cat]').forEach(header => { + header.addEventListener('click', () => { + const body = el(header.dataset.toolCat); + if (!body) return; + body.classList.toggle('hidden'); + const chevron = header.querySelector('.admin-tool-cat-chevron'); + const isOpen = !body.classList.contains('hidden'); + if (chevron) { + chevron.style.transform = isOpen ? 'rotate(180deg)' : ''; + chevron.style.opacity = isOpen ? '0.7' : '0.3'; + } + }); + }); + + // Helper: save disabled tools + update counters + async function _saveToolState() { + const allChecks = list.querySelectorAll('input[data-tool-id]'); + const disabled = []; + allChecks.forEach(c => { if (!c.checked) disabled.push(c.dataset.toolId); }); + await fetch('/api/tools', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ disabled }), + credentials: 'same-origin', + }); + } + function _updateCatCounter(catEl) { + if (!catEl) return; + const catChecks = catEl.querySelectorAll('input[data-tool-id]'); + const catEnabled = Array.from(catChecks).filter(c => c.checked).length; + const counter = catEl.querySelector('.admin-tool-cat-count'); + if (counter) counter.textContent = catEnabled + '/' + catChecks.length; + const catToggle = catEl.querySelector('input[data-tool-cat-toggle]'); + if (catToggle) catToggle.checked = (catEnabled === catChecks.length); + } + + // Wire individual tool toggles + list.querySelectorAll('input[data-tool-id]').forEach(chk => { + chk.addEventListener('change', async () => { + await _saveToolState(); + _updateCatCounter(chk.closest('.admin-tool-category')); + }); + }); + + // Wire category-level toggle (enable/disable all in category) + list.querySelectorAll('input[data-tool-cat-toggle]').forEach(chk => { + chk.addEventListener('change', async () => { + const catEl = chk.closest('.admin-tool-category'); + if (!catEl) return; + const checked = chk.checked; + catEl.querySelectorAll('input[data-tool-id]').forEach(c => { c.checked = checked; }); + await _saveToolState(); + _updateCatCounter(catEl); + }); + }); + } catch (e) { + console.error('Failed to load tools:', e); + list.innerHTML = '
Failed to load tools
'; + } +} + +async function loadMcpServers() { + const list = el('adm-mcpList'); + if (!list) return; // MCP section not visible / not yet rendered + try { + const res = await fetch('/api/mcp/servers', { credentials: 'same-origin' }); + const servers = await res.json(); + if (!servers.length) { list.innerHTML = '
No MCP servers configured
'; return; } + list.innerHTML = servers.map(s => { + const statusColor = s.needs_oauth ? '#e5a33a' : s.status === 'connected' ? 'var(--fg)' : s.status === 'error' ? 'var(--red)' : 'color-mix(in srgb, var(--fg) 50%, transparent)'; + const toolInfo = s.status === 'connected' ? `${s.enabled_tool_count}/${s.tool_count} tools enabled` : ''; + const statusText = s.needs_oauth ? 'Needs authorization' : s.status === 'connected' ? `Connected (${toolInfo})` : s.status === 'error' ? `Error: ${s.error || 'unknown'}` : 'Disconnected'; + const hasTools = s.status === 'connected' && s.tool_count > 0; + return `
+
+ +
+ ${s.needs_oauth ? `Authorize` : ''} + + + + ${hasTools ? '' : ''} +
+
+ ${hasTools ? `` : ''} +
`; + }).join(''); + list.querySelectorAll('[data-adm-mcp-reconnect]').forEach(btn => { + btn.addEventListener('click', async () => { + const msg = el('adm-mcpMsg'); msg.textContent = 'Reconnecting...'; msg.className = ''; + try { + const res = await fetch(`/api/mcp/servers/${btn.dataset.admMcpReconnect}/reconnect`, { method: 'POST', credentials: 'same-origin' }); + const data = await res.json(); + msg.textContent = data.connected ? `Reconnected (${data.tool_count} tools)` : `Failed: ${data.error || 'unknown'}`; + msg.className = data.connected ? 'admin-success' : 'admin-error'; + loadMcpServers(); + } catch (e) { msg.textContent = 'Failed: ' + e.message; msg.className = 'admin-error'; } + }); + }); + list.querySelectorAll('[data-adm-mcp-toggle]').forEach(btn => { + btn.addEventListener('click', async () => { + const fd = new FormData(); fd.append('is_enabled', btn.dataset.admMcpEnable); + await fetch(`/api/mcp/servers/${btn.dataset.admMcpToggle}`, { method: 'PATCH', body: fd, credentials: 'same-origin' }); + loadMcpServers(); + }); + }); + list.querySelectorAll('[data-adm-mcp-delete]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!await uiModule.styledConfirm('Delete this MCP server?', { confirmText: 'Delete', danger: true })) return; + await fetch(`/api/mcp/servers/${btn.dataset.admMcpDelete}`, { method: 'DELETE', credentials: 'same-origin' }); + loadMcpServers(); + }); + }); + // Tools expand/collapse (click anywhere on card) + list.querySelectorAll('[data-adm-mcp-id]').forEach(row => { + const header = row.querySelector('[data-adm-mcp-header]'); + if (!header) return; + let _toolsLoaded = false; + row.style.cursor = 'pointer'; + row.addEventListener('click', async (e) => { + if (e.target.closest('.admin-btn-sm, .admin-btn-delete, a, .mcp-tools-list, .mcp-tools-header')) return; + const sid = header.dataset.admMcpHeader; + const panel = row.querySelector(`[data-adm-mcp-tools-panel="${sid}"]`); + if (!panel) return; + panel.classList.toggle('hidden'); + const chevron = row.querySelector('.admin-user-chevron'); + const isOpen = !panel.classList.contains('hidden'); + if (chevron) { + chevron.style.transform = isOpen ? 'rotate(180deg)' : ''; + chevron.style.opacity = isOpen ? '0.7' : '0.3'; + } + if (!_toolsLoaded && isOpen) { + _toolsLoaded = true; + panel.innerHTML = 'Loading tools...'; + try { + const res = await fetch(`/api/mcp/servers/${sid}/tools`, { credentials: 'same-origin' }); + const tools = await res.json(); + if (!tools.length) { panel.innerHTML = 'No tools'; return; } + const disabled = new Set(tools.filter(t => t.is_disabled).map(t => t.name)); + panel.innerHTML = `
+ Tools + + ${tools.length - disabled.size}/${tools.length} enabled + All + None + +
` + tools.map(t => + `` + ).join('') + '
'; + panel.querySelector(`[data-mcp-select-all="${sid}"]`)?.addEventListener('click', (e) => { + e.preventDefault(); + panel.querySelectorAll('input[type=checkbox]').forEach(cb => cb.checked = true); + _saveMcpToolState(sid, panel); + }); + panel.querySelector(`[data-mcp-select-none="${sid}"]`)?.addEventListener('click', (e) => { + e.preventDefault(); + panel.querySelectorAll('input[type=checkbox]').forEach(cb => cb.checked = false); + _saveMcpToolState(sid, panel); + }); + panel.querySelectorAll('input[type=checkbox]').forEach(cb => { + cb.addEventListener('change', () => _saveMcpToolState(sid, panel)); + }); + } catch (e) { panel.innerHTML = 'Failed to load tools'; } + } + }); + }); + } catch (e) { if (list) list.innerHTML = '
Failed to load MCP servers
'; } +} + +async function _saveMcpToolState(serverId, panel) { + const disabled = []; + panel.querySelectorAll('input[type=checkbox]').forEach(cb => { + if (!cb.checked) disabled.push(cb.dataset.mcpToolName); + }); + const total = panel.querySelectorAll('input[type=checkbox]').length; + try { + await fetch(`/api/mcp/servers/${serverId}/tools`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ disabled }), + }); + // Update the count label in the panel + const countLabel = panel.querySelector('.mcp-tools-count'); + if (countLabel) countLabel.textContent = `${total - disabled.length}/${total} enabled`; + // Update badge in the server row + const row = panel.closest('[data-adm-mcp-id]'); + if (row) { + const badge = row.querySelector('.admin-badge'); + if (badge) badge.textContent = `Connected (${total - disabled.length}/${total} tools enabled)`; + } + } catch (e) { /* silent */ } +} + +function initMcpForm() { + const cmdEl = el('adm-mcpCommand'); + if (!cmdEl) return; // MCP form not present in this build — nothing to wire + const transportSel = el('adm-mcpTransport'); + const sseRow = el('adm-mcpSseRow'); + const envRow = el('adm-mcpEnvRow'); + const envFieldsWrap = el('adm-mcpEnvFields'); + const helpBox = el('adm-mcpHelp'); + const cmdRow = cmdEl.parentElement; + let _activeHelp = null; + let _envKeys = []; // track which env keys have dedicated fields + let _activeOauthFile = null; // preset oauthFile config (for Google servers) + let _activeOauth = null; // preset OAuth flow config (provider, scopes, etc.) + + function _clearEnvFields() { + envFieldsWrap.innerHTML = ''; + _envKeys = []; + envRow.style.display = 'none'; + el('adm-mcpEnv').value = ''; + _activeOauth = null; + } + + function _buildEnvFields(envObj, help, preset) { + _clearEnvFields(); + const keys = Object.keys(envObj); + if (!keys.length) return; + _envKeys = keys; + + // Provider dropdown (e.g. for Email IMAP/SMTP) + if (preset?.providerDropdown) { + const pd = preset.providerDropdown; + const row = document.createElement('div'); + row.className = 'admin-model-form-row'; + row.style.cssText = 'gap:6px;align-items:center;'; + const label = document.createElement('span'); + label.style.cssText = 'font-size:11px;opacity:0.55;min-width:0;white-space:nowrap;'; + label.textContent = pd.label || 'Provider'; + const select = document.createElement('select'); + select.style.cssText = 'flex:1;padding:6px 8px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font-size:12px;'; + pd.options.forEach((opt, i) => { + const o = document.createElement('option'); + o.value = i; + o.textContent = opt.name; + select.appendChild(o); + }); + select.addEventListener('change', () => { + const opt = pd.options[parseInt(select.value)]; + for (const [envKey, field] of Object.entries(pd.targets)) { + const inp = envFieldsWrap.querySelector(`.mcp-env-input[data-env-key="${envKey}"]`); + if (inp) inp.value = opt[field] || ''; + } + }); + row.appendChild(label); + row.appendChild(select); + envFieldsWrap.appendChild(row); + // Auto-fill with first provider after inputs are created + setTimeout(() => { + const first = pd.options[0]; + for (const [envKey, field] of Object.entries(pd.targets)) { + const inp = envFieldsWrap.querySelector(`.mcp-env-input[data-env-key="${envKey}"]`); + if (inp && !inp.value) inp.value = first[field] || ''; + } + }, 0); + } + + for (const key of keys) { + const row = document.createElement('div'); + row.className = 'admin-model-form-row'; + row.style.cssText = 'gap:6px;align-items:center;'; + const label = document.createElement('span'); + label.style.cssText = 'font-size:11px;opacity:0.55;min-width:0;white-space:nowrap;'; + label.textContent = key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + const input = document.createElement('input'); + input.type = key.toLowerCase().includes('secret') || key.toLowerCase().includes('token') || key.toLowerCase().includes('key') || key.toLowerCase().includes('password') ? 'password' : 'text'; + input.placeholder = key; + input.dataset.envKey = key; + input.className = 'mcp-env-input'; + input.style.cssText = 'flex:1;'; + if (envObj[key]) input.value = envObj[key]; + row.appendChild(label); + row.appendChild(input); + envFieldsWrap.appendChild(row); + } + // Help toggle link + if (help) { + _activeHelp = help; + const helpLink = document.createElement('a'); + helpLink.textContent = 'How do I get these?'; + helpLink.href = '#'; + helpLink.style.cssText = 'font-size:10.5px;opacity:0.5;margin-top:2px;display:inline-block;'; + helpLink.addEventListener('click', (e) => { + e.preventDefault(); + helpBox.style.display = helpBox.style.display === 'none' ? '' : 'none'; + }); + envFieldsWrap.appendChild(helpLink); + helpBox.textContent = help; + helpBox.style.display = 'none'; + } else { + _activeHelp = null; + helpBox.style.display = 'none'; + } + } + + // Collect env from either dedicated fields or raw JSON fallback + function _collectEnv() { + if (_envKeys.length) { + const obj = {}; + envFieldsWrap.querySelectorAll('.mcp-env-input').forEach(inp => { + if (inp.value.trim()) obj[inp.dataset.envKey] = inp.value.trim(); + }); + return JSON.stringify(obj); + } + return el('adm-mcpEnv').value.trim() || '{}'; + } + + transportSel.addEventListener('change', () => { + const isSse = transportSel.value === 'sse'; + sseRow.style.display = isSse ? '' : 'none'; + cmdRow.style.display = isSse ? 'none' : ''; + if (isSse) { _clearEnvFields(); helpBox.style.display = 'none'; } + }); + + // Preset catalog + const presetSel = el('adm-mcpPreset'); + if (presetSel) { + MCP_PRESETS.forEach((p, i) => { + const opt = document.createElement('option'); + opt.value = i; + opt.textContent = p.name + (Object.keys(p.env).length ? ' (requires keys)' : ''); + presetSel.appendChild(opt); + }); + presetSel.addEventListener('change', () => { + if (presetSel.value === '') return; + const p = MCP_PRESETS[parseInt(presetSel.value)]; + el('adm-mcpName').value = p.name.toLowerCase().replace(/\s+/g, '-'); + transportSel.value = 'stdio'; + el('adm-mcpCommand').value = p.command; + el('adm-mcpArgs').value = JSON.stringify(p.args); + sseRow.style.display = 'none'; + cmdRow.style.display = ''; + _buildEnvFields(p.env, p.help || null, p); + _activeOauthFile = p.oauthFile || null; + _activeOauth = p.oauth || null; + presetSel.value = ''; + // Focus first env field if keys are needed + const firstInput = envFieldsWrap.querySelector('.mcp-env-input'); + if (firstInput) firstInput.focus(); + else el('adm-mcpAddBtn').focus(); + }); + } + + el('adm-mcpAddBtn').addEventListener('click', async () => { + const name = el('adm-mcpName').value.trim(); + const transport = transportSel.value; + const command = el('adm-mcpCommand').value.trim(); + const args = el('adm-mcpArgs').value.trim() || '[]'; + const env = _collectEnv(); + const url = el('adm-mcpUrl').value.trim(); + const msg = el('adm-mcpMsg'); + if (!name) { msg.textContent = 'Name is required'; msg.className = 'admin-error'; return; } + if (transport === 'stdio' && !command) { msg.textContent = 'Command is required for stdio'; msg.className = 'admin-error'; return; } + if (transport === 'sse' && !url) { msg.textContent = 'URL is required for SSE'; msg.className = 'admin-error'; return; } + try { JSON.parse(env); } catch { msg.textContent = 'Env must be valid JSON'; msg.className = 'admin-error'; return; } + const fd = new FormData(); + fd.append('name', name); fd.append('transport', transport); fd.append('command', command); fd.append('args', args); fd.append('env', env); fd.append('url', url); + // If preset has oauthFile config, send credentials for file generation + if (_activeOauthFile) { + const envObj = JSON.parse(env); + fd.append('oauth_file', JSON.stringify({ + dir: _activeOauthFile.dir, + filename: _activeOauthFile.filename, + client_id: envObj.GOOGLE_CLIENT_ID || '', + client_secret: envObj.GOOGLE_CLIENT_SECRET || '', + })); + } + // If preset has OAuth flow config, send it so the server can handle authorization + if (_activeOauth) { + fd.append('oauth_config', JSON.stringify(_activeOauth)); + } + msg.textContent = 'Adding...'; msg.className = ''; + try { + const res = await fetch('/api/mcp/servers', { method: 'POST', body: fd, credentials: 'same-origin' }); + const data = await res.json(); + if (data.needs_oauth) { + msg.innerHTML = `Added ${esc(name)} — Authorize with Google to connect`; + msg.className = 'admin-success'; + } else if (data.connected) { + msg.textContent = `Added ${name} (${data.tool_count} tools discovered)`; msg.className = 'admin-success'; + } else { msg.textContent = `Added but connection failed: ${data.error || 'unknown'}`; msg.className = 'admin-error'; } + el('adm-mcpName').value = ''; el('adm-mcpCommand').value = ''; el('adm-mcpArgs').value = ''; el('adm-mcpUrl').value = ''; + _clearEnvFields(); helpBox.style.display = 'none'; _activeHelp = null; _activeOauthFile = null; _activeOauth = null; + loadMcpServers(); + } catch (e) { msg.textContent = 'Failed: ' + e.message; msg.className = 'admin-error'; } + }); +} + +/* ── Embedding model ── + No settings UI: the embedding model (RAG, semantic memory, tool selection) + is fixed infrastructure that ships with the app, and swapping it would + invalidate every existing vector. Configure via the FASTEMBED_MODEL / + EMBEDDING_URL env vars if you really need to override it. */ + +/* ── RAG ── */ +async function loadRag() { + try { + const res = await fetch('/api/personal'); + const data = await res.json(); + const dirList = el('adm-ragDirList'); + const dirs = data.directories || []; + if (dirs.length === 0) { dirList.innerHTML = '
No directories indexed
'; } + else { + dirList.innerHTML = dirs.map(d => `
${esc(d)}
`).join(''); + dirList.querySelectorAll('[data-adm-rag-dir]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!await uiModule.styledConfirm(`Remove directory "${btn.dataset.admRagDir}" from RAG?`, { confirmText: 'Remove', danger: true })) return; + btn.disabled = true; btn.textContent = '...'; + try { + const res = await fetch('/api/personal/remove_directory?directory=' + encodeURIComponent(btn.dataset.admRagDir), { method: 'DELETE' }); + if (res.ok) { ragMsg('Directory removed'); loadRag(); } + else { const e = await res.json(); ragMsg(e.detail || 'Failed', true); } + } catch (e) { ragMsg('Error: ' + e.message, true); } + }); + }); + } + const fileList = el('adm-ragFileList'); + const files = data.files || []; + if (files.length === 0) { fileList.innerHTML = '
No files indexed
'; } + else { + fileList.innerHTML = files.map(f => { + const size = f.size ? (f.size > 1024 ? (f.size / 1024).toFixed(1) + ' KB' : f.size + ' B') : ''; + return `
${esc(f.name)}${size}
`; + }).join(''); + fileList.querySelectorAll('[data-adm-rag-file]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!await uiModule.styledConfirm(`Delete "${btn.dataset.admRagFile}" from RAG?`, { confirmText: 'Delete', danger: true })) return; + btn.disabled = true; btn.textContent = '...'; + try { + const res = await fetch('/api/personal/file?filepath=' + encodeURIComponent(btn.dataset.admRagFile), { method: 'DELETE' }); + if (res.ok) { ragMsg('File removed'); loadRag(); } + else { const e = await res.json(); ragMsg(e.detail || 'Failed', true); } + } catch (e) { ragMsg('Error: ' + e.message, true); } + }); + }); + } + } catch (e) { + el('adm-ragDirList').innerHTML = '
Failed to load
'; + el('adm-ragFileList').innerHTML = ''; + } +} + +let _ragMsgTimer = null; +function ragMsg(text, isError, persist) { + const s = el('adm-ragStatus'); + s.textContent = text; s.style.color = isError ? 'var(--red)' : 'var(--fg)'; + if (_ragMsgTimer) { clearTimeout(_ragMsgTimer); _ragMsgTimer = null; } + if (text && !persist) _ragMsgTimer = setTimeout(() => { s.textContent = ''; }, 5000); +} + +async function ragUpload(files) { + if (!files || files.length === 0) return; + ragMsg('Uploading ' + files.length + ' file(s)...', false, true); + const fd = new FormData(); + for (const f of files) fd.append('files', f); + try { + const res = await fetch('/api/personal/upload', { method: 'POST', body: fd }); + const data = await res.json(); + if (data.success) { ragMsg(`Uploaded ${data.uploaded.length} file(s), ${data.indexed_count} chunks indexed`); loadRag(); } + else ragMsg(data.detail || 'Upload failed', true); + } catch (e) { ragMsg('Upload error: ' + e.message, true); } +} + +function initRag() { + const dropZone = el('adm-ragDropZone'); + const fileInput = el('adm-ragFileInput'); + dropZone.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', () => ragUpload(fileInput.files)); + dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); }); + dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover')); + dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('dragover'); ragUpload(e.dataTransfer.files); }); + el('adm-ragAddDirBtn').addEventListener('click', async () => { + const dir = el('adm-ragDirInput').value.trim(); + if (!dir) return; + const btn = el('adm-ragAddDirBtn'); + btn.disabled = true; btn.textContent = 'Indexing...'; + try { + const res = await fetch('/api/personal/add_directory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ directory: dir }) }); + const data = await res.json(); + if (data.success) { ragMsg(`Indexed ${data.indexed_count} chunks from directory`); el('adm-ragDirInput').value = ''; loadRag(); } + else ragMsg(data.detail || data.message || 'Failed', true); + } catch (e) { ragMsg('Error: ' + e.message, true); } + btn.disabled = false; btn.textContent = 'Add Directory'; + }); + el('adm-ragReloadBtn').addEventListener('click', async () => { + const btn = el('adm-ragReloadBtn'); + btn.disabled = true; btn.textContent = 'Reloading...'; + try { + const res = await fetch('/api/personal/reload', { method: 'POST' }); + const data = await res.json(); + ragMsg(`Index reloaded: ${data.count} documents`); + loadRag(); + } catch (e) { ragMsg('Reload failed: ' + e.message, true); } + btn.disabled = false; btn.textContent = 'Reload Index'; + }); +} + +/* ═══════════════════════════════════════════ + SYSTEM TAB — Tokens + ═══════════════════════════════════════════ */ +async function loadTokens() { + const list = el('adm-tokenList'); + try { + const res = await fetch('/api/tokens', { credentials: 'same-origin' }); + const tokens = await res.json(); + if (!tokens.length) { list.innerHTML = '
No API tokens
'; return; } + list.innerHTML = tokens.map(t => ` +
+ + +
`).join(''); + list.querySelectorAll('[data-adm-del-token]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!await uiModule.styledConfirm('Revoke this API token? External integrations using it will stop working.', { confirmText: 'Revoke', danger: true })) return; + await fetch(`/api/tokens/${btn.dataset.admDelToken}`, { method: 'DELETE', credentials: 'same-origin' }); + loadTokens(); + }); + }); + } catch (e) { list.innerHTML = '
Failed to load tokens
'; } +} + +function initTokenForm() { + el('adm-tokenAddBtn').addEventListener('click', async () => { + const msg = el('adm-tokenMsg'); + const reveal = el('adm-tokenReveal'); + msg.textContent = ''; msg.className = ''; reveal.style.display = 'none'; + const name = el('adm-tokenName').value.trim(); + if (!name) { msg.textContent = 'Token name is required'; msg.className = 'admin-error'; return; } + const fd = new FormData(); fd.append('name', name); + try { + const res = await fetch('/api/tokens', { method: 'POST', body: fd, credentials: 'same-origin' }); + const data = await res.json(); + if (res.ok) { el('adm-tokenValue').textContent = data.token; reveal.style.display = ''; el('adm-tokenName').value = ''; loadTokens(); } + else { msg.textContent = data.detail || 'Failed'; msg.className = 'admin-error'; } + } catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; } + }); + el('adm-tokenCopyBtn').addEventListener('click', () => { + const val = el('adm-tokenValue').textContent; + navigator.clipboard.writeText(val).then(() => { + el('adm-tokenCopyBtn').textContent = 'Copied!'; + setTimeout(() => { el('adm-tokenCopyBtn').textContent = 'Copy'; }, 2000); + }); + }); +} + +/* ── Webhooks ── */ +async function loadWebhooks() { + const list = el('adm-whList'); + try { + const res = await fetch('/api/webhooks', { credentials: 'same-origin' }); + const hooks = await res.json(); + if (!hooks.length) { list.innerHTML = '
No webhooks configured
'; return; } + list.innerHTML = hooks.map(w => { + const events = (w.events || []).map(e => `${esc(e)}`).join(' '); + const statusBadge = w.last_status_code + ? `${w.last_status_code}` + : ''; + const lastTriggered = w.last_triggered_at ? new Date(w.last_triggered_at).toLocaleString() : 'Never'; + const errorText = w.last_error ? `
Error: ${esc(w.last_error.substring(0, 80))}
` : ''; + return ` +
+
+
${esc(w.name)} ${w.is_active ? '' : 'disabled'} ${w.has_secret ? 'signed' : ''}
+
${esc(w.url)}
+
${events}
+
Last: ${lastTriggered} ${statusBadge}
+ ${errorText} +
+
+ + + +
+
`; + }).join(''); + list.querySelectorAll('[data-adm-wh-test]').forEach(btn => { + btn.addEventListener('click', async () => { + const msg = el('adm-whMsg'); msg.textContent = 'Sending test...'; msg.className = ''; + try { + const res = await fetch(`/api/webhooks/${btn.dataset.admWhTest}/test`, { method: 'POST', credentials: 'same-origin' }); + msg.textContent = res.ok ? 'Test sent!' : 'Test failed'; msg.className = res.ok ? 'admin-success' : 'admin-error'; + setTimeout(() => loadWebhooks(), 1000); + } catch (e) { msg.textContent = 'Failed: ' + e.message; msg.className = 'admin-error'; } + }); + }); + list.querySelectorAll('[data-adm-wh-toggle]').forEach(btn => { + btn.addEventListener('click', async () => { await fetch(`/api/webhooks/${btn.dataset.admWhToggle}`, { method: 'PATCH', credentials: 'same-origin' }); loadWebhooks(); }); + }); + list.querySelectorAll('[data-adm-wh-delete]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!await uiModule.styledConfirm('Delete this webhook?', { confirmText: 'Delete', danger: true })) return; + await fetch(`/api/webhooks/${btn.dataset.admWhDelete}`, { method: 'DELETE', credentials: 'same-origin' }); loadWebhooks(); + }); + }); + } catch (e) { list.innerHTML = '
Failed to load webhooks
'; } +} + +function initWebhookForm() { + el('adm-whAddBtn').addEventListener('click', async () => { + const msg = el('adm-whMsg'); + msg.textContent = ''; msg.className = ''; + const name = el('adm-whName').value.trim(); + const url = el('adm-whUrl').value.trim(); + const secret = el('adm-whSecret').value.trim(); + const events = Array.from(modalEl.querySelectorAll('.adm-wh-event:checked')).map(e => e.value).join(','); + if (!name) { msg.textContent = 'Name is required'; msg.className = 'admin-error'; return; } + if (!url) { msg.textContent = 'URL is required'; msg.className = 'admin-error'; return; } + if (!events) { msg.textContent = 'Select at least one event'; msg.className = 'admin-error'; return; } + const fd = new FormData(); + fd.append('name', name); fd.append('url', url); fd.append('secret', secret); fd.append('events', events); + try { + const res = await fetch('/api/webhooks', { method: 'POST', body: fd, credentials: 'same-origin' }); + if (res.ok) { msg.textContent = 'Webhook added'; msg.className = 'admin-success'; el('adm-whName').value = ''; el('adm-whUrl').value = ''; el('adm-whSecret').value = ''; loadWebhooks(); } + else { const d = await res.json(); msg.textContent = d.detail || 'Failed'; msg.className = 'admin-error'; } + } catch (e) { msg.textContent = 'Failed: ' + e.message; msg.className = 'admin-error'; } + }); +} + +/* ── Features ── */ +const featureLabels = { + web_search: 'Web Search', deep_research: 'Deep Research', + memory: 'Memory', document_editor: 'Document Editor', rag: 'RAG Knowledge Base', sensitive_filter: 'Sensitive Info Filter', + gallery: 'Gallery' +}; + +async function loadFeatures() { + const container = el('adm-featureToggles'); + try { + const res = await fetch('/api/auth/features', { credentials: 'same-origin' }); + const features = await res.json(); + container.innerHTML = Object.entries(featureLabels).map(([key, label]) => ` +
+
${label}
+ +
`).join(''); + container.querySelectorAll('input[data-adm-feature]').forEach(toggle => { + toggle.addEventListener('change', async () => { + const body = {}; body[toggle.dataset.admFeature] = toggle.checked; + await fetch('/api/auth/features', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + }); + }); + } catch (e) { container.innerHTML = '
Failed to load features
'; } +} + +/* ── CalDAV Config ── */ +function initCalDAV() { + const urlIn = el('caldav-url'); + const userIn = el('caldav-user'); + const passIn = el('caldav-pass'); + const saveBtn = el('caldav-save-btn'); + const testBtn = el('caldav-test-btn'); + const status = el('caldav-status'); + if (!urlIn || !saveBtn) return; + + // Load current config + fetch(`${API_BASE}/api/calendar/config`, { credentials: 'same-origin' }) + .then(r => r.json()).then(d => { + urlIn.value = d.caldav_url || ''; + userIn.value = d.caldav_username || ''; + passIn.value = d.caldav_password || ''; + }).catch(() => {}); + + saveBtn.addEventListener('click', async () => { + status.textContent = 'Saving...'; + try { + const res = await fetch(`${API_BASE}/api/calendar/config`, { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caldav_url: urlIn.value, caldav_username: userIn.value, caldav_password: passIn.value }), + }); + const d = await res.json(); + status.textContent = d.ok ? 'Saved' : 'Error'; + status.style.color = d.ok ? 'var(--green)' : 'var(--red)'; + } catch (e) { status.textContent = 'Error'; status.style.color = 'var(--red)'; } + setTimeout(() => { status.textContent = ''; status.style.color = ''; }, 3000); + }); + + testBtn.addEventListener('click', async () => { + status.textContent = 'Testing...'; + try { + // Save first + await fetch(`${API_BASE}/api/calendar/config`, { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ caldav_url: urlIn.value, caldav_username: userIn.value, caldav_password: passIn.value }), + }); + const res = await fetch(`${API_BASE}/api/calendar/test`, { method: 'POST', credentials: 'same-origin' }); + const d = await res.json(); + status.textContent = d.ok ? `Connected (${d.calendars} calendars)` : `Failed: ${d.error}`; + status.style.color = d.ok ? 'var(--green)' : 'var(--red)'; + } catch (e) { status.textContent = 'Error'; status.style.color = 'var(--red)'; } + setTimeout(() => { status.textContent = ''; status.style.color = ''; }, 5000); + }); +} + +/* ── Data Backup (export/import) ── */ +function initBackup() { + el('adm-exportDataBtn').addEventListener('click', async () => { + const btn = el('adm-exportDataBtn'); + const msg = el('adm-backupMsg'); + btn.disabled = true; btn.textContent = 'Exporting...'; msg.textContent = ''; + try { + const res = await fetch('/api/export', { credentials: 'same-origin' }); + if (!res.ok) throw new Error('Export failed'); + const blob = await res.blob(); + const disposition = res.headers.get('Content-Disposition') || ''; + const match = disposition.match(/filename=(.+)/); + const filename = match ? match[1] : 'odysseus_backup.json'; + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + a.click(); + URL.revokeObjectURL(a.href); + msg.textContent = 'Export downloaded.'; msg.className = 'admin-success'; + } catch (e) { msg.textContent = 'Export failed: ' + e.message; msg.className = 'admin-error'; } + btn.disabled = false; btn.textContent = 'Export Data'; + }); + + const fileInput = el('adm-importFile'); + el('adm-importDataBtn').addEventListener('click', () => { fileInput.value = ''; fileInput.click(); }); + fileInput.addEventListener('change', async () => { + const file = fileInput.files[0]; + if (!file) return; + const msg = el('adm-backupMsg'); + const btn = el('adm-importDataBtn'); + btn.disabled = true; btn.textContent = 'Importing...'; msg.textContent = ''; + try { + const text = await file.text(); + const data = JSON.parse(text); + const res = await fetch('/api/import', { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + const result = await res.json(); + if (res.ok && result.ok) { + msg.textContent = result.message || 'Import successful.'; msg.className = 'admin-success'; + } else { + msg.textContent = result.message || result.detail || 'Import failed'; msg.className = 'admin-error'; + } + } catch (e) { msg.textContent = 'Import failed: ' + e.message; msg.className = 'admin-error'; } + btn.disabled = false; btn.textContent = 'Import Data'; + }); +} + +/* ── Danger Zone ── */ +function initDangerZone() { + // Per-category Danger Zone wipes. Each button declares its target + // via data-wipe-kind; one delegated handler handles double-confirm, + // POSTs to /api/admin/wipe/{kind}, and writes the result. + const _LABELS = { + chats: 'chats', memory: 'memory entries', skills: 'skills', + notes: 'notes', tasks: 'tasks', documents: 'documents', + gallery: 'gallery images', calendar: 'calendar items', + }; + const _wipeMsg = el('adm-wipeMsg'); + modalEl.querySelectorAll('[data-wipe-kind]').forEach(btn => { + btn.addEventListener('click', async () => { + const kind = btn.dataset.wipeKind; + const label = _LABELS[kind] || kind; + if (!await uiModule.styledConfirm(`Wipe ALL ${label}? This cannot be undone.`, { confirmText: 'Wipe', danger: true })) return; + if (!await uiModule.styledConfirm(`Really wipe every one of your ${label}?`, { confirmText: 'Yes, wipe everything', danger: true })) return; + btn.disabled = true; const prev = btn.textContent; btn.textContent = 'Wiping…'; + if (_wipeMsg) { _wipeMsg.textContent = ''; _wipeMsg.className = ''; } + try { + const res = await fetch(`/api/admin/wipe/${kind}`, { method: 'DELETE', credentials: 'same-origin' }); + const data = await res.json().catch(() => ({})); + if (res.ok) { + if (_wipeMsg) { _wipeMsg.textContent = `Wiped ${data.count ?? 0} ${label}.`; _wipeMsg.className = 'admin-success'; } + } else { + if (_wipeMsg) { _wipeMsg.textContent = data.detail || 'Failed'; _wipeMsg.className = 'admin-error'; } + } + } catch (e) { + if (_wipeMsg) { _wipeMsg.textContent = 'Request failed: ' + e.message; _wipeMsg.className = 'admin-error'; } + } + btn.disabled = false; btn.textContent = prev; + }); + }); +} + +/* ═══════════════════════════════════════════ + INIT & REFRESH + ═══════════════════════════════════════════ */ +function initAll() { + modalEl = el('settings-modal'); + const inits = [initSignupToggle, initAddUser, initEndpointForm, initMcpForm, initCalDAV, initBackup, initDangerZone, () => settingsModule.initIntegrations()]; + for (const fn of inits) { + try { fn(); } catch (e) { console.error('Admin init error in', fn.name || 'anonymous', e); } + } + initialized = true; + refreshAll(); +} + +function refreshAll() { + loadUsers(); + loadEndpoints(); + loadBuiltinTools(); + loadMcpServers(); +} + +/* ═══════════════════════════════════════════ + PUBLIC API + ═══════════════════════════════════════════ */ +export function _initData() { + if (!initialized) initAll(); + else refreshAll(); +} + +export function open(tab) { + _initData(); + settingsModule.open(tab || 'services'); +} + +export function close() { + settingsModule.close(); +} + +const adminModule = { open, close, _initData, get _initialized() { return initialized; } }; +export default adminModule; diff --git a/static/js/assistant.js b/static/js/assistant.js new file mode 100644 index 0000000..e3bcbe0 --- /dev/null +++ b/static/js/assistant.js @@ -0,0 +1,475 @@ +// Personal Assistant — sidebar entry, settings modal, and chat-header extras. +// +// The Assistant is just a specially-flagged CrewMember whose pinned Session +// lives alongside normal chats. The sidebar button resolves the per-user +// singleton via /api/assistant/session and hands it to selectSession() so we +// reuse the full existing chat render path. + +import uiModule from './ui.js'; +import { selectSession } from './sessions.js'; + +const API = '/api/assistant'; + +let _cachedSettings = null; // most recent GET /api/assistant/settings payload +let _modalEl = null; + +async function _fetchJSON(url, opts = {}) { + const res = await fetch(url, { credentials: 'same-origin', ...opts }); + if (!res.ok) throw new Error(`${url} → ${res.status}`); + return res.json(); +} + +export async function openAssistantChat() { + try { + const info = await _fetchJSON(`${API}/session`); + if (!info?.session_id) { + uiModule.showToast('Assistant session unavailable'); + return; + } + await selectSession(info.session_id); + // Refresh settings cache so the header buttons / gear act on fresh data. + _cachedSettings = null; + } catch (e) { + console.error('openAssistantChat failed:', e); + uiModule.showToast('Could not open assistant'); + } +} + +async function _getSettings(force = false) { + if (!force && _cachedSettings) return _cachedSettings; + _cachedSettings = await _fetchJSON(`${API}/settings`); + return _cachedSettings; +} + +async function _saveSettings(payload) { + const res = await fetch(`${API}/settings`, { + method: 'PATCH', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(`PATCH ${API}/settings → ${res.status}`); + _cachedSettings = await res.json(); + return _cachedSettings; +} + +async function _listTimezones() { + try { + const { timezones } = await _fetchJSON(`${API}/available-timezones`); + return timezones || ['UTC']; + } catch { + return ['UTC']; + } +} + +async function _runCheckInNow(taskId) { + try { + await fetch(`${API}/run/${encodeURIComponent(taskId)}`, { + method: 'POST', + credentials: 'same-origin', + }); + uiModule.showToast('Check-in running…'); + } catch (e) { + console.error(e); + uiModule.showToast('Could not run check-in'); + } +} + +// ── Settings modal ───────────────────────────────────────────────────────── + +function _closeModal() { + if (_modalEl) { + _modalEl.classList.add('hidden'); + _modalEl.style.display = ''; + } +} + +function _ensureModalEl() { + if (_modalEl) return _modalEl; + const modal = document.createElement('div'); + modal.id = 'assistant-settings-modal'; + modal.className = 'modal hidden'; + modal.innerHTML = ` + `; + document.body.appendChild(modal); + modal.querySelector('#assistant-settings-close').addEventListener('click', _closeModal); + modal.addEventListener('click', (e) => { + if (e.target === modal) _closeModal(); + }); + _modalEl = modal; + return modal; +} + +function _esc(s) { + return (s || '').replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + }[c])); +} + +// Tool groups for the tool selector UI +const TOOL_GROUPS = { + 'Email': ['list_emails', 'read_email', 'send_email', 'reply_to_email', 'archive_email', 'delete_email', 'mark_email_read'], + 'Calendar & Notes': ['manage_calendar', 'manage_notes', 'manage_tasks'], + 'Knowledge': ['web_search', 'read_file', 'manage_memory', 'manage_rag', 'search_chats'], + 'Code': ['bash', 'python', 'write_file'], + 'Documents': ['create_document', 'edit_document', 'update_document', 'suggest_document'], + 'AI & Models': ['chat_with_model', 'second_opinion', 'ask_teacher', 'pipeline', 'list_models', 'generate_image'], + 'System': ['manage_session', 'manage_endpoints', 'manage_mcp', 'manage_settings', 'manage_skills', 'manage_webhooks', 'manage_tokens', 'manage_documents', 'create_session', 'list_sessions', 'send_to_session', 'ui_control'], +}; + +async function _fetchEndpoints() { + try { + const eps = await _fetchJSON('/api/model-endpoints'); + return Array.isArray(eps) ? eps : []; + } catch { return []; } +} + +function _renderSettingsBody(body, data, tzList) { + const crew = data.crew || {}; + const checkIns = data.check_ins || []; + const enabledTools = new Set(crew.enabled_tools || []); + const tzOptions = tzList.map((z) => + `` + ).join(''); + const checkInsHTML = checkIns.map((c) => ` +
+
+ + + + +
+ +
+ ${c.next_run ? `next run: ${_esc(c.next_run)}` : ''} + ${c.last_run ? ` · last run: ${_esc(c.last_run)}` : ''} + ${typeof c.run_count === 'number' ? ` · ${c.run_count} runs` : ''} +
+
`).join(''); + + // Tool selector grouped by category + let toolsHTML = ''; + for (const [group, tools] of Object.entries(TOOL_GROUPS)) { + toolsHTML += `
${_esc(group)}`; + for (const t of tools) { + const checked = enabledTools.has(t) ? ' checked' : ''; + const label = t.replace(/_/g, ' '); + toolsHTML += ``; + } + toolsHTML += '
'; + } + + body.innerHTML = ` +
+ +
+ Personality + + + +
+
+ +
+
+ + +
+
+ Tools + + + +
+ ${toolsHTML} +
+
+
+
Daily check-ins
+ ${checkInsHTML || '
No check-ins configured.
'} +
+
+ + +
+
+ `; + + // ── Populate model/endpoint dropdowns ── + const epSelect = body.querySelector('#assistant-endpoint'); + const modelSelect = body.querySelector('#assistant-model'); + _fetchEndpoints().then(endpoints => { + let epHTML = ''; + for (const ep of endpoints) { + if (!ep.is_enabled) continue; + const url = ep.base_url || ''; + const name = ep.name || url; + const sel = (crew.endpoint_url && url.includes(crew.endpoint_url.replace('/v1', '').replace(/\/$/, ''))) ? ' selected' : ''; + epHTML += ``; + } + epSelect.innerHTML = epHTML; + // When endpoint changes, load its models + epSelect.addEventListener('change', async () => { + const url = epSelect.value; + if (!url) { modelSelect.innerHTML = ''; return; } + const ep = endpoints.find(e => e.base_url === url); + if (!ep) return; + modelSelect.innerHTML = ''; + try { + const models = await _fetchJSON(`/api/model-endpoints/${ep.id}/models`); + let mHTML = ''; + for (const m of (models.models || models || [])) { + const mid = typeof m === 'string' ? m : (m.id || m.name || ''); + if (!mid) continue; + const sel = mid === crew.model ? ' selected' : ''; + mHTML += ``; + } + modelSelect.innerHTML = mHTML || ''; + } catch { modelSelect.innerHTML = ''; } + }); + // Trigger initial model load if endpoint is pre-selected + if (epSelect.value) epSelect.dispatchEvent(new Event('change')); + }); + + // ── Tool toggle buttons ── + body.querySelector('#assistant-tools-all')?.addEventListener('click', () => { + body.querySelectorAll('.assistant-tool-cb').forEach(cb => { cb.checked = true; }); + }); + body.querySelector('#assistant-tools-none')?.addEventListener('click', () => { + body.querySelectorAll('.assistant-tool-cb').forEach(cb => { cb.checked = false; }); + }); + + // ── Character picker — populate from presets + templates ── + const charPick = body.querySelector('#assistant-character-pick'); + const personalityEl = body.querySelector('#assistant-personality'); + if (charPick && personalityEl) { + (async () => { + try { + const [presetsRaw, templates] = await Promise.all([ + _fetchJSON('/api/presets').catch(() => ({})), + _fetchJSON('/api/presets/templates').catch(() => []), + ]); + // Presets API returns a dict keyed by preset ID, not an array + const allPresets = []; + if (presetsRaw && typeof presetsRaw === 'object' && !Array.isArray(presetsRaw)) { + for (const [key, val] of Object.entries(presetsRaw)) { + if (val && typeof val === 'object' && val.system_prompt) { + allPresets.push({ ...val, _key: key }); + } + } + } else if (Array.isArray(presetsRaw)) { + allPresets.push(...presetsRaw); + } + const allTemplates = Array.isArray(templates) ? templates : []; + let opts = ''; + if (allPresets.length) { + opts += ''; + for (const p of allPresets) { + if (!p.system_prompt) continue; + const name = p.character_name || p.name || p._key || 'Unnamed'; + opts += ``; + } + opts += ''; + } + if (allTemplates.length) { + opts += ''; + for (const t of allTemplates) { + if (!t.system_prompt && !t.personality) continue; + const name = t.character_name || t.name || 'Unnamed'; + opts += ``; + } + opts += ''; + } + charPick.innerHTML = opts; + charPick._presets = allPresets; + charPick._templates = allTemplates; + } catch {} + })(); + charPick.addEventListener('change', () => { + const val = charPick.value; + if (!val) return; + const [type, id] = val.split(':', 2); + let prompt = ''; + let name = ''; + if (type === 'preset') { + const p = (charPick._presets || []).find(x => (x._key || x.name || x.id) === id); + if (p) { prompt = p.system_prompt || p.personality || ''; name = p.character_name || p.name || p._key || ''; } + } else if (type === 'template') { + const t = (charPick._templates || []).find(x => (x.id || x.name) === id); + if (t) { prompt = t.system_prompt || t.personality || ''; name = t.character_name || t.name || ''; } + } + if (prompt) personalityEl.value = prompt; + const nameEl = body.querySelector('#assistant-name'); + if (name && nameEl) nameEl.value = name; + charPick.selectedIndex = 0; + }); + } + + // ── Event wiring ── + body.querySelector('#assistant-settings-cancel').addEventListener('click', _closeModal); + body.querySelector('#assistant-settings-save').addEventListener('click', async () => { + const selectedTools = []; + body.querySelectorAll('.assistant-tool-cb:checked').forEach(cb => selectedTools.push(cb.value)); + const payload = { + name: body.querySelector('#assistant-name').value.trim(), + personality: body.querySelector('#assistant-personality').value, + timezone: body.querySelector('#assistant-timezone').value || null, + model: body.querySelector('#assistant-model').value || null, + endpoint_url: body.querySelector('#assistant-endpoint').value || null, + enabled_tools: selectedTools, + check_ins: Array.from(body.querySelectorAll('.assistant-checkin-row')).map((row) => ({ + id: row.dataset.taskId, + name: row.querySelector('.assistant-checkin-name').value.trim(), + scheduled_time: row.querySelector('.assistant-checkin-time').value, + prompt: row.querySelector('.assistant-checkin-prompt').value, + enabled: row.querySelector('.assistant-checkin-enabled').checked, + })), + }; + try { + await _saveSettings(payload); + uiModule.showToast('Assistant settings saved'); + _closeModal(); + } catch (e) { + console.error(e); + uiModule.showToast('Save failed'); + } + }); + body.querySelectorAll('.assistant-checkin-run').forEach((btn) => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const row = btn.closest('.assistant-checkin-row'); + if (!row?.dataset.taskId) return; + const taskId = row.dataset.taskId; + btn.disabled = true; + btn.textContent = 'Running...'; + await _runCheckInNow(taskId); + _closeModal(); + // Poll until done, then navigate to assistant chat + const sid = _cachedSettings?.crew?.session_id; + const _poll = setInterval(async () => { + try { + const res = await fetch(`${API}/run-status/${encodeURIComponent(taskId)}`, { credentials: 'same-origin' }); + if (!res.ok) return; + const data = await res.json(); + if (data.status === 'done' || data.status === 'error') { + clearInterval(_poll); + // Hard navigate to force full reload of the session + if (sid) { + window.location.href = window.location.pathname + '#' + sid; + window.location.reload(); + } + } + } catch {} + }, 2000); + setTimeout(() => clearInterval(_poll), 90000); + }); + }); +} + +export async function openAssistantSettings() { + const modal = _ensureModalEl(); + modal.classList.remove('hidden'); + modal.style.display = 'flex'; + const body = modal.querySelector('#assistant-settings-body'); + body.innerHTML = '
Loading…
'; + try { + const [data, tzList] = await Promise.all([_getSettings(true), _listTimezones()]); + _renderSettingsBody(body, data, tzList); + } catch (e) { + console.error(e); + body.innerHTML = '
Could not load assistant settings.
'; + } +} + +// Sidebar wiring removed — Assistant chat + settings now live as +// Activity / Settings tabs inside the Tasks modal (see tasks.js). The +// exports below are still used by tasks.js to surface those views. + +// ── Chat-header affordances when the assistant session is active ─────────── + +async function _ensureHeaderAffordances(sessionId) { + try { + const settings = await _getSettings(); + if (settings?.crew?.session_id !== sessionId) return; + } catch { + return; + } + const headerRight = document.querySelector('.chat-header-right, #chat-header .actions, .chat-header'); + if (!headerRight) return; + if (headerRight.querySelector('#assistant-header-gear')) return; + const gear = document.createElement('button'); + gear.id = 'assistant-header-gear'; + gear.type = 'button'; + gear.title = 'Assistant settings'; + gear.className = 'chat-header-btn'; + gear.innerHTML = ''; + gear.addEventListener('click', openAssistantSettings); + headerRight.appendChild(gear); +} + +// Run a short polling check after session loads so we can add the gear button +// once the chat header DOM is in place. Fire-and-forget. +function _watchForAssistantActivation() { + let retries = 0; + const interval = setInterval(async () => { + retries += 1; + const activeSessionId = window.sessionModule?.getActiveSession?.()?.id + || document.body.dataset.activeSessionId + || null; + if (activeSessionId) { + await _ensureHeaderAffordances(activeSessionId); + } + if (retries > 120) clearInterval(interval); // ~2 minutes + }, 1000); +} + +// ── Boot ─────────────────────────────────────────────────────────────────── + +function _boot() { + _watchForAssistantActivation(); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', _boot); +} else { + _boot(); +} + +const assistantModule = { + openAssistantChat, + openAssistantSettings, +}; + +export default assistantModule; diff --git a/static/js/calendar.js b/static/js/calendar.js new file mode 100644 index 0000000..0abf019 --- /dev/null +++ b/static/js/calendar.js @@ -0,0 +1,3318 @@ +/** + * Calendar Module — CalDAV-backed month/week/year calendar. + */ + +import uiModule from './ui.js'; +import spinnerModule from './spinner.js'; +import * as Modals from './modalManager.js'; +import { makeWindowDraggable } from './windowDrag.js'; +import { attachColorPicker } from './colorPicker.js'; +import { + WEEKDAYS, MONTHS, MON_SHORT, + CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE, + _trashIcon, _moreIcon, _bellIcon, + _isCalBgImage, _calBgImageUrl, _calBgCss, + _ds, _addDays, _shiftDT, _tzOffset, _localDateOf, +} from './calendar/utils.js'; + +const API_BASE = window.location.origin; +// Open a file picker, upload the chosen image, return the URL string. +function _pickCalBgImage() { + return new Promise(resolve => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.style.cssText = 'position:fixed; left:-9999px; top:-9999px;'; + document.body.appendChild(input); + let done = false; + const finish = (v) => { if (done) return; done = true; input.remove(); resolve(v); }; + input.addEventListener('change', async () => { + const file = input.files?.[0]; + if (!file) return finish(null); + const fd = new FormData(); + fd.append('files', file); + try { + const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: fd, credentials: 'same-origin' }); + const data = await res.json(); + const fileId = data.files?.[0]?.id; + if (!fileId) throw new Error('Upload failed'); + finish(`${API_BASE}/api/upload/${fileId}`); + } catch { finish(null); } + }); + setTimeout(() => { if (!done && !input.files?.length) finish(null); }, 30000); + input.click(); + }); +} + +let _open = false; +// Set when the calendar opens so the first month render scrolls today's +// cell into view — the grid scrolls on mobile and today can sit below the +// fold, so we always land on the current date. +let _scrollToTodayOnOpen = false; +let _currentDate = new Date(); +let _events = []; +let _allEvents = {}; +let _fetchedRanges = []; +let _calendars = []; +let _hiddenCals = new Set(); +let _hiddenTypes = new Set(); // event_type values to hide +// "Only important" filter — when true, only events with importance +// high/critical render, regardless of their category. Toggled via the "!" +// chip; orthogonal to _hiddenTypes (which deals with event_type categories). +let _onlyImportant = false; + +let _filtersCollapsed = localStorage.getItem('cal-filters-collapsed') === '1'; +let _selectedDay = null; +let _view = 'month'; +let _searchQuery = ''; +let _escHandler = null; +let _modal = null; + +let _dragUid = null; +let _sidebarWasOpen = false; +let _slideDir = 0; // -1 = prev, +1 = next, 0 = none + +// (Single undo stack lives at `_calUndoStack` further below; this used to +// hold a one-deep `_lastUndo` which has been collapsed into that stack.) + +function _showCalUndoToast(label, undoFn) { + // Push onto the shared undo stack (also used by month drag-drop) so + // Cmd/Ctrl+Z and the toast button consume the same source of truth. + _pushCalUndo({ label, run: undoFn }); + const isMac = /Mac|iPhone|iPad/.test(navigator.platform || '') || /Mac/.test(navigator.userAgent || ''); + uiModule.showToast(label, { + action: 'Undo', + actionHint: isMac ? '⌘Z' : 'Ctrl+Z', + duration: 6000, + onAction: _popAndRunCalUndo, + }); +} + +// ── API ── + +function _rangeIsCached(start, end) { + // Check if [start, end] is fully covered by any single fetched range + for (const [s, e] of _fetchedRanges) { + if (s <= start && e >= end) return true; + } + return false; +} + +function _filterPool(start, end) { + // Return all events in pool that overlap [start, end) + return Object.values(_allEvents).filter(ev => { + const evStart = ev.all_day ? ev.dtstart : _localDateOf(ev.dtstart); + const evEnd = ev.all_day ? ev.dtend : _localDateOf(ev.dtend || ev.dtstart); + return evStart < end && evEnd >= start; + }).sort((a, b) => a.dtstart < b.dtstart ? -1 : 1); +} + +async function _fetchEvents(start, end, force) { + if (!force && _rangeIsCached(start, end)) { + _events = _filterPool(start, end); + return; + } + // Render from pool immediately if we have any cached data + const hasCache = Object.keys(_allEvents).length > 0; + if (hasCache) _events = _filterPool(start, end); + const fetchPromise = fetch(`${API_BASE}/api/calendar/events?start=${start}&end=${end}`, { credentials: 'same-origin' }) + .then(r => r.json()) + .then(data => { + // On first fetch after cache load, replace pool entirely to avoid + // stale/duplicate UIDs from a previous backend (e.g. CalDAV → SQLite) + if (hasCache && _fetchedRanges.length === 0) _allEvents = {}; + (data.events || []).forEach(ev => { _allEvents[ev.uid] = ev; }); + _fetchedRanges.push([start, end]); + _events = _filterPool(start, end); + if (typeof _saveCache === 'function') _saveCache(); + // Re-render in background when new data arrives (if calendar still open) + if (_open && hasCache) _render(); + }) + .catch(e => { console.error('Calendar: failed to fetch events', e); }); + // If we have cache, don't block on fetch — return immediately so render is instant + if (hasCache) return; + // No cache — must await the fetch + await fetchPromise; +} + +// Prefetch surrounding months in background — fire-and-forget, no blocking +function _prefetchAdjacent() { + const ranges = []; + if (_view === 'month' || _view === 'week') { + // Prefetch ±2 months around current + for (let offset = -2; offset <= 2; offset++) { + if (offset === 0) continue; + const d = new Date(_currentDate.getFullYear(), _currentDate.getMonth() + offset, 1); + ranges.push(_monthRange(d)); + } + } else if (_view === 'year') { + // Prefetch prev/next year + ranges.push([`${_currentDate.getFullYear() - 1}-01-01`, `${_currentDate.getFullYear()}-01-01`]); + ranges.push([`${_currentDate.getFullYear() + 1}-01-01`, `${_currentDate.getFullYear() + 2}-01-01`]); + } + // Fire all prefetches in parallel, ignore failures + for (const [s, e] of ranges) { + if (_rangeIsCached(s, e)) continue; + fetch(`${API_BASE}/api/calendar/events?start=${s}&end=${e}`, { credentials: 'same-origin' }) + .then(r => r.json()) + .then(d => { + (d.events || []).forEach(ev => { _allEvents[ev.uid] = ev; }); + _fetchedRanges.push([s, e]); + }) + .catch(() => {}); + } +} + +let _calendarsError = null; +// Guard so we only trigger an on-open CalDAV pull once per page load — +// every list/render path calls _fetchCalendars, but we only want to +// hit the remote server lazily on the first user open. +let _caldavSyncedOnce = false; +async function _fetchCalendars() { + _calendarsError = null; + try { + const res = await fetch(`${API_BASE}/api/calendar/calendars`, { credentials: 'same-origin' }); + const data = await res.json(); + _calendars = data.calendars || []; + if (data.error) _calendarsError = data.error; + _calendars.forEach((c, i) => { + if (!c.color || c.color.startsWith('<')) c.color = CAL_PALETTE[i % CAL_PALETTE.length]; + }); + } catch (e) { _calendars = []; _calendarsError = e.message || 'Connection failed'; } + + // First open: fire a background CalDAV pull. We don't await — the + // initial render uses whatever's already cached locally, and the + // sync's writes show up on the next paint after it resolves. + if (!_caldavSyncedOnce) { + _caldavSyncedOnce = true; + _syncCaldav(false); + } +} + +// Trigger a CalDAV pull. `interactive=true` waits for the result and +// refreshes the UI; false fires-and-forgets (used on first open). Both +// no-op silently if CalDAV isn't configured. +async function _syncCaldav(interactive) { + try { + const res = await fetch(`${API_BASE}/api/calendar/sync`, { + method: 'POST', credentials: 'same-origin', + }); + const data = await res.json().catch(() => ({})); + if (interactive) return data; + // Background path: if the pull actually changed anything, drop + // local caches and re-render so new events appear. + const changed = (data.calendars || 0) > 0 && ((data.events || 0) > 0 || (data.deleted || 0) > 0); + if (changed) { + _allEvents = {}; _fetchedRanges = []; + try { localStorage.removeItem(LS_KEY); } catch (_) {} + await _fetchCalendars(); + _render(); + } + } catch (e) { + if (interactive) return { errors: [e.message || 'Sync failed'] }; + } +} + +function _optimisticEvent(data, uid) { + const cal = _calendars.find(c => c.href === data.calendar_href) || _calendars[0]; + return { + uid, + summary: data.summary || '', + dtstart: data.dtstart, + dtend: data.dtend || data.dtstart, + all_day: !!data.all_day, + description: data.description || '', + location: data.location || '', + rrule: data.rrule || '', + calendar: cal?.name || '', + calendar_href: data.calendar_href || cal?.href || '', + // Per-event color override (including the bg: sentinel for custom + // backgrounds) wins over the parent calendar's default hex. + color: (data.color !== undefined && data.color !== null) ? data.color : (cal?.color || ''), + }; +} + +// v2 review error-handling MEDs: every fetch here previously checked +// only `.then(r => r.json())` with no `r.ok` test. A 500/404 still +// resolved the promise and the optimistic state got promoted to truth. +// All three flows now inspect `r.ok` and roll back the optimistic +// state + surface a toast on the failure path. +async function _createEvent(data) { + const tempUid = 'temp-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8); + _allEvents[tempUid] = _optimisticEvent(data, tempUid); + fetch(`${API_BASE}/api/calendar/events`, { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), + }).then(async r => { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }).then(d => { + if (d.uid) { + delete _allEvents[tempUid]; + _allEvents[d.uid] = _optimisticEvent(data, d.uid); + _saveCache && _saveCache(); + if (_open) _render(); + } + }).catch((e) => { + delete _allEvents[tempUid]; + if (_open) _render(); + if (window.uiModule) window.uiModule.showError('Failed to create event: ' + (e?.message || 'unknown')); + }); + return { uid: tempUid }; +} + +async function _updateEvent(uid, data) { + const merged = { ...(_allEvents[uid] || {}), ...data }; + const _preMergeBackup = _allEvents[uid]; + _allEvents[uid] = _optimisticEvent(merged, uid); + fetch(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, { + method: 'PUT', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), + }).then(r => { + if (!r.ok) throw new Error('HTTP ' + r.status); + _saveCache && _saveCache(); + }).catch((e) => { + if (_preMergeBackup) _allEvents[uid] = _preMergeBackup; + else delete _allEvents[uid]; + if (_open) _render(); + if (window.uiModule) window.uiModule.showError('Failed to update event: ' + (e?.message || 'unknown')); + }); + return { ok: true }; +} + +async function _deleteEvent(uid) { + const backup = _allEvents[uid]; + delete _allEvents[uid]; + fetch(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, { + method: 'DELETE', credentials: 'same-origin', + }).then(r => { + if (!r.ok) throw new Error('HTTP ' + r.status); + _saveCache && _saveCache(); + }).catch((e) => { + if (backup) _allEvents[uid] = backup; + if (window.uiModule) window.uiModule.showError('Failed to delete event: ' + (e?.message || 'unknown')); + if (_open) _render(); + }); + return { ok: true }; +} + +// ── Date helpers ── +// _ds, _addDays, _shiftDT, _localDateOf, _tzOffset live in ./calendar/utils.js +// _monthRange / _weekRange / _today depend on _ds so they stay here. + +function _today() { return _ds(new Date()); } + +function _monthRange(d) { + const y = d.getFullYear(), m = d.getMonth(); + const first = new Date(y, m, 1); + const dow = (first.getDay() + 6) % 7; + const gs = new Date(y, m, 1 - dow); + const ge = new Date(gs); ge.setDate(gs.getDate() + 42); + return [_ds(gs), _ds(ge)]; +} + +function _weekRange(d) { + const dow = (d.getDay() + 6) % 7; + const s = new Date(d); s.setDate(d.getDate() - dow); + const e = new Date(s); e.setDate(s.getDate() + 7); + return [_ds(s), _ds(e)]; +} + +function _eventsForDay(dateStr) { + return _events.filter(e => { + if (!_eventVisible(e)) return false; + if (e.all_day) { + // Zero-duration all-day event (dtstart == dtend) is a single-day event + if (e.dtstart === e.dtend) return e.dtstart === dateStr; + return e.dtstart <= dateStr && e.dtend > dateStr; + } + // Multi-day timed events: show on each day they span + const startDate = _localDateOf(e.dtstart); + const endDate = _localDateOf(e.dtend); + if (startDate !== endDate) return startDate <= dateStr && endDate >= dateStr; + return startDate === dateStr; + }); +} + +function _calColor(ev) { + // Custom bg-image colors fall back to the parent calendar's solid hex + // in spots that need a plain color (dots, multi-day bars, week tile + // borders). The full image is shown via _calItemBgStyle() where it + // makes sense (event-item rows). + if (_isCalBgImage(ev.color)) { + const c = _calendars.find(c => c.href === ev.calendar_href); + return c?.color || 'var(--accent)'; + } + if (ev.color && !ev.color.startsWith('<')) return ev.color; + const c = _calendars.find(c => c.href === ev.calendar_href); + return c?.color || 'var(--accent)'; +} + +// Extra inline style for an event row when the event has a custom BG image. +// Returns '' for normal solid-color events. +function _calItemBgStyle(ev) { + if (!_isCalBgImage(ev.color)) return ''; + const url = _calBgImageUrl(ev.color).replace(/'/g, "\\'"); + return `background-image: linear-gradient(color-mix(in srgb, var(--bg) 70%, transparent), color-mix(in srgb, var(--bg) 70%, transparent)), url('${url}'); background-size: cover; background-position: center;`; +} + +function _todayCount() { + const t = _today(); + return _events.filter(e => { + if (!_eventVisible(e)) return false; + if (e.all_day) { + if (e.dtstart === e.dtend) return e.dtstart === t; + return e.dtstart <= t && e.dtend > t; + } + return _localDateOf(e.dtstart) === t; + }).length; +} + +// Per-event ⋮ menu: Remind me / Delete +function _wireQuickDelete(body) { + body.querySelectorAll('.cal-event-more').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const uid = btn.dataset.uid; + if (!uid) return; + const ev = _allEvents[uid]; + if (!ev) return; + _showEventMoreMenu(ev, btn); + }); + }); +} + +function _clampDropdown(dropdown, anchorRect) { + const margin = 8; + const vw = window.innerWidth; + const vh = window.innerHeight; + const r = dropdown.getBoundingClientRect(); + const w = r.width, h = r.height; + // Horizontal: prefer right-aligned with anchor, clamp to viewport + let left = anchorRect.right - w; + if (left + w > vw - margin) left = vw - margin - w; + if (left < margin) left = margin; + // Vertical: below anchor if it fits, else above + let top = anchorRect.bottom + 4; + if (top + h > vh - margin) { + const above = anchorRect.top - 4 - h; + top = above >= margin ? above : Math.max(margin, vh - margin - h); + } + dropdown.style.left = `${left}px`; + dropdown.style.top = `${top}px`; + dropdown.style.right = 'auto'; +} + +function _showEventMoreMenu(ev, anchor) { + document.querySelectorAll('.cal-event-dropdown').forEach(d => d.remove()); + const dropdown = document.createElement('div'); + dropdown.className = 'cal-event-dropdown'; + const rect = anchor.getBoundingClientRect(); + dropdown.style.cssText = `position:fixed;z-index:10001;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:0px;visibility:hidden;`; + + const _item = (icon, label, onClick, danger) => { + const it = document.createElement('div'); + it.className = 'dropdown-item-compact' + (danger ? ' dropdown-item-danger' : ''); + it.innerHTML = `${icon}${label}`; + it.addEventListener('click', (e) => { e.stopPropagation(); onClick(); }); + return it; + }; + + const _editIcon = ''; + + dropdown.appendChild(_item(_editIcon, 'Edit', () => { + dropdown.remove(); + _showEventForm(ev); + })); + + dropdown.appendChild(_item(_trashIcon, 'Delete', async () => { + dropdown.remove(); + const name = ev.summary ? `"${ev.summary}"` : 'this event'; + const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true }); + if (!ok) return; + try { await _deleteEvent(ev.uid); setTimeout(() => _render(), 100); } catch (_) {} + }, true)); + + document.body.appendChild(dropdown); + dropdown._anchorRect = rect; + _clampDropdown(dropdown, rect); + dropdown.style.visibility = ''; + const close = (ev2) => { + if (!dropdown.contains(ev2.target) && ev2.target !== anchor) { + dropdown.remove(); + document.removeEventListener('click', close, true); + } + }; + setTimeout(() => document.addEventListener('click', close, true), 10); +} + +async function _createEventReminder(ev, dueDate) { + // Store the reminder as an absolute UTC instant (with the Z suffix) so the + // notification poller fires at the right wall-clock moment regardless of: + // - the event's source timezone (CalDAV/import may carry a TZID), + // - the user's current local timezone differing from when the reminder + // was created, + // - any naive ISO mis-interpretation downstream. + // Both notes.js and the calendar poller already use `new Date(due_date)`, + // which handles Z-suffixed ISO correctly and converts back to local time + // when displayed. + const iso = new Date(dueDate).toISOString(); + const startFmt = ev.all_day + ? new Date(ev.dtstart).toLocaleDateString([], { weekday:'short', month:'short', day:'numeric' }) + : new Date(ev.dtstart).toLocaleString([], { weekday:'short', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }); + const summary = ev.summary || '(no title)'; + const loc = ev.location ? ` @ ${ev.location}` : ''; + const text = `${summary}${loc} — ${startFmt}`; + const payload = { + title: `Reminder: ${summary}`, + note_type: 'todo', + items: [{ text, done: false, checked: false }], + label: 'calendar', + due_date: iso, + source: 'calendar', + // Persist the EVENT'S absolute start so the notification body can be + // computed live at fire time ("Starts in 5 min") instead of using a + // stale string baked at scheduling time. + event_dtstart: new Date(ev.dtstart).toISOString(), + }; + try { + const res = await fetch(`/api/notes`, { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error('Failed'); + const fmt = dueDate.toLocaleString([], { month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }); + if (uiModule.showToast) uiModule.showToast(`Reminder set for ${fmt}`); + try { window.notesModule?.refreshDueBadge?.({ force: true }); } catch {} + if ('Notification' in window && Notification.permission === 'default') { + try { Notification.requestPermission(); } catch {} + } + } catch (e) { + if (uiModule.showError) uiModule.showError('Failed to create reminder'); + } +} + +// ── Sidebar collapse ── + +function _collapseSidebar() { + const sb = document.getElementById('sidebar'); + if (sb && !sb.classList.contains('hidden')) { + // Only remember the prior state on desktop. On mobile the sidebar is an + // overlay that the user intentionally swipes/taps away when the tool + // opens — popping it back on close is unwanted. + if (window.innerWidth >= 700) _sidebarWasOpen = true; + sb.classList.add('hidden'); + if (window.syncRailSide) window.syncRailSide(); + } +} + +function _restoreSidebar() { + if (_sidebarWasOpen) { + const sb = document.getElementById('sidebar'); + if (sb) { sb.classList.remove('hidden'); if (window.syncRailSide) window.syncRailSide(); } + _sidebarWasOpen = false; + } +} + +// ── Badge ── + +const BADGE_SEEN_KEY = 'odysseus-calendar-badge-seen'; + +function _todayStr() { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +function _isBadgeSeenToday() { + try { return localStorage.getItem(BADGE_SEEN_KEY) === _todayStr(); } catch { return false; } +} + +function _markBadgeSeen() { + try { localStorage.setItem(BADGE_SEEN_KEY, _todayStr()); } catch {} +} + +function _updateBadge() { + const btn = document.getElementById('tool-calendar-btn'); + if (!btn) return; + let badge = btn.querySelector('.cal-badge'); + const count = _todayCount(); + if (count > 0 && !_isBadgeSeenToday()) { + if (!badge) { badge = document.createElement('span'); badge.className = 'cal-badge'; btn.appendChild(badge); } + badge.title = `${count} event${count > 1 ? 's' : ''} today`; + } else if (badge) badge.remove(); +} + +// ── Modal ── + +function _getModal() { + if (_modal) return _modal; + _modal = document.createElement('div'); + _modal.id = 'calendar-modal'; + _modal.className = 'modal'; + _modal.style.display = 'none'; + _modal.innerHTML = ` + `; + document.body.appendChild(_modal); + _modal.querySelector('#cal-close').addEventListener('click', closeCalendar); + _modal.addEventListener('click', (e) => { if (e.target === _modal) closeCalendar(); }); + // Make draggable — replaced ~50 lines of inline drag/dock plumbing with + // a single call to the shared helper. Calendar doesn't support fullscreen + // snap so no fsClass / enter/exit callbacks here. + { + const content = _modal.querySelector('.modal-content'); + const header = _modal.querySelector('.modal-header'); + if (content && header) { + makeWindowDraggable(_modal, { content, header }); + } + } + return _modal; +} + +// ── Render dispatch ── + +// Stash the quick-add input's state (focus + caret + value) before a +// re-render so background fetches don't kick the user out mid-type. Picked +// up by _wireAll after the new DOM lands. +let _qaPendingRestore = null; +function _saveQuickAddState() { + const el = document.getElementById('cal-quickadd'); + if (!el || document.activeElement !== el) { _qaPendingRestore = null; return; } + _qaPendingRestore = { + value: el.value, + selStart: el.selectionStart, + selEnd: el.selectionEnd, + }; +} + +// True while the user is actively in the quick-add field. On mobile a +// programmatic re-focus after a DOM rebuild can't reopen the soft keyboard, so +// we must NOT swap the calendar body out from under an active quick-add — we +// defer the render and flush it on blur instead. +let _renderPending = false; +let _qaSubmitting = false; +function _qaTyping() { + const el = document.getElementById('cal-quickadd'); + return !!el && document.activeElement === el; +} + +// Update only the search-results portion of the day-detail panel, keeping +// the search input element itself in the DOM so the on-screen keyboard +// doesn't dismiss between keystrokes. Used by the search input's `input` +// listener instead of a full _render(). +function _updateDaySearchResults() { + const dayDetail = document.querySelector('.cal-day-detail'); + if (!dayDetail) { _render(); return; } + // Searching forces a selected day so the panel is always available + // (matches the logic in _render). + if (_searchQuery && !_selectedDay) _selectedDay = _today(); + const ds = _selectedDay || _today(); + // Build the day-detail HTML in a detached node so we can extract its + // children (results, header, etc.) without touching the live input. + const tmp = document.createElement('div'); + tmp.innerHTML = _dayDetailHTML(ds); + const fresh = tmp.querySelector('.cal-day-detail'); + if (!fresh) return; + // Remove every child of the live day-detail except the search-wrap. + const keep = dayDetail.querySelector('.cal-search-wrap'); + [...dayDetail.children].forEach(c => { if (c !== keep) c.remove(); }); + // Move children from the fresh build into the live panel, skipping + // the duplicate search-wrap. + [...fresh.children].forEach(c => { + if (!c.classList.contains('cal-search-wrap')) dayDetail.appendChild(c); + }); + // Re-wire click handlers on the newly-inserted event rows. + dayDetail.querySelectorAll('.cal-event-item').forEach(it => { + it.addEventListener('click', (e) => { + if (e.target.closest('.cal-event-more')) return; + const ev = _events.find(x => x.uid === it.dataset.uid); + if (ev) _showEventForm(ev); + }); + }); + dayDetail.querySelector('#cal-add-day')?.addEventListener('click', () => _showEventForm(null, _selectedDay)); + _wireQuickDelete(dayDetail); +} + +// Step between calendar views by "zoom level" — pinch IN goes year→month→week, +// pinch OUT goes the other way. Agenda is its own thing so it's excluded. +function _zoomView(direction) { + const chain = ['year', 'month', 'week']; + const idx = chain.indexOf(_view); + if (idx < 0) return; + const next = idx + direction; + if (next < 0 || next >= chain.length) return; + _view = chain[next]; + _render(); +} + +// Monotonic counter bumped on every _render() call. The async per-view +// render functions snapshot this at entry and bail before painting DOM if +// a newer render has already started. Stops fast prev/next/today clicks +// from letting a slow fetch clobber the latest layout. +let _renderToken = 0; +function _isStaleRender(t) { return t !== _renderToken; } + +function _render() { + // Don't rebuild the DOM while the user is typing in quick-add — defer it. + if (_qaTyping()) { _renderPending = true; return; } + // Empty state: no calendars configured or connection failed + if (!_calendars.length) { + _renderEmpty(); + return; + } + _renderToken++; + // Search now lives inside the day-detail panel and filters in place, + // so we don't replace the whole calendar body when a query is active. + // Force a selected day in month/week so the panel (and its search box) + // is always available. + if (_searchQuery && (_view === 'month' || _view === 'week') && !_selectedDay) { + _selectedDay = _today(); + } + if (_view === 'agenda') _renderAgenda(); + else if (_view === 'year') _renderYear(); + else if (_view === 'week') _renderWeek(); + else _renderMonth(); + // Prefetch adjacent in background after a short delay + setTimeout(() => _prefetchAdjacent(), 200); +} + +function _renderEmpty() { + const body = document.getElementById('cal-body'); + if (!body) return; + const hasError = !!_calendarsError; + body.innerHTML = ` +
+ + + + + + +
${hasError ? 'Calendar unavailable' : 'No calendars yet'}
+
${hasError ? _e(_calendarsError) : 'Create a local calendar, import an .ics file, or sync via CalDAV.'}
+ ${hasError ? ` + + ` : ` +
+ + +
+ + `} +
`; + document.getElementById('cal-goto-settings')?.addEventListener('click', () => { + closeCalendar(); + const modal = document.getElementById('settings-modal'); + if (modal) { + modal.classList.remove('hidden'); + const tab = modal.querySelector('[data-settings-tab="integrations"]'); + if (tab) tab.click(); + } + }); + // New / Import open the calendar settings panel; the panel already + // has the "New calendar" button and the .ics file picker. Import + // triggers the file picker immediately so it's a one-click flow. + document.getElementById('cal-empty-new')?.addEventListener('click', () => { + _showCalSettings(); + setTimeout(() => document.getElementById('cal-settings-add')?.click(), 50); + }); + document.getElementById('cal-empty-import')?.addEventListener('click', () => { + _showCalSettings(); + setTimeout(() => document.getElementById('cal-import-file')?.click(), 50); + }); + document.getElementById('cal-empty-caldav')?.addEventListener('click', (e) => { + e.preventDefault(); + closeCalendar(); + // Integrations is an admin tab — settingsModule.open() only sets + // the .active class for admin tabs; the actual panel renders via + // adminModule.open(). Without the admin-first branch the modal + // appears with Integrations highlighted but showing the previous + // panel, so the user has to click the tab again to land there. + if (window.adminModule && typeof window.adminModule.open === 'function') { + try { window.adminModule.open('integrations'); return; } catch (_) {} + } + if (window.settingsModule && typeof window.settingsModule.open === 'function') { + try { window.settingsModule.open('integrations'); return; } catch (_) {} + } + const modal = document.getElementById('settings-modal'); + if (modal) { + modal.classList.remove('hidden'); + const tab = modal.querySelector('[data-settings-tab="integrations"]'); + if (tab) tab.click(); + } + }); +} + +// ── Header + Filters (shared) ── + +function _isoWeekNumber(d) { + // ISO 8601: weeks start Monday; week 1 contains the year's first Thursday. + const tgt = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + // Move to Thursday of this week (so the year is determined correctly). + tgt.setDate(tgt.getDate() + 3 - ((tgt.getDay() + 6) % 7)); + const yearStart = new Date(tgt.getFullYear(), 0, 1); + return Math.ceil(((tgt - yearStart) / 86400000 + 1) / 7); +} + +function _headerHTML() { + const weekSuffix = _view === 'week' + ? ` W${_isoWeekNumber(_currentDate)}` + : ''; + return `
+
+ + + ${_view === 'agenda' ? 'Upcoming' : MONTHS[_currentDate.getMonth()] + ' ' + _currentDate.getFullYear()}${weekSuffix} + +
+
+
+ ${['week', 'month', 'year', 'agenda'].map(v => + `` + ).join('')} +
+ + + ${_filtersToggleHTML()} + +
+
+
+ + + +
`; +} + +function _filtersData() { + // Build chip HTML once; reused by toolbar toggle + chip-row renderers. + let calFilters = ''; + if (_calendars.length > 1) { + calFilters = _calendars.map(c => { + const off = _hiddenCals.has(c.href); + return ``; + }).join(''); + } + const presentTypes = new Set(_events.map(e => e.event_type).filter(Boolean)); + const hasUntagged = _events.some(e => !e.event_type); + const hasImportant = _events.some(e => e.importance === 'high' || e.importance === 'critical'); + if (hasImportant) presentTypes.add('!'); + const typeOrder = ['!', 'work', 'personal', 'health', 'travel', 'meal', 'social', 'admin', 'other']; + let typeFilters = ''; + for (const t of typeOrder) { + if (!presentTypes.has(t)) continue; + const off = (t === '!') ? false : _hiddenTypes.has(t); + const active = (t === '!') && _onlyImportant; + const label = t === '!' ? '! important' : t; + typeFilters += ``; + } + if (hasUntagged) { + const off = _hiddenTypes.has('__untagged__'); + typeFilters += ``; + } + return { calFilters, typeFilters }; +} + +function _filtersToggleHTML() { + // Inline toolbar button only. The chip row renders separately below. + const { calFilters, typeFilters } = _filtersData(); + if (!calFilters && !typeFilters) return ''; + return ``; +} + +function _filtersRowHTML() { + // Chip row beneath the toolbar — empty when collapsed. + if (_filtersCollapsed) return ''; + const { calFilters, typeFilters } = _filtersData(); + if (!calFilters && !typeFilters) return ''; + const sep = (calFilters && typeFilters) ? '·' : ''; + return `
${calFilters}${sep}${typeFilters}
`; +} + +function _eventVisible(e) { + if (_hiddenCals.has(e.calendar_href)) return false; + // "Only important" mode short-circuits category filters: nothing else + // matters except whether the event itself is high/critical. + if (_onlyImportant) { + return e.importance === 'high' || e.importance === 'critical'; + } + if (e.event_type) { + if (_hiddenTypes.has(e.event_type)) return false; + } else if (_hiddenTypes.has('__untagged__')) { + return false; + } + return true; +} + +// ── Month View ── + +async function _renderMonth() { + const body = document.getElementById('cal-body'); + if (!body) return; + const _tk = _renderToken; + const [rs, re] = _monthRange(_currentDate); + await _fetchEvents(rs, re); + if (_isStaleRender(_tk)) return; // newer render already in flight + const today = _today(); + const y = _currentDate.getFullYear(), m = _currentDate.getMonth(); + + const slideClass = _slideDir > 0 ? ' cal-slide-in-right' : _slideDir < 0 ? ' cal-slide-in-left' : ''; + _slideDir = 0; + let h = _headerHTML() + _filtersRowHTML() + `
`; + h += '
'; + for (const wd of WEEKDAYS) h += `
${wd}
`; + h += '
'; + + const first = new Date(y, m, 1); + const dow = (first.getDay() + 6) % 7; + const gs = new Date(y, m, 1 - dow); + + const multiDay = _events.filter(e => { + if (!_eventVisible(e)) return false; + const startD = new Date(e.dtstart), endD = new Date(e.dtend); + return Math.round((endD - startD) / 86400000) > 1 || (!e.all_day && _localDateOf(e.dtstart) !== _localDateOf(e.dtend)); + }); + const multiUids = new Set(multiDay.map(e => e.uid)); + + // Render 6 week rows. Each row is a positioned container that holds + // 7 day cells AND any multi-day bars that span the row, drawn as an + // absolute overlay on top of the cells. This avoids the old "each + // bar lives inside its start cell and gets clipped at the cell edge" + // problem so a multi-day event reads as one continuous line across + // every day it covers. + for (let row = 0; row < 6; row++) { + // Count how many multi-day bars overlap any column in this row so + // cells can reserve top padding for them — otherwise the bars + // (drawn as absolute overlays) sit on top of the day-number and + // single-event rows below. + const rowStartCd0 = new Date(gs); rowStartCd0.setDate(gs.getDate() + row * 7); + const rowEndCd0 = new Date(gs); rowEndCd0.setDate(gs.getDate() + row * 7 + 6); + const rowStart0 = _ds(rowStartCd0); + const rowEnd0 = _ds(rowEndCd0); + const barsInRow = multiDay.filter(md => { + const mdStart = _localDateOf(md.dtstart); + const mdEnd = _localDateOf(md.dtend); + return !(mdEnd < rowStart0 || mdStart > rowEnd0); + }).length; + h += `
`; + // Day cells for this row + for (let col = 0; col < 7; col++) { + const i = row * 7 + col; + const cd = new Date(gs); cd.setDate(gs.getDate() + i); + const d = _ds(cd); + const isOther = cd.getMonth() !== m; + const cls = 'cal-day' + (isOther ? ' cal-other' : '') + (d === today ? ' cal-today' : '') + (d === _selectedDay ? ' cal-selected' : ''); + h += `
${cd.getDate()}`; + // Single events — show up to 3 inline rows (multi-day events are + // drawn separately as an overlay below). + const singles = _eventsForDay(d).filter(e => !multiUids.has(e.uid)); + if (singles.length) { + const maxInline = window.innerWidth <= 768 ? 2 : 3; + const showInline = singles.slice(0, maxInline); + for (const ev of showInline) { + const t = ev.all_day ? '' : _fmtTime(ev.dtstart); + const _impMark = ev.importance === 'critical' ? '!!' + : ev.importance === 'high' ? '!' : ''; + const _typeBadge = ev.event_type ? `` : ''; + h += `
+ + ${_typeBadge} + ${t ? `${t}` : ''} + ${_impMark}${_e(ev.summary)} +
`; + } + if (singles.length > maxInline) h += `
+${singles.length - maxInline} more
`; + } + h += '
'; + } + // Multi-day overlay bars for this row. Stack each bar one slot below + // the previous so two events on the same row don't overlap. + let barSlot = 0; + for (const md of multiDay) { + const mdStart = _localDateOf(md.dtstart); + const mdEnd = _localDateOf(md.dtend); + // Compute the row's date range + const rowStartCd = new Date(gs); rowStartCd.setDate(gs.getDate() + row * 7); + const rowEndCd = new Date(gs); rowEndCd.setDate(gs.getDate() + row * 7 + 6); + const rowStart = _ds(rowStartCd); + const rowEnd = _ds(rowEndCd); + if (mdEnd < rowStart || mdStart > rowEnd) continue; // not in this row + // Column within the row where the bar starts and how many days it spans + const startCol = mdStart < rowStart ? 0 : ((new Date(mdStart + 'T00:00:00') - rowStartCd) / 86400000); + const endCol = mdEnd > rowEnd ? 6 : ((new Date(mdEnd + 'T00:00:00') - rowStartCd) / 86400000); + const startColInt = Math.round(startCol); + const endColInt = Math.round(endCol); + const span = endColInt - startColInt + 1; + h += `
${_e(md.summary)}
`; + barSlot++; + } + h += '
'; + } + h += '
'; + if (_selectedDay) h += _dayDetailHTML(_selectedDay); + // Capture the grid's scroll position before innerHTML wipes it — + // selecting a day shouldn't jump the user back to the top of the + // month, that hides the row they just clicked. + const _prevGrid = body.querySelector('.cal-grid'); + const _prevScroll = _prevGrid ? _prevGrid.scrollTop : 0; + // If the user grabbed the quick-add field mid-fetch, skip the swap (which + // would destroy the focused input + drop the keyboard) and defer until blur. + if (_qaTyping()) { _renderPending = true; return; } + body.innerHTML = h; + const _newGrid = body.querySelector('.cal-grid'); + if (_newGrid && _prevScroll) _newGrid.scrollTop = _prevScroll; + // On open, scroll today's cell into view so the current date is always + // visible even when its row sits below the fold (mobile scrolls the grid). + if (_scrollToTodayOnOpen) { + _scrollToTodayOnOpen = false; + const todayCell = body.querySelector('.cal-day.cal-today'); + if (todayCell && _newGrid) { + requestAnimationFrame(() => { + try { todayCell.scrollIntoView({ block: 'center', behavior: 'auto' }); } + catch { _newGrid.scrollTop = Math.max(0, todayCell.offsetTop - _newGrid.clientHeight / 2); } + }); + } + } + _wireAll(body); + _updateBadge(); +} + +// ── Week View ── + +// Hour-grid week view. Each column is a day; a vertical hour rail on the +// left labels 6am–11pm. Events render as absolute-positioned blocks. +// Drag on an empty cell to scaffold a new event for that range. +// Render the full 24-hour day so events at any hour are reachable. +// On first open the grid auto-scrolls to ~7 AM so the default landing +// still matches the old "morning is visible" behaviour; subsequent +// renders preserve whatever scrollTop the user is on. +const WEEK_HOUR_START = 0; +const WEEK_HOUR_END = 24; +const WK_DEFAULT_SCROLL_HOUR = 7; +let _wkScrollY = null; // remembered scroll position across renders +let _wkScrolledOnce = false; // tracks the first auto-scroll-to-morning +// pixel height per hour — user-zoomable, persisted in localStorage so the +// preference sticks across reloads. Bounds keep the layout sane. +const WK_PX_MIN = 28; +const WK_PX_MAX = 120; +const WK_PX_DEFAULT = 64; +let WEEK_HOUR_PX = (() => { + const saved = parseInt(localStorage.getItem('cal-wk-hour-px') || '', 10); + return (saved >= WK_PX_MIN && saved <= WK_PX_MAX) ? saved : WK_PX_DEFAULT; +})(); +function _wkSetZoom(px) { + // Capture the hour currently at the top of the viewport so the same + // hour stays put across the zoom-induced re-render — otherwise the + // saved pixel scrollTop misaligns at the new px/hour. + const wrap = document.querySelector('.cal-wk-wrap'); + let _hourAtTop = null; + if (wrap && WEEK_HOUR_PX) _hourAtTop = wrap.scrollTop / WEEK_HOUR_PX; + WEEK_HOUR_PX = Math.max(WK_PX_MIN, Math.min(WK_PX_MAX, Math.round(px))); + try { localStorage.setItem('cal-wk-hour-px', String(WEEK_HOUR_PX)); } catch {} + if (_hourAtTop != null) _wkScrollY = Math.round(_hourAtTop * WEEK_HOUR_PX); + if (_view === 'week') _render(); +} +function _wkZoomBy(delta) { _wkSetZoom(WEEK_HOUR_PX + delta); } +function _wkHours() { return WEEK_HOUR_END - WEEK_HOUR_START; } + +// Round a Y offset (px from top of grid) to the nearest 15-minute slot, +// returns minutes-from-WEEK_HOUR_START. +function _wkPxToMin(y) { + const totalMin = (y / WEEK_HOUR_PX) * 60; + return Math.max(0, Math.round(totalMin / 15) * 15); +} +function _wkMinToHHMM(mins) { + const t = WEEK_HOUR_START * 60 + mins; + const h = Math.floor(t / 60), m = t % 60; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} +function _wkFormatHourLabel(h) { + const use12 = (new Date()).toLocaleString().toLowerCase().match(/am|pm/); + if (!use12) return `${String(h).padStart(2, '0')}:00`; + const ampm = h < 12 ? 'AM' : 'PM'; + const hh = ((h + 11) % 12) + 1; + return `${hh} ${ampm}`; +} +function _wkEventTopHeight(ev, dayStr) { + // Convert event start/end (local) into top/height in px relative to the + // day's grid origin. Clamp to visible window. + // The dtstart/dtend strings are like "2026-05-11T09:00:00" (no tz), so + // pull the time portion directly to avoid TZ math drift; falls back to + // Date math if the string isn't shaped as expected. + const _toMin = (iso, fallbackDate) => { + if (!iso) return null; + const m = iso.match(/T(\d{2}):(\d{2})/); + if (m) { + // If the event spans into a previous/next day, clamp to today's bounds. + const evDate = iso.slice(0, 10); + if (evDate < fallbackDate) return 0; // event started before today + if (evDate > fallbackDate) return 24 * 60; // event ends after today + return parseInt(m[1], 10) * 60 + parseInt(m[2], 10); + } + // All-day or date-only — treat as start of day. + return 0; + }; + const startMin = _toMin(ev.dtstart, dayStr); + const endMin = _toMin(ev.dtend, dayStr) ?? (startMin + 60); + const gridStart = WEEK_HOUR_START * 60; + const gridEnd = WEEK_HOUR_END * 60; + const sMin = Math.max(gridStart, startMin); + const eMin = Math.min(gridEnd, Math.max(endMin, sMin + 15)); + const top = (sMin - gridStart) * (WEEK_HOUR_PX / 60); + const height = Math.max(18, (eMin - sMin) * (WEEK_HOUR_PX / 60)); + return { top, height }; +} + +async function _renderWeek() { + const body = document.getElementById('cal-body'); + if (!body) return; + const _tk = _renderToken; + // Stash current scroll so we can restore after re-render (zoom, drag, + // etc. all rebuild the body). + const _prevWrap = body.querySelector('.cal-wk-wrap'); + if (_prevWrap) _wkScrollY = _prevWrap.scrollTop; + const [rs, re] = _weekRange(_currentDate); + await _fetchEvents(rs, re); + if (_isStaleRender(_tk)) return; + const today = _today(); + const ws = new Date(rs + 'T00:00:00'); + + // Build day list once (used for both all-day strip and grid). + const days = []; + for (let i = 0; i < 7; i++) { + const d = new Date(ws); d.setDate(ws.getDate() + i); + days.push({ d, ds: _ds(d), idx: i }); + } + + // Hour rail on the left. The spacer up top hosts the zoom controls + // (toolbar is already crowded — this empty 56-px corner is a free home). + let railHtml = `
+
+ + +
`; + for (let h = WEEK_HOUR_START; h < WEEK_HOUR_END; h++) { + railHtml += `
${_wkFormatHourLabel(h)}
`; + } + railHtml += '
'; + + // Day columns + let colsHtml = '
'; + for (const { d, ds, idx } of days) { + const isToday = ds === today; + const allDayEvents = _eventsForDay(ds).filter(e => _eventVisible(e) && e.all_day); + const timedEvents = _eventsForDay(ds).filter(e => _eventVisible(e) && !e.all_day); + + const isSun = d.getDay() === 0; + colsHtml += `
`; + colsHtml += `
${WEEKDAYS[idx]}${d.getDate()}
`; + // All-day strip + colsHtml += `
`; + for (const ev of allDayEvents) { + colsHtml += `
${_e(ev.summary)}
`; + } + colsHtml += `
`; + // Hour-grid body + colsHtml += `
`; + // Hour cell lines + for (let h = WEEK_HOUR_START; h < WEEK_HOUR_END; h++) { + colsHtml += `
`; + } + // Now-line indicator (only on today) + if (isToday) { + const now = new Date(); + const minSinceStart = (now.getHours() - WEEK_HOUR_START) * 60 + now.getMinutes(); + if (minSinceStart >= 0 && minSinceStart <= _wkHours() * 60) { + const top = minSinceStart * (WEEK_HOUR_PX / 60); + colsHtml += `
`; + } + } + // Timed event blocks. Each block carries a 6-px bottom-edge handle + // for drag-to-resize (extend duration without opening the form). + for (const ev of timedEvents) { + const { top, height } = _wkEventTopHeight(ev, ds); + const t = _fmtTime(ev.dtstart) + '–' + _fmtTime(ev.dtend); + // Custom-bg events get the image as the tile background; solid-color + // events keep the original tinted treatment. + let bgDecl; + if (_isCalBgImage(ev.color)) { + const _url = _calBgImageUrl(ev.color).replace(/'/g, "\\'"); + bgDecl = `background-image: linear-gradient(color-mix(in srgb, var(--bg) 55%, transparent), color-mix(in srgb, var(--bg) 55%, transparent)), url('${_url}'); background-size: cover; background-position: center;`; + } else { + bgDecl = `background:color-mix(in srgb, ${_calColor(ev)} 18%, var(--bg));`; + } + colsHtml += `
`; + colsHtml += `
${_e(ev.summary)}
`; + colsHtml += `
${t}
`; + colsHtml += `
`; + colsHtml += `
`; + } + colsHtml += `
`; // /cal-wk-grid /cal-wk-col + } + colsHtml += '
'; + + let h = _headerHTML() + _filtersRowHTML(); + h += `
${railHtml}${colsHtml}
`; + if (_selectedDay) h += _dayDetailHTML(_selectedDay); + // If the user grabbed the quick-add field mid-fetch, skip the swap (which + // would destroy the focused input + drop the keyboard) and defer until blur. + if (_qaTyping()) { _renderPending = true; return; } + body.innerHTML = h; + _wireAll(body); + + // Single click (tap) an event block → open edit form. A drag-to-move or + // drag-to-resize sets `justResized` in its mouseup so the trailing click + // doesn't also open the form; the bottom-edge resize handle is ignored too. + body.querySelectorAll('.cal-wk-block, .cal-wk-allday-event').forEach(el => { + el.addEventListener('click', (e) => { + if (e.target.classList.contains('cal-wk-block-resize')) return; + if (el.dataset.justResized) { delete el.dataset.justResized; return; } + e.stopPropagation(); + const ev = _events.find(x => x.uid === el.dataset.uid); + if (ev) _showEventForm(ev); + }); + }); + + // Drag the body of a block to reschedule (different day or time). The + // bottom-edge handle has its own gesture (resize) and stops here, so + // the two never fight. Same duration is preserved. + body.querySelectorAll('.cal-wk-block').forEach(block => { + block.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; + if (e.target.classList.contains('cal-wk-block-resize')) return; // resize wins + e.preventDefault(); + const uid = block.dataset.uid; + const ev = _events.find(x => x.uid === uid); + if (!ev) return; + const cols = Array.from(body.querySelectorAll('.cal-wk-grid')); + if (!cols.length) return; + // Original timing + const m1 = (ev.dtstart || '').match(/T(\d{2}):(\d{2})/); + const m2 = (ev.dtend || '').match(/T(\d{2}):(\d{2})/); + const startMin0 = m1 ? parseInt(m1[1], 10) * 60 + parseInt(m1[2], 10) : 0; + const endMin0 = m2 ? parseInt(m2[1], 10) * 60 + parseInt(m2[2], 10) : startMin0 + 60; + const durationMin = Math.max(15, endMin0 - startMin0); + + // Where did the cursor grab the block? (offset from block-top in px) + const blockRect = block.getBoundingClientRect(); + const grabOffsetPx = e.clientY - blockRect.top; + + // Ghost that follows the cursor across columns. + const ghost = block.cloneNode(true); + ghost.classList.add('cal-wk-block-ghost'); + ghost.style.pointerEvents = 'none'; + ghost.style.opacity = '0.85'; + ghost.querySelector('.cal-wk-block-resize')?.remove(); + // Mute the original while dragging. + block.style.opacity = '0.25'; + + let nextDs = null; + let nextStartMin = startMin0; + let activeGrid = null; + let moved = false; + const _attachGhost = (grid) => { + if (activeGrid === grid) return; + activeGrid = grid; + grid.appendChild(ghost); + }; + const onMove = (mv) => { + moved = true; + // Pick the column under the cursor. If the cursor lands between + // columns (gutter/border) or just outside the grid horizontally, + // snap to the nearest column instead of giving up — that's why + // horizontal cross-day drag could feel stuck before. + let cur = cols.find(c => { + const r = c.getBoundingClientRect(); + return mv.clientX >= r.left && mv.clientX <= r.right; + }); + if (!cur) { + let best = null, bestDist = Infinity; + for (const c of cols) { + const r = c.getBoundingClientRect(); + const cx = (r.left + r.right) / 2; + const d = Math.abs(mv.clientX - cx); + if (d < bestDist) { bestDist = d; best = c; } + } + cur = best; + } + if (!cur) return; + _attachGhost(cur); + const r = cur.getBoundingClientRect(); + const yIn = Math.max(0, Math.min(cur.clientHeight, mv.clientY - r.top)); + // Subtract the grab offset so the cursor stays at the same spot + // inside the block as you drag it around. + const blockTopY = yIn - grabOffsetPx; + const snapMin = Math.max(0, Math.round(_wkPxToMin(blockTopY) / 15) * 15); + nextStartMin = WEEK_HOUR_START * 60 + snapMin; + nextDs = cur.dataset.date; + const top = (nextStartMin - WEEK_HOUR_START * 60) * (WEEK_HOUR_PX / 60); + const height = durationMin * (WEEK_HOUR_PX / 60); + ghost.style.top = top + 'px'; + ghost.style.height = height + 'px'; + const hh = String(Math.floor(nextStartMin / 60)).padStart(2, '0'); + const mm = String(nextStartMin % 60).padStart(2, '0'); + const hh2 = String(Math.floor((nextStartMin + durationMin) / 60)).padStart(2, '0'); + const mm2 = String((nextStartMin + durationMin) % 60).padStart(2, '0'); + const timeEl = ghost.querySelector('.cal-wk-block-time'); + if (timeEl) timeEl.textContent = `${hh}:${mm}–${hh2}:${mm2}`; + }; + const onUp = async (up) => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + ghost.remove(); + block.style.opacity = ''; + // Only suppress the trailing click-open if the user actually dragged — + // a plain click (no movement) must still open the event. + if (moved) block.dataset.justResized = '1'; + // Decide whether anything actually moved. + const oldDs = (ev.dtstart || '').slice(0, 10); + if (!nextDs) return; + if (nextDs === oldDs && nextStartMin === startMin0) return; + // Snapshot the original times so we can offer an Undo. + const prevDtstart = ev.dtstart; + const prevDtend = ev.dtend; + const newEndMin = nextStartMin + durationMin; + const hh = String(Math.floor(nextStartMin / 60)).padStart(2, '0'); + const mm = String(nextStartMin % 60).padStart(2, '0'); + const hh2 = String(Math.floor(newEndMin / 60)).padStart(2, '0'); + const mm2 = String((newEndMin) % 60).padStart(2, '0'); + const _tz = _tzOffset(); + const newDtstart = `${nextDs}T${hh}:${mm}:00${_tz}`; + const newDtend = `${nextDs}T${hh2}:${mm2}:00${_tz}`; + try { + await _updateEvent(uid, { dtstart: newDtstart, dtend: newDtend }); + _render(); + _showCalUndoToast('Moved event', async () => { + try { + await _updateEvent(uid, { dtstart: prevDtstart, dtend: prevDtend }); + _render(); + } catch (err) { console.error('Undo failed:', err); } + }); + } catch { + _render(); + } + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + }); + + // Drag the bottom edge of a timed block to extend / shrink the event. + // Snaps to 15-min increments; releases with a PUT to /api/calendar/events. + body.querySelectorAll('.cal-wk-block .cal-wk-block-resize').forEach(handle => { + handle.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; + e.stopPropagation(); + e.preventDefault(); + const block = handle.closest('.cal-wk-block'); + const grid = block.parentElement; + const ds = grid.dataset.date; + const uid = block.dataset.uid; + const ev = _events.find(x => x.uid === uid); + if (!ev || !grid || !ds) return; + const startMin = (() => { + const m = (ev.dtstart || '').match(/T(\d{2}):(\d{2})/); + return m ? parseInt(m[1], 10) * 60 + parseInt(m[2], 10) : 0; + })(); + const initialTop = parseFloat(block.style.top || '0'); + const gridRect = grid.getBoundingClientRect(); + let newEndMin = startMin; + let resized = false; + const onMove = (mv) => { + resized = true; + const y = Math.max(0, Math.min(grid.clientHeight, mv.clientY - gridRect.top)); + // Snap to 15-min increments; enforce a 15-min minimum duration. + newEndMin = Math.max(startMin + 15, Math.round(_wkPxToMin(y) / 15) * 15); + const newHeight = Math.max(18, (newEndMin - startMin) * (WEEK_HOUR_PX / 60)); + block.style.height = newHeight + 'px'; + const timeEl = block.querySelector('.cal-wk-block-time'); + if (timeEl) { + const hh = String(Math.floor(newEndMin / 60)).padStart(2, '0'); + const mm = String(newEndMin % 60).padStart(2, '0'); + timeEl.textContent = `${_fmtTime(ev.dtstart)}–${hh}:${mm}`; + } + }; + const onUp = async () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + if (resized) block.dataset.justResized = '1'; + if (newEndMin === startMin) return; + const prevDtend = ev.dtend; + const hh = String(Math.floor(newEndMin / 60)).padStart(2, '0'); + const mm = String(newEndMin % 60).padStart(2, '0'); + const newDtend = `${ds}T${hh}:${mm}:00${_tzOffset()}`; + try { + await _updateEvent(uid, { dtend: newDtend }); + _render(); + _showCalUndoToast('Resized event', async () => { + try { + await _updateEvent(uid, { dtend: prevDtend }); + _render(); + } catch (err) { console.error('Undo failed:', err); } + }); + } catch (err) { + // Roll back the visual on failure + _render(); + } + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + }); + + // Drag-to-create on empty grid: mousedown on a cell, drag down, release. + body.querySelectorAll('.cal-wk-grid').forEach(grid => { + grid.addEventListener('mousedown', (e) => { + // Don't start a drag-create when the press lands on an existing event. + if (e.target.closest('.cal-wk-block')) return; + if (e.button !== 0) return; + e.preventDefault(); + const rect = grid.getBoundingClientRect(); + const ds = grid.dataset.date; + const startY = e.clientY - rect.top; + const ghost = document.createElement('div'); + ghost.className = 'cal-wk-ghost'; + grid.appendChild(ghost); + const onMove = (mv) => { + const y2 = Math.max(0, Math.min(grid.clientHeight, mv.clientY - rect.top)); + const y1 = Math.min(startY, y2); + const yEnd = Math.max(startY, y2); + const startMin = _wkPxToMin(y1); + const endMin = Math.max(_wkPxToMin(yEnd), startMin + 15); + ghost.style.top = (startMin / 60) * WEEK_HOUR_PX + 'px'; + ghost.style.height = ((endMin - startMin) / 60) * WEEK_HOUR_PX + 'px'; + ghost.dataset.start = _wkMinToHHMM(startMin); + ghost.dataset.end = _wkMinToHHMM(endMin); + ghost.textContent = `${ghost.dataset.start} – ${ghost.dataset.end}`; + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + const startHHMM = ghost.dataset.start; + const endHHMM = ghost.dataset.end; + ghost.remove(); + if (!startHHMM || !endHHMM) return; + // Open the bespoke event form pre-filled with this slot. + _showEventFormForRange(ds, startHHMM, endHHMM); + }; + onMove(e); + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + }); + + // Restore scroll. Default-land at WK_DEFAULT_SCROLL_HOUR the first time + // week view opens; afterwards keep the user's last position. + const _wrap = body.querySelector('.cal-wk-wrap'); + if (_wrap) { + if (_wkScrollY != null) { + _wrap.scrollTop = _wkScrollY; + } else if (!_wkScrolledOnce) { + _wrap.scrollTop = WK_DEFAULT_SCROLL_HOUR * WEEK_HOUR_PX; + _wkScrolledOnce = true; + } + } + + // Zoom buttons in the rail-spacer corner. + document.getElementById('cal-wk-zoom-in')?.addEventListener('click', (e) => { e.stopPropagation(); _wkZoomBy(+12); }); + document.getElementById('cal-wk-zoom-out')?.addEventListener('click', (e) => { e.stopPropagation(); _wkZoomBy(-12); }); + + // Keyboard zoom (`+` / `-`), Ctrl/Cmd-wheel zoom — both only fire while + // we're in week view and no text input has focus. + if (!body._wkZoomKeysWired) { + body._wkZoomKeysWired = true; + document.addEventListener('keydown', (e) => { + if (_view !== 'week') return; + const tag = (document.activeElement?.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea' || tag === 'select') return; + if (e.key === '+' || e.key === '=' ) { e.preventDefault(); _wkZoomBy(+12); } + else if (e.key === '-' || e.key === '_') { e.preventDefault(); _wkZoomBy(-12); } + else if (e.key === '0') { e.preventDefault(); _wkSetZoom(WK_PX_DEFAULT); } + }); + } + body.querySelector('.cal-wk-wrap')?.addEventListener('wheel', (e) => { + if (!(e.ctrlKey || e.metaKey)) return; + e.preventDefault(); + _wkZoomBy(e.deltaY < 0 ? +8 : -8); + }, { passive: false }); + + _updateBadge(); +} + +function _showEventFormForRange(ds, startHHMM, endHHMM) { + // Open the new-event form, then seed the time inputs with the dragged + // range and force the details panel open so the user can see/adjust. + _showEventForm(null, ds, ds); + requestAnimationFrame(() => { + const startEl = document.getElementById('cal-f-start'); + const endEl = document.getElementById('cal-f-end'); + if (startEl) startEl.value = startHHMM; + if (endEl) endEl.value = endHHMM; + startEl?.dispatchEvent(new Event('input')); + // Auto-expand details so the time fields are visible when someone + // arrived here via drag-to-create rather than the +New button. + document.querySelector('.cal-form-bespoke')?.classList.add('is-expanded'); + const details = document.getElementById('cal-form-details'); + if (details) details.setAttribute('aria-hidden', 'false'); + }); +} + +// ── Agenda View ── + +async function _renderAgenda() { + const body = document.getElementById('cal-body'); + if (!body) return; + const _tk = _renderToken; + // Fetch 3 months forward from current date + const s = _ds(_currentDate); + const eDate = new Date(_currentDate); eDate.setMonth(eDate.getMonth() + 3); + const e = _ds(eDate); + await _fetchEvents(s, e); + if (_isStaleRender(_tk)) return; + + // Filter + group by date + const visible = _events.filter(ev => !!_eventVisible(ev)) + .sort((a, b) => a.dtstart < b.dtstart ? -1 : 1); + + let h = _headerHTML() + _filtersRowHTML() + '
'; + // Group events by local date, then always surface today (when it's inside + // the agenda window) even if it has no events, so the user can see "today". + const byDate = new Map(); + for (const ev of visible) { + const d = _localDateOf(ev.dtstart); + if (!byDate.has(d)) byDate.set(d, []); + byDate.get(d).push(ev); + } + const today = _today(); + if (today >= s && today <= e && !byDate.has(today)) byDate.set(today, []); + const dates = [...byDate.keys()].sort(); + + if (!dates.length) { + // Empty-state mirrors the email panel: short message + a Settings › + // Integrations link to set up CalDAV, OR a quick "Create event" action. + h += '
' + + 'No upcoming events' + + '' + + 'Settings › Integrations' + + ' · ' + + 'Create event' + + '' + + '
'; + } else { + for (const date of dates) { + const evs = byDate.get(date); + const todayBadge = (date === today) ? ' Today' : ''; + h += `
${_fmtDate(date)}${todayBadge}
`; + if (!evs.length) { + h += '
No events
'; + } + for (const ev of evs) { + const t = ev.all_day ? 'All day' : _fmtTime(ev.dtstart) + ' – ' + _fmtTime(ev.dtend); + const _typeTag = ev.event_type + ? `#${_e(ev.event_type)}` + : ''; + const _impMark = ev.importance === 'critical' ? '!!' + : ev.importance === 'high' ? '!' : ''; + h += `
+
+
+
${_impMark}${_e(ev.summary)} ${_typeTag}
+
${t}${ev.location ? ' · ' + _locHTML(ev.location) : ''}
+
+ +
`; + } + h += '
'; + } + } + h += '
'; + // If the user grabbed the quick-add field mid-fetch, skip the swap (which + // would destroy the focused input + drop the keyboard) and defer until blur. + if (_qaTyping()) { _renderPending = true; return; } + body.innerHTML = h; + _wireAll(body); + _wireQuickDelete(body); + body.querySelectorAll('.cal-agenda-event').forEach(el => el.addEventListener('click', (e) => { + if (e.target.closest('.cal-event-more')) return; + const ev = _events.find(e => e.uid === el.dataset.uid); + if (ev) _showEventForm(ev); + })); + // Empty-state links: Settings › Integrations + Create event. + body.querySelector('[data-cal-open-settings]')?.addEventListener('click', (e) => { + e.preventDefault(); + closeCalendar(); + const modal = document.getElementById('settings-modal'); + if (modal) { + modal.classList.remove('hidden'); + const tab = modal.querySelector('[data-settings-tab="integrations"]'); + if (tab) tab.click(); + } + }); + body.querySelector('[data-cal-create-event]')?.addEventListener('click', (e) => { + e.preventDefault(); + _showEventForm(null); + }); + _updateBadge(); +} + +// ── Search View ── + +async function _renderSearch() { + const body = document.getElementById('cal-body'); + if (!body) return; + // Search across all events in pool (no fetch needed — use what we have) + const q = _searchQuery.toLowerCase(); + const results = Object.values(_allEvents) + .filter(ev => !!_eventVisible(ev)) + .filter(ev => + (ev.summary || '').toLowerCase().includes(q) || + (ev.description || '').toLowerCase().includes(q) || + (ev.location || '').toLowerCase().includes(q) + ) + .sort((a, b) => a.dtstart < b.dtstart ? -1 : 1); + + let h = _headerHTML() + _filtersRowHTML() + '
'; + h += `
${results.length} result${results.length !== 1 ? 's' : ''} for "${_e(_searchQuery)}"
`; + if (!results.length) { + h += '
No events match your search
'; + } else { + for (const ev of results) { + const evDate = _localDateOf(ev.dtstart); + const t = ev.all_day ? 'All day' : _fmtTime(ev.dtstart) + ' – ' + _fmtTime(ev.dtend); + h += `
+
+
+
${_e(ev.summary)}
+
${_fmtDate(evDate)} · ${t}${ev.location ? ' · ' + _locHTML(ev.location) : ''}
+
+ +
`; + } + } + h += '
'; + // If the user grabbed the quick-add field mid-fetch, skip the swap (which + // would destroy the focused input + drop the keyboard) and defer until blur. + if (_qaTyping()) { _renderPending = true; return; } + body.innerHTML = h; + _wireAll(body); + _wireQuickDelete(body); + body.querySelectorAll('.cal-agenda-event').forEach(el => el.addEventListener('click', (e) => { + if (e.target.closest('.cal-event-more')) return; + const ev = _allEvents[el.dataset.uid]; + if (ev) _showEventForm(ev); + })); + // Focus search input after re-render + const searchInput = document.getElementById('cal-search'); + if (searchInput && document.activeElement !== searchInput) { + searchInput.focus(); + searchInput.setSelectionRange(searchInput.value.length, searchInput.value.length); + } +} + +// ── Year View ── + +async function _renderYear() { + const body = document.getElementById('cal-body'); + if (!body) return; + const _tk = _renderToken; + const y = _currentDate.getFullYear(); + await _fetchEvents(`${y}-01-01`, `${y + 1}-01-01`); + if (_isStaleRender(_tk)) return; + const today = _today(); + + let h = _headerHTML() + _filtersRowHTML() + '
'; + for (let m = 0; m < 12; m++) { + h += `
${MON_SHORT[m]}
`; + h += '
'; + for (const wd of ['M', 'T', 'W', 'T', 'F', 'S', 'S']) h += `
${wd}
`; + const first = new Date(y, m, 1); + const dow = (first.getDay() + 6) % 7; + const daysInMonth = new Date(y, m + 1, 0).getDate(); + for (let p = 0; p < dow; p++) h += '
'; + for (let d = 1; d <= daysInMonth; d++) { + const ds = `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; + const evs = _eventsForDay(ds); + const isToday = ds === today; + let cls = 'cal-year-cell cal-year-day'; + if (isToday) cls += ' cal-year-today'; + if (evs.length) cls += ' cal-year-has'; + h += `
${d}
`; + } + h += '
'; + } + h += '
'; + // If the user grabbed the quick-add field mid-fetch, skip the swap (which + // would destroy the focused input + drop the keyboard) and defer until blur. + if (_qaTyping()) { _renderPending = true; return; } + body.innerHTML = h; + _wireAll(body); + // Month box click → jump to month view (but not when clicking a specific day) + body.querySelectorAll('.cal-year-month').forEach(el => { + el.addEventListener('click', (e) => { + if (e.target.closest('.cal-year-day')) return; + const m = parseInt(el.dataset.month); + _currentDate = new Date(_currentDate.getFullYear(), m, 1); + _view = 'month'; + _render(); + }); + }); + // Day click in year view → jump to month + body.querySelectorAll('.cal-year-day').forEach(el => { + el.addEventListener('click', () => { + const d = el.dataset.date; + _currentDate = new Date(d + 'T00:00:00'); + _selectedDay = d; + _view = 'month'; + _render(); + }); + }); + _updateBadge(); +} + +// ── Shared HTML builders ── + +function _dayDetailHTML(dateStr) { + const isToday = dateStr === _today(); + // Search lives inside the day panel now — typing filters the panel + // body to global search results instead of just this day's events. + // Magnifying-glass icon inside the search field via a wrapper + padding-left. + const searchInput = `
+ + +
`; + let h = ` +
+ ${searchInput} +
+ ${_fmtDate(dateStr)}${isToday ? ' (Today)' : ''} + +
`; + if (_searchQuery) { + const q = _searchQuery.toLowerCase(); + const results = _events + .filter(_eventVisible) + .filter(e => + (e.summary || '').toLowerCase().includes(q) || + (e.description || '').toLowerCase().includes(q) || + (e.location || '').toLowerCase().includes(q) + ) + .sort((a, b) => (a.dtstart || '').localeCompare(b.dtstart || '')); + h += `
${results.length} result${results.length !== 1 ? 's' : ''}
`; + if (!results.length) { + h += '
No events match
'; + } else { + results.forEach(ev => { + const date = ev.all_day ? ev.dtstart : _localDateOf(ev.dtstart); + const t = ev.all_day ? 'All day' : _fmtTime(ev.dtstart) + ' – ' + _fmtTime(ev.dtend); + const bgStyle = _calItemBgStyle(ev); + h += `
+
+
+
${_e(ev.summary)}
+
${_fmtDate(date)} · ${t}
+ ${ev.location ? `
${_locHTML(ev.location)}
` : ''} +
+ +
`; + }); + } + return h + '
'; + } + const evs = _eventsForDay(dateStr); + if (!evs.length) h += '
No events
'; + else evs.forEach(ev => { + const t = ev.all_day ? 'All day' : _fmtTime(ev.dtstart) + ' – ' + _fmtTime(ev.dtend); + const _bgStyle = _calItemBgStyle(ev); + h += `
${_e(ev.summary)}
${t}
${ev.location ? `
${_locHTML(ev.location)}
` : ''}
`; + }); + return h + ''; +} + +// ── Wire all common listeners ── + +function _wireAll(body) { + // ── Day-detail splitter (drag to resize) ──────────────────────── + // Restores the saved height each render so the user's choice survives + // navigation between months/weeks. Drag adjusts a single CSS variable + // on #cal-body — the grid clamps its height and the day-detail expands + // / contracts accordingly via CSS rules. + try { + const calBody = document.getElementById('cal-body'); + const splitter = body.querySelector('.cal-splitter'); + if (calBody && splitter) { + // Only seed from localStorage on the first wire-up. Subsequent + // renders (every keystroke when the user is typing in search) + // would otherwise clobber an in-progress focus-expand and bounce + // the day-detail pane up and down on every character. + const alreadySet = calBody.style.getPropertyValue('--cal-detail-h'); + if (!alreadySet) { + const saved = parseInt(localStorage.getItem('odysseus.cal.detailH') || '0', 10); + if (saved && saved > 80) calBody.style.setProperty('--cal-detail-h', saved + 'px'); + } + let startY = 0, startH = 240, dragging = false; + const onMove = (ev) => { + if (!dragging) return; + const y = ev.touches ? ev.touches[0].clientY : ev.clientY; + // Drag UP (smaller y) → bigger day-detail. Allow the pane to grow + // all the way to the top of the visible viewport so the user can + // hide the calendar entirely. We leave ~24px headroom so the + // splitter handle itself stays grabbable to drag back down. + const vh = (window.visualViewport?.height) || window.innerHeight; + const newH = Math.max(40, Math.min(vh - 24, startH + (startY - y))); + calBody.style.setProperty('--cal-detail-h', newH + 'px'); + }; + const onUp = () => { + if (!dragging) return; + dragging = false; + splitter.classList.remove('cal-splitter-dragging'); + document.removeEventListener('pointermove', onMove); + document.removeEventListener('pointerup', onUp); + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onUp); + const cur = calBody.style.getPropertyValue('--cal-detail-h'); + const px = parseInt(cur, 10); + if (px) { try { localStorage.setItem('odysseus.cal.detailH', String(px)); } catch {} } + }; + const onDown = (ev) => { + ev.preventDefault(); + dragging = true; + splitter.classList.add('cal-splitter-dragging'); + startY = ev.touches ? ev.touches[0].clientY : ev.clientY; + const detail = body.querySelector('.cal-day-detail'); + startH = detail ? detail.getBoundingClientRect().height : 240; + document.addEventListener('pointermove', onMove); + document.addEventListener('pointerup', onUp, { once: false }); + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onUp); + }; + splitter.addEventListener('pointerdown', onDown); + splitter.addEventListener('touchstart', onDown, { passive: false }); + + // Double-tap (or double-click) the splitter to reset the day-detail + // pane to its CSS default height. + let _lastTap = 0; + const resetSplit = () => { + calBody.style.removeProperty('--cal-detail-h'); + try { localStorage.removeItem('odysseus.cal.detailH'); } catch {} + }; + splitter.addEventListener('dblclick', resetSplit); + splitter.addEventListener('touchend', () => { + const now = Date.now(); + if (now - _lastTap < 320) { + resetSplit(); + _lastTap = 0; + } else { + _lastTap = now; + } + }); + } + } catch {} + + // ── Quick-add input ───────────────────────────────────────────── + const _qaInput = document.getElementById('cal-quickadd'); + const _qaStatus = document.getElementById('cal-quickadd-status'); + if (_qaInput && !_qaInput._wired) { + _qaInput._wired = true; + const _submitQA = async () => { + const text = _qaInput.value.trim(); + if (!text || _qaSubmitting) return; + // Use a flag rather than `disabled` to block double-submit — disabling + // the input blurs it, which would flush a deferred render and wipe the + // spinner's container mid-parse. + _qaSubmitting = true; + // Whirlpool spinner after the text — but only once parsing has run long + // enough to be worth showing (~250ms), so fast parses don't flash it. + let _qaSpin = null; + let _qaSpinTimer = null; + if (_qaStatus) { + _qaStatus.textContent = ''; + try { + const sp = (await import('./spinner.js')).default; + _qaSpinTimer = setTimeout(() => { + _qaSpin = sp.createWhirlpool(14); + _qaSpin.element.style.cssText = 'display:inline-block;vertical-align:middle;position:relative;top:1px;left:-2px;margin-left:4px;'; + _qaStatus.appendChild(_qaSpin.element); + }, 250); + } catch { + _qaSpinTimer = setTimeout(() => { if (_qaStatus) _qaStatus.textContent = 'parsing…'; }, 250); + } + } + try { + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; + const res = await fetch(`${API_BASE}/api/calendar/quick-parse`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, tz }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.ok) { + if (_qaStatus) _qaStatus.textContent = ''; + uiModule.showError('Quick-add: ' + (data.error || data.detail || `HTTP ${res.status}`)); + return; + } + // Open the bespoke event form, then push the parsed fields in. + const ev = data.event; + const ds = (ev.dtstart || '').slice(0, 10); + const de = (ev.dtend || '').slice(0, 10) || ds; + _showEventForm(null, ds, de); + requestAnimationFrame(() => { + const set = (id, v) => { const el = document.getElementById(id); if (el && v != null) el.value = v; }; + set('cal-f-sum', ev.summary); + set('cal-f-loc', ev.location); + set('cal-f-desc', ev.description); + if (ev.all_day) { + const ad = document.getElementById('cal-f-allday'); + if (ad && !ad.checked) { ad.checked = true; ad.dispatchEvent(new Event('change')); } + } else { + const t1 = (ev.dtstart || '').match(/T(\d{2}:\d{2})/); + const t2 = (ev.dtend || '').match(/T(\d{2}:\d{2})/); + if (t1) set('cal-f-start', t1[1]); + if (t2) set('cal-f-end', t2[1]); + document.getElementById('cal-f-start')?.dispatchEvent(new Event('input')); + } + // Make sure the details panel is open so the user can verify time. + document.querySelector('.cal-form-bespoke')?.classList.add('is-expanded'); + const det = document.getElementById('cal-form-details'); + if (det) det.setAttribute('aria-hidden', 'false'); + // Trigger Apple-Maps link sync now that location is filled in. + document.getElementById('cal-f-loc')?.dispatchEvent(new Event('input')); + }); + // Reset for next quick add. + _qaInput.value = ''; + } catch (e) { + uiModule.showError('Quick-add failed: ' + e.message); + } finally { + _qaSubmitting = false; + clearTimeout(_qaSpinTimer); + if (_qaSpin) { try { _qaSpin.destroy(); } catch {} _qaSpin.element?.remove(); } + if (_qaStatus) _qaStatus.textContent = ''; + } + }; + _qaInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); _submitQA(); } + else if (e.key === 'Escape') { _qaInput.value = ''; _qaInput.blur(); } + }); + // Flush any render we deferred while the field was focused. + _qaInput.addEventListener('blur', () => { + if (_renderPending) { _renderPending = false; _render(); } + }); + } + // After a background re-render (e.g. /events fetch returning), restore + // focus + caret + value so the user can keep typing uninterrupted. + if (_qaInput && _qaPendingRestore) { + _qaInput.value = _qaPendingRestore.value; + _qaInput.focus(); + try { + _qaInput.setSelectionRange(_qaPendingRestore.selStart, _qaPendingRestore.selEnd); + } catch {} + _qaPendingRestore = null; + } + // Q anywhere on the page (when not typing elsewhere) focuses quick-add. + if (!body._qaShortcutWired) { + body._qaShortcutWired = true; + document.addEventListener('keydown', (e) => { + if (!_open) return; + if (e.key !== 'q' && e.key !== 'Q') return; + const tag = (document.activeElement?.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea' || tag === 'select') return; + const inp = document.getElementById('cal-quickadd'); + if (inp) { e.preventDefault(); inp.focus(); inp.select(); } + }); + } + + // Pinch zoom on the calendar body changes the view granularity: + // year ⇆ month ⇆ week. Pinch IN zooms to a tighter view, pinch OUT + // zooms out. Fires once per gesture so a strong pinch doesn't skip + // straight from year to week (the user gets one step at a time and + // can release-and-pinch again). + if (body && !body._pinchZoomWired) { + body._pinchZoomWired = true; + let pinchStart = 0, pinchActive = false, pinchFired = false; + const dist = (ts) => Math.hypot(ts[0].clientX - ts[1].clientX, ts[0].clientY - ts[1].clientY); + body.addEventListener('touchstart', (e) => { + if (e.touches.length === 2) { + pinchStart = dist(e.touches); + pinchActive = true; + pinchFired = false; + } + }, { passive: true }); + body.addEventListener('touchmove', (e) => { + if (!pinchActive || pinchFired || e.touches.length !== 2) return; + const ratio = dist(e.touches) / pinchStart; + if (ratio > 1.35) { _zoomView(+1); pinchFired = true; } + else if (ratio < 0.7) { _zoomView(-1); pinchFired = true; } + }, { passive: true }); + body.addEventListener('touchend', (e) => { + if (e.touches.length < 2) pinchActive = false; + }, { passive: true }); + } + + // Touch swipe ← → on the calendar body switches months/weeks/etc. Only + // fires when the swipe is clearly horizontal so vertical scrolling inside + // long event lists isn't hijacked. Attached fresh on each render via + // _wireAll → existing prev/next handlers do the actual navigation. + if (body && !body._swipeWired) { + body._swipeWired = true; + let _sx = 0, _sy = 0, _t0 = 0, _tracking = false; + body.addEventListener('touchstart', (e) => { + if (!e.touches || e.touches.length !== 1) return; + _sx = e.touches[0].clientX; + _sy = e.touches[0].clientY; + _t0 = Date.now(); + _tracking = true; + }, { passive: true }); + body.addEventListener('touchend', (e) => { + if (!_tracking) return; + _tracking = false; + const t = e.changedTouches && e.changedTouches[0]; + if (!t) return; + const dx = t.clientX - _sx; + const dy = t.clientY - _sy; + const dt = Date.now() - _t0; + // Threshold: at least 50px horizontal, dominant axis is horizontal, + // and reasonably quick (under 600ms) so it feels intentional. + if (Math.abs(dx) < 50) return; + if (Math.abs(dx) < Math.abs(dy) * 1.3) return; + if (dt > 600) return; + if (dx < 0) document.getElementById('cal-next')?.click(); + else document.getElementById('cal-prev')?.click(); + }, { passive: true }); + } + + document.getElementById('cal-prev')?.addEventListener('click', () => { + _slideDir = -1; + if (_view === 'year') _currentDate = new Date(_currentDate.getFullYear() - 1, 0, 1); + else if (_view === 'week') _currentDate.setDate(_currentDate.getDate() - 7); + else if (_view === 'agenda') _currentDate.setDate(_currentDate.getDate() - 30); + else _currentDate = new Date(_currentDate.getFullYear(), _currentDate.getMonth() - 1, 1); + // Keep a day selected in month/week so the day-detail panel — which hosts + // the search box — stays available (otherwise browsing hides search). + _selectedDay = (_view === 'month' || _view === 'week') ? _ds(_currentDate) : null; + _render(); + }); + document.getElementById('cal-next')?.addEventListener('click', () => { + _slideDir = 1; + if (_view === 'year') _currentDate = new Date(_currentDate.getFullYear() + 1, 0, 1); + else if (_view === 'week') _currentDate.setDate(_currentDate.getDate() + 7); + else if (_view === 'agenda') _currentDate.setDate(_currentDate.getDate() + 30); + else _currentDate = new Date(_currentDate.getFullYear(), _currentDate.getMonth() + 1, 1); + _selectedDay = (_view === 'month' || _view === 'week') ? _ds(_currentDate) : null; + _render(); + }); + document.getElementById('cal-today')?.addEventListener('click', () => { _currentDate = new Date(); _selectedDay = _today(); _render(); }); + document.getElementById('cal-settings')?.addEventListener('click', () => _showCalSettings()); + document.getElementById('cal-sync')?.addEventListener('click', async () => { + // Visible feedback: toggle a CSS class on the button so the spin runs + // even if the network round-trip is too fast to perceive. We hold it + // for at least 700ms (one full rotation) AND for as long as the actual + // fetch is in flight, then clear. Previously `await _render()` + // resolved instantly because _render is synchronous, so the spinner + // was set→cleared in the same tick and you saw nothing. + const btn = document.getElementById('cal-sync'); + btn?.classList.add('cal-syncing'); + window._calSyncing = true; + _allEvents = {}; + _fetchedRanges = []; + localStorage.removeItem(LS_KEY); + + // Compute the visible range and force-refetch — _render() kicks off + // a fetch internally but doesn't return a promise, so we await our + // own one to actually serialize on the network. + const _range = (_view === 'year') + ? [`${_currentDate.getFullYear()}-01-01`, `${_currentDate.getFullYear() + 1}-01-01`] + : (_view === 'week') ? _weekRange(_currentDate) : _monthRange(_currentDate); + const minSpin = new Promise(r => setTimeout(r, 700)); + try { + await Promise.all([ + _fetchEvents(_range[0], _range[1], /*force*/ true).catch(() => {}), + minSpin, + ]); + } finally { + window._calSyncing = false; + // Flash a checkmark for ~900ms. Drive it through a flag the toolbar + // template reads (not a one-off innerHTML on the button), so a stray + // _render() — the calendar re-renders mid-flow — can't wipe it. Same + // reason the spin is flag-driven. + window._calSyncDone = true; + _render(); + setTimeout(() => { + window._calSyncDone = false; + if (_open) _render(); + }, 900); + if (uiModule?.showToast) uiModule.showToast('Calendar refreshed'); + } + }); + // Brief spin on the "+" glyph before the new-event form opens. The + // glyph already rotates on hover (desktop). On mobile there's no + // hover, so play the rotation on tap as a quick affordance. + const _addClick = (e, openFn) => { + if (window.innerWidth <= 768) { + const plus = e.currentTarget.querySelector('.cal-add-plus'); + if (plus) { + plus.classList.add('cal-add-spinning'); + setTimeout(() => plus.classList.remove('cal-add-spinning'), 360); + } + setTimeout(openFn, 220); + } else { + openFn(); + } + }; + // If the user typed in quick-add but pressed "+ New" instead of Enter, treat + // it as a quick-add (parse the text) rather than opening a blank event — a + // common mix-up since the two controls sit side by side. + const _tryQuickAddFromButton = () => { + const qa = document.getElementById('cal-quickadd'); + if (qa && qa.value.trim()) { + qa.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + return true; + } + return false; + }; + document.getElementById('cal-add')?.addEventListener('click', (e) => _addClick(e, () => { if (!_tryQuickAddFromButton()) _showEventForm(null, _selectedDay || _today()); })); + // Solo "+" on the day-detail header: no spin (the small round button + // doesn't look good rotating in place — open the form immediately). + document.getElementById('cal-add-day')?.addEventListener('click', () => { if (!_tryQuickAddFromButton()) _showEventForm(null, _selectedDay); }); + + // Mobile: relocate the toolbar's +New pill so it sits NEXT TO the + // quick-add row (not inside it — the row has its own border/background + // that makes embedded buttons look like part of the input field). + // Wrap the row and button in a flex container so they share one line. + if (window.innerWidth <= 768) { + const addBtn = document.getElementById('cal-add'); + const qaRow = document.getElementById('cal-quickadd-row'); + if (addBtn && qaRow) { + let wrap = qaRow.parentElement; + if (!wrap?.classList.contains('cal-quickadd-wrap')) { + wrap = document.createElement('div'); + wrap.className = 'cal-quickadd-wrap'; + qaRow.parentElement?.insertBefore(wrap, qaRow); + wrap.appendChild(qaRow); + } + if (addBtn.parentElement !== wrap) wrap.appendChild(addBtn); + } + } + + // Search input — re-render rebuilds the day-detail DOM on each keystroke, + // so refocus and restore caret position to keep typing smooth. + const searchInput = document.getElementById('cal-search'); + if (searchInput) { + if (document.activeElement?.id === 'cal-search') { + // First call after a re-render: refocus and place caret at end. + searchInput.focus(); + const len = searchInput.value.length; + try { searchInput.setSelectionRange(len, len); } catch {} + } + searchInput.addEventListener('input', (e) => { + _searchQuery = e.target.value.trim(); + // Partial update: swap only the search results inside the day-detail + // panel, leaving the search input element itself in place. A full + // _render() destroys the input via innerHTML, and on iOS the + // keyboard dismisses even if a brand-new input is focused + // synchronously after. Keeping the same input element across + // keystrokes is the only way to keep the keyboard up. + _updateDaySearchResults(); + }); + // Mobile: when the search input gains focus the on-screen keyboard + // pops up. Expand the day-detail pane to (near) the visible viewport + // height so the search bar sits at the top of the screen, well above + // the keyboard, instead of staying squashed behind it. + searchInput.addEventListener('focus', () => { + if (window.innerWidth > 768) return; + const calBody = document.getElementById('cal-body'); + if (!calBody) return; + const vh = (window.visualViewport?.height) || window.innerHeight; + const target = vh - 24; + // Skip if already expanded — every keystroke triggers a re-render + // which re-focuses the input. Re-running this on each keystroke + // would shove the layout around as the user types. + const cur = parseInt(calBody.style.getPropertyValue('--cal-detail-h'), 10) || 0; + if (cur >= target - 24) return; + calBody.style.setProperty('--cal-detail-h', target + 'px'); + }); + } + + body.querySelectorAll('.cal-view-btn').forEach(b => b.addEventListener('click', () => { + _view = b.dataset.view; + _searchQuery = ''; + _selectedDay = null; + // Switching to Agenda always lands on today so you see "what's coming + // up" rather than wherever you happened to be browsing. + if (_view === 'agenda') _currentDate = new Date(); + _render(); + })); + body.querySelector('#cal-filter-toggle')?.addEventListener('click', () => { + _filtersCollapsed = !_filtersCollapsed; + localStorage.setItem('cal-filters-collapsed', _filtersCollapsed ? '1' : '0'); + _render(); + }); + body.querySelectorAll('.cal-filter-item').forEach(it => it.addEventListener('click', (e) => { + const href = it.dataset.href; + const type = it.dataset.type; + if (href) { + // Solo-filter: click = show only this calendar; click again = show all. + // Shift/Ctrl+click = toggle individually (legacy hide/show). + const allHrefs = Array.from(body.querySelectorAll('.cal-filter-item[data-href]')).map(el => el.dataset.href); + if (e.shiftKey || e.ctrlKey || e.metaKey) { + _hiddenCals.has(href) ? _hiddenCals.delete(href) : _hiddenCals.add(href); + } else { + const soloed = !_hiddenCals.has(href) && allHrefs.every(h => h === href || _hiddenCals.has(h)); + if (soloed) { + _hiddenCals.clear(); + } else { + _hiddenCals.clear(); + allHrefs.forEach(h => { if (h !== href) _hiddenCals.add(h); }); + } + } + } else if (type) { + // "!" chip toggles a separate "only important" axis — clicking it + // doesn't solo-hide other categories the way a normal type chip does. + if (type === '!') { + _onlyImportant = !_onlyImportant; + // Clear category hides so importance becomes the active filter. + if (_onlyImportant) _hiddenTypes.clear(); + } else { + const allTypes = Array.from(body.querySelectorAll('.cal-filter-item[data-type]')) + .map(el => el.dataset.type) + .filter(t => t !== '!'); + // Engaging a category filter cancels "only important" so it doesn't + // silently keep filtering on top. + _onlyImportant = false; + if (e.shiftKey || e.ctrlKey || e.metaKey) { + _hiddenTypes.has(type) ? _hiddenTypes.delete(type) : _hiddenTypes.add(type); + } else { + const soloed = !_hiddenTypes.has(type) && allTypes.every(t => t === type || _hiddenTypes.has(t)); + if (soloed) { + _hiddenTypes.clear(); + } else { + _hiddenTypes.clear(); + allTypes.forEach(t => { if (t !== type) _hiddenTypes.add(t); }); + } + } + } + } + _render(); + })); + body.querySelectorAll('.cal-day[data-date]').forEach(cell => cell.addEventListener('click', (e) => { + if (e.target.closest('.cal-event-item,.cal-multiday')) return; + const d = cell.dataset.date; + // First click on a day: select it. Second click on the same already- + // selected day: open the new-event form pre-filled with that date. + if (_selectedDay === d) { + _showEventForm(null, d); + return; + } + _selectedDay = d; + _render(); + })); + body.querySelectorAll('.cal-event-item').forEach(it => it.addEventListener('click', (e) => { + if (e.target.closest('.cal-event-more')) return; + const ev = _events.find(e => e.uid === it.dataset.uid); + if (ev) _showEventForm(ev); + })); + _wireQuickDelete(body); + + // Drag + body.querySelectorAll('[draggable="true"][data-uid]').forEach(el => { + el.addEventListener('dragstart', (e) => { + _dragUid = el.dataset.uid; + e.dataTransfer.effectAllowed = 'move'; + el.classList.add('cal-dragging'); + }); + el.addEventListener('dragend', () => { + el.classList.remove('cal-dragging'); + _dragUid = null; + body.querySelectorAll('.cal-drag-over').forEach(d => d.classList.remove('cal-drag-over')); + }); + }); + // Helper — find the day cell directly under the cursor at (x,y). Reading + // it from the cursor is more reliable than trusting whichever cell fired + // the `drop` event: if the user releases over a nested event item or + // multi-day bar, the drop fires on the inner element and the calling + // cell's `data-date` may be the wrong row. + const _cellAtPoint = (x, y) => { + const stack = document.elementsFromPoint ? document.elementsFromPoint(x, y) : [document.elementFromPoint(x, y)]; + for (const el of stack) { + if (!el || !el.closest) continue; + // Prefer the month-view day cell, fall back to any data-date target + // (e.g. week-view column) so week-view drag still works. + const dayCell = el.closest('.cal-day[data-date]'); + if (dayCell) return dayCell; + const anyCell = el.closest('[data-date]'); + if (anyCell) return anyCell; + } + return null; + }; + body.querySelectorAll('[data-date]').forEach(cell => { + cell.addEventListener('dragover', (e) => { + if (!_dragUid) return; + e.preventDefault(); + // Only highlight the cell genuinely under the cursor — prevents two + // adjacent cells flashing as the cursor crosses a border. + const target = _cellAtPoint(e.clientX, e.clientY); + body.querySelectorAll('.cal-drag-over').forEach(c => { + if (c !== target) c.classList.remove('cal-drag-over'); + }); + if (target) target.classList.add('cal-drag-over'); + }); + cell.addEventListener('dragleave', (e) => { + // Only clear if the cursor really left this cell (dragleave fires when + // entering a child too — that's the flicker bug). + const target = _cellAtPoint(e.clientX, e.clientY); + if (target !== cell) cell.classList.remove('cal-drag-over'); + }); + cell.addEventListener('drop', async (e) => { + e.preventDefault(); + e.stopPropagation(); + body.querySelectorAll('.cal-drag-over').forEach(c => c.classList.remove('cal-drag-over')); + if (!_dragUid) return; + // Drop target = whichever cell is actually under the cursor at release, + // not the bubbling target. Fixes "drops on wrong day" reports. + const target = _cellAtPoint(e.clientX, e.clientY) || cell; + const nd = target.dataset.date; + const ev = _events.find(e => e.uid === _dragUid); + if (!ev || !nd) return; + const od = _localDateOf(ev.dtstart); + if (od === nd) return; + const diff = Math.round((new Date(nd + 'T00:00:00') - new Date(od + 'T00:00:00')) / 86400000); + // Snapshot the original times for undo BEFORE we mutate. + const undoSnap = { uid: ev.uid, dtstart: ev.dtstart, dtend: ev.dtend }; + _pushCalUndo({ label: 'move', run: () => _updateEvent(undoSnap.uid, { dtstart: undoSnap.dtstart, dtend: undoSnap.dtend || undefined }).then(_render) }); + await _updateEvent(ev.uid, { dtstart: _shiftDT(ev.dtstart, diff), dtend: ev.dtend ? _shiftDT(ev.dtend, diff) : undefined }); + _render(); + uiModule.showToast?.('Moved', { duration: 4000, action: 'Undo', actionHint: 'Ctrl+Z', onAction: _popAndRunCalUndo }); + }); + }); +} + +// ── Undo stack (calendar) ── +const _calUndoStack = []; +function _pushCalUndo(entry) { + _calUndoStack.push(entry); + if (_calUndoStack.length > 20) _calUndoStack.shift(); +} +function _popAndRunCalUndo() { + const entry = _calUndoStack.pop(); + if (entry && typeof entry.run === 'function') { + try { entry.run(); } catch {} + } +} +// Ctrl/Cmd+Z anywhere inside the calendar modal undoes the last drag-move. +if (typeof window !== 'undefined' && !window._calUndoBound) { + window._calUndoBound = true; + document.addEventListener('keydown', (e) => { + if (!(e.ctrlKey || e.metaKey) || e.key !== 'z' || e.shiftKey) return; + // Skip if the user's typing in a real field — let the browser's text undo run. + const t = e.target; + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; + const modal = document.getElementById('calendar-modal'); + if (!modal || modal.classList.contains('hidden') || !_calUndoStack.length) return; + e.preventDefault(); + _popAndRunCalUndo(); + }); +} + +// ── Calendar Settings ── + +async function _showCalSettings() { + const existing = document.getElementById('cal-settings-panel'); + if (existing) { existing.remove(); return; } + + const cals = _calendars; + const COLORS = ['#5b8abf','#4caf50','#ff9800','#e91e63','#9c27b0','#00bcd4','#795548','#607d8b','#f44336','#7c4dff']; + + const overlay = document.createElement('div'); + overlay.id = 'cal-settings-panel'; + overlay.className = 'modal'; + overlay.style.display = 'flex'; + overlay.style.zIndex = '999'; + overlay.innerHTML = ` + + `; + document.body.appendChild(overlay); + + const cleanup = () => overlay.remove(); + overlay.querySelector('#cal-settings-close').addEventListener('click', cleanup); + overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(); }); + + // Create a new (local) calendar. Defaults the name + next palette color, then + // reopens the panel so the user can rename it inline and pick a color. + overlay.querySelector('#cal-settings-add')?.addEventListener('click', async (e) => { + const btn = e.currentTarget; + btn.disabled = true; + const color = COLORS[_calendars.length % COLORS.length]; + try { + const r = await fetch(`${API_BASE}/api/calendar/calendars?name=${encodeURIComponent('New calendar')}&color=${encodeURIComponent(color)}`, { method: 'POST', credentials: 'same-origin' }); + const d = await r.json().catch(() => ({})); + if (!r.ok || !d.ok) throw new Error(d.error || 'Failed to create calendar'); + _calendars.push({ name: d.name, href: d.id, color: d.color }); + _allEvents = {}; _fetchedRanges = []; localStorage.removeItem(LS_KEY); + _render(); + cleanup(); + _showCalSettings(); + // Focus the new row's name field so it's ready to rename. + setTimeout(() => { + const rows = document.querySelectorAll('#cal-settings-list .cal-settings-row'); + const last = rows[rows.length - 1]; + const nm = last?.querySelector('.cal-s-name'); + if (nm) { nm.focus(); nm.select(); } + }, 30); + } catch (err) { + btn.disabled = false; + if (window.showError) window.showError(err.message || 'Failed to create calendar'); + else console.error(err); + } + }); + + // Color + name changes + overlay.querySelectorAll('.cal-settings-row').forEach(row => { + const id = row.dataset.id; + const colorInput = row.querySelector('.cal-s-color'); + const nameInput = row.querySelector('.cal-s-name'); + const delBtn = row.querySelector('.cal-s-del'); + + let saveTimer; + const save = () => { + clearTimeout(saveTimer); + saveTimer = setTimeout(async () => { + await fetch(`${API_BASE}/api/calendar/calendars/${id}?name=${encodeURIComponent(nameInput.value)}&color=${encodeURIComponent(colorInput.value)}`, { method: 'PUT' }); + if (uiModule?.showToast) uiModule.showToast(`Saved “${nameInput.value || 'calendar'}”`); + // Update local calendar list + const c = _calendars.find(c => c.href === id); + if (c) { c.name = nameInput.value; c.color = colorInput.value; } + // Update colors on cached events + for (const uid of Object.keys(_allEvents)) { + if (_allEvents[uid].calendar_href === id) { + _allEvents[uid].color = colorInput.value; + _allEvents[uid].calendar = nameInput.value; + } + } + localStorage.removeItem(LS_KEY); + _fetchedRanges = []; + _render(); + }, 300); + }; + colorInput.addEventListener('input', save); + nameInput.addEventListener('change', save); + // Upgrade the native color box into the app's themed color picker. + try { attachColorPicker(colorInput); } catch (_) {} + + delBtn.addEventListener('click', async () => { + const name = nameInput.value; + if (!await window.styledConfirm(`Delete calendar "${name}" and all its events?`, { confirmText: 'Delete', danger: true })) return; + await fetch(`${API_BASE}/api/calendar/calendars/${id}`, { method: 'DELETE' }); + row.remove(); + _allEvents = {}; _fetchedRanges = []; localStorage.removeItem(LS_KEY); + _calendars = _calendars.filter(c => c.href !== id); + _render(); + }); + }); + + // ICS import + overlay.querySelector('#cal-import-file').addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + const status = overlay.querySelector('#cal-import-status'); + status.textContent = 'Importing...'; + try { + const fd = new FormData(); + fd.append('file', file); + const res = await fetch(`${API_BASE}/api/calendar/import`, { method: 'POST', body: fd, credentials: 'same-origin' }); + // Try JSON first; fall back to text so HTML auth-walls and bare + // 500s surface something the user can act on instead of the + // generic "Import failed". + let data = null, raw = ''; + try { data = await res.clone().json(); } catch (_) { raw = await res.text().catch(() => ''); } + if (res.ok && data && data.ok) { + status.textContent = `${data.imported} events imported to "${data.calendar}"` + (data.skipped ? ` (${data.skipped} skipped)` : ''); + _allEvents = {}; _fetchedRanges = []; localStorage.removeItem(LS_KEY); + await _fetchCalendars(); + _render(); + } else { + // FastAPI HTTPException → {detail}; some routes use {error}. + const reason = (data && (data.detail || data.error)) || raw.slice(0, 200) || `HTTP ${res.status}`; + status.textContent = `Import failed: ${reason}`; + console.error('Calendar import failed', res.status, data || raw); + } + } catch (err) { + status.textContent = `Import failed: ${err.message || err}`; + console.error('Calendar import threw', err); + } + e.target.value = ''; + }); + + // Export chips — one per calendar; downloads that calendar's .ics. + overlay.querySelectorAll('.cal-s-export-chip').forEach(chip => { + chip.addEventListener('click', () => { + window.open(`${API_BASE}/api/calendar/export/${chip.dataset.id}`, '_blank'); + }); + }); + + // Sync now — fires the CalDAV pull synchronously so we can show the + // result inline, then refreshes the panel + calendar grid. + overlay.querySelector('#cal-settings-sync-now')?.addEventListener('click', async (e) => { + const btn = e.currentTarget; + const status = overlay.querySelector('#cal-settings-sync-status'); + btn.disabled = true; + status.textContent = 'Syncing…'; + const data = await _syncCaldav(true) || {}; + if (data.errors && data.errors.length) { + status.textContent = `Sync failed: ${data.errors[0]}`; + } else { + const parts = []; + if (data.events) parts.push(`${data.events} events`); + if (data.deleted) parts.push(`${data.deleted} removed`); + status.textContent = parts.length ? `Synced — ${parts.join(', ')}` : 'Synced — no changes'; + _allEvents = {}; _fetchedRanges = []; + try { localStorage.removeItem(LS_KEY); } catch (_) {} + await _fetchCalendars(); + _render(); + // Reopen the panel so the calendars list reflects any new ones. + const reopenWith = !!document.getElementById('cal-settings-panel'); + cleanup(); + if (reopenWith) _showCalSettings(); + } + btn.disabled = false; + }); + + // Integrations link — close this overlay and open Settings → Integrations. + overlay.querySelector('#cal-settings-open-caldav')?.addEventListener('click', (e) => { + e.preventDefault(); + cleanup(); + if (window.settingsModule && typeof window.settingsModule.open === 'function') { + try { window.settingsModule.open('integrations'); return; } catch (_) {} + } + const modal = document.getElementById('settings-modal'); + if (modal) { + modal.classList.remove('hidden'); + const tabBtn = modal.querySelector('[data-settings-tab="integrations"]'); + if (tabBtn) tabBtn.click(); + } + }); +} + +// ── Event Form ── + +// Pull an explicit clock time out of a free-text title so it can overrule the +// time pickers on save (e.g. title "Standup 10am" wins over a 9pm picker). +// Returns {h, m} in 24h, or null when the title has no unambiguous time. +function _parseTitleTime(text) { + if (!text) return null; + // 12-hour with am/pm — "10am", "10:30 pm", "at 7 p.m." + let m = text.match(/\b(\d{1,2})(?::(\d{2}))?\s*([ap])\.?\s*m\.?\b/i); + if (m) { + let h = parseInt(m[1], 10); + const mm = m[2] ? parseInt(m[2], 10) : 0; + if (h < 1 || h > 12 || mm > 59) return null; + const pm = m[3].toLowerCase() === 'p'; + if (pm && h !== 12) h += 12; + if (!pm && h === 12) h = 0; + return { h, m: mm }; + } + // 24-hour HH:MM — "15:00", "at 9:30" (needs the colon to avoid matching + // bare numbers like "room 5" or years). + m = text.match(/\b([01]?\d|2[0-3]):([0-5]\d)\b/); + if (m) return { h: parseInt(m[1], 10), m: parseInt(m[2], 10) }; + return null; +} + +function _showEventForm(existing, defaultDate, defaultEndDate) { + const body = document.getElementById('cal-body'); + if (!body) return; + const isEdit = !!existing; + const ds = existing ? _localDateOf(existing.dtstart) : (defaultDate || _today()); + const de = existing && existing.dtend ? _localDateOf(existing.dtend) : (defaultEndDate || ds); + const isMultiDay = ds !== de; + const st = existing && !existing.all_day ? _fmtTime(existing.dtstart) : '09:00'; + const et = existing && !existing.all_day && existing.dtend ? _fmtTime(existing.dtend) : '10:00'; + // Default to all-day when dragging across multiple days + const ad = existing ? existing.all_day : (defaultEndDate && defaultEndDate !== defaultDate); + + let calOpts = _calendars.filter(c => !_hiddenCals.has(c.href)).map(c => + `` + ).join(''); + + // "Bespoke" event form: a big clock-face hero (time + date) and a single + // title input. Everything else (location, description, recurrence, + // reminder, color, calendar) is folded behind a click — focusing the + // title or clicking "Add details" reveals it. Empty drafts feel like a + // sticky-note; full-detail editing is one keystroke away. + const _hasDetails = !!(existing && ( + existing.location || existing.description || existing.rrule || + (existing.color && existing.color.length) || + isMultiDay + )); + const _expandedAtStart = isEdit && _hasDetails; + + body.innerHTML = `
+ +
Today is ${_clockDate(_today())} · ${_nowClock()}
+
+ + +
+ +
+ + +
+ +
+
+ + to + +
+ All day + +
+
+
+ + + +
+
+ + + + +
+ + +
+ + + +
+
+ +
+ ${CAL_COLORS.map(c => { + const cur = existing?.color || ''; + const isCustom = c.hex === 'custom'; + const isActive = isCustom ? _isCalBgImage(cur) : (cur === c.hex || (!cur && !c.hex)); + let bg; + if (isCustom) { + const url = _calBgImageUrl(cur); + bg = url ? `center/cover no-repeat url('${url}')` : _CAL_CUSTOM_GRADIENT; + } else { + bg = c.hex || 'var(--border)'; + } + return ``; + }).join('')} +
+
+ ${_calendars.length > 1 ? `` : ''} +
+ +
+ ${isEdit ? `` : ''} + + +
+
`; + + document.getElementById('cal-f-allday')?.addEventListener('change', (e) => { + document.getElementById('cal-time-row').style.display = e.target.checked ? 'none' : ''; + }); + // Keep end date >= start date + document.getElementById('cal-f-date')?.addEventListener('change', () => { + const s = document.getElementById('cal-f-date').value; + const eEl = document.getElementById('cal-f-date-end'); + if (eEl && eEl.value < s) eEl.value = s; + }); + // Color dot picker — also live-tints the form card (border, focus + // rings, primary button) so the user sees the choice immediately. + const _formCard = document.querySelector('.cal-form-bespoke'); + // Dismiss the keyboard by pressing Enter in a single-line text field — the + // ↵ glyph next to the title hints at this. + if (_formCard) { + _formCard.querySelectorAll('input[type="text"]').forEach(inp => { + inp.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); inp.blur(); } + }); + }); + } + // Tint the calendar-picker select with the chosen calendar's colour so it's + // clear which calendar the event lands in. + const _calSel = document.getElementById('cal-f-cal'); + if (_calSel) { + const _tintCalSel = () => { + const c = _calendars.find(x => x.href === _calSel.value); + const col = (c && c.color && !_isCalBgImage(c.color)) ? c.color : 'var(--accent, var(--red))'; + // Soft full-width background tint only — no side bar/border highlight. + _calSel.style.background = `color-mix(in srgb, ${col} 16%, var(--bg))`; + }; + _calSel.addEventListener('change', _tintCalSel); + _tintCalSel(); + } + const _applyFormTint = (hex) => { + if (!_formCard) return; + if (_isCalBgImage(hex)) { + // Paint the form card with the uploaded image (mirrors how the notes + // form previews a custom-bg note), plus a translucent overlay so text + // stays readable. Chrome accent falls back to the theme accent. + const url = _calBgImageUrl(hex); + _formCard.style.setProperty('--ev-color', 'var(--accent)'); + _formCard.style.backgroundImage = `linear-gradient(color-mix(in srgb, var(--panel) 65%, transparent), color-mix(in srgb, var(--panel) 65%, transparent)), url('${url.replace(/'/g, "\\'")}')`; + _formCard.style.backgroundSize = 'cover'; + _formCard.style.backgroundPosition = 'center'; + _formCard.classList.add('cal-form-bg-image'); + return; + } + // Clear any prior custom-bg styling. + _formCard.classList.remove('cal-form-bg-image'); + _formCard.style.backgroundImage = ''; + _formCard.style.backgroundSize = ''; + _formCard.style.backgroundPosition = ''; + if (hex) _formCard.style.setProperty('--ev-color', hex); + else _formCard.style.removeProperty('--ev-color'); + }; + document.querySelectorAll('#cal-f-colors .note-color-dot').forEach(dot => { + dot.addEventListener('click', async () => { + // Custom dot: prompt for an image upload. Empty input → no-op. + if (dot.dataset.color === 'custom') { + const url = await _pickCalBgImage(); + if (!url) return; + const sentinel = 'bg:' + url; + dot.dataset.color = sentinel; + dot.style.background = `center/cover no-repeat url('${url}')`; + document.querySelectorAll('#cal-f-colors .note-color-dot').forEach(d => d.classList.remove('active')); + dot.classList.add('active'); + _applyFormTint(sentinel); + return; + } + document.querySelectorAll('#cal-f-colors .note-color-dot').forEach(d => d.classList.remove('active')); + dot.classList.add('active'); + _applyFormTint(dot.dataset.color || ''); + }); + }); + // Initial tint for edit-an-existing-event so the card already reflects + // the saved color when the form opens. + _applyFormTint(existing?.color || ''); + // When the user changes the start time, shift the end time by the same + // delta so the event keeps its original duration (or a 1-hour default if + // start == end). Skipped if the user has already nudged the end input + // since opening the form — we don't want to clobber a deliberate edit. + (function _wireStartShiftsEnd() { + const startEl = document.getElementById('cal-f-start'); + const endEl = document.getElementById('cal-f-end'); + if (!startEl || !endEl) return; + const _toMin = (v) => { + if (!v || !/^\d{2}:\d{2}$/.test(v)) return null; + const [h, m] = v.split(':').map(n => parseInt(n, 10)); + return h * 60 + m; + }; + const _toHHMM = (mins) => { + let m = ((mins % 1440) + 1440) % 1440; + const hh = String(Math.floor(m / 60)).padStart(2, '0'); + const mm = String(m % 60).padStart(2, '0'); + return `${hh}:${mm}`; + }; + let prevStartMin = _toMin(startEl.value); + endEl.addEventListener('input', () => { endEl.dataset.userEdited = '1'; }); + startEl.addEventListener('change', () => { + const newStartMin = _toMin(startEl.value); + const endMin = _toMin(endEl.value); + if (newStartMin == null) { prevStartMin = newStartMin; return; } + // Compute the duration before the change. Use the user's existing + // start→end gap, fallback to 1 hour. + let durationMin = 60; + if (prevStartMin != null && endMin != null && endMin > prevStartMin) { + durationMin = endMin - prevStartMin; + } else if (endMin != null && newStartMin != null && endMin > newStartMin && endEl.dataset.userEdited === '1') { + // User already set a custom end before changing start — leave it. + prevStartMin = newStartMin; + return; + } + endEl.value = _toHHMM(newStartMin + durationMin); + prevStartMin = newStartMin; + }); + })(); + // Custom reminder picker + document.getElementById('cal-f-remind')?.addEventListener('change', (e) => { + const customInput = document.getElementById('cal-f-remind-custom'); + if (e.target.value === 'custom') { + customInput.style.display = ''; + // Default to 1 hour before event + const dv = document.getElementById('cal-f-date')?.value || _today(); + const st = document.getElementById('cal-f-start')?.value || '09:00'; + const eventDt = new Date(`${dv}T${st}:00`); + eventDt.setHours(eventDt.getHours() - 1); + const pad = n => String(n).padStart(2, '0'); + customInput.value = `${eventDt.getFullYear()}-${pad(eventDt.getMonth()+1)}-${pad(eventDt.getDate())}T${pad(eventDt.getHours())}:${pad(eventDt.getMinutes())}`; + customInput.focus(); + } else { + customInput.style.display = 'none'; + } + // Jingle the bell whenever a non-empty reminder is picked. CSS handles the + // animation; we just toggle the class so it re-fires on every change. + const _bell = document.querySelector('.cal-remind-bell'); + if (_bell && e.target.value) { + _bell.classList.remove('jingling'); + void _bell.offsetWidth; + _bell.classList.add('jingling'); + setTimeout(() => _bell.classList.remove('jingling'), 700); + } + }); + const _cancelEventForm = () => _render(); + document.getElementById('cal-f-cancel')?.addEventListener('click', _cancelEventForm); + document.getElementById('cal-form-mobile-cancel')?.addEventListener('click', _cancelEventForm); + document.getElementById('cal-f-save')?.addEventListener('click', async () => { + const summary = document.getElementById('cal-f-sum').value.trim(); + if (!summary) { uiModule.showToast('Title required'); return; } + const dv = document.getElementById('cal-f-date').value; + const dvEnd = document.getElementById('cal-f-date-end').value || dv; + const isAD = document.getElementById('cal-f-allday').checked; + // Title overrules: if the title states a time, apply it to the start + // (keeping the current duration) so the picker can't silently disagree. + if (!isAD) { + const tt = _parseTitleTime(summary); + const startEl = document.getElementById('cal-f-start'); + const endEl = document.getElementById('cal-f-end'); + const newStart = tt ? `${String(tt.h).padStart(2, '0')}:${String(tt.m).padStart(2, '0')}` : null; + if (newStart && startEl && startEl.value !== newStart) { + const toMin = (v) => { const p = (v || '').split(':'); return p.length === 2 ? (+p[0]) * 60 + (+p[1]) : null; }; + const s0 = toMin(startEl.value), e0 = toMin(endEl?.value); + const dur = (s0 != null && e0 != null && e0 > s0) ? e0 - s0 : 60; + startEl.value = newStart; + const endMin = (tt.h * 60 + tt.m + dur) % 1440; + if (endEl) endEl.value = `${String(Math.floor(endMin / 60)).padStart(2, '0')}:${String(endMin % 60).padStart(2, '0')}`; + startEl.dispatchEvent(new Event('input')); + } + } + const activeDot = document.querySelector('#cal-f-colors .note-color-dot.active'); + const colorVal = activeDot?.dataset.color || ''; + // Append the user's current UTC offset so the backend stores events as + // proper UTC instants (is_utc=True). Without this, naive "10:00" gets + // re-interpreted as local elsewhere — the timezone-misfire bug. + const _tz = _tzOffset(); + const payload = { + summary, + dtstart: isAD ? dv : `${dv}T${document.getElementById('cal-f-start').value}:00${_tz}`, + dtend: isAD ? dvEnd : `${dvEnd}T${document.getElementById('cal-f-end').value}:00${_tz}`, + all_day: isAD, + description: document.getElementById('cal-f-desc').value, + location: document.getElementById('cal-f-loc').value, + rrule: document.getElementById('cal-f-rrule').value || undefined, + calendar_href: document.getElementById('cal-f-cal')?.value || (_calendars[0]?.href || ''), + color: colorVal || undefined, + }; + try { + if (isEdit) await _updateEvent(existing.uid, payload); + else await _createEvent(payload); + // Create reminder if selected + const remindVal = document.getElementById('cal-f-remind')?.value; + if (remindVal) { + let remindAt; + if (remindVal === 'custom') { + const customVal = document.getElementById('cal-f-remind-custom')?.value; + remindAt = customVal ? new Date(customVal) : null; + } else { + const eventStart = isAD ? new Date(dv + 'T00:00:00') : new Date(`${dv}T${document.getElementById('cal-f-start').value}:00`); + remindAt = new Date(eventStart.getTime() - parseInt(remindVal) * 60 * 1000); + } + if (remindAt && remindAt > new Date()) { + await _createEventReminder({ summary, dtstart: payload.dtstart, all_day: isAD, location: payload.location }, remindAt); + } + } + _selectedDay = dv; _render(); + } catch (e) { uiModule.showToast('Failed to save'); } + }); + document.getElementById('cal-f-del')?.addEventListener('click', async () => { + const name = existing && existing.summary ? `"${existing.summary}"` : 'this event'; + const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true }); + if (!ok) return; + try { await _deleteEvent(existing.uid); _render(); } + catch (e) { uiModule.showToast('Failed to delete'); } + }); + // ── Bespoke-form behavior ────────────────────────────────────────── + const formEl = body.querySelector('.cal-form'); + const detailsEl = document.getElementById('cal-form-details'); + const titleInput = document.getElementById('cal-f-sum'); + + const setExpanded = (on) => { + if (!formEl) return; + formEl.classList.toggle('is-expanded', on); + if (detailsEl) detailsEl.setAttribute('aria-hidden', on ? 'false' : 'true'); + }; + + // Focusing the title input unfolds the details once (new events). Edit + // mode opens already expanded when there's any detail content to see. + titleInput?.addEventListener('focus', () => setExpanded(true), { once: true }); + + // Location → Apple Maps. The pin button next to the input is enabled + // only when there's a non-empty location, and its href tracks the live + // input value. Apple's universal URL opens the native Maps app on + // iOS/macOS and falls back to a web view on everything else. + const locInput = document.getElementById('cal-f-loc'); + const locMap = document.getElementById('cal-f-loc-map'); + const _syncLocMap = () => { + if (!locMap) return; + const v = (locInput?.value || '').trim(); + if (!v) { + locMap.classList.add('is-disabled'); + locMap.removeAttribute('href'); + locMap.setAttribute('tabindex', '-1'); + locMap.setAttribute('aria-disabled', 'true'); + } else { + locMap.classList.remove('is-disabled'); + locMap.setAttribute('href', 'https://maps.apple.com/?q=' + encodeURIComponent(v)); + locMap.setAttribute('tabindex', '0'); + locMap.removeAttribute('aria-disabled'); + } + }; + locInput?.addEventListener('input', _syncLocMap); + _syncLocMap(); + + // Hero is clickable — clicking the time or date opens the matching + // native picker. Expands the details panel first so the input has been + // laid out (showPicker fails on display:none / 0-height inputs in some + // browsers). + const _openPicker = (inputId, { uncheckAllDay = false } = {}) => { + setExpanded(true); + const input = document.getElementById(inputId); + if (!input) return; + if (uncheckAllDay) { + const allday = document.getElementById('cal-f-allday'); + if (allday && allday.checked) { + allday.checked = false; + document.getElementById('cal-time-row').style.display = ''; + _syncHero(); + } + } + // Wait one frame for the reveal layout to settle. + requestAnimationFrame(() => { + input.focus(); + try { if (typeof input.showPicker === 'function') input.showPicker(); } catch {} + }); + }; + document.getElementById('cal-hero-time')?.addEventListener('click', (e) => { + // Detect which segment of the visible clock was clicked (hh, mm, or + // somewhere else) so clicking the minutes digits puts the caret right + // on the minute field of the picker. + const seg = e.target?.closest('[data-seg]')?.dataset?.seg; + _openPicker('cal-f-start', { uncheckAllDay: true }); + if (seg === 'mm') { + // `` accepts setSelectionRange in Chromium for + // selecting the minute segment; Firefox/Safari are no-ops but the + // picker still opens, so nothing is lost. + requestAnimationFrame(() => { + const inp = document.getElementById('cal-f-start'); + if (!inp) return; + try { inp.setSelectionRange(3, 5); } catch {} + }); + } + }); + document.getElementById('cal-hero-date')?.addEventListener('click', () => { + _openPicker('cal-f-date'); + }); + + // Live hero clock — keep the big time/date in sync with the inputs the + // user can still tweak inside the details panel. + const _syncHero = () => { + const allday = document.getElementById('cal-f-allday')?.checked; + const startVal = document.getElementById('cal-f-start')?.value || ''; + const dateVal = document.getElementById('cal-f-date')?.value || ds; + const clockEl = document.getElementById('cal-hero-clock'); + const ampmEl = document.getElementById('cal-hero-ampm'); + const dateEl = document.getElementById('cal-hero-date'); + if (clockEl) clockEl.innerHTML = allday ? 'All day' : _clockFace(startVal); + if (ampmEl) ampmEl.textContent = allday ? '' : _clockAmpm(startVal); + if (dateEl) dateEl.textContent = _clockDate(dateVal); + }; + document.getElementById('cal-f-start')?.addEventListener('input', _syncHero); + document.getElementById('cal-f-allday')?.addEventListener('change', _syncHero); + document.getElementById('cal-f-date')?.addEventListener('change', _syncHero); + _syncHero(); + + // New events: expand the details up front (don't rely on the title's focus + // event — programmatic .focus() is often a no-op on mobile, which would leave + // the form showing only the title + buttons), then focus the title. + if (!isEdit) { setExpanded(true); titleInput?.focus(); } + + // Live "Today is …" tick. Updates every 30s; auto-stops the moment the + // header element disappears (any _render() call swaps #cal-body's HTML). + const _todayTextEl = document.getElementById('cal-form-today-text'); + if (_todayTextEl) { + const _tick = () => { + const el = document.getElementById('cal-form-today-text'); + if (!el) { clearInterval(_todayInterval); return; } + el.textContent = `${_clockDate(_today())} · ${_nowClock()}`; + }; + const _todayInterval = setInterval(_tick, 30000); + } +} + +// ── Helpers ── + +function _fmtDate(s) { return new Date(s + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }); } + +// Hero clock helpers — used by the bespoke event form. +// _clockFace returns the colon-separated digits ("HH : MM"), _clockAmpm +// returns "AM"/"PM"/"" (empty for all-day), _clockDate is a long form +// "Sat · May 10, 2026". 24-h time stays without an AM/PM marker. +function _clockFace(hhmm) { + // Return the clock split into hh / separator / mm sub-spans so each + // segment is individually clickable. The wrapping #cal-hero-clock has + // its innerHTML re-set by _syncHero, so the spans round-trip cleanly. + if (!hhmm) { + return ' : '; + } + const [h, m] = hhmm.split(':'); + const use12 = (new Date()).toLocaleString().toLowerCase().match(/am|pm/); + let hh = parseInt(h, 10); + if (use12) { hh = ((hh + 11) % 12) + 1; } + const hhStr = String(hh).padStart(2, '0'); + return `${hhStr} : ${m}`; +} +function _clockAmpm(hhmm) { + if (!hhmm) return ''; + const use12 = (new Date()).toLocaleString().toLowerCase().match(/am|pm/); + if (!use12) return ''; + const h = parseInt(hhmm.split(':')[0], 10); + return h < 12 ? 'AM' : 'PM'; +} +function _clockDate(ds) { + if (!ds) return ''; + return new Date(ds + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }); +} +function _nowClock() { + // Live wall-clock string for the "Today is …" header. Locale-aware so + // 24-h users don't see AM/PM. + return new Date().toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); +} +function _fmtTime(s) { + // Show the time as written in the ICS file (ignore UTC offset). + if (!s || s.length < 16) return ''; + return s.slice(11, 16); +} +function _e(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(//g, '>').replace(/"/g, '"'); } + +// Linkify a location string: URLs become clickable, plain addresses get a Maps link. +function _locHTML(loc) { + if (!loc) return ''; + const urlRe = /(https?:\/\/[^\s]+)/gi; + if (urlRe.test(loc)) { + return loc.replace(urlRe, (url) => { + const safe = _e(url); + return `${safe}`; + }).replace(/\n/g, '
'); + } + // No URL — link the whole thing to OpenStreetMap. + const mapUrl = 'https://www.openstreetmap.org/search?query=' + encodeURIComponent(loc); + return `${_e(loc)}`; +} + +// ── Open / Close ── + +let _wheelDebounce = 0; +function _wheelNav(e) { + if (!_open) return; + // Don't intercept scroll inside the day-detail panel or any other inner scroll area + if (e.target.closest('.cal-day-detail') || e.target.closest('.cal-form')) return; + const body = document.getElementById('cal-body'); + if (!body) return; + const now = Date.now(); + if (now - _wheelDebounce < 300) { e.preventDefault(); return; } + if (Math.abs(e.deltaY) < 30) return; + _wheelDebounce = now; + e.preventDefault(); + if (e.deltaY > 0) { + _slideDir = 1; + if (_view === 'year') _currentDate = new Date(_currentDate.getFullYear() + 1, 0, 1); + else if (_view === 'week') _currentDate.setDate(_currentDate.getDate() + 7); + else _currentDate = new Date(_currentDate.getFullYear(), _currentDate.getMonth() + 1, 1); + } else { + _slideDir = -1; + if (_view === 'year') _currentDate = new Date(_currentDate.getFullYear() - 1, 0, 1); + else if (_view === 'week') _currentDate.setDate(_currentDate.getDate() - 7); + else _currentDate = new Date(_currentDate.getFullYear(), _currentDate.getMonth() - 1, 1); + } + _selectedDay = null; + _render(); +} + +function openCalendar() { + if (_open) return; + // If currently minimized — restore in place, preserve all state + if (Modals.isMinimized('calendar-modal')) { + Modals.restore('calendar-modal'); + _open = true; + return; + } + _open = true; + if (_todayCount() > 0) { _markBadgeSeen(); _updateBadge(); } + _collapseSidebar(); + const modal = _getModal(); + // Clean up any leftover state from a previous swipe-dismiss + modal.classList.remove('hidden', 'modal-minimized'); + const _content = modal.querySelector('.modal-content'); + if (_content) { + _content.classList.remove('modal-closing', 'sheet-ready'); + _content.style.transform = ''; + _content.style.transition = ''; + _content.style.animation = ''; + _content.style.opacity = ''; + } + modal.style.display = 'flex'; + Modals.register('calendar-modal', { + railBtnId: 'rail-calendar', + sidebarBtnId: 'tool-calendar-btn', + closeFn: () => _doCloseCalendar(), + restoreFn: () => {}, + }); + _currentDate = new Date(); + _selectedDay = _today(); // auto-show today's events on open + _view = 'month'; + _scrollToTodayOnOpen = true; // first render lands on today's row + _escHandler = (e) => { + if (e.key === 'Escape') { + // Layer Esc: close the topmost calendar surface first, only fall through + // to closing the whole calendar when nothing else is on top. + const settings = document.getElementById('cal-settings-panel'); + if (settings) { settings.remove(); return; } + if (document.querySelector('.cal-form')) { _render(); return; } + closeCalendar(); + } + else if (e.key === 'ArrowLeft') document.getElementById('cal-prev')?.click(); + else if (e.key === 'ArrowRight') document.getElementById('cal-next')?.click(); + else if (e.key === 't' || e.key === 'T') document.getElementById('cal-today')?.click(); + // Cmd/Ctrl+Z is handled by the module-level `_calUndoBound` listener, + // which consumes the shared `_calUndoStack`. Don't duplicate here. + }; + document.addEventListener('keydown', _escHandler); + const body = document.getElementById('cal-body'); + if (body) { + body.innerHTML = '
'; + const wp = spinnerModule.createWhirlpool(28); + wp.element.style.margin = '40px auto'; + body.querySelector('.cal-loading').appendChild(wp.element); + body.addEventListener('wheel', _wheelNav, { passive: false }); + } + _fetchCalendars().then(() => _render()); +} + +// Open the calendar focused on a specific event (by uid) or date. +// Used by the chat anchor-link delegate so `[Wake up](#event-)` +// opens the calendar on that day with the event highlighted. +async function openCalendarTo(target) { + openCalendar(); + if (!target) return; + try { + await _fetchCalendars(); + // If target looks like an ISO date (YYYY-MM-DD...), go straight there. + let dt = null; + const isoMatch = /^\d{4}-\d{2}-\d{2}/.test(String(target)); + if (isoMatch) { + dt = new Date(target); + } else { + // Treat as an event uid — find it among loaded events. + const ev = (_events || []).find(e => e.uid === target || (e.uid || '').startsWith(target)); + if (ev && ev.dtstart) dt = new Date(ev.dtstart); + if (ev) _highlightEventUid = ev.uid; + } + if (dt && !isNaN(dt.getTime())) { + _currentDate = new Date(dt); + _selectedDay = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate()); + _view = 'month'; + _render(); + } + } catch (e) { /* best-effort focus */ } +} + +let _highlightEventUid = null; + +function _doCloseCalendar() { + _open = false; + _restoreSidebar(); + if (_modal) { + _modal.style.display = 'none'; + _modal.classList.add('hidden'); + } + if (_escHandler) { document.removeEventListener('keydown', _escHandler); _escHandler = null; } + // Drop any pending undo — closures captured event uids/state that may + // no longer be valid by the time the user reopens. A reopened calendar + // starts with a clean slate. + _calUndoStack.length = 0; +} + +function closeCalendar() { + if (!_open && !Modals.isMinimized('calendar-modal')) return; + if (Modals.isRegistered('calendar-modal')) { + Modals.close('calendar-modal'); + } else { + _doCloseCalendar(); + } +} + +function isCalendarOpen() { + // Treat minimized as "not open" so toggle handler will restore via Modals.toggle + if (Modals.isMinimized('calendar-modal')) return false; + return _open; +} + +// ── Persistent cache (localStorage) ── +const LS_KEY = 'odysseus-calendar-cache'; +const LS_TTL = 10 * 60 * 1000; // 10 min + +function _saveCache() { + try { + const data = { + ts: Date.now(), + calendars: _calendars, + events: Object.values(_allEvents), + ranges: _fetchedRanges, + }; + localStorage.setItem(LS_KEY, JSON.stringify(data)); + } catch (e) {} +} + +function _loadCache() { + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return false; + const data = JSON.parse(raw); + if (!data.ts || Date.now() - data.ts > LS_TTL) return false; + if (data.calendars) _calendars = data.calendars; + if (data.events) data.events.forEach(ev => { _allEvents[ev.uid] = ev; }); + // Don't restore _fetchedRanges — always re-fetch from API to pick up + // external changes (e.g. TimeTree sync adding events) + return true; + } catch (e) { return false; } +} + +// Boot: load cache, refresh badge, prefetch current month +(async () => { + _loadCache(); + _updateBadge(); + try { + await _fetchCalendars(); + _saveCache(); + const [s, e] = _monthRange(new Date()); + await _fetchEvents(s, e); + _saveCache(); + _updateBadge(); + } catch (e) {} +})(); + +// Live-refresh when the AI agent adds/edits/deletes events. chat.js dispatches +// `calendar-refresh` after a manage_calendar tool call, so a new event shows up +// without the user hard-refreshing. Drop the cache (so adds/edits/deletes all +// reflect), refetch the visible range, re-render if open, and update the badge. +window.addEventListener('calendar-refresh', () => { + _allEvents = {}; + _fetchedRanges = []; + const range = (_view === 'year') + ? [`${_currentDate.getFullYear()}-01-01`, `${_currentDate.getFullYear() + 1}-01-01`] + : (_view === 'week') ? _weekRange(_currentDate) : _monthRange(_currentDate); + _fetchEvents(range[0], range[1], /*force*/ true) + .then(() => { if (_open) _render(); _updateBadge(); }) + .catch(() => {}); +}); + +// Calendar reminders are stored as Notes. The Notes reminder loop owns +// notification dispatch so calendar reminders do not fire twice. + +const calendarModule = { openCalendar, closeCalendar, isCalendarOpen }; +export { openCalendar, openCalendarTo, closeCalendar, isCalendarOpen }; +export default calendarModule; diff --git a/static/js/calendar/reminders.js b/static/js/calendar/reminders.js new file mode 100644 index 0000000..3f5a52a --- /dev/null +++ b/static/js/calendar/reminders.js @@ -0,0 +1,114 @@ +// static/js/calendar/reminders.js +// +// Browser-notification poller for calendar reminder notes. Self-contained: +// module-private `_notifFired` Set tracks which note IDs we've already +// notified, persisted to localStorage. Polls `/api/notes?label=calendar` +// every 60 seconds and fires a Notification + toast for any note whose +// `due_date` is in the past but within the staleness window. +// +// `start()` kicks off the poll loop + permission request. Call once from +// the calendar's entry module. + +import uiModule from '../ui.js'; + +const API_BASE = window.location.origin; + +let _notifFired = new Set(JSON.parse(localStorage.getItem('cal-notif-fired') || '[]')); + +// Compute a fresh, system-clock-accurate notification body. Tries the +// note's `event_dtstart` first (set by _createEventReminder); falls back +// to scrubbing stale time tokens out of items[0].text so legacy +// reminders don't show "in 29 min" at 9pm. +function _formatReminderBody(note) { + const dtstartRaw = note.event_dtstart || note.eventDtstart || null; + if (dtstartRaw) { + const start = new Date(dtstartRaw); + if (!isNaN(start.getTime())) { + const now = new Date(); + const mins = Math.round((start - now) / 60000); + const when = start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + let when2 = ''; + const sameDay = start.toDateString() === now.toDateString(); + if (!sameDay) when2 = ' ' + start.toLocaleDateString([], { month: 'short', day: 'numeric' }); + if (mins >= 1 && mins <= 60) return `Starts in ${mins} min (${when}${when2})`; + if (mins === 0) return `Starting now (${when}${when2})`; + if (mins > 60) { + const h = Math.round(mins / 60); + return `Starts in ${h} hour${h === 1 ? '' : 's'} (${when}${when2})`; + } + if (mins >= -60) return `Started ${Math.abs(mins)} min ago (${when}${when2})`; + return `Was scheduled for ${when}${when2}`; + } + } + // Legacy notes (no event_dtstart). Scrub stale relative-time strings. + let body = (note.items || []).map(i => i.text).join('\n') || note.content || ''; + body = body.replace(/\bin\s+\d+\s*(min|minute|hour|hr|day)s?\b/gi, '').trim(); + body = body.replace(/\(\s*\d{1,2}:\d{2}\s*\)/g, '').trim(); + body = body.replace(/\s{2,}/g, ' '); + return body; +} + +// Only fire a reminder if `due` was within this many minutes BEFORE now. +// Stops a fresh browser (empty `cal-notif-fired` localStorage) from spamming +// every 2-week-old reminder on first poll. Anything older is silently +// marked fired so it doesn't keep getting picked up. +const _REMINDER_STALENESS_MIN = 5; + +async function _pollReminders() { + try { + const res = await fetch(`${API_BASE}/api/notes?label=calendar`, { credentials: 'same-origin' }); + if (!res.ok) return; + const notes = await res.json(); + const now = new Date(); + const stalenessMs = _REMINDER_STALENESS_MIN * 60 * 1000; + for (const note of notes) { + if (!note.due_date || _notifFired.has(note.id)) continue; + const due = new Date(note.due_date); + if (isNaN(due)) continue; + if (due > now) continue; // not yet due + const ageMs = now - due; + if (ageMs > stalenessMs) { + // Too old to fire — mark as seen so we don't recheck every minute. + _notifFired.add(note.id); + continue; + } + _notifFired.add(note.id); + const body = _formatReminderBody(note); + fetch(`${API_BASE}/api/notes/fire-reminder`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + note_id: note.id, + title: note.title || 'Calendar Reminder', + body, + }), + }).catch(() => {}); + if ('Notification' in window && Notification.permission === 'granted') { + new Notification(note.title || 'Calendar Reminder', { + body, + icon: '/static/favicon.png', + tag: `cal-remind-${note.id}`, + }); + } + if (uiModule.showToast) uiModule.showToast((note.title || 'Calendar Reminder') + (body ? ' — ' + body : '')); + } + // Persist fired set (keep last 200) + const arr = [..._notifFired].slice(-200); + localStorage.setItem('cal-notif-fired', JSON.stringify(arr)); + } catch (_) {} +} + +let _started = false; + +// Idempotent: safe to call multiple times. Kicks off permission request +// and the 60s poll loop on first call. +export function startReminderPoll() { + if (_started) return; + _started = true; + if ('Notification' in window && Notification.permission === 'default') { + Notification.requestPermission(); + } + _pollReminders(); + setInterval(_pollReminders, 60000); +} diff --git a/static/js/calendar/utils.js b/static/js/calendar/utils.js new file mode 100644 index 0000000..a688852 --- /dev/null +++ b/static/js/calendar/utils.js @@ -0,0 +1,126 @@ +// static/js/calendar/utils.js +// +// Pure constants + zero-state helpers for the calendar UI. +// No DOM, no fetch, no global mutable state — safe to import anywhere. + +export const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +export const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December']; + +export const MON_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +export const CAL_PALETTE = [ + 'var(--accent)', '#5b8abf', '#bf6b5b', '#5bbf7a', '#bf9a5b', + '#9a5bbf', '#5bbfb8', '#bf8a5b', '#7070c0', '#bf5b8a', +]; + +export const CAL_COLORS = [ + { name: 'default', hex: '' }, + // Pale/pastel palette — softer event tints. + { name: 'red', hex: '#f0b5ba' }, + { name: 'orange', hex: '#e8ccb2' }, + { name: 'yellow', hex: '#f2dfbd' }, + { name: 'green', hex: '#cce0bc' }, + { name: 'blue', hex: '#b0d7f7' }, + { name: 'purple', hex: '#e2bcee' }, + { name: 'teal', hex: '#abdbe0' }, + { name: 'pink', hex: '#f0b5cc' }, + // Custom — mirrors the notes color picker. Clicking opens a file picker + // and the chosen image URL is stored as a `bg:` sentinel. + { name: 'custom', hex: 'custom' }, +]; + +export const _CAL_CUSTOM_GRADIENT = 'conic-gradient(from 0deg, #e06c75, #d19a66, #e5c07b, #98c379, #61afef, #c678dd, #e06c75)'; + +// Per-event-type accent palette. Used by the colored dots in month/year +// grids and the chip stripe behind agenda rows. +export const _TYPE_PALETTE = { + '!': '#e5a33a', // important — amber, less harsh than red + work: '#5b8abf', + personal: '#a07ae0', + health: '#e06c75', + travel: '#e5a33a', + meal: '#d8b974', + social: '#82c882', + admin: '#888888', + other: '#6b9cb5', + untagged: '#555', +}; + +// SVG icon literals reused across the calendar UI. +export const _trashIcon = ''; +export const _moreIcon = ''; +export const _bellIcon = ''; + +// ── background CSS helpers ── + +export function _isCalBgImage(c) { + return typeof c === 'string' && c.startsWith('bg:'); +} + +export function _calBgImageUrl(c) { + return _isCalBgImage(c) ? c.slice(3) : ''; +} + +// Returns a value safe to drop into `style="background:..."`. Falls back to +// the calendar default for bg-image events in spots where an image would be +// too small to render usefully (small grid dots, multi-day bars). +export function _calBgCss(c, fallback) { + if (_isCalBgImage(c)) { + const u = _calBgImageUrl(c); + return u ? `center/cover no-repeat url('${u.replace(/'/g, "\\'")}')` : (fallback || 'var(--accent)'); + } + return c || fallback || 'var(--accent)'; +} + +// ── date helpers ── + +// `YYYY-MM-DD` string from a Date. +export function _ds(d) { + return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); +} + +export function _addDays(dateStr, n) { + const d = new Date(dateStr + 'T00:00:00'); + d.setDate(d.getDate() + n); + return _ds(d); +} + +export function _shiftDT(iso, days) { + const d = new Date(iso); + d.setDate(d.getDate() + days); + return _ds(d) + (iso.length > 10 ? 'T' + iso.slice(11) : ''); +} + +// Current user's UTC offset as `±HH:MM`. Used to stamp event payloads so +// the backend can interpret naive datetimes in the user's tz. +export function _tzOffset() { + const o = -new Date().getTimezoneOffset(); + const sign = o >= 0 ? '+' : '-'; + const h = String(Math.floor(Math.abs(o) / 60)).padStart(2, '0'); + const m = String(Math.abs(o) % 60).padStart(2, '0'); + return `${sign}${h}:${m}`; +} + +// For naive datetimes (no tz suffix), display the date portion as written — +// TimeTree and many sync tools store "local time" without an offset, so +// re-interpreting them via the user's tz would shift days. +// +// For tz-aware ISO (`Z` or `±HH:MM`), parse as an absolute instant and +// bucket by the USER's local date. Without this an event at +// "2026-05-13T22:00:00Z" (07:00 May 14 JST) would render on May 13. +export function _localDateOf(isoStr) { + if (!isoStr) return ''; + if (isoStr.length === 10) return isoStr; + if (/[Zz]$|[+\-]\d{2}:?\d{2}$/.test(isoStr)) { + const d = new Date(isoStr); + if (!isNaN(d)) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${dd}`; + } + } + return isoStr.slice(0, 10); +} diff --git a/static/js/censor.js b/static/js/censor.js new file mode 100644 index 0000000..ecb5f2f --- /dev/null +++ b/static/js/censor.js @@ -0,0 +1,350 @@ +// static/js/censor.js +/** + * Sensitive Information Censor Module + * Detects emails, passwords, API keys, tokens, etc. in chat responses + * and blurs them. Click to reveal individual items. + */ + +let _enabled = true; +let _observer = null; +const PREF_KEY = 'odysseus-sensitive-blur'; +const _prefEnabled = () => localStorage.getItem(PREF_KEY) === 'on'; + +// Patterns that indicate sensitive data +const PATTERNS = [ + // Emails + { re: /\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b/g, label: 'email' }, + // API key prefixes (common services) + { re: /\b(sk-[a-zA-Z0-9]{20,}|pk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{36,}|gho_[a-zA-Z0-9]{36,}|glpat-[a-zA-Z0-9\-_]{20,}|xox[bpras]-[a-zA-Z0-9\-]{10,}|npm_[a-zA-Z0-9]{36,}|AKIA[A-Z0-9]{12,})\b/g, label: 'api-key' }, + // Bearer tokens + { re: /Bearer\s+[A-Za-z0-9._\-]{20,}/g, label: 'token' }, + // Generic tokens/secrets in key=value or key: value patterns + // Credentials with delimiters (key: value, key=value, key value) + { re: /(?:password|passwd|secret|api[_\-]?key|access[_\-]?token|auth[_\-]?token|private[_\-]?key|client[_\-]?secret)[\s]*[:=]\s*["']?[^\s"'<]{4,}["']?/gi, label: 'credential' }, + // Credentials in tabular/label-value format (Password xyzABC123) + { re: /(?:password|passwd|secret|api[_\-]?key|access[_\-]?token|auth[_\-]?token|private[_\-]?key|client[_\-]?secret)\s{2,}[^\s<]{4,}/gi, label: 'credential' }, + // Value after a line starting with password-like label + { re: /(?:^|\n)\s*(?:password|passwd|secret|api[_\-]?key|token|private[_\-]?key)[\t ]*\n\s*([^\s<]{4,})/gim, label: 'credential' }, + // SSH / PEM private keys (inline) + { re: /-----BEGIN\s[\w\s]*PRIVATE KEY-----[\s\S]*?-----END\s[\w\s]*PRIVATE KEY-----/g, label: 'private-key' }, + // Long hex strings (32+ chars) that look like hashes/tokens + { re: /\b[0-9a-f]{32,}\b/gi, label: 'hash' }, + // JWT tokens (three dot-separated base64 segments) + { re: /\beyJ[A-Za-z0-9_\-]{10,}\.eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b/g, label: 'jwt' }, + // IP addresses with ports (internal networks) + { re: /\b(?:10\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])|192\.168)\.\d{1,3}\.\d{1,3}(?::\d+)?\b/g, label: 'internal-ip' }, +]; + +export function init() { + // Load enabled state from feature flags + _loadState(); + window.addEventListener('odysseus-sensitive-blur-change', (e) => { + setEnabled(e.detail?.enabled !== false); + }); + // Set up click handler for reveals (delegated) + document.addEventListener('click', (e) => { + const el = e.target.closest('.censored-item'); + if (!el) return; + e.preventDefault(); + e.stopPropagation(); + el.classList.toggle('revealed'); + }); +} + +function _loadState() { + // Check admin feature flag + fetch('/api/auth/features', { credentials: 'same-origin' }) + .then(r => r.json()) + .then(features => { + _enabled = features.sensitive_filter !== false && _prefEnabled(); + // Start observer after loading state + _startObserver(); + }) + .catch(() => { + // Default: enabled + _enabled = _prefEnabled(); + _startObserver(); + }); +} + +function _startObserver() { + if (_observer) return; + // Observe chat-history, compare panes, and split panes for new messages + _observer = new MutationObserver((mutations) => { + if (!_enabled) return; + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType !== 1) continue; + // Process any .body elements within newly added nodes + if (node.classList && node.classList.contains('body')) { + _scheduleProcess(node); + } else if (node.querySelectorAll) { + node.querySelectorAll('.msg .body, .msg-ai .body').forEach(b => _scheduleProcess(b)); + } + } + } + }); + + // Observe the entire main area for new messages + const targets = [ + document.getElementById('chat-container'), + document.getElementById('chat-history'), + ].filter(Boolean); + + targets.forEach(t => { + _observer.observe(t, { childList: true, subtree: true }); + }); +} + +// Debounce processing — content may still be streaming +const _pending = new WeakSet(); +function _scheduleProcess(el) { + if (_pending.has(el)) return; + _pending.add(el); + // Wait for streaming to settle — process after a short delay + // Re-process periodically during streaming + let attempts = 0; + const maxAttempts = 30; + const interval = setInterval(() => { + _processElement(el); + attempts++; + if (attempts >= maxAttempts) clearInterval(interval); + }, 2000); + // Also process once immediately (catches non-streaming content) + setTimeout(() => _processElement(el), 100); + // Final pass after streaming likely done + setTimeout(() => { + clearInterval(interval); + _processElement(el); + _pending.delete(el); + }, 60000); +} + +// Labels that indicate the NEXT value should be censored +const SENSITIVE_LABELS = /^(?:password|passwd|secret|api[_\-]?key|access[_\-]?token|auth[_\-]?token|private[_\-]?key|client[_\-]?secret|token|credentials?)$/i; + +function _processElement(el) { + if (!_enabled || !el) return; + if (el.closest && el.closest('.setup-guide-no-censor')) return; + + // --- Pass 1: Pattern-based censoring on text nodes --- + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); + const textNodes = []; + let node; + while ((node = walker.nextNode())) { + if (node.parentElement.closest('.setup-guide-no-censor')) continue; + if (node.parentElement.closest('pre:not(.censored-item), .censored-item')) continue; + textNodes.push(node); + } + + for (const textNode of textNodes) { + const text = textNode.textContent; + if (!text || text.trim().length < 4) continue; + + const matches = []; + for (const pattern of PATTERNS) { + pattern.re.lastIndex = 0; + let m; + while ((m = pattern.re.exec(text)) !== null) { + matches.push({ start: m.index, end: m.index + m[0].length, text: m[0], label: pattern.label }); + } + } + if (matches.length === 0) continue; + + matches.sort((a, b) => a.start - b.start); + const deduped = [matches[0]]; + for (let i = 1; i < matches.length; i++) { + const prev = deduped[deduped.length - 1]; + if (matches[i].start < prev.end) { + if (matches[i].end > prev.end) prev.end = matches[i].end; + } else { + deduped.push(matches[i]); + } + } + + const frag = document.createDocumentFragment(); + let lastIdx = 0; + for (const match of deduped) { + if (match.start > lastIdx) { + frag.appendChild(document.createTextNode(text.slice(lastIdx, match.start))); + } + const span = document.createElement('span'); + span.className = 'censored-item'; + span.dataset.type = match.label; + span.title = 'Click to reveal ' + match.label; + span.textContent = match.text; + frag.appendChild(span); + lastIdx = match.end; + } + if (lastIdx < text.length) { + frag.appendChild(document.createTextNode(text.slice(lastIdx))); + } + textNode.parentNode.replaceChild(frag, textNode); + } + + // --- Pass 2: Context-aware label/value censoring --- + // Finds elements where text matches a sensitive label, then censors + // the adjacent sibling or next text content as a value. + _contextCensor(el); +} + +function _contextCensor(el) { + // Strategy 1: Walk all elements looking for sensitive labels + const allElements = el.querySelectorAll('td, th, dt, dd, span, strong, b, em, li, p, div'); + for (let i = 0; i < allElements.length; i++) { + const elem = allElements[i]; + if (elem.closest('.setup-guide-no-censor')) continue; + if (elem.closest('.censored-item, pre')) continue; + const txt = (elem.textContent || '').trim(); + if (!SENSITIVE_LABELS.test(txt)) continue; + + // Found a label — censor value via multiple strategies + let censored = false; + + // A) Next text sibling node (e.g. Password value123) + let sibling = elem.nextSibling; + while (sibling && !censored) { + if (sibling.nodeType === 3) { // text node + const val = sibling.textContent.trim(); + if (val.length >= 4 && !SENSITIVE_LABELS.test(val)) { + const span = document.createElement('span'); + span.className = 'censored-item'; + span.dataset.type = 'credential'; + span.title = 'Click to reveal credential'; + span.textContent = sibling.textContent; + sibling.parentNode.replaceChild(span, sibling); + censored = true; + } + } else if (sibling.nodeType === 1 && !sibling.closest('.censored-item')) { + // Element sibling — censor its text + const val = sibling.textContent.trim(); + if (val.length >= 4 && !SENSITIVE_LABELS.test(val)) { + _censorAllText(sibling); + censored = true; + } + } + sibling = censored ? null : sibling.nextSibling; + } + + // B) Parent's next element sibling (for /
pairs) + if (!censored) { + const parent = elem.parentElement; + if (parent) { + const nextEl = parent.nextElementSibling; + if (nextEl && !nextEl.closest('.censored-item')) { + const val = nextEl.textContent.trim(); + if (val.length >= 2 && !SENSITIVE_LABELS.test(val)) { + _censorAllText(nextEl); + censored = true; + } + } + } + } + + // C) Same parent, next text node after this element + if (!censored && elem.parentElement) { + const parent = elem.parentElement; + let found = false; + for (let c = 0; c < parent.childNodes.length; c++) { + const child = parent.childNodes[c]; + if (child === elem) { found = true; continue; } + if (!found) continue; + if (child.nodeType === 3 && child.textContent.trim().length >= 4) { + const val = child.textContent.trim(); + if (!SENSITIVE_LABELS.test(val)) { + const span = document.createElement('span'); + span.className = 'censored-item'; + span.dataset.type = 'credential'; + span.title = 'Click to reveal credential'; + span.textContent = child.textContent; + child.parentNode.replaceChild(span, child); + break; + } + } + } + } + } + + // Strategy 2: Full-text scan for label-value patterns across lines + // Get the full text, find patterns like "Password\n value" or "Password: value" + const fullText = el.textContent || ''; + const labelValueRe = /(?:password|passwd|secret|api[_\-]?key|access[_\-]?token|private[_\-]?key|client[_\-]?secret|token|auth[_\-]?token)\s*[:\s]\s*(\S{4,})/gi; + let m; + while ((m = labelValueRe.exec(fullText)) !== null) { + const value = m[1]; + // Find and censor this value string in text nodes + _censorValueInElement(el, value); + } +} + +function _censorValueInElement(el, value) { + if (!value || value.length < 4) return; + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); + let node; + while ((node = walker.nextNode())) { + if (node.parentElement.closest('.setup-guide-no-censor')) continue; + if (node.parentElement.closest('pre:not(.censored-item), .censored-item')) continue; + const idx = node.textContent.indexOf(value); + if (idx < 0) continue; + // Split text node and wrap the value + const before = node.textContent.slice(0, idx); + const after = node.textContent.slice(idx + value.length); + const frag = document.createDocumentFragment(); + if (before) frag.appendChild(document.createTextNode(before)); + const span = document.createElement('span'); + span.className = 'censored-item'; + span.dataset.type = 'credential'; + span.title = 'Click to reveal credential'; + span.textContent = value; + frag.appendChild(span); + if (after) frag.appendChild(document.createTextNode(after)); + node.parentNode.replaceChild(frag, node); + return; // One replacement per call to avoid walker issues + } +} + +function _censorAllText(el) { + // Wrap all text content in a censored span + if (el.querySelector('.censored-item')) return; + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); + const nodes = []; + let n; + while ((n = walker.nextNode())) { + if (n.parentElement.closest('.setup-guide-no-censor')) continue; + if (n.parentElement.closest('.censored-item, pre')) continue; + if (n.textContent.trim().length >= 2) nodes.push(n); + } + for (const tn of nodes) { + const span = document.createElement('span'); + span.className = 'censored-item'; + span.dataset.type = 'credential'; + span.title = 'Click to reveal credential'; + span.textContent = tn.textContent; + tn.parentNode.replaceChild(span, tn); + } +} + +/** Manually censor a specific element (for dynamically loaded content) */ +export function censorElement(el) { + if (!_enabled) return; + _processElement(el); +} + +/** Toggle censoring on/off (client-side) */ +export function setEnabled(enabled) { + _enabled = enabled; + if (!enabled) { + // Reveal all currently censored items + document.querySelectorAll('.censored-item').forEach(el => el.classList.add('revealed')); + } else { + document.querySelectorAll('.censored-item').forEach(el => el.classList.remove('revealed')); + } +} + +export function isEnabled() { + return _enabled; +} + +const censorModule = { init, censorElement, setEnabled, isEnabled }; + +export default censorModule; diff --git a/static/js/chat.js b/static/js/chat.js new file mode 100644 index 0000000..d26d1f0 --- /dev/null +++ b/static/js/chat.js @@ -0,0 +1,4488 @@ +// static/js/chat.js + +/** + * Main chat functionality - message handling and streaming + */ +// ES6 module — IIFE removed + +import Storage from './storage.js'; +import uiModule from './ui.js'; +import sessionModule from './sessions.js'; +import chatRenderer from './chatRenderer.js'; +import chatStream from './chatStream.js'; +import { addAITTSButton } from './tts-ai.js'; +import markdownModule from './markdown.js'; +import spinnerModule from './spinner.js'; +import presetsModule from './presets.js'; +import fileHandlerModule from './fileHandler.js'; +import searchModule from './search.js'; +import documentModule from './document.js'; +import * as emailInbox from './emailInbox.js'; +import codeRunnerModule from './codeRunner.js'; +import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js'; +import createResearchSynapse from './researchSynapse.js'; + const RESEARCH_TIMEOUT_MS = 360000; + const DEFAULT_TIMEOUT_MS = 120000; + const RESEARCH_SVG = ''; + + let API_BASE = ''; + let currentAbort = null; + let isStreaming = false; + // Continuous stall watchdog: while streaming, if the SSE stream produces + // NOTHING for STALL_THRESHOLD_MS (no deltas, no tool heartbeat — tools beat + // every 2s, so a full minute of silence means it's genuinely stuck or the + // model quietly stopped), surface a non-destructive "still working?" prompt + // instead of silently hanging. Replaces relying only on the tab-refocus + // recovery (which fired only on visibilitychange and silently reloaded). + let _stallWatchdog = null; + let _stallBannerShown = false; + const STALL_THRESHOLD_MS = 60000; + let _sendInFlight = false; // covers the window from click → streaming start + let _displayOverride = null; // Override visible user bubble text (hides injected prompts) + let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop) + let _pendingContinue = null; // Stores the stopped AI element to merge with new response + // ── Auto-recovery: when a turn's stream silently dies (connection drop) or + // goes quiet while the connection is alive, re-engage the model with a + // completion handshake instead of leaving it hung. Capped so it can't loop. + let _autoNudges = 0; // handshakes fired for the CURRENT user turn + let _autoContinuePending = false; // marks the next submit as an auto-continue (don't reset the counter) + const _AUTO_NUDGE_CAP = 3; + + // shortModel and modelColor are now in chatRenderer.js + var _shortModel = chatRenderer.shortModel; + var _applyModelColor = chatRenderer.applyModelColor; + // Per-session research tracking (supports concurrent research across sessions) + const _researchingStreamIds = new Set(); + let _researchTimerEl = null, _researchTimerInterval = null; + let _researchStartTime = 0, _researchAvgDuration = null; + let _researchSynapse = null; + function _clearResearchTimer() { + if (_researchTimerInterval) { clearInterval(_researchTimerInterval); _researchTimerInterval = null; } + if (_researchTimerEl) { _researchTimerEl.remove(); _researchTimerEl = null; } + if (_researchSynapse) { + // Mark complete first so the user briefly sees the "done" state, + // then tear it down on next tick. + try { _researchSynapse.complete(); } catch {} + const s = _researchSynapse; + _researchSynapse = null; + setTimeout(() => { try { s.destroy(); } catch {} }, 800); + } + _researchStartTime = 0; + _researchAvgDuration = null; + } + + /** Append a "Generate Visual Report" button — delegates to chatRenderer. */ + function _appendViewReportLink(msgEl, sessionId) { + const body = msgEl.querySelector('.body'); + if (body) chatRenderer.appendReportButton(body, sessionId); + } + let currentAccumulated = ''; // Track accumulated text across function scope + let currentHolder = null; // Track current message holder + let currentSpinner = null; // Track current spinner for stop cleanup + + // Background streaming support + const _backgroundStreams = new Map(); // sessionId -> { status, accumulated, sourcesHtml, abortCtrl, query, metrics } + let _streamSessionId = null; // Session ID for the currently active reader loop + let _lastReaderActivity = 0; // Timestamp of last reader.read() success — used to detect frozen streams + let _webLockRelease = null; // Function to release the Web Lock held during streaming + + /** Check if an SSE reader is still actively connected for a session. */ + function hasActiveStream(sessionId) { + return _streamSessionId === sessionId || _backgroundStreams.has(sessionId); + } + + // Sources box builder and toggleSources are now in chatRenderer.js + var _buildSourcesBox = chatRenderer.buildSourcesBox; + + // Browser notifications now in chatStream.js + var _notifyResearchComplete = chatStream.notifyResearchComplete; + + // Model/image pricing, _buildImageBubble now in chatRenderer.js + var _buildImageBubble = chatRenderer.buildImageBubble; + var getModelCost = chatRenderer.getModelCost; + var getImageCost = chatRenderer.getImageCost; + + // stripToolBlocks and roleTimestamp now in chatRenderer.js + var stripToolBlocks = chatRenderer.stripToolBlocks; + + function _normalizeEndpointForCompare(url) { + if (!url) return ''; + try { + const u = new URL(String(url), window.location.origin); + let path = u.pathname.replace(/\/+$/, ''); + const suffixes = [ + '/v1/chat/completions', '/chat/completions', + '/v1/completions', '/completions', + '/v1/messages', '/messages', + '/v1/models', '/models', + ]; + for (const suffix of suffixes) { + if (path.toLowerCase().endsWith(suffix)) { + path = path.slice(0, -suffix.length).replace(/\/+$/, ''); + break; + } + } + return (u.origin + path).toLowerCase(); + } catch (_) { + return String(url).trim().replace(/\/+$/, '').toLowerCase(); + } + } + + async function _probeCurrentEndpointStatus(endpointUrl, signal) { + const target = _normalizeEndpointForCompare(endpointUrl); + if (!target) return null; + const modelsRes = await fetch(`${API_BASE}/api/models`, { credentials: 'same-origin', signal }); + if (!modelsRes.ok) return null; + const modelsData = await modelsRes.json().catch(() => ({})); + const item = (modelsData.items || []).find(ep => + _normalizeEndpointForCompare(ep.url || ep.endpoint_url || ep.base_url) === target + ); + if (!item || !item.endpoint_id) return null; + + const probesRes = await fetch(`${API_BASE}/api/model-endpoints/probe-local`, { + credentials: 'same-origin', + signal, + }); + if (!probesRes.ok) return null; + const probes = await probesRes.json().catch(() => ({})); + return probes[item.endpoint_id] || null; + } + + /** + * Initialize with dependencies + */ + export function init(apiBase) { + API_BASE = apiBase; + initSlashCommands({ apiBase, isStreaming: () => isStreaming }); + // Initialize email inbox + emailInbox.init(documentModule); + } + + // addMessage, createMsgFooter, displayMetrics, hideWelcomeScreen, showWelcomeScreen + // are now in chatRenderer.js — referenced via the public API delegation above. + var addMessage = chatRenderer.addMessage; + var createMsgFooter = chatRenderer.createMsgFooter; + var displayMetrics = chatRenderer.displayMetrics; + var hideWelcomeScreen = chatRenderer.hideWelcomeScreen; + var showWelcomeScreen = chatRenderer.showWelcomeScreen; + + /** + * Update submit button state + */ + function updateSubmitButton(state, submitBtn) { + if (!submitBtn) return; + + if (state === 'streaming') { + // Clear any pending transitions from + → arrow swap + submitBtn.classList.remove('anim-spin', 'anim-spin-swap', 'anim-land', 'mic-mode', 'newchat-mode', 'newchat-expanded', 'recording'); + // Ensure arrow icon is showing before launch + var icons = window._odysseusBtnIcons; + if (icons) submitBtn.innerHTML = icons.send; + void submitBtn.offsetWidth; + // Arrow launches up, then stop icon lands in + submitBtn.classList.add('anim-launch'); + const _stopSvg = ''; + // Wait for the launch keyframe to finish (0.3s) before swapping the + // arrow out for the stop icon — otherwise the swap happens mid-flight + // and the user sees nothing fly out. + setTimeout(() => { + submitBtn.innerHTML = _stopSvg; + submitBtn.classList.remove('anim-launch'); + void submitBtn.offsetWidth; + submitBtn.classList.add('anim-land'); + submitBtn.addEventListener('animationend', () => submitBtn.classList.remove('anim-land'), { once: true }); + }, 300); + submitBtn.title = 'Stop generation'; + submitBtn.dataset.mode = 'streaming'; + submitBtn.dataset.phase = 'processing'; + isStreaming = true; + _startStallWatchdog(); + } else if (state === 'idle') { + submitBtn.dataset.mode = ''; + delete submitBtn.dataset.phase; + submitBtn.classList.remove('recording'); + isStreaming = false; + _stopStallWatchdog(); + // Defer to global updater which handles mic/newchat/send modes + if (window._updateSendBtnIcon) { + setTimeout(window._updateSendBtnIcon, 50); + } else { + var icons = window._odysseusBtnIcons; + submitBtn.innerHTML = icons ? icons.send : ''; + submitBtn.title = 'Send message'; + submitBtn.classList.remove('mic-mode', 'newchat-mode'); + } + } + } + + // ----------------------------------------------------------------------- + // Slash commands — now in slashCommands.js + // ----------------------------------------------------------------------- + + // API key pattern for the guard in handleChatSubmit + const API_KEY_RE = /^(sk-[a-zA-Z0-9_\-]{20,}|gsk_[a-zA-Z0-9]{20,}|AIza[a-zA-Z0-9_\-]{30,}|xai-[a-zA-Z0-9]{20,})$/; + + + /** + * Handle chat form submission + */ + export async function handleChatSubmit(e) { + e.preventDefault(); + // Cancel research clarification timeout if active + if (window._researchTimeoutTimer) { + clearTimeout(window._researchTimeoutTimer); + window._researchTimeoutTimer = null; + } + // Get current session + const sessionId = sessionModule.getCurrentSessionId(); + const session = sessionModule.getSessions().find(s => s.id === sessionId); + + const submitBtn = document.querySelector('.send-btn'); + + // If compare is active, stop all compare streams + if (window.compareModule && window.compareModule.isActive()) { + window.compareModule.handleCompareSubmit(); + return; + } + + // If currently streaming, stop it + if (isStreaming) { + // Cancel server-side research if in progress + const _cancelSid = sessionModule.getCurrentSessionId(); + if (_cancelSid && _researchingStreamIds.has(_cancelSid)) { + fetch(`${API_BASE}/api/research/cancel/${_cancelSid}`, { method: 'POST' }).catch(e => console.warn('Research cancel failed:', e)); + _researchingStreamIds.delete(_cancelSid); + _clearResearchTimer(); + } + abortCurrentRequest(true); // explicit user Stop → also cancel the detached server run + + // Clean up any running agent thread nodes (stop wave animation, remove "running" state) + document.querySelectorAll('.agent-thread-node.running').forEach(node => { + if (node._waveInterval) { clearInterval(node._waveInterval); node._waveInterval = null; } + if (node._elapsedTicker) { clearInterval(node._elapsedTicker); node._elapsedTicker = null; } + node.classList.remove('running'); + const wave = node.querySelector('.agent-thread-wave'); + if (wave) wave.textContent = ''; + const icon = node.querySelector('.agent-thread-icon'); + if (icon) icon.textContent = '\u25A0'; // stop square + const statusEl = node.querySelector('.agent-thread-status'); + if (!statusEl) { + const header = node.querySelector('.agent-thread-header'); + if (header) { + const s = document.createElement('span'); + s.className = 'agent-thread-status'; + s.textContent = 'stopped'; + header.appendChild(s); + } + } + }); + document.querySelectorAll('.agent-thread.streaming').forEach(t => t.classList.remove('streaming')); + + // Clean up any thinking spinners + document.querySelectorAll('.agent-thinking-dots').forEach(el => { + if (el._spinner) el._spinner.destroy(); + el.remove(); + }); + // No text accumulated — remove the empty holder with spinner + if (currentHolder && !currentAccumulated) { + if (currentSpinner) { currentSpinner.destroy(); currentSpinner = null; } + // Empty cancel — keep the assistant bubble around with a "Cancelled + // by user" indicator and persist a placeholder server-side so the + // turn survives a refresh instead of vanishing without a trace. + _renderCancelledBubble(currentHolder); + currentHolder = null; + updateSubmitButton('idle', submitBtn); + const messageInput = uiModule.el('message'); + if (messageInput) messageInput.disabled = false; + currentAccumulated = ''; + return; + } + // Render whatever was accumulated so far + if (currentHolder && currentAccumulated) { + // Store accumulated in a closure variable before it gets cleared + const stoppedContent = currentAccumulated; + + // Store raw content in dataset for consistency with other messages + currentHolder.dataset.raw = stoppedContent; + + currentHolder.querySelector('.body').innerHTML = markdownModule.processWithThinking( + markdownModule.squashOutsideCode(stoppedContent) + ); + + // Highlight code blocks + if (window.hljs) { + currentHolder.querySelectorAll('pre code').forEach((block) => { + window.hljs.highlightElement(block); + }); + } + + // Add the stopped indicator with continue button + const stoppedIndicator = document.createElement('div'); + stoppedIndicator.className = 'stopped-indicator'; + const stoppedLabel = document.createElement('span'); + stoppedLabel.textContent = '[Message interrupted]'; + stoppedIndicator.appendChild(stoppedLabel); + const continueBtn = document.createElement('button'); + continueBtn.className = 'continue-btn'; + continueBtn.title = 'Continue'; + continueBtn.textContent = '\u25B8'; + const _stoppedHolder = currentHolder; // capture before it gets cleared + continueBtn.addEventListener('click', () => { + stoppedIndicator.remove(); + _hideUserBubble = true; + _pendingContinue = _stoppedHolder; + const cutoff = stoppedContent; + const msgInput = uiModule.el('message'); + if (msgInput) { + msgInput.value = 'Your previous response was interrupted. It ended with:\n\n' + cutoff.slice(-500) + '\n\nDo NOT repeat what you already said. Continue exactly from where you were cut off.'; + const sb = document.querySelector('.send-btn'); + if (sb) sb.click(); + } + }); + stoppedIndicator.appendChild(continueBtn); + currentHolder.querySelector('.body').appendChild(stoppedIndicator); + + // Tell server to mark this message as stopped + const _sid = sessionModule.getCurrentSessionId(); + if (_sid) fetch(`${API_BASE}/api/session/${_sid}/mark-stopped`, { method: 'POST' }).catch(e => console.warn('mark-stopped failed:', e)); + + // Add footer with copy/regen if not already present + if (!currentHolder.querySelector('.msg-footer')) { + currentHolder.dataset.raw = stoppedContent; + currentHolder.appendChild(createMsgFooter(currentHolder)); + } + + uiModule.scrollHistory(); + } + + // Reset button state + updateSubmitButton('idle', submitBtn); + + // Re-enable message input + const messageInput = uiModule.el('message'); + if (messageInput) messageInput.disabled = false; + + // Clear tracking variables + currentAccumulated = ''; + currentHolder = null; + + return; + } + + // --- Send-path entry: block re-clicks between submit and stream start --- + if (_sendInFlight) return; + _sendInFlight = true; + // Instant visual feedback so the user sees their click was accepted + // even before the streaming button state kicks in below. + const _earlyMessageInput = uiModule.el('message'); + if (_earlyMessageInput) _earlyMessageInput.disabled = true; + if (submitBtn) submitBtn.classList.add('send-pending'); + const _releaseSendFlag = () => { + _sendInFlight = false; + if (_earlyMessageInput) _earlyMessageInput.disabled = false; + if (submitBtn) submitBtn.classList.remove('send-pending'); + }; + + // --- Setup mode: intercept next message (but let slash commands through) --- + { + const el = uiModule.el; + const rawMsg = (el('message').value || '').trim(); + const currentSetupMode = slashCommands.getSetupMode(); + if (currentSetupMode && rawMsg && !isCommand(rawMsg)) { + const mode = currentSetupMode; + slashCommands.clearSetupMode(mode === 'endpoint-provider' || mode === 'endpoint-key-for-provider'); + el('message').value = ''; + if (window._syncModelPickerAutohide) window._syncModelPickerAutohide(); + if (uiModule.autoResize) uiModule.autoResize(el('message')); + if (mode === true || mode === 'endpoint') { + handleSetupInput(rawMsg); + } else { + handleSetupWizard(mode, rawMsg); + } + _releaseSendFlag(); + return; + } + if (currentSetupMode && rawMsg && isCommand(rawMsg)) { + slashCommands.clearSetupMode(); // Clear setup mode, fall through to slash handler + } + } + + const el = uiModule.el; + const msg = el('message').value; + // Allow empty text when a regen carries over the original message's + // attachment ids — a photo-only message still has something to send. + if (!msg.trim() && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; } + + // --- Slash commands: execute directly without AI (no session needed) --- + if (isCommand(msg.trim())) { + const handled = await handleSlashCommand(msg.trim()); + if (handled) { + el('message').value = ''; + if (window._syncModelPickerAutohide) window._syncModelPickerAutohide(); + if (uiModule.autoResize) uiModule.autoResize(el('message')); + _releaseSendFlag(); + return; + } + } + + // Materialize pending session (deferred from model click) on first message + if (sessionModule.hasPendingChat && sessionModule.hasPendingChat()) { + const ok = await sessionModule.materializePendingSession(); + if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; } + } + + if (!sessionModule.getCurrentSessionId()) { + // Auto-create a session using default chat config. Always fetch fresh + // so that a recent Settings change takes effect without a page reload. + try { + let dc = null; + try { + const dcRes = await fetch('/api/default-chat'); + dc = await dcRes.json(); + if (dc && dc.endpoint_url && dc.model) { + try { window.__odysseusDefaultChat = dc; } catch (_) {} + } + } catch (_) { + dc = (typeof window !== 'undefined' && window.__odysseusDefaultChat) || null; + } + if (dc.endpoint_url && dc.model) { + await sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id); + const ok = await sessionModule.materializePendingSession(); + if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; } + } else { + addMessage('assistant', + 'No chat session active. You can:\n\n' + + '- Pick a model from the sidebar to start a chat\n' + + '- Run `/setup` to configure an endpoint\n' + + '- Run `/new` to create a session manually\n' + + '- Use `/help` to see all available commands'); + _releaseSendFlag(); + return; + } + } catch (e) { + addMessage('assistant', + 'No chat session active. You can:\n\n' + + '- Pick a model from the sidebar to start a chat\n' + + '- Run `/setup` to configure an endpoint\n' + + '- Run `/new` to create a session manually\n' + + '- Use `/help` to see all available commands'); + _releaseSendFlag(); + return; + } + } + + // --- API key guard: warn if message looks like an API key --- + if (API_KEY_RE.test(msg.trim())) { + if (!await window.styledConfirm('This looks like an API key. Sending it to the AI could expose it.\n\nDid you mean to use /setup instead?', { confirmText: 'Send anyway', danger: true })) { + _releaseSendFlag(); + return; + } + } + + + const messageInput = el('message'); + const originalBtnText = submitBtn ? submitBtn.innerHTML : ''; + + // Re-enable the textarea now that we've handed off to the stream: the + // user wants to compose the next message while the AI is still talking. + // The `isStreaming` flag is the re-click guard for the send button. + if (messageInput) messageInput.disabled = false; + updateSubmitButton('streaming', submitBtn); + if (submitBtn) submitBtn.classList.remove('send-pending'); + _sendInFlight = false; + + // Capture session ID for background stream detection + const streamSessionId = sessionModule.getCurrentSessionId(); + _streamSessionId = streamSessionId; + const streamQuery = msg; + _lastReaderActivity = Date.now(); + + // Acquire Web Lock to hint browser not to discard this tab while streaming + if (navigator.locks) { + navigator.locks.request('odysseus-stream-' + streamSessionId, { mode: 'exclusive', ifAvailable: true }, lock => { + if (!lock) return; // Another stream already holds a lock — fine + return new Promise(resolve => { _webLockRelease = resolve; }); + }).catch(e => console.warn('web lock acquire failed:', e)); // Ignore lock errors — best-effort + } + + // Declare accumulated outside try block so it's accessible in catch + let accumulated = ''; + let holder = null; + let finalMeta = null; + let finalModelName = null; + let spinner = null; + let timedOut = false; + let processingProbeTimer = null; + let processingProbeAbort = null; + const clearProcessingProbe = () => { + if (processingProbeTimer) { + clearTimeout(processingProbeTimer); + processingProbeTimer = null; + } + if (processingProbeAbort) { + try { processingProbeAbort.abort(); } catch (_) {} + processingProbeAbort = null; + } + }; + + // Reset tracking variables at start + currentAccumulated = ''; + currentHolder = null; + + try { + // Re-enable auto-scroll when user sends a message + uiModule.setAutoScroll(true); + uiModule.scrollHistoryInstant(); + // Clear completed dot now that user is interacting + if (sessionModule.clearStreamComplete) sessionModule.clearStreamComplete(sessionModule.getCurrentSessionId()); + + // Check for document selection context before consuming display override + const docSel = documentModule && documentModule.getSelectionContext(); + if (docSel) { + const sels = Array.isArray(docSel) ? docSel : [docSel]; + const lineRefs = sels.map(s => + s.startLine === s.endLine ? `L${s.startLine}` : `L${s.startLine}-${s.endLine}` + ); + _displayOverride = `[Doc edit: ${lineRefs.join(', ')}] ${msg}`; + } + + const userDisplay = _displayOverride || msg; + _displayOverride = null; + const skipBubble = _hideUserBubble; + _hideUserBubble = false; + // Auto-recovery counter: carries across a turn's auto-continues, but resets + // when the user genuinely sends a new message (so each task gets a fresh cap). + // A real user turn (visible bubble) ALWAYS resets the budget — even if a + // prior auto-continue's deferred click never cleared the pending flag — so a + // stuck flag can't silently eat the next turn's recovery budget. + if (!skipBubble) { _autoNudges = 0; _autoContinuePending = false; } + else if (_autoContinuePending) { _autoContinuePending = false; } + const _pendingAttachInfo = fileHandlerModule.getPendingCount() ? fileHandlerModule.getPendingInfo() : null; + // Pre-read importable file contents before upload clears pending files + const IMPORTABLE_EXT = /\.(txt|py|js|ts|html|htm|css|md|json|csv|yml|yaml|sh|sql|rs|go|java|c|cpp|h|rb|php|xml|jsx|tsx|log|toml|ini|conf|env|vue|svelte|scss|sass|less)$/i; + const _importableFiles = []; + if (_pendingAttachInfo && documentModule) { + const rawFiles = fileHandlerModule.getPendingRaw ? fileHandlerModule.getPendingRaw() : []; + for (let i = 0; i < _pendingAttachInfo.length; i++) { + const att = _pendingAttachInfo[i]; + if (IMPORTABLE_EXT.test(att.name) && rawFiles[i]) { + _importableFiles.push({ info: att, file: rawFiles[i] }); + } + } + } + let _userMsgEl = null; + if (!skipBubble) { + _userMsgEl = addMessage('user', userDisplay, null, _pendingAttachInfo ? { attachments: _pendingAttachInfo } : null); + } + messageInput.value = ''; + messageInput.style.height = ''; + messageInput.dispatchEvent(new Event('input')); + // Mobile: dismiss the on-screen keyboard after sending. iOS in + // particular ignores a bare blur() in some cases (or some other + // listener refocuses straight after), so we temporarily mark the + // input readonly which forces the keyboard to retract, then blur, + // then drop the readonly attribute after the keyboard is gone so + // typing still works for the next message. + if (window.innerWidth <= 768) { + try { + messageInput.setAttribute('readonly', 'readonly'); + messageInput.blur(); + const _dropReadonly = () => { try { messageInput.removeAttribute('readonly'); } catch {} }; + setTimeout(() => { + // If the blur stuck, the input is no longer the active element — + // safe to drop readonly now so the next message can be typed. + // If it did NOT stick (some mobile browsers keep the textarea + // focused after a programmatic blur), removing readonly here would + // re-summon the keyboard mid-stream — the "bounce up" that then + // lingers until the end-of-stream blur. In that case keep readonly + // on (keyboard stays down) and drop it the moment the user taps to + // type again, so typing still works without the bounce. + if (document.activeElement === messageInput) { + messageInput.addEventListener('pointerdown', _dropReadonly, { once: true }); + messageInput.addEventListener('focus', _dropReadonly, { once: true }); + } else { + _dropReadonly(); + } + }, 120); + } catch {} + } + + let ids = []; + try { + ids = await fileHandlerModule.uploadPending(); + } catch(e) { + console.error('upload failed', e); + } + + // Carry over the original message's file-ids on a regenerate so the new + // send still references the same photos / docs (and picks up the user's + // edited OCR text via the server-side .vision cache). Always CONSUME the + // slot — even when empty / errored — so the regen ids can't bleed into + // an unrelated next message if uploadPending() above had thrown. + if (_pendingRegenAttachments && _pendingRegenAttachments.length) { + ids = ids.concat(_pendingRegenAttachments); + } + _pendingRegenAttachments = null; + + // The optimistic user bubble was rendered before the upload assigned ids, + // so image previews couldn't show (the renderer needs att.id). Now that + // the upload resolved, stamp the ids — plus width/height for images so + // the skeleton can size itself to the photo's aspect ratio — and + // re-render so the thumbnail appears live, no refresh needed. + if (_userMsgEl && _pendingAttachInfo && ids.length) { + const _meta = fileHandlerModule.getLastUploadedMeta?.() || []; + for (let i = 0; i < _pendingAttachInfo.length && i < ids.length; i++) { + _pendingAttachInfo[i].id = ids[i]; + const _m = _meta[i]; + if (_m) { + if (_m.width) _pendingAttachInfo[i].width = _m.width; + if (_m.height) _pendingAttachInfo[i].height = _m.height; + } + } + chatRenderer.updateMessageAttachments(_userMsgEl, _pendingAttachInfo); + } + + // Offer to import text files to document library + if (_importableFiles.length > 0) { + const existing = document.getElementById('import-prompt-banner'); + if (existing) existing.remove(); + const banner = document.createElement('div'); + banner.id = 'import-prompt-banner'; + banner.className = 'import-prompt-banner'; + const label = _importableFiles.length === 1 + ? `Import "${_importableFiles[0].info.name}" to document library?` + : `Import ${_importableFiles.length} files to document library?`; + const textEl = document.createElement('span'); + textEl.textContent = label; + banner.appendChild(textEl); + const importBtn = document.createElement('button'); + importBtn.textContent = 'Import'; + importBtn.addEventListener('click', async () => { + importBtn.disabled = true; + importBtn.textContent = 'Importing…'; + const EXT_LANG = {'.py':'python','.js':'javascript','.ts':'typescript','.html':'html','.css':'css','.md':'markdown','.json':'json','.yml':'yaml','.yaml':'yaml','.sh':'bash','.sql':'sql','.rs':'rust','.go':'go','.java':'java','.c':'c','.cpp':'cpp','.rb':'ruby','.php':'php','.xml':'xml','.jsx':'javascript','.tsx':'typescript'}; + let imported = 0; + for (const { info, file } of _importableFiles) { + try { + const content = await file.text(); + const dotIdx = info.name.lastIndexOf('.'); + const title = dotIdx > 0 ? info.name.slice(0, dotIdx) : info.name; + const ext = dotIdx >= 0 ? info.name.slice(dotIdx).toLowerCase() : ''; + await fetch(`${API_BASE}/api/document`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, language: EXT_LANG[ext] || '', content }), + }); + imported++; + } catch (e) { console.error('Import failed:', info.name, e); } + } + banner.textContent = `Imported ${imported} file${imported !== 1 ? 's' : ''}`; + setTimeout(() => banner.remove(), 2000); + }); + banner.appendChild(importBtn); + const dismissBtn = document.createElement('button'); + dismissBtn.textContent = '\u00d7'; + dismissBtn.className = 'import-prompt-dismiss'; + dismissBtn.addEventListener('click', () => banner.remove()); + banner.appendChild(dismissBtn); + const chatBar = document.getElementById('chat-bar'); + if (chatBar) chatBar.parentNode.insertBefore(banner, chatBar); + // Auto-dismiss after 15 seconds + setTimeout(() => { if (banner.parentNode) banner.remove(); }, 15000); + } + + // Auto-save document editor content before sending so the AI sees latest text + if (documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) { + try { await documentModule.saveDocument(); } catch(e) { console.warn('doc auto-save failed', e); } + } + + // Inject document selection context if present + let finalMsg = msg; + if (docSel) { + const sels = Array.isArray(docSel) ? docSel : [docSel]; + if (sels.length === 1) { + const s = sels[0]; + const lineRef = s.startLine === s.endLine ? `line ${s.startLine}` : `lines ${s.startLine}-${s.endLine}`; + finalMsg = `In the document, edit this specific text (${lineRef}):\n\`\`\`\n${s.text}\n\`\`\`\n\nInstruction: ${msg}`; + } else { + const parts = sels.map((s, i) => { + const lineRef = s.startLine === s.endLine ? `line ${s.startLine}` : `lines ${s.startLine}-${s.endLine}`; + return `Selection ${i + 1} (${lineRef}):\n\`\`\`\n${s.text}\n\`\`\``; + }); + finalMsg = `In the document, edit these specific sections:\n\n${parts.join('\n\n')}\n\nInstruction: ${msg}`; + } + } + + // Apply inject prefix/suffix + const _inject = presetsModule.getInject ? presetsModule.getInject() : { prefix: '', suffix: '' }; + let _finalMsgWithInject = finalMsg; + if (_inject.prefix) _finalMsgWithInject = _inject.prefix + ' ' + _finalMsgWithInject; + if (_inject.suffix) _finalMsgWithInject = _finalMsgWithInject + ' ' + _inject.suffix; + + const fd = new FormData(); + fd.append('message', _finalMsgWithInject); + fd.append('session', streamSessionId); + if (ids.length) fd.append('attachments', JSON.stringify(ids)); + // Auto-save & send active doc ID so the backend sees latest content + if (documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) { + try { await documentModule.saveDocument({ silent: true }); } catch (_e) { /* best-effort */ } + fd.append('active_doc_id', documentModule.getCurrentDocId()); + } + // Web toggle: pre-search in Chat mode, tool permission in Agent mode + const toggleState = Storage.loadToggleState(); + let isAgentMode = (toggleState.mode || 'chat') === 'agent'; + // Auto-escalate to agent mode when a document is open — the user expects + // the AI to see the document and have tools to edit it + if (!isAgentMode && documentModule && documentModule.isPanelOpen() && documentModule.getCurrentDocId()) { + isAgentMode = true; + } + fd.append('mode', isAgentMode ? 'agent' : 'chat'); + if (el('web-toggle').checked) { + if (isAgentMode) { + fd.append('allow_web_search', 'true'); + } else { + fd.append('use_web', 'true'); + } + } + if (el('research-toggle').checked) { + fd.append('use_research', 'true'); + // Research always runs in chat mode — override agent if set + fd.set('mode', 'chat'); + } + if (el('bash-toggle').checked) { + fd.append('allow_bash', 'true'); + } + const ragChk = el('rag-toggle'); + if (ragChk && !ragChk.checked) { + fd.append('use_rag', 'false'); + } + const incognitoChk = el('incognito-toggle'); + if (incognitoChk && incognitoChk.checked) { + fd.append('incognito', 'true'); + } + if (presetsModule.getSelectedPreset()) { + fd.append('preset_id', presetsModule.getSelectedPreset()); + } + + + const abortCtrl = new AbortController(); + abortCtrl._reason = ''; + currentAbort = abortCtrl; + + const _tState = Storage.loadToggleState(); + const _isAgent = (_tState.mode || 'chat') === 'agent'; + + // Timeout: 6 min for research and agent mode, 3 min otherwise + const timeoutMs = el('research-toggle').checked || _isAgent ? RESEARCH_TIMEOUT_MS : DEFAULT_TIMEOUT_MS; + const timeoutId = setTimeout(() => { + if (!abortCtrl.signal.aborted) { + timedOut = true; + abortCtrl._reason = 'timeout'; + abortCtrl.abort(); + } + }, timeoutMs); + + const box = el('chat-history'); + holder = document.createElement('div'); + holder.className = 'msg msg-ai streaming'; + + // Track holder globally so stop button can access it + currentHolder = holder; + holder._researchQuery = msg; // Store query for notification text + + const modelName = sessionModule.getCurrentModel() || null; + + let loadingText = 'Initializing...'; + + if (el('web-toggle').checked && !_isAgent) { + const _searchLabel = searchModule ? searchModule.getProviderLabel() : 'web'; + loadingText = `Searching via ${_searchLabel}...
+ + Query: "${msg.substring(0, 50)}${msg.length > 50 ? '...' : ''}"
+ Fetching top results...
`; + } else if (el('research-toggle').checked) { + loadingText = 'Deep research mode active...'; + } else { + loadingText = 'Processing request...'; + } + + var roleLabel = _shortModel(modelName); + var _charNameInit = presetsModule.getCharacterName ? presetsModule.getCharacterName() : ''; + if (_charNameInit) roleLabel = _charNameInit; + const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + holder.innerHTML = `
${roleLabel} ${roleTs}
`; + _applyModelColor(holder.querySelector('.role'), modelName); + holder.style.position = 'relative'; + + // Create spinner + spinner = spinnerModule.create('Initializing', 'right', 'wave'); + currentSpinner = spinner; + const bodyDiv = holder.querySelector('.body'); + bodyDiv.appendChild(spinner.createElement()); + spinner.start(); + + // Update spinner message based on mode + if (el('web-toggle').checked && !_isAgent) { + spinner.updateMessage('Searching web with ' + (searchModule ? searchModule.getProviderLabel() : 'SearXNG')); + setTimeout(() => spinner.updateMessage('Processing results'), 1500); + } else if (el('research-toggle').checked) { + spinner.updateMessage('Researching'); + setTimeout(() => spinner.updateMessage('Analyzing sources'), 1500); + } else { + spinner.updateMessage('Processing request'); + const endpointUrlForProbe = sessionModule.getCurrentEndpointUrl ? sessionModule.getCurrentEndpointUrl() : null; + if (endpointUrlForProbe && modelName) { + processingProbeTimer = setTimeout(async () => { + processingProbeTimer = null; + if (accumulated || !spinner || !spinner.element || (currentAbort && currentAbort.signal.aborted)) return; + processingProbeAbort = new AbortController(); + try { + spinner.updateMessage('Checking model endpoint'); + const status = await _probeCurrentEndpointStatus(endpointUrlForProbe, processingProbeAbort.signal); + if (accumulated || !spinner || !spinner.element || (currentAbort && currentAbort.signal.aborted)) return; + if (!status) { + spinner.updateMessage('Still waiting for model'); + } else if (status.alive) { + const latency = status.latency_ms ? ` (${status.latency_ms}ms)` : ''; + spinner.updateMessage(`Endpoint online${latency}; waiting for first token`); + } else { + // Probe confirms the endpoint isn't responding. Don't + // sit on a hung fetch — give the user 5s to read the + // status, then auto-abort with reason='offline' so the + // catch handler shows a clean "switch model" message + // instead of leaving the spinner spinning forever. + if (status.error) console.warn('Model endpoint probe failed:', status.error); + let _countdown = 5; + spinner.updateMessage(`Endpoint offline — cancelling in ${_countdown}s`); + const _tick = setInterval(() => { + _countdown--; + if (!spinner || !spinner.element || (currentAbort && currentAbort.signal.aborted) || accumulated) { + clearInterval(_tick); + return; + } + if (_countdown > 0) { + spinner.updateMessage(`Endpoint offline — cancelling in ${_countdown}s`); + } else { + clearInterval(_tick); + if (currentAbort && !currentAbort.signal.aborted) { + currentAbort._reason = 'offline'; + currentAbort.abort(); + } + } + }, 1000); + } + } catch (e) { + if (e && e.name !== 'AbortError' && spinner && spinner.element && !accumulated) { + spinner.updateMessage('Still waiting for model'); + } + } finally { + processingProbeAbort = null; + } + }, 10000); + } + } + + const researchBtn = el('research-toggle-btn'); + if (el('research-toggle').checked && researchBtn) { + researchBtn.disabled = true; + researchBtn.classList.remove('active'); + } + box.appendChild(holder); + uiModule.scrollHistory(); + + const enableResearchBtn = () => { + if (!researchBtn) return; + researchBtn.disabled = false; + researchBtn.classList.toggle('active', el('research-toggle').checked); + }; + + if (el('research-toggle').checked && researchBtn) { + researchBtn.style.display = 'none'; + // Uncheck research toggle so follow-up messages don't trigger another research + el('research-toggle').checked = false; + } + + // User's current UTC offset in minutes (east of UTC). Threaded into + // the agent so natural-language times like "today at 9pm" are + // interpreted in YOUR timezone, not the server's. + const _tzOffsetMin = -new Date().getTimezoneOffset(); + const res = await fetch(`${API_BASE}/api/chat_stream`, { + method: 'POST', + body: fd, + headers: { 'X-Tz-Offset': String(_tzOffsetMin) }, + signal: abortCtrl.signal + }); + + clearTimeout(timeoutId); + + if (!res.ok) { + if (res.status === 404) { + // Session was deleted (e.g. by AI) — reload and go to welcome + holder.remove(); + if (sessionModule) await sessionModule.loadSessions(); + return; + } + let errText = `Error ${res.status}`; + try { + const errBody = await res.text(); + // Parse nested JSON error if present + const m = errBody.match(/"message"\s*:\s*"([^"]+)"/); + if (m) errText = m[1].replace(/\\"/g, '"'); + else if (errBody.length < 200) errText = errBody; + } catch {} + // Auto-switch to chat mode for tool-related errors + if (errText.includes('tool') || errText.includes('auto')) { + errText = 'This model doesn\'t support agent tools — switched to Chat mode. Try again.'; + const _ab = document.getElementById('mode-agent-btn'); + const _cb = document.getElementById('mode-chat-btn'); + if (_ab && _cb) { + _ab.classList.remove('active'); + _cb.classList.add('active'); + const _toggle = _ab.closest('.mode-toggle'); + if (_toggle) _toggle.classList.add('mode-chat'); + } + if (typeof Storage !== 'undefined' && Storage.KEYS) { + const _st = Storage.getJSON(Storage.KEYS.TOGGLES, {}); + _st.mode = 'chat'; + Storage.setJSON(Storage.KEYS.TOGGLES, _st); + } + } + typewriterInto(holder.querySelector('.body'), errText); + enableResearchBtn(); + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let metrics = null; + let isThinking = false; + let thinkingStartTime = null; + // Streaming TTS: synthesize sentence-by-sentence during streaming + const streamingTTS = !!(window.aiTTSManager && window.aiTTSManager.autoPlay && window.aiTTSManager.available); + if (streamingTTS) window.aiTTSManager.streamingStart(); + // Multi-bubble agent tracking + let roundHolder = holder; // Current AI text bubble (changes per round) + let roundText = ''; // Text accumulated for current round + let currentToolBubble = null; // Current tool execution bubble + let roundFinalized = false; // Whether current round's text is finalized + let _sourcesHtml = ''; // Sources box HTML to prepend to body + let _sourcesExpanded = false; // Track if user expanded sources during stream + let _sourcesData = null; // Raw sources data for rebuilding + let _sourcesType = ''; // 'web' or 'research' + let _findingsData = null; // Raw findings data for collapsible box + // _keepResearchOn removed — clarification state now persisted server-side via DB mode + // Insert sources box as a stable DOM node that won't be replaced during streaming. + // Returns the content container to use for innerHTML updates. + function _ensureStreamLayout(body) { + if (!body) return body; + // Sources are deferred to final render — don't insert during streaming + // Ensure a stable content div exists for text content + var contentDiv = body.querySelector('.stream-content'); + if (!contentDiv) { + contentDiv = document.createElement('div'); + contentDiv.className = 'stream-content'; + body.appendChild(contentDiv); + } + return contentDiv; + } + const esc = uiModule.esc; + // Remove thinking spinner helper + function _removeThinkingSpinner() { + const el = document.querySelector('.agent-thinking-dots'); + if (el) { + if (el._spinner) el._spinner.destroy(); + el.remove(); + } + } + + // Tool-aware thinking spinner + let _lastToolName = ''; + const _searchIcon = ''; + const _toolLabels = { + 'web_search': _searchIcon + 'Searching', + 'bash': 'Running', + 'python': 'Running', + 'create_document': 'Writing', + 'update_document': 'Writing', + 'read_document': 'Reading', + 'edit_file': 'Editing', + 'read_file': 'Reading', + 'write_file': 'Writing', + 'list_files': 'Browsing', + 'image_gen': 'Generating', + 'generate_image': 'Generating', + 'manage_memory': 'Remembering', + 'save_memory': 'Remembering', + 'search_memory': 'Recalling', + 'manage_session': 'Organizing', + 'deep_research': 'Researching', + 'list_models': 'Browsing', + 'ui_control': 'Adjusting', + }; + function _thinkingLabel() { + if (!_lastToolName) { + return 'Thinking'; + } + // Check exact match first, then prefix match + const lower = _lastToolName.toLowerCase(); + if (_toolLabels[lower]) return _toolLabels[lower]; + for (const [key, label] of Object.entries(_toolLabels)) { + if (lower.includes(key) || key.includes(lower)) return label; + } + return 'Thinking'; + } + + function _showThinkingSpinner(label) { + if (document.querySelector('.agent-thinking-dots')) return; + const _thinkMsg = document.createElement('div'); + _thinkMsg.className = 'msg msg-ai agent-thinking-dots'; + const _thinkBody = document.createElement('div'); + _thinkBody.className = 'body'; + const _ts = spinnerModule.create(label || 'Thinking', 'right', 'wave'); + _thinkBody.appendChild(_ts.createElement()); + _ts.start(120); + _thinkMsg._spinner = _ts; + _thinkMsg.appendChild(_thinkBody); + document.getElementById('chat-history').appendChild(_thinkMsg); + uiModule.scrollHistory(); + } + + // Auto-show thinking spinner after text stops streaming + let _textPauseTimer = null; + function _scheduleThinkingSpinner() { + if (_textPauseTimer) clearTimeout(_textPauseTimer); + _textPauseTimer = setTimeout(() => { + if (!document.querySelector('.agent-thinking-dots') && isStreaming) { + _showThinkingSpinner(_thinkingLabel()); + } + }, 400); + } + function _cancelThinkingTimer() { + if (_textPauseTimer) { clearTimeout(_textPauseTimer); _textPauseTimer = null; } + } + + // Document streaming state (text-fence detection) + let _docFenceOpened = false; + let _docFenceContentStart = -1; + let _liveThinkSection = null; + let _liveThinkContent = null; + let _liveThinkInner = null; + let _liveThinkHeader = null; + let _liveThinkSpinnerSlot = null; + let _liveThinkTimerEl = null; + let _liveThinkToggle = null; + let _liveThinkDomId = null; + + // Offscreen measurement div — reused across renders + let _measureDiv = null; + + function _replyAfterClosedThinking(text) { + const closeRe = /<\/think(?:ing)?>/gi; + let match = null; + let last = null; + while ((match = closeRe.exec(text || '')) !== null) last = match; + if (!last) return ''; + return (text || '').slice(last.index + last[0].length).trimStart(); + } + + // Direct render helper for streaming text + function _renderStream() { + let dt = stripToolBlocks(roundText); + const bodyEl = roundHolder.querySelector('.body'); + const contentEl = _ensureStreamLayout(bodyEl); + + // If thinking was already collapsed in-place, only render the reply portion + let liveReply = contentEl.querySelector('.live-reply-content'); + if (liveReply) { + // Extract reply text — handle native tags and non-tag patterns + const closedThinkReply = _replyAfterClosedThinking(dt); + const { thinkingBlocks, content: replyText } = closedThinkReply + ? { thinkingBlocks: [''], content: closedThinkReply } + : markdownModule.extractThinkingBlocks(dt); + let replyTrimmed = ''; + if (thinkingBlocks.length) { + replyTrimmed = (replyText || '').trim(); + } else { + // Non-tag: check for garbled (reasoning\nreply) + const _gm = dt.match(/^[\s\S]+?\s*([\s\S]*?)(?:<\/think(?:ing)?>)?\s*$/i); + if (_gm && _gm[1].trim()) { + replyTrimmed = _gm[1].trim(); + } else { + // Pure non-tag: find reply boundary + const _rPrefixes = markdownModule.startsWithReasoningPrefix; + const _rpStarts = ['Hey', 'Hi ', 'Hi!', 'Hello', 'Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"]; + const _rt = (replyText || '').trimStart(); + if (_rPrefixes(_rt)) { + const _rLines = _rt.split('\n'); + for (let _ri = 1; _ri < _rLines.length; _ri++) { + const _rl = _rLines[_ri].trim(); + if (!_rl) continue; + if (_rpStarts.some(rp => _rl.startsWith(rp))) { replyTrimmed = _rLines.slice(_ri).join('\n'); break; } + } + if (!replyTrimmed) { + for (const rp of _rpStarts) { + const rx = new RegExp('[.!?]\\s*(' + rp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')'); + const m = rx.exec(_rt); + if (m && m.index > 20) { replyTrimmed = _rt.slice(m.index + 1).trim(); break; } + } + } + } + } + } + if (replyTrimmed) { + const replyHtml = markdownModule.mdToHtml(markdownModule.squashOutsideCode(replyTrimmed)); + const prevLen = liveReply._prevTextLen || 0; + liveReply.innerHTML = replyHtml; + _fadeNewTokens(liveReply, prevLen); + liveReply._prevTextLen = liveReply.textContent.length; + if (window.hljs) liveReply.querySelectorAll('pre code').forEach((b) => window.hljs.highlightElement(b)); + } + // Reply empty or not — preserve thinking bar, don't fall through to full re-render + uiModule.scrollHistory(); + return; + } + + const prevLen = contentEl._prevTextLen || 0; + // If thinking is still streaming (unclosed ), show indicator instead of raw text + if (markdownModule.hasUnclosedThinkTag && markdownModule.hasUnclosedThinkTag(dt)) { + const thinkStart = dt.search(//i); + const thinkContent = dt.substring(thinkStart).replace(//i, '').trim(); + const lines = thinkContent.split('\n').length; + // Don't show beforeThink text during streaming — it'll appear in the final render + // This prevents the "split into two" duplication + contentEl.innerHTML = + '
Thinking' + + (lines > 1 ? ` (${lines} lines)` : '') + '
'; + contentEl._prevTextLen = 0; + uiModule.scrollHistory(); + return; + } + const html = markdownModule.processWithThinking(markdownModule.squashOutsideCode(dt)); + + // Smooth expand only for regular chat text (not thinking/agent blocks) + const _hasThinking = html.includes('thinking-section'); + const _isAgentRound = roundHolder !== holder; + if (!_hasThinking && !_isAgentRound) { + // Render into offscreen clone to measure new height before swapping + if (!_measureDiv) { + _measureDiv = document.createElement('div'); + _measureDiv.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;z-index:-1;'; + } + _measureDiv.style.width = contentEl.offsetWidth + 'px'; + _measureDiv.className = contentEl.className; + _measureDiv.innerHTML = html; + contentEl.parentNode.appendChild(_measureDiv); + const measuredH = _measureDiv.offsetHeight; + _measureDiv.remove(); + const curMin = parseFloat(contentEl.style.minHeight) || 0; + contentEl.style.minHeight = Math.max(curMin, measuredH) + 'px'; + } else { + contentEl.style.minHeight = ''; + } + + contentEl.innerHTML = html; + _fadeNewTokens(contentEl, prevLen); + contentEl._prevTextLen = contentEl.textContent.length; + if (window.hljs) contentEl.querySelectorAll('pre code').forEach((b) => window.hljs.highlightElement(b)); + uiModule.scrollHistory(); + } + + // Walk text nodes, skip past `prevLen` characters of old text, + // wrap everything after that in for fade-in + function _fadeNewTokens(container, prevLen) { + if (!prevLen) return; // First chunk — skip, whole msg already has entrance anim + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); + let charCount = 0; + const toWrap = []; + while (walker.nextNode()) { + const node = walker.currentNode; + const len = node.textContent.length; + if (charCount + len <= prevLen) { charCount += len; continue; } + const splitAt = charCount < prevLen ? prevLen - charCount : 0; + toWrap.push({ node, splitAt }); + charCount += len; + } + for (const { node, splitAt } of toWrap) { + const parent = node.parentNode; + if (!parent || parent.closest('pre, .think-content')) continue; + const target = splitAt > 0 ? node.splitText(splitAt) : node; + const span = document.createElement('span'); + span.className = 'token-new'; + parent.replaceChild(span, target); + span.appendChild(target); + } + } + + let _nextIsError = false; + + while (true) { + const { done, value } = await reader.read(); + _lastReaderActivity = Date.now(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + // Log SSE event types (e.g. "event: error") for debugging + if (line.startsWith('event: ')) { + const evtType = line.slice(7).trim(); + if (evtType === 'error') _nextIsError = true; + continue; + } + if (line.startsWith('data: ')) { + const data = line.slice(6); + + // (thinking spinner removal is handled in agent_step / tool_start / content handlers) + + // Background detection: are we on a different session? + const _isBg = (sessionModule.getCurrentSessionId() !== streamSessionId); + + // On first transition to background, store state in map + if (_isBg && !_backgroundStreams.has(streamSessionId)) { + _backgroundStreams.set(streamSessionId, { + status: 'running', + accumulated: accumulated, + sourcesHtml: _sourcesHtml, + findingsData: null, + abortCtrl: currentAbort, + query: streamQuery, + metrics: null, + }); + if (sessionModule && sessionModule.markStreaming) { + sessionModule.markStreaming(streamSessionId); + } + } + + if (data === '[DONE]') { + // Always update background map if entry exists (even if user switched back) + var bgDone = _backgroundStreams.get(streamSessionId); + if (bgDone) { + bgDone.status = 'completed'; + bgDone.accumulated = accumulated; + if (_isBg) { + try { + _notifyStreamComplete(streamSessionId, streamQuery); + _insertStreamDoneToast(streamSessionId, streamQuery); + } catch (toastErr) { + console.warn('[bg-stream] Toast/notification error:', toastErr); + } + } + // CRITICAL: always mark stream complete for the sidebar dot + try { + if (sessionModule && sessionModule.markStreamComplete) { + sessionModule.markStreamComplete(streamSessionId); + } + } catch (dotErr) { + console.warn('[bg-stream] markStreamComplete error:', dotErr); + } + // Don't do foreground final render — the checkBackgroundStream poll + // will detect 'completed' and reload history cleanly + break; + } + // Force-close thinking if still open (model never output boundary) + if (isThinking) { + isThinking = false; + cancelAnimationFrame(_thinkTimerRAF); + var _elapsedDone = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null; + if (_elapsedDone) { + accumulated = accumulated.replace(//i, ''); + roundText = roundText.replace(//i, ''); + } + if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process'; + if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove(); + if (_liveThinkTimerEl && _elapsedDone) { + _liveThinkTimerEl.textContent = _elapsedDone + 's'; + _liveThinkTimerEl.style.marginLeft = 'auto'; + _liveThinkTimerEl.style.marginRight = '5px'; + var _hdrDone = _liveThinkTimerEl.closest('.thinking-header'); + // Keep the chevron furthest right with the timer to its left + // (match the live + final-render layout) — insert before the + // toggle rather than appending (which would land after it). + if (_hdrDone) { + if (_liveThinkToggle && _liveThinkToggle.parentElement === _hdrDone) + _hdrDone.insertBefore(_liveThinkTimerEl, _liveThinkToggle); + else _hdrDone.appendChild(_liveThinkTimerEl); + } + } + // Assign stable IDs + var _thinkIdDone = 'think-' + Date.now(); + var _liveHdrDone = _liveThinkSection && _liveThinkSection.querySelector('.thinking-header'); + if (_liveHdrDone) _liveHdrDone.dataset.thinkingId = _thinkIdDone; + if (_liveThinkContent) _liveThinkContent.id = _thinkIdDone; + if (_liveThinkToggle) _liveThinkToggle.id = _thinkIdDone + '-toggle'; + // Create live-reply container so final render preserves thinking bar + var _streamElDone = _liveThinkSection ? _liveThinkSection.parentElement : roundHolder.querySelector('.stream-content'); + if (!_streamElDone) _streamElDone = roundHolder.querySelector('.body'); + if (_streamElDone && !_streamElDone.querySelector('.live-reply-content')) { + var _replyElDone = document.createElement('div'); + _replyElDone.className = 'live-reply-content'; + _streamElDone.appendChild(_replyElDone); + } + } + // Normal foreground completion — metrics will be displayed in the final render block below + break; + } + try { + const json = JSON.parse(data); + // Handle SSE error events (e.g. HTTP 404 from provider) + if (_nextIsError || json.status >= 400) { + _nextIsError = false; + const errMsg = json.text || json.error?.message || `Error ${json.status || 'unknown'}`; + console.error('Stream error:', errMsg); + if (spinner && spinner.element) spinner.destroy(); + typewriterInto(roundHolder.querySelector('.body'), errMsg); + break; + } + if (json.delta || json.type === 'tool_start' || json.type === 'agent_step' || json.type === 'doc_stream_delta') { + clearProcessingProbe(); + } + if (json.delta) { + _cancelThinkingTimer(); + _removeThinkingSpinner(); + // Text arrived after tools — connect thread line to this bubble + const _threadAbove = roundHolder?.previousElementSibling; + if (_threadAbove && _threadAbove.classList.contains('agent-thread') && !_threadAbove.classList.contains('has-bottom')) { + _threadAbove.classList.add('has-bottom'); + } + // VLLM reasoning tokens: wrap in tags for the thinking UI + let _delta = json.delta; + if (json.thinking) { + if (!accumulated.includes('')) _delta = '' + _delta; + } else if (accumulated.includes('') && !accumulated.includes('')) { + _delta = '' + _delta; + } + const wasEmpty = !accumulated; + accumulated += _delta; + roundText += _delta; + currentAccumulated = accumulated; // Update global tracker + // First token arrived — switch stop button from processing to streaming + if (wasEmpty && submitBtn && !_isBg) { + submitBtn.dataset.phase = 'receiving'; + } + + // Update background map if running in background + if (_isBg) { + var bgEntry = _backgroundStreams.get(streamSessionId); + if (bgEntry) bgEntry.accumulated = accumulated; + continue; // Skip all DOM writes + } + + // --- Text-fence doc streaming (for models that don't use native tool calls) --- + if (!_docFenceOpened && documentModule && roundText.includes('```create_document\n')) { + const fenceIdx = roundText.indexOf('```create_document\n'); + const afterFence = roundText.slice(fenceIdx + '```create_document\n'.length); + const fenceLines = afterFence.split('\n'); + if (fenceLines.length >= 1 && fenceLines[0].trim()) { + _docFenceOpened = true; + const title = fenceLines[0].trim(); + // Keep in sync with backend _KNOWN_LANGS in src/tool_implementations.py + const knownLangs = ['python','py','javascript','js','typescript','ts','html','css','json','yaml','bash','sql','rust','go','java','c','cpp','markdown','text','plain','ruby','swift','kotlin','php','email','csv','xml','toml','ini']; + const isLang = fenceLines.length >= 2 && knownLangs.includes(fenceLines[1].trim().toLowerCase()); + const lang = isLang ? fenceLines[1].trim() : ''; + _docFenceContentStart = fenceIdx + '```create_document\n'.length + title.length + 1 + (isLang ? fenceLines[1].length + 1 : 0); + documentModule.streamDocOpen(title, lang); + } + } + if (_docFenceOpened && _docFenceContentStart > 0 && documentModule) { + let raw = roundText.slice(_docFenceContentStart); + const closeIdx = raw.indexOf('\n```'); + if (closeIdx >= 0) raw = raw.slice(0, closeIdx); + documentModule.streamDocDelta(raw); + } + + // Detect thinking-in-progress: + // 1. Normal: ...no closing tag yet + // 2. Malformed: \n...text but no second yet + // 3. Qwen3.5: "Thinking Process:" without tags + let hasUnclosedThink = markdownModule.hasUnclosedThinkTag(roundText); + // Detect non-tag thinking patterns: "Thinking:", "Thinking Process:", Gemma-style reasoning + // These patterns don't use tags, so we simulate unclosed thinking during streaming + const _replyPrefixes = ['Hey', 'Hi ', 'Hi!', 'Hello', 'Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"]; + if (!hasUnclosedThink && !roundText.includes(' _l.startsWith(rp))) { + _replyFound = true; + break; + } + } + if (!_replyFound) { + // Also check within-line: "reasoning text.Reply text" + const _inlineReply = _replyPrefixes.some(rp => { + const rx = new RegExp('[.!?]\\s*' + rp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + const m = rx.exec(_trimmedRT); + return m && m.index > 20; + }); + if (!_inlineReply) hasUnclosedThink = true; + } + } + } + if (!hasUnclosedThink && /^\s*<\/think(?:ing)?>/i.test(roundText)) { + // Empty — the model likely put thinking outside the tags + const afterEmpty = roundText.replace(/^\s*<\/think(?:ing)?>/i, '').trim(); + const closeTags = (afterEmpty.match(/<\/think(?:ing)?>/gi) || []).length; + if (closeTags === 0 && afterEmpty.length > 0) { + hasUnclosedThink = true; // still waiting for real closing tag + } + } + // Detect false close: short where real thinking follows untagged + // Only applies when there's a second later (model leaked thinking outside tags) + // Do NOT trigger if the text after contains tool calls (that's real content) + if (!hasUnclosedThink && isThinking) { + const _thinkMatch = roundText.match(/([\s\S]*?)<\/think(?:ing)?>/i); + const _thinkLen = _thinkMatch ? _thinkMatch[1].trim().length : 0; + if (_thinkLen < 20) { + const _afterClose = roundText.replace(/([\s\S]*?)<\/think(?:ing)?>/i, '').trim(); + // Only keep waiting if there's trailing text that looks like thinking (not tool calls) + const _hasToolCall = /```(?:bash|python|web_search|read_file|write_file|create_document|edit_document|manage_|generate_image)/i.test(_afterClose); + const _hasOrphanClose = /<\/think(?:ing)?>/i.test(_afterClose); + if (!_hasToolCall && (_hasOrphanClose || (Date.now() - thinkingStartTime) < 500)) { + hasUnclosedThink = true; // keep waiting for real + } + } + } + + if (hasUnclosedThink && !isThinking) { + isThinking = true; + thinkingStartTime = Date.now(); + if (spinner && spinner.element) spinner.destroy(); + + // Create a live thinking box — starts expanded so content streams visibly + var thinkBody = roundHolder.querySelector('.body'); + var thinkContent = _ensureStreamLayout(thinkBody); + thinkContent.style.minHeight = ''; + _liveThinkDomId = 'live-think-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8); + thinkContent.innerHTML = ` +
+
+
Thinking\u2026
+ + + +
+
+
+
+
`; + _liveThinkSection = thinkContent.querySelector('.thinking-section'); + _liveThinkContent = thinkContent.querySelector('.thinking-content'); + _liveThinkInner = thinkContent.querySelector('.live-think-inner'); + _liveThinkHeader = thinkContent.querySelector('.live-think-header-text'); + _liveThinkSpinnerSlot = thinkContent.querySelector('.live-think-spinner-slot'); + _liveThinkTimerEl = thinkContent.querySelector('.live-think-timer'); + _liveThinkToggle = thinkContent.querySelector('.live-think-toggle'); + // Live timer + var _thinkTimerStart = Date.now(); + var _thinkTimerRAF = 0; + function _tickThinkTimer() { + if (!_liveThinkTimerEl || !_liveThinkTimerEl.isConnected) return; + var s = ((Date.now() - _thinkTimerStart) / 1000).toFixed(1); + _liveThinkTimerEl.textContent = s + 's'; + _thinkTimerRAF = requestAnimationFrame(_tickThinkTimer); + } + _thinkTimerRAF = requestAnimationFrame(_tickThinkTimer); + // Whirlpool spinner + if (_liveThinkSpinnerSlot) { + var _wp = spinnerModule.createWhirlpool(12); + _wp.element.style.margin = '0'; + _wp.element.style.width = '12px'; + _wp.element.style.height = '12px'; + _wp.element.style.transform = 'translateY(-1px)'; // align the whirlpool with the header text + _liveThinkSpinnerSlot.appendChild(_wp.element); + } + } else if (hasUnclosedThink && isThinking) { + if (_liveThinkInner) { + // Extract raw thinking text (strip all / open/close tags and prefixes) + var thinkText = roundText.replace(/<\/?think(?:ing)?>/gi, ''); + thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, ''); + _liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText); + // Keep thinking box scrolled to bottom + var thinkBox = _liveThinkInner.closest('.thinking-content'); + if (thinkBox) thinkBox.scrollTop = thinkBox.scrollHeight; + } + uiModule.scrollHistory(); + continue; + } else if (!hasUnclosedThink && isThinking) { + isThinking = false; + var _thinkTextLen = _liveThinkInner ? _liveThinkInner.textContent.trim().length : 0; + + // If thinking was trivially short (< 20 chars), remove the section entirely + // Models sometimes emit The or similar noise + if (_thinkTextLen < 20 && _liveThinkSection) { + _liveThinkSection.remove(); + _liveThinkSection = null; + _liveThinkContent = null; + _liveThinkInner = null; + _liveThinkHeader = null; + _liveThinkSpinnerSlot = null; + _liveThinkTimerEl = null; + _liveThinkToggle = null; + _liveThinkDomId = null; + // Fall through to normal streaming + if (spinner && spinner.element) spinner.destroy(); + _renderStream(); + _scheduleThinkingSpinner(); + continue; + } + + // Thinking ended — smooth transition: update header, pause, then collapse + // Stop live timer and spinner + cancelAnimationFrame(_thinkTimerRAF); + var elapsed = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null; + // Embed thinking time in the tag for persistence on reload + if (elapsed) { + accumulated = accumulated.replace(//i, ''); + roundText = roundText.replace(//i, ''); + } + if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process'; + if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove(); + // Move timer to right side of header + if (_liveThinkTimerEl && elapsed) { + _liveThinkTimerEl.textContent = elapsed + 's'; + _liveThinkTimerEl.style.marginLeft = 'auto'; + _liveThinkTimerEl.style.marginRight = '5px'; + var _hdrRow = _liveThinkTimerEl.closest('.thinking-header'); + // Chevron furthest right, timer to its left — insert before + // the toggle (appending would put the timer after it). + if (_hdrRow) { + if (_liveThinkToggle && _liveThinkToggle.parentElement === _hdrRow) + _hdrRow.insertBefore(_liveThinkTimerEl, _liveThinkToggle); + else _hdrRow.appendChild(_liveThinkTimerEl); + } + } + + // Assign stable IDs (for click-toggle handler in markdown.js) + var _thinkId = 'think-' + Date.now(); + var _liveHdr = _liveThinkSection && _liveThinkSection.querySelector('.thinking-header'); + if (_liveHdr) _liveHdr.dataset.thinkingId = _thinkId; + if (_liveThinkContent) _liveThinkContent.id = _thinkId; + if (_liveThinkToggle) _liveThinkToggle.id = _thinkId + '-toggle'; + + // Append a container for the reply text that follows thinking + var _streamEl = _liveThinkSection ? _liveThinkSection.parentElement : roundHolder.querySelector('.stream-content'); + if (!_streamEl) _streamEl = roundHolder.querySelector('.body'); + if (_streamEl) { + var _replyEl = document.createElement('div'); + _replyEl.className = 'live-reply-content'; + _streamEl.appendChild(_replyEl); + } + + // Render any reply text that arrived with the closing token + _renderStream(); + } else { + // Normal streaming + if (spinner && spinner.element) spinner.destroy(); + _renderStream(); + _scheduleThinkingSpinner(); + // Feed streaming TTS with accumulated text + if (streamingTTS) window.aiTTSManager.streamingUpdate(roundText); + } + } else if (json.type === 'research_progress') { + if (_isBg) continue; // Skip DOM updates in background + _researchingStreamIds.add(streamSessionId); + // Highlight research button while running + var _rToggle = document.getElementById('research-toggle-btn'); + if (_rToggle) _rToggle.classList.add('research-running'); + // Request notification permission on first research event + if ('Notification' in window && Notification.permission === 'default') { + Notification.requestPermission(); + } + // Mark session as researching in sidebar + var _rSid = sessionModule && sessionModule.getCurrentSessionId(); + if (_rSid && sessionModule.markResearching) sessionModule.markResearching(_rSid); + const rp = json.data; + // Start research timer + synapse on first progress event + if (!_researchTimerEl && spinner && spinner.element) { + _researchStartTime = rp.started_at ? rp.started_at * 1000 : Date.now(); + _researchAvgDuration = rp.avg_duration || null; + _researchTimerEl = document.createElement('div'); + _researchTimerEl.className = 'research-timer'; + // Styles in .research-timer CSS class + spinner.element.parentNode.insertBefore(_researchTimerEl, spinner.element.nextSibling); + _researchTimerInterval = setInterval(() => { + if (!_researchTimerEl) return; + var elapsed = Math.floor((Date.now() - _researchStartTime) / 1000); + var mm = String(Math.floor(elapsed / 60)).padStart(2, '0'); + var ss = String(elapsed % 60).padStart(2, '0'); + var txt = mm + ':' + ss; + if (_researchAvgDuration) { + var avgM = String(Math.floor(_researchAvgDuration / 60)).padStart(2, '0'); + var avgS = String(Math.round(_researchAvgDuration % 60)).padStart(2, '0'); + txt += ' / avg ' + avgM + ':' + avgS; + } + _researchTimerEl.textContent = txt; + }, 1000); + // Synapse visualization — insert right above the timer so + // it sits between the spinner message and the timer line. + try { + _researchSynapse = createResearchSynapse(spinner.element.parentNode, { + query: holder._researchQuery || rp.query || '', + startedAt: _researchStartTime, + }); + // Move it to live between spinner and timer + if (_researchSynapse.element && _researchTimerEl) { + spinner.element.parentNode.insertBefore(_researchSynapse.element, _researchTimerEl); + } + } catch (e) { console.warn('synapse init failed', e); } + } + if (_researchSynapse) { + _researchSynapse.setPhase(rp.phase, rp); + if (typeof rp.round === 'number') _researchSynapse.setRound(rp.round); + if (typeof rp.total_sources === 'number') _researchSynapse.setSourceCount(rp.total_sources); + if (rp.phase === 'error') _researchSynapse.complete(); + } + if (spinner && spinner.element) { + if (rp.phase === 'probing') { + spinner.updateMessage(`Verifying model: ${rp.model || '?'}`); + } else if (rp.phase === 'planning') { + spinner.updateMessage('Analyzing question & planning research strategy'); + } else if (rp.phase === 'searching') { + const q = rp.queries ? `${rp.queries} queries` : ''; + const s = rp.total_sources ? ` · ${rp.total_sources} sources` : ''; + spinner.updateMessage(`Round ${rp.round || '?'}: Searching${q ? ' (' + q + ')' : ''}${s}`); + } else if (rp.phase === 'reading') { + spinner.updateMessage(rp.title ? `Reading: ${rp.title}` : `Round ${rp.round || '?'}: Reading ${rp.new_sources || ''} pages · ${rp.total_sources || 0} sources total`); + } else if (rp.phase === 'analyzing') { + spinner.updateMessage(`Round ${rp.round || '?'}: Analyzing ${rp.total_findings || 0} findings`); + } else if (rp.phase === 'writing') { + spinner.updateMessage(`Writing report · ${rp.total_sources || 0} sources`); + } else if (rp.phase === 'error') { + spinner.updateMessage(rp.message || 'Search error'); + } + } + } else if (json.type === 'research_sources') { + if (_isBg) { + // Store sources HTML in background map + if (json.data && json.data.length > 0) { + _sourcesHtml = _buildSourcesBox(json.data, 'research'); + var bgE = _backgroundStreams.get(streamSessionId); + if (bgE) bgE.sourcesHtml = _sourcesHtml; + } + // Clear researching indicator for this background session + if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(streamSessionId); + continue; + } + // Research done — clean up timer, show sources box, then spinner for LLM response + _clearResearchTimer(); + holder._researchSources = json.data; + var _rSid2 = sessionModule && sessionModule.getCurrentSessionId(); + if (_rSid2 && sessionModule.clearResearching) sessionModule.clearResearching(_rSid2); + if (json.data && json.data.length > 0) { + _sourcesData = json.data; _sourcesType = 'research'; + _sourcesHtml = _buildSourcesBox(json.data, 'research'); + } + if (document.hidden) { + _notifyResearchComplete(_rSid2 || '', holder._researchQuery || ''); + } + } else if (json.type === 'research_findings') { + if (_isBg) { + var bgEf = _backgroundStreams.get(streamSessionId); + if (bgEf) bgEf.findingsData = json.data; + continue; + } + if (json.data && json.data.length > 0) { + _findingsData = json.data; + } + } else if (json.type === 'research_done') { + // Research complete — reload session to show the persisted report + _clearResearchTimer(); + if (sessionModule && sessionModule.clearResearching) { + sessionModule.clearResearching(streamSessionId); + } + _researchingStreamIds.delete(streamSessionId); + // Small delay then reload session history which includes the full report + setTimeout(async () => { + // Don't yank the user back to this chat if they've navigated + // away (e.g. started a new chat) while research finished — + // just refresh the sidebar so the report shows when they return. + if (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId() === streamSessionId) { + await sessionModule.selectSession(streamSessionId); + } else { + await sessionModule.loadSessions(); + } + }, 500); + continue; + } else if (json.type === 'web_sources') { + if (_isBg) { + if (json.data && json.data.length > 0) { + _sourcesHtml = _buildSourcesBox(json.data, 'web'); + var bgE2 = _backgroundStreams.get(streamSessionId); + if (bgE2) bgE2.sourcesHtml = _sourcesHtml; + } + continue; + } + // Web search done — store sources for final render (don't render mid-stream) + holder._webSources = json.data; + if (json.data && json.data.length > 0) { + _sourcesData = json.data; _sourcesType = 'web'; + _sourcesHtml = _buildSourcesBox(json.data, 'web'); + } + } else if (json.type === 'model_fallback') { + // Model went offline — switched to fallback + var _fbData = json.data || {}; + uiModule.showToast( + `Model ${_fbData.old_model || '?'} offline — switched to ${_fbData.new_model || '?'}`, + 5000 + ); + // Update the model picker to reflect the new model + if (sessionModule && sessionModule.updateModelPicker) { + sessionModule.updateModelPicker(); + } + continue; + } else if (json.type === 'model_info') { + // Update role label with model name as soon as we know it + if (!_isBg && holder) { + const roleEl = holder.querySelector('.role'); + if (roleEl) { + const tsSpan = roleEl.querySelector('.role-timestamp'); + var _modelLabel = _shortModel(json.model); + if (json.suffix) { + _modelLabel += ' (' + json.suffix + ')'; + holder._roleSuffix = json.suffix; + } + // Prepend character name if sent by server or set locally + var _charName = json.character_name || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : ''); + if (_charName) { + _modelLabel = _charName; + holder._characterName = _charName; + } + roleEl.textContent = _modelLabel + ' '; + _applyModelColor(roleEl, json.model); + if (tsSpan) roleEl.appendChild(tsSpan); + } + } + } else if (json.type === 'attachments') { + if (_isBg) continue; + // Update user bubble — replace file chips with image previews + const _ub = document.querySelector('#chat-history .msg-user:last-of-type'); + if (_ub) { + const _aw = _ub.querySelector('.attach-cards'); + if (_aw) { + for (const _att of json.data) { + const _isImg = (_att.mime || '').startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(_att.name || ''); + if (_isImg && _att.id) { + // Skip if we already have a preview for this file id — + // on a regenerate the original user bubble keeps its + // photo and the backend re-emits the attachment event + // for the same id; without this guard we'd append a + // duplicate (which visually pushes the real photo off). + const _existingPreview = _aw.querySelector('[data-file-id="' + _att.id + '"]'); + if (_existingPreview) { + if (_att.vision_model && !_existingPreview.querySelector('.attach-vision-model')) { + const _vl = document.createElement('div'); + _vl.className = 'attach-vision-model'; + _vl.textContent = 'Vision: ' + String(_att.vision_model).split('/').pop(); + const _name = _existingPreview.querySelector('.attach-image-name'); + if (_name) _existingPreview.insertBefore(_vl, _name); + else _existingPreview.appendChild(_vl); + } + continue; + } + const _card = _aw.querySelector('.attach-card[data-name="' + (_att.name || '').replace(/"/g, '\\"') + '"]'); + const _iw = document.createElement('div'); + _iw.className = 'attach-image-preview'; + _iw.dataset.fileId = _att.id; + _iw.style.cursor = 'pointer'; + _iw.onclick = () => window.open(API_BASE + '/api/upload/' + _att.id, '_blank'); + const _im = document.createElement('img'); + _im.src = API_BASE + '/api/upload/' + _att.id; + _im.alt = _att.name || 'Image'; + _im.style.cssText = 'max-width:300px;max-height:200px;border-radius:6px;display:block;'; + _iw.appendChild(_im); + if (_att.vision_model) { + const _vl = document.createElement('div'); + _vl.className = 'attach-vision-model'; + _vl.textContent = 'Vision: ' + String(_att.vision_model).split('/').pop(); + _iw.appendChild(_vl); + } + if (_att.name) { + const _nm = document.createElement('div'); + _nm.className = 'attach-image-name'; + _nm.textContent = _att.name; + _iw.appendChild(_nm); + } + if (_card) _card.replaceWith(_iw); else _aw.appendChild(_iw); + } else { + const _card = _aw.querySelector('.attach-card[data-name="' + (_att.name || '').replace(/"/g, '\\"') + '"]'); + if (_card && _att.id) { + _card.dataset.fileId = _att.id; + _card.style.cursor = 'pointer'; + _card.onclick = () => window.open(API_BASE + '/api/upload/' + _att.id, '_blank'); + } + } + } + } + // Caption / OCR text is no longer rendered as an inline + // collapsible on the user bubble — the user can view/edit + // it via the "Caption" button on the photo thumbnail. + } + } else if (json.type === 'rag_sources') { + if (_isBg) continue; + holder._ragSources = json.data; + } else if (json.type === 'memories_used') { + if (_isBg) continue; + holder._memoriesUsed = json.data; + } else if (json.type === 'compacted') { + if (!_isBg) { + uiModule.showToast('Context compacted — older messages summarized'); + } + } else if (json.type === 'metrics') { + metrics = json.data; + if (_isBg) { + var bgM = _backgroundStreams.get(streamSessionId); + if (bgM) bgM.metrics = json.data; + continue; + } + + } else if (json.type === 'message_saved') { + // Wire the persisted DB id onto the just-streamed bubble so it + // can be edited/deleted immediately, without reloading the chat. + if (_isBg) continue; + if (currentHolder && json.id) currentHolder.dataset.dbId = json.id; + + } else if (json.type === 'tool_start') { + if (_isBg) continue; + _cancelThinkingTimer(); + _removeThinkingSpinner(); + // Force-close thinking if still open — tools are real content, not thinking + if (isThinking) { + isThinking = false; + cancelAnimationFrame(_thinkTimerRAF); + var _elapsed2 = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null; + if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process'; + if (_liveThinkTimerEl) _liveThinkTimerEl.textContent = _elapsed2 ? _elapsed2 + 's' : ''; + if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove(); + // Assign stable IDs + var _thinkId2 = 'think-' + Date.now(); + var _liveHdr2 = _liveThinkSection && _liveThinkSection.querySelector('.thinking-header'); + if (_liveHdr2) _liveHdr2.dataset.thinkingId = _thinkId2; + if (_liveThinkContent) _liveThinkContent.id = _thinkId2; + if (_liveThinkToggle) _liveThinkToggle.id = _thinkId2 + '-toggle'; + } + _renderStream(); + // --- Finalize current text bubble (only once per round) --- + if (!roundFinalized) { + roundFinalized = true; + if (spinner && spinner.element) spinner.destroy(); + const dt = stripToolBlocks(roundText); + if (dt.trim()) { + var _body3 = roundHolder.querySelector('.body'); + var _contentEl3 = _ensureStreamLayout(_body3); + _contentEl3.style.minHeight = ''; // clear streaming inflate + _contentEl3.innerHTML = markdownModule.processWithThinking(markdownModule.squashOutsideCode(dt)); + if (window.hljs) roundHolder.querySelectorAll('pre code').forEach((b) => window.hljs.highlightElement(b)); + } else { + roundHolder.style.display = 'none'; + } + } + + // Track tool name for contextual spinner labels + _lastToolName = json.tool || ''; + + // --- Thread timeline: group tools in a thread container --- + const cmd = json.command || ''; + const chatBox = document.getElementById('chat-history'); + // Find existing thread to append to — check last few children + // (agent_step may insert an empty msg-ai between tool rounds) + let threadWrap = null; + for (let ci = chatBox.children.length - 1; ci >= Math.max(0, chatBox.children.length - 5); ci--) { + const child = chatBox.children[ci]; + if (child.classList.contains('agent-thread')) { + threadWrap = child; + break; + } + // Skip hidden (empty) bubbles and thinking spinners + if (child.style.display === 'none' || child.classList.contains('agent-thinking-dots')) continue; + // Stop if we hit a visible message bubble (has real content between tools) + if (child.classList.contains('msg')) break; + } + if (threadWrap) { + // Continuing an existing thread — remove has-bottom (agent_step may have set it + // expecting text, but we got more tools instead) + threadWrap.classList.remove('has-bottom'); + } else { + threadWrap = document.createElement('div'); + threadWrap.className = 'agent-thread'; + // Extend line up to connect to chat bubble above (if there is one) + const _prevSib = chatBox.lastElementChild; + const _hasBubbleAbove = _prevSib && (_prevSib.classList.contains('msg') && _prevSib.style.display !== 'none'); + const _hasThreadAbove = _prevSib && _prevSib.classList.contains('agent-thread'); + if (_hasBubbleAbove || _hasThreadAbove || (roundText.trim() && roundHolder && roundHolder.style.display !== 'none')) { + threadWrap.classList.add('has-top'); + } + chatBox.appendChild(threadWrap); + } + threadWrap.classList.add('streaming'); + const toolLabel = _toolLabels[json.tool.toLowerCase()] || json.tool; + const node = document.createElement('div') + node.className = 'agent-thread-node running'; + const cmdHtml = cmd ? `
${esc(cmd)}
` : ''; + node.innerHTML = `
\u25B6${toolLabel}▁▂▃
${cmdHtml}
`; + // Expand/collapse via delegated click handler (init at module bottom). + threadWrap.appendChild(node); + currentToolBubble = node; + // Animate the wave + const waveEl = node.querySelector('.agent-thread-wave'); + if (waveEl) { + const waveFrames = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂']; + let waveIdx = 0; + node._waveInterval = setInterval(() => { + waveIdx = (waveIdx + 1) % waveFrames.length; + waveEl.textContent = waveFrames[waveIdx]; + }, 100); + } + // Smooth per-second "cooking" timer — ticks every second (not + // just on the 2s backend heartbeat) so a long-running tool + // always shows visible motion and never reads as frozen. + node._startTime = Date.now(); + node._elapsedTicker = setInterval(() => { + const hdr2 = node.querySelector('.agent-thread-header'); + if (!hdr2) return; + let el2 = hdr2.querySelector('.agent-thread-elapsed'); + if (!el2) { + el2 = document.createElement('span'); + el2.className = 'agent-thread-elapsed'; + // Sits on the LEFT, right after the icon. + const icon = hdr2.querySelector('.agent-thread-icon'); + if (icon && icon.nextSibling) hdr2.insertBefore(el2, icon.nextSibling); + else hdr2.appendChild(el2); + } + const s = (Date.now() - node._startTime) / 1000; + // Hundredths so it visibly counts sub-second (1.00, 1.05, …). + el2.textContent = s < 60 ? `${s.toFixed(2)}s` : `${Math.floor(s / 60)}m ${(s % 60).toFixed(2).padStart(5, '0')}s`; + }, 50); + uiModule.scrollHistory(); + + } else if (json.type === 'tool_progress') { + // Long-running subprocess (bash, python) is still in + // flight — refresh the running tool card with the + // elapsed-time + tail of its stdout/stderr so the + // user doesn't stare at a blind "Running…" spinner. + if (_isBg) continue; + if (!currentToolBubble) continue; + // The per-second ticker (started in tool_start) owns the + // elapsed display; here we just surface the live output tail. + const tailStr = (json.tail || '').trim(); + if (tailStr) { + let tailEl = currentToolBubble.querySelector('.agent-thread-tail'); + if (!tailEl) { + tailEl = document.createElement('pre'); + tailEl.className = 'agent-thread-tail'; + tailEl.style.cssText = 'margin:4px 0 0;padding:6px 8px;font-size:11px;background:rgba(0,0,0,0.18);border-radius:4px;max-height:140px;overflow:auto;white-space:pre-wrap;opacity:0.85;'; + const content = currentToolBubble.querySelector('.agent-thread-content'); + if (content) content.appendChild(tailEl); + } + tailEl.textContent = tailStr; + tailEl.scrollTop = tailEl.scrollHeight; + } + uiModule.scrollHistory(); + + } else if (json.type === 'tool_output') { + if (_isBg) continue; + // --- Update the current thread node --- + if (currentToolBubble) { + // Stop wave animation + the per-second cooking ticker + if (currentToolBubble._waveInterval) { + clearInterval(currentToolBubble._waveInterval); + currentToolBubble._waveInterval = null; + } + if (currentToolBubble._elapsedTicker) { + clearInterval(currentToolBubble._elapsedTicker); + currentToolBubble._elapsedTicker = null; + } + const ok = (json.exit_code === 0 || json.exit_code == null); + const cmd = json.command || ''; + let outHtml = ''; + if (json.output && json.output.trim()) { + outHtml = `
Output
${esc(json.output)}
`; + } + const cmdHtml2 = cmd ? `
${esc(cmd)}
` : ''; + // Preserve the user's .open choice across the innerHTML + // rewrite \u2014 otherwise expanding a running tool collapses + // it as soon as the result lands, forcing the user to + // click again. Click handling is delegated (see init at + // bottom of file) so no per-node listener needed. + const _wasOpen = currentToolBubble.classList.contains('open'); + currentToolBubble.className = 'agent-thread-node' + (ok ? '' : ' error') + (_wasOpen ? ' open' : ''); + currentToolBubble.innerHTML = `
${ok ? '\u2713' : '\u2717'}${esc(json.tool)}${ok ? 'done' : 'failed'}\u25B6
${cmdHtml2}${outHtml}
`; + // Reset so thinking spinner between tools says "Thinking" not the old tool's label + _lastToolName = ''; + uiModule.scrollHistory(); + } + // --- Render generated images inline --- + if (json.image_url) { + const chatBox = document.getElementById('chat-history'); + chatBox.appendChild(_buildImageBubble(json.image_url, json.image_prompt, json.image_model, json.image_size, json.image_quality, json.image_id)); + uiModule.scrollHistory(); + // Notify gallery to refresh if open + window.dispatchEvent(new CustomEvent('gallery-refresh')); + } + // --- Render browser screenshots in tool output --- + if (json.screenshot && currentToolBubble) { + const contentEl = currentToolBubble.querySelector('.agent-thread-content'); + if (contentEl) { + const details = document.createElement('details'); + details.className = 'agent-tool-output'; + details.innerHTML = `Screenshot`; + contentEl.appendChild(details); + } + } + // --- Reload sessions after manage_session tool (delete, rename, etc.) --- + // Debounce so bulk deletes don't fire loadSessions per call + if (json.tool === 'manage_session' && sessionModule) { + if (window._manageSessionTimer) clearTimeout(window._manageSessionTimer); + window._manageSessionTimer = setTimeout(() => sessionModule.loadSessions(), 1000); + } + // --- Live-refresh the calendar after manage_calendar (add/edit/delete) --- + // so a new event shows without the user hard-refreshing. Debounced + // so a batch of event creates only triggers one refetch. + if (json.tool === 'manage_calendar') { + if (window._manageCalTimer) clearTimeout(window._manageCalTimer); + window._manageCalTimer = setTimeout( + () => window.dispatchEvent(new CustomEvent('calendar-refresh')), 600); + } + // --- Live-refresh Memories after manage_memory changes --- + if (json.tool === 'manage_memory') { + if (window._manageMemoryTimer) clearTimeout(window._manageMemoryTimer); + window._manageMemoryTimer = setTimeout( + () => window.dispatchEvent(new CustomEvent('memory-refresh')), 600); + } + // --- Apply UI control actions embedded in tool_output --- + if (json.ui_event) { + chatStream.handleUIControl(json); + } + + // Schedule a thinking spinner between tool rounds (short delay so + // agent_step in the same SSE chunk can cancel it before it shows) + _scheduleThinkingSpinner(); + uiModule.scrollHistory(); + + } else if (json.type === 'doc_stream_open') { + if (_isBg) { + // Store for replay when user returns to this session + var bgDocOpen = _backgroundStreams.get(streamSessionId); + if (bgDocOpen) { + bgDocOpen._docTitle = json.title || ''; + bgDocOpen._docLang = json.language || ''; + bgDocOpen._docContent = ''; + } + continue; + } + if (documentModule) { + documentModule.streamDocOpen(json.title || '', json.language || ''); + } + + } else if (json.type === 'doc_stream_delta') { + if (_isBg) { + var bgDocDelta = _backgroundStreams.get(streamSessionId); + if (bgDocDelta) bgDocDelta._docContent = json.content || ''; + continue; + } + if (documentModule) { + documentModule.streamDocDelta(json.content || ''); + } + + } else if (json.type === 'doc_update') { + // doc_update means the server already saved the doc to DB. + if (_isBg) continue; + if (documentModule) { + documentModule.handleDocUpdate(json); + } + + } else if (json.type === 'doc_suggestions') { + if (_isBg) continue; + if (documentModule && documentModule.handleDocSuggestions) { + documentModule.handleDocSuggestions(json); + } + + } else if (json.type === 'ui_control') { + if (_isBg) continue; + chatStream.handleUIControl(json.data || {}); + + } else if (json.type === 'agent_step') { + if (_isBg) continue; + _cancelThinkingTimer(); + _removeThinkingSpinner(); + _renderStream(); + // Mark thread as connected to bubble below + const _activeThread = document.querySelector('.agent-thread.streaming'); + if (_activeThread) { + _activeThread.classList.add('has-bottom'); + } + // --- New round: create fresh AI bubble with spinner --- + currentToolBubble = null; + roundFinalized = false; + isThinking = false; + _docFenceOpened = false; + _docFenceContentStart = -1; + const box = document.getElementById('chat-history'); + const newWrap = document.createElement('div'); + newWrap.className = 'msg msg-ai msg-continuation streaming'; + // Add model name label + const newRole = document.createElement('div'); + newRole.className = 'role'; + const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId); + newRole.textContent = _shortModel(metaS?.model) || ''; + _applyModelColor(newRole, metaS?.model); + newWrap.appendChild(newRole); + const newBody = document.createElement('div'); + newBody.className = 'body'; + newWrap.appendChild(newBody); + box.appendChild(newWrap); + roundHolder = newWrap; + roundText = ''; + // Destroy any previous spinner before creating new one + if (spinner && spinner.element) spinner.destroy(); + // Show spinner while waiting for text (skip for research — has its own progress) + if (!_researchingStreamIds.has(streamSessionId)) { + spinner = spinnerModule.create('Generating response', 'right', 'wave'); + newBody.appendChild(spinner.createElement()); + spinner.start(); + } + if (streamingTTS) window.aiTTSManager._streamSentencesSent = 0; + uiModule.scrollHistory(); + } else if (json.type === 'budget_exceeded') { + if (_isBg) continue; + _cancelThinkingTimer(); + _removeThinkingSpinner(); + const budgetDiv = document.createElement('div'); + budgetDiv.style.cssText = 'font-size:11px;opacity:0.6;font-style:italic;padding:4px 8px;margin:4px 0;'; + budgetDiv.textContent = `Tool budget reached (${json.used}/${json.limit} calls). Agent stopped.`; + const chatBox = document.getElementById('chat-history'); + chatBox.appendChild(budgetDiv); + + } else if (json.type === 'teacher_takeover') { + if (_isBg) continue; + _cancelThinkingTimer(); + _removeThinkingSpinner(); + // Finalize any in-flight bubble so the takeover banner + // separates student attempt from teacher attempt. + if (spinner && spinner.element) { try { spinner.destroy(); } catch(_){} spinner = null; } + const chatBox = document.getElementById('chat-history'); + const banner = document.createElement('div'); + banner.className = 'teacher-takeover-banner'; + banner.style.cssText = 'margin:10px 0;padding:8px 12px;border-left:3px solid #c08a3e;background:rgba(192,138,62,0.08);font-size:12px;color:var(--fg);border-radius:4px;'; + const teacherName = json.teacher_model || 'teacher'; + const why = json.student_failure ? ` — ${esc(json.student_failure)}` : ''; + banner.innerHTML = `Teacher takeover: escalating to ${esc(teacherName)}${why}`; + chatBox.appendChild(banner); + // Reset round bubble state so the teacher's first text starts a new bubble + roundHolder = null; + roundText = ''; + roundFinalized = false; + currentToolBubble = null; + uiModule.scrollHistory(); + + } else if (json.type === 'skill_saved') { + if (_isBg) continue; + const chatBox = document.getElementById('chat-history'); + const note = document.createElement('div'); + note.className = 'skill-saved-note'; + note.style.cssText = 'margin:6px 0;padding:6px 10px;border-left:3px solid #4a8a4a;background:rgba(74,138,74,0.07);font-size:12px;color:var(--fg);border-radius:4px;'; + note.innerHTML = `Skill learned: ${esc(json.name || '')}${json.category ? ` [${esc(json.category)}]` : ''}`; + chatBox.appendChild(note); + uiModule.scrollHistory(); + + } else if (json.type === 'escalation_failed' || json.type === 'skill_save_failed') { + if (_isBg) continue; + const chatBox = document.getElementById('chat-history'); + const note = document.createElement('div'); + note.className = 'escalation-failed-note'; + note.style.cssText = 'margin:6px 0;padding:6px 10px;border-left:3px solid #8a4a4a;background:rgba(138,74,74,0.07);font-size:12px;color:var(--fg);border-radius:4px;'; + const label = json.type === 'escalation_failed' ? 'Teacher could not solve it' : 'Skill not saved'; + note.innerHTML = `${label}: ${esc(json.reason || '')}`; + chatBox.appendChild(note); + uiModule.scrollHistory(); + + } else if (json.error) { + // --- Backend error (timeout, connection issue, etc.) --- + console.error('Stream error from backend:', json.error); + if (_isBg) continue; + if (spinner && spinner.element) spinner.destroy(); + const errDiv = document.createElement('div'); + errDiv.style.cssText = 'color: var(--color-error); font-style: italic; padding: 4px 0;'; + errDiv.textContent = `[Error: ${json.error}]`; + roundHolder.querySelector('.body').appendChild(errDiv); + uiModule.scrollHistory(); + } + } catch (e) { + console.error('Error parsing SSE data:', e); + } + } + } + } + + _renderStream(); + _cancelThinkingTimer(); + _removeThinkingSpinner(); + // Stop any thread pulse animations + document.querySelectorAll('.agent-thread.streaming').forEach(t => t.classList.remove('streaming')); + // --- Final render (skip if stream was ever backgrounded or currently in background) --- + // Remove streaming class from all round bubbles + holder.classList.remove('streaming'); + if (roundHolder && roundHolder !== holder) roundHolder.classList.remove('streaming'); + + const _isBgFinal = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId); + if (!_isBgFinal) { + finalMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId()); + finalModelName = _shortModel(metrics?.model || finalMeta?.model); + // Preserve suffix (e.g. "Research") if set by model_info event + if (holder._roleSuffix) finalModelName += ' (' + holder._roleSuffix + ')'; + // Prepend character name if set + var _charNameFinal = presetsModule.getCharacterName ? presetsModule.getCharacterName() : ''; + if (_charNameFinal) finalModelName = _charNameFinal; + const roleEl = holder.querySelector('.role'); + if (roleEl) { + const tsSpan = roleEl.querySelector('.role-timestamp'); + roleEl.textContent = finalModelName + ' '; + _applyModelColor(roleEl, metrics?.model || finalMeta?.model); + if (tsSpan) roleEl.appendChild(tsSpan); + } + holder.dataset.raw = accumulated; + + // Anti-stall: a turn that ran tools but ended with essentially no + // final prose usually means the model stopped mid-task (the case + // where you had to type "did you finish?"). Offer a one-click + // Continue that resumes exactly where it left off — reuses the same + // resume mechanism as the user-stop "[Message interrupted]" button. + try { + const _usedTools = holder.querySelector('.agent-thread-node'); + const _proseLen = (accumulated || '').replace(/<[^>]*>/g, '').trim().length; + if (_usedTools && _proseLen < 24 && !holder.querySelector('.agent-continue-btn')) { + const _stall = document.createElement('div'); + _stall.className = 'stopped-indicator'; + const _lbl = document.createElement('span'); + _lbl.style.cssText = 'font-style:italic;opacity:0.7;'; + _lbl.textContent = 'Paused mid-task'; + _stall.appendChild(_lbl); + const _cont = document.createElement('button'); + _cont.className = 'continue-btn agent-continue-btn'; + _cont.title = 'Continue — pick up where it left off'; + _cont.textContent = '▸'; + _cont.addEventListener('click', () => { + _stall.remove(); + const mi = uiModule.el('message'); + if (mi) { + mi.value = 'Continue — you stopped before finishing. Pick up exactly where you left off and complete the task.'; + const sb = document.querySelector('.send-btn'); + if (sb) sb.click(); + } + }); + _stall.appendChild(_cont); + (holder.querySelector('.body') || holder).appendChild(_stall); + } + } catch (_) {} + + // Clear streaming minHeight lock + const _streamContent = roundHolder.querySelector('.stream-content'); + if (_streamContent) _streamContent.style.minHeight = ''; + + // Finalize the last round's bubble — flatten stream-content wrapper for clean DOM + const finalDisplay = stripToolBlocks(roundText); + if (finalDisplay.trim()) { + var _body4 = roundHolder.querySelector('.body'); + // Preserve sources expanded state before final render + var _wasExpanded = _sourcesExpanded || !!(_body4 && _body4.querySelector('.sources-content.expanded')); + + // If thinking was collapsed in-place during streaming, preserve it + var _liveReplyEl = _body4 && _body4.querySelector('.live-reply-content'); + var _extracted = _liveReplyEl ? markdownModule.extractThinkingBlocks(finalDisplay) : null; + var _finalReply = ''; + if (_liveReplyEl) { + // Try standard extraction first (for native tags) + if (_extracted?.thinkingBlocks?.length) { + _finalReply = (_extracted.content || '').trim(); + } else { + // Non-tag thinking: extract reply from raw text + // Handle garbled tag: "Thinking: reasoning\nreply" + const _garbledMatch = finalDisplay.match(/^[\s\S]+?\s*([\s\S]*?)(?:<\/think(?:ing)?>)?\s*$/i); + if (_garbledMatch && _garbledMatch[1].trim()) { + _finalReply = _garbledMatch[1].trim(); + } else { + // Pure non-tag: find reply boundary by prefix patterns + const _rs2 = ['Hey', 'Hi ', 'Hi!', 'Hello', 'Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"]; + const _fr = (finalDisplay || '').trimStart(); + if (markdownModule.startsWithReasoningPrefix(_fr)) { + const _fLines = _fr.split('\n'); + for (let _fi = 1; _fi < _fLines.length; _fi++) { + const _fl = _fLines[_fi].trim(); + if (!_fl) continue; + if (_rs2.some(rp => _fl.startsWith(rp))) { _finalReply = _fLines.slice(_fi).join('\n'); break; } + } + // Within-line check + if (!_finalReply) { + for (const rp of _rs2) { + const rx = new RegExp('[.!?]\\s*(' + rp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')'); + const m = rx.exec(_fr); + if (m && m.index > 20) { _finalReply = _fr.slice(m.index + 1).trim(); break; } + } + } + } + } + } + } + if (_liveReplyEl && _finalReply) { + // Render reply into the live-reply container (thinking bar already showing) + var _replyHtml = markdownModule.mdToHtml(markdownModule.squashOutsideCode(_finalReply)); + _liveReplyEl.innerHTML = _replyHtml; + _liveReplyEl.classList.remove('live-reply-content'); + if (_sourcesData) { + var _srcEl = document.createElement('div'); + _srcEl.innerHTML = _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded); + _body4.insertBefore(_srcEl.firstChild || _srcEl, _body4.firstChild); + } + if (_findingsData) _body4.insertAdjacentHTML('beforeend', chatRenderer.buildFindingsBox(_findingsData)); + } else { + // Full re-render (reply empty or no live-reply container) + _body4.innerHTML = (_sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded) : '') + + markdownModule.processWithThinking(markdownModule.squashOutsideCode(finalDisplay)) + + (_findingsData ? chatRenderer.buildFindingsBox(_findingsData) : ''); + } + } else if (_sourcesHtml) { + var _body4b = roundHolder.querySelector('.body'); + var _wasExpanded2 = _sourcesExpanded || !!(_body4b && _body4b.querySelector('.sources-content.expanded')); + _body4b.innerHTML = _sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded2) : _sourcesHtml; + } else if (roundHolder !== holder) { + // Check if there's thinking content worth showing + const _thinkMatch = roundText.match(/([\s\S]*?)<\/think(?:ing)?>/i); + if (_thinkMatch && _thinkMatch[1].trim()) { + // Show thinking in a collapsed section even if no visible reply text + const _body4c = roundHolder.querySelector('.body'); + if (_body4c) _body4c.innerHTML = markdownModule.processWithThinking(roundText); + } else { + roundHolder.style.display = 'none'; + // Thread above expected a bubble below — remove has-bottom since bubble is hidden + const _lastThread = roundHolder.previousElementSibling; + if (_lastThread && _lastThread.classList.contains('agent-thread')) { + _lastThread.classList.remove('has-bottom'); + } + } + } + + + if (window.hljs) { + roundHolder.querySelectorAll('pre code').forEach((block) => { + window.hljs.highlightElement(block); + }); + } + if (markdownModule.renderMermaid) markdownModule.renderMermaid(roundHolder); + + uiModule.scrollHistory(); + // Render RAG sources if present + if (holder._ragSources && holder._ragSources.length) { + const details = document.createElement('details'); + details.className = 'rag-sources'; + const summary = document.createElement('summary'); + summary.textContent = `Sources (${holder._ragSources.length} documents)`; + details.appendChild(summary); + holder._ragSources.forEach(src => { + const item = document.createElement('div'); + item.className = 'rag-source-item'; + const _esc = uiModule.esc; + item.innerHTML = `${_esc(src.filename)} ${(src.similarity * 100).toFixed(1)}%
${_esc(src.snippet)}
`; + details.appendChild(item); + }); + holder.querySelector('.body').appendChild(details); + } + + // Hide first bubble if it has no visible text content (e.g. agent went straight to tools) + if (holder !== roundHolder && holder.style.display !== 'none') { + const _hBody = holder.querySelector('.body'); + const _hText = _hBody ? _hBody.textContent.trim() : ''; + if (!_hText) holder.style.display = 'none'; + } + + // Attach footer to the last visible bubble (roundHolder for multi-round agent, holder for single) + const footerTarget = (roundHolder && roundHolder !== holder && roundHolder.style.display !== 'none') ? roundHolder : holder; + footerTarget.appendChild(createMsgFooter(footerTarget)); + // Add "View Report" link for completed research + if (_researchingStreamIds.has(streamSessionId)) { + _appendViewReportLink(footerTarget, streamSessionId); + } + // Also store raw on the footer target so copy/TTS work + if (footerTarget !== holder) footerTarget.dataset.raw = accumulated; + if (addAITTSButton && accumulated && window.aiTTSManager?._provider !== 'disabled' && window.aiTTSManager?.available) { + addAITTSButton(footerTarget, accumulated); + } + // TTS auto-play: streaming mode flushes remaining text, non-streaming enqueues full message + if (accumulated && window.aiTTSManager && window.aiTTSManager.autoPlay) { + const ttsBtn = holder.querySelector('.ai-tts-button'); + if (ttsBtn) { + var ICON_PLAY_TTS = ''; + var ICON_STOP_TTS = ''; + const resetFn = () => { + ttsBtn.innerHTML = ICON_PLAY_TTS; + ttsBtn.classList.remove('playing', 'loading'); + ttsBtn.style.color = '#6b7280'; + ttsBtn.title = 'Read aloud'; + }; + if (streamingTTS) { + // Flush remaining partial sentence and attach the real button + window.aiTTSManager.streamingEnd(accumulated); + window.aiTTSManager.streamingAttachButton(ttsBtn, resetFn); + // If still playing sentences from the stream, show stop icon + if (window.aiTTSManager.isPlaying || window.aiTTSManager._processing) { + ttsBtn.innerHTML = ICON_STOP_TTS; + ttsBtn.classList.add('playing'); + ttsBtn.style.color = '#ccc'; + ttsBtn.title = 'Stop'; + } + } else { + // Non-streaming fallback (autoPlay toggled mid-stream, etc.) + window.aiTTSManager.enqueue(accumulated, ttsBtn, resetFn); + } + } + } + if (metrics) { + displayMetrics(footerTarget, metrics); + } + // Attach variant navigation if this was a regeneration + _attachVariantNav(footerTarget); + + // Merge with previous stopped message if this was a continue + if (_pendingContinue) { + const prevEl = _pendingContinue; + _pendingContinue = null; + const prevBody = prevEl.querySelector('.body'); + const newBody = footerTarget.querySelector('.body'); + if (prevBody && newBody && prevEl.parentNode) { + // Merge: combine raw text with *(continued)* marker + const oldRaw = prevEl.dataset.raw || ''; + const newRaw = footerTarget.dataset.raw || ''; + const mergedRaw = oldRaw + '\n\n*(continued)*\n\n' + newRaw; + prevEl.dataset.raw = mergedRaw; + // Re-render merged content + prevBody.innerHTML = markdownModule.processWithThinking( + markdownModule.squashOutsideCode(mergedRaw) + ); + // Remove the new bubble and re-add footer to the merged one + footerTarget.remove(); + const oldFooter = prevEl.querySelector('.msg-footer'); + if (oldFooter) oldFooter.remove(); + prevEl.appendChild(createMsgFooter(prevEl)); + if (window.hljs) { + prevEl.querySelectorAll('pre code').forEach(block => window.hljs.highlightElement(block)); + } + + // Persist merge to server + const sid = sessionModule.getCurrentSessionId(); + if (sid) { + fetch(`${API_BASE}/api/session/${sid}/merge-last-assistant`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ separator: '\n\n*(continued)*\n\n' }) + }).catch(e => console.warn('merge-last-assistant failed:', e)); + } + } + } + } // end if (!_isBgFinal) + + } catch (err) { + _renderStream(); + // Clean up any active spinner (e.g. "Generating response" during tool calls) + if (spinner && spinner.element) spinner.destroy(); + _cancelThinkingTimer(); + _removeThinkingSpinner(); + document.querySelectorAll('.agent-thread.streaming').forEach(t => t.classList.remove('streaming')); + // Check if this stream was running in background + const _isBgCatch = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId); + + if (_isBgCatch) { + // Error happened while backgrounded — update map, don't touch DOM + console.error('Background stream error:', err); + var bgErr = _backgroundStreams.get(streamSessionId); + if (bgErr && bgErr.status === 'completed') { + // [DONE] was already processed — this error is benign (e.g. reader.read() after close) + // Don't override the completed status; just ensure the completed dot stays + if (sessionModule && sessionModule.clearStreaming) { + sessionModule.clearStreaming(streamSessionId); + } + } else if (bgErr) { + bgErr.status = 'error'; + if (sessionModule && sessionModule.clearStreaming) { + sessionModule.clearStreaming(streamSessionId); + } + } + } else { + // Stop streaming TTS on any error/abort + if (streamingTTS && window.aiTTSManager) window.aiTTSManager.stop(); + + if (currentAbort && currentAbort.signal.aborted) { + const abortReason = currentAbort._reason || ''; + // Timeout-triggered aborts should remain visible instead of disappearing. + if (timedOut || abortReason === 'timeout') { + const timeoutMsg = _isAgent + ? 'Agent response timed out. Try again, switch to a faster model, or reduce tool usage.' + : 'Response timed out. Try again.'; + + if (holder && !accumulated) { + holder.querySelector('.body').innerHTML = + `
[${timeoutMsg}]
`; + } else if (holder && accumulated) { + const timeoutNote = document.createElement('div'); + timeoutNote.className = 'stopped-indicator'; + timeoutNote.innerHTML = + `[${timeoutMsg}]`; + holder.querySelector('.body').appendChild(timeoutNote); + } + currentAbort = null; + return; + } + + if (abortReason === 'offline') { + const offlineMsg = 'Endpoint offline — switch model or try again.'; + if (holder && !accumulated) { + holder.querySelector('.body').innerHTML = + `
[${offlineMsg}]
`; + } else if (holder && accumulated) { + const offlineNote = document.createElement('div'); + offlineNote.className = 'stopped-indicator'; + offlineNote.innerHTML = + `[${offlineMsg}]`; + holder.querySelector('.body').appendChild(offlineNote); + } + currentAbort = null; + return; + } + + if (abortReason === 'recovery') { + const recoveryMsg = 'Streaming was interrupted after the tab went inactive. Partial output was preserved.'; + if (holder && !accumulated) { + holder.querySelector('.body').innerHTML = + `
[${recoveryMsg}]
`; + } else if (holder && accumulated) { + const recoveryNote = document.createElement('div'); + recoveryNote.className = 'stopped-indicator'; + recoveryNote.innerHTML = + `[${recoveryMsg}]`; + holder.querySelector('.body').appendChild(recoveryNote); + } + currentAbort = null; + return; + } + + // User-initiated stop (or browser navigation abort). + // Stopped before any text arrived — keep the bubble as a + // "Cancelled by user" record (so it survives a refresh). + if (holder && !accumulated) { + _renderCancelledBubble(holder); + } + + // But just in case the stop button didn't render it, render it here + if (holder && accumulated && !currentHolder) { + holder.dataset.raw = accumulated; + holder.querySelector('.body').innerHTML = markdownModule.processWithThinking( + markdownModule.squashOutsideCode(accumulated) + ); + + if (window.hljs) { + holder.querySelectorAll('pre code').forEach((block) => { + window.hljs.highlightElement(block); + }); + } + + const stoppedIndicator = document.createElement('div'); + stoppedIndicator.className = 'stopped-indicator'; + const stoppedLabel = document.createElement('span'); + stoppedLabel.textContent = '[Message interrupted]'; + stoppedIndicator.appendChild(stoppedLabel); + const continueBtn = document.createElement('button'); + continueBtn.className = 'continue-btn'; + continueBtn.title = 'Continue'; + continueBtn.textContent = '\u25B8'; + continueBtn.addEventListener('click', () => { + stoppedIndicator.remove(); + _hideUserBubble = true; + _pendingContinue = holder; + const cutoff = accumulated; + const msgInput = uiModule.el('message'); + if (msgInput) { + msgInput.value = 'Your previous response was interrupted. It ended with:\n\n' + cutoff.slice(-500) + '\n\nDo NOT repeat what you already said. Continue exactly from where you were cut off.'; + const sb = document.querySelector('.send-btn'); + if (sb) sb.click(); + } + }); + stoppedIndicator.appendChild(continueBtn); + holder.querySelector('.body').appendChild(stoppedIndicator); + + // Tell server to mark this message as stopped + const _sid2 = sessionModule.getCurrentSessionId(); + if (_sid2) fetch(`${API_BASE}/api/session/${_sid2}/mark-stopped`, { method: 'POST' }).catch(e => console.warn('mark-stopped failed:', e)); + + if (!holder.querySelector('.msg-footer')) { + holder.appendChild(createMsgFooter(holder)); + } + + uiModule.scrollHistory(); + } + + // Now clear the abort controller + currentAbort = null; + } else { + console.error(err); + // Stream died with a tool node still spinning. Its per-node tickers + // (_elapsedTicker 50ms / _waveInterval 100ms) are normally cleared in + // `tool_output`, which will never arrive now — without this sweep they + // fire forever on the orphaned node (and auto-recover compounds it per + // nudge). Safe here: auto-recover's new send is deferred 200ms, so no + // fresh running nodes exist yet. + document.querySelectorAll('.agent-thread-node.running').forEach(node => { + if (node._waveInterval) { clearInterval(node._waveInterval); node._waveInterval = null; } + if (node._elapsedTicker) { clearInterval(node._elapsedTicker); node._elapsedTicker = null; } + node.classList.remove('running'); + }); + // Stream died unexpectedly — the "silently died" case. Re-engage the + // model immediately (no wait) with a completion handshake, up to the + // cap. Only auto-recover from connection-class failures; deterministic + // errors (unsupported tools, 4xx/5xx, parse failures) surface right away + // instead of burning the nudge budget on a guaranteed-to-fail retry. + if (!(_isRecoverableStreamErr(err) && _tryAutoRecover(holder, accumulated, streamSessionId))) { + const errorHolder = document.querySelector('.msg-ai:last-of-type .body'); + if (errorHolder) { + let errMsg = `Error: ${err.message}`; + // Add hint for tool-call errors + if (err.message && (err.message.includes('tool') || err.message.includes('auto'))) { + errMsg += '\n\nThis model may not support tools — try switching to Chat mode.'; + } + typewriterInto(errorHolder, errMsg); + } + } + } + } + } finally { + clearProcessingProbe(); + // Always clean up research tracking regardless of background state + _researchingStreamIds.delete(streamSessionId); + if (_researchingStreamIds.size === 0) { + var _rToggleCleanup = document.getElementById('research-toggle-btn'); + if (_rToggleCleanup) _rToggleCleanup.classList.remove('research-running'); + } + + // Only reset UI state if still on the stream's session and was never backgrounded + const _isBgFinally = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId); + + if (!_isBgFinally) { + // Reset button to idle state + updateSubmitButton('idle', submitBtn); + + // Re-enable message input; on mobile blur to dismiss keyboard + if (messageInput) { + messageInput.disabled = false; + if (window.innerWidth <= 768) { + messageInput.blur(); + } else { + messageInput.focus(); + } + } + + // Clear tracking variables + currentAccumulated = ''; + currentHolder = null; + currentSpinner = null; + _researchingStreamIds.delete(streamSessionId); + // Clear research-running highlight if no more active research + if (_researchingStreamIds.size === 0) { + var _rToggle2 = document.getElementById('research-toggle-btn'); + if (_rToggle2) _rToggle2.classList.remove('research-running'); + } + _clearResearchTimer(); + + // Re-enable research button and auto-untoggle after use + // (skip if clarification round — keep toggle on for follow-up) + const _el = uiModule.el; + const _researchBtn = _el('research-toggle-btn'); + const _researchToggle = _el('research-toggle'); + if (_researchToggle && _researchToggle.checked) { + _researchToggle.checked = false; + Storage.setToggle('research', false); + } + if (_researchBtn) { + _researchBtn.disabled = false; + _researchBtn.classList.remove('active'); + _researchBtn.style.display = 'none'; + } + // Also sync overflow and tool sidebar buttons + const _overflowRes = _el('overflow-research-btn'); + if (_overflowRes) _overflowRes.classList.remove('active'); + const _toolRes = _el('tool-research-btn'); + if (_toolRes) _toolRes.classList.remove('active'); + + } + + // Research clarification timeout — if user doesn't reply within 5 min, show timeout + if (holder && holder._roleSuffix === 'Research' && !_researchingStreamIds.has(streamSessionId)) { + var _timeoutSessionId = streamSessionId; + var _timeoutTimer = setTimeout(async function() { + // Check if research_pending is still active (user hasn't replied) + try { + var _box = document.getElementById('chat-history'); + if (_box && sessionModule.getCurrentSessionId() === _timeoutSessionId) { + var _timeoutMsg = document.createElement('div'); + _timeoutMsg.className = 'msg msg-ai'; + _timeoutMsg.innerHTML = '
Odysseus
Research clarification timed out. Toggle research again to start over.
'; + _box.appendChild(_timeoutMsg); + uiModule.scrollHistory(); + } + } catch(_te) {} + }, 5 * 60 * 1000); + // Cancel timeout if user sends a message + var _origSubmit = window._researchTimeoutTimer; + if (_origSubmit) clearTimeout(_origSubmit); + window._researchTimeoutTimer = _timeoutTimer; + } + + // Release Web Lock + if (_webLockRelease) { + _webLockRelease(); + _webLockRelease = null; + } + + // Refresh session list after a delay (picks up auto-generated names) + setTimeout(() => { + if (sessionModule && sessionModule.loadSessions) { + sessionModule.loadSessions(); + } + }, 3000); + } + } + + /** + * Abort current chat request + */ + // stopServer=true ONLY for an explicit user Stop. The run is now DETACHED + // (survives tab close / navigation), so the generic abort used by cleanup + // paths (session switch, delete, reader teardown on tab close) must NOT stop + // the server run — otherwise closing the tab would kill the background task, + // defeating the whole point. Only the Stop button cancels the server run. + export function abortCurrentRequest(stopServer = false) { + if (currentAbort) { + currentAbort.abort(); + // Don't set to null here - let catch block handle it + } + if (stopServer) { + try { + const _sid = _streamSessionId + || (window.sessionModule && window.sessionModule.getCurrentSessionId && window.sessionModule.getCurrentSessionId()); + if (_sid) { + fetch(`/api/chat/stop/${encodeURIComponent(_sid)}`, { method: 'POST', credentials: 'same-origin' }).catch(() => {}); + } + } catch (_) {} + } + } + + // ── Stall watchdog ────────────────────────────────────────────── + // Auto-recover a turn whose stream died (connection drop) or went silent: + // preserve the partial, then re-submit a completion handshake by reusing the + // existing continue/resume path. Returns false at the cap so the caller can + // surface the failure instead of nudging forever. + // Only auto-recover from connection-class failures (the genuine "silently + // died" case). Deterministic errors — unsupported tools, HTTP 4xx/5xx, JSON + // parse failures — will fail identically on retry, so surfacing them + // immediately is both more honest and avoids wasting the nudge budget. + function _isRecoverableStreamErr(err) { + if (!err) return false; + if (err.name === 'TypeError') return true; // fetch/reader network failure + const m = (err.message || '').toLowerCase(); + if (/\btool\b|unsupported|json|parse|\b4\d\d\b|\b5\d\d\b/.test(m)) return false; + return /network|fetch|connection|reset|closed|aborted|stream|tim(?:e|ed)\s?out|econn|eof/.test(m); + } + + function _tryAutoRecover(holder, accumulated, sessionId) { + if (_autoNudges >= _AUTO_NUDGE_CAP) return false; + _autoNudges++; + if (holder && accumulated) { + holder.dataset.raw = accumulated; + try { + holder.querySelector('.body').innerHTML = + markdownModule.processWithThinking(markdownModule.squashOutsideCode(accumulated)); + } catch (_) {} + } + _pendingContinue = holder || null; // merge the continuation into the same bubble + _hideUserBubble = true; // no user bubble for the handshake + _autoContinuePending = true; // don't reset the counter on this submit + const _abandon = () => { // clear the pending flags so they can't + _pendingContinue = null; // leak into whatever chat is now open + _hideUserBubble = false; + _autoContinuePending = false; + }; + // Defer so the stream's finally resets state first — otherwise the send + // button is still in "stop" mode and clicking it would toggle, not send. + setTimeout(() => { + // The stream that died may not be the chat the user is now looking at — + // never inject the recovery handshake into the wrong conversation. + if (sessionId && sessionModule.getCurrentSessionId() !== sessionId) { _abandon(); return; } + const msgInput = uiModule.el('message'); + const sb = document.querySelector('.send-btn'); + if (!msgInput || !sb) { _abandon(); return; } + const tail = (accumulated || '').slice(-400); + msgInput.value = tail + ? `The stream dropped before you finished. It ended with:\n\n${tail}\n\nIf the task is fully complete, reply with just: DONE. Otherwise continue exactly where you left off and finish it — do not repeat what you already wrote.` + : `The stream dropped before you produced anything. If the task is already done, reply with just: DONE. Otherwise complete it now.`; + sb.click(); + }, 200); + return true; + } + + function _removeStallBanner() { + const b = document.getElementById('stall-banner'); + if (b) b.remove(); + _stallBannerShown = false; + } + function _showStallBanner(secs) { + if (document.getElementById('stall-banner')) return; + _stallBannerShown = true; + const box = document.getElementById('chat-history'); + if (!box) return; + const bar = document.createElement('div'); + bar.id = 'stall-banner'; + bar.className = 'stall-banner'; + const mins = Math.floor(secs / 60); + const label = mins >= 1 ? `${mins}m` : `${secs}s`; + bar.innerHTML = `Quiet for ${label} — still working?`; + const cont = document.createElement('button'); + cont.className = 'stall-banner-btn'; + cont.textContent = 'Nudge it'; + cont.title = 'Stop the stalled stream and ask it to continue'; + cont.addEventListener('click', () => { + _removeStallBanner(); + const mi = uiModule.el('message'); + if (mi) { + mi.value = 'Are you still working? If you stopped, continue exactly where you left off and finish the task.'; + const sb = document.querySelector('.send-btn'); + if (sb) sb.click(); + } + }); + const stop = document.createElement('button'); + stop.className = 'stall-banner-btn stall-banner-stop'; + stop.textContent = 'Stop'; + stop.addEventListener('click', () => { _removeStallBanner(); abortCurrentRequest(true); }); + bar.appendChild(cont); + bar.appendChild(stop); + box.appendChild(bar); + if (uiModule.scrollHistory) uiModule.scrollHistory(); + } + function _startStallWatchdog() { + // Disabled: the server-side stall detector / auto-continue (agent + // loop-breaker) handles quiet/stalled streams now, so the manual + // "Quiet for Nm — still working?" banner is redundant (and annoying). + if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; } + _removeStallBanner(); + } + function _stopStallWatchdog() { + if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; } + _removeStallBanner(); + } + + /** Show a "Cancelled by user" record in `holder` and persist an empty + * assistant placeholder server-side so the turn survives a refresh. + * Called from both abort paths when no tokens had streamed yet. */ + function _renderCancelledBubble(holder) { + if (!holder) return; + holder.dataset.raw = ''; + const body = holder.querySelector('.body'); + if (body) { + body.innerHTML = ''; + const indicator = document.createElement('div'); + indicator.className = 'stopped-indicator'; + const label = document.createElement('span'); + label.style.fontStyle = 'italic'; + label.style.opacity = '0.7'; + label.textContent = '[Cancelled by user]'; + indicator.appendChild(label); + body.appendChild(indicator); + } + if (typeof createMsgFooter === 'function' && !holder.querySelector('.msg-footer')) { + holder.appendChild(createMsgFooter(holder)); + } + // Persist as an assistant message with stopped+cancelled metadata so the + // chat-history loader renders the same indicator after a refresh. + // Include the model name so the bubble header still shows which model + // was running when the user hit Stop. + const sid = sessionModule.getCurrentSessionId(); + if (sid) { + let modelName = ''; + try { modelName = sessionModule.getCurrentModel?.() || ''; } catch {} + // Fallback: pull from the holder's existing meta (the streaming + // placeholder usually has the model set in the header already). + if (!modelName) { + modelName = holder.dataset.model + || holder.querySelector('.msg-header .msg-model')?.textContent + || ''; + } + fetch(`${API_BASE}/api/session/${sid}/inject_messages`, { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [{ + role: 'assistant', + content: '', + metadata: { stopped: true, cancelled: true, model: modelName }, + }], + }), + }).catch(() => {}); + } + } + + /** + * Detach current stream to run in background instead of aborting. + * Called when user switches sessions mid-stream. + */ + export function detachCurrentStream(sessionId) { + if (!isStreaming || !currentAbort) { + // Not streaming — fall through to abort + abortCurrentRequest(); + return; + } + // Store background stream state + _backgroundStreams.set(sessionId, { + status: 'running', + accumulated: currentAccumulated, + sourcesHtml: '', + findingsData: null, + abortCtrl: currentAbort, + query: currentHolder ? (currentHolder._researchQuery || '') : '', + metrics: null, + }); + // Mark session with pulsing dot in sidebar + if (sessionModule && sessionModule.markStreaming) { + sessionModule.markStreaming(sessionId); + } + // Clear local state WITHOUT aborting the fetch + currentAbort = null; + isStreaming = false; + currentHolder = null; + currentAccumulated = ''; + // Reset submit button so the new chat is ready to send + const submitBtn = document.querySelector('.send-btn'); + if (submitBtn) updateSubmitButton('idle', submitBtn); + } + + // _notifyStreamComplete and _insertStreamDoneToast now in chatStream.js + var _notifyStreamComplete = chatStream.notifyStreamComplete; + var _insertStreamDoneToast = chatStream.insertStreamDoneToast; + + /** + * Check for background streams when switching to a session. + * Called after history loads on session switch. + */ + export function checkBackgroundStream(sessionId) { + if (!sessionId || !_backgroundStreams.has(sessionId)) return; + var entry = _backgroundStreams.get(sessionId); + + if (entry.status === 'completed') { + // Response is already saved to DB and will appear in history — just clean up + _backgroundStreams.delete(sessionId); + return; + } + + if (entry.status === 'error') { + _backgroundStreams.delete(sessionId); + var box = document.getElementById('chat-history'); + if (box) { + var errHolder = document.createElement('div'); + errHolder.className = 'msg msg-ai'; + errHolder.innerHTML = '
[Background stream encountered an error]
'; + box.appendChild(errHolder); + } + return; + } + + if (entry.status === 'running') { + // Stream is still active — show a clean spinner, poll until done, + // then reload history to show the final saved response. + var box = document.getElementById('chat-history'); + if (!box) return; + + // Replay any doc content that was streamed in the background + if (entry._docTitle != null && documentModule) { + documentModule.streamDocOpen(entry._docTitle, entry._docLang || ''); + if (entry._docContent) { + documentModule.streamDocDelta(entry._docContent); + } + } + + var holder = document.createElement('div'); + holder.className = 'msg msg-ai'; + var meta = sessionModule.getSessions().find(function(s) { return s.id === sessionId; }); + var roleLabel = _shortModel(meta && meta.model); + var roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + holder.innerHTML = '
' + roleLabel + ' ' + roleTs + '
'; + _applyModelColor(holder.querySelector('.role'), meta && meta.model); + + var bodyDiv = holder.querySelector('.body'); + var spinner = spinnerModule.create('Response streaming in background', 'right'); + bodyDiv.appendChild(spinner.createElement()); + spinner.start(); + + box.appendChild(holder); + uiModule.scrollHistory(); + + // Poll map until stream finishes, then reload history + var pollId = setInterval(function() { + if (sessionModule.getCurrentSessionId() !== sessionId) { + clearInterval(pollId); + spinner.destroy(); + if (holder.parentNode) holder.remove(); + return; + } + // Update doc content while polling + var curPoll = _backgroundStreams.get(sessionId); + if (curPoll && curPoll._docContent && documentModule) { + documentModule.streamDocDelta(curPoll._docContent); + } + if (!curPoll || curPoll.status !== 'running') { + clearInterval(pollId); + spinner.destroy(); + if (holder.parentNode) holder.remove(); // Remove entire holder, not just spinner + _backgroundStreams.delete(sessionId); + // Reload session to show the completed response — but only if the user + // is still on it; don't yank them back from a new chat they opened. + if (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId() === sessionId) { + sessionModule.selectSession(sessionId); + } else { + sessionModule.loadSessions(); + } + } + }, 500); + } + } + + // Tag short single-line code blocks with .pre-compact so the CSS can + // render the Run/Edit/Copy buttons as a slim row that doesn't make a + // 1-line bash block taller than its own contents. + function _markCompactPre(pre) { + const code = pre.querySelector('code'); + if (!code) return; + const txt = code.textContent || ''; + // Count visible lines — ignore trailing newline (common with fenced + // blocks) and treat any empty extra line as not a real second line. + const lines = txt.replace(/\n+$/, '').split('\n'); + const compact = lines.length <= 1 && txt.length < 200; + pre.classList.toggle('pre-compact', compact); + } + function _scanCompactPres(root) { + if (!root || !root.querySelectorAll) return; + root.querySelectorAll('pre').forEach(_markCompactPre); + } + // Global observer so any
 added anywhere in the app (chat stream,
+  // chat re-renders, document library chat previews, slash commands,
+  // research previews, etc.) gets tagged without each call site needing
+  // to remember.
+  (function _initCompactPreObserver() {
+    if (window._cmpPreObserverWired) return;
+    window._cmpPreObserverWired = true;
+    _scanCompactPres(document.body);
+    const obs = new MutationObserver((muts) => {
+      for (const m of muts) {
+        for (const n of m.addedNodes) {
+          if (n.nodeType !== 1) continue;
+          if (n.tagName === 'PRE') _markCompactPre(n);
+          if (n.querySelectorAll) _scanCompactPres(n);
+        }
+      }
+    });
+    obs.observe(document.body, { childList: true, subtree: true });
+  })();
+
+  /**
+   * Initialize event listeners
+   */
+  export function initListeners() {
+    // Global event delegation for copy-code buttons
+    document.addEventListener('click', (e) => {
+      const btn = e.target.closest('.copy-code');
+      if (!btn) return;
+      e.stopPropagation();
+      const code = btn.getAttribute('data-code');
+      if (code && uiModule) {
+        uiModule.copyToClipboard(code);
+        // Visual feedback: swap the icon to a checkmark (regular size)
+        // and add .copied which the CSS uses to flash green + pulse.
+        // For slim/.pre-compact buttons the label text comes from a
+        // CSS ::before — swap it via data-state so we don't break the
+        // text-button layout.
+        const origHTML = btn.innerHTML;
+        const isCompact = !!btn.closest('pre.pre-compact');
+        if (!isCompact) {
+          btn.innerHTML = '';
+        }
+        btn.classList.add('copied');
+        btn.dataset.state = 'copied';
+        setTimeout(() => {
+          if (!isCompact) btn.innerHTML = origHTML;
+          btn.classList.remove('copied');
+          delete btn.dataset.state;
+        }, 1500);
+      }
+    });
+
+    // Run code button delegation
+    document.addEventListener('click', (e) => {
+      const btn = e.target.closest('.run-code');
+      if (!btn) return;
+      e.stopPropagation();
+      if (codeRunnerModule) codeRunnerModule.run(btn);
+    });
+
+    // Edit code button delegation — toggle contentEditable on the code element
+    document.addEventListener('click', (e) => {
+      const btn = e.target.closest('.edit-code');
+      if (!btn) return;
+      e.stopPropagation();
+      const pre = btn.closest('pre');
+      if (!pre) return;
+      const codeEl = pre.querySelector('code');
+      if (!codeEl) return;
+      const isEditing = codeEl.contentEditable !== 'false' && codeEl.contentEditable !== 'inherit';
+      if (isEditing) {
+        // Save: exit edit mode, update data-code on copy/run buttons
+        codeEl.contentEditable = 'false';
+        codeEl.classList.remove('editing');
+        pre.classList.remove('editing');
+        const newCode = codeEl.textContent;
+        const copyBtn = pre.querySelector('.copy-code');
+        if (copyBtn) copyBtn.setAttribute('data-code', newCode);
+        const runBtn = pre.querySelector('.run-code');
+        if (runBtn) runBtn.setAttribute('data-code', newCode);
+        // Swap icon back to pencil
+        btn.innerHTML = '';
+        btn.title = 'Edit';
+        btn.classList.remove('active');
+      } else {
+        // Enter edit mode. Firefox (especially on mobile) historically lacks
+        // contentEditable="plaintext-only" — setting it there leaves the block
+        // non-editable, so the tap "just gets a checkmark" with no way to type.
+        // Fall back to "true" when plaintext-only didn't take.
+        try { codeEl.contentEditable = 'plaintext-only'; } catch (_) { /* unsupported value */ }
+        if (codeEl.contentEditable !== 'plaintext-only') codeEl.contentEditable = 'true';
+        codeEl.classList.add('editing');
+        pre.classList.add('editing');
+        // preventScroll keeps the page from jumping to the codeblock when
+        // focusing the editable on mobile — the browser would otherwise
+        // scroll it into view above the keyboard, which reads as "auto-
+        // scroll triggered by clicking Edit".
+        try { codeEl.focus({ preventScroll: true }); } catch (_) { codeEl.focus(); }
+        // Swap icon to checkmark
+        btn.innerHTML = '';
+        btn.title = 'Done editing';
+        btn.classList.add('active');
+      }
+    });
+
+    // Tapping a code block body (not its buttons) toggles the overlay
+    // copy/edit/run buttons, which otherwise cover the text on mobile.
+    document.addEventListener('click', (e) => {
+      if (e.target.closest('.copy-code, .edit-code, .run-code')) return;
+      const pre = e.target.closest('pre');
+      if (!pre || !pre.querySelector('.copy-code')) return;
+      // Don't hide while editing — the buttons (incl. the Done checkmark) matter.
+      if (pre.classList.contains('editing')) return;
+      pre.classList.toggle('buttons-hidden');
+    });
+
+    // Position copy/run buttons top or bottom based on viewport position
+    // — DESKTOP ONLY. On mobile this was constantly retriggering on tap
+    // (synthetic mouseenter) and made the buttons jump, so the user's
+    // finger landed on the moved target. Keep them pinned at the top on
+    // touch — no auto-repositioning.
+    document.addEventListener('mouseenter', (e) => {
+      if (window.matchMedia('(max-width: 768px)').matches) return;
+      const pre = e.target.closest ? e.target.closest('pre') : null;
+      if (!pre || pre.dataset.btnPosComputed) return;
+      const rect = pre.getBoundingClientRect();
+      const threshold = window.innerHeight * 0.35;
+      const isBottom = rect.top < threshold;
+      const copyBtn = pre.querySelector('.copy-code');
+      if (copyBtn) copyBtn.classList.toggle('bottom', isBottom);
+      const editBtn = pre.querySelector('.edit-code');
+      if (editBtn) editBtn.classList.toggle('bottom', isBottom);
+      const runBtn = pre.querySelector('.run-code');
+      if (runBtn) runBtn.classList.toggle('bottom', isBottom);
+      pre.dataset.btnPosComputed = '1';
+    }, true);
+
+    // Tab suspension recovery: when user tabs back in, check if stream froze
+    document.addEventListener('visibilitychange', () => {
+      if (document.visibilityState !== 'visible') return;
+      if (!isStreaming) return;
+
+      // Stream claims to be running — check if reader is actually alive
+      const staleSince = Date.now() - _lastReaderActivity;
+      if (staleSince < 20000) return; // Active recently, probably fine
+
+      // Reader hasn't produced data in 5+ seconds after tab resume.
+      // Give it a short grace period then recover.
+      console.warn('[tab-recovery] Stream appears frozen (no activity for ' + Math.round(staleSince/1000) + 's). Recovering...');
+
+      setTimeout(() => {
+        // Re-check — maybe the reader woke up during the grace period
+        if (!isStreaming) return;
+        const stillStale = Date.now() - _lastReaderActivity;
+        if (stillStale < 5000) return; // Came back to life
+
+        console.warn('[tab-recovery] Stream confirmed dead. Aborting and reloading session.');
+
+        // Abort the frozen stream, but preserve the visible bubble.
+        if (currentAbort) {
+          currentAbort._reason = 'recovery';
+          currentAbort.abort();
+        }
+        isStreaming = false;
+
+        // Release Web Lock
+        if (_webLockRelease) {
+          _webLockRelease();
+          _webLockRelease = null;
+        }
+
+        // Reset UI state
+        var _submitBtn = document.getElementById('submit');
+        updateSubmitButton('idle', _submitBtn);
+        var _msgInput = document.getElementById('message');
+        if (_msgInput) _msgInput.disabled = false;
+      }, 2000); // 2 second grace period
+    });
+
+    // On mobile, fade out welcome text when keyboard opens to prevent overlap
+    if (window.innerWidth <= 768) {
+      const msgInput = document.getElementById('message');
+      if (msgInput) {
+        msgInput.addEventListener('focus', () => {
+          const ws = document.getElementById('welcome-screen');
+          if (ws && !ws.classList.contains('hidden')) {
+            ws.classList.add('kb-hidden');
+          }
+        });
+        msgInput.addEventListener('blur', () => {
+          const ws = document.getElementById('welcome-screen');
+          if (ws && !ws.classList.contains('hidden')) {
+            // Delay re-show so tapping within chatbox doesn't flash
+            setTimeout(() => {
+              if (document.activeElement !== msgInput) {
+                ws.classList.remove('kb-hidden');
+              }
+            }, 200);
+          }
+        });
+      }
+      // Smooth viewport resize when keyboard opens/closes
+      if (window.visualViewport) {
+        window.visualViewport.addEventListener('resize', () => {
+          document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px');
+        });
+        document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px');
+      }
+    }
+
+    // If the browser discarded and restored this tab, reload the current session
+    // so the user sees the server-saved partial response instead of a blank page
+    if (document.wasDiscarded) {
+      console.warn('[tab-recovery] Tab was discarded by browser — reloading session');
+      setTimeout(() => {
+        var _sid = sessionModule && sessionModule.getCurrentSessionId();
+        if (_sid) sessionModule.selectSession(_sid);
+      }, 500);
+    }
+  }
+
+  /**
+   * Regenerate response: truncate history to the user message before this AI message,
+   * then re-submit that user message.
+   */
+  /**
+   * Edit a user message: show an input, truncate to before it, resubmit the edited text.
+   */
+  export async function editUserMessage(userMsgElement) {
+    const box = document.getElementById('chat-history');
+    const allMsgs = Array.from(box.querySelectorAll('.msg'));
+    const msgIndex = allMsgs.indexOf(userMsgElement);
+    if (msgIndex < 0) return;
+
+    const bodyEl = userMsgElement.querySelector('.body');
+    const currentText = bodyEl ? bodyEl.textContent.trim().replace(/\s*\[\d+ attachment\(s\)\]$/, '') : '';
+
+    // Replace body with an editable textarea
+    const editor = document.createElement('textarea');
+    editor.className = 'edit-textarea';
+    editor.value = currentText;
+    editor.rows = Math.max(2, currentText.split('\n').length);
+
+    const btnRow = document.createElement('div');
+    btnRow.style.cssText = 'display:flex; gap:6px; margin-top:4px;';
+
+    const saveBtn = document.createElement('button');
+    saveBtn.className = 'edit-save-btn';
+    saveBtn.textContent = 'Send';
+    const cancelBtn = document.createElement('button');
+    cancelBtn.className = 'edit-cancel-btn';
+    cancelBtn.textContent = 'Cancel';
+    btnRow.appendChild(saveBtn);
+    btnRow.appendChild(cancelBtn);
+
+    const originalHTML = bodyEl.innerHTML;
+    bodyEl.innerHTML = '';
+    bodyEl.appendChild(editor);
+    bodyEl.appendChild(btnRow);
+    editor.focus();
+
+    cancelBtn.addEventListener('click', (e) => {
+      e.stopPropagation();
+      bodyEl.innerHTML = originalHTML;
+    });
+
+    saveBtn.addEventListener('click', async (e) => {
+      e.stopPropagation();
+      const newText = editor.value.trim();
+      if (!newText) return;
+
+      const sessionId = sessionModule.getCurrentSessionId();
+      if (!sessionId) return;
+
+      const keepCount = msgIndex;
+      try {
+        await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ keep_count: keepCount })
+        });
+
+        // Remove DOM elements from msgIndex onward
+        for (let i = allMsgs.length - 1; i >= msgIndex; i--) {
+          allMsgs[i].remove();
+        }
+
+        // Submit the edited text
+        const messageInput = uiModule.el('message');
+        messageInput.value = newText;
+        const submitBtn = document.querySelector('.send-btn');
+        if (submitBtn) submitBtn.click();
+      } catch (err) {
+        console.error('Edit failed:', err);
+        if (uiModule) uiModule.showError('Edit failed: ' + err.message);
+        bodyEl.innerHTML = originalHTML;
+      }
+    });
+
+    // Also submit on Enter (without shift)
+    editor.addEventListener('keydown', (e) => {
+      if (e.key === 'Enter' && !e.shiftKey) {
+        e.preventDefault();
+        saveBtn.click();
+      }
+    });
+  }
+
+  /**
+   * Resend a user message — truncates history to that point and resubmits.
+   */
+  export async function resendUserMessage(userMsgElement) {
+    const box = document.getElementById('chat-history');
+    const allMsgs = Array.from(box.querySelectorAll('.msg'));
+    const msgIndex = allMsgs.indexOf(userMsgElement);
+    if (msgIndex < 0) return;
+
+    // Prefer dataset.raw (stripped original user text) over .body.textContent
+    // — the latter slurps the rendered "View image description" collapsible
+    // content too, which would then be sent back as the user's question and
+    // the AI would reply to that gibberish instead of the actual prompt.
+    const bodyEl = userMsgElement.querySelector('.body');
+    let text = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
+    text = text.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
+
+    // Collect file_ids attached to this user message so the resend re-carries
+    // the photos / docs (and the chat handler picks up the user-edited OCR
+    // text cached server-side under those file ids).
+    const _attachEls = userMsgElement.querySelectorAll('[data-file-id]');
+    let _ids = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
+    if (!_ids.length) {
+      const _imgs = userMsgElement.querySelectorAll('.attach-image-preview img, .attach-card img');
+      for (const _im of _imgs) {
+        const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
+        if (_m && _m[1] && !_ids.includes(_m[1])) _ids.push(_m[1]);
+      }
+    }
+
+    // Rescue: legacy bubbles may have stored the filename as the message
+    // content (artifact of earlier broken resends). Don't re-send that as
+    // the user prompt if we still have the file attached. Loosen the regex
+    // to cover real-world camera/screenshot names with spaces, parens,
+    // multi-dots: "Screen Shot 2026-05-28 at 4.05.32 PM.png", "IMG (1).JPG".
+    if (text && _ids.length && /^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(text)) {
+      text = '';
+    }
+    // Empty text + no attachments → tell the user instead of silently bailing.
+    // The common case is a regen during a pre-upload race where the bubble
+    // never had an `[data-file-id]` to scrape.
+    if (!text && !_ids.length) {
+      if (uiModule?.showError) uiModule.showError('Nothing to resend — message has no text and no attachments yet (try again after the upload finishes).');
+      return;
+    }
+
+    const sessionId = sessionModule.getCurrentSessionId();
+    if (!sessionId) return;
+
+    // Truncate backend to keep everything before this user message
+    const keepCount = msgIndex;
+    try {
+      await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ keep_count: keepCount })
+      });
+
+      // Drop the AI replies after the user message but KEEP the user bubble
+      // itself (so its photo stays visible). Then suppress the new user
+      // bubble that send would otherwise add — same pattern as regenerate.
+      let sibling = userMsgElement.nextSibling;
+      while (sibling) {
+        const next = sibling.nextSibling;
+        sibling.remove();
+        sibling = next;
+      }
+      _hideUserBubble = true;
+      _pendingRegenAttachments = _ids;
+
+      // Resubmit
+      const messageInput = uiModule.el('message');
+      messageInput.value = text;
+      const submitBtn = document.querySelector('.send-btn');
+      if (submitBtn) submitBtn.click();
+    } catch (err) {
+      console.error('Resend failed:', err);
+      if (uiModule) uiModule.showError('Resend failed: ' + err.message);
+    }
+  }
+
+  export async function regenerateFrom(aiMsgElement) {
+    const box = document.getElementById('chat-history');
+    const allMsgs = Array.from(box.querySelectorAll('.msg'));
+    const aiIndex = allMsgs.indexOf(aiMsgElement);
+    if (aiIndex < 0) return;
+
+    // Find the preceding user message
+    let userIndex = -1;
+    let userText = '';
+    let userMsgEl = null;
+    for (let i = aiIndex - 1; i >= 0; i--) {
+      if (allMsgs[i].classList.contains('msg-user')) {
+        userIndex = i;
+        userMsgEl = allMsgs[i];
+        // Prefer dataset.raw (set by addMessage with the stripped, original
+        // user text) over the rendered body's textContent — the latter
+        // pulls in the "View image description" collapsible content too,
+        // duplicating the OCR text on regen.
+        const bodyEl = userMsgEl.querySelector('.body');
+        userText = (userMsgEl.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
+        userText = userText.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
+        break;
+      }
+    }
+
+    if (userIndex < 0) {
+      if (uiModule) uiModule.showError('Could not find the user message to regenerate');
+      return;
+    }
+
+    // Collect any file_ids attached to the original user message so the
+    // regenerated send re-uses them. Without this the AI is regenerated on
+    // text alone — photos (and the user-edited OCR text cached server-side
+    // under that file_id) would be silently dropped.
+    const _attachEls = userMsgEl ? userMsgEl.querySelectorAll('[data-file-id]') : [];
+    let _regenIds = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
+    // Fallback for bubbles rendered before the data-file-id stamp landed:
+    // sniff the file id straight out of any `.attach-image-preview img`
+    // src URLs (matches /api/upload/). Otherwise an older bubble would
+    // regen with zero attachments and the photo would be lost from the
+    // resulting message even though the file still exists on disk.
+    if (!_regenIds.length && userMsgEl) {
+      const _imgs = userMsgEl.querySelectorAll('.attach-image-preview img, .attach-card img');
+      for (const _im of _imgs) {
+        const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
+        if (_m && _m[1] && !_regenIds.includes(_m[1])) _regenIds.push(_m[1]);
+      }
+    }
+    _pendingRegenAttachments = _regenIds;
+
+    // Rescue: earlier-version regens (before the dataset.raw fix) stored the
+    // photo's filename as the user-message content. On a follow-up regen,
+    // that filename would be sent back as the literal user prompt, so the
+    // AI thinks the question is "blue_night_preview.jpg" and replies "that's
+    // an image file". If userText is just a bare image filename and we have
+    // attachments, drop it so the OCR text (or the image bytes for vision
+    // models) is what the model actually sees.
+    if (userText && _pendingRegenAttachments.length &&
+        /^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(userText.trim())) {
+      userText = '';
+    }
+
+    // A photo-only message has empty user text — regen must still proceed,
+    // because the attachments themselves are the message. Bail only if there
+    // is no text AND no attachments to send.
+    if (!userText && !_pendingRegenAttachments.length) {
+      if (uiModule) uiModule.showError('Nothing to regenerate — the user message has no text and no attachments');
+      return;
+    }
+
+    const sessionId = sessionModule.getCurrentSessionId();
+    if (!sessionId) return;
+
+    // Save current response as a variant
+    const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || '';
+    const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || '';
+    let variants = [];
+    try { variants = JSON.parse(aiMsgElement.dataset.variants || '[]'); } catch(_) {}
+    if (variants.length === 0) {
+      // First regen — save the original as variant 0
+      variants.push({ raw: oldRaw, html: oldHtml, label: 'original' });
+    }
+
+    const keepCount = userIndex;
+
+    try {
+      await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ keep_count: keepCount })
+      });
+
+      for (let i = allMsgs.length - 1; i > aiIndex; i--) {
+        allMsgs[i].remove();
+      }
+
+      // Remove the AI message from DOM — it will be replaced by the new streaming response
+      // But first, stash the variants data so we can transfer it to the new element
+      _pendingVariants = variants;
+      _pendingVariantLabel = 'regen';
+      aiMsgElement.remove();
+
+      _hideUserBubble = true;
+      const messageInput = uiModule.el('message');
+      messageInput.value = userText;
+      const submitBtn = document.querySelector('.send-btn');
+      if (submitBtn) submitBtn.click();
+
+    } catch (err) {
+      console.error('Regenerate failed:', err);
+      if (uiModule) uiModule.showError('Regenerate failed: ' + err.message);
+    }
+  }
+
+  // Pending variants from a regeneration — transferred to new streaming element
+  let _pendingVariants = null;
+  let _pendingVariantLabel = null;
+  // File-ids carried over from the original user message during a regen, so
+  // photos / OCR overrides survive into the new send. Consumed once.
+  let _pendingRegenAttachments = null;
+
+  /**
+   * Called after streaming completes to attach variant navigation if this was a regen.
+   */
+  function _attachVariantNav(msgElement) {
+    if (!_pendingVariants) return;
+    const variants = _pendingVariants;
+    _pendingVariants = null;
+
+    // Add the new response as the latest variant
+    const newRaw = msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || '';
+    const newHtml = msgElement.querySelector('.body')?.innerHTML || '';
+    const varLabel = _pendingVariantLabel || 'regen';
+    _pendingVariantLabel = null;
+    variants.push({ raw: newRaw, html: newHtml, label: varLabel });
+
+    msgElement.dataset.variants = JSON.stringify(variants);
+    msgElement.dataset.variantIndex = String(variants.length - 1);
+
+    _renderVariantNav(msgElement, variants, variants.length - 1);
+
+    // Persist variants to server
+    const sid = sessionModule.getCurrentSessionId();
+    if (sid) {
+      fetch(`${API_BASE}/api/session/${sid}/update-last-meta`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ metadata: { variants: variants, variantIndex: variants.length - 1 } })
+      }).catch(e => console.warn('update-last-meta (variants) failed:', e));
+    }
+  }
+
+  const _VARIANT_ICONS = { regen: '\u21BB', shorter: '\u2702', simpler: '?', original: '\u25CB' };
+  function _variantTagText(label) {
+    return _VARIANT_ICONS[label] || _VARIANT_ICONS['original'];
+  }
+
+  function _renderVariantNav(msgElement, variants, currentIdx) {
+    // Remove existing nav if any
+    const old = msgElement.querySelector('.variant-nav');
+    if (old) old.remove();
+
+    if (variants.length < 2) return;
+
+    const nav = document.createElement('span');
+    nav.className = 'variant-nav';
+    nav.addEventListener('click', (e) => e.stopPropagation());
+
+    // Label showing what this variant is
+    // Divider
+    const divider = document.createElement('span');
+    divider.className = 'variant-divider';
+    divider.textContent = '|';
+    nav.appendChild(divider);
+
+    // Label
+    const curVariant = variants[currentIdx];
+    const tagLabel = document.createElement('span');
+    tagLabel.className = 'variant-tag' + (curVariant?.label === 'shorter' ? ' variant-tag-scissors' : '');
+    tagLabel.textContent = _variantTagText(curVariant?.label);
+    nav.appendChild(tagLabel);
+
+    // < button
+    const prevBtn = document.createElement('button');
+    prevBtn.className = 'variant-btn';
+    prevBtn.textContent = '<';
+    prevBtn.disabled = currentIdx === 0;
+    prevBtn.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx - 1); });
+    nav.appendChild(prevBtn);
+
+    // Clickable number for current index (click left number = go left, right = go right)
+    const numLeft = document.createElement('button');
+    numLeft.className = 'variant-num';
+    numLeft.textContent = String(currentIdx + 1);
+    numLeft.disabled = currentIdx === 0;
+    numLeft.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx - 1); });
+    nav.appendChild(numLeft);
+
+    const slash = document.createElement('span');
+    slash.className = 'variant-slash';
+    slash.textContent = '/';
+    nav.appendChild(slash);
+
+    const numRight = document.createElement('button');
+    numRight.className = 'variant-num';
+    numRight.textContent = String(variants.length);
+    numRight.disabled = currentIdx === variants.length - 1;
+    numRight.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx + 1); });
+    nav.appendChild(numRight);
+
+    // > button
+    const nextBtn = document.createElement('button');
+    nextBtn.className = 'variant-btn';
+    nextBtn.textContent = '>';
+    nextBtn.disabled = currentIdx === variants.length - 1;
+    nextBtn.addEventListener('click', (e) => { e.stopPropagation(); _switchVariant(msgElement, variants, currentIdx + 1); });
+    nav.appendChild(nextBtn);
+
+    // Insert into the .role header
+    const roleEl = msgElement.querySelector('.role');
+    if (roleEl) {
+      roleEl.appendChild(nav);
+    } else {
+      msgElement.appendChild(nav);
+    }
+  }
+
+  function _switchVariant(msgElement, variants, newIdx) {
+    if (newIdx < 0 || newIdx >= variants.length) return;
+    const v = variants[newIdx];
+    const body = msgElement.querySelector('.body');
+    if (body) body.innerHTML = v.html;
+    msgElement.dataset.raw = v.raw;
+    msgElement.dataset.variantIndex = String(newIdx);
+    if (window.hljs) {
+      msgElement.querySelectorAll('pre code').forEach(block => window.hljs.highlightElement(block));
+    }
+    _renderVariantNav(msgElement, variants, newIdx);
+
+    // Persist selected variant to server
+    const sid = sessionModule.getCurrentSessionId();
+    if (sid) {
+      fetch(`${API_BASE}/api/session/${sid}/update-last-meta`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ metadata: { variantIndex: newIdx } })
+      }).catch(e => console.warn('update-last-meta (variantIndex) failed:', e));
+    }
+  }
+
+  export async function forkFrom(aiMsgElement) {
+    const box = document.getElementById('chat-history');
+    const allMsgs = Array.from(box.querySelectorAll('.msg'));
+    const aiIndex = allMsgs.indexOf(aiMsgElement);
+    if (aiIndex < 0) return;
+
+    const sessionId = sessionModule.getCurrentSessionId();
+    if (!sessionId) return;
+
+    const keepCount = aiIndex + 1;
+
+    try {
+      const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ keep_count: keepCount }),
+      });
+      if (!res.ok) throw new Error(await res.text());
+      const data = await res.json();
+
+      await sessionModule.loadSessions();
+      await sessionModule.selectSession(data.id);
+      if (uiModule) uiModule.showToast(`Forked → ${data.name}`);
+    } catch (err) {
+      console.error('Fork failed:', err);
+      if (uiModule) uiModule.showError('Fork failed: ' + err.message);
+    }
+  }
+
+  /**
+   * Check for pending/completed research after page refresh or session switch.
+   * If research is still running, show a spinner and poll until done.
+   * If research is done, fetch result and render it.
+   */
+  export async function checkPendingResearch(sessionId) {
+    if (!sessionId) return;
+    try {
+      const res = await fetch(`${API_BASE}/api/research/status/${sessionId}`);
+      if (!res.ok) return; // 404 = no research for this session
+      const data = await res.json();
+
+      if (data.status === 'done') {
+        // Fetch and render the completed result
+        _notifyResearchComplete(sessionId, data.query || '');
+        if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(sessionId);
+        const resultRes = await fetch(`${API_BASE}/api/research/result/${sessionId}`, { method: 'POST' });
+        if (resultRes.ok) {
+          const resultData = await resultRes.json();
+          if (resultData.result) {
+            // Skip if history already has a research message for this session
+            if (document.querySelector(`#chat-history .msg-ai[data-research-session="${sessionId}"]`)) return;
+
+            var srcBox = '';
+            if (resultData.sources && resultData.sources.length > 0) {
+              srcBox = _buildSourcesBox(resultData.sources, 'research');
+            }
+            var findingsBox = chatRenderer.buildFindingsBox(resultData.raw_findings);
+            var cleanResult = resultData.result;
+            // Build DOM directly to avoid double-processing through addMessage
+            chatRenderer.hideWelcomeScreen();
+            var _box = document.getElementById('chat-history');
+            if (_box) {
+              var _wrap = document.createElement('div');
+              _wrap.className = 'msg msg-ai';
+              _wrap.dataset.researchSession = sessionId;
+              var _role = document.createElement('div');
+              _role.className = 'role';
+              var _meta = sessionModule.getSessions().find(function(s) { return s.id === sessionId; });
+              _role.textContent = _shortModel(_meta?.model);
+              _applyModelColor(_role, _meta?.model);
+              _role.appendChild(chatRenderer.roleTimestamp());
+              var _body = document.createElement('div');
+              _body.className = 'body';
+              _body.innerHTML = srcBox + markdownModule.processWithThinking(
+                markdownModule.squashOutsideCode(cleanResult)
+              ) + findingsBox;
+              _wrap.dataset.raw = cleanResult;
+              _wrap.appendChild(_role);
+              _wrap.appendChild(_body);
+              _wrap.appendChild(chatRenderer.createMsgFooter(_wrap));
+              _appendViewReportLink(_wrap, sessionId);
+              _box.appendChild(_wrap);
+              if (window.hljs) _wrap.querySelectorAll('pre code').forEach(function(b) { window.hljs.highlightElement(b); });
+              uiModule.scrollHistory();
+            }
+          }
+        }
+        return;
+      }
+
+      if (data.status !== 'running') return;
+
+      // Don't show reconnect UI if we've already switched away
+      if (sessionModule.getCurrentSessionId() !== sessionId) return;
+
+      // Research is still running — show reconnect UI with spinner
+      const box = document.getElementById('chat-history');
+      if (!box) return;
+
+      const holder = document.createElement('div');
+      holder.className = 'msg msg-ai research-reconnect';
+      holder.dataset.researchSession = sessionId;
+      const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
+      const agentMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
+      const agentModelLabel = _shortModel(agentMeta?.model);
+      holder.innerHTML = `
${agentModelLabel} ${roleTs}
`; + _applyModelColor(holder.querySelector('.role'), agentMeta?.model); + box.appendChild(holder); + + const bodyDiv = holder.querySelector('.body'); + const spinner = spinnerModule.create('Reconnecting to research...', 'right'); + bodyDiv.appendChild(spinner.createElement()); + spinner.start(); + + // Update spinner with current progress if available + function updateSpinnerFromProgress(progress) { + if (!progress || !progress.phase) return; + const rp = progress; + if (rp.phase === 'probing') { + spinner.updateMessage(`Verifying model: ${rp.model || '?'}`); + } else if (rp.phase === 'planning') { + spinner.updateMessage('Analyzing question & planning research strategy'); + } else if (rp.phase === 'searching') { + const q = rp.queries ? `${rp.queries} queries` : ''; + const s = rp.total_sources ? ` · ${rp.total_sources} sources` : ''; + spinner.updateMessage(`Round ${rp.round || '?'}: Searching${q ? ' (' + q + ')' : ''}${s}`); + } else if (rp.phase === 'reading') { + spinner.updateMessage(rp.title ? `Reading: ${rp.title}` : `Round ${rp.round || '?'}: Reading ${rp.new_sources || ''} pages · ${rp.total_sources || 0} sources total`); + } else if (rp.phase === 'analyzing') { + spinner.updateMessage(`Round ${rp.round || '?'}: Analyzing ${rp.total_findings || 0} findings`); + } else if (rp.phase === 'writing') { + spinner.updateMessage(`Writing report · ${rp.total_sources || 0} sources`); + } + } + + updateSpinnerFromProgress(data.progress); + _researchingStreamIds.add(sessionId); + if (sessionModule && sessionModule.markResearching) sessionModule.markResearching(sessionId); + + // Restore research timer from started_at + if (data.started_at && spinner && spinner.element) { + _researchStartTime = data.started_at * 1000; + _researchAvgDuration = data.avg_duration || null; + _researchTimerEl = document.createElement('div'); + _researchTimerEl.className = 'research-timer'; + _researchTimerEl.style.cssText = 'font-size:0.8em; opacity:0.6; margin-top:4px; font-family:monospace;'; + spinner.element.parentNode.insertBefore(_researchTimerEl, spinner.element.nextSibling); + _researchTimerInterval = setInterval(() => { + if (!_researchTimerEl) return; + var elapsed = Math.floor((Date.now() - _researchStartTime) / 1000); + var mm = String(Math.floor(elapsed / 60)).padStart(2, '0'); + var ss = String(elapsed % 60).padStart(2, '0'); + var txt = mm + ':' + ss; + if (_researchAvgDuration) { + var avgM = String(Math.floor(_researchAvgDuration / 60)).padStart(2, '0'); + var avgS = String(Math.round(_researchAvgDuration % 60)).padStart(2, '0'); + txt += ' / avg ' + avgM + ':' + avgS; + } + _researchTimerEl.textContent = txt; + }, 1000); + // Reconnect synapse — seed it with whatever progress is already known + try { + _researchSynapse = createResearchSynapse(spinner.element.parentNode, { + query: data.query || '', + startedAt: _researchStartTime, + }); + if (_researchSynapse.element && _researchTimerEl) { + spinner.element.parentNode.insertBefore(_researchSynapse.element, _researchTimerEl); + } + if (data.progress) { + _researchSynapse.setPhase(data.progress.phase, data.progress); + if (typeof data.progress.round === 'number') _researchSynapse.setRound(data.progress.round); + if (typeof data.progress.total_sources === 'number') _researchSynapse.setSourceCount(data.progress.total_sources); + } + } catch (e) { console.warn('synapse reconnect failed', e); } + } + + // Poll for completion + const pollInterval = setInterval(async () => { + // Stop polling if user switched to a different session + if (sessionModule.getCurrentSessionId() !== sessionId) { + clearInterval(pollInterval); + spinner.destroy(); + _clearResearchTimer(); + if (holder.parentNode) holder.remove(); + _researchingStreamIds.delete(sessionId); + if (_researchingStreamIds.size === 0) { + var _rToggleP = document.getElementById('research-toggle-btn'); + if (_rToggleP) _rToggleP.classList.remove('research-running'); + } + return; + } + try { + const pollRes = await fetch(`${API_BASE}/api/research/status/${sessionId}`); + if (!pollRes.ok) { + clearInterval(pollInterval); + spinner.destroy(); + _clearResearchTimer(); + _researchingStreamIds.delete(sessionId); + if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(sessionId); + return; + } + const pollData = await pollRes.json(); + updateSpinnerFromProgress(pollData.progress); + if (_researchSynapse && pollData.progress) { + _researchSynapse.setPhase(pollData.progress.phase, pollData.progress); + if (typeof pollData.progress.round === 'number') _researchSynapse.setRound(pollData.progress.round); + if (typeof pollData.progress.total_sources === 'number') _researchSynapse.setSourceCount(pollData.progress.total_sources); + } + + if (pollData.status !== 'running') { + clearInterval(pollInterval); + spinner.destroy(); + _clearResearchTimer(); + _researchingStreamIds.delete(sessionId); + if (sessionModule && sessionModule.clearResearching) sessionModule.clearResearching(sessionId); + + if (pollData.status === 'done') { + _notifyResearchComplete(sessionId, data.query || ''); + const rRes = await fetch(`${API_BASE}/api/research/result/${sessionId}`, { method: 'POST' }); + if (rRes.ok) { + const rData = await rRes.json(); + if (rData.result) { + var srcHtml = ''; + if (rData.sources && rData.sources.length > 0) { + srcHtml = _buildSourcesBox(rData.sources, 'research'); + } + var findingsHtml = chatRenderer.buildFindingsBox(rData.raw_findings); + bodyDiv.innerHTML = srcHtml + markdownModule.processWithThinking( + markdownModule.squashOutsideCode(rData.result) + ) + findingsHtml; + holder.dataset.raw = rData.result; + _appendViewReportLink(holder, sessionId); + if (window.hljs) { + holder.querySelectorAll('pre code').forEach(b => window.hljs.highlightElement(b)); + } + } + } + } else { + bodyDiv.innerHTML = '[Research ' + pollData.status + ']'; + } + } + } catch (e) { + console.error('Research poll error:', e); + } + }, 2000); + } catch (e) { + // No research pending, that's fine + } + } + + /** Set a display override for the next user message bubble */ + export function setDisplayOverride(text) { + _displayOverride = text; + } + + /** Hide the user bubble for the next submit (e.g. continue after stop) */ + export function setHideUserBubble() { + _hideUserBubble = true; + } + + /** Set the AI element to merge with the next streamed response (continue after stop) */ + export function setPendingContinue(el) { + _pendingContinue = el; + } + + /** + * Delete an AI message and its preceding user message from the conversation. + */ + export async function deleteMessage(msgElement) { + const box = document.getElementById('chat-history'); + const allMsgs = Array.from(box.querySelectorAll('.msg')); + const clickedIndex = allMsgs.indexOf(msgElement); + if (clickedIndex < 0) return; + + const sessionId = sessionModule.getCurrentSessionId(); + if (!sessionId) return; + + const clickedIsUser = msgElement.classList.contains('msg-user'); + + // Find the user+AI pair + let userIndex = -1; + let aiIndex = -1; + if (clickedIsUser) { + userIndex = clickedIndex; + // Find the following AI message + for (let i = clickedIndex + 1; i < allMsgs.length; i++) { + if (allMsgs[i].classList.contains('msg-ai') && !allMsgs[i].classList.contains('msg-continuation')) { + aiIndex = i; + break; + } + if (allMsgs[i].classList.contains('msg-user')) break; // next user msg, no AI response + } + } else { + // If clicked on a continuation, walk back to the main AI message + let mainAiIndex = clickedIndex; + if (allMsgs[mainAiIndex].classList.contains('msg-continuation')) { + for (let i = mainAiIndex - 1; i >= 0; i--) { + if (allMsgs[i].classList.contains('msg-ai') && !allMsgs[i].classList.contains('msg-continuation')) { + mainAiIndex = i; + break; + } + } + } + aiIndex = mainAiIndex; + // Find the preceding user message + for (let i = aiIndex - 1; i >= 0; i--) { + if (allMsgs[i].classList.contains('msg-user')) { + userIndex = i; + break; + } + } + } + + // Collect DB message IDs and DOM elements to remove + const msgIds = []; + const domToRemove = []; + + // Add the user message if found + if (userIndex >= 0) { + domToRemove.push(allMsgs[userIndex]); + const uid = allMsgs[userIndex].dataset.dbId; + if (uid) msgIds.push(uid); + } + + // Add the AI message if found + if (aiIndex >= 0) { + domToRemove.push(allMsgs[aiIndex]); + const aid = allMsgs[aiIndex].dataset.dbId; + if (aid) msgIds.push(aid); + + const aiEl = allMsgs[aiIndex]; + // Also remove agent-thread elements BETWEEN user and AI + if (userIndex >= 0) { + let between = allMsgs[userIndex].nextElementSibling; + while (between && between !== aiEl) { + domToRemove.push(between); + between = between.nextElementSibling; + } + } + // Walk forward from the AI element to remove continuations and tool bubbles + let sibling = aiEl.nextElementSibling; + while (sibling) { + if (sibling.classList.contains('msg-user') || + (sibling.classList.contains('msg-ai') && !sibling.classList.contains('msg-continuation'))) { + break; + } + domToRemove.push(sibling); + sibling = sibling.nextElementSibling; + } + } + + if (!msgIds.length) { + // Fallback: just remove DOM elements if no DB IDs available + domToRemove.forEach(el => el.remove()); + if (uiModule) uiModule.showToast('Message deleted'); + return; + } + + try { + const res = await fetch(`${API_BASE}/api/session/${sessionId}/delete-messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ msg_ids: msgIds }) + }); + if (!res.ok) throw new Error('Server error ' + res.status); + domToRemove.forEach(el => el.remove()); + if (uiModule) uiModule.showToast('Message deleted'); + } catch (err) { + console.error('Delete failed:', err); + if (uiModule) uiModule.showError('Delete failed: ' + err.message); + } + } + + /** + * Edit an AI message inline. Makes the body contentEditable, saves to DB on confirm. + */ + export async function editAIMessage(msgElement) { + const body = msgElement.querySelector('.body'); + if (!body) return; + + const isEditing = body.contentEditable === 'true' || body.contentEditable === 'plaintext-only'; + if (isEditing) return; // already editing + + const originalRaw = msgElement.dataset.raw || body.textContent || ''; + + // Create editable textarea overlay + const textarea = document.createElement('textarea'); + textarea.className = 'msg-edit-textarea'; + textarea.value = originalRaw; + textarea.style.width = '100%'; + textarea.style.minHeight = Math.max(100, body.offsetHeight) + 'px'; + body.style.display = 'none'; + body.parentNode.insertBefore(textarea, body.nextSibling); + textarea.focus(); + + // Add save/cancel bar + const bar = document.createElement('div'); + bar.className = 'msg-edit-bar'; + const saveBtn = document.createElement('button'); + saveBtn.className = 'msg-edit-save'; + saveBtn.textContent = 'Save'; + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'msg-edit-cancel'; + cancelBtn.textContent = 'Cancel'; + bar.appendChild(saveBtn); + bar.appendChild(cancelBtn); + textarea.parentNode.insertBefore(bar, textarea.nextSibling); + + function cleanup() { + textarea.remove(); + bar.remove(); + body.style.display = ''; + } + + cancelBtn.addEventListener('click', (e) => { + e.stopPropagation(); + cleanup(); + }); + + saveBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + const newContent = textarea.value; + if (newContent === originalRaw) { cleanup(); return; } + + const msgId = msgElement.dataset.dbId; + if (!msgId) { if (uiModule) uiModule.showError('Cannot edit: message ID not found'); cleanup(); return; } + + const sessionId = sessionModule.getCurrentSessionId(); + if (!sessionId) { cleanup(); return; } + + try { + const res = await fetch(`${API_BASE}/api/session/${sessionId}/edit-message`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ msg_id: msgId, content: newContent }), + }); + if (!res.ok) throw new Error('Server error ' + res.status); + + // Re-render body with markdown + body.innerHTML = markdownModule.processWithThinking(markdownModule.squashOutsideCode(newContent)); + msgElement.dataset.raw = newContent; + + // Add edited indicator if not already present + if (!msgElement.querySelector('.edited-indicator')) { + const indicator = document.createElement('div'); + indicator.className = 'edited-indicator'; + indicator.textContent = '[Message edited]'; + body.parentNode.insertBefore(indicator, body.nextSibling); + } + + cleanup(); + if (uiModule) uiModule.showToast('Message edited'); + } catch (err) { + console.error('Edit failed:', err); + if (uiModule) uiModule.showError('Edit failed: ' + err.message); + } + }); + } + + /** + * Rewrite the AI's last response with a specific instruction. + * Uses the lightweight /api/rewrite endpoint — no tools, no agent loop. + * Just rewrites the text of the last AI bubble. + */ + export async function rewriteWith(aiMsgElement, instruction) { + const sessionId = sessionModule.getCurrentSessionId(); + if (!sessionId) return; + + // Get the original text from the AI bubble + const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || ''; + const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || ''; + + if (!oldRaw.trim()) { + if (uiModule) uiModule.showError('No text to rewrite'); + return; + } + + // Save current response as a variant + let variants = []; + try { variants = JSON.parse(aiMsgElement.dataset.variants || '[]'); } catch(_) {} + if (variants.length === 0) { + variants.push({ raw: oldRaw, html: oldHtml, label: 'original' }); + } + + // Determine label from instruction + let varLabel = 'rewrite'; + if (instruction.includes('shorter')) varLabel = 'shorter'; + else if (instruction.includes('simpler')) varLabel = 'simpler'; + + // Clear the bubble and show a whirlpool spinner while we wait for the + // rewrite (replaces the old "Rewriting..." text). + const bodyEl = aiMsgElement.querySelector('.body'); + let _rwSpin = null; + if (bodyEl) { + bodyEl.innerHTML = ''; + _rwSpin = spinnerModule.createWhirlpool(18); + _rwSpin.element.style.margin = '4px 0'; + bodyEl.appendChild(_rwSpin.element); + } + // Stop + detach the spinner (called once real content starts rendering, and + // on the failure path so it never spins forever). + const _killRwSpin = () => { if (_rwSpin) { try { _rwSpin.destroy(); } catch (_) {} _rwSpin = null; } }; + + try { + const res = await fetch(`${API_BASE}/api/rewrite`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: sessionId, + original_text: oldRaw, + instruction: instruction, + }), + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let newText = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const payload = line.slice(6).trim(); + if (payload === '[DONE]') continue; + try { + const data = JSON.parse(payload); + // The endpoint streams `event: error\ndata: {error,status}` on + // failure — surface it instead of silently hanging on "Rewriting…". + if (data.error) { + throw new Error(data.error || ('HTTP ' + (data.status || 500))); + } + // Reasoning tokens (vLLM --reasoning-parser: Qwen3 / DeepSeek-R1) + // arrive as separate {delta, thinking:true} chunks. They are NOT + // the rewrite — fold them away so they don't pollute the result. + if (data.thinking) continue; + if (data.delta) { + newText += data.delta; + _killRwSpin(); + if (bodyEl) { + bodyEl.innerHTML = markdownModule.processWithThinking( + markdownModule.squashOutsideCode(newText) + ); + } + } + } catch (e) { + if (e instanceof Error && e.message) throw e; // re-throw real errors + /* ignore JSON parse noise */ + } + } + } + + // Strip any thinking markup from the answer. A reasoning model may emit + // an inline block, a bare (no opener), or — when + // its reasoning came via reasoning_content — a stray leading that + // never closes (so it would otherwise hide the whole answer). Peel all of + // those off so what's left is just the rewritten text. + const _stripThink = (t) => { + t = t.replace(/[\s\S]*?<\/think>/gi, ''); // complete blocks + if (/<\/think>/i.test(t)) t = t.replace(/^[\s\S]*?<\/think>/i, ''); // reasoning w/o opener + return t.replace(/<\/?think>/gi, '').trim(); // any orphan tag + }; + newText = _stripThink(newText); + + // Nothing left after stripping (or an empty stream) → real failure, not a + // blank bubble. + if (!newText.trim()) { + throw new Error('model returned no rewritten text'); + } + + // Update the element's raw text + if (newText) { + aiMsgElement.dataset.raw = newText; + // Final render with proper markdown + if (bodyEl) { + bodyEl.innerHTML = markdownModule.processWithThinking( + markdownModule.squashOutsideCode(newText) + ); + } + + // Save the new response as a variant + variants.push({ raw: newText, html: bodyEl ? bodyEl.innerHTML : '', label: varLabel }); + aiMsgElement.dataset.variants = JSON.stringify(variants); + aiMsgElement.dataset.variantIndex = String(variants.length - 1); + + // Persist variant metadata to server + try { + await fetch(`${API_BASE}/api/session/${sessionId}/update-last-meta`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ metadata: { variants: variants, variantIndex: variants.length - 1 } }), + }); + } catch (_) {} + + // Re-render variant navigation + _renderVariantNav(aiMsgElement, variants, variants.length - 1); + } + + if (uiModule) uiModule.scrollHistory(); + + } catch (err) { + console.error('Rewrite failed:', err); + _killRwSpin(); + // Restore original content on failure + if (bodyEl) bodyEl.innerHTML = oldHtml; + if (uiModule) uiModule.showError('Rewrite failed: ' + err.message); + } + } + + /** + * Continue the AI's response from where it left off. + */ + export async function continueFrom(aiMsgElement) { + const sessionId = sessionModule.getCurrentSessionId(); + if (!sessionId) return; + + const messageInput = uiModule.el('message'); + if (messageInput) { + messageInput.value = 'Continue from where you left off.'; + const submitBtn = document.querySelector('.send-btn'); + if (submitBtn) submitBtn.click(); + } + } + + // Open a chat attachment in the right place: images → Gallery editor; PDFs & + // text/code/markdown → Documents viewer; anything else → raw file. A given + // upload's imported document is reused (cached by upload id) so clicking it + // again re-opens the same doc instead of making duplicates. + const _attachDocCache = new Map(); // upload id -> doc id + function _attachLang(name) { + const m = (name || '').toLowerCase().match(/\.([a-z0-9]+)$/); + const ext = m ? m[1] : ''; + const map = { md:'markdown', markdown:'markdown', js:'javascript', ts:'typescript', + jsx:'javascript', tsx:'typescript', py:'python', rb:'ruby', go:'go', rs:'rust', + java:'java', c:'c', cpp:'cpp', h:'c', hpp:'cpp', cs:'csharp', php:'php', html:'html', + htm:'html', css:'css', scss:'scss', json:'json', yaml:'yaml', yml:'yaml', sh:'bash', + bash:'bash', sql:'sql', csv:'csv', xml:'xml' }; + return map[ext] || ''; + } + async function openAttachment(att, isImage) { + if (!att || !att.id) return; + const id = att.id, name = att.name || '', mime = att.mime || ''; + const url = `${API_BASE}/api/upload/${id}`; + + // Images → Gallery editor. + if (isImage) { + try { + const gx = await import('./galleryEditor.js'); + if (gx.openEditor) { gx.openEditor(url, id, null, name); return; } + } catch (e) { console.warn('gallery open failed', e); } + window.open(url, '_blank'); + return; + } + + const isPdf = mime === 'application/pdf' || /\.pdf$/i.test(name); + const TEXT_EXT = /\.(txt|md|markdown|js|ts|jsx|tsx|py|rb|go|rs|java|c|cpp|h|hpp|cs|php|html?|css|scss|sass|less|json|ya?ml|toml|ini|conf|env|sh|bash|sql|csv|tsv|xml|log|vue|svelte)$/i; + const isTextDoc = TEXT_EXT.test(name) || /^text\//.test(mime); + if (!isPdf && !isTextDoc) { window.open(url, '_blank'); return; } // binary/unknown → raw + + // Reuse the doc we already imported for this upload, if it still loads. + const cached = _attachDocCache.get(id); + if (cached) { + try { + documentModule.openPanel && documentModule.openPanel(); + await documentModule.loadDocument(cached); + return; + } catch (_) { _attachDocCache.delete(id); } + } + + // Need a session to attach the doc to (bare-session fallback, same as compose). + let sid = ''; + try { sid = sessionModule.getCurrentSessionId() || ''; } catch (_) {} + if (!sid) { + try { + const _fd = new FormData(); + _fd.append('name', name || 'Attachment'); + _fd.append('skip_validation', 'true'); + const r = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: _fd, credentials: 'same-origin' }); + if (r.ok) { const d = await r.json(); if (d && d.id) { sid = d.id; if (sessionModule.loadSessions) await sessionModule.loadSessions(); } } + } catch (_) {} + } + + try { + let doc; + if (isPdf) { + // import-pdf wants a fresh file upload — re-fetch the stored blob and post it. + const blob = await (await fetch(url)).blob(); + const fd = new FormData(); + fd.append('file', blob, name || 'document.pdf'); + if (sid) fd.append('session_id', sid); + const res = await fetch(`${API_BASE}/api/documents/import-pdf`, { method: 'POST', body: fd, credentials: 'same-origin' }); + if (!res.ok) throw new Error('import-pdf ' + res.status); + doc = await res.json(); + } else { + const text = await (await fetch(url)).text(); + const res = await fetch(`${API_BASE}/api/document`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sid || null, title: name.replace(/\.[^.]+$/, '') || 'Document', content: text, language: _attachLang(name) }), + }); + if (!res.ok) throw new Error('document ' + res.status); + doc = await res.json(); + } + if (doc && doc.id) { + _attachDocCache.set(id, doc.id); + documentModule.openPanel && documentModule.openPanel(); + if (documentModule.injectFreshDoc) documentModule.injectFreshDoc(doc); + else await documentModule.loadDocument(doc.id); + } + } catch (e) { + console.error('open attachment as document failed', e); + import('./ui.js').then(m => m.showError && m.showError('Could not open attachment')).catch(() => {}); + window.open(url, '_blank'); // fallback so the file is still reachable + } + } + + // Public API + const chatModule = { + init, + initListeners, + openAttachment, + addMessage: chatRenderer.addMessage, + displayMetrics: chatRenderer.displayMetrics, + handleChatSubmit, + abortCurrentRequest, + detachCurrentStream, + checkBackgroundStream, + hideWelcomeScreen: chatRenderer.hideWelcomeScreen, + showWelcomeScreen: chatRenderer.showWelcomeScreen, + checkPendingResearch, + getImageCost: chatRenderer.getImageCost, + setDisplayOverride, + setHideUserBubble, + setPendingContinue, + regenerateFrom, + forkFrom, + editUserMessage, + editAIMessage, + resendUserMessage, + deleteMessage, + rewriteWith, + continueFrom, + _appendViewReportLink, + hasActiveStream, + }; + + // Single delegated handler for tool-call fold/expand. One listener on + // document.body covers every .agent-thread-node — running, completed, + // streaming, history-rendered, compare-mode, all of them. Re-attaching + // per-node listeners on every innerHTML rewrite was the source of the + // "needs many clicks" bug. + if (!window.__odysseus_thread_click_bound) { + document.body.addEventListener('click', (e) => { + const header = e.target.closest('.agent-thread-header'); + if (!header) return; + const node = header.closest('.agent-thread-node'); + if (!node) return; + node.classList.toggle('open'); + }); + window.__odysseus_thread_click_bound = true; + } + + export default chatModule; + window.chatModule = chatModule; diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js new file mode 100644 index 0000000..73b2d99 --- /dev/null +++ b/static/js/chatRenderer.js @@ -0,0 +1,2303 @@ +// static/js/chatRenderer.js +// Extracted from chat.js — message rendering, sources, images, metrics + +import uiModule from './ui.js'; +import markdownModule from './markdown.js'; +import { addAITTSButton } from './tts-ai.js'; +import { providerLogo } from './providers.js'; +import settingsModule from './settings.js'; +import spinnerModule from './spinner.js'; + +const SEARCH_ICON = ''; +const REPORT_ICON = ''; +const CHAT_ABOUT_ICON = ''; +const COPY_ICON = ''; +const CHECK_ICON = ''; + +/** Sanitize a URL for use in href — only allow http(s) and protocol-relative. */ +function _safeHref(url) { + if (!url) return '#'; + try { + var parsed = new URL(url, window.location.origin); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return uiModule.esc(url); + } catch(e) { /* invalid URL */ } + return '#'; +} + +function _makeActionBtn(className, title, text, handler) { + const btn = document.createElement('button'); + btn.className = className; + btn.type = 'button'; + btn.title = title; + btn.textContent = text; + btn.addEventListener('click', handler); + return btn; +} + +// Attachment card helpers +function _attachIcon(mimeOrName) { + const s = (mimeOrName || '').toLowerCase(); + if (s.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(s)) + return ''; + if (s.startsWith('audio/') || /\.(mp3|wav|ogg|m4a|webm)$/i.test(s)) + return ''; + if (s === 'application/pdf' || /\.pdf$/i.test(s)) + return ''; + // Default: generic document + return ''; +} +function _formatSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / 1048576).toFixed(1) + ' MB'; +} + +// Build the `.attach-cards` element for a message's attachment list. Shared by +// addMessage and updateMessageAttachments so a live (optimistic) user bubble +// can be re-rendered with real upload ids once the upload resolves. +function buildAttachCards(attachments) { + const attachWrap = document.createElement('div'); + attachWrap.className = 'attach-cards'; + for (const att of attachments) { + const isImage = (att.mime || '').startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(att.name || ''); + if (isImage) { + // Image preview. Shown for both uploaded (att.id present) and still- + // uploading attachments. A shimmering skeleton + whirlpool fills the + // space until either the upload resolves (no id yet) or the thumbnail + // image finishes loading, so the photo doesn't pop in abruptly. + const imgWrap = document.createElement('div'); + imgWrap.className = 'attach-image-preview'; + imgWrap.style.cursor = att.id ? 'zoom-in' : 'default'; + if (att.id) imgWrap.dataset.fileId = att.id; + if (att.id) { + imgWrap.addEventListener('click', (e) => { + // Tapping the corner OCR button shouldn't also open the lightbox. + if (e.target.closest('.attach-ocr-btn')) return; + _openImageLightbox(att); + }); + } + + let skel = null; + let sp = null; + if (!att.previewUrl) { + // Skeleton placeholder with a centered whirlpool. Self-stops when removed. + skel = document.createElement('div'); + skel.className = 'attach-image-skeleton'; + // Match the photo's aspect ratio when the backend knew it at upload + // time, so the skeleton doesn't sit at a 4:3 default and then snap to + // a portrait shape when the image arrives. + if (att.width && att.height) { + skel.style.aspectRatio = att.width + ' / ' + att.height; + skel.style.width = 'auto'; + skel.style.height = 'auto'; + skel.style.maxWidth = '300px'; + skel.style.maxHeight = '200px'; + skel.style.minWidth = '80px'; + } + sp = spinnerModule.createWhirlpool(20); + skel.appendChild(sp.element); + imgWrap.appendChild(skel); + } + + if (att.id || att.previewUrl) { + const img = document.createElement('img'); + // Small cached thumbnail — the preview is tiny, no need to pull the + // full-resolution photo. Click still opens the full image. + img.alt = att.name || 'Image'; + img.loading = 'lazy'; + img.style.cssText = 'max-width:300px;max-height:200px;border-radius:6px;display:' + (att.previewUrl ? 'block' : 'none') + ';'; + let _revealed = false; + let _revealTimer = null; + const _reveal = () => { + if (_revealed) return; + _revealed = true; + if (_revealTimer) { clearTimeout(_revealTimer); _revealTimer = null; } + img.style.display = 'block'; + try { sp && sp.stop(); } catch {} + if (skel) skel.remove(); + }; + img.addEventListener('load', _reveal); + img.addEventListener('error', _reveal); + img.src = att.previewUrl || `/api/upload/${att.id}?thumb=1`; + // Cached images can be complete before the load listener attaches. + if (img.complete && img.naturalWidth) _reveal(); + // Failsafe: if neither load nor error fires within 8s, reveal anyway. + // The timer is cleared on reveal AND when updateMessageAttachments + // replaces the card (which scrubs the img / skel from the DOM), so + // repeated re-renders don't accumulate stranded timers. + if (!att.previewUrl) _revealTimer = setTimeout(_reveal, 8000); + imgWrap.appendChild(img); + + if (att.id) { + // Small corner button → opens the vision/OCR editor so the user can + // correct what the vision model extracted. The edit is cached on the + // server keyed by file id, so any later message referencing this same + // image picks up the corrected text instead of re-running the model. + const ocrBtn = document.createElement('button'); + ocrBtn.type = 'button'; + ocrBtn.className = 'attach-ocr-btn'; + ocrBtn.title = 'View / edit OCR text'; + ocrBtn.innerHTML = 'Caption'; + ocrBtn.addEventListener('click', (e) => { + e.stopPropagation(); + _openVisionEditor(att, ocrBtn.closest('.msg')); + }); + imgWrap.appendChild(ocrBtn); + } + } + + if (att.vision_model) { + const visionLabel = document.createElement('div'); + visionLabel.className = 'attach-vision-model'; + visionLabel.textContent = 'Vision: ' + String(att.vision_model).split('/').pop(); + imgWrap.appendChild(visionLabel); + } + if (att.name) { + const label = document.createElement('div'); + label.className = 'attach-image-name'; + label.textContent = att.name; + imgWrap.appendChild(label); + } + attachWrap.appendChild(imgWrap); + } else { + // Non-image file card + const card = document.createElement('div'); + card.className = 'attach-card'; + card.dataset.name = att.name; + if (att.id) { + card.dataset.fileId = att.id; + card.style.cursor = 'pointer'; + card.addEventListener('click', () => { + // PDFs & text/code/markdown → open in the Documents viewer + // (others fall back to the raw file). + if (window.chatModule?.openAttachment) window.chatModule.openAttachment(att, false); + else window.open(`/api/upload/${att.id}`, '_blank'); + }); + } + const icon = _attachIcon(att.mime || att.name); + const nameSpan = document.createElement('span'); + nameSpan.className = 'attach-card-name'; + nameSpan.textContent = att.name; + card.innerHTML = icon; + card.appendChild(nameSpan); + if (att.size) { + const sizeSpan = document.createElement('span'); + sizeSpan.className = 'attach-card-size'; + sizeSpan.textContent = _formatSize(att.size); + card.appendChild(sizeSpan); + } + attachWrap.appendChild(card); + } + } + return attachWrap; +} + +// Re-render the attachment cards of an already-rendered message. Used to swap +// in real upload ids (and image thumbnails) on the optimistic user bubble once +// uploadPending() resolves — otherwise image previews only appear after a +// refresh, because the bubble is rendered before the upload assigns ids. +export function updateMessageAttachments(msgWrap, attachments) { + if (!msgWrap || !attachments?.length) return; + const body = msgWrap.querySelector('.body') || msgWrap; + const existing = body.querySelector('.attach-cards'); + const fresh = buildAttachCards(attachments); + if (existing) existing.replaceWith(fresh); + else body.appendChild(fresh); +} + +// Quick full-size preview when the user taps a chat photo thumbnail. Just an +// overlay with the original image centered — no Gallery panel, no editor. +function _openImageLightbox(att) { + if (!att?.id) return; + const overlay = document.createElement('div'); + overlay.className = 'attach-lightbox'; + // Show the cached thumb immediately so the overlay doesn't sit blank + // while a 25MB original streams in. The full image swaps in once loaded; + // if the full load fails (404 / network), we keep the thumb + show an + // error label rather than a blank overlay forever. + const img = document.createElement('img'); + img.alt = att.name || ''; + img.src = `/api/upload/${att.id}?thumb=1`; + overlay.appendChild(img); + const full = new Image(); + full.addEventListener('load', () => { img.src = full.src; }); + full.addEventListener('error', () => { + const err = document.createElement('div'); + err.className = 'attach-lightbox-err'; + err.textContent = 'Failed to load full-resolution image.'; + overlay.appendChild(err); + }); + full.src = `/api/upload/${att.id}`; + + const _onKey = (e) => { if (e.key === 'Escape') _close(); }; + const _close = () => { + document.removeEventListener('keydown', _onKey); + if (_overlayObs) { try { _overlayObs.disconnect(); } catch {} } + overlay.remove(); + }; + // If the overlay is removed via any path other than our close handler + // (session switch, parent re-render, external cleanup), still drop the + // document-level keydown listener so it doesn't leak. + let _overlayObs = null; + try { + _overlayObs = new MutationObserver(() => { + if (!document.body.contains(overlay)) { + document.removeEventListener('keydown', _onKey); + _overlayObs.disconnect(); + } + }); + _overlayObs.observe(document.body, { childList: true, subtree: false }); + } catch {} + overlay.addEventListener('click', _close); + document.addEventListener('keydown', _onKey); + document.body.appendChild(overlay); +} + +// Vision/OCR editor modal — opened from the corner "Aa" button on a chat photo +// thumbnail. Lets the user view and correct the text the vision model fed to +// the LLM (e.g. when OCR misreads a word). Persists to the server's vision +// cache (PUT /api/upload/{id}/vision), so any subsequent message that +// references the same file picks up the corrected text. +let _visionEditorEl = null; +let _visionEditorEsc = null; +function _closeVisionEditor() { + if (_visionEditorEsc) { document.removeEventListener('keydown', _visionEditorEsc); _visionEditorEsc = null; } + if (_visionEditorEl) { _visionEditorEl.remove(); _visionEditorEl = null; } +} +function _openVisionEditor(att, userMsgEl) { + if (!att?.id) return; + _closeVisionEditor(); + const overlay = document.createElement('div'); + overlay.className = 'vision-editor-overlay'; + overlay.addEventListener('click', (e) => { if (e.target === overlay) _closeVisionEditor(); }); + const panel = document.createElement('div'); + panel.className = 'vision-editor-panel'; + const title = document.createElement('div'); + title.className = 'vision-editor-title'; + // Eye icon matches the one in Settings → Vision so users recognise where + // this text originates. + title.innerHTML = 'Vision text'; + panel.appendChild(title); + const desc = document.createElement('div'); + desc.className = 'vision-editor-desc'; + desc.textContent = 'Edit text and save, new chats will have the new context. Regenerate or continue from there.'; + panel.appendChild(desc); + const ta = document.createElement('textarea'); + ta.className = 'vision-editor-text'; + ta.rows = 10; + ta.placeholder = 'Loading…'; + ta.disabled = true; + panel.appendChild(ta); + const actions = document.createElement('div'); + actions.className = 'vision-editor-actions'; + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'vision-editor-btn'; + closeBtn.innerHTML = 'Close'; + closeBtn.addEventListener('click', _closeVisionEditor); + const _saveVisionText = async () => { + const res = await fetch(`/api/upload/${att.id}/vision`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ text: ta.value }), + }); + if (!res.ok) throw new Error('save failed'); + }; + const saveBtn = document.createElement('button'); + saveBtn.type = 'button'; + saveBtn.className = 'vision-editor-btn vision-editor-btn-primary'; + saveBtn.innerHTML = 'Save'; + saveBtn.disabled = true; + saveBtn.addEventListener('click', async () => { + saveBtn.disabled = true; + saveBtn.innerHTML = 'Saving…'; + try { + await _saveVisionText(); + if (uiModule?.showToast) uiModule.showToast('Saved'); + _closeVisionEditor(); + } catch (e) { + saveBtn.disabled = false; + saveBtn.innerHTML = 'Save'; + if (uiModule?.showError) uiModule.showError('Failed to save OCR text'); + } + }); + // Regenerate-message: save the edited text, close, then trigger a resend of + // the user message so the new AI reply uses the edit immediately. + const regenBtn = document.createElement('button'); + regenBtn.type = 'button'; + regenBtn.className = 'vision-editor-btn vision-editor-btn-primary'; + regenBtn.title = 'Save and regenerate the message'; + regenBtn.innerHTML = 'Regenerate message'; + regenBtn.disabled = true; + regenBtn.addEventListener('click', async () => { + regenBtn.disabled = true; + saveBtn.disabled = true; + try { + await _saveVisionText(); + _closeVisionEditor(); + if (userMsgEl && window.chatModule?.resendUserMessage) { + window.chatModule.resendUserMessage(userMsgEl); + } else if (uiModule?.showToast) { + uiModule.showToast('Saved'); + } + } catch (e) { + regenBtn.disabled = false; + saveBtn.disabled = false; + if (uiModule?.showError) uiModule.showError('Failed to save OCR text'); + } + }); + actions.appendChild(closeBtn); + actions.appendChild(saveBtn); + actions.appendChild(regenBtn); + panel.appendChild(actions); + overlay.appendChild(panel); + document.body.appendChild(overlay); + _visionEditorEl = overlay; + + // ESC closes the popup. Registered on document so it works regardless of + // focus (the textarea swallows the event otherwise). + _visionEditorEsc = (e) => { if (e.key === 'Escape') _closeVisionEditor(); }; + document.addEventListener('keydown', _visionEditorEsc); + + fetch(`/api/upload/${att.id}/vision`, { credentials: 'same-origin' }) + .then(r => r.ok ? r.json() : Promise.reject(r)) + .then(data => { + ta.value = data.text || ''; + ta.placeholder = ''; + ta.disabled = false; + saveBtn.disabled = false; + regenBtn.disabled = !userMsgEl; + ta.focus(); + }) + .catch(() => { + ta.value = ''; + ta.placeholder = 'Could not load OCR text — type your correction and save.'; + ta.disabled = false; + saveBtn.disabled = false; + regenBtn.disabled = !userMsgEl; + }); +} + +// Tool call syntax patterns to strip from displayed text +const TOOL_CALL_RE = /\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/gi; +// Only strip fenced tool-call blocks that look like structured invocations, not regular code examples +const EXEC_FENCE_RE = /```(?:web_search|read_file|write_file|create_document|edit_document|update_document)\s*\n[\s\S]*?```/gi; +// XML-style tool calls: , , , bare +const XML_TOOL_CALL_RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi; +const XML_INVOKE_RE = /[\s\S]*?<\/invoke>/gi; +// DeepSeek "DSML" tool-call markup (fullwidth-pipe | or ascii | delimited) that +// leaks into content when the model emits a text tool call instead of a native +// one. Strip the whole block; the second pattern catches stray/partial tags +// (e.g. mid-stream before the closing tag arrives). +const DSML_TOOL_RE = /<\s*[||]+\s*DSML\s*[||]+\s*tool_calls\s*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*tool_calls\s*>|$)/gi; +const DSML_STRAY_RE = /<\s*\/?\s*[||]+\s*DSML\s*[||]+[^>]*>/gi; +// Self-narration about tool results (model echoing stdout/exit_code) +const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi; + + +// Model pricing table — per million tokens +// Model info: pricing (per 1M tokens) + context window length +const MODEL_INFO = { + // --- Anthropic --- + 'claude-sonnet-4-5': { input: 3.00, output: 15.00, ctx: 200000 }, + 'claude-sonnet-4-6': { input: 3.00, output: 15.00, ctx: 200000 }, + 'claude-sonnet-4': { input: 3.00, output: 15.00, ctx: 200000 }, + 'claude-opus-4': { input: 15.00, output: 75.00, ctx: 200000 }, + 'claude-opus-4-6': { input: 15.00, output: 75.00, ctx: 200000 }, + 'claude-haiku-4': { input: 0.80, output: 4.00, ctx: 200000 }, + 'claude-haiku-3-5': { input: 0.80, output: 4.00, ctx: 200000 }, + 'claude-3-5-sonnet': { input: 3.00, output: 15.00, ctx: 200000 }, + 'claude-3-5-haiku': { input: 0.80, output: 4.00, ctx: 200000 }, + 'claude-3-opus': { input: 15.00, output: 75.00, ctx: 200000 }, + 'claude-3-sonnet': { input: 3.00, output: 15.00, ctx: 200000 }, + 'claude-3-haiku': { input: 0.25, output: 1.25, ctx: 200000 }, + // --- OpenAI --- + 'gpt-5': { input: 2.00, output: 8.00, ctx: 400000 }, + 'gpt-4.1': { input: 2.00, output: 8.00, ctx: 1047576 }, + 'gpt-4.1-mini': { input: 0.40, output: 1.60, ctx: 1047576 }, + 'gpt-4.1-nano': { input: 0.10, output: 0.40, ctx: 1047576 }, + 'gpt-4o': { input: 2.50, output: 10.00, ctx: 128000 }, + 'gpt-4o-mini': { input: 0.15, output: 0.60, ctx: 128000 }, + 'gpt-4-turbo': { input: 10.00, output: 30.00, ctx: 128000 }, + 'o1': { input: 15.00, output: 60.00, ctx: 200000 }, + 'o1-mini': { input: 3.00, output: 12.00, ctx: 128000 }, + 'o1-pro': { input: 150.0, output: 600.0, ctx: 200000 }, + 'o3': { input: 2.00, output: 8.00, ctx: 200000 }, + 'o3-mini': { input: 1.10, output: 4.40, ctx: 200000 }, + 'o4-mini': { input: 1.10, output: 4.40, ctx: 200000 }, + // --- DeepSeek --- + 'deepseek-chat': { input: 0.27, output: 1.10, ctx: 64000 }, + 'deepseek-coder': { input: 0.27, output: 1.10, ctx: 64000 }, + 'deepseek-reasoner': { input: 0.55, output: 2.19, ctx: 64000 }, + 'deepseek-r1': { input: 0.55, output: 2.19, ctx: 64000 }, + 'deepseek-v3': { input: 0.27, output: 1.10, ctx: 64000 }, + 'deepseek-v2': { input: 0.14, output: 0.28, ctx: 64000 }, + // --- Google --- + 'gemini-2.5-pro': { input: 1.25, output: 10.00, ctx: 1048576 }, + 'gemini-2.5-flash': { input: 0.15, output: 0.60, ctx: 1048576 }, + 'gemini-2.0-flash': { input: 0.10, output: 0.40, ctx: 1048576 }, + 'gemini-1.5-pro': { input: 1.25, output: 5.00, ctx: 1048576 }, + 'gemini-1.5-flash': { input: 0.075, output: 0.30, ctx: 1048576 }, + 'gemma-3': { input: 0.10, output: 0.10, ctx: 128000 }, + // --- Mistral --- + 'mistral-large': { input: 2.00, output: 6.00, ctx: 128000 }, + 'mistral-medium': { input: 2.00, output: 6.00, ctx: 32000 }, + 'mistral-small': { input: 0.20, output: 0.60, ctx: 32000 }, + 'mistral-nemo': { input: 0.15, output: 0.15, ctx: 128000 }, + 'mixtral': { input: 0.24, output: 0.24, ctx: 32000 }, + 'codestral': { input: 0.30, output: 0.90, ctx: 32000 }, + 'pixtral': { input: 2.00, output: 6.00, ctx: 128000 }, + // --- xAI --- + 'grok-4': { input: 3.00, output: 15.00, ctx: 131072 }, + 'grok-3': { input: 3.00, output: 15.00, ctx: 131072 }, + 'grok-2': { input: 2.00, output: 10.00, ctx: 131072 }, + // --- Meta --- + 'llama-4': { input: 0.20, output: 0.20, ctx: 1048576 }, + 'llama-3.3': { input: 0.20, output: 0.20, ctx: 131072 }, + 'llama-3.2': { input: 0.20, output: 0.20, ctx: 131072 }, + 'llama-3.1': { input: 0.20, output: 0.20, ctx: 131072 }, + 'llama-3': { input: 0.20, output: 0.20, ctx: 131072 }, + // --- Qwen --- + 'qwen3': { input: 0.30, output: 1.20, ctx: 131072 }, + 'qwen2.5': { input: 0.30, output: 1.20, ctx: 131072 }, + 'qwq': { input: 0.30, output: 1.20, ctx: 32768 }, + // --- Cohere --- + 'command-a': { input: 2.50, output: 10.00, ctx: 256000 }, + 'command-r-plus': { input: 2.50, output: 10.00, ctx: 128000 }, + 'command-r': { input: 0.15, output: 0.60, ctx: 128000 }, + // --- Perplexity --- + 'sonar-pro': { input: 3.00, output: 15.00, ctx: 200000 }, + 'sonar': { input: 1.00, output: 1.00, ctx: 128000 }, + // --- MiniMax --- + 'minimax': { input: 0.70, output: 0.70, ctx: 1000000 }, + // --- Kimi / Moonshot --- + 'moonshot': { input: 1.00, output: 1.00, ctx: 128000 }, + 'kimi': { input: 1.00, output: 1.00, ctx: 128000 }, + // --- Microsoft --- + 'phi-4': { input: 0.07, output: 0.14, ctx: 16000 }, + 'phi-3': { input: 0.07, output: 0.14, ctx: 128000 }, + // --- Nvidia --- + 'nemotron': { input: 0.30, output: 1.20, ctx: 131072 }, + // --- Nous --- + 'hermes': { input: 0.20, output: 0.20, ctx: 131072 }, +}; + +// Compat alias +const MODEL_PRICING = MODEL_INFO; + +// Image generation cost lookup (per-image, by model × quality × size) +const IMAGE_PRICING = { + 'gpt-image-1.5': { 'low': { '1024x1024': 0.009, '1024x1536': 0.013, '1536x1024': 0.013 }, 'medium': { '1024x1024': 0.034, '1024x1536': 0.05, '1536x1024': 0.05 }, 'high': { '1024x1024': 0.133, '1024x1536': 0.2, '1536x1024': 0.2 } }, + 'gpt-image-1': { 'low': { '1024x1024': 0.011, '1024x1536': 0.016, '1536x1024': 0.016 }, 'medium': { '1024x1024': 0.042, '1024x1536': 0.063, '1536x1024': 0.063 }, 'high': { '1024x1024': 0.167, '1024x1536': 0.25, '1536x1024': 0.25 } }, + 'gpt-image-1-mini': { 'low': { '1024x1024': 0.005, '1024x1536': 0.006, '1536x1024': 0.006 }, 'medium': { '1024x1024': 0.011, '1024x1536': 0.015, '1536x1024': 0.015 }, 'high': { '1024x1024': 0.036, '1024x1536': 0.052, '1536x1024': 0.052 } }, +}; + +export function shortModel(name) { + if (!name) return '...'; + if (typeof name !== 'string') name = String(name); + let short = name.split('/').pop(); + // Strip .gguf extension + short = short.replace(/\.gguf$/i, ''); + // Strip quantization suffixes (Q4_K_M, Q8_0, etc.) and shard numbers + short = short.replace(/-0000\d-of-\d+$/, ''); + short = short.replace(/[-_](Q\d[_A-Z\d]*|F16|F32|BF16|fp16|fp32)$/i, ''); + // Truncate if still too long (keep first meaningful part) + if (short.length > 25) { + // Try to find a natural break point (dash after model size like -35B or -7B) + const sizeMatch = short.match(/^(.+?-\d+[BbMm])/); + if (sizeMatch) short = sizeMatch[1]; + else short = short.substring(0, 22) + '…'; + } + return short; +} + +/** + * Generate a consistent HSL color for a model name. + * Returns an hsl() string. The hue is derived from a string hash, + * saturation and lightness are fixed for readability on dark/light themes. + */ +export function modelColor(name) { + if (!name) return null; + const key = name.toLowerCase(); + let hash = 0; + for (let i = 0; i < key.length; i++) { + hash = ((hash << 5) - hash + key.charCodeAt(i)) | 0; + } + const hue = ((hash % 360) + 360) % 360; + return `hsl(${hue}, 55%, 65%)`; +} + +/** Look up model info (pricing + context) by substring match */ +export function getModelInfo(modelName) { + if (!modelName) return null; + const name = modelName.toLowerCase(); + for (const [key, info] of Object.entries(MODEL_INFO)) { + if (name.includes(key)) return { key, ...info }; + } + return null; +} + +function _fmtCtx(n) { + if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; + return Math.round(n / 1000) + 'K'; +} + +/** + * Apply model color to a role element (sets color + dot color). + */ +export function applyModelColor(roleEl, modelName) { + if (!modelName) return; + const color = modelColor(modelName); + if (color) { + roleEl.style.color = color; + roleEl.style.setProperty('--model-dot', color); + } + // Replace generic dot with provider logo if available + const logo = providerLogo(modelName); + if (logo && !roleEl.querySelector('.role-provider-logo')) { + const span = document.createElement('span'); + span.className = 'role-provider-logo'; + span.innerHTML = logo; + roleEl.classList.add('has-logo'); + roleEl.prepend(span); + } + // Click to show model info popup + if (!roleEl._hasInfoClick) { + roleEl._hasInfoClick = true; + roleEl.style.cursor = 'pointer'; + roleEl.addEventListener('click', (e) => { + e.stopPropagation(); + document.querySelectorAll('.ctx-popup').forEach(p => p.remove()); + const info = getModelInfo(modelName); + const short = shortModel(modelName); + const logoHtml = providerLogo(modelName); + const popup = document.createElement('div'); + popup.className = 'ctx-popup'; + let html = '
'; + if (logoHtml) html += ''; + html += short + '
'; + html += '
Model ' + modelName.split('/').pop() + '
'; + // Show static context initially, then fetch real from server + const _realCtx = window._realContextLengths && window._realContextLengths[modelName]; + if (_realCtx) { + html += '
Context ' + _fmtCtx(_realCtx) + ' tokens'; + if (info && info.ctx && info.ctx !== _realCtx) html += ' (spec: ' + _fmtCtx(info.ctx) + ')'; + html += '
'; + } else if (info && info.ctx) { + html += '
Context ' + _fmtCtx(info.ctx) + ' tokens
'; + } + // Fetch real context from server async + if (!_realCtx && window.sessionModule) { + const _sid = window.sessionModule.getCurrentSessionId(); + if (_sid) { + fetch('/api/session/' + _sid + '/context_info').then(r => r.ok ? r.json() : null).then(d => { + if (d && d.context_length) { + if (!window._realContextLengths) window._realContextLengths = {}; + window._realContextLengths[modelName] = d.context_length; + const el = document.getElementById('_ctx-val'); + if (el) { + el.innerHTML = _fmtCtx(d.context_length) + ' tokens'; + if (info && info.ctx && info.ctx !== d.context_length) { + el.innerHTML += ' (spec: ' + _fmtCtx(info.ctx) + ')'; + } + } + } + }).catch(() => {}); + } + } + // Show configured max tokens if set + if (window.presetsModule) { + const _pid = window.presetsModule.getSelectedPreset(); + const _preset = _pid ? window.presetsModule.getPreset(_pid) : null; + const _mt = _preset?.max_tokens; + if (_mt && _mt > 0 && _mt <= 8192) { + html += '
Max tokens ' + _mt.toLocaleString() + ' (configured)
'; + } + } + if (info && info.input != null) html += '
Input $' + info.input.toFixed(2) + ' / 1M
'; + if (info && info.output != null) html += '
Output $' + info.output.toFixed(2) + ' / 1M
'; + if (!info) html += '
No pricing data available
'; + popup.innerHTML = html; + const rect = roleEl.getBoundingClientRect(); + popup.style.top = (rect.bottom + 4) + 'px'; + popup.style.left = rect.left + 'px'; + document.body.appendChild(popup); + const pr = popup.getBoundingClientRect(); + if (pr.bottom > window.innerHeight - 8) popup.style.top = (rect.top - pr.height - 4) + 'px'; + if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px'; + const closePopup = (ev) => { + if (!popup.contains(ev.target)) { popup.remove(); document.removeEventListener('click', closePopup, true); } + }; + setTimeout(() => document.addEventListener('click', closePopup, true), 0); + }); + } +} + +export function getModelCost(modelName, inputTokens, outputTokens) { + if (!modelName) return null; + const name = modelName.toLowerCase(); + for (const [key, price] of Object.entries(MODEL_PRICING)) { + if (name.includes(key)) { + return (inputTokens * price.input + outputTokens * price.output) / 1_000_000; + } + } + return null; +} + +/** + * Is this endpoint a local / self-hosted model server (vLLM, Ollama, …)? + * Local models are free, so we must NOT bill them at cloud rates — the + * pricing table matches on a name substring, so a local `qwen2.5-coder` + * would otherwise be charged like cloud `qwen2.5`. When the serving host is + * loopback, a private LAN range, Tailscale CGNAT (100.64–100.127.x), a + * `.local` name, or the app's own host, the model is local → free. + * Unknown / missing endpoint also counts as local (bias to not over-bill). + */ +export function isLocalEndpoint(url) { + if (!url) return true; + let host; + try { host = new URL(url).hostname; } catch (_e) { return true; } + if (!host) return true; + if (host === 'localhost' || host === '0.0.0.0' || host.endsWith('.local')) return true; + if (typeof window !== 'undefined' && window.location && host === window.location.hostname) return true; + if (/^127\./.test(host)) return true; + if (/^10\./.test(host)) return true; + if (/^192\.168\./.test(host)) return true; + if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true; + const cg = host.match(/^100\.(\d+)\./); // Tailscale CGNAT + if (cg && +cg[1] >= 64 && +cg[1] <= 127) return true; + return false; +} + +/** Cost for the current turn, returning null (free) for local endpoints. */ +function _billableCost(model, inputTokens, outputTokens) { + const url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl) + ? window.sessionModule.getCurrentEndpointUrl() : null; + if (isLocalEndpoint(url)) return null; + return getModelCost(model, inputTokens, outputTokens); +} + +export function getImageCost(model, quality, size) { + if (!model) return null; + const m = model.toLowerCase(); + for (const [key, quals] of Object.entries(IMAGE_PRICING)) { + if (m.includes(key)) { + const q = quals[(quality || 'medium').toLowerCase()] || quals['medium']; + return q ? (q[size] || q['1024x1024'] || null) : null; + } + } + return null; +} + +/* ── Session cost helpers ─────────────────────────────────────────── */ +const _COST_KEY = 'ody-session-cost'; + +/** Return the accumulated cost for the current (or given) session. */ +export function getSessionCost(sessionId) { + const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId()); + if (!sid) return 0; + try { + const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); + return costs[sid] || 0; + } catch (_e) { return 0; } +} + +/** Reset session cost for the given session (defaults to current). */ +export function resetSessionCost(sessionId) { + const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId()); + if (!sid) return; + try { + const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); + delete costs[sid]; + localStorage.setItem(_COST_KEY, JSON.stringify(costs)); + } catch (_e) { /* ignore */ } + updateSessionCostUI(); +} + +/** Update the persistent session-cost badge in the input bar. */ +export function updateSessionCostUI() { + const el = document.getElementById('session-cost-display'); + if (!el) return; + // Local model? It's free — hide the badge and clear any stale cost that a + // previous (buggy) cloud-rate billing left in localStorage for this session. + const _url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl) + ? window.sessionModule.getCurrentEndpointUrl() : null; + if (isLocalEndpoint(_url)) { + const sid = window.sessionModule && window.sessionModule.getCurrentSessionId(); + if (sid && getSessionCost(sid) > 0) { + try { + const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); + delete costs[sid]; + localStorage.setItem(_COST_KEY, JSON.stringify(costs)); + } catch (_e) { /* ignore */ } + } + el.style.display = 'none'; + return; + } + const cost = getSessionCost(); + if (cost > 0) { + el.textContent = '$' + (cost < 0.01 ? cost.toFixed(4) : cost < 1 ? cost.toFixed(3) : cost.toFixed(2)); + el.style.display = ''; + } else { + el.style.display = 'none'; + } +} + +/** Create a timestamp span for role labels. + * Pass an ISO string / Date / epoch-ms to render the message's own time + * (used when replaying history). Falls back to "now" when no value is given. */ +export function roleTimestamp(when) { + const ts = document.createElement('span'); + ts.className = 'role-timestamp'; + let d; + if (when instanceof Date) d = when; + else if (typeof when === 'number') d = new Date(when); + else if (typeof when === 'string' && when) d = new Date(when); + else d = new Date(); + if (isNaN(d.getTime())) d = new Date(); + ts.textContent = d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + ts.title = d.toLocaleString(); + return ts; +} + +/** + * Strip tool invocation blocks from text before rendering. + */ +export function stripToolBlocks(text) { + let cleaned = text.replace(TOOL_CALL_RE, ''); + cleaned = cleaned.replace(EXEC_FENCE_RE, ''); + cleaned = cleaned.replace(DSML_TOOL_RE, ''); + cleaned = cleaned.replace(DSML_STRAY_RE, ''); + cleaned = cleaned.replace(XML_TOOL_CALL_RE, ''); + cleaned = cleaned.replace(XML_INVOKE_RE, ''); + cleaned = cleaned.replace(TOOL_NARRATION_RE, ''); + cleaned = cleaned.replace(/\n{3,}/g, '\n\n'); + return cleaned.trim(); +} + +/** + * Build a collapsible sources box (used by both research and web search). + */ +export function buildSourcesBox(sources, type, expanded) { + var esc = uiModule.esc; + var id = 'sources-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5); + var count = sources.length; + var label = type === 'research' ? 'Research sources' : 'Web sources'; + var lines = ''; + for (var i = 0; i < count; i++) { + var s = sources[i]; + var domain = ''; + try { domain = new URL(s.url).hostname.replace('www.', ''); } catch(e) { domain = s.url; } + var title = esc(s.title || domain || ''); + var safeUrl = _safeHref(s.url); + lines += '' + + '' + (i + 1) + '' + + '' + title + '' + + '' + esc(domain) + '' + + ''; + } + var arrow = expanded ? 'down' : 'right'; + var expandedClass = expanded ? ' expanded' : ''; + return '
' + + '
' + + '
' + SEARCH_ICON + '' + count + ' ' + label + '
' + + '' + + '
' + + '
' + + '
' + lines + '
' + + '
'; +} + +/** + * Build the RAG "Sources (N documents)" box — mirrors the live render in + * chat.js so persisted rag_sources survive a refresh. Items carry a + * filename, similarity %, and snippet (not URLs, unlike web sources). + * @param {Array<{filename, similarity, snippet}>} sources + */ +export function buildRagSourcesBox(sources) { + if (!sources || !sources.length) return ''; + var esc = uiModule.esc; + var items = ''; + for (var i = 0; i < sources.length; i++) { + var s = sources[i] || {}; + var pct = (typeof s.similarity === 'number') ? (s.similarity * 100).toFixed(1) + '%' : ''; + items += '
' + esc(s.filename || '') + '' + + (pct ? ' ' + pct + '' : '') + + '
' + esc(s.snippet || '') + '
'; + } + return '
Sources (' + sources.length + ' documents)' + items + '
'; +} + +/** + * Build a collapsible "Raw collected findings" section, styled like the sources box. + * @param {Array<{url, title, summary}>} findings + * @param {boolean} [expanded=false] + */ +export function buildFindingsBox(findings, expanded) { + if (!findings || !findings.length) return ''; + var esc = uiModule.esc; + var id = 'findings-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5); + var count = findings.length; + var lines = ''; + for (var i = 0; i < count; i++) { + var f = findings[i]; + var domain = ''; + try { domain = new URL(f.url).hostname.replace('www.', ''); } catch(e) { domain = f.url; } + var title = esc(f.title || domain || ''); + var summary = esc(f.summary || ''); + var safeUrl = _safeHref(f.url); + lines += ''; + } + var FINDINGS_ICON = ''; + var arrow = expanded ? 'down' : 'right'; + var expandedClass = expanded ? ' expanded' : ''; + return '
' + + '
' + + '
' + FINDINGS_ICON + '' + count + ' Raw collected findings
' + + '' + + '
' + + '
' + + '
' + lines + '
' + + '
'; +} + +/** Append report button + continue research prompt. */ +export function appendReportButton(container, sessionId) { + _appendReportButton(container, sessionId); + _appendContinuePrompt(container); +} + +function _appendContinuePrompt(container) { + var wrap = document.createElement('div'); + wrap.className = 'continue-research-wrap'; + wrap.innerHTML = + '
' + + '' + + 'Dig deeper? Activate Research again and type a follow-up question to continue this research.' + + '
'; + container.appendChild(wrap); +} +function _appendReportButton(container, sessionId) { + var apiBase = window.API_BASE || ''; + + // Wrapper holds report button + chat-about button + var wrap = document.createElement('div'); + wrap.className = 'report-btn-wrap'; + + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'view-report-btn'; + btn.innerHTML = REPORT_ICON + ' Open Visual Report'; + + var reportUrl = apiBase + '/api/research/report/' + sessionId; + btn.addEventListener('click', function() { + window.open(reportUrl, '_blank'); + }); + wrap.appendChild(btn); + + var chatBtn = document.createElement('button'); + chatBtn.type = 'button'; + chatBtn.className = 'view-report-btn chat-about-btn'; + chatBtn.innerHTML = CHAT_ABOUT_ICON + ' Discuss'; + chatBtn.addEventListener('click', async function() { + if (chatBtn.disabled) return; + var origLabel = chatBtn.innerHTML; + chatBtn.disabled = true; + chatBtn.innerHTML = CHAT_ABOUT_ICON + ' Creating…'; + try { + var res = await fetch(apiBase + '/api/research/spinoff/' + sessionId, { method: 'POST' }); + if (!res.ok) { + var detail = ''; + try { detail = (await res.json()).detail || ''; } catch {} + throw new Error(detail || ('HTTP ' + res.status)); + } + var payload = await res.json(); + if (window.sessionModule && payload.session_id) { + await window.sessionModule.loadSessions().catch(() => {}); + await window.sessionModule.selectSession(payload.session_id); + } + } catch (e) { + chatBtn.disabled = false; + chatBtn.innerHTML = origLabel; + if (window.uiModule && uiModule.showError) { + uiModule.showError('Could not start follow-up chat: ' + e.message); + } else { + alert('Could not start follow-up chat: ' + e.message); + } + } + }); + wrap.appendChild(chatBtn); + + container.appendChild(wrap); +} + +window.toggleSources = function(id) { + // Debounce to prevent double-fire from both inline onclick and delegation + var now = Date.now(); + if (window._lastSourcesToggle && now - window._lastSourcesToggle < 100) return; + window._lastSourcesToggle = now; + + var content = document.getElementById(id); + var toggle = document.getElementById(id + '-toggle'); + if (content && toggle) { + var expanded = content.classList.contains('expanded'); + content.classList.toggle('expanded', !expanded); + toggle.dataset.arrow = expanded ? 'right' : 'down'; + } +}; + +// Event delegation for sources toggle (capture phase, handles SVG targets) +document.addEventListener('click', function(e) { + // Walk up from target manually to handle SVG elements that may not support closest() + var el = e.target; + while (el && el !== document) { + if (el.classList && el.classList.contains('sources-header') && el.dataset && el.dataset.sourcesId) { + e.stopPropagation(); + window.toggleSources(el.dataset.sourcesId); + return; + } + el = el.parentElement || el.parentNode; + } +}, true); + +// Jump-to-entity anchors — the agent emits links like +// [New Chat](#session-89effa28) +// [Notes](#document-abc123) +// [Reminder](#note-42) +// and the chat-history click delegate turns them into navigation +// instead of default in-page anchor jumps. Each prefix routes to the +// matching module via a dynamic import (avoids circular deps — +// sessions.js itself imports chatRenderer.js). +document.addEventListener('click', function(e) { + const a = e.target && e.target.closest && e.target.closest('a[href]'); + if (!a) return; + const href = a.getAttribute('href') || ''; + if (!href.startsWith('#')) return; + const m = href.match(/^#(session|document|note|image|email|event|task|skill|research)-(.+)$/); + if (!m) return; + e.preventDefault(); + e.stopPropagation(); + const [, kind, id] = m; + if (kind === 'session') { + import('./sessions.js').then(mod => { + const fn = mod.selectSession || (mod.default && mod.default.selectSession); + if (fn) fn(id); + }); + } else if (kind === 'document') { + import('./document.js').then(mod => { + const open = mod.loadDocument + || mod.openDocument + || (mod.default && (mod.default.loadDocument || mod.default.openDocument)); + if (open) open(id); + }).catch(() => {}); + } else if (kind === 'note') { + import('./notes.js').then(mod => { + const open = mod.openNote || (mod.default && mod.default.openNote); + if (open) open(id); + }).catch(() => {}); + } else if (kind === 'image') { + import('./gallery.js').then(mod => { + const open = mod.openGalleryImage || (mod.default && mod.default.openGalleryImage); + if (open) open(id); + }).catch(() => {}); + } else if (kind === 'email') { + import('./emailLibrary.js').then(mod => { + const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary); + if (open) open({ uid: id }); + }).catch(() => {}); + } else if (kind === 'event') { + import('./calendar.js').then(mod => { + const open = mod.openCalendarTo || (mod.default && mod.default.openCalendarTo); + if (open) open(id); + }).catch(() => {}); + } else if (kind === 'task') { + import('./tasks.js').then(mod => { + const open = mod.openTasks || (mod.default && mod.default.openTasks); + if (open) open(id); + else { const b = document.getElementById('tasks-btn'); if (b) b.click(); } + }).catch(() => { const b = document.getElementById('tasks-btn'); if (b) b.click(); }); + } else if (kind === 'skill') { + import('./skills.js').then(mod => { + const open = mod.openSkill || (mod.default && mod.default.openSkill); + if (open) open(id); + }).catch(() => {}); + } else if (kind === 'research') { + import('./research/panel.js').then(mod => { + const open = mod.openPanel || (mod.default && mod.default.openPanel); + if (open) open(id); + }).catch(() => {}); + } +}); + +/** + * Build a generated-image bubble element. + */ +export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId) { + var esc = uiModule.esc; + const wrap = document.createElement('div'); + wrap.className = 'msg msg-ai generated-image-wrap'; + + const role = document.createElement('div'); + role.className = 'role'; + role.textContent = (model || 'image').split('/').pop(); + wrap.appendChild(role); + + const body = document.createElement('div'); + body.className = 'body'; + + const img = document.createElement('img'); + img.className = 'generated-image'; + img.alt = prompt || 'Generated image'; + img.title = prompt || 'Generated image'; + img.src = imageUrl; + img.addEventListener('click', () => { window.open(img.src, '_blank'); }); + body.appendChild(img); + + if (prompt) { + const caption = document.createElement('div'); + caption.className = 'generated-image-caption'; + caption.textContent = prompt; + body.appendChild(caption); + } + + wrap.appendChild(body); + + const footer = document.createElement('div'); + footer.className = 'msg-footer'; + + const actions = document.createElement('span'); + actions.className = 'msg-actions'; + + const copyBtn = document.createElement('button'); + copyBtn.className = 'footer-copy-btn'; + copyBtn.type = 'button'; + copyBtn.title = 'Copy prompt'; + copyBtn.innerHTML = COPY_ICON; + copyBtn.addEventListener('click', (e) => { + e.stopPropagation(); + uiModule.copyToClipboard(prompt || ''); + copyBtn.innerHTML = CHECK_ICON; + setTimeout(() => { copyBtn.innerHTML = COPY_ICON; }, 1500); + }); + actions.appendChild(copyBtn); + + const dlBtn = document.createElement('button'); + dlBtn.className = 'footer-copy-btn'; + dlBtn.type = 'button'; + dlBtn.title = 'Download image'; + dlBtn.textContent = '\u2913'; + dlBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + try { + const resp = await fetch(imageUrl); + const blob = await resp.blob(); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = (prompt || 'image').slice(0, 40).replace(/[^a-zA-Z0-9 ]/g, '') + '.png'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(a.href); + dlBtn.textContent = '\u2713'; + setTimeout(() => { dlBtn.textContent = '\u2913'; }, 1500); + } catch { dlBtn.textContent = '\u2717'; setTimeout(() => { dlBtn.textContent = '\u2913'; }, 1500); } + }); + actions.appendChild(dlBtn); + + const editBtn = document.createElement('button'); + editBtn.className = 'footer-copy-btn'; + editBtn.type = 'button'; + editBtn.title = 'Edit in image editor'; + editBtn.innerHTML = ''; + editBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + try { + const [galleryMod, editorMod] = await Promise.all([ + import('./gallery.js'), + import('./galleryEditor.js'), + ]); + // Ensure the Gallery modal is open so the editor has a container + // to render into; switch its tabs to the Edit tab. + galleryMod.default.openGallery(); + const modal = document.getElementById('gallery-modal'); + if (modal) { + modal.querySelectorAll('.gallery-tab').forEach(t => t.classList.remove('active')); + modal.querySelector('.gallery-tab[data-tab="editor"]')?.classList.add('active'); + } + const imagesContainer = document.getElementById('gallery-images-container'); + const albumsContainer = document.getElementById('gallery-albums-container'); + if (imagesContainer) imagesContainer.style.display = 'none'; + if (albumsContainer) albumsContainer.style.display = 'none'; + const editorContainer = document.getElementById('gallery-editor-container'); + if (editorContainer) editorContainer.style.display = 'flex'; + const label = (prompt || '').trim().slice(0, 60) || 'Generated image'; + editorMod.openEditor(imageUrl, null, null, label); + } catch (err) { + console.error('[chat] open in editor failed', err); + } + }); + actions.appendChild(editBtn); + + const delBtn = document.createElement('button'); + delBtn.className = 'footer-copy-btn footer-delete-btn'; + delBtn.type = 'button'; + delBtn.title = 'Delete image'; + delBtn.innerHTML = ''; + delBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + const ok = await uiModule.styledConfirm('Delete this image?', { + confirmText: 'Delete', + cancelText: 'Cancel', + danger: true, + }); + if (!ok) return; + // If we have a gallery id, delete server-side; otherwise just remove + // the bubble from chat (e.g. external DALL-E url that wasn't saved). + if (imageId) { + try { + const res = await fetch(`/api/gallery/${encodeURIComponent(imageId)}`, { + method: 'DELETE', credentials: 'same-origin', + }); + if (!res.ok && res.status !== 404) { + uiModule.showToast?.('Delete failed', 4000); + return; + } + window.dispatchEvent(new CustomEvent('gallery-refresh')); + } catch (_) { + uiModule.showToast?.('Delete failed', 4000); + return; + } + } + wrap.remove(); + }); + actions.appendChild(delBtn); + + footer.appendChild(actions); + + const metrics = document.createElement('span'); + metrics.className = 'response-metrics'; + const parts = []; + if (model) parts.push(model.split('/').pop()); + if (size) parts.push(size); + if (quality) parts.push(quality); + const cost = getImageCost(model, quality, size); + if (cost !== null) parts.push('$' + (cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3))); + metrics.textContent = parts.join(' \u00B7 '); + footer.appendChild(metrics); + + wrap.appendChild(footer); + return wrap; +} + +export function hideWelcomeScreen() { + const ws = document.getElementById('welcome-screen'); + const cc = document.getElementById('chat-container'); + if (ws) ws.classList.add('hidden'); + if (cc) cc.classList.remove('welcome-active'); + // Update send button — switches from muted arrow to + Chat + if (window._updateSendBtnIcon) setTimeout(window._updateSendBtnIcon, 50); + const ib = document.getElementById('incognito-btn'); + if (ib) ib.style.display = ib.classList.contains('active') ? '' : 'none'; +} + +export function showWelcomeScreen() { + const ws = document.getElementById('welcome-screen'); + const cc = document.getElementById('chat-container'); + if (ws) ws.classList.remove('hidden'); + if (cc) cc.classList.add('welcome-active'); + // Re-trigger the L→R clip-wipe reveal on the welcome name each time the + // welcome screen is shown (new session, deleted last session, etc.) — without + // this, the CSS animation only fires on initial DOM insertion. + const wn = document.querySelector('.welcome-name'); + if (wn) { + wn.style.animation = 'none'; + // force reflow so the next assignment registers as a new animation + void wn.offsetHeight; + wn.style.animation = ''; + } + // Update send button — switches from + Chat to muted arrow on empty session + if (window._updateSendBtnIcon) setTimeout(window._updateSendBtnIcon, 50); + const ib = document.getElementById('incognito-btn'); + const _researchChk = document.getElementById('research-toggle'); + if (ib && !(_researchChk && _researchChk.checked)) ib.style.display = ''; + if (window.innerWidth > 768) { + const msg = document.getElementById('message'); + if (msg) msg.focus(); + } +} + +// ── Dynamic action buttons (show 3 most recent, rest under ···) ── +const _ACTION_RECENTS_KEY = 'odysseus-msg-actions-recent'; +const _MAX_VISIBLE = 2; + +function _getRecentActions() { + try { return JSON.parse(localStorage.getItem(_ACTION_RECENTS_KEY) || '[]'); } catch { return []; } +} +function _trackAction(id) { + let recent = _getRecentActions().filter(x => x !== id); + recent.unshift(id); + if (recent.length > 10) recent.length = 10; + localStorage.setItem(_ACTION_RECENTS_KEY, JSON.stringify(recent)); +} + +/** + * Create a footer row for an AI message with timestamp and action buttons. + */ +export function createMsgFooter(msgElement) { + const footer = document.createElement('div'); + footer.className = 'msg-footer'; + + const actions = document.createElement('span'); + actions.className = 'msg-actions'; + + // Define all available actions: { id, icon, title, className, handler } + const allActions = [ + { id: 'copy', icon: COPY_ICON, title: 'Copy message', cls: 'footer-copy-btn', html: true, handler(e) { + e.stopPropagation(); + const btn = e.currentTarget; + uiModule.copyToClipboard(msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || ''); + btn.innerHTML = CHECK_ICON; + setTimeout(() => { btn.innerHTML = COPY_ICON; }, 1500); + }}, + { id: 'edit', icon: '\u270E', title: 'Edit', cls: 'msg-action-btn', handler(e) { + e.stopPropagation(); + if (window.chatModule?.editAIMessage) window.chatModule.editAIMessage(msgElement); + }}, + { id: 'regen', icon: '\u21BB', title: 'Regenerate from here', cls: 'msg-action-btn', handler(e) { + e.stopPropagation(); + if (window.chatModule?.regenerateFrom) window.chatModule.regenerateFrom(msgElement); + }}, + { id: 'shorten', icon: '\u2702', title: 'Rewrite shorter', cls: 'msg-action-btn', handler(e) { + e.stopPropagation(); + if (window.chatModule?.rewriteWith) window.chatModule.rewriteWith(msgElement, 'Rewrite your last response to be shorter and more concise. Keep the key information but cut the fluff.'); + }}, + { id: 'explain', icon: '?', title: 'Explain simpler', cls: 'msg-action-btn', handler(e) { + e.stopPropagation(); + if (window.chatModule?.rewriteWith) window.chatModule.rewriteWith(msgElement, 'Explain your last response in simpler terms. Use plain language and short sentences.'); + }}, + { id: 'fork', icon: '\u2ADD', title: 'Fork conversation', cls: 'msg-action-btn', handler(e) { + e.stopPropagation(); + if (window.chatModule?.forkFrom) window.chatModule.forkFrom(msgElement); + }}, + { id: 'delete', icon: '\u2715', title: 'Delete message', cls: 'msg-action-btn msg-delete-btn', handler(e) { + e.stopPropagation(); + if (window.chatModule?.deleteMessage) window.chatModule.deleteMessage(msgElement); + }}, + ]; + + // Filter out unavailable actions (e.g. TTS when not enabled) + const availableActions = allActions.filter(a => !a.available || a.available()); + + // Determine which 3 to show: use recent order, fallback to defaults + const recent = _getRecentActions(); + const defaults = ['copy', 'delete', 'fork']; + const order = recent.length > 0 ? recent : defaults; + const sorted = [...availableActions].sort((a, b) => { + const ai = order.indexOf(a.id), bi = order.indexOf(b.id); + if (ai >= 0 && bi >= 0) return ai - bi; + if (ai >= 0) return -1; + if (bi >= 0) return 1; + return 0; + }); + const visible = sorted.slice(0, _MAX_VISIBLE); + const overflow = sorted.slice(_MAX_VISIBLE); + + // Render visible buttons + function _addBtn(action, container) { + const btn = _makeActionBtn(action.cls, action.title, action.html ? '' : action.icon, (e) => { + _trackAction(action.id); + action.handler(e); + }); + if (action.html) btn.innerHTML = action.icon; + btn.dataset.action = action.id; + container.appendChild(btn); + } + + visible.forEach(a => _addBtn(a, actions)); + + // Overflow "···" button + if (overflow.length > 0) { + const moreBtn = document.createElement('button'); + moreBtn.className = 'msg-action-btn msg-more-btn'; + moreBtn.type = 'button'; + moreBtn.title = 'More actions'; + moreBtn.textContent = '\u00B7\u00B7\u00B7'; + moreBtn.addEventListener('click', (e) => { + e.stopPropagation(); + // Toggle overflow menu — close any existing one first + const existing = document.querySelector('.msg-overflow-menu'); + if (existing) { existing.remove(); if (existing._trigger === moreBtn) return; } + + const menu = document.createElement('div'); + menu.className = 'msg-overflow-menu'; + overflow.forEach(a => { + const item = document.createElement('button'); + item.className = 'msg-overflow-item'; + item.type = 'button'; + item.title = a.title; + item.innerHTML = `${a.icon} ${a.title}`; + item.addEventListener('click', (ev) => { + ev.stopPropagation(); + _trackAction(a.id); + menu.remove(); + a.handler(ev); + }); + menu.appendChild(item); + }); + menu._trigger = moreBtn; + document.body.appendChild(menu); + // Position fixed relative to the ··· button + const btnRect = moreBtn.getBoundingClientRect(); + menu.style.top = (btnRect.top - menu.offsetHeight - 4) + 'px'; + menu.style.left = btnRect.left + 'px'; + // Flip down if above viewport + if (parseFloat(menu.style.top) < 8) menu.style.top = (btnRect.bottom + 4) + 'px'; + // Keep within right edge + const mr = menu.getBoundingClientRect(); + if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px'; + // Close on outside click + const close = (ev) => { + if (!menu.contains(ev.target) && ev.target !== moreBtn) { + menu.remove(); + document.removeEventListener('click', close, true); + } + }; + setTimeout(() => document.addEventListener('click', close, true), 0); + }); + actions.appendChild(moreBtn); + } + + // Memory-used indicator pill + const mems = msgElement._memoriesUsed; + if (mems && mems.length > 0) { + const pill = document.createElement('button'); + pill.className = 'memory-used-pill'; + pill.type = 'button'; + const pinnedCount = mems.filter(m => m.type === 'pinned').length; + const recalledCount = mems.filter(m => m.type === 'recalled').length; + const parts = []; + if (pinnedCount) parts.push(`${pinnedCount} pinned`); + if (recalledCount) parts.push(`${recalledCount} recalled`); + pill.innerHTML = `${parts.join(', ')}`; + pill.title = mems.map(m => `[${m.type}] ${m.text}`).join('\n'); + + pill.addEventListener('click', (e) => { + e.stopPropagation(); + let detail = pill._openDetail || document.querySelector('.memory-used-detail'); + if (detail) { detail.remove(); pill._openDetail = null; return; } + detail = document.createElement('div'); + detail.className = 'memory-used-detail'; + mems.forEach(m => { + const row = document.createElement('div'); + row.className = 'memory-used-row'; + row.style.cursor = 'pointer'; + row.title = 'Click to open memory manager'; + const badge = document.createElement('span'); + badge.className = 'memory-used-badge ' + (m.type === 'pinned' ? 'pinned' : 'recalled'); + badge.textContent = m.type === 'pinned' ? '\u25CF' : '\u21BB'; + const text = document.createElement('span'); + text.className = 'memory-used-text'; + text.textContent = m.text; + row.appendChild(badge); + row.appendChild(text); + row.addEventListener('click', (ev) => { + ev.stopPropagation(); + detail.remove(); + pill._openDetail = null; + const memModal = document.getElementById('memory-modal'); + if (memModal) memModal.classList.remove('hidden'); + }); + detail.appendChild(row); + }); + detail.style.visibility = 'hidden'; + document.body.appendChild(detail); + const pillRect = pill.getBoundingClientRect(); + const detailRect = detail.getBoundingClientRect(); + const spaceAbove = pillRect.top; + const spaceBelow = window.innerHeight - pillRect.bottom; + if (spaceAbove >= detailRect.height + 8 || spaceAbove > spaceBelow) { + detail.style.top = (pillRect.top - detailRect.height - 8) + 'px'; + } else { + detail.style.top = (pillRect.bottom + 8) + 'px'; + } + detail.style.left = pillRect.left + 'px'; + if (pillRect.left + detailRect.width > window.innerWidth - 8) { + detail.style.left = (window.innerWidth - detailRect.width - 8) + 'px'; + } + if (parseFloat(detail.style.left) < 8) detail.style.left = '8px'; + detail.style.visibility = ''; + pill._openDetail = detail; + const close = (ev) => { + if (!detail.contains(ev.target) && ev.target !== pill) { + detail.remove(); + pill._openDetail = null; + document.removeEventListener('click', close, true); + } + }; + setTimeout(() => document.addEventListener('click', close, true), 0); + }); + + footer.appendChild(pill); + } + + footer.appendChild(actions); + return footer; +} + +/** + * Create a footer row for a user message with action buttons (same system as AI footer). + */ +const _USER_ACTION_RECENTS_KEY = 'odysseus-user-actions-recent'; + +function _getUserRecentActions() { + try { return JSON.parse(localStorage.getItem(_USER_ACTION_RECENTS_KEY) || '[]'); } catch { return []; } +} +function _trackUserAction(id) { + let recent = _getUserRecentActions().filter(x => x !== id); + recent.unshift(id); + if (recent.length > 10) recent.length = 10; + localStorage.setItem(_USER_ACTION_RECENTS_KEY, JSON.stringify(recent)); +} + +export function createUserMsgFooter(msgElement) { + const footer = document.createElement('div'); + footer.className = 'msg-footer'; + + const actions = document.createElement('span'); + actions.className = 'msg-actions'; + + const allActions = [ + { id: 'edit', icon: '\u270E', title: 'Edit message', cls: 'msg-action-btn', handler(e) { + e.stopPropagation(); + if (window.chatModule?.editUserMessage) window.chatModule.editUserMessage(msgElement); + }}, + { id: 'delete', icon: '\u2715', title: 'Delete message', cls: 'msg-action-btn msg-delete-btn', handler(e) { + e.stopPropagation(); + if (window.chatModule?.deleteMessage) window.chatModule.deleteMessage(msgElement); + }}, + { id: 'copy', icon: COPY_ICON, title: 'Copy message', cls: 'footer-copy-btn', html: true, handler(e) { + e.stopPropagation(); + const btn = e.currentTarget; + uiModule.copyToClipboard(msgElement.querySelector('.body')?.textContent || ''); + btn.innerHTML = CHECK_ICON; + setTimeout(() => { btn.innerHTML = COPY_ICON; }, 1500); + }}, + { id: 'resend', icon: '\u21BB', title: 'Resend message', cls: 'msg-action-btn', handler(e) { + e.stopPropagation(); + if (window.chatModule?.resendUserMessage) window.chatModule.resendUserMessage(msgElement); + }}, + ]; + + const recent = _getUserRecentActions(); + const defaults = ['edit', 'delete', 'copy']; + const order = recent.length > 0 ? recent : defaults; + const sorted = [...allActions].sort((a, b) => { + const ai = order.indexOf(a.id), bi = order.indexOf(b.id); + if (ai >= 0 && bi >= 0) return ai - bi; + if (ai >= 0) return -1; + if (bi >= 0) return 1; + return 0; + }); + const visible = sorted.slice(0, _MAX_VISIBLE); + const overflow = sorted.slice(_MAX_VISIBLE); + + visible.forEach(a => { + const btn = _makeActionBtn(a.cls, a.title, a.html ? '' : a.icon, (ev) => { + _trackUserAction(a.id); + a.handler(ev); + }); + if (a.html) btn.innerHTML = a.icon; + btn.dataset.action = a.id; + actions.appendChild(btn); + }); + + if (overflow.length > 0) { + const moreBtn = document.createElement('button'); + moreBtn.className = 'msg-action-btn msg-more-btn'; + moreBtn.type = 'button'; + moreBtn.title = 'More actions'; + moreBtn.textContent = '\u00B7\u00B7\u00B7'; + moreBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const existing = document.querySelector('.msg-overflow-menu'); + if (existing) { existing.remove(); if (existing._trigger === moreBtn) return; } + + const menu = document.createElement('div'); + menu.className = 'msg-overflow-menu'; + overflow.forEach(a => { + const item = document.createElement('button'); + item.className = 'msg-overflow-item'; + item.type = 'button'; + item.title = a.title; + item.innerHTML = `${a.icon} ${a.title}`; + item.addEventListener('click', (ev) => { + ev.stopPropagation(); + _trackUserAction(a.id); + menu.remove(); + a.handler(ev); + }); + menu.appendChild(item); + }); + menu._trigger = moreBtn; + document.body.appendChild(menu); + const btnRect = moreBtn.getBoundingClientRect(); + menu.style.top = (btnRect.top - menu.offsetHeight - 4) + 'px'; + menu.style.left = btnRect.left + 'px'; + if (parseFloat(menu.style.top) < 8) menu.style.top = (btnRect.bottom + 4) + 'px'; + const mr = menu.getBoundingClientRect(); + if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px'; + const close = (ev) => { + if (!menu.contains(ev.target) && ev.target !== moreBtn) { + menu.remove(); + document.removeEventListener('click', close, true); + } + }; + setTimeout(() => document.addEventListener('click', close, true), 0); + }); + actions.appendChild(moreBtn); + } + + footer.appendChild(actions); + return footer; +} + +/** + * Display performance metrics for a message. + */ +export function displayMetrics(messageElement, metrics) { + const existingMetrics = messageElement.querySelector('.response-metrics'); + if (existingMetrics) existingMetrics.remove(); + + const metricsContainer = document.createElement('span'); + metricsContainer.className = 'response-metrics'; + + const responseTime = metrics.response_time; + const inputTokens = metrics.input_tokens || 0; + const outputTokens = metrics.output_tokens || 0; + const tps = metrics.tokens_per_second; + const isReal = metrics.usage_source === 'real'; + const ctxPct = metrics.context_percent; + const model = metrics.model || 'Unknown'; + const cost = _billableCost(model, inputTokens, outputTokens); + + // Nothing useful to show — bail out (only if ALL metrics are missing) + if (!responseTime && !outputTokens && tps == null && !ctxPct) return; + + // Accumulate session cost (only on fresh metrics, not history reload) + if (!metrics._fromHistory) { + const _sid = window.sessionModule && window.sessionModule.getCurrentSessionId(); + if (_sid && cost !== null) { + try { + const _costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); + _costs[_sid] = (_costs[_sid] || 0) + cost; + localStorage.setItem(_COST_KEY, JSON.stringify(_costs)); + } catch (_e) { /* ignore */ } + updateSessionCostUI(); + } + } + + // Default: show tok/s if available, else fall back to other stats + const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null; + const metricsLabel = tps != null && tps !== 'undefined' + ? `${tps} tok/s` + : costStr0 + ? `${outputTokens} tok · ${costStr0}` + : outputTokens + ? `${outputTokens} tok · ${responseTime != null ? responseTime + 's' : ''}` + : responseTime != null + ? `${responseTime}s` + : ''; + if (!metricsLabel) return; + metricsContainer.textContent = metricsLabel; + metricsContainer.style.cursor = 'pointer'; + metricsContainer.title = 'Click for details'; + const metricsDivider = document.createElement('span'); + metricsDivider.textContent = ' | '; + metricsDivider.style.color = 'var(--color-muted-alt)'; + metricsDivider.style.pointerEvents = 'none'; + metricsContainer.addEventListener('click', (e) => { + e.stopPropagation(); + document.querySelectorAll('.ctx-popup').forEach(p => p.remove()); + + const costStr = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : 'n/a'; + const speedStr = tps != null && tps !== 'undefined' ? `${tps} tok/s` : 'n/a'; + const totalTok = inputTokens + outputTokens; + const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--color-muted-alt, #6b7280)'; + const prepTime = metrics.agent_prep_time; + const modelWaitTime = metrics.agent_model_wait_time; + const prepBreakdown = metrics.agent_prep_breakdown || null; + const prepDetails = prepBreakdown + ? Object.entries(prepBreakdown).map(([k, v]) => `${k}: ${v}s`).join('
') + : ''; + + // Session total cost + let sessionCostStr = ''; + const sc = getSessionCost(); + if (sc > 0) { + sessionCostStr = `
Session $${sc < 0.01 ? sc.toFixed(4) : sc.toFixed(3)}
`; + } + + const popup = document.createElement('div'); + popup.className = 'ctx-popup'; + popup.innerHTML = ` +
Message Stats
+
Model ${model.split('/').pop()}
+
Input ${inputTokens.toLocaleString()} tokens${isReal ? '' : '~'}
+
Output ${outputTokens.toLocaleString()} tokens${isReal ? '' : '~'}
+
Total ${totalTok.toLocaleString()} tokens
+
Speed ${speedStr}
+
Time ${responseTime}s
+ ${prepTime != null ? `
Prep ${prepTime}s
` : ''} + ${modelWaitTime != null ? `
Model wait ${modelWaitTime}s
` : ''} +
Cost ${costStr}
+ ${sessionCostStr} + ${prepDetails ? `
+
Agent prep
+ ${prepDetails} +
` : ''} + ${ctxPct !== undefined && ctxPct > 0 ? `
+ Context ${ctxPct}% used +
` : ''} + ${isReal ? '' : '
~ estimated token count
'} + `; + + const rect = metricsContainer.getBoundingClientRect(); + popup.style.left = rect.left + 'px'; + popup.style.visibility = 'hidden'; + document.body.appendChild(popup); + const pr = popup.getBoundingClientRect(); + const spaceAbove = rect.top; + const spaceBelow = window.innerHeight - rect.bottom; + if (spaceAbove >= pr.height + 8 || spaceAbove > spaceBelow) { + popup.style.top = (rect.top - pr.height - 8) + 'px'; + } else { + popup.style.top = (rect.bottom + 8) + 'px'; + } + if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px'; + if (parseFloat(popup.style.left) < 8) popup.style.left = '8px'; + popup.style.visibility = ''; + + const closePopup = (ev) => { + if (!popup.contains(ev.target)) { + popup.remove(); + document.removeEventListener('click', closePopup, true); + } + }; + setTimeout(() => document.addEventListener('click', closePopup, true), 0); + }); + + // Store real context length for model info popup + if (metrics.context_length && metrics.model) { + if (!window._realContextLengths) window._realContextLengths = {}; + window._realContextLengths[metrics.model] = metrics.context_length; + } + + // Context usage ring + let ctxRing = null; + const ctxLen = metrics.context_length || 0; + if (ctxPct !== undefined && ctxPct > 0) { + const r = 6, stroke = 1.5; + const circ = 2 * Math.PI * r; + const fill = circ * (ctxPct / 100); + const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--green, #98c379)'; + ctxRing = document.createElement('span'); + ctxRing.className = 'ctx-ring'; + ctxRing.title = `${ctxPct}% context used — click for details`; + ctxRing.style.cursor = 'pointer'; + ctxRing.style.setProperty('--ctx-color', ctxColor); + ctxRing.innerHTML = ` + + + ${Math.round(ctxPct)}%`; + + ctxRing.addEventListener('click', (e) => { + e.stopPropagation(); + document.querySelectorAll('.ctx-detail-popup').forEach(p => p.remove()); + + const usedTokens = inputTokens || 0; + const totalCtx = ctxLen || 0; + const modelShort = model.split('/').pop(); + const fmtNum = n => n ? n.toLocaleString() : '?'; + + const popup = document.createElement('div'); + popup.className = 'ctx-detail-popup'; + popup.innerHTML = ` +
Context Window
+
+
+
+
+ ${fmtNum(usedTokens)} used + ${fmtNum(totalCtx)} total +
+
+
Model ${modelShort}
+
Usage ${ctxPct}%
+
Window ${fmtNum(totalCtx)} tokens
+
+ ${ctxPct >= 70 ? `` : ''} + `; + + const compactBtn = popup.querySelector('.ctx-compact-btn'); + if (compactBtn) { + compactBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + const sid = window.sessionModule && window.sessionModule.getCurrentSessionId(); + if (!sid) return; + popup.remove(); + + // Add a spinner bubble at the bottom of chat + const chatBox = document.getElementById('chat-history'); + if (!chatBox) return; + const compactMsg = document.createElement('div'); + compactMsg.className = 'msg msg-ai'; + const compactRole = document.createElement('div'); + compactRole.className = 'role'; + compactRole.textContent = 'Odysseus'; + const compactBody = document.createElement('div'); + compactBody.className = 'body'; + compactBody.innerHTML = 'Compacting context ▁▂▃▅▂▁'; + compactMsg.appendChild(compactRole); + compactMsg.appendChild(compactBody); + chatBox.appendChild(compactMsg); + chatBox.scrollTop = chatBox.scrollHeight; + + // Animate the wave + const waveFrames = ['▁▂▃▅▂▁', '▂▃▅▃▂▁', '▃▅▃▂▁▂', '▅▃▂▁▂▃', '▃▂▁▂▃▅', '▂▁▂▃▅▃']; + let frame = 0; + const waveEl = compactBody.querySelector('.compact-wave'); + const waveInterval = setInterval(() => { + frame = (frame + 1) % waveFrames.length; + if (waveEl) waveEl.textContent = waveFrames[frame]; + }, 150); + + try { + const res = await fetch(window.location.origin + '/api/session/' + sid + '/compact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + clearInterval(waveInterval); + if (res.ok) { + const data = await res.json(); + // Reload session — the compacted history will show + if (window.sessionModule) await window.sessionModule.selectSession(sid); + // Scroll to the compacted message (first msg with compacted metadata) + setTimeout(() => { + const msgs = document.querySelectorAll('#chat-history .msg'); + for (const m of msgs) { + if (m.querySelector('.body')?.textContent.includes('Conversation compacted')) { + m.scrollIntoView({ behavior: 'smooth', block: 'center' }); + break; + } + } + }, 200); + } else { + compactBody.innerHTML = 'Compaction failed. Try again later.'; + } + } catch (err) { + clearInterval(waveInterval); + console.warn('compact failed:', err); + compactBody.innerHTML = 'Compaction failed: ' + err.message + ''; + } + }); + } + + const rect = ctxRing.getBoundingClientRect(); + popup.style.visibility = 'hidden'; + document.body.appendChild(popup); + const pr = popup.getBoundingClientRect(); + // Position above the ring, right-aligned + popup.style.left = Math.max(8, rect.right - pr.width) + 'px'; + const spaceAbove = rect.top; + if (spaceAbove >= pr.height + 8) { + popup.style.top = (rect.top - pr.height - 8) + 'px'; + } else { + popup.style.top = (rect.bottom + 8) + 'px'; + } + popup.style.visibility = ''; + + const closePopup = (ev) => { + if (!popup.contains(ev.target) && ev.target !== ctxRing && !ctxRing.contains(ev.target)) { + popup.remove(); + document.removeEventListener('click', closePopup, true); + } + }; + setTimeout(() => document.addEventListener('click', closePopup, true), 0); + }); + } + + let footer = messageElement.querySelector('.msg-footer'); + if (footer) { + const actions = footer.querySelector('.msg-actions'); + if (actions) { + footer.insertBefore(metricsDivider, actions); + footer.insertBefore(metricsContainer, metricsDivider); + } else { + footer.appendChild(metricsContainer); + footer.appendChild(metricsDivider); + } + if (ctxRing) { + const ctxDiv = document.createElement('span'); + ctxDiv.textContent = ' | '; + ctxDiv.style.color = 'var(--color-muted-alt)'; + ctxDiv.style.pointerEvents = 'none'; + ctxDiv.className = 'ctx-divider'; + footer.appendChild(ctxDiv); + footer.appendChild(ctxRing); + } + } else { + messageElement.appendChild(metricsContainer); + if (ctxRing) messageElement.appendChild(ctxRing); + } + + if (uiModule) uiModule.scrollHistory(); +} + +/** + * Add a message to the chat history. + */ +export function addMessage(role, content, modelName, metadata) { + try { + hideWelcomeScreen(); + const box = document.getElementById('chat-history'); + if (!box) { console.error('Chat history element not found'); return; } + + var esc = uiModule.esc; + const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content; + + // --- Agent multi-bubble reconstruction from saved metadata --- + if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) { + const roundTexts = metadata.round_texts || []; + const toolEvents = metadata.tool_events; + let lastWrap = null; + let firstMsgAi = null; + let lastMsgAi = null; + + const toolsByRound = {}; + for (const ev of toolEvents) { + const r = ev.round || 1; + if (!toolsByRound[r]) toolsByRound[r] = []; + toolsByRound[r].push(ev); + } + + const maxRound = Math.max(...Object.keys(toolsByRound).map(Number), roundTexts.length); + + for (let r = 0; r < maxRound; r++) { + const roundNum = r + 1; + const txt = (roundTexts[r] || '').trim(); + + if (txt) { + const wrap = document.createElement('div'); + wrap.className = 'msg msg-ai' + (r > 0 ? ' msg-continuation' : ''); + const roleEl = document.createElement('div'); + roleEl.className = 'role'; + const contModel = modelName || metadata?.model; + roleEl.textContent = shortModel(contModel); + applyModelColor(roleEl, contModel); + if (r === 0) roleEl.appendChild(roleTimestamp(metadata?.timestamp)); + wrap.appendChild(roleEl); + const body = document.createElement('div'); + body.className = 'body'; + // Check if this is the last text round — sources go on top of final response + var agentSourcesPrefix = ''; + var isLastTextRound = true; + for (let rr = r + 1; rr < maxRound; rr++) { + if ((roundTexts[rr] || '').trim()) { isLastTextRound = false; break; } + } + var agentFindingsSuffix = ''; + if (isLastTextRound && metadata?.web_sources?.length) { + agentSourcesPrefix = buildSourcesBox(metadata.web_sources, 'web'); + } else if (isLastTextRound && metadata?.research_sources?.length) { + agentSourcesPrefix = buildSourcesBox(metadata.research_sources, 'research'); + } + if (isLastTextRound && metadata?.research_findings?.length) { + agentFindingsSuffix = buildFindingsBox(metadata.research_findings); + } + // RAG document sources — restored on the final text round. + if (isLastTextRound && metadata?.rag_sources?.length) { + agentFindingsSuffix += buildRagSourcesBox(metadata.rag_sources); + } + body.innerHTML = agentSourcesPrefix + markdownModule.processWithThinking(markdownModule.squashOutsideCode(txt)) + agentFindingsSuffix; + wrap.appendChild(body); + wrap.dataset.raw = txt; + if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id; + box.appendChild(wrap); + lastWrap = wrap; + if (!firstMsgAi) firstMsgAi = wrap; + lastMsgAi = wrap; + } + + const roundTools = toolsByRound[roundNum] || []; + if (roundTools.length > 0) { + // Reuse previous thread if no text separated us (merge consecutive tool rounds) + let threadWrap = null; + if (!txt && lastWrap && lastWrap.classList.contains('agent-thread')) { + threadWrap = lastWrap; + } else { + threadWrap = document.createElement('div'); + threadWrap.className = 'agent-thread'; + // Extend line up if there's a chat bubble above + if (txt) threadWrap.classList.add('has-top'); + box.appendChild(threadWrap); + } + for (const ev of roundTools) { + const ok = (ev.exit_code === 0 || ev.exit_code == null); + let outHtml = ''; + if (ev.output && ev.output.trim()) { + outHtml = `
Output
${esc(ev.output)}
`; + } + if (ev.screenshot) { + outHtml += `
Screenshot
`; + } + const node = document.createElement('div'); + node.className = 'agent-thread-node' + (ok ? '' : ' error'); + const evCmdHtml = ev.command ? `
${esc(ev.command)}
` : ''; + node.innerHTML = `
${ok ? '\u2713' : '\u2717'}${esc(ev.tool)}${ok ? 'done' : 'failed'}\u25B6
${evCmdHtml}${outHtml}
`; + // Click handling is delegated globally \u2014 see chat.js init. + threadWrap.appendChild(node); + } + // Check if next round has text — extend line down to connect + const nextTxt = (roundTexts[r + 1] || '').trim(); + if (nextTxt) threadWrap.classList.add('has-bottom'); + lastWrap = threadWrap; + + for (const ev of roundTools) { + if (ev.image_url) { + box.appendChild(buildImageBubble(ev.image_url, ev.image_prompt, ev.image_model, ev.image_size, ev.image_quality, ev.image_id)); + } + } + } + } + + const firstWrap = lastMsgAi || lastWrap; + if (firstWrap && firstWrap.classList.contains('msg-ai')) { + if (metadata?.memories_used?.length) firstWrap._memoriesUsed = metadata.memories_used; + firstWrap.appendChild(createMsgFooter(firstWrap)); + if (metadata) displayMetrics(firstWrap, metadata); + } + + if (window.hljs) { + box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b)); + } + if (markdownModule.renderMermaid) markdownModule.renderMermaid(box); + return lastWrap; + } + + // --- Standard single-bubble message --- + const wrap = document.createElement('div'); + wrap.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai'); + + const r = document.createElement('div'); + r.className = 'role'; + const isSlash = metadata?.source === 'slash'; + const isCompacted = metadata?.compacted; + const resolvedModel = modelName || metadata?.model; + var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : shortModel(resolvedModel); + if (role === 'assistant' && (metadata?.research || metadata?.research_clarification)) { + _roleText += ' (Research)'; + } + if (metadata?.group_model && role !== 'user') { + _roleText = metadata.group_model; + } else if (metadata?.character_name && role !== 'user' && !isSlash && !isCompacted) { + _roleText = metadata.character_name; + } + r.textContent = _roleText; + if (role !== 'user') { + if (!isSlash && !isCompacted) applyModelColor(r, resolvedModel); + r.appendChild(roleTimestamp(metadata?.timestamp)); + } + + const b = document.createElement('div'); + b.className = 'body'; + + let text = markdownModule.squashOutsideCode(stripToolBlocks(textRaw || '')); + + // For user messages, pull out vision-model image descriptions ([Image: name]\n + // ) into a collapsible "image description" section. Done for + // ALL user messages (not just ones with attachment metadata) so it rebuilds + // from the stored text even after a browser restart drops the cached attachments. + const attachments = metadata?.attachments; + const _visionBlocks = []; + if (role === 'user') { + text = text.replace( + /\n*\[Image: ([^\]]+)\]\n([\s\S]*?)(?=\n*\[Image: |\n*\[Image attached: |\n*=== File: |\n*\[PDF content\]:|$)/g, + (_m, name, desc) => { const d = desc.trim(); if (d) _visionBlocks.push({ name: name, desc: d }); return ''; } + ); + } + // With attachments present, also strip the embedded file/PDF/image-marker text. + if (role === 'user' && attachments?.length) { + // Strip === File: ... === blocks, [PDF content]: blocks, and [Image attached: ...] lines + text = text + .replace(/\n*=== File: .+? ===\n\[Type: .+?\]\n+```[\s\S]*?```/g, '') + .replace(/\n*=== File: .+? ===\n\[Type: .+?\]\n+[\s\S]*?(?=\n*=== File:|$)/g, '') + .replace(/\n*\[PDF content\]:[\s\S]*?(?=\n*\[PDF content\]|\n*=== File:|$)/g, '') + .replace(/\n*\[Image attached: [^\]]+\]/g, '') + .replace(/\n*\[Attached (?:document|non-text) file\]/g, '') + .trim(); + } + + wrap.dataset.raw = text; + if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id; + // Prepend sources box if saved in metadata + var sourcesPrefix = ''; + var findingsSuffix = ''; + if (role === 'assistant' && metadata?.research_sources?.length) { + sourcesPrefix = buildSourcesBox(metadata.research_sources, 'research'); + } else if (role === 'assistant' && metadata?.web_sources?.length) { + sourcesPrefix = buildSourcesBox(metadata.web_sources, 'web'); + } + if (role === 'assistant' && metadata?.research_findings?.length) { + findingsSuffix = buildFindingsBox(metadata.research_findings); + } + // RAG document sources — restored from metadata so they survive refresh. + if (role === 'assistant' && metadata?.rag_sources?.length) { + findingsSuffix += buildRagSourcesBox(metadata.rag_sources); + } + // If thinking is stored in metadata (not in text), reconstruct the full display + if (role === 'assistant' && metadata?.thinking) { + const thinkTime = metadata.thinking_time || null; + const thinkHtml = markdownModule.processWithThinking( + '' + metadata.thinking + '
\n\n' + text + ); + b.innerHTML = sourcesPrefix + thinkHtml + findingsSuffix; + } else { + b.innerHTML = sourcesPrefix + markdownModule.processWithThinking(text) + findingsSuffix; + } + + // The vision/OCR caption is stripped from the displayed text above (so the + // bubble doesn't show the raw model output) but no longer rendered as an + // inline collapsible — the user can still view/edit it via the "Caption" + // button on the photo thumbnail. _visionBlocks is intentionally left unused + // so the parsing-and-strip side-effect on `text` still happens. + void _visionBlocks; + + // Add "Open Visual Report" button for persisted research messages + if (role === 'assistant' && metadata?.research) { + var _sid = window.sessionModule?.getCurrentSessionId?.(); + if (_sid) _appendReportButton(b, _sid); + } + + // Style [Doc edit: ...] prefix in user messages + if (role === 'user') { + // Match compact format: [Doc edit: line X] instruction + b.innerHTML = b.innerHTML.replace( + /\[Doc edit: (lines? [\d–\-]+)\]\s*/, + 'Doc edit: $1 ' + ); + // Match raw format: "In the document, edit this specific text (line X):\n```\n...\n```\n\nInstruction: ..." + // After markdown processing this becomes a

+

 block + 

Instruction: text

+ const rawDocMatch = b.innerHTML.match(/In the document, edit this specific text \((lines? [\d–\-]+)\)/); + if (rawDocMatch) { + const lineRef = rawDocMatch[1]; + // Extract instruction text (after "Instruction: ") + const instrMatch = b.textContent.match(/Instruction:\s*([\s\S]*)$/); + const instrText = instrMatch ? instrMatch[1].trim() : ''; + b.innerHTML = 'Doc edit: ' + lineRef + ' ' + markdownModule.processWithThinking(instrText); + } + + // Render attachment cards + if (attachments?.length) { + b.appendChild(buildAttachCards(attachments)); + } + } + + wrap.appendChild(r); + wrap.appendChild(b); + + // Add stopped indicator + continue button for messages that were stopped by user + if (role === 'assistant' && metadata?.stopped) { + const stoppedIndicator = document.createElement('div'); + stoppedIndicator.className = 'stopped-indicator'; + const stoppedLabel = document.createElement('span'); + // Differentiate between "stopped mid-stream" (had content, can continue) + // and "cancelled before any content" — the latter has no Continue affordance. + stoppedLabel.textContent = metadata.cancelled + ? '[Cancelled by user]' + : '[Message interrupted]'; + stoppedIndicator.appendChild(stoppedLabel); + // Continue button only makes sense when there's partial content to + // resume from \u2014 skip it for fully-cancelled (empty) turns. + if (!metadata.cancelled) { + const continueBtn = document.createElement('button'); + continueBtn.className = 'continue-btn'; + continueBtn.title = 'Continue'; + continueBtn.textContent = '\u25B8'; + continueBtn.addEventListener('click', () => { + stoppedIndicator.remove(); + if (window.chatModule) { + window.chatModule.setHideUserBubble(); + window.chatModule.setPendingContinue(wrap); + const rawText = wrap.dataset.raw || wrap.querySelector('.body')?.textContent || ''; + const cutoff = rawText; + const msgInput = document.getElementById('message'); + if (msgInput) { + msgInput.value = 'Your previous response was interrupted. It ended with:\n\n' + cutoff.slice(-500) + '\n\nDo NOT repeat what you already said. Continue exactly from where you were cut off.'; + const sb = document.querySelector('.send-btn'); + if (sb) sb.click(); + } + } + }); + stoppedIndicator.appendChild(continueBtn); + } + b.appendChild(stoppedIndicator); + } + + if (metadata?.edited) { + const editedIndicator = document.createElement('div'); + editedIndicator.className = 'edited-indicator'; + editedIndicator.textContent = '[Message edited]'; + b.appendChild(editedIndicator); + } + + // Restore variant navigation from saved metadata + if (role === 'assistant' && metadata?.variants && metadata.variants.length > 1) { + wrap.dataset.variants = JSON.stringify(metadata.variants); + const idx = metadata.variantIndex ?? metadata.variants.length - 1; + wrap.dataset.variantIndex = String(idx); + + // Re-render from `raw` markdown rather than trusting cached `v.html`. + // Variants ride through localStorage / chat export-import; cached HTML + // would let an attacker-controlled session JSON inject markup. + const _renderVariant = (v) => (v && v.raw) + ? markdownModule.processWithThinking(markdownModule.squashOutsideCode(v.raw)) + : (v && v.html) || ''; + + // Show the selected variant's content + const v = metadata.variants[idx]; + if (v) { + b.innerHTML = _renderVariant(v); + wrap.dataset.raw = v.raw; + } + + // Render nav + const nav = document.createElement('span'); + nav.className = 'variant-nav'; + nav.addEventListener('click', (e) => e.stopPropagation()); + + const divider = document.createElement('span'); + divider.className = 'variant-divider'; + divider.textContent = '|'; + nav.appendChild(divider); + + const tagLabel = document.createElement('span'); + const _icons = { regen: '\u21BB', shorter: '\u2702', simpler: '?', original: '\u25CB' }; + const _tl0 = metadata.variants[idx]?.label; + tagLabel.className = 'variant-tag' + (_tl0 === 'shorter' ? ' variant-tag-scissors' : ''); + tagLabel.textContent = _icons[_tl0] || ''; + nav.appendChild(tagLabel); + + const prevBtn = document.createElement('button'); + prevBtn.className = 'variant-btn'; + prevBtn.textContent = '<'; + prevBtn.disabled = idx === 0; + nav.appendChild(prevBtn); + + const numLeft = document.createElement('button'); + numLeft.className = 'variant-num'; + numLeft.textContent = String(idx + 1); + numLeft.disabled = idx === 0; + nav.appendChild(numLeft); + + const slash = document.createElement('span'); + slash.className = 'variant-slash'; + slash.textContent = '/'; + nav.appendChild(slash); + + const numRight = document.createElement('button'); + numRight.className = 'variant-num'; + numRight.textContent = String(metadata.variants.length); + numRight.disabled = idx === metadata.variants.length - 1; + nav.appendChild(numRight); + + const nextBtn = document.createElement('button'); + nextBtn.className = 'variant-btn'; + nextBtn.textContent = '>'; + nextBtn.disabled = idx === metadata.variants.length - 1; + nav.appendChild(nextBtn); + + const switchFn = (newIdx) => { + const vars = metadata.variants; + if (newIdx < 0 || newIdx >= vars.length) return; + const sv = vars[newIdx]; + b.innerHTML = _renderVariant(sv); + wrap.dataset.raw = sv.raw; + wrap.dataset.variantIndex = String(newIdx); + if (window.hljs) wrap.querySelectorAll('pre code').forEach(bl => window.hljs.highlightElement(bl)); + tagLabel.textContent = _icons[sv.label] || ''; + tagLabel.className = 'variant-tag' + (sv.label === 'shorter' ? ' variant-tag-scissors' : ''); + numLeft.textContent = String(newIdx + 1); + numLeft.disabled = newIdx === 0; + numRight.disabled = newIdx === vars.length - 1; + prevBtn.disabled = newIdx === 0; + nextBtn.disabled = newIdx === vars.length - 1; + }; + prevBtn.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) - 1); }); + numLeft.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) - 1); }); + numRight.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) + 1); }); + nextBtn.addEventListener('click', (e) => { e.stopPropagation(); switchFn(parseInt(wrap.dataset.variantIndex) + 1); }); + + r.appendChild(nav); + } + + if (role === 'assistant') { + // The "N pinned" / "N recalled" pill in the footer reads from + // wrap._memoriesUsed — propagate it from saved metadata so the pill + // survives a page refresh (live-stream path sets it via SSE, but + // history reloads need this assignment). + if (metadata?.memories_used?.length) wrap._memoriesUsed = metadata.memories_used; + wrap.appendChild(createMsgFooter(wrap)); + if (metadata) displayMetrics(wrap, metadata); + } else { + // Add timestamp to user header (like AI messages) + r.appendChild(roleTimestamp(metadata?.timestamp)); + + wrap.appendChild(createUserMsgFooter(wrap)); + } + + box.appendChild(wrap); + + // TTS is now part of the msg-actions system + if (role === 'assistant' && markdownModule.renderMermaid) { + markdownModule.renderMermaid(wrap); + } + return wrap; + } catch (error) { + console.error('Error in addMessage:', error); + if (uiModule) uiModule.showError('Failed to add message: ' + error.message); + } +} + +const chatRenderer = { + shortModel, + modelColor, + applyModelColor, + getModelCost, + getImageCost, + getSessionCost, + resetSessionCost, + updateSessionCostUI, + roleTimestamp, + stripToolBlocks, + buildSourcesBox, + buildFindingsBox, + appendReportButton, + buildImageBubble, + hideWelcomeScreen, + showWelcomeScreen, + createMsgFooter, + displayMetrics, + addMessage, + updateMessageAttachments, +}; + +export default chatRenderer; diff --git a/static/js/chatStream.js b/static/js/chatStream.js new file mode 100644 index 0000000..0cc1446 --- /dev/null +++ b/static/js/chatStream.js @@ -0,0 +1,276 @@ +// static/js/chatStream.js +// SSE event handlers extracted from chat.js handleChatSubmit +// Handles: ui_control events, background stream management + +import uiModule from './ui.js'; +import Storage from './storage.js'; +import themeModule from './theme.js'; +import markdownModule from './markdown.js'; +import sessionModule from './sessions.js'; + +/** + * Handle a ui_control SSE event — AI-driven UI manipulation. + * Extracted from the duplicated ui_control + tool_output.ui_event handlers. + */ +export function handleUIControl(uiData) { + var uiEvent = uiData.ui_event || uiData; + var esc = uiModule.esc; + + try { + if (uiEvent === 'toggle' || uiData.ui_event === 'toggle') { + var toggleMap = { + web: 'web-toggle', bash: 'bash-toggle', rag: 'rag-toggle', + research: 'research-toggle', incognito: 'incognito-toggle', + }; + var btnMap = { + web: 'web-toggle-btn', bash: 'bash-toggle-btn', rag: 'rag-indicator-btn', + }; + var chkId = toggleMap[uiData.toggle_name]; + var btnId = btnMap[uiData.toggle_name]; + if (uiData.toggle_name === 'rag' && window._syncRagIndicator) { + window._syncRagIndicator(!!uiData.state); + } else { + if (chkId) { + var chk = document.getElementById(chkId); + if (chk) chk.checked = !!uiData.state; + } + if (btnId) { + var btn = document.getElementById(btnId); + if (btn) btn.classList.toggle('active', !!uiData.state); + } + } + var ts = Storage.getJSON(Storage.KEYS.TOGGLES, {}); + ts[uiData.toggle_name] = !!uiData.state; + Storage.setJSON(Storage.KEYS.TOGGLES, ts); + + } else if (uiEvent === 'set_mode' || uiData.ui_event === 'set_mode') { + var modeVal = uiData.mode; + var agentBtn = document.getElementById('mode-agent-btn'); + var chatBtn = document.getElementById('mode-chat-btn'); + if (agentBtn && chatBtn) { + agentBtn.classList.toggle('active', modeVal === 'agent'); + chatBtn.classList.toggle('active', modeVal !== 'agent'); + } + var ts2 = Storage.getJSON(Storage.KEYS.TOGGLES, {}); + ts2.mode = modeVal; + Storage.setJSON(Storage.KEYS.TOGGLES, ts2); + document.querySelectorAll('[data-mode-tool]').forEach(function(b) { + b.style.display = modeVal === 'agent' ? '' : 'none'; + }); + + } else if (uiEvent === 'switch_model' || uiData.ui_event === 'switch_model') { + var modelDisplay = document.querySelector('.current-model-name, #current-model'); + if (modelDisplay) modelDisplay.textContent = uiData.model; + + } else if (uiEvent === 'set_theme' || uiData.ui_event === 'set_theme') { + var tm = themeModule; + if (tm && tm.THEMES && tm.applyColors && tm.save) { + var themeName = uiData.theme_name; + if (themeName === 'chatgpt') themeName = 'gpt'; // renamed preset + var customThemes = tm.getCustomThemes ? tm.getCustomThemes() : {}; + var colors = tm.THEMES[themeName] || customThemes[themeName] || uiData.colors; + if (colors) { + tm.applyColors(colors); + tm.save(themeName, colors); + var grid = document.getElementById('themeGrid'); + if (grid) { + grid.querySelectorAll('.theme-swatch').forEach(function(s) { s.classList.remove('active'); }); + var sw = grid.querySelector('[data-theme="' + themeName + '"]'); + if (sw) sw.classList.add('active'); + } + } + } + + } else if (uiEvent === 'create_theme' || uiData.ui_event === 'create_theme') { + var tm2 = themeModule; + if (tm2 && tm2.applyColors && tm2.save) { + var colors2 = uiData.colors; + var name = uiData.theme_name || 'custom'; + if (colors2) { + tm2.applyColors(colors2); + tm2.save(name, colors2); + // Background effects (animated pattern / frosted glass) the model + // optionally set — apply them live and persist with the theme so + // they survive re-applying it later. + var bg = uiData.bg || null; + var opts = {}; + if (bg) { + if (bg.pattern && tm2.applyBgPattern) { tm2.applyBgPattern(bg.pattern); opts.bgPattern = bg.pattern; } + if (bg.effectColor && tm2.applyBgEffectColor) { tm2.applyBgEffectColor(bg.effectColor); opts.bgEffectColor = bg.effectColor; } + if (bg.effectIntensity != null && tm2.applyBgEffectIntensity) { tm2.applyBgEffectIntensity(bg.effectIntensity); opts.bgEffectIntensity = bg.effectIntensity; } + if (bg.effectSize != null && tm2.applyBgEffectSize) { tm2.applyBgEffectSize(bg.effectSize); opts.bgEffectSize = bg.effectSize; } + if (bg.frosted != null && tm2.applyFrostedGlass) { tm2.applyFrostedGlass(bg.frosted); opts.frosted = bg.frosted; } + } + if (tm2.saveCustomTheme) tm2.saveCustomTheme(name, colors2, Object.keys(opts).length ? opts : undefined); + } + } + + } else if (uiEvent === 'highlight' || uiData.ui_event === 'highlight') { + document.querySelectorAll('.odysseus-highlight').forEach(function(e) { e.classList.remove('odysseus-highlight'); }); + document.querySelectorAll('.odysseus-hl-label').forEach(function(e) { e.remove(); }); + var target = document.querySelector(uiData.selector); + if (target) { + target.classList.add('odysseus-highlight'); + target.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + if (uiData.label) { + var lbl = document.createElement('div'); + lbl.className = 'odysseus-hl-label'; + lbl.textContent = uiData.label; + if (!target.style.position) target.style.position = 'relative'; + target.appendChild(lbl); + } + } + + } else if (uiEvent === 'clear_highlight' || uiData.ui_event === 'clear_highlight') { + document.querySelectorAll('.odysseus-highlight').forEach(function(e) { e.classList.remove('odysseus-highlight'); }); + document.querySelectorAll('.odysseus-hl-label').forEach(function(e) { e.remove(); }); + + } else if (uiEvent === 'research_started' || uiData.ui_event === 'research_started') { + // Agent kicked off deep research — adopt the session into the + // sidebar immediately so the user sees it without waiting for + // the 12s active-poll. + var rsid = uiData.research_session_id || uiData.session_id; + if (rsid) { + import('./research/jobs.js').then(function(mod) { + var fn = mod.adoptSession || (mod.default && mod.default.adoptSession); + if (fn) fn(rsid); + }).catch(function(){}); + // The clickable "Open in Deep Research" link is now emitted by the + // agent loop as a `#research-` markdown anchor in the assistant's + // response text — it renders as a regular clickable chat link AND + // persists across refresh (saved with the message). No ephemeral + // chip injection needed here anymore. + } + + } else if (uiEvent === 'open_panel' || uiData.ui_event === 'open_panel') { + var panel = uiData.panel; + if (panel === 'documents') { + import('./documentLibrary.js').then(function(mod) { + var fn = mod.openLibrary || (mod.default && mod.default.openLibrary); + if (fn) fn(); + }).catch(function(){}); + } else if (panel === 'gallery') { + import('./gallery.js').then(function(mod) { + var fn = mod.openGallery || (mod.default && mod.default.openGallery); + if (fn) fn(); + }).catch(function(){}); + } else if (panel === 'email') { + import('./emailLibrary.js').then(function(mod) { + var fn = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary); + if (fn) fn(); + }).catch(function(){}); + } else if (panel === 'sessions') { + import('./sessions.js').then(function(mod) { + var fn = mod.openLibrary || (mod.default && mod.default.openLibrary); + if (fn) fn(); + }).catch(function(){}); + } else if (panel === 'cookbook') { + import('./cookbook.js').then(function(mod) { + var fn = mod.open || (mod.default && mod.default.open); + if (fn) fn(); + }).catch(function(){}); + } else if (panel === 'notes') { + import('./notes.js').then(function(mod) { + var fn = mod.openPanel || mod.openNotes || (mod.default && (mod.default.openPanel || mod.default.openNotes)); + if (fn) fn(); + }).catch(function(){}); + } else if (panel === 'memories' || panel === 'skills' || panel === 'settings') { + // These live in the sidebar / settings drawer — most just need + // an existing button click. + var ids = { memories: 'tool-memory-btn', skills: 'skills-btn', settings: 'open-settings-btn' }; + var btn = document.getElementById(ids[panel]); + if (btn) btn.click(); + } + + } else if (uiEvent === 'open_email_reply' || uiData.ui_event === 'open_email_reply') { + import('./emailInbox.js').then(function(mod) { + var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft); + if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply'); + }).catch(function(e) { + console.warn('open_email_reply failed:', e); + }); + } + } catch(e) { + console.warn('ui_control handler error:', e); + } +} + +/** + * Notify user when a background stream completes. + */ +export function notifyStreamComplete(sessionId, query) { + var isHidden = document.hidden; + var isOtherSession = sessionModule && sessionModule.getCurrentSessionId() !== sessionId; + if (!isHidden && !isOtherSession) return; + if (!('Notification' in window) || Notification.permission !== 'granted') return; + var body = query ? 'Response to "' + query.substring(0, 60) + '" is ready' : 'Your chat response has completed'; + var notification = new Notification('Response Complete', { + body: body, + tag: 'stream-' + sessionId, + }); + notification.onclick = function() { + window.focus(); + if (isOtherSession && sessionModule) { + sessionModule.selectSession(sessionId); + } + notification.close(); + }; + setTimeout(function() { notification.close(); }, 10000); +} + +/** + * Insert a clickable in-chat toast when a background stream finishes. + */ +export function insertStreamDoneToast(sessionId, query) { + var box = document.getElementById('chat-history'); + if (!box) return; + var sessions = sessionModule ? sessionModule.getSessions() : []; + var sess = sessions.find(function(s) { return s.id === sessionId; }); + var name = sess ? sess.name : 'another session'; + var preview = query ? '"' + query.substring(0, 50) + (query.length > 50 ? '...' : '') + '"' : ''; + var div = document.createElement('div'); + div.className = 'msg msg-system stream-done-toast'; + div.innerHTML = '
' + + '' + + 'Response ready in ' + (name || 'session').replace(/' + + (preview ? ' — ' + preview.replace(/' + + '
'; + div.addEventListener('click', function() { + if (sessionModule) sessionModule.selectSession(sessionId); + }); + box.appendChild(div); + uiModule.scrollHistory(); +} + +/** + * Notify when research completes (browser notification). + */ +export function notifyResearchComplete(sessionId, query) { + var isHidden = document.hidden; + var isOtherSession = sessionModule && sessionModule.getCurrentSessionId() !== sessionId; + if (!isHidden && !isOtherSession) return; + if (!('Notification' in window) || Notification.permission !== 'granted') return; + var body = query ? 'Research on "' + query.substring(0, 60) + '" is ready' : 'Your deep research has completed'; + var notification = new Notification('Research Complete', { + body: body, + tag: 'research-' + sessionId, + }); + notification.onclick = function() { + window.focus(); + if (isOtherSession && sessionModule) { + sessionModule.selectSession(sessionId); + } + notification.close(); + }; + setTimeout(function() { notification.close(); }, 10000); +} + +const chatStream = { + handleUIControl, + notifyStreamComplete, + insertStreamDoneToast, + notifyResearchComplete, +}; + +export default chatStream; diff --git a/static/js/codeRunner.js b/static/js/codeRunner.js new file mode 100644 index 0000000..76b67f9 --- /dev/null +++ b/static/js/codeRunner.js @@ -0,0 +1,398 @@ +// static/js/codeRunner.js + +import * as uiModule from './ui.js'; + +/** + * In-browser code runner for Python (Pyodide), JavaScript, and HTML + */ + +let pyodideInstance = null; +let pyodideLoading = false; +const pyodideQueue = []; + +/** + * Get or create an output panel below the
 element
+ */
+function getOrCreatePanel(pre) {
+  let panel = pre.nextElementSibling;
+  if (panel && panel.classList.contains('code-runner-output')) {
+    panel.innerHTML = '';
+    panel.style.display = 'block';
+    return panel;
+  }
+  panel = document.createElement('div');
+  panel.className = 'code-runner-output';
+  pre.parentNode.insertBefore(panel, pre.nextSibling);
+  return panel;
+}
+
+/**
+ * Show a loading message in the panel
+ */
+function showLoading(panel, msg) {
+  panel.innerHTML = `
${msg}
`; +} + +/** + * Show output text in the panel + */ +function showOutput(panel, text, isError) { + const el = document.createElement('pre'); + el.className = isError ? 'code-runner-pre code-runner-error' : 'code-runner-pre'; + el.textContent = text; + panel.innerHTML = ''; + panel.appendChild(el); + // Copy button — visible labeled pill at the top-right of the panel + // itself (no separate footer / divider, no tiny icon corner). + if (text) { + const cbtn = document.createElement('button'); + cbtn.type = 'button'; + cbtn.className = 'code-runner-copy-inline'; + cbtn.innerHTML = 'Copy'; + cbtn.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + let ok = false; + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + ta.setSelectionRange(0, text.length); + ok = document.execCommand && document.execCommand('copy'); + ta.remove(); + } catch (_) {} + if (!ok && navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(() => { + if (uiModule.showToast) uiModule.showToast('Copied'); + cbtn.textContent = 'Copied!'; + setTimeout(() => { cbtn.innerHTML = 'Copy'; }, 1500); + }).catch(() => { if (uiModule.showToast) uiModule.showToast('Copy failed'); }); + return; + } + if (uiModule.showToast) uiModule.showToast(ok ? 'Copied' : 'Copy failed'); + const orig = cbtn.innerHTML; + cbtn.textContent = ok ? 'Copied!' : 'Copy failed'; + setTimeout(() => { cbtn.innerHTML = orig; }, 1500); + }); + // Button lives directly in the panel — no wrapping bar. The panel is + // position:relative so the button can sit absolute-top-right of it. + panel.appendChild(cbtn); + } + if (isError) { + setTimeout(() => { if (panel) panel.style.display = 'none'; }, 7000); + } +} + +/** + * Legacy absolute-positioned copy button — replaced by the inline bar in + * showOutput. Kept here as no-op so any earlier callers don't crash. + */ +function addCopyBtn_unused(panel, text) { + if (!text) return; + const btn = document.createElement('button'); + btn.type = 'button'; // Default + + + + + + + + + + +
+ + + + + diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..24d2de8 --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "Odysseus", + "short_name": "Odysseus", + "description": "Self-hosted AI chat with memory, documents, and tools", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#282c34", + "theme_color": "#282c34", + "icons": [ + { "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, + { "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..6914e91 --- /dev/null +++ b/static/style.css @@ -0,0 +1,34071 @@ +/* ============================================ */ +/* Odysseus UI — Consolidated Stylesheet */ +/* ============================================ */ + + +/* ── Variables ── + * + * Theme-public variables (override via theme.js or custom themes): + * Core: --bg, --fg, --panel, --border, --red + * Syntax: --hl-keyword, --hl-string, --hl-comment, --hl-function, + * --hl-number, --hl-builtin, --hl-variable, --hl-params, + * --hl-bg, --hl-fg + * Accents: --accent-primary, --accent-error (set by theme.js) + * Semantic: --color-error, --color-success, --color-warning, --color-danger, + * --color-accent, --color-muted, --color-muted-alt + */ + +:root { + /* Core palette */ + --bg: #282c34; + --fg: #9cdef2; + --panel: #111; + --border: #355a66; + --red: #e06c75; + /* Were `var(--green)` / `var(--warn)` — self-referential, so they + resolved to invalid and every site fell back to its own literal + (or, for sites with no fallback, painted as transparent/inherit). + Anchor them to real hex so the token layer actually works. */ + --green: #50fa7b; + --warn: #f0ad4e; + + /* Syntax highlighting */ + --hl-bg: #1e2228; + --hl-fg: #9cdef2; + --hl-keyword: #c678dd; + --hl-string: #e5c07b; + --hl-comment: #828997; + --hl-function: #61afef; + --hl-number: #d19a66; + --hl-builtin: #56b6c2; + --hl-variable: #abb2bf; + --hl-params: #a8c0d4; + + /* Semantic colors */ + --color-error: #ff4444; + --color-error-light: #ff6666; + --color-success: #4caf50; + --color-warning: #f0ad4e; + --color-danger: #c0392b; + --color-recording: #ff3b30; + --color-recording-hover: #d63031; + --color-muted: #888; + --color-muted-alt: #6b7280; + --color-accent: #00aaff; + --color-agent-active: #00ff00; + --color-brand-blue: #3b82f6; + --color-blind-orange: #ff9800; + --color-save-green: var(--color-success); + --color-link-hover: #66c7ff; + --color-subheader: #6b8a94; + /* Warm accent — used by the Goals/Today UI in Notes. Lives as a token so + themes can override without touching the goal CSS. */ + --accent-warm: #d19a66; +} + +:root.light { + --bg: #f5f5f5; + --fg: #2b2b2b; + --panel: #fff; + --border: #bbb; + --hl-bg: #f9f9f9; + --hl-fg: #2b2b2b; + --hl-keyword: #7928a1; + --hl-string: #986801; + --hl-comment: #6a737d; + --hl-function: #005cc5; + --hl-number: #986801; + --hl-builtin: #0070a0; + --hl-variable: #383a42; + --hl-params: #4a4f5c; +} + +/* ── Reset & Base ── */ + +* { box-sizing: border-box; } +html, body { overflow-x: hidden; height: 100%; margin: 0; overscroll-behavior: none; } +body { + background-color: var(--bg); + color: var(--fg); + font-family: var(--font-family, 'Fira Code', monospace); + display: flex; + height: 100%; + height: 100dvh; /* dynamic viewport height — adapts when mobile keyboard opens */ + overflow: hidden; +} + +/* Self-hosted Fira Code font */ +@font-face { font-family: 'Fira Code'; font-weight: 300; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Light.woff2') format('woff2'); } +@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); } +@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); } + +/* Code block baseline */ +pre, code, .hljs { + font-size: 0.95em; + line-height: 1.5; +} + +/* Scrollbar styling */ +@supports selector(::-webkit-scrollbar) { + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + ::-webkit-scrollbar-track { + background: var(--panel); + } + ::-webkit-scrollbar-thumb { + background-color: var(--red); + border-radius: 4px; + border: 2px solid var(--panel); + } + ::-webkit-scrollbar-thumb:hover { + background-color: color-mix(in srgb, var(--red) 80%, white); + } +} + +html { + scrollbar-color: var(--red) var(--panel); + scrollbar-width: thin; +} + +/* Utility */ +.red-text { color: var(--red); } + +/* ── Density Overrides ── */ + +:root.density-compact { font-size: 13px; } +:root.density-compact .msg { padding: 6px 10px; margin-bottom: 4px; } +:root.density-compact .list-item { padding: 4px 8px; } +:root.density-compact .sidebar .section { padding: 0; } +:root.density-spacious { font-size: 16px; } +:root.density-spacious .msg { padding: 14px 18px; margin-bottom: 12px; } +:root.density-spacious .list-item { padding: 8px 12px; } +:root.density-spacious .sidebar .section { padding: 0; } + +/* ── Background Patterns ── */ + +:root { --bg-effect-intensity: 1; } + +/* Canvas-based effects — single source of truth for intensity */ +#synapse-canvas, #rain-canvas, #constellations-canvas, +#perlin-flow-canvas, #petals-canvas, #sparkles-canvas, +#embers-canvas { + opacity: var(--bg-effect-intensity, 1); +} + +body.bg-pattern-dots { + background-image: radial-gradient(color-mix(in srgb, var(--bg-effect-color, var(--fg)) calc(5% * var(--bg-effect-intensity, 1)), transparent) 1px, transparent 1px); + background-size: 20px 20px; background-attachment: fixed; +} +body.bg-pattern-synapse { + /* CSS grid as base, canvas pulses overlay */ + background-image: linear-gradient(color-mix(in srgb, var(--bg-effect-color, var(--fg)) calc(3.5% * var(--bg-effect-intensity, 1)), transparent) 1px, transparent 1px), + linear-gradient(90deg, color-mix(in srgb, var(--bg-effect-color, var(--fg)) calc(3.5% * var(--bg-effect-intensity, 1)), transparent) 1px, transparent 1px); + background-size: 24px 24px; background-attachment: fixed; +} +body.bg-pattern-perlin-flow, +body.bg-pattern-petals, +body.bg-pattern-sparkles { + /* canvas-only backgrounds */ +} + + +/* ── Layout ── */ + + /* Top bar — session meta + incognito, single row */ + .chat-top-bar { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + position: relative; + z-index: 2; + padding: 5px 0 0; + min-height: 25px; + box-sizing: border-box; + } + .chat-top-bar::after { + content: none; + } + body.sidebar-collapsed.hamburger-left .chat-top-bar { + padding-left: 38px; + } + body.sidebar-collapsed.hamburger-right .chat-top-bar { + padding-right: 38px; + } + .incognito-indicator { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + background: color-mix(in srgb, var(--accent) 12%, transparent); + border: 1px solid var(--accent); + color: var(--accent); + cursor: pointer; + padding: 4px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.15s, background 0.15s, left 0.15s; + z-index: 1; + opacity: 0.7; + } + body.sidebar-collapsed.hamburger-left .incognito-indicator { + left: 12px; + } + .incognito-indicator:hover { + opacity: 1; + background: color-mix(in srgb, var(--accent) 20%, transparent); + } + + .chat-new-btn { + position: absolute; + right: 7px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--fg); + opacity: 0.6; + cursor: pointer; + padding: 4px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.08s, background 0.08s, left 0.15s, right 0.15s; + flex-shrink: 0; + z-index: 1; + } + .chat-new-btn:hover { + opacity: 1; + color: var(--accent); + } + /* Flip new-chat and incognito when sidebar is on the right */ + body:has(.sidebar.right-side) .chat-new-btn { + right: auto; + left: 12px; + } + body:has(.sidebar.right-side) .incognito-indicator { + left: auto; + right: 12px; + } + body.sidebar-collapsed.hamburger-right .incognito-indicator { + right: 42px; + } + + /* Session meta — sits at top of chat area, scrolls with content */ + .chat-meta-overlay { + position: absolute; + left: 0; + right: 0; + top: 50%; + transform: translateY(calc(-50% - 2px)); + font-size: 0.75em; + line-height: 1; + color: color-mix(in srgb, var(--fg) 40%, transparent); + white-space: nowrap; + display: flex; + align-items: center; + justify-content: center; + gap: 2px; + pointer-events: none; + } + .chat-meta-overlay > * { + pointer-events: auto; + } + .chat-meta-overlay #current-meta { + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + } + .chat-meta-overlay:hover { + color: color-mix(in srgb, var(--fg) 75%, transparent); + } + /* Offline model rows */ + .models-row-offline { + opacity: 0.4; + pointer-events: none; + } + .models-row-offline .model-chat-btn { + pointer-events: auto; + } + /* Offline badge next to endpoint name */ + .endpoint-offline-badge { + color: var(--danger, #f44); + font-size: 0.8em; + margin-left: 4px; + opacity: 0.7; + } + .chat-meta-overlay:empty, + .chat-meta-overlay:not(:has(#current-meta:not(:empty))) { + display: none; + } + .export-dropdown-wrap { + position: relative; + display: inline-flex; + flex-shrink: 0; + margin-left: -4px; + margin-right: -20px; + } + .export-dl-btn { + background: none; border: none; + color: inherit; + cursor: pointer; + padding: 4px 5px; border-radius: 4px; + display: flex; align-items: center; + transition: color 0.08s, background 0.08s; + } + .export-dl-btn:hover { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + } + .export-dropdown-menu { + display: none; + position: fixed; + background: var(--panel); border: 1px solid var(--border); + border-radius: 8px; padding: 4px; min-width: 120px; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); + z-index: 300; + color: var(--fg); + } + .export-dropdown-menu.open { display: block; } + .export-dropdown-item { + padding: 6px 8px; font-size: 11px; cursor: pointer; + border-radius: 6px; color: var(--fg); white-space: nowrap; + display: flex; align-items: center; gap: 10px; + transition: background 0.1s; + } + .export-dropdown-item:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); } + .export-dropdown-item .dropdown-icon { + width: 14px; height: 14px; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; opacity: 0.5; + } + /* On mobile the chat-header export dropdown was cramped — items + were 11px font with 6px padding. Bump to readable touch targets: + larger min-width, taller rows, bigger icons + text. */ + @media (max-width: 768px) { + .export-dropdown-menu { + min-width: 200px; + padding: 6px; + border-radius: 10px; + } + .export-dropdown-item { + padding: 12px 14px; + font-size: 14px; + gap: 12px; + min-height: 44px; + } + .export-dropdown-item .dropdown-icon { + width: 18px; height: 18px; opacity: 0.7; + } + .export-dropdown-item .dropdown-icon svg { + width: 18px; height: 18px; + } + } + .sidebar { + width: 240px; + background: var(--sidebar-bg, var(--panel)); + border-right: 1px solid var(--border); + transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease; + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow: hidden; + min-height: 0; + margin: 0; + padding: 0; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + backdrop-filter: blur(10px); + border: 1px solid color-mix(in srgb, var(--fg) 11%, transparent); + position: relative; + } + .sidebar-resize-handle { + position: absolute; + top: 0; + right: -3px; + width: 6px; + height: 100%; + cursor: col-resize; + z-index: 10; + background: transparent; + } + .sidebar-resize-handle:hover, + .sidebar-resize-handle.dragging { + background: var(--accent); + opacity: 0.6; + } + .sidebar.right-side .sidebar-resize-handle { + right: auto; + left: -3px; + } + .sidebar.resizing { + transition: none; + user-select: none; + } + .sidebar.right-side { + order: 2; + margin: 0; + border-right: none; + border-left: 1px solid var(--border); + } + .sidebar.hidden { + /* !important so it beats the inline width init.js restores from storage — + otherwise the width never changes and only opacity animates, making the + collapse look instant. With this, width animates from the inline value + down to 0 via the .sidebar width transition. */ + width: 0 !important; + padding: 0 !important; + border: none; + overflow: hidden; + opacity: 0; + } + /* ===== Sidebar User Bar ===== */ + .sidebar-user-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 12px; + flex-shrink: 0; + gap: 4px; + min-height: 48px; + } + + .user-bar-left { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + cursor: pointer; + padding: 6px 8px; + border-radius: 8px; + transition: background 0.15s; + } + + .user-bar-left:hover { + background: color-mix(in srgb, var(--fg) 6%, transparent); + } + + .user-bar-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + background: color-mix(in srgb, var(--fg) 12%, transparent); + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 600; + color: var(--fg); + opacity: 0.7; + flex-shrink: 0; + text-transform: uppercase; + } + + .user-bar-name { + font-size: 9.75px; + font-weight: 500; + color: var(--fg); + opacity: 0.8; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .user-bar-actions { + display: flex; + gap: 1px; + flex-shrink: 0; + } + + .user-bar-btn { + background: none; + border: none; + color: var(--fg); + opacity: 0.35; + cursor: pointer; + padding: 6px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.12s, background 0.12s; + } + + .user-bar-btn:hover { + opacity: 1; + background: color-mix(in srgb, var(--fg) 8%, transparent); + } + + + .sidebar.hidden .sidebar-user-bar { + display: none; + } + + /* Sticky sidebar header — logo, never scrolls */ + .sidebar-header { + display: flex; + align-items: center; + justify-content: flex-end; /* right-align when sidebar is on left */ + gap: 8px; + padding: 15px 10px 0 40px; /* top padding aligns logo with fixed hamburger */ + flex-shrink: 0; + min-height: 40px; + border: none !important; + box-shadow: none !important; + position: relative; + z-index: 3; + background: var(--sidebar-bg, var(--panel)); + } + .sidebar-hamburger { + display: none !important; /* external #hamburger-btn is the only toggle */ + } + .sidebar-inner { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior-y: none; + scrollbar-width: none; + display: flex; + flex-direction: column; + gap: 0; + padding: 10px 8px 8px; + min-height: 0; + transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1); + } + .sidebar-brand { + display: flex; + align-items: center; + flex-shrink: 0; + min-height: 24px; + } + .sidebar-brand-title { + font-size: 1rem; + font-weight: 600; + color: var(--brand-color, var(--red)); + white-space: nowrap; + user-select: none; + position: relative; + top: 1px; + left: -10px; + } + .sidebar-sep { + display: none; + } + #sidebar-search-btn, + #sidebar-new-chat-btn { + margin: 0; + padding: 8px 8px; + } + #sessions-section { + margin-top: -1px; + } + #tools-section { + margin-top: 1px; + } + #tools-section .list-item { + padding: 8px 8px; + } + .sidebar.right-side .sidebar-header { + justify-content: flex-start; + padding-left: 10px; + padding-right: 40px; + } + .sidebar.right-side .sidebar-inner { + padding: 8px; + } + .sidebar.right-side .sidebar-brand { + justify-content: flex-start; + padding: 2px 30px 4px 4px; + } + .sidebar.right-side .sidebar-brand-title { + margin-left: 10px; + } + /* Fixed hamburger — always visible, toggles sidebar */ + .hamburger-btn { + position: fixed; + top: 12px; + left: 9px; + right: auto; + z-index: 210; + width: 30px; + height: 30px; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--hamburger-color, var(--fg)); + cursor: pointer; + opacity: 0.5; + -webkit-tap-highlight-color: transparent; + outline: none; + transition: opacity 0.15s; + padding: 0; + display: flex; + } + body.hamburger-right .hamburger-btn { + left: auto; + right: 9px; + } + .mobile-new-chat-btn { + display: none; + } + .hamburger-btn:hover { + opacity: 1; + } + /* Icon rail — mini sidebar that replaces the wide .sidebar when it's + hidden (mutually exclusive — see sidebar-layout.js:57). Fullscreen + panels reserve this strip of width via `left: var(--icon-rail-w)` + so the rail stays visible without needing a z-index hack (which + used to cover the fixed-position hamburger button). */ + .icon-rail { + width: 48px; + flex-shrink: 0; + background: var(--panel); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + align-items: center; + padding: 48px 4px 8px 4px; + gap: 4px; + margin: 0; + position: relative; + box-sizing: border-box; + /* Allow hover labels (e.g. the rail-new-chat "New" tooltip) to extend + outside the 48px column. overflow:hidden was clipping them. */ + overflow: visible; + } + .rail-resize-handle { + position: absolute; + top: 0; + right: -3px; + width: 6px; + height: 100%; + cursor: col-resize; + z-index: 10; + background: transparent; + } + .rail-resize-handle:hover, + .rail-resize-handle.dragging { + background: var(--accent); + opacity: 0.6; + } + .icon-rail.right-side .rail-resize-handle { + right: auto; + left: -3px; + } + .icon-rail.right-side { + order: 2; + margin: 0; + border-right: none; + border-left: 1px solid var(--border); + } + .icon-rail-divider { + width: 24px; + height: 1px; + background: var(--border); + margin: 4px 0; + } + .icon-rail-btn { + position: relative; + width: 34px; + height: 34px; + border: none; + background: transparent; + color: var(--accent, var(--red)); + font-size: 16px; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.5; + transition: opacity 0.08s, background 0.08s; + } + .rail-notes-badge { + position: absolute; + top: 1px; + right: 1px; + min-width: 14px; + height: 14px; + padding: 0 3px; + border-radius: 7px; + background: color-mix(in srgb, var(--accent) 85%, var(--bg)); + color: var(--bg); + font-size: 9px; + font-weight: 700; + line-height: 14px; + text-align: center; + box-sizing: border-box; + pointer-events: none; + box-shadow: 0 0 0 1px var(--bg); + } + .rail-notes-badge.fired { + background: var(--red); + animation: rail-notes-pulse 1.6s ease-in-out infinite; + } + @keyframes rail-notes-pulse { + 0%, 100% { box-shadow: 0 0 0 1px var(--bg), 0 0 0 0 color-mix(in srgb, var(--red) 50%, transparent); } + 50% { box-shadow: 0 0 0 1px var(--bg), 0 0 0 4px color-mix(in srgb, var(--red) 15%, transparent); } + } + /* Main sidebar notes button — dot when a reminder has fired */ + .tool-notes-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--red); + /* Match the Deep Research badge: right-aligned (auto) with the same + 4px left nudge, so both sidebar buttons' dots line up identically. */ + margin-left: auto; + position: relative; + left: -4px; + flex-shrink: 0; + align-self: center; + animation: rail-notes-pulse 1.6s ease-in-out infinite; + pointer-events: none; + } + /* Individual card — subtle accent border tint when a reminder has fired */ + .note-card.note-card-reminder-due .note-card-reminder { + background: color-mix(in srgb, var(--red) 22%, transparent); + color: var(--red); + font-weight: 600; + } + .icon-rail-btn:hover { opacity: 1; background: color-mix(in srgb, var(--accent) 12%, transparent); } + .icon-rail-btn.active-section { opacity: 1; background: color-mix(in srgb, var(--color-accent) 15%, transparent); } + /* Unified "minimized" indicator for any rail/sidebar button whose modal is held open */ + .rail-minimized { position: relative; } + .rail-minimized::after { + content: ''; position: absolute; + /* Default for inline spans like #email-section-title — sit just to the + right of the element so it never overlaps the icon. */ + top: 50%; right: -10px; transform: translateY(-50%); + width: 6px; height: 6px; border-radius: 50%; + background: var(--accent, var(--red)); + box-shadow: 0 0 0 2px var(--bg); + pointer-events: none; + animation: rail-min-pulse 2s ease-in-out infinite; + } + /* Compact icon-rail buttons: top-right corner of the icon */ + .icon-rail-btn.rail-minimized::after { top: 4px; right: 4px; transform: none; } + /* Sidebar list-items are wider — anchor right-aligned vertically centered */ + .list-item.rail-minimized::after { top: 50%; right: 8px; transform: translateY(-50%); } + #tool-memory-btn.rail-minimized::after { right: 12px; } + /* Per-user nudge: the listed tools' minimized dot sits 2px further + in from the right edge so it doesn't look glued to the border. */ + #tool-theme-btn.rail-minimized::after, + #tool-tasks-btn.rail-minimized::after, + #tool-notes-btn.rail-minimized::after, + #tool-library-btn.rail-minimized::after, + #tool-gallery-btn.rail-minimized::after, + #tool-compare-btn.rail-minimized::after, + #tool-calendar-btn.rail-minimized::after { right: 12px; } + /* Cookbook already shows its own running/served-status dot + (#cookbook-notif-dot, toggled with .cookbook-notif-active on the + button). Don't stack the tabbed-down pulse on top of it — the two + dots overlap. Suppress the minimized dot while the status dot is up. */ + .list-item.rail-minimized.cookbook-notif-active::after { display: none; } + @media (max-width: 768px) { + /* On mobile the list-items are taller (touch-sized), so the tabbed-down + pulsing dot reads a hair low and right. Email uses its own dot and is + already aligned — only the tool list-item dots need the nudge: + 4px left (right 8 → 12) and 2px up. */ + .list-item.rail-minimized::after { + right: 13px; + transform: translateY(calc(-50% - 2px)); + } + #tool-memory-btn.rail-minimized::after { right: 17px; } + } + @keyframes rail-min-pulse { + 0%,100% { opacity: 1; } + 50% { opacity: 0.4; } + } + /* Compact `_` minimize button for modal headers — matches the close + button's bordered square so the two read as a paired control. The + header's h4 carries margin-right:auto, which groups minimize + close + on the right; this button just needs a small gap before close. */ + .modal-minimize-btn { + background: var(--bg); + color: var(--fg); + border: 1px solid var(--fg); + cursor: pointer; + width: 24px; + height: 24px; + padding: 0; + margin-left: 0; + margin-right: 4px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + flex-shrink: 0; + transition: background 0.15s, color 0.15s; + } + .modal-minimize-btn:hover { background: var(--fg); color: var(--bg); } + .modal.modal-minimized { display: none !important; } + /* Window tile snap ghost (desktop only) */ + #tile-ghost { + position: fixed; pointer-events: none; z-index: 9000; + background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent); + border: 2px solid color-mix(in srgb, var(--accent, var(--red)) 55%, transparent); + border-radius: 8px; + opacity: 0; transform: scale(0.96); + transition: left 0.12s ease, top 0.12s ease, width 0.12s ease, height 0.12s ease, opacity 0.12s, transform 0.12s; + } + #tile-ghost.visible { opacity: 1; transform: scale(1); } + /* Bottom dock — chip per minimized modal */ + #minimized-dock { + position: fixed; bottom: 12px; left: 50%; transform: translateX(-50%); + display: flex; gap: 6px; flex-wrap: wrap; + max-width: calc(100vw - 24px); + padding: 4px; + z-index: 999; + pointer-events: none; + } + .minimized-dock-chip { + pointer-events: auto; + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 8px 6px 10px; + background: var(--panel, var(--bg)); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--fg); font-family: inherit; font-size: 12px; + cursor: grab; touch-action: none; user-select: none; + box-shadow: 0 4px 14px rgba(0,0,0,0.35); + transition: transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.15s, border-color 0.15s; + animation: dock-chip-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) both; + } + .minimized-dock-chip:hover { + background: color-mix(in srgb, var(--accent, var(--red)) 12%, var(--panel, var(--bg))); + border-color: color-mix(in srgb, var(--accent, var(--red)) 40%, var(--border)); + } + .minimized-dock-chip:active { cursor: grabbing; } + .minimized-dock-chip.dragging { + cursor: grabbing; z-index: 1000; + box-shadow: 0 8px 24px rgba(0,0,0,0.55); + transition: none; + opacity: 0.95; + } + /* Whole-dock drag (grabbed an edge chip) */ + #minimized-dock.dock-dragging { cursor: grabbing; } + #minimized-dock.dock-dragging .minimized-dock-chip { + transition: none; + box-shadow: 0 8px 24px rgba(0,0,0,0.45); + } + /* Subtle visual cue that edge chips drag the whole dock */ + #minimized-dock .minimized-dock-chip:first-child:not(:only-child), + #minimized-dock .minimized-dock-chip:last-child:not(:only-child) { + box-shadow: 0 4px 14px rgba(0,0,0,0.35), + inset 0 0 0 1px color-mix(in srgb, var(--accent, var(--red)) 25%, transparent); + } + .minimized-dock-chip svg { opacity: 0.7; flex-shrink: 0; } + .minimized-dock-label { white-space: nowrap; } + + /* Mobile: chips are icon-only round pills. Tap to restore, drag toward + the trash zone at top-center to close. touch-action:none lets the + pointermove listener claim the gesture instead of the page scroll. */ + @media (max-width: 768px) { + .minimized-dock-chip { + width: 40px; height: 40px; + padding: 0 !important; + border-radius: 50% !important; + justify-content: center; + position: relative; + overflow: visible; + touch-action: none; + } + .minimized-dock-chip svg { width: 18px; height: 18px; opacity: 0.9; } + .minimized-dock-label, + .minimized-dock-x { display: none !important; } + .minimized-dock-chip.chip-free-drag { + box-shadow: 0 8px 22px rgba(0,0,0,0.55), + 0 0 0 2px color-mix(in srgb, var(--accent, var(--red)) 50%, transparent) !important; + } + /* Long-press hint — chip swells and settles while the detach timer + counts down so the user feels feedback before the bubble peels out + of the chain. Returns to scale(1) at the end so there's no visual + jump when the timer fires and the chip becomes a free-drag puck. */ + .minimized-dock-chip.chip-long-press { + background: + radial-gradient(circle at 30% 25%, color-mix(in srgb, #fff 28%, transparent), transparent 36%), + linear-gradient(135deg, + color-mix(in srgb, var(--accent, var(--red)) 34%, var(--panel, var(--bg))), + color-mix(in srgb, #7dd3fc 26%, var(--panel, var(--bg))) 52%, + color-mix(in srgb, #f0abfc 22%, var(--panel, var(--bg)))); + border-color: color-mix(in srgb, var(--accent, var(--red)) 72%, #fff 12%) !important; + animation: chip-long-press-pulse 0.82s ease-in-out infinite; + z-index: 10; + } + .minimized-dock-chip.chip-long-press::before { + content: ''; + position: absolute; + inset: -96px; + border-radius: inherit; + background: + radial-gradient(circle, + color-mix(in srgb, var(--accent, var(--red)) 42%, transparent) 0 18%, + color-mix(in srgb, #7dd3fc 34%, transparent) 34%, + color-mix(in srgb, #f0abfc 30%, transparent) 50%, + transparent 72%); + pointer-events: none; + z-index: -1; + animation: chip-long-press-ripple 0.82s ease-out infinite; + } + @keyframes chip-long-press-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.42); + box-shadow: 0 14px 42px rgba(0,0,0,0.66), + 0 0 0 16px color-mix(in srgb, var(--accent, var(--red)) 42%, transparent), + 0 0 72px color-mix(in srgb, #7dd3fc 44%, transparent), + 0 0 118px color-mix(in srgb, #f0abfc 32%, transparent); } + } + @keyframes chip-long-press-ripple { + 0% { opacity: 0.92; transform: scale(0.08); } + 72% { opacity: 0.28; transform: scale(3.6); } + 100% { opacity: 0; transform: scale(5.4); } + } + /* Chip whose modal is currently open — accent ring so the user can + tell at a glance which floating bubble belongs to the visible + modal. Tap it to minimize. */ + .minimized-dock-chip.chip-active { + border-color: var(--accent, var(--red)) !important; + box-shadow: 0 4px 14px rgba(0,0,0,0.35), + 0 0 0 2px color-mix(in srgb, var(--accent, var(--red)) 35%, transparent); + } + .minimized-dock-chip.chip-active svg { opacity: 1; } + } + /* Magnetic close zone — slides in from the side opposite the chip's + starting position so it never overlaps the dock. Always accent + color, larger than the chips, snappy spring transition. */ + #dock-trash-zone { + position: fixed; + left: 50%; + width: 88px; height: 88px; + border-radius: 50%; + background: var(--accent, var(--red, #e53935)); + color: #fff; + display: flex; align-items: center; justify-content: center; + pointer-events: none; + opacity: 0; + z-index: 9000; + box-shadow: 0 8px 28px color-mix(in srgb, var(--accent, var(--red, #e53935)) 55%, transparent); + transition: opacity 0.18s ease, + transform 0.26s cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 0.18s ease; + } + /* Off-screen start position depends on which side the chip is on so + the X slides in from the opposite edge with a snappy overshoot. */ + #dock-trash-zone[data-side="top"] { transform: translateX(-50%) translateY(-180%) scale(0.7); } + #dock-trash-zone[data-side="bottom"] { transform: translateX(-50%) translateY(180%) scale(0.7); } + #dock-trash-zone.visible { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } + #dock-trash-zone.engaged { + transform: translateX(-50%) translateY(0) scale(1.22); + box-shadow: 0 0 0 14px color-mix(in srgb, var(--accent, var(--red, #e53935)) 22%, transparent), + 0 12px 36px color-mix(in srgb, var(--accent, var(--red, #e53935)) 60%, transparent); + } + /* Whirlpool ring — fades in when chip is in capture range and spins + continuously, then bursts on drop. */ + #dock-trash-zone .whirlpool { + position: absolute; inset: -8px; + border-radius: 50%; + border: 2px solid transparent; + border-top-color: rgba(255,255,255,0.9); + border-right-color: rgba(255,255,255,0.55); + border-bottom-color: rgba(255,255,255,0.25); + opacity: 0; + pointer-events: none; + animation: whirlpool-spin 0.85s linear infinite; + transition: opacity 0.15s ease; + } + #dock-trash-zone.engaged .whirlpool { opacity: 1; } + #dock-trash-zone.dropping .whirlpool { + opacity: 1; + animation: whirlpool-burst 0.36s cubic-bezier(0.4, 0, 0.2, 1) forwards; + } + @keyframes whirlpool-spin { to { transform: rotate(360deg); } } + @keyframes whirlpool-burst { + 0% { transform: rotate(0deg) scale(1); opacity: 1; } + 60% { transform: rotate(540deg) scale(1.5); opacity: 0.6; } + 100% { transform: rotate(900deg) scale(2.2); opacity: 0; } + } + + /* Email chip badges: + - email-lib-modal chip: "1" badge when an email is expanded + inside (JS sets data-has-expanded). + - email-reader-* chips: auto-numbered 1, 2, 3 … via CSS counter, + so multiple opened-email windows are visually distinguishable. */ + .minimized-dock-chip[data-modal-id="email-lib-modal"], + .minimized-dock-chip[data-modal-id^="email-reader-"] { + position: relative; + } + .minimized-dock-chip[data-modal-id^="email-reader-"][data-tab-num]::after { + content: attr(data-tab-num); + position: absolute; + top: -4px; + right: -4px; + min-width: 16px; + height: 16px; + padding: 0 4px; + background: var(--accent, var(--red)); + color: #fff; + font-size: 9px; + font-weight: 700; + line-height: 16px; + text-align: center; + border-radius: 8px; + box-shadow: 0 0 0 2px var(--bg); + pointer-events: none; + } + .minimized-dock-chip[data-modal-id="email-lib-modal"][data-email-unread-label]::after { + content: attr(data-email-unread-label); + position: absolute; + top: -6px; + right: 10px; + height: 16px; + padding: 0 6px; + background: var(--accent, var(--red)); + color: #fff; + font-size: 9px; + font-weight: 700; + line-height: 16px; + white-space: nowrap; + border-radius: 8px; + box-shadow: 0 0 0 2px var(--bg); + pointer-events: none; + } + + .minimized-dock-x { + display: inline-flex; align-items: center; justify-content: center; + width: 16px; height: 16px; border-radius: 50%; + font-size: 14px; line-height: 1; opacity: 0.4; + margin-left: 3px; + } + .minimized-dock-x:hover { opacity: 1; background: color-mix(in srgb, var(--fg) 12%, transparent); } + @keyframes dock-chip-in { + from { opacity: 0; transform: translateY(20px) scale(0.85); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + .rail-new-chat svg { transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); } + .rail-new-chat:hover svg { transform: rotate(90deg); } + /* A "New" label slides out from the right of the + as it spins, so users + discover what the icon does without needing the tooltip. */ + .rail-new-chat { position: relative; } + .rail-new-chat::after { + content: 'New'; + position: absolute; + left: 100%; + top: 50%; + margin-left: 6px; + transform: translateY(-50%) translateX(-6px); + opacity: 0; + pointer-events: none; + white-space: nowrap; + font-size: 11px; + font-weight: 600; + color: var(--fg); + background: var(--panel); + padding: 2px 6px; + border-radius: 4px; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + z-index: 20; + transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); + } + .rail-new-chat:hover::after { + opacity: 1; + transform: translateY(-50%) translateX(0); + } + #rail-admin, + #rail-settings { color: var(--fg); } + .rail-separator { width: 20px; height: 1px; background: var(--border); margin: 4px auto; } + .rail-dynamic { + position: relative; + } + .rail-dynamic::after { + content: ''; + position: absolute; + bottom: 2px; + left: 50%; + transform: translateX(-50%); + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--accent, var(--red)); + opacity: 0.7; + } + /* ── Sidebar sections (flat layout) ── */ + .section { padding: 0; border: none; background: none; border-radius: 0; box-shadow: none; margin: 0; } + + /* Section header row — identical sizing to .list-item */ + .section-header-flex { + display: flex; align-items: center; gap: 6px; + padding: 8px 8px; margin: 1px 0; border-radius: 4px; + background: transparent; cursor: pointer; + transition: background 0.08s; + height: 29px; + box-sizing: border-box; + } + .section-header-flex:hover { background: color-mix(in srgb, var(--red) 8%, transparent); } + .section-header-flex::after { content: none; } + .section-header-flex h4::before { content: none; } + + /* Section title text — same font as .list-item .grow */ + .section-header-flex h4, + .section-header-flex .section-title { + flex: 1; cursor: pointer; user-select: none; + display: flex; align-items: center; gap: 6px; + margin: 0; padding: 0; border: none; + font-size: 10px; font-weight: 400; font-family: inherit; + line-height: 1; letter-spacing: 0; text-transform: none; + color: var(--fg); + } + .section-icon, + .sidebar-action-icon { + flex-shrink: 0; + stroke: var(--accent, var(--red)); + position: relative; + left: -1px; + color: var(--accent, var(--red)); + } + /* Shared notification dot for sidebar section titles. Single source of + truth so chats / email / assistant / future-section dots all sit at + the same offset from their label. */ + .sidebar-notif-dot { + display: inline-block; + width: 6px; + height: 6px; + /* Push to the right edge of the flex section-title so chats / email + / assistant dots all line up vertically in the same column + instead of trailing right after each (differently-sized) label. */ + margin-left: auto; + border-radius: 50%; + background: var(--accent, var(--red)); + flex-shrink: 0; + vertical-align: middle; + } + /* The email notification gets a soft breathing glow so new-mail catches + the eye without being shouty. Vertical alignment stays with the + inherited .sidebar-notif-dot rule so it lines up with chats/assistant. */ + #email-unread-dot.sidebar-notif-dot { + animation: email-notif-breathe 2.2s ease-in-out infinite; + /* Nudge in from the far-right edge so it doesn't crowd the corner. */ + margin-right: 4px; + /* Tiny vertical nudge to center with the email label. */ + position: relative; + top: 0.1px; + } + @keyframes email-notif-breathe { + 0%, 100% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent, var(--red)) 60%, transparent); + opacity: 0.85; + } + 50% { + box-shadow: 0 0 6px 2px color-mix(in srgb, var(--accent, var(--red)) 55%, transparent); + opacity: 1; + } + } + @media (prefers-reduced-motion: reduce) { + #email-unread-dot.sidebar-notif-dot { animation: none; } + } + @media (max-width: 768px) { + /* Nudge the sidebar notification dot up 1px on mobile so it lines + up with the bigger section titles. vertical-align:middle drifts + a hair low against the larger touch-friendly text. */ + .sidebar-notif-dot { + position: relative; + top: -1px; + } + /* Cookbook's sidebar status dot carries an inline top:-1px, so override + with the ID + !important to nudge it 2px left / 1px up on mobile. */ + #cookbook-notif-dot { + left: -1px !important; + top: -2px !important; + } + } + #sidebar-new-chat-btn svg { + transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); + } + #sidebar-new-chat-btn:hover svg { + transform: rotate(90deg); + } + + /* Sort/select buttons in header — compact */ + .section-header-flex > div { display: flex; align-items: center; gap: 2px; } + .section-header-btn { + all: unset; + cursor: pointer; opacity: 0.4; padding: 1px 3px; border-radius: 4px; + display: inline-flex; align-items: center; justify-content: center; + transition: opacity 0.08s, background 0.08s; + } + .section-header-btn:hover { opacity: 1; background: color-mix(in srgb, var(--fg) 7%, transparent); } + .section-header-btn.active { opacity: 0.9; color: var(--accent); } + .section-header-btn svg { width: 12px; height: 12px; } + + /* Collapse chevron */ + .section-collapse-btn { + all: unset; + cursor: pointer; display: inline-flex; align-items: center; padding: 0 2px; border-radius: 4px; + } + .section-collapse-btn:hover { background: color-mix(in srgb, var(--fg) 8%, transparent); } + .section-collapse-chevron { display: inline-flex; opacity: 0.3; transition: transform 0.2s, opacity 0.15s; } + .section-collapse-btn:hover .section-collapse-chevron { opacity: 0.6; } + .section.collapsed .section-collapse-chevron { transform: rotate(-90deg); opacity: 0.5; } + .section-header-flex:has(.section-header-btn) .section-collapse-btn { display: none; } + .section.collapsed .section-collapse-btn { display: inline-flex !important; } + + /* Collapsed state */ + .section.collapsed > *:not(h4):not(.section-title):not(.section-header-flex) { display: none !important; } + .section.collapsed .section-header-flex { margin-bottom: 0; } + .section.collapsed .section-header-btn { display: none; } + .section.collapsed { cursor: pointer; } + + /* Domino expand: every time a section goes from .collapsed → open + (toggleCollapse in section-management.js adds .section-just-expanded + for ~700ms), the .list-item children cascade in one after another, + same feel as the chat input's tools menu. Each row springs in + from a tiny offset below + scaled-down, staggered by nth-child. */ + .section.section-just-expanded .list-item { + animation: section-domino-in 0.36s cubic-bezier(0.22, 1.61, 0.36, 1) backwards; + } + .section.section-just-expanded .list-item:nth-child(1) { animation-delay: 0.04s; } + .section.section-just-expanded .list-item:nth-child(2) { animation-delay: 0.08s; } + .section.section-just-expanded .list-item:nth-child(3) { animation-delay: 0.12s; } + .section.section-just-expanded .list-item:nth-child(4) { animation-delay: 0.16s; } + .section.section-just-expanded .list-item:nth-child(5) { animation-delay: 0.20s; } + .section.section-just-expanded .list-item:nth-child(6) { animation-delay: 0.24s; } + .section.section-just-expanded .list-item:nth-child(7) { animation-delay: 0.28s; } + .section.section-just-expanded .list-item:nth-child(8) { animation-delay: 0.32s; } + .section.section-just-expanded .list-item:nth-child(9) { animation-delay: 0.36s; } + .section.section-just-expanded .list-item:nth-child(10) { animation-delay: 0.40s; } + .section.section-just-expanded .list-item:nth-child(11) { animation-delay: 0.44s; } + .section.section-just-expanded .list-item:nth-child(12) { animation-delay: 0.48s; } + @keyframes section-domino-in { + 0% { opacity: 0; transform: translateY(8px) translateX(-4px) scale(0.92); } + 60% { opacity: 1; } + 100% { opacity: 1; transform: translateY(0) translateX(0) scale(1); } + } + + /* Domino collapse: when toggleCollapse goes open → closed, JS adds + .section-just-collapsing for ~530ms before flipping in .collapsed. + During that window the items fade/slide DOWN one after another so + you see them peel off instead of vanishing as a block. Uses + nth-last-child so the BOTTOM item leaves first and the cascade + rolls upward — mirrors the "stacked deck" feeling of the open + animation reversed. */ + .section.section-just-collapsing .list-item { + animation: section-domino-out 0.22s ease-in forwards; + } + .section.section-just-collapsing .list-item:nth-last-child(1) { animation-delay: 0.00s; } + .section.section-just-collapsing .list-item:nth-last-child(2) { animation-delay: 0.025s; } + .section.section-just-collapsing .list-item:nth-last-child(3) { animation-delay: 0.05s; } + .section.section-just-collapsing .list-item:nth-last-child(4) { animation-delay: 0.075s; } + .section.section-just-collapsing .list-item:nth-last-child(5) { animation-delay: 0.10s; } + .section.section-just-collapsing .list-item:nth-last-child(6) { animation-delay: 0.125s; } + .section.section-just-collapsing .list-item:nth-last-child(7) { animation-delay: 0.15s; } + .section.section-just-collapsing .list-item:nth-last-child(8) { animation-delay: 0.175s; } + .section.section-just-collapsing .list-item:nth-last-child(9) { animation-delay: 0.20s; } + .section.section-just-collapsing .list-item:nth-last-child(10) { animation-delay: 0.225s; } + .section.section-just-collapsing .list-item:nth-last-child(11) { animation-delay: 0.25s; } + .section.section-just-collapsing .list-item:nth-last-child(12) { animation-delay: 0.275s; } + @keyframes section-domino-out { + 0% { opacity: 1; transform: translateY(0) translateX(0) scale(1); } + 100% { opacity: 0; transform: translateY(6px) translateX(-3px) scale(0.94); } + } + @keyframes spin { to { transform: rotate(360deg); } } + .row { display:flex; gap:6px; align-items:center; } + .list-item:hover, + .models-row:hover { + background: color-mix(in srgb, var(--red) 8%, transparent); + border-color: var(--red); + } + /* Disabled tool — dimmed to signal its feature is turned off globally */ + .list-item.tool-disabled { + opacity: 0.4; + } + .list-item.tool-disabled:hover { + opacity: 0.7; + } + /* Session bulk select mode */ + .session-bulk-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-bottom: 1px solid var(--border); + background: var(--sidebar-bg, var(--panel)); + position: sticky; + top: 0; + z-index: 3; + font-size: 11px; + } + .session-bulk-bar.hidden { display: none; } + #session-select-all-dot { + display: inline-block; + position: relative; + top: -2px; + } + .session-bulk-btn { + background: none; + border: none; + border-radius: 4px; + color: var(--fg); + padding: 4px; + font-family: inherit; + cursor: pointer; + opacity: 0.4; + transition: opacity 0.1s; + display: inline-flex; + align-items: center; + justify-content: center; + } + .session-bulk-btn:hover { opacity: 1; } + .session-bulk-btn-danger { color: var(--red); opacity: 0.5; } + .session-bulk-btn-danger:hover { opacity: 1; } + /* Checkbox in select mode */ + .session-select-cb { + accent-color: var(--accent, var(--red)); + margin: 0 4px 0 0; + flex-shrink: 0; + cursor: pointer; + } + + .session-icon, + .model-icon { + flex-shrink: 0; + opacity: 0.35; + display: inline-flex; + align-items: center; + } + .session-icon.has-docs { + color: inherit; + opacity: 0.5; + } + .session-star { + width: 10px; height: 10px; + border-radius: 50%; + border: 1.5px solid color-mix(in srgb, var(--fg) 22%, transparent); + flex-shrink: 0; + position: relative; + } + .session-star.processing { + animation: research-pulse 1.5s ease-in-out infinite; + } + .session-star.notify { + animation: research-done-pulse 1.2s ease-in-out 5; + opacity: 1; + /* Bigger, solid status dot so "research done" reads clearly. Use the + defined accent (bare --accent is undefined here → no colour). */ + width: 14px; height: 14px; + background: var(--accent-primary, var(--red)); + border-color: var(--accent-primary, var(--red)); + } + /* The dot doubles as the provider-logo holder; hide that logo in the + done state so the solid notif dot doesn't collide with the SVG behind it. */ + .session-star.notify svg { display: none; } + @keyframes research-done-pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.5); opacity: 0.7; } + } + .session-fav { + flex-shrink: 0; + cursor: pointer; + color: var(--red); + opacity: 0.6; + display: inline-flex; + align-items: center; + transition: opacity 0.1s; + } + .session-fav:hover { + opacity: 1; + } + .list-item.active-session { + background: color-mix(in srgb, var(--red) 10%, transparent); + border-color: var(--red); + border-left-color: var(--red); + } + .list-item .provider-logo { opacity: 0.4 !important; } + .list-item:has(.session-star.processing) .provider-logo { opacity: 1 !important; } + .list-item.stream-complete .provider-logo { opacity: 1 !important; } + .list-item.active { + background: color-mix(in srgb, var(--red) 10%, transparent); + border-color: var(--red); + border-left-color: var(--red); + } + /* Only show the red focus ring for keyboard navigation (Tab). Mouse + clicks shouldn't leave a sticky red outline on a sidebar item. */ + .list-item:focus { outline: none; } + .list-item:focus-visible { + outline: none; + border-color: var(--red, var(--color-error)); + box-shadow: 0 0 0 1px var(--red, var(--color-error)); + } + /* Drag handles hidden by default, shown via body.rearrange-mode */ + .drag-handle, .item-drag-handle, .folder-drag-handle { display: none; } + body.rearrange-mode .drag-handle, + body.rearrange-mode .item-drag-handle, + body.rearrange-mode .folder-drag-handle { display: inline; } + + /* Drag sorting styles for list items */ + .item-drag-handle { + cursor: grab; + opacity: 0.4; + font-size: 10px; + padding: 0 4px; + user-select: none; + letter-spacing: -2px; + transition: opacity 0.15s; + } + .item-drag-handle:hover { + opacity: 1; + color: var(--red); + } + .item-drag-handle:active { + cursor: grabbing; + } + .list-item.dragging, + .models-row.dragging, + .session-folder.dragging { + opacity: 0.95; + box-shadow: 0 8px 24px color-mix(in srgb, var(--red) 30%, transparent); + border-color: var(--red); + background: var(--panel); + cursor: grabbing; + } + .list-item.dragging .item-drag-handle, + .models-row.dragging .item-drag-handle, + .session-folder.dragging .folder-drag-handle { + opacity: 1; + color: var(--red); + } + /* ── Model category grouping ── */ + .models-category-header { + display: flex; align-items: center; gap: 6px; + padding: 4px 8px; cursor: pointer; + font-size: 0.85em; font-weight: 600; + color: color-mix(in srgb, var(--fg) 55%, transparent); + border-radius: 4px; user-select: none; + margin-top: 4px; + transition: opacity 0.08s, background 0.08s, color 0.08s; + } + .models-category-header:hover { color: var(--fg); background: color-mix(in srgb, var(--fg) 4%, transparent); } + .models-category-header .folder-count { font-weight: 400; opacity: 0.5; font-size: 0.9em; } + .models-endpoint-label { + display: flex; align-items: center; gap: 6px; + padding: 4px 8px; cursor: pointer; + font-size: 0.85em; font-weight: 600; + color: color-mix(in srgb, var(--fg) 55%, transparent); + border-radius: 4px; user-select: none; + transition: opacity 0.08s, background 0.08s; + } + .models-endpoint-label:hover { color: var(--fg); background: color-mix(in srgb, var(--fg) 4%, transparent); } + .models-endpoint-label .folder-count { font-weight: 400; opacity: 0.5; } + .models-group-content.indented { + padding-left: 4px; + } + .models-group-content.indented .models-row { border-bottom: 1px solid color-mix(in srgb, var(--fg) 7%, transparent); } + .models-group-content.indented .models-row:last-child { border-bottom: none; } + /* ── Session folders ── */ + .session-folder { margin: 2px 0; } + .session-folder-header { + display: flex; align-items: center; gap: 6px; + padding: 4px 8px; cursor: pointer; + font-size: 0.88em; font-weight: 600; + color: color-mix(in srgb, var(--fg) 55%, transparent); + border-radius: 4px; user-select: none; + transition: opacity 0.08s, background 0.08s; + } + .session-folder-header:hover { color: var(--fg); background: color-mix(in srgb, var(--fg) 4%, transparent); } + .session-folder-header .folder-count { font-weight: 400; opacity: 0.5; } + .session-folder-header.drag-over { + outline: 2px dashed var(--red); background: color-mix(in srgb, var(--red) 13%, transparent); opacity: 1; + } + .session-folder.dragging-folder { opacity: 0.4; } + .folder-toggle { font-size: 0.7em; width: 10px; text-align: center; } + .folder-name { font-weight: inherit; flex: 1; } + .folder-count { font-size: 0.85em; opacity: 0.5; } + .folder-drag-handle { + cursor: grab; opacity: 0.3; font-size: 0.8em; padding: 0 2px; + transition: opacity 0.08s; + } + .session-folder-header:hover .folder-drag-handle { opacity: 0.7; } + .folder-delete-btn { + background: none; border: none; color: var(--fg); opacity: 0; + cursor: pointer; font-size: 1.1em; padding: 0 2px; line-height: 1; + min-height: 0; height: auto; + transition: opacity 0.08s, color 0.08s; + } + .session-folder-header:hover .folder-delete-btn { opacity: 0.5; } + .folder-delete-btn:hover { opacity: 1 !important; color: var(--color-error); } + .session-folder-content { padding-left: 4px; } + .session-folder-content .list-item { border-bottom: 1px solid color-mix(in srgb, var(--fg) 7%, transparent); } + .session-folder-content .list-item:last-child { border-bottom: none; } + .session-show-more-btn { + display: block; + width: 100%; + background: none; + border: none; + color: var(--fg); + opacity: 0.4; + font-size: 0.8em; + padding: 6px 8px; + cursor: pointer; + text-align: center; + transition: opacity 0.15s; + } + .session-show-more-btn:hover { opacity: 0.8; } + .drag-folder-placeholder { + height: 4px; margin: 2px 0; border-radius: 2px; + background: var(--red); opacity: 0.6; + } + .unfiled-drop-zone { + min-height: 8px; border-radius: 4px; transition: all 0.15s; + } + .unfiled-drop-zone.drag-over { + min-height: 24px; outline: 2px dashed var(--red); + background: color-mix(in srgb, var(--red) 9%, transparent); + } + + /* Mobile swipe-to-delete */ + .swipe-delete-action { + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 60px; + background: color-mix(in srgb, var(--color-error) 12%, transparent); + color: var(--color-error); + display: flex; + align-items: center; + justify-content: center; + border-radius: 0 4px 4px 0; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s; + cursor: pointer; + z-index: 1; + } + .swipe-delete-action::before { + content: ''; + display: block; + width: 18px; + height: 18px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='%23ff4444' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 6 5 6 21 6'/%3E%3Cpath d='M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; + } + .swipe-delete-action:active { + background: color-mix(in srgb, var(--color-error) 22%, transparent); + } + .drag-placeholder { + background: color-mix(in srgb, var(--red) 10%, transparent); + border: 2px dashed color-mix(in srgb, var(--color-accent) 40%, transparent); + border-radius: 6px; + margin: 4px 0; + transition: height 0.15s ease; + } + .list-item, + .models-row { + display: flex; + gap: 6px; + align-items: center; + padding: 3px 8px; + margin: 0; + border-radius: 4px; + border: none; + line-height: 1; + font-size: 13px; + background: transparent; + transition: background 0.08s; + cursor: pointer; + touch-action: pan-y; + } + .list-item button { + margin: 0; + height: 24px; + padding: 0 8px; + font-size: 9px; + } + /* Inline "+" action on a tool/section row (Library → new document, + Email → compose). Sizing forced + min-* zeroed so the mobile + touch-target rule (44px) can't inflate it. Selector intentionally + NOT scoped to `.list-item` so the same class also styles buttons + sitting in `.section-header-flex` (e.g. #email-compose-btn). */ + .list-item-plus-btn { + all: unset; + box-sizing: border-box; + flex-shrink: 0; + position: relative; + left: 4px; + display: inline-flex !important; + align-items: center; + justify-content: center; + height: 14px !important; + min-height: 0 !important; + width: auto !important; + min-width: 0 !important; + padding: 0 5px !important; + border-radius: 4px; + color: var(--fg); + /* Always visible at rest. On hover (devices that have it) the + spins + 90° and "new" reveals to its right — neat expand affordance. */ + opacity: 1 !important; + cursor: pointer; + gap: 0; + overflow: visible; + z-index: 1; + transition: background 0.12s, color 0.12s, gap 0.18s ease; + } + .list-item-plus-btn svg.list-item-plus-icon { + width: 13px; height: 13px; + transition: transform 0.22s ease; + } + /* Legacy fallback for plus-btns without the `.list-item-plus-icon` class. */ + .list-item-plus-btn svg:not(.list-item-plus-icon) { width: 13px; height: 13px; } + /* Label is absolutely positioned to the LEFT of the icon so the + stays + put — it just appears beside it on hover instead of pushing it. The + button's intrinsic width remains the icon, so neighbours don't shift. */ + .list-item-plus-label { + position: absolute; + right: 100%; + top: 50%; + transform: translateY(-50%); + padding-right: 5px; + opacity: 0; + pointer-events: none; + white-space: nowrap; + font-size: 9.5px; + line-height: 1; + letter-spacing: 0.02em; + transition: opacity 0.18s ease, transform 0.22s ease; + } + @media (hover: hover) { + .list-item-plus-btn:hover .list-item-plus-icon { + transform: rotate(90deg); + } + .list-item-plus-btn:hover .list-item-plus-label { + opacity: 1; + transform: translateY(-50%) translateX(0); + pointer-events: auto; + } + .list-item-plus-btn .list-item-plus-label { + transform: translateY(-50%) translateX(6px); + } + } + .list-item-plus-btn:hover { + background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent); + color: var(--accent, var(--red)); + } + .list-item span { + color: var(--fg); + font-size: 9.75px; + } + .grow { + flex: 1; + overflow: hidden; + text-overflow: clip; + white-space: nowrap; + } + input, textarea, button, select { + background:var(--bg); + color:var(--fg); + border:1px solid var(--border); + font-family:inherit; + padding:6.9px; + border-radius: 4px; + transition: background 0.08s, border-color 0.08s, color 0.08s, opacity 0.08s; + } + input:hover, textarea:hover, button:hover, select:hover { + border-color: var(--fg); + background-color: var(--panel); + } + :root.light input, + :root.light textarea, + :root.light button, + :root.light select { + background:#eaeaea; + color-scheme: light; + } + input[type="text"] { height:32px; width:100%; } + textarea { width:100%; min-height:32px; height:auto; max-height:30lh; overflow-y:auto; resize:none; } + button { height:32px; padding:0 10px; } + #chat-form button[type="submit"] { height:38px; } + select { height:32px; color-scheme: dark; } + .chat-container { + flex:1; + display:flex; + flex-direction:column; + padding:0 16px; + overflow:hidden; + position:relative; + min-height:0; + min-width:0; + margin-top:8px; + margin-bottom: 0; + } + .chat-meta { font-size:12px; color:color-mix(in srgb, var(--fg) 60%, transparent); margin-bottom:6px; } + .chat-history { + flex:1; + overflow-y:auto; + overflow-x:hidden; + overscroll-behavior-y: none; + margin-bottom:8px; + white-space:normal; + min-height:0; + --chat-max: 800px; + padding-left: max(0px, calc((100% - var(--chat-max)) / 2)); + padding-right: max(12px, calc((100% - var(--chat-max)) / 2 + 12px)); + } + /* Welcome screen — centered in available space above input bar */ + #welcome-screen { + position:absolute; + top:40%; + left:50%; + transform:translate(-50%,-50%); + display:flex; + flex-direction:column; + align-items:center; + text-align:center; + pointer-events:none; + animation: welcome-enter 0.4s ease-out both; + transition: top 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease, transform 0.3s ease; + } + #welcome-screen .welcome-tip, + #welcome-screen .welcome-sub, + #welcome-screen .welcome-version { + transition: opacity 0.25s ease, max-height 0.25s ease, margin 0.25s ease; + max-height: 60px; + overflow: hidden; + } + @media (max-height: 650px) { + #welcome-screen { top: 28%; } + #welcome-screen .welcome-tip { opacity: 0; max-height: 0; margin: 0; } + #welcome-screen .welcome-version { margin-top: 6px; } + .incognito-btn { margin-top: 8px; } + } + @media (max-height: 500px) { + #welcome-screen { top: 22%; } + #welcome-screen .welcome-name { font-size: 1.4rem; margin-bottom: 0; } + #welcome-screen .welcome-sub { opacity: 0; max-height: 0; margin: 0; } + .incognito-btn { margin-top: 4px !important; } + } + @media (max-height: 380px) { + #welcome-screen { opacity: 0; pointer-events: none; } + } + #welcome-screen.hidden { display:none; } + #welcome-screen.kb-hidden { + opacity: 0; + pointer-events: none; + transform: translate(-50%, -50%) scale(0.95); + } + @media (max-width: 768px) { + #welcome-screen { top: 42%; } + #welcome-screen .welcome-name { margin-bottom: 2px; } + #welcome-screen .welcome-sub { margin-bottom: 0; } + #welcome-screen .incognito-btn { margin-top: 6px; } + } + #welcome-screen .welcome-name { + font-size:2.2rem; + font-weight:700; + background: linear-gradient(135deg, var(--brand-color, var(--red)), color-mix(in srgb, var(--brand-color, var(--red)) 60%, var(--fg))); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing:0.03em; + margin-bottom:10px; + } + .welcome-boat { + width: 1.8rem; + height: 1.8rem; + margin-right: 0.4rem; + vertical-align: -0.15em; + color: var(--brand-color, var(--red)); + } + #welcome-screen .welcome-sub { + font-size:0.85rem; + color:color-mix(in srgb, var(--fg) 60%, transparent); + opacity:0.6; + line-height:1.5; + max-width:300px; + } + #welcome-screen .welcome-version { + font-size:0.7rem; + opacity:0.25; + margin-top:12px; + pointer-events:none; + user-select:none; + } + #welcome-screen .welcome-tip { + font-size:0.75rem; + color:var(--fg); + opacity:0.2; + margin-top:24px; + max-width:320px; + line-height:1.4; + } + /* Incognito toggle — on welcome screen */ + .incognito-btn { + pointer-events: auto; + background: none; + border: 1px solid var(--border); + color: var(--fg); + opacity: 0.25; + cursor: pointer; + padding: 6px 12px; + border-radius: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: all 0.15s; + margin-top: 16px; + font-family: inherit; + font-size: 11px; + } + .incognito-btn:hover { + opacity: 0.6; + background: color-mix(in srgb, var(--fg) 6%, transparent); + } + .incognito-btn.active { + opacity: 1; + color: var(--accent); + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); + } + .incognito-btn.active .eye-open { display: none; } + .incognito-btn.active .eye-blinded { display: inline !important; } + .incognito-label { + font-size: 11px; + font-weight: 500; + letter-spacing: 0.3px; + } + /* When welcome is active, push input bar up toward center */ + .chat-container .chat-input-bar { + transition: margin 0.3s ease, max-width 0.3s ease; + } + .chat-container.welcome-active .chat-input-bar { + margin-bottom:30vh; + margin-left:auto; + margin-right:auto; + max-width: 800px; + width: 100%; + } + @media (max-width:768px) { + #welcome-screen .welcome-name { font-size:1.8rem; } + #welcome-screen .welcome-sub { font-size:0.8rem; } + .chat-container.welcome-active .chat-input-bar { + margin-bottom:0; + margin-left:0; + margin-right:0; + } + } + .msg { + margin: 8px 0; + position: relative; + display: flex; + flex-direction: column; + width: fit-content; + max-width: 85%; + min-width: 80px; + border-radius: 12px; + padding: 10px 12px; + line-height: 1.4; + word-wrap: break-word; + overflow-wrap: break-word; + overflow: hidden; + animation: msg-enter 0.3s ease-out both; + } + .msg-user { + align-items: flex-end; + margin-left: auto; + margin-right: 8px; + background: var(--user-bubble-bg, color-mix(in srgb, var(--fg) 8%, var(--bg))); + border: 1px solid var(--bubble-border, var(--border)); + border-radius: 18px 18px 0 18px; + align-self: flex-end; + width: fit-content; + max-width: 85%; + min-width: 80px; + word-wrap: break-word; + overflow-wrap: break-word; + overflow: hidden; + } + .msg-ai { + align-items: flex-start; + margin-right: auto; + margin-left: 8px; + background: var(--ai-bubble-bg, var(--panel)); + border: 1px solid var(--bubble-border, var(--border)); + border-radius: 18px 18px 18px 0; + align-self: flex-start; + width: 85%; + max-width: 85%; + min-width: 80px; + word-wrap: break-word; + overflow-wrap: break-word; + overflow: hidden; + transition: min-height 0.25s ease-out; + } + .msg .role { + font-weight: 600; + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .msg .role::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--model-dot, color-mix(in srgb, var(--fg) 30%, transparent)); + flex-shrink: 0; + } + .msg .role.has-logo::before { display: none; } + .role-provider-logo { display: inline-flex; align-items: center; flex-shrink: 0; } + .role-provider-logo svg { width: 12px; height: 12px; } + .msg-user .role { + color: color-mix(in srgb, var(--fg) 60%, transparent); + } + .msg-user .role::before { + background: color-mix(in srgb, var(--fg) 40%, transparent); + } + .msg .body { + width: 100%; + white-space: normal; + word-break: break-word; + overflow-wrap: anywhere; + line-height: 1.5; + font-size: 0.95em; + margin: 0; + overflow: hidden; + max-width: 100%; + } + .msg .body > * { + margin-top: 8px; + margin-bottom: 8px; + } + .msg .body > *:first-child { + margin-top: 0; + } + .msg .body > *:last-child { + margin-bottom: 0; + } + .msg .body p:empty { + display: none; + } + .msg-user .body { + color: var(--fg); + } + .msg-ai .body { + color: var(--fg); + } + .rag-sources { + margin-top: 12px; + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px; + font-size: 12px; + } + .rag-sources summary { + cursor: pointer; + color: var(--red); + font-weight: bold; + font-size: 11px; + } + .rag-source-item { + margin-top: 8px; + padding: 6px; + background: color-mix(in srgb, var(--fg) 4%, transparent); + border-radius: 4px; + border-left: 2px solid var(--red); + } + .rag-similarity { + color: color-mix(in srgb, var(--fg) 50%, transparent); + font-size: 10px; + margin-left: 6px; + } + .rag-snippet { + color: color-mix(in srgb, var(--fg) 65%, transparent); + font-size: 11px; + margin-top: 4px; + white-space: pre-wrap; + max-height: 60px; + overflow: hidden; + } + .rag-file-delete { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: color-mix(in srgb, var(--fg) 50%, transparent); + cursor: pointer; + font-size: 10px; + padding: 1px 5px; + } + .rag-file-delete:hover { color: var(--fg); border-color: var(--fg); } + .rag-file-size { + color: color-mix(in srgb, var(--fg) 50%, transparent); + font-size: 10px; + margin-left: 4px; + } + .rag-upload-zone { + border: 2px dashed var(--border); + border-radius: 6px; + padding: 12px; + text-align: center; + color: color-mix(in srgb, var(--fg) 45%, transparent); + font-size: 11px; + cursor: pointer; + transition: border-color 0.2s; + } + .rag-upload-zone.dragover { + border-color: var(--red); + color: var(--red); + } + .msg .timestamp { + font-size: 10px; + color: color-mix(in srgb, var(--fg) 45%, transparent); + margin-top: 4px; + text-align: right; + opacity: 0.7; + } + .msg-user .timestamp { + color: color-mix(in srgb, var(--fg) 72%, transparent); + } + pre { + overflow: hidden; + padding: 6px 4px 2px 4px; + border: 1px solid var(--border); + background: var(--bg); + position: relative; + line-height: 1.4; + font-size: 0.95em; + font-family: 'Fira Code', 'Courier New', monospace; + min-height: 38px; + max-width: 100%; + margin: 8px 0; + white-space: pre-wrap; + word-break: break-word; + border-radius: 4px; + min-height: 20px; + min-width: 80px; + } + + /* Ensure code block headers are only slightly larger than regular text */ + pre h1, pre h2, pre h3, pre h4, pre h5, pre h6 { + font-size: 1.1em; + font-weight: bold; + margin: 8px 0 4px 0; + color: var(--fg); + } + .code-lang { + font-size:0.8em; + color:color-mix(in srgb, var(--fg) 60%, transparent); + display:block; + margin-bottom:2px; + font-style:italic; + } + code { font-family:inherit; } + .loading { color:var(--red); font-style:italic; } + #chat-form { display:none; } + + /* Unified chat input bar */ + .chat-input-bar { + background: var(--input-bg, var(--panel)); + border: 1px solid var(--input-border, var(--border)); + border-radius: 16px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 8px; + max-width: 800px; + margin-left: auto; + margin-right: auto; + width: 100%; + } + .chat-input-top { + width: 100%; + position: relative; + } + .chat-input-top > .model-picker-wrap { + position: absolute; + top: 0; + right: 0; + z-index: 2; + transform-origin: top right; + transition: opacity 0.22s ease, transform 0.22s ease; + will-change: opacity, transform; + } + .chat-input-top > .model-picker-wrap.model-picker-autohide { + opacity: 0; + pointer-events: none; + transform: translateY(-4px) scale(0.96); + } + .ghost-text-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + pointer-events: none; + white-space: pre-wrap; + overflow-wrap: break-word; + font-family: inherit; + font-size: 14px; + line-height: 1.5; + padding: 0; + border: 1px solid transparent; + color: transparent; + z-index: 1; + } + .ghost-text-overlay .ghost-suggestion { + color: color-mix(in srgb, var(--fg) 30%, transparent); + } + .chat-input-bar textarea#message { + width: 100%; + background: transparent; + border: none; + outline: none; + resize: none; + font-size: 14px; + line-height: 1.5; + color: var(--fg); + min-height: 24px; + max-height: 200px; + padding: 0; + font-family: inherit; + transition: height 0.12s ease-out; + } + .chat-input-bar textarea#message::placeholder { + color: color-mix(in srgb, var(--fg) 35%, transparent); + } + .chat-input-bottom { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 4px; + } + /* Progressive shrinkage: as the chat input bar gets narrow, sacrifice + chrome before the typing area. First the Agent/Chat toggle drops out, + then the model picker — textarea + send button always stay. Container + queries so it responds to *bar width*, not viewport width (the bar + can be in a split pane). */ + .chat-input-bar { container-type: inline-size; container-name: chatbar; } + @container chatbar (max-width: 340px) { + .chat-input-right .mode-toggle { display: none !important; } + } + @container chatbar (max-width: 260px) { + #model-picker-wrap { display: none !important; } + } + .chat-input-left { + display: flex; + gap: 4px; + align-items: center; + flex-wrap: nowrap; + overflow: hidden; + min-width: 0; + flex: 1; + } + /* Collapsible buttons shrink away; collapsed ones disappear */ + .chat-input-left > .input-icon-btn { + flex-shrink: 0; + } + .chat-input-left > .input-icon-btn.toolbar-collapsed { + display: none !important; + } + /* Always keep overflow wrapper visible and leftmost */ + .chat-input-left > .overflow-wrapper { + flex-shrink: 0; + position: relative; + z-index: 1; + } + .input-divider { + width: 1px; + height: 16px; + background: var(--border); + opacity: 0.4; + margin: 0 2px; + flex-shrink: 0; + } + .chat-input-right { + display: flex; + gap: 8px; + align-items: center; + flex-shrink: 0; + } + .input-icon-btn { + background: none; + border: none; + color: var(--fg); + opacity: 0.5; + cursor: pointer; + padding: 6px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.15s, background 0.15s, color 0.15s; + position: relative; + } + .input-icon-btn:hover { + opacity: 0.8; + background: color-mix(in srgb, var(--accent) 8%, transparent); + } + .input-icon-btn.active, + .input-icon-btn.expanded { + opacity: 1; + color: var(--fg); + background: color-mix(in srgb, var(--fg) 9%, transparent); + } + /* While the menu is open the chevron stays in its highlighted state + — don't run the opacity fade transition so we never flash from + 0.5 → hover-1.0 → drop-back. The state holds steady. */ + .input-icon-btn.expanded { transition: none; } + /* Accent color for Research, Compare toolbar indicators */ + #research-toggle-btn.active, + .tool-indicator.active { + color: var(--red); + background: color-mix(in srgb, var(--red) 12%, transparent); + } + /* Research button glow while actively running */ + #research-toggle-btn.research-running { + opacity: 1 !important; + color: var(--red); + animation: research-pulse 2s ease-in-out infinite; + } + @keyframes research-pulse { + 0%, 100% { background: color-mix(in srgb, var(--red) 12%, transparent); } + 50% { background: color-mix(in srgb, var(--red) 22%, transparent); } + } + .tool-indicator-x { + margin-left: 2px; + opacity: 0.4; + flex-shrink: 0; + transition: opacity 0.15s; + } + .tool-indicator:hover .tool-indicator-x { + opacity: 1; + } + /* Character indicator — hide name text below 768px, icon always visible */ + @media (max-width: 768px) { + #character-indicator-name { display: none !important; } + } + + /* Document indicator — hidden by default, shown when docs exist */ + #doc-indicator-btn { display: none !important; } + #doc-indicator-btn.visible { display: inline-flex !important; } + /* On mobile, the minimized-dock chip replaces this indicator — keep + it hidden regardless of `.visible`. */ + @media (max-width: 768px) { + #doc-indicator-btn, + #doc-indicator-btn.visible { display: none !important; } + } + .doc-indicator-active { + color: var(--accent, var(--accent-primary, var(--red))) !important; + opacity: 1 !important; + } + #doc-indicator-btn.active { + color: var(--accent, var(--accent-primary, var(--red))) !important; + opacity: 1 !important; + background: color-mix(in srgb, var(--fg) 9%, transparent) !important; + } + #overflow-doc-btn.has-docs, + #overflow-doc-btn.active { + color: var(--accent, var(--red)); + opacity: 1; + } + #overflow-doc-btn.has-docs.active { + background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent); + } + .send-btn { + /* Prefer the theme accent (--red). A stored custom `--send-btn-bg` + override only kicks in if it's set to a non-white value — guards + against ChatGPT-style themes where the stored override leaked + to white and rendered the send button invisible. */ + background: var(--send-btn-bg, var(--red)); + color: #fff; + border: none; + border-radius: 8px; + min-width: 32px; + width: 32px; + height: 32px !important; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.25s, border-color 0.25s, color 0.25s, width 0.3s cubic-bezier(0.34, 1, 0.64, 1), padding 0.3s, border-radius 0.3s, opacity 0.1s; + flex-shrink: 0; + overflow: hidden; + } + /* Instant feedback while the send handler is spinning up before streaming begins */ + .send-btn.send-pending { + opacity: 0.55; + pointer-events: none; + animation: send-pending-pulse 0.9s ease-in-out infinite; + } + @keyframes send-pending-pulse { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 0.85; } + } + /* Send button icon transitions — send mode forces no animation */ + .send-btn[data-mode="send"] svg { animation: none !important; } + /* Spin out: + rotates away, then arrow spins in via anim-spin */ + .send-btn.anim-spin-swap svg { animation: btn-spin-out 0.15s ease-in forwards; } + .send-btn.anim-spin-swap .send-btn-label { opacity: 0; transition: opacity 0.1s; } + @keyframes btn-spin-out { + 0% { transform: rotate(0deg) scale(1); opacity: 1; } + 100% { transform: rotate(90deg) scale(0); opacity: 0; } + } + .send-btn.anim-spin svg { animation: btn-spin-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } + .send-btn.anim-launch svg { animation: btn-launch 0.3s cubic-bezier(0.6, 0, 0.4, 1) forwards; } + .send-btn.anim-land svg { animation: btn-land 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); } + @keyframes btn-spin-in { + 0% { transform: rotate(-180deg) scale(0); opacity: 0; } + 100% { transform: rotate(0deg) scale(1); opacity: 1; } + } + /* Arrow flies UP and OUT of the button before the stop icon lands in. + Stays opaque most of the way so it reads as a real launch instead of a + fade-in-place. .anim-launch temporarily lifts the button's overflow so + the icon escapes the 32px box. */ + @keyframes btn-launch { + 0% { transform: translateY(0) scale(1); opacity: 1; } + 70% { transform: translateY(-30px) scale(0.95); opacity: 1; } + 100% { transform: translateY(-58px) scale(0.55); opacity: 0; } + } + .send-btn.anim-launch { overflow: visible !important; } + .send-btn.anim-launch svg { transform-origin: 50% 50%; } + @keyframes btn-land { + 0% { transform: translateY(10px) scale(0.3); opacity: 0; } + 100% { transform: translateY(0) scale(1); opacity: 1; } + } + /* Stop button — Processing: Siren pulse, Receiving: Quarter turn spin */ + .send-btn[data-mode="streaming"][data-phase="processing"] svg { + animation: siren-icon 1.5s ease-in-out infinite; + } + .send-btn[data-mode="streaming"][data-phase="receiving"] svg { + animation: quarter-turn 2s cubic-bezier(0.4, 0, 0.2, 1) infinite; + } + @keyframes siren-icon { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(0.8); } + } + @keyframes quarter-turn { + 0%, 15% { transform: rotate(0deg); } + 25%, 40% { transform: rotate(90deg); } + 50%, 65% { transform: rotate(180deg); } + 75%, 90% { transform: rotate(270deg); } + 100% { transform: rotate(360deg); } + } + .send-btn:hover { + background: var(--send-btn-hover, color-mix(in srgb, var(--red) 80%, white)); + color: #fff; + } + .send-btn.mic-mode, + .send-btn.newchat-mode { + /* Idle / new-chat / mic states blend the picked Send Btn color into + the panel so the user's color choice is visible across every state, + not only briefly while typing. */ + background: color-mix(in srgb, var(--send-btn-bg, var(--red)) 30%, var(--panel, #2a2a2a)); + color: var(--fg); + border: 1px solid var(--border); + transition: width 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.25s, border-color 0.25s, color 0.25s, border-radius 0.3s; + } + .send-btn.mic-mode:hover, + .send-btn.newchat-mode:hover { + background: color-mix(in srgb, var(--send-btn-hover, var(--send-btn-bg, var(--red))) 85%, var(--panel, #2a2a2a)); + color: #fff; + } + /* Hover: just spin the + 90° (no size change). The "New chat" affordance + is the tooltip (title="New chat") plus the icon rotation. + Gated on data-mode="newchat" so the arrow variant (empty-session state + which keeps the newchat-mode class but shows the send arrow) does NOT + rotate on hover. */ + .send-btn[data-mode="newchat"] svg { + transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); + } + .send-btn[data-mode="newchat"]:hover svg { + transform: rotate(90deg); + } + /* "New Session" expanding label */ + .send-btn-label { + width: 0; + max-width: 0; + overflow: hidden; + white-space: nowrap; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.3px; + opacity: 0; + padding: 0; + margin: 0; + transition: max-width 0.35s cubic-bezier(0.34, 1.2, 0.64, 1), width 0.35s, opacity 0.25s, margin-left 0.25s; + } + .send-btn.newchat-expanded .send-btn-label { + width: auto; + max-width: 50px; + opacity: 1; + margin-left: 4px; + } + .send-btn.newchat-expanded { + width: 68px; + padding: 0 10px 0 8px; + } + .send-btn.recording { + background: var(--red) !important; + color: #fff !important; + border: none !important; + animation: pulse-recording 1.5s infinite; + } + @keyframes pulse-recording { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } + } + /* Mode toggle — Agent / Chat */ + .mode-toggle { + display: flex; + flex-shrink: 0; + height: 28px; + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + position: relative; + } + .mode-toggle::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 50%; + height: 100%; + background: color-mix(in srgb, var(--fg) 10%, transparent); + border-radius: 9px; + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + z-index: 0; + } + .mode-toggle.mode-chat::before { + transform: translateX(100%); + } + #mode-agent-btn { + border-radius: 10px 0 0 10px; + } + #mode-chat-btn { + border-radius: 0 10px 10px 0; + } + .mode-toggle-btn { + background: none; + border: none; + color: color-mix(in srgb, var(--fg) 40%, transparent); + cursor: pointer; + padding: 0 10px; + font-size: 11px; + font-weight: 500; + font-family: inherit; + transition: color 0.2s; + white-space: nowrap; + height: 100%; + position: relative; + z-index: 1; + } + .mode-toggle-btn:not(.active):hover { + color: color-mix(in srgb, var(--fg) 60%, transparent); + } + .mode-toggle-btn:hover { + background: none !important; + border-color: transparent !important; + } + .mode-toggle-btn.active, + .mode-toggle-btn.active:hover, + .mode-toggle-btn.active:focus { + color: var(--fg) !important; + background: none !important; + border-color: transparent !important; + cursor: default; + } + .mode-toggle-btn + .mode-toggle-btn { + border-left: none; + } + /* Message count badge in the chat-meta header (next to the title). + Auto-hides when empty so a brand-new chat doesn't show "0 msgs". */ + .chat-meta-count { + font-size: inherit; + font-weight: 400; + color: color-mix(in srgb, var(--fg) 35%, transparent); + white-space: nowrap; + user-select: none; + margin-left: 6px; + } + .chat-meta-count:empty { display: none; } + /* Session cost indicator (next to chevron in header) */ + .session-cost-display { + font-size: inherit; + font-weight: 400; + color: color-mix(in srgb, var(--fg) 35%, transparent); + white-space: nowrap; + user-select: none; + margin-right: 2px; + } + /* Model picker — input bar drop-up */ + .model-picker-wrap { + position: relative; + flex-shrink: 0; + } + .model-picker-btn { + display: inline-flex; + align-items: center; + gap: 4px; + height: 21px; + padding: 0 6px; + font-size: 11px; + font-weight: 500; + font-family: inherit; + background: none; + border: 1px solid transparent; + border-radius: 4px; + color: color-mix(in srgb, var(--fg) 40%, transparent); + cursor: pointer; + white-space: nowrap; + transition: background 0.15s, color 0.15s, border-color 0.15s; + } + .model-picker-btn:hover { + border-color: var(--border); + background: color-mix(in srgb, var(--fg) 8%, transparent); + color: var(--fg); + } + .model-picker-btn #model-picker-label { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + } + .model-picker-btn svg { + flex-shrink: 0; + opacity: 0.5; + } + .model-picker-logo { + display: inline-flex; + align-items: center; + vertical-align: -2px; + } + .model-picker-logo svg { + width: 12px; + height: 12px; + opacity: 0.7; + } + .model-picker-menu { + position: absolute; + bottom: calc(100% + 16px); + right: 0; + z-index: 300; + min-width: 260px; + max-width: 360px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 -4px 20px rgba(0,0,0,0.4); + padding: 6px; + animation: picker-roll-up 0.2s ease-out; + transform-origin: bottom right; + } + .model-picker-menu.closing { + animation: picker-roll-down 0.15s ease-in forwards; + } + .model-picker-menu.hidden { display: none; } + .model-picker-menu input[type="text"] { + width: 100%; + box-sizing: border-box; + padding: 6px 8px; + font-size: 0.82em; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--fg); + font-family: inherit; + outline: none; + margin-bottom: 4px; + transition: border-color 0.15s; + } + .model-picker-menu input[type="text"]:focus { + border-color: var(--red); + } + .model-picker-menu input[type="text"]::placeholder { + color: color-mix(in srgb, var(--fg) 30%, transparent); + } + .model-picker-list { + max-height: min(280px, 50dvh); + overflow-y: auto; + } + .model-picker-list .model-switch-item { + display: flex; + align-items: center; + padding: 5px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 0.82em; + color: var(--fg); + gap: 6px; + } + .model-picker-list .model-switch-item:hover { + background: color-mix(in srgb, var(--red) 8%, transparent); + } + .model-picker-list .mp-section-label { + font-size: 0.72em; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.4; + padding: 6px 8px 2px; + } + /* Overflow "+" menu */ + .overflow-wrapper { + position: relative; + display: flex; + align-items: center; + } + .plus-active-dot { + position: absolute; + top: 2px; + right: 2px; + width: 6px; + height: 6px; + background: var(--fg); + border-radius: 50%; + display: none; + } + .overflow-plus-btn.has-active .plus-active-dot { + display: block; + } + .overflow-menu { + position: fixed; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 4px; + min-width: 170px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + z-index: 1000; + /* Container spring-in: the rounded panel scales/grows out of the + chevron's position before the menu items domino in on top. */ + transform-origin: bottom left; + animation: overflow-menu-pop 0.22s cubic-bezier(0.34, 1.56, 0.64, 1); + } + @keyframes overflow-menu-pop { + 0% { transform: scale(0.6) translateY(8px); opacity: 0; } + 100% { transform: scale(1) translateY(0); opacity: 1; } + } + .overflow-menu.hidden { + display: none; + } + /* Closing state: JS adds `.closing` to play the fold-in animation, + waits for it to finish, then flips to `.hidden`. Container + scales/translates back into the chevron while items peel off from + the top down so the menu collapses into its anchor. */ + .overflow-menu.closing { + animation: overflow-menu-pop-out 0.22s cubic-bezier(0.5, 0, 0.75, 0) forwards; + animation-delay: 0.16s; + } + @keyframes overflow-menu-pop-out { + 0% { transform: scale(1) translateY(0); opacity: 1; } + 100% { transform: scale(0.6) translateY(8px); opacity: 0; } + } + .overflow-menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + background: none; + border: none; + color: var(--fg); + opacity: 0.7; + cursor: pointer; + border-radius: 6px; + font-size: 13px; + font-family: inherit; + transition: background 0.15s, opacity 0.15s, color 0.15s; + /* Domino-style cascade: each item slides up + fades in with a tiny + delay after the previous one (set via nth-last-child below so the + BOTTOM item appears first and the cascade rolls upward — visually + feels like the menu is "stacking up" from the chevron). The + container itself springs in via .overflow-menu's keyframe. */ + animation: overflow-item-in 0.32s cubic-bezier(0.22, 1.61, 0.36, 1) backwards; + } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(1) { animation-delay: 0.06s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(2) { animation-delay: 0.10s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(3) { animation-delay: 0.14s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(4) { animation-delay: 0.18s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(5) { animation-delay: 0.22s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(6) { animation-delay: 0.26s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(7) { animation-delay: 0.30s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(8) { animation-delay: 0.34s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(9) { animation-delay: 0.38s; } + .overflow-menu:not(.hidden):not(.closing) .overflow-menu-item:nth-last-child(10) { animation-delay: 0.42s; } + @keyframes overflow-item-in { + 0% { opacity: 0; transform: translateY(10px) translateX(-6px) scale(0.9); } + 60% { opacity: 1; } + 100% { opacity: 1; transform: translateY(0) translateX(0) scale(1); } + } + /* Fold-in: items peel off top-down (mirror of the open's bottom-up + cascade) so the menu visibly empties before the container scales + back into the chevron. */ + .overflow-menu.closing .overflow-menu-item { + animation: overflow-item-out 0.20s ease-in forwards; + } + .overflow-menu.closing .overflow-menu-item:nth-child(1) { animation-delay: 0.00s; } + .overflow-menu.closing .overflow-menu-item:nth-child(2) { animation-delay: 0.02s; } + .overflow-menu.closing .overflow-menu-item:nth-child(3) { animation-delay: 0.04s; } + .overflow-menu.closing .overflow-menu-item:nth-child(4) { animation-delay: 0.06s; } + .overflow-menu.closing .overflow-menu-item:nth-child(5) { animation-delay: 0.08s; } + .overflow-menu.closing .overflow-menu-item:nth-child(6) { animation-delay: 0.10s; } + .overflow-menu.closing .overflow-menu-item:nth-child(7) { animation-delay: 0.12s; } + .overflow-menu.closing .overflow-menu-item:nth-child(8) { animation-delay: 0.14s; } + .overflow-menu.closing .overflow-menu-item:nth-child(9) { animation-delay: 0.16s; } + .overflow-menu.closing .overflow-menu-item:nth-child(10) { animation-delay: 0.18s; } + @keyframes overflow-item-out { + 0% { opacity: 1; transform: translateY(0) translateX(0) scale(1); } + 100% { opacity: 0; transform: translateY(6px) translateX(-3px) scale(0.92); } + } + .overflow-menu-item:hover { + opacity: 1; + background: color-mix(in srgb, var(--red) 10%, transparent); + } + #overflow-attach-btn { + position: relative; + font-weight: 600; + } + #overflow-attach-btn svg { + transition: transform 0.16s cubic-bezier(0.34, 1.56, 0.64, 1); + } + .overflow-menu-item.active { + opacity: 1; + color: var(--fg); + } + .overflow-active-dot { + width: 6px; + height: 6px; + background: var(--fg); + border-radius: 50%; + margin-left: auto; + display: none; + flex-shrink: 0; + } + .overflow-menu-item.active .overflow-active-dot { + display: block; + } + .attach-strip { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin: 0 0 8px; + min-height: 0; + } + .attach-strip:empty { + display: none; + } + .attach-strip { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin: 6px 0 0; + min-height: 32px; + padding: 2px; + } + .attachment-placeholder { + display: flex; + align-items: center; + color: var(--fg); + opacity: 0.7; + font-style: italic; + padding: 4px 8px; + border-radius: 4px; + background: color-mix(in srgb, var(--fg) 6%, transparent); + } + .attach-btn { width:32px; display:grid; place-items:center; } + .hidden { display:none; } + .toggle { position:relative; display:inline-block; width:30px; height:16px; vertical-align:middle; } + .toggle input { opacity:0; width:0; height:0; } + .toggle .slider { + position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; + background:color-mix(in srgb, var(--fg) 15%, transparent); transition:background .08s; border-radius:8px; + } + .toggle .slider:before { + position:absolute; content:""; height:12px; width:12px; left:2px; top:2px; + background:var(--panel); border-radius:50%; transform:translateX(0); transition:transform .08s; + box-shadow:0 1px 2px rgba(0,0,0,0.25); + } + .toggle input:checked + .slider { background:var(--red); } + .toggle input:checked + .slider:before { transform:translateX(14px); } + .copy-btn { + position:absolute; top:6px; right:6px; + background:var(--bg); color:var(--fg); + border:1px solid var(--border); border-radius:6px; + font-size:12px; height:24px; padding:0 8px; cursor:pointer; + opacity:0; transition:.15s; + } + .msg:hover .copy-btn { opacity:1; } + .role-timestamp { + font-size:0.7rem; color:var(--color-muted-alt); font-weight:normal; margin-left:6px; + } + .msg-footer { + display:flex; align-items:center; gap:6px; + flex-wrap: wrap; + margin-top:6px; + color:var(--color-muted-alt); font-size:0.75rem; + position: relative; + } + .msg-footer .timestamp { + font-size:0.75rem; color:var(--color-muted-alt); + margin:0; opacity:1; + } + .msg-footer .response-metrics { + font-size:0.75rem; color:var(--color-muted-alt); + transition: color .15s; + } + .msg-footer .response-metrics:hover { color:var(--fg); } + /* Context usage ring — right side of footer */ + .ctx-ring { + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: auto; + line-height: 0; + opacity: 0.6; + cursor: default; + transition: opacity 0.15s; + --ctx-stroke: var(--color-muted, #888); + } + .ctx-ring .ctx-ring-pct { + color: var(--color-muted, #888); + transition: color 0.1s ease; + } + .ctx-ring svg circle { + transition: stroke 0.1s ease; + } + .ctx-ring:hover { + opacity: 1; + --ctx-stroke: var(--ctx-color); + } + .ctx-ring:hover .ctx-ring-pct { + color: var(--ctx-color); + } + .ctx-ring-pct { + font-size: 0.7rem; + font-weight: 600; + line-height: 1; + } + /* Context detail popup */ + .ctx-detail-popup { + position: fixed; + z-index: 200; + background: var(--panel, var(--bg)); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 14px; + box-shadow: 0 4px 20px rgba(0,0,0,0.4); + min-width: 220px; + max-width: 280px; + font-size: 0.85rem; + color: var(--fg); + } + .ctx-bar-wrap { + width: 100%; + height: 6px; + background: var(--border); + border-radius: 4px; + overflow: hidden; + } + .ctx-bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.3s; + } + .ctx-compact-btn { + display: block; + width: 100%; + margin-top: 10px; + padding: 6px 0; + background: none; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--fg); + font-size: 0.8rem; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; + } + .ctx-compact-btn:hover { + border-color: var(--accent, var(--red)); + color: var(--accent, var(--red)); + } + .ctx-compact-btn:disabled { + opacity: 0.5; + cursor: default; + } + .compact-wave { + display: inline-block; + color: var(--accent, var(--red)); + letter-spacing: 1px; + font-size: 0.9em; + } + /* Memory-used indicator pill */ + .memory-used-pill { + display: inline-flex; + align-items: center; + background: var(--panel); + border: 1px solid var(--border); + color: var(--fg); + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 10px; + cursor: pointer; + opacity: 0.6; + transition: opacity 0.15s, background 0.15s; + white-space: nowrap; + position: relative; + z-index: 1; + } + .memory-used-pill:hover { opacity: 1; background: var(--border); } + /* Nudge label text 1px down so it visually centers with the icon. */ + .memory-used-pill-text { position: relative; top: 1px; display: inline-block; } + .memory-used-detail { + position: fixed; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 6px 8px; + min-width: 200px; + max-width: min(360px, calc(100vw - 16px)); + max-height: 50vh; + overflow-y: auto; + z-index: 300; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + font-size: 0.75rem; + } + .memory-used-row { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 3px 0; + line-height: 1.3; + } + .memory-used-row + .memory-used-row { + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + } + .memory-used-badge { + flex-shrink: 0; + font-size: 0.7rem; + width: 16px; + text-align: center; + } + .memory-used-badge.pinned { color: var(--red); } + .memory-used-badge.recalled { color: var(--fg); opacity: 0.5; } + .memory-used-text { + color: var(--fg); + opacity: 0.85; + } + + .msg-actions { + display:inline-flex; align-items:center; gap:4px; + } + .footer-copy-btn { + background:none; border:none; + color:var(--color-muted-alt); cursor:pointer; + padding:2px 6px; border-radius:4px; + transition: color .15s; + line-height:1; + display: inline-flex; align-items: center; justify-content: center; + } + .footer-copy-btn:hover { color:var(--accent); } + /* Delete action — same chrome as copy/download/edit but hover reveals + the destructive red tint so the user can tell it's not a benign op. */ + .footer-delete-btn:hover { color: var(--red); } + .regen-btn { + background:none; border:none; + color:var(--color-muted-alt); font-size:1.1rem; cursor:pointer; + padding:2px 6px; border-radius:4px; + transition: color .15s; + line-height:1; + } + .regen-btn:hover { color:var(--accent); } + .fork-btn { + background:none; border:none; + color:var(--color-muted-alt); font-size:1.1rem; cursor:pointer; + padding:2px 6px; border-radius:4px; + transition: color .15s; + line-height:1; + } + .fork-btn:hover { color:var(--accent); } + .msg-action-btn { + background:none; border:none; + color:var(--muted, var(--color-muted-alt)); font-size:1.1rem; cursor:pointer; + padding:2px 6px; border-radius:4px; + transition: color .15s; + line-height:1; + } + .msg-action-btn:hover { color:var(--accent); } + .msg-action-btn[data-action="shorten"] { position:relative; top:1px; font-size:1.25rem; } + .msg-delete-btn { position:relative; top:1px; } + .msg-delete-btn:hover { color:var(--red); } + .msg-more-btn { + font-size:0.7rem; letter-spacing:0.5px; + } + .msg-overflow-menu { + position:fixed; + background:var(--panel); + border:1px solid var(--border); + border-radius:6px; + padding:4px; + box-shadow:0 4px 16px rgba(0,0,0,0.4); + z-index:100; + min-width:150px; + } + .msg-overflow-item { + display:flex; + align-items:center; + gap:6px; + width:100%; + background:none; + border:none; + border-bottom:1px solid color-mix(in srgb, var(--border) 40%, transparent); + color:var(--fg); + font-size:0.8rem; + padding:5px 8px; + border-radius:4px; + cursor:pointer; + text-align:left; + font-family:inherit; + transition:background .1s; + } + .msg-overflow-item:last-child { border-bottom:none; } + .msg-overflow-item:hover { + background:color-mix(in srgb, var(--red) 8%, transparent); + } + .overflow-icon { + width:16px; + text-align:center; + flex-shrink:0; + font-size:1rem; + } + .variant-nav { + display:inline-flex; + align-items:center; + gap:0px; + margin-left:auto; + font-size:0.85em; + font-family:inherit; + opacity:0.6; + } + .variant-divider { + opacity:0.25; + margin:0 4px 0 2px; + } + .variant-tag { + font-size:1.1em; + opacity:0.8; + margin-right:2px; + position:relative; + top:-1px; + } + .variant-tag-scissors { + top:-1px; + } + /* The ✂ "Rewrite shorter" action button glyph sits a touch low against the + other footer icons — nudge it up 2px. */ + .msg-action-btn[data-action="shorten"] { + position: relative; + top: -2px; + } + /* The "?" Explain-simpler glyph also sits slightly low — nudge it up 1px. */ + .msg-action-btn[data-action="explain"] { + position: relative; + top: -1px; + } + .variant-btn { + background:none; + border:none; + color:var(--fg); + cursor:pointer; + padding:4px 6px; + font-size:1em; + font-family:inherit; + opacity:0.7; + line-height:1; + } + .variant-btn:hover { opacity:1; color:var(--fg); } + .variant-btn:disabled { opacity:0.3; cursor:default; } + .variant-num { + background:none; + border:none; + color:var(--fg); + cursor:pointer; + padding:4px 4px; + font-size:1em; + font-family:inherit; + opacity:0.7; + line-height:1; + } + .variant-num:hover { opacity:1; color:var(--fg); } + .variant-num:disabled { opacity:0.5; cursor:default; } + .variant-slash { + opacity:0.4; + font-size:1em; + } + .continue-separator { + display:inline; + opacity:0.35; + font-size:0.85em; + font-style:italic; + } + .stopped-indicator { + color:var(--red); + margin-top:8px; + font-size:0.85em; + opacity:0.8; + display:flex; + align-items:center; + gap:8px; + flex-wrap:wrap; + } + + /* Message edit UI */ + .msg-edit-textarea { + background:var(--bg); + color:var(--fg); + border:1px solid var(--border); + border-radius:6px; + padding:10px; + font-family:inherit; + font-size:inherit; + line-height:1.6; + resize:vertical; + box-sizing:border-box; + } + .msg-edit-textarea:focus { outline:1px solid var(--hl-function, #61afef); border-color:var(--hl-function, #61afef); } + .msg-edit-bar { + display:flex; + gap:8px; + margin-top:6px; + } + .msg-edit-save, .msg-edit-cancel { + background:var(--bg); + color:var(--fg); + border:1px solid var(--border); + border-radius:6px; + padding:4px 14px; + cursor:pointer; + font-size:0.85em; + } + .msg-edit-save:hover { border-color:var(--color-save-green, #4caf50); color:var(--color-save-green, #4caf50); } + .msg-edit-cancel:hover { border-color:var(--red); color:var(--red); } + + /* Edited indicator — similar to stopped-indicator */ + .edited-indicator { + color:var(--fg); + margin-top:8px; + font-size:0.85em; + opacity:0.4; + font-style:italic; + } + .continue-btn { + background:none; + border:none; + color:var(--fg); + opacity:0.5; + cursor:pointer; + font-size:2.6em; + padding:2px 2px 0; + line-height:1; + } + .continue-btn:hover { + opacity:0.8; + } + .ctx-indicator { + display:inline-flex; align-items:center; gap:1px; + font-size:0.75rem; + } + .ctx-popup { + position:fixed; + z-index:250; + background:var(--panel); + border:1px solid var(--border); + border-radius:8px; + padding:10px 14px; + font-size:0.8rem; + color:var(--fg); + box-shadow:0 8px 24px rgba(0,0,0,0.4); + min-width:180px; + line-height:1.7; + } + .ctx-label { + display:inline-block; + width:60px; + color:var(--color-muted-alt); + font-size:0.75rem; + } + .edit-btn { + background:none; border:none; + color:var(--color-muted-alt); font-size:1.1rem; cursor:pointer; + padding:2px 6px; border-radius:4px; + transition: color .15s; + line-height:1; + } + .edit-btn:hover { color:var(--accent); } + .edit-textarea { + width:100%; background:var(--bg); color:var(--fg); + border:1px solid var(--border); border-radius:6px; + padding:8px; font-family:inherit; font-size:0.95rem; + resize:vertical; outline:none; + min-height:80px; + } + .edit-textarea:focus { border-color:var(--red); } + .edit-save-btn, .edit-cancel-btn { + background:var(--bg); color:var(--fg); + border:1px solid var(--border); border-radius:6px; + padding:4px 12px; cursor:pointer; font-size:0.8rem; + } + .edit-save-btn:hover { background:var(--panel); } + .edit-cancel-btn:hover { background:var(--panel); } + pre .copy-code { + position:absolute; right:6px; + background:var(--bg); color:var(--fg); + border:1px solid var(--border); border-radius:6px; + width:28px; height:28px; padding:0; cursor:pointer; + opacity:0; transition: opacity .15s, color .15s, border-color .15s; + display:flex; align-items:center; justify-content:center; + } + pre .copy-code { top:6px; } + pre .copy-code.bottom { top:auto; bottom:6px; } + pre:hover .copy-code { opacity:0.7; } + pre .copy-code:hover { opacity:1; } + pre .copy-code.copied { + opacity: 1; + color: var(--color-save-green, #4caf50); + border-color: var(--color-save-green, #4caf50); + background: color-mix(in srgb, var(--color-save-green, #4caf50) 18%, var(--bg)); + animation: code-copy-pulse 0.36s cubic-bezier(0.34, 1.56, 0.64, 1); + } + @keyframes code-copy-pulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.15); } + 100% { transform: scale(1); } + } + /* Slim text-button variant: swap "Copy" → "✓ Copied" via the + ::before content while still inheriting the green flash + pulse. */ + pre.pre-compact .copy-code.copied::before { content: '✓ Copied'; } + + /* Edit code button — positioned left of copy button */ + pre .edit-code { + position:absolute; right:42px; top:6px; + background:var(--bg); color:var(--fg); + border:1px solid var(--border); border-radius:6px; + width:28px; height:28px; padding:0; cursor:pointer; + opacity:0; transition: opacity .15s, color .15s, border-color .15s; + display:flex; align-items:center; justify-content:center; + } + pre .edit-code.bottom { top:auto; bottom:6px; } + pre:hover .edit-code { opacity:0.7; } + pre .edit-code:hover { opacity:1; } + /* When the edit-code button is in "save" mode (checkmark), use the + theme accent so it matches the EDITING outline + label that are + also accent-coloured — clearer that this is the confirm action. */ + pre .edit-code.active { + opacity: 1; + color: var(--accent-primary, var(--red)); + border-color: var(--accent-primary, var(--red)); + background: color-mix(in srgb, var(--accent-primary, var(--red)) 12%, var(--bg)); + } + + /* Tapping the code body (not a button) toggles the overlay buttons off so + they stop covering the text on touch screens. Tap again to bring back. */ + pre.buttons-hidden .copy-code, + pre.buttons-hidden .edit-code, + pre.buttons-hidden .run-code { opacity:0 !important; pointer-events:none !important; } + + /* Editing state — subtle border on the code block */ + /* Editing state: was a 1px subtle outline that was almost invisible on + mobile, so users couldn't tell their tap-to-edit had actually engaged. + Use the accent colour + a tinted background so it reads at a glance. */ + pre.editing { + outline: 2px solid var(--accent-primary, var(--red)); + outline-offset: -2px; + background: color-mix(in srgb, var(--accent-primary, var(--red)) 6%, var(--bg)) !important; + } + pre.editing code.editing { outline:none; cursor:text; } + pre.editing::before { + content: 'EDITING'; + position: absolute; top: 0; left: 0; + padding: 2px 8px; + font-size: 9px; font-weight: 700; letter-spacing: 0.5px; + background: var(--accent-primary, var(--red)); + color: #fff; + border-radius: 0 0 4px 0; + z-index: 2; + pointer-events: none; + } + + /* Run code button — positioned left of edit button */ + pre .run-code { + position:absolute; right:78px; top:6px; + background:var(--bg); color:var(--fg); + border:1px solid var(--border); border-radius:6px; + width:28px; height:28px; padding:0; cursor:pointer; + opacity:0; transition: opacity .15s, color .15s, border-color .15s; + display:flex; align-items:center; justify-content:center; + } + pre .run-code.bottom { top:auto; bottom:6px; } + pre:hover .run-code { opacity:0.7; } + pre .run-code:hover { opacity:1; color:var(--hl-function, #61afef); border-color:var(--hl-function, #61afef); } + + /* Compact (single-line) code blocks: slim buttons so the row doesn't + double the height of a 1-line bash. Copy is text ("Copy" → "✓ Copied"), + Run and Edit keep their icons but at smaller sizes. Edit swaps to + a "Save" text label when its .active state is on. */ + pre.pre-compact { padding-right: 200px; min-height: 0; } + pre.pre-compact .copy-code, + pre.pre-compact .edit-code, + pre.pre-compact .run-code { + height: 20px; + padding: 0; + font-size: 10px; + font-weight: 500; + line-height: 20px; + top: 3px; + } + /* Copy: text-only, hide SVG */ + pre.pre-compact .copy-code { + width: auto; + padding: 0 8px; + right: 6px; + gap: 0; + } + pre.pre-compact .copy-code svg { display: none; } + pre.pre-compact .copy-code::before { content: 'Copy'; } + pre.pre-compact .copy-code.copied::before { content: '✓ Copied'; } + /* Edit: icon + "Edit" label, swap to "Save" when editing */ + pre.pre-compact .edit-code { + width: auto; + padding: 0 8px 0 6px; + gap: 3px; + right: 64px; + } + pre.pre-compact .edit-code svg { width: 12px; height: 12px; } + pre.pre-compact .edit-code::after { content: 'Edit'; } + pre.pre-compact .edit-code.active::after { content: 'Save'; } + /* Run: icon + "Run" label */ + pre.pre-compact .run-code { + width: auto; + padding: 0 8px 0 6px; + gap: 3px; + right: 126px; + } + pre.pre-compact .run-code svg { width: 12px; height: 12px; } + pre.pre-compact .run-code::after { content: 'Run'; } + /* Bottom-positioned slim buttons (when pre is near the top of the + viewport, the existing JS toggles .bottom to flip them down). */ + pre.pre-compact .copy-code.bottom, + pre.pre-compact .edit-code.bottom, + pre.pre-compact .run-code.bottom { top: auto; bottom: 3px; } + + /* Touch devices: no hover, so always show copy/run/edit buttons */ + @media (hover: none) { + pre .copy-code { opacity:0.7; } + pre .edit-code { opacity:0.7; } + pre .run-code { opacity:0.7; } + } + + /* Code runner output panel */ + .code-runner-output { + position:relative; + border:1px solid var(--border); border-top:2px solid var(--hl-function, #61afef); + border-radius:0 0 4px 4px; + background:var(--bg); + margin:-4px 0 8px 0; + padding:8px 12px; + max-height:400px; + overflow:auto; + } + .code-runner-pre { + margin:0; padding:0; + font-family:'Fira Code', 'Courier New', monospace; + font-size:0.9em; line-height:1.5; + white-space:pre-wrap; word-break:break-word; + color:var(--fg); + background:none !important; + border:none !important; + } + .code-runner-error { color:var(--red); } + .code-runner-loading { font-style:italic; color:var(--red); padding:4px 0; } + .code-runner-close { + position:absolute; top:4px; right:4px; + background:none; border:none; color:var(--fg); + cursor:pointer; opacity:0.5; font-size:14px; padding:2px 6px; + } + .code-runner-close:hover { opacity:1; } + /* Labeled copy pill — pinned top-right INSIDE the run-output panel, not + in a separate footer. Panel is position:relative so absolute works. */ + .code-runner-copy-inline { + position: absolute; top: 6px; right: 32px; /* sits LEFT of the X close (top:4 right:4 ~24px wide) */ + z-index: 2; + background: var(--panel); color: var(--fg); + border: 1px solid var(--border); border-radius: 6px; + padding: 3px 10px; font-size: 11px; cursor: pointer; + display: inline-flex; align-items: center; + transition: border-color 0.15s, color 0.15s, background 0.15s; + } + .code-runner-copy-inline:hover { + border-color: var(--accent-primary, var(--red)); + color: var(--accent-primary, var(--red)); + } + /* Reserve room on the right so the output text doesn't slide under + either the Copy pill or the Close X. Applies to both the chat run + panel AND the document panel (doc-run-output reuses the same children). */ + .code-runner-output, + .doc-run-output { padding-right: 110px; } + + .toast { + position:fixed; + top: 16px; right: 16px; + left: auto; bottom: auto; + background:var(--panel); color:var(--fg); + border:1px solid color-mix(in srgb, var(--accent) 30%, transparent); + border-left: 3px solid var(--accent); + padding:8px 12px; border-radius:6px; font-size:12px; opacity:0; + /* Off-screen to the right by default; .show slides to 0; + removing .show transitions to -120% (off-screen left). */ + transform: translateX(120%); + transition: opacity .35s cubic-bezier(0.22, 1, 0.36, 1), + transform .45s cubic-bezier(0.22, 1, 0.36, 1); + z-index: 9999; pointer-events: none; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + backdrop-filter: blur(12px); + max-width: min(360px, calc(100vw - 32px)); + } + .toast.show { opacity:1; transform: translateX(0); } + .toast.exiting { + opacity: 0; + transform: translateX(-120%); + } + .toast.error { + border-color: color-mix(in srgb, var(--color-error) 40%, transparent); + border-left-color: var(--color-error); + color: var(--color-error); + } + /* When the notes panel is docked to the right, the default top-right toast + sits directly over the Archive / View-toggle buttons in the panel header. + Flip it to the top-left so you can still reach the header after an + archive action. Mirror the slide direction too (enter from left, exit + to right) so the motion still reads naturally. */ + body:has(.notes-pane.modal-right-docked) .toast { + right: auto; + left: 16px; + transform: translateX(-120%); + } + body:has(.notes-pane.modal-right-docked) .toast.show { transform: translateX(0); } + body:has(.notes-pane.modal-right-docked) .toast.exiting { transform: translateX(120%); } + @media (max-width: 768px) { + .toast { + top: 12px; + right: 12px; + max-width: calc(100vw - 24px); + /* Receive touches so the swipe-to-dismiss gesture works (desktop keeps + pointer-events:none so the toast never blocks clicks). Horizontal pan + only, so it doesn't fight page scroll. */ + pointer-events: auto; + touch-action: pan-x; + } + } + .stop-btn { + position:absolute; top:2px; right:2px; + background:var(--panel); + color:var(--fg); + border:1px solid var(--fg); + font-family:inherit; + font-size:1em; line-height:1; padding:2px 5px; + cursor:pointer; + } + .small-note { font-size:12px; color:color-mix(in srgb, var(--fg) 60%, transparent); margin-top:4px; } + .row-end { justify-content:flex-end; } + .model-chat-btn { + height:32px; padding:0 10px; margin-left:auto; + } + /* Nudge the "+ Chat" label down 1px to sit centered in the button. */ + .model-chat-btn-label { + position: relative; + top: 1px; + } + .openai-row { + display:flex; align-items:center; gap:6px; + } + .models-row { + display:flex; align-items:center; gap:6px; border:1px solid var(--border); padding:4px; margin:4px 0; border-radius: 4px; + } + .models-row .grow, + .models-row select { + flex:1; + display:flex; + align-items:center; + font-size: 9.75px; + } + .model-fav-btn { + width: 8px; height: 8px; + border-radius: 50%; + border: 1.5px solid color-mix(in srgb, var(--fg) 22%, transparent); + flex-shrink: 0; + cursor: pointer; + transition: all 0.15s; + position: relative; + margin-left: 4px; + } + .model-fav-btn::before { + content: ''; + position: absolute; + top: -10px; left: -10px; right: -10px; bottom: -10px; + } + .model-fav-btn:hover { + border-color: var(--fg); + background: color-mix(in srgb, var(--fg) 27%, transparent); + transform: scale(1.3); + } + .model-fav-btn.active { + background: var(--fg); + border-color: var(--fg); + } + .model-fav-btn.active:hover { + opacity: 0.6; + } + .model-search-input { + width: 100%; + padding: 6px 10px; + margin-bottom: 4px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--fg); + font-family: inherit; + font-size: 0.8rem; + outline: none; + box-sizing: border-box; + transition: border-color 0.15s; + } + .model-search-input:focus { + border-color: var(--red); + } + .model-search-input::placeholder { + color: color-mix(in srgb, var(--fg) 30%, transparent); + } + .models-row button { + font-size: 9px; + height: 24px; + padding: 0 8px; + } + @media (max-width:768px){ + .box { max-height:none; } + .chat-container { padding:10px; flex:1; margin-top:0; padding-top:42px; min-height:0; } + .scroll-nav-btn { width:44px; height:44px; font-size:14px; margin-bottom:0; } + .send-btn { width:48px; height:48px !important; border-radius:12px; } + .send-btn svg { width:22px; height:22px; } + #compare-toggle-btn { display:none !important; } + .section[draggable] { -webkit-user-drag:none; } + .drag-handle, .item-drag-handle, .folder-drag-handle { display:none !important; } + /* Sidebar overlays chat on mobile */ + .sidebar { + position: fixed !important; + top: 0; bottom: 0; left: 0; + z-index: 200; + width: 80% !important; + max-width: 340px; + box-shadow: 4px 0 20px rgba(0,0,0,0.5); + transition: transform 0.25s ease, opacity 0.25s ease !important; + opacity: 1 !important; + overflow: visible !important; + } + .sidebar.hidden { + width: 80% !important; + transform: translateX(-100%); + pointer-events: none; + overflow: hidden !important; + } + .sidebar.right-side.hidden { + transform: translateX(100%); + } + .sidebar.right-side { + left: auto; right: 0; + box-shadow: -4px 0 20px rgba(0,0,0,0.5); + } + /* Backdrop behind sidebar */ + #sidebar-backdrop { + position: fixed; + inset: 0; + z-index: 199; + background: rgba(0,0,0,0.4); + opacity: 0; + pointer-events: none; + transition: opacity 0.35s ease; + } + #sidebar-backdrop.visible { opacity: 1; pointer-events: auto; } + /* Elastic overscroll on sidebar */ + .sidebar-inner { + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: auto; + overflow-y: scroll !important; + padding-bottom: 40px; + } + .sidebar:not(.hidden) { + overscroll-behavior: auto; + } + /* Sidebar header — room for hamburger */ + .sidebar-header { + padding: 18px 12px 8px 12px; + min-height: 44px; + } + .sidebar-brand-title { + font-size: 1.2rem; + left: 0 !important; + } + /* Sidebar inner — bigger spacing */ + .sidebar-inner { + padding: 12px 10px 12px !important; + gap: 4px !important; + } + /* Section headers — match list item sizing */ + .section-header-flex { + padding: 12px 10px !important; + height: 48px !important; + border-radius: 8px; + box-sizing: border-box; + } + .section-header-flex h4, + .section-header-flex .section-title { + font-size: 14px !important; + gap: 11px !important; + } + .section-icon, + .sidebar-action-icon { + width: 16px !important; + height: 16px !important; + left: 0 !important; + } + /* List items — bigger touch targets, bigger text */ + .list-item { + padding: 12px 10px !important; + min-height: 48px; + font-size: 14px; + border-radius: 8px; + gap: 10px !important; + } + .list-item .grow { + font-size: 14px !important; + } + /* Search, New Chat & Assistant */ + #sidebar-search-btn, + #sidebar-new-chat-btn, + .sidebar-assistant-entry { + padding: 12px 10px !important; + min-height: 48px !important; + } + #sidebar-search-btn .grow, + #sidebar-new-chat-btn .grow, + .sidebar-assistant-entry .grow { + font-size: 14px !important; + left: 0 !important; + } + /* Section separator — more breathing room */ + .section { + margin-top: 4px; + border-radius: 8px; + } + /* Wave accent — slightly bigger on mobile */ + .section-header-flex h4::before { + height: 18px; + } + /* Compact top bar on mobile — align with sidebar header */ + .chat-top-bar { + padding: 1px 8px; + min-height: 14px; + margin-top: -31px; + padding-top: 31px; + } + .chat-top-bar .chat-new-btn { display: none; } + .chat-meta-overlay { font-size: 0.65em; max-width: 55%; overflow: visible; left: 50%; top: 50%; transform: translate(-50%, -50%); position: absolute; } + .chat-meta-overlay .export-dl-btn { display: inline-flex; } + /* Incognito — smaller on mobile */ + .incognito-btn { + padding: 4px 10px; + font-size: 10px; + } + /* Incognito indicator — right side next to hamburger on mobile */ + .incognito-indicator { + position: fixed; + top: 12px; + left: auto !important; + right: 48px !important; + transform: none; + width: 32px; + height: 32px; + z-index: 210; + opacity: 0.8; + } + /* Icon rail on mobile — hidden by default, shown in mini-sidebar state */ + .icon-rail { display: none !important; } + .icon-rail.mobile-mini { + display: flex !important; + position: fixed; + top: 0; bottom: 0; left: 0; + z-index: 200; + width: 48px; + box-shadow: 2px 0 12px rgba(0,0,0,0.4); + } + .icon-rail.mobile-mini.right-side { + left: auto; right: 0; + box-shadow: -2px 0 12px rgba(0,0,0,0.4); + } + /* Chat bubbles — AI stretches full width, user stays compact */ + .msg { font-size: 0.85em; } + .msg .body { font-size: 0.9em; } + .msg-user { max-width: 90% !important; margin-left: auto; margin-right: 4px; } + .msg-ai, .agent-thread { width: 100% !important; max-width: 100% !important; margin: 8px 0; } + /* Prevent inner scrollable elements from trapping vertical scroll */ + .msg pre, + .agent-tool-output pre, + .agent-thread-cmd, + .msg details { + overflow-y: hidden !important; + max-height: none !important; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + touch-action: pan-y pan-x; + } + /* Models row — match list items */ + .models-row { + padding: 12px 10px; + min-height: 48px; + } + /* Input icon buttons — bigger touch targets */ + .input-icon-btn { + padding: 10px; + min-width: 44px; + min-height: 44px; + } + /* Tool indicators — icon only on mobile (hide text, keep x) */ + .tool-indicator > span { + display: none !important; + } + .tool-indicator .tool-indicator-x { + display: inline-block !important; + opacity: 0.6; + } + /* Hamburger — always right on mobile */ + .hamburger-btn { + width: 44px; + height: 44px; + top: 6px; + left: auto !important; + right: 4px !important; + -webkit-tap-highlight-color: transparent; + } + .hamburger-btn:hover, + .hamburger-btn:active { + background: none !important; + border: none !important; + box-shadow: none !important; + } + /* New chat — always left on mobile */ + .mobile-new-chat-btn { + display: flex; + position: fixed; + top: 12px; + left: 8px; + z-index: 210; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--fg); + cursor: pointer; + opacity: 0.5; + padding: 0; + } + /* Modal close button — bigger on mobile */ + .close-btn, + .modal-close { + min-width: 44px; + min-height: 44px; + width: 44px; + height: 44px; + font-size: 14px; + } + /* Code block buttons — slightly bigger touch targets */ + pre .copy-code, + pre .edit-code, + pre .run-code { + width: 44px; + height: 44px; + } + /* Touch-friendly targets for small buttons */ + .export-dl-btn { + min-width: 44px; + min-height: 44px; + } + .section-header-btn { + min-width: 44px; + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + } + /* 44×44 touch buttons starting from right:6 ends at 50. ~8px gap is + enough to be distinguishable without spreading them too far apart. */ + pre .edit-code { + right: 58px; + } + pre .run-code { + right: 110px; + } + /* Dropdowns — bigger touch targets on mobile */ + .dropdown-item-compact { + padding: 12px 12px !important; + font-size: 14px !important; + min-height: 44px; + gap: 10px !important; + } + .dropdown-item-compact .dropdown-icon { + width: 18px !important; + height: 18px !important; + } + .dropdown-item-compact .dropdown-icon svg { + width: 16px !important; + height: 16px !important; + } + .dropdown, + .session-dropdown { + padding: 6px !important; + border-radius: 12px !important; + } + /* Safe area padding for notched devices */ + .chat-input-bar { + padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px)); + } + /* Mode toggle — larger touch targets */ + .mode-toggle { + height: 34px; + border-radius: 12px; + } + .mode-toggle::before { + border-radius: 11px; + } + .mode-toggle-btn { + padding: 0 14px; + font-size: 12px; + min-height: 34px; + } + #mode-agent-btn { border-radius: 12px 0 0 12px; } + #mode-chat-btn { border-radius: 0 12px 12px 0; } + /* Diff mode — stack toolbar, bigger buttons */ + .diff-toolbar { + padding: 8px 12px; + gap: 6px; + flex-wrap: wrap; + } + .diff-toolbar-btn { + padding: 6px 12px; + font-size: 12px; + min-height: 36px; + } + .diff-chunk-btn { + width: 28px; + height: 28px; + font-size: 14px; + } + /* Document/email/gallery/research library — full-height bottom-sheet + on mobile. Keep the rounded top corners + border-top so they match + the cookbook/calendar/compare look instead of looking like raw + full-bleed panels. */ + .doclib-modal-content, + .gallery-modal-content { + width: 100vw !important; + max-width: 100vw !important; + /* vh fallback, dvh override so the modal adapts to mobile + URL-bar show/hide. Order matters: later same-specificity rule + wins, so dvh must come after vh. */ + max-height: 100vh !important; + max-height: 100dvh !important; + height: 100vh; + height: 100dvh; + border-radius: 14px 14px 0 0 !important; + border: none !important; + border-top: 1px solid var(--border) !important; + box-shadow: none !important; + padding: 6px !important; + padding-bottom: env(safe-area-inset-bottom, 6px) !important; + margin: 0 !important; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + } + #email-lib-modal .doclib-grid { + max-height: none; + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + } + /* Library modal layout: pin the header + tab strip, give the active + panel (admin-card) the remaining height, and let the grid scroll + internally. The load-more button sits below the grid as a sibling + inside admin-card so it stays visible at the bottom of the panel + without competing with the grid for scroll. */ + #doclib-modal .doclib-modal-content { + display: flex !important; + flex-direction: column !important; + overflow: hidden !important; + } + #doclib-modal .modal-header, + #doclib-modal .lib-tabs { + flex: 0 0 auto !important; + } + #doclib-modal .modal-body { + flex: 1 1 0 !important; + min-height: 0 !important; + overflow: hidden !important; + } + #doclib-modal .admin-card { + flex: 1 1 0 !important; + min-height: 0 !important; + } + /* :not(:has(.doclib-card-expanded)) scopes these to the normal list + state — when a document card is expanded, the existing expand-state + rules take over (grid claims flex:1 with overflow:hidden so the + expanded card can scroll itself). */ + #doclib-modal .doclib-grid:not(:has(.doclib-card-expanded)) { + flex: 1 1 0 !important; + min-height: 0 !important; + max-height: none !important; + overflow-y: auto !important; + -webkit-overflow-scrolling: touch; + } + #doclib-modal .doclib-load-more { + margin: 8px auto 12px !important; + } + /* Squeeze a little more height for the preview content by tightening + the spacing inside an expanded doc card on mobile. */ + #doclib-modal .doclib-card.doclib-card-expanded, + #email-lib-modal .doclib-card.doclib-card-expanded, + #memory-modal .doclib-card.doclib-card-expanded { + gap: 2px !important; + padding: 4px 6px !important; + height: 100% !important; + } + /* Skills preview kept stopping at partial height because the flex + chain (modal → tabs → panel → admin-card → grid → card) wouldn't + reliably hand the card a full-height parent. But the skills LIST + already scrolls correctly inside #skills-list, so that grid box + already has the right bounded height (the area below the tabs). + Anchor the expanded card to THAT box with position:absolute so it + fills the list area exactly — header + tabs stay visible. + + NOTE: position:relative here is UNCONDITIONAL (not gated behind + :has(.doclib-card-expanded)). Firefox mobile builds without :has() + support never applied the gated rule, so the absolute card lost its + anchor and only filled ~50% — while Chromium/desktop-narrow worked. + Relative-when-collapsed is harmless (no abs children then). */ + #memory-modal #skills-list.doclib-grid { + position: relative !important; + } + #memory-modal .doclib-card.skill-card.doclib-card-expanded { + position: absolute !important; + inset: 0 !important; + /* Height is set in JS (skills.js _fillSkillCardHeight) as an explicit + px value — Firefox does NOT treat inset:0 stretch OR height:100% + (against the flex-sized grid) as a definite height, so grid/flex + children never filled. An explicit px height is unambiguous. */ + margin: 0 !important; + padding: 8px 10px calc(8px + env(safe-area-inset-bottom, 0px)) !important; + background: var(--bg) !important; + /* Flex column. JS (_fillSkillCardHeight) sets EXPLICIT px heights on + the preview +
; in a flex column an explicit-height item
+           (flex:0 0 auto + height) is honoured. (Grid was worse here — the
+           collapsed 1fr track overrode the preview's explicit height.) */
+        display: flex !important;
+        flex-direction: column !important;
+        overflow: hidden !important;
+        border: none !important;
+        border-radius: 0 !important;
+        box-sizing: border-box !important;
+      }
+      /* Preview is the 1fr grid track — give it a definite-height flex column
+         so the 
 (flex:1) fills and the footer pins at the bottom. */
+      #memory-modal .doclib-card.skill-card.doclib-card-expanded > .doclib-card-preview {
+        display: flex !important;
+        flex-direction: column !important;
+        min-height: 0 !important;
+        overflow: hidden !important;
+      }
+      /* The card now fills the screen, but a base rule (.skill-card...
+         .doclib-card-preview { flex: 0 1 auto }) sizes the preview to its
+         CONTENT — so a medium SKILL.md left the preview at ~half the card
+         (the "only 50%" the debug confirmed: card was full, content wasn't).
+         Force the preview AND the 
 to FILL the card. Extra .skill-card
+         in the selector out-specifies both the base and the generic mobile
+         rule so this wins without ambiguity. */
+      #memory-modal .doclib-card.skill-card.doclib-card-expanded > .doclib-card-preview {
+        flex: 1 1 auto !important;
+        min-height: 0 !important;
+      }
+      #memory-modal .doclib-card.skill-card.doclib-card-expanded .skill-md-pre {
+        flex: 1 1 auto !important;
+        min-height: 0 !important;
+      }
+      /* Flatten the expanded doc/email card on mobile — drop the inner
+         border + background so it doesn't read as a "subwindow" inside the
+         modal (the chat preview doesn't have that nested-card look). The
+         body prefix beats the desktop email rule later in the file that
+         paints a 2px accent border + box-shadow with !important. */
+      body #doclib-modal .doclib-card.doclib-card-expanded,
+      body #email-lib-modal .doclib-card.doclib-card-expanded,
+      body #memory-modal .doclib-card.doclib-card-expanded {
+        background: transparent !important;
+        border: none !important;
+        border-radius: 0 !important;
+        box-shadow: none !important;
+      }
+      /* Same layout pattern the chat preview uses: preview itself clips,
+         the 
 (or PDF iframe) inside owns the scroll, and the action
+         bar pins to the bottom with extra bottom padding so it floats
+         above the iOS safe-area / home-indicator strip. */
+      #doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview,
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-preview,
+      #memory-modal .doclib-card.doclib-card-expanded .doclib-card-preview {
+        padding: 4px 6px 20px !important;
+        flex: 1 1 auto !important;
+        min-height: 82dvh !important;
+        overflow: hidden !important;
+        display: flex !important;
+        flex-direction: column !important;
+        border-top: none !important;
+      }
+      /* The "Load more" button is flex-shrink:0 and sits in the panel AFTER the
+         grid, so even when a card is expanded it reserves ~40px at the panel
+         bottom — shrinking the visible grid and clipping the action bar by that
+         much (the "black bar"). The global collapse rule's max-height:0 can't
+         remove a flex-shrink:0 item, so hide it outright on expand. */
+      #doclib-modal [data-doclib-panel="documents"]:has(.doclib-card-expanded) .doclib-load-more,
+      #doclib-modal [data-doclib-panel="documents"]:has(.doclib-card-expanded) .doclib-inline-load-more {
+        display: none !important;
+      }
+      /* Small bottom padding now that the load-more no longer steals space —
+         the action bar sits low, just clearing the home-indicator safe area. */
+      #doclib-modal [data-doclib-panel="documents"] .doclib-card.doclib-card-expanded .doclib-card-preview {
+        padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px)) !important;
+      }
+      #doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview pre,
+      #doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview .doclib-card-pdf-frame,
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-preview pre,
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-preview .email-reader-body,
+      #memory-modal .doclib-card.doclib-card-expanded .doclib-card-preview pre,
+      #memory-modal .doclib-card.doclib-card-expanded .doclib-card-preview .skill-md-editor {
+        flex: 1 1 auto !important;
+        min-height: 0 !important;
+        overflow-y: auto !important;
+        -webkit-overflow-scrolling: touch;
+        /* Strip the code-box visual treatment so the 
 / reader body
+           doesn't read as a "sub-window" inside the modal. */
+        background: transparent !important;
+        border: none !important;
+        box-shadow: none !important;
+        padding: 0 !important;
+        margin: 0 !important;
+        border-radius: 0 !important;
+      }
+      /* The skill editor textarea keeps a light frame on mobile (it's an
+         input, not read-only text) — override the strip-down above. */
+      #memory-modal .doclib-card.doclib-card-expanded .doclib-card-preview .skill-md-editor {
+        background: var(--bg) !important;
+        border: 1px solid var(--border) !important;
+        padding: 8px !important;
+      }
+      #doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview pre code,
+      #doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview code.hljs,
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-preview pre code,
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-preview code.hljs,
+      #memory-modal .doclib-card.doclib-card-expanded .doclib-card-preview pre code {
+        background: transparent !important;
+        border: none !important;
+        padding: 0 !important;
+        box-shadow: none !important;
+      }
+      #doclib-modal .doclib-card.doclib-card-expanded .doclib-card-expanded-actions,
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-expanded-actions,
+      #memory-modal .doclib-card.doclib-card-expanded .doclib-card-expanded-actions {
+        padding: 6px 4px 0 !important;
+        margin-top: 4px !important;
+        flex-shrink: 0 !important;
+      }
+      /* Email reader on mobile inherits the library modal's horizontal
+         padding (the .doclib-modal-content default — 6px). No extra
+         overrides for the modal-content / modal-body / admin-card / grid
+         chain; we just clean up the inner email reader header/body
+         padding so the From / To lines align with the email subject. */
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-preview .email-reader-header,
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-preview .email-reader-atts,
+      #email-lib-modal .doclib-card.doclib-card-expanded .doclib-card-preview .email-reader-body {
+        padding-left: 6px !important;
+        padding-right: 6px !important;
+      }
+      /* Mobile email-reader header: meta on the left, actions on the right
+         (two stacked rows). The two .email-reader-actions-row siblings
+         render as their own flex-row strips so primary (reply/reply-all/
+         forward) sits above secondary (AI/summary/more), instead of
+         flattening into one line that collided with the recipient chips. */
+      #email-lib-modal .email-reader-header,
+      .email-reader-tab-modal .email-reader-header,
+      .email-window-modal .email-reader-header {
+        padding: 8px 8px !important;
+        gap: 6px !important;
+        flex-direction: row !important;
+        align-items: flex-start !important;
+      }
+      #email-lib-modal .email-reader-actions,
+      .email-reader-tab-modal .email-reader-actions,
+      .email-window-modal .email-reader-actions {
+        display: flex !important;
+        flex-direction: column !important;
+        align-items: flex-end !important;
+        gap: 4px !important;
+        margin-left: auto !important;
+        flex-shrink: 0 !important;
+        position: relative !important;
+        top: -3px !important; /* lift the reply/forward/etc. action buttons up on mobile */
+      }
+      #email-lib-modal .email-reader-actions-row,
+      .email-reader-tab-modal .email-reader-actions-row,
+      .email-window-modal .email-reader-actions-row {
+        display: flex !important;
+        flex-direction: row !important;
+        flex-wrap: nowrap !important;
+        align-items: center !important;
+        justify-content: flex-end !important;
+        gap: 4px !important;
+      }
+      #email-lib-modal .email-reader-actions .memory-toolbar-btn.reader-icon-btn,
+      .email-reader-tab-modal .email-reader-actions .memory-toolbar-btn.reader-icon-btn,
+      .email-window-modal .email-reader-actions .memory-toolbar-btn.reader-icon-btn {
+        width: 44px !important;
+        height: 44px !important;
+        flex: 0 0 auto !important;
+        display: inline-flex !important;
+        flex-direction: column !important;
+        align-items: center !important;
+        justify-content: center !important;
+        gap: 3px !important;
+        padding: 4px 2px !important;
+      }
+      #email-lib-modal .email-reader-actions .memory-toolbar-btn.reader-icon-btn svg,
+      .email-reader-tab-modal .email-reader-actions .memory-toolbar-btn.reader-icon-btn svg,
+      .email-window-modal .email-reader-actions .memory-toolbar-btn.reader-icon-btn svg {
+        width: 16px !important;
+        height: 16px !important;
+      }
+      /* Gallery-style label under each button — only on mobile. The
+         global rule below the @media block hides them on desktop. */
+      #email-lib-modal .reader-btn-label,
+      .email-reader-tab-modal .reader-btn-label,
+      .email-window-modal .reader-btn-label {
+        display: inline-block !important;
+        font-size: 8.5px !important;
+        font-weight: 500 !important;
+        line-height: 1 !important;
+        letter-spacing: 0.02em !important;
+        opacity: 0.75 !important;
+        white-space: nowrap !important;
+      }
+      /* List-view cards keep the framed look from their own .doclib-card /
+         .memory-item base styles — no extra grid padding here, so the
+         spacing matches the documents/library tab exactly. */
+      /* Tighten the top of the email sheet — modal header + description +
+         account row + toolbar were stacking with full desktop spacing and
+         pushed the email subjects way down. */
+      #email-lib-modal .modal-header {
+        padding: 4px 8px !important;
+        min-height: 0 !important;
+      }
+      #email-lib-modal .modal-header h4 {
+        /* Match the other tool headers (base .modal-header h4 = 1rem); was
+           13px, which read noticeably smaller than Calendar/Tasks/etc. */
+        font-size: 1rem !important;
+        line-height: 1.2 !important;
+      }
+      #email-lib-modal .modal-body {
+        gap: 4px !important;
+      }
+      #email-lib-modal .admin-card {
+        gap: 4px !important;
+      }
+      #email-lib-modal .admin-card > .doclib-desc {
+        display: none !important;
+      }
+      /* When an email is expanded the list-mode toolbar siblings are
+         already hidden by the existing :has rule. Hide the modal-header
+         entirely AND zero out the modal-content top padding so the email
+         reader claims the full sheet height. The swipe-down gesture (and
+         the dock chip) still dismiss the modal. */
+      #email-lib-modal:has(.doclib-card-expanded) .modal-header,
+      #email-lib-modal.email-reading .modal-header {
+        display: none !important;
+      }
+      #email-lib-modal:has(.doclib-card-expanded) .doclib-modal-content,
+      #email-lib-modal.email-reading .doclib-modal-content {
+        padding-top: 0 !important;
+      }
+      #email-lib-modal:has(.doclib-card-expanded) .modal-body,
+      #email-lib-modal.email-reading .modal-body {
+        gap: 0 !important;
+      }
+      /* Flatten the From / To bar so it doesn't read as a cut-off framed
+         strip with a hard background change. Remove the background, drop
+         the border-bottom to a faint divider, and let it blend with the
+         sheet. */
+      #email-lib-modal .email-reader-header {
+        background: transparent !important;
+        border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent) !important;
+      }
+      #email-lib-modal .email-card-nav-btn {
+        padding: 6px 10px !important;
+        min-width: 40px !important;
+        height: 38px !important;
+      }
+      #email-lib-modal .email-card-nav-btn svg {
+        width: 18px !important;
+        height: 18px !important;
+      }
+      /* Nudge the prev/next arrow cluster ~4px to the right so it sits
+         comfortably out at the edge instead of crowding the subject text. */
+      #email-lib-modal .email-card-nav-arrows {
+        transform: translateX(4px);
+      }
+      /* Done check — extend the actual TAP area via padding + negative
+         margin (a transparent ::before doesn't change the parent's hit
+         box). Layout stays the same height as library cards because the
+         margin cancels the padding visually, but the clickable region is
+         ~37px square. */
+      #email-lib-modal .email-card-done {
+        position: relative !important;
+        z-index: 5 !important;
+        pointer-events: auto !important;
+        touch-action: manipulation !important;
+        padding: 12px !important;
+        margin: -12px !important;
+      }
+      #email-lib-modal .email-card-done svg {
+        width: 13px !important;
+        height: 13px !important;
+      }
+      /* Make sure no overlay or pseudo intercepts the tap. */
+      #email-lib-modal .email-card-done * { pointer-events: none; }
+      /* Tighten the From / To meta bar and the email body — they had
+         desktop padding (10–14px) that wasted real estate on phones. */
+      #email-lib-modal .email-reader-header {
+        padding: 6px 4px !important;
+        gap: 4px !important;
+      }
+      #email-lib-modal .email-reader-meta {
+        font-size: 11px !important;
+      }
+      #email-lib-modal .email-reader-meta-row strong {
+        min-width: 28px !important;
+      }
+      #email-lib-modal .email-reader-atts {
+        padding: 6px 4px !important;
+      }
+      #email-lib-modal .email-reader-body {
+        padding: 8px 4px !important;
+      }
+      /* Make sure the grid claims the full admin-card height when a doc
+         is expanded — the existing rule sets flex:1, but on mobile we also
+         need a hard height fallback because the parent uses dvh which
+         desktop-tuned selectors don't always trickle through. */
+      #doclib-modal .admin-card:has(.doclib-card-expanded) > .doclib-grid,
+      #memory-modal .admin-card:has(.doclib-card-expanded) > .doclib-grid {
+        height: 100% !important;
+      }
+      /* Skills modal: keep the header + tab strip visible; the expanded
+         card fills the skills-list area below them via position:absolute
+         (see the #skills-list rule above). Just make sure the tab-panel +
+         its card give the list its full height. */
+      #memory-modal .memory-tab-panel[data-memory-panel="skills"] > .admin-card:has(.doclib-card-expanded) {
+        flex: 1 1 auto !important;
+        min-height: 0 !important;
+      }
+      .doclib-card-header {
+        padding: 10px 8px;
+        gap: 4px;
+      }
+      .doclib-card-session,
+      .doclib-card-time {
+        display: none;
+      }
+      .doclib-card-expanded-actions {
+        flex-wrap: wrap;
+      }
+      /* Keep the footer buttons identical to the chat/research footers on
+         mobile too — those have no mobile enlargement, so neither should
+         these (otherwise the doc footer reads in a larger/different font). */
+      .doclib-card-action-btn {
+        font-size: 10px;
+        padding: 3px 8px;
+      }
+      /* Chat top bar — adjusted for reduced height */
+      /* Suggestion nav — bigger touch targets */
+      .doc-suggestion-nav-btn {
+        padding: 6px 8px;
+        font-size: 18px;
+      }
+      .doc-suggestion-close {
+        padding: 10px 12px;
+        margin: -10px -12px;
+      }
+    }
+    #mobile-backdrop, #mobile-menu-btn { display:none !important; }
+    #sidebar-backdrop { display:none !important; }
+    /* ----- Loading spinner ----- */
+    @keyframes spin {
+      to { transform: rotate(360deg); }
+    }
+    .spinner {
+      width: 24px;
+      height: 24px;
+      margin: 8px auto;
+      border: 3px solid var(--border);
+      border-top-color: var(--red);
+      border-radius: 50%;
+      animation: spin 0.9s linear infinite;
+    }
+    /* Inline spinner for buttons */
+    .btn-spinner {
+      display: inline-block;
+      width: 12px;
+      height: 12px;
+      border: 2px solid transparent;
+      border-top-color: currentColor;
+      border-radius: 50%;
+      animation: spin 1s linear infinite;
+      margin-right: 6px;
+    }
+    .search-status {
+      font-size: 0.85em;
+      color: var(--red);
+      margin-top: 4px;
+      padding: 4px;
+      border-left: 2px solid var(--red);
+      background: color-mix(in srgb, var(--red) 5%, transparent);
+    } 
+    /* Loading indicator for messages */
+    .loading-indicator {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      padding: 10px;
+    }
+    .loading-dots {
+      display: flex;
+      gap: 4px;
+    }
+    .loading-dot {
+      width: 6px;
+      height: 6px;
+      background-color: var(--red);
+      border-radius: 50%;
+    }
+    .loading-dot:nth-child(1) {
+      animation: loading-bounce 1.4s infinite ease-in-out both;
+    }
+    .loading-dot:nth-child(2) {
+      animation: loading-bounce 1.4s infinite ease-in-out both;
+      animation-delay: -0.32s;
+    }
+    .loading-dot:nth-child(3) {
+      animation: loading-bounce 1.4s infinite ease-in-out both;
+      animation-delay: -0.64s;
+    }
+    @keyframes loading-bounce {
+      0%, 80%, 100% {
+        transform: scale(0);
+      }
+      40% {
+        transform: scale(1);
+      }
+    }
+    /* Modal styling */
+    .modal {
+      position:fixed;
+      top:0; left:0; width:100%; height:100%;
+      background:none;
+      display:flex; align-items:center; justify-content:center;
+      z-index:250;
+      backdrop-filter:none;
+      pointer-events:none;
+    }
+    /* Cookbook always sits above Gallery so the "Serve a model in
+       Cookbook…" flow (and any other "open Cookbook from inside another
+       modal") is visible no matter which modal was opened first. */
+    #cookbook-modal { z-index: 260; }
+    .modal.hidden { display:none; }
+
+    /* Tool windows open centered in the CHAT AREA (the space right of the
+       sidebar + icon rail), rather than the full viewport.
+       We narrow the overlay to the chat area so its flex-centering lands the
+       window there; fullscreen / docked states use position:fixed so they
+       escape this narrowed overlay and still fill the screen. The
+       --sidebar-w / --icon-rail-w vars track collapse state live, so when a
+       window (e.g. Cookbook) hides the sidebar this naturally re-centers.
+       Desktop only — on mobile these are full-screen sheets. */
+    @media (min-width: 769px) {
+      #calendar-modal,
+      #gallery-modal,
+      #tasks-modal,
+      #memory-modal,
+      #doclib-modal,
+      #compare-model-overlay,
+      #research-overlay,
+      #theme-modal,
+      #settings-modal,
+      #email-lib-modal {
+        left: calc(var(--icon-rail-w, 48px) + var(--sidebar-w, 0px));
+        width: calc(100% - (var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)));
+        box-sizing: border-box;
+        /* Slide in sync with the sidebar's 0.25s collapse/expand so the
+           centered window glides instead of jumping when the nav toggles. */
+        transition: left 0.25s ease, width 0.25s ease;
+      }
+    }
+    .modal-content {
+      background:var(--panel);
+      border:1px solid var(--border);
+      width:min(520px, 92vw); max-height:85vh; padding:10px;
+      box-sizing:border-box; font-size:14px;
+      font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+      letter-spacing: -0.015em;
+      display:flex; flex-direction:column;
+      position:relative;
+      overflow-y:auto;
+      border-radius:10px;
+      box-shadow:0 8px 32px rgba(0,0,0,0.45);
+      pointer-events:auto;
+      animation: modal-enter 0.25s ease-out both;
+    }
+    .modal-header {
+      display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;
+      cursor:grab; user-select:none;
+      /* Pin the header (with its close button) to the top of the
+         scrollable modal-content so users can always dismiss the modal
+         even after scrolling far down — especially important on mobile
+         where the modal can be taller than the viewport. */
+      position: sticky;
+      top: 0;
+      z-index: 5;
+      /* Inherit the modal-content's background so the header matches the
+         panel body. Many tool modals set their content to var(--bg) inline
+         while this header was hard-coded to var(--panel) — making the title
+         strip a different shade in every theme. `inherit` tracks whatever
+         the content uses (var(--bg) or the default var(--panel)) and stays
+         opaque, so the sticky header still masks scrolled content. */
+      background-color: inherit;
+    }
+    .modal-header:active { cursor:grabbing; }
+    /* Cookbook's modal-content is var(--bg) (inline) instead of the default
+       var(--panel), so its sticky header — which defaults to var(--panel) —
+       read as a different-coloured band. Match the header to the cookbook
+       body so the panel is one uniform colour. */
+    #cookbook-modal .modal-header {
+      background: var(--bg);
+      /* Cookbook opts out of the sticky header on mobile; the
+         title bar scrolls away with the content instead of following. */
+      position: static;
+      top: auto;
+    }
+    .modal-header h4 {
+      margin:0;
+      /* Push every header control (opacity slider, minimize, close) into one
+         group on the right — works whether or not optional controls like the
+         opacity slider are present, so the minimize button never floats to
+         the centre when a sibling is hidden. */
+      margin-right:auto;
+      font-size:1rem;
+      font-weight:600;
+      letter-spacing:-0.03em;
+      color:var(--red);
+    }
+    .close-btn,
+    .modal-close {
+      background:var(--bg);
+      color:var(--fg);
+      border:1px solid var(--fg);
+      font-size:12px;
+      width:24px;
+      height:24px;
+      padding:0;
+      display:inline-flex;
+      align-items:center;
+      justify-content:center;
+      line-height:1;
+      text-indent:0;
+      cursor:pointer;
+      border-radius:4px;
+      line-height:1;
+      flex-shrink:0;
+    }
+    .close-btn:hover,
+    .modal-close:hover {
+      background:var(--fg);
+      color:var(--bg);
+    }
+    /* Minimize button — sits beside the close button on every modal */
+    .minimize-btn {
+      background:var(--bg);
+      color:var(--fg);
+      border:1px solid var(--fg);
+      font-size:14px;
+      font-weight:700;
+      width:24px;
+      height:24px;
+      padding:0 0 6px 0; /* nudge the underscore visually toward the middle */
+      display:inline-flex;
+      align-items:center;
+      justify-content:center;
+      line-height:1;
+      cursor:pointer;
+      border-radius:4px;
+      flex-shrink:0;
+      margin-left:auto; /* push to the right so it docks next to .close-btn */
+      margin-right:4px;
+    }
+    .minimize-btn:hover {
+      background:var(--fg);
+      color:var(--bg);
+    }
+    @media (max-width: 768px) {
+      .minimize-btn { display: none !important; }
+      #modal-dock { display: none !important; }
+    }
+    /* Minimized modals are hidden but stay in the DOM with their state intact */
+    .modal.minimized { display:none !important; }
+    /* Bottom dock for minimized modals */
+    #modal-dock {
+      position:fixed;
+      bottom:0;
+      left:0;
+      right:0;
+      display:flex;
+      flex-wrap:wrap;
+      gap:4px;
+      padding:4px 8px;
+      z-index:240;
+      pointer-events:none;
+      justify-content:flex-start;
+    }
+    #modal-dock:empty { display:none; }
+    .modal-dock-item {
+      background:var(--panel);
+      border:1px solid var(--border);
+      border-bottom:none;
+      border-radius:6px 6px 0 0;
+      padding:4px 4px 4px 10px;
+      display:inline-flex;
+      align-items:center;
+      gap:6px;
+      cursor:pointer;
+      pointer-events:auto;
+      font-size:12px;
+      color:var(--fg);
+      max-width:220px;
+      box-shadow:0 -2px 8px rgba(0,0,0,0.25);
+      transition:background 0.15s;
+    }
+    .modal-dock-item:hover { background:var(--bg); }
+    .modal-dock-label {
+      white-space:nowrap;
+      overflow:hidden;
+      text-overflow:ellipsis;
+      max-width:180px;
+    }
+    .modal-dock-close {
+      background:transparent;
+      border:none;
+      color:var(--fg);
+      cursor:pointer;
+      font-size:14px;
+      padding:0 4px;
+      line-height:1;
+      opacity:0.6;
+    }
+    .modal-dock-close:hover { color:var(--red); opacity:1; }
+    .modal-body { flex:1; overflow-y:auto; }
+    .modal-body button { margin-top:6px; }
+    /* Styled confirm dialog — keeps backdrop */
+    #styled-confirm-overlay {
+      background:rgba(0,0,0,0.5);
+      backdrop-filter:blur(4px);
+      pointer-events:auto !important;
+      z-index: 99999 !important;
+      position: fixed !important;
+      top: 0 !important;
+      left: 0 !important;
+      width: 100% !important;
+      height: 100% !important;
+      top: 0; left: 0; width: 100%; height: 100%;
+    }
+    #styled-confirm-overlay .modal-content {
+      position: relative;
+      z-index: 10001;
+    }
+    .styled-confirm-box {
+      width:360px; max-width:90vw;
+      max-height:none; /* override modal-content's 85vh */
+      padding:14px 18px;
+    }
+    .styled-confirm-box .modal-header { margin-bottom:4px; }
+    .styled-confirm-box .modal-body p {
+      margin:8px 0 12px; color:var(--fg); font-size:0.92rem; line-height:1.45;
+      white-space:pre-line;
+    }
+    .styled-confirm-box .modal-footer {
+      display:flex; justify-content:flex-end; gap:8px; padding-top:6px;
+      border-top:1px solid var(--border); margin-top:4px;
+    }
+    @media (max-width:768px) {
+      .styled-confirm-box {
+        width: 85vw;
+        padding: 12px 16px;
+        font-size: 0.88rem;
+        border-radius: 12px;
+      }
+      .styled-confirm-box .modal-header h4 { font-size: 0.9rem; }
+      .styled-confirm-box .modal-body p { font-size: 0.85rem; margin: 6px 0 10px; }
+      .styled-confirm-box .modal-footer { gap: 10px; }
+      .styled-confirm-box .confirm-btn {
+        flex: 1;
+        /* More bottom than top padding nudges the label up ~2px so it isn't
+           sitting low in the taller mobile buttons. */
+        padding: 8px 12px 12px;
+        font-size: 0.85rem;
+        border-radius: 8px;
+        text-align: center;
+      }
+    }
+    .confirm-btn {
+      /* Asymmetric padding nudges the label UP ~2px from where it was (more
+         bottom than top padding), so the confirm-dialog text isn't sitting low. */
+      padding:3px 16px 5px; border-radius:4px; font-size:0.85rem;
+      cursor:pointer; border:1px solid var(--border);
+      font-family:inherit;
+    }
+    @media (max-width: 820px) {
+      /* Mobile: flip the asymmetry to shift the text 1 px UP from
+         centre instead (the bigger touch targets on mobile read better
+         with the label sitting slightly high). */
+      .confirm-btn { padding:2px 16px 4px; }
+    }
+    .confirm-btn-secondary { background:var(--bg); color:var(--fg); }
+    .confirm-btn-secondary:hover { background:var(--border); }
+    .confirm-btn-primary { background:var(--accent-primary, var(--red, #4a9eff)); color:#fff; border-color:transparent; }
+    .confirm-btn-primary:hover { filter:brightness(1.15); }
+    .confirm-btn-danger { background:var(--color-danger); color:#fff; border-color:transparent; }
+    .confirm-btn-danger:hover { background:var(--color-error); }
+    /* Styled prompt — text-input dialog (used in place of window.prompt) */
+    #styled-prompt-overlay {
+      background:rgba(0,0,0,0.5);
+      backdrop-filter:blur(4px);
+      pointer-events:auto !important;
+      z-index: 99999 !important;
+      position: fixed !important;
+      top: 0 !important;
+      left: 0 !important;
+      width: 100% !important;
+      height: 100% !important;
+    }
+    #styled-prompt-overlay .modal-content {
+      position: relative;
+      z-index: 10001;
+    }
+    .styled-prompt-box { width: min(400px, 92vw); max-width: 100%; box-sizing: border-box; }
+    .styled-prompt-box .modal-body { padding-top: 4px; }
+    .styled-prompt-input {
+      width:100%;
+      box-sizing:border-box;
+      margin-top:8px;
+      padding:9px 12px;
+      border:1px solid var(--border);
+      border-radius:6px;
+      background:var(--bg);
+      color:var(--fg);
+      font:inherit;
+      font-size:0.95rem;
+      outline:none;
+      transition: border-color 0.15s, box-shadow 0.15s;
+    }
+    .styled-prompt-input:focus {
+      border-color: var(--accent-primary, var(--red, #4a9eff));
+      box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-primary, var(--red, #4a9eff)) 25%, transparent);
+    }
+    @media (max-width:768px) {
+      .styled-prompt-box { width: 85vw; }
+      .styled-prompt-input { font-size: 0.9rem; padding: 10px 12px; }
+    }
+    /* Scroll navigation buttons */
+    .scroll-nav-btn {
+      position:fixed;
+      background:var(--panel);
+      color: var(--accent);
+      border:none;
+      border-radius:10px;
+      width:38px; height:38px;
+      padding:0;
+      display:flex; align-items:center; justify-content:center;
+      font-size:14px;
+      line-height:1;
+      font-family:inherit;
+      cursor:pointer;
+      opacity:0;
+      pointer-events:none;
+      transition: opacity .2s, transform .3s cubic-bezier(0.25, 1, 0.5, 1), background .15s;
+      z-index:100;
+      transform: translateY(0);
+    }
+    .scroll-nav-btn::before {
+      content: '';
+      position: absolute;
+      inset: 0;
+      border-radius: 10px;
+      padding: 1px;
+      background: linear-gradient(to bottom, var(--border), color-mix(in srgb, var(--border) 30%, transparent));
+      -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
+      mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
+      -webkit-mask-composite: xor;
+      mask-composite: exclude;
+      pointer-events: none;
+    }
+    #scroll-bottom-btn.show { opacity:1; pointer-events:auto; }
+    @media (hover: hover) and (pointer: fine) {
+      #scroll-bottom-btn.show:hover {
+        border-color: color-mix(in srgb, var(--fg) 25%, transparent);
+      }
+    }
+    #scroll-bottom-btn.slide-out {
+      transform: translateY(20px);
+      opacity: 0 !important;
+      pointer-events: none;
+    }
+    /* Focus outline for accessibility */
+    :focus-visible {
+      outline: 2px solid var(--red);
+      outline-offset: 2px;
+    }
+    /* Hamburger menu button */
+    .hamburger {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      width: 32px;
+      height: 32px;
+      background: none;
+      border: 1px solid transparent;
+      border-radius: 6px;
+      padding: 0;
+      cursor: pointer;
+      transition: background 0.15s, border-color 0.15s;
+    }
+    .hamburger:hover {
+      background: color-mix(in srgb, var(--fg) 7%, transparent);
+      border-color: var(--border);
+    }
+    .hamburger span {
+      display: block;
+      width: 16px;
+      height: 2px;
+      background: var(--fg);
+      border-radius: 1px;
+      transition: transform 0.2s, opacity 0.2s;
+    }
+    .hamburger span + span { margin-top: 3px; }
+    /* Agent indicator */
+    #agent-indicator {
+      position: fixed;
+      top: 20px;
+      right: 20px;
+      background: var(--bg);
+      color: var(--fg);
+      border: 1px solid var(--border);
+      padding: 6px 12px;
+      border-radius: 6px;
+      font-size: 12px;
+      display: none;
+      z-index: 100;
+      cursor: pointer;
+      transition: all 0.2s ease;
+    }
+    #agent-indicator.active {
+      display: block;
+      border-color: var(--color-agent-active);
+      box-shadow: 0 0 10px rgba(0, 255, 0, 0.3);
+    }
+    #agent-indicator:hover {
+      border-color: var(--color-agent-active);
+      background: var(--panel);
+    }
+    #research-toggle-btn:disabled {
+      opacity: 0.6;
+      cursor: not-allowed;
+    }
+    
+/* ── Drag & Drop ── */
+
+/* ---------- Color palette (dark / light) ---------- */
+
+    /* Drag and drop styling */
+    .section[dnd-active="true"] {
+      background: color-mix(in srgb, var(--red) 10%, transparent) !important;
+      border-color: var(--red) !important;
+    }
+    
+    .section[dnd-over="true"] {
+      background: color-mix(in srgb, var(--red) 20%, transparent) !important;
+      border-color: var(--red) !important;
+      transform: scale(1.02);
+    }
+    
+    .drag-handle {
+      cursor: grab;
+      opacity: 0.5;
+      padding: 0 6px;
+      user-select: none;
+    }
+    
+    .drag-handle:hover {
+      opacity: 0.8;
+    }
+    
+    .drag-handle:active {
+      cursor: grabbing;
+    }
+    
+/* ── UI Controls (Radio, Presets, Toolbar, Settings) ── */
+
+
+    /* Custom radio button styling */
+    .radio-option {
+      display: flex;
+      align-items: center;
+      gap: 10px;
+      padding: 8px;
+      border: 1px solid var(--border);
+      border-radius: 6px;
+      background: color-mix(in srgb, var(--fg) 6%, transparent);
+      cursor: pointer;
+      transition: all 0.2s ease;
+    }
+    
+    .radio-option:hover {
+      background: color-mix(in srgb, var(--fg) 9%, transparent);
+      border-color: var(--fg);
+    }
+    
+    .radio-option input[type="radio"] {
+      appearance: none;
+      -webkit-appearance: none;
+      width: 18px;
+      height: 18px;
+      border: 2px solid var(--border);
+      border-radius: 50%;
+      outline: none;
+      margin: 0;
+      background: var(--bg);
+      transition: all 0.2s ease;
+      position: relative;
+      flex-shrink: 0;
+    }
+    
+    .radio-option input[type="radio"]:checked {
+      border-color: var(--red);
+      background: var(--red);
+    }
+    
+    .radio-option input[type="radio"]:focus {
+      box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 30%, transparent);
+    }
+    
+    .radio-label {
+      color: var(--fg);
+      font-size: 14px;
+      user-select: none;
+    }
+    
+    /* Preset buttons */
+    .preset-btn {
+      height: 27.2px; /* 15% smaller than 32px */
+      padding: 0 8.5px; /* 15% smaller than 10px */
+      margin-left: 4px;
+      border: 1px solid var(--border);
+      border-radius: 4px;
+      background: var(--bg);
+      color: var(--fg);
+      font-family: 'Fira Code', monospace;
+      font-size: 10.2px; /* 15% smaller than 12px */
+      cursor: pointer;
+      transition: all 0.2s ease;
+    }
+    
+    .preset-btn:hover {
+      background: var(--panel);
+      border-color: var(--fg);
+    }
+    
+    .preset-btn.active {
+      background: var(--panel);
+      border-color: var(--fg);
+      box-shadow: 0 0 0 1px var(--fg), 0 0 8px color-mix(in srgb, var(--fg) 16%, transparent);
+      font-weight: 600;
+    }
+    
+    /* All preset buttons use the same blue color */
+    .preset-btn {
+      border-color: var(--red); /* Blue color */
+    }
+    
+    .preset-btn.active {
+      border-color: var(--red); /* Blue color when active */
+      box-shadow: 0 0 0 1px var(--red), 0 0 8px color-mix(in srgb, var(--red) 30%, transparent);
+    }
+    
+    /* Custom preset modal: the base .preset-modal-content sets overflow:hidden
+       (for desktop rounded-corner clipping), which on the mobile sheet clipped
+       the footer (Start/Cancel) off the bottom with no way to reach it. Let the
+       whole sheet scroll — same as every other mobile modal (.modal-content is
+       overflow-y:auto on mobile) — so the footer is always reachable. No flex
+       changes, so the body can't collapse. */
+    #custom-preset-modal .preset-modal-content {
+      overflow-y: auto !important;
+    }
+    #custom-preset-modal .modal-body label {
+      font-size: 13px;
+      font-weight: 500;
+      color: var(--fg);
+      margin-top: 8px;
+      margin-bottom: 4px;
+      display: block;
+    }
+
+    #custom-preset-modal .modal-body input,
+    #custom-preset-modal .modal-body textarea {
+      width: 100%;
+      margin-bottom: 8px;
+      box-sizing: border-box;
+    }
+    #custom-preset-modal .modal-body textarea,
+    #custom-preset-modal .modal-body input[type="text"] {
+      background: var(--panel);
+      border: 1px solid var(--border);
+      border-radius: 6px;
+      color: var(--fg);
+      padding: 8px 10px;
+      font-size: 13px;
+      font-family: inherit;
+      transition: border-color 0.15s;
+    }
+    #custom-preset-modal .modal-body textarea {
+      resize: vertical;
+    }
+    #custom-preset-modal .modal-body textarea:focus,
+    #custom-preset-modal .modal-body input[type="text"]:focus {
+      outline: none;
+      border-color: var(--red);
+    }
+
+    #custom-preset-modal .modal-footer {
+      display: flex;
+      justify-content: flex-end;
+      gap: 8px;
+      margin-top: 12px;
+      padding-top: 10px;
+      border-top: 1px solid var(--border);
+    }
+
+    #custom-preset-modal .modal-footer button {
+      padding: 7px 14px;
+      border-radius: 6px;
+      font-size: 12px;
+      font-weight: 500;
+      border: 1px solid var(--border);
+      background: none;
+      color: var(--fg);
+      cursor: pointer;
+      transition: all 0.15s;
+    }
+    #custom-preset-modal .modal-footer button:hover {
+      background: color-mix(in srgb, var(--fg) 8%, transparent);
+    }
+
+    #custom-preset-modal .modal-footer button#save-custom-preset {
+      /* The theme's accent is stored in --red (theme.js sets --red = accentHex)
+         and --accent is undefined, so the canonical accent expression is
+         var(--accent, var(--red)). The old bare var(--accent) resolved to nothing
+         which, with color:var(--bg), made the button invisible. */
+      background: var(--accent, var(--red));
+      color: var(--bg);
+      border-color: var(--accent, var(--red));
+    }
+    #custom-preset-modal .modal-footer button#save-custom-preset:hover {
+      opacity: 0.85;
+    }
+    /* Toolbar visibility tab */
+    .toolbar-hint {
+      font-size: 12px;
+      color: color-mix(in srgb, var(--fg) 55%, transparent);
+      margin-bottom: 12px;
+    }
+    /* ── Appearance visibility toggles ── */
+    .vis-toggles {
+      display: flex;
+      flex-direction: column;
+    }
+    .vis-row {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      cursor: pointer;
+      padding: 5px 6px;
+      border-radius: 4px;
+      transition: background 0.12s;
+    }
+    .vis-row:hover {
+      background: color-mix(in srgb, var(--fg) 5%, transparent);
+    }
+    .vis-row input[type="checkbox"] {
+      display: none;
+    }
+    .vis-icon {
+      width: 20px;
+      height: 20px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      flex-shrink: 0;
+      color: color-mix(in srgb, var(--fg) 40%, transparent);
+    }
+    .vis-icon-text {
+      font-size: 10px;
+      font-weight: 700;
+      font-family: inherit;
+      letter-spacing: -0.5px;
+    }
+    .vis-label {
+      flex: 1;
+      font-size: 12px;
+      color: var(--fg);
+      user-select: none;
+    }
+    .vis-switch {
+      position: relative;
+      width: 30px;
+      height: 16px;
+      background: color-mix(in srgb, var(--fg) 15%, transparent);
+      border-radius: 8px;
+      transition: background 0.2s;
+      flex-shrink: 0;
+    }
+    .vis-switch::after {
+      content: '';
+      position: absolute;
+      top: 2px;
+      left: 2px;
+      width: 12px;
+      height: 12px;
+      background: var(--panel);
+      border-radius: 50%;
+      transition: transform 0.2s;
+      box-shadow: 0 1px 2px rgba(0,0,0,0.25);
+    }
+    .vis-row input:checked + .vis-switch {
+      background: var(--red);
+    }
+    .vis-row input:checked + .vis-switch::after {
+      transform: translateX(14px);
+    }
+    .vis-row input:checked ~ .vis-icon,
+    .vis-row:has(input:checked) .vis-icon {
+      color: var(--fg);
+    }
+    /* Compare model selector — match the calendar modal's clean header
+       (no border underline). */
+    #compare-model-overlay .modal-header h4 {
+      pointer-events: none;
+    }
+    /* Compare modal sizes to content — the global .modal-content max-height
+       + .modal-body overflow combo makes BOTH the outer card and the inner
+       body scrollable, so even when the content fits the viewport you get
+       a stray vertical scrollbar. Drop the cap and disable inner scroll
+       here; if the viewport is genuinely tiny the modal still won't exceed
+       it because it's centered and the parent .modal flex layout shrinks. */
+    #compare-model-overlay .modal-content {
+      max-height: none;
+      overflow: visible;
+    }
+    #compare-model-overlay .modal-body {
+      overflow: visible;
+      flex: 0 0 auto;
+    }
+    .vis-hint {
+      font-size: 10px;
+      color: color-mix(in srgb, var(--fg) 30%, transparent);
+      font-weight: 400;
+      margin-left: 2px;
+    }
+    /* Settings toggle — admin-only lock indicator */
+    .ui-vis-lock {
+      display: none;
+    }
+    /* (legacy toolbar-toggle styles removed — now using .vis-* classes) */
+
+    /* Demo highlight pulse */
+    .odysseus-highlight {
+      outline: 2px solid var(--accent, var(--red)) !important;
+      outline-offset: 1px;
+      box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);
+      animation: ody-pulse 1.5s ease-in-out infinite;
+      z-index: 100;
+      position: relative;
+    }
+    @keyframes ody-pulse {
+      0%, 100% { outline-color: var(--accent, var(--red)); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, var(--red)) 18%, transparent); }
+      50%       { outline-color: color-mix(in srgb, var(--accent, var(--red)) 40%, transparent); box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent, var(--red)) 8%, transparent); }
+    }
+    /* Floating breathing halo. Rendered as a body-level div positioned over
+       the target, so we don't fight the target's own outline / box-shadow /
+       overflow chain. JS keeps it in sync with the target's bounding rect. */
+    .tour-halo {
+      position: fixed;
+      pointer-events: none;
+      border: 3px solid var(--accent, var(--red));
+      border-radius: 10px;
+      z-index: 10000;
+      animation: ody-breathe 1.4s ease-in-out infinite;
+      opacity: 0;
+      transform: scale(0.94);
+      transition: opacity 0.35s ease-out, transform 0.35s ease-out;
+    }
+    .tour-halo.tour-fade-in {
+      opacity: 1;
+      transform: scale(1);
+    }
+    @keyframes ody-breathe {
+      0%, 100% {
+        box-shadow: 0 0 0 3px  color-mix(in srgb, var(--accent, var(--red)) 55%, transparent),
+                    0 0 22px 4px color-mix(in srgb, var(--accent, var(--red)) 40%, transparent);
+        border-color: var(--accent, var(--red));
+      }
+      50% {
+        box-shadow: 0 0 0 6px  color-mix(in srgb, var(--accent, var(--red)) 30%, transparent),
+                    0 0 40px 14px color-mix(in srgb, var(--accent, var(--red)) 70%, transparent);
+        border-color: color-mix(in srgb, var(--accent, var(--red)) 80%, transparent);
+      }
+    }
+    /* While the tour is active, lift overflow:hidden on common clipping
+       ancestors so the halo around a highlighted child isn't cropped. */
+    body.tour-active .sidebar,
+    body.tour-active .sidebar-inner,
+    body.tour-active .chat-input-bar,
+    body.tour-active .chat-input-top,
+    body.tour-active .chat-input-wrap,
+    body.tour-active .chat-input-right,
+    body.tour-active .mode-toggle,
+    body.tour-active .model-picker-wrap {
+      overflow: visible !important;
+    }
+
+    /* ── Secret tour hint (drag-to-snap on first modal open) ── */
+    .tour-hint {
+      position: fixed;
+      z-index: 10002;
+      background: var(--bg);
+      color: var(--fg);
+      border: 1px solid var(--border);
+      border-radius: 10px;
+      padding: 12px 14px 10px;
+      width: 240px;
+      font-size: 0.78rem;
+      line-height: 1.5;
+      box-shadow: 0 6px 20px rgba(0, 0, 0, 0.32);
+      opacity: 0;
+      transform: translateY(-4px);
+      transition: opacity 0.28s ease-out, transform 0.28s ease-out;
+      pointer-events: auto;
+    }
+    .tour-hint.tour-hint-in  { opacity: 1; transform: translateY(0); }
+    .tour-hint.tour-hint-out { opacity: 0; transform: translateY(-4px); }
+    .tour-hint-visual {
+      display: flex;
+      justify-content: center;
+      margin-bottom: 8px;
+      color: var(--accent, var(--red));
+    }
+    .tour-hint-visual svg { display: block; }
+    .tour-hint-text { margin-bottom: 10px; opacity: 0.92; }
+    .tour-hint-text b { color: var(--accent, var(--red)); font-weight: 600; }
+    .tour-hint-dismiss {
+      display: block;
+      margin: 0 0 0 auto;
+      background: none;
+      border: 1px solid var(--border);
+      color: var(--fg);
+      border-radius: 6px;
+      padding: 3px 12px;
+      cursor: pointer;
+      font-family: inherit;
+      font-size: 0.72rem;
+      opacity: 0.85;
+      transition: opacity 0.15s, background 0.15s;
+    }
+    .tour-hint-dismiss:hover { opacity: 1; background: color-mix(in srgb, var(--fg) 8%, transparent); }
+
+    /* SVG dance: cursor approaches title bar, drags right, modal snaps to a
+       right-half zone, holds, returns. 3.2s loop. */
+    .th-cursor { animation: th-cursor 3.2s ease-in-out infinite; transform-origin: 0 0; }
+    .th-modal-group { animation: th-modal-slide 3.2s ease-in-out infinite; transform-origin: 39px 31px; }
+    .th-zone { animation: th-zone-flash 3.2s ease-in-out infinite; }
+    @keyframes th-cursor {
+      0%, 5%    { transform: translate(35px, 32px); }
+      18%       { transform: translate(35px, 22px); }
+      48%       { transform: translate(80px, 22px); }
+      72%       { transform: translate(80px, 22px); }
+      90%, 100% { transform: translate(35px, 32px); }
+    }
+    @keyframes th-modal-slide {
+      0%, 18%   { transform: translate(0, 0) scale(1, 1); }
+      48%       { transform: translate(45px, 0) scale(1, 1); }
+      58%       { transform: translate(30px, -19px) scale(1.4, 2.4); }
+      72%       { transform: translate(30px, -19px) scale(1.4, 2.4); }
+      90%, 100% { transform: translate(0, 0) scale(1, 1); }
+    }
+    @keyframes th-zone-flash {
+      0%, 38%   { opacity: 0; }
+      48%       { opacity: 0.22; }
+      58%, 72%  { opacity: 0; }
+      100%      { opacity: 0; }
+    }
+    @media (prefers-reduced-motion: reduce) {
+      .th-cursor, .th-modal-group, .th-zone { animation: none !important; }
+    }
+    .odysseus-hl-label {
+      position: absolute;
+      top: -22px;
+      left: 8px;
+      background: var(--red);
+      color: var(--bg);
+      font-size: 0.7rem;
+      padding: 2px 8px;
+      border-radius: 4px;
+      white-space: nowrap;
+      z-index: 101;
+      pointer-events: none;
+    }
+
+    /* Generated images inside chat bubbles */
+    .msg.generated-image-wrap .body { text-align: center; }
+    .generated-image {
+      max-width: 100%;
+      max-height: 512px;
+      border-radius: 8px;
+      cursor: pointer;
+      transition: transform 0.2s;
+      display: inline-block;
+      margin: 0 auto;
+    }
+    .generated-image:hover { transform: scale(1.02); }
+    .generated-image-caption {
+      font-size: 0.8rem;
+      opacity: 0.5;
+      margin-top: 6px;
+      font-style: italic;
+      text-align: center;
+    }
+
+    /* Setup wizard */
+    .setup-wizard { padding: 16px; max-width: 500px; }
+    .setup-wizard .setup-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; color: var(--fg); }
+    .setup-wizard .setup-label { font-size: 0.85rem; color: var(--fg); opacity: 0.7; margin-bottom: 8px; }
+    .setup-wizard .setup-presets { display: flex; flex-wrap: wrap; gap: 8px; }
+    .setup-wizard .setup-preset-btn {
+      padding: 8px 14px; border: 1px solid var(--border); border-radius: 6px;
+      background: var(--panel); color: var(--fg); cursor: pointer;
+      font-size: 0.85rem; transition: all 0.2s;
+    }
+    .setup-wizard .setup-preset-btn:hover { border-color: var(--red); background: color-mix(in srgb, var(--red) 11%, transparent); }
+    .setup-wizard .setup-input {
+      display: block; width: 100%; padding: 8px 12px; margin-bottom: 8px;
+      background: var(--panel); border: 1px solid var(--border);
+      border-radius: 6px; color: var(--fg); font-size: 0.85rem; box-sizing: border-box;
+    }
+    .setup-wizard .setup-input:focus { border-color: var(--red); outline: none; }
+    .setup-wizard .setup-connect-btn {
+      padding: 8px 20px; background: var(--red); color: var(--bg);
+      border: none; border-radius: 6px; cursor: pointer; font-weight: 600; margin-top: 4px;
+    }
+    .setup-wizard .setup-connect-btn:hover { opacity: 0.9; }
+    .setup-wizard .setup-status { font-size: 0.8rem; margin-top: 8px; color: var(--fg); opacity: 0.7; }
+    .setup-wizard .setup-model-list { display: flex; flex-wrap: wrap; gap: 8px; }
+    .setup-wizard .setup-model-btn {
+      padding: 8px 14px; border: 1px solid var(--border); border-radius: 6px;
+      background: var(--panel); color: var(--fg); cursor: pointer;
+      font-size: 0.85rem; transition: all 0.2s;
+    }
+    .setup-wizard .setup-model-btn:hover { border-color: var(--red); background: color-mix(in srgb, var(--red) 11%, transparent); }
+    .setup-wizard .setup-step.hidden { display: none; }
+
+    /* Dropdown menu styles */
+    .dropdown {
+      position: absolute;
+      top: calc(100% + 6px);
+      right: 0;
+      background: var(--panel);
+      border: 1px solid var(--border);
+      border-radius: 10px;
+      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), 0 0 0 1px color-mix(in srgb, var(--fg) 5%, transparent);
+      z-index: 1000;
+      display: none;
+      min-width: 220px;
+      padding: 6px;
+      backdrop-filter: blur(12px);
+    }
+
+    .dropdown.show {
+      display: block;
+      animation: dropdown-in 0.15s ease-out;
+    }
+    @keyframes dropdown-in {
+      from { opacity:0; transform:translateY(-6px) scale(0.97); }
+      to { opacity:1; transform:translateY(0) scale(1); }
+    }
+
+    .dropdown-item {
+      padding: 8px 10px;
+      border-radius: 6px;
+      cursor: pointer;
+      transition: background 0.12s ease;
+      display: flex;
+      align-items: center;
+      gap: 10px;
+      border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent);
+    }
+
+    .dropdown-item:last-child {
+      border-bottom: none;
+    }
+
+    .dropdown-item .menu-icon {
+      width: 28px;
+      height: 28px;
+      border-radius: 6px;
+      background: color-mix(in srgb, var(--fg) 5%, transparent);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 14px;
+      flex-shrink: 0;
+    }
+
+    .dropdown-item .menu-text h4 {
+      margin: 0;
+      font-size: 12.5px;
+      font-weight: 500;
+      color: var(--fg);
+      line-height: 1.3;
+    }
+
+    .dropdown-item .menu-text p {
+      margin: 0;
+      font-size: 10.5px;
+      color: var(--color-subheader);
+      line-height: 1.3;
+    }
+
+    .dropdown-item:hover {
+      background: color-mix(in srgb, var(--red) 10%, transparent);
+    }
+    .dropdown-item:hover .menu-icon {
+      background: color-mix(in srgb, var(--red) 15%, transparent);
+    }
+
+    /* Compact dropdown items (session/model context menus) */
+    .dropdown-item-compact {
+      cursor: pointer;
+      padding: 6px 8px;
+      font-size: 11px;
+      border-radius: 8px;
+      display: flex;
+      align-items: center;
+      gap: 10px;
+      color: var(--fg);
+      transition: background 0.1s;
+    }
+    .dropdown-item-compact:hover {
+      background: color-mix(in srgb, var(--accent) 10%, transparent);
+    }
+    .dropdown-item-compact .dropdown-icon {
+      width: 14px;
+      height: 14px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      flex-shrink: 0;
+      opacity: 0.5;
+    }
+    .dropdown-item-compact .dropdown-icon svg {
+      width: 14px;
+      height: 14px;
+    }
+    .dropdown-item-compact .dropdown-shortcut {
+      margin-left: auto;
+      font-size: 9.5px;
+      opacity: 0.35;
+      font-weight: 400;
+    }
+    /* Keyboard shortcut hints (⌘+Alt+D etc.) are meaningless on touch —
+       hide them in the per-chat actions menu on mobile. */
+    @media (max-width: 768px) {
+      .session-dropdown .dropdown-shortcut { display: none; }
+    }
+    .dropdown-item-danger {
+      color: var(--red) !important;
+    }
+    .dropdown-item-danger .dropdown-icon {
+      opacity: 0.7;
+    }
+
+    /* Inline rename input for sessions */
+    .session-rename-input {
+      width: 100%;
+      background: var(--bg);
+      color: var(--fg);
+      border: 1px solid var(--accent, var(--accent-primary));
+      border-radius: 4px;
+      padding: 2px 6px;
+      font-family: inherit;
+      font-size: 12px;
+      line-height: 1.3;
+      outline: none;
+    }
+
+    /* Session dropdown container */
+    .session-dropdown-menu {
+      position: fixed;
+      z-index: 1000;
+      display: none;
+      min-width: auto;
+      width: max-content;
+      padding: 4px;
+      animation: dropdown-in 0.15s ease-out;
+    }
+
+    /* Folder move submenu */
+    .session-folder-submenu {
+      position: fixed;
+      z-index: 1001;
+      display: none;
+      min-width: auto;
+      width: max-content;
+      padding: 4px;
+    }
+
+    .dropdown-divider {
+      height: 1px;
+      background: var(--border);
+      margin: 4px 8px;
+    }
+    
+    /* Search toggle styles */
+    .search-toggle {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 6px 0;
+      border-bottom: 1px solid var(--border);
+      margin-bottom: 8px;
+    }
+    
+    .search-provider-label {
+      font-size: 14px;
+      color: var(--fg);
+    }
+
+/* ── Voice, Search, Themes, Comparison, Censor, Print ── */
+
+    /* Voice recording styles */
+    #mic-btn {
+      color: var(--fg);
+      border: 1px solid var(--border);
+      border-radius: 4px;
+      transition: all 0.2s ease;
+    }
+    
+    #mic-btn:hover {
+      background: color-mix(in srgb, var(--fg) 6%, transparent);
+      border-color: var(--fg);
+    }
+    
+    #mic-btn.recording {
+      background: var(--color-recording);
+      border-color: var(--color-recording);
+      animation: pulse 1.5s infinite;
+    }
+    
+    @keyframes pulse {
+      0% { opacity: 1; }
+      50% { opacity: 0.7; }
+      100% { opacity: 1; }
+    }
+    
+    #recording-indicator {
+      position: fixed;
+      top: 10px;
+      left: 0;
+      right: 0;
+      background: rgba(0, 0, 0, 0.8);
+      backdrop-filter: blur(10px);
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      padding: 12px;
+      margin: 10px;
+      z-index: 1000;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+    }
+    
+    #recording-indicator.hidden {
+      display: none !important;
+    }
+    
+    .recording-content {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+      color: white;
+    }
+    
+    .recording-icon {
+      color: var(--color-recording);
+      font-size: 20px;
+      animation: pulse 1.5s infinite;
+    }
+    
+    .recording-text {
+      font-size: 16px;
+      font-weight: 500;
+    }
+    
+    #stop-recording {
+      background: var(--color-recording);
+      color: white;
+      border: none;
+      border-radius: 6px;
+      padding: 6px 12px;
+      font-size: 14px;
+      font-weight: 500;
+      cursor: pointer;
+      transition: background 0.2s ease;
+    }
+    
+    #stop-recording:hover {
+      background: var(--color-recording-hover);
+    }
+    
+    /* Error state for recording */
+    #recording-indicator.error {
+      background: rgba(173, 26, 26, 0.9);
+    }
+    
+    .recording-error {
+      color: var(--color-recording);
+      font-size: 14px;
+      margin-top: 4px;
+    }
+    
+    @media (max-width: 768px) {
+      #recording-indicator {
+        margin: 8px;
+        padding: 10px;
+      }
+      
+      .recording-text {
+        font-size: 14px;
+      }
+      
+      #stop-recording {
+        padding: 6px 10px;
+        font-size: 12px;
+      }
+    }
+/* SYNTAX HIGHLIGHTING — uses theme vars from style.css, no hardcoded overrides */
+.hljs { color: var(--hl-fg, #9cdef2); background: none !important; }
+pre { background: var(--code-bg, var(--hl-bg, #282c34)) !important; }
+
+    /* ---------- Search overlay (Ctrl+K command palette) ---------- */
+    .search-overlay {
+      position: fixed;
+      top: 0; left: 0; width: 100%; height: 100%;
+      background: rgba(0, 0, 0, 0.6);
+      display: flex;
+      align-items: flex-start;
+      justify-content: center;
+      padding-top: 15vh;
+      z-index: 300;
+      backdrop-filter: blur(6px);
+    }
+    .search-overlay.hidden { display: none; }
+    .search-popup {
+      width: 520px;
+      max-width: 90vw;
+      background: var(--bg);
+      border: 1px solid var(--border);
+      border-radius: 12px;
+      box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5), 0 0 0 1px color-mix(in srgb, var(--fg) 6%, transparent);
+      overflow: hidden;
+      display: flex;
+      flex-direction: column;
+      max-height: 60vh;
+    }
+    .search-popup input#search-input {
+      width: 100%;
+      background: transparent;
+      border: none;
+      border-bottom: 1px solid var(--border);
+      outline: none;
+      color: var(--fg);
+      font-size: 16px;
+      font-family: 'Fira Code', monospace;
+      padding: 14px 16px;
+      box-sizing: border-box;
+    }
+    .search-popup input#search-input::placeholder {
+      color: color-mix(in srgb, var(--fg) 30%, transparent);
+    }
+    .search-results {
+      overflow-y: auto;
+      flex: 1;
+      padding: 4px;
+    }
+    .search-results:empty {
+      display: none;
+    }
+    .search-group-header {
+      font-size: 10px;
+      font-weight: 600;
+      color: var(--color-subheader);
+      text-transform: uppercase;
+      letter-spacing: 0.5px;
+      padding: 10px 12px 4px;
+    }
+    .search-result-item {
+      display: flex;
+      align-items: baseline;
+      gap: 8px;
+      padding: 8px 12px;
+      border-radius: 6px;
+      cursor: pointer;
+      transition: background 0.1s;
+    }
+    .search-result-item:hover,
+    .search-result-item.selected {
+      background: color-mix(in srgb, var(--red) 10%, transparent);
+    }
+    .search-result-role {
+      font-size: 10px;
+      font-weight: 600;
+      color: var(--color-subheader);
+      flex-shrink: 0;
+      min-width: 24px;
+    }
+    .search-result-snippet {
+      flex: 1;
+      font-size: 12px;
+      color: var(--fg);
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+    .search-result-time {
+      font-size: 10px;
+      color: color-mix(in srgb, var(--fg) 35%, transparent);
+      flex-shrink: 0;
+      white-space: nowrap;
+    }
+    mark.search-highlight {
+      /* Was orange-on-orange (0.35 alpha bg + orange text) — too low-contrast
+         to read. Solid accent fill with bg-colored text reads clearly. */
+      background: var(--accent, #e8a830);
+      color: var(--bg, #1a1a1a);
+      border-radius: 2px;
+      padding: 0 2px;
+      font-weight: 600;
+    }
+    /* Document library search-term highlight — clear, high-contrast. */
+    mark.doclib-search-hl {
+      background: var(--accent, #e8a830);
+      color: var(--bg, #1a1a1a);
+      border-radius: 2px;
+      padding: 0 2px;
+      font-weight: 600;
+    }
+    .search-empty {
+      text-align: center;
+      color: color-mix(in srgb, var(--fg) 35%, transparent);
+      padding: 24px;
+      font-size: 13px;
+    }
+
+    /* ---------- Theme popup (uses .modal > .modal-content frame) ---------- */
+    #theme-modal { z-index: 260; }
+    #theme-popup .theme-popup-sub { color: color-mix(in srgb, var(--fg) 50%, transparent); font-size: 11px; margin-bottom: 10px; }
+    /* `max-height` instead of a fixed height so the popup shrinks to fit its
+       content (avoids the leftover whitespace after the time-based switching
+       card was removed). The inner .theme-tab-panel keeps overflow:auto, so
+       any taller content still scrolls inside. */
+    #theme-popup { overflow-y: hidden; max-height: min(85vh, 600px); }
+    #theme-popup .modal-header { flex-shrink: 0; }
+    #theme-popup .admin-tabs { margin: 0 -10px 8px; padding: 0 10px; flex-shrink: 0; }
+    .theme-tab-panel { overflow-y: auto; min-height: 0; flex: 1; padding-bottom: 10px; }
+
+    .theme-grid {
+      display: grid; grid-template-columns: repeat(auto-fill, minmax(66px, 1fr));
+      gap: 6px; margin-bottom: 12px;
+    }
+    .theme-swatch {
+      border: 2px solid var(--border); border-radius: 8px; cursor: pointer;
+      padding: 5px; text-align: center; font-size: 0.65rem; color: var(--fg);
+      transition: border-color 0.15s, transform 0.15s;
+    }
+    .theme-swatch:hover { transform: scale(1.06); }
+    .theme-swatch.active { border-color: var(--red); box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 33%, transparent); }
+    .theme-swatch-colors {
+      display: flex; justify-content: center; margin-bottom: 3px;
+    }
+    .theme-swatch-colors span {
+      width: 15px; height: 15px; border-radius: 50%;
+      margin-left: -5px;
+      border: 1.5px solid color-mix(in srgb, var(--fg) 12%, transparent);
+    }
+    .theme-swatch-colors span:first-child { margin-left: 0; }
+    .theme-custom-label { font-size: 11px; font-weight: 600; color: color-mix(in srgb, var(--fg) 50%, transparent); text-transform: uppercase; letter-spacing: 0.06em; margin: 10px 0 6px; }
+    .theme-custom { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 12px; }
+    .color-row { display: flex; align-items: center; gap: 4px; }
+    .color-row label { font-size: 13px; font-weight: 500; color: var(--fg); opacity: 0.7; flex: 1; }
+    .color-row input[type="color"],
+    .color-row input.cp-swatch-input {
+      width: 24px; height: 24px; border: 1px solid var(--border); border-radius: 50%;
+      background: none; cursor: pointer; padding: 0; flex-shrink: 0;
+      overflow: hidden;
+      -webkit-appearance: none;
+      appearance: none;
+    }
+    .color-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
+    .color-row input[type="color"]::-webkit-color-swatch { border: none; border-radius: 50%; }
+    .color-row input[type="color"]::-moz-color-swatch { border: none; border-radius: 50%; }
+    .color-row input.cp-swatch-input {
+      color: transparent;
+      text-shadow: none;
+      caret-color: transparent;
+      font-size: 0;
+      user-select: none;
+    }
+    .color-row input.cp-swatch-input::selection { background: transparent; }
+    .color-row input.cp-swatch-input:focus { outline: 1px solid var(--red); outline-offset: 1px; }
+    .color-reset-btn {
+      width: 24px; height: 24px; border: none; background: none; cursor: pointer;
+      color: var(--fg); opacity: 0; font-size: 1.15rem; padding: 0; line-height: 1;
+      transition: opacity 0.15s, color 0.15s; flex-shrink: 0; pointer-events: none;
+    }
+    .color-reset-btn.changed { opacity: 0.4; pointer-events: auto; }
+    .color-reset-btn.changed:hover { opacity: 1; color: var(--red); }
+    .theme-custom-divider {
+      grid-column: 1 / -1; font-size: 11px; color: var(--fg); opacity: 0.5;
+      text-transform: uppercase; letter-spacing: 0.04em; margin: 6px 0 2px;
+      border-top: 1px solid var(--border); padding-top: 6px;
+    }
+    .theme-swatch[data-custom] { position: relative; overflow: visible; }
+    /* Accent-coloured circular X — always visible (mobile-friendly), sits
+       INSIDE the swatch's top-right corner so it never crops into a
+       neighbouring swatch. */
+    .theme-delete-btn {
+      position: absolute;
+      top: -2px;
+      right: -2px;
+      width: 20px;
+      height: 20px;
+      padding: 0;
+      border: none;
+      border-radius: 50%;
+      background: var(--accent, var(--red, #d92534));
+      color: #fff;
+      cursor: pointer;
+      z-index: 2;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
+      transition: transform 0.12s, background 0.12s;
+    }
+    .theme-delete-btn:hover {
+      background: color-mix(in srgb, var(--accent, var(--red)) 80%, white);
+      transform: scale(1.12);
+    }
+    .theme-delete-btn:active { transform: scale(0.95); }
+    .theme-delete-btn svg {
+      display: block;
+      /* Optical nudge — the X reads off-center inside the circle without it. */
+      position: relative;
+      left: 1px;
+      top: -1px;
+    }
+    .theme-save-row {
+      display: flex; gap: 6px; margin-top: 8px;
+    }
+    .theme-save-row input {
+      flex: 1; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px;
+      background: var(--bg); color: var(--fg); font-size: 12px; font-family: inherit;
+    }
+    .theme-save-row input:focus { outline: none; border-color: var(--red); }
+    .theme-save-row input::placeholder { color: color-mix(in srgb, var(--fg) 35%, transparent); }
+    .theme-save-row button {
+      padding: 6px 12px; border: 1px solid var(--red); border-radius: 6px;
+      background: transparent; color: var(--red); cursor: pointer;
+      font-size: 12px; font-family: inherit; white-space: nowrap; transition: all 0.15s;
+    }
+    .theme-save-row button:hover { background: color-mix(in srgb, var(--red) 11%, transparent); }
+    .theme-save-error {
+      font-size: 11px; color: var(--red); margin-top: 2px; display: none;
+    }
+    /* Import/Export */
+    .theme-io-row { display: flex; gap: 6px; margin-top: 6px; }
+    .theme-io-btn {
+      flex: 1; padding: 5px 10px; border: 1px solid var(--border); border-radius: 6px;
+      background: transparent; color: var(--fg); cursor: pointer;
+      font-size: 12px; opacity: 0.7; transition: all 0.15s; font-family: inherit;
+    }
+    .theme-io-btn:hover { opacity: 1; border-color: var(--fg); background: color-mix(in srgb, var(--fg) 5%, transparent); }
+    .theme-import-area {
+      width: 100%; margin-top: 6px; padding: 6px 8px;
+      border: 1px solid var(--border); border-radius: 6px;
+      background: var(--bg); color: var(--fg); font-size: 0.7rem;
+      font-family: inherit; resize: vertical; min-height: 48px;
+    }
+    .theme-import-area:focus { outline: none; border-color: var(--red); }
+    .theme-import-area.hidden { display: none; }
+    .theme-import-actions { display: flex; gap: 6px; margin-top: 4px; }
+    .theme-import-actions.hidden { display: none; }
+    /* Font & Density */
+    .theme-fd-row { display: flex; gap: 8px; margin-bottom: 8px; }
+    .theme-fd-group { flex: 1; display: flex; flex-direction: column; gap: 3px; }
+    .theme-fd-label { font-size: 12px; font-weight: 500; color: var(--fg); opacity: 0.6; }
+    .theme-fd-select {
+      padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px;
+      background: var(--bg); color: var(--fg); font-size: 12px;
+      font-family: inherit; cursor: pointer;
+    }
+    .theme-fd-select:focus { outline: none; border-color: var(--red); }
+    .theme-fd-range {
+      width: 100%;
+      max-width: 100%;
+      box-sizing: border-box;
+      margin: 0;
+      padding: 0;
+      height: 24px;
+      background: transparent;
+      cursor: pointer;
+      -webkit-appearance: none;
+      appearance: none;
+      accent-color: var(--red);
+    }
+    .theme-fd-range::-webkit-slider-runnable-track {
+      height: 4px; background: var(--border); border-radius: 2px;
+    }
+    .theme-fd-range::-moz-range-track {
+      height: 4px; background: var(--border); border-radius: 2px;
+    }
+    .theme-fd-range::-webkit-slider-thumb {
+      -webkit-appearance: none; appearance: none;
+      width: 14px; height: 14px; border-radius: 50%;
+      background: var(--red); border: none; margin-top: -5px; cursor: pointer;
+    }
+    .theme-fd-range::-moz-range-thumb {
+      width: 14px; height: 14px; border-radius: 50%;
+      background: var(--red); border: none; cursor: pointer;
+    }
+    .theme-fd-range:focus { outline: none; }
+    /* Color Harmony Generator */
+    .theme-harmony-row { display: flex; gap: 8px; align-items: flex-end; }
+    .harmony-generate-btn {
+      padding: 5px 14px; border: 1px solid var(--red); border-radius: 6px;
+      background: transparent; color: var(--red); cursor: pointer;
+      font-size: 12px; white-space: nowrap; transition: all 0.15s;
+      font-family: inherit; width: 100%;
+    }
+    .harmony-generate-btn:hover { background: color-mix(in srgb, var(--red) 11%, transparent); }
+    .harmony-preview {
+      display: flex; height: 20px; border-radius: 6px; overflow: hidden;
+      margin-top: 8px; border: 1px solid var(--border);
+    }
+    .harmony-preview:empty { display: none; border: none; }
+    .harmony-preview span { flex: 1; }
+    #theme-reset-btn {
+      margin-top: 6px; width: 100%; padding: 6px; border: 1px solid var(--border);
+      border-radius: 6px; background: var(--bg); color: var(--fg); cursor: pointer;
+      font-size: 12px; font-family: inherit; opacity: 0.7; transition: opacity 0.15s;
+    }
+    #theme-reset-btn:hover { opacity: 1; }
+    .theme-adv-toggle {
+      margin-top: 10px; margin-bottom: 10px;
+      padding: 6px 8px; cursor: pointer;
+      font-size: 11px; color: var(--red); opacity: 0.8;
+      border: 1px solid var(--border); border-radius: 6px;
+      transition: opacity 0.15s, background 0.15s;
+      user-select: none;
+    }
+    .theme-adv-toggle:hover { opacity: 1; background: color-mix(in srgb, var(--fg) 4%, transparent); }
+    .theme-adv-toggle .theme-adv-arrow {
+      display: inline-block; transition: transform 0.2s; font-size: 10px;
+      margin-right: 4px;
+    }
+    .theme-adv-toggle.open .theme-adv-arrow { transform: rotate(90deg); }
+    .theme-adv-section { margin-top: 8px; margin-bottom: 10px; }
+    .theme-adv-section.hidden { display: none; }
+    .theme-adv-group { margin-bottom: 8px; }
+    .theme-adv-group-label {
+      font-size: 10px; color: var(--fg); opacity: 0.5;
+      text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px;
+    }
+    .theme-adv-clear-btn {
+      margin-top: 6px; width: 100%; padding: 5px; border: 1px solid var(--border);
+      border-radius: 6px; background: transparent; color: var(--fg); opacity: 0.5;
+      cursor: pointer; font-size: 12px; font-family: inherit; transition: opacity 0.15s;
+    }
+    .theme-adv-clear-btn:hover { opacity: 1; }
+
+
+    /* Mobile: bottom sheet modals — slide up from bottom */
+    @media (max-width: 768px) {
+      .modal {
+        align-items: flex-end;
+        background: rgba(0,0,0,0.4);
+        pointer-events: auto;
+        /* Anchor the bottom sheet to the DYNAMIC viewport. The base overlay is
+           position:fixed; height:100% = the layout viewport, whose bottom sits
+           UNDER Firefox Android's bottom URL bar — so flex-end parked the sheet
+           (and its footer / Start button) beneath the bar. dvh excludes the bar
+           so the footer lands above it. Extra safe-area pad for iOS home bar. */
+        height: 100dvh;
+        padding-bottom: env(safe-area-inset-bottom, 0px);
+      }
+      /* Confirm dialog stays centered, not a bottom sheet */
+      #styled-confirm-overlay {
+        align-items: center;
+      }
+      .styled-confirm-box {
+        border-radius: 12px !important;
+        border: 1px solid var(--border) !important;
+        animation: modal-enter 0.25s ease-out both !important;
+        max-height: none !important;
+      }
+      .styled-confirm-box.modal-closing {
+        animation: modal-exit 0.18s ease-in both !important;
+      }
+      .styled-confirm-box::before {
+        display: none !important;
+      }
+      .styled-confirm-box .close-btn {
+        display: none !important;
+      }
+      #theme-popup {
+        width: 100% !important;
+        height: 65vh !important;
+        max-height: 65vh !important;
+        top: auto !important; left: 0 !important; right: 0 !important; bottom: 0 !important;
+        position: fixed !important;
+        border-radius: 14px 14px 0 0;
+        border: none;
+        border-top: 1px solid var(--border);
+        padding: 6px 12px 12px;
+        animation: sheet-enter 0.2s ease-out forwards;
+        overflow-y: hidden !important;
+      }
+      #theme-popup .theme-tab-panel {
+        touch-action: pan-y;
+        overscroll-behavior: contain;
+        -webkit-overflow-scrolling: touch;
+      }
+      #theme-popup.sheet-ready {
+        animation: none;
+      }
+      #theme-popup.modal-closing {
+        animation: sheet-exit 0.15s ease-in both !important;
+      }
+      .modal-content,
+      .memory-modal-content,
+      .settings-modal-content,
+      #compare-model-overlay .modal-content {
+        width: 100% !important;
+        /* 85dvh leaves comfortable headroom above the sheet so the user can
+           still see the chat behind it and the drag handle has breathing
+           room. Was 65vh (too short, content clipped) → 90vh (too tall, top
+           hugged the status bar). */
+        max-height: 85dvh !important;
+        max-height: 85vh !important; /* fallback for browsers without dvh */
+        height: auto !important;
+        border-radius: 14px 14px 0 0;
+        border: none;
+        border-top: 1px solid var(--border);
+        padding-top: 6px !important;
+        /* Clip children to the rounded top corners — otherwise the sticky
+           modal-header's var(--panel) background paints a darker
+           rectangle past the radius and the corners look square again. */
+        overflow: hidden;
+        animation: sheet-enter 0.2s ease-out forwards;
+      }
+      /* Tool modals fill the full mobile viewport — top edge to bottom —
+         instead of stopping at the 85dvh sheet height and leaving a gap.
+         Email's content carries both `modal-content` + `doclib-modal-content`,
+         so the generic 85dvh rule above (later in source, equal specificity)
+         was capping it; the ID-scoped selectors here win on specificity and
+         bring every tool (cookbook / tasks / memory / settings / email /
+         library) to the same top edge. */
+      #cookbook-modal .modal-content,
+      #tasks-modal .modal-content,
+      #calendar-modal .modal-content,
+      #memory-modal .memory-modal-content,
+      #settings-modal .settings-modal-content,
+      #email-lib-modal .modal-content,
+      #doclib-modal .doclib-modal-content {
+        max-height: 100vh !important;
+        max-height: 100dvh !important;
+        height: 100vh !important;
+        height: 100dvh !important;
+        /* Reserve the iOS home-indicator / bottom-bar strip so an expanded
+           skill card's footer (Delete / Edit / Run) isn't hidden under it.
+           dvh already excludes the URL bar; this handles the safe area. */
+        padding-bottom: env(safe-area-inset-bottom, 0px) !important;
+      }
+      /* Grab handle pill on the non-standard content classes too. Memory's
+         desktop rule defines a radial-glow ::before later in the file —
+         these body-prefixed selectors win the specificity battle on mobile. */
+      body .memory-modal-content::before,
+      body .settings-modal-content::before {
+        content: '';
+        display: block;
+        position: static;
+        inset: auto;
+        width: 36px; height: 4px;
+        background: var(--fg);
+        opacity: 0.25;
+        border-radius: 2px;
+        margin: 0 auto 4px;
+        flex-shrink: 0;
+        padding: 0;
+        border-top: 10px solid transparent;
+        border-bottom: 6px solid transparent;
+        background-clip: padding-box;
+        animation: none;
+      }
+      .memory-modal-content .close-btn,
+      .memory-modal-content .modal-close,
+      .settings-modal-content .close-btn,
+      .settings-modal-content .modal-close {
+        display: none !important;
+      }
+      .memory-modal-content,
+      .settings-modal-content {
+        touch-action: pan-y;
+        overscroll-behavior: contain;
+      }
+      /* Library modals — go full-bleed on mobile so content extends to the
+         very bottom (no wasted vh strip below). The parent modal centers
+         them; padding-top reset is in the per-modal rules below. */
+      #cookbook-modal .modal-content,
+      #calendar-modal .modal-content,
+      #email-lib-modal .modal-content,
+      #doclib-modal .modal-content,
+      #gallery-modal .modal-content,
+      #tasks-modal .modal-content {
+        /* vh-first as fallback for very old browsers, then dvh wins so
+           the modal shrinks/grows with the mobile URL bar. Wrong order
+           previously made the expanded chat preview extend below the
+           visible viewport on Chrome/Safari mobile (URL bar covered the
+           action buttons row). */
+        max-height: 100vh !important;
+        max-height: 100dvh !important;
+        height: 100vh !important;
+        height: 100dvh !important;
+      }
+      /* Anchor those modals to the top so the full height is usable */
+      #cookbook-modal,
+      #calendar-modal,
+      #email-lib-modal,
+      #doclib-modal,
+      #gallery-modal,
+      #tasks-modal {
+        padding-top: 0 !important;
+        align-items: stretch !important;
+      }
+      /* Deep Research already gets the swipe grab-handle pill from the
+         shared `.modal-content::before` rule (the pane carries that class).
+         The previous header-level pill was redundant and rendered as a
+         SECOND pill stacked above the real one — removed. */
+      /* The inner body must flex to fill the new full height, and the
+         tasks list inside it must scroll independently — otherwise the
+         list crops at whatever pre-mobile height the desktop layout had. */
+      #tasks-modal .modal-content {
+        display: flex !important;
+        flex-direction: column !important;
+      }
+      #tasks-modal .modal-body {
+        flex: 1 1 auto !important;
+        min-height: 0 !important;
+      }
+      /* Memory/Skills: the body lacks flex:1, so on the fixed-height mobile
+         sheet (overflow:hidden) it grew past the viewport and clipped the
+         bottom of the skills list + its row action buttons with no way to
+         scroll there. Bound the body so the inner list scrolls internally. */
+      #memory-modal .memory-modal-content {
+        display: flex !important;
+        flex-direction: column !important;
+      }
+      /* flex-basis MUST be 0 (not auto) — matches the working #doclib-modal
+         .modal-body. With basis:auto, Firefox (incl. mobile) sizes the body
+         to its content (~half height) instead of filling, while Chromium
+         fills it regardless — which is exactly why the skill expand worked
+         on desktop/Chromium but stuck at ~50% on Firefox mobile. */
+      #memory-modal .memory-modal-body {
+        flex: 1 1 0 !important;
+        min-height: 0 !important;
+        overflow: hidden !important;
+      }
+      /* Same basis:0 fix down the rest of the skills chain so every layer
+         fills instead of sizing to content under Firefox. */
+      #memory-modal .memory-tab-panel[data-memory-panel="skills"] {
+        flex: 1 1 0 !important;
+        min-height: 0 !important;
+      }
+      #memory-modal .memory-tab-panel[data-memory-panel="skills"] > .admin-card {
+        flex: 1 1 0 !important;
+        min-height: 0 !important;
+      }
+      /* The skills modal carries an extra toolbar row (search/sort/select +
+         bulk bar) the doc/email libraries don't, so the shared 82dvh preview
+         min-height overflowed here and pushed the expanded footer (Delete /
+         Edit / Run) off the bottom of the screen. Let flexbox size it from
+         the resolved card height instead, so the footer always pins inside
+         the visible area. */
+      #memory-modal .doclib-card.doclib-card-expanded .doclib-card-preview {
+        min-height: 0 !important;
+      }
+      /* Once enter animation finishes, clear it so inline transform works for dragging */
+      .modal-content.sheet-ready {
+        animation: none;
+      }
+      .modal-content.modal-closing {
+        animation: sheet-exit 0.15s ease-in both !important;
+      }
+      /* Grab handle — large touch target, visible pill */
+      #theme-popup::before,
+      .modal-content::before {
+        content: '';
+        display: block;
+        width: 36px; height: 4px;
+        background: var(--fg); opacity: 0.25;
+        border-radius: 2px;
+        margin: 0 auto 4px;
+        flex-shrink: 0;
+        padding: 0;
+        /* Expand touch target without changing visual size */
+        border-top: 10px solid transparent;
+        border-bottom: 6px solid transparent;
+        background-clip: padding-box;
+      }
+      /* Hide close X on mobile — swipe down to dismiss */
+      .modal-content .close-btn,
+      .modal-content .modal-close,
+      #theme-popup .close-btn {
+        display: none !important;
+      }
+      /* Hide the auto-injected minimize (_) button on mobile — the dock
+         chip already represents the minimized state, and the swipe-down
+         gesture is the canonical minimize action. */
+      .modal-minimize-btn,
+      .minimize-btn,
+      [data-minimize] {
+        display: none !important;
+      }
+      /* Lock modals to vertical touch only, prevent horizontal dragging */
+      .modal-content {
+        touch-action: pan-y;
+        overscroll-behavior: contain;
+      }
+      .modal-content .cookbook-body,
+      .modal-content .modal-body {
+        touch-action: pan-y;
+        overscroll-behavior: contain;
+      }
+      .modal-header {
+        cursor: default;
+        touch-action: none;
+      }
+      @keyframes sheet-enter {
+        from { transform: translateY(100%); }
+        to   { transform: translateY(0); }
+      }
+      @keyframes sheet-exit {
+        from { transform: translateY(0); }
+        to   { transform: translateY(100%); }
+      }
+    }
+
+    /* ── Model A/B Comparison ── */
+
+    /* -- Extracted inline-style classes -- */
+    .cmp-header-action-btn {
+      background: none; border: 1px solid var(--border); color: var(--fg);
+      cursor: pointer; padding: 3px 10px; font-size: 11px; font-weight: 600;
+      opacity: 0.7; transition: all 0.15s; line-height: 1; border-radius: 4px;
+      display: inline-flex; align-items: center; font-family: inherit;
+    }
+    .cmp-form-control {
+      padding: 8px; background: var(--bg); color: var(--fg);
+      border: 1px solid var(--border); border-radius: 6px; font-size: 0.85em;
+    }
+    .cmp-btn-secondary {
+      background: transparent; color: var(--fg); border: 1px solid var(--border);
+      border-radius: 6px; cursor: pointer;
+    }
+    .cmp-btn-primary {
+      background: var(--fg); color: var(--bg); border: none;
+      border-radius: 6px; cursor: pointer; font-weight: 600;
+      transition: filter 0.12s, background 0.12s, color 0.12s;
+    }
+    /* Override global button:hover (which switches bg to var(--panel) =
+       very dark) — keep the bright primary look and just brighten slightly. */
+    .cmp-btn-primary:hover:not(:disabled) {
+      background: var(--fg); color: var(--bg);
+      filter: brightness(1.1);
+    }
+    .cmp-btn-primary:active:not(:disabled) { filter: brightness(0.95); }
+    .cmp-btn-secondary:hover:not(:disabled) {
+      background: color-mix(in srgb, var(--fg) 8%, transparent);
+      border-color: var(--fg); color: var(--fg);
+    }
+    .cmp-model-row {
+      display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
+      transition: margin-left 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
+    }
+    .cmp-row-label {
+      color: var(--fg); font-size: 0.82em; font-weight: 600; min-width: 20px;
+      opacity: 0.4; text-align: center; flex-shrink: 0;
+    }
+    .cmp-rm-btn {
+      background: none; border: none; color: var(--fg); cursor: pointer;
+      min-width: 20px; font-size: 16px; font-weight: 600; opacity: 0.3;
+      transition: all 0.15s; padding: 0; line-height: 1; text-align: center;
+      position: relative; top: -1px;
+    }
+    .cmp-prov-select {
+      flex: 0 0 auto; width: 120px; font-size: 0.8em;
+    }
+    /* Eval-prompts picker — only shown during compare; absolute top-right
+       inside .chat-input-top, matching .model-picker-wrap's slot. */
+    .chat-input-top > .cmp-eval-wrap {
+      position: absolute;
+      top: 0; right: 0;
+      z-index: 2;
+    }
+    .cmp-eval-btn {
+      display: inline-flex; align-items: center; gap: 4px;
+      padding: 4px 8px;
+      background: transparent;
+      border: 1px solid var(--border);
+      border-radius: 6px;
+      color: var(--fg);
+      font-size: 11px; font-weight: 500;
+      font-family: inherit;
+      cursor: pointer; opacity: 0.75;
+      transition: opacity 0.15s, border-color 0.15s;
+    }
+    .cmp-eval-btn:hover { opacity: 1; border-color: var(--fg); }
+    .cmp-eval-caret { opacity: 0.7; transform: rotate(180deg); }
+    .cmp-eval-menu {
+      position: absolute; bottom: calc(100% + 4px); right: 0;
+      min-width: 220px; max-width: 280px;
+      max-height: 360px; overflow-y: auto;
+      background: var(--panel);
+      border: 1px solid var(--border);
+      border-radius: 6px;
+      box-shadow: 0 -4px 16px rgba(0,0,0,0.3);
+      padding: 4px;
+      z-index: 1000;
+    }
+    .cmp-eval-menu.hidden { display: none; }
+    .cmp-eval-group-label {
+      font-size: 9px; text-transform: uppercase; letter-spacing: 0.5px;
+      opacity: 0.45; font-weight: 600;
+      padding: 6px 8px 2px;
+    }
+    .cmp-eval-item {
+      display: block; width: 100%;
+      text-align: left;
+      padding: 5px 8px;
+      background: none; border: none;
+      color: var(--fg); font-size: 11px;
+      font-family: inherit;
+      border-radius: 4px;
+      cursor: pointer;
+    }
+    .cmp-eval-item:hover {
+      background: color-mix(in srgb, var(--fg) 8%, transparent);
+    }
+    .cmp-eval-empty {
+      padding: 10px; text-align: center;
+      font-size: 11px; opacity: 0.5;
+    }
+    /* Tick on items that ship a known expected answer */
+    .cmp-eval-item-tick {
+      float: right;
+      margin-left: 6px;
+      font-size: 10px;
+      color: var(--color-success, #4caf50);
+      opacity: 0.8;
+    }
+    /* Expected-answer panel — floats as its own little window above the
+       chat-input-bar. Distinct surface, padded, drop-shadow, slightly
+       lifted so it reads as a separate UI element, not part of the input. */
+    .chat-input-bar:has(.cmp-eval-expected) { position: relative; }
+    .cmp-eval-expected {
+      position: absolute;
+      bottom: calc(100% + 8px);
+      right: 0;
+      display: inline-flex; align-items: center; gap: 8px;
+      padding: 8px 12px;
+      font-size: 11px;
+      background: var(--panel);
+      border: 1px solid color-mix(in srgb, var(--color-success, #4caf50) 50%, transparent);
+      border-radius: 8px;
+      box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.2);
+      color: var(--fg);
+      width: fit-content;
+      z-index: 5;
+      pointer-events: auto;
+    }
+    .cmp-eval-expected.hidden { display: none; }
+    .cmp-eval-expected-label {
+      opacity: 0.6;
+      text-transform: uppercase;
+      font-size: 9px;
+      letter-spacing: 0.5px;
+      font-weight: 600;
+    }
+    .cmp-eval-expected-value {
+      font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+      font-size: 11px;
+    }
+    .cmp-eval-expected-close {
+      background: none; border: none; color: var(--fg);
+      font-size: 14px; line-height: 1; padding: 0 0 0 4px;
+      opacity: 0.5; cursor: pointer; font-family: inherit;
+    }
+    .cmp-eval-expected-close:hover { opacity: 1; }
+    /* Auto-grade badge — stamped on a pane after stream completes when an
+       eval prompt with a known expected answer was used. */
+    .pane-grade-badge {
+      display: inline-flex; align-items: center; justify-content: center;
+      width: 18px; height: 18px;
+      margin: 0 4px;
+      font-size: 12px; font-weight: 700;
+      border-radius: 50%;
+      border: 1px solid currentColor;
+      flex-shrink: 0;
+    }
+    .pane-grade-badge.pass {
+      color: var(--color-success, #4caf50);
+      background: color-mix(in srgb, var(--color-success, #4caf50) 12%, transparent);
+    }
+    .pane-grade-badge.fail {
+      color: var(--color-error, #e55);
+      background: color-mix(in srgb, var(--color-error, #e55) 12%, transparent);
+    }
+
+    /* Compare probe overlay */
+    .compare-probe-overlay {
+      position: fixed; inset: 0; z-index: 300;
+      background: rgba(0,0,0,0.5); display: flex;
+      align-items: center; justify-content: center;
+    }
+    .compare-probe-card {
+      background: var(--panel); border-radius: 12px; padding: 20px 24px;
+      display: flex; flex-direction: column; align-items: center; gap: 12px;
+      min-width: 280px; max-width: 90vw; box-shadow: 0 8px 32px rgba(0,0,0,0.3);
+      overflow: hidden;
+    }
+    .compare-probe-title {
+      font-size: 13px; font-weight: 600; opacity: 0.7;
+    }
+    .compare-probe-list {
+      display: grid; grid-template-columns: 1fr 1fr; gap: 6px; min-width: 320px; width: 100%;
+    }
+    .compare-probe-row {
+      display: flex; align-items: center; gap: 6px; padding: 6px 10px;
+      border-radius: 6px; background: color-mix(in srgb, var(--fg) 4%, transparent);
+      font-size: 12px; transition: background 0.2s; overflow: hidden;
+    }
+    .compare-probe-row.fail {
+      background: color-mix(in srgb, var(--color-error, #f44) 8%, transparent);
+    }
+    .compare-probe-spinner {
+      width: 24px; text-align: center; font-size: 10px; flex-shrink: 0;
+      letter-spacing: -1px; opacity: 0.6;
+    }
+    .compare-probe-spinner.ok { color: var(--color-success); animation: none; }
+    .compare-probe-spinner.fail { color: var(--color-error, #f44); animation: none; }
+    .compare-probe-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+    .compare-probe-status { font-size: 11px; opacity: 0.5; flex-shrink: 0; max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+    .compare-probe-status.ok { color: var(--color-success); opacity: 1; }
+    .compare-probe-status.fail { color: var(--color-error, #f44); opacity: 1; }
+    .compare-probe-action-btn {
+      padding: 2px 8px; background: transparent; color: var(--fg); border: 1px solid var(--border);
+      border-radius: 4px; cursor: pointer; font-size: 10px; font-family: inherit;
+      opacity: 0.7; transition: opacity 0.15s, border-color 0.15s; white-space: nowrap; flex-shrink: 0;
+    }
+    .compare-probe-action-btn:hover { opacity: 1; border-color: var(--accent); }
+    @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
+    @keyframes pane-shake {
+      0%, 100% { transform: translateX(0); }
+      15% { transform: translateX(-3px) rotate(-0.5deg); }
+      30% { transform: translateX(3px) rotate(0.5deg); }
+      45% { transform: translateX(-2px); }
+      60% { transform: translateX(2px); }
+      75% { transform: translateX(-1px); }
+    }
+
+    .chat-container.compare-active {
+      display: flex;
+      flex-direction: column;
+      padding: 0;
+      overflow: hidden;
+      animation: compare-enter 0.3s ease-out;
+    }
+    @keyframes compare-enter {
+      from { opacity: 0; transform: translateY(8px); }
+      to { opacity: 1; transform: translateY(0); }
+    }
+    .chat-container.compare-active .chat-input-bar {
+      margin-bottom: 0;
+    }
+    .chat-container.compare-active #model-picker-wrap {
+      display: none !important;
+    }
+    .compare-grid {
+      display: grid;
+      gap: 4px;
+      flex: 1 1 0;
+      min-height: 0;
+      overflow: hidden;
+    }
+    .compare-grid[data-cols="2"] { grid-template-columns: 1fr 1fr; }
+    .compare-grid[data-cols="3"] { grid-template-columns: 1fr 1fr 1fr; }
+    .compare-grid[data-cols="4"] { grid-template-columns: repeat(4, 1fr); }
+    .compare-grid[data-cols="5"] { grid-template-columns: repeat(4, 1fr); }
+    .compare-grid[data-cols="6"] { grid-template-columns: repeat(4, 1fr); }
+    .compare-grid[data-cols="7"] { grid-template-columns: repeat(4, 1fr); }
+    .compare-grid[data-cols="8"] { grid-template-columns: repeat(4, 1fr); }
+    .compare-grid { grid-auto-rows: 1fr; }
+    /* Sequential waterfall layout — stacked rows, staggered left, flush right */
+    .compare-grid.sequential-layout {
+      display: flex !important;
+      flex-direction: column !important;
+      grid-template-columns: none !important;
+      gap: 4px;
+      overflow-y: auto;
+    }
+    .compare-grid.sequential-layout .compare-pane {
+      flex-shrink: 0;
+      min-height: 200px;
+      transition: margin-left 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease;
+    }
+    /* Herringbone diagonal cut on left side of sequential pane headers */
+    .compare-grid.sequential-layout .compare-pane .pane-header {
+      clip-path: polygon(20px 0, 100% 0, 100% 100%, 0 100%);
+      padding-left: 26px;
+    }
+    .compare-pane {
+      display: flex;
+      flex-direction: column;
+      border: 1px solid var(--border);
+      border-radius: 6px;
+      overflow: hidden;
+      min-height: 0;
+      min-width: 0;
+    }
+    .compare-pane .pane-header {
+      display: flex;
+      align-items: center;
+      gap: 4px;
+      padding: 4px 10px;
+      background: color-mix(in srgb, var(--fg) 4%, transparent);
+      border-bottom: 1px solid var(--border);
+      font-size: 0.82em;
+      font-weight: 600;
+      color: var(--fg);
+      transition: background 0.4s;
+      flex-shrink: 0;
+      overflow: hidden;
+      min-width: 0;
+      flex-wrap: wrap;
+    }
+    .pane-actions {
+      display: flex; gap: 4px; align-items: center; margin-left: auto; flex-shrink: 0;
+    }
+    .compare-pane-footer {
+      font-size: 0.72em; opacity: 0.4; padding: 4px 10px;
+      border-top: 1px solid color-mix(in srgb, var(--border) 40%, transparent);
+      text-align: center; flex-shrink: 0;
+    }
+    /* Per-pane vote button. Sits at the bottom of each compare pane so
+       the action lives right under the response it judges. */
+    .pane-vote-footer {
+      padding: 6px 8px;
+      border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
+      background: color-mix(in srgb, var(--fg) 3%, transparent);
+      flex-shrink: 0;
+    }
+    .pane-vote-btn {
+      width: 100%;
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      gap: 4px;
+      background: var(--bg);
+      color: var(--fg);
+      border: 1px solid var(--border);
+      border-radius: 6px;
+      padding: 6px 10px;
+      font-family: inherit;
+      font-size: 12px;
+      font-weight: 500;
+      cursor: pointer;
+      transition: background 0.15s, border-color 0.15s, opacity 0.15s;
+    }
+    .pane-vote-btn:hover:not(:disabled) {
+      background: color-mix(in srgb, var(--accent, var(--fg)) 12%, var(--bg));
+      border-color: var(--accent, var(--fg));
+    }
+    .pane-vote-btn:disabled { cursor: not-allowed; }
+    .pane-vote-label {
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      min-width: 0;
+    }
+    .pane-action-btn {
+      background: none; border: none; color: var(--fg); cursor: pointer;
+      opacity: 0.3; padding: 2px; border-radius: 4px;
+      display: flex; align-items: center; transition: all 0.15s;
+    }
+    .pane-action-btn:hover { opacity: 0.8; background: color-mix(in srgb, var(--fg) 6%, transparent); }
+    /* Pane title as clickable model-swap button */
+    .pane-title-btn {
+      background: none; border: none; cursor: pointer;
+      font-size: 10px; font-weight: 400; font-family: inherit;
+      color: var(--fg); padding: 0;
+      text-align: left; display: flex; align-items: center; gap: 4px;
+      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+      transition: opacity 0.15s;
+      min-width: 0; flex: 1 1 0;
+    }
+    .pane-title-btn:hover { opacity: 0.7; }
+    .pane-title-caret { font-size: 0.6em; opacity: 0.35; flex-shrink: 0; position: relative; top: 2px; }
+    .pane-title-btn:hover .pane-title-caret { opacity: 0.7; }
+    .compare-pane .pane-close-btn { opacity: 0.3; }
+    .compare-pane .pane-close-btn:hover { opacity: 1; color: var(--color-error); }
+    /* Model swap dropdown under pane title */
+    .pane-model-dropdown {
+      position: absolute;
+      z-index: 1000;
+      min-width: 220px;
+      max-height: 300px;
+      overflow-y: auto;
+      background: var(--bg);
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      box-shadow: 0 4px 16px rgba(0,0,0,0.3);
+      padding: 4px;
+    }
+    .pane-model-item {
+      display: block; width: 100%;
+      padding: 6px 10px; font-size: 0.7em;
+      text-align: left; background: none; border: none; border-radius: 4px;
+      color: var(--fg); cursor: pointer; transition: background 0.1s;
+      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+    }
+    .pane-model-item:hover { background: color-mix(in srgb, var(--fg) 10%, transparent); }
+    .pane-model-item.current { color: var(--red); font-weight: 600; }
+    .pane-timer {
+      font-size: 10px; font-weight: 400; opacity: 0.45; font-variant-numeric: tabular-nums;
+      white-space: nowrap; padding-right: 4px;
+    }
+    /* When 4+ panes, timer wraps to its own row */
+    .compare-grid[data-cols="4"] .pane-timer,
+    .compare-grid[data-cols="5"] .pane-timer,
+    .compare-grid[data-cols="6"] .pane-timer {
+      width: 100%; order: 99; margin-top: -4px; padding-bottom: 2px; padding-left: 2px;
+    }
+    .pane-finish-badge {
+      font-weight: 600; color: var(--red);
+    }
+    .compare-pane.winner .pane-header {
+      background: color-mix(in srgb, var(--red) 12%, transparent);
+      border-bottom-color: color-mix(in srgb, var(--red) 30%, var(--border));
+    }
+    .compare-pane.winner .pane-title {
+      color: var(--red);
+    }
+    .compare-pane.loser .pane-header {
+      opacity: 0.5;
+    }
+    .confetti-piece {
+      position: fixed;
+      pointer-events: none;
+      z-index: 1000000;
+    }
+    .compare-pane.expanded { grid-column: 1 / -1; }
+    .compare-pane .chat-history {
+      flex: 1 1 0;
+      min-height: 0;
+      overflow-y: auto !important;
+      overflow-x: hidden;
+      padding: 8px;
+      display: flex;
+      flex-direction: column;
+    }
+    .compare-pane .chat-history .msg {
+      flex-shrink: 0;
+    }
+    .compare-gen-image {
+      max-width: 100%;
+      max-height: 100%;
+      object-fit: contain;
+      border-radius: 4px;
+      cursor: pointer;
+    }
+    .compare-section {
+      margin-bottom: 14px;
+    }
+    .compare-section:last-child {
+      margin-bottom: 0;
+    }
+    .compare-section-label {
+      font-size: 9px;
+      text-transform: uppercase;
+      letter-spacing: 0.6px;
+      opacity: 0.5;
+      margin-bottom: 5px;
+      font-weight: 600;
+    }
+    /* Active-type/mode readout next to "Type:"/"Mode:" — only needed on mobile
+       (where the tab/toggle text labels are hidden); hidden on desktop. */
+    .compare-type-current,
+    .compare-mode-current { display: none; }
+    /* Contextual one-liner under the mode toggles describing what you just
+       changed — empty until the first toggle, then a subtle hint. */
+    .compare-mode-hint {
+      font-size: 11px;
+      opacity: 0.55;
+      margin-top: 6px;
+      min-height: 0;
+      line-height: 1.3;
+    }
+    .compare-mode-hint:empty { display: none; }
+    .compare-mode-tabs {
+      display: flex;
+      gap: 4px;
+    }
+    /* Type tabs match Mode toggles 1:1 (same flex column layout, same metrics) */
+    .compare-mode-tab {
+      display: flex; flex-direction: column; align-items: center; justify-content: center;
+      width: 56px; height: auto; flex: 1 1 0;
+      padding: 5px 4px 4px; border: 1px solid var(--border); border-radius: 6px;
+      cursor: pointer; user-select: none; transition: all 0.15s;
+      background: none; color: var(--fg); opacity: 0.35;
+      flex-shrink: 0; gap: 0;
+      font-family: inherit;
+    }
+    .compare-mode-tab:hover {
+      opacity: 0.7; background: color-mix(in srgb, var(--fg) 6%, transparent);
+    }
+    .compare-mode-tab.active {
+      opacity: 1; border-color: var(--fg);
+      background: color-mix(in srgb, var(--fg) 10%, transparent);
+    }
+    .compare-sources-box {
+      display: flex; align-items: center; gap: 6px;
+      padding: 6px 10px; margin-bottom: 8px;
+      border-radius: 6px; font-size: 0.78em;
+      background: color-mix(in srgb, var(--red) 8%, transparent);
+      border: 1px solid color-mix(in srgb, var(--red) 20%, transparent);
+      color: color-mix(in srgb, var(--fg) 70%, transparent);
+      cursor: default;
+    }
+    .compare-sources-box .sources-label { font-weight: 600; }
+    /* Compact tool blocks inside compare panes */
+    .compare-pane .agent-thread-node {
+      margin: 4px 0; font-size: 0.85em;
+    }
+    .compare-pane .agent-thread-cmd {
+      max-height: 80px; overflow-y: auto;
+    }
+    .compare-pane .agent-tool-output pre {
+      max-height: 120px; overflow-y: auto;
+    }
+    .compare-vote-bar {
+      display: flex; justify-content: center; gap: 8px;
+      padding: 8px; border-top: 1px solid var(--border);
+      flex-shrink: 0; flex-wrap: wrap;
+    }
+    .compare-vote-bar.hidden { display: none; }
+    .compare-vote-btn {
+      padding: 6px 13px; border: 1px solid var(--border); border-radius: 6px;
+      background: var(--panel); color: var(--fg); cursor: pointer; font-size: 0.8em;
+      transition: all 0.15s; white-space: nowrap;
+    }
+    .compare-vote-btn:hover { border-color: var(--red); background: color-mix(in srgb, var(--red) 11%, transparent); }
+    .compare-vote-tie { opacity: 0.7; }
+    .compare-rematch-btn { display: flex; align-items: center; gap: 6px; margin-left: 8px; border-color: color-mix(in srgb, var(--fg) 20%, transparent); opacity: 0.6; }
+    .compare-rematch-btn:hover { opacity: 1; }
+    /* Preview button accent in pane header */
+    .pane-preview-btn.active { color: var(--red); }
+    /* Full-pane iframe for HTML preview */
+    .compare-pane-iframe {
+      flex: 1;
+      width: 100%;
+      border: none;
+      border-radius: 0 0 8px 8px;
+      background: #fff;
+    }
+    /* ---- Add-pane "+" button in pane header (last pane only) ---- */
+    .pane-add-btn { display: none !important; font-size: 16px; font-weight: 600; }
+    .compare-pane:last-child .pane-add-btn { display: flex !important; }
+    /* Dropdown for adding a pane */
+    .add-pane-dropdown {
+      position: absolute;
+      right: 0;
+      z-index: 100;
+      background: var(--panel, var(--bg));
+      border: 1px solid var(--border);
+      border-radius: 6px;
+      max-height: 300px;
+      overflow-y: auto;
+      min-width: 220px;
+      box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+      padding: 4px;
+    }
+    .add-pane-search {
+      width: 100%;
+      padding: 6px 8px;
+      border: 1px solid var(--border);
+      border-radius: 4px;
+      background: var(--bg);
+      color: var(--fg);
+      font-size: 0.85em;
+      box-sizing: border-box;
+      margin-bottom: 4px;
+      position: sticky;
+      top: 0;
+      z-index: 1;
+    }
+    /* Compare toggle buttons — icon + label stacked */
+    .compare-toggle-label {
+      display: block;
+      font-size: 9px;
+      line-height: 1;
+      margin-top: 2px;
+      font-weight: 500;
+    }
+    .compare-blind-toggle,
+    .compare-save-toggle,
+    .compare-dice-toggle,
+    .compare-parallel-toggle,
+    .compare-reset-toggle {
+      display: flex; flex-direction: column; align-items: center; justify-content: center;
+      width: 56px; height: auto; flex: 1 1 0;
+      padding: 5px 4px 4px; border: 1px solid var(--border); border-radius: 6px;
+      cursor: pointer; user-select: none; transition: all 0.15s;
+      background: none; color: var(--fg); opacity: 0.35;
+      flex-shrink: 0; gap: 0;
+    }
+    .compare-blind-toggle:hover,
+    .compare-save-toggle:hover,
+    .compare-dice-toggle:hover,
+    .compare-parallel-toggle:hover,
+    .compare-reset-toggle:hover {
+      opacity: 0.7; background: color-mix(in srgb, var(--fg) 6%, transparent);
+    }
+    .compare-blind-toggle.active {
+      opacity: 1; color: var(--color-blind-orange); border-color: var(--color-blind-orange);
+      background: rgba(255, 152, 0, 0.1);
+    }
+    .compare-save-toggle.active {
+      opacity: 1; color: var(--color-save-green); border-color: var(--color-save-green);
+      background: color-mix(in srgb, var(--red) 10%, transparent);
+    }
+    .compare-dice-toggle.active {
+      opacity: 1; color: var(--red); border-color: var(--red);
+      background: color-mix(in srgb, var(--red) 10%, transparent);
+    }
+    .compare-parallel-toggle {
+      opacity: 1; color: #e0a050; border-color: #e0a050;
+      background: rgba(224, 160, 80, 0.1);
+    }
+    .compare-parallel-toggle.active {
+      color: #5b8def; border-color: #5b8def;
+      background: rgba(91, 141, 239, 0.1);
+    }
+    @media (max-width: 520px) {
+      .compare-toggle-label { display: none; }
+      /* Tab text labels are hidden on mobile, so spell out the active type next
+         to "Type:" with its icon. */
+      .compare-type-current {
+        display: inline-flex;
+        align-items: center;
+        gap: 4px;
+        margin-left: 5px;
+        font-weight: 600;
+        vertical-align: -3px;
+      }
+      .compare-type-current svg { width: 14px; height: 14px; }
+      /* Mode list — comma-separated, each name already coloured inline. */
+      .compare-mode-current {
+        display: inline;
+        margin-left: 5px;
+        font-weight: 600;
+      }
+      .compare-blind-toggle,
+      .compare-save-toggle,
+      .compare-dice-toggle,
+      .compare-parallel-toggle,
+      .compare-reset-toggle {
+        width: 32px; height: 32px; min-width: 32px; padding: 0;
+      }
+      /* The Group tab's seq/parallel toggle is a single full-width button (not
+         a row of compact icons like the Compare header), so keep its label and
+         make it a comfortable touch target with the text beside the icon. */
+      #group-mode-btn {
+        flex-direction: row !important;
+        width: auto !important;
+        height: auto !important;
+        min-height: 44px;
+        padding: 8px 14px !important;
+        gap: 8px !important;
+        font-size: 13px;
+      }
+      #group-mode-btn .compare-toggle-label {
+        display: inline !important;
+        font-size: 13px;
+        margin-top: 0;
+      }
+      #group-mode-btn svg { width: 18px; height: 18px; }
+      /* Compare header: hide labels + close button, show icons only */
+      #compare-shuffle-btn span {
+        display: none;
+      }
+      #compare-shuffle-btn,
+      #compare-check-btn,
+      #compare-add-btn {
+        padding: 3px 6px;
+      }
+      /* Save space so the header buttons fit on one row: tighter padding +
+         smaller labels (icons kept full size). (Score now lives in the vote bar.) */
+      .compare-header-bar button { padding: 3px 5px !important; }
+      .compare-header-bar #compare-check-btn > span,
+      .compare-header-bar #compare-add-btn > span {
+        font-size: 10px; margin-left: 2px;
+      }
+      /* Compare mobile: keep the close X visible — it's the only way out
+         now that the hamburger is hidden during compare mode. */
+      .compare-header-bar {
+        padding: 14px 8px 10px 8px !important;
+        min-height: 44px;
+      }
+      /* Mode tabs: icons only, centered */
+      .compare-mode-tab span { display: none; }
+      .compare-mode-tabs { justify-content: center; }
+      /* Header action buttons: hide text labels on Export / Shuffle /
+         Model on mobile so the close X fits on the right. Score keeps
+         its "Score" label because its icon (4-square grid) reads too
+         similar to Shuffle's icon without text. */
+      .compare-header-bar #compare-export-btn > span,
+      .compare-header-bar #compare-shuffle-btn > span {
+        display: none;
+      }
+      /* Override the desktop override: on mobile the model picker overlay
+         MUST cap its height to the viewport so the dropdown doesn't run
+         past the bottom of the screen. */
+      #compare-model-overlay .modal-content {
+        max-height: 85dvh !important;
+        max-height: 85vh !important;
+        overflow: hidden !important;
+      }
+      #compare-model-overlay .modal-body {
+        overflow: auto !important;
+        flex: 1 1 auto !important;
+        min-height: 0 !important;
+      }
+    }
+    /* Hide number input spinners */
+    input[type="number"]::-webkit-inner-spin-button,
+    input[type="number"]::-webkit-outer-spin-button {
+      -webkit-appearance: none; margin: 0;
+    }
+
+    /* ── Sensitive info censor ── */
+    .censored-item {
+      filter: blur(5px);
+      cursor: pointer;
+      transition: filter 0.2s;
+      border-radius: 2px;
+      padding: 0 2px;
+      background: rgba(255,100,100,0.08);
+      user-select: none;
+    }
+    .censored-item:hover { filter: blur(3px); background: rgba(255,100,100,0.15); }
+    .censored-item.revealed {
+      filter: none;
+      background: rgba(100,255,100,0.08);
+      user-select: auto;
+      cursor: text;
+    }
+
+    #settings-menu-list .list-item.active {
+      background: color-mix(in srgb, var(--accent) 10%, transparent);
+      border-color: var(--accent);
+    }
+
+    /* ── Print / PDF Export ── */
+    @media print {
+      body { background: #fff !important; color: #000 !important; }
+      #sidebar, .sidebar, #icon-rail, .hamburger-btn, #sidebar-backdrop, .chat-input-bar, .input-bar-wrapper,
+      #welcome-screen, .chat-top-bar, .chat-meta-overlay, .msg-footer,
+      .modal, .toast, .overflow-wrapper, .mode-toggle, .incognito-btn,
+      button, .dropdown, .session-dropdown,
+      .agent-tool-spinner, .agent-thread-node.running { display: none !important; }
+      main.chat-container { width: 100% !important; margin: 0 !important; padding: 0 !important; max-height: none !important; overflow: visible !important; }
+      #chat-history { max-height: none !important; overflow: visible !important; height: auto !important; padding: 0 !important; }
+      .msg { break-inside: avoid; page-break-inside: avoid; border: none !important; box-shadow: none !important; }
+      .msg-ai { background: #f5f5f5 !important; color: #000 !important; }
+      .msg-user { background: #e8e8e8 !important; color: #000 !important; }
+      .msg .role { color: #333 !important; font-weight: bold; }
+      .msg .body { color: #000 !important; }
+      pre, code { background: #f0f0f0 !important; color: #000 !important; border: 1px solid #ccc !important; }
+      details { display: block !important; }
+      details[open] summary ~ * { display: block !important; }
+      details > summary { list-style: none; }
+      details > summary::before { content: "" !important; }
+      #chat-history::before { content: attr(data-print-title); display: block; font-size: 1.3em; font-weight: bold; margin-bottom: 1em; color: #000; }
+      a { color: #000 !important; text-decoration: underline; }
+    }
+
+
+/* ── Components (from style.css) ── */
+
+/* Self-hosted Fira Code font */
+@font-face { font-family: 'Fira Code'; font-weight: 300; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Light.woff2') format('woff2'); }
+@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
+@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
+
+/* Scrollbar styling */
+
+/* Code block styling */
+pre, code, .hljs {
+  font-size: 0.95em;
+  line-height: 1.5;
+}
+
+/* WebKit (Chrome, Edge, Safari) */
+/* Utility class for red text */
+.red-text {
+  color: var(--red);
+}
+
+/* Internal chat links (search results, session references) */
+a.chat-link {
+  color: var(--hl-function);
+  text-decoration: none;
+  border-bottom: 1px dotted var(--hl-function);
+  cursor: pointer;
+}
+a.chat-link:hover {
+  opacity: 0.8;
+  border-bottom-style: solid;
+}
+
+/* Session items */
+.session-item { position: relative; }
+.text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.session-menu-btn { padding: 0 2px !important; min-width: 20px; height: 20px; display: inline-flex !important; align-items: center; justify-content: center; background: none !important; border-color: transparent !important; }
+.session-menu-btn:hover { background: none !important; border-color: transparent !important; }
+@media (max-width: 768px) {
+  .session-menu-btn { display: none !important; }
+  .item-drag-handle { display: none !important; }
+}
+.session-menu-btn svg { transition: transform 0.2s ease; }
+
+/* First-time swipe hint */
+.swipe-hint {
+  position: absolute;
+  right: 8px;
+  top: 50%;
+  transform: translateY(-50%);
+  font-size: 0.7rem;
+  color: var(--color-error, #f44);
+  opacity: 0.8;
+  transition: opacity 0.5s ease;
+  pointer-events: none;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  z-index: 2;
+}
+.swipe-hint-arrow {
+  animation: swipe-nudge 1s ease-in-out infinite;
+}
+@keyframes swipe-nudge {
+  0%, 100% { transform: translateX(0); }
+  50% { transform: translateX(-6px); }
+}
+
+/* Utility classes */
+.muted { opacity: 0.5; }
+.muted-sm { opacity: 0.35; font-size: 0.8em; }
+.accent-link { color: var(--accent-primary, var(--color-accent)); cursor: pointer; font-size: 0.85em; }
+.models-empty-state { text-align: center; padding: 12px 8px; line-height: 1.6; }
+
+/* Provider logo inside favorite dot */
+.provider-logo {
+  border: none !important;
+  background: none !important;
+  width: 14px !important; height: 14px !important;
+  display: inline-flex; align-items: center; justify-content: center;
+  transition: opacity 0.15s;
+}
+.provider-logo svg { width: 14px; height: 14px; display: block; }
+.provider-logo:hover { opacity: 1 !important; transform: scale(1.2); }
+
+/* Hide session menu button until hover — use width:0 so it doesn't steal space from text */
+.list-item .hamburger { opacity: 0; width: 0; min-width: 0; overflow: hidden; padding: 0 !important; transition: opacity 0.15s, width 0.15s, padding 0.15s; flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
+.list-item:hover .hamburger { opacity: 1; width: 24px; min-width: 24px; padding: 0 4px !important; }
+@media (max-width: 768px) {
+  .list-item .hamburger { opacity: 0.5; width: 28px; min-width: 28px; padding: 0 4px !important; }
+  .list-item .hamburger:active { opacity: 1; }
+}
+
+/* Hamburger menu button styling (overrides default button appearance) */
+button.hamburger {
+  background: none;
+  border: none;
+  padding: 0;
+  cursor: pointer;
+}
+
+/* ============================================ */
+/* HEADER SIZING FOR CHAT MESSAGES */
+/* ============================================ */
+/* Markdown in chat messages — colorful, scannable */
+.msg h1, .msg h2, .msg h3, .msg h4, .msg h5, .msg h6 {
+  margin: 0.6em 0 0.3em 0;
+  line-height: 1.3;
+  border-bottom: none;
+  padding-bottom: 0;
+}
+
+.msg h1 {
+  font-size: 1.15em;
+  font-weight: 700;
+  color: var(--hl-keyword, #c678dd);
+}
+
+.msg h2 {
+  font-size: 1.1em;
+  font-weight: 600;
+  color: var(--hl-function, #5b8def);
+}
+
+.msg h3 {
+  font-size: 1.05em;
+  font-weight: 600;
+  color: var(--hl-string, #98c379);
+}
+
+.msg h4 {
+  font-size: 1.02em;
+  font-weight: 600;
+  color: var(--hl-builtin, #e5c07b);
+}
+
+.msg h5 {
+  font-size: 1em;
+  font-weight: 600;
+  color: var(--hl-variable, #61afef);
+}
+
+.msg h6 {
+  font-size: 0.95em;
+  font-weight: 600;
+  color: var(--hl-number, #d19a66);
+}
+
+/* Bold text — subtle accent color */
+.msg strong, .msg b {
+  color: var(--hl-builtin, #e5c07b);
+  font-weight: 600;
+}
+
+/* Italic — softer highlight */
+.msg em, .msg i {
+  color: var(--hl-params, #abb2bf);
+  font-style: italic;
+}
+
+/* Bold + italic */
+.msg strong em, .msg em strong,
+.msg b i, .msg i b,
+.msg b em, .msg em b,
+.msg strong i, .msg i strong {
+  color: var(--hl-keyword, #c678dd);
+}
+
+/* Strikethrough */
+.msg del {
+  color: color-mix(in srgb, var(--fg) 45%, transparent);
+  text-decoration: line-through;
+}
+
+/* Inline code */
+.msg code:not(pre code) {
+  color: var(--hl-string, #98c379);
+  background: color-mix(in srgb, var(--fg) 6%, transparent);
+  padding: 1px 5px;
+  border-radius: 4px;
+  font-size: 0.9em;
+}
+
+/* Blockquotes */
+.msg blockquote {
+  border-left: 3px solid var(--hl-function, #5b8def);
+  padding: 4px 12px;
+  margin: 0.5em 0;
+  color: color-mix(in srgb, var(--fg) 75%, transparent);
+  background: color-mix(in srgb, var(--fg) 3%, transparent);
+  border-radius: 0 6px 6px 0;
+}
+.msg blockquote p { margin: 0.3em 0; }
+
+/* Horizontal rules */
+.msg hr {
+  border: none;
+  height: 1px;
+  background: linear-gradient(90deg, transparent, var(--border), transparent);
+  margin: 0.8em 0;
+}
+
+/* Lists */
+.msg ul, .msg ol {
+  margin: 0.3em 0 0.3em 1.2em;
+  padding: 0;
+}
+.msg li {
+  margin: 0.15em 0;
+}
+.msg li::marker {
+  color: var(--hl-function, #5b8def);
+}
+
+/* Links */
+.msg a {
+  color: var(--hl-function, #5b8def);
+  text-decoration: none;
+  border-bottom: 1px solid rgba(91, 141, 239, 0.3);
+  transition: border-color 0.15s;
+}
+.msg a:hover {
+  border-bottom-color: var(--hl-function, #5b8def);
+}
+
+/* Tables */
+.msg table {
+  border-collapse: collapse;
+  margin: 0.5em 0;
+  font-size: 0.9em;
+  width: auto;
+}
+.msg th {
+  background: color-mix(in srgb, var(--fg) 7%, transparent);
+  color: var(--hl-keyword, #c678dd);
+  font-weight: 600;
+  padding: 6px 12px;
+  border: 1px solid var(--border);
+  text-align: left;
+}
+.msg td {
+  padding: 5px 12px;
+  border: 1px solid var(--border);
+}
+
+/* Agent UI Styling */
+.agent-controls {
+  background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
+  border: 1px solid #dee2e6;
+  border-radius: 8px;
+  padding: 12px;
+  margin-bottom: 12px;
+  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.agent-toggle label {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  cursor: pointer;
+  font-weight: 500;
+  margin-bottom: 8px;
+}
+
+#workflow-selector {
+  margin-top: 8px;
+}
+
+#workflow-type {
+  width: 100%;
+  padding: 6px 12px;
+  border: 1px solid #ced4da;
+  border-radius: 4px;
+  background: white;
+  font-size: 14px;
+}
+
+.agent-progress {
+  background: #ffebee;
+  border: 1px solid #ef9a9a;
+  border-radius: 6px;
+  padding: 12px;
+  margin: 8px 0;
+  text-align: center;
+}
+
+.agent-working {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  font-style: italic;
+  color: var(--red);
+}
+
+.loading-dots::after {
+  content: '...';
+  animation: dots 1.5s infinite;
+}
+
+@keyframes dots {
+  0%, 20% { opacity: 0; }
+  40% { opacity: 0.5; }
+  60%, 100% { opacity: 1; }
+}
+
+.workflow-info {
+  background: #f8f9fa;
+  border: 1px solid var(--red);
+  border-radius: 6px;
+  padding: 8px 12px;
+  margin: 4px 0;
+  font-size: 0.9em;
+  color: var(--red);
+  text-align: center;
+}
+
+/* Scrollbar uses --red from :root (set at top of file) */
+
+/* Loading spinner */
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+.spinner {
+  width: 24px;
+  height: 24px;
+  margin: 8px auto;
+  border: 3px solid var(--border);
+  border-top-color: var(--red);
+  border-radius: 50%;
+  animation: spin 0.9s linear infinite;
+}
+
+/* Inline spinner for buttons */
+.btn-spinner {
+  display: inline-block;
+  width: 12px;
+  height: 12px;
+  border: 2px solid transparent;
+  border-top-color: currentColor;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+  margin-right: 6px;
+}
+
+/* Loading indicator for messages */
+.loading-indicator {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 10px;
+}
+.loading-dots {
+  display: flex;
+  gap: 4px;
+}
+.loading-dot {
+  width: 6px;
+  height: 6px;
+  background-color: var(--fg);
+  border-radius: 50%;
+  opacity: 0.6;
+}
+.loading-dot:nth-child(1) {
+  animation: loading-bounce 1.4s infinite ease-in-out both;
+}
+.loading-dot:nth-child(2) {
+  animation: loading-bounce 1.4s infinite ease-in-out both;
+  animation-delay: -0.32s;
+}
+.loading-dot:nth-child(3) {
+  animation: loading-bounce 1.4s infinite ease-in-out both;
+  animation-delay: -0.64s;
+}
+@keyframes loading-bounce {
+  0%, 80%, 100% {
+    transform: scale(0);
+  }
+  40% {
+    transform: scale(1);
+  }
+}
+
+/* Hamburger menu button */
+.hamburger {
+  display: inline-flex;
+  flex-direction: column;
+  justify-content: space-between;
+  width: 24px;
+  height: 18px;
+  background: none;
+  border: none;
+  padding: 0;
+  cursor: pointer;
+}
+.hamburger span {
+  display: block;
+  width: 100%;
+  height: 3px;
+  background: var(--fg);
+  border-radius: 2px;
+}
+
+/* Agent indicator */
+#agent-indicator {
+  position: fixed;
+  top: 20px;
+  right: 20px;
+  background: var(--bg);
+  color: var(--fg);
+  border: 1px solid var(--border);
+  padding: 6px 12px;
+  border-radius: 6px;
+  font-size: 12px;
+  display: none;
+  z-index: 100;
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+#agent-indicator.active {
+  display: block;
+  border-color: var(--color-agent-active);
+  box-shadow: 0 0 10px rgba(0, 255, 0, 0.3);
+}
+#agent-indicator:hover {
+  border-color: var(--color-agent-active);
+  background: var(--panel);
+}
+
+/* Preset buttons */
+.preset-btn {
+  height: 27.2px;
+  padding: 0 8.5px;
+  margin-left: 4px;
+  border: 1px solid var(--red);
+  border-radius: 4px;
+  background: var(--bg);
+  color: var(--fg);
+  font-family: inherit;
+  font-size: 10.2px;
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.preset-btn:hover {
+  background: var(--panel);
+  border-color: var(--fg);
+}
+
+.preset-btn.active {
+  background: var(--panel);
+  border-color: var(--red);
+  box-shadow: 0 0 0 1px var(--red), 0 0 8px color-mix(in srgb, var(--red) 30%, transparent);
+  font-weight: 600;
+}
+
+/* Custom preset modal — inherits from .modal base class */
+
+/* Unified chat input area */
+.chat-input-area {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  background: var(--panel);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  padding: 12px;
+  margin-top: 12px;
+  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.chat-controls-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 8px;
+}
+
+.chat-controls-left {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.chat-controls-right {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.control-group {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.control-label {
+  font-size: 11px;
+  color: var(--fg);
+  opacity: 0.8;
+}
+
+.toggle-switch {
+  position: relative;
+  display: inline-block;
+  width: 30px;
+  height: 16px;
+}
+
+.toggle-switch input {
+  opacity: 0;
+  width: 0;
+  height: 0;
+}
+
+.toggle-slider {
+  position: absolute;
+  cursor: pointer;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: color-mix(in srgb, var(--fg) 15%, transparent);
+  border-radius: 8px;
+  transition: background 0.08s;
+}
+
+.toggle-slider:before {
+  position: absolute;
+  content: "";
+  height: 12px;
+  width: 12px;
+  left: 2px;
+  top: 2px;
+  background-color: var(--panel);
+  border-radius: 50%;
+  transition: transform 0.08s;
+  box-shadow: 0 1px 2px rgba(0,0,0,0.25);
+}
+
+.toggle-switch input:checked + .toggle-slider {
+  background-color: var(--toggle-active, var(--red));
+}
+
+.toggle-switch input:checked + .toggle-slider:before {
+  transform: translateX(14px);
+}
+
+.preset-buttons-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+
+.chat-input-form {
+  display: flex;
+  gap: 8px;
+  align-items: flex-end;
+}
+
+#message {
+  flex: 1;
+  min-height: 34px;
+  max-height: 120px;
+  resize: none;
+  font-size: 13px !important;
+  overflow-y: auto !important;
+  line-height: 1.4 !important;
+  font-family: inherit !important;
+}
+
+.action-button {
+  width: 34px;
+  height: 34px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  margin: 0;
+  background: none;
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  cursor: pointer;
+  color: var(--fg);
+  transition: all 0.2s ease;
+}
+
+.action-button:hover {
+  background: color-mix(in srgb, var(--fg) 6%, transparent);
+  border-color: var(--fg);
+}
+
+.action-button:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.action-button.recording {
+  background: var(--color-recording);
+  border-color: var(--color-recording);
+  animation: pulse 1.5s infinite;
+}
+
+@keyframes pulse {
+  0% { opacity: 1; }
+  50% { opacity: 0.7; }
+  100% { opacity: 1; }
+}
+
+#stop-icon {
+  display: none;
+  width: 14px;
+  height: 14px;
+  background: var(--color-recording);
+  border-radius: 2px;
+}
+
+/* Attachment strip — centered + max-width to match the chat-input-bar below,
+   otherwise the chip floats flush-left while the input is centered (visible on
+   desktop where the chat area is wider than 800px). */
+.attach-strip {
+  display: flex;
+  gap: 6px;
+  flex-wrap: wrap;
+  padding: 2px 8px;
+  max-width: 800px;
+  width: 100%;
+  margin-left: auto;
+  margin-right: auto;
+  box-sizing: border-box;
+}
+.attach-strip:empty { display: none; }
+
+/* Upload-in-progress feedback: the message bubble shows immediately, so while
+   the files are still uploading we put a whirlpool ON each attachment chip and
+   dim the chip's content — making it obvious that file is being sent, not stuck. */
+.attach-strip.attach-uploading .thumb {
+  position: relative;
+  pointer-events: none;
+}
+.attach-strip.attach-uploading .thumb > :not(.thumb-upload-spinner) {
+  opacity: 0.4;
+}
+.thumb-upload-spinner {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  pointer-events: none;
+  z-index: 3;
+}
+
+.thumb {
+  border: 1px solid var(--border);
+  background: color-mix(in srgb, var(--fg) 11%, transparent);
+  padding: 3px 6px;
+  font-size: 12px;
+  display: flex;
+  gap: 6px;
+  align-items: center;
+  border-radius: 4px;
+  transition: all 0.2s ease;
+  max-width: 180px;
+}
+.thumb-img {
+  max-width: 60px;
+  max-height: 40px;
+  border-radius: 3px;
+  object-fit: cover;
+}
+.attach-image-preview {
+  margin: 4px 0;
+}
+.attach-image-preview img {
+  box-shadow: 0 1px 4px rgba(0,0,0,0.2);
+  /* Same border as the chat bubbles. */
+  border: 1px solid var(--bubble-border, var(--border));
+}
+/* Image chips: image fills the chip, X overlays as a corner accent badge.
+   Same on desktop and mobile — doc/text chips keep the beside-X layout. */
+.thumb.thumb-image {
+  position: relative;
+  padding: 0;
+}
+.thumb.thumb-image .thumb-img {
+  max-height: 56px;
+  display: block;
+}
+.thumb.thumb-image button {
+  position: absolute;
+  /* Sit on the top-right corner edge as an accent badge. */
+  top: -7px;
+  right: -7px;
+  width: 24px;
+  height: 24px;
+  min-width: 0;
+  padding: 0;
+  border: 2px solid var(--bg);
+  border-radius: 50%;
+  background: var(--accent-primary, var(--red));
+  color: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 15px;
+  line-height: 1;
+  z-index: 3;
+  transition: transform 0.12s ease, filter 0.12s ease, box-shadow 0.12s ease;
+}
+.thumb.thumb-image button:hover {
+  transform: scale(1.12);
+  filter: brightness(1.12);
+  box-shadow: 0 2px 8px rgba(0,0,0,0.25);
+}
+.thumb.thumb-image button:active {
+  transform: scale(0.96);
+}
+@media (max-width: 768px) {
+  /* Collapsed "N files" badge: use the same corner-X accent badge as image thumbs. */
+  .thumb-collapsed { position: relative; }
+  .thumb-collapsed .thumb-collapsed-x {
+    position: absolute;
+    top: -7px;
+    right: -7px;
+    width: 24px;
+    height: 24px;
+    min-width: 0;
+    padding: 0;
+    border: 2px solid var(--bg);
+    border-radius: 50%;
+    background: var(--accent-primary, var(--red));
+    color: #fff;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 15px;
+    line-height: 1;
+    z-index: 3;
+    opacity: 1;
+  }
+  /* Bigger remove-X tap target for non-image (doc/text) chips on mobile too. */
+  .thumb button {
+    height: 28px;
+    min-width: 28px;
+    font-size: 15px;
+  }
+}
+.thumb span {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.thumb:hover {
+  background: color-mix(in srgb, var(--fg) 16%, transparent);
+  border-color: var(--fg);
+}
+
+.thumb button {
+  height: 24px;
+  padding: 0 7px;
+  font-size: 13px;
+  border-radius: 4px;
+  color: var(--accent-primary, var(--red));
+}
+
+.thumb-collapsed {
+  cursor: pointer;
+  color: var(--red);
+  border-color: var(--red);
+  background: color-mix(in srgb, var(--red) 10%, transparent);
+  font-weight: 600;
+  gap: 8px;
+  border-radius: 999px;   /* pill — rounder than the square file chips */
+  padding-left: 12px;
+}
+.thumb-collapsed:hover {
+  background: color-mix(in srgb, var(--red) 20%, transparent);
+}
+.thumb-collapsed-label { white-space: nowrap; }
+.thumb-collapsed-x {
+  height: 24px; padding: 0 7px; font-size: 13px; border-radius: 4px;
+  color: var(--accent-primary, var(--red));
+  background: none; border: none; cursor: pointer; opacity: 0.6;
+}
+.thumb-collapsed-x:hover { opacity: 1; }
+
+/* Recording indicator */
+#recording-indicator {
+  position: fixed;
+  top: 10px;
+  left: 0;
+  right: 0;
+  background: rgba(0, 0, 0, 0.8);
+  backdrop-filter: blur(10px);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  padding: 12px;
+  margin: 10px;
+  z-index: 1000;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+#recording-indicator.hidden {
+  display: none !important;
+}
+
+.recording-content {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  color: white;
+}
+
+.recording-icon {
+  color: var(--color-recording);
+  font-size: 20px;
+  animation: pulse 1.5s infinite;
+}
+
+.recording-text {
+  font-size: 16px;
+  font-weight: 500;
+}
+
+.stop-recording-btn {
+  background: var(--color-recording);
+  color: white;
+  border: none;
+  border-radius: 6px;
+  padding: 6px 12px;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s ease;
+}
+
+.stop-recording-btn:hover {
+  background: var(--color-recording-hover);
+}
+
+#recording-indicator.error {
+  background: rgba(173, 26, 26, 0.9);
+}
+
+.recording-error {
+  color: var(--color-recording);
+  font-size: 14px;
+  margin-top: 4px;
+}
+
+/* Mermaid diagram containers */
+.mermaid-container {
+  margin: 12px 0;
+  padding: 16px;
+  background: color-mix(in srgb, var(--bg) 95%, var(--fg));
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  overflow-x: auto;
+  text-align: center;
+}
+.mermaid-container svg { max-width: 100%; height: auto; }
+
+/* KaTeX math overrides */
+.katex-display { margin: 0.8em 0; overflow-x: auto; overflow-y: hidden; }
+.katex { font-size: 1.1em; }
+
+/* Hide thinking sections globally via settings toggle */
+body.hide-thinking .thinking-section { display: none !important; }
+
+/* Thinking process styles — colors follow theme accent */
+.msg .body .stream-content {
+  width: 100%;
+}
+.thinking-section {
+  margin: 12px 0;
+  width: 100%;
+  max-width: 100%;
+  box-sizing: border-box;
+  border: 1px solid color-mix(in srgb, var(--red) 30%, transparent);
+  border-radius: 8px;
+  background: color-mix(in srgb, var(--red) 5%, transparent);
+  overflow: hidden;
+  transition: all 0.3s ease;
+}
+
+.thinking-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 6px 12px;
+  cursor: pointer;
+  user-select: none;
+  background: color-mix(in srgb, var(--red) 8%, transparent);
+  border-bottom: 1px solid color-mix(in srgb, var(--red) 20%, transparent);
+  transition: background 0.2s ease;
+}
+
+.thinking-header:hover {
+  background: color-mix(in srgb, var(--red) 12%, transparent);
+}
+
+.thinking-header-left {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 0.9em;
+  color: var(--red);
+  font-weight: 500;
+  overflow: hidden;
+  min-width: 0;
+}
+.thinking-header-left span {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  transition: opacity 0.2s ease;
+}
+
+.thinking-icon {
+  font-size: 1.1em;
+}
+
+.thinking-toggle {
+  font-size: 0.9em;
+  color: var(--red);
+  transition: transform 0.3s ease;
+}
+.thinking-toggle::after {
+  content: '\25BC'; /* ▼ */
+}
+
+.thinking-toggle.expanded {
+  transform: rotate(180deg);
+}
+
+.thinking-content {
+  max-height: 0;
+  overflow: hidden;
+  transition: max-height 0.3s ease, padding 0.3s ease;
+  padding: 0 12px;
+}
+
+.thinking-content.expanded {
+  max-height: 300px;
+  overflow-y: auto;
+  padding: 12px;
+}
+
+.thinking-content-inner {
+  font-size: 0.85em;
+  color: var(--fg);
+  opacity: 0.9;
+  line-height: 1.5;
+}
+.live-reply-content {
+  animation: fadeSlideIn 0.3s ease-out;
+}
+@keyframes fadeSlideIn {
+  from { opacity: 0; transform: translateY(4px); }
+  to { opacity: 1; transform: translateY(0); }
+}
+
+/* Thinking indicator animation */
+.thinking-indicator {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  color: var(--red);
+  font-style: italic;
+  padding: 8px 0;
+}
+
+.thinking-dots::after {
+  content: '...';
+  animation: thinking-dots 1.5s infinite;
+  display: inline-block;
+  width: 20px;
+  text-align: left;
+}
+
+@keyframes thinking-dots {
+  0%, 20% { content: '.'; }
+  40% { content: '..'; }
+  60%, 100% { content: '...'; }
+}
+
+.thinking-complete {
+  color: var(--red);
+  font-size: 0.9em;
+  padding: 4px 0;
+  opacity: 0.8;
+}
+
+/* ── Sources section — collapsible source citations ── */
+.sources-section {
+  margin: 8px 0 12px;
+  border: 1px solid color-mix(in srgb, var(--red) 30%, transparent);
+  border-radius: 8px;
+  overflow: hidden;
+  transition: all 0.3s ease;
+}
+.sources-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 12px;
+  cursor: pointer;
+  background: color-mix(in srgb, var(--red) 8%, transparent);
+  border-bottom: 1px solid color-mix(in srgb, var(--red) 20%, transparent);
+  transition: background 0.2s ease;
+  user-select: none;
+}
+.sources-header:hover {
+  background: color-mix(in srgb, var(--red) 12%, transparent);
+}
+.sources-header-left {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 0.85em;
+  font-weight: 500;
+  color: var(--red);
+}
+.sources-header-left svg {
+  width: 14px;
+  height: 14px;
+  flex-shrink: 0;
+  opacity: 0.7;
+}
+.sources-toggle {
+  font-size: 0.8em;
+  color: var(--red);
+  opacity: 0.7;
+  transition: none;
+}
+.sources-toggle::after {
+  content: '\25B6'; /* ▶ right arrow */
+}
+.sources-toggle[data-arrow="down"]::after {
+  content: '\25BC'; /* ▼ down arrow */
+}
+.sources-content {
+  max-height: 0;
+  overflow: hidden;
+  transition: max-height 0.3s ease, padding 0.3s ease;
+  padding: 0 10px;
+}
+.sources-content.expanded {
+  max-height: 3000px;
+  padding: 8px 10px;
+}
+.sources-content-inner {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.source-link {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 5px 8px;
+  border-radius: 6px;
+  background: color-mix(in srgb, var(--fg) 4%, transparent);
+  text-decoration: none;
+  color: var(--fg);
+  transition: background 0.15s ease;
+}
+.source-link:hover {
+  background: color-mix(in srgb, var(--fg) 10%, transparent);
+}
+.source-num {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 20px;
+  height: 20px;
+  border-radius: 50%;
+  background: color-mix(in srgb, var(--fg) 15%, transparent);
+  color: var(--fg);
+  font-size: 0.7em;
+  font-weight: 600;
+  flex-shrink: 0;
+}
+.source-title {
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-size: 0.82em;
+}
+.source-domain {
+  font-size: 0.72em;
+  opacity: 0.45;
+  flex-shrink: 0;
+}
+
+/* ── Processing pulse animation (reused by session-star) ── */
+@keyframes research-pulse {
+  0%, 100% { opacity: 0.3; transform: scale(0.8); }
+  50% { opacity: 1; transform: scale(1.2); }
+}
+.ai-spinner {
+  color: var(--red);
+}
+/* Nudge the Tidy button 2px left. */
+#memory-tidy-btn { position: relative; left: -2px; }
+/* Tidy button's whirlpool nudge — sits 1px lower so it visually centers on
+   the Tidy label baseline. */
+#memory-tidy-btn .ai-spinner-whirlpool,
+#memory-tidy-btn .spinner-whirlpool {
+  position: relative;
+  top: 1px;
+}
+.list-item.stream-complete {
+  animation: stream-complete-pulse 2s ease-in-out infinite;
+}
+.cookbook-notif-active svg { opacity: 1 !important; }
+
+/* Rail notification dot — pulsing indicator on icon-rail buttons */
+.icon-rail-btn.rail-notify {
+  opacity: 1 !important;
+  position: relative;
+}
+.icon-rail-btn.rail-notify::before {
+  content: '';
+  position: absolute;
+  top: 4px;
+  right: 4px;
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: var(--accent, var(--red));
+  animation: rail-notif-pulse 2s ease-in-out infinite;
+  z-index: 1;
+}
+.icon-rail-btn.rail-notify.rail-notify-success::before {
+  background: var(--color-success, #4caf50);
+}
+@keyframes rail-notif-pulse {
+  0%, 100% { opacity: 1; transform: scale(1); }
+  50% { opacity: 0.4; transform: scale(0.8); }
+}
+@keyframes stream-complete-pulse {
+  0%, 100% { box-shadow: none; }
+  50% { box-shadow: inset 0 0 0 1.5px var(--accent); }
+}
+
+/* ===== SLASH COMMAND RESPONSES ===== */
+.msg.msg-system {
+  padding: 6px 12px; margin: 4px auto 4px 8px;
+  max-width: 85%; background: none; border-left: 2px solid var(--border);
+}
+.msg.msg-system .body { padding: 0; }
+.msg.msg-system pre { margin: 4px 0; white-space: pre-wrap; font-size: 0.85em; }
+.msg.stream-done-toast {
+  cursor: pointer;
+  border-left-color: var(--accent, var(--border));
+  border-radius: 6px;
+  background: color-mix(in srgb, var(--accent) 6%, transparent);
+  transition: background 0.15s;
+}
+.msg.stream-done-toast:hover {
+  background: color-mix(in srgb, var(--accent) 12%, transparent);
+}
+.msg.stream-done-toast .body {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.stream-done-indicator {
+  font-family: monospace;
+  font-size: 1.1em;
+  line-height: 1;
+  color: var(--accent);
+  flex-shrink: 0;
+  animation: bar-pulse 1.2s ease-in-out infinite;
+}
+@keyframes bar-pulse {
+  0%, 100% { opacity: 0.4; }
+  50% { opacity: 1; }
+}
+
+/* ===== AGENT MULTI-BUBBLE ===== */
+.msg.msg-tool {
+  display: none; /* legacy — hidden, replaced by agent-thread */
+}
+.msg.msg-continuation {
+  margin-top: 2px;
+}
+
+/* ===== AGENT THREAD TIMELINE ===== */
+.agent-thread {
+  position: relative;
+  margin: 2px 0 2px 28px;
+  padding: 4px 0 4px 22px;
+  max-width: calc(85% - 20px);
+  box-sizing: border-box;
+}
+.agent-thread::before {
+  content: '';
+  position: absolute;
+  left: 5px;
+  top: 14px;
+  bottom: 14px;
+  width: 2px;
+  background: color-mix(in srgb, var(--red) 18%, transparent);
+  border-radius: 1px;
+}
+/* Extend line to connect to chat bubble above/below */
+.agent-thread.has-top::before {
+  top: -6.5px;
+}
+.agent-thread.has-bottom::before {
+  bottom: -5px;
+}
+/* Terminating dot at bottom when no bubble below and last node is expanded */
+.agent-thread:not(.has-bottom) .agent-thread-node.open:last-child::after {
+  content: '';
+  position: absolute;
+  /* -17px (was -15) nudges the terminating dot 2px further left so it
+     sits flush with the thread's left rail when the search node is
+     expanded. This is the "big glow dot at the bottom" the user sees
+     after a web_search step. */
+  left: -17px;
+  bottom: 5px;
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: var(--red);
+  box-shadow: 0 0 4px 1px color-mix(in srgb, var(--red) 55%, transparent),
+              0 0 0 3px color-mix(in srgb, var(--red) 18%, transparent);
+}
+
+/* Synapse pulse — a bright dot traveling down the line */
+.agent-thread::after {
+  content: '';
+  position: absolute;
+  left: 4px;
+  width: 4px;
+  height: 4px;
+  border-radius: 50%;
+  background: var(--red);
+  box-shadow: 0 0 3px 1px color-mix(in srgb, var(--red) 50%, transparent);
+  pointer-events: none;
+  top: 0%;
+  opacity: 0;
+}
+.agent-thread.streaming::after {
+  animation: synapse-capped-short 0.8s ease-in-out infinite;
+}
+.agent-thread.streaming.has-top::after {
+  animation: synapse-capped 0.8s ease-in-out infinite;
+}
+.agent-thread.streaming.has-bottom::after {
+  animation: synapse-travel-short 0.8s ease-in-out infinite;
+}
+.agent-thread.streaming.has-top.has-bottom::after {
+  animation: synapse-travel 0.8s ease-in-out infinite;
+}
+@keyframes synapse-travel {
+  0% { top: 0%; opacity: 0; }
+  5% { opacity: 0.5; }
+  85% { opacity: 0.35; }
+  100% { top: 100%; opacity: 0; }
+}
+@keyframes synapse-capped {
+  0% { top: 0%; opacity: 0; }
+  5% { opacity: 0.5; }
+  70% { opacity: 0.35; top: calc(100% - 20px); }
+  100% { opacity: 0; top: calc(100% - 20px); }
+}
+@keyframes synapse-travel-short {
+  0% { top: 14px; opacity: 0; }
+  5% { opacity: 0.5; }
+  85% { opacity: 0.35; }
+  100% { top: 100%; opacity: 0; }
+}
+@keyframes synapse-capped-short {
+  0% { top: 14px; opacity: 0; }
+  5% { opacity: 0.5; }
+  70% { opacity: 0.35; top: calc(100% - 20px); }
+  100% { opacity: 0; top: calc(100% - 20px); }
+}
+.agent-thread-node {
+  position: relative;
+  padding: 5px 0;
+}
+.agent-thread-node + .agent-thread-node {
+  margin-top: 2px;
+}
+.agent-thread-dot {
+  position: absolute;
+  left: -20px;
+  top: 10px;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: var(--red);
+  border: 2px solid var(--bg);
+  z-index: 1;
+}
+.agent-thread-node.running .agent-thread-dot {
+  background: var(--red);
+  box-shadow: 0 0 0 3px color-mix(in srgb, var(--red) 25%, transparent);
+  animation: thread-pulse 1.5s ease-in-out infinite;
+  top: 10px;
+}
+@keyframes thread-pulse {
+  0%, 100% { box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 20%, transparent); }
+  50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--red) 10%, transparent); }
+}
+.agent-thread-node.error .agent-thread-dot {
+  background: var(--color-error);
+}
+.agent-thread-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  cursor: pointer;
+  font-size: 0.85em;
+  color: color-mix(in srgb, var(--fg) 70%, transparent);
+  user-select: none;
+  padding: 2px 0;
+}
+.agent-thread-header:hover {
+  color: var(--fg);
+}
+.agent-thread-icon {
+  font-size: 0.9em;
+  color: var(--red);
+}
+.agent-thread-node.error .agent-thread-icon {
+  color: var(--color-error);
+}
+.agent-thread-tool {
+  font-weight: 600;
+  color: var(--red);
+  text-transform: uppercase;
+  letter-spacing: 0.3px;
+  font-size: 0.9em;
+}
+.agent-thread-status {
+  font-size: 0.85em;
+  opacity: 0.5;
+}
+.agent-thread-chevron {
+  font-size: 0.7em;
+  transition: transform 0.2s ease;
+  opacity: 0.4;
+}
+.agent-thread-node.open .agent-thread-chevron {
+  transform: rotate(90deg);
+}
+.agent-thread-wave {
+  font-family: monospace;
+  font-size: 0.85em;
+  color: var(--red);
+  letter-spacing: -1px;
+}
+/* Live "cooking" timer on a running tool — prominent (accent, tabular) so a
+   long-running command always reads as alive, not frozen. */
+.agent-thread-elapsed {
+  margin: 0 6px 0 4px;
+  font-size: 11px;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+  color: var(--accent, var(--red));
+}
+/* Stall watchdog banner — shown when the stream has been silent for ~1min. */
+.stall-banner {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin: 8px auto;
+  padding: 8px 12px;
+  max-width: 90%;
+  font-size: 12px;
+  border-radius: 8px;
+  background: color-mix(in srgb, var(--color-warning, #f0ad4e) 12%, var(--bg));
+  border: 1px solid color-mix(in srgb, var(--color-warning, #f0ad4e) 40%, transparent);
+}
+.stall-banner-txt { flex: 1; opacity: 0.85; }
+.stall-banner-btn {
+  font-size: 11px;
+  font-weight: 600;
+  padding: 4px 10px;
+  border-radius: 6px;
+  border: none;
+  background: var(--accent, var(--red));
+  color: #fff;
+  cursor: pointer;
+  flex-shrink: 0;
+}
+.stall-banner-stop {
+  background: none;
+  color: var(--fg);
+  border: 1px solid var(--border);
+}
+.agent-thread-content {
+  display: none;
+  padding: 4px 0 2px 0;
+  overflow-x: auto;
+  overflow-y: hidden;
+}
+.agent-thread-node.open .agent-thread-content {
+  display: block;
+}
+.agent-thread-cmd {
+  background: color-mix(in srgb, var(--fg) 5%, transparent);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  padding: 8px 12px;
+  margin: 4px 0;
+  color: var(--fg);
+  font-size: 0.85em;
+  white-space: pre-wrap;
+  word-break: break-word;
+  line-height: 1.4;
+  overflow-x: auto;
+}
+
+/* Mobile: constrain thread content */
+@media (max-width: 768px) {
+  .agent-thread {
+    margin-left: 16px;
+    padding-left: 18px;
+    max-width: calc(90% - 16px);
+  }
+  .agent-thread-cmd {
+    font-size: 0.78em;
+    padding: 6px 8px;
+    max-width: 100%;
+    overflow-x: auto;
+  }
+  .agent-thread-content {
+    max-width: calc(100vw - 90px);
+  }
+  .agent-tool-output pre {
+    font-size: 0.8em;
+    max-width: 100%;
+    overflow-x: auto;
+  }
+  .agent-thread-header {
+    font-size: 0.8em;
+  }
+  .agent-thread-dot {
+    left: -16px;
+    top: 10px;
+  }
+}
+
+/* ===== AGENT TOOL OUTPUT (inside thread nodes) ===== */
+.agent-tool-output {
+  margin-top: 8px;
+  background: color-mix(in srgb, var(--red) 5%, transparent);
+  border: 1px solid color-mix(in srgb, var(--red) 20%, transparent);
+  border-radius: 8px;
+  overflow: hidden;
+}
+.agent-tool-output summary {
+  color: var(--red);
+  background: color-mix(in srgb, var(--red) 10%, transparent);
+  border-bottom: 1px solid color-mix(in srgb, var(--red) 15%, transparent);
+  cursor: pointer;
+  font-size: 0.85em;
+  user-select: none;
+  padding: 6px 10px;
+  font-weight: 500;
+  /* Chevron on the right (like the thinking section) instead of the default
+     left-side disclosure triangle. */
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  list-style: none;
+}
+.agent-tool-output summary::-webkit-details-marker { display: none; }
+/* Suppress the global `summary::before { content: '▶' }` left arrow — this
+   section uses a right-side chevron instead. */
+.agent-tool-output summary::before { content: none; }
+.agent-tool-output summary::after {
+  content: '\25BC'; /* ▼ */
+  color: var(--red);
+  font-size: 0.9em;
+  transition: transform 0.3s ease;
+}
+.agent-tool-output[open] > summary::after {
+  transform: rotate(180deg);
+}
+.agent-tool-output summary:hover {
+  background: color-mix(in srgb, var(--red) 15%, transparent);
+}
+.agent-thinking-dots .ai-spinner {
+  font-size: 12px;
+  letter-spacing: 0.5px;
+}
+
+.agent-tool-output[open] {
+  background: color-mix(in srgb, var(--red) 6%, transparent);
+}
+.agent-tool-output[open] > :not(summary) {
+  animation: detail-reveal 0.25s ease-out both;
+}
+@keyframes detail-reveal {
+  from { opacity: 0; transform: translateY(-4px); }
+  to   { opacity: 1; transform: translateY(0); }
+}
+.agent-tool-output pre {
+  background: transparent;
+  border: none;
+  border-radius: 0;
+  padding: 10px 14px;
+  margin-top: 0;
+  max-height: 300px;
+  overflow-y: auto;
+  font-size: 0.95em;
+  color: var(--fg);
+  opacity: 0.85;
+  white-space: pre-wrap;
+  word-break: break-all;
+  line-height: 1.5;
+}
+
+/* Mobile responsive styles */
+@media (max-width: 768px) {
+  #recording-indicator {
+    margin: 8px;
+    padding: 10px;
+  }
+  
+  .recording-text {
+    font-size: 14px;
+  }
+  
+  .stop-recording-btn {
+    padding: 6px 10px;
+    font-size: 12px;
+  }
+}
+/* ===== PANE STYLES (shared by compare) ===== */
+
+.pane-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 6px 0 4px;
+  border-bottom: 1px solid var(--border);
+}
+.close-split-btn,
+.pane-close-btn {
+  background: none;
+  border: none;
+  color: var(--color-error);
+  font-size: 18px;
+  cursor: pointer;
+  padding: 0 4px;
+  line-height: 1;
+  opacity: 0;
+  transition: opacity 0.15s;
+}
+.pane-header:hover .close-split-btn,
+.pane-header:hover .pane-close-btn {
+  opacity: 1;
+}
+.close-split-btn:hover,
+.pane-close-btn:hover {
+  color: var(--color-error-light);
+}
+
+
+/* ============================================ */
+/* RESEARCH DETAILS EXPANDABLE SECTION - UPDATED */
+/* ============================================ */
+
+/* Style the details element */
+details {
+  background: color-mix(in srgb, var(--hl-string) 5%, transparent);
+  border: 1px solid color-mix(in srgb, var(--hl-string) 30%, transparent);
+  border-radius: 8px;
+  margin: 12px 0;
+  padding: 0;
+  overflow: hidden;
+  transition: all 0.3s ease;
+}
+
+details[open] {
+  background: color-mix(in srgb, var(--hl-string) 8%, transparent);
+}
+details[open] > :not(summary) {
+  animation: detail-reveal 0.25s ease-out both;
+}
+
+/* Style the summary (clickable header) - NO CURSIVE, NORMAL SIZE */
+summary {
+  cursor: pointer;
+  padding: 10px 14px;
+  background: color-mix(in srgb, var(--hl-string) 10%, transparent);
+  border-bottom: 1px solid color-mix(in srgb, var(--hl-string) 20%, transparent);
+  font-weight: 500;
+  font-size: 0.95em;
+  font-style: normal;
+  font-family: inherit;
+  color: var(--hl-string);
+  user-select: none;
+  list-style: none;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  transition: background 0.2s ease;
+}
+
+summary:hover {
+  background: color-mix(in srgb, var(--hl-string) 15%, transparent);
+}
+
+/* Add custom arrow */
+summary::before {
+  content: '▶';
+  display: inline-block;
+  transition: transform 0.3s ease;
+  font-size: 0.75em;  /* Smaller arrow */
+}
+
+details[open] summary::before {
+  transform: rotate(90deg);
+}
+
+/* Hide default marker in webkit browsers */
+summary::-webkit-details-marker {
+  display: none;
+}
+
+/* Style the content inside details - SMALLER, MORE COMPACT */
+details > div,
+details > p,
+details > ul,
+details > ol {
+  padding: 12px 14px;  /* Less padding */
+  animation: fadeIn 0.3s ease;
+  font-size: 0.9em;  /* Smaller text */
+  line-height: 1.5;
+}
+
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+    transform: translateY(-10px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+/* Style research findings inside details - SMALLER HEADINGS */
+details h3 {
+  margin-top: 12px;
+  margin-bottom: 6px;
+  color: var(--hl-string);
+  font-size: 0.95em;
+  font-weight: 500;
+}
+
+details h4 {
+  margin-top: 10px;
+  margin-bottom: 5px;
+  color: var(--hl-string);
+  font-size: 0.9em;
+  font-weight: 500;
+}
+
+details ul {
+  margin-left: 18px;
+  margin-bottom: 10px;
+}
+
+details li {
+  margin-bottom: 5px;
+  line-height: 1.4;
+  font-size: 0.85em;  /* Smaller list items */
+}
+
+details strong {
+  color: var(--hl-string);
+  font-weight: 500;
+}
+
+details a {
+  color: var(--accent);
+  text-decoration: none;
+  transition: color 0.2s ease;
+}
+
+details a:hover {
+  color: var(--color-link-hover);
+  text-decoration: underline;
+}
+
+/* Research metadata - SMALLER */
+details .research-meta {
+  font-size: 0.8em;
+  color: var(--fg);
+  opacity: 0.8;
+  margin-top: 4px;
+}
+
+/* Source links - SMALLER */
+details .source-link {
+  display: inline-block;
+  margin-top: 3px;
+  padding: 2px 5px;
+  background: color-mix(in srgb, var(--accent) 10%, transparent);
+  border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
+  border-radius: 4px;
+  font-size: 0.75em;
+  color: var(--accent);
+}
+
+details .source-link:hover {
+  background: color-mix(in srgb, var(--accent) 20%, transparent);
+  border-color: var(--accent);
+}
+
+/* Research report links - clean and subtle */
+details a {
+  color: var(--accent);
+  text-decoration: none;
+  font-weight: normal;  /* Remove bold */
+  font-size: 0.85em;
+  opacity: 0.9;
+  transition: all 0.2s ease;
+  word-break: break-all;  /* Break long URLs nicely */
+}
+
+details a:hover {
+  color: var(--color-link-hover);
+  text-decoration: underline;
+  opacity: 1;
+}
+
+/* ============================================ */
+/* CUSTOM SYNTAX HIGHLIGHTING - Theme-Reactive  */
+/* ============================================ */
+.hljs {
+  background: var(--code-bg, var(--hl-bg, var(--panel)));
+  color: var(--code-fg, var(--hl-fg, var(--fg)));
+  padding: 8px;
+  border-radius: 4px;
+}
+
+/* Keywords & control flow — purple/magenta */
+.hljs-keyword,
+.hljs-selector-tag { color: var(--hl-keyword); }
+
+/* Strings & regex — warm yellow/orange */
+.hljs-string,
+.hljs-regexp,
+.hljs-addition { color: var(--hl-string); }
+
+/* Comments & docs — muted. font-style intentionally omitted: italics shift
+   glyph widths in the highlight overlay relative to the transparent textarea
+   above it, which makes the caret drift away from the visible character. */
+.hljs-comment,
+.hljs-quote,
+.hljs-meta { color: var(--hl-comment); }
+
+/* Functions & method names — blue */
+.hljs-function,
+.hljs-title,
+.hljs-title.function_,
+.hljs-section { color: var(--hl-function); }
+
+/* Numbers & constants — distinct from strings */
+.hljs-number,
+.hljs-literal { color: var(--hl-number, var(--hl-string)); }
+
+/* Built-ins & types — teal/cyan tint */
+.hljs-built_in,
+.hljs-type,
+.hljs-class,
+.hljs-title.class_ { color: var(--hl-builtin, var(--hl-function)); }
+
+/* Variables & identifiers — fg with slight distinction */
+.hljs-variable,
+.hljs-template-variable,
+.hljs-attr { color: var(--hl-variable, var(--hl-fg, var(--fg))); }
+
+/* Operators & punctuation — slightly dimmed fg */
+.hljs-operator,
+.hljs-punctuation { color: var(--hl-fg, var(--fg)); opacity: 0.8; }
+
+/* Parameters */
+.hljs-params { color: var(--hl-params, var(--hl-fg, var(--fg))); }
+
+/* Property access, attributes in HTML/XML */
+.hljs-property,
+.hljs-selector-class,
+.hljs-selector-id { color: var(--hl-variable, var(--hl-function)); }
+
+/* Tags (HTML) */
+.hljs-tag { color: var(--hl-keyword); }
+.hljs-name { color: var(--hl-keyword); }
+
+/* Deletion/diff */
+.hljs-deletion { color: var(--red); }
+
+/* Symbol, special */
+.hljs-symbol { color: var(--hl-string); }
+.hljs-link { color: var(--hl-function); text-decoration: underline; }
+
+/* Emphasis — applied globally (chat messages, rendered markdown in docs
+   preview, etc.) but explicitly NEUTRALIZED in the doc editor's overlay so
+   the textarea-and-overlay caret alignment stays bit-perfect. Bold / italic
+   shift glyph widths and that drifts the click-to-row mapping. */
+.hljs-emphasis { font-style: italic; }
+.hljs-strong { font-weight: bold; }
+.doc-editor-highlight .hljs-emphasis,
+.doc-editor-highlight .hljs-strong {
+  font-style: normal !important;
+  font-weight: inherit !important;
+}
+
+/* ===== Markdown highlighting — document editor =====
+   IMPORTANT: this is an overlay layered behind a transparent textarea, so
+   every styled token must occupy the EXACT same width as the corresponding
+   characters in the textarea. Anything that changes glyph metrics
+   (font-weight/style, padding, border, letter-spacing) makes the caret drift
+   away from the rendered glyph underneath. Color / background / text-
+   decoration are safe — they don't change layout width. */
+.doc-editor-highlight .language-markdown .hljs-section {
+  color: var(--hl-keyword, #c678dd);
+}
+.doc-editor-highlight .language-markdown .hljs-strong {
+  color: var(--hl-number, #d19a66);
+}
+.doc-editor-highlight .language-markdown .hljs-emphasis {
+  color: var(--hl-string, #e5c07b);
+}
+.doc-editor-highlight .language-markdown .hljs-bullet {
+  color: var(--hl-builtin, #56b6c2);
+}
+.doc-editor-highlight .language-markdown .hljs-code {
+  color: var(--hl-builtin, #56b6c2);
+  background: color-mix(in srgb, var(--hl-builtin, #56b6c2) 10%, transparent);
+  border-radius: 2px;
+}
+.doc-editor-highlight .language-markdown .hljs-link {
+  color: var(--hl-function, #61afef);
+  text-decoration: underline;
+}
+.doc-editor-highlight .language-markdown .hljs-quote {
+  color: var(--hl-comment, #828997);
+}
+.doc-editor-highlight .language-markdown .hljs-symbol {
+  color: var(--red);
+}
+/* Standalone [bracketed text] — scene directions, annotations */
+.doc-editor-highlight .language-markdown .md-bracket {
+  color: var(--hl-builtin, #56b6c2);
+  opacity: 0.85;
+}
+/* Heading # markers — dimmer than the heading text */
+.doc-editor-highlight .language-markdown .md-heading-marker {
+  color: var(--hl-comment, #828997);
+  font-weight: 400;
+}
+
+/* ===== CUSTOM PRESET MODAL ===== */
+
+.preset-modal-content {
+  width: min(460px, 90vw);
+  border-radius: 12px;
+  overflow: hidden;
+}
+.preset-modal-body {
+  display: flex;
+  flex-direction: column;
+  overflow-x: hidden;
+}
+
+/* Footer Start/Cancel buttons get a leading icon. Done via ::before + a masked
+   SVG (not an inline  child) because the labels are set with .textContent,
+   which would wipe a child element on every tab switch. background:currentColor
+   makes the icon follow the button's text color. */
+#save-custom-preset,
+#cancel-custom-preset {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+#save-custom-preset::before,
+#cancel-custom-preset::before {
+  content: "";
+  width: 13px; height: 13px;
+  flex-shrink: 0;
+  background-color: currentColor;
+  -webkit-mask: var(--_btn-ic) center / contain no-repeat;
+  mask: var(--_btn-ic) center / contain no-repeat;
+}
+#save-custom-preset::before {
+  --_btn-ic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpolygon points='5 3 19 12 5 21 5 3'/%3E%3C/svg%3E");
+}
+#cancel-custom-preset::before {
+  --_btn-ic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'/%3E%3Cline x1='6' y1='6' x2='18' y2='18'/%3E%3C/svg%3E");
+}
+
+.preset-tabs {
+  display: flex;
+  gap: 0;
+  border-bottom: 1px solid var(--border);
+  margin: 0 -10px 12px;
+  padding: 0 10px;
+  margin: 0 -16px;
+  padding: 0 16px;
+  margin-bottom: 12px;
+}
+
+.preset-tab {
+  flex: 1;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  padding: 8px 10px;
+  background: none;
+  border: none;
+  border-bottom: 2px solid transparent;
+  color: var(--color-muted);
+  font-family: inherit;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.15s;
+}
+.preset-tab-icon { flex-shrink: 0; }
+/* On narrow widths the icon + label can crowd 4 tabs — drop the labels to
+   icon-only so the row stays clean. */
+@media (max-width: 460px) {
+  .preset-tab span { display: none; }
+}
+
+.preset-tab:hover {
+  color: var(--fg);
+  background: color-mix(in srgb, var(--fg) 4%, transparent);
+}
+
+.preset-tab.active {
+  color: var(--red);
+  border-bottom-color: var(--red);
+}
+
+.preset-tab-content {
+  overflow: hidden;
+}
+.preset-tab-content.hidden {
+  display: none;
+}
+
+.preset-templates-hint {
+  font-size: 11px;
+  color: var(--color-muted);
+  margin: 0 0 8px;
+}
+
+.prompt-templates-list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  max-height: 300px;
+  overflow-y: auto;
+}
+
+.prompt-template-card {
+  padding: 10px 12px;
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  background: color-mix(in srgb, var(--fg) 3%, transparent);
+  cursor: pointer;
+  transition: all 0.15s;
+}
+
+.prompt-template-card:hover {
+  background: color-mix(in srgb, var(--accent) 8%, transparent);
+  border-color: color-mix(in srgb, var(--accent) 30%, transparent);
+}
+
+.prompt-template-card.selected {
+  border-color: var(--accent);
+  background: color-mix(in srgb, var(--accent) 10%, transparent);
+}
+
+.prompt-template-name {
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--fg);
+  margin-bottom: 4px;
+}
+
+.prompt-template-preview {
+  font-size: 11px;
+  color: var(--color-muted);
+  line-height: 1.4;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.preset-slider-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 12px;
+  margin-bottom: 6px;
+}
+
+.preset-slider-row label {
+  font-size: 13px;
+  color: var(--fg);
+  font-weight: 500;
+  margin: 0;
+}
+
+.preset-slider-value {
+  font-size: 12px;
+  color: var(--fg);
+  font-weight: 600;
+  min-width: 40px;
+  text-align: right;
+}
+
+.preset-range {
+  -webkit-appearance: none;
+  appearance: none;
+  width: 100%;
+  height: 6px;
+  background: var(--border);
+  border-radius: 4px;
+  outline: none;
+  margin-bottom: 4px;
+  box-sizing: border-box;
+  display: block;
+  padding: 0;
+  margin-left: 0;
+  margin-right: 0;
+}
+
+.preset-range::-webkit-slider-thumb {
+  -webkit-appearance: none;
+  appearance: none;
+  width: 18px;
+  height: 18px;
+  border-radius: 50%;
+  background: var(--red, var(--fg));
+  cursor: pointer;
+  border: 2px solid var(--panel);
+  box-shadow: 0 1px 4px rgba(0,0,0,0.2);
+}
+
+.preset-range::-moz-range-thumb {
+  width: 18px;
+  height: 18px;
+  border-radius: 50%;
+  background: var(--red, var(--fg));
+  cursor: pointer;
+  border: 2px solid var(--panel);
+  box-shadow: 0 1px 4px rgba(0,0,0,0.2);
+}
+
+.preset-range::-webkit-slider-runnable-track {
+  height: 6px;
+  border-radius: 4px;
+}
+
+.preset-range::-moz-range-track {
+  height: 6px;
+  background: var(--border);
+  border-radius: 4px;
+}
+
+.preset-temp-hints {
+  display: flex;
+  font-size: 10px;
+  color: var(--color-muted);
+  margin-top: -4px;
+  margin-bottom: 10px;
+  padding: 0 2px;
+  opacity: 0.7;
+}
+.preset-temp-hints span {
+  flex: 1;
+}
+.preset-temp-hints span:nth-child(2) {
+  text-align: center;
+}
+.preset-temp-hints span:last-child {
+  text-align: right;
+}
+
+.preset-clear-btn {
+  padding: 7px 14px;
+  background: none;
+  border: 1px solid var(--border);
+  color: var(--color-muted);
+  border-radius: 6px;
+  cursor: pointer;
+  font-size: 12px;
+  font-weight: 500;
+  transition: all 0.15s;
+}
+
+.preset-clear-btn:hover {
+  color: var(--color-error);
+  border-color: var(--color-error);
+}
+
+.preset-hint-icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 15px;
+  height: 15px;
+  border-radius: 50%;
+  border: 1px solid var(--border);
+  font-size: 10px;
+  font-weight: 600;
+  color: var(--color-muted);
+  cursor: help;
+  vertical-align: middle;
+  margin-left: 4px;
+  transition: all 0.15s;
+}
+.preset-hint-icon:hover {
+  color: var(--fg);
+  border-color: var(--fg);
+}
+
+.preset-section-header {
+  font-size: 11px;
+  font-weight: 600;
+  color: var(--color-muted);
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  margin-bottom: 6px;
+  padding: 0 2px;
+}
+
+.user-template-card {
+  position: relative;
+}
+.user-template-delete {
+  background: none;
+  border: none;
+  color: var(--color-muted);
+  font-size: 13px;
+  cursor: pointer;
+  padding: 0 2px;
+  line-height: 1;
+  opacity: 0;
+  transition: opacity 0.15s, color 0.15s;
+}
+.user-template-card:hover .user-template-delete {
+  opacity: 1;
+}
+.user-template-delete:hover {
+  color: var(--color-error);
+}
+
+.preset-save-template-btn {
+  padding: 7px 14px;
+  border-radius: 6px;
+  font-size: 12px;
+  font-weight: 500;
+  border: 1px solid var(--border);
+  background: none;
+  color: var(--fg);
+  cursor: pointer;
+  transition: all 0.15s;
+}
+.preset-save-template-btn:hover {
+  border-color: var(--accent, var(--red));
+  color: var(--accent, var(--red));
+}
+
+.char-prompt-wrap {
+  position: relative;
+}
+.char-prompt-wrap textarea {
+  padding-bottom: 28px;
+}
+.char-expand-btn {
+  position: absolute;
+  bottom: 14px;
+  right: 6px;
+  background: var(--panel);
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  color: var(--color-muted);
+  font-size: 11px;
+  padding: 2px 8px;
+  cursor: pointer;
+  transition: all 0.15s;
+  margin: 0;
+  z-index: 1;
+}
+.char-expand-btn:hover {
+  color: var(--red);
+  border-color: var(--red);
+}
+.char-expand-btn.expanding {
+  opacity: 0.5;
+  pointer-events: none;
+}
+
+/* Memory scope bar (My Memories / Characters) */
+.memory-scope-bar {
+  display: flex;
+  gap: 0;
+  margin: 0 0 8px 0 !important;
+  padding: 0 !important;
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  overflow: hidden;
+}
+.memory-scope-btn {
+  flex: 1;
+  padding: 8px 12px !important;
+  margin: 0 !important;
+  font-size: 13px !important;
+  font-weight: 600;
+  background: none;
+  border: none;
+  color: var(--fg);
+  opacity: 0.5;
+  cursor: pointer;
+  transition: all 0.15s;
+}
+.memory-scope-btn + .memory-scope-btn {
+  border-left: 1px solid var(--border);
+}
+.memory-scope-btn.active {
+  background: color-mix(in srgb, var(--red) 12%, transparent);
+  color: var(--red);
+  opacity: 1;
+}
+.memory-scope-btn:hover:not(.active) {
+  background: color-mix(in srgb, var(--fg) 5%, transparent);
+}
+.memory-char-list {
+  display: flex;
+  gap: 4px;
+  flex-wrap: wrap;
+  margin-bottom: 8px;
+}
+.memory-char-chip {
+  padding: 4px 10px;
+  font-size: 11px;
+  font-weight: 500;
+  border: 1px solid var(--border);
+  border-radius: 12px;
+  background: none;
+  color: var(--fg);
+  cursor: pointer;
+  transition: all 0.15s;
+  margin: 0;
+}
+.memory-char-chip.active {
+  background: color-mix(in srgb, var(--red) 12%, transparent);
+  border-color: var(--red);
+  color: var(--red);
+}
+.memory-char-chip:hover:not(.active) {
+  background: color-mix(in srgb, var(--fg) 6%, transparent);
+}
+
+/* Disabled state dims the form */
+#char-fields-wrap.disabled {
+  opacity: 0.35;
+  pointer-events: none;
+  filter: grayscale(0.5);
+  transition: opacity 0.2s, filter 0.2s;
+}
+#char-fields-wrap {
+  transition: opacity 0.2s, filter 0.2s;
+}
+
+/* Name combo: input + delete btn */
+.char-name-combo {
+  display: flex;
+  gap: 4px;
+  align-items: center;
+  margin-bottom: 8px;
+}
+.char-name-combo input,
+.char-name-combo select {
+  flex: 1;
+}
+.char-template-select {
+  background: var(--panel);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  color: var(--fg);
+  padding: 6px 8px;
+  font-size: 13px;
+  font-family: inherit;
+  cursor: pointer;
+}
+.char-template-select:focus {
+  outline: none;
+  border-color: var(--red);
+}
+.char-action-btn {
+  background: none;
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  color: var(--color-muted);
+  font-size: 11px;
+  padding: 4px 8px;
+  cursor: pointer;
+  transition: all 0.15s;
+  flex-shrink: 0;
+  margin: 0 !important;
+  white-space: nowrap;
+  /* Uniform width so the trailing button column matches between the select
+     row (+ New) and the name row (Reset) — that keeps the select and the
+     name input the same width, since both fields flex:1 into the leftover. */
+  min-width: 64px;
+  text-align: center;
+}
+.char-action-btn:hover {
+  color: var(--fg);
+  border-color: var(--fg);
+}
+#char-delete-template-btn:hover {
+  color: var(--color-error);
+  border-color: var(--color-error);
+}
+
+/* Character toggle row in preset modal */
+.preset-toggle-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin-top: 12px;
+  font-size: 13px;
+  color: var(--fg);
+}
+.preset-sub-option {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin-top: 6px;
+  padding: 8px 10px;
+  font-size: 12px;
+  color: var(--color-muted);
+  background: var(--panel);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+}
+.preset-mem-choice {
+  flex: 1;
+  padding: 6px 10px;
+  font-size: 12px;
+  font-weight: 500;
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  background: none;
+  color: var(--fg);
+  cursor: pointer;
+  transition: all 0.15s;
+  margin: 0;
+}
+.preset-mem-choice.active {
+  background: color-mix(in srgb, var(--red) 12%, transparent);
+  border-color: var(--red);
+  color: var(--red);
+}
+.preset-mem-choice:hover:not(.active) {
+  background: color-mix(in srgb, var(--fg) 6%, transparent);
+}
+
+
+/* ===== MEMORY MODAL ===== */
+
+.memory-modal-content {
+  width: min(560px, 90vw);
+  max-height: 78vh;
+  font-size: 12px;
+  overflow: hidden;   /* clip both axes; the inner .memory-modal-body owns scrolling.
+                         (overflow-x alone promotes overflow-y to `auto` → a stray vertical scrollbar) */
+  /* Subtle synapse-pulse — a soft radial glow that breathes in/out.
+     Layered over the existing modal bg so it shows through without
+     overpowering content. */
+  position: relative;
+  isolation: isolate;
+}
+.memory-modal-content::before { content: none; }
+@keyframes memory-synapse-pulse {
+  0%, 100% { opacity: 0.35; transform: scale(1); }
+  50%      { opacity: 0.65; transform: scale(1.02); }
+}
+@media (prefers-reduced-motion: reduce) {
+  .memory-modal-content::before { animation: none; opacity: 0.4; }
+}
+.memory-modal-content .modal-header h4 {
+  font-size: 1rem;
+}
+
+.memory-modal-body {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  overflow-y: auto;
+  overflow-x: hidden;        /* Stop synapse-pulse pseudo-elements from triggering a sideways scrollbar */
+  overscroll-behavior: contain;
+  min-height: 0;
+  /* Fill the modal-content's height so the flex chain (tab-panel → admin-card
+     → list → expanded card) is bounded. Without this the chain grows to
+     content, so an expanded skill card pushed its footer off-screen; capping
+     the preview then left it floating too high. Bounding here lets the
+     preview flex to fill and the footer pin to the bottom naturally. */
+  flex: 1 1 auto;
+}
+.memory-tabs {
+  display: flex;
+  gap: 0;
+  border-bottom: 1px solid var(--border);
+  margin: -4px -4px 0;
+  padding: 0 4px;
+  flex-shrink: 0;
+}
+.memory-tab {
+  background: none;
+  border: none;
+  color: var(--fg);
+  opacity: 0.5;
+  font-size: 12px;
+  font-family: inherit;
+  padding: 8px 14px;
+  cursor: pointer;
+  border-bottom: 2px solid transparent;
+  transition: opacity 0.15s, border-color 0.15s, color 0.15s, background 0.15s;
+}
+.memory-tab:hover {
+  opacity: 0.8;
+  background: color-mix(in srgb, var(--fg) 5%, transparent);
+}
+.memory-tab.active {
+  opacity: 1;
+  color: var(--red);
+  border-bottom-color: var(--red);
+}
+.memory-tab-panel {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  overflow-y: auto;
+  /* overflow-y:auto makes the browser compute overflow-x to `auto` too, which
+     produced a stray horizontal scrollbar whenever a child was slightly too
+     wide (long unbroken memory text, or the skills two-column row). Clip X
+     explicitly — inner code blocks keep their own overflow-x:auto. */
+  overflow-x: hidden;
+  flex: 1;
+  min-width: 0;
+  min-height: 0;
+}
+.memory-tab-panel.hidden { display: none; }
+/* Settings cards dim + mute when their toggle is OFF (matches the
+   .memory-toolbar-toggle "off" treatment elsewhere). */
+#memory-modal .memory-tab-panel[data-memory-panel="settings"] .admin-card {
+  transition: opacity 0.15s, border-color 0.15s, background 0.15s;
+}
+#memory-modal .memory-tab-panel[data-memory-panel="settings"] .admin-card:has(.admin-switch input:not(:checked)) {
+  opacity: 0.55;
+  border-color: color-mix(in srgb, var(--fg) 8%, transparent);
+  background: color-mix(in srgb, var(--fg) 2%, transparent);
+}
+/* Skills tab — two-column layout: skills list (left, wider) + Add Skill
+   form (right, narrower). Collapses to a single column on narrow screens. */
+.memory-tab-panel[data-memory-panel="skills"] {
+  flex-direction: row;
+  align-items: stretch;
+  gap: 10px;
+}
+.memory-tab-panel[data-memory-panel="skills"] > .admin-card:first-of-type {
+  flex: 2 1 0;
+  min-width: 0;
+}
+.memory-tab-panel[data-memory-panel="skills"] > .admin-card:nth-of-type(2) {
+  flex: 1 1 0;
+  margin-top: 0 !important;
+  min-width: 220px;
+}
+@media (max-width: 640px) {
+  .memory-tab-panel[data-memory-panel="skills"] { flex-direction: column; }
+  .memory-tab-panel[data-memory-panel="skills"] > .admin-card:nth-of-type(2) {
+    margin-top: 12px !important;
+  }
+}
+.memory-desc {
+  margin: 0;
+  font-size: 11px;
+  line-height: 1.5;
+  color: color-mix(in srgb, var(--fg) 50%, transparent);
+}
+
+.memory-add-row {
+  display: flex;
+  gap: 6px;
+  align-items: center;
+  height: 32px;
+}
+
+.memory-add-input {
+  flex: 1;
+  height: 28px;
+  padding: 0 10px;
+  border-radius: 6px;
+  border: 1px solid var(--border);
+  background: var(--bg);
+  color: var(--fg);
+  font-family: inherit;
+  font-size: 12px;
+  box-sizing: border-box;
+}
+/* Textareas need explicit vertical padding — inputs vertically center text
+   via line-height/height; textareas would otherwise pin text to the top. */
+textarea.memory-add-input {
+  height: auto;
+  padding: 6px 10px;
+  line-height: 1.4;
+}
+.memory-add-input::placeholder {
+  color: color-mix(in srgb, var(--fg) 40%, transparent);
+}
+
+.memory-add-input:focus {
+  outline: none;
+  border-color: var(--red);
+}
+
+.memory-add-btn {
+  width: 28px;
+  height: 28px;
+  border-radius: 6px;
+  border: 1px solid var(--border);
+  background: var(--bg);
+  color: var(--fg);
+  font-size: 16px;
+  box-sizing: border-box;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: all 0.15s;
+  flex-shrink: 0;
+}
+
+.memory-add-btn:hover {
+  background: var(--panel);
+  border-color: var(--red);
+  color: var(--red);
+}
+
+.memory-toolbar {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  padding: 4px 0 8px;
+}
+
+.memory-toolbar-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+
+
+.memory-toolbar-btn {
+  background: none;
+  border: 1px solid var(--border);
+  color: color-mix(in srgb, var(--fg) 60%, transparent);
+  font-size: 11px;
+  height: 24px;
+  padding: 0 8px;
+  border-radius: 6px;
+  cursor: pointer;
+  font-family: inherit;
+  transition: all 0.15s;
+  white-space: nowrap;
+}
+
+
+
+.memory-toolbar-btn:hover {
+  border-color: var(--fg);
+  color: var(--fg);
+}
+
+.memory-toolbar-btn.active {
+  background: color-mix(in srgb, var(--red) 15%, transparent);
+  border-color: color-mix(in srgb, var(--red) 40%, transparent);
+  color: var(--red);
+}
+
+.memory-toolbar-btn.danger {
+  color: var(--color-error);
+  border-color: var(--color-error);
+}
+
+.memory-toolbar-btn.danger:hover {
+  background: color-mix(in srgb, var(--color-error) 10%, transparent);
+}
+
+.memory-toolbar-btn:disabled {
+  opacity: 1;
+  cursor: default;
+}
+.memory-toolbar-btn.spinning {
+  border-color: transparent;
+  background: none;
+}
+.memory-toolbar-toggle {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 11px;
+  height: 24px;
+  color: color-mix(in srgb, var(--fg) 60%, transparent);
+  cursor: pointer;
+  padding: 0 4px;
+  user-select: none;
+  transition: all 0.15s;
+}
+.memory-toolbar-toggle:hover {
+  color: var(--fg);
+}
+.memory-toolbar-toggle .admin-switch {
+  vertical-align: middle;
+}
+.memory-toolbar-toggle:has(input:not(:checked)) {
+  opacity: 0.7;
+}
+.memory-toolbar-toggle:has(input:not(:checked)) > span {
+  text-decoration: line-through;
+  text-decoration-color: color-mix(in srgb, var(--fg) 30%, transparent);
+}
+
+/* Bulk action bar */
+.memory-bulk-bar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 6px 2px;
+  border: 1px solid color-mix(in srgb, var(--red) 30%, transparent);
+  border-radius: 8px;
+  background: color-mix(in srgb, var(--red) 5%, transparent);
+  font-size: 11px;
+}
+
+.memory-bulk-bar.hidden {
+  display: none;
+}
+/* Nudge the bulk-bar action buttons up 2px (and Memory's -2px left) to
+   align with the row baseline. Covers both the Memory bulk bar
+   (Cancel/Delete) and the Skills bulk bar (Cancel/Approve/Delete) — both
+   live inside #memory-modal. */
+#memory-modal .memory-bulk-bar #memory-bulk-cancel,
+#memory-modal .memory-bulk-bar #memory-bulk-delete {
+  position: relative;
+  top: -2px;
+  left: -2px;
+}
+#memory-modal .memory-bulk-bar #skills-bulk-cancel,
+#memory-modal .memory-bulk-bar #skills-bulk-publish,
+#memory-modal .memory-bulk-bar #skills-bulk-audit,
+#memory-modal .memory-bulk-bar #skills-bulk-delete-nonpassing,
+#memory-modal .memory-bulk-bar #skills-bulk-delete {
+  position: relative;
+  top: -2px;
+}
+/* Research bulk bar — right-align the action buttons (keep All + count on
+   the left). Cancel now lives on the Select toggle, so Archive anchors. */
+#doclib-research-bulk #doclib-research-bulk-archive {
+  margin-left: auto;
+}
+#doclib-research-bulk .memory-toolbar-btn {
+  position: relative;
+  top: 3px;
+  right: 16px;
+}
+/* Archive bulk buttons — nudge down 1px to match research. */
+#doclib-arc-bulk .memory-toolbar-btn {
+  position: relative;
+  top: 1px;
+}
+/* Same right-aligned layout for the other bulk bars — Chats, Documents,
+   Archive, Skills, Memories. Cancel's auto-margin pushes the action group
+   to the right; 8px of extra right padding seats it off the edge (matching
+   the research bar's 8px nudge). */
+#doclib-chats-bulk #doclib-chats-bulk-archive,
+#doclib-bulk-bar #doclib-bulk-archive,
+#doclib-arc-bulk #doclib-arc-bulk-restore,
+#email-lib-bulk #email-lib-bulk-actions,
+#tasks-bulk-bar #tasks-bulk-delete,
+#serve-bulk-bar #serve-bulk-delete,
+#gallery-bulk-bar #gallery-bulk-actions,
+#gallery-editor-drafts-bulk #gallery-editor-drafts-bulk-delete,
+#memory-modal .memory-bulk-bar #memory-bulk-delete,
+#memory-modal .memory-bulk-bar #skills-bulk-publish {
+  margin-left: auto;
+  position: relative;
+  top: -1px;
+}
+
+/* X-icon Cancel button used in every bulk-select bar (Esc target). The bare SVG
+   sits slightly too high vs. the adjacent text buttons — nudge it down 2px. */
+[id$="-bulk-cancel"] svg {
+  position: relative;
+  top: 2px;
+}
+#doclib-chats-bulk,
+#doclib-bulk-bar,
+#doclib-arc-bulk,
+#email-lib-bulk,
+#tasks-bulk-bar,
+#serve-bulk-bar,
+#gallery-bulk-bar,
+#gallery-editor-drafts-bulk,
+#memory-modal .memory-bulk-bar {
+  padding-right: 18px;
+}
+/* Drafts bulk bar defaults to justify-content:flex-end (whole row hugs the
+   right). Reset it so All + count sit on the left and only the action button
+   is pushed right — matching every other bulk bar. */
+#gallery-editor-drafts-bulk {
+  justify-content: flex-start;
+}
+/* Nudge the whole memory + skills bulk buttons (icon + label together) up. */
+#memory-modal #memory-bulk-bar .memory-toolbar-btn,
+#memory-modal #skills-bulk-bar .memory-toolbar-btn {
+  position: relative;
+  top: -2px;
+}
+
+.memory-bulk-check-all {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  cursor: pointer;
+  color: color-mix(in srgb, var(--fg) 60%, transparent);
+  font-size: 10px;
+  padding: 4px 8px;
+  border-radius: 4px;
+  user-select: none;
+  position: relative;
+  top: 0;
+}
+.memory-bulk-check-all:hover {
+  background: color-mix(in srgb, var(--fg) 6%, transparent);
+}
+
+#memory-selected-count {
+  color: color-mix(in srgb, var(--fg) 50%, transparent);
+  font-size: 10px;
+  flex: 1;
+}
+
+/* Custom checkbox — toggle dot (shared by select-all and per-item) */
+.memory-select-cb,
+.memory-bulk-check-all input {
+  -webkit-appearance: none;
+  appearance: none;
+  width: 6px !important;
+  height: 6px !important;
+  min-width: 6px;
+  min-height: 6px;
+  max-width: 6px;
+  max-height: 6px;
+  padding: 0;
+  border: 1px solid var(--border);
+  border-radius: 50%;
+  background: transparent;
+  cursor: pointer;
+  flex-shrink: 0;
+  margin: 0;
+  align-self: center;
+  position: relative;
+  box-sizing: content-box;
+  transition: all 0.15s;
+}
+
+.memory-select-cb:hover,
+.memory-bulk-check-all input:hover {
+  border-color: var(--red);
+}
+
+.memory-select-cb:checked,
+.memory-bulk-check-all input:checked {
+  background: var(--red);
+  border-color: var(--red);
+}
+
+.memory-count {
+  font-size: 11px;
+  color: var(--color-muted);
+}
+
+.memory-search-input {
+  height: 24px;
+  margin-top: 6px;
+  padding: 0 8px;
+  border-radius: 6px;
+  border: 1px solid var(--border);
+  background: var(--bg);
+  color: var(--fg);
+  font-family: inherit;
+  font-size: 11px;
+  width: 100%;
+  box-sizing: border-box;
+}
+
+.memory-search-input:focus {
+  outline: none;
+  border-color: var(--red);
+}
+
+.memory-list {
+  flex: 1;
+  min-height: 0;             /* Required so flex:1 inside a flex parent can shrink rather than push its parent past 85vh */
+  overflow-y: auto;
+  overflow-x: hidden;        /* Stop the synapse sweep from triggering a sideways scrollbar */
+  overscroll-behavior: contain;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.memory-item.task-paused {
+  opacity: 0.45 !important;
+  filter: saturate(0.55);
+  background: repeating-linear-gradient(
+    45deg,
+    color-mix(in srgb, var(--fg) 2%, transparent),
+    color-mix(in srgb, var(--fg) 2%, transparent) 8px,
+    color-mix(in srgb, var(--fg) 5%, transparent) 8px,
+    color-mix(in srgb, var(--fg) 5%, transparent) 16px
+  ) !important;
+}
+.memory-item.task-paused:hover {
+  opacity: 0.85 !important;
+  filter: saturate(0.9);
+}
+.task-paused-badge {
+  display: inline-flex;
+  align-items: center;
+  gap: 3px;
+  font-size: 10px;
+  font-weight: 600;
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+  color: var(--orange, #ff9800);
+  background: color-mix(in srgb, var(--orange, #ff9800) 12%, transparent);
+  padding: 2px 6px;
+  border-radius: 10px;
+  flex-shrink: 0;
+}
+
+.task-builtin-badge {
+  font-size: 9px;
+  font-weight: 600;
+  text-transform: uppercase;
+  letter-spacing: 0.4px;
+  color: var(--accent, var(--red));
+  background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);
+  border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 40%, transparent);
+  padding: 1px 6px;
+  border-radius: 8px;
+  flex-shrink: 0;
+  white-space: nowrap;
+}
+.task-builtin-badge.modified {
+  color: var(--orange, #ff9800);
+  border-color: color-mix(in srgb, var(--orange, #ff9800) 40%, transparent);
+  background: color-mix(in srgb, var(--orange, #ff9800) 12%, transparent);
+}
+.memory-item {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+  padding: 8px 10px;
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  background: color-mix(in srgb, var(--fg) 3%, transparent);
+  max-height: 200px;
+  flex-shrink: 0;        /* memory-list is a flex column; without this, items get squeezed to fit */
+  transition: all 0.15s;
+}
+
+.memory-item-title {
+  font-size: 12px;
+  font-weight: 500;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.memory-item:hover {
+  background: color-mix(in srgb, var(--fg) 5%, transparent);
+  border-color: color-mix(in srgb, var(--fg) 16%, transparent);
+}
+/* Synapse pulse — a brief horizontal light sweep runs left → right across
+   each memory like a signal traversing a neural pathway. Per-item stagger
+   via nth-child + varied durations keeps the list shimmering rather than
+   pulsing in sync. */
+#memory-list .memory-item {
+  position: relative;
+  overflow: hidden;
+}
+#memory-list .memory-item::after {
+  /* Sweep highlight rides the border ring only — gradient-fill + mask cutout
+     keeps the bright pulse on the 1px stroke instead of washing the body. */
+  content: '';
+  position: absolute;
+  inset: 0;
+  border-radius: inherit;
+  padding: 1px;
+  pointer-events: none;
+  background: linear-gradient(
+    to right,
+    transparent 0%,
+    transparent calc(var(--sweep, -20%) - 8%),
+    color-mix(in srgb, var(--red) 85%, transparent) var(--sweep, -20%),
+    transparent calc(var(--sweep, -20%) + 8%),
+    transparent 100%
+  );
+  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
+  -webkit-mask-composite: xor;
+          mask-composite: exclude;
+  animation: memory-synapse-sweep 6.2s linear infinite;
+  animation-delay: 0.4s;
+}
+#memory-list .memory-item:nth-child(2n)::after { animation-duration: 7.4s; animation-delay: 1.6s; }
+#memory-list .memory-item:nth-child(3n)::after { animation-duration: 8.8s; animation-delay: 3.2s; }
+#memory-list .memory-item:nth-child(5n)::after { animation-duration: 9.3s; animation-delay: 4.7s; }
+#memory-list .memory-item:nth-child(7n)::after { animation-duration: 5.5s; animation-delay: 2.3s; }
+#memory-list .memory-item:hover::after { animation: none; opacity: 0; }
+@property --sweep {
+  syntax: '';
+  inherits: false;
+  initial-value: -20%;
+}
+@keyframes memory-synapse-sweep {
+  /* Sweep traverses left → right in the first ~12% of the cycle (≈0.7s of
+     a 6.2s loop), then waits offscreen. */
+  0%   { --sweep: -20%; }
+  12%  { --sweep: 120%; }
+  13%, 100% { --sweep: 120%; }
+}
+@media (prefers-reduced-motion: reduce) {
+  #memory-list .memory-item::after { animation: none; opacity: 0; }
+}
+.memory-pinned:hover {
+  background: color-mix(in srgb, var(--red) 4%, transparent);
+  border-color: var(--border);
+  border-left-color: var(--red);
+}
+
+.memory-item-content {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+}
+
+.memory-item-text {
+  font-size: 11px;
+  line-height: 1.5;
+  word-break: break-word;
+  color: var(--fg);
+}
+
+.memory-item-edit-input {
+  flex: 1;
+  padding: 3px 5px;
+  border-radius: 4px;
+  border: 1px solid var(--red);
+  background: var(--bg);
+  color: var(--fg);
+  font-family: inherit;
+  font-size: 11px;
+  min-width: 0;
+}
+
+.memory-item-edit-input:focus {
+  outline: none;
+}
+
+/* Edit row: text input + category select side by side */
+.memory-edit-row {
+  display: flex;
+  gap: 4px;
+  flex: 1;
+  min-width: 0;
+}
+
+.memory-edit-cat-select {
+  background: var(--bg);
+  color: var(--fg);
+  border: 1px solid var(--red);
+  border-radius: 4px;
+  font-family: inherit;
+  font-size: 9px;
+  padding: 2px 3px;
+  cursor: pointer;
+  flex-shrink: 0;
+}
+
+.memory-edit-cat-select:focus {
+  outline: none;
+}
+
+.memory-item-editing {
+  border-color: color-mix(in srgb, var(--red) 40%, transparent);
+  background: color-mix(in srgb, var(--red) 3%, transparent);
+}
+
+.memory-menu-btn {
+  background: none;
+  border: 1px solid transparent;
+  color: var(--color-muted);
+  font-size: 18px;
+  width: 24px;
+  height: 24px;
+  line-height: 24px;
+  padding: 0;
+  border-radius: 6px;
+  cursor: pointer;
+  flex-shrink: 0;
+  opacity: 0;
+  transition: opacity 0.15s, background 0.15s, border-color 0.15s;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.memory-item:hover .memory-menu-btn {
+  opacity: 1;
+}
+
+.memory-menu-btn:hover {
+  background: color-mix(in srgb, var(--fg) 7%, transparent);
+  border-color: var(--border);
+  color: var(--fg);
+}
+
+.memory-item-dropdown {
+  display: none;
+  position: fixed;
+  z-index: 1000;
+  background: var(--panel);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  padding: 4px;
+  box-shadow: 0 4px 16px rgba(0,0,0,0.3);
+  min-width: auto;
+  width: max-content;
+}
+
+.memory-item-dropdown .dropdown-item-compact {
+  padding: 6px 10px;
+  font-size: 12px;
+  cursor: pointer;
+  border-radius: 6px;
+  white-space: nowrap;
+}
+
+.memory-item-dropdown .dropdown-item-compact:hover {
+  background: color-mix(in srgb, var(--accent) 10%, transparent);
+}
+
+.memory-dropdown-delete:hover {
+  color: var(--red) !important;
+}
+
+.memory-item-actions {
+  display: flex;
+  gap: 4px;
+  flex-shrink: 0;
+  margin-left: auto;
+  opacity: 0;
+  transition: opacity 0.15s;
+}
+
+.memory-item:hover .memory-item-actions {
+  opacity: 1;
+}
+
+/* Skill rows show actions at a dim opacity by default so view/run/delete are
+   always discoverable, then brighten on hover. */
+.memory-item.skill-row .memory-item-actions {
+  opacity: 0.35;
+  position: relative;
+  top: -1px;
+}
+.memory-item.skill-row:hover .memory-item-actions {
+  opacity: 1;
+}
+
+.memory-item-btn {
+  background: none;
+  border: 1px solid transparent;
+  color: var(--color-muted);
+  font-size: 11px;
+  height: 22px;
+  padding: 0 6px;
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all 0.15s;
+  display: flex;
+  align-items: center;
+}
+@media (max-width: 768px) {
+  /* Nudge the ••• menu button up on mobile so it visually aligns with the
+     title row rather than sitting a hair below it. */
+  .memory-item-actions .memory-item-btn { transform: translateY(-3px); }
+  /* Email rows sit on a slightly different baseline (extra meta row /
+     nav-arrows cluster), so pull the menu button back down 1px. */
+  #email-lib-modal .memory-item-actions .memory-item-btn { transform: translateY(-2px); }
+  /* Base rule above hides .memory-item-actions until hover. Mobile has no
+     hover → the ⋮ button in cookbook serve / library cards was invisible
+     and effectively unclickable. Force-show on mobile. */
+  .memory-item-actions { opacity: 0.7 !important; }
+  .memory-item-actions .memory-item-btn {
+    width: 32px;
+    height: 32px;
+    min-width: 32px;
+  }
+}
+
+/* Research-preview sub-sections — used by the research-tab expand pattern. */
+.doclib-research-section-label {
+  font-size: 9px;
+  font-weight: 600;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  opacity: 0.55;
+  margin: 8px 0 4px;
+}
+.doclib-research-sources ol {
+  margin: 0;
+  padding-left: 18px;
+  font-size: 11px;
+  line-height: 1.5;
+}
+.doclib-research-sources a {
+  color: var(--accent, var(--red));
+  text-decoration: none;
+}
+.doclib-research-sources a:hover { text-decoration: underline; }
+.doclib-research-summary {
+  font-size: 11px;
+  line-height: 1.5;
+}
+.doclib-research-summary p { margin: 4px 0; }
+
+.memory-item-btn:hover {
+  color: var(--fg);
+  border-color: var(--border);
+  background: color-mix(in srgb, var(--fg) 6%, transparent);
+}
+
+.memory-item-btn.delete:hover {
+  color: var(--color-error);
+  border-color: var(--color-error);
+}
+
+.memory-item-btn.save {
+  color: var(--red);
+}
+
+.memory-item-btn.save:hover {
+  border-color: var(--red);
+}
+
+.memory-item-btn.pin {
+  padding: 1px 4px;
+  opacity: 0.4;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.memory-item-btn.pin.active {
+  opacity: 1;
+}
+.memory-pin-dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: var(--fg);
+  opacity: 0.5;
+  transition: all 0.15s;
+}
+.memory-item-btn.pin.active .memory-pin-dot {
+  background: var(--red);
+  opacity: 1;
+}
+.memory-pinned {
+  border-left: 3px solid var(--red);
+  border-radius: 4px;
+  background: color-mix(in srgb, var(--red) 4%, transparent);
+}
+.memory-pinned .memory-item-actions {
+  opacity: 1;
+}
+.memory-pinned .memory-item-actions .memory-item-btn:not(.pin) {
+  opacity: 0;
+}
+.memory-pinned:hover .memory-item-actions .memory-item-btn:not(.pin) {
+  opacity: 1;
+}
+
+/* Category filter chips */
+.memory-category-filters {
+  display: flex;
+  gap: 4px;
+  flex-wrap: wrap;
+}
+
+.memory-cat-chip {
+  background: none;
+  border: 1px solid var(--border);
+  color: color-mix(in srgb, var(--fg) 60%, transparent);
+  font-size: 10px;
+  height: 22px;
+  padding: 0 8px;
+  display: inline-flex;
+  align-items: center;
+  border-radius: 10px;
+  cursor: pointer;
+  font-family: inherit;
+  transition: all 0.15s;
+  text-transform: lowercase;
+}
+
+.memory-cat-chip:hover {
+  border-color: var(--red);
+  color: var(--red);
+}
+
+.memory-cat-chip.active {
+  background: color-mix(in srgb, var(--red) 15%, transparent);
+  border-color: color-mix(in srgb, var(--red) 40%, transparent);
+  color: var(--red);
+}
+
+/* Sort select */
+.memory-sort-select {
+  position: relative;
+  top: 3px;
+  background: var(--bg);
+  color: var(--fg);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  font-family: inherit;
+  font-size: 11px;
+  height: 24px;
+  padding: 0 6px;
+  cursor: pointer;
+}
+
+.memory-sort-select:focus {
+  outline: none;
+  border-color: var(--red);
+}
+
+/* Item metadata row */
+.memory-item-meta {
+  display: flex;
+  gap: 6px;
+  align-items: center;
+  flex-wrap: wrap;
+}
+
+/* Category badge on each item */
+.memory-cat-badge {
+  font-size: 10px;
+  padding: 2px 6px;
+  border-radius: 4px;
+  background: color-mix(in srgb, var(--fg) 8%, transparent);
+  color: color-mix(in srgb, var(--fg) 55%, transparent);
+  text-transform: lowercase;
+}
+
+.memory-cat-identity {
+  background: color-mix(in srgb, var(--hl-function) 15%, transparent);
+  color: var(--hl-function);
+}
+.memory-cat-preference {
+  background: color-mix(in srgb, var(--hl-keyword) 15%, transparent);
+  color: var(--hl-keyword);
+}
+.memory-cat-contact {
+  background: color-mix(in srgb, #98c379 15%, transparent);
+  color: #98c379;
+}
+.memory-cat-project {
+  background: color-mix(in srgb, var(--hl-string) 15%, transparent);
+  color: var(--hl-string);
+}
+.memory-cat-goal {
+  background: color-mix(in srgb, var(--red) 15%, transparent);
+  color: var(--red);
+}
+.memory-cat-task {
+  background: color-mix(in srgb, #d19a66 15%, transparent);
+  color: #d19a66;
+}
+.memory-cat-pinned {
+  background: color-mix(in srgb, var(--red) 15%, transparent);
+  color: var(--red);
+}
+
+/* Source and time metadata */
+.memory-item-source,
+.memory-item-time,
+.memory-item-uses {
+  font-size: 9px;
+  color: color-mix(in srgb, var(--fg) 35%, transparent);
+}
+.memory-item-uses {
+  font-family: monospace;
+  color: color-mix(in srgb, var(--fg) 55%, transparent);
+}
+
+.memory-item-source::before,
+.memory-item-time::before {
+  content: '\00b7  ';
+}
+
+/* Empty state */
+.memory-empty {
+  padding: 24px 16px;
+  color: color-mix(in srgb, var(--fg) 35%, transparent);
+  text-align: center;
+  font-size: 11px;
+  font-style: italic;
+}
+
+/* Suggestions area */
+.memory-suggestions {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.memory-suggestions.hidden {
+  display: none;
+}
+
+.memory-suggestions-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-size: 11px;
+  color: color-mix(in srgb, var(--fg) 70%, transparent);
+  padding-bottom: 4px;
+  border-bottom: 1px solid var(--border);
+}
+.memory-suggestions-actions,
+.memory-suggestion-actions {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  flex-shrink: 0;
+}
+.memory-suggestions-actions {
+  position: relative;
+  left: -4px;
+}
+.memory-suggestions-actions .memory-item-btn,
+.memory-suggestion-actions .memory-item-btn {
+  background: color-mix(in srgb, var(--fg) 6%, transparent);
+  border-color: color-mix(in srgb, var(--border) 85%, transparent);
+}
+.memory-suggestions-actions .memory-item-btn.save,
+.memory-suggestion-actions .memory-item-btn.save {
+  background: color-mix(in srgb, var(--red) 9%, transparent);
+  border-color: color-mix(in srgb, var(--red) 28%, transparent);
+}
+
+.memory-suggestion-item {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 6px;
+  padding: 5px 8px;
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  background: color-mix(in srgb, var(--red) 3%, transparent);
+}
+
+/* Tidy animations */
+.memory-tidy-editing {
+  border-color: color-mix(in srgb, var(--hl-function) 40%, transparent);
+  background: color-mix(in srgb, var(--hl-function) 6%, transparent);
+  transition: all 0.3s;
+}
+
+.memory-tidy-text-old {
+  opacity: 0.4;
+  text-decoration: line-through;
+  transition: opacity 0.25s;
+}
+
+.memory-tidy-text-new {
+  color: var(--hl-function);
+  transition: color 0.4s;
+}
+
+.memory-tidy-removing {
+  text-decoration: line-through;
+  opacity: 0;
+  max-height: 0;
+  padding-top: 0;
+  padding-bottom: 0;
+  margin-top: 0;
+  margin-bottom: 0;
+  border-color: transparent;
+  overflow: hidden;
+  transition: opacity 0.3s, max-height 0.4s 0.1s, padding 0.4s 0.1s, border-color 0.3s;
+}
+
+/* ===== DOCUMENT EDITOR (ARTIFACTS) PANEL ===== */
+
+/* Doc editor is a body-level sibling of chat-container */
+
+/* Divider between chat and doc editor */
+.doc-divider {
+  width: 1px;
+  flex-shrink: 0;
+  background: color-mix(in srgb, var(--fg) 11%, transparent);
+  cursor: col-resize;
+  transition: background 0.15s;
+  position: relative;
+  z-index: 10;
+}
+.doc-divider::before {
+  content: '';
+  position: absolute;
+  top: 0; bottom: 0;
+  left: -10px;
+  width: 21px;
+  cursor: col-resize;
+}
+.doc-divider:hover {
+  background: color-mix(in srgb, var(--fg) 30%, transparent);
+}
+/* Always-visible "›" handle on the drag divider — clickable to collapse the
+   panel, and signals the divider is interactive. */
+.doc-divider-collapse {
+  position: absolute;
+  top: 50%;
+  left: 2px;
+  transform: translateY(-50%);
+  width: 20px;
+  height: 34px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid var(--border);
+  border-radius: 7px;
+  background: var(--panel);
+  color: var(--fg);
+  font-size: 16px;
+  font-weight: 700;
+  line-height: 1;
+  opacity: 0.6;
+  cursor: pointer;
+  transition: opacity 0.15s, border-color 0.15s, left 0.22s ease;
+  z-index: 12;
+}
+/* When in fullscreen mode (cursor outside the doc), the flap itself slides
+   to the OUTSIDE edge of the divider — visually belonging to the chat side
+   the cursor is on, not the doc. The left/right shift pairs with the glyph
+   rotation for a single coordinated transition. */
+.doc-divider-collapse[data-mode="fullscreen"] {
+  left: -22px;
+}
+.doc-divider:hover .doc-divider-collapse { opacity: 0.92; }
+.doc-divider-collapse:hover { opacity: 1 !important; border-color: var(--accent, var(--red)); }
+/* The same `›` glyph is in the markup; CSS rotates 180° for the left-pointing
+   (fullscreen) state. Smooth transition pairs with the chevron's slide. */
+.doc-divider-collapse > span {
+  display: inline-block;
+  transition: transform 0.22s ease, opacity 0.18s ease;
+}
+.doc-divider-collapse[data-mode="fullscreen"] > span {
+  transform: rotate(180deg);
+}
+/* Secondary "hide panel" X button in the divider — invisible until the pane
+   is fullscreen, then floats just below the unfullscreen chevron so the user
+   has a one-tap escape that minimises the pane instead of just exiting
+   fullscreen. */
+.doc-divider-hide {
+  position: absolute;
+  top: 50%;
+  left: 2px;
+  width: 20px;
+  height: 20px;
+  margin-top: 22px;
+  display: none;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid var(--border);
+  border-radius: 50%;
+  background: var(--panel);
+  color: var(--accent, var(--red));
+  cursor: pointer;
+  padding: 0;
+  z-index: 12;
+  opacity: 0.85;
+  transition: opacity 0.15s, border-color 0.15s;
+}
+.doc-divider-hide:hover {
+  opacity: 1;
+  border-color: var(--accent, var(--red));
+}
+/* Smooth entrance — slide in from the left + fade up so it doesn't snap into
+   place when fullscreen activates. The chevron's vertical centering uses
+   translateY(-50%), so the animation has to keep that part. */
+@keyframes doc-fs-chevron-in {
+  from { opacity: 0; transform: translateY(-50%) translateX(-18px); }
+  to   { opacity: 0.85; transform: translateY(-50%) translateX(0); }
+}
+
+/* Copy / Export split button — main click copies, the caret opens the export menu. */
+.doc-split-btn {
+  display: inline-flex;
+  align-items: stretch;
+  border: 1px solid var(--border);
+  border-radius: 7px;
+  overflow: hidden;
+  height: 22px;
+  flex-shrink: 0;
+}
+.doc-split-btn .doc-split-main,
+.doc-split-btn .doc-split-caret {
+  border: none !important;
+  border-radius: 0 !important;
+  height: 100% !important;
+  min-height: 0 !important;
+  /* Keep the element fully opaque so the divider line stays crisp; dim the
+     glyph via colour instead (the base .doc-action-icon-btn fades the whole
+     element to 0.3, which also hides the divider). */
+  opacity: 1 !important;
+  color: color-mix(in srgb, var(--fg) 55%, transparent) !important;
+  background: none !important;
+  transition: color 0.12s, background 0.12s;
+}
+.doc-split-btn:hover .doc-split-main,
+.doc-split-btn:hover .doc-split-caret { color: var(--fg) !important; }
+.doc-split-btn .doc-split-caret {
+  border-left: 1px solid var(--border) !important;
+  padding: 0 5px 0 7px !important;
+}
+.doc-split-btn .doc-split-main:hover,
+.doc-split-btn .doc-split-caret:hover {
+  background: color-mix(in srgb, var(--fg) 8%, transparent) !important;
+  color: var(--fg) !important;
+}
+
+/* Editor pane — body-level flex sibling */
+.doc-editor-pane {
+  flex: 1;
+  min-width: 0;
+  max-width: 70vw;
+  display: flex;
+  flex-direction: column;
+  background: var(--bg);
+  border-left: 1px solid var(--border);
+  box-shadow: -10px 0 22px rgba(0, 0, 0, 0.16);
+  overflow: hidden;
+  height: 100%;
+  position: relative;
+  z-index: 1;
+  color-scheme: dark;
+  /* Smooth open: slide in from the right + fade. Same easing/duration as
+     the notes pane so both drawers feel like one mechanism. */
+  animation: doc-pane-enter 200ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
+  transform-origin: right center;
+  will-change: transform, opacity;
+}
+@keyframes doc-pane-enter {
+  from { transform: translateX(24px); opacity: 0; }
+  to   { transform: translateX(0);    opacity: 1; }
+}
+.doc-editor-pane.doc-pane-leaving {
+  animation: doc-pane-leave 160ms cubic-bezier(0.4, 0, 1, 1) both;
+  pointer-events: none;
+}
+@keyframes doc-pane-leave {
+  from { transform: translateX(0);    opacity: 1; }
+  to   { transform: translateX(24px); opacity: 0; }
+}
+@media (prefers-reduced-motion: reduce) {
+  .doc-editor-pane,
+  .doc-editor-pane.doc-pane-leaving { animation: none; }
+}
+.doc-loading-overlay {
+  position: absolute;
+  inset: 0;
+  z-index: 10;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: var(--bg);
+}
+
+/* ---- Tab bar ---- */
+.doc-tab-bar {
+  display: flex;
+  align-items: stretch;
+  background: var(--bg);
+  border-bottom: 1px solid var(--border);
+  flex-shrink: 0;
+  height: 36px;
+}
+.doc-tab-scroll {
+  display: flex;
+  align-items: stretch;
+  flex: 1;
+  min-width: 0;
+  overflow-x: auto;
+  overflow-y: hidden;
+  scrollbar-width: none;
+  justify-content: flex-start;
+  /* Fade tabs into the bar's background at the edges (next to the scroll
+     arrows) so an overflowing tab dissolves instead of being hard-cut. The
+     fade is conditional — when we're at an edge there's nothing to fade to,
+     so the mask gradient becomes flat on that side (no shadow). */
+  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 18px, #000 calc(100% - 18px), transparent 100%);
+          mask-image: linear-gradient(to right, transparent 0, #000 18px, #000 calc(100% - 18px), transparent 100%);
+}
+.doc-tab-scroll.is-at-left {
+  -webkit-mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 18px), transparent 100%);
+          mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 18px), transparent 100%);
+}
+.doc-tab-scroll.is-at-right {
+  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 18px, #000 100%);
+          mask-image: linear-gradient(to right, transparent 0, #000 18px, #000 100%);
+}
+.doc-tab-scroll.is-at-left.is-at-right {
+  -webkit-mask-image: none;
+          mask-image: none;
+}
+.doc-tab-scroll::-webkit-scrollbar { display: none; }
+.doc-tab-arrow {
+  background: none;
+  border: none;
+  color: var(--fg);
+  opacity: 0.3;
+  cursor: pointer;
+  font-size: 18px;
+  padding: 0 6px;
+  flex-shrink: 0;
+  transition: opacity 0.15s;
+  line-height: 1;
+  display: flex;
+  align-items: center;
+  position: relative;
+  top: -2px;
+}
+.doc-tab-arrow:hover {
+  opacity: 1;
+}
+#doc-tab-right,
+#doc-tab-left {
+  position: relative;
+  top: 3px;
+}
+/* Mobile swipe-down grab handle at the top of the doc sheet. */
+.doc-mobile-grabber { display: none; }
+@media (max-width: 768px) {
+  body.doc-view .doc-mobile-grabber {
+    display: block;
+    flex-shrink: 0;
+    height: 18px;
+    position: relative;
+    background: transparent;
+    background-color: transparent;
+    background-image: none;
+    touch-action: none;
+    cursor: grab;
+  }
+  body.doc-view .doc-mobile-grabber::before {
+    content: '';
+    position: absolute;
+    top: 7px;
+    left: 50%;
+    transform: translateX(-50%);
+    width: 36px;
+    height: 4px;
+    background: var(--fg);
+    opacity: 0.25;
+    border-radius: 2px;
+  }
+}
+
+/* Ghost tab shown in the empty state (new, not-yet-saved document). Muted +
+   italic so it reads as a placeholder, and non-interactive so clicking it
+   can't hit the tab handlers (it has no data-doc-id). */
+.doc-tab.doc-tab-ghost {
+  /* New, not-yet-saved doc tab. It already carries .active, so it shows the
+     accent underline like any active tab — that's all we want. The old dashed
+     border + italic/dim "pending" styling looked weird, so they're gone. */
+  pointer-events: none;
+}
+.doc-tab {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 0 10px;
+  font-family: inherit;
+  font-size: 11px;
+  color: var(--fg);
+  opacity: 0.4;
+  cursor: pointer;
+  white-space: nowrap;
+  transition: opacity 0.1s, background 0.1s;
+  flex-shrink: 0;
+  border-right: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
+  position: relative;
+}
+.doc-tab:hover {
+  opacity: 0.7;
+  background: color-mix(in srgb, var(--fg) 3%, transparent);
+}
+.doc-tab.active {
+  opacity: 1;
+  background: color-mix(in srgb, var(--fg) 5%, transparent);
+  border-radius: 7px 7px 0 0;
+}
+.doc-tab.active::after {
+  content: '';
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  height: 2px;
+  background: var(--red);
+}
+.doc-tab-title {
+  max-width: 140px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.doc-tab-title-input {
+  background: transparent;
+  border: 1px solid var(--fg);
+  border-radius: 2px;
+  color: var(--fg);
+  font-family: inherit;
+  font-size: 11px;
+  padding: 0 4px;
+  height: 18px;
+  width: 120px;
+  max-width: 180px;
+  outline: none;
+}
+.doc-tab-lang {
+  font-size: 9px;
+  opacity: 0.5;
+}
+.doc-tab-close {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  background: none;
+  border: none;
+  color: var(--fg);
+  opacity: 0.4;
+  cursor: pointer;
+  font-size: 18px;
+  line-height: 1;
+  padding: 0 5px;
+  margin-left: 3px;
+  flex-shrink: 0;
+  align-self: center;
+  transition: opacity 0.12s;
+}
+.doc-tab-close:hover { opacity: 1; }
+/* Mobile-only footer (Close + Copy); hidden on desktop and in email mode. */
+.doc-mobile-footer { display: none; }
+.doc-tab-new {
+  background: none;
+  border: none;
+  color: var(--fg);
+  opacity: 0.25;
+  cursor: pointer;
+  font-size: 11px;
+  font-weight: 600;
+  padding: 0 10px;
+  transition: opacity 0.1s;
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  height: 100%;
+}
+.doc-tab-new:hover {
+  opacity: 0.7;
+}
+.doc-tab-play {
+  background: none;
+  border: none;
+  color: var(--fg);
+  opacity: 0.3;
+  cursor: pointer;
+  padding: 0 2px;
+  font-size: 10px;
+  line-height: 1;
+  transition: opacity 0.1s, color 0.1s;
+  flex-shrink: 0;
+}
+.doc-tab-play:hover {
+  opacity: 1;
+  color: var(--green, #4ec970);
+}
+.doc-tab-play.active {
+  opacity: 1;
+  color: var(--green, #4ec970);
+}
+.doc-tab.dragging {
+  opacity: 0.3;
+}
+.doc-tab.drag-over {
+  border-left: 2px solid var(--fg);
+}
+
+/* ---- HTML preview iframe ---- */
+.doc-html-preview {
+  flex: 1;
+  width: 100%;
+  border: none;
+  background: #fff;
+}
+
+/* ---- Editor header ---- */
+.doc-editor-header {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 6px 10px;
+  /* Moved to the bottom as a footer: flex order pushes it last in the pane
+     column, and the divider flips to the top edge. */
+  order: 99;
+  border-top: 1px solid var(--border);
+  flex-shrink: 0;
+  flex-wrap: nowrap;
+  min-height: 36px;
+  background: var(--bg);
+}
+/* Version now lives in the document tab, not the footer. */
+#doc-version-badge { display: none !important; }
+/* Language icon chip on doc tabs — sits between the version pill and title.
+   Hidden when empty so docs without a language don't get awkward spacing. */
+.doc-tab-lang {
+  display: inline-flex; align-items: center;
+  flex-shrink: 0;
+  align-self: center;
+}
+.doc-tab-lang:empty { display: none; }
+.doc-tab-lang svg { display: block; }
+
+.doc-tab-version {
+  font-size: 9px;
+  font-weight: 600;
+  padding: 1px 6px;
+  cursor: pointer;
+  flex-shrink: 0;
+  align-self: center;
+  line-height: 1.4;
+  /* Sits to the LEFT of the title now — space it off the title text. */
+  margin-right: 6px;
+  /* Accent pill so it's obvious the version is a clickable control. */
+  color: var(--accent-primary, var(--red));
+  border: 1px solid color-mix(in srgb, var(--accent-primary, var(--red)) 45%, transparent);
+  background: color-mix(in srgb, var(--accent-primary, var(--red)) 12%, transparent);
+  border-radius: 9px;
+}
+.doc-tab-version:hover {
+  border-color: var(--accent-primary, var(--red));
+  background: color-mix(in srgb, var(--accent-primary, var(--red)) 22%, transparent);
+}
+.doc-close-btn {
+  order: -1;
+  opacity: 0.5;
+  flex-shrink: 0;
+}
+.doc-close-btn:hover {
+  opacity: 1;
+}
+
+.doc-editor-actions {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex: 1;
+  justify-content: flex-end;
+}
+.doc-left .doc-editor-actions {
+  margin-left: 0;
+}
+
+.doc-version-badge {
+  background: color-mix(in srgb, var(--red) 12%, transparent);
+  border: 1px solid color-mix(in srgb, var(--red) 55%, transparent);
+  color: var(--red);
+  padding: 1px 8px;
+  border-radius: 999px;
+  font-size: 10px;
+  font-weight: 600;
+  line-height: 1.4;
+  cursor: pointer;
+  user-select: none;
+  transition: background 0.1s, border-color 0.1s;
+  opacity: 0.9;
+}
+.doc-version-badge:hover {
+  opacity: 1;
+  background: color-mix(in srgb, var(--red) 20%, transparent);
+  border-color: var(--red);
+}
+
+.doc-stream-indicator {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 11px;
+  color: var(--accent);
+  opacity: 0.9;
+  white-space: nowrap;
+  animation: doc-stream-pulse 1.5s ease-in-out infinite;
+}
+.doc-stream-dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: var(--accent);
+}
+@keyframes doc-stream-pulse {
+  0%, 100% { opacity: 0.5; }
+  50% { opacity: 1; }
+}
+.doc-updated-flash {
+  animation: doc-update-flash 0.6s ease-out;
+}
+@keyframes doc-update-flash {
+  0%   { box-shadow: inset 0 0 0 2px var(--accent); }
+  100% { box-shadow: inset 0 0 0 2px transparent; }
+}
+
+/* In the doc footer the type picker sits next to the accent Copy/Export split —
+   match its 28px height and 6px radius so the right-hand controls line up. */
+.doc-actions-footer #doc-language-select {
+  height: 28px;
+  border-radius: 6px;
+  font-size: 11px;
+  top: 0;
+}
+/* Lang-type icon shown to the LEFT of the language select. Browsers won't
+   render SVG inside s in the overlay, which
+     breaks the ligature on this side while the textarea still forms it.
+     Result: visible row drift whenever code contains those pairs. Pin
+     ligatures off in both layers (textarea below) so widths stay equal. */
+  font-variant-ligatures: none !important;
+  font-feature-settings: "kern" 0, "liga" 0, "calt" 0, "dlig" 0 !important;
+  font-kerning: none !important;
+  text-rendering: geometricPrecision !important;
+  box-sizing: border-box;
+}
+.doc-editor-highlight::-webkit-scrollbar { display: none; }
+
+/* Document font size options */
+.doc-font-m .doc-editor-textarea,
+.doc-font-m .doc-editor-highlight,
+.doc-font-m .doc-line-numbers { font-size: 13px !important; }
+.doc-font-l .doc-editor-textarea,
+.doc-font-l .doc-editor-highlight,
+.doc-font-l .doc-line-numbers { font-size: 15px !important; }
+.doc-email-richbody.doc-font-m { font-size: 15px !important; }
+.doc-email-richbody.doc-font-l { font-size: 17px !important; }
+/* Markdown base text should match chat text color, not code color */
+.doc-editor-highlight .language-markdown {
+  color: var(--fg) !important;
+}
+.doc-editor-highlight code,
+.doc-editor-highlight code.hljs,
+.doc-editor-highlight .hljs {
+  font-family: inherit;
+  font-size: inherit !important;
+  line-height: inherit !important;
+  background: transparent !important;
+  padding: 0 !important;
+  margin: 0 !important;
+  border-radius: 0 !important;
+  overflow: hidden !important;
+  display: block;
+  pointer-events: none;
+}
+.doc-editor-textarea {
+  position: absolute;
+  top: 0; left: 0; right: 0; bottom: 0;
+  width: 100%;
+  height: 100% !important;
+  max-height: none !important;
+  min-height: 0 !important;
+  z-index: 1;
+  background: transparent !important;
+  color: transparent !important;
+  color-scheme: dark;
+  caret-color: var(--fg);
+  border: none;
+  outline: none;
+  resize: none;
+  font-family: inherit;
+  /* Caret position only matches the underlying highlight if BOTH layers use
+     identical metrics — !important on font-size + line-height defends against
+     anything else in the cascade nudging the textarea but not the overlay. */
+  font-size: 11px !important;
+  line-height: 1.45 !important;
+  padding: 10px 12px 10px 48px;
+  overflow-y: scroll;
+  /* Pair with .doc-editor-highlight's scrollbar-gutter: stable so the
+     textarea's content width DOESN'T shrink the moment its scrollbar
+     appears (overflow-y: scroll keeps scrollbar permanent, gutter
+     reserves the space layout-wise). Without this, line wrap diverges
+     between textarea and overlay whenever content exceeds the visible
+     area — caret stays right, but typed text appears on a different row
+     than the caret. */
+  scrollbar-gutter: stable;
+  /* The highlight overlay hides its scrollbar, so the textarea must too —
+     otherwise the scrollbar shrinks the textarea's text-area width and
+     wraps lines earlier than the overlay, putting the caret on the wrong
+     line entirely. */
+  scrollbar-width: none;
+  -webkit-overflow-scrolling: touch;
+  tab-size: 4;
+  white-space: pre-wrap;
+  word-wrap: break-word;
+  -moz-appearance: none;
+  appearance: none;
+  box-sizing: border-box;
+  /* Mirror the ligature/kerning lockdown from .doc-editor-highlight above.
+     Without this the textarea forms `=>`/`!=`/`->`/`==` as single-glyph
+     ligatures while the overlay can't (hljs splits the pair into
+     separate spans), so glyph widths diverge and the visible text drifts
+     down relative to the caret as code accumulates. */
+  font-variant-ligatures: none !important;
+  font-feature-settings: "kern" 0, "liga" 0, "calt" 0, "dlig" 0 !important;
+  font-kerning: none !important;
+  text-rendering: geometricPrecision !important;
+}
+.doc-editor-textarea::-webkit-scrollbar { display: none; }
+.doc-editor-textarea:hover,
+.doc-editor-textarea:focus,
+.doc-editor-textarea:active {
+  background: transparent !important;
+  /* Used to force `color: transparent` here so the hidden two-layer
+     textarea wouldn't bleed through on hover/focus. Now that the
+     textarea renders its OWN visible text (overlay is hidden), forcing
+     transparent here makes typed text disappear the moment the cursor
+     enters the page. Keep the visible fg color instead. */
+  color: var(--fg) !important;
+  outline: none !important;
+}
+.doc-editor-textarea::placeholder {
+  color: var(--fg);
+  opacity: 0.25;
+}
+/* Show real text when selecting so copy/paste is visible */
+.doc-editor-textarea::selection {
+  color: var(--fg);
+  background: color-mix(in srgb, var(--color-accent) 30%, transparent);
+}
+
+/* ---- Selection indicator badge ---- */
+.doc-selection-badge {
+  font-size: 10px;
+  color: var(--red);
+  background: color-mix(in srgb, var(--red) 12%, transparent);
+  border: 1px solid color-mix(in srgb, var(--red) 30%, transparent);
+  border-radius: 4px;
+  padding: 1px 4px 1px 6px;
+  white-space: nowrap;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+.doc-selection-clear {
+  background: none;
+  border: none;
+  color: var(--fg);
+  opacity: 0.5;
+  cursor: pointer;
+  font-size: 13px;
+  line-height: 1;
+  padding: 0 2px;
+}
+.doc-selection-clear:hover {
+  opacity: 1;
+  color: var(--red, var(--color-error));
+}
+.doc-edit-tag {
+  font-size: 0.75em;
+  opacity: 0.5;
+  background: color-mix(in srgb, var(--fg) 8%, transparent);
+  border-radius: 4px;
+  padding: 1px 5px;
+  margin-right: 2px;
+  white-space: nowrap;
+}
+/* Attachment cards in user messages */
+.attach-cards {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin-top: 8px;
+}
+.attach-card {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 5px 10px;
+  border-radius: 6px;
+  background: color-mix(in srgb, var(--fg) 6%, transparent);
+  /* Same border as the chat bubbles. */
+  border: 1px solid var(--bubble-border, var(--border));
+  font-size: 12px;
+  transition: background 0.15s;
+}
+.attach-card[style*="cursor: pointer"]:hover,
+.attach-card[data-file-id]:hover {
+  background: color-mix(in srgb, var(--fg) 12%, transparent);
+}
+.attach-card-icon {
+  flex-shrink: 0;
+  opacity: 0.6;
+}
+.attach-card-name {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  max-width: 200px;
+}
+.attach-card-size {
+  opacity: 0.45;
+  font-size: 11px;
+  white-space: nowrap;
+}
+/* Import prompt banner (above chatbar) */
+.import-prompt-banner {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 8px 12px;
+  margin: 0 auto 6px;
+  max-width: 800px;
+  background: var(--panel);
+  border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
+  border-left: 3px solid var(--accent);
+  border-radius: 6px;
+  font-size: 12px;
+  color: var(--fg);
+  box-shadow: 0 4px 12px rgba(0,0,0,0.2);
+  backdrop-filter: blur(12px);
+  animation: modal-enter 0.15s ease-out;
+}
+.import-prompt-banner span { flex: 1; }
+.import-prompt-banner button {
+  padding: 3px 12px;
+  border: 1px solid var(--border);
+  border-radius: 5px;
+  background: none;
+  color: var(--fg);
+  cursor: pointer;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.import-prompt-banner button:hover {
+  border-color: var(--fg);
+}
+.import-prompt-dismiss {
+  border: none !important;
+  background: none !important;
+  opacity: 0.5;
+  font-size: 16px !important;
+  padding: 0 4px !important;
+}
+.import-prompt-dismiss:hover { opacity: 1; background: none !important; }
+.doc-selection-overlay {
+  position: absolute;
+  background: color-mix(in srgb, var(--red) 10%, transparent);
+  border-left: 2px solid color-mix(in srgb, var(--red) 50%, transparent);
+  pointer-events: none;
+  z-index: 0;
+  transition: top 0.05s;
+}
+
+/* ── Suggestion comments (Google Docs style) ── */
+.doc-suggestion-highlight {
+  position: absolute;
+  background: color-mix(in srgb, var(--accent) 12%, transparent);
+  border-left: 3px solid var(--accent);
+  pointer-events: none;
+  z-index: 1;
+  transition: top 0.1s, opacity 0.15s;
+}
+/* Suggestion card — fixed next to editor, anchored to the change */
+.doc-suggestion-card {
+  position: fixed;
+  width: 250px;
+  background: var(--panel);
+  border: 1px solid var(--accent);
+  border-radius: 10px;
+  padding: 12px 12px 10px;
+  font-size: 12px;
+  box-shadow: 0 4px 20px rgba(0,0,0,0.25);
+  animation: suggestion-enter 0.25s ease-out;
+  z-index: 250;
+  overflow: visible;
+}
+/* Arrow pointing toward the editor */
+.doc-suggestion-card::before {
+  content: '';
+  position: absolute;
+  top: 16px;
+  left: -10px;
+  width: 18px;
+  height: 18px;
+  background: var(--panel);
+  border-left: 2px solid var(--accent);
+  border-bottom: 2px solid var(--accent);
+  transform: rotate(45deg);
+  z-index: 1;
+}
+.doc-suggestion-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 6px;
+}
+.doc-suggestion-nav {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+.doc-suggestion-nav-btn {
+  background: none;
+  border: none;
+  color: var(--fg);
+  cursor: pointer;
+  font-size: 16px;
+  line-height: 1;
+  padding: 2px 4px;
+  opacity: 0.35;
+  transition: opacity 0.1s;
+}
+.doc-suggestion-nav-btn:hover { opacity: 1; }
+.doc-suggestion-close {
+  background: none;
+  border: none;
+  color: var(--fg);
+  opacity: 0.3;
+  cursor: pointer;
+  font-size: 14px;
+  line-height: 1;
+  padding: 6px 8px;
+  margin: -6px -8px;
+  border-radius: 6px;
+  transition: opacity 0.1s, background 0.1s;
+}
+.doc-suggestion-close:hover { opacity: 1; background: color-mix(in srgb, var(--fg) 8%, transparent); }
+.doc-suggestion-counter {
+  font-size: 10px;
+  opacity: 0.4;
+  font-weight: 600;
+  letter-spacing: 0.5px;
+}
+/* Inline diff markers — injected into the code highlight element */
+.sugg-inline-del {
+  background: color-mix(in srgb, var(--red) 20%, transparent);
+  color: color-mix(in srgb, var(--red) 70%, var(--fg));
+  text-decoration: line-through;
+  text-decoration-thickness: 1px;
+  text-decoration-color: color-mix(in srgb, var(--red) 40%, transparent);
+  border-radius: 2px;
+  padding: 0 2px;
+}
+.sugg-inline-add {
+  background: color-mix(in srgb, var(--green) 25%, transparent);
+  color: var(--green);
+  border-radius: 2px;
+  padding: 0 2px;
+}
+/* ---- Diff mode ---- */
+.diff-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 5px 12px;
+  background: color-mix(in srgb, var(--fg) 4%, var(--bg));
+  border-bottom: 1px solid var(--border);
+  font-size: 11px;
+  flex-shrink: 0;
+}
+.diff-toolbar-status {
+  opacity: 0.5;
+  font-size: 10px;
+  margin-right: auto;
+}
+.diff-toolbar-btn {
+  background: none;
+  border: 1px solid var(--border);
+  color: var(--fg);
+  font-size: 10px;
+  padding: 3px 10px;
+  border-radius: 4px;
+  cursor: pointer;
+  font-family: inherit;
+  transition: border-color 0.15s, background 0.15s;
+}
+.diff-toolbar-btn:hover {
+  border-color: var(--accent, var(--red));
+  background: color-mix(in srgb, var(--accent, var(--red)) 8%, transparent);
+}
+.diff-toolbar-btn-accept { color: var(--green); border-color: color-mix(in srgb, var(--green) 30%, transparent); }
+.diff-toolbar-btn-accept:hover { border-color: var(--green); background: color-mix(in srgb, var(--green) 10%, transparent); }
+.diff-toolbar-btn-reject { color: var(--red); border-color: color-mix(in srgb, var(--red) 30%, transparent); }
+.diff-toolbar-btn-reject:hover { border-color: var(--red); background: color-mix(in srgb, var(--red) 10%, transparent); }
+.diff-line-del {
+  display: block;
+  background: color-mix(in srgb, var(--accent) 18%, transparent);
+  border-left: 3px solid var(--accent);
+  padding-left: 4px;
+  margin-left: -7px;
+  text-decoration: line-through;
+  opacity: 0.7;
+}
+.diff-line-add {
+  display: block;
+  background: color-mix(in srgb, var(--accent) 28%, transparent);
+  border-left: 3px solid var(--accent);
+  padding-left: 4px;
+  margin-left: -7px;
+}
+/* Inline diff summary (version history cards) */
+.diff-del {
+  color: var(--accent);
+  text-decoration: line-through;
+  opacity: 0.7;
+}
+.diff-add {
+  color: var(--accent);
+  font-weight: 600;
+}
+.diff-line-equal {
+  display: block;
+}
+.diff-chunk-resolved {
+  opacity: 0.3;
+  transition: opacity 0.3s;
+}
+.diff-chunk-actions {
+  position: absolute;
+  right: 8px;
+  top: 0;
+  display: flex;
+  gap: 4px;
+  z-index: 5;
+  pointer-events: auto;
+}
+/* Diff mode: textarea sits on top of the highlight where chunk buttons live.
+   Disable its pointer events so clicks reach the buttons in the layer below. */
+.doc-editor-wrap.diff-mode .doc-editor-textarea {
+  pointer-events: none;
+}
+.doc-editor-wrap.diff-mode .doc-editor-highlight {
+  pointer-events: auto;
+  z-index: 2;
+}
+.diff-chunk-btn {
+  width: 20px;
+  height: 20px;
+  border-radius: 4px;
+  border: 1px solid var(--border);
+  background: var(--bg);
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 12px;
+  line-height: 1;
+  transition: border-color 0.15s, background 0.15s;
+  opacity: 0.6;
+}
+.diff-chunk-btn:hover { opacity: 1; }
+.diff-chunk-btn-accept { color: var(--green); }
+.diff-chunk-btn-accept:hover { border-color: var(--green); background: color-mix(in srgb, var(--green) 15%, transparent); }
+.diff-chunk-btn-reject { color: var(--red); }
+.diff-chunk-btn-reject:hover { border-color: var(--red); background: color-mix(in srgb, var(--red) 15%, transparent); }
+
+.doc-suggestion-accept-all {
+  flex: 1;
+  padding: 5px 8px;
+  border: none;
+  border-radius: 5px;
+  cursor: pointer;
+  font-size: 10px;
+  font-family: inherit;
+  background: transparent;
+  border: 1px solid var(--accent);
+  color: var(--accent);
+  transition: background 0.1s;
+}
+.doc-suggestion-accept-all:hover {
+  background: color-mix(in srgb, var(--accent) 15%, transparent);
+}
+.doc-suggestion-reason {
+  opacity: 0.6;
+  margin-bottom: 6px;
+  font-size: 11px;
+  line-height: 1.4;
+}
+.doc-suggestion-diff {
+  background: var(--bg);
+  border-radius: 4px;
+  padding: 6px 8px;
+  font-family: var(--code-font, monospace);
+  font-size: 11px;
+  margin-bottom: 8px;
+  max-height: 100px;
+  overflow-y: auto;
+  word-break: break-word;
+}
+.doc-suggestion-del {
+  color: var(--red);
+  text-decoration: line-through;
+  opacity: 0.7;
+}
+.doc-suggestion-add {
+  color: var(--green);
+}
+.doc-suggestion-actions {
+  display: flex;
+  gap: 6px;
+}
+.doc-suggestion-accept,
+.doc-suggestion-dismiss {
+  flex: 1;
+  padding: 5px 8px;
+  border: none;
+  border-radius: 5px;
+  cursor: pointer;
+  font-size: 11px;
+  font-family: inherit;
+  font-weight: 500;
+  transition: background 0.1s;
+}
+.doc-suggestion-accept {
+  background: var(--accent, var(--red));
+  color: #fff;
+}
+.doc-suggestion-accept:hover {
+  filter: brightness(1.15);
+}
+.doc-suggestion-dismiss {
+  background: transparent;
+  border: 1px solid var(--border);
+  color: var(--fg);
+}
+.doc-suggestion-dismiss:hover {
+  background: color-mix(in srgb, var(--fg) 8%, transparent);
+}
+@keyframes suggestion-enter {
+  from { opacity: 0; transform: translateX(10px); }
+  to { opacity: 1; transform: translateX(0); }
+}
+/* Mobile: suggestion card overlays top of editor (no room on side) */
+@media (max-width: 768px) {
+  .doc-suggestion-card {
+    right: 8px;
+    right: 8px;
+    width: auto;
+    top: 8px !important;
+  }
+  .doc-suggestion-card::before { display: none; }
+}
+
+/* ---- Streaming animation ---- */
+.doc-editor-textarea[readonly] {
+  caret-color: var(--red);
+}
+.doc-editor-wrap.animating::after {
+  content: '';
+  position: absolute;
+  top: 6px; right: 8px;
+  width: 6px; height: 6px;
+  border-radius: 50%;
+  background: var(--red);
+  animation: doc-pulse 0.8s ease-in-out infinite;
+  z-index: 3;
+  pointer-events: none;
+}
+@keyframes doc-pulse {
+  0%, 100% { opacity: 0.3; transform: scale(0.8); }
+  50% { opacity: 1; transform: scale(1.2); }
+}
+
+/* Diff overlay */
+.doc-diff-overlay {
+  position: absolute;
+  inset: 0;
+  background: var(--bg);
+  z-index: 5;
+  overflow-y: auto;
+  padding: 8px 12px;
+  font-family: 'Fira Code', monospace;
+  font-size: 0.85rem;
+  line-height: 1.5;
+  opacity: 0;
+  transition: opacity 0.3s ease;
+  border-radius: 6px;
+}
+.doc-diff-overlay.visible { opacity: 1; }
+.doc-diff-overlay.fading { opacity: 0; transition: opacity 0.4s ease; }
+.doc-diff-stats {
+  display: flex;
+  gap: 10px;
+  padding: 4px 0 8px;
+  font-size: 0.8rem;
+  font-weight: 600;
+}
+.diff-stat-del { color: var(--warn); }
+.diff-stat-add { color: var(--green); }
+.doc-diff-content { }
+.doc-diff-line {
+  padding: 1px 8px;
+  border-radius: 4px;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+.doc-diff-line.same {
+  opacity: 0.4;
+}
+.doc-diff-line.del {
+  background: color-mix(in srgb, var(--warn) 15%, transparent);
+  color: var(--warn);
+  text-decoration: line-through;
+  text-decoration-color: color-mix(in srgb, var(--warn) 40%, transparent);
+}
+.doc-diff-line.add {
+  background: color-mix(in srgb, var(--green) 12%, transparent);
+  color: var(--green);
+}
+.doc-diff-sep {
+  text-align: center;
+  padding: 2px 0;
+  font-size: 0.7rem;
+  opacity: 0.3;
+  color: var(--fg);
+}
+
+/* Version history panel (slide-out) */
+.doc-version-panel {
+  position: fixed;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  width: 300px;
+  background: var(--bg);
+  border-right: 1px solid var(--border);
+  display: flex;
+  flex-direction: column;
+  z-index: 200;
+  box-shadow: 4px 0 16px rgba(0,0,0,0.35);
+  animation: version-slide-in 0.2s ease-out;
+  /* Match the app modal aesthetic so nothing inherits the large body font. */
+  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+  font-size: 12px;
+  letter-spacing: -0.01em;
+}
+@keyframes version-slide-in {
+  from { opacity: 0; transform: translateX(-30px); }
+  to { opacity: 1; transform: translateX(0); }
+}
+.doc-version-panel.hidden {
+  display: none;
+}
+@media (max-width: 768px) {
+  .doc-version-panel {
+    left: 0;
+    right: 0;
+    top: auto;
+    bottom: 0;
+    width: 100%;
+    height: 50vh;
+    border-right: none;
+    border-left: none;
+    border-top: 1px solid var(--border);
+    border-radius: 14px 14px 0 0;
+    box-shadow: 0 -4px 16px rgba(0,0,0,0.3);
+    /* It's a bottom sheet on mobile — slide UP from the bottom, not in from
+       the left (the desktop animation looked like a black box janking in). */
+    animation: version-slide-up 0.2s ease-out;
+  }
+}
+@keyframes version-slide-up {
+  from { opacity: 0; transform: translateY(30px); }
+  to   { opacity: 1; transform: translateY(0); }
+}
+
+.doc-version-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 10px 12px;
+  border-bottom: 1px solid var(--border);
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--fg);
+}
+
+.doc-version-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: 8px;
+}
+
+.doc-version-item {
+  padding: 8px;
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  margin-bottom: 6px;
+  cursor: pointer;
+  transition: all 0.15s;
+  background: color-mix(in srgb, var(--fg) 3%, transparent);
+}
+.doc-version-item:hover {
+  background: color-mix(in srgb, var(--fg) 6%, transparent);
+  border-color: var(--fg);
+}
+
+.doc-version-info {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 4px;
+}
+
+.doc-version-num {
+  font-weight: 600;
+  font-size: 12px;
+  color: var(--fg);
+}
+.doc-version-source {
+  font-size: 10px;
+  color: var(--fg);
+  opacity: 0.5;
+  background: color-mix(in srgb, var(--fg) 6%, transparent);
+  padding: 1px 6px;
+  border-radius: 4px;
+}
+.doc-version-time {
+  font-size: 10px;
+  color: var(--fg);
+  opacity: 0.4;
+  margin-left: auto;
+}
+.doc-version-summary {
+  font-size: 11px;
+  color: var(--fg);
+  opacity: 0.5;
+  margin-bottom: 4px;
+}
+
+.doc-version-restore {
+  background: none;
+  border: 1px solid var(--border);
+  color: var(--fg);
+  font-size: 10px;
+  padding: 2px 8px;
+  border-radius: 4px;
+  cursor: pointer;
+  transition: all 0.15s;
+}
+.doc-version-restore:hover {
+  background: color-mix(in srgb, var(--fg) 6%, transparent);
+  border-color: var(--fg);
+}
+/* "latest" badge */
+.doc-version-latest {
+  font-size: 10px;
+  font-weight: 600;
+  color: var(--accent, var(--red));
+  background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent);
+  padding: 1px 6px;
+  border-radius: 4px;
+}
+/* Diff preview lines — small and muted so they don't dominate the item. */
+.doc-version-diff {
+  font-size: 10px;
+  line-height: 1.5;
+  opacity: 0.8;
+  margin-top: 2px;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  word-break: break-word;
+}
+.doc-version-diff .diff-del { color: var(--red); opacity: 0.75; }
+.doc-version-diff .diff-add { color: #3fb950; }
+
+/* Mobile: doc editor takes over full screen as toggle */
+@media (max-width: 768px) {
+  body.doc-view .doc-editor-pane {
+    /* Force its own full-screen window on mobile. !important on these so an
+       inline width/position left over from desktop drag-resize, or the base
+       desktop split layout (flex: 1; max-width: 70vw; border-left), can never
+       render it as a narrow side pane ("sidebar") on a phone. */
+    position: fixed !important;
+    inset: 0 !important;
+    top: 0 !important; right: 0 !important; bottom: 0 !important; left: 0 !important;
+    max-width: 100% !important;
+    width: 100% !important;
+    flex: none !important;
+    z-index: 170;
+    /* Stroke the top edge so the rounded corners read as a curved sheet edge. */
+    border: 1px solid var(--border);
+    border-bottom: none;
+    /* Rounded top corners like the other mobile sheet windows. */
+    border-radius: 14px 14px 0 0;
+    /* Slide up from the bottom (sheet), not in from the side, on mobile. */
+    animation: sheet-enter 0.25s cubic-bezier(0.2, 0.8, 0.2, 1) both;
+    transform-origin: bottom center;
+  }
+  body.doc-view .doc-divider {
+    display: none;
+  }
+  /* Hide chat behind doc panel on mobile */
+  body.doc-view .chat-container {
+    display: none;
+  }
+  /* Doc + email windows alternate: whichever was opened last is in front.
+     Default (a doc was opened last) → email windows sit BELOW the full-screen
+     doc pane (170) so the draft is on top. When the user re-opens an email
+     window (body.email-front, set by openEmailLibrary / reader open+restore) →
+     the email windows jump ABOVE the doc. Covers the email library AND any open
+     reader window (email-reader-); both are .modal at z-250 by default. */
+  body.doc-view #email-lib-modal,
+  body.doc-view .modal[id^="email-reader-"] { z-index: 150 !important; }
+  body.doc-view.email-front #email-lib-modal,
+  body.doc-view.email-front .modal[id^="email-reader-"] { z-index: 300 !important; }
+  /* Hide new-session button and hamburger when doc editor is open on mobile */
+  body.doc-view .mobile-new-chat-btn,
+  /* Hide the global hamburger while Compare Mode is active — the compare
+   header has its own close button in the top-right, and overlapping the
+   two looks like a bug. The .compare-active class lives on the chat
+   container, not body, so use :has(). */
+body:has(.compare-active) .hamburger-btn { display: none !important; }
+/* Mobile: hide the sidebar hamburger when a document panel (or notes pane)
+   is open — those sheets cover the whole screen on mobile, so a floating
+   hamburger sticking out over them is just clutter / mis-tap bait. */
+@media (max-width: 768px) {
+  body.doc-view .hamburger-btn,
+  body:has(#notes-pane) .hamburger-btn { display: none !important; }
+}
+  /* Make room for hamburger button alongside the tab/header bars (fallback if shown) */
+  body.doc-view.sidebar-collapsed.hamburger-left .doc-tab-bar,
+  body.doc-view.sidebar-collapsed.hamburger-left .doc-editor-header,
+  body.doc-view.sidebar-collapsed.hamburger-left .doc-md-toolbar {
+    padding-left: 44px;
+  }
+  body.doc-view.sidebar-collapsed.hamburger-right .doc-tab-bar,
+  body.doc-view.sidebar-collapsed.hamburger-right .doc-editor-header,
+  body.doc-view.sidebar-collapsed.hamburger-right .doc-md-toolbar {
+    padding-right: 44px;
+  }
+  /* ── Tab bar — match header height, bigger touch targets ── */
+  .doc-tab-bar {
+    padding: 0;
+    height: 40px;
+  }
+  .doc-tab {
+    padding: 0 12px;
+    font-size: 13px;
+  }
+  /* Bigger × touch target on mobile. */
+  .doc-tab-close {
+    font-size: 22px;
+    padding: 0 8px;
+    opacity: 0.5;
+  }
+  .doc-tab .doc-tab-menu-btn {
+    opacity: 0.4 !important;
+    padding: 4px 6px !important;
+  }
+  .doc-tab .doc-tab-menu-btn svg {
+    width: 14px !important;
+    height: 14px !important;
+  }
+  /* Footer is identical to desktop now, so the separate mobile Close/Copy
+     footer is dropped and the per-tab × stays (matching desktop). */
+  .doc-mobile-footer { display: none !important; }
+  .doc-tab-new {
+    font-size: 13px !important;
+    padding: 0 14px !important;
+    gap: 4px;
+  }
+  /* No hamburger padding — it's hidden in doc-view */
+  body.doc-view.sidebar-collapsed.hamburger-left .doc-tab-bar,
+  body.doc-view.sidebar-collapsed.hamburger-left .doc-editor-header,
+  body.doc-view.sidebar-collapsed.hamburger-left .doc-md-toolbar {
+    padding-left: 0 !important;
+  }
+  body.doc-view.sidebar-collapsed.hamburger-right .doc-tab-bar,
+  body.doc-view.sidebar-collapsed.hamburger-right .doc-editor-header,
+  body.doc-view.sidebar-collapsed.hamburger-right .doc-md-toolbar {
+    padding-right: 0 !important;
+  }
+  /* ── Header — identical layout to desktop (left-aligned), match tab height ── */
+  .doc-editor-header {
+    padding: 6px 12px 6px 8px;
+    gap: 6px;
+    min-height: 40px;
+  }
+  .doc-import-label,
+  #doc-version-badge {
+    display: none !important;
+  }
+  /* ── Markdown toolbar — same height as tab bar ── */
+  .doc-md-toolbar {
+    height: 40px !important;
+    padding: 4px 8px;
+    gap: 2px;
+    overflow-x: auto;
+    -webkit-overflow-scrolling: touch;
+  }
+  /* The toolbar's padding-left is forced to 0 by the hamburger rules, so give
+     the Edit/Preview toggle its own left margin to clear the screen edge +
+     the 18px mask fade. Margin isn't overridden by those !important paddings. */
+  .doc-md-toolbar .md-view-toggle { margin-left: 8px; }
+  .doc-md-toolbar button {
+    font-size: 13px !important;
+    padding: 6px 10px !important;
+    min-width: 34px;
+    min-height: 34px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    flex-shrink: 0;
+  }
+  .md-toolbar-sep {
+    height: 18px;
+    margin: 0 4px;
+  }
+}
+
+/* Opacity slider in the theme tabs row — lets the user see the page behind
+   the modal while tweaking colors. JS toggles .hidden based on active tab. */
+.theme-opacity-wrap {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  margin-left: auto;
+  margin-right: 6px;
+  padding: 2px 9px;
+  border: 1px solid var(--border);
+  border-radius: 999px;
+  opacity: 0.65;
+  transition: opacity 0.15s, border-color 0.15s, background 0.15s, color 0.15s;
+  height: 22px;
+  align-self: center;
+  /* Now a