Merge branch 'main' into feature/tool-choice-kwarg-openai-format

This commit is contained in:
Lee Jackson 2026-04-16 17:25:26 +01:00 committed by GitHub
commit 02da8a5f27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 7237 additions and 2437 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

@ -88,7 +88,7 @@ huggingfacenotorch = [
]
huggingface = [
"unsloth[huggingfacenotorch]",
"unsloth_zoo>=2026.4.3",
"unsloth_zoo>=2026.4.7",
"torchvision",
"unsloth[triton]",
]
@ -578,7 +578,7 @@ colab-ampere-torch220 = [
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
]
colab-new = [
"unsloth_zoo>=2026.4.3",
"unsloth_zoo>=2026.4.7",
"packaging",
"tyro",
"transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0",

View file

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

@ -213,3 +213,68 @@ class ScanFolderInfo(BaseModel):
id: int = Field(..., description = "Database row ID")
path: str = Field(..., description = "Normalized absolute path")
created_at: str = Field(..., description = "ISO 8601 creation timestamp")
class BrowseEntry(BaseModel):
"""A directory entry surfaced by the folder browser."""
name: str = Field(..., description = "Entry name (basename, not full path)")
has_models: bool = Field(
False,
description = (
"Hint that the directory likely contains models "
"(*.gguf, *.safetensors, config.json, or HF-style "
"`models--*` subfolders). Used by the UI to highlight "
"promising candidates; the scanner itself is authoritative."
),
)
hidden: bool = Field(
False,
description = "Name starts with a dot (e.g. `.cache`)",
)
class BrowseFoldersResponse(BaseModel):
"""Response schema for the folder browser endpoint."""
current: str = Field(..., description = "Absolute path of the directory just listed")
parent: Optional[str] = Field(
None,
description = (
"Parent directory of `current`, or null if `current` is the "
"filesystem root. The frontend uses this to render an `Up` row."
),
)
entries: List[BrowseEntry] = Field(
default_factory = list,
description = (
"Subdirectories of `current`. Sorted with model-bearing "
"directories first, then alphabetically case-insensitive; "
"hidden entries come last within each group."
),
)
suggestions: List[str] = Field(
default_factory = list,
description = (
"Handy starting points (home, HF cache, already-registered "
"scan folders). Rendered as quick-pick chips above the list."
),
)
truncated: bool = Field(
False,
description = (
"True when the listing was capped because the directory had "
"more subfolders than the server is willing to enumerate in "
"one request. The UI should show a hint telling the user to "
"narrow their path."
),
)
model_files_here: int = Field(
0,
description = (
"Count of GGUF/safetensors files immediately inside "
"``current``. Used by the UI to surface a hint on leaf "
"model directories (which otherwise look `empty` because "
"they contain only files, no subdirectories)."
),
)

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
@ -101,6 +104,8 @@ from models import (
ModelListResponse,
)
from models.models import (
BrowseEntry,
BrowseFoldersResponse,
GgufVariantDetail,
GgufVariantsResponse,
ModelType,
@ -409,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(
@ -491,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
@ -573,6 +855,580 @@ 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.
_BROWSE_MODEL_HINT_PROBE = 64
# Hard cap on how many subdirectory entries we send back. Pointing the
# browser at something like ``/usr/lib`` or ``/proc`` must not stat-storm
# the process or send tens of thousands of rows to the client.
_BROWSE_ENTRY_CAP = 2000
def _count_model_files(directory: Path, cap: int = 200) -> int:
"""Count GGUF/safetensors files immediately inside *directory*.
Used to surface a count-hint on the response so the UI can tell
users that a leaf directory (no subdirs, only weights) is a valid
"Use this folder" target.
Bounded by *visited entries*, not by *match count*: in directories
with many non-model files (or many subdirectories) the scan still
stops after ``cap`` entries so a UI hint never costs more than a
bounded directory walk.
"""
n = 0
visited = 0
try:
for f in directory.iterdir():
visited += 1
if visited > cap:
break
try:
if f.is_file():
low = f.name.lower()
if low.endswith((".gguf", ".safetensors")):
n += 1
except OSError:
continue
except PermissionError as e:
logger.debug("browse-folders: permission denied counting %s: %s", directory, e)
return 0
except OSError as e:
logger.debug("browse-folders: OS error counting %s: %s", directory, e)
return 0
return n
def _has_direct_model_signal(directory: Path) -> bool:
"""Return True if *directory* has an immediate child that signals
it holds a model: a GGUF/safetensors/config.json file, or a
`models--*` subdir (HF hub cache). Bounded by
``_BROWSE_MODEL_HINT_PROBE`` to stay fast."""
try:
it = directory.iterdir()
except OSError:
return False
try:
for i, child in enumerate(it):
if i >= _BROWSE_MODEL_HINT_PROBE:
break
try:
name = child.name
if child.is_file():
low = name.lower()
if low.endswith((".gguf", ".safetensors")):
return True
if low in ("config.json", "adapter_config.json"):
return True
elif child.is_dir() and name.startswith("models--"):
return True
except OSError:
continue
except OSError:
return False
return False
def _looks_like_model_dir(directory: Path) -> bool:
"""Bounded heuristic used by the folder browser to flag directories
worth exploring. False negatives are fine; the real scanner is
authoritative.
Three signals, cheapest first:
1. Directory name itself: ``models--*`` is the HuggingFace hub cache
layout (``blobs``/``refs``/``snapshots`` children wouldn't match
the file-level probes below).
2. An immediate child is a weight file or config (handled by
:func:`_has_direct_model_signal`).
3. A grandchild has a direct signal -- this catches the
``publisher/model/weights.gguf`` layout used by LM Studio and
Ollama. We probe at most the first
``_BROWSE_MODEL_HINT_PROBE`` child directories, each of which is
checked with a bounded :func:`_has_direct_model_signal` call,
so the total cost stays O(PROBE^2) worst-case.
"""
if directory.name.startswith("models--"):
return True
if _has_direct_model_signal(directory):
return True
# Grandchild probe: LM Studio / Ollama publisher/model layout.
try:
it = directory.iterdir()
except OSError:
return False
try:
for i, child in enumerate(it):
if i >= _BROWSE_MODEL_HINT_PROBE:
break
try:
if not child.is_dir():
continue
except OSError:
continue
# Fast name check first
if child.name.startswith("models--"):
return True
if _has_direct_model_signal(child):
return True
except OSError:
return False
return False
def _build_browse_allowlist() -> list[Path]:
"""Return the list of root directories the folder browser is allowed
to walk. The same list is used to seed the sidebar suggestion chips,
so chip targets are always reachable.
Roots include the current user's HOME, the resolved HF cache dirs,
Studio's own outputs/exports/studio root, registered scan folders,
and well-known third-party local-LLM dirs (LM Studio, Ollama,
`~/models`). Each is added only if it currently resolves to a real
directory, so we never produce a "dead" sandbox boundary the user
can't navigate into.
"""
from utils.paths import (
hf_default_cache_dir,
legacy_hf_cache_dir,
well_known_model_dirs,
)
from storage.studio_db import list_scan_folders
candidates: list[Path] = []
def _add(p: Optional[Path]) -> None:
if p is None:
return
try:
resolved = p.resolve()
except OSError:
return
if resolved.is_dir():
candidates.append(resolved)
_add(Path.home())
_add(_resolve_hf_cache_dir())
try:
_add(hf_default_cache_dir())
except Exception: # noqa: BLE001 -- best-effort
pass
try:
_add(legacy_hf_cache_dir())
except Exception: # noqa: BLE001 -- best-effort
pass
try:
from utils.paths import (
exports_root,
outputs_root,
studio_root,
)
_add(studio_root())
_add(outputs_root())
_add(exports_root())
except Exception as exc: # noqa: BLE001 -- best-effort
logger.debug("browse-folders: studio roots unavailable: %s", exc)
try:
for folder in list_scan_folders():
p = folder.get("path")
if p:
_add(Path(p))
except Exception as exc: # noqa: BLE001 -- best-effort
logger.debug("browse-folders: could not load scan folders: %s", exc)
try:
for p in well_known_model_dirs():
_add(p)
except Exception as exc: # noqa: BLE001 -- best-effort
logger.debug("browse-folders: well-known dirs unavailable: %s", exc)
# Dedupe while preserving order.
seen: set[str] = set()
deduped: list[Path] = []
for p in candidates:
key = str(p)
if key in seen:
continue
seen.add(key)
deduped.append(p)
return deduped
def _is_path_inside_allowlist(target: Path, allowed_roots: list[Path]) -> bool:
"""Return True if *target* equals or is a descendant of any allowed
root. The comparison uses ``os.path.realpath`` so symlinks cannot be
used to escape the sandbox.
"""
try:
target_real = os.path.realpath(str(target))
except OSError:
return False
for root in allowed_roots:
try:
root_real = os.path.realpath(str(root))
except OSError:
continue
if target_real == root_real or target_real.startswith(root_real + os.sep):
return True
return False
def _normalize_browse_request_path(path: Optional[str]) -> str:
"""Normalize the browse request path lexically, without touching the FS."""
if path is None or not path.strip():
return os.path.normpath(str(Path.home()))
expanded = os.path.expanduser(path.strip())
if not os.path.isabs(expanded):
expanded = os.path.join(str(Path.cwd()), expanded)
return os.path.normpath(expanded)
def _browse_relative_parts(requested_path: str, root: Path) -> Optional[list[str]]:
"""Return validated relative path components under ``root``."""
root_text = os.path.normpath(str(root))
try:
rel_text = os.path.relpath(requested_path, root_text)
except ValueError:
return None
if rel_text == ".":
return []
if rel_text == ".." or rel_text.startswith(f"..{os.sep}"):
return None
parts = [part for part in rel_text.split(os.sep) if part not in ("", ".")]
altsep = os.altsep
for part in parts:
if part == ".." or os.sep in part or (altsep and altsep in part):
return None
return parts
def _match_browse_child(current: Path, name: str) -> Optional[Path]:
"""Return the immediate child named ``name`` under ``current``."""
try:
for child in current.iterdir():
if child.name == name:
return child
except PermissionError:
raise HTTPException(
status_code = 403,
detail = f"Permission denied reading {current}",
) from None
except OSError as exc:
raise HTTPException(
status_code = 500,
detail = f"Could not read {current}: {exc}",
) from exc
return None
def _resolve_browse_target(path: Optional[str], allowed_roots: list[Path]) -> Path:
"""Resolve a requested browse path by walking from trusted allowlist roots."""
requested_path = _normalize_browse_request_path(path)
resolved_roots: list[Path] = []
seen_roots: set[str] = set()
for root in sorted(allowed_roots, key = lambda p: len(str(p)), reverse = True):
try:
resolved = root.resolve()
except OSError:
continue
key = str(resolved)
if key in seen_roots:
continue
seen_roots.add(key)
resolved_roots.append(resolved)
for root in resolved_roots:
parts = _browse_relative_parts(requested_path, root)
if parts is None:
continue
current = root
for part in parts:
child = _match_browse_child(current, part)
if child is None:
raise HTTPException(
status_code = 404,
detail = f"Path does not exist: {requested_path}",
)
try:
resolved_child = child.resolve()
except OSError as exc:
raise HTTPException(
status_code = 400,
detail = f"Invalid path: {exc}",
) from exc
if not _is_path_inside_allowlist(resolved_child, resolved_roots):
raise HTTPException(
status_code = 403,
detail = (
"Path is not in the browseable allowlist. Register it via "
"POST /api/models/scan-folders first, or pick a directory "
"under your home folder."
),
)
current = resolved_child
if not current.is_dir():
raise HTTPException(
status_code = 400,
detail = f"Not a directory: {current}",
)
return current
raise HTTPException(
status_code = 403,
detail = (
"Path is not in the browseable allowlist. Register it via "
"POST /api/models/scan-folders first, or pick a directory "
"under your home folder."
),
)
@router.get("/browse-folders", response_model = BrowseFoldersResponse)
async def browse_folders(
path: Optional[str] = Query(
None,
description = (
"Directory to list. If omitted, defaults to the current user's "
"home directory. Tilde (`~`) and relative paths are expanded. "
"Must resolve inside the allowlist of browseable roots (HOME, "
"HF cache, Studio dirs, registered scan folders, well-known "
"model dirs)."
),
),
show_hidden: bool = Query(
False,
description = "Include entries whose name starts with a dot",
),
current_subject: str = Depends(get_current_subject),
):
"""
List immediate subdirectories of *path* for the Custom Folders picker.
The frontend uses this to render a modal folder browser without needing
a native OS dialog (Studio is served over HTTP, so the browser can't
reveal absolute paths on the host). The endpoint is read-only and does
not create, move, or delete anything. It simply enumerates visible
subdirectories so the user can click their way to a folder and hand
the resulting string back to POST `/api/models/scan-folders`.
Sandbox: requests are bounded to the allowlist returned by
:func:`_build_browse_allowlist` (HOME, HF cache, Studio dirs,
registered scan folders, well-known model dirs). Paths outside the
allowlist return 403 so users cannot probe ``/etc``, ``/proc``,
``/root`` (when not HOME), or other sensitive system locations
even if the server process can read them. Symlinks are resolved
via ``os.path.realpath`` before the check, so symlink traversal
cannot escape the sandbox either.
Sorting: directories that look like they hold models come first, then
plain directories, then hidden entries (if `show_hidden=true`).
"""
from utils.paths import hf_default_cache_dir, well_known_model_dirs
from storage.studio_db import list_scan_folders
# Build the allowlist once -- both the sandbox check below and the
# suggestion chips use the same set, so chips are always navigable.
allowed_roots = _build_browse_allowlist()
try:
target = _resolve_browse_target(path, allowed_roots)
except HTTPException:
requested_path = _normalize_browse_request_path(path)
if path is not None and path.strip():
logger.warning(
"browse-folders: rejected path %r (normalized=%s)",
path,
requested_path,
)
raise
# Enumerate immediate subdirectories with a bounded cap so a stray
# query against ``/usr/lib`` or ``/proc`` can't stat-storm the process.
entries: list[BrowseEntry] = []
truncated = False
visited = 0
try:
it = target.iterdir()
except PermissionError:
raise HTTPException(
status_code = 403,
detail = f"Permission denied reading {target}",
)
except OSError as exc:
raise HTTPException(
status_code = 500,
detail = f"Could not read {target}: {exc}",
)
try:
for child in it:
# Bound by *visited entries*, not by *appended entries*: in
# directories full of files (or hidden subdirs when
# ``show_hidden=False``) the cap on ``len(entries)`` would
# never trigger and we'd still stat every child. Counting
# visits keeps the worst-case work to ``_BROWSE_ENTRY_CAP``
# iterdir/is_dir calls regardless of how many of them
# survive the filters below.
visited += 1
if visited > _BROWSE_ENTRY_CAP:
truncated = True
break
try:
if not child.is_dir():
continue
except OSError:
continue
name = child.name
is_hidden = name.startswith(".")
if is_hidden and not show_hidden:
continue
entries.append(
BrowseEntry(
name = name,
has_models = _looks_like_model_dir(child),
hidden = is_hidden,
)
)
except PermissionError as exc:
logger.debug(
"browse-folders: permission denied during enumeration of %s: %s",
target,
exc,
)
except OSError as exc:
# Rare: iterdir succeeded but reading a specific entry failed.
logger.warning("browse-folders: partial enumeration of %s: %s", target, exc)
# Model-bearing dirs first, then plain, then hidden; case-insensitive
# alphabetical within each bucket.
def _sort_key(e: BrowseEntry) -> tuple[int, str]:
bucket = 0 if e.has_models else (2 if e.hidden else 1)
return (bucket, e.name.lower())
entries.sort(key = _sort_key)
# Parent is None at the filesystem root (`p.parent == p`) AND when
# the parent would step outside the sandbox -- otherwise the up-row
# would 403 on click. Users can still hop to other allowed roots
# via the suggestion chips below.
parent: Optional[str]
if target.parent == target or not _is_path_inside_allowlist(
target.parent, allowed_roots
):
parent = None
else:
parent = str(target.parent)
# Handy starting points for the quick-pick chips.
suggestions: list[str] = []
seen_sug: set[str] = set()
def _add_sug(p: Optional[Path]) -> None:
if p is None:
return
try:
resolved = str(p.resolve())
except OSError:
return
if resolved in seen_sug:
return
if Path(resolved).is_dir():
seen_sug.add(resolved)
suggestions.append(resolved)
# Home always comes first -- it's the safe fallback when everything
# else is cold.
_add_sug(Path.home())
# The HF cache root the process is actually using.
try:
_add_sug(hf_default_cache_dir())
except Exception:
pass
# Already-registered scan folders (what the user has curated).
try:
for folder in list_scan_folders():
_add_sug(Path(folder.get("path", "")))
except Exception as exc:
logger.debug("browse-folders: could not load scan folders: %s", exc)
# Directories commonly used by other local-LLM tools: LM Studio
# (`~/.lmstudio/models` + legacy `~/.cache/lm-studio/models` +
# user-configured downloadsFolder from LM Studio's settings.json),
# Ollama (`~/.ollama/models` + common system paths + OLLAMA_MODELS
# env var), and generic user-choice spots (`~/models`, `~/Models`).
# Each helper only returns paths that currently exist so we never
# show dead chips.
try:
for p in well_known_model_dirs():
_add_sug(p)
except Exception as exc:
logger.debug("browse-folders: could not load well-known dirs: %s", exc)
return BrowseFoldersResponse(
current = str(target),
parent = parent,
entries = entries,
suggestions = suggestions,
truncated = truncated,
model_files_here = _count_model_files(target),
)
@router.get("/list")
async def list_models(
current_subject: str = Depends(get_current_subject),

View file

@ -0,0 +1,86 @@
# 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 os
import sys
import types
from pathlib import Path
import pytest
from fastapi import HTTPException
# Keep this test runnable in lightweight environments where optional logging
# deps are not installed.
if "structlog" not in sys.modules:
class _DummyLogger:
def __getattr__(self, _name):
return lambda *args, **kwargs: None
sys.modules["structlog"] = types.SimpleNamespace(
BoundLogger = _DummyLogger,
get_logger = lambda *args, **kwargs: _DummyLogger(),
)
import routes.models as models_route
def test_resolve_browse_target_returns_allowed_directory(tmp_path):
allowed = tmp_path / "allowed"
target = allowed / "models" / "nested"
target.mkdir(parents = True)
resolved = models_route._resolve_browse_target(str(target), [allowed])
assert resolved == target.resolve()
def test_resolve_browse_target_rejects_outside_allowlist(tmp_path):
allowed = tmp_path / "allowed"
disallowed = tmp_path / "disallowed"
allowed.mkdir()
disallowed.mkdir()
with pytest.raises(HTTPException) as exc_info:
models_route._resolve_browse_target(str(disallowed), [allowed])
assert exc_info.value.status_code == 403
def test_resolve_browse_target_rejects_file_path(tmp_path):
allowed = tmp_path / "allowed"
allowed.mkdir()
model_file = allowed / "model.gguf"
model_file.write_text("gguf")
with pytest.raises(HTTPException) as exc_info:
models_route._resolve_browse_target(str(model_file), [allowed])
assert exc_info.value.status_code == 400
def test_resolve_browse_target_allows_symlink_into_other_allowed_root(tmp_path):
home_root = tmp_path / "home"
scan_root = tmp_path / "scan"
target = scan_root / "nested"
home_root.mkdir()
target.mkdir(parents = True)
(home_root / "scan-link").symlink_to(scan_root, target_is_directory = True)
resolved = models_route._resolve_browse_target(
str(home_root / "scan-link" / "nested"),
[home_root, scan_root],
)
assert resolved == target.resolve()
@pytest.mark.skipif(os.altsep is not None, reason = "POSIX-only path semantics")
def test_resolve_browse_target_allows_backslash_in_posix_segment(tmp_path):
allowed = tmp_path / "allowed"
target = allowed / r"dir\name"
target.mkdir(parents = True)
resolved = models_route._resolve_browse_target(str(target), [allowed])
assert resolved == target.resolve()

View file

@ -215,6 +215,21 @@ TEMPLATE_TO_MODEL_MAPPER = {
"google/gemma-3n-E2B-it",
"unsloth/gemma-3n-E2B-it-unsloth-bnb-4bit",
),
"gemma-4": (
"unsloth/gemma-4-E2B-it",
"google/gemma-4-E2B-it",
"unsloth/gemma-4-E4B-it",
"google/gemma-4-E4B-it",
"unsloth/gemma-4-E2B-it-unsloth-bnb-4bit",
"unsloth/gemma-4-E4B-it-unsloth-bnb-4bit",
),
"gemma-4-thinking": (
"unsloth/gemma-4-26B-A4B-it",
"google/gemma-4-26B-A4B-it",
"unsloth/gemma-4-31B-it",
"unsloth/gemma-4-31B-it-unsloth-bnb-4bit",
"google/gemma-4-31B-it",
),
"qwen2.5": (
"unsloth/Qwen2.5-0.5B-Instruct-unsloth-bnb-4bit",
"unsloth/Qwen2.5-0.5B-Instruct",
@ -399,6 +414,15 @@ TEMPLATE_TO_MODEL_MAPPER = {
"THUDM/GLM-4.7-Flash",
"unsloth/GLM-4.7-Flash-bnb-4bit",
),
"lfm-2": (
"unsloth/LFM2-1.2B",
"LiquidAI/LFM2-1.2B",
"unsloth/LFM2-1.2B-unsloth-bnb-4bit",
),
"lfm-2.5": (
"unsloth/LFM2.5-1.2B-Instruct",
"LiquidAI/LFM2.5-1.2B-Instruct",
),
}
MODEL_TO_TEMPLATE_MAPPER = {}
@ -414,6 +438,14 @@ for key, values in TEMPLATE_TO_MODEL_MAPPER.items():
TEMPLATE_TO_RESPONSES_MAPPER = {
"gemma-4-thinking": {
"instruction": "<|turn>user\n",
"response": "<|turn>model\n",
},
"gemma-4": {
"instruction": "<|turn>user\n",
"response": "<|turn>model\n",
},
"gemma-3": {
"instruction": "<start_of_turn>user\n",
"response": "<start_of_turn>model\n",
@ -514,6 +546,10 @@ TEMPLATE_TO_RESPONSES_MAPPER = {
"instruction": "<|im_start|>user\n",
"response": "<|im_start|>assistant\n",
},
"lfm-2.5": {
"instruction": "<|im_start|>user\n",
"response": "<|im_start|>assistant\n",
},
"starling": {
"instruction": "GPT4 Correct User: ",
"response": "GPT4 Correct Assistant: ",

View file

@ -908,32 +908,95 @@ def _is_gguf_filename(filename: str) -> bool:
return filename.lower().endswith(".gguf")
def _iter_gguf_files(directory: Path):
def _iter_gguf_files(directory: Path, recursive: bool = False):
if not directory.is_dir():
return
for f in directory.iterdir():
iterator = directory.rglob("*") if recursive else directory.iterdir()
for f in iterator:
if f.is_file() and _is_gguf_filename(f.name):
yield f
def detect_mmproj_file(path: str) -> Optional[str]:
def detect_mmproj_file(path: str, search_root: Optional[str] = None) -> Optional[str]:
"""
Find the mmproj (vision projection) GGUF file in a directory.
Find the mmproj (vision projection) GGUF file for a given model.
Args:
path: Directory to search or a .gguf file (uses its parent dir).
path: Directory to search or a .gguf file (uses its parent dir
as the starting point).
search_root: Optional outer directory that should also be scanned
(and any directory between it and ``path``). This handles
local layouts where the model weights live in a quant-named
subdir (``snapshot/BF16/foo.gguf``) but the mmproj sits at
the snapshot root (``snapshot/mmproj-BF16.gguf``). When
``None``, only the immediate parent dir is scanned, matching
the historical behavior.
Returns:
Full path to the mmproj .gguf file, or None if not found.
"""
p = Path(path)
search_dir = p.parent if p.is_file() else p
if not search_dir.is_dir():
start_dir = p.parent if p.is_file() else p
if not start_dir.is_dir():
return None
for f in _iter_gguf_files(search_dir):
if _is_mmproj(f.name):
return str(f.resolve())
# Build the list of dirs to scan: immediate dir first, then walk up
# to (and including) ``search_root`` if it is an ancestor. We walk
# incrementally rather than recursing into ``search_root`` so we
# don't accidentally pick up an mmproj from a sibling subdir
# belonging to a different model variant.
seen: set[Path] = set()
scan_order: list[Path] = []
def _add(d: Path) -> None:
try:
resolved = d.resolve()
except OSError:
return
if resolved in seen or not resolved.is_dir():
return
seen.add(resolved)
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()
start_resolved = start_dir.resolve()
# Only walk if start_dir is inside (or equal to) search_root.
if root_resolved == start_resolved or (
start_resolved.is_relative_to(root_resolved)
if hasattr(start_resolved, "is_relative_to")
else str(start_resolved).startswith(str(root_resolved) + "/")
):
cur = start_resolved
# Walk up from start_dir to (and including) root_resolved.
while cur != root_resolved and cur.parent != cur:
cur = cur.parent
_add(cur)
if cur == root_resolved:
break
except OSError:
pass
for d in scan_order:
for f in _iter_gguf_files(d):
if _is_mmproj(f.name):
return str(f.resolve())
return None
@ -957,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():
@ -1183,7 +1249,11 @@ def list_local_gguf_variants(
quant_first_file: dict[str, str] = {}
has_vision = False
for f in sorted(_iter_gguf_files(p)):
# Recurse so variant-specific subdirectories (e.g. ``BF16/...gguf``
# used by some HF GGUF repos for the largest quants) are picked up.
# Filenames in the result preserve the relative subpath so that
# ``_find_local_gguf_by_variant`` can locate the file again.
for f in sorted(_iter_gguf_files(p, recursive = True)):
if _is_mmproj(f.name):
has_vision = True
continue
@ -1193,8 +1263,14 @@ def list_local_gguf_variants(
size = 0
quant = _extract_quant_label(f.name)
quant_totals[quant] = quant_totals.get(quant, 0) + size
# Only compute the (potentially expensive) relative path when this
# is the first file we've seen for this quant -- after that we'd
# discard the result anyway. Use posix-style separators so the
# filename matches what ``list_gguf_variants`` (the remote HF
# API path) returns on every platform; otherwise Windows would
# emit ``BF16\foo.gguf`` here.
if quant not in quant_first_file:
quant_first_file[quant] = f.name
quant_first_file[quant] = f.relative_to(p).as_posix()
variants = [
GgufVariantInfo(
@ -1220,9 +1296,11 @@ def _find_local_gguf_by_variant(directory: str, variant: str) -> Optional[str]:
if p is None:
return None
# Recurse into subdirectories so variants stored under a quant-named
# subdir (e.g. ``BF16/foo-BF16-00001-of-00002.gguf``) are found.
matches = sorted(
f
for f in _iter_gguf_files(p)
for f in _iter_gguf_files(p, recursive = True)
if not _is_mmproj(f.name) and _extract_quant_label(f.name) == variant
)
if matches:
@ -1932,8 +2010,16 @@ class ModelConfig:
except Exception as e:
logger.debug(f"Could not read export metadata: {e}")
# If vision (or mmproj happens to exist), find the mmproj file
mmproj_file = detect_mmproj_file(gguf_file)
# If vision (or mmproj happens to exist), find the mmproj
# file. The recursive variant scan in
# ``_find_local_gguf_by_variant`` may have returned a
# weight file inside a quant-named subdir (e.g.
# ``.../BF16/foo.gguf``) while ``mmproj-*.gguf`` lives
# at the snapshot root. Pass ``search_root=path`` so
# ``detect_mmproj_file`` walks up to the snapshot root
# instead of seeing only the weight file's immediate
# parent.
mmproj_file = detect_mmproj_file(gguf_file, search_root = path)
if mmproj_file:
gguf_is_vision = True
logger.info(f"Detected mmproj for vision: {mmproj_file}")

View file

@ -34,6 +34,7 @@ from .storage_roots import (
legacy_hf_cache_dir,
hf_default_cache_dir,
lmstudio_model_dirs,
well_known_model_dirs,
ensure_dir,
ensure_studio_directories,
resolve_under_root,
@ -70,6 +71,7 @@ __all__ = [
"legacy_hf_cache_dir",
"hf_default_cache_dir",
"lmstudio_model_dirs",
"well_known_model_dirs",
"ensure_dir",
"ensure_studio_directories",
"resolve_under_root",

View file

@ -130,6 +130,51 @@ def lmstudio_model_dirs() -> list[Path]:
return dirs
def well_known_model_dirs() -> list[Path]:
"""Return directories commonly used by other local LLM tools.
Used by the folder browser to offer quick-pick chips. Returns only
paths that exist on disk, so the UI never shows dead chips. Order
reflects a rough "likelihood the user has models here" -- LM Studio
and Ollama first, then the generic fallbacks.
"""
candidates: list[Path] = []
# LM Studio (reuses the logic above, including settings.json override)
candidates.extend(lmstudio_model_dirs())
# Ollama -- both the user-level and common system-wide install paths
# (https://github.com/ollama/ollama/issues/733).
ollama_env = os.environ.get("OLLAMA_MODELS")
if ollama_env:
candidates.append(Path(ollama_env).expanduser())
candidates.append(Path.home() / ".ollama" / "models")
candidates.append(Path("/usr/share/ollama/.ollama/models"))
candidates.append(Path("/var/lib/ollama/.ollama/models"))
# HF hub cache root (separate from the explicit HF cache chip)
candidates.append(Path.home() / ".cache" / "huggingface" / "hub")
# Generic "my models" spots users tend to drop things into
for name in ("models", "Models"):
candidates.append(Path.home() / name)
# Deduplicate while preserving order; keep only extant dirs
out: list[Path] = []
seen: set[str] = set()
for p in candidates:
try:
resolved = str(p.resolve())
except OSError:
continue
if resolved in seen:
continue
if Path(resolved).is_dir():
seen.add(resolved)
out.append(Path(resolved))
return out
def _setup_cache_env() -> None:
"""Set cache environment variables for HuggingFace, uv, and vLLM.

View file

@ -52,6 +52,7 @@ TRANSFORMERS_5_MODEL_SUBSTRINGS: tuple[str, ...] = (
"qwen3.5", # Qwen3.5 family (35B-A3B, etc.)
"qwen3-next", # Qwen3-Next and variants
"tiny_qwen3_moe", # imdatta0/tiny_qwen3_moe_2.8B_0.7B
"lfm2.5-vl-450m", # LiquidAI/LFM2.5-VL-450M
)
# Lowercase substrings for models that require transformers 5.5.0 (checked first).

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

@ -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="Studio"
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">Studio</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

@ -0,0 +1,328 @@
// 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 {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import {
type BrowseFoldersResponse,
browseFolders,
} from "@/features/chat/api/chat-api";
import { cn } from "@/lib/utils";
import { ArrowUp02Icon, Folder02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
export interface FolderBrowserProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called with the absolute path the user confirmed. */
onSelect: (path: string) => void;
/** Optional initial directory. Defaults to the user's home on the server. */
initialPath?: string;
}
function splitBreadcrumb(path: string): { label: string; value: string }[] {
if (!path) return [];
// Distinguish path styles BEFORE normalizing separators. On POSIX
// backslashes are valid filename characters, so we cannot blindly
// rewrite ``\`` -> ``/`` -- doing so would mangle directory names
// like ``my\backup`` into ``my/backup`` and produce breadcrumb
// values that 404 on the server. Only Windows-style absolute paths
// (drive letter, or UNC ``\\server\share``) get the conversion.
const isWindowsDrive = /^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path);
const isUnc = /^\\\\/.test(path);
const isWindows = isWindowsDrive || isUnc;
const normalized = isWindows ? path.replace(/\\/g, "/") : path;
const segments = normalized.split("/");
const parts: { label: string; value: string }[] = [];
// POSIX absolute path: leading empty segment from split("/")
if (segments[0] === "") {
parts.push({ label: "/", value: "/" });
let cur = "";
for (const seg of segments.slice(1)) {
if (!seg) continue;
cur = `${cur}/${seg}`;
parts.push({ label: seg, value: cur });
}
return parts;
}
// Windows-ish drive path (C:, D:): first segment is the drive. Use
// ``C:/`` (drive-absolute) as the crumb value so clicking the drive
// root navigates to the root of the drive rather than the
// drive-relative current working directory on that drive (``C:``
// alone resolves to ``CWD-on-C``, not ``C:\``).
if (/^[A-Za-z]:$/.test(segments[0])) {
const driveRoot = `${segments[0]}/`;
let cur = driveRoot;
parts.push({ label: segments[0], value: driveRoot });
for (const seg of segments.slice(1)) {
if (!seg) continue;
cur = cur.endsWith("/") ? `${cur}${seg}` : `${cur}/${seg}`;
parts.push({ label: seg, value: cur });
}
return parts;
}
// Fallback: relative / UNC-ish. Render as-is as a single crumb.
return [{ label: path, value: path }];
}
export function FolderBrowser({
open,
onOpenChange,
onSelect,
initialPath,
}: FolderBrowserProps) {
const [data, setData] = useState<BrowseFoldersResponse | null>(null);
const [path, setPath] = useState<string | undefined>(initialPath);
const [showHidden, setShowHidden] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const navigate = useCallback(
(
target: string | undefined,
hidden: boolean,
opts?: { fallbackOnError?: boolean },
) => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
// Forward the signal so cancelled navigation actually cancels the
// backend enumeration instead of just discarding the response.
browseFolders(target, hidden, ctrl.signal)
.then((res) => {
if (ctrl.signal.aborted) return;
setData(res);
setPath(res.current);
})
.catch((err) => {
if (ctrl.signal.aborted) return;
// Surface the error, but if the very first request (typically
// a typo'd or denylisted ``initialPath``) fails AND the
// browser is empty (no ``data`` to render against), fall
// back to the user's HOME so the modal is navigable instead
// of an irrecoverable dead end.
const message = err instanceof Error ? err.message : String(err);
setError(message);
if (opts?.fallbackOnError && target !== undefined) {
// Re-issue without a target -> backend defaults to HOME.
// Don't recurse if HOME itself fails (paranoia: shouldn't
// happen since the sandbox allowlist always includes HOME).
queueMicrotask(() => navigate(undefined, hidden));
}
})
.finally(() => {
if (!ctrl.signal.aborted) setLoading(false);
});
},
[],
);
// Fetch when the dialog opens. Only re-run when the dialog transitions
// closed -> open; subsequent navigation is driven by `navigate()` so we
// don't want `path` in the dependency list here.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
if (!open) return;
// ``fallbackOnError``: if the user-supplied ``initialPath`` is bad
// (typo, denylisted, deleted) we recover into HOME instead of
// showing an empty modal with no breadcrumbs/entries.
navigate(initialPath, showHidden, { fallbackOnError: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const handleConfirm = useCallback(() => {
if (!path) return;
onSelect(path);
onOpenChange(false);
}, [onSelect, onOpenChange, path]);
const crumbs = useMemo(
() => (data?.current ? splitBreadcrumb(data.current) : []),
[data?.current],
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-md p-0 gap-0"
overlayClassName="bg-black/20 backdrop-blur-none"
data-testid="folder-browser-dialog"
>
<DialogHeader className="px-4 pt-4 pb-2">
<DialogTitle className="text-sm font-medium">
Browse for folder
</DialogTitle>
</DialogHeader>
{/* Breadcrumb */}
<div className="flex flex-wrap items-center gap-0.5 border-t border-border/50 px-4 py-2 font-mono text-[11px] text-muted-foreground">
{crumbs.length === 0 ? (
<span className="text-muted-foreground/60">(loading)</span>
) : (
crumbs.map((c, i) => (
<span key={c.value} className="flex items-center gap-0.5">
<button
type="button"
className="rounded px-1 py-0.5 hover:bg-accent hover:text-foreground"
onClick={() => navigate(c.value, showHidden)}
disabled={loading}
>
{c.label}
</button>
{i < crumbs.length - 1 && (
<span className="text-muted-foreground/40">/</span>
)}
</span>
))
)}
</div>
{/* Suggestions (quick-pick chips) */}
{data?.suggestions && data.suggestions.length > 0 && (
<div className="flex flex-wrap gap-1 border-t border-border/50 px-4 py-2">
{data.suggestions.map((s) => (
<button
key={s}
type="button"
onClick={() => navigate(s, showHidden)}
disabled={loading}
className="rounded-full border border-border/50 px-2 py-0.5 font-mono text-[10px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
title={s}
>
{s.length > 36 ? `${s.slice(-33)}` : s}
</button>
))}
</div>
)}
{/* Entry list */}
<div className="max-h-64 min-h-24 overflow-y-auto border-t border-border/50">
{error && (
<div className="px-4 py-3 text-xs text-destructive">{error}</div>
)}
{!error && loading && (
<div className="flex items-center gap-2 px-4 py-3">
<Spinner className="size-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Loading</span>
</div>
)}
{!error && !loading && data && (
<>
{/* Up row */}
{data.parent !== null && (
<button
type="button"
onClick={() => navigate(data.parent ?? undefined, showHidden)}
className="flex w-full items-center gap-2 px-4 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<HugeiconsIcon
icon={ArrowUp02Icon}
className="size-3 shrink-0"
/>
<span className="font-mono">..</span>
</button>
)}
{data.entries.length === 0 && !(data.model_files_here && data.model_files_here > 0) && (
<div className="px-4 py-3 text-xs text-muted-foreground/60">
(empty directory)
</div>
)}
{data.model_files_here !== undefined && data.model_files_here > 0 && (
<div className="border-t border-border/30 px-4 py-1.5 text-[10px] text-foreground/70">
{data.model_files_here} model file{data.model_files_here === 1 ? "" : "s"} in this folder. Click "Use this folder" to scan it.
</div>
)}
{data.truncated === true && (
<div className="border-t border-border/30 px-4 py-1.5 text-[10px] text-muted-foreground/70">
Showing first {data.entries.length} entries. Narrow the path
to see more.
</div>
)}
{data.entries.map((e) => (
<button
type="button"
key={e.name}
onClick={() => {
const sep = data.current.endsWith("/") ? "" : "/";
navigate(`${data.current}${sep}${e.name}`, showHidden);
}}
className={cn(
"flex w-full items-center gap-2 px-4 py-1.5 text-left text-xs transition-colors hover:bg-accent hover:text-foreground",
e.hidden && "text-muted-foreground/60",
)}
>
<HugeiconsIcon
icon={Folder02Icon}
className={cn(
"size-3 shrink-0",
e.has_models
? "text-foreground"
: "text-muted-foreground/50",
)}
/>
<span className="truncate font-mono">{e.name}</span>
{e.has_models && (
<span className="ml-auto shrink-0 rounded-full border border-border/50 px-1.5 py-0 text-[9px] uppercase tracking-wider text-muted-foreground">
models
</span>
)}
</button>
))}
</>
)}
</div>
{/* Footer */}
<DialogFooter className="flex items-center justify-between gap-2 border-t border-border/50 px-4 py-2">
<label className="flex cursor-pointer items-center gap-1.5 text-[10px] text-muted-foreground">
<input
type="checkbox"
checked={showHidden}
onChange={(e) => {
const next = e.target.checked;
setShowHidden(next);
navigate(path, next);
}}
className="size-3"
/>
Show hidden
</label>
<div className="flex gap-2">
<DialogClose asChild={true}>
<button
type="button"
className="h-7 rounded border border-border/50 px-2.5 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Cancel
</button>
</DialogClose>
<button
type="button"
onClick={handleConfirm}
disabled={!path || loading || !!error}
className="h-7 rounded bg-foreground px-2.5 text-[11px] font-medium text-background transition-colors hover:bg-foreground/90 disabled:opacity-40"
>
Use this folder
</button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -27,6 +27,7 @@ import {
listCachedModels,
listGgufVariants,
listLocalModels,
listRecommendedFolders,
listScanFolders,
removeScanFolder,
} from "@/features/chat/api/chat-api";
@ -48,7 +49,8 @@ import type { VramFitStatus } from "@/lib/vram";
import { checkVramFit, estimateLoadingVram } from "@/lib/vram";
import { Add01Icon, Cancel01Icon, Folder02Icon, Search01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Trash2Icon } from "lucide-react";
import { FolderBrowser } from "./folder-browser";
import { ChevronDownIcon, ChevronRightIcon, DownloadIcon, StarIcon, Trash2Icon } from "lucide-react";
import {
type ReactNode,
useCallback,
@ -72,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>
);
}
@ -488,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.
@ -512,6 +542,8 @@ export function HubModelPicker({
const [folderError, setFolderError] = useState<string | null>(null);
const [showFolderInput, setShowFolderInput] = useState(false);
const [folderLoading, setFolderLoading] = useState(false);
const [showFolderBrowser, setShowFolderBrowser] = useState(false);
const [recommendedFolders, setRecommendedFolders] = useState<string[]>([]);
const refreshLocalModelsList = useCallback(() => {
listLocalModels()
@ -537,11 +569,22 @@ export function HubModelPicker({
.catch(() => {});
}, []);
const handleAddFolder = useCallback(async () => {
const trimmed = folderInput.trim();
const handleAddFolder = useCallback(async (overridePath?: string) => {
// Accept an explicit path so the folder browser can submit the
// chosen path in the same tick it calls `setFolderInput`; reading
// `folderInput` alone would race the state update.
const raw = overridePath !== undefined ? overridePath : folderInput;
const trimmed = raw.trim();
if (!trimmed || folderLoading) return;
setFolderError(null);
setFolderLoading(true);
// True when the request originated from the folder browser's
// ``onSelect`` (one-click "Use this folder"). In that flow the
// typed-input panel is closed, so the inline ``folderError``
// paragraph is invisible. Surface failures via toast instead so
// the action doesn't appear to silently no-op when the backend
// rejects (denylisted path, sandbox 403, etc.).
const fromBrowser = overridePath !== undefined;
try {
const created = await addScanFolder(trimmed);
// Backend returns existing row for duplicates, so deduplicate
@ -557,7 +600,11 @@ export function HubModelPicker({
// Background reconciliation with the server
void refreshScanFolders();
} catch (e) {
setFolderError(e instanceof Error ? e.message : "Failed to add folder");
const message = e instanceof Error ? e.message : "Failed to add folder";
setFolderError(message);
if (fromBrowser) {
toast.error("Couldn't add folder", { description: message });
}
} finally {
setFolderLoading(false);
}
@ -599,8 +646,15 @@ export function HubModelPicker({
// Always refresh LM Studio + custom folder models (not gated by alreadyCached)
refreshLocalModelsList();
refreshScanFolders();
listRecommendedFolders()
.then(setRecommendedFolders)
.catch(() => {});
if (alreadyCached) return;
// Always refetch cached GGUF/model lists. The module-level caches give
// an instant render with stale data (no spinner flash), but newly
// downloaded repos won't appear unless we re-hit the backend on every
// mount. Initial state already has cachedReady=alreadyCached, so the
// background refresh is invisible when we already had data.
let done = 0;
const check = () => {
if (++done >= 2) setCachedReady(true);
@ -619,7 +673,7 @@ export function HubModelPicker({
})
.catch(() => {})
.finally(check);
}, [alreadyCached, refreshLocalModelsList, refreshScanFolders]);
}, [refreshLocalModelsList, refreshScanFolders]);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTarget) return;
@ -872,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}
@ -901,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">
@ -980,30 +1038,56 @@ export function HubModelPicker({
{!showHfSection ? (
<>
<div className="flex items-center justify-between 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>
<button
type="button"
aria-label={showFolderInput ? "Cancel adding folder" : "Add scan folder"}
onClick={() => {
setShowFolderInput((open) => {
if (open) { setFolderInput(""); setFolderError(null); }
return !open;
});
}}
className="rounded p-0.5 text-muted-foreground/60 transition-colors hover:text-foreground"
>
<HugeiconsIcon icon={showFolderInput ? Cancel01Icon : Add01Icon} className="size-3" />
</button>
<div className="flex items-center gap-0.5">
<button
type="button"
aria-label={showFolderInput ? "Cancel adding folder" : "Add scan folder by path"}
title={showFolderInput ? "Cancel" : "Add by typing a path"}
onClick={() => {
setShowFolderInput((open) => {
if (open) { setFolderInput(""); setFolderError(null); }
return !open;
});
}}
className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
>
<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-3 py-0.5"
className="group flex items-center gap-1.5 px-2.5 py-0.5"
>
<HugeiconsIcon icon={Folder02Icon} className="size-3 shrink-0 text-muted-foreground/40" />
<span
@ -1016,15 +1100,38 @@ export function HubModelPicker({
type="button"
onClick={() => handleRemoveFolder(f.id)}
aria-label={`Remove folder ${f.path}`}
className="shrink-0 rounded p-0.5 text-muted-foreground/40 opacity-100 md:opacity-0 md:group-hover:opacity-100 focus-visible:opacity-100 transition-opacity hover:text-destructive"
className="shrink-0 rounded p-1 text-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive"
>
<HugeiconsIcon icon={Cancel01Icon} className="size-2.5" />
<HugeiconsIcon icon={Cancel01Icon} className="size-3" />
</button>
</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" />
@ -1042,7 +1149,17 @@ export function HubModelPicker({
/>
<button
type="button"
onClick={handleAddFolder}
onClick={() => setShowFolderBrowser(true)}
disabled={folderLoading}
aria-label="Browse for folder"
title="Browse folders on the server"
className="flex h-6 shrink-0 items-center justify-center rounded border border-border/50 px-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
>
<HugeiconsIcon icon={Search01Icon} className="size-3" />
</button>
<button
type="button"
onClick={() => { void handleAddFolder(); }}
disabled={folderLoading || !folderInput.trim()}
className="h-6 shrink-0 rounded border border-border/50 px-1.5 text-[10px] text-muted-foreground transition-colors hover:bg-accent disabled:opacity-40"
>
@ -1055,23 +1172,31 @@ export function HubModelPicker({
</div>
)}
{/* Empty state */}
{scanFolders.length === 0 && customFolderModels.length === 0 && !showFolderInput && (
<button
type="button"
onClick={() => setShowFolderInput(true)}
className="px-2.5 pb-1.5 text-left text-[10px] text-muted-foreground/60 transition-colors hover:text-muted-foreground"
>
+ Add a folder to scan for local models
</button>
)}
<FolderBrowser
open={showFolderBrowser}
onOpenChange={setShowFolderBrowser}
initialPath={folderInput.trim() || undefined}
onSelect={(picked) => {
setFolderInput(picked);
setFolderError(null);
// One-click UX: the "Use this folder" button submits
// the scan folder directly. Pass the path explicitly
// because `folderInput` state hasn't flushed yet.
void handleAddFolder(picked);
}}
/>
{/* 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
@ -1079,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,
);
@ -1111,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>
@ -1156,7 +1291,7 @@ export function HubModelPicker({
);
})
)}
{hasMoreRecommended && (
{!recommendedCollapsed && hasMoreRecommended && (
<>
<div ref={recommendedSentinelRef} className="h-px" />
<div className="flex items-center justify-center py-2">
@ -1169,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

@ -247,6 +247,48 @@ export async function removeScanFolder(id: number): Promise<void> {
await parseJsonOrThrow<unknown>(response);
}
export interface BrowseEntry {
name: string;
has_models: boolean;
hidden: boolean;
}
export interface BrowseFoldersResponse {
current: string;
parent: string | null;
entries: BrowseEntry[];
suggestions: string[];
truncated?: boolean;
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,
signal?: AbortSignal,
): Promise<BrowseFoldersResponse> {
const params = new URLSearchParams();
if (path !== undefined && path !== null) params.set("path", path);
if (showHidden) params.set("show_hidden", "true");
const qs = params.toString();
// Forward the AbortSignal through authFetch -> fetch so that a
// navigation cancelled in the FolderBrowser (rapid breadcrumb / row /
// hidden-toggle clicks) actually cancels the in-flight HTTP request
// server-side, instead of merely dropping the response client-side
// while the backend keeps walking large directory trees.
const response = await authFetch(
`/api/models/browse-folders${qs ? `?${qs}` : ""}`,
signal ? { signal } : undefined,
);
return parseJsonOrThrow<BrowseFoldersResponse>(response);
}
export async function listGgufVariants(
repoId: string,
hfToken?: string,

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

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

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

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

@ -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,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 { 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 { useState } from "react";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
const VERSION: string =
(import.meta.env.VITE_APP_VERSION as string | undefined) ?? "dev";
export function AboutTab() {
const deviceType = usePlatformStore((s) => s.deviceType);
const defaultShell = deviceType === "windows" ? "windows" : "unix";
const [shutdownOpen, setShutdownOpen] = useState(false);
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,197 @@
// 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 { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
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 hfToken = useChatRuntimeStore((s) => s.hfToken);
const setHfToken = useChatRuntimeStore((s) => s.setHfToken);
const autoTitle = useChatRuntimeStore((s) => s.autoTitle);
const setAutoTitle = useChatRuntimeStore((s) => s.setAutoTitle);
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>
<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";
@ -49,7 +50,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,9 +67,20 @@ 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,
@ -86,6 +103,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 +131,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: 0.625rem;
--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: 0.625rem;
--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: 0.625rem;
--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

@ -64,6 +64,42 @@ class TestGetModelName(unittest.TestCase):
"unsloth/Ministral-3-3B-Instruct-2512",
True,
),
(
"allenai/Olmo-3-7B-Instruct",
True,
"unsloth/Olmo-3-7B-Instruct-unsloth-bnb-4bit",
True,
),
(
"allenai/Olmo-3-7B-Instruct",
False,
"unsloth/Olmo-3-7B-Instruct",
True,
),
(
"allenai/Olmo-3-7B-Think",
True,
"unsloth/Olmo-3-7B-Think-unsloth-bnb-4bit",
True,
),
(
"allenai/Olmo-3-7B-Think",
False,
"unsloth/Olmo-3-7B-Think",
True,
),
(
"allenai/Olmo-3-32B-Think",
True,
"unsloth/Olmo-3-32B-Think-unsloth-bnb-4bit",
True,
),
(
"allenai/Olmo-3-32B-Think",
False,
"unsloth/Olmo-3-32B-Think",
True,
),
("unsloth/Kimi-K2-Instruct", True, "unsloth/Kimi-K2-Instruct-BF16", True),
("unsloth/Kimi-K2-Instruct", False, "unsloth/Kimi-K2-Instruct", False),
# Fallback-to-original behavior
@ -113,6 +149,10 @@ class TestGetModelName(unittest.TestCase):
"mistralai/ministral-3-3b-instruct-2512",
"unsloth/ministral-3-3b-instruct-2512-unsloth-bnb-4bit",
),
(
"allenai/olmo-3-7b-instruct",
"unsloth/olmo-3-7b-instruct-unsloth-bnb-4bit",
),
("unsloth/kimi-k2-instruct", "unsloth/kimi-k2-instruct-bf16"),
]
for src, expected in contracts:

View file

@ -70,6 +70,11 @@ def _test_linear_is_fake_quantized(linear: torch.nn.Linear, qat_scheme: str):
weight_fq_class = IntxFakeQuantizer
min_in_features = 128
weight_only = True
elif qat_scheme == "cactus":
act_fq_class = None
weight_fq_class = IntxFakeQuantizer
min_in_features = 32
weight_only = True
else:
raise ValueError(f"Unknown qat_scheme: {qat_scheme}")
@ -106,7 +111,7 @@ def _test_fake_quantizers_are_called(
"""
Verify that the fake quantizers are actually called when the model is called.
"""
weight_only = qat_scheme == "int8"
weight_only = qat_scheme in ["int8", "cactus"]
def _swap_fake_quantizers(model: torch.nn.Module):
for name, child in model.named_children():
@ -167,11 +172,11 @@ def _test_model_fake_quantize(qat_scheme: str, full_finetuning: bool):
# TODO: there are bad interactions across tests right now, need to figure out
# how to disable model caching before re-enabling this test
@pytest.mark.parametrize("qat_scheme", ["fp8-int4", "fp8-fp8", "int8"])
@pytest.mark.parametrize("qat_scheme", ["fp8-int4", "fp8-fp8", "int8", "cactus"])
def _test_full_model_fake_quantize(qat_scheme: str):
_test_model_fake_quantize(qat_scheme, full_finetuning = True)
@pytest.mark.parametrize("qat_scheme", ["fp8-int4", "fp8-fp8", "int8"])
@pytest.mark.parametrize("qat_scheme", ["fp8-int4", "fp8-fp8", "int8", "cactus"])
def test_lora_model_fake_quantize(qat_scheme: str):
_test_model_fake_quantize(qat_scheme, full_finetuning = False)

View file

@ -1716,6 +1716,8 @@ liquid_lfm2_template = \
liquid_lfm2_template_eos_token = "<|im_end|>"
CHAT_TEMPLATES["lfm-2"] = (liquid_lfm2_template, liquid_lfm2_template_eos_token, False, None)
DEFAULT_SYSTEM_MESSAGE["lfm-2"] = None # No system message in Phi-3
CHAT_TEMPLATES["lfm-2.5"] = (liquid_lfm2_template, liquid_lfm2_template_eos_token, False, None)
DEFAULT_SYSTEM_MESSAGE["lfm-2.5"] = None
# =========================================== Starling-LM

View file

@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "2026.4.4"
__version__ = "2026.4.5"
__all__ = [
"SUPPORTS_BFLOAT16",
@ -45,6 +45,7 @@ __all__ = [
# "accelerate_old_send_to_device",
# "accelerate_new_send_to_device",
"patch_gradient_accumulation_fix",
"apply_accepts_loss_kwargs_fix",
"patch_compiling_bitsandbytes",
"patch_regional_compilation",
"patch_layernorm",
@ -64,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",
@ -232,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):
@ -242,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):
@ -2083,47 +2312,148 @@ def patch_gradient_accumulation_fix(Trainer):
exec(function, globals())
Trainer.training_step = _unsloth_training_step
# Prevent double scaling gradient accumulation
# https://github.com/huggingface/transformers/pull/37208
# Patch model_accepts_loss_kwargs detection in Trainer.__init__
if Trainer.__init__.__name__ != "_unsloth___init__":
# Wrap Trainer.__init__: (1) pre-init, shadow accepts_loss_kwargs on whatever
# model was passed in (covers PEFT wrapping done after FastModel.from_pretrained);
# (2) post-init, clamp accelerator GA to 1 for the transformers 5.0-5.5
# GradientAccumulationPlugin regression. No-op on 4.x and 5.6+. See #4982.
if not getattr(Trainer, "_unsloth_init_wrapped_for_accelerate_gas", False):
_original_trainer_init = Trainer.__init__
def _unsloth_trainer_init(self, *args, **kwargs):
model = kwargs.get("model")
if model is None and len(args) > 0:
model = args[0]
if model is not None:
try:
apply_accepts_loss_kwargs_fix(model)
except Exception:
pass
_original_trainer_init(self, *args, **kwargs)
try:
accelerator = getattr(self, "accelerator", None)
if (
accelerator is not None
and getattr(accelerator, "gradient_accumulation_steps", 1) > 1
):
accelerator.gradient_accumulation_steps = 1
gs = getattr(accelerator, "gradient_state", None)
if gs is not None and hasattr(gs, "plugin_kwargs"):
try:
gs.plugin_kwargs["num_steps"] = 1
except Exception:
pass
except Exception:
pass
_unsloth_trainer_init.__wrapped__ = _original_trainer_init
Trainer.__init__ = _unsloth_trainer_init
Trainer._unsloth_init_wrapped_for_accelerate_gas = True
def _unsloth_compile_cache_leaves():
# Accepts `UNSLOTH_COMPILE_LOCATION` overrides (the env var unsloth_zoo honors).
leaves = {"unsloth_compiled_cache", "unsloth_cache", "unsloth_compiled"}
loc = os.environ.get("UNSLOTH_COMPILE_LOCATION", "") or ""
loc = loc.rstrip("/\\")
if loc:
leaves.add(os.path.basename(loc) or loc)
return leaves
def _forward_is_unsloth_compiled(model):
# True iff forward was installed from the Unsloth compile cache directory.
# __module__ stays as the transformers module, so check co_filename.
leaves = _unsloth_compile_cache_leaves()
def check(m):
if m is None:
return False
fwd = getattr(type(m), "forward", None)
if fwd is None:
return False
code = getattr(fwd, "__code__", None)
fn = getattr(code, "co_filename", "") if code is not None else ""
fn = fn.replace("\\", "/")
parts = set(fn.split("/"))
return any(leaf in parts for leaf in leaves)
if check(model):
return True
seen = set()
m = model
for _ in range(4):
if m is None or id(m) in seen:
break
seen.add(id(m))
nxt = getattr(m, "base_model", None)
if nxt is None or nxt is m:
nxt = getattr(m, "model", None)
if nxt is None or nxt is m:
break
if check(nxt):
return True
m = nxt
return False
def _find_concrete_accepts_loss_kwargs(model):
# Walk wrapper chain for first class that declares accepts_loss_kwargs in its
# own __mro__ dict. Avoids PEFT __getattr__ forwarding and our own shadow.
seen = set()
m = model
for _ in range(6):
if m is None or id(m) in seen:
break
seen.add(id(m))
for klass in type(m).__mro__:
if "accepts_loss_kwargs" in klass.__dict__:
return klass.__dict__[
"accepts_loss_kwargs"
], f"{klass.__name__}.accepts_loss_kwargs"
nxt = getattr(m, "base_model", None)
if nxt is None or nxt is m:
nxt = getattr(m, "model", None)
if nxt is None or nxt is m:
break
m = nxt
return None, "no explicit accepts_loss_kwargs on any wrapper level"
def _shadow_accepts_loss_kwargs(model, value):
# Set the attribute at every wrapper level so HF's hasattr check resolves
# regardless of where accelerator / peft unwrap lands.
seen = set()
m = model
for _ in range(8):
if m is None or id(m) in seen:
break
seen.add(id(m))
try:
init_function = inspect.getsource(Trainer.__init__)
setattr(m, "accepts_loss_kwargs", value)
except Exception:
init_function = ""
if init_function is not None:
init_function = textwrap.dedent(init_function)
pass
nxt = getattr(m, "base_model", None)
if nxt is None or nxt is m:
nxt = getattr(m, "model", None)
if nxt is None or nxt is m:
break
m = nxt
# Import all variables that need importing
import transformers.trainer
items_in_trainer = dir(transformers.trainer)
good_items = []
for item in items_in_trainer:
if item in init_function:
good_items.append(item)
exec(
"from transformers.trainer import ("
+ ", ".join(x for x in good_items)
+ ")",
globals(),
)
def apply_accepts_loss_kwargs_fix(model):
# Shadow the correct accepts_loss_kwargs on the model so HF Trainer picks it
# up via hasattr(unwrapped_model, ...). Replaces the old Trainer.__init__
# source rewrite. Priority: compiled forward -> True; else first class attr
# in wrapper chain; else leave HF default. Issue #4982.
if _forward_is_unsloth_compiled(model):
_shadow_accepts_loss_kwargs(model, True)
return "True (Unsloth compiled forward)"
init_function = init_function.replace(
"def __init__", "def _unsloth___init__", 1
)
# Respect an inner wrapped model's explicit accepts_loss_kwargs flag before inferring from forward(**kwargs).
# https://github.com/unslothai/unsloth/issues/4982 Gemma4ForConditionalGeneration had issues with grad_acc
init_function = init_function.replace(
"self.model_accepts_loss_kwargs = unwrapped_model.accepts_loss_kwargs\n else:",
"self.model_accepts_loss_kwargs = unwrapped_model.accepts_loss_kwargs\n"
' elif hasattr(getattr(unwrapped_model, "model", None), "accepts_loss_kwargs"):\n'
" self.model_accepts_loss_kwargs = unwrapped_model.model.accepts_loss_kwargs\n"
" else:",
)
exec(init_function, globals())
Trainer.__init__ = _unsloth___init__
value, reason = _find_concrete_accepts_loss_kwargs(model)
if value is None:
return f"default (signature inspection, {reason})"
_shadow_accepts_loss_kwargs(model, value)
return f"{value} ({reason})"
def patch_tokenizer(model, tokenizer):
@ -2625,6 +2955,55 @@ def _prepare_model_for_qat(
qat_scheme = qat_scheme,
base_config_and_filter_fns = [(base_config, filter_fn)],
)
elif qat_scheme == "cactus":
try:
from torchao.quantization import IntxWeightOnlyConfig
except ImportError:
raise ImportError(TORCHAO_MSG)
# IntxWeightOnlyConfig already defaults to
# `mapping_type = MappingType.SYMMETRIC`, so we intentionally do not
# import `MappingType` here. Matches the upstream Cactus runtime
# int8 / per-group-32 / symmetric weight-only configuration.
group_size = 32
base_config = IntxWeightOnlyConfig(
weight_dtype = torch.int8,
granularity = PerGroup(group_size),
)
filter_fn = (
lambda m, _: isinstance(m, torch.nn.Linear)
and m.in_features >= group_size
and m.in_features % group_size == 0
)
# Warn if any Linear layer is skipped by the cactus filter because
# its in_features is not divisible by `group_size`. torchao's
# PerGroup(32) quantizer rejects non-divisible widths at
# `quantize_()` time, so the filter excludes those layers to keep
# the QAT prepare step from crashing. Surface that silently-skipped
# coverage gap to the user so they know some Linears will stay in
# full precision during training.
skipped_cactus_layers = [
name
for name, module in model.named_modules()
if isinstance(module, torch.nn.Linear)
and module.in_features >= group_size
and module.in_features % group_size != 0
]
if skipped_cactus_layers:
preview = ", ".join(skipped_cactus_layers[:8])
if len(skipped_cactus_layers) > 8:
preview += f", ... ({len(skipped_cactus_layers) - 8} more)"
warnings.warn(
f"Unsloth: qat_scheme='cactus' uses PerGroup({group_size}) "
"which requires in_features to be divisible by "
f"{group_size}. The following Linear layers will be kept "
f"in full precision during QAT: {preview}",
stacklevel = 2,
)
torchao_config = TorchAOConfig(
qat_scheme = qat_scheme,
base_config_and_filter_fns = [(base_config, filter_fn)],
)
else:
raise ValueError(f"Unexpected QAT scheme {qat_scheme}")
assert torchao_config is not None, f"TorchAOConfig was not set for {qat_scheme}"

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
)
@ -2695,7 +2695,8 @@ class FastLlamaModel:
patch_saving_functions(model)
Trainer._inner_training_loop = _fast_inner_training_loop
# Fix gradient accumulation
# Fix gradient accumulation. See issue #4982.
apply_accepts_loss_kwargs_fix(model)
patch_gradient_accumulation_fix(Trainer)
# Save tokenizer for inference purposes

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"):
@ -1210,13 +1207,20 @@ class FastModel(FastBaseModel):
# Granite-4 rms norms are stored as 16 bit, but we upcast
os.environ["UNSLOTH_HIGH_PRECISION_LAYERNORM"] = "1"
os.environ["UNSLOTH_DISABLE_STATIC_GENERATION"] = "1"
# Olmo 2
# OLMo 2
elif "olmo2" in model_types_all and transformers_version < Version(
"4.50.0.dev0"
):
raise RuntimeError(
"Unsloth: OLMo-2 only works on transformers >= 4.50.0." + NIGHTLY
)
# OLMo 3
elif "olmo3" in model_types_all and transformers_version < Version(
"4.57.0.dev0"
):
raise RuntimeError(
"Unsloth: OLMo-3 only works on transformers >= 4.57.0." + LATEST
)
elif "falcon_h1" in model_types_all:
# Falcon must use float32 Triton ie TRITON_F32_DEFAULT = 'ieee'
# since Mamba kernels error out on using lower precision

View file

@ -22,6 +22,39 @@ __all__ = [
__INT_TO_FLOAT_MAPPER = \
{
"unsloth/gemma-4-E2B-it-unsloth-bnb-4bit" : (
"unsloth/gemma-4-E2B-it",
"google/gemma-4-E2B-it",
),
"unsloth/gemma-4-E4B-it-unsloth-bnb-4bit" : (
"unsloth/gemma-4-E4B-it",
"google/gemma-4-E4B-it",
),
"unsloth/gemma-4-31B-it-unsloth-bnb-4bit" : (
"unsloth/gemma-4-31B-it",
"google/gemma-4-31B-it",
),
"unsloth/gemma-4-26B-A4B-it" : (
"unsloth/gemma-4-26B-A4B-it",
"google/gemma-4-26B-A4B-it",
),
"unsloth/gemma-4-E2B-unsloth-bnb-4bit" : (
"unsloth/gemma-4-E2B",
"google/gemma-4-E2B",
),
"unsloth/gemma-4-E4B-unsloth-bnb-4bit" : (
"unsloth/gemma-4-E4B",
"google/gemma-4-E4B",
),
"unsloth/gemma-4-31B-unsloth-bnb-4bit" : (
"unsloth/gemma-4-31B",
"google/gemma-4-31B",
),
"unsloth/LFM2-1.2B-unsloth-bnb-4bit" : (
"unsloth/LFM2-1.2B",
"LiquidAI/LFM2-1.2B",
),
"unsloth/mistral-7b-bnb-4bit" : (
"unsloth/mistral-7b",
"mistralai/Mistral-7B-v0.1",
@ -762,6 +795,18 @@ __INT_TO_FLOAT_MAPPER = \
"allenai/OLMo-2-0325-32B-Instruct",
"unsloth/OLMo-2-0325-32B-Instruct-bnb-4bit",
),
"unsloth/Olmo-3-7B-Instruct-unsloth-bnb-4bit" : (
"unsloth/Olmo-3-7B-Instruct",
"allenai/Olmo-3-7B-Instruct",
),
"unsloth/Olmo-3-7B-Think-unsloth-bnb-4bit" : (
"unsloth/Olmo-3-7B-Think",
"allenai/Olmo-3-7B-Think",
),
"unsloth/Olmo-3-32B-Think-unsloth-bnb-4bit" : (
"unsloth/Olmo-3-32B-Think",
"allenai/Olmo-3-32B-Think",
),
"unsloth/Mistral-Small-3.1-24B-Instruct-2503-unsloth-bnb-4bit" : (
"unsloth/Mistral-Small-3.1-24B-Instruct-2503",
"mistralai/Mistral-Small-3.1-24B-Instruct-2503",
@ -1416,3 +1461,6 @@ for key, values in __INT_TO_FLOAT_MAPPER.items():
for value in values:
FLOAT_TO_INT_MAPPER[value.lower()] = lowered_key
_add_with_lower(MAP_TO_UNSLOTH_16bit, "google/gemma-4-26B-A4B", "unsloth/gemma-4-26B-A4B")
_add_with_lower(MAP_TO_UNSLOTH_16bit, "LiquidAI/LFM2.5-1.2B-Instruct", "unsloth/LFM2.5-1.2B-Instruct")

View file

@ -2018,11 +2018,12 @@ def patch_trl_disable_gradient_checkpointing():
except (AttributeError, TypeError):
pass
logger.warning_once(
"Unsloth: Patched trl.models.utils.disable_gradient_checkpointing with "
"a no-op to preserve Unsloth gradient checkpointing across TRL "
"generation passes."
)
if os.environ.get("UNSLOTH_ENABLE_LOGGING", "0") == "1":
logger.warning_once(
"Unsloth: Patched trl.models.utils.disable_gradient_checkpointing with "
"a no-op to preserve Unsloth gradient checkpointing across TRL "
"generation passes."
)
return

View file

@ -1154,6 +1154,7 @@ def grpo_trainer_compute_loss(function_name, function):
ref_logps,
per_token_logps,
old_logps,
sampling_per_token_logps,
input_ids,
completion_mask,
self.beta,
@ -1174,7 +1175,6 @@ def grpo_trainer_compute_loss(function_name, function):
num_items_in_batch = num_items_in_batch,
current_gradient_accumulation_steps = current_gradient_accumulation_steps,
num_processes = num_processes,
sampling_per_token_logps = sampling_per_token_logps,
)
else:
if hasattr(self.args, "loss_type"):

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
@ -607,37 +613,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 +767,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)
@ -1123,9 +1108,10 @@ class FastBaseModel:
)
patch_saving_functions(tokenizer, vision = True)
# Fix gradient accumulation
# Fix gradient accumulation. See issue #4982.
from transformers.trainer import Trainer
apply_accepts_loss_kwargs_fix(model)
patch_gradient_accumulation_fix(Trainer)
# Save tokenizer for inference purposes

View file

@ -1978,12 +1978,19 @@ OLLAMA_TEMPLATE_TO_MODEL_MAPPER = {
),
"gemma4": (
"unsloth/gemma-4-E2B-it",
"unsloth/gemma-4-E2B-it-unsloth-bnb-4bit",
"google/gemma-4-E2B-it",
"unsloth/gemma-4-E2B",
"unsloth/gemma-4-E4B-it",
"unsloth/gemma-4-E4B-it-unsloth-bnb-4bit",
"google/gemma-4-E4B-it",
"unsloth/gemma-4-E4B",
"unsloth/gemma-4-31B-it",
"unsloth/gemma-4-31B-it-unsloth-bnb-4bit",
"google/gemma-4-31B-it",
"unsloth/gemma-4-31B",
"unsloth/gemma-4-26B-A4B-it",
"google/gemma-4-26B-A4B-it",
"unsloth/gemma-4-26B-A4B",
),
"gemma3n": (

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(