| """Blind Quill — gradio.Server backend for the custom "Invisible Bindery" frontend. |
| |
| The UI lives in web/ as the production React-via-Babel frontend. |
| Here we serve that frontend and expose the bindery as queued Gradio API endpoints, |
| so the rich custom UI keeps Gradio's queue, concurrency control, and ZeroGPU. |
| |
| `stitch` is a streaming generator endpoint: it yields progress events while the |
| editor works and a final result event, so slow local (CPU/MPS) runs show real |
| progress. The Gradio JS client consumes the stream via `submit`. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| import queue |
| import threading |
| import traceback |
| from pathlib import Path |
| from typing import Iterator |
|
|
| import gradio as gr |
| from fastapi.responses import HTMLResponse |
| from fastapi.staticfiles import StaticFiles |
| from gradio import Server |
|
|
| import core |
| from model_client import ModelClientError, execution_mode |
| from observability import configure_logging, get_logger |
| from patcher import PatchApplicationError |
| from presenter import card_dict, full_story_dict, reveal_dict |
| from story_store import StoryStoreError |
| from utils import InputValidationError |
|
|
| configure_logging() |
|
|
| WEB_DIR = Path(__file__).resolve().parent / "web" |
|
|
| _USER_FACING_ERRORS = ( |
| InputValidationError, |
| StoryStoreError, |
| PatchApplicationError, |
| ModelClientError, |
| ValueError, |
| ) |
|
|
|
|
| def _guard(call, *args, **kwargs): |
| """Run a flow, converting known failures into client-visible gr.Error messages.""" |
| try: |
| return call(*args, **kwargs) |
| except gr.Error: |
| raise |
| except _USER_FACING_ERRORS as exc: |
| raise gr.Error(str(exc)) from exc |
| except Exception as exc: |
| traceback.print_exc() |
| raise gr.Error("The bindery hit an internal error. Please try again.") from exc |
|
|
|
|
| def _to_user_error(exc: BaseException) -> gr.Error: |
| if isinstance(exc, gr.Error): |
| return exc |
| if isinstance(exc, _USER_FACING_ERRORS): |
| return gr.Error(str(exc)) |
| traceback.print_exc() |
| return gr.Error("The bindery hit an internal error. Please try again.") |
|
|
|
|
| def _result_event(result) -> dict: |
| return {"type": "result", "story": full_story_dict(result.story), "reveal": reveal_dict(result)} |
|
|
|
|
| |
| |
| |
| _QUOTA_MARKERS = ("quota exceeded", "credits exceeded", "exceeded your", "runs limit") |
|
|
| _CPU_FALLBACK_NOTICE = ( |
| "No ZeroGPU quota for this session — running locally on CPU. This is slower; " |
| "the progress below is live." |
| ) |
|
|
|
|
| def _is_quota_error(exc: BaseException) -> bool: |
| if not isinstance(exc, gr.Error): |
| return False |
| text = " ".join( |
| str(part) for part in (getattr(exc, "title", ""), getattr(exc, "message", ""), exc) |
| ).lower() |
| return any(marker in text for marker in _QUOTA_MARKERS) |
|
|
|
|
| def _stream_stitch(story_id: str, fragment: str, force_cpu: bool, notice: str | None = None) -> Iterator[dict]: |
| """Run `core.stitch` in a worker thread and stream its progress events. |
| |
| Used for in-process execution (local CUDA/MPS/CPU, or the CPU fallback after |
| a ZeroGPU quota miss). A worker thread is safe here precisely because no |
| `@spaces.GPU` call is involved — that path must stay on the request thread. |
| `notice` is attached to every event so the UI can explain a fallback. |
| """ |
| events: "queue.Queue" = queue.Queue() |
| done = object() |
| holder: dict = {} |
|
|
| def worker() -> None: |
| try: |
| holder["result"] = core.stitch( |
| story_id, fragment, on_progress=events.put, force_cpu=force_cpu |
| ) |
| except BaseException as exc: |
| holder["error"] = exc |
| finally: |
| events.put(done) |
|
|
| thread = threading.Thread(target=worker, name="bq-stitch", daemon=True) |
| thread.start() |
| while True: |
| event = events.get() |
| if event is done: |
| break |
| yield {**event, "notice": notice} if notice else event |
| thread.join() |
|
|
| if "error" in holder: |
| raise holder["error"] |
| yield _result_event(holder["result"]) |
|
|
|
|
| def _stitch_events(story_id: str, fragment: str) -> Iterator[dict]: |
| """Yield progress events then a result event for one stitch. |
| |
| On a ZeroGPU Space the stitch is attempted synchronously on the request |
| thread (ZeroGPU needs that thread's context to bill the right user). If the |
| user's per-user quota is spent, ZeroGPU raises and we transparently re-run on |
| CPU with live streamed progress. Local execution always streams. |
| """ |
| try: |
| if execution_mode() == "zerogpu": |
| try: |
| |
| result = core.stitch(story_id, fragment) |
| yield _result_event(result) |
| return |
| except gr.Error as exc: |
| if not _is_quota_error(exc): |
| raise |
| get_logger().warning("ZeroGPU quota exhausted for this request; falling back to CPU.") |
| yield from _stream_stitch(story_id, fragment, force_cpu=True, notice=_CPU_FALLBACK_NOTICE) |
| return |
|
|
| yield from _stream_stitch(story_id, fragment, force_cpu=False) |
| except gr.Error: |
| raise |
| except BaseException as exc: |
| raise _to_user_error(exc) from exc |
|
|
|
|
| def build_server() -> Server: |
| app = Server(title="Blind Quill") |
|
|
| @app.api(name="list_stories") |
| def list_stories() -> dict: |
| stories = _guard(core.gallery) |
| return {"stories": [card_dict(story) for story in stories]} |
|
|
| @app.api(name="get_capsule") |
| def get_capsule(story_id: str) -> dict: |
| story = _guard(core.capsule, story_id) |
| return {"story": card_dict(story)} |
|
|
| @app.api(name="create_story", concurrency_limit=1, concurrency_id="bindery") |
| def create_story(seed: str) -> dict: |
| story = _guard(core.create, seed) |
| return {"story": full_story_dict(story)} |
|
|
| @app.api(name="stitch", concurrency_limit=1, concurrency_id="bindery") |
| def stitch(story_id: str, fragment: str) -> dict: |
| |
| yield from _stitch_events(story_id, fragment) |
|
|
| @app.api(name="read_manuscript") |
| def read_manuscript(story_id: str) -> dict: |
| story = _guard(core.read_manuscript, story_id) |
| return {"story": full_story_dict(story)} |
|
|
| app.mount("/web", StaticFiles(directory=str(WEB_DIR)), name="web") |
|
|
| @app.get("/", response_class=HTMLResponse) |
| def homepage() -> str: |
| return (WEB_DIR / "index.html").read_text(encoding="utf-8") |
|
|
| return app |
|
|
|
|
| def _port() -> int: |
| for key in ("GRADIO_SERVER_PORT", "PORT"): |
| value = os.environ.get(key) |
| if value: |
| try: |
| return int(value) |
| except ValueError: |
| pass |
| return 7860 |
|
|
|
|
| def _should_launch() -> bool: |
| if os.environ.get("BQ_NO_LAUNCH") == "1": |
| return False |
| |
| return __name__ == "__main__" or bool(os.environ.get("SPACE_ID")) |
|
|
|
|
| app = build_server() |
|
|
| if _should_launch(): |
| get_logger().info("Launching Blind Quill on port %d (execution=%s)", _port(), execution_mode()) |
| app.launch(server_name="0.0.0.0", server_port=_port(), show_error=True) |
|
|