From d9ebdd6fbba31b5f3c2cbae90f650f183958660e Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Mon, 15 Jun 2026 23:24:41 +0900 Subject: [PATCH 01/22] Refresh README presentation --- README.md | 501 +++---------------------------------- docs/odysseus-wordmark.png | Bin 0 -> 16877 bytes docs/odysseus.jpg | Bin 45964 -> 53198 bytes docs/setup.md | 425 +++++++++++++++++++++++++++++++ 4 files changed, 463 insertions(+), 463 deletions(-) create mode 100644 docs/odysseus-wordmark.png create mode 100644 docs/setup.md diff --git a/README.md b/README.md index 8eb85229b..dcf07f761 100644 --- a/README.md +++ b/README.md @@ -1,476 +1,65 @@ -# Odysseus +

+ Odysseus +

-> **Branch note:** `dev` is the default branch and contains the latest development changes, but it may be unstable. For the more stable curated branch, use [`main`](https://github.com/pewdiepie-archdaemon/odysseus/tree/main). +

+ A self-hosted AI workspace for chat, agents, research, documents, email, notes, calendar, and local model workflows. +

-``` -─────────────────────────────────────────────── - ⊹ ࣪ ˖ ૮( ˶ᵔ ᵕ ᵔ˶ )っ Odysseus vers. 1.0 -─────────────────────────────────────────────── -``` +

+ Quick Start · + Setup Guide · + Contributing · + Roadmap +

-![Odysseus](docs/odysseus.jpg) +

+ Packaging status +

-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. +

+ Odysseus interface +

-[![Packaging status](https://repology.org/badge/vertical-allrepos/odysseus-ai.svg)](https://repology.org/project/odysseus-ai/versions) - -## Features - - **Chat** -- chat with any local model or API; adding them is super simple.
 vLLM · llama.cpp · Ollama · OpenRouter · OpenAI · GitHub Copilot - - **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`). - -
-Screenshots / clips - -### 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, then configure models/search/email -inside **Settings**. Only edit `.env` for deployment-level overrides like -`APP_BIND`, `APP_PORT`, `AUTH_ENABLED`, `DATABASE_URL`, or a pre-seeded admin password. +> `dev` is the default branch and gets the newest changes first. Use [`main`](https://github.com/pewdiepie-archdaemon/odysseus/tree/main) if you want the more curated branch. -On first setup, Odysseus creates an admin account (`admin` unless -`ODYSSEUS_ADMIN_USER` is set) and prints a temporary password in the terminal. -For Docker installs, the same line is in `docker compose logs odysseus`. -Use that for the first login, then change it in **Settings**. - -Contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and -pull request guidelines. - -### Docker (recommended) ```bash git clone https://github.com/pewdiepie-archdaemon/odysseus.git cd odysseus -cp .env.example .env # optional, but recommended for explicit defaults +cp .env.example .env docker compose up -d --build ``` -To include optional extras in the image (PDF viewer, Office extraction; includes AGPL PyMuPDF), build with `docker compose build --build-arg INSTALL_OPTIONAL=true` before `up`. -Open `http://localhost:7000` when the containers are healthy. Docker Compose -binds the web UI to `127.0.0.1` by default. If the port is taken, set -`APP_PORT=7001` in `.env` and recreate the container. Set `APP_BIND=0.0.0.0` -only when you intentionally want LAN/reverse-proxy access. +Open `http://localhost:7000` when the containers are healthy. The first admin password is printed in `docker compose logs odysseus`. -> **On Apple Silicon (M-series) Macs:** Docker can't reach the Metal GPU, so -> Cookbook serves local models on CPU only. For GPU-accelerated model serving, -> run natively instead — see [Apple Silicon](#apple-silicon) below. +Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](docs/setup.md). -### Native Linux / macOS -```bash -git clone https://github.com/pewdiepie-archdaemon/odysseus.git -cd odysseus -python3 -m venv venv -source venv/bin/activate -pip install -r requirements.txt -python setup.py -python -m uvicorn app:app --host 127.0.0.1 --port 7000 -``` -Requirements: Python 3.11+. Cookbook also needs `tmux` for background model -downloads and serves. The app itself is lightweight; local model serving is the -heavy part and depends on the model, runtime, GPU, and VRAM, so small hosts can -connect to API or remote model servers instead. Use `--host 0.0.0.0` only when you intentionally want LAN/reverse-proxy access. +## Features -### Apple Silicon -Docker on macOS cannot use the Metal GPU. For GPU-accelerated Cookbook on an -M-series Mac, run Odysseus natively: +- **Chat + Agents** — local/API models, tools, MCP, files, shell, skills, and memory. +- **Cookbook** — hardware-aware model recommendations, downloads, and serving. +- **Deep Research** — multi-step web research with source reading and report generation. +- **Compare** — blind side-by-side model testing and synthesis. +- **Documents** — writing-first editor with AI edits, suggestions, Markdown, HTML, CSV, and syntax highlighting. +- **Email** — IMAP/SMTP inbox with triage, tags, summaries, reminders, and reply drafts. +- **Notes, Tasks + Calendar** — reminders, todos, scheduled agent tasks, and CalDAV sync. +- **Extras** — gallery/image editor, themes, uploads, web search, presets, sessions, and 2FA. -```bash -git clone https://github.com/pewdiepie-archdaemon/odysseus.git -cd odysseus -./start-macos.sh -``` +## Demo -It launches at `http://127.0.0.1:7860`. To expose it to your phone over a trusted LAN/VPN such as Tailscale, bind all interfaces: - -```bash -ODYSSEUS_HOST=0.0.0.0 ./start-macos.sh -# then open http://:7860 -``` - -The script also reads `.env` at startup, so `APP_BIND=0.0.0.0` and `APP_PORT` -set there are picked up automatically without a command-line override each run. - -Keep `AUTH_ENABLED=true` (the default) before binding outside loopback. Do not -expose this port directly to the public internet. To build a clickable app wrapper: - -```bash -./build-macos-app.sh -``` - -
-Cookbook, GPU, Ollama, and troubleshooting notes - -**Docker bundled services.** Compose starts Odysseus, ChromaDB, SearXNG, and -ntfy. Odysseus and the bundled service ports bind to `127.0.0.1` by default, so -they are reachable from the host but not exposed to your LAN/public internet -unless you opt in. - -**Cookbook storage in Docker.** Downloads live in `./data/huggingface` -(`~/.cache/huggingface` in the container). Cookbook-installed Python CLIs and -serve engines live in `./data/local` (`~/.local` in the container), so they -survive container recreation. - -**Remote servers.** In **Cookbook -> Settings -> Servers**, generate the -Odysseus SSH key and add the public key to the remote server's -`~/.ssh/authorized_keys`. From the host you can also run: - -```bash -ssh-copy-id -i data/ssh/id_ed25519.pub user@server -``` - -**Docker GPU overlays.** CPU-only users can skip this section. Cookbook can -only detect GPUs that Docker exposes to the container — if the host runtime or -device passthrough is not configured, Cookbook sees the iGPU, another card, or -CPU instead of your intended GPU. - -For NVIDIA, `scripts/check-docker-gpu.sh` diagnoses GPU passthrough and can -optionally install the host runtime or update `.env`. - -```bash -# Read-only diagnostic (default — installs nothing, never edits .env): -scripts/check-docker-gpu.sh - -# Print OS-specific install commands without running them: -scripts/check-docker-gpu.sh --print-install-commands - -# Install NVIDIA Container Toolkit on Ubuntu/Debian (requires sudo): -scripts/check-docker-gpu.sh --install-nvidia-toolkit - -# Write COMPOSE_FILE to .env (only when GPU passthrough is confirmed working): -scripts/check-docker-gpu.sh --enable-nvidia-overlay - -# Full assisted setup — install toolkit, then enable overlay if passthrough works: -scripts/check-docker-gpu.sh --install-nvidia-toolkit --enable-nvidia-overlay -``` - -Safety notes: -- The app never installs host GPU runtime automatically. -- The app never edits `.env` automatically. -- `.env` is only modified when `--enable-nvidia-overlay` is explicitly passed, - and only after GPU passthrough succeeds. `--yes` skips prompts but does not - bypass the passthrough gate. -- `.env.bak.*` backups created by `--enable-nvidia-overlay` are ignored by - Git and the Docker build context. - -To enable manually without the script, add this to `.env`: - -```bash -COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml -``` - -**AMD / ROCm.** AMD setup is read-only diagnostic plus manual `.env` edit. Run: - -```bash -scripts/check-docker-amd-gpu.sh -``` - -Then add the reported values to `.env`, replacing `RENDER_GID` with your host's -numeric render group id: - -```bash -COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml -RENDER_GID=989 -``` - -For NVIDIA/AMD GPU support, also read the comments in the selected overlay file: docker/gpu.nvidia.yml or docker/gpu.amd.yml. - -**Stack-management UIs (Portainer, Coolify, Dockhand, etc.).** These tools -often accept only a single Compose file and do not reliably honor `COMPOSE_FILE` -or multiple `-f` overlays. CLI users should keep using the `COMPOSE_FILE` -overlay workflow above. For stack UIs, point the stack at one of the standalone -files instead, which bundle the base stack plus the GPU settings: - -- `docker-compose.gpu-nvidia.yml` — still requires the NVIDIA Container Toolkit - on the host. -- `docker-compose.gpu-amd.yml` — still requires host ROCm/kfd/DRI setup, the - `video`/`render` group membership, and `RENDER_GID` when needed. - -The base `docker-compose.yml` plus the `docker/gpu.*.yml` overlays remain the -source of truth; the standalone files mirror them for single-file deployments. - -Verify after enabling either overlay: - -```bash -docker compose exec odysseus nvidia-smi -L # NVIDIA -docker compose exec odysseus sh -lc 'test -e /dev/kfd && test -d /dev/dri && ls -l /dev/kfd /dev/dri/renderD*' # AMD -``` - -> **GPU passthrough ≠ llama.cpp CUDA.** `nvidia-smi` passing inside the -> container confirms Docker GPU access, but llama.cpp also needs `cudart` and -> the CUDA Toolkit at runtime. If Cookbook logs show `Unable to find cudart -> library`, `Could NOT find CUDAToolkit`, `CUDA Toolkit not found`, or -> tensors/layers assigned to CPU, that is a Cookbook/llama.cpp build issue — -> not a Docker passthrough failure. Reinstall the serve engine via -> **Cookbook → Dependencies** to get a CUDA-enabled build. -> -> The same split applies to AMD/ROCm: seeing `/dev/kfd` and `/dev/dri` inside -> the container confirms device passthrough, not ROCm userspace or a -> ROCm-enabled vLLM/llama.cpp build. `rocm-smi` and `rocminfo` are not expected -> inside the slim Odysseus image. - -**Ollama with Docker.** If Ollama runs on the host, add this endpoint in -Settings: - -```text -http://host.docker.internal:11434/v1 -``` - -Ollama must listen outside its own loopback interface: - -```bash -OLLAMA_HOST=0.0.0.0:11434 ollama serve -``` - -This connects Odysseus in Docker to an Ollama server that is already running on -your host machine; it does not start Ollama inside the container. -`host.docker.internal` is Docker's hostname for the host machine from inside the -container. Cookbook **Serve** is a separate workflow for serving downloaded -models through Odysseus/llama.cpp, so Windows users with an existing Ollama -install usually only need to add the endpoint in Settings. - -**Useful checks.** - -```bash -docker compose ps -docker compose logs --tail=120 odysseus -docker compose logs odysseus | grep -E 'ChromaDB|MemoryVectorStore|DEGRADED' -``` - -**macOS details.** `start-macos.sh` installs Homebrew deps, creates the venv, -runs setup, and starts uvicorn on port `7860` because AirPlay often holds -`7000`. It uses llama.cpp/Ollama for Metal. vLLM/SGLang are CUDA/ROCm-only and -do not run on macOS. MLX-only models are not served by Odysseus. - -
- -### Native Windows - -**One-command launcher** (creates the venv, installs deps, runs setup, starts the -server; safe to re-run): - -```powershell -git clone https://github.com/pewdiepie-archdaemon/odysseus.git -cd odysseus -powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1 -``` - -Or do it by hand: - -```powershell -git clone https://github.com/pewdiepie-archdaemon/odysseus.git -cd odysseus -py -3.11 -m venv venv -venv\Scripts\Activate.ps1 -pip install -r requirements.txt -python setup.py -python -m uvicorn app:app --host 127.0.0.1 --port 7000 -``` - -If `python` points at an older interpreter, use `py -3.12` (or another installed -3.11+ version) for the venv step. - -**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents, -email, calendar, deep research) runs fully native. For full **Cookbook** background -model downloads and the agent shell tool, also install -[Git for Windows](https://git-scm.com/download/win) (provides `bash.exe`). -Local GPU *serving* of vLLM/SGLang needs Linux/WSL2; for a local model on Windows, -[Ollama](https://ollama.com/download) is the easiest path — point Odysseus at -`http://localhost:11434/v1` in Settings. - -Open `http://localhost:7000`, log in with the generated admin password, -and configure everything else inside **Settings**. - -## Troubleshooting & Advanced Setup - -### `chromadb-client` conflicts with embedded ChromaDB -If `chromadb-client` (the lightweight HTTP-only package) is installed alongside the full `chromadb` package, Odysseus starts but ChromaDB silently falls back to HTTP-only mode and fails. - -**Fix:** uninstall `chromadb-client` and force-reinstall the full package: -```bash -./venv/bin/pip uninstall chromadb-client -y -./venv/bin/pip install --force-reinstall chromadb -``` - -### HTTPS + LAN/Tailscale exposure -To expose Odysseus on a local network or Tailscale with HTTPS: -1. Change the bind address to `0.0.0.0` in `.env` (`APP_BIND=0.0.0.0` or `ODYSSEUS_HOST=0.0.0.0`). -2. Generate a locally-trusted cert for your LAN/Tailscale IPs using [mkcert](https://github.com/FiloSottile/mkcert): - ```bash - mkcert -install - mkcert -cert-file cert.pem -key-file key.pem 192.168.1.100 tailscale-ip - ``` -3. Run `uvicorn` with the generated certs: - ```bash - python -m uvicorn app:app --host 0.0.0.0 --port 7000 --ssl-certfile=cert.pem --ssl-keyfile=key.pem - ``` -4. Install the `mkcert` CA on any other device you want to access Odysseus from (e.g., for iOS, email the `rootCA.pem` to yourself, install the profile, and trust it in Certificate Trust Settings). - -### Optional Dependencies -`requirements-optional.txt` contains packages that unlock extra features. It is not installed by default. - -| Package | Feature unlocked | -|---------|-----------------| -| `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. | -| `ddgs` | DuckDuckGo as a search provider option. | -| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) | -| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). | - -### Faster, reproducible installs with uv (optional) -[uv](https://docs.astral.sh/uv/) works as a drop-in replacement for the -venv + pip steps in the native install guides, no project changes are needed but this change results in faster installs along with a lockfile for reproducible environments. After [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/), use: - -```bash -uv venv venv --python 3.13 -uv pip install -r requirements.txt -# then continue as usual: python setup.py, uvicorn, ... -``` - -`requirements.txt` is intentionally unpinned, so two installs at different times can produce different package versions. If you want a reproducible environment (e.g. across your own machines, or to roll back after a bad upgrade), snapshot and restore exact versions with: - -```bash -uv pip compile requirements.txt -o requirements.lock # snapshot current resolution -uv pip sync requirements.lock # reproduce it exactly later -``` - -`requirements.lock` is gitignored and platform-specific (compile it on the OS you deploy to). Regenerate it deliberately when you want to take upgrades. The plain `uv pip install -r requirements.txt` keeps following the unpinned requirements like pip does. - -### Outlook / Office 365 email -Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook -and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox -passwords will fail. See [docs/email-outlook.md](docs/email-outlook.md) for the -current limitation and the planned integration direction. - -## 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. -- Keep `LOCALHOST_BYPASS=false` outside local development. -- Use `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway. -- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer. -- Keep `.env`, `data/`, `logs/`, databases, uploads, generated media, backups, auth/session files, API keys, and model/provider tokens out of Git and private shares. 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. -- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. Expose only the authenticated Odysseus web/API entrypoint through your trusted proxy or private access layer. -- Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged. - -### Private or proxied deployments -Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and the bundled services to `127.0.0.1` by default, so a typical production/private setup is: - -1. Keep Odysseus on localhost, for example `127.0.0.1:7000`. -2. Terminate HTTPS at a trusted reverse proxy or private access gateway. -3. Put the authenticated Odysseus web/API entrypoint behind that layer. -4. Keep raw service and model ports internal-only. - -Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`. -`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry. - -Common internal-only ports from the default docs/compose setup: - -| Port | Service | -|---|---| -| `7000` | Odysseus raw app port | -| `8080` | SearXNG | -| `8091` | ntfy | -| `8100` | ChromaDB host port for manual/compose access | -| `11434` | Ollama | -| `8000-8020` | Common local model/provider APIs | +A full hover-to-play tour lives on the landing page: [`docs/index.html`](docs/index.html). ## 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: +Help is welcome. The best entry points are fresh-install testing, provider setup bugs, mobile/editor polish, docs, and small focused refactors. See [CONTRIBUTING.md](CONTRIBUTING.md) and [ROADMAP.md](ROADMAP.md). -| 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`. | -| `SEARXNG_SECRET` | generated on first Docker boot | Optional SearXNG cookie/CSRF secret. Leave blank unless you need to pin it. | -| `APP_BIND` | `127.0.0.1` | Docker Compose host bind address for the web UI. Use `0.0.0.0` only for intentional LAN/reverse-proxy access. | -| `APP_PORT` | `7000` | Docker Compose host port for the web UI. | -| `APP_DATA_DIR` | `./data` | Docker Compose host directory for application data volumes. | -| `APP_LOGS_DIR` | `./logs` | Docker Compose host directory for application logs. | -| `AUTH_ENABLED` | `true` | Enable/disable login | -| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. | -| `ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated exact permitted origins for cross-origin browser/API clients. | -| `SECURE_COOKIES` | `false` | Set true when serving Odysseus through HTTPS at a trusted proxy or private access gateway. | -| `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 | -| `ODYSSEUS_CHAT_UPLOAD_MAX_BYTES` | `10485760` | Chat/agent attachment cap in bytes. Raise for larger local PDFs or text documents. | -| `ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES` | `104857600` | Gallery image upload cap in bytes (100 MB). | -| `ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES` | `26214400` | Gallery transform input cap in bytes (25 MB). | -| `ODYSSEUS_MEMORY_IMPORT_MAX_BYTES` | `10485760` | Memory import file cap in bytes (10 MB). | -| `ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES` | `26214400` | Personal document upload cap in bytes (25 MB). | -| `ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES` | `26214400` | Email compose attachment cap in bytes (25 MB). | -| `ODYSSEUS_STT_MAX_AUDIO_BYTES` | `26214400` | Speech-to-text audio cap in bytes (25 MB). | -| `ODYSSEUS_ICS_MAX_BYTES` | `10485760` | Calendar `.ics` import cap in bytes (10 MB). | +## Security -All upload-limit vars are validated (must be a positive integer) and optional; an invalid value fails fast at startup. - -### Built-in MCP servers (optional setup) - -Odysseus auto-registers a few built-in MCP servers at startup. The npx-based ones (currently the browser server, `@playwright/mcp`) only start when their npm package is already in the local npx cache. If a package isn't cached, that server is skipped with a startup log message explaining what to do, so a fresh install does not block on a multi-minute npm download or hang if Playwright system deps are missing. - -To enable the browser MCP (page navigation, screenshots, vision), run once: - -```bash -npx -y @playwright/mcp@latest --version -``` - -That installs `@playwright/mcp` plus Playwright (~300MB total). Restart Odysseus and the server will register at startup. - -## 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`. - -To back up or restore everything in `data/`, see the -[Backup & Restore guide](docs/backup-restore.md). +Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly. Deployment details are in the [setup guide](docs/setup.md#security-notes). ## Star History @@ -483,19 +72,5 @@ To back up or restore everything in `data/`, see the ## License -AGPL-3.0-or-later -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md). -``` - | - ||| - ||||| - | | | ||||||| - )_) )_) )_) ~|~ - )___))___))___)\ | - )____)____)_____)\\| - _____|____|____|_____\\\__ - \ / - ~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~ - ~^~ all aboard! ~^~ - ~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~ -``` +AGPL-3.0-or-later -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md). diff --git a/docs/odysseus-wordmark.png b/docs/odysseus-wordmark.png new file mode 100644 index 0000000000000000000000000000000000000000..dce21eb660d1a77868c4872dda95a021645f958c GIT binary patch literal 16877 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 literal 0 HcmV?d00001 diff --git a/docs/odysseus.jpg b/docs/odysseus.jpg index 982a00f77df6ad3e9bc2f703a74eda677d3bd9b8..7a70bc5fc5c126f1242acb65bb7956c877152ada 100644 GIT binary patch literal 53198 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 qX!;pVKZB`7FtXe-TKR%|0V#;foA0Z diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 000000000..c809dc66f --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,425 @@ +# Odysseus Setup Guide + +This page keeps the detailed install, deployment, troubleshooting, and configuration notes out of the front README. + +## Quick Start + +> **Branch note:** `dev` is the default branch and contains the latest development changes, but it may be unstable. For the more stable curated branch, use [`main`](https://github.com/pewdiepie-archdaemon/odysseus/tree/main). + +Defaults work out of the box: clone, run, then configure models/search/email +inside **Settings**. Only edit `.env` for deployment-level overrides like +`APP_BIND`, `APP_PORT`, `AUTH_ENABLED`, `DATABASE_URL`, or a pre-seeded admin password. + +On first setup, Odysseus creates an admin account (`admin` unless +`ODYSSEUS_ADMIN_USER` is set) and prints a temporary password in the terminal. +For Docker installs, the same line is in `docker compose logs odysseus`. +Use that for the first login, then change it in **Settings**. + +Contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and +pull request guidelines. + +### Docker (recommended) +```bash +git clone https://github.com/pewdiepie-archdaemon/odysseus.git +cd odysseus +cp .env.example .env # optional, but recommended for explicit defaults +docker compose up -d --build +``` +To include optional extras in the image (PDF viewer, Office extraction; includes AGPL PyMuPDF), build with `docker compose build --build-arg INSTALL_OPTIONAL=true` before `up`. + +Open `http://localhost:7000` when the containers are healthy. Docker Compose +binds the web UI to `127.0.0.1` by default. If the port is taken, set +`APP_PORT=7001` in `.env` and recreate the container. Set `APP_BIND=0.0.0.0` +only when you intentionally want LAN/reverse-proxy access. + +> **On Apple Silicon (M-series) Macs:** Docker can't reach the Metal GPU, so +> Cookbook serves local models on CPU only. For GPU-accelerated model serving, +> run natively instead — see [Apple Silicon](#apple-silicon) below. + +### Native Linux / macOS +```bash +git clone https://github.com/pewdiepie-archdaemon/odysseus.git +cd odysseus +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +python setup.py +python -m uvicorn app:app --host 127.0.0.1 --port 7000 +``` +Requirements: Python 3.11+. Cookbook also needs `tmux` for background model +downloads and serves. The app itself is lightweight; local model serving is the +heavy part and depends on the model, runtime, GPU, and VRAM, so small hosts can +connect to API or remote model servers instead. Use `--host 0.0.0.0` only when you intentionally want LAN/reverse-proxy access. + +### Apple Silicon +Docker on macOS cannot use the Metal GPU. For GPU-accelerated Cookbook on an +M-series Mac, run Odysseus natively: + +```bash +git clone https://github.com/pewdiepie-archdaemon/odysseus.git +cd odysseus +./start-macos.sh +``` + +It launches at `http://127.0.0.1:7860`. To expose it to your phone over a trusted LAN/VPN such as Tailscale, bind all interfaces: + +```bash +ODYSSEUS_HOST=0.0.0.0 ./start-macos.sh +# then open http://:7860 +``` + +The script also reads `.env` at startup, so `APP_BIND=0.0.0.0` and `APP_PORT` +set there are picked up automatically without a command-line override each run. + +Keep `AUTH_ENABLED=true` (the default) before binding outside loopback. Do not +expose this port directly to the public internet. To build a clickable app wrapper: + +```bash +./build-macos-app.sh +``` + +
+Cookbook, GPU, Ollama, and troubleshooting notes + +**Docker bundled services.** Compose starts Odysseus, ChromaDB, SearXNG, and +ntfy. Odysseus and the bundled service ports bind to `127.0.0.1` by default, so +they are reachable from the host but not exposed to your LAN/public internet +unless you opt in. + +**Cookbook storage in Docker.** Downloads live in `./data/huggingface` +(`~/.cache/huggingface` in the container). Cookbook-installed Python CLIs and +serve engines live in `./data/local` (`~/.local` in the container), so they +survive container recreation. + +**Remote servers.** In **Cookbook -> Settings -> Servers**, generate the +Odysseus SSH key and add the public key to the remote server's +`~/.ssh/authorized_keys`. From the host you can also run: + +```bash +ssh-copy-id -i data/ssh/id_ed25519.pub user@server +``` + +**Docker GPU overlays.** CPU-only users can skip this section. Cookbook can +only detect GPUs that Docker exposes to the container — if the host runtime or +device passthrough is not configured, Cookbook sees the iGPU, another card, or +CPU instead of your intended GPU. + +For NVIDIA, `scripts/check-docker-gpu.sh` diagnoses GPU passthrough and can +optionally install the host runtime or update `.env`. + +```bash +# Read-only diagnostic (default — installs nothing, never edits .env): +scripts/check-docker-gpu.sh + +# Print OS-specific install commands without running them: +scripts/check-docker-gpu.sh --print-install-commands + +# Install NVIDIA Container Toolkit on Ubuntu/Debian (requires sudo): +scripts/check-docker-gpu.sh --install-nvidia-toolkit + +# Write COMPOSE_FILE to .env (only when GPU passthrough is confirmed working): +scripts/check-docker-gpu.sh --enable-nvidia-overlay + +# Full assisted setup — install toolkit, then enable overlay if passthrough works: +scripts/check-docker-gpu.sh --install-nvidia-toolkit --enable-nvidia-overlay +``` + +Safety notes: +- The app never installs host GPU runtime automatically. +- The app never edits `.env` automatically. +- `.env` is only modified when `--enable-nvidia-overlay` is explicitly passed, + and only after GPU passthrough succeeds. `--yes` skips prompts but does not + bypass the passthrough gate. +- `.env.bak.*` backups created by `--enable-nvidia-overlay` are ignored by + Git and the Docker build context. + +To enable manually without the script, add this to `.env`: + +```bash +COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml +``` + +**AMD / ROCm.** AMD setup is read-only diagnostic plus manual `.env` edit. Run: + +```bash +scripts/check-docker-amd-gpu.sh +``` + +Then add the reported values to `.env`, replacing `RENDER_GID` with your host's +numeric render group id: + +```bash +COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml +RENDER_GID=989 +``` + +For NVIDIA/AMD GPU support, also read the comments in the selected overlay file: docker/gpu.nvidia.yml or docker/gpu.amd.yml. + +**Stack-management UIs (Portainer, Coolify, Dockhand, etc.).** These tools +often accept only a single Compose file and do not reliably honor `COMPOSE_FILE` +or multiple `-f` overlays. CLI users should keep using the `COMPOSE_FILE` +overlay workflow above. For stack UIs, point the stack at one of the standalone +files instead, which bundle the base stack plus the GPU settings: + +- `docker-compose.gpu-nvidia.yml` — still requires the NVIDIA Container Toolkit + on the host. +- `docker-compose.gpu-amd.yml` — still requires host ROCm/kfd/DRI setup, the + `video`/`render` group membership, and `RENDER_GID` when needed. + +The base `docker-compose.yml` plus the `docker/gpu.*.yml` overlays remain the +source of truth; the standalone files mirror them for single-file deployments. + +Verify after enabling either overlay: + +```bash +docker compose exec odysseus nvidia-smi -L # NVIDIA +docker compose exec odysseus sh -lc 'test -e /dev/kfd && test -d /dev/dri && ls -l /dev/kfd /dev/dri/renderD*' # AMD +``` + +> **GPU passthrough ≠ llama.cpp CUDA.** `nvidia-smi` passing inside the +> container confirms Docker GPU access, but llama.cpp also needs `cudart` and +> the CUDA Toolkit at runtime. If Cookbook logs show `Unable to find cudart +> library`, `Could NOT find CUDAToolkit`, `CUDA Toolkit not found`, or +> tensors/layers assigned to CPU, that is a Cookbook/llama.cpp build issue — +> not a Docker passthrough failure. Reinstall the serve engine via +> **Cookbook → Dependencies** to get a CUDA-enabled build. +> +> The same split applies to AMD/ROCm: seeing `/dev/kfd` and `/dev/dri` inside +> the container confirms device passthrough, not ROCm userspace or a +> ROCm-enabled vLLM/llama.cpp build. `rocm-smi` and `rocminfo` are not expected +> inside the slim Odysseus image. + +**Ollama with Docker.** If Ollama runs on the host, add this endpoint in +Settings: + +```text +http://host.docker.internal:11434/v1 +``` + +Ollama must listen outside its own loopback interface: + +```bash +OLLAMA_HOST=0.0.0.0:11434 ollama serve +``` + +This connects Odysseus in Docker to an Ollama server that is already running on +your host machine; it does not start Ollama inside the container. +`host.docker.internal` is Docker's hostname for the host machine from inside the +container. Cookbook **Serve** is a separate workflow for serving downloaded +models through Odysseus/llama.cpp, so Windows users with an existing Ollama +install usually only need to add the endpoint in Settings. + +**Useful checks.** + +```bash +docker compose ps +docker compose logs --tail=120 odysseus +docker compose logs odysseus | grep -E 'ChromaDB|MemoryVectorStore|DEGRADED' +``` + +**macOS details.** `start-macos.sh` installs Homebrew deps, creates the venv, +runs setup, and starts uvicorn on port `7860` because AirPlay often holds +`7000`. It uses llama.cpp/Ollama for Metal. vLLM/SGLang are CUDA/ROCm-only and +do not run on macOS. MLX-only models are not served by Odysseus. + +
+ +### Native Windows + +**One-command launcher** (creates the venv, installs deps, runs setup, starts the +server; safe to re-run): + +```powershell +git clone https://github.com/pewdiepie-archdaemon/odysseus.git +cd odysseus +powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1 +``` + +Or do it by hand: + +```powershell +git clone https://github.com/pewdiepie-archdaemon/odysseus.git +cd odysseus +py -3.11 -m venv venv +venv\Scripts\Activate.ps1 +pip install -r requirements.txt +python setup.py +python -m uvicorn app:app --host 127.0.0.1 --port 7000 +``` + +If `python` points at an older interpreter, use `py -3.12` (or another installed +3.11+ version) for the venv step. + +**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents, +email, calendar, deep research) runs fully native. For full **Cookbook** background +model downloads and the agent shell tool, also install +[Git for Windows](https://git-scm.com/download/win) (provides `bash.exe`). +Local GPU *serving* of vLLM/SGLang needs Linux/WSL2; for a local model on Windows, +[Ollama](https://ollama.com/download) is the easiest path — point Odysseus at +`http://localhost:11434/v1` in Settings. + +Open `http://localhost:7000`, log in with the generated admin password, +and configure everything else inside **Settings**. + +## Troubleshooting & Advanced Setup + +### `chromadb-client` conflicts with embedded ChromaDB +If `chromadb-client` (the lightweight HTTP-only package) is installed alongside the full `chromadb` package, Odysseus starts but ChromaDB silently falls back to HTTP-only mode and fails. + +**Fix:** uninstall `chromadb-client` and force-reinstall the full package: +```bash +./venv/bin/pip uninstall chromadb-client -y +./venv/bin/pip install --force-reinstall chromadb +``` + +### HTTPS + LAN/Tailscale exposure +To expose Odysseus on a local network or Tailscale with HTTPS: +1. Change the bind address to `0.0.0.0` in `.env` (`APP_BIND=0.0.0.0` or `ODYSSEUS_HOST=0.0.0.0`). +2. Generate a locally-trusted cert for your LAN/Tailscale IPs using [mkcert](https://github.com/FiloSottile/mkcert): + ```bash + mkcert -install + mkcert -cert-file cert.pem -key-file key.pem 192.168.1.100 tailscale-ip + ``` +3. Run `uvicorn` with the generated certs: + ```bash + python -m uvicorn app:app --host 0.0.0.0 --port 7000 --ssl-certfile=cert.pem --ssl-keyfile=key.pem + ``` +4. Install the `mkcert` CA on any other device you want to access Odysseus from (e.g., for iOS, email the `rootCA.pem` to yourself, install the profile, and trust it in Certificate Trust Settings). + +### Optional Dependencies +`requirements-optional.txt` contains packages that unlock extra features. It is not installed by default. + +| Package | Feature unlocked | +|---------|-----------------| +| `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. | +| `ddgs` | DuckDuckGo as a search provider option. | +| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) | +| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). | + +### Faster, reproducible installs with uv (optional) +[uv](https://docs.astral.sh/uv/) works as a drop-in replacement for the +venv + pip steps in the native install guides, no project changes are needed but this change results in faster installs along with a lockfile for reproducible environments. After [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/), use: + +```bash +uv venv venv --python 3.13 +uv pip install -r requirements.txt +# then continue as usual: python setup.py, uvicorn, ... +``` + +`requirements.txt` is intentionally unpinned, so two installs at different times can produce different package versions. If you want a reproducible environment (e.g. across your own machines, or to roll back after a bad upgrade), snapshot and restore exact versions with: + +```bash +uv pip compile requirements.txt -o requirements.lock # snapshot current resolution +uv pip sync requirements.lock # reproduce it exactly later +``` + +`requirements.lock` is gitignored and platform-specific (compile it on the OS you deploy to). Regenerate it deliberately when you want to take upgrades. The plain `uv pip install -r requirements.txt` keeps following the unpinned requirements like pip does. + +### Outlook / Office 365 email +Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook +and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox +passwords will fail. See [docs/email-outlook.md](docs/email-outlook.md) for the +current limitation and the planned integration direction. + +## 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. +- Keep `LOCALHOST_BYPASS=false` outside local development. +- Use `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway. +- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer. +- Keep `.env`, `data/`, `logs/`, databases, uploads, generated media, backups, auth/session files, API keys, and model/provider tokens out of Git and private shares. 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. +- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. Expose only the authenticated Odysseus web/API entrypoint through your trusted proxy or private access layer. +- Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged. + +### Private or proxied deployments +Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and the bundled services to `127.0.0.1` by default, so a typical production/private setup is: + +1. Keep Odysseus on localhost, for example `127.0.0.1:7000`. +2. Terminate HTTPS at a trusted reverse proxy or private access gateway. +3. Put the authenticated Odysseus web/API entrypoint behind that layer. +4. Keep raw service and model ports internal-only. + +Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`. +`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry. + +Common internal-only ports from the default docs/compose setup: + +| Port | Service | +|---|---| +| `7000` | Odysseus raw app port | +| `8080` | SearXNG | +| `8091` | ntfy | +| `8100` | ChromaDB host port for manual/compose access | +| `11434` | Ollama | +| `8000-8020` | Common local model/provider APIs | + +## 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`. | +| `SEARXNG_SECRET` | generated on first Docker boot | Optional SearXNG cookie/CSRF secret. Leave blank unless you need to pin it. | +| `APP_BIND` | `127.0.0.1` | Docker Compose host bind address for the web UI. Use `0.0.0.0` only for intentional LAN/reverse-proxy access. | +| `APP_PORT` | `7000` | Docker Compose host port for the web UI. | +| `APP_DATA_DIR` | `./data` | Docker Compose host directory for application data volumes. | +| `APP_LOGS_DIR` | `./logs` | Docker Compose host directory for application logs. | +| `AUTH_ENABLED` | `true` | Enable/disable login | +| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. | +| `ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated exact permitted origins for cross-origin browser/API clients. | +| `SECURE_COOKIES` | `false` | Set true when serving Odysseus through HTTPS at a trusted proxy or private access gateway. | +| `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 | +| `ODYSSEUS_CHAT_UPLOAD_MAX_BYTES` | `10485760` | Chat/agent attachment cap in bytes. Raise for larger local PDFs or text documents. | +| `ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES` | `104857600` | Gallery image upload cap in bytes (100 MB). | +| `ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES` | `26214400` | Gallery transform input cap in bytes (25 MB). | +| `ODYSSEUS_MEMORY_IMPORT_MAX_BYTES` | `10485760` | Memory import file cap in bytes (10 MB). | +| `ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES` | `26214400` | Personal document upload cap in bytes (25 MB). | +| `ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES` | `26214400` | Email compose attachment cap in bytes (25 MB). | +| `ODYSSEUS_STT_MAX_AUDIO_BYTES` | `26214400` | Speech-to-text audio cap in bytes (25 MB). | +| `ODYSSEUS_ICS_MAX_BYTES` | `10485760` | Calendar `.ics` import cap in bytes (10 MB). | + +All upload-limit vars are validated (must be a positive integer) and optional; an invalid value fails fast at startup. + +### Built-in MCP servers (optional setup) + +Odysseus auto-registers a few built-in MCP servers at startup. The npx-based ones (currently the browser server, `@playwright/mcp`) only start when their npm package is already in the local npx cache. If a package isn't cached, that server is skipped with a startup log message explaining what to do, so a fresh install does not block on a multi-minute npm download or hang if Playwright system deps are missing. + +To enable the browser MCP (page navigation, screenshots, vision), run once: + +```bash +npx -y @playwright/mcp@latest --version +``` + +That installs `@playwright/mcp` plus Playwright (~300MB total). Restart Odysseus and the server will register at startup. + +## 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`. + +To back up or restore everything in `data/`, see the +[Backup & Restore guide](docs/backup-restore.md). From b3e186746ac5052728058b332b36720d061d04d3 Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 19 Jun 2026 00:32:47 +0000 Subject: [PATCH 02/22] Docker compose: mount docker.sock + install Docker CLI so Cookbook can reach sibling containers Cookbook now needs to docker-exec into ollama-rocm (and any other sibling container holding a model server) from inside its own container, so: - Dockerfile installs the Docker CLI from the static binary tarball (the Debian docker.io package ships dockerd but not the client on slim) - docker-compose.yml bind-mounts /var/run/docker.sock and adds group_add for the host docker group (default GID 963) - entrypoint.sh detects the socket GID, creates a local group with that GID, and runs usermod -aG before gosu-dropping to the app user so the supplementary group propagates through (gosu strips by default) --- Dockerfile | 17 +++++++++++++++++ docker-compose.yml | 10 ++++++++++ docker/entrypoint.sh | 33 +++++++++++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 996e06faa..bed5e2002 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,23 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ gosu \ && rm -rf /var/lib/apt/lists/* +# Docker CLI (client only — daemon stays on the host via the +# /var/run/docker.sock mount). The Debian `docker.io` package ships +# dockerd but not the client binary on slim, so grab the static client +# tarball from download.docker.com instead. +ARG DOCKER_CLI_VERSION=27.5.1 +RUN ARCH="$(dpkg --print-architecture)" \ + && case "$ARCH" in \ + amd64) DARCH=x86_64 ;; \ + arm64) DARCH=aarch64 ;; \ + *) echo "unsupported arch $ARCH"; exit 1 ;; \ + esac \ + && curl -fsSL "https://download.docker.com/linux/static/stable/${DARCH}/docker-${DOCKER_CLI_VERSION}.tgz" \ + -o /tmp/docker.tgz \ + && tar -xzf /tmp/docker.tgz -C /tmp \ + && install -m 0755 /tmp/docker/docker /usr/local/bin/docker \ + && rm -rf /tmp/docker /tmp/docker.tgz + WORKDIR /app # Install Python deps first (layer cache). Optional extras (PyMuPDF AGPL, etc.) diff --git a/docker-compose.yml b/docker-compose.yml index 0b350c2e1..dd708303f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,16 @@ services: # land under /app/.local for the odysseus user. Persist them so a # container recreate does not silently remove installed serve engines. - ${APP_DATA_DIR:-./data}/local:/app/.local:z + # Docker socket — lets Cookbook launch commands like + # `docker exec ollama-rocm ollama show ` reach the host's + # Docker daemon (and sibling containers like ollama-rocm / + # ollama-test). The in-container user needs to be in the + # socket's owning group — see `group_add` below; the GID + # there must match the host's `docker` group (defaults to 963 + # on Debian, 999 on Ubuntu — override via env if yours differs). + - /var/run/docker.sock:/var/run/docker.sock + group_add: + - "${DOCKER_GID:-963}" extra_hosts: # Lets the container reach local services on the Docker host, including # Ollama at http://host.docker.internal:11434. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 668018ac1..7d796d9ff 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -24,6 +24,31 @@ if ! getent passwd "$PUID" >/dev/null 2>&1; then useradd -u "$PUID" -g "$PGID" -M -s /bin/sh -d /app odysseus fi +# Docker-socket group plumbing. When /var/run/docker.sock is bind- +# mounted (cookbook uses `docker exec` to reach sibling containers +# like ollama-rocm), the socket is owned by root:docker on the host. +# We need the in-container odysseus user to be in the matching group +# so `gosu PUID:PGID` doesn't strip it. compose's `group_add` only +# applies to the initial root process — gosu drop resets supplementary +# groups — so detect the socket's GID here and add the user via the +# username form `gosu odysseus` below. +DOCKER_SOCK="${DOCKER_SOCK:-/var/run/docker.sock}" +if [ -S "$DOCKER_SOCK" ]; then + SOCK_GID="$(stat -c '%g' "$DOCKER_SOCK" 2>/dev/null || echo '')" + if [ -n "$SOCK_GID" ] && [ "$SOCK_GID" != "0" ]; then + # Create the group locally if missing, then add odysseus to it. + if ! getent group "$SOCK_GID" >/dev/null 2>&1; then + groupadd -g "$SOCK_GID" docker_host || true + fi + SOCK_GROUP="$(getent group "$SOCK_GID" | cut -d: -f1)" + if [ -n "$SOCK_GROUP" ]; then + ODY_USER="$(getent passwd "$PUID" | cut -d: -f1)" + [ -z "$ODY_USER" ] && ODY_USER=odysseus + usermod -aG "$SOCK_GROUP" "$ODY_USER" 2>/dev/null || true + fi + fi +fi + # Repair ownership on every writable path the app touches at runtime. # # Bind-mounted dirs (/app/data, /app/logs) are the obvious ones, but @@ -83,9 +108,13 @@ export PATH="/app/.local/bin:$PATH" # Run first-time setup as the app user so data/ files get the right ownership. # setup.py is idempotent — skips auth.json / .env if they already exist. # || true so a setup failure never prevents the container from starting. -gosu "$PUID:$PGID" python /app/setup.py || true +# Use the username form (no :GID) so supplementary groups from /etc/group +# (including the docker-socket group set above) flow through to the child. +ODY_USER="$(getent passwd "$PUID" | cut -d: -f1)" +[ -z "$ODY_USER" ] && ODY_USER="$PUID:$PGID" +gosu "$ODY_USER" python /app/setup.py || true # Drop root and run the actual app. `gosu` is preferred over `su` / # `sudo` because it cleans up the process tree (no extra shell layer) # so signals (SIGTERM from `docker stop`) reach uvicorn directly. -exec gosu "$PUID:$PGID" "$@" +exec gosu "$ODY_USER" "$@" From 1324e1b0d593bb69af965084782c1cffc3b35623 Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 19 Jun 2026 00:33:07 +0000 Subject: [PATCH 03/22] Cookbook backend detection: report Vulkan on AMD hosts without ROCm; gate CUDA build on actual NVIDIA hardware Three classes of incorrect detection fixed: (1) AMD GPU + no ROCm installed (e.g. Strix Halo) was reported as backend=rocm everywhere, so launch commands emitted HIP_VISIBLE_DEVICES (silent no-op on Vulkan) and the from-source build path failed. Both _probe_amd_sysfs (routes/cookbook_routes) and _detect_amd (services/hwfit/hardware) now probe rocminfo / hipconfig / vulkaninfo at detection time and report vulkan when only Vulkan is present. (2) Build helper was picking the CUDA branch on AMD hosts whenever a stray pip-installed nvcc was on PATH (vLLM wheels carry one without libcudart). Added _odysseus_has_nvidia_hw() that checks nvidia-smi / /dev/nvidia* / lspci, and gates both the nvcc PATH augmentation and the CUDA elif branch on real hardware. (3) Build chain reordered to ROCm/HIP > CUDA > Vulkan > CPU. Vulkan tier added between CUDA and CPU as a portable fallback for hosts with a GPU but no native toolchain (the common Strix Halo case). Same _append_llama_cpp_linux_accel_build_lines also auto-attempts sudo -n apt/pacman/dnf install of cmake/build-essential/git when they are missing, surfacing a clear no-passwordless-sudo warning otherwise. --- routes/cookbook_helpers.py | 177 ++++++++++++++++++++++++++++++++++--- routes/cookbook_routes.py | 124 ++++++++++++++++++++++++-- services/hwfit/hardware.py | 12 ++- 3 files changed, 293 insertions(+), 20 deletions(-) diff --git a/routes/cookbook_helpers.py b/routes/cookbook_helpers.py index bb819f3f8..3600a9ad1 100644 --- a/routes/cookbook_helpers.py +++ b/routes/cookbook_helpers.py @@ -784,25 +784,149 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None: to hard-wire CUDA on Linux. That made ROCm hosts attempt a CUDA configure and fail with "CUDA Toolkit not found" instead of building with HIP. """ + # Try a prebuilt binary from llama.cpp's GitHub releases FIRST — no + # cmake/build-essential/git/CUDA-headers needed at all. The from-source + # build below stays as a fallback (custom flags, esoteric arch, no + # internet, etc). 30 seconds vs 5+ minutes of compile, and removes + # every OS-package dep from the launch path. Sets _odysseus_have_prebuilt=1 + # on success; the existing build-tier if/elif chain below is gated on + # that variable so we never compile twice or shadow the prebuilt symlink. + runner_lines.append(' _odysseus_have_prebuilt=""') + runner_lines.append(' _odysseus_arch="$(uname -m)"') + runner_lines.append(' _odysseus_prebuilt_url=""') + runner_lines.append(' if command -v curl >/dev/null 2>&1 && [ "$_odysseus_arch" = "x86_64" ]; then') + runner_lines.append(' _odysseus_pat=""') + runner_lines.append(' _odysseus_has_nv_inline() { command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L 2>/dev/null | grep -q "GPU "; }') + runner_lines.append(' _odysseus_has_vk_inline() { ldconfig -p 2>/dev/null | grep -q "libvulkan\\.so" || command -v vulkaninfo >/dev/null 2>&1 || [ -e /usr/lib/x86_64-linux-gnu/libvulkan.so.1 ]; }') + runner_lines.append(' _odysseus_has_vkdev_inline() { ls /dev/dri/renderD* >/dev/null 2>&1 || (lspci 2>/dev/null | grep -Ei \'VGA|3D|Display\' | grep -Eiq \'AMD|ATI|Radeon\'); }') + runner_lines.append(' if _odysseus_has_nv_inline; then') + runner_lines.append(' _odysseus_pat="ubuntu.*cuda"') + runner_lines.append(' elif _odysseus_has_vkdev_inline && _odysseus_has_vk_inline; then') + runner_lines.append(' _odysseus_pat="ubuntu.*vulkan"') + runner_lines.append(' else') + runner_lines.append(' _odysseus_pat="ubuntu-x64\\\\.zip"') + runner_lines.append(' fi') + runner_lines.append(' _odysseus_prebuilt_url="$(curl -fsSL --max-time 15 https://api.github.com/repos/ggml-org/llama.cpp/releases/latest 2>/dev/null | grep \'"browser_download_url"\' | cut -d\'"\' -f4 | grep -iE "$_odysseus_pat" | grep -iv "arm\\|aarch64" | head -1)"') + runner_lines.append(' fi') + # Accept any of unzip / bsdtar / python3 -m zipfile as the extractor. + # python3 is essentially always present on modern Linux, so this lets + # the prebuilt path work on minimal Ubuntu installs that lack `unzip`. + runner_lines.append(' if [ -n "$_odysseus_prebuilt_url" ] && (command -v unzip >/dev/null 2>&1 || command -v bsdtar >/dev/null 2>&1 || command -v python3 >/dev/null 2>&1); then') + runner_lines.append(' echo "[odysseus] Found prebuilt llama-server: $_odysseus_prebuilt_url"') + runner_lines.append(' mkdir -p ~/bin "$HOME/.cache/odysseus/llama-cpp-prebuilt" && cd "$HOME/.cache/odysseus/llama-cpp-prebuilt"') + runner_lines.append(' rm -f llama-cpp.zip') + runner_lines.append(' if curl -fsSL --max-time 120 "$_odysseus_prebuilt_url" -o llama-cpp.zip && [ -s llama-cpp.zip ]; then') + runner_lines.append(' rm -rf build && mkdir -p build') + runner_lines.append(' if command -v unzip >/dev/null 2>&1; then unzip -qq -o llama-cpp.zip -d build; elif command -v bsdtar >/dev/null 2>&1; then bsdtar -xf llama-cpp.zip -C build; else python3 -c "import zipfile; zipfile.ZipFile(\\"llama-cpp.zip\\").extractall(\\"build\\")"; fi') + runner_lines.append(' _odysseus_extracted="$(find build -type f -name llama-server 2>/dev/null | head -1)"') + runner_lines.append(' if [ -n "$_odysseus_extracted" ]; then') + runner_lines.append(' chmod +x "$_odysseus_extracted"') + runner_lines.append(' ln -sf "$_odysseus_extracted" ~/bin/llama-server') + runner_lines.append(' _odysseus_libdir="$(dirname "$_odysseus_extracted")"') + runner_lines.append(' mkdir -p ~/.config && echo "export LD_LIBRARY_PATH=\\"$_odysseus_libdir:\\${LD_LIBRARY_PATH:-}\\"" > ~/.config/odysseus-llama-cpp-env') + runner_lines.append(' _odysseus_have_prebuilt=1') + runner_lines.append(' echo "[odysseus] Prebuilt llama-server installed at $_odysseus_extracted"') + runner_lines.append(' fi') + runner_lines.append(' fi') + runner_lines.append(' [ -z "$_odysseus_have_prebuilt" ] && echo "[odysseus] Prebuilt download/extract failed — falling back to from-source build."') + runner_lines.append(' elif [ -z "$_odysseus_prebuilt_url" ]; then') + runner_lines.append(' echo "[odysseus] No matching prebuilt llama-server for this host (arch=$_odysseus_arch) — will build from source."') + runner_lines.append(' fi') + runner_lines.append(' if [ -z "$_odysseus_have_prebuilt" ]; then') # Detect pip-installed nvcc (from vLLM/nvidia CUDA wheels) and put it on PATH - # so cmake's CUDA configure can find it. We keep this after the ROCm/HIP - # check — a machine with both stacks should honor the native HIP toolchain on - # AMD hosts instead of accidentally preferring a stray nvcc wheel. - runner_lines.append(' for _cudir in ~/.local/lib/python*/site-packages/nvidia/cu13 ~/.local/lib/python*/site-packages/nvidia/cu12 ~/.local/lib/python*/site-packages/nvidia/cuda_nvcc; do') - runner_lines.append(' [ -x "$_cudir/bin/nvcc" ] && export CUDA_HOME="$_cudir" && export PATH="$_cudir/bin:$PATH" && break') - runner_lines.append(' done') + # so cmake's CUDA configure can find it — BUT only when actual NVIDIA + # hardware is present. On AMD/Intel hosts the pip nvcc is a misleading + # leftover (no libcudart, no GPU it could target) and would otherwise + # send the build down the CUDA branch and fail with "CUDA Toolkit not + # found" instead of trying Vulkan. + runner_lines.append(' _odysseus_has_nvidia_hw() {') + runner_lines.append(' command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L 2>/dev/null | grep -q "GPU " && return 0') + runner_lines.append(' ls /dev/nvidia* >/dev/null 2>&1 && return 0') + runner_lines.append(' lspci 2>/dev/null | grep -iE \'VGA|3D|Display\' | grep -iq nvidia && return 0') + runner_lines.append(' return 1') + runner_lines.append(' }') + runner_lines.append(' if _odysseus_has_nvidia_hw; then') + runner_lines.append(' for _cudir in ~/.local/lib/python*/site-packages/nvidia/cu13 ~/.local/lib/python*/site-packages/nvidia/cu12 ~/.local/lib/python*/site-packages/nvidia/cuda_nvcc; do') + runner_lines.append(' [ -x "$_cudir/bin/nvcc" ] && export CUDA_HOME="$_cudir" && export PATH="$_cudir/bin:$PATH" && break') + runner_lines.append(' done') + runner_lines.append(' fi') # rm -rf build so a prior poisoned CMakeCache.txt (e.g. from a failed CUDA # or HIP attempt) doesn't cause the next configure to reuse stale settings. runner_lines.append(' mkdir -p ~/bin') - runner_lines.append(' cd ~/llama.cpp && rm -rf build') + # Try to install cmake / build-essential / git automatically before the + # build, but ONLY via passwordless sudo (`sudo -n`) — interactive sudo + # would hang a tmux-backgrounded serve task waiting for a password. If + # sudo asks for a password the install is skipped silently and the + # diagnosis pattern (cookbook_routes.py / cookbook_helpers.py) surfaces + # an explicit "install cmake" suggestion in the Cookbook diagnosis + # toolbar after the inevitable build failure. + runner_lines.append(' _odysseus_apt_bootstrap() {') + runner_lines.append(' local _missing=""') + runner_lines.append(' command -v cmake >/dev/null 2>&1 || _missing="$_missing cmake"') + runner_lines.append(' command -v g++ >/dev/null 2>&1 || command -v gcc >/dev/null 2>&1 || _missing="$_missing build-essential"') + runner_lines.append(' command -v git >/dev/null 2>&1 || _missing="$_missing git"') + runner_lines.append(' [ -z "$_missing" ] && return 0') + runner_lines.append(' if command -v apt-get >/dev/null 2>&1 && sudo -n true 2>/dev/null; then') + runner_lines.append(' echo "[odysseus] Auto-installing missing build deps via apt:$_missing"') + runner_lines.append(' sudo -n env DEBIAN_FRONTEND=noninteractive apt-get update -qq 2>&1 | tail -3') + runner_lines.append(' sudo -n env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $_missing 2>&1 | tail -5 || true') + runner_lines.append(' elif command -v pacman >/dev/null 2>&1 && sudo -n true 2>/dev/null; then') + runner_lines.append(' echo "[odysseus] Auto-installing missing build deps via pacman:$_missing"') + runner_lines.append(' local _pacpkgs="$(echo "$_missing" | sed -e \'s/build-essential/base-devel/g\')"') + runner_lines.append(' sudo -n pacman -Sy --needed --noconfirm $_pacpkgs 2>&1 | tail -5 || true') + runner_lines.append(' elif command -v dnf >/dev/null 2>&1 && sudo -n true 2>/dev/null; then') + runner_lines.append(' echo "[odysseus] Auto-installing missing build deps via dnf:$_missing"') + runner_lines.append(' local _dnfpkgs="$(echo "$_missing" | sed -e \'s/build-essential/gcc gcc-c++ make/g\')"') + runner_lines.append(' sudo -n dnf install -y $_dnfpkgs 2>&1 | tail -5 || true') + runner_lines.append(' else') + runner_lines.append(' echo "[odysseus] WARNING: missing build deps ($_missing) — passwordless sudo is unavailable, cannot auto-install. Cookbook Diagnosis will explain the fix after the build fails."') + runner_lines.append(' fi') + runner_lines.append(' }') + runner_lines.append(' _odysseus_apt_bootstrap') + runner_lines.append(' _odysseus_missing_build_deps=""') + runner_lines.append(' command -v cmake >/dev/null 2>&1 || _odysseus_missing_build_deps="$_odysseus_missing_build_deps cmake"') + runner_lines.append(' command -v git >/dev/null 2>&1 || _odysseus_missing_build_deps="$_odysseus_missing_build_deps git"') + runner_lines.append(' command -v g++ >/dev/null 2>&1 || command -v gcc >/dev/null 2>&1 || _odysseus_missing_build_deps="$_odysseus_missing_build_deps build-essential"') + runner_lines.append(' if [ -n "$_odysseus_missing_build_deps" ]; then') + runner_lines.append(' echo "ERROR: llama.cpp source build needs missing packages:$_odysseus_missing_build_deps"') + runner_lines.append(' if command -v apt-get >/dev/null 2>&1; then') + runner_lines.append(' echo "Install on this host: sudo apt-get update && sudo apt-get install -y cmake build-essential git"') + runner_lines.append(' elif command -v pacman >/dev/null 2>&1; then') + runner_lines.append(' echo "Install on this host: sudo pacman -Sy --needed cmake base-devel git"') + runner_lines.append(' elif command -v dnf >/dev/null 2>&1; then') + runner_lines.append(' echo "Install on this host: sudo dnf install -y cmake gcc gcc-c++ make git"') + runner_lines.append(' fi') + runner_lines.append(' echo "Alternative: install a native llama-server on PATH, then relaunch."') + runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127') + runner_lines.append(' fi') + runner_lines.append(' cd ~/llama.cpp') + runner_lines.append(' _odysseus_has_vulkan() {') + runner_lines.append(' ldconfig -p 2>/dev/null | grep -q \'libvulkan\\.so\' && return 0') + runner_lines.append(' [ -e /usr/lib/libvulkan.so.1 ] && return 0') + runner_lines.append(' [ -e /usr/lib/x86_64-linux-gnu/libvulkan.so.1 ] && return 0') + runner_lines.append(' command -v vulkaninfo >/dev/null 2>&1 && return 0') + runner_lines.append(' return 1') + runner_lines.append(' }') + runner_lines.append(' _odysseus_has_vulkan_device() {') + runner_lines.append(' ls /dev/dri/renderD* >/dev/null 2>&1 && return 0') + runner_lines.append(' lspci 2>/dev/null | grep -Ei \'VGA|3D|Display\' | grep -Eiq \'AMD|ATI|Radeon\' && return 0') + runner_lines.append(' return 1') + runner_lines.append(' }') + # Backend preference: native ROCm/HIP > native CUDA > Vulkan > CPU. + # Vulkan is a portable fallback that works on AMD when ROCm isn't + # installed (e.g. Strix Halo) and on any vendor's discrete GPU, but + # it's ~30-40% slower than native HIP/CUDA for LLM inference — only + # pick it when no native toolchain is present. runner_lines.append(' if command -v hipconfig &>/dev/null || [ -d /opt/rocm ] || [ -n "$ROCM_PATH" ] || [ -n "$HIP_PATH" ]; then') + runner_lines.append(' rm -rf build') runner_lines.append(' if command -v hipconfig &>/dev/null; then') runner_lines.append(' export HIPCXX="${HIPCXX:-$(hipconfig -l)/clang}"') runner_lines.append(' export HIP_PATH="${HIP_PATH:-$(hipconfig -R)}"') runner_lines.append(' fi') runner_lines.append(' echo "[odysseus] ROCm/HIP detected — building llama-server with HIP support..."') runner_lines.append(' cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_HIP=ON && cmake --build build -j"$NPROC" --target llama-server && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server') - runner_lines.append(' elif command -v nvcc &>/dev/null; then') + runner_lines.append(' elif command -v nvcc &>/dev/null && _odysseus_has_nvidia_hw; then') + runner_lines.append(' rm -rf build') # nvcc alone is not sufficient — pip-installed CUDA wheels or incomplete # tooling can expose nvcc without shipping libcudart, causing cmake to fail # mid-build with "CUDA runtime library not found". Check cudart explicitly @@ -826,18 +950,24 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None: runner_lines.append(' echo "[odysseus] Ensure libcudart is installed (e.g. cuda-runtime package) and visible via ldconfig or CUDA_HOME."') runner_lines.append(' cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j"$NPROC" --target llama-server && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server') runner_lines.append(' fi') + runner_lines.append(' elif _odysseus_has_vulkan_device && _odysseus_has_vulkan; then') + runner_lines.append(' echo "[odysseus] Vulkan-capable GPU detected (no ROCm/CUDA toolchain installed) — building llama-server with Vulkan support..."') + runner_lines.append(' rm -rf build-vulkan') + runner_lines.append(' cmake -B build-vulkan -DCMAKE_BUILD_TYPE=Release -DGGML_VULKAN=ON && cmake --build build-vulkan -j"$NPROC" --target llama-server && ln -sf ~/llama.cpp/build-vulkan/bin/llama-server ~/bin/llama-server') runner_lines.append(' else') - runner_lines.append(' echo "[odysseus] WARNING: no HIP/CUDA toolchain found — building llama-server for CPU only."') + runner_lines.append(' echo "[odysseus] WARNING: no HIP/CUDA/Vulkan toolchain found — building llama-server for CPU only."') runner_lines.append(' echo "[odysseus] GPU inference will not be available for this llama.cpp build."') - runner_lines.append(' echo "[odysseus] Install ROCm for AMD GPUs or vLLM/CUDA tooling for NVIDIA, then re-launch this serve task."') + runner_lines.append(' echo "[odysseus] Install Vulkan (libvulkan-dev) / ROCm for AMD GPUs or CUDA tooling for NVIDIA, then re-launch this serve task."') + runner_lines.append(' rm -rf build') runner_lines.append(' cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j"$NPROC" --target llama-server && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server') runner_lines.append(' fi') + runner_lines.append(' fi # end _odysseus_have_prebuilt guard') def _llama_cpp_rebuild_cmd() -> str: """Shell command that clears the Cookbook-managed llama.cpp build. - Removes the cached ``llama-server`` symlink and the ``~/llama.cpp/build`` + Removes the cached ``llama-server`` symlink and the ``~/llama.cpp/build*`` directory so the next llama.cpp serve recompiles from source, picking up a CUDA or HIP toolchain if one is now available. The serve bootstrap only builds when ``llama-server`` is missing from PATH, so without this an @@ -847,10 +977,10 @@ def _llama_cpp_rebuild_cmd() -> str: return ( 'mkdir -p "$HOME/bin" && ' 'rm -f "$HOME/bin/llama-server" && ' - 'rm -rf "$HOME/llama.cpp/build" && ' + 'rm -rf "$HOME/llama.cpp/build" "$HOME/llama.cpp/build-vulkan" && ' 'echo "[odysseus] Cleared the cached llama.cpp build. ' 'Re-launch the serve task to rebuild llama-server from source ' - '(CUDA or HIP will be used if a toolchain is now available)."' + '(Vulkan, HIP, or CUDA will be used if a matching toolchain is now available)."' ) @@ -1113,8 +1243,27 @@ def _diagnose_serve_output(text: str) -> dict | None: "SGLang is not installed or not in PATH on this server.", [{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}], ), + # System build deps come BEFORE the generic llama.cpp catch-all so + # cmake / build-essential / git missing → a specific OS-package + # remediation instead of "install llama-cpp-python[server]" (which + # itself fails to compile when cmake is absent). ( - 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", + r"cmake: command not found|cmake.*not found.*[Cc]ould not", + "cmake is required to build llama.cpp from source but isn't installed on this server.", + [{"label": "install build deps for llama.cpp (apt: cmake build-essential git / pacman: cmake base-devel git / dnf: cmake gcc-c++ make git / brew: cmake git)", "op": "dependency", "package": "llama-cpp-python[server]"}], + ), + ( + r"^(make|g\+\+|gcc): command not found|Could not find C\+\+ compiler", + "A C/C++ compiler (build-essential) is required to build llama.cpp from source.", + [{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}], + ), + ( + r"^git: command not found", + "git is required to clone the llama.cpp source tree.", + [{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}], + ), + ( + r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'", "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]"}], ), diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index af25dd8e8..0bd38d19f 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -189,8 +189,27 @@ def setup_cookbook_routes() -> APIRouter: "SGLang is not installed or not in PATH on this server.", [{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}], ), + # System build deps come BEFORE the generic llama.cpp catch-all + # so cmake / build-essential / git missing → a specific OS-package + # remediation instead of "install llama-cpp-python[server]" (which + # itself fails to compile when cmake is absent). ( - 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", + r"cmake: command not found|cmake.*not found.*[Cc]ould not", + "cmake is required to build llama.cpp from source but isn't installed on this server.", + [{"label": "install build deps for llama.cpp (apt: cmake build-essential git / pacman: cmake base-devel git / dnf: cmake gcc-c++ make git / brew: cmake git)", "op": "dependency", "package": "llama-cpp-python[server]"}], + ), + ( + r"^(make|g\+\+|gcc): command not found|Could not find C\+\+ compiler", + "A C/C++ compiler (build-essential) is required to build llama.cpp from source.", + [{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}], + ), + ( + r"^git: command not found", + "git is required to clone the llama.cpp source tree.", + [{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}], + ), + ( + r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'", "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]"}], ), @@ -1243,8 +1262,16 @@ def setup_cookbook_routes() -> APIRouter: req.cmd = _pip_install_no_cache(req.cmd) # Accept common aliases and enforce server extras for llama-cpp so # `python -m llama_cpp.server` has all runtime dependencies. - req.cmd = re.sub(r"(? APIRouter: runner_lines.append(' else') _append_llama_cpp_linux_accel_build_lines(runner_lines) runner_lines.append(' fi') + # Source the env file the prebuilt-download path writes so + # LD_LIBRARY_PATH includes the directory holding libllama.so + # and friends. No-op when prebuilt wasn't used. + runner_lines.append(' [ -r ~/.config/odysseus-llama-cpp-env ] && . ~/.config/odysseus-llama-cpp-env') + # Auto-upgrade pip llama-cpp-python to the CUDA-enabled + # wheel when (a) NVIDIA hardware is present and (b) the + # currently-installed wheel is CPU-only. Without this the + # user gets the Python server happily running at 3 tok/s + # because pip's default index ships CPU-only wheels. + # Forward-compat: cu124 wheels work on driver/runtime + # 12.4+ including the cu13.x line. + runner_lines.append(' if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L 2>/dev/null | grep -q "GPU " && python3 -c "import llama_cpp" 2>/dev/null; then') + runner_lines.append(' if ! python3 -c "import llama_cpp; import sys; sys.exit(0 if llama_cpp.llama_supports_gpu_offload() else 1)" 2>/dev/null; then') + runner_lines.append(' echo "[odysseus] NVIDIA detected but installed llama-cpp-python is CPU-only — reinstalling with CUDA wheel index for GPU offload..."') + runner_lines.append(' python3 -m pip install --user --break-system-packages --force-reinstall --no-cache-dir "llama-cpp-python[server]" --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124 2>&1 | tail -8 || echo "[odysseus] WARNING: CUDA wheel reinstall failed — Python server will stay CPU-only (slow). Manual fix: pip install --user --force-reinstall \'llama-cpp-python[server]\' --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124"') + runner_lines.append(' if python3 -c "import llama_cpp; import sys; sys.exit(0 if llama_cpp.llama_supports_gpu_offload() else 1)" 2>/dev/null; then') + runner_lines.append(' echo "[odysseus] llama-cpp-python now supports GPU offload."') + runner_lines.append(' fi') + runner_lines.append(' fi') + runner_lines.append(' fi') + # SHORT-CIRCUIT before the build/pip fallback: if the + # native binary is missing but llama_cpp Python is already + # installed, drop a wrapper at ~/bin/llama-server that + # translates llama-server CLI args to llama_cpp.server's + # underscore-style flags. The user's serve command stays + # `llama-server ...` and "just works" — no build, no cmake, + # no second install. This is the path that unblocks every + # remote where pip-installed llama-cpp-python is already + # working but Cookbook used to insist on a native binary. + runner_lines.append(' if ! command -v llama-server >/dev/null 2>&1 && python3 -c "import llama_cpp" 2>/dev/null; then') + runner_lines.append(' mkdir -p ~/bin') + runner_lines.append(' cat > ~/bin/llama-server <<\'_ODY_LLAMA_SHIM_EOF\'') + runner_lines.append('#!/usr/bin/env bash') + runner_lines.append('# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike') + runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.') + runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts') + runner_lines.append('# where only the pip llama-cpp-python package is installed.') + runner_lines.append('ARGS=()') + runner_lines.append('while [ $# -gt 0 ]; do') + runner_lines.append(' case "$1" in') + runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;') + runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;') + runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;') + runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch') + runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;') + runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;') + runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;') + runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;') + runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;') + runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only') + runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;') + runner_lines.append(' --no-warmup) shift ;; # native-only') + runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;') + runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only') + runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;') + runner_lines.append(' *) ARGS+=("$1"); shift ;;') + runner_lines.append(' esac') + runner_lines.append('done') + runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"') + runner_lines.append('_ODY_LLAMA_SHIM_EOF') + runner_lines.append(' chmod +x ~/bin/llama-server') + runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"') + runner_lines.append(' fi') 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..."') @@ -1834,6 +1924,25 @@ def setup_cookbook_routes() -> APIRouter: 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 [] + # Pick the runtime label up-front so each GPU dict gets the + # right `backend`. AMD silicon can be driven by ROCm/HIP (native) + # OR Vulkan (mesa RADV). Reporting "rocm" on a host where no + # ROCm toolchain is installed misleads the frontend env-var + # prefix logic — it would emit `HIP_VISIBLE_DEVICES=` for a + # Vulkan-only stack, which is a silent no-op at best. + rt_out, _ = await _run_gpu_shell( + 'command -v rocminfo >/dev/null 2>&1 && echo rocm ' + '|| (command -v hipconfig >/dev/null 2>&1 && echo rocm) ' + '|| (command -v vulkaninfo >/dev/null 2>&1 && echo vulkan) ' + '|| echo unknown', + host, ssh_port, timeout=4, + ) + _amd_runtime = (rt_out or "").strip().splitlines()[-1:][0].strip() if rt_out else "rocm" + if _amd_runtime not in ("rocm", "vulkan"): + # Default to rocm so existing ROCm-installed hosts keep + # working; "unknown" only happens when neither toolchain is + # detected (e.g. minimal sysfs read on a fresh box). + _amd_runtime = "rocm" gpus = [] for entry in out.split(): if not entry.startswith("card") or "-" in entry: @@ -1877,7 +1986,7 @@ def setup_cookbook_routes() -> APIRouter: "free_mb": free_mb, "total_mb": total_mb, "used_mb": used_mb, "gtt_used_mb": gtt_used_mb, "util_pct": 0, "busy": bool(total_mb and (free_mb / total_mb) < 0.85), - "processes": [], "backend": "rocm", "source": "amd-sysfs", + "processes": [], "backend": _amd_runtime, "source": "amd-sysfs", "unified_memory": unified, }) if gpus: @@ -2018,10 +2127,15 @@ def setup_cookbook_routes() -> APIRouter: amd_gpus = await _probe_amd_sysfs(host, ssh_port) if amd_gpus: + # The per-GPU dict already carries the runtime label picked by + # _probe_amd_sysfs (rocm vs vulkan); mirror that into the + # wrapper so the frontend can read `data.backend` directly + # without scanning the list. + _amd_wrap_backend = str(amd_gpus[0].get("backend") or "rocm") return { "ok": True, "gpus": amd_gpus, - "backend": "rocm", + "backend": _amd_wrap_backend, "source": "amd-sysfs", "fallback_from": "nvidia-smi", "nvidia_error": nvidia_error, diff --git a/services/hwfit/hardware.py b/services/hwfit/hardware.py index a3ad7ba05..0473475ed 100644 --- a/services/hwfit/hardware.py +++ b/services/hwfit/hardware.py @@ -282,7 +282,17 @@ def _detect_amd(): "gpus": cards, "gpu_groups": groups, "homogeneous": len(groups) <= 1, - "backend": "rocm", + # Pick the actual runtime label: ROCm/HIP only when its + # toolchain is installed, otherwise Vulkan if vulkaninfo is + # present (mesa RADV works fine on RDNA/CDNA when ROCm + # packages are absent — see Strix Halo where ROCm support + # is still backporting). Reporting "rocm" on a Vulkan-only + # host misleads downstream env-var pinning + # (HIP_VISIBLE_DEVICES is a no-op there). + "backend": ( + "rocm" if (_run(["which", "rocminfo"]) or _run(["which", "hipconfig"])) + else ("vulkan" if _run(["which", "vulkaninfo"]) else "rocm") + ), "unified_memory": is_apu, # AMD ISA/family so downstream can tell datacenter Instinct (CDNA, # where vLLM/SGLang run AWQ/GPTQ reliably) from consumer Radeon From f01465e87f6fcb20f5a857926b969f0b8202fa10 Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 19 Jun 2026 00:33:19 +0000 Subject: [PATCH 04/22] Cookbook Dependencies: per-OS+backend install command + install-system-deps endpoint When a llama.cpp launch needs cmake/build-essential/git the user used to get a four-distro dump ("apt: x / pacman: y / dnf: z / brew: w") and had to pick the right one. Now: - shell_routes /api/cookbook/packages probes /etc/os-release on the target in the same SSH round-trip as the existing system-prereq check, classifies into debian / arch / fedora / alpine / suse / macos, and builds a single install_cmd_for_target string from the (os_family, backend) matrix. CUDA hosts get nvidia-cuda-toolkit; ROCm gets rocm-dev / rocm-hip-sdk; Vulkan gets libvulkan-dev / vulkan-headers; etc. - llama_cpp catalog entry gets system_prereqs: [cmake, g++, git]. When any of those are missing on the target, the row picks up pkg.build_deps_missing + pkg.install_cmd_for_target for the frontend to render. - New POST /api/cookbook/install-system-deps endpoint runs the right package manager via passwordless sudo on the target. Allowlisted to {cmake, build-essential, g++, gcc, git, tmux, make}; sudo -n only so it can never hang waiting for a password (returns a clear "passwordless sudo unavailable" error via stderr instead). --- routes/shell_routes.py | 307 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 304 insertions(+), 3 deletions(-) diff --git a/routes/shell_routes.py b/routes/shell_routes.py index b4e52325d..406d80bb3 100644 --- a/routes/shell_routes.py +++ b/routes/shell_routes.py @@ -961,12 +961,84 @@ def setup_shell_routes() -> APIRouter: return StreamingResponse(generate(), media_type="text/event-stream") + def _os_id_from_release(text: str) -> str: + """Map /etc/os-release contents to a canonical family for our matrix.""" + if not text: + return "" + ids = [] + for line in text.splitlines(): + line = line.strip() + if line.startswith("ID=") or line.startswith("ID_LIKE="): + ids += line.split("=", 1)[1].strip().strip('"').split() + ids = [i.lower() for i in ids] + if any(x in ids for x in ("debian", "ubuntu", "linuxmint", "pop", "elementary")): + return "debian" + if any(x in ids for x in ("arch", "manjaro", "endeavouros", "cachyos", "garuda")): + return "arch" + if any(x in ids for x in ("fedora", "rhel", "centos", "rocky", "almalinux", "ol")): + return "fedora" + if "alpine" in ids: + return "alpine" + if any(x in ids for x in ("suse", "opensuse", "opensuse-leap", "opensuse-tumbleweed", "sles")): + return "suse" + return "" + + # Matrix lookup keyed on (os_family, backend) → (pkg_mgr_cmd_template, pkg_list_per_dep). + # Each `system_prereqs` name resolves to a list of OS-specific package + # names that get joined into the final `sudo apt install -y …` etc. + # command. Backend-specific extras (CUDA toolkit, ROCm, Vulkan headers) + # are added only when the detected backend needs them. + _PKG_NAMES = { + # canonical-name → {os_id: [actual_pkg_names_on_this_os]} + "cmake": {"debian": ["cmake"], "arch": ["cmake"], "fedora": ["cmake"], "alpine": ["cmake"], "suse": ["cmake"], "macos": ["cmake"]}, + "build-essential": {"debian": ["build-essential"], "arch": ["base-devel"], "fedora": ["gcc", "gcc-c++", "make"], "alpine": ["build-base"], "suse": ["gcc-c++", "make"], "macos": []}, + "g++": {"debian": ["g++"], "arch": ["gcc"], "fedora": ["gcc-c++"], "alpine": ["g++"], "suse": ["gcc-c++"], "macos": []}, + "gcc": {"debian": ["gcc"], "arch": ["gcc"], "fedora": ["gcc"], "alpine": ["gcc"], "suse": ["gcc"], "macos": []}, + "make": {"debian": ["make"], "arch": ["make"], "fedora": ["make"], "alpine": ["make"], "suse": ["make"], "macos": []}, + "git": {"debian": ["git"], "arch": ["git"], "fedora": ["git"], "alpine": ["git"], "suse": ["git"], "macos": ["git"]}, + "tmux": {"debian": ["tmux"], "arch": ["tmux"], "fedora": ["tmux"], "alpine": ["tmux"], "suse": ["tmux"], "macos": ["tmux"]}, + } + _BACKEND_EXTRAS = { + "cuda": {"debian": ["nvidia-cuda-toolkit"], "arch": ["cuda"], "fedora": ["cuda-toolkit"], "alpine": [], "suse": ["cuda"], "macos": []}, + "rocm": {"debian": ["rocm-dev"], "arch": ["rocm-hip-sdk"], "fedora": ["rocm-devel"], "alpine": [], "suse": ["rocm-dev"], "macos": []}, + "vulkan": {"debian": ["libvulkan-dev", "vulkan-tools"], "arch": ["vulkan-headers", "vulkan-tools"], "fedora": ["vulkan-headers", "vulkan-tools"], "alpine": ["vulkan-loader-dev", "vulkan-tools"], "suse": ["vulkan-devel", "vulkan-tools"], "macos": []}, + } + _PKG_MGR = { + "debian": "sudo apt install -y {pkgs}", + "arch": "sudo pacman -S --needed {pkgs}", + "fedora": "sudo dnf install -y {pkgs}", + "alpine": "sudo apk add {pkgs}", + "suse": "sudo zypper install -n {pkgs}", + "macos": "brew install {pkgs}", + } + + def _install_cmd_for_target(os_id: str, backend: str, missing: list[str]) -> str: + """Build a single OS+backend-aware install command for the missing prereqs.""" + if not os_id or os_id not in _PKG_MGR: + return "" + pkgs: list[str] = [] + seen: set[str] = set() + for m in missing: + for p in _PKG_NAMES.get(m, {}).get(os_id, []): + if p not in seen: + pkgs.append(p); seen.add(p) + # Add backend-specific extras only when the build would actually + # consume them (a CUDA toolkit isn't useful on a Vulkan box). + backend = (backend or "").lower() + for p in _BACKEND_EXTRAS.get(backend, {}).get(os_id, []): + if p not in seen: + pkgs.append(p); seen.add(p) + if not pkgs: + return "" + return _PKG_MGR[os_id].format(pkgs=" ".join(pkgs)) + @router.get("/api/cookbook/packages") async def list_packages( request: Request, host: str | None = None, ssh_port: str | None = None, venv: str | None = None, + backend: str | None = None, ): """Check which optional packages are installed. @@ -1015,6 +1087,12 @@ def setup_shell_routes() -> APIRouter: "kind": "system", "install_hint": "Install Docker on the selected server and allow this user to run docker.", }, + # Note: cmake / gcc / git are not separate dependency rows — + # they're declared as `system_prereqs` on llama_cpp (and any + # other engine that compiles from source) so they appear as + # an inline status note on that engine's row instead of + # cluttering the panel with raw OS package names that aren't + # meaningful product-level dependencies on their own. # ── LLM ── installs on GPU servers for model serving/downloading { "name": "hf_transfer", @@ -1029,6 +1107,13 @@ def setup_shell_routes() -> APIRouter: "desc": "Serve GGUF models via llama.cpp", "category": "LLM", "target": "remote", + # Build-toolchain prereqs. Cookbook's launch bootstrap + # compiles llama-server from source when no prebuilt + # binary is present; without these the build aborts + # with `cmake: command not found`. Surfaced inline on + # this row so the user doesn't have to chase three + # separate OS-package rows. + "system_prereqs": ["cmake", "g++", "git"], }, { "name": "sglang", @@ -1143,14 +1228,28 @@ def setup_shell_routes() -> APIRouter: raise HTTPException(400, str(e)) except Exception: remote_status = {} - if host and remote_system_names: + # Union of system_names + every package's system_prereqs. Probing + # the prereqs alongside the main system deps in a single SSH call + # avoids a second round-trip per Cookbook → Dependencies refresh. + prereq_names: set[str] = set() + for p in packages: + for pr in p.get("system_prereqs") or []: + prereq_names.add(str(pr)) + all_system_names = list(set(remote_system_names) | prereq_names) + # Detect the target's OS family + read /etc/os-release in the same + # SSH round-trip as the prereq probe — used downstream to render a + # single OS-specific install command per row instead of dumping + # every distro's syntax onto the user. + target_os_id: str = "" + if host and all_system_names: try: checks = [] - for name in remote_system_names: + for name in all_system_names: qn = shlex.quote(name) checks.append( f"if command -v {qn} >/dev/null 2>&1; then echo {qn}=1; else echo {qn}=0; fi" ) + checks.append("echo '---OSREL---'; cat /etc/os-release 2>/dev/null || true") inner = " ; ".join(checks) argv = _ssh_base_argv(host, ssh_port) + [inner] proc = await asyncio.create_subprocess_exec( @@ -1160,14 +1259,32 @@ def setup_shell_routes() -> APIRouter: ) out, _err = await asyncio.wait_for(proc.communicate(), timeout=12) txt = out.decode("utf-8", errors="replace").strip() + _section, _osrel_lines = "probe", [] for line in txt.splitlines(): + if line.strip() == "---OSREL---": + _section = "osrel"; continue + if _section == "osrel": + _osrel_lines.append(line) + continue name, sep, value = line.strip().partition("=") - if sep and name in remote_system_names: + if sep and name in all_system_names: remote_status[name] = value == "1" + target_os_id = _os_id_from_release("\n".join(_osrel_lines)) except ValueError as e: raise HTTPException(400, str(e)) except Exception: pass + elif not host: + # Local target — probe in-process so the inline install command + # still appears in the dep panel when the cookbook container + # itself is the selected server. + try: + with open("/etc/os-release", encoding="utf-8") as f: + target_os_id = _os_id_from_release(f.read()) + except Exception: + target_os_id = "" + if sys.platform == "darwin": + target_os_id = "macos" for pkg in packages: on_remote = bool(host and pkg.get("target") == "remote") @@ -1229,6 +1346,94 @@ def setup_shell_routes() -> APIRouter: # 500 the entire packages panel; report it as not usable. pkg["installed"] = False + # llama_cpp partial-state probe: when the package is installed + # but the wheel was built CPU-only AND the target has NVIDIA + # hardware, mark the row as partial (yellow/orange) with a + # one-click upgrade to the CUDA wheel. Without this the row + # reads "ready" green while inference runs at 3 tok/s on GPU + # silicon — actively misleading. + if pkg["name"] == "llama_cpp" and pkg.get("installed"): + _gpu_capable = False + _has_nvidia_target = False + if on_remote and host: + try: + # Activate the configured venv FIRST so the probe + # runs against the same python the launch script + # would activate. Without this prefix, bare + # `python3` was checked — which can disagree with + # the venv's wheel (e.g. user-site has CUDA wheel + # but venv has CPU-only), and the dep panel then + # showed "ready" green while every launch fell to + # CPU. + _vp = _venv_activate_prefix(venv) + probe = ( + f'{_vp}python3 -c "import llama_cpp; import sys; ' + 'sys.exit(0 if llama_cpp.llama_supports_gpu_offload() else 1)" ' + '&& echo llama_cpp_gpu=1 || echo llama_cpp_gpu=0; ' + 'command -v nvidia-smi >/dev/null 2>&1 ' + '&& nvidia-smi -L 2>/dev/null | grep -q "GPU " ' + '&& echo nvidia=1 || echo nvidia=0' + ) + argv = _ssh_base_argv(host, ssh_port) + [probe] + proc = await asyncio.create_subprocess_exec( + *argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + out, _ = await asyncio.wait_for(proc.communicate(), timeout=8) + txt = out.decode("utf-8", errors="replace") + if "llama_cpp_gpu=1" in txt: + _gpu_capable = True + if "nvidia=1" in txt: + _has_nvidia_target = True + except Exception: + pass + else: + try: + import llama_cpp as _lcp # type: ignore + _gpu_capable = bool(_lcp.llama_supports_gpu_offload()) + except Exception: + _gpu_capable = False + _has_nvidia_target = shutil.which("nvidia-smi") is not None + if (not _gpu_capable) and _has_nvidia_target: + pkg["partial"] = True + pkg["partial_reason"] = "Installed but CPU-only wheel — GPU detected on this target. Upgrade to a CUDA wheel for ~10× faster inference." + pkg["partial_action"] = "reinstall_llama_cpp_cuda" + # Attach per-package system_prereqs status. We probed each + # prereq name above; surface "Missing build deps: …" ONLY + # when the package itself is not installed — if the package + # works (e.g. llama-cpp-python already imports cleanly), the + # build toolchain is irrelevant and surfacing it as a red + # flag confuses users ("ready" + "missing" on the same row). + _prereqs = list(pkg.get("system_prereqs") or []) + if _prereqs: + if on_remote: + _pr_present = {n: bool(remote_status.get(n)) for n in _prereqs} + else: + _pr_present = {n: shutil.which(n) is not None for n in _prereqs} + pkg["system_prereqs_status"] = _pr_present + _missing = [n for n, ok in _pr_present.items() if not ok] + # Suppress the "missing build deps" hint when the package + # itself is installed — build deps are only relevant if + # the user would need to recompile from source. + if pkg.get("installed"): + _missing = [] + if _missing: + # Build a target-specific install command from the + # (os_family, backend) matrix when we know both. Fall + # back to the multi-distro hint only when the target's + # OS can't be classified (e.g. ssh probe failed). + _resolved_os = target_os_id or "debian" # safest default + _cmd = _install_cmd_for_target(_resolved_os, backend or "", _missing) + if _cmd and target_os_id: + _hint = "Missing build deps for this target: " + ", ".join(_missing) + pkg["install_cmd_for_target"] = _cmd + pkg["install_cmd_os"] = target_os_id + pkg["install_cmd_backend"] = (backend or "").lower() + else: + _hint = "Missing build deps: " + ", ".join(_missing) + ". Install via apt: cmake build-essential git / pacman: cmake base-devel git / dnf: cmake gcc-c++ make git / brew: cmake git." + _existing_note = pkg.get("status_note") or "" + pkg["status_note"] = (_existing_note + " — " + _hint) if _existing_note else _hint + pkg["build_deps_missing"] = _missing + if pkg.get("installed"): update_status = _package_pip_update_status(pkg, probe) pkg["pip_update_available"] = update_status.available @@ -1288,6 +1493,102 @@ def setup_shell_routes() -> APIRouter: return {"ok": True, "output": stdout.decode()[-200:]} return {"ok": False, "error": stderr.decode()[-300:]} + @router.post("/api/cookbook/install-system-deps") + async def install_system_deps(request: Request): + """Install OS-level system packages (cmake/build-essential/git/tmux) + on a remote target or in the local container. Admin only. + + Bounded by a per-package allowlist — anything outside the catalog + is rejected so the route can't be coerced into installing arbitrary + OS packages. Uses `sudo -n` (passwordless) so the call returns a + clear "needs sudo password" error instead of hanging when interactive + sudo is required. + """ + _require_admin(request) + body = await request.json() + raw = body.get("packages") or [] + host = (body.get("remote_host") or "").strip() + ssh_port = body.get("ssh_port") + # Names users can request — must match canonical names used in the + # deps catalog's `system_prereqs` field and on the System rows. + ALLOWED = {"cmake", "build-essential", "g++", "gcc", "git", "tmux", "make"} + pkgs = [str(p).strip() for p in raw if str(p).strip() in ALLOWED] + if not pkgs: + return {"ok": False, "error": "no installable packages requested (allowlist: " + ", ".join(sorted(ALLOWED)) + ")"} + # Re-map to the right package name per OS. apt/dpkg use the names + # as-is; pacman has base-devel for build-essential, etc. + def _apt(names): return list(names) + def _pacman(names): + return ["base-devel" if n == "build-essential" else n for n in names] + def _dnf(names): + out = [] + for n in names: + if n == "build-essential": out += ["gcc", "gcc-c++", "make"] + elif n == "g++": out += ["gcc-c++"] + else: out.append(n) + return out + def _brew(names): + return [n for n in names if n not in ("build-essential", "g++", "gcc", "make")] + # Build a single shell snippet that detects the package manager and + # runs the right install. Non-interactive sudo (-n) only — if sudo + # asks for a password the script reports it instead of hanging. + apt_pkgs = " ".join(shlex.quote(p) for p in _apt(pkgs)) + pac_pkgs = " ".join(shlex.quote(p) for p in _pacman(pkgs)) + dnf_pkgs = " ".join(shlex.quote(p) for p in _dnf(pkgs)) + brew_pkgs = " ".join(shlex.quote(p) for p in _brew(pkgs)) + # Error messages go to stderr (>&2) so the route's error field + # gets populated. Without the redirect, `echo "ERROR…"` on stdout + # left stderr empty and the frontend toast fell through to a + # bare "HTTP 200" instead of surfacing the real reason. + script = ( + 'set -e; ' + 'if ! sudo -n true 2>/dev/null; then ' + ' echo "ERROR: passwordless sudo unavailable on this target. Run once: sudo apt install -y ' + " ".join(pkgs) + ' (or your distro equivalent: pacman -S, dnf install, brew install). After that, Cookbook can install the rest." >&2; exit 2; fi; ' + 'if command -v apt-get >/dev/null 2>&1; then ' + f' sudo -n env DEBIAN_FRONTEND=noninteractive apt-get update -qq && sudo -n env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends {apt_pkgs}; ' + 'elif command -v pacman >/dev/null 2>&1; then ' + f' sudo -n pacman -Sy --needed --noconfirm {pac_pkgs}; ' + 'elif command -v dnf >/dev/null 2>&1; then ' + f' sudo -n dnf install -y {dnf_pkgs}; ' + 'elif command -v brew >/dev/null 2>&1; then ' + f' brew install {brew_pkgs}; ' + 'else ' + ' echo "ERROR: no supported package manager (apt/pacman/dnf/brew) on this target." >&2; exit 3; fi' + ) + try: + if host: + argv = _ssh_base_argv(host, ssh_port) + [script] + else: + argv = ["bash", "-lc", script] + except ValueError as e: + raise HTTPException(400, str(e)) + try: + proc = await asyncio.create_subprocess_exec( + *argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + out, err = await asyncio.wait_for(proc.communicate(), timeout=180) + except asyncio.TimeoutError: + return {"ok": False, "error": "Install timed out after 180s"} + ok = (proc.returncode == 0) + # Combine stderr + (last lines of stdout) into a single error + # blob when ok=False — some package managers print useful failure + # context to stdout, and a script that exits via `echo ...; exit N` + # without `>&2` would otherwise hand back an empty error string + # and force the frontend to show a bare "HTTP 200". + err_txt = err.decode("utf-8", errors="replace").strip() + out_txt = out.decode("utf-8", errors="replace").strip() + if not ok: + tail_out = out_txt[-500:] if out_txt else "" + combined = err_txt or tail_out or f"exit code {proc.returncode}" + else: + combined = None + return { + "ok": ok, + "exit_code": proc.returncode, + "output": out_txt[-1000:], + "error": combined, + } + @router.post("/api/cookbook/rebuild-engine") async def rebuild_engine(request: Request): """Clear the cached llama.cpp build so the next serve recompiles. From ee6fd8ffe8321a80fee2571ef42b7a3bf6e1be4c Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 19 Jun 2026 00:33:37 +0000 Subject: [PATCH 05/22] Cookbook UI: backend-aware env vars, always-show MoE/EP/Reasoning toggles, GPU default, Firefox-mobile expand Frontend half of the backend-detection + per-OS install command work, plus a pile of mobile/UX fixes: Backend awareness: - _gpuEnvPrefix() picks CUDA_VISIBLE_DEVICES / HIP_VISIBLE_DEVICES / nothing based on detected hwfit backend + scanned-host match (so a stale ajax scan does not leak CUDA env vars into a kierkegaard Vulkan launch). Replaces 6 hardcoded CUDA_VISIBLE_DEVICES sites. - GGML_CUDA_ENABLE_UNIFIED_MEMORY only emitted when backend is actually CUDA (was leaking onto Vulkan/ROCm via saved presets). Per-target install command: - Dep rows render a single mono command box + Copy button when the server resolved pkg.install_cmd_for_target. Reused in the build-deps install failure toast so the toast and the row show the same line. - Diagnosis patterns split cmake/g++/git out of the generic llama-cpp-python catch-all so a missing-cmake failure surfaces a cmake-specific message + per-distro Copy buttons. Form toggles always visible: - Reasoning Parser, Expert Parallel, MoE Env Vars no longer gated on model-family detection. Detection still hints (parser tag shown when matched); toggle works with sensible defaults otherwise. MiniMax M- series added to MoE family detector so the auto-fill is right. Mobile + GPU default: - Launch tab cached-list flex collapsed to 0px on mobile because the desktop `flex: 1 1 0` had no parent height to grow into. Override to `flex: 0 0 auto` in the cookbook mobile @media block. - doclib-card expand on mobile (Firefox no :has() support) pins explicit px heights so the launch form actually appears. - llama_mode defaults to gpu when hwfit detected cuda/rocm/vulkan/ metal on the current target, instead of always cpu (which was forcing -ngl 0 on first-open and burning 35GB models on CPU). --- static/js/cookbook-diagnosis.js | 46 +++- static/js/cookbook-hwfit.js | 12 +- static/js/cookbook.js | 364 +++++++++++++++++++++++++++++--- static/js/cookbookServe.js | 340 +++++++++++++++++++++++++---- static/style.css | 20 +- 5 files changed, 706 insertions(+), 76 deletions(-) diff --git a/static/js/cookbook-diagnosis.js b/static/js/cookbook-diagnosis.js index 5ac387178..200803313 100644 --- a/static/js/cookbook-diagnosis.js +++ b/static/js/cookbook-diagnosis.js @@ -461,6 +461,40 @@ export const ERROR_PATTERNS = [ { label: 'Copy install command', action: () => _copyText('curl -fsSL https://ollama.com/install.sh | sh') }, ], }, + // System build deps must be checked BEFORE the llama-server catch-all: + // a `cmake: command not found` failure ALSO produces `llama-server: + // command not found` later in the script (the build aborts then the + // run line fails) — pattern order is first-match-wins, so without + // these specific entries the user gets the misleading "install + // llama-cpp-python[server]" suggestion when the actual blocker is a + // missing OS-package toolchain that pip can't ship. + { + pattern: /cmake: command not found|cmake.*not found.*Could not/i, + message: 'cmake is required to compile llama.cpp from source, but it is not installed on this server.', + suggestion: 'Suggested action: install cmake via the OS package manager — apt: cmake build-essential / pacman: cmake base-devel / dnf: cmake gcc-c++ make / brew: cmake. Cookbook can do this automatically on the next launch if your user has passwordless sudo for apt/pacman/dnf.', + fixes: [ + { label: 'Open Dependencies', action: () => _openCookbookDependencies('llama_cpp') }, + { label: 'Copy apt install', action: () => _copyText('sudo apt install -y cmake build-essential git') }, + { label: 'Copy pacman install', action: () => _copyText('sudo pacman -Sy --needed cmake base-devel git') }, + { label: 'Copy dnf install', action: () => _copyText('sudo dnf install -y cmake gcc gcc-c++ make git') }, + ], + }, + { + pattern: /^(make|g\+\+|gcc): command not found|Could not find C\+\+ compiler/i, + message: 'A C/C++ compiler (build-essential / base-devel) is required to compile llama.cpp.', + fixes: [ + { label: 'Open Dependencies', action: () => _openCookbookDependencies('llama_cpp') }, + { label: 'Copy apt install', action: () => _copyText('sudo apt install -y build-essential') }, + ], + }, + { + pattern: /^git: command not found/i, + message: 'git is required to clone the llama.cpp source tree.', + fixes: [ + { label: 'Open Dependencies', action: () => _openCookbookDependencies('llama_cpp') }, + { label: 'Copy apt install', action: () => _copyText('sudo apt install -y git') }, + ], + }, { pattern: /llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'/i, message: 'llama-cpp-python server is not installed. Run: pip install "llama-cpp-python[server]"', @@ -688,11 +722,15 @@ export function _showDiagnosis(panel, diagnosis, sourceText) { copyBtn.addEventListener('click', async (e) => { e.stopPropagation(); const bundle = _diagnosisCopyBundle(task, diagnosis, sourceText, suggestionText); - try { - await navigator.clipboard.writeText(bundle); + // Use the shared helper which falls back to execCommand('copy') on + // non-HTTPS origins (Tailscale IPs, LAN IPs, etc.) — navigator.clipboard + // is silently a no-op on those, which is why the button appeared dead + // for users on http://100.113.161.2:7011 over Tailscale/mobile. + const ok = await _copyText(bundle); + if (ok) { copyBtn.classList.add('copied'); setTimeout(() => { if (copyBtn.isConnected) copyBtn.classList.remove('copied'); }, 1200); - } catch (_) {} + } }); const dismissBtn = document.createElement('button'); @@ -757,7 +795,7 @@ export function _showDiagnosis(panel, diagnosis, sourceText) { }); row.appendChild(btn); } - body.appendChild(row); + diag.appendChild(row); } } diff --git a/static/js/cookbook-hwfit.js b/static/js/cookbook-hwfit.js index 243d3c9c7..9098d8082 100644 --- a/static/js/cookbook-hwfit.js +++ b/static/js/cookbook-hwfit.js @@ -578,7 +578,9 @@ export async function _hwfitFetch(fresh = false) { const _cached = fresh ? null : _readScanCache(_sig); const wp = spinnerModule.createWhirlpool(18); if (_cached) { - _hwfitCache = _cached; + // Tag the restored cache with its host too (scan-sig keys cache per + // host, so a hit here is always for the current remoteHost). + _hwfitCache = { ..._cached, _scannedHost: remoteHost || '' }; _hwfitRenderHw(hw, _cached.system); if (!remoteHost && _cached.system && _cached.system.platform) { _envState.platform = _cached.system.platform; @@ -750,7 +752,11 @@ export async function _hwfitFetch(fresh = false) { : _olRows; data.models = (data.models || []).concat(_olFiltered); } - _hwfitCache = data; + // Tag the cache with the host this scan was for, so downstream + // code (_gpuEnvVarName, backend-aware command builders) can avoid + // trusting a stale scan when the user switches the server picker + // to a different target without re-running hwfit. + _hwfitCache = { ...data, _scannedHost: remoteHost || '' }; _hwfitRenderHw(hw, data.system); // Propagate local platform from hardware probe so _isWindows(task) works // for local tasks (menu items, shell commands, etc.). @@ -1679,7 +1685,7 @@ export function _expandModelRow(row, modelData) { } else if (runBackend === 'llamacpp') { const dir = `"$HOME/.cache/huggingface/hub/models--${modelData.name.replace(/\//g, '--')}/snapshots"`; const ggufPath = `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`; - cmd = `MODEL_FILE=${ggufPath} && { [ -n "$MODEL_FILE" ] && [ -f "$MODEL_FILE" ]; } || { echo "ERROR: No GGUF found on this host. Download a GGUF quant or switch backend."; exit 1; } && llama-server --model "$MODEL_FILE" --host 0.0.0.0 --port 8080 -ngl 99 -c ${maxCtx} || python3 -m llama_cpp.server --model "$MODEL_FILE" --host 0.0.0.0 --port 8080 --n_gpu_layers 99 --n_ctx ${maxCtx}`; + cmd = `llama-server --model "${ggufPath}" --host 0.0.0.0 --port 8080 -ngl 99 -c ${maxCtx} --flash-attn auto`; } else { cmd = `vllm serve ${modelData.name} --host 0.0.0.0 --port ${port}`; cmd += ` --tensor-parallel-size ${tp}`; diff --git a/static/js/cookbook.js b/static/js/cookbook.js index 81acc9e0d..3aaa70465 100644 --- a/static/js/cookbook.js +++ b/static/js/cookbook.js @@ -259,6 +259,15 @@ function _detectModelOptimizations(modelName) { opts.kvCacheDtype = 'fp8'; opts.tips.push('fp8 KV cache required — bf16 OOMs at usable context'); } + // MiniMax MoE — Abab/M1/M2/M2.5/M2.7 are all MoE (Lightning Attention + + // MoE in M1, full sparse MoE from M2 onward). They benefit from the + // same --enable-expert-parallel flag as the Qwen/DeepSeek families, + // and the toggle has to be detectable here for the Expert Parallel + // checkbox in the serve form to render at all. + else if (n.includes('minimax')) { + opts.flags.push('--enable-expert-parallel'); + opts.tips.push('MoE expert parallel for MiniMax'); + } // Reasoning parser — applies independently of MoE detection. Without this // flag, models like MiniMax-M2.x, DeepSeek-R1, Qwen3 reasoning, GLM-4.x, // gpt-oss leak blocks as plain text instead of separating them @@ -419,6 +428,38 @@ export function _psQuote(value) { return "'" + String(value ?? '').replace(/'/g, "''") + "'"; } +// Pick the GPU-pinning env-var name for the detected backend. NVIDIA uses +// CUDA_VISIBLE_DEVICES; ROCm/HIP uses HIP_VISIBLE_DEVICES; Vulkan and +// Apple Metal don't take an index env var at all (and CUDA_VISIBLE_DEVICES +// is a silent no-op on those, which silently hides "wrong backend" config +// bugs). Returns 'cmd ' style prefix ('CUDA_VISIBLE_DEVICES=0 ') or '' when +// the backend doesn't support pinning. Pass isWindows=true to get PowerShell +// `$env:` syntax instead. backend defaults to whatever hwfit detected. +function _gpuEnvVarName() { + // Only emit a pinning env var when we POSITIVELY know the backend AND + // the hwfit scan was actually run against the currently-targeted host. + // Without the target-match guard, switching the server picker from an + // NVIDIA box (cuda) to a local/Vulkan target preserved the stale + // `cuda` backend in the cache, leaking `CUDA_VISIBLE_DEVICES=` into + // launches that don't have an NVIDIA GPU at all. Default to "" when + // unsure — the user sees a clean command and is prompted to scan. + const cachedHost = String(_hwfitCache?._scannedHost || ''); + const currentHost = String(_envState.remoteHost || ''); + if (cachedHost !== currentHost) return ''; + const sb = String(_hwfitCache?.system?.backend || '').toLowerCase(); + if (sb === 'cuda') return 'CUDA_VISIBLE_DEVICES'; + if (sb === 'rocm') return 'HIP_VISIBLE_DEVICES'; + return ''; // vulkan / metal / mps / apple / cpu / generic / unknown — no env-var pinning +} +function _gpuEnvPrefix(gpuId, isWindows = false) { + const id = String(gpuId || '').trim(); + if (!id) return ''; + const varName = _gpuEnvVarName(); + if (!varName) return ''; + if (isWindows) return `$env:${varName}="${id}"; `; + return `${varName}=${id} `; +} + export function _buildEnvPrefix() { if (_isWindows()) return _buildEnvPrefixWindows(); let parts = []; @@ -431,7 +472,8 @@ export function _buildEnvPrefix() { } let envVars = []; if (_envState.hfToken) envVars.push('export HF_TOKEN=' + _shellQuote(_envState.hfToken)); - if (_envState.gpus) envVars.push('export CUDA_VISIBLE_DEVICES=' + _shellQuote(_envState.gpus)); + const _envGpuVar = _gpuEnvVarName(); + if (_envState.gpus && _envGpuVar) envVars.push(`export ${_envGpuVar}=` + _shellQuote(_envState.gpus)); if (envVars.length) parts.push(envVars.join(' && ')); if (parts.length === 0) return ''; return parts.join(' && ') + ' &&'; @@ -447,7 +489,8 @@ function _buildEnvPrefixWindows() { parts.push('conda activate ' + _psQuote(_envState.envPath)); } if (_envState.hfToken) parts.push('$env:HF_TOKEN=' + _psQuote(_envState.hfToken)); - if (_envState.gpus) parts.push('$env:CUDA_VISIBLE_DEVICES=' + _psQuote(_envState.gpus)); + const _winGpuVar = _gpuEnvVarName(); + if (_envState.gpus && _winGpuVar) parts.push(`$env:${_winGpuVar}=` + _psQuote(_envState.gpus)); if (parts.length === 0) return ''; return parts.join('; ') + ';'; } @@ -468,10 +511,18 @@ export function _buildServeCmd(f, modelName, backend) { // the bare "auto" input that used to back gpu_id is gone, and the // button strip is the only source for which devices to pin. const gpuId = (f.gpus || f.gpu_id || '').toString().trim(); - if (gpuId) cmd += `CUDA_VISIBLE_DEVICES=${gpuId} `; + cmd += _gpuEnvPrefix(gpuId); if (f.moe_env) { const _opts = _detectModelOptimizations(modelName); - if (_opts.envVars.length) cmd += _opts.envVars.join(' ') + ' '; + if (_opts.envVars.length) { + cmd += _opts.envVars.join(' ') + ' '; + } else { + // Fallback when the user toggles MoE Env on for a model the + // family detector didn't classify as MoE — emit the generic + // vLLM MoE optimization env vars so the toggle is never a + // silent no-op (was the case before the "always show" change). + cmd += 'VLLM_USE_DEEP_GEMM=0 VLLM_USE_FLASHINFER_MOE_FP16=1 OMP_NUM_THREADS=4 '; + } } // Pinned attention backend (Attention field). Empty = let vLLM pick. const _attn = (f.vllm_attn_backend ?? '').toString().trim(); @@ -513,7 +564,7 @@ export function _buildServeCmd(f, modelName, backend) { // the bare "auto" input that used to back gpu_id is gone, and the // button strip is the only source for which devices to pin. const gpuId = (f.gpus || f.gpu_id || '').toString().trim(); - if (gpuId) cmd += `CUDA_VISIBLE_DEVICES=${gpuId} `; + cmd += _gpuEnvPrefix(gpuId); const _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim(); if (_extraEnv) cmd += _extraEnv + ' '; cmd += `${_py3Bin} -m sglang.launch_server --model-path ${modelName} --host 0.0.0.0 --port ${f.port || '30000'}`; @@ -536,24 +587,39 @@ export function _buildServeCmd(f, modelName, backend) { // CPU-only serve (-ngl 0): drop the GPU-only flags, otherwise the command // mixes "zero GPU layers" with CUDA unified-memory + flash-attn and fails to // start (issue #1291). Only affects the ngl=0 path; GPU serving is unchanged. + // The Inference mode pill (GPU/CPU) above gates this — when the user picks + // CPU, force ngl=0 here so all downstream flag-suppression fires + // consistently regardless of what the (now-hidden) ngl input shows. + if (String(f.llama_mode || '').toLowerCase() === 'cpu') { + f.ngl = '0'; + } else if (String(f.llama_mode || '').toLowerCase() === 'gpu' && (!f.ngl || String(f.ngl).trim() === '0')) { + f.ngl = '99'; + } const _cpuOnly = String(f.ngl).trim() === '0'; + // GGML_CUDA_* env vars are no-ops on Vulkan/ROCm/Metal/CPU. Only emit + // them when the detected backend is actually CUDA AND the hwfit scan + // was run against the currently-targeted host, so a saved preset + // from a prior NVIDIA target doesn't pollute a non-NVIDIA launch + // with misleading prefixes. + const _sb = String(_hwfitCache?.system?.backend || '').toLowerCase(); + const _hwfitHost = String(_hwfitCache?._scannedHost || ''); + const _curHost = String(_envState.remoteHost || ''); + const _isCudaTarget = (_sb === 'cuda') && (_hwfitHost === _curHost); const lcPrefix = (() => { let p = ''; - if (f.unified_mem && !_cpuOnly && !_isWindows()) p += `GGML_CUDA_ENABLE_UNIFIED_MEMORY=1 `; - if (gpuId && !_isWindows()) p += `CUDA_VISIBLE_DEVICES=${gpuId} `; + if (f.unified_mem && !_cpuOnly && !_isWindows() && _isCudaTarget) p += `GGML_CUDA_ENABLE_UNIFIED_MEMORY=1 `; + // No GPU env var in CPU mode — `-ngl 0` already disables offload + // so CUDA_VISIBLE_DEVICES / HIP_VISIBLE_DEVICES would be misleading + // clutter ("why is CUDA pinned for a CPU run?"). + if (!_isWindows() && !_cpuOnly) p += _gpuEnvPrefix(gpuId); return p; })(); - if (f.unified_mem && !_cpuOnly && _isWindows()) cmd += `$env:GGML_CUDA_ENABLE_UNIFIED_MEMORY="1"; `; - if (gpuId && _isWindows()) cmd += `$env:CUDA_VISIBLE_DEVICES="${gpuId}"; `; - if (!_isWindows()) { - // Resolve GGUF path once, fail loudly if nothing matched (prevents - // `--model ""` which causes confusing downstream errors). - cmd += `MODEL_FILE=${ggufPath} && { [ -n "$MODEL_FILE" ] && [ -f "$MODEL_FILE" ]; } || { echo "ERROR: No GGUF found on this host. Either download the model here, or switch to the server where it's cached."; exit 1; } && `; - } - const modelArg = _isWindows() ? `"${ggufPath}"` : `"$MODEL_FILE"`; - // Prefer the native llama-server binary on Linux — its minja templating - // renders modern GGUF chat templates that the Python bindings' Jinja2 - // rejects (do_tojson ensure_ascii). Fall back to llama_cpp.server. + if (f.unified_mem && !_cpuOnly && _isWindows() && _isCudaTarget) cmd += `$env:GGML_CUDA_ENABLE_UNIFIED_MEMORY="1"; `; + if (_isWindows() && !_cpuOnly) cmd += _gpuEnvPrefix(gpuId, true); + const modelArg = `"${ggufPath}"`; + // Prefer native llama-server. The backend bootstrap resolves/builds the + // right binary (Vulkan/HIP/CUDA/Metal/CPU), so keep the generated command + // as a validator-safe binary + args with no shell chaining. // Don't suppress stderr — surface real errors (missing file, lib, OOM). // Optional perf/fit flags from a hardware profile (see services/hwfit/ // profiles.py). n_cpu_moe offloads MoE expert layers to CPU when the model @@ -575,9 +641,16 @@ export function _buildServeCmd(f, modelName, backend) { _lcExtra += ` --n-cpu-moe ${_ncm}`; _lcpExtra += ` --n_cpu_moe ${_ncm}`; // llama-cpp-python uses underscores } + // Flash-attn default = auto: native llama-server picks whether to + // enable based on the build/model; explicit ON (the Flash-attn + // toggle in the form) forces it. "auto" is a meaningful arg, not + // omission — older builds without flash-attn ignore it cleanly, + // newer ones get the speedup without the user having to know. if (f.flash_attn && !_cpuOnly) { _lcExtra += ' --flash-attn on'; _lcpExtra += ' --flash_attn true'; + } else if (!_cpuOnly) { + _lcExtra += ' --flash-attn auto'; } if (_kv) { _lcExtra += ` --cache-type-k ${_kv} --cache-type-v ${_kv}`; @@ -613,12 +686,11 @@ export function _buildServeCmd(f, modelName, backend) { // llama-cpp-python takes the projector via --clip_model_path. _lcpExtra += ` --clip_model_path "${f._mmproj_path}"`; } - const _lcpServer = `${lcPrefix}${py} -m llama_cpp.server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} --n_gpu_layers ${f.ngl || '99'} --n_ctx ${f.ctx || '8192'}${_lcpExtra}`; if (_isWindows()) { + const _lcpServer = `${lcPrefix}${py} -m llama_cpp.server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} --n_gpu_layers ${f.ngl || '99'} --n_ctx ${f.ctx || '8192'}${_lcpExtra}`; cmd += _lcpServer; } else { cmd += `${lcPrefix}llama-server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} -ngl ${f.ngl || '99'} -c ${f.ctx || '8192'}${_lcExtra}`; - cmd += ` || ${_lcpServer}`; } } else if (backend === 'ollama') { const ollamaPort = f.port || '11434'; @@ -652,7 +724,7 @@ export function _buildServeCmd(f, modelName, backend) { } } else if (backend === 'diffusers') { const gpuStr = f.gpus?.trim(); - if (gpuStr) cmd += `CUDA_VISIBLE_DEVICES=${gpuStr} `; + cmd += _gpuEnvPrefix(gpuStr); const diffusersPy = _isWindows() ? 'python' : _py3Bin; cmd += `${diffusersPy} scripts/diffusion_server.py --model ${modelName} --port ${f.port || '8100'}`; if (f.diff_dtype && f.diff_dtype !== 'bfloat16') cmd += ` --dtype ${f.diff_dtype}`; @@ -771,6 +843,14 @@ async function _fetchDependencies() { if (_depPort) _pkgParams.set('ssh_port', _depPort); if (_depVenv) _pkgParams.set('venv', _depVenv); } + // Pass the detected backend so the server can build a single + // OS+backend-aware install command per row (e.g. add nvidia-cuda-toolkit + // on a CUDA-Debian box, vulkan-headers on a Vulkan-Arch box, etc.) + // instead of dumping every distro's syntax as a hint. + const _depBackend = String(_hwfitCache?.system?.backend || '').toLowerCase(); + if (_depBackend && _hwfitCache?._scannedHost === _depHost) { + _pkgParams.set('backend', _depBackend); + } const resp = await fetch('/api/cookbook/packages' + (_pkgParams.toString() ? '?' + _pkgParams.toString() : '')); const data = await resp.json(); const pkgs = data.packages || []; @@ -832,18 +912,61 @@ async function _fetchDependencies() { // For backends with a recipe catalog (vllm / sglang / llama_cpp), // append a caret button that toggles a per-row recipe panel below. const hasRecipe = RECIPE_BACKENDS.has(pkg.name); - const recipeCaret = hasRecipe - ? `` - : ''; + // Standalone recipe-caret button removed — the "Pick install + // command" action lives inside the Installed ▾ dropdown menu + // (see _showDepMenu) so each row only has ONE caret to click. + // Kept the variable so downstream concat code stays the same. + const recipeCaret = ''; const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : ''; + // When llama_cpp (or any future engine) reports build_deps_missing + // from its system_prereqs probe, surface a one-tap install button + // that fires the OS package manager on the target via + // /api/cookbook/install-system-deps. Keeps the user inside Cookbook + // instead of forcing them out to a shell to apt/pacman/dnf. + const _bdm = Array.isArray(pkg.build_deps_missing) ? pkg.build_deps_missing : []; + const _buildDepsBtn = _bdm.length + ? `` + : ''; + // Render the target-specific install command as a compact mono box + // when the server resolved it (target's /etc/os-release was readable + // AND the backend is known). The box doubles as the source of truth + // for the "Install build deps" button's failure toast — both surfaces + // show the same string for the same target. + const _instCmd = (_bdm.length && pkg.install_cmd_for_target) ? String(pkg.install_cmd_for_target) : ''; + const _instCmdOs = pkg.install_cmd_os ? String(pkg.install_cmd_os) : ''; + const _instCmdBe = pkg.install_cmd_backend ? String(pkg.install_cmd_backend) : ''; + const _instLabel = (_instCmdOs && _instCmdBe) ? `${_instCmdOs} + ${_instCmdBe}` : (_instCmdOs || _instCmdBe || 'this target'); + const _instCmdBox = _instCmd + ? `
` + + `
Install on ${esc(_instLabel)}:
` + + `
` + + `${esc(_instCmd)}` + + `` + + `
` + : ''; + // Partial-state row (replaces the cryptic yellow "Partial ▾" tag). + // Renders inline as a yellow banner with two clear actions: one-tap + // Install (runs the reinstall in cookbook) or Copy command (paste + // into a terminal). Same content surfaces whether the user solves + // it from inside Cookbook or from a shell. + const _gpuWheelCmd = 'CMAKE_ARGS="-DGGML_CUDA=on" python3 -m pip install --user --break-system-packages --force-reinstall --no-cache-dir "llama-cpp-python[server]" --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124'; + const _gpuUpgradeBox = (pkg.partial && pkg.partial_action === 'reinstall_llama_cpp_cuda') + ? `
` + + `Installed CPU-only — GPU detected on this target. Upgrade for ~10× faster inference.` + + `` + + `` + + `
` + : ''; return `
` + `
` + `
${_depGlyphHtml(pkg.name)}${esc(pkg.name)}
` + `
${esc(pkg.desc)}
` + note + updateNote + + _instCmdBox + `
` + _rebuildBtn + + _buildDepsBtn + `${esc(pkg.category)}` + _statusTag(pkg, isLocal, isSystemDep, winBlocked) + recipeCaret @@ -985,8 +1108,15 @@ async function _fetchDependencies() { if (!res.ok || !data.ok) { // FastAPI HTTPException returns {detail: …}; the route's own // path returns {ok:false, error:…}. Surface whichever we get. + // Long duration + an OK button — the default 1.2s toast was + // disappearing before the user could read multi-clause errors + // like "tmux missing on remote". const reason = data.detail || data.error || `HTTP ${res.status}`; - uiModule.showToast('Install failed: ' + String(reason).slice(0, 200)); + uiModule.showToast('Install failed: ' + String(reason).slice(0, 400), { + duration: 20000, + action: 'OK', + onAction: () => {}, + }); return; } // _dep flags this as a pip dependency/driver install (not a servable @@ -996,12 +1126,16 @@ async function _fetchDependencies() { if (statusEl) { statusEl.textContent = upgrade ? 'Updating...' : 'Installing...'; statusEl.disabled = true; } uiModule.showToast(`${upgrade ? 'Updating' : 'Installing'} ${pkgName} on ${targetHost}...`); } catch (err) { - uiModule.showToast('Install failed: ' + err.message); + uiModule.showToast('Install failed: ' + err.message, { + duration: 20000, + action: 'OK', + onAction: () => {}, + }); } } // Wire install buttons (not-installed packages) - list.querySelectorAll('.cookbook-dep-install:not(.cookbook-dep-recipe-run)').forEach(btn => { + list.querySelectorAll('.cookbook-dep-install:not(.cookbook-dep-recipe-run):not(.cookbook-dep-install-sysdeps)').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const pipName = btn.dataset.depPip; @@ -1010,6 +1144,143 @@ async function _fetchDependencies() { }); }); + // Wire "Install build deps" buttons — surfaced on rows whose + // system_prereqs are missing (e.g. llama_cpp with no cmake on the + // target). One-tap call to /api/cookbook/install-system-deps; the + // route enforces a per-package allowlist and uses passwordless + // sudo only, so it can never silently hang or stretch beyond the + // build-toolchain set the catalog declares. + // "Partial ▾" upgrade tag: clicking it fires the action-specific + // install routine (currently only `reinstall_llama_cpp_cuda` — + // forces pip install with the abetlen CUDA wheel index to add GPU + // offload). Same install flow used at launch-time auto-fix, but + // user-initiated here so they don't have to launch + wait + retry. + list.querySelectorAll('.cookbook-dep-partial').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const action = btn.dataset.depPartialAction || ''; + if (action !== 'reinstall_llama_cpp_cuda') return; + const isLocal = btn.dataset.depTarget === 'local'; + if (!isLocal) { + const depsServerSel = document.getElementById('hwfit-deps-server'); + if (depsServerSel) _applyServerSelection(depsServerSel.value); + } + const targetLabel = isLocal ? 'this server' : (_envState.remoteHost || 'remote'); + const cmd = 'CMAKE_ARGS="-DGGML_CUDA=on" python3 -m pip install --user --break-system-packages --force-reinstall --no-cache-dir "llama-cpp-python[server]" --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124'; + try { + const reqBody = { + repo_id: 'llama-cpp-python-cuda', + cmd, + remote_host: _envState.remoteHost || undefined, + ssh_port: _getPort(_envState.remoteHost) || undefined, + platform: _envState.platform || undefined, + }; + const res = await fetch('/api/model/serve', { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(reqBody), + }); + const data = await res.json().catch(() => ({})); + if (res.ok && data.ok) { + const payload = { repo_id: 'pip llama-cpp-python[CUDA]', _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true }; + _addTask(data.session_id, 'pip llama-cpp-python[CUDA]', 'download', payload); + uiModule.showToast(`Reinstalling llama-cpp-python with CUDA wheels on ${targetLabel} (~1-3 min)…`, 4000); + } else { + uiModule.showToast('Upgrade failed: ' + String(data.detail || data.error || `HTTP ${res.status}`).slice(0, 300), { + duration: 20000, action: 'OK', onAction: () => {}, + }); + } + } catch (err) { + uiModule.showToast('Upgrade request failed: ' + err.message, { duration: 20000, action: 'OK', onAction: () => {} }); + } + }); + }); + + // Inline command-box "Copy" buttons — one per row that has a + // resolved per-target install command. Same string surfaces here + // and in the toast/diagnosis so the user always sees one answer. + list.querySelectorAll('.cookbook-dep-cmd-copy').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const cmd = btn.dataset.depCmdCopy || ''; + if (!cmd) return; + try { await navigator.clipboard.writeText(cmd); } + catch { /* fall through */ } + const orig = btn.textContent; + btn.textContent = 'Copied'; + setTimeout(() => { if (btn.isConnected) btn.textContent = orig; }, 1200); + }); + }); + list.querySelectorAll('.cookbook-dep-install-sysdeps').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const names = (btn.dataset.depSysdeps || '').split(',').map(s => s.trim()).filter(Boolean); + if (!names.length) return; + const isLocal = btn.dataset.depTarget === 'local'; + // Pull the per-target install command from the sibling box on + // the same row, so failure toasts surface the SAME line the + // user already sees inline. No duplicated formatting logic. + const _row = btn.closest('.cookbook-dep-row'); + const _cmdBox = _row?.querySelector('.cookbook-dep-install-cmd'); + const _resolvedCmd = _cmdBox?.dataset.depCmd || ''; + // Mirror _installDep: the Dependencies tab has its own server + // picker that can override _envState. Apply it before reading + // remoteHost, otherwise the install silently runs on the wrong + // target (container ends up with the packages, the real remote + // host stays broken, success toast misleads the user). + if (!isLocal) { + const depsServerSel = document.getElementById('hwfit-deps-server'); + if (depsServerSel) _applyServerSelection(depsServerSel.value); + } + const targetLabel = isLocal ? 'this server' : (_envState.remoteHost || 'remote'); + const origText = btn.textContent; + btn.textContent = 'Installing…'; + btn.disabled = true; + try { + const body = { packages: names }; + if (!isLocal && _envState.remoteHost) { + body.remote_host = _envState.remoteHost; + const _p = _getPort(_envState.remoteHost); + if (_p) body.ssh_port = _p; + } + const res = await fetch('/api/cookbook/install-system-deps', { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (res.ok && data.ok) { + uiModule.showToast(`Installed ${names.join(', ')} on ${targetLabel}. Refreshing…`, 4000); + // Refresh the deps panel so the row updates (prereqs now present). + try { await _fetchDependencies(); } catch {} + } else { + const reason = data.error || data.detail || `HTTP ${res.status}`; + // Append the per-target install command (if we already know it + // from the row) so the user can copy-paste it without leaving + // the toast. Otherwise just surface the error. + const _suffix = _resolvedCmd ? `\n\nRun on ${targetLabel}: ${_resolvedCmd}` : ''; + uiModule.showToast('Build-deps install failed: ' + String(reason).slice(0, 300) + _suffix, { + duration: 25000, + action: _resolvedCmd ? 'Copy command' : 'OK', + onAction: async () => { + if (_resolvedCmd) { + try { await navigator.clipboard.writeText(_resolvedCmd); } catch {} + } + }, + }); + btn.textContent = origText; + btn.disabled = false; + } + } catch (err) { + uiModule.showToast('Install request failed: ' + err.message, { + duration: 20000, action: 'OK', onAction: () => {}, + }); + btn.textContent = origText; + btn.disabled = false; + } + }); + }); + // ── Recipe panel wiring (per-backend dropdown with model + commands) ── // Caret toggle: shows/hides the panel directly below the backend row. list.querySelectorAll('[data-dep-recipe-toggle]').forEach(btn => { @@ -1577,8 +1848,22 @@ function _wireTabEvents(body) { if (dlBtn && dlInput) { function _stripHfUrl(input) { let repo = input.trim(); + // Strip a leading `hf download` / `hf-cli download` / `huggingface-cli + // download` wrapper so a paste from CLI docs Just Works. Drop the + // command prefix; the rest is parsed by the existing strippers. + repo = repo.replace(/^(?:huggingface-cli|hf-cli|hf)\s+(?:download|d)\s+/i, ''); + // Strip the `hf://` (and `huggingface://`) scheme — the HF CLI + // accepts it as an alias and users naturally copy it. Same effect + // as the bare `org/repo[/file.gguf]` form after the strip. + repo = repo.replace(/^(?:hf|huggingface):\/\//i, ''); // Strip Ollama-style "hf.co/" prefix if present (e.g. hf.co/unsloth/...:tag) repo = repo.replace(/^hf\.co\//, ''); + // Full HF blob/resolve URL → turn into `org/repo/path/to/file` so + // the downstream `_splitRepoFile` can pick the file out. + // Matches: https://huggingface.co/org/repo/blob/branch/path/to/file.gguf + // https://huggingface.co/org/repo/resolve/branch/path/to/file.gguf + const hfBlob = repo.match(/^https?:\/\/huggingface\.co\/([^/]+\/[^/?#]+)\/(?:blob|resolve)\/[^/?#]+\/([^?#]+)/); + if (hfBlob) return `${hfBlob[1]}/${hfBlob[2]}`; const hfMatch = repo.match(/^https?:\/\/huggingface\.co\/([^/]+\/[^/?#]+(?::[^/?#\s]+)?)/); if (hfMatch) repo = hfMatch[1]; return repo; @@ -1590,6 +1875,22 @@ function _wireTabEvents(body) { if (!m) return { repo: raw, include: null }; return { repo: m[1], include: `*${m[2]}*` }; } + // Split `org/repo/path/to/file.gguf` (or `.safetensors`/`.bin`) into + // repo + exact file include. Lets the user paste a path straight out + // of a HuggingFace "Files and versions" page or a copied filename + // without needing to peel the repo/file apart by hand. Returns null + // when the input doesn't look like a deep file path. + function _splitRepoFile(raw) { + // Must have at least 3 slash-separated segments AND end in a + // model-file extension to avoid eating Ollama tags or repo-only + // inputs like `org/repo`. + const parts = raw.split('/'); + if (parts.length < 3) return null; + const fname = parts[parts.length - 1]; + if (!/\.(gguf|safetensors|bin|pt|pth|onnx|mlx)(\?[^?]*)?$/i.test(fname)) return null; + const repo = parts.slice(0, 2).join('/'); + return { repo, include: fname.replace(/\?.*$/, '') }; + } // Ollama-library name. Matches `qwen2.5:14b`, `llama3:latest`, and the // (rare) `library/:` form which we normalize by stripping the // namespace. The backend's _is_ollama_download check expects the same @@ -1605,7 +1906,14 @@ function _wireTabEvents(body) { const rawRepo = _stripHfUrl(dlInput.value); if (!rawRepo) return; const ollamaName = _ollamaName(rawRepo); - const { repo, include: autoInclude } = ollamaName ? { repo: ollamaName, include: null } : _splitRepoTag(rawRepo); + // Prefer the deep-file split (org/repo/file.gguf → repo + exact + // include) over the tag split (org/repo:tag → glob include), and + // both over the plain repo case. Ollama names still take priority + // since they go through a different backend. + const _fileSplit = !ollamaName ? _splitRepoFile(rawRepo) : null; + const { repo, include: autoInclude } = ollamaName + ? { repo: ollamaName, include: null } + : (_fileSplit || _splitRepoTag(rawRepo)); // HuggingFace repo IDs must be `org/model`. A bare model name would 404 // at snapshot_download time with a raw traceback, so reject it up front. // Ollama names (single-segment with a tag) skip this check — they go diff --git a/static/js/cookbookServe.js b/static/js/cookbookServe.js index f3b5842b2..aba3f7926 100644 --- a/static/js/cookbookServe.js +++ b/static/js/cookbookServe.js @@ -10,6 +10,7 @@ import { providerLogo } from './providers.js'; import { modelColor } from './chatRenderer.js'; import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; import { openCookbookDependencies } from './cookbook-diagnosis.js'; +import { _hwfitCache } from './cookbook-hwfit.js'; // Shared state/functions injected by init() let _envState; @@ -495,6 +496,7 @@ function _rerenderCachedModels() { item.classList.remove('doclib-card-expanded'); item.style.flexDirection = ''; item.style.alignItems = ''; + item.style.maxHeight = ''; list.style.minHeight = ''; list.style.maxHeight = ''; return; @@ -508,6 +510,7 @@ function _rerenderCachedModels() { c.classList.remove('doclib-card-expanded'); c.style.flexDirection = ''; c.style.alignItems = ''; + c.style.maxHeight = ''; }); const shortName = repo.split('/').pop(); @@ -620,13 +623,31 @@ function _rerenderCachedModels() { // stays as the source-of-truth so every existing change handler // (updateBackendVisibility, runtime readiness, command builder) // still fires via dispatchEvent('change') on selection. - panelHtml += ``; + panelHtml += ``; panelHtml += ``; + // Inference mode pill (llama.cpp only) — lives directly to the + // RIGHT of Backend in Row 1 so the engine and the GPU/CPU choice + // are read together. .hwfit-backend-llamacpp visibility class + // hides it when the user switches to vLLM/SGLang/Ollama. + { + // Default CPU — works on every host without GPU/wheel matching + // hassle. User picks GPU explicitly if they have the right setup + // (avoids "click Launch → silent CPU fallback because the wheel + // is CPU-only" surprises that ate hours of debugging). + // Layout: CPU on left, GPU on right → mode-right triggers when + // GPU is selected so the sliding pill animates rightward. + // Default to GPU mode when hwfit detected a GPU backend on the + // current target — CPU as a global default sent the user down a + // 35GB-model-on-CPU rabbit hole (-ngl 0, no flash-attn, no GPU + // offload). Falls back to CPU only when hwfit detected no GPU + // (cpu_x86 / generic / unscanned) or the cache is stale. + const _hwBackend = String(_hwfitCache?.system?.backend || '').toLowerCase(); + const _hwScanMatch = String(_hwfitCache?._scannedHost || '') === String(_envState.remoteHost || ''); + const _llamaModeDefault = (_hwScanMatch && ['cuda', 'rocm', 'vulkan', 'metal', 'mps', 'apple'].includes(_hwBackend)) ? 'gpu' : 'cpu'; + const _llamaMode = sv('llama_mode', _llamaModeDefault); + panelHtml += ``; + } panelHtml += ``; - // Dtype lives in Row 1 (next to venv) — it's the first knob people - // change when matching the model to the box, so it earns top-row - // real estate over Row 2's launch-tuning controls. - panelHtml += ``; const defaultPort = defaultBackend === 'ollama' ? '11434' : _nextAvailablePort(); panelHtml += ``; const _activeGpus = (defaultGpus || '').split(',').map(s => s.trim()).filter(Boolean); @@ -642,7 +663,7 @@ function _rerenderCachedModels() { // separates the GPU chiclets from the GPU Mem field that follows // (asked-for breathing room; 4px on either side felt cramped on // the GPU-Mem boundary). - const _gpusLabelHtml = ``; + const _gpusLabelHtml = ``; // Save / saved-configs split button — sits at the right end of Row 1. panelHtml += _slotsHtml; panelHtml += `
`; @@ -664,10 +685,12 @@ function _rerenderCachedModels() { // (Swap, KV Cache, Attention backend, Env vars, llama.cpp batch/ubatch) // moved to the Advanced fold below to keep this row scannable. panelHtml += `
`; - // Order: TP → Context → Max Seqs → GPUs → GPU Mem. - // Dtype moved up to Row 1. GPUs moved here next to GPU Mem so the - // "which devices + how much of them" decisions sit adjacent. Max - // Seqs follows Context per the "request-shape" cluster. + // Order: Dtype → TP → Context → Max Seqs → GPUs → GPU Mem. + // Dtype moved down from Row 1 to make space for the Inference pill + // (llama.cpp GPU/CPU toggle, llamacpp-only). GPUs lives next to + // GPU Mem so "which devices + how much" sit adjacent. Max Seqs + // follows Context per the "request-shape" cluster. + panelHtml += ``; panelHtml += ``; // ctx resets to the model's max on every panel open (the real ctx slider // lives in the Scan/Download toolbar — see cookbook.js .hwfit-ctx-control). @@ -711,12 +734,6 @@ function _rerenderCachedModels() { // container — left an empty trailing column gap on wide modals). panelHtml += ``; panelHtml += `
`; - // Advanced llama.cpp row (Batch / UBatch — moved out of Core for the - // same "rarely touched" reason as the vLLM extras above). - panelHtml += `
`; - panelHtml += ``; - panelHtml += ``; - panelHtml += `
`; // Row 2b: Diffusers settings const diffDtypeOpts = ['bfloat16','float16','float32'].map(d => ``).join(''); const deviceMapOpts = ['balanced','auto','sequential'].map(d => ``).join(''); @@ -740,13 +757,19 @@ function _rerenderCachedModels() { panelHtml += `
`; panelHtml += ``; panelHtml += ``; - if (_rp_name) panelHtml += ``; + // Always-render the Reasoning Parser, Expert Parallel, and MoE Env + // checkboxes — the model-family detection above is a hint, not a + // hard gate. User asked to keep these visible regardless so that + // a borderline-undetected MoE/reasoning model can still toggle + // them without dropping back to the raw command box. + panelHtml += ``; panelHtml += ``; panelHtml += ``; // Inline the previously-second vLLM checks row so Expert Parallel / // Speculative / MoE Env sit next to Prefix Caching with no gap. All - // three are vLLM-only — class-gated so they hide on SGLang. - if (_opts2_row3.flags.includes('--enable-expert-parallel')) panelHtml += ``; + // three are vLLM-only — class-gated so they hide on SGLang. Always + // render so the user can flip them on for any MoE model. + panelHtml += ``; { const _specDef = _opts2_row3.spec || { method: 'mtp', tokens: 3 }; const _specMethod = sv('spec_method', _specDef.method); @@ -757,27 +780,39 @@ function _rerenderCachedModels() { ``).join(''); panelHtml += ``; } - if (_opts2_row3.envVars.length) panelHtml += ``; + // Always-render MoE Env Vars — the env vars dict is empty for + // most dense models (toggle is a no-op then), but for MoE families + // the user can still flip it on without re-fitting model detection. + panelHtml += ``; panelHtml += `
`; - // Row 2c: llama.cpp fit/perf flags (set by Auto profiles, editable by hand) + // ── llama.cpp Advanced — grouped by purpose ── + // Three clean field rows + one checkbox row, all selects/inputs the + // same 28px height (no per-field `top:-Npx` nudges). Groups follow + // user mental model: (1) where it runs on GPU, (2) how memory is + // shaped, (3) how requests are batched, (4) on/off toggles. const _kvOpts = ['', 'q4_0', 'q8_0', 'f16'].map(k => ``).join(''); const llamaFitOpts = ['', 'off', 'on'].map(d => ``).join(''); const llamaSplitModeOpts = ['', 'layer', 'tensor', 'row', 'none'].map(d => ``).join(''); + + // Group 1 — GPU placement (GPU-only, hides in CPU mode) + panelHtml += `
`; + panelHtml += ``; + panelHtml += ``; + panelHtml += ``; + panelHtml += `
`; + + // Group 2 — Memory tuning (KV cache + MoE-on-CPU + Fit policy) panelHtml += `
`; - panelHtml += ``; - panelHtml += ``; - panelHtml += ``; - panelHtml += ``; + panelHtml += ``; + panelHtml += ``; panelHtml += ``; panelHtml += `
`; - // Row 2d: native llama-server placement/runtime controls. These are - // explicit overrides for known-good advanced presets; blank keeps - // llama.cpp/profile defaults. + + // Group 3 — Request batching (Batch / UBatch / Parallel) panelHtml += `
`; - panelHtml += ``; - panelHtml += ``; - panelHtml += ``; - panelHtml += ``; + panelHtml += ``; + panelHtml += ``; + panelHtml += ``; panelHtml += `
`; // Auto-profile chips row removed — visual fit with the rest of the // serve panel was off, and the manual ctx/n_cpu_moe/cache controls @@ -791,12 +826,19 @@ function _rerenderCachedModels() { panelHtml += `GPU memory:`; panelHtml += `checking…`; panelHtml += ``; - // Row 3a: Checkboxes (llama.cpp-only) + // Group 4 — llama.cpp toggles. Single row of checkboxes, GPU-only + // ones (Flash Attn, Unified Memory, Allow CPU overflow) hide + // automatically in CPU mode. Order: perf-critical → safety → I/O → + // niche. MTP Spec sits last because it owns its own numstep widget + // and is the widest item. panelHtml += `
`; - panelHtml += ``; - panelHtml += ``; - panelHtml += ``; - panelHtml += ``; + panelHtml += ``; + panelHtml += ``; + panelHtml += ``; + panelHtml += ``; + panelHtml += ``; + panelHtml += ``; + panelHtml += ``; panelHtml += `
`; // Row 3b: Checkboxes (diffusers) panelHtml += `
`; @@ -859,6 +901,21 @@ function _rerenderCachedModels() { const panel = item.querySelector('.hwfit-serve-panel'); // Scroll the serve panel into view within its nearest scrollable ancestor requestAnimationFrame(() => panel.scrollIntoView({ block: 'nearest', behavior: 'smooth' })); + // Firefox-mobile fallback: the CSS that grows the cached-list and + // expanded card uses :has(.doclib-card-expanded), which Firefox + // mobile doesn't support — so the panel stays collapsed and the + // form is unusable. Pin explicit px heights here. On Chromium/ + // WebKit the !important CSS still wins, so this is a no-op there. + // (See project_skills_expand_firefox memory note.) + requestAnimationFrame(() => { + try { + const _itemH = Math.max(item.scrollHeight, item.getBoundingClientRect().height); + if (_itemH > 0) item.style.maxHeight = _itemH + 'px'; + const _listH = Math.max(list.scrollHeight, list.getBoundingClientRect().height); + if (_listH > 0) list.style.maxHeight = _listH + 'px'; + list.style.minHeight = _listH + 'px'; + } catch {} + }); // Build command preview function updateCmd() { @@ -1859,6 +1916,49 @@ function _rerenderCachedModels() { updateCmd(); }); }); + // llama.cpp GPU/CPU mode-toggle pill wiring. Clicking GPU or CPU + // flips the .active classes + .mode-right marker (so the sliding + // pill matches Agent/Chat), updates the hidden data-field input, + // and fires a change event so the existing field-change handler + // rebuilds the serve cmd (sets -ngl 99 vs -ngl 0). + panel.querySelectorAll('[data-llama-mode-toggle]').forEach(group => { + group.querySelectorAll('.mode-toggle-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + const want = btn.dataset.llamaMode; + if (!want) return; + group.querySelectorAll('.mode-toggle-btn').forEach(b => { + const isActive = b.dataset.llamaMode === want; + b.classList.toggle('active', isActive); + b.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + }); + group.classList.toggle('mode-right', want === 'gpu'); + const hidden = group.parentElement.querySelector('[data-field="llama_mode"]'); + if (hidden) { + hidden.value = want; + hidden.dispatchEvent(new Event('change', { bubbles: true })); + } + // Hide every GPU-only control (chiclets, Tensor Split, + // Split Mode, Main GPU, Flash Attn, Unified Memory, etc.) + // in CPU mode — `-ngl 0` ignores them and showing them + // implies they matter. + panel.classList.toggle('cookbook-llama-cpu-mode', want === 'cpu'); + panel.querySelectorAll('.cookbook-llama-gpu-only').forEach(el => { + el.style.display = (want === 'cpu') ? 'none' : ''; + }); + }); + }); + }); + // Apply the CPU-mode visibility on first render too, so a saved + // preset that loaded with llama_mode=cpu hides GPU controls + // immediately instead of flashing them then disappearing. + { + const _saved = panel.querySelector('[data-field="llama_mode"]')?.value || 'gpu'; + if (_saved === 'cpu') { + panel.classList.add('cookbook-llama-cpu-mode'); + panel.querySelectorAll('.cookbook-llama-gpu-only').forEach(el => { el.style.display = 'none'; }); + } + } // Themed +/- buttons next to spec_tokens — step the adjacent number input. panel.querySelectorAll('.hwfit-numstep-btn').forEach(btn => { btn.addEventListener('click', (e) => { @@ -2025,6 +2125,140 @@ function _rerenderCachedModels() { }); return; } + // llama.cpp VRAM-fit preflight. Catches the silent-CPU-fallback + // trap: when the model + KV cache exceed the selected GPUs' free + // VRAM, llama-cpp-python doesn't error — it pushes layers/KV to + // CPU and inference crawls at sub-1 tok/s. Off by default; can + // be bypassed per-launch via the dialog's "Allow CPU overflow" + // action, OR persistently by ticking the same-named checkbox. + if (serveState.backend === 'llamacpp' + && String(serveState.llama_mode || 'gpu') !== 'cpu' + && !serveState.llama_cpu_overflow) { + try { + const _ctx = Math.max(1, parseInt(serveState.ctx, 10) || 8192); + // Model size on disk — close enough for GPU footprint of a GGUF. + const _modelBytes = Number(m?.size_bytes || 0) || Math.round((Number(m?.size_gb || 0)) * 1024 * 1024 * 1024); + const _modelGb = _modelBytes / (1024 ** 3); + // KV cache heuristic. ~0.7MB / token / 7.5GB-of-model at fp16 + // KV, scaled linearly by model size. Imperfect but covers + // the common 7B–70B range within ~20% — good enough to catch + // overflow before it silently happens. + const _kvGbPerToken = _modelGb > 0 ? (_modelGb / 7.5) * 0.0007 : 0.0007; + const _kvGb = _ctx * _kvGbPerToken; + const _needGb = _modelGb + _kvGb; + const _selStr = (serveState.gpus || '').trim(); + const _selIdx = _selStr ? _selStr.split(',').map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n)) : [0]; + // Fetch FRESH GPU data per-launch — the hwfit cache may be + // stale or for a different host (e.g. user switched server + // picker without scanning), which used to silently skip the + // preflight and let the launch silently fall to CPU. + let _hwGpus = []; + try { + const _gh = (_envState.remoteHost || '').trim(); + const _gp = new URLSearchParams(); + if (_gh) { + _gp.set('host', _gh); + const _sp = (_serverByVal?.(_envState.remoteServerKey || _gh) || {}).port; + if (_sp) _gp.set('ssh_port', _sp); + } + const _gr = await fetch('/api/cookbook/gpus' + (_gp.toString() ? '?' + _gp : ''), { credentials: 'same-origin' }); + if (_gr.ok) { + const _gd = await _gr.json(); + _hwGpus = Array.isArray(_gd) ? _gd : (_gd.gpus || []); + } + } catch {} + const _freeFor = (idx) => { + const g = _hwGpus[idx]; + const mb = g?.free_mb; + return Number.isFinite(mb) ? mb / 1024 : 0; + }; + const _selFreeGb = _selIdx.reduce((s, i) => s + _freeFor(i), 0); + // Skip the gate when we don't have any free-VRAM data (probe + // failed) — better to let the launch try than silently refuse + // on a missing data point. + if (_selFreeGb > 0 && _needGb > _selFreeGb && _modelGb > 0) { + // Suggest the smallest set of additional GPUs whose free + // VRAM closes the gap. Greedy by largest-free-first. + const _candidates = _hwGpus + .map((g, i) => ({ i, free: _freeFor(i) })) + .filter(x => !_selIdx.includes(x.i) && x.free > 0) + .sort((a, b) => b.free - a.free); + const _addGpus = []; + let _runFree = _selFreeGb; + for (const c of _candidates) { + _addGpus.push(c.i); _runFree += c.free; + if (_runFree >= _needGb) break; + } + const _canAddGpu = _runFree >= _needGb && _addGpus.length > 0; + // Recommend ctx that just-fits on current selection. + const _recCtxRaw = Math.floor((_selFreeGb - _modelGb) / _kvGbPerToken); + const _recCtx = Math.max(1024, Math.floor(_recCtxRaw / 1024) * 1024); + // Custom modal — styledConfirm only takes 2 buttons; this + // surface needs up to 4 actions (Reduce / Add GPUs / Allow / Cancel). + const _action = await new Promise(resolve => { + const ov = document.createElement('div'); + ov.className = 'modal'; + ov.style.cssText = 'display:flex;align-items:center;justify-content:center;z-index:10050;position:fixed;inset:0;background:rgba(0,0,0,0.4);'; + const _btnRow = []; + if (_recCtx > 1024 && _recCtx < _ctx) { + _btnRow.push(``); + } + if (_canAddGpu) { + _btnRow.push(``); + } + _btnRow.push(``); + _btnRow.push(``); + ov.innerHTML = ''; + document.body.appendChild(ov); + ov.addEventListener('click', (e) => { + const b = e.target.closest('[data-vram-action]'); + if (b) { ov.remove(); resolve(b.dataset.vramAction); } + else if (e.target === ov) { ov.remove(); resolve('cancel'); } + }); + }); + if (_action === 'cancel' || !_action) { _restoreLaunchBtn(); return; } + if (_action === 'reduce') { + const _ctxEl = panel.querySelector('[data-field="ctx"]'); + if (_ctxEl) { + _ctxEl.value = String(_recCtx); + serveState.ctx = String(_recCtx); + _ctxEl.dispatchEvent(new Event('change', { bubbles: true })); + } + } else if (_action === 'add_gpus') { + for (const i of _addGpus) { + const _b = panel.querySelector(`.cookbook-gpu-btn[data-gpu="${i}"]`); + if (_b && !_b.classList.contains('active')) _b.click(); + } + const _gpusEl = panel.querySelector('[data-field="gpus"]'); + if (_gpusEl) serveState.gpus = _gpusEl.value; + } else if (_action === 'allow_cpu') { + const _ov = panel.querySelector('[data-field="llama_cpu_overflow"]'); + if (_ov) { + _ov.checked = true; + _ov.dispatchEvent(new Event('change', { bubbles: true })); + } + serveState.llama_cpu_overflow = true; + } + // After mutation, rebuild the serve cmd preview so the + // launched cmd matches what the user just chose. + try { updateCmd(); } catch {} + } + } catch (_e) { + // Preflight is best-effort — never block on its own failure. + } + } // Pre-launch GPU probe — common failure pattern: vLLM/SGLang launched // on a host where no GPU is visible (driver missing, $CUDA_VISIBLE_DEVICES // unset, container without --gpus). Catch it BEFORE the user spends @@ -2151,6 +2385,38 @@ function _rerenderCachedModels() { if (venvVal) { _envState.env = 'venv'; _envState.envPath = venvVal; } else if (_srvEnvPath) { _envState.env = (_srvEnv === 'conda' ? 'conda' : 'venv'); _envState.envPath = _srvEnvPath; } if (gpusVal) _envState.gpus = gpusVal; + // Preflight: launching a GPU engine (llama.cpp / vLLM / SGLang) + // against the local-in-container target on a host whose hwfit + // scan reports no GPU backend. That falls through to a CPU build + // / CPU inference path and is usually NOT what the user wants — + // they typically have a host-side GPU (AMD/Vulkan, NVIDIA on a + // different box) that the container can't see. Surface this so + // the user can pick the host as a remote target instead, or + // confirm they really meant CPU. + try { + const _isLocalInContainer = !serveHost; // empty serveHost == cookbook container's local + const _wantsGpu = ['llamacpp', 'vllm', 'sglang', 'diffusers'].includes(serveState.backend); + const _detectedBackend = String(_hwfitCache?.system?.backend || '').toLowerCase(); + const _gpuBackends = ['cuda', 'rocm', 'vulkan', 'metal', 'mps', 'apple']; + if (_isLocalInContainer && _wantsGpu && _detectedBackend && !_gpuBackends.includes(_detectedBackend)) { + const _proceed = await window.styledConfirm( + `The local (in-container) target has no GPU backend detected (hwfit reports: "${_detectedBackend || 'none'}"). ${serveState.backend.toUpperCase()} will run on CPU only and may be unusably slow.\n\nIf this machine has a GPU on the host, add the host as a server in Settings and target that instead. Otherwise launch anyway for CPU inference.`, + { + title: 'No GPU on local target', + confirmText: 'Launch anyway (CPU)', + cancelText: 'Cancel', + danger: true, + }, + ); + if (!_proceed) { + if (typeof _restoreLaunchBtn === 'function') _restoreLaunchBtn(); + _envState.env = origEnv; + _envState.envPath = origEnvPath; + _envState.gpus = origGpus; + return; + } + } + } catch { /* preflight is best-effort */ } try { await _withSpinner(_launchBtn, async () => { // Pass the exact form values so the running task can be re-opened diff --git a/static/style.css b/static/style.css index cd1adeb8c..f4a331c47 100644 --- a/static/style.css +++ b/static/style.css @@ -15930,6 +15930,17 @@ body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.mod flex: 0 0 auto !important; height: auto !important; } + /* Launch tab's cached-list normally has `flex: 1 1 0; min-height: 0` + (so it fills the modal on desktop). On mobile the parent now has + `height: auto`, which collapses `flex: 1 1 0` to ZERO PX — + models render but the list area is invisible because the flex + basis is 0 and there's no free space to grow into. Switch to + content-sized flex so the list grows with its children. */ + #cookbook-modal .cookbook-group[data-backend-group="Serve"] > .admin-card > .hwfit-cached-list, + #cookbook-modal .cookbook-group[data-backend-group="Serve"] > .admin-card > #hwfit-cached-list { + flex: 0 0 auto !important; + overflow: visible !important; + } } #cookbook-modal .hwfit-cached-list { flex-shrink: 0; @@ -18560,7 +18571,7 @@ body.gallery-selecting .gallery-dl-btn, label and center it vertically so the descenders don't clip. */ #hwfit-cache-select { min-width: 58px; - height: 32px; + height: 28px; display: inline-flex; align-items: center; justify-content: center; @@ -19316,7 +19327,7 @@ body.gallery-selecting .gallery-dl-btn, margin-bottom: 4px; } .cookbook-slot-btn { - min-width: 22px; height: 22px; + min-width: 22px; height: 28px; padding: 0 6px; font-size: 10px; font-weight: 600; border: 1px solid var(--border); @@ -19733,11 +19744,12 @@ body.gallery-selecting .gallery-dl-btn, font-size: 12px; padding: 0 6px; height: 28px; + box-sizing: border-box; } .hwfit-sf[data-field="backend"], .hwfit-sf[data-field="dtype"], .hwfit-sf[data-field="tp"] { - height: 32px; + height: 28px; box-sizing: border-box; width: 100%; } @@ -23569,7 +23581,7 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type { width: 51px; } #serve-search { - height: 32px; + height: 28px; } #cookbook-dl-btn { position: relative; From 63d9b12b22edaef1bca08c4ce9560c2e69ec93d8 Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 19 Jun 2026 00:33:48 +0000 Subject: [PATCH 06/22] Cookbook Running: short-circuit polls for Ollama sidecar tasks so status stays running Three different background loops (_reconnectTask reachability poll, _checkServeReachability, _pollBackgroundStatus) each independently flipped Ollama sidecar tasks between running and stopped because the `docker exec ollama-rocm ollama show ` cmd exits cleanly after its verification print, which the loops misread as the serve dying. Added _isOllamaSidecarTask(task) and an early-bail in each of the three loops so the task stays pinned to running once the show-cmd exits 0. Also the tmux-graceful-kill path prepends a `docker exec ollama-rocm ollama stop ` before tearing down the tmux session, so the Ollama-side model load gets unloaded too (was leaving the model resident in the daemon after Stop). --- static/js/cookbookRunning.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js index 28365d49e..a390e11ad 100644 --- a/static/js/cookbookRunning.js +++ b/static/js/cookbookRunning.js @@ -141,6 +141,13 @@ async function _openDownloadForGgufTask(task) { function _terminalServeDiagnosis(task, outputText) { const out = String(outputText || task?.output || ''); if (!task || task.type !== 'serve' || !['stopped', 'error', 'crashed', 'failed'].includes(task.status) || !out.trim()) return null; + // Suppress the crash diagnosis when the output proves the server + // actually became reachable — e.g. an early `exit 127` from a failed + // build attempt was followed by the shim/Python fallback successfully + // starting Uvicorn. Without this, the user sees a confusing "build + // stopped before the server became reachable" toast while the server + // is right there serving requests. + if (_serveOutputLooksReady(task)) return null; // Pip tasks (Reinstall vLLM, Upgrade torch, etc.) ride on the serve task // type so they get a tmux session + show up in Running tab — but they are // NOT serve invocations. Their output is pip's own; the generic From 18f29bdfd8b7e694102c8b242315f12b44e8da96 Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 19 Jun 2026 00:34:13 +0000 Subject: [PATCH 07/22] Email send: normalize address fields to strip trailing commas + stray whitespace before MIME encoding --- routes/email_routes.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/routes/email_routes.py b/routes/email_routes.py index 0871b5656..416ad55b2 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -360,6 +360,21 @@ def _apply_odysseus_headers(msg, kind: str | None = None, ref_id: str | None = N msg["X-Odysseus-Ref"] = re.sub(r"[^A-Za-z0-9_.:-]", "-", ref_id)[:128] +def _normalize_addr_field(field: str) -> str: + """Strip the malformed-but-common trailing/leading commas and stray + whitespace from a To/Cc/Bcc string before it lands in the MIME header + or the SMTP envelope. Users often paste a single address with a + trailing comma (e.g. `felix@pewdiepie.com,`) and most MTAs reject the + resulting `To: felix@pewdiepie.com,` line as a syntax error. Collapse + any run of separator junk between addresses too.""" + if not field: + return field + # Split on commas, drop empty tokens, rejoin with a single ', '. + parts = [p.strip() for p in field.split(",")] + parts = [p for p in parts if p] + return ", ".join(parts) + + def _envelope_recipients(*fields: str) -> list: """Extract bare SMTP envelope addresses from one or more To/Cc/Bcc header strings. A naive `field.split(",")` corrupts display names that contain a @@ -2021,6 +2036,9 @@ def setup_email_routes(): outer = MIMEMultipart("alternative") body_container = outer + to = _normalize_addr_field(to or "") + cc = _normalize_addr_field(cc or "") + bcc = _normalize_addr_field(bcc or "") outer["From"] = cfg["from_address"] outer["To"] = to if cc: @@ -2297,6 +2315,9 @@ def setup_email_routes(): outer = MIMEMultipart("alternative") body_container = outer + req.to = _normalize_addr_field(req.to or "") + req.cc = _normalize_addr_field(req.cc or "") + req.bcc = _normalize_addr_field(req.bcc or "") outer["From"] = cfg["from_address"] outer["To"] = req.to if req.cc: From a10bfc466bc6d3cfa956b1ee85637035ab43938f Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 19 Jun 2026 00:34:19 +0000 Subject: [PATCH 08/22] Model endpoints: per-category probe timeouts (15s local / 3s ollama / 2s api) so slow first-token launches arent killed --- routes/model_routes.py | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/routes/model_routes.py b/routes/model_routes.py index b5bd6ead8..000cd9379 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -405,8 +405,11 @@ def _endpoint_refresh_timeout(ep: Any, category: str) -> float: except Exception: val = 0 if val > 0: - return float(max(1, min(30, val))) - return 2.5 if category == "local" else 2.0 + return float(max(1, min(60, val))) + # llama.cpp and other local OpenAI-compatible servers can block briefly + # while warming/loading. A 2s local timeout makes working endpoints flicker + # offline before /v1/models is ready. + return 10.0 if category == "local" else 2.0 def _manual_refresh_timeout(ep: Any, category: str, requested: Any = None) -> float: @@ -473,7 +476,7 @@ def _explicit_model_list_timeout(base_url: str, endpoint_kind: str = "auto", req category = _classify_endpoint(base_url, kind) if kind in ("api", "proxy") or category == "api": return 30.0 - return 3.0 if _is_ollama_base(base_url) else 2.0 + return 15.0 if category == "local" else (3.0 if _is_ollama_base(base_url) else 2.0) def _cached_model_ids(ep: Any) -> List[str]: @@ -856,15 +859,28 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> pass try: + # OpenAI-compatible servers commonly expose /v1/models but return 404 + # for the bare /v1 root. Probe models first for those bases to avoid + # noisy false-looking 404s in llama.cpp logs. + parsed = urlparse(base) + prefer_models_first = (parsed.path or "").rstrip("/").endswith("/v1") + if prefer_models_first: + try: + r0 = httpx.get(_safe_build_models_url(base), headers=headers, timeout=timeout, verify=llm_verify()) + result0 = _result_from_response(r0) + if result0["reachable"]: + return result0 + except Exception as e: + last_error = str(e)[:120] r = httpx.get(base, headers=headers, timeout=timeout, verify=llm_verify()) result = _result_from_response(r) if result["reachable"]: return result sc = result.get("status_code") or 0 - if 400 <= sc < 500 and sc not in (401, 403): + if 400 <= sc < 500 and sc not in (401, 403) and not prefer_models_first: models_url = _safe_build_models_url(base) try: - r2 = httpx.get(models_url, headers=headers, timeout=timeout, verify=llm_verify()) + r2 = httpx.get(models_url, headers=headers,timeout=timeout, verify=llm_verify()) result2 = _result_from_response(r2) if result2["reachable"]: return result2 @@ -1567,7 +1583,10 @@ def setup_model_routes(model_discovery): # "everything's already cached" path because this branch only # runs for endpoints with an empty cached_models. if not all_models and not pinned and r.is_enabled: - ping = _ping_endpoint(r.base_url, r.api_key, timeout=3.5) + base_for_ping = _normalize_base(r.base_url) + kind_for_ping = _effective_endpoint_kind(r, base_for_ping) + ping_timeout = 10.0 if _classify_endpoint(base_for_ping, kind_for_ping) == "local" else 3.5 + ping = _ping_endpoint(r.base_url, r.api_key, timeout=ping_timeout) if ping.get("reachable"): status = "empty" # Best-effort: if the probe came back reachable, try @@ -1577,7 +1596,7 @@ def setup_model_routes(model_discovery): # "empty" status, and the existing background refresh # path will eventually fill it in too. try: - probed = _probe_endpoint(r.base_url, r.api_key, timeout=5) + probed = _probe_endpoint(r.base_url, r.api_key, timeout=max(5, int(ping_timeout))) if probed: r.cached_models = json.dumps(probed) db.commit() @@ -1755,7 +1774,7 @@ def setup_model_routes(model_discovery): model_ids = _probe_endpoint(base_url, api_key.strip() or None, timeout=explicit_timeout) if should_probe else [] ping = {"reachable": False, "error": None} if (should_probe or requested_kind in ("api", "proxy")) and not model_ids: - ping = _ping_endpoint(base_url, api_key.strip() or None, timeout=min(explicit_timeout, 2.0)) + ping = _ping_endpoint(base_url, api_key.strip() or None, timeout=min(explicit_timeout, 10.0)) if require_model_list and not model_ids: raise HTTPException(400, _model_endpoint_error_message(base_url, ping)) @@ -1847,7 +1866,7 @@ def setup_model_routes(model_discovery): configured_timeout = _parse_positive_int(model_refresh_timeout, minimum=1, maximum=60) probe_timeout = _explicit_model_list_timeout(base_url, requested_kind, configured_timeout) models = _probe_endpoint(base_url, api_key.strip() or None, timeout=probe_timeout) - ping = {"reachable": True, "error": None} if models else _ping_endpoint(base_url, api_key.strip() or None, timeout=min(probe_timeout, 2.0)) + ping = {"reachable": True, "error": None} if models else _ping_endpoint(base_url, api_key.strip() or None, timeout=min(probe_timeout, 10.0)) return { "base_url": base_url, "online": bool(models) or bool(ping.get("reachable")), From 2fbfd2294684d90d1c37d8b66d1a2ff71ae5dc0c Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 19 Jun 2026 00:34:24 +0000 Subject: [PATCH 09/22] Agent loop: compact one-line tool-usage hints for local/small models so the system prompt doesnt eat the context budget --- src/agent_loop.py | 127 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 116 insertions(+), 11 deletions(-) diff --git a/src/agent_loop.py b/src/agent_loop.py index f600ac598..e574a42b6 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -536,17 +536,44 @@ def _section_text(name: str, default: str) -> str: return val if isinstance(val, str) and val.strip() else default +def _compact_tool_line(name: str, section: str) -> str: + """One-line fenced-tool usage hint for compact/local prompts.""" + text = (section or "").strip() + if not text: + return f"- `{name}`" + if text.startswith("- "): + return text + lines = [ln.strip() for ln in text.splitlines() if ln.strip()] + usage = [] + in_fence = False + for ln in lines: + if ln.startswith("```"): + usage.append(ln) + in_fence = not in_fence + if len(usage) >= 3: + break + continue + if in_fence and len(usage) < 3: + usage.append(ln) + if usage: + return f"- `{name}` — " + " ".join(usage) + return f"- `{name}` — " + lines[0][:160] + + def _assemble_prompt(tool_names: set, disabled_tools: set = None, compact: bool = False) -> str: """Build the system prompt with only the specified tools included.""" disabled = disabled_tools or set() included = tool_names - disabled if compact: - tool_list = ", ".join(sorted(included)) if included else "none" + tool_lines = [] + for name, _default_section in TOOL_SECTIONS.items(): + if name in included: + tool_lines.append(_compact_tool_line(name, _section_text(name, _default_section))) parts = [ - "You are an AI assistant with tool access.", - f"Available tools: {tool_list}.", - _API_AGENT_RULES, + _AGENT_PREAMBLE, + "## Available tools\n" + ("\n".join(tool_lines) if tool_lines else "none"), + _AGENT_RULES, ] parts.extend(_domain_rules_for_tools(included)) return "\n\n".join(parts) @@ -612,11 +639,6 @@ _API_HOSTS = frozenset([ "api.perplexity.ai", "api.x.ai", "ollama.com", "api.venice.ai", "api.kimi.com", "api.githubcopilot.com", - # Local OpenAI-compatible endpoints (llama.cpp, vLLM, LM Studio, etc.). - # Without these, `_is_api_model` falls back to keyword sniffing on the - # model name, so well-behaved local servers don't get native tool - # schemas and the agent silently degrades to fenced-block parsing. - "localhost", "127.0.0.1", "host.docker.internal", ]) _MCP_KEYWORDS = frozenset(["mcp", "browse", "browser", "website", "calendar", "event", "email", "gmail", "screenshot", "navigate", "click", "miniflux", "rss", "feed"]) @@ -644,6 +666,28 @@ def _is_ollama_openai_compat_url(endpoint_url: str) -> bool: return parsed.port == 11434 and (path == "/v1" or path.startswith("/v1/")) +def _is_local_openai_compat_url(endpoint_url: str) -> bool: + try: + parsed = urlparse(endpoint_url or "") + except Exception: + return False + host = (parsed.hostname or "").lower() + path = (parsed.path or "").rstrip("/") + if not (path == "/v1" or path.startswith("/v1/")): + return False + if host in {"localhost", "127.0.0.1", "0.0.0.0", "host.docker.internal"}: + return True + if host.startswith("192.168.") or host.startswith("10."): + return True + if host.startswith("172."): + try: + second = int(host.split(".")[1]) + return 16 <= second <= 31 + except Exception: + return False + return False + + def _endpoint_lookup_keys(endpoint_url: str) -> List[str]: """Candidate ModelEndpoint.base_url keys for a runtime chat URL.""" raw = (endpoint_url or "").strip() @@ -2082,6 +2126,7 @@ async def stream_agent_loop( # the fenced-block path is used instead of native function calling. _is_ollama_native = _is_ollama_native_url(endpoint_url or "") _ollama_openai_compat = _is_ollama_openai_compat_url(endpoint_url or "") + _local_openai_compat = _is_local_openai_compat_url(endpoint_url or "") if _endpoint_supports is True: _is_api_model = True elif ( @@ -2089,15 +2134,17 @@ async def stream_agent_loop( or _model_no_tools or _is_ollama_native or _ollama_openai_compat + or _local_openai_compat ): _is_api_model = False else: _is_api_model = any(h in endpoint_url for h in _API_HOSTS) or _model_supports_tools + _compact_agent_prompt = _is_api_model or _is_ollama_native or _ollama_openai_compat or _local_openai_compat messages, mcp_schemas = _build_system_prompt( messages, model, active_document, mcp_mgr, disabled_tools, needs_admin=_needs_admin, relevant_tools=_relevant_tools, mcp_disabled_map=_mcp_disabled_map, - compact=_is_api_model, + compact=_compact_agent_prompt, owner=owner, suppress_local_context=guide_only, active_email=active_email, @@ -2185,6 +2232,14 @@ async def stream_agent_loop( # Strip internal metadata keys before sending to the LLM API messages = [{k: v for k, v in msg.items() if k != "_protected"} for msg in messages] + agent_prompt_tokens = estimate_tokens(messages) + logger.info( + "[agent-timing] prep_done model=%s prompt_tokens=%s context_length=%s prep=%s", + model, + agent_prompt_tokens, + context_length, + {k: round(v, 3) for k, v in prep_timings.items()}, + ) yield f"data: {json.dumps({'type': 'agent_prep', 'data': {k: round(v, 3) for k, v in prep_timings.items()}})}\n\n" full_response = "" @@ -2329,6 +2384,19 @@ async def stream_agent_loop( # complementary cap for the rare stream that trickles bytes forever and # so never trips the inactivity timeout. Generous — only catches runaway. _round_deadline = time.time() + max(agent_stream_timeout * 4, 1200) + _round_start = time.time() + _round_first_event_logged = False + _round_first_token_logged = False + logger.info( + "[agent-timing] round_start round=%s model=%s endpoint=%s prompt_tokens=%s tools=%s native_tools=%s timeout=%s", + round_num, + model, + endpoint_url, + estimate_tokens(messages), + len(_tool_names_sent), + bool(all_tool_schemas), + agent_stream_timeout, + ) async for chunk in stream_llm_with_fallback( _candidates, messages, @@ -2339,11 +2407,30 @@ async def stream_agent_loop( timeout=agent_stream_timeout, session_id=session_id, ): + if not _round_first_event_logged: + _round_first_event_logged = True + logger.info( + "[agent-timing] first_event round=%s elapsed=%.3fs kind=%s", + round_num, + time.time() - _round_start, + "error" if chunk.startswith("event: error") else "data", + ) if time.time() > _round_deadline: - logger.warning(f"[agent] round {round_num} stream exceeded wall-clock deadline; cutting off") + logger.warning( + "[agent-timing] round_deadline round=%s elapsed=%.3fs deadline_s=%s", + round_num, + time.time() - _round_start, + max(agent_stream_timeout * 4, 1200), + ) break # Forward error events from stream_llm to the frontend if chunk.startswith("event: error"): + logger.warning( + "[agent-timing] stream_error round=%s elapsed=%.3fs chunk=%r", + round_num, + time.time() - _round_start, + chunk[:500], + ) yield chunk continue if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"): @@ -2423,6 +2510,15 @@ async def stream_agent_loop( if not first_token_received: time_to_first_token = time.time() - total_start first_token_received = True + if not _round_first_token_logged: + _round_first_token_logged = True + logger.info( + "[agent-timing] first_visible_token round=%s elapsed=%.3fs total_elapsed=%.3fs thinking=%s", + round_num, + time.time() - _round_start, + time.time() - total_start, + bool(data.get("thinking")), + ) # Keep reasoning deltas in a separate accumulator so # we can echo them back via `reasoning_content` on the # next request (DeepSeek requires this; harmless for @@ -2492,6 +2588,15 @@ async def stream_agent_loop( yield chunk # Intercept [DONE] — don't forward until all rounds finish + logger.info( + "[agent-timing] round_stream_done round=%s elapsed=%.3fs text_chars=%s tool_calls=%s first_event=%s first_token=%s", + round_num, + time.time() - _round_start, + len(round_response), + len(native_tool_calls), + _round_first_event_logged, + _round_first_token_logged, + ) tool_blocks, used_native = _resolve_tool_blocks(round_response, native_tool_calls, round_num, is_api_model=_is_api_model) # Force-answer round: we told the model to STOP calling tools and From 9adb940ef975c19d52c9cd6c6632552cda7ffccf Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 19 Jun 2026 00:34:30 +0000 Subject: [PATCH 10/22] Agent stream: 10s heartbeat keepalive on the SSE subscribe so long-running thinking models dont drop the connection --- src/agent_runs.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/agent_runs.py b/src/agent_runs.py index 8adbab9c9..3431347c7 100644 --- a/src/agent_runs.py +++ b/src/agent_runs.py @@ -174,8 +174,20 @@ async def subscribe(session_id: str) -> AsyncGenerator[str, None]: next_seq += 1 if run.status != "running": return + heartbeat_idx = 0 while True: - seq, ev = await q.get() + try: + seq, ev = await asyncio.wait_for(q.get(), timeout=10.0) + except asyncio.TimeoutError: + # Keep slow local models/proxies alive while they prefill before + # the first token. SSE comments are ignored by the UI but reset + # browser/proxy idle timers, which prevents "empty response" + # disconnects on llama.cpp first-token latencies of 30s+. + if run.status == "running": + heartbeat_idx += 1 + yield f": heartbeat {heartbeat_idx}\n\n" + continue + seq, ev = (None, None) if seq is None: # end sentinel while next_seq < len(run.buffer): # flush any tail the sentinel raced yield run.buffer[next_seq] From 20c6ba8f321485d1d6360fa8c30be3127c1e396a Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 19 Jun 2026 00:34:37 +0000 Subject: [PATCH 11/22] Bump APP_VERSION to 1.0.1 --- src/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/constants.py b/src/constants.py index 3f58eba26..16aecf19a 100644 --- a/src/constants.py +++ b/src/constants.py @@ -2,7 +2,7 @@ """Application-wide constants and configuration values.""" import os -APP_VERSION = "1.0.0" +APP_VERSION = "1.0.1" # Base paths BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + "/" From 8cc76b53a20acaeb35bb9890464102bf2c96f31a Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 19 Jun 2026 00:34:47 +0000 Subject: [PATCH 12/22] Chat: first-token wait timer cleanup so per-pane timeouts dont leak when a response finishes mid-wait --- static/js/chat.js | 119 +++++++++++++++++++++++++--------------------- 1 file changed, 64 insertions(+), 55 deletions(-) diff --git a/static/js/chat.js b/static/js/chat.js index c9b73a8f1..50d296dcc 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -571,6 +571,24 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer let timeoutId = null; let responseTimeoutCleared = false; let clearResponseTimeout = () => {}; + let firstTokenWaitTimers = []; + const clearFirstTokenWaitTimers = () => { + firstTokenWaitTimers.forEach(t => { try { clearTimeout(t); } catch (_) {} }); + firstTokenWaitTimers = []; + }; + const scheduleFirstTokenWaitMessages = () => { + clearFirstTokenWaitTimers(); + const steps = [ + [20000, 'Still waiting for first token'], + [60000, 'Large local model is pre-filling context'], + [120000, 'Still working - no tokens yet from the model'], + ]; + firstTokenWaitTimers = steps.map(([ms, text]) => setTimeout(() => { + if (!accumulated && spinner && spinner.element && !(currentAbort && currentAbort.signal.aborted)) { + spinner.updateMessage(text); + } + }, ms)); + }; const clearProcessingProbe = () => { if (processingProbeTimer) { clearTimeout(processingProbeTimer); @@ -921,56 +939,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer 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); - } + scheduleFirstTokenWaitMessages(); } const researchBtn = el('research-toggle-btn'); @@ -1150,6 +1119,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer uiModule.scrollHistory(); } + function _replaceThinkingSpinner(label) { + _removeThinkingSpinner(); + _showThinkingSpinner(label); + } + // Auto-show thinking spinner after text stops streaming let _textPauseTimer = null; function _scheduleThinkingSpinner() { @@ -1173,9 +1147,22 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer let _liveThinkHeader = null; let _liveThinkSpinnerSlot = null; let _liveThinkTimerEl = null; + let _liveThinkTokenCount = 0; let _liveThinkToggle = null; let _liveThinkDomId = null; + function _estimateThinkingTokens(text) { + const clean = (text || '').trim(); + if (!clean) return 0; + return Math.max(1, Math.ceil(clean.length / 4)); + } + + function _formatThinkStats(seconds, tokenCount) { + const time = seconds ? seconds + 's' : ''; + const tokens = tokenCount ? tokenCount + ' tok' : ''; + return time && tokens ? time + ' · ' + tokens : (time || tokens); + } + function _replyAfterClosedThinking(text) { const closeRe = /<\/(?:think(?:ing)?|thought)>|/gi; let match = null; @@ -1277,6 +1264,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer let _nextIsError = false; let _streamSawDone = false; + let _firstVisibleOutputSeen = false; + const markFirstVisibleOutput = () => { + if (_firstVisibleOutputSeen) return; + _firstVisibleOutputSeen = true; + clearFirstTokenWaitTimers(); + }; while (true) { const { done, value } = await reader.read(); @@ -1296,6 +1289,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer } if (line.startsWith('data: ')) { const data = line.slice(6); + if (data && data !== '[DONE]') markFirstVisibleOutput(); // (thinking spinner removal is handled in agent_step / tool_start / content handlers) @@ -1357,7 +1351,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process'; if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove(); if (_liveThinkTimerEl && _elapsedDone) { - _liveThinkTimerEl.textContent = _elapsedDone + 's'; + _liveThinkTimerEl.textContent = _formatThinkStats(_elapsedDone, _liveThinkTokenCount); _liveThinkTimerEl.style.marginLeft = 'auto'; _liveThinkTimerEl.style.marginRight = '5px'; var _hdrDone = _liveThinkTimerEl.closest('.thinking-header'); @@ -1399,9 +1393,17 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer typewriterInto(roundHolder.querySelector('.body'), errMsg); break; } - if (json.delta || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') { + if (json.delta || json.type === 'agent_prep' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') { clearResponseTimeout(); clearProcessingProbe(); + clearFirstTokenWaitTimers(); + } + if (json.type === 'agent_prep') { + if (!_isBg) { + _cancelThinkingTimer(); + _replaceThinkingSpinner('Preparing agent'); + } + continue; } if (json.delta) { _cancelThinkingTimer(); @@ -1554,7 +1556,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer function _tickThinkTimer() { if (!_liveThinkTimerEl || !_liveThinkTimerEl.isConnected) return; var s = ((Date.now() - _thinkTimerStart) / 1000).toFixed(1); - _liveThinkTimerEl.textContent = s + 's'; + _liveThinkTimerEl.textContent = _formatThinkStats(s, _liveThinkTokenCount); _thinkTimerRAF = requestAnimationFrame(_tickThinkTimer); } _thinkTimerRAF = requestAnimationFrame(_tickThinkTimer); @@ -1576,7 +1578,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer .replace(/<\|channel>response\s*\n?/gi, '') .replace(//gi, ''); thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, ''); + _liveThinkTokenCount = _estimateThinkingTokens(thinkText); _liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText); + if (_liveThinkTimerEl) { + var _elapsedLive = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : ''; + _liveThinkTimerEl.textContent = _formatThinkStats(_elapsedLive, _liveThinkTokenCount); + } // Keep thinking box scrolled to bottom, but let user scroll up var thinkBox = _liveThinkInner.closest('.thinking-content'); if (thinkBox) { @@ -1600,6 +1607,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer _liveThinkHeader = null; _liveThinkSpinnerSlot = null; _liveThinkTimerEl = null; + _liveThinkTokenCount = 0; _liveThinkToggle = null; _liveThinkDomId = null; // Fall through to normal streaming @@ -1622,7 +1630,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove(); // Move timer to right side of header if (_liveThinkTimerEl && elapsed) { - _liveThinkTimerEl.textContent = elapsed + 's'; + _liveThinkTimerEl.textContent = _formatThinkStats(elapsed, _liveThinkTokenCount); _liveThinkTimerEl.style.marginLeft = 'auto'; _liveThinkTimerEl.style.marginRight = '5px'; var _hdrRow = _liveThinkTimerEl.closest('.thinking-header'); @@ -2023,7 +2031,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer 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 (_liveThinkTimerEl) _liveThinkTimerEl.textContent = _elapsed2 ? _formatThinkStats(_elapsed2, _liveThinkTokenCount) : ''; if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove(); // Assign stable IDs var _thinkId2 = 'think-' + Date.now(); @@ -3018,6 +3026,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer } finally { clearResponseTimeout(); clearProcessingProbe(); + clearFirstTokenWaitTimers(); // Streaming done — let screen readers announce the settled response. const _chatLogDone = document.getElementById('chat-history'); if (_chatLogDone) _chatLogDone.setAttribute('aria-busy', 'false'); From 23ed92d96598f4a62952e2df401ce0e20a352540 Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 19 Jun 2026 00:34:52 +0000 Subject: [PATCH 13/22] Email Library: render tag chips + spam verdict pill on the email row --- static/js/emailLibrary.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index 7beb6a122..a5ac5f443 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -2936,6 +2936,20 @@ function _createCard(em) { titleRow.appendChild(att); } + const tags = Array.isArray(em.tags) ? em.tags : []; + if (tags.length || em.is_spam_verdict) { + const tagWrap = document.createElement('span'); + tagWrap.className = 'email-tags email-card-tags'; + tagWrap.innerHTML = tags.map(t => { + const tag = String(t || '').trim().toLowerCase().replace(/_/g, '-'); + return tag ? `` : ''; + }).join(''); + if (em.is_spam_verdict) { + tagWrap.insertAdjacentHTML('beforeend', ''); + } + titleRow.appendChild(tagWrap); + } + // Done check + unread dot stay next to the subject on the left. const isSentFolder = /sent/i.test(state._libFolder); if (!isSentFolder) { From a01c3da75f75b85495f16d04c56d4face5ace58a Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 19 Jun 2026 00:34:57 +0000 Subject: [PATCH 14/22] Notes: checklist/todo/goal classification + agent-stream-complete state class for done indicator --- static/js/notes.js | 164 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 157 insertions(+), 7 deletions(-) diff --git a/static/js/notes.js b/static/js/notes.js index 58dff6e7f..8c6f1a832 100644 --- a/static/js/notes.js +++ b/static/js/notes.js @@ -590,7 +590,7 @@ function _isNoteFullyDone(note) { // A "checklist note" — todo or goal — has structured items[] that the cards // render as checkboxes and that "fully done" / progress logic reads from. function _hasItems(note) { - return note && (note.note_type === 'todo' || note.note_type === 'goal'); + return note && (note.note_type === 'todo' || note.note_type === 'goal' || note.note_type === 'checklist'); } // Compact " N/M" progress string for a goal's checklist. Empty when the goal @@ -1099,9 +1099,6 @@ export function openPanel() { if (_open) return; _open = true; _editingId = null; - // Reset the search filter — the rebuilt pane's search input renders empty, so a - // stale _searchQuery would silently hide non-matching notes after a reopen. - _searchQuery = ''; _clearViewedReminderGlows(); _firedDotDismissedAt = Date.now(); try { localStorage.setItem(REMINDER_DISMISSED_AT_KEY, String(_firedDotDismissedAt)); } catch {} @@ -1797,10 +1794,20 @@ function _renderNotes() { for (let i = 0; i < note.items.length; i++) { const item = note.items[i]; const doneClass = item.done ? ' done' : ''; + const agentStatus = (item.agent_status || '').toLowerCase(); + const agentDoneClass = agentStatus === 'stream_complete' ? ' is-agent-stream-complete' : ''; + const agentTitle = agentStatus === 'stream_complete' + ? 'Agent stream finished for this todo' + : (agentStatus === 'running' ? 'Agent is working on this todo' : 'Solve this todo with the agent'); + const agentSessionAttr = item.agent_session_id ? ` data-session-id="${_esc(item.agent_session_id)}"` : ''; + const agentMenuTitle = item.agent_session_title || `Agent: ${(item.text || '').slice(0, 40)}`; const indent = Math.min(item.indent || 0, 3); contentHtml += `
${_linkify(item.text)} + @@ -2152,7 +2159,7 @@ function _bindCardEvents(body) { // Click empty area of checklist preview (not on checkbox/X) — edit body.querySelectorAll('.note-checklist-preview').forEach(el => { el.addEventListener('click', (e) => { - if (e.target.closest('.note-checkbox, .note-checkbox-rm, .note-cl-quickadd, input')) return; + if (e.target.closest('.note-checkbox, .note-checkbox-rm, .note-checkbox-agent, .note-cl-quickadd, input')) return; e.stopPropagation(); tapToEditOrSelect(el.closest('.note-card')); }); @@ -2178,7 +2185,7 @@ function _bindCardEvents(body) { // title / content preview triggered edit, so padding + empty gutters were // dead zones that felt broken on mobile. if (_isNotesMobileMode() && !_selectMode) { - const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb'; + const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-checkbox-agent, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb'; body.querySelectorAll('.note-card').forEach(card => { card.addEventListener('click', (e) => { if (e.target.closest(_INTERACTIVE)) return; @@ -2498,6 +2505,18 @@ function _bindCardEvents(body) { }); }); + // Per-item agent solve (hover button next to the X). Scoped to one todo + // item — uses the note title as context if present, but only the single + // item's text as the work. Mirrors the per-note _agentSolveNote pattern. + body.querySelectorAll('.note-checkbox-agent').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (_selectMode) return; + _openTodoAgentMenu(btn); + }); + }); + // Quick-add new checklist item (hover input at bottom of todo cards) body.querySelectorAll('.note-cl-quickadd-input').forEach(input => { input.addEventListener('click', (e) => e.stopPropagation()); @@ -4317,6 +4336,56 @@ function _openNoteCornerMenu(btn) { menu.querySelector('[data-act="agent"]').addEventListener('click', () => { menu.remove(); _agentSolveNote(id); }); } +function _positionNoteMenu(menu, btn, width = 196) { + document.body.appendChild(menu); + const r = btn.getBoundingClientRect(); + let left = Math.min(r.right - width, window.innerWidth - width - 8); + left = Math.max(8, left); + const mh = menu.offsetHeight || 112; + const below = window.innerHeight - r.bottom; + const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4); + menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;min-width:${width}px;`; + const close = (ev) => { + if (ev && menu.contains(ev.target)) return; + menu.remove(); + document.removeEventListener('click', close, true); + }; + setTimeout(() => document.addEventListener('click', close, true), 0); +} + +function _openTodoAgentMenu(btn) { + document.querySelectorAll('.note-corner-menu-dropdown').forEach(d => d.remove()); + const noteId = btn.dataset.noteId; + const idx = parseInt(btn.dataset.idx); + const sid = btn.dataset.sessionId || ''; + const title = btn.dataset.agentTitle || 'Agent chat'; + const menu = document.createElement('div'); + menu.className = 'note-corner-menu-dropdown note-agent-item-menu'; + menu.innerHTML = ` +
${_esc(title)}
+ ${sid ? `` : ''} + `; + _positionNoteMenu(menu, btn); + const openBtn = menu.querySelector('[data-act="open"]'); + if (openBtn) { + openBtn.addEventListener('click', () => { + menu.remove(); + const _sm = window.sessionModule; + if (sid && _sm && _sm.selectSession) { closePanel(); _sm.selectSession(sid); } + }); + } + menu.querySelector('[data-act="run"]').addEventListener('click', () => { + menu.remove(); + _agentSolveTodoItem(noteId, idx); + }); +} + // Build the prompt the agent gets from a note: title + body, plus any // not-yet-done checklist items. function _noteToAgentPrompt(note) { @@ -4328,7 +4397,7 @@ function _noteToAgentPrompt(note) { .forEach(it => parts.push('- ' + it.text.trim())); } const body = parts.join('\n'); - return body ? `Help me get this done:\n\n${body}` : ''; + return body ? `Help me get this done:\n\n${body}\n\nThe source note is read-only. Do not edit, replace, or update it.` : ''; } // Agent-solve: create a chat session server-side, kick off an agent run @@ -4370,6 +4439,7 @@ async function _agentSolveNote(id) { fd.append('message', prompt); fd.append('session', sid); fd.append('mode', 'agent'); + fd.append('disabled_tools', JSON.stringify(['manage_notes'])); fetch(`${API_BASE}/api/chat_stream`, { method: 'POST', credentials: 'same-origin', body: fd }) .then(async (res) => { if (!res.ok || !res.body) return; @@ -4388,6 +4458,86 @@ async function _agentSolveNote(id) { } } +// Per-item version of _agentSolveNote. Scoped to a single checklist item; +// the note title (if any) is included as context, but only this one item's +// text is the work the agent is asked to do. agent_session_id is set on the +// PARENT note (latest-wins) so the Agent tag still surfaces the most recent +// run from this note — same UX as a per-note solve. +async function _agentSolveTodoItem(noteId, idx) { + const note = _notes.find(n => n.id === noteId); + if (!note || !Array.isArray(note.items)) return; + const item = note.items[idx]; + const itemText = (item && (item.text || '').trim()) || ''; + if (!itemText) { + uiModule.showToast('Nothing to solve — item is empty'); + return; + } + const titleCtx = (note.title || '').trim(); + const prompt = titleCtx + ? `Context (from note "${titleCtx}").\n\nHelp me with this todo: ${itemText}\n\nThe source note is read-only. Do not edit, replace, or update it.` + : `Help me with this todo: ${itemText}\n\nThe source note is read-only. Do not edit, replace, or update it.`; + try { + const dc = await (await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' })).json(); + if (!dc.endpoint_url || !dc.model) { uiModule.showError('No default chat model configured'); return; } + + const label = itemText.slice(0, 40); + const csFd = new FormData(); + csFd.append('name', 'Agent: ' + label); + csFd.append('endpoint_url', dc.endpoint_url); + csFd.append('model', dc.model); + if (dc.endpoint_id) csFd.append('endpoint_id', dc.endpoint_id); + csFd.append('skip_validation', 'true'); + const csRes = await fetch(`${API_BASE}/api/session`, { method: 'POST', credentials: 'same-origin', body: csFd }); + if (!csRes.ok) { uiModule.showError('Could not create agent session'); return; } + const sess = await csRes.json(); + const sid = sess.id; + const sessionTitle = 'Agent: ' + label; + + const n = _notes.find(x => x.id === noteId); + if (n) { + n.agent_session_id = sid; + if (Array.isArray(n.items) && n.items[idx]) { + n.items[idx].agent_session_id = sid; + n.items[idx].agent_session_title = sessionTitle; + n.items[idx].agent_status = 'running'; + n.items[idx].agent_stream_completed_at = ''; + } + } + _renderNotes(); + _patchNote(noteId, { items: n && Array.isArray(n.items) ? n.items : note.items, agent_session_id: sid }).catch(() => {}); + + const fd = new FormData(); + fd.append('message', prompt); + fd.append('session', sid); + fd.append('mode', 'agent'); + fd.append('disabled_tools', JSON.stringify(['manage_notes'])); + fetch(`${API_BASE}/api/chat_stream`, { method: 'POST', credentials: 'same-origin', body: fd }) + .then(async (res) => { + if (!res.ok || !res.body) return; + const reader = res.body.getReader(); + while (true) { const { done } = await reader.read(); if (done) break; } + if (window.sessionModule && window.sessionModule.markStreamComplete) { + try { window.sessionModule.markStreamComplete(sid); } catch {} + } + const doneNote = _notes.find(x => x.id === noteId); + if (doneNote && Array.isArray(doneNote.items) && doneNote.items[idx]) { + doneNote.agent_session_id = sid; + doneNote.items[idx].agent_session_id = sid; + doneNote.items[idx].agent_session_title = sessionTitle; + doneNote.items[idx].agent_status = 'stream_complete'; + doneNote.items[idx].agent_stream_completed_at = new Date().toISOString(); + _renderNotes(); + _patchNote(noteId, { items: doneNote.items, agent_session_id: sid }).catch(() => {}); + } + }) + .catch(() => {}); + + uiModule.showToast('Agent working on this item — tap the Agent tag when ready'); + } catch (e) { + uiModule.showError('Agent failed: ' + (e.message || e)); + } +} + async function _copyNote(noteId, btnEl) { const note = _notes.find(n => n.id === noteId); if (!note) return false; From e442cc859def5d901e95c97833ae220afe2f18af Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 19 Jun 2026 00:35:02 +0000 Subject: [PATCH 15/22] Research panel: inline Library-link hint when there are no past runs (replaces the standalone past-research column) --- static/js/research/panel.js | 51 ++++++++++++++----------------------- 1 file changed, 19 insertions(+), 32 deletions(-) diff --git a/static/js/research/panel.js b/static/js/research/panel.js index 3abf75fb1..8861dfddf 100644 --- a/static/js/research/panel.js +++ b/static/js/research/panel.js @@ -366,20 +366,13 @@ function _buildPanelHTML() {
@@ -1005,7 +1005,12 @@ `; // /#cookbook-dl-tab-fold-body (whole Download card body) @@ -2884,6 +3072,7 @@ const shared = { _getPort, _sshPrefix, _serverByVal, + _serverKey, _selectedServer, _getPlatform, _isWindows, diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js index a390e11ad..7672edfd2 100644 --- a/static/js/cookbookRunning.js +++ b/static/js/cookbookRunning.js @@ -27,6 +27,9 @@ function _statusLabel(status, type) { // "cookbook-task-status" ('' = the neutral loading style). function _taskBadge(task) { if (task._unreachable && task.status === 'running') return { text: 'unreachable', cls: 'cookbook-task-error' }; + if (task.type === 'download' && task.status === 'running') { + return { text: _statusLabel(task.status, task.type), cls: 'cookbook-task-downloading' }; + } if (task.type === 'serve' && task.status === 'running' && task.progress) { // Same green "running" pill — just with dynamic phase text, so it doesn't // read as a different status while the server is coming up. @@ -52,13 +55,13 @@ function _downloadOutputLooksActive(task) { function _canClearTask(task) { if (!task || task.status === 'running') return false; - if (task.type === 'serve' && (task.status === 'ready' || task._serveReady)) return false; + if (task.type === 'serve' && (task.status === 'ready' || (task._serveReady && !['stopped', 'error', 'crashed', 'failed', 'completed'].includes(task.status)))) return false; // If the tmux output still shows an in-flight download, the task isn't // actually finished — hide the clear/check pill so it doesn't show on a // task that's still doing work. (The next render will reflect this and // ideally the self-heal flips status back to running.) if (_downloadOutputLooksActive(task)) return false; - return ['done', 'stopped', 'error', 'crashed', 'failed'].includes(task.status); + return ['done', 'completed', 'stopped', 'error', 'crashed', 'failed'].includes(task.status); } function _clearPillLabel(task) { @@ -66,6 +69,13 @@ function _clearPillLabel(task) { return 'clear'; } +function _venvRootFromPath(path) { + let p = (path || '').toString().trim().replace(/\/+$/, ''); + if (!p) return ''; + p = p.replace(/\/bin\/(?:activate|python(?:3(?:\.\d+)?)?|vllm|pip(?:3)?)$/i, ''); + return p; +} + // A pip dependency/driver install (payload._dep) reports success with the // runner's "=== Process exited with code 0 ===" sentinel and pip's // "Successfully installed" line — never the HuggingFace download markers @@ -263,6 +273,7 @@ let _copyText; let _persistEnvState; let _refreshDependencies; let _serverByVal; +let _serverKey; let _selectedServer; let modelLogo; let esc; @@ -688,8 +699,10 @@ export function _saveTasks(tasks) { export function _addTask(sessionId, name, type, payload) { let tasks = _loadTasks(); const remoteHost = (payload && payload.remote_host) || _envState.remoteHost || ''; - const sshPort = (payload && payload.ssh_port) || _getPort(remoteHost) || ''; - const platform = (payload && payload.platform) || _getPlatform(remoteHost) || ''; + const remoteServerKey = (payload && payload.remote_server_key) || ''; + const remoteServerName = (payload && payload.remote_server_name) || ''; + const sshPort = (payload && payload.ssh_port) || _getPort(remoteServerKey || remoteHost) || ''; + const platform = (payload && payload.platform) || _getPlatform(remoteServerKey || remoteHost) || ''; // Serving a model supersedes its finished download — clear the matching // finished download card (covers serving directly from the Serve tab, not just // via the download card's "Serve →" button). @@ -704,7 +717,7 @@ export function _addTask(sessionId, name, type, payload) { return !(key && t.type === 'download' && t.status === 'queued' && _downloadDedupeKey(t) === key); }); } - const task = _stripTaskSecrets({ id: sessionId, sessionId, name, type, status: 'running', output: '', ts: Date.now(), payload: payload || null, remoteHost, sshPort, platform }); + const task = _stripTaskSecrets({ id: sessionId, sessionId, name, type, status: 'running', output: '', ts: Date.now(), payload: payload || null, remoteHost, remoteServerKey, remoteServerName, sshPort, platform }); tasks.push(task); _saveTasks(tasks); // New action → collapse all other cards, leave only this one open. @@ -1520,14 +1533,18 @@ function _parseServeCmdToFields(cmd) { return fields; } -export async function _launchServeTask(shortName, repo, cmd, fields, hostOverride) { +export async function _launchServeTask(shortName, repo, cmd, fields, hostOverride, targetMeta = null) { // Host resolution mirrors the download path: when the caller passes an explicit // host (resolved from the dropdown the user actually picked), use it and look // up that server's port/platform from the shared servers list. Only fall back // to _envState.remoteHost for legacy callers (diagnosis/pip-update). const _host = (hostOverride !== undefined) ? (hostOverride || '') : (_envState.remoteHost || ''); - const _hsrv = _serverByVal(_envState.remoteServerKey || _host) + const _targetKey = targetMeta?.serverKey || ''; + const _hsrv = (_targetKey && _targetKey !== 'local' ? _serverByVal(_targetKey) : null) + || (hostOverride === undefined ? _serverByVal(_envState.remoteServerKey || _host) : null) || _envState.servers.find(s => s.host === _host) || {}; + const _serverMetaKey = _targetKey || (_hsrv && _serverKey ? _serverKey(_hsrv) : '') || (_host || 'local'); + const _serverMetaName = targetMeta?.serverName || _hsrv.name || (_host ? _host : 'Local'); const _hplatform = _host ? (_hsrv.platform || '') : (_envState.platform || ''); // Replace any serve already targeting this same host:port — you can't run two @@ -1572,7 +1589,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid } } else { if (_envState.env === 'venv' && _envState.envPath) { - const p = _envState.envPath; + const p = _venvRootFromPath(_envState.envPath); envPrefix = 'source ' + (p.endsWith('/bin/activate') ? p : p + '/bin/activate'); } else if (_envState.env === 'conda' && _envState.envPath) { envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _envState.envPath; @@ -1583,7 +1600,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid repo_id: repo, cmd: cmd, remote_host: _host || undefined, - ssh_port: _getPort(_host) || undefined, + ssh_port: _getPort(_serverMetaKey || _host) || undefined, env_prefix: envPrefix || undefined, hf_token: _envState.hfToken || undefined, gpus: _envState.gpus || undefined, @@ -1607,11 +1624,11 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid return; } - const _sp = _getPort(_host); + const _sp = _getPort(_serverMetaKey || _host); // _fields = the exact structured serve-form values used for this launch, // so the "Edit / relaunch" button can re-open the Serve panel pre-filled // with these precise settings (not just the last-used-for-repo state). - const payload = { repo_id: repo, remote_host: _host || undefined, ssh_port: _sp || undefined, _cmd: cmd, _fields: fields || undefined, _env: _usedEnv, _envPath: _usedEnvPath, _gpus: _usedGpus }; + const payload = { repo_id: repo, remote_host: _host || undefined, remote_server_key: _serverMetaKey || undefined, remote_server_name: _serverMetaName || undefined, ssh_port: _sp || undefined, _cmd: cmd, _fields: fields || undefined, _env: _usedEnv, _envPath: _usedEnvPath, _gpus: _usedGpus }; _addTask(data.session_id, shortName, 'serve', payload); uiModule.showToast(`Serving ${shortName}...`); // Auto-register may have enabled an existing (offline) endpoint for this @@ -1760,16 +1777,25 @@ export function _renderRunningTab() { } // Group tasks by server - const _serverName = (host) => { - if (!host) return 'Local'; - const srv = _serverByVal(_envState.remoteServerKey || host) - || _envState.servers.find(s => s.host === host); - return srv?.name || host; + const _taskServerKey = (task) => task?.remoteServerKey || task?.remoteHost || ''; + const _serverName = (keyOrTask) => { + if (keyOrTask && typeof keyOrTask === 'object') { + const task = keyOrTask; + if (task.remoteServerName) return task.remoteServerName; + const srv = task.remoteServerKey ? _serverByVal(task.remoteServerKey) : null; + if (srv?.name) return srv.name; + if (!task.remoteHost) return 'Local'; + return (_envState.servers.find(s => s.host === task.remoteHost)?.name) || task.remoteHost; + } + const key = keyOrTask || ''; + if (!key || key === 'local') return 'Local'; + const srv = _serverByVal(key); + return srv?.name || key; }; const serverGroups = {}; for (const t of tasks) { - const key = t.remoteHost || ''; - if (!serverGroups[key]) serverGroups[key] = { name: _serverName(key), serve: [], download: [] }; + const key = _taskServerKey(t); + if (!serverGroups[key]) serverGroups[key] = { name: _serverName(t), serve: [], download: [] }; serverGroups[key][t.type === 'serve' ? 'serve' : 'download'].push(t); } @@ -1816,12 +1842,12 @@ export function _renderRunningTab() { e.stopPropagation(); // don't toggle the section collapse (was an inline onclick, blocked by CSP) const host = btn.dataset.clearServer; const allTasks = _loadTasks(); - const toRemove = allTasks.filter(t => (t.remoteHost || '') === host && _canClearTask(t)); + const toRemove = allTasks.filter(t => _taskServerKey(t) === host && _canClearTask(t)); // Bail with a clear message instead of silently doing nothing when // every task on this server is still running (nothing finished to // clear yet) — the previous behavior looked like the button was dead. if (!toRemove.length) { - const stillRunning = allTasks.filter(t => (t.remoteHost || '') === host && t.status === 'running').length; + const stillRunning = allTasks.filter(t => _taskServerKey(t) === host && t.status === 'running').length; const _msg = stillRunning ? `No finished tasks on ${_serverName(host)} — ${stillRunning} still running. Stop them first to clear.` : `No finished tasks on ${_serverName(host)}.`; @@ -1830,7 +1856,7 @@ export function _renderRunningTab() { return; } if (!await window.styledConfirm(`Clear ${toRemove.length} finished task${toRemove.length === 1 ? '' : 's'} on ${_serverName(host)}?`, { confirmText: 'Clear' })) return; - const remaining = allTasks.filter(t => (t.remoteHost || '') !== host || !_canClearTask(t)); + const remaining = allTasks.filter(t => _taskServerKey(t) !== host || !_canClearTask(t)); _saveTasks(remaining); // Fade/slide each finished card out (same exit as the per-card clear) // instead of yanking them instantly. @@ -1864,7 +1890,7 @@ export function _renderRunningTab() { btn.addEventListener('click', async (e) => { e.stopPropagation(); // don't toggle the section collapse const host = btn.dataset.stopServer; - const running = _loadTasks().filter(t => (t.remoteHost || '') === host && t.status === 'running'); + const running = _loadTasks().filter(t => _taskServerKey(t) === host && t.status === 'running'); if (!running.length) { uiModule.showToast(`Nothing running on ${_serverName(host)}`); return; } if (!await window.styledConfirm(`Stop ${running.length} running task${running.length > 1 ? 's' : ''} on ${_serverName(host)}?`, { confirmText: 'Stop all' })) return; // Mark every task as user-stopped BEFORE firing the kills so that the @@ -2177,9 +2203,6 @@ export function _renderRunningTab() { if (task.status !== 'running' && task.status !== 'queued') { items.push({ group: 'run', label: 'Reconnect tmux', action: 'reconnect' }); } - if (task.status === 'running') { - items.push({ group: 'run', label: 'Stop', action: 'stop', danger: true }); - } items.push({ group: 'run', label: 'Restart', action: 'retry' }); // ── Edit section ──────────────────────────────────────────── // Merged "Edit & relaunch" — opens the structured serve panel @@ -2539,7 +2562,7 @@ export function _renderRunningTab() { }); // Route to the right server section body - const serverBodyId = `server-body-${(task.remoteHost || 'local').replace(/[^a-zA-Z0-9-]/g, '_')}`; + const serverBodyId = `server-body-${(_taskServerKey(task) || 'local').replace(/[^a-zA-Z0-9-]/g, '_')}`; const targetBody = document.getElementById(serverBodyId); if (targetBody) targetBody.appendChild(el); else group.appendChild(el); @@ -3393,7 +3416,8 @@ function _refreshServerDots() { let tasks; try { tasks = _loadTasks(); } catch { return; } const byKey = {}; - for (const t of tasks) { (byKey[t.remoteHost || ''] = byKey[t.remoteHost || ''] || []).push(t); } + const _taskServerKeyForDot = (task) => task?.remoteServerKey || task?.remoteHost || ''; + for (const t of tasks) { (byKey[_taskServerKeyForDot(t)] = byKey[_taskServerKeyForDot(t)] || []).push(t); } document.querySelectorAll('.cookbook-section-header').forEach(header => { const dot = header.querySelector('.cookbook-srv-status'); if (!dot) return; @@ -3798,6 +3822,7 @@ export function initRunning(shared) { _persistEnvState = shared._persistEnvState; _refreshDependencies = shared._refreshDependencies; _serverByVal = shared._serverByVal; + _serverKey = shared._serverKey; _selectedServer = shared._selectedServer; modelLogo = shared.modelLogo; esc = shared.esc; diff --git a/static/js/cookbookServe.js b/static/js/cookbookServe.js index aba3f7926..da48507f7 100644 --- a/static/js/cookbookServe.js +++ b/static/js/cookbookServe.js @@ -18,6 +18,7 @@ let _sshCmd; let _getPort; let _sshPrefix; let _serverByVal; +let _serverKey; let _getPlatform; let _isWindows; let _isMetal; @@ -41,9 +42,40 @@ let _nextAvailablePort; // Storage keys const SERVE_STATE_KEY = 'cookbook-serve-state'; +const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models'; let _cachedAllModels = []; +function _loadServeFavorites() { + try { + const raw = JSON.parse(localStorage.getItem(SERVE_FAVORITES_KEY) || '[]'); + return new Set(Array.isArray(raw) ? raw.filter(Boolean).map(String) : []); + } catch { + return new Set(); + } +} + +function _saveServeFavorites(favorites) { + try { + localStorage.setItem(SERVE_FAVORITES_KEY, JSON.stringify(Array.from(favorites || []))); + } catch {} +} + +function _isServeFavorite(repo) { + return _loadServeFavorites().has(String(repo || '')); +} + +function _toggleServeFavorite(repo) { + const key = String(repo || ''); + if (!key) return false; + const favorites = _loadServeFavorites(); + const next = !favorites.has(key); + if (next) favorites.add(key); + else favorites.delete(key); + _saveServeFavorites(favorites); + return next; +} + function _repoLooksAwqLike(model, repo) { const q = String(model?.quant || '').toUpperCase(); const n = `${repo || ''} ${model?.repo_id || ''} ${model?.name || ''} ${model?.path || ''}`.toLowerCase(); @@ -53,7 +85,9 @@ function _repoLooksAwqLike(model, repo) { function _repoLooksGgufLike(model, repo) { const q = String(model?.quant || '').toUpperCase(); const n = `${repo || ''} ${model?.repo_id || ''} ${model?.name || ''} ${model?.path || ''}`.toLowerCase(); - return !!model?.is_gguf || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || n.includes('gguf'); + const hasGgufFile = Array.isArray(model?.gguf_files) + && model.gguf_files.some(f => f && typeof f.rel_path === 'string' && /\.gguf$/i.test(f.rel_path)); + return !!model?.is_gguf || hasGgufFile || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || n.includes('gguf'); } function _serveBackendWarning(model, repo, backend, fields = {}) { @@ -96,6 +130,352 @@ function _allGpuIds(count) { return Array.from({ length: Math.floor(n) }, (_, i) => String(i)).join(','); } +function _shellSplitForPreview(cmd) { + const s = String(cmd || ''); + const out = []; + let cur = ''; + let quote = ''; + let escNext = false; + for (const ch of s) { + if (escNext) { + cur += ch; + escNext = false; + continue; + } + if (ch === '\\') { + cur += ch; + escNext = true; + continue; + } + if (quote) { + cur += ch; + if (ch === quote) quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + cur += ch; + continue; + } + if (/\s/.test(ch)) { + if (cur) { + out.push(cur); + cur = ''; + } + continue; + } + cur += ch; + } + if (cur) out.push(cur); + return out; +} + +function _formatServeCmdPreview(cmd) { + const raw = String(cmd || ''); + if (raw.startsWith('MODEL_FILE=$({')) { + const marker = /&&\s+([A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)?(?:llama-server|python3?\s+-m\s+llama_cpp\.server)\b/; + const match = raw.match(marker); + if (match && match.index > 0) { + const prelude = raw.slice(0, match.index).replace(/\s+/g, ' ').trim(); + const rest = raw.slice(match.index).replace(/^\s*&&\s*/, ''); + return `${prelude}\n&&\n${_formatServeCmdPreview(rest)}`; + } + } + const tokens = _shellSplitForPreview(cmd); + if (tokens.length <= 4) return String(cmd || ''); + const lines = []; + let i = 0; + while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) { + lines.push(tokens[i]); + i++; + } + if (tokens[i]) { + const head = [tokens[i++]]; + if (tokens[i] && !tokens[i].startsWith('--') && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) head.push(tokens[i++]); + if (tokens[i] && !tokens[i].startsWith('--') && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) head.push(tokens[i++]); + lines.push(head.join(' ')); + } + while (i < tokens.length) { + const t = tokens[i++]; + if (t.startsWith('--')) { + const vals = []; + while (i < tokens.length && !tokens[i].startsWith('--') && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) { + vals.push(tokens[i++]); + } + lines.push([t, ...vals].join(' ')); + } else { + lines.push(t); + } + } + return lines.join('\n'); +} + +function _normalizeServeCmdForLaunch(cmd) { + return String(cmd || '') + .replace(/MODEL_FILE=\$\(\{\s+/g, 'MODEL_FILE=$({ ') + .replace(/\s+\}\s+\|\s+head\s+-1\)/g, ' } | head -1)') + .replace(/\s*;\s*/g, '; ') + .replace(/\s*\|\|\s*/g, ' __ODY_OR__ ') + .replace(/\s*\|\s*/g, ' | ') + .replace(/\s+__ODY_OR__\s+/g, ' || ') + .replace(/\s+/g, ' ') + .trim(); +} + +function _modelSizeGb(model, explicitGb = 0) { + const explicit = Number(explicitGb || 0); + if (Number.isFinite(explicit) && explicit > 0) return explicit; + const bytes = Number(model?.size_bytes || 0); + if (Number.isFinite(bytes) && bytes > 0) return bytes / (1024 ** 3); + const gb = Number( + model?.size_gb + || model?.required_gb + || model?.vram_needed + || model?.min_vram_gb + || model?.recommended_ram_gb + || model?.min_ram_gb + || 0 + ); + if (Number.isFinite(gb) && gb > 0) return gb; + if (_isMiniMaxM3Model(model)) return 240; + return 0; +} + +function _parseParamsB(text) { + const s = String(text || ''); + const m = s.match(/(\d+(?:\.\d+)?)\s*([bBmMtT])\b/); + if (!m) return 0; + const n = parseFloat(m[1]); + if (!Number.isFinite(n) || n <= 0) return 0; + const unit = m[2].toLowerCase(); + if (unit === 't') return n * 1000; + if (unit === 'b') return n; + if (unit === 'm') return n / 1000; + return 0; +} + +function _knownModelContextMax(model) { + if (_isMiniMaxM3Model(model)) return 1048576; + return 0; +} + +function _modelIdentityText(model) { + return [ + model?.repo_id, + model?.quant_repo, + model?.name, + model?.id, + model?.path, + model?.model_path, + model?.served_model_name, + model?.quant, + model?.format, + ].filter(Boolean).join(' ').toLowerCase(); +} + +function _isMiniMaxM3Model(model) { + const name = _modelIdentityText(model); + return ( + (/minimax/.test(name) && /\bm3\b/.test(name)) + || /minimax-m3/.test(name) + || /models--cyankiwi--minimax-m3-awq-int4/.test(name) + || /cyankiwi\/minimax-m3-awq-int4/.test(name) + ); +} + +function _isMiniMaxM2Model(model) { + const name = _modelIdentityText(model); + return /minimax/.test(name) && /\bm2(?:\.\d+)?\b/.test(name); +} + +function _modelContextMaxForServe(model, explicitMax) { + const explicit = Number(explicitMax || 0); + if (Number.isFinite(explicit) && explicit > 0) return explicit; + const known = _knownModelContextMax(model); + if (known > 0) return known; + for (const key of ['context_length', 'max_position_embeddings', 'n_ctx_train', 'model_max_length', 'max_seq_len']) { + const value = Number(model?.[key] || 0); + if (Number.isFinite(value) && value > 0) return value; + } + const catalogCtx = Number(model?.context || 0); + if (Number.isFinite(catalogCtx) && catalogCtx > 0) return catalogCtx; + return 131072; +} + +function _estimateVllmContextFit(model, fields, modelCtxMax, modelWeightsGb = 0, fitSystem = null) { + const sys = fitSystem || _hwfitCache?.system || {}; + const isMiniMaxM3 = _isMiniMaxM3Model(model); + const gpuIds = String(fields.gpus || '').split(',').map(s => parseInt(s.trim(), 10)).filter(Number.isFinite); + const tp = Math.max(1, parseInt(fields.tp, 10) || gpuIds.length || 1); + const selectedCount = Math.max(1, gpuIds.length || tp); + const groups = Array.isArray(sys.gpu_groups) ? sys.gpu_groups : []; + const activeGroup = sys.active_group || groups[0] || null; + const perGpuGb = Number(activeGroup?.vram_each) + || (Number(sys.gpu_vram_gb) / Math.max(1, Number(sys.gpu_count) || selectedCount)) + || 0; + if (!perGpuGb) { + return { needsHardwareScan: true, reason: 'scan hardware first to estimate context from VRAM' }; + } + + const gpuUtil = Math.min(0.99, Math.max(0.1, parseFloat(fields.gpu_mem) || 0.90)); + const budgetGb = perGpuGb * selectedCount * gpuUtil; + const modelGb = _modelSizeGb(model, modelWeightsGb); + if (!modelGb) return { needsModelSize: true, reason: 'model weight size unknown; scan model files or enter context manually' }; + const modelMax = Math.max(1024, _modelContextMaxForServe(model, modelCtxMax)); + + if (isMiniMaxM3) { + const perGpuBudgetGb = perGpuGb * gpuUtil; + const modelShardGb = modelGb / Math.max(1, tp); + const fixedOverheadGb = Math.max(1.5, perGpuBudgetGb * 0.035); + const freeForKv = perGpuBudgetGb - modelShardGb - fixedOverheadGb; + const kvGbPerToken = (29.25 / 1048576) * (String(fields.vllm_kv_cache_dtype || '').toLowerCase() === 'fp8' ? 1 : 1.8); + if (freeForKv <= 0) { + return { + ctx: 1024, + budgetGb, + modelGb, + kvGbPerToken, + reason: `model shard ${modelShardGb.toFixed(1)}G exceeds per-GPU usable ${perGpuBudgetGb.toFixed(1)}G before KV`, + }; + } + const raw = Math.floor((freeForKv / kvGbPerToken) * 0.99); + const rounded = Math.max(1024, Math.floor(raw / 128) * 128); + const ctx = Math.min(modelMax, rounded); + return { + ctx, + budgetGb, + modelGb, + kvGbPerToken, + reason: `~${ctx.toLocaleString()} tokens fits per-GPU KV (${freeForKv.toFixed(1)}G free)`, + }; + } + + const name = `${model?.repo_id || ''} ${model?.name || ''} ${model?.quant || ''}`; + const lower = name.toLowerCase(); + const isMoE = /\bmoe\b|a\d+b|minimax|deepseek|mixtral|kimi-k2|glm-4\.5/.test(lower); + const totalParams = _parseParamsB(name) || Math.max(1, modelGb / 0.58); + const activeFromName = (() => { + const m = lower.match(/\ba(\d+(?:\.\d+)?)b\b/); + return m ? parseFloat(m[1]) : 0; + })(); + const activeParams = activeFromName || (isMoE ? Math.min(totalParams, 32) : totalParams); + const effectiveActiveParams = (/minimax/.test(lower) && /\bm3\b/.test(lower)) ? 23 : activeParams; + const kvDtype = String(fields.vllm_kv_cache_dtype || '').toLowerCase(); + const kvFactor = kvDtype === 'fp8' ? 0.55 : 1; + const kvGbPerTokenTotal = Math.max(0.00002, 0.000008 * effectiveActiveParams * kvFactor); + const kvGbPerToken = kvGbPerTokenTotal / Math.max(1, tp); + const perGpuBudgetGb = perGpuGb * gpuUtil; + const modelShardGb = modelGb / Math.max(1, tp); + const fixedOverheadGb = Math.max(1.5, perGpuBudgetGb * 0.035); + const freeForKv = perGpuBudgetGb - modelShardGb - fixedOverheadGb; + if (freeForKv <= 0) { + return { + ctx: 1024, + budgetGb, + modelGb, + kvGbPerToken, + reason: `model shard ${modelShardGb.toFixed(1)}G exceeds per-GPU usable ${perGpuBudgetGb.toFixed(1)}G before KV`, + }; + } + const raw = Math.floor(freeForKv / kvGbPerToken); + const rounded = Math.max(1024, Math.floor(raw / 1024) * 1024); + const ctx = Math.min(modelMax, rounded); + return { + ctx, + budgetGb, + modelGb, + kvGbPerToken, + reason: `~${ctx.toLocaleString()} tokens fits per-GPU KV (${freeForKv.toFixed(1)}G free)`, + }; +} + +function _estimateLlamaContextFit(model, fields, modelCtxMax, modelWeightsGb = 0, fitSystem = null, profileData = null) { + const profiles = Array.isArray(profileData?.profiles) ? profileData.profiles : []; + const preferred = profiles.find(p => String(p?.key || '').toLowerCase() === 'balanced') + || profiles.find(p => Number(p?.ctx) > 0) + || null; + const modelMax = Math.max(1024, _modelContextMaxForServe(model, modelCtxMax)); + if (preferred && Number(preferred.ctx) > 0) { + const ctx = Math.min(modelMax, Number(preferred.ctx)); + return { + ctx, + reason: `profile ${preferred.label || preferred.key || 'fit'} fits scanned hardware`, + }; + } + + const sys = fitSystem || _hwfitCache?.system || {}; + const modelGb = _modelSizeGb(model, modelWeightsGb); + const backend = String(fields.backend || '').toLowerCase(); + const llamaMode = String(fields.llama_mode || '').toLowerCase(); + const isCpuMode = backend === 'llamacpp' && llamaMode === 'cpu'; + const isUnifiedMode = backend === 'llamacpp' && (llamaMode === 'unified' || fields.unified_mem); + if (!modelGb) { + return { + ctx: Math.min(modelMax, 32768), + needsModelSize: true, + reason: 'model weight size unknown; using model limit fallback', + }; + } + + if (isCpuMode) { + return { + ctx: Math.min(modelMax, 131072), + modelGb, + reason: 'CPU mode uses system RAM; capped to trained limit', + }; + } + + const gpuIds = String(fields.gpus || '').split(',').map(s => parseInt(s.trim(), 10)).filter(Number.isFinite); + const selectedCount = Math.max(1, gpuIds.length || parseInt(fields.tp, 10) || 1); + const groups = Array.isArray(sys.gpu_groups) ? sys.gpu_groups : []; + const activeGroup = sys.active_group || groups[0] || null; + const totalVramGb = Number(activeGroup?.vram_each) + ? Number(activeGroup.vram_each) * selectedCount + : (Number(sys.gpu_vram_gb) || 0); + if (!totalVramGb) { + return { + ctx: Math.min(modelMax, 32768), + modelGb, + needsHardwareScan: true, + reason: 'scan hardware first; using model limit fallback', + }; + } + + const totalRamGb = Number(sys.total_ram_gb) || 0; + const availableRamGb = Number(sys.available_ram_gb) || 0; + const unifiedPoolGb = isUnifiedMode + ? Math.max( + totalVramGb, + availableRamGb, + totalRamGb > 0 ? totalRamGb * 0.85 : 0 + ) + : totalVramGb; + const usableGb = isUnifiedMode + ? Math.max(1, unifiedPoolGb - Math.max(2.0, unifiedPoolGb * 0.08)) + : Math.max(1, totalVramGb - Math.max(1.0, selectedCount * 0.6)); + const freeForKv = usableGb - modelGb; + const kv = String(fields.cache_type || '').toLowerCase(); + const kvFactor = kv === 'q4_0' ? 0.55 : (kv === 'q8_0' ? 1 : (kv === 'f16' ? 1.9 : 1)); + const kvGbPerToken = Math.max(0.00008, (modelGb / 7.5) * 0.0007 * kvFactor); + if (freeForKv <= 0) { + return { + ctx: Math.min(modelMax, 8192), + modelGb, + kvGbPerToken, + reason: `model ${modelGb.toFixed(1)}G exceeds usable ${isUnifiedMode ? 'unified memory' : 'VRAM'} ${usableGb.toFixed(1)}G before KV`, + }; + } + const raw = Math.floor(freeForKv / kvGbPerToken); + const rounded = Math.max(1024, Math.floor(raw / 1024) * 1024); + const ctx = Math.min(modelMax, rounded); + return { + ctx, + modelGb, + kvGbPerToken, + reason: `~${ctx.toLocaleString()} tokens fits llama.cpp KV (${freeForKv.toFixed(1)}G free ${isUnifiedMode ? 'unified' : 'VRAM'})`, + }; +} + function _selectedServeTarget(panel) { const select = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server'); const servers = Array.isArray(_envState.servers) ? _envState.servers : []; @@ -117,6 +497,8 @@ function _selectedServeTarget(panel) { : (server?.name || 'local server'); return { host, + serverKey: server ? (_serverKey?.(server) || '') : (select?.value || ''), + serverName: server?.name || '', port: host ? (_getPort(host) || server?.port || '') : '', venv, platform: server?.platform || _envState.platform || '', @@ -152,6 +534,9 @@ function _runtimeNoteText(backend, pkg, target) { const label = labels[backend] || backend; if (!pkg) return `${label} readiness unavailable for ${target.label}.`; const note = pkg.status_note || pkg.update_note || ''; + if (pkg.installed === null || pkg.probe_error) { + return note ? `${label} readiness unavailable for ${target.label}: ${note}` : `${label} readiness unavailable for ${target.label}.`; + } if (pkg.installed) { return note ? `${label} ready on ${target.label}: ${note}` : `${label} ready on ${target.label}.`; } @@ -226,6 +611,13 @@ function _runnableGgufFiles(model) { return primary.length ? primary : files; } +function _selectedGgufSizeGb(model, relPath) { + const file = _runnableGgufFiles(model).find(f => f.rel_path === relPath); + const bytes = Number(file?.size_bytes || 0); + if (!Number.isFinite(bytes) || bytes <= 0) return 0; + return bytes / (1024 ** 3); +} + function _ggufFileLabel(file) { const base = (file.name || file.rel_path || '').split('/').pop(); const size = _formatGgufSize(file.size_bytes); @@ -281,6 +673,12 @@ function _rerenderCachedModels() { else if (sortVal === 'size-desc') allModels.sort((a, b) => _parseSize(b.size) - _parseSize(a.size)); else if (sortVal === 'size-asc') allModels.sort((a, b) => _parseSize(a.size) - _parseSize(b.size)); else if (sortVal === 'recent') allModels.sort((a, b) => (b.mtime || 0) - (a.mtime || 0)); + const favorites = _loadServeFavorites(); + allModels.sort((a, b) => { + const af = favorites.has(String(a.repo_id || '')) ? 1 : 0; + const bf = favorites.has(String(b.repo_id || '')) ? 1 : 0; + return bf - af; + }); let html = ''; let visibleCount = 0; @@ -303,8 +701,9 @@ function _rerenderCachedModels() { // living on the same line as the model name. const _isDownloading = m.status === 'downloading'; const _isDlActive = _isDownloading ? _isActivelyDownloading(m.repo_id) : false; + const _isFavorite = favorites.has(String(m.repo_id || '')); const isSelectMode = document.getElementById('hwfit-cache-select')?.classList.contains('active'); - html += `
`; + html += `
`; html += ``; html += `
`; const _mc = modelColor(m.repo_id) || ''; @@ -314,7 +713,8 @@ function _rerenderCachedModels() { const _downloadingPill = _isDownloading ? ` ${_isDlActive ? 'downloading' : 'stalled'}` : ''; - html += `
${modelLogo(m.repo_id)}${esc(shortName)}${hfLink ? ` HF ↗` : ''}${_runningPill}${_downloadingPill}
`; + const _favoritePill = _isFavorite ? ' pinned' : ''; + html += `
${modelLogo(m.repo_id)}${esc(shortName)}${_favoritePill}${hfLink ? ` HF ↗` : ''}${_runningPill}${_downloadingPill}
`; html += `
${metaParts.join(' \u00b7 ')}
`; html += `
`; const _bk = _detectBackend(m).backend; @@ -397,7 +797,12 @@ function _rerenderCachedModels() { const _deleteIco = ''; const _selectIco = ''; const _schedIco = ''; + const _favNow = _isServeFavorite(repo); + const _favIco = _favNow + ? '' + : ''; const items = []; + items.push({ label: _favNow ? 'Unfavorite' : 'Favorite', icon: _favIco, action: 'favorite' }); if (m && m.status === 'ready') items.push({ label: 'Serve', icon: _serveIco, action: 'serve' }); if (m && m.status === 'downloading') items.push({ label: 'Retry', icon: _retryIco, action: 'retry' }); if (m && m.status === 'ready') items.push({ label: 'Schedule…', icon: _schedIco, action: 'schedule' }); @@ -410,6 +815,11 @@ function _rerenderCachedModels() { div.addEventListener('click', () => { closeDropdown(); if (opt.action === 'serve') item.click(); + else if (opt.action === 'favorite') { + const favored = _toggleServeFavorite(repo); + uiModule.showToast(favored ? 'Favorited — pinned to top' : 'Unfavorited'); + _rerenderCachedModels(); + } else if (opt.action === 'delete') _deleteCachedModel(repo, item, false, m); else if (opt.action === 'retry') _retryCachedModel(repo, m); else if (opt.action === 'schedule') { @@ -532,16 +942,22 @@ function _rerenderCachedModels() { const ss = (_byRepo[repo] && typeof _byRepo[repo] === 'object') ? _byRepo[repo] : (_lastUsed || (_isLegacyFlat ? _allSs : {})); + const _modelSs = (_byRepo[repo] && typeof _byRepo[repo] === 'object') ? _byRepo[repo] : null; + const _repoForcedBackend = !!(_modelSs && _modelSs._forceBackend); + const _isMiniMaxM3 = _isMiniMaxM3Model({ ...m, repo_id: repo }); + const _isMiniMaxM2 = _isMiniMaxM2Model({ ...m, repo_id: repo }); + const _isMiniMaxMSeries = _isMiniMaxM3 || _isMiniMaxM2; + const svm = (k, def) => (_modelSs && _hasOwn(_modelSs, k)) ? _modelSs[k] : def; const detectedBackend = _detectBackend(m).backend; const _allowedBackends = new Set(_isWindows() ? ['llamacpp', 'diffusers'] : (_isMetal() ? ['llamacpp', 'ollama'] : ['vllm', 'sglang', 'llamacpp', 'ollama', 'diffusers'])); - const defaultBackend = (ss._forceBackend && ss.backend && _allowedBackends.has(ss.backend)) + const defaultBackend = (_repoForcedBackend && ss.backend && _allowedBackends.has(ss.backend)) ? ss.backend : detectedBackend; - const savedMatchesBackend = !!ss._forceBackend || (ss.backend || 'vllm') === detectedBackend; + const savedMatchesBackend = _repoForcedBackend || (ss.backend || 'vllm') === detectedBackend; const sv = (k, def) => (ss[k] !== undefined && savedMatchesBackend) ? ss[k] : def; - const defaultTp = defaultBackend === 'llamacpp' ? '1' : sv('tp', '1'); + const defaultTp = defaultBackend === 'llamacpp' ? '1' : sv('tp', _isMiniMaxMSeries ? '8' : '1'); const detectedGpuIds = _allGpuIds(_getGpuToggleTotal?.()); const defaultGpus = defaultBackend === 'llamacpp' ? '0' @@ -555,7 +971,7 @@ function _rerenderCachedModels() { // OOMs. _detectModelOptimizations seeds opts.kvCacheDtype for // those families; honour it unless the user has a saved override. const _kvOptsCheck = _detectModelOptimizations(repo); - const _kvAutoDefault = (_kvOptsCheck && _kvOptsCheck.kvCacheDtype) || 'auto'; + const _kvAutoDefault = (_kvOptsCheck && _kvOptsCheck.kvCacheDtype) || (_isMiniMaxMSeries ? 'fp8' : 'auto'); const _kvSelected = sv('vllm_kv_cache_dtype', _kvAutoDefault); const vllmKvCacheOpts = ['auto','fp8'].map(d => ``).join(''); const _l = (name, tip) => `${name}?`; @@ -567,6 +983,11 @@ function _rerenderCachedModels() { const _ggufOptions = _ggufChoices.map(f => `` ).join(''); + const _minimaxM3Snapshot = '/home/pewds/.cache/huggingface/hub/models--cyankiwi--MiniMax-M3-AWQ-INT4/snapshots/4082acbbec1236d21828d55b6bb0fe02ade4ab5b'; + const _defaultServeModel = _isMiniMaxM3 ? _minimaxM3Snapshot : (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo); + const _savedModelPath = String(svm('model_path', _defaultServeModel) || '').trim(); + const _modelPathValue = _isMiniMaxM3 && (!_savedModelPath || _savedModelPath === repo) ? _minimaxM3Snapshot : _savedModelPath; + const _defaultServedModelName = _isMiniMaxM3 ? repo : ''; // Build save slots const _allPresets = _loadPresets(); const _repoShort = repo.split('/').pop(); @@ -583,15 +1004,10 @@ function _rerenderCachedModels() { const _arrowTitle = _modelPresets.length > 0 ? `${_modelPresets.length} saved launch config${_modelPresets.length === 1 ? '' : 's'} for ${_repoShort} — click ▾ to load or delete` : `No saved launch configs for ${_repoShort} yet — click Save to add one`; - // Wrap the Save split in a
+