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/studio/backend/assets/configs/inference_defaults.json b/studio/backend/assets/configs/inference_defaults.json index 1b4b5381e8..1b10b557e4 100644 --- a/studio/backend/assets/configs/inference_defaults.json +++ b/studio/backend/assets/configs/inference_defaults.json @@ -1,6 +1,14 @@ { "_comment": "Per-model-family inference parameter defaults. Sources: (1) Ollama params blobs, (2) Existing Unsloth Studio YAML configs. Patterns ordered longest-match-first.", "families": { + "qwen3.6": { + "temperature": 0.7, + "top_p": 0.8, + "top_k": 20, + "min_p": 0.0, + "repetition_penalty": 1.0, + "presence_penalty": 1.5 + }, "qwen3.5": { "temperature": 0.7, "top_p": 0.8, @@ -369,7 +377,7 @@ } }, "patterns": [ - "qwen3.5", + "qwen3.6", "qwen3.5", "qwen3-coder", "qwen3-next", "qwen3-vl", "qwen3", "qwen2.5-coder", "qwen2.5-vl", "qwen2.5-omni", "qwen2.5-math", "qwen2.5", "qwen2-vl", "qwen2", diff --git a/studio/backend/core/inference/defaults.py b/studio/backend/core/inference/defaults.py index f3026dddaf..53718c1294 100644 --- a/studio/backend/core/inference/defaults.py +++ b/studio/backend/core/inference/defaults.py @@ -10,6 +10,7 @@ DEFAULT_MODELS_GGUF = [ "unsloth/gemma-4-E4B-it-GGUF", "unsloth/gemma-4-31B-it-GGUF", "unsloth/gemma-4-26B-A4B-it-GGUF", + "unsloth/Qwen3.6-35B-A3B-GGUF", "unsloth/Qwen3.5-4B-GGUF", "unsloth/Qwen3.5-9B-GGUF", "unsloth/Qwen3.5-35B-A3B-GGUF", @@ -27,6 +28,7 @@ DEFAULT_MODELS_STANDARD = [ "unsloth/gemma-4-E4B-it-GGUF", "unsloth/gemma-4-31B-it-GGUF", "unsloth/gemma-4-26B-A4B-it-GGUF", + "unsloth/Qwen3.6-35B-A3B-GGUF", "unsloth/Qwen3.5-4B-GGUF", "unsloth/Qwen3.5-9B-GGUF", "unsloth/Qwen3.5-35B-A3B-GGUF", diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index b53fc513de..2e26995309 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -1514,12 +1514,12 @@ class LlamaCppBackend: ) # For reasoning models, set default thinking mode. - # Qwen3.5 models below 9B (0.8B, 2B, 4B) disable thinking by default. + # Qwen3.5/3.6 models below 9B (0.8B, 2B, 4B) disable thinking by default. # Only 9B and larger enable thinking. if self._supports_reasoning: thinking_default = True mid = (model_identifier or "").lower() - if "qwen3.5" in mid: + if "qwen3.5" in mid or "qwen3.6" in mid: size_val = _extract_model_size_b(mid) if size_val is not None and size_val < 9: thinking_default = False @@ -1703,6 +1703,28 @@ class LlamaCppBackend: # Wait for llama-server to become healthy if not self._wait_for_health(timeout = 600.0): self._kill_process() + _gguf = gguf_path or "" + _is_ollama = ( + ".studio_links" in _gguf + or os.sep + "ollama_links" + os.sep in _gguf + or os.sep + ".cache" + os.sep + "ollama" + os.sep in _gguf + or (self._model_identifier or "").startswith("ollama/") + ) + # Only show the Ollama-specific message when the server + # output indicates a GGUF compatibility issue, not for + # unrelated failures like OOM or missing binaries. + if _is_ollama: + _output = "\n".join(self._stdout_lines[-50:]).lower() + _gguf_compat_hints = ( + "key not found", + "unknown model architecture", + "failed to load model", + ) + if any(h in _output for h in _gguf_compat_hints): + raise RuntimeError( + "Some Ollama models do not work with llama.cpp. " + "Try a different model, or use this model directly through Ollama instead." + ) raise RuntimeError( "llama-server failed to start. " "Check that the GGUF file is valid and you have enough memory." diff --git a/studio/backend/main.py b/studio/backend/main.py index 8a40791c06..d146a8ef12 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -27,6 +27,7 @@ import mimetypes import shutil import warnings from contextlib import asynccontextmanager +from importlib.metadata import PackageNotFoundError, version as package_version # Fix broken Windows registry MIME types. Some Windows installs map .js to # "text/plain" in the registry (HKCR\.js\Content Type). Python's mimetypes @@ -78,6 +79,27 @@ import utils.hardware.hardware as _hw_module from utils.cache_cleanup import clear_unsloth_compiled_cache +def get_unsloth_version() -> str: + try: + return package_version("unsloth") + except PackageNotFoundError: + pass + + version_file = ( + _Path(__file__).resolve().parents[2] / "unsloth" / "models" / "_utils.py" + ) + try: + for line in version_file.read_text(encoding = "utf-8").splitlines(): + if line.startswith("__version__ = "): + return line.split("=", 1)[1].strip().strip('"').strip("'") + except OSError: + pass + return "dev" + + +UNSLOTH_VERSION = get_unsloth_version() + + @asynccontextmanager async def lifespan(app: FastAPI): """Startup: detect hardware, seed default admin if needed. Shutdown: clean up compiled cache.""" @@ -140,7 +162,7 @@ async def lifespan(app: FastAPI): # Create FastAPI app app = FastAPI( title = "Unsloth UI Backend", - version = "1.0.0", + version = UNSLOTH_VERSION, description = "Backend API for Unsloth UI - Training and Model Management", lifespan = lifespan, ) @@ -198,6 +220,7 @@ async def health_check(): "status": "healthy", "timestamp": datetime.now().isoformat(), "service": "Unsloth UI Backend", + "version": UNSLOTH_VERSION, "device_type": device_type, "chat_only": _hw_module.CHAT_ONLY, } 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 9e7168eed6..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 @@ -411,6 +414,267 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: return found +def _ollama_links_dir(ollama_dir: Path) -> Optional[Path]: + """Return a writable directory for Ollama ``.gguf`` symlinks. + + Prefers ``/.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( @@ -493,11 +757,27 @@ async def list_local_models( for folder in custom_folders: folder_path = Path(folder["path"]) try: - custom_models = ( - _scan_models_dir(folder_path, limit = _MAX_MODELS_PER_FOLDER) - + _scan_hf_cache(folder_path) - + _scan_lmstudio_dir(folder_path) - )[:_MAX_MODELS_PER_FOLDER] + # Ollama scanner creates .studio_links/ with .gguf symlinks. + # Filter those from the generic scanners to avoid duplicates + # and leaking internal paths into the UI. + _generic = [ + m + for m in ( + _scan_models_dir(folder_path, limit = _MAX_MODELS_PER_FOLDER) + + _scan_hf_cache(folder_path) + + _scan_lmstudio_dir(folder_path) + ) + if not any( + p in (".studio_links", "ollama_links") + for p in Path(m.path).parts + ) + ] + custom_models = _generic + if len(custom_models) < _MAX_MODELS_PER_FOLDER: + custom_models += _scan_ollama_dir( + folder_path, + limit = _MAX_MODELS_PER_FOLDER - len(custom_models), + ) except OSError as e: logger.warning("Skipping unreadable scan folder %s: %s", folder_path, e) continue @@ -575,6 +855,57 @@ async def remove_scan_folder_endpoint( return {"ok": True} +@router.get("/recommended-folders") +async def get_recommended_folders( + current_subject: str = Depends(get_current_subject), +): + """Return well-known model directories that exist on this machine. + + Lightweight alternative to ``browse-folders`` for showing quick-pick + chips without the overhead of enumerating a directory tree. Returns + paths that actually exist on disk (HF cache, LM Studio, Ollama, + ``~/models``, etc.) so the frontend can offer them as one-click + "Recommended" shortcuts in the Custom Folders section. + """ + from utils.paths.storage_roots import lmstudio_model_dirs + + folders: list[str] = [] + seen: set[str] = set() + + def _add(p: Optional[Path]) -> None: + if p is None: + return + try: + resolved = str(p.resolve()) + except OSError: + return + if resolved in seen: + return + if Path(resolved).is_dir() and os.access(resolved, os.R_OK | os.X_OK): + seen.add(resolved) + folders.append(resolved) + + # LM Studio model directories + try: + for p in lmstudio_model_dirs(): + _add(p) + except Exception as e: + logger.warning("Failed to scan for LM Studio model directories: %s", e) + + # Ollama model directories + ollama_env = os.environ.get("OLLAMA_MODELS") + if ollama_env: + _add(Path(ollama_env).expanduser()) + for candidate in ( + Path.home() / ".ollama" / "models", + Path("/usr/share/ollama/.ollama/models"), + Path("/var/lib/ollama/.ollama/models"), + ): + _add(candidate) + + return {"folders": folders} + + # Heuristic ceiling on how many children to stat when checking whether a # directory "looks like" it contains models. Keeps the browser snappy # even when a directory has thousands of unrelated entries. diff --git a/studio/backend/utils/datasets/llm_assist.py b/studio/backend/utils/datasets/llm_assist.py index fdc4f374ab..4c66d2ebf6 100644 --- a/studio/backend/utils/datasets/llm_assist.py +++ b/studio/backend/utils/datasets/llm_assist.py @@ -26,7 +26,7 @@ from loggers import get_logger logger = get_logger(__name__) -DEFAULT_HELPER_MODEL_REPO = "unsloth/Qwen3.5-4B-GGUF" +DEFAULT_HELPER_MODEL_REPO = "unsloth/gemma-4-E2B-it-GGUF" DEFAULT_HELPER_MODEL_VARIANT = "UD-Q4_K_XL" README_MAX_CHARS = 1500 diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index 44754520e3..a2d48cf009 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -959,6 +959,20 @@ def detect_mmproj_file(path: str, search_root: Optional[str] = None) -> Optional scan_order.append(resolved) _add(start_dir) + + # When ``path`` is a symlink (e.g. Ollama's ``.studio_links/...gguf`` + # -> ``blobs/sha256-...``), the symlink's parent directory rarely + # contains the mmproj sibling; the real mmproj file lives next to + # the symlink target. Add the target's parent to the scan so vision + # GGUFs that are surfaced via symlinks are still recognised as + # vision models. + try: + if p.is_symlink() and p.is_file(): + target_parent = p.resolve().parent + if target_parent.is_dir(): + _add(target_parent) + except OSError: + pass if search_root is not None: try: root_resolved = Path(search_root).resolve() @@ -1006,7 +1020,10 @@ def detect_gguf_model(path: str) -> Optional[str]: if p.suffix.lower() == ".gguf" and p.is_file(): if _is_mmproj(p.name): return None - return str(p.resolve()) + # Use absolute (not resolve) to preserve symlink names -- e.g. + # Ollama .studio_links/model.gguf -> blobs/sha256-... should + # keep the readable symlink name, not the opaque blob hash. + return str(p.absolute()) # Case 2: directory containing .gguf files (skip mmproj) if p.is_dir(): 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/app/routes/onboarding.tsx b/studio/frontend/src/app/routes/onboarding.tsx index dcc3593b1a..8d1cd6ff5f 100644 --- a/studio/frontend/src/app/routes/onboarding.tsx +++ b/studio/frontend/src/app/routes/onboarding.tsx @@ -6,6 +6,8 @@ import { lazy } from "react"; import { requireAuth } from "../auth-guards"; import { Route as rootRoute } from "./__root"; +export type OnboardingSearch = { redirectTo?: string }; + const WizardLayout = lazy(() => import("@/features/onboarding/components/wizard-layout").then((m) => ({ default: m.WizardLayout, @@ -16,5 +18,8 @@ export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/onboarding", beforeLoad: () => requireAuth(), + validateSearch: (search: Record): OnboardingSearch => ({ + redirectTo: typeof search.redirectTo === "string" ? search.redirectTo : undefined, + }), component: WizardLayout, }); diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx new file mode 100644 index 0000000000..329175fa9c --- /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 + Train +
+ +
+
+ + + 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/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 2f661c2e72..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"; @@ -49,7 +50,7 @@ import { checkVramFit, estimateLoadingVram } from "@/lib/vram"; import { Add01Icon, Cancel01Icon, Folder02Icon, Search01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { FolderBrowser } from "./folder-browser"; -import { Trash2Icon } from "lucide-react"; +import { ChevronDownIcon, ChevronRightIcon, DownloadIcon, StarIcon, Trash2Icon } from "lucide-react"; import { type ReactNode, useCallback, @@ -73,10 +74,35 @@ function normalizeForSearch(s: string): string { return s.toLowerCase().replace(/[\s\-_\.]/g, ""); } -function ListLabel({ children }: { children: ReactNode }) { +function ListLabel({ + children, + icon, + collapsed, + onToggle, +}: { + children: ReactNode; + icon?: ReactNode; + collapsed?: boolean; + onToggle?: () => void; +}) { return ( -
- {children} +
+ + {icon} + {children} + + {onToggle && ( + + )}
); } @@ -489,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. @@ -514,6 +543,7 @@ export function HubModelPicker({ const [showFolderInput, setShowFolderInput] = useState(false); const [folderLoading, setFolderLoading] = useState(false); const [showFolderBrowser, setShowFolderBrowser] = useState(false); + const [recommendedFolders, setRecommendedFolders] = useState([]); const refreshLocalModelsList = useCallback(() => { listLocalModels() @@ -616,6 +646,9 @@ export function HubModelPicker({ // Always refresh LM Studio + custom folder models (not gated by alreadyCached) refreshLocalModelsList(); refreshScanFolders(); + listRecommendedFolders() + .then(setRecommendedFolders) + .catch(() => {}); // Always refetch cached GGUF/model lists. The module-level caches give // an instant render with stale data (no spinner flash), but newly @@ -893,8 +926,12 @@ export function HubModelPicker({ (cachedGguf.length > 0 || (!chatOnly && cachedModels.length > 0)) ? ( <> - Downloaded - {cachedGguf.map((c) => ( + } + collapsed={downloadedCollapsed} + onToggle={() => setDownloadedCollapsed((v) => !v)} + >Downloaded + {!downloadedCollapsed && cachedGguf.map((c) => (
))} - {!chatOnly && + {!downloadedCollapsed && !chatOnly && cachedModels.map((c) => (
@@ -1001,20 +1038,12 @@ export function HubModelPicker({ {!showHfSection ? ( <> -
- +
+ + Custom Folders
- + +
+
+
{/* Folder paths */} - {scanFolders.map((f) => ( + {!customFoldersCollapsed && scanFolders.map((f) => (
))} + {/* 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 && (
@@ -1114,11 +1188,15 @@ export function HubModelPicker({ {/* Models from custom folders */} - {customFolderModels.map((m) => { + {!customFoldersCollapsed && customFolderModels.map((m) => { + const isGgufFile = m.path.toLowerCase().endsWith(".gguf"); const isGguf = + isGgufFile || isGgufRepo(m.id) || - isGgufRepo(m.display_name) || - m.path.toLowerCase().endsWith(".gguf"); + isGgufRepo(m.display_name); + // Single .gguf files (e.g. Ollama blobs) load directly; + // GGUF repos/directories expand to pick a variant. + const isDirectGguf = isGgufFile; return (
{ - if (isGguf) { + if (isDirectGguf) { + onSelect(m.id, { + source: "local", + isLora: false, + isDownloaded: true, + }); + } else if (isGguf) { setExpandedGguf((prev) => prev === m.id ? null : m.id, ); @@ -1158,8 +1242,12 @@ export function HubModelPicker({ {!showHfSection && cachedReady ? ( <> - Recommended - {visibleRecommendedIds.length === 0 ? ( + } + collapsed={recommendedCollapsed} + onToggle={() => setRecommendedCollapsed((v) => !v)} + >Recommended + {recommendedCollapsed ? null : visibleRecommendedIds.length === 0 ? (
No default models.
@@ -1203,7 +1291,7 @@ export function HubModelPicker({ ); }) )} - {hasMoreRecommended && ( + {!recommendedCollapsed && hasMoreRecommended && ( <>
@@ -1216,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/auth/session.ts b/studio/frontend/src/features/auth/session.ts index 3d3502e073..6012174077 100644 --- a/studio/frontend/src/features/auth/session.ts +++ b/studio/frontend/src/features/auth/session.ts @@ -8,7 +8,7 @@ export const AUTH_REFRESH_TOKEN_KEY = "unsloth_auth_refresh_token"; export const ONBOARDING_DONE_KEY = "unsloth_onboarding_done"; export const AUTH_MUST_CHANGE_PASSWORD_KEY = "unsloth_auth_must_change_password"; -type PostAuthRoute = "/onboarding" | "/studio" | "/change-password" | "/chat"; +type PostAuthRoute = "/change-password" | "/chat"; function canUseStorage(): boolean { return typeof window !== "undefined"; @@ -80,5 +80,5 @@ export function resetOnboardingDone(): void { export function getPostAuthRoute(): PostAuthRoute { if (mustChangePassword()) return "/change-password"; if (usePlatformStore.getState().isChatOnly()) return "/chat"; - return isOnboardingDone() ? "/studio" : "/onboarding"; + return "/chat"; } diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 60904f3cb5..53a935cf45 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -455,13 +455,13 @@ async function autoLoadSmallestModel(): Promise<{ // No cached models found — try downloading a small default GGUF toast("Downloading a small model…", { id: toastId, - description: "No downloaded models found. Fetching Qwen3.5-4B (UD-Q4_K_XL).", + description: "No downloaded models found. Fetching Gemma-4-E2B-it (UD-Q4_K_XL).", duration: 30000, }); try { if ( !(await canAutoLoad({ - model_path: "unsloth/Qwen3.5-4B-GGUF", + model_path: "unsloth/gemma-4-E2B-it-GGUF", max_seq_length: 0, is_lora: false, gguf_variant: "UD-Q4_K_XL", @@ -471,7 +471,7 @@ async function autoLoadSmallestModel(): Promise<{ return { loaded: false, blockedByTrustRemoteCode }; } const loadResp = await loadModel({ - model_path: "unsloth/Qwen3.5-4B-GGUF", + model_path: "unsloth/gemma-4-E2B-it-GGUF", hf_token: hfToken, max_seq_length: 0, load_in_4bit: true, @@ -479,20 +479,20 @@ async function autoLoadSmallestModel(): Promise<{ gguf_variant: "UD-Q4_K_XL", trust_remote_code: trustRemoteCode, }); - useChatRuntimeStore.getState().setCheckpoint("unsloth/Qwen3.5-4B-GGUF", "UD-Q4_K_XL"); + useChatRuntimeStore.getState().setCheckpoint("unsloth/gemma-4-E2B-it-GGUF", "UD-Q4_K_XL"); const store = useChatRuntimeStore.getState(); store.setModelRequiresTrustRemoteCode( loadResp.requires_trust_remote_code ?? false, ); store.setParams({ ...store.params, maxTokens: loadResp.context_length ?? 131072 }); const defaultModel: ChatModelSummary = { - id: "unsloth/Qwen3.5-4B-GGUF", - name: loadResp.display_name ?? "Qwen3.5-4B-GGUF", + id: "unsloth/gemma-4-E2B-it-GGUF", + name: loadResp.display_name ?? "gemma-4-E2B-it-GGUF", isVision: loadResp.is_vision ?? false, isLora: false, isGguf: true, }; - if (!store.models.some((m) => m.id === "unsloth/Qwen3.5-4B-GGUF")) { + if (!store.models.some((m) => m.id === "unsloth/gemma-4-E2B-it-GGUF")) { store.setModels([...store.models, defaultModel]); } useChatRuntimeStore.setState({ @@ -509,7 +509,7 @@ async function autoLoadSmallestModel(): Promise<{ defaultChatTemplate: loadResp.chat_template ?? null, chatTemplateOverride: null, }); - toast.success("Loaded Qwen3.5-4B (UD-Q4_K_XL)", { id: toastId }); + toast.success("Loaded Gemma-4-E2B-it (UD-Q4_K_XL)", { id: toastId }); return { loaded: true, blockedByTrustRemoteCode: false }; } catch { toast.dismiss(toastId); diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index 9aacfc5af4..15ac8748f0 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -262,6 +262,12 @@ export interface BrowseFoldersResponse { 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, 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({