diff --git a/.dockerignore b/.dockerignore
index 0521228c..fd316071 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -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/
diff --git a/.github/workflows/package-build.yml b/.github/workflows/package-build.yml
index f6cd3296..0be880db 100644
--- a/.github/workflows/package-build.yml
+++ b/.github/workflows/package-build.yml
@@ -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:
diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml
index 12adcf9b..9037f758 100644
--- a/.github/workflows/pypi-publish.yml
+++ b/.github/workflows/pypi-publish.yml
@@ -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: |
diff --git a/.gitignore b/.gitignore
index 8693a33e..f9e72c46 100644
--- a/.gitignore
+++ b/.gitignore
@@ -181,6 +181,8 @@ frontend/*.sw?
docsgpt/vectors/
application/vectors/
+# The web UI build the package ships (scripts/build_frontend.sh)
+docsgpt/static/
**/inputs
diff --git a/AGENTS.md b/AGENTS.md
index d096cf22..b8aeb247 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/docs/content/Deploying/DocsGPT-Settings.mdx b/docs/content/Deploying/DocsGPT-Settings.mdx
index 6ac584a7..61d30483 100644
--- a/docs/content/Deploying/DocsGPT-Settings.mdx
+++ b/docs/content/Deploying/DocsGPT-Settings.mdx
@@ -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.
diff --git a/docs/content/Deploying/Pip-Install.mdx b/docs/content/Deploying/Pip-Install.mdx
index 8da950d3..dbfa604b 100644
--- a/docs/content/Deploying/Pip-Install.mdx
+++ b/docs/content/Deploying/Pip-Install.mdx
@@ -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).
- 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.
## 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
```
diff --git a/docsgpt/asgi.py b/docsgpt/asgi.py
index dd60d4fe..e694fefc 100644
--- a/docsgpt/asgi.py
+++ b/docsgpt/asgi.py
@@ -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(
diff --git a/docsgpt/cli.py b/docsgpt/cli.py
index 04fe1960..e9d4d670 100644
--- a/docsgpt/cli.py
+++ b/docsgpt/cli.py
@@ -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
diff --git a/docsgpt/core/settings.py b/docsgpt/core/settings.py
index f926485b..14c834b2 100644
--- a/docsgpt/core/settings.py
+++ b/docsgpt/core/settings.py
@@ -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)
diff --git a/docsgpt/ui.py b/docsgpt/ui.py
new file mode 100644
index 00000000..04a951e4
--- /dev/null
+++ b/docsgpt/ui.py
@@ -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)
diff --git a/pyproject.toml b/pyproject.toml
index 99fcf8a0..e1788ccc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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",
diff --git a/scripts/build_frontend.sh b/scripts/build_frontend.sh
new file mode 100755
index 00000000..294d2b11
--- /dev/null
+++ b/scripts/build_frontend.sh
@@ -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|
||' 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))"
diff --git a/tests/test_cli.py b/tests/test_cli.py
index af60c7df..c8f934ce 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -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)
diff --git a/tests/test_ui.py b/tests/test_ui.py
new file mode 100644
index 00000000..4a1045dd
--- /dev/null
+++ b/tests/test_ui.py
@@ -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('ui')
+ (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