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 ( +