From 0e1616d51cc8494b993df19ec80504fedd2db16b Mon Sep 17 00:00:00 2001 From: Mikan <72257910+Mikan-DS@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:21:47 +0300 Subject: [PATCH] rebase --- .dockerignore | 50 +++++++++++++++++++++++++++++++++++ .env.example | 40 ++++++++++++++++++++++------ CHANGELOG.md | 16 +++++++++++ README.md | 16 +++++++---- app/api/deps.py | 27 ++++++++++++++++--- deploy/Dockerfile.backend | 6 ++--- deploy/Dockerfile.frontend | 20 +++++++++++++- deploy/nginx.conf | 28 ++++++++++++++++---- docker-compose.yml | 22 ++++++++++----- frontend/src/lib/api.ts | 5 +++- frontend/src/vite-env.d.ts | 9 +++++++ frontend/tsconfig.tsbuildinfo | 2 +- 12 files changed, 206 insertions(+), 35 deletions(-) create mode 100644 .dockerignore create mode 100644 frontend/src/vite-env.d.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..52a0e20 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,50 @@ +# Python +__pycache__/ +**/*.pyc +**/*.pyo +**/.pytest_cache/ +**/.ruff_cache/ +**/.mypy_cache/ +.venv/ +venv/ +*.egg-info/ +.coverage +.coverage.* +htmlcov/ + +# Node / Frontend +**/node_modules/ +frontend/dist/ +frontend/.vite/ +npm-debug.log* +yarn-* + +# Environment & secrets +.env +.env.local +.env.*.local + +# Data & uploads (kept on volumes, not in image) +data/ +!data/.gitkeep +*.db +*.sqlite3 + +# IDE / OS +.vscode/ +.idea/ +*.swp +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Tests artifacts +.pytest_cache/ +tests/.cache/ + +# Git +.git/ +.gitignore diff --git a/.env.example b/.env.example index 841d6c3..636a96e 100644 --- a/.env.example +++ b/.env.example @@ -1,38 +1,62 @@ +# ============================================================ +# AI-RPG environment configuration +# Copy to .env and edit before running `docker compose up`. +# ============================================================ + # === Database === POSTGRES_USER=airpg POSTGRES_PASSWORD=airpg POSTGRES_DB=airpg -DATABASE_URL=postgresql+asyncpg://airpg:airpg@db:5432/airpg -DATABASE_URL_SYNC=postgresql+psycopg2://airpg:airpg@db:5432/airpg +# Used by backend when running OUTSIDE docker (local dev). Inside docker-compose +# this is overridden in docker-compose.yml to point at the `db` service. +DATABASE_URL=postgresql+asyncpg://airpg:airpg@localhost:5432/airpg +DATABASE_URL_SYNC=postgresql+psycopg2://airpg:airpg@localhost:5432/airpg # === Qdrant === -QDRANT_URL=http://qdrant:6333 +QDRANT_URL=http://localhost:6333 QDRANT_API_KEY= -# === LLM === +# === LLM (OpenAI-compatible) === +# Examples: +# OpenAI: https://api.openai.com/v1 +# Ollama (host): http://host.docker.internal:11434/v1 ← for docker +# Ollama (local): http://localhost:11434/v1 ← for non-docker dev +# vLLM: http://localhost:8000/v1 +# Leave empty to use MockLlmClient (replay-based, for dev/tests). LLM_API_URL=http://host.docker.internal:11434/v1 LLM_API_KEY= LLM_MODEL=qwen2.5-7b-instruct LLM_TIMEOUT_SECONDS=60 # === Embeddings === +# Provider: "offline_hash" (deterministic, dev-only, no HTTP) or "openai" EMBEDDINGS_PROVIDER=offline_hash +# If provider=openai and these are empty, falls back to LLM_API_URL / LLM_API_KEY EMBEDDINGS_API_URL= EMBEDDINGS_API_KEY= EMBEDDINGS_MODEL=text-embedding-3-small +# For offline_hash: 256. For openai text-embedding-3-small: 1536. +# Use admin "Probe dimension" button to auto-detect. EMBEDDINGS_DIMENSION=256 # === Application === -SECRET_KEY=change-me-in-production-32-bytes-long-min +# Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))" +SECRET_KEY=change-me-to-a-random-32-byte-string-min DEBUG=false LOG_LEVEL=INFO -CORS_ORIGINS=http://localhost,http://localhost:5173 +# Comma-separated list of allowed CORS origins (or * for any) +CORS_ORIGINS=http://localhost:8080,http://localhost:5173,http://localhost # === Admin setup === +# Leave empty to auto-generate on first startup (will be printed in backend logs). +# Or set explicitly to control the URL: /register/admin?token= ADMIN_SETUP_TOKEN= # === Storage === DATA_DIR=/app/data -# === Frontend (Vite dev) === -VITE_API_BASE_URL=http://localhost/api +# === Frontend (Vite — ONLY used by `npm run dev`, ignored by docker build) === +# The docker build bakes "/api" (relative) into the bundle so the browser +# uses the same origin + nginx proxies /api → backend:8000. +# For local `npm run dev` (outside docker), set this to the backend URL. +VITE_API_BASE_URL=http://localhost:8000/api diff --git a/CHANGELOG.md b/CHANGELOG.md index aac0de8..1d2c78a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to AI-RPG are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.1] — 2026-06-20 + +### Fixed +- **docker-compose.yml**: removed obsolete `version: "3.9"` (caused warning in modern Docker Compose). +- **docker-compose.yml**: fixed frontend build context — was `./frontend` (broke `COPY deploy/nginx.conf` and `COPY frontend/package*.json` in Dockerfile.frontend). Now correctly `.` (project root) with `dockerfile: deploy/Dockerfile.frontend`. +- **docker-compose.yml**: fixed frontend port mapping — was `5173:5173` but the frontend container is nginx on port 80. Now `8080:80` so the app is accessible at `http://localhost:8080`. +- **docker-compose.yml**: added `extra_hosts: ["host.docker.internal:host-gateway"]` to the backend service — enables `LLM_API_URL=http://host.docker.internal:11434/v1` to work on Linux hosts (not just Docker Desktop on Mac/Windows). +- **docker-compose.yml**: added `VITE_API_BASE_URL: /api` build arg for the frontend service — bakes the relative `/api` URL into the Vite bundle so the browser uses the same origin + nginx proxies `/api` → `backend:8000`. +- **deploy/Dockerfile.frontend**: added `ARG VITE_API_BASE_URL=/api` + `ENV` so the build arg actually gets baked into the Vite bundle. +- **deploy/nginx.conf**: extended SSE timeouts from 300s → 600s; added `proxy_send_timeout`; added `Upgrade`/`Connection` headers for future WebSocket support; added gzip for static assets. +- **.env.example**: clarified `VITE_API_BASE_URL` — only used by local `npm run dev`, ignored by docker build (which uses `/api` relative). Removed the misleading `http://localhost/api` default. +- **frontend/src/lib/api.ts**: now respects `VITE_API_BASE_URL` env var with fallback to relative `/api`. Works both for local dev (point at separate backend) and Docker (nginx proxy). +- **frontend/src/vite-env.d.ts**: added Vite env type declarations so TypeScript knows about `import.meta.env.VITE_API_BASE_URL`. +- **app/api/deps.py**: added `?access_token=` query parameter fallback for SSE endpoints. Native `EventSource` cannot send `Authorization` headers, so the frontend SSE client passes the token via query string. Without this fix, all SSE endpoints (`/iterate/stream`, `/builder/stream`, `/editor/stream`) returned 401. +- **.dockerignore**: added at project root — excludes `node_modules/`, `__pycache__/`, `.venv/`, `data/`, `.git/`, etc. from Docker build contexts (faster builds, smaller context transfer). + ## [1.0.0] — 2026-06-20 ### Added — Sprint 1: Foundation diff --git a/README.md b/README.md index 819840e..1fc3a80 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,15 @@ cp .env.example .env # Отредактируйте SECRET_KEY, ADMIN_SETUP_TOKEN, LLM_API_URL, LLM_API_KEY # 2. Поднять всё -docker compose up -d +docker compose up -d --build -# 3. Зайти на http://localhost (frontend через nginx) -# Или http://localhost:5173 (frontend dev) / http://localhost:8000/api/docs (backend) +# 3. Открыть в браузере: +# - Frontend (nginx + React build): http://localhost:8080 +# - Backend Swagger docs: http://localhost:8000/api/docs +# - Health-check: http://localhost:8000/api/health ``` -При первом старте сервер напечатает в лог: +При первом старте backend напечатает в лог: ``` === AI-RPG Admin Setup === No admin user yet. Open this URL in your browser: @@ -87,7 +89,11 @@ No admin user yet. Open this URL in your browser: =========================== ``` -Откройте `http://localhost/register/admin?token=<...>` и создайте первого админа. +Посмотреть лог: `docker compose logs backend | head -20`. + +Откройте `http://localhost:8080/register/admin?token=<...>` и создайте первого админа. + +> **Примечание про LLM:** если у вас Ollama на хосте, используйте `LLM_API_URL=http://host.docker.internal:11434/v1` — backend-контейнер автоматически резолвит `host.docker.internal` через `extra_hosts: host-gateway` (работает на Linux/macOS/Windows). ### Опция 2: локальный dev (backend + frontend раздельно) diff --git a/app/api/deps.py b/app/api/deps.py index 785109c..4f43a48 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -1,11 +1,17 @@ -"""Shared API dependencies: current user, admin guard, db session, settings.""" +"""Shared API dependencies: current user, admin guard, db session, settings. + +Token resolution order: +1. `Authorization: Bearer ` header (preferred). +2. `?access_token=` query parameter (fallback for SSE — EventSource cannot + set custom headers). +""" from __future__ import annotations import uuid from typing import Any -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Query, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -20,16 +26,29 @@ _bearer = HTTPBearer(auto_error=False) async def get_current_user( creds: HTTPAuthorizationCredentials | None = Depends(_bearer), + access_token: str | None = Query(default=None, description="JWT access token (fallback for SSE)"), db: AsyncSession = Depends(get_db), ) -> User: """Resolve the JWT bearer token to a User row. + Accepts the token either in the `Authorization: Bearer ` header + or as the `?access_token=` query parameter (the latter is needed + because native `EventSource` cannot send custom headers, and the + frontend SSE client passes the token via query string). + Raises 401 on missing/invalid/expired token. """ - if creds is None or creds.scheme.lower() != "bearer": + raw_token: str | None = None + if creds is not None and creds.scheme.lower() == "bearer": + raw_token = creds.credentials + elif access_token: + raw_token = access_token + + if not raw_token: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing bearer token") + try: - payload = decode_token(creds.credentials) + payload = decode_token(raw_token) except Exception as e: # noqa: BLE001 raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Invalid token: {e}") if payload.get("type") != "access": diff --git a/deploy/Dockerfile.backend b/deploy/Dockerfile.backend index 4e2ee1d..e8510ef 100644 --- a/deploy/Dockerfile.backend +++ b/deploy/Dockerfile.backend @@ -13,10 +13,10 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -# Create data directory +# Create data directory for assets / uploads RUN mkdir -p /app/data/assets EXPOSE 8000 -# Default: run uvicorn; in dev override with `npm run dev` equivalent -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +# Default: run uvicorn. In dev override via docker-compose volumes for hot reload. +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/deploy/Dockerfile.frontend b/deploy/Dockerfile.frontend index 81887c4..3f6db94 100644 --- a/deploy/Dockerfile.frontend +++ b/deploy/Dockerfile.frontend @@ -1,14 +1,32 @@ # Build stage FROM node:20-alpine AS build WORKDIR /app + +# Bake the API base URL into the Vite bundle at build time. +# Default is the relative "/api" — works when the app is served from the same +# origin as the /api reverse proxy (nginx in front of the backend). +ARG VITE_API_BASE_URL=/api +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL + +# Copy frontend manifest first for better layer caching COPY frontend/package*.json ./ RUN npm install + +# Copy the rest of the frontend source and build COPY frontend/ . RUN npm run build -# Serve stage +# ------------------------------------------------------------------- +# Serve stage — nginx +# ------------------------------------------------------------------- FROM nginx:1.24-alpine + +# Copy built static assets from the build stage COPY --from=build /app/dist /usr/share/nginx/html + +# Copy nginx config (path is relative to the build context, which is the project root) COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf + EXPOSE 80 + CMD ["nginx", "-g", "daemon off;"] diff --git a/deploy/nginx.conf b/deploy/nginx.conf index e98d280..091a5af 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -5,12 +5,17 @@ server { root /usr/share/nginx/html; index index.html; - # SPA fallback + # Gzip for text assets + gzip on; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + gzip_min_length 1024; + + # SPA fallback — must be before /api/ location location / { try_files $uri $uri/ /index.html; } - # API + SSE + # API + SSE — proxy to backend container location /api/ { proxy_pass http://backend:8000; proxy_http_version 1.1; @@ -19,14 +24,27 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - # SSE: disable buffering + # SSE: disable buffering, extend timeouts for long-running streams proxy_buffering off; proxy_cache off; - proxy_read_timeout 300s; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + + # WebSocket support (in case of future upgrade) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; } - # Static assets (icons, uploads) + # Static assets (icons, uploads) served by backend location /static/ { proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # Health-check passthrough + location = /health { + proxy_pass http://backend:8000/api/health; } } diff --git a/docker-compose.yml b/docker-compose.yml index 28a0805..d06f685 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.9" - services: db: image: postgres:15-alpine @@ -39,8 +37,13 @@ services: env_file: - .env environment: + # Override .env values with docker-compose service names DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-airpg}:${POSTGRES_PASSWORD:-airpg}@db:5432/${POSTGRES_DB:-airpg} QDRANT_URL: http://qdrant:6333 + # Allow backend to reach host services (e.g. Ollama on host) via host.docker.internal + # On Linux this requires the extra_hosts mapping below. + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - ./data:/app/data - ./app:/app/app @@ -53,13 +56,18 @@ services: condition: service_healthy frontend: + # nginx-served production build of the React app. + # Listens on container port 80, mapped to host port 8080. + # Access the app at http://localhost:8080 build: - context: ./frontend - dockerfile: ../deploy/Dockerfile.frontend - environment: - VITE_API_BASE_URL: /api + context: . + dockerfile: deploy/Dockerfile.frontend + args: + # Bake the API base URL into the bundle at build time. + # Relative "/api" works because nginx proxies /api/* → backend:8000. + VITE_API_BASE_URL: /api ports: - - "5173:5173" + - "8080:80" depends_on: - backend diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index eb7dc0f..a606f26 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -29,7 +29,10 @@ import type { WorldPreset, } from "@/types"; -const BASE_URL = "/api"; +// Use VITE_API_BASE_URL if set (for local `npm run dev` pointing at a separate +// backend); otherwise default to relative "/api" which works behind the nginx +// reverse proxy in the Docker deployment. +const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") || "/api"; const ACCESS_TOKEN_KEY = "airpg_access_token"; const REFRESH_TOKEN_KEY = "airpg_refresh_token"; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..d43868c --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 6f5bcf9..469b7ca 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/components/admin/IconsPanel.tsx","./src/components/admin/LlmLogsTable.tsx","./src/components/admin/SettingsPanel.tsx","./src/components/admin/StatsPanel.tsx","./src/components/admin/TestButtons.tsx","./src/components/admin/UsersTable.tsx","./src/components/auth/ProtectedRoute.tsx","./src/components/sessions/ActionInput.tsx","./src/components/sessions/ChatView.tsx","./src/components/sessions/PhaseProgress.tsx","./src/components/sessions/SseStatus.tsx","./src/components/sessions/ToolCallBubble.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/Input.tsx","./src/components/ui/JsonEditor.tsx","./src/components/ui/Modal.tsx","./src/components/ui/Navbar.tsx","./src/components/ui/Spinner.tsx","./src/components/ui/Textarea.tsx","./src/components/ui/Toast.tsx","./src/components/worlds/WorldBuilder.tsx","./src/components/worlds/WorldCard.tsx","./src/components/worlds/WorldEditor.tsx","./src/i18n/index.ts","./src/lib/api.ts","./src/lib/cn.ts","./src/lib/sse.ts","./src/pages/AdminPage.tsx","./src/pages/AdminRegisterPage.tsx","./src/pages/LoginPage.tsx","./src/pages/PlayPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/WorldBuilderPage.tsx","./src/pages/WorldEditPage.tsx","./src/pages/WorldsListPage.tsx","./src/stores/authStore.ts","./src/stores/sessionStore.ts","./src/stores/toastStore.ts","./src/stores/uiStore.ts","./src/stores/worldsStore.ts","./src/types/index.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/admin/IconsPanel.tsx","./src/components/admin/LlmLogsTable.tsx","./src/components/admin/SettingsPanel.tsx","./src/components/admin/StatsPanel.tsx","./src/components/admin/TestButtons.tsx","./src/components/admin/UsersTable.tsx","./src/components/auth/ProtectedRoute.tsx","./src/components/sessions/ActionInput.tsx","./src/components/sessions/ChatView.tsx","./src/components/sessions/PhaseProgress.tsx","./src/components/sessions/SseStatus.tsx","./src/components/sessions/ToolCallBubble.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/Input.tsx","./src/components/ui/JsonEditor.tsx","./src/components/ui/Modal.tsx","./src/components/ui/Navbar.tsx","./src/components/ui/Spinner.tsx","./src/components/ui/Textarea.tsx","./src/components/ui/Toast.tsx","./src/components/worlds/WorldBuilder.tsx","./src/components/worlds/WorldCard.tsx","./src/components/worlds/WorldEditor.tsx","./src/i18n/index.ts","./src/lib/api.ts","./src/lib/cn.ts","./src/lib/sse.ts","./src/pages/AdminPage.tsx","./src/pages/AdminRegisterPage.tsx","./src/pages/LoginPage.tsx","./src/pages/PlayPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/WorldBuilderPage.tsx","./src/pages/WorldEditPage.tsx","./src/pages/WorldsListPage.tsx","./src/stores/authStore.ts","./src/stores/sessionStore.ts","./src/stores/toastStore.ts","./src/stores/uiStore.ts","./src/stores/worldsStore.ts","./src/types/index.ts"],"version":"5.9.3"} \ No newline at end of file