Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c | # 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"] | |