Spaces:
Running
Running
File size: 4,039 Bytes
414dc55 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 | """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
@server.api(name="new_case")
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)
@server.api(name="interrogate")
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
)
@server.get("/healthz")
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",
)
@server.get("/")
def _index() -> FileResponse:
return FileResponse(_INDEX)
@server.get("/{filename}")
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
|