diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..fa11d09
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,48 @@
+name: CI
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ branches: [master]
+
+jobs:
+ lint-backend:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: backend
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - run: pip install ruff
+ - run: ruff check app/
+
+ lint-frontend:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: frontend
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ - run: npm ci
+ - run: npm run build
+
+ build-backend:
+ needs: [lint-backend]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - run: docker build -t backend ./backend
+
+ build-frontend:
+ needs: [lint-frontend]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - run: docker build -t frontend ./frontend
diff --git a/.gitignore b/.gitignore
index 94842f0..7daa210 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,7 @@
__pycache__/
*.pyc
.env
-chroma_db/
+.env.*
venv/
.venv/
*.egg-info/
@@ -10,3 +10,26 @@ build/
.streamlit/
vector_store/
*.faiss.bin
+chroma_db/
+
+# backend
+backend/__pycache__/
+backend/.env
+backend/vector_store/
+
+# frontend
+frontend/node_modules/
+frontend/dist/
+frontend/.env
+
+# opencode
+.opencode/node_modules/
+
+# tools
+twc-*.yaml
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..385ccd3
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,12 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+EXPOSE 8000
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/auth/api_key.py b/backend/app/auth/api_key.py
new file mode 100644
index 0000000..e063728
--- /dev/null
+++ b/backend/app/auth/api_key.py
@@ -0,0 +1,24 @@
+import uuid
+
+from fastapi import Depends, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database import get_db
+from app.models.user import User
+
+api_key_scheme = HTTPBearer()
+
+
+async def get_user_by_api_key(
+ credentials: HTTPAuthorizationCredentials = Depends(api_key_scheme),
+ db: AsyncSession = Depends(get_db),
+) -> User:
+ result = await db.execute(
+ select(User).where(User.api_key == credentials.credentials)
+ )
+ user = result.scalar_one_or_none()
+ if user is None:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
+ return user
diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py
new file mode 100644
index 0000000..cf5c3b6
--- /dev/null
+++ b/backend/app/auth/deps.py
@@ -0,0 +1,32 @@
+import uuid
+
+from fastapi import Depends, HTTPException, status
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.auth.jwt import decode_token
+from app.database import get_db
+from app.models.user import User
+
+bearer_scheme = HTTPBearer()
+
+
+async def get_current_user(
+ credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
+ db: AsyncSession = Depends(get_db),
+) -> User:
+ payload = decode_token(credentials.credentials)
+ if payload is None:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+ user_id = payload.get("sub")
+ if user_id is None:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+ result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
+ user = result.scalar_one_or_none()
+ if user is None:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
+
+ return user
diff --git a/backend/app/auth/hashing.py b/backend/app/auth/hashing.py
new file mode 100644
index 0000000..553f089
--- /dev/null
+++ b/backend/app/auth/hashing.py
@@ -0,0 +1,11 @@
+from passlib.context import CryptContext
+
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+
+def hash_password(password: str) -> str:
+ return pwd_context.hash(password)
+
+
+def verify_password(plain: str, hashed: str) -> bool:
+ return pwd_context.verify(plain, hashed)
diff --git a/backend/app/auth/jwt.py b/backend/app/auth/jwt.py
new file mode 100644
index 0000000..8a3b344
--- /dev/null
+++ b/backend/app/auth/jwt.py
@@ -0,0 +1,22 @@
+import uuid
+from datetime import datetime, timedelta, timezone
+
+import jwt
+
+from app.config import settings
+
+
+def create_token(user_id: uuid.UUID) -> str:
+ payload = {
+ "sub": str(user_id),
+ "iat": datetime.now(timezone.utc),
+ "exp": datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes),
+ }
+ return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
+
+
+def decode_token(token: str) -> dict | None:
+ try:
+ return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
+ except jwt.PyJWTError:
+ return None
diff --git a/backend/app/config.py b/backend/app/config.py
new file mode 100644
index 0000000..2592502
--- /dev/null
+++ b/backend/app/config.py
@@ -0,0 +1,27 @@
+from pydantic_settings import BaseSettings
+from pathlib import Path
+
+
+class Settings(BaseSettings):
+ app_name: str = "Support Bot SaaS"
+ debug: bool = False
+
+ database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/support_bot"
+ redis_url: str = "redis://localhost:6379/0"
+ qdrant_url: str = "http://localhost:6333"
+
+ openrouter_api_key: str = ""
+ openrouter_embedding_model: str = "nvidia/llama-nemotron-embed-vl-1b-v2:free"
+ openrouter_llm_model: str = "google/gemma-4-31b-it:free"
+
+ jwt_secret: str = "change-me-in-production"
+ jwt_algorithm: str = "HS256"
+ jwt_expire_minutes: int = 1440
+
+ class Config:
+ env_file = ".env"
+ env_file_encoding = "utf-8"
+
+
+settings = Settings()
+BASE_DIR = Path(__file__).resolve().parent.parent.parent
diff --git a/backend/app/database.py b/backend/app/database.py
new file mode 100644
index 0000000..5b738d7
--- /dev/null
+++ b/backend/app/database.py
@@ -0,0 +1,14 @@
+from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
+
+from app.config import settings
+
+engine = create_async_engine(settings.database_url, echo=settings.debug)
+async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+
+async def get_db() -> AsyncSession:
+ async with async_session() as session:
+ try:
+ yield session
+ finally:
+ await session.close()
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..c0fbc04
--- /dev/null
+++ b/backend/app/main.py
@@ -0,0 +1,42 @@
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from app.config import settings
+from app.database import engine
+from app.models.base import Base
+from app.routers import auth, tickets, search
+from app.middleware import RateLimitMiddleware
+from app.metrics import MetricsMiddleware, metrics_endpoint
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ yield
+
+
+app = FastAPI(title=settings.app_name, version="0.1.0", lifespan=lifespan)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["https://sovet.itoservice.ru", "https://api.sovet.itoservice.ru"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+app.add_middleware(MetricsMiddleware)
+app.add_middleware(RateLimitMiddleware, requests_per_minute=30)
+
+app.include_router(auth.router)
+app.include_router(tickets.router)
+app.include_router(search.router)
+
+app.add_route("/api/metrics", metrics_endpoint, methods=["GET"])
+
+
+@app.get("/api/health")
+async def health():
+ return {"status": "ok"}
diff --git a/backend/app/metrics.py b/backend/app/metrics.py
new file mode 100644
index 0000000..2488e69
--- /dev/null
+++ b/backend/app/metrics.py
@@ -0,0 +1,34 @@
+from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.requests import Request
+from starlette.responses import Response
+
+from app.config import settings
+
+http_requests = Counter(
+ "http_requests_total",
+ "Total HTTP requests",
+ ["method", "path", "status"],
+)
+
+http_duration = Histogram(
+ "http_request_duration_seconds",
+ "HTTP request duration in seconds",
+ ["method", "path"],
+)
+
+
+class MetricsMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, request: Request, call_next):
+ method = request.method
+ path = request.url.path
+
+ with http_duration.labels(method=method, path=path).time():
+ response = await call_next(request)
+
+ http_requests.labels(method=method, path=path, status=response.status_code).inc()
+ return response
+
+
+async def metrics_endpoint(request: Request):
+ return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
diff --git a/backend/app/middleware.py b/backend/app/middleware.py
new file mode 100644
index 0000000..d92fb04
--- /dev/null
+++ b/backend/app/middleware.py
@@ -0,0 +1,29 @@
+import time
+
+from fastapi import Request
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.responses import JSONResponse
+from collections import defaultdict
+
+
+class RateLimitMiddleware(BaseHTTPMiddleware):
+ def __init__(self, app, requests_per_minute: int = 10):
+ super().__init__(app)
+ self.requests_per_minute = requests_per_minute
+ self.requests: dict = defaultdict(list)
+
+ async def dispatch(self, request: Request, call_next):
+ if request.url.path.startswith("/api/health"):
+ return await call_next(request)
+
+ user_key = request.headers.get("X-Forwarded-For", request.client.host if request.client else "unknown")
+ now = time.time()
+ window = now - 60
+
+ self.requests[user_key] = [t for t in self.requests[user_key] if t > window]
+ self.requests[user_key].append(now)
+
+ if len(self.requests[user_key]) > self.requests_per_minute:
+ return JSONResponse(status_code=429, content={"detail": "Rate limit exceeded"})
+
+ return await call_next(request)
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/models/base.py b/backend/app/models/base.py
new file mode 100644
index 0000000..fa2b68a
--- /dev/null
+++ b/backend/app/models/base.py
@@ -0,0 +1,5 @@
+from sqlalchemy.orm import DeclarativeBase
+
+
+class Base(DeclarativeBase):
+ pass
diff --git a/backend/app/models/collection.py b/backend/app/models/collection.py
new file mode 100644
index 0000000..ab35cb9
--- /dev/null
+++ b/backend/app/models/collection.py
@@ -0,0 +1,24 @@
+import uuid
+from datetime import datetime
+
+from sqlalchemy import String, Integer, DateTime, ForeignKey, func
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.models.base import Base
+
+
+class Collection(Base):
+ __tablename__ = "collections"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
+ )
+ user_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)
+ ticket_count: Mapped[int] = mapped_column(Integer, default=0)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now()
+ )
diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py
new file mode 100644
index 0000000..ba808e0
--- /dev/null
+++ b/backend/app/models/schemas.py
@@ -0,0 +1,66 @@
+import uuid
+from datetime import datetime
+
+from pydantic import BaseModel, EmailStr
+
+
+class RegisterRequest(BaseModel):
+ email: EmailStr
+ password: str
+
+
+class LoginRequest(BaseModel):
+ email: EmailStr
+ password: str
+
+
+class TokenResponse(BaseModel):
+ access_token: str
+ token_type: str = "bearer"
+
+
+class UserResponse(BaseModel):
+ id: uuid.UUID
+ email: str
+ api_key: str
+ plan: str
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+class CollectionCreate(BaseModel):
+ name: str
+
+
+class CollectionResponse(BaseModel):
+ id: uuid.UUID
+ user_id: uuid.UUID
+ name: str
+ ticket_count: int
+ created_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+class TicketMessage(BaseModel):
+ role: str
+ text: str
+
+
+class TicketInput(BaseModel):
+ ticket_id: str
+ category: str = ""
+ description: str = ""
+ client: str = ""
+ messages: list[TicketMessage] = []
+
+
+class TicketsUpload(BaseModel):
+ tickets: list[TicketInput]
+
+
+class TicketsUploadResponse(BaseModel):
+ processed: int
+ skipped: int
+ collection_ticket_count: int
diff --git a/backend/app/models/search_schemas.py b/backend/app/models/search_schemas.py
new file mode 100644
index 0000000..fc2153d
--- /dev/null
+++ b/backend/app/models/search_schemas.py
@@ -0,0 +1,18 @@
+from pydantic import BaseModel
+
+
+class SearchRequest(BaseModel):
+ collection_id: str
+ query: str
+ generate_answer: bool = True
+
+
+class SourceItem(BaseModel):
+ ticket_id: str
+ score: float
+ category: str
+
+
+class SearchResponse(BaseModel):
+ answer: str | None = None
+ sources: list[SourceItem] = []
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
new file mode 100644
index 0000000..030ebee
--- /dev/null
+++ b/backend/app/models/user.py
@@ -0,0 +1,23 @@
+import uuid
+from datetime import datetime
+
+from sqlalchemy import String, DateTime, func
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.models.base import Base
+
+
+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, nullable=False, index=True)
+ password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+ api_key: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
+ plan: Mapped[str] = mapped_column(String(32), default="free")
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now()
+ )
diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
new file mode 100644
index 0000000..b9a622a
--- /dev/null
+++ b/backend/app/routers/auth.py
@@ -0,0 +1,97 @@
+import uuid
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.auth.deps import get_current_user
+from app.auth.hashing import hash_password, verify_password
+from app.auth.jwt import create_token
+from app.database import get_db
+from app.models.schemas import (
+ RegisterRequest,
+ LoginRequest,
+ TokenResponse,
+ UserResponse,
+ CollectionCreate,
+ CollectionResponse,
+)
+from app.models.user import User
+from app.models.collection import Collection
+
+router = APIRouter(prefix="/api", tags=["auth"])
+
+
+@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
+async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
+ result = await db.execute(select(User).where(User.email == body.email))
+ if result.scalar_one_or_none():
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
+
+ api_key = str(uuid.uuid4().hex[:32])
+ user = User(
+ email=body.email,
+ password_hash=hash_password(body.password),
+ api_key=api_key,
+ )
+ db.add(user)
+ await db.commit()
+ await db.refresh(user)
+
+ return TokenResponse(access_token=create_token(user.id))
+
+
+@router.post("/login", response_model=TokenResponse)
+async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
+ result = await db.execute(select(User).where(User.email == body.email))
+ user = result.scalar_one_or_none()
+ if not user or not verify_password(body.password, user.password_hash):
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
+
+ return TokenResponse(access_token=create_token(user.id))
+
+
+@router.get("/me", response_model=UserResponse)
+async def get_me(user: User = Depends(get_current_user)):
+ return user
+
+
+@router.post("/collections", response_model=CollectionResponse, status_code=status.HTTP_201_CREATED)
+async def create_collection(
+ body: CollectionCreate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ collection = Collection(user_id=user.id, name=body.name)
+ db.add(collection)
+ await db.commit()
+ await db.refresh(collection)
+ return collection
+
+
+@router.get("/collections", response_model=list[CollectionResponse])
+async def list_collections(
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(Collection).where(Collection.user_id == user.id).order_by(Collection.created_at.desc())
+ )
+ return result.scalars().all()
+
+
+@router.delete("/collections/{collection_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_collection(
+ collection_id: uuid.UUID,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(Collection).where(Collection.id == collection_id, Collection.user_id == user.id)
+ )
+ collection = result.scalar_one_or_none()
+ if not collection:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found")
+
+ await db.delete(collection)
+ await db.commit()
diff --git a/backend/app/routers/search.py b/backend/app/routers/search.py
new file mode 100644
index 0000000..b0b3612
--- /dev/null
+++ b/backend/app/routers/search.py
@@ -0,0 +1,69 @@
+import uuid
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.auth.deps import get_current_user
+from app.database import get_db
+from app.models.collection import Collection
+from app.models.search_schemas import SearchRequest, SearchResponse, SourceItem
+from app.models.user import User
+from app.services.embedding import embed_query
+from app.services.vector_store import search as qdrant_search
+from app.services.reranking import rerank
+from app.services.llm import generate_answer
+
+router = APIRouter(prefix="/api", tags=["search"])
+
+FETCH_K = 20
+FINAL_K = 5
+
+
+@router.post("/search", response_model=SearchResponse)
+async def search(
+ body: SearchRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(Collection).where(
+ Collection.id == uuid.UUID(body.collection_id),
+ Collection.user_id == user.id,
+ )
+ )
+ collection = result.scalar_one_or_none()
+ if not collection:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found")
+
+ query_vector = await embed_query(body.query)
+
+ qdrant_results = await qdrant_search(
+ tenant_id=str(user.id),
+ query_vector=query_vector,
+ collection_id=body.collection_id,
+ k=FETCH_K,
+ )
+
+ if not qdrant_results:
+ return SearchResponse(
+ answer="Недостаточно информации в истории обращений" if body.generate_answer else None,
+ sources=[],
+ )
+
+ reranked = rerank(body.query, qdrant_results, k=FINAL_K)
+
+ sources = [
+ SourceItem(ticket_id=r["ticket_id"], score=r["score"], category=r["category"])
+ for r in reranked
+ ]
+
+ answer = None
+ if body.generate_answer:
+ context = "\n\n".join(
+ f"Тикет №{r['ticket_id']} ({r['category']}):\n{r['full_text']}"
+ for r in reranked
+ )
+ answer = await generate_answer(body.query, context)
+
+ return SearchResponse(answer=answer, sources=sources)
diff --git a/backend/app/routers/tickets.py b/backend/app/routers/tickets.py
new file mode 100644
index 0000000..af3155e
--- /dev/null
+++ b/backend/app/routers/tickets.py
@@ -0,0 +1,113 @@
+import uuid
+import json
+import csv
+import io
+
+from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.auth.api_key import get_user_by_api_key
+from app.auth.deps import get_current_user
+from app.database import get_db
+from app.models.collection import Collection
+from app.models.schemas import TicketInput, TicketsUpload, TicketsUploadResponse
+from app.models.user import User
+from app.services.chunking import build_chunk
+from app.services.embedding import embed_texts
+from app.services.vector_store import upsert_chunks
+
+router = APIRouter(prefix="/api/collections", tags=["tickets"])
+
+
+async def process_tickets(
+ db: AsyncSession,
+ collection_id: uuid.UUID,
+ user: User,
+ tickets: list[TicketInput],
+) -> TicketsUploadResponse:
+ result = await db.execute(
+ select(Collection).where(Collection.id == collection_id, Collection.user_id == user.id)
+ )
+ collection = result.scalar_one_or_none()
+ if not collection:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found")
+
+ processed = 0
+ skipped = 0
+ chunks = []
+
+ for t in tickets:
+ ticket_dict = {
+ "ticket_id": t.ticket_id,
+ "category": t.category,
+ "description": t.description,
+ "client": t.client,
+ "messages": [{"role": m.role, "text": m.text} for m in t.messages],
+ }
+ chunk = build_chunk(ticket_dict)
+ if chunk:
+ chunk["tenant_id"] = str(user.id)
+ chunk["collection_id"] = str(collection_id)
+ chunks.append(chunk)
+ processed += 1
+ else:
+ skipped += 1
+
+ if chunks:
+ search_texts = [c["search_text"] for c in chunks]
+ vectors = await embed_texts(search_texts)
+ await upsert_chunks(str(user.id), chunks, vectors)
+
+ collection.ticket_count += processed
+ await db.commit()
+
+ return TicketsUploadResponse(
+ processed=processed,
+ skipped=skipped,
+ collection_ticket_count=collection.ticket_count,
+ )
+
+
+@router.post("/{collection_id}/tickets", response_model=TicketsUploadResponse)
+async def upload_tickets_json(
+ collection_id: uuid.UUID,
+ body: TicketsUpload,
+ user: User = Depends(get_user_by_api_key),
+ db: AsyncSession = Depends(get_db),
+):
+ return await process_tickets(db, collection_id, user, body.tickets)
+
+
+@router.post("/{collection_id}/tickets/upload", response_model=TicketsUploadResponse)
+async def upload_tickets_file(
+ collection_id: uuid.UUID,
+ file: UploadFile = File(...),
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ content = await file.read()
+
+ if file.filename.endswith(".json"):
+ data = json.loads(content)
+ tickets = [TicketInput(**t) for t in data] if isinstance(data, list) else [TicketInput(**data)]
+ elif file.filename.endswith(".csv"):
+ text = content.decode("utf-8-sig")
+ reader = csv.DictReader(io.StringIO(text))
+ tickets = []
+ for row in reader:
+ tickets.append(
+ TicketInput(
+ ticket_id=row.get("ticket_id", ""),
+ category=row.get("category", ""),
+ description=row.get("description", ""),
+ client=row.get("client", ""),
+ )
+ )
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Unsupported format. Use JSON or CSV.",
+ )
+
+ return await process_tickets(db, collection_id, user, tickets)
diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/services/chunking.py b/backend/app/services/chunking.py
new file mode 100644
index 0000000..545608f
--- /dev/null
+++ b/backend/app/services/chunking.py
@@ -0,0 +1,83 @@
+import re
+from typing import Optional
+
+NON_SOLUTION = re.compile(
+ r"(уточнит|приложите скрин|какая ошибка|с какой проблемой"
+ r"|нет обратной связи|запрос завершу|открыть его снова"
+ r"|откройте новый|обратиться в службу поддержки"
+ r"|свяжитесь с технической"
+ r"|напишите нам|позвоните нам)", re.I
+)
+
+
+def clean_text(text: str) -> str:
+ text = re.sub(r'\[/?q\].*?\[/q\]', '', text, flags=re.DOTALL | re.IGNORECASE)
+ text = re.sub(r'https?://\S+', '', text)
+ text = re.sub(r'\s+', ' ', text).strip()
+ return text
+
+
+def has_real_solution(messages: list[dict]) -> Optional[str]:
+ support_texts = []
+ for m in messages:
+ if m["role"] == "support":
+ cleaned = clean_text(m["text"])
+ if cleaned:
+ support_texts.append(cleaned)
+ if not support_texts:
+ return None
+ combined = "\n".join(support_texts)
+ if len(combined) < 100:
+ return None
+ last = support_texts[-1]
+ if NON_SOLUTION.search(last):
+ return None
+ if last.strip().endswith("?") and len(last) < 150:
+ return None
+ return combined
+
+
+def get_client_dialogue(messages: list[dict]) -> str:
+ texts = []
+ for m in messages:
+ if m["role"] == "client":
+ cleaned = clean_text(m["text"])
+ if cleaned:
+ texts.append(cleaned)
+ return "\n".join(texts)
+
+
+def build_chunk(ticket: dict) -> Optional[dict]:
+ msgs = ticket.get("messages") or []
+ if not msgs:
+ return None
+ description = clean_text(ticket.get("description", ""))
+ solution = has_real_solution(msgs)
+ if not solution:
+ return None
+
+ client_msgs = get_client_dialogue(msgs)
+
+ search_parts = []
+ if description:
+ search_parts.append(description)
+ if client_msgs:
+ search_parts.append(client_msgs)
+ search_text = "\n".join(search_parts).strip()
+ if not search_text:
+ return None
+
+ full_text = (
+ f"Категория: {ticket['category']}\n"
+ f"Проблема: {description}\n"
+ f"Решение: {solution}"
+ )
+
+ return {
+ "ticket_id": ticket["ticket_id"],
+ "client": ticket["client"],
+ "category": ticket["category"],
+ "search_text": search_text,
+ "full_text": full_text,
+ "tenant_id": None,
+ }
diff --git a/backend/app/services/embedding.py b/backend/app/services/embedding.py
new file mode 100644
index 0000000..aca186a
--- /dev/null
+++ b/backend/app/services/embedding.py
@@ -0,0 +1,35 @@
+from openai import AsyncOpenAI
+
+from app.config import settings
+
+client = AsyncOpenAI(
+ base_url="https://openrouter.ai/api/v1",
+ api_key=settings.openrouter_api_key,
+)
+
+EMBEDDING_MODEL = settings.openrouter_embedding_model
+IS_NVIDIA = "nvidia" in EMBEDDING_MODEL
+
+
+def _prefix_text(text: str, prefix: str) -> str:
+ if IS_NVIDIA:
+ return f"{prefix}: {text}"
+ return text
+
+
+async def embed_texts(texts: list[str]) -> list[list[float]]:
+ prefixed = [_prefix_text(t, "passage") for t in texts]
+ response = await client.embeddings.create(
+ model=EMBEDDING_MODEL,
+ input=prefixed,
+ )
+ return [item.embedding for item in response.data]
+
+
+async def embed_query(text: str) -> list[float]:
+ prefixed = _prefix_text(text, "query")
+ response = await client.embeddings.create(
+ model=EMBEDDING_MODEL,
+ input=[prefixed],
+ )
+ return response.data[0].embedding
diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py
new file mode 100644
index 0000000..095c326
--- /dev/null
+++ b/backend/app/services/llm.py
@@ -0,0 +1,33 @@
+from openai import OpenAI
+
+from app.config import settings
+
+client = OpenAI(
+ base_url="https://openrouter.ai/api/v1",
+ api_key=settings.openrouter_api_key,
+)
+
+SYSTEM_PROMPT = """Ты — специалист техподдержки. Отвечай клиенту, используя ТОЛЬКО информацию из переданных тикетов (контекст).
+
+Правила:
+- Если контекст содержит подходящее решение — напиши ответ своими словами, адаптируя под вопрос
+- Если контекст не относится к вопросу — напиши: «Недостаточно информации в истории обращений»
+- НЕ придумывай ответы, НЕ используй общие знания
+- НЕ говори «обратитесь в службу поддержки» — ты сам и есть поддержка
+- Укажи в конце: «Основано на тикете №...»"""
+
+
+async def generate_answer(query: str, context: str) -> str:
+ response = await client.chat.completions.create(
+ model=settings.openrouter_llm_model,
+ messages=[
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {
+ "role": "user",
+ "content": f"Контекст (история обращений):\n{context}\n\nВопрос клиента:\n{query}",
+ },
+ ],
+ temperature=0.1,
+ max_tokens=2000,
+ )
+ return response.choices[0].message.content
diff --git a/backend/app/services/reranking.py b/backend/app/services/reranking.py
new file mode 100644
index 0000000..2fca8be
--- /dev/null
+++ b/backend/app/services/reranking.py
@@ -0,0 +1,27 @@
+from sentence_transformers import CrossEncoder
+
+reranker = CrossEncoder(
+ "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1",
+ max_length=512,
+ device="cpu",
+)
+
+
+def rerank(query: str, results: list[dict], k: int = 5) -> list[dict]:
+ if not results:
+ return []
+
+ pairs = [(query, r["payload"]["full_text"]) for r in results]
+ scores = reranker.predict(pairs)
+
+ scored = []
+ for i, score in enumerate(scores):
+ scored.append({
+ "ticket_id": results[i]["payload"]["ticket_id"],
+ "score": float(score),
+ "full_text": results[i]["payload"]["full_text"],
+ "category": results[i]["payload"].get("category", ""),
+ })
+
+ scored.sort(key=lambda x: x["score"], reverse=True)
+ return scored[:k]
diff --git a/backend/app/services/vector_store.py b/backend/app/services/vector_store.py
new file mode 100644
index 0000000..2c57aa8
--- /dev/null
+++ b/backend/app/services/vector_store.py
@@ -0,0 +1,91 @@
+from qdrant_client import QdrantClient
+from qdrant_client.http.models import (
+ PointStruct,
+ Filter,
+ FieldCondition,
+ MatchValue,
+ VectorParams,
+ Distance,
+)
+
+from app.config import settings
+
+client = QdrantClient(url=settings.qdrant_url)
+COLLECTION_NAME = "tickets"
+_vector_size: int | None = None
+
+
+def get_vector_size() -> int:
+ global _vector_size
+ if _vector_size is not None:
+ return _vector_size
+
+ collections = client.get_collections().collections
+ for c in collections:
+ if c.name == COLLECTION_NAME:
+ info = client.get_collection(COLLECTION_NAME)
+ _vector_size = info.config.params.vectors.size
+ return _vector_size
+
+ return 0
+
+
+async def ensure_collection(size: int):
+ global _vector_size
+ collections = client.get_collections().collections
+ exists = any(c.name == COLLECTION_NAME for c in collections)
+
+ if not exists:
+ client.create_collection(
+ collection_name=COLLECTION_NAME,
+ vectors_config=VectorParams(size=size, distance=Distance.COSINE),
+ )
+ _vector_size = size
+ else:
+ get_vector_size()
+
+
+async def upsert_chunks(tenant_id: str, chunks: list[dict], vectors: list[list[float]]):
+ size = len(vectors[0])
+ await ensure_collection(size)
+
+ points = []
+ for chunk, vector in zip(chunks, vectors):
+ points.append(
+ PointStruct(
+ id=hash(f"{tenant_id}:{chunk['ticket_id']}") & 0x7FFFFFFFFFFFFFFF,
+ vector=vector,
+ payload={
+ "tenant_id": tenant_id,
+ "ticket_id": chunk["ticket_id"],
+ "client": chunk.get("client", ""),
+ "category": chunk.get("category", ""),
+ "collection_id": chunk.get("collection_id", ""),
+ "search_text": chunk.get("search_text", ""),
+ "full_text": chunk.get("full_text", ""),
+ },
+ )
+ )
+
+ client.upsert(collection_name=COLLECTION_NAME, points=points)
+
+
+async def search(
+ tenant_id: str,
+ query_vector: list[float],
+ collection_id: str | None = None,
+ k: int = 20,
+):
+ must_conditions = [FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id))]
+ if collection_id:
+ must_conditions.append(FieldCondition(key="collection_id", match=MatchValue(value=collection_id)))
+
+ results = client.query_points(
+ collection_name=COLLECTION_NAME,
+ query=query_vector,
+ query_filter=Filter(must=must_conditions),
+ limit=k,
+ with_payload=True,
+ )
+
+ return results.points
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/process_file.py b/backend/app/workers/process_file.py
new file mode 100644
index 0000000..436eb92
--- /dev/null
+++ b/backend/app/workers/process_file.py
@@ -0,0 +1,9 @@
+from celery import Celery
+from app.config import settings
+
+celery_app = Celery("worker", broker=settings.redis_url, backend=settings.redis_url)
+
+
+@celery_app.task
+def process_tickets_file(file_path: str, tenant_id: str, collection_id: str):
+ pass
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..7c5b774
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,18 @@
+fastapi==0.115.0
+uvicorn[standard]==0.30.0
+pydantic==2.9.0
+pydantic-settings==2.5.0
+pydantic[email]
+sqlalchemy[asyncio]==2.0.35
+asyncpg==0.29.0
+redis==5.1.0
+qdrant-client==1.11.0
+httpx==0.27.0
+openai==1.54.0
+celery==5.4.0
+pyjwt==2.9.0
+passlib[bcrypt]==1.7.4
+python-multipart==0.0.12
+sentence-transformers==3.2.0
+numpy==1.26.4
+prometheus-client==0.20.0
diff --git a/docker-compose.monitoring.yml b/docker-compose.monitoring.yml
new file mode 100644
index 0000000..0b58bde
--- /dev/null
+++ b/docker-compose.monitoring.yml
@@ -0,0 +1,21 @@
+services:
+ prometheus:
+ image: prom/prometheus:latest
+ ports:
+ - "9090:9090"
+ volumes:
+ - ./prometheus.yml:/etc/prometheus/prometheus.yml
+ - prometheus_data:/prometheus
+
+ grafana:
+ image: grafana/grafana:latest
+ ports:
+ - "3001:3000"
+ environment:
+ - GF_SECURITY_ADMIN_PASSWORD=admin
+ volumes:
+ - grafana_data:/var/lib/grafana
+
+volumes:
+ prometheus_data:
+ grafana_data:
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..da0dba1
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,55 @@
+services:
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_DB: support_bot
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ ports:
+ - "5432:5432"
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+
+ redis:
+ image: redis:7-alpine
+ ports:
+ - "6379:6379"
+
+ qdrant:
+ image: qdrant/qdrant:latest
+ ports:
+ - "6333:6333"
+ - "6334:6334"
+ volumes:
+ - qdrant_data:/qdrant/storage
+
+ backend:
+ build: ./backend
+ ports:
+ - "8000:8000"
+ env_file: .env
+ environment:
+ - DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/support_bot
+ - REDIS_URL=redis://redis:6379/0
+ - QDRANT_URL=http://qdrant:6333
+ depends_on:
+ - postgres
+ - redis
+ - qdrant
+
+ worker:
+ build: ./backend
+ command: celery -A app.workers.process_file worker --loglevel=info
+ env_file: .env
+ environment:
+ - DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/support_bot
+ - REDIS_URL=redis://redis:6379/0
+ - QDRANT_URL=http://qdrant:6333
+ depends_on:
+ - postgres
+ - redis
+ - qdrant
+
+volumes:
+ pgdata:
+ qdrant_data:
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..23301a7
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,11 @@
+FROM node:22-alpine AS builder
+
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
+FROM nginx:alpine
+COPY --from=builder /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..d6d9b09
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Support Bot SaaS
+
+
+
+
+
+
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
new file mode 100644
index 0000000..759d9b3
--- /dev/null
+++ b/frontend/nginx.conf
@@ -0,0 +1,18 @@
+server {
+ listen 80;
+ server_name sovet.itoservice.ru;
+
+ 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;
+ }
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..0741fbf
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,1774 @@
+{
+ "name": "support-bot-saas-frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "support-bot-saas-frontend",
+ "version": "0.1.0",
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.26.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.3.1",
+ "typescript": "^5.5.3",
+ "vite": "^5.4.0"
+ }
+ },
+ "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/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/@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.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "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/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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==",
+ "dev": true,
+ "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==",
+ "dev": true,
+ "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/@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/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/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/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/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/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.376",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz",
+ "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "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/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/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/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/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/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/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/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/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-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/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "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.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.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/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "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/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/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..b4cf483
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "support-bot-saas-frontend",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.26.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.3.1",
+ "typescript": "^5.5.3",
+ "vite": "^5.4.0"
+ }
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..3bf92f5
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,23 @@
+import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
+import Login from './pages/Login'
+import Register from './pages/Register'
+import Dashboard from './pages/Dashboard'
+import CollectionView from './pages/CollectionView'
+import Settings from './pages/Settings'
+
+function App() {
+ return (
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ )
+}
+
+export default App
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
new file mode 100644
index 0000000..47ac1b9
--- /dev/null
+++ b/frontend/src/api/client.ts
@@ -0,0 +1,88 @@
+const API_BASE = import.meta.env.VITE_API_URL || '/api'
+
+let token: string | null = localStorage.getItem('token')
+
+export function setToken(t: string | null) {
+ token = t
+ if (t) localStorage.setItem('token', t)
+ else localStorage.removeItem('token')
+}
+
+export function getToken() {
+ return token
+}
+
+async function request(path: string, options: RequestInit = {}): Promise {
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ ...(options.headers as Record || {}),
+ }
+
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`
+ }
+
+ const res = await fetch(`${API_BASE}${path}`, { ...options, headers })
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ detail: res.statusText }))
+ throw new Error(err.detail || 'Request failed')
+ }
+
+ if (res.status === 204) return undefined as T
+ return res.json()
+}
+
+export interface User {
+ id: string
+ email: string
+ api_key: string
+ plan: string
+ created_at: string
+}
+
+export interface Collection {
+ id: string
+ user_id: string
+ name: string
+ ticket_count: number
+ created_at: string
+}
+
+export interface SearchResult {
+ answer: string | null
+ sources: { ticket_id: string; score: number; category: string }[]
+}
+
+export const api = {
+ register: (email: string, password: string) =>
+ request<{ access_token: string }>('/register', {
+ method: 'POST',
+ body: JSON.stringify({ email, password }),
+ }),
+
+ login: (email: string, password: string) =>
+ request<{ access_token: string }>('/login', {
+ method: 'POST',
+ body: JSON.stringify({ email, password }),
+ }),
+
+ me: () => request('/me'),
+
+ createCollection: (name: string) =>
+ request('/collections', {
+ method: 'POST',
+ body: JSON.stringify({ name }),
+ }),
+
+ listCollections: () => request('/collections'),
+
+ deleteCollection: (id: string) =>
+ request(`/collections/${id}`, { method: 'DELETE' }),
+
+ search: (collectionId: string, query: string, generateAnswer = true) =>
+ request('/search', {
+ method: 'POST',
+ body: JSON.stringify({ collection_id: collectionId, query, generate_answer: generateAnswer }),
+ }),
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..c4c5b6d
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,9 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/frontend/src/pages/CollectionView.tsx b/frontend/src/pages/CollectionView.tsx
new file mode 100644
index 0000000..a0f68bd
--- /dev/null
+++ b/frontend/src/pages/CollectionView.tsx
@@ -0,0 +1,81 @@
+import { useEffect, useState } from 'react'
+import { useParams, Link } from 'react-router-dom'
+import { api, getToken } from '../api/client'
+
+export default function CollectionView() {
+ const { id } = useParams<{ id: string }>()
+ const [query, setQuery] = useState('')
+ const [result, setResult] = useState(null)
+ const [loading, setLoading] = useState(false)
+
+ useEffect(() => {
+ if (!getToken()) {
+ window.location.href = '/login'
+ }
+ }, [])
+
+ async function handleSearch(e: React.FormEvent) {
+ e.preventDefault()
+ if (!query.trim()) return
+
+ setLoading(true)
+ try {
+ const res = await api.search(id!, query)
+ setResult(res)
+ } catch (err: any) {
+ setResult({ error: err.message })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+ ← Назад к списку
+
+
+
Поиск по базе знаний
+
+
+
+ {result && (
+
+ {result.answer && (
+
+
Ответ
+
{result.answer}
+
+ )}
+
+ {result.sources && result.sources.length > 0 && (
+
+
Найденные тикеты
+ {result.sources.map((s: any, i: number) => (
+
+ Тикет №{s.ticket_id}
+
+ Релевантность: {s.score.toFixed(3)} | {s.category}
+
+
+ ))}
+
+ )}
+
+ {result.error &&
{result.error}
}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
new file mode 100644
index 0000000..54d229a
--- /dev/null
+++ b/frontend/src/pages/Dashboard.tsx
@@ -0,0 +1,79 @@
+import { useEffect, useState } from 'react'
+import { useNavigate, Link } from 'react-router-dom'
+import { api, Collection, setToken, getToken } from '../api/client'
+
+export default function Dashboard() {
+ const [collections, setCollections] = useState([])
+ const [name, setName] = useState('')
+ const [user, setUser] = useState(null)
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ if (!getToken()) {
+ navigate('/login')
+ return
+ }
+ api.me().then(u => {
+ setUser(u)
+ return api.listCollections()
+ }).then(setCollections).catch(() => navigate('/login'))
+ }, [navigate])
+
+ async function createCollection(e: React.FormEvent) {
+ e.preventDefault()
+ const c = await api.createCollection(name)
+ setCollections([c, ...collections])
+ setName('')
+ }
+
+ async function removeCollection(id: string) {
+ await api.deleteCollection(id)
+ setCollections(collections.filter(c => c.id !== id))
+ }
+
+ function logout() {
+ setToken(null)
+ navigate('/login')
+ }
+
+ return (
+
+
+
Support Bot SaaS
+
+ Настройки
+
+
+
+
+ {user &&
План: {user.plan} | Тип: {user.email}
}
+
+
+
+
Базы знаний
+ {collections.length === 0 &&
Пока нет ни одной базы знаний
}
+ {collections.map(c => (
+
+
+ {c.name}
+
+
+ Тикетов: {c.ticket_count} | Создана: {new Date(c.created_at).toLocaleDateString()}
+
+
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
new file mode 100644
index 0000000..156f78c
--- /dev/null
+++ b/frontend/src/pages/Login.tsx
@@ -0,0 +1,56 @@
+import { useState } from 'react'
+import { useNavigate, Link } from 'react-router-dom'
+import { api, setToken } from '../api/client'
+
+export default function Login() {
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState('')
+ const navigate = useNavigate()
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ try {
+ const res = await api.login(email, password)
+ setToken(res.access_token)
+ navigate('/dashboard')
+ } catch (err: any) {
+ setError(err.message)
+ }
+ }
+
+ return (
+
+
Вход
+
+
+ Нет аккаунта? Регистрация
+
+
+ )
+}
diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx
new file mode 100644
index 0000000..335a89b
--- /dev/null
+++ b/frontend/src/pages/Register.tsx
@@ -0,0 +1,56 @@
+import { useState } from 'react'
+import { useNavigate, Link } from 'react-router-dom'
+import { api, setToken } from '../api/client'
+
+export default function Register() {
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState('')
+ const navigate = useNavigate()
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ try {
+ const res = await api.register(email, password)
+ setToken(res.access_token)
+ navigate('/dashboard')
+ } catch (err: any) {
+ setError(err.message)
+ }
+ }
+
+ return (
+
+
Регистрация
+
+
+ Уже есть аккаунт? Войти
+
+
+ )
+}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
new file mode 100644
index 0000000..d1ca465
--- /dev/null
+++ b/frontend/src/pages/Settings.tsx
@@ -0,0 +1,34 @@
+import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { api, getToken, User } from '../api/client'
+
+export default function Settings() {
+ const [user, setUser] = useState(null)
+
+ useEffect(() => {
+ if (!getToken()) {
+ window.location.href = '/login'
+ return
+ }
+ api.me().then(setUser)
+ }, [])
+
+ return (
+
+
+ ← Назад
+
+
+
Настройки
+
+ {user && (
+
+
Email: {user.email}
+
API Key: {user.api_key}
+
План: {user.plan}
+
Дата регистрации: {new Date(user.created_at).toLocaleDateString()}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..fdb15fc
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo
new file mode 100644
index 0000000..9e483fe
--- /dev/null
+++ b/frontend/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/pages/collectionview.tsx","./src/pages/dashboard.tsx","./src/pages/login.tsx","./src/pages/register.tsx","./src/pages/settings.tsx"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..bb65808
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 3000,
+ proxy: {
+ '/api': 'http://localhost:8000',
+ },
+ },
+})
diff --git a/helm/support-bot-saas/Chart.yaml b/helm/support-bot-saas/Chart.yaml
new file mode 100644
index 0000000..f539811
--- /dev/null
+++ b/helm/support-bot-saas/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+name: support-bot-saas
+description: RAG SaaS for tech support
+type: application
+version: 0.1.0
+appVersion: "0.1.0"
diff --git a/helm/support-bot-saas/templates/deployment-backend.yaml b/helm/support-bot-saas/templates/deployment-backend.yaml
new file mode 100644
index 0000000..aff3e6b
--- /dev/null
+++ b/helm/support-bot-saas/templates/deployment-backend.yaml
@@ -0,0 +1,19 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ .Release.Name }}-backend
+spec:
+ replicas: {{ .Values.replicaCount }}
+ selector:
+ matchLabels:
+ app: {{ .Release.Name }}-backend
+ template:
+ metadata:
+ labels:
+ app: {{ .Release.Name }}-backend
+ spec:
+ containers:
+ - name: backend
+ image: {{ .Values.image.backend }}
+ ports:
+ - containerPort: {{ .Values.service.backendPort }}
diff --git a/helm/support-bot-saas/templates/deployment-frontend.yaml b/helm/support-bot-saas/templates/deployment-frontend.yaml
new file mode 100644
index 0000000..26b7d03
--- /dev/null
+++ b/helm/support-bot-saas/templates/deployment-frontend.yaml
@@ -0,0 +1,19 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ .Release.Name }}-frontend
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: {{ .Release.Name }}-frontend
+ template:
+ metadata:
+ labels:
+ app: {{ .Release.Name }}-frontend
+ spec:
+ containers:
+ - name: frontend
+ image: {{ .Values.image.frontend }}
+ ports:
+ - containerPort: {{ .Values.service.frontendPort }}
diff --git a/helm/support-bot-saas/templates/ingress.yaml b/helm/support-bot-saas/templates/ingress.yaml
new file mode 100644
index 0000000..ea90b9b
--- /dev/null
+++ b/helm/support-bot-saas/templates/ingress.yaml
@@ -0,0 +1,26 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: {{ .Release.Name }}-ingress
+spec:
+ rules:
+ - host: {{ .Values.ingress.apiHost }}
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: {{ .Release.Name }}-backend
+ port:
+ number: {{ .Values.service.backendPort }}
+ - host: {{ .Values.ingress.appHost }}
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: {{ .Release.Name }}-frontend
+ port:
+ number: {{ .Values.service.frontendPort }}
diff --git a/helm/support-bot-saas/templates/service-backend.yaml b/helm/support-bot-saas/templates/service-backend.yaml
new file mode 100644
index 0000000..0605c41
--- /dev/null
+++ b/helm/support-bot-saas/templates/service-backend.yaml
@@ -0,0 +1,10 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ .Release.Name }}-backend
+spec:
+ selector:
+ app: {{ .Release.Name }}-backend
+ ports:
+ - port: {{ .Values.service.backendPort }}
+ targetPort: {{ .Values.service.backendPort }}
diff --git a/helm/support-bot-saas/templates/service-frontend.yaml b/helm/support-bot-saas/templates/service-frontend.yaml
new file mode 100644
index 0000000..88a07e0
--- /dev/null
+++ b/helm/support-bot-saas/templates/service-frontend.yaml
@@ -0,0 +1,10 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ .Release.Name }}-frontend
+spec:
+ selector:
+ app: {{ .Release.Name }}-frontend
+ ports:
+ - port: {{ .Values.service.frontendPort }}
+ targetPort: {{ .Values.service.frontendPort }}
diff --git a/helm/support-bot-saas/values.yaml b/helm/support-bot-saas/values.yaml
new file mode 100644
index 0000000..d848308
--- /dev/null
+++ b/helm/support-bot-saas/values.yaml
@@ -0,0 +1,23 @@
+replicaCount: 2
+
+image:
+ backend: support-bot-backend:latest
+ frontend: support-bot-frontend:latest
+ pullPolicy: IfNotPresent
+
+service:
+ backendPort: 8000
+ frontendPort: 80
+
+ingress:
+ apiHost: api.sovet.itoservice.ru
+ appHost: sovet.itoservice.ru
+
+postgres:
+ size: 10Gi
+
+redis:
+ size: 5Gi
+
+qdrant:
+ size: 20Gi
diff --git a/prometheus.yml b/prometheus.yml
new file mode 100644
index 0000000..3e36ab1
--- /dev/null
+++ b/prometheus.yml
@@ -0,0 +1,8 @@
+global:
+ scrape_interval: 15s
+
+scrape_configs:
+ - job_name: 'backend'
+ static_configs:
+ - targets: ['backend:8000']
+ metrics_path: '/api/metrics'