diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67ebe0dc..01752509 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,24 +9,17 @@ jobs: if: github.repository == 'arc53/DocsGPT' strategy: matrix: - include: - - platform: linux/amd64 - runner: ubuntu-latest - suffix: amd64 - - platform: linux/arm64 - runner: ubuntu-24.04-arm - suffix: arm64 - runs-on: ${{ matrix.runner }} + platform: [linux/amd64, linux/arm64] + # "" is the slim default image; "-docling" bakes the docling parser + # engine, its models and tesseract in (OCR-ready). + variant: ["", "-docling"] + runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - - name: Set up QEMU # Only needed for emulation, not for native arm64 builds - if: matrix.platform == 'linux/arm64' - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 with: @@ -46,6 +39,17 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Image metadata (OCI labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ secrets.DOCKER_USERNAME }}/docsgpt + ghcr.io/${{ github.repository_owner }}/docsgpt + labels: | + org.opencontainers.image.title=DocsGPT${{ matrix.variant }} + org.opencontainers.image.version=${{ github.event.release.tag_name }} + - name: Build and push platform-specific images uses: docker/build-push-action@v6 with: @@ -53,17 +57,24 @@ jobs: platforms: ${{ matrix.platform }} context: ./application push: true + build-args: | + EXTRAS=${{ matrix.variant == '-docling' && 'docling' || '' }} + INSTALL_TESSERACT=${{ matrix.variant == '-docling' && 'true' || 'false' }} tags: | - ${{ secrets.DOCKER_USERNAME }}/docsgpt:${{ github.event.release.tag_name }}-${{ matrix.suffix }} - ghcr.io/${{ github.repository_owner }}/docsgpt:${{ github.event.release.tag_name }}-${{ matrix.suffix }} + ${{ secrets.DOCKER_USERNAME }}/docsgpt:${{ github.event.release.tag_name }}${{ matrix.variant }}-${{ matrix.platform == 'linux/arm64' && 'arm64' || 'amd64' }} + ghcr.io/${{ github.repository_owner }}/docsgpt:${{ github.event.release.tag_name }}${{ matrix.variant }}-${{ matrix.platform == 'linux/arm64' && 'arm64' || 'amd64' }} + labels: ${{ steps.meta.outputs.labels }} provenance: false sbom: false - cache-from: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/docsgpt:latest + cache-from: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/docsgpt:latest${{ matrix.variant }} cache-to: type=inline manifest: if: github.repository == 'arc53/DocsGPT' needs: build + strategy: + matrix: + variant: ["", "-docling"] runs-on: ubuntu-latest permissions: packages: write @@ -87,26 +98,33 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Create and push manifest for DockerHub + - name: Create and push multi-arch manifests + env: + TAG: ${{ github.event.release.tag_name }}${{ matrix.variant }} + LATEST: latest${{ matrix.variant }} run: | set -e - docker manifest create ${{ secrets.DOCKER_USERNAME }}/docsgpt:${{ github.event.release.tag_name }} \ - --amend ${{ secrets.DOCKER_USERNAME }}/docsgpt:${{ github.event.release.tag_name }}-amd64 \ - --amend ${{ secrets.DOCKER_USERNAME }}/docsgpt:${{ github.event.release.tag_name }}-arm64 - docker manifest push ${{ secrets.DOCKER_USERNAME }}/docsgpt:${{ github.event.release.tag_name }} - docker manifest create ${{ secrets.DOCKER_USERNAME }}/docsgpt:latest \ - --amend ${{ secrets.DOCKER_USERNAME }}/docsgpt:${{ github.event.release.tag_name }}-amd64 \ - --amend ${{ secrets.DOCKER_USERNAME }}/docsgpt:${{ github.event.release.tag_name }}-arm64 - docker manifest push ${{ secrets.DOCKER_USERNAME }}/docsgpt:latest + for repo in "${{ secrets.DOCKER_USERNAME }}/docsgpt" "ghcr.io/${{ github.repository_owner }}/docsgpt"; do + for name in "$TAG" "$LATEST"; do + docker manifest create "$repo:$name" \ + --amend "$repo:$TAG-amd64" \ + --amend "$repo:$TAG-arm64" + docker manifest push "$repo:$name" + done + done - - name: Create and push manifest for ghcr.io + release-assets: + if: github.repository == 'arc53/DocsGPT' + needs: manifest + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Attach the standalone compose file to the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - set -e - docker manifest create ghcr.io/${{ github.repository_owner }}/docsgpt:${{ github.event.release.tag_name }} \ - --amend ghcr.io/${{ github.repository_owner }}/docsgpt:${{ github.event.release.tag_name }}-amd64 \ - --amend ghcr.io/${{ github.repository_owner }}/docsgpt:${{ github.event.release.tag_name }}-arm64 - docker manifest push ghcr.io/${{ github.repository_owner }}/docsgpt:${{ github.event.release.tag_name }} - docker manifest create ghcr.io/${{ github.repository_owner }}/docsgpt:latest \ - --amend ghcr.io/${{ github.repository_owner }}/docsgpt:${{ github.event.release.tag_name }}-amd64 \ - --amend ghcr.io/${{ github.repository_owner }}/docsgpt:${{ github.event.release.tag_name }}-arm64 - docker manifest push ghcr.io/${{ github.repository_owner }}/docsgpt:latest \ No newline at end of file + gh release upload "${{ github.event.release.tag_name }}" \ + deployment/docker-compose-standalone.yaml --clobber diff --git a/.github/workflows/docker-develop-build.yml b/.github/workflows/docker-develop-build.yml index 44a61769..0f97a331 100644 --- a/.github/workflows/docker-develop-build.yml +++ b/.github/workflows/docker-develop-build.yml @@ -11,14 +11,11 @@ jobs: if: github.repository == 'arc53/DocsGPT' strategy: matrix: - include: - - platform: linux/amd64 - runner: ubuntu-latest - suffix: amd64 - - platform: linux/arm64 - runner: ubuntu-24.04-arm - suffix: arm64 - runs-on: ${{ matrix.runner }} + platform: [linux/amd64, linux/arm64] + # "" is the slim default image; "-docling" bakes the docling parser + # engine, its models and tesseract in (OCR-ready). + variant: ["", "-docling"] + runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} permissions: contents: read packages: write @@ -36,7 +33,7 @@ jobs: with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - + - name: Login to ghcr.io uses: docker/login-action@v3 with: @@ -44,6 +41,17 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Image metadata (OCI labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ secrets.DOCKER_USERNAME }}/docsgpt + ghcr.io/${{ github.repository_owner }}/docsgpt + labels: | + org.opencontainers.image.title=DocsGPT${{ matrix.variant }} + org.opencontainers.image.version=develop + - name: Build and push platform-specific images uses: docker/build-push-action@v6 with: @@ -51,17 +59,24 @@ jobs: platforms: ${{ matrix.platform }} context: ./application push: true + build-args: | + EXTRAS=${{ matrix.variant == '-docling' && 'docling' || '' }} + INSTALL_TESSERACT=${{ matrix.variant == '-docling' && 'true' || 'false' }} tags: | - ${{ secrets.DOCKER_USERNAME }}/docsgpt:develop-${{ matrix.suffix }} - ghcr.io/${{ github.repository_owner }}/docsgpt:develop-${{ matrix.suffix }} + ${{ secrets.DOCKER_USERNAME }}/docsgpt:develop${{ matrix.variant }}-${{ matrix.platform == 'linux/arm64' && 'arm64' || 'amd64' }} + ghcr.io/${{ github.repository_owner }}/docsgpt:develop${{ matrix.variant }}-${{ matrix.platform == 'linux/arm64' && 'arm64' || 'amd64' }} + labels: ${{ steps.meta.outputs.labels }} provenance: false sbom: false - cache-from: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/docsgpt:develop + cache-from: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/docsgpt:develop${{ matrix.variant }} cache-to: type=inline manifest: if: github.repository == 'arc53/DocsGPT' needs: build + strategy: + matrix: + variant: ["", "-docling"] runs-on: ubuntu-latest permissions: packages: write @@ -77,24 +92,22 @@ jobs: with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - + - name: Login to ghcr.io uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - - name: Create and push manifest for DockerHub - run: | - docker manifest create ${{ secrets.DOCKER_USERNAME }}/docsgpt:develop \ - --amend ${{ secrets.DOCKER_USERNAME }}/docsgpt:develop-amd64 \ - --amend ${{ secrets.DOCKER_USERNAME }}/docsgpt:develop-arm64 - docker manifest push ${{ secrets.DOCKER_USERNAME }}/docsgpt:develop - - name: Create and push manifest for ghcr.io + - name: Create and push multi-arch manifests + env: + TAG: develop${{ matrix.variant }} run: | - docker manifest create ghcr.io/${{ github.repository_owner }}/docsgpt:develop \ - --amend ghcr.io/${{ github.repository_owner }}/docsgpt:develop-amd64 \ - --amend ghcr.io/${{ github.repository_owner }}/docsgpt:develop-arm64 - docker manifest push ghcr.io/${{ github.repository_owner }}/docsgpt:develop \ No newline at end of file + set -e + for repo in "${{ secrets.DOCKER_USERNAME }}/docsgpt" "ghcr.io/${{ github.repository_owner }}/docsgpt"; do + docker manifest create "$repo:$TAG" \ + --amend "$repo:$TAG-amd64" \ + --amend "$repo:$TAG-arm64" + docker manifest push "$repo:$TAG" + done diff --git a/.github/workflows/docker-image-verify.yml b/.github/workflows/docker-image-verify.yml new file mode 100644 index 00000000..4ac525c2 --- /dev/null +++ b/.github/workflows/docker-image-verify.yml @@ -0,0 +1,52 @@ +name: Verify the Docker image works offline + +# Builds the backend image and runs its offline check with networking off, so +# a change that reintroduces a first-request download (a tokenizer, tiktoken's +# encoding, an embedding model) fails here instead of in an air-gapped install. + +on: + workflow_dispatch: + pull_request: + paths: + - 'application/Dockerfile' + - 'application/.dockerignore' + - 'application/requirements*.txt' + - 'application/scripts/prefetch_models.py' + - 'application/scripts/verify_offline.py' + - 'application/vectorstore/model_registry.py' + - 'application/parser/tokenization.py' + - 'application/vectorstore/embeddings_local.py' + - '.github/workflows/docker-image-verify.yml' + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build the slim image + uses: docker/build-push-action@v6 + with: + file: ./application/Dockerfile + context: ./application + platforms: linux/amd64 + load: true + tags: docsgpt:verify + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Image size + run: | + docker image inspect docsgpt:verify --format '{{.Size}}' | awk '{printf "uncompressed: %.2f GB\n", $1/1e9}' + docker history docsgpt:verify --format '{{.Size}}\t{{.CreatedBy}}' | head -20 + + - name: Offline verification (no network) + run: | + docker run --rm --network none docsgpt:verify \ + python -m application.scripts.verify_offline diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ab80ebcf..8f731dc5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -20,3 +20,18 @@ jobs: uses: chartboost/ruff-action@v1 with: version: 0.14.10 + + requirements-in-sync: + # application/requirements*.txt are exported from uv.lock; fail when a + # change to pyproject.toml or uv.lock was not re-exported. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v6 + + - name: Re-export and diff + run: | + uv lock --check + bash scripts/export_requirements.sh + git diff --exit-code -- application/requirements.txt application/requirements-docling.txt application/requirements-milvus.txt diff --git a/application/.dockerignore b/application/.dockerignore new file mode 100644 index 00000000..90ed9e4b --- /dev/null +++ b/application/.dockerignore @@ -0,0 +1,23 @@ +# Build context is application/. Keep local state and caches out of the image. +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ +*.log + +# Runtime data: bind-mounted or created at run time, never baked in. +indexes/ +inputs/ +vectors/ +*.faiss +*.pkl + +# Secrets and local config. +.env +.env.* + +# Not needed inside the image. +Dockerfile +.dockerignore diff --git a/application/Dockerfile b/application/Dockerfile index 43d3bebc..9caa2720 100644 --- a/application/Dockerfile +++ b/application/Dockerfile @@ -1,74 +1,81 @@ -# Builder Stage -FROM ubuntu:24.04 as builder +# DocsGPT backend image. +# +# Build args: +# EXTRAS comma-separated optional extras to bake in, matching the +# pyproject extras / requirements-.txt files: +# docling (layout-model parser + OCR backend), milvus. +# INSTALL_DOCLING legacy alias for EXTRAS=docling (setup.sh writes it). +# INSTALL_TESSERACT bake the tesseract binary for OCR_ENGINE=tesseract. +# EMBEDDINGS_PREFETCH registry names of the embedding models to bake; empty +# bakes both defaults (mpnet for upgrades, granite for +# new installs). +# +# Everything the default configuration needs is inside the image: embedding +# models, their tokenizers, tiktoken's encoding and, with the docling extra, +# docling's layout/table/OCR models. `python -m application.scripts.verify_offline` +# under `docker run --network none` proves it. + +FROM ubuntu:24.04 AS builder + +ENV DEBIAN_FRONTEND=noninteractive + +# Ubuntu 24.04 ships Python 3.12 in its main archive: no PPA needed. Every pin +# resolves to a wheel, so no compiler toolchain either. +RUN apt-get update && \ + apt-get install -y --no-install-recommends python3.12 python3.12-venv ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +COPY requirements.txt requirements-docling.txt requirements-milvus.txt ./ + +RUN python3.12 -m venv /venv +ENV PATH="/venv/bin:$PATH" + +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir --only-binary=:all: -r requirements.txt + +# Optional extras. Each requirements-.txt is exported from the same +# lock as requirements.txt, so installing it on top only adds the extra's +# packages. The docling file takes torch from the CPU-only PyTorch index. +# Not wheels-only: docling's antlr4 runtime ships as a pure-Python sdist. +ARG EXTRAS="" +ARG INSTALL_DOCLING=false +RUN set -e; \ + extras="$EXTRAS"; \ + if [ "$INSTALL_DOCLING" = "true" ]; then extras="$extras,docling"; fi; \ + for extra in $(echo "$extras" | tr ',' ' '); do \ + echo "Installing extra: $extra"; \ + pip install --no-cache-dir -r "requirements-$extra.txt"; \ + done + +# google-api-python-client bundles discovery documents for ~600 Google APIs +# (99 MB). The application builds one client, Drive v3; keep only its document. +# Building another API's client needs its file back, or static_discovery=False. +RUN find /venv/lib/python3.12/site-packages/googleapiclient/discovery_cache/documents \ + -type f ! -name 'drive.v3.json' -delete + + +FROM ubuntu:24.04 AS final ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && \ - apt-get install -y software-properties-common && \ - add-apt-repository ppa:deadsnakes/ppa && \ - apt-get update && \ - apt-get install -y --no-install-recommends gcc g++ wget unzip libc6-dev python3.12 python3.12-venv python3.12-dev && \ - rm -rf /var/lib/apt/lists/* - -# Verify Python installation and setup symlink -RUN if [ -f /usr/bin/python3.12 ]; then \ - ln -s /usr/bin/python3.12 /usr/bin/python; \ - else \ - echo "Python 3.12 not found"; exit 1; \ - fi - -# Install Rust -RUN wget -q -O - https://sh.rustup.rs | sh -s -- -y - -# Clean up to reduce container size -RUN apt-get remove --purge -y wget unzip && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* - -# Copy requirements manifests -COPY requirements.txt requirements-docling.txt ./ - -# Setup Python virtual environment -RUN python3.12 -m venv /venv - -# Activate virtual environment and install Python packages -ENV PATH="/venv/bin:$PATH" - -# Install Python packages -RUN pip install --no-cache-dir --upgrade pip && \ - pip install --no-cache-dir tiktoken && \ - pip install --no-cache-dir -r requirements.txt - -# Optional docling parser engine (DOC_PARSER_ENGINE=docling, the docling OCR -# backend, read_document's structured output) — OFF by default: it pulls the -# layout/OCR model stack and adds gigabytes to the image. anydoc (in -# requirements.txt) is the default parser and needs none of it, and OCR runs -# natively on tesseract (below, also opt-in) without it. -ARG INSTALL_DOCLING=false -RUN if [ "$INSTALL_DOCLING" = "true" ]; then \ - pip install --no-cache-dir -r requirements-docling.txt; \ - fi - -# Final Stage -FROM ubuntu:24.04 as final - -RUN apt-get update && \ - apt-get install -y software-properties-common && \ - add-apt-repository ppa:deadsnakes/ppa && \ - apt-get update && apt-get install -y --no-install-recommends \ - python3.12 \ - libgl1 \ - libglib2.0-0 \ - poppler-utils \ - && \ + apt-get install -y --no-install-recommends python3.12 poppler-utils ca-certificates && \ ln -s /usr/bin/python3.12 /usr/bin/python && \ rm -rf /var/lib/apt/lists/* +# opencv (rapidocr, part of the docling extra) needs libGL at import time. +ARG EXTRAS="" +ARG INSTALL_DOCLING=false +RUN if [ "$INSTALL_DOCLING" = "true" ] || echo ",$EXTRAS," | grep -q ",docling,"; then \ + apt-get update && \ + apt-get install -y --no-install-recommends libgl1 libglib2.0-0 && \ + rm -rf /var/lib/apt/lists/*; \ + fi + # Optional tesseract OCR engine (OCR_ENABLED=true with OCR_ENGINE=tesseract, -# the default engine) — OFF by default like every other OCR dependency; OCR -# itself is off unless configured. Opt in with --build-arg -# INSTALL_TESSERACT=true (setup.sh writes it to .env when OCR is enabled); -# ~35 MB of system packages. Extra language packs are a deployment concern -# (apt: tesseract-ocr-, then list them in OCR_LANGS). A DeepSeek-OCR -# endpoint (OCR_ENGINE=deepseek) needs none of this. +# the default engine); ~35 MB of system packages. Extra language packs are a +# deployment concern (apt: tesseract-ocr-, then list them in OCR_LANGS). +# A DeepSeek-OCR endpoint (OCR_ENGINE=deepseek) needs none of this. ARG INSTALL_TESSERACT=false RUN if [ "$INSTALL_TESSERACT" = "true" ]; then \ apt-get update && \ @@ -76,61 +83,70 @@ RUN if [ "$INSTALL_TESSERACT" = "true" ]; then \ rm -rf /var/lib/apt/lists/*; \ fi -# Set working directory +LABEL org.opencontainers.image.source="https://github.com/arc53/DocsGPT" \ + org.opencontainers.image.title="DocsGPT" \ + org.opencontainers.image.description="DocsGPT backend: API and Celery worker" \ + org.opencontainers.image.licenses="MIT" + WORKDIR /app -# Create a non-root user: `appuser` (Feel free to choose a name) +# The process user owns /app so the model prefetch below can run as it: an +# unprivileged prefetch writes the model files with the right owner up front, +# instead of a trailing chown -R that rewrites every model file into a second +# layer. RUN groupadd -r appuser && \ - useradd -r -g appuser -d /app -s /sbin/nologin -c "Docker image user" appuser + useradd -r -g appuser -d /app -s /sbin/nologin -c "Docker image user" appuser && \ + chown appuser:appuser /app && \ + install -d -o appuser -g appuser /app/models /app/application -# Copy the virtual environment and model from the builder stage COPY --from=builder /venv /venv -# Pre-fetch the embedding models into FastEmbed's cache so a fresh container -# does not download on first ingest and an air-gapped install works at all. -# Both defaults are baked: an upgraded deployment keeps using mpnet until it -# runs the re-embed script, while a new one starts on granite. -# The prefetch writes hub-layout snapshots (including tokenizer.json) here, so -# HF_HUB_CACHE has to point at the same directory: chunking loads the tokenizer -# through ``tokenizers``, which reads the hub cache and would otherwise fetch -# over the network on first ingest -- and fall back to cl100k when offline. +# Every cache the application reads at run time lives under /app/models and is +# filled at build time: +# EMBEDDINGS_CACHE_DIR / HF_HUB_CACHE FastEmbed models and their tokenizers +# (chunking reads tokenizer.json from +# the same hub-layout snapshot) +# TIKTOKEN_CACHE_DIR cl100k_base for token accounting +# DOCLING_ARTIFACTS_PATH docling's models (docling extra only) ENV EMBEDDINGS_CACHE_DIR=/app/models \ - HF_HUB_CACHE=/app/models + HF_HUB_CACHE=/app/models \ + TIKTOKEN_CACHE_DIR=/app/models/tiktoken \ + DOCLING_ARTIFACTS_PATH=/app/models/docling \ + HF_HUB_DISABLE_TELEMETRY=1 \ + PATH="/venv/bin:$PATH" + +# Only the modules the prefetch imports are copied first, so an unrelated +# source edit does not invalidate the model layer. +COPY --chown=appuser:appuser __init__.py /app/application/__init__.py +COPY --chown=appuser:appuser scripts/__init__.py scripts/prefetch_models.py /app/application/scripts/ +COPY --chown=appuser:appuser vectorstore/__init__.py vectorstore/model_registry.py /app/application/vectorstore/ + +USER appuser -# Only the modules the prefetch imports are copied first. It reaches nothing -# beyond model_registry, which is stdlib-only, so keeping the full source copy -# below this layer stops an unrelated edit from re-downloading ~780 MB of model -# artifacts on every build. -COPY __init__.py /app/application/__init__.py -COPY scripts/__init__.py scripts/prefetch_models.py /app/application/scripts/ -COPY vectorstore/__init__.py vectorstore/model_registry.py /app/application/vectorstore/ ARG EMBEDDINGS_PREFETCH="" -RUN PYTHONPATH=/app /venv/bin/python -m application.scripts.prefetch_models ${EMBEDDINGS_PREFETCH} +RUN PYTHONPATH=/app python -m application.scripts.prefetch_models ${EMBEDDINGS_PREFETCH} && \ + rm -rf /app/models/.locks /app/.cache -# Copy your application code -COPY . /app/application +# docling downloads its layout, table-structure and OCR models on first parse; +# bake them so the docling variant is as self-contained as the default image. +RUN if python -c "import docling" 2>/dev/null; then \ + docling-tools models download --output-dir /app/models/docling layout tableformer rapidocr && \ + rm -rf /app/.cache; \ + fi -# Change the ownership of the /app directory to the appuser +COPY --chown=appuser:appuser . /app/application RUN mkdir -p /app/application/inputs/local -RUN chown -R appuser:appuser /app -# Set environment variables -ENV FLASK_APP=app.py \ - FLASK_DEBUG=true \ - PATH="/venv/bin:$PATH" +ENV FLASK_APP=app.py ENV MALLOC_ARENA_MAX=2 \ OMP_NUM_THREADS=4 \ MKL_NUM_THREADS=4 \ OPENBLAS_NUM_THREADS=4 -# Expose the port the app runs on EXPOSE 7091 -# Switch to non-root user -USER appuser - # BoundedDrainUvicornWorker makes max_requests recycles safe with held-open SSE # connections (see application/gunicorn_worker.py); with recycles now safe, # --max-requests is raised (kept for memory hygiene) to cut churn. diff --git a/deployment/docker-compose-azure.yaml b/deployment/docker-compose-azure.yaml index 21f71b52..f276e6cc 100644 --- a/deployment/docker-compose-azure.yaml +++ b/deployment/docker-compose-azure.yaml @@ -1,6 +1,8 @@ services: frontend: - build: ../frontend + build: + context: ../frontend + target: dev environment: - VITE_API_HOST=http://localhost:7091 - VITE_API_STREAMING=$VITE_API_STREAMING @@ -13,6 +15,7 @@ services: build: context: ../application args: + EXTRAS: ${EXTRAS:-} INSTALL_DOCLING: ${INSTALL_DOCLING:-false} # Off by default; deployments running OCR_ENABLED=true with tesseract # must set INSTALL_TESSERACT=true before rebuilding (see docker-compose.yaml). @@ -40,6 +43,7 @@ services: build: context: ../application args: + EXTRAS: ${EXTRAS:-} INSTALL_DOCLING: ${INSTALL_DOCLING:-false} # Off by default; deployments running OCR_ENABLED=true with tesseract # must set INSTALL_TESSERACT=true before rebuilding (see docker-compose.yaml). diff --git a/deployment/docker-compose-hub.yaml b/deployment/docker-compose-hub.yaml index fe691aee..40f7533c 100644 --- a/deployment/docker-compose-hub.yaml +++ b/deployment/docker-compose-hub.yaml @@ -1,8 +1,14 @@ +# Pre-built images from Docker Hub (mirrored at ghcr.io/arc53). +# DOCSGPT_IMAGE_TAG develop (default, follows main) or a release, e.g. 0.20.0 +# DOCSGPT_IMAGE_VARIANT empty (default, slim) or -docling: docling parser engine, +# its models, and tesseract baked in (OCR-ready) +# Set them in ../.env or the shell. deployment/docker-compose-standalone.yaml is +# the same stack without a git checkout. name: docsgpt-oss services: frontend: - image: arc53/docsgpt-fe:develop + image: arc53/docsgpt-fe:${DOCSGPT_IMAGE_TAG:-develop} environment: - VITE_API_HOST=http://localhost:7091 - VITE_API_STREAMING=${VITE_API_STREAMING:-true} @@ -15,7 +21,7 @@ services: backend: user: root - image: arc53/docsgpt:develop + image: arc53/docsgpt:${DOCSGPT_IMAGE_TAG:-develop}${DOCSGPT_IMAGE_VARIANT:-} env_file: - ../.env environment: @@ -38,7 +44,7 @@ services: worker: user: root - image: arc53/docsgpt:develop + image: arc53/docsgpt:${DOCSGPT_IMAGE_TAG:-develop}${DOCSGPT_IMAGE_VARIANT:-} # `parsing` queue carries read_document/parse_document; required for its await to resolve. command: celery -A application.app.celery worker -l INFO -B -Q docsgpt,parsing,embeddings env_file: diff --git a/deployment/docker-compose-standalone.yaml b/deployment/docker-compose-standalone.yaml new file mode 100644 index 00000000..14bc3135 --- /dev/null +++ b/deployment/docker-compose-standalone.yaml @@ -0,0 +1,104 @@ +# DocsGPT from pre-built images, with no git checkout. +# +# curl -fsSLO https://raw.githubusercontent.com/arc53/DocsGPT/main/deployment/docker-compose-standalone.yaml +# printf 'LLM_PROVIDER=docsgpt\nVITE_API_STREAMING=true\n' > .env # or any provider, see the settings guide +# docker compose up -d +# open http://localhost:5173 +# +# Every release also attaches this file as an asset. Settings come from .env +# next to this file (any DocsGPT setting; the compose-internal service URLs +# below take precedence). Data lives in named volumes, so `docker compose +# down` keeps it and `docker compose down -v` removes it. +# +# DOCSGPT_IMAGE_TAG release to run, e.g. 0.20.0 (default: latest release); +# develop follows the main branch +# DOCSGPT_IMAGE_VARIANT empty (slim, default) or -docling: docling parser +# engine, its models, and tesseract baked in (OCR-ready) +name: docsgpt + +services: + frontend: + image: arc53/docsgpt-fe:${DOCSGPT_IMAGE_TAG:-latest} + env_file: + - path: .env + required: false + environment: + - VITE_API_HOST=${VITE_API_HOST:-http://localhost:7091} + - VITE_API_STREAMING=${VITE_API_STREAMING:-true} + ports: + - "5173:5173" + depends_on: + - backend + + backend: + image: arc53/docsgpt:${DOCSGPT_IMAGE_TAG:-latest}${DOCSGPT_IMAGE_VARIANT:-} + env_file: + - path: .env + required: false + environment: + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/1 + - CACHE_REDIS_URL=redis://redis:6379/2 + - POSTGRES_URI=postgresql://docsgpt:docsgpt@postgres:5432/docsgpt + ports: + - "7091:7091" + volumes: + - indexes:/app/indexes + - inputs:/app/inputs + - vectors:/app/vectors + depends_on: + redis: + condition: service_started + postgres: + condition: service_healthy + restart: unless-stopped + + worker: + image: arc53/docsgpt:${DOCSGPT_IMAGE_TAG:-latest}${DOCSGPT_IMAGE_VARIANT:-} + # Consumes the default queue plus `parsing` (read_document) and `embeddings` + # (query embedding); without the latter every search times out. + command: celery -A application.app.celery worker -l INFO -B -Q docsgpt,parsing,embeddings + env_file: + - path: .env + required: false + environment: + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/1 + - CACHE_REDIS_URL=redis://redis:6379/2 + - POSTGRES_URI=postgresql://docsgpt:docsgpt@postgres:5432/docsgpt + - API_URL=http://backend:7091 + volumes: + - indexes:/app/indexes + - inputs:/app/inputs + - vectors:/app/vectors + depends_on: + redis: + condition: service_started + postgres: + condition: service_healthy + restart: unless-stopped + + redis: + image: redis:6-alpine + restart: unless-stopped + + postgres: + image: postgres:16-alpine + environment: + - POSTGRES_USER=docsgpt + - POSTGRES_PASSWORD=docsgpt + - POSTGRES_DB=docsgpt + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U docsgpt -d docsgpt"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + +volumes: + indexes: + inputs: + vectors: + postgres_data: diff --git a/deployment/docker-compose.yaml b/deployment/docker-compose.yaml index c6d3b51f..fe9e9fd4 100644 --- a/deployment/docker-compose.yaml +++ b/deployment/docker-compose.yaml @@ -1,7 +1,11 @@ name: docsgpt-oss services: frontend: - build: ../frontend + build: + context: ../frontend + # Vite dev server with hot reload over the bind mount below. The default + # target (what Docker Hub publishes) is a static build behind nginx. + target: dev volumes: - ../frontend/src:/app/src environment: @@ -18,8 +22,10 @@ services: build: context: ../application args: - # Bake the optional docling engine (layout-model OCR backend, structured - # output) into the image: set INSTALL_DOCLING=true in ../.env or the shell. + # Optional extras to bake in (comma-separated): docling, milvus. The + # docling extra brings the layout-model parser/OCR backend and its + # models. INSTALL_DOCLING=true is the older spelling of EXTRAS=docling. + EXTRAS: ${EXTRAS:-} INSTALL_DOCLING: ${INSTALL_DOCLING:-false} # Bake the tesseract binary behind OCR_ENABLED=true (~35 MB): set # INSTALL_TESSERACT=true in ../.env or the shell (setup.sh does this @@ -53,6 +59,7 @@ services: build: context: ../application args: + EXTRAS: ${EXTRAS:-} INSTALL_DOCLING: ${INSTALL_DOCLING:-false} INSTALL_TESSERACT: ${INSTALL_TESSERACT:-false} # Consumes the default queue AND the dedicated `parsing` (read_document / diff --git a/docs/content/Deploying/Docker-Deploying.mdx b/docs/content/Deploying/Docker-Deploying.mdx index 8cec05e0..2ab0a03c 100644 --- a/docs/content/Deploying/Docker-Deploying.mdx +++ b/docs/content/Deploying/Docker-Deploying.mdx @@ -17,9 +17,57 @@ Docker is the recommended method for deploying DocsGPT, providing a consistent a **Important Note for Windows Users:** Docker Desktop on Windows generally requires the WSL 2 backend to function correctly, especially when using features like host networking which are utilized in DocsGPT's Docker Compose setup. Ensure WSL 2 is enabled and configured in Docker Desktop settings. -## Quickest Setup: Using DocsGPT Public API +## Quickest Setup: Pre-built Images, No Checkout -The fastest way to try out DocsGPT is by using the public API endpoint. This requires minimal configuration and no local LLM setup. +Every release publishes ready-to-run images to Docker Hub (`arc53/docsgpt`, +`arc53/docsgpt-fe`) and GitHub Container Registry (`ghcr.io/arc53/docsgpt`, +`ghcr.io/arc53/docsgpt-fe`) for `linux/amd64` and `linux/arm64`. The images +contain everything the default configuration needs (embedding models, +tokenizers, tiktoken's encoding), so a fresh container makes no downloads on +first use. You do not need the source tree to run them: + +1. **Download the standalone Compose file** (also attached to every + [release](https://github.com/arc53/DocsGPT/releases)): + + ```bash + mkdir docsgpt && cd docsgpt + curl -fsSLO https://raw.githubusercontent.com/arc53/DocsGPT/main/deployment/docker-compose-standalone.yaml + ``` + +2. **Create a `.env` next to it** with your settings, for example the public API: + + ``` + LLM_PROVIDER=docsgpt + VITE_API_STREAMING=true + ``` + +3. **Start it:** + + ```bash + docker compose -f docker-compose-standalone.yaml up -d + ``` + + Then open [http://localhost:5173/](http://localhost:5173/). Data lives in + named Docker volumes; `docker compose -f docker-compose-standalone.yaml down` + keeps it and `down -v` removes it. + +**Tags and variants.** `DOCSGPT_IMAGE_TAG` picks the version: a release such +as `0.20.0`, `latest` (the newest release, the default) or `develop` (follows +the `main` branch). `DOCSGPT_IMAGE_VARIANT` picks the flavour: empty for the +slim default image, or `-docling` for the image with the docling parser +engine, its models and tesseract baked in (needed for OCR of scanned +documents, see the [OCR guide](/Guides/ocr)). Both are read from `.env` or +the shell, e.g. `DOCSGPT_IMAGE_TAG=0.20.0 DOCSGPT_IMAGE_VARIANT=-docling`. +The same two variables drive `deployment/docker-compose-hub.yaml` in a +checkout. + +## Using the Source Checkout + +With a clone of the repository, `deployment/docker-compose-hub.yaml` runs the +same pre-built images while keeping your data in `application/indexes`, +`application/inputs` and `application/vectors`, and `deployment/docker-compose.yaml` +builds the images from your working tree (for local changes, or a build with +extra packages: `EXTRAS=docling` in `.env`). 1. **Clone the DocsGPT Repository (if you haven't already):** @@ -48,10 +96,12 @@ The fastest way to try out DocsGPT is by using the public API endpoint. This req Navigate to the root directory of the DocsGPT repository in your terminal and run: ```bash - docker compose --env-file .env -f deployment/docker-compose.yaml up -d + docker compose --env-file .env -f deployment/docker-compose-hub.yaml up -d ``` The `-d` flag runs Docker Compose in detached mode (in the background). + To build the images from your working tree instead of pulling them, use + `deployment/docker-compose.yaml` with `up --build -d`. 5. **Access DocsGPT in your browser:** @@ -62,7 +112,7 @@ The fastest way to try out DocsGPT is by using the public API endpoint. This req To stop the application, navigate to the same directory in your terminal and run: ```bash - docker compose -f deployment/docker-compose.yaml down + docker compose -f deployment/docker-compose-hub.yaml down ``` ## Optional Ollama Setup (Local Models) @@ -84,11 +134,11 @@ There are two Ollama optional files: **CPU:** ```bash - docker compose --env-file .env -f deployment/docker-compose.yaml -f deployment/optional/docker-compose.optional.ollama-cpu.yaml up -d + docker compose --env-file .env -f deployment/docker-compose-hub.yaml -f deployment/optional/docker-compose.optional.ollama-cpu.yaml up -d ``` **GPU:** ```bash - docker compose --env-file .env -f deployment/docker-compose.yaml -f deployment/optional/docker-compose.optional.ollama-gpu.yaml up -d + docker compose --env-file .env -f deployment/docker-compose-hub.yaml -f deployment/optional/docker-compose.optional.ollama-gpu.yaml up -d ``` 3. **Pull the Ollama Model:** @@ -96,11 +146,11 @@ There are two Ollama optional files: **Crucially, after launching with Ollama, you need to pull the desired model into the Ollama container.** Find the `LLM_NAME` you configured in your `.env` file (e.g., `llama3.2:1b`). Then execute the following command to pull the model *inside* the running Ollama container: ```bash - docker compose -f deployment/docker-compose.yaml -f deployment/optional/docker-compose.optional.ollama-cpu.yaml exec -it ollama ollama pull + docker compose -f deployment/docker-compose-hub.yaml -f deployment/optional/docker-compose.optional.ollama-cpu.yaml exec -it ollama ollama pull ``` or (for GPU): ```bash - docker compose -f deployment/docker-compose.yaml -f deployment/optional/docker-compose.optional.ollama-gpu.yaml exec -it ollama ollama pull + docker compose -f deployment/docker-compose-hub.yaml -f deployment/optional/docker-compose.optional.ollama-gpu.yaml exec -it ollama ollama pull ``` Replace `` with the actual model name from your `.env` file. @@ -113,12 +163,12 @@ There are two Ollama optional files: To stop a DocsGPT setup launched with Ollama optional files, use `docker compose down` and include all the compose files used during the `up` command: ```bash - docker compose -f deployment/docker-compose.yaml -f deployment/optional/docker-compose.optional.ollama-cpu.yaml down + docker compose -f deployment/docker-compose-hub.yaml -f deployment/optional/docker-compose.optional.ollama-cpu.yaml down ``` or ```bash - docker compose -f deployment/docker-compose.yaml -f deployment/optional/docker-compose.optional.ollama-gpu.yaml down + docker compose -f deployment/docker-compose-hub.yaml -f deployment/optional/docker-compose.optional.ollama-gpu.yaml down ``` **Important for GPU Usage:** diff --git a/docs/content/Guides/ocr.mdx b/docs/content/Guides/ocr.mdx index e3da0f49..5c26de75 100644 --- a/docs/content/Guides/ocr.mdx +++ b/docs/content/Guides/ocr.mdx @@ -64,11 +64,17 @@ docker build --build-arg INSTALL_TESSERACT=true ./application `INSTALL_TESSERACT=true` in `.env` (or the shell) bakes tesseract plus the English pack into locally built backend and worker images; `setup.sh` writes it when you answer yes to the OCR question after choosing to build images -locally. Pre-built Docker Hub images (`docker-compose-hub.yaml`) do not -include it, so `setup.sh` leaves OCR at its default (off) for them; to OCR -there, point `OCR_ENGINE=deepseek` at a DeepSeek-OCR endpoint or install -`tesseract-ocr` in a derived image. With `OCR_ENABLED=true` and no binary on -`PATH`, scanned pages fail with an install hint (text-layer documents are +locally. + +With pre-built images the switch is the image variant: every tag is +published twice, slim (`arc53/docsgpt:`) and `-docling` +(`arc53/docsgpt:-docling`), and the latter bakes tesseract, the docling +engine and its models in. Set `DOCSGPT_IMAGE_VARIANT=-docling` in `.env` for +`docker-compose-hub.yaml` or `docker-compose-standalone.yaml`; `setup.sh` +writes it when you answer yes to the OCR question with Docker Hub images. +Alternatively point `OCR_ENGINE=deepseek` at a DeepSeek-OCR endpoint, which +needs no system package. With `OCR_ENABLED=true` and no binary on `PATH`, +scanned pages fail with an install hint (text-layer documents are unaffected). @@ -88,19 +94,27 @@ docling is not part of the base install, and OCR does not need it (see output: ```bash -pip install -r application/requirements-docling.txt +pip install -r application/requirements-docling.txt # or: uv sync --extra docling ``` -Docker images build without it by default; opt in with the build argument: +That file is the core set plus the `docling` extra, exported from the same +lock. On Linux it takes torch from the CPU-only PyTorch index, so the extra +costs about 1.5 GB rather than the 2.7 GB the CUDA build of torch would; a +GPU deployment can reinstall torch from PyPI on top. + +Pre-built images: use the `-docling` variant (`arc53/docsgpt:-docling`, +`DOCSGPT_IMAGE_VARIANT=-docling` in `.env`), which also bakes docling's +layout, table-structure and RapidOCR models in so the first parse does not +download them. Local builds opt in with the build argument: ```bash -docker build --build-arg INSTALL_DOCLING=true ./application +docker build --build-arg EXTRAS=docling ./application ``` `deployment/docker-compose.yaml` forwards the same switch, so setting -`INSTALL_DOCLING=true` in `.env` (or the shell) bakes docling into locally -built backend and worker images; `setup.sh` offers it as a follow-up to the -OCR question. Compose reads build arguments from the shell or from the +`EXTRAS=docling` (or the older `INSTALL_DOCLING=true`) in `.env` (or the +shell) bakes docling into locally built backend and worker images; `setup.sh` +offers it as a follow-up to the OCR question. Compose reads build arguments from the shell or from the `.env` you pass with `--env-file .env` (not from the containers' `env_file`), so build with `docker compose --env-file .env -f deployment/docker-compose.yaml build` as `setup.sh` does. Pre-built Docker Hub images (`docker-compose-hub.yaml`) diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..50fd39ab --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.env +.env.* +Dockerfile +.dockerignore +*.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 19574bf5..007795a6 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,11 +1,52 @@ -FROM node:22-bullseye-slim +# DocsGPT frontend image: a static production build served by nginx. +# +# Vite inlines VITE_* settings at build time, so the old image ran the Vite +# dev server just to read VITE_API_HOST from the container environment. This +# image builds once and injects the container's VITE_* variables at start-up +# instead (docker/40-runtime-env.sh writes them to /config.js, which the app +# reads before its own bundle; see src/env.ts). Same env vars, same port. +# +# Targets: +# (default) nginx serving the built bundle -- what Docker Hub publishes +# dev the Vite dev server with hot reload, for docker-compose.yaml's +# bind-mounted frontend (build: target: dev) +FROM node:22-alpine AS deps WORKDIR /app COPY package*.json ./ -RUN npm install +RUN npm ci --no-audit --no-fund + + +FROM deps AS dev + COPY . . - +# vite.config.ts polls the filesystem when DOCKER is set: native fs events do +# not cross a Windows host into a Linux container. +ENV DOCKER=1 EXPOSE 5173 +CMD ["npm", "run", "dev", "--", "--host"] -CMD [ "npm", "run", "dev", "--" , "--host"] + +FROM deps AS build + +COPY . . +# Bake nothing deployment-specific; the runtime script supplies it. +ENV VITE_API_HOST="" +RUN npm run build && \ + sed -i 's|||' dist/index.html + + +FROM nginx:1.27-alpine + +LABEL org.opencontainers.image.source="https://github.com/arc53/DocsGPT" \ + org.opencontainers.image.title="DocsGPT frontend" \ + org.opencontainers.image.licenses="MIT" + +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY docker/40-runtime-env.sh /docker-entrypoint.d/40-runtime-env.sh +RUN chmod +x /docker-entrypoint.d/40-runtime-env.sh +COPY --from=build /app/dist /usr/share/nginx/html + +# Same port the dev server used, so compose files and docs keep working. +EXPOSE 5173 diff --git a/frontend/docker/40-runtime-env.sh b/frontend/docker/40-runtime-env.sh new file mode 100644 index 00000000..a99fee3a --- /dev/null +++ b/frontend/docker/40-runtime-env.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Expose the container's VITE_* environment to the static bundle. +# +# nginx's entrypoint runs everything in /docker-entrypoint.d before serving. +# The generated /config.js is loaded by index.html ahead of the app bundle and +# read by src/env.ts, so VITE_API_HOST and friends can differ per deployment +# without rebuilding the image. +set -eu + +out=/usr/share/nginx/html/config.js +{ + printf 'window.__DOCSGPT_ENV__ = {' + first=1 + env | grep -E '^VITE_[A-Za-z0-9_]+=' | while IFS='=' read -r key value; do + # JSON-escape backslashes and double quotes; values are plain URLs/ids. + escaped=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g') + if [ "$first" -eq 1 ]; then first=0; else printf ','; fi + printf '"%s":"%s"' "$key" "$escaped" + done + printf '};\n' +} > "$out" + +echo "runtime-env: wrote $(grep -o 'VITE_[A-Za-z0-9_]*' "$out" | wc -l | tr -d ' ') VITE_* values to /config.js" diff --git a/frontend/docker/nginx.conf b/frontend/docker/nginx.conf new file mode 100644 index 00000000..fe2de964 --- /dev/null +++ b/frontend/docker/nginx.conf @@ -0,0 +1,25 @@ +server { + listen 5173; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + + # Hashed bundle assets are immutable; index.html and config.js are not. + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location = /config.js { + add_header Cache-Control "no-store"; + } + + # Single-page app: every unknown path renders index.html. + location / { + add_header Cache-Control "no-cache"; + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bc367d8d..da9a493d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import { envVar } from '@/env'; import './locale/i18n'; import { useState } from 'react'; @@ -108,8 +109,8 @@ export default function App() { const saved = localStorage.getItem('showNotification'); return saved ? JSON.parse(saved) : true; }); - const notificationText = import.meta.env.VITE_NOTIFICATION_TEXT; - const notificationLink = import.meta.env.VITE_NOTIFICATION_LINK; + const notificationText = envVar('VITE_NOTIFICATION_TEXT'); + const notificationLink = envVar('VITE_NOTIFICATION_LINK'); // Hide the changelog banner on public share routes — those pages are // embedded / shared externally and shouldn't carry product chrome. const isPublicShareRoute = diff --git a/frontend/src/agents/agentPreviewSlice.ts b/frontend/src/agents/agentPreviewSlice.ts index 27387db3..cc2f5023 100644 --- a/frontend/src/agents/agentPreviewSlice.ts +++ b/frontend/src/agents/agentPreviewSlice.ts @@ -1,3 +1,4 @@ +import { envVar } from '@/env'; import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { @@ -26,7 +27,7 @@ const initialState: ConversationState = { conversationId: null, }; -const API_STREAMING = import.meta.env.VITE_API_STREAMING === 'true'; +const API_STREAMING = envVar('VITE_API_STREAMING') === 'true'; let abortController: AbortController | null = null; export function handlePreviewAbort() { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c7e599ac..21ebf123 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,7 +1,8 @@ +import { envVar } from '@/env'; import { withThrottle, type FetchLike } from './throttle'; export const baseURL = - import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; + envVar('VITE_API_HOST') || 'https://docsapi.arc53.com'; const getHeaders = ( token: string | null, diff --git a/frontend/src/components/GoogleDrivePicker.tsx b/frontend/src/components/GoogleDrivePicker.tsx index 96dc9142..6cdef9b2 100644 --- a/frontend/src/components/GoogleDrivePicker.tsx +++ b/frontend/src/components/GoogleDrivePicker.tsx @@ -1,3 +1,4 @@ +import { envVar } from '@/env'; import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import drivePickerImport from 'react-google-drive-picker'; @@ -121,9 +122,9 @@ const GoogleDrivePicker: React.FC = ({ } try { - const clientId: string = import.meta.env.VITE_GOOGLE_CLIENT_ID; + const clientId: string = envVar('VITE_GOOGLE_CLIENT_ID'); const developerKey: string = - import.meta.env.VITE_GOOGLE_PICKER_API_KEY ?? ''; + envVar('VITE_GOOGLE_PICKER_API_KEY') ?? ''; // Derive appId from clientId (extract numeric part before first dash) const appId = clientId ? clientId.split('-')[0] : null; diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index c472bc58..1fc75d1d 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -1,3 +1,4 @@ +import { envVar } from '@/env'; import { useCallback, useEffect, @@ -63,7 +64,7 @@ const LIVE_TRANSCRIPTION_TIMESLICE_MS = 1000; const LIVE_CAPTURE_SAMPLE_RATE = 16000; const LIVE_CAPTURE_MAX_BUFFER_SECONDS = 20; const LIVE_SILENCE_RMS_THRESHOLD = 0.015; -const ENABLE_VOICE_INPUT = import.meta.env.VITE_ENABLE_VOICE_INPUT === 'true'; +const ENABLE_VOICE_INPUT = envVar('VITE_ENABLE_VOICE_INPUT') === 'true'; type AudioContextWindow = Window & typeof globalThis & { @@ -526,7 +527,7 @@ export default function MessageInput({ if (supported.length === 0) return; const files = supported; - const apiHost = import.meta.env.VITE_API_HOST; + const apiHost = envVar('VITE_API_HOST'); if (files.length > 1) { const formData = new FormData(); diff --git a/frontend/src/conversation/ConversationBubble.tsx b/frontend/src/conversation/ConversationBubble.tsx index 05b14000..97464e4b 100644 --- a/frontend/src/conversation/ConversationBubble.tsx +++ b/frontend/src/conversation/ConversationBubble.tsx @@ -1,3 +1,4 @@ +import { envVar } from '@/env'; import 'katex/dist/katex.min.css'; import { Pencil } from 'lucide-react'; @@ -37,7 +38,7 @@ import ResearchProgress from './ResearchProgress'; import { ToolCallsType } from './types'; import { wikiWriteActionKey, wikiWritePath } from './wikiToolCall'; -const DisableSourceFE = import.meta.env.VITE_DISABLE_SOURCE_FE || false; +const DisableSourceFE = envVar('VITE_DISABLE_SOURCE_FE') || false; const ConversationBubble = forwardRef< HTMLDivElement, diff --git a/frontend/src/conversation/conversationSlice.ts b/frontend/src/conversation/conversationSlice.ts index 9e1c95b6..a46e2978 100644 --- a/frontend/src/conversation/conversationSlice.ts +++ b/frontend/src/conversation/conversationSlice.ts @@ -1,3 +1,4 @@ +import { envVar } from '@/env'; import { createAsyncThunk, createListenerMiddleware, @@ -90,8 +91,8 @@ const initialState: ConversationState = { conversationId: null, }; -const API_STREAMING = import.meta.env.VITE_API_STREAMING === 'true'; -const USE_V1_API = import.meta.env.VITE_USE_V1_API === 'true'; +const API_STREAMING = envVar('VITE_API_STREAMING') === 'true'; +const USE_V1_API = envVar('VITE_USE_V1_API') === 'true'; let abortController: AbortController | null = null; export function handleAbort() { diff --git a/frontend/src/conversation/sharedConversationSlice.ts b/frontend/src/conversation/sharedConversationSlice.ts index cd1ca8de..5e29942f 100644 --- a/frontend/src/conversation/sharedConversationSlice.ts +++ b/frontend/src/conversation/sharedConversationSlice.ts @@ -1,3 +1,4 @@ +import { envVar } from '@/env'; import { createSlice } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit'; import store from '../store'; @@ -12,7 +13,7 @@ import { clearAttachments, } from '../upload/uploadSlice'; -const API_STREAMING = import.meta.env.VITE_API_STREAMING === 'true'; +const API_STREAMING = envVar('VITE_API_STREAMING') === 'true'; interface SharedConversationsType { queries: Query[]; apiKey?: string; diff --git a/frontend/src/env.ts b/frontend/src/env.ts new file mode 100644 index 00000000..6d832126 --- /dev/null +++ b/frontend/src/env.ts @@ -0,0 +1,24 @@ +/** + * Runtime-overridable build settings. + * + * Vite inlines `import.meta.env.VITE_*` at build time, which forced the + * Docker image to run the dev server so `VITE_API_HOST` could change per + * deployment. The production image serves a static build instead and writes + * the container's `VITE_*` environment into `window.__DOCSGPT_ENV__` (see + * frontend/docker/40-runtime-env.sh). That object wins over the build-time + * value; outside Docker nothing sets it and the build-time value applies. + */ + +declare global { + interface Window { + __DOCSGPT_ENV__?: Record; + } +} + +export function envVar(name: string): string { + const runtime = + typeof window !== 'undefined' ? window.__DOCSGPT_ENV__?.[name] : undefined; + if (runtime !== undefined && runtime !== '') return runtime; + const buildTime = (import.meta.env as Record)[name]; + return typeof buildTime === 'string' ? buildTime : ''; +} diff --git a/frontend/src/modals/AgentDetailsModal.tsx b/frontend/src/modals/AgentDetailsModal.tsx index b2bb8ee3..a13cc4a1 100644 --- a/frontend/src/modals/AgentDetailsModal.tsx +++ b/frontend/src/modals/AgentDetailsModal.tsx @@ -1,3 +1,4 @@ +import { envVar } from '@/env'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; @@ -13,7 +14,7 @@ import { ActiveState } from '../models/misc'; import { selectToken } from '../preferences/preferenceSlice'; import ConfirmationModal from './ConfirmationModal'; -const baseURL = import.meta.env.VITE_BASE_URL; +const baseURL = envVar('VITE_BASE_URL'); type AgentDetailsModalProps = { agent: Agent; diff --git a/frontend/src/upload/Upload.tsx b/frontend/src/upload/Upload.tsx index d197f851..3355f1d2 100644 --- a/frontend/src/upload/Upload.tsx +++ b/frontend/src/upload/Upload.tsx @@ -1,3 +1,4 @@ +import { envVar } from '@/env'; import { useCallback, useEffect, useState } from 'react'; import { nanoid } from '@reduxjs/toolkit'; import { useDropzone } from 'react-dropzone'; @@ -575,7 +576,7 @@ function Upload({ JSON.stringify(optionsToConfig(retrievalOptions)), ); - const apiHost = import.meta.env.VITE_API_HOST; + const apiHost = envVar('VITE_API_HOST'); const xhr = new XMLHttpRequest(); dispatch( @@ -706,7 +707,7 @@ function Upload({ formData.append('data', JSON.stringify(configData)); - const apiHost: string = import.meta.env.VITE_API_HOST; + const apiHost: string = envVar('VITE_API_HOST'); const endpoint = ingestor.type === 'local_file' ? `${apiHost}/api/upload` diff --git a/frontend/src/upload/types/ingestor.ts b/frontend/src/upload/types/ingestor.ts index b3bf41f2..5a31d0a4 100644 --- a/frontend/src/upload/types/ingestor.ts +++ b/frontend/src/upload/types/ingestor.ts @@ -1,3 +1,4 @@ +import { envVar } from '@/env'; import CrawlerIcon from '../../assets/crawler.svg'; import FileUploadIcon from '../../assets/file_upload.svg'; import UrlIcon from '../../assets/url.svg'; @@ -146,7 +147,7 @@ export const IngestorFormSchemas: IngestorSchema[] = [ icon: DriveIcon, heading: 'Upload from Google Drive', validate: () => { - const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID; + const googleClientId = envVar('VITE_GOOGLE_CLIENT_ID'); return !!googleClientId; }, fields: [ @@ -208,7 +209,7 @@ export const IngestorFormSchemas: IngestorSchema[] = [ icon: SharePoint, heading: 'Upload from Share Point', validate: () => { - const sharePointClientId = import.meta.env.VITE_SHARE_POINT_CLIENT_ID; + const sharePointClientId = envVar('VITE_SHARE_POINT_CLIENT_ID'); return !!sharePointClientId; }, fields: [ @@ -226,7 +227,7 @@ export const IngestorFormSchemas: IngestorSchema[] = [ icon: ConfluenceIcon, heading: 'Upload from Confluence', validate: () => { - const confluenceClientId = import.meta.env.VITE_CONFLUENCE_CLIENT_ID; + const confluenceClientId = envVar('VITE_CONFLUENCE_CLIENT_ID'); return !!confluenceClientId; }, fields: [ diff --git a/setup.ps1 b/setup.ps1 index 9c947d5a..b7921990 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -513,29 +513,32 @@ function Configure-DocProcessing { Write-ColorText "PDF-as-image parsing enabled." -ForegroundColor "Green" } - # OCR needs the tesseract binary, an optional system package that only - # locally built images can include (INSTALL_TESSERACT build arg). The - # pre-built Docker Hub images ship without it, so there OCR stays off - # (its default) rather than being switched on to fail on every scan. - if ($COMPOSE_FILE -ne $COMPOSE_FILE_LOCAL) { - Write-ColorText "OCR for scanned PDFs and images stays off: the pre-built Docker Hub images do not include tesseract. To use OCR, rerun setup and choose option 5 (build images locally), or use a DeepSeek-OCR endpoint by adding OCR_ENABLED=true, OCR_ENGINE=deepseek and OCR_DEEPSEEK_URL= to .env." -ForegroundColor "Yellow" + # OCR needs the tesseract binary. The default (slim) images ship without + # it; the pre-built "-docling" image variant bakes tesseract, the docling + # layout engine and its models in, so with Docker Hub images OCR means + # switching the variant. Locally built images get it via build args. + $ocr_enabled = Read-Host "Enable OCR for scanned PDFs and images? (y/N)" + if (-not ($ocr_enabled -eq "y" -or $ocr_enabled -eq "Y")) { return } - - $ocr_enabled = Read-Host "Enable OCR for scanned PDFs and images? (y/N)" - if ($ocr_enabled -eq "y" -or $ocr_enabled -eq "Y") { - "OCR_ENABLED=true" | Add-Content -Path $ENV_FILE -Encoding utf8 - # Bakes tesseract into the locally built images (docker compose - # --env-file .env build). - "INSTALL_TESSERACT=true" | Add-Content -Path $ENV_FILE -Encoding utf8 - Write-ColorText "OCR enabled. tesseract will be built into the images (INSTALL_TESSERACT=true); for a DeepSeek-OCR endpoint instead, set OCR_ENGINE=deepseek and OCR_DEEPSEEK_URL= in .env." -ForegroundColor "Green" - $docling_ocr = Read-Host "Also install the Docling layout engine for OCR (better tables/reading order, several GB heavier)? (y/N)" - if ($docling_ocr -eq "y" -or $docling_ocr -eq "Y") { - # Locally built images include docling via this build arg; it becomes - # the OCR backend automatically (OCR_BACKEND=auto). - "INSTALL_DOCLING=true" | Add-Content -Path $ENV_FILE -Encoding utf8 - Write-ColorText "Docling will be built into locally built images (docker compose --env-file .env build). Pre-built Docker Hub images do not include it." -ForegroundColor "Green" - } + "OCR_ENABLED=true" | Add-Content -Path $ENV_FILE -Encoding utf8 + if ($COMPOSE_FILE -ne $COMPOSE_FILE_LOCAL) { + # Pre-built images: pull arc53/docsgpt:-docling instead of the + # slim default (about 1.5 GB more to download). + "DOCSGPT_IMAGE_VARIANT=-docling" | Add-Content -Path $ENV_FILE -Encoding utf8 + Write-ColorText "OCR enabled. The -docling image variant will be pulled (tesseract, docling layout engine and its models included). For a DeepSeek-OCR endpoint instead, set OCR_ENGINE=deepseek and OCR_DEEPSEEK_URL= in .env." -ForegroundColor "Green" + return + } + # Bakes tesseract into the locally built images (docker compose + # --env-file .env build). + "INSTALL_TESSERACT=true" | Add-Content -Path $ENV_FILE -Encoding utf8 + Write-ColorText "OCR enabled. tesseract will be built into the images (INSTALL_TESSERACT=true); for a DeepSeek-OCR endpoint instead, set OCR_ENGINE=deepseek and OCR_DEEPSEEK_URL= in .env." -ForegroundColor "Green" + $docling_ocr = Read-Host "Also install the Docling layout engine for OCR (better tables/reading order, several GB heavier)? (y/N)" + if ($docling_ocr -eq "y" -or $docling_ocr -eq "Y") { + # Locally built images include docling via this build arg; it becomes + # the OCR backend automatically (OCR_BACKEND=auto). + "INSTALL_DOCLING=true" | Add-Content -Path $ENV_FILE -Encoding utf8 + Write-ColorText "Docling will be built into locally built images (docker compose --env-file .env build)." -ForegroundColor "Green" } } diff --git a/setup.sh b/setup.sh index f0e4aa0f..bb52f0fe 100755 --- a/setup.sh +++ b/setup.sh @@ -367,29 +367,32 @@ configure_doc_processing() { echo -e "${GREEN}PDF-as-image parsing enabled.${NC}" fi - # OCR needs the tesseract binary, an optional system package that only - # locally built images can include (INSTALL_TESSERACT build arg). The - # pre-built Docker Hub images ship without it, so there OCR stays off - # (its default) rather than being switched on to fail on every scan. - if [[ "$COMPOSE_FILE" != "$COMPOSE_FILE_LOCAL" ]]; then - echo -e "${YELLOW}OCR for scanned PDFs and images stays off: the pre-built Docker Hub images do not include tesseract. To use OCR, rerun setup and choose option 5 (build images locally), or use a DeepSeek-OCR endpoint by adding OCR_ENABLED=true, OCR_ENGINE=deepseek and OCR_DEEPSEEK_URL= to .env.${NC}" + # OCR needs the tesseract binary. The default (slim) images ship without + # it; the pre-built "-docling" image variant bakes tesseract, the docling + # layout engine and its models in, so with Docker Hub images OCR means + # switching the variant. Locally built images get it via build args. + read -p "$(echo -e "${DEFAULT_FG}Enable OCR for scanned PDFs and images? (y/N): ${NC}")" ocr_enabled + if [[ ! "$ocr_enabled" =~ ^[yY]$ ]]; then return fi - - read -p "$(echo -e "${DEFAULT_FG}Enable OCR for scanned PDFs and images? (y/N): ${NC}")" ocr_enabled - if [[ "$ocr_enabled" =~ ^[yY]$ ]]; then - echo "OCR_ENABLED=true" >> "$ENV_FILE" - # Bakes tesseract into the locally built images (docker compose - # --env-file .env build). - echo "INSTALL_TESSERACT=true" >> "$ENV_FILE" - echo -e "${GREEN}OCR enabled. tesseract will be built into the images (INSTALL_TESSERACT=true); for a DeepSeek-OCR endpoint instead, set OCR_ENGINE=deepseek and OCR_DEEPSEEK_URL= in .env.${NC}" - read -p "$(echo -e "${DEFAULT_FG}Also install the Docling layout engine for OCR (better tables/reading order, several GB heavier)? (y/N): ${NC}")" docling_ocr - if [[ "$docling_ocr" =~ ^[yY]$ ]]; then - # Locally built images include docling via this build arg; it becomes - # the OCR backend automatically (OCR_BACKEND=auto). - echo "INSTALL_DOCLING=true" >> "$ENV_FILE" - echo -e "${GREEN}Docling will be built into locally built images (docker compose --env-file .env build). Pre-built Docker Hub images do not include it.${NC}" - fi + echo "OCR_ENABLED=true" >> "$ENV_FILE" + if [[ "$COMPOSE_FILE" != "$COMPOSE_FILE_LOCAL" ]]; then + # Pre-built images: pull arc53/docsgpt:-docling instead of the + # slim default (about 1.5 GB more to download). + echo "DOCSGPT_IMAGE_VARIANT=-docling" >> "$ENV_FILE" + echo -e "${GREEN}OCR enabled. The -docling image variant will be pulled (tesseract, docling layout engine and its models included). For a DeepSeek-OCR endpoint instead, set OCR_ENGINE=deepseek and OCR_DEEPSEEK_URL= in .env.${NC}" + return + fi + # Bakes tesseract into the locally built images (docker compose + # --env-file .env build). + echo "INSTALL_TESSERACT=true" >> "$ENV_FILE" + echo -e "${GREEN}OCR enabled. tesseract will be built into the images (INSTALL_TESSERACT=true); for a DeepSeek-OCR endpoint instead, set OCR_ENGINE=deepseek and OCR_DEEPSEEK_URL= in .env.${NC}" + read -p "$(echo -e "${DEFAULT_FG}Also install the Docling layout engine for OCR (better tables/reading order, several GB heavier)? (y/N): ${NC}")" docling_ocr + if [[ "$docling_ocr" =~ ^[yY]$ ]]; then + # Locally built images include docling via this build arg; it becomes + # the OCR backend automatically (OCR_BACKEND=auto). + echo "INSTALL_DOCLING=true" >> "$ENV_FILE" + echo -e "${GREEN}Docling will be built into locally built images (docker compose --env-file .env build).${NC}" fi }