loosecanvas / Dockerfile
Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c
Raw
History Blame Contribute Delete
8.46 kB
# syntax=docker/dockerfile:1
# ─────────────────────────────────────────────────────────────────────────────
# loosecanvas β€” single-container HF Docker Space (Build Small Hackathon).
#
# One image, two processes (entrypoint.sh): native llama.cpp `llama-server` on
# 127.0.0.1:8080 (SAME binary + flags validated locally β€” the structured-output
# path is build-sensitive, so we do NOT swap to llama-cpp-python) and uvicorn on
# the ONE public port (HF proxies HTTPS to app_port).
#
# Multi-stage: the `builder` has Node + build tools to compile the Svelte custom
# component; the `runtime` stage drops them and copies only the ready /opt/venv +
# app source + the native llama-server (already in the base). GPU is runtime-only
# on HF β€” nothing here runs a CUDA command at build.
#
# Model handling (docker best practice β€” keep the image small, don't re-download):
# β€’ BAKE_MODEL=0 (DEFAULT): model NOT in the image. The entrypoint downloads it
# once to $MODEL_DIR. Mount a persistent HF Storage Bucket at $MODEL_DIR so it
# survives restarts/rebuilds and is fetched exactly once. Locally: bind-mount
# ./models and it is never downloaded at all.
# β€’ BAKE_MODEL=1: bake the GGUF + MTP draft into the image (self-contained, zero
# cold-start download β€” simplest/most reliable for a judged Space, ~+14 GB).
#
# Build (fast, no model layer): docker build -t loosecanvas .
# Self-contained (bakes ~14 GB): docker build --build-arg BAKE_MODEL=1 -t loosecanvas .
# ─────────────────────────────────────────────────────────────────────────────
# Pinned by digest (resolved 2026-06-14) β€” a moving tag can silently change the
# llama-server CLI / structured-output behaviour. Re-pin deliberately on bump.
ARG LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:full-cuda@sha256:e9ecf4e6e88b0c0677d1e9edd4ac27c942fb0bac5fedaf821f391e4b244efba9
# Fixed locations so the venv is self-contained + copyable across stages.
ARG VENV=/opt/venv
ARG UVPY=/opt/uv-python
# ============================================================================
# Stage 1 β€” builder: compile the custom component + resolve the venv.
# ============================================================================
FROM ${LLAMACPP_IMAGE} AS builder
ARG VENV
ARG UVPY
ENV DEBIAN_FRONTEND=noninteractive \
UV_LINK_MODE=copy \
UV_PYTHON_INSTALL_DIR=${UVPY} \
UV_PROJECT_ENVIRONMENT=${VENV} \
VIRTUAL_ENV=${VENV} \
PATH=/root/.local/bin:${VENV}/bin:${PATH}
# build deps: node (custom-component frontend) + git/curl. apt + uv caches mounted.
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates git \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& node --version && npm --version
# uv + Python 3.13.
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
RUN --mount=type=cache,target=/root/.cache/uv \
uv python install 3.13
# Build at the SAME path the runtime uses so the editable install's .pth and the
# app's fixture lookup (parents[2] of main.py) resolve after the cross-stage copy.
WORKDIR /home/user/app
# Deps layer keyed on the lockfile only (cached across source edits).
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-install-project
# Full source, then install the project + build the Svelte custom component.
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev \
&& uv pip install build \
&& uv pip install -e ./cytoscapecanvas
# Frontend deps (package-lock.json is committed β†’ reproducible npm ci).
RUN --mount=type=cache,target=/root/.npm \
cd cytoscapecanvas/frontend && npm ci --cache /root/.npm
# Compile Svelte β†’ templates/, build the wheel, then install the real wheel
# (replaces the editable install with one that bundles the built templates).
# Invoke gradio via the venv binary (NOT `uv run` from the subdir: uv would treat
# the component pyproject as a standalone project and fail resolution).
RUN cd cytoscapecanvas \
&& ${VENV}/bin/gradio cc build --no-generate-docs --python-path ${VENV}/bin/python \
&& uv pip install --no-deps --force-reinstall dist/*.whl
# ============================================================================
# Stage 2 β€” runtime: native llama-server (base) + the built venv + app source.
# ============================================================================
FROM ${LLAMACPP_IMAGE} AS runtime
ARG VENV
ARG UVPY
# curl is needed by the entrypoint health probe.
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends curl ca-certificates
# HF Spaces run the container as uid 1000; the base (Ubuntu noble) already has a
# uid-1000 user β€” remove whoever owns it, then create our own.
RUN (userdel -r "$(getent passwd 1000 | cut -d: -f1)" 2>/dev/null || true) \
&& useradd -m -u 1000 -s /bin/bash user
ENV HOME=/home/user \
PATH=${VENV}/bin:/app:/home/user/.local/bin:${PATH} \
HF_HOME=/home/user/.cache/huggingface \
PYTHONUNBUFFERED=1 \
LD_LIBRARY_PATH=/app:/usr/local/cuda/lib64
# The native llama.cpp shared libs (libllama.so, libggml*.so, …) live in /app.
# The base image's /app/tools.sh entrypoint sets this up; we invoke llama-server
# directly, so we add /app to the loader path ourselves.
# The compiled venv + its managed Python (identical paths β†’ venv symlinks resolve).
COPY --from=builder --chown=user ${VENV} ${VENV}
COPY --from=builder --chown=user ${UVPY} ${UVPY}
# App source at the SAME path used in the builder so the editable .pth + fixtures/ resolve.
COPY --from=builder --chown=user /home/user/app /home/user/app
# chown the app dir itself (COPY's --chown sets contents, but the top dir can stay
# root-owned β†’ uid-1000 mkdir later fails), then drop build-only weight.
RUN chown user:user /home/user/app \
&& rm -rf /home/user/app/cytoscapecanvas/frontend/node_modules \
/home/user/app/cytoscapecanvas/dist /home/user/app/.git
WORKDIR /home/user/app
# ── model source (public/ungated Unsloth repo β€” no HF token) ────────────────
ARG BAKE_MODEL=0
ARG MODEL_REPO=unsloth/gemma-4-26B-A4B-it-qat-GGUF
ARG MODEL_FILE=gemma-4-26B-A4B-it-qat-UD-Q4_K_XL.gguf
ARG MTP_REPO=unsloth/gemma-4-26B-A4B-it-qat-GGUF
ARG MTP_FILE=mtp-gemma-4-26B-A4B-it.gguf
ENV MODEL_REPO=${MODEL_REPO} \
GEMMA_MODEL_FILENAME=${MODEL_FILE} \
MTP_REPO=${MTP_REPO} \
MTP_MODEL_FILE=${MTP_FILE} \
MODEL_DIR=/home/user/app/models
USER user
RUN mkdir -p "$MODEL_DIR" \
&& if [ "$BAKE_MODEL" = "1" ]; then \
echo "Baking model + MTP draft from ${MODEL_REPO}…" && \
python -c "import os;from huggingface_hub import hf_hub_download as d;d(os.environ['MODEL_REPO'],os.environ['GEMMA_MODEL_FILENAME'],local_dir=os.environ['MODEL_DIR']);d(os.environ['MTP_REPO'],os.environ['MTP_MODEL_FILE'],local_dir=os.environ['MODEL_DIR'])" ; \
else \
echo "BAKE_MODEL=0 β€” models NOT baked; entrypoint downloads to \$MODEL_DIR (mount a persistent volume there to fetch once)." ; \
fi
# ── runtime config (the app reads these via raw os.environ) ─────────────────
# ctx 32768 fits the 24 GB L4 with MTP on with headroom (base ~13.5 GB + MTP
# ~2 GB); 65536 is also viable on the L4. Set LLAMA_CPP_MTP=0 on a 16 GB GPU.
ENV LLAMA_CPP_PORT=8080 \
LLAMA_CPP_CTX=32768 \
LLAMA_CPP_MTP=1 \
SPEC_DRAFT_N_MAX=2 \
APP_PORT=7860 \
LOOSECANVAS_ALLOWED_BASE=/home/user/app \
LOOSECANVAS_DEMO_SEED=1
# DEMO_SEED=1 seeds a graph on first paint for the public demo (instant magic);
# the default app (env unset) keeps the P0-01 blank-slate workspace.
EXPOSE 7860
COPY --chown=user entrypoint.sh /home/user/entrypoint.sh
RUN chmod +x /home/user/entrypoint.sh
ENTRYPOINT ["/home/user/entrypoint.sh"]