Merge branch 'main' into pip

This commit is contained in:
Daniel Han 2026-04-16 12:06:45 -07:00
commit e9a2b5c010
84 changed files with 5974 additions and 2400 deletions

View file

@ -100,22 +100,115 @@ function Install-UnslothStudio {
Write-Host ""
# ── Helper: refresh PATH from registry (deduplicating entries) ──
# Merge order: venv Scripts (if active) > Machine > User > current $env:Path.
# Dedup compares both raw and expanded forms (%VAR% vs literal).
function Refresh-SessionPath {
$machine = [System.Environment]::GetEnvironmentVariable("Path", "Machine")
$user = [System.Environment]::GetEnvironmentVariable("Path", "User")
$merged = "$machine;$user;$env:Path"
$venvScripts = if ($env:VIRTUAL_ENV) { Join-Path $env:VIRTUAL_ENV "Scripts" } else { $null }
$sources = @()
if ($venvScripts) { $sources += $venvScripts }
$sources += @($machine, $user, $env:Path)
$merged = ($sources | Where-Object { $_ }) -join ";"
$seen = @{}
$unique = @()
$unique = New-Object System.Collections.Generic.List[string]
foreach ($p in $merged -split ";") {
$key = $p.TrimEnd("\").ToLowerInvariant()
if ($key -and -not $seen.ContainsKey($key)) {
$seen[$key] = $true
$unique += $p
$rawKey = $p.Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
$expKey = [Environment]::ExpandEnvironmentVariables($p).Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
if ($rawKey -and -not $seen.ContainsKey($rawKey) -and -not $seen.ContainsKey($expKey)) {
$seen[$rawKey] = $true
if ($expKey -and $expKey -ne $rawKey) { $seen[$expKey] = $true }
$unique.Add($p)
}
}
$env:Path = $unique -join ";"
}
# ── Helper: safely add a directory to the persistent User PATH ──
# Direct registry access preserves REG_EXPAND_SZ (avoids dotnet/runtime#1442).
# Append (default) keeps existing tools first; Prepend for must-win entries.
function Add-ToUserPath {
param(
[Parameter(Mandatory = $true)][string]$Directory,
[ValidateSet('Append','Prepend')]
[string]$Position = 'Append'
)
try {
$regKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
try {
$rawPath = $regKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
[string[]]$entries = if ($rawPath) { $rawPath -split ';' } else { @() } # string[] prevents scalar collapse
$normalDir = $Directory.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
$expNormalDir = [Environment]::ExpandEnvironmentVariables($Directory).Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
$kept = New-Object System.Collections.Generic.List[string]
$matchIndices = New-Object System.Collections.Generic.List[int]
for ($i = 0; $i -lt $entries.Count; $i++) {
$stripped = $entries[$i].Trim().Trim('"')
$rawNorm = $stripped.TrimEnd('\').ToLowerInvariant()
$expNorm = [Environment]::ExpandEnvironmentVariables($stripped).TrimEnd('\').ToLowerInvariant()
$isMatch = ($rawNorm -and ($rawNorm -eq $normalDir -or $rawNorm -eq $expNormalDir)) -or
($expNorm -and ($expNorm -eq $normalDir -or $expNorm -eq $expNormalDir))
if ($isMatch) {
$matchIndices.Add($i)
continue
}
$kept.Add($entries[$i])
}
$alreadyPresent = $matchIndices.Count -gt 0
if ($alreadyPresent -and $Position -eq 'Append') { # Append: idempotent no-op
return $false
}
if ($alreadyPresent -and $Position -eq 'Prepend' -and # Prepend: no-op if already at front
$matchIndices.Count -eq 1 -and $matchIndices[0] -eq 0) {
return $false
}
# One-time backup under HKCU\Software\Unsloth\PathBackup
if ($rawPath) {
try {
$backupKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Unsloth')
try {
$existingBackup = $backupKey.GetValue('PathBackup', $null)
if (-not $existingBackup) {
$backupKey.SetValue('PathBackup', $rawPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
}
} finally {
$backupKey.Close()
}
} catch { }
}
if (-not $rawPath) {
Write-Host "[WARN] User PATH is empty - initializing with $Directory" -ForegroundColor Yellow
}
$newPath = if ($rawPath) {
if ($Position -eq 'Prepend') {
(@($Directory) + $kept) -join ';'
} else {
($kept + @($Directory)) -join ';'
}
} else {
$Directory
}
if ($newPath -ceq $rawPath) { # no actual change
return $false
}
$regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
# Broadcast WM_SETTINGCHANGE via dummy env-var roundtrip.
# [NullString]::Value avoids PS 7.5+/.NET 9 $null-to-"" coercion.
try {
$d = "UnslothPathRefresh_$([guid]::NewGuid().ToString('N').Substring(0,8))"
[Environment]::SetEnvironmentVariable($d, '1', 'User')
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
} catch { }
return $true
} finally {
$regKey.Close()
}
} catch {
Write-Host "[WARN] Could not update User PATH: $($_.Exception.Message)" -ForegroundColor Yellow
return $false
}
}
function step {
param(
[Parameter(Mandatory = $true)][string]$Label,
@ -819,7 +912,7 @@ shell.Run cmd, 0, False
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.4.4" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.4.5" unsloth-zoo }
if ($baseInstallExit -eq 0) {
$NoTorchReq = Find-NoTorchRuntimeFile
if ($NoTorchReq) {
@ -827,7 +920,7 @@ shell.Run cmd, 0, False
}
}
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.4.4" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.4.5" unsloth-zoo }
}
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
@ -857,7 +950,7 @@ shell.Run cmd, 0, False
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.4.4" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.4.5" unsloth-zoo }
if ($baseInstallExit -eq 0) {
$NoTorchReq = Find-NoTorchRuntimeFile
if ($NoTorchReq) {
@ -865,7 +958,7 @@ shell.Run cmd, 0, False
}
}
} elseif ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.4.4" unsloth-zoo }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.4.5" unsloth-zoo }
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "$PackageName" }
}
@ -886,7 +979,7 @@ shell.Run cmd, 0, False
# Fallback: GPU detection failed to produce a URL -- let uv resolve torch
substep "installing unsloth (this may take a few minutes)..."
if ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.4.4" --torch-backend=auto }
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.4.5" --torch-backend=auto }
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
return
@ -945,18 +1038,76 @@ shell.Run cmd, 0, False
New-StudioShortcuts -UnslothExePath $UnslothExe
# ── Add venv Scripts dir to User PATH so `unsloth studio` works from any terminal ──
$ScriptsDir = Join-Path $VenvDir "Scripts"
$UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User")
if (-not $UserPath -or $UserPath -notlike "*$ScriptsDir*") {
if ($UserPath) {
[System.Environment]::SetEnvironmentVariable("Path", "$ScriptsDir;$UserPath", "User")
} else {
[System.Environment]::SetEnvironmentVariable("Path", "$ScriptsDir", "User")
# ── Expose `unsloth` via a shim dir containing only unsloth.exe ──
# We do NOT add the venv Scripts dir to PATH (it also holds python.exe
# and pip.exe, which would hijack the user's system interpreter).
# Hardlink preferred; falls back to copy if cross-volume or non-NTFS.
#
# Remove the legacy venv Scripts PATH entry that older installers wrote.
$LegacyScriptsDir = Join-Path $VenvDir "Scripts"
try {
$legacyKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
try {
$rawPath = $legacyKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
if ($rawPath) {
[string[]]$pathEntries = $rawPath -split ';'
$normalLegacy = $LegacyScriptsDir.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
$expNormalLegacy = [Environment]::ExpandEnvironmentVariables($LegacyScriptsDir).Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
$filtered = @($pathEntries | Where-Object {
$stripped = $_.Trim().Trim('"')
$rawNorm = $stripped.TrimEnd('\').ToLowerInvariant()
$expNorm = [Environment]::ExpandEnvironmentVariables($stripped).TrimEnd('\').ToLowerInvariant()
($rawNorm -ne $normalLegacy -and $rawNorm -ne $expNormalLegacy) -and
($expNorm -ne $normalLegacy -and $expNorm -ne $expNormalLegacy)
})
$cleanedPath = $filtered -join ';'
if ($cleanedPath -ne $rawPath) {
$legacyKey.SetValue('Path', $cleanedPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
try {
$d = "UnslothPathRefresh_$([guid]::NewGuid().ToString('N').Substring(0,8))"
[Environment]::SetEnvironmentVariable($d, '1', 'User')
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
} catch { }
}
}
} finally {
$legacyKey.Close()
}
} catch { }
$ShimDir = Join-Path $StudioHome "bin"
New-Item -ItemType Directory -Force -Path $ShimDir | Out-Null
$ShimExe = Join-Path $ShimDir "unsloth.exe"
# try/catch: if unsloth.exe is locked (Studio running), keep the old shim.
$shimUpdated = $false
try {
if (Test-Path $ShimExe) { Remove-Item $ShimExe -Force -ErrorAction Stop }
try {
New-Item -ItemType HardLink -Path $ShimExe -Target $UnslothExe -ErrorAction Stop | Out-Null
} catch {
Copy-Item -Path $UnslothExe -Destination $ShimExe -Force -ErrorAction Stop # fallback: copy
}
$shimUpdated = $true
} catch {
if (Test-Path $ShimExe) {
Write-Host "[WARN] Could not refresh unsloth launcher at $ShimExe." -ForegroundColor Yellow
Write-Host " This usually means a running 'unsloth studio' process still holds the file open." -ForegroundColor Yellow
Write-Host " Close Studio and re-run the installer to pick up the latest launcher." -ForegroundColor Yellow
Write-Host " Continuing with the existing launcher." -ForegroundColor Yellow
} else {
Write-Host "[WARN] Could not create unsloth launcher at $ShimExe" -ForegroundColor Yellow
Write-Host " $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host " Launch unsloth studio directly via '$UnslothExe' until the next successful install." -ForegroundColor Yellow
}
Refresh-SessionPath
step "path" "added unsloth to PATH"
}
# Only add to PATH when the launcher actually exists on disk.
$pathAdded = $false
if (Test-Path $ShimExe) {
$pathAdded = Add-ToUserPath -Directory $ShimDir -Position 'Prepend'
}
if ($shimUpdated -and $pathAdded) {
step "path" "added unsloth launcher to PATH"
}
Refresh-SessionPath # sync current session with registry
# Launch studio automatically in interactive terminals;
# in non-interactive environments (CI, Docker) just print instructions.

View file

@ -1316,7 +1316,7 @@ if [ "$_MIGRATED" = true ]; then
# to prevent transitive torch resolution.
run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.4.4" unsloth-zoo
"unsloth>=2026.4.5" unsloth-zoo
_NO_TORCH_RT="$(_find_no_torch_runtime)"
if [ -n "$_NO_TORCH_RT" ]; then
run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
@ -1324,7 +1324,7 @@ if [ "$_MIGRATED" = true ]; then
else
run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
"unsloth>=2026.4.4" unsloth-zoo
"unsloth>=2026.4.5" unsloth-zoo
fi
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
substep "overlaying local repo (editable)..."
@ -1487,7 +1487,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--upgrade-package unsloth --upgrade-package unsloth-zoo \
"unsloth>=2026.4.4" unsloth-zoo
"unsloth>=2026.4.5" unsloth-zoo
_NO_TORCH_RT="$(_find_no_torch_runtime)"
if [ -n "$_NO_TORCH_RT" ]; then
run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
@ -1498,7 +1498,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
fi
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \
--upgrade-package unsloth "unsloth>=2026.4.4" unsloth-zoo
--upgrade-package unsloth "unsloth>=2026.4.5" unsloth-zoo
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
else
@ -1525,7 +1525,7 @@ else
# Fallback: GPU detection failed to produce a URL -- let uv resolve torch
substep "installing unsloth (this may take a few minutes)..."
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.4.4" --torch-backend=auto
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.4.5" --torch-backend=auto
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
else

View file

@ -1,6 +1,14 @@
{
"_comment": "Per-model-family inference parameter defaults. Sources: (1) Ollama params blobs, (2) Existing Unsloth Studio YAML configs. Patterns ordered longest-match-first.",
"families": {
"qwen3.6": {
"temperature": 0.7,
"top_p": 0.8,
"top_k": 20,
"min_p": 0.0,
"repetition_penalty": 1.0,
"presence_penalty": 1.5
},
"qwen3.5": {
"temperature": 0.7,
"top_p": 0.8,
@ -369,7 +377,7 @@
}
},
"patterns": [
"qwen3.5",
"qwen3.6", "qwen3.5",
"qwen3-coder", "qwen3-next", "qwen3-vl", "qwen3",
"qwen2.5-coder", "qwen2.5-vl", "qwen2.5-omni", "qwen2.5-math", "qwen2.5",
"qwen2-vl", "qwen2",

View file

@ -10,6 +10,7 @@ DEFAULT_MODELS_GGUF = [
"unsloth/gemma-4-E4B-it-GGUF",
"unsloth/gemma-4-31B-it-GGUF",
"unsloth/gemma-4-26B-A4B-it-GGUF",
"unsloth/Qwen3.6-35B-A3B-GGUF",
"unsloth/Qwen3.5-4B-GGUF",
"unsloth/Qwen3.5-9B-GGUF",
"unsloth/Qwen3.5-35B-A3B-GGUF",
@ -27,6 +28,7 @@ DEFAULT_MODELS_STANDARD = [
"unsloth/gemma-4-E4B-it-GGUF",
"unsloth/gemma-4-31B-it-GGUF",
"unsloth/gemma-4-26B-A4B-it-GGUF",
"unsloth/Qwen3.6-35B-A3B-GGUF",
"unsloth/Qwen3.5-4B-GGUF",
"unsloth/Qwen3.5-9B-GGUF",
"unsloth/Qwen3.5-35B-A3B-GGUF",

View file

@ -1514,12 +1514,12 @@ class LlamaCppBackend:
)
# For reasoning models, set default thinking mode.
# Qwen3.5 models below 9B (0.8B, 2B, 4B) disable thinking by default.
# Qwen3.5/3.6 models below 9B (0.8B, 2B, 4B) disable thinking by default.
# Only 9B and larger enable thinking.
if self._supports_reasoning:
thinking_default = True
mid = (model_identifier or "").lower()
if "qwen3.5" in mid:
if "qwen3.5" in mid or "qwen3.6" in mid:
size_val = _extract_model_size_b(mid)
if size_val is not None and size_val < 9:
thinking_default = False
@ -1703,6 +1703,28 @@ class LlamaCppBackend:
# Wait for llama-server to become healthy
if not self._wait_for_health(timeout = 600.0):
self._kill_process()
_gguf = gguf_path or ""
_is_ollama = (
".studio_links" in _gguf
or os.sep + "ollama_links" + os.sep in _gguf
or os.sep + ".cache" + os.sep + "ollama" + os.sep in _gguf
or (self._model_identifier or "").startswith("ollama/")
)
# Only show the Ollama-specific message when the server
# output indicates a GGUF compatibility issue, not for
# unrelated failures like OOM or missing binaries.
if _is_ollama:
_output = "\n".join(self._stdout_lines[-50:]).lower()
_gguf_compat_hints = (
"key not found",
"unknown model architecture",
"failed to load model",
)
if any(h in _output for h in _gguf_compat_hints):
raise RuntimeError(
"Some Ollama models do not work with llama.cpp. "
"Try a different model, or use this model directly through Ollama instead."
)
raise RuntimeError(
"llama-server failed to start. "
"Check that the GGUF file is valid and you have enough memory."

View file

@ -27,6 +27,7 @@ import mimetypes
import shutil
import warnings
from contextlib import asynccontextmanager
from importlib.metadata import PackageNotFoundError, version as package_version
# Fix broken Windows registry MIME types. Some Windows installs map .js to
# "text/plain" in the registry (HKCR\.js\Content Type). Python's mimetypes
@ -78,6 +79,27 @@ import utils.hardware.hardware as _hw_module
from utils.cache_cleanup import clear_unsloth_compiled_cache
def get_unsloth_version() -> str:
try:
return package_version("unsloth")
except PackageNotFoundError:
pass
version_file = (
_Path(__file__).resolve().parents[2] / "unsloth" / "models" / "_utils.py"
)
try:
for line in version_file.read_text(encoding = "utf-8").splitlines():
if line.startswith("__version__ = "):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError:
pass
return "dev"
UNSLOTH_VERSION = get_unsloth_version()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup: detect hardware, seed default admin if needed. Shutdown: clean up compiled cache."""
@ -140,7 +162,7 @@ async def lifespan(app: FastAPI):
# Create FastAPI app
app = FastAPI(
title = "Unsloth UI Backend",
version = "1.0.0",
version = UNSLOTH_VERSION,
description = "Backend API for Unsloth UI - Training and Model Management",
lifespan = lifespan,
)
@ -198,6 +220,7 @@ async def health_check():
"status": "healthy",
"timestamp": datetime.now().isoformat(),
"service": "Unsloth UI Backend",
"version": UNSLOTH_VERSION,
"device_type": device_type,
"chat_only": _hw_module.CHAT_ONLY,
}

View file

@ -2,7 +2,7 @@
descript-audio-codec
descript-audiotools
julius
torchcodec
torchcodec==0.10.0
snac
# peft 0.19.0 causes export subprocess shutdown issues in Studio;

View file

@ -5,8 +5,11 @@
Model Management API routes
"""
import hashlib
import json
import os
import sys
import uuid
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from typing import List, Optional
@ -411,6 +414,267 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
return found
def _ollama_links_dir(ollama_dir: Path) -> Optional[Path]:
"""Return a writable directory for Ollama ``.gguf`` symlinks.
Prefers ``<ollama_dir>/.studio_links/`` so the links sit next to the
blobs they point at. Falls back to a per-ollama-dir namespace under
Studio's own cache when the models directory is read-only (common
for system installs under ``/usr/share/ollama`` or ``/var/lib/ollama``)
so we still surface Ollama models in those environments.
"""
from utils.paths.storage_roots import cache_root
primary = ollama_dir / ".studio_links"
try:
primary.mkdir(exist_ok = True)
return primary
except OSError as e:
logger.debug(
"Ollama dir %s not writable for .studio_links (%s); "
"falling back to Studio cache",
ollama_dir,
e,
)
# Fallback: namespace by a hash of the ollama_dir so two different
# Ollama roots don't collide. This is a cache path, not a security
# boundary.
try:
digest = hashlib.sha256(str(ollama_dir.resolve()).encode()).hexdigest()[:12]
except OSError:
digest = "default"
fallback = cache_root() / "ollama_links" / digest
try:
fallback.mkdir(parents = True, exist_ok = True)
return fallback
except OSError as e:
logger.warning(
"Could not create Ollama symlink cache at %s: %s",
fallback,
e,
)
return None
def _scan_ollama_dir(
ollama_dir: Path, limit: Optional[int] = None
) -> List[LocalModelInfo]:
"""Scan an Ollama models directory for downloaded models.
Ollama stores models in a content-addressable layout::
<ollama_dir>/manifests/<host>/<namespace>/<model>/<tag>
<ollama_dir>/blobs/sha256-...
The default host is ``registry.ollama.ai`` with namespace
``library`` (official models), but users can pull from custom
namespaces (``mradermacher/llama3``) or entirely different hosts
(``hf.co/org/repo:tag``). We iterate all manifest files via
``rglob`` so every layout depth is discovered.
Each manifest is JSON with a ``layers`` array. The layer with
``mediaType == "application/vnd.ollama.image.model"`` contains the
GGUF weights. Vision models also have a projector layer
(``application/vnd.ollama.image.projector``). We read the config
layer to extract family/size info.
Since Ollama blobs lack a ``.gguf`` extension (which the GGUF
loading pipeline requires), we create ``.gguf``-named links
pointing at the blobs so the existing ``detect_gguf_model`` and
``llama-server -m`` paths work unchanged. Each model gets its
own subdirectory under the links dir (keyed by a short hash of
the manifest path) so that ``detect_mmproj_file`` only sees the
projector for *that* model. Links are created as symlinks when
possible, falling back to hardlinks (Windows without Developer
Mode) as a last resort. The link dir lives under
``<ollama_dir>/.studio_links/`` when writable, otherwise under
Studio's own cache directory.
"""
manifests_root = ollama_dir / "manifests"
if not manifests_root.is_dir():
return []
found: List[LocalModelInfo] = []
blobs_dir = ollama_dir / "blobs"
links_root = _ollama_links_dir(ollama_dir)
if links_root is None:
logger.warning(
"Skipping Ollama scan for %s: no writable location for .gguf links",
ollama_dir,
)
return []
def _make_link(link_dir: Path, link_name: str, target: Path) -> Optional[str]:
"""Create a .gguf-named link to an Ollama blob.
Tries symlink first, then hardlink (works on Windows without
Developer Mode when target is on the same filesystem). Skips
the model if neither works -- a full file copy of a multi-GB
GGUF inside a synchronous API request would block the backend.
Idempotent: skips recreation when a valid link already exists.
"""
link_dir.mkdir(parents = True, exist_ok = True)
link_path = link_dir / link_name
resolved = target.resolve()
# Skip if the link already points at the exact same blob.
# Only use samefile -- size-based checks can reuse stale links
# after `ollama pull` updates a tag to a same-sized blob.
try:
if link_path.exists() and os.path.samefile(str(link_path), str(resolved)):
return str(link_path)
except OSError as e:
logger.debug("Error checking existing link %s: %s", link_path, e)
tmp_path = link_dir / f".{link_name}.tmp-{uuid.uuid4().hex[:8]}"
try:
if tmp_path.is_symlink() or tmp_path.exists():
tmp_path.unlink()
try:
tmp_path.symlink_to(resolved)
except OSError:
try:
os.link(str(resolved), str(tmp_path))
except OSError:
logger.warning(
"Could not create link for Ollama blob %s "
"(symlinks and hardlinks both failed). "
"Skipping model to avoid blocking the API.",
target,
)
return None
os.replace(str(tmp_path), str(link_path))
return str(link_path)
except OSError as e:
logger.debug("Could not create Ollama link %s: %s", link_path, e)
try:
if tmp_path.is_symlink() or tmp_path.exists():
tmp_path.unlink()
except OSError as cleanup_err:
logger.debug(
"Could not clean up tmp path %s: %s", tmp_path, cleanup_err
)
return None
try:
for tag_file in manifests_root.rglob("*"):
if not tag_file.is_file():
continue
rel = tag_file.relative_to(manifests_root)
parts = rel.parts
if len(parts) < 3:
continue
host = parts[0]
repo_parts = list(parts[1:-1])
tag = parts[-1]
if (
host == "registry.ollama.ai"
and repo_parts
and repo_parts[0] == "library"
):
repo_name = "/".join(repo_parts[1:])
elif host == "registry.ollama.ai":
repo_name = "/".join(repo_parts)
else:
repo_name = "/".join([host] + repo_parts)
if not repo_name:
continue
display = f"{repo_name}:{tag}"
manifest_key = rel.as_posix()
stem_hash = hashlib.sha256(manifest_key.encode()).hexdigest()[:10]
try:
manifest = json.loads(tag_file.read_text())
except (json.JSONDecodeError, OSError) as e:
logger.debug(
"Skipping unreadable/invalid Ollama manifest %s: %s",
tag_file,
e,
)
continue
config_digest = manifest.get("config", {}).get("digest", "")
model_type = ""
file_type = ""
if config_digest and blobs_dir.is_dir():
config_blob = blobs_dir / config_digest.replace(":", "-")
if config_blob.is_file():
try:
cfg = json.loads(config_blob.read_text())
model_type = cfg.get("model_type", "")
file_type = cfg.get("file_type", "")
except (json.JSONDecodeError, OSError) as e:
logger.debug(
"Could not parse Ollama config blob %s: %s",
config_blob,
e,
)
model_link_dir = links_root / stem_hash
gguf_link_path: Optional[str] = None
quant = f"-{file_type}" if file_type else ""
safe_name = repo_name.replace("/", "-")
for layer in manifest.get("layers", []):
media = layer.get("mediaType", "")
digest = layer.get("digest", "")
if not digest:
continue
if media == "application/vnd.ollama.image.model":
candidate = blobs_dir / digest.replace(":", "-")
if candidate.is_file():
link_name = f"{safe_name}-{tag}{quant}.gguf"
gguf_link_path = _make_link(
model_link_dir, link_name, candidate
)
elif media == "application/vnd.ollama.image.projector":
candidate = blobs_dir / digest.replace(":", "-")
if candidate.is_file():
mmproj_name = f"{safe_name}-{tag}-mmproj.gguf"
_make_link(model_link_dir, mmproj_name, candidate)
if not gguf_link_path:
continue
suffix = ""
if model_type:
suffix += f" ({model_type}"
if file_type:
suffix += f" {file_type}"
suffix += ")"
try:
updated_at = tag_file.stat().st_mtime
except OSError:
updated_at = None
found.append(
LocalModelInfo(
id = gguf_link_path,
model_id = f"ollama/{repo_name}:{tag}",
display_name = display + suffix,
path = gguf_link_path,
source = "custom",
updated_at = updated_at,
),
)
if limit is not None and len(found) >= limit:
return found
except OSError as e:
logger.warning("Error scanning Ollama directory %s: %s", ollama_dir, e)
return found
@router.get("/local", response_model = LocalModelListResponse)
async def list_local_models(
models_dir: str = Query(
@ -493,11 +757,27 @@ async def list_local_models(
for folder in custom_folders:
folder_path = Path(folder["path"])
try:
custom_models = (
_scan_models_dir(folder_path, limit = _MAX_MODELS_PER_FOLDER)
+ _scan_hf_cache(folder_path)
+ _scan_lmstudio_dir(folder_path)
)[:_MAX_MODELS_PER_FOLDER]
# Ollama scanner creates .studio_links/ with .gguf symlinks.
# Filter those from the generic scanners to avoid duplicates
# and leaking internal paths into the UI.
_generic = [
m
for m in (
_scan_models_dir(folder_path, limit = _MAX_MODELS_PER_FOLDER)
+ _scan_hf_cache(folder_path)
+ _scan_lmstudio_dir(folder_path)
)
if not any(
p in (".studio_links", "ollama_links")
for p in Path(m.path).parts
)
]
custom_models = _generic
if len(custom_models) < _MAX_MODELS_PER_FOLDER:
custom_models += _scan_ollama_dir(
folder_path,
limit = _MAX_MODELS_PER_FOLDER - len(custom_models),
)
except OSError as e:
logger.warning("Skipping unreadable scan folder %s: %s", folder_path, e)
continue
@ -575,6 +855,57 @@ async def remove_scan_folder_endpoint(
return {"ok": True}
@router.get("/recommended-folders")
async def get_recommended_folders(
current_subject: str = Depends(get_current_subject),
):
"""Return well-known model directories that exist on this machine.
Lightweight alternative to ``browse-folders`` for showing quick-pick
chips without the overhead of enumerating a directory tree. Returns
paths that actually exist on disk (HF cache, LM Studio, Ollama,
``~/models``, etc.) so the frontend can offer them as one-click
"Recommended" shortcuts in the Custom Folders section.
"""
from utils.paths.storage_roots import lmstudio_model_dirs
folders: list[str] = []
seen: set[str] = set()
def _add(p: Optional[Path]) -> None:
if p is None:
return
try:
resolved = str(p.resolve())
except OSError:
return
if resolved in seen:
return
if Path(resolved).is_dir() and os.access(resolved, os.R_OK | os.X_OK):
seen.add(resolved)
folders.append(resolved)
# LM Studio model directories
try:
for p in lmstudio_model_dirs():
_add(p)
except Exception as e:
logger.warning("Failed to scan for LM Studio model directories: %s", e)
# Ollama model directories
ollama_env = os.environ.get("OLLAMA_MODELS")
if ollama_env:
_add(Path(ollama_env).expanduser())
for candidate in (
Path.home() / ".ollama" / "models",
Path("/usr/share/ollama/.ollama/models"),
Path("/var/lib/ollama/.ollama/models"),
):
_add(candidate)
return {"folders": folders}
# Heuristic ceiling on how many children to stat when checking whether a
# directory "looks like" it contains models. Keeps the browser snappy
# even when a directory has thousands of unrelated entries.

View file

@ -26,7 +26,7 @@ from loggers import get_logger
logger = get_logger(__name__)
DEFAULT_HELPER_MODEL_REPO = "unsloth/Qwen3.5-4B-GGUF"
DEFAULT_HELPER_MODEL_REPO = "unsloth/gemma-4-E2B-it-GGUF"
DEFAULT_HELPER_MODEL_VARIANT = "UD-Q4_K_XL"
README_MAX_CHARS = 1500

View file

@ -959,6 +959,20 @@ def detect_mmproj_file(path: str, search_root: Optional[str] = None) -> Optional
scan_order.append(resolved)
_add(start_dir)
# When ``path`` is a symlink (e.g. Ollama's ``.studio_links/...gguf``
# -> ``blobs/sha256-...``), the symlink's parent directory rarely
# contains the mmproj sibling; the real mmproj file lives next to
# the symlink target. Add the target's parent to the scan so vision
# GGUFs that are surfaced via symlinks are still recognised as
# vision models.
try:
if p.is_symlink() and p.is_file():
target_parent = p.resolve().parent
if target_parent.is_dir():
_add(target_parent)
except OSError:
pass
if search_root is not None:
try:
root_resolved = Path(search_root).resolve()
@ -1006,7 +1020,10 @@ def detect_gguf_model(path: str) -> Optional[str]:
if p.suffix.lower() == ".gguf" and p.is_file():
if _is_mmproj(p.name):
return None
return str(p.resolve())
# Use absolute (not resolve) to preserve symlink names -- e.g.
# Ollama .studio_links/model.gguf -> blobs/sha256-... should
# keep the readable symlink name, not the opaque blob hash.
return str(p.absolute())
# Case 2: directory containing .gguf files (skip mmproj)
if p.is_dir():

View file

@ -87,6 +87,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"playwright": "^1.59.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.55.0",
"vite": "^8.0.1"

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View file

@ -13,7 +13,6 @@ import { Route as loginRoute } from "./routes/login";
import { Route as onboardingRoute } from "./routes/onboarding";
import { Route as changePasswordRoute } from "./routes/change-password";
import { Route as studioRoute } from "./routes/studio";
import { Route as apiKeysRoute } from "./routes/api-keys";
const routeTree = rootRoute.addChildren([
indexRoute,
@ -26,7 +25,6 @@ const routeTree = rootRoute.addChildren([
exportRoute,
dataRecipesRoute,
dataRecipeRoute,
apiKeysRoute,
]);
export const router = createRouter({ routeTree });

View file

@ -1,8 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { AppSidebar } from "@/components/app-sidebar";
import { Navbar } from "@/components/navbar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { usePlatformStore } from "@/config/env";
import { SettingsDialog, useSettingsDialogStore } from "@/features/settings";
import { useTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
import { useSidebarPin } from "@/hooks/use-sidebar-pin";
import {
Outlet,
createRootRoute,
@ -10,7 +15,7 @@ import {
useRouterState,
} from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react";
import { Suspense } from "react";
import { Suspense, useEffect } from "react";
import { AppProvider } from "../provider";
const CHAT_ONLY_ALLOWED = new Set([
@ -19,7 +24,6 @@ const CHAT_ONLY_ALLOWED = new Set([
"/login",
"/signup",
"/change-password",
"/api-keys",
]);
function isChatOnlyAllowed(pathname: string): boolean {
@ -43,24 +47,63 @@ const HIDDEN_NAVBAR_ROUTES = ["/onboarding", "/login", "/change-password"];
function RootLayout() {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const hideNavbar = HIDDEN_NAVBAR_ROUTES.includes(pathname);
const isChatRoute = pathname.startsWith("/chat");
const { pinned, setPinned, togglePinned } = useSidebarPin();
useTrainingUnloadGuard();
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
if ((e.metaKey || e.ctrlKey) && e.key === ",") {
e.preventDefault();
useSettingsDialogStore.getState().openDialog();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
return (
<AppProvider>
{!hideNavbar && <Navbar />}
<AnimatePresence initial={false} mode="wait">
<motion.div
key={pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="flex-1"
>
<SettingsDialog />
{hideNavbar ? (
<main className="flex-1">
<Suspense fallback={null}>
<Outlet />
</Suspense>
</motion.div>
</AnimatePresence>
</main>
) : (
<SidebarProvider
pinned={pinned}
setPinned={setPinned}
togglePinned={togglePinned}
className="!min-h-0 h-dvh overflow-hidden"
>
<AppSidebar />
<SidebarInset className={isChatRoute ? "overflow-hidden" : "overflow-y-auto"}>
<Navbar />
<div
className={`flex min-h-0 min-w-0 flex-1 basis-0 flex-col ${isChatRoute ? "overflow-hidden" : "overflow-visible"} ${isChatRoute ? "" : "pt-14 md:pt-0"}`}
>
<AnimatePresence initial={false} mode="wait">
<motion.div
key={pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className={`flex min-h-0 min-w-0 flex-1 basis-0 flex-col ${isChatRoute ? "overflow-hidden" : "overflow-visible"}`}
>
<Suspense fallback={null}>
<Outlet />
</Suspense>
</motion.div>
</AnimatePresence>
</div>
</SidebarInset>
</SidebarProvider>
)}
</AppProvider>
);
}

View file

@ -1,18 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const ApiKeysPage = lazy(() =>
import("@/features/auth/api-keys-page").then((m) => ({ default: m.ApiKeysPage })),
);
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/api-keys",
beforeLoad: () => requireAuth(),
component: ApiKeysPage,
});

View file

@ -1,18 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { ChatPage } from "@/features/chat/chat-page";
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const ChatPage = lazy(() =>
import("@/features/chat/chat-page").then((m) => ({ default: m.ChatPage })),
);
export type ChatSearch = {
thread?: string;
compare?: string;
new?: string;
};
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/chat",
beforeLoad: () => requireAuth(),
validateSearch: (search: Record<string, unknown>): ChatSearch => ({
thread: typeof search.thread === "string" ? search.thread : undefined,
compare: typeof search.compare === "string" ? search.compare : undefined,
new: typeof search.new === "string" ? search.new : undefined,
}),
component: ChatPage,
});

View file

@ -6,6 +6,8 @@ import { lazy } from "react";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
export type OnboardingSearch = { redirectTo?: string };
const WizardLayout = lazy(() =>
import("@/features/onboarding/components/wizard-layout").then((m) => ({
default: m.WizardLayout,
@ -16,5 +18,8 @@ export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/onboarding",
beforeLoad: () => requireAuth(),
validateSearch: (search: Record<string, unknown>): OnboardingSearch => ({
redirectTo: typeof search.redirectTo === "string" ? search.redirectTo : undefined,
}),
component: WizardLayout,
});

View file

@ -0,0 +1,607 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler";
import { cn } from "@/lib/utils";
import {
Book03Icon,
ChefHatIcon,
ColumnInsertIcon,
CursorInfo02Icon,
Delete02Icon,
MessageSearch01Icon,
Search01Icon,
NewReleasesIcon,
PackageIcon,
PencilEdit02Icon,
Settings02Icon,
ZapIcon,
} from "@hugeicons/core-free-icons";
import {
Tooltip,
TooltipContent,
} from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { ChevronDown, ChevronsUpDown, Moon, PanelLeft, Sun } from "lucide-react";
import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
import { motion } from "motion/react";
import { useTrainingRuntimeStore } from "@/features/training";
import { useSettingsDialogStore } from "@/features/settings";
import { usePlatformStore } from "@/config/env";
import { TOUR_OPEN_EVENT } from "@/features/tour";
import {
useChatSidebarItems,
deleteChatItem,
} from "@/features/chat/hooks/use-chat-sidebar-items";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { useChatSearchStore } from "@/features/chat/stores/chat-search-store";
import { ChatSearchDialog } from "@/features/chat/components/chat-search-dialog";
import { useTrainingHistorySidebarItems, deleteTrainingRun } from "@/features/training";
import type { TrainingRunSummary } from "@/features/training";
import { useState } from "react";
function getTourId(pathname: string): string | null {
if (pathname.startsWith("/studio")) return "studio";
if (pathname.startsWith("/export")) return "export";
if (pathname.startsWith("/chat")) return "chat";
return null;
}
const NAV_SPRING = { type: "spring", stiffness: 500, damping: 35, mass: 0.5 } as const;
function runStatusDotClass(status: TrainingRunSummary["status"]): string {
switch (status) {
case "running":
return "bg-blue-500 animate-pulse";
case "completed":
return "bg-emerald-500";
case "stopped":
return "bg-amber-500";
case "error":
return "bg-red-500";
default:
return "bg-muted-foreground";
}
}
function formatRelativeShort(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "";
const diffMs = Date.now() - then;
const s = Math.max(0, Math.floor(diffMs / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
const d = Math.floor(h / 24);
return `${d}d`;
}
function NavItem({
icon,
label,
active,
disabled,
onClick,
children,
variant = "nav",
dataTour,
}: {
icon: typeof ZapIcon;
label: string;
active: boolean;
disabled?: boolean;
onClick: () => void;
children?: React.ReactNode;
variant?: "nav" | "menu";
dataTour?: string;
}) {
const isNav = variant === "nav";
return (
<SidebarMenuItem>
<div className="relative">
{isNav && active && (
<motion.div
layoutId="sidebar-active-indicator"
className="absolute left-0 top-0 bottom-0 w-[3px] rounded-full bg-primary"
transition={NAV_SPRING}
/>
)}
<SidebarMenuButton
tooltip={label}
disabled={disabled}
onClick={onClick}
isActive={active}
data-tour={dataTour}
className={
isNav
? "rounded-none pr-0 pl-4 text-[#475569] dark:text-[#94a3b8] data-active:text-foreground!"
: "rounded-none pr-0 pl-4 text-[#475569] dark:text-[#94a3b8] hover:bg-muted! hover:text-foreground! data-active:bg-[oklch(0.94_0_0)]! data-active:text-foreground! dark:data-active:bg-[oklch(0.3_0_0)]!"
}
>
<HugeiconsIcon icon={icon} strokeWidth={2} className="size-[18px]" />
<span className="text-[13px] font-medium">{label}</span>
</SidebarMenuButton>
</div>
{children}
</SidebarMenuItem>
);
}
export function AppSidebar() {
const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle();
const { pathname, search } = useRouterState({
select: (s) => ({
pathname: s.location.pathname,
search: s.location.search as Record<string, string | undefined>,
}),
});
const { togglePinned, isMobile, setOpenMobile } = useSidebar();
const navigate = useNavigate();
// Auto-close mobile Sheet after navigation
const closeMobileIfOpen = () => {
if (isMobile) setOpenMobile(false);
};
const isTrainingRunning = useTrainingRuntimeStore((s) => s.isTrainingRunning);
const chatOnly = usePlatformStore((s) => s.isChatOnly());
// Chat collapsible state — open by default, syncs with route
const isChatRoute = pathname.startsWith("/chat");
const isStudioRoute = pathname === "/studio" || pathname.startsWith("/studio/");
const [chatOpen, setChatOpen] = useState(true);
const [runsOpen, setRunsOpen] = useState(true);
const effectiveChatOpen = isChatRoute || chatOpen;
const effectiveRunsOpen = isStudioRoute || runsOpen;
const isRecipesRoute = pathname.startsWith("/data-recipes");
const { items: chatItems } = useChatSidebarItems();
const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
const setActiveThreadId = useChatRuntimeStore((s) => s.setActiveThreadId);
const activeThreadId = isChatRoute
? (search.thread as string | undefined) ??
(search.compare as string | undefined) ??
storeThreadId ??
undefined
: undefined;
// Training runs
const { items: runItems, refresh: refreshRuns } = useTrainingHistorySidebarItems(
!chatOnly && isStudioRoute,
);
const activeJobId = useTrainingRuntimeStore((s) => s.jobId);
const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId);
const setSelectedHistoryRunId = useTrainingRuntimeStore((s) => s.setSelectedHistoryRunId);
const chatDisabled = isTrainingRunning;
async function handleDeleteThread(item: Parameters<typeof deleteChatItem>[0]) {
await deleteChatItem(item, activeThreadId, (view) => {
navigate({
to: "/chat",
search: { new: view.newThreadNonce },
});
});
}
return (
<>
<Sidebar collapsible="icon" variant="sidebar">
<SidebarHeader className="group-data-[collapsible=icon]:px-0">
{/* Expanded: compact logo + close toggle */}
<div className="flex items-center justify-between gap-2 px-1 py-1 group-data-[collapsible=icon]:hidden">
<Link
to={chatOnly ? "/chat" : "/studio"}
onClick={closeMobileIfOpen}
className="flex items-center select-none"
aria-label="Unsloth home"
>
<img
src="/blacklogo-c.png"
alt="Unsloth"
className="h-7 w-auto dark:hidden"
/>
<img
src="/whitelogo-c.png"
alt="Unsloth"
className="hidden h-7 w-auto dark:block"
/>
</Link>
{!isMobile && (
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={togglePinned}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Close sidebar"
>
<PanelLeft strokeWidth={1.5} className="size-4" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent side="bottom" sideOffset={6}>
Close sidebar
</TooltipContent>
</Tooltip>
)}
</div>
{/* Collapsed: sticker with hover-swap to open toggle */}
{!isMobile && (
<div className="hidden group-data-[collapsible=icon]:flex items-center justify-center h-9 w-full">
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={togglePinned}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Open sidebar"
>
<PanelLeft strokeWidth={1.5} className="size-4" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent side="right" sideOffset={8}>
Open sidebar
</TooltipContent>
</Tooltip>
</div>
)}
</SidebarHeader>
<SidebarGroup className="group-data-[collapsible=icon]:p-0 p-0 pt-1 shrink-0">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
icon={PencilEdit02Icon}
label="New Chat"
active={false}
disabled={chatDisabled}
onClick={() => {
if (chatDisabled) return;
setActiveThreadId(null);
navigate({ to: "/chat", search: { new: crypto.randomUUID() } });
closeMobileIfOpen();
}}
/>
<NavItem
icon={ColumnInsertIcon}
label="Compare"
active={!!search.compare}
disabled={chatDisabled}
dataTour="chat-compare"
onClick={() => {
if (chatDisabled) return;
setActiveThreadId(null);
navigate({ to: "/chat", search: { compare: crypto.randomUUID() } });
closeMobileIfOpen();
}}
/>
<NavItem
icon={Search01Icon}
label="Search"
active={false}
disabled={chatDisabled}
onClick={() => {
if (chatDisabled) return;
useChatSearchStore.getState().open();
closeMobileIfOpen();
}}
/>
</SidebarMenu>
</SidebarGroupContent>
<div className="my-2" />
</SidebarGroup>
<SidebarContent className="gap-0 overflow-y-auto overscroll-contain min-h-0">
{/* Navigate (no header) */}
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:p-0 p-0">
<SidebarGroupContent>
<SidebarMenu>
<NavItem
icon={ZapIcon}
label="Train"
active={pathname === "/studio" || pathname.startsWith("/studio/")}
disabled={chatOnly}
onClick={() => {
if (chatOnly) return;
navigate({ to: "/studio" });
closeMobileIfOpen();
}}
/>
<NavItem
icon={ChefHatIcon}
label="Recipes"
active={isRecipesRoute}
onClick={() => {
navigate({ to: "/data-recipes" });
closeMobileIfOpen();
}}
/>
<NavItem
icon={PackageIcon}
label="Export"
active={pathname === "/export" || pathname.startsWith("/export/")}
disabled={chatOnly}
onClick={() => {
if (chatOnly) return;
navigate({ to: "/export" });
closeMobileIfOpen();
}}
/>
</SidebarMenu>
</SidebarGroupContent>
<div className="my-2" />
</SidebarGroup>
{/* Recent Chats */}
{chatItems.length > 0 && (
<Collapsible open={effectiveChatOpen} onOpenChange={setChatOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden p-0">
<SidebarGroupLabel asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
Recent Chats
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{chatItems.map((item) => (
<SidebarMenuItem key={item.id} className="group/recent-item relative">
<SidebarMenuButton
isActive={activeThreadId === item.id}
className="rounded-none pl-4 pr-7 text-[13px] font-medium text-[#475569] dark:text-[#94a3b8] hover:bg-muted! hover:text-foreground! data-active:bg-[oklch(0.94_0_0)]! data-active:text-foreground! dark:data-active:bg-[oklch(0.3_0_0)]!"
onClick={() => {
navigate({
to: "/chat",
search:
item.type === "single"
? { thread: item.id }
: { compare: item.id },
});
closeMobileIfOpen();
}}
>
<span className="truncate">{item.title}</span>
</SidebarMenuButton>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDeleteThread(item);
}}
title="Delete"
className="absolute right-1 top-1/2 -translate-y-1/2 flex size-5 scale-90 items-center justify-center rounded-md text-sidebar-foreground/55 opacity-0 transition-all duration-150 hover:bg-destructive/12 hover:text-destructive group-hover/recent-item:scale-100 group-hover/recent-item:opacity-100"
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
</button>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)}
{/* Recent Runs */}
{isStudioRoute && runItems.length > 0 && !chatOnly && (
<Collapsible open={effectiveRunsOpen} onOpenChange={setRunsOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden p-0">
<SidebarGroupLabel asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
Recent Runs
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{runItems.map((run) => {
const isActiveRun =
selectedHistoryRunId === run.id || activeJobId === run.id;
return (
<SidebarMenuItem
key={run.id}
className="group/run-item relative"
>
<SidebarMenuButton
isActive={isActiveRun}
className="h-auto flex-col items-start gap-0.5 py-2 rounded-none pl-4 pr-7 text-[13px] font-medium text-[#475569] dark:text-[#94a3b8] hover:bg-muted! hover:text-foreground! data-active:bg-[oklch(0.94_0_0)]! data-active:text-foreground! dark:data-active:bg-[oklch(0.3_0_0)]!"
onClick={() => {
setSelectedHistoryRunId(run.id);
closeMobileIfOpen();
}}
>
<div className="flex w-full items-center gap-2">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
runStatusDotClass(run.status),
)}
aria-hidden
/>
<span className="truncate text-sm font-medium">
{run.model_name}
</span>
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">
{formatRelativeShort(run.started_at)}
</span>
</div>
<span className="w-full truncate pl-3.5 text-xs text-muted-foreground">
{run.dataset_name}
</span>
</SidebarMenuButton>
<button
type="button"
onClick={async (e) => {
e.stopPropagation();
try {
await deleteTrainingRun(run.id);
if (selectedHistoryRunId === run.id) {
setSelectedHistoryRunId(null);
}
await refreshRuns();
} catch {
// ignore — next refresh will reconcile
}
}}
title="Delete"
className="absolute right-1 top-1/2 -translate-y-1/2 flex size-5 scale-90 items-center justify-center rounded-md text-sidebar-foreground/55 opacity-0 transition-all duration-150 hover:bg-destructive/12 hover:text-destructive group-hover/run-item:scale-100 group-hover/run-item:opacity-100"
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
</button>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)}
</SidebarContent>
<SidebarFooter className="border-t border-sidebar-border">
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<img
src="/Sloth emojis/sloth rounded.png"
alt="Unsloth"
className="size-8 rounded-lg shrink-0"
/>
<div className="flex flex-col gap-0.5 leading-none group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-semibold">Unsloth</span>
<span className="truncate text-[11px] text-muted-foreground">Train</span>
</div>
<ChevronsUpDown strokeWidth={1.25} className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
className="w-56"
>
<DropdownMenuGroup>
<DropdownMenuItem
onSelect={() => useSettingsDialogStore.getState().openDialog()}
>
<HugeiconsIcon icon={Settings02Icon} className="size-4" />
<span>Settings</span>
<DropdownMenuShortcut>,</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
ref={anchorRef as React.Ref<HTMLDivElement>}
onSelect={(e) => { e.preventDefault(); toggleTheme(); }}
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
<span>{isDark ? "Light Mode" : "Dark Mode"}</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!getTourId(pathname)}
onSelect={() => {
const tourId = getTourId(pathname);
if (!tourId) return;
window.dispatchEvent(
new CustomEvent(TOUR_OPEN_EVENT, {
detail: { id: tourId },
}),
);
}}
>
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
<span>Guided Tour</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
>
<HugeiconsIcon icon={Book03Icon} className="size-4" />
<span>Learn More</span>
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a
href="https://unsloth.ai/docs/new/changelog"
target="_blank"
rel="noopener noreferrer"
>
<HugeiconsIcon
icon={NewReleasesIcon}
className="size-4"
/>
<span>What's New</span>
</a>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a
href="https://github.com/unslothai/unsloth/issues"
target="_blank"
rel="noopener noreferrer"
>
<HugeiconsIcon
icon={MessageSearch01Icon}
className="size-4"
/>
<span>Feedback</span>
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
<ChatSearchDialog />
</>
);
}

View file

@ -27,6 +27,7 @@ import {
listCachedModels,
listGgufVariants,
listLocalModels,
listRecommendedFolders,
listScanFolders,
removeScanFolder,
} from "@/features/chat/api/chat-api";
@ -49,7 +50,7 @@ import { checkVramFit, estimateLoadingVram } from "@/lib/vram";
import { Add01Icon, Cancel01Icon, Folder02Icon, Search01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { FolderBrowser } from "./folder-browser";
import { Trash2Icon } from "lucide-react";
import { ChevronDownIcon, ChevronRightIcon, DownloadIcon, StarIcon, Trash2Icon } from "lucide-react";
import {
type ReactNode,
useCallback,
@ -73,10 +74,35 @@ function normalizeForSearch(s: string): string {
return s.toLowerCase().replace(/[\s\-_\.]/g, "");
}
function ListLabel({ children }: { children: ReactNode }) {
function ListLabel({
children,
icon,
collapsed,
onToggle,
}: {
children: ReactNode;
icon?: ReactNode;
collapsed?: boolean;
onToggle?: () => void;
}) {
return (
<div className="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{children}
<div className="flex items-center justify-between gap-1 px-2.5 py-1.5">
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{icon}
{children}
</span>
{onToggle && (
<button
type="button"
onClick={onToggle}
aria-label={collapsed ? "Expand section" : "Collapse section"}
className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
>
{collapsed
? <ChevronRightIcon className="size-3" />
: <ChevronDownIcon className="size-3" />}
</button>
)}
</div>
);
}
@ -489,6 +515,9 @@ export function HubModelPicker({
// Delete confirmation dialog state
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [downloadedCollapsed, setDownloadedCollapsed] = useState(false);
const [customFoldersCollapsed, setCustomFoldersCollapsed] = useState(false);
const [recommendedCollapsed, setRecommendedCollapsed] = useState(false);
// Cached (already downloaded) repos -- use module-level cache so
// re-mounting the popover does not flash an empty "Downloaded" section.
@ -514,6 +543,7 @@ export function HubModelPicker({
const [showFolderInput, setShowFolderInput] = useState(false);
const [folderLoading, setFolderLoading] = useState(false);
const [showFolderBrowser, setShowFolderBrowser] = useState(false);
const [recommendedFolders, setRecommendedFolders] = useState<string[]>([]);
const refreshLocalModelsList = useCallback(() => {
listLocalModels()
@ -616,6 +646,9 @@ export function HubModelPicker({
// Always refresh LM Studio + custom folder models (not gated by alreadyCached)
refreshLocalModelsList();
refreshScanFolders();
listRecommendedFolders()
.then(setRecommendedFolders)
.catch(() => {});
// Always refetch cached GGUF/model lists. The module-level caches give
// an instant render with stale data (no spinner flash), but newly
@ -893,8 +926,12 @@ export function HubModelPicker({
(cachedGguf.length > 0 ||
(!chatOnly && cachedModels.length > 0)) ? (
<>
<ListLabel>Downloaded</ListLabel>
{cachedGguf.map((c) => (
<ListLabel
icon={<DownloadIcon className="size-3" />}
collapsed={downloadedCollapsed}
onToggle={() => setDownloadedCollapsed((v) => !v)}
>Downloaded</ListLabel>
{!downloadedCollapsed && cachedGguf.map((c) => (
<div key={c.repo_id}>
<ModelRow
label={c.repo_id}
@ -922,7 +959,7 @@ export function HubModelPicker({
)}
</div>
))}
{!chatOnly &&
{!downloadedCollapsed && !chatOnly &&
cachedModels.map((c) => (
<div key={c.repo_id} className="flex items-center gap-0.5">
<div className="min-w-0 flex-1">
@ -1001,20 +1038,12 @@ export function HubModelPicker({
{!showHfSection ? (
<>
<div className="flex items-center justify-between gap-1 px-2.5 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<div className="flex items-center gap-1 px-2.5 py-1.5">
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<HugeiconsIcon icon={Folder02Icon} className="size-3" />
Custom Folders
</span>
<div className="flex items-center gap-0.5">
<button
type="button"
aria-label="Browse for a folder on the server"
title="Browse folders on the server"
onClick={() => setShowFolderBrowser(true)}
className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
>
<HugeiconsIcon icon={Search01Icon} className="size-3" />
</button>
<button
type="button"
aria-label={showFolderInput ? "Cancel adding folder" : "Add scan folder by path"}
@ -1029,11 +1058,33 @@ export function HubModelPicker({
>
<HugeiconsIcon icon={showFolderInput ? Cancel01Icon : Add01Icon} className="size-3" />
</button>
<button
type="button"
aria-label="Browse for a folder on the server"
title="Browse folders on the server"
onClick={() => setShowFolderBrowser(true)}
className="shrink-0 rounded p-0.5 text-muted-foreground/60 transition-colors hover:text-foreground"
>
<HugeiconsIcon icon={Search01Icon} className="size-2.5" />
</button>
</div>
<div className="ml-auto">
<button
type="button"
aria-label={customFoldersCollapsed ? "Expand custom folders" : "Collapse custom folders"}
title={customFoldersCollapsed ? "Expand" : "Collapse"}
onClick={() => setCustomFoldersCollapsed((v) => !v)}
className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
>
{customFoldersCollapsed
? <ChevronRightIcon className="size-3" />
: <ChevronDownIcon className="size-3" />}
</button>
</div>
</div>
{/* Folder paths */}
{scanFolders.map((f) => (
{!customFoldersCollapsed && scanFolders.map((f) => (
<div
key={f.id}
className="group flex items-center gap-1.5 px-2.5 py-0.5"
@ -1056,8 +1107,31 @@ export function HubModelPicker({
</div>
))}
{/* Recommended folders */}
{!customFoldersCollapsed && (() => {
const registered = new Set(scanFolders.map((f) => f.path));
const unregistered = recommendedFolders.filter((p) => !registered.has(p));
if (unregistered.length === 0) return null;
return (
<div className="flex flex-wrap gap-1 px-2.5 pb-0.5">
{unregistered.map((p) => (
<button
key={p}
type="button"
onClick={() => void handleAddFolder(p)}
disabled={folderLoading}
title={`Add ${p}`}
className="rounded-full border border-dashed border-border/50 px-2 py-0.5 font-mono text-[10px] text-muted-foreground/70 transition-colors hover:border-foreground/30 hover:bg-accent hover:text-foreground disabled:opacity-40"
>
<span className="text-[11px] font-semibold">+</span> {p.length > 30 ? `...${p.slice(-27)}` : p}
</button>
))}
</div>
);
})()}
{/* Add folder input */}
{showFolderInput && (
{!customFoldersCollapsed && showFolderInput && (
<div className="px-2.5 pb-1 pt-0.5">
<div className="flex items-center gap-1">
<HugeiconsIcon icon={Folder02Icon} className="size-3 shrink-0 text-muted-foreground/40" />
@ -1114,11 +1188,15 @@ export function HubModelPicker({
{/* Models from custom folders */}
{customFolderModels.map((m) => {
{!customFoldersCollapsed && customFolderModels.map((m) => {
const isGgufFile = m.path.toLowerCase().endsWith(".gguf");
const isGguf =
isGgufFile ||
isGgufRepo(m.id) ||
isGgufRepo(m.display_name) ||
m.path.toLowerCase().endsWith(".gguf");
isGgufRepo(m.display_name);
// Single .gguf files (e.g. Ollama blobs) load directly;
// GGUF repos/directories expand to pick a variant.
const isDirectGguf = isGgufFile;
return (
<div key={m.id}>
<ModelRow
@ -1126,7 +1204,13 @@ export function HubModelPicker({
meta={isGguf ? "GGUF" : "Local"}
selected={value === m.id}
onClick={() => {
if (isGguf) {
if (isDirectGguf) {
onSelect(m.id, {
source: "local",
isLora: false,
isDownloaded: true,
});
} else if (isGguf) {
setExpandedGguf((prev) =>
prev === m.id ? null : m.id,
);
@ -1158,8 +1242,12 @@ export function HubModelPicker({
{!showHfSection && cachedReady ? (
<>
<ListLabel>Recommended</ListLabel>
{visibleRecommendedIds.length === 0 ? (
<ListLabel
icon={<StarIcon className="size-3" />}
collapsed={recommendedCollapsed}
onToggle={() => setRecommendedCollapsed((v) => !v)}
>Recommended</ListLabel>
{recommendedCollapsed ? null : visibleRecommendedIds.length === 0 ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
No default models.
</div>
@ -1203,7 +1291,7 @@ export function HubModelPicker({
);
})
)}
{hasMoreRecommended && (
{!recommendedCollapsed && hasMoreRecommended && (
<>
<div ref={recommendedSentinelRef} className="h-px" />
<div className="flex items-center justify-center py-2">
@ -1216,7 +1304,7 @@ export function HubModelPicker({
{showHfSection && filteredRecommendedIds.length > 0 ? (
<>
<ListLabel>Recommended</ListLabel>
<ListLabel icon={<StarIcon className="size-3" />}>Recommended</ListLabel>
{filteredRecommendedIds.map((id) => {
const vram = recommendedVramMap.get(id);
return (

View file

@ -6,7 +6,6 @@
/* eslint-disable react-refresh/only-export-components */
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { AnimatedShinyText } from "@/components/ui/animated-shiny-text";
import {
Collapsible,
CollapsibleContent,
@ -151,34 +150,6 @@ function ReasoningRoot({
);
}
function ReasoningFade({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="reasoning-fade"
className={cn(
"aui-reasoning-fade pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8",
"bg-gradient-to-t from-background to-transparent",
className,
)}
{...props}
/>
);
}
function ReasoningFadeTop({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="reasoning-fade-top"
className={cn(
"aui-reasoning-fade-top pointer-events-none absolute inset-x-0 top-0 z-10 h-8",
"bg-gradient-to-b from-background to-transparent",
className,
)}
{...props}
/>
);
}
function ReasoningTrigger({
active,
duration,
@ -206,7 +177,7 @@ function ReasoningTrigger({
className="aui-reasoning-trigger-label-wrapper relative inline-block leading-none"
>
{active ? (
<AnimatedShinyText className="text-sm">Thinking...</AnimatedShinyText>
<span className="text-sm">Thinking...</span>
) : (
<span>Thought for {duration ?? 0} seconds</span>
)}
@ -234,7 +205,7 @@ function ReasoningContent({
<CollapsibleContent
data-slot="reasoning-content"
className={cn(
"aui-reasoning-content relative overflow-hidden text-muted-foreground text-sm outline-none",
"aui-reasoning-content relative overflow-hidden text-foreground/85 text-[13.5px] outline-none",
"group/collapsible-content ease-out",
"data-[state=closed]:animate-collapsible-up",
"data-[state=open]:animate-collapsible-down",
@ -246,9 +217,7 @@ function ReasoningContent({
)}
{...props}
>
{streaming && <ReasoningFadeTop />}
{children}
<ReasoningFade />
</CollapsibleContent>
);
}
@ -481,8 +450,6 @@ const Reasoning = memo(
Trigger: typeof ReasoningTrigger;
Content: typeof ReasoningContent;
Text: typeof ReasoningText;
Fade: typeof ReasoningFade;
FadeTop: typeof ReasoningFadeTop;
};
Reasoning.displayName = "Reasoning";
@ -490,8 +457,6 @@ Reasoning.Root = ReasoningRoot;
Reasoning.Trigger = ReasoningTrigger;
Reasoning.Content = ReasoningContent;
Reasoning.Text = ReasoningText;
Reasoning.Fade = ReasoningFade;
Reasoning.FadeTop = ReasoningFadeTop;
const ReasoningGroup = memo(ReasoningGroupImpl);
ReasoningGroup.displayName = "ReasoningGroup";
@ -503,6 +468,4 @@ export {
ReasoningTrigger,
ReasoningContent,
ReasoningText,
ReasoningFade,
ReasoningFadeTop,
};

View file

@ -16,7 +16,6 @@ import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { AnimatedShinyText } from "@/components/ui/animated-shiny-text";
import { Button } from "@/components/ui/button";
import { sentAudioNames } from "@/features/chat/api/chat-adapter";
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
@ -70,13 +69,25 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
}) => {
return (
<ThreadPrimitive.Root
className="aui-root aui-thread-root @container flex h-full flex-col "
className={cn(
"aui-root aui-thread-root @container flex flex-col",
hideComposer
? "h-full"
: "relative min-h-0 min-w-0 flex-1 basis-0 overflow-hidden",
)}
style={{
["--thread-max-width" as string]: "44rem",
["--thread-content-max-width" as string]:
"calc(var(--thread-max-width) - 2.5rem)",
}}
>
<ThreadPrimitive.Viewport
className="aui-thread-viewport relative flex min-w-0 flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
className={cn(
"aui-thread-viewport relative flex min-w-0 flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-5",
hideComposer
? "pt-4"
: "h-0 min-h-0 basis-0 pt-[56px]",
)}
>
{!hideWelcome && (
<AuiIf condition={({ thread }) => thread.isEmpty}>
@ -92,19 +103,21 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
}}
/>
{/* Small overlap and extra slack so the last lines can scroll under the composer cleanly */}
{!hideComposer && <div className="h-40 shrink-0" aria-hidden />}
<ThreadPrimitive.ViewportFooter
className={cn(
"aui-thread-viewport-footer sticky bottom-0 z-20 mt-auto flex w-full flex-col overflow-visible bg-transparent",
hideComposer ? "gap-2" : "gap-4",
"aui-thread-viewport-footer sticky z-20 mt-auto flex w-full flex-col overflow-visible bg-transparent",
hideComposer
? "bottom-0 gap-2"
: "bottom-[140px] shrink-0 gap-3",
// Compare: pointer-events pass-through so messages behind footer stay clickable
hideComposer
? "pointer-events-none pb-3"
: "relative pb-4",
: "pb-2",
)}
>
{!hideComposer && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-4 bg-background" aria-hidden />
)}
<div
className={cn(
"flex justify-center",
@ -113,11 +126,26 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
>
<ThreadScrollToBottom />
</div>
<AuiIf condition={({ thread }) => !thread.isEmpty}>
{!hideComposer && <ComposerAnimated />}
</AuiIf>
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
{!hideComposer && (
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<div className="aui-thread-composer-dock pointer-events-none absolute bottom-0 left-0 right-0 md:right-2 z-20">
<div
aria-hidden
className="absolute inset-x-0 bottom-0 top-[10px] bg-background"
/>
<div className="relative px-5 pb-2">
<div className="pointer-events-auto mx-auto w-full max-w-(--thread-max-width)">
<ComposerAnimated />
</div>
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">
LLM's can make mistakes. Double-check all responses.
</p>
</div>
</div>
</AuiIf>
)}
</ThreadPrimitive.Root>
);
};
@ -207,18 +235,13 @@ const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
alt="Sloth mascot"
className="size-20"
/>
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in font-semibold text-2xl duration-200">
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in font-bold text-2xl tracking-[-0.02em] duration-200">
Chat with your model
</h1>
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in text-muted-foreground text-base delay-75 duration-200">
Run GGUFs, safetensors, vision and audio models!
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in text-muted-foreground text-sm delay-75 duration-200">
Run GGUFs, safetensors, vision and audio models
</p>
</div>
<div className="grid grid-cols-2 gap-2">
<ThreadPrimitive.Suggestions
components={{ Suggestion: SuggestionItem }}
/>
</div>
<GeneratingSpinner />
{!hideComposer && <ComposerAnimated />}
</div>
@ -243,10 +266,6 @@ const GeneratingSpinner: FC = () => {
const ComposerAnimated: FC = () => {
return (
<div className="relative mx-auto min-w-0 w-full max-w-(--thread-max-width)">
<div
className="pointer-events-none absolute inset-x-0 top-1/2 bottom-0 z-0 bg-background"
aria-hidden
/>
<motion.div
layout={true}
layoutId="composer"
@ -284,14 +303,15 @@ const PendingAudioChip: FC = () => {
const Composer: FC = () => {
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone shadow-border ring-1 ring-border flex w-full flex-col rounded-2xl bg-background px-1 pt-2 outline-none transition-shadow data-[dragging=true]:ring-ring data-[dragging=true]:bg-accent/50">
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone chat-composer-surface flex w-full flex-col rounded-3xl bg-background px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:bg-accent/50">
<ComposerAttachments />
<PendingAudioChip />
<ToolStatusDisplay />
<ComposerPrimitive.Input
placeholder="Send a message..."
className="aui-composer-input mb-1 max-h-32 min-h-12 w-full resize-none bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-0"
rows={1}
className="aui-composer-input mb-1 min-h-12 w-full resize-none overflow-y-auto bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-0"
minRows={1}
maxRows={6}
autoFocus={true}
aria-label="Message input"
/>
@ -457,12 +477,30 @@ const CodeToolsToggle: FC = () => {
)}
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
>
<TerminalIcon className="size-3.5" />
<CodeToggleIcon className="size-3.5" />
<span>Code</span>
</button>
);
};
const CodeToggleIcon: FC<{ className?: string }> = ({ className }) => {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
);
};
const ToolStatusDisplay: FC = () => {
const toolStatus = useChatRuntimeStore((s) => s.toolStatus);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
@ -594,15 +632,13 @@ const GeneratingIndicator: FC = () => {
message.content.length === 0 && message.status?.type === "running",
);
if (!show) return null;
return (
<AnimatedShinyText className="text-sm">Generating...</AnimatedShinyText>
);
return <span className="text-sm text-muted-foreground">Generating...</span>;
};
const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto min-w-0 w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto min-w-0 w-full max-w-(--thread-content-max-width) animate-in py-0.5 text-[15.5px] duration-150"
data-role="assistant"
>
<div className="aui-assistant-message-content wrap-break-word min-w-0 text-foreground leading-relaxed">
@ -701,9 +737,9 @@ const AssistantActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
hideWhenRunning={true}
autohide="not-last"
autohide="always"
autohideFloat="single-branch"
className="aui-assistant-action-bar-root col-start-3 row-start-2 -ml-1 flex gap-1 text-muted-foreground data-floating:absolute data-floating:rounded-md data-floating:border data-floating:bg-background data-floating:p-1 data-floating:shadow-sm"
className="aui-assistant-action-bar-root col-start-3 row-start-2 -ml-1 flex gap-1 text-muted-foreground data-floating:absolute"
>
<CopyButton />
<ActionBarPrimitive.Reload asChild={true}>
@ -755,22 +791,22 @@ const UserMessageAudio: FC = () => {
const UserMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2"
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto flex w-full max-w-(--thread-content-max-width) animate-in flex-col items-end gap-y-2 pt-6 pb-0.5 text-[15.5px] duration-150"
data-role="user"
>
<UserMessageAttachments />
<UserMessageAudio />
<div className="aui-user-message-content-wrapper relative col-start-2 min-w-0">
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
<div className="aui-user-message-content-wrapper flex max-w-[80%] min-w-0 flex-col items-end">
<div className="aui-user-message-content wrap-break-word w-fit rounded-2xl bg-muted px-4 py-2.5 text-foreground">
<MessagePrimitive.Parts />
</div>
<div className="aui-user-action-bar-wrapper absolute top-1/2 left-0 -translate-x-full -translate-y-1/2 pr-2">
<div className="mt-1 flex min-h-6">
<UserActionBar />
</div>
</div>
<BranchPicker className="aui-user-branch-picker col-span-full col-start-1 row-start-3 -mr-1 justify-end" />
<BranchPicker className="aui-user-branch-picker -mr-1 justify-end" />
</MessagePrimitive.Root>
);
};
@ -778,8 +814,8 @@ const UserMessage: FC = () => {
const UserActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
autohide="not-last"
className="aui-user-action-bar-root flex items-center"
autohide="always"
className="aui-user-action-bar-root -mr-1 flex gap-1 text-muted-foreground"
>
<CopyButton />
<ActionBarPrimitive.Edit asChild={true}>
@ -805,7 +841,7 @@ const EditComposer: FC = () => {
});
return (
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-max-width) flex-col px-2 py-3">
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-content-max-width) flex-col py-3">
<ComposerPrimitive.Root className="aui-edit-composer-root ml-auto flex w-full max-w-[85%] flex-col rounded-2xl bg-muted">
<ComposerPrimitive.Input
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm outline-none"

View file

@ -1,647 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import {
ArrowReloadHorizontalIcon,
ArrowRight01Icon,
Cancel01Icon,
Book03Icon,
BubbleChatIcon,
ChefHatIcon,
Copy01Icon,
CursorInfo02Icon,
Key01Icon,
PackageIcon,
Tick02Icon,
ZapIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { useTrainingRuntimeStore } from "@/features/training";
import { usePlatformStore } from "@/config/env";
import { Link, useRouterState } from "@tanstack/react-router";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ReactElement } from "react";
import { useEffect, useRef, useState } from "react";
import { TOUR_OPEN_EVENT } from "@/features/tour";
import { ShutdownDialog } from "@/components/shutdown-dialog";
const NAV_ITEMS = [
{ label: "Studio", href: "/studio", icon: ZapIcon, enabled: true },
{ label: "Recipes", href: "/data-recipes", icon: ChefHatIcon, enabled: true },
{ label: "Export", href: "/export", icon: PackageIcon, enabled: true },
{ label: "Chat", href: "/chat", icon: BubbleChatIcon, enabled: true },
];
const STUDIO_UPDATE_CMD = "unsloth studio update";
const STUDIO_UPDATE_FALLBACK_UNIX_CMD =
"curl -fsSL https://unsloth.ai/install.sh | sh";
const STUDIO_UPDATE_FALLBACK_WINDOWS_CMD =
"irm https://unsloth.ai/install.ps1 | iex";
type UpdateShell = "windows" | "unix";
function getDefaultUpdateShell(deviceType: string): UpdateShell {
return deviceType === "windows" ? "windows" : "unix";
}
function getStudioUpdateInstructionLine(shell: UpdateShell): string {
return shell === "windows" ? "Open PowerShell and run:" : "Open Terminal and run:";
}
function CopyableCommand({
command,
copyLabel,
}: {
command: string;
copyLabel: string;
}): ReactElement {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);
const handleCopy = () => {
if (!copyToClipboard(command)) {
return;
}
setCopied(true);
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => setCopied(false), 2000);
};
return (
<div className="flex min-w-0 items-stretch overflow-hidden rounded-md border border-border bg-muted/40">
<input
type="text"
readOnly
value={command}
className="min-w-0 flex-1 bg-transparent px-2 py-1.5 font-mono text-[11px] text-foreground outline-none"
title={command}
aria-label={`${copyLabel} text`}
/>
<button
type="button"
onClick={handleCopy}
className="flex shrink-0 items-center justify-center border-l border-border px-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={copied ? "Copied" : "Copy command"}
aria-label={copied ? `${copyLabel} copied` : `Copy ${copyLabel}`}
>
{copied ? (
<HugeiconsIcon icon={Tick02Icon} className="size-4 text-emerald-600" />
) : (
<HugeiconsIcon icon={Copy01Icon} className="size-4" />
)}
</button>
</div>
);
}
function UpdateStudioInstructions({
className,
defaultShell,
showTitle = true,
}: {
className?: string;
defaultShell: UpdateShell;
showTitle?: boolean;
}): ReactElement {
const [shell, setShell] = useState<UpdateShell>(defaultShell);
const prefersReducedMotion = useReducedMotion();
const windows = shell === "windows";
const fadeTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.16, ease: [0.165, 0.84, 0.44, 1] as const };
const fadeInitial = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 2 };
const fadeAnimate = { opacity: 1, y: 0 };
const fadeExit = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: -2 };
useEffect(() => {
setShell(defaultShell);
}, [defaultShell]);
return (
<div className={cn("flex flex-col gap-3", className)}>
<div
className={cn(
"flex items-center gap-3",
showTitle ? "justify-between" : "justify-start",
)}
>
{showTitle ? (
<p className="shrink-0 whitespace-nowrap text-sm font-semibold font-heading">
Update Unsloth Studio
</p>
) : null}
<div className="flex shrink-0 items-center gap-0.5 text-[11px]">
<button
type="button"
onClick={() => setShell("windows")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
windows
? "text-foreground"
: "text-muted-foreground hover:text-emerald-600",
)}
aria-pressed={windows}
>
Windows
</button>
<span className="text-border">/</span>
<button
type="button"
onClick={() => setShell("unix")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
!windows
? "text-foreground"
: "text-muted-foreground hover:text-emerald-600",
)}
aria-pressed={!windows}
>
macOS/Linux
</button>
</div>
</div>
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={`instruction-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
className="text-xs text-muted-foreground leading-relaxed"
>
{getStudioUpdateInstructionLine(shell)}
</motion.p>
</AnimatePresence>
<CopyableCommand command={STUDIO_UPDATE_CMD} copyLabel="update command" />
<p className="text-xs text-muted-foreground leading-relaxed">
If that fails or unsloth studio update is unavailable, run:
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`fallback-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
<CopyableCommand
command={
windows
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
}
copyLabel="fallback command"
/>
</motion.div>
</AnimatePresence>
<p className="text-xs text-muted-foreground leading-relaxed">
Restart Studio after updating for changes to take effect.
</p>
</div>
);
}
function getTourId(pathname: string): "studio" | "chat" | "export" | null {
if (pathname === "/studio") return "studio";
if (pathname === "/chat") return "chat";
if (pathname === "/export") return "export";
return null;
}
import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
export function Navbar() {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const isTrainingRunning = useTrainingRuntimeStore((s) => s.isTrainingRunning);
const [mobileOpen, setMobileOpen] = useState(false);
const [mobileUpdateOpen, setMobileUpdateOpen] = useState(false);
const [shutdownOpen, setShutdownOpen] = useState(false);
const deviceType = usePlatformStore((s) => s.deviceType);
const chatOnly = usePlatformStore((s) => s.isChatOnly());
const defaultUpdateShell = getDefaultUpdateShell(deviceType);
// Warn before closing the tab only when training is running (data loss risk).
// We store the handler in a ref so removeUnloadHandler() can clean it up
// before the "Server stopped" page renders.
const unloadHandlerRef = useRef<((e: BeforeUnloadEvent) => void) | null>(null);
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (!useTrainingRuntimeStore.getState().isTrainingRunning) return;
e.preventDefault();
e.returnValue = "";
};
unloadHandlerRef.current = handler;
window.addEventListener("beforeunload", handler);
return () => {
window.removeEventListener("beforeunload", handler);
};
}, []);
const removeUnloadHandler = () => {
if (unloadHandlerRef.current) {
window.removeEventListener("beforeunload", unloadHandlerRef.current);
unloadHandlerRef.current = null;
}
};
const tourId = getTourId(pathname);
const openTour = () => {
if (!tourId) return;
window.dispatchEvent(
new CustomEvent(TOUR_OPEN_EVENT, { detail: { id: tourId } }),
const { isMobile } = useSidebar();
if (!isMobile) {
return (
<header className="absolute top-0 inset-x-0 z-40 h-11 pointer-events-none" />
);
};
}
return (
<>
<header className="relative top-0 z-40 h-16 w-full">
<div className="mx-auto grid h-full max-w-7xl grid-cols-[1fr_auto_1fr] items-center px-4 sm:px-6">
{/* Left: logo */}
<Link to={chatOnly ? "/chat" : "/studio"} className="flex items-center gap-1.5 justify-self-start select-none">
<img
src="/blacklogo.png"
alt="Unsloth"
className="h-9 w-auto dark:hidden"
/>
<img
src="/whitelogo.png"
alt="Unsloth"
className="hidden h-9 w-auto dark:block"
/>
<span className="relative -top-[1px] inline-flex items-center text-[10px] font-extrabold leading-none tracking-[0.12em] text-primary">
BETA
</span>
</Link>
{/* Center: pill nav */}
<nav
data-tour="navbar"
className="hidden items-center rounded-full border border-border bg-card p-1 ring-1 ring-foreground/5 md:flex"
>
{NAV_ITEMS.map((item) => {
const active =
pathname === item.href || pathname.startsWith(`${item.href}/`);
const disabledByTraining =
isTrainingRunning && item.href !== "/studio";
const disabledByDevice =
chatOnly && item.href !== "/chat" && item.href !== "/data-recipes";
if (!item.enabled || disabledByTraining || disabledByDevice) {
return (
<span
key={item.href}
className="relative rounded-full px-3 py-1.5 text-sm font-medium text-muted-foreground/40 cursor-not-allowed"
>
{item.label}
</span>
);
}
return (
<Link
key={item.href}
to={item.href}
className={cn(
"relative rounded-full px-3 py-1.5 text-sm font-medium transition-colors",
active
? "text-background"
: "text-muted-foreground hover:text-foreground",
)}
>
{active && (
<motion.span
layoutId="nav-pill"
className="absolute inset-0 rounded-full bg-foreground"
transition={{
type: "spring",
stiffness: 500,
damping: 35,
mass: 0.5,
}}
/>
)}
<span className="relative z-10 flex items-center">
<motion.span
initial={false}
animate={{
width: active ? 14 : 0,
marginLeft: active ? -4 : 0,
marginRight: active ? 4 : 0,
opacity: active ? 1 : 0,
}}
transition={{ duration: 0.2, ease: [0.165, 0.84, 0.44, 1] }}
className="inline-flex shrink-0 items-center justify-center overflow-hidden"
>
<HugeiconsIcon
icon={item.icon}
className="size-3.5 -mt-px shrink-0"
/>
</motion.span>
{item.label}
</span>
</Link>
);
})}
</nav>
{/* Right: docs/tour desktop — one wrapper per control so flex gap is even (HoverCard roots can confuse flex spacing). */}
<div className="hidden items-center justify-self-end gap-0 md:flex">
<div className="flex shrink-0 items-center">
<AnimatedThemeToggler
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
title="Toggle theme"
aria-label="Toggle theme"
/>
</div>
<div className="flex shrink-0 items-center">
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium text-emerald-600 transition-colors hover:bg-accent hover:text-emerald-700 dark:hover:text-emerald-400"
>
<HugeiconsIcon icon={Book03Icon} className="size-4" />
Learn more
</a>
</HoverCardTrigger>
<HoverCardContent align="end" className="w-80 p-0">
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="group/card flex flex-col gap-1 p-4 no-underline"
>
<p className="text-sm font-semibold font-heading">
Unsloth Documentation
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Guides on fine-tuning LLMs 2x faster with 70% less memory.
Covers LoRA, QLoRA, data formatting, and deployment.
</p>
<span className="mt-1 flex items-center gap-1 text-xs font-medium text-emerald-600 group-hover/card:underline">
Visit docs
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
</span>
</a>
</HoverCardContent>
</HoverCard>
</div>
<div className="flex shrink-0 items-center">
<Link
to="/api-keys"
className={cn(
"flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent",
pathname === "/api-keys"
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<HugeiconsIcon icon={Key01Icon} className="size-4" />
API Keys
</Link>
</div>
{tourId ? (
<div className="flex shrink-0 items-center">
<button
type="button"
onClick={openTour}
className="flex h-9 items-center gap-1.5 rounded-md px-3 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Tour"
>
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
<span className="text-sm font-medium">Tour</span>
</button>
</div>
) : null}
<div className="flex shrink-0 items-center">
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>
<button
type="button"
className="flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="How to update Unsloth Studio"
>
<HugeiconsIcon icon={ArrowReloadHorizontalIcon} className="size-4" />
Update
</button>
</HoverCardTrigger>
<HoverCardContent align="end" className="w-[22.5rem] p-0">
<UpdateStudioInstructions
className="p-4"
defaultShell={defaultUpdateShell}
/>
</HoverCardContent>
</HoverCard>
</div>
<div className="flex shrink-0 items-center">
<button
type="button"
onClick={() => setShutdownOpen(true)}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Shut down Unsloth Studio server"
aria-label="Shut down Unsloth Studio server"
>
<HugeiconsIcon icon={Cancel01Icon} className="size-5" />
</button>
</div>
</div>
{/* Right: mobile */}
<div className="col-start-3 flex items-center gap-2 justify-self-end md:hidden">
{tourId ? (
<button
type="button"
onClick={openTour}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Tour"
>
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
</button>
) : null}
<Sheet
open={mobileOpen}
onOpenChange={(open) => {
setMobileOpen(open);
if (!open) setMobileUpdateOpen(false);
}}
>
<SheetTrigger asChild={true}>
<button
type="button"
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground"
aria-label="Open navigation menu"
>
Menu
</button>
</SheetTrigger>
<SheetContent side="right" className="w-[300px] p-4">
<SheetHeader>
<SheetTitle>Navigate</SheetTitle>
</SheetHeader>
<div className="mt-6 flex max-h-[calc(100dvh-8rem)] flex-col gap-2 overflow-y-auto pr-1">
{NAV_ITEMS.filter((item) => item.enabled).map((item) => {
const active = pathname === item.href;
const disabledByTraining =
isTrainingRunning && item.href !== "/studio";
const disabledByDevice =
chatOnly && item.href !== "/chat" && item.href !== "/data-recipes";
if (disabledByTraining || disabledByDevice) {
return (
<span
key={item.href}
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground/40 cursor-not-allowed"
>
<HugeiconsIcon icon={item.icon} className="size-4" />
{item.label}
</span>
);
}
return (
<Link
key={item.href}
to={item.href}
onClick={() => setMobileOpen(false)}
className={cn(
"flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium",
active
? "border-foreground bg-foreground text-background"
: "border-border text-foreground hover:bg-accent",
)}
>
<HugeiconsIcon icon={item.icon} className="size-4" />
{item.label}
</Link>
);
})}
<Link
to="/api-keys"
onClick={() => setMobileOpen(false)}
className={cn(
"mt-3 flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium",
pathname === "/api-keys"
? "border-foreground bg-foreground text-background"
: "border-border text-foreground hover:bg-accent",
)}
>
<HugeiconsIcon icon={Key01Icon} className="size-4" />
API Keys
</Link>
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-foreground hover:bg-accent"
onClick={() => setMobileOpen(false)}
>
<HugeiconsIcon icon={Book03Icon} className="size-4" />
Learn more (Docs)
</a>
{tourId ? (
<button
type="button"
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-left text-sm font-medium text-foreground hover:bg-accent"
onClick={() => {
openTour();
setMobileOpen(false);
}}
>
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
Start tour
</button>
) : null}
<Collapsible
open={mobileUpdateOpen}
onOpenChange={setMobileUpdateOpen}
className="rounded-md border border-border"
>
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium text-foreground transition-colors hover:bg-accent"
aria-label="Toggle update instructions"
>
<span className="flex items-center gap-2">
<HugeiconsIcon icon={ArrowReloadHorizontalIcon} className="size-4" />
Update Unsloth Studio
</span>
<HugeiconsIcon
icon={ArrowRight01Icon}
className={cn(
"size-4 text-muted-foreground transition-transform",
mobileUpdateOpen && "rotate-90",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-t border-border p-3 pt-2">
<UpdateStudioInstructions
defaultShell={defaultUpdateShell}
showTitle={false}
/>
</CollapsibleContent>
</Collapsible>
<button
type="button"
className="mt-3 flex items-center gap-2 rounded-md border border-border px-3 py-2 text-left text-sm font-medium text-foreground hover:bg-accent"
onClick={() => {
setMobileOpen(false);
setShutdownOpen(true);
}}
>
<HugeiconsIcon icon={Cancel01Icon} className="size-5" />
Quit Unsloth Studio
</button>
<div className="mt-2 flex items-center justify-between rounded-md border border-border px-3 py-2">
<span className="text-sm font-medium text-foreground">Theme</span>
<AnimatedThemeToggler
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
title="Toggle theme"
aria-label="Toggle theme"
/>
</div>
</div>
</SheetContent>
</Sheet>
</div>
<header className="absolute top-0 inset-x-0 z-40 h-11 pointer-events-none">
<div className="flex h-full items-center pl-2">
<SidebarTrigger className="pointer-events-auto" />
</div>
</header>
<ShutdownDialog
open={shutdownOpen}
onOpenChange={setShutdownOpen}
onBeforeShutdown={removeUnloadHandler}
/>
</>
);
}

View file

@ -18,16 +18,17 @@ import {
interface ShutdownDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called right before the shutdown API request so callers can remove the
* beforeunload listener otherwise the "Server stopped" page would still
* trigger a "Leave site?" prompt when the user tries to close it. */
onBeforeShutdown?: () => void;
/** Called after the shutdown API returns success, right before we replace
* document.body with the "Server stopped" page. Callers use this to remove
* their beforeunload listener otherwise the browser would prompt
* "Leave site?" when the user tries to close the final tab. */
onAfterShutdown?: () => void;
}
export function ShutdownDialog({
open,
onOpenChange,
onBeforeShutdown,
onAfterShutdown,
}: ShutdownDialogProps) {
const [stopping, setStopping] = useState(false);
@ -49,7 +50,7 @@ export function ShutdownDialog({
return;
}
onBeforeShutdown?.();
onAfterShutdown?.();
document.body.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;gap:12px">
<p style="font-size:1.1rem;font-weight:600;margin:0">Unsloth Studio has stopped.</p>

View file

@ -6,11 +6,73 @@ import { Moon, Sun } from "lucide-react"
import { flushSync } from "react-dom"
import { cn } from "@/lib/utils"
import { setTheme } from "@/features/settings/stores/theme-store"
interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> {
duration?: number
}
export function useAnimatedThemeToggle(duration = 400) {
const [isDark, setIsDark] = useState(false)
const anchorRef = useRef<HTMLElement | null>(null)
useEffect(() => {
const updateTheme = () => {
setIsDark(document.documentElement.classList.contains("dark"))
}
updateTheme()
const observer = new MutationObserver(updateTheme)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
})
return () => observer.disconnect()
}, [])
const toggleTheme = useCallback(async () => {
const anchor = anchorRef.current
const applyTheme = () => {
flushSync(() => {
const newTheme = !isDark
setIsDark(newTheme)
setTheme(newTheme ? "dark" : "light")
})
}
if (!document.startViewTransition) {
applyTheme()
return
}
await document.startViewTransition(applyTheme).ready
if (anchor) {
const { top, left, width, height } = anchor.getBoundingClientRect()
const x = left + width / 2
const y = top + height / 2
const maxRadius = Math.hypot(
Math.max(left, window.innerWidth - left),
Math.max(top, window.innerHeight - top)
)
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
}
)
}
}, [isDark, duration])
return { isDark, toggleTheme, anchorRef }
}
export const AnimatedThemeToggler = ({
className,
duration = 400,
@ -38,14 +100,20 @@ export const AnimatedThemeToggler = ({
const toggleTheme = useCallback(async () => {
if (!buttonRef.current) return
await document.startViewTransition(() => {
const apply = () => {
flushSync(() => {
const newTheme = !isDark
setIsDark(newTheme)
document.documentElement.classList.toggle("dark")
localStorage.setItem("theme", newTheme ? "dark" : "light")
setTheme(newTheme ? "dark" : "light")
})
}).ready
}
if (!document.startViewTransition) {
apply()
return
}
await document.startViewTransition(apply).ready
const { top, left, width, height } =
buttonRef.current.getBoundingClientRect()

View file

@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client";
import { Command as CommandPrimitive } from "cmdk";
@ -39,12 +39,14 @@ function CommandDialog({
description = "Search for a command to run...",
children,
className,
overlayClassName,
showCloseButton = false,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
overlayClassName?: string;
showCloseButton?: boolean;
}) {
return (
@ -55,9 +57,10 @@ function CommandDialog({
</DialogHeader>
<DialogContent
className={cn(
"rounded-4xl! p-0 top-1/3 translate-y-0 overflow-hidden p-0",
"rounded-4xl! top-1/3 translate-y-0 overflow-hidden p-0",
className,
)}
overlayClassName={overlayClassName}
showCloseButton={showCloseButton}
>
{children}

View file

@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"use client"
import * as React from "react"
@ -28,10 +28,9 @@ import { useIsMobile } from "@/hooks/use-mobile"
import { HugeiconsIcon } from "@hugeicons/react"
import { SidebarLeftIcon } from "@hugeicons/core-free-icons"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const noop = () => {}
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
@ -43,6 +42,10 @@ type SidebarContextProps = {
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
hasPinMode: boolean
pinned: boolean
setPinned: (value: boolean) => void
togglePinned: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
@ -60,6 +63,9 @@ function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
pinned: pinnedProp,
setPinned: setPinnedProp,
togglePinned: togglePinnedProp,
className,
style,
children,
@ -68,33 +74,57 @@ function SidebarProvider({
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
pinned?: boolean
setPinned?: (value: boolean) => void
togglePinned?: () => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const prevIsMobileRef = React.useRef(isMobile)
React.useEffect(() => {
if (prevIsMobileRef.current && !isMobile) {
setOpenMobile(false)
}
prevIsMobileRef.current = isMobile
}, [isMobile])
// Whether pin mode is active (caller provides pinned + setPinned + togglePinned).
const hasPinMode = pinnedProp !== undefined && setPinnedProp !== undefined && togglePinnedProp !== undefined
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
// When pin mode is active, open is driven entirely by `pinned` (explicit
// user toggle). Otherwise fall back to the controlled/uncontrolled pattern.
const open = hasPinMode ? !!pinnedProp : (openProp ?? _open)
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (hasPinMode) {
// In pin mode, setOpen controls pinned state.
setPinnedProp?.(openState)
return
}
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
[setOpenProp, open, hasPinMode, setPinnedProp]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
if (isMobile) return setOpenMobile((open) => !open)
if (hasPinMode && togglePinnedProp) return togglePinnedProp()
return setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile, hasPinMode, togglePinnedProp])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
@ -116,6 +146,10 @@ function SidebarProvider({
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const pinned = pinnedProp ?? false
const setPinned = setPinnedProp ?? noop
const togglePinned = togglePinnedProp ?? noop
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
@ -125,8 +159,12 @@ function SidebarProvider({
openMobile,
setOpenMobile,
toggleSidebar,
hasPinMode,
pinned,
setPinned,
togglePinned,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, hasPinMode, pinned, setPinned, togglePinned]
)
return (
@ -165,7 +203,7 @@ function Sidebar({
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { isMobile, state, openMobile, setOpenMobile, hasPinMode, pinned } = useSidebar()
if (collapsible === "none") {
return (
@ -190,12 +228,7 @@ function Sidebar({
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
className="bg-sidebar text-sidebar-foreground w-2/3 max-w-[18rem] p-0 [&>button]:hidden"
side={side}
>
<SheetHeader className="sr-only">
@ -210,7 +243,11 @@ function Sidebar({
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
className={cn(
"group peer text-sidebar-foreground relative shrink-0",
hasPinMode && pinned && "w-(--sidebar-width)",
hasPinMode && !pinned && "w-(--sidebar-width-icon)",
)}
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
@ -221,23 +258,45 @@ function Sidebar({
<div
data-slot="sidebar-gap"
className={cn(
"transition-[width] duration-200 ease-linear relative w-(--sidebar-width) bg-transparent",
"group-data-[collapsible=offcanvas]:w-0",
"relative bg-transparent shrink-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
hasPinMode
? cn(
// Pin mode: always push content. Expanded when pinned.
pinned
? "w-(--sidebar-width)"
: (variant === "floating" || variant === "inset"
? "w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "w-(--sidebar-width-icon)"),
)
: cn(
// Legacy mode: original shadcn behavior.
"w-(--sidebar-width)",
"group-data-[collapsible=offcanvas]:w-0",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
),
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
hasPinMode
? cn(
// Pin mode: always push content, full height.
"absolute top-0 bottom-0 flex w-(--sidebar-width) data-[side=left]:left-0",
"group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)
: cn(
// Legacy mode: fixed to viewport (original shadcn behavior).
"fixed inset-y-0 z-10 flex h-svh w-(--sidebar-width) data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
),
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
: !hasPinMode && "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
@ -245,7 +304,11 @@ function Sidebar({
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:ring-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 flex size-full flex-col"
className={cn(
"bg-sidebar flex size-full flex-col overflow-hidden border-r border-sidebar-border",
"group-data-[variant=floating]:ring-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1",
hasPinMode && "ring-1 ring-sidebar-border/60",
)}
>
{children}
</div>
@ -310,7 +373,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 relative flex w-full flex-1 flex-col",
"bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 relative flex min-h-0 w-full flex-1 flex-col",
className
)}
{...props}
@ -374,7 +437,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar gap-2 flex min-h-0 flex-1 flex-col overflow-auto group-data-[collapsible=icon]:overflow-hidden",
"gap-2 flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden group-data-[collapsible=icon]:overflow-hidden [&>*]:shrink-0",
className
)}
{...props}
@ -408,7 +471,7 @@ function SidebarGroupLabel({
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring h-8 rounded-md px-2 text-xs font-medium transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0",
"text-[#94a3b8] dark:text-[#64748b] ring-sidebar-ring h-auto pt-3 pb-2 px-4 rounded-md text-[10px] font-semibold uppercase tracking-[0.08em] group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-3 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0",
className
)}
{...props}
@ -455,7 +518,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("gap-1 flex w-full min-w-0 flex-col", className)}
className={cn("gap-0.5 flex w-full min-w-0 flex-col", className)}
{...props}
/>
)
@ -473,7 +536,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
"ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-lg corner-squircle p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium peer/menu-button flex w-full items-center overflow-hidden outline-hidden group/menu-button disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&_svg]:size-4 [&_svg]:shrink-0",
"ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm cursor-pointer transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:w-full! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium peer/menu-button flex w-full items-center overflow-hidden outline-hidden group/menu-button disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate group-data-[collapsible=icon]:[&>span]:hidden [&_svg]:size-4 [&_svg]:shrink-0 group-data-[collapsible=icon]:[&_svg]:size-5",
{
variants: {
variant: {

View file

@ -1,426 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { copyToClipboardAsync } from "@/lib/copy-to-clipboard";
import {
AlertCircleIcon,
Copy01Icon,
Delete02Icon,
Key01Icon,
Tick02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { authFetch } from "./api";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ApiKey {
id: number;
name: string;
key_prefix: string;
created_at: string;
last_used_at: string | null;
expires_at: string | null;
is_active: boolean;
}
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
async function fetchApiKeys(): Promise<ApiKey[]> {
const res = await authFetch("/api/auth/api-keys");
if (!res.ok) throw new Error("Failed to load API keys");
const data = (await res.json()) as { api_keys: ApiKey[] };
return data.api_keys;
}
async function createApiKey(
name: string,
expiresInDays: number | null,
): Promise<{ key: string; api_key: ApiKey }> {
const res = await authFetch("/api/auth/api-keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
expires_in_days: expiresInDays,
}),
});
if (!res.ok) throw new Error("Failed to create API key");
return res.json();
}
async function revokeApiKey(keyId: number): Promise<void> {
const res = await authFetch(`/api/auth/api-keys/${keyId}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to revoke API key");
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatDate(iso: string | null): string {
if (!iso) return "--";
const d = new Date(iso);
return d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const handleCopy = async () => {
if (!(await copyToClipboardAsync(text))) return;
setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 2000);
};
return (
<Button
variant="ghost"
size="icon-sm"
onClick={handleCopy}
className={cn(
"shrink-0 rounded-md text-muted-foreground hover:text-foreground",
copied && "text-emerald-600 hover:text-emerald-600",
)}
aria-label={copied ? "Copied API key" : "Copy API key"}
title={copied ? "Copied" : "Copy"}
>
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy01Icon}
className="size-4"
/>
</Button>
);
}
function RevealKeyDialog({
open,
rawKey,
onClose,
}: {
open: boolean;
rawKey: string;
onClose: () => void;
}) {
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>API Key Created</DialogTitle>
<DialogDescription>
Copy this key now. It will not be shown again.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 p-3">
<code className="min-w-0 flex-1 break-all font-mono text-sm">
{rawKey}
</code>
<CopyButton text={rawKey} />
</div>
<div className="flex items-start gap-2 rounded-md border border-amber-500/20 bg-amber-50 p-3 text-amber-800 dark:border-amber-400/20 dark:bg-amber-950/30 dark:text-amber-300">
<HugeiconsIcon icon={AlertCircleIcon} className="mt-0.5 size-4 shrink-0" />
<p className="text-xs leading-relaxed">
Store this key securely. You will not be able to see it again after closing this dialog.
</p>
</div>
<DialogFooter>
<Button onClick={onClose}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function CreateKeyForm({ onCreated }: { onCreated: (rawKey: string) => void }) {
const [name, setName] = useState("");
const [expiresInDays, setExpiresInDays] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setLoading(true);
try {
const days = expiresInDays ? parseInt(expiresInDays, 10) : null;
const result = await createApiKey(name.trim(), days);
onCreated(result.key);
setName("");
setExpiresInDays("");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 rounded-lg border border-border p-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="key-name">Key name</Label>
<Input
id="key-name"
placeholder="e.g. My application"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="key-expiry">Expires in (days)</Label>
<Input
id="key-expiry"
type="number"
min={1}
placeholder="Leave blank for no expiry"
value={expiresInDays}
onChange={(e) => setExpiresInDays(e.target.value)}
/>
</div>
<Button type="submit" disabled={loading || !name.trim()} className="self-start">
{loading ? "Creating..." : "Create API key"}
</Button>
</form>
);
}
function KeysTable({
keys,
onRevoke,
}: {
keys: ApiKey[];
onRevoke: (id: number) => void;
}) {
if (keys.length === 0) {
return (
<p className="py-8 text-center text-sm text-muted-foreground">
No API keys yet. Create one above.
</p>
);
}
return (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/40">
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Name</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Key</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Created</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Last used</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Expires</th>
<th className="px-4 py-2.5 text-right font-medium text-muted-foreground" />
</tr>
</thead>
<tbody>
{keys.map((k) => (
<tr
key={k.id}
className={cn(
"border-b border-border last:border-b-0",
!k.is_active && "opacity-50",
)}
>
<td className="px-4 py-2.5 font-medium">{k.name}</td>
<td className="px-4 py-2.5">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
sk-unsloth-{k.key_prefix}...
</code>
</td>
<td className="px-4 py-2.5 text-muted-foreground">{formatDate(k.created_at)}</td>
<td className="px-4 py-2.5 text-muted-foreground">{formatDate(k.last_used_at)}</td>
<td className="px-4 py-2.5 text-muted-foreground">{formatDate(k.expires_at)}</td>
<td className="px-4 py-2.5 text-right">
{k.is_active ? (
<Button
variant="ghost"
size="sm"
onClick={() => onRevoke(k.id)}
className="text-destructive hover:text-destructive"
>
<HugeiconsIcon icon={Delete02Icon} className="size-3.5 mr-1" />
Revoke
</Button>
) : (
<span className="text-xs text-muted-foreground">Revoked</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function UsageExamples() {
const base = window.location.origin;
const curlExample = `curl ${base}/v1/chat/completions \\
-H "Authorization: Bearer sk-unsloth-YOUR_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"messages": [{"role": "user", "content": "Hello"}],
"stream": true
}'`;
const pythonExample = `from openai import OpenAI
client = OpenAI(
base_url="${base}/v1",
api_key="sk-unsloth-YOUR_KEY",
)
response = client.chat.completions.create(
model="current",
messages=[{"role": "user", "content": "Hello"}],
stream=True,
)
for chunk in response:
print(chunk.choices[0].delta.content or "", end="")`;
const toolsExample = `curl ${base}/v1/chat/completions \\
-H "Authorization: Bearer sk-unsloth-YOUR_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"messages": [{"role": "user", "content": "Search for Python 3.13 features"}],
"stream": true,
"enable_tools": true,
"enabled_tools": ["web_search", "python"],
"session_id": "my-session"
}'`;
return (
<div className="flex flex-col gap-4">
<h3 className="text-sm font-semibold">Usage examples</h3>
<div className="flex flex-col gap-3">
<div>
<p className="mb-1.5 text-xs font-medium text-muted-foreground">curl</p>
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs leading-relaxed">
{curlExample}
</pre>
</div>
<div>
<p className="mb-1.5 text-xs font-medium text-muted-foreground">Python (OpenAI SDK)</p>
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs leading-relaxed">
{pythonExample}
</pre>
</div>
<div>
<p className="mb-1.5 text-xs font-medium text-muted-foreground">With tools (web search + code execution)</p>
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs leading-relaxed">
{toolsExample}
</pre>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export function ApiKeysPage() {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [revealedKey, setRevealedKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const loadKeys = useCallback(async () => {
try {
setError(null);
const loaded = await fetchApiKeys();
setKeys(loaded);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load API keys");
}
}, []);
useEffect(() => {
void loadKeys();
}, [loadKeys]);
const handleCreated = (rawKey: string) => {
setRevealedKey(rawKey);
void loadKeys();
};
const handleRevoke = async (keyId: number) => {
try {
await revokeApiKey(keyId);
void loadKeys();
} catch {
setError("Failed to revoke key");
}
};
return (
<DashboardLayout>
<div className="flex flex-col gap-8">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-border bg-muted/40">
<HugeiconsIcon icon={Key01Icon} className="size-5" />
</div>
<div>
<h1 className="text-xl font-bold font-heading">API Keys</h1>
<p className="text-sm text-muted-foreground">
Create keys to access Unsloth Studio programmatically via the OpenAI-compatible API.
</p>
</div>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/5 p-3 text-sm text-destructive">
<HugeiconsIcon icon={AlertCircleIcon} className="size-4 shrink-0" />
{error}
</div>
)}
<CreateKeyForm onCreated={handleCreated} />
<KeysTable keys={keys} onRevoke={handleRevoke} />
<UsageExamples />
</div>
<RevealKeyDialog
open={revealedKey !== null}
rawKey={revealedKey ?? ""}
onClose={() => setRevealedKey(null)}
/>
</DashboardLayout>
);
}

View file

@ -1,7 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export { ApiKeysPage } from "./api-keys-page";
export { LoginPage } from "./login-page";
export { ChangePasswordPage } from "./change-password-page";
export { authFetch, refreshSession } from "./api";

View file

@ -8,7 +8,7 @@ export const AUTH_REFRESH_TOKEN_KEY = "unsloth_auth_refresh_token";
export const ONBOARDING_DONE_KEY = "unsloth_onboarding_done";
export const AUTH_MUST_CHANGE_PASSWORD_KEY = "unsloth_auth_must_change_password";
type PostAuthRoute = "/onboarding" | "/studio" | "/change-password" | "/chat";
type PostAuthRoute = "/change-password" | "/chat";
function canUseStorage(): boolean {
return typeof window !== "undefined";
@ -80,5 +80,5 @@ export function resetOnboardingDone(): void {
export function getPostAuthRoute(): PostAuthRoute {
if (mustChangePassword()) return "/change-password";
if (usePlatformStore.getState().isChatOnly()) return "/chat";
return isOnboardingDone() ? "/studio" : "/onboarding";
return "/chat";
}

View file

@ -455,13 +455,13 @@ async function autoLoadSmallestModel(): Promise<{
// No cached models found — try downloading a small default GGUF
toast("Downloading a small model…", {
id: toastId,
description: "No downloaded models found. Fetching Qwen3.5-4B (UD-Q4_K_XL).",
description: "No downloaded models found. Fetching Gemma-4-E2B-it (UD-Q4_K_XL).",
duration: 30000,
});
try {
if (
!(await canAutoLoad({
model_path: "unsloth/Qwen3.5-4B-GGUF",
model_path: "unsloth/gemma-4-E2B-it-GGUF",
max_seq_length: 0,
is_lora: false,
gguf_variant: "UD-Q4_K_XL",
@ -471,7 +471,7 @@ async function autoLoadSmallestModel(): Promise<{
return { loaded: false, blockedByTrustRemoteCode };
}
const loadResp = await loadModel({
model_path: "unsloth/Qwen3.5-4B-GGUF",
model_path: "unsloth/gemma-4-E2B-it-GGUF",
hf_token: hfToken,
max_seq_length: 0,
load_in_4bit: true,
@ -479,20 +479,20 @@ async function autoLoadSmallestModel(): Promise<{
gguf_variant: "UD-Q4_K_XL",
trust_remote_code: trustRemoteCode,
});
useChatRuntimeStore.getState().setCheckpoint("unsloth/Qwen3.5-4B-GGUF", "UD-Q4_K_XL");
useChatRuntimeStore.getState().setCheckpoint("unsloth/gemma-4-E2B-it-GGUF", "UD-Q4_K_XL");
const store = useChatRuntimeStore.getState();
store.setModelRequiresTrustRemoteCode(
loadResp.requires_trust_remote_code ?? false,
);
store.setParams({ ...store.params, maxTokens: loadResp.context_length ?? 131072 });
const defaultModel: ChatModelSummary = {
id: "unsloth/Qwen3.5-4B-GGUF",
name: loadResp.display_name ?? "Qwen3.5-4B-GGUF",
id: "unsloth/gemma-4-E2B-it-GGUF",
name: loadResp.display_name ?? "gemma-4-E2B-it-GGUF",
isVision: loadResp.is_vision ?? false,
isLora: false,
isGguf: true,
};
if (!store.models.some((m) => m.id === "unsloth/Qwen3.5-4B-GGUF")) {
if (!store.models.some((m) => m.id === "unsloth/gemma-4-E2B-it-GGUF")) {
store.setModels([...store.models, defaultModel]);
}
useChatRuntimeStore.setState({
@ -509,7 +509,7 @@ async function autoLoadSmallestModel(): Promise<{
defaultChatTemplate: loadResp.chat_template ?? null,
chatTemplateOverride: null,
});
toast.success("Loaded Qwen3.5-4B (UD-Q4_K_XL)", { id: toastId });
toast.success("Loaded Gemma-4-E2B-it (UD-Q4_K_XL)", { id: toastId });
return { loaded: true, blockedByTrustRemoteCode: false };
} catch {
toast.dismiss(toastId);

View file

@ -262,6 +262,12 @@ export interface BrowseFoldersResponse {
model_files_here?: number;
}
export async function listRecommendedFolders(): Promise<string[]> {
const response = await authFetch("/api/models/recommended-folders");
const data = await parseJsonOrThrow<{ folders: string[] }>(response);
return data.folders;
}
export async function browseFolders(
path?: string,
showHidden = false,

View file

@ -7,36 +7,16 @@ import {
ModelSelector,
} from "@/components/assistant-ui/model-selector";
import { Thread } from "@/components/assistant-ui/thread";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
SidebarProvider,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { cn } from "@/lib/utils";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { useSidebar } from "@/components/ui/sidebar";
import {
ColumnInsertIcon,
PencilEdit02Icon,
Settings04Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate, useSearch } from "@tanstack/react-router";
import {
type CSSProperties,
type ReactElement,
type ReactNode,
memo,
useCallback,
useEffect,
@ -45,6 +25,7 @@ import {
useState,
} from "react";
import { toast } from "sonner";
import type { ChatSearch } from "@/app/routes/chat";
import { listLocalModels } from "./api/chat-api";
import { ChatSettingsPanel } from "./chat-settings-sheet";
import { ContextUsageBar } from "./components/context-usage-bar";
@ -63,7 +44,6 @@ import {
SharedComposer,
} from "./shared-composer";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import { ThreadSidebar } from "./thread-sidebar";
import { buildChatTourSteps } from "./tour";
import type { ChatView, MessageRecord } from "./types";
@ -135,7 +115,7 @@ const SingleContent = memo(function SingleContent({
initialThreadId={threadId}
newThreadNonce={newThreadNonce}
>
<div className="min-h-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread />
</div>
</ChatRuntimeProvider>
@ -223,7 +203,7 @@ const LoraCompareContent = memo(function LoraCompareContent({
Base Model
</span>
</div>
<div className="min-h-0 flex-1">
<div className="flex min-h-0 flex-1 flex-col">
<ChatRuntimeProvider
modelType="base"
pairId={pairId}
@ -231,7 +211,9 @@ const LoraCompareContent = memo(function LoraCompareContent({
syncActiveThreadId={false}
>
<RegisterCompareHandle name="base" />
<Thread hideComposer={true} hideWelcome={true} />
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread hideComposer={true} hideWelcome={true} />
</div>
</ChatRuntimeProvider>
</div>
</div>
@ -241,7 +223,7 @@ const LoraCompareContent = memo(function LoraCompareContent({
Fine-tuned
</span>
</div>
<div className="min-h-0 flex-1">
<div className="flex min-h-0 flex-1 flex-col">
<ChatRuntimeProvider
modelType="lora"
pairId={pairId}
@ -249,12 +231,14 @@ const LoraCompareContent = memo(function LoraCompareContent({
syncActiveThreadId={false}
>
<RegisterCompareHandle name="lora" />
<Thread hideComposer={true} hideWelcome={true} />
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread hideComposer={true} hideWelcome={true} />
</div>
</ChatRuntimeProvider>
</div>
</div>
</div>
<div className="z-20 mx-auto w-full max-w-4xl shrink-0 border-t border-border/60 bg-background px-4 pt-2 pb-4">
<div className="z-20 mx-auto w-full max-w-4xl shrink-0 bg-background px-4 pt-2 pb-4">
<SharedComposer handlesRef={handlesRef} />
</div>
</div>
@ -322,10 +306,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
className="grid min-h-0 flex-1 grid-cols-1 px-0 md:grid-cols-2"
>
<div className="flex min-h-0 flex-col">
<div className="flex items-center gap-2 px-3 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Model 1
</span>
<div className="flex h-11 shrink-0 items-center gap-2 px-3">
<ModelSelector
models={models}
loraModels={loraModels}
@ -340,10 +321,10 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
onFoldersChange={onFoldersChange}
variant="ghost"
size="sm"
className="max-w-[50%]"
className="max-w-[80%]"
/>
</div>
<div className="min-h-0 flex-1">
<div className="flex min-h-0 flex-1 flex-col">
<ChatRuntimeProvider
modelType="model1"
pairId={pairId}
@ -351,15 +332,14 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
syncActiveThreadId={false}
>
<RegisterCompareHandle name="model1" />
<Thread hideComposer={true} hideWelcome={true} />
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread hideComposer={true} hideWelcome={true} />
</div>
</ChatRuntimeProvider>
</div>
</div>
<div className="flex min-h-0 flex-col border-t border-border/60 md:border-t-0 md:border-l">
<div className="flex items-center gap-2 px-3 py-1.5 md:justify-end">
<span className="text-[10px] font-semibold uppercase tracking-wider text-primary">
Model 2
</span>
<div className="flex min-h-0 flex-col border-t border-sidebar-border md:border-t-0 md:border-l">
<div className="flex h-11 shrink-0 items-center gap-2 px-3">
<ModelSelector
models={models}
loraModels={loraModels}
@ -374,10 +354,10 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
onFoldersChange={onFoldersChange}
variant="ghost"
size="sm"
className="max-w-[50%]"
className="max-w-[80%]"
/>
</div>
<div className="min-h-0 flex-1">
<div className="flex min-h-0 flex-1 flex-col">
<ChatRuntimeProvider
modelType="model2"
pairId={pairId}
@ -385,12 +365,14 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
syncActiveThreadId={false}
>
<RegisterCompareHandle name="model2" />
<Thread hideComposer={true} hideWelcome={true} />
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread hideComposer={true} hideWelcome={true} />
</div>
</ChatRuntimeProvider>
</div>
</div>
</div>
<div className="z-20 mx-auto w-full max-w-4xl shrink-0 border-t border-border/60 bg-background px-4 pt-2 pb-4">
<div className="z-20 mx-auto w-full max-w-4xl shrink-0 bg-background px-4 pt-2 pb-4">
<SharedComposer
handlesRef={handlesRef}
model1={model1}
@ -402,110 +384,19 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
);
});
function InlineSidebar({
children,
side = "left",
}: {
children: ReactNode;
side?: "left" | "right";
}) {
const { state, isMobile, openMobile, setOpenMobile } = useSidebar();
const collapsed = state === "collapsed";
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile}>
<SheetContent side={side} className="w-[18rem] p-0">
<SheetHeader className="sr-only">
<SheetTitle>Chat sidebar</SheetTitle>
<SheetDescription>Chat threads and actions</SheetDescription>
</SheetHeader>
<div className="h-full overflow-auto">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group shrink-0 h-full pb-3.5"
data-state={state}
data-collapsible={collapsed ? "offcanvas" : ""}
data-side={side}
>
<aside
data-sidebar="sidebar"
className={cn(
"bg-muted/70 text-sidebar-foreground h-full overflow-hidden rounded-2xl corner-squircle transition-[width] duration-200 ease-linear",
!collapsed && side === "right" && "border-l border-sidebar-border/70",
collapsed ? "w-0" : "w-(--sidebar-width)",
)}
>
<div className="flex h-full w-(--sidebar-width) flex-col">
{children}
</div>
</aside>
</div>
);
}
function TopBarActions({
onNewThread,
onNewCompare,
showCompare,
}: {
onNewThread: () => void;
onNewCompare: () => void;
showCompare: boolean;
}) {
const { state } = useSidebar();
if (state !== "collapsed") {
return null;
}
return (
<>
<Tooltip>
<TooltipTrigger asChild={true}>
<Button variant="ghost" size="icon-sm" onClick={onNewThread}>
<HugeiconsIcon icon={PencilEdit02Icon} strokeWidth={2} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">New Chat</TooltipContent>
</Tooltip>
{showCompare ? (
<Tooltip>
<TooltipTrigger asChild={true}>
<Button variant="ghost" size="icon-sm" onClick={onNewCompare}>
<HugeiconsIcon icon={ColumnInsertIcon} strokeWidth={2} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Compare</TooltipContent>
</Tooltip>
) : null}
</>
);
}
function getInitialSingleChatView(): ChatView {
const id = useChatRuntimeStore.getState().activeThreadId;
if (typeof id === "string" && id.length > 0 && !id.startsWith("__LOCALID_")) {
return { mode: "single", threadId: id };
}
return { mode: "single" };
}
export function ChatPage(): ReactElement {
// Do not set newThreadNonce here: each /chat mount would run ThreadNewChatSwitch
// and create spurious threads when navigating (e.g. Recipes / Export). New Chat
// explicitly sets a nonce in handleNewThread.
const [view, setView] = useState<ChatView>(getInitialSingleChatView);
const [settingsOpen, setSettingsOpen] = useState(false);
const search = useSearch({ from: "/chat" });
const navigate = useNavigate();
const settingsOpen = useChatRuntimeStore((s) => s.settingsPanelOpen);
const setSettingsOpen = useChatRuntimeStore((s) => s.setSettingsPanelOpen);
useEffect(() => {
return () => setSettingsOpen(false);
}, [setSettingsOpen]);
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [viewBeforeCompare, setViewBeforeCompare] = useState<ChatView | null>(
null,
);
const viewBeforeCompareRef = useRef<ChatSearch | null>(null);
const inferenceParams = useChatRuntimeStore((state) => state.params);
const setInferenceParams = useChatRuntimeStore((state) => state.setParams);
const activeGgufVariant = useChatRuntimeStore(
@ -515,8 +406,6 @@ export function ChatPage(): ReactElement {
(state) => state.ggufContextLength,
);
const contextUsage = useChatRuntimeStore((state) => state.contextUsage);
const autoTitle = useChatRuntimeStore((state) => state.autoTitle);
const setAutoTitle = useChatRuntimeStore((state) => state.setAutoTitle);
const modelsFromStore = useChatRuntimeStore((state) => state.models);
const lorasFromStore = useChatRuntimeStore((state) => state.loras);
const modelsError = useChatRuntimeStore((state) => state.modelsError);
@ -541,6 +430,27 @@ export function ChatPage(): ReactElement {
return Boolean(inferenceParams.checkpoint);
}, [inferenceParams.checkpoint]);
// Derive view from URL search params
const view = useMemo<ChatView>(() => {
if (search.compare) {
return {
mode: "compare",
pairId:
search.compare,
};
}
if (search.thread) {
return { mode: "single", threadId: search.thread };
}
if (activeThreadId && !activeThreadId.startsWith("__LOCALID_")) {
return { mode: "single", threadId: activeThreadId };
}
if (search.new) {
return { mode: "single", newThreadNonce: search.new };
}
return { mode: "single" };
}, [search.thread, search.compare, search.new, activeThreadId]);
const handleCheckpointChange = useCallback(
(
value: string,
@ -601,28 +511,6 @@ export function ChatPage(): ReactElement {
const handleEject = useCallback(() => {
void ejectModel();
}, [ejectModel]);
const handleNewThread = useCallback(() => {
// Skip if we are already on a fresh unsaved draft with no messages sent.
// Once the user sends a message, append() sets activeThreadId in the store,
// so we check the store to know whether the current draft has been sent.
if (
view.mode === "single" &&
!view.threadId &&
!useChatRuntimeStore.getState().activeThreadId
) {
return;
}
useChatRuntimeStore.getState().setActiveThreadId(null);
setView({ mode: "single", newThreadNonce: crypto.randomUUID() });
}, [view]);
const handleNewCompare = useCallback(() => {
setView({ mode: "compare", pairId: crypto.randomUUID() });
// Clear activeThreadId so compare panes do not inherit the single-chat
// thread ID as a fallback for session_id routing.
useChatRuntimeStore.getState().setActiveThreadId(null);
useChatRuntimeStore.getState().setContextUsage(null);
}, []);
const openModelSelector = useCallback(() => {
setModelSelectorLocked(true);
@ -641,30 +529,26 @@ export function ChatPage(): ReactElement {
},
[modelSelectorLocked],
);
const openSettings = useCallback(() => setSettingsOpen(true), []);
const closeSettings = useCallback(() => setSettingsOpen(false), []);
const openSidebar = useCallback(() => setSidebarOpen(true), []);
const openSettings = useCallback(() => setSettingsOpen(true), [setSettingsOpen]);
const closeSettings = useCallback(() => setSettingsOpen(false), [setSettingsOpen]);
const { setPinned, isMobile } = useSidebar();
const openSidebar = useCallback(() => setPinned(true), [setPinned]);
const enterCompare = useCallback(() => {
setViewBeforeCompare((prev) => prev ?? view);
setView({ mode: "compare", pairId: crypto.randomUUID() });
// Clear activeThreadId so compare panes do not inherit the single-chat
// thread ID as a fallback for session_id routing.
viewBeforeCompareRef.current = { ...search };
useChatRuntimeStore.getState().setActiveThreadId(null);
useChatRuntimeStore.getState().setContextUsage(null);
}, [view]);
navigate({ to: "/chat", search: { compare: crypto.randomUUID() } });
}, [navigate, search]);
const exitCompare = useCallback(() => {
if (!viewBeforeCompare) return;
setView(viewBeforeCompare);
setViewBeforeCompare(null);
const saved = viewBeforeCompareRef.current;
if (!saved) return;
viewBeforeCompareRef.current = null;
navigate({ to: "/chat", search: saved });
// Restore context usage from the active thread's last assistant message.
// Use the thread ID from the saved view rather than the store, because
// activeThreadId may have been cleared on compare entry.
const store = useChatRuntimeStore.getState();
const threadId =
("threadId" in viewBeforeCompare ? viewBeforeCompare.threadId : null) ??
store.activeThreadId;
saved.thread ?? useChatRuntimeStore.getState().activeThreadId;
if (threadId) {
void db.messages
.where("threadId")
@ -672,18 +556,12 @@ export function ChatPage(): ReactElement {
.reverse()
.first()
.then((msg) => {
const saved = msg?.metadata as Record<string, unknown> | undefined;
const usage = saved?.contextUsage as
| typeof store.contextUsage
| undefined;
if (usage) store.setContextUsage(usage);
const metadata = msg?.metadata as Record<string, unknown> | undefined;
const usage = metadata?.contextUsage as ReturnType<typeof useChatRuntimeStore.getState>["contextUsage"];
if (usage) useChatRuntimeStore.getState().setContextUsage(usage);
});
}
}, [viewBeforeCompare]);
const handleThreadSelect = useCallback((nextView: ChatView) => {
setView(nextView);
}, []);
}, [navigate]);
const models = useMemo<ModelOption[]>(
() =>
@ -727,7 +605,7 @@ export function ChatPage(): ReactElement {
);
})
.catch(() => {});
}, []);
}, [navigate]);
const loraModels = useMemo<LoraModelOption[]>(() => {
const fromLoras = lorasFromStore.map((lora) => ({
@ -771,9 +649,9 @@ export function ChatPage(): ReactElement {
});
await selectModelRef.current({ id: targetLora.id, isLora: true });
if (canceled) return;
setView({ mode: "compare", pairId: crypto.randomUUID() });
useChatRuntimeStore.getState().setActiveThreadId(null);
useChatRuntimeStore.getState().setContextUsage(null);
navigate({ to: "/chat", search: { compare: crypto.randomUUID() } });
clearHandoff();
console.info("[chat-handoff] loaded lora + opened compare");
return;
@ -851,39 +729,18 @@ export function ChatPage(): ReactElement {
}, [modelSelectorLocked, tour.open]);
return (
<div className="h-[calc(100dvh-4rem)] bg-background overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 basis-0 bg-background overflow-hidden">
<GuidedTour {...tour.tourProps} />
<SidebarProvider
defaultOpen={true}
open={sidebarOpen}
onOpenChange={setSidebarOpen}
className="!min-h-0 h-full w-full max-w-7xl mx-auto px-2 sm:px-4"
style={
{
"--sidebar-width": "14rem",
"--sidebar-width-icon": "3rem",
} as CSSProperties
}
>
<InlineSidebar>
<ThreadSidebar
view={view}
onSelect={handleThreadSelect}
onNewThread={handleNewThread}
onNewCompare={handleNewCompare}
showCompare={canCompare}
/>
</InlineSidebar>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex h-11 shrink-0 items-center px-1.5 sm:px-2">
<div className="flex items-center gap-1">
<SidebarTrigger />
<TopBarActions
onNewThread={handleNewThread}
onNewCompare={handleNewCompare}
showCompare={canCompare}
/>
<div className="relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<div
className={cn(
"absolute top-0 left-0 right-2 z-30 flex h-11 shrink-0 items-center pr-2 bg-background",
isMobile ? "pl-12 pr-1.5" : "pl-2",
view.mode === "compare" && "right-2 left-auto w-auto bg-transparent pl-0 pr-2",
)}
>
<div className="flex items-center gap-1">
{view.mode !== "compare" && (
<ModelSelector
models={models}
loraModels={loraModels}
@ -899,92 +756,90 @@ export function ChatPage(): ReactElement {
contentDataTour="chat-model-selector-popover"
className="max-w-[62vw] sm:max-w-none"
/>
{loadingModel && loadToastDismissed ? (
<ModelLoadInlineStatus
label={
loadProgress?.phase === "starting"
? "Starting model…"
: loadingModel.isDownloaded || loadingModel.isCachedLora
? "Loading model…"
: "Downloading model…"
}
title={
loadingModel.isDownloaded
? `Loading ${loadingModel.displayName} from cache.`
: loadingModel.isCachedLora
? `Loading ${loadingModel.displayName} into memory.`
: `Loading ${loadingModel.displayName}. This may include downloading.`
}
progressPercent={loadProgress?.percent}
progressLabel={loadProgress?.label}
onStop={cancelLoading}
/>
) : null}
</div>
{modelsError && (
<div className="ml-2 text-xs text-destructive truncate max-w-[28rem]">
{modelsError}
</div>
)}
<div className="flex-1" />
{view.mode === "single" && ggufContextLength && contextUsage ? (
<ContextUsageBar
used={contextUsage.totalTokens}
total={ggufContextLength}
cached={contextUsage.cachedTokens}
promptTokens={contextUsage.promptTokens}
completionTokens={contextUsage.completionTokens}
{loadingModel && loadToastDismissed ? (
<ModelLoadInlineStatus
label={
loadProgress?.phase === "starting"
? "Starting model…"
: loadingModel.isDownloaded || loadingModel.isCachedLora
? "Loading model…"
: "Downloading model…"
}
title={
loadingModel.isDownloaded
? `Loading ${loadingModel.displayName} from cache.`
: loadingModel.isCachedLora
? `Loading ${loadingModel.displayName} into memory.`
: `Loading ${loadingModel.displayName}. This may include downloading.`
}
progressPercent={loadProgress?.percent}
progressLabel={loadProgress?.label}
onStop={cancelLoading}
/>
) : null}
<button
type="button"
onClick={() => setSettingsOpen((o) => !o)}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Inference settings"
data-tour="chat-settings"
>
<HugeiconsIcon icon={Settings04Icon} className="size-5" />
</button>
</div>
{view.mode === "single" ? (
<SingleContent
key={view.threadId ?? "single"}
threadId={view.threadId}
newThreadNonce={view.newThreadNonce}
/>
) : (
<CompareContent
key={view.pairId}
pairId={view.pairId}
models={models}
loraModels={loraModels}
onFoldersChange={refreshLocalModels}
/>
{modelsError && (
<div className="ml-2 text-xs text-destructive truncate max-w-[28rem]">
{modelsError}
</div>
)}
<div className="flex-1" />
{view.mode === "single" && ggufContextLength && contextUsage ? (
<ContextUsageBar
used={contextUsage.totalTokens}
total={ggufContextLength}
cached={contextUsage.cachedTokens}
promptTokens={contextUsage.promptTokens}
completionTokens={contextUsage.completionTokens}
/>
) : null}
<button
type="button"
onClick={() => setSettingsOpen(!settingsOpen)}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Inference settings"
data-tour="chat-settings"
>
<HugeiconsIcon icon={Settings04Icon} className="size-5" />
</button>
</div>
<ChatSettingsPanel
open={settingsOpen}
onOpenChange={setSettingsOpen}
params={inferenceParams}
onParamsChange={setInferenceParams}
autoTitle={autoTitle}
onAutoTitleChange={setAutoTitle}
onReloadModel={() => {
const state = useChatRuntimeStore.getState();
if (state.params.checkpoint) {
selectModel({
id: state.params.checkpoint,
ggufVariant: state.activeGgufVariant ?? undefined,
forceReload: true,
isDownloaded: true,
loadingDescription: "Reloading with updated chat template.",
});
}
}}
/>
</SidebarProvider>
{view.mode === "single" ? (
<SingleContent
key={view.threadId ?? "single"}
threadId={view.threadId}
newThreadNonce={view.newThreadNonce}
/>
) : (
<CompareContent
key={view.pairId}
pairId={view.pairId}
models={models}
loraModels={loraModels}
onFoldersChange={refreshLocalModels}
/>
)}
</div>
<ChatSettingsPanel
open={settingsOpen}
onOpenChange={setSettingsOpen}
params={inferenceParams}
onParamsChange={setInferenceParams}
onReloadModel={() => {
const state = useChatRuntimeStore.getState();
if (state.params.checkpoint) {
selectModel({
id: state.params.checkpoint,
ggufVariant: state.activeGgufVariant ?? undefined,
forceReload: true,
isDownloaded: true,
loadingDescription: "Reloading with updated chat template.",
});
}
}}
/>
</div>
);
}

View file

@ -55,7 +55,6 @@ import {
PencilEdit01Icon,
Settings02Icon,
SlidersHorizontalIcon,
UserSettings01Icon,
Wrench01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
@ -476,8 +475,6 @@ interface ChatSettingsPanelProps {
onOpenChange?: (open: boolean) => void;
params: InferenceParams;
onParamsChange: (params: InferenceParams) => void;
autoTitle: boolean;
onAutoTitleChange: (enabled: boolean) => void;
onReloadModel?: () => void;
}
@ -486,8 +483,6 @@ export function ChatSettingsPanel({
onOpenChange,
params,
onParamsChange,
autoTitle,
onAutoTitleChange,
onReloadModel,
}: ChatSettingsPanelProps) {
const isMobile = useIsMobile();
@ -743,7 +738,8 @@ export function ChatSettingsPanel({
const settingsContent = (
<>
<div className="flex items-center gap-2 px-4 py-3">
<div className="aui-thread-viewport relative h-full overflow-y-auto bg-muted/70">
<div className="sticky top-0 z-10 flex items-center gap-2 bg-muted/70 px-4 py-3 backdrop-blur">
<HugeiconsIcon
icon={PencilEdit01Icon}
className="size-4 text-muted-foreground/70"
@ -753,7 +749,7 @@ export function ChatSettingsPanel({
</span>
</div>
<div className="flex-1 overflow-y-auto px-1.5">
<div className="px-1.5">
{/* mt-4 matches the Playground sidebar gap (SidebarHeader py-3 + SidebarGroup pt-1) */}
<div className="mt-4 px-2 pb-3">
<div className="space-y-1.5">
@ -893,7 +889,7 @@ export function ChatSettingsPanel({
value={params.systemPrompt}
onChange={(e) => set("systemPrompt")(e.target.value)}
placeholder="You are a helpful assistant..."
className="min-h-20 text-xs corner-squircle"
className="min-h-20 max-h-48 overflow-y-auto text-xs corner-squircle"
rows={3}
/>
</div>
@ -1174,27 +1170,9 @@ export function ChatSettingsPanel({
</div>
</CollapsibleSection>
<CollapsibleSection
icon={UserSettings01Icon}
label="Preferences"
defaultOpen={true}
>
<div className="flex flex-col gap-3 py-1">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium">Auto title</div>
<div className="text-[11px] text-muted-foreground">
Generate short title after reply.
</div>
</div>
<Switch checked={autoTitle} onCheckedChange={onAutoTitleChange} />
</div>
<HfTokenField />
</div>
</CollapsibleSection>
<ChatTemplateSection onReloadModel={onReloadModel} />
</div>
</div>
<Dialog
open={systemPromptEditorOpen}
onOpenChange={(nextOpen) => {
@ -1224,7 +1202,7 @@ export function ChatSettingsPanel({
value={systemPromptDraft}
onChange={(event) => setSystemPromptDraft(event.target.value)}
placeholder="You are a helpful assistant..."
className="min-h-[24rem] text-sm leading-6 corner-squircle"
className="min-h-[24rem] max-h-[50vh] overflow-y-auto text-sm leading-6 corner-squircle"
rows={14}
/>
</div>
@ -1268,9 +1246,9 @@ export function ChatSettingsPanel({
return (
<aside
className={`shrink-0 self-start h-[calc(100%-0.875rem)] overflow-hidden bg-muted/70 rounded-2xl corner-squircle transition-[width] duration-200 ease-linear ${open ? "w-[17rem] border-l border-sidebar-border/70" : "w-0"}`}
className={`relative z-50 shrink-0 h-full overflow-hidden bg-muted/70 transition-[width] duration-200 ease-linear ${open ? "w-[17rem]" : "w-0"}`}
>
<div className="flex h-full w-[17rem] flex-col">{settingsContent}</div>
<div className="h-full w-[17rem]">{settingsContent}</div>
</aside>
);
}
@ -1348,29 +1326,6 @@ function AutoHealToolCallsToggle() {
);
}
function HfTokenField() {
const hfToken = useChatRuntimeStore((s) => s.hfToken);
const setHfToken = useChatRuntimeStore((s) => s.setHfToken);
return (
<div className="flex flex-col gap-1.5">
<div className="min-w-0">
<div className="text-xs font-medium">Hugging Face Token</div>
<div className="text-[11px] text-muted-foreground">
For downloading gated or private models.
</div>
</div>
<Input
type="password"
value={hfToken}
placeholder="hf_..."
className="h-7 text-xs font-mono"
onChange={(e) => setHfToken(e.target.value)}
/>
</div>
);
}
function ChatTemplateSection({
onReloadModel,
}: {
@ -1391,7 +1346,7 @@ function ChatTemplateSection({
<Textarea
value={displayValue}
onChange={(e) => setOverride(e.target.value)}
className="min-h-32 font-mono text-[10px] leading-relaxed md:text-[10px] corner-squircle"
className="min-h-32 max-h-64 overflow-y-auto font-mono text-[10px] leading-relaxed md:text-[10px] corner-squircle"
rows={6}
spellCheck={false}
/>

View file

@ -0,0 +1,120 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandList,
} from "@/components/ui/command";
import { useTrainingRuntimeStore } from "@/features/training";
import { Cancel01Icon, Message01Icon, SearchIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate } from "@tanstack/react-router";
import { Command as CommandPrimitive } from "cmdk";
import { useEffect } from "react";
import { useChatSearchIndex } from "../hooks/use-chat-search-index";
import { useChatSearchStore } from "../stores/chat-search-store";
function formatRelative(createdAt: number): string {
const diff = Date.now() - createdAt;
const day = 86_400_000;
if (diff < day) return "Today";
if (diff < 7 * day) return "Past week";
if (diff < 30 * day) return "Past month";
return "Older";
}
export function ChatSearchDialog() {
const isOpen = useChatSearchStore((s) => s.isOpen);
const setOpen = useChatSearchStore((s) => s.setOpen);
const close = useChatSearchStore((s) => s.close);
const navigate = useNavigate();
const { items, loading } = useChatSearchIndex(isOpen);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== "k") return;
if (useTrainingRuntimeStore.getState().isTrainingRunning) return;
const el = document.activeElement as HTMLElement | null;
const tag = el?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || el?.isContentEditable) return;
e.preventDefault();
useChatSearchStore.getState().open();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
return (
<CommandDialog
open={isOpen}
onOpenChange={setOpen}
className="shadow-border corner-squircle w-[635px] max-w-[calc(100%-2rem)] gap-0 p-0 sm:max-w-[635px]"
overlayClassName="bg-transparent"
>
<Command className="rounded-none p-0">
<div className="flex items-center gap-3 border-b border-border/40 px-4 py-3">
<HugeiconsIcon
icon={SearchIcon}
strokeWidth={2}
className="size-4 shrink-0 text-muted-foreground"
/>
<CommandPrimitive.Input
placeholder="Search chats..."
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<button
type="button"
onClick={close}
className="flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Close"
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="size-4" />
</button>
</div>
<CommandList className="max-h-[420px] p-1">
<CommandEmpty className="py-6 text-center text-xs text-muted-foreground">
{loading
? "Loading…"
: items.length === 0
? "No chats yet."
: "No chats match."}
</CommandEmpty>
<CommandGroup className="p-0">
{items.map((item) => (
<CommandPrimitive.Item
key={item.id}
value={`${item.title} ${item.preview}`}
onSelect={() => {
navigate({
to: "/chat",
search:
item.type === "single"
? { thread: item.id }
: { compare: item.id },
});
close();
}}
className="relative flex cursor-default select-none items-center gap-3 rounded-lg px-3 py-2.5 text-sm outline-hidden data-selected:bg-muted data-selected:text-foreground"
>
<HugeiconsIcon
icon={Message01Icon}
strokeWidth={2}
className="size-4 shrink-0 text-muted-foreground"
/>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">
{item.title || "Untitled chat"}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{formatRelative(item.createdAt)}
</span>
</CommandPrimitive.Item>
))}
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
);
}

View file

@ -289,11 +289,11 @@ export function useChatModelRuntime() {
loadedSpeculativeType: currentSpecType,
});
// Set reasoning default for Qwen3.5 small models
// Set reasoning default for Qwen3.5/3.6 small models
if (supportsReasoning) {
let reasoningDefault = true;
const mid = statusRes.active_model.toLowerCase();
if (mid.includes("qwen3.5")) {
if (mid.includes("qwen3.5") || mid.includes("qwen3.6")) {
const sizeMatch = mid.match(/(\d+\.?\d*)\s*b/);
if (sizeMatch && parseFloat(sizeMatch[1]) < 9) {
reasoningDefault = false;
@ -437,9 +437,10 @@ export function useChatModelRuntime() {
const { chatTemplateOverride, kvCacheDtype, customContextLength, ggufContextLength, speculativeType } = useChatRuntimeStore.getState();
// GGUF: use custom context length, or 0 = model's native context
// Non-GGUF: use the Max Seq Length slider value
const isDirectGgufFile = modelId.toLowerCase().endsWith(".gguf");
const effectiveMaxSeqLength = customContextLength != null
? customContextLength
: ggufVariant != null ? (ggufContextLength ?? 0) : maxSeqLength;
: (ggufVariant != null || isDirectGgufFile) ? (ggufContextLength ?? 0) : maxSeqLength;
const loadResponse = await loadModel({
model_path: modelId,
hf_token: hfToken,
@ -461,11 +462,11 @@ export function useChatModelRuntime() {
setParams(
mergeRecommendedInference(currentParams, loadResponse, modelId),
);
// Qwen3.5 small models (0.8B, 2B, 4B, 9B) disable thinking by default
// Qwen3.5/3.6 small models (0.8B, 2B, 4B, 9B) disable thinking by default
let reasoningDefault = loadResponse.supports_reasoning ?? false;
if (reasoningDefault) {
const mid = modelId.toLowerCase();
if (mid.includes("qwen3.5")) {
if (mid.includes("qwen3.5") || mid.includes("qwen3.6")) {
const sizeMatch = mid.match(/(\d+\.?\d*)\s*b/);
if (sizeMatch && parseFloat(sizeMatch[1]) < 9) {
reasoningDefault = false;
@ -508,12 +509,14 @@ export function useChatModelRuntime() {
defaultChatTemplate: loadResponse.chat_template ?? null,
chatTemplateOverride: null,
});
// Qwen3/3.5: apply thinking-mode-specific params after load
// Qwen3/3.5/3.6: apply thinking-mode-specific params after load
if (modelId.toLowerCase().includes("qwen3") && (loadResponse.supports_reasoning ?? false)) {
const store = useChatRuntimeStore.getState();
const mid = modelId.toLowerCase();
const needsPresencePenalty = mid.includes("qwen3.5") || mid.includes("qwen3.6");
const p = reasoningDefault
? { temperature: 0.6, topP: 0.95, topK: 20, minP: 0.0 }
: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0.0 };
? { temperature: 0.6, topP: 0.95, topK: 20, minP: 0.0, ...(needsPresencePenalty ? { presencePenalty: 1.5 } : {}) }
: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0.0, ...(needsPresencePenalty ? { presencePenalty: 1.5 } : {}) };
store.setParams({ ...store.params, ...p });
}
await refresh();

View file

@ -0,0 +1,156 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useEffect, useState } from "react";
import { db } from "../db";
import type { MessageRecord, ThreadRecord } from "../types";
export interface ChatSearchItem {
type: "single" | "compare";
id: string;
title: string;
preview: string;
createdAt: number;
}
const THREAD_LIMIT = 200;
const PREVIEW_MAX = 120;
function extractText(message: MessageRecord): string {
const content = message.content;
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const part of content) {
if (!part || typeof part !== "object") continue;
const p = part as { type?: string; text?: unknown };
if ((p.type === "text" || p.type === "reasoning") && typeof p.text === "string") {
parts.push(p.text);
}
}
return parts.join(" ").replace(/\s+/g, " ").trim();
}
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(0, max).trimEnd() + "…";
}
async function buildIndex(): Promise<ChatSearchItem[]> {
// Fetch all threads newest-first, filter archived in JS, then take top N.
// `archived` is a boolean which Dexie does not index reliably, so we filter
// after the sort instead of using `.where("archived")`.
const all = (await db.threads
.orderBy("createdAt")
.reverse()
.toArray()) as ThreadRecord[];
const active = all.filter((t) => !t.archived).slice(0, THREAD_LIMIT);
const itemThreadIds = new Map<
string,
{ item: Omit<ChatSearchItem, "preview">; threadIds: string[] }
>();
const seenPairs = new Set<string>();
for (const t of active) {
if (t.pairId) {
if (seenPairs.has(t.pairId)) {
const existing = itemThreadIds.get(t.pairId);
if (existing) existing.threadIds.push(t.id);
continue;
}
seenPairs.add(t.pairId);
itemThreadIds.set(t.pairId, {
item: {
type: "compare",
id: t.pairId,
title: t.title,
createdAt: t.createdAt,
},
threadIds: [t.id],
});
} else {
itemThreadIds.set(t.id, {
item: {
type: "single",
id: t.id,
title: t.title,
createdAt: t.createdAt,
},
threadIds: [t.id],
});
}
}
// One query for all messages across all relevant threads, then group by
// threadId in memory. Avoids N sequential awaits.
const allThreadIds = Array.from(itemThreadIds.values()).flatMap(
(e) => e.threadIds,
);
const messages = (await db.messages
.where("threadId")
.anyOf(allThreadIds)
.toArray()) as MessageRecord[];
const byThreadId = new Map<string, MessageRecord[]>();
for (const m of messages) {
const arr = byThreadId.get(m.threadId);
if (arr) arr.push(m);
else byThreadId.set(m.threadId, [m]);
}
const results: ChatSearchItem[] = [];
for (const { item, threadIds } of itemThreadIds.values()) {
const merged: MessageRecord[] = [];
for (const tid of threadIds) {
const arr = byThreadId.get(tid);
if (arr) merged.push(...arr);
}
merged.sort((a, b) => b.createdAt - a.createdAt);
let preview = "";
for (const m of merged) {
const text = extractText(m);
if (text) {
preview = truncate(text, PREVIEW_MAX);
break;
}
}
results.push({ ...item, preview });
}
results.sort((a, b) => b.createdAt - a.createdAt);
return results;
}
export function useChatSearchIndex(enabled: boolean): {
items: ChatSearchItem[];
loading: boolean;
} {
const [items, setItems] = useState<ChatSearchItem[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!enabled) {
// Clear stale results so the next open doesn't flash old items.
setItems([]);
return;
}
let cancelled = false;
setLoading(true);
buildIndex()
.then((result) => {
if (!cancelled) setItems(result);
})
.catch(() => {
if (!cancelled) setItems([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [enabled]);
return { items, loading };
}

View file

@ -0,0 +1,82 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { db, useLiveQuery } from "../db";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import type { ThreadRecord } from "../types";
export interface SidebarItem {
type: "single" | "compare";
id: string;
title: string;
createdAt: number;
}
export function groupThreads(threads: ThreadRecord[]): SidebarItem[] {
const items: SidebarItem[] = [];
const seenPairs = new Set<string>();
for (const t of threads) {
if (t.archived) {
continue;
}
if (t.pairId) {
if (seenPairs.has(t.pairId)) {
continue;
}
seenPairs.add(t.pairId);
items.push({
type: "compare",
id: t.pairId,
title: t.title,
createdAt: t.createdAt,
});
} else if (!t.pairId) {
items.push({
type: "single",
id: t.id,
title: t.title,
createdAt: t.createdAt,
});
}
}
return items.sort((a, b) => b.createdAt - a.createdAt);
}
export function useChatSidebarItems() {
const allThreads = useLiveQuery(async () => {
const threadIdsWithMessage = new Set(
(await db.messages.orderBy("threadId").uniqueKeys()) as string[],
);
const rows = await db.threads.orderBy("createdAt").reverse().toArray();
return rows.filter((t) => !t.archived && threadIdsWithMessage.has(t.id));
}, []);
const items = groupThreads(allThreads ?? []);
const canCompare = useChatRuntimeStore((s) => Boolean(s.params.checkpoint));
return { items, canCompare };
}
export async function deleteChatItem(
item: SidebarItem,
activeId: string | undefined,
onSelect: (view: { mode: "single"; newThreadNonce: string }) => void,
) {
await db.transaction("rw", db.threads, db.messages, async () => {
if (item.type === "single") {
await db.messages.where("threadId").equals(item.id).delete();
await db.threads.delete(item.id);
} else {
const paired = await db.threads.where("pairId").equals(item.id).toArray();
for (const t of paired) {
await db.messages.where("threadId").equals(t.id).delete();
await db.threads.delete(t.id);
}
}
});
if (activeId === item.id) {
useChatRuntimeStore.getState().setActiveThreadId(null);
onSelect({ mode: "single", newThreadNonce: crypto.randomUUID() });
}
}

View file

@ -455,7 +455,7 @@ export function SharedComposer({
return (
<div
className={`shadow-border ring-1 ring-border relative flex w-full flex-col rounded-2xl bg-background px-1 pt-2 transition-shadow outline-none ${dragging ? "ring-ring bg-accent/50" : ""}`}
className={`chat-composer-surface relative flex w-full flex-col rounded-3xl bg-background px-1 pt-2 transition-shadow outline-none ${dragging ? "border-ring bg-accent/50" : ""}`}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
@ -557,13 +557,14 @@ export function SharedComposer({
if (reasoningAlwaysOn) return;
const next = !reasoningEnabled;
setReasoningEnabled(next);
// Qwen3/3.5: adjust params for thinking on/off
// Qwen3/3.5/3.6: adjust params for thinking on/off
const store = useChatRuntimeStore.getState();
const cp = store.params.checkpoint?.toLowerCase() ?? "";
if (cp.includes("qwen3")) {
const needsPresencePenalty = cp.includes("qwen3.5") || cp.includes("qwen3.6");
const p = next
? { temperature: 0.6, topP: 0.95, topK: 20, minP: 0.0 }
: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0.0 };
? { temperature: 0.6, topP: 0.95, topK: 20, minP: 0.0, ...(needsPresencePenalty ? { presencePenalty: 1.5 } : {}) }
: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0.0, ...(needsPresencePenalty ? { presencePenalty: 1.5 } : {}) };
store.setParams({ ...store.params, ...p });
}
}}

View file

@ -173,6 +173,7 @@ type ChatRuntimeStore = {
defaultChatTemplate: string | null;
chatTemplateOverride: string | null;
activeThreadId: string | null;
settingsPanelOpen: boolean;
pendingAudioBase64: string | null;
pendingAudioName: string | null;
contextUsage: {
@ -193,6 +194,7 @@ type ChatRuntimeStore = {
setModelsError: (error: string | null) => void;
setCheckpoint: (modelId: string, ggufVariant?: string | null) => void;
setActiveThreadId: (threadId: string | null) => void;
setSettingsPanelOpen: (open: boolean) => void;
clearCheckpoint: () => void;
setReasoningEnabled: (enabled: boolean) => void;
setToolsEnabled: (enabled: boolean) => void;
@ -243,6 +245,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
defaultChatTemplate: null,
chatTemplateOverride: null,
activeThreadId: null,
settingsPanelOpen: false,
pendingAudioBase64: null,
pendingAudioName: null,
contextUsage: null,
@ -294,6 +297,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
activeGgufVariant: ggufVariant ?? null,
})),
setActiveThreadId: (activeThreadId) => set({ activeThreadId, contextUsage: null }),
setSettingsPanelOpen: (settingsPanelOpen) => set({ settingsPanelOpen }),
clearCheckpoint: () =>
set((state) => ({
params: {

View file

@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
interface ChatSearchStore {
isOpen: boolean;
open: () => void;
close: () => void;
setOpen: (open: boolean) => void;
}
export const useChatSearchStore = create<ChatSearchStore>((set) => ({
isOpen: false,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
setOpen: (isOpen) => set({ isOpen }),
}));

View file

@ -21,48 +21,10 @@ import {
PencilEdit02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { db, useLiveQuery } from "./db";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import type { ChatView, ThreadRecord } from "./types";
interface SidebarItem {
type: "single" | "compare";
id: string;
title: string;
createdAt: number;
}
function groupThreads(threads: ThreadRecord[]): SidebarItem[] {
const items: SidebarItem[] = [];
const seenPairs = new Set<string>();
for (const t of threads) {
if (t.archived) {
continue;
}
if (t.pairId) {
if (seenPairs.has(t.pairId)) {
continue;
}
seenPairs.add(t.pairId);
items.push({
type: "compare",
id: t.pairId,
title: t.title,
createdAt: t.createdAt,
});
} else if (!t.pairId) {
items.push({
type: "single",
id: t.id,
title: t.title,
createdAt: t.createdAt,
});
}
}
return items.sort((a, b) => b.createdAt - a.createdAt);
}
import type { ChatView } from "./types";
import { deleteChatItem, useChatSidebarItems } from "./hooks/use-chat-sidebar-items";
import type { SidebarItem } from "./hooks/use-chat-sidebar-items";
export function ThreadSidebar({
view,
@ -77,14 +39,7 @@ export function ThreadSidebar({
onNewCompare: () => void;
showCompare: boolean;
}) {
const allThreads = useLiveQuery(async () => {
const threadIdsWithMessage = new Set(
(await db.messages.orderBy("threadId").uniqueKeys()) as string[],
);
const rows = await db.threads.orderBy("createdAt").reverse().toArray();
return rows.filter((t) => !t.archived && threadIdsWithMessage.has(t.id));
}, []);
const items = groupThreads(allThreads ?? []);
const { items } = useChatSidebarItems();
const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
const activeId =
view.mode === "single" ? (view.threadId ?? storeThreadId) : view.pairId;
@ -96,23 +51,10 @@ export function ThreadSidebar({
}
async function handleDelete(item: SidebarItem) {
if (item.type === "single") {
await db.messages.where("threadId").equals(item.id).delete();
await db.threads.delete(item.id);
} else {
const paired = await db.threads.where("pairId").equals(item.id).toArray();
for (const t of paired) {
await db.messages.where("threadId").equals(t.id).delete();
await db.threads.delete(t.id);
}
}
if (activeId === item.id) {
// Directly set a new view with a nonce rather than going through
// onNewThread(), which may return early if the guard sees no
// threadId and no activeThreadId (after we just cleared it).
useChatRuntimeStore.getState().setActiveThreadId(null);
onSelect({ mode: "single", newThreadNonce: crypto.randomUUID() });
}
// Directly set a new view with a nonce rather than going through
// onNewThread(), which may return early if the guard sees no
// threadId and no activeThreadId (after we just cleared it).
await deleteChatItem(item, activeId ?? undefined, onSelect);
}
return (

View file

@ -0,0 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { db } from "../db";
export async function countAllChats(): Promise<number> {
return db.threads.count();
}
export async function clearAllChats(): Promise<void> {
await db.transaction("rw", db.threads, db.messages, async () => {
await db.messages.clear();
await db.threads.clear();
});
}

View file

@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { db } from "../db";
interface ExportedChat {
exportedAt: string;
version: 1;
threadCount: number;
threads: unknown[];
messages: unknown[];
}
export async function buildChatExport(): Promise<ExportedChat> {
const [threads, messages] = await Promise.all([
db.threads.toArray(),
db.messages.toArray(),
]);
return {
exportedAt: new Date().toISOString(),
version: 1,
threadCount: threads.length,
threads,
messages,
};
}
export async function downloadChatExport(): Promise<void> {
const data = await buildChatExport();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `unsloth-chats-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}

View file

@ -0,0 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useEffect, useState } from "react";
import { liveQuery } from "dexie";
import { listRecipes } from "../data/recipes-db";
import type { RecipeRecord } from "../types";
export function useRecipeSidebarItems(enabled: boolean) {
const [recipes, setRecipes] = useState<RecipeRecord[]>([]);
useEffect(() => {
if (!enabled) return;
const sub = liveQuery(() => listRecipes()).subscribe({
next: (value) => setRecipes(value),
error: (err) => console.error("recipe sidebar liveQuery:", err),
});
return () => sub.unsubscribe();
}, [enabled]);
return recipes;
}

View file

@ -7,12 +7,12 @@ import { motion } from "motion/react";
interface SplashScreenProps {
onStartOnboarding: () => void;
onGoToStudio: () => void;
onSkipOnboarding: () => void;
}
export function SplashScreen({
onStartOnboarding,
onGoToStudio,
onSkipOnboarding,
}: SplashScreenProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-b from-background via-background to-primary/5 p-6">
@ -65,7 +65,7 @@ export function SplashScreen({
<Button size="lg" onClick={onStartOnboarding}>
Start Onboarding
</Button>
<Button size="lg" variant="outline" onClick={onGoToStudio}>
<Button size="lg" variant="outline" onClick={onSkipOnboarding}>
Skip Onboarding
</Button>
</motion.div>

View file

@ -7,10 +7,15 @@ import { markOnboardingDone } from "@/features/auth";
import { useTrainingConfigStore } from "@/features/training";
import { ArrowLeft02Icon, ArrowRight02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate } from "@tanstack/react-router";
import { useShallow } from "zustand/react/shallow";
export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void }) {
export function WizardFooter({
returnTo,
onBackToSplash,
}: {
returnTo: string;
onBackToSplash: () => void;
}) {
const { currentStep, prevStep, nextStep, canProceed } = useTrainingConfigStore(
useShallow((s) => ({
currentStep: s.currentStep,
@ -19,7 +24,6 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void })
canProceed: s.canProceed(),
})),
);
const navigate = useNavigate();
const isFirst = currentStep === 1;
const isLast = currentStep === STEPS.length;
@ -41,7 +45,7 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void })
className="px-4"
onClick={() => {
markOnboardingDone();
navigate({ to: "/studio" });
window.location.assign(returnTo);
}}
>
Skip
@ -51,12 +55,12 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void })
<Button
onClick={() => {
markOnboardingDone();
navigate({ to: "/studio" });
window.location.assign(returnTo);
}}
disabled={!canProceed}
className="px-4 !pr-4"
>
Go to Studio
Finish onboarding
<HugeiconsIcon icon={ArrowRight02Icon} data-icon="inline-end" />
</Button>
) : (
@ -65,7 +69,7 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void })
if (currentStep === 1 && sessionStorage.getItem("unsloth_chat_only") === "1") {
sessionStorage.removeItem("unsloth_chat_only");
markOnboardingDone();
window.location.href = "/chat";
window.location.assign("/chat");
} else {
nextStep();
}

View file

@ -2,7 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Card } from "@/components/ui/card";
import { useNavigate } from "@tanstack/react-router";
import { Route as OnboardingRoute } from "@/app/routes/onboarding";
import { motion } from "motion/react";
import { Suspense, lazy, useEffect, useRef, useState } from "react";
@ -19,13 +19,23 @@ const Confetti = lazy(() =>
import("@/components/ui/confetti").then((m) => ({ default: m.Confetti })),
);
function sanitizeRedirectTarget(value: string | undefined): string {
if (!value) return "/chat";
if (!value.startsWith("/")) return "/chat";
if (value.startsWith("//")) return "/chat";
if (value.includes("\\")) return "/chat";
return value;
}
export function WizardLayout() {
const navigate = useNavigate();
const search = OnboardingRoute.useSearch();
const [showSplash, setShowSplash] = useState(true);
const currentStep = useTrainingConfigStore((s) => s.currentStep);
const confettiRef = useRef<ConfettiRef>(null);
const hasFiredRef = useRef(false);
const isFinalStep = currentStep === STEPS.length;
const returnTo = sanitizeRedirectTarget(search.redirectTo);
const exitToReturnTo = () => window.location.assign(returnTo);
// Only redirect on initial mount — not on re-renders after markOnboardingDone()
// which would override explicit /chat navigation from skip buttons.
@ -34,10 +44,10 @@ export function WizardLayout() {
if (!checkedRef.current) {
checkedRef.current = true;
if (isOnboardingDone()) {
navigate({ to: "/studio" });
exitToReturnTo();
}
}
}, [navigate]);
}, [returnTo]);
useEffect(() => {
if (isFinalStep && !hasFiredRef.current) {
@ -67,9 +77,9 @@ export function WizardLayout() {
{showSplash && (
<SplashScreen
onStartOnboarding={() => setShowSplash(false)}
onGoToStudio={() => {
onSkipOnboarding={() => {
markOnboardingDone();
window.location.href = "/studio";
exitToReturnTo();
}}
/>
)}
@ -91,10 +101,10 @@ export function WizardLayout() {
}}
>
<Card className="relative z-10 w-full !gap-0 !m-0 !p-0 flex min-h-[560px] flex-col overflow-hidden shadow-border ring-1 ring-border md:min-h-[620px] md:flex-row lg:h-[660px]">
<WizardSidebar />
<WizardSidebar returnTo={returnTo} />
<div className="flex-1 flex flex-col">
<WizardContent />
<WizardFooter onBackToSplash={() => setShowSplash(true)} />
<WizardFooter returnTo={returnTo} onBackToSplash={() => setShowSplash(true)} />
</div>
</Card>
</motion.div>

View file

@ -10,7 +10,7 @@ import { ArrowRight02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { WizardStepItem } from "./wizard-step-item";
export function WizardSidebar() {
export function WizardSidebar({ returnTo }: { returnTo: string }) {
const currentStep = useTrainingConfigStore((s) => s.currentStep);
const progress = ((currentStep - 1) / (STEPS.length - 1)) * 100;
@ -38,10 +38,10 @@ export function WizardSidebar() {
className="mt-2 w-full md:hidden"
onClick={() => {
markOnboardingDone();
window.location.href = "/chat";
window.location.assign(returnTo);
}}
>
Skip to Chat
Skip onboarding
<HugeiconsIcon icon={ArrowRight02Icon} data-icon="inline-end" />
</Button>
<nav className="mt-3 hidden flex-col gap-1 md:flex">
@ -54,10 +54,10 @@ export function WizardSidebar() {
className="mt-3 hidden w-full md:flex"
onClick={() => {
markOnboardingDone();
window.location.href = "/chat";
window.location.assign(returnTo);
}}
>
Skip to Chat
Skip onboarding
<HugeiconsIcon icon={ArrowRight02Icon} data-icon="inline-end" />
</Button>
</aside>

View file

@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch } from "@/features/auth/api";
export interface ApiKey {
id: number;
name: string;
key_prefix: string;
created_at: string;
last_used_at: string | null;
expires_at: string | null;
is_active: boolean;
}
export async function fetchApiKeys(): Promise<ApiKey[]> {
const res = await authFetch("/api/auth/api-keys");
if (!res.ok) throw new Error("Failed to load API keys");
const data = (await res.json()) as { api_keys: ApiKey[] };
return data.api_keys.filter((k) => k.is_active);
}
export async function createApiKey(
name: string,
expiresInDays: number | null,
): Promise<{ key: string; api_key: ApiKey }> {
const res = await authFetch("/api/auth/api-keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, expires_in_days: expiresInDays }),
});
if (!res.ok) throw new Error("Failed to create API key");
return res.json();
}
export async function revokeApiKey(keyId: number): Promise<void> {
const res = await authFetch(`/api/auth/api-keys/${keyId}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to revoke API key");
}

View file

@ -0,0 +1,101 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Delete02Icon,
Copy01Icon,
MoreHorizontalIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import type { ApiKey } from "../api/api-keys";
function relative(iso: string | null): string {
if (!iso) return "never";
const diff = Date.now() - new Date(iso).getTime();
const days = Math.floor(diff / 86400000);
if (days < 1) {
const hours = Math.floor(diff / 3600000);
if (hours < 1) return "just now";
return `${hours}h ago`;
}
if (days < 30) return `${days}d ago`;
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
return `${Math.floor(days / 365)}y ago`;
}
function expiresText(iso: string | null): string {
if (!iso) return "never";
const diff = new Date(iso).getTime() - Date.now();
if (diff < 0) return "expired";
const days = Math.floor(diff / 86400000);
if (days < 1) return "today";
return `in ${days}d`;
}
export function ApiKeyRow({
apiKey,
onRevoke,
}: {
apiKey: ApiKey;
onRevoke: (key: ApiKey) => void;
}) {
const prefix = `sk-unsloth-${apiKey.key_prefix}`;
return (
<div className="group flex items-center gap-3 border-b border-border/60 px-1 py-3 last:border-b-0 transition-colors hover:bg-accent/40">
<span
className="size-1.5 shrink-0 rounded-full bg-emerald-500"
aria-hidden="true"
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate text-sm font-medium text-foreground" title={apiKey.name}>
{apiKey.name}
</span>
<code className="shrink-0 font-mono text-[11px] text-muted-foreground">
{prefix}
</code>
</div>
<div className="flex flex-wrap gap-x-1.5 text-[11px] text-muted-foreground">
<span>Created {relative(apiKey.created_at)}</span>
<span>·</span>
<span>Used {relative(apiKey.last_used_at)}</span>
<span>·</span>
<span>Expires {expiresText(apiKey.expires_at)}</span>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-7 p-0 opacity-0 transition-opacity group-hover:opacity-100 data-[state=open]:opacity-100 max-sm:!opacity-100 max-sm:size-9"
aria-label={`Actions for ${apiKey.name}`}
>
<HugeiconsIcon icon={MoreHorizontalIcon} className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => copyToClipboard(prefix)}>
<HugeiconsIcon icon={Copy01Icon} className="size-3.5 mr-2" />
Copy prefix
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onRevoke(apiKey)}
className="text-destructive focus:text-destructive"
>
<HugeiconsIcon icon={Delete02Icon} className="size-3.5 mr-2" />
Revoke key
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View file

@ -0,0 +1,83 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { createApiKey } from "../api/api-keys";
const EXPIRY_PRESETS = [
{ label: "Never", value: null as number | null },
{ label: "7d", value: 7 },
{ label: "30d", value: 30 },
{ label: "90d", value: 90 },
];
export function CreateKeyForm({
onCreated,
onError,
}: {
onCreated: (rawKey: string) => void;
onError: (message: string) => void;
}) {
const [name, setName] = useState("");
const [expiry, setExpiry] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || loading) return;
setLoading(true);
try {
const result = await createApiKey(name.trim(), expiry);
onCreated(result.key);
setName("");
} catch (err) {
onError(err instanceof Error ? err.message : "Couldn't create key.");
} finally {
setLoading(false);
}
};
return (
<form
onSubmit={handleSubmit}
className="flex flex-col gap-2 rounded-lg border border-border bg-muted/20 p-3"
>
<div className="flex flex-wrap items-center gap-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Key name (e.g. production)"
className="h-8 min-w-[180px] flex-1 text-sm"
aria-label="New key name"
/>
<div className="inline-flex items-center rounded-md border border-border bg-background p-0.5">
{EXPIRY_PRESETS.map((p) => {
const active = expiry === p.value;
return (
<button
key={p.label}
type="button"
onClick={() => setExpiry(p.value)}
aria-pressed={active}
className={cn(
"rounded px-2 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active
? "bg-accent text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{p.label}
</button>
);
})}
</div>
<Button type="submit" size="sm" disabled={loading || !name.trim()}>
{loading ? "Creating…" : "Create key"}
</Button>
</div>
</form>
);
}

View file

@ -0,0 +1,71 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useState } from "react";
export function KeyRevealCard({
rawKey,
onDone,
}: {
rawKey: string;
onDone: () => void;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
if (copyToClipboard(rawKey)) {
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}
};
return (
<div className="flex flex-col gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-3">
<div className="flex items-center gap-1.5">
<HugeiconsIcon
icon={Tick02Icon}
className="size-3.5 text-emerald-600 dark:text-emerald-500"
/>
<span className="text-xs font-medium text-emerald-700 dark:text-emerald-500">
New key created
</span>
</div>
<button
type="button"
onClick={handleCopy}
className={cn(
"flex w-full items-center justify-between gap-3 rounded-md border border-border bg-muted/40 px-3 py-2.5 font-mono text-sm transition-colors hover:bg-muted/60",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
copied && "border-emerald-500/40 bg-emerald-500/10",
)}
aria-label={copied ? "Key copied" : "Copy key"}
>
<code className="min-w-0 flex-1 break-all text-left text-foreground">
{rawKey}
</code>
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy01Icon}
className={cn("size-4 shrink-0", copied && "text-emerald-600")}
/>
</button>
<div className="flex items-center justify-between gap-3 pt-0.5">
<p className="text-[11px] text-muted-foreground">
Copy now this won't be shown again.
</p>
<Button
type="button"
size="sm"
onClick={onDone}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
>
Done
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,39 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
export function SettingsRow({
label,
description,
children,
destructive,
className,
}: {
label: string;
description?: string;
children?: ReactNode;
destructive?: boolean;
className?: string;
}) {
return (
<div
className={cn(
"flex items-center justify-between gap-6 py-3",
destructive && "border-t border-border/60 mt-2 pt-4",
className,
)}
>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">{label}</span>
{description ? (
<span className="text-xs text-muted-foreground leading-snug">
{description}
</span>
) : null}
</div>
{children ? <div className="flex shrink-0 items-center">{children}</div> : null}
</div>
);
}

View file

@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import type { ReactNode } from "react";
export function SettingsSection({
title,
description,
children,
}: {
title: string;
description?: string;
children: ReactNode;
}) {
return (
<section className="flex flex-col">
<div className="mb-1 flex flex-col gap-0.5">
<h2 className="text-base font-semibold font-heading text-foreground">
{title}
</h2>
{description ? (
<p className="text-xs text-muted-foreground leading-relaxed">
{description}
</p>
) : null}
</div>
<div className="flex flex-col divide-y divide-border/60">{children}</div>
</section>
);
}

View file

@ -0,0 +1,58 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { cn } from "@/lib/utils";
import {
LaptopIcon,
Moon02Icon,
Sun02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { motion, useReducedMotion } from "motion/react";
import { useTheme, type Theme } from "../stores/theme-store";
const OPTIONS: { value: Theme; label: string; icon: typeof Sun02Icon }[] = [
{ value: "light", label: "Light", icon: Sun02Icon },
{ value: "dark", label: "Dark", icon: Moon02Icon },
{ value: "system", label: "System", icon: LaptopIcon },
];
export function ThemeSegmented() {
const { theme, setTheme } = useTheme();
const reduced = useReducedMotion();
return (
<div className="inline-flex items-center rounded-md border border-border bg-muted/30 p-0.5">
{OPTIONS.map((opt) => {
const active = theme === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => setTheme(opt.value)}
aria-pressed={active}
className={cn(
"relative flex h-7 items-center gap-1.5 rounded px-2.5 text-xs font-medium transition-colors",
active
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{active && (
<motion.span
layoutId="theme-pill"
className="absolute inset-0 rounded bg-background shadow-border"
transition={
reduced
? { duration: 0 }
: { type: "spring", stiffness: 500, damping: 35, mass: 0.5 }
}
/>
)}
<HugeiconsIcon icon={opt.icon} className="relative z-10 size-3.5" />
<span className="relative z-10">{opt.label}</span>
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,185 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { cn } from "@/lib/utils";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ReactElement } from "react";
import { useEffect, useRef, useState } from "react";
const STUDIO_UPDATE_CMD = "unsloth studio update";
const STUDIO_UPDATE_FALLBACK_UNIX_CMD =
"curl -fsSL https://unsloth.ai/install.sh | sh";
const STUDIO_UPDATE_FALLBACK_WINDOWS_CMD =
"irm https://unsloth.ai/install.ps1 | iex";
export type UpdateShell = "windows" | "unix";
function getStudioUpdateInstructionLine(shell: UpdateShell): string {
return shell === "windows" ? "Open PowerShell and run:" : "Open Terminal and run:";
}
function CopyableCommand({
command,
copyLabel,
}: {
command: string;
copyLabel: string;
}): ReactElement {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);
const handleCopy = () => {
if (!copyToClipboard(command)) {
return;
}
setCopied(true);
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => setCopied(false), 2000);
};
return (
<div className="flex min-w-0 items-stretch overflow-hidden rounded-md border border-border bg-muted/40">
<input
type="text"
readOnly
value={command}
className="min-w-0 flex-1 bg-transparent px-2 py-1.5 font-mono text-[11px] text-foreground outline-none"
title={command}
aria-label={`${copyLabel} text`}
/>
<button
type="button"
onClick={handleCopy}
className="flex shrink-0 items-center justify-center border-l border-border px-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={copied ? "Copied" : "Copy command"}
aria-label={copied ? `${copyLabel} copied` : `Copy ${copyLabel}`}
>
{copied ? (
<HugeiconsIcon icon={Tick02Icon} className="size-4 text-emerald-600" />
) : (
<HugeiconsIcon icon={Copy01Icon} className="size-4" />
)}
</button>
</div>
);
}
export function UpdateStudioInstructions({
className,
defaultShell,
showTitle = true,
}: {
className?: string;
defaultShell: UpdateShell;
showTitle?: boolean;
}): ReactElement {
const [shell, setShell] = useState<UpdateShell>(defaultShell);
const prefersReducedMotion = useReducedMotion();
const windows = shell === "windows";
const fadeTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.16, ease: [0.165, 0.84, 0.44, 1] as const };
const fadeInitial = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 2 };
const fadeAnimate = { opacity: 1, y: 0 };
const fadeExit = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: -2 };
useEffect(() => {
setShell(defaultShell);
}, [defaultShell]);
return (
<div className={cn("flex flex-col gap-3", className)}>
<div
className={cn(
"flex items-center gap-3",
showTitle ? "justify-between" : "justify-start",
)}
>
{showTitle ? (
<p className="shrink-0 whitespace-nowrap text-sm font-semibold font-heading">
Update Unsloth Studio
</p>
) : null}
<div className="flex shrink-0 items-center gap-0.5 text-[11px]">
<button
type="button"
onClick={() => setShell("windows")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
windows
? "text-foreground"
: "text-muted-foreground hover:text-emerald-600",
)}
aria-pressed={windows}
>
Windows
</button>
<span className="text-border">/</span>
<button
type="button"
onClick={() => setShell("unix")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
!windows
? "text-foreground"
: "text-muted-foreground hover:text-emerald-600",
)}
aria-pressed={!windows}
>
macOS/Linux
</button>
</div>
</div>
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={`instruction-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
className="text-xs text-muted-foreground leading-relaxed"
>
{getStudioUpdateInstructionLine(shell)}
</motion.p>
</AnimatePresence>
<CopyableCommand command={STUDIO_UPDATE_CMD} copyLabel="update command" />
<p className="text-xs text-muted-foreground leading-relaxed">
If that fails or unsloth studio update is unavailable, run:
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`fallback-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
<CopyableCommand
command={
windows
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
}
copyLabel="fallback command"
/>
</motion.div>
</AnimatePresence>
<p className="text-xs text-muted-foreground leading-relaxed">
Restart Studio after updating for changes to take effect.
</p>
</div>
);
}

View file

@ -0,0 +1,146 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { cn } from "@/lib/utils";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import {
ArrowDown01Icon,
Copy01Icon,
Tick02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion } from "motion/react";
import { useMemo, useState } from "react";
type Lang = "curl" | "python" | "tools";
const TABS: { id: Lang; label: string }[] = [
{ id: "curl", label: "curl" },
{ id: "python", label: "Python" },
{ id: "tools", label: "Tools" },
];
function buildSnippets(base: string) {
return {
curl: `curl ${base}/v1/chat/completions \\
-H "Authorization: Bearer sk-unsloth-YOUR_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"messages": [{"role": "user", "content": "Hello"}],
"stream": true
}'`,
python: `from openai import OpenAI
client = OpenAI(
base_url="${base}/v1",
api_key="sk-unsloth-YOUR_KEY",
)
response = client.chat.completions.create(
model="current",
messages=[{"role": "user", "content": "Hello"}],
stream=True,
)
for chunk in response:
print(chunk.choices[0].delta.content or "", end="")`,
tools: `curl ${base}/v1/chat/completions \\
-H "Authorization: Bearer sk-unsloth-YOUR_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"messages": [{"role": "user", "content": "Search Python 3.13 features"}],
"enable_tools": true,
"enabled_tools": ["web_search", "python"],
"stream": true
}'`,
};
}
export function UsageExamples() {
const [open, setOpen] = useState(false);
const [lang, setLang] = useState<Lang>("curl");
const [copied, setCopied] = useState(false);
const snippets = useMemo(
() =>
buildSnippets(
typeof window !== "undefined" ? window.location.origin : "",
),
[],
);
const handleCopy = () => {
if (copyToClipboard(snippets[lang])) {
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}
};
return (
<section className="flex flex-col">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-fit items-center gap-1.5 rounded text-xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-expanded={open}
>
<HugeiconsIcon
icon={ArrowDown01Icon}
className={cn("size-3.5 transition-transform", open && "rotate-180")}
/>
{open ? "Hide usage examples" : "Show usage examples"}
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: [0.165, 0.84, 0.44, 1] }}
className="overflow-hidden"
>
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
<div className="flex items-center justify-between border-b border-border px-2 py-1.5">
<div className="flex items-center gap-0.5">
{TABS.map((t) => {
const active = lang === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => setLang(t.id)}
aria-pressed={active}
className={cn(
"rounded px-2 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active
? "bg-background text-foreground shadow-border"
: "text-muted-foreground hover:text-foreground",
)}
>
{t.label}
</button>
);
})}
</div>
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Copy snippet"
>
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy01Icon}
className={cn("size-3.5", copied && "text-emerald-600")}
/>
{copied ? "Copied" : "Copy"}
</button>
</div>
<pre className="overflow-x-auto p-3 font-mono text-[11px] leading-relaxed text-foreground">
{snippets[lang]}
</pre>
</div>
</motion.div>
)}
</AnimatePresence>
</section>
);
}

View file

@ -0,0 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export { SettingsDialog } from "./settings-dialog";
export { useSettingsDialogStore } from "./stores/settings-dialog-store";
export type { SettingsTab } from "./stores/settings-dialog-store";

View file

@ -0,0 +1,142 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import {
Cancel01Icon,
Key01Icon,
Message01Icon,
PaintBrush02Icon,
Settings02Icon,
SparklesIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { motion, useReducedMotion } from "motion/react";
import { useSettingsDialogStore, type SettingsTab } from "./stores/settings-dialog-store";
import { AboutTab } from "./tabs/about-tab";
import { ApiKeysTab } from "./tabs/api-keys-tab";
import { AppearanceTab } from "./tabs/appearance-tab";
import { ChatTab } from "./tabs/chat-tab";
import { GeneralTab } from "./tabs/general-tab";
interface TabDef {
id: SettingsTab;
label: string;
icon: typeof Settings02Icon;
}
const TABS: TabDef[] = [
{ id: "general", label: "General", icon: Settings02Icon },
{ id: "appearance", label: "Appearance", icon: PaintBrush02Icon },
{ id: "chat", label: "Chat", icon: Message01Icon },
{ id: "api-keys", label: "API Keys", icon: Key01Icon },
{ id: "about", label: "About", icon: SparklesIcon },
];
function renderTab(tab: SettingsTab) {
switch (tab) {
case "general":
return <GeneralTab />;
case "appearance":
return <AppearanceTab />;
case "chat":
return <ChatTab />;
case "api-keys":
return <ApiKeysTab />;
case "about":
return <AboutTab />;
}
}
export function SettingsDialog() {
const open = useSettingsDialogStore((s) => s.open);
const activeTab = useSettingsDialogStore((s) => s.activeTab);
const setActiveTab = useSettingsDialogStore((s) => s.setActiveTab);
const closeDialog = useSettingsDialogStore((s) => s.closeDialog);
const reduced = useReducedMotion();
return (
<Dialog open={open} onOpenChange={(o) => !o && closeDialog()}>
<DialogContent
showCloseButton={false}
overlayClassName="bg-background/40"
className={cn(
"!max-w-none h-[560px] w-[820px] p-0 overflow-hidden",
"shadow-border rounded-xl border-border",
"sm:h-[560px] sm:w-[820px]",
"max-sm:h-dvh max-sm:w-dvw max-sm:rounded-none",
)}
>
<DialogTitle className="sr-only">Settings</DialogTitle>
<DialogDescription className="sr-only">
Manage your Unsloth Studio preferences.
</DialogDescription>
<div className="flex h-full min-h-0">
<aside className="flex w-[200px] shrink-0 flex-col border-r border-border bg-muted/20 p-2">
<nav className="flex flex-col gap-0.5">
{TABS.map((tab) => {
const active = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"relative flex h-9 items-center gap-2 rounded-md px-2.5 text-sm font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
active
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{active && (
<motion.span
layoutId="settings-active-pill"
className="absolute inset-0 rounded-md bg-accent"
transition={
reduced
? { duration: 0 }
: {
type: "spring",
stiffness: 500,
damping: 35,
mass: 0.5,
}
}
/>
)}
<HugeiconsIcon
icon={tab.icon}
className="relative z-10 size-4"
/>
<span className="relative z-10">{tab.label}</span>
</button>
);
})}
</nav>
</aside>
<main className="relative flex min-w-0 flex-1 flex-col">
<button
type="button"
onClick={closeDialog}
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Close settings"
>
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
</button>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-6 pr-12">
{renderTab(activeTab)}
</div>
</main>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,52 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
export type SettingsTab =
| "general"
| "appearance"
| "chat"
| "api-keys"
| "about";
interface SettingsDialogState {
open: boolean;
activeTab: SettingsTab;
openDialog: (tab?: SettingsTab) => void;
closeDialog: () => void;
setActiveTab: (tab: SettingsTab) => void;
}
const ACTIVE_TAB_KEY = "unsloth_settings_active_tab";
function loadInitialTab(): SettingsTab {
if (typeof window === "undefined") return "general";
let stored: string | null = null;
try {
stored = window.localStorage.getItem(ACTIVE_TAB_KEY);
} catch {
return "general";
}
const valid: SettingsTab[] = ["general", "appearance", "chat", "api-keys", "about"];
return valid.includes(stored as SettingsTab) ? (stored as SettingsTab) : "general";
}
export const useSettingsDialogStore = create<SettingsDialogState>((set) => ({
open: false,
activeTab: loadInitialTab(),
openDialog: (tab) =>
set((state) => ({
open: true,
activeTab: tab ?? state.activeTab,
})),
closeDialog: () => set({ open: false }),
setActiveTab: (tab) => {
try {
window.localStorage.setItem(ACTIVE_TAB_KEY, tab);
} catch {
// ignore storage failures
}
set({ activeTab: tab });
},
}));

View file

@ -0,0 +1,96 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useSyncExternalStore } from "react";
export type Theme = "light" | "dark" | "system";
export type ResolvedTheme = "light" | "dark";
const STORAGE_KEY = "theme";
function readStoredTheme(): Theme {
if (typeof window === "undefined") return "system";
let stored: string | null = null;
try {
stored = window.localStorage.getItem(STORAGE_KEY);
} catch {
return "system";
}
if (stored === "light" || stored === "dark" || stored === "system") return stored;
return "system";
}
function systemPrefersDark(): boolean {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
function resolveTheme(theme: Theme): ResolvedTheme {
if (theme === "system") return systemPrefersDark() ? "dark" : "light";
return theme;
}
function applyToDocument(resolved: ResolvedTheme) {
if (typeof document === "undefined") return;
document.documentElement.classList.toggle("dark", resolved === "dark");
}
const listeners = new Set<() => void>();
function subscribe(cb: () => void) {
listeners.add(cb);
if (typeof window === "undefined") {
return () => listeners.delete(cb);
}
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const syncTheme = () => {
applyToDocument(resolveTheme(readStoredTheme()));
cb();
};
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY || e.key === null) syncTheme();
};
mq.addEventListener("change", syncTheme);
window.addEventListener("storage", onStorage);
return () => {
listeners.delete(cb);
mq.removeEventListener("change", syncTheme);
window.removeEventListener("storage", onStorage);
};
}
function getSnapshot(): Theme {
return readStoredTheme();
}
function getServerSnapshot(): Theme {
return "system";
}
/**
* Single source of truth for setting the theme. All writers (the Settings
* dialog's segmented control AND the sidebar dropdown's animated toggler)
* must route through this so the DOM class, localStorage, and React
* subscribers stay in sync.
*/
export function setTheme(next: Theme): void {
if (typeof window === "undefined") return;
// Persist "system" explicitly so next-themes (mounted with
// defaultTheme="light") doesn't clobber the choice on reload.
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// ignore storage failures
}
applyToDocument(resolveTheme(next));
listeners.forEach((cb) => cb());
}
export function useTheme(): {
theme: Theme;
resolved: ResolvedTheme;
setTheme: (next: Theme) => void;
} {
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const resolved = resolveTheme(theme);
return { theme, resolved, setTheme };
}

View file

@ -0,0 +1,120 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { ShutdownDialog } from "@/components/shutdown-dialog";
import { UpdateStudioInstructions } from "../components/update-studio-instructions";
import { usePlatformStore } from "@/config/env";
import { removeTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
import {
ArrowUpRight01Icon,
Book03Icon,
Cancel01Icon,
MessageNotification01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useState } from "react";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
export function AboutTab() {
const deviceType = usePlatformStore((s) => s.deviceType);
const defaultShell = deviceType === "windows" ? "windows" : "unix";
const [shutdownOpen, setShutdownOpen] = useState(false);
const [version, setVersion] = useState("dev");
useEffect(() => {
let canceled = false;
(async () => {
try {
const res = await fetch("/api/health");
if (!res.ok) return;
const data = (await res.json()) as { version?: string };
if (!canceled && data.version) {
setVersion(data.version);
}
} catch {
// fall back to dev label
}
})();
return () => {
canceled = true;
};
}, []);
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-semibold font-heading">About</h1>
<p className="text-xs text-muted-foreground">
Unsloth Studio build info and support.
</p>
</header>
<SettingsSection title="Studio">
<SettingsRow label="Version">
<code className="font-mono text-xs text-muted-foreground">{version}</code>
</SettingsRow>
</SettingsSection>
<SettingsSection title="Updates">
<div className="py-2">
<UpdateStudioInstructions defaultShell={defaultShell} showTitle={false} />
</div>
</SettingsSection>
<SettingsSection title="Help">
<SettingsRow label="Documentation">
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<HugeiconsIcon icon={Book03Icon} className="size-3.5" />
unsloth.ai/docs
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
</a>
</SettingsRow>
<SettingsRow label="Feedback">
<a
href="https://github.com/unslothai/unsloth/issues"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<HugeiconsIcon icon={MessageNotification01Icon} className="size-3.5" />
Report an issue
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
</a>
</SettingsRow>
</SettingsSection>
<SettingsSection title="Danger zone">
<SettingsRow
destructive
label="Shut down Unsloth Studio"
description="Stops the Studio server process and ends your session."
>
<Button
variant="outline"
size="sm"
onClick={() => setShutdownOpen(true)}
className="text-destructive hover:text-destructive hover:border-destructive/60"
>
<HugeiconsIcon icon={Cancel01Icon} className="size-3.5 mr-1.5" />
Shut down
</Button>
</SettingsRow>
</SettingsSection>
<ShutdownDialog
open={shutdownOpen}
onOpenChange={setShutdownOpen}
onAfterShutdown={removeTrainingUnloadGuard}
/>
</div>
);
}

View file

@ -0,0 +1,159 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useState } from "react";
import { fetchApiKeys, revokeApiKey, type ApiKey } from "../api/api-keys";
import { ApiKeyRow } from "../components/api-key-row";
import { CreateKeyForm } from "../components/create-key-form";
import { KeyRevealCard } from "../components/key-reveal-card";
import { UsageExamples } from "../components/usage-examples";
export function ApiKeysTab() {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [revokeTarget, setRevokeTarget] = useState<ApiKey | null>(null);
const [revoking, setRevoking] = useState(false);
const [revealed, setRevealed] = useState<string | null>(null);
const reduced = useReducedMotion();
const t = reduced
? { duration: 0 }
: { duration: 0.18, ease: [0.165, 0.84, 0.44, 1] as const };
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setKeys(await fetchApiKeys());
} catch (e) {
setError(e instanceof Error ? e.message : "Couldn't load API keys.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const confirmRevoke = async () => {
if (!revokeTarget) return;
setRevoking(true);
try {
await revokeApiKey(revokeTarget.id);
await load();
setRevokeTarget(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Couldn't revoke key.");
} finally {
setRevoking(false);
}
};
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-semibold font-heading">API Keys</h1>
<p className="text-xs text-muted-foreground">
Access Unsloth Studio programmatically via the OpenAI-compatible API.
</p>
</header>
<AnimatePresence mode="wait" initial={false}>
{revealed !== null ? (
<motion.div
key="reveal"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={t}
>
<KeyRevealCard
rawKey={revealed}
onDone={() => setRevealed(null)}
/>
</motion.div>
) : (
<motion.div
key="form"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={t}
>
<CreateKeyForm
onCreated={(raw) => {
setRevealed(raw);
void load();
}}
onError={setError}
/>
</motion.div>
)}
</AnimatePresence>
<section className="flex flex-col">
<h2 className="mb-2 text-sm font-semibold text-foreground">Your keys</h2>
{error ? (
<div className="rounded-md border border-destructive/20 bg-destructive/5 p-3 text-xs text-destructive">
{error}
</div>
) : loading ? (
<div className="flex flex-col gap-2 py-2">
{[0, 1].map((i) => (
<div
key={i}
className="h-12 animate-pulse rounded-md bg-muted/40"
/>
))}
</div>
) : keys.length === 0 ? (
<p className="py-6 text-center text-xs text-muted-foreground">
No API keys yet.
</p>
) : (
<div className="flex flex-col">
{keys.map((k) => (
<ApiKeyRow key={k.id} apiKey={k} onRevoke={setRevokeTarget} />
))}
</div>
)}
</section>
<UsageExamples />
<Dialog open={revokeTarget !== null} onOpenChange={(o) => !o && setRevokeTarget(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Revoke key {revokeTarget?.name}?</DialogTitle>
<DialogDescription>
Applications using this key will immediately lose access. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRevokeTarget(null)}>
Cancel
</Button>
<Button
onClick={confirmRevoke}
disabled={revoking}
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
>
{revoking ? "Revoking…" : `Revoke “${revokeTarget?.name}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Switch } from "@/components/ui/switch";
import { useSidebarPin } from "@/hooks/use-sidebar-pin";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
import { ThemeSegmented } from "../components/theme-segmented";
export function AppearanceTab() {
const { pinned, setPinned } = useSidebarPin();
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-semibold font-heading">Appearance</h1>
<p className="text-xs text-muted-foreground">
How Unsloth Studio looks on this device.
</p>
</header>
<SettingsSection title="Theme">
<SettingsRow
label="Color scheme"
description="Choose light, dark, or follow your system."
>
<ThemeSegmented />
</SettingsRow>
</SettingsSection>
<SettingsSection title="Layout">
<SettingsRow
label="Pin sidebar by default"
description="Keep the sidebar expanded instead of collapsing to icons."
>
<Switch checked={pinned} onCheckedChange={setPinned} />
</SettingsRow>
</SettingsSection>
</div>
);
}

View file

@ -0,0 +1,129 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
clearAllChats,
countAllChats,
} from "@/features/chat/utils/clear-all-chats";
import { downloadChatExport } from "@/features/chat/utils/export-chat-history";
import { Delete02Icon, Download02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useState } from "react";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
export function ChatTab() {
const [confirmOpen, setConfirmOpen] = useState(false);
const [count, setCount] = useState<number | null>(null);
const [exporting, setExporting] = useState(false);
const [clearing, setClearing] = useState(false);
useEffect(() => {
void countAllChats().then(setCount);
}, []);
const handleExport = async () => {
setExporting(true);
try {
await downloadChatExport();
} finally {
setExporting(false);
}
};
const handleClear = async () => {
setClearing(true);
try {
await clearAllChats();
setCount(0);
setConfirmOpen(false);
} finally {
setClearing(false);
}
};
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-semibold font-heading">Chat</h1>
<p className="text-xs text-muted-foreground">
Manage your chat history stored on this device.
</p>
</header>
<SettingsSection title="Data">
<SettingsRow
label="Export chat history"
description="Download all chats and messages as a JSON file."
>
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={exporting || count === 0}
>
<HugeiconsIcon icon={Download02Icon} className="size-3.5 mr-1.5" />
{exporting ? "Exporting…" : "Export"}
</Button>
</SettingsRow>
<SettingsRow
destructive
label="Clear all chats"
description={
count === null
? "Permanently delete every chat on this device."
: count === 0
? "No chats to clear."
: `Permanently delete all ${count} chat${count === 1 ? "" : "s"} on this device.`
}
>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmOpen(true)}
disabled={count === 0}
className="text-destructive hover:text-destructive hover:border-destructive/60"
>
<HugeiconsIcon icon={Delete02Icon} className="size-3.5 mr-1.5" />
Clear chats
</Button>
</SettingsRow>
</SettingsSection>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
Clear {count ?? 0} chat{count === 1 ? "" : "s"}?
</DialogTitle>
<DialogDescription>
This permanently deletes every chat and message stored on this device. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
Cancel
</Button>
<Button
onClick={handleClear}
disabled={clearing}
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
>
{clearing ? "Clearing…" : `Clear ${count ?? 0} chat${count === 1 ? "" : "s"}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,237 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { usePlatformStore } from "@/config/env";
import { resetOnboardingDone } from "@/features/auth";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { useSettingsDialogStore } from "@/features/settings";
import { useNavigate, useRouterState } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
// Keys cleared by "Reset all local preferences".
//
// NEVER include auth / session keys here — resetting them would log the user
// out, which is not what users expect from a "reset preferences" button.
//
// Explicitly EXCLUDED:
// - "unsloth_auth_token" (auth: access token)
// - "unsloth_auth_refresh_token" (auth: refresh token)
// - "unsloth_auth_must_change_password" (auth: forced password change flag)
// - "unsloth_onboarding_done" (session: would force re-onboarding)
const PREFS_KEYS: string[] = [
// Appearance
"theme",
// UI state
"sidebar_pinned",
"unsloth_sidebar_navigate_open",
"unsloth_settings_active_tab",
// Chat runtime prefs
"unsloth_chat_auto_title",
"unsloth_hf_token",
"unsloth_auto_heal_tool_calls",
"unsloth_max_tool_calls_per_message",
"unsloth_tool_call_timeout",
"unsloth_chat_inference_params",
"unsloth_chat_collapsible_state",
// Chat presets
"unsloth_chat_custom_presets",
"unsloth_chat_active_preset",
"unsloth_chat_system_prompts",
"unsloth_chat_system_prompts_migrated",
// Training UI prefs
"unsloth_training_config_v1",
"unsloth_prev_max_steps",
"unsloth_prev_save_steps",
// Guided tour flags
"tour:studio:v1",
];
// Set to true from resetAllPrefs so the unmount-commit effect skips writing
// back the in-memory draft — otherwise the cleanup would re-persist the old
// HF token into localStorage after it was just cleared, and the subsequent
// reload would read the re-written value.
let resetInProgress = false;
function resetAllPrefs() {
resetInProgress = true;
for (const key of PREFS_KEYS) {
try {
localStorage.removeItem(key);
} catch {
// ignore
}
}
window.location.reload();
}
export function GeneralTab() {
const navigate = useNavigate();
const closeDialog = useSettingsDialogStore((s) => s.closeDialog);
const { pathname, search } = useRouterState({
select: (s) => ({
pathname: s.location.pathname,
search:
"searchStr" in s.location
? (s.location as { searchStr?: string }).searchStr ?? ""
: typeof window !== "undefined"
? window.location.search
: "",
}),
});
const hfToken = useChatRuntimeStore((s) => s.hfToken);
const setHfToken = useChatRuntimeStore((s) => s.setHfToken);
const autoTitle = useChatRuntimeStore((s) => s.autoTitle);
const setAutoTitle = useChatRuntimeStore((s) => s.setAutoTitle);
const chatOnly = usePlatformStore((s) => s.chatOnly);
const redirectTo = `${pathname}${search}`;
const [draftToken, setDraftToken] = useState(hfToken ?? "");
const [showToken, setShowToken] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const draftRef = useRef(draftToken);
useEffect(() => {
draftRef.current = draftToken;
}, [draftToken]);
// Commit on unmount (dialog close / tab switch). Skip during reset-prefs
// flow so we don't re-persist the draft after localStorage was cleared.
useEffect(() => {
return () => {
if (resetInProgress) return;
const trimmed = draftRef.current.trim();
const current = useChatRuntimeStore.getState().hfToken;
if (trimmed !== current) {
useChatRuntimeStore.getState().setHfToken(trimmed);
}
};
}, []);
const commitToken = () => {
const trimmed = draftToken.trim();
if (trimmed !== draftToken) setDraftToken(trimmed);
if (trimmed !== hfToken) setHfToken(trimmed);
};
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-semibold font-heading">General</h1>
<p className="text-xs text-muted-foreground">
Global preferences for Unsloth Studio.
</p>
</header>
<SettingsSection title="Account">
<SettingsRow
label="Hugging Face token"
description="Used to load gated models and push artifacts."
>
<div className="relative w-[260px]">
<Input
type={showToken ? "text" : "password"}
placeholder="hf_…"
value={draftToken}
onChange={(e) => setDraftToken(e.target.value)}
onBlur={commitToken}
className="h-8 w-full pr-8 font-mono text-xs"
/>
<button
type="button"
onClick={() => setShowToken((s) => !s)}
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
aria-label={showToken ? "Hide token" : "Show token"}
tabIndex={-1}
>
{showToken ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
</button>
</div>
</SettingsRow>
</SettingsSection>
<SettingsSection title="Chat defaults">
<SettingsRow
label="Auto-title new chats"
description="Generate a short title from the first message."
>
<Switch checked={autoTitle} onCheckedChange={setAutoTitle} />
</SettingsRow>
</SettingsSection>
{!chatOnly && (
<SettingsSection title="Getting started">
<SettingsRow
label="Start onboarding"
description="Open the setup wizard again without changing your account."
>
<Button
variant="outline"
size="sm"
onClick={() => {
resetOnboardingDone();
closeDialog();
navigate({ to: "/onboarding", search: { redirectTo } });
}}
>
Start onboarding
</Button>
</SettingsRow>
</SettingsSection>
)}
<SettingsSection title="Danger zone">
<SettingsRow
destructive
label="Reset all local preferences"
description="Clears theme, tokens, sidebar state, and presets. Chats and API keys are not affected."
>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmOpen(true)}
className="text-destructive hover:text-destructive hover:border-destructive/60"
>
Reset preferences
</Button>
</SettingsRow>
</SettingsSection>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Reset all local preferences?</DialogTitle>
<DialogDescription>
This clears your theme, tokens, and stored settings, then reloads
Studio. Chats and API keys are not affected.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
Cancel
</Button>
<Button
onClick={resetAllPrefs}
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
>
Reset and reload
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -14,7 +14,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactElement, useEffect, useState } from "react";
import { type ReactElement, useCallback, useEffect, useMemo, useState } from "react";
import { useSidebar } from "@/components/ui/sidebar";
import { DatasetPreviewDialog } from "./sections/dataset-preview-dialog";
import { DatasetSection } from "./sections/dataset-section";
import { ModelSection } from "./sections/model-section";
@ -24,8 +25,6 @@ import { LiveTrainingView } from "./live-training-view";
import { HistoricalTrainingView } from "./historical-training-view";
import { HistoryCardGrid } from "./history-card-grid";
const STUDIO_TOUR_KEY = "tour:studio:v1";
export function StudioPage(): ReactElement {
useTrainingRuntimeLifecycle();
const showTrainingView = useTrainingRuntimeStore(shouldShowTrainingView);
@ -49,7 +48,12 @@ export function StudioPage(): ReactElement {
const closeDialog = useDatasetPreviewDialogStore((s) => s.close);
const [requestedTab, setRequestedTab] = useState("configure");
const [selectedHistoryRunId, setSelectedHistoryRunId] = useState<string | null>(null);
const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId);
const setSelectedHistoryRunId = useTrainingRuntimeStore((s) => s.setSelectedHistoryRunId);
useEffect(() => {
return () => setSelectedHistoryRunId(null);
}, [setSelectedHistoryRunId]);
// Derive activeTab: auto-switch to "current-run" only while training is
// genuinely running. Once training ends, honour whatever tab the user clicks.
@ -61,15 +65,24 @@ export function StudioPage(): ReactElement {
? "configure"
: requestedTab;
const { setPinned } = useSidebar();
const pinSidebar = useCallback(() => setPinned(true), [setPinned]);
const tourEnabled = hasHydratedRuntime && !isHydratingRuntime;
const isConfigTour = activeTab === "configure";
const tourSteps = activeTab === "current-run" ? studioTrainingTourSteps : studioTourSteps;
const baseTourSteps = activeTab === "current-run" ? studioTrainingTourSteps : studioTourSteps;
// Inject onEnter for navbar-targeting steps so the sidebar expands during the tour.
const tourSteps = useMemo(
() =>
baseTourSteps.map((step) =>
step.target === "navbar" ? { ...step, onEnter: pinSidebar } : step,
),
[baseTourSteps, pinSidebar],
);
const tour = useGuidedTourController({
id: "studio",
steps: tourSteps,
enabled: tourEnabled,
autoKey: isConfigTour ? STUDIO_TOUR_KEY : undefined,
autoWhen: isConfigTour,
});
const setTourOpen = tour.setOpen;
@ -86,6 +99,14 @@ export function StudioPage(): ReactElement {
}
}, [isTrainingRunning, requestedTab]);
// Selecting a run from the sidebar only sets selectedHistoryRunId; auto-switch
// to the History tab so the main panel reflects the selection.
useEffect(() => {
if (selectedHistoryRunId && requestedTab !== "history") {
setRequestedTab("history");
}
}, [selectedHistoryRunId, requestedTab]);
useEffect(() => {
ensureModelDefaultsLoaded();
ensureDatasetChecked();
@ -106,7 +127,7 @@ export function StudioPage(): ReactElement {
})();
return (
<div className="relative min-h-screen overflow-hidden bg-background">
<div className="relative min-h-screen bg-background">
<main className="relative z-10 mx-auto max-w-7xl px-4 py-4 sm:px-6">
<GuidedTour {...tour.tourProps} celebrate={isConfigTour} />

View file

@ -0,0 +1,56 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useCallback, useEffect, useRef, useState } from "react";
import { listTrainingRuns } from "../api/history-api";
import type { TrainingRunSummary } from "../types/history";
const SIDEBAR_LIMIT = 20;
const RUNNING_POLL_MS = 5000;
export function useTrainingHistorySidebarItems(enabled: boolean) {
const [items, setItems] = useState<TrainingRunSummary[]>([]);
const [loaded, setLoaded] = useState(false);
const controllerRef = useRef<AbortController | null>(null);
const inFlightRef = useRef(false);
const fetchRuns = useCallback(async () => {
if (inFlightRef.current) {
return;
}
const controller = new AbortController();
controllerRef.current = controller;
inFlightRef.current = true;
try {
const result = await listTrainingRuns(SIDEBAR_LIMIT, 0, controller.signal);
setItems(result.runs);
setLoaded(true);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
} finally {
if (controllerRef.current === controller) {
controllerRef.current = null;
}
inFlightRef.current = false;
}
}, []);
useEffect(() => {
if (!enabled) return;
void fetchRuns();
return () => {
controllerRef.current?.abort();
};
}, [enabled, fetchRuns]);
const hasRunning = items.some((r) => r.status === "running");
useEffect(() => {
if (!enabled || !hasRunning) return;
const timer = setInterval(() => {
void fetchRuns();
}, RUNNING_POLL_MS);
return () => clearInterval(timer);
}, [enabled, hasRunning, fetchRuns]);
return { items, loaded, refresh: fetchRuns };
}

View file

@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useEffect } from "react";
import { useTrainingRuntimeStore } from "@/features/training";
let currentHandler: ((e: BeforeUnloadEvent) => void) | null = null;
/**
* Mounts a beforeunload guard that warns the user if training is running.
* Call once at the app root.
*/
export function useTrainingUnloadGuard() {
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (!useTrainingRuntimeStore.getState().isTrainingRunning) return;
e.preventDefault();
e.returnValue = "";
};
currentHandler = handler;
window.addEventListener("beforeunload", handler);
return () => {
if (currentHandler === handler) currentHandler = null;
window.removeEventListener("beforeunload", handler);
};
}, []);
}
/**
* Removes the active beforeunload guard (if any).
* Call this before intentionally ending the session (e.g. shutting down
* the Studio server) so the "Server stopped" page can render without
* the browser prompting the user to confirm leaving.
*/
export function removeTrainingUnloadGuard() {
if (currentHandler) {
window.removeEventListener("beforeunload", currentHandler);
currentHandler = null;
}
}

View file

@ -7,6 +7,7 @@ export {
useTrainingRuntimeStore,
} from "./stores/training-runtime-store";
export { useTrainingActions } from "./hooks/use-training-actions";
export { useTrainingHistorySidebarItems } from "./hooks/use-training-history-sidebar";
export { useTrainingRuntimeLifecycle } from "./hooks/use-training-runtime-lifecycle";
export { useMaxStepsEpochsToggle } from "./hooks/use-max-steps-epochs-toggle";
export { HfDatasetSubsetSplitSelectors } from "./components/hf-dataset-subset-split-selectors";

View file

@ -41,6 +41,7 @@ const initialState: TrainingRuntimeState = {
evalLossHistory: [],
resetGeneration: 0,
stopRequested: false,
selectedHistoryRunId: null,
};
function sortSeries(points: TrainingSeriesPoint[]): TrainingSeriesPoint[] {
@ -171,6 +172,9 @@ export const useTrainingRuntimeStore = create<TrainingRuntimeStore>()((set) => (
sseConnected: false,
}),
setSelectedHistoryRunId: (selectedHistoryRunId) =>
set({ selectedHistoryRunId }),
applyStatus: (payload) =>
set((state) => {
const metricHistory = applyMetricHistoryFromStatus(payload);

View file

@ -99,6 +99,7 @@ export interface TrainingRuntimeState {
evalLossHistory: TrainingSeriesPoint[];
resetGeneration: number;
stopRequested: boolean;
selectedHistoryRunId: string | null;
}
export interface TrainingRuntimeActions {
@ -115,6 +116,7 @@ export interface TrainingRuntimeActions {
applyProgress: (payload: TrainingProgressPayload, eventId?: number) => void;
setStartQueued: (jobId: string, message: string) => void;
setRuntimeError: (message: string) => void;
setSelectedHistoryRunId: (id: string | null) => void;
}
export type TrainingRuntimeStore = TrainingRuntimeState & TrainingRuntimeActions;

View file

@ -1,22 +1,23 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useEffect, useState } from "react";
import { useSyncExternalStore } from "react";
const MOBILE_BREAKPOINT = 768;
const MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
export function useIsMobile() {
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
function getSnapshot(): boolean {
if (typeof window === "undefined") return false;
return window.matchMedia(MEDIA_QUERY).matches;
}
function subscribe(callback: () => void): () => void {
if (typeof window === "undefined") return () => {};
const mql = window.matchMedia(MEDIA_QUERY);
mql.addEventListener("change", callback);
return () => mql.removeEventListener("change", callback);
}
export function useIsMobile(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, () => false);
}

View file

@ -0,0 +1,59 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useCallback, useSyncExternalStore } from "react";
const PINNED_KEY = "sidebar_pinned";
function loadPinned(): boolean {
if (typeof window === "undefined") return true;
try {
const raw = window.localStorage.getItem(PINNED_KEY);
if (raw === null) return true;
return raw === "true";
} catch {
return true;
}
}
let pinnedValue = loadPinned();
const listeners = new Set<() => void>();
function subscribe(cb: () => void) {
listeners.add(cb);
if (typeof window === "undefined") {
return () => listeners.delete(cb);
}
const onStorage = (e: StorageEvent) => {
if (e.key === PINNED_KEY || e.key === null) {
pinnedValue = loadPinned();
cb();
}
};
window.addEventListener("storage", onStorage);
return () => {
listeners.delete(cb);
window.removeEventListener("storage", onStorage);
};
}
function setPinnedGlobal(next: boolean) {
pinnedValue = next;
try {
window.localStorage.setItem(PINNED_KEY, String(next));
} catch {}
listeners.forEach((cb) => cb());
}
export function useSidebarPin() {
const pinned = useSyncExternalStore(
subscribe,
() => pinnedValue,
() => false,
);
const setPinned = useCallback((value: boolean) => setPinnedGlobal(value), []);
const togglePinned = useCallback(() => setPinnedGlobal(!pinnedValue), []);
return { pinned, setPinned, togglePinned };
}

View file

@ -15,439 +15,501 @@
@custom-variant dark (&:is(.dark *));
@font-face {
font-family: "Hellix";
src: url("/fonts/Hellix-SemiBold.woff2") format("woff2"),
url("/fonts/Hellix-SemiBold.woff") format("woff");
font-weight: 600;
font-style: normal;
font-display: swap;
font-family: "Hellix";
src: url("/fonts/Hellix-SemiBold.woff2") format("woff2"),
url("/fonts/Hellix-SemiBold.woff") format("woff");
font-weight: 600;
font-style: normal;
font-display: swap;
}
:root {
/* Animation timing */
--duration-micro: 100ms;
--duration-fast: 150ms;
--duration-normal: 200ms;
/* Animation timing */
--duration-micro: 100ms;
--duration-fast: 150ms;
--duration-normal: 200ms;
/* Easing curves (Emil Kowalski) */
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
/* Easing curves (Emil Kowalski) */
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
--background: oklch(1 0 0);
--foreground: oklch(0.2686 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.1281 0.0179 169.2764);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.1281 0.0179 169.2764);
--primary: oklch(0.6929 0.1396 166.5513);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.9596 0.0275 167.8295);
--secondary-foreground: oklch(0.2868 0.0649 159.9823);
--muted: oklch(0.9702 0 0);
--muted-foreground: oklch(0.5486 0 0);
--accent: oklch(0.9596 0.0275 167.8295);
--accent-foreground: oklch(0.2868 0.0649 159.9823);
--destructive: oklch(0.6368 0.2078 25.3313);
--border: oklch(0.9208 0.0101 164.8536);
--input: oklch(0.9208 0.0101 164.8536);
--ring: oklch(0.6929 0.1396 166.5513);
--chart-1: oklch(0.6929 0.1396 166.5513);
--chart-2: oklch(0.694 0.1395 136.6059);
--chart-3: oklch(0.7014 0.1193 197.5897);
--chart-4: oklch(0.6926 0.1112 346.5775);
--chart-5: oklch(0.7497 0.1003 85.0057);
--radius: 1.2rem;
--sidebar: oklch(0.975 0 0);
--sidebar-foreground: oklch(0.1281 0.0179 169.2764);
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.96 0.0279 166.55);
--sidebar-accent-foreground: oklch(0.2868 0.0649 159.9823);
--sidebar-border: oklch(0.9208 0.0101 164.8536);
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
--destructive-foreground: oklch(1 0 0);
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
--font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans);
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 0px;
--letter-spacing: 0em;
--spacing: 0.25rem;
/*--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
/*--shadow-xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
/*--shadow-sm:*/
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
/* 0px 1px 2px 0px hsl(0 0% 0% / 0);*/
/*--shadow:*/
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
/* 0px 1px 2px 0px hsl(0 0% 0% / 0);*/
/*--shadow-md:*/
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
/* 0px 2px 4px 0px hsl(0 0% 0% / 0);*/
/*--shadow-lg:*/
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
/* 0px 4px 6px 0px hsl(0 0% 0% / 0);*/
/*--shadow-xl:*/
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
/* 0px 8px 10px 0px hsl(0 0% 0% / 0);*/
/*--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
--tracking-normal: 0em;
--background: oklch(1 0 0);
--foreground: oklch(0.2686 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.1281 0.0179 169.2764);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.1281 0.0179 169.2764);
--primary: oklch(0.6929 0.1396 166.5513);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.9596 0.0275 167.8295);
--secondary-foreground: oklch(0.2868 0.0649 159.9823);
--muted: oklch(0.9702 0 0);
--muted-foreground: oklch(0.5486 0 0);
--accent: oklch(0.9596 0.0275 167.8295);
--accent-foreground: oklch(0.2868 0.0649 159.9823);
--destructive: oklch(0.6368 0.2078 25.3313);
--border: oklch(0.9208 0.0101 164.8536);
--input: oklch(0.9208 0.0101 164.8536);
--ring: oklch(0.6929 0.1396 166.5513);
--chart-1: oklch(0.6929 0.1396 166.5513);
--chart-2: oklch(0.694 0.1395 136.6059);
--chart-3: oklch(0.7014 0.1193 197.5897);
--chart-4: oklch(0.6926 0.1112 346.5775);
--chart-5: oklch(0.7497 0.1003 85.0057);
--radius: 1.1rem;
--sidebar: oklch(0.99 0 0);
--sidebar-foreground: oklch(0.1281 0.0179 169.2764);
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.96 0.0279 166.55);
--sidebar-accent-foreground: oklch(0.2868 0.0649 159.9823);
--sidebar-border: oklch(0.9208 0.0101 164.8536);
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
--destructive-foreground: oklch(1 0 0);
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
--font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans);
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 0px;
--letter-spacing: 0em;
--spacing: 0.25rem;
/*--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
/*--shadow-xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
/*--shadow-sm:*/
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
/* 0px 1px 2px 0px hsl(0 0% 0% / 0);*/
/*--shadow:*/
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
/* 0px 1px 2px 0px hsl(0 0% 0% / 0);*/
/*--shadow-md:*/
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
/* 0px 2px 4px 0px hsl(0 0% 0% / 0);*/
/*--shadow-lg:*/
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
/* 0px 4px 6px 0px hsl(0 0% 0% / 0);*/
/*--shadow-xl:*/
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
/* 0px 8px 10px 0px hsl(0 0% 0% / 0);*/
/*--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
--tracking-normal: -0.01em;
}
.dark {
--background: oklch(0.24 0 0);
--foreground: oklch(0.98 0 0);
--card: oklch(0.28 0 0);
--card-foreground: oklch(0.98 0 0);
--popover: oklch(0.28 0 0);
--popover-foreground: oklch(0.98 0 0);
--primary: oklch(0.6929 0.1396 166.5513);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.33 0 0);
--secondary-foreground: oklch(0.98 0 0);
--muted: oklch(0.33 0 0);
--muted-foreground: oklch(0.70 0 0);
--accent: oklch(0.33 0 0);
--accent-foreground: oklch(0.98 0 0);
--destructive: oklch(0.6368 0.2078 25.3313);
--border: oklch(0.38 0 0);
--input: oklch(0.38 0 0);
--ring: oklch(0.6929 0.1396 166.5513);
--chart-1: oklch(0.7511 0.1407 166.2284);
--chart-2: oklch(0.75 0.14 136.5572);
--chart-3: oklch(0.7554 0.1285 197.339);
--chart-4: oklch(0.7503 0.1199 346.7805);
--chart-5: oklch(0.799 0.1196 84.6633);
--sidebar: oklch(0.24 0 0);
--sidebar-foreground: oklch(0.98 0 0);
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.33 0 0);
--sidebar-accent-foreground: oklch(0.98 0 0);
--sidebar-border: oklch(0.38 0 0);
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
--destructive-foreground: oklch(1 0 0);
--radius: 1.2rem;
--font-sans: Geist, ui-sans-serif, sans-serif, system-ui;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 0px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px
hsl(0 0% 0% / 0);
--shadow: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px hsl(0 0% 0% / 0);
--shadow-md: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 2px 4px 0px
hsl(0 0% 0% / 0);
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px 0px
hsl(0 0% 0% / 0);
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px 0px
hsl(0 0% 0% / 0);
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);
--background: oklch(0.24 0 0);
--foreground: oklch(0.98 0 0);
--card: oklch(0.28 0 0);
--card-foreground: oklch(0.98 0 0);
--popover: oklch(0.28 0 0);
--popover-foreground: oklch(0.98 0 0);
--primary: oklch(0.6929 0.1396 166.5513);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.33 0 0);
--secondary-foreground: oklch(0.98 0 0);
--muted: oklch(0.33 0 0);
--muted-foreground: oklch(0.70 0 0);
--accent: oklch(0.33 0 0);
--accent-foreground: oklch(0.98 0 0);
--destructive: oklch(0.6368 0.2078 25.3313);
--border: oklch(0.38 0 0);
--input: oklch(0.38 0 0);
--ring: oklch(0.6929 0.1396 166.5513);
--chart-1: oklch(0.7511 0.1407 166.2284);
--chart-2: oklch(0.75 0.14 136.5572);
--chart-3: oklch(0.7554 0.1285 197.339);
--chart-4: oklch(0.7503 0.1199 346.7805);
--chart-5: oklch(0.799 0.1196 84.6633);
--sidebar: oklch(0.24 0 0);
--sidebar-foreground: oklch(0.98 0 0);
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.33 0 0);
--sidebar-accent-foreground: oklch(0.98 0 0);
--sidebar-border: oklch(0.38 0 0);
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
--destructive-foreground: oklch(1 0 0);
--radius: 1.1rem;
--font-sans: Geist, ui-sans-serif, sans-serif, system-ui;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 0px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px hsl(0 0% 0% / 0);
--shadow: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px hsl(0 0% 0% / 0);
--shadow-md: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 2px 4px 0px hsl(0 0% 0% / 0);
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px 0px hsl(0 0% 0% / 0);
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px 0px hsl(0 0% 0% / 0);
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);
}
@theme inline {
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
--font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-code-block: #181818;
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--font-mono: JetBrains Mono, monospace;
--font-serif: Source Serif 4, serif;
--radius: 1.2rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
/*--shadow-2xl: var(--shadow-2xl);*/
/*--shadow-xl: var(--shadow-xl);*/
/*--shadow-lg: var(--shadow-lg);*/
/*--shadow-md: var(--shadow-md);*/
/*--shadow: var(--shadow);*/
/*--shadow-sm: var(--shadow-sm);*/
/*--shadow-xs: var(--shadow-xs);*/
/*--shadow-2xs: var(--shadow-2xs);*/
/*--spacing: var(--spacing);*/
/*--letter-spacing: var(--letter-spacing);*/
/*--shadow-offset-y: var(--shadow-offset-y);*/
/*--shadow-offset-x: var(--shadow-offset-x);*/
/*--shadow-spread: var(--shadow-spread);*/
/*--shadow-blur: var(--shadow-blur);*/
/*--shadow-opacity: var(--shadow-opacity);*/
/*--color-shadow-color: var(--shadow-color);*/
--color-destructive-foreground: var(--destructive-foreground);
--animate-pulse: pulse var(--duration) ease-out infinite;
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 0 var(--pulse-color);
}
50% {
box-shadow: 0 0 0 8px var(--pulse-color);
}
}
--animate-shiny-text: shiny-text 8s infinite;
@keyframes shiny-text {
0%,
90%,
100% {
background-position: calc(-100% - var(--shiny-width)) 0;
}
30%,
60% {
background-position: calc(100% + var(--shiny-width)) 0;
}
}
--animate-shine: shine var(--duration) infinite linear
;
@keyframes shine {
0% {
background-position: 0% 0%;
}
50% {
background-position: 100% 100%;
}
to {
background-position: 0% 0%;
}
}}
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
--font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-code-block: #181818;
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--font-mono: JetBrains Mono, monospace;
--font-serif: Source Serif 4, serif;
--radius: 1.1rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
/*--shadow-2xl: var(--shadow-2xl);*/
/*--shadow-xl: var(--shadow-xl);*/
/*--shadow-lg: var(--shadow-lg);*/
/*--shadow-md: var(--shadow-md);*/
/*--shadow: var(--shadow);*/
/*--shadow-sm: var(--shadow-sm);*/
/*--shadow-xs: var(--shadow-xs);*/
/*--shadow-2xs: var(--shadow-2xs);*/
/*--spacing: var(--spacing);*/
/*--letter-spacing: var(--letter-spacing);*/
/*--shadow-offset-y: var(--shadow-offset-y);*/
/*--shadow-offset-x: var(--shadow-offset-x);*/
/*--shadow-spread: var(--shadow-spread);*/
/*--shadow-blur: var(--shadow-blur);*/
/*--shadow-opacity: var(--shadow-opacity);*/
/*--color-shadow-color: var(--shadow-color);*/
--color-destructive-foreground: var(--destructive-foreground);
--animate-pulse: pulse var(--duration) ease-out infinite;
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 0 var(--pulse-color);
}
50% {
box-shadow: 0 0 0 8px var(--pulse-color);
}
}
--animate-shiny-text: shiny-text 8s infinite;
@keyframes shiny-text {
0%,
90%,
100% {
background-position: calc(-100% - var(--shiny-width)) 0;
}
30%,
60% {
background-position: calc(100% + var(--shiny-width)) 0;
}
}
--animate-shine: shine var(--duration) infinite linear;
@keyframes shine {
0% {
background-position: 0% 0%;
}
50% {
background-position: 100% 100%;
}
to {
background-position: 0% 0%;
}
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply font-sans bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
html {
@apply font-sans;
scrollbar-gutter: stable;
}
body[data-scroll-locked] {
margin-right: 0 !important;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-heading);
}
.font-medium,
.font-semibold,
.font-bold {
font-family: var(--font-heading);
}
* {
@apply border-border outline-ring/50;
}
body {
@apply font-sans bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
html {
@apply font-sans h-full;
}
body,
#root {
@apply h-full;
}
body[data-scroll-locked] {
margin-right: 0 !important;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-sans);
letter-spacing: -0.02em;
}
}
@layer utilities {
/* Heading font utility */
.font-heading {
font-family: var(--font-heading);
}
/* Elevated surface shadow (use ring-* for borders) */
.shadow-border {
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
--tw-shadow-colored: 0 4px 16px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000),
var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.dark .shadow-border {
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
/* Heading font utility */
.font-heading {
font-family: var(--font-heading);
}
/* Fine-tuning Studio: equal default height, expandable when needed (md+) */
.min-h-studio-config-column {
@apply md:min-h-[470px];
}
.h-studio-config-column {
@apply md:h-[470px];
}
/* Elevated surface shadow (use ring-* for borders) */
.shadow-border {
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
--tw-shadow-colored: 0 4px 16px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000),
var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
[data-streamdown="unordered-list"] {
list-style-type: disc;
list-style-position: outside;
padding-left: 1.25rem;
margin-block: 0.5rem;
}
.dark .shadow-border {
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
[data-streamdown="ordered-list"] {
list-style-type: decimal;
list-style-position: outside;
padding-left: 1.25rem;
margin-block: 0.5rem;
}
.chat-composer-surface {
border: 1px solid oklch(0.93 0 0 / 1);
background-clip: padding-box;
box-shadow:
0 1px 2px oklch(0 0 0 / 0.04),
0 6px 14px oklch(0 0 0 / 0.05),
0 18px 40px oklch(0 0 0 / 0.05);
}
[data-streamdown="list-item"] {
display: list-item;
}
.dark .chat-composer-surface {
border-color: oklch(0.38 0 0 / 1);
box-shadow:
0 1px 2px oklch(0 0 0 / 0.2),
0 8px 20px oklch(0 0 0 / 0.25),
0 22px 48px oklch(0 0 0 / 0.22);
}
/* Flatten code blocks: single border, language label, then code directly */
[data-streamdown="code-block-body"] {
border: none !important;
border-radius: 0 !important;
background: transparent !important;
padding: 0 !important;
}
[data-streamdown="code-block"] {
gap: 0;
padding: 0.5rem;
/* Wide lines must scroll inside the thread column, not widen past the composer (flex min-width:auto). */
max-width: 100%;
min-width: 0;
overflow-x: auto;
}
[data-streamdown="code-block-header"] {
padding-left: 0.75rem;
}
/* Fine-tuning Studio: equal default height, expandable when needed (md+) */
.min-h-studio-config-column {
@apply md:min-h-[470px];
}
/* Chat thread: code slightly smaller by default; step up when the thread column is wide. */
.aui-thread-root [data-streamdown="code-block"] {
font-size: 0.8125rem;
line-height: 1.55;
}
.h-studio-config-column {
@apply md:h-[470px];
}
.aui-thread-root [data-streamdown="code-block-header"] {
font-size: 0.6875rem;
}
[data-streamdown="unordered-list"] {
list-style-type: disc;
list-style-position: outside;
padding-left: 1.25rem;
margin-block: 0.5rem;
}
@container (min-width: 36rem) {
.aui-thread-root [data-streamdown="code-block"] {
font-size: 0.875rem;
}
[data-streamdown="ordered-list"] {
list-style-type: decimal;
list-style-position: outside;
padding-left: 1.25rem;
margin-block: 0.5rem;
}
.aui-thread-root [data-streamdown="code-block-header"] {
font-size: 0.75rem;
}
}
[data-streamdown="list-item"] {
display: list-item;
}
/* Chat: use the app sans stack for UI + prose. */
.aui-thread-root {
--font-heading: var(--font-sans);
font-family: var(--font-sans);
}
/* Flatten code blocks: single border, language label, then code directly */
[data-streamdown="code-block-body"] {
border: none !important;
border-radius: 0 !important;
background: transparent !important;
padding: 0 !important;
}
/* Keep monospace for code fences and inline code (not KaTeX). */
.aui-thread-root [data-streamdown="code-block"] pre,
.aui-thread-root [data-streamdown="code-block"] code {
font-family: var(--font-mono), ui-monospace, monospace;
}
[data-streamdown="code-block"] {
gap: 0;
padding: 0.5rem;
/* Wide lines must scroll inside the thread column, not widen past the composer (flex min-width:auto). */
max-width: 100%;
min-width: 0;
overflow-x: auto;
}
.aui-thread-root :where(p, li, td, th, blockquote, h1, h2, h3, h4, h5, h6) code {
font-family: var(--font-mono), ui-monospace, monospace;
}
[data-streamdown="code-block-header"] {
padding-left: 0.75rem;
}
/* Align fenced code blocks with the main chat column even when nested in lists. */
.aui-thread-root [data-streamdown="list-item"] > [data-streamdown="code-block"],
.aui-thread-root [data-streamdown="list-item"] [data-streamdown="code-block"] {
margin-left: -1.25rem;
width: calc(100% + 1.25rem);
max-width: calc(100% + 1.25rem);
}
/* Chat thread: code slightly smaller by default; step up when the thread column is wide. */
.aui-thread-root [data-streamdown="code-block"] {
font-size: 0.8125rem;
line-height: 1.55;
}
.dark .aui-thread-root [data-streamdown="code-block"] {
/* Streamdown `pre` uses `dark:bg-[var(--shiki-dark-bg,...)]`; keep one surface on the outer shell. */
--shiki-dark-bg: transparent;
background: var(--color-code-block);
border: 1px solid oklch(1 0 0 / 0.07);
}
.aui-thread-root [data-streamdown="code-block-header"] {
font-size: 0.6875rem;
}
@container (min-width: 36rem) {
.aui-thread-root [data-streamdown="code-block"] {
font-size: 0.875rem;
}
.aui-thread-root [data-streamdown="code-block-header"] {
font-size: 0.75rem;
}
}
/* Chat: use the app sans stack for UI + prose. */
.aui-thread-root {
--font-heading: var(--font-sans);
font-family: var(--font-sans);
}
/* Keep monospace for code fences and inline code (not KaTeX). */
.aui-thread-root [data-streamdown="code-block"] pre,
.aui-thread-root [data-streamdown="code-block"] code {
font-family: var(--font-mono), ui-monospace, monospace;
}
.aui-thread-root :where(p, li, td, th, blockquote, h1, h2, h3, h4, h5, h6) code {
font-family: var(--font-mono), ui-monospace, monospace;
}
/* Align fenced code blocks with the main chat column even when nested in lists. */
.aui-thread-root [data-streamdown="list-item"] > [data-streamdown="code-block"],
.aui-thread-root [data-streamdown="list-item"] [data-streamdown="code-block"] {
margin-left: -1.25rem;
width: calc(100% + 1.25rem);
max-width: calc(100% + 1.25rem);
}
.dark .aui-thread-root [data-streamdown="code-block"] {
/* Streamdown `pre` uses `dark:bg-[var(--shiki-dark-bg,...)]`; keep one surface on the outer shell. */
--shiki-dark-bg: transparent;
background: var(--color-code-block);
border: 1px solid oklch(1 0 0 / 0.07);
}
}
/* Minimal scrollbar — thumb only, no track */
/* Flat scrollbar chrome */
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
scrollbar-width: thin;
scrollbar-color: oklch(0.5 0 0 / 0.54) transparent;
}
*:hover {
scrollbar-color: oklch(0.6 0 0 / 0.3) transparent;
}
.dark *:hover {
scrollbar-color: oklch(0.5 0 0 / 0.35) transparent;
.dark * {
scrollbar-color: oklch(0.67 0 0 / 0.5) transparent;
}
/* Webkit (Chrome, Safari, Edge) */
::-webkit-scrollbar {
width: 6px;
height: 6px;
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
background: transparent;
}
::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 9999px;
background: oklch(0.5 0 0 / 0.54);
border-radius: 9999px;
}
*:hover::-webkit-scrollbar-thumb {
background: oklch(0.6 0 0 / 0.3);
::-webkit-scrollbar-button {
display: none;
width: 0;
height: 0;
}
.dark *:hover::-webkit-scrollbar-thumb {
background: oklch(0.5 0 0 / 0.35);
.dark *::-webkit-scrollbar-thumb {
background: oklch(0.67 0 0 / 0.5);
}
/* Chat viewport: solid track matching sidebar so the scrollbar reads as a
full-height rail flush to the right edge, without a separate decorative strip. */
.aui-thread-viewport {
scrollbar-color: oklch(0.5 0 0 / 0.54) var(--sidebar);
}
.dark .aui-thread-viewport {
scrollbar-color: oklch(0.67 0 0 / 0.5) var(--sidebar);
}
.aui-thread-viewport::-webkit-scrollbar-track {
background: var(--sidebar);
}
[data-sidebar="content"] {
scrollbar-color: oklch(0.5 0 0 / 0.7) transparent;
}
.dark [data-sidebar="content"] {
scrollbar-color: oklch(0.72 0 0 / 0.65) transparent;
}
/*---break---*/
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
::view-transition-old(root), ::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}

View file

@ -73,7 +73,109 @@ function Refresh-Environment {
}
$machinePath = [System.Environment]::GetEnvironmentVariable('Path', 'Machine')
$userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User')
$env:Path = "$machinePath;$userPath"
# Merge: venv Scripts (if active) > Machine > User > current $env:Path. Dedup raw+expanded.
$venvScripts = if ($env:VIRTUAL_ENV) { Join-Path $env:VIRTUAL_ENV 'Scripts' } else { $null }
$sources = @()
if ($venvScripts) { $sources += $venvScripts }
$sources += @($machinePath, $userPath, $env:Path)
$merged = ($sources | Where-Object { $_ }) -join ';'
$seen = @{}
$unique = New-Object System.Collections.Generic.List[string]
foreach ($p in $merged -split ";") {
$rawKey = $p.Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
$expKey = [Environment]::ExpandEnvironmentVariables($p).Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
if ($rawKey -and -not $seen.ContainsKey($rawKey) -and -not $seen.ContainsKey($expKey)) {
$seen[$rawKey] = $true
if ($expKey -and $expKey -ne $rawKey) { $seen[$expKey] = $true }
$unique.Add($p)
}
}
$env:Path = $unique -join ";"
}
# ── Helper: safely add a directory to the persistent User PATH ──
# Direct registry access preserves REG_EXPAND_SZ (avoids dotnet/runtime#1442).
# Append (default) keeps existing tools first; Prepend for must-win entries.
function Add-ToUserPath {
param(
[Parameter(Mandatory = $true)][string]$Directory,
[ValidateSet('Append','Prepend')]
[string]$Position = 'Append'
)
try {
$regKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
try {
$rawPath = $regKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
[string[]]$entries = if ($rawPath) { $rawPath -split ';' } else { @() } # string[] prevents scalar collapse
$normalDir = $Directory.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
$expNormalDir = [Environment]::ExpandEnvironmentVariables($Directory).Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
$kept = New-Object System.Collections.Generic.List[string]
$matchIndices = New-Object System.Collections.Generic.List[int]
for ($i = 0; $i -lt $entries.Count; $i++) {
$stripped = $entries[$i].Trim().Trim('"')
$rawNorm = $stripped.TrimEnd('\').ToLowerInvariant()
$expNorm = [Environment]::ExpandEnvironmentVariables($stripped).TrimEnd('\').ToLowerInvariant()
$isMatch = ($rawNorm -and ($rawNorm -eq $normalDir -or $rawNorm -eq $expNormalDir)) -or
($expNorm -and ($expNorm -eq $normalDir -or $expNorm -eq $expNormalDir))
if ($isMatch) {
$matchIndices.Add($i)
continue
}
$kept.Add($entries[$i])
}
$alreadyPresent = $matchIndices.Count -gt 0
if ($alreadyPresent -and $Position -eq 'Append') { # Append: idempotent no-op
return $false
}
if ($alreadyPresent -and $Position -eq 'Prepend' -and # Prepend: no-op if already at front
$matchIndices.Count -eq 1 -and $matchIndices[0] -eq 0) {
return $false
}
# One-time backup under HKCU\Software\Unsloth\PathBackup
if ($rawPath) {
try {
$backupKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Unsloth')
try {
$existingBackup = $backupKey.GetValue('PathBackup', $null)
if (-not $existingBackup) {
$backupKey.SetValue('PathBackup', $rawPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
}
} finally {
$backupKey.Close()
}
} catch { }
}
if (-not $rawPath) {
Write-Host "[WARN] User PATH is empty - initializing with $Directory" -ForegroundColor Yellow
}
$newPath = if ($rawPath) {
if ($Position -eq 'Prepend') {
(@($Directory) + $kept) -join ';'
} else {
($kept + @($Directory)) -join ';'
}
} else {
$Directory
}
if ($newPath -ceq $rawPath) { # no actual change
return $false
}
$regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
# Broadcast WM_SETTINGCHANGE via dummy env-var roundtrip.
# [NullString]::Value avoids PS 7.5+/.NET 9 $null-to-"" coercion.
try {
$d = "UnslothPathRefresh_$([guid]::NewGuid().ToString('N').Substring(0,8))"
[Environment]::SetEnvironmentVariable($d, '1', 'User')
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
} catch { }
return $true
} finally {
$regKey.Close()
}
} catch {
Write-Host "[WARN] Could not update User PATH: $($_.Exception.Message)" -ForegroundColor Yellow
return $false
}
}
# PowerShell 5.1 compatibility helper: avoid relying on New-TemporaryFile.
@ -493,6 +595,31 @@ if ($script:StudioVtOk -and -not $env:NO_COLOR) {
Write-Host " $Rule" -ForegroundColor DarkGray
}
# Back up User PATH under HKCU\Software\Unsloth before any modifications.
try {
$envKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $false)
if ($envKey) {
try {
$rawPath = $envKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
} finally {
$envKey.Close()
}
if ($rawPath) {
$backupKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Unsloth')
try {
$existingBackup = $backupKey.GetValue('PathBackup', $null)
if (-not $existingBackup) {
$backupKey.SetValue('PathBackup', $rawPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
}
} finally {
$backupKey.Close()
}
}
}
} catch {
Write-Host "[DEBUG] Could not back up User PATH: $($_.Exception.Message)" -ForegroundColor DarkGray
}
# ==========================================================================
# PHASE 1: System-level prerequisites (winget installs, env vars)
# All heavy system tool installs happen here BEFORE touching Python.
@ -626,11 +753,8 @@ if (-not $HasCmake) {
foreach ($d in $cmakeDefaults) {
if (Test-Path (Join-Path $d "cmake.exe")) {
$env:Path = "$d;$env:Path"
# Persist to user PATH so Refresh-Environment does not drop it later
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (-not $userPath -or $userPath -notlike "*$d*") {
[Environment]::SetEnvironmentVariable('Path', "$d;$userPath", 'User')
}
# Persist to user PATH (Prepend so this cmake wins over older ones).
Add-ToUserPath -Directory $d -Position 'Prepend' | Out-Null
$HasCmake = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue)
if ($HasCmake) {
Write-Host " Found cmake at $d (added to PATH)" -ForegroundColor Gray
@ -896,14 +1020,8 @@ $nvccBinDir = Split-Path $NvccPath -Parent
if ($env:PATH -notlike "*$nvccBinDir*") {
[Environment]::SetEnvironmentVariable('PATH', "$nvccBinDir;$env:PATH", 'Process')
}
# Persist nvcc bin dir to User PATH so it works in new terminals
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (-not $userPath -or $userPath -notlike "*$nvccBinDir*") {
if ($userPath) {
[Environment]::SetEnvironmentVariable('Path', "$nvccBinDir;$userPath", 'User')
} else {
[Environment]::SetEnvironmentVariable('Path', "$nvccBinDir", 'User')
}
# Persist nvcc bin dir (Prepend so the driver-compatible toolkit wins).
if (Add-ToUserPath -Directory $nvccBinDir -Position 'Prepend') {
substep "Persisted CUDA bin dir to user PATH"
}
@ -1061,15 +1179,11 @@ if ($HasPython) {
$PythonOk = $true
}
# Ensure Python Scripts dir is on PATH (so 'unsloth' command works in new terminals)
$ScriptsDir = python -c "import sysconfig; print(sysconfig.get_path('scripts', 'nt_user') if __import__('os').path.exists(sysconfig.get_path('scripts', 'nt_user')) else sysconfig.get_path('scripts'))"
# Add user-scheme Python Scripts dir to PATH (nt_user only, no venv fallback).
$ScriptsDir = python -c "import os, sysconfig; p = sysconfig.get_path('scripts', 'nt_user'); print(p if os.path.exists(p) else '')"
if ($LASTEXITCODE -eq 0 -and $ScriptsDir -and (Test-Path $ScriptsDir)) {
$UserPath = [Environment]::GetEnvironmentVariable('Path', 'User')
$UserPathEntries = if ($UserPath) { $UserPath.Split(';') } else { @() }
if (-not ($UserPathEntries | Where-Object { $_.TrimEnd('\') -eq $ScriptsDir })) {
$newUserPath = if ($UserPath) { "$ScriptsDir;$UserPath" } else { $ScriptsDir }
[Environment]::SetEnvironmentVariable('Path', $newUserPath, 'User')
# Append (not Prepend) -- this dir has other pip scripts; shim handles unsloth.
if (Add-ToUserPath -Directory $ScriptsDir) {
# Also add to current process so it's available immediately
$ProcessPathEntries = $env:PATH.Split(';')
if (-not ($ProcessPathEntries | Where-Object { $_.TrimEnd('\') -eq $ScriptsDir })) {

View file

@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "2026.4.5"
__version__ = "2026.4.6"
__all__ = [
"SUPPORTS_BFLOAT16",
@ -65,7 +65,9 @@ __all__ = [
"patch_compiled_autograd",
"process_vision_info",
"unsloth_compile_transformers",
"determine_attention_implementation",
"resolve_model_class",
"resolve_attention_implementation",
"resolve_encoder_attention_implementation",
"_set_attn_impl",
"patch_fast_lora",
"validate_loftq_config",
@ -233,6 +235,8 @@ def apply_unsloth_gradient_checkpointing(
# access on some GPU architectures (B200). Falls back to eager safely.
_FLEX_EXCLUDED_MODELS = ("gpt_oss", "mllama", "nemotron_h", "modernbert")
_EAGER_ONLY_PREFIXES = ("gemma3n",)
_FLASH_ATTENTION_MAX_HEAD_DIM = 256
_FLASH_ATTENTION_DISABLED_WARNED = set()
def _is_flex_excluded(model_type):
@ -243,57 +247,281 @@ def _is_eager_only(model_type):
return any(model_type.startswith(p) for p in _EAGER_ONLY_PREFIXES)
def _config_items(config):
if isinstance(config, dict):
return config.items()
if hasattr(config, "__dict__"):
return vars(config).items()
return ()
def _config_get(config, field_name, default = None):
if isinstance(config, dict):
return config.get(field_name, default)
return getattr(config, field_name, default)
def _config_set(config, field_name, value):
if isinstance(config, dict):
config[field_name] = value
elif config is not None:
setattr(config, field_name, value)
def _iter_attention_configs(config, seen = None):
if config is None or (
not isinstance(config, dict) and not hasattr(config, "__dict__")
):
return
if seen is None:
seen = set()
config_id = id(config)
if config_id in seen:
return
seen.add(config_id)
yield config
for field_name, child_config in _config_items(config):
if not isinstance(field_name, str) or not field_name.endswith("_config"):
continue
if isinstance(child_config, dict) or hasattr(child_config, "__dict__"):
yield from _iter_attention_configs(child_config, seen)
def _collect_attention_head_dims(config):
explicit_head_dims = []
for field_name in (
"head_dim",
"global_head_dim",
"local_head_dim",
"kv_head_dim",
):
value = _config_get(config, field_name, None)
if isinstance(value, int) and value > 0:
explicit_head_dims.append(value)
if len(explicit_head_dims) != 0:
return explicit_head_dims
head_dims = []
hidden_size_names = ("hidden_size", "d_model", "embed_dim", "dim")
num_heads_names = ("num_attention_heads", "num_heads", "n_heads")
for hidden_size_name in hidden_size_names:
hidden_size = _config_get(config, hidden_size_name, None)
if not isinstance(hidden_size, int) or hidden_size <= 0:
continue
for num_heads_name in num_heads_names:
num_heads = _config_get(config, num_heads_name, None)
if (
isinstance(num_heads, int)
and num_heads > 0
and (hidden_size % num_heads) == 0
):
head_dims.append(hidden_size // num_heads)
return head_dims
def _get_max_attention_head_dim(config):
head_dims = []
for attention_config in _iter_attention_configs(config):
head_dims.extend(_collect_attention_head_dims(attention_config))
return max(head_dims) if len(head_dims) != 0 else None
def _get_flash_attention_disable_reason(config):
max_head_dim = _get_max_attention_head_dim(config)
if max_head_dim is not None and max_head_dim > _FLASH_ATTENTION_MAX_HEAD_DIM:
return (
f"max attention head dim {max_head_dim} exceeds the Flash Attention 2 "
f"limit of {_FLASH_ATTENTION_MAX_HEAD_DIM}"
)
return None
def _is_flash_attention_disabled(config):
return _get_flash_attention_disable_reason(config) is not None
def _is_flash_attention_requested(attn_implementation):
return isinstance(attn_implementation, str) and attn_implementation.startswith(
"flash_attention"
)
def _disable_flash_attention_if_needed(
config,
attn_implementation = None,
supports_sdpa = False,
would_use_flash_attention = False,
disable_reason = None,
):
if disable_reason is None:
disable_reason = _get_flash_attention_disable_reason(config)
if disable_reason is None:
return attn_implementation
requested_attn_implementation = attn_implementation
if requested_attn_implementation is None:
requested_attn_implementation = _config_get(
config, "_attn_implementation", None
)
if requested_attn_implementation is None:
requested_attn_implementation = _config_get(config, "attn_implementation", None)
if requested_attn_implementation == "eager":
return _set_attn_impl(config, "eager")
fallback_attn_implementation = "sdpa" if supports_sdpa else "eager"
if (
_is_flash_attention_requested(requested_attn_implementation)
or would_use_flash_attention
):
logged_attn_implementation = (
requested_attn_implementation
if _is_flash_attention_requested(requested_attn_implementation)
else "flash_attention_2"
)
model_type = _config_get(config, "model_type", "")
warning_key = (
model_type,
logged_attn_implementation,
fallback_attn_implementation,
disable_reason,
)
if warning_key not in _FLASH_ATTENTION_DISABLED_WARNED:
_FLASH_ATTENTION_DISABLED_WARNED.add(warning_key)
print(
f"Unsloth: `{logged_attn_implementation}` is not supported "
f"for `{model_type}` because {disable_reason} - "
f"defaulting to `{fallback_attn_implementation}`."
)
return _set_attn_impl(config, fallback_attn_implementation)
def _set_attn_impl(config, impl):
"""Helper function to set attention implementation on config and return it."""
if config is not None:
setattr(config, "_attn_implementation", impl)
if hasattr(config, "attn_implementation"):
setattr(config, "attn_implementation", impl)
_config_set(config, "_attn_implementation", impl)
if isinstance(config, dict) or hasattr(config, "attn_implementation"):
_config_set(config, "attn_implementation", impl)
return impl
def determine_attention_implementation(model_class, config):
model_type = getattr(config, "model_type", "").lower()
def resolve_model_class(auto_model, config):
mapping = getattr(auto_model, "_model_mapping", {})
try:
result = mapping[config.__class__]
except Exception:
for config_class, model_class in mapping.items():
if isinstance(config, config_class):
result = model_class
break
else:
return None
# Eager-only models (e.g. gemma3n timm vision towers)
if _is_eager_only(model_type):
_set_attn_impl(config, "eager")
return "eager"
return result[0] if isinstance(result, (list, tuple)) else result
# Flash Attention 2
if HAS_FLASH_ATTENTION and model_class is not None:
supports_fa2 = getattr(model_class, "_supports_flash_attn_2", False) or getattr(
model_class, "_supports_flash_attn", False
def resolve_attention_implementation(
model_class,
config,
requested_attn_implementation = None,
supports_sdpa = None,
):
model_type_name = _config_get(config, "model_type", "")
model_type = model_type_name.lower()
if supports_sdpa is None:
supports_sdpa = model_class is not None and getattr(
model_class, "_supports_sdpa", False
)
if supports_fa2:
_set_attn_impl(config, "flash_attention_2")
return "flash_attention_2"
supports_flash_attention = model_class is not None and (
getattr(model_class, "_supports_flash_attn_2", False)
or getattr(model_class, "_supports_flash_attn", False)
)
disable_reason = _get_flash_attention_disable_reason(config)
flash_attention_disabled = disable_reason is not None
# Flex Attention
if os.environ.get("UNSLOTH_ENABLE_FLEX_ATTENTION", "1") != "0":
try:
from transformers.utils.import_utils import is_torch_flex_attn_available
if model_class is None:
attn_impl = _set_attn_impl(config, "sdpa" if supports_sdpa else "eager")
else:
if _is_eager_only(model_type):
attn_impl = _set_attn_impl(config, "eager")
elif flash_attention_disabled:
attn_impl = _disable_flash_attention_if_needed(
config,
supports_sdpa = supports_sdpa,
would_use_flash_attention = (
HAS_FLASH_ATTENTION and supports_flash_attention
),
disable_reason = disable_reason,
)
elif HAS_FLASH_ATTENTION and supports_flash_attention:
attn_impl = _set_attn_impl(config, "flash_attention_2")
elif supports_sdpa:
attn_impl = _set_attn_impl(config, "sdpa")
else:
attn_impl = "eager"
if os.environ.get("UNSLOTH_ENABLE_FLEX_ATTENTION", "1") != "0":
try:
from transformers.utils.import_utils import (
is_torch_flex_attn_available,
)
if (
is_torch_flex_attn_available()
and model_class is not None
and getattr(model_class, "_supports_flex_attn", False)
and not _is_flex_excluded(model_type)
):
attention_dropout = getattr(config, "attention_dropout", 0) or 0
if attention_dropout == 0:
_set_attn_impl(config, "flex_attention")
return "flex_attention"
except Exception:
pass
if (
is_torch_flex_attn_available()
and getattr(model_class, "_supports_flex_attn", False)
and not _is_flex_excluded(model_type)
):
attention_dropout = (
_config_get(config, "attention_dropout", 0) or 0
)
if attention_dropout == 0:
attn_impl = _set_attn_impl(config, "flex_attention")
except Exception:
pass
if attn_impl == "eager":
attn_impl = _set_attn_impl(config, "eager")
# SDPA
if model_class is not None and getattr(model_class, "_supports_sdpa", False):
_set_attn_impl(config, "sdpa")
if requested_attn_implementation is None:
final_attn_impl = attn_impl
elif flash_attention_disabled:
final_attn_impl = _disable_flash_attention_if_needed(
config,
requested_attn_implementation,
supports_sdpa = supports_sdpa,
disable_reason = disable_reason,
)
else:
final_attn_impl = requested_attn_implementation
_set_attn_impl(config, final_attn_impl)
if not supports_sdpa and final_attn_impl == "sdpa":
print(
f"Unsloth: {(model_type_name or 'model').title()} does not support SDPA - switching to fast eager."
)
final_attn_impl = _set_attn_impl(config, "eager")
return final_attn_impl
def resolve_encoder_attention_implementation(
auto_model,
config,
model_type = "",
disable_sdpa_model_names = (),
):
model_class = resolve_model_class(auto_model, config)
supports_sdpa = model_class is not None and getattr(
model_class, "_supports_sdpa", False
)
if any(name in model_type.lower() for name in disable_sdpa_model_names):
return "eager"
if supports_sdpa:
return "sdpa"
_set_attn_impl(config, "eager")
return "eager"
return None
def _run_temporary_patches(phase):

View file

@ -2346,7 +2346,7 @@ class FastLlamaModel:
model_function = MODEL_FOR_CAUSAL_LM_MAPPING[model_config.__class__]
IS_FALCON_H1 = model_config.model_type.startswith("falcon_h1")
preferred_attn_impl = determine_attention_implementation(
preferred_attn_impl = resolve_attention_implementation(
model_function, model_config
)
@ -2478,6 +2478,16 @@ class FastLlamaModel:
and not _head.weight.is_floating_point()
):
_head.to(dtype)
# Attach dispatch hooks for bnb multi-device loads.
from unsloth.models.vision import _attach_bnb_multidevice_hooks
_attach_bnb_multidevice_hooks(
model,
load_in_4bit = load_in_4bit,
load_in_8bit = kwargs.get("load_in_8bit", False),
offload_embedding = False,
fast_inference = fast_inference,
)
elif not fast_inference:
model = AutoModelForCausalLM.from_pretrained(
model_name,
@ -2490,6 +2500,16 @@ class FastLlamaModel:
attn_implementation = preferred_attn_impl,
**kwargs,
)
# Attach dispatch hooks for bnb multi-device loads.
from unsloth.models.vision import _attach_bnb_multidevice_hooks
_attach_bnb_multidevice_hooks(
model,
load_in_4bit = load_in_4bit,
load_in_8bit = kwargs.get("load_in_8bit", False),
offload_embedding = False,
fast_inference = False,
)
model.fast_generate = make_fast_generate_wrapper(model.generate)
model.fast_generate_batches = None
else:

View file

@ -1151,9 +1151,6 @@ class FastModel(FastBaseModel):
)
os.environ["UNSLOTH_DISABLE_STATIC_GENERATION"] = "1"
os.environ["UNSLOTH_HIGH_PRECISION_LAYERNORM"] = "1"
# Disable flex_attention for Gemma-4: flex compile overhead is 2.7x slower
# than SDPA. Our attention patch ensures Q/K/V dtype alignment for SDPA.
os.environ["UNSLOTH_ENABLE_FLEX_ATTENTION"] = "0"
# Gemma 3N must be before Gemma 3
elif "gemma3n" in model_types_all:
if transformers_version < Version("4.53.0"):

View file

@ -15,7 +15,11 @@
import logging
from .loader import FastModel, DISABLE_SDPA_MODEL_NAMES
from ._utils import SUPPORTS_BFLOAT16
from ._utils import (
SUPPORTS_BFLOAT16,
resolve_model_class,
resolve_encoder_attention_implementation,
)
import inspect
import json
import os
@ -31,7 +35,6 @@ import transformers
from packaging.version import Version
import re
from transformers import AutoModel, AutoConfig
from transformers.models.auto.auto_factory import _get_model_class
import tempfile
from huggingface_hub import HfApi, get_token
from ..save import unsloth_save_pretrained_torchao, unsloth_save_pretrained_gguf
@ -870,7 +873,7 @@ class FastSentenceTransformer(FastModel):
if auto_model_class is None:
auto_model_class = AutoModel
# try to resolve the class
model_class = _get_model_class(config, auto_model_class._model_mapping)
model_class = resolve_model_class(auto_model_class, config)
if model_class:
sig = inspect.signature(model_class.__init__)
@ -1446,32 +1449,18 @@ class FastSentenceTransformer(FastModel):
):
st_device = "cuda"
# Check if model supports SDPA (Scaled Dot Product Attention) for extra speedup
supports_sdpa = False
if config is not None:
try:
model_class = _get_model_class(
config, kwargs.get("auto_model", AutoModel)._model_mapping
)
supports_sdpa = getattr(model_class, "_supports_sdpa", False)
except:
pass
# Build model_kwargs for SentenceTransformer
model_kwargs = {"torch_dtype": dtype}
# Enable SDPA if supported (1.2x extra speedup on top of torch.compile)
# But disable for models with known SDPA + torch.compile backward issues
_force_eager = False
for _sdpa_model in DISABLE_SDPA_MODEL_NAMES:
if _sdpa_model in model_type.lower():
supports_sdpa = False
_force_eager = True
break
if supports_sdpa:
model_kwargs["attn_implementation"] = "sdpa"
elif _force_eager:
model_kwargs["attn_implementation"] = "eager"
encoder_attn_impl = resolve_encoder_attention_implementation(
kwargs.get("auto_model", AutoModel),
config,
model_type = model_type,
disable_sdpa_model_names = DISABLE_SDPA_MODEL_NAMES,
)
supports_sdpa = encoder_attn_impl == "sdpa"
if encoder_attn_impl is not None:
model_kwargs["attn_implementation"] = encoder_attn_impl
# Print optimization status
sdpa_str = " + SDPA" if supports_sdpa else ""

View file

@ -29,7 +29,13 @@ except:
from ..kernels import (
post_patch_loss_function,
)
from ._utils import __version__, importlib_version, _prepare_model_for_qat
from ._utils import (
__version__,
importlib_version,
_prepare_model_for_qat,
resolve_model_class,
resolve_attention_implementation,
)
from ._utils import *
from .loader_utils import _get_fp8_mode_and_check_settings
from ..save import patch_saving_functions
@ -69,6 +75,7 @@ import functools
import os
import gc
import math
import warnings
from typing import Optional, Tuple, List, Union
import re, inspect, sys
import contextlib
@ -91,6 +98,144 @@ __all__ = [
"FastBaseModel",
]
def _infer_device_map_from_loaded_model(model):
"""Build a compact device_map by inspecting actual parameter placements."""
device_map = {}
def _assign(module, prefix):
params = list(module.named_parameters(remove_duplicate = False))
if not params:
bufs = list(module.named_buffers())
if bufs:
device_map[prefix] = bufs[0][1].device
return
devices = {p.device for _, p in params}
if len(devices) == 1:
device_map[prefix] = next(iter(devices))
else:
for child_name, child in module.named_children():
child_prefix = f"{prefix}.{child_name}" if prefix else child_name
_assign(child, child_prefix)
for pname, param in module.named_parameters(remove_duplicate = False):
if "." not in pname:
full = f"{prefix}.{pname}" if prefix else pname
if not any(
full == k or full.startswith(k + ".") for k in device_map
):
device_map[full] = param.device
_assign(model, "")
if "" in device_map and len(device_map) > 1:
device_map.pop("")
return device_map
def _attach_bnb_multidevice_hooks(
model, load_in_4bit, load_in_8bit, offload_embedding, fast_inference
):
"""
Attach accelerate AlignDevicesHook on a bnb model loaded across multiple
devices (or a non-default device). No-op for single-GPU cuda:0, non-bnb,
vLLM, or already-dispatched models.
"""
if fast_inference:
return
is_bnb = (
load_in_4bit
or load_in_8bit
or getattr(model, "is_loaded_in_4bit", False)
or getattr(model, "is_loaded_in_8bit", False)
or getattr(model, "quantization_method", None) == "bitsandbytes"
)
if not is_bnb:
return
if offload_embedding:
return
if getattr(model, "hf_device_map", None) is not None:
return # already dispatched
try:
all_devs = {p.device for p in model.parameters()}
except Exception as exc:
warnings.warn(
"Unsloth: Failed to determine device placement from model parameters, "
f"so multi-GPU hooks cannot be attached. ({type(exc).__name__}: {exc})",
RuntimeWarning,
stacklevel = 2,
)
return
cuda_devs = {d for d in all_devs if d.type == "cuda"}
if not cuda_devs:
return
default_cuda = torch.device("cuda", 0)
if all_devs == {default_cuda}:
return
try:
from accelerate import dispatch_model
except ImportError:
return # accelerate not available
try:
inferred_map = _infer_device_map_from_loaded_model(model)
if not inferred_map:
return
# bnb constructors reject _is_hf_initialized; strip before dispatch.
_extra_keys = ("_is_hf_initialized",)
_stripped = []
for _, param in model.named_parameters():
for key in _extra_keys:
if key in param.__dict__:
_stripped.append((param, key, param.__dict__.pop(key)))
try:
# CUDA -> int index, non-CUDA -> type string ("cpu", "meta").
device_map_int = {
k: (v.index if v.type == "cuda" else v.type)
if isinstance(v, torch.device)
else v
for k, v in inferred_map.items()
}
# force_hooks=True: install hooks even for single-device maps.
main_device = device_map_int.get("")
if main_device in (None, "cpu", "disk"):
main_device = next(
(d for d in device_map_int.values() if d not in ("cpu", "disk")),
None,
)
dispatch_model(
model,
device_map = device_map_int,
main_device = main_device,
skip_keys = getattr(model, "_skip_keys_device_placement", None),
force_hooks = True,
)
desc = f"{len(inferred_map)} block(s) across {len(cuda_devs)} device(s)"
finally:
# Restore stripped keys.
for param, key, val in _stripped:
param.__dict__[key] = val
logger.info(
f"Unsloth: Attached accelerate AlignDevicesHook ({desc}) "
f"for bnb multi-GPU inference."
)
except Exception as exc:
warnings.warn(
f"Unsloth: Could not attach multi-device dispatch hooks automatically "
f"({type(exc).__name__}: {exc}). "
"Cross-device inference may fail. Consider using a single GPU or "
"calling accelerate.dispatch_model() manually.",
RuntimeWarning,
stacklevel = 2,
)
global NUM_LOGITS_TO_KEEP
NUM_LOGITS_TO_KEEP = dict()
@ -607,37 +752,18 @@ class FastBaseModel:
token = token,
trust_remote_code = trust_remote_code,
)
try:
model_class = auto_model._model_mapping[auto_config.__class__]
except Exception:
model_class = None
if model_class is None:
# When model_class cannot be resolved (remote-code or unmapped
# configs), preserve the old fallback of sdpa when supported.
attn_impl = _set_attn_impl(
auto_config, "sdpa" if supports_sdpa else "eager"
)
else:
attn_impl = determine_attention_implementation(model_class, auto_config)
model_class = resolve_model_class(auto_model, auto_config)
attn_impl = resolve_attention_implementation(
model_class,
auto_config,
requested_attn_implementation = kwargs.get("attn_implementation", None),
supports_sdpa = supports_sdpa,
)
# Handle FP8 models: get_model_name has already redirected this to BF16 sibling if the model ships with
# FP8 weights. We just need to update it here for sanity.
auto_config.model_name = model_name
# Re-resolve model_class after potential config change
try:
model_class = auto_model._model_mapping[auto_config.__class__]
except Exception:
model_class = None
if not ("attn_implementation" in kwargs):
kwargs["attn_implementation"] = attn_impl
if not supports_sdpa and kwargs.get("attn_implementation") == "sdpa":
print(
f"Unsloth: {model_type_arch.title()} does not support SDPA - switching to fast eager."
)
del kwargs["attn_implementation"]
# Re-stamp config so it stays consistent with the actual impl
_set_attn_impl(auto_config, "eager")
kwargs["attn_implementation"] = attn_impl
bnb_config = None
user_quantization_config = kwargs.get("quantization_config", None)
@ -780,9 +906,7 @@ class FastBaseModel:
token = token,
trust_remote_code = trust_remote_code,
)
setattr(auto_config, "_attn_implementation", config_attn_impl)
if hasattr(auto_config, "attn_implementation"):
setattr(auto_config, "attn_implementation", config_attn_impl)
_set_attn_impl(auto_config, config_attn_impl)
model_config = auto_config
verify_fp8_support_if_applicable(model_config)
@ -811,6 +935,14 @@ class FastBaseModel:
# attn_implementation = attn_implementation,
**kwargs,
)
# Attach dispatch hooks for bnb multi-device loads.
_attach_bnb_multidevice_hooks(
model,
load_in_4bit = load_in_4bit,
load_in_8bit = load_in_8bit,
offload_embedding = offload_embedding,
fast_inference = fast_inference,
)
if hasattr(model, "generate"):
model.fast_generate = make_fast_generate_wrapper(model.generate)
model.fast_generate_batches = error_out_no_vllm

View file

@ -636,48 +636,597 @@ def load_correct_tokenizer(
return tokenizer
def _find_end_position(template, endfor, endif):
where_endfor = template.find(endfor)
where_endif = template.find(endif)
if where_endfor == where_endif == -1:
# All four Jinja whitespace-control variants of endfor/endif:
# {% endfor %} {%- endfor %} {% endfor -%} {%- endfor -%}
_RE_ENDFOR = re.compile(r"\{%(-?)\s*endfor\s*(-?)%\}")
_RE_ENDIF = re.compile(r"\{%(-?)\s*endif\s*(-?)%\}")
_RE_JINJA_COMMENT = re.compile(r"\{#.*?#\}", flags = re.DOTALL)
def _find_end_position(template, endfor = None, endif = None):
"""Rightmost {% endfor %}/{% endif %} (any dash variant), as a dict
with start/end/text/dash_left/dash_right. Tokens inside Jinja comments
are ignored. `endfor`/`endif` kwargs kept for back-compat, ignored."""
# Space-pad comments so positions still map 1:1 to the original.
scrubbed = _RE_JINJA_COMMENT.sub(lambda m: " " * len(m.group(0)), template)
endfor_matches = list(_RE_ENDFOR.finditer(scrubbed))
endif_matches = list(_RE_ENDIF.finditer(scrubbed))
last_endfor = endfor_matches[-1] if endfor_matches else None
last_endif = endif_matches[-1] if endif_matches else None
candidates = [m for m in (last_endfor, last_endif) if m is not None]
if not candidates:
return None
elif where_endfor > where_endif:
return endfor
m = max(candidates, key = lambda x: x.end())
return {
"start": m.start(),
"end": m.end(),
"text": m.group(0),
"dash_left": bool(m.group(1)),
"dash_right": bool(m.group(2)),
}
def _template_ends_with_toplevel_for(chat_template):
"""Return True if the last structural node at the template's top level is
a For (message-iteration) loop, ignoring trailing pure-whitespace Output
nodes. Unwraps benign outer-If guards (no else branch, not testing
add_generation_prompt) so that templates like
``{% if messages %}{% for ... %}{% endfor %}{% endif %}`` are still
repairable. Rejects real structural wrappers (e.g. Qwen3-Guard with
else branches)."""
try:
import jinja2
import jinja2.nodes
ast = jinja2.Environment().parse(chat_template)
except Exception:
return False
def _last_structural(nodes):
for node in reversed(nodes):
if isinstance(node, jinja2.nodes.Output):
only_ws = all(
isinstance(child, jinja2.nodes.TemplateData)
and child.data.strip() == ""
for child in node.nodes
)
if only_ws:
continue
return node
return None
node = _last_structural(ast.body)
while isinstance(node, jinja2.nodes.If) and not node.else_:
names = []
if isinstance(node.test, jinja2.nodes.Name):
names.append(node.test)
names.extend(node.test.find_all(jinja2.nodes.Name))
if any(n.name == "add_generation_prompt" for n in names):
break
node = _last_structural(node.body)
return isinstance(node, jinja2.nodes.For)
def _if_body_emits_content(if_node):
"""True if the If's body contains any Output node (directly or nested).
Distinguishes a real generation block from a header guard that only
does `{% set ... %}`."""
import jinja2.nodes
for node in if_node.body:
if isinstance(node, jinja2.nodes.Output):
return True
if any(
isinstance(d, jinja2.nodes.Output)
for d in node.find_all(jinja2.nodes.Output)
):
return True
return False
def _has_add_generation_prompt_block(chat_template):
"""True if the template has a *positive* `{% if add_generation_prompt %}`
gate whose body emits output. Rejects header guards like
`{% if not add_generation_prompt is defined %}{% set ... %}{% endif %}`
that reference the name but emit nothing. AST-based; string-scan
fallback if Jinja fails to parse."""
try:
import jinja2
import jinja2.nodes
ast = jinja2.Environment().parse(chat_template)
except Exception:
return "if add_generation_prompt" in chat_template and "%}" in chat_template
for if_node in ast.find_all(jinja2.nodes.If):
test = if_node.test
# Reject negated gates: `{% if not add_generation_prompt %}` fires
# when agp=False, so it's not a generation block even if it emits.
if isinstance(test, jinja2.nodes.Not):
continue
# find_all skips the test root, so check bare Name tests explicitly.
references_agp = False
if isinstance(test, jinja2.nodes.Name) and test.name == "add_generation_prompt":
references_agp = True
else:
for name_node in test.find_all(jinja2.nodes.Name):
if name_node.name == "add_generation_prompt":
references_agp = True
break
if references_agp and _if_body_emits_content(if_node):
return True
return False
# Sentinels for _derive_assistant_prefix_by_render. Diverge at char 0 so
# commonprefix can't absorb them; long random tail makes collision with real
# template literals negligible (see T18).
_RENDER_DIFF_SENTINEL_A = "AAAA_0123456789_UNSLOTH_RENDER_DIFF_SENTINEL"
_RENDER_DIFF_SENTINEL_B = "BBBB_0123456789_UNSLOTH_RENDER_DIFF_SENTINEL"
_RENDER_DIFF_SENTINEL_C = "CCCC_0123456789_UNSLOTH_RENDER_DIFF_SENTINEL"
def _derive_assistant_prefix_by_render(chat_template, is_sharegpt = False):
"""Return the assistant-turn prefix the template emits, derived by
rendering two dialogs that differ only in assistant content: the common
prefix of their tails (after the base [user]-only render) is what the
template emits for an assistant turn. None if any guard fails.
Works for Llama-3 / Gemma / Phi-3 and other non-ChatML shapes; the
template is its own ground truth.
Known limitation: an `eos-on-non-last` pattern (turn-end sentinel only
emitted for non-last messages) would produce a consistent but wrong
prefix that `_validate_patched_template` can't catch. No real-world
template is known to use this.
"""
try:
from jinja2.sandbox import SandboxedEnvironment
except Exception:
return None
if is_sharegpt:
base_msgs = [{"from": "human", "value": "Hi"}]
sent_a_msgs = base_msgs + [{"from": "gpt", "value": _RENDER_DIFF_SENTINEL_A}]
sent_b_msgs = base_msgs + [{"from": "gpt", "value": _RENDER_DIFF_SENTINEL_B}]
# User-role cross-check (Guard C below).
sent_c_msgs = base_msgs + [{"from": "human", "value": _RENDER_DIFF_SENTINEL_C}]
else:
return endif
base_msgs = [{"role": "user", "content": "Hi"}]
sent_a_msgs = base_msgs + [
{"role": "assistant", "content": _RENDER_DIFF_SENTINEL_A}
]
sent_b_msgs = base_msgs + [
{"role": "assistant", "content": _RENDER_DIFF_SENTINEL_B}
]
sent_c_msgs = base_msgs + [{"role": "user", "content": _RENDER_DIFF_SENTINEL_C}]
# Strip trailing whitespace/comments after the last endfor/endif: they
# appear after the message loop and would break Guard A. The splice in
# `_fix_chat_template` drops them too.
probe_template = chat_template
end = _find_end_position(chat_template)
if end is not None:
after = chat_template[end["end"] :]
if _RE_JINJA_COMMENT.sub("", after).strip() == "":
probe_template = chat_template[: end["end"]]
# Sandboxed: probe renders at load time, before user calls
# apply_chat_template. SandboxedEnvironment blocks attribute-chain exploits.
try:
env = SandboxedEnvironment(
autoescape = False,
keep_trailing_newline = True,
)
tmpl = env.from_string(probe_template)
out_base = tmpl.render(messages = base_msgs, add_generation_prompt = False)
out_a = tmpl.render(messages = sent_a_msgs, add_generation_prompt = False)
out_b = tmpl.render(messages = sent_b_msgs, add_generation_prompt = False)
except Exception:
return None
# Best-effort: alternation-enforcing templates (e.g. Gemma's
# raise_exception) fail on [user, user]; that's a positive signal
# for Guard C, not a probe failure.
out_user_c = None
try:
out_user_c = tmpl.render(messages = sent_c_msgs, add_generation_prompt = False)
except Exception:
pass
# Guard A: assistant renders extend base (no reordering).
if not (out_a.startswith(out_base) and out_b.startswith(out_base)):
return None
tail_a = out_a[len(out_base) :]
tail_b = out_b[len(out_base) :]
if not tail_a or not tail_b:
return None
prefix = os.path.commonprefix([tail_a, tail_b])
# Guard B: divergence is exactly at the content-insertion site.
if not (
tail_a[len(prefix) :].startswith(_RENDER_DIFF_SENTINEL_A)
and tail_b[len(prefix) :].startswith(_RENDER_DIFF_SENTINEL_B)
):
return None
# Guard C: reject if a [user, user] render also emits the same prefix
# (role-insensitive template, e.g. `{% set greeting='Hi' %}...`).
if out_user_c is not None and out_user_c.startswith(out_base):
tail_c = out_user_c[len(out_base) :]
if tail_c.startswith(prefix) and prefix != "":
return None
if not prefix:
return None
return prefix
def _fix_chat_template(chat_template):
endfor = "{% endfor %}"
endif = "{% endif %}"
chosen_end = _find_end_position(chat_template, endfor, endif)
if chosen_end is None:
endfor = "{%- endfor %}"
endif = "{%- endif %}"
chosen_end = _find_end_position(chat_template, endfor, endif)
if chosen_end is None:
def _fix_chat_template(chat_template, is_sharegpt = False):
# Fast path: already has an {% if add_generation_prompt %} block, nothing
# to do. This catches cases the old string-based check would miss (e.g.
# templates that use {%- if add_generation_prompt -%} with both-side dash,
# or that sneak the block into a nested If/For).
if _has_add_generation_prompt_block(chat_template):
return chat_template
where = chat_template.find(chosen_end)
end = _find_end_position(chat_template)
if end is None:
return chat_template
after_endfor = chat_template[where + len(chosen_end) :]
dash = "-" if chosen_end.startswith("{%-") else ""
after_endfor = chat_template[end["end"] :]
dash_l = "-" if end["dash_left"] else ""
dash_r = "-" if end["dash_right"] else ""
open_tag = lambda body: "{%" + dash_l + " " + body + " " + dash_r + "%}"
# Case 1 (pre-existing base case): template ends with a single trailing
# {{ expr }} that is the generation prefix. Wrap it in an
# {% if add_generation_prompt %} ... {% endif %}.
if (
"{%" + dash + " if" not in after_endfor
and "{%" + dash + " set " not in after_endfor
"{%" + dash_l + " if" not in after_endfor
and "{%" + dash_l + " set " not in after_endfor
and after_endfor.startswith("{{")
and after_endfor.endswith("}}")
and after_endfor.count("{{") == 1
and after_endfor.count("}}") == 1
):
after_endfor = (
"{%" + dash + " if add_generation_prompt %}" + after_endfor + endif
wrapped = (
open_tag("if add_generation_prompt") + after_endfor + open_tag("endif")
)
return chat_template[: end["end"]] + wrapped
# Case 2 (GH#4150): template ends at {% endfor %} with only whitespace
# or comments left. Inject an {% if add_generation_prompt %} block with
# the assistant prefix derived by render-diff. The top-level-For gate
# keeps us out of outer-If wrappers (e.g. Qwen3-Guard).
if _RE_JINJA_COMMENT.sub(
"", after_endfor
).strip() == "" and _template_ends_with_toplevel_for(chat_template):
# No redundant "agp not in scrubbed" check: the fast path already
# confirmed no *positive* block, and a mere reference (header
# guard) should still get repaired.
assistant_prefix = _derive_assistant_prefix_by_render(
chat_template, is_sharegpt
)
# Dual-probe: dict/list callers don't know the shape up front.
if assistant_prefix is None and not is_sharegpt:
assistant_prefix = _derive_assistant_prefix_by_render(
chat_template, is_sharegpt = True
)
if assistant_prefix is None:
return chat_template
# Escape for a double-quoted Jinja string literal.
escaped = (
assistant_prefix.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
)
generation_block = (
open_tag("if add_generation_prompt")
+ '{{ "'
+ escaped
+ '" }}'
+ open_tag("endif")
)
return chat_template[: end["end"]] + generation_block
return chat_template
def _is_strict_chat_template_mode():
"""Opt-in strict mode restores the pre-warn RuntimeError behavior."""
val = os.environ.get("UNSLOTH_STRICT_CHAT_TEMPLATE", "0")
return str(val).strip().lower() in ("1", "true", "yes", "on")
def _name_is_local_path(name_or_path):
"""True if name_or_path refers to an existing local directory. Used to
tailor the warning message: for local paths the user cannot 'file a bug
report to the maintainers of <path>' since that path is their own."""
if not name_or_path:
return False
try:
return os.path.isdir(str(name_or_path))
except Exception:
return False
def _format_chat_template_message(
name_or_path,
repaired,
has_generation_block = False,
local_path_source = None,
strict = False,
):
"""Build a user-facing warning/error message that points at the right
responsible party (user's downstream tool vs. upstream model maintainer)."""
local = _name_is_local_path(
local_path_source if local_path_source is not None else name_or_path
)
if local:
source_hint = (
"This tokenizer was loaded from a local path. The likely cause is a "
"downstream tool (LlamaFactory, Axolotl, etc.) that re-serialized "
"the tokenizer during save and stripped the generation-prompt "
"block. Either re-save with the original template, or set "
"`tokenizer.chat_template` manually before loading."
)
else:
source_hint = (
"The chat_template shipped with `{name}` appears incomplete. "
"Consider filing a bug report with the model maintainers."
).format(name = name_or_path)
strict_suffix = (
""
if strict
else (" Set UNSLOTH_STRICT_CHAT_TEMPLATE=1 to raise instead of warn.")
)
if repaired:
return (
"Unsloth: Patched the chat_template on `{name}` to add a "
"{{% if add_generation_prompt %}} block. {hint}"
).format(name = name_or_path, hint = source_hint)
if has_generation_block:
return (
"Unsloth: The tokenizer `{name}` has a "
"{{% if add_generation_prompt %}} block, but it does not change "
"the rendered output. {hint}{suffix}"
).format(name = name_or_path, hint = source_hint, suffix = strict_suffix)
load_clause = (
"Loading is blocked in strict mode."
if strict
else "The model will still load, but "
"`apply_chat_template(add_generation_prompt=True)` may not produce a "
"correct assistant-turn marker."
)
return (
"Unsloth: The tokenizer `{name}` does not have a "
"{{% if add_generation_prompt %}} block for generation purposes, and "
"automatic repair was not possible. {load_clause} {hint}{suffix}"
).format(
name = name_or_path,
load_clause = load_clause,
hint = source_hint,
suffix = strict_suffix,
)
def _validate_patched_template(tokenizer, patched_template, is_sharegpt):
"""Render the just-patched template with and without
add_generation_prompt, and confirm the patched output responds to the
flag by appending (not replacing) content. Returns True if validation
passes."""
msgs = (
[{"from": "human", "value": "Hi"}]
if is_sharegpt
else [{"role": "user", "content": "Hi"}]
)
original = getattr(tokenizer, "chat_template", None)
try:
try:
tokenizer.chat_template = patched_template
except Exception:
return False # read-only tokenizer, skip validation
try:
yes = tokenizer.apply_chat_template(
msgs,
add_generation_prompt = True,
tokenize = False,
)
no = tokenizer.apply_chat_template(
msgs,
add_generation_prompt = False,
tokenize = False,
)
except Exception:
return False
finally:
try:
tokenizer.chat_template = original
except Exception:
pass # best-effort restore
# Contract after a successful repair: the two renders differ, and the
# "yes" render is a strict extension of the "no" render (we only
# appended content inside the new add_generation_prompt block).
return yes != no and yes.startswith(no)
def _repair_string_template(tokenizer, chat_template, is_sharegpt):
"""Core string-template repair. Returns the repaired template on success,
or None if repair was not possible / failed validation."""
candidate = _fix_chat_template(chat_template, is_sharegpt = is_sharegpt)
if not _has_add_generation_prompt_block(candidate):
return None
# Validate with the caller's is_sharegpt first. If that fails, the
# dual-probe in _fix_chat_template may have fallen back to the other
# schema internally -- try validating with the opposite schema before
# giving up.
if _validate_patched_template(tokenizer, candidate, is_sharegpt):
return candidate
if _validate_patched_template(tokenizer, candidate, not is_sharegpt):
return candidate
return None
def _fix_chat_template_for_tokenizer(tokenizer, chat_template):
"""Entry point for a string chat_template. Runs the no==yes diagnostic,
attempts repair if needed, and returns the (possibly patched) template.
On repair failure, the behavior is controlled by
UNSLOTH_STRICT_CHAT_TEMPLATE: warn + return original (default) or raise
RuntimeError (strict)."""
name = getattr(tokenizer, "name_or_path", "unknown")
source_path = getattr(tokenizer, "_source_path", name)
# Detect ShareGPT vs HF style by probing apply_chat_template.
is_sharegpt = None
try:
tokenizer.apply_chat_template(
[{"role": "user", "content": "Who are you?"}],
add_generation_prompt = False,
tokenize = False,
)
is_sharegpt = False
except Exception:
try:
tokenizer.apply_chat_template(
[{"from": "human", "value": "Who are you?"}],
add_generation_prompt = False,
tokenize = False,
)
is_sharegpt = True
except Exception:
is_sharegpt = None
if is_sharegpt is None:
return chat_template
messages = (
[{"from": "human", "value": "Who are you?"}]
if is_sharegpt
else [{"role": "user", "content": "Who are you?"}]
)
try:
no = tokenizer.apply_chat_template(
messages,
add_generation_prompt = False,
tokenize = False,
)
yes = tokenizer.apply_chat_template(
messages,
add_generation_prompt = True,
tokenize = False,
)
except Exception:
return chat_template
if no != yes:
# Template already responds to the flag; leave as is.
return chat_template
# no == yes: template ignores add_generation_prompt. Try to repair.
if _has_add_generation_prompt_block(chat_template):
# Template has the block but it does not change output. This is the
# "wasn't provided correctly" case from the pre-warn code path.
strict = _is_strict_chat_template_mode()
msg = _format_chat_template_message(
name,
repaired = False,
has_generation_block = True,
local_path_source = source_path,
strict = strict,
)
if strict:
raise RuntimeError(msg)
logger.warning_once(msg)
return chat_template
repaired = _repair_string_template(tokenizer, chat_template, is_sharegpt)
if repaired is not None:
logger.warning_once(
_format_chat_template_message(
name,
repaired = True,
local_path_source = source_path,
)
)
return repaired
strict = _is_strict_chat_template_mode()
msg = _format_chat_template_message(
name,
repaired = False,
local_path_source = source_path,
strict = strict,
)
if strict:
raise RuntimeError(msg)
logger.warning_once(msg)
return chat_template
class _VariantTokenizerProxy:
"""Single-variant view of a multi-variant tokenizer. Routes each variant
through `_fix_chat_template_for_tokenizer` so the full contract
(is_sharegpt probe, no==yes, warn/strict, `_validate_patched_template`)
applies instead of jumping straight to structural repair.
`apply_chat_template` swaps `base.chat_template` to the variant before
calling so tokenizer globals (bos_token, filters, raise_exception) are
preserved; falls back to bare Jinja for read-only stubs.
"""
def __init__(self, base_tokenizer, variant_template, variant_label = ""):
self._base = base_tokenizer
self._template = variant_template
base_name = getattr(base_tokenizer, "name_or_path", "unknown")
self._source_path = base_name
self.name_or_path = (
f"{base_name} ({variant_label})" if variant_label else base_name
)
chat_template = chat_template[: where + len(chosen_end)] + after_endfor
return chat_template
@property
def chat_template(self):
return self._template
@chat_template.setter
def chat_template(self, value):
self._template = value
def apply_chat_template(self, *args, **kwargs):
base_original = getattr(self._base, "chat_template", None)
swapped = False
try:
try:
self._base.chat_template = self._template
swapped = True
except Exception:
swapped = False
if swapped:
return self._base.apply_chat_template(*args, **kwargs)
# Read-only base: fall back to sandboxed Jinja.
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment(
autoescape = False,
keep_trailing_newline = True,
)
messages = args[0] if args else kwargs.get("messages", [])
add_generation_prompt = kwargs.get("add_generation_prompt", False)
return env.from_string(self._template).render(
messages = messages,
add_generation_prompt = add_generation_prompt,
)
finally:
if swapped:
try:
self._base.chat_template = base_original
except Exception:
pass # best-effort restore
def fix_chat_template(tokenizer):
@ -685,76 +1234,41 @@ def fix_chat_template(tokenizer):
if chat_template is None:
return None
### 1. Check if add_generation_prompt works
# Check for ShareGPT style first
is_sharegpt = None
try:
messages = [
{"role": "user", "content": "Who are you?"},
]
tokenizer.apply_chat_template(
messages, add_generation_prompt = False, tokenize = False
)
is_sharegpt = False
except:
try:
messages = [
{"from": "human", "value": "Who are you?"},
]
tokenizer.apply_chat_template(
messages, add_generation_prompt = False, tokenize = False
# Multi-variant dict (e.g. Hermes-3 {default, tool_use}): route each
# variant through the full repair contract via _VariantTokenizerProxy.
if isinstance(chat_template, dict):
fixed = {}
for key, tmpl in chat_template.items():
if not isinstance(tmpl, str):
fixed[key] = tmpl
continue
proxy = _VariantTokenizerProxy(
tokenizer, tmpl, variant_label = f"variant={key!r}"
)
is_sharegpt = True
except:
is_sharegpt = None
fixed[key] = _fix_chat_template_for_tokenizer(proxy, tmpl)
return fixed
# Not ShareGPT or HF style - just return
if is_sharegpt is None:
return chat_template
# Tokenize
messages = [
{"role": "user", "content": "Who are you?"}
if not is_sharegpt
else {"from": "human", "value": "Who are you?"}
]
no = tokenizer.apply_chat_template(
messages, add_generation_prompt = False, tokenize = False
)
yes = tokenizer.apply_chat_template(
messages, add_generation_prompt = True, tokenize = False
)
if no == yes:
# SAME?! That's not good! We check for add_generation_prompt
if (
"{% if add_generation_prompt %}" not in chat_template
and "{%- if add_generation_prompt %}" not in chat_template
):
# Try fixing it by adding it
new_chat_template = _fix_chat_template(chat_template)
if (
"{% if add_generation_prompt %}" not in new_chat_template
and "{%- if add_generation_prompt %}" not in new_chat_template
):
raise RuntimeError(
f"Unsloth: The tokenizer `{tokenizer.name_or_path}`\n"
"does not have a {% if add_generation_prompt %} for generation purposes.\n"
f"Please file a bug report to the maintainers of `{tokenizer.name_or_path}` - thanks!"
)
# List-of-dicts form (older HF multi-template style).
if isinstance(chat_template, list):
fixed = []
for item in chat_template:
if not isinstance(item, dict) or "template" not in item:
fixed.append(item)
continue
tmpl = item["template"]
if not isinstance(tmpl, str):
fixed.append(item)
continue
label = f"variant={item.get('name', '?')!r}"
proxy = _VariantTokenizerProxy(tokenizer, tmpl, variant_label = label)
new_tmpl = _fix_chat_template_for_tokenizer(proxy, tmpl)
if new_tmpl is tmpl or new_tmpl == tmpl:
fixed.append(item)
else:
logger.warning_once(
"Unsloth: We successfully patched the tokenizer to add a {% if add_generation_prompt %} to the chat_template.\n"
f"This is not a bug, but please notify the maintainers of `{tokenizer.name_or_path}` - thanks!"
)
chat_template = new_chat_template
else:
raise RuntimeError(
f"Unsloth: The tokenizer `{tokenizer.name_or_path}`\n"
"has a {% if add_generation_prompt %} for generation purposes, but wasn't provided correctly.\n"
"Please file a bug report immediately - thanks!"
)
return chat_template
fixed.append({**item, "template": new_tmpl})
return fixed
return _fix_chat_template_for_tokenizer(tokenizer, chat_template)
def check_tokenizer(