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

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/),
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
### 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
# 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 раздельно)

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
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 <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.
"""
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":

View File

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

View File

@@ -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;"]

View File

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

View File

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

View File

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

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