Merge branch 'main' into feature/tool-choice-kwarg-openai-format
This commit is contained in:
commit
02da8a5f27
88 changed files with 7237 additions and 2437 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
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ huggingfacenotorch = [
|
|||
]
|
||||
huggingface = [
|
||||
"unsloth[huggingfacenotorch]",
|
||||
"unsloth_zoo>=2026.4.3",
|
||||
"unsloth_zoo>=2026.4.7",
|
||||
"torchvision",
|
||||
"unsloth[triton]",
|
||||
]
|
||||
|
|
@ -578,7 +578,7 @@ colab-ampere-torch220 = [
|
|||
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
|
||||
]
|
||||
colab-new = [
|
||||
"unsloth_zoo>=2026.4.3",
|
||||
"unsloth_zoo>=2026.4.7",
|
||||
"packaging",
|
||||
"tyro",
|
||||
"transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -213,3 +213,68 @@ class ScanFolderInfo(BaseModel):
|
|||
id: int = Field(..., description = "Database row ID")
|
||||
path: str = Field(..., description = "Normalized absolute path")
|
||||
created_at: str = Field(..., description = "ISO 8601 creation timestamp")
|
||||
|
||||
|
||||
class BrowseEntry(BaseModel):
|
||||
"""A directory entry surfaced by the folder browser."""
|
||||
|
||||
name: str = Field(..., description = "Entry name (basename, not full path)")
|
||||
has_models: bool = Field(
|
||||
False,
|
||||
description = (
|
||||
"Hint that the directory likely contains models "
|
||||
"(*.gguf, *.safetensors, config.json, or HF-style "
|
||||
"`models--*` subfolders). Used by the UI to highlight "
|
||||
"promising candidates; the scanner itself is authoritative."
|
||||
),
|
||||
)
|
||||
hidden: bool = Field(
|
||||
False,
|
||||
description = "Name starts with a dot (e.g. `.cache`)",
|
||||
)
|
||||
|
||||
|
||||
class BrowseFoldersResponse(BaseModel):
|
||||
"""Response schema for the folder browser endpoint."""
|
||||
|
||||
current: str = Field(..., description = "Absolute path of the directory just listed")
|
||||
parent: Optional[str] = Field(
|
||||
None,
|
||||
description = (
|
||||
"Parent directory of `current`, or null if `current` is the "
|
||||
"filesystem root. The frontend uses this to render an `Up` row."
|
||||
),
|
||||
)
|
||||
entries: List[BrowseEntry] = Field(
|
||||
default_factory = list,
|
||||
description = (
|
||||
"Subdirectories of `current`. Sorted with model-bearing "
|
||||
"directories first, then alphabetically case-insensitive; "
|
||||
"hidden entries come last within each group."
|
||||
),
|
||||
)
|
||||
suggestions: List[str] = Field(
|
||||
default_factory = list,
|
||||
description = (
|
||||
"Handy starting points (home, HF cache, already-registered "
|
||||
"scan folders). Rendered as quick-pick chips above the list."
|
||||
),
|
||||
)
|
||||
truncated: bool = Field(
|
||||
False,
|
||||
description = (
|
||||
"True when the listing was capped because the directory had "
|
||||
"more subfolders than the server is willing to enumerate in "
|
||||
"one request. The UI should show a hint telling the user to "
|
||||
"narrow their path."
|
||||
),
|
||||
)
|
||||
model_files_here: int = Field(
|
||||
0,
|
||||
description = (
|
||||
"Count of GGUF/safetensors files immediately inside "
|
||||
"``current``. Used by the UI to surface a hint on leaf "
|
||||
"model directories (which otherwise look `empty` because "
|
||||
"they contain only files, no subdirectories)."
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -101,6 +104,8 @@ from models import (
|
|||
ModelListResponse,
|
||||
)
|
||||
from models.models import (
|
||||
BrowseEntry,
|
||||
BrowseFoldersResponse,
|
||||
GgufVariantDetail,
|
||||
GgufVariantsResponse,
|
||||
ModelType,
|
||||
|
|
@ -409,6 +414,267 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
|
|||
return found
|
||||
|
||||
|
||||
def _ollama_links_dir(ollama_dir: Path) -> Optional[Path]:
|
||||
"""Return a writable directory for Ollama ``.gguf`` symlinks.
|
||||
|
||||
Prefers ``<ollama_dir>/.studio_links/`` so the links sit next to the
|
||||
blobs they point at. Falls back to a per-ollama-dir namespace under
|
||||
Studio's own cache when the models directory is read-only (common
|
||||
for system installs under ``/usr/share/ollama`` or ``/var/lib/ollama``)
|
||||
so we still surface Ollama models in those environments.
|
||||
"""
|
||||
from utils.paths.storage_roots import cache_root
|
||||
|
||||
primary = ollama_dir / ".studio_links"
|
||||
try:
|
||||
primary.mkdir(exist_ok = True)
|
||||
return primary
|
||||
except OSError as e:
|
||||
logger.debug(
|
||||
"Ollama dir %s not writable for .studio_links (%s); "
|
||||
"falling back to Studio cache",
|
||||
ollama_dir,
|
||||
e,
|
||||
)
|
||||
|
||||
# Fallback: namespace by a hash of the ollama_dir so two different
|
||||
# Ollama roots don't collide. This is a cache path, not a security
|
||||
# boundary.
|
||||
try:
|
||||
digest = hashlib.sha256(str(ollama_dir.resolve()).encode()).hexdigest()[:12]
|
||||
except OSError:
|
||||
digest = "default"
|
||||
fallback = cache_root() / "ollama_links" / digest
|
||||
try:
|
||||
fallback.mkdir(parents = True, exist_ok = True)
|
||||
return fallback
|
||||
except OSError as e:
|
||||
logger.warning(
|
||||
"Could not create Ollama symlink cache at %s: %s",
|
||||
fallback,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _scan_ollama_dir(
|
||||
ollama_dir: Path, limit: Optional[int] = None
|
||||
) -> List[LocalModelInfo]:
|
||||
"""Scan an Ollama models directory for downloaded models.
|
||||
|
||||
Ollama stores models in a content-addressable layout::
|
||||
|
||||
<ollama_dir>/manifests/<host>/<namespace>/<model>/<tag>
|
||||
<ollama_dir>/blobs/sha256-...
|
||||
|
||||
The default host is ``registry.ollama.ai`` with namespace
|
||||
``library`` (official models), but users can pull from custom
|
||||
namespaces (``mradermacher/llama3``) or entirely different hosts
|
||||
(``hf.co/org/repo:tag``). We iterate all manifest files via
|
||||
``rglob`` so every layout depth is discovered.
|
||||
|
||||
Each manifest is JSON with a ``layers`` array. The layer with
|
||||
``mediaType == "application/vnd.ollama.image.model"`` contains the
|
||||
GGUF weights. Vision models also have a projector layer
|
||||
(``application/vnd.ollama.image.projector``). We read the config
|
||||
layer to extract family/size info.
|
||||
|
||||
Since Ollama blobs lack a ``.gguf`` extension (which the GGUF
|
||||
loading pipeline requires), we create ``.gguf``-named links
|
||||
pointing at the blobs so the existing ``detect_gguf_model`` and
|
||||
``llama-server -m`` paths work unchanged. Each model gets its
|
||||
own subdirectory under the links dir (keyed by a short hash of
|
||||
the manifest path) so that ``detect_mmproj_file`` only sees the
|
||||
projector for *that* model. Links are created as symlinks when
|
||||
possible, falling back to hardlinks (Windows without Developer
|
||||
Mode) as a last resort. The link dir lives under
|
||||
``<ollama_dir>/.studio_links/`` when writable, otherwise under
|
||||
Studio's own cache directory.
|
||||
"""
|
||||
manifests_root = ollama_dir / "manifests"
|
||||
if not manifests_root.is_dir():
|
||||
return []
|
||||
|
||||
found: List[LocalModelInfo] = []
|
||||
blobs_dir = ollama_dir / "blobs"
|
||||
links_root = _ollama_links_dir(ollama_dir)
|
||||
if links_root is None:
|
||||
logger.warning(
|
||||
"Skipping Ollama scan for %s: no writable location for .gguf links",
|
||||
ollama_dir,
|
||||
)
|
||||
return []
|
||||
|
||||
def _make_link(link_dir: Path, link_name: str, target: Path) -> Optional[str]:
|
||||
"""Create a .gguf-named link to an Ollama blob.
|
||||
|
||||
Tries symlink first, then hardlink (works on Windows without
|
||||
Developer Mode when target is on the same filesystem). Skips
|
||||
the model if neither works -- a full file copy of a multi-GB
|
||||
GGUF inside a synchronous API request would block the backend.
|
||||
|
||||
Idempotent: skips recreation when a valid link already exists.
|
||||
"""
|
||||
link_dir.mkdir(parents = True, exist_ok = True)
|
||||
link_path = link_dir / link_name
|
||||
resolved = target.resolve()
|
||||
|
||||
# Skip if the link already points at the exact same blob.
|
||||
# Only use samefile -- size-based checks can reuse stale links
|
||||
# after `ollama pull` updates a tag to a same-sized blob.
|
||||
try:
|
||||
if link_path.exists() and os.path.samefile(str(link_path), str(resolved)):
|
||||
return str(link_path)
|
||||
except OSError as e:
|
||||
logger.debug("Error checking existing link %s: %s", link_path, e)
|
||||
|
||||
tmp_path = link_dir / f".{link_name}.tmp-{uuid.uuid4().hex[:8]}"
|
||||
try:
|
||||
if tmp_path.is_symlink() or tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
try:
|
||||
tmp_path.symlink_to(resolved)
|
||||
except OSError:
|
||||
try:
|
||||
os.link(str(resolved), str(tmp_path))
|
||||
except OSError:
|
||||
logger.warning(
|
||||
"Could not create link for Ollama blob %s "
|
||||
"(symlinks and hardlinks both failed). "
|
||||
"Skipping model to avoid blocking the API.",
|
||||
target,
|
||||
)
|
||||
return None
|
||||
os.replace(str(tmp_path), str(link_path))
|
||||
return str(link_path)
|
||||
except OSError as e:
|
||||
logger.debug("Could not create Ollama link %s: %s", link_path, e)
|
||||
try:
|
||||
if tmp_path.is_symlink() or tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
except OSError as cleanup_err:
|
||||
logger.debug(
|
||||
"Could not clean up tmp path %s: %s", tmp_path, cleanup_err
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
for tag_file in manifests_root.rglob("*"):
|
||||
if not tag_file.is_file():
|
||||
continue
|
||||
|
||||
rel = tag_file.relative_to(manifests_root)
|
||||
parts = rel.parts
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
|
||||
host = parts[0]
|
||||
repo_parts = list(parts[1:-1])
|
||||
tag = parts[-1]
|
||||
|
||||
if (
|
||||
host == "registry.ollama.ai"
|
||||
and repo_parts
|
||||
and repo_parts[0] == "library"
|
||||
):
|
||||
repo_name = "/".join(repo_parts[1:])
|
||||
elif host == "registry.ollama.ai":
|
||||
repo_name = "/".join(repo_parts)
|
||||
else:
|
||||
repo_name = "/".join([host] + repo_parts)
|
||||
|
||||
if not repo_name:
|
||||
continue
|
||||
|
||||
display = f"{repo_name}:{tag}"
|
||||
|
||||
manifest_key = rel.as_posix()
|
||||
stem_hash = hashlib.sha256(manifest_key.encode()).hexdigest()[:10]
|
||||
|
||||
try:
|
||||
manifest = json.loads(tag_file.read_text())
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.debug(
|
||||
"Skipping unreadable/invalid Ollama manifest %s: %s",
|
||||
tag_file,
|
||||
e,
|
||||
)
|
||||
continue
|
||||
|
||||
config_digest = manifest.get("config", {}).get("digest", "")
|
||||
model_type = ""
|
||||
file_type = ""
|
||||
if config_digest and blobs_dir.is_dir():
|
||||
config_blob = blobs_dir / config_digest.replace(":", "-")
|
||||
if config_blob.is_file():
|
||||
try:
|
||||
cfg = json.loads(config_blob.read_text())
|
||||
model_type = cfg.get("model_type", "")
|
||||
file_type = cfg.get("file_type", "")
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.debug(
|
||||
"Could not parse Ollama config blob %s: %s",
|
||||
config_blob,
|
||||
e,
|
||||
)
|
||||
|
||||
model_link_dir = links_root / stem_hash
|
||||
|
||||
gguf_link_path: Optional[str] = None
|
||||
quant = f"-{file_type}" if file_type else ""
|
||||
safe_name = repo_name.replace("/", "-")
|
||||
for layer in manifest.get("layers", []):
|
||||
media = layer.get("mediaType", "")
|
||||
digest = layer.get("digest", "")
|
||||
if not digest:
|
||||
continue
|
||||
|
||||
if media == "application/vnd.ollama.image.model":
|
||||
candidate = blobs_dir / digest.replace(":", "-")
|
||||
if candidate.is_file():
|
||||
link_name = f"{safe_name}-{tag}{quant}.gguf"
|
||||
gguf_link_path = _make_link(
|
||||
model_link_dir, link_name, candidate
|
||||
)
|
||||
|
||||
elif media == "application/vnd.ollama.image.projector":
|
||||
candidate = blobs_dir / digest.replace(":", "-")
|
||||
if candidate.is_file():
|
||||
mmproj_name = f"{safe_name}-{tag}-mmproj.gguf"
|
||||
_make_link(model_link_dir, mmproj_name, candidate)
|
||||
|
||||
if not gguf_link_path:
|
||||
continue
|
||||
|
||||
suffix = ""
|
||||
if model_type:
|
||||
suffix += f" ({model_type}"
|
||||
if file_type:
|
||||
suffix += f" {file_type}"
|
||||
suffix += ")"
|
||||
|
||||
try:
|
||||
updated_at = tag_file.stat().st_mtime
|
||||
except OSError:
|
||||
updated_at = None
|
||||
|
||||
found.append(
|
||||
LocalModelInfo(
|
||||
id = gguf_link_path,
|
||||
model_id = f"ollama/{repo_name}:{tag}",
|
||||
display_name = display + suffix,
|
||||
path = gguf_link_path,
|
||||
source = "custom",
|
||||
updated_at = updated_at,
|
||||
),
|
||||
)
|
||||
if limit is not None and len(found) >= limit:
|
||||
return found
|
||||
except OSError as e:
|
||||
logger.warning("Error scanning Ollama directory %s: %s", ollama_dir, e)
|
||||
return found
|
||||
|
||||
|
||||
@router.get("/local", response_model = LocalModelListResponse)
|
||||
async def list_local_models(
|
||||
models_dir: str = Query(
|
||||
|
|
@ -491,11 +757,27 @@ async def list_local_models(
|
|||
for folder in custom_folders:
|
||||
folder_path = Path(folder["path"])
|
||||
try:
|
||||
custom_models = (
|
||||
_scan_models_dir(folder_path, limit = _MAX_MODELS_PER_FOLDER)
|
||||
+ _scan_hf_cache(folder_path)
|
||||
+ _scan_lmstudio_dir(folder_path)
|
||||
)[:_MAX_MODELS_PER_FOLDER]
|
||||
# Ollama scanner creates .studio_links/ with .gguf symlinks.
|
||||
# Filter those from the generic scanners to avoid duplicates
|
||||
# and leaking internal paths into the UI.
|
||||
_generic = [
|
||||
m
|
||||
for m in (
|
||||
_scan_models_dir(folder_path, limit = _MAX_MODELS_PER_FOLDER)
|
||||
+ _scan_hf_cache(folder_path)
|
||||
+ _scan_lmstudio_dir(folder_path)
|
||||
)
|
||||
if not any(
|
||||
p in (".studio_links", "ollama_links")
|
||||
for p in Path(m.path).parts
|
||||
)
|
||||
]
|
||||
custom_models = _generic
|
||||
if len(custom_models) < _MAX_MODELS_PER_FOLDER:
|
||||
custom_models += _scan_ollama_dir(
|
||||
folder_path,
|
||||
limit = _MAX_MODELS_PER_FOLDER - len(custom_models),
|
||||
)
|
||||
except OSError as e:
|
||||
logger.warning("Skipping unreadable scan folder %s: %s", folder_path, e)
|
||||
continue
|
||||
|
|
@ -573,6 +855,580 @@ async def remove_scan_folder_endpoint(
|
|||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/recommended-folders")
|
||||
async def get_recommended_folders(
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""Return well-known model directories that exist on this machine.
|
||||
|
||||
Lightweight alternative to ``browse-folders`` for showing quick-pick
|
||||
chips without the overhead of enumerating a directory tree. Returns
|
||||
paths that actually exist on disk (HF cache, LM Studio, Ollama,
|
||||
``~/models``, etc.) so the frontend can offer them as one-click
|
||||
"Recommended" shortcuts in the Custom Folders section.
|
||||
"""
|
||||
from utils.paths.storage_roots import lmstudio_model_dirs
|
||||
|
||||
folders: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _add(p: Optional[Path]) -> None:
|
||||
if p is None:
|
||||
return
|
||||
try:
|
||||
resolved = str(p.resolve())
|
||||
except OSError:
|
||||
return
|
||||
if resolved in seen:
|
||||
return
|
||||
if Path(resolved).is_dir() and os.access(resolved, os.R_OK | os.X_OK):
|
||||
seen.add(resolved)
|
||||
folders.append(resolved)
|
||||
|
||||
# LM Studio model directories
|
||||
try:
|
||||
for p in lmstudio_model_dirs():
|
||||
_add(p)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to scan for LM Studio model directories: %s", e)
|
||||
|
||||
# Ollama model directories
|
||||
ollama_env = os.environ.get("OLLAMA_MODELS")
|
||||
if ollama_env:
|
||||
_add(Path(ollama_env).expanduser())
|
||||
for candidate in (
|
||||
Path.home() / ".ollama" / "models",
|
||||
Path("/usr/share/ollama/.ollama/models"),
|
||||
Path("/var/lib/ollama/.ollama/models"),
|
||||
):
|
||||
_add(candidate)
|
||||
|
||||
return {"folders": folders}
|
||||
|
||||
|
||||
# Heuristic ceiling on how many children to stat when checking whether a
|
||||
# directory "looks like" it contains models. Keeps the browser snappy
|
||||
# even when a directory has thousands of unrelated entries.
|
||||
_BROWSE_MODEL_HINT_PROBE = 64
|
||||
# Hard cap on how many subdirectory entries we send back. Pointing the
|
||||
# browser at something like ``/usr/lib`` or ``/proc`` must not stat-storm
|
||||
# the process or send tens of thousands of rows to the client.
|
||||
_BROWSE_ENTRY_CAP = 2000
|
||||
|
||||
|
||||
def _count_model_files(directory: Path, cap: int = 200) -> int:
|
||||
"""Count GGUF/safetensors files immediately inside *directory*.
|
||||
Used to surface a count-hint on the response so the UI can tell
|
||||
users that a leaf directory (no subdirs, only weights) is a valid
|
||||
"Use this folder" target.
|
||||
|
||||
Bounded by *visited entries*, not by *match count*: in directories
|
||||
with many non-model files (or many subdirectories) the scan still
|
||||
stops after ``cap`` entries so a UI hint never costs more than a
|
||||
bounded directory walk.
|
||||
"""
|
||||
n = 0
|
||||
visited = 0
|
||||
try:
|
||||
for f in directory.iterdir():
|
||||
visited += 1
|
||||
if visited > cap:
|
||||
break
|
||||
try:
|
||||
if f.is_file():
|
||||
low = f.name.lower()
|
||||
if low.endswith((".gguf", ".safetensors")):
|
||||
n += 1
|
||||
except OSError:
|
||||
continue
|
||||
except PermissionError as e:
|
||||
logger.debug("browse-folders: permission denied counting %s: %s", directory, e)
|
||||
return 0
|
||||
except OSError as e:
|
||||
logger.debug("browse-folders: OS error counting %s: %s", directory, e)
|
||||
return 0
|
||||
return n
|
||||
|
||||
|
||||
def _has_direct_model_signal(directory: Path) -> bool:
|
||||
"""Return True if *directory* has an immediate child that signals
|
||||
it holds a model: a GGUF/safetensors/config.json file, or a
|
||||
`models--*` subdir (HF hub cache). Bounded by
|
||||
``_BROWSE_MODEL_HINT_PROBE`` to stay fast."""
|
||||
try:
|
||||
it = directory.iterdir()
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
for i, child in enumerate(it):
|
||||
if i >= _BROWSE_MODEL_HINT_PROBE:
|
||||
break
|
||||
try:
|
||||
name = child.name
|
||||
if child.is_file():
|
||||
low = name.lower()
|
||||
if low.endswith((".gguf", ".safetensors")):
|
||||
return True
|
||||
if low in ("config.json", "adapter_config.json"):
|
||||
return True
|
||||
elif child.is_dir() and name.startswith("models--"):
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _looks_like_model_dir(directory: Path) -> bool:
|
||||
"""Bounded heuristic used by the folder browser to flag directories
|
||||
worth exploring. False negatives are fine; the real scanner is
|
||||
authoritative.
|
||||
|
||||
Three signals, cheapest first:
|
||||
|
||||
1. Directory name itself: ``models--*`` is the HuggingFace hub cache
|
||||
layout (``blobs``/``refs``/``snapshots`` children wouldn't match
|
||||
the file-level probes below).
|
||||
2. An immediate child is a weight file or config (handled by
|
||||
:func:`_has_direct_model_signal`).
|
||||
3. A grandchild has a direct signal -- this catches the
|
||||
``publisher/model/weights.gguf`` layout used by LM Studio and
|
||||
Ollama. We probe at most the first
|
||||
``_BROWSE_MODEL_HINT_PROBE`` child directories, each of which is
|
||||
checked with a bounded :func:`_has_direct_model_signal` call,
|
||||
so the total cost stays O(PROBE^2) worst-case.
|
||||
"""
|
||||
if directory.name.startswith("models--"):
|
||||
return True
|
||||
if _has_direct_model_signal(directory):
|
||||
return True
|
||||
# Grandchild probe: LM Studio / Ollama publisher/model layout.
|
||||
try:
|
||||
it = directory.iterdir()
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
for i, child in enumerate(it):
|
||||
if i >= _BROWSE_MODEL_HINT_PROBE:
|
||||
break
|
||||
try:
|
||||
if not child.is_dir():
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
# Fast name check first
|
||||
if child.name.startswith("models--"):
|
||||
return True
|
||||
if _has_direct_model_signal(child):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _build_browse_allowlist() -> list[Path]:
|
||||
"""Return the list of root directories the folder browser is allowed
|
||||
to walk. The same list is used to seed the sidebar suggestion chips,
|
||||
so chip targets are always reachable.
|
||||
|
||||
Roots include the current user's HOME, the resolved HF cache dirs,
|
||||
Studio's own outputs/exports/studio root, registered scan folders,
|
||||
and well-known third-party local-LLM dirs (LM Studio, Ollama,
|
||||
`~/models`). Each is added only if it currently resolves to a real
|
||||
directory, so we never produce a "dead" sandbox boundary the user
|
||||
can't navigate into.
|
||||
"""
|
||||
from utils.paths import (
|
||||
hf_default_cache_dir,
|
||||
legacy_hf_cache_dir,
|
||||
well_known_model_dirs,
|
||||
)
|
||||
from storage.studio_db import list_scan_folders
|
||||
|
||||
candidates: list[Path] = []
|
||||
|
||||
def _add(p: Optional[Path]) -> None:
|
||||
if p is None:
|
||||
return
|
||||
try:
|
||||
resolved = p.resolve()
|
||||
except OSError:
|
||||
return
|
||||
if resolved.is_dir():
|
||||
candidates.append(resolved)
|
||||
|
||||
_add(Path.home())
|
||||
_add(_resolve_hf_cache_dir())
|
||||
try:
|
||||
_add(hf_default_cache_dir())
|
||||
except Exception: # noqa: BLE001 -- best-effort
|
||||
pass
|
||||
try:
|
||||
_add(legacy_hf_cache_dir())
|
||||
except Exception: # noqa: BLE001 -- best-effort
|
||||
pass
|
||||
try:
|
||||
from utils.paths import (
|
||||
exports_root,
|
||||
outputs_root,
|
||||
studio_root,
|
||||
)
|
||||
|
||||
_add(studio_root())
|
||||
_add(outputs_root())
|
||||
_add(exports_root())
|
||||
except Exception as exc: # noqa: BLE001 -- best-effort
|
||||
logger.debug("browse-folders: studio roots unavailable: %s", exc)
|
||||
try:
|
||||
for folder in list_scan_folders():
|
||||
p = folder.get("path")
|
||||
if p:
|
||||
_add(Path(p))
|
||||
except Exception as exc: # noqa: BLE001 -- best-effort
|
||||
logger.debug("browse-folders: could not load scan folders: %s", exc)
|
||||
try:
|
||||
for p in well_known_model_dirs():
|
||||
_add(p)
|
||||
except Exception as exc: # noqa: BLE001 -- best-effort
|
||||
logger.debug("browse-folders: well-known dirs unavailable: %s", exc)
|
||||
|
||||
# Dedupe while preserving order.
|
||||
seen: set[str] = set()
|
||||
deduped: list[Path] = []
|
||||
for p in candidates:
|
||||
key = str(p)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(p)
|
||||
return deduped
|
||||
|
||||
|
||||
def _is_path_inside_allowlist(target: Path, allowed_roots: list[Path]) -> bool:
|
||||
"""Return True if *target* equals or is a descendant of any allowed
|
||||
root. The comparison uses ``os.path.realpath`` so symlinks cannot be
|
||||
used to escape the sandbox.
|
||||
"""
|
||||
try:
|
||||
target_real = os.path.realpath(str(target))
|
||||
except OSError:
|
||||
return False
|
||||
for root in allowed_roots:
|
||||
try:
|
||||
root_real = os.path.realpath(str(root))
|
||||
except OSError:
|
||||
continue
|
||||
if target_real == root_real or target_real.startswith(root_real + os.sep):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_browse_request_path(path: Optional[str]) -> str:
|
||||
"""Normalize the browse request path lexically, without touching the FS."""
|
||||
if path is None or not path.strip():
|
||||
return os.path.normpath(str(Path.home()))
|
||||
|
||||
expanded = os.path.expanduser(path.strip())
|
||||
if not os.path.isabs(expanded):
|
||||
expanded = os.path.join(str(Path.cwd()), expanded)
|
||||
return os.path.normpath(expanded)
|
||||
|
||||
|
||||
def _browse_relative_parts(requested_path: str, root: Path) -> Optional[list[str]]:
|
||||
"""Return validated relative path components under ``root``."""
|
||||
root_text = os.path.normpath(str(root))
|
||||
try:
|
||||
rel_text = os.path.relpath(requested_path, root_text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if rel_text == ".":
|
||||
return []
|
||||
if rel_text == ".." or rel_text.startswith(f"..{os.sep}"):
|
||||
return None
|
||||
|
||||
parts = [part for part in rel_text.split(os.sep) if part not in ("", ".")]
|
||||
altsep = os.altsep
|
||||
for part in parts:
|
||||
if part == ".." or os.sep in part or (altsep and altsep in part):
|
||||
return None
|
||||
return parts
|
||||
|
||||
|
||||
def _match_browse_child(current: Path, name: str) -> Optional[Path]:
|
||||
"""Return the immediate child named ``name`` under ``current``."""
|
||||
try:
|
||||
for child in current.iterdir():
|
||||
if child.name == name:
|
||||
return child
|
||||
except PermissionError:
|
||||
raise HTTPException(
|
||||
status_code = 403,
|
||||
detail = f"Permission denied reading {current}",
|
||||
) from None
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code = 500,
|
||||
detail = f"Could not read {current}: {exc}",
|
||||
) from exc
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_browse_target(path: Optional[str], allowed_roots: list[Path]) -> Path:
|
||||
"""Resolve a requested browse path by walking from trusted allowlist roots."""
|
||||
requested_path = _normalize_browse_request_path(path)
|
||||
resolved_roots: list[Path] = []
|
||||
seen_roots: set[str] = set()
|
||||
for root in sorted(allowed_roots, key = lambda p: len(str(p)), reverse = True):
|
||||
try:
|
||||
resolved = root.resolve()
|
||||
except OSError:
|
||||
continue
|
||||
key = str(resolved)
|
||||
if key in seen_roots:
|
||||
continue
|
||||
seen_roots.add(key)
|
||||
resolved_roots.append(resolved)
|
||||
|
||||
for root in resolved_roots:
|
||||
parts = _browse_relative_parts(requested_path, root)
|
||||
if parts is None:
|
||||
continue
|
||||
|
||||
current = root
|
||||
for part in parts:
|
||||
child = _match_browse_child(current, part)
|
||||
if child is None:
|
||||
raise HTTPException(
|
||||
status_code = 404,
|
||||
detail = f"Path does not exist: {requested_path}",
|
||||
)
|
||||
try:
|
||||
resolved_child = child.resolve()
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = f"Invalid path: {exc}",
|
||||
) from exc
|
||||
if not _is_path_inside_allowlist(resolved_child, resolved_roots):
|
||||
raise HTTPException(
|
||||
status_code = 403,
|
||||
detail = (
|
||||
"Path is not in the browseable allowlist. Register it via "
|
||||
"POST /api/models/scan-folders first, or pick a directory "
|
||||
"under your home folder."
|
||||
),
|
||||
)
|
||||
current = resolved_child
|
||||
|
||||
if not current.is_dir():
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = f"Not a directory: {current}",
|
||||
)
|
||||
return current
|
||||
|
||||
raise HTTPException(
|
||||
status_code = 403,
|
||||
detail = (
|
||||
"Path is not in the browseable allowlist. Register it via "
|
||||
"POST /api/models/scan-folders first, or pick a directory "
|
||||
"under your home folder."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/browse-folders", response_model = BrowseFoldersResponse)
|
||||
async def browse_folders(
|
||||
path: Optional[str] = Query(
|
||||
None,
|
||||
description = (
|
||||
"Directory to list. If omitted, defaults to the current user's "
|
||||
"home directory. Tilde (`~`) and relative paths are expanded. "
|
||||
"Must resolve inside the allowlist of browseable roots (HOME, "
|
||||
"HF cache, Studio dirs, registered scan folders, well-known "
|
||||
"model dirs)."
|
||||
),
|
||||
),
|
||||
show_hidden: bool = Query(
|
||||
False,
|
||||
description = "Include entries whose name starts with a dot",
|
||||
),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""
|
||||
List immediate subdirectories of *path* for the Custom Folders picker.
|
||||
|
||||
The frontend uses this to render a modal folder browser without needing
|
||||
a native OS dialog (Studio is served over HTTP, so the browser can't
|
||||
reveal absolute paths on the host). The endpoint is read-only and does
|
||||
not create, move, or delete anything. It simply enumerates visible
|
||||
subdirectories so the user can click their way to a folder and hand
|
||||
the resulting string back to POST `/api/models/scan-folders`.
|
||||
|
||||
Sandbox: requests are bounded to the allowlist returned by
|
||||
:func:`_build_browse_allowlist` (HOME, HF cache, Studio dirs,
|
||||
registered scan folders, well-known model dirs). Paths outside the
|
||||
allowlist return 403 so users cannot probe ``/etc``, ``/proc``,
|
||||
``/root`` (when not HOME), or other sensitive system locations
|
||||
even if the server process can read them. Symlinks are resolved
|
||||
via ``os.path.realpath`` before the check, so symlink traversal
|
||||
cannot escape the sandbox either.
|
||||
|
||||
Sorting: directories that look like they hold models come first, then
|
||||
plain directories, then hidden entries (if `show_hidden=true`).
|
||||
"""
|
||||
from utils.paths import hf_default_cache_dir, well_known_model_dirs
|
||||
from storage.studio_db import list_scan_folders
|
||||
|
||||
# Build the allowlist once -- both the sandbox check below and the
|
||||
# suggestion chips use the same set, so chips are always navigable.
|
||||
allowed_roots = _build_browse_allowlist()
|
||||
|
||||
try:
|
||||
target = _resolve_browse_target(path, allowed_roots)
|
||||
except HTTPException:
|
||||
requested_path = _normalize_browse_request_path(path)
|
||||
if path is not None and path.strip():
|
||||
logger.warning(
|
||||
"browse-folders: rejected path %r (normalized=%s)",
|
||||
path,
|
||||
requested_path,
|
||||
)
|
||||
raise
|
||||
|
||||
# Enumerate immediate subdirectories with a bounded cap so a stray
|
||||
# query against ``/usr/lib`` or ``/proc`` can't stat-storm the process.
|
||||
entries: list[BrowseEntry] = []
|
||||
truncated = False
|
||||
visited = 0
|
||||
try:
|
||||
it = target.iterdir()
|
||||
except PermissionError:
|
||||
raise HTTPException(
|
||||
status_code = 403,
|
||||
detail = f"Permission denied reading {target}",
|
||||
)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code = 500,
|
||||
detail = f"Could not read {target}: {exc}",
|
||||
)
|
||||
|
||||
try:
|
||||
for child in it:
|
||||
# Bound by *visited entries*, not by *appended entries*: in
|
||||
# directories full of files (or hidden subdirs when
|
||||
# ``show_hidden=False``) the cap on ``len(entries)`` would
|
||||
# never trigger and we'd still stat every child. Counting
|
||||
# visits keeps the worst-case work to ``_BROWSE_ENTRY_CAP``
|
||||
# iterdir/is_dir calls regardless of how many of them
|
||||
# survive the filters below.
|
||||
visited += 1
|
||||
if visited > _BROWSE_ENTRY_CAP:
|
||||
truncated = True
|
||||
break
|
||||
try:
|
||||
if not child.is_dir():
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
name = child.name
|
||||
is_hidden = name.startswith(".")
|
||||
if is_hidden and not show_hidden:
|
||||
continue
|
||||
entries.append(
|
||||
BrowseEntry(
|
||||
name = name,
|
||||
has_models = _looks_like_model_dir(child),
|
||||
hidden = is_hidden,
|
||||
)
|
||||
)
|
||||
except PermissionError as exc:
|
||||
logger.debug(
|
||||
"browse-folders: permission denied during enumeration of %s: %s",
|
||||
target,
|
||||
exc,
|
||||
)
|
||||
except OSError as exc:
|
||||
# Rare: iterdir succeeded but reading a specific entry failed.
|
||||
logger.warning("browse-folders: partial enumeration of %s: %s", target, exc)
|
||||
|
||||
# Model-bearing dirs first, then plain, then hidden; case-insensitive
|
||||
# alphabetical within each bucket.
|
||||
def _sort_key(e: BrowseEntry) -> tuple[int, str]:
|
||||
bucket = 0 if e.has_models else (2 if e.hidden else 1)
|
||||
return (bucket, e.name.lower())
|
||||
|
||||
entries.sort(key = _sort_key)
|
||||
|
||||
# Parent is None at the filesystem root (`p.parent == p`) AND when
|
||||
# the parent would step outside the sandbox -- otherwise the up-row
|
||||
# would 403 on click. Users can still hop to other allowed roots
|
||||
# via the suggestion chips below.
|
||||
parent: Optional[str]
|
||||
if target.parent == target or not _is_path_inside_allowlist(
|
||||
target.parent, allowed_roots
|
||||
):
|
||||
parent = None
|
||||
else:
|
||||
parent = str(target.parent)
|
||||
|
||||
# Handy starting points for the quick-pick chips.
|
||||
suggestions: list[str] = []
|
||||
seen_sug: set[str] = set()
|
||||
|
||||
def _add_sug(p: Optional[Path]) -> None:
|
||||
if p is None:
|
||||
return
|
||||
try:
|
||||
resolved = str(p.resolve())
|
||||
except OSError:
|
||||
return
|
||||
if resolved in seen_sug:
|
||||
return
|
||||
if Path(resolved).is_dir():
|
||||
seen_sug.add(resolved)
|
||||
suggestions.append(resolved)
|
||||
|
||||
# Home always comes first -- it's the safe fallback when everything
|
||||
# else is cold.
|
||||
_add_sug(Path.home())
|
||||
# The HF cache root the process is actually using.
|
||||
try:
|
||||
_add_sug(hf_default_cache_dir())
|
||||
except Exception:
|
||||
pass
|
||||
# Already-registered scan folders (what the user has curated).
|
||||
try:
|
||||
for folder in list_scan_folders():
|
||||
_add_sug(Path(folder.get("path", "")))
|
||||
except Exception as exc:
|
||||
logger.debug("browse-folders: could not load scan folders: %s", exc)
|
||||
# Directories commonly used by other local-LLM tools: LM Studio
|
||||
# (`~/.lmstudio/models` + legacy `~/.cache/lm-studio/models` +
|
||||
# user-configured downloadsFolder from LM Studio's settings.json),
|
||||
# Ollama (`~/.ollama/models` + common system paths + OLLAMA_MODELS
|
||||
# env var), and generic user-choice spots (`~/models`, `~/Models`).
|
||||
# Each helper only returns paths that currently exist so we never
|
||||
# show dead chips.
|
||||
try:
|
||||
for p in well_known_model_dirs():
|
||||
_add_sug(p)
|
||||
except Exception as exc:
|
||||
logger.debug("browse-folders: could not load well-known dirs: %s", exc)
|
||||
|
||||
return BrowseFoldersResponse(
|
||||
current = str(target),
|
||||
parent = parent,
|
||||
entries = entries,
|
||||
suggestions = suggestions,
|
||||
truncated = truncated,
|
||||
model_files_here = _count_model_files(target),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def list_models(
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
|
|
|
|||
86
studio/backend/tests/test_browse_folders_route.py
Normal file
86
studio/backend/tests/test_browse_folders_route.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Keep this test runnable in lightweight environments where optional logging
|
||||
# deps are not installed.
|
||||
if "structlog" not in sys.modules:
|
||||
|
||||
class _DummyLogger:
|
||||
def __getattr__(self, _name):
|
||||
return lambda *args, **kwargs: None
|
||||
|
||||
sys.modules["structlog"] = types.SimpleNamespace(
|
||||
BoundLogger = _DummyLogger,
|
||||
get_logger = lambda *args, **kwargs: _DummyLogger(),
|
||||
)
|
||||
|
||||
import routes.models as models_route
|
||||
|
||||
|
||||
def test_resolve_browse_target_returns_allowed_directory(tmp_path):
|
||||
allowed = tmp_path / "allowed"
|
||||
target = allowed / "models" / "nested"
|
||||
target.mkdir(parents = True)
|
||||
|
||||
resolved = models_route._resolve_browse_target(str(target), [allowed])
|
||||
|
||||
assert resolved == target.resolve()
|
||||
|
||||
|
||||
def test_resolve_browse_target_rejects_outside_allowlist(tmp_path):
|
||||
allowed = tmp_path / "allowed"
|
||||
disallowed = tmp_path / "disallowed"
|
||||
allowed.mkdir()
|
||||
disallowed.mkdir()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
models_route._resolve_browse_target(str(disallowed), [allowed])
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
def test_resolve_browse_target_rejects_file_path(tmp_path):
|
||||
allowed = tmp_path / "allowed"
|
||||
allowed.mkdir()
|
||||
model_file = allowed / "model.gguf"
|
||||
model_file.write_text("gguf")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
models_route._resolve_browse_target(str(model_file), [allowed])
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
def test_resolve_browse_target_allows_symlink_into_other_allowed_root(tmp_path):
|
||||
home_root = tmp_path / "home"
|
||||
scan_root = tmp_path / "scan"
|
||||
target = scan_root / "nested"
|
||||
home_root.mkdir()
|
||||
target.mkdir(parents = True)
|
||||
(home_root / "scan-link").symlink_to(scan_root, target_is_directory = True)
|
||||
|
||||
resolved = models_route._resolve_browse_target(
|
||||
str(home_root / "scan-link" / "nested"),
|
||||
[home_root, scan_root],
|
||||
)
|
||||
|
||||
assert resolved == target.resolve()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.altsep is not None, reason = "POSIX-only path semantics")
|
||||
def test_resolve_browse_target_allows_backslash_in_posix_segment(tmp_path):
|
||||
allowed = tmp_path / "allowed"
|
||||
target = allowed / r"dir\name"
|
||||
target.mkdir(parents = True)
|
||||
|
||||
resolved = models_route._resolve_browse_target(str(target), [allowed])
|
||||
|
||||
assert resolved == target.resolve()
|
||||
|
|
@ -215,6 +215,21 @@ TEMPLATE_TO_MODEL_MAPPER = {
|
|||
"google/gemma-3n-E2B-it",
|
||||
"unsloth/gemma-3n-E2B-it-unsloth-bnb-4bit",
|
||||
),
|
||||
"gemma-4": (
|
||||
"unsloth/gemma-4-E2B-it",
|
||||
"google/gemma-4-E2B-it",
|
||||
"unsloth/gemma-4-E4B-it",
|
||||
"google/gemma-4-E4B-it",
|
||||
"unsloth/gemma-4-E2B-it-unsloth-bnb-4bit",
|
||||
"unsloth/gemma-4-E4B-it-unsloth-bnb-4bit",
|
||||
),
|
||||
"gemma-4-thinking": (
|
||||
"unsloth/gemma-4-26B-A4B-it",
|
||||
"google/gemma-4-26B-A4B-it",
|
||||
"unsloth/gemma-4-31B-it",
|
||||
"unsloth/gemma-4-31B-it-unsloth-bnb-4bit",
|
||||
"google/gemma-4-31B-it",
|
||||
),
|
||||
"qwen2.5": (
|
||||
"unsloth/Qwen2.5-0.5B-Instruct-unsloth-bnb-4bit",
|
||||
"unsloth/Qwen2.5-0.5B-Instruct",
|
||||
|
|
@ -399,6 +414,15 @@ TEMPLATE_TO_MODEL_MAPPER = {
|
|||
"THUDM/GLM-4.7-Flash",
|
||||
"unsloth/GLM-4.7-Flash-bnb-4bit",
|
||||
),
|
||||
"lfm-2": (
|
||||
"unsloth/LFM2-1.2B",
|
||||
"LiquidAI/LFM2-1.2B",
|
||||
"unsloth/LFM2-1.2B-unsloth-bnb-4bit",
|
||||
),
|
||||
"lfm-2.5": (
|
||||
"unsloth/LFM2.5-1.2B-Instruct",
|
||||
"LiquidAI/LFM2.5-1.2B-Instruct",
|
||||
),
|
||||
}
|
||||
|
||||
MODEL_TO_TEMPLATE_MAPPER = {}
|
||||
|
|
@ -414,6 +438,14 @@ for key, values in TEMPLATE_TO_MODEL_MAPPER.items():
|
|||
|
||||
|
||||
TEMPLATE_TO_RESPONSES_MAPPER = {
|
||||
"gemma-4-thinking": {
|
||||
"instruction": "<|turn>user\n",
|
||||
"response": "<|turn>model\n",
|
||||
},
|
||||
"gemma-4": {
|
||||
"instruction": "<|turn>user\n",
|
||||
"response": "<|turn>model\n",
|
||||
},
|
||||
"gemma-3": {
|
||||
"instruction": "<start_of_turn>user\n",
|
||||
"response": "<start_of_turn>model\n",
|
||||
|
|
@ -514,6 +546,10 @@ TEMPLATE_TO_RESPONSES_MAPPER = {
|
|||
"instruction": "<|im_start|>user\n",
|
||||
"response": "<|im_start|>assistant\n",
|
||||
},
|
||||
"lfm-2.5": {
|
||||
"instruction": "<|im_start|>user\n",
|
||||
"response": "<|im_start|>assistant\n",
|
||||
},
|
||||
"starling": {
|
||||
"instruction": "GPT4 Correct User: ",
|
||||
"response": "GPT4 Correct Assistant: ",
|
||||
|
|
|
|||
|
|
@ -908,32 +908,95 @@ def _is_gguf_filename(filename: str) -> bool:
|
|||
return filename.lower().endswith(".gguf")
|
||||
|
||||
|
||||
def _iter_gguf_files(directory: Path):
|
||||
def _iter_gguf_files(directory: Path, recursive: bool = False):
|
||||
if not directory.is_dir():
|
||||
return
|
||||
for f in directory.iterdir():
|
||||
iterator = directory.rglob("*") if recursive else directory.iterdir()
|
||||
for f in iterator:
|
||||
if f.is_file() and _is_gguf_filename(f.name):
|
||||
yield f
|
||||
|
||||
|
||||
def detect_mmproj_file(path: str) -> Optional[str]:
|
||||
def detect_mmproj_file(path: str, search_root: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Find the mmproj (vision projection) GGUF file in a directory.
|
||||
Find the mmproj (vision projection) GGUF file for a given model.
|
||||
|
||||
Args:
|
||||
path: Directory to search — or a .gguf file (uses its parent dir).
|
||||
path: Directory to search — or a .gguf file (uses its parent dir
|
||||
as the starting point).
|
||||
search_root: Optional outer directory that should also be scanned
|
||||
(and any directory between it and ``path``). This handles
|
||||
local layouts where the model weights live in a quant-named
|
||||
subdir (``snapshot/BF16/foo.gguf``) but the mmproj sits at
|
||||
the snapshot root (``snapshot/mmproj-BF16.gguf``). When
|
||||
``None``, only the immediate parent dir is scanned, matching
|
||||
the historical behavior.
|
||||
|
||||
Returns:
|
||||
Full path to the mmproj .gguf file, or None if not found.
|
||||
"""
|
||||
p = Path(path)
|
||||
search_dir = p.parent if p.is_file() else p
|
||||
if not search_dir.is_dir():
|
||||
start_dir = p.parent if p.is_file() else p
|
||||
if not start_dir.is_dir():
|
||||
return None
|
||||
|
||||
for f in _iter_gguf_files(search_dir):
|
||||
if _is_mmproj(f.name):
|
||||
return str(f.resolve())
|
||||
# Build the list of dirs to scan: immediate dir first, then walk up
|
||||
# to (and including) ``search_root`` if it is an ancestor. We walk
|
||||
# incrementally rather than recursing into ``search_root`` so we
|
||||
# don't accidentally pick up an mmproj from a sibling subdir
|
||||
# belonging to a different model variant.
|
||||
seen: set[Path] = set()
|
||||
scan_order: list[Path] = []
|
||||
|
||||
def _add(d: Path) -> None:
|
||||
try:
|
||||
resolved = d.resolve()
|
||||
except OSError:
|
||||
return
|
||||
if resolved in seen or not resolved.is_dir():
|
||||
return
|
||||
seen.add(resolved)
|
||||
scan_order.append(resolved)
|
||||
|
||||
_add(start_dir)
|
||||
|
||||
# When ``path`` is a symlink (e.g. Ollama's ``.studio_links/...gguf``
|
||||
# -> ``blobs/sha256-...``), the symlink's parent directory rarely
|
||||
# contains the mmproj sibling; the real mmproj file lives next to
|
||||
# the symlink target. Add the target's parent to the scan so vision
|
||||
# GGUFs that are surfaced via symlinks are still recognised as
|
||||
# vision models.
|
||||
try:
|
||||
if p.is_symlink() and p.is_file():
|
||||
target_parent = p.resolve().parent
|
||||
if target_parent.is_dir():
|
||||
_add(target_parent)
|
||||
except OSError:
|
||||
pass
|
||||
if search_root is not None:
|
||||
try:
|
||||
root_resolved = Path(search_root).resolve()
|
||||
start_resolved = start_dir.resolve()
|
||||
# Only walk if start_dir is inside (or equal to) search_root.
|
||||
if root_resolved == start_resolved or (
|
||||
start_resolved.is_relative_to(root_resolved)
|
||||
if hasattr(start_resolved, "is_relative_to")
|
||||
else str(start_resolved).startswith(str(root_resolved) + "/")
|
||||
):
|
||||
cur = start_resolved
|
||||
# Walk up from start_dir to (and including) root_resolved.
|
||||
while cur != root_resolved and cur.parent != cur:
|
||||
cur = cur.parent
|
||||
_add(cur)
|
||||
if cur == root_resolved:
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
for d in scan_order:
|
||||
for f in _iter_gguf_files(d):
|
||||
if _is_mmproj(f.name):
|
||||
return str(f.resolve())
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -957,7 +1020,10 @@ def detect_gguf_model(path: str) -> Optional[str]:
|
|||
if p.suffix.lower() == ".gguf" and p.is_file():
|
||||
if _is_mmproj(p.name):
|
||||
return None
|
||||
return str(p.resolve())
|
||||
# Use absolute (not resolve) to preserve symlink names -- e.g.
|
||||
# Ollama .studio_links/model.gguf -> blobs/sha256-... should
|
||||
# keep the readable symlink name, not the opaque blob hash.
|
||||
return str(p.absolute())
|
||||
|
||||
# Case 2: directory containing .gguf files (skip mmproj)
|
||||
if p.is_dir():
|
||||
|
|
@ -1183,7 +1249,11 @@ def list_local_gguf_variants(
|
|||
quant_first_file: dict[str, str] = {}
|
||||
has_vision = False
|
||||
|
||||
for f in sorted(_iter_gguf_files(p)):
|
||||
# Recurse so variant-specific subdirectories (e.g. ``BF16/...gguf``
|
||||
# used by some HF GGUF repos for the largest quants) are picked up.
|
||||
# Filenames in the result preserve the relative subpath so that
|
||||
# ``_find_local_gguf_by_variant`` can locate the file again.
|
||||
for f in sorted(_iter_gguf_files(p, recursive = True)):
|
||||
if _is_mmproj(f.name):
|
||||
has_vision = True
|
||||
continue
|
||||
|
|
@ -1193,8 +1263,14 @@ def list_local_gguf_variants(
|
|||
size = 0
|
||||
quant = _extract_quant_label(f.name)
|
||||
quant_totals[quant] = quant_totals.get(quant, 0) + size
|
||||
# Only compute the (potentially expensive) relative path when this
|
||||
# is the first file we've seen for this quant -- after that we'd
|
||||
# discard the result anyway. Use posix-style separators so the
|
||||
# filename matches what ``list_gguf_variants`` (the remote HF
|
||||
# API path) returns on every platform; otherwise Windows would
|
||||
# emit ``BF16\foo.gguf`` here.
|
||||
if quant not in quant_first_file:
|
||||
quant_first_file[quant] = f.name
|
||||
quant_first_file[quant] = f.relative_to(p).as_posix()
|
||||
|
||||
variants = [
|
||||
GgufVariantInfo(
|
||||
|
|
@ -1220,9 +1296,11 @@ def _find_local_gguf_by_variant(directory: str, variant: str) -> Optional[str]:
|
|||
if p is None:
|
||||
return None
|
||||
|
||||
# Recurse into subdirectories so variants stored under a quant-named
|
||||
# subdir (e.g. ``BF16/foo-BF16-00001-of-00002.gguf``) are found.
|
||||
matches = sorted(
|
||||
f
|
||||
for f in _iter_gguf_files(p)
|
||||
for f in _iter_gguf_files(p, recursive = True)
|
||||
if not _is_mmproj(f.name) and _extract_quant_label(f.name) == variant
|
||||
)
|
||||
if matches:
|
||||
|
|
@ -1932,8 +2010,16 @@ class ModelConfig:
|
|||
except Exception as e:
|
||||
logger.debug(f"Could not read export metadata: {e}")
|
||||
|
||||
# If vision (or mmproj happens to exist), find the mmproj file
|
||||
mmproj_file = detect_mmproj_file(gguf_file)
|
||||
# If vision (or mmproj happens to exist), find the mmproj
|
||||
# file. The recursive variant scan in
|
||||
# ``_find_local_gguf_by_variant`` may have returned a
|
||||
# weight file inside a quant-named subdir (e.g.
|
||||
# ``.../BF16/foo.gguf``) while ``mmproj-*.gguf`` lives
|
||||
# at the snapshot root. Pass ``search_root=path`` so
|
||||
# ``detect_mmproj_file`` walks up to the snapshot root
|
||||
# instead of seeing only the weight file's immediate
|
||||
# parent.
|
||||
mmproj_file = detect_mmproj_file(gguf_file, search_root = path)
|
||||
if mmproj_file:
|
||||
gguf_is_vision = True
|
||||
logger.info(f"Detected mmproj for vision: {mmproj_file}")
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ from .storage_roots import (
|
|||
legacy_hf_cache_dir,
|
||||
hf_default_cache_dir,
|
||||
lmstudio_model_dirs,
|
||||
well_known_model_dirs,
|
||||
ensure_dir,
|
||||
ensure_studio_directories,
|
||||
resolve_under_root,
|
||||
|
|
@ -70,6 +71,7 @@ __all__ = [
|
|||
"legacy_hf_cache_dir",
|
||||
"hf_default_cache_dir",
|
||||
"lmstudio_model_dirs",
|
||||
"well_known_model_dirs",
|
||||
"ensure_dir",
|
||||
"ensure_studio_directories",
|
||||
"resolve_under_root",
|
||||
|
|
|
|||
|
|
@ -130,6 +130,51 @@ def lmstudio_model_dirs() -> list[Path]:
|
|||
return dirs
|
||||
|
||||
|
||||
def well_known_model_dirs() -> list[Path]:
|
||||
"""Return directories commonly used by other local LLM tools.
|
||||
|
||||
Used by the folder browser to offer quick-pick chips. Returns only
|
||||
paths that exist on disk, so the UI never shows dead chips. Order
|
||||
reflects a rough "likelihood the user has models here" -- LM Studio
|
||||
and Ollama first, then the generic fallbacks.
|
||||
"""
|
||||
candidates: list[Path] = []
|
||||
|
||||
# LM Studio (reuses the logic above, including settings.json override)
|
||||
candidates.extend(lmstudio_model_dirs())
|
||||
|
||||
# Ollama -- both the user-level and common system-wide install paths
|
||||
# (https://github.com/ollama/ollama/issues/733).
|
||||
ollama_env = os.environ.get("OLLAMA_MODELS")
|
||||
if ollama_env:
|
||||
candidates.append(Path(ollama_env).expanduser())
|
||||
candidates.append(Path.home() / ".ollama" / "models")
|
||||
candidates.append(Path("/usr/share/ollama/.ollama/models"))
|
||||
candidates.append(Path("/var/lib/ollama/.ollama/models"))
|
||||
|
||||
# HF hub cache root (separate from the explicit HF cache chip)
|
||||
candidates.append(Path.home() / ".cache" / "huggingface" / "hub")
|
||||
|
||||
# Generic "my models" spots users tend to drop things into
|
||||
for name in ("models", "Models"):
|
||||
candidates.append(Path.home() / name)
|
||||
|
||||
# Deduplicate while preserving order; keep only extant dirs
|
||||
out: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for p in candidates:
|
||||
try:
|
||||
resolved = str(p.resolve())
|
||||
except OSError:
|
||||
continue
|
||||
if resolved in seen:
|
||||
continue
|
||||
if Path(resolved).is_dir():
|
||||
seen.add(resolved)
|
||||
out.append(Path(resolved))
|
||||
return out
|
||||
|
||||
|
||||
def _setup_cache_env() -> None:
|
||||
"""Set cache environment variables for HuggingFace, uv, and vLLM.
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ TRANSFORMERS_5_MODEL_SUBSTRINGS: tuple[str, ...] = (
|
|||
"qwen3.5", # Qwen3.5 family (35B-A3B, etc.)
|
||||
"qwen3-next", # Qwen3-Next and variants
|
||||
"tiny_qwen3_moe", # imdatta0/tiny_qwen3_moe_2.8B_0.7B
|
||||
"lfm2.5-vl-450m", # LiquidAI/LFM2.5-VL-450M
|
||||
)
|
||||
|
||||
# Lowercase substrings for models that require transformers 5.5.0 (checked first).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
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="Studio"
|
||||
active={pathname === "/studio" || pathname.startsWith("/studio/")}
|
||||
disabled={chatOnly}
|
||||
onClick={() => {
|
||||
if (chatOnly) return;
|
||||
navigate({ to: "/studio" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
|
||||
<NavItem
|
||||
icon={ChefHatIcon}
|
||||
label="Recipes"
|
||||
active={isRecipesRoute}
|
||||
onClick={() => {
|
||||
navigate({ to: "/data-recipes" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
|
||||
<NavItem
|
||||
icon={PackageIcon}
|
||||
label="Export"
|
||||
active={pathname === "/export" || pathname.startsWith("/export/")}
|
||||
disabled={chatOnly}
|
||||
onClick={() => {
|
||||
if (chatOnly) return;
|
||||
navigate({ to: "/export" });
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
/>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
<div className="my-2" />
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Recent Chats */}
|
||||
{chatItems.length > 0 && (
|
||||
<Collapsible open={effectiveChatOpen} onOpenChange={setChatOpen} asChild>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden p-0">
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
|
||||
Recent Chats
|
||||
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{chatItems.map((item) => (
|
||||
<SidebarMenuItem key={item.id} className="group/recent-item relative">
|
||||
<SidebarMenuButton
|
||||
isActive={activeThreadId === item.id}
|
||||
className="rounded-none pl-4 pr-7 text-[13px] font-medium text-[#475569] dark:text-[#94a3b8] hover:bg-muted! hover:text-foreground! data-active:bg-[oklch(0.94_0_0)]! data-active:text-foreground! dark:data-active:bg-[oklch(0.3_0_0)]!"
|
||||
onClick={() => {
|
||||
navigate({
|
||||
to: "/chat",
|
||||
search:
|
||||
item.type === "single"
|
||||
? { thread: item.id }
|
||||
: { compare: item.id },
|
||||
});
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteThread(item);
|
||||
}}
|
||||
title="Delete"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 flex size-5 scale-90 items-center justify-center rounded-md text-sidebar-foreground/55 opacity-0 transition-all duration-150 hover:bg-destructive/12 hover:text-destructive group-hover/recent-item:scale-100 group-hover/recent-item:opacity-100"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
|
||||
</button>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Recent Runs */}
|
||||
{isStudioRoute && runItems.length > 0 && !chatOnly && (
|
||||
<Collapsible open={effectiveRunsOpen} onOpenChange={setRunsOpen} asChild>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden overflow-hidden p-0">
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger className="cursor-pointer flex w-full items-center justify-between">
|
||||
Recent Runs
|
||||
<ChevronDown className="size-3.5 transition-transform duration-200 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg]" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{runItems.map((run) => {
|
||||
const isActiveRun =
|
||||
selectedHistoryRunId === run.id || activeJobId === run.id;
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={run.id}
|
||||
className="group/run-item relative"
|
||||
>
|
||||
<SidebarMenuButton
|
||||
isActive={isActiveRun}
|
||||
className="h-auto flex-col items-start gap-0.5 py-2 rounded-none pl-4 pr-7 text-[13px] font-medium text-[#475569] dark:text-[#94a3b8] hover:bg-muted! hover:text-foreground! data-active:bg-[oklch(0.94_0_0)]! data-active:text-foreground! dark:data-active:bg-[oklch(0.3_0_0)]!"
|
||||
onClick={() => {
|
||||
setSelectedHistoryRunId(run.id);
|
||||
closeMobileIfOpen();
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
runStatusDotClass(run.status),
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate text-sm font-medium">
|
||||
{run.model_name}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">
|
||||
{formatRelativeShort(run.started_at)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="w-full truncate pl-3.5 text-xs text-muted-foreground">
|
||||
{run.dataset_name}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await deleteTrainingRun(run.id);
|
||||
if (selectedHistoryRunId === run.id) {
|
||||
setSelectedHistoryRunId(null);
|
||||
}
|
||||
await refreshRuns();
|
||||
} catch {
|
||||
// ignore — next refresh will reconcile
|
||||
}
|
||||
}}
|
||||
title="Delete"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 flex size-5 scale-90 items-center justify-center rounded-md text-sidebar-foreground/55 opacity-0 transition-all duration-150 hover:bg-destructive/12 hover:text-destructive group-hover/run-item:scale-100 group-hover/run-item:opacity-100"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
|
||||
</button>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
)}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="border-t border-sidebar-border">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<img
|
||||
src="/Sloth emojis/sloth rounded.png"
|
||||
alt="Unsloth"
|
||||
className="size-8 rounded-lg shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5 leading-none group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate text-sm font-semibold">Unsloth</span>
|
||||
<span className="truncate text-[11px] text-muted-foreground">Studio</span>
|
||||
</div>
|
||||
<ChevronsUpDown strokeWidth={1.25} className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-56"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => useSettingsDialogStore.getState().openDialog()}
|
||||
>
|
||||
<HugeiconsIcon icon={Settings02Icon} className="size-4" />
|
||||
<span>Settings</span>
|
||||
<DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
ref={anchorRef as React.Ref<HTMLDivElement>}
|
||||
onSelect={(e) => { e.preventDefault(); toggleTheme(); }}
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
<span>{isDark ? "Light Mode" : "Dark Mode"}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!getTourId(pathname)}
|
||||
onSelect={() => {
|
||||
const tourId = getTourId(pathname);
|
||||
if (!tourId) return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(TOUR_OPEN_EVENT, {
|
||||
detail: { id: tourId },
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
|
||||
<span>Guided Tour</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href="https://unsloth.ai/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<HugeiconsIcon icon={Book03Icon} className="size-4" />
|
||||
<span>Learn More</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href="https://unsloth.ai/docs/new/changelog"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={NewReleasesIcon}
|
||||
className="size-4"
|
||||
/>
|
||||
<span>What's New</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href="https://github.com/unslothai/unsloth/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={MessageSearch01Icon}
|
||||
className="size-4"
|
||||
/>
|
||||
<span>Feedback</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<ChatSearchDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
type BrowseFoldersResponse,
|
||||
browseFolders,
|
||||
} from "@/features/chat/api/chat-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowUp02Icon, Folder02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
export interface FolderBrowserProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Called with the absolute path the user confirmed. */
|
||||
onSelect: (path: string) => void;
|
||||
/** Optional initial directory. Defaults to the user's home on the server. */
|
||||
initialPath?: string;
|
||||
}
|
||||
|
||||
function splitBreadcrumb(path: string): { label: string; value: string }[] {
|
||||
if (!path) return [];
|
||||
// Distinguish path styles BEFORE normalizing separators. On POSIX
|
||||
// backslashes are valid filename characters, so we cannot blindly
|
||||
// rewrite ``\`` -> ``/`` -- doing so would mangle directory names
|
||||
// like ``my\backup`` into ``my/backup`` and produce breadcrumb
|
||||
// values that 404 on the server. Only Windows-style absolute paths
|
||||
// (drive letter, or UNC ``\\server\share``) get the conversion.
|
||||
const isWindowsDrive = /^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path);
|
||||
const isUnc = /^\\\\/.test(path);
|
||||
const isWindows = isWindowsDrive || isUnc;
|
||||
const normalized = isWindows ? path.replace(/\\/g, "/") : path;
|
||||
const segments = normalized.split("/");
|
||||
const parts: { label: string; value: string }[] = [];
|
||||
|
||||
// POSIX absolute path: leading empty segment from split("/")
|
||||
if (segments[0] === "") {
|
||||
parts.push({ label: "/", value: "/" });
|
||||
let cur = "";
|
||||
for (const seg of segments.slice(1)) {
|
||||
if (!seg) continue;
|
||||
cur = `${cur}/${seg}`;
|
||||
parts.push({ label: seg, value: cur });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Windows-ish drive path (C:, D:): first segment is the drive. Use
|
||||
// ``C:/`` (drive-absolute) as the crumb value so clicking the drive
|
||||
// root navigates to the root of the drive rather than the
|
||||
// drive-relative current working directory on that drive (``C:``
|
||||
// alone resolves to ``CWD-on-C``, not ``C:\``).
|
||||
if (/^[A-Za-z]:$/.test(segments[0])) {
|
||||
const driveRoot = `${segments[0]}/`;
|
||||
let cur = driveRoot;
|
||||
parts.push({ label: segments[0], value: driveRoot });
|
||||
for (const seg of segments.slice(1)) {
|
||||
if (!seg) continue;
|
||||
cur = cur.endsWith("/") ? `${cur}${seg}` : `${cur}/${seg}`;
|
||||
parts.push({ label: seg, value: cur });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Fallback: relative / UNC-ish. Render as-is as a single crumb.
|
||||
return [{ label: path, value: path }];
|
||||
}
|
||||
|
||||
export function FolderBrowser({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
initialPath,
|
||||
}: FolderBrowserProps) {
|
||||
const [data, setData] = useState<BrowseFoldersResponse | null>(null);
|
||||
const [path, setPath] = useState<string | undefined>(initialPath);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const navigate = useCallback(
|
||||
(
|
||||
target: string | undefined,
|
||||
hidden: boolean,
|
||||
opts?: { fallbackOnError?: boolean },
|
||||
) => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Forward the signal so cancelled navigation actually cancels the
|
||||
// backend enumeration instead of just discarding the response.
|
||||
browseFolders(target, hidden, ctrl.signal)
|
||||
.then((res) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
setData(res);
|
||||
setPath(res.current);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
// Surface the error, but if the very first request (typically
|
||||
// a typo'd or denylisted ``initialPath``) fails AND the
|
||||
// browser is empty (no ``data`` to render against), fall
|
||||
// back to the user's HOME so the modal is navigable instead
|
||||
// of an irrecoverable dead end.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
if (opts?.fallbackOnError && target !== undefined) {
|
||||
// Re-issue without a target -> backend defaults to HOME.
|
||||
// Don't recurse if HOME itself fails (paranoia: shouldn't
|
||||
// happen since the sandbox allowlist always includes HOME).
|
||||
queueMicrotask(() => navigate(undefined, hidden));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) setLoading(false);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Fetch when the dialog opens. Only re-run when the dialog transitions
|
||||
// closed -> open; subsequent navigation is driven by `navigate()` so we
|
||||
// don't want `path` in the dependency list here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// ``fallbackOnError``: if the user-supplied ``initialPath`` is bad
|
||||
// (typo, denylisted, deleted) we recover into HOME instead of
|
||||
// showing an empty modal with no breadcrumbs/entries.
|
||||
navigate(initialPath, showHidden, { fallbackOnError: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!path) return;
|
||||
onSelect(path);
|
||||
onOpenChange(false);
|
||||
}, [onSelect, onOpenChange, path]);
|
||||
|
||||
const crumbs = useMemo(
|
||||
() => (data?.current ? splitBreadcrumb(data.current) : []),
|
||||
[data?.current],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-md p-0 gap-0"
|
||||
overlayClassName="bg-black/20 backdrop-blur-none"
|
||||
data-testid="folder-browser-dialog"
|
||||
>
|
||||
<DialogHeader className="px-4 pt-4 pb-2">
|
||||
<DialogTitle className="text-sm font-medium">
|
||||
Browse for folder
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex flex-wrap items-center gap-0.5 border-t border-border/50 px-4 py-2 font-mono text-[11px] text-muted-foreground">
|
||||
{crumbs.length === 0 ? (
|
||||
<span className="text-muted-foreground/60">(loading…)</span>
|
||||
) : (
|
||||
crumbs.map((c, i) => (
|
||||
<span key={c.value} className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-1 py-0.5 hover:bg-accent hover:text-foreground"
|
||||
onClick={() => navigate(c.value, showHidden)}
|
||||
disabled={loading}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
{i < crumbs.length - 1 && (
|
||||
<span className="text-muted-foreground/40">/</span>
|
||||
)}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Suggestions (quick-pick chips) */}
|
||||
{data?.suggestions && data.suggestions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 border-t border-border/50 px-4 py-2">
|
||||
{data.suggestions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => navigate(s, showHidden)}
|
||||
disabled={loading}
|
||||
className="rounded-full border border-border/50 px-2 py-0.5 font-mono text-[10px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
title={s}
|
||||
>
|
||||
{s.length > 36 ? `…${s.slice(-33)}` : s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entry list */}
|
||||
<div className="max-h-64 min-h-24 overflow-y-auto border-t border-border/50">
|
||||
{error && (
|
||||
<div className="px-4 py-3 text-xs text-destructive">{error}</div>
|
||||
)}
|
||||
{!error && loading && (
|
||||
<div className="flex items-center gap-2 px-4 py-3">
|
||||
<Spinner className="size-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Loading…</span>
|
||||
</div>
|
||||
)}
|
||||
{!error && !loading && data && (
|
||||
<>
|
||||
{/* Up row */}
|
||||
{data.parent !== null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(data.parent ?? undefined, showHidden)}
|
||||
className="flex w-full items-center gap-2 px-4 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowUp02Icon}
|
||||
className="size-3 shrink-0"
|
||||
/>
|
||||
<span className="font-mono">..</span>
|
||||
</button>
|
||||
)}
|
||||
{data.entries.length === 0 && !(data.model_files_here && data.model_files_here > 0) && (
|
||||
<div className="px-4 py-3 text-xs text-muted-foreground/60">
|
||||
(empty directory)
|
||||
</div>
|
||||
)}
|
||||
{data.model_files_here !== undefined && data.model_files_here > 0 && (
|
||||
<div className="border-t border-border/30 px-4 py-1.5 text-[10px] text-foreground/70">
|
||||
{data.model_files_here} model file{data.model_files_here === 1 ? "" : "s"} in this folder. Click "Use this folder" to scan it.
|
||||
</div>
|
||||
)}
|
||||
{data.truncated === true && (
|
||||
<div className="border-t border-border/30 px-4 py-1.5 text-[10px] text-muted-foreground/70">
|
||||
Showing first {data.entries.length} entries. Narrow the path
|
||||
to see more.
|
||||
</div>
|
||||
)}
|
||||
{data.entries.map((e) => (
|
||||
<button
|
||||
type="button"
|
||||
key={e.name}
|
||||
onClick={() => {
|
||||
const sep = data.current.endsWith("/") ? "" : "/";
|
||||
navigate(`${data.current}${sep}${e.name}`, showHidden);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-4 py-1.5 text-left text-xs transition-colors hover:bg-accent hover:text-foreground",
|
||||
e.hidden && "text-muted-foreground/60",
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Folder02Icon}
|
||||
className={cn(
|
||||
"size-3 shrink-0",
|
||||
e.has_models
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground/50",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate font-mono">{e.name}</span>
|
||||
{e.has_models && (
|
||||
<span className="ml-auto shrink-0 rounded-full border border-border/50 px-1.5 py-0 text-[9px] uppercase tracking-wider text-muted-foreground">
|
||||
models
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<DialogFooter className="flex items-center justify-between gap-2 border-t border-border/50 px-4 py-2">
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showHidden}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked;
|
||||
setShowHidden(next);
|
||||
navigate(path, next);
|
||||
}}
|
||||
className="size-3"
|
||||
/>
|
||||
Show hidden
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<DialogClose asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 rounded border border-border/50 px-2.5 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={!path || loading || !!error}
|
||||
className="h-7 rounded bg-foreground px-2.5 text-[11px] font-medium text-background transition-colors hover:bg-foreground/90 disabled:opacity-40"
|
||||
>
|
||||
Use this folder
|
||||
</button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ import {
|
|||
listCachedModels,
|
||||
listGgufVariants,
|
||||
listLocalModels,
|
||||
listRecommendedFolders,
|
||||
listScanFolders,
|
||||
removeScanFolder,
|
||||
} from "@/features/chat/api/chat-api";
|
||||
|
|
@ -48,7 +49,8 @@ import type { VramFitStatus } from "@/lib/vram";
|
|||
import { checkVramFit, estimateLoadingVram } from "@/lib/vram";
|
||||
import { Add01Icon, Cancel01Icon, Folder02Icon, Search01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import { FolderBrowser } from "./folder-browser";
|
||||
import { ChevronDownIcon, ChevronRightIcon, DownloadIcon, StarIcon, Trash2Icon } from "lucide-react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
|
|
@ -72,10 +74,35 @@ function normalizeForSearch(s: string): string {
|
|||
return s.toLowerCase().replace(/[\s\-_\.]/g, "");
|
||||
}
|
||||
|
||||
function ListLabel({ children }: { children: ReactNode }) {
|
||||
function ListLabel({
|
||||
children,
|
||||
icon,
|
||||
collapsed,
|
||||
onToggle,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
icon?: ReactNode;
|
||||
collapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{children}
|
||||
<div className="flex items-center justify-between gap-1 px-2.5 py-1.5">
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{icon}
|
||||
{children}
|
||||
</span>
|
||||
{onToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={collapsed ? "Expand section" : "Collapse section"}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
{collapsed
|
||||
? <ChevronRightIcon className="size-3" />
|
||||
: <ChevronDownIcon className="size-3" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -488,6 +515,9 @@ export function HubModelPicker({
|
|||
// Delete confirmation dialog state
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [downloadedCollapsed, setDownloadedCollapsed] = useState(false);
|
||||
const [customFoldersCollapsed, setCustomFoldersCollapsed] = useState(false);
|
||||
const [recommendedCollapsed, setRecommendedCollapsed] = useState(false);
|
||||
|
||||
// Cached (already downloaded) repos -- use module-level cache so
|
||||
// re-mounting the popover does not flash an empty "Downloaded" section.
|
||||
|
|
@ -512,6 +542,8 @@ export function HubModelPicker({
|
|||
const [folderError, setFolderError] = useState<string | null>(null);
|
||||
const [showFolderInput, setShowFolderInput] = useState(false);
|
||||
const [folderLoading, setFolderLoading] = useState(false);
|
||||
const [showFolderBrowser, setShowFolderBrowser] = useState(false);
|
||||
const [recommendedFolders, setRecommendedFolders] = useState<string[]>([]);
|
||||
|
||||
const refreshLocalModelsList = useCallback(() => {
|
||||
listLocalModels()
|
||||
|
|
@ -537,11 +569,22 @@ export function HubModelPicker({
|
|||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleAddFolder = useCallback(async () => {
|
||||
const trimmed = folderInput.trim();
|
||||
const handleAddFolder = useCallback(async (overridePath?: string) => {
|
||||
// Accept an explicit path so the folder browser can submit the
|
||||
// chosen path in the same tick it calls `setFolderInput`; reading
|
||||
// `folderInput` alone would race the state update.
|
||||
const raw = overridePath !== undefined ? overridePath : folderInput;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || folderLoading) return;
|
||||
setFolderError(null);
|
||||
setFolderLoading(true);
|
||||
// True when the request originated from the folder browser's
|
||||
// ``onSelect`` (one-click "Use this folder"). In that flow the
|
||||
// typed-input panel is closed, so the inline ``folderError``
|
||||
// paragraph is invisible. Surface failures via toast instead so
|
||||
// the action doesn't appear to silently no-op when the backend
|
||||
// rejects (denylisted path, sandbox 403, etc.).
|
||||
const fromBrowser = overridePath !== undefined;
|
||||
try {
|
||||
const created = await addScanFolder(trimmed);
|
||||
// Backend returns existing row for duplicates, so deduplicate
|
||||
|
|
@ -557,7 +600,11 @@ export function HubModelPicker({
|
|||
// Background reconciliation with the server
|
||||
void refreshScanFolders();
|
||||
} catch (e) {
|
||||
setFolderError(e instanceof Error ? e.message : "Failed to add folder");
|
||||
const message = e instanceof Error ? e.message : "Failed to add folder";
|
||||
setFolderError(message);
|
||||
if (fromBrowser) {
|
||||
toast.error("Couldn't add folder", { description: message });
|
||||
}
|
||||
} finally {
|
||||
setFolderLoading(false);
|
||||
}
|
||||
|
|
@ -599,8 +646,15 @@ export function HubModelPicker({
|
|||
// Always refresh LM Studio + custom folder models (not gated by alreadyCached)
|
||||
refreshLocalModelsList();
|
||||
refreshScanFolders();
|
||||
listRecommendedFolders()
|
||||
.then(setRecommendedFolders)
|
||||
.catch(() => {});
|
||||
|
||||
if (alreadyCached) return;
|
||||
// Always refetch cached GGUF/model lists. The module-level caches give
|
||||
// an instant render with stale data (no spinner flash), but newly
|
||||
// downloaded repos won't appear unless we re-hit the backend on every
|
||||
// mount. Initial state already has cachedReady=alreadyCached, so the
|
||||
// background refresh is invisible when we already had data.
|
||||
let done = 0;
|
||||
const check = () => {
|
||||
if (++done >= 2) setCachedReady(true);
|
||||
|
|
@ -619,7 +673,7 @@ export function HubModelPicker({
|
|||
})
|
||||
.catch(() => {})
|
||||
.finally(check);
|
||||
}, [alreadyCached, refreshLocalModelsList, refreshScanFolders]);
|
||||
}, [refreshLocalModelsList, refreshScanFolders]);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
|
@ -872,8 +926,12 @@ export function HubModelPicker({
|
|||
(cachedGguf.length > 0 ||
|
||||
(!chatOnly && cachedModels.length > 0)) ? (
|
||||
<>
|
||||
<ListLabel>Downloaded</ListLabel>
|
||||
{cachedGguf.map((c) => (
|
||||
<ListLabel
|
||||
icon={<DownloadIcon className="size-3" />}
|
||||
collapsed={downloadedCollapsed}
|
||||
onToggle={() => setDownloadedCollapsed((v) => !v)}
|
||||
>Downloaded</ListLabel>
|
||||
{!downloadedCollapsed && cachedGguf.map((c) => (
|
||||
<div key={c.repo_id}>
|
||||
<ModelRow
|
||||
label={c.repo_id}
|
||||
|
|
@ -901,7 +959,7 @@ export function HubModelPicker({
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
{!chatOnly &&
|
||||
{!downloadedCollapsed && !chatOnly &&
|
||||
cachedModels.map((c) => (
|
||||
<div key={c.repo_id} className="flex items-center gap-0.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
|
|
@ -980,30 +1038,56 @@ export function HubModelPicker({
|
|||
|
||||
{!showHfSection ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-2.5 py-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<div className="flex items-center gap-1 px-2.5 py-1.5">
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<HugeiconsIcon icon={Folder02Icon} className="size-3" />
|
||||
Custom Folders
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showFolderInput ? "Cancel adding folder" : "Add scan folder"}
|
||||
onClick={() => {
|
||||
setShowFolderInput((open) => {
|
||||
if (open) { setFolderInput(""); setFolderError(null); }
|
||||
return !open;
|
||||
});
|
||||
}}
|
||||
className="rounded p-0.5 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={showFolderInput ? Cancel01Icon : Add01Icon} className="size-3" />
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showFolderInput ? "Cancel adding folder" : "Add scan folder by path"}
|
||||
title={showFolderInput ? "Cancel" : "Add by typing a path"}
|
||||
onClick={() => {
|
||||
setShowFolderInput((open) => {
|
||||
if (open) { setFolderInput(""); setFolderError(null); }
|
||||
return !open;
|
||||
});
|
||||
}}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={showFolderInput ? Cancel01Icon : Add01Icon} className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Browse for a folder on the server"
|
||||
title="Browse folders on the server"
|
||||
onClick={() => setShowFolderBrowser(true)}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={Search01Icon} className="size-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={customFoldersCollapsed ? "Expand custom folders" : "Collapse custom folders"}
|
||||
title={customFoldersCollapsed ? "Expand" : "Collapse"}
|
||||
onClick={() => setCustomFoldersCollapsed((v) => !v)}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
{customFoldersCollapsed
|
||||
? <ChevronRightIcon className="size-3" />
|
||||
: <ChevronDownIcon className="size-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Folder paths */}
|
||||
{scanFolders.map((f) => (
|
||||
{!customFoldersCollapsed && scanFolders.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className="group flex items-center gap-1.5 px-3 py-0.5"
|
||||
className="group flex items-center gap-1.5 px-2.5 py-0.5"
|
||||
>
|
||||
<HugeiconsIcon icon={Folder02Icon} className="size-3 shrink-0 text-muted-foreground/40" />
|
||||
<span
|
||||
|
|
@ -1016,15 +1100,38 @@ export function HubModelPicker({
|
|||
type="button"
|
||||
onClick={() => handleRemoveFolder(f.id)}
|
||||
aria-label={`Remove folder ${f.path}`}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground/40 opacity-100 md:opacity-0 md:group-hover:opacity-100 focus-visible:opacity-100 transition-opacity hover:text-destructive"
|
||||
className="shrink-0 rounded p-1 text-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-2.5" />
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Recommended folders */}
|
||||
{!customFoldersCollapsed && (() => {
|
||||
const registered = new Set(scanFolders.map((f) => f.path));
|
||||
const unregistered = recommendedFolders.filter((p) => !registered.has(p));
|
||||
if (unregistered.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 px-2.5 pb-0.5">
|
||||
{unregistered.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => void handleAddFolder(p)}
|
||||
disabled={folderLoading}
|
||||
title={`Add ${p}`}
|
||||
className="rounded-full border border-dashed border-border/50 px-2 py-0.5 font-mono text-[10px] text-muted-foreground/70 transition-colors hover:border-foreground/30 hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
<span className="text-[11px] font-semibold">+</span> {p.length > 30 ? `...${p.slice(-27)}` : p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Add folder input */}
|
||||
{showFolderInput && (
|
||||
{!customFoldersCollapsed && showFolderInput && (
|
||||
<div className="px-2.5 pb-1 pt-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<HugeiconsIcon icon={Folder02Icon} className="size-3 shrink-0 text-muted-foreground/40" />
|
||||
|
|
@ -1042,7 +1149,17 @@ export function HubModelPicker({
|
|||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddFolder}
|
||||
onClick={() => setShowFolderBrowser(true)}
|
||||
disabled={folderLoading}
|
||||
aria-label="Browse for folder"
|
||||
title="Browse folders on the server"
|
||||
className="flex h-6 shrink-0 items-center justify-center rounded border border-border/50 px-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
<HugeiconsIcon icon={Search01Icon} className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void handleAddFolder(); }}
|
||||
disabled={folderLoading || !folderInput.trim()}
|
||||
className="h-6 shrink-0 rounded border border-border/50 px-1.5 text-[10px] text-muted-foreground transition-colors hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
|
|
@ -1055,23 +1172,31 @@ export function HubModelPicker({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{scanFolders.length === 0 && customFolderModels.length === 0 && !showFolderInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFolderInput(true)}
|
||||
className="px-2.5 pb-1.5 text-left text-[10px] text-muted-foreground/60 transition-colors hover:text-muted-foreground"
|
||||
>
|
||||
+ Add a folder to scan for local models
|
||||
</button>
|
||||
)}
|
||||
<FolderBrowser
|
||||
open={showFolderBrowser}
|
||||
onOpenChange={setShowFolderBrowser}
|
||||
initialPath={folderInput.trim() || undefined}
|
||||
onSelect={(picked) => {
|
||||
setFolderInput(picked);
|
||||
setFolderError(null);
|
||||
// One-click UX: the "Use this folder" button submits
|
||||
// the scan folder directly. Pass the path explicitly
|
||||
// because `folderInput` state hasn't flushed yet.
|
||||
void handleAddFolder(picked);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
{/* Models from custom folders */}
|
||||
{customFolderModels.map((m) => {
|
||||
{!customFoldersCollapsed && customFolderModels.map((m) => {
|
||||
const isGgufFile = m.path.toLowerCase().endsWith(".gguf");
|
||||
const isGguf =
|
||||
isGgufFile ||
|
||||
isGgufRepo(m.id) ||
|
||||
isGgufRepo(m.display_name) ||
|
||||
m.path.toLowerCase().endsWith(".gguf");
|
||||
isGgufRepo(m.display_name);
|
||||
// Single .gguf files (e.g. Ollama blobs) load directly;
|
||||
// GGUF repos/directories expand to pick a variant.
|
||||
const isDirectGguf = isGgufFile;
|
||||
return (
|
||||
<div key={m.id}>
|
||||
<ModelRow
|
||||
|
|
@ -1079,7 +1204,13 @@ export function HubModelPicker({
|
|||
meta={isGguf ? "GGUF" : "Local"}
|
||||
selected={value === m.id}
|
||||
onClick={() => {
|
||||
if (isGguf) {
|
||||
if (isDirectGguf) {
|
||||
onSelect(m.id, {
|
||||
source: "local",
|
||||
isLora: false,
|
||||
isDownloaded: true,
|
||||
});
|
||||
} else if (isGguf) {
|
||||
setExpandedGguf((prev) =>
|
||||
prev === m.id ? null : m.id,
|
||||
);
|
||||
|
|
@ -1111,8 +1242,12 @@ export function HubModelPicker({
|
|||
|
||||
{!showHfSection && cachedReady ? (
|
||||
<>
|
||||
<ListLabel>Recommended</ListLabel>
|
||||
{visibleRecommendedIds.length === 0 ? (
|
||||
<ListLabel
|
||||
icon={<StarIcon className="size-3" />}
|
||||
collapsed={recommendedCollapsed}
|
||||
onToggle={() => setRecommendedCollapsed((v) => !v)}
|
||||
>Recommended</ListLabel>
|
||||
{recommendedCollapsed ? null : visibleRecommendedIds.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-xs text-muted-foreground">
|
||||
No default models.
|
||||
</div>
|
||||
|
|
@ -1156,7 +1291,7 @@ export function HubModelPicker({
|
|||
);
|
||||
})
|
||||
)}
|
||||
{hasMoreRecommended && (
|
||||
{!recommendedCollapsed && hasMoreRecommended && (
|
||||
<>
|
||||
<div ref={recommendedSentinelRef} className="h-px" />
|
||||
<div className="flex items-center justify-center py-2">
|
||||
|
|
@ -1169,7 +1304,7 @@ export function HubModelPicker({
|
|||
|
||||
{showHfSection && filteredRecommendedIds.length > 0 ? (
|
||||
<>
|
||||
<ListLabel>Recommended</ListLabel>
|
||||
<ListLabel icon={<StarIcon className="size-3" />}>Recommended</ListLabel>
|
||||
{filteredRecommendedIds.map((id) => {
|
||||
const vram = recommendedVramMap.get(id);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -247,6 +247,48 @@ export async function removeScanFolder(id: number): Promise<void> {
|
|||
await parseJsonOrThrow<unknown>(response);
|
||||
}
|
||||
|
||||
export interface BrowseEntry {
|
||||
name: string;
|
||||
has_models: boolean;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface BrowseFoldersResponse {
|
||||
current: string;
|
||||
parent: string | null;
|
||||
entries: BrowseEntry[];
|
||||
suggestions: string[];
|
||||
truncated?: boolean;
|
||||
model_files_here?: number;
|
||||
}
|
||||
|
||||
export async function listRecommendedFolders(): Promise<string[]> {
|
||||
const response = await authFetch("/api/models/recommended-folders");
|
||||
const data = await parseJsonOrThrow<{ folders: string[] }>(response);
|
||||
return data.folders;
|
||||
}
|
||||
|
||||
export async function browseFolders(
|
||||
path?: string,
|
||||
showHidden = false,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowseFoldersResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (path !== undefined && path !== null) params.set("path", path);
|
||||
if (showHidden) params.set("show_hidden", "true");
|
||||
const qs = params.toString();
|
||||
// Forward the AbortSignal through authFetch -> fetch so that a
|
||||
// navigation cancelled in the FolderBrowser (rapid breadcrumb / row /
|
||||
// hidden-toggle clicks) actually cancels the in-flight HTTP request
|
||||
// server-side, instead of merely dropping the response client-side
|
||||
// while the backend keeps walking large directory trees.
|
||||
const response = await authFetch(
|
||||
`/api/models/browse-folders${qs ? `?${qs}` : ""}`,
|
||||
signal ? { signal } : undefined,
|
||||
);
|
||||
return parseJsonOrThrow<BrowseFoldersResponse>(response);
|
||||
}
|
||||
|
||||
export async function listGgufVariants(
|
||||
repoId: string,
|
||||
hfToken?: string,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
101
studio/frontend/src/features/settings/tabs/about-tab.tsx
Normal file
101
studio/frontend/src/features/settings/tabs/about-tab.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 { ShutdownDialog } from "@/components/shutdown-dialog";
|
||||
import { UpdateStudioInstructions } from "../components/update-studio-instructions";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { removeTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
|
||||
import {
|
||||
ArrowUpRight01Icon,
|
||||
Book03Icon,
|
||||
Cancel01Icon,
|
||||
MessageNotification01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useState } from "react";
|
||||
import { SettingsRow } from "../components/settings-row";
|
||||
import { SettingsSection } from "../components/settings-section";
|
||||
|
||||
const VERSION: string =
|
||||
(import.meta.env.VITE_APP_VERSION as string | undefined) ?? "dev";
|
||||
|
||||
export function AboutTab() {
|
||||
const deviceType = usePlatformStore((s) => s.deviceType);
|
||||
const defaultShell = deviceType === "windows" ? "windows" : "unix";
|
||||
const [shutdownOpen, setShutdownOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">About</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Unsloth Studio build info and support.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SettingsSection title="Studio">
|
||||
<SettingsRow label="Version">
|
||||
<code className="font-mono text-xs text-muted-foreground">{VERSION}</code>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Updates">
|
||||
<div className="py-2">
|
||||
<UpdateStudioInstructions defaultShell={defaultShell} showTitle={false} />
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Help">
|
||||
<SettingsRow label="Documentation">
|
||||
<a
|
||||
href="https://unsloth.ai/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={Book03Icon} className="size-3.5" />
|
||||
unsloth.ai/docs
|
||||
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
|
||||
</a>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Feedback">
|
||||
<a
|
||||
href="https://github.com/unslothai/unsloth/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={MessageNotification01Icon} className="size-3.5" />
|
||||
Report an issue
|
||||
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
|
||||
</a>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Danger zone">
|
||||
<SettingsRow
|
||||
destructive
|
||||
label="Shut down Unsloth Studio"
|
||||
description="Stops the Studio server process and ends your session."
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShutdownOpen(true)}
|
||||
className="text-destructive hover:text-destructive hover:border-destructive/60"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-3.5 mr-1.5" />
|
||||
Shut down
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<ShutdownDialog
|
||||
open={shutdownOpen}
|
||||
onOpenChange={setShutdownOpen}
|
||||
onAfterShutdown={removeTrainingUnloadGuard}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
197
studio/frontend/src/features/settings/tabs/general-tab.tsx
Normal file
197
studio/frontend/src/features/settings/tabs/general-tab.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { SettingsRow } from "../components/settings-row";
|
||||
import { SettingsSection } from "../components/settings-section";
|
||||
|
||||
// Keys cleared by "Reset all local preferences".
|
||||
//
|
||||
// NEVER include auth / session keys here — resetting them would log the user
|
||||
// out, which is not what users expect from a "reset preferences" button.
|
||||
//
|
||||
// Explicitly EXCLUDED:
|
||||
// - "unsloth_auth_token" (auth: access token)
|
||||
// - "unsloth_auth_refresh_token" (auth: refresh token)
|
||||
// - "unsloth_auth_must_change_password" (auth: forced password change flag)
|
||||
// - "unsloth_onboarding_done" (session: would force re-onboarding)
|
||||
const PREFS_KEYS: string[] = [
|
||||
// Appearance
|
||||
"theme",
|
||||
// UI state
|
||||
"sidebar_pinned",
|
||||
"unsloth_sidebar_navigate_open",
|
||||
"unsloth_settings_active_tab",
|
||||
// Chat runtime prefs
|
||||
"unsloth_chat_auto_title",
|
||||
"unsloth_hf_token",
|
||||
"unsloth_auto_heal_tool_calls",
|
||||
"unsloth_max_tool_calls_per_message",
|
||||
"unsloth_tool_call_timeout",
|
||||
"unsloth_chat_inference_params",
|
||||
"unsloth_chat_collapsible_state",
|
||||
// Chat presets
|
||||
"unsloth_chat_custom_presets",
|
||||
"unsloth_chat_active_preset",
|
||||
"unsloth_chat_system_prompts",
|
||||
"unsloth_chat_system_prompts_migrated",
|
||||
// Training UI prefs
|
||||
"unsloth_training_config_v1",
|
||||
"unsloth_prev_max_steps",
|
||||
"unsloth_prev_save_steps",
|
||||
// Guided tour flags
|
||||
"tour:studio:v1",
|
||||
];
|
||||
|
||||
// Set to true from resetAllPrefs so the unmount-commit effect skips writing
|
||||
// back the in-memory draft — otherwise the cleanup would re-persist the old
|
||||
// HF token into localStorage after it was just cleared, and the subsequent
|
||||
// reload would read the re-written value.
|
||||
let resetInProgress = false;
|
||||
|
||||
function resetAllPrefs() {
|
||||
resetInProgress = true;
|
||||
for (const key of PREFS_KEYS) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
export function GeneralTab() {
|
||||
const hfToken = useChatRuntimeStore((s) => s.hfToken);
|
||||
const setHfToken = useChatRuntimeStore((s) => s.setHfToken);
|
||||
const autoTitle = useChatRuntimeStore((s) => s.autoTitle);
|
||||
const setAutoTitle = useChatRuntimeStore((s) => s.setAutoTitle);
|
||||
|
||||
const [draftToken, setDraftToken] = useState(hfToken ?? "");
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const draftRef = useRef(draftToken);
|
||||
useEffect(() => {
|
||||
draftRef.current = draftToken;
|
||||
}, [draftToken]);
|
||||
|
||||
// Commit on unmount (dialog close / tab switch). Skip during reset-prefs
|
||||
// flow so we don't re-persist the draft after localStorage was cleared.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetInProgress) return;
|
||||
const trimmed = draftRef.current.trim();
|
||||
const current = useChatRuntimeStore.getState().hfToken;
|
||||
if (trimmed !== current) {
|
||||
useChatRuntimeStore.getState().setHfToken(trimmed);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const commitToken = () => {
|
||||
const trimmed = draftToken.trim();
|
||||
if (trimmed !== draftToken) setDraftToken(trimmed);
|
||||
if (trimmed !== hfToken) setHfToken(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold font-heading">General</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Global preferences for Unsloth Studio.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SettingsSection title="Account">
|
||||
<SettingsRow
|
||||
label="Hugging Face token"
|
||||
description="Used to load gated models and push artifacts."
|
||||
>
|
||||
<div className="relative w-[260px]">
|
||||
<Input
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder="hf_…"
|
||||
value={draftToken}
|
||||
onChange={(e) => setDraftToken(e.target.value)}
|
||||
onBlur={commitToken}
|
||||
className="h-8 w-full pr-8 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken((s) => !s)}
|
||||
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={showToken ? "Hide token" : "Show token"}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showToken ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Chat defaults">
|
||||
<SettingsRow
|
||||
label="Auto-title new chats"
|
||||
description="Generate a short title from the first message."
|
||||
>
|
||||
<Switch checked={autoTitle} onCheckedChange={setAutoTitle} />
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Danger zone">
|
||||
<SettingsRow
|
||||
destructive
|
||||
label="Reset all local preferences"
|
||||
description="Clears theme, tokens, sidebar state, and presets. Chats and API keys are not affected."
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
className="text-destructive hover:text-destructive hover:border-destructive/60"
|
||||
>
|
||||
Reset preferences
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset all local preferences?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This clears your theme, tokens, and stored settings, then reloads
|
||||
Studio. Chats and API keys are not affected.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={resetAllPrefs}
|
||||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
>
|
||||
Reset and reload
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,7 +14,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import { type ReactElement, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { DatasetPreviewDialog } from "./sections/dataset-preview-dialog";
|
||||
import { DatasetSection } from "./sections/dataset-section";
|
||||
import { ModelSection } from "./sections/model-section";
|
||||
|
|
@ -49,7 +50,12 @@ export function StudioPage(): ReactElement {
|
|||
const closeDialog = useDatasetPreviewDialogStore((s) => s.close);
|
||||
|
||||
const [requestedTab, setRequestedTab] = useState("configure");
|
||||
const [selectedHistoryRunId, setSelectedHistoryRunId] = useState<string | null>(null);
|
||||
const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId);
|
||||
const setSelectedHistoryRunId = useTrainingRuntimeStore((s) => s.setSelectedHistoryRunId);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setSelectedHistoryRunId(null);
|
||||
}, [setSelectedHistoryRunId]);
|
||||
|
||||
// Derive activeTab: auto-switch to "current-run" only while training is
|
||||
// genuinely running. Once training ends, honour whatever tab the user clicks.
|
||||
|
|
@ -61,9 +67,20 @@ export function StudioPage(): ReactElement {
|
|||
? "configure"
|
||||
: requestedTab;
|
||||
|
||||
const { setPinned } = useSidebar();
|
||||
const pinSidebar = useCallback(() => setPinned(true), [setPinned]);
|
||||
|
||||
const tourEnabled = hasHydratedRuntime && !isHydratingRuntime;
|
||||
const isConfigTour = activeTab === "configure";
|
||||
const tourSteps = activeTab === "current-run" ? studioTrainingTourSteps : studioTourSteps;
|
||||
const baseTourSteps = activeTab === "current-run" ? studioTrainingTourSteps : studioTourSteps;
|
||||
// Inject onEnter for navbar-targeting steps so the sidebar expands during the tour.
|
||||
const tourSteps = useMemo(
|
||||
() =>
|
||||
baseTourSteps.map((step) =>
|
||||
step.target === "navbar" ? { ...step, onEnter: pinSidebar } : step,
|
||||
),
|
||||
[baseTourSteps, pinSidebar],
|
||||
);
|
||||
const tour = useGuidedTourController({
|
||||
id: "studio",
|
||||
steps: tourSteps,
|
||||
|
|
@ -86,6 +103,14 @@ export function StudioPage(): ReactElement {
|
|||
}
|
||||
}, [isTrainingRunning, requestedTab]);
|
||||
|
||||
// Selecting a run from the sidebar only sets selectedHistoryRunId; auto-switch
|
||||
// to the History tab so the main panel reflects the selection.
|
||||
useEffect(() => {
|
||||
if (selectedHistoryRunId && requestedTab !== "history") {
|
||||
setRequestedTab("history");
|
||||
}
|
||||
}, [selectedHistoryRunId, requestedTab]);
|
||||
|
||||
useEffect(() => {
|
||||
ensureModelDefaultsLoaded();
|
||||
ensureDatasetChecked();
|
||||
|
|
@ -106,7 +131,7 @@ export function StudioPage(): ReactElement {
|
|||
})();
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden bg-background">
|
||||
<div className="relative min-h-screen bg-background">
|
||||
<main className="relative z-10 mx-auto max-w-7xl px-4 py-4 sm:px-6">
|
||||
<GuidedTour {...tour.tourProps} celebrate={isConfigTour} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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: 0.625rem;
|
||||
--sidebar: oklch(0.99 0 0);
|
||||
--sidebar-foreground: oklch(0.1281 0.0179 169.2764);
|
||||
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.96 0.0279 166.55);
|
||||
--sidebar-accent-foreground: oklch(0.2868 0.0649 159.9823);
|
||||
--sidebar-border: oklch(0.9208 0.0101 164.8536);
|
||||
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
|
||||
--font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans);
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-opacity: 0;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-offset-x: 0px;
|
||||
--shadow-offset-y: 0px;
|
||||
--letter-spacing: 0em;
|
||||
--spacing: 0.25rem;
|
||||
/*--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-sm:*/
|
||||
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
|
||||
/* 0px 1px 2px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow:*/
|
||||
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
|
||||
/* 0px 1px 2px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-md:*/
|
||||
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
|
||||
/* 0px 2px 4px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-lg:*/
|
||||
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
|
||||
/* 0px 4px 6px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-xl:*/
|
||||
/* 0px 0px 0px 0px hsl(0 0% 0% / 0),*/
|
||||
/* 0px 8px 10px 0px hsl(0 0% 0% / 0);*/
|
||||
/*--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);*/
|
||||
--tracking-normal: -0.01em;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.24 0 0);
|
||||
--foreground: oklch(0.98 0 0);
|
||||
--card: oklch(0.28 0 0);
|
||||
--card-foreground: oklch(0.98 0 0);
|
||||
--popover: oklch(0.28 0 0);
|
||||
--popover-foreground: oklch(0.98 0 0);
|
||||
--primary: oklch(0.6929 0.1396 166.5513);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.33 0 0);
|
||||
--secondary-foreground: oklch(0.98 0 0);
|
||||
--muted: oklch(0.33 0 0);
|
||||
--muted-foreground: oklch(0.70 0 0);
|
||||
--accent: oklch(0.33 0 0);
|
||||
--accent-foreground: oklch(0.98 0 0);
|
||||
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||
--border: oklch(0.38 0 0);
|
||||
--input: oklch(0.38 0 0);
|
||||
--ring: oklch(0.6929 0.1396 166.5513);
|
||||
--chart-1: oklch(0.7511 0.1407 166.2284);
|
||||
--chart-2: oklch(0.75 0.14 136.5572);
|
||||
--chart-3: oklch(0.7554 0.1285 197.339);
|
||||
--chart-4: oklch(0.7503 0.1199 346.7805);
|
||||
--chart-5: oklch(0.799 0.1196 84.6633);
|
||||
--sidebar: oklch(0.24 0 0);
|
||||
--sidebar-foreground: oklch(0.98 0 0);
|
||||
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.33 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.98 0 0);
|
||||
--sidebar-border: oklch(0.38 0 0);
|
||||
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--radius: 1.2rem;
|
||||
--font-sans: Geist, ui-sans-serif, sans-serif, system-ui;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-opacity: 0;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-offset-x: 0px;
|
||||
--shadow-offset-y: 0px;
|
||||
--letter-spacing: 0em;
|
||||
--spacing: 0.25rem;
|
||||
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px
|
||||
hsl(0 0% 0% / 0);
|
||||
--shadow: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-md: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 2px 4px 0px
|
||||
hsl(0 0% 0% / 0);
|
||||
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px 0px
|
||||
hsl(0 0% 0% / 0);
|
||||
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px 0px
|
||||
hsl(0 0% 0% / 0);
|
||||
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);
|
||||
--background: oklch(0.24 0 0);
|
||||
--foreground: oklch(0.98 0 0);
|
||||
--card: oklch(0.28 0 0);
|
||||
--card-foreground: oklch(0.98 0 0);
|
||||
--popover: oklch(0.28 0 0);
|
||||
--popover-foreground: oklch(0.98 0 0);
|
||||
--primary: oklch(0.6929 0.1396 166.5513);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.33 0 0);
|
||||
--secondary-foreground: oklch(0.98 0 0);
|
||||
--muted: oklch(0.33 0 0);
|
||||
--muted-foreground: oklch(0.70 0 0);
|
||||
--accent: oklch(0.33 0 0);
|
||||
--accent-foreground: oklch(0.98 0 0);
|
||||
--destructive: oklch(0.6368 0.2078 25.3313);
|
||||
--border: oklch(0.38 0 0);
|
||||
--input: oklch(0.38 0 0);
|
||||
--ring: oklch(0.6929 0.1396 166.5513);
|
||||
--chart-1: oklch(0.7511 0.1407 166.2284);
|
||||
--chart-2: oklch(0.75 0.14 136.5572);
|
||||
--chart-3: oklch(0.7554 0.1285 197.339);
|
||||
--chart-4: oklch(0.7503 0.1199 346.7805);
|
||||
--chart-5: oklch(0.799 0.1196 84.6633);
|
||||
--sidebar: oklch(0.24 0 0);
|
||||
--sidebar-foreground: oklch(0.98 0 0);
|
||||
--sidebar-primary: oklch(0.6929 0.1396 166.5513);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.33 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.98 0 0);
|
||||
--sidebar-border: oklch(0.38 0 0);
|
||||
--sidebar-ring: oklch(0.6929 0.1396 166.5513);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--radius: 0.625rem;
|
||||
--font-sans: Geist, ui-sans-serif, sans-serif, system-ui;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-opacity: 0;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-offset-x: 0px;
|
||||
--shadow-offset-y: 0px;
|
||||
--letter-spacing: 0em;
|
||||
--spacing: 0.25rem;
|
||||
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px hsl(0 0% 0% / 0);
|
||||
--shadow: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-md: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 2px 4px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px 0px hsl(0 0% 0% / 0);
|
||||
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 0% / 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
|
||||
--font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-code-block: #181818;
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--radius: 1.2rem;
|
||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
||||
--tracking-normal: var(--tracking-normal);
|
||||
/*--shadow-2xl: var(--shadow-2xl);*/
|
||||
/*--shadow-xl: var(--shadow-xl);*/
|
||||
/*--shadow-lg: var(--shadow-lg);*/
|
||||
/*--shadow-md: var(--shadow-md);*/
|
||||
/*--shadow: var(--shadow);*/
|
||||
/*--shadow-sm: var(--shadow-sm);*/
|
||||
/*--shadow-xs: var(--shadow-xs);*/
|
||||
/*--shadow-2xs: var(--shadow-2xs);*/
|
||||
/*--spacing: var(--spacing);*/
|
||||
/*--letter-spacing: var(--letter-spacing);*/
|
||||
/*--shadow-offset-y: var(--shadow-offset-y);*/
|
||||
/*--shadow-offset-x: var(--shadow-offset-x);*/
|
||||
/*--shadow-spread: var(--shadow-spread);*/
|
||||
/*--shadow-blur: var(--shadow-blur);*/
|
||||
/*--shadow-opacity: var(--shadow-opacity);*/
|
||||
/*--color-shadow-color: var(--shadow-color);*/
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--animate-pulse: pulse var(--duration) ease-out infinite;
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 var(--pulse-color);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px var(--pulse-color);
|
||||
}
|
||||
}
|
||||
--animate-shiny-text: shiny-text 8s infinite;
|
||||
@keyframes shiny-text {
|
||||
0%,
|
||||
90%,
|
||||
100% {
|
||||
background-position: calc(-100% - var(--shiny-width)) 0;
|
||||
}
|
||||
30%,
|
||||
60% {
|
||||
background-position: calc(100% + var(--shiny-width)) 0;
|
||||
}
|
||||
}
|
||||
--animate-shine: shine var(--duration) infinite linear
|
||||
;
|
||||
@keyframes shine {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
to {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
}}
|
||||
--font-sans: "Inter Variable", ui-sans-serif, sans-serif, system-ui;
|
||||
--font-heading: "Hellix", "Space Grotesk Variable", var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-code-block: #181818;
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--font-mono: JetBrains Mono, monospace;
|
||||
--font-serif: Source Serif 4, serif;
|
||||
--radius: 0.625rem;
|
||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
||||
--tracking-normal: var(--tracking-normal);
|
||||
/*--shadow-2xl: var(--shadow-2xl);*/
|
||||
/*--shadow-xl: var(--shadow-xl);*/
|
||||
/*--shadow-lg: var(--shadow-lg);*/
|
||||
/*--shadow-md: var(--shadow-md);*/
|
||||
/*--shadow: var(--shadow);*/
|
||||
/*--shadow-sm: var(--shadow-sm);*/
|
||||
/*--shadow-xs: var(--shadow-xs);*/
|
||||
/*--shadow-2xs: var(--shadow-2xs);*/
|
||||
/*--spacing: var(--spacing);*/
|
||||
/*--letter-spacing: var(--letter-spacing);*/
|
||||
/*--shadow-offset-y: var(--shadow-offset-y);*/
|
||||
/*--shadow-offset-x: var(--shadow-offset-x);*/
|
||||
/*--shadow-spread: var(--shadow-spread);*/
|
||||
/*--shadow-blur: var(--shadow-blur);*/
|
||||
/*--shadow-opacity: var(--shadow-opacity);*/
|
||||
/*--color-shadow-color: var(--shadow-color);*/
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--animate-pulse: pulse var(--duration) ease-out infinite;
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 var(--pulse-color);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px var(--pulse-color);
|
||||
}
|
||||
}
|
||||
|
||||
--animate-shiny-text: shiny-text 8s infinite;
|
||||
|
||||
@keyframes shiny-text {
|
||||
|
||||
0%,
|
||||
90%,
|
||||
100% {
|
||||
background-position: calc(-100% - var(--shiny-width)) 0;
|
||||
}
|
||||
|
||||
30%,
|
||||
60% {
|
||||
background-position: calc(100% + var(--shiny-width)) 0;
|
||||
}
|
||||
}
|
||||
|
||||
--animate-shine: shine var(--duration) infinite linear;
|
||||
|
||||
@keyframes shine {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply font-sans bg-background text-foreground;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
body[data-scroll-locked] {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
.font-medium,
|
||||
.font-semibold,
|
||||
.font-bold {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply font-sans bg-background text-foreground;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
|
||||
html {
|
||||
@apply font-sans h-full;
|
||||
}
|
||||
|
||||
body,
|
||||
#root {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
body[data-scroll-locked] {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-sans);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Heading font utility */
|
||||
.font-heading {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
/* Elevated surface shadow (use ring-* for borders) */
|
||||
.shadow-border {
|
||||
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
--tw-shadow-colored: 0 4px 16px var(--tw-shadow-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000),
|
||||
var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
}
|
||||
.dark .shadow-border {
|
||||
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
/* Heading font utility */
|
||||
.font-heading {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
/* Fine-tuning Studio: equal default height, expandable when needed (md+) */
|
||||
.min-h-studio-config-column {
|
||||
@apply md:min-h-[470px];
|
||||
}
|
||||
.h-studio-config-column {
|
||||
@apply md:h-[470px];
|
||||
}
|
||||
/* Elevated surface shadow (use ring-* for borders) */
|
||||
.shadow-border {
|
||||
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
--tw-shadow-colored: 0 4px 16px var(--tw-shadow-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000),
|
||||
var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
}
|
||||
|
||||
[data-streamdown="unordered-list"] {
|
||||
list-style-type: disc;
|
||||
list-style-position: outside;
|
||||
padding-left: 1.25rem;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
.dark .shadow-border {
|
||||
--tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
[data-streamdown="ordered-list"] {
|
||||
list-style-type: decimal;
|
||||
list-style-position: outside;
|
||||
padding-left: 1.25rem;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
.chat-composer-surface {
|
||||
border: 1px solid oklch(0.93 0 0 / 1);
|
||||
background-clip: padding-box;
|
||||
box-shadow:
|
||||
0 1px 2px oklch(0 0 0 / 0.04),
|
||||
0 6px 14px oklch(0 0 0 / 0.05),
|
||||
0 18px 40px oklch(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
[data-streamdown="list-item"] {
|
||||
display: list-item;
|
||||
}
|
||||
.dark .chat-composer-surface {
|
||||
border-color: oklch(0.38 0 0 / 1);
|
||||
box-shadow:
|
||||
0 1px 2px oklch(0 0 0 / 0.2),
|
||||
0 8px 20px oklch(0 0 0 / 0.25),
|
||||
0 22px 48px oklch(0 0 0 / 0.22);
|
||||
}
|
||||
|
||||
/* Flatten code blocks: single border, language label, then code directly */
|
||||
[data-streamdown="code-block-body"] {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
[data-streamdown="code-block"] {
|
||||
gap: 0;
|
||||
padding: 0.5rem;
|
||||
/* Wide lines must scroll inside the thread column, not widen past the composer (flex min-width:auto). */
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
[data-streamdown="code-block-header"] {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
/* Fine-tuning Studio: equal default height, expandable when needed (md+) */
|
||||
.min-h-studio-config-column {
|
||||
@apply md:min-h-[470px];
|
||||
}
|
||||
|
||||
/* Chat thread: code slightly smaller by default; step up when the thread column is wide. */
|
||||
.aui-thread-root [data-streamdown="code-block"] {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.h-studio-config-column {
|
||||
@apply md:h-[470px];
|
||||
}
|
||||
|
||||
.aui-thread-root [data-streamdown="code-block-header"] {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
[data-streamdown="unordered-list"] {
|
||||
list-style-type: disc;
|
||||
list-style-position: outside;
|
||||
padding-left: 1.25rem;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
@container (min-width: 36rem) {
|
||||
.aui-thread-root [data-streamdown="code-block"] {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
[data-streamdown="ordered-list"] {
|
||||
list-style-type: decimal;
|
||||
list-style-position: outside;
|
||||
padding-left: 1.25rem;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.aui-thread-root [data-streamdown="code-block-header"] {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
[data-streamdown="list-item"] {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/* Chat: use the app sans stack for UI + prose. */
|
||||
.aui-thread-root {
|
||||
--font-heading: var(--font-sans);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
/* Flatten code blocks: single border, language label, then code directly */
|
||||
[data-streamdown="code-block-body"] {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Keep monospace for code fences and inline code (not KaTeX). */
|
||||
.aui-thread-root [data-streamdown="code-block"] pre,
|
||||
.aui-thread-root [data-streamdown="code-block"] code {
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
}
|
||||
[data-streamdown="code-block"] {
|
||||
gap: 0;
|
||||
padding: 0.5rem;
|
||||
/* Wide lines must scroll inside the thread column, not widen past the composer (flex min-width:auto). */
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.aui-thread-root :where(p, li, td, th, blockquote, h1, h2, h3, h4, h5, h6) code {
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
}
|
||||
[data-streamdown="code-block-header"] {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
/* Align fenced code blocks with the main chat column even when nested in lists. */
|
||||
.aui-thread-root [data-streamdown="list-item"] > [data-streamdown="code-block"],
|
||||
.aui-thread-root [data-streamdown="list-item"] [data-streamdown="code-block"] {
|
||||
margin-left: -1.25rem;
|
||||
width: calc(100% + 1.25rem);
|
||||
max-width: calc(100% + 1.25rem);
|
||||
}
|
||||
/* Chat thread: code slightly smaller by default; step up when the thread column is wide. */
|
||||
.aui-thread-root [data-streamdown="code-block"] {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.dark .aui-thread-root [data-streamdown="code-block"] {
|
||||
/* Streamdown `pre` uses `dark:bg-[var(--shiki-dark-bg,...)]`; keep one surface on the outer shell. */
|
||||
--shiki-dark-bg: transparent;
|
||||
background: var(--color-code-block);
|
||||
border: 1px solid oklch(1 0 0 / 0.07);
|
||||
}
|
||||
.aui-thread-root [data-streamdown="code-block-header"] {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
@container (min-width: 36rem) {
|
||||
.aui-thread-root [data-streamdown="code-block"] {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.aui-thread-root [data-streamdown="code-block-header"] {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chat: use the app sans stack for UI + prose. */
|
||||
.aui-thread-root {
|
||||
--font-heading: var(--font-sans);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Keep monospace for code fences and inline code (not KaTeX). */
|
||||
.aui-thread-root [data-streamdown="code-block"] pre,
|
||||
.aui-thread-root [data-streamdown="code-block"] code {
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.aui-thread-root :where(p, li, td, th, blockquote, h1, h2, h3, h4, h5, h6) code {
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Align fenced code blocks with the main chat column even when nested in lists. */
|
||||
.aui-thread-root [data-streamdown="list-item"] > [data-streamdown="code-block"],
|
||||
.aui-thread-root [data-streamdown="list-item"] [data-streamdown="code-block"] {
|
||||
margin-left: -1.25rem;
|
||||
width: calc(100% + 1.25rem);
|
||||
max-width: calc(100% + 1.25rem);
|
||||
}
|
||||
|
||||
.dark .aui-thread-root [data-streamdown="code-block"] {
|
||||
/* Streamdown `pre` uses `dark:bg-[var(--shiki-dark-bg,...)]`; keep one surface on the outer shell. */
|
||||
--shiki-dark-bg: transparent;
|
||||
background: var(--color-code-block);
|
||||
border: 1px solid oklch(1 0 0 / 0.07);
|
||||
}
|
||||
}
|
||||
|
||||
/* Minimal scrollbar — thumb only, no track */
|
||||
/* Flat scrollbar chrome */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(0.5 0 0 / 0.54) transparent;
|
||||
}
|
||||
*:hover {
|
||||
scrollbar-color: oklch(0.6 0 0 / 0.3) transparent;
|
||||
}
|
||||
.dark *:hover {
|
||||
scrollbar-color: oklch(0.5 0 0 / 0.35) transparent;
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: oklch(0.67 0 0 / 0.5) transparent;
|
||||
}
|
||||
|
||||
/* Webkit (Chrome, Safari, Edge) */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 9999px;
|
||||
background: oklch(0.5 0 0 / 0.54);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
*:hover::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.6 0 0 / 0.3);
|
||||
|
||||
::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.dark *:hover::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.5 0 0 / 0.35);
|
||||
|
||||
.dark *::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.67 0 0 / 0.5);
|
||||
}
|
||||
|
||||
/* Chat viewport: solid track matching sidebar so the scrollbar reads as a
|
||||
full-height rail flush to the right edge, without a separate decorative strip. */
|
||||
.aui-thread-viewport {
|
||||
scrollbar-color: oklch(0.5 0 0 / 0.54) var(--sidebar);
|
||||
}
|
||||
|
||||
.dark .aui-thread-viewport {
|
||||
scrollbar-color: oklch(0.67 0 0 / 0.5) var(--sidebar);
|
||||
}
|
||||
|
||||
.aui-thread-viewport::-webkit-scrollbar-track {
|
||||
background: var(--sidebar);
|
||||
}
|
||||
|
||||
[data-sidebar="content"] {
|
||||
scrollbar-color: oklch(0.5 0 0 / 0.7) transparent;
|
||||
}
|
||||
|
||||
.dark [data-sidebar="content"] {
|
||||
scrollbar-color: oklch(0.72 0 0 / 0.65) transparent;
|
||||
}
|
||||
|
||||
/*---break---*/
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-old(root), ::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
|
|
|||
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 })) {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,42 @@ class TestGetModelName(unittest.TestCase):
|
|||
"unsloth/Ministral-3-3B-Instruct-2512",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"allenai/Olmo-3-7B-Instruct",
|
||||
True,
|
||||
"unsloth/Olmo-3-7B-Instruct-unsloth-bnb-4bit",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"allenai/Olmo-3-7B-Instruct",
|
||||
False,
|
||||
"unsloth/Olmo-3-7B-Instruct",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"allenai/Olmo-3-7B-Think",
|
||||
True,
|
||||
"unsloth/Olmo-3-7B-Think-unsloth-bnb-4bit",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"allenai/Olmo-3-7B-Think",
|
||||
False,
|
||||
"unsloth/Olmo-3-7B-Think",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"allenai/Olmo-3-32B-Think",
|
||||
True,
|
||||
"unsloth/Olmo-3-32B-Think-unsloth-bnb-4bit",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"allenai/Olmo-3-32B-Think",
|
||||
False,
|
||||
"unsloth/Olmo-3-32B-Think",
|
||||
True,
|
||||
),
|
||||
("unsloth/Kimi-K2-Instruct", True, "unsloth/Kimi-K2-Instruct-BF16", True),
|
||||
("unsloth/Kimi-K2-Instruct", False, "unsloth/Kimi-K2-Instruct", False),
|
||||
# Fallback-to-original behavior
|
||||
|
|
@ -113,6 +149,10 @@ class TestGetModelName(unittest.TestCase):
|
|||
"mistralai/ministral-3-3b-instruct-2512",
|
||||
"unsloth/ministral-3-3b-instruct-2512-unsloth-bnb-4bit",
|
||||
),
|
||||
(
|
||||
"allenai/olmo-3-7b-instruct",
|
||||
"unsloth/olmo-3-7b-instruct-unsloth-bnb-4bit",
|
||||
),
|
||||
("unsloth/kimi-k2-instruct", "unsloth/kimi-k2-instruct-bf16"),
|
||||
]
|
||||
for src, expected in contracts:
|
||||
|
|
|
|||
|
|
@ -70,6 +70,11 @@ def _test_linear_is_fake_quantized(linear: torch.nn.Linear, qat_scheme: str):
|
|||
weight_fq_class = IntxFakeQuantizer
|
||||
min_in_features = 128
|
||||
weight_only = True
|
||||
elif qat_scheme == "cactus":
|
||||
act_fq_class = None
|
||||
weight_fq_class = IntxFakeQuantizer
|
||||
min_in_features = 32
|
||||
weight_only = True
|
||||
else:
|
||||
raise ValueError(f"Unknown qat_scheme: {qat_scheme}")
|
||||
|
||||
|
|
@ -106,7 +111,7 @@ def _test_fake_quantizers_are_called(
|
|||
"""
|
||||
Verify that the fake quantizers are actually called when the model is called.
|
||||
"""
|
||||
weight_only = qat_scheme == "int8"
|
||||
weight_only = qat_scheme in ["int8", "cactus"]
|
||||
|
||||
def _swap_fake_quantizers(model: torch.nn.Module):
|
||||
for name, child in model.named_children():
|
||||
|
|
@ -167,11 +172,11 @@ def _test_model_fake_quantize(qat_scheme: str, full_finetuning: bool):
|
|||
|
||||
# TODO: there are bad interactions across tests right now, need to figure out
|
||||
# how to disable model caching before re-enabling this test
|
||||
@pytest.mark.parametrize("qat_scheme", ["fp8-int4", "fp8-fp8", "int8"])
|
||||
@pytest.mark.parametrize("qat_scheme", ["fp8-int4", "fp8-fp8", "int8", "cactus"])
|
||||
def _test_full_model_fake_quantize(qat_scheme: str):
|
||||
_test_model_fake_quantize(qat_scheme, full_finetuning = True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("qat_scheme", ["fp8-int4", "fp8-fp8", "int8"])
|
||||
@pytest.mark.parametrize("qat_scheme", ["fp8-int4", "fp8-fp8", "int8", "cactus"])
|
||||
def test_lora_model_fake_quantize(qat_scheme: str):
|
||||
_test_model_fake_quantize(qat_scheme, full_finetuning = False)
|
||||
|
|
|
|||
|
|
@ -1716,6 +1716,8 @@ liquid_lfm2_template = \
|
|||
liquid_lfm2_template_eos_token = "<|im_end|>"
|
||||
CHAT_TEMPLATES["lfm-2"] = (liquid_lfm2_template, liquid_lfm2_template_eos_token, False, None)
|
||||
DEFAULT_SYSTEM_MESSAGE["lfm-2"] = None # No system message in Phi-3
|
||||
CHAT_TEMPLATES["lfm-2.5"] = (liquid_lfm2_template, liquid_lfm2_template_eos_token, False, None)
|
||||
DEFAULT_SYSTEM_MESSAGE["lfm-2.5"] = None
|
||||
|
||||
|
||||
# =========================================== Starling-LM
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
__version__ = "2026.4.4"
|
||||
__version__ = "2026.4.5"
|
||||
|
||||
__all__ = [
|
||||
"SUPPORTS_BFLOAT16",
|
||||
|
|
@ -45,6 +45,7 @@ __all__ = [
|
|||
# "accelerate_old_send_to_device",
|
||||
# "accelerate_new_send_to_device",
|
||||
"patch_gradient_accumulation_fix",
|
||||
"apply_accepts_loss_kwargs_fix",
|
||||
"patch_compiling_bitsandbytes",
|
||||
"patch_regional_compilation",
|
||||
"patch_layernorm",
|
||||
|
|
@ -64,7 +65,9 @@ __all__ = [
|
|||
"patch_compiled_autograd",
|
||||
"process_vision_info",
|
||||
"unsloth_compile_transformers",
|
||||
"determine_attention_implementation",
|
||||
"resolve_model_class",
|
||||
"resolve_attention_implementation",
|
||||
"resolve_encoder_attention_implementation",
|
||||
"_set_attn_impl",
|
||||
"patch_fast_lora",
|
||||
"validate_loftq_config",
|
||||
|
|
@ -232,6 +235,8 @@ def apply_unsloth_gradient_checkpointing(
|
|||
# access on some GPU architectures (B200). Falls back to eager safely.
|
||||
_FLEX_EXCLUDED_MODELS = ("gpt_oss", "mllama", "nemotron_h", "modernbert")
|
||||
_EAGER_ONLY_PREFIXES = ("gemma3n",)
|
||||
_FLASH_ATTENTION_MAX_HEAD_DIM = 256
|
||||
_FLASH_ATTENTION_DISABLED_WARNED = set()
|
||||
|
||||
|
||||
def _is_flex_excluded(model_type):
|
||||
|
|
@ -242,57 +247,281 @@ def _is_eager_only(model_type):
|
|||
return any(model_type.startswith(p) for p in _EAGER_ONLY_PREFIXES)
|
||||
|
||||
|
||||
def _config_items(config):
|
||||
if isinstance(config, dict):
|
||||
return config.items()
|
||||
if hasattr(config, "__dict__"):
|
||||
return vars(config).items()
|
||||
return ()
|
||||
|
||||
|
||||
def _config_get(config, field_name, default = None):
|
||||
if isinstance(config, dict):
|
||||
return config.get(field_name, default)
|
||||
return getattr(config, field_name, default)
|
||||
|
||||
|
||||
def _config_set(config, field_name, value):
|
||||
if isinstance(config, dict):
|
||||
config[field_name] = value
|
||||
elif config is not None:
|
||||
setattr(config, field_name, value)
|
||||
|
||||
|
||||
def _iter_attention_configs(config, seen = None):
|
||||
if config is None or (
|
||||
not isinstance(config, dict) and not hasattr(config, "__dict__")
|
||||
):
|
||||
return
|
||||
if seen is None:
|
||||
seen = set()
|
||||
config_id = id(config)
|
||||
if config_id in seen:
|
||||
return
|
||||
seen.add(config_id)
|
||||
yield config
|
||||
|
||||
for field_name, child_config in _config_items(config):
|
||||
if not isinstance(field_name, str) or not field_name.endswith("_config"):
|
||||
continue
|
||||
if isinstance(child_config, dict) or hasattr(child_config, "__dict__"):
|
||||
yield from _iter_attention_configs(child_config, seen)
|
||||
|
||||
|
||||
def _collect_attention_head_dims(config):
|
||||
explicit_head_dims = []
|
||||
|
||||
for field_name in (
|
||||
"head_dim",
|
||||
"global_head_dim",
|
||||
"local_head_dim",
|
||||
"kv_head_dim",
|
||||
):
|
||||
value = _config_get(config, field_name, None)
|
||||
if isinstance(value, int) and value > 0:
|
||||
explicit_head_dims.append(value)
|
||||
|
||||
if len(explicit_head_dims) != 0:
|
||||
return explicit_head_dims
|
||||
|
||||
head_dims = []
|
||||
|
||||
hidden_size_names = ("hidden_size", "d_model", "embed_dim", "dim")
|
||||
num_heads_names = ("num_attention_heads", "num_heads", "n_heads")
|
||||
for hidden_size_name in hidden_size_names:
|
||||
hidden_size = _config_get(config, hidden_size_name, None)
|
||||
if not isinstance(hidden_size, int) or hidden_size <= 0:
|
||||
continue
|
||||
for num_heads_name in num_heads_names:
|
||||
num_heads = _config_get(config, num_heads_name, None)
|
||||
if (
|
||||
isinstance(num_heads, int)
|
||||
and num_heads > 0
|
||||
and (hidden_size % num_heads) == 0
|
||||
):
|
||||
head_dims.append(hidden_size // num_heads)
|
||||
|
||||
return head_dims
|
||||
|
||||
|
||||
def _get_max_attention_head_dim(config):
|
||||
head_dims = []
|
||||
for attention_config in _iter_attention_configs(config):
|
||||
head_dims.extend(_collect_attention_head_dims(attention_config))
|
||||
return max(head_dims) if len(head_dims) != 0 else None
|
||||
|
||||
|
||||
def _get_flash_attention_disable_reason(config):
|
||||
max_head_dim = _get_max_attention_head_dim(config)
|
||||
if max_head_dim is not None and max_head_dim > _FLASH_ATTENTION_MAX_HEAD_DIM:
|
||||
return (
|
||||
f"max attention head dim {max_head_dim} exceeds the Flash Attention 2 "
|
||||
f"limit of {_FLASH_ATTENTION_MAX_HEAD_DIM}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _is_flash_attention_disabled(config):
|
||||
return _get_flash_attention_disable_reason(config) is not None
|
||||
|
||||
|
||||
def _is_flash_attention_requested(attn_implementation):
|
||||
return isinstance(attn_implementation, str) and attn_implementation.startswith(
|
||||
"flash_attention"
|
||||
)
|
||||
|
||||
|
||||
def _disable_flash_attention_if_needed(
|
||||
config,
|
||||
attn_implementation = None,
|
||||
supports_sdpa = False,
|
||||
would_use_flash_attention = False,
|
||||
disable_reason = None,
|
||||
):
|
||||
if disable_reason is None:
|
||||
disable_reason = _get_flash_attention_disable_reason(config)
|
||||
if disable_reason is None:
|
||||
return attn_implementation
|
||||
|
||||
requested_attn_implementation = attn_implementation
|
||||
if requested_attn_implementation is None:
|
||||
requested_attn_implementation = _config_get(
|
||||
config, "_attn_implementation", None
|
||||
)
|
||||
if requested_attn_implementation is None:
|
||||
requested_attn_implementation = _config_get(config, "attn_implementation", None)
|
||||
|
||||
if requested_attn_implementation == "eager":
|
||||
return _set_attn_impl(config, "eager")
|
||||
|
||||
fallback_attn_implementation = "sdpa" if supports_sdpa else "eager"
|
||||
if (
|
||||
_is_flash_attention_requested(requested_attn_implementation)
|
||||
or would_use_flash_attention
|
||||
):
|
||||
logged_attn_implementation = (
|
||||
requested_attn_implementation
|
||||
if _is_flash_attention_requested(requested_attn_implementation)
|
||||
else "flash_attention_2"
|
||||
)
|
||||
model_type = _config_get(config, "model_type", "")
|
||||
warning_key = (
|
||||
model_type,
|
||||
logged_attn_implementation,
|
||||
fallback_attn_implementation,
|
||||
disable_reason,
|
||||
)
|
||||
if warning_key not in _FLASH_ATTENTION_DISABLED_WARNED:
|
||||
_FLASH_ATTENTION_DISABLED_WARNED.add(warning_key)
|
||||
print(
|
||||
f"Unsloth: `{logged_attn_implementation}` is not supported "
|
||||
f"for `{model_type}` because {disable_reason} - "
|
||||
f"defaulting to `{fallback_attn_implementation}`."
|
||||
)
|
||||
|
||||
return _set_attn_impl(config, fallback_attn_implementation)
|
||||
|
||||
|
||||
def _set_attn_impl(config, impl):
|
||||
"""Helper function to set attention implementation on config and return it."""
|
||||
if config is not None:
|
||||
setattr(config, "_attn_implementation", impl)
|
||||
if hasattr(config, "attn_implementation"):
|
||||
setattr(config, "attn_implementation", impl)
|
||||
_config_set(config, "_attn_implementation", impl)
|
||||
if isinstance(config, dict) or hasattr(config, "attn_implementation"):
|
||||
_config_set(config, "attn_implementation", impl)
|
||||
return impl
|
||||
|
||||
|
||||
def determine_attention_implementation(model_class, config):
|
||||
model_type = getattr(config, "model_type", "").lower()
|
||||
def resolve_model_class(auto_model, config):
|
||||
mapping = getattr(auto_model, "_model_mapping", {})
|
||||
try:
|
||||
result = mapping[config.__class__]
|
||||
except Exception:
|
||||
for config_class, model_class in mapping.items():
|
||||
if isinstance(config, config_class):
|
||||
result = model_class
|
||||
break
|
||||
else:
|
||||
return None
|
||||
|
||||
# Eager-only models (e.g. gemma3n timm vision towers)
|
||||
if _is_eager_only(model_type):
|
||||
_set_attn_impl(config, "eager")
|
||||
return "eager"
|
||||
return result[0] if isinstance(result, (list, tuple)) else result
|
||||
|
||||
# Flash Attention 2
|
||||
if HAS_FLASH_ATTENTION and model_class is not None:
|
||||
supports_fa2 = getattr(model_class, "_supports_flash_attn_2", False) or getattr(
|
||||
model_class, "_supports_flash_attn", False
|
||||
|
||||
def resolve_attention_implementation(
|
||||
model_class,
|
||||
config,
|
||||
requested_attn_implementation = None,
|
||||
supports_sdpa = None,
|
||||
):
|
||||
model_type_name = _config_get(config, "model_type", "")
|
||||
model_type = model_type_name.lower()
|
||||
if supports_sdpa is None:
|
||||
supports_sdpa = model_class is not None and getattr(
|
||||
model_class, "_supports_sdpa", False
|
||||
)
|
||||
if supports_fa2:
|
||||
_set_attn_impl(config, "flash_attention_2")
|
||||
return "flash_attention_2"
|
||||
supports_flash_attention = model_class is not None and (
|
||||
getattr(model_class, "_supports_flash_attn_2", False)
|
||||
or getattr(model_class, "_supports_flash_attn", False)
|
||||
)
|
||||
disable_reason = _get_flash_attention_disable_reason(config)
|
||||
flash_attention_disabled = disable_reason is not None
|
||||
|
||||
# Flex Attention
|
||||
if os.environ.get("UNSLOTH_ENABLE_FLEX_ATTENTION", "1") != "0":
|
||||
try:
|
||||
from transformers.utils.import_utils import is_torch_flex_attn_available
|
||||
if model_class is None:
|
||||
attn_impl = _set_attn_impl(config, "sdpa" if supports_sdpa else "eager")
|
||||
else:
|
||||
if _is_eager_only(model_type):
|
||||
attn_impl = _set_attn_impl(config, "eager")
|
||||
elif flash_attention_disabled:
|
||||
attn_impl = _disable_flash_attention_if_needed(
|
||||
config,
|
||||
supports_sdpa = supports_sdpa,
|
||||
would_use_flash_attention = (
|
||||
HAS_FLASH_ATTENTION and supports_flash_attention
|
||||
),
|
||||
disable_reason = disable_reason,
|
||||
)
|
||||
elif HAS_FLASH_ATTENTION and supports_flash_attention:
|
||||
attn_impl = _set_attn_impl(config, "flash_attention_2")
|
||||
elif supports_sdpa:
|
||||
attn_impl = _set_attn_impl(config, "sdpa")
|
||||
else:
|
||||
attn_impl = "eager"
|
||||
if os.environ.get("UNSLOTH_ENABLE_FLEX_ATTENTION", "1") != "0":
|
||||
try:
|
||||
from transformers.utils.import_utils import (
|
||||
is_torch_flex_attn_available,
|
||||
)
|
||||
|
||||
if (
|
||||
is_torch_flex_attn_available()
|
||||
and model_class is not None
|
||||
and getattr(model_class, "_supports_flex_attn", False)
|
||||
and not _is_flex_excluded(model_type)
|
||||
):
|
||||
attention_dropout = getattr(config, "attention_dropout", 0) or 0
|
||||
if attention_dropout == 0:
|
||||
_set_attn_impl(config, "flex_attention")
|
||||
return "flex_attention"
|
||||
except Exception:
|
||||
pass
|
||||
if (
|
||||
is_torch_flex_attn_available()
|
||||
and getattr(model_class, "_supports_flex_attn", False)
|
||||
and not _is_flex_excluded(model_type)
|
||||
):
|
||||
attention_dropout = (
|
||||
_config_get(config, "attention_dropout", 0) or 0
|
||||
)
|
||||
if attention_dropout == 0:
|
||||
attn_impl = _set_attn_impl(config, "flex_attention")
|
||||
except Exception:
|
||||
pass
|
||||
if attn_impl == "eager":
|
||||
attn_impl = _set_attn_impl(config, "eager")
|
||||
|
||||
# SDPA
|
||||
if model_class is not None and getattr(model_class, "_supports_sdpa", False):
|
||||
_set_attn_impl(config, "sdpa")
|
||||
if requested_attn_implementation is None:
|
||||
final_attn_impl = attn_impl
|
||||
elif flash_attention_disabled:
|
||||
final_attn_impl = _disable_flash_attention_if_needed(
|
||||
config,
|
||||
requested_attn_implementation,
|
||||
supports_sdpa = supports_sdpa,
|
||||
disable_reason = disable_reason,
|
||||
)
|
||||
else:
|
||||
final_attn_impl = requested_attn_implementation
|
||||
_set_attn_impl(config, final_attn_impl)
|
||||
|
||||
if not supports_sdpa and final_attn_impl == "sdpa":
|
||||
print(
|
||||
f"Unsloth: {(model_type_name or 'model').title()} does not support SDPA - switching to fast eager."
|
||||
)
|
||||
final_attn_impl = _set_attn_impl(config, "eager")
|
||||
|
||||
return final_attn_impl
|
||||
|
||||
|
||||
def resolve_encoder_attention_implementation(
|
||||
auto_model,
|
||||
config,
|
||||
model_type = "",
|
||||
disable_sdpa_model_names = (),
|
||||
):
|
||||
model_class = resolve_model_class(auto_model, config)
|
||||
supports_sdpa = model_class is not None and getattr(
|
||||
model_class, "_supports_sdpa", False
|
||||
)
|
||||
if any(name in model_type.lower() for name in disable_sdpa_model_names):
|
||||
return "eager"
|
||||
if supports_sdpa:
|
||||
return "sdpa"
|
||||
|
||||
_set_attn_impl(config, "eager")
|
||||
return "eager"
|
||||
return None
|
||||
|
||||
|
||||
def _run_temporary_patches(phase):
|
||||
|
|
@ -2083,47 +2312,148 @@ def patch_gradient_accumulation_fix(Trainer):
|
|||
exec(function, globals())
|
||||
Trainer.training_step = _unsloth_training_step
|
||||
|
||||
# Prevent double scaling gradient accumulation
|
||||
# https://github.com/huggingface/transformers/pull/37208
|
||||
# Patch model_accepts_loss_kwargs detection in Trainer.__init__
|
||||
if Trainer.__init__.__name__ != "_unsloth___init__":
|
||||
# Wrap Trainer.__init__: (1) pre-init, shadow accepts_loss_kwargs on whatever
|
||||
# model was passed in (covers PEFT wrapping done after FastModel.from_pretrained);
|
||||
# (2) post-init, clamp accelerator GA to 1 for the transformers 5.0-5.5
|
||||
# GradientAccumulationPlugin regression. No-op on 4.x and 5.6+. See #4982.
|
||||
if not getattr(Trainer, "_unsloth_init_wrapped_for_accelerate_gas", False):
|
||||
_original_trainer_init = Trainer.__init__
|
||||
|
||||
def _unsloth_trainer_init(self, *args, **kwargs):
|
||||
model = kwargs.get("model")
|
||||
if model is None and len(args) > 0:
|
||||
model = args[0]
|
||||
if model is not None:
|
||||
try:
|
||||
apply_accepts_loss_kwargs_fix(model)
|
||||
except Exception:
|
||||
pass
|
||||
_original_trainer_init(self, *args, **kwargs)
|
||||
try:
|
||||
accelerator = getattr(self, "accelerator", None)
|
||||
if (
|
||||
accelerator is not None
|
||||
and getattr(accelerator, "gradient_accumulation_steps", 1) > 1
|
||||
):
|
||||
accelerator.gradient_accumulation_steps = 1
|
||||
gs = getattr(accelerator, "gradient_state", None)
|
||||
if gs is not None and hasattr(gs, "plugin_kwargs"):
|
||||
try:
|
||||
gs.plugin_kwargs["num_steps"] = 1
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_unsloth_trainer_init.__wrapped__ = _original_trainer_init
|
||||
Trainer.__init__ = _unsloth_trainer_init
|
||||
Trainer._unsloth_init_wrapped_for_accelerate_gas = True
|
||||
|
||||
|
||||
def _unsloth_compile_cache_leaves():
|
||||
# Accepts `UNSLOTH_COMPILE_LOCATION` overrides (the env var unsloth_zoo honors).
|
||||
leaves = {"unsloth_compiled_cache", "unsloth_cache", "unsloth_compiled"}
|
||||
loc = os.environ.get("UNSLOTH_COMPILE_LOCATION", "") or ""
|
||||
loc = loc.rstrip("/\\")
|
||||
if loc:
|
||||
leaves.add(os.path.basename(loc) or loc)
|
||||
return leaves
|
||||
|
||||
|
||||
def _forward_is_unsloth_compiled(model):
|
||||
# True iff forward was installed from the Unsloth compile cache directory.
|
||||
# __module__ stays as the transformers module, so check co_filename.
|
||||
leaves = _unsloth_compile_cache_leaves()
|
||||
|
||||
def check(m):
|
||||
if m is None:
|
||||
return False
|
||||
fwd = getattr(type(m), "forward", None)
|
||||
if fwd is None:
|
||||
return False
|
||||
code = getattr(fwd, "__code__", None)
|
||||
fn = getattr(code, "co_filename", "") if code is not None else ""
|
||||
fn = fn.replace("\\", "/")
|
||||
parts = set(fn.split("/"))
|
||||
return any(leaf in parts for leaf in leaves)
|
||||
|
||||
if check(model):
|
||||
return True
|
||||
seen = set()
|
||||
m = model
|
||||
for _ in range(4):
|
||||
if m is None or id(m) in seen:
|
||||
break
|
||||
seen.add(id(m))
|
||||
nxt = getattr(m, "base_model", None)
|
||||
if nxt is None or nxt is m:
|
||||
nxt = getattr(m, "model", None)
|
||||
if nxt is None or nxt is m:
|
||||
break
|
||||
if check(nxt):
|
||||
return True
|
||||
m = nxt
|
||||
return False
|
||||
|
||||
|
||||
def _find_concrete_accepts_loss_kwargs(model):
|
||||
# Walk wrapper chain for first class that declares accepts_loss_kwargs in its
|
||||
# own __mro__ dict. Avoids PEFT __getattr__ forwarding and our own shadow.
|
||||
seen = set()
|
||||
m = model
|
||||
for _ in range(6):
|
||||
if m is None or id(m) in seen:
|
||||
break
|
||||
seen.add(id(m))
|
||||
for klass in type(m).__mro__:
|
||||
if "accepts_loss_kwargs" in klass.__dict__:
|
||||
return klass.__dict__[
|
||||
"accepts_loss_kwargs"
|
||||
], f"{klass.__name__}.accepts_loss_kwargs"
|
||||
nxt = getattr(m, "base_model", None)
|
||||
if nxt is None or nxt is m:
|
||||
nxt = getattr(m, "model", None)
|
||||
if nxt is None or nxt is m:
|
||||
break
|
||||
m = nxt
|
||||
return None, "no explicit accepts_loss_kwargs on any wrapper level"
|
||||
|
||||
|
||||
def _shadow_accepts_loss_kwargs(model, value):
|
||||
# Set the attribute at every wrapper level so HF's hasattr check resolves
|
||||
# regardless of where accelerator / peft unwrap lands.
|
||||
seen = set()
|
||||
m = model
|
||||
for _ in range(8):
|
||||
if m is None or id(m) in seen:
|
||||
break
|
||||
seen.add(id(m))
|
||||
try:
|
||||
init_function = inspect.getsource(Trainer.__init__)
|
||||
setattr(m, "accepts_loss_kwargs", value)
|
||||
except Exception:
|
||||
init_function = ""
|
||||
if init_function is not None:
|
||||
init_function = textwrap.dedent(init_function)
|
||||
pass
|
||||
nxt = getattr(m, "base_model", None)
|
||||
if nxt is None or nxt is m:
|
||||
nxt = getattr(m, "model", None)
|
||||
if nxt is None or nxt is m:
|
||||
break
|
||||
m = nxt
|
||||
|
||||
# Import all variables that need importing
|
||||
import transformers.trainer
|
||||
|
||||
items_in_trainer = dir(transformers.trainer)
|
||||
good_items = []
|
||||
for item in items_in_trainer:
|
||||
if item in init_function:
|
||||
good_items.append(item)
|
||||
exec(
|
||||
"from transformers.trainer import ("
|
||||
+ ", ".join(x for x in good_items)
|
||||
+ ")",
|
||||
globals(),
|
||||
)
|
||||
def apply_accepts_loss_kwargs_fix(model):
|
||||
# Shadow the correct accepts_loss_kwargs on the model so HF Trainer picks it
|
||||
# up via hasattr(unwrapped_model, ...). Replaces the old Trainer.__init__
|
||||
# source rewrite. Priority: compiled forward -> True; else first class attr
|
||||
# in wrapper chain; else leave HF default. Issue #4982.
|
||||
if _forward_is_unsloth_compiled(model):
|
||||
_shadow_accepts_loss_kwargs(model, True)
|
||||
return "True (Unsloth compiled forward)"
|
||||
|
||||
init_function = init_function.replace(
|
||||
"def __init__", "def _unsloth___init__", 1
|
||||
)
|
||||
|
||||
# Respect an inner wrapped model's explicit accepts_loss_kwargs flag before inferring from forward(**kwargs).
|
||||
# https://github.com/unslothai/unsloth/issues/4982 Gemma4ForConditionalGeneration had issues with grad_acc
|
||||
init_function = init_function.replace(
|
||||
"self.model_accepts_loss_kwargs = unwrapped_model.accepts_loss_kwargs\n else:",
|
||||
"self.model_accepts_loss_kwargs = unwrapped_model.accepts_loss_kwargs\n"
|
||||
' elif hasattr(getattr(unwrapped_model, "model", None), "accepts_loss_kwargs"):\n'
|
||||
" self.model_accepts_loss_kwargs = unwrapped_model.model.accepts_loss_kwargs\n"
|
||||
" else:",
|
||||
)
|
||||
exec(init_function, globals())
|
||||
Trainer.__init__ = _unsloth___init__
|
||||
value, reason = _find_concrete_accepts_loss_kwargs(model)
|
||||
if value is None:
|
||||
return f"default (signature inspection, {reason})"
|
||||
_shadow_accepts_loss_kwargs(model, value)
|
||||
return f"{value} ({reason})"
|
||||
|
||||
|
||||
def patch_tokenizer(model, tokenizer):
|
||||
|
|
@ -2625,6 +2955,55 @@ def _prepare_model_for_qat(
|
|||
qat_scheme = qat_scheme,
|
||||
base_config_and_filter_fns = [(base_config, filter_fn)],
|
||||
)
|
||||
elif qat_scheme == "cactus":
|
||||
try:
|
||||
from torchao.quantization import IntxWeightOnlyConfig
|
||||
except ImportError:
|
||||
raise ImportError(TORCHAO_MSG)
|
||||
|
||||
# IntxWeightOnlyConfig already defaults to
|
||||
# `mapping_type = MappingType.SYMMETRIC`, so we intentionally do not
|
||||
# import `MappingType` here. Matches the upstream Cactus runtime
|
||||
# int8 / per-group-32 / symmetric weight-only configuration.
|
||||
group_size = 32
|
||||
base_config = IntxWeightOnlyConfig(
|
||||
weight_dtype = torch.int8,
|
||||
granularity = PerGroup(group_size),
|
||||
)
|
||||
filter_fn = (
|
||||
lambda m, _: isinstance(m, torch.nn.Linear)
|
||||
and m.in_features >= group_size
|
||||
and m.in_features % group_size == 0
|
||||
)
|
||||
# Warn if any Linear layer is skipped by the cactus filter because
|
||||
# its in_features is not divisible by `group_size`. torchao's
|
||||
# PerGroup(32) quantizer rejects non-divisible widths at
|
||||
# `quantize_()` time, so the filter excludes those layers to keep
|
||||
# the QAT prepare step from crashing. Surface that silently-skipped
|
||||
# coverage gap to the user so they know some Linears will stay in
|
||||
# full precision during training.
|
||||
skipped_cactus_layers = [
|
||||
name
|
||||
for name, module in model.named_modules()
|
||||
if isinstance(module, torch.nn.Linear)
|
||||
and module.in_features >= group_size
|
||||
and module.in_features % group_size != 0
|
||||
]
|
||||
if skipped_cactus_layers:
|
||||
preview = ", ".join(skipped_cactus_layers[:8])
|
||||
if len(skipped_cactus_layers) > 8:
|
||||
preview += f", ... ({len(skipped_cactus_layers) - 8} more)"
|
||||
warnings.warn(
|
||||
f"Unsloth: qat_scheme='cactus' uses PerGroup({group_size}) "
|
||||
"which requires in_features to be divisible by "
|
||||
f"{group_size}. The following Linear layers will be kept "
|
||||
f"in full precision during QAT: {preview}",
|
||||
stacklevel = 2,
|
||||
)
|
||||
torchao_config = TorchAOConfig(
|
||||
qat_scheme = qat_scheme,
|
||||
base_config_and_filter_fns = [(base_config, filter_fn)],
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unexpected QAT scheme {qat_scheme}")
|
||||
assert torchao_config is not None, f"TorchAOConfig was not set for {qat_scheme}"
|
||||
|
|
|
|||
|
|
@ -2346,7 +2346,7 @@ class FastLlamaModel:
|
|||
model_function = MODEL_FOR_CAUSAL_LM_MAPPING[model_config.__class__]
|
||||
IS_FALCON_H1 = model_config.model_type.startswith("falcon_h1")
|
||||
|
||||
preferred_attn_impl = determine_attention_implementation(
|
||||
preferred_attn_impl = resolve_attention_implementation(
|
||||
model_function, model_config
|
||||
)
|
||||
|
||||
|
|
@ -2695,7 +2695,8 @@ class FastLlamaModel:
|
|||
patch_saving_functions(model)
|
||||
Trainer._inner_training_loop = _fast_inner_training_loop
|
||||
|
||||
# Fix gradient accumulation
|
||||
# Fix gradient accumulation. See issue #4982.
|
||||
apply_accepts_loss_kwargs_fix(model)
|
||||
patch_gradient_accumulation_fix(Trainer)
|
||||
|
||||
# Save tokenizer for inference purposes
|
||||
|
|
|
|||
|
|
@ -1151,9 +1151,6 @@ class FastModel(FastBaseModel):
|
|||
)
|
||||
os.environ["UNSLOTH_DISABLE_STATIC_GENERATION"] = "1"
|
||||
os.environ["UNSLOTH_HIGH_PRECISION_LAYERNORM"] = "1"
|
||||
# Disable flex_attention for Gemma-4: flex compile overhead is 2.7x slower
|
||||
# than SDPA. Our attention patch ensures Q/K/V dtype alignment for SDPA.
|
||||
os.environ["UNSLOTH_ENABLE_FLEX_ATTENTION"] = "0"
|
||||
# Gemma 3N must be before Gemma 3
|
||||
elif "gemma3n" in model_types_all:
|
||||
if transformers_version < Version("4.53.0"):
|
||||
|
|
@ -1210,13 +1207,20 @@ class FastModel(FastBaseModel):
|
|||
# Granite-4 rms norms are stored as 16 bit, but we upcast
|
||||
os.environ["UNSLOTH_HIGH_PRECISION_LAYERNORM"] = "1"
|
||||
os.environ["UNSLOTH_DISABLE_STATIC_GENERATION"] = "1"
|
||||
# Olmo 2
|
||||
# OLMo 2
|
||||
elif "olmo2" in model_types_all and transformers_version < Version(
|
||||
"4.50.0.dev0"
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Unsloth: OLMo-2 only works on transformers >= 4.50.0." + NIGHTLY
|
||||
)
|
||||
# OLMo 3
|
||||
elif "olmo3" in model_types_all and transformers_version < Version(
|
||||
"4.57.0.dev0"
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Unsloth: OLMo-3 only works on transformers >= 4.57.0." + LATEST
|
||||
)
|
||||
elif "falcon_h1" in model_types_all:
|
||||
# Falcon must use float32 Triton ie TRITON_F32_DEFAULT = 'ieee'
|
||||
# since Mamba kernels error out on using lower precision
|
||||
|
|
|
|||
|
|
@ -22,6 +22,39 @@ __all__ = [
|
|||
|
||||
__INT_TO_FLOAT_MAPPER = \
|
||||
{
|
||||
"unsloth/gemma-4-E2B-it-unsloth-bnb-4bit" : (
|
||||
"unsloth/gemma-4-E2B-it",
|
||||
"google/gemma-4-E2B-it",
|
||||
),
|
||||
"unsloth/gemma-4-E4B-it-unsloth-bnb-4bit" : (
|
||||
"unsloth/gemma-4-E4B-it",
|
||||
"google/gemma-4-E4B-it",
|
||||
),
|
||||
"unsloth/gemma-4-31B-it-unsloth-bnb-4bit" : (
|
||||
"unsloth/gemma-4-31B-it",
|
||||
"google/gemma-4-31B-it",
|
||||
),
|
||||
"unsloth/gemma-4-26B-A4B-it" : (
|
||||
"unsloth/gemma-4-26B-A4B-it",
|
||||
"google/gemma-4-26B-A4B-it",
|
||||
),
|
||||
"unsloth/gemma-4-E2B-unsloth-bnb-4bit" : (
|
||||
"unsloth/gemma-4-E2B",
|
||||
"google/gemma-4-E2B",
|
||||
),
|
||||
"unsloth/gemma-4-E4B-unsloth-bnb-4bit" : (
|
||||
"unsloth/gemma-4-E4B",
|
||||
"google/gemma-4-E4B",
|
||||
),
|
||||
"unsloth/gemma-4-31B-unsloth-bnb-4bit" : (
|
||||
"unsloth/gemma-4-31B",
|
||||
"google/gemma-4-31B",
|
||||
),
|
||||
"unsloth/LFM2-1.2B-unsloth-bnb-4bit" : (
|
||||
"unsloth/LFM2-1.2B",
|
||||
"LiquidAI/LFM2-1.2B",
|
||||
),
|
||||
|
||||
"unsloth/mistral-7b-bnb-4bit" : (
|
||||
"unsloth/mistral-7b",
|
||||
"mistralai/Mistral-7B-v0.1",
|
||||
|
|
@ -762,6 +795,18 @@ __INT_TO_FLOAT_MAPPER = \
|
|||
"allenai/OLMo-2-0325-32B-Instruct",
|
||||
"unsloth/OLMo-2-0325-32B-Instruct-bnb-4bit",
|
||||
),
|
||||
"unsloth/Olmo-3-7B-Instruct-unsloth-bnb-4bit" : (
|
||||
"unsloth/Olmo-3-7B-Instruct",
|
||||
"allenai/Olmo-3-7B-Instruct",
|
||||
),
|
||||
"unsloth/Olmo-3-7B-Think-unsloth-bnb-4bit" : (
|
||||
"unsloth/Olmo-3-7B-Think",
|
||||
"allenai/Olmo-3-7B-Think",
|
||||
),
|
||||
"unsloth/Olmo-3-32B-Think-unsloth-bnb-4bit" : (
|
||||
"unsloth/Olmo-3-32B-Think",
|
||||
"allenai/Olmo-3-32B-Think",
|
||||
),
|
||||
"unsloth/Mistral-Small-3.1-24B-Instruct-2503-unsloth-bnb-4bit" : (
|
||||
"unsloth/Mistral-Small-3.1-24B-Instruct-2503",
|
||||
"mistralai/Mistral-Small-3.1-24B-Instruct-2503",
|
||||
|
|
@ -1416,3 +1461,6 @@ for key, values in __INT_TO_FLOAT_MAPPER.items():
|
|||
|
||||
for value in values:
|
||||
FLOAT_TO_INT_MAPPER[value.lower()] = lowered_key
|
||||
|
||||
_add_with_lower(MAP_TO_UNSLOTH_16bit, "google/gemma-4-26B-A4B", "unsloth/gemma-4-26B-A4B")
|
||||
_add_with_lower(MAP_TO_UNSLOTH_16bit, "LiquidAI/LFM2.5-1.2B-Instruct", "unsloth/LFM2.5-1.2B-Instruct")
|
||||
|
|
|
|||
|
|
@ -2018,11 +2018,12 @@ def patch_trl_disable_gradient_checkpointing():
|
|||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
logger.warning_once(
|
||||
"Unsloth: Patched trl.models.utils.disable_gradient_checkpointing with "
|
||||
"a no-op to preserve Unsloth gradient checkpointing across TRL "
|
||||
"generation passes."
|
||||
)
|
||||
if os.environ.get("UNSLOTH_ENABLE_LOGGING", "0") == "1":
|
||||
logger.warning_once(
|
||||
"Unsloth: Patched trl.models.utils.disable_gradient_checkpointing with "
|
||||
"a no-op to preserve Unsloth gradient checkpointing across TRL "
|
||||
"generation passes."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1154,6 +1154,7 @@ def grpo_trainer_compute_loss(function_name, function):
|
|||
ref_logps,
|
||||
per_token_logps,
|
||||
old_logps,
|
||||
sampling_per_token_logps,
|
||||
input_ids,
|
||||
completion_mask,
|
||||
self.beta,
|
||||
|
|
@ -1174,7 +1175,6 @@ def grpo_trainer_compute_loss(function_name, function):
|
|||
num_items_in_batch = num_items_in_batch,
|
||||
current_gradient_accumulation_steps = current_gradient_accumulation_steps,
|
||||
num_processes = num_processes,
|
||||
sampling_per_token_logps = sampling_per_token_logps,
|
||||
)
|
||||
else:
|
||||
if hasattr(self.args, "loss_type"):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -607,37 +613,18 @@ class FastBaseModel:
|
|||
token = token,
|
||||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
try:
|
||||
model_class = auto_model._model_mapping[auto_config.__class__]
|
||||
except Exception:
|
||||
model_class = None
|
||||
if model_class is None:
|
||||
# When model_class cannot be resolved (remote-code or unmapped
|
||||
# configs), preserve the old fallback of sdpa when supported.
|
||||
attn_impl = _set_attn_impl(
|
||||
auto_config, "sdpa" if supports_sdpa else "eager"
|
||||
)
|
||||
else:
|
||||
attn_impl = determine_attention_implementation(model_class, auto_config)
|
||||
model_class = resolve_model_class(auto_model, auto_config)
|
||||
attn_impl = resolve_attention_implementation(
|
||||
model_class,
|
||||
auto_config,
|
||||
requested_attn_implementation = kwargs.get("attn_implementation", None),
|
||||
supports_sdpa = supports_sdpa,
|
||||
)
|
||||
|
||||
# Handle FP8 models: get_model_name has already redirected this to BF16 sibling if the model ships with
|
||||
# FP8 weights. We just need to update it here for sanity.
|
||||
auto_config.model_name = model_name
|
||||
# Re-resolve model_class after potential config change
|
||||
try:
|
||||
model_class = auto_model._model_mapping[auto_config.__class__]
|
||||
except Exception:
|
||||
model_class = None
|
||||
|
||||
if not ("attn_implementation" in kwargs):
|
||||
kwargs["attn_implementation"] = attn_impl
|
||||
if not supports_sdpa and kwargs.get("attn_implementation") == "sdpa":
|
||||
print(
|
||||
f"Unsloth: {model_type_arch.title()} does not support SDPA - switching to fast eager."
|
||||
)
|
||||
del kwargs["attn_implementation"]
|
||||
# Re-stamp config so it stays consistent with the actual impl
|
||||
_set_attn_impl(auto_config, "eager")
|
||||
kwargs["attn_implementation"] = attn_impl
|
||||
|
||||
bnb_config = None
|
||||
user_quantization_config = kwargs.get("quantization_config", None)
|
||||
|
|
@ -780,9 +767,7 @@ class FastBaseModel:
|
|||
token = token,
|
||||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
setattr(auto_config, "_attn_implementation", config_attn_impl)
|
||||
if hasattr(auto_config, "attn_implementation"):
|
||||
setattr(auto_config, "attn_implementation", config_attn_impl)
|
||||
_set_attn_impl(auto_config, config_attn_impl)
|
||||
model_config = auto_config
|
||||
|
||||
verify_fp8_support_if_applicable(model_config)
|
||||
|
|
@ -1123,9 +1108,10 @@ class FastBaseModel:
|
|||
)
|
||||
patch_saving_functions(tokenizer, vision = True)
|
||||
|
||||
# Fix gradient accumulation
|
||||
# Fix gradient accumulation. See issue #4982.
|
||||
from transformers.trainer import Trainer
|
||||
|
||||
apply_accepts_loss_kwargs_fix(model)
|
||||
patch_gradient_accumulation_fix(Trainer)
|
||||
|
||||
# Save tokenizer for inference purposes
|
||||
|
|
|
|||
|
|
@ -1978,12 +1978,19 @@ OLLAMA_TEMPLATE_TO_MODEL_MAPPER = {
|
|||
),
|
||||
"gemma4": (
|
||||
"unsloth/gemma-4-E2B-it",
|
||||
"unsloth/gemma-4-E2B-it-unsloth-bnb-4bit",
|
||||
"google/gemma-4-E2B-it",
|
||||
"unsloth/gemma-4-E2B",
|
||||
"unsloth/gemma-4-E4B-it",
|
||||
"unsloth/gemma-4-E4B-it-unsloth-bnb-4bit",
|
||||
"google/gemma-4-E4B-it",
|
||||
"unsloth/gemma-4-E4B",
|
||||
"unsloth/gemma-4-31B-it",
|
||||
"unsloth/gemma-4-31B-it-unsloth-bnb-4bit",
|
||||
"google/gemma-4-31B-it",
|
||||
"unsloth/gemma-4-31B",
|
||||
"unsloth/gemma-4-26B-A4B-it",
|
||||
"google/gemma-4-26B-A4B-it",
|
||||
"unsloth/gemma-4-26B-A4B",
|
||||
),
|
||||
"gemma3n": (
|
||||
|
|
|
|||
|
|
@ -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