Spaces:
Running
Running
Case Zero - initial public release (fully local: Qwen2.5-1.5B via llama.cpp + Supertonic, custom pixel-noir SPA via gradio.Server)
414dc55 | """Build the single ``gradio.Server`` that powers Case Zero. | |
| ``gradio.Server`` is a FastAPI subclass, so all standard FastAPI methods work on it | |
| (`.include_router`, `.get`, `.mount`, ...) plus a `.api()` decorator that adds Gradio's | |
| queue + SSE streaming + concurrency control. We: | |
| * register the game's JSON/SSE routers under ``/api``; | |
| * serve the built Preact pixel-art SPA (``web/dist``). | |
| The whole app runs 100% inside one Gradio process - no separate frontend host. | |
| Static serving uses explicit routes (assets under ``/assets``, index at ``/``) rather than | |
| a catch-all mount at ``/`` on purpose: a ``Mount("/")`` shadows Gradio's own internal | |
| ``/gradio_api/*`` routes (including its launch health check) and breaks startup. | |
| """ | |
| from __future__ import annotations | |
| from pathlib import Path | |
| from fastapi import HTTPException | |
| from fastapi.responses import FileResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from gradio import Server | |
| from ..constants import ASSETS_DIR | |
| from .routes_case import router as case_router | |
| from .routes_run import router as run_router | |
| # src/case_zero/api/server.py -> repo root is three parents up from this file's dir. | |
| _REPO_ROOT = Path(__file__).resolve().parents[3] | |
| WEB_DIST = (_REPO_ROOT / "web" / "dist").resolve() | |
| _INDEX = WEB_DIST / "index.html" | |
| _AUDIO_DIR = ASSETS_DIR / "ui" | |
| def build_server() -> Server: | |
| """Return a configured (not yet launched) ``gradio.Server``.""" | |
| server = Server(title="Case Zero", docs_url=None, redoc_url=None, openapi_url=None) | |
| server.include_router(case_router) | |
| server.include_router(run_router) | |
| # Gradio API endpoints (Server mode): the core game actions registered through Gradio's | |
| # own API engine - queue + concurrency + gradio_client-callable + visible in the API view. | |
| # This makes the app unambiguously a Gradio application (the custom SPA is the Off-Brand | |
| # frontend on top). The SPA itself uses the REST routes above; these mirror them. | |
| from .dto import NewCaseRequest | |
| from .routes_case import new_case | |
| from .routes_run import InterrogateBody, interrogate | |
| def gr_new_case(case_id: str = "") -> dict: | |
| """Generate a fresh case (or load one by id); returns the public case JSON.""" | |
| return new_case(NewCaseRequest(case_id=case_id or None)).model_dump(by_alias=True) | |
| def gr_interrogate(run_id: str, suspect_id: str, question: str) -> dict: | |
| """Ask a suspect a free-text question; returns their reply + server suspicion.""" | |
| return interrogate(run_id, suspect_id, InterrogateBody(free_text=question)).model_dump( | |
| by_alias=True | |
| ) | |
| def healthz() -> dict[str, bool]: | |
| return {"ok": True} | |
| # UI audio (sfx + ambient music) - a specific prefix, served locally (Off-the-Grid). | |
| if _AUDIO_DIR.is_dir(): | |
| server.mount("/audio", StaticFiles(directory=str(_AUDIO_DIR)), name="audio") | |
| if WEB_DIST.is_dir(): | |
| # Hashed JS/CSS live under /assets - a specific prefix that never collides with /api. | |
| server.mount( | |
| "/assets", | |
| StaticFiles(directory=str(WEB_DIST / "assets")), | |
| name="assets", | |
| ) | |
| def _index() -> FileResponse: | |
| return FileResponse(_INDEX) | |
| def _root_or_spa(filename: str) -> FileResponse: | |
| # Serve real root files (favicon.svg, icons.svg); otherwise fall back to the SPA | |
| # index so single-segment client routes resolve. Single-segment matching means | |
| # this never shadows /gradio_api/* or /api/* (those have deeper paths). | |
| candidate = (WEB_DIST / filename).resolve() | |
| if candidate.is_file() and candidate.is_relative_to(WEB_DIST): | |
| return FileResponse(candidate) | |
| if _INDEX.is_file(): | |
| return FileResponse(_INDEX) | |
| raise HTTPException(status_code=404) | |
| return server | |