diff --git a/.gitignore b/.gitignore index 9fb8bc6a1f..17924a1137 100755 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ Thumbs.db # Other resources/ tmp/ +auth.db # Local working docs **/CLAUDE.md diff --git a/studio/backend/auth/__init__.py b/studio/backend/auth/__init__.py index 7b67b74084..60ad59885c 100644 --- a/studio/backend/auth/__init__.py +++ b/studio/backend/auth/__init__.py @@ -1,24 +1,46 @@ """ Authentication module for JWT-based auth with SQLite storage. """ -from .authentication import create_access_token, get_current_subject, reload_secret +from .authentication import ( + create_access_token, + create_refresh_token, + refresh_access_token, + get_current_subject, + reload_secret, +) from .storage import ( is_initialized, create_initial_user, get_user_and_secret, load_jwt_secret, + save_setup_token, + consume_setup_token, + has_pending_setup_token, + save_refresh_token, + verify_refresh_token, + revoke_user_refresh_tokens, ) from .hashing import hash_password, verify_password __all__ = [ "create_access_token", + "create_refresh_token", + "refresh_access_token", "get_current_subject", "reload_secret", "is_initialized", "create_initial_user", "get_user_and_secret", "load_jwt_secret", + "save_setup_token", + "consume_setup_token", + "has_pending_setup_token", + "save_refresh_token", + "verify_refresh_token", + "revoke_user_refresh_tokens", "hash_password", "verify_password", ] + + diff --git a/studio/backend/auth/auth.db b/studio/backend/auth/auth.db deleted file mode 100644 index b525e4b096..0000000000 Binary files a/studio/backend/auth/auth.db and /dev/null differ diff --git a/studio/backend/auth/authentication.py b/studio/backend/auth/authentication.py index 33b5125ddb..6ea668db3e 100644 --- a/studio/backend/auth/authentication.py +++ b/studio/backend/auth/authentication.py @@ -6,10 +6,11 @@ from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer import jwt -from .storage import load_jwt_secret +from .storage import load_jwt_secret, save_refresh_token, verify_refresh_token ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 +REFRESH_TOKEN_EXPIRE_DAYS = 7 # Load stable secret from SQLite (set during first-time setup) # This will raise RuntimeError if auth hasn't been initialized yet @@ -40,6 +41,31 @@ def create_access_token( return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) +def create_refresh_token(subject: str) -> str: + """ + Create a random refresh token, store its hash in SQLite, and return it. + + Refresh tokens are opaque (not JWTs) and expire after REFRESH_TOKEN_EXPIRE_DAYS. + """ + token = secrets.token_urlsafe(48) + expires_at = datetime.now(UTC) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + save_refresh_token(token, subject, expires_at.isoformat()) + return token + + +def refresh_access_token(refresh_token: str) -> Optional[str]: + """ + Validate a refresh token and issue a new access token. + + The refresh token itself is NOT consumed — it stays valid until expiry. + Returns a new access_token or None if the refresh token is invalid/expired. + """ + username = verify_refresh_token(refresh_token) + if username is None: + return None + return create_access_token(subject=username) + + def reload_secret() -> None: """ Reload the JWT secret from SQLite. @@ -77,7 +103,3 @@ async def get_current_subject( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token", ) -# token = create_access_token("local-user") -# print(token) - - diff --git a/studio/backend/auth/storage.py b/studio/backend/auth/storage.py index 0e2c9d7388..2864cb9852 100644 --- a/studio/backend/auth/storage.py +++ b/studio/backend/auth/storage.py @@ -1,13 +1,20 @@ """ SQLite storage for authentication data (user credentials + JWT secret). """ +import hashlib import sqlite3 +from datetime import UTC, datetime from pathlib import Path from typing import Optional, Tuple DB_PATH = Path(__file__).parent / "auth.db" +def _hash_token(token: str) -> str: + """SHA-256 hash a setup token for safe storage.""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + def get_connection() -> sqlite3.Connection: """Get a connection to the auth database, creating tables if needed.""" conn = sqlite3.connect(DB_PATH) @@ -23,6 +30,24 @@ def get_connection() -> sqlite3.Connection: ); """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS setup_tokens ( + id INTEGER PRIMARY KEY, + token_hash TEXT NOT NULL + ); + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id INTEGER PRIMARY KEY, + token_hash TEXT NOT NULL, + username TEXT NOT NULL, + expires_at TEXT NOT NULL + ); + """ + ) conn.commit() return conn @@ -99,3 +124,119 @@ def load_jwt_secret() -> str: finally: conn.close() + +def save_setup_token(token: str) -> None: + """ + Store a hashed setup token, replacing any existing one. + """ + token_hash = _hash_token(token) + conn = get_connection() + try: + conn.execute("DELETE FROM setup_tokens") + conn.execute("INSERT INTO setup_tokens (token_hash) VALUES (?)", (token_hash,)) + conn.commit() + finally: + conn.close() + + +def consume_setup_token(token: str) -> bool: + """ + Verify a setup token and delete it if valid. + + Returns True if the token was valid (and is now consumed), False otherwise. + """ + token_hash = _hash_token(token) + conn = get_connection() + try: + cur = conn.execute( + "SELECT id FROM setup_tokens WHERE token_hash = ?", (token_hash,) + ) + row = cur.fetchone() + if row is None: + return False + conn.execute("DELETE FROM setup_tokens WHERE id = ?", (row["id"],)) + conn.commit() + return True + finally: + conn.close() + + +def has_pending_setup_token() -> bool: + """Check if a setup token is waiting to be consumed.""" + conn = get_connection() + try: + cur = conn.execute("SELECT COUNT(*) AS c FROM setup_tokens") + row = cur.fetchone() + return bool(row["c"]) + finally: + conn.close() + + +def save_refresh_token(token: str, username: str, expires_at: str) -> None: + """ + Store a hashed refresh token with its associated username and expiry. + """ + token_hash = _hash_token(token) + conn = get_connection() + try: + conn.execute( + """ + INSERT INTO refresh_tokens (token_hash, username, expires_at) + VALUES (?, ?, ?) + """, + (token_hash, username, expires_at), + ) + conn.commit() + finally: + conn.close() + + +def verify_refresh_token(token: str) -> Optional[str]: + """ + Verify a refresh token and return the username. + + Returns the username if valid and not expired, None otherwise. + The token is NOT consumed — it stays valid until it expires. + """ + token_hash = _hash_token(token) + conn = get_connection() + try: + # Clean up any expired tokens while we're here + conn.execute( + "DELETE FROM refresh_tokens WHERE expires_at < ?", + (datetime.now(UTC).isoformat(),), + ) + conn.commit() + + cur = conn.execute( + """ + SELECT id, username, expires_at FROM refresh_tokens + WHERE token_hash = ? + """, + (token_hash,), + ) + row = cur.fetchone() + if row is None: + return None + + # Check expiry + expires_at = datetime.fromisoformat(row["expires_at"]) + if datetime.now(UTC) > expires_at: + conn.execute("DELETE FROM refresh_tokens WHERE id = ?", (row["id"],)) + conn.commit() + return None + + return row["username"] + finally: + conn.close() + + +def revoke_user_refresh_tokens(username: str) -> None: + """Revoke all refresh tokens for a user (e.g. on logout).""" + conn = get_connection() + try: + conn.execute("DELETE FROM refresh_tokens WHERE username = ?", (username,)) + conn.commit() + finally: + conn.close() + diff --git a/studio/backend/core/__init__.py b/studio/backend/core/__init__.py index 46ee6c14b1..7b562c6cb0 100644 --- a/studio/backend/core/__init__.py +++ b/studio/backend/core/__init__.py @@ -13,7 +13,8 @@ from utils.models import is_vision_model, ModelConfig, scan_trained_loras, load_ # Utilities (from utils) from utils.paths import normalize_path, is_local_path, is_model_cached -from utils.utils import without_hf_auth, format_error_message, get_gpu_memory_info, search_hf_models +from utils.utils import without_hf_auth, format_error_message +from utils.hardware import get_device, is_apple_silicon, clear_gpu_cache, get_gpu_memory_info, log_gpu_memory, DeviceType from utils.datasets import format_and_template_dataset __all__ = [ @@ -37,7 +38,6 @@ __all__ = [ 'get_base_model_from_lora', # Utils - 'search_hf_models', 'format_and_template_dataset', 'normalize_path', 'is_local_path', @@ -45,4 +45,9 @@ __all__ = [ 'without_hf_auth', 'format_error_message', 'get_gpu_memory_info', + 'log_gpu_memory', + 'get_device', + 'is_apple_silicon', + 'clear_gpu_cache', + 'DeviceType', ] diff --git a/studio/backend/core/export/export.py b/studio/backend/core/export/export.py index 11662c9e5f..8977586ac3 100644 --- a/studio/backend/core/export/export.py +++ b/studio/backend/core/export/export.py @@ -11,6 +11,7 @@ from unsloth import FastLanguageModel, FastVisionModel from huggingface_hub import HfApi, ModelCard from transformers.modeling_utils import PushToHubMixin import torch +from utils.hardware import clear_gpu_cache from utils.models import is_vision_model, get_base_model_from_lora from core.inference import get_inference_backend @@ -69,14 +70,8 @@ class ExportBackend: self.current_tokenizer = None self.current_checkpoint = None - # Force garbage collection - import gc - gc.collect() - - # Clear CUDA cache - if torch.cuda.is_available(): - torch.cuda.empty_cache() - torch.cuda.ipc_collect() + # Clear GPU memory cache (handles gc + backend-specific cleanup) + clear_gpu_cache() logger.info("Memory cleanup completed successfully") return True diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py index 70b6f49f32..fe75d6a976 100644 --- a/studio/backend/core/inference/inference.py +++ b/studio/backend/core/inference/inference.py @@ -11,7 +11,8 @@ import torch from typing import Optional, Generator, Tuple from utils.models import ModelConfig, get_base_model_from_lora from utils.paths import is_model_cached -from utils.utils import format_error_message, log_gpu_memory +from utils.utils import format_error_message +from utils.hardware import get_device, clear_gpu_cache, log_gpu_memory from io import StringIO import logging @@ -35,7 +36,7 @@ class InferenceBackend: "unsloth/Gemma-3-4B-it", "unsloth/Qwen2-VL-2B-Instruct-bnb-4bit", ] - self.device = "cuda" if torch.cuda.is_available() else "cpu" + self.device = get_device().value # Thread safety import threading @@ -154,12 +155,8 @@ class InferenceBackend: if self.active_model_name == model_name: self.active_model_name = None - # Use garbage collection and clear CUDA cache to release memory - import gc - import torch - gc.collect() - if torch.cuda.is_available(): - torch.cuda.empty_cache() + # Clear GPU memory cache + clear_gpu_cache() logger.info(f"Model '{model_name}' successfully unloaded.") return True @@ -562,11 +559,11 @@ class InferenceBackend: input_text, add_special_tokens=False, return_tensors="pt", - ).to("cuda") + ).to(self.device) else: # Text-only for vision model formatted_prompt = self.format_chat_prompt(messages, system_prompt) - inputs = processor.tokenizer(formatted_prompt, return_tensors="pt").to("cuda") + inputs = processor.tokenizer(formatted_prompt, return_tensors="pt").to(self.device) # Generate with streaming captured_output = StringIO() @@ -888,11 +885,8 @@ class InferenceBackend: for model_name in self.models.keys(): self._reset_model_generation_state(model_name) - import torch - if torch.cuda.is_available(): - torch.cuda.empty_cache() - torch.cuda.ipc_collect() - logger.debug("Cleared CUDA cache and IPC resources") + clear_gpu_cache() + logger.debug("Cleared GPU cache") import gc gc.collect() diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index 5fc48aa18a..af5c3ebe01 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -3,6 +3,7 @@ Unsloth Training Backend Integrates Unsloth training capabilities with the Gradio UI """ import torch +from utils.hardware import clear_gpu_cache torch._dynamo.config.recompile_limit = 64 from unsloth import FastLanguageModel, FastVisionModel, is_bfloat16_supported from unsloth.chat_templates import get_chat_template @@ -98,9 +99,7 @@ class UnslothTrainer: """Load model for training (supports both text and vision models)""" try: print("\nClearing GPU memory before training...") - torch.cuda.empty_cache() - import gc - gc.collect() + clear_gpu_cache() # Detect if this is a vision model first self.is_vlm = is_vision_model(model_name) @@ -804,8 +803,7 @@ class UnslothTrainer: self.tokenizer = None # Clear GPU memory - if torch.cuda.is_available(): - torch.cuda.empty_cache() + clear_gpu_cache() def _ensure_deepseek_ocr_installed(): diff --git a/studio/backend/main.py b/studio/backend/main.py index f93f6c2820..e957c668e3 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -1,6 +1,10 @@ """ Main FastAPI application for Unsloth UI Backend """ +import secrets +import shutil +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles @@ -10,12 +14,40 @@ from datetime import datetime # Import routers from routes import training_router, models_router, inference_router, datasets_router, auth_router +from auth import storage +from utils.hardware import detect_hardware +import utils.hardware.hardware as _hw_module + +UNSLOTH_CACHE_DIR = Path(__file__).parent / "unsloth_compiled_cache" + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Startup: detect hardware, print setup token if needed. Shutdown: clean up compiled cache.""" + # Detect hardware first — sets DEVICE global used everywhere + detect_hardware() + + if not storage.is_initialized(): + setup_token = secrets.token_urlsafe(32) + storage.save_setup_token(setup_token) + print("\n" + "=" * 60) + print("FIRST-TIME SETUP REQUIRED") + print("Use this one-time setup token to create your admin account:\n") + print(f" {setup_token}\n") + print("This token can only be used once.") + print("=" * 60 + "\n") + yield + # Cleanup + _hw_module.DEVICE = None + shutil.rmtree(UNSLOTH_CACHE_DIR, ignore_errors=True) + # Create FastAPI app app = FastAPI( title="Unsloth UI Backend", version="1.0.0", - description="Backend API for Unsloth UI - Training and Model Management" + description="Backend API for Unsloth UI - Training and Model Management", + lifespan=lifespan, ) # CORS middleware @@ -52,23 +84,20 @@ async def health_check(): @app.get("/api/system") async def get_system_info(): """Get system information""" - import torch import platform import psutil + from utils.hardware import get_device, get_gpu_memory_info, DeviceType - # GPU Info - gpu_info = {"available": False, "devices": []} - if torch.cuda.is_available(): - gpu_info["available"] = True - for i in range(torch.cuda.device_count()): - props = torch.cuda.get_device_properties(i) - gpu_info["devices"].append( - { - "index": i, - "name": props.name, - "memory_total_gb": round(props.total_memory / 1e9, 2), - } - ) + # GPU Info — uses the hardware module (works on CUDA, MPS, CPU) + mem_info = get_gpu_memory_info() + gpu_info = {"available": mem_info.get("available", False), "devices": []} + + if mem_info.get("available"): + gpu_info["devices"].append({ + "index": mem_info.get("device", 0), + "name": mem_info.get("device_name", "Unknown"), + "memory_total_gb": round(mem_info.get("total_gb", 0), 2), + }) # CPU & Memory memory = psutil.virtual_memory() @@ -76,6 +105,7 @@ async def get_system_info(): return { "platform": platform.platform(), "python_version": platform.python_version(), + "device_backend": get_device().value, "cpu_count": psutil.cpu_count(), "memory": { "total_gb": round(memory.total / 1e9, 2), diff --git a/studio/backend/models/__init__.py b/studio/backend/models/__init__.py index 54696c9f99..584fb9f7c2 100644 --- a/studio/backend/models/__init__.py +++ b/studio/backend/models/__init__.py @@ -16,8 +16,28 @@ from .models import ( from .auth import ( AuthSetupRequest, AuthLoginRequest, + RefreshTokenRequest, AuthStatusResponse, ) +from .users import Token +from .datasets import ( + CheckFormatRequest, + CheckFormatResponse, +) +from .inference import ( + LoadRequest, + UnloadRequest, + GenerateRequest, + LoadResponse, + UnloadResponse, + InferenceStatusResponse, +) +from .responses import ( + TrainingStopResponse, + TrainingMetricsResponse, + LoRABaseModelResponse, + VisionCheckResponse, +) __all__ = [ # Training schemas @@ -33,6 +53,22 @@ __all__ = [ # Auth schemas "AuthSetupRequest", "AuthLoginRequest", + "RefreshTokenRequest", "AuthStatusResponse", + "Token", + # Dataset schemas + "CheckFormatRequest", + "CheckFormatResponse", + # Inference schemas + "LoadRequest", + "UnloadRequest", + "GenerateRequest", + "LoadResponse", + "UnloadResponse", + "InferenceStatusResponse", + # Response schemas + "TrainingStopResponse", + "TrainingMetricsResponse", + "LoRABaseModelResponse", + "VisionCheckResponse", ] - diff --git a/studio/backend/models/auth.py b/studio/backend/models/auth.py index f71956db58..43c591fb53 100644 --- a/studio/backend/models/auth.py +++ b/studio/backend/models/auth.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, Field class AuthSetupRequest(BaseModel): """First-time setup: create the initial admin user + password.""" + setup_token: str = Field(..., description="One-time setup token printed to the server console") username: str = Field(..., description="Admin username") password: str = Field(..., min_length=8, description="Admin password (minimum 8 characters)") @@ -16,6 +17,11 @@ class AuthLoginRequest(BaseModel): password: str = Field(..., description="Password") +class RefreshTokenRequest(BaseModel): + """Refresh token payload to obtain new access + refresh tokens.""" + refresh_token: str = Field(..., description="Refresh token from a previous login or refresh") + + class AuthStatusResponse(BaseModel): """Indicate whether auth has been initialized.""" initialized: bool = Field(..., description="True if auth setup has been completed") diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py new file mode 100644 index 0000000000..3917e38e36 --- /dev/null +++ b/studio/backend/models/inference.py @@ -0,0 +1,54 @@ +""" +Pydantic schemas for Inference API +""" +from pydantic import BaseModel, Field +from typing import Optional, List + + +class LoadRequest(BaseModel): + """Request to load a model for inference""" + model_path: str = Field(..., description="Model identifier or local path") + hf_token: Optional[str] = Field(None, description="HuggingFace token for gated models") + max_seq_length: int = Field(2048, ge=128, le=32768, description="Maximum sequence length") + load_in_4bit: bool = Field(True, description="Load model in 4-bit quantization") + is_lora: bool = Field(False, description="Whether this is a LoRA adapter") + + +class UnloadRequest(BaseModel): + """Request to unload a model""" + model_path: str = Field(..., description="Model identifier to unload") + + +class GenerateRequest(BaseModel): + """Request for text generation""" + messages: List[dict] = Field(..., description="Chat messages in OpenAI format") + system_prompt: str = Field("You are a helpful AI assistant.", description="System prompt") + temperature: float = Field(0.7, ge=0.0, le=2.0, description="Sampling temperature") + top_p: float = Field(0.9, ge=0.0, le=1.0, description="Top-p sampling") + top_k: int = Field(40, ge=1, le=100, description="Top-k sampling") + max_new_tokens: int = Field(512, ge=1, le=4096, description="Maximum tokens to generate") + repetition_penalty: float = Field(1.1, ge=1.0, le=2.0, description="Repetition penalty") + image_base64: Optional[str] = Field(None, description="Base64 encoded image for vision models") + + +class LoadResponse(BaseModel): + """Response after loading a model""" + status: str = Field(..., description="Load status") + model: str = Field(..., description="Model identifier") + display_name: str = Field(..., description="Display name of the model") + is_vision: bool = Field(False, description="Whether model is a vision model") + is_lora: bool = Field(False, description="Whether model is a LoRA adapter") + + +class UnloadResponse(BaseModel): + """Response after unloading a model""" + status: str = Field(..., description="Unload status") + model: str = Field(..., description="Model identifier that was unloaded") + + +class InferenceStatusResponse(BaseModel): + """Current inference backend status""" + active_model: Optional[str] = Field(None, description="Currently active model identifier") + is_vision: bool = Field(False, description="Whether the active model is a vision model") + loading: List[str] = Field(default_factory=list, description="Models currently being loaded") + loaded: List[str] = Field(default_factory=list, description="Models currently loaded") diff --git a/studio/backend/models/responses.py b/studio/backend/models/responses.py new file mode 100644 index 0000000000..2aa798c5c9 --- /dev/null +++ b/studio/backend/models/responses.py @@ -0,0 +1,38 @@ +""" +Pydantic response schemas for endpoints that previously returned raw dicts. +These are small response models for training and model management routes. +""" +from pydantic import BaseModel, Field +from typing import Optional, List + + +# --- Training route response models --- + +class TrainingStopResponse(BaseModel): + """Response for stopping a training job""" + status: str = Field(..., description="Current status: 'stopped' or 'idle'") + message: str = Field(..., description="Human-readable status message") + + +class TrainingMetricsResponse(BaseModel): + """Response for training metrics history""" + loss_history: List[float] = Field(default_factory=list, description="Loss values per step") + lr_history: List[float] = Field(default_factory=list, description="Learning rate per step") + step_history: List[int] = Field(default_factory=list, description="Step numbers") + current_loss: Optional[float] = Field(None, description="Most recent loss value") + current_lr: Optional[float] = Field(None, description="Most recent learning rate") + current_step: Optional[int] = Field(None, description="Most recent step number") + + +# --- Model management route response models --- + +class LoRABaseModelResponse(BaseModel): + """Response for getting a LoRA's base model""" + lora_path: str = Field(..., description="Path to the LoRA adapter") + base_model: str = Field(..., description="Base model identifier") + + +class VisionCheckResponse(BaseModel): + """Response for checking if a model is a vision model""" + model_name: str = Field(..., description="Model identifier") + is_vision: bool = Field(..., description="Whether the model is a vision model") diff --git a/studio/backend/models/users.py b/studio/backend/models/users.py index ba6bb8f8e7..c90e27942f 100644 --- a/studio/backend/models/users.py +++ b/studio/backend/models/users.py @@ -1,32 +1,15 @@ -"""Pydantic models for user-related API endpoints. +"""Pydantic models for authentication tokens. -This module defines the data models used for user authentication and management -in the FastAPI application. +This module defines the Token response model used by auth routes. """ -from pydantic import BaseModel - - -class User(BaseModel): - """Basic user model containing username.""" - - username: str - - -class UserInDB(BaseModel): - """User model with password for database storage.""" - - password: str +from pydantic import BaseModel, Field class Token(BaseModel): - """Authentication token model with access token and type.""" + """Authentication token model with access and refresh tokens.""" - access_token: str - token_type: str + access_token: str = Field(..., description="JWT access token (60 min expiry)") + refresh_token: str = Field(..., description="Opaque refresh token (7 day expiry)") + token_type: str = Field(..., description="Token type, always 'bearer'") - -class TokenData(BaseModel): - """Token payload model containing username.""" - - username: str | None = None diff --git a/studio/backend/routes/auth.py b/studio/backend/routes/auth.py index 4d0ac38742..21ac5ac1eb 100644 --- a/studio/backend/routes/auth.py +++ b/studio/backend/routes/auth.py @@ -7,11 +7,17 @@ import secrets from models.auth import ( AuthSetupRequest, AuthLoginRequest, + RefreshTokenRequest, AuthStatusResponse, ) from models.users import Token from auth import storage, hashing -from auth.authentication import create_access_token, reload_secret +from auth.authentication import ( + create_access_token, + create_refresh_token, + refresh_access_token, + reload_secret, +) router = APIRouter() @@ -32,6 +38,7 @@ async def setup_auth(payload: AuthSetupRequest) -> Token: """ First-time setup: create the admin user and a JWT secret. + Requires a valid setup token (printed to the server console on startup). Can only be called once. Subsequent calls will return 400. """ if storage.is_initialized(): @@ -40,6 +47,13 @@ async def setup_auth(payload: AuthSetupRequest) -> Token: detail="Auth is already initialized.", ) + # Validate the one-time setup token + if not storage.consume_setup_token(payload.setup_token): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid or expired setup token.", + ) + # Generate a strong random JWT secret for this installation jwt_secret = secrets.token_urlsafe(64) @@ -59,15 +73,20 @@ async def setup_auth(payload: AuthSetupRequest) -> Token: # Reload JWT secret from DB (so authentication.py picks it up) reload_secret() - # Issue a token for the new user + # Issue access + refresh tokens for the new user access_token = create_access_token(subject=payload.username) - return Token(access_token=access_token, token_type="bearer") + refresh_token = create_refresh_token(subject=payload.username) + return Token( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer", + ) @router.post("/login", response_model=Token) async def login(payload: AuthLoginRequest) -> Token: """ - Login with username/password and receive a JWT. + Login with username/password and receive access + refresh tokens. """ record = storage.get_user_and_secret(payload.username) if record is None: @@ -84,5 +103,31 @@ async def login(payload: AuthLoginRequest) -> Token: ) access_token = create_access_token(subject=payload.username) - return Token(access_token=access_token, token_type="bearer") + refresh_token = create_refresh_token(subject=payload.username) + return Token( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer", + ) + + +@router.post("/refresh", response_model=Token) +async def refresh(payload: RefreshTokenRequest) -> Token: + """ + Exchange a valid refresh token for a new access token. + + The refresh token itself is reusable until it expires (7 days). + """ + new_access_token = refresh_access_token(payload.refresh_token) + if new_access_token is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired refresh token", + ) + + return Token( + access_token=new_access_token, + refresh_token=payload.refresh_token, + token_type="bearer", + ) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index b80a163585..17d2011205 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -5,8 +5,7 @@ import sys from pathlib import Path from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field -from typing import Optional, List +from typing import Optional import json import logging @@ -26,6 +25,15 @@ except ImportError: from core.inference import get_inference_backend from utils.models import ModelConfig +from models.inference import ( + LoadRequest, + UnloadRequest, + GenerateRequest, + LoadResponse, + UnloadResponse, + InferenceStatusResponse, +) + router = APIRouter() logger = logging.getLogger(__name__) @@ -39,57 +47,6 @@ if not logger.handlers: logger.setLevel(logging.INFO) -# ============================================ -# Request/Response Models -# ============================================ - -class LoadRequest(BaseModel): - """Request to load a model for inference""" - model_path: str = Field(..., description="Model identifier or local path") - hf_token: Optional[str] = Field(None, description="HuggingFace token for gated models") - max_seq_length: int = Field(2048, ge=128, le=32768, description="Maximum sequence length") - load_in_4bit: bool = Field(True, description="Load model in 4-bit quantization") - is_lora: bool = Field(False, description="Whether this is a LoRA adapter") - - -class UnloadRequest(BaseModel): - """Request to unload a model""" - model_path: str = Field(..., description="Model identifier to unload") - - -class GenerateRequest(BaseModel): - """Request for text generation""" - messages: List[dict] = Field(..., description="Chat messages in OpenAI format") - system_prompt: str = Field("You are a helpful AI assistant.", description="System prompt") - temperature: float = Field(0.7, ge=0.0, le=2.0, description="Sampling temperature") - top_p: float = Field(0.9, ge=0.0, le=1.0, description="Top-p sampling") - top_k: int = Field(40, ge=1, le=100, description="Top-k sampling") - max_new_tokens: int = Field(512, ge=1, le=4096, description="Maximum tokens to generate") - repetition_penalty: float = Field(1.1, ge=1.0, le=2.0, description="Repetition penalty") - image_base64: Optional[str] = Field(None, description="Base64 encoded image for vision models") - - -class LoadResponse(BaseModel): - """Response after loading a model""" - status: str - model: str - display_name: str - is_vision: bool - is_lora: bool - - -class StatusResponse(BaseModel): - """Current inference backend status""" - active_model: Optional[str] - is_vision: bool - loading: List[str] - loaded: List[str] - - -# ============================================ -# Routes -# ============================================ - @router.post("/load", response_model=LoadResponse) async def load_model(request: LoadRequest): """ @@ -147,7 +104,7 @@ async def load_model(request: LoadRequest): ) -@router.post("/unload") +@router.post("/unload", response_model=UnloadResponse) async def unload_model(request: UnloadRequest): """ Unload a model from memory. @@ -156,7 +113,7 @@ async def unload_model(request: UnloadRequest): backend = get_inference_backend() backend.unload_model(request.model_path) logger.info(f"Unloaded model: {request.model_path}") - return {"status": "unloaded", "model": request.model_path} + return UnloadResponse(status="unloaded", model=request.model_path) except Exception as e: logger.error(f"Error unloading model: {e}", exc_info=True) @@ -239,7 +196,7 @@ async def generate_stream(request: GenerateRequest): ) -@router.get("/status", response_model=StatusResponse) +@router.get("/status", response_model=InferenceStatusResponse) async def get_status(): """ Get current inference backend status. @@ -252,7 +209,7 @@ async def get_status(): model_info = backend.models.get(backend.active_model_name, {}) is_vision = model_info.get("is_vision", False) - return StatusResponse( + return InferenceStatusResponse( active_model=backend.active_model_name, is_vision=is_vision, loading=list(getattr(backend, 'loading_models', set())), diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 713cbdc033..1bfd08f38c 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -7,8 +7,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query from typing import List, Optional import logging -from pydantic import BaseModel - # Add backend directory to path backend_path = Path(__file__).parent.parent.parent if str(backend_path) not in sys.path: @@ -46,6 +44,8 @@ from models import ( LoRAInfo, ModelListResponse, ) +from models.responses import LoRABaseModelResponse, VisionCheckResponse + router = APIRouter() logger = logging.getLogger(__name__) @@ -207,7 +207,7 @@ async def scan_loras( ) -@router.get("/loras/{lora_path:path}/base-model") +@router.get("/loras/{lora_path:path}/base-model", response_model=LoRABaseModelResponse) async def get_lora_base_model( lora_path: str, current_subject: str = Depends(get_current_subject), @@ -226,10 +226,10 @@ async def get_lora_base_model( detail=f"Could not determine base model for LoRA: {lora_path}" ) - return { - "lora_path": lora_path, - "base_model": base_model - } + return LoRABaseModelResponse( + lora_path=lora_path, + base_model=base_model, + ) except HTTPException: raise @@ -241,7 +241,7 @@ async def get_lora_base_model( ) -@router.get("/check-vision/{model_name:path}") +@router.get("/check-vision/{model_name:path}", response_model=VisionCheckResponse) async def check_vision_model( model_name: str, current_subject: str = Depends(get_current_subject), @@ -254,10 +254,10 @@ async def check_vision_model( try: is_vision = is_vision_model(model_name) - return { - "model_name": model_name, - "is_vision": is_vision - } + return VisionCheckResponse( + model_name=model_name, + is_vision=is_vision, + ) except Exception as e: logger.error(f"Error checking vision model: {e}", exc_info=True) diff --git a/studio/backend/routes/training.py b/studio/backend/routes/training.py index 6e2fb3c355..9f6c298a4d 100644 --- a/studio/backend/routes/training.py +++ b/studio/backend/routes/training.py @@ -36,6 +36,7 @@ from models import ( TrainingStatus, TrainingProgress, ) +from models.responses import TrainingStopResponse, TrainingMetricsResponse router = APIRouter() logger = logging.getLogger(__name__) @@ -242,7 +243,7 @@ async def start_training( ) -@router.post("/stop") +@router.post("/stop", response_model=TrainingStopResponse) async def stop_training( current_subject: str = Depends(get_current_subject), ): @@ -253,18 +254,18 @@ async def stop_training( backend = get_training_backend() if not backend.is_training_active(): - return { - "status": "idle", - "message": "No training job is currently running" - } + return TrainingStopResponse( + status="idle", + message="No training job is currently running" + ) # Call backend stop method backend.stop_training() - return { - "status": "stopped", - "message": "Training job stopped successfully" - } + return TrainingStopResponse( + status="stopped", + message="Training job stopped successfully" + ) except Exception as e: logger.error(f"Error stopping training: {e}", exc_info=True) @@ -353,7 +354,7 @@ async def get_training_status( ) -@router.get("/metrics") +@router.get("/metrics", response_model=TrainingMetricsResponse) async def get_training_metrics( current_subject: str = Depends(get_current_subject), ): @@ -373,15 +374,14 @@ async def get_training_metrics( current_lr = lr_history[-1] if lr_history else None current_step = step_history[-1] if step_history else None - # Keep metrics as a simple JSON payload instead of a Pydantic model - return { - "loss_history": loss_history, - "lr_history": lr_history, - "step_history": step_history, - "current_loss": current_loss, - "current_lr": current_lr, - "current_step": current_step, - } + return TrainingMetricsResponse( + loss_history=loss_history, + lr_history=lr_history, + step_history=step_history, + current_loss=current_loss, + current_lr=current_lr, + current_step=current_step, + ) except Exception as e: logger.error(f"Error getting training metrics: {e}", exc_info=True) diff --git a/studio/backend/tests/__init__.py b/studio/backend/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/studio/backend/tests/conftest.py b/studio/backend/tests/conftest.py new file mode 100644 index 0000000000..82cbeb3da5 --- /dev/null +++ b/studio/backend/tests/conftest.py @@ -0,0 +1,12 @@ +""" +Shared pytest configuration for the backend test suite. +Ensures that the backend root is on sys.path so that +`import utils.utils` (and similar flat imports) resolve correctly. +""" +import sys +from pathlib import Path + +# Add backend root to sys.path (mirrors how the app itself is launched) +_backend_root = Path(__file__).resolve().parent.parent +if str(_backend_root) not in sys.path: + sys.path.insert(0, str(_backend_root)) diff --git a/studio/backend/tests/test_utils.py b/studio/backend/tests/test_utils.py new file mode 100644 index 0000000000..afb4bab65a --- /dev/null +++ b/studio/backend/tests/test_utils.py @@ -0,0 +1,350 @@ +""" +Tests for utils/hardware and utils/utils — device detection, GPU memory, error formatting. + +These tests are designed to pass on ANY platform: + • NVIDIA GPU (CUDA backend, requires torch) + • Apple Silicon (MLX backend, requires mlx) + • CPU-only (no GPU at all) + +No ML framework is imported at the top level. +Tests that need torch/mlx internals for mocking are skipped when unavailable. + +Run with: + cd studio/backend + python -m pytest tests/test_utils.py -v +""" +import platform +from unittest.mock import patch, MagicMock + +import pytest + +# --- Conditional framework imports --- +try: + import torch + HAS_TORCH = True +except ImportError: + HAS_TORCH = False + +try: + import mlx.core as mx + HAS_MLX = True +except ImportError: + HAS_MLX = False + +needs_torch = pytest.mark.skipif(not HAS_TORCH, reason="PyTorch not installed") +needs_mlx = pytest.mark.skipif(not HAS_MLX, reason="MLX not installed") + +from utils.hardware import ( + get_device, + detect_hardware, + is_apple_silicon, + clear_gpu_cache, + get_gpu_memory_info, + log_gpu_memory, + DeviceType, +) +import utils.hardware.hardware as _hw_module +from utils.utils import format_error_message + + +# ========== Helpers ========== + +def _actual_device() -> str: + """Return the real device string for the current machine.""" + if HAS_TORCH and torch.cuda.is_available(): + return "cuda" + if is_apple_silicon() and HAS_MLX: + return "mlx" + return "cpu" + + +def _reset_and_detect(): + """Reset the cached DEVICE global and re-run detection.""" + _hw_module.DEVICE = None + return detect_hardware() + + +# ========== get_device() ========== + +class TestGetDevice: + """Tests for get_device() — should agree with the real hardware.""" + + def setup_method(self): + self._saved_device = _hw_module.DEVICE + + def teardown_method(self): + _hw_module.DEVICE = self._saved_device + + def test_returns_valid_device_type(self): + result = get_device() + assert result in (DeviceType.CUDA, DeviceType.MLX, DeviceType.CPU) + + def test_matches_actual_hardware(self): + assert get_device().value == _actual_device() + + # --- Mocked paths --- + + @needs_torch + def test_returns_cuda_when_cuda_available(self): + with patch("utils.hardware.hardware._has_torch", return_value=True), \ + patch("torch.cuda.is_available", return_value=True): + assert _reset_and_detect() == DeviceType.CUDA + + @needs_mlx + def test_returns_mlx_when_on_apple_silicon_with_mlx(self): + with patch("utils.hardware.hardware._has_torch", return_value=False), \ + patch("utils.hardware.hardware.is_apple_silicon", return_value=True), \ + patch("utils.hardware.hardware._has_mlx", return_value=True): + assert _reset_and_detect() == DeviceType.MLX + + def test_returns_cpu_when_nothing_available(self): + with patch("utils.hardware.hardware._has_torch", return_value=False), \ + patch("utils.hardware.hardware.is_apple_silicon", return_value=False), \ + patch("utils.hardware.hardware._has_mlx", return_value=False): + assert _reset_and_detect() == DeviceType.CPU + + +# ========== is_apple_silicon() ========== + +class TestIsAppleSilicon: + + def test_returns_bool(self): + assert isinstance(is_apple_silicon(), bool) + + def test_true_on_darwin_arm64(self): + with patch("utils.hardware.hardware.platform") as mock_plat: + mock_plat.system.return_value = "Darwin" + mock_plat.machine.return_value = "arm64" + assert is_apple_silicon() is True + + def test_false_on_linux_x86(self): + with patch("utils.hardware.hardware.platform") as mock_plat: + mock_plat.system.return_value = "Linux" + mock_plat.machine.return_value = "x86_64" + assert is_apple_silicon() is False + + def test_false_on_darwin_x86(self): + """Intel Mac should return False.""" + with patch("utils.hardware.hardware.platform") as mock_plat: + mock_plat.system.return_value = "Darwin" + mock_plat.machine.return_value = "x86_64" + assert is_apple_silicon() is False + + +# ========== clear_gpu_cache() ========== + +class TestClearGpuCache: + """clear_gpu_cache() must never raise, regardless of platform.""" + + def test_does_not_raise(self): + clear_gpu_cache() + + @needs_torch + def test_calls_cuda_cache_when_cuda(self): + with patch("utils.hardware.hardware.get_device", return_value=DeviceType.CUDA), \ + patch("torch.cuda.empty_cache") as mock_empty, \ + patch("torch.cuda.ipc_collect") as mock_ipc: + clear_gpu_cache() + mock_empty.assert_called_once() + mock_ipc.assert_called_once() + + @needs_mlx + def test_mlx_does_not_raise(self): + """MLX cache clear is a no-op — should just succeed.""" + with patch("utils.hardware.hardware.get_device", return_value=DeviceType.MLX): + clear_gpu_cache() + + def test_noop_on_cpu(self): + with patch("utils.hardware.hardware.get_device", return_value=DeviceType.CPU): + clear_gpu_cache() + + +# ========== get_gpu_memory_info() ========== + +class TestGetGpuMemoryInfo: + + def test_returns_dict(self): + result = get_gpu_memory_info() + assert isinstance(result, dict) + + def test_has_available_key(self): + assert "available" in get_gpu_memory_info() + + def test_has_backend_key(self): + assert "backend" in get_gpu_memory_info() + + def test_backend_matches_device(self): + result = get_gpu_memory_info() + assert result["backend"] == get_device().value + + # --- When a GPU IS available --- + + @pytest.mark.skipif( + _actual_device() == "cpu", + reason="No GPU available on this machine" + ) + def test_gpu_available_fields(self): + result = get_gpu_memory_info() + assert result["available"] is True + assert result["total_gb"] > 0 + assert result["allocated_gb"] >= 0 + assert result["free_gb"] >= 0 + assert 0 <= result["utilization_pct"] <= 100 + assert "device_name" in result + + # --- CUDA-specific mocked test --- + + @needs_torch + def test_cuda_path_returns_correct_fields(self): + mock_props = MagicMock() + mock_props.total_memory = 16 * (1024 ** 3) + mock_props.name = "NVIDIA Test GPU" + + with patch("utils.hardware.hardware.get_device", return_value=DeviceType.CUDA), \ + patch("torch.cuda.current_device", return_value=0), \ + patch("torch.cuda.get_device_properties", return_value=mock_props), \ + patch("torch.cuda.memory_allocated", return_value=4 * (1024 ** 3)), \ + patch("torch.cuda.memory_reserved", return_value=6 * (1024 ** 3)): + result = get_gpu_memory_info() + + assert result["available"] is True + assert result["backend"] == "cuda" + assert result["device_name"] == "NVIDIA Test GPU" + assert abs(result["total_gb"] - 16.0) < 0.01 + assert abs(result["allocated_gb"] - 4.0) < 0.01 + assert abs(result["free_gb"] - 12.0) < 0.01 + assert abs(result["utilization_pct"] - 25.0) < 0.1 + + # --- MLX-specific mocked test --- + + @needs_mlx + def test_mlx_path_returns_correct_fields(self): + mock_psutil_mem = MagicMock() + mock_psutil_mem.total = 32 * (1024 ** 3) # 32 GB unified + + mock_psutil = MagicMock() + mock_psutil.virtual_memory.return_value = mock_psutil_mem + + with patch("utils.hardware.hardware.get_device", return_value=DeviceType.MLX), \ + patch.dict("sys.modules", {"psutil": mock_psutil}): + result = get_gpu_memory_info() + + assert result["available"] is True + assert result["backend"] == "mlx" + assert "Apple Silicon" in result["device_name"] + assert abs(result["total_gb"] - 32.0) < 0.01 + + # --- CPU-only path --- + + def test_cpu_path_returns_unavailable(self): + with patch("utils.hardware.hardware.get_device", return_value=DeviceType.CPU): + result = get_gpu_memory_info() + assert result["available"] is False + assert result["backend"] == "cpu" + + # --- Error resilience --- + + @needs_torch + def test_cuda_error_returns_unavailable(self): + with patch("utils.hardware.hardware.get_device", return_value=DeviceType.CUDA), \ + patch("torch.cuda.current_device", side_effect=RuntimeError("CUDA init failed")): + result = get_gpu_memory_info() + assert result["available"] is False + assert "error" in result + + +# ========== log_gpu_memory() ========== + +class TestLogGpuMemory: + + def test_does_not_raise(self): + log_gpu_memory("test") + + def test_logs_gpu_info_when_available(self, caplog): + fake_info = { + "available": True, + "backend": "cuda", + "device_name": "FakeGPU", + "allocated_gb": 2.0, + "total_gb": 16.0, + "utilization_pct": 12.5, + "free_gb": 14.0, + } + import logging + with patch("utils.hardware.hardware.get_gpu_memory_info", return_value=fake_info), \ + caplog.at_level(logging.INFO, logger="utils.hardware.hardware"): + log_gpu_memory("unit-test") + + assert "unit-test" in caplog.text + assert "CUDA" in caplog.text + assert "FakeGPU" in caplog.text + + def test_logs_cpu_fallback_when_no_gpu(self, caplog): + fake_info = {"available": False, "backend": "cpu"} + import logging + with patch("utils.hardware.hardware.get_gpu_memory_info", return_value=fake_info), \ + caplog.at_level(logging.INFO, logger="utils.hardware.hardware"): + log_gpu_memory("cpu-test") + + assert "No GPU available" in caplog.text + + +# ========== format_error_message() ========== + +class TestFormatErrorMessage: + + def test_not_found(self): + err = Exception("Repository not found for unsloth/test") + msg = format_error_message(err, "unsloth/test") + assert "not found" in msg.lower() + assert "test" in msg + + def test_unauthorized(self): + err = Exception("401 Unauthorized") + msg = format_error_message(err, "some/model") + assert "authentication" in msg.lower() or "unauthorized" in msg.lower() + + def test_gated_model(self): + err = Exception("Access to model requires authentication") + msg = format_error_message(err, "meta/llama") + assert "authentication" in msg.lower() + + def test_invalid_token(self): + err = Exception("Invalid user token") + msg = format_error_message(err, "any/model") + assert "invalid" in msg.lower() + + # --- OOM on CUDA --- + + @needs_torch + def test_cuda_oom(self): + err = Exception("CUDA out of memory") + with patch("utils.hardware.get_device", return_value=DeviceType.CUDA): + msg = format_error_message(err, "big/model") + assert "GPU" in msg + assert "big/model" not in msg + assert "model" in msg + + # --- OOM on MLX --- + + @needs_mlx + def test_mlx_oom(self): + err = Exception("MLX backend out of memory") + with patch("utils.hardware.get_device", return_value=DeviceType.MLX): + msg = format_error_message(err, "unsloth/huge-model") + assert "Apple Silicon" in msg + + # --- OOM on CPU --- + + def test_cpu_oom(self): + err = Exception("not enough memory to allocate") + with patch("utils.hardware.get_device", return_value=DeviceType.CPU): + msg = format_error_message(err, "any/model") + assert "system" in msg.lower() + + # --- Generic fallback --- + + def test_generic_error(self): + err = Exception("Something completely unexpected") + msg = format_error_message(err, "any/model") + assert msg == "Something completely unexpected" diff --git a/studio/backend/utils/datasets/dataset_utils.py b/studio/backend/utils/datasets/dataset_utils.py index a1137ae1f6..c16ea3fe63 100644 --- a/studio/backend/utils/datasets/dataset_utils.py +++ b/studio/backend/utils/datasets/dataset_utils.py @@ -12,7 +12,7 @@ All internal utilities have been moved to separate modules: - chat_templates: apply_chat_template_to_dataset, get_tokenizer_chat_template, etc. - vlm_processing: generate_smart_vlm_instruction - data_collators: DeepSeekOCRDataCollator, VLMDataCollator -- model_mappings: TEMPLATE_TO_MODEL_MAPPER, RESPONSE_MARKERS +- model_mappings: TEMPLATE_TO_MODEL_MAPPER """ # Import from modular files @@ -37,7 +37,7 @@ from .chat_templates import ( ) from .vlm_processing import generate_smart_vlm_instruction from .data_collators import DeepSeekOCRDataCollator, VLMDataCollator -from .model_mappings import TEMPLATE_TO_MODEL_MAPPER, RESPONSE_MARKERS +from .model_mappings import TEMPLATE_TO_MODEL_MAPPER def check_dataset_format(dataset, is_vlm: bool = False) -> dict: diff --git a/studio/backend/utils/hardware/__init__.py b/studio/backend/utils/hardware/__init__.py new file mode 100644 index 0000000000..b992fc191d --- /dev/null +++ b/studio/backend/utils/hardware/__init__.py @@ -0,0 +1,24 @@ +""" +Hardware detection and GPU utilities +""" +from .hardware import ( + DeviceType, + DEVICE, + detect_hardware, + get_device, + is_apple_silicon, + clear_gpu_cache, + get_gpu_memory_info, + log_gpu_memory, +) + +__all__ = [ + 'DeviceType', + 'DEVICE', + 'detect_hardware', + 'get_device', + 'is_apple_silicon', + 'clear_gpu_cache', + 'get_gpu_memory_info', + 'log_gpu_memory', +] diff --git a/studio/backend/utils/hardware/hardware.py b/studio/backend/utils/hardware/hardware.py new file mode 100644 index 0000000000..25320a25e1 --- /dev/null +++ b/studio/backend/utils/hardware/hardware.py @@ -0,0 +1,208 @@ +""" +Hardware detection — run once at startup, read everywhere. + +Usage: + # At FastAPI lifespan startup: + from utils.hardware import detect_hardware + detect_hardware() + + # Anywhere else: + from utils.hardware import DEVICE, DeviceType, is_apple_silicon + if DEVICE == DeviceType.CUDA: + import torch + ... +""" +import platform +import logging +from enum import Enum +from typing import Optional, Dict, Any + +logger = logging.getLogger(__name__) + + +# ========== Device Enum ========== + +class DeviceType(str, Enum): + """Supported compute backends. Inherits from str so it serializes cleanly in JSON.""" + CUDA = "cuda" + MLX = "mlx" + CPU = "cpu" + + +# ========== Global State (set once by detect_hardware) ========== + +DEVICE: Optional[DeviceType] = None + + +# ========== Detection ========== + +def is_apple_silicon() -> bool: + """Check if running on Apple Silicon hardware (pure platform check, no ML imports).""" + return platform.system() == "Darwin" and platform.machine() == "arm64" + + +def _has_torch() -> bool: + """Check if PyTorch is importable.""" + try: + import torch + return True + except ImportError: + return False + + +def _has_mlx() -> bool: + """Check if MLX is importable.""" + try: + import mlx.core + return True + except ImportError: + return False + + +def detect_hardware() -> DeviceType: + """ + Detect the best available compute device and set the module-level DEVICE global. + + Should be called exactly once during FastAPI lifespan startup. + Safe to call multiple times (idempotent). + + Detection order: + 1. CUDA (NVIDIA GPU, requires torch) + 2. MLX (Apple Silicon via MLX framework) + 3. CPU (fallback) + """ + global DEVICE + + # --- CUDA: try PyTorch --- + if _has_torch(): + import torch + if torch.cuda.is_available(): + DEVICE = DeviceType.CUDA + device_name = torch.cuda.get_device_properties(0).name + logger.info(f"Hardware detected: CUDA — {device_name}") + return DEVICE + + # --- MLX: Apple Silicon --- + if is_apple_silicon() and _has_mlx(): + DEVICE = DeviceType.MLX + chip = platform.processor() or platform.machine() + logger.info(f"Hardware detected: MLX — Apple Silicon ({chip})") + return DEVICE + + # --- Fallback --- + DEVICE = DeviceType.CPU + logger.info("Hardware detected: CPU (no GPU backend available)") + return DEVICE + + +# ========== Convenience helpers ========== + +def get_device() -> DeviceType: + """ + Return the detected device. Auto-detects if detect_hardware() hasn't been called yet. + Prefer calling detect_hardware() explicitly at startup instead. + """ + global DEVICE + if DEVICE is None: + detect_hardware() + return DEVICE + + +def clear_gpu_cache(): + """ + Clear GPU memory cache for the current device. + Safe to call on any platform — no-ops gracefully. + """ + import gc + gc.collect() + + device = get_device() + + if device == DeviceType.CUDA: + import torch + torch.cuda.empty_cache() + torch.cuda.ipc_collect() + elif device == DeviceType.MLX: + # MLX manages memory automatically; no explicit cache clear needed. + # mlx.core has no empty_cache equivalent — gc.collect() above is enough. + pass + + +def get_gpu_memory_info() -> Dict[str, Any]: + """ + Get GPU memory information. + Supports CUDA (NVIDIA), MLX (Apple Silicon), and CPU-only environments. + """ + device = get_device() + + # ---- CUDA path ---- + if device == DeviceType.CUDA: + try: + import torch + idx = torch.cuda.current_device() + props = torch.cuda.get_device_properties(idx) + + total = props.total_memory + allocated = torch.cuda.memory_allocated(idx) + reserved = torch.cuda.memory_reserved(idx) + + return { + "available": True, + "backend": device.value, + "device": idx, + "device_name": props.name, + "total_gb": total / (1024**3), + "allocated_gb": allocated / (1024**3), + "reserved_gb": reserved / (1024**3), + "free_gb": (total - allocated) / (1024**3), + "utilization_pct": (allocated / total) * 100, + } + except Exception as e: + logger.error(f"Error getting CUDA GPU info: {e}") + return {"available": False, "backend": device.value, "error": str(e)} + + # ---- MLX path (Apple Silicon) ---- + if device == DeviceType.MLX: + try: + import mlx.core as mx + import psutil + + # MLX uses unified memory — report system memory as the pool + total = psutil.virtual_memory().total + # MLX doesn't expose per-process GPU allocation; report 0 as allocated + allocated = 0 + + return { + "available": True, + "backend": device.value, + "device": 0, + "device_name": f"Apple Silicon ({platform.processor() or platform.machine()})", + "total_gb": total / (1024**3), + "allocated_gb": allocated / (1024**3), + "reserved_gb": 0, + "free_gb": (total - allocated) / (1024**3), + "utilization_pct": (allocated / total) * 100 if total else 0, + } + except Exception as e: + logger.error(f"Error getting MLX GPU info: {e}") + return {"available": False, "backend": device.value, "error": str(e)} + + # ---- CPU-only ---- + return {"available": False, "backend": "cpu"} + + +def log_gpu_memory(context: str): + """Log GPU memory usage with context.""" + memory_info = get_gpu_memory_info() + if memory_info.get("available"): + backend = memory_info.get("backend", "unknown").upper() + device_name = memory_info.get("device_name", "") + label = f"{backend}" + (f" ({device_name})" if device_name else "") + logger.info( + f"GPU Memory [{context}] {label}: " + f"{memory_info['allocated_gb']:.2f}GB/{memory_info['total_gb']:.2f}GB " + f"({memory_info['utilization_pct']:.1f}% used, " + f"{memory_info['free_gb']:.2f}GB free)" + ) + else: + logger.info(f"GPU Memory [{context}]: No GPU available (CPU-only)") diff --git a/studio/backend/utils/utils.py b/studio/backend/utils/utils.py index 74d5c05abf..0acdbb3313 100644 --- a/studio/backend/utils/utils.py +++ b/studio/backend/utils/utils.py @@ -1,18 +1,17 @@ """ Shared backend utilities """ -import gradio as gr import os import logging from contextlib import contextmanager from pathlib import Path -from typing import Optional, Dict, Any import shutil import tempfile logger = logging.getLogger(__name__) + @contextmanager def without_hf_auth(): """ @@ -96,113 +95,12 @@ def format_error_message(error: Exception, model_name: str) -> str: if "invalid user token" in error_str: return "Invalid HF token. Please check your token and try again." - if "memory" in error_str or "cuda" in error_str or "out of memory" in error_str: - return f"Not enough GPU memory to load '{model_short}'. Try a smaller model or free GPU memory." + if "memory" in error_str or "cuda" in error_str or "mlx" in error_str or "out of memory" in error_str: + from utils.hardware import get_device + device = get_device() + device_label = {"cuda": "GPU", "mlx": "Apple Silicon GPU", "cpu": "system"}.get(device.value, "GPU") + return f"Not enough {device_label} memory to load '{model_short}'. Try a smaller model or free memory." # Generic fallback return str(error) pass - -def get_gpu_memory_info() -> Dict[str, Any]: - """Get GPU memory information.""" - import torch - - if not torch.cuda.is_available(): - return {"available": False} - - try: - device = torch.cuda.current_device() - props = torch.cuda.get_device_properties(device) - - total = props.total_memory - allocated = torch.cuda.memory_allocated(device) - reserved = torch.cuda.memory_reserved(device) - - return { - "available": True, - "device": device, - "total_gb": total / (1024**3), - "allocated_gb": allocated / (1024**3), - "reserved_gb": reserved / (1024**3), - "free_gb": (total - allocated) / (1024**3), - "utilization_pct": (allocated / total) * 100 - } - except Exception as e: - logger.error(f"Error getting GPU info: {e}") - return {"available": False, "error": str(e)} -pass - -def log_gpu_memory(context: str): - """Log GPU memory usage with context.""" - memory_info = get_gpu_memory_info() - if memory_info.get("available"): - logger.info( - f"GPU Memory [{context}]: " - f"{memory_info['allocated_gb']:.2f}GB/{memory_info['total_gb']:.2f}GB " - f"({memory_info['utilization_pct']:.1f}% used, " - f"{memory_info['free_gb']:.2f}GB free)" - ) - else: - logger.info(f"GPU Memory [{context}]: No CUDA GPU available") -pass - -""" -Model utility functions - search, discovery, etc. -""" - - -def search_hf_models(search_query: str, hf_token: Optional[str] = None): - """ - Search HuggingFace model hub. - """ - import requests - - if not search_query or not search_query.strip(): - return gr.update(choices=[]) - - # Simple debouncing: only search if query is at least 2 characters - if len(search_query.strip()) < 2: - return gr.update(choices=[]) - - try: - headers = {} - if hf_token and hf_token.strip(): - headers["Authorization"] = f"Bearer {hf_token.strip()}" - - url = "https://huggingface.co/api/models" - params = { - "search": search_query, - "pipeline_tag": "text-generation", - "library": "transformers", - "limit": 15, - "sort": "downloads", - "direction": -1 - } - - response = requests.get(url, headers=headers, params=params, timeout=10) - - if response.status_code == 200: - models = response.json() - unsloth_results = [] - other_results = [] - - for model in models: - model_id = model.get("modelId", "") - if model_id and "gguf" not in model_id.lower(): - result = (f"{model_id}", model_id) - - if model_id.startswith("unsloth/"): - unsloth_results.append(result) - else: - other_results.append(result) - - # Combine with unsloth models first - search_results = unsloth_results + other_results - return gr.update(choices=search_results) - else: - logger.warning(f"HF API returned status {response.status_code}") - return gr.update(choices=[]) - - except Exception as e: - logger.warning(f"Model search failed: {e}") - return gr.update(choices=[]) diff --git a/studio/frontend/bun.lock b/studio/frontend/bun.lock index e3a347ef63..6b58567408 100644 --- a/studio/frontend/bun.lock +++ b/studio/frontend/bun.lock @@ -16,8 +16,11 @@ "@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/react": "^1.1.4", "@huggingface/hub": "^2.8.0", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", "@streamdown/cjk": "^1.0.1", "@streamdown/code": "^1.0.1", "@streamdown/math": "^1.0.1", @@ -39,6 +42,7 @@ "lucide-react": "^0.563.0", "mammoth": "^1.11.0", "motion": "^12.29.2", + "next": "^16.1.6", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "^19.2.0", @@ -185,9 +189,9 @@ "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], - "@dagrejs/dagre": ["@dagrejs/dagre@2.0.3", "", { "dependencies": { "@dagrejs/graphlib": "2.2.4" } }, "sha512-ig9Vg52tsijTIKNgW9BAeUVBhDRvqlZ2a6FQ6i41YcPpoAy7VXXt2qye22PXRecGSjAp0OEEVUBhJ4oS9BnBzQ=="], + "@dagrejs/dagre": ["@dagrejs/dagre@2.0.4", "", { "dependencies": { "@dagrejs/graphlib": "3.0.4" } }, "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA=="], - "@dagrejs/graphlib": ["@dagrejs/graphlib@2.2.4", "", {}, "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw=="], + "@dagrejs/graphlib": ["@dagrejs/graphlib@3.0.4", "", {}, "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg=="], "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], @@ -195,6 +199,8 @@ "@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="], + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], @@ -301,6 +307,56 @@ "@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="], + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], "@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="], @@ -331,6 +387,24 @@ "@mswjs/interceptors": ["@mswjs/interceptors@0.40.0", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ=="], + "@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], + + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw=="], + + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ=="], + + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg=="], + + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="], + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], @@ -395,7 +469,7 @@ "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], - "@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], @@ -427,7 +501,7 @@ "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], - "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="], @@ -549,6 +623,8 @@ "@streamdown/mermaid": ["@streamdown/mermaid@1.0.1", "", { "dependencies": { "mermaid": "^11.12.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-LVGbxYd6t1DKMCMqm3cpbfsdD4/EKpQelanOlJaBMKv83kbrl8syZJhVBsd/jka+CawhpeR9xsGQJzSJEpjoVw=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], @@ -817,6 +893,8 @@ "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -1495,6 +1573,8 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], @@ -1725,6 +1805,8 @@ "shadcn": ["shadcn@3.7.0", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.17.2", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-zOXNAIFclguSYmmoibyXyKiYA6qjEJtXDSvloAMziSREW9Q0R/dLqBUYdb81lOejmZkDYuZApGabbMLH7G8qvQ=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1781,6 +1863,8 @@ "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -2029,14 +2113,14 @@ "@radix-ui/react-form/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-form/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], + "@radix-ui/react-form/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-hover-card/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-hover-card/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@radix-ui/react-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -2093,8 +2177,6 @@ "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@radix-ui/react-slider/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-slider/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -2121,6 +2203,8 @@ "@radix-ui/react-toolbar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-toolbar/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], + "@radix-ui/react-tooltip/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -2191,6 +2275,8 @@ "motion/framer-motion": ["framer-motion@12.29.2", "", { "dependencies": { "motion-dom": "^12.29.2", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-lSNRzBJk4wuIy0emYQ/nfZ7eWhqud2umPKw2QAQki6uKhZPKm2hRQHeQoHTG9MIvfobb+A/LbEWPJU794ZUKrg=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -2207,8 +2293,12 @@ "radix-ui/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], + "radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "radix-ui/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], + "radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], @@ -2219,6 +2309,8 @@ "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -2263,8 +2355,6 @@ "@radix-ui/react-hover-card/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-label/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-menubar/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-navigation-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -2285,8 +2375,6 @@ "@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-separator/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-slider/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-switch/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -2323,6 +2411,8 @@ "motion/framer-motion/motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="], + "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/studio/frontend/package.json b/studio/frontend/package.json index bfecb0b2b2..13bc323307 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -24,8 +24,11 @@ "@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/react": "^1.1.4", "@huggingface/hub": "^2.8.0", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", "@streamdown/cjk": "^1.0.1", "@streamdown/code": "^1.0.1", "@streamdown/math": "^1.0.1", @@ -47,6 +50,7 @@ "lucide-react": "^0.563.0", "mammoth": "^1.11.0", "motion": "^12.29.2", + "next": "^16.1.6", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "^19.2.0", diff --git a/studio/frontend/src/app/auth-guards.ts b/studio/frontend/src/app/auth-guards.ts new file mode 100644 index 0000000000..83e4e39a32 --- /dev/null +++ b/studio/frontend/src/app/auth-guards.ts @@ -0,0 +1,23 @@ +import { redirect } from "@tanstack/react-router"; +import { + getPostAuthRoute, + hasAuthToken, + hasRefreshToken, + refreshSession, +} from "@/features/auth"; + +async function hasActiveSession(): Promise { + if (hasAuthToken()) return true; + if (!hasRefreshToken()) return false; + return refreshSession(); +} + +export async function requireAuth(): Promise { + if (await hasActiveSession()) return; + throw redirect({ to: "/login" }); +} + +export async function requireGuest(): Promise { + if (!(await hasActiveSession())) return; + throw redirect({ to: getPostAuthRoute() }); +} diff --git a/studio/frontend/src/app/router.tsx b/studio/frontend/src/app/router.tsx index 957a6f672a..2376127293 100644 --- a/studio/frontend/src/app/router.tsx +++ b/studio/frontend/src/app/router.tsx @@ -5,12 +5,16 @@ import { Route as chatRoute } from "./routes/chat"; import { Route as exportRoute } from "./routes/export"; import { Route as gridTestRoute } from "./routes/grid-test"; import { Route as homeRoute } from "./routes/home"; +import { Route as loginRoute } from "./routes/login"; import { Route as onboardingRoute } from "./routes/onboarding"; +import { Route as signupRoute } from "./routes/signup"; import { Route as studioRoute } from "./routes/studio"; const routeTree = rootRoute.addChildren([ homeRoute, onboardingRoute, + loginRoute, + signupRoute, gridTestRoute, studioRoute, chatRoute, diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index 501399f907..bf47008c70 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -12,7 +12,7 @@ export const Route = createRootRoute({ component: RootLayout, }); -const HIDDEN_NAVBAR_ROUTES = ["/onboarding"]; +const HIDDEN_NAVBAR_ROUTES = ["/onboarding", "/login", "/signup"]; function RootLayout() { const pathname = useRouterState({ select: (s) => s.location.pathname }); diff --git a/studio/frontend/src/app/routes/chat.tsx b/studio/frontend/src/app/routes/chat.tsx index 87d1dcd853..d773b74a41 100644 --- a/studio/frontend/src/app/routes/chat.tsx +++ b/studio/frontend/src/app/routes/chat.tsx @@ -1,5 +1,6 @@ import { createRoute } from "@tanstack/react-router"; import { lazy } from "react"; +import { requireAuth } from "../auth-guards"; import { Route as rootRoute } from "./__root"; const ChatPage = lazy(() => @@ -9,5 +10,6 @@ const ChatPage = lazy(() => export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/chat", + beforeLoad: () => requireAuth(), component: ChatPage, }); diff --git a/studio/frontend/src/app/routes/export.tsx b/studio/frontend/src/app/routes/export.tsx index 93bb83cd98..26f047b23d 100644 --- a/studio/frontend/src/app/routes/export.tsx +++ b/studio/frontend/src/app/routes/export.tsx @@ -1,5 +1,6 @@ import { createRoute } from "@tanstack/react-router"; import { lazy } from "react"; +import { requireAuth } from "../auth-guards"; import { Route as rootRoute } from "./__root"; const ExportPage = lazy(() => @@ -11,5 +12,6 @@ const ExportPage = lazy(() => export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/export", + beforeLoad: () => requireAuth(), component: ExportPage, }); diff --git a/studio/frontend/src/app/routes/grid-test.tsx b/studio/frontend/src/app/routes/grid-test.tsx index 6afde70656..dd9113bf28 100644 --- a/studio/frontend/src/app/routes/grid-test.tsx +++ b/studio/frontend/src/app/routes/grid-test.tsx @@ -7,11 +7,13 @@ import { CardTitle, } from "@/components/ui/card"; import { createRoute } from "@tanstack/react-router"; +import { requireAuth } from "../auth-guards"; import { Route as rootRoute } from "./__root"; export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/grid-test", + beforeLoad: () => requireAuth(), component: GridTestPage, }); diff --git a/studio/frontend/src/app/routes/home.tsx b/studio/frontend/src/app/routes/home.tsx index 5903b33295..bf2f3a6b58 100644 --- a/studio/frontend/src/app/routes/home.tsx +++ b/studio/frontend/src/app/routes/home.tsx @@ -1,10 +1,12 @@ import { ComponentExample } from "@/components/component-example"; import { createRoute } from "@tanstack/react-router"; +import { requireAuth } from "../auth-guards"; import { Route as rootRoute } from "./__root"; export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/", + beforeLoad: () => requireAuth(), component: HomePage, }); diff --git a/studio/frontend/src/app/routes/login.tsx b/studio/frontend/src/app/routes/login.tsx new file mode 100644 index 0000000000..5d86484288 --- /dev/null +++ b/studio/frontend/src/app/routes/login.tsx @@ -0,0 +1,15 @@ +import { createRoute } from "@tanstack/react-router"; +import { lazy } from "react"; +import { requireGuest } from "../auth-guards"; +import { Route as rootRoute } from "./__root"; + +const LoginPage = lazy(() => + import("@/features/auth").then((m) => ({ default: m.LoginPage })), +); + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: "/login", + beforeLoad: () => requireGuest(), + component: LoginPage, +}); diff --git a/studio/frontend/src/app/routes/onboarding.tsx b/studio/frontend/src/app/routes/onboarding.tsx index e2f9b9db86..664f75249e 100644 --- a/studio/frontend/src/app/routes/onboarding.tsx +++ b/studio/frontend/src/app/routes/onboarding.tsx @@ -1,5 +1,6 @@ import { createRoute } from "@tanstack/react-router"; import { lazy } from "react"; +import { requireAuth } from "../auth-guards"; import { Route as rootRoute } from "./__root"; const WizardLayout = lazy(() => @@ -11,5 +12,6 @@ const WizardLayout = lazy(() => export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/onboarding", + beforeLoad: () => requireAuth(), component: WizardLayout, }); diff --git a/studio/frontend/src/app/routes/signup.tsx b/studio/frontend/src/app/routes/signup.tsx new file mode 100644 index 0000000000..241a8e852a --- /dev/null +++ b/studio/frontend/src/app/routes/signup.tsx @@ -0,0 +1,17 @@ +import { createRoute } from "@tanstack/react-router"; +import { lazy } from "react"; +import { requireGuest } from "../auth-guards"; +import { Route as rootRoute } from "./__root"; + +const SignupPage = lazy(() => + import("@/features/auth").then((m) => ({ + default: m.SignupPage, + })), +); + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: "/signup", + beforeLoad: () => requireGuest(), + component: SignupPage, +}); diff --git a/studio/frontend/src/app/routes/studio.tsx b/studio/frontend/src/app/routes/studio.tsx index 333bcefdb8..be3a912644 100644 --- a/studio/frontend/src/app/routes/studio.tsx +++ b/studio/frontend/src/app/routes/studio.tsx @@ -1,5 +1,6 @@ import { createRoute } from "@tanstack/react-router"; import { lazy } from "react"; +import { requireAuth } from "../auth-guards"; import { Route as rootRoute } from "./__root"; const StudioPage = lazy(() => @@ -11,5 +12,6 @@ const StudioPage = lazy(() => export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/studio", + beforeLoad: () => requireAuth(), component: StudioPage, }); diff --git a/studio/frontend/src/features/auth/api.ts b/studio/frontend/src/features/auth/api.ts new file mode 100644 index 0000000000..6705caa5e4 --- /dev/null +++ b/studio/frontend/src/features/auth/api.ts @@ -0,0 +1,66 @@ +import { + clearAuthTokens, + getAuthToken, + getRefreshToken, + storeAuthTokens, +} from "./session"; + +type RefreshResponse = { + access_token: string; + refresh_token: string; +}; + +export async function refreshSession(): Promise { + const refreshToken = getRefreshToken(); + if (!refreshToken) return false; + + try { + const response = await fetch("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + if (!response.ok) { + clearAuthTokens(); + return false; + } + + const payload = (await response.json()) as RefreshResponse; + storeAuthTokens(payload.access_token, payload.refresh_token); + return true; + } catch { + return false; + } +} + +export async function authFetch( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { + const headers = new Headers(init?.headers); + const accessToken = getAuthToken(); + if (accessToken) { + headers.set("Authorization", `Bearer ${accessToken}`); + } + + const response = await fetch(input, { ...init, headers }); + if (response.status !== 401) return response; + + const refreshed = await refreshSession(); + if (!refreshed) return response; + + const retryHeaders = new Headers(init?.headers); + const newToken = getAuthToken(); + if (newToken) { + retryHeaders.set("Authorization", `Bearer ${newToken}`); + } else { + clearAuthTokens(); + } + + return fetch(input, { ...init, headers: retryHeaders }); +} + +export function logout(): void { + clearAuthTokens(); +} diff --git a/studio/frontend/src/features/auth/components/auth-form.tsx b/studio/frontend/src/features/auth/components/auth-form.tsx new file mode 100644 index 0000000000..e8d531c0b4 --- /dev/null +++ b/studio/frontend/src/features/auth/components/auth-form.tsx @@ -0,0 +1,240 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { Eye, EyeOff } from "lucide-react"; +import { useEffect, useState } from "react"; +import type { FormEvent } from "react"; +import type { ReactElement } from "react"; +import { refreshSession } from "../api"; +import { + getPostAuthRoute, + hasAuthToken, + hasRefreshToken, + resetOnboardingDone, + storeAuthTokens, +} from "../session"; + +type AuthMode = "login" | "signup"; + +type AuthStatusResponse = { + initialized: boolean; +}; + +type TokenResponse = { + access_token: string; + refresh_token: string; +}; + +type AuthFormProps = { + mode: AuthMode; +}; + +export function AuthForm({ mode }: AuthFormProps): ReactElement | null { + const navigate = useNavigate(); + const [showPassword, setShowPassword] = useState(false); + const [username, setUsername] = useState("admin"); + const [setupToken, setSetupToken] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [statusLoading, setStatusLoading] = useState(true); + const [initialized, setInitialized] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let canceled = false; + + async function initializeAuthForm(): Promise { + if (hasRefreshToken()) { + const refreshed = await refreshSession(); + if (refreshed) { + if (!canceled) setStatusLoading(false); + navigate({ to: getPostAuthRoute() }); + return; + } + } + if (hasAuthToken()) { + if (!canceled) setStatusLoading(false); + navigate({ to: getPostAuthRoute() }); + return; + } + + try { + const response = await fetch("/api/auth/status"); + if (!response.ok) throw new Error("Failed to load auth status."); + const result = (await response.json()) as AuthStatusResponse; + if (!canceled) setInitialized(result.initialized); + } catch (err: unknown) { + if (!canceled) { + setError(err instanceof Error ? err.message : "Failed to load."); + } + } finally { + if (!canceled) setStatusLoading(false); + } + } + + void initializeAuthForm(); + + return () => { + canceled = true; + }; + }, [navigate]); + + const blockedByState = + (mode === "login" && initialized === false) || + (mode === "signup" && initialized === true); + + const isLoginMode = mode === "login"; + let helperText: string | null = null; + if (isLoginMode && initialized === false) { + helperText = "Auth not initialized. go setup first."; + } else if (!isLoginMode && initialized === true) { + helperText = "Auth already initialized. use login."; + } + const title = isLoginMode ? "Welcome back" : "Welcome to Unsloth Studio!"; + const subtitle = isLoginMode + ? "Sign in to continue" + : "Create first admin account"; + const submitLabel = isLoginMode ? "Login" : "Create account"; + const switchText = isLoginMode ? "Need setup first? " : "Already initialized? "; + const switchLinkTo = isLoginMode ? "/signup" : "/login"; + const switchLinkText = isLoginMode ? "Setup account" : "Login"; + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + + if (!isLoginMode && !setupToken.trim()) { + setError("Setup token required."); + return; + } + + setLoading(true); + try { + const endpoint = isLoginMode ? "/api/auth/login" : "/api/auth/setup"; + const payload: { username: string; password: string; setup_token?: string } = { + username: username.trim(), + password, + }; + if (!isLoginMode) { + payload.setup_token = setupToken.trim(); + } + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + let message = "Auth failed."; + const errorPayload = (await response + .json() + .catch(() => null)) as { detail?: string } | null; + if (errorPayload?.detail) message = errorPayload.detail; + throw new Error(message); + } + const token = (await response.json()) as TokenResponse; + + if (!isLoginMode) resetOnboardingDone(); + storeAuthTokens(token.access_token, token.refresh_token); + navigate({ to: getPostAuthRoute() }); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Auth failed."); + } finally { + setLoading(false); + } + } + + if (statusLoading && initialized === null && error === null) return null; + + return ( +
+
+ Unsloth waving mascot +

