diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml index 8063e491..ae8e77e1 100644 --- a/CHANGELOG.yaml +++ b/CHANGELOG.yaml @@ -38,6 +38,7 @@ - "OpenRouter Client: The default model for newly created OpenRouter clients is now google/gemini-3.6-flash." - "OpenRouter Client: New OpenRouter clients now have reasoning enabled by default, with a budget of 2048 reasoning tokens, so the default model works out of the box. With reasoning off Talemate pre-fills the start of the response to steer it, and some providers — Google and Anthropic among them — reject requests that do that." fixes: + - "Docker: `docker compose up` now requires an NVIDIA GPU and the NVIDIA Container Toolkit and exposes all host NVIDIA GPUs to Talemate, restoring CUDA detection for PyTorch. Hosts without an NVIDIA GPU must use `docker compose -f docker-compose.cpu.yml up`; the standalone manual-build command remains a complete CPU configuration." - "Scene Forking: Forking from a message now refuses a save name that is not a valid filename or that an existing save already uses, instead of writing over that save or outside the scene directory. The timeline fork already refused both." - "Prompt Finalization: The fuzzy match threshold slider's always-visible value bubble no longer overlaps the note above it." - "Frontend: A backend websocket URL configured with the host 0.0.0.0 now connects — the URL was used literally, which most browsers refuse, so the app stayed on 'backend not connected'. The host is resolved to the hostname the UI itself was loaded from." diff --git a/docker-compose.cpu.yml b/docker-compose.cpu.yml new file mode 100644 index 00000000..d224e21f --- /dev/null +++ b/docker-compose.cpu.yml @@ -0,0 +1,20 @@ +services: + talemate: + image: ghcr.io/vegu-ai/talemate:latest + ports: + - "${TALEMATE_FRONTEND_PORT:-8082}:${TALEMATE_FRONTEND_PORT:-8082}" + - "${TALEMATE_BACKEND_PORT:-5050}:${TALEMATE_BACKEND_PORT:-5050}" + volumes: + - ./config.yaml:/app/config.yaml + - ./secrets:/app/secrets + - ./pi:/app/pi + - ./scenes:/app/scenes + - ./templates:/app/templates + - ./chroma:/app/chroma + - ./tts:/app/tts + environment: + - PYTHONUNBUFFERED=1 + - PYTHONPATH=/app/src:$PYTHONPATH + - VITE_TALEMATE_BACKEND_WEBSOCKET_URL=${VITE_TALEMATE_BACKEND_WEBSOCKET_URL:-} + - TALEMATE_FRONTEND_PORT=${TALEMATE_FRONTEND_PORT:-8082} + - TALEMATE_BACKEND_PORT=${TALEMATE_BACKEND_PORT:-5050} diff --git a/docker-compose.manual.yml b/docker-compose.manual.yml index fbc39341..4fc778bb 100644 --- a/docker-compose.manual.yml +++ b/docker-compose.manual.yml @@ -1,24 +1,18 @@ -version: '3.8' - services: talemate: + extends: + file: docker-compose.cpu.yml + service: talemate + # Uncomment this block to enable NVIDIA GPU access for standalone manual builds. + # deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: all + # capabilities: [gpu] + image: talemate:local + pull_policy: build build: context: . dockerfile: Dockerfile - ports: - - "${TALEMATE_FRONTEND_PORT:-8082}:${TALEMATE_FRONTEND_PORT:-8082}" - - "${TALEMATE_BACKEND_PORT:-5050}:${TALEMATE_BACKEND_PORT:-5050}" - volumes: - - ./config.yaml:/app/config.yaml - - ./secrets:/app/secrets - - ./pi:/app/pi - - ./scenes:/app/scenes - - ./templates:/app/templates - - ./chroma:/app/chroma - - ./tts:/app/tts - environment: - - PYTHONUNBUFFERED=1 - - PYTHONPATH=/app/src:$PYTHONPATH - - VITE_TALEMATE_BACKEND_WEBSOCKET_URL=${VITE_TALEMATE_BACKEND_WEBSOCKET_URL:-} - - TALEMATE_FRONTEND_PORT=${TALEMATE_FRONTEND_PORT:-8082} - - TALEMATE_BACKEND_PORT=${TALEMATE_BACKEND_PORT:-5050} diff --git a/docker-compose.yml b/docker-compose.yml index 9a8417fd..6bd0a3ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,12 @@ -version: '3.8' - services: talemate: - image: ghcr.io/vegu-ai/talemate:latest - ports: - - "${TALEMATE_FRONTEND_PORT:-8082}:${TALEMATE_FRONTEND_PORT:-8082}" - - "${TALEMATE_BACKEND_PORT:-5050}:${TALEMATE_BACKEND_PORT:-5050}" - volumes: - - ./config.yaml:/app/config.yaml - - ./secrets:/app/secrets - - ./pi:/app/pi - - ./scenes:/app/scenes - - ./templates:/app/templates - - ./chroma:/app/chroma - - ./tts:/app/tts - environment: - - PYTHONUNBUFFERED=1 - - PYTHONPATH=/app/src:$PYTHONPATH - - VITE_TALEMATE_BACKEND_WEBSOCKET_URL=${VITE_TALEMATE_BACKEND_WEBSOCKET_URL:-} - - TALEMATE_FRONTEND_PORT=${TALEMATE_FRONTEND_PORT:-8082} - - TALEMATE_BACKEND_PORT=${TALEMATE_BACKEND_PORT:-5050} + extends: + file: docker-compose.cpu.yml + service: talemate + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] diff --git a/docs/getting-started/advanced/change-host-and-port.md b/docs/getting-started/advanced/change-host-and-port.md index 00dc1627..025b6674 100644 --- a/docs/getting-started/advanced/change-host-and-port.md +++ b/docs/getting-started/advanced/change-host-and-port.md @@ -177,6 +177,8 @@ start_custom.bat For Docker deployments, you can configure the frontend port, backend port, and the WebSocket URL at container startup without rebuilding the image. +The commands below use the default CUDA configuration and require an NVIDIA GPU and the NVIDIA Container Toolkit. On a host without an NVIDIA GPU, add `-f docker-compose.cpu.yml` to every command; for example, `TALEMATE_FRONTEND_PORT=9090 docker compose -f docker-compose.cpu.yml up`. + ### Changing the frontend port Set `TALEMATE_FRONTEND_PORT` before running `docker compose up`: @@ -226,4 +228,4 @@ The WebSocket URL is determined in this order: This means you can use a single Docker image across different environments (staging, production) by simply changing the environment variable. !!! info "`0.0.0.0` in the WebSocket URL" - `0.0.0.0` is a bind address, not an address a browser can connect to. If the environment variable's host is `0.0.0.0`, the frontend replaces it with the hostname the page was loaded from — the configured port and path are kept. So `ws://0.0.0.0:6060/ws` behaves like auto-detection for the host, but on port `6060`. \ No newline at end of file + `0.0.0.0` is a bind address, not an address a browser can connect to. If the environment variable's host is `0.0.0.0`, the frontend replaces it with the hostname the page was loaded from — the configured port and path are kept. So `ws://0.0.0.0:6060/ws` behaves like auto-detection for the host, but on port `6060`. diff --git a/docs/getting-started/advanced/environment-variables.md b/docs/getting-started/advanced/environment-variables.md index f46cac32..8e2f8638 100644 --- a/docs/getting-started/advanced/environment-variables.md +++ b/docs/getting-started/advanced/environment-variables.md @@ -48,7 +48,7 @@ See [API key encryption](../../user-guide/api-key-encryption.md) for the full ke ## Docker Compose passthroughs -The values below are not consumed by Talemate's Python code directly — they're consumed by `docker-compose.yml` so that the same variable controls both the published host port and the value passed into the container as `TALEMATE_BACKEND_PORT` / `TALEMATE_FRONTEND_PORT`: +The values below are not consumed by Talemate's Python code directly — they're consumed by the default and CPU-only Compose configurations so that the same variable controls both the published host port and the value passed into the container as `TALEMATE_BACKEND_PORT` / `TALEMATE_FRONTEND_PORT`: - `TALEMATE_BACKEND_PORT` - `TALEMATE_FRONTEND_PORT` diff --git a/docs/getting-started/installation/docker.md b/docs/getting-started/installation/docker.md index 47d67a35..653c76da 100644 --- a/docs/getting-started/installation/docker.md +++ b/docs/getting-started/installation/docker.md @@ -5,11 +5,32 @@ 1. copy config file 1. linux: `cp config.example.yaml config.yaml` 1. windows: `copy config.example.yaml config.yaml` (or just copy the file and rename it via the file explorer) -1. `docker compose up` +1. Start Talemate: + 1. NVIDIA GPU host with the NVIDIA Container Toolkit installed: `docker compose up` + 1. Host without an NVIDIA GPU: `docker compose -f docker-compose.cpu.yml up` 1. Navigate your browser to http://localhost:8082 +The default Compose configuration requires an NVIDIA GPU and the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). It reserves all NVIDIA GPUs and does not fall back to CPU execution. + +On a host without an NVIDIA GPU, you must use: + +```bash +docker compose -f docker-compose.cpu.yml up +``` + !!! info "Pre-built Images" - The default setup uses pre-built images from GitHub Container Registry that include CUDA support by default. To manually build the container instead, use `docker compose -f docker-compose.manual.yml up --build`. + The default setup uses a pre-built image from GitHub Container Registry. To build it locally with CUDA enabled, use `docker compose -f docker-compose.yml -f docker-compose.manual.yml up --build`. For a local CPU-only build, use the standalone command `docker compose -f docker-compose.manual.yml up --build`; combining `docker-compose.cpu.yml` and `docker-compose.manual.yml` is also supported. + +## Verify CUDA access + +With the container running, verify that Docker exposed the GPU and that PyTorch can use it: + +```bash +docker compose exec talemate nvidia-smi +docker compose exec talemate /app/.venv/bin/python -B -c "import torch; print(torch.__version__, torch.version.cuda, torch.cuda.is_available())" +``` + +The final value from the Python command should be `True`. See [Common issues](troubleshoot.md#cuda-is-not-available-in-a-running-container) if either command fails. !!! note When connecting local APIs running on the hostmachine (e.g. text-generation-webui), you need to use `host.docker.internal` as the hostname. diff --git a/docs/getting-started/installation/troubleshoot.md b/docs/getting-started/installation/troubleshoot.md index 1f55672e..5253a7c1 100644 --- a/docs/getting-started/installation/troubleshoot.md +++ b/docs/getting-started/installation/troubleshoot.md @@ -8,6 +8,43 @@ ## Docker +### Docker cannot start with the NVIDIA device request + +The default configuration requires both an NVIDIA GPU and a working NVIDIA Container Toolkit. If either is missing, Docker reports an error similar to: + +```text +could not select device driver "nvidia" with capabilities: [[gpu]] +``` + +On a host without an NVIDIA GPU, start the CPU-only configuration instead: + +```bash +docker compose -f docker-compose.cpu.yml up +``` + +If the host has an NVIDIA GPU, confirm `nvidia-smi` works on the host, then install or repair the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). Verify the Toolkit before retrying Talemate: + +```bash +docker run --rm --gpus all ubuntu nvidia-smi +``` + +### CUDA is not available in a running container + +If the Toolkit probe succeeds and Talemate starts, check the running container: + +```bash +docker compose exec talemate nvidia-smi +docker compose exec talemate /app/.venv/bin/python -B -c "import torch; print(torch.__version__, torch.version.cuda, torch.cuda.is_available())" +``` + +If `nvidia-smi` works inside Talemate but the Python command reports `False`, update the host NVIDIA driver to one compatible with the image's locked CUDA 12.8 build, then recreate the container. + +To run without CUDA instead, use: + +```bash +docker compose -f docker-compose.cpu.yml up +``` + ### Docker has created `config.yaml` directory If you do not copy the example config to `config.yaml` before running `docker compose up` docker will create a `config` directory in the root of the project. This will cause the backend to fail to start. @@ -66,4 +103,4 @@ location /ws { proxy_set_header Connection "upgrade"; proxy_set_header Host $host; } -``` \ No newline at end of file +``` diff --git a/src/talemate/agents/help/docs-index.yaml b/src/talemate/agents/help/docs-index.yaml index f8de4239..de5cfb90 100644 --- a/src/talemate/agents/help/docs-index.yaml +++ b/src/talemate/agents/help/docs-index.yaml @@ -21,9 +21,9 @@ Google AI, Groq, KoboldCpp, LMStudio, Mistral, OpenAI, TabbyAPI, and Text-Generation-WebUI.' - path: getting-started/advanced/change-host-and-port.md title: Changing host and port - summary: How to change the backend (default localhost:5050) and frontend (default localhost:8082) host/port via TALEMATE_BACKEND_/FRONTEND_HOST/PORT - env vars or --host/--port CLI flags, point the frontend at a new backend with VITE_TALEMATE_BACKEND_WEBSOCKET_URL, plus - Docker runtime configuration and 0.36.x-to-0.37.0 upgrade notes (frontend port 8080-to-8082, renamed compose variables). + summary: Changing the backend (localhost:5050) and frontend (localhost:8082) bind addresses via TALEMATE_BACKEND_HOST/PORT + and TALEMATE_FRONTEND_HOST/PORT or the --host/--port CLI flags, rebuilding the frontend with VITE_TALEMATE_BACKEND_WEBSOCKET_URL, + Docker Compose runtime overrides with CUDA/CPU file selection, and the 0.37.0 rename notes. - path: getting-started/advanced/debug-logging.md title: Debug Logging summary: Enable verbose DEBUG-level logging with TALEMATE_DEBUG=1 on Linux and Windows; errors then also go to a rotating @@ -45,8 +45,9 @@ Text-Generation-WebUI, LMStudio, TabbyAPI), and assigning the client to all agents.' - path: getting-started/installation/docker.md title: Docker - summary: 'Docker install: clone the repo, copy config.example.yaml to config.yaml, run docker compose up, and open http://localhost:8082. - Notes on pre-built CUDA images vs manual build and using host.docker.internal to reach local APIs on the host machine.' + summary: 'Docker installation and GPU/CPU launch paths: clone and copy config.yaml, then use docker compose up for NVIDIA + CUDA or docker compose -f docker-compose.cpu.yml up on CPU-only hosts. Covers local builds with docker-compose.manual.yml, + CUDA verification with nvidia-smi/PyTorch, host.docker.internal, and Pi Bridge config.' - path: getting-started/installation/linux.md title: Linux summary: 'Linux install: python and uv prerequisites, running install.sh (which downloads portable Node.js 22 and offers @@ -54,9 +55,9 @@ frontend (corepack pnpm serve).' - path: getting-started/installation/troubleshoot.md title: Common issues - summary: 'Installation troubleshooting: Windows frontend failures caused by special characters in the path, Docker creating - a config.yaml directory when the example config was not copied, configuring VITE_TALEMATE_BACKEND_WEBSOCKET_URL at container - runtime, and running behind a reverse proxy with SSL/nginx WebSocket upgrades.' + summary: Troubleshooting Windows install paths, Docker CUDA startup failures, and reverse-proxy WebSockets. Shows the CPU + opt-out command, NVIDIA Container Toolkit smoke test, in-container nvidia-smi/PyTorch checks, the config.yaml mount pitfall, + VITE WebSocket URL, and nginx upgrade headers. - path: getting-started/installation/windows.md title: Windows summary: 'Windows install: download the release ZIP and double-click start.bat, which auto-downloads portable Python 3 and diff --git a/tests/test_docker_compose.py b/tests/test_docker_compose.py new file mode 100644 index 00000000..a77c8694 --- /dev/null +++ b/tests/test_docker_compose.py @@ -0,0 +1,181 @@ +import json +import os +import shutil +import subprocess +import tomllib +from pathlib import Path + +import pytest +from packaging.markers import Marker, default_environment + + +ROOT = Path(__file__).parent.parent +COMPOSE_AVAILABLE = ( + shutil.which("docker") is not None + and subprocess.run( + ["docker", "compose", "version"], + capture_output=True, + check=False, + ).returncode + == 0 +) +PORTS = {5050: 5050, 8082: 8082} +CPU_COMMAND = "docker compose -f docker-compose.cpu.yml up" +COMPOSE_DEFAULTS = { + "PYTHONPATH": "", + "TALEMATE_BACKEND_PORT": "5050", + "TALEMATE_FRONTEND_PORT": "8082", + "VITE_TALEMATE_BACKEND_WEBSOCKET_URL": "", +} +ENVIRONMENT_KEYS = { + "PYTHONPATH", + "PYTHONUNBUFFERED", + "TALEMATE_BACKEND_PORT", + "TALEMATE_FRONTEND_PORT", + "VITE_TALEMATE_BACKEND_WEBSOCKET_URL", +} +VOLUME_TARGETS = { + "/app/chroma", + "/app/config.yaml", + "/app/pi", + "/app/scenes", + "/app/secrets", + "/app/templates", + "/app/tts", +} + + +def render_compose(*files: str) -> dict: + command = ["docker", "compose"] + for compose_file in files: + command.extend(["-f", compose_file]) + command.extend(["config", "--format", "json"]) + result = subprocess.run( + command, + cwd=ROOT, + capture_output=True, + check=True, + env={**os.environ, **COMPOSE_DEFAULTS}, + text=True, + ) + return json.loads(result.stdout) + + +def assert_runtime_contract(service: dict, *, gpu: bool, local_build: bool) -> None: + ports = {int(port["target"]): int(port["published"]) for port in service["ports"]} + assert ports == PORTS + assert {volume["target"] for volume in service["volumes"]} == VOLUME_TARGETS + assert set(service["environment"]) == ENVIRONMENT_KEYS + + devices = ( + service.get("deploy", {}) + .get("resources", {}) + .get("reservations", {}) + .get("devices", []) + ) + if gpu: + assert devices == [{"driver": "nvidia", "count": -1, "capabilities": ["gpu"]}] + else: + assert devices == [] + + if local_build: + assert service["image"] == "talemate:local" + assert service["pull_policy"] == "build" + assert service["build"]["dockerfile"] == "Dockerfile" + else: + assert service["image"] == "ghcr.io/vegu-ai/talemate:latest" + assert "build" not in service + + +@pytest.mark.skipif(not COMPOSE_AVAILABLE, reason="Docker Compose CLI is unavailable") +@pytest.mark.parametrize( + ("files", "gpu", "local_build"), + [ + pytest.param((), True, False, id="default-cuda-image"), + pytest.param(("docker-compose.cpu.yml",), False, False, id="cpu-image"), + pytest.param( + ("docker-compose.manual.yml",), False, True, id="standalone-cpu-build" + ), + pytest.param( + ("docker-compose.yml", "docker-compose.manual.yml"), + True, + True, + id="cuda-build", + ), + pytest.param( + ("docker-compose.cpu.yml", "docker-compose.manual.yml"), + False, + True, + id="cpu-build", + ), + ], +) +def test_documented_compose_invocations_render_complete_service( + files: tuple[str, ...], gpu: bool, local_build: bool +): + service = render_compose(*files)["services"]["talemate"] + + assert_runtime_contract(service, gpu=gpu, local_build=local_build) + + +@pytest.mark.skipif(not COMPOSE_AVAILABLE, reason="Docker Compose CLI is unavailable") +def test_render_compose_ignores_ambient_port_overrides(monkeypatch): + monkeypatch.setenv("TALEMATE_FRONTEND_PORT", "9090") + monkeypatch.setenv("TALEMATE_BACKEND_PORT", "6060") + + service = render_compose("docker-compose.cpu.yml")["services"]["talemate"] + ports = {int(port["target"]): int(port["published"]) for port in service["ports"]} + + assert ports == PORTS + + +def applies_to_docker(package: dict) -> bool: + markers = package.get("resolution-markers") + if not markers: + return True + + environment = default_environment() + environment.update( + platform_machine="x86_64", + platform_system="Linux", + python_full_version="3.11.0", + python_version="3.11", + sys_platform="linux", + ) + return any(Marker(marker).evaluate(environment) for marker in markers) + + +def test_locked_torch_build_includes_cuda(): + with (ROOT / "uv.lock").open("rb") as lock_file: + packages = tomllib.load(lock_file)["package"] + + docker_torch_packages = [ + package + for package in packages + if package["name"] == "torch" and applies_to_docker(package) + ] + + assert len(docker_torch_packages) == 1 + torch = docker_torch_packages[0] + assert "+cu" in torch["version"] + assert torch["source"]["registry"].startswith("https://download.pytorch.org/whl/cu") + + +def test_cpu_opt_out_is_shown_on_install_and_startup_failure_paths(): + install = (ROOT / "docs/getting-started/installation/docker.md").read_text() + advanced = ( + ROOT / "docs/getting-started/advanced/change-host-and-port.md" + ).read_text() + environment_variables = ( + ROOT / "docs/getting-started/advanced/environment-variables.md" + ).read_text() + troubleshooting = ( + ROOT / "docs/getting-started/installation/troubleshoot.md" + ).read_text() + + assert CPU_COMMAND in install + assert CPU_COMMAND in advanced + assert CPU_COMMAND in troubleshooting + assert "default and CPU-only Compose configurations" in environment_variables + assert "working NVIDIA Container Toolkit" in troubleshooting + assert 'could not select device driver "nvidia"' in troubleshooting diff --git a/tests/test_help_agent.py b/tests/test_help_agent.py index 0c15c25e..c84d9867 100644 --- a/tests/test_help_agent.py +++ b/tests/test_help_agent.py @@ -51,6 +51,7 @@ def test_docs_index_loads_and_paths_exist(): for entry in index: assert entry.keys() >= {"path", "title", "summary"} assert (docs.DOCS_DIR / entry["path"]).is_file(), entry["path"] + assert not entry["title"].startswith(":material-"), entry["path"] def test_search_docs_returns_matches():