mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 20:14:16 +00:00
Merge pull request #2751 from arc53/feat/ui-in-package
feat(package): ship the web UI in the wheel and serve it from the API
This commit is contained in:
15 files changed
+433
-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
+47
@@ -0,0 +1,47 @@
|
||||
#!/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.
|
||||
# A developer's own .env.production.local is set aside for the build and put
|
||||
# back afterwards, whatever happens in between.
|
||||
saved_override=""
|
||||
if [ -e .env.production.local ] || [ -L .env.production.local ]; then
|
||||
saved_override="$(mktemp "${TMPDIR:-/tmp}/docsgpt-env-production-local.XXXXXX")"
|
||||
cp -p .env.production.local "$saved_override"
|
||||
fi
|
||||
restore_override() {
|
||||
if [ -n "$saved_override" ]; then
|
||||
mv -f "$saved_override" .env.production.local
|
||||
else
|
||||
rm -f .env.production.local
|
||||
fi
|
||||
}
|
||||
trap restore_override EXIT
|
||||
cp .env.development .env.production.local
|
||||
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