telltale / app.py
smpdl
Fix Space startup port conflict on gr.Server launch.
1247ccf
Raw
History Blame Contribute Delete
8.85 kB
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Literal
import gradio as gr
from fastapi import HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from telltale.server import api
os.environ.setdefault("GRADIO_SSR_MODE", "false")
WEB_DIR = Path(__file__).parent / "telltale" / "web"
DIST_DIR = WEB_DIR / "dist"
ASSETS_DIR = DIST_DIR / "assets"
SOURCE_INDEX = WEB_DIR / "index.html"
ErrorCode = Literal["bad_request", "not_found", "illegal_action", "server_error"]
class ErrorPayload(BaseModel):
code: ErrorCode
message: str
class StartRequest(BaseModel):
seed: int | None = None
model_mode: Literal["mock", "zero_gpu", "llama_server", "llama_cpp"] | None = None
tts_enabled: bool = False
stt_enabled: bool = False
class ActionRequest(BaseModel):
run_id: str = Field(min_length=1)
action: Literal["fold", "check", "call", "bet", "raise", "all_in"]
amount: int = Field(default=0, ge=0)
utterance: str = ""
class RewardRequest(BaseModel):
run_id: str = Field(min_length=1)
perk_id: str = Field(min_length=1)
class ContinueRequest(BaseModel):
run_id: str = Field(min_length=1)
def create_gradio_app() -> gr.Blocks:
with gr.Blocks(title="Telltale API", fill_height=True) as demo:
gr.Markdown(
"""
# Telltale backend
The player-facing game UI is served at `/`. This hidden Gradio mount exists for
Hugging Face/Gradio compatibility and lightweight backend inspection.
"""
)
start = gr.Button("Start llama.cpp backend run", variant="primary")
state_json = gr.JSON(label="Public state")
def on_start() -> dict[str, Any]:
return api.start_run()
start.click(on_start, inputs=[], outputs=state_json)
return demo
def create_app() -> gr.Server:
app = gr.Server(title="Telltale", docs_url="/api/docs", redoc_url=None)
app.add_exception_handler(HTTPException, _http_exception_handler)
app.add_exception_handler(RequestValidationError, _validation_exception_handler)
app.add_exception_handler(Exception, _unhandled_exception_handler)
@app.post("/api/start")
def start_run(request: StartRequest):
settings: dict[str, Any] = {
"tts_enabled": request.tts_enabled,
"stt_enabled": request.stt_enabled,
}
if request.model_mode is not None:
settings["model_mode"] = request.model_mode
return _api_call(
lambda: api.start_run(
seed=request.seed,
settings=settings,
)
)
@app.get("/api/floors")
def list_floors():
return _api_call(api.list_floors)
@app.get("/api/state/{run_id}")
def get_state(run_id: str):
return _api_call(lambda: api.get_state(run_id))
@app.post("/api/action")
def submit_action(request: ActionRequest):
return _api_call(
lambda: api.submit_player_action(
request.run_id,
request.action,
amount=request.amount,
utterance=request.utterance,
)
)
@app.post("/api/reward")
def choose_reward(request: RewardRequest):
return _api_call(lambda: api.choose_reward(request.run_id, request.perk_id))
@app.post("/api/continue")
def continue_run(request: ContinueRequest):
return _api_call(lambda: api.continue_until_player_turn(request.run_id))
@app.get("/api/trace/{run_id}")
def export_trace(run_id: str):
return _api_call(lambda: api.export_trace(run_id))
@app.post("/api/transcribe/{run_id}")
async def transcribe_audio(run_id: str, request: Request):
audio = await request.body()
return _api_call(lambda: api.transcribe_player_audio(run_id, audio))
@app.get("/api/audio/{audio_id}")
def get_audio(audio_id: str):
result = _api_call(lambda: api.get_tts_audio(audio_id))
if isinstance(result, JSONResponse):
return result
return FileResponse(result["path"], media_type=result["mime_type"])
if ASSETS_DIR.exists():
app.mount("/assets", StaticFiles(directory=ASSETS_DIR), name="assets")
public_assets = WEB_DIR / "public" / "assets"
if public_assets.exists():
app.mount("/game-assets", StaticFiles(directory=public_assets), name="game-assets")
@app.get("/")
def index() -> Response:
index_path = DIST_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path)
if SOURCE_INDEX.exists():
return FileResponse(SOURCE_INDEX)
return JSONResponse({"message": "Build the React frontend with `cd telltale/web && npm run build`."})
gr.mount_gradio_app(app, create_gradio_app(), path="/gradio", footer_links=[])
@app.get("/{path:path}")
def spa_fallback(path: str) -> Response:
if path.startswith(("api/", "gradio/")):
raise HTTPException(status_code=404, detail=f"Unknown path: /{path}")
static_path = DIST_DIR / path
if static_path.is_file():
return FileResponse(static_path)
public_path = WEB_DIR / "public" / path
if public_path.is_file():
return FileResponse(public_path)
index_path = DIST_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path)
raise HTTPException(status_code=404, detail="React frontend has not been built.")
return app
def _error(code: ErrorCode, message: str, status_code: int) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content={"error": ErrorPayload(code=code, message=message).model_dump()},
)
def _api_call(callback: Any) -> dict[str, Any] | JSONResponse:
try:
return callback()
except KeyError as error:
return _error("not_found", _clean_message(error), 404)
except ValueError as error:
message = _clean_message(error)
code: ErrorCode = "illegal_action" if "not legal" in message else "bad_request"
return _error(code, message, 400)
except Exception as error:
return _error("server_error", _clean_message(error), 500)
async def _http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
code: ErrorCode = "not_found" if exc.status_code == 404 else "bad_request"
return _error(code, str(exc.detail), exc.status_code)
async def _validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
return _error("bad_request", "Request validation failed.", 422)
async def _unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
if isinstance(exc, KeyError):
return _error("not_found", _clean_message(exc), 404)
if isinstance(exc, ValueError):
message = _clean_message(exc)
code: ErrorCode = "illegal_action" if "not legal" in message else "bad_request"
return _error(code, message, 400)
return _error("server_error", _clean_message(exc), 500)
def _clean_message(error: Exception) -> str:
text = str(error)
if len(text) >= 2 and text[0] == text[-1] == "'":
return text[1:-1]
return text or error.__class__.__name__
app = create_app()
def _launch_kwargs() -> dict[str, Any]:
return {
"server_name": os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
"server_port": int(os.getenv("PORT", os.getenv("GRADIO_SERVER_PORT", "7860"))),
"ssr_mode": False,
"_frontend": False,
"show_error": True,
}
def _acquire_space_launch_lock() -> bool:
if not os.getenv("SPACE_ID"):
return True
import fcntl
import sys
lock_path = Path(os.getenv("TMPDIR", "/tmp")) / "telltale-space.launch.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
lock_file = lock_path.open("w")
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
# Dev-mode Spaces can spawn multiple workers; only one should bind :7860.
sys.exit(0)
globals()["_space_launch_lock_file"] = lock_file
return True
def launch_app() -> None:
import time
_acquire_space_launch_lock()
kwargs = _launch_kwargs()
retries = 10 if os.getenv("SPACE_ID") else 1
for attempt in range(retries):
try:
app.launch(**kwargs)
return
except OSError as error:
if "empty port" not in str(error) or attempt >= retries - 1:
raise
time.sleep(1)
if __name__ == "__main__":
launch_app()