{title}

+

{subtitle}

+
+
+
+ + setUsername(event.target.value)} + required + /> +
+ +
+ +
+ setPassword(event.target.value)} + minLength={8} + required + /> + +
+
+ + {!isLoginMode && ( +
+ + setSetupToken(event.target.value)} + required + /> +
+ )} + + {helperText && ( +

{helperText}

+ )} + {error &&

{error}

} + + +
+ +

+ {switchText} + + {switchLinkText} + +

+
+ ); +} diff --git a/studio/frontend/src/features/auth/index.ts b/studio/frontend/src/features/auth/index.ts new file mode 100644 index 0000000000..a6651b0c00 --- /dev/null +++ b/studio/frontend/src/features/auth/index.ts @@ -0,0 +1,10 @@ +export { LoginPage } from "./login-page"; +export { SignupPage } from "./signup-page"; +export { refreshSession } from "./api"; +export { + getPostAuthRoute, + hasAuthToken, + hasRefreshToken, + isOnboardingDone, + markOnboardingDone, +} from "./session"; diff --git a/studio/frontend/src/features/auth/login-page.tsx b/studio/frontend/src/features/auth/login-page.tsx new file mode 100644 index 0000000000..a350002f80 --- /dev/null +++ b/studio/frontend/src/features/auth/login-page.tsx @@ -0,0 +1,20 @@ +import { LightRays } from "@/components/ui/light-rays"; +import { AuthForm } from "./components/auth-form"; + +export function LoginPage() { + return ( +
+ +
+ +
+
+ ); +} diff --git a/studio/frontend/src/features/auth/session.ts b/studio/frontend/src/features/auth/session.ts new file mode 100644 index 0000000000..69ff2ac2a2 --- /dev/null +++ b/studio/frontend/src/features/auth/session.ts @@ -0,0 +1,63 @@ +export const AUTH_TOKEN_KEY = "unsloth_auth_token"; +export const AUTH_REFRESH_TOKEN_KEY = "unsloth_auth_refresh_token"; +export const ONBOARDING_DONE_KEY = "unsloth_onboarding_done"; + +type PostAuthRoute = "/onboarding" | "/studio"; + +function canUseStorage(): boolean { + return typeof window !== "undefined"; +} + +export function hasAuthToken(): boolean { + if (!canUseStorage()) return false; + return Boolean(localStorage.getItem(AUTH_TOKEN_KEY)); +} + +export function hasRefreshToken(): boolean { + if (!canUseStorage()) return false; + return Boolean(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)); +} + +export function getAuthToken(): string | null { + if (!canUseStorage()) return null; + return localStorage.getItem(AUTH_TOKEN_KEY); +} + +export function getRefreshToken(): string | null { + if (!canUseStorage()) return null; + return localStorage.getItem(AUTH_REFRESH_TOKEN_KEY); +} + +export function storeAuthTokens( + accessToken: string, + refreshToken: string, +): void { + if (!canUseStorage()) return; + localStorage.setItem(AUTH_TOKEN_KEY, accessToken); + localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, refreshToken); +} + +export function clearAuthTokens(): void { + if (!canUseStorage()) return; + localStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_REFRESH_TOKEN_KEY); +} + +export function isOnboardingDone(): boolean { + if (!canUseStorage()) return false; + return localStorage.getItem(ONBOARDING_DONE_KEY) === "true"; +} + +export function markOnboardingDone(): void { + if (!canUseStorage()) return; + localStorage.setItem(ONBOARDING_DONE_KEY, "true"); +} + +export function resetOnboardingDone(): void { + if (!canUseStorage()) return; + localStorage.removeItem(ONBOARDING_DONE_KEY); +} + +export function getPostAuthRoute(): PostAuthRoute { + return isOnboardingDone() ? "/studio" : "/onboarding"; +} diff --git a/studio/frontend/src/features/auth/signup-page.tsx b/studio/frontend/src/features/auth/signup-page.tsx new file mode 100644 index 0000000000..5b71fe52ee --- /dev/null +++ b/studio/frontend/src/features/auth/signup-page.tsx @@ -0,0 +1,20 @@ +import { LightRays } from "@/components/ui/light-rays"; +import { AuthForm } from "./components/auth-form"; + +export function SignupPage() { + return ( +
+ +
+ +
+
+ ); +} diff --git a/studio/frontend/src/features/onboarding/components/wizard-footer.tsx b/studio/frontend/src/features/onboarding/components/wizard-footer.tsx index 7f69ed2f82..c167303ec6 100644 --- a/studio/frontend/src/features/onboarding/components/wizard-footer.tsx +++ b/studio/frontend/src/features/onboarding/components/wizard-footer.tsx @@ -1,5 +1,6 @@ import { Button } from "@/components/ui/button"; import { STEPS } from "@/config/training"; +import { markOnboardingDone } from "@/features/auth"; import { useWizardStore } from "@/stores/training"; import { ArrowLeft02Icon, ArrowRight02Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; @@ -33,7 +34,10 @@ export function WizardFooter() { {isLast ? (