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:
commit
e39d03c21e
47 changed files with 1713 additions and 296 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -35,6 +35,7 @@ Thumbs.db
|
|||
# Other
|
||||
resources/
|
||||
tmp/
|
||||
auth.db
|
||||
|
||||
# Local working docs
|
||||
**/CLAUDE.md
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
54
studio/backend/models/inference.py
Normal file
54
studio/backend/models/inference.py
Normal 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")
|
||||
38
studio/backend/models/responses.py
Normal file
38
studio/backend/models/responses.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
0
studio/backend/tests/__init__.py
Normal file
0
studio/backend/tests/__init__.py
Normal file
12
studio/backend/tests/conftest.py
Normal file
12
studio/backend/tests/conftest.py
Normal 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))
|
||||
350
studio/backend/tests/test_utils.py
Normal file
350
studio/backend/tests/test_utils.py
Normal 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"
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
24
studio/backend/utils/hardware/__init__.py
Normal file
24
studio/backend/utils/hardware/__init__.py
Normal 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',
|
||||
]
|
||||
208
studio/backend/utils/hardware/hardware.py
Normal file
208
studio/backend/utils/hardware/hardware.py
Normal 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)")
|
||||
|
|
@ -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=[])
|
||||
|
|
|
|||
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
23
studio/frontend/src/app/auth-guards.ts
Normal file
23
studio/frontend/src/app/auth-guards.ts
Normal 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() });
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
15
studio/frontend/src/app/routes/login.tsx
Normal file
15
studio/frontend/src/app/routes/login.tsx
Normal 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,
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
17
studio/frontend/src/app/routes/signup.tsx
Normal file
17
studio/frontend/src/app/routes/signup.tsx
Normal 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,
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
66
studio/frontend/src/features/auth/api.ts
Normal file
66
studio/frontend/src/features/auth/api.ts
Normal 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();
|
||||
}
|
||||
240
studio/frontend/src/features/auth/components/auth-form.tsx
Normal file
240
studio/frontend/src/features/auth/components/auth-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
studio/frontend/src/features/auth/index.ts
Normal file
10
studio/frontend/src/features/auth/index.ts
Normal 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";
|
||||
20
studio/frontend/src/features/auth/login-page.tsx
Normal file
20
studio/frontend/src/features/auth/login-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
studio/frontend/src/features/auth/session.ts
Normal file
63
studio/frontend/src/features/auth/session.ts
Normal 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";
|
||||
}
|
||||
20
studio/frontend/src/features/auth/signup-page.tsx
Normal file
20
studio/frontend/src/features/auth/signup-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue