This commit is contained in:
Mikan
2026-06-20 22:21:47 +03:00
parent 8514c63ec6
commit 0e1616d51c
12 changed files with 206 additions and 35 deletions

50
.dockerignore Normal file
View File

@@ -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

View File

@@ -1,38 +1,62 @@
# ============================================================
# AI-RPG environment configuration
# Copy to .env and edit before running `docker compose up`.
# ============================================================
# === Database === # === Database ===
POSTGRES_USER=airpg POSTGRES_USER=airpg
POSTGRES_PASSWORD=airpg POSTGRES_PASSWORD=airpg
POSTGRES_DB=airpg POSTGRES_DB=airpg
DATABASE_URL=postgresql+asyncpg://airpg:airpg@db:5432/airpg # Used by backend when running OUTSIDE docker (local dev). Inside docker-compose
DATABASE_URL_SYNC=postgresql+psycopg2://airpg:airpg@db:5432/airpg # 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 ===
QDRANT_URL=http://qdrant:6333 QDRANT_URL=http://localhost:6333
QDRANT_API_KEY= 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_URL=http://host.docker.internal:11434/v1
LLM_API_KEY= LLM_API_KEY=
LLM_MODEL=qwen2.5-7b-instruct LLM_MODEL=qwen2.5-7b-instruct
LLM_TIMEOUT_SECONDS=60 LLM_TIMEOUT_SECONDS=60
# === Embeddings === # === Embeddings ===
# Provider: "offline_hash" (deterministic, dev-only, no HTTP) or "openai"
EMBEDDINGS_PROVIDER=offline_hash 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_URL=
EMBEDDINGS_API_KEY= EMBEDDINGS_API_KEY=
EMBEDDINGS_MODEL=text-embedding-3-small 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 EMBEDDINGS_DIMENSION=256
# === Application === # === 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 DEBUG=false
LOG_LEVEL=INFO 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 === # === 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>
ADMIN_SETUP_TOKEN= ADMIN_SETUP_TOKEN=
# === Storage === # === Storage ===
DATA_DIR=/app/data DATA_DIR=/app/data
# === Frontend (Vite dev) === # === Frontend (Vite — ONLY used by `npm run dev`, ignored by docker build) ===
VITE_API_BASE_URL=http://localhost/api # 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

View File

@@ -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/), 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). 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=<jwt>` 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 ## [1.0.0] — 2026-06-20
### Added — Sprint 1: Foundation ### Added — Sprint 1: Foundation

View File

@@ -73,13 +73,15 @@ cp .env.example .env
# Отредактируйте SECRET_KEY, ADMIN_SETUP_TOKEN, LLM_API_URL, LLM_API_KEY # Отредактируйте SECRET_KEY, ADMIN_SETUP_TOKEN, LLM_API_URL, LLM_API_KEY
# 2. Поднять всё # 2. Поднять всё
docker compose up -d docker compose up -d --build
# 3. Зайти на http://localhost (frontend через nginx) # 3. Открыть в браузере:
# Или http://localhost:5173 (frontend dev) / http://localhost:8000/api/docs (backend) # - 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 === === AI-RPG Admin Setup ===
No admin user yet. Open this URL in your browser: 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 раздельно) ### Опция 2: локальный dev (backend + frontend раздельно)

View File

@@ -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 <jwt>` header (preferred).
2. `?access_token=<jwt>` query parameter (fallback for SSE — EventSource cannot
set custom headers).
"""
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from typing import Any from typing import Any
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, Query, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,16 +26,29 @@ _bearer = HTTPBearer(auto_error=False)
async def get_current_user( async def get_current_user(
creds: HTTPAuthorizationCredentials | None = Depends(_bearer), creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
access_token: str | None = Query(default=None, description="JWT access token (fallback for SSE)"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> User: ) -> User:
"""Resolve the JWT bearer token to a User row. """Resolve the JWT bearer token to a User row.
Accepts the token either in the `Authorization: Bearer <jwt>` header
or as the `?access_token=<jwt>` 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. 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") raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing bearer token")
try: try:
payload = decode_token(creds.credentials) payload = decode_token(raw_token)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Invalid token: {e}") raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Invalid token: {e}")
if payload.get("type") != "access": if payload.get("type") != "access":

View File

@@ -13,10 +13,10 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
# Create data directory # Create data directory for assets / uploads
RUN mkdir -p /app/data/assets RUN mkdir -p /app/data/assets
EXPOSE 8000 EXPOSE 8000
# Default: run uvicorn; in dev override with `npm run dev` equivalent # 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", "--reload"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,14 +1,32 @@
# Build stage # Build stage
FROM node:20-alpine AS build FROM node:20-alpine AS build
WORKDIR /app 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 ./ COPY frontend/package*.json ./
RUN npm install RUN npm install
# Copy the rest of the frontend source and build
COPY frontend/ . COPY frontend/ .
RUN npm run build RUN npm run build
# Serve stage # -------------------------------------------------------------------
# Serve stage — nginx
# -------------------------------------------------------------------
FROM nginx:1.24-alpine FROM nginx:1.24-alpine
# Copy built static assets from the build stage
COPY --from=build /app/dist /usr/share/nginx/html 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 COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@@ -5,12 +5,17 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# API + SSE # API + SSE — proxy to backend container
location /api/ { location /api/ {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
proxy_http_version 1.1; 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-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# SSE: disable buffering # SSE: disable buffering, extend timeouts for long-running streams
proxy_buffering off; proxy_buffering off;
proxy_cache 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/ { location /static/ {
proxy_pass http://backend:8000; 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;
} }
} }

View File

@@ -1,5 +1,3 @@
version: "3.9"
services: services:
db: db:
image: postgres:15-alpine image: postgres:15-alpine
@@ -39,8 +37,13 @@ services:
env_file: env_file:
- .env - .env
environment: environment:
# Override .env values with docker-compose service names
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-airpg}:${POSTGRES_PASSWORD:-airpg}@db:5432/${POSTGRES_DB:-airpg} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-airpg}:${POSTGRES_PASSWORD:-airpg}@db:5432/${POSTGRES_DB:-airpg}
QDRANT_URL: http://qdrant:6333 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: volumes:
- ./data:/app/data - ./data:/app/data
- ./app:/app/app - ./app:/app/app
@@ -53,13 +56,18 @@ services:
condition: service_healthy condition: service_healthy
frontend: 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: build:
context: ./frontend context: .
dockerfile: ../deploy/Dockerfile.frontend dockerfile: deploy/Dockerfile.frontend
environment: args:
VITE_API_BASE_URL: /api # 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: ports:
- "5173:5173" - "8080:80"
depends_on: depends_on:
- backend - backend

View File

@@ -29,7 +29,10 @@ import type {
WorldPreset, WorldPreset,
} from "@/types"; } 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 ACCESS_TOKEN_KEY = "airpg_access_token";
const REFRESH_TOKEN_KEY = "airpg_refresh_token"; const REFRESH_TOKEN_KEY = "airpg_refresh_token";

9
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -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"} {"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"}