Merge branch 'main' into pip
This commit is contained in:
commit
e9a2b5c010
84 changed files with 5974 additions and 2400 deletions
193
install.ps1
193
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.
|
||||
|
|
|
|||
10
install.sh
10
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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@
|
|||
Model Management API routes
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from typing import List, Optional
|
||||
|
|
@ -411,6 +414,267 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
|
|||
return found
|
||||
|
||||
|
||||
def _ollama_links_dir(ollama_dir: Path) -> Optional[Path]:
|
||||
"""Return a writable directory for Ollama ``.gguf`` symlinks.
|
||||
|
||||
Prefers ``<ollama_dir>/.studio_links/`` so the links sit next to the
|
||||
blobs they point at. Falls back to a per-ollama-dir namespace under
|
||||
Studio's own cache when the models directory is read-only (common
|
||||
for system installs under ``/usr/share/ollama`` or ``/var/lib/ollama``)
|
||||
so we still surface Ollama models in those environments.
|
||||
"""
|
||||
from utils.paths.storage_roots import cache_root
|
||||
|
||||
primary = ollama_dir / ".studio_links"
|
||||
try:
|
||||
primary.mkdir(exist_ok = True)
|
||||
return primary
|
||||
except OSError as e:
|
||||
logger.debug(
|
||||
"Ollama dir %s not writable for .studio_links (%s); "
|
||||
"falling back to Studio cache",
|
||||
ollama_dir,
|
||||
e,
|
||||
)
|
||||
|
||||
# Fallback: namespace by a hash of the ollama_dir so two different
|
||||
# Ollama roots don't collide. This is a cache path, not a security
|
||||
# boundary.
|
||||
try:
|
||||
digest = hashlib.sha256(str(ollama_dir.resolve()).encode()).hexdigest()[:12]
|
||||
except OSError:
|
||||
digest = "default"
|
||||
fallback = cache_root() / "ollama_links" / digest
|
||||
try:
|
||||
fallback.mkdir(parents = True, exist_ok = True)
|
||||
return fallback
|
||||
except OSError as e:
|
||||
logger.warning(
|
||||
"Could not create Ollama symlink cache at %s: %s",
|
||||
fallback,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _scan_ollama_dir(
|
||||
ollama_dir: Path, limit: Optional[int] = None
|
||||
) -> List[LocalModelInfo]:
|
||||
"""Scan an Ollama models directory for downloaded models.
|
||||
|
||||
Ollama stores models in a content-addressable layout::
|
||||
|
||||
<ollama_dir>/manifests/<host>/<namespace>/<model>/<tag>
|
||||
<ollama_dir>/blobs/sha256-...
|
||||
|
||||
The default host is ``registry.ollama.ai`` with namespace
|
||||
``library`` (official models), but users can pull from custom
|
||||
namespaces (``mradermacher/llama3``) or entirely different hosts
|
||||
(``hf.co/org/repo:tag``). We iterate all manifest files via
|
||||
``rglob`` so every layout depth is discovered.
|
||||
|
||||
Each manifest is JSON with a ``layers`` array. The layer with
|
||||
``mediaType == "application/vnd.ollama.image.model"`` contains the
|
||||
GGUF weights. Vision models also have a projector layer
|
||||
(``application/vnd.ollama.image.projector``). We read the config
|
||||
layer to extract family/size info.
|
||||
|
||||
Since Ollama blobs lack a ``.gguf`` extension (which the GGUF
|
||||
loading pipeline requires), we create ``.gguf``-named links
|
||||
pointing at the blobs so the existing ``detect_gguf_model`` and
|
||||
``llama-server -m`` paths work unchanged. Each model gets its
|
||||
own subdirectory under the links dir (keyed by a short hash of
|
||||
the manifest path) so that ``detect_mmproj_file`` only sees the
|
||||
projector for *that* model. Links are created as symlinks when
|
||||
possible, falling back to hardlinks (Windows without Developer
|
||||
Mode) as a last resort. The link dir lives under
|
||||
``<ollama_dir>/.studio_links/`` when writable, otherwise under
|
||||
Studio's own cache directory.
|
||||
"""
|
||||
manifests_root = ollama_dir / "manifests"
|
||||
if not manifests_root.is_dir():
|
||||
return []
|
||||
|
||||
found: List[LocalModelInfo] = []
|
||||
blobs_dir = ollama_dir / "blobs"
|
||||
links_root = _ollama_links_dir(ollama_dir)
|
||||
if links_root is None:
|
||||
logger.warning(
|
||||
"Skipping Ollama scan for %s: no writable location for .gguf links",
|
||||
ollama_dir,
|
||||
)
|
||||
return []
|
||||
|
||||
def _make_link(link_dir: Path, link_name: str, target: Path) -> Optional[str]:
|
||||
"""Create a .gguf-named link to an Ollama blob.
|
||||
|
||||
Tries symlink first, then hardlink (works on Windows without
|
||||
Developer Mode when target is on the same filesystem). Skips
|
||||
the model if neither works -- a full file copy of a multi-GB
|
||||
GGUF inside a synchronous API request would block the backend.
|
||||
|
||||
Idempotent: skips recreation when a valid link already exists.
|
||||
"""
|
||||
link_dir.mkdir(parents = True, exist_ok = True)
|
||||
link_path = link_dir / link_name
|
||||
resolved = target.resolve()
|
||||
|
||||
# Skip if the link already points at the exact same blob.
|
||||
# Only use samefile -- size-based checks can reuse stale links
|
||||
# after `ollama pull` updates a tag to a same-sized blob.
|
||||
try:
|
||||
if link_path.exists() and os.path.samefile(str(link_path), str(resolved)):
|
||||
return str(link_path)
|
||||
except OSError as e:
|
||||
logger.debug("Error checking existing link %s: %s", link_path, e)
|
||||
|
||||
tmp_path = link_dir / f".{link_name}.tmp-{uuid.uuid4().hex[:8]}"
|
||||
try:
|
||||
if tmp_path.is_symlink() or tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
try:
|
||||
tmp_path.symlink_to(resolved)
|
||||
except OSError:
|
||||
try:
|
||||
os.link(str(resolved), str(tmp_path))
|
||||
except OSError:
|
||||
logger.warning(
|
||||
"Could not create link for Ollama blob %s "
|
||||
"(symlinks and hardlinks both failed). "
|
||||
"Skipping model to avoid blocking the API.",
|
||||
target,
|
||||
)
|
||||
return None
|
||||
os.replace(str(tmp_path), str(link_path))
|
||||
return str(link_path)
|
||||
except OSError as e:
|
||||
logger.debug("Could not create Ollama link %s: %s", link_path, e)
|
||||
try:
|
||||
if tmp_path.is_symlink() or tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
except OSError as cleanup_err:
|
||||
logger.debug(
|
||||
"Could not clean up tmp path %s: %s", tmp_path, cleanup_err
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
for tag_file in manifests_root.rglob("*"):
|
||||
if not tag_file.is_file():
|
||||
continue
|
||||
|
||||
rel = tag_file.relative_to(manifests_root)
|
||||
parts = rel.parts
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
|
||||
host = parts[0]
|
||||
repo_parts = list(parts[1:-1])
|
||||
tag = parts[-1]
|
||||
|
||||
if (
|
||||
host == "registry.ollama.ai"
|
||||
and repo_parts
|
||||
and repo_parts[0] == "library"
|
||||
):
|
||||
repo_name = "/".join(repo_parts[1:])
|
||||
elif host == "registry.ollama.ai":
|
||||
repo_name = "/".join(repo_parts)
|
||||
else:
|
||||
repo_name = "/".join([host] + repo_parts)
|
||||
|
||||
if not repo_name:
|
||||
continue
|
||||
|
||||
display = f"{repo_name}:{tag}"
|
||||
|
||||
manifest_key = rel.as_posix()
|
||||
stem_hash = hashlib.sha256(manifest_key.encode()).hexdigest()[:10]
|
||||
|
||||
try:
|
||||
manifest = json.loads(tag_file.read_text())
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.debug(
|
||||
"Skipping unreadable/invalid Ollama manifest %s: %s",
|
||||
tag_file,
|
||||
e,
|
||||
)
|
||||
continue
|
||||
|
||||
config_digest = manifest.get("config", {}).get("digest", "")
|
||||
model_type = ""
|
||||
file_type = ""
|
||||
if config_digest and blobs_dir.is_dir():
|
||||
config_blob = blobs_dir / config_digest.replace(":", "-")
|
||||
if config_blob.is_file():
|
||||
try:
|
||||
cfg = json.loads(config_blob.read_text())
|
||||
model_type = cfg.get("model_type", "")
|
||||
file_type = cfg.get("file_type", "")
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.debug(
|
||||
"Could not parse Ollama config blob %s: %s",
|
||||
config_blob,
|
||||
e,
|
||||
)
|
||||
|
||||
model_link_dir = links_root / stem_hash
|
||||
|
||||
gguf_link_path: Optional[str] = None
|
||||
quant = f"-{file_type}" if file_type else ""
|
||||
safe_name = repo_name.replace("/", "-")
|
||||
for layer in manifest.get("layers", []):
|
||||
media = layer.get("mediaType", "")
|
||||
digest = layer.get("digest", "")
|
||||
if not digest:
|
||||
continue
|
||||
|
||||
if media == "application/vnd.ollama.image.model":
|
||||
candidate = blobs_dir / digest.replace(":", "-")
|
||||
if candidate.is_file():
|
||||
link_name = f"{safe_name}-{tag}{quant}.gguf"
|
||||
gguf_link_path = _make_link(
|
||||
model_link_dir, link_name, candidate
|
||||
)
|
||||
|
||||
elif media == "application/vnd.ollama.image.projector":
|
||||
candidate = blobs_dir / digest.replace(":", "-")
|
||||
if candidate.is_file():
|
||||
mmproj_name = f"{safe_name}-{tag}-mmproj.gguf"
|
||||
_make_link(model_link_dir, mmproj_name, candidate)
|
||||
|
||||
if not gguf_link_path:
|
||||
continue
|
||||
|
||||
suffix = ""
|
||||
if model_type:
|
||||
suffix += f" ({model_type}"
|
||||
if file_type:
|
||||
suffix += f" {file_type}"
|
||||
suffix += ")"
|
||||
|
||||
try:
|
||||
updated_at = tag_file.stat().st_mtime
|
||||
except OSError:
|
||||
updated_at = None
|
||||
|
||||
found.append(
|
||||
LocalModelInfo(
|
||||
id = gguf_link_path,
|
||||
model_id = f"ollama/{repo_name}:{tag}",
|
||||
display_name = display + suffix,
|
||||
path = gguf_link_path,
|
||||
source = "custom",
|
||||
updated_at = updated_at,
|
||||
),
|
||||
)
|
||||
if limit is not None and len(found) >= limit:
|
||||
return found
|
||||
except OSError as e:
|
||||
logger.warning("Error scanning Ollama directory %s: %s", ollama_dir, e)
|
||||
return found
|
||||
|
||||
|
||||
@router.get("/local", response_model = LocalModelListResponse)
|
||||
async def list_local_models(
|
||||
models_dir: str = Query(
|
||||
|
|
@ -493,11 +757,27 @@ async def list_local_models(
|
|||
for folder in custom_folders:
|
||||
folder_path = Path(folder["path"])
|
||||
try:
|
||||
custom_models = (
|
||||
_scan_models_dir(folder_path, limit = _MAX_MODELS_PER_FOLDER)
|
||||
+ _scan_hf_cache(folder_path)
|
||||
+ _scan_lmstudio_dir(folder_path)
|
||||
)[:_MAX_MODELS_PER_FOLDER]
|
||||
# Ollama scanner creates .studio_links/ with .gguf symlinks.
|
||||
# Filter those from the generic scanners to avoid duplicates
|
||||
# and leaking internal paths into the UI.
|
||||
_generic = [
|
||||
m
|
||||
for m in (
|
||||
_scan_models_dir(folder_path, limit = _MAX_MODELS_PER_FOLDER)
|
||||
+ _scan_hf_cache(folder_path)
|
||||
+ _scan_lmstudio_dir(folder_path)
|
||||
)
|
||||
if not any(
|
||||
p in (".studio_links", "ollama_links")
|
||||
for p in Path(m.path).parts
|
||||
)
|
||||
]
|
||||
custom_models = _generic
|
||||
if len(custom_models) < _MAX_MODELS_PER_FOLDER:
|
||||
custom_models += _scan_ollama_dir(
|
||||
folder_path,
|
||||
limit = _MAX_MODELS_PER_FOLDER - len(custom_models),
|
||||
)
|
||||
except OSError as e:
|
||||
logger.warning("Skipping unreadable scan folder %s: %s", folder_path, e)
|
||||
continue
|
||||
|
|
@ -575,6 +855,57 @@ async def remove_scan_folder_endpoint(
|
|||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/recommended-folders")
|
||||
async def get_recommended_folders(
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""Return well-known model directories that exist on this machine.
|
||||
|
||||
Lightweight alternative to ``browse-folders`` for showing quick-pick
|
||||
chips without the overhead of enumerating a directory tree. Returns
|
||||
paths that actually exist on disk (HF cache, LM Studio, Ollama,
|
||||
``~/models``, etc.) so the frontend can offer them as one-click
|
||||
"Recommended" shortcuts in the Custom Folders section.
|
||||
"""
|
||||
from utils.paths.storage_roots import lmstudio_model_dirs
|
||||
|
||||
folders: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _add(p: Optional[Path]) -> None:
|
||||
if p is None:
|
||||
return
|
||||
try:
|
||||
resolved = str(p.resolve())
|
||||
except OSError:
|
||||
return
|
||||
if resolved in seen:
|
||||
return
|
||||
if Path(resolved).is_dir() and os.access(resolved, os.R_OK | os.X_OK):
|
||||
seen.add(resolved)
|
||||
folders.append(resolved)
|
||||
|
||||
# LM Studio model directories
|
||||
try:
|
||||
for p in lmstudio_model_dirs():
|
||||
_add(p)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to scan for LM Studio model directories: %s", e)
|
||||
|
||||
# Ollama model directories
|
||||
ollama_env = os.environ.get("OLLAMA_MODELS")
|
||||
if ollama_env:
|
||||
_add(Path(ollama_env).expanduser())
|
||||
for candidate in (
|
||||
Path.home() / ".ollama" / "models",
|
||||
Path("/usr/share/ollama/.ollama/models"),
|
||||
Path("/var/lib/ollama/.ollama/models"),
|
||||
):
|
||||
_add(candidate)
|
||||
|
||||
return {"folders": folders}
|
||||
|
||||
|
||||
# Heuristic ceiling on how many children to stat when checking whether a
|
||||
# directory "looks like" it contains models. Keeps the browser snappy
|
||||
# even when a directory has thousands of unrelated entries.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
BIN
studio/frontend/public/blacklogo-c.png
Normal file
BIN
studio/frontend/public/blacklogo-c.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
BIN
studio/frontend/public/sticker.png
Normal file
BIN
studio/frontend/public/sticker.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 990 KiB |
BIN
studio/frontend/public/whitelogo-c.png
Normal file
BIN
studio/frontend/public/whitelogo-c.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { SettingsDialog, useSettingsDialogStore } from "@/features/settings";
|
||||
import { useTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
|
||||
import { useSidebarPin } from "@/hooks/use-sidebar-pin";
|
||||
import {
|
||||
Outlet,
|
||||
createRootRoute,
|
||||
|
|
@ -10,7 +15,7 @@ import {
|
|||
useRouterState,
|
||||
} from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Suspense } from "react";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { AppProvider } from "../provider";
|
||||
|
||||
const CHAT_ONLY_ALLOWED = new Set([
|
||||
|
|
@ -19,7 +24,6 @@ const CHAT_ONLY_ALLOWED = new Set([
|
|||
"/login",
|
||||
"/signup",
|
||||
"/change-password",
|
||||
"/api-keys",
|
||||
]);
|
||||
|
||||
function isChatOnlyAllowed(pathname: string): boolean {
|
||||
|
|
@ -43,24 +47,63 @@ const HIDDEN_NAVBAR_ROUTES = ["/onboarding", "/login", "/change-password"];
|
|||
function RootLayout() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
const hideNavbar = HIDDEN_NAVBAR_ROUTES.includes(pathname);
|
||||
const isChatRoute = pathname.startsWith("/chat");
|
||||
const { pinned, setPinned, togglePinned } = useSidebarPin();
|
||||
|
||||
useTrainingUnloadGuard();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === ",") {
|
||||
e.preventDefault();
|
||||
useSettingsDialogStore.getState().openDialog();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppProvider>
|
||||
{!hideNavbar && <Navbar />}
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="flex-1"
|
||||
>
|
||||
<SettingsDialog />
|
||||
{hideNavbar ? (
|
||||
<main className="flex-1">
|
||||
<Suspense fallback={null}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
) : (
|
||||
<SidebarProvider
|
||||
pinned={pinned}
|
||||
setPinned={setPinned}
|
||||
togglePinned={togglePinned}
|
||||
className="!min-h-0 h-dvh overflow-hidden"
|
||||
>
|
||||
<AppSidebar />
|
||||
<SidebarInset className={isChatRoute ? "overflow-hidden" : "overflow-y-auto"}>
|
||||
<Navbar />
|
||||
<div
|
||||
className={`flex min-h-0 min-w-0 flex-1 basis-0 flex-col ${isChatRoute ? "overflow-hidden" : "overflow-visible"} ${isChatRoute ? "" : "pt-14 md:pt-0"}`}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={`flex min-h-0 min-w-0 flex-1 basis-0 flex-col ${isChatRoute ? "overflow-hidden" : "overflow-visible"}`}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)}
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -1,18 +1,25 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { ChatPage } from "@/features/chat/chat-page";
|
||||
import { createRoute } from "@tanstack/react-router";
|
||||
import { lazy } from "react";
|
||||
import { requireAuth } from "../auth-guards";
|
||||
import { Route as rootRoute } from "./__root";
|
||||
|
||||
const ChatPage = lazy(() =>
|
||||
import("@/features/chat/chat-page").then((m) => ({ default: m.ChatPage })),
|
||||
);
|
||||
export type ChatSearch = {
|
||||
thread?: string;
|
||||
compare?: string;
|
||||
new?: string;
|
||||
};
|
||||
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/chat",
|
||||
beforeLoad: () => requireAuth(),
|
||||
validateSearch: (search: Record<string, unknown>): ChatSearch => ({
|
||||
thread: typeof search.thread === "string" ? search.thread : undefined,
|
||||
compare: typeof search.compare === "string" ? search.compare : undefined,
|
||||
new: typeof search.new === "string" ? search.new : undefined,
|
||||
}),
|
||||
component: ChatPage,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { lazy } from "react";
|
|||
import { requireAuth } from "../auth-guards";
|
||||
import { Route as rootRoute } from "./__root";
|
||||
|
||||
export type OnboardingSearch = { redirectTo?: string };
|
||||
|
||||
const WizardLayout = lazy(() =>
|
||||
import("@/features/onboarding/components/wizard-layout").then((m) => ({
|
||||
default: m.WizardLayout,
|
||||
|
|
@ -16,5 +18,8 @@ export const Route = createRoute({
|
|||
getParentRoute: () => rootRoute,
|
||||
path: "/onboarding",
|
||||
beforeLoad: () => requireAuth(),
|
||||
validateSearch: (search: Record<string, unknown>): OnboardingSearch => ({
|
||||
redirectTo: typeof search.redirectTo === "string" ? search.redirectTo : undefined,
|
||||
}),
|
||||
component: WizardLayout,
|
||||
});
|
||||
|
|
|
|||
607
studio/frontend/src/components/app-sidebar.tsx
Normal file
607
studio/frontend/src/components/app-sidebar.tsx
Normal file
|
|
@ -0,0 +1,607 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Book03Icon,
|
||||
ChefHatIcon,
|
||||
ColumnInsertIcon,
|
||||
CursorInfo02Icon,
|
||||
Delete02Icon,
|
||||
MessageSearch01Icon,
|
||||
Search01Icon,
|
||||
NewReleasesIcon,
|
||||
PackageIcon,
|
||||
PencilEdit02Icon,
|
||||
Settings02Icon,
|
||||
ZapIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ChevronDown, ChevronsUpDown, Moon, PanelLeft, Sun } from "lucide-react";
|
||||
import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { useTrainingRuntimeStore } from "@/features/training";
|
||||
import { useSettingsDialogStore } from "@/features/settings";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { TOUR_OPEN_EVENT } from "@/features/tour";
|
||||
import {
|
||||
useChatSidebarItems,
|
||||
deleteChatItem,
|
||||
} from "@/features/chat/hooks/use-chat-sidebar-items";
|
||||
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
|
||||
import { useChatSearchStore } from "@/features/chat/stores/chat-search-store";
|
||||
import { ChatSearchDialog } from "@/features/chat/components/chat-search-dialog";
|
||||
import { useTrainingHistorySidebarItems, deleteTrainingRun } from "@/features/training";
|
||||
import type { TrainingRunSummary } from "@/features/training";
|
||||
import { useState } from "react";
|
||||
|
||||
function getTourId(pathname: string): string | null {
|
||||
if (pathname.startsWith("/studio")) return "studio";
|
||||
if (pathname.startsWith("/export")) return "export";
|
||||
if (pathname.startsWith("/chat")) return "chat";
|
||||
return null;
|
||||
}
|
||||
|
||||
const NAV_SPRING = { type: "spring", stiffness: 500, damping: 35, mass: 0.5 } as const;
|
||||
|
||||
function runStatusDotClass(status: TrainingRunSummary["status"]): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "bg-blue-500 animate-pulse";
|
||||
case "completed":
|
||||
return "bg-emerald-500";
|
||||
case "stopped":
|
||||
return "bg-amber-500";
|
||||
case "error":
|
||||
return "bg-red-500";
|
||||
default:
|
||||
return "bg-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeShort(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return "";
|
||||
const diffMs = Date.now() - then;
|
||||
const s = Math.max(0, Math.floor(diffMs / 1000));
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d`;
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
icon,
|
||||
label,
|
||||
active,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
variant = "nav",
|
||||
dataTour,
|
||||
}: {
|
||||
icon: typeof ZapIcon;
|
||||
label: string;
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
children?: React.ReactNode;
|
||||
variant?: "nav" | "menu";
|
||||
dataTour?: string;
|
||||
}) {
|
||||
const isNav = variant === "nav";
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<div className="relative">
|
||||
{isNav && active && (
|
||||
<motion.div
|
||||
layoutId="sidebar-active-indicator"
|
||||
className="absolute left-0 top-0 bottom-0 w-[3px] rounded-full bg-primary"
|
||||
transition={NAV_SPRING}
|
||||
/>
|
||||
)}
|
||||
<SidebarMenuButton
|
||||
tooltip={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
isActive={active}
|
||||
data-tour={dataTour}
|
||||
className={
|
||||
isNav
|
||||
? "rounded-none pr-0 pl-4 text-[#475569] dark:text-[#94a3b8] data-active:text-foreground!"
|
||||
: "rounded-none pr-0 pl-4 text-[#475569] dark:text-[#94a3b8] hover:bg-muted! hover:text-foreground! data-active:bg-[oklch(0.94_0_0)]! data-active:text-foreground! dark:data-active:bg-[oklch(0.3_0_0)]!"
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon icon={icon} strokeWidth={2} className="size-[18px]" />
|
||||
<span className="text-[13px] font-medium">{label}</span>
|
||||
</SidebarMenuButton>
|
||||
</div>
|
||||
{children}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle();
|
||||
const { pathname, search } = useRouterState({
|
||||
select: (s) => ({
|
||||
pathname: s.location.pathname,
|
||||
search: s.location.search as Record<string, string | undefined>,
|
||||
}),
|
||||
});
|
||||
const { togglePinned, isMobile, setOpenMobile } = useSidebar();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Auto-close mobile Sheet after navigation
|
||||
const closeMobileIfOpen = () => {
|
||||
if (isMobile) setOpenMobile(false);
|
||||
};
|
||||
|
||||
const isTrainingRunning = useTrainingRuntimeStore((s) => s.isTrainingRunning);
|
||||
const chatOnly = usePlatformStore((s) => s.isChatOnly());
|
||||
|
||||
// Chat collapsible state — open by default, syncs with route
|
||||
const isChatRoute = pathname.startsWith("/chat");
|
||||
const isStudioRoute = pathname === "/studio" || pathname.startsWith("/studio/");
|
||||
const [chatOpen, setChatOpen] = useState(true);
|
||||
const [runsOpen, setRunsOpen] = useState(true);
|
||||
const effectiveChatOpen = isChatRoute || chatOpen;
|
||||
const effectiveRunsOpen = isStudioRoute || runsOpen;
|
||||
|
||||
const isRecipesRoute = pathname.startsWith("/data-recipes");
|
||||
|
||||
const { items: chatItems } = useChatSidebarItems();
|
||||
const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
|
||||
const setActiveThreadId = useChatRuntimeStore((s) => s.setActiveThreadId);
|
||||
const activeThreadId = isChatRoute
|
||||
? (search.thread as string | undefined) ??
|
||||
(search.compare as string | undefined) ??
|
||||
storeThreadId ??
|
||||
undefined
|
||||
: undefined;
|
||||
|
||||
// Training runs
|
||||
const { items: runItems, refresh: refreshRuns } = useTrainingHistorySidebarItems(
|
||||
!chatOnly && isStudioRoute,
|
||||
);
|
||||
const activeJobId = useTrainingRuntimeStore((s) => s.jobId);
|
||||
const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId);
|
||||
const setSelectedHistoryRunId = useTrainingRuntimeStore((s) => s.setSelectedHistoryRunId);
|
||||
|
||||
const chatDisabled = isTrainingRunning;
|
||||
|
||||
async function handleDeleteThread(item: Parameters<typeof deleteChatItem>[0]) {
|
||||
await deleteChatItem(item, activeThreadId, (view) => {
|
||||
navigate({
|
||||
to: "/chat",
|
||||
search: { new: view.newThreadNonce },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar collapsible="icon" variant="sidebar">
|
||||
<SidebarHeader className="group-data-[collapsible=icon]:px-0">
|
||||
{/* Expanded: compact logo + close toggle */}
|
||||
<div className="flex items-center justify-between gap-2 px-1 py-1 group-data-[collapsible=icon]:hidden">
|
||||
<Link
|
||||
to={chatOnly ? "/chat" : "/studio"}
|
||||
onClick={closeMobileIfOpen}
|
||||
className="flex items-center select-none"
|
||||
aria-label="Unsloth home"
|
||||
>
|
||||
<img
|
||||
src="/blacklogo-c.png"
|
||||
alt="Unsloth"
|
||||
className="h-7 w-auto dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/whitelogo-c.png"
|
||||
alt="Unsloth"
|
||||
className="hidden h-7 w-auto dark:block"
|
||||
/>
|
||||
</Link>
|
||||
{!isMobile && (
|
||||
<Tooltip>
|
||||
<TooltipPrimitive.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePinned}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<PanelLeft strokeWidth={1.5} className="size-4" />
|
||||
</button>
|
||||
</TooltipPrimitive.Trigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Close sidebar
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collapsed: sticker with hover-swap to open toggle */}
|
||||
{!isMobile && (
|
||||
<div className="hidden group-data-[collapsible=icon]:flex items-center justify-center h-9 w-full">
|
||||
<Tooltip>
|
||||
<TooltipPrimitive.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePinned}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<PanelLeft strokeWidth={1.5} className="size-4" />
|
||||
</button>
|
||||
</TooltipPrimitive.Trigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Open sidebar
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:p-0 p-0 pt-1 shrink-0">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<NavItem
|
||||
icon={PencilEdit02Icon}
|
||||
label="New Chat"
|
||||
active={false}
|
||||
disabled={chatDisabled}
|
||||
onClick={() => {
|
||||
if (chatDisabled) return;
|
||||
setActiveThreadId(null);
|
||||
navigate({ to: "/chat", search: { new: crypto.randomUUID() } });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
<NavItem
|
||||
icon={ColumnInsertIcon}
|
||||
label="Compare"
|
||||
active={!!search.compare}
|
||||
disabled={chatDisabled}
|
||||
dataTour="chat-compare"
|
||||
onClick={() => {
|
||||
if (chatDisabled) return;
|
||||
setActiveThreadId(null);
|
||||
navigate({ to: "/chat", search: { compare: crypto.randomUUID() } });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
<NavItem
|
||||
icon={Search01Icon}
|
||||
label="Search"
|
||||
active={false}
|
||||
disabled={chatDisabled}
|
||||
onClick={() => {
|
||||
if (chatDisabled) return;
|
||||
useChatSearchStore.getState().open();
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
<div className="my-2" />
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarContent className="gap-0 overflow-y-auto overscroll-contain min-h-0">
|
||||
{/* Navigate (no header) */}
|
||||
<SidebarGroup data-tour="navbar" className="group-data-[collapsible=icon]:p-0 p-0">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<NavItem
|
||||
icon={ZapIcon}
|
||||
label="Train"
|
||||
active={pathname === "/studio" || pathname.startsWith("/studio/")}
|
||||
disabled={chatOnly}
|
||||
onClick={() => {
|
||||
if (chatOnly) return;
|
||||
navigate({ to: "/studio" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
|
||||
<NavItem
|
||||
icon={ChefHatIcon}
|
||||
label="Recipes"
|
||||
active={isRecipesRoute}
|
||||
onClick={() => {
|
||||
navigate({ to: "/data-recipes" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
|
||||
<NavItem
|
||||
icon={PackageIcon}
|
||||
label="Export"
|
||||
active={pathname === "/export" || pathname.startsWith("/export/")}
|
||||
disabled={chatOnly}
|
||||
onClick={() => {
|
||||
if (chatOnly) return;
|
||||
navigate({ to: "/export" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
<div className="my-2" />
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Recent Chats */}
|
||||
{chatItems.length > 0 && (
|
||||
<Collapsible open={effectiveChatOpen} onOpenChange={setChatOpen} asChild>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden p-0">
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
|
||||
Recent Chats
|
||||
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{chatItems.map((item) => (
|
||||
<SidebarMenuItem key={item.id} className="group/recent-item relative">
|
||||
<SidebarMenuButton
|
||||
isActive={activeThreadId === item.id}
|
||||
className="rounded-none pl-4 pr-7 text-[13px] font-medium text-[#475569] dark:text-[#94a3b8] hover:bg-muted! hover:text-foreground! data-active:bg-[oklch(0.94_0_0)]! data-active:text-foreground! dark:data-active:bg-[oklch(0.3_0_0)]!"
|
||||
onClick={() => {
|
||||
navigate({
|
||||
to: "/chat",
|
||||
search:
|
||||
item.type === "single"
|
||||
? { thread: item.id }
|
||||
: { compare: item.id },
|
||||
});
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteThread(item);
|
||||
}}
|
||||
title="Delete"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 flex size-5 scale-90 items-center justify-center rounded-md text-sidebar-foreground/55 opacity-0 transition-all duration-150 hover:bg-destructive/12 hover:text-destructive group-hover/recent-item:scale-100 group-hover/recent-item:opacity-100"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
|
||||
</button>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Recent Runs */}
|
||||
{isStudioRoute && runItems.length > 0 && !chatOnly && (
|
||||
<Collapsible open={effectiveRunsOpen} onOpenChange={setRunsOpen} asChild>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden p-0">
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
|
||||
Recent Runs
|
||||
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{runItems.map((run) => {
|
||||
const isActiveRun =
|
||||
selectedHistoryRunId === run.id || activeJobId === run.id;
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={run.id}
|
||||
className="group/run-item relative"
|
||||
>
|
||||
<SidebarMenuButton
|
||||
isActive={isActiveRun}
|
||||
className="h-auto flex-col items-start gap-0.5 py-2 rounded-none pl-4 pr-7 text-[13px] font-medium text-[#475569] dark:text-[#94a3b8] hover:bg-muted! hover:text-foreground! data-active:bg-[oklch(0.94_0_0)]! data-active:text-foreground! dark:data-active:bg-[oklch(0.3_0_0)]!"
|
||||
onClick={() => {
|
||||
setSelectedHistoryRunId(run.id);
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
runStatusDotClass(run.status),
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate text-sm font-medium">
|
||||
{run.model_name}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">
|
||||
{formatRelativeShort(run.started_at)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="w-full truncate pl-3.5 text-xs text-muted-foreground">
|
||||
{run.dataset_name}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await deleteTrainingRun(run.id);
|
||||
if (selectedHistoryRunId === run.id) {
|
||||
setSelectedHistoryRunId(null);
|
||||
}
|
||||
await refreshRuns();
|
||||
} catch {
|
||||
// ignore — next refresh will reconcile
|
||||
}
|
||||
}}
|
||||
title="Delete"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 flex size-5 scale-90 items-center justify-center rounded-md text-sidebar-foreground/55 opacity-0 transition-all duration-150 hover:bg-destructive/12 hover:text-destructive group-hover/run-item:scale-100 group-hover/run-item:opacity-100"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
|
||||
</button>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
)}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="border-t border-sidebar-border">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<img
|
||||
src="/Sloth emojis/sloth rounded.png"
|
||||
alt="Unsloth"
|
||||
className="size-8 rounded-lg shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5 leading-none group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate text-sm font-semibold">Unsloth</span>
|
||||
<span className="truncate text-[11px] text-muted-foreground">Train</span>
|
||||
</div>
|
||||
<ChevronsUpDown strokeWidth={1.25} className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-56"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => useSettingsDialogStore.getState().openDialog()}
|
||||
>
|
||||
<HugeiconsIcon icon={Settings02Icon} className="size-4" />
|
||||
<span>Settings</span>
|
||||
<DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
ref={anchorRef as React.Ref<HTMLDivElement>}
|
||||
onSelect={(e) => { e.preventDefault(); toggleTheme(); }}
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
<span>{isDark ? "Light Mode" : "Dark Mode"}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!getTourId(pathname)}
|
||||
onSelect={() => {
|
||||
const tourId = getTourId(pathname);
|
||||
if (!tourId) return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(TOUR_OPEN_EVENT, {
|
||||
detail: { id: tourId },
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
|
||||
<span>Guided Tour</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href="https://unsloth.ai/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<HugeiconsIcon icon={Book03Icon} className="size-4" />
|
||||
<span>Learn More</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href="https://unsloth.ai/docs/new/changelog"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={NewReleasesIcon}
|
||||
className="size-4"
|
||||
/>
|
||||
<span>What's New</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href="https://github.com/unslothai/unsloth/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={MessageSearch01Icon}
|
||||
className="size-4"
|
||||
/>
|
||||
<span>Feedback</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<ChatSearchDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ import {
|
|||
listCachedModels,
|
||||
listGgufVariants,
|
||||
listLocalModels,
|
||||
listRecommendedFolders,
|
||||
listScanFolders,
|
||||
removeScanFolder,
|
||||
} from "@/features/chat/api/chat-api";
|
||||
|
|
@ -49,7 +50,7 @@ import { checkVramFit, estimateLoadingVram } from "@/lib/vram";
|
|||
import { Add01Icon, Cancel01Icon, Folder02Icon, Search01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { FolderBrowser } from "./folder-browser";
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import { ChevronDownIcon, ChevronRightIcon, DownloadIcon, StarIcon, Trash2Icon } from "lucide-react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
|
|
@ -73,10 +74,35 @@ function normalizeForSearch(s: string): string {
|
|||
return s.toLowerCase().replace(/[\s\-_\.]/g, "");
|
||||
}
|
||||
|
||||
function ListLabel({ children }: { children: ReactNode }) {
|
||||
function ListLabel({
|
||||
children,
|
||||
icon,
|
||||
collapsed,
|
||||
onToggle,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
icon?: ReactNode;
|
||||
collapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{children}
|
||||
<div className="flex items-center justify-between gap-1 px-2.5 py-1.5">
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{icon}
|
||||
{children}
|
||||
</span>
|
||||
{onToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={collapsed ? "Expand section" : "Collapse section"}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
{collapsed
|
||||
? <ChevronRightIcon className="size-3" />
|
||||
: <ChevronDownIcon className="size-3" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -489,6 +515,9 @@ export function HubModelPicker({
|
|||
// Delete confirmation dialog state
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [downloadedCollapsed, setDownloadedCollapsed] = useState(false);
|
||||
const [customFoldersCollapsed, setCustomFoldersCollapsed] = useState(false);
|
||||
const [recommendedCollapsed, setRecommendedCollapsed] = useState(false);
|
||||
|
||||
// Cached (already downloaded) repos -- use module-level cache so
|
||||
// re-mounting the popover does not flash an empty "Downloaded" section.
|
||||
|
|
@ -514,6 +543,7 @@ export function HubModelPicker({
|
|||
const [showFolderInput, setShowFolderInput] = useState(false);
|
||||
const [folderLoading, setFolderLoading] = useState(false);
|
||||
const [showFolderBrowser, setShowFolderBrowser] = useState(false);
|
||||
const [recommendedFolders, setRecommendedFolders] = useState<string[]>([]);
|
||||
|
||||
const refreshLocalModelsList = useCallback(() => {
|
||||
listLocalModels()
|
||||
|
|
@ -616,6 +646,9 @@ export function HubModelPicker({
|
|||
// Always refresh LM Studio + custom folder models (not gated by alreadyCached)
|
||||
refreshLocalModelsList();
|
||||
refreshScanFolders();
|
||||
listRecommendedFolders()
|
||||
.then(setRecommendedFolders)
|
||||
.catch(() => {});
|
||||
|
||||
// Always refetch cached GGUF/model lists. The module-level caches give
|
||||
// an instant render with stale data (no spinner flash), but newly
|
||||
|
|
@ -893,8 +926,12 @@ export function HubModelPicker({
|
|||
(cachedGguf.length > 0 ||
|
||||
(!chatOnly && cachedModels.length > 0)) ? (
|
||||
<>
|
||||
<ListLabel>Downloaded</ListLabel>
|
||||
{cachedGguf.map((c) => (
|
||||
<ListLabel
|
||||
icon={<DownloadIcon className="size-3" />}
|
||||
collapsed={downloadedCollapsed}
|
||||
onToggle={() => setDownloadedCollapsed((v) => !v)}
|
||||
>Downloaded</ListLabel>
|
||||
{!downloadedCollapsed && cachedGguf.map((c) => (
|
||||
<div key={c.repo_id}>
|
||||
<ModelRow
|
||||
label={c.repo_id}
|
||||
|
|
@ -922,7 +959,7 @@ export function HubModelPicker({
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
{!chatOnly &&
|
||||
{!downloadedCollapsed && !chatOnly &&
|
||||
cachedModels.map((c) => (
|
||||
<div key={c.repo_id} className="flex items-center gap-0.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
|
|
@ -1001,20 +1038,12 @@ export function HubModelPicker({
|
|||
|
||||
{!showHfSection ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-1 px-2.5 py-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<div className="flex items-center gap-1 px-2.5 py-1.5">
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<HugeiconsIcon icon={Folder02Icon} className="size-3" />
|
||||
Custom Folders
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Browse for a folder on the server"
|
||||
title="Browse folders on the server"
|
||||
onClick={() => setShowFolderBrowser(true)}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={Search01Icon} className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showFolderInput ? "Cancel adding folder" : "Add scan folder by path"}
|
||||
|
|
@ -1029,11 +1058,33 @@ export function HubModelPicker({
|
|||
>
|
||||
<HugeiconsIcon icon={showFolderInput ? Cancel01Icon : Add01Icon} className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Browse for a folder on the server"
|
||||
title="Browse folders on the server"
|
||||
onClick={() => setShowFolderBrowser(true)}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={Search01Icon} className="size-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={customFoldersCollapsed ? "Expand custom folders" : "Collapse custom folders"}
|
||||
title={customFoldersCollapsed ? "Expand" : "Collapse"}
|
||||
onClick={() => setCustomFoldersCollapsed((v) => !v)}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
{customFoldersCollapsed
|
||||
? <ChevronRightIcon className="size-3" />
|
||||
: <ChevronDownIcon className="size-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Folder paths */}
|
||||
{scanFolders.map((f) => (
|
||||
{!customFoldersCollapsed && scanFolders.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className="group flex items-center gap-1.5 px-2.5 py-0.5"
|
||||
|
|
@ -1056,8 +1107,31 @@ export function HubModelPicker({
|
|||
</div>
|
||||
))}
|
||||
|
||||
{/* Recommended folders */}
|
||||
{!customFoldersCollapsed && (() => {
|
||||
const registered = new Set(scanFolders.map((f) => f.path));
|
||||
const unregistered = recommendedFolders.filter((p) => !registered.has(p));
|
||||
if (unregistered.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 px-2.5 pb-0.5">
|
||||
{unregistered.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => void handleAddFolder(p)}
|
||||
disabled={folderLoading}
|
||||
title={`Add ${p}`}
|
||||
className="rounded-full border border-dashed border-border/50 px-2 py-0.5 font-mono text-[10px] text-muted-foreground/70 transition-colors hover:border-foreground/30 hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
<span className="text-[11px] font-semibold">+</span> {p.length > 30 ? `...${p.slice(-27)}` : p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Add folder input */}
|
||||
{showFolderInput && (
|
||||
{!customFoldersCollapsed && showFolderInput && (
|
||||
<div className="px-2.5 pb-1 pt-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<HugeiconsIcon icon={Folder02Icon} className="size-3 shrink-0 text-muted-foreground/40" />
|
||||
|
|
@ -1114,11 +1188,15 @@ export function HubModelPicker({
|
|||
|
||||
|
||||
{/* Models from custom folders */}
|
||||
{customFolderModels.map((m) => {
|
||||
{!customFoldersCollapsed && customFolderModels.map((m) => {
|
||||
const isGgufFile = m.path.toLowerCase().endsWith(".gguf");
|
||||
const isGguf =
|
||||
isGgufFile ||
|
||||
isGgufRepo(m.id) ||
|
||||
isGgufRepo(m.display_name) ||
|
||||
m.path.toLowerCase().endsWith(".gguf");
|
||||
isGgufRepo(m.display_name);
|
||||
// Single .gguf files (e.g. Ollama blobs) load directly;
|
||||
// GGUF repos/directories expand to pick a variant.
|
||||
const isDirectGguf = isGgufFile;
|
||||
return (
|
||||
<div key={m.id}>
|
||||
<ModelRow
|
||||
|
|
@ -1126,7 +1204,13 @@ export function HubModelPicker({
|
|||
meta={isGguf ? "GGUF" : "Local"}
|
||||
selected={value === m.id}
|
||||
onClick={() => {
|
||||
if (isGguf) {
|
||||
if (isDirectGguf) {
|
||||
onSelect(m.id, {
|
||||
source: "local",
|
||||
isLora: false,
|
||||
isDownloaded: true,
|
||||
});
|
||||
} else if (isGguf) {
|
||||
setExpandedGguf((prev) =>
|
||||
prev === m.id ? null : m.id,
|
||||
);
|
||||
|
|
@ -1158,8 +1242,12 @@ export function HubModelPicker({
|
|||
|
||||
{!showHfSection && cachedReady ? (
|
||||
<>
|
||||
<ListLabel>Recommended</ListLabel>
|
||||
{visibleRecommendedIds.length === 0 ? (
|
||||
<ListLabel
|
||||
icon={<StarIcon className="size-3" />}
|
||||
collapsed={recommendedCollapsed}
|
||||
onToggle={() => setRecommendedCollapsed((v) => !v)}
|
||||
>Recommended</ListLabel>
|
||||
{recommendedCollapsed ? null : visibleRecommendedIds.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-xs text-muted-foreground">
|
||||
No default models.
|
||||
</div>
|
||||
|
|
@ -1203,7 +1291,7 @@ export function HubModelPicker({
|
|||
);
|
||||
})
|
||||
)}
|
||||
{hasMoreRecommended && (
|
||||
{!recommendedCollapsed && hasMoreRecommended && (
|
||||
<>
|
||||
<div ref={recommendedSentinelRef} className="h-px" />
|
||||
<div className="flex items-center justify-center py-2">
|
||||
|
|
@ -1216,7 +1304,7 @@ export function HubModelPicker({
|
|||
|
||||
{showHfSection && filteredRecommendedIds.length > 0 ? (
|
||||
<>
|
||||
<ListLabel>Recommended</ListLabel>
|
||||
<ListLabel icon={<StarIcon className="size-3" />}>Recommended</ListLabel>
|
||||
{filteredRecommendedIds.map((id) => {
|
||||
const vram = recommendedVramMap.get(id);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { AnimatedShinyText } from "@/components/ui/animated-shiny-text";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
|
|
@ -151,34 +150,6 @@ function ReasoningRoot({
|
|||
);
|
||||
}
|
||||
|
||||
function ReasoningFade({ className, ...props }: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="reasoning-fade"
|
||||
className={cn(
|
||||
"aui-reasoning-fade pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8",
|
||||
"bg-gradient-to-t from-background to-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasoningFadeTop({ className, ...props }: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="reasoning-fade-top"
|
||||
className={cn(
|
||||
"aui-reasoning-fade-top pointer-events-none absolute inset-x-0 top-0 z-10 h-8",
|
||||
"bg-gradient-to-b from-background to-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasoningTrigger({
|
||||
active,
|
||||
duration,
|
||||
|
|
@ -206,7 +177,7 @@ function ReasoningTrigger({
|
|||
className="aui-reasoning-trigger-label-wrapper relative inline-block leading-none"
|
||||
>
|
||||
{active ? (
|
||||
<AnimatedShinyText className="text-sm">Thinking...</AnimatedShinyText>
|
||||
<span className="text-sm">Thinking...</span>
|
||||
) : (
|
||||
<span>Thought for {duration ?? 0} seconds</span>
|
||||
)}
|
||||
|
|
@ -234,7 +205,7 @@ function ReasoningContent({
|
|||
<CollapsibleContent
|
||||
data-slot="reasoning-content"
|
||||
className={cn(
|
||||
"aui-reasoning-content relative overflow-hidden text-muted-foreground text-sm outline-none",
|
||||
"aui-reasoning-content relative overflow-hidden text-foreground/85 text-[13.5px] outline-none",
|
||||
"group/collapsible-content ease-out",
|
||||
"data-[state=closed]:animate-collapsible-up",
|
||||
"data-[state=open]:animate-collapsible-down",
|
||||
|
|
@ -246,9 +217,7 @@ function ReasoningContent({
|
|||
)}
|
||||
{...props}
|
||||
>
|
||||
{streaming && <ReasoningFadeTop />}
|
||||
{children}
|
||||
<ReasoningFade />
|
||||
</CollapsibleContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -481,8 +450,6 @@ const Reasoning = memo(
|
|||
Trigger: typeof ReasoningTrigger;
|
||||
Content: typeof ReasoningContent;
|
||||
Text: typeof ReasoningText;
|
||||
Fade: typeof ReasoningFade;
|
||||
FadeTop: typeof ReasoningFadeTop;
|
||||
};
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
|
|
@ -490,8 +457,6 @@ Reasoning.Root = ReasoningRoot;
|
|||
Reasoning.Trigger = ReasoningTrigger;
|
||||
Reasoning.Content = ReasoningContent;
|
||||
Reasoning.Text = ReasoningText;
|
||||
Reasoning.Fade = ReasoningFade;
|
||||
Reasoning.FadeTop = ReasoningFadeTop;
|
||||
|
||||
const ReasoningGroup = memo(ReasoningGroupImpl);
|
||||
ReasoningGroup.displayName = "ReasoningGroup";
|
||||
|
|
@ -503,6 +468,4 @@ export {
|
|||
ReasoningTrigger,
|
||||
ReasoningContent,
|
||||
ReasoningText,
|
||||
ReasoningFade,
|
||||
ReasoningFadeTop,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
|
|||
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
|
||||
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { AnimatedShinyText } from "@/components/ui/animated-shiny-text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { sentAudioNames } from "@/features/chat/api/chat-adapter";
|
||||
import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils";
|
||||
|
|
@ -70,13 +69,25 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
|
|||
}) => {
|
||||
return (
|
||||
<ThreadPrimitive.Root
|
||||
className="aui-root aui-thread-root @container flex h-full flex-col "
|
||||
className={cn(
|
||||
"aui-root aui-thread-root @container flex flex-col",
|
||||
hideComposer
|
||||
? "h-full"
|
||||
: "relative min-h-0 min-w-0 flex-1 basis-0 overflow-hidden",
|
||||
)}
|
||||
style={{
|
||||
["--thread-max-width" as string]: "44rem",
|
||||
["--thread-content-max-width" as string]:
|
||||
"calc(var(--thread-max-width) - 2.5rem)",
|
||||
}}
|
||||
>
|
||||
<ThreadPrimitive.Viewport
|
||||
className="aui-thread-viewport relative flex min-w-0 flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
|
||||
className={cn(
|
||||
"aui-thread-viewport relative flex min-w-0 flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-5",
|
||||
hideComposer
|
||||
? "pt-4"
|
||||
: "h-0 min-h-0 basis-0 pt-[56px]",
|
||||
)}
|
||||
>
|
||||
{!hideWelcome && (
|
||||
<AuiIf condition={({ thread }) => thread.isEmpty}>
|
||||
|
|
@ -92,19 +103,21 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
|
|||
}}
|
||||
/>
|
||||
|
||||
{/* Small overlap and extra slack so the last lines can scroll under the composer cleanly */}
|
||||
{!hideComposer && <div className="h-40 shrink-0" aria-hidden />}
|
||||
|
||||
<ThreadPrimitive.ViewportFooter
|
||||
className={cn(
|
||||
"aui-thread-viewport-footer sticky bottom-0 z-20 mt-auto flex w-full flex-col overflow-visible bg-transparent",
|
||||
hideComposer ? "gap-2" : "gap-4",
|
||||
"aui-thread-viewport-footer sticky z-20 mt-auto flex w-full flex-col overflow-visible bg-transparent",
|
||||
hideComposer
|
||||
? "bottom-0 gap-2"
|
||||
: "bottom-[140px] shrink-0 gap-3",
|
||||
// Compare: pointer-events pass-through so messages behind footer stay clickable
|
||||
hideComposer
|
||||
? "pointer-events-none pb-3"
|
||||
: "relative pb-4",
|
||||
: "pb-2",
|
||||
)}
|
||||
>
|
||||
{!hideComposer && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-4 bg-background" aria-hidden />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-center",
|
||||
|
|
@ -113,11 +126,26 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
|
|||
>
|
||||
<ThreadScrollToBottom />
|
||||
</div>
|
||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||
{!hideComposer && <ComposerAnimated />}
|
||||
</AuiIf>
|
||||
</ThreadPrimitive.ViewportFooter>
|
||||
</ThreadPrimitive.Viewport>
|
||||
{!hideComposer && (
|
||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<div className="aui-thread-composer-dock pointer-events-none absolute bottom-0 left-0 right-0 md:right-2 z-20">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-x-0 bottom-0 top-[10px] bg-background"
|
||||
/>
|
||||
<div className="relative px-5 pb-2">
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-(--thread-max-width)">
|
||||
<ComposerAnimated />
|
||||
</div>
|
||||
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">
|
||||
LLM's can make mistakes. Double-check all responses.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuiIf>
|
||||
)}
|
||||
</ThreadPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
|
@ -207,18 +235,13 @@ const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
|
|||
alt="Sloth mascot"
|
||||
className="size-20"
|
||||
/>
|
||||
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in font-semibold text-2xl duration-200">
|
||||
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in font-bold text-2xl tracking-[-0.02em] duration-200">
|
||||
Chat with your model
|
||||
</h1>
|
||||
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in text-muted-foreground text-base delay-75 duration-200">
|
||||
Run GGUFs, safetensors, vision and audio models!
|
||||
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in text-muted-foreground text-sm delay-75 duration-200">
|
||||
Run GGUFs, safetensors, vision and audio models
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ThreadPrimitive.Suggestions
|
||||
components={{ Suggestion: SuggestionItem }}
|
||||
/>
|
||||
</div>
|
||||
<GeneratingSpinner />
|
||||
{!hideComposer && <ComposerAnimated />}
|
||||
</div>
|
||||
|
|
@ -243,10 +266,6 @@ const GeneratingSpinner: FC = () => {
|
|||
const ComposerAnimated: FC = () => {
|
||||
return (
|
||||
<div className="relative mx-auto min-w-0 w-full max-w-(--thread-max-width)">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-1/2 bottom-0 z-0 bg-background"
|
||||
aria-hidden
|
||||
/>
|
||||
<motion.div
|
||||
layout={true}
|
||||
layoutId="composer"
|
||||
|
|
@ -284,14 +303,15 @@ const PendingAudioChip: FC = () => {
|
|||
const Composer: FC = () => {
|
||||
return (
|
||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
|
||||
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone shadow-border ring-1 ring-border flex w-full flex-col rounded-2xl bg-background px-1 pt-2 outline-none transition-shadow data-[dragging=true]:ring-ring data-[dragging=true]:bg-accent/50">
|
||||
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone chat-composer-surface flex w-full flex-col rounded-3xl bg-background px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:bg-accent/50">
|
||||
<ComposerAttachments />
|
||||
<PendingAudioChip />
|
||||
<ToolStatusDisplay />
|
||||
<ComposerPrimitive.Input
|
||||
placeholder="Send a message..."
|
||||
className="aui-composer-input mb-1 max-h-32 min-h-12 w-full resize-none bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-0"
|
||||
rows={1}
|
||||
className="aui-composer-input mb-1 min-h-12 w-full resize-none overflow-y-auto bg-transparent pl-5 pr-4 pt-2 pb-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-0"
|
||||
minRows={1}
|
||||
maxRows={6}
|
||||
autoFocus={true}
|
||||
aria-label="Message input"
|
||||
/>
|
||||
|
|
@ -457,12 +477,30 @@ const CodeToolsToggle: FC = () => {
|
|||
)}
|
||||
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
|
||||
>
|
||||
<TerminalIcon className="size-3.5" />
|
||||
<CodeToggleIcon className="size-3.5" />
|
||||
<span>Code</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const CodeToggleIcon: FC<{ className?: string }> = ({ className }) => {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolStatusDisplay: FC = () => {
|
||||
const toolStatus = useChatRuntimeStore((s) => s.toolStatus);
|
||||
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||
|
|
@ -594,15 +632,13 @@ const GeneratingIndicator: FC = () => {
|
|||
message.content.length === 0 && message.status?.type === "running",
|
||||
);
|
||||
if (!show) return null;
|
||||
return (
|
||||
<AnimatedShinyText className="text-sm">Generating...</AnimatedShinyText>
|
||||
);
|
||||
return <span className="text-sm text-muted-foreground">Generating...</span>;
|
||||
};
|
||||
|
||||
const AssistantMessage: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto min-w-0 w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
||||
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto min-w-0 w-full max-w-(--thread-content-max-width) animate-in py-0.5 text-[15.5px] duration-150"
|
||||
data-role="assistant"
|
||||
>
|
||||
<div className="aui-assistant-message-content wrap-break-word min-w-0 text-foreground leading-relaxed">
|
||||
|
|
@ -701,9 +737,9 @@ const AssistantActionBar: FC = () => {
|
|||
return (
|
||||
<ActionBarPrimitive.Root
|
||||
hideWhenRunning={true}
|
||||
autohide="not-last"
|
||||
autohide="always"
|
||||
autohideFloat="single-branch"
|
||||
className="aui-assistant-action-bar-root col-start-3 row-start-2 -ml-1 flex gap-1 text-muted-foreground data-floating:absolute data-floating:rounded-md data-floating:border data-floating:bg-background data-floating:p-1 data-floating:shadow-sm"
|
||||
className="aui-assistant-action-bar-root col-start-3 row-start-2 -ml-1 flex gap-1 text-muted-foreground data-floating:absolute"
|
||||
>
|
||||
<CopyButton />
|
||||
<ActionBarPrimitive.Reload asChild={true}>
|
||||
|
|
@ -755,22 +791,22 @@ const UserMessageAudio: FC = () => {
|
|||
const UserMessage: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2"
|
||||
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto flex w-full max-w-(--thread-content-max-width) animate-in flex-col items-end gap-y-2 pt-6 pb-0.5 text-[15.5px] duration-150"
|
||||
data-role="user"
|
||||
>
|
||||
<UserMessageAttachments />
|
||||
<UserMessageAudio />
|
||||
|
||||
<div className="aui-user-message-content-wrapper relative col-start-2 min-w-0">
|
||||
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
|
||||
<div className="aui-user-message-content-wrapper flex max-w-[80%] min-w-0 flex-col items-end">
|
||||
<div className="aui-user-message-content wrap-break-word w-fit rounded-2xl bg-muted px-4 py-2.5 text-foreground">
|
||||
<MessagePrimitive.Parts />
|
||||
</div>
|
||||
<div className="aui-user-action-bar-wrapper absolute top-1/2 left-0 -translate-x-full -translate-y-1/2 pr-2">
|
||||
<div className="mt-1 flex min-h-6">
|
||||
<UserActionBar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BranchPicker className="aui-user-branch-picker col-span-full col-start-1 row-start-3 -mr-1 justify-end" />
|
||||
<BranchPicker className="aui-user-branch-picker -mr-1 justify-end" />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
|
@ -778,8 +814,8 @@ const UserMessage: FC = () => {
|
|||
const UserActionBar: FC = () => {
|
||||
return (
|
||||
<ActionBarPrimitive.Root
|
||||
autohide="not-last"
|
||||
className="aui-user-action-bar-root flex items-center"
|
||||
autohide="always"
|
||||
className="aui-user-action-bar-root -mr-1 flex gap-1 text-muted-foreground"
|
||||
>
|
||||
<CopyButton />
|
||||
<ActionBarPrimitive.Edit asChild={true}>
|
||||
|
|
@ -805,7 +841,7 @@ const EditComposer: FC = () => {
|
|||
});
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-max-width) flex-col px-2 py-3">
|
||||
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-content-max-width) flex-col py-3">
|
||||
<ComposerPrimitive.Root className="aui-edit-composer-root ml-auto flex w-full max-w-[85%] flex-col rounded-2xl bg-muted">
|
||||
<ComposerPrimitive.Input
|
||||
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm outline-none"
|
||||
|
|
|
|||
|
|
@ -1,647 +1,20 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ArrowReloadHorizontalIcon,
|
||||
ArrowRight01Icon,
|
||||
Cancel01Icon,
|
||||
Book03Icon,
|
||||
BubbleChatIcon,
|
||||
ChefHatIcon,
|
||||
Copy01Icon,
|
||||
CursorInfo02Icon,
|
||||
Key01Icon,
|
||||
PackageIcon,
|
||||
Tick02Icon,
|
||||
ZapIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { useTrainingRuntimeStore } from "@/features/training";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { Link, useRouterState } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { TOUR_OPEN_EVENT } from "@/features/tour";
|
||||
import { ShutdownDialog } from "@/components/shutdown-dialog";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ label: "Studio", href: "/studio", icon: ZapIcon, enabled: true },
|
||||
{ label: "Recipes", href: "/data-recipes", icon: ChefHatIcon, enabled: true },
|
||||
{ label: "Export", href: "/export", icon: PackageIcon, enabled: true },
|
||||
{ label: "Chat", href: "/chat", icon: BubbleChatIcon, enabled: true },
|
||||
];
|
||||
|
||||
const STUDIO_UPDATE_CMD = "unsloth studio update";
|
||||
const STUDIO_UPDATE_FALLBACK_UNIX_CMD =
|
||||
"curl -fsSL https://unsloth.ai/install.sh | sh";
|
||||
const STUDIO_UPDATE_FALLBACK_WINDOWS_CMD =
|
||||
"irm https://unsloth.ai/install.ps1 | iex";
|
||||
|
||||
type UpdateShell = "windows" | "unix";
|
||||
|
||||
function getDefaultUpdateShell(deviceType: string): UpdateShell {
|
||||
return deviceType === "windows" ? "windows" : "unix";
|
||||
}
|
||||
|
||||
function getStudioUpdateInstructionLine(shell: UpdateShell): string {
|
||||
return shell === "windows" ? "Open PowerShell and run:" : "Open Terminal and run:";
|
||||
}
|
||||
|
||||
function CopyableCommand({
|
||||
command,
|
||||
copyLabel,
|
||||
}: {
|
||||
command: string;
|
||||
copyLabel: string;
|
||||
}): ReactElement {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!copyToClipboard(command)) {
|
||||
return;
|
||||
}
|
||||
setCopied(true);
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
timerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-stretch overflow-hidden rounded-md border border-border bg-muted/40">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={command}
|
||||
className="min-w-0 flex-1 bg-transparent px-2 py-1.5 font-mono text-[11px] text-foreground outline-none"
|
||||
title={command}
|
||||
aria-label={`${copyLabel} text`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex shrink-0 items-center justify-center border-l border-border px-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={copied ? "Copied" : "Copy command"}
|
||||
aria-label={copied ? `${copyLabel} copied` : `Copy ${copyLabel}`}
|
||||
>
|
||||
{copied ? (
|
||||
<HugeiconsIcon icon={Tick02Icon} className="size-4 text-emerald-600" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Copy01Icon} className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateStudioInstructions({
|
||||
className,
|
||||
defaultShell,
|
||||
showTitle = true,
|
||||
}: {
|
||||
className?: string;
|
||||
defaultShell: UpdateShell;
|
||||
showTitle?: boolean;
|
||||
}): ReactElement {
|
||||
const [shell, setShell] = useState<UpdateShell>(defaultShell);
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const windows = shell === "windows";
|
||||
const fadeTransition = prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.16, ease: [0.165, 0.84, 0.44, 1] as const };
|
||||
const fadeInitial = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 2 };
|
||||
const fadeAnimate = { opacity: 1, y: 0 };
|
||||
const fadeExit = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: -2 };
|
||||
|
||||
useEffect(() => {
|
||||
setShell(defaultShell);
|
||||
}, [defaultShell]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3",
|
||||
showTitle ? "justify-between" : "justify-start",
|
||||
)}
|
||||
>
|
||||
{showTitle ? (
|
||||
<p className="shrink-0 whitespace-nowrap text-sm font-semibold font-heading">
|
||||
Update Unsloth Studio
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex shrink-0 items-center gap-0.5 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShell("windows")}
|
||||
className={cn(
|
||||
"px-0.5 py-0.5 font-medium transition-colors",
|
||||
windows
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-emerald-600",
|
||||
)}
|
||||
aria-pressed={windows}
|
||||
>
|
||||
Windows
|
||||
</button>
|
||||
<span className="text-border">/</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShell("unix")}
|
||||
className={cn(
|
||||
"px-0.5 py-0.5 font-medium transition-colors",
|
||||
!windows
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-emerald-600",
|
||||
)}
|
||||
aria-pressed={!windows}
|
||||
>
|
||||
macOS/Linux
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.p
|
||||
key={`instruction-${shell}`}
|
||||
initial={fadeInitial}
|
||||
animate={fadeAnimate}
|
||||
exit={fadeExit}
|
||||
transition={fadeTransition}
|
||||
className="text-xs text-muted-foreground leading-relaxed"
|
||||
>
|
||||
{getStudioUpdateInstructionLine(shell)}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
<CopyableCommand command={STUDIO_UPDATE_CMD} copyLabel="update command" />
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
If that fails or unsloth studio update is unavailable, run:
|
||||
</p>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`fallback-${shell}`}
|
||||
initial={fadeInitial}
|
||||
animate={fadeAnimate}
|
||||
exit={fadeExit}
|
||||
transition={fadeTransition}
|
||||
>
|
||||
<CopyableCommand
|
||||
command={
|
||||
windows
|
||||
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
|
||||
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
|
||||
}
|
||||
copyLabel="fallback command"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Restart Studio after updating for changes to take effect.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTourId(pathname: string): "studio" | "chat" | "export" | null {
|
||||
if (pathname === "/studio") return "studio";
|
||||
if (pathname === "/chat") return "chat";
|
||||
if (pathname === "/export") return "export";
|
||||
return null;
|
||||
}
|
||||
import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
|
||||
|
||||
export function Navbar() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
const isTrainingRunning = useTrainingRuntimeStore((s) => s.isTrainingRunning);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [mobileUpdateOpen, setMobileUpdateOpen] = useState(false);
|
||||
const [shutdownOpen, setShutdownOpen] = useState(false);
|
||||
|
||||
const deviceType = usePlatformStore((s) => s.deviceType);
|
||||
const chatOnly = usePlatformStore((s) => s.isChatOnly());
|
||||
const defaultUpdateShell = getDefaultUpdateShell(deviceType);
|
||||
|
||||
// Warn before closing the tab only when training is running (data loss risk).
|
||||
// We store the handler in a ref so removeUnloadHandler() can clean it up
|
||||
// before the "Server stopped" page renders.
|
||||
const unloadHandlerRef = useRef<((e: BeforeUnloadEvent) => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (!useTrainingRuntimeStore.getState().isTrainingRunning) return;
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
};
|
||||
unloadHandlerRef.current = handler;
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const removeUnloadHandler = () => {
|
||||
if (unloadHandlerRef.current) {
|
||||
window.removeEventListener("beforeunload", unloadHandlerRef.current);
|
||||
unloadHandlerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const tourId = getTourId(pathname);
|
||||
|
||||
const openTour = () => {
|
||||
if (!tourId) return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(TOUR_OPEN_EVENT, { detail: { id: tourId } }),
|
||||
const { isMobile } = useSidebar();
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<header className="absolute top-0 inset-x-0 z-40 h-11 pointer-events-none" />
|
||||
);
|
||||
};
|
||||
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<header className="relative top-0 z-40 h-16 w-full">
|
||||
<div className="mx-auto grid h-full max-w-7xl grid-cols-[1fr_auto_1fr] items-center px-4 sm:px-6">
|
||||
{/* Left: logo */}
|
||||
<Link to={chatOnly ? "/chat" : "/studio"} className="flex items-center gap-1.5 justify-self-start select-none">
|
||||
<img
|
||||
src="/blacklogo.png"
|
||||
alt="Unsloth"
|
||||
className="h-9 w-auto dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/whitelogo.png"
|
||||
alt="Unsloth"
|
||||
className="hidden h-9 w-auto dark:block"
|
||||
/>
|
||||
<span className="relative -top-[1px] inline-flex items-center text-[10px] font-extrabold leading-none tracking-[0.12em] text-primary">
|
||||
BETA
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Center: pill nav */}
|
||||
<nav
|
||||
data-tour="navbar"
|
||||
className="hidden items-center rounded-full border border-border bg-card p-1 ring-1 ring-foreground/5 md:flex"
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active =
|
||||
pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
const disabledByTraining =
|
||||
isTrainingRunning && item.href !== "/studio";
|
||||
const disabledByDevice =
|
||||
chatOnly && item.href !== "/chat" && item.href !== "/data-recipes";
|
||||
if (!item.enabled || disabledByTraining || disabledByDevice) {
|
||||
return (
|
||||
<span
|
||||
key={item.href}
|
||||
className="relative rounded-full px-3 py-1.5 text-sm font-medium text-muted-foreground/40 cursor-not-allowed"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={cn(
|
||||
"relative rounded-full px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "text-background"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId="nav-pill"
|
||||
className="absolute inset-0 rounded-full bg-foreground"
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
damping: 35,
|
||||
mass: 0.5,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10 flex items-center">
|
||||
<motion.span
|
||||
initial={false}
|
||||
animate={{
|
||||
width: active ? 14 : 0,
|
||||
marginLeft: active ? -4 : 0,
|
||||
marginRight: active ? 4 : 0,
|
||||
opacity: active ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: [0.165, 0.84, 0.44, 1] }}
|
||||
className="inline-flex shrink-0 items-center justify-center overflow-hidden"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={item.icon}
|
||||
className="size-3.5 -mt-px shrink-0"
|
||||
/>
|
||||
</motion.span>
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Right: docs/tour desktop — one wrapper per control so flex gap is even (HoverCard roots can confuse flex spacing). */}
|
||||
<div className="hidden items-center justify-self-end gap-0 md:flex">
|
||||
<div className="flex shrink-0 items-center">
|
||||
<AnimatedThemeToggler
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
|
||||
title="Toggle theme"
|
||||
aria-label="Toggle theme"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild={true}>
|
||||
<a
|
||||
href="https://unsloth.ai/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium text-emerald-600 transition-colors hover:bg-accent hover:text-emerald-700 dark:hover:text-emerald-400"
|
||||
>
|
||||
<HugeiconsIcon icon={Book03Icon} className="size-4" />
|
||||
Learn more
|
||||
</a>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="end" className="w-80 p-0">
|
||||
<a
|
||||
href="https://unsloth.ai/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group/card flex flex-col gap-1 p-4 no-underline"
|
||||
>
|
||||
<p className="text-sm font-semibold font-heading">
|
||||
Unsloth Documentation
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Guides on fine-tuning LLMs 2x faster with 70% less memory.
|
||||
Covers LoRA, QLoRA, data formatting, and deployment.
|
||||
</p>
|
||||
<span className="mt-1 flex items-center gap-1 text-xs font-medium text-emerald-600 group-hover/card:underline">
|
||||
Visit docs
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
|
||||
</span>
|
||||
</a>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Link
|
||||
to="/api-keys"
|
||||
className={cn(
|
||||
"flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent",
|
||||
pathname === "/api-keys"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon icon={Key01Icon} className="size-4" />
|
||||
API Keys
|
||||
</Link>
|
||||
</div>
|
||||
{tourId ? (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openTour}
|
||||
className="flex h-9 items-center gap-1.5 rounded-md px-3 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Tour"
|
||||
>
|
||||
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
|
||||
<span className="text-sm font-medium">Tour</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex shrink-0 items-center">
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
aria-label="How to update Unsloth Studio"
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowReloadHorizontalIcon} className="size-4" />
|
||||
Update
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="end" className="w-[22.5rem] p-0">
|
||||
<UpdateStudioInstructions
|
||||
className="p-4"
|
||||
defaultShell={defaultUpdateShell}
|
||||
/>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShutdownOpen(true)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Shut down Unsloth Studio server"
|
||||
aria-label="Shut down Unsloth Studio server"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: mobile */}
|
||||
<div className="col-start-3 flex items-center gap-2 justify-self-end md:hidden">
|
||||
{tourId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openTour}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Tour"
|
||||
>
|
||||
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<Sheet
|
||||
open={mobileOpen}
|
||||
onOpenChange={(open) => {
|
||||
setMobileOpen(open);
|
||||
if (!open) setMobileUpdateOpen(false);
|
||||
}}
|
||||
>
|
||||
<SheetTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground"
|
||||
aria-label="Open navigation menu"
|
||||
>
|
||||
Menu
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[300px] p-4">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Navigate</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-6 flex max-h-[calc(100dvh-8rem)] flex-col gap-2 overflow-y-auto pr-1">
|
||||
{NAV_ITEMS.filter((item) => item.enabled).map((item) => {
|
||||
const active = pathname === item.href;
|
||||
const disabledByTraining =
|
||||
isTrainingRunning && item.href !== "/studio";
|
||||
const disabledByDevice =
|
||||
chatOnly && item.href !== "/chat" && item.href !== "/data-recipes";
|
||||
if (disabledByTraining || disabledByDevice) {
|
||||
return (
|
||||
<span
|
||||
key={item.href}
|
||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground/40 cursor-not-allowed"
|
||||
>
|
||||
<HugeiconsIcon icon={item.icon} className="size-4" />
|
||||
{item.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium",
|
||||
active
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon icon={item.icon} className="size-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<Link
|
||||
to="/api-keys"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(
|
||||
"mt-3 flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium",
|
||||
pathname === "/api-keys"
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon icon={Key01Icon} className="size-4" />
|
||||
API Keys
|
||||
</Link>
|
||||
<a
|
||||
href="https://unsloth.ai/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-foreground hover:bg-accent"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<HugeiconsIcon icon={Book03Icon} className="size-4" />
|
||||
Learn more (Docs)
|
||||
</a>
|
||||
{tourId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-left text-sm font-medium text-foreground hover:bg-accent"
|
||||
onClick={() => {
|
||||
openTour();
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
|
||||
Start tour
|
||||
</button>
|
||||
) : null}
|
||||
<Collapsible
|
||||
open={mobileUpdateOpen}
|
||||
onOpenChange={setMobileUpdateOpen}
|
||||
className="rounded-md border border-border"
|
||||
>
|
||||
<CollapsibleTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
||||
aria-label="Toggle update instructions"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<HugeiconsIcon icon={ArrowReloadHorizontalIcon} className="size-4" />
|
||||
Update Unsloth Studio
|
||||
</span>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowRight01Icon}
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground transition-transform",
|
||||
mobileUpdateOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-t border-border p-3 pt-2">
|
||||
<UpdateStudioInstructions
|
||||
defaultShell={defaultUpdateShell}
|
||||
showTitle={false}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 flex items-center gap-2 rounded-md border border-border px-3 py-2 text-left text-sm font-medium text-foreground hover:bg-accent"
|
||||
onClick={() => {
|
||||
setMobileOpen(false);
|
||||
setShutdownOpen(true);
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-5" />
|
||||
Quit Unsloth Studio
|
||||
</button>
|
||||
<div className="mt-2 flex items-center justify-between rounded-md border border-border px-3 py-2">
|
||||
<span className="text-sm font-medium text-foreground">Theme</span>
|
||||
<AnimatedThemeToggler
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
|
||||
title="Toggle theme"
|
||||
aria-label="Toggle theme"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
<header className="absolute top-0 inset-x-0 z-40 h-11 pointer-events-none">
|
||||
<div className="flex h-full items-center pl-2">
|
||||
<SidebarTrigger className="pointer-events-auto" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ShutdownDialog
|
||||
open={shutdownOpen}
|
||||
onOpenChange={setShutdownOpen}
|
||||
onBeforeShutdown={removeUnloadHandler}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,16 +18,17 @@ import {
|
|||
interface ShutdownDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Called right before the shutdown API request so callers can remove the
|
||||
* beforeunload listener — otherwise the "Server stopped" page would still
|
||||
* trigger a "Leave site?" prompt when the user tries to close it. */
|
||||
onBeforeShutdown?: () => void;
|
||||
/** Called after the shutdown API returns success, right before we replace
|
||||
* document.body with the "Server stopped" page. Callers use this to remove
|
||||
* their beforeunload listener — otherwise the browser would prompt
|
||||
* "Leave site?" when the user tries to close the final tab. */
|
||||
onAfterShutdown?: () => void;
|
||||
}
|
||||
|
||||
export function ShutdownDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onBeforeShutdown,
|
||||
onAfterShutdown,
|
||||
}: ShutdownDialogProps) {
|
||||
const [stopping, setStopping] = useState(false);
|
||||
|
||||
|
|
@ -49,7 +50,7 @@ export function ShutdownDialog({
|
|||
return;
|
||||
}
|
||||
|
||||
onBeforeShutdown?.();
|
||||
onAfterShutdown?.();
|
||||
document.body.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;gap:12px">
|
||||
<p style="font-size:1.1rem;font-weight:600;margin:0">Unsloth Studio has stopped.</p>
|
||||
|
|
|
|||
|
|
@ -6,11 +6,73 @@ import { Moon, Sun } from "lucide-react"
|
|||
import { flushSync } from "react-dom"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { setTheme } from "@/features/settings/stores/theme-store"
|
||||
|
||||
interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> {
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export function useAnimatedThemeToggle(duration = 400) {
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
const anchorRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const updateTheme = () => {
|
||||
setIsDark(document.documentElement.classList.contains("dark"))
|
||||
}
|
||||
updateTheme()
|
||||
const observer = new MutationObserver(updateTheme)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
})
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const toggleTheme = useCallback(async () => {
|
||||
const anchor = anchorRef.current
|
||||
const applyTheme = () => {
|
||||
flushSync(() => {
|
||||
const newTheme = !isDark
|
||||
setIsDark(newTheme)
|
||||
setTheme(newTheme ? "dark" : "light")
|
||||
})
|
||||
}
|
||||
|
||||
if (!document.startViewTransition) {
|
||||
applyTheme()
|
||||
return
|
||||
}
|
||||
|
||||
await document.startViewTransition(applyTheme).ready
|
||||
|
||||
if (anchor) {
|
||||
const { top, left, width, height } = anchor.getBoundingClientRect()
|
||||
const x = left + width / 2
|
||||
const y = top + height / 2
|
||||
const maxRadius = Math.hypot(
|
||||
Math.max(left, window.innerWidth - left),
|
||||
Math.max(top, window.innerHeight - top)
|
||||
)
|
||||
document.documentElement.animate(
|
||||
{
|
||||
clipPath: [
|
||||
`circle(0px at ${x}px ${y}px)`,
|
||||
`circle(${maxRadius}px at ${x}px ${y}px)`,
|
||||
],
|
||||
},
|
||||
{
|
||||
duration,
|
||||
easing: "ease-in-out",
|
||||
pseudoElement: "::view-transition-new(root)",
|
||||
}
|
||||
)
|
||||
}
|
||||
}, [isDark, duration])
|
||||
|
||||
return { isDark, toggleTheme, anchorRef }
|
||||
}
|
||||
|
||||
export const AnimatedThemeToggler = ({
|
||||
className,
|
||||
duration = 400,
|
||||
|
|
@ -38,14 +100,20 @@ export const AnimatedThemeToggler = ({
|
|||
const toggleTheme = useCallback(async () => {
|
||||
if (!buttonRef.current) return
|
||||
|
||||
await document.startViewTransition(() => {
|
||||
const apply = () => {
|
||||
flushSync(() => {
|
||||
const newTheme = !isDark
|
||||
setIsDark(newTheme)
|
||||
document.documentElement.classList.toggle("dark")
|
||||
localStorage.setItem("theme", newTheme ? "dark" : "light")
|
||||
setTheme(newTheme ? "dark" : "light")
|
||||
})
|
||||
}).ready
|
||||
}
|
||||
|
||||
if (!document.startViewTransition) {
|
||||
apply()
|
||||
return
|
||||
}
|
||||
|
||||
await document.startViewTransition(apply).ready
|
||||
|
||||
const { top, left, width, height } =
|
||||
buttonRef.current.getBoundingClientRect()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
|
|
@ -39,12 +39,14 @@ function CommandDialog({
|
|||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
overlayClassName,
|
||||
showCloseButton = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
overlayClassName?: string;
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
|
|
@ -55,9 +57,10 @@ function CommandDialog({
|
|||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"rounded-4xl! p-0 top-1/3 translate-y-0 overflow-hidden p-0",
|
||||
"rounded-4xl! top-1/3 translate-y-0 overflow-hidden p-0",
|
||||
className,
|
||||
)}
|
||||
overlayClassName={overlayClassName}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
|
@ -28,10 +28,9 @@ import { useIsMobile } from "@/hooks/use-mobile"
|
|||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { SidebarLeftIcon } from "@hugeicons/core-free-icons"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const noop = () => {}
|
||||
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
|
|
@ -43,6 +42,10 @@ type SidebarContextProps = {
|
|||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
hasPinMode: boolean
|
||||
pinned: boolean
|
||||
setPinned: (value: boolean) => void
|
||||
togglePinned: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
|
@ -60,6 +63,9 @@ function SidebarProvider({
|
|||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
pinned: pinnedProp,
|
||||
setPinned: setPinnedProp,
|
||||
togglePinned: togglePinnedProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
|
|
@ -68,33 +74,57 @@ function SidebarProvider({
|
|||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
pinned?: boolean
|
||||
setPinned?: (value: boolean) => void
|
||||
togglePinned?: () => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
const prevIsMobileRef = React.useRef(isMobile)
|
||||
React.useEffect(() => {
|
||||
if (prevIsMobileRef.current && !isMobile) {
|
||||
setOpenMobile(false)
|
||||
}
|
||||
prevIsMobileRef.current = isMobile
|
||||
}, [isMobile])
|
||||
|
||||
// Whether pin mode is active (caller provides pinned + setPinned + togglePinned).
|
||||
const hasPinMode = pinnedProp !== undefined && setPinnedProp !== undefined && togglePinnedProp !== undefined
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
|
||||
// When pin mode is active, open is driven entirely by `pinned` (explicit
|
||||
// user toggle). Otherwise fall back to the controlled/uncontrolled pattern.
|
||||
const open = hasPinMode ? !!pinnedProp : (openProp ?? _open)
|
||||
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
|
||||
if (hasPinMode) {
|
||||
// In pin mode, setOpen controls pinned state.
|
||||
setPinnedProp?.(openState)
|
||||
return
|
||||
}
|
||||
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
[setOpenProp, open, hasPinMode, setPinnedProp]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
if (isMobile) return setOpenMobile((open) => !open)
|
||||
if (hasPinMode && togglePinnedProp) return togglePinnedProp()
|
||||
return setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile, hasPinMode, togglePinnedProp])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
|
|
@ -116,6 +146,10 @@ function SidebarProvider({
|
|||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const pinned = pinnedProp ?? false
|
||||
const setPinned = setPinnedProp ?? noop
|
||||
const togglePinned = togglePinnedProp ?? noop
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
|
|
@ -125,8 +159,12 @@ function SidebarProvider({
|
|||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
hasPinMode,
|
||||
pinned,
|
||||
setPinned,
|
||||
togglePinned,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, hasPinMode, pinned, setPinned, togglePinned]
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -165,7 +203,7 @@ function Sidebar({
|
|||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
const { isMobile, state, openMobile, setOpenMobile, hasPinMode, pinned } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
|
|
@ -190,12 +228,7 @@ function Sidebar({
|
|||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className="bg-sidebar text-sidebar-foreground w-2/3 max-w-[18rem] p-0 [&>button]:hidden"
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
|
|
@ -210,7 +243,11 @@ function Sidebar({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
className={cn(
|
||||
"group peer text-sidebar-foreground relative shrink-0",
|
||||
hasPinMode && pinned && "w-(--sidebar-width)",
|
||||
hasPinMode && !pinned && "w-(--sidebar-width-icon)",
|
||||
)}
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
|
|
@ -221,23 +258,45 @@ function Sidebar({
|
|||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"transition-[width] duration-200 ease-linear relative w-(--sidebar-width) bg-transparent",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"relative bg-transparent shrink-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
hasPinMode
|
||||
? cn(
|
||||
// Pin mode: always push content. Expanded when pinned.
|
||||
pinned
|
||||
? "w-(--sidebar-width)"
|
||||
: (variant === "floating" || variant === "inset"
|
||||
? "w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "w-(--sidebar-width-icon)"),
|
||||
)
|
||||
: cn(
|
||||
// Legacy mode: original shadcn behavior.
|
||||
"w-(--sidebar-width)",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||
),
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
hasPinMode
|
||||
? cn(
|
||||
// Pin mode: always push content, full height.
|
||||
"absolute top-0 bottom-0 flex w-(--sidebar-width) data-[side=left]:left-0",
|
||||
"group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||
)
|
||||
: cn(
|
||||
// Legacy mode: fixed to viewport (original shadcn behavior).
|
||||
"fixed inset-y-0 z-10 flex h-svh w-(--sidebar-width) data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
),
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
: !hasPinMode && "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -245,7 +304,11 @@ function Sidebar({
|
|||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:ring-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 flex size-full flex-col"
|
||||
className={cn(
|
||||
"bg-sidebar flex size-full flex-col overflow-hidden border-r border-sidebar-border",
|
||||
"group-data-[variant=floating]:ring-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1",
|
||||
hasPinMode && "ring-1 ring-sidebar-border/60",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
@ -310,7 +373,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
|||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 relative flex w-full flex-1 flex-col",
|
||||
"bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 relative flex min-h-0 w-full flex-1 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -374,7 +437,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"no-scrollbar gap-2 flex min-h-0 flex-1 flex-col overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
"gap-2 flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden group-data-[collapsible=icon]:overflow-hidden [&>*]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -408,7 +471,7 @@ function SidebarGroupLabel({
|
|||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring h-8 rounded-md px-2 text-xs font-medium transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0",
|
||||
"text-[#94a3b8] dark:text-[#64748b] ring-sidebar-ring h-auto pt-3 pb-2 px-4 rounded-md text-[10px] font-semibold uppercase tracking-[0.08em] group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-3 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -455,7 +518,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
|||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("gap-1 flex w-full min-w-0 flex-col", className)}
|
||||
className={cn("gap-0.5 flex w-full min-w-0 flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
@ -473,7 +536,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
|||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-lg corner-squircle p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium peer/menu-button flex w-full items-center overflow-hidden outline-hidden group/menu-button disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm cursor-pointer transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:w-full! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium peer/menu-button flex w-full items-center overflow-hidden outline-hidden group/menu-button disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate group-data-[collapsible=icon]:[&>span]:hidden [&_svg]:size-4 [&_svg]:shrink-0 group-data-[collapsible=icon]:[&_svg]:size-5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
|
|
|||
|
|
@ -1,426 +0,0 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyToClipboardAsync } from "@/lib/copy-to-clipboard";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
Copy01Icon,
|
||||
Delete02Icon,
|
||||
Key01Icon,
|
||||
Tick02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { authFetch } from "./api";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ApiKey {
|
||||
id: number;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
expires_at: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchApiKeys(): Promise<ApiKey[]> {
|
||||
const res = await authFetch("/api/auth/api-keys");
|
||||
if (!res.ok) throw new Error("Failed to load API keys");
|
||||
const data = (await res.json()) as { api_keys: ApiKey[] };
|
||||
return data.api_keys;
|
||||
}
|
||||
|
||||
async function createApiKey(
|
||||
name: string,
|
||||
expiresInDays: number | null,
|
||||
): Promise<{ key: string; api_key: ApiKey }> {
|
||||
const res = await authFetch("/api/auth/api-keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
expires_in_days: expiresInDays,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create API key");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function revokeApiKey(keyId: number): Promise<void> {
|
||||
const res = await authFetch(`/api/auth/api-keys/${keyId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to revoke API key");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return "--";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!(await copyToClipboardAsync(text))) return;
|
||||
setCopied(true);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md text-muted-foreground hover:text-foreground",
|
||||
copied && "text-emerald-600 hover:text-emerald-600",
|
||||
)}
|
||||
aria-label={copied ? "Copied API key" : "Copy API key"}
|
||||
title={copied ? "Copied" : "Copy"}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={copied ? Tick02Icon : Copy01Icon}
|
||||
className="size-4"
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function RevealKeyDialog({
|
||||
open,
|
||||
rawKey,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
rawKey: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>API Key Created</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy this key now. It will not be shown again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 p-3">
|
||||
<code className="min-w-0 flex-1 break-all font-mono text-sm">
|
||||
{rawKey}
|
||||
</code>
|
||||
<CopyButton text={rawKey} />
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-500/20 bg-amber-50 p-3 text-amber-800 dark:border-amber-400/20 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
<HugeiconsIcon icon={AlertCircleIcon} className="mt-0.5 size-4 shrink-0" />
|
||||
<p className="text-xs leading-relaxed">
|
||||
Store this key securely. You will not be able to see it again after closing this dialog.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateKeyForm({ onCreated }: { onCreated: (rawKey: string) => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [expiresInDays, setExpiresInDays] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const days = expiresInDays ? parseInt(expiresInDays, 10) : null;
|
||||
const result = await createApiKey(name.trim(), days);
|
||||
onCreated(result.key);
|
||||
setName("");
|
||||
setExpiresInDays("");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 rounded-lg border border-border p-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="key-name">Key name</Label>
|
||||
<Input
|
||||
id="key-name"
|
||||
placeholder="e.g. My application"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="key-expiry">Expires in (days)</Label>
|
||||
<Input
|
||||
id="key-expiry"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Leave blank for no expiry"
|
||||
value={expiresInDays}
|
||||
onChange={(e) => setExpiresInDays(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading || !name.trim()} className="self-start">
|
||||
{loading ? "Creating..." : "Create API key"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function KeysTable({
|
||||
keys,
|
||||
onRevoke,
|
||||
}: {
|
||||
keys: ApiKey[];
|
||||
onRevoke: (id: number) => void;
|
||||
}) {
|
||||
if (keys.length === 0) {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No API keys yet. Create one above.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40">
|
||||
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Name</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Key</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Created</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Last used</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Expires</th>
|
||||
<th className="px-4 py-2.5 text-right font-medium text-muted-foreground" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((k) => (
|
||||
<tr
|
||||
key={k.id}
|
||||
className={cn(
|
||||
"border-b border-border last:border-b-0",
|
||||
!k.is_active && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-2.5 font-medium">{k.name}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
sk-unsloth-{k.key_prefix}...
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground">{formatDate(k.created_at)}</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground">{formatDate(k.last_used_at)}</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground">{formatDate(k.expires_at)}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
{k.is_active ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRevoke(k.id)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-3.5 mr-1" />
|
||||
Revoke
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Revoked</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageExamples() {
|
||||
const base = window.location.origin;
|
||||
|
||||
const curlExample = `curl ${base}/v1/chat/completions \\
|
||||
-H "Authorization: Bearer sk-unsloth-YOUR_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"stream": true
|
||||
}'`;
|
||||
|
||||
const pythonExample = `from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="${base}/v1",
|
||||
api_key="sk-unsloth-YOUR_KEY",
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="current",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
stream=True,
|
||||
)
|
||||
for chunk in response:
|
||||
print(chunk.choices[0].delta.content or "", end="")`;
|
||||
|
||||
const toolsExample = `curl ${base}/v1/chat/completions \\
|
||||
-H "Authorization: Bearer sk-unsloth-YOUR_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"messages": [{"role": "user", "content": "Search for Python 3.13 features"}],
|
||||
"stream": true,
|
||||
"enable_tools": true,
|
||||
"enabled_tools": ["web_search", "python"],
|
||||
"session_id": "my-session"
|
||||
}'`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-sm font-semibold">Usage examples</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-medium text-muted-foreground">curl</p>
|
||||
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs leading-relaxed">
|
||||
{curlExample}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-medium text-muted-foreground">Python (OpenAI SDK)</p>
|
||||
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs leading-relaxed">
|
||||
{pythonExample}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-medium text-muted-foreground">With tools (web search + code execution)</p>
|
||||
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs leading-relaxed">
|
||||
{toolsExample}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ApiKeysPage() {
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [revealedKey, setRevealedKey] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadKeys = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const loaded = await fetchApiKeys();
|
||||
setKeys(loaded);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load API keys");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadKeys();
|
||||
}, [loadKeys]);
|
||||
|
||||
const handleCreated = (rawKey: string) => {
|
||||
setRevealedKey(rawKey);
|
||||
void loadKeys();
|
||||
};
|
||||
|
||||
const handleRevoke = async (keyId: number) => {
|
||||
try {
|
||||
await revokeApiKey(keyId);
|
||||
void loadKeys();
|
||||
} catch {
|
||||
setError("Failed to revoke key");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-border bg-muted/40">
|
||||
<HugeiconsIcon icon={Key01Icon} className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold font-heading">API Keys</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create keys to access Unsloth Studio programmatically via the OpenAI-compatible API.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
<HugeiconsIcon icon={AlertCircleIcon} className="size-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateKeyForm onCreated={handleCreated} />
|
||||
<KeysTable keys={keys} onRevoke={handleRevoke} />
|
||||
<UsageExamples />
|
||||
</div>
|
||||
|
||||
<RevealKeyDialog
|
||||
open={revealedKey !== null}
|
||||
rawKey={revealedKey ?? ""}
|
||||
onClose={() => setRevealedKey(null)}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -262,6 +262,12 @@ export interface BrowseFoldersResponse {
|
|||
model_files_here?: number;
|
||||
}
|
||||
|
||||
export async function listRecommendedFolders(): Promise<string[]> {
|
||||
const response = await authFetch("/api/models/recommended-folders");
|
||||
const data = await parseJsonOrThrow<{ folders: string[] }>(response);
|
||||
return data.folders;
|
||||
}
|
||||
|
||||
export async function browseFolders(
|
||||
path?: string,
|
||||
showHidden = false,
|
||||
|
|
|
|||
|
|
@ -7,36 +7,16 @@ import {
|
|||
ModelSelector,
|
||||
} from "@/components/assistant-ui/model-selector";
|
||||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import {
|
||||
ColumnInsertIcon,
|
||||
PencilEdit02Icon,
|
||||
Settings04Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
|
|
@ -45,6 +25,7 @@ import {
|
|||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ChatSearch } from "@/app/routes/chat";
|
||||
import { listLocalModels } from "./api/chat-api";
|
||||
import { ChatSettingsPanel } from "./chat-settings-sheet";
|
||||
import { ContextUsageBar } from "./components/context-usage-bar";
|
||||
|
|
@ -63,7 +44,6 @@ import {
|
|||
SharedComposer,
|
||||
} from "./shared-composer";
|
||||
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
|
||||
import { ThreadSidebar } from "./thread-sidebar";
|
||||
import { buildChatTourSteps } from "./tour";
|
||||
import type { ChatView, MessageRecord } from "./types";
|
||||
|
||||
|
|
@ -135,7 +115,7 @@ const SingleContent = memo(function SingleContent({
|
|||
initialThreadId={threadId}
|
||||
newThreadNonce={newThreadNonce}
|
||||
>
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
|
||||
<Thread />
|
||||
</div>
|
||||
</ChatRuntimeProvider>
|
||||
|
|
@ -223,7 +203,7 @@ const LoraCompareContent = memo(function LoraCompareContent({
|
|||
Base Model
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ChatRuntimeProvider
|
||||
modelType="base"
|
||||
pairId={pairId}
|
||||
|
|
@ -231,7 +211,9 @@ const LoraCompareContent = memo(function LoraCompareContent({
|
|||
syncActiveThreadId={false}
|
||||
>
|
||||
<RegisterCompareHandle name="base" />
|
||||
<Thread hideComposer={true} hideWelcome={true} />
|
||||
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
|
||||
<Thread hideComposer={true} hideWelcome={true} />
|
||||
</div>
|
||||
</ChatRuntimeProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -241,7 +223,7 @@ const LoraCompareContent = memo(function LoraCompareContent({
|
|||
Fine-tuned
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ChatRuntimeProvider
|
||||
modelType="lora"
|
||||
pairId={pairId}
|
||||
|
|
@ -249,12 +231,14 @@ const LoraCompareContent = memo(function LoraCompareContent({
|
|||
syncActiveThreadId={false}
|
||||
>
|
||||
<RegisterCompareHandle name="lora" />
|
||||
<Thread hideComposer={true} hideWelcome={true} />
|
||||
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
|
||||
<Thread hideComposer={true} hideWelcome={true} />
|
||||
</div>
|
||||
</ChatRuntimeProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-20 mx-auto w-full max-w-4xl shrink-0 border-t border-border/60 bg-background px-4 pt-2 pb-4">
|
||||
<div className="z-20 mx-auto w-full max-w-4xl shrink-0 bg-background px-4 pt-2 pb-4">
|
||||
<SharedComposer handlesRef={handlesRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -322,10 +306,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
className="grid min-h-0 flex-1 grid-cols-1 px-0 md:grid-cols-2"
|
||||
>
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Model 1
|
||||
</span>
|
||||
<div className="flex h-11 shrink-0 items-center gap-2 px-3">
|
||||
<ModelSelector
|
||||
models={models}
|
||||
loraModels={loraModels}
|
||||
|
|
@ -340,10 +321,10 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
onFoldersChange={onFoldersChange}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="max-w-[50%]"
|
||||
className="max-w-[80%]"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ChatRuntimeProvider
|
||||
modelType="model1"
|
||||
pairId={pairId}
|
||||
|
|
@ -351,15 +332,14 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
syncActiveThreadId={false}
|
||||
>
|
||||
<RegisterCompareHandle name="model1" />
|
||||
<Thread hideComposer={true} hideWelcome={true} />
|
||||
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
|
||||
<Thread hideComposer={true} hideWelcome={true} />
|
||||
</div>
|
||||
</ChatRuntimeProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col border-t border-border/60 md:border-t-0 md:border-l">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 md:justify-end">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-primary">
|
||||
Model 2
|
||||
</span>
|
||||
<div className="flex min-h-0 flex-col border-t border-sidebar-border md:border-t-0 md:border-l">
|
||||
<div className="flex h-11 shrink-0 items-center gap-2 px-3">
|
||||
<ModelSelector
|
||||
models={models}
|
||||
loraModels={loraModels}
|
||||
|
|
@ -374,10 +354,10 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
onFoldersChange={onFoldersChange}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="max-w-[50%]"
|
||||
className="max-w-[80%]"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ChatRuntimeProvider
|
||||
modelType="model2"
|
||||
pairId={pairId}
|
||||
|
|
@ -385,12 +365,14 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
syncActiveThreadId={false}
|
||||
>
|
||||
<RegisterCompareHandle name="model2" />
|
||||
<Thread hideComposer={true} hideWelcome={true} />
|
||||
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
|
||||
<Thread hideComposer={true} hideWelcome={true} />
|
||||
</div>
|
||||
</ChatRuntimeProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-20 mx-auto w-full max-w-4xl shrink-0 border-t border-border/60 bg-background px-4 pt-2 pb-4">
|
||||
<div className="z-20 mx-auto w-full max-w-4xl shrink-0 bg-background px-4 pt-2 pb-4">
|
||||
<SharedComposer
|
||||
handlesRef={handlesRef}
|
||||
model1={model1}
|
||||
|
|
@ -402,110 +384,19 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
);
|
||||
});
|
||||
|
||||
function InlineSidebar({
|
||||
children,
|
||||
side = "left",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
side?: "left" | "right";
|
||||
}) {
|
||||
const { state, isMobile, openMobile, setOpenMobile } = useSidebar();
|
||||
const collapsed = state === "collapsed";
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile}>
|
||||
<SheetContent side={side} className="w-[18rem] p-0">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Chat sidebar</SheetTitle>
|
||||
<SheetDescription>Chat threads and actions</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="h-full overflow-auto">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group shrink-0 h-full pb-3.5"
|
||||
data-state={state}
|
||||
data-collapsible={collapsed ? "offcanvas" : ""}
|
||||
data-side={side}
|
||||
>
|
||||
<aside
|
||||
data-sidebar="sidebar"
|
||||
className={cn(
|
||||
"bg-muted/70 text-sidebar-foreground h-full overflow-hidden rounded-2xl corner-squircle transition-[width] duration-200 ease-linear",
|
||||
!collapsed && side === "right" && "border-l border-sidebar-border/70",
|
||||
collapsed ? "w-0" : "w-(--sidebar-width)",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full w-(--sidebar-width) flex-col">
|
||||
{children}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBarActions({
|
||||
onNewThread,
|
||||
onNewCompare,
|
||||
showCompare,
|
||||
}: {
|
||||
onNewThread: () => void;
|
||||
onNewCompare: () => void;
|
||||
showCompare: boolean;
|
||||
}) {
|
||||
const { state } = useSidebar();
|
||||
if (state !== "collapsed") {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<Button variant="ghost" size="icon-sm" onClick={onNewThread}>
|
||||
<HugeiconsIcon icon={PencilEdit02Icon} strokeWidth={2} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">New Chat</TooltipContent>
|
||||
</Tooltip>
|
||||
{showCompare ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<Button variant="ghost" size="icon-sm" onClick={onNewCompare}>
|
||||
<HugeiconsIcon icon={ColumnInsertIcon} strokeWidth={2} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Compare</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getInitialSingleChatView(): ChatView {
|
||||
const id = useChatRuntimeStore.getState().activeThreadId;
|
||||
if (typeof id === "string" && id.length > 0 && !id.startsWith("__LOCALID_")) {
|
||||
return { mode: "single", threadId: id };
|
||||
}
|
||||
return { mode: "single" };
|
||||
}
|
||||
|
||||
export function ChatPage(): ReactElement {
|
||||
// Do not set newThreadNonce here: each /chat mount would run ThreadNewChatSwitch
|
||||
// and create spurious threads when navigating (e.g. Recipes / Export). New Chat
|
||||
// explicitly sets a nonce in handleNewThread.
|
||||
const [view, setView] = useState<ChatView>(getInitialSingleChatView);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const search = useSearch({ from: "/chat" });
|
||||
const navigate = useNavigate();
|
||||
|
||||
const settingsOpen = useChatRuntimeStore((s) => s.settingsPanelOpen);
|
||||
const setSettingsOpen = useChatRuntimeStore((s) => s.setSettingsPanelOpen);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setSettingsOpen(false);
|
||||
}, [setSettingsOpen]);
|
||||
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
|
||||
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [viewBeforeCompare, setViewBeforeCompare] = useState<ChatView | null>(
|
||||
null,
|
||||
);
|
||||
const viewBeforeCompareRef = useRef<ChatSearch | null>(null);
|
||||
const inferenceParams = useChatRuntimeStore((state) => state.params);
|
||||
const setInferenceParams = useChatRuntimeStore((state) => state.setParams);
|
||||
const activeGgufVariant = useChatRuntimeStore(
|
||||
|
|
@ -515,8 +406,6 @@ export function ChatPage(): ReactElement {
|
|||
(state) => state.ggufContextLength,
|
||||
);
|
||||
const contextUsage = useChatRuntimeStore((state) => state.contextUsage);
|
||||
const autoTitle = useChatRuntimeStore((state) => state.autoTitle);
|
||||
const setAutoTitle = useChatRuntimeStore((state) => state.setAutoTitle);
|
||||
const modelsFromStore = useChatRuntimeStore((state) => state.models);
|
||||
const lorasFromStore = useChatRuntimeStore((state) => state.loras);
|
||||
const modelsError = useChatRuntimeStore((state) => state.modelsError);
|
||||
|
|
@ -541,6 +430,27 @@ export function ChatPage(): ReactElement {
|
|||
return Boolean(inferenceParams.checkpoint);
|
||||
}, [inferenceParams.checkpoint]);
|
||||
|
||||
// Derive view from URL search params
|
||||
const view = useMemo<ChatView>(() => {
|
||||
if (search.compare) {
|
||||
return {
|
||||
mode: "compare",
|
||||
pairId:
|
||||
search.compare,
|
||||
};
|
||||
}
|
||||
if (search.thread) {
|
||||
return { mode: "single", threadId: search.thread };
|
||||
}
|
||||
if (activeThreadId && !activeThreadId.startsWith("__LOCALID_")) {
|
||||
return { mode: "single", threadId: activeThreadId };
|
||||
}
|
||||
if (search.new) {
|
||||
return { mode: "single", newThreadNonce: search.new };
|
||||
}
|
||||
return { mode: "single" };
|
||||
}, [search.thread, search.compare, search.new, activeThreadId]);
|
||||
|
||||
const handleCheckpointChange = useCallback(
|
||||
(
|
||||
value: string,
|
||||
|
|
@ -601,28 +511,6 @@ export function ChatPage(): ReactElement {
|
|||
const handleEject = useCallback(() => {
|
||||
void ejectModel();
|
||||
}, [ejectModel]);
|
||||
const handleNewThread = useCallback(() => {
|
||||
// Skip if we are already on a fresh unsaved draft with no messages sent.
|
||||
// Once the user sends a message, append() sets activeThreadId in the store,
|
||||
// so we check the store to know whether the current draft has been sent.
|
||||
if (
|
||||
view.mode === "single" &&
|
||||
!view.threadId &&
|
||||
!useChatRuntimeStore.getState().activeThreadId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
useChatRuntimeStore.getState().setActiveThreadId(null);
|
||||
setView({ mode: "single", newThreadNonce: crypto.randomUUID() });
|
||||
}, [view]);
|
||||
const handleNewCompare = useCallback(() => {
|
||||
setView({ mode: "compare", pairId: crypto.randomUUID() });
|
||||
// Clear activeThreadId so compare panes do not inherit the single-chat
|
||||
// thread ID as a fallback for session_id routing.
|
||||
useChatRuntimeStore.getState().setActiveThreadId(null);
|
||||
useChatRuntimeStore.getState().setContextUsage(null);
|
||||
}, []);
|
||||
|
||||
const openModelSelector = useCallback(() => {
|
||||
setModelSelectorLocked(true);
|
||||
|
|
@ -641,30 +529,26 @@ export function ChatPage(): ReactElement {
|
|||
},
|
||||
[modelSelectorLocked],
|
||||
);
|
||||
const openSettings = useCallback(() => setSettingsOpen(true), []);
|
||||
const closeSettings = useCallback(() => setSettingsOpen(false), []);
|
||||
const openSidebar = useCallback(() => setSidebarOpen(true), []);
|
||||
const openSettings = useCallback(() => setSettingsOpen(true), [setSettingsOpen]);
|
||||
const closeSettings = useCallback(() => setSettingsOpen(false), [setSettingsOpen]);
|
||||
const { setPinned, isMobile } = useSidebar();
|
||||
const openSidebar = useCallback(() => setPinned(true), [setPinned]);
|
||||
|
||||
const enterCompare = useCallback(() => {
|
||||
setViewBeforeCompare((prev) => prev ?? view);
|
||||
setView({ mode: "compare", pairId: crypto.randomUUID() });
|
||||
// Clear activeThreadId so compare panes do not inherit the single-chat
|
||||
// thread ID as a fallback for session_id routing.
|
||||
viewBeforeCompareRef.current = { ...search };
|
||||
useChatRuntimeStore.getState().setActiveThreadId(null);
|
||||
useChatRuntimeStore.getState().setContextUsage(null);
|
||||
}, [view]);
|
||||
navigate({ to: "/chat", search: { compare: crypto.randomUUID() } });
|
||||
}, [navigate, search]);
|
||||
|
||||
const exitCompare = useCallback(() => {
|
||||
if (!viewBeforeCompare) return;
|
||||
setView(viewBeforeCompare);
|
||||
setViewBeforeCompare(null);
|
||||
const saved = viewBeforeCompareRef.current;
|
||||
if (!saved) return;
|
||||
viewBeforeCompareRef.current = null;
|
||||
navigate({ to: "/chat", search: saved });
|
||||
// Restore context usage from the active thread's last assistant message.
|
||||
// Use the thread ID from the saved view rather than the store, because
|
||||
// activeThreadId may have been cleared on compare entry.
|
||||
const store = useChatRuntimeStore.getState();
|
||||
const threadId =
|
||||
("threadId" in viewBeforeCompare ? viewBeforeCompare.threadId : null) ??
|
||||
store.activeThreadId;
|
||||
saved.thread ?? useChatRuntimeStore.getState().activeThreadId;
|
||||
if (threadId) {
|
||||
void db.messages
|
||||
.where("threadId")
|
||||
|
|
@ -672,18 +556,12 @@ export function ChatPage(): ReactElement {
|
|||
.reverse()
|
||||
.first()
|
||||
.then((msg) => {
|
||||
const saved = msg?.metadata as Record<string, unknown> | undefined;
|
||||
const usage = saved?.contextUsage as
|
||||
| typeof store.contextUsage
|
||||
| undefined;
|
||||
if (usage) store.setContextUsage(usage);
|
||||
const metadata = msg?.metadata as Record<string, unknown> | undefined;
|
||||
const usage = metadata?.contextUsage as ReturnType<typeof useChatRuntimeStore.getState>["contextUsage"];
|
||||
if (usage) useChatRuntimeStore.getState().setContextUsage(usage);
|
||||
});
|
||||
}
|
||||
}, [viewBeforeCompare]);
|
||||
|
||||
const handleThreadSelect = useCallback((nextView: ChatView) => {
|
||||
setView(nextView);
|
||||
}, []);
|
||||
}, [navigate]);
|
||||
|
||||
const models = useMemo<ModelOption[]>(
|
||||
() =>
|
||||
|
|
@ -727,7 +605,7 @@ export function ChatPage(): ReactElement {
|
|||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
}, [navigate]);
|
||||
|
||||
const loraModels = useMemo<LoraModelOption[]>(() => {
|
||||
const fromLoras = lorasFromStore.map((lora) => ({
|
||||
|
|
@ -771,9 +649,9 @@ export function ChatPage(): ReactElement {
|
|||
});
|
||||
await selectModelRef.current({ id: targetLora.id, isLora: true });
|
||||
if (canceled) return;
|
||||
setView({ mode: "compare", pairId: crypto.randomUUID() });
|
||||
useChatRuntimeStore.getState().setActiveThreadId(null);
|
||||
useChatRuntimeStore.getState().setContextUsage(null);
|
||||
navigate({ to: "/chat", search: { compare: crypto.randomUUID() } });
|
||||
clearHandoff();
|
||||
console.info("[chat-handoff] loaded lora + opened compare");
|
||||
return;
|
||||
|
|
@ -851,39 +729,18 @@ export function ChatPage(): ReactElement {
|
|||
}, [modelSelectorLocked, tour.open]);
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100dvh-4rem)] bg-background overflow-hidden">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 basis-0 bg-background overflow-hidden">
|
||||
<GuidedTour {...tour.tourProps} />
|
||||
<SidebarProvider
|
||||
defaultOpen={true}
|
||||
open={sidebarOpen}
|
||||
onOpenChange={setSidebarOpen}
|
||||
className="!min-h-0 h-full w-full max-w-7xl mx-auto px-2 sm:px-4"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "14rem",
|
||||
"--sidebar-width-icon": "3rem",
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<InlineSidebar>
|
||||
<ThreadSidebar
|
||||
view={view}
|
||||
onSelect={handleThreadSelect}
|
||||
onNewThread={handleNewThread}
|
||||
onNewCompare={handleNewCompare}
|
||||
showCompare={canCompare}
|
||||
/>
|
||||
</InlineSidebar>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div className="flex h-11 shrink-0 items-center px-1.5 sm:px-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarTrigger />
|
||||
<TopBarActions
|
||||
onNewThread={handleNewThread}
|
||||
onNewCompare={handleNewCompare}
|
||||
showCompare={canCompare}
|
||||
/>
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 right-2 z-30 flex h-11 shrink-0 items-center pr-2 bg-background",
|
||||
isMobile ? "pl-12 pr-1.5" : "pl-2",
|
||||
view.mode === "compare" && "right-2 left-auto w-auto bg-transparent pl-0 pr-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{view.mode !== "compare" && (
|
||||
<ModelSelector
|
||||
models={models}
|
||||
loraModels={loraModels}
|
||||
|
|
@ -899,92 +756,90 @@ export function ChatPage(): ReactElement {
|
|||
contentDataTour="chat-model-selector-popover"
|
||||
className="max-w-[62vw] sm:max-w-none"
|
||||
/>
|
||||
{loadingModel && loadToastDismissed ? (
|
||||
<ModelLoadInlineStatus
|
||||
label={
|
||||
loadProgress?.phase === "starting"
|
||||
? "Starting model…"
|
||||
: loadingModel.isDownloaded || loadingModel.isCachedLora
|
||||
? "Loading model…"
|
||||
: "Downloading model…"
|
||||
}
|
||||
title={
|
||||
loadingModel.isDownloaded
|
||||
? `Loading ${loadingModel.displayName} from cache.`
|
||||
: loadingModel.isCachedLora
|
||||
? `Loading ${loadingModel.displayName} into memory.`
|
||||
: `Loading ${loadingModel.displayName}. This may include downloading.`
|
||||
}
|
||||
progressPercent={loadProgress?.percent}
|
||||
progressLabel={loadProgress?.label}
|
||||
onStop={cancelLoading}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{modelsError && (
|
||||
<div className="ml-2 text-xs text-destructive truncate max-w-[28rem]">
|
||||
{modelsError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{view.mode === "single" && ggufContextLength && contextUsage ? (
|
||||
<ContextUsageBar
|
||||
used={contextUsage.totalTokens}
|
||||
total={ggufContextLength}
|
||||
cached={contextUsage.cachedTokens}
|
||||
promptTokens={contextUsage.promptTokens}
|
||||
completionTokens={contextUsage.completionTokens}
|
||||
{loadingModel && loadToastDismissed ? (
|
||||
<ModelLoadInlineStatus
|
||||
label={
|
||||
loadProgress?.phase === "starting"
|
||||
? "Starting model…"
|
||||
: loadingModel.isDownloaded || loadingModel.isCachedLora
|
||||
? "Loading model…"
|
||||
: "Downloading model…"
|
||||
}
|
||||
title={
|
||||
loadingModel.isDownloaded
|
||||
? `Loading ${loadingModel.displayName} from cache.`
|
||||
: loadingModel.isCachedLora
|
||||
? `Loading ${loadingModel.displayName} into memory.`
|
||||
: `Loading ${loadingModel.displayName}. This may include downloading.`
|
||||
}
|
||||
progressPercent={loadProgress?.percent}
|
||||
progressLabel={loadProgress?.label}
|
||||
onStop={cancelLoading}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen((o) => !o)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Inference settings"
|
||||
data-tour="chat-settings"
|
||||
>
|
||||
<HugeiconsIcon icon={Settings04Icon} className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{view.mode === "single" ? (
|
||||
<SingleContent
|
||||
key={view.threadId ?? "single"}
|
||||
threadId={view.threadId}
|
||||
newThreadNonce={view.newThreadNonce}
|
||||
/>
|
||||
) : (
|
||||
<CompareContent
|
||||
key={view.pairId}
|
||||
pairId={view.pairId}
|
||||
models={models}
|
||||
loraModels={loraModels}
|
||||
onFoldersChange={refreshLocalModels}
|
||||
/>
|
||||
{modelsError && (
|
||||
<div className="ml-2 text-xs text-destructive truncate max-w-[28rem]">
|
||||
{modelsError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{view.mode === "single" && ggufContextLength && contextUsage ? (
|
||||
<ContextUsageBar
|
||||
used={contextUsage.totalTokens}
|
||||
total={ggufContextLength}
|
||||
cached={contextUsage.cachedTokens}
|
||||
promptTokens={contextUsage.promptTokens}
|
||||
completionTokens={contextUsage.completionTokens}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(!settingsOpen)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Inference settings"
|
||||
data-tour="chat-settings"
|
||||
>
|
||||
<HugeiconsIcon icon={Settings04Icon} className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ChatSettingsPanel
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
params={inferenceParams}
|
||||
onParamsChange={setInferenceParams}
|
||||
autoTitle={autoTitle}
|
||||
onAutoTitleChange={setAutoTitle}
|
||||
onReloadModel={() => {
|
||||
const state = useChatRuntimeStore.getState();
|
||||
if (state.params.checkpoint) {
|
||||
selectModel({
|
||||
id: state.params.checkpoint,
|
||||
ggufVariant: state.activeGgufVariant ?? undefined,
|
||||
forceReload: true,
|
||||
isDownloaded: true,
|
||||
loadingDescription: "Reloading with updated chat template.",
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
{view.mode === "single" ? (
|
||||
<SingleContent
|
||||
key={view.threadId ?? "single"}
|
||||
threadId={view.threadId}
|
||||
newThreadNonce={view.newThreadNonce}
|
||||
/>
|
||||
) : (
|
||||
<CompareContent
|
||||
key={view.pairId}
|
||||
pairId={view.pairId}
|
||||
models={models}
|
||||
loraModels={loraModels}
|
||||
onFoldersChange={refreshLocalModels}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChatSettingsPanel
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
params={inferenceParams}
|
||||
onParamsChange={setInferenceParams}
|
||||
onReloadModel={() => {
|
||||
const state = useChatRuntimeStore.getState();
|
||||
if (state.params.checkpoint) {
|
||||
selectModel({
|
||||
id: state.params.checkpoint,
|
||||
ggufVariant: state.activeGgufVariant ?? undefined,
|
||||
forceReload: true,
|
||||
isDownloaded: true,
|
||||
loadingDescription: "Reloading with updated chat template.",
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ import {
|
|||
PencilEdit01Icon,
|
||||
Settings02Icon,
|
||||
SlidersHorizontalIcon,
|
||||
UserSettings01Icon,
|
||||
Wrench01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
|
@ -476,8 +475,6 @@ interface ChatSettingsPanelProps {
|
|||
onOpenChange?: (open: boolean) => void;
|
||||
params: InferenceParams;
|
||||
onParamsChange: (params: InferenceParams) => void;
|
||||
autoTitle: boolean;
|
||||
onAutoTitleChange: (enabled: boolean) => void;
|
||||
onReloadModel?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -486,8 +483,6 @@ export function ChatSettingsPanel({
|
|||
onOpenChange,
|
||||
params,
|
||||
onParamsChange,
|
||||
autoTitle,
|
||||
onAutoTitleChange,
|
||||
onReloadModel,
|
||||
}: ChatSettingsPanelProps) {
|
||||
const isMobile = useIsMobile();
|
||||
|
|
@ -743,7 +738,8 @@ export function ChatSettingsPanel({
|
|||
|
||||
const settingsContent = (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-4 py-3">
|
||||
<div className="aui-thread-viewport relative h-full overflow-y-auto bg-muted/70">
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 bg-muted/70 px-4 py-3 backdrop-blur">
|
||||
<HugeiconsIcon
|
||||
icon={PencilEdit01Icon}
|
||||
className="size-4 text-muted-foreground/70"
|
||||
|
|
@ -753,7 +749,7 @@ export function ChatSettingsPanel({
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-1.5">
|
||||
<div className="px-1.5">
|
||||
{/* mt-4 matches the Playground sidebar gap (SidebarHeader py-3 + SidebarGroup pt-1) */}
|
||||
<div className="mt-4 px-2 pb-3">
|
||||
<div className="space-y-1.5">
|
||||
|
|
@ -893,7 +889,7 @@ export function ChatSettingsPanel({
|
|||
value={params.systemPrompt}
|
||||
onChange={(e) => set("systemPrompt")(e.target.value)}
|
||||
placeholder="You are a helpful assistant..."
|
||||
className="min-h-20 text-xs corner-squircle"
|
||||
className="min-h-20 max-h-48 overflow-y-auto text-xs corner-squircle"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1174,27 +1170,9 @@ export function ChatSettingsPanel({
|
|||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection
|
||||
icon={UserSettings01Icon}
|
||||
label="Preferences"
|
||||
defaultOpen={true}
|
||||
>
|
||||
<div className="flex flex-col gap-3 py-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium">Auto title</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Generate short title after reply.
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={autoTitle} onCheckedChange={onAutoTitleChange} />
|
||||
</div>
|
||||
<HfTokenField />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<ChatTemplateSection onReloadModel={onReloadModel} />
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
open={systemPromptEditorOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
|
|
@ -1224,7 +1202,7 @@ export function ChatSettingsPanel({
|
|||
value={systemPromptDraft}
|
||||
onChange={(event) => setSystemPromptDraft(event.target.value)}
|
||||
placeholder="You are a helpful assistant..."
|
||||
className="min-h-[24rem] text-sm leading-6 corner-squircle"
|
||||
className="min-h-[24rem] max-h-[50vh] overflow-y-auto text-sm leading-6 corner-squircle"
|
||||
rows={14}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1268,9 +1246,9 @@ export function ChatSettingsPanel({
|
|||
|
||||
return (
|
||||
<aside
|
||||
className={`shrink-0 self-start h-[calc(100%-0.875rem)] overflow-hidden bg-muted/70 rounded-2xl corner-squircle transition-[width] duration-200 ease-linear ${open ? "w-[17rem] border-l border-sidebar-border/70" : "w-0"}`}
|
||||
className={`relative z-50 shrink-0 h-full overflow-hidden bg-muted/70 transition-[width] duration-200 ease-linear ${open ? "w-[17rem]" : "w-0"}`}
|
||||
>
|
||||
<div className="flex h-full w-[17rem] flex-col">{settingsContent}</div>
|
||||
<div className="h-full w-[17rem]">{settingsContent}</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -1348,29 +1326,6 @@ function AutoHealToolCallsToggle() {
|
|||
);
|
||||
}
|
||||
|
||||
function HfTokenField() {
|
||||
const hfToken = useChatRuntimeStore((s) => s.hfToken);
|
||||
const setHfToken = useChatRuntimeStore((s) => s.setHfToken);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium">Hugging Face Token</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
For downloading gated or private models.
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
type="password"
|
||||
value={hfToken}
|
||||
placeholder="hf_..."
|
||||
className="h-7 text-xs font-mono"
|
||||
onChange={(e) => setHfToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatTemplateSection({
|
||||
onReloadModel,
|
||||
}: {
|
||||
|
|
@ -1391,7 +1346,7 @@ function ChatTemplateSection({
|
|||
<Textarea
|
||||
value={displayValue}
|
||||
onChange={(e) => setOverride(e.target.value)}
|
||||
className="min-h-32 font-mono text-[10px] leading-relaxed md:text-[10px] corner-squircle"
|
||||
className="min-h-32 max-h-64 overflow-y-auto font-mono text-[10px] leading-relaxed md:text-[10px] corner-squircle"
|
||||
rows={6}
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { useTrainingRuntimeStore } from "@/features/training";
|
||||
import { Cancel01Icon, Message01Icon, SearchIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { useEffect } from "react";
|
||||
import { useChatSearchIndex } from "../hooks/use-chat-search-index";
|
||||
import { useChatSearchStore } from "../stores/chat-search-store";
|
||||
|
||||
function formatRelative(createdAt: number): string {
|
||||
const diff = Date.now() - createdAt;
|
||||
const day = 86_400_000;
|
||||
if (diff < day) return "Today";
|
||||
if (diff < 7 * day) return "Past week";
|
||||
if (diff < 30 * day) return "Past month";
|
||||
return "Older";
|
||||
}
|
||||
|
||||
export function ChatSearchDialog() {
|
||||
const isOpen = useChatSearchStore((s) => s.isOpen);
|
||||
const setOpen = useChatSearchStore((s) => s.setOpen);
|
||||
const close = useChatSearchStore((s) => s.close);
|
||||
const navigate = useNavigate();
|
||||
const { items, loading } = useChatSearchIndex(isOpen);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== "k") return;
|
||||
if (useTrainingRuntimeStore.getState().isTrainingRunning) return;
|
||||
const el = document.activeElement as HTMLElement | null;
|
||||
const tag = el?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || el?.isContentEditable) return;
|
||||
e.preventDefault();
|
||||
useChatSearchStore.getState().open();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={isOpen}
|
||||
onOpenChange={setOpen}
|
||||
className="shadow-border corner-squircle w-[635px] max-w-[calc(100%-2rem)] gap-0 p-0 sm:max-w-[635px]"
|
||||
overlayClassName="bg-transparent"
|
||||
>
|
||||
<Command className="rounded-none p-0">
|
||||
<div className="flex items-center gap-3 border-b border-border/40 px-4 py-3">
|
||||
<HugeiconsIcon
|
||||
icon={SearchIcon}
|
||||
strokeWidth={2}
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<CommandPrimitive.Input
|
||||
placeholder="Search chats..."
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<CommandList className="max-h-[420px] p-1">
|
||||
<CommandEmpty className="py-6 text-center text-xs text-muted-foreground">
|
||||
{loading
|
||||
? "Loading…"
|
||||
: items.length === 0
|
||||
? "No chats yet."
|
||||
: "No chats match."}
|
||||
</CommandEmpty>
|
||||
<CommandGroup className="p-0">
|
||||
{items.map((item) => (
|
||||
<CommandPrimitive.Item
|
||||
key={item.id}
|
||||
value={`${item.title} ${item.preview}`}
|
||||
onSelect={() => {
|
||||
navigate({
|
||||
to: "/chat",
|
||||
search:
|
||||
item.type === "single"
|
||||
? { thread: item.id }
|
||||
: { compare: item.id },
|
||||
});
|
||||
close();
|
||||
}}
|
||||
className="relative flex cursor-default select-none items-center gap-3 rounded-lg px-3 py-2.5 text-sm outline-hidden data-selected:bg-muted data-selected:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Message01Icon}
|
||||
strokeWidth={2}
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">
|
||||
{item.title || "Untitled chat"}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{formatRelative(item.createdAt)}
|
||||
</span>
|
||||
</CommandPrimitive.Item>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -289,11 +289,11 @@ export function useChatModelRuntime() {
|
|||
loadedSpeculativeType: currentSpecType,
|
||||
});
|
||||
|
||||
// Set reasoning default for Qwen3.5 small models
|
||||
// Set reasoning default for Qwen3.5/3.6 small models
|
||||
if (supportsReasoning) {
|
||||
let reasoningDefault = true;
|
||||
const mid = statusRes.active_model.toLowerCase();
|
||||
if (mid.includes("qwen3.5")) {
|
||||
if (mid.includes("qwen3.5") || mid.includes("qwen3.6")) {
|
||||
const sizeMatch = mid.match(/(\d+\.?\d*)\s*b/);
|
||||
if (sizeMatch && parseFloat(sizeMatch[1]) < 9) {
|
||||
reasoningDefault = false;
|
||||
|
|
@ -437,9 +437,10 @@ export function useChatModelRuntime() {
|
|||
const { chatTemplateOverride, kvCacheDtype, customContextLength, ggufContextLength, speculativeType } = useChatRuntimeStore.getState();
|
||||
// GGUF: use custom context length, or 0 = model's native context
|
||||
// Non-GGUF: use the Max Seq Length slider value
|
||||
const isDirectGgufFile = modelId.toLowerCase().endsWith(".gguf");
|
||||
const effectiveMaxSeqLength = customContextLength != null
|
||||
? customContextLength
|
||||
: ggufVariant != null ? (ggufContextLength ?? 0) : maxSeqLength;
|
||||
: (ggufVariant != null || isDirectGgufFile) ? (ggufContextLength ?? 0) : maxSeqLength;
|
||||
const loadResponse = await loadModel({
|
||||
model_path: modelId,
|
||||
hf_token: hfToken,
|
||||
|
|
@ -461,11 +462,11 @@ export function useChatModelRuntime() {
|
|||
setParams(
|
||||
mergeRecommendedInference(currentParams, loadResponse, modelId),
|
||||
);
|
||||
// Qwen3.5 small models (0.8B, 2B, 4B, 9B) disable thinking by default
|
||||
// Qwen3.5/3.6 small models (0.8B, 2B, 4B, 9B) disable thinking by default
|
||||
let reasoningDefault = loadResponse.supports_reasoning ?? false;
|
||||
if (reasoningDefault) {
|
||||
const mid = modelId.toLowerCase();
|
||||
if (mid.includes("qwen3.5")) {
|
||||
if (mid.includes("qwen3.5") || mid.includes("qwen3.6")) {
|
||||
const sizeMatch = mid.match(/(\d+\.?\d*)\s*b/);
|
||||
if (sizeMatch && parseFloat(sizeMatch[1]) < 9) {
|
||||
reasoningDefault = false;
|
||||
|
|
@ -508,12 +509,14 @@ export function useChatModelRuntime() {
|
|||
defaultChatTemplate: loadResponse.chat_template ?? null,
|
||||
chatTemplateOverride: null,
|
||||
});
|
||||
// Qwen3/3.5: apply thinking-mode-specific params after load
|
||||
// Qwen3/3.5/3.6: apply thinking-mode-specific params after load
|
||||
if (modelId.toLowerCase().includes("qwen3") && (loadResponse.supports_reasoning ?? false)) {
|
||||
const store = useChatRuntimeStore.getState();
|
||||
const mid = modelId.toLowerCase();
|
||||
const needsPresencePenalty = mid.includes("qwen3.5") || mid.includes("qwen3.6");
|
||||
const p = reasoningDefault
|
||||
? { temperature: 0.6, topP: 0.95, topK: 20, minP: 0.0 }
|
||||
: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0.0 };
|
||||
? { temperature: 0.6, topP: 0.95, topK: 20, minP: 0.0, ...(needsPresencePenalty ? { presencePenalty: 1.5 } : {}) }
|
||||
: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0.0, ...(needsPresencePenalty ? { presencePenalty: 1.5 } : {}) };
|
||||
store.setParams({ ...store.params, ...p });
|
||||
}
|
||||
await refresh();
|
||||
|
|
|
|||
156
studio/frontend/src/features/chat/hooks/use-chat-search-index.ts
Normal file
156
studio/frontend/src/features/chat/hooks/use-chat-search-index.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { db } from "../db";
|
||||
import type { MessageRecord, ThreadRecord } from "../types";
|
||||
|
||||
export interface ChatSearchItem {
|
||||
type: "single" | "compare";
|
||||
id: string;
|
||||
title: string;
|
||||
preview: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const THREAD_LIMIT = 200;
|
||||
const PREVIEW_MAX = 120;
|
||||
|
||||
function extractText(message: MessageRecord): string {
|
||||
const content = message.content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
const parts: string[] = [];
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== "object") continue;
|
||||
const p = part as { type?: string; text?: unknown };
|
||||
if ((p.type === "text" || p.type === "reasoning") && typeof p.text === "string") {
|
||||
parts.push(p.text);
|
||||
}
|
||||
}
|
||||
return parts.join(" ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
return text.slice(0, max).trimEnd() + "…";
|
||||
}
|
||||
|
||||
async function buildIndex(): Promise<ChatSearchItem[]> {
|
||||
// Fetch all threads newest-first, filter archived in JS, then take top N.
|
||||
// `archived` is a boolean which Dexie does not index reliably, so we filter
|
||||
// after the sort instead of using `.where("archived")`.
|
||||
const all = (await db.threads
|
||||
.orderBy("createdAt")
|
||||
.reverse()
|
||||
.toArray()) as ThreadRecord[];
|
||||
const active = all.filter((t) => !t.archived).slice(0, THREAD_LIMIT);
|
||||
|
||||
const itemThreadIds = new Map<
|
||||
string,
|
||||
{ item: Omit<ChatSearchItem, "preview">; threadIds: string[] }
|
||||
>();
|
||||
const seenPairs = new Set<string>();
|
||||
|
||||
for (const t of active) {
|
||||
if (t.pairId) {
|
||||
if (seenPairs.has(t.pairId)) {
|
||||
const existing = itemThreadIds.get(t.pairId);
|
||||
if (existing) existing.threadIds.push(t.id);
|
||||
continue;
|
||||
}
|
||||
seenPairs.add(t.pairId);
|
||||
itemThreadIds.set(t.pairId, {
|
||||
item: {
|
||||
type: "compare",
|
||||
id: t.pairId,
|
||||
title: t.title,
|
||||
createdAt: t.createdAt,
|
||||
},
|
||||
threadIds: [t.id],
|
||||
});
|
||||
} else {
|
||||
itemThreadIds.set(t.id, {
|
||||
item: {
|
||||
type: "single",
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
createdAt: t.createdAt,
|
||||
},
|
||||
threadIds: [t.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// One query for all messages across all relevant threads, then group by
|
||||
// threadId in memory. Avoids N sequential awaits.
|
||||
const allThreadIds = Array.from(itemThreadIds.values()).flatMap(
|
||||
(e) => e.threadIds,
|
||||
);
|
||||
const messages = (await db.messages
|
||||
.where("threadId")
|
||||
.anyOf(allThreadIds)
|
||||
.toArray()) as MessageRecord[];
|
||||
|
||||
const byThreadId = new Map<string, MessageRecord[]>();
|
||||
for (const m of messages) {
|
||||
const arr = byThreadId.get(m.threadId);
|
||||
if (arr) arr.push(m);
|
||||
else byThreadId.set(m.threadId, [m]);
|
||||
}
|
||||
|
||||
const results: ChatSearchItem[] = [];
|
||||
for (const { item, threadIds } of itemThreadIds.values()) {
|
||||
const merged: MessageRecord[] = [];
|
||||
for (const tid of threadIds) {
|
||||
const arr = byThreadId.get(tid);
|
||||
if (arr) merged.push(...arr);
|
||||
}
|
||||
merged.sort((a, b) => b.createdAt - a.createdAt);
|
||||
|
||||
let preview = "";
|
||||
for (const m of merged) {
|
||||
const text = extractText(m);
|
||||
if (text) {
|
||||
preview = truncate(text, PREVIEW_MAX);
|
||||
break;
|
||||
}
|
||||
}
|
||||
results.push({ ...item, preview });
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return results;
|
||||
}
|
||||
|
||||
export function useChatSearchIndex(enabled: boolean): {
|
||||
items: ChatSearchItem[];
|
||||
loading: boolean;
|
||||
} {
|
||||
const [items, setItems] = useState<ChatSearchItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
// Clear stale results so the next open doesn't flash old items.
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
buildIndex()
|
||||
.then((result) => {
|
||||
if (!cancelled) setItems(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setItems([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return { items, loading };
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { db, useLiveQuery } from "../db";
|
||||
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
|
||||
import type { ThreadRecord } from "../types";
|
||||
|
||||
export interface SidebarItem {
|
||||
type: "single" | "compare";
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export function groupThreads(threads: ThreadRecord[]): SidebarItem[] {
|
||||
const items: SidebarItem[] = [];
|
||||
const seenPairs = new Set<string>();
|
||||
|
||||
for (const t of threads) {
|
||||
if (t.archived) {
|
||||
continue;
|
||||
}
|
||||
if (t.pairId) {
|
||||
if (seenPairs.has(t.pairId)) {
|
||||
continue;
|
||||
}
|
||||
seenPairs.add(t.pairId);
|
||||
items.push({
|
||||
type: "compare",
|
||||
id: t.pairId,
|
||||
title: t.title,
|
||||
createdAt: t.createdAt,
|
||||
});
|
||||
} else if (!t.pairId) {
|
||||
items.push({
|
||||
type: "single",
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
createdAt: t.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
export function useChatSidebarItems() {
|
||||
const allThreads = useLiveQuery(async () => {
|
||||
const threadIdsWithMessage = new Set(
|
||||
(await db.messages.orderBy("threadId").uniqueKeys()) as string[],
|
||||
);
|
||||
const rows = await db.threads.orderBy("createdAt").reverse().toArray();
|
||||
return rows.filter((t) => !t.archived && threadIdsWithMessage.has(t.id));
|
||||
}, []);
|
||||
const items = groupThreads(allThreads ?? []);
|
||||
const canCompare = useChatRuntimeStore((s) => Boolean(s.params.checkpoint));
|
||||
|
||||
return { items, canCompare };
|
||||
}
|
||||
|
||||
export async function deleteChatItem(
|
||||
item: SidebarItem,
|
||||
activeId: string | undefined,
|
||||
onSelect: (view: { mode: "single"; newThreadNonce: string }) => void,
|
||||
) {
|
||||
await db.transaction("rw", db.threads, db.messages, async () => {
|
||||
if (item.type === "single") {
|
||||
await db.messages.where("threadId").equals(item.id).delete();
|
||||
await db.threads.delete(item.id);
|
||||
} else {
|
||||
const paired = await db.threads.where("pairId").equals(item.id).toArray();
|
||||
for (const t of paired) {
|
||||
await db.messages.where("threadId").equals(t.id).delete();
|
||||
await db.threads.delete(t.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (activeId === item.id) {
|
||||
useChatRuntimeStore.getState().setActiveThreadId(null);
|
||||
onSelect({ mode: "single", newThreadNonce: crypto.randomUUID() });
|
||||
}
|
||||
}
|
||||
|
|
@ -455,7 +455,7 @@ export function SharedComposer({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`shadow-border ring-1 ring-border relative flex w-full flex-col rounded-2xl bg-background px-1 pt-2 transition-shadow outline-none ${dragging ? "ring-ring bg-accent/50" : ""}`}
|
||||
className={`chat-composer-surface relative flex w-full flex-col rounded-3xl bg-background px-1 pt-2 transition-shadow outline-none ${dragging ? "border-ring bg-accent/50" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
|
|
@ -557,13 +557,14 @@ export function SharedComposer({
|
|||
if (reasoningAlwaysOn) return;
|
||||
const next = !reasoningEnabled;
|
||||
setReasoningEnabled(next);
|
||||
// Qwen3/3.5: adjust params for thinking on/off
|
||||
// Qwen3/3.5/3.6: adjust params for thinking on/off
|
||||
const store = useChatRuntimeStore.getState();
|
||||
const cp = store.params.checkpoint?.toLowerCase() ?? "";
|
||||
if (cp.includes("qwen3")) {
|
||||
const needsPresencePenalty = cp.includes("qwen3.5") || cp.includes("qwen3.6");
|
||||
const p = next
|
||||
? { temperature: 0.6, topP: 0.95, topK: 20, minP: 0.0 }
|
||||
: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0.0 };
|
||||
? { temperature: 0.6, topP: 0.95, topK: 20, minP: 0.0, ...(needsPresencePenalty ? { presencePenalty: 1.5 } : {}) }
|
||||
: { temperature: 0.7, topP: 0.8, topK: 20, minP: 0.0, ...(needsPresencePenalty ? { presencePenalty: 1.5 } : {}) };
|
||||
store.setParams({ ...store.params, ...p });
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ type ChatRuntimeStore = {
|
|||
defaultChatTemplate: string | null;
|
||||
chatTemplateOverride: string | null;
|
||||
activeThreadId: string | null;
|
||||
settingsPanelOpen: boolean;
|
||||
pendingAudioBase64: string | null;
|
||||
pendingAudioName: string | null;
|
||||
contextUsage: {
|
||||
|
|
@ -193,6 +194,7 @@ type ChatRuntimeStore = {
|
|||
setModelsError: (error: string | null) => void;
|
||||
setCheckpoint: (modelId: string, ggufVariant?: string | null) => void;
|
||||
setActiveThreadId: (threadId: string | null) => void;
|
||||
setSettingsPanelOpen: (open: boolean) => void;
|
||||
clearCheckpoint: () => void;
|
||||
setReasoningEnabled: (enabled: boolean) => void;
|
||||
setToolsEnabled: (enabled: boolean) => void;
|
||||
|
|
@ -243,6 +245,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
|
|||
defaultChatTemplate: null,
|
||||
chatTemplateOverride: null,
|
||||
activeThreadId: null,
|
||||
settingsPanelOpen: false,
|
||||
pendingAudioBase64: null,
|
||||
pendingAudioName: null,
|
||||
contextUsage: null,
|
||||
|
|
@ -294,6 +297,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
|
|||
activeGgufVariant: ggufVariant ?? null,
|
||||
})),
|
||||
setActiveThreadId: (activeThreadId) => set({ activeThreadId, contextUsage: null }),
|
||||
setSettingsPanelOpen: (settingsPanelOpen) => set({ settingsPanelOpen }),
|
||||
clearCheckpoint: () =>
|
||||
set((state) => ({
|
||||
params: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
interface ChatSearchStore {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useChatSearchStore = create<ChatSearchStore>((set) => ({
|
||||
isOpen: false,
|
||||
open: () => set({ isOpen: true }),
|
||||
close: () => set({ isOpen: false }),
|
||||
setOpen: (isOpen) => set({ isOpen }),
|
||||
}));
|
||||
|
|
@ -21,48 +21,10 @@ import {
|
|||
PencilEdit02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { db, useLiveQuery } from "./db";
|
||||
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
|
||||
import type { ChatView, ThreadRecord } from "./types";
|
||||
|
||||
interface SidebarItem {
|
||||
type: "single" | "compare";
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
function groupThreads(threads: ThreadRecord[]): SidebarItem[] {
|
||||
const items: SidebarItem[] = [];
|
||||
const seenPairs = new Set<string>();
|
||||
|
||||
for (const t of threads) {
|
||||
if (t.archived) {
|
||||
continue;
|
||||
}
|
||||
if (t.pairId) {
|
||||
if (seenPairs.has(t.pairId)) {
|
||||
continue;
|
||||
}
|
||||
seenPairs.add(t.pairId);
|
||||
items.push({
|
||||
type: "compare",
|
||||
id: t.pairId,
|
||||
title: t.title,
|
||||
createdAt: t.createdAt,
|
||||
});
|
||||
} else if (!t.pairId) {
|
||||
items.push({
|
||||
type: "single",
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
createdAt: t.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
import type { ChatView } from "./types";
|
||||
import { deleteChatItem, useChatSidebarItems } from "./hooks/use-chat-sidebar-items";
|
||||
import type { SidebarItem } from "./hooks/use-chat-sidebar-items";
|
||||
|
||||
export function ThreadSidebar({
|
||||
view,
|
||||
|
|
@ -77,14 +39,7 @@ export function ThreadSidebar({
|
|||
onNewCompare: () => void;
|
||||
showCompare: boolean;
|
||||
}) {
|
||||
const allThreads = useLiveQuery(async () => {
|
||||
const threadIdsWithMessage = new Set(
|
||||
(await db.messages.orderBy("threadId").uniqueKeys()) as string[],
|
||||
);
|
||||
const rows = await db.threads.orderBy("createdAt").reverse().toArray();
|
||||
return rows.filter((t) => !t.archived && threadIdsWithMessage.has(t.id));
|
||||
}, []);
|
||||
const items = groupThreads(allThreads ?? []);
|
||||
const { items } = useChatSidebarItems();
|
||||
const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
|
||||
const activeId =
|
||||
view.mode === "single" ? (view.threadId ?? storeThreadId) : view.pairId;
|
||||
|
|
@ -96,23 +51,10 @@ export function ThreadSidebar({
|
|||
}
|
||||
|
||||
async function handleDelete(item: SidebarItem) {
|
||||
if (item.type === "single") {
|
||||
await db.messages.where("threadId").equals(item.id).delete();
|
||||
await db.threads.delete(item.id);
|
||||
} else {
|
||||
const paired = await db.threads.where("pairId").equals(item.id).toArray();
|
||||
for (const t of paired) {
|
||||
await db.messages.where("threadId").equals(t.id).delete();
|
||||
await db.threads.delete(t.id);
|
||||
}
|
||||
}
|
||||
if (activeId === item.id) {
|
||||
// Directly set a new view with a nonce rather than going through
|
||||
// onNewThread(), which may return early if the guard sees no
|
||||
// threadId and no activeThreadId (after we just cleared it).
|
||||
useChatRuntimeStore.getState().setActiveThreadId(null);
|
||||
onSelect({ mode: "single", newThreadNonce: crypto.randomUUID() });
|
||||
}
|
||||
// Directly set a new view with a nonce rather than going through
|
||||
// onNewThread(), which may return early if the guard sees no
|
||||
// threadId and no activeThreadId (after we just cleared it).
|
||||
await deleteChatItem(item, activeId ?? undefined, onSelect);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
15
studio/frontend/src/features/chat/utils/clear-all-chats.ts
Normal file
15
studio/frontend/src/features/chat/utils/clear-all-chats.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { db } from "../db";
|
||||
|
||||
export async function countAllChats(): Promise<number> {
|
||||
return db.threads.count();
|
||||
}
|
||||
|
||||
export async function clearAllChats(): Promise<void> {
|
||||
await db.transaction("rw", db.threads, db.messages, async () => {
|
||||
await db.messages.clear();
|
||||
await db.threads.clear();
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { db } from "../db";
|
||||
|
||||
interface ExportedChat {
|
||||
exportedAt: string;
|
||||
version: 1;
|
||||
threadCount: number;
|
||||
threads: unknown[];
|
||||
messages: unknown[];
|
||||
}
|
||||
|
||||
export async function buildChatExport(): Promise<ExportedChat> {
|
||||
const [threads, messages] = await Promise.all([
|
||||
db.threads.toArray(),
|
||||
db.messages.toArray(),
|
||||
]);
|
||||
return {
|
||||
exportedAt: new Date().toISOString(),
|
||||
version: 1,
|
||||
threadCount: threads.length,
|
||||
threads,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadChatExport(): Promise<void> {
|
||||
const data = await buildChatExport();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `unsloth-chats-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { liveQuery } from "dexie";
|
||||
import { listRecipes } from "../data/recipes-db";
|
||||
import type { RecipeRecord } from "../types";
|
||||
|
||||
export function useRecipeSidebarItems(enabled: boolean) {
|
||||
const [recipes, setRecipes] = useState<RecipeRecord[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const sub = liveQuery(() => listRecipes()).subscribe({
|
||||
next: (value) => setRecipes(value),
|
||||
error: (err) => console.error("recipe sidebar liveQuery:", err),
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
}, [enabled]);
|
||||
|
||||
return recipes;
|
||||
}
|
||||
|
|
@ -7,12 +7,12 @@ import { motion } from "motion/react";
|
|||
|
||||
interface SplashScreenProps {
|
||||
onStartOnboarding: () => void;
|
||||
onGoToStudio: () => void;
|
||||
onSkipOnboarding: () => void;
|
||||
}
|
||||
|
||||
export function SplashScreen({
|
||||
onStartOnboarding,
|
||||
onGoToStudio,
|
||||
onSkipOnboarding,
|
||||
}: SplashScreenProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-b from-background via-background to-primary/5 p-6">
|
||||
|
|
@ -65,7 +65,7 @@ export function SplashScreen({
|
|||
<Button size="lg" onClick={onStartOnboarding}>
|
||||
Start Onboarding
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" onClick={onGoToStudio}>
|
||||
<Button size="lg" variant="outline" onClick={onSkipOnboarding}>
|
||||
Skip Onboarding
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -7,10 +7,15 @@ import { markOnboardingDone } from "@/features/auth";
|
|||
import { useTrainingConfigStore } from "@/features/training";
|
||||
import { ArrowLeft02Icon, ArrowRight02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void }) {
|
||||
export function WizardFooter({
|
||||
returnTo,
|
||||
onBackToSplash,
|
||||
}: {
|
||||
returnTo: string;
|
||||
onBackToSplash: () => void;
|
||||
}) {
|
||||
const { currentStep, prevStep, nextStep, canProceed } = useTrainingConfigStore(
|
||||
useShallow((s) => ({
|
||||
currentStep: s.currentStep,
|
||||
|
|
@ -19,7 +24,6 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void })
|
|||
canProceed: s.canProceed(),
|
||||
})),
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
const isFirst = currentStep === 1;
|
||||
const isLast = currentStep === STEPS.length;
|
||||
|
||||
|
|
@ -41,7 +45,7 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void })
|
|||
className="px-4"
|
||||
onClick={() => {
|
||||
markOnboardingDone();
|
||||
navigate({ to: "/studio" });
|
||||
window.location.assign(returnTo);
|
||||
}}
|
||||
>
|
||||
Skip
|
||||
|
|
@ -51,12 +55,12 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void })
|
|||
<Button
|
||||
onClick={() => {
|
||||
markOnboardingDone();
|
||||
navigate({ to: "/studio" });
|
||||
window.location.assign(returnTo);
|
||||
}}
|
||||
disabled={!canProceed}
|
||||
className="px-4 !pr-4"
|
||||
>
|
||||
Go to Studio
|
||||
Finish onboarding
|
||||
<HugeiconsIcon icon={ArrowRight02Icon} data-icon="inline-end" />
|
||||
</Button>
|
||||
) : (
|
||||
|
|
@ -65,7 +69,7 @@ export function WizardFooter({ onBackToSplash }: { onBackToSplash: () => void })
|
|||
if (currentStep === 1 && sessionStorage.getItem("unsloth_chat_only") === "1") {
|
||||
sessionStorage.removeItem("unsloth_chat_only");
|
||||
markOnboardingDone();
|
||||
window.location.href = "/chat";
|
||||
window.location.assign("/chat");
|
||||
} else {
|
||||
nextStep();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { Route as OnboardingRoute } from "@/app/routes/onboarding";
|
||||
import { motion } from "motion/react";
|
||||
import { Suspense, lazy, useEffect, useRef, useState } from "react";
|
||||
|
||||
|
|
@ -19,13 +19,23 @@ const Confetti = lazy(() =>
|
|||
import("@/components/ui/confetti").then((m) => ({ default: m.Confetti })),
|
||||
);
|
||||
|
||||
function sanitizeRedirectTarget(value: string | undefined): string {
|
||||
if (!value) return "/chat";
|
||||
if (!value.startsWith("/")) return "/chat";
|
||||
if (value.startsWith("//")) return "/chat";
|
||||
if (value.includes("\\")) return "/chat";
|
||||
return value;
|
||||
}
|
||||
|
||||
export function WizardLayout() {
|
||||
const navigate = useNavigate();
|
||||
const search = OnboardingRoute.useSearch();
|
||||
const [showSplash, setShowSplash] = useState(true);
|
||||
const currentStep = useTrainingConfigStore((s) => s.currentStep);
|
||||
const confettiRef = useRef<ConfettiRef>(null);
|
||||
const hasFiredRef = useRef(false);
|
||||
const isFinalStep = currentStep === STEPS.length;
|
||||
const returnTo = sanitizeRedirectTarget(search.redirectTo);
|
||||
const exitToReturnTo = () => window.location.assign(returnTo);
|
||||
|
||||
// Only redirect on initial mount — not on re-renders after markOnboardingDone()
|
||||
// which would override explicit /chat navigation from skip buttons.
|
||||
|
|
@ -34,10 +44,10 @@ export function WizardLayout() {
|
|||
if (!checkedRef.current) {
|
||||
checkedRef.current = true;
|
||||
if (isOnboardingDone()) {
|
||||
navigate({ to: "/studio" });
|
||||
exitToReturnTo();
|
||||
}
|
||||
}
|
||||
}, [navigate]);
|
||||
}, [returnTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFinalStep && !hasFiredRef.current) {
|
||||
|
|
@ -67,9 +77,9 @@ export function WizardLayout() {
|
|||
{showSplash && (
|
||||
<SplashScreen
|
||||
onStartOnboarding={() => setShowSplash(false)}
|
||||
onGoToStudio={() => {
|
||||
onSkipOnboarding={() => {
|
||||
markOnboardingDone();
|
||||
window.location.href = "/studio";
|
||||
exitToReturnTo();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -91,10 +101,10 @@ export function WizardLayout() {
|
|||
}}
|
||||
>
|
||||
<Card className="relative z-10 w-full !gap-0 !m-0 !p-0 flex min-h-[560px] flex-col overflow-hidden shadow-border ring-1 ring-border md:min-h-[620px] md:flex-row lg:h-[660px]">
|
||||
<WizardSidebar />
|
||||
<WizardSidebar returnTo={returnTo} />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<WizardContent />
|
||||
<WizardFooter onBackToSplash={() => setShowSplash(true)} />
|
||||
<WizardFooter returnTo={returnTo} onBackToSplash={() => setShowSplash(true)} />
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { ArrowRight02Icon } from "@hugeicons/core-free-icons";
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { WizardStepItem } from "./wizard-step-item";
|
||||
|
||||
export function WizardSidebar() {
|
||||
export function WizardSidebar({ returnTo }: { returnTo: string }) {
|
||||
const currentStep = useTrainingConfigStore((s) => s.currentStep);
|
||||
const progress = ((currentStep - 1) / (STEPS.length - 1)) * 100;
|
||||
|
||||
|
|
@ -38,10 +38,10 @@ export function WizardSidebar() {
|
|||
className="mt-2 w-full md:hidden"
|
||||
onClick={() => {
|
||||
markOnboardingDone();
|
||||
window.location.href = "/chat";
|
||||
window.location.assign(returnTo);
|
||||
}}
|
||||
>
|
||||
Skip to Chat
|
||||
Skip onboarding
|
||||
<HugeiconsIcon icon={ArrowRight02Icon} data-icon="inline-end" />
|
||||
</Button>
|
||||
<nav className="mt-3 hidden flex-col gap-1 md:flex">
|
||||
|
|
@ -54,10 +54,10 @@ export function WizardSidebar() {
|
|||
className="mt-3 hidden w-full md:flex"
|
||||
onClick={() => {
|
||||
markOnboardingDone();
|
||||
window.location.href = "/chat";
|
||||
window.location.assign(returnTo);
|
||||
}}
|
||||
>
|
||||
Skip to Chat
|
||||
Skip onboarding
|
||||
<HugeiconsIcon icon={ArrowRight02Icon} data-icon="inline-end" />
|
||||
</Button>
|
||||
</aside>
|
||||
|
|
|
|||
41
studio/frontend/src/features/settings/api/api-keys.ts
Normal file
41
studio/frontend/src/features/settings/api/api-keys.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { authFetch } from "@/features/auth/api";
|
||||
|
||||
export interface ApiKey {
|
||||
id: number;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
expires_at: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export async function fetchApiKeys(): Promise<ApiKey[]> {
|
||||
const res = await authFetch("/api/auth/api-keys");
|
||||
if (!res.ok) throw new Error("Failed to load API keys");
|
||||
const data = (await res.json()) as { api_keys: ApiKey[] };
|
||||
return data.api_keys.filter((k) => k.is_active);
|
||||
}
|
||||
|
||||
export async function createApiKey(
|
||||
name: string,
|
||||
expiresInDays: number | null,
|
||||
): Promise<{ key: string; api_key: ApiKey }> {
|
||||
const res = await authFetch("/api/auth/api-keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, expires_in_days: expiresInDays }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create API key");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function revokeApiKey(keyId: number): Promise<void> {
|
||||
const res = await authFetch(`/api/auth/api-keys/${keyId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to revoke API key");
|
||||
}
|
||||
101
studio/frontend/src/features/settings/components/api-key-row.tsx
Normal file
101
studio/frontend/src/features/settings/components/api-key-row.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Delete02Icon,
|
||||
Copy01Icon,
|
||||
MoreHorizontalIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import type { ApiKey } from "../api/api-keys";
|
||||
|
||||
function relative(iso: string | null): string {
|
||||
if (!iso) return "never";
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (days < 1) {
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
if (hours < 1) return "just now";
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
if (days < 30) return `${days}d ago`;
|
||||
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
||||
return `${Math.floor(days / 365)}y ago`;
|
||||
}
|
||||
|
||||
function expiresText(iso: string | null): string {
|
||||
if (!iso) return "never";
|
||||
const diff = new Date(iso).getTime() - Date.now();
|
||||
if (diff < 0) return "expired";
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (days < 1) return "today";
|
||||
return `in ${days}d`;
|
||||
}
|
||||
|
||||
export function ApiKeyRow({
|
||||
apiKey,
|
||||
onRevoke,
|
||||
}: {
|
||||
apiKey: ApiKey;
|
||||
onRevoke: (key: ApiKey) => void;
|
||||
}) {
|
||||
const prefix = `sk-unsloth-${apiKey.key_prefix}…`;
|
||||
return (
|
||||
<div className="group flex items-center gap-3 border-b border-border/60 px-1 py-3 last:border-b-0 transition-colors hover:bg-accent/40">
|
||||
<span
|
||||
className="size-1.5 shrink-0 rounded-full bg-emerald-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<span className="truncate text-sm font-medium text-foreground" title={apiKey.name}>
|
||||
{apiKey.name}
|
||||
</span>
|
||||
<code className="shrink-0 font-mono text-[11px] text-muted-foreground">
|
||||
{prefix}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-1.5 text-[11px] text-muted-foreground">
|
||||
<span>Created {relative(apiKey.created_at)}</span>
|
||||
<span>·</span>
|
||||
<span>Used {relative(apiKey.last_used_at)}</span>
|
||||
<span>·</span>
|
||||
<span>Expires {expiresText(apiKey.expires_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 p-0 opacity-0 transition-opacity group-hover:opacity-100 data-[state=open]:opacity-100 max-sm:!opacity-100 max-sm:size-9"
|
||||
aria-label={`Actions for ${apiKey.name}`}
|
||||
>
|
||||
<HugeiconsIcon icon={MoreHorizontalIcon} className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => copyToClipboard(prefix)}>
|
||||
<HugeiconsIcon icon={Copy01Icon} className="size-3.5 mr-2" />
|
||||
Copy prefix
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onRevoke(apiKey)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-3.5 mr-2" />
|
||||
Revoke key
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { createApiKey } from "../api/api-keys";
|
||||
|
||||
const EXPIRY_PRESETS = [
|
||||
{ label: "Never", value: null as number | null },
|
||||
{ label: "7d", value: 7 },
|
||||
{ label: "30d", value: 30 },
|
||||
{ label: "90d", value: 90 },
|
||||
];
|
||||
|
||||
export function CreateKeyForm({
|
||||
onCreated,
|
||||
onError,
|
||||
}: {
|
||||
onCreated: (rawKey: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [expiry, setExpiry] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await createApiKey(name.trim(), expiry);
|
||||
onCreated(result.key);
|
||||
setName("");
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Couldn't create key.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-2 rounded-lg border border-border bg-muted/20 p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Key name (e.g. production)"
|
||||
className="h-8 min-w-[180px] flex-1 text-sm"
|
||||
aria-label="New key name"
|
||||
/>
|
||||
<div className="inline-flex items-center rounded-md border border-border bg-background p-0.5">
|
||||
{EXPIRY_PRESETS.map((p) => {
|
||||
const active = expiry === p.value;
|
||||
return (
|
||||
<button
|
||||
key={p.label}
|
||||
type="button"
|
||||
onClick={() => setExpiry(p.value)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
active
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={loading || !name.trim()}>
|
||||
{loading ? "Creating…" : "Create key"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function KeyRevealCard({
|
||||
rawKey,
|
||||
onDone,
|
||||
}: {
|
||||
rawKey: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (copyToClipboard(rawKey)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HugeiconsIcon
|
||||
icon={Tick02Icon}
|
||||
className="size-3.5 text-emerald-600 dark:text-emerald-500"
|
||||
/>
|
||||
<span className="text-xs font-medium text-emerald-700 dark:text-emerald-500">
|
||||
New key created
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-3 rounded-md border border-border bg-muted/40 px-3 py-2.5 font-mono text-sm transition-colors hover:bg-muted/60",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
||||
copied && "border-emerald-500/40 bg-emerald-500/10",
|
||||
)}
|
||||
aria-label={copied ? "Key copied" : "Copy key"}
|
||||
>
|
||||
<code className="min-w-0 flex-1 break-all text-left text-foreground">
|
||||
{rawKey}
|
||||
</code>
|
||||
<HugeiconsIcon
|
||||
icon={copied ? Tick02Icon : Copy01Icon}
|
||||
className={cn("size-4 shrink-0", copied && "text-emerald-600")}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-3 pt-0.5">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Copy now — this won't be shown again.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={onDone}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function SettingsRow({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
destructive,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
children?: ReactNode;
|
||||
destructive?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-6 py-3",
|
||||
destructive && "border-t border-border/60 mt-2 pt-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">{label}</span>
|
||||
{description ? (
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{children ? <div className="flex shrink-0 items-center">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function SettingsSection({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col">
|
||||
<div className="mb-1 flex flex-col gap-0.5">
|
||||
<h2 className="text-base font-semibold font-heading text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col divide-y divide-border/60">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LaptopIcon,
|
||||
Moon02Icon,
|
||||
Sun02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useTheme, type Theme } from "../stores/theme-store";
|
||||
|
||||
const OPTIONS: { value: Theme; label: string; icon: typeof Sun02Icon }[] = [
|
||||
{ value: "light", label: "Light", icon: Sun02Icon },
|
||||
{ value: "dark", label: "Dark", icon: Moon02Icon },
|
||||
{ value: "system", label: "System", icon: LaptopIcon },
|
||||
];
|
||||
|
||||
export function ThemeSegmented() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const reduced = useReducedMotion();
|
||||
return (
|
||||
<div className="inline-flex items-center rounded-md border border-border bg-muted/30 p-0.5">
|
||||
{OPTIONS.map((opt) => {
|
||||
const active = theme === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setTheme(opt.value)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"relative flex h-7 items-center gap-1.5 rounded px-2.5 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId="theme-pill"
|
||||
className="absolute inset-0 rounded bg-background shadow-border"
|
||||
transition={
|
||||
reduced
|
||||
? { duration: 0 }
|
||||
: { type: "spring", stiffness: 500, damping: 35, mass: 0.5 }
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<HugeiconsIcon icon={opt.icon} className="relative z-10 size-3.5" />
|
||||
<span className="relative z-10">{opt.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const STUDIO_UPDATE_CMD = "unsloth studio update";
|
||||
const STUDIO_UPDATE_FALLBACK_UNIX_CMD =
|
||||
"curl -fsSL https://unsloth.ai/install.sh | sh";
|
||||
const STUDIO_UPDATE_FALLBACK_WINDOWS_CMD =
|
||||
"irm https://unsloth.ai/install.ps1 | iex";
|
||||
|
||||
export type UpdateShell = "windows" | "unix";
|
||||
|
||||
function getStudioUpdateInstructionLine(shell: UpdateShell): string {
|
||||
return shell === "windows" ? "Open PowerShell and run:" : "Open Terminal and run:";
|
||||
}
|
||||
|
||||
function CopyableCommand({
|
||||
command,
|
||||
copyLabel,
|
||||
}: {
|
||||
command: string;
|
||||
copyLabel: string;
|
||||
}): ReactElement {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!copyToClipboard(command)) {
|
||||
return;
|
||||
}
|
||||
setCopied(true);
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
timerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-stretch overflow-hidden rounded-md border border-border bg-muted/40">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={command}
|
||||
className="min-w-0 flex-1 bg-transparent px-2 py-1.5 font-mono text-[11px] text-foreground outline-none"
|
||||
title={command}
|
||||
aria-label={`${copyLabel} text`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex shrink-0 items-center justify-center border-l border-border px-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={copied ? "Copied" : "Copy command"}
|
||||
aria-label={copied ? `${copyLabel} copied` : `Copy ${copyLabel}`}
|
||||
>
|
||||
{copied ? (
|
||||
<HugeiconsIcon icon={Tick02Icon} className="size-4 text-emerald-600" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Copy01Icon} className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateStudioInstructions({
|
||||
className,
|
||||
defaultShell,
|
||||
showTitle = true,
|
||||
}: {
|
||||
className?: string;
|
||||
defaultShell: UpdateShell;
|
||||
showTitle?: boolean;
|
||||
}): ReactElement {
|
||||
const [shell, setShell] = useState<UpdateShell>(defaultShell);
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const windows = shell === "windows";
|
||||
const fadeTransition = prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.16, ease: [0.165, 0.84, 0.44, 1] as const };
|
||||
const fadeInitial = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 2 };
|
||||
const fadeAnimate = { opacity: 1, y: 0 };
|
||||
const fadeExit = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: -2 };
|
||||
|
||||
useEffect(() => {
|
||||
setShell(defaultShell);
|
||||
}, [defaultShell]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3",
|
||||
showTitle ? "justify-between" : "justify-start",
|
||||
)}
|
||||
>
|
||||
{showTitle ? (
|
||||
<p className="shrink-0 whitespace-nowrap text-sm font-semibold font-heading">
|
||||
Update Unsloth Studio
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex shrink-0 items-center gap-0.5 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShell("windows")}
|
||||
className={cn(
|
||||
"px-0.5 py-0.5 font-medium transition-colors",
|
||||
windows
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-emerald-600",
|
||||
)}
|
||||
aria-pressed={windows}
|
||||
>
|
||||
Windows
|
||||
</button>
|
||||
<span className="text-border">/</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShell("unix")}
|
||||
className={cn(
|
||||
"px-0.5 py-0.5 font-medium transition-colors",
|
||||
!windows
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-emerald-600",
|
||||
)}
|
||||
aria-pressed={!windows}
|
||||
>
|
||||
macOS/Linux
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.p
|
||||
key={`instruction-${shell}`}
|
||||
initial={fadeInitial}
|
||||
animate={fadeAnimate}
|
||||
exit={fadeExit}
|
||||
transition={fadeTransition}
|
||||
className="text-xs text-muted-foreground leading-relaxed"
|
||||
>
|
||||
{getStudioUpdateInstructionLine(shell)}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
<CopyableCommand command={STUDIO_UPDATE_CMD} copyLabel="update command" />
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
If that fails or unsloth studio update is unavailable, run:
|
||||
</p>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={`fallback-${shell}`}
|
||||
initial={fadeInitial}
|
||||
animate={fadeAnimate}
|
||||
exit={fadeExit}
|
||||
transition={fadeTransition}
|
||||
>
|
||||
<CopyableCommand
|
||||
command={
|
||||
windows
|
||||
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
|
||||
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
|
||||
}
|
||||
copyLabel="fallback command"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Restart Studio after updating for changes to take effect.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
Copy01Icon,
|
||||
Tick02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type Lang = "curl" | "python" | "tools";
|
||||
|
||||
const TABS: { id: Lang; label: string }[] = [
|
||||
{ id: "curl", label: "curl" },
|
||||
{ id: "python", label: "Python" },
|
||||
{ id: "tools", label: "Tools" },
|
||||
];
|
||||
|
||||
function buildSnippets(base: string) {
|
||||
return {
|
||||
curl: `curl ${base}/v1/chat/completions \\
|
||||
-H "Authorization: Bearer sk-unsloth-YOUR_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"stream": true
|
||||
}'`,
|
||||
python: `from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="${base}/v1",
|
||||
api_key="sk-unsloth-YOUR_KEY",
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="current",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
stream=True,
|
||||
)
|
||||
for chunk in response:
|
||||
print(chunk.choices[0].delta.content or "", end="")`,
|
||||
tools: `curl ${base}/v1/chat/completions \\
|
||||
-H "Authorization: Bearer sk-unsloth-YOUR_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"messages": [{"role": "user", "content": "Search Python 3.13 features"}],
|
||||
"enable_tools": true,
|
||||
"enabled_tools": ["web_search", "python"],
|
||||
"stream": true
|
||||
}'`,
|
||||
};
|
||||
}
|
||||
|
||||
export function UsageExamples() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [lang, setLang] = useState<Lang>("curl");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const snippets = useMemo(
|
||||
() =>
|
||||
buildSnippets(
|
||||
typeof window !== "undefined" ? window.location.origin : "",
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (copyToClipboard(snippets[lang])) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-fit items-center gap-1.5 rounded text-xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDown01Icon}
|
||||
className={cn("size-3.5 transition-transform", open && "rotate-180")}
|
||||
/>
|
||||
{open ? "Hide usage examples" : "Show usage examples"}
|
||||
</button>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: [0.165, 0.84, 0.44, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
|
||||
<div className="flex items-center justify-between border-b border-border px-2 py-1.5">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{TABS.map((t) => {
|
||||
const active = lang === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setLang(t.id)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
active
|
||||
? "bg-background text-foreground shadow-border"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-1 text-[11px] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Copy snippet"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={copied ? Tick02Icon : Copy01Icon}
|
||||
className={cn("size-3.5", copied && "text-emerald-600")}
|
||||
/>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto p-3 font-mono text-[11px] leading-relaxed text-foreground">
|
||||
{snippets[lang]}
|
||||
</pre>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
6
studio/frontend/src/features/settings/index.ts
Normal file
6
studio/frontend/src/features/settings/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export { SettingsDialog } from "./settings-dialog";
|
||||
export { useSettingsDialogStore } from "./stores/settings-dialog-store";
|
||||
export type { SettingsTab } from "./stores/settings-dialog-store";
|
||||
142
studio/frontend/src/features/settings/settings-dialog.tsx
Normal file
142
studio/frontend/src/features/settings/settings-dialog.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Cancel01Icon,
|
||||
Key01Icon,
|
||||
Message01Icon,
|
||||
PaintBrush02Icon,
|
||||
Settings02Icon,
|
||||
SparklesIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useSettingsDialogStore, type SettingsTab } from "./stores/settings-dialog-store";
|
||||
import { AboutTab } from "./tabs/about-tab";
|
||||
import { ApiKeysTab } from "./tabs/api-keys-tab";
|
||||
import { AppearanceTab } from "./tabs/appearance-tab";
|
||||
import { ChatTab } from "./tabs/chat-tab";
|
||||
import { GeneralTab } from "./tabs/general-tab";
|
||||
|
||||
interface TabDef {
|
||||
id: SettingsTab;
|
||||
label: string;
|
||||
icon: typeof Settings02Icon;
|
||||
}
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ id: "general", label: "General", icon: Settings02Icon },
|
||||
{ id: "appearance", label: "Appearance", icon: PaintBrush02Icon },
|
||||
{ id: "chat", label: "Chat", icon: Message01Icon },
|
||||
{ id: "api-keys", label: "API Keys", icon: Key01Icon },
|
||||
{ id: "about", label: "About", icon: SparklesIcon },
|
||||
];
|
||||
|
||||
function renderTab(tab: SettingsTab) {
|
||||
switch (tab) {
|
||||
case "general":
|
||||
return <GeneralTab />;
|
||||
case "appearance":
|
||||
return <AppearanceTab />;
|
||||
case "chat":
|
||||
return <ChatTab />;
|
||||
case "api-keys":
|
||||
return <ApiKeysTab />;
|
||||
case "about":
|
||||
return <AboutTab />;
|
||||
}
|
||||
}
|
||||
|
||||
export function SettingsDialog() {
|
||||
const open = useSettingsDialogStore((s) => s.open);
|
||||
const activeTab = useSettingsDialogStore((s) => s.activeTab);
|
||||
const setActiveTab = useSettingsDialogStore((s) => s.setActiveTab);
|
||||
const closeDialog = useSettingsDialogStore((s) => s.closeDialog);
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && closeDialog()}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
overlayClassName="bg-background/40"
|
||||
className={cn(
|
||||
"!max-w-none h-[560px] w-[820px] p-0 overflow-hidden",
|
||||
"shadow-border rounded-xl border-border",
|
||||
"sm:h-[560px] sm:w-[820px]",
|
||||
"max-sm:h-dvh max-sm:w-dvw max-sm:rounded-none",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">Settings</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Manage your Unsloth Studio preferences.
|
||||
</DialogDescription>
|
||||
<div className="flex h-full min-h-0">
|
||||
<aside className="flex w-[200px] shrink-0 flex-col border-r border-border bg-muted/20 p-2">
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{TABS.map((tab) => {
|
||||
const active = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"relative flex h-9 items-center gap-2 rounded-md px-2.5 text-sm font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
||||
active
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId="settings-active-pill"
|
||||
className="absolute inset-0 rounded-md bg-accent"
|
||||
transition={
|
||||
reduced
|
||||
? { duration: 0 }
|
||||
: {
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
damping: 35,
|
||||
mass: 0.5,
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<HugeiconsIcon
|
||||
icon={tab.icon}
|
||||
className="relative z-10 size-4"
|
||||
/>
|
||||
<span className="relative z-10">{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className="relative flex min-w-0 flex-1 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeDialog}
|
||||
className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Close settings"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
|
||||
</button>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-6 pr-12">
|
||||
{renderTab(activeTab)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
export type SettingsTab =
|
||||
| "general"
|
||||
| "appearance"
|
||||
| "chat"
|
||||
| "api-keys"
|
||||
| "about";
|
||||
|
||||
interface SettingsDialogState {
|
||||
open: boolean;
|
||||
activeTab: SettingsTab;
|
||||
openDialog: (tab?: SettingsTab) => void;
|
||||
closeDialog: () => void;
|
||||
setActiveTab: (tab: SettingsTab) => void;
|
||||
}
|
||||
|
||||
const ACTIVE_TAB_KEY = "unsloth_settings_active_tab";
|
||||
|
||||
function loadInitialTab(): SettingsTab {
|
||||
if (typeof window === "undefined") return "general";
|
||||
let stored: string | null = null;
|
||||
try {
|
||||
stored = window.localStorage.getItem(ACTIVE_TAB_KEY);
|
||||
} catch {
|
||||
return "general";
|
||||
}
|
||||
const valid: SettingsTab[] = ["general", "appearance", "chat", "api-keys", "about"];
|
||||
return valid.includes(stored as SettingsTab) ? (stored as SettingsTab) : "general";
|
||||
}
|
||||
|
||||
export const useSettingsDialogStore = create<SettingsDialogState>((set) => ({
|
||||
open: false,
|
||||
activeTab: loadInitialTab(),
|
||||
openDialog: (tab) =>
|
||||
set((state) => ({
|
||||
open: true,
|
||||
activeTab: tab ?? state.activeTab,
|
||||
})),
|
||||
closeDialog: () => set({ open: false }),
|
||||
setActiveTab: (tab) => {
|
||||
try {
|
||||
window.localStorage.setItem(ACTIVE_TAB_KEY, tab);
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
set({ activeTab: tab });
|
||||
},
|
||||
}));
|
||||
96
studio/frontend/src/features/settings/stores/theme-store.ts
Normal file
96
studio/frontend/src/features/settings/stores/theme-store.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type Theme = "light" | "dark" | "system";
|
||||
export type ResolvedTheme = "light" | "dark";
|
||||
|
||||
const STORAGE_KEY = "theme";
|
||||
|
||||
function readStoredTheme(): Theme {
|
||||
if (typeof window === "undefined") return "system";
|
||||
let stored: string | null = null;
|
||||
try {
|
||||
stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
return "system";
|
||||
}
|
||||
if (stored === "light" || stored === "dark" || stored === "system") return stored;
|
||||
return "system";
|
||||
}
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
function resolveTheme(theme: Theme): ResolvedTheme {
|
||||
if (theme === "system") return systemPrefersDark() ? "dark" : "light";
|
||||
return theme;
|
||||
}
|
||||
|
||||
function applyToDocument(resolved: ResolvedTheme) {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.classList.toggle("dark", resolved === "dark");
|
||||
}
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
function subscribe(cb: () => void) {
|
||||
listeners.add(cb);
|
||||
if (typeof window === "undefined") {
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const syncTheme = () => {
|
||||
applyToDocument(resolveTheme(readStoredTheme()));
|
||||
cb();
|
||||
};
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEY || e.key === null) syncTheme();
|
||||
};
|
||||
mq.addEventListener("change", syncTheme);
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
listeners.delete(cb);
|
||||
mq.removeEventListener("change", syncTheme);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot(): Theme {
|
||||
return readStoredTheme();
|
||||
}
|
||||
|
||||
function getServerSnapshot(): Theme {
|
||||
return "system";
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for setting the theme. All writers (the Settings
|
||||
* dialog's segmented control AND the sidebar dropdown's animated toggler)
|
||||
* must route through this so the DOM class, localStorage, and React
|
||||
* subscribers stay in sync.
|
||||
*/
|
||||
export function setTheme(next: Theme): void {
|
||||
if (typeof window === "undefined") return;
|
||||
// Persist "system" explicitly so next-themes (mounted with
|
||||
// defaultTheme="light") doesn't clobber the choice on reload.
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
applyToDocument(resolveTheme(next));
|
||||
listeners.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
export function useTheme(): {
|
||||
theme: Theme;
|
||||
resolved: ResolvedTheme;
|
||||
setTheme: (next: Theme) => void;
|
||||
} {
|
||||
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
const resolved = resolveTheme(theme);
|
||||
return { theme, resolved, setTheme };
|
||||
}
|
||||
120
studio/frontend/src/features/settings/tabs/about-tab.tsx
Normal file
120
studio/frontend/src/features/settings/tabs/about-tab.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ShutdownDialog } from "@/components/shutdown-dialog";
|
||||
import { UpdateStudioInstructions } from "../components/update-studio-instructions";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { removeTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
|
||||
import {
|
||||
ArrowUpRight01Icon,
|
||||
Book03Icon,
|
||||
Cancel01Icon,
|
||||
MessageNotification01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SettingsRow } from "../components/settings-row";
|
||||
import { SettingsSection } from "../components/settings-section";
|
||||
|
||||
export function AboutTab() {
|
||||
const deviceType = usePlatformStore((s) => s.deviceType);
|
||||
const defaultShell = deviceType === "windows" ? "windows" : "unix";
|
||||
const [shutdownOpen, setShutdownOpen] = useState(false);
|
||||
const [version, setVersion] = useState("dev");
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/health");
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { version?: string };
|
||||
if (!canceled && data.version) {
|
||||
setVersion(data.version);
|
||||
}
|
||||
} catch {
|
||||
// fall back to dev label
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">About</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Unsloth Studio build info and support.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SettingsSection title="Studio">
|
||||
<SettingsRow label="Version">
|
||||
<code className="font-mono text-xs text-muted-foreground">{version}</code>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Updates">
|
||||
<div className="py-2">
|
||||
<UpdateStudioInstructions defaultShell={defaultShell} showTitle={false} />
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Help">
|
||||
<SettingsRow label="Documentation">
|
||||
<a
|
||||
href="https://unsloth.ai/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={Book03Icon} className="size-3.5" />
|
||||
unsloth.ai/docs
|
||||
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
|
||||
</a>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Feedback">
|
||||
<a
|
||||
href="https://github.com/unslothai/unsloth/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={MessageNotification01Icon} className="size-3.5" />
|
||||
Report an issue
|
||||
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
|
||||
</a>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Danger zone">
|
||||
<SettingsRow
|
||||
destructive
|
||||
label="Shut down Unsloth Studio"
|
||||
description="Stops the Studio server process and ends your session."
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShutdownOpen(true)}
|
||||
className="text-destructive hover:text-destructive hover:border-destructive/60"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-3.5 mr-1.5" />
|
||||
Shut down
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<ShutdownDialog
|
||||
open={shutdownOpen}
|
||||
onOpenChange={setShutdownOpen}
|
||||
onAfterShutdown={removeTrainingUnloadGuard}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
studio/frontend/src/features/settings/tabs/api-keys-tab.tsx
Normal file
159
studio/frontend/src/features/settings/tabs/api-keys-tab.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { fetchApiKeys, revokeApiKey, type ApiKey } from "../api/api-keys";
|
||||
import { ApiKeyRow } from "../components/api-key-row";
|
||||
import { CreateKeyForm } from "../components/create-key-form";
|
||||
import { KeyRevealCard } from "../components/key-reveal-card";
|
||||
import { UsageExamples } from "../components/usage-examples";
|
||||
|
||||
export function ApiKeysTab() {
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [revokeTarget, setRevokeTarget] = useState<ApiKey | null>(null);
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
const [revealed, setRevealed] = useState<string | null>(null);
|
||||
const reduced = useReducedMotion();
|
||||
const t = reduced
|
||||
? { duration: 0 }
|
||||
: { duration: 0.18, ease: [0.165, 0.84, 0.44, 1] as const };
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setKeys(await fetchApiKeys());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn't load API keys.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const confirmRevoke = async () => {
|
||||
if (!revokeTarget) return;
|
||||
setRevoking(true);
|
||||
try {
|
||||
await revokeApiKey(revokeTarget.id);
|
||||
await load();
|
||||
setRevokeTarget(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn't revoke key.");
|
||||
} finally {
|
||||
setRevoking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">API Keys</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Access Unsloth Studio programmatically via the OpenAI-compatible API.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{revealed !== null ? (
|
||||
<motion.div
|
||||
key="reveal"
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={t}
|
||||
>
|
||||
<KeyRevealCard
|
||||
rawKey={revealed}
|
||||
onDone={() => setRevealed(null)}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="form"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 4 }}
|
||||
transition={t}
|
||||
>
|
||||
<CreateKeyForm
|
||||
onCreated={(raw) => {
|
||||
setRevealed(raw);
|
||||
void load();
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<section className="flex flex-col">
|
||||
<h2 className="mb-2 text-sm font-semibold text-foreground">Your keys</h2>
|
||||
{error ? (
|
||||
<div className="rounded-md border border-destructive/20 bg-destructive/5 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
{[0, 1].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-12 animate-pulse rounded-md bg-muted/40"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">
|
||||
No API keys yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{keys.map((k) => (
|
||||
<ApiKeyRow key={k.id} apiKey={k} onRevoke={setRevokeTarget} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<UsageExamples />
|
||||
|
||||
<Dialog open={revokeTarget !== null} onOpenChange={(o) => !o && setRevokeTarget(null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Revoke key “{revokeTarget?.name}”?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Applications using this key will immediately lose access. This cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRevokeTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmRevoke}
|
||||
disabled={revoking}
|
||||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
>
|
||||
{revoking ? "Revoking…" : `Revoke “${revokeTarget?.name}”`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useSidebarPin } from "@/hooks/use-sidebar-pin";
|
||||
import { SettingsRow } from "../components/settings-row";
|
||||
import { SettingsSection } from "../components/settings-section";
|
||||
import { ThemeSegmented } from "../components/theme-segmented";
|
||||
|
||||
export function AppearanceTab() {
|
||||
const { pinned, setPinned } = useSidebarPin();
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">Appearance</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How Unsloth Studio looks on this device.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SettingsSection title="Theme">
|
||||
<SettingsRow
|
||||
label="Color scheme"
|
||||
description="Choose light, dark, or follow your system."
|
||||
>
|
||||
<ThemeSegmented />
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Layout">
|
||||
<SettingsRow
|
||||
label="Pin sidebar by default"
|
||||
description="Keep the sidebar expanded instead of collapsing to icons."
|
||||
>
|
||||
<Switch checked={pinned} onCheckedChange={setPinned} />
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
studio/frontend/src/features/settings/tabs/chat-tab.tsx
Normal file
129
studio/frontend/src/features/settings/tabs/chat-tab.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
clearAllChats,
|
||||
countAllChats,
|
||||
} from "@/features/chat/utils/clear-all-chats";
|
||||
import { downloadChatExport } from "@/features/chat/utils/export-chat-history";
|
||||
import { Delete02Icon, Download02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SettingsRow } from "../components/settings-row";
|
||||
import { SettingsSection } from "../components/settings-section";
|
||||
|
||||
export function ChatTab() {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [count, setCount] = useState<number | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void countAllChats().then(setCount);
|
||||
}, []);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
await downloadChatExport();
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
setClearing(true);
|
||||
try {
|
||||
await clearAllChats();
|
||||
setCount(0);
|
||||
setConfirmOpen(false);
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">Chat</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Manage your chat history stored on this device.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SettingsSection title="Data">
|
||||
<SettingsRow
|
||||
label="Export chat history"
|
||||
description="Download all chats and messages as a JSON file."
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={exporting || count === 0}
|
||||
>
|
||||
<HugeiconsIcon icon={Download02Icon} className="size-3.5 mr-1.5" />
|
||||
{exporting ? "Exporting…" : "Export"}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
destructive
|
||||
label="Clear all chats"
|
||||
description={
|
||||
count === null
|
||||
? "Permanently delete every chat on this device."
|
||||
: count === 0
|
||||
? "No chats to clear."
|
||||
: `Permanently delete all ${count} chat${count === 1 ? "" : "s"} on this device.`
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={count === 0}
|
||||
className="text-destructive hover:text-destructive hover:border-destructive/60"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-3.5 mr-1.5" />
|
||||
Clear chats
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Clear {count ?? 0} chat{count === 1 ? "" : "s"}?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This permanently deletes every chat and message stored on this device. This cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClear}
|
||||
disabled={clearing}
|
||||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
>
|
||||
{clearing ? "Clearing…" : `Clear ${count ?? 0} chat${count === 1 ? "" : "s"}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
studio/frontend/src/features/settings/tabs/general-tab.tsx
Normal file
237
studio/frontend/src/features/settings/tabs/general-tab.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { resetOnboardingDone } from "@/features/auth";
|
||||
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
|
||||
import { useSettingsDialogStore } from "@/features/settings";
|
||||
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { SettingsRow } from "../components/settings-row";
|
||||
import { SettingsSection } from "../components/settings-section";
|
||||
|
||||
// Keys cleared by "Reset all local preferences".
|
||||
//
|
||||
// NEVER include auth / session keys here — resetting them would log the user
|
||||
// out, which is not what users expect from a "reset preferences" button.
|
||||
//
|
||||
// Explicitly EXCLUDED:
|
||||
// - "unsloth_auth_token" (auth: access token)
|
||||
// - "unsloth_auth_refresh_token" (auth: refresh token)
|
||||
// - "unsloth_auth_must_change_password" (auth: forced password change flag)
|
||||
// - "unsloth_onboarding_done" (session: would force re-onboarding)
|
||||
const PREFS_KEYS: string[] = [
|
||||
// Appearance
|
||||
"theme",
|
||||
// UI state
|
||||
"sidebar_pinned",
|
||||
"unsloth_sidebar_navigate_open",
|
||||
"unsloth_settings_active_tab",
|
||||
// Chat runtime prefs
|
||||
"unsloth_chat_auto_title",
|
||||
"unsloth_hf_token",
|
||||
"unsloth_auto_heal_tool_calls",
|
||||
"unsloth_max_tool_calls_per_message",
|
||||
"unsloth_tool_call_timeout",
|
||||
"unsloth_chat_inference_params",
|
||||
"unsloth_chat_collapsible_state",
|
||||
// Chat presets
|
||||
"unsloth_chat_custom_presets",
|
||||
"unsloth_chat_active_preset",
|
||||
"unsloth_chat_system_prompts",
|
||||
"unsloth_chat_system_prompts_migrated",
|
||||
// Training UI prefs
|
||||
"unsloth_training_config_v1",
|
||||
"unsloth_prev_max_steps",
|
||||
"unsloth_prev_save_steps",
|
||||
// Guided tour flags
|
||||
"tour:studio:v1",
|
||||
];
|
||||
|
||||
// Set to true from resetAllPrefs so the unmount-commit effect skips writing
|
||||
// back the in-memory draft — otherwise the cleanup would re-persist the old
|
||||
// HF token into localStorage after it was just cleared, and the subsequent
|
||||
// reload would read the re-written value.
|
||||
let resetInProgress = false;
|
||||
|
||||
function resetAllPrefs() {
|
||||
resetInProgress = true;
|
||||
for (const key of PREFS_KEYS) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
export function GeneralTab() {
|
||||
const navigate = useNavigate();
|
||||
const closeDialog = useSettingsDialogStore((s) => s.closeDialog);
|
||||
const { pathname, search } = useRouterState({
|
||||
select: (s) => ({
|
||||
pathname: s.location.pathname,
|
||||
search:
|
||||
"searchStr" in s.location
|
||||
? (s.location as { searchStr?: string }).searchStr ?? ""
|
||||
: typeof window !== "undefined"
|
||||
? window.location.search
|
||||
: "",
|
||||
}),
|
||||
});
|
||||
const hfToken = useChatRuntimeStore((s) => s.hfToken);
|
||||
const setHfToken = useChatRuntimeStore((s) => s.setHfToken);
|
||||
const autoTitle = useChatRuntimeStore((s) => s.autoTitle);
|
||||
const setAutoTitle = useChatRuntimeStore((s) => s.setAutoTitle);
|
||||
const chatOnly = usePlatformStore((s) => s.chatOnly);
|
||||
const redirectTo = `${pathname}${search}`;
|
||||
|
||||
const [draftToken, setDraftToken] = useState(hfToken ?? "");
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const draftRef = useRef(draftToken);
|
||||
useEffect(() => {
|
||||
draftRef.current = draftToken;
|
||||
}, [draftToken]);
|
||||
|
||||
// Commit on unmount (dialog close / tab switch). Skip during reset-prefs
|
||||
// flow so we don't re-persist the draft after localStorage was cleared.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetInProgress) return;
|
||||
const trimmed = draftRef.current.trim();
|
||||
const current = useChatRuntimeStore.getState().hfToken;
|
||||
if (trimmed !== current) {
|
||||
useChatRuntimeStore.getState().setHfToken(trimmed);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const commitToken = () => {
|
||||
const trimmed = draftToken.trim();
|
||||
if (trimmed !== draftToken) setDraftToken(trimmed);
|
||||
if (trimmed !== hfToken) setHfToken(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">General</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Global preferences for Unsloth Studio.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SettingsSection title="Account">
|
||||
<SettingsRow
|
||||
label="Hugging Face token"
|
||||
description="Used to load gated models and push artifacts."
|
||||
>
|
||||
<div className="relative w-[260px]">
|
||||
<Input
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder="hf_…"
|
||||
value={draftToken}
|
||||
onChange={(e) => setDraftToken(e.target.value)}
|
||||
onBlur={commitToken}
|
||||
className="h-8 w-full pr-8 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken((s) => !s)}
|
||||
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={showToken ? "Hide token" : "Show token"}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showToken ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Chat defaults">
|
||||
<SettingsRow
|
||||
label="Auto-title new chats"
|
||||
description="Generate a short title from the first message."
|
||||
>
|
||||
<Switch checked={autoTitle} onCheckedChange={setAutoTitle} />
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
{!chatOnly && (
|
||||
<SettingsSection title="Getting started">
|
||||
<SettingsRow
|
||||
label="Start onboarding"
|
||||
description="Open the setup wizard again without changing your account."
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
resetOnboardingDone();
|
||||
closeDialog();
|
||||
navigate({ to: "/onboarding", search: { redirectTo } });
|
||||
}}
|
||||
>
|
||||
Start onboarding
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection title="Danger zone">
|
||||
<SettingsRow
|
||||
destructive
|
||||
label="Reset all local preferences"
|
||||
description="Clears theme, tokens, sidebar state, and presets. Chats and API keys are not affected."
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
className="text-destructive hover:text-destructive hover:border-destructive/60"
|
||||
>
|
||||
Reset preferences
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset all local preferences?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This clears your theme, tokens, and stored settings, then reloads
|
||||
Studio. Chats and API keys are not affected.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={resetAllPrefs}
|
||||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
>
|
||||
Reset and reload
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,7 +14,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import { type ReactElement, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { DatasetPreviewDialog } from "./sections/dataset-preview-dialog";
|
||||
import { DatasetSection } from "./sections/dataset-section";
|
||||
import { ModelSection } from "./sections/model-section";
|
||||
|
|
@ -24,8 +25,6 @@ import { LiveTrainingView } from "./live-training-view";
|
|||
import { HistoricalTrainingView } from "./historical-training-view";
|
||||
import { HistoryCardGrid } from "./history-card-grid";
|
||||
|
||||
const STUDIO_TOUR_KEY = "tour:studio:v1";
|
||||
|
||||
export function StudioPage(): ReactElement {
|
||||
useTrainingRuntimeLifecycle();
|
||||
const showTrainingView = useTrainingRuntimeStore(shouldShowTrainingView);
|
||||
|
|
@ -49,7 +48,12 @@ export function StudioPage(): ReactElement {
|
|||
const closeDialog = useDatasetPreviewDialogStore((s) => s.close);
|
||||
|
||||
const [requestedTab, setRequestedTab] = useState("configure");
|
||||
const [selectedHistoryRunId, setSelectedHistoryRunId] = useState<string | null>(null);
|
||||
const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId);
|
||||
const setSelectedHistoryRunId = useTrainingRuntimeStore((s) => s.setSelectedHistoryRunId);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setSelectedHistoryRunId(null);
|
||||
}, [setSelectedHistoryRunId]);
|
||||
|
||||
// Derive activeTab: auto-switch to "current-run" only while training is
|
||||
// genuinely running. Once training ends, honour whatever tab the user clicks.
|
||||
|
|
@ -61,15 +65,24 @@ export function StudioPage(): ReactElement {
|
|||
? "configure"
|
||||
: requestedTab;
|
||||
|
||||
const { setPinned } = useSidebar();
|
||||
const pinSidebar = useCallback(() => setPinned(true), [setPinned]);
|
||||
|
||||
const tourEnabled = hasHydratedRuntime && !isHydratingRuntime;
|
||||
const isConfigTour = activeTab === "configure";
|
||||
const tourSteps = activeTab === "current-run" ? studioTrainingTourSteps : studioTourSteps;
|
||||
const baseTourSteps = activeTab === "current-run" ? studioTrainingTourSteps : studioTourSteps;
|
||||
// Inject onEnter for navbar-targeting steps so the sidebar expands during the tour.
|
||||
const tourSteps = useMemo(
|
||||
() =>
|
||||
baseTourSteps.map((step) =>
|
||||
step.target === "navbar" ? { ...step, onEnter: pinSidebar } : step,
|
||||
),
|
||||
[baseTourSteps, pinSidebar],
|
||||
);
|
||||
const tour = useGuidedTourController({
|
||||
id: "studio",
|
||||
steps: tourSteps,
|
||||
enabled: tourEnabled,
|
||||
autoKey: isConfigTour ? STUDIO_TOUR_KEY : undefined,
|
||||
autoWhen: isConfigTour,
|
||||
});
|
||||
|
||||
const setTourOpen = tour.setOpen;
|
||||
|
|
@ -86,6 +99,14 @@ export function StudioPage(): ReactElement {
|
|||
}
|
||||
}, [isTrainingRunning, requestedTab]);
|
||||
|
||||
// Selecting a run from the sidebar only sets selectedHistoryRunId; auto-switch
|
||||
// to the History tab so the main panel reflects the selection.
|
||||
useEffect(() => {
|
||||
if (selectedHistoryRunId && requestedTab !== "history") {
|
||||
setRequestedTab("history");
|
||||
}
|
||||
}, [selectedHistoryRunId, requestedTab]);
|
||||
|
||||
useEffect(() => {
|
||||
ensureModelDefaultsLoaded();
|
||||
ensureDatasetChecked();
|
||||
|
|
@ -106,7 +127,7 @@ export function StudioPage(): ReactElement {
|
|||
})();
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden bg-background">
|
||||
<div className="relative min-h-screen bg-background">
|
||||
<main className="relative z-10 mx-auto max-w-7xl px-4 py-4 sm:px-6">
|
||||
<GuidedTour {...tour.tourProps} celebrate={isConfigTour} />
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { listTrainingRuns } from "../api/history-api";
|
||||
import type { TrainingRunSummary } from "../types/history";
|
||||
|
||||
const SIDEBAR_LIMIT = 20;
|
||||
const RUNNING_POLL_MS = 5000;
|
||||
|
||||
export function useTrainingHistorySidebarItems(enabled: boolean) {
|
||||
const [items, setItems] = useState<TrainingRunSummary[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const inFlightRef = useRef(false);
|
||||
|
||||
const fetchRuns = useCallback(async () => {
|
||||
if (inFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
inFlightRef.current = true;
|
||||
try {
|
||||
const result = await listTrainingRuns(SIDEBAR_LIMIT, 0, controller.signal);
|
||||
setItems(result.runs);
|
||||
setLoaded(true);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
} finally {
|
||||
if (controllerRef.current === controller) {
|
||||
controllerRef.current = null;
|
||||
}
|
||||
inFlightRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
void fetchRuns();
|
||||
return () => {
|
||||
controllerRef.current?.abort();
|
||||
};
|
||||
}, [enabled, fetchRuns]);
|
||||
|
||||
const hasRunning = items.some((r) => r.status === "running");
|
||||
useEffect(() => {
|
||||
if (!enabled || !hasRunning) return;
|
||||
const timer = setInterval(() => {
|
||||
void fetchRuns();
|
||||
}, RUNNING_POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [enabled, hasRunning, fetchRuns]);
|
||||
|
||||
return { items, loaded, refresh: fetchRuns };
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTrainingRuntimeStore } from "@/features/training";
|
||||
|
||||
let currentHandler: ((e: BeforeUnloadEvent) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Mounts a beforeunload guard that warns the user if training is running.
|
||||
* Call once at the app root.
|
||||
*/
|
||||
export function useTrainingUnloadGuard() {
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (!useTrainingRuntimeStore.getState().isTrainingRunning) return;
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
};
|
||||
currentHandler = handler;
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => {
|
||||
if (currentHandler === handler) currentHandler = null;
|
||||
window.removeEventListener("beforeunload", handler);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the active beforeunload guard (if any).
|
||||
* Call this before intentionally ending the session (e.g. shutting down
|
||||
* the Studio server) so the "Server stopped" page can render without
|
||||
* the browser prompting the user to confirm leaving.
|
||||
*/
|
||||
export function removeTrainingUnloadGuard() {
|
||||
if (currentHandler) {
|
||||
window.removeEventListener("beforeunload", currentHandler);
|
||||
currentHandler = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ export {
|
|||
useTrainingRuntimeStore,
|
||||
} from "./stores/training-runtime-store";
|
||||
export { useTrainingActions } from "./hooks/use-training-actions";
|
||||
export { useTrainingHistorySidebarItems } from "./hooks/use-training-history-sidebar";
|
||||
export { useTrainingRuntimeLifecycle } from "./hooks/use-training-runtime-lifecycle";
|
||||
export { useMaxStepsEpochsToggle } from "./hooks/use-max-steps-epochs-toggle";
|
||||
export { HfDatasetSubsetSplitSelectors } from "./components/hf-dataset-subset-split-selectors";
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ const initialState: TrainingRuntimeState = {
|
|||
evalLossHistory: [],
|
||||
resetGeneration: 0,
|
||||
stopRequested: false,
|
||||
selectedHistoryRunId: null,
|
||||
};
|
||||
|
||||
function sortSeries(points: TrainingSeriesPoint[]): TrainingSeriesPoint[] {
|
||||
|
|
@ -171,6 +172,9 @@ export const useTrainingRuntimeStore = create<TrainingRuntimeStore>()((set) => (
|
|||
sseConnected: false,
|
||||
}),
|
||||
|
||||
setSelectedHistoryRunId: (selectedHistoryRunId) =>
|
||||
set({ selectedHistoryRunId }),
|
||||
|
||||
applyStatus: (payload) =>
|
||||
set((state) => {
|
||||
const metricHistory = applyMetricHistoryFromStatus(payload);
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export interface TrainingRuntimeState {
|
|||
evalLossHistory: TrainingSeriesPoint[];
|
||||
resetGeneration: number;
|
||||
stopRequested: boolean;
|
||||
selectedHistoryRunId: string | null;
|
||||
}
|
||||
|
||||
export interface TrainingRuntimeActions {
|
||||
|
|
@ -115,6 +116,7 @@ export interface TrainingRuntimeActions {
|
|||
applyProgress: (payload: TrainingProgressPayload, eventId?: number) => void;
|
||||
setStartQueued: (jobId: string, message: string) => void;
|
||||
setRuntimeError: (message: string) => void;
|
||||
setSelectedHistoryRunId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export type TrainingRuntimeStore = TrainingRuntimeState & TrainingRuntimeActions;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,23 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
const MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
function getSnapshot(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.matchMedia(MEDIA_QUERY).matches;
|
||||
}
|
||||
|
||||
function subscribe(callback: () => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
const mql = window.matchMedia(MEDIA_QUERY);
|
||||
mql.addEventListener("change", callback);
|
||||
return () => mql.removeEventListener("change", callback);
|
||||
}
|
||||
|
||||
export function useIsMobile(): boolean {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, () => false);
|
||||
}
|
||||
|
|
|
|||
59
studio/frontend/src/hooks/use-sidebar-pin.ts
Normal file
59
studio/frontend/src/hooks/use-sidebar-pin.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
const PINNED_KEY = "sidebar_pinned";
|
||||
|
||||
function loadPinned(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PINNED_KEY);
|
||||
if (raw === null) return true;
|
||||
return raw === "true";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let pinnedValue = loadPinned();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
listeners.add(cb);
|
||||
if (typeof window === "undefined") {
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === PINNED_KEY || e.key === null) {
|
||||
pinnedValue = loadPinned();
|
||||
cb();
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
listeners.delete(cb);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}
|
||||
|
||||
function setPinnedGlobal(next: boolean) {
|
||||
pinnedValue = next;
|
||||
try {
|
||||
window.localStorage.setItem(PINNED_KEY, String(next));
|
||||
} catch {}
|
||||
listeners.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
export function useSidebarPin() {
|
||||
const pinned = useSyncExternalStore(
|
||||
subscribe,
|
||||
() => pinnedValue,
|
||||
() => false,
|
||||
);
|
||||
|
||||
const setPinned = useCallback((value: boolean) => setPinnedGlobal(value), []);
|
||||
const togglePinned = useCallback(() => setPinnedGlobal(!pinnedValue), []);
|
||||
|
||||
return { pinned, setPinned, togglePinned };
|
||||
}
|
||||
|
|
@ -15,439 +15,501 @@
|
|||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@font-face {
|
||||
font-family: "Hellix";
|
||||
src: url("/fonts/Hellix-SemiBold.woff2") format("woff2"),
|
||||
url("/fonts/Hellix-SemiBold.woff") format("woff");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-family: "Hellix";
|
||||
src: url("/fonts/Hellix-SemiBold.woff2") format("woff2"),
|
||||
url("/fonts/Hellix-SemiBold.woff") format("woff");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Animation timing */
|
||||
--duration-micro: 100ms;
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 200ms;
|
||||
/* Animation timing */
|
||||
--duration-micro: 100ms;
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 200ms;
|
||||
|
||||
/* Easing curves (Emil Kowalski) */
|
||||
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
/* Easing curves (Emil Kowalski) */
|
||||
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.2686 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.1281 0.0179 169.2764);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.1281 0.0179 169.2764);
|
||||
--primary: oklch(0.6929 0.1396 166.5513);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.9596 0.0275 167.8295);
|
||||
--secondary-foreground: oklch(0.2868 0.0649 159.9823);
|
||||
--muted: oklch(0.9702 0 0);
|
||||
--muted-foreground: oklch(0.5486 0 0);
|
||||
--accent: oklch(0.9596 0.0275 167.8295);
|
||||
--accent-foreground: oklch(0.2868 0.0649 159.9823);
|
||||
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||
--border: oklch(0.9208 0.0101 164.8536);
|
||||
--input: oklch(0.9208 0.0101 164.8536);
|
||||
--ring: oklch(0.6929 0.1396 166.5513);
|
||||
--chart-1: oklch(0.6929 0.1396 166.5513);
|
||||
--chart-2: oklch(0.694 0.1395 136.6059);
|
||||
--chart-3: oklch(0.7014 0.1193 197.5897);
|
||||
--chart-4: oklch(0.6926 0.1112 346.5775);
|
||||
--chart-5: oklch(0.7497 0.1003 85.0057);
|
||||
--radius: 1.2rem;
|
||||
--sidebar: oklch(0.975 0 0);
|
||||
--sidebar-foreground: oklch(0.1281 0.0179 169.2764);
|
||||
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.96 0.0279 166.55);
|
||||
--sidebar-accent-foreground: oklch(0.2868 0.0649 159.9823);
|
||||
--sidebar-border: oklch(0.9208 0.0101 164.8536);
|
||||
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
|
||||
--font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans);
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-opacity: 0;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-offset-x: 0px;
|
||||
--shadow-offset-y: 0px;
|
||||
--letter-spacing: 0em;
|
||||
--spacing: 0.25rem;
|
||||
/*--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-sm:*/
|
||||
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
|
||||
/* 0px 1px 2px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow:*/
|
||||
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
|
||||
/* 0px 1px 2px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-md:*/
|
||||
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
|
||||
/* 0px 2px 4px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-lg:*/
|
||||
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
|
||||
/* 0px 4px 6px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-xl:*/
|
||||
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
|
||||
/* 0px 8px 10px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
|
||||
--tracking-normal: 0em;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.2686 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.1281 0.0179 169.2764);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.1281 0.0179 169.2764);
|
||||
--primary: oklch(0.6929 0.1396 166.5513);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.9596 0.0275 167.8295);
|
||||
--secondary-foreground: oklch(0.2868 0.0649 159.9823);
|
||||
--muted: oklch(0.9702 0 0);
|
||||
--muted-foreground: oklch(0.5486 0 0);
|
||||
--accent: oklch(0.9596 0.0275 167.8295);
|
||||
--accent-foreground: oklch(0.2868 0.0649 159.9823);
|
||||
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||
--border: oklch(0.9208 0.0101 164.8536);
|
||||
--input: oklch(0.9208 0.0101 164.8536);
|
||||
--ring: oklch(0.6929 0.1396 166.5513);
|
||||
--chart-1: oklch(0.6929 0.1396 166.5513);
|
||||
--chart-2: oklch(0.694 0.1395 136.6059);
|
||||
--chart-3: oklch(0.7014 0.1193 197.5897);
|
||||
--chart-4: oklch(0.6926 0.1112 346.5775);
|
||||
--chart-5: oklch(0.7497 0.1003 85.0057);
|
||||
--radius: 1.1rem;
|
||||
--sidebar: oklch(0.99 0 0);
|
||||
--sidebar-foreground: oklch(0.1281 0.0179 169.2764);
|
||||
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.96 0.0279 166.55);
|
||||
--sidebar-accent-foreground: oklch(0.2868 0.0649 159.9823);
|
||||
--sidebar-border: oklch(0.9208 0.0101 164.8536);
|
||||
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
|
||||
--font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans);
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-opacity: 0;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-offset-x: 0px;
|
||||
--shadow-offset-y: 0px;
|
||||
--letter-spacing: 0em;
|
||||
--spacing: 0.25rem;
|
||||
/*--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-sm:*/
|
||||
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
|
||||
/* 0px 1px 2px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow:*/
|
||||
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
|
||||
/* 0px 1px 2px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-md:*/
|
||||
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
|
||||
/* 0px 2px 4px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-lg:*/
|
||||
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
|
||||
/* 0px 4px 6px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-xl:*/
|
||||
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
|
||||
/* 0px 8px 10px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
|
||||
--tracking-normal: -0.01em;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.24 0 0);
|
||||
--foreground: oklch(0.98 0 0);
|
||||
--card: oklch(0.28 0 0);
|
||||
--card-foreground: oklch(0.98 0 0);
|
||||
--popover: oklch(0.28 0 0);
|
||||
--popover-foreground: oklch(0.98 0 0);
|
||||
--primary: oklch(0.6929 0.1396 166.5513);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.33 0 0);
|
||||
--secondary-foreground: oklch(0.98 0 0);
|
||||
--muted: oklch(0.33 0 0);
|
||||
--muted-foreground: oklch(0.70 0 0);
|
||||
--accent: oklch(0.33 0 0);
|
||||
--accent-foreground: oklch(0.98 0 0);
|
||||
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||
--border: oklch(0.38 0 0);
|
||||
--input: oklch(0.38 0 0);
|
||||
--ring: oklch(0.6929 0.1396 166.5513);
|
||||
--chart-1: oklch(0.7511 0.1407 166.2284);
|
||||
--chart-2: oklch(0.75 0.14 136.5572);
|
||||
--chart-3: oklch(0.7554 0.1285 197.339);
|
||||
--chart-4: oklch(0.7503 0.1199 346.7805);
|
||||
--chart-5: oklch(0.799 0.1196 84.6633);
|
||||
--sidebar: oklch(0.24 0 0);
|
||||
--sidebar-foreground: oklch(0.98 0 0);
|
||||
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.33 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.98 0 0);
|
||||
--sidebar-border: oklch(0.38 0 0);
|
||||
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--radius: 1.2rem;
|
||||
--font-sans: Geist, ui-sans-serif, sans-serif, system-ui;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-opacity: 0;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-offset-x: 0px;
|
||||
--shadow-offset-y: 0px;
|
||||
--letter-spacing: 0em;
|
||||
--spacing: 0.25rem;
|
||||
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px
|
||||
hsl(0 0% 0% / 0);
|
||||
--shadow: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-md: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 2px 4px 0px
|
||||
hsl(0 0% 0% / 0);
|
||||
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px 0px
|
||||
hsl(0 0% 0% / 0);
|
||||
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px 0px
|
||||
hsl(0 0% 0% / 0);
|
||||
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);
|
||||
--background: oklch(0.24 0 0);
|
||||
--foreground: oklch(0.98 0 0);
|
||||
--card: oklch(0.28 0 0);
|
||||
--card-foreground: oklch(0.98 0 0);
|
||||
--popover: oklch(0.28 0 0);
|
||||
--popover-foreground: oklch(0.98 0 0);
|
||||
--primary: oklch(0.6929 0.1396 166.5513);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.33 0 0);
|
||||
--secondary-foreground: oklch(0.98 0 0);
|
||||
--muted: oklch(0.33 0 0);
|
||||
--muted-foreground: oklch(0.70 0 0);
|
||||
--accent: oklch(0.33 0 0);
|
||||
--accent-foreground: oklch(0.98 0 0);
|
||||
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||
--border: oklch(0.38 0 0);
|
||||
--input: oklch(0.38 0 0);
|
||||
--ring: oklch(0.6929 0.1396 166.5513);
|
||||
--chart-1: oklch(0.7511 0.1407 166.2284);
|
||||
--chart-2: oklch(0.75 0.14 136.5572);
|
||||
--chart-3: oklch(0.7554 0.1285 197.339);
|
||||
--chart-4: oklch(0.7503 0.1199 346.7805);
|
||||
--chart-5: oklch(0.799 0.1196 84.6633);
|
||||
--sidebar: oklch(0.24 0 0);
|
||||
--sidebar-foreground: oklch(0.98 0 0);
|
||||
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.33 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.98 0 0);
|
||||
--sidebar-border: oklch(0.38 0 0);
|
||||
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--radius: 1.1rem;
|
||||
--font-sans: Geist, ui-sans-serif, sans-serif, system-ui;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-opacity: 0;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-offset-x: 0px;
|
||||
--shadow-offset-y: 0px;
|
||||
--letter-spacing: 0em;
|
||||
--spacing: 0.25rem;
|
||||
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px hsl(0 0% 0% / 0);
|
||||
--shadow: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-md: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 2px 4px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
|
||||
--font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-code-block: #181818;
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--radius: 1.2rem;
|
||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
||||
--tracking-normal: var(--tracking-normal);
|
||||
/*--shadow-2xl: var(--shadow-2xl);*/
|
||||
/*--shadow-xl: var(--shadow-xl);*/
|
||||
/*--shadow-lg: var(--shadow-lg);*/
|
||||
/*--shadow-md: var(--shadow-md);*/
|
||||
/*--shadow: var(--shadow);*/
|
||||
/*--shadow-sm: var(--shadow-sm);*/
|
||||
/*--shadow-xs: var(--shadow-xs);*/
|
||||
/*--shadow-2xs: var(--shadow-2xs);*/
|
||||
/*--spacing: var(--spacing);*/
|
||||
/*--letter-spacing: var(--letter-spacing);*/
|
||||
/*--shadow-offset-y: var(--shadow-offset-y);*/
|
||||
/*--shadow-offset-x: var(--shadow-offset-x);*/
|
||||
/*--shadow-spread: var(--shadow-spread);*/
|
||||
/*--shadow-blur: var(--shadow-blur);*/
|
||||
/*--shadow-opacity: var(--shadow-opacity);*/
|
||||
/*--color-shadow-color: var(--shadow-color);*/
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--animate-pulse: pulse var(--duration) ease-out infinite;
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 var(--pulse-color);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px var(--pulse-color);
|
||||
}
|
||||
}
|
||||
--animate-shiny-text: shiny-text 8s infinite;
|
||||
@keyframes shiny-text {
|
||||
0%,
|
||||
90%,
|
||||
100% {
|
||||
background-position: calc(-100% - var(--shiny-width)) 0;
|
||||
}
|
||||
30%,
|
||||
60% {
|
||||
background-position: calc(100% + var(--shiny-width)) 0;
|
||||
}
|
||||
}
|
||||
--animate-shine: shine var(--duration) infinite linear
|
||||
;
|
||||
@keyframes shine {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
to {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
}}
|
||||
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
|
||||
--font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-code-block: #181818;
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--radius: 1.1rem;
|
||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
||||
--tracking-normal: var(--tracking-normal);
|
||||
/*--shadow-2xl: var(--shadow-2xl);*/
|
||||
/*--shadow-xl: var(--shadow-xl);*/
|
||||
/*--shadow-lg: var(--shadow-lg);*/
|
||||
/*--shadow-md: var(--shadow-md);*/
|
||||
/*--shadow: var(--shadow);*/
|
||||
/*--shadow-sm: var(--shadow-sm);*/
|
||||
/*--shadow-xs: var(--shadow-xs);*/
|
||||
/*--shadow-2xs: var(--shadow-2xs);*/
|
||||
/*--spacing: var(--spacing);*/
|
||||
/*--letter-spacing: var(--letter-spacing);*/
|
||||
/*--shadow-offset-y: var(--shadow-offset-y);*/
|
||||
/*--shadow-offset-x: var(--shadow-offset-x);*/
|
||||
/*--shadow-spread: var(--shadow-spread);*/
|
||||
/*--shadow-blur: var(--shadow-blur);*/
|
||||
/*--shadow-opacity: var(--shadow-opacity);*/
|
||||
/*--color-shadow-color: var(--shadow-color);*/
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--animate-pulse: pulse var(--duration) ease-out infinite;
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 var(--pulse-color);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px var(--pulse-color);
|
||||
}
|
||||
}
|
||||
|
||||
--animate-shiny-text: shiny-text 8s infinite;
|
||||
|
||||
@keyframes shiny-text {
|
||||
|
||||
0%,
|
||||
90%,
|
||||
100% {
|
||||
background-position: calc(-100% - var(--shiny-width)) 0;
|
||||
}
|
||||
|
||||
30%,
|
||||
60% {
|
||||
background-position: calc(100% + var(--shiny-width)) 0;
|
||||
}
|
||||
}
|
||||
|
||||
--animate-shine: shine var(--duration) infinite linear;
|
||||
|
||||
@keyframes shine {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply font-sans bg-background text-foreground;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
body[data-scroll-locked] {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
.font-medium,
|
||||
.font-semibold,
|
||||
.font-bold {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply font-sans bg-background text-foreground;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
|
||||
html {
|
||||
@apply font-sans h-full;
|
||||
}
|
||||
|
||||
body,
|
||||
#root {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
body[data-scroll-locked] {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-sans);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Heading font utility */
|
||||
.font-heading {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
/* Elevated surface shadow (use ring-* for borders) */
|
||||
.shadow-border {
|
||||
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
--tw-shadow-colored: 0 4px 16px var(--tw-shadow-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000),
|
||||
var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
}
|
||||
.dark .shadow-border {
|
||||
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
/* Heading font utility */
|
||||
.font-heading {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
/* Fine-tuning Studio: equal default height, expandable when needed (md+) */
|
||||
.min-h-studio-config-column {
|
||||
@apply md:min-h-[470px];
|
||||
}
|
||||
.h-studio-config-column {
|
||||
@apply md:h-[470px];
|
||||
}
|
||||
/* Elevated surface shadow (use ring-* for borders) */
|
||||
.shadow-border {
|
||||
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
--tw-shadow-colored: 0 4px 16px var(--tw-shadow-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000),
|
||||
var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
}
|
||||
|
||||
[data-streamdown="unordered-list"] {
|
||||
list-style-type: disc;
|
||||
list-style-position: outside;
|
||||
padding-left: 1.25rem;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
.dark .shadow-border {
|
||||
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
[data-streamdown="ordered-list"] {
|
||||
list-style-type: decimal;
|
||||
list-style-position: outside;
|
||||
padding-left: 1.25rem;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
.chat-composer-surface {
|
||||
border: 1px solid oklch(0.93 0 0 / 1);
|
||||
background-clip: padding-box;
|
||||
box-shadow:
|
||||
0 1px 2px oklch(0 0 0 / 0.04),
|
||||
0 6px 14px oklch(0 0 0 / 0.05),
|
||||
0 18px 40px oklch(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
[data-streamdown="list-item"] {
|
||||
display: list-item;
|
||||
}
|
||||
.dark .chat-composer-surface {
|
||||
border-color: oklch(0.38 0 0 / 1);
|
||||
box-shadow:
|
||||
0 1px 2px oklch(0 0 0 / 0.2),
|
||||
0 8px 20px oklch(0 0 0 / 0.25),
|
||||
0 22px 48px oklch(0 0 0 / 0.22);
|
||||
}
|
||||
|
||||
/* Flatten code blocks: single border, language label, then code directly */
|
||||
[data-streamdown="code-block-body"] {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
[data-streamdown="code-block"] {
|
||||
gap: 0;
|
||||
padding: 0.5rem;
|
||||
/* Wide lines must scroll inside the thread column, not widen past the composer (flex min-width:auto). */
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
[data-streamdown="code-block-header"] {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
/* Fine-tuning Studio: equal default height, expandable when needed (md+) */
|
||||
.min-h-studio-config-column {
|
||||
@apply md:min-h-[470px];
|
||||
}
|
||||
|
||||
/* Chat thread: code slightly smaller by default; step up when the thread column is wide. */
|
||||
.aui-thread-root [data-streamdown="code-block"] {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.h-studio-config-column {
|
||||
@apply md:h-[470px];
|
||||
}
|
||||
|
||||
.aui-thread-root [data-streamdown="code-block-header"] {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
[data-streamdown="unordered-list"] {
|
||||
list-style-type: disc;
|
||||
list-style-position: outside;
|
||||
padding-left: 1.25rem;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
@container (min-width: 36rem) {
|
||||
.aui-thread-root [data-streamdown="code-block"] {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
[data-streamdown="ordered-list"] {
|
||||
list-style-type: decimal;
|
||||
list-style-position: outside;
|
||||
padding-left: 1.25rem;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.aui-thread-root [data-streamdown="code-block-header"] {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
[data-streamdown="list-item"] {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/* Chat: use the app sans stack for UI + prose. */
|
||||
.aui-thread-root {
|
||||
--font-heading: var(--font-sans);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
/* Flatten code blocks: single border, language label, then code directly */
|
||||
[data-streamdown="code-block-body"] {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Keep monospace for code fences and inline code (not KaTeX). */
|
||||
.aui-thread-root [data-streamdown="code-block"] pre,
|
||||
.aui-thread-root [data-streamdown="code-block"] code {
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
}
|
||||
[data-streamdown="code-block"] {
|
||||
gap: 0;
|
||||
padding: 0.5rem;
|
||||
/* Wide lines must scroll inside the thread column, not widen past the composer (flex min-width:auto). */
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.aui-thread-root :where(p, li, td, th, blockquote, h1, h2, h3, h4, h5, h6) code {
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
}
|
||||
[data-streamdown="code-block-header"] {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
/* Align fenced code blocks with the main chat column even when nested in lists. */
|
||||
.aui-thread-root [data-streamdown="list-item"] > [data-streamdown="code-block"],
|
||||
.aui-thread-root [data-streamdown="list-item"] [data-streamdown="code-block"] {
|
||||
margin-left: -1.25rem;
|
||||
width: calc(100% + 1.25rem);
|
||||
max-width: calc(100% + 1.25rem);
|
||||
}
|
||||
/* Chat thread: code slightly smaller by default; step up when the thread column is wide. */
|
||||
.aui-thread-root [data-streamdown="code-block"] {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.dark .aui-thread-root [data-streamdown="code-block"] {
|
||||
/* Streamdown `pre` uses `dark:bg-[var(--shiki-dark-bg,...)]`; keep one surface on the outer shell. */
|
||||
--shiki-dark-bg: transparent;
|
||||
background: var(--color-code-block);
|
||||
border: 1px solid oklch(1 0 0 / 0.07);
|
||||
}
|
||||
.aui-thread-root [data-streamdown="code-block-header"] {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
@container (min-width: 36rem) {
|
||||
.aui-thread-root [data-streamdown="code-block"] {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.aui-thread-root [data-streamdown="code-block-header"] {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chat: use the app sans stack for UI + prose. */
|
||||
.aui-thread-root {
|
||||
--font-heading: var(--font-sans);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Keep monospace for code fences and inline code (not KaTeX). */
|
||||
.aui-thread-root [data-streamdown="code-block"] pre,
|
||||
.aui-thread-root [data-streamdown="code-block"] code {
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.aui-thread-root :where(p, li, td, th, blockquote, h1, h2, h3, h4, h5, h6) code {
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Align fenced code blocks with the main chat column even when nested in lists. */
|
||||
.aui-thread-root [data-streamdown="list-item"] > [data-streamdown="code-block"],
|
||||
.aui-thread-root [data-streamdown="list-item"] [data-streamdown="code-block"] {
|
||||
margin-left: -1.25rem;
|
||||
width: calc(100% + 1.25rem);
|
||||
max-width: calc(100% + 1.25rem);
|
||||
}
|
||||
|
||||
.dark .aui-thread-root [data-streamdown="code-block"] {
|
||||
/* Streamdown `pre` uses `dark:bg-[var(--shiki-dark-bg,...)]`; keep one surface on the outer shell. */
|
||||
--shiki-dark-bg: transparent;
|
||||
background: var(--color-code-block);
|
||||
border: 1px solid oklch(1 0 0 / 0.07);
|
||||
}
|
||||
}
|
||||
|
||||
/* Minimal scrollbar — thumb only, no track */
|
||||
/* Flat scrollbar chrome */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(0.5 0 0 / 0.54) transparent;
|
||||
}
|
||||
*:hover {
|
||||
scrollbar-color: oklch(0.6 0 0 / 0.3) transparent;
|
||||
}
|
||||
.dark *:hover {
|
||||
scrollbar-color: oklch(0.5 0 0 / 0.35) transparent;
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: oklch(0.67 0 0 / 0.5) transparent;
|
||||
}
|
||||
|
||||
/* Webkit (Chrome, Safari, Edge) */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 9999px;
|
||||
background: oklch(0.5 0 0 / 0.54);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
*:hover::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.6 0 0 / 0.3);
|
||||
|
||||
::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.dark *:hover::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.5 0 0 / 0.35);
|
||||
|
||||
.dark *::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.67 0 0 / 0.5);
|
||||
}
|
||||
|
||||
/* Chat viewport: solid track matching sidebar so the scrollbar reads as a
|
||||
full-height rail flush to the right edge, without a separate decorative strip. */
|
||||
.aui-thread-viewport {
|
||||
scrollbar-color: oklch(0.5 0 0 / 0.54) var(--sidebar);
|
||||
}
|
||||
|
||||
.dark .aui-thread-viewport {
|
||||
scrollbar-color: oklch(0.67 0 0 / 0.5) var(--sidebar);
|
||||
}
|
||||
|
||||
.aui-thread-viewport::-webkit-scrollbar-track {
|
||||
background: var(--sidebar);
|
||||
}
|
||||
|
||||
[data-sidebar="content"] {
|
||||
scrollbar-color: oklch(0.5 0 0 / 0.7) transparent;
|
||||
}
|
||||
|
||||
.dark [data-sidebar="content"] {
|
||||
scrollbar-color: oklch(0.72 0 0 / 0.65) transparent;
|
||||
}
|
||||
|
||||
/*---break---*/
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-old(root), ::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
|
|
|||
158
studio/setup.ps1
158
studio/setup.ps1
|
|
@ -73,7 +73,109 @@ function Refresh-Environment {
|
|||
}
|
||||
$machinePath = [System.Environment]::GetEnvironmentVariable('Path', 'Machine')
|
||||
$userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
$env:Path = "$machinePath;$userPath"
|
||||
# Merge: venv Scripts (if active) > Machine > User > current $env:Path. Dedup raw+expanded.
|
||||
$venvScripts = if ($env:VIRTUAL_ENV) { Join-Path $env:VIRTUAL_ENV 'Scripts' } else { $null }
|
||||
$sources = @()
|
||||
if ($venvScripts) { $sources += $venvScripts }
|
||||
$sources += @($machinePath, $userPath, $env:Path)
|
||||
$merged = ($sources | Where-Object { $_ }) -join ';'
|
||||
$seen = @{}
|
||||
$unique = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($p in $merged -split ";") {
|
||||
$rawKey = $p.Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
|
||||
$expKey = [Environment]::ExpandEnvironmentVariables($p).Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
|
||||
if ($rawKey -and -not $seen.ContainsKey($rawKey) -and -not $seen.ContainsKey($expKey)) {
|
||||
$seen[$rawKey] = $true
|
||||
if ($expKey -and $expKey -ne $rawKey) { $seen[$expKey] = $true }
|
||||
$unique.Add($p)
|
||||
}
|
||||
}
|
||||
$env:Path = $unique -join ";"
|
||||
}
|
||||
|
||||
# ── Helper: safely add a directory to the persistent User PATH ──
|
||||
# Direct registry access preserves REG_EXPAND_SZ (avoids dotnet/runtime#1442).
|
||||
# Append (default) keeps existing tools first; Prepend for must-win entries.
|
||||
function Add-ToUserPath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Directory,
|
||||
[ValidateSet('Append','Prepend')]
|
||||
[string]$Position = 'Append'
|
||||
)
|
||||
try {
|
||||
$regKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
|
||||
try {
|
||||
$rawPath = $regKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
[string[]]$entries = if ($rawPath) { $rawPath -split ';' } else { @() } # string[] prevents scalar collapse
|
||||
$normalDir = $Directory.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
||||
$expNormalDir = [Environment]::ExpandEnvironmentVariables($Directory).Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
||||
$kept = New-Object System.Collections.Generic.List[string]
|
||||
$matchIndices = New-Object System.Collections.Generic.List[int]
|
||||
for ($i = 0; $i -lt $entries.Count; $i++) {
|
||||
$stripped = $entries[$i].Trim().Trim('"')
|
||||
$rawNorm = $stripped.TrimEnd('\').ToLowerInvariant()
|
||||
$expNorm = [Environment]::ExpandEnvironmentVariables($stripped).TrimEnd('\').ToLowerInvariant()
|
||||
$isMatch = ($rawNorm -and ($rawNorm -eq $normalDir -or $rawNorm -eq $expNormalDir)) -or
|
||||
($expNorm -and ($expNorm -eq $normalDir -or $expNorm -eq $expNormalDir))
|
||||
if ($isMatch) {
|
||||
$matchIndices.Add($i)
|
||||
continue
|
||||
}
|
||||
$kept.Add($entries[$i])
|
||||
}
|
||||
$alreadyPresent = $matchIndices.Count -gt 0
|
||||
if ($alreadyPresent -and $Position -eq 'Append') { # Append: idempotent no-op
|
||||
return $false
|
||||
}
|
||||
if ($alreadyPresent -and $Position -eq 'Prepend' -and # Prepend: no-op if already at front
|
||||
$matchIndices.Count -eq 1 -and $matchIndices[0] -eq 0) {
|
||||
return $false
|
||||
}
|
||||
# One-time backup under HKCU\Software\Unsloth\PathBackup
|
||||
if ($rawPath) {
|
||||
try {
|
||||
$backupKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Unsloth')
|
||||
try {
|
||||
$existingBackup = $backupKey.GetValue('PathBackup', $null)
|
||||
if (-not $existingBackup) {
|
||||
$backupKey.SetValue('PathBackup', $rawPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
||||
}
|
||||
} finally {
|
||||
$backupKey.Close()
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
if (-not $rawPath) {
|
||||
Write-Host "[WARN] User PATH is empty - initializing with $Directory" -ForegroundColor Yellow
|
||||
}
|
||||
$newPath = if ($rawPath) {
|
||||
if ($Position -eq 'Prepend') {
|
||||
(@($Directory) + $kept) -join ';'
|
||||
} else {
|
||||
($kept + @($Directory)) -join ';'
|
||||
}
|
||||
} else {
|
||||
$Directory
|
||||
}
|
||||
if ($newPath -ceq $rawPath) { # no actual change
|
||||
return $false
|
||||
}
|
||||
$regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
||||
# Broadcast WM_SETTINGCHANGE via dummy env-var roundtrip.
|
||||
# [NullString]::Value avoids PS 7.5+/.NET 9 $null-to-"" coercion.
|
||||
try {
|
||||
$d = "UnslothPathRefresh_$([guid]::NewGuid().ToString('N').Substring(0,8))"
|
||||
[Environment]::SetEnvironmentVariable($d, '1', 'User')
|
||||
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
|
||||
} catch { }
|
||||
return $true
|
||||
} finally {
|
||||
$regKey.Close()
|
||||
}
|
||||
} catch {
|
||||
Write-Host "[WARN] Could not update User PATH: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# PowerShell 5.1 compatibility helper: avoid relying on New-TemporaryFile.
|
||||
|
|
@ -493,6 +595,31 @@ if ($script:StudioVtOk -and -not $env:NO_COLOR) {
|
|||
Write-Host " $Rule" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# Back up User PATH under HKCU\Software\Unsloth before any modifications.
|
||||
try {
|
||||
$envKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $false)
|
||||
if ($envKey) {
|
||||
try {
|
||||
$rawPath = $envKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
} finally {
|
||||
$envKey.Close()
|
||||
}
|
||||
if ($rawPath) {
|
||||
$backupKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Unsloth')
|
||||
try {
|
||||
$existingBackup = $backupKey.GetValue('PathBackup', $null)
|
||||
if (-not $existingBackup) {
|
||||
$backupKey.SetValue('PathBackup', $rawPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
||||
}
|
||||
} finally {
|
||||
$backupKey.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Host "[DEBUG] Could not back up User PATH: $($_.Exception.Message)" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# ==========================================================================
|
||||
# PHASE 1: System-level prerequisites (winget installs, env vars)
|
||||
# All heavy system tool installs happen here BEFORE touching Python.
|
||||
|
|
@ -626,11 +753,8 @@ if (-not $HasCmake) {
|
|||
foreach ($d in $cmakeDefaults) {
|
||||
if (Test-Path (Join-Path $d "cmake.exe")) {
|
||||
$env:Path = "$d;$env:Path"
|
||||
# Persist to user PATH so Refresh-Environment does not drop it later
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
if (-not $userPath -or $userPath -notlike "*$d*") {
|
||||
[Environment]::SetEnvironmentVariable('Path', "$d;$userPath", 'User')
|
||||
}
|
||||
# Persist to user PATH (Prepend so this cmake wins over older ones).
|
||||
Add-ToUserPath -Directory $d -Position 'Prepend' | Out-Null
|
||||
$HasCmake = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue)
|
||||
if ($HasCmake) {
|
||||
Write-Host " Found cmake at $d (added to PATH)" -ForegroundColor Gray
|
||||
|
|
@ -896,14 +1020,8 @@ $nvccBinDir = Split-Path $NvccPath -Parent
|
|||
if ($env:PATH -notlike "*$nvccBinDir*") {
|
||||
[Environment]::SetEnvironmentVariable('PATH', "$nvccBinDir;$env:PATH", 'Process')
|
||||
}
|
||||
# Persist nvcc bin dir to User PATH so it works in new terminals
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
if (-not $userPath -or $userPath -notlike "*$nvccBinDir*") {
|
||||
if ($userPath) {
|
||||
[Environment]::SetEnvironmentVariable('Path', "$nvccBinDir;$userPath", 'User')
|
||||
} else {
|
||||
[Environment]::SetEnvironmentVariable('Path', "$nvccBinDir", 'User')
|
||||
}
|
||||
# Persist nvcc bin dir (Prepend so the driver-compatible toolkit wins).
|
||||
if (Add-ToUserPath -Directory $nvccBinDir -Position 'Prepend') {
|
||||
substep "Persisted CUDA bin dir to user PATH"
|
||||
}
|
||||
|
||||
|
|
@ -1061,15 +1179,11 @@ if ($HasPython) {
|
|||
$PythonOk = $true
|
||||
}
|
||||
|
||||
# Ensure Python Scripts dir is on PATH (so 'unsloth' command works in new terminals)
|
||||
$ScriptsDir = python -c "import sysconfig; print(sysconfig.get_path('scripts', 'nt_user') if __import__('os').path.exists(sysconfig.get_path('scripts', 'nt_user')) else sysconfig.get_path('scripts'))"
|
||||
# Add user-scheme Python Scripts dir to PATH (nt_user only, no venv fallback).
|
||||
$ScriptsDir = python -c "import os, sysconfig; p = sysconfig.get_path('scripts', 'nt_user'); print(p if os.path.exists(p) else '')"
|
||||
if ($LASTEXITCODE -eq 0 -and $ScriptsDir -and (Test-Path $ScriptsDir)) {
|
||||
$UserPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
$UserPathEntries = if ($UserPath) { $UserPath.Split(';') } else { @() }
|
||||
if (-not ($UserPathEntries | Where-Object { $_.TrimEnd('\') -eq $ScriptsDir })) {
|
||||
$newUserPath = if ($UserPath) { "$ScriptsDir;$UserPath" } else { $ScriptsDir }
|
||||
[Environment]::SetEnvironmentVariable('Path', $newUserPath, 'User')
|
||||
|
||||
# Append (not Prepend) -- this dir has other pip scripts; shim handles unsloth.
|
||||
if (Add-ToUserPath -Directory $ScriptsDir) {
|
||||
# Also add to current process so it's available immediately
|
||||
$ProcessPathEntries = $env:PATH.Split(';')
|
||||
if (-not ($ProcessPathEntries | Where-Object { $_.TrimEnd('\') -eq $ScriptsDir })) {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
__version__ = "2026.4.5"
|
||||
__version__ = "2026.4.6"
|
||||
|
||||
__all__ = [
|
||||
"SUPPORTS_BFLOAT16",
|
||||
|
|
@ -65,7 +65,9 @@ __all__ = [
|
|||
"patch_compiled_autograd",
|
||||
"process_vision_info",
|
||||
"unsloth_compile_transformers",
|
||||
"determine_attention_implementation",
|
||||
"resolve_model_class",
|
||||
"resolve_attention_implementation",
|
||||
"resolve_encoder_attention_implementation",
|
||||
"_set_attn_impl",
|
||||
"patch_fast_lora",
|
||||
"validate_loftq_config",
|
||||
|
|
@ -233,6 +235,8 @@ def apply_unsloth_gradient_checkpointing(
|
|||
# access on some GPU architectures (B200). Falls back to eager safely.
|
||||
_FLEX_EXCLUDED_MODELS = ("gpt_oss", "mllama", "nemotron_h", "modernbert")
|
||||
_EAGER_ONLY_PREFIXES = ("gemma3n",)
|
||||
_FLASH_ATTENTION_MAX_HEAD_DIM = 256
|
||||
_FLASH_ATTENTION_DISABLED_WARNED = set()
|
||||
|
||||
|
||||
def _is_flex_excluded(model_type):
|
||||
|
|
@ -243,57 +247,281 @@ def _is_eager_only(model_type):
|
|||
return any(model_type.startswith(p) for p in _EAGER_ONLY_PREFIXES)
|
||||
|
||||
|
||||
def _config_items(config):
|
||||
if isinstance(config, dict):
|
||||
return config.items()
|
||||
if hasattr(config, "__dict__"):
|
||||
return vars(config).items()
|
||||
return ()
|
||||
|
||||
|
||||
def _config_get(config, field_name, default = None):
|
||||
if isinstance(config, dict):
|
||||
return config.get(field_name, default)
|
||||
return getattr(config, field_name, default)
|
||||
|
||||
|
||||
def _config_set(config, field_name, value):
|
||||
if isinstance(config, dict):
|
||||
config[field_name] = value
|
||||
elif config is not None:
|
||||
setattr(config, field_name, value)
|
||||
|
||||
|
||||
def _iter_attention_configs(config, seen = None):
|
||||
if config is None or (
|
||||
not isinstance(config, dict) and not hasattr(config, "__dict__")
|
||||
):
|
||||
return
|
||||
if seen is None:
|
||||
seen = set()
|
||||
config_id = id(config)
|
||||
if config_id in seen:
|
||||
return
|
||||
seen.add(config_id)
|
||||
yield config
|
||||
|
||||
for field_name, child_config in _config_items(config):
|
||||
if not isinstance(field_name, str) or not field_name.endswith("_config"):
|
||||
continue
|
||||
if isinstance(child_config, dict) or hasattr(child_config, "__dict__"):
|
||||
yield from _iter_attention_configs(child_config, seen)
|
||||
|
||||
|
||||
def _collect_attention_head_dims(config):
|
||||
explicit_head_dims = []
|
||||
|
||||
for field_name in (
|
||||
"head_dim",
|
||||
"global_head_dim",
|
||||
"local_head_dim",
|
||||
"kv_head_dim",
|
||||
):
|
||||
value = _config_get(config, field_name, None)
|
||||
if isinstance(value, int) and value > 0:
|
||||
explicit_head_dims.append(value)
|
||||
|
||||
if len(explicit_head_dims) != 0:
|
||||
return explicit_head_dims
|
||||
|
||||
head_dims = []
|
||||
|
||||
hidden_size_names = ("hidden_size", "d_model", "embed_dim", "dim")
|
||||
num_heads_names = ("num_attention_heads", "num_heads", "n_heads")
|
||||
for hidden_size_name in hidden_size_names:
|
||||
hidden_size = _config_get(config, hidden_size_name, None)
|
||||
if not isinstance(hidden_size, int) or hidden_size <= 0:
|
||||
continue
|
||||
for num_heads_name in num_heads_names:
|
||||
num_heads = _config_get(config, num_heads_name, None)
|
||||
if (
|
||||
isinstance(num_heads, int)
|
||||
and num_heads > 0
|
||||
and (hidden_size % num_heads) == 0
|
||||
):
|
||||
head_dims.append(hidden_size // num_heads)
|
||||
|
||||
return head_dims
|
||||
|
||||
|
||||
def _get_max_attention_head_dim(config):
|
||||
head_dims = []
|
||||
for attention_config in _iter_attention_configs(config):
|
||||
head_dims.extend(_collect_attention_head_dims(attention_config))
|
||||
return max(head_dims) if len(head_dims) != 0 else None
|
||||
|
||||
|
||||
def _get_flash_attention_disable_reason(config):
|
||||
max_head_dim = _get_max_attention_head_dim(config)
|
||||
if max_head_dim is not None and max_head_dim > _FLASH_ATTENTION_MAX_HEAD_DIM:
|
||||
return (
|
||||
f"max attention head dim {max_head_dim} exceeds the Flash Attention 2 "
|
||||
f"limit of {_FLASH_ATTENTION_MAX_HEAD_DIM}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _is_flash_attention_disabled(config):
|
||||
return _get_flash_attention_disable_reason(config) is not None
|
||||
|
||||
|
||||
def _is_flash_attention_requested(attn_implementation):
|
||||
return isinstance(attn_implementation, str) and attn_implementation.startswith(
|
||||
"flash_attention"
|
||||
)
|
||||
|
||||
|
||||
def _disable_flash_attention_if_needed(
|
||||
config,
|
||||
attn_implementation = None,
|
||||
supports_sdpa = False,
|
||||
would_use_flash_attention = False,
|
||||
disable_reason = None,
|
||||
):
|
||||
if disable_reason is None:
|
||||
disable_reason = _get_flash_attention_disable_reason(config)
|
||||
if disable_reason is None:
|
||||
return attn_implementation
|
||||
|
||||
requested_attn_implementation = attn_implementation
|
||||
if requested_attn_implementation is None:
|
||||
requested_attn_implementation = _config_get(
|
||||
config, "_attn_implementation", None
|
||||
)
|
||||
if requested_attn_implementation is None:
|
||||
requested_attn_implementation = _config_get(config, "attn_implementation", None)
|
||||
|
||||
if requested_attn_implementation == "eager":
|
||||
return _set_attn_impl(config, "eager")
|
||||
|
||||
fallback_attn_implementation = "sdpa" if supports_sdpa else "eager"
|
||||
if (
|
||||
_is_flash_attention_requested(requested_attn_implementation)
|
||||
or would_use_flash_attention
|
||||
):
|
||||
logged_attn_implementation = (
|
||||
requested_attn_implementation
|
||||
if _is_flash_attention_requested(requested_attn_implementation)
|
||||
else "flash_attention_2"
|
||||
)
|
||||
model_type = _config_get(config, "model_type", "")
|
||||
warning_key = (
|
||||
model_type,
|
||||
logged_attn_implementation,
|
||||
fallback_attn_implementation,
|
||||
disable_reason,
|
||||
)
|
||||
if warning_key not in _FLASH_ATTENTION_DISABLED_WARNED:
|
||||
_FLASH_ATTENTION_DISABLED_WARNED.add(warning_key)
|
||||
print(
|
||||
f"Unsloth: `{logged_attn_implementation}` is not supported "
|
||||
f"for `{model_type}` because {disable_reason} - "
|
||||
f"defaulting to `{fallback_attn_implementation}`."
|
||||
)
|
||||
|
||||
return _set_attn_impl(config, fallback_attn_implementation)
|
||||
|
||||
|
||||
def _set_attn_impl(config, impl):
|
||||
"""Helper function to set attention implementation on config and return it."""
|
||||
if config is not None:
|
||||
setattr(config, "_attn_implementation", impl)
|
||||
if hasattr(config, "attn_implementation"):
|
||||
setattr(config, "attn_implementation", impl)
|
||||
_config_set(config, "_attn_implementation", impl)
|
||||
if isinstance(config, dict) or hasattr(config, "attn_implementation"):
|
||||
_config_set(config, "attn_implementation", impl)
|
||||
return impl
|
||||
|
||||
|
||||
def determine_attention_implementation(model_class, config):
|
||||
model_type = getattr(config, "model_type", "").lower()
|
||||
def resolve_model_class(auto_model, config):
|
||||
mapping = getattr(auto_model, "_model_mapping", {})
|
||||
try:
|
||||
result = mapping[config.__class__]
|
||||
except Exception:
|
||||
for config_class, model_class in mapping.items():
|
||||
if isinstance(config, config_class):
|
||||
result = model_class
|
||||
break
|
||||
else:
|
||||
return None
|
||||
|
||||
# Eager-only models (e.g. gemma3n timm vision towers)
|
||||
if _is_eager_only(model_type):
|
||||
_set_attn_impl(config, "eager")
|
||||
return "eager"
|
||||
return result[0] if isinstance(result, (list, tuple)) else result
|
||||
|
||||
# Flash Attention 2
|
||||
if HAS_FLASH_ATTENTION and model_class is not None:
|
||||
supports_fa2 = getattr(model_class, "_supports_flash_attn_2", False) or getattr(
|
||||
model_class, "_supports_flash_attn", False
|
||||
|
||||
def resolve_attention_implementation(
|
||||
model_class,
|
||||
config,
|
||||
requested_attn_implementation = None,
|
||||
supports_sdpa = None,
|
||||
):
|
||||
model_type_name = _config_get(config, "model_type", "")
|
||||
model_type = model_type_name.lower()
|
||||
if supports_sdpa is None:
|
||||
supports_sdpa = model_class is not None and getattr(
|
||||
model_class, "_supports_sdpa", False
|
||||
)
|
||||
if supports_fa2:
|
||||
_set_attn_impl(config, "flash_attention_2")
|
||||
return "flash_attention_2"
|
||||
supports_flash_attention = model_class is not None and (
|
||||
getattr(model_class, "_supports_flash_attn_2", False)
|
||||
or getattr(model_class, "_supports_flash_attn", False)
|
||||
)
|
||||
disable_reason = _get_flash_attention_disable_reason(config)
|
||||
flash_attention_disabled = disable_reason is not None
|
||||
|
||||
# Flex Attention
|
||||
if os.environ.get("UNSLOTH_ENABLE_FLEX_ATTENTION", "1") != "0":
|
||||
try:
|
||||
from transformers.utils.import_utils import is_torch_flex_attn_available
|
||||
if model_class is None:
|
||||
attn_impl = _set_attn_impl(config, "sdpa" if supports_sdpa else "eager")
|
||||
else:
|
||||
if _is_eager_only(model_type):
|
||||
attn_impl = _set_attn_impl(config, "eager")
|
||||
elif flash_attention_disabled:
|
||||
attn_impl = _disable_flash_attention_if_needed(
|
||||
config,
|
||||
supports_sdpa = supports_sdpa,
|
||||
would_use_flash_attention = (
|
||||
HAS_FLASH_ATTENTION and supports_flash_attention
|
||||
),
|
||||
disable_reason = disable_reason,
|
||||
)
|
||||
elif HAS_FLASH_ATTENTION and supports_flash_attention:
|
||||
attn_impl = _set_attn_impl(config, "flash_attention_2")
|
||||
elif supports_sdpa:
|
||||
attn_impl = _set_attn_impl(config, "sdpa")
|
||||
else:
|
||||
attn_impl = "eager"
|
||||
if os.environ.get("UNSLOTH_ENABLE_FLEX_ATTENTION", "1") != "0":
|
||||
try:
|
||||
from transformers.utils.import_utils import (
|
||||
is_torch_flex_attn_available,
|
||||
)
|
||||
|
||||
if (
|
||||
is_torch_flex_attn_available()
|
||||
and model_class is not None
|
||||
and getattr(model_class, "_supports_flex_attn", False)
|
||||
and not _is_flex_excluded(model_type)
|
||||
):
|
||||
attention_dropout = getattr(config, "attention_dropout", 0) or 0
|
||||
if attention_dropout == 0:
|
||||
_set_attn_impl(config, "flex_attention")
|
||||
return "flex_attention"
|
||||
except Exception:
|
||||
pass
|
||||
if (
|
||||
is_torch_flex_attn_available()
|
||||
and getattr(model_class, "_supports_flex_attn", False)
|
||||
and not _is_flex_excluded(model_type)
|
||||
):
|
||||
attention_dropout = (
|
||||
_config_get(config, "attention_dropout", 0) or 0
|
||||
)
|
||||
if attention_dropout == 0:
|
||||
attn_impl = _set_attn_impl(config, "flex_attention")
|
||||
except Exception:
|
||||
pass
|
||||
if attn_impl == "eager":
|
||||
attn_impl = _set_attn_impl(config, "eager")
|
||||
|
||||
# SDPA
|
||||
if model_class is not None and getattr(model_class, "_supports_sdpa", False):
|
||||
_set_attn_impl(config, "sdpa")
|
||||
if requested_attn_implementation is None:
|
||||
final_attn_impl = attn_impl
|
||||
elif flash_attention_disabled:
|
||||
final_attn_impl = _disable_flash_attention_if_needed(
|
||||
config,
|
||||
requested_attn_implementation,
|
||||
supports_sdpa = supports_sdpa,
|
||||
disable_reason = disable_reason,
|
||||
)
|
||||
else:
|
||||
final_attn_impl = requested_attn_implementation
|
||||
_set_attn_impl(config, final_attn_impl)
|
||||
|
||||
if not supports_sdpa and final_attn_impl == "sdpa":
|
||||
print(
|
||||
f"Unsloth: {(model_type_name or 'model').title()} does not support SDPA - switching to fast eager."
|
||||
)
|
||||
final_attn_impl = _set_attn_impl(config, "eager")
|
||||
|
||||
return final_attn_impl
|
||||
|
||||
|
||||
def resolve_encoder_attention_implementation(
|
||||
auto_model,
|
||||
config,
|
||||
model_type = "",
|
||||
disable_sdpa_model_names = (),
|
||||
):
|
||||
model_class = resolve_model_class(auto_model, config)
|
||||
supports_sdpa = model_class is not None and getattr(
|
||||
model_class, "_supports_sdpa", False
|
||||
)
|
||||
if any(name in model_type.lower() for name in disable_sdpa_model_names):
|
||||
return "eager"
|
||||
if supports_sdpa:
|
||||
return "sdpa"
|
||||
|
||||
_set_attn_impl(config, "eager")
|
||||
return "eager"
|
||||
return None
|
||||
|
||||
|
||||
def _run_temporary_patches(phase):
|
||||
|
|
|
|||
|
|
@ -2346,7 +2346,7 @@ class FastLlamaModel:
|
|||
model_function = MODEL_FOR_CAUSAL_LM_MAPPING[model_config.__class__]
|
||||
IS_FALCON_H1 = model_config.model_type.startswith("falcon_h1")
|
||||
|
||||
preferred_attn_impl = determine_attention_implementation(
|
||||
preferred_attn_impl = resolve_attention_implementation(
|
||||
model_function, model_config
|
||||
)
|
||||
|
||||
|
|
@ -2478,6 +2478,16 @@ class FastLlamaModel:
|
|||
and not _head.weight.is_floating_point()
|
||||
):
|
||||
_head.to(dtype)
|
||||
# Attach dispatch hooks for bnb multi-device loads.
|
||||
from unsloth.models.vision import _attach_bnb_multidevice_hooks
|
||||
|
||||
_attach_bnb_multidevice_hooks(
|
||||
model,
|
||||
load_in_4bit = load_in_4bit,
|
||||
load_in_8bit = kwargs.get("load_in_8bit", False),
|
||||
offload_embedding = False,
|
||||
fast_inference = fast_inference,
|
||||
)
|
||||
elif not fast_inference:
|
||||
model = AutoModelForCausalLM.from_pretrained(
|
||||
model_name,
|
||||
|
|
@ -2490,6 +2500,16 @@ class FastLlamaModel:
|
|||
attn_implementation = preferred_attn_impl,
|
||||
**kwargs,
|
||||
)
|
||||
# Attach dispatch hooks for bnb multi-device loads.
|
||||
from unsloth.models.vision import _attach_bnb_multidevice_hooks
|
||||
|
||||
_attach_bnb_multidevice_hooks(
|
||||
model,
|
||||
load_in_4bit = load_in_4bit,
|
||||
load_in_8bit = kwargs.get("load_in_8bit", False),
|
||||
offload_embedding = False,
|
||||
fast_inference = False,
|
||||
)
|
||||
model.fast_generate = make_fast_generate_wrapper(model.generate)
|
||||
model.fast_generate_batches = None
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1151,9 +1151,6 @@ class FastModel(FastBaseModel):
|
|||
)
|
||||
os.environ["UNSLOTH_DISABLE_STATIC_GENERATION"] = "1"
|
||||
os.environ["UNSLOTH_HIGH_PRECISION_LAYERNORM"] = "1"
|
||||
# Disable flex_attention for Gemma-4: flex compile overhead is 2.7x slower
|
||||
# than SDPA. Our attention patch ensures Q/K/V dtype alignment for SDPA.
|
||||
os.environ["UNSLOTH_ENABLE_FLEX_ATTENTION"] = "0"
|
||||
# Gemma 3N must be before Gemma 3
|
||||
elif "gemma3n" in model_types_all:
|
||||
if transformers_version < Version("4.53.0"):
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@
|
|||
import logging
|
||||
|
||||
from .loader import FastModel, DISABLE_SDPA_MODEL_NAMES
|
||||
from ._utils import SUPPORTS_BFLOAT16
|
||||
from ._utils import (
|
||||
SUPPORTS_BFLOAT16,
|
||||
resolve_model_class,
|
||||
resolve_encoder_attention_implementation,
|
||||
)
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
|
|
@ -31,7 +35,6 @@ import transformers
|
|||
from packaging.version import Version
|
||||
import re
|
||||
from transformers import AutoModel, AutoConfig
|
||||
from transformers.models.auto.auto_factory import _get_model_class
|
||||
import tempfile
|
||||
from huggingface_hub import HfApi, get_token
|
||||
from ..save import unsloth_save_pretrained_torchao, unsloth_save_pretrained_gguf
|
||||
|
|
@ -870,7 +873,7 @@ class FastSentenceTransformer(FastModel):
|
|||
if auto_model_class is None:
|
||||
auto_model_class = AutoModel
|
||||
# try to resolve the class
|
||||
model_class = _get_model_class(config, auto_model_class._model_mapping)
|
||||
model_class = resolve_model_class(auto_model_class, config)
|
||||
|
||||
if model_class:
|
||||
sig = inspect.signature(model_class.__init__)
|
||||
|
|
@ -1446,32 +1449,18 @@ class FastSentenceTransformer(FastModel):
|
|||
):
|
||||
st_device = "cuda"
|
||||
|
||||
# Check if model supports SDPA (Scaled Dot Product Attention) for extra speedup
|
||||
supports_sdpa = False
|
||||
if config is not None:
|
||||
try:
|
||||
model_class = _get_model_class(
|
||||
config, kwargs.get("auto_model", AutoModel)._model_mapping
|
||||
)
|
||||
supports_sdpa = getattr(model_class, "_supports_sdpa", False)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Build model_kwargs for SentenceTransformer
|
||||
model_kwargs = {"torch_dtype": dtype}
|
||||
|
||||
# Enable SDPA if supported (1.2x extra speedup on top of torch.compile)
|
||||
# But disable for models with known SDPA + torch.compile backward issues
|
||||
_force_eager = False
|
||||
for _sdpa_model in DISABLE_SDPA_MODEL_NAMES:
|
||||
if _sdpa_model in model_type.lower():
|
||||
supports_sdpa = False
|
||||
_force_eager = True
|
||||
break
|
||||
if supports_sdpa:
|
||||
model_kwargs["attn_implementation"] = "sdpa"
|
||||
elif _force_eager:
|
||||
model_kwargs["attn_implementation"] = "eager"
|
||||
encoder_attn_impl = resolve_encoder_attention_implementation(
|
||||
kwargs.get("auto_model", AutoModel),
|
||||
config,
|
||||
model_type = model_type,
|
||||
disable_sdpa_model_names = DISABLE_SDPA_MODEL_NAMES,
|
||||
)
|
||||
supports_sdpa = encoder_attn_impl == "sdpa"
|
||||
if encoder_attn_impl is not None:
|
||||
model_kwargs["attn_implementation"] = encoder_attn_impl
|
||||
|
||||
# Print optimization status
|
||||
sdpa_str = " + SDPA" if supports_sdpa else ""
|
||||
|
|
|
|||
|
|
@ -29,7 +29,13 @@ except:
|
|||
from ..kernels import (
|
||||
post_patch_loss_function,
|
||||
)
|
||||
from ._utils import __version__, importlib_version, _prepare_model_for_qat
|
||||
from ._utils import (
|
||||
__version__,
|
||||
importlib_version,
|
||||
_prepare_model_for_qat,
|
||||
resolve_model_class,
|
||||
resolve_attention_implementation,
|
||||
)
|
||||
from ._utils import *
|
||||
from .loader_utils import _get_fp8_mode_and_check_settings
|
||||
from ..save import patch_saving_functions
|
||||
|
|
@ -69,6 +75,7 @@ import functools
|
|||
import os
|
||||
import gc
|
||||
import math
|
||||
import warnings
|
||||
from typing import Optional, Tuple, List, Union
|
||||
import re, inspect, sys
|
||||
import contextlib
|
||||
|
|
@ -91,6 +98,144 @@ __all__ = [
|
|||
"FastBaseModel",
|
||||
]
|
||||
|
||||
|
||||
def _infer_device_map_from_loaded_model(model):
|
||||
"""Build a compact device_map by inspecting actual parameter placements."""
|
||||
device_map = {}
|
||||
|
||||
def _assign(module, prefix):
|
||||
params = list(module.named_parameters(remove_duplicate = False))
|
||||
if not params:
|
||||
bufs = list(module.named_buffers())
|
||||
if bufs:
|
||||
device_map[prefix] = bufs[0][1].device
|
||||
return
|
||||
devices = {p.device for _, p in params}
|
||||
if len(devices) == 1:
|
||||
device_map[prefix] = next(iter(devices))
|
||||
else:
|
||||
for child_name, child in module.named_children():
|
||||
child_prefix = f"{prefix}.{child_name}" if prefix else child_name
|
||||
_assign(child, child_prefix)
|
||||
for pname, param in module.named_parameters(remove_duplicate = False):
|
||||
if "." not in pname:
|
||||
full = f"{prefix}.{pname}" if prefix else pname
|
||||
if not any(
|
||||
full == k or full.startswith(k + ".") for k in device_map
|
||||
):
|
||||
device_map[full] = param.device
|
||||
|
||||
_assign(model, "")
|
||||
if "" in device_map and len(device_map) > 1:
|
||||
device_map.pop("")
|
||||
return device_map
|
||||
|
||||
|
||||
def _attach_bnb_multidevice_hooks(
|
||||
model, load_in_4bit, load_in_8bit, offload_embedding, fast_inference
|
||||
):
|
||||
"""
|
||||
Attach accelerate AlignDevicesHook on a bnb model loaded across multiple
|
||||
devices (or a non-default device). No-op for single-GPU cuda:0, non-bnb,
|
||||
vLLM, or already-dispatched models.
|
||||
"""
|
||||
if fast_inference:
|
||||
return
|
||||
is_bnb = (
|
||||
load_in_4bit
|
||||
or load_in_8bit
|
||||
or getattr(model, "is_loaded_in_4bit", False)
|
||||
or getattr(model, "is_loaded_in_8bit", False)
|
||||
or getattr(model, "quantization_method", None) == "bitsandbytes"
|
||||
)
|
||||
if not is_bnb:
|
||||
return
|
||||
if offload_embedding:
|
||||
return
|
||||
if getattr(model, "hf_device_map", None) is not None:
|
||||
return # already dispatched
|
||||
|
||||
try:
|
||||
all_devs = {p.device for p in model.parameters()}
|
||||
except Exception as exc:
|
||||
warnings.warn(
|
||||
"Unsloth: Failed to determine device placement from model parameters, "
|
||||
f"so multi-GPU hooks cannot be attached. ({type(exc).__name__}: {exc})",
|
||||
RuntimeWarning,
|
||||
stacklevel = 2,
|
||||
)
|
||||
return
|
||||
|
||||
cuda_devs = {d for d in all_devs if d.type == "cuda"}
|
||||
if not cuda_devs:
|
||||
return
|
||||
|
||||
default_cuda = torch.device("cuda", 0)
|
||||
if all_devs == {default_cuda}:
|
||||
return
|
||||
|
||||
try:
|
||||
from accelerate import dispatch_model
|
||||
except ImportError:
|
||||
return # accelerate not available
|
||||
|
||||
try:
|
||||
inferred_map = _infer_device_map_from_loaded_model(model)
|
||||
if not inferred_map:
|
||||
return
|
||||
|
||||
# bnb constructors reject _is_hf_initialized; strip before dispatch.
|
||||
_extra_keys = ("_is_hf_initialized",)
|
||||
_stripped = []
|
||||
for _, param in model.named_parameters():
|
||||
for key in _extra_keys:
|
||||
if key in param.__dict__:
|
||||
_stripped.append((param, key, param.__dict__.pop(key)))
|
||||
|
||||
try:
|
||||
# CUDA -> int index, non-CUDA -> type string ("cpu", "meta").
|
||||
device_map_int = {
|
||||
k: (v.index if v.type == "cuda" else v.type)
|
||||
if isinstance(v, torch.device)
|
||||
else v
|
||||
for k, v in inferred_map.items()
|
||||
}
|
||||
|
||||
# force_hooks=True: install hooks even for single-device maps.
|
||||
main_device = device_map_int.get("")
|
||||
if main_device in (None, "cpu", "disk"):
|
||||
main_device = next(
|
||||
(d for d in device_map_int.values() if d not in ("cpu", "disk")),
|
||||
None,
|
||||
)
|
||||
dispatch_model(
|
||||
model,
|
||||
device_map = device_map_int,
|
||||
main_device = main_device,
|
||||
skip_keys = getattr(model, "_skip_keys_device_placement", None),
|
||||
force_hooks = True,
|
||||
)
|
||||
desc = f"{len(inferred_map)} block(s) across {len(cuda_devs)} device(s)"
|
||||
finally:
|
||||
# Restore stripped keys.
|
||||
for param, key, val in _stripped:
|
||||
param.__dict__[key] = val
|
||||
|
||||
logger.info(
|
||||
f"Unsloth: Attached accelerate AlignDevicesHook ({desc}) "
|
||||
f"for bnb multi-GPU inference."
|
||||
)
|
||||
except Exception as exc:
|
||||
warnings.warn(
|
||||
f"Unsloth: Could not attach multi-device dispatch hooks automatically "
|
||||
f"({type(exc).__name__}: {exc}). "
|
||||
"Cross-device inference may fail. Consider using a single GPU or "
|
||||
"calling accelerate.dispatch_model() manually.",
|
||||
RuntimeWarning,
|
||||
stacklevel = 2,
|
||||
)
|
||||
|
||||
|
||||
global NUM_LOGITS_TO_KEEP
|
||||
NUM_LOGITS_TO_KEEP = dict()
|
||||
|
||||
|
|
@ -607,37 +752,18 @@ class FastBaseModel:
|
|||
token = token,
|
||||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
try:
|
||||
model_class = auto_model._model_mapping[auto_config.__class__]
|
||||
except Exception:
|
||||
model_class = None
|
||||
if model_class is None:
|
||||
# When model_class cannot be resolved (remote-code or unmapped
|
||||
# configs), preserve the old fallback of sdpa when supported.
|
||||
attn_impl = _set_attn_impl(
|
||||
auto_config, "sdpa" if supports_sdpa else "eager"
|
||||
)
|
||||
else:
|
||||
attn_impl = determine_attention_implementation(model_class, auto_config)
|
||||
model_class = resolve_model_class(auto_model, auto_config)
|
||||
attn_impl = resolve_attention_implementation(
|
||||
model_class,
|
||||
auto_config,
|
||||
requested_attn_implementation = kwargs.get("attn_implementation", None),
|
||||
supports_sdpa = supports_sdpa,
|
||||
)
|
||||
|
||||
# Handle FP8 models: get_model_name has already redirected this to BF16 sibling if the model ships with
|
||||
# FP8 weights. We just need to update it here for sanity.
|
||||
auto_config.model_name = model_name
|
||||
# Re-resolve model_class after potential config change
|
||||
try:
|
||||
model_class = auto_model._model_mapping[auto_config.__class__]
|
||||
except Exception:
|
||||
model_class = None
|
||||
|
||||
if not ("attn_implementation" in kwargs):
|
||||
kwargs["attn_implementation"] = attn_impl
|
||||
if not supports_sdpa and kwargs.get("attn_implementation") == "sdpa":
|
||||
print(
|
||||
f"Unsloth: {model_type_arch.title()} does not support SDPA - switching to fast eager."
|
||||
)
|
||||
del kwargs["attn_implementation"]
|
||||
# Re-stamp config so it stays consistent with the actual impl
|
||||
_set_attn_impl(auto_config, "eager")
|
||||
kwargs["attn_implementation"] = attn_impl
|
||||
|
||||
bnb_config = None
|
||||
user_quantization_config = kwargs.get("quantization_config", None)
|
||||
|
|
@ -780,9 +906,7 @@ class FastBaseModel:
|
|||
token = token,
|
||||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
setattr(auto_config, "_attn_implementation", config_attn_impl)
|
||||
if hasattr(auto_config, "attn_implementation"):
|
||||
setattr(auto_config, "attn_implementation", config_attn_impl)
|
||||
_set_attn_impl(auto_config, config_attn_impl)
|
||||
model_config = auto_config
|
||||
|
||||
verify_fp8_support_if_applicable(model_config)
|
||||
|
|
@ -811,6 +935,14 @@ class FastBaseModel:
|
|||
# attn_implementation = attn_implementation,
|
||||
**kwargs,
|
||||
)
|
||||
# Attach dispatch hooks for bnb multi-device loads.
|
||||
_attach_bnb_multidevice_hooks(
|
||||
model,
|
||||
load_in_4bit = load_in_4bit,
|
||||
load_in_8bit = load_in_8bit,
|
||||
offload_embedding = offload_embedding,
|
||||
fast_inference = fast_inference,
|
||||
)
|
||||
if hasattr(model, "generate"):
|
||||
model.fast_generate = make_fast_generate_wrapper(model.generate)
|
||||
model.fast_generate_batches = error_out_no_vllm
|
||||
|
|
|
|||
|
|
@ -636,48 +636,597 @@ def load_correct_tokenizer(
|
|||
return tokenizer
|
||||
|
||||
|
||||
def _find_end_position(template, endfor, endif):
|
||||
where_endfor = template.find(endfor)
|
||||
where_endif = template.find(endif)
|
||||
if where_endfor == where_endif == -1:
|
||||
# All four Jinja whitespace-control variants of endfor/endif:
|
||||
# {% endfor %} {%- endfor %} {% endfor -%} {%- endfor -%}
|
||||
_RE_ENDFOR = re.compile(r"\{%(-?)\s*endfor\s*(-?)%\}")
|
||||
_RE_ENDIF = re.compile(r"\{%(-?)\s*endif\s*(-?)%\}")
|
||||
_RE_JINJA_COMMENT = re.compile(r"\{#.*?#\}", flags = re.DOTALL)
|
||||
|
||||
|
||||
def _find_end_position(template, endfor = None, endif = None):
|
||||
"""Rightmost {% endfor %}/{% endif %} (any dash variant), as a dict
|
||||
with start/end/text/dash_left/dash_right. Tokens inside Jinja comments
|
||||
are ignored. `endfor`/`endif` kwargs kept for back-compat, ignored."""
|
||||
# Space-pad comments so positions still map 1:1 to the original.
|
||||
scrubbed = _RE_JINJA_COMMENT.sub(lambda m: " " * len(m.group(0)), template)
|
||||
endfor_matches = list(_RE_ENDFOR.finditer(scrubbed))
|
||||
endif_matches = list(_RE_ENDIF.finditer(scrubbed))
|
||||
last_endfor = endfor_matches[-1] if endfor_matches else None
|
||||
last_endif = endif_matches[-1] if endif_matches else None
|
||||
candidates = [m for m in (last_endfor, last_endif) if m is not None]
|
||||
if not candidates:
|
||||
return None
|
||||
elif where_endfor > where_endif:
|
||||
return endfor
|
||||
m = max(candidates, key = lambda x: x.end())
|
||||
return {
|
||||
"start": m.start(),
|
||||
"end": m.end(),
|
||||
"text": m.group(0),
|
||||
"dash_left": bool(m.group(1)),
|
||||
"dash_right": bool(m.group(2)),
|
||||
}
|
||||
|
||||
|
||||
def _template_ends_with_toplevel_for(chat_template):
|
||||
"""Return True if the last structural node at the template's top level is
|
||||
a For (message-iteration) loop, ignoring trailing pure-whitespace Output
|
||||
nodes. Unwraps benign outer-If guards (no else branch, not testing
|
||||
add_generation_prompt) so that templates like
|
||||
``{% if messages %}{% for ... %}{% endfor %}{% endif %}`` are still
|
||||
repairable. Rejects real structural wrappers (e.g. Qwen3-Guard with
|
||||
else branches)."""
|
||||
try:
|
||||
import jinja2
|
||||
import jinja2.nodes
|
||||
|
||||
ast = jinja2.Environment().parse(chat_template)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _last_structural(nodes):
|
||||
for node in reversed(nodes):
|
||||
if isinstance(node, jinja2.nodes.Output):
|
||||
only_ws = all(
|
||||
isinstance(child, jinja2.nodes.TemplateData)
|
||||
and child.data.strip() == ""
|
||||
for child in node.nodes
|
||||
)
|
||||
if only_ws:
|
||||
continue
|
||||
return node
|
||||
return None
|
||||
|
||||
node = _last_structural(ast.body)
|
||||
while isinstance(node, jinja2.nodes.If) and not node.else_:
|
||||
names = []
|
||||
if isinstance(node.test, jinja2.nodes.Name):
|
||||
names.append(node.test)
|
||||
names.extend(node.test.find_all(jinja2.nodes.Name))
|
||||
if any(n.name == "add_generation_prompt" for n in names):
|
||||
break
|
||||
node = _last_structural(node.body)
|
||||
|
||||
return isinstance(node, jinja2.nodes.For)
|
||||
|
||||
|
||||
def _if_body_emits_content(if_node):
|
||||
"""True if the If's body contains any Output node (directly or nested).
|
||||
Distinguishes a real generation block from a header guard that only
|
||||
does `{% set ... %}`."""
|
||||
import jinja2.nodes
|
||||
|
||||
for node in if_node.body:
|
||||
if isinstance(node, jinja2.nodes.Output):
|
||||
return True
|
||||
if any(
|
||||
isinstance(d, jinja2.nodes.Output)
|
||||
for d in node.find_all(jinja2.nodes.Output)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_add_generation_prompt_block(chat_template):
|
||||
"""True if the template has a *positive* `{% if add_generation_prompt %}`
|
||||
gate whose body emits output. Rejects header guards like
|
||||
`{% if not add_generation_prompt is defined %}{% set ... %}{% endif %}`
|
||||
that reference the name but emit nothing. AST-based; string-scan
|
||||
fallback if Jinja fails to parse."""
|
||||
try:
|
||||
import jinja2
|
||||
import jinja2.nodes
|
||||
|
||||
ast = jinja2.Environment().parse(chat_template)
|
||||
except Exception:
|
||||
return "if add_generation_prompt" in chat_template and "%}" in chat_template
|
||||
for if_node in ast.find_all(jinja2.nodes.If):
|
||||
test = if_node.test
|
||||
# Reject negated gates: `{% if not add_generation_prompt %}` fires
|
||||
# when agp=False, so it's not a generation block even if it emits.
|
||||
if isinstance(test, jinja2.nodes.Not):
|
||||
continue
|
||||
# find_all skips the test root, so check bare Name tests explicitly.
|
||||
references_agp = False
|
||||
if isinstance(test, jinja2.nodes.Name) and test.name == "add_generation_prompt":
|
||||
references_agp = True
|
||||
else:
|
||||
for name_node in test.find_all(jinja2.nodes.Name):
|
||||
if name_node.name == "add_generation_prompt":
|
||||
references_agp = True
|
||||
break
|
||||
if references_agp and _if_body_emits_content(if_node):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Sentinels for _derive_assistant_prefix_by_render. Diverge at char 0 so
|
||||
# commonprefix can't absorb them; long random tail makes collision with real
|
||||
# template literals negligible (see T18).
|
||||
_RENDER_DIFF_SENTINEL_A = "AAAA_0123456789_UNSLOTH_RENDER_DIFF_SENTINEL"
|
||||
_RENDER_DIFF_SENTINEL_B = "BBBB_0123456789_UNSLOTH_RENDER_DIFF_SENTINEL"
|
||||
_RENDER_DIFF_SENTINEL_C = "CCCC_0123456789_UNSLOTH_RENDER_DIFF_SENTINEL"
|
||||
|
||||
|
||||
def _derive_assistant_prefix_by_render(chat_template, is_sharegpt = False):
|
||||
"""Return the assistant-turn prefix the template emits, derived by
|
||||
rendering two dialogs that differ only in assistant content: the common
|
||||
prefix of their tails (after the base [user]-only render) is what the
|
||||
template emits for an assistant turn. None if any guard fails.
|
||||
|
||||
Works for Llama-3 / Gemma / Phi-3 and other non-ChatML shapes; the
|
||||
template is its own ground truth.
|
||||
|
||||
Known limitation: an `eos-on-non-last` pattern (turn-end sentinel only
|
||||
emitted for non-last messages) would produce a consistent but wrong
|
||||
prefix that `_validate_patched_template` can't catch. No real-world
|
||||
template is known to use this.
|
||||
"""
|
||||
try:
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if is_sharegpt:
|
||||
base_msgs = [{"from": "human", "value": "Hi"}]
|
||||
sent_a_msgs = base_msgs + [{"from": "gpt", "value": _RENDER_DIFF_SENTINEL_A}]
|
||||
sent_b_msgs = base_msgs + [{"from": "gpt", "value": _RENDER_DIFF_SENTINEL_B}]
|
||||
# User-role cross-check (Guard C below).
|
||||
sent_c_msgs = base_msgs + [{"from": "human", "value": _RENDER_DIFF_SENTINEL_C}]
|
||||
else:
|
||||
return endif
|
||||
base_msgs = [{"role": "user", "content": "Hi"}]
|
||||
sent_a_msgs = base_msgs + [
|
||||
{"role": "assistant", "content": _RENDER_DIFF_SENTINEL_A}
|
||||
]
|
||||
sent_b_msgs = base_msgs + [
|
||||
{"role": "assistant", "content": _RENDER_DIFF_SENTINEL_B}
|
||||
]
|
||||
sent_c_msgs = base_msgs + [{"role": "user", "content": _RENDER_DIFF_SENTINEL_C}]
|
||||
|
||||
# Strip trailing whitespace/comments after the last endfor/endif: they
|
||||
# appear after the message loop and would break Guard A. The splice in
|
||||
# `_fix_chat_template` drops them too.
|
||||
probe_template = chat_template
|
||||
end = _find_end_position(chat_template)
|
||||
if end is not None:
|
||||
after = chat_template[end["end"] :]
|
||||
if _RE_JINJA_COMMENT.sub("", after).strip() == "":
|
||||
probe_template = chat_template[: end["end"]]
|
||||
|
||||
# Sandboxed: probe renders at load time, before user calls
|
||||
# apply_chat_template. SandboxedEnvironment blocks attribute-chain exploits.
|
||||
try:
|
||||
env = SandboxedEnvironment(
|
||||
autoescape = False,
|
||||
keep_trailing_newline = True,
|
||||
)
|
||||
tmpl = env.from_string(probe_template)
|
||||
out_base = tmpl.render(messages = base_msgs, add_generation_prompt = False)
|
||||
out_a = tmpl.render(messages = sent_a_msgs, add_generation_prompt = False)
|
||||
out_b = tmpl.render(messages = sent_b_msgs, add_generation_prompt = False)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Best-effort: alternation-enforcing templates (e.g. Gemma's
|
||||
# raise_exception) fail on [user, user]; that's a positive signal
|
||||
# for Guard C, not a probe failure.
|
||||
out_user_c = None
|
||||
try:
|
||||
out_user_c = tmpl.render(messages = sent_c_msgs, add_generation_prompt = False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Guard A: assistant renders extend base (no reordering).
|
||||
if not (out_a.startswith(out_base) and out_b.startswith(out_base)):
|
||||
return None
|
||||
|
||||
tail_a = out_a[len(out_base) :]
|
||||
tail_b = out_b[len(out_base) :]
|
||||
if not tail_a or not tail_b:
|
||||
return None
|
||||
|
||||
prefix = os.path.commonprefix([tail_a, tail_b])
|
||||
|
||||
# Guard B: divergence is exactly at the content-insertion site.
|
||||
if not (
|
||||
tail_a[len(prefix) :].startswith(_RENDER_DIFF_SENTINEL_A)
|
||||
and tail_b[len(prefix) :].startswith(_RENDER_DIFF_SENTINEL_B)
|
||||
):
|
||||
return None
|
||||
|
||||
# Guard C: reject if a [user, user] render also emits the same prefix
|
||||
# (role-insensitive template, e.g. `{% set greeting='Hi' %}...`).
|
||||
if out_user_c is not None and out_user_c.startswith(out_base):
|
||||
tail_c = out_user_c[len(out_base) :]
|
||||
if tail_c.startswith(prefix) and prefix != "":
|
||||
return None
|
||||
|
||||
if not prefix:
|
||||
return None
|
||||
|
||||
return prefix
|
||||
|
||||
|
||||
def _fix_chat_template(chat_template):
|
||||
endfor = "{% endfor %}"
|
||||
endif = "{% endif %}"
|
||||
chosen_end = _find_end_position(chat_template, endfor, endif)
|
||||
if chosen_end is None:
|
||||
endfor = "{%- endfor %}"
|
||||
endif = "{%- endif %}"
|
||||
chosen_end = _find_end_position(chat_template, endfor, endif)
|
||||
if chosen_end is None:
|
||||
def _fix_chat_template(chat_template, is_sharegpt = False):
|
||||
# Fast path: already has an {% if add_generation_prompt %} block, nothing
|
||||
# to do. This catches cases the old string-based check would miss (e.g.
|
||||
# templates that use {%- if add_generation_prompt -%} with both-side dash,
|
||||
# or that sneak the block into a nested If/For).
|
||||
if _has_add_generation_prompt_block(chat_template):
|
||||
return chat_template
|
||||
|
||||
where = chat_template.find(chosen_end)
|
||||
end = _find_end_position(chat_template)
|
||||
if end is None:
|
||||
return chat_template
|
||||
|
||||
after_endfor = chat_template[where + len(chosen_end) :]
|
||||
|
||||
dash = "-" if chosen_end.startswith("{%-") else ""
|
||||
after_endfor = chat_template[end["end"] :]
|
||||
dash_l = "-" if end["dash_left"] else ""
|
||||
dash_r = "-" if end["dash_right"] else ""
|
||||
open_tag = lambda body: "{%" + dash_l + " " + body + " " + dash_r + "%}"
|
||||
|
||||
# Case 1 (pre-existing base case): template ends with a single trailing
|
||||
# {{ expr }} that is the generation prefix. Wrap it in an
|
||||
# {% if add_generation_prompt %} ... {% endif %}.
|
||||
if (
|
||||
"{%" + dash + " if" not in after_endfor
|
||||
and "{%" + dash + " set " not in after_endfor
|
||||
"{%" + dash_l + " if" not in after_endfor
|
||||
and "{%" + dash_l + " set " not in after_endfor
|
||||
and after_endfor.startswith("{{")
|
||||
and after_endfor.endswith("}}")
|
||||
and after_endfor.count("{{") == 1
|
||||
and after_endfor.count("}}") == 1
|
||||
):
|
||||
after_endfor = (
|
||||
"{%" + dash + " if add_generation_prompt %}" + after_endfor + endif
|
||||
wrapped = (
|
||||
open_tag("if add_generation_prompt") + after_endfor + open_tag("endif")
|
||||
)
|
||||
return chat_template[: end["end"]] + wrapped
|
||||
|
||||
# Case 2 (GH#4150): template ends at {% endfor %} with only whitespace
|
||||
# or comments left. Inject an {% if add_generation_prompt %} block with
|
||||
# the assistant prefix derived by render-diff. The top-level-For gate
|
||||
# keeps us out of outer-If wrappers (e.g. Qwen3-Guard).
|
||||
if _RE_JINJA_COMMENT.sub(
|
||||
"", after_endfor
|
||||
).strip() == "" and _template_ends_with_toplevel_for(chat_template):
|
||||
# No redundant "agp not in scrubbed" check: the fast path already
|
||||
# confirmed no *positive* block, and a mere reference (header
|
||||
# guard) should still get repaired.
|
||||
assistant_prefix = _derive_assistant_prefix_by_render(
|
||||
chat_template, is_sharegpt
|
||||
)
|
||||
# Dual-probe: dict/list callers don't know the shape up front.
|
||||
if assistant_prefix is None and not is_sharegpt:
|
||||
assistant_prefix = _derive_assistant_prefix_by_render(
|
||||
chat_template, is_sharegpt = True
|
||||
)
|
||||
if assistant_prefix is None:
|
||||
return chat_template
|
||||
# Escape for a double-quoted Jinja string literal.
|
||||
escaped = (
|
||||
assistant_prefix.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
)
|
||||
generation_block = (
|
||||
open_tag("if add_generation_prompt")
|
||||
+ '{{ "'
|
||||
+ escaped
|
||||
+ '" }}'
|
||||
+ open_tag("endif")
|
||||
)
|
||||
return chat_template[: end["end"]] + generation_block
|
||||
|
||||
return chat_template
|
||||
|
||||
|
||||
def _is_strict_chat_template_mode():
|
||||
"""Opt-in strict mode restores the pre-warn RuntimeError behavior."""
|
||||
val = os.environ.get("UNSLOTH_STRICT_CHAT_TEMPLATE", "0")
|
||||
return str(val).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _name_is_local_path(name_or_path):
|
||||
"""True if name_or_path refers to an existing local directory. Used to
|
||||
tailor the warning message: for local paths the user cannot 'file a bug
|
||||
report to the maintainers of <path>' since that path is their own."""
|
||||
if not name_or_path:
|
||||
return False
|
||||
try:
|
||||
return os.path.isdir(str(name_or_path))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _format_chat_template_message(
|
||||
name_or_path,
|
||||
repaired,
|
||||
has_generation_block = False,
|
||||
local_path_source = None,
|
||||
strict = False,
|
||||
):
|
||||
"""Build a user-facing warning/error message that points at the right
|
||||
responsible party (user's downstream tool vs. upstream model maintainer)."""
|
||||
local = _name_is_local_path(
|
||||
local_path_source if local_path_source is not None else name_or_path
|
||||
)
|
||||
if local:
|
||||
source_hint = (
|
||||
"This tokenizer was loaded from a local path. The likely cause is a "
|
||||
"downstream tool (LlamaFactory, Axolotl, etc.) that re-serialized "
|
||||
"the tokenizer during save and stripped the generation-prompt "
|
||||
"block. Either re-save with the original template, or set "
|
||||
"`tokenizer.chat_template` manually before loading."
|
||||
)
|
||||
else:
|
||||
source_hint = (
|
||||
"The chat_template shipped with `{name}` appears incomplete. "
|
||||
"Consider filing a bug report with the model maintainers."
|
||||
).format(name = name_or_path)
|
||||
strict_suffix = (
|
||||
""
|
||||
if strict
|
||||
else (" Set UNSLOTH_STRICT_CHAT_TEMPLATE=1 to raise instead of warn.")
|
||||
)
|
||||
if repaired:
|
||||
return (
|
||||
"Unsloth: Patched the chat_template on `{name}` to add a "
|
||||
"{{% if add_generation_prompt %}} block. {hint}"
|
||||
).format(name = name_or_path, hint = source_hint)
|
||||
if has_generation_block:
|
||||
return (
|
||||
"Unsloth: The tokenizer `{name}` has a "
|
||||
"{{% if add_generation_prompt %}} block, but it does not change "
|
||||
"the rendered output. {hint}{suffix}"
|
||||
).format(name = name_or_path, hint = source_hint, suffix = strict_suffix)
|
||||
load_clause = (
|
||||
"Loading is blocked in strict mode."
|
||||
if strict
|
||||
else "The model will still load, but "
|
||||
"`apply_chat_template(add_generation_prompt=True)` may not produce a "
|
||||
"correct assistant-turn marker."
|
||||
)
|
||||
return (
|
||||
"Unsloth: The tokenizer `{name}` does not have a "
|
||||
"{{% if add_generation_prompt %}} block for generation purposes, and "
|
||||
"automatic repair was not possible. {load_clause} {hint}{suffix}"
|
||||
).format(
|
||||
name = name_or_path,
|
||||
load_clause = load_clause,
|
||||
hint = source_hint,
|
||||
suffix = strict_suffix,
|
||||
)
|
||||
|
||||
|
||||
def _validate_patched_template(tokenizer, patched_template, is_sharegpt):
|
||||
"""Render the just-patched template with and without
|
||||
add_generation_prompt, and confirm the patched output responds to the
|
||||
flag by appending (not replacing) content. Returns True if validation
|
||||
passes."""
|
||||
msgs = (
|
||||
[{"from": "human", "value": "Hi"}]
|
||||
if is_sharegpt
|
||||
else [{"role": "user", "content": "Hi"}]
|
||||
)
|
||||
original = getattr(tokenizer, "chat_template", None)
|
||||
try:
|
||||
try:
|
||||
tokenizer.chat_template = patched_template
|
||||
except Exception:
|
||||
return False # read-only tokenizer, skip validation
|
||||
try:
|
||||
yes = tokenizer.apply_chat_template(
|
||||
msgs,
|
||||
add_generation_prompt = True,
|
||||
tokenize = False,
|
||||
)
|
||||
no = tokenizer.apply_chat_template(
|
||||
msgs,
|
||||
add_generation_prompt = False,
|
||||
tokenize = False,
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
tokenizer.chat_template = original
|
||||
except Exception:
|
||||
pass # best-effort restore
|
||||
# Contract after a successful repair: the two renders differ, and the
|
||||
# "yes" render is a strict extension of the "no" render (we only
|
||||
# appended content inside the new add_generation_prompt block).
|
||||
return yes != no and yes.startswith(no)
|
||||
|
||||
|
||||
def _repair_string_template(tokenizer, chat_template, is_sharegpt):
|
||||
"""Core string-template repair. Returns the repaired template on success,
|
||||
or None if repair was not possible / failed validation."""
|
||||
candidate = _fix_chat_template(chat_template, is_sharegpt = is_sharegpt)
|
||||
if not _has_add_generation_prompt_block(candidate):
|
||||
return None
|
||||
# Validate with the caller's is_sharegpt first. If that fails, the
|
||||
# dual-probe in _fix_chat_template may have fallen back to the other
|
||||
# schema internally -- try validating with the opposite schema before
|
||||
# giving up.
|
||||
if _validate_patched_template(tokenizer, candidate, is_sharegpt):
|
||||
return candidate
|
||||
if _validate_patched_template(tokenizer, candidate, not is_sharegpt):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _fix_chat_template_for_tokenizer(tokenizer, chat_template):
|
||||
"""Entry point for a string chat_template. Runs the no==yes diagnostic,
|
||||
attempts repair if needed, and returns the (possibly patched) template.
|
||||
|
||||
On repair failure, the behavior is controlled by
|
||||
UNSLOTH_STRICT_CHAT_TEMPLATE: warn + return original (default) or raise
|
||||
RuntimeError (strict)."""
|
||||
name = getattr(tokenizer, "name_or_path", "unknown")
|
||||
source_path = getattr(tokenizer, "_source_path", name)
|
||||
|
||||
# Detect ShareGPT vs HF style by probing apply_chat_template.
|
||||
is_sharegpt = None
|
||||
try:
|
||||
tokenizer.apply_chat_template(
|
||||
[{"role": "user", "content": "Who are you?"}],
|
||||
add_generation_prompt = False,
|
||||
tokenize = False,
|
||||
)
|
||||
is_sharegpt = False
|
||||
except Exception:
|
||||
try:
|
||||
tokenizer.apply_chat_template(
|
||||
[{"from": "human", "value": "Who are you?"}],
|
||||
add_generation_prompt = False,
|
||||
tokenize = False,
|
||||
)
|
||||
is_sharegpt = True
|
||||
except Exception:
|
||||
is_sharegpt = None
|
||||
|
||||
if is_sharegpt is None:
|
||||
return chat_template
|
||||
|
||||
messages = (
|
||||
[{"from": "human", "value": "Who are you?"}]
|
||||
if is_sharegpt
|
||||
else [{"role": "user", "content": "Who are you?"}]
|
||||
)
|
||||
try:
|
||||
no = tokenizer.apply_chat_template(
|
||||
messages,
|
||||
add_generation_prompt = False,
|
||||
tokenize = False,
|
||||
)
|
||||
yes = tokenizer.apply_chat_template(
|
||||
messages,
|
||||
add_generation_prompt = True,
|
||||
tokenize = False,
|
||||
)
|
||||
except Exception:
|
||||
return chat_template
|
||||
|
||||
if no != yes:
|
||||
# Template already responds to the flag; leave as is.
|
||||
return chat_template
|
||||
|
||||
# no == yes: template ignores add_generation_prompt. Try to repair.
|
||||
if _has_add_generation_prompt_block(chat_template):
|
||||
# Template has the block but it does not change output. This is the
|
||||
# "wasn't provided correctly" case from the pre-warn code path.
|
||||
strict = _is_strict_chat_template_mode()
|
||||
msg = _format_chat_template_message(
|
||||
name,
|
||||
repaired = False,
|
||||
has_generation_block = True,
|
||||
local_path_source = source_path,
|
||||
strict = strict,
|
||||
)
|
||||
if strict:
|
||||
raise RuntimeError(msg)
|
||||
logger.warning_once(msg)
|
||||
return chat_template
|
||||
|
||||
repaired = _repair_string_template(tokenizer, chat_template, is_sharegpt)
|
||||
if repaired is not None:
|
||||
logger.warning_once(
|
||||
_format_chat_template_message(
|
||||
name,
|
||||
repaired = True,
|
||||
local_path_source = source_path,
|
||||
)
|
||||
)
|
||||
return repaired
|
||||
|
||||
strict = _is_strict_chat_template_mode()
|
||||
msg = _format_chat_template_message(
|
||||
name,
|
||||
repaired = False,
|
||||
local_path_source = source_path,
|
||||
strict = strict,
|
||||
)
|
||||
if strict:
|
||||
raise RuntimeError(msg)
|
||||
logger.warning_once(msg)
|
||||
return chat_template
|
||||
|
||||
|
||||
class _VariantTokenizerProxy:
|
||||
"""Single-variant view of a multi-variant tokenizer. Routes each variant
|
||||
through `_fix_chat_template_for_tokenizer` so the full contract
|
||||
(is_sharegpt probe, no==yes, warn/strict, `_validate_patched_template`)
|
||||
applies instead of jumping straight to structural repair.
|
||||
|
||||
`apply_chat_template` swaps `base.chat_template` to the variant before
|
||||
calling so tokenizer globals (bos_token, filters, raise_exception) are
|
||||
preserved; falls back to bare Jinja for read-only stubs.
|
||||
"""
|
||||
|
||||
def __init__(self, base_tokenizer, variant_template, variant_label = ""):
|
||||
self._base = base_tokenizer
|
||||
self._template = variant_template
|
||||
base_name = getattr(base_tokenizer, "name_or_path", "unknown")
|
||||
self._source_path = base_name
|
||||
self.name_or_path = (
|
||||
f"{base_name} ({variant_label})" if variant_label else base_name
|
||||
)
|
||||
|
||||
chat_template = chat_template[: where + len(chosen_end)] + after_endfor
|
||||
return chat_template
|
||||
@property
|
||||
def chat_template(self):
|
||||
return self._template
|
||||
|
||||
@chat_template.setter
|
||||
def chat_template(self, value):
|
||||
self._template = value
|
||||
|
||||
def apply_chat_template(self, *args, **kwargs):
|
||||
base_original = getattr(self._base, "chat_template", None)
|
||||
swapped = False
|
||||
try:
|
||||
try:
|
||||
self._base.chat_template = self._template
|
||||
swapped = True
|
||||
except Exception:
|
||||
swapped = False
|
||||
if swapped:
|
||||
return self._base.apply_chat_template(*args, **kwargs)
|
||||
# Read-only base: fall back to sandboxed Jinja.
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
env = SandboxedEnvironment(
|
||||
autoescape = False,
|
||||
keep_trailing_newline = True,
|
||||
)
|
||||
messages = args[0] if args else kwargs.get("messages", [])
|
||||
add_generation_prompt = kwargs.get("add_generation_prompt", False)
|
||||
return env.from_string(self._template).render(
|
||||
messages = messages,
|
||||
add_generation_prompt = add_generation_prompt,
|
||||
)
|
||||
finally:
|
||||
if swapped:
|
||||
try:
|
||||
self._base.chat_template = base_original
|
||||
except Exception:
|
||||
pass # best-effort restore
|
||||
|
||||
|
||||
def fix_chat_template(tokenizer):
|
||||
|
|
@ -685,76 +1234,41 @@ def fix_chat_template(tokenizer):
|
|||
if chat_template is None:
|
||||
return None
|
||||
|
||||
### 1. Check if add_generation_prompt works
|
||||
# Check for ShareGPT style first
|
||||
is_sharegpt = None
|
||||
try:
|
||||
messages = [
|
||||
{"role": "user", "content": "Who are you?"},
|
||||
]
|
||||
tokenizer.apply_chat_template(
|
||||
messages, add_generation_prompt = False, tokenize = False
|
||||
)
|
||||
is_sharegpt = False
|
||||
except:
|
||||
try:
|
||||
messages = [
|
||||
{"from": "human", "value": "Who are you?"},
|
||||
]
|
||||
tokenizer.apply_chat_template(
|
||||
messages, add_generation_prompt = False, tokenize = False
|
||||
# Multi-variant dict (e.g. Hermes-3 {default, tool_use}): route each
|
||||
# variant through the full repair contract via _VariantTokenizerProxy.
|
||||
if isinstance(chat_template, dict):
|
||||
fixed = {}
|
||||
for key, tmpl in chat_template.items():
|
||||
if not isinstance(tmpl, str):
|
||||
fixed[key] = tmpl
|
||||
continue
|
||||
proxy = _VariantTokenizerProxy(
|
||||
tokenizer, tmpl, variant_label = f"variant={key!r}"
|
||||
)
|
||||
is_sharegpt = True
|
||||
except:
|
||||
is_sharegpt = None
|
||||
fixed[key] = _fix_chat_template_for_tokenizer(proxy, tmpl)
|
||||
return fixed
|
||||
|
||||
# Not ShareGPT or HF style - just return
|
||||
if is_sharegpt is None:
|
||||
return chat_template
|
||||
|
||||
# Tokenize
|
||||
messages = [
|
||||
{"role": "user", "content": "Who are you?"}
|
||||
if not is_sharegpt
|
||||
else {"from": "human", "value": "Who are you?"}
|
||||
]
|
||||
no = tokenizer.apply_chat_template(
|
||||
messages, add_generation_prompt = False, tokenize = False
|
||||
)
|
||||
yes = tokenizer.apply_chat_template(
|
||||
messages, add_generation_prompt = True, tokenize = False
|
||||
)
|
||||
|
||||
if no == yes:
|
||||
# SAME?! That's not good! We check for add_generation_prompt
|
||||
if (
|
||||
"{% if add_generation_prompt %}" not in chat_template
|
||||
and "{%- if add_generation_prompt %}" not in chat_template
|
||||
):
|
||||
# Try fixing it by adding it
|
||||
new_chat_template = _fix_chat_template(chat_template)
|
||||
if (
|
||||
"{% if add_generation_prompt %}" not in new_chat_template
|
||||
and "{%- if add_generation_prompt %}" not in new_chat_template
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Unsloth: The tokenizer `{tokenizer.name_or_path}`\n"
|
||||
"does not have a {% if add_generation_prompt %} for generation purposes.\n"
|
||||
f"Please file a bug report to the maintainers of `{tokenizer.name_or_path}` - thanks!"
|
||||
)
|
||||
# List-of-dicts form (older HF multi-template style).
|
||||
if isinstance(chat_template, list):
|
||||
fixed = []
|
||||
for item in chat_template:
|
||||
if not isinstance(item, dict) or "template" not in item:
|
||||
fixed.append(item)
|
||||
continue
|
||||
tmpl = item["template"]
|
||||
if not isinstance(tmpl, str):
|
||||
fixed.append(item)
|
||||
continue
|
||||
label = f"variant={item.get('name', '?')!r}"
|
||||
proxy = _VariantTokenizerProxy(tokenizer, tmpl, variant_label = label)
|
||||
new_tmpl = _fix_chat_template_for_tokenizer(proxy, tmpl)
|
||||
if new_tmpl is tmpl or new_tmpl == tmpl:
|
||||
fixed.append(item)
|
||||
else:
|
||||
logger.warning_once(
|
||||
"Unsloth: We successfully patched the tokenizer to add a {% if add_generation_prompt %} to the chat_template.\n"
|
||||
f"This is not a bug, but please notify the maintainers of `{tokenizer.name_or_path}` - thanks!"
|
||||
)
|
||||
chat_template = new_chat_template
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Unsloth: The tokenizer `{tokenizer.name_or_path}`\n"
|
||||
"has a {% if add_generation_prompt %} for generation purposes, but wasn't provided correctly.\n"
|
||||
"Please file a bug report immediately - thanks!"
|
||||
)
|
||||
return chat_template
|
||||
fixed.append({**item, "template": new_tmpl})
|
||||
return fixed
|
||||
|
||||
return _fix_chat_template_for_tokenizer(tokenizer, chat_template)
|
||||
|
||||
|
||||
def check_tokenizer(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue