Merge remote-tracking branch 'origin/nightly' into feature/canvas-lab

# Conflicts:
#	.gitignore
#	studio/frontend/.gitignore
#	studio/frontend/bun.lock
#	studio/frontend/src/app/router.tsx
This commit is contained in:
shine1i 2026-02-11 22:36:34 +01:00
commit e39d03c21e
47 changed files with 1713 additions and 296 deletions

1
.gitignore vendored
View file

@ -35,6 +35,7 @@ Thumbs.db
# Other
resources/
tmp/
auth.db
# Local working docs
**/CLAUDE.md

View file

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

Binary file not shown.

View file

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

View file

@ -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()

View file

@ -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',
]

View file

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

View file

@ -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()

View file

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

View file

@ -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),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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",
)

View file

@ -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())),

View file

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

View file

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

View file

View file

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

View file

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

View file

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

View file

@ -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',
]

View file

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

View file

@ -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=[])

View file

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

View file

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

View file

@ -0,0 +1,23 @@
import { redirect } from "@tanstack/react-router";
import {
getPostAuthRoute,
hasAuthToken,
hasRefreshToken,
refreshSession,
} from "@/features/auth";
async function hasActiveSession(): Promise<boolean> {
if (hasAuthToken()) return true;
if (!hasRefreshToken()) return false;
return refreshSession();
}
export async function requireAuth(): Promise<void> {
if (await hasActiveSession()) return;
throw redirect({ to: "/login" });
}
export async function requireGuest(): Promise<void> {
if (!(await hasActiveSession())) return;
throw redirect({ to: getPostAuthRoute() });
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,66 @@
import {
clearAuthTokens,
getAuthToken,
getRefreshToken,
storeAuthTokens,
} from "./session";
type RefreshResponse = {
access_token: string;
refresh_token: string;
};
export async function refreshSession(): Promise<boolean> {
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<Response> {
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();
}

View file

@ -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<boolean | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let canceled = false;
async function initializeAuthForm(): Promise<void> {
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<HTMLFormElement>) {
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 (
<div className="w-full max-w-sm space-y-6">
<div className="space-y-1.5 text-center">
<img
src="/Sloth emojis/large sloth wave.png"
alt="Unsloth waving mascot"
className="mx-auto mb-2 h-20 w-20 object-contain"
/>
<h2 className="text-2xl font-semibold text-foreground">{title}</h2>
<p className="text-muted-foreground">{subtitle}</p>
</div>
<form className="space-y-5" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
autoComplete="username"
placeholder="admin"
value={username}
onChange={(event) => setUsername(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
className="pr-10"
autoComplete={
mode === "login" ? "current-password" : "new-password"
}
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
required
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:bg-transparent"
onClick={() => setShowPassword((prev) => !prev)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
{!isLoginMode && (
<div className="space-y-2">
<Label htmlFor="setup-token">Setup token</Label>
<Input
id="setup-token"
autoComplete="off"
placeholder="Paste token from backend console"
value={setupToken}
onChange={(event) => setSetupToken(event.target.value)}
required
/>
</div>
)}
{helperText && (
<p className="text-center text-sm text-amber-600">{helperText}</p>
)}
{error && <p className="text-center text-sm text-destructive">{error}</p>}
<Button
type="submit"
className="w-full"
disabled={loading || statusLoading || blockedByState}
>
{loading ? "Please wait..." : submitLabel}
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
{switchText}
<Link to={switchLinkTo} className="text-primary hover:underline">
{switchLinkText}
</Link>
</p>
</div>
);
}

View file

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

View file

@ -0,0 +1,20 @@
import { LightRays } from "@/components/ui/light-rays";
import { AuthForm } from "./components/auth-form";
export function LoginPage() {
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-6 py-10 md:px-10">
<LightRays
count={6}
color="rgba(34, 197, 94, 0.25)"
blur={34}
speed={15}
length="70vh"
style={{ opacity: 0.4 }}
/>
<div className="relative z-10 w-full max-w-sm">
<AuthForm mode="login" />
</div>
</div>
);
}

View file

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

View file

@ -0,0 +1,20 @@
import { LightRays } from "@/components/ui/light-rays";
import { AuthForm } from "./components/auth-form";
export function SignupPage() {
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-6 py-10 md:px-10">
<LightRays
count={6}
color="rgba(34, 197, 94, 0.25)"
blur={34}
speed={15}
length="70vh"
style={{ opacity: 0.4 }}
/>
<div className="relative z-10 w-full max-w-sm">
<AuthForm mode="signup" />
</div>
</div>
);
}

View file

@ -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() {
</Button>
{isLast ? (
<Button
onClick={() => navigate({ to: "/studio" })}
onClick={() => {
markOnboardingDone();
navigate({ to: "/studio" });
}}
disabled={!canProceed}
className="px-4 !pr-4"
>

View file

@ -5,6 +5,7 @@ import { Suspense, lazy, useEffect, useRef, useState } from "react";
import type { ConfettiRef } from "@/components/ui/confetti";
import { STEPS } from "@/config/training";
import { isOnboardingDone, markOnboardingDone } from "@/features/auth";
import { useWizardStore } from "@/stores/training";
import { SplashScreen } from "./splash-screen";
import { WizardContent } from "./wizard-content";
@ -23,6 +24,12 @@ export function WizardLayout() {
const hasFiredRef = useRef(false);
const isFinalStep = currentStep === STEPS.length;
useEffect(() => {
if (isOnboardingDone()) {
navigate({ to: "/studio" });
}
}, [navigate]);
useEffect(() => {
if (isFinalStep && !hasFiredRef.current) {
hasFiredRef.current = true;
@ -51,7 +58,10 @@ export function WizardLayout() {
{showSplash && (
<SplashScreen
onStartOnboarding={() => setShowSplash(false)}
onGoToStudio={() => navigate({ to: "/studio" })}
onGoToStudio={() => {
markOnboardingDone();
navigate({ to: "/studio" });
}}
/>
)}
<Suspense fallback={null}>