diff --git a/install.ps1 b/install.ps1 index 61f31d85b4..3fc9ac4690 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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. diff --git a/install.sh b/install.sh index 0dbcdf380e..6915893ddf 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 50bdf58b95..b4c0122f4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index b53fc513de..77b58e22fb 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -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." diff --git a/studio/backend/models/models.py b/studio/backend/models/models.py index f67014a17b..46ca4e3784 100644 --- a/studio/backend/models/models.py +++ b/studio/backend/models/models.py @@ -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)." + ), + ) diff --git a/studio/backend/requirements/extras-no-deps.txt b/studio/backend/requirements/extras-no-deps.txt index a16571567d..23c61baa44 100644 --- a/studio/backend/requirements/extras-no-deps.txt +++ b/studio/backend/requirements/extras-no-deps.txt @@ -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; diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 4ce2d0787c..db27ce1907 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -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 ``/.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:: + + /manifests//// + /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 + ``/.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), diff --git a/studio/backend/tests/test_browse_folders_route.py b/studio/backend/tests/test_browse_folders_route.py new file mode 100644 index 0000000000..19a83987d3 --- /dev/null +++ b/studio/backend/tests/test_browse_folders_route.py @@ -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() diff --git a/studio/backend/utils/datasets/model_mappings.py b/studio/backend/utils/datasets/model_mappings.py index 95b4791574..7fcac637c6 100644 --- a/studio/backend/utils/datasets/model_mappings.py +++ b/studio/backend/utils/datasets/model_mappings.py @@ -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": "user\n", "response": "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: ", diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index fae2337bbd..a2d48cf009 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -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}") diff --git a/studio/backend/utils/paths/__init__.py b/studio/backend/utils/paths/__init__.py index 11709ae56e..92191dccdd 100644 --- a/studio/backend/utils/paths/__init__.py +++ b/studio/backend/utils/paths/__init__.py @@ -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", diff --git a/studio/backend/utils/paths/storage_roots.py b/studio/backend/utils/paths/storage_roots.py index 4841c5d0a3..b52609b06b 100644 --- a/studio/backend/utils/paths/storage_roots.py +++ b/studio/backend/utils/paths/storage_roots.py @@ -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. diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 0c13b5455b..36c3a4c22d 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -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). diff --git a/studio/frontend/package.json b/studio/frontend/package.json index ffb3c65719..a2eebd5cb5 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -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" diff --git a/studio/frontend/public/blacklogo-c.png b/studio/frontend/public/blacklogo-c.png new file mode 100644 index 0000000000..7ab9959536 Binary files /dev/null and b/studio/frontend/public/blacklogo-c.png differ diff --git a/studio/frontend/public/sticker.png b/studio/frontend/public/sticker.png new file mode 100644 index 0000000000..d04573c080 Binary files /dev/null and b/studio/frontend/public/sticker.png differ diff --git a/studio/frontend/public/whitelogo-c.png b/studio/frontend/public/whitelogo-c.png new file mode 100644 index 0000000000..ee15955092 Binary files /dev/null and b/studio/frontend/public/whitelogo-c.png differ diff --git a/studio/frontend/src/app/router.tsx b/studio/frontend/src/app/router.tsx index d507929758..13ff8a5cbe 100644 --- a/studio/frontend/src/app/router.tsx +++ b/studio/frontend/src/app/router.tsx @@ -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 }); diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index e1bbdc03f7..69aeb11748 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -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 ( - {!hideNavbar && } - - + + {hideNavbar ? ( +
- - +
+ ) : ( + + + + +
+ + + + + + + +
+
+
+ )}
); } diff --git a/studio/frontend/src/app/routes/api-keys.tsx b/studio/frontend/src/app/routes/api-keys.tsx deleted file mode 100644 index 5846690d7b..0000000000 --- a/studio/frontend/src/app/routes/api-keys.tsx +++ /dev/null @@ -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, -}); diff --git a/studio/frontend/src/app/routes/chat.tsx b/studio/frontend/src/app/routes/chat.tsx index e435f090bd..49c05ce219 100644 --- a/studio/frontend/src/app/routes/chat.tsx +++ b/studio/frontend/src/app/routes/chat.tsx @@ -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): 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, }); diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx new file mode 100644 index 0000000000..8264f329a3 --- /dev/null +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -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 ( + +
+ {isNav && active && ( + + )} + + + {label} + +
+ {children} +
+ ); +} + +export function AppSidebar() { + const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle(); + const { pathname, search } = useRouterState({ + select: (s) => ({ + pathname: s.location.pathname, + search: s.location.search as Record, + }), + }); + 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[0]) { + await deleteChatItem(item, activeThreadId, (view) => { + navigate({ + to: "/chat", + search: { new: view.newThreadNonce }, + }); + }); + } + + return ( + <> + + + {/* Expanded: compact logo + close toggle */} +
+ + Unsloth + Unsloth + + {!isMobile && ( + + + + + + Close sidebar + + + )} +
+ + {/* Collapsed: sticker with hover-swap to open toggle */} + {!isMobile && ( +
+ + + + + + Open sidebar + + +
+ )} +
+ + + + + { + if (chatDisabled) return; + setActiveThreadId(null); + navigate({ to: "/chat", search: { new: crypto.randomUUID() } }); + closeMobileIfOpen(); + }} + /> + { + if (chatDisabled) return; + setActiveThreadId(null); + navigate({ to: "/chat", search: { compare: crypto.randomUUID() } }); + closeMobileIfOpen(); + }} + /> + { + if (chatDisabled) return; + useChatSearchStore.getState().open(); + closeMobileIfOpen(); + }} + /> + + +
+ + + + {/* Navigate (no header) */} + + + + { + if (chatOnly) return; + navigate({ to: "/studio" }); + closeMobileIfOpen(); + }} + /> + + { + navigate({ to: "/data-recipes" }); + closeMobileIfOpen(); + }} + /> + + { + if (chatOnly) return; + navigate({ to: "/export" }); + closeMobileIfOpen(); + }} + /> + + +
+ + + {/* Recent Chats */} + {chatItems.length > 0 && ( + + + + + Recent Chats + + + + + + + {chatItems.map((item) => ( + + { + navigate({ + to: "/chat", + search: + item.type === "single" + ? { thread: item.id } + : { compare: item.id }, + }); + closeMobileIfOpen(); + }} + > + {item.title} + + + + ))} + + + + + + )} + + {/* Recent Runs */} + {isStudioRoute && runItems.length > 0 && !chatOnly && ( + + + + + Recent Runs + + + + + + + {runItems.map((run) => { + const isActiveRun = + selectedHistoryRunId === run.id || activeJobId === run.id; + return ( + + { + setSelectedHistoryRunId(run.id); + closeMobileIfOpen(); + }} + > +
+ + + {run.model_name} + + + {formatRelativeShort(run.started_at)} + +
+ + {run.dataset_name} + +
+ +
+ ); + })} +
+
+
+
+
+ )} + + + + + + + + + Unsloth +
+ Unsloth + Studio +
+ +
+
+ + + useSettingsDialogStore.getState().openDialog()} + > + + Settings + ⌘, + + + + + } + onSelect={(e) => { e.preventDefault(); toggleTheme(); }} + > + {isDark ? : } + {isDark ? "Light Mode" : "Dark Mode"} + + { + const tourId = getTourId(pathname); + if (!tourId) return; + window.dispatchEvent( + new CustomEvent(TOUR_OPEN_EVENT, { + detail: { id: tourId }, + }), + ); + }} + > + + Guided Tour + + + + + + + + Learn More + + + + + + What's New + + + + + + + + Feedback + + + +
+
+
+
+ + + + ); +} diff --git a/studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx b/studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx new file mode 100644 index 0000000000..42bd1716a1 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx @@ -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(null); + const [path, setPath] = useState(initialPath); + const [showHidden, setShowHidden] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(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 ( + + + + + Browse for folder + + + + {/* Breadcrumb */} +
+ {crumbs.length === 0 ? ( + (loading…) + ) : ( + crumbs.map((c, i) => ( + + + {i < crumbs.length - 1 && ( + / + )} + + )) + )} +
+ + {/* Suggestions (quick-pick chips) */} + {data?.suggestions && data.suggestions.length > 0 && ( +
+ {data.suggestions.map((s) => ( + + ))} +
+ )} + + {/* Entry list */} +
+ {error && ( +
{error}
+ )} + {!error && loading && ( +
+ + Loading… +
+ )} + {!error && !loading && data && ( + <> + {/* Up row */} + {data.parent !== null && ( + + )} + {data.entries.length === 0 && !(data.model_files_here && data.model_files_here > 0) && ( +
+ (empty directory) +
+ )} + {data.model_files_here !== undefined && data.model_files_here > 0 && ( +
+ {data.model_files_here} model file{data.model_files_here === 1 ? "" : "s"} in this folder. Click "Use this folder" to scan it. +
+ )} + {data.truncated === true && ( +
+ Showing first {data.entries.length} entries. Narrow the path + to see more. +
+ )} + {data.entries.map((e) => ( + + ))} + + )} +
+ + {/* Footer */} + + +
+ + + + +
+
+
+
+ ); +} diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 313a950cc1..8f318a8292 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -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 ( -
- {children} +
+ + {icon} + {children} + + {onToggle && ( + + )}
); } @@ -488,6 +515,9 @@ export function HubModelPicker({ // Delete confirmation dialog state const [deleteTarget, setDeleteTarget] = useState(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(null); const [showFolderInput, setShowFolderInput] = useState(false); const [folderLoading, setFolderLoading] = useState(false); + const [showFolderBrowser, setShowFolderBrowser] = useState(false); + const [recommendedFolders, setRecommendedFolders] = useState([]); 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)) ? ( <> - Downloaded - {cachedGguf.map((c) => ( + } + collapsed={downloadedCollapsed} + onToggle={() => setDownloadedCollapsed((v) => !v)} + >Downloaded + {!downloadedCollapsed && cachedGguf.map((c) => (
))} - {!chatOnly && + {!downloadedCollapsed && !chatOnly && cachedModels.map((c) => (
@@ -980,30 +1038,56 @@ export function HubModelPicker({ {!showHfSection ? ( <> -
- +
+ + Custom Folders - +
+ + +
+
+ +
{/* Folder paths */} - {scanFolders.map((f) => ( + {!customFoldersCollapsed && scanFolders.map((f) => (
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" > - +
))} + {/* 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 ( +
+ {unregistered.map((p) => ( + + ))} +
+ ); + })()} + {/* Add folder input */} - {showFolderInput && ( + {!customFoldersCollapsed && showFolderInput && (
@@ -1042,7 +1149,17 @@ export function HubModelPicker({ /> +
)} - {/* Empty state */} - {scanFolders.length === 0 && customFolderModels.length === 0 && !showFolderInput && ( - - )} + { + 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 (
{ - 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 ? ( <> - Recommended - {visibleRecommendedIds.length === 0 ? ( + } + collapsed={recommendedCollapsed} + onToggle={() => setRecommendedCollapsed((v) => !v)} + >Recommended + {recommendedCollapsed ? null : visibleRecommendedIds.length === 0 ? (
No default models.
@@ -1156,7 +1291,7 @@ export function HubModelPicker({ ); }) )} - {hasMoreRecommended && ( + {!recommendedCollapsed && hasMoreRecommended && ( <>
@@ -1169,7 +1304,7 @@ export function HubModelPicker({ {showHfSection && filteredRecommendedIds.length > 0 ? ( <> - Recommended + }>Recommended {filteredRecommendedIds.map((id) => { const vram = recommendedVramMap.get(id); return ( diff --git a/studio/frontend/src/components/assistant-ui/reasoning.tsx b/studio/frontend/src/components/assistant-ui/reasoning.tsx index 6b2c7a05e7..e4306df4b9 100644 --- a/studio/frontend/src/components/assistant-ui/reasoning.tsx +++ b/studio/frontend/src/components/assistant-ui/reasoning.tsx @@ -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 ( -
- ); -} - -function ReasoningFadeTop({ className, ...props }: ComponentProps<"div">) { - return ( -
- ); -} - function ReasoningTrigger({ active, duration, @@ -206,7 +177,7 @@ function ReasoningTrigger({ className="aui-reasoning-trigger-label-wrapper relative inline-block leading-none" > {active ? ( - Thinking... + Thinking... ) : ( Thought for {duration ?? 0} seconds )} @@ -234,7 +205,7 @@ function ReasoningContent({ - {streaming && } {children} - ); } @@ -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, }; diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 8f41987fbf..77fdf28736 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -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 ( {!hideWelcome && ( 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 &&
} + - {!hideComposer && ( -
- )}
= ({ >
- !thread.isEmpty}> - {!hideComposer && } - + {!hideComposer && ( + !thread.isEmpty}> +
+
+
+
+ +
+

+ LLM's can make mistakes. Double-check all responses. +

+
+
+ + )} ); }; @@ -207,18 +235,13 @@ const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => { alt="Sloth mascot" className="size-20" /> -

+

Chat with your model

-

- Run GGUFs, safetensors, vision and audio models! +

+ Run GGUFs, safetensors, vision and audio models

-
- -
{!hideComposer && }
@@ -243,10 +266,6 @@ const GeneratingSpinner: FC = () => { const ComposerAnimated: FC = () => { return (
-
{ const Composer: FC = () => { return ( - + @@ -457,12 +477,30 @@ const CodeToolsToggle: FC = () => { )} aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"} > - + Code ); }; +const CodeToggleIcon: FC<{ className?: string }> = ({ className }) => { + return ( + + ); +}; + 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 ( - Generating... - ); + return Generating...; }; const AssistantMessage: FC = () => { return (
@@ -701,9 +737,9 @@ const AssistantActionBar: FC = () => { return ( @@ -755,22 +791,22 @@ const UserMessageAudio: FC = () => { const UserMessage: FC = () => { return ( -
-
+
+
-
+
- + ); }; @@ -778,8 +814,8 @@ const UserMessage: FC = () => { const UserActionBar: FC = () => { return ( @@ -805,7 +841,7 @@ const EditComposer: FC = () => { }); return ( - + | 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 ( -
- - -
- ); -} - -function UpdateStudioInstructions({ - className, - defaultShell, - showTitle = true, -}: { - className?: string; - defaultShell: UpdateShell; - showTitle?: boolean; -}): ReactElement { - const [shell, setShell] = useState(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 ( -
-
- {showTitle ? ( -

- Update Unsloth Studio -

- ) : null} -
- - / - -
-
- - - {getStudioUpdateInstructionLine(shell)} - - - -

- If that fails or unsloth studio update is unavailable, run: -

- - - - - -

- Restart Studio after updating for changes to take effect. -

-
- ); -} - -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 ( +
); - }; - + } return ( - <> -
-
- {/* Left: logo */} - - Unsloth - Unsloth - - BETA - - - - {/* Center: pill nav */} - - - {/* Right: docs/tour desktop — one wrapper per control so flex gap is even (HoverCard roots can confuse flex spacing). */} -
-
- -
- -
- - - API Keys - -
- {tourId ? ( -
- -
- ) : null} -
- - - - - - - - -
-
- -
-
- - {/* Right: mobile */} -
- {tourId ? ( - - ) : null} - { - setMobileOpen(open); - if (!open) setMobileUpdateOpen(false); - }} - > - - - - - - Navigate - -
- {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 ( - - - {item.label} - - ); - } - return ( - 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", - )} - > - - {item.label} - - ); - })} - 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", - )} - > - - API Keys - - setMobileOpen(false)} - > - - Learn more (Docs) - - {tourId ? ( - - ) : null} - - - - - - - - - -
- Theme - -
-
-
-
-
+
+
+
- - - ); } diff --git a/studio/frontend/src/components/shutdown-dialog.tsx b/studio/frontend/src/components/shutdown-dialog.tsx index dea738bf6b..dfeafb33eb 100644 --- a/studio/frontend/src/components/shutdown-dialog.tsx +++ b/studio/frontend/src/components/shutdown-dialog.tsx @@ -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 = `

Unsloth Studio has stopped.

diff --git a/studio/frontend/src/components/ui/animated-theme-toggler.tsx b/studio/frontend/src/components/ui/animated-theme-toggler.tsx index 24f3c68ec9..d83e278401 100644 --- a/studio/frontend/src/components/ui/animated-theme-toggler.tsx +++ b/studio/frontend/src/components/ui/animated-theme-toggler.tsx @@ -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(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() diff --git a/studio/frontend/src/components/ui/command.tsx b/studio/frontend/src/components/ui/command.tsx index 6181d55ea1..a2340b62ed 100644 --- a/studio/frontend/src/components/ui/command.tsx +++ b/studio/frontend/src/components/ui/command.tsx @@ -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 & { title?: string; description?: string; className?: string; + overlayClassName?: string; showCloseButton?: boolean; }) { return ( @@ -55,9 +57,10 @@ function CommandDialog({ {children} diff --git a/studio/frontend/src/components/ui/sidebar.tsx b/studio/frontend/src/components/ui/sidebar.tsx index 898972f1ee..6d9016f8e2 100644 --- a/studio/frontend/src/components/ui/sidebar.tsx +++ b/studio/frontend/src/components/ui/sidebar.tsx @@ -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(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( () => ({ 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} > @@ -210,7 +243,11 @@ function Sidebar({ return (
@@ -310,7 +373,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
) { 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">) {
    ) @@ -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: { diff --git a/studio/frontend/src/features/auth/api-keys-page.tsx b/studio/frontend/src/features/auth/api-keys-page.tsx deleted file mode 100644 index 2065384a7d..0000000000 --- a/studio/frontend/src/features/auth/api-keys-page.tsx +++ /dev/null @@ -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 { - 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 { - 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 | 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 ( - - ); -} - -function RevealKeyDialog({ - open, - rawKey, - onClose, -}: { - open: boolean; - rawKey: string; - onClose: () => void; -}) { - return ( - !o && onClose()}> - - - API Key Created - - Copy this key now. It will not be shown again. - - -
    - - {rawKey} - - -
    -
    - -

    - Store this key securely. You will not be able to see it again after closing this 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 ( -
    -
    - - setName(e.target.value)} - required - /> -
    -
    - - setExpiresInDays(e.target.value)} - /> -
    - -
    - ); -} - -function KeysTable({ - keys, - onRevoke, -}: { - keys: ApiKey[]; - onRevoke: (id: number) => void; -}) { - if (keys.length === 0) { - return ( -

    - No API keys yet. Create one above. -

    - ); - } - - return ( -
    - - - - - - - - - - - - {keys.map((k) => ( - - - - - - - - - ))} - -
    NameKeyCreatedLast usedExpires -
    {k.name} - - sk-unsloth-{k.key_prefix}... - - {formatDate(k.created_at)}{formatDate(k.last_used_at)}{formatDate(k.expires_at)} - {k.is_active ? ( - - ) : ( - Revoked - )} -
    -
    - ); -} - -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 ( -
    -

    Usage examples

    -
    -
    -

    curl

    -
    -            {curlExample}
    -          
    -
    -
    -

    Python (OpenAI SDK)

    -
    -            {pythonExample}
    -          
    -
    -
    -

    With tools (web search + code execution)

    -
    -            {toolsExample}
    -          
    -
    -
    -
    - ); -} - -// --------------------------------------------------------------------------- -// Page -// --------------------------------------------------------------------------- - -export function ApiKeysPage() { - const [keys, setKeys] = useState([]); - const [revealedKey, setRevealedKey] = useState(null); - const [error, setError] = useState(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 ( - -
    -
    -
    - -
    -
    -

    API Keys

    -

    - Create keys to access Unsloth Studio programmatically via the OpenAI-compatible API. -

    -
    -
    - - {error && ( -
    - - {error} -
    - )} - - - - -
    - - setRevealedKey(null)} - /> -
    - ); -} diff --git a/studio/frontend/src/features/auth/index.ts b/studio/frontend/src/features/auth/index.ts index 9962f6d431..75db92432c 100644 --- a/studio/frontend/src/features/auth/index.ts +++ b/studio/frontend/src/features/auth/index.ts @@ -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"; diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index ddc0e9d39e..15ac8748f0 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -247,6 +247,48 @@ export async function removeScanFolder(id: number): Promise { await parseJsonOrThrow(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 { + 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 { + 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(response); +} + export async function listGgufVariants( repoId: string, hfToken?: string, diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index c3153cdec7..32c3b6cd16 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -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} > -
    +
    @@ -223,7 +203,7 @@ const LoraCompareContent = memo(function LoraCompareContent({ Base Model
    -
    +
    - +
    + +
    @@ -241,7 +223,7 @@ const LoraCompareContent = memo(function LoraCompareContent({ Fine-tuned
-
+
- +
+ +
-
+
@@ -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" >
-
- - Model 1 - +
-
+
- +
+ +
-
-
- - Model 2 - +
+
-
+
- +
+ +
-
+
- - - Chat sidebar - Chat threads and actions - -
{children}
-
- - ); - } - - return ( -
- -
- ); -} - -function TopBarActions({ - onNewThread, - onNewCompare, - showCompare, -}: { - onNewThread: () => void; - onNewCompare: () => void; - showCompare: boolean; -}) { - const { state } = useSidebar(); - if (state !== "collapsed") { - return null; - } - return ( - <> - - - - - New Chat - - {showCompare ? ( - - - - - Compare - - ) : 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(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( - null, - ); + const viewBeforeCompareRef = useRef(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(() => { + 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 | undefined; - const usage = saved?.contextUsage as - | typeof store.contextUsage - | undefined; - if (usage) store.setContextUsage(usage); + const metadata = msg?.metadata as Record | undefined; + const usage = metadata?.contextUsage as ReturnType["contextUsage"]; + if (usage) useChatRuntimeStore.getState().setContextUsage(usage); }); } - }, [viewBeforeCompare]); - - const handleThreadSelect = useCallback((nextView: ChatView) => { - setView(nextView); - }, []); + }, [navigate]); const models = useMemo( () => @@ -727,7 +605,7 @@ export function ChatPage(): ReactElement { ); }) .catch(() => {}); - }, []); + }, [navigate]); const loraModels = useMemo(() => { 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 ( -
+
- - - - - -
-
-
- - +
+
+
+ {view.mode !== "compare" && ( - {loadingModel && loadToastDismissed ? ( - - ) : null} -
- {modelsError && ( -
- {modelsError} -
)} -
- {view.mode === "single" && ggufContextLength && contextUsage ? ( - ) : null} -
- - {view.mode === "single" ? ( - - ) : ( - + {modelsError && ( +
+ {modelsError} +
)} +
+ {view.mode === "single" && ggufContextLength && contextUsage ? ( + + ) : null} +
- { - 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.", - }); - } - }} - /> - + {view.mode === "single" ? ( + + ) : ( + + )} +
+ + { + 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.", + }); + } + }} + />
); } diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index bcfd585214..2e03591a8e 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -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 = ( <> -
+
+
-
+
{/* mt-4 matches the Playground sidebar gap (SidebarHeader py-3 + SidebarGroup pt-1) */}
@@ -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} />
@@ -1174,27 +1170,9 @@ export function ChatSettingsPanel({
- -
-
-
-
Auto title
-
- Generate short title after reply. -
-
- -
- -
-
-
+
{ @@ -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} />
@@ -1268,9 +1246,9 @@ export function ChatSettingsPanel({ return ( ); } @@ -1348,29 +1326,6 @@ function AutoHealToolCallsToggle() { ); } -function HfTokenField() { - const hfToken = useChatRuntimeStore((s) => s.hfToken); - const setHfToken = useChatRuntimeStore((s) => s.setHfToken); - - return ( -
-
-
Hugging Face Token
-
- For downloading gated or private models. -
-
- setHfToken(e.target.value)} - /> -
- ); -} - function ChatTemplateSection({ onReloadModel, }: { @@ -1391,7 +1346,7 @@ function ChatTemplateSection({