diff --git a/Dockerfile b/Dockerfile index 9254fcf..f1c90df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,34 @@ +FROM node:22-bookworm-slim AS ui + +WORKDIR /ui +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend ./ +RUN npm run build + + FROM nvidia/cuda:13.3.0-runtime-ubuntu24.04 ENV PYTHONUNBUFFERED=1 \ ELY_EYE_HOME=/data/ely-eye \ ELY_EYE_RUNTIME_BACKEND=sglang \ - ELY_EYE_SGLANG_BASE_URL=http://127.0.0.1:8000/v1 + ELY_EYE_SGLANG_BASE_URL=http://127.0.0.1:8000/v1 \ + ELY_EYE_UI_DIR=/srv/ely-eye/ui WORKDIR /app RUN apt-get update \ - && apt-get install -y --no-install-recommends python3.12 python3.12-venv python3-pip nodejs npm ffmpeg \ + && apt-get install -y --no-install-recommends python3.12 python3.12-venv python3-pip ffmpeg \ && rm -rf /var/lib/apt/lists/* COPY backend ./backend -COPY frontend ./frontend -COPY README.md PRD.md ./ +COPY README.md ./ RUN python3.12 -m venv /opt/ely-eye \ && /opt/ely-eye/bin/pip install --upgrade pip setuptools wheel \ - && /opt/ely-eye/bin/pip install -e ./backend \ - && cd frontend \ - && npm install \ - && npm run build + && /opt/ely-eye/bin/pip install ./backend + +COPY --from=ui /ui/dist /srv/ely-eye/ui EXPOSE 8765 CMD ["/opt/ely-eye/bin/ely-eye", "serve", "--host", "0.0.0.0", "--port", "8765"] diff --git a/backend/src/ely_eye/api.py b/backend/src/ely_eye/api.py index d38e8da..080facf 100644 --- a/backend/src/ely_eye/api.py +++ b/backend/src/ely_eye/api.py @@ -7,6 +7,7 @@ from typing import Annotated from fastapi import Depends, FastAPI, File, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from . import __version__ @@ -339,6 +340,15 @@ def create_app() -> FastAPI: "cartridges": CartridgeService(settings, db).list_manifests(), } + mounted_ui = get_settings().ui_dir + if mounted_ui is not None: + if not mounted_ui.is_dir(): + raise SystemExit( + f"ui_dir {mounted_ui} is set but holds no built interface; " + "run `npm run build` in frontend/ or unset ELY_EYE_UI_DIR" + ) + app.mount("/", StaticFiles(directory=mounted_ui, html=True), name="ui") + return app diff --git a/backend/src/ely_eye/config.py b/backend/src/ely_eye/config.py index fb0ef79..d39cf30 100644 --- a/backend/src/ely_eye/config.py +++ b/backend/src/ely_eye/config.py @@ -26,6 +26,7 @@ class Settings(BaseSettings): runtime_backend: Literal["sglang", "transformers"] = "transformers" sglang_base_url: str = "http://127.0.0.1:8000/v1" sglang_api_key: str = "ely-eye-local" + ui_dir: Path | None = None dense_embeddings_enabled: bool = False live_context_tokens: int = 262_144 extreme_context_tokens: int = 1_010_000 diff --git a/backend/tests/test_serve_ui.py b/backend/tests/test_serve_ui.py new file mode 100644 index 0000000..32d78f3 --- /dev/null +++ b/backend/tests/test_serve_ui.py @@ -0,0 +1,41 @@ +"""The container story only holds if a built interface is actually served.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from ely_eye import api as api_module +from ely_eye.config import Settings + + +def _client(settings: Settings, monkeypatch) -> TestClient: + monkeypatch.setattr(api_module, "get_settings", lambda: settings) + return TestClient(api_module.create_app()) + + +def test_a_built_interface_is_served_from_the_api_origin(tmp_path: Path, monkeypatch) -> None: + ui = tmp_path / "dist" + ui.mkdir() + (ui / "index.html").write_text("ely-eye", encoding="utf-8") + settings = Settings(home=tmp_path / "home", ui_dir=ui) + client = _client(settings, monkeypatch) + answer = client.get("/") + assert answer.status_code == 200 and "ely-eye" in answer.text + assert client.get("/api/status").status_code == 200 + + +def test_a_ui_directory_that_was_never_built_names_the_fix(tmp_path: Path, monkeypatch) -> None: + settings = Settings(home=tmp_path / "home", ui_dir=tmp_path / "dist") + monkeypatch.setattr(api_module, "get_settings", lambda: settings) + with pytest.raises(SystemExit, match="npm run build"): + api_module.create_app() + + +def test_no_ui_directory_means_the_api_alone_answers(tmp_path: Path, monkeypatch) -> None: + settings = Settings(home=tmp_path / "home") + client = _client(settings, monkeypatch) + assert client.get("/").status_code == 404 + assert client.get("/api/status").status_code == 200