mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-03 20:12:55 +00:00
pip install docsgpt now brings the web UI with it: `docsgpt api` serves the API and the UI on one port. - scripts/build_frontend.sh builds the frontend into docsgpt/static (gitignored) the way the frontend image does: .env.development as the production baseline, and index.html loading /config.js ahead of the bundle. hatch admits the directory into the wheel and the sdist through `artifacts`; the package workflows run the script before `uv build` and fail if the wheel lacks the UI. The backend image keeps ignoring it. - docsgpt/ui.py serves the build in front of Flask: files as they are, hashed assets immutable, Flask's own path prefixes (taken from its URL map, so new blueprints need no registration) passed through, every other GET rendered as index.html for the client-side router. /config.js is generated per request with VITE_API_HOST and VITE_BASE_URL set to the page's origin, VITE_* environment variables winning. SERVE_UI=false leaves the API alone. - docsgpt api configures gunicorn in code (gunicorn.app.base.Application) instead of rewriting sys.argv, so the SIGUSR2 re-exec that gunicorn uses for zero-downtime upgrades runs the docsgpt console script again and works; verified with a live handover. - Docs: the pip page says the UI is included, that DOCSGPT_HOME and DOCSGPT_ENV_FILE are process environment variables rather than .env entries, and the settings page describes SERVE_UI.
109 lines
4.8 KiB
Python
109 lines
4.8 KiB
Python
"""Serve the built web UI from the API process.
|
|
|
|
The wheel ships the frontend build under ``docsgpt/static`` (produced by
|
|
``scripts/build_frontend.sh``). When that directory holds an ``index.html``
|
|
and ``SERVE_UI`` is on, the ASGI shell puts :class:`StaticUI` in front of
|
|
Flask: files are served as they are, paths that belong to the backend pass
|
|
through, and every other GET renders ``index.html`` for the client-side
|
|
router. ``/config.js`` is generated per request so the UI talks to the origin
|
|
it was loaded from, the same mechanism the nginx image uses.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from collections.abc import Iterable, Mapping
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from starlette.responses import FileResponse, Response
|
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
|
|
from docsgpt.core.paths import package_dir
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Runtime values the page reads before the bundle loads. These default to the
|
|
# origin the page came from; a VITE_* variable in the process environment
|
|
# wins, so a deployment can still point the UI somewhere else.
|
|
_ORIGIN_KEYS = ("VITE_API_HOST", "VITE_BASE_URL")
|
|
|
|
# Types Python's mimetypes table may not know.
|
|
_MEDIA_TYPES = {".woff2": "font/woff2", ".woff": "font/woff", ".webmanifest": "application/manifest+json"}
|
|
|
|
_IMMUTABLE = "public, max-age=31536000, immutable"
|
|
_NO_CACHE = "no-cache"
|
|
|
|
|
|
def config_js(environ: Optional[Mapping[str, str]] = None) -> str:
|
|
"""The ``/config.js`` body: ``window.__DOCSGPT_ENV__`` from the VITE_* environment."""
|
|
env = os.environ if environ is None else environ
|
|
values = {key: value for key, value in env.items() if key.startswith("VITE_") and value}
|
|
entries = [f"{json.dumps(key)}:window.location.origin" for key in _ORIGIN_KEYS if key not in values]
|
|
entries += [f"{json.dumps(key)}:{json.dumps(value)}" for key, value in sorted(values.items())]
|
|
return "window.__DOCSGPT_ENV__={" + ",".join(entries) + "};\n"
|
|
|
|
|
|
class StaticUI:
|
|
"""ASGI app: the built UI in front of the backend."""
|
|
|
|
def __init__(self, static_dir: Path, backend: ASGIApp, backend_prefixes: Iterable[str]) -> None:
|
|
self.static_dir = Path(static_dir).resolve()
|
|
self.index = self.static_dir / "index.html"
|
|
self.backend = backend
|
|
self.backend_prefixes = frozenset(backend_prefixes)
|
|
|
|
@classmethod
|
|
def wrap(cls, backend: ASGIApp, url_map, static_dir: Optional[Path] = None, enabled: bool = True) -> ASGIApp:
|
|
"""``backend`` alone when there is no UI to serve (or it is switched off), else the UI in front of it.
|
|
|
|
The backend's path prefixes come from Flask's URL map, so a new
|
|
blueprint never needs registering here: any first path segment Flask
|
|
routes stays Flask's, everything else is the UI's.
|
|
"""
|
|
static_dir = Path(static_dir) if static_dir is not None else package_dir() / "static"
|
|
if not enabled or not (static_dir / "index.html").is_file():
|
|
return backend
|
|
prefixes = {rule.rule.split("/")[1] for rule in url_map.iter_rules() if rule.rule != "/"}
|
|
logger.info("Serving the web UI from %s", static_dir)
|
|
return cls(static_dir, backend, prefixes)
|
|
|
|
def _file(self, path: str) -> Optional[Path]:
|
|
"""The file under the static directory for ``path``, or None (never a path outside it)."""
|
|
relative = path.lstrip("/")
|
|
if not relative:
|
|
return None
|
|
try:
|
|
resolved = (self.static_dir / relative).resolve()
|
|
except OSError:
|
|
return None
|
|
if self.static_dir not in resolved.parents:
|
|
return None
|
|
return resolved if resolved.is_file() else None
|
|
|
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
if scope["type"] != "http" or scope["method"] not in ("GET", "HEAD"):
|
|
await self.backend(scope, receive, send)
|
|
return
|
|
path = scope["path"]
|
|
root = scope.get("root_path", "")
|
|
if root and path.startswith(root):
|
|
path = path[len(root):] or "/"
|
|
if path == "/config.js":
|
|
response = Response(config_js(), media_type="application/javascript", headers={"Cache-Control": "no-store"})
|
|
await response(scope, receive, send)
|
|
return
|
|
first = path.split("/")[1] if len(path) > 1 else ""
|
|
if first in self.backend_prefixes:
|
|
await self.backend(scope, receive, send)
|
|
return
|
|
file = self._file(path)
|
|
if file is None:
|
|
file, cache = self.index, _NO_CACHE
|
|
else:
|
|
cache = _IMMUTABLE if first == "assets" else _NO_CACHE
|
|
response = FileResponse(file, media_type=_MEDIA_TYPES.get(file.suffix), headers={"Cache-Control": cache})
|
|
await response(scope, receive, send)
|