From 53c89829a8d26646acfd12825d40fb1a91d826f3 Mon Sep 17 00:00:00 2001
From: Mikan <72257910+Mikan-DS@users.noreply.github.com>
Date: Fri, 19 Jun 2026 11:28:04 +0300
Subject: [PATCH] initial
---
.env.example | 25 +
.gitignore | 21 +
README.md | 110 +
backend/Dockerfile | 23 +
backend/app/__init__.py | 0
backend/app/api/__init__.py | 0
backend/app/api/admin.py | 144 +
backend/app/api/auth.py | 83 +
backend/app/api/misc.py | 65 +
backend/app/api/presets.py | 71 +
backend/app/api/sessions.py | 162 +
backend/app/api/worlds.py | 161 +
backend/app/config.py | 81 +
backend/app/core/__init__.py | 0
backend/app/core/llm.py | 243 +
backend/app/core/rag.py | 463 ++
backend/app/core/security.py | 44 +
backend/app/core/settings_service.py | 92 +
backend/app/core/state_validator.py | 108 +
backend/app/db.py | 49 +
backend/app/deps.py | 40 +
backend/app/engine/__init__.py | 0
backend/app/engine/context.py | 230 +
backend/app/engine/orchestrator.py | 443 ++
backend/app/engine/tools/__init__.py | 0
backend/app/engine/tools/tools.py | 295 ++
backend/app/engine/world_builder.py | 267 +
backend/app/logging_setup.py | 38 +
backend/app/main.py | 76 +
backend/app/migrations/__init__.py | 0
backend/app/migrations/init_db.py | 103 +
backend/app/models/__init__.py | 189 +
backend/app/prompts/__init__.py | 0
backend/app/prompts/fantasy_preset.py | 225 +
backend/app/prompts/templates.py | 295 ++
backend/app/schemas/__init__.py | 238 +
backend/app/workers/__init__.py | 0
backend/app/workers/main.py | 23 +
backend/app/workers/trigger_runner.py | 190 +
backend/requirements.txt | 22 +
docker-compose.yml | 122 +
frontend/Dockerfile | 23 +
frontend/index.html | 13 +
frontend/nginx.conf | 23 +
frontend/package-lock.json | 4388 +++++++++++++++++
frontend/package.json | 38 +
frontend/postcss.config.js | 6 +
frontend/public/.gitkeep | 0
frontend/src/App.tsx | 91 +
frontend/src/api/index.ts | 190 +
frontend/src/components/ui/Button.tsx | 37 +
frontend/src/components/ui/Card.tsx | 26 +
frontend/src/components/ui/Input.tsx | 70 +
frontend/src/components/ui/Modal.tsx | 60 +
frontend/src/components/ui/Navbar.tsx | 99 +
frontend/src/components/ui/cn.ts | 6 +
frontend/src/components/ui/ui-overview.ts | 5 +
.../src/components/world/CharacterSheet.tsx | 104 +
.../src/components/world/GlossaryModal.tsx | 60 +
frontend/src/i18n/en.ts | 168 +
frontend/src/i18n/index.ts | 24 +
frontend/src/i18n/ru.ts | 168 +
frontend/src/index.css | 59 +
frontend/src/main.tsx | 14 +
frontend/src/pages/AdminPanelPage.tsx | 468 ++
frontend/src/pages/AdminSetupPage.tsx | 91 +
frontend/src/pages/DashboardPage.tsx | 153 +
frontend/src/pages/HomePage.tsx | 76 +
frontend/src/pages/LoginPage.tsx | 72 +
frontend/src/pages/RegisterPage.tsx | 80 +
frontend/src/pages/SessionPage.tsx | 341 ++
frontend/src/pages/WorldBuilderPage.tsx | 266 +
frontend/src/pages/WorldCreatePage.tsx | 143 +
frontend/src/pages/WorldEditPage.tsx | 125 +
frontend/src/store/auth.ts | 24 +
frontend/src/types/index.ts | 131 +
frontend/tailwind.config.ts | 41 +
frontend/tsconfig.json | 25 +
frontend/tsconfig.node.json | 11 +
frontend/vite.config.ts | 22 +
80 files changed, 12482 insertions(+)
create mode 100644 .env.example
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 backend/Dockerfile
create mode 100644 backend/app/__init__.py
create mode 100644 backend/app/api/__init__.py
create mode 100644 backend/app/api/admin.py
create mode 100644 backend/app/api/auth.py
create mode 100644 backend/app/api/misc.py
create mode 100644 backend/app/api/presets.py
create mode 100644 backend/app/api/sessions.py
create mode 100644 backend/app/api/worlds.py
create mode 100644 backend/app/config.py
create mode 100644 backend/app/core/__init__.py
create mode 100644 backend/app/core/llm.py
create mode 100644 backend/app/core/rag.py
create mode 100644 backend/app/core/security.py
create mode 100644 backend/app/core/settings_service.py
create mode 100644 backend/app/core/state_validator.py
create mode 100644 backend/app/db.py
create mode 100644 backend/app/deps.py
create mode 100644 backend/app/engine/__init__.py
create mode 100644 backend/app/engine/context.py
create mode 100644 backend/app/engine/orchestrator.py
create mode 100644 backend/app/engine/tools/__init__.py
create mode 100644 backend/app/engine/tools/tools.py
create mode 100644 backend/app/engine/world_builder.py
create mode 100644 backend/app/logging_setup.py
create mode 100644 backend/app/main.py
create mode 100644 backend/app/migrations/__init__.py
create mode 100644 backend/app/migrations/init_db.py
create mode 100644 backend/app/models/__init__.py
create mode 100644 backend/app/prompts/__init__.py
create mode 100644 backend/app/prompts/fantasy_preset.py
create mode 100644 backend/app/prompts/templates.py
create mode 100644 backend/app/schemas/__init__.py
create mode 100644 backend/app/workers/__init__.py
create mode 100644 backend/app/workers/main.py
create mode 100644 backend/app/workers/trigger_runner.py
create mode 100644 backend/requirements.txt
create mode 100644 docker-compose.yml
create mode 100644 frontend/Dockerfile
create mode 100644 frontend/index.html
create mode 100644 frontend/nginx.conf
create mode 100644 frontend/package-lock.json
create mode 100644 frontend/package.json
create mode 100644 frontend/postcss.config.js
create mode 100644 frontend/public/.gitkeep
create mode 100644 frontend/src/App.tsx
create mode 100644 frontend/src/api/index.ts
create mode 100644 frontend/src/components/ui/Button.tsx
create mode 100644 frontend/src/components/ui/Card.tsx
create mode 100644 frontend/src/components/ui/Input.tsx
create mode 100644 frontend/src/components/ui/Modal.tsx
create mode 100644 frontend/src/components/ui/Navbar.tsx
create mode 100644 frontend/src/components/ui/cn.ts
create mode 100644 frontend/src/components/ui/ui-overview.ts
create mode 100644 frontend/src/components/world/CharacterSheet.tsx
create mode 100644 frontend/src/components/world/GlossaryModal.tsx
create mode 100644 frontend/src/i18n/en.ts
create mode 100644 frontend/src/i18n/index.ts
create mode 100644 frontend/src/i18n/ru.ts
create mode 100644 frontend/src/index.css
create mode 100644 frontend/src/main.tsx
create mode 100644 frontend/src/pages/AdminPanelPage.tsx
create mode 100644 frontend/src/pages/AdminSetupPage.tsx
create mode 100644 frontend/src/pages/DashboardPage.tsx
create mode 100644 frontend/src/pages/HomePage.tsx
create mode 100644 frontend/src/pages/LoginPage.tsx
create mode 100644 frontend/src/pages/RegisterPage.tsx
create mode 100644 frontend/src/pages/SessionPage.tsx
create mode 100644 frontend/src/pages/WorldBuilderPage.tsx
create mode 100644 frontend/src/pages/WorldCreatePage.tsx
create mode 100644 frontend/src/pages/WorldEditPage.tsx
create mode 100644 frontend/src/store/auth.ts
create mode 100644 frontend/src/types/index.ts
create mode 100644 frontend/tailwind.config.ts
create mode 100644 frontend/tsconfig.json
create mode 100644 frontend/tsconfig.node.json
create mode 100644 frontend/vite.config.ts
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..e898788
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,25 @@
+# === Database ===
+POSTGRES_DB=airpg
+POSTGRES_USER=airpg
+POSTGRES_PASSWORD=airpg_secret
+
+# === Auth ===
+JWT_SECRET=change_me_in_production_please
+# If empty, the backend will generate and print a one-time admin setup token to console on first run.
+ADMIN_SETUP_TOKEN=
+
+# === CORS ===
+CORS_ORIGINS=http://localhost:5173,http://localhost:8080
+
+# === Logging ===
+LOG_LEVEL=INFO
+
+# === Default LLM (overridable via admin panel) ===
+# Any OpenAI-compatible endpoint (vLLM, llama.cpp server, LM Studio, Ollama with /v1, OpenAI, OpenRouter, etc.)
+DEFAULT_LLM_BASE_URL=http://host.docker.internal:1234/v1
+DEFAULT_LLM_API_KEY=dummy
+DEFAULT_LLM_MODEL=local-model
+
+# === Frontend ===
+VITE_API_URL=http://localhost:8000
+FRONTEND_BUILD_TARGET=dev
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..cce0ce7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,21 @@
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+.Python
+*.egg-info/
+.venv/
+venv/
+env/
+.env
+
+node_modules/
+dist/
+.vite/
+
+.DS_Store
+*.log
+
+postgres_data/
+redis_data/
+qdrant_data/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..644cbf5
--- /dev/null
+++ b/README.md
@@ -0,0 +1,110 @@
+# AI RPG — гибкая ролевая игра с ИИ
+
+Веб-приложение для проведения ролевых игр с искусственным интеллектом.
+FastAPI (backend) + React/Vite/TypeScript (frontend) + PostgreSQL + Redis + Qdrant.
+Упаковано в Docker Compose.
+
+## Возможности
+
+- Регистрация / аутентификация пользователей
+- Первичная инициализация администратора по одноразовому токену (выводится в консоль backend при первом запуске)
+- Панель администратора: настройка OpenAI-совместимого endpoint'а, токена, модели, параметров генерации, лимитов контекста
+- Создание мира: пресет (встроенный Fantasy) или с нуля через многошаговый диалог с ИИ
+- ИИ генерирует: расширенный сеттинг, правила, JSON-схему состояния мира (характеристики, инвентарь, статы), начальную дату/время, общие рельсы сюжета
+- Игрок правит и подтверждает → запускается первая итерация
+- Сессия: чат-интерфейс со стримингом, глоссарий (RAG), лист персонажа, кнопки опций + свободный ввод
+- Итерация: ИИ использует инструменты (dice, RAG, обновление состояния, sub-агенты, отложенные триггеры) и пишет сценарный шаг + технический "за-кадровый" шаг
+- Контекстный менеджер: гарантированные последние N сообщений + динамическая граница с суммаризацией
+- Отложенные триггеры, привязанные к дате/времени мира (background worker)
+- Двуязычный UI (RU/EN)
+- Логирование всех LLM-вызовов в БД
+
+## Быстрый старт
+
+```bash
+# 1. Скопировать .env и при необходимости отредактировать
+cp .env.example .env
+
+# 2. Поднять стек
+docker compose up --build
+
+# 3. В логах backend найти строку:
+# "ADMIN_SETUP_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxx"
+# (или задать ADMIN_SETUP_TOKEN в .env вручную)
+
+# 4. Открыть http://localhost:5173
+# - Перейти на /admin/setup
+# - Ввести токен → создать учётку администратора
+# - Зайти в Admin Panel → настроить LLM endpoint
+
+# 5. Зарегистрировать обычного пользователя и начать создавать мир
+```
+
+## Архитектура
+
+```
+┌────────────┐ SSE ┌────────────────────────────┐
+│ Frontend │ <──────► │ FastAPI backend │
+│ React/Vite │ HTTP │ - auth (JWT) │
+└────────────┘ │ - admin / settings │
+ │ - worlds / sessions │
+ │ - engine (orchestrator) │
+ │ - tools (dice/rag/...) │
+ │ - context manager │
+ │ - trigger runner │
+ └──┬──────────┬──────────┬───┘
+ │ │ │
+ ┌────────▼─┐ ┌─────▼────┐ ┌───▼─────┐
+ │PostgreSQL│ │ Redis │ │ Qdrant │
+ │ (data) │ │ (queues) │ │ (RAG) │
+ └──────────┘ └──────────┘ └─────────┘
+```
+
+## Локальная разработка
+
+```bash
+# Backend hot reload
+docker compose up backend postgres redis qdrant
+
+# Frontend dev
+docker compose up frontend
+
+# Миграции (автоматически при старте backend, но можно вручную)
+docker compose exec backend python -m app.migrations.init_db
+```
+
+## Структура проекта
+
+```
+ai-rpg/
+├── docker-compose.yml
+├── .env.example
+├── backend/
+│ ├── Dockerfile
+│ ├── requirements.txt
+│ └── app/
+│ ├── main.py # FastAPI entrypoint
+│ ├── config.py # Settings
+│ ├── api/ # Routers
+│ ├── core/ # LLM client, security, rag
+│ ├── engine/ # Game orchestrator
+│ │ └── tools/ # LLM tool-call handlers
+│ ├── models/ # SQLAlchemy models
+│ ├── schemas/ # Pydantic schemas
+│ ├── prompts/ # Bilingual prompt templates
+│ └── workers/ # Background workers (triggers)
+└── frontend/
+ ├── Dockerfile
+ ├── package.json
+ └── src/
+ ├── api/ # API client
+ ├── components/ # UI components
+ ├── pages/ # Page components
+ ├── store/ # Zustand stores
+ ├── hooks/ # React hooks
+ └── i18n/ # RU/EN translations
+```
+
+## Лицензия
+
+MIT
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..9a8e8dd
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,23 @@
+FROM python:3.11-slim
+
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ PIP_NO_CACHE_DIR=1 \
+ PIP_DISABLE_PIP_VERSION_CHECK=1
+
+WORKDIR /app
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+EXPOSE 8000
+
+# Default: run uvicorn with hot reload for dev
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py
new file mode 100644
index 0000000..77ac52e
--- /dev/null
+++ b/backend/app/api/admin.py
@@ -0,0 +1,144 @@
+"""Admin panel routes: settings, LLM logs, users."""
+from __future__ import annotations
+
+from typing import Any, Dict, List
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from fastapi import APIRouter, Body, Depends, HTTPException
+
+from app.core.settings_service import EDITABLE_SETTING_KEYS, get_all_settings, update_settings
+from app.db import get_db_dep
+from app.deps import require_admin
+from app.models import LlmCallLog, Setting, User
+from app.schemas import LlmLogOut, SettingsOut, SettingsUpdate
+
+router = APIRouter(prefix="/api/admin", tags=["admin"])
+
+
+def _mask_secrets(values: Dict[str, Any]) -> Dict[str, Any]:
+ """Mask sensitive api_key fields in outbound responses."""
+ for k in ("llm.api_key", "embedding.api_key"):
+ v = values.get(k)
+ if isinstance(v, str) and v:
+ values[k] = v[:4] + "***" + v[-4:] if len(v) > 8 else "***"
+ # Never expose admin setup token via this endpoint
+ values.pop("admin.setup_token", None)
+ return values
+
+
+@router.get("/settings", response_model=SettingsOut)
+async def get_settings_endpoint(
+ db: AsyncSession = Depends(get_db_dep),
+ _: User = Depends(require_admin),
+):
+ values = await get_all_settings(db)
+ values = _mask_secrets(values)
+ return SettingsOut(values=values, editable_keys=sorted(EDITABLE_SETTING_KEYS.keys()))
+
+
+@router.put("/settings", response_model=SettingsOut)
+async def update_settings_endpoint(
+ payload: SettingsUpdate,
+ db: AsyncSession = Depends(get_db_dep),
+ _: User = Depends(require_admin),
+):
+ # Strip masked api_key fields unless the user typed a new value
+ cleaned: Dict[str, Any] = {}
+ for k, v in (payload.values or {}).items():
+ if k in ("llm.api_key", "embedding.api_key") and isinstance(v, str) and "***" in v:
+ continue
+ cleaned[k] = v
+ new_values = await update_settings(db, cleaned)
+ # If embedding settings changed, drop the cached RAG client so the next
+ # get_rag() call rebuilds it (and reconfigures Qdrant collections if dim changed).
+ if any(k.startswith("embedding.") for k in cleaned):
+ from app.core.rag import reset_rag
+ await reset_rag()
+ new_values = _mask_secrets(new_values)
+ return SettingsOut(values=new_values, editable_keys=sorted(EDITABLE_SETTING_KEYS.keys()))
+
+
+@router.post("/embeddings/test")
+async def test_embeddings_endpoint(
+ payload: Dict[str, Any] = Body(default={}),
+ db: AsyncSession = Depends(get_db_dep),
+ _: User = Depends(require_admin),
+):
+ """Probe the currently configured embeddings endpoint.
+
+ Accepts an optional `overrides` dict with embedding.* keys (e.g. to test
+ a new endpoint before saving). Returns: ok, provider, base_url, model,
+ dim, sample_norm (or error).
+ """
+ from app.core.rag import probe_embeddings
+ settings_map = await get_all_settings(db)
+ # Apply ad-hoc overrides (without saving) so the admin can try before save
+ overrides = (payload or {}).get("overrides") or {}
+ for k, v in overrides.items():
+ if k in EDITABLE_SETTING_KEYS:
+ settings_map[k] = v
+ return await probe_embeddings(settings_map)
+
+
+@router.get("/llm-logs", response_model=List[LlmLogOut])
+async def list_llm_logs(
+ limit: int = 50,
+ offset: int = 0,
+ db: AsyncSession = Depends(get_db_dep),
+ _: User = Depends(require_admin),
+):
+ result = await db.execute(
+ select(LlmCallLog).order_by(LlmCallLog.created_at.desc()).limit(min(limit, 200)).offset(offset)
+ )
+ return result.scalars().all()
+
+
+@router.get("/llm-logs/{log_id}")
+async def get_llm_log(
+ log_id: str,
+ db: AsyncSession = Depends(get_db_dep),
+ _: User = Depends(require_admin),
+):
+ from uuid import UUID
+ result = await db.execute(select(LlmCallLog).where(LlmCallLog.id == UUID(log_id)))
+ log = result.scalars().first()
+ if not log:
+ raise HTTPException(status_code=404, detail="log_not_found")
+ return {
+ "id": str(log.id),
+ "purpose": log.purpose,
+ "model": log.model,
+ "base_url": log.base_url,
+ "prompt_messages": log.prompt_messages,
+ "tools": log.tools,
+ "response_text": log.response_text,
+ "tool_calls": log.tool_calls,
+ "prompt_tokens": log.prompt_tokens,
+ "completion_tokens": log.completion_tokens,
+ "total_tokens": log.total_tokens,
+ "latency_ms": log.latency_ms,
+ "error": log.error,
+ "created_at": log.created_at.isoformat() if log.created_at else None,
+ }
+
+
+@router.get("/users")
+async def list_users(
+ db: AsyncSession = Depends(get_db_dep),
+ _: User = Depends(require_admin),
+):
+ result = await db.execute(select(User).order_by(User.created_at.desc()))
+ users = result.scalars().all()
+ return [
+ {
+ "id": str(u.id),
+ "email": u.email,
+ "username": u.username,
+ "is_admin": u.is_admin,
+ "is_active": u.is_active,
+ "created_at": u.created_at.isoformat() if u.created_at else None,
+ }
+ for u in users
+ ]
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
new file mode 100644
index 0000000..0485749
--- /dev/null
+++ b/backend/app/api/auth.py
@@ -0,0 +1,83 @@
+"""Authentication routes: register, login, me, admin setup."""
+from __future__ import annotations
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from fastapi import APIRouter, Depends, HTTPException, status
+
+from app.core.security import create_access_token, hash_password, verify_password
+from app.core.settings_service import get_setting
+from app.db import get_db_dep
+from app.deps import get_current_user
+from app.models import User
+from app.schemas import AdminSetupRequest, TokenOut, UserLogin, UserOut, UserRegister
+
+router = APIRouter(prefix="/api/auth", tags=["auth"])
+
+
+@router.post("/register", response_model=TokenOut, status_code=status.HTTP_201_CREATED)
+async def register(payload: UserRegister, db: AsyncSession = Depends(get_db_dep)):
+ existing = await db.execute(select(User).where((User.email == payload.email) | (User.username == payload.username)))
+ if existing.scalars().first():
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="user_already_exists")
+ user = User(
+ email=payload.email,
+ username=payload.username,
+ hashed_password=hash_password(payload.password),
+ is_admin=False,
+ )
+ db.add(user)
+ await db.commit()
+ await db.refresh(user)
+ token = create_access_token(subject=str(user.id), extra={"is_admin": user.is_admin})
+ return TokenOut(access_token=token, user=UserOut.model_validate(user))
+
+
+@router.post("/login", response_model=TokenOut)
+async def login(payload: UserLogin, db: AsyncSession = Depends(get_db_dep)):
+ result = await db.execute(select(User).where(User.email == payload.email))
+ user = result.scalars().first()
+ if not user or not verify_password(payload.password, user.hashed_password):
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_credentials")
+ if not user.is_active:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="user_disabled")
+ token = create_access_token(subject=str(user.id), extra={"is_admin": user.is_admin})
+ return TokenOut(access_token=token, user=UserOut.model_validate(user))
+
+
+@router.get("/me", response_model=UserOut)
+async def me(user: User = Depends(get_current_user)):
+ return user
+
+
+@router.post("/admin-setup", response_model=TokenOut)
+async def admin_setup(payload: AdminSetupRequest, db: AsyncSession = Depends(get_db_dep)):
+ """One-time endpoint to create the first admin user using a setup token."""
+ # Check if any admin already exists
+ existing_admins = await db.execute(select(User).where(User.is_admin.is_(True)))
+ if existing_admins.scalars().first() is not None:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="admin_already_exists")
+
+ # Validate setup token (from DB or env)
+ db_token = await get_setting(db, "admin.setup_token", default=None)
+ env_token = payload.token # what the user supplied
+ if not db_token or db_token != env_token:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="invalid_setup_token")
+
+ # Check user collision
+ existing = await db.execute(select(User).where((User.email == payload.email) | (User.username == payload.username)))
+ if existing.scalars().first():
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="user_already_exists")
+
+ user = User(
+ email=payload.email,
+ username=payload.username,
+ hashed_password=hash_password(payload.password),
+ is_admin=True,
+ )
+ db.add(user)
+ await db.commit()
+ await db.refresh(user)
+ token = create_access_token(subject=str(user.id), extra={"is_admin": user.is_admin})
+ return TokenOut(access_token=token, user=UserOut.model_validate(user))
diff --git a/backend/app/api/misc.py b/backend/app/api/misc.py
new file mode 100644
index 0000000..a47e1d6
--- /dev/null
+++ b/backend/app/api/misc.py
@@ -0,0 +1,65 @@
+"""Glossary + Triggers routes."""
+from __future__ import annotations
+
+from typing import List
+from uuid import UUID
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from fastapi import APIRouter, Depends, HTTPException
+
+from app.db import get_db_dep
+from app.deps import get_current_user
+from app.models import DeferredTrigger, GlossaryEntry, Session, User, World
+from app.schemas import GlossaryEntryOut, TriggerOut
+
+router = APIRouter(prefix="/api", tags=["misc"])
+
+
+@router.get("/worlds/{world_id}/glossary", response_model=List[GlossaryEntryOut])
+async def list_glossary(
+ world_id: UUID,
+ kind: str | None = None,
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ w_result = await db.execute(select(World).where(World.id == world_id))
+ world = w_result.scalars().first()
+ if not world:
+ raise HTTPException(status_code=404, detail="world_not_found")
+ if world.owner_id != user.id and not user.is_admin:
+ raise HTTPException(status_code=403, detail="forbidden")
+
+ q = select(GlossaryEntry).where(GlossaryEntry.world_id == world_id)
+ if kind:
+ q = q.where(GlossaryEntry.kind == kind)
+ q = q.order_by(GlossaryEntry.created_at.desc())
+ result = await db.execute(q)
+ return result.scalars().all()
+
+
+@router.get("/sessions/{session_id}/triggers", response_model=List[TriggerOut])
+async def list_triggers(
+ session_id: UUID,
+ include_fired: bool = True,
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ s_result = await db.execute(
+ select(Session).join(World, Session.world_id == World.id).where(Session.id == session_id)
+ )
+ session = s_result.scalars().first()
+ if not session:
+ raise HTTPException(status_code=404, detail="session_not_found")
+ w_result = await db.execute(select(World).where(World.id == session.world_id))
+ world = w_result.scalars().first()
+ if not world or (world.owner_id != user.id and not user.is_admin):
+ raise HTTPException(status_code=403, detail="forbidden")
+
+ q = select(DeferredTrigger).where(DeferredTrigger.session_id == session_id)
+ if not include_fired:
+ q = q.where(DeferredTrigger.fired.is_(False))
+ q = q.order_by(DeferredTrigger.fire_at)
+ result = await db.execute(q)
+ return result.scalars().all()
diff --git a/backend/app/api/presets.py b/backend/app/api/presets.py
new file mode 100644
index 0000000..056f4f2
--- /dev/null
+++ b/backend/app/api/presets.py
@@ -0,0 +1,71 @@
+"""Preset routes: list / get / create."""
+from __future__ import annotations
+
+from typing import List
+from uuid import UUID
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from fastapi import APIRouter, Depends, HTTPException
+
+from app.db import get_db_dep
+from app.deps import get_current_user
+from app.models import Preset, User
+from app.schemas import PresetCreate, PresetOut
+
+router = APIRouter(prefix="/api/presets", tags=["presets"])
+
+
+@router.get("", response_model=List[PresetOut])
+async def list_presets(
+ language: str | None = None,
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ """List public presets + user's private ones, optionally filtered by language."""
+ q = select(Preset).where(
+ (Preset.is_public.is_(True)) | (Preset.author_id == user.id)
+ )
+ if language:
+ q = q.where(Preset.language == language)
+ q = q.order_by(Preset.is_builtin.desc(), Preset.created_at.desc())
+ result = await db.execute(q)
+ return result.scalars().all()
+
+
+@router.get("/{preset_id}", response_model=PresetOut)
+async def get_preset(
+ preset_id: UUID,
+ db: AsyncSession = Depends(get_db_dep),
+ _: User = Depends(get_current_user),
+):
+ result = await db.execute(select(Preset).where(Preset.id == preset_id))
+ preset = result.scalars().first()
+ if not preset:
+ raise HTTPException(status_code=404, detail="preset_not_found")
+ if not preset.is_public and preset.author_id != _.id:
+ raise HTTPException(status_code=403, detail="forbidden")
+ return preset
+
+
+@router.post("", response_model=PresetOut, status_code=201)
+async def create_preset(
+ payload: PresetCreate,
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ preset = Preset(
+ slug=payload.slug,
+ title=payload.title,
+ description=payload.description,
+ language=payload.language,
+ is_public=payload.is_public,
+ is_builtin=False,
+ payload=payload.payload,
+ author_id=user.id,
+ )
+ db.add(preset)
+ await db.commit()
+ await db.refresh(preset)
+ return preset
diff --git a/backend/app/api/sessions.py b/backend/app/api/sessions.py
new file mode 100644
index 0000000..8aa9789
--- /dev/null
+++ b/backend/app/api/sessions.py
@@ -0,0 +1,162 @@
+"""Sessions routes: list / create / get / messages / start iteration (SSE)."""
+from __future__ import annotations
+
+import json
+from datetime import datetime, timezone
+from typing import List
+from uuid import UUID
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sse_starlette.sse import EventSourceResponse
+
+from app.db import get_db_dep
+from app.deps import get_current_user
+from app.engine.orchestrator import run_iteration
+from app.models import Message, Session, User, World
+from app.schemas import IterationRequest, MessageOut, SessionCreate, SessionOut
+
+router = APIRouter(prefix="/api/sessions", tags=["sessions"])
+
+
+@router.get("", response_model=List[SessionOut])
+async def list_sessions(
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ result = await db.execute(
+ select(Session)
+ .join(World, Session.world_id == World.id)
+ .where(World.owner_id == user.id)
+ .order_by(Session.last_played_at.desc().nullslast())
+ )
+ return result.scalars().all()
+
+
+@router.post("", response_model=SessionOut, status_code=201)
+async def create_session(
+ payload: SessionCreate,
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ # Verify world ownership
+ result = await db.execute(select(World).where(World.id == payload.world_id))
+ world = result.scalars().first()
+ if not world:
+ raise HTTPException(status_code=404, detail="world_not_found")
+ if world.owner_id != user.id and not user.is_admin:
+ raise HTTPException(status_code=403, detail="forbidden")
+ if world.status not in ("ready", "active"):
+ raise HTTPException(status_code=400, detail=f"world_not_ready: status={world.status}")
+
+ session = Session(
+ world_id=world.id,
+ title=payload.title or f"Сессия в мире «{world.name}»",
+ )
+ db.add(session)
+ # Mark world as active
+ world.status = "active"
+ await db.commit()
+ await db.refresh(session)
+ return session
+
+
+@router.get("/{session_id}", response_model=SessionOut)
+async def get_session(
+ session_id: UUID,
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ result = await db.execute(
+ select(Session).join(World, Session.world_id == World.id).where(Session.id == session_id)
+ )
+ session = result.scalars().first()
+ if not session:
+ raise HTTPException(status_code=404, detail="session_not_found")
+ # Verify ownership via world
+ w_result = await db.execute(select(World).where(World.id == session.world_id))
+ world = w_result.scalars().first()
+ if not world or (world.owner_id != user.id and not user.is_admin):
+ raise HTTPException(status_code=403, detail="forbidden")
+ return session
+
+
+@router.get("/{session_id}/messages", response_model=List[MessageOut])
+async def list_messages(
+ session_id: UUID,
+ include_hidden: bool = Query(False),
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ # Verify access
+ result = await db.execute(
+ select(Session).join(World, Session.world_id == World.id).where(Session.id == session_id)
+ )
+ session = result.scalars().first()
+ if not session:
+ raise HTTPException(status_code=404, detail="session_not_found")
+ w_result = await db.execute(select(World).where(World.id == session.world_id))
+ world = w_result.scalars().first()
+ if not world or (world.owner_id != user.id and not user.is_admin):
+ raise HTTPException(status_code=403, detail="forbidden")
+
+ q = select(Message).where(Message.session_id == session_id).order_by(Message.seq)
+ if not include_hidden:
+ q = q.where(Message.hidden.is_(False))
+ result = await db.execute(q)
+ return result.scalars().all()
+
+
+@router.post("/{session_id}/iterate")
+async def iterate_session(
+ session_id: UUID,
+ payload: IterationRequest,
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ """SSE stream of the iteration."""
+ # Verify access
+ result = await db.execute(
+ select(Session).join(World, Session.world_id == World.id).where(Session.id == session_id)
+ )
+ session = result.scalars().first()
+ if not session:
+ raise HTTPException(status_code=404, detail="session_not_found")
+ w_result = await db.execute(select(World).where(World.id == session.world_id))
+ world = w_result.scalars().first()
+ if not world or (world.owner_id != user.id and not user.is_admin):
+ raise HTTPException(status_code=403, detail="forbidden")
+ if payload.session_id != session_id:
+ raise HTTPException(status_code=400, detail="session_id_mismatch")
+
+ async def event_generator():
+ try:
+ async for event in run_iteration(db=db, user_id=user.id, session_id=session_id, action_text=payload.action_text):
+ yield {"event": event["type"], "data": json.dumps(event.get("data", {}), ensure_ascii=False, default=str)}
+ except Exception as e:
+ yield {"event": "error", "data": json.dumps({"message": str(e)}, ensure_ascii=False)}
+ yield {"event": "done", "data": "{}"}
+
+ return EventSourceResponse(event_generator())
+
+
+@router.delete("/{session_id}", status_code=204)
+async def delete_session(
+ session_id: UUID,
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ result = await db.execute(
+ select(Session).join(World, Session.world_id == World.id).where(Session.id == session_id)
+ )
+ session = result.scalars().first()
+ if not session:
+ raise HTTPException(status_code=404, detail="session_not_found")
+ w_result = await db.execute(select(World).where(World.id == session.world_id))
+ world = w_result.scalars().first()
+ if not world or (world.owner_id != user.id and not user.is_admin):
+ raise HTTPException(status_code=403, detail="forbidden")
+ await db.delete(session)
+ await db.commit()
diff --git a/backend/app/api/worlds.py b/backend/app/api/worlds.py
new file mode 100644
index 0000000..bd869ba
--- /dev/null
+++ b/backend/app/api/worlds.py
@@ -0,0 +1,161 @@
+"""Worlds routes: CRUD + world builder flow."""
+from __future__ import annotations
+
+from typing import List
+from uuid import UUID
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from fastapi import APIRouter, Depends, HTTPException
+
+from app.db import get_db_dep
+from app.deps import get_current_user
+from app.engine.world_builder import commit_world_builder, continue_world_builder, start_world_builder
+from app.models import User, World
+from app.schemas import (
+ WorldBuilderCommit,
+ WorldBuilderMessage,
+ WorldBuilderReply,
+ WorldBuilderStart,
+ WorldCreate,
+ WorldOut,
+ WorldUpdate,
+)
+
+router = APIRouter(prefix="/api/worlds", tags=["worlds"])
+
+
+@router.get("", response_model=List[WorldOut])
+async def list_worlds(
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ result = await db.execute(
+ select(World).where(World.owner_id == user.id).order_by(World.updated_at.desc())
+ )
+ return result.scalars().all()
+
+
+@router.get("/{world_id}", response_model=WorldOut)
+async def get_world(
+ world_id: UUID,
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ result = await db.execute(select(World).where(World.id == world_id))
+ world = result.scalars().first()
+ if not world:
+ raise HTTPException(status_code=404, detail="world_not_found")
+ if world.owner_id != user.id and not user.is_admin:
+ raise HTTPException(status_code=403, detail="forbidden")
+ return world
+
+
+@router.post("", response_model=WorldOut, status_code=201)
+async def create_world(
+ payload: WorldCreate,
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ world = World(
+ owner_id=user.id,
+ name=payload.name,
+ language=payload.language,
+ definition={},
+ state={},
+ status="draft",
+ preset_id=payload.preset_id,
+ )
+ db.add(world)
+ await db.commit()
+ await db.refresh(world)
+ return world
+
+
+@router.patch("/{world_id}", response_model=WorldOut)
+async def update_world(
+ world_id: UUID,
+ payload: WorldUpdate,
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ result = await db.execute(select(World).where(World.id == world_id))
+ world = result.scalars().first()
+ if not world:
+ raise HTTPException(status_code=404, detail="world_not_found")
+ if world.owner_id != user.id and not user.is_admin:
+ raise HTTPException(status_code=403, detail="forbidden")
+ for field, value in payload.model_dump(exclude_unset=True).items():
+ setattr(world, field, value)
+ await db.commit()
+ await db.refresh(world)
+ return world
+
+
+@router.delete("/{world_id}", status_code=204)
+async def delete_world(
+ world_id: UUID,
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ result = await db.execute(select(World).where(World.id == world_id))
+ world = result.scalars().first()
+ if not world:
+ raise HTTPException(status_code=404, detail="world_not_found")
+ if world.owner_id != user.id and not user.is_admin:
+ raise HTTPException(status_code=403, detail="forbidden")
+ await db.delete(world)
+ await db.commit()
+
+
+# === World Builder flow ===
+
+@router.post("/builder/start", response_model=WorldBuilderReply)
+async def builder_start(
+ payload: WorldBuilderStart,
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ try:
+ return await start_world_builder(
+ db=db,
+ user=user,
+ world_name=payload.world_name,
+ language=payload.language,
+ preset_id=payload.preset_id,
+ setting_brief=payload.setting_brief,
+ character_brief=payload.character_brief,
+ rules_brief=payload.rules_brief,
+ notes=payload.notes,
+ )
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"builder_start_failed: {e}")
+
+
+@router.post("/builder/continue", response_model=WorldBuilderReply)
+async def builder_continue(
+ payload: WorldBuilderMessage,
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ try:
+ return await continue_world_builder(db=db, user=user, session_id=payload.session_id, user_message=payload.message)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"builder_continue_failed: {e}")
+
+
+@router.post("/builder/commit", response_model=WorldOut)
+async def builder_commit(
+ payload: WorldBuilderCommit,
+ db: AsyncSession = Depends(get_db_dep),
+ user: User = Depends(get_current_user),
+):
+ try:
+ return await commit_world_builder(db=db, user=user, session_id=payload.session_id, name=payload.name)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"builder_commit_failed: {e}")
diff --git a/backend/app/config.py b/backend/app/config.py
new file mode 100644
index 0000000..7102d57
--- /dev/null
+++ b/backend/app/config.py
@@ -0,0 +1,81 @@
+"""Application configuration loaded from environment + DB-backed admin settings."""
+from __future__ import annotations
+
+import os
+import secrets
+from functools import lru_cache
+from typing import List
+
+from pydantic import Field, field_validator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False)
+
+ # Database
+ database_url: str = "postgresql+asyncpg://airpg:airpg_secret@localhost:5432/airpg"
+
+ # Redis
+ redis_url: str = "redis://localhost:6379/0"
+
+ # Qdrant
+ qdrant_url: str = "http://localhost:6333"
+
+ # Auth
+ jwt_secret: str = Field(default_factory=lambda: secrets.token_hex(32))
+ jwt_algorithm: str = "HS256"
+ access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
+
+ # Admin setup
+ # If empty, will be generated at first run and printed to console.
+ admin_setup_token: str = ""
+
+ # CORS
+ cors_origins: List[str] = Field(default_factory=lambda: ["http://localhost:5173"])
+
+ @field_validator("cors_origins", mode="before")
+ @classmethod
+ def _split_origins(cls, v):
+ if isinstance(v, str):
+ return [o.strip() for o in v.split(",") if o.strip()]
+ return v
+
+ # Logging
+ log_level: str = "INFO"
+
+ # Default LLM (used to seed DB on first run; overridable via admin panel)
+ default_llm_base_url: str = "http://localhost:1234/v1"
+ default_llm_api_key: str = "dummy"
+ default_llm_model: str = "local-model"
+
+ # Default embeddings / RAG settings (overridable via admin panel)
+ # provider="hash" is a deterministic offline fallback (no semantic quality).
+ # Switch to "openai" and point embedding.base_url at an OpenAI-compatible /embeddings endpoint
+ # for real semantic search.
+ default_embedding_provider: str = "hash"
+ default_embedding_base_url: str = "" # empty = reuse llm.base_url
+ default_embedding_api_key: str = "" # empty = reuse llm.api_key
+ default_embedding_model: str = "text-embedding-3-small"
+ default_embedding_dim: int = 0 # 0 = auto-probe from endpoint
+ default_embedding_request_timeout: int = 60
+
+ # Context manager defaults (admin-overridable)
+ default_recent_messages: int = 10
+ default_compress_threshold: int = 20
+ default_summary_messages: int = 10
+
+ # Worker mode flag
+ worker_mode: bool = False
+
+ @property
+ def is_worker(self) -> bool:
+ return bool(os.getenv("WORKER_MODE")) or self.worker_mode
+
+
+@lru_cache
+def get_settings() -> Settings:
+ return Settings()
+
+
+settings = get_settings()
diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/core/llm.py b/backend/app/core/llm.py
new file mode 100644
index 0000000..9222730
--- /dev/null
+++ b/backend/app/core/llm.py
@@ -0,0 +1,243 @@
+"""OpenAI-compatible LLM client with tool calling, streaming, and logging."""
+from __future__ import annotations
+
+import json
+import time
+import uuid
+from typing import Any, AsyncIterator, Dict, List, Optional
+
+import httpx
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.settings_service import get_all_settings, cast_setting
+from app.logging_setup import get_logger
+from app.models import LlmCallLog
+
+log = get_logger("llm")
+
+
+class LlmResponse:
+ """Non-streaming response wrapper."""
+
+ def __init__(self, text: str, tool_calls: List[Dict[str, Any]], usage: Optional[Dict[str, int]]):
+ self.text = text
+ self.tool_calls = tool_calls
+ self.usage = usage or {}
+
+
+class LlmClient:
+ """Lightweight OpenAI-compatible chat-completions client."""
+
+ def __init__(self, settings_map: Dict[str, Any]):
+ self.base_url: str = str(settings_map.get("llm.base_url", "")).rstrip("/")
+ self.api_key: str = str(settings_map.get("llm.api_key", "dummy"))
+ self.model: str = str(settings_map.get("llm.model", "local-model"))
+ self.temperature: float = float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7)))
+ self.max_tokens: int = int(cast_setting("llm.max_tokens", settings_map.get("llm.max_tokens", 1024)))
+ self.timeout: int = int(cast_setting("llm.request_timeout", settings_map.get("llm.request_timeout", 120)))
+ self.streaming: bool = bool(cast_setting("llm.streaming", settings_map.get("llm.streaming", True)))
+
+ @classmethod
+ async def from_db(cls, db: AsyncSession) -> "LlmClient":
+ s = await get_all_settings(db)
+ return cls(s)
+
+ def _headers(self) -> Dict[str, str]:
+ h = {"Content-Type": "application/json"}
+ if self.api_key and self.api_key != "dummy":
+ h["Authorization"] = f"Bearer {self.api_key}"
+ return h
+
+ async def chat(
+ self,
+ messages: List[Dict[str, Any]],
+ tools: Optional[List[Dict[str, Any]]] = None,
+ tool_choice: Any = None,
+ temperature: Optional[float] = None,
+ max_tokens: Optional[int] = None,
+ purpose: str = "orchestrator",
+ user_id: Optional[uuid.UUID] = None,
+ session_id: Optional[uuid.UUID] = None,
+ db: Optional[AsyncSession] = None,
+ ) -> LlmResponse:
+ """Non-streaming chat completion with tool support."""
+ url = f"{self.base_url}/chat/completions"
+ payload: Dict[str, Any] = {
+ "model": self.model,
+ "messages": messages,
+ "temperature": temperature if temperature is not None else self.temperature,
+ "max_tokens": max_tokens or self.max_tokens,
+ "stream": False,
+ }
+ if tools:
+ payload["tools"] = tools
+ if tool_choice is not None:
+ payload["tool_choice"] = tool_choice
+ started = time.monotonic()
+ err: Optional[str] = None
+ text = ""
+ tool_calls: List[Dict[str, Any]] = []
+ usage: Dict[str, int] = {}
+ try:
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
+ resp = await client.post(url, json=payload, headers=self._headers())
+ resp.raise_for_status()
+ data = resp.json()
+ choice = (data.get("choices") or [{}])[0]
+ msg = choice.get("message", {})
+ text = msg.get("content") or ""
+ tool_calls = msg.get("tool_calls") or []
+ usage = data.get("usage") or {}
+ except Exception as e:
+ err = f"{type(e).__name__}: {e}"
+ log.error("llm_call_failed", purpose=purpose, error=err)
+ raise
+ finally:
+ latency_ms = int((time.monotonic() - started) * 1000)
+ if db is not None:
+ db.add(LlmCallLog(
+ user_id=user_id,
+ session_id=session_id,
+ purpose=purpose,
+ model=self.model,
+ base_url=self.base_url,
+ prompt_messages=messages,
+ tools=tools,
+ response_text=text,
+ tool_calls=tool_calls,
+ prompt_tokens=usage.get("prompt_tokens"),
+ completion_tokens=usage.get("completion_tokens"),
+ total_tokens=usage.get("total_tokens"),
+ latency_ms=latency_ms,
+ error=err,
+ ))
+ try:
+ await db.commit()
+ except Exception:
+ await db.rollback()
+ return LlmResponse(text=text, tool_calls=tool_calls, usage=usage)
+
+ async def stream_chat(
+ self,
+ messages: List[Dict[str, Any]],
+ tools: Optional[List[Dict[str, Any]]] = None,
+ tool_choice: Any = None,
+ temperature: Optional[float] = None,
+ max_tokens: Optional[int] = None,
+ purpose: str = "orchestrator",
+ user_id: Optional[uuid.UUID] = None,
+ session_id: Optional[uuid.UUID] = None,
+ db: Optional[AsyncSession] = None,
+ ) -> AsyncIterator[Dict[str, Any]]:
+ """Streaming chat completion. Yields incremental deltas.
+
+ Yields dicts of the form:
+ {"type": "delta", "content": "..."} - text delta
+ {"type": "tool_calls", "tool_calls": [...]} - final tool calls (if any)
+ {"type": "done", "usage": {...}}
+ {"type": "error", "error": "..."}
+ """
+ url = f"{self.base_url}/chat/completions"
+ payload: Dict[str, Any] = {
+ "model": self.model,
+ "messages": messages,
+ "temperature": temperature if temperature is not None else self.temperature,
+ "max_tokens": max_tokens or self.max_tokens,
+ "stream": True,
+ }
+ if tools:
+ payload["tools"] = tools
+ if tool_choice is not None:
+ payload["tool_choice"] = tool_choice
+
+ started = time.monotonic()
+ full_text_parts: List[str] = []
+ tool_call_accum: Dict[int, Dict[str, Any]] = {}
+ usage: Dict[str, int] = {}
+ err: Optional[str] = None
+
+ try:
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
+ async with client.stream("POST", url, json=payload, headers=self._headers()) as resp:
+ resp.raise_for_status()
+ async for line in resp.aiter_lines():
+ if not line or not line.startswith("data:"):
+ continue
+ data_str = line[5:].strip()
+ if data_str == "[DONE]":
+ break
+ try:
+ chunk = json.loads(data_str)
+ except json.JSONDecodeError:
+ continue
+ choices = chunk.get("choices") or []
+ if not choices:
+ if chunk.get("usage"):
+ usage = chunk["usage"]
+ continue
+ delta = choices[0].get("delta", {})
+ if delta.get("content"):
+ full_text_parts.append(delta["content"])
+ yield {"type": "delta", "content": delta["content"]}
+ if delta.get("tool_calls"):
+ for tc in delta["tool_calls"]:
+ idx = tc.get("index", 0)
+ acc = tool_call_accum.setdefault(idx, {
+ "id": tc.get("id", ""),
+ "type": "function",
+ "function": {"name": "", "arguments": ""},
+ })
+ if tc.get("id"):
+ acc["id"] = tc["id"]
+ if tc.get("function", {}).get("name"):
+ acc["function"]["name"] += tc["function"]["name"]
+ if tc.get("function", {}).get("arguments"):
+ acc["function"]["arguments"] += tc["function"]["arguments"]
+ if chunk.get("usage"):
+ usage = chunk["usage"]
+ except Exception as e:
+ err = f"{type(e).__name__}: {e}"
+ log.error("llm_stream_failed", purpose=purpose, error=err)
+ yield {"type": "error", "error": err}
+ return
+
+ full_text = "".join(full_text_parts)
+ final_tool_calls = [tool_call_accum[i] for i in sorted(tool_call_accum.keys())]
+ if final_tool_calls:
+ yield {"type": "tool_calls", "tool_calls": final_tool_calls}
+ yield {"type": "done", "usage": usage, "full_text": full_text}
+
+ latency_ms = int((time.monotonic() - started) * 1000)
+ if db is not None:
+ db.add(LlmCallLog(
+ user_id=user_id,
+ session_id=session_id,
+ purpose=purpose,
+ model=self.model,
+ base_url=self.base_url,
+ prompt_messages=messages,
+ tools=tools,
+ response_text=full_text,
+ tool_calls=final_tool_calls,
+ prompt_tokens=usage.get("prompt_tokens"),
+ completion_tokens=usage.get("completion_tokens"),
+ total_tokens=usage.get("total_tokens"),
+ latency_ms=latency_ms,
+ error=err,
+ ))
+ try:
+ await db.commit()
+ except Exception:
+ await db.rollback()
+
+
+def build_tool_schema(name: str, description: str, params: Dict[str, Any]) -> Dict[str, Any]:
+ """Helper to build an OpenAI-style tool schema."""
+ return {
+ "type": "function",
+ "function": {
+ "name": name,
+ "description": description,
+ "parameters": params,
+ },
+ }
diff --git a/backend/app/core/rag.py b/backend/app/core/rag.py
new file mode 100644
index 0000000..7b4fec0
--- /dev/null
+++ b/backend/app/core/rag.py
@@ -0,0 +1,463 @@
+"""Qdrant RAG client: glossary / facts / history indexing and retrieval.
+
+Embeddings are configurable via admin settings (see `embedding.*` keys):
+
+ * `embedding.provider = "hash"` — deterministic offline fallback (no semantic quality).
+ * `embedding.provider = "openai"` — calls the OpenAI-compatible `/embeddings`
+ endpoint of `embedding.base_url` (falls back to `llm.base_url` if empty).
+
+Vector dimension (`embedding.dim`) is normally auto-probed from the endpoint on
+first use (set it to 0). When the configured dimension changes, the Qdrant
+collections are dropped and recreated — already-indexed points are lost, but
+they will be repopulated on the next RAG upsert from the engine.
+"""
+from __future__ import annotations
+
+import uuid
+from typing import Any, Dict, List, Optional
+
+import httpx
+from qdrant_client import AsyncQdrantClient
+from qdrant_client.http import models as qm
+
+from app.config import settings
+from app.core.settings_service import cast_setting
+from app.logging_setup import get_logger
+
+log = get_logger("rag")
+
+
+COLLECTION_GLOSSARY = "glossary"
+COLLECTION_HISTORY = "history"
+ALL_COLLECTIONS = (COLLECTION_GLOSSARY, COLLECTION_HISTORY)
+
+# Fallback dimension for the hash embedder (kept stable across restarts).
+HASH_EMBED_DIM = 384
+
+
+# ---------------------------------------------------------------------------
+# Embedders
+# ---------------------------------------------------------------------------
+class _HashEmbedder:
+ """Deterministic lightweight embedder used as an offline fallback.
+
+ Not semantically rich, but provides stable vectors for retrieval by keyword
+ overlap (bag-of-tokens hashed into a fixed-dim vector, L2-normalized).
+ """
+
+ def __init__(self, dim: int = HASH_EMBED_DIM):
+ self.dim = dim
+
+ async def embed(self, text: str) -> List[float]:
+ vec = [0.0] * self.dim
+ tokens = [t for t in text.lower().split() if t]
+ if not tokens:
+ return vec
+ for tok in tokens:
+ h = abs(hash(tok)) % self.dim
+ vec[h] += 1.0
+ h2 = abs(hash(tok + "_b")) % self.dim
+ vec[h2] += 0.5
+ norm = sum(v * v for v in vec) ** 0.5
+ if norm > 0:
+ vec = [v / norm for v in vec]
+ return vec
+
+ async def probe_dim(self) -> int:
+ return self.dim
+
+
+class OpenAIEmbedder:
+ """Real embeddings via OpenAI-compatible `/embeddings` endpoint.
+
+ Falls back to `_HashEmbedder` per-call if the endpoint is unreachable or
+ returns an error — so RAG keeps working even if the embeddings server is
+ temporarily down.
+ """
+
+ def __init__(
+ self,
+ base_url: str,
+ api_key: str,
+ model: str,
+ timeout: int = 60,
+ fallback_dim: int = HASH_EMBED_DIM,
+ ):
+ self.base_url = base_url.rstrip("/")
+ self.api_key = api_key
+ self.model = model or "text-embedding-3-small"
+ self.timeout = timeout
+ self._fallback = _HashEmbedder(fallback_dim)
+
+ def _headers(self) -> Dict[str, str]:
+ h = {"Content-Type": "application/json"}
+ if self.api_key and self.api_key != "dummy":
+ h["Authorization"] = f"Bearer {self.api_key}"
+ return h
+
+ async def _raw_embed(self, text: str) -> Optional[List[float]]:
+ url = f"{self.base_url}/embeddings"
+ payload = {"model": self.model, "input": text}
+ try:
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
+ resp = await client.post(url, json=payload, headers=self._headers())
+ resp.raise_for_status()
+ data = resp.json()
+ arr = (data.get("data") or [{}])[0].get("embedding") or []
+ if not arr:
+ return None
+ return [float(x) for x in arr]
+ except Exception as e:
+ log.warning("openai_embed_failed", model=self.model, error=f"{type(e).__name__}: {e}")
+ return None
+
+ async def embed(self, text: str) -> List[float]:
+ vec = await self._raw_embed(text)
+ if vec:
+ return vec
+ # Network/endpoint failure — degrade gracefully to hash fallback
+ return await self._fallback.embed(text)
+
+ async def probe_dim(self) -> int:
+ """Probe the endpoint with a short text and return the vector dimension.
+
+ Returns HASH_EMBED_DIM if the endpoint is unreachable so the system
+ keeps working (with degraded retrieval quality).
+ """
+ vec = await self._raw_embed("dimension probe")
+ if vec:
+ return len(vec)
+ log.warning("embed_probe_failed_using_hash_dim", dim=HASH_EMBED_DIM)
+ return HASH_EMBED_DIM
+
+
+# ---------------------------------------------------------------------------
+# RAG client
+# ---------------------------------------------------------------------------
+class RagClient:
+ """Qdrant-backed RAG client with configurable embeddings."""
+
+ def __init__(self, url: str | None = None):
+ url = url or settings.qdrant_url
+ self.client = AsyncQdrantClient(url=url)
+ # Cache of {collection_name: configured_dim}. Populated by ensure_collections.
+ self._collection_dims: Dict[str, int] = {}
+ # Lazily constructed embedder + its config signature (so we rebuild on settings change).
+ self._embedder: Optional[Any] = None
+ self._embedder_sig: Optional[str] = None
+ self._configured_dim: Optional[int] = None # resolved dim (after probe)
+
+ @staticmethod
+ def _resolve_embedder_config(settings_map: Dict[str, Any]) -> Dict[str, Any]:
+ provider = str(settings_map.get("embedding.provider", "hash")).lower().strip() or "hash"
+ base_url = str(settings_map.get("embedding.base_url", "") or "").strip()
+ if not base_url:
+ base_url = str(settings_map.get("llm.base_url", "") or "").strip()
+ api_key = str(settings_map.get("embedding.api_key", "") or "").strip()
+ if not api_key:
+ api_key = str(settings_map.get("llm.api_key", "") or "").strip()
+ model = str(settings_map.get("embedding.model", "text-embedding-3-small") or "text-embedding-3-small")
+ dim = int(cast_setting("embedding.dim", settings_map.get("embedding.dim", 0)) or 0)
+ timeout = int(cast_setting("embedding.request_timeout", settings_map.get("embedding.request_timeout", 60)) or 60)
+ return {
+ "provider": provider,
+ "base_url": base_url,
+ "api_key": api_key,
+ "model": model,
+ "dim": dim,
+ "timeout": timeout,
+ }
+
+ @staticmethod
+ def _build_embedder(cfg: Dict[str, Any]) -> Any:
+ if cfg["provider"] == "openai" and cfg["base_url"]:
+ return OpenAIEmbedder(
+ base_url=cfg["base_url"],
+ api_key=cfg["api_key"],
+ model=cfg["model"],
+ timeout=cfg["timeout"],
+ fallback_dim=HASH_EMBED_DIM,
+ )
+ return _HashEmbedder(HASH_EMBED_DIM)
+
+ def _embedder_signature(self, cfg: Dict[str, Any]) -> str:
+ # Only fields that affect the produced vector — `dim` is resolved via probe.
+ return f"{cfg['provider']}|{cfg['base_url']}|{cfg['model']}"
+
+ async def get_embedder(self, settings_map: Optional[Dict[str, Any]] = None) -> Any:
+ """Return the current embedder, rebuilding it if settings changed.
+
+ If `settings_map` is provided and the provider/base_url/model changed,
+ the embedder is rebuilt and Qdrant collections are reconfigured.
+ """
+ if settings_map is None:
+ # Caller has no DB context — return whatever is cached.
+ if self._embedder is None:
+ self._embedder = _HashEmbedder(HASH_EMBED_DIM)
+ self._embedder_sig = "hash||"
+ return self._embedder
+
+ cfg = RagClient._resolve_embedder_config(settings_map)
+ sig = self._embedder_signature(cfg)
+ if self._embedder is None or sig != self._embedder_sig:
+ self._embedder = RagClient._build_embedder(cfg)
+ self._embedder_sig = sig
+ self._configured_dim = None # force re-probe on next ensure_collections
+ await self.ensure_collections(settings_map)
+ return self._embedder
+
+ async def _resolve_dim(self, embedder: Any, cfg: Dict[str, Any]) -> int:
+ if cfg["dim"] and cfg["dim"] > 0:
+ return cfg["dim"]
+ if self._configured_dim is not None:
+ return self._configured_dim
+ # Auto-probe from the endpoint (or fallback to HASH_EMBED_DIM).
+ dim = await embedder.probe_dim()
+ self._configured_dim = dim
+ log.info("rag_dim_probed", dim=dim, provider=cfg["provider"])
+ return dim
+
+ async def ensure_collections(self, settings_map: Optional[Dict[str, Any]] = None) -> None:
+ """Create Qdrant collections if missing; recreate if dim changed.
+
+ Recreating drops all points — they will be repopulated by subsequent
+ upserts from the engine (glossary tool, history indexing).
+ """
+ cfg = RagClient._resolve_embedder_config(settings_map or {})
+ embedder = await self.get_embedder(settings_map)
+ desired_dim = await self._resolve_dim(embedder, cfg)
+
+ for name in ALL_COLLECTIONS:
+ existing_dim = await self._get_collection_dim(name)
+ if existing_dim is None:
+ try:
+ await self.client.create_collection(
+ collection_name=name,
+ vectors_config=qm.VectorParams(size=desired_dim, distance=qm.Distance.COSINE),
+ )
+ self._collection_dims[name] = desired_dim
+ log.info("rag_collection_created", name=name, dim=desired_dim)
+ except Exception as e:
+ log.warning("rag_collection_create_failed", name=name, error=str(e))
+ elif existing_dim != desired_dim:
+ log.warning(
+ "rag_collection_dim_mismatch_recreate",
+ name=name,
+ old=existing_dim,
+ new=desired_dim,
+ )
+ try:
+ await self.client.delete_collection(collection_name=name)
+ except Exception:
+ pass
+ try:
+ await self.client.create_collection(
+ collection_name=name,
+ vectors_config=qm.VectorParams(size=desired_dim, distance=qm.Distance.COSINE),
+ )
+ self._collection_dims[name] = desired_dim
+ except Exception as e:
+ log.warning("rag_collection_recreate_failed", name=name, error=str(e))
+ else:
+ self._collection_dims[name] = existing_dim
+
+ async def _get_collection_dim(self, name: str) -> Optional[int]:
+ try:
+ info = await self.client.get_collection(collection_name=name)
+ cfg = info.config.params.vectors
+ # Qdrant returns either a single VectorParams or a NamedVectors dict
+ if isinstance(cfg, qm.VectorParams):
+ return cfg.size
+ # NamedVectors: take first vector config
+ if hasattr(cfg, "size") and isinstance(cfg.size, int):
+ return cfg.size
+ if isinstance(cfg, dict):
+ for v in cfg.values():
+ if hasattr(v, "size") and isinstance(v.size, int):
+ return v.size
+ except Exception:
+ return None
+ return None
+
+ async def embed(self, text: str, settings_map: Optional[Dict[str, Any]] = None) -> List[float]:
+ embedder = await self.get_embedder(settings_map)
+ return await embedder.embed(text)
+
+ async def upsert_glossary(
+ self,
+ world_id: uuid.UUID,
+ entry_id: uuid.UUID,
+ kind: str,
+ name: str,
+ description: str,
+ payload: Dict[str, Any],
+ settings_map: Optional[Dict[str, Any]] = None,
+ ) -> None:
+ text = f"{kind}: {name}. {description}"
+ vector = await self.embed(text, settings_map)
+ await self.client.upsert(
+ collection_name=COLLECTION_GLOSSARY,
+ points=[
+ qm.PointStruct(
+ id=str(entry_id),
+ vector=vector,
+ payload={
+ "world_id": str(world_id),
+ "entry_id": str(entry_id),
+ "kind": kind,
+ "name": name,
+ "description": description,
+ "text": text,
+ **payload,
+ },
+ )
+ ],
+ )
+
+ async def upsert_history(
+ self,
+ session_id: uuid.UUID,
+ message_id: uuid.UUID,
+ seq: int,
+ text: str,
+ kind: str,
+ settings_map: Optional[Dict[str, Any]] = None,
+ ) -> None:
+ vector = await self.embed(text, settings_map)
+ await self.client.upsert(
+ collection_name=COLLECTION_HISTORY,
+ points=[
+ qm.PointStruct(
+ id=str(message_id),
+ vector=vector,
+ payload={
+ "session_id": str(session_id),
+ "message_id": str(message_id),
+ "seq": seq,
+ "kind": kind,
+ "text": text,
+ },
+ )
+ ],
+ )
+
+ async def search_glossary(
+ self,
+ world_id: uuid.UUID,
+ query: str,
+ limit: int = 5,
+ settings_map: Optional[Dict[str, Any]] = None,
+ ) -> List[Dict[str, Any]]:
+ try:
+ vector = await self.embed(query, settings_map)
+ res = await self.client.search(
+ collection_name=COLLECTION_GLOSSARY,
+ query_vector=vector,
+ query_filter=qm.Filter(
+ must=[qm.FieldCondition(key="world_id", match=qm.MatchValue(value=str(world_id)))]
+ ),
+ limit=limit,
+ with_payload=True,
+ )
+ return [r.payload for r in res]
+ except Exception as e:
+ log.warning("rag_search_glossary_failed", error=str(e))
+ return []
+
+ async def search_history(
+ self,
+ session_id: uuid.UUID,
+ query: str,
+ limit: int = 5,
+ settings_map: Optional[Dict[str, Any]] = None,
+ ) -> List[Dict[str, Any]]:
+ try:
+ vector = await self.embed(query, settings_map)
+ res = await self.client.search(
+ collection_name=COLLECTION_HISTORY,
+ query_vector=vector,
+ query_filter=qm.Filter(
+ must=[qm.FieldCondition(key="session_id", match=qm.MatchValue(value=str(session_id)))]
+ ),
+ limit=limit,
+ with_payload=True,
+ )
+ return [r.payload for r in res]
+ except Exception as e:
+ log.warning("rag_search_history_failed", error=str(e))
+ return []
+
+ async def delete_history(self, session_id: uuid.UUID) -> None:
+ try:
+ await self.client.delete(
+ collection_name=COLLECTION_HISTORY,
+ points_selector=qm.FilterSelector(
+ filter=qm.Filter(must=[qm.FieldCondition(key="session_id", match=qm.MatchValue(value=str(session_id)))])
+ ),
+ )
+ except Exception:
+ pass
+
+
+# ---------------------------------------------------------------------------
+# Singleton + cache invalidation
+# ---------------------------------------------------------------------------
+_rag: Optional[RagClient] = None
+
+
+async def get_rag(settings_map: Optional[Dict[str, Any]] = None) -> RagClient:
+ """Get the shared RagClient, ensuring collections are configured for the
+ current embedding settings.
+
+ Pass `settings_map` from DB on the first call (or whenever settings may
+ have changed) so the client can rebuild its embedder and reconfigure
+ Qdrant collections if `embedding.provider` / `embedding.base_url` /
+ `embedding.model` / `embedding.dim` changed.
+ """
+ global _rag
+ if _rag is None:
+ _rag = RagClient()
+ await _rag.ensure_collections(settings_map)
+ elif settings_map is not None:
+ # Re-check embedder signature; ensure_collections runs only if changed.
+ await _rag.get_embedder(settings_map)
+ return _rag
+
+
+async def reset_rag() -> None:
+ """Drop the cached RAG client so the next `get_rag()` rebuilds it from
+ current settings. Call this after admin updates embedding.* settings.
+ """
+ global _rag
+ _rag = None
+
+
+async def probe_embeddings(settings_map: Dict[str, Any]) -> Dict[str, Any]:
+ """Standalone probe used by the admin "Test embeddings" button.
+
+ Returns dict with: ok, provider, base_url, model, dim, sample_norm, error.
+ Does not touch the shared singleton or Qdrant.
+ """
+ cfg = RagClient._resolve_embedder_config(settings_map)
+ embedder = RagClient._build_embedder(cfg)
+ try:
+ vec = await embedder.embed("RAG embedding probe: a brave adventurer enters a tavern.")
+ if not vec:
+ return {"ok": False, "provider": cfg["provider"], "error": "empty_vector"}
+ norm = sum(v * v for v in vec) ** 0.5
+ return {
+ "ok": True,
+ "provider": cfg["provider"],
+ "base_url": cfg["base_url"],
+ "model": cfg["model"],
+ "dim": len(vec),
+ "sample_norm": round(norm, 4),
+ }
+ except Exception as e:
+ return {
+ "ok": False,
+ "provider": cfg["provider"],
+ "base_url": cfg["base_url"],
+ "model": cfg["model"],
+ "error": f"{type(e).__name__}: {e}",
+ }
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
new file mode 100644
index 0000000..7251ef7
--- /dev/null
+++ b/backend/app/core/security.py
@@ -0,0 +1,44 @@
+"""Security: password hashing + JWT."""
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+from jose import JWTError, jwt
+from passlib.context import CryptContext
+
+from app.config import settings
+
+_pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+
+def hash_password(password: str) -> str:
+ return _pwd_ctx.hash(password)
+
+
+def verify_password(plain: str, hashed: str) -> bool:
+ try:
+ return _pwd_ctx.verify(plain, hashed)
+ except Exception:
+ return False
+
+
+def create_access_token(subject: str, extra: dict[str, Any] | None = None) -> str:
+ now = datetime.now(timezone.utc)
+ payload = {
+ "sub": subject,
+ "iat": now,
+ "exp": now + timedelta(minutes=settings.access_token_expire_minutes),
+ "type": "access",
+ }
+ if extra:
+ payload.update(extra)
+ return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
+
+
+def decode_access_token(token: str) -> dict[str, Any] | None:
+ try:
+ payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
+ return payload
+ except JWTError:
+ return None
diff --git a/backend/app/core/settings_service.py b/backend/app/core/settings_service.py
new file mode 100644
index 0000000..52f6449
--- /dev/null
+++ b/backend/app/core/settings_service.py
@@ -0,0 +1,92 @@
+"""Admin settings service (DB-backed)."""
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models import Setting
+
+
+# Settings that can be edited by admin via the admin panel
+EDITABLE_SETTING_KEYS = {
+ "llm.base_url": str,
+ "llm.api_key": str,
+ "llm.model": str,
+ "llm.temperature": float,
+ "llm.step_temperature": float,
+ "llm.summary_temperature": float,
+ "llm.max_tokens": int,
+ "llm.request_timeout": int,
+ "llm.streaming": bool,
+ "context.recent_messages": int,
+ "context.compress_threshold": int,
+ "context.summary_messages": int,
+ "context.max_tokens_total": int,
+ "triggers.enabled": bool,
+ "triggers.check_interval": int,
+ # Embeddings / RAG
+ "embedding.provider": str, # "hash" | "openai"
+ "embedding.base_url": str, # OpenAI-compatible base URL (e.g. http://localhost:1234/v1)
+ "embedding.api_key": str, # API key (may be empty for local servers)
+ "embedding.model": str, # e.g. text-embedding-3-small, bge-m3, nomic-embed-text
+ "embedding.dim": int, # vector dimension; 0 = auto-probe from endpoint
+ "embedding.request_timeout": int, # request timeout, seconds
+}
+
+
+async def get_all_settings(db: AsyncSession) -> Dict[str, Any]:
+ result = await db.execute(select(Setting))
+ return {row.key: row.value for row in result.scalars().all()}
+
+
+async def get_setting(db: AsyncSession, key: str, default: Any = None) -> Any:
+ result = await db.execute(select(Setting).where(Setting.key == key))
+ row = result.scalars().first()
+ return row.value if row else default
+
+
+async def update_settings(db: AsyncSession, updates: Dict[str, Any]) -> Dict[str, Any]:
+ for key, value in updates.items():
+ if key not in EDITABLE_SETTING_KEYS:
+ continue
+ expected = EDITABLE_SETTING_KEYS[key]
+ try:
+ if expected is bool:
+ value = bool(value)
+ elif expected is int:
+ value = int(value)
+ elif expected is float:
+ value = float(value)
+ else:
+ value = str(value)
+ except (TypeError, ValueError):
+ continue
+ result = await db.execute(select(Setting).where(Setting.key == key))
+ row = result.scalars().first()
+ if row is None:
+ db.add(Setting(key=key, value=value))
+ else:
+ row.value = value
+ await db.commit()
+ return await get_all_settings(db)
+
+
+def cast_setting(key: str, value: Any) -> Any:
+ """Cast raw DB value to the expected type for use."""
+ if key not in EDITABLE_SETTING_KEYS:
+ return value
+ expected = EDITABLE_SETTING_KEYS[key]
+ try:
+ if expected is bool:
+ if isinstance(value, str):
+ return value.lower() in ("1", "true", "yes", "on")
+ return bool(value)
+ if expected is int:
+ return int(value)
+ if expected is float:
+ return float(value)
+ return str(value)
+ except (TypeError, ValueError):
+ return value
diff --git a/backend/app/core/state_validator.py b/backend/app/core/state_validator.py
new file mode 100644
index 0000000..2d1632d
--- /dev/null
+++ b/backend/app/core/state_validator.py
@@ -0,0 +1,108 @@
+"""World-state JSON schema validator (player/NPC stats, inventory, etc.)."""
+from __future__ import annotations
+
+from typing import Any, Dict, List, Tuple
+
+from jsonschema import ValidationError, validate
+
+from app.logging_setup import get_logger
+
+log = get_logger("state_validator")
+
+
+def validate_state(state: Dict[str, Any], schema: Dict[str, Any]) -> Tuple[bool, List[str]]:
+ """Validate state against world's JSON Schema. Returns (ok, errors)."""
+ if not schema:
+ return True, []
+ try:
+ validate(instance=state, schema=schema)
+ return True, []
+ except ValidationError as e:
+ return False, [f"{e.message} at path {list(e.absolute_path)}"]
+ except Exception as e:
+ return False, [f"schema_error: {e}"]
+
+
+def apply_patch(state: Dict[str, Any], patch: Dict[str, Any]) -> Dict[str, Any]:
+ """Apply a JSON-patch-like update to state.
+
+ Patch format:
+ {"set": {"path.to.field": value, ...},
+ "unset": ["path.to.field", ...],
+ "append": {"path.to.list": value, ...},
+ "increment": {"path.to.number": delta, ...}}
+
+ Paths use dot notation. Creates intermediate dicts as needed.
+ """
+ if not patch:
+ return state
+ new_state = _deep_copy(state)
+
+ for op, items in patch.items():
+ if op == "set":
+ for path, value in items.items():
+ _set_path(new_state, path, value)
+ elif op == "unset":
+ for path in items:
+ _unset_path(new_state, path)
+ elif op == "append":
+ for path, value in items.items():
+ lst = _get_path(new_state, path) or []
+ if not isinstance(lst, list):
+ lst = []
+ lst.append(value)
+ _set_path(new_state, path, lst)
+ elif op == "increment":
+ for path, delta in items.items():
+ cur = _get_path(new_state, path) or 0
+ try:
+ cur = float(cur)
+ except (TypeError, ValueError):
+ cur = 0
+ _set_path(new_state, path, cur + delta)
+ elif op == "remove":
+ for path, value in items.items():
+ lst = _get_path(new_state, path) or []
+ if isinstance(lst, list):
+ lst = [x for x in lst if x != value]
+ _set_path(new_state, path, lst)
+ return new_state
+
+
+def _deep_copy(obj: Any) -> Any:
+ if isinstance(obj, dict):
+ return {k: _deep_copy(v) for k, v in obj.items()}
+ if isinstance(obj, list):
+ return [_deep_copy(v) for v in obj]
+ return obj
+
+
+def _get_path(obj: Any, path: str) -> Any:
+ cur = obj
+ for part in path.split("."):
+ if isinstance(cur, dict) and part in cur:
+ cur = cur[part]
+ else:
+ return None
+ return cur
+
+
+def _set_path(obj: Dict[str, Any], path: str, value: Any) -> None:
+ cur = obj
+ parts = path.split(".")
+ for part in parts[:-1]:
+ if part not in cur or not isinstance(cur[part], dict):
+ cur[part] = {}
+ cur = cur[part]
+ cur[parts[-1]] = value
+
+
+def _unset_path(obj: Dict[str, Any], path: str) -> None:
+ cur = obj
+ parts = path.split(".")
+ for part in parts[:-1]:
+ if not isinstance(cur, dict) or part not in cur:
+ return
+ cur = cur[part]
+ if isinstance(cur, dict):
+ cur.pop(parts[-1], None)
diff --git a/backend/app/db.py b/backend/app/db.py
new file mode 100644
index 0000000..631f024
--- /dev/null
+++ b/backend/app/db.py
@@ -0,0 +1,49 @@
+"""Database engine + session factory."""
+from __future__ import annotations
+
+from contextlib import asynccontextmanager
+from typing import AsyncIterator
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy.orm import DeclarativeBase
+
+from app.config import settings
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+_engine_kwargs: dict = dict(
+ echo=False,
+ pool_pre_ping=True,
+)
+# Pool size params only for Postgres/MySQL (not SQLite)
+if "sqlite" not in settings.database_url:
+ _engine_kwargs.update(pool_size=10, max_overflow=20)
+
+engine = create_async_engine(settings.database_url, **_engine_kwargs)
+
+AsyncSessionLocal = async_sessionmaker(
+ engine, class_=AsyncSession, expire_on_commit=False, autoflush=False
+)
+
+
+@asynccontextmanager
+async def get_db() -> AsyncIterator[AsyncSession]:
+ async with AsyncSessionLocal() as session:
+ try:
+ yield session
+ await session.commit()
+ except Exception:
+ await session.rollback()
+ raise
+
+
+async def get_db_dep() -> AsyncIterator[AsyncSession]:
+ """FastAPI dependency."""
+ async with AsyncSessionLocal() as session:
+ try:
+ yield session
+ finally:
+ await session.close()
diff --git a/backend/app/deps.py b/backend/app/deps.py
new file mode 100644
index 0000000..f0b33f3
--- /dev/null
+++ b/backend/app/deps.py
@@ -0,0 +1,40 @@
+"""FastAPI dependencies: DB, current user, admin-only."""
+from __future__ import annotations
+
+from typing import AsyncIterator
+
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.security import decode_access_token
+from app.db import get_db_dep
+from app.models import User
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
+
+
+async def get_current_user(
+ token: str | None = Depends(oauth2_scheme),
+ db: AsyncSession = Depends(get_db_dep),
+) -> User:
+ if not token:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing_token")
+ payload = decode_access_token(token)
+ if not payload or payload.get("type") != "access":
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_token")
+ user_id = payload.get("sub")
+ if not user_id:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_token")
+ result = await db.execute(select(User).where(User.id == user_id))
+ user = result.scalars().first()
+ if not user or not user.is_active:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user_not_found")
+ return user
+
+
+async def require_admin(user: User = Depends(get_current_user)) -> User:
+ if not user.is_admin:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin_required")
+ return user
diff --git a/backend/app/engine/__init__.py b/backend/app/engine/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/engine/context.py b/backend/app/engine/context.py
new file mode 100644
index 0000000..112c857
--- /dev/null
+++ b/backend/app/engine/context.py
@@ -0,0 +1,230 @@
+"""Context manager: builds the LLM prompt context with guaranteed-recent + dynamic summarization."""
+from __future__ import annotations
+
+import json
+import uuid
+from typing import Any, Dict, List, Optional
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.llm import LlmClient
+from app.core.settings_service import cast_setting, get_all_settings
+from app.logging_setup import get_logger
+from app.models import Message, World
+from app.prompts.templates import get_prompt
+
+log = get_logger("context")
+
+
+async def build_orchestrator_messages(
+ db: AsyncSession,
+ world: World,
+ session_id: uuid.UUID,
+ action_text: str,
+) -> tuple[List[Dict[str, Any]], Dict[str, Any]]:
+ """Build the messages list for the orchestrator LLM call.
+
+ Returns (messages, settings_used).
+ """
+ settings_map = await get_all_settings(db)
+ recent_n = int(cast_setting("context.recent_messages", settings_map.get("context.recent_messages", 10)))
+ threshold = int(cast_setting("context.compress_threshold", settings_map.get("context.compress_threshold", 20)))
+ summary_n = int(cast_setting("context.summary_messages", settings_map.get("context.summary_messages", 10)))
+
+ # Load all messages ordered by seq
+ result = await db.execute(
+ select(Message).where(Message.session_id == session_id).order_by(Message.seq)
+ )
+ all_msgs: List[Message] = list(result.scalars().all())
+
+ # Check if we need to compress
+ if len(all_msgs) >= threshold:
+ await _maybe_compress(db, session_id, all_msgs, summary_n, recent_n, world, settings_map)
+ # Reload after compression
+ result = await db.execute(
+ select(Message).where(Message.session_id == session_id).order_by(Message.seq)
+ )
+ all_msgs = list(result.scalars().all())
+
+ # Get summary message (the latest summary before the recent window)
+ summary_text = ""
+ visible_msgs = [m for m in all_msgs if not m.hidden]
+ if len(visible_msgs) > recent_n:
+ # Look for the latest summary
+ summaries = [m for m in all_msgs if m.kind == "summary"]
+ if summaries:
+ summary_text = summaries[-1].content
+
+ recent = visible_msgs[-recent_n:] if visible_msgs else []
+
+ # Build orchestrator system prompt with current state
+ defn = world.definition or {}
+ system_prompt_template = get_prompt("orchestrator", world.language)
+ player_state = world.state.get("player", {}) if world.state else {}
+ system_prompt = system_prompt_template.format(
+ world_name=world.name,
+ setting_description=defn.get("setting_description", "")[:800],
+ rules=json.dumps(defn.get("rules", {}), ensure_ascii=False)[:600],
+ current_time=world.current_time or "",
+ player_state=json.dumps(player_state, ensure_ascii=False)[:600],
+ plot_rails=json.dumps(defn.get("plot_rails", {}), ensure_ascii=False)[:400],
+ summary=summary_text or "(нет сводки)",
+ )
+
+ messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}]
+
+ # Add summary as a system note if present
+ if summary_text:
+ messages.append({"role": "system", "content": f"Сводка прошлого:\n{summary_text}"})
+
+ # Add recent visible messages
+ for m in recent:
+ if m.kind == "player_action":
+ messages.append({"role": "user", "content": m.content})
+ elif m.kind == "narrative_step":
+ messages.append({"role": "assistant", "content": m.content})
+
+ # The current action
+ messages.append({"role": "user", "content": f'Действие игрока: "{action_text}"'})
+
+ return messages, settings_map
+
+
+async def _maybe_compress(
+ db: AsyncSession,
+ session_id: uuid.UUID,
+ all_msgs: List[Message],
+ summary_n: int,
+ recent_n: int,
+ world: World,
+ settings_map: Dict[str, Any],
+) -> None:
+ """If history exceeds threshold, summarize older messages into a single summary message."""
+ visible = [m for m in all_msgs if not m.hidden]
+ if len(visible) <= recent_n + summary_n:
+ return
+
+ # Take the messages that will be summarized (everything before the recent window)
+ to_summarize = visible[:-recent_n]
+ if not to_summarize:
+ return
+
+ # Build summarization input
+ summary_input_lines = []
+ for m in to_summarize:
+ prefix = {
+ "player_action": "Игрок",
+ "narrative_step": "Сцена",
+ "summary": "Сводка",
+ "orchestrator_plan": "GM",
+ "technical_offscreen": "За кадром",
+ }.get(m.kind, m.kind)
+ summary_input_lines.append(f"{prefix}: {m.content[:300]}")
+ summary_input = "\n\n".join(summary_input_lines)
+
+ llm = LlmClient(settings_map)
+ system_prompt = get_prompt("summarizer", world.language)
+ response = await llm.chat(
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": summary_input[:4000]},
+ ],
+ temperature=float(cast_setting("llm.summary_temperature", settings_map.get("llm.summary_temperature", 0.3))),
+ max_tokens=300,
+ purpose="summary",
+ session_id=session_id,
+ db=db,
+ )
+
+ # Parse summary response
+ summary_text = response.text
+ facts: List[Dict[str, Any]] = []
+ import re as _re
+ json_match = _re.search(r"\{[\s\S]*\}", response.text)
+ if json_match:
+ try:
+ data = json.loads(json_match.group(0))
+ summary_text = data.get("summary", response.text)
+ facts = data.get("facts", [])
+ except json.JSONDecodeError:
+ pass
+
+ # Create summary message
+ next_seq = (max((m.seq for m in all_msgs), default=0)) + 1
+ summary_msg = Message(
+ session_id=session_id,
+ seq=next_seq,
+ role="system",
+ kind="summary",
+ content=summary_text,
+ payload={"summarized_count": len(to_summarize), "facts": facts},
+ is_pinned=True,
+ hidden=False,
+ )
+ db.add(summary_msg)
+
+ # Hide the summarized messages (but keep them in DB)
+ for m in to_summarize:
+ m.hidden = True
+
+ # Index facts into RAG glossary
+ if facts:
+ from app.core.rag import get_rag
+ from app.models import GlossaryEntry
+ rag = await get_rag(settings_map)
+ for f in facts:
+ if not isinstance(f, dict):
+ continue
+ entry = GlossaryEntry(
+ world_id=world.id,
+ session_id=session_id,
+ kind=f.get("kind", "lore"),
+ name=f.get("name", "unknown"),
+ description=f.get("description", ""),
+ payload={},
+ )
+ db.add(entry)
+ await db.flush()
+ await rag.upsert_glossary(
+ world_id=world.id,
+ entry_id=entry.id,
+ kind=entry.kind,
+ name=entry.name,
+ description=entry.description,
+ payload={},
+ settings_map=settings_map,
+ )
+
+ await db.commit()
+ log.info("context_compressed", session_id=str(session_id), summarized=len(to_summarize))
+
+
+async def build_step_writer_messages(
+ db: AsyncSession,
+ world: World,
+ session_id: uuid.UUID,
+ outcome: str,
+ narrative_prompt: str,
+) -> List[Dict[str, Any]]:
+ """Build messages for the step writer LLM call."""
+ defn = world.definition or {}
+ player_state = world.state.get("player", {}) if world.state else {}
+ system_prompt = get_prompt("step_writer", world.language).format(
+ setting_description=defn.get("setting_description", "")[:600],
+ current_time=world.current_time or "",
+ player_state=json.dumps(player_state, ensure_ascii=False)[:400],
+ outcome=outcome,
+ narrative_prompt=narrative_prompt[:600],
+ )
+ return [{"role": "system", "content": system_prompt}]
+
+
+async def build_subagent_messages(
+ world: World,
+ task: str,
+ context: str,
+) -> List[Dict[str, Any]]:
+ """Build messages for a clean-context sub-agent call."""
+ system_prompt = get_prompt("subagent", world.language).format(task=task, context=context[:600])
+ return [{"role": "system", "content": system_prompt}]
diff --git a/backend/app/engine/orchestrator.py b/backend/app/engine/orchestrator.py
new file mode 100644
index 0000000..0a667d5
--- /dev/null
+++ b/backend/app/engine/orchestrator.py
@@ -0,0 +1,443 @@
+"""Game orchestrator: runs the multi-step LLM tool-calling loop and produces a narrative step."""
+from __future__ import annotations
+
+import json
+import uuid
+from datetime import datetime, timezone
+from typing import Any, AsyncIterator, Dict, List, Optional
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.llm import LlmClient
+from app.core.settings_service import cast_setting, get_all_settings
+from app.engine.context import (
+ build_orchestrator_messages,
+ build_step_writer_messages,
+ build_subagent_messages,
+)
+from app.engine.tools.tools import ALL_TOOL_SCHEMAS, ToolContext, handle_tool_call
+from app.logging_setup import get_logger
+from app.models import DeferredTrigger, Message, Session, World
+from app.prompts.templates import get_prompt
+
+log = get_logger("orchestrator")
+
+
+async def run_iteration(
+ db: AsyncSession,
+ user_id: uuid.UUID,
+ session_id: uuid.UUID,
+ action_text: str,
+) -> AsyncIterator[Dict[str, Any]]:
+ """Run one full iteration: plan -> tools -> step -> technical side-effects.
+
+ Yields SSE-ready event dicts:
+ {"type": "status", "data": {"message": "..."}}
+ {"type": "plan", "data": {...}} # orchestrator plan with tool calls
+ {"type": "tool_call", "data": {"name": ..., "args": ..., "result": ...}}
+ {"type": "narrative_chunk", "data": {"content": "..."}}
+ {"type": "step_complete", "data": {"message_id": ..., "options": [...], "state": ...}}
+ {"type": "error", "data": {"message": "..."}}
+ {"type": "done", "data": {}}
+ """
+ # Load session + world
+ result = await db.execute(select(Session).where(Session.id == session_id))
+ session = result.scalars().first()
+ if not session:
+ yield {"type": "error", "data": {"message": "session_not_found"}}
+ return
+ result = await db.execute(select(World).where(World.id == session.world_id))
+ world = result.scalars().first()
+ if not world:
+ yield {"type": "error", "data": {"message": "world_not_found"}}
+ return
+
+ settings_map = await get_all_settings(db)
+ llm = LlmClient(settings_map)
+
+ # Save the player's action as a message
+ next_seq = await _next_seq(db, session_id)
+ player_msg = Message(
+ session_id=session_id,
+ seq=next_seq,
+ role="user",
+ kind="player_action",
+ content=action_text,
+ payload={},
+ is_pinned=True,
+ hidden=False,
+ )
+ db.add(player_msg)
+ await db.commit()
+ await db.refresh(player_msg)
+
+ yield {"type": "status", "data": {"message": "planning"}}
+
+ # Subagent runner
+ async def _subagent(task: str, context: str) -> str:
+ sub_messages = await build_subagent_messages(world, task, context)
+ resp = await llm.chat(
+ messages=sub_messages,
+ temperature=0.7,
+ max_tokens=300,
+ purpose="subagent",
+ user_id=user_id,
+ session_id=session_id,
+ db=db,
+ )
+ return resp.text
+
+ ctx = ToolContext(db=db, world=world, session_id=session_id, user_id=user_id, subagent_runner=_subagent, settings_map=settings_map)
+
+ # === Phase 1: Orchestrator with tool calls (max 5 iterations) ===
+ orchestrator_messages, _ = await build_orchestrator_messages(db, world, session_id, action_text)
+ # Add a final user instruction forcing JSON output
+ orchestrator_messages.append({
+ "role": "user",
+ "content": "Используй инструменты при необходимости, затем верни финальный JSON-ответ с assessment, outcome, state_patch, time_advance, narrative_prompt, next_options, triggers, rails_update, rag_facts.",
+ })
+
+ max_iters = 5
+ final_assistant_text: Optional[str] = None
+ final_tool_calls: List[Dict[str, Any]] = []
+
+ for i in range(max_iters):
+ yield {"type": "status", "data": {"message": f"orchestrator_turn_{i + 1}"}}
+ response = await llm.chat(
+ messages=orchestrator_messages,
+ tools=ALL_TOOL_SCHEMAS,
+ temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))),
+ purpose="orchestrator",
+ user_id=user_id,
+ session_id=session_id,
+ db=db,
+ )
+
+ if response.tool_calls:
+ # Append assistant message with tool_calls
+ orchestrator_messages.append({
+ "role": "assistant",
+ "content": response.text or "",
+ "tool_calls": response.tool_calls,
+ })
+ # Execute each tool call
+ for tc in response.tool_calls:
+ fn = tc.get("function", {})
+ name = fn.get("name", "")
+ args_str = fn.get("arguments", "{}")
+ try:
+ args = json.loads(args_str) if args_str else {}
+ except json.JSONDecodeError:
+ args = {}
+ yield {"type": "tool_call", "data": {"name": name, "args": args}}
+ result_dict = await handle_tool_call(name, args, ctx)
+ yield {"type": "tool_result", "data": {"name": name, "result": result_dict}}
+ # Append tool result message
+ orchestrator_messages.append({
+ "role": "tool",
+ "tool_call_id": tc.get("id", ""),
+ "name": name,
+ "content": json.dumps(result_dict, ensure_ascii=False, default=str)[:800],
+ })
+ await db.commit()
+ continue # Let orchestrator continue with tool results
+ else:
+ # No tool calls - this is the final answer
+ final_assistant_text = response.text
+ break
+
+ if final_assistant_text is None:
+ # Ran out of iterations - use last text
+ final_assistant_text = response.text or "{}"
+
+ yield {"type": "status", "data": {"message": "writing_scene"}}
+
+ # === Parse orchestrator final response ===
+ parsed = _parse_orchestrator_response(final_assistant_text)
+
+ # Apply final state patch (if any)
+ if parsed.get("state_patch"):
+ from app.core.state_validator import apply_patch, validate_state
+ new_state = apply_patch(world.state, parsed["state_patch"])
+ schema = world.definition.get("world_schema", {})
+ ok, errors = validate_state(new_state, schema)
+ if ok:
+ world.state = new_state
+ else:
+ log.warning("state_patch_invalid", errors=errors)
+
+ # Advance time
+ time_advance = parsed.get("time_advance")
+ if time_advance and isinstance(time_advance, dict):
+ new_time = _advance_world_time(world.current_time, time_advance, world)
+ world.current_time = new_time
+
+ # Save orchestrator plan as hidden message
+ plan_seq = await _next_seq(db, session_id)
+ plan_msg = Message(
+ session_id=session_id,
+ seq=plan_seq,
+ role="assistant",
+ kind="orchestrator_plan",
+ content=final_assistant_text[:2000],
+ payload={
+ "assessment": parsed.get("assessment", ""),
+ "outcome": parsed.get("outcome", ""),
+ "state_patch": parsed.get("state_patch", {}),
+ "time_advance": time_advance,
+ "tool_calls_made": [tc for tc in final_tool_calls],
+ "scheduled_triggers": ctx.scheduled_triggers,
+ "rag_added": ctx.rag_added,
+ },
+ is_pinned=False,
+ hidden=True,
+ )
+ db.add(plan_msg)
+
+ # === Phase 2: Step writer (narrative scene) ===
+ narrative_prompt_parts = [parsed.get("narrative_prompt", "")]
+ # Add RAG context if relevant
+ if parsed.get("outcome"):
+ try:
+ from app.core.rag import get_rag
+ rag = await get_rag(settings_map)
+ rag_results = await rag.search_glossary(world.id, parsed.get("outcome", ""), limit=3, settings_map=settings_map)
+ if rag_results:
+ rag_text = "\n".join(f"- {r.get('name', '?')}: {r.get('description', '')[:120]}" for r in rag_results)
+ narrative_prompt_parts.append(f"Relevant facts from glossary:\n{rag_text}")
+ except Exception as e:
+ log.warning("rag_lookup_failed", error=str(e))
+
+ step_messages = await build_step_writer_messages(
+ db=db,
+ world=world,
+ session_id=session_id,
+ outcome=parsed.get("outcome", action_text),
+ narrative_prompt="\n".join(p for p in narrative_prompt_parts if p),
+ )
+
+ step_resp = await llm.chat(
+ messages=step_messages,
+ temperature=float(cast_setting("llm.step_temperature", settings_map.get("llm.step_temperature", 0.85))),
+ max_tokens=800,
+ purpose="step",
+ user_id=user_id,
+ session_id=session_id,
+ db=db,
+ )
+
+ step_text = step_resp.text
+ step_options: List[str] = parsed.get("next_options", []) or []
+ # Try to extract structured step from JSON
+ import re as _re
+ json_match = _re.search(r"\{[\s\S]*\}", step_resp.text)
+ if json_match:
+ try:
+ step_data = json.loads(json_match.group(0))
+ if "narrative" in step_data:
+ step_text = step_data["narrative"]
+ if "options" in step_data and isinstance(step_data["options"], list):
+ step_options = [str(o) for o in step_data["options"]][:5]
+ except json.JSONDecodeError:
+ pass
+
+ # Save narrative step message
+ step_seq = await _next_seq(db, session_id)
+ step_msg = Message(
+ session_id=session_id,
+ seq=step_seq,
+ role="assistant",
+ kind="narrative_step",
+ content=step_text,
+ payload={
+ "options": step_options,
+ "outcome": parsed.get("outcome", ""),
+ "world_time": world.current_time,
+ "player_state": world.state.get("player", {}),
+ },
+ is_pinned=True,
+ hidden=False,
+ )
+ db.add(step_msg)
+
+ # === Phase 3: Update plot rails (if any) ===
+ rails_update = parsed.get("rails_update")
+ if rails_update and isinstance(rails_update, dict):
+ defn = dict(world.definition)
+ rails = dict(defn.get("plot_rails", {}))
+ if "main_goal" in rails_update:
+ rails["main_goal"] = rails_update["main_goal"]
+ if "new_subgoals" in rails_update:
+ existing = list(rails.get("subgoals", []))
+ existing.extend(rails_update["new_subgoals"])
+ rails["subgoals"] = existing
+ if "completed_subgoals" in rails_update:
+ completed = set(rails.get("completed_subgoals", []))
+ completed.update(rails_update["completed_subgoals"])
+ rails["completed_subgoals"] = list(completed)
+ # Remove completed from subgoals
+ rails["subgoals"] = [s for s in rails.get("subgoals", []) if s not in completed]
+ defn["plot_rails"] = rails
+ world.definition = defn
+
+ # Add RAG facts from orchestrator response
+ rag_facts = parsed.get("rag_facts", []) or []
+ if rag_facts:
+ from app.core.rag import get_rag
+ from app.models import GlossaryEntry
+ rag = await get_rag(settings_map)
+ for f in rag_facts:
+ if not isinstance(f, dict):
+ continue
+ entry = GlossaryEntry(
+ world_id=world.id,
+ session_id=session_id,
+ kind=f.get("kind", "lore"),
+ name=f.get("name", "unknown"),
+ description=f.get("description", ""),
+ payload={},
+ )
+ db.add(entry)
+ await db.flush()
+ await rag.upsert_glossary(
+ world_id=world.id,
+ entry_id=entry.id,
+ kind=entry.kind,
+ name=entry.name,
+ description=entry.description,
+ payload={},
+ settings_map=settings_map,
+ )
+
+ # Update session last_played_at
+ session.last_played_at = datetime.now(timezone.utc)
+
+ await db.commit()
+ await db.refresh(step_msg)
+
+ # Check for triggers that should fire immediately (fire_at <= current_time)
+ fired_now = await _check_due_triggers(db, session_id, world.current_time or "")
+
+ yield {
+ "type": "step_complete",
+ "data": {
+ "message_id": str(step_msg.id),
+ "seq": step_msg.seq,
+ "narrative": step_text,
+ "options": step_options,
+ "state": world.state,
+ "world_time": world.current_time,
+ "player_state": world.state.get("player", {}),
+ "fired_triggers": fired_now,
+ },
+ }
+ yield {"type": "done", "data": {}}
+
+
+def _parse_orchestrator_response(text: str) -> Dict[str, Any]:
+ """Extract the JSON object from the orchestrator's final response."""
+ if not text:
+ return {}
+ import re as _re
+ m = _re.search(r"\{[\s\S]*\}", text)
+ if not m:
+ return {"outcome": text, "narrative_prompt": text, "next_options": []}
+ try:
+ data = json.loads(m.group(0))
+ return data
+ except json.JSONDecodeError:
+ return {"outcome": text, "narrative_prompt": text, "next_options": []}
+
+
+async def _next_seq(db: AsyncSession, session_id: uuid.UUID) -> int:
+ result = await db.execute(
+ select(Message.seq).where(Message.session_id == session_id).order_by(Message.seq.desc()).limit(1)
+ )
+ row = result.first()
+ return (row[0] + 1) if row else 1
+
+
+def _advance_world_time(current_time: Optional[str], advance: Dict[str, int], world: World) -> str:
+ """Advance world time string. Supports format like 'day_N_hour_H' or ISO datetime."""
+ if not current_time:
+ # Try to use the world_state's world_time field
+ wt = (world.state or {}).get("world_time", {})
+ if wt:
+ day = int(wt.get("day", 1))
+ hour = int(wt.get("hour", 8))
+ else:
+ day, hour = 1, 8
+ else:
+ # Parse 'day_N_hour_H' or fall back to numbers
+ import re as _re
+ m = _re.match(r"day_(\d+)_hour_(\d+)", current_time)
+ if m:
+ day, hour = int(m.group(1)), int(m.group(2))
+ else:
+ # Try ISO format
+ try:
+ from datetime import datetime as _dt, timedelta as _td
+ dt = _dt.fromisoformat(current_time)
+ dt = dt + _td(
+ days=int(advance.get("days", 0)),
+ hours=int(advance.get("hours", 0)),
+ minutes=int(advance.get("minutes", 0)),
+ )
+ return dt.isoformat()
+ except Exception:
+ day, hour = 1, 8
+
+ total_minutes = day * 24 * 60 + hour * 60
+ total_minutes += int(advance.get("days", 0)) * 24 * 60
+ total_minutes += int(advance.get("hours", 0)) * 60
+ total_minutes += int(advance.get("minutes", 0))
+ new_day = total_minutes // (24 * 60)
+ new_hour = (total_minutes % (24 * 60)) // 60
+ new_time = f"day_{new_day}_hour_{new_hour}"
+
+ # Also update world_time in state if present
+ if world.state and "world_time" in world.state:
+ world.state["world_time"] = {
+ **world.state["world_time"],
+ "day": new_day,
+ "hour": new_hour,
+ }
+
+ return new_time
+
+
+async def _check_due_triggers(db: AsyncSession, session_id: uuid.UUID, current_time: str) -> List[Dict[str, Any]]:
+ """Mark triggers as fired if their fire_at <= current_time. Returns list of fired triggers."""
+ import re as _re
+ def _parse(t: str):
+ m = _re.match(r"day_(\d+)_hour_(\d+)", t or "")
+ if m:
+ return int(m.group(1)) * 24 * 60 + int(m.group(2)) * 60
+ try:
+ from datetime import datetime as _dt
+ dt = _dt.fromisoformat(t)
+ return int(dt.timestamp() // 60)
+ except Exception:
+ return 0
+ cur = _parse(current_time)
+ result = await db.execute(
+ select(DeferredTrigger).where(
+ DeferredTrigger.session_id == session_id,
+ DeferredTrigger.fired.is_(False),
+ )
+ )
+ triggers = list(result.scalars().all())
+ fired: List[Dict[str, Any]] = []
+ for t in triggers:
+ if _parse(t.fire_at) <= cur:
+ t.fired = True
+ fired.append({
+ "id": str(t.id),
+ "fire_at": t.fire_at,
+ "description": t.description,
+ "payload": t.payload,
+ })
+ if fired:
+ await db.commit()
+ return fired
diff --git a/backend/app/engine/tools/__init__.py b/backend/app/engine/tools/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/engine/tools/tools.py b/backend/app/engine/tools/tools.py
new file mode 100644
index 0000000..d655e76
--- /dev/null
+++ b/backend/app/engine/tools/tools.py
@@ -0,0 +1,295 @@
+"""Tool definitions and handlers for the orchestrator's tool-calling loop."""
+from __future__ import annotations
+
+import json
+import random
+import uuid
+from typing import Any, Awaitable, Callable, Dict, List, Optional
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.llm import build_tool_schema
+from app.core.rag import get_rag
+from app.core.state_validator import apply_patch, validate_state
+from app.logging_setup import get_logger
+from app.models import DeferredTrigger, GlossaryEntry, World
+
+log = get_logger("tools")
+
+
+# === Tool schemas (OpenAI function-calling format) ===
+
+DICE_ROLL_SCHEMA = build_tool_schema(
+ name="dice_roll",
+ description="Roll dice. Use 'sides' (e.g. 20 for d20) and optional 'count' (default 1) and 'modifier'. Returns the rolls and total.",
+ params={
+ "type": "object",
+ "properties": {
+ "sides": {"type": "integer", "description": "Number of sides on the die, e.g. 20 for d20"},
+ "count": {"type": "integer", "description": "Number of dice to roll", "default": 1},
+ "modifier": {"type": "integer", "description": "Modifier to add to total", "default": 0},
+ "label": {"type": "string", "description": "What this roll represents, e.g. 'attack' or 'perception'"},
+ },
+ "required": ["sides"],
+ },
+)
+
+
+UPDATE_STATE_SCHEMA = build_tool_schema(
+ name="update_state",
+ description="Apply a patch to world state. Paths use dot notation. ops: set, unset, append, increment, remove.",
+ params={
+ "type": "object",
+ "properties": {
+ "patch": {
+ "type": "object",
+ "description": "JSON-patch object with optional keys: set, unset, append, increment, remove. Each is a dict of path->value (or list of paths for unset).",
+ "properties": {
+ "set": {"type": "object"},
+ "unset": {"type": "array", "items": {"type": "string"}},
+ "append": {"type": "object"},
+ "increment": {"type": "object"},
+ "remove": {"type": "object"},
+ },
+ }
+ },
+ "required": ["patch"],
+ },
+)
+
+
+RAG_QUERY_SCHEMA = build_tool_schema(
+ name="rag_query",
+ description="Search the glossary (NPCs, locations, items, lore) for relevant facts.",
+ params={
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "Free-text search query"},
+ "limit": {"type": "integer", "description": "Max results", "default": 5},
+ },
+ "required": ["query"],
+ },
+)
+
+
+RAG_ADD_SCHEMA = build_tool_schema(
+ name="rag_add",
+ description="Add a new entry to the glossary (NPC, location, item, lore, event).",
+ params={
+ "type": "object",
+ "properties": {
+ "kind": {"type": "string", "enum": ["npc", "location", "item", "lore", "event", "rule"]},
+ "name": {"type": "string"},
+ "description": {"type": "string"},
+ "payload": {"type": "object", "description": "Optional extra fields"},
+ },
+ "required": ["kind", "name", "description"],
+ },
+)
+
+
+SCHEDULE_TRIGGER_SCHEMA = build_tool_schema(
+ name="schedule_trigger",
+ description="Schedule a deferred event tied to world time. When world time reaches fire_at, the system will fire it.",
+ params={
+ "type": "object",
+ "properties": {
+ "fire_at": {"type": "string", "description": "World time string in same format as world.current_time, e.g. 'day_3_hour_14'"},
+ "description": {"type": "string", "description": "What should happen"},
+ "payload": {"type": "object", "description": "Arbitrary structured payload for the trigger runner"},
+ },
+ "required": ["fire_at", "description"],
+ },
+)
+
+
+ADVANCE_TIME_SCHEMA = build_tool_schema(
+ name="advance_time",
+ description="Advance the world's internal clock by days/hours/minutes. Use this when the action takes time.",
+ params={
+ "type": "object",
+ "properties": {
+ "days": {"type": "integer", "default": 0},
+ "hours": {"type": "integer", "default": 0},
+ "minutes": {"type": "integer", "default": 0},
+ "reason": {"type": "string", "description": "Why time advances"},
+ },
+ },
+)
+
+
+RUN_SUBAGENT_SCHEMA = build_tool_schema(
+ name="run_subagent",
+ description="Spawn a sub-agent with clean context for a focused sub-task (e.g. generate NPC backstory, room description).",
+ params={
+ "type": "object",
+ "properties": {
+ "task": {"type": "string", "description": "The specific task for the sub-agent"},
+ "context": {"type": "string", "description": "Minimal context needed (max 200 words)"},
+ },
+ "required": ["task"],
+ },
+)
+
+
+ALL_TOOL_SCHEMAS = [
+ DICE_ROLL_SCHEMA,
+ UPDATE_STATE_SCHEMA,
+ RAG_QUERY_SCHEMA,
+ RAG_ADD_SCHEMA,
+ SCHEDULE_TRIGGER_SCHEMA,
+ ADVANCE_TIME_SCHEMA,
+ RUN_SUBAGENT_SCHEMA,
+]
+
+
+# === Tool handlers ===
+
+class ToolContext:
+ """Holds everything tools need to execute."""
+ def __init__(
+ self,
+ db: AsyncSession,
+ world: World,
+ session_id: uuid.UUID,
+ user_id: uuid.UUID,
+ subagent_runner: Optional[Callable[[str, str], Awaitable[str]]] = None,
+ settings_map: Optional[Dict[str, Any]] = None,
+ ):
+ self.db = db
+ self.world = world
+ self.session_id = session_id
+ self.user_id = user_id
+ self.subagent_runner = subagent_runner
+ self.settings_map = settings_map or {}
+ # Track time advancement during this iteration
+ self.time_advance: Dict[str, int] = {"days": 0, "hours": 0, "minutes": 0}
+ # Track scheduled triggers
+ self.scheduled_triggers: List[Dict[str, Any]] = []
+ # Track rag facts added
+ self.rag_added: List[Dict[str, Any]] = []
+
+
+async def handle_tool_call(name: str, args: Dict[str, Any], ctx: ToolContext) -> Dict[str, Any]:
+ if name == "dice_roll":
+ sides = int(args.get("sides", 20))
+ count = int(args.get("count", 1))
+ modifier = int(args.get("modifier", 0))
+ label = args.get("label", "")
+ rolls = [random.randint(1, sides) for _ in range(max(1, count))]
+ total = sum(rolls) + modifier
+ return {"rolls": rolls, "modifier": modifier, "total": total, "label": label}
+
+ if name == "update_state":
+ patch = args.get("patch", {})
+ new_state = apply_patch(ctx.world.state, patch)
+ schema = ctx.world.definition.get("world_schema", {})
+ ok, errors = validate_state(new_state, schema)
+ if not ok:
+ return {"ok": False, "errors": errors, "state_unchanged": True}
+ ctx.world.state = new_state
+ return {"ok": True, "new_state_summary": _summarize_state(new_state)}
+
+ if name == "rag_query":
+ query = args.get("query", "")
+ limit = int(args.get("limit", 5))
+ rag = await get_rag(ctx.settings_map)
+ results = await rag.search_glossary(ctx.world.id, query, limit=limit, settings_map=ctx.settings_map)
+ return {"results": results}
+
+ if name == "rag_add":
+ kind = args.get("kind", "lore")
+ entry_name = args.get("name", "")
+ desc = args.get("description", "")
+ extra = args.get("payload", {}) or {}
+ entry = GlossaryEntry(
+ world_id=ctx.world.id,
+ session_id=ctx.session_id,
+ kind=kind,
+ name=entry_name,
+ description=desc,
+ payload=extra,
+ )
+ ctx.db.add(entry)
+ await ctx.db.flush()
+ rag = await get_rag(ctx.settings_map)
+ await rag.upsert_glossary(
+ world_id=ctx.world.id,
+ entry_id=entry.id,
+ kind=kind,
+ name=entry_name,
+ description=desc,
+ payload=extra,
+ settings_map=ctx.settings_map,
+ )
+ ctx.rag_added.append({"kind": kind, "name": entry_name, "description": desc})
+ return {"ok": True, "entry_id": str(entry.id)}
+
+ if name == "schedule_trigger":
+ fire_at = args.get("fire_at", "")
+ description = args.get("description", "")
+ payload = args.get("payload", {}) or {}
+ trigger = DeferredTrigger(
+ session_id=ctx.session_id,
+ fire_at=fire_at,
+ description=description,
+ payload=payload,
+ )
+ ctx.db.add(trigger)
+ await ctx.db.flush()
+ ctx.scheduled_triggers.append({
+ "id": str(trigger.id),
+ "fire_at": fire_at,
+ "description": description,
+ })
+ return {"ok": True, "trigger_id": str(trigger.id)}
+
+ if name == "advance_time":
+ days = int(args.get("days", 0))
+ hours = int(args.get("hours", 0))
+ minutes = int(args.get("minutes", 0))
+ ctx.time_advance["days"] += days
+ ctx.time_advance["hours"] += hours
+ ctx.time_advance["minutes"] += minutes
+ return {
+ "ok": True,
+ "advance": {"days": days, "hours": hours, "minutes": minutes},
+ "reason": args.get("reason", ""),
+ }
+
+ if name == "run_subagent":
+ if ctx.subagent_runner is None:
+ return {"error": "subagent_runner_not_available"}
+ task = args.get("task", "")
+ context = args.get("context", "")
+ try:
+ result = await ctx.subagent_runner(task, context)
+ return {"result": result}
+ except Exception as e:
+ return {"error": str(e)}
+
+ return {"error": f"unknown_tool: {name}"}
+
+
+def _summarize_state(state: Dict[str, Any]) -> str:
+ """Quick human-readable summary of state for the LLM."""
+ if not state:
+ return "(empty)"
+ parts: List[str] = []
+ player = state.get("player", {})
+ if player:
+ name = player.get("name", "?")
+ stats = player.get("stats", {})
+ location = player.get("location", "?")
+ hp = stats.get("health", "?")
+ hp_max = stats.get("health_max", "?")
+ mp = stats.get("mana", "?")
+ parts.append(f"player={name} hp={hp}/{hp_max} mp={mp} loc={location}")
+ inv = player.get("inventory", []) if isinstance(player, dict) else []
+ if inv:
+ parts.append("inv=" + ", ".join(f"{i.get('name','?')}x{i.get('qty',1)}" for i in inv[:8]))
+ npcs = state.get("npcs", [])
+ if npcs:
+ parts.append(f"npcs={len(npcs)}")
+ return " | ".join(parts)
diff --git a/backend/app/engine/world_builder.py b/backend/app/engine/world_builder.py
new file mode 100644
index 0000000..677ee06
--- /dev/null
+++ b/backend/app/engine/world_builder.py
@@ -0,0 +1,267 @@
+"""World builder: multi-turn dialogue to produce a finalized WorldDefinition."""
+from __future__ import annotations
+
+import json
+import uuid
+from typing import Any, Dict, List, Optional
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.llm import LlmClient
+from app.core.settings_service import cast_setting, get_all_settings
+from app.logging_setup import get_logger
+from app.models import Preset, User, World
+from app.prompts.templates import get_prompt
+from app.schemas import WorldBuilderReply, WorldDefinition
+
+log = get_logger("world_builder")
+
+
+# In-memory store of world-builder dialogues (session_id -> dialogue state).
+# For production scale, move this to Redis. For MVP single-instance it's fine.
+_DIALOGUES: Dict[uuid.UUID, Dict[str, Any]] = {}
+
+
+async def start_world_builder(
+ db: AsyncSession,
+ user: User,
+ world_name: str,
+ language: str,
+ preset_id: Optional[uuid.UUID],
+ setting_brief: str,
+ character_brief: str,
+ rules_brief: str,
+ notes: str,
+) -> WorldBuilderReply:
+ """Kick off a new world-builder dialogue. Returns the first AI reply."""
+ session_id = uuid.uuid4()
+ llm = await LlmClient.from_db(db)
+
+ preset_payload: Optional[Dict[str, Any]] = None
+ if preset_id:
+ result = await db.execute(select(Preset).where(Preset.id == preset_id))
+ preset = result.scalars().first()
+ if preset:
+ preset_payload = preset.payload
+
+ user_brief = _build_user_brief(
+ world_name=world_name,
+ setting_brief=setting_brief,
+ character_brief=character_brief,
+ rules_brief=rules_brief,
+ notes=notes,
+ preset_payload=preset_payload,
+ language=language,
+ )
+
+ system_prompt = get_prompt("world_builder", language)
+ messages = [
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_brief},
+ ]
+
+ response = await llm.chat(
+ messages=messages,
+ temperature=0.7,
+ purpose="world_builder",
+ user_id=user.id,
+ db=db,
+ )
+
+ ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text)
+
+ _DIALOGUES[session_id] = {
+ "user_id": user.id,
+ "world_name": world_name,
+ "language": language,
+ "preset_id": preset_id,
+ "messages": messages + [{"role": "assistant", "content": response.text}],
+ "turn": 1,
+ "last_proposed": proposed.model_dump() if proposed else None,
+ }
+
+ return WorldBuilderReply(
+ session_id=session_id,
+ turn=1,
+ ai_message=ai_text,
+ proposed_definition=proposed,
+ is_final=is_final,
+ followup_questions=followups,
+ )
+
+
+async def continue_world_builder(
+ db: AsyncSession,
+ user: User,
+ session_id: uuid.UUID,
+ user_message: str,
+) -> WorldBuilderReply:
+ """Continue an existing world-builder dialogue."""
+ dialogue = _DIALOGUES.get(session_id)
+ if not dialogue:
+ raise ValueError("dialogue_not_found")
+ if dialogue["user_id"] != user.id:
+ raise ValueError("forbidden")
+
+ llm = await LlmClient.from_db(db)
+ dialogue["messages"].append({"role": "user", "content": user_message})
+ dialogue["turn"] += 1
+
+ response = await llm.chat(
+ messages=dialogue["messages"],
+ temperature=0.7,
+ purpose="world_builder",
+ user_id=user.id,
+ db=db,
+ )
+ dialogue["messages"].append({"role": "assistant", "content": response.text})
+
+ ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text)
+ if proposed:
+ dialogue["last_proposed"] = proposed.model_dump()
+
+ return WorldBuilderReply(
+ session_id=session_id,
+ turn=dialogue["turn"],
+ ai_message=ai_text,
+ proposed_definition=proposed,
+ is_final=is_final,
+ followup_questions=followups,
+ )
+
+
+async def commit_world_builder(
+ db: AsyncSession,
+ user: User,
+ session_id: uuid.UUID,
+ name: Optional[str] = None,
+) -> World:
+ """Commit the proposed world definition into a real World row."""
+ dialogue = _DIALOGUES.get(session_id)
+ if not dialogue:
+ raise ValueError("dialogue_not_found")
+ if dialogue["user_id"] != user.id:
+ raise ValueError("forbidden")
+ proposed = dialogue.get("last_proposed")
+ if not proposed:
+ raise ValueError("no_proposed_definition")
+
+ definition = WorldDefinition.model_validate(proposed)
+ world = World(
+ owner_id=user.id,
+ name=name or dialogue.get("world_name") or "New World",
+ language=dialogue.get("language", "ru"),
+ definition=definition.model_dump(),
+ state=definition.initial_state or {},
+ current_time=definition.initial_time,
+ status="ready",
+ preset_id=dialogue.get("preset_id"),
+ )
+ db.add(world)
+ await db.commit()
+ await db.refresh(world)
+
+ # Clean up dialogue
+ _DIALOGUES.pop(session_id, None)
+ return world
+
+
+def _build_user_brief(
+ world_name: str,
+ setting_brief: str,
+ character_brief: str,
+ rules_brief: str,
+ notes: str,
+ preset_payload: Optional[Dict[str, Any]],
+ language: str,
+) -> str:
+ parts = [f"=== WORLD BRIEF ({language.upper()}) ==="]
+ parts.append(f"Name: {world_name}")
+ if preset_payload:
+ parts.append(f"Preset seed: {preset_payload.get('world_seed_prompt', '')}")
+ parts.append(f"Suggested rules: {json.dumps(preset_payload.get('rules', {}), ensure_ascii=False)[:400]}")
+ if setting_brief:
+ parts.append(f"Setting: {setting_brief}")
+ if character_brief:
+ parts.append(f"Character: {character_brief}")
+ if rules_brief:
+ parts.append(f"Rules: {rules_brief}")
+ if notes:
+ parts.append(f"Notes: {notes}")
+ parts.append("\nPlease ask 2-4 clarifying questions OR build a proposed world definition.")
+ return "\n".join(parts)
+
+
+def _parse_world_builder_response(text: str) -> tuple[str, Optional[WorldDefinition], bool, List[str]]:
+ """Extract AI message text, proposed definition (if any), is_final flag, and followup questions."""
+ proposed = None
+ is_final = False
+ followups: List[str] = []
+
+ # Try to find a JSON block in the response
+ json_str = _extract_json_block(text)
+ if json_str:
+ try:
+ data = json.loads(json_str)
+ if isinstance(data, dict):
+ if "proposed_definition" in data:
+ pd = data["proposed_definition"]
+ if isinstance(pd, dict):
+ try:
+ proposed = WorldDefinition.model_validate(pd)
+ except Exception:
+ proposed = None
+ if "is_final" in data:
+ is_final = bool(data["is_final"])
+ if "followup_questions" in data and isinstance(data["followup_questions"], list):
+ followups = [str(q) for q in data["followup_questions"]]
+ if "ai_message" in data and isinstance(data["ai_message"], str):
+ text = data["ai_message"]
+ except json.JSONDecodeError:
+ pass
+
+ # Heuristic: if response contains "готово" / "ready" and a proposed_definition — mark final
+ if proposed is not None:
+ low = text.lower()
+ if any(kw in low for kw in ["готово", "world is ready", "world_ready", "ready to commit"]):
+ is_final = True
+
+ return text, proposed, is_final, followups
+
+
+def _extract_json_block(text: str) -> Optional[str]:
+ """Find the first JSON object/array block in text."""
+ if not text:
+ return None
+ # Try fenced ```json ... ```
+ import re
+ m = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text)
+ if m:
+ return m.group(1)
+ # Try raw {...} (greedy from first { to matching })
+ start = text.find("{")
+ if start == -1:
+ return None
+ depth = 0
+ in_str = False
+ esc = False
+ for i in range(start, len(text)):
+ c = text[i]
+ if in_str:
+ if esc:
+ esc = False
+ elif c == "\\":
+ esc = True
+ elif c == '"':
+ in_str = False
+ else:
+ if c == '"':
+ in_str = True
+ elif c == "{":
+ depth += 1
+ elif c == "}":
+ depth -= 1
+ if depth == 0:
+ return text[start:i + 1]
+ return None
diff --git a/backend/app/logging_setup.py b/backend/app/logging_setup.py
new file mode 100644
index 0000000..78ad0e1
--- /dev/null
+++ b/backend/app/logging_setup.py
@@ -0,0 +1,38 @@
+"""Structured logging setup."""
+from __future__ import annotations
+
+import logging
+import sys
+
+import structlog
+
+from app.config import settings
+
+
+def setup_logging() -> None:
+ level = getattr(logging, settings.log_level.upper(), logging.INFO)
+
+ logging.basicConfig(
+ format="%(message)s",
+ stream=sys.stdout,
+ level=level,
+ )
+
+ structlog.configure(
+ processors=[
+ structlog.contextvars.merge_contextvars,
+ structlog.processors.add_log_level,
+ structlog.processors.TimeStamper(fmt="iso"),
+ structlog.processors.StackInfoRenderer(),
+ structlog.processors.format_exc_info,
+ structlog.processors.JSONRenderer(),
+ ],
+ wrapper_class=structlog.make_filtering_bound_logger(level),
+ context_class=dict,
+ logger_factory=structlog.PrintLoggerFactory(file=sys.stdout),
+ cache_logger_on_first_use=True,
+ )
+
+
+def get_logger(name: str | None = None):
+ return structlog.get_logger(name)
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..3303350
--- /dev/null
+++ b/backend/app/main.py
@@ -0,0 +1,76 @@
+"""FastAPI application entrypoint."""
+from __future__ import annotations
+
+import asyncio
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from app.api import admin, auth, misc, presets, sessions, worlds
+from app.config import settings
+from app.logging_setup import get_logger, setup_logging
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ setup_logging()
+ log = get_logger("app")
+ log.info("app_starting", worker_mode=settings.is_worker)
+
+ # Initialize DB tables and seed defaults
+ from app.migrations.init_db import init_db
+ try:
+ await init_db()
+ except Exception as e:
+ log.error("init_db_failed", error=str(e))
+
+ # Initialize RAG collections (using current DB-backed embedding settings)
+ try:
+ from app.core.rag import get_rag
+ from app.core.settings_service import get_all_settings
+ from app.db import AsyncSessionLocal
+ async with AsyncSessionLocal() as session:
+ settings_map = await get_all_settings(session)
+ await get_rag(settings_map)
+ except Exception as e:
+ log.warning("rag_init_failed", error=str(e))
+
+ yield
+
+ log.info("app_stopping")
+
+
+app = FastAPI(
+ title="AI RPG Backend",
+ version="0.1.0",
+ description="Flexible AI-powered role-playing game backend.",
+ lifespan=lifespan,
+)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.cors_origins,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+
+@app.get("/health")
+async def health():
+ return {"status": "ok"}
+
+
+@app.get("/")
+async def root():
+ return {"app": "ai-rpg", "version": "0.1.0"}
+
+
+# Routers
+app.include_router(auth.router)
+app.include_router(admin.router)
+app.include_router(presets.router)
+app.include_router(worlds.router)
+app.include_router(sessions.router)
+app.include_router(misc.router)
diff --git a/backend/app/migrations/__init__.py b/backend/app/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/migrations/init_db.py b/backend/app/migrations/init_db.py
new file mode 100644
index 0000000..9631fa7
--- /dev/null
+++ b/backend/app/migrations/init_db.py
@@ -0,0 +1,103 @@
+"""Database initialization: create all tables and seed defaults."""
+from __future__ import annotations
+
+import asyncio
+import json
+from pathlib import Path
+
+from sqlalchemy import select, text
+
+from app.db import AsyncSessionLocal, Base, engine
+from app.models import GlossaryEntry, Preset, Setting, User
+from app.config import settings
+from app.logging_setup import get_logger, setup_logging
+from app.core.security import hash_password
+from app.prompts.fantasy_preset import FANTASY_PRESET_RU, FANTASY_PRESET_EN
+
+log = get_logger("migrations")
+
+
+DEFAULT_SETTINGS = [
+ ("llm.base_url", settings.default_llm_base_url, "OpenAI-compatible base URL"),
+ ("llm.api_key", settings.default_llm_api_key, "API key for LLM endpoint"),
+ ("llm.model", settings.default_llm_model, "Default model name"),
+ ("llm.temperature", 0.7, "Temperature for orchestrator"),
+ ("llm.step_temperature", 0.85, "Temperature for narrative step writer"),
+ ("llm.summary_temperature", 0.3, "Temperature for summarizer"),
+ ("llm.max_tokens", 1024, "Max tokens per LLM response"),
+ ("llm.request_timeout", 120, "LLM request timeout, seconds"),
+ ("llm.streaming", True, "Whether to use streaming responses"),
+ ("context.recent_messages", settings.default_recent_messages, "Guaranteed recent messages in prompt"),
+ ("context.compress_threshold", settings.default_compress_threshold, "Trigger compression at this count"),
+ ("context.summary_messages", settings.default_summary_messages, "Number of messages per summary block"),
+ ("context.max_tokens_total", 6000, "Soft token budget for context window (small models)"),
+ ("triggers.enabled", True, "Enable deferred trigger processing"),
+ ("triggers.check_interval", 30, "Trigger checker interval, seconds"),
+ # Embeddings / RAG
+ ("embedding.provider", settings.default_embedding_provider, "Embeddings provider: 'hash' (offline fallback) or 'openai' (real semantic embeddings)"),
+ ("embedding.base_url", settings.default_embedding_base_url, "OpenAI-compatible embeddings base URL. Empty = reuse llm.base_url"),
+ ("embedding.api_key", settings.default_embedding_api_key, "API key for embeddings endpoint. Empty = reuse llm.api_key"),
+ ("embedding.model", settings.default_embedding_model, "Embedding model name (e.g. text-embedding-3-small, bge-m3, nomic-embed-text)"),
+ ("embedding.dim", settings.default_embedding_dim, "Vector dimension. 0 = auto-probe from endpoint on first use"),
+ ("embedding.request_timeout", settings.default_embedding_request_timeout, "Embeddings request timeout, seconds"),
+]
+
+
+async def init_db() -> None:
+ setup_logging()
+ log.info("creating_tables")
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ log.info("tables_ready")
+
+ async with AsyncSessionLocal() as session:
+ # Seed settings
+ result = await session.execute(select(Setting).limit(1))
+ if result.scalars().first() is None:
+ for key, value, desc in DEFAULT_SETTINGS:
+ session.add(Setting(key=key, value=value, description=desc))
+ await session.commit()
+ log.info("settings_seeded", count=len(DEFAULT_SETTINGS))
+ else:
+ log.info("settings_already_exist")
+
+ # Seed built-in Fantasy preset
+ result = await session.execute(select(Preset).where(Preset.is_builtin.is_(True)))
+ if result.scalars().first() is None:
+ for preset_def in (FANTASY_PRESET_RU, FANTASY_PRESET_EN):
+ session.add(Preset(
+ slug=preset_def["slug"],
+ title=preset_def["title"],
+ description=preset_def["description"],
+ language=preset_def["language"],
+ is_public=True,
+ is_builtin=True,
+ payload=preset_def["payload"],
+ ))
+ await session.commit()
+ log.info("builtin_presets_seeded")
+ else:
+ log.info("builtin_presets_already_exist")
+
+ # Ensure admin_setup_token is set; if empty, generate and print
+ token = settings.admin_setup_token.strip()
+ if not token:
+ import secrets as _s
+ token = _s.token_urlsafe(24)
+ async with AsyncSessionLocal() as session:
+ existing = await session.execute(select(Setting).where(Setting.key == "admin.setup_token"))
+ existing_obj = existing.scalars().first()
+ if existing_obj is None:
+ session.add(Setting(key="admin.setup_token", value=token, description="One-time token for /admin/setup"))
+ await session.commit()
+ print("=" * 60)
+ print("ADMIN SETUP TOKEN (use at /admin/setup):")
+ print(token)
+ print("=" * 60)
+ log.info("admin_setup_token_generated")
+ else:
+ log.info("admin_setup_token_already_set")
+
+
+if __name__ == "__main__":
+ asyncio.run(init_db())
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
new file mode 100644
index 0000000..59d6fd1
--- /dev/null
+++ b/backend/app/models/__init__.py
@@ -0,0 +1,189 @@
+"""SQLAlchemy models for the AI RPG backend."""
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any, Dict, List, Optional
+import uuid
+
+from sqlalchemy import (
+ Boolean,
+ DateTime,
+ ForeignKey,
+ Integer,
+ String,
+ Text,
+ JSON,
+ func,
+)
+from sqlalchemy.dialects.postgresql import JSONB, UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.db import Base
+
+
+def _utcnow() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+class User(Base):
+ __tablename__ = "users"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
+ username: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
+ hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
+ is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ preferred_language: Mapped[str] = mapped_column(String(8), default="ru", nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
+
+ worlds: Mapped[List["World"]] = relationship(back_populates="owner", cascade="all, delete-orphan")
+
+
+class Setting(Base):
+ """Key/value admin settings. Override defaults (LLM, context manager params)."""
+ __tablename__ = "settings"
+
+ key: Mapped[str] = mapped_column(String(128), primary_key=True)
+ value: Mapped[Any] = mapped_column(JSONB, nullable=False)
+ description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False)
+
+
+class Preset(Base):
+ """World presets published by admin or users."""
+ __tablename__ = "presets"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ slug: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
+ title: Mapped[str] = mapped_column(String(255), nullable=False)
+ description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
+ language: Mapped[str] = mapped_column(String(8), default="ru", nullable=False)
+ is_public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ # JSON: world_schema, default_rules, initial_state, world_seed_prompt, suggested_system_prompt
+ payload: Mapped[Dict[str, Any]] = mapped_column(JSONB, nullable=False)
+ author_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
+
+
+class World(Base):
+ __tablename__ = "worlds"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ language: Mapped[str] = mapped_column(String(8), default="ru", nullable=False)
+ # Frozen world definition: setting description, rules, world_schema (JSON Schema for state), plot_rails
+ definition: Mapped[Dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
+ # Current live state of the world (player character, NPC, inventory, time, etc.)
+ state: Mapped[Dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
+ # Current world time (ISO string)
+ current_time: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
+ # Status: draft / ready / active / archived
+ status: Mapped[str] = mapped_column(String(32), default="draft", nullable=False, index=True)
+ preset_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("presets.id"), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False)
+
+ owner: Mapped[User] = relationship(back_populates="worlds")
+ sessions: Mapped[List["Session"]] = relationship(back_populates="world", cascade="all, delete-orphan")
+
+
+class Session(Base):
+ __tablename__ = "sessions"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ world_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("worlds.id"), nullable=False, index=True)
+ title: Mapped[str] = mapped_column(String(255), default="Новая сессия", nullable=False)
+ # Snapshot of world state at session start (we mutate world.state during play; session stores narrative history)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
+ last_played_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
+
+ world: Mapped[World] = relationship(back_populates="sessions")
+ messages: Mapped[List["Message"]] = relationship(
+ back_populates="session", cascade="all, delete-orphan", order_by="Message.seq"
+ )
+ triggers: Mapped[List["DeferredTrigger"]] = relationship(
+ back_populates="session", cascade="all, delete-orphan"
+ )
+
+
+class Message(Base):
+ """Conversation messages: scene steps, player actions, orchestrator thoughts, summaries."""
+ __tablename__ = "messages"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sessions.id"), nullable=False, index=True)
+ seq: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
+ # role: system / user / assistant / scene / summary / technical / tool
+ role: Mapped[str] = mapped_column(String(32), nullable=False)
+ # kind: narrative_step / player_action / orchestrator_plan / tool_call / summary / technical_offscreen / system_note
+ kind: Mapped[str] = mapped_column(String(64), default="narrative_step", nullable=False)
+ content: Mapped[str] = mapped_column(Text, nullable=False, default="")
+ # Structured payload: suggested_options, tool_calls, state_diff, time_diff, etc.
+ payload: Mapped[Dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
+ # Whether this message is in the "guaranteed recent" context window
+ is_pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ # True if message is hidden from the chat UI (technical, tool, summary)
+ hidden: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
+
+ session: Mapped[Session] = relationship(back_populates="messages")
+
+
+class DeferredTrigger(Base):
+ """Scheduled events tied to in-world time."""
+ __tablename__ = "deferred_triggers"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sessions.id"), nullable=False, index=True)
+ # ISO datetime in world's internal time
+ fire_at: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
+ description: Mapped[str] = mapped_column(Text, nullable=False)
+ # Arbitrary payload (what should happen, who, conditions)
+ payload: Mapped[Dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
+ fired: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
+
+ session: Mapped[Session] = relationship(back_populates="triggers")
+
+
+class LlmCallLog(Base):
+ """All LLM calls logged for observability and cost tracking."""
+ __tablename__ = "llm_call_logs"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ user_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True, index=True)
+ session_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sessions.id"), nullable=True, index=True)
+ purpose: Mapped[str] = mapped_column(String(64), nullable=False) # orchestrator / step / summary / world_builder / subagent
+ model: Mapped[str] = mapped_column(String(255), nullable=False)
+ base_url: Mapped[str] = mapped_column(String(512), nullable=False)
+ prompt_messages: Mapped[List[Dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list)
+ # tools schema sent
+ tools: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSONB, nullable=True)
+ # response
+ response_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
+ tool_calls: Mapped[Optional[List[Dict[str, Any]]]] = mapped_column(JSONB, nullable=True)
+ prompt_tokens: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
+ completion_tokens: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
+ total_tokens: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
+ latency_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
+ error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False, index=True)
+
+
+class GlossaryEntry(Base):
+ """Indexed facts for RAG (glossary terms, NPCs, locations, items)."""
+ __tablename__ = "glossary_entries"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ world_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("worlds.id"), nullable=False, index=True)
+ session_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sessions.id"), nullable=True, index=True)
+ # kind: npc / location / item / lore / event / rule
+ kind: Mapped[str] = mapped_column(String(32), default="lore", nullable=False)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ description: Mapped[str] = mapped_column(Text, nullable=False, default="")
+ payload: Mapped[Dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
diff --git a/backend/app/prompts/__init__.py b/backend/app/prompts/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/prompts/fantasy_preset.py b/backend/app/prompts/fantasy_preset.py
new file mode 100644
index 0000000..5f7a5fc
--- /dev/null
+++ b/backend/app/prompts/fantasy_preset.py
@@ -0,0 +1,225 @@
+"""Built-in Fantasy preset (RU + EN)."""
+from __future__ import annotations
+
+
+FANTASY_PRESET_RU = {
+ "slug": "fantasy-default-ru",
+ "title": "Фэнтези: Меч и Магия",
+ "description": "Классический фэнтези-сеттинг с HP/MP, инвентарём, фракциями и заклинаниями.",
+ "language": "ru",
+ "payload": {
+ "world_seed_prompt": (
+ "Классическое темное фэнтези в духе позднего средневековья. Королевства людей, эльфийские леса, "
+ "гномьи города под горами, орды орков на восточных рубежах. Магия редкая и опасная, церковь "
+ "борется с ересями. Герой — начинающий авантюрист, ищущий славы и средств к существованию."
+ ),
+ "rules": {
+ "stats": ["health", "mana", "stamina", "gold", "level", "xp"],
+ "combat": "пошаговые броски d20 + модификатор против сложности",
+ "magic": "трата маны на заклинания, восстановление во сне",
+ "death": "при health <= 0 — состояние при смерти, нужно стабилизировать",
+ "inventory": "слоты = 10 + сила модификатор",
+ "time": "внутренний календарь: дни, часы. Сон = 8ч, путешествие между локациями 4-12ч.",
+ },
+ "world_schema": {
+ "type": "object",
+ "properties": {
+ "player": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "race": {"type": "string"},
+ "class": {"type": "string"},
+ "level": {"type": "integer", "minimum": 1},
+ "xp": {"type": "integer", "minimum": 0},
+ "stats": {
+ "type": "object",
+ "properties": {
+ "health": {"type": "number"},
+ "health_max": {"type": "number"},
+ "mana": {"type": "number"},
+ "mana_max": {"type": "number"},
+ "stamina": {"type": "number"},
+ "stamina_max": {"type": "number"},
+ "strength": {"type": "integer"},
+ "dexterity": {"type": "integer"},
+ "constitution": {"type": "integer"},
+ "intelligence": {"type": "integer"},
+ "wisdom": {"type": "integer"},
+ "charisma": {"type": "integer"},
+ },
+ "required": ["health", "health_max", "mana", "mana_max"],
+ },
+ "inventory": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string"},
+ "qty": {"type": "integer", "minimum": 0},
+ "type": {"type": "string"},
+ "notes": {"type": "string"},
+ },
+ "required": ["name", "qty"],
+ },
+ },
+ "effects": {"type": "array", "items": {"type": "object"}},
+ "gold": {"type": "integer", "minimum": 0},
+ "location": {"type": "string"},
+ },
+ "required": ["name", "stats", "inventory"],
+ },
+ "npcs": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {"type": "string"},
+ "name": {"type": "string"},
+ "description": {"type": "string"},
+ "relation": {"type": "string"},
+ "stats": {"type": "object"},
+ "location": {"type": "string"},
+ },
+ "required": ["id", "name"],
+ },
+ },
+ "locations": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {"type": "string"},
+ "name": {"type": "string"},
+ "description": {"type": "string"},
+ "type": {"type": "string"},
+ "danger": {"type": "string"},
+ },
+ "required": ["id", "name"],
+ },
+ },
+ "world_time": {
+ "type": "object",
+ "properties": {
+ "day": {"type": "integer"},
+ "hour": {"type": "integer"},
+ "season": {"type": "string"},
+ "weather": {"type": "string"},
+ },
+ },
+ "flags": {"type": "object"},
+ },
+ "required": ["player"],
+ },
+ "initial_state": {
+ "player": {
+ "name": "Герой",
+ "race": "Человек",
+ "class": "Авантюрист",
+ "level": 1,
+ "xp": 0,
+ "stats": {
+ "health": 20, "health_max": 20,
+ "mana": 10, "mana_max": 10,
+ "stamina": 15, "stamina_max": 15,
+ "strength": 10, "dexterity": 10, "constitution": 10,
+ "intelligence": 10, "wisdom": 10, "charisma": 10,
+ },
+ "inventory": [
+ {"name": "Старый меч", "qty": 1, "type": "weapon", "notes": "1d8 урон"},
+ {"name": "Кожаная броня", "qty": 1, "type": "armor", "notes": "+1 AC"},
+ {"name": "Хлеб", "qty": 3, "type": "food", "notes": "восстанавливает 2 стамины"},
+ {"name": "Факел", "qty": 5, "type": "tool", "notes": "горит 1 час"},
+ ],
+ "effects": [],
+ "gold": 10,
+ "location": "Деревня Старый Дуб",
+ },
+ "npcs": [],
+ "locations": [
+ {
+ "id": "village_old_oak",
+ "name": "Деревня Старый Дуб",
+ "description": "Маленькая деревня на опушке Тёмного Леса.",
+ "type": "settlement",
+ "danger": "safe",
+ }
+ ],
+ "world_time": {"day": 1, "hour": 8, "season": "spring", "weather": "clear"},
+ "flags": {},
+ },
+ "initial_time": "day_1_hour_8",
+ "suggested_system_prompt": (
+ "Ты — Game Master классического фэнтези. Используй пошаговые правила: броски d20, "
+ "трата маны на заклинания, учёт усталости. Описывай сцены кинематографично, но коротко. "
+ "Соблюдай сеттинг средневекового тёмного фэнтези. Не давай игроку несбыточных обещаний."
+ ),
+ },
+}
+
+
+FANTASY_PRESET_EN = {
+ "slug": "fantasy-default-en",
+ "title": "Fantasy: Sword & Sorcery",
+ "description": "Classic fantasy setting with HP/MP, inventory, factions and spells.",
+ "language": "en",
+ "payload": {
+ "world_seed_prompt": (
+ "Classic dark fantasy in a late-medieval style. Human kingdoms, elven forests, dwarven cities "
+ "under the mountains, orc hordes on the eastern marches. Magic is rare and dangerous, the "
+ "church hunts heretics. The hero is a novice adventurer seeking fame and coin."
+ ),
+ "rules": {
+ "stats": ["health", "mana", "stamina", "gold", "level", "xp"],
+ "combat": "turn-based d20 rolls + modifier vs difficulty",
+ "magic": "mana cost per spell, recovered by sleep",
+ "death": "at health <= 0 — dying state, must be stabilized",
+ "inventory": "slots = 10 + strength modifier",
+ "time": "internal calendar: days, hours. Sleep = 8h, travel between locations 4-12h.",
+ },
+ "world_schema": FANTASY_PRESET_RU["payload"]["world_schema"],
+ "initial_state": {
+ "player": {
+ "name": "Hero",
+ "race": "Human",
+ "class": "Adventurer",
+ "level": 1,
+ "xp": 0,
+ "stats": {
+ "health": 20, "health_max": 20,
+ "mana": 10, "mana_max": 10,
+ "stamina": 15, "stamina_max": 15,
+ "strength": 10, "dexterity": 10, "constitution": 10,
+ "intelligence": 10, "wisdom": 10, "charisma": 10,
+ },
+ "inventory": [
+ {"name": "Old sword", "qty": 1, "type": "weapon", "notes": "1d8 damage"},
+ {"name": "Leather armor", "qty": 1, "type": "armor", "notes": "+1 AC"},
+ {"name": "Bread", "qty": 3, "type": "food", "notes": "restores 2 stamina"},
+ {"name": "Torch", "qty": 5, "type": "tool", "notes": "burns 1 hour"},
+ ],
+ "effects": [],
+ "gold": 10,
+ "location": "Old Oak Village",
+ },
+ "npcs": [],
+ "locations": [
+ {
+ "id": "village_old_oak",
+ "name": "Old Oak Village",
+ "description": "A small village on the edge of the Darkwood.",
+ "type": "settlement",
+ "danger": "safe",
+ }
+ ],
+ "world_time": {"day": 1, "hour": 8, "season": "spring", "weather": "clear"},
+ "flags": {},
+ },
+ "initial_time": "day_1_hour_8",
+ "suggested_system_prompt": (
+ "You are the Game Master of a classic fantasy. Use turn-based rules: d20 rolls, mana "
+ "costs for spells, track fatigue. Describe scenes cinematically but briefly. Stay in "
+ "the dark-fantasy medieval setting. Don't make the player impossible promises."
+ ),
+ },
+}
diff --git a/backend/app/prompts/templates.py b/backend/app/prompts/templates.py
new file mode 100644
index 0000000..586000d
--- /dev/null
+++ b/backend/app/prompts/templates.py
@@ -0,0 +1,295 @@
+"""System prompts for all LLM stages. Bilingual (RU/EN)."""
+from __future__ import annotations
+
+from typing import Dict
+
+
+# === World Builder ===
+WORLD_BUILDER_SYSTEM_RU = """Ты — опытный архитектор миров для ролевой игры.
+Твоя задача — помочь игроку создать мир через диалог. Игрок даёт начальный бриф (сеттинг, персонаж, правила, заметки).
+Ты должен:
+1. Если информации мало — задать 2-4 уточняющих вопроса коротко и по делу.
+2. Если информации достаточно — построить complete world definition и представить его игроку как draft.
+3. Принять правки и уточнения, цикл продолжается пока игрок не скажет "готово".
+
+Структура world definition (выводи в JSON в поле proposed_definition когда считаешь что мир готов или близок):
+{
+ "setting_description": "расширенный сеттинг (1-2 абзаца)",
+ "rules": {объект с правилами: статы, бой, магия, время, инвентарь, смерть},
+ "world_schema": {JSON Schema для состояния мира: player, npcs, locations, world_time, flags},
+ "plot_rails": {"main_goal": "...", "subgoals": [...], "hooks": [...]},
+ "initial_state": {начальное состояние мира согласно schema},
+ "initial_time": "строка времени мира (например 'day_1_hour_8')"
+}
+
+ВАЖНО для small models:
+- Будь лаконичен. Не более 200 слов в каждом сообщении.
+- JSON выводи строго валидный, без комментариев.
+- В каждом ответе: либо задавай вопросы (если данных мало), либо давай proposed_definition.
+- Когда мир готов — поставь is_final=true (но только если игрок согласился).
+"""
+
+
+WORLD_BUILDER_SYSTEM_EN = """You are a master world-builder for a role-playing game.
+Your job is to help the player design a world through dialogue. The player gives a brief (setting, character, rules, notes).
+You must:
+1. If information is sparse — ask 2-4 short, focused clarifying questions.
+2. If information is sufficient — build a complete world definition and present it as a draft.
+3. Accept edits and clarifications; the loop continues until the player says "ok".
+
+World definition structure (output in JSON as proposed_definition when the world is ready or near-ready):
+{
+ "setting_description": "expanded setting (1-2 paragraphs)",
+ "rules": {object with rules: stats, combat, magic, time, inventory, death},
+ "world_schema": {JSON Schema for world state: player, npcs, locations, world_time, flags},
+ "plot_rails": {"main_goal": "...", "subgoals": [...], "hooks": [...]},
+ "initial_state": {initial world state matching schema},
+ "initial_time": "world time string (e.g. 'day_1_hour_8')"
+}
+
+CRITICAL for small models:
+- Be concise. Max 200 words per message.
+- Output strictly valid JSON, no comments.
+- In each reply: either ask questions (if data is sparse), or give proposed_definition.
+- When world is ready — set is_final=true (only if the player agreed).
+"""
+
+
+# === Orchestrator (main game loop with tools) ===
+ORCHESTRATOR_SYSTEM_RU = """Ты — Game Master ролевой игры. Ведёшь сессию через инструментальные вызовы.
+
+ТЕКУЩИЙ КОНТЕКСТ:
+- Мир: {world_name}
+- Сеттинг: {setting_description}
+- Правила: {rules}
+- Текущее время мира: {current_time}
+- Состояние игрока: {player_state}
+- Главные рельсы сюжета: {plot_rails}
+- Сводка прошлого: {summary}
+
+ЗАДАЧА:
+Игрок сделал действие: "{action_text}"
+Оцени реалистичность (соответствие сеттингу и правилам), спланируй что должно произойти, используй инструменты для:
+- бросков кубиков (dice_roll)
+- обновления состояния (update_state)
+- проверки/добавления фактов в RAG (rag_query, rag_add)
+- планирования отложенных событий (schedule_trigger)
+- обновления времени мира (advance_time)
+- запуска sub-агента для генерации деталей с чистым контекстом (run_subagent)
+
+После выполнения плана — верни ответ в виде JSON (без текста вне JSON):
+{
+ "assessment": "краткая оценка действия (1-2 предложения)",
+ "outcome": "что произошло (сырой, 1-3 предложения)",
+ "state_patch": {JSON-patch для состояния мира},
+ "time_advance": {"days": 0, "hours": 0, "minutes": 0} | null,
+ "narrative_prompt": "факты которые должен знать step-writer для написания сценария",
+ "next_options": ["вариант 1", "вариант 2", "вариант 3"],
+ "triggers": [{"fire_at": "world_time_str", "description": "...", "payload": {}}],
+ "rails_update": {"main_goal": "...", "new_subgoals": [...], "completed_subgoals": [...]} | null,
+ "rag_facts": [{"kind": "npc|location|item|lore|event", "name": "...", "description": "..."}]
+}
+
+ВАЖНО:
+- Экономь токены. Минимум 1-3 tool calls на итерацию, не больше 5.
+- Если действие тривиальное — пропусти dice_roll.
+- Не пиши сценарное описание — это задача step-writer.
+- Соблюдай сеттинг.
+"""
+
+
+ORCHESTRATOR_SYSTEM_EN = """You are the Game Master of a role-playing game. You run the session through tool calls.
+
+CURRENT CONTEXT:
+- World: {world_name}
+- Setting: {setting_description}
+- Rules: {rules}
+- Current world time: {current_time}
+- Player state: {player_state}
+- Main plot rails: {plot_rails}
+- Past summary: {summary}
+
+TASK:
+The player performed action: "{action_text}"
+Assess realism (consistency with setting and rules), plan what should happen, use tools to:
+- roll dice (dice_roll)
+- update state (update_state)
+- query / add facts to RAG (rag_query, rag_add)
+- schedule deferred events (schedule_trigger)
+- advance world time (advance_time)
+- spawn a sub-agent for detail generation with clean context (run_subagent)
+
+After executing the plan — return your reply as JSON (no text outside JSON):
+{
+ "assessment": "brief assessment of the action (1-2 sentences)",
+ "outcome": "what happened (raw, 1-3 sentences)",
+ "state_patch": {JSON-patch for world state},
+ "time_advance": {"days": 0, "hours": 0, "minutes": 0} | null,
+ "narrative_prompt": "facts the step-writer should know to write the scene",
+ "next_options": ["option 1", "option 2", "option 3"],
+ "triggers": [{"fire_at": "world_time_str", "description": "...", "payload": {}}],
+ "rails_update": {"main_goal": "...", "new_subgoals": [...], "completed_subgoals": [...]} | null,
+ "rag_facts": [{"kind": "npc|location|item|lore|event", "name": "...", "description": "..."}]
+}
+
+CRITICAL:
+- Save tokens. 1-3 tool calls per iteration, max 5.
+- Skip dice_roll for trivial actions.
+- Do NOT write the narrative scene — that's the step-writer's job.
+- Stay in setting.
+"""
+
+
+# === Step Writer ===
+STEP_WRITER_SYSTEM_RU = """Ты — сценарист ролевой игры. Превращаешь сырой outcome в сценарный шаг как в книге.
+
+КОНТЕКСТ:
+- Сеттинг: {setting_description}
+- Текущее время мира: {current_time}
+- Состояние игрока: {player_state}
+- Что произошло (сырое): {outcome}
+- Дополнительные факты: {narrative_prompt}
+
+НАПИШИ:
+1. Сценарное описание (2-4 абзаца, кинематографично, от второго лица "Ты...").
+2. В конце — 3 опции следующего действия (короткие, 5-12 слов).
+
+Формат ответа (строгий JSON):
+{
+ "narrative": "...",
+ "options": ["...", "...", "..."]
+}
+
+ВАЖНО:
+- 200-400 слов сценария. Не больше.
+- Не повторяй то что игрок уже знает.
+- Заканчивай клиффхэнгером или моментом выбора.
+"""
+
+
+STEP_WRITER_SYSTEM_EN = """You are the narrative writer of a role-playing game. You turn raw outcome into a book-like scene.
+
+CONTEXT:
+- Setting: {setting_description}
+- Current world time: {current_time}
+- Player state: {player_state}
+- What happened (raw): {outcome}
+- Additional facts: {narrative_prompt}
+
+WRITE:
+1. Narrative description (2-4 paragraphs, cinematic, second-person "You...").
+2. End with 3 options for the next action (short, 5-12 words).
+
+Response format (strict JSON):
+{
+ "narrative": "...",
+ "options": ["...", "...", "..."]
+}
+
+CRITICAL:
+- 200-400 words of narrative. Not more.
+- Don't repeat what the player already knows.
+- End with a cliffhanger or decision moment.
+"""
+
+
+# === Summarizer ===
+SUMMARIZER_SYSTEM_RU = """Ты сжимаешь историю ролевой сессии. Дано несколько сообщений — выдай компактную сводку.
+
+Выведи:
+1. summary: 3-6 предложений ключевых событий и изменений состояния.
+2. facts: массив важных устойчивых фактов [{kind, name, description}] (коротко).
+
+Формат (строгий JSON):
+{"summary": "...", "facts": [{"kind": "npc|location|item|lore|event", "name": "...", "description": "..."}]}
+
+ВАЖНО: Не более 150 слов в summary. Сохраняй имена, числа, важные изменения.
+"""
+
+
+SUMMARIZER_SYSTEM_EN = """You compress the history of a role-playing session. Given several messages — produce a compact summary.
+
+Output:
+1. summary: 3-6 sentences of key events and state changes.
+2. facts: array of important persistent facts [{kind, name, description}] (brief).
+
+Format (strict JSON):
+{"summary": "...", "facts": [{"kind": "npc|location|item|lore|event", "name": "...", "description": "..."}]}
+
+CRITICAL: Max 150 words in summary. Preserve names, numbers, important changes.
+"""
+
+
+# === Sub-agent (clean context detail generator) ===
+SUBAGENT_SYSTEM_RU = """Ты — суб-агент с чистым контекстом. Получаешь задачу от главного GM, выдаёшь конкретный результат.
+
+Задача: {task}
+Контекст: {context}
+
+Дай компактный, сфокусированный ответ. Не более 150 слов.
+"""
+
+SUBAGENT_SYSTEM_EN = """You are a sub-agent with clean context. You receive a task from the main GM, return a specific result.
+
+Task: {task}
+Context: {context}
+
+Give a compact, focused answer. Max 150 words.
+"""
+
+
+# === Trigger runner ===
+TRIGGER_RUNNER_SYSTEM_RU = """Ты обрабатываешь отложенное событие в ролевой игре.
+
+Событие: {description}
+Payload: {payload}
+Текущее состояние мира: {state}
+
+Верни JSON:
+{
+ "outcome": "что произошло (1-2 предложения)",
+ "state_patch": {JSON-patch},
+ "narrative": "сценарное описание для игрока (1 абзац, опционально если игрок не видит — пустая строка)",
+ "should_notify_player": true|false
+}
+"""
+
+TRIGGER_RUNNER_SYSTEM_EN = """You process a deferred event in a role-playing game.
+
+Event: {description}
+Payload: {payload}
+Current world state: {state}
+
+Return JSON:
+{
+ "outcome": "what happened (1-2 sentences)",
+ "state_patch": {JSON-patch},
+ "narrative": "scene description for the player (1 paragraph, optional — empty string if player doesn't witness)",
+ "should_notify_player": true|false
+}
+"""
+
+
+PROMPTS = {
+ "ru": {
+ "world_builder": WORLD_BUILDER_SYSTEM_RU,
+ "orchestrator": ORCHESTRATOR_SYSTEM_RU,
+ "step_writer": STEP_WRITER_SYSTEM_RU,
+ "summarizer": SUMMARIZER_SYSTEM_RU,
+ "subagent": SUBAGENT_SYSTEM_RU,
+ "trigger_runner": TRIGGER_RUNNER_SYSTEM_RU,
+ },
+ "en": {
+ "world_builder": WORLD_BUILDER_SYSTEM_EN,
+ "orchestrator": ORCHESTRATOR_SYSTEM_EN,
+ "step_writer": STEP_WRITER_SYSTEM_EN,
+ "summarizer": SUMMARIZER_SYSTEM_EN,
+ "subagent": SUBAGENT_SYSTEM_EN,
+ "trigger_runner": TRIGGER_RUNNER_SYSTEM_EN,
+ },
+}
+
+
+def get_prompt(stage: str, language: str = "ru") -> str:
+ lang = language if language in PROMPTS else "ru"
+ return PROMPTS[lang].get(stage, PROMPTS["ru"][stage])
diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py
new file mode 100644
index 0000000..b78ffac
--- /dev/null
+++ b/backend/app/schemas/__init__.py
@@ -0,0 +1,238 @@
+"""Pydantic schemas for API request/response."""
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Dict, List, Optional
+from uuid import UUID
+
+from pydantic import BaseModel, ConfigDict, EmailStr, Field
+
+
+# === Auth ===
+class UserRegister(BaseModel):
+ email: EmailStr
+ username: str = Field(min_length=3, max_length=64)
+ password: str = Field(min_length=6, max_length=128)
+
+
+class UserLogin(BaseModel):
+ email: EmailStr
+ password: str
+
+
+class UserOut(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+ id: UUID
+ email: EmailStr
+ username: str
+ is_admin: bool
+ is_active: bool
+ preferred_language: str
+ created_at: datetime
+
+
+class TokenOut(BaseModel):
+ access_token: str
+ token_type: str = "bearer"
+ user: UserOut
+
+
+class AdminSetupRequest(BaseModel):
+ token: str
+ email: EmailStr
+ username: str = Field(min_length=3, max_length=64)
+ password: str = Field(min_length=6, max_length=128)
+
+
+# === Settings ===
+class SettingsUpdate(BaseModel):
+ values: Dict[str, Any]
+
+
+class SettingsOut(BaseModel):
+ values: Dict[str, Any]
+ editable_keys: List[str]
+
+
+# === Presets ===
+class PresetOut(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+ id: UUID
+ slug: str
+ title: str
+ description: Optional[str] = None
+ language: str
+ is_public: bool
+ is_builtin: bool
+ payload: Dict[str, Any]
+ created_at: datetime
+
+
+class PresetCreate(BaseModel):
+ slug: str
+ title: str
+ description: Optional[str] = None
+ language: str = "ru"
+ is_public: bool = True
+ payload: Dict[str, Any]
+
+
+# === Worlds ===
+class WorldCreate(BaseModel):
+ name: str = Field(min_length=1, max_length=255)
+ language: str = "ru"
+ preset_id: Optional[UUID] = None
+
+
+class WorldDefinition(BaseModel):
+ setting_description: str = ""
+ rules: Dict[str, Any] = Field(default_factory=dict)
+ world_schema: Dict[str, Any] = Field(default_factory=dict)
+ plot_rails: Dict[str, Any] = Field(default_factory=dict)
+ initial_state: Dict[str, Any] = Field(default_factory=dict)
+ initial_time: Optional[str] = None
+
+
+class WorldOut(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+ id: UUID
+ owner_id: UUID
+ name: str
+ language: str
+ definition: Dict[str, Any]
+ state: Dict[str, Any]
+ current_time: Optional[str]
+ status: str
+ preset_id: Optional[UUID] = None
+ created_at: datetime
+ updated_at: datetime
+
+
+class WorldUpdate(BaseModel):
+ name: Optional[str] = None
+ definition: Optional[Dict[str, Any]] = None
+ state: Optional[Dict[str, Any]] = None
+ current_time: Optional[str] = None
+ status: Optional[str] = None
+
+
+# === World Builder ===
+class WorldBuilderStart(BaseModel):
+ """Kick off a new world-building conversation."""
+ world_name: str = Field(min_length=1, max_length=255)
+ language: str = "ru"
+ # Either pick a preset to start from, or fill the freeform brief.
+ preset_id: Optional[UUID] = None
+ setting_brief: str = ""
+ character_brief: str = ""
+ rules_brief: str = ""
+ notes: str = ""
+
+
+class WorldBuilderMessage(BaseModel):
+ """User reply in the world-builder dialogue."""
+ session_id: UUID
+ message: str
+
+
+class WorldBuilderReply(BaseModel):
+ """AI reply in the world-builder dialogue."""
+ session_id: UUID
+ turn: int
+ ai_message: str
+ proposed_definition: Optional[WorldDefinition] = None
+ is_final: bool = False # True when AI thinks world is ready to commit
+ followup_questions: List[str] = Field(default_factory=list)
+
+
+class WorldBuilderCommit(BaseModel):
+ """User accepts the proposed world definition and creates the world."""
+ session_id: UUID
+ name: Optional[str] = None
+
+
+# === Sessions ===
+class SessionOut(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+ id: UUID
+ world_id: UUID
+ title: str
+ is_active: bool
+ created_at: datetime
+ last_played_at: Optional[datetime] = None
+
+
+class SessionCreate(BaseModel):
+ world_id: UUID
+ title: Optional[str] = None
+
+
+# === Messages ===
+class MessageOut(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+ id: UUID
+ seq: int
+ role: str
+ kind: str
+ content: str
+ payload: Dict[str, Any]
+ is_pinned: bool
+ hidden: bool
+ created_at: datetime
+
+
+# === Iteration ===
+class IterationRequest(BaseModel):
+ """Player submits an action/choice for the next iteration."""
+ session_id: UUID
+ action_text: str = Field(min_length=1, max_length=4000)
+
+
+class IterationEvent(BaseModel):
+ """SSE event sent to the frontend during an iteration."""
+ type: str # status / plan / tool_call / tool_result / narrative_chunk / step_complete / error / done
+ data: Dict[str, Any] = Field(default_factory=dict)
+
+
+# === Glossary ===
+class GlossaryEntryOut(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+ id: UUID
+ kind: str
+ name: str
+ description: str
+ payload: Dict[str, Any]
+
+
+# === LLM Logs ===
+class LlmLogOut(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+ id: UUID
+ purpose: str
+ model: str
+ base_url: str
+ prompt_tokens: Optional[int]
+ completion_tokens: Optional[int]
+ total_tokens: Optional[int]
+ latency_ms: Optional[int]
+ error: Optional[str]
+ created_at: datetime
+
+
+# === Triggers ===
+class TriggerOut(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+ id: UUID
+ session_id: UUID
+ fire_at: str
+ description: str
+ payload: Dict[str, Any]
+ fired: bool
+ created_at: datetime
+
+
+class TriggerCreate(BaseModel):
+ session_id: UUID
+ fire_at: str
+ description: str
+ payload: Dict[str, Any] = Field(default_factory=dict)
diff --git a/backend/app/workers/__init__.py b/backend/app/workers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/workers/main.py b/backend/app/workers/main.py
new file mode 100644
index 0000000..c83d6e0
--- /dev/null
+++ b/backend/app/workers/main.py
@@ -0,0 +1,23 @@
+"""Worker entrypoint: runs trigger checker + future background jobs."""
+from __future__ import annotations
+
+import asyncio
+
+from app.logging_setup import get_logger, setup_logging
+from app.workers.trigger_runner import main_loop as trigger_loop
+
+log = get_logger("worker")
+
+
+async def main():
+ setup_logging()
+ log.info("worker_starting")
+ # Run all background loops concurrently
+ await asyncio.gather(
+ trigger_loop(),
+ # Future: rag indexer, summary compactor, etc.
+ )
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/backend/app/workers/trigger_runner.py b/backend/app/workers/trigger_runner.py
new file mode 100644
index 0000000..691aa6a
--- /dev/null
+++ b/backend/app/workers/trigger_runner.py
@@ -0,0 +1,190 @@
+"""Trigger checker: scans due deferred triggers and fires them.
+
+The actual firing = creating a new narrative step for the player to see,
+OR a hidden technical message if the event is "offscreen".
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import re
+import uuid
+from typing import Any, Dict, List
+
+from sqlalchemy import select
+
+from app.core.llm import LlmClient
+from app.core.settings_service import cast_setting, get_all_settings
+from app.core.state_validator import apply_patch, validate_state
+from app.db import AsyncSessionLocal
+from app.logging_setup import get_logger, setup_logging
+from app.models import DeferredTrigger, Message, Session, World
+from app.prompts.templates import get_prompt
+
+log = get_logger("trigger_runner")
+
+
+def _parse_time(t: str) -> int:
+ m = re.match(r"day_(\d+)_hour_(\d+)", t or "")
+ if m:
+ return int(m.group(1)) * 24 * 60 + int(m.group(2)) * 60
+ try:
+ from datetime import datetime
+ return int(datetime.fromisoformat(t).timestamp() // 60)
+ except Exception:
+ return 0
+
+
+async def check_and_fire_triggers() -> int:
+ """Find all unfired triggers whose fire_at <= current world time, fire them.
+
+ Returns the number of triggers fired.
+ """
+ setup_logging()
+ async with AsyncSessionLocal() as db:
+ result = await db.execute(
+ select(DeferredTrigger, Session, World)
+ .join(Session, DeferredTrigger.session_id == Session.id)
+ .join(World, Session.world_id == World.id)
+ .where(DeferredTrigger.fired.is_(False))
+ )
+ rows = result.all()
+ if not rows:
+ return 0
+
+ fired = 0
+ for trigger, session, world in rows:
+ cur = _parse_time(world.current_time or "")
+ fire_at = _parse_time(trigger.fire_at)
+ if fire_at > cur:
+ continue
+ try:
+ await _fire_trigger(db, trigger, session, world)
+ fired += 1
+ except Exception as e:
+ log.error("trigger_fire_failed", trigger_id=str(trigger.id), error=str(e))
+ if fired:
+ await db.commit()
+ return fired
+
+
+async def _fire_trigger(db, trigger: DeferredTrigger, session: Session, world: World) -> None:
+ """Fire a single trigger: produce narrative + apply state patch."""
+ settings_map = await get_all_settings(db)
+ llm = LlmClient(settings_map)
+
+ system_prompt = get_prompt("trigger_runner", world.language).format(
+ description=trigger.description,
+ payload=json.dumps(trigger.payload, ensure_ascii=False)[:600],
+ state=json.dumps(world.state, ensure_ascii=False)[:1000],
+ )
+
+ response = await llm.chat(
+ messages=[{"role": "system", "content": system_prompt}],
+ temperature=0.5,
+ max_tokens=500,
+ purpose="trigger",
+ session_id=session.id,
+ db=db,
+ )
+
+ # Parse response
+ parsed: Dict[str, Any] = {}
+ m = re.search(r"\{[\s\S]*\}", response.text or "")
+ if m:
+ try:
+ parsed = json.loads(m.group(0))
+ except json.JSONDecodeError:
+ pass
+
+ # Apply state patch
+ state_patch = parsed.get("state_patch", {})
+ if state_patch:
+ new_state = apply_patch(world.state, state_patch)
+ schema = world.definition.get("world_schema", {})
+ ok, errors = validate_state(new_state, schema)
+ if ok:
+ world.state = new_state
+
+ narrative = parsed.get("narrative", "")
+ should_notify = bool(parsed.get("should_notify_player", True))
+
+ # Save as message
+ next_seq_result = await db.execute(
+ select(Message.seq).where(Message.session_id == session.id).order_by(Message.seq.desc()).limit(1)
+ )
+ row = next_seq_result.first()
+ next_seq = (row[0] + 1) if row else 1
+
+ if should_notify and narrative:
+ msg = Message(
+ session_id=session.id,
+ seq=next_seq,
+ role="system",
+ kind="narrative_step",
+ content=f"[Событие] {narrative}",
+ payload={
+ "trigger_id": str(trigger.id),
+ "triggered_at": trigger.fire_at,
+ "outcome": parsed.get("outcome", trigger.description),
+ "world_time": world.current_time,
+ "player_state": world.state.get("player", {}),
+ "options": [], # triggers don't usually offer choices
+ },
+ is_pinned=True,
+ hidden=False,
+ )
+ else:
+ msg = Message(
+ session_id=session.id,
+ seq=next_seq,
+ role="system",
+ kind="technical_offscreen",
+ content=f"[Trigger fired: {trigger.description}] Outcome: {parsed.get('outcome', '')}",
+ payload={
+ "trigger_id": str(trigger.id),
+ "outcome": parsed.get("outcome", ""),
+ "state_patch": state_patch,
+ },
+ is_pinned=False,
+ hidden=True,
+ )
+ db.add(msg)
+ trigger.fired = True
+ log.info("trigger_fired", trigger_id=str(trigger.id), session_id=str(session.id))
+
+
+async def main_loop():
+ """Main worker loop. Polls every N seconds for due triggers."""
+ setup_logging()
+ log.info("trigger_worker_started")
+ while True:
+ try:
+ async with AsyncSessionLocal() as db:
+ enabled = await _get_setting(db, "triggers.enabled", True)
+ interval = int(await _get_setting(db, "triggers.check_interval", 30))
+ if enabled:
+ fired = await check_and_fire_triggers()
+ if fired:
+ log.info("triggers_fired", count=fired)
+ except Exception as e:
+ log.error("trigger_worker_iteration_failed", error=str(e))
+ await asyncio.sleep(max(5, int(await _get_setting_sleep())))
+
+
+async def _get_setting(db, key: str, default):
+ from app.models import Setting
+ result = await db.execute(select(Setting).where(Setting.key == key))
+ row = result.scalars().first()
+ if row is None:
+ return default
+ return cast_setting(key, row.value)
+
+
+async def _get_setting_sleep() -> int:
+ async with AsyncSessionLocal() as db:
+ return int(await _get_setting(db, "triggers.check_interval", 30))
+
+
+if __name__ == "__main__":
+ asyncio.run(main_loop())
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..188a4f3
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,22 @@
+fastapi==0.115.0
+uvicorn[standard]==0.30.6
+sqlalchemy[asyncio]==2.0.35
+asyncpg==0.29.0
+alembic==1.13.3
+pydantic==2.9.2
+pydantic-settings==2.5.2
+python-jose[cryptography]==3.3.0
+passlib[bcrypt]==1.7.4
+bcrypt==4.0.1
+python-multipart==0.0.12
+httpx==0.27.2
+sse-starlette==2.1.3
+redis==5.0.8
+rq==1.16.2
+qdrant-client==1.11.3
+jsonschema==4.23.0
+structlog==24.4.0
+tenacity==9.0.0
+backoff==2.2.1
+tiktoken==0.7.0
+psycopg2-binary==2.9.9
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..3939c09
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,122 @@
+version: "3.9"
+
+services:
+ postgres:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB:-airpg}
+ POSTGRES_USER: ${POSTGRES_USER:-airpg}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-airpg_secret}
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ ports:
+ - "5432:5432"
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-airpg}"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+ redis:
+ image: redis:7-alpine
+ restart: unless-stopped
+ ports:
+ - "6379:6379"
+ command: ["redis-server", "--appendonly", "yes"]
+ volumes:
+ - redisdata:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+
+ qdrant:
+ image: qdrant/qdrant:v1.11.3
+ restart: unless-stopped
+ ports:
+ - "6333:6333"
+ - "6334:6334"
+ volumes:
+ - qdrantdata:/qdrant/storage
+
+ backend:
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ qdrant:
+ condition: service_started
+ environment:
+ DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-airpg}:${POSTGRES_PASSWORD:-airpg_secret}@postgres:5432/${POSTGRES_DB:-airpg}
+ REDIS_URL: redis://redis:6379/0
+ QDRANT_URL: http://qdrant:6333
+ JWT_SECRET: ${JWT_SECRET:-change_me_in_production_please}
+ ADMIN_SETUP_TOKEN: ${ADMIN_SETUP_TOKEN:-}
+ CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://localhost:8080}
+ LOG_LEVEL: ${LOG_LEVEL:-INFO}
+ # Default LLM settings (overridable via admin panel)
+ DEFAULT_LLM_BASE_URL: ${DEFAULT_LLM_BASE_URL:-http://host.docker.internal:1234/v1}
+ DEFAULT_LLM_API_KEY: ${DEFAULT_LLM_API_KEY:-dummy}
+ DEFAULT_LLM_MODEL: ${DEFAULT_LLM_MODEL:-local-model}
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./backend:/app
+ # Print admin setup token to console on first run
+ stdin_open: true
+ tty: true
+
+ worker:
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ qdrant:
+ condition: service_started
+ environment:
+ DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-airpg}:${POSTGRES_PASSWORD:-airpg_secret}@postgres:5432/${POSTGRES_DB:-airpg}
+ REDIS_URL: redis://redis:6379/0
+ QDRANT_URL: http://qdrant:6333
+ JWT_SECRET: ${JWT_SECRET:-change_me_in_production_please}
+ LOG_LEVEL: ${LOG_LEVEL:-INFO}
+ DEFAULT_LLM_BASE_URL: ${DEFAULT_LLM_BASE_URL:-http://host.docker.internal:1234/v1}
+ DEFAULT_LLM_API_KEY: ${DEFAULT_LLM_API_KEY:-dummy}
+ DEFAULT_LLM_MODEL: ${DEFAULT_LLM_MODEL:-local-model}
+ WORKER_MODE: "1"
+ command: ["python", "-m", "app.workers.main"]
+ volumes:
+ - ./backend:/app
+
+ frontend:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ target: ${FRONTEND_BUILD_TARGET:-dev}
+ restart: unless-stopped
+ depends_on:
+ - backend
+ environment:
+ VITE_API_URL: ${VITE_API_URL:-http://localhost:8000}
+ ports:
+ - "5173:5173"
+ - "8080:80"
+ volumes:
+ - ./frontend:/app
+ - /app/node_modules
+
+volumes:
+ pgdata:
+ redisdata:
+ qdrantdata:
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..ee2535a
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,23 @@
+# Build stage
+FROM node:20-alpine AS builder
+WORKDIR /app
+COPY package.json package-lock.json* ./
+RUN npm install
+COPY . .
+RUN npm run build
+
+# Dev stage (default target)
+FROM node:20-alpine AS dev
+WORKDIR /app
+COPY package.json package-lock.json* ./
+RUN npm install
+COPY . .
+EXPOSE 5173
+CMD ["npm", "run", "dev"]
+
+# Prod stage
+FROM nginx:alpine AS prod
+COPY --from=builder /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..0dd4b7f
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ AI RPG
+
+
+
+
+
+
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
new file mode 100644
index 0000000..0bc5d54
--- /dev/null
+++ b/frontend/nginx.conf
@@ -0,0 +1,23 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ location /api/ {
+ 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;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ # SSE support
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 300s;
+ proxy_http_version 1.1;
+ }
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..e2b57b3
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,4388 @@
+{
+ "name": "ai-rpg-frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ai-rpg-frontend",
+ "version": "0.1.0",
+ "dependencies": {
+ "@microsoft/fetch-event-source": "^2.0.1",
+ "axios": "^1.7.7",
+ "clsx": "^2.1.1",
+ "i18next": "^23.15.1",
+ "i18next-browser-languagedetector": "^8.0.0",
+ "lucide-react": "^0.445.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-i18next": "^15.0.2",
+ "react-markdown": "^9.0.1",
+ "react-router-dom": "^6.26.2",
+ "tailwind-merge": "^2.5.2",
+ "zustand": "^4.5.5"
+ },
+ "devDependencies": {
+ "@types/node": "^22.7.4",
+ "@types/react": "^18.3.11",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.3.2",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.4.47",
+ "tailwindcss": "^3.4.13",
+ "typescript": "^5.6.2",
+ "vite": "^5.4.8"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@microsoft/fetch-event-source": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz",
+ "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==",
+ "license": "MIT"
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@remix-run/router": {
+ "version": "1.23.3",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
+ "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz",
+ "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz",
+ "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz",
+ "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz",
+ "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz",
+ "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz",
+ "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz",
+ "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz",
+ "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz",
+ "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz",
+ "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz",
+ "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz",
+ "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz",
+ "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz",
+ "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz",
+ "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz",
+ "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz",
+ "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz",
+ "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz",
+ "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz",
+ "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz",
+ "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz",
+ "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz",
+ "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz",
+ "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz",
+ "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/debug": {
+ "version": "4.1.13",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
+ "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
+ "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
+ "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.21",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
+ "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.31",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
+ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz",
+ "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==",
+ "license": "ISC"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
+ "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.2",
+ "caniuse-lite": "^1.0.30001787",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/axios": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
+ "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.16.0",
+ "form-data": "^4.0.5",
+ "https-proxy-agent": "^5.0.1",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
+ "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.38",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
+ "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001799",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
+ "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
+ "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
+ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decode-named-character-reference": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
+ "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.375",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz",
+ "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/estree-util-is-identifier-name": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
+ "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.4",
+ "mime-types": "^2.1.35"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hast-util-to-jsx-runtime": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
+ "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "estree-util-is-identifier-name": "^3.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "mdast-util-mdx-expression": "^2.0.0",
+ "mdast-util-mdx-jsx": "^3.0.0",
+ "mdast-util-mdxjs-esm": "^2.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "style-to-js": "^1.0.0",
+ "unist-util-position": "^5.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/html-parse-stringify": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+ "license": "MIT",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
+ "node_modules/html-url-attributes": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
+ "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/i18next": {
+ "version": "23.16.8",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz",
+ "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://locize.com"
+ },
+ {
+ "type": "individual",
+ "url": "https://locize.com/i18next.html"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.23.2"
+ }
+ },
+ "node_modules/i18next-browser-languagedetector": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
+ "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.23.2"
+ }
+ },
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+ "license": "MIT"
+ },
+ "node_modules/is-alphabetical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
+ "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
+ "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^2.0.0",
+ "is-decimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.2",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-decimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
+ "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-hexadecimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
+ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
+ "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.445.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.445.0.tgz",
+ "integrity": "sha512-YrLf3aAHvmd4dZ8ot+mMdNFrFpJD7YRwQ2pUcBhgqbmxtrMP4xDzIorcj+8y+6kpuXBF4JB0NOCTUWIYetJjgA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
+ "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark": "^4.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-expression": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
+ "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
+ "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "parse-entities": "^4.0.0",
+ "stringify-entities": "^4.0.0",
+ "unist-util-stringify-position": "^4.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdxjs-esm": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
+ "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-phrasing": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
+ "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-markdown": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
+ "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-phrasing": "^4.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "unist-util-visit": "^5.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
+ "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromark": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
+ "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.0.0",
+ "debug": "^4.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-core-commonmark": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
+ "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-factory-destination": "^2.0.0",
+ "micromark-factory-label": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-factory-title": "^2.0.0",
+ "micromark-factory-whitespace": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-destination": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
+ "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-label": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
+ "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-space": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
+ "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-title": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
+ "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-whitespace": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
+ "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-chunked": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
+ "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-classify-character": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
+ "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-combine-extensions": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
+ "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-numeric-character-reference": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
+ "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-string": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
+ "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-html-tag-name": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
+ "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-normalize-identifier": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
+ "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-resolve-all": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
+ "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-subtokenize": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
+ "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.13",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz",
+ "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.48",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
+ "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/parse-entities": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
+ "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "character-entities-legacy": "^3.0.0",
+ "character-reference-invalid": "^2.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "is-alphanumerical": "^2.0.0",
+ "is-decimal": "^2.0.0",
+ "is-hexadecimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-entities/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
+ "license": "MIT"
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.4",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
+ "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/property-information": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
+ "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-i18next": {
+ "version": "15.7.4",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
+ "integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.27.6",
+ "html-parse-stringify": "^3.0.1"
+ },
+ "peerDependencies": {
+ "i18next": ">= 23.4.0",
+ "react": ">= 16.8.0",
+ "typescript": "^5"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-markdown": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz",
+ "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "6.30.4",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
+ "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.4",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
+ "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.3",
+ "react-router": "6.30.4"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/remark-parse": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
+ "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-rehype": {
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
+ "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "unified": "^11.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz",
+ "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.0",
+ "@rollup/rollup-android-arm64": "4.62.0",
+ "@rollup/rollup-darwin-arm64": "4.62.0",
+ "@rollup/rollup-darwin-x64": "4.62.0",
+ "@rollup/rollup-freebsd-arm64": "4.62.0",
+ "@rollup/rollup-freebsd-x64": "4.62.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.0",
+ "@rollup/rollup-linux-arm64-musl": "4.62.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.0",
+ "@rollup/rollup-linux-loong64-musl": "4.62.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.0",
+ "@rollup/rollup-linux-x64-gnu": "4.62.0",
+ "@rollup/rollup-linux-x64-musl": "4.62.0",
+ "@rollup/rollup-openbsd-x64": "4.62.0",
+ "@rollup/rollup-openharmony-arm64": "4.62.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.0",
+ "@rollup/rollup-win32-x64-gnu": "4.62.0",
+ "@rollup/rollup-win32-x64-msvc": "4.62.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/style-to-js": {
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
+ "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.14"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
+ "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
+ "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..a196297
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "ai-rpg-frontend",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 0.0.0.0 --port 5173",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview --host 0.0.0.0 --port 8080",
+ "lint": "eslint . --ext ts,tsx"
+ },
+ "dependencies": {
+ "@microsoft/fetch-event-source": "^2.0.1",
+ "axios": "^1.7.7",
+ "clsx": "^2.1.1",
+ "i18next": "^23.15.1",
+ "i18next-browser-languagedetector": "^8.0.0",
+ "lucide-react": "^0.445.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-i18next": "^15.0.2",
+ "react-router-dom": "^6.26.2",
+ "react-markdown": "^9.0.1",
+ "tailwind-merge": "^2.5.2",
+ "zustand": "^4.5.5"
+ },
+ "devDependencies": {
+ "@types/node": "^22.7.4",
+ "@types/react": "^18.3.11",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.3.2",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.4.47",
+ "tailwindcss": "^3.4.13",
+ "typescript": "^5.6.2",
+ "vite": "^5.4.8"
+ }
+}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..2aa7205
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/frontend/public/.gitkeep b/frontend/public/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..6de96dd
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,91 @@
+import { Routes, Route, Navigate } from "react-router-dom";
+import { useAuthStore } from "@/store/auth";
+import { Navbar } from "@/components/ui/Navbar";
+import { HomePage } from "@/pages/HomePage";
+import { LoginPage } from "@/pages/LoginPage";
+import { RegisterPage } from "@/pages/RegisterPage";
+import { AdminSetupPage } from "@/pages/AdminSetupPage";
+import { DashboardPage } from "@/pages/DashboardPage";
+import { WorldCreatePage } from "@/pages/WorldCreatePage";
+import { WorldBuilderPage } from "@/pages/WorldBuilderPage";
+import { WorldEditPage } from "@/pages/WorldEditPage";
+import { SessionPage } from "@/pages/SessionPage";
+import { AdminPanelPage } from "@/pages/AdminPanelPage";
+
+function PrivateRoute({ children }: { children: JSX.Element }) {
+ const token = useAuthStore((s) => s.token);
+ if (!token) return ;
+ return children;
+}
+
+function AdminRoute({ children }: { children: JSX.Element }) {
+ const { token, user } = useAuthStore();
+ if (!token) return ;
+ if (!user?.is_admin) return ;
+ return children;
+}
+
+export default function App() {
+ return (
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ } />
+
+
+
+ );
+}
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
new file mode 100644
index 0000000..c0ae778
--- /dev/null
+++ b/frontend/src/api/index.ts
@@ -0,0 +1,190 @@
+import axios, { AxiosError } from "axios";
+import type {
+ GlossaryEntry,
+ LlmLog,
+ Message,
+ Preset,
+ Session,
+ SettingsOut,
+ TokenOut,
+ Trigger,
+ User,
+ World,
+ WorldBuilderReply,
+} from "@/types";
+import { useAuthStore } from "@/store/auth";
+
+const API_BASE = "/api";
+
+const api = axios.create({
+ baseURL: API_BASE,
+ headers: { "Content-Type": "application/json" },
+});
+
+// Inject auth token
+api.interceptors.request.use((config) => {
+ const token = useAuthStore.getState().token;
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+});
+
+// Auto-logout on 401
+api.interceptors.response.use(
+ (r) => r,
+ (err: AxiosError) => {
+ if (err.response?.status === 401) {
+ useAuthStore.getState().logout();
+ }
+ return Promise.reject(err);
+ }
+);
+
+export const authApi = {
+ register: async (email: string, username: string, password: string): Promise => {
+ const { data } = await api.post("/auth/register", { email, username, password });
+ return data;
+ },
+ login: async (email: string, password: string): Promise => {
+ const { data } = await api.post("/auth/login", { email, password });
+ return data;
+ },
+ me: async (): Promise => {
+ const { data } = await api.get("/auth/me");
+ return data;
+ },
+ adminSetup: async (token: string, email: string, username: string, password: string): Promise => {
+ const { data } = await api.post("/auth/admin-setup", { token, email, username, password });
+ return data;
+ },
+};
+
+export const adminApi = {
+ getSettings: async (): Promise => {
+ const { data } = await api.get("/admin/settings");
+ return data;
+ },
+ updateSettings: async (values: Record): Promise => {
+ const { data } = await api.put("/admin/settings", { values });
+ return data;
+ },
+ testEmbeddings: async (overrides?: Record): Promise<{
+ ok: boolean;
+ provider?: string;
+ base_url?: string;
+ model?: string;
+ dim?: number;
+ sample_norm?: number;
+ error?: string;
+ }> => {
+ const { data } = await api.post("/admin/embeddings/test", { overrides: overrides || {} });
+ return data;
+ },
+ listLlmLogs: async (limit = 50, offset = 0): Promise => {
+ const { data } = await api.get(`/admin/llm-logs?limit=${limit}&offset=${offset}`);
+ return data;
+ },
+ getLlmLog: async (id: string): Promise => {
+ const { data } = await api.get(`/admin/llm-logs/${id}`);
+ return data;
+ },
+ listUsers: async (): Promise => {
+ const { data } = await api.get("/admin/users");
+ return data;
+ },
+};
+
+export const presetsApi = {
+ list: async (language?: string): Promise => {
+ const url = language ? `/presets?language=${language}` : "/presets";
+ const { data } = await api.get(url);
+ return data;
+ },
+ get: async (id: string): Promise => {
+ const { data } = await api.get(`/presets/${id}`);
+ return data;
+ },
+ create: async (payload: Partial): Promise => {
+ const { data } = await api.post("/presets", payload);
+ return data;
+ },
+};
+
+export const worldsApi = {
+ list: async (): Promise => {
+ const { data } = await api.get("/worlds");
+ return data;
+ },
+ get: async (id: string): Promise => {
+ const { data } = await api.get(`/worlds/${id}`);
+ return data;
+ },
+ create: async (name: string, language: string, preset_id?: string): Promise => {
+ const { data } = await api.post("/worlds", { name, language, preset_id });
+ return data;
+ },
+ update: async (id: string, payload: Partial): Promise => {
+ const { data } = await api.patch(`/worlds/${id}`, payload);
+ return data;
+ },
+ delete: async (id: string): Promise => {
+ await api.delete(`/worlds/${id}`);
+ },
+ builderStart: async (payload: {
+ world_name: string;
+ language: string;
+ preset_id?: string;
+ setting_brief: string;
+ character_brief: string;
+ rules_brief: string;
+ notes: string;
+ }): Promise => {
+ const { data } = await api.post("/worlds/builder/start", payload);
+ return data;
+ },
+ builderContinue: async (session_id: string, message: string): Promise => {
+ const { data } = await api.post("/worlds/builder/continue", { session_id, message });
+ return data;
+ },
+ builderCommit: async (session_id: string, name?: string): Promise => {
+ const { data } = await api.post("/worlds/builder/commit", { session_id, name });
+ return data;
+ },
+};
+
+export const sessionsApi = {
+ list: async (): Promise => {
+ const { data } = await api.get("/sessions");
+ return data;
+ },
+ create: async (world_id: string, title?: string): Promise => {
+ const { data } = await api.post("/sessions", { world_id, title });
+ return data;
+ },
+ get: async (id: string): Promise => {
+ const { data } = await api.get(`/sessions/${id}`);
+ return data;
+ },
+ listMessages: async (id: string, includeHidden = false): Promise => {
+ const { data } = await api.get(`/sessions/${id}/messages?include_hidden=${includeHidden}`);
+ return data;
+ },
+ delete: async (id: string): Promise => {
+ await api.delete(`/sessions/${id}`);
+ },
+};
+
+export const miscApi = {
+ listGlossary: async (worldId: string, kind?: string): Promise => {
+ const url = kind ? `/worlds/${worldId}/glossary?kind=${kind}` : `/worlds/${worldId}/glossary`;
+ const { data } = await api.get(url);
+ return data;
+ },
+ listTriggers: async (sessionId: string, includeFired = true): Promise => {
+ const { data } = await api.get(`/sessions/${sessionId}/triggers?include_fired=${includeFired}`);
+ return data;
+ },
+};
+
+export const SSE_ENDPOINT = "/api/sessions";
diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx
new file mode 100644
index 0000000..2245f13
--- /dev/null
+++ b/frontend/src/components/ui/Button.tsx
@@ -0,0 +1,37 @@
+import { ButtonHTMLAttributes, forwardRef } from "react";
+import { cn } from "./cn";
+
+interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: "primary" | "secondary" | "ghost" | "danger" | "outline";
+ size?: "sm" | "md" | "lg";
+}
+
+export const Button = forwardRef(
+ ({ className, variant = "primary", size = "md", ...props }, ref) => {
+ const variants = {
+ primary: "bg-accent-500 hover:bg-accent-600 text-white shadow-md shadow-accent-500/20",
+ secondary: "bg-ink-800 hover:bg-ink-700 text-ink-100 border border-ink-700",
+ ghost: "bg-transparent hover:bg-ink-800 text-ink-200",
+ danger: "bg-red-600 hover:bg-red-700 text-white",
+ outline: "bg-transparent border border-ink-600 hover:bg-ink-800 text-ink-100",
+ };
+ const sizes = {
+ sm: "px-2.5 py-1.5 text-xs",
+ md: "px-4 py-2 text-sm",
+ lg: "px-6 py-3 text-base",
+ };
+ return (
+
+ );
+ }
+);
+Button.displayName = "Button";
diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx
new file mode 100644
index 0000000..b1ab3e9
--- /dev/null
+++ b/frontend/src/components/ui/Card.tsx
@@ -0,0 +1,26 @@
+import { ReactNode } from "react";
+import { cn } from "./cn";
+
+export function Card({ className, children }: { className?: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function CardHeader({ title, subtitle, action }: { title: string; subtitle?: string; action?: ReactNode }) {
+ return (
+
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+ {action}
+
+ );
+}
+
+export function CardBody({ className, children }: { className?: string; children: ReactNode }) {
+ return {children}
;
+}
diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx
new file mode 100644
index 0000000..7abfb96
--- /dev/null
+++ b/frontend/src/components/ui/Input.tsx
@@ -0,0 +1,70 @@
+import { InputHTMLAttributes, TextareaHTMLAttributes, forwardRef } from "react";
+import { cn } from "./cn";
+
+interface InputProps extends InputHTMLAttributes {
+ label?: string;
+ error?: string;
+}
+
+export const Input = forwardRef(
+ ({ className, label, error, id, ...props }, ref) => {
+ const inputId = id || props.name;
+ return (
+
+ {label && (
+
+ )}
+
+ {error &&
{error}
}
+
+ );
+ }
+);
+Input.displayName = "Input";
+
+interface TextareaProps extends TextareaHTMLAttributes {
+ label?: string;
+ error?: string;
+}
+
+export const Textarea = forwardRef(
+ ({ className, label, error, id, ...props }, ref) => {
+ const inputId = id || props.name;
+ return (
+
+ {label && (
+
+ )}
+
+ {error &&
{error}
}
+
+ );
+ }
+);
+Textarea.displayName = "Textarea";
diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx
new file mode 100644
index 0000000..92bb814
--- /dev/null
+++ b/frontend/src/components/ui/Modal.tsx
@@ -0,0 +1,60 @@
+import { ReactNode, useEffect } from "react";
+import { X } from "lucide-react";
+import { cn } from "./cn";
+
+interface ModalProps {
+ open: boolean;
+ onClose: () => void;
+ title?: string;
+ children: ReactNode;
+ size?: "sm" | "md" | "lg" | "xl";
+}
+
+export function Modal({ open, onClose, title, children, size = "md" }: ModalProps) {
+ useEffect(() => {
+ if (!open) return;
+ const onEsc = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ document.addEventListener("keydown", onEsc);
+ return () => document.removeEventListener("keydown", onEsc);
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ const sizes = {
+ sm: "max-w-md",
+ md: "max-w-2xl",
+ lg: "max-w-4xl",
+ xl: "max-w-6xl",
+ };
+
+ return (
+
+
e.stopPropagation()}
+ >
+ {title && (
+
+
{title}
+
+
+ )}
+
{children}
+
+
+ );
+}
diff --git a/frontend/src/components/ui/Navbar.tsx b/frontend/src/components/ui/Navbar.tsx
new file mode 100644
index 0000000..81d4ad1
--- /dev/null
+++ b/frontend/src/components/ui/Navbar.tsx
@@ -0,0 +1,99 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Link, useNavigate } from "react-router-dom";
+import { LogOut, Shield, Globe, BookOpen } from "lucide-react";
+import { useAuthStore } from "@/store/auth";
+import { Button } from "./Button";
+import { cn } from "./cn";
+
+export function Navbar() {
+ const { t, i18n } = useTranslation();
+ const { user, logout, isAdmin } = useAuthStore();
+ const navigate = useNavigate();
+ const [langOpen, setLangOpen] = useState(false);
+
+ const handleLogout = () => {
+ logout();
+ navigate("/");
+ };
+
+ const changeLang = (lang: string) => {
+ i18n.changeLanguage(lang);
+ setLangOpen(false);
+ };
+
+ return (
+
+
+
+
+
{t("app.title")}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/ui/cn.ts b/frontend/src/components/ui/cn.ts
new file mode 100644
index 0000000..a5ef193
--- /dev/null
+++ b/frontend/src/components/ui/cn.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/frontend/src/components/ui/ui-overview.ts b/frontend/src/components/ui/ui-overview.ts
new file mode 100644
index 0000000..d12b9e7
--- /dev/null
+++ b/frontend/src/components/ui/ui-overview.ts
@@ -0,0 +1,5 @@
+export { Modal } from "./Modal";
+export { Button } from "./Button";
+export { Input, Textarea } from "./Input";
+export { Card, CardBody, CardHeader } from "./Card";
+export { cn } from "./cn";
diff --git a/frontend/src/components/world/CharacterSheet.tsx b/frontend/src/components/world/CharacterSheet.tsx
new file mode 100644
index 0000000..c2e2ec0
--- /dev/null
+++ b/frontend/src/components/world/CharacterSheet.tsx
@@ -0,0 +1,104 @@
+import { useTranslation } from "react-i18next";
+
+interface CharacterSheetProps {
+ state: Record;
+}
+
+export function CharacterSheet({ state }: CharacterSheetProps) {
+ const { t } = useTranslation();
+ if (!state || !state.player) {
+ return {t("character.empty")}
;
+ }
+ const p = state.player;
+ const stats = p.stats || {};
+
+ return (
+
+
+ {t("character.name")}:
+ {p.name}
+
+ {(p.race || p.class) && (
+
+ {p.race} {p.class ? `· ${p.class}` : ""} {p.level ? `· Lvl ${p.level}` : ""}
+
+ )}
+ {p.location && (
+
+ {t("character.location")}:
+ {p.location}
+
+ )}
+
+
+
+
+ {p.gold !== undefined && (
+
+ {t("character.gold")}:
+ {p.gold}
+
+ )}
+
+ {(stats.strength || stats.dexterity || stats.intelligence) && (
+
+ {["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"].map((s) =>
+ stats[s] !== undefined ? (
+
+
{s.slice(0, 3)}
+
{stats[s]}
+
+ ) : null
+ )}
+
+ )}
+ {p.inventory && p.inventory.length > 0 && (
+
+
{t("character.inventory")}
+
+ {p.inventory.slice(0, 8).map((item: any, i: number) => (
+ -
+ {item.name}
+ {item.qty > 1 ? ` ×${item.qty}` : ""}
+
+ ))}
+ {p.inventory.length > 8 && - +{p.inventory.length - 8}…
}
+
+
+ )}
+ {p.effects && p.effects.length > 0 && (
+
+
{t("character.effects")}
+
+ {p.effects.map((e: any, i: number) => (
+
+ {e.name || JSON.stringify(e)}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+function Stat({ label, value, max, color }: { label: string; value?: number; max?: number; color: string }) {
+ if (value === undefined) return null;
+ const pct = max ? Math.max(0, Math.min(100, (value / max) * 100)) : 0;
+ return (
+
+
+ {label}
+
+ {value}
+ {max ? `/${max}` : ""}
+
+
+ {max && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/world/GlossaryModal.tsx b/frontend/src/components/world/GlossaryModal.tsx
new file mode 100644
index 0000000..7559b0b
--- /dev/null
+++ b/frontend/src/components/world/GlossaryModal.tsx
@@ -0,0 +1,60 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Modal } from "@/components/ui/ui-overview";
+import type { GlossaryEntry } from "@/types";
+import { BookOpen } from "lucide-react";
+
+interface GlossaryModalProps {
+ open: boolean;
+ onClose: () => void;
+ entries: GlossaryEntry[];
+}
+
+export function GlossaryModal({ open, onClose, entries }: GlossaryModalProps) {
+ const { t } = useTranslation();
+ const [filter, setFilter] = useState("all");
+
+ const kinds = ["all", "npc", "location", "item", "lore", "event", "rule"];
+ const filtered = filter === "all" ? entries : entries.filter((e) => e.kind === filter);
+
+ return (
+
+
+ {kinds.map((k) => (
+
+ ))}
+
+
+ {filtered.length === 0 ? (
+
+
+
{t("glossary.empty")}
+
+ ) : (
+
+ {filtered.map((e) => (
+
+
+
+ {e.kind}
+
+
{e.name}
+
+
{e.description}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts
new file mode 100644
index 0000000..078e108
--- /dev/null
+++ b/frontend/src/i18n/en.ts
@@ -0,0 +1,168 @@
+export const en = {
+ app: {
+ title: "AI RPG",
+ subtitle: "Flexible AI-powered role-playing game",
+ },
+ nav: {
+ home: "Home",
+ dashboard: "My worlds",
+ admin: "Admin",
+ logout: "Logout",
+ login: "Login",
+ register: "Register",
+ language: "Language",
+ },
+ auth: {
+ login_title: "Login",
+ register_title: "Register",
+ email: "Email",
+ username: "Username",
+ password: "Password",
+ login_btn: "Login",
+ register_btn: "Register",
+ no_account: "No account? Register",
+ have_account: "Have an account? Login",
+ admin_setup_title: "Admin setup",
+ admin_setup_desc: "Enter the token printed to the backend console to create the first admin user.",
+ admin_setup_token: "Setup token",
+ setup_btn: "Create admin",
+ },
+ worlds: {
+ title: "Your worlds",
+ new: "Create new world",
+ empty: "You don't have any worlds yet.",
+ preset_choice: "Pick a preset or start from scratch",
+ use_preset: "From preset",
+ from_scratch: "From scratch",
+ name: "World name",
+ language: "World language",
+ create: "Create",
+ cancel: "Cancel",
+ start: "Start game",
+ continue: "Continue",
+ edit: "Edit",
+ delete: "Delete",
+ builder_title: "World builder",
+ builder_desc: "Describe your world — the AI will help you build it out.",
+ setting_brief: "Setting",
+ setting_brief_ph: "Dark fantasy in a late-medieval style...",
+ character_brief: "Character",
+ character_brief_ph: "Young adventurer seeking fame...",
+ rules_brief: "World rules",
+ rules_brief_ph: "Mana-based magic system, d20 turn-based combat...",
+ notes: "Additional notes",
+ notes_ph: "I want a complex faction reputation system...",
+ start_builder: "Start dialogue with AI",
+ builder_history: "Dialogue history",
+ builder_message_ph: "Your reply or edits...",
+ send: "Send",
+ accept: "Accept world and create",
+ accepting: "Creating world...",
+ status_draft: "Draft",
+ status_ready: "Ready",
+ status_active: "Active",
+ status_archived: "Archived",
+ },
+ session: {
+ title: "Session",
+ back: "Back to worlds",
+ glossary: "Glossary",
+ character: "Character",
+ edit_world: "Edit world",
+ history: "History",
+ action_placeholder: "What do you do?",
+ send: "Act",
+ sending: "Thinking...",
+ options: "Options",
+ custom_action: "Or your own action",
+ status_planning: "Planning...",
+ status_orchestrator_turn: "GM turn...",
+ status_writing_scene: "Writing scene...",
+ world_time: "World time",
+ triggers_panel: "Deferred events",
+ no_messages: "Start with your first action!",
+ new_session: "New session",
+ error_iter: "Iteration failed",
+ },
+ admin: {
+ title: "Admin panel",
+ settings: "LLM settings",
+ base_url: "OpenAI-compatible endpoint",
+ api_key: "API key",
+ model: "Model",
+ temperature: "Temperature (orchestrator)",
+ step_temperature: "Temperature (step writer)",
+ summary_temperature: "Temperature (summarizer)",
+ max_tokens: "Max tokens",
+ request_timeout: "Timeout (sec)",
+ streaming: "Streaming",
+ context_settings: "Context manager",
+ recent_messages: "Guaranteed recent messages",
+ compress_threshold: "Compression threshold",
+ summary_messages: "Messages per summary",
+ max_tokens_total: "Context token budget",
+ trigger_settings: "Deferred triggers",
+ triggers_enabled: "Enabled",
+ triggers_check_interval: "Check interval (sec)",
+ embedding_settings: "Embeddings (RAG)",
+ embedding_provider: "Provider",
+ embedding_provider_hash: "Hash (offline fallback, no semantics)",
+ embedding_provider_openai: "OpenAI-compatible /embeddings",
+ embedding_base_url: "Endpoint (empty = same as LLM)",
+ embedding_api_key: "API key (empty = same as LLM)",
+ embedding_model: "Embedding model",
+ embedding_dim: "Dimension (0 = auto-probe)",
+ embedding_timeout: "Timeout (sec)",
+ embedding_test: "Test embeddings",
+ embedding_testing: "Testing...",
+ embedding_test_ok: "OK: {{provider}} | dim={{dim}} | norm={{norm}}",
+ embedding_test_fail: "Error: {{error}}",
+ save: "Save",
+ saved: "Saved!",
+ llm_logs: "LLM logs",
+ users: "Users",
+ back: "Back",
+ },
+ glossary: {
+ title: "World glossary",
+ npc: "NPCs",
+ location: "Locations",
+ item: "Items",
+ lore: "Lore",
+ event: "Events",
+ rule: "Rules",
+ empty: "Nothing yet. The AI will fill the glossary as you play.",
+ },
+ character: {
+ title: "Character sheet",
+ name: "Name",
+ race: "Race",
+ class: "Class",
+ level: "Level",
+ stats: "Stats",
+ inventory: "Inventory",
+ effects: "Effects",
+ gold: "Gold",
+ location: "Location",
+ empty: "No character data.",
+ },
+ common: {
+ save: "Save",
+ cancel: "Cancel",
+ delete: "Delete",
+ edit: "Edit",
+ confirm: "Confirm",
+ yes: "Yes",
+ no: "No",
+ loading: "Loading...",
+ error: "Error",
+ success: "Done",
+ not_found: "Not found",
+ forbidden: "Forbidden",
+ },
+ errors: {
+ network: "Network error. Check your connection.",
+ unauthorized: "Unauthorized.",
+ unknown: "Unknown error.",
+ },
+};
diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts
new file mode 100644
index 0000000..3feb494
--- /dev/null
+++ b/frontend/src/i18n/index.ts
@@ -0,0 +1,24 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import LanguageDetector from "i18next-browser-languagedetector";
+import { ru } from "./ru";
+import { en } from "./en";
+
+i18n
+ .use(LanguageDetector)
+ .use(initReactI18next)
+ .init({
+ resources: {
+ ru: { translation: ru },
+ en: { translation: en },
+ },
+ fallbackLng: "ru",
+ supportedLngs: ["ru", "en"],
+ interpolation: { escapeValue: false },
+ detection: {
+ order: ["localStorage", "navigator"],
+ caches: ["localStorage"],
+ },
+ });
+
+export default i18n;
diff --git a/frontend/src/i18n/ru.ts b/frontend/src/i18n/ru.ts
new file mode 100644
index 0000000..321bd4d
--- /dev/null
+++ b/frontend/src/i18n/ru.ts
@@ -0,0 +1,168 @@
+export const ru = {
+ app: {
+ title: "AI RPG",
+ subtitle: "Гибкая ролевая игра с ИИ",
+ },
+ nav: {
+ home: "Главная",
+ dashboard: "Мои миры",
+ admin: "Админка",
+ logout: "Выйти",
+ login: "Войти",
+ register: "Регистрация",
+ language: "Язык",
+ },
+ auth: {
+ login_title: "Вход",
+ register_title: "Регистрация",
+ email: "Email",
+ username: "Имя пользователя",
+ password: "Пароль",
+ login_btn: "Войти",
+ register_btn: "Зарегистрироваться",
+ no_account: "Нет аккаунта? Зарегистрируйтесь",
+ have_account: "Уже есть аккаунт? Войдите",
+ admin_setup_title: "Создание администратора",
+ admin_setup_desc: "Введите токен из консоли backend, чтобы создать первого администратора.",
+ admin_setup_token: "Токен инициализации",
+ setup_btn: "Создать администратора",
+ },
+ worlds: {
+ title: "Ваши миры",
+ new: "Создать новый мир",
+ empty: "У вас пока нет ни одного мира.",
+ preset_choice: "Выберите пресет или начните с нуля",
+ use_preset: "Из пресета",
+ from_scratch: "С нуля",
+ name: "Название мира",
+ language: "Язык мира",
+ create: "Создать",
+ cancel: "Отмена",
+ start: "Начать игру",
+ continue: "Продолжить",
+ edit: "Редактировать",
+ delete: "Удалить",
+ builder_title: "Конструктор мира",
+ builder_desc: "Опишите свой мир — ИИ поможет его достроить.",
+ setting_brief: "Сеттинг",
+ setting_brief_ph: "Тёмное фэнтези в духе позднего средневековья...",
+ character_brief: "Персонаж",
+ character_brief_ph: "Молодой авантюрист, ищущий славы...",
+ rules_brief: "Правила мира",
+ rules_brief_ph: "Система магии с маной, пошаговый бой d20...",
+ notes: "Дополнительные заметки",
+ notes_ph: "Хочу сложную систему репутации с фракциями...",
+ start_builder: "Начать диалог с ИИ",
+ builder_history: "История диалога",
+ builder_message_ph: "Ваш ответ или правки...",
+ send: "Отправить",
+ accept: "Принять мир и создать",
+ accepting: "Создаём мир...",
+ status_draft: "Черновик",
+ status_ready: "Готов",
+ status_active: "Активен",
+ status_archived: "В архиве",
+ },
+ session: {
+ title: "Сессия",
+ back: "Назад к мирам",
+ glossary: "Глоссарий",
+ character: "Персонаж",
+ edit_world: "Редактировать мир",
+ history: "История",
+ action_placeholder: "Что вы делаете?",
+ send: "Действие",
+ sending: "Думаю...",
+ options: "Варианты",
+ custom_action: "Или своё действие",
+ status_planning: "Планирование...",
+ status_orchestrator_turn: "Ход GM...",
+ status_writing_scene: "Пишем сцену...",
+ world_time: "Время мира",
+ triggers_panel: "Отложенные события",
+ no_messages: "Начните с первого действия!",
+ new_session: "Новая сессия",
+ error_iter: "Ошибка при выполнении итерации",
+ },
+ admin: {
+ title: "Панель администратора",
+ settings: "Настройки LLM",
+ base_url: "OpenAI-совместимый endpoint",
+ api_key: "API ключ",
+ model: "Модель",
+ temperature: "Temperature (orchestrator)",
+ step_temperature: "Temperature (step writer)",
+ summary_temperature: "Temperature (summarizer)",
+ max_tokens: "Max tokens",
+ request_timeout: "Таймаут (сек)",
+ streaming: "Стриминг",
+ context_settings: "Контекстный менеджер",
+ recent_messages: "Гарантированных сообщений",
+ compress_threshold: "Порог сжатия",
+ summary_messages: "Сообщений на сводку",
+ max_tokens_total: "Бюджет токенов контекста",
+ trigger_settings: "Отложенные триггеры",
+ triggers_enabled: "Включены",
+ triggers_check_interval: "Интервал проверки (сек)",
+ embedding_settings: "Эмбеддинги (RAG)",
+ embedding_provider: "Провайдер",
+ embedding_provider_hash: "Hash (офлайн-фолбэк, без семантики)",
+ embedding_provider_openai: "OpenAI-compatible /embeddings",
+ embedding_base_url: "Endpoint (пусто = как у LLM)",
+ embedding_api_key: "API ключ (пусто = как у LLM)",
+ embedding_model: "Модель эмбеддингов",
+ embedding_dim: "Размерность (0 = авто-проба)",
+ embedding_timeout: "Таймаут (сек)",
+ embedding_test: "Проверить эмбеддинги",
+ embedding_testing: "Проверяю...",
+ embedding_test_ok: "OK: {{provider}} | dim={{dim}} | norm={{norm}}",
+ embedding_test_fail: "Ошибка: {{error}}",
+ save: "Сохранить",
+ saved: "Сохранено!",
+ llm_logs: "Логи LLM",
+ users: "Пользователи",
+ back: "Назад",
+ },
+ glossary: {
+ title: "Глоссарий мира",
+ npc: "NPC",
+ location: "Локации",
+ item: "Предметы",
+ lore: "Лор",
+ event: "События",
+ rule: "Правила",
+ empty: "Пока пусто. ИИ заполнит глоссарий по ходу игры.",
+ },
+ character: {
+ title: "Лист персонажа",
+ name: "Имя",
+ race: "Раса",
+ class: "Класс",
+ level: "Уровень",
+ stats: "Характеристики",
+ inventory: "Инвентарь",
+ effects: "Эффекты",
+ gold: "Золото",
+ location: "Локация",
+ empty: "Нет данных о персонаже.",
+ },
+ common: {
+ save: "Сохранить",
+ cancel: "Отмена",
+ delete: "Удалить",
+ edit: "Редактировать",
+ confirm: "Подтвердить",
+ yes: "Да",
+ no: "Нет",
+ loading: "Загрузка...",
+ error: "Ошибка",
+ success: "Готово",
+ not_found: "Не найдено",
+ forbidden: "Доступ запрещён",
+ },
+ errors: {
+ network: "Сетевая ошибка. Проверьте подключение.",
+ unauthorized: "Не авторизован.",
+ unknown: "Неизвестная ошибка.",
+ },
+};
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..9c7187d
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,59 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ color-scheme: dark;
+}
+
+html, body, #root {
+ height: 100%;
+}
+
+body {
+ @apply bg-ink-950 text-ink-100 antialiased;
+ font-family: Inter, system-ui, sans-serif;
+}
+
+/* Scrollbar */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+::-webkit-scrollbar-track {
+ @apply bg-ink-900;
+}
+::-webkit-scrollbar-thumb {
+ @apply bg-ink-700 rounded;
+}
+::-webkit-scrollbar-thumb:hover {
+ @apply bg-ink-600;
+}
+
+/* Narrative prose */
+.prose-rpg {
+ @apply text-ink-100 leading-relaxed;
+}
+.prose-rpg p {
+ @apply mb-3;
+}
+.prose-rpg p:last-child {
+ @apply mb-0;
+}
+
+/* Animations */
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(4px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+.fade-in {
+ animation: fadeIn 0.2s ease-out;
+}
+
+@keyframes pulse-soft {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
+.pulse-soft {
+ animation: pulse-soft 1.5s ease-in-out infinite;
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..e76133d
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,14 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import "./index.css";
+import App from "./App";
+import "./i18n";
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+);
diff --git a/frontend/src/pages/AdminPanelPage.tsx b/frontend/src/pages/AdminPanelPage.tsx
new file mode 100644
index 0000000..e92ea4d
--- /dev/null
+++ b/frontend/src/pages/AdminPanelPage.tsx
@@ -0,0 +1,468 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+import { adminApi } from "@/api";
+import type { LlmLog, SettingsOut } from "@/types";
+import { Button } from "@/components/ui/Button";
+import { Input } from "@/components/ui/Input";
+import { Card, CardBody, CardHeader } from "@/components/ui/Card";
+import { Save, ArrowLeft, Activity, Users, Zap } from "lucide-react";
+
+export function AdminPanelPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const [settings, setSettings] = useState(null);
+ const [values, setValues] = useState>({});
+ const [logs, setLogs] = useState([]);
+ const [users, setUsers] = useState([]);
+ const [tab, setTab] = useState<"settings" | "logs" | "users">("settings");
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [saved, setSaved] = useState(false);
+ const [error, setError] = useState("");
+ const [embeddingTest, setEmbeddingTest] = useState(null);
+ const [testingEmbeddings, setTestingEmbeddings] = useState(false);
+
+ useEffect(() => {
+ (async () => {
+ try {
+ const s = await adminApi.getSettings();
+ setSettings(s);
+ setValues(s.values);
+ const l = await adminApi.listLlmLogs(50);
+ setLogs(l);
+ const u = await adminApi.listUsers();
+ setUsers(u);
+ } catch (err: any) {
+ setError(err.response?.data?.detail || t("errors.unknown"));
+ } finally {
+ setLoading(false);
+ }
+ })();
+ }, []);
+
+ const save = async () => {
+ setError("");
+ setSaving(true);
+ setSaved(false);
+ try {
+ // Strip masked api_key unless user typed a new one
+ const payload: Record = { ...values };
+ for (const k of ["llm.api_key", "embedding.api_key"]) {
+ if (typeof payload[k] === "string" && payload[k].includes("***")) {
+ delete payload[k];
+ }
+ }
+ const s = await adminApi.updateSettings(payload);
+ setSettings(s);
+ setValues(s.values);
+ setSaved(true);
+ setTimeout(() => setSaved(false), 2000);
+ } catch (err: any) {
+ setError(err.response?.data?.detail || t("errors.unknown"));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const testEmbeddings = async () => {
+ setError("");
+ setTestingEmbeddings(true);
+ setEmbeddingTest(null);
+ try {
+ // Build overrides from current form values (excluding masked api_key)
+ const overrides: Record = {};
+ for (const k of [
+ "embedding.provider",
+ "embedding.base_url",
+ "embedding.api_key",
+ "embedding.model",
+ "embedding.dim",
+ "embedding.request_timeout",
+ "llm.base_url",
+ "llm.api_key",
+ ]) {
+ const v = values[k];
+ if (v !== undefined && v !== null && !(typeof v === "string" && v.includes("***"))) {
+ overrides[k] = v;
+ }
+ }
+ const r = await adminApi.testEmbeddings(overrides);
+ if (r.ok) {
+ setEmbeddingTest({
+ ok: true,
+ msg: t("admin.embedding_test_ok", {
+ provider: r.provider,
+ dim: r.dim,
+ norm: r.sample_norm,
+ }),
+ });
+ } else {
+ setEmbeddingTest({ ok: false, msg: t("admin.embedding_test_fail", { error: r.error || "unknown" }) });
+ }
+ } catch (err: any) {
+ setEmbeddingTest({
+ ok: false,
+ msg: t("admin.embedding_test_fail", { error: err.response?.data?.detail || err.message }),
+ });
+ } finally {
+ setTestingEmbeddings(false);
+ }
+ };
+
+ if (loading) {
+ return {t("common.loading")}
;
+ }
+
+ return (
+
+
+
+
{t("admin.title")}
+
+
+
+
+ {error &&
{error}
}
+
+ {/* Tabs */}
+
+
setTab("settings")}>
+ {t("admin.settings")}
+
+
setTab("logs")}>
+
+ {t("admin.llm_logs")}
+
+
setTab("users")}>
+
+ {t("admin.users")}
+
+
+
+ {tab === "settings" && settings && (
+
+
+
+
+ setValues({ ...values, "llm.base_url": e.target.value })}
+ placeholder="http://localhost:1234/v1"
+ />
+ setValues({ ...values, "llm.api_key": e.target.value })}
+ placeholder="sk-..."
+ />
+ setValues({ ...values, "llm.model": e.target.value })}
+ placeholder="local-model"
+ />
+
+ setValues({ ...values, "llm.temperature": v })}
+ step={0.1}
+ />
+ setValues({ ...values, "llm.step_temperature": v })}
+ step={0.1}
+ />
+ setValues({ ...values, "llm.summary_temperature": v })}
+ step={0.1}
+ />
+ setValues({ ...values, "llm.max_tokens": v })}
+ />
+ setValues({ ...values, "llm.request_timeout": v })}
+ />
+
+
+
+
+
+ {/* === Embeddings / RAG === */}
+
+
+
+
+
+
+
+ setValues({ ...values, "embedding.base_url": e.target.value })}
+ placeholder="http://localhost:1234/v1"
+ />
+ setValues({ ...values, "embedding.api_key": e.target.value })}
+ placeholder="sk-... (пусто = как у LLM)"
+ />
+
+ setValues({ ...values, "embedding.model": e.target.value })}
+ placeholder="text-embedding-3-small"
+ />
+ setValues({ ...values, "embedding.dim": v })}
+ />
+ setValues({ ...values, "embedding.request_timeout": v })}
+ />
+
+
+
+
+ {embeddingTest && (
+
+ {embeddingTest.msg}
+
+ )}
+
+
+
+
+
+
+
+
+ setValues({ ...values, "context.recent_messages": v })}
+ />
+ setValues({ ...values, "context.compress_threshold": v })}
+ />
+ setValues({ ...values, "context.summary_messages": v })}
+ />
+ setValues({ ...values, "context.max_tokens_total": v })}
+ />
+
+
+
+
+
+
+
+
+
+ setValues({ ...values, "triggers.check_interval": v })}
+ />
+
+
+
+
+
+ {saved && {t("admin.saved")}}
+
+
+
+ )}
+
+ {tab === "logs" && (
+
+
+
+
+
+
+
+ | Time |
+ Purpose |
+ Model |
+ Tokens |
+ Latency |
+ Status |
+
+
+
+ {logs.map((l) => (
+
+ |
+ {new Date(l.created_at).toLocaleTimeString()}
+ |
+ {l.purpose} |
+ {l.model} |
+
+ {l.total_tokens ?? "—"}
+ |
+
+ {l.latency_ms ? `${l.latency_ms}ms` : "—"}
+ |
+
+ {l.error ? (
+ err
+ ) : (
+ ok
+ )}
+ |
+
+ ))}
+
+
+
+
+
+ )}
+
+ {tab === "users" && (
+
+
+
+
+
+
+
+ | Email |
+ Username |
+ Role |
+ Active |
+ Created |
+
+
+
+ {users.map((u) => (
+
+ | {u.email} |
+ {u.username} |
+
+ {u.is_admin ? (
+ admin
+ ) : (
+ user
+ )}
+ |
+
+ {u.is_active ? (
+ ●
+ ) : (
+ ●
+ )}
+ |
+
+ {new Date(u.created_at).toLocaleDateString()}
+ |
+
+ ))}
+
+
+
+
+
+ )}
+
+ );
+}
+
+function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+function NumberInput({
+ label,
+ value,
+ onChange,
+ step,
+}: {
+ label: string;
+ value: any;
+ onChange: (v: number) => void;
+ step?: number;
+}) {
+ return (
+ {
+ const v = parseFloat(e.target.value);
+ onChange(isNaN(v) ? 0 : v);
+ }}
+ />
+ );
+}
diff --git a/frontend/src/pages/AdminSetupPage.tsx b/frontend/src/pages/AdminSetupPage.tsx
new file mode 100644
index 0000000..7e16558
--- /dev/null
+++ b/frontend/src/pages/AdminSetupPage.tsx
@@ -0,0 +1,91 @@
+import { FormEvent, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+import { Shield } from "lucide-react";
+import { authApi } from "@/api";
+import { useAuthStore } from "@/store/auth";
+import { Button } from "@/components/ui/Button";
+import { Input } from "@/components/ui/Input";
+import { Card, CardBody, CardHeader } from "@/components/ui/Card";
+
+export function AdminSetupPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { setAuth } = useAuthStore();
+ const [token, setToken] = useState("");
+ const [email, setEmail] = useState("");
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ const onSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setLoading(true);
+ try {
+ const { access_token, user } = await authApi.adminSetup(token, email, username, password);
+ setAuth(access_token, user);
+ navigate("/admin");
+ } catch (err: any) {
+ setError(err.response?.data?.detail || t("errors.unknown"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
{t("auth.admin_setup_title")}
+
{t("auth.admin_setup_desc")}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
new file mode 100644
index 0000000..e4d6f79
--- /dev/null
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -0,0 +1,153 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Link, useNavigate } from "react-router-dom";
+import { Plus, BookOpen, Play, Pencil, Trash2 } from "lucide-react";
+import { worldsApi, sessionsApi } from "@/api";
+import type { World } from "@/types";
+import { Button } from "@/components/ui/Button";
+import { Card, CardBody } from "@/components/ui/Card";
+
+export function DashboardPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const [worlds, setWorlds] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ const load = async () => {
+ try {
+ setLoading(true);
+ const ws = await worldsApi.list();
+ setWorlds(ws);
+ } catch (err: any) {
+ setError(err.response?.data?.detail || t("errors.unknown"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ load();
+ }, []);
+
+ const handleDelete = async (id: string) => {
+ if (!confirm(t("common.delete") + "?")) return;
+ try {
+ await worldsApi.delete(id);
+ await load();
+ } catch (err: any) {
+ setError(err.response?.data?.detail || t("errors.unknown"));
+ }
+ };
+
+ const handlePlay = async (world: World) => {
+ try {
+ const session = await sessionsApi.create(world.id);
+ navigate(`/sessions/${session.id}`);
+ } catch (err: any) {
+ setError(err.response?.data?.detail || t("errors.unknown"));
+ }
+ };
+
+ return (
+
+
+
{t("worlds.title")}
+
+
+
+
+
+ {error &&
{error}
}
+
+ {loading ? (
+
{t("common.loading")}
+ ) : worlds.length === 0 ? (
+
+
+
+ {t("worlds.empty")}
+
+
+
+
+
+ ) : (
+
+ {worlds.map((w) => (
+
+
+
+
+
{w.name}
+
+
+ {w.language.toUpperCase()}
+ {w.current_time && (
+ · {w.current_time}
+ )}
+
+
+ {w.definition?.setting_description?.slice(0, 160) || "—"}
+
+
+
+
+ {w.status === "draft" ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+function StatusBadge({ status, t }: { status: string; t: any }) {
+ const colors: Record = {
+ draft: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
+ ready: "bg-blue-500/10 text-blue-400 border-blue-500/30",
+ active: "bg-green-500/10 text-green-400 border-green-500/30",
+ archived: "bg-ink-500/10 text-ink-400 border-ink-500/30",
+ };
+ const labels: Record = {
+ draft: t("worlds.status_draft"),
+ ready: t("worlds.status_ready"),
+ active: t("worlds.status_active"),
+ archived: t("worlds.status_archived"),
+ };
+ return (
+
+ {labels[status] || status}
+
+ );
+}
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx
new file mode 100644
index 0000000..1da13eb
--- /dev/null
+++ b/frontend/src/pages/HomePage.tsx
@@ -0,0 +1,76 @@
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router-dom";
+import { useAuthStore } from "@/store/auth";
+import { Button } from "@/components/ui/Button";
+import { BookOpen, Sparkles, Cog, Globe } from "lucide-react";
+
+export function HomePage() {
+ const { t } = useTranslation();
+ const { user } = useAuthStore();
+
+ return (
+
+
+
+
+
+
{t("app.title")}
+
{t("app.subtitle")}
+
+ {user ? (
+
+
+
+ ) : (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+ }
+ title={t("worlds.builder_title")}
+ desc={t("worlds.builder_desc")}
+ />
+ }
+ title={t("admin.settings")}
+ desc={t("admin.base_url")}
+ />
+ }
+ title={t("nav.language")}
+ desc="RU / EN"
+ />
+
+
+
+
+ {t("auth.admin_setup_title")}
+
+
+
+ );
+}
+
+function FeatureCard({ icon, title, desc }: { icon: React.ReactNode; title: string; desc: string }) {
+ return (
+
+
{icon}
+
{title}
+
{desc}
+
+ );
+}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..dafdfe3
--- /dev/null
+++ b/frontend/src/pages/LoginPage.tsx
@@ -0,0 +1,72 @@
+import { FormEvent, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Link, useNavigate } from "react-router-dom";
+import { authApi } from "@/api";
+import { useAuthStore } from "@/store/auth";
+import { Button } from "@/components/ui/Button";
+import { Input } from "@/components/ui/Input";
+import { Card, CardBody } from "@/components/ui/Card";
+
+export function LoginPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { setAuth } = useAuthStore();
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ const onSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setLoading(true);
+ try {
+ const { access_token, user } = await authApi.login(email, password);
+ setAuth(access_token, user);
+ navigate("/dashboard");
+ } catch (err: any) {
+ setError(err.response?.data?.detail || t("errors.unknown"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
{t("auth.login_title")}
+
+
+
+
+
+ {t("auth.no_account")}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx
new file mode 100644
index 0000000..48c15f5
--- /dev/null
+++ b/frontend/src/pages/RegisterPage.tsx
@@ -0,0 +1,80 @@
+import { FormEvent, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Link, useNavigate } from "react-router-dom";
+import { authApi } from "@/api";
+import { useAuthStore } from "@/store/auth";
+import { Button } from "@/components/ui/Button";
+import { Input } from "@/components/ui/Input";
+import { Card, CardBody } from "@/components/ui/Card";
+
+export function RegisterPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { setAuth } = useAuthStore();
+ const [email, setEmail] = useState("");
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ const onSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setLoading(true);
+ try {
+ const { access_token, user } = await authApi.register(email, username, password);
+ setAuth(access_token, user);
+ navigate("/dashboard");
+ } catch (err: any) {
+ setError(err.response?.data?.detail || t("errors.unknown"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
{t("auth.register_title")}
+
+
+
+
+
+ {t("auth.have_account")}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/SessionPage.tsx b/frontend/src/pages/SessionPage.tsx
new file mode 100644
index 0000000..2ad78ba
--- /dev/null
+++ b/frontend/src/pages/SessionPage.tsx
@@ -0,0 +1,341 @@
+import { useEffect, useState, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate, useParams } from "react-router-dom";
+import { fetchEventSource } from "@microsoft/fetch-event-source";
+import { sessionsApi, worldsApi, miscApi } from "@/api";
+import { useAuthStore } from "@/store/auth";
+import type { Message, Session, World, GlossaryEntry, Trigger } from "@/types";
+import { Button } from "@/components/ui/Button";
+import { Textarea } from "@/components/ui/Input";
+import { Card, CardBody } from "@/components/ui/Card";
+import { Modal } from "@/components/ui/Modal";
+import { GlossaryModal } from "@/components/world/GlossaryModal";
+import { CharacterSheet } from "@/components/world/CharacterSheet";
+import { Send, BookOpen, User, Pencil, Clock, Zap, Loader2 } from "lucide-react";
+import ReactMarkdown from "react-markdown";
+
+export function SessionPage() {
+ const { t } = useTranslation();
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const { token } = useAuthStore();
+
+ const [session, setSession] = useState(null);
+ const [world, setWorld] = useState(null);
+ const [messages, setMessages] = useState([]);
+ const [actionText, setActionText] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [iterating, setIterating] = useState(false);
+ const [status, setStatus] = useState("");
+ const [error, setError] = useState("");
+ const [glossaryOpen, setGlossaryOpen] = useState(false);
+ const [glossary, setGlossary] = useState([]);
+ const [triggers, setTriggers] = useState([]);
+ const messagesEndRef = useRef(null);
+
+ const load = async () => {
+ if (!id) return;
+ try {
+ const s = await sessionsApi.get(id);
+ setSession(s);
+ const w = await worldsApi.get(s.world_id);
+ setWorld(w);
+ const msgs = await sessionsApi.listMessages(id);
+ setMessages(msgs);
+ const g = await miscApi.listGlossary(s.world_id);
+ setGlossary(g);
+ const tr = await miscApi.listTriggers(id, false);
+ setTriggers(tr);
+ } catch (err: any) {
+ setError(err.response?.data?.detail || t("errors.unknown"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ load();
+ }, [id]);
+
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [messages, status]);
+
+ const runIteration = async () => {
+ if (!id || !actionText.trim() || iterating) return;
+ setError("");
+ setIterating(true);
+ setStatus(t("session.status_planning"));
+ const action = actionText.trim();
+ setActionText("");
+
+ // Optimistic: show user action immediately
+ const optimisticUserMsg: Message = {
+ id: `tmp-${Date.now()}`,
+ seq: messages.length + 1,
+ role: "user",
+ kind: "player_action",
+ content: action,
+ payload: {},
+ is_pinned: true,
+ hidden: false,
+ created_at: new Date().toISOString(),
+ };
+ setMessages((prev) => [...prev, optimisticUserMsg]);
+
+ try {
+ await fetchEventSource(`/api/sessions/${id}/iterate`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ session_id: id, action_text: action }),
+ openWhenHidden: true,
+
+ onmessage(ev) {
+ const eventName = ev.event;
+ let data: any = {};
+ try {
+ data = JSON.parse(ev.data || "{}");
+ } catch {
+ data = {};
+ }
+ if (eventName === "status") {
+ const msg = data.message || "";
+ if (msg.startsWith("orchestrator")) setStatus(t("session.status_orchestrator_turn"));
+ else if (msg === "writing_scene") setStatus(t("session.status_writing_scene"));
+ else if (msg === "planning") setStatus(t("session.status_planning"));
+ else setStatus(msg);
+ } else if (eventName === "tool_call") {
+ setStatus(`🔧 ${data.name}(...)` );
+ } else if (eventName === "step_complete") {
+ const stepMsg: Message = {
+ id: data.message_id || `step-${Date.now()}`,
+ seq: data.seq || messages.length + 2,
+ role: "assistant",
+ kind: "narrative_step",
+ content: data.narrative || "",
+ payload: { options: data.options || [], world_time: data.world_time },
+ is_pinned: true,
+ hidden: false,
+ created_at: new Date().toISOString(),
+ };
+ setMessages((prev) => [...prev, stepMsg]);
+ if (world && data.world_time) {
+ setWorld({ ...world, current_time: data.world_time, state: data.state || world.state });
+ }
+ // Refresh triggers
+ if (id) {
+ miscApi.listTriggers(id, false).then(setTriggers).catch(() => {});
+ miscApi.listGlossary(world?.id || "").then(setGlossary).catch(() => {});
+ }
+ } else if (eventName === "error") {
+ setError(data.message || t("session.error_iter"));
+ } else if (eventName === "done") {
+ // Final reload to get fresh seq/order
+ load();
+ }
+ },
+
+ onclose() {
+ setIterating(false);
+ setStatus("");
+ },
+
+ onerror(err) {
+ setIterating(false);
+ setStatus("");
+ setError(String(err) || t("session.error_iter"));
+ throw err; // stop retry
+ },
+ });
+ } catch (err: any) {
+ setIterating(false);
+ setStatus("");
+ setError(err.message || t("session.error_iter"));
+ }
+ };
+
+ const pickOption = (opt: string) => {
+ setActionText(opt);
+ };
+
+ if (loading) {
+ return {t("common.loading")}
;
+ }
+
+ if (!session || !world) {
+ return {error || t("common.not_found")}
;
+ }
+
+ const lastNarrative = [...messages].reverse().find((m) => m.kind === "narrative_step");
+ const options: string[] = lastNarrative?.payload?.options || [];
+
+ return (
+
+ {/* Top bar */}
+
+
+
+
+
{session.title}
+
+
+ {world.current_time || "—"}
+
+
+
+
+
+
+
+
+
+ {/* Main area: chat + sidebar */}
+
+ {/* Chat */}
+
+
+ {messages.length === 0 && !iterating && (
+
{t("session.no_messages")}
+ )}
+ {messages
+ .filter((m) => !m.hidden)
+ .map((m, i) => (
+
+ ))}
+ {iterating && (
+
+
+ {status || t("session.sending")}
+
+ )}
+
+
+
+ {/* Options + input */}
+
+ {options.length > 0 && !iterating && (
+
+ {options.map((opt, i) => (
+
+ ))}
+
+ )}
+
+
+ {error &&
{error}
}
+
+
+
+ {/* Sidebar: character sheet + triggers */}
+
+
+
+
setGlossaryOpen(false)} entries={glossary} />
+
+ );
+}
+
+function MessageBubble({ message }: { message: Message }) {
+ if (message.kind === "player_action") {
+ return (
+
+
+
Игрок
+
{message.content}
+
+
+ );
+ }
+ if (message.kind === "narrative_step") {
+ const content = message.content.startsWith("[Событие]")
+ ? message.content
+ : message.content;
+ return (
+
+
+
+ {content}
+
+ {message.payload?.options?.length > 0 && (
+
+ {message.payload.options.map((opt: string, i: number) => (
+
+ {opt}
+
+ ))}
+
+ )}
+
+
+ );
+ }
+ if (message.kind === "summary") {
+ return (
+
+ 📜 {message.content}
+
+ );
+ }
+ return null;
+}
diff --git a/frontend/src/pages/WorldBuilderPage.tsx b/frontend/src/pages/WorldBuilderPage.tsx
new file mode 100644
index 0000000..91b8dea
--- /dev/null
+++ b/frontend/src/pages/WorldBuilderPage.tsx
@@ -0,0 +1,266 @@
+import { FormEvent, useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useLocation, useNavigate } from "react-router-dom";
+import { worldsApi } from "@/api";
+import type { WorldBuilderReply } from "@/types";
+import { Button } from "@/components/ui/Button";
+import { Input, Textarea } from "@/components/ui/Input";
+import { Card, CardBody, CardHeader } from "@/components/ui/Card";
+import { Send, Check, Sparkles } from "lucide-react";
+
+interface BuilderMessage {
+ role: "user" | "assistant";
+ text: string;
+ proposed?: any;
+ is_final?: boolean;
+ followups?: string[];
+}
+
+export function WorldBuilderPage() {
+ const { t, i18n } = useTranslation();
+ const navigate = useNavigate();
+ const location = useLocation() as any;
+ const presetId = location.state?.presetId as string | undefined;
+ const fromScratch = location.state?.fromScratch as boolean | undefined;
+
+ const [worldName, setWorldName] = useState("");
+ const [language, setLanguage] = useState(i18n.language === "en" ? "en" : "ru");
+ const [settingBrief, setSettingBrief] = useState("");
+ const [characterBrief, setCharacterBrief] = useState("");
+ const [rulesBrief, setRulesBrief] = useState("");
+ const [notes, setNotes] = useState("");
+ const [started, setStarted] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [committing, setCommitting] = useState(false);
+ const [error, setError] = useState("");
+ const [sessionId, setSessionId] = useState(null);
+ const [messages, setMessages] = useState([]);
+ const [currentReply, setCurrentReply] = useState(null);
+ const [userInput, setUserInput] = useState("");
+ const scrollRef = useRef(null);
+
+ useEffect(() => {
+ if (scrollRef.current) {
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+ }
+ }, [messages, currentReply]);
+
+ const startBuilder = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!worldName.trim()) return;
+ setError("");
+ setLoading(true);
+ try {
+ const reply = await worldsApi.builderStart({
+ world_name: worldName,
+ language,
+ preset_id: presetId,
+ setting_brief: settingBrief,
+ character_brief: characterBrief,
+ rules_brief: rulesBrief,
+ notes,
+ });
+ setSessionId(reply.session_id);
+ setMessages([{ role: "assistant", text: reply.ai_message, proposed: reply.proposed_definition, is_final: reply.is_final, followups: reply.followup_questions }]);
+ setCurrentReply(reply);
+ setStarted(true);
+ } catch (err: any) {
+ setError(err.response?.data?.detail || err.message || t("errors.unknown"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const sendMessage = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!userInput.trim() || !sessionId) return;
+ setError("");
+ const msg = userInput.trim();
+ setUserInput("");
+ setMessages((prev) => [...prev, { role: "user", text: msg }]);
+ setLoading(true);
+ try {
+ const reply = await worldsApi.builderContinue(sessionId, msg);
+ setMessages((prev) => [
+ ...prev,
+ {
+ role: "assistant",
+ text: reply.ai_message,
+ proposed: reply.proposed_definition,
+ is_final: reply.is_final,
+ followups: reply.followup_questions,
+ },
+ ]);
+ setCurrentReply(reply);
+ } catch (err: any) {
+ setError(err.response?.data?.detail || err.message || t("errors.unknown"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const commitWorld = async () => {
+ if (!sessionId) return;
+ setError("");
+ setCommitting(true);
+ try {
+ const world = await worldsApi.builderCommit(sessionId, worldName);
+ navigate(`/worlds/${world.id}/edit`);
+ } catch (err: any) {
+ setError(err.response?.data?.detail || err.message || t("errors.unknown"));
+ } finally {
+ setCommitting(false);
+ }
+ };
+
+ if (!started) {
+ return (
+
+
{t("worlds.builder_title")}
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
{t("worlds.builder_title")} — {worldName}
+
+
+
+
+
+ {messages.map((m, i) => (
+
+
+
+ {m.role === "user" ? "Вы" : "ИИ"}
+
+
{m.text}
+ {m.followups && m.followups.length > 0 && (
+
+ {m.followups.map((q, qi) => (
+ - {q}
+ ))}
+
+ )}
+ {m.proposed && (
+
+
+ Предложенное определение мира
+
+
+ {JSON.stringify(m.proposed, null, 2)}
+
+
+ )}
+
+
+ ))}
+ {loading && (
+
+
+ {t("common.loading")}
+
+
+ )}
+
+
+ {error && {error}
}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/WorldCreatePage.tsx b/frontend/src/pages/WorldCreatePage.tsx
new file mode 100644
index 0000000..4c2b898
--- /dev/null
+++ b/frontend/src/pages/WorldCreatePage.tsx
@@ -0,0 +1,143 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+import { presetsApi } from "@/api";
+import type { Preset } from "@/types";
+import { Button } from "@/components/ui/Button";
+import { Card, CardBody, CardHeader } from "@/components/ui/Card";
+import { Sparkles, FileText } from "lucide-react";
+
+export function WorldCreatePage() {
+ const { t, i18n } = useTranslation();
+ const navigate = useNavigate();
+ const [presets, setPresets] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [mode, setMode] = useState<"preset" | "scratch" | null>(null);
+ const [selectedPreset, setSelectedPreset] = useState(null);
+
+ useEffect(() => {
+ (async () => {
+ try {
+ const p = await presetsApi.list(i18n.language);
+ setPresets(p);
+ } catch (e) {
+ // ignore
+ } finally {
+ setLoading(false);
+ }
+ })();
+ }, [i18n.language]);
+
+ const startBuilder = () => {
+ // Pass state via location state to WorldBuilderPage
+ navigate("/worlds/builder", {
+ state: {
+ presetId: mode === "preset" ? selectedPreset?.id : undefined,
+ fromScratch: mode === "scratch",
+ },
+ });
+ };
+
+ return (
+
+
{t("worlds.preset_choice")}
+
+ {!mode && (
+
+
+
+
+ )}
+
+ {mode === "preset" && (
+
+ setMode(null)}>
+ {t("worlds.cancel")}
+
+ }
+ />
+
+ {loading ? (
+ {t("common.loading")}
+ ) : (
+
+ {presets.map((p) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ )}
+
+ {mode === "scratch" && (
+
+ setMode(null)}>
+ {t("worlds.cancel")}
+
+ }
+ />
+
+ {t("worlds.builder_desc")}
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/WorldEditPage.tsx b/frontend/src/pages/WorldEditPage.tsx
new file mode 100644
index 0000000..c886735
--- /dev/null
+++ b/frontend/src/pages/WorldEditPage.tsx
@@ -0,0 +1,125 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate, useParams } from "react-router-dom";
+import { worldsApi, sessionsApi } from "@/api";
+import type { World } from "@/types";
+import { Button } from "@/components/ui/Button";
+import { Card, CardBody, CardHeader } from "@/components/ui/Card";
+import { Play } from "lucide-react";
+
+export function WorldEditPage() {
+ const { t } = useTranslation();
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const [world, setWorld] = useState(null);
+ const [definitionText, setDefinitionText] = useState("");
+ const [stateText, setStateText] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ (async () => {
+ if (!id) return;
+ try {
+ const w = await worldsApi.get(id);
+ setWorld(w);
+ setDefinitionText(JSON.stringify(w.definition, null, 2));
+ setStateText(JSON.stringify(w.state, null, 2));
+ } catch (err: any) {
+ setError(err.response?.data?.detail || t("errors.unknown"));
+ } finally {
+ setLoading(false);
+ }
+ })();
+ }, [id]);
+
+ const save = async () => {
+ if (!id || !world) return;
+ setError("");
+ setSaving(true);
+ try {
+ const definition = JSON.parse(definitionText);
+ const state = JSON.parse(stateText);
+ const updated = await worldsApi.update(id, {
+ definition,
+ state,
+ current_time: world.current_time,
+ status: world.status === "draft" ? "ready" : world.status,
+ });
+ setWorld(updated);
+ } catch (err: any) {
+ setError(err.message || t("errors.unknown"));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const startSession = async () => {
+ if (!id) return;
+ try {
+ const session = await sessionsApi.create(id);
+ navigate(`/sessions/${session.id}`);
+ } catch (err: any) {
+ setError(err.response?.data?.detail || t("errors.unknown"));
+ }
+ };
+
+ if (loading) return {t("common.loading")}
;
+ if (!world) return {error || t("common.not_found")}
;
+
+ return (
+
+
+
+ {t("worlds.edit")}: {world.name}
+
+
+
+
+
+
+
+ {error &&
{error}
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts
new file mode 100644
index 0000000..48d2b8c
--- /dev/null
+++ b/frontend/src/store/auth.ts
@@ -0,0 +1,24 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import type { User } from "@/types";
+
+interface AuthState {
+ token: string | null;
+ user: User | null;
+ setAuth: (token: string, user: User) => void;
+ logout: () => void;
+ isAdmin: () => boolean;
+}
+
+export const useAuthStore = create()(
+ persist(
+ (set, get) => ({
+ token: null,
+ user: null,
+ setAuth: (token, user) => set({ token, user }),
+ logout: () => set({ token: null, user: null }),
+ isAdmin: () => !!get().user?.is_admin,
+ }),
+ { name: "ai-rpg-auth" }
+ )
+);
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
new file mode 100644
index 0000000..f085479
--- /dev/null
+++ b/frontend/src/types/index.ts
@@ -0,0 +1,131 @@
+export interface User {
+ id: string;
+ email: string;
+ username: string;
+ is_admin: boolean;
+ is_active: boolean;
+ preferred_language: string;
+ created_at: string;
+}
+
+export interface TokenOut {
+ access_token: string;
+ token_type: string;
+ user: User;
+}
+
+export interface Preset {
+ id: string;
+ slug: string;
+ title: string;
+ description: string | null;
+ language: string;
+ is_public: boolean;
+ is_builtin: boolean;
+ payload: Record;
+ created_at: string;
+}
+
+export interface WorldDefinition {
+ setting_description: string;
+ rules: Record;
+ world_schema: Record;
+ plot_rails: Record;
+ initial_state: Record;
+ initial_time: string | null;
+}
+
+export interface World {
+ id: string;
+ owner_id: string;
+ name: string;
+ language: string;
+ definition: Record;
+ state: Record;
+ current_time: string | null;
+ status: "draft" | "ready" | "active" | "archived";
+ preset_id: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Session {
+ id: string;
+ world_id: string;
+ title: string;
+ is_active: boolean;
+ created_at: string;
+ last_played_at: string | null;
+}
+
+export interface Message {
+ id: string;
+ seq: number;
+ role: string;
+ kind: string;
+ content: string;
+ payload: Record;
+ is_pinned: boolean;
+ hidden: boolean;
+ created_at: string;
+}
+
+export interface GlossaryEntry {
+ id: string;
+ kind: string;
+ name: string;
+ description: string;
+ payload: Record;
+}
+
+export interface Trigger {
+ id: string;
+ session_id: string;
+ fire_at: string;
+ description: string;
+ payload: Record;
+ fired: boolean;
+ created_at: string;
+}
+
+export interface SettingsOut {
+ values: Record;
+ editable_keys: string[];
+}
+
+export interface LlmLog {
+ id: string;
+ purpose: string;
+ model: string;
+ base_url: string;
+ prompt_tokens: number | null;
+ completion_tokens: number | null;
+ total_tokens: number | null;
+ latency_ms: number | null;
+ error: string | null;
+ created_at: string;
+}
+
+export interface WorldBuilderReply {
+ session_id: string;
+ turn: number;
+ ai_message: string;
+ proposed_definition: WorldDefinition | null;
+ is_final: boolean;
+ followup_questions: string[];
+}
+
+export type IterationEventType =
+ | "status"
+ | "plan"
+ | "tool_call"
+ | "tool_result"
+ | "narrative_chunk"
+ | "step_complete"
+ | "error"
+ | "done";
+
+export interface IterationEvent {
+ type: IterationEventType;
+ data: Record;
+}
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
new file mode 100644
index 0000000..ddefb59
--- /dev/null
+++ b/frontend/tailwind.config.ts
@@ -0,0 +1,41 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ["./index.html", "./src/**/*.{ts,tsx}"],
+ darkMode: "class",
+ theme: {
+ extend: {
+ colors: {
+ ink: {
+ 50: "#f7f7f8",
+ 100: "#eeeef0",
+ 200: "#d8d8dd",
+ 300: "#b6b6bf",
+ 400: "#8c8c99",
+ 500: "#6b6b78",
+ 600: "#52525c",
+ 700: "#3f3f47",
+ 800: "#2a2a31",
+ 900: "#1a1a1f",
+ 950: "#0e0e12",
+ },
+ accent: {
+ 50: "#fff8eb",
+ 100: "#ffeac8",
+ 200: "#ffd28b",
+ 300: "#ffb44e",
+ 400: "#ff9924",
+ 500: "#f97316",
+ 600: "#db5509",
+ 700: "#b63d0a",
+ 800: "#92310f",
+ 900: "#782a10",
+ },
+ },
+ fontFamily: {
+ serif: ["'Source Serif Pro'", "Georgia", "serif"],
+ sans: ["Inter", "system-ui", "sans-serif"],
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..0e7b52a
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..97ede7e
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..4b09196
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import path from "path";
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ server: {
+ host: "0.0.0.0",
+ port: 5173,
+ proxy: {
+ "/api": {
+ target: process.env.VITE_API_URL || "http://localhost:8000",
+ changeOrigin: true,
+ },
+ },
+ },
+});