Port Docker CUDA support to 0.39 (#350)

* fix: port Docker CUDA support to 0.39

* docs: show manual Docker GPU opt-in
This commit is contained in:
veguAI
2026-08-21 00:53:19 +03:00
committed by GitHub
parent 8288064fb1
commit 4b26c28802
11 changed files with 300 additions and 52 deletions

View File

@@ -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."

20
docker-compose.cpu.yml Normal file
View File

@@ -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}

View File

@@ -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}

View File

@@ -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]

View File

@@ -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`.
`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`.

View File

@@ -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`

View File

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

View File

@@ -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;
}
```
```

View File

@@ -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

View File

@@ -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

View File

@@ -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():