mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 14:14:50 +00:00
feat(package): ship the web UI in the wheel and serve it from the API
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.
This commit is contained in:
1 parent
0c348917b1
commit
43f493b926
15 files changed
+419
-39
No files matched your search
@@ -23,3 +23,5 @@ docsgpt/*.pkl
|
||||
docsgpt/.env
|
||||
docsgpt/.env.*
|
||||
docsgpt/Dockerfile
|
||||
# The backend image serves no UI (the frontend image does); a local UI build stays out.
|
||||
docsgpt/static/
|
||||
@@ -12,6 +12,8 @@ on:
|
||||
- 'docsgpt/**'
|
||||
- 'README.md'
|
||||
- 'LICENSE'
|
||||
- 'frontend/**'
|
||||
- 'scripts/build_frontend.sh'
|
||||
- '.github/workflows/package-build.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -21,6 +23,8 @@ on:
|
||||
- 'docsgpt/**'
|
||||
- 'README.md'
|
||||
- 'LICENSE'
|
||||
- 'frontend/**'
|
||||
- 'scripts/build_frontend.sh'
|
||||
- '.github/workflows/package-build.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -37,6 +41,17 @@ jobs:
|
||||
|
||||
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build the web UI into the package
|
||||
env:
|
||||
CI: "true"
|
||||
run: bash scripts/build_frontend.sh
|
||||
|
||||
- name: Build the sdist and wheel
|
||||
run: uv build
|
||||
|
||||
@@ -73,6 +88,21 @@ jobs:
|
||||
print(f"wheel ok: {len(names)} files")
|
||||
PY
|
||||
|
||||
- name: The wheel carries the web UI and no generated runtime config
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import glob
|
||||
import zipfile
|
||||
|
||||
wheel = zipfile.ZipFile(glob.glob("dist/*.whl")[0])
|
||||
names = set(wheel.namelist())
|
||||
assert "docsgpt/static/index.html" in names, "the UI build is missing from the wheel"
|
||||
assert any(n.startswith("docsgpt/static/assets/") for n in names), "no bundle under docsgpt/static/assets/"
|
||||
assert "docsgpt/static/config.js" not in names, "config.js is generated by the API at runtime"
|
||||
assert b'src="/config.js"' in wheel.read("docsgpt/static/index.html"), "index.html must load /config.js"
|
||||
print("ui ok:", sum(n.startswith("docsgpt/static/") for n in names), "files")
|
||||
PY
|
||||
|
||||
- name: Install the wheel into a clean environment and run the command
|
||||
run: |
|
||||
uv venv "$RUNNER_TEMP/docsgpt-venv"
|
||||
@@ -81,6 +111,7 @@ jobs:
|
||||
"$RUNNER_TEMP/docsgpt-venv/bin/docsgpt" --help
|
||||
"$RUNNER_TEMP/docsgpt-venv/bin/docsgpt" api --help
|
||||
"$RUNNER_TEMP/docsgpt-venv/bin/python" -c "from docsgpt.core import paths; print('home:', paths.home_dir())"
|
||||
"$RUNNER_TEMP/docsgpt-venv/bin/python" -c "from docsgpt.core.paths import package_dir; assert (package_dir() / 'static' / 'index.html').is_file(); print('ui installed')"
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
@@ -59,9 +59,35 @@ jobs:
|
||||
echo "::error::unknown target '$TARGET': expected pypi or testpypi"
|
||||
exit 1
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
# A publishing workflow must not restore a cache another job could have poisoned.
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Build the web UI into the package
|
||||
env:
|
||||
CI: "true"
|
||||
run: bash scripts/build_frontend.sh
|
||||
|
||||
- name: Build the sdist and wheel
|
||||
run: uv build
|
||||
|
||||
- name: The wheel carries the web UI and no generated runtime config
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import glob
|
||||
import zipfile
|
||||
|
||||
wheel = zipfile.ZipFile(glob.glob("dist/*.whl")[0])
|
||||
names = set(wheel.namelist())
|
||||
assert "docsgpt/static/index.html" in names, "the UI build is missing from the wheel"
|
||||
assert any(n.startswith("docsgpt/static/assets/") for n in names), "no bundle under docsgpt/static/assets/"
|
||||
assert "docsgpt/static/config.js" not in names, "config.js is generated by the API at runtime"
|
||||
assert b'src="/config.js"' in wheel.read("docsgpt/static/index.html"), "index.html must load /config.js"
|
||||
print("ui ok:", sum(n.startswith("docsgpt/static/") for n in names), "files")
|
||||
PY
|
||||
|
||||
- name: The archives carry the released version
|
||||
if: env.RELEASE_VERSION != ''
|
||||
run: |
|
||||
|
||||
@@ -181,6 +181,8 @@ frontend/*.sw?
|
||||
|
||||
docsgpt/vectors/
|
||||
application/vectors/
|
||||
# The web UI build the package ships (scripts/build_frontend.sh)
|
||||
docsgpt/static/
|
||||
|
||||
**/inputs
|
||||
|
||||
|
||||
@@ -43,7 +43,10 @@ The backend is also an installable package (`pyproject.toml`, hatchling).
|
||||
`docsgpt prefetch-models`, `docsgpt verify-offline`. Runtime data (`.env`,
|
||||
`inputs/`, `indexes/`) lives in the checkout by default; `DOCSGPT_HOME` moves
|
||||
that data home, and `DOCSGPT_ENV_FILE` selects only the `.env` file (see
|
||||
`docsgpt/core/paths.py`).
|
||||
`docsgpt/core/paths.py`). `bash scripts/build_frontend.sh` builds the web UI
|
||||
into `docsgpt/static` (gitignored); the API serves it when present
|
||||
(`docsgpt/ui.py`, switch `SERVE_UI`), and the package workflows run the script
|
||||
before `uv build` so the wheel ships it.
|
||||
|
||||
Dependencies are declared in `pyproject.toml` and locked in `uv.lock`; the
|
||||
`docsgpt/requirements*.txt` files are exported from the lock. To add or
|
||||
|
||||
@@ -69,6 +69,8 @@ Here are some of the most fundamental settings you'll likely want to configure:
|
||||
|
||||
- **`STT_PROVIDER`**: Selects the speech-to-text provider used for microphone transcription in chat and for audio file ingestion through the parser pipeline.
|
||||
|
||||
- **`SERVE_UI`**: Whether the API process serves the web UI shipped in the Python package (`pip install docsgpt`). Defaults to `true`; set `false` when a separate frontend, such as the Docker frontend image, serves the UI. Has no effect in the backend Docker image, which carries no UI build.
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
Let's look at some concrete examples of how to configure these settings in your `.env` file.
|
||||
|
||||
@@ -7,10 +7,10 @@ import { Callout } from 'nextra/components'
|
||||
|
||||
# Install with pip
|
||||
|
||||
The DocsGPT backend is on PyPI as [`docsgpt`](https://pypi.org/project/docsgpt/): the API server, the Celery worker and the maintenance scripts in one package, behind a single `docsgpt` command. Use it when you want DocsGPT inside your own Python environment or process manager rather than the [Docker images](/Deploying/Docker-Deploying).
|
||||
DocsGPT is on PyPI as [`docsgpt`](https://pypi.org/project/docsgpt/): the API server, the web UI, the Celery worker and the maintenance scripts in one package, behind a single `docsgpt` command. Use it when you want DocsGPT inside your own Python environment or process manager rather than the [Docker images](/Deploying/Docker-Deploying).
|
||||
|
||||
<Callout type="info">
|
||||
The package is the backend only. The web UI ships in the Docker images and in the repository under `frontend/`; serving it from the package is planned.
|
||||
`docsgpt api` serves the web UI on the same port as the API. Set `SERVE_UI=false` to run the API alone, for example behind the frontend Docker image or a UI you host yourself.
|
||||
</Callout>
|
||||
|
||||
## Requirements
|
||||
@@ -49,7 +49,7 @@ pipx runpip docsgpt install --force-reinstall --no-deps --index-url https://down
|
||||
|
||||
## Configure
|
||||
|
||||
DocsGPT keeps its runtime files in a **data home**: the `.env` file it reads settings from, uploaded files under `inputs/` and vector indexes under `indexes/`. The data home is the directory you run the commands from, or the directory `DOCSGPT_HOME` points to. `DOCSGPT_ENV_FILE` points at a `.env` kept somewhere else.
|
||||
DocsGPT keeps its runtime files in a **data home**: the `.env` file it reads settings from, uploaded files under `inputs/` and vector indexes under `indexes/`. The data home is the directory you run the commands from, or the directory `DOCSGPT_HOME` points to. `DOCSGPT_ENV_FILE` points at a `.env` kept somewhere else. Both variables must be set in the process environment, not in `.env`: they decide where `.env` is read from.
|
||||
|
||||
Create a `.env` in the data home. The minimum for a hosted LLM:
|
||||
|
||||
@@ -72,7 +72,7 @@ Redis defaults to `localhost:6379` (`CELERY_BROKER_URL`, `CELERY_RESULT_BACKEND`
|
||||
|
||||
```bash
|
||||
docsgpt migrate # create the database if it is missing and apply the migrations
|
||||
docsgpt api # the API on http://127.0.0.1:7091
|
||||
docsgpt api # the API and the web UI on http://127.0.0.1:7091
|
||||
docsgpt worker # in a second terminal: the Celery worker, with the scheduler
|
||||
```
|
||||
|
||||
|
||||
+9
-1
@@ -12,11 +12,19 @@ from docsgpt.api.async_sse import async_sse_routes
|
||||
from docsgpt.app import app as flask_app
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.mcp_server import mcp
|
||||
from docsgpt.ui import StaticUI
|
||||
|
||||
_WSGI_THREADPOOL = int(settings.WSGI_THREADPOOL_WORKERS)
|
||||
|
||||
mcp_app = mcp.http_app(path="/")
|
||||
|
||||
# The web UI, when the package ships one (docsgpt/static) and SERVE_UI is on:
|
||||
# files are served directly, Flask's own path prefixes pass through, and any
|
||||
# other GET renders index.html for the client-side router.
|
||||
_backend = StaticUI.wrap(
|
||||
WSGIMiddleware(flask_app, workers=_WSGI_THREADPOOL), flask_app.url_map, enabled=settings.SERVE_UI
|
||||
)
|
||||
|
||||
asgi_app = Starlette(
|
||||
routes=[
|
||||
Mount("/mcp", app=mcp_app),
|
||||
@@ -26,7 +34,7 @@ asgi_app = Starlette(
|
||||
# Starlette matches routes top-to-bottom, so these must precede the
|
||||
# Mount("/") that hands everything else to Flask.
|
||||
*async_sse_routes,
|
||||
Mount("/", app=WSGIMiddleware(flask_app, workers=_WSGI_THREADPOOL)),
|
||||
Mount("/", app=_backend),
|
||||
],
|
||||
middleware=[
|
||||
Middleware(
|
||||
|
||||
+44
-19
@@ -25,6 +25,49 @@ def _announce_home() -> None:
|
||||
print(f"docsgpt: data home {home_dir()} (env file {env_file()})", file=sys.stderr)
|
||||
|
||||
|
||||
def _gunicorn_options(host: str, port: int, workers: int) -> dict:
|
||||
"""The image's gunicorn flags (docsgpt/Dockerfile CMD), as settings."""
|
||||
options = {
|
||||
"bind": f"{host}:{port}",
|
||||
"workers": workers,
|
||||
"worker_class": "docsgpt.gunicorn_worker.BoundedDrainUvicornWorker",
|
||||
"timeout": 180,
|
||||
"graceful_timeout": 120,
|
||||
"keepalive": 5,
|
||||
"max_requests": 5000,
|
||||
"max_requests_jitter": 500,
|
||||
}
|
||||
if os.path.isdir("/dev/shm"):
|
||||
options["worker_tmp_dir"] = "/dev/shm"
|
||||
return options
|
||||
|
||||
|
||||
def _gunicorn_application(options: dict):
|
||||
"""A gunicorn application configured in code rather than from sys.argv.
|
||||
|
||||
gunicorn records sys.argv at start and re-executes it on SIGUSR2 (the
|
||||
zero-downtime upgrade), so sys.argv has to stay the ``docsgpt api ...``
|
||||
invocation: the console script is what a re-exec must run again.
|
||||
"""
|
||||
from gunicorn.app.base import Application
|
||||
|
||||
class DocsGPTApplication(Application):
|
||||
def init(self, parser, opts, args):
|
||||
return None
|
||||
|
||||
def load_config(self):
|
||||
self.load_config_from_module_name_or_filename("python:docsgpt.gunicorn_conf")
|
||||
for key, value in options.items():
|
||||
self.cfg.set(key, value)
|
||||
|
||||
def load(self):
|
||||
from docsgpt.asgi import asgi_app
|
||||
|
||||
return asgi_app
|
||||
|
||||
return DocsGPTApplication()
|
||||
|
||||
|
||||
def _api(args: argparse.Namespace) -> int:
|
||||
"""Serve the ASGI app: gunicorn with the bounded-drain uvicorn worker, or uvicorn when reloading."""
|
||||
_announce_home()
|
||||
@@ -34,25 +77,7 @@ def _api(args: argparse.Namespace) -> int:
|
||||
uvicorn.run("docsgpt.asgi:asgi_app", host=args.host, port=args.port, reload=args.reload)
|
||||
return 0
|
||||
|
||||
from gunicorn.app.wsgiapp import run as gunicorn_run
|
||||
|
||||
argv = [
|
||||
"gunicorn",
|
||||
"-w", str(args.workers),
|
||||
"-k", "docsgpt.gunicorn_worker.BoundedDrainUvicornWorker",
|
||||
"--bind", f"{args.host}:{args.port}",
|
||||
"--timeout", "180",
|
||||
"--graceful-timeout", "120",
|
||||
"--keep-alive", "5",
|
||||
"--max-requests", "5000",
|
||||
"--max-requests-jitter", "500",
|
||||
"--config", "python:docsgpt.gunicorn_conf",
|
||||
]
|
||||
if os.path.isdir("/dev/shm"):
|
||||
argv += ["--worker-tmp-dir", "/dev/shm"]
|
||||
argv.append("docsgpt.asgi:asgi_app")
|
||||
sys.argv = argv
|
||||
gunicorn_run()
|
||||
_gunicorn_application(_gunicorn_options(args.host, args.port, args.workers)).run()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -107,6 +107,8 @@ class Settings(BaseSettings):
|
||||
"request_limit": 500,
|
||||
}
|
||||
UPLOAD_FOLDER: str = "inputs"
|
||||
# Serve the web UI shipped in the package (docsgpt/static) from the API process.
|
||||
SERVE_UI: bool = True
|
||||
# Request cap is applied by Flask before multipart parsing; the per-file cap also while copying.
|
||||
UPLOAD_MAX_REQUEST_BYTES: int = Field(default=256 * 1024 * 1024, gt=0)
|
||||
UPLOAD_MAX_FILE_BYTES: int = Field(default=100 * 1024 * 1024, gt=0)
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
"""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)
|
||||
+7
-3
@@ -170,11 +170,14 @@ dev = [
|
||||
path = "docsgpt/version.py"
|
||||
|
||||
# The wheel is the docsgpt package with the data it reads at runtime: prompts,
|
||||
# model catalogs, the seed config, alembic.ini and the migrations. Build inputs
|
||||
# (Dockerfile, exported requirements), the sample index and local runtime data
|
||||
# stay out. The one-release `application` import alias is checkout-only.
|
||||
# model catalogs, the seed config, alembic.ini and the migrations, plus the web
|
||||
# UI build under docsgpt/static (gitignored; scripts/build_frontend.sh makes
|
||||
# it, and `artifacts` admits it). Build inputs (Dockerfile, exported
|
||||
# requirements), the sample index and local runtime data stay out. The
|
||||
# one-release `application` import alias is checkout-only.
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["docsgpt"]
|
||||
artifacts = ["docsgpt/static/**"]
|
||||
exclude = [
|
||||
"docsgpt/Dockerfile",
|
||||
"docsgpt/requirements*.txt",
|
||||
@@ -187,6 +190,7 @@ exclude = [
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = ["/docsgpt"]
|
||||
artifacts = ["docsgpt/static/**"]
|
||||
exclude = [
|
||||
"docsgpt/Dockerfile",
|
||||
"docsgpt/requirements*.txt",
|
||||
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the web UI into docsgpt/static so the Python package ships it.
|
||||
#
|
||||
# The API serves that directory when it exists (docsgpt/ui.py). The package
|
||||
# workflows run this before `uv build`; run it locally to test the served UI
|
||||
# or to build a wheel with the UI in it.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../frontend"
|
||||
|
||||
if [ ! -d node_modules ] || [ "${CI:-}" = "true" ]; then
|
||||
npm ci --include=dev
|
||||
fi
|
||||
|
||||
if [ -f .env.local ]; then
|
||||
echo "note: frontend/.env.local exists; its VITE_* values are baked into this build" >&2
|
||||
fi
|
||||
|
||||
# As in frontend/Dockerfile: the committed .env.development is the baseline
|
||||
# of the production build. The API rewrites VITE_API_HOST and VITE_BASE_URL
|
||||
# at runtime through /config.js, so those baked values never reach a browser.
|
||||
cp .env.development .env.production.local
|
||||
trap 'rm -f .env.production.local' EXIT
|
||||
npm run build
|
||||
|
||||
# Load the runtime config ahead of the bundle, as the nginx image does.
|
||||
sed -i.bak 's|<head>|<head><script src="/config.js"></script>|' dist/index.html
|
||||
rm -f dist/index.html.bak
|
||||
grep -q 'src="/config.js"' dist/index.html
|
||||
|
||||
rm -rf ../docsgpt/static
|
||||
cp -R dist ../docsgpt/static
|
||||
echo "built docsgpt/static ($(du -sh ../docsgpt/static | cut -f1))"
|
||||
+22
-11
@@ -35,21 +35,32 @@ class TestTopLevel:
|
||||
|
||||
|
||||
class TestApi:
|
||||
def test_gunicorn_argv(self, monkeypatch, capsys):
|
||||
run = MagicMock()
|
||||
monkeypatch.setitem(sys.modules, "gunicorn.app.wsgiapp", types.SimpleNamespace(run=run))
|
||||
def test_gunicorn_runs_with_the_image_settings_and_leaves_argv_alone(self, monkeypatch, capsys):
|
||||
application = MagicMock()
|
||||
factory = MagicMock(return_value=application)
|
||||
monkeypatch.setattr(cli, "_gunicorn_application", factory)
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
monkeypatch.setattr(sys, "argv", ["docsgpt"])
|
||||
monkeypatch.setattr(sys, "argv", ["/venv/bin/docsgpt", "api", "--port", "8000"])
|
||||
assert cli.main(["api", "--port", "8000", "--workers", "2"]) == 0
|
||||
run.assert_called_once_with()
|
||||
argv = sys.argv
|
||||
assert argv[0] == "gunicorn" and argv[-1] == "docsgpt.asgi:asgi_app"
|
||||
assert argv[argv.index("--bind") + 1] == "127.0.0.1:8000"
|
||||
assert argv[argv.index("-w") + 1] == "2"
|
||||
assert argv[argv.index("-k") + 1] == "docsgpt.gunicorn_worker.BoundedDrainUvicornWorker"
|
||||
assert argv[argv.index("--config") + 1] == "python:docsgpt.gunicorn_conf"
|
||||
application.run.assert_called_once_with()
|
||||
options = factory.call_args.args[0]
|
||||
assert options["bind"] == "127.0.0.1:8000"
|
||||
assert options["workers"] == 2
|
||||
assert options["worker_class"] == "docsgpt.gunicorn_worker.BoundedDrainUvicornWorker"
|
||||
assert options["max_requests"] == 5000
|
||||
# gunicorn re-executes sys.argv on SIGUSR2; it must still be the docsgpt invocation.
|
||||
assert sys.argv == ["/venv/bin/docsgpt", "api", "--port", "8000"]
|
||||
assert "data home" in capsys.readouterr().err
|
||||
|
||||
def test_the_gunicorn_application_carries_the_settings_and_the_log_config(self):
|
||||
app = cli._gunicorn_application(cli._gunicorn_options("127.0.0.1", 8001, 3))
|
||||
assert app.cfg.bind == ["127.0.0.1:8001"]
|
||||
assert app.cfg.workers == 3
|
||||
assert app.cfg.worker_class_str == "docsgpt.gunicorn_worker.BoundedDrainUvicornWorker"
|
||||
assert app.cfg.keepalive == 5
|
||||
assert app.cfg.graceful_timeout == 120
|
||||
assert "ncsa_access" in app.cfg.logconfig_dict["formatters"], "docsgpt.gunicorn_conf was loaded"
|
||||
|
||||
def test_reload_uses_uvicorn(self, monkeypatch):
|
||||
uvicorn = types.SimpleNamespace(run=MagicMock())
|
||||
monkeypatch.setitem(sys.modules, "uvicorn", uvicorn)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""The web UI shipped in the package is served in front of the API (docsgpt.ui)."""
|
||||
|
||||
import pytest
|
||||
from a2wsgi import WSGIMiddleware
|
||||
from flask import Flask
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from docsgpt.ui import StaticUI, config_js
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def static_dir(tmp_path):
|
||||
(tmp_path / "index.html").write_text('<html><head><script src="/config.js"></script></head><body>ui</body></html>')
|
||||
(tmp_path / "assets").mkdir()
|
||||
(tmp_path / "assets" / "index-abc123.js").write_text("console.log('bundle')")
|
||||
(tmp_path / "fonts").mkdir()
|
||||
(tmp_path / "fonts" / "inter.woff2").write_bytes(b"\x00\x01")
|
||||
(tmp_path / "favicon.ico").write_bytes(b"\x00")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _backend() -> Flask:
|
||||
app = Flask("backend")
|
||||
|
||||
@app.route("/")
|
||||
def home():
|
||||
return "backend home"
|
||||
|
||||
@app.route("/api/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.route("/api/echo", methods=["POST"])
|
||||
def echo():
|
||||
return "posted"
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(static_dir):
|
||||
flask_app = _backend()
|
||||
ui = StaticUI.wrap(WSGIMiddleware(flask_app), flask_app.url_map, static_dir=static_dir)
|
||||
assert isinstance(ui, StaticUI)
|
||||
return TestClient(Starlette(routes=[Mount("/", app=ui)]))
|
||||
|
||||
|
||||
class TestRouting:
|
||||
def test_root_is_the_ui_not_the_backend_home(self, client):
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "ui" in response.text and "backend home" not in response.text
|
||||
assert response.headers["cache-control"] == "no-cache"
|
||||
|
||||
def test_client_side_routes_render_index(self, client):
|
||||
assert client.get("/agents/123/edit").text == client.get("/").text
|
||||
|
||||
def test_hashed_assets_are_immutable(self, client):
|
||||
response = client.get("/assets/index-abc123.js")
|
||||
assert response.status_code == 200
|
||||
assert "bundle" in response.text
|
||||
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
assert "javascript" in response.headers["content-type"]
|
||||
|
||||
def test_font_has_a_media_type(self, client):
|
||||
assert client.get("/fonts/inter.woff2").headers["content-type"] == "font/woff2"
|
||||
|
||||
def test_backend_prefixes_pass_through(self, client):
|
||||
assert client.get("/api/health").json() == {"status": "ok"}
|
||||
missing = client.get("/api/does-not-exist")
|
||||
assert missing.status_code == 404
|
||||
assert "ui" not in missing.text, "an unknown API path is the backend's 404, not the UI"
|
||||
|
||||
def test_writes_never_hit_the_ui(self, client):
|
||||
assert client.post("/api/echo").text == "posted"
|
||||
assert client.post("/settings").status_code in (404, 405)
|
||||
|
||||
def test_head_works(self, client):
|
||||
response = client.head("/")
|
||||
assert response.status_code == 200
|
||||
assert response.content == b""
|
||||
|
||||
|
||||
class TestConfigJs:
|
||||
def test_defaults_to_the_page_origin(self, client):
|
||||
response = client.get("/config.js")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert '"VITE_API_HOST":window.location.origin' in response.text
|
||||
assert '"VITE_BASE_URL":window.location.origin' in response.text
|
||||
|
||||
def test_environment_values_win_and_are_escaped(self):
|
||||
body = config_js({"VITE_API_HOST": "https://api.example.com", "VITE_NOTE": 'say "hi"\\', "OTHER": "x", "VITE_EMPTY": ""})
|
||||
assert '"VITE_API_HOST":"https://api.example.com"' in body
|
||||
assert '"VITE_BASE_URL":window.location.origin' in body
|
||||
assert '"VITE_NOTE":"say \\"hi\\"\\\\"' in body
|
||||
assert "OTHER" not in body and "VITE_EMPTY" not in body
|
||||
assert body.startswith("window.__DOCSGPT_ENV__={") and body.endswith("};\n")
|
||||
|
||||
|
||||
class TestSafety:
|
||||
def test_paths_outside_the_static_dir_are_never_served(self, static_dir):
|
||||
flask_app = _backend()
|
||||
ui = StaticUI.wrap(WSGIMiddleware(flask_app), flask_app.url_map, static_dir=static_dir)
|
||||
(static_dir.parent / "secret.txt").write_text("nope")
|
||||
assert ui._file("/../secret.txt") is None
|
||||
assert ui._file("/assets/../../secret.txt") is None
|
||||
assert ui._file("/") is None
|
||||
assert ui._file("/assets") is None, "directories are not files"
|
||||
|
||||
|
||||
class TestWrap:
|
||||
def test_no_build_means_the_backend_alone(self, tmp_path):
|
||||
flask_app = _backend()
|
||||
backend = WSGIMiddleware(flask_app)
|
||||
assert StaticUI.wrap(backend, flask_app.url_map, static_dir=tmp_path) is backend
|
||||
|
||||
def test_switched_off_means_the_backend_alone(self, static_dir):
|
||||
flask_app = _backend()
|
||||
backend = WSGIMiddleware(flask_app)
|
||||
assert StaticUI.wrap(backend, flask_app.url_map, static_dir=static_dir, enabled=False) is backend
|
||||
Reference in new issue
Block a user