Merge branch 'main' into image-generation

This commit is contained in:
Lee Jackson 2026-06-25 13:03:13 +01:00 committed by GitHub
commit fd7dea55a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1483 additions and 361 deletions

View file

@ -353,7 +353,7 @@ jobs:
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
# ── Node.js ──
- name: Setup Node.js
@ -406,9 +406,24 @@ jobs:
if (config.bundle?.linux?.rpm) {
throw new Error('bundle.linux.rpm must not be configured');
}
if (config.bundle?.linux?.appimage?.bundleMediaFramework !== false) {
throw new Error('Linux AppImage bundleMediaFramework must stay false');
}
const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8');
const lines = workflow.split(/\r?\n/);
const linuxInstallLines = lines.filter((line) => line.includes('sudo apt-get install'));
const ayatanaPackage = ['libayatana', 'appindicator3-dev'].join('-');
if (linuxInstallLines.some((line) => line.includes(ayatanaPackage))) {
throw new Error('Desktop Linux release must not install the Ayatana appindicator dev package');
}
if (!linuxInstallLines.some((line) => line.includes('libappindicator3-dev'))) {
throw new Error('Desktop Linux release must install libappindicator3-dev');
}
const linuxdeployLines = lines.filter((line) => line.includes('github.com/linuxdeploy/linuxdeploy/releases/download'));
if (!linuxdeployLines.some((line) => line.includes('1-alpha-20250213-2/linuxdeploy-x86_64.AppImage'))) {
throw new Error('Desktop Linux release must pin linuxdeploy 1-alpha-20250213-2');
}
const releaseBodies = [];
for (let i = 0; i < lines.length; i += 1) {
const match = lines[i].match(/^(\s*)releaseBody:\s*\|\s*$/);
@ -438,6 +453,12 @@ jobs:
if (/\brpm\b|\.rpm/i.test(body)) {
throw new Error('Desktop release body must not advertise RPM packages');
}
if (/AppImage.*universal|universal.*AppImage/i.test(body)) {
throw new Error('Desktop release body must not advertise AppImage as universal');
}
if (!/AppImage.*experimental/i.test(body)) {
throw new Error('Desktop release body must mark AppImage as experimental');
}
}
JS
@ -562,6 +583,19 @@ jobs:
Get-Command trusted-signing-cli -ErrorAction SilentlyContinue || Write-Output "trusted-signing-cli NOT in PATH"
trusted-signing-cli --version || Write-Output "trusted-signing-cli failed to run"
# ── Linux: pin AppImage packaging toolchain ──
- name: Pin linuxdeploy for AppImage
if: matrix.platform == 'ubuntu-22.04'
shell: bash
run: |
set -euo pipefail
tools_dir="$RUNNER_TEMP/tauri-tools-cache/tauri"
mkdir -p "$tools_dir"
curl -fsSL \
"https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20250213-2/linuxdeploy-x86_64.AppImage" \
-o "$tools_dir/linuxdeploy-x86_64.AppImage"
chmod +x "$tools_dir/linuxdeploy-x86_64.AppImage"
# ── Linux: build + sign + upload ──
- name: Build Linux app
if: matrix.platform == 'ubuntu-22.04'
@ -570,6 +604,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
XDG_CACHE_HOME: ${{ runner.temp }}/tauri-tools-cache
with:
projectPath: studio
tauriScript: npx --prefix . tauri
@ -580,9 +615,10 @@ jobs:
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}
@ -611,9 +647,10 @@ jobs:
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}
@ -643,9 +680,10 @@ jobs:
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}

View file

@ -47,7 +47,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \
libwebkit2gtk-4.1-dev libappindicator3-dev \
librsvg2-dev libxdo-dev libssl-dev patchelf
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0

View file

@ -246,6 +246,15 @@ curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_STUDIO_HOME=/abs/path sh
$env:UNSLOTH_STUDIO_HOME='C:\path'; irm https://unsloth.ai/install.ps1 | iex
```
Point the frontend build at a corporate npm mirror/proxy with `UNSLOTH_NPM_REGISTRY` (for the developer install behind a firewall that blocks `registry.npmjs.org`):
```bash
UNSLOTH_NPM_REGISTRY=https://artifactory.example.com/api/npm/npm/ ./install.sh --local
```
```powershell
$env:UNSLOTH_NPM_REGISTRY='https://artifactory.example.com/api/npm/npm/'; .\install.ps1 --local
```
It is threaded as `--registry` into the Studio frontend `npm`/`bun` installs; the supply-chain locks (7-day `min-release-age`, exact version pins) stay in force.
Cap Studio's native CPU thread pools on high-core hosts: `UNSLOTH_CPU_THREADS=8 unsloth studio -p 8888`.
#### Uninstall

View file

@ -35,10 +35,19 @@ _restore_gitignores() {
}
trap _restore_gitignores EXIT
# Corporate-mirror / proxy escape hatch (#6491). When UNSLOTH_NPM_REGISTRY is set we
# thread it as `--registry <url>` into the installs (overrides frontend/.npmrc's pinned
# registry for both bun and npm; min-release-age / save-exact stay in force). Empty
# array (the default) expands to nothing under `set -u`.
_NPM_REGISTRY_ARGS=()
if [ -n "${UNSLOTH_NPM_REGISTRY:-}" ]; then
_NPM_REGISTRY_ARGS=(--registry "$UNSLOTH_NPM_REGISTRY")
fi
# Use bun for install if available (faster), fall back to npm.
_install_ok=false
if command -v bun &>/dev/null; then
if bun install; then
if bun install "${_NPM_REGISTRY_ARGS[@]+"${_NPM_REGISTRY_ARGS[@]}"}"; then
_install_ok=true
else
echo "⚠ bun install failed, falling back to npm"
@ -46,8 +55,10 @@ if command -v bun &>/dev/null; then
fi
fi
if [ "$_install_ok" != "true" ]; then
if ! npm install; then
if ! npm install "${_NPM_REGISTRY_ARGS[@]+"${_NPM_REGISTRY_ARGS[@]}"}"; then
echo "❌ ERROR: package install failed" >&2
echo " If you are behind a corporate firewall/proxy, set UNSLOTH_NPM_REGISTRY to your mirror and retry, e.g.:" >&2
echo " UNSLOTH_NPM_REGISTRY=https://your-mirror.example/api/npm/ ./build.sh" >&2
exit 1
fi
fi

View file

@ -447,8 +447,12 @@ _on_install_exit() {
if [ "$_status" -ne 0 ]; then
_restore_studio_venv_replacement
fi
[ -n "${_UV_OVERRIDE_TMPDIR:-}" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true
exit "$_status"
}
# Empty so an inherited value can never reach the trap's rm; only a temp dir
# this script creates below (Apple Silicon, spaced path) is ever removed.
_UV_OVERRIDE_TMPDIR=""
trap _on_install_exit EXIT
# ── Helper: download a URL to a file (supports curl and wget) ──
@ -1427,6 +1431,25 @@ fi
if [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
_OVERRIDES_FILE="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)/studio/backend/requirements/single-env/overrides-darwin-arm64.txt"
if [ -f "$_OVERRIDES_FILE" ]; then
# uv splits UV_OVERRIDE on whitespace, so a repo path with whitespace
# truncates it and aborts every later uv call (issue #6503). Hand uv a copy.
case "$_OVERRIDES_FILE" in
*[[:space:]]*)
_UV_OVERRIDE_TMPDIR=$(mktemp -d 2>/dev/null) || _UV_OVERRIDE_TMPDIR=""
case "$_UV_OVERRIDE_TMPDIR" in
"") ;;
*[[:space:]]*) rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true; _UV_OVERRIDE_TMPDIR="" ;;
*)
if cp "$_OVERRIDES_FILE" "$_UV_OVERRIDE_TMPDIR/overrides-darwin-arm64.txt" 2>/dev/null; then
_OVERRIDES_FILE="$_UV_OVERRIDE_TMPDIR/overrides-darwin-arm64.txt"
else
rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true
_UV_OVERRIDE_TMPDIR=""
fi
;;
esac
;;
esac
export UV_OVERRIDE="$_OVERRIDES_FILE"
fi
fi

View file

@ -3543,9 +3543,12 @@ class UnslothTrainer:
# ── Safety net: check if all samples were filtered out ──
# train_on_responses_only masks non-response tokens with -100;
# if max_seq_length is too short the response is truncated away,
# every sample becomes all -100, and Unsloth drops them, leaving
# 0 usable samples. Skip this len()-based check for streaming.
# a row becomes all -100 (and Unsloth drops it) when the response
# template is not found in the formatted text. That is usually a
# dataset/template mismatch (already-formatted data, or 'Train on
# completions' applied to data that doesn't match the model's chat
# template), and only sometimes max_seq_length truncating the
# response away. Skip this len()-based check for streaming.
if detect_streaming_dataset(self.trainer.train_dataset):
logger.info("Skipping post-filter length check for streaming dataset\n")
else:
@ -3560,13 +3563,18 @@ class UnslothTrainer:
if filtered_len == 0 or drop_pct > 30:
max_seq = training_args.get("max_seq_length", 2048)
error_msg = (
f"{dropped}/{original_len} samples ({drop_pct}%) "
f"were dropped after applying 'train on responses "
f"only' — only {filtered_len} remain. This usually "
f"means max_seq_length ({max_seq}) is too short "
f"and the response portion is being truncated "
f"away. Try increasing max_seq_length (e.g. 8192) "
f"or disabling 'Train on completions'."
f"{dropped}/{original_len} samples ({drop_pct}%) were "
f"dropped after applying 'Train on completions': after "
f"masking, those rows had no trainable response tokens "
f"left. The usual cause is that this model's response "
f"template was not found in the formatted samples, so "
f"every token was masked out. That typically means the "
f"dataset is already formatted, or its structure does "
f"not match the model's chat template, so 'Train on "
f"completions' should be turned off for this dataset. "
f"Less commonly, a max_seq_length ({max_seq}) shorter "
f"than the prompt can truncate the response away; only "
f"raise it if your samples are actually longer than that."
)
logger.error(error_msg)
self._update_progress(error = error_msg, is_training = False)

View file

@ -6,6 +6,7 @@
from __future__ import annotations
import asyncio
import errno
from pathlib import Path
from typing import Optional
@ -15,7 +16,7 @@ from loggers import get_logger
from hub.utils import download_manifest
from hub.utils import download_registry
from hub.utils import inventory_scan as hf_cache_scan
from hub.utils.gguf import extract_quant_label
from hub.utils.gguf import extract_quant_label, extract_quant_token
from hub.utils.hf_cache_state import (
INCOMPLETE_SUFFIX,
purge_partial_repo,
@ -106,6 +107,52 @@ def _has_remaining_main_gguf(target_repo) -> bool:
)
def _remove_empty_variant_dirs(target_repos: list, variant: str) -> tuple[int, list[str]]:
"""Remove now-empty ``snapshots/<rev>/<quant>/`` folders for *variant* (the
quant label names the folder); only empty dirs go, so siblings are safe.
Returns (count removed, removal failures other than a concurrent refill)."""
variant_key = (extract_quant_token(variant) or variant).lower()
removed = 0
failures: list[str] = []
for target_repo in target_repos:
repo_path = getattr(target_repo, "repo_path", None)
if not repo_path:
continue
snapshots = Path(repo_path) / "snapshots"
if not snapshots.is_dir():
continue
try:
snap_dirs = [s for s in snapshots.iterdir() if s.is_dir() and not s.is_symlink()]
except OSError:
continue
for snap in snap_dirs:
try:
subs = list(snap.iterdir())
except OSError:
continue
for sub in subs:
try:
if sub.is_symlink() or not sub.is_dir():
continue
folder_quant = extract_quant_token(sub.name)
matches = (
folder_quant is not None and folder_quant.lower() == variant_key
) or sub.name.lower() == variant.lower()
if not matches or any(sub.iterdir()):
continue
except OSError:
continue
try:
sub.rmdir()
removed += 1
except OSError as e:
# A concurrent download refilling the dir (ENOTEMPTY) is not a
# failure; a read-only cache or locked dir is, so surface it.
if e.errno != errno.ENOTEMPTY:
failures.append(f"{sub.name}: {e}")
return removed, failures
def _delete_gguf_variant_from_repos(
repo_id: str,
variant: str,
@ -206,11 +253,23 @@ def _delete_gguf_variant_from_repos(
)
state_purged = download_manifest.purge_state("model", repo_id, variant)
# Reclaim the empty quant folder so it stops 404ing on delete.
removed_dirs, dir_failures = _remove_empty_variant_dirs(target_repos, variant)
if dir_failures:
raise HTTPException(
status_code = 409,
detail = (
f"Couldn't fully delete {variant} for {repo_id}: "
f"{len(dir_failures)} folder(s) could not be removed "
"(read-only cache or in use). Try again."
),
)
if (
removed_snapshots == 0
and deleted_blobs == 0
and incomplete_result.deleted == 0
and not state_purged
and removed_dirs == 0
):
raise HTTPException(
status_code = 404,

View file

@ -27,6 +27,7 @@ from hub.utils.gguf import (
extract_quant_label,
iter_hf_cache_snapshots,
is_big_endian_gguf_path,
list_empty_gguf_variant_dirs,
list_gguf_variants,
list_gguf_variants_from_hf_cache,
list_local_gguf_variants,
@ -334,6 +335,32 @@ def delete_variant_incomplete_blobs_result(
return VariantIncompleteDeleteResult(deleted = deleted, unresolved = False)
def _mark_empty_dir_cleanables(
repo_id: str, response: GgufVariantsResponse
) -> GgufVariantsResponse:
"""Surface empty leftover ``<quant>/`` folders (interrupted downloads) as
partial so the UI can delete them -- on local/offline paths too, not just a
remote listing. A listed quant is flipped to partial; an unlisted one is
appended as a zero-byte cleanable entry."""
try:
empty_labels = list_empty_gguf_variant_dirs(repo_id)
except Exception as e:
logger.warning(f"Failed to scan empty GGUF variant folders for {repo_id}: {e}")
return response
if not empty_labels:
return response
empty_by_key = {label.lower(): label for label in empty_labels}
variants = list(response.variants)
listed = {v.quant.lower() for v in variants}
for i, v in enumerate(variants):
if v.quant.lower() in empty_by_key and not v.downloaded and not v.partial:
variants[i] = v.model_copy(update = {"partial": True})
for key, label in sorted(empty_by_key.items()):
if key not in listed:
variants.append(GgufVariantDetail(filename = f"{label}.gguf", quant = label, partial = True))
return response.model_copy(update = {"variants": variants})
async def get_gguf_variants_response(
repo_id: str,
prefer_local_cache: bool = False,
@ -653,8 +680,28 @@ async def get_gguf_variants_response(
default_variant = default_variant,
)
def _compute_with_cleanables() -> GgufVariantsResponse:
skip = is_local_path(repo_id) or not _is_valid_repo_id(repo_id)
try:
response = _compute()
except Exception:
# Offline / metadata fetch failed with only an empty leftover
# <quant>/ folder cached: still surface it so the UI can delete it,
# otherwise re-raise the original error.
if skip:
raise
enriched = _mark_empty_dir_cleanables(
repo_id, GgufVariantsResponse(repo_id = repo_id, variants = [])
)
if enriched.variants:
return enriched
raise
if skip:
return response
return _mark_empty_dir_cleanables(repo_id, response)
try:
return await asyncio.to_thread(_compute)
return await asyncio.to_thread(_compute_with_cleanables)
except HTTPException:
raise
except Exception as e:

View file

@ -0,0 +1,166 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Cleanup of empty leftover quant folders from interrupted split downloads."""
import errno
from pathlib import Path
from types import SimpleNamespace
from hub.schemas.inventory import GgufVariantDetail, GgufVariantsResponse
from hub.services.models import deletion, gguf_variants
from hub.utils import gguf
def _make_snapshot(root: Path) -> Path:
snap = root / "snapshots" / "rev0"
(snap / "UD-IQ1_M").mkdir(parents = True)
(snap / "UD-IQ1_M" / "GLM-UD-IQ1_M-00001-of-00002.gguf").write_bytes(b"x")
(snap / "UD-IQ1_M" / "GLM-UD-IQ1_M-00002-of-00002.gguf").write_bytes(b"y")
(snap / "UD-IQ1_S").mkdir(parents = True) # empty leftover
return snap
def test_list_empty_gguf_variant_dirs_finds_empty_leftover(tmp_path, monkeypatch):
snap = _make_snapshot(tmp_path)
monkeypatch.setattr(gguf, "iter_hf_cache_snapshots", lambda repo_id: iter([snap]))
assert gguf.list_empty_gguf_variant_dirs("org/Repo-GGUF") == {"UD-IQ1_S"}
def test_list_empty_excludes_quant_with_files_in_another_snapshot(tmp_path, monkeypatch):
snap1 = tmp_path / "s1" / "snapshots" / "rev"
(snap1 / "UD-IQ1_S").mkdir(parents = True) # empty here
snap2 = tmp_path / "s2" / "snapshots" / "rev"
(snap2 / "UD-IQ1_S").mkdir(parents = True)
(snap2 / "UD-IQ1_S" / "m-UD-IQ1_S-00001-of-00001.gguf").write_bytes(b"z") # has shards
monkeypatch.setattr(gguf, "iter_hf_cache_snapshots", lambda repo_id: iter([snap1, snap2]))
assert gguf.list_empty_gguf_variant_dirs("org/Repo-GGUF") == set()
def test_list_empty_ignores_non_quant_dirs(tmp_path, monkeypatch):
snap = tmp_path / "snapshots" / "rev"
(snap / "not-a-quant").mkdir(parents = True) # empty but not a quant label
monkeypatch.setattr(gguf, "iter_hf_cache_snapshots", lambda repo_id: iter([snap]))
assert gguf.list_empty_gguf_variant_dirs("org/Repo-GGUF") == set()
def test_remove_empty_variant_dirs_removes_only_empty_match(tmp_path):
snap = _make_snapshot(tmp_path)
repo = SimpleNamespace(repo_path = str(tmp_path))
removed, failures = deletion._remove_empty_variant_dirs([repo], "UD-IQ1_S")
assert removed == 1
assert failures == []
assert not (snap / "UD-IQ1_S").exists()
assert (snap / "UD-IQ1_M").is_dir()
def test_remove_empty_variant_dirs_never_touches_populated_folder(tmp_path):
snap = _make_snapshot(tmp_path)
repo = SimpleNamespace(repo_path = str(tmp_path))
removed, failures = deletion._remove_empty_variant_dirs([repo], "UD-IQ1_M")
assert removed == 0
assert failures == []
assert len(list((snap / "UD-IQ1_M").iterdir())) == 2
def test_remove_empty_variant_dirs_surfaces_real_failure(tmp_path, monkeypatch):
_make_snapshot(tmp_path)
repo = SimpleNamespace(repo_path = str(tmp_path))
def _denied(self):
raise OSError(errno.EACCES, "permission denied")
monkeypatch.setattr(Path, "rmdir", _denied)
removed, failures = deletion._remove_empty_variant_dirs([repo], "UD-IQ1_S")
assert removed == 0
assert len(failures) == 1
def test_remove_empty_variant_dirs_ignores_concurrent_refill(tmp_path, monkeypatch):
_make_snapshot(tmp_path)
repo = SimpleNamespace(repo_path = str(tmp_path))
def _refilled(self):
raise OSError(errno.ENOTEMPTY, "directory not empty")
monkeypatch.setattr(Path, "rmdir", _refilled)
removed, failures = deletion._remove_empty_variant_dirs([repo], "UD-IQ1_S")
assert removed == 0
assert failures == []
def test_mark_empty_dir_cleanables_appends_unlisted(monkeypatch):
monkeypatch.setattr(gguf_variants, "list_empty_gguf_variant_dirs", lambda repo_id: {"UD-IQ1_S"})
resp = GgufVariantsResponse(
repo_id = "org/Repo-GGUF",
variants = [GgufVariantDetail(filename = "m-UD-IQ1_M.gguf", quant = "UD-IQ1_M", downloaded = True)],
)
out = gguf_variants._mark_empty_dir_cleanables("org/Repo-GGUF", resp)
by_q = {v.quant: v for v in out.variants}
assert by_q["UD-IQ1_M"].downloaded is True
assert by_q["UD-IQ1_S"].partial is True and by_q["UD-IQ1_S"].downloaded is False
def test_mark_empty_dir_cleanables_flips_listed_variant(monkeypatch):
monkeypatch.setattr(gguf_variants, "list_empty_gguf_variant_dirs", lambda repo_id: {"UD-IQ1_S"})
resp = GgufVariantsResponse(
repo_id = "org/Repo-GGUF",
variants = [GgufVariantDetail(filename = "m-UD-IQ1_S.gguf", quant = "UD-IQ1_S")],
)
out = gguf_variants._mark_empty_dir_cleanables("org/Repo-GGUF", resp)
assert len(out.variants) == 1
assert out.variants[0].partial is True
def _force_compute_to_raise(monkeypatch):
# Drive _compute() down its remote path, fail metadata, and have both cache
# fallbacks miss so the original error re-raises.
def _boom(*a, **k):
raise RuntimeError("offline")
monkeypatch.setattr(gguf_variants, "list_gguf_variants", _boom, raising = False)
monkeypatch.setattr(
gguf_variants, "list_gguf_variants_from_hf_cache", lambda repo_id: None, raising = False
)
monkeypatch.setattr(
gguf_variants, "list_partial_gguf_variants_from_state", lambda repo_id: None, raising = False
)
def test_get_variants_surfaces_cleanable_when_metadata_fails(monkeypatch):
# Offline / model_info fails and only an empty leftover folder is cached:
# the cleanable must still be returned instead of the error propagating.
import asyncio
_force_compute_to_raise(monkeypatch)
monkeypatch.setattr(gguf_variants, "list_empty_gguf_variant_dirs", lambda repo_id: {"UD-IQ1_S"})
resp = asyncio.run(
gguf_variants.get_gguf_variants_response(
"org/Repo-GGUF", prefer_local_cache = False, hf_token = None
)
)
by_q = {v.quant: v for v in resp.variants}
assert "UD-IQ1_S" in by_q
assert by_q["UD-IQ1_S"].partial is True and by_q["UD-IQ1_S"].downloaded is False
def test_get_variants_reraises_when_no_cleanable(monkeypatch):
# Offline with nothing cleanable: original error must propagate (as HTTP).
import asyncio
from fastapi import HTTPException
_force_compute_to_raise(monkeypatch)
monkeypatch.setattr(gguf_variants, "list_empty_gguf_variant_dirs", lambda repo_id: set())
try:
asyncio.run(
gguf_variants.get_gguf_variants_response(
"org/Repo-GGUF", prefer_local_cache = False, hf_token = None
)
)
raised = False
except (HTTPException, RuntimeError):
raised = True
assert raised

View file

@ -276,6 +276,33 @@ def iter_hf_cache_snapshots(repo_id: str):
yield from snapshots
def list_empty_gguf_variant_dirs(repo_id: str) -> set[str]:
"""Quant labels present only as an EMPTY snapshot ``<quant>/`` folder (an
interrupted split download); a quant with shards in any snapshot is excluded."""
empty: dict[str, str] = {}
nonempty: set[str] = set()
for snapshot in iter_hf_cache_snapshots(repo_id):
try:
entries = list(snapshot.iterdir())
except OSError:
continue
for sub in entries:
try:
if sub.is_symlink() or not sub.is_dir():
continue
quant = extract_quant_token(sub.name)
if not quant:
continue
has_child = any(sub.iterdir())
except OSError:
continue
if has_child:
nonempty.add(quant.lower())
else:
empty.setdefault(quant.lower(), quant)
return {label for key, label in empty.items() if key not in nonempty}
def list_gguf_variants_from_hf_cache(repo_id: str) -> Optional[tuple[list[GgufVariantInfo], bool]]:
for snapshot in iter_hf_cache_snapshots(repo_id):
variants, has_vision = list_local_gguf_variants(str(snapshot))

View file

@ -68,6 +68,11 @@ class TrainingStopRequest(PydanticBaseModel):
router = APIRouter()
logger = get_logger(__name__)
# Consecutive 1s polls without a step update that count as a stall. Applied only
# once stepping: the pre-first-step phase (model load + tokenization) can take far
# longer, and timing out there made a healthy long-prep run look frozen.
_PROGRESS_STALL_TIMEOUT_POLLS = 1800 # ~30 min at 1 poll/sec
def _validate_local_dataset_paths(paths: list[str], label: str = "Local dataset") -> list[str]:
"""Resolve and validate a list of local dataset paths. Returns validated absolute paths."""
@ -833,7 +838,13 @@ async def stream_training_progress(
# ── Live polling loop ────────────────────────────────────
last_step = resume_from_step if resume_from_step is not None else -1
no_update_count = 0
max_no_updates = 1800 # Timeout after 30 min (large models need compile time)
# The stall timeout applies only once the run is stepping (pre-step prep
# may legitimately emit no step for a long time). On reconnect to an
# already-stepping run, seed from the resume point / history, else a worker
# that hangs after step N never times out for a client that reconnects past it.
seen_live_step = (resume_from_step is not None and resume_from_step > 0) or bool(
backend.step_history
)
while backend.is_training_active():
try:
@ -871,6 +882,7 @@ async def stream_training_progress(
)
last_step = current_step
no_update_count = 0
seen_live_step = True
else:
no_update_count += 1
# Heartbeat every 10 seconds.
@ -913,8 +925,9 @@ async def stream_training_progress(
event_id = 0,
)
# Timeout check
if no_update_count > max_no_updates:
# Fires only once stepping: a long pre-first-step prep phase is not
# a stall, and ending the stream there made a healthy run look frozen.
if seen_live_step and no_update_count > _PROGRESS_STALL_TIMEOUT_POLLS:
logger.warning("Progress stream timeout - no updates received")
tp_timeout = getattr(
getattr(backend, "trainer", None), "training_progress", None

View file

@ -0,0 +1,141 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""The live progress SSE must not time out during the pre-first-step phase.
A large model load / dataset tokenization can keep a run at step 0 for longer
than the stall timeout. Treating that as a stall ends the live stream and makes a
healthy run look frozen, so the timeout must apply only once the run is stepping.
"""
import asyncio
import sys
import types
import pytest
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.training as rt
class _Progress:
def __init__(
self,
step = 0,
total_steps = 1000,
):
self.step = step
self.total_steps = total_steps
self.loss = None
self.learning_rate = None
self.epoch = None
self.grad_norm = None
self.num_tokens = None
self.eval_loss = None
self.elapsed_seconds = None
self.eta_seconds = None
class _Backend:
def __init__(
self,
*,
active_polls,
step_history = None,
live_step = 0,
):
self.current_job_id = "job-prep"
self.step_history = list(step_history or [])
self.loss_history = [1.0 for _ in self.step_history]
self.lr_history = [1e-4 for _ in self.step_history]
self.eval_enabled = False
self._active_calls = 0
self._active_polls = active_polls
self.trainer = types.SimpleNamespace(training_progress = _Progress(step = live_step))
def is_training_active(self):
self._active_calls += 1
return self._active_calls <= self._active_polls
class _FakeRequest:
headers = {}
class _ReconnectRequest:
# Reconnect carrying the last step the client already received.
headers = {"last-event-id": "10"}
def _raw(response):
async def _drain():
chunks = []
async for chunk in response.body_iterator:
chunks.append(chunk)
return "".join(c.decode() if isinstance(c, bytes) else c for c in chunks)
return asyncio.run(asyncio.wait_for(_drain(), 15))
@pytest.fixture
def _fast_short_timeout(monkeypatch):
"""Make the poll loop instant and the stall timeout tiny."""
async def _no_sleep(*_a, **_k):
return None
monkeypatch.setattr(rt.asyncio, "sleep", _no_sleep)
monkeypatch.setattr(rt, "_PROGRESS_STALL_TIMEOUT_POLLS", 3)
def test_prep_phase_does_not_time_out_before_first_step(monkeypatch, _fast_short_timeout):
# Step 0 for many polls (far past the timeout), then the run ends. Pre-step
# this is preparation, not a stall: no error event may be emitted.
backend = _Backend(active_polls = 20, step_history = [], live_step = 0)
monkeypatch.setattr(rt, "get_training_backend", lambda: backend)
raw = _raw(asyncio.run(rt.stream_training_progress(_FakeRequest(), current_subject = "tester")))
assert (
backend._active_calls > rt._PROGRESS_STALL_TIMEOUT_POLLS + 1
), "the loop must have run past the stall threshold for this test to be meaningful"
assert "event: heartbeat" in raw, "prep heartbeats should still flow"
assert "event: error" not in raw, "a still-preparing run must not be timed out as a stall"
def test_stall_after_first_step_still_times_out(monkeypatch, _fast_short_timeout):
# Emits a live step (so seen_live_step becomes True) then stays put: a genuine
# post-step stall that must still trigger the timeout error.
backend = _Backend(active_polls = 100, step_history = [1, 2], live_step = 5)
monkeypatch.setattr(rt, "get_training_backend", lambda: backend)
raw = _raw(asyncio.run(rt.stream_training_progress(_FakeRequest(), current_subject = "tester")))
assert "event: error" in raw, "a real post-step stall should still time out"
def test_reconnect_to_stepped_run_still_times_out(monkeypatch, _fast_short_timeout):
# Client reconnects at step 10 (Last-Event-ID) to a run that already stepped
# then hangs (only heartbeats): the post-step stall timeout must still fire.
# Without seeding seen_live_step from the resume point it resets to False and
# never times out for this client.
backend = _Backend(active_polls = 100, step_history = [10], live_step = 10)
monkeypatch.setattr(rt, "get_training_backend", lambda: backend)
raw = _raw(
asyncio.run(rt.stream_training_progress(_ReconnectRequest(), current_subject = "tester"))
)
assert (
"event: error" in raw
), "a reconnect to an already-stepped run that then stalls must still time out"

View file

@ -14,9 +14,18 @@ min-release-age=7
# `npm install <name>@<version> --save-exact` pass) but it stops new
# carets from creeping into the manifest as patch-version footguns.
save-exact=true
# Lock the registry. A user-set PIP_INDEX_URL-style override (here:
# NPM_CONFIG_REGISTRY env var or a stale ~/.npmrc) shouldn't redirect
# our installs to an attacker registry.
# Pin the default registry so a stale or hostile *lower-precedence* ~/.npmrc
# can't silently redirect our installs to an attacker registry. Note this does
# NOT block an ambient NPM_CONFIG_REGISTRY env var: npm and bun honor that at a
# higher precedence than this project file. That is exactly why Unsloth does not
# read NPM_CONFIG_REGISTRY and instead exposes one deliberate, explicit opt-in.
#
# Corporate mirror / proxy (issue #6491): if your firewall blocks
# registry.npmjs.org, set UNSLOTH_NPM_REGISTRY=<your-mirror-url> when running
# ./install.sh (or setup.sh / setup.ps1). The installer threads it as
# `--registry <url>`, which overrides this line for both npm and bun while
# leaving the min-release-age and save-exact locks above in force. Do not edit
# this line for that -- the env var keeps the default pinned for everyone else.
registry=https://registry.npmjs.org/
audit-level=high
fund=false

View file

@ -6,6 +6,7 @@ import { UpdateBanner } from "@/components/tauri/update-banner";
import { UpdateScreen } from "@/components/tauri/update-screen";
import {
WindowTitlebar,
shouldUseNativeMacWindowTitlebar,
shouldUseCustomWindowTitlebar,
} from "@/components/tauri/window-titlebar";
import { Toaster } from "@/components/ui/sonner";
@ -18,9 +19,16 @@ import { NativeIntentDrain } from "@/features/native-intents/native-intent-drain
import { useTauriBackend, type BackendStatus } from "@/hooks/use-tauri-backend";
import { useTauriUpdate } from "@/hooks/use-tauri-update";
import { isTauri } from "@/lib/api-base";
import { fetchDeviceType } from "@/config/env";
import { useRouterState } from "@tanstack/react-router";
import { ThemeProvider } from "next-themes";
import { useEffect, useRef, useState, type ReactNode } from "react";
import {
useEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
interface AppProviderProps {
children: ReactNode;
@ -31,18 +39,43 @@ type WindowLayoutGuard = () => boolean;
const MIN_WINDOW_WIDTH = 900;
const MIN_WINDOW_HEIGHT = 600;
const SETUP_WINDOW_WIDTH = 760;
const SETUP_WINDOW_HEIGHT = 560;
async function showSetupWindow(isCurrent: WindowLayoutGuard): Promise<void> {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
const { getCurrentWindow, LogicalSize } = await import("@tauri-apps/api/window");
if (!isCurrent()) return;
const win = getCurrentWindow();
await win.setResizable(false);
if (!isCurrent()) return;
await win.setSize(new LogicalSize(SETUP_WINDOW_WIDTH, SETUP_WINDOW_HEIGHT));
if (!isCurrent()) return;
await win.center();
if (!isCurrent()) return;
await win.show();
}
async function enforceMinimumWindowSize(
win: Awaited<ReturnType<typeof import("@tauri-apps/api/window")["getCurrentWindow"]>>,
LogicalSize: typeof import("@tauri-apps/api/window")["LogicalSize"],
isCurrent: WindowLayoutGuard,
): Promise<void> {
const [innerSize, scaleFactor] = await Promise.all([
win.innerSize(),
win.scaleFactor(),
]);
if (!isCurrent()) return;
const logicalWidth = Math.round(innerSize.width / scaleFactor);
const logicalHeight = Math.round(innerSize.height / scaleFactor);
const nextWidth = Math.max(logicalWidth, MIN_WINDOW_WIDTH);
const nextHeight = Math.max(logicalHeight, MIN_WINDOW_HEIGHT);
if (nextWidth !== logicalWidth || nextHeight !== logicalHeight) {
await win.setSize(new LogicalSize(nextWidth, nextHeight));
}
}
async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise<void> {
const { getCurrentWindow, currentMonitor, LogicalSize } = await import("@tauri-apps/api/window");
const { invoke } = await import("@tauri-apps/api/core");
@ -91,6 +124,8 @@ async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise<void>
// Apply constraints after restore/show: doing so before plugin restore can emit
// a Resized event and overwrite the plugin's cached saved size.
await win.setSizeConstraints({ minWidth: MIN_WINDOW_WIDTH, minHeight: MIN_WINDOW_HEIGHT });
if (!isCurrent()) return;
await enforceMinimumWindowSize(win, LogicalSize, isCurrent);
}
async function showWindowFallback(): Promise<void> {
@ -123,7 +158,13 @@ function getTauriWindowMode(
}
}
function TauriUpdateLayer({ isExternalServer }: { isExternalServer: boolean }) {
function TauriUpdateLayer({
isExternalServer,
children,
}: {
isExternalServer: boolean;
children?: ReactNode;
}) {
const update = useTauriUpdate(isExternalServer);
const isUpdating =
update.status === "updating-backend" ||
@ -146,18 +187,22 @@ function TauriUpdateLayer({ isExternalServer }: { isExternalServer: boolean }) {
}
return (
<UpdateBanner
status={update.status}
info={update.info}
dismissed={update.dismissed}
lastFailure={update.lastFailure}
isExternalServer={isExternalServer}
updatePolicyMode={update.updatePolicyMode}
manualReleaseUrl={update.manualReleaseUrl}
onInstall={update.installUpdate}
onDismiss={update.dismiss}
onCopyDiagnostics={update.copyDiagnostics}
/>
<div className="pointer-events-none fixed bottom-4 right-4 z-[9998] flex w-[calc(100vw-2rem)] max-w-[400px] flex-col items-stretch gap-2">
<UpdateBanner
status={update.status}
info={update.info}
dismissed={update.dismissed}
lastFailure={update.lastFailure}
isExternalServer={isExternalServer}
updatePolicyMode={update.updatePolicyMode}
manualReleaseUrl={update.manualReleaseUrl}
positioned={false}
onInstall={update.installUpdate}
onDismiss={update.dismiss}
onCopyDiagnostics={update.copyDiagnostics}
/>
{children}
</div>
);
}
@ -175,6 +220,35 @@ const WEB_UPDATE_HIDDEN_ROUTES = new Set([
"/signup",
]);
const MAC_NATIVE_CHROME_STYLE = {
"--studio-titlebar-height": "0px",
"--studio-mac-titlebar-height": "34px",
"--studio-mac-traffic-light-inset": "78px",
"--studio-startup-top-inset": "58px",
"--studio-content-top-inset": "0px",
"--studio-non-chat-content-top-inset": "34px",
"--studio-hidden-route-top-inset": "34px",
"--studio-chat-header-height": "44px",
"--studio-chat-header-padding-top": "8px",
"--studio-chat-control-height": "33px",
"--studio-chat-header-right-inset": "0px",
} as CSSProperties;
const CUSTOM_CHROME_STYLE = {
"--studio-titlebar-height": "0px",
"--studio-custom-titlebar-height": "34px",
"--studio-sidebar-expanded-width": "17.5rem",
"--studio-sidebar-collapsed-width": "3rem",
"--studio-startup-top-inset": "42px",
"--studio-content-top-inset": "34px",
"--studio-hidden-route-top-inset": "34px",
"--studio-chat-header-height": "48px",
"--studio-chat-header-padding-top": "9px",
"--studio-chat-control-height": "33px",
"--studio-chat-header-right-inset": "0px",
"--studio-window-control-inset": "112px",
} as CSSProperties;
function TauriWrapper({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const {
@ -254,6 +328,11 @@ function TauriWrapper({ children }: { children: ReactNode }) {
return () => { disposed = true; };
}, [status, desktopAuthRetry]);
useEffect(() => {
if (!isTauri || status !== "running" || !desktopAuthReady) return;
void fetchDeviceType({ force: true }).catch(() => undefined);
}, [status, desktopAuthReady]);
if (!isTauri) {
return (
<>
@ -281,10 +360,19 @@ function TauriWrapper({ children }: { children: ReactNode }) {
status === "running" && !desktopAuthReady
? "Signing in to desktop session..."
: progressDetail;
const usesCustomTitlebar = shouldUseCustomWindowTitlebar();
const usesNativeMacTitlebar = shouldUseNativeMacWindowTitlebar();
const hidesTitlebarSidebar = HIDDEN_TITLEBAR_SIDEBAR_ROUTES.has(pathname);
const content = showApp ? (
<>
<TauriUpdateLayer isExternalServer={isExternalServer} />
<TauriUpdateLayer isExternalServer={isExternalServer}>
<LlamaUpdateBanner
positioned={false}
enabled={!hidesTitlebarSidebar}
/>
<DownloadManagerPanel positioned={false} />
</TauriUpdateLayer>
<NativeIntentDrain />
{children}
</>
@ -305,39 +393,48 @@ function TauriWrapper({ children }: { children: ReactNode }) {
/>
);
if (!shouldUseCustomWindowTitlebar()) {
if (!usesCustomTitlebar) {
// macOS desktop uses the native titlebar and returns here before the
// custom-titlebar branch, so mount the updater banner on this path too.
return (
<>
{content}
<div className="pointer-events-none fixed bottom-4 right-4 z-[9998] flex w-[calc(100vw-2rem)] max-w-[400px] flex-col items-stretch gap-2">
<LlamaUpdateBanner
positioned={false}
enabled={showApp && !HIDDEN_TITLEBAR_SIDEBAR_ROUTES.has(pathname)}
/>
{showApp ? <DownloadManagerPanel positioned={false} /> : null}
if (usesNativeMacTitlebar) {
return (
<div
className={
hidesTitlebarSidebar
? "relative h-dvh min-h-0 overflow-x-hidden overflow-y-auto bg-background"
: "relative h-dvh min-h-0 overflow-hidden bg-background"
}
style={MAC_NATIVE_CHROME_STYLE}
>
{(!showApp || hidesTitlebarSidebar) ? (
<div
data-tauri-drag-region
aria-hidden="true"
className="pointer-events-auto fixed inset-x-0 top-0 z-50 h-[var(--studio-mac-titlebar-height,34px)] select-none"
/>
) : null}
{content}
</div>
</>
);
}
return (
<>{content}</>
);
}
const showSidebarSurface =
showApp && !HIDDEN_TITLEBAR_SIDEBAR_ROUTES.has(pathname);
showApp && !hidesTitlebarSidebar;
return (
<div className="flex h-dvh min-h-0 flex-col overflow-hidden bg-background [--studio-titlebar-height:34px]">
<div
className="relative h-dvh min-h-0 overflow-hidden bg-background"
style={CUSTOM_CHROME_STYLE}
>
<WindowTitlebar showSidebarSurface={showSidebarSurface} />
<div className="min-h-0 flex-1 overflow-hidden">
<div className="h-full min-h-0 overflow-hidden">
{content}
</div>
<div className="pointer-events-none fixed bottom-4 right-4 z-[9998] flex w-[calc(100vw-2rem)] max-w-[400px] flex-col items-stretch gap-2">
<LlamaUpdateBanner
positioned={false}
enabled={showApp && !HIDDEN_TITLEBAR_SIDEBAR_ROUTES.has(pathname)}
/>
{showApp ? <DownloadManagerPanel positioned={false} /> : null}
</div>
</div>
);
}

View file

@ -219,7 +219,7 @@ function RootLayout() {
<SettingsDialog />
<RemoteCodeConsentDialog />
{hideNavbar ? (
<main className="flex-1">
<main className="flex-1 pt-[var(--studio-hidden-route-top-inset,0px)] [--studio-titlebar-height:var(--studio-hidden-route-top-inset,0px)]">
<Suspense fallback={<RouteFallback />}>
<Outlet />
</Suspense>
@ -235,7 +235,7 @@ function RootLayout() {
<SidebarInset className={isChatRoute ? "overflow-hidden" : "overflow-y-auto"}>
<Navbar />
<div
className={`relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col ${isChatRoute ? "overflow-hidden" : "overflow-visible"} ${isChatRoute ? "" : "pt-14 md:pt-0"}`}
className={`relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col ${isChatRoute ? "overflow-hidden" : "overflow-visible"} ${isChatRoute ? "" : "pt-14 md:pt-[var(--studio-non-chat-content-top-inset,var(--studio-content-top-inset,0px))] md:[--studio-titlebar-height:var(--studio-non-chat-content-top-inset,var(--studio-content-top-inset,0px))]"}`}
>
{/* Stays mounted across navigation so an in-flight generation is
not cancelled when leaving /chat; hidden (not unmounted) off-route.

View file

@ -44,7 +44,12 @@ import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler";
import {
shouldUseCustomWindowTitlebar,
shouldUseNativeMacWindowTitlebar,
} from "@/components/tauri/window-titlebar";
import { cn } from "@/lib/utils";
import { isTauri } from "@/lib/api-base";
import { useWebUpdateCheck } from "@/hooks/use-web-update-check";
import {
Archive03Icon,
@ -273,6 +278,8 @@ function devForceUpdateCard(): boolean {
export function AppSidebar() {
const t = useT();
const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle();
const [usesCustomTitlebar] = useState(shouldUseCustomWindowTitlebar);
const [usesNativeMacTitlebar] = useState(shouldUseNativeMacWindowTitlebar);
const { pathname, search } = useRouterState({
select: (s) => ({
pathname: s.location.pathname,
@ -459,6 +466,10 @@ export function AppSidebar() {
isStudioRoute,
]);
const chatDisabled = trainingInProgress;
const showSidebarBrand = !usesCustomTitlebar;
const showCompactMacBrand = showSidebarBrand && usesNativeMacTitlebar;
function chatSearchForProject(projectId: string | null) {
if (projectId) {
return { project: projectId };
@ -984,81 +995,118 @@ export function AppSidebar() {
variant="sidebar"
className="font-heading group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-white dark:group-data-[collapsible=icon]:[&_[data-sidebar=sidebar]]:bg-background"
>
<SidebarHeader className="pl-[17px] pr-3 pt-[14px] pb-[8px] group-data-[collapsible=icon]:px-0">
{/* Expanded: compact logo + close toggle */}
<div className="flex items-center justify-between gap-[8.5px] group-data-[collapsible=icon]:hidden">
<Link
to="/chat"
onClick={(event) => {
event.preventDefault();
openNewChat(null);
}}
className="flex items-center gap-[6px] select-none"
aria-label={t("shell.aria.home")}
>
<img
src="/circle-logo-small.png"
alt="Unsloth"
className="h-[34px] w-[34px] rounded-full object-cover"
/>
<span className="font-heading text-[21px] font-semibold tracking-[0em] dark:tracking-[0.02em] leading-none text-black dark:text-white">
unsloth
</span>
<span className="nav-badge ml-0.5 inline-flex items-center justify-center rounded-full border border-nav-beta-border px-[5px] pt-[3px] pb-[2px] text-[8px] font-medium leading-none tracking-[0.04em] text-nav-fg-muted antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]">
{t("shell.beta")}
</span>
</Link>
{!isMobile && (
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={togglePinned}
className="inline-flex h-[33px] w-[33px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("shell.aria.closeSidebar")}
<SidebarHeader
className={cn(
"relative",
showSidebarBrand
? showCompactMacBrand
? "h-[var(--studio-chat-header-height,48px)] pl-[calc(var(--studio-mac-traffic-light-inset,78px)+6px)] pr-2 pt-[var(--studio-chat-header-padding-top,9px)] pb-0 group-data-[collapsible=icon]:h-[calc(var(--studio-mac-titlebar-height,34px)+var(--studio-chat-control-height,33px)+8px)] group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:pt-[calc(var(--studio-mac-titlebar-height,34px)+8px)]"
: "pl-[17px] pr-3 pt-[14px] pb-[8px] group-data-[collapsible=icon]:px-0"
: "h-[var(--studio-custom-titlebar-height,34px)] shrink-0 p-0",
)}
>
{showSidebarBrand && (
<>
{usesNativeMacTitlebar && (
<div
data-tauri-drag-region
aria-hidden="true"
className="absolute inset-x-0 top-0 z-0 h-[var(--studio-mac-titlebar-height,34px)] select-none"
/>
)}
<div
className={cn(
"relative z-10 flex items-center gap-[8.5px] group-data-[collapsible=icon]:hidden",
showCompactMacBrand &&
"h-[var(--studio-chat-control-height,33px)] justify-end gap-2",
!showCompactMacBrand && "justify-between",
)}
>
{!showCompactMacBrand && (
<Link
to="/chat"
onClick={(event) => {
event.preventDefault();
if (chatDisabled) return;
openNewChat(null);
}}
className={cn(
"flex items-center gap-[6px] select-none transition-opacity",
chatDisabled && "pointer-events-none opacity-50",
)}
aria-label={t("shell.aria.home")}
aria-disabled={chatDisabled}
tabIndex={chatDisabled ? -1 : undefined}
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent
side="bottom"
sideOffset={6}
className="tooltip-compact"
>
{t("shell.aria.closeSidebar")}
</TooltipContent>
</Tooltip>
)}
</div>
{/* Collapsed: panel icon doubles as expand trigger */}
{!isMobile && (
<div className="hidden group-data-[collapsible=icon]:flex h-[33px] items-center justify-center w-full">
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={togglePinned}
className="inline-flex h-[33px] w-[33px] cursor-pointer items-center justify-center rounded-[10px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("shell.aria.openSidebar")}
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent
side="right"
sideOffset={8}
className="tooltip-compact"
>
{t("shell.aria.openSidebar")}
</TooltipContent>
</Tooltip>
</div>
<img
src="/circle-logo-small.png"
alt="Unsloth"
className="h-[34px] w-[34px] rounded-full object-cover"
/>
<span className="font-heading text-[21px] font-semibold tracking-[0em] leading-none text-black dark:text-white dark:tracking-[0.02em]">
unsloth
</span>
<span className="nav-badge ml-0.5 inline-flex items-center justify-center rounded-full border border-nav-beta-border px-[5px] pt-[3px] pb-[2px] text-[8px] font-medium leading-none tracking-[0.04em] text-nav-fg-muted antialiased subpixel-antialiased shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]">
{t("shell.beta")}
</span>
</Link>
)}
{!isMobile && (
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={togglePinned}
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("shell.aria.closeSidebar")}
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent
side="bottom"
sideOffset={6}
className="tooltip-compact"
>
{t("shell.aria.closeSidebar")}
</TooltipContent>
</Tooltip>
)}
</div>
{!isMobile && (
<div className="relative z-10 hidden group-data-[collapsible=icon]:flex h-[33px] items-center justify-center w-full">
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={togglePinned}
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("shell.aria.openSidebar")}
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
</button>
</TooltipPrimitive.Trigger>
<TooltipContent
side="right"
sideOffset={8}
className="tooltip-compact"
>
{t("shell.aria.openSidebar")}
</TooltipContent>
</Tooltip>
</div>
)}
</>
)}
</SidebarHeader>
{/* Uniform pl-1.5 pr-2 keeps every hover pill the same width, inset from the edge. */}
<SidebarGroup className="group-data-[collapsible=icon]:px-0 pl-1.5 pr-2 pt-[9px] pb-px shrink-0">
<SidebarGroup
className={cn(
"group-data-[collapsible=icon]:px-0 pl-1.5 pr-2 pb-px shrink-0",
showCompactMacBrand ? "pt-0" : "pt-[9px]",
)}
>
<SidebarGroupContent>
<SidebarMenu>
<NavItem
@ -1558,25 +1606,29 @@ export function AppSidebar() {
<HugeiconsIcon icon={HelpCircleIcon} strokeWidth={1.75} className="size-icon" />
<span>{t("common.help")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={async () => {
// Best-effort server revocation; ignore network errors so
// the local clear still runs and the user lands on /login.
try {
await logout();
} catch {
clearAuthTokens();
}
void navigate({ to: "/login" });
}}
>
<HugeiconsIcon icon={Logout05Icon} strokeWidth={1.75} className="size-icon" />
<span>{t("shell.navigation.logOut")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShutdownOpen(true)}>
<HugeiconsIcon icon={PowerIcon} strokeWidth={1.75} className="size-icon" />
<span>{t("common.shutdown")}</span>
</DropdownMenuItem>
{!isTauri && (
<DropdownMenuItem
onSelect={async () => {
// Best-effort server revocation; ignore network errors so
// the local clear still runs and the user lands on /login.
try {
await logout();
} catch {
clearAuthTokens();
}
void navigate({ to: "/login" });
}}
>
<HugeiconsIcon icon={Logout05Icon} strokeWidth={1.75} className="size-icon" />
<span>{t("shell.navigation.logOut")}</span>
</DropdownMenuItem>
)}
{!isTauri && (
<DropdownMenuItem onSelect={() => setShutdownOpen(true)}>
<HugeiconsIcon icon={PowerIcon} strokeWidth={1.75} className="size-icon" />
<span>{t("common.shutdown")}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
@ -1584,11 +1636,13 @@ export function AppSidebar() {
</SidebarFooter>
</Sidebar>
<ChatSearchDialog />
<ShutdownDialog
open={shutdownOpen}
onOpenChange={setShutdownOpen}
onAfterShutdown={removeTrainingUnloadGuard}
/>
{!isTauri && (
<ShutdownDialog
open={shutdownOpen}
onOpenChange={setShutdownOpen}
onAfterShutdown={removeTrainingUnloadGuard}
/>
)}
<Dialog
open={confirmingDelete !== null}
onOpenChange={(open) => {

View file

@ -968,7 +968,9 @@ export const Thread: FC<{
scrollToBottomOnThreadSwitch={false}
className={cn(
"aui-thread-viewport aui-stream-viewport relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-x-auto overflow-y-auto scroll-smooth px-5",
hideComposer ? "pt-4" : "pt-[48px]",
hideComposer
? "pt-4"
: "pt-[calc(var(--studio-content-top-inset,0px)+48px)]",
)}
>
{!hideWelcome && (

View file

@ -1,13 +1,24 @@
// 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 { shouldUseNativeMacWindowTitlebar } from "@/components/tauri/window-titlebar";
import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
import { useState } from "react";
export function Navbar() {
const { isMobile } = useSidebar();
const [usesNativeMacTitlebar] = useState(shouldUseNativeMacWindowTitlebar);
if (!isMobile) {
return (
<header className="absolute top-0 inset-x-0 z-40 h-[48px] pointer-events-none" />
<header className="absolute top-0 inset-x-0 z-40 h-[48px] pointer-events-none">
{usesNativeMacTitlebar && (
<div
data-tauri-drag-region
aria-hidden="true"
className="pointer-events-auto absolute inset-x-0 top-0 h-[var(--studio-mac-titlebar-height,34px)] select-none"
/>
)}
</header>
);
}
return (

View file

@ -433,12 +433,12 @@ export function StartupScreen({
}
return (
<div className="flex h-full w-full flex-col items-center bg-background">
<div className="flex flex-1 w-full max-w-md items-center justify-center px-6">
<div className="box-border flex h-full w-full flex-col items-center overflow-y-auto bg-background pb-6 pt-[var(--studio-startup-top-inset,0px)]">
<div className="flex min-h-0 flex-1 w-full max-w-md items-center justify-center px-6">
<AnimatePresence mode="wait">
<motion.div
key={status}
className="flex h-full w-full flex-col items-center text-center"
className="flex h-full w-full flex-col items-center justify-center text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}

View file

@ -9,6 +9,8 @@ import type {
UpdateStatus,
} from "@/hooks/use-tauri-update";
import type { CopySupportDiagnosticsResult } from "@/lib/tauri-diagnostics";
import { cn } from "@/lib/utils";
import { CircleAlert, Download } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
@ -20,6 +22,8 @@ interface UpdateBannerProps {
isExternalServer?: boolean;
updatePolicyMode: DesktopUpdatePolicyMode;
manualReleaseUrl: string | null;
// false fills a shared overlay stack; true self-anchors.
positioned?: boolean;
onInstall: () => void;
onDismiss: () => void;
onCopyDiagnostics: () => Promise<CopySupportDiagnosticsResult>;
@ -27,6 +31,11 @@ interface UpdateBannerProps {
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
function formatVersion(version: string | null | undefined): string {
if (!version) return "";
return version.startsWith("v") ? version : `v${version}`;
}
export function UpdateBanner({
status,
info,
@ -35,6 +44,7 @@ export function UpdateBanner({
isExternalServer = false,
updatePolicyMode,
manualReleaseUrl,
positioned = true,
onInstall,
onDismiss,
onCopyDiagnostics,
@ -49,6 +59,9 @@ export function UpdateBanner({
const installDisabled = isManualLinuxPackage
? manualReleaseUrl === null
: isExternalServer;
const currentVersion = formatVersion(info?.currentVersion);
const latestVersion = formatVersion(info?.version);
const Icon = showFailure ? CircleAlert : Download;
async function handleCopyDiagnostics() {
setCopying(true);
@ -59,7 +72,10 @@ export function UpdateBanner({
setManualMessage(null);
} else {
setManualReport(result.report);
setManualMessage(result.error ?? "Clipboard copy failed. Select and copy the diagnostics below.");
setManualMessage(
result.error ??
"Clipboard copy failed. Select and copy the diagnostics below.",
);
}
} catch (error) {
setManualReport(null);
@ -73,30 +89,60 @@ export function UpdateBanner({
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0, y: -12, scale: 0.96 }}
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.97 }}
exit={{ opacity: 0, y: 8, scale: 0.97 }}
transition={{ duration: 0.35, ease: EASE_OUT_QUART }}
className="fixed top-4 right-4 z-[9999] w-[380px]"
className={cn(
positioned
? "fixed bottom-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[400px]"
: "pointer-events-auto w-full",
)}
data-testid="tauri-update-banner"
>
<div className="corner-squircle relative overflow-hidden border border-border/60 bg-background/95 px-5 py-4 shadow-lg backdrop-blur-md">
<div className="relative overflow-hidden rounded-[24px] bg-white px-5 pb-4 pt-5 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] dark:bg-card dark:shadow-[0_8px_28px_-6px_rgba(0,0,0,0.28)]">
<button
type="button"
onClick={onDismiss}
className="absolute top-3 right-3 flex size-6 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
className="absolute top-2.5 right-3 flex size-6 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
aria-label="Dismiss app update notification"
>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 3L3 11M3 3l8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<svg
aria-hidden="true"
width="12"
height="12"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 3L3 11M3 3l8 8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
<div className="flex items-center gap-2">
<span className="text-lg">🦥</span>
<div>
<p className="text-sm font-semibold text-foreground">
{showFailure ? "App update failed" : `New version: v${info?.version}`}
<div className="flex min-w-0 items-start gap-4 pr-6">
<Icon
aria-hidden="true"
className="mt-1 size-5 shrink-0 text-foreground"
strokeWidth={1.75}
/>
<div className="min-w-0">
<p className="font-heading text-base font-medium text-foreground">
{showFailure ? "App update failed" : "New Unsloth version"}
</p>
<p className="text-xs text-muted-foreground">
{showFailure ? null : (
<p className="mt-0.5 text-xs text-muted-foreground">
{currentVersion} &rarr;{" "}
<span className="font-medium text-foreground">
{latestVersion}
</span>
</p>
)}
<p className="mt-1 text-[11px] text-muted-foreground/70">
{showFailure
? "Backend recovered. Diagnostics are still available."
: isManualLinuxPackage
@ -114,31 +160,56 @@ export function UpdateBanner({
</p>
)}
<div className="mt-3 flex items-center gap-2">
<div className="mt-4 flex flex-wrap items-center justify-end gap-x-1 gap-y-2">
{showFailure ? (
<>
<Button size="sm" variant="outline" className="corner-squircle" onClick={() => {
handleCopyDiagnostics().catch(console.error);
}}>
{copying ? "Copying..." : "Copy Diagnostics"}
<Button
size="sm"
variant="ghost"
className="h-auto rounded-full px-3 py-2 text-[13px] font-medium text-foreground"
onClick={() => {
handleCopyDiagnostics().catch(console.error);
}}
>
{copying ? "Copying..." : "Copy diagnostics"}
</Button>
<Button size="sm" className="corner-squircle" onClick={onInstall} disabled={installDisabled}>
{isManualLinuxPackage ? "Open Release Page" : "Retry Update"}
<Button
size="sm"
variant="ghost"
className="h-auto rounded-full px-3 py-2 text-[13px] font-medium text-foreground"
onClick={onDismiss}
>
Later
</Button>
<Button
size="sm"
className="-mr-1 h-auto rounded-full px-3.5 py-2 text-[13px]"
onClick={onInstall}
disabled={installDisabled}
>
{isManualLinuxPackage ? "Open release page" : "Retry update"}
</Button>
</>
) : (
<>
<Button size="sm" className="corner-squircle" onClick={onInstall} disabled={installDisabled}>
{isManualLinuxPackage ? "Open Release Page" : "Update Now"}
<Button
size="sm"
variant="ghost"
className="h-auto rounded-full px-3 py-2 text-[13px] font-medium text-foreground"
onClick={onDismiss}
>
Remind me later
</Button>
<Button size="sm" variant="outline" className="corner-squircle" disabled={true}>
Release Notes
<Button
size="sm"
className="-mr-1 h-auto rounded-full px-3.5 py-2 text-[13px]"
onClick={onInstall}
disabled={installDisabled}
>
{isManualLinuxPackage ? "Open release page" : "Update"}
</Button>
</>
)}
<Button size="sm" variant="ghost" className="corner-squircle" onClick={onDismiss}>
Later
</Button>
</div>
{manualMessage && (
<p className="mt-3 text-xs text-destructive">{manualMessage}</p>

View file

@ -117,12 +117,12 @@ export function UpdateScreen({
}
return (
<div className="flex h-full w-full items-center justify-center bg-background">
<div className="box-border flex h-full w-full overflow-y-auto bg-background">
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: EASE_OUT_QUART }}
className="flex w-full max-w-xl flex-col items-center px-6"
className="mx-auto flex min-h-full w-full max-w-xl flex-col items-center justify-center px-6 pb-6 pt-[var(--studio-startup-top-inset,0px)]"
>
<Logo />

View file

@ -1,10 +1,17 @@
// 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_WIDTH, SIDEBAR_WIDTH_ICON } from "@/components/ui/sidebar";
import { useSidebarPin } from "@/hooks/use-sidebar-pin";
import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils";
import {
Cancel01Icon,
LayoutAlignLeftIcon,
MinusSignIcon,
SquareIcon,
SquareSquareIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { Window as TauriWindow } from "@tauri-apps/api/window";
import {
type MouseEvent,
@ -56,6 +63,13 @@ export function shouldUseCustomWindowTitlebar(): boolean {
return CUSTOM_TITLEBAR_PLATFORMS.some((token) => platform.includes(token));
}
export function shouldUseNativeMacWindowTitlebar(): boolean {
if (!isTauri) {
return false;
}
return getClientPlatform().includes("mac");
}
async function getAppWindow(): Promise<TauriWindow> {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
return getCurrentWindow();
@ -79,7 +93,7 @@ function WindowControlButton({
title={label}
onClick={onClick}
className={cn(
"relative z-[80] inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"relative z-[80] inline-flex size-8 items-center justify-center rounded-[10px] text-muted-foreground/90 transition-colors hover:bg-nav-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
@ -88,39 +102,6 @@ function WindowControlButton({
);
}
function MinimizeGlyph(): ReactElement {
return (
<span aria-hidden="true" className="h-px w-3.5 rounded-full bg-current" />
);
}
function MaximizeGlyph(): ReactElement {
return (
<span
aria-hidden="true"
className="size-3 rounded-[2px] border border-current"
/>
);
}
function RestoreGlyph(): ReactElement {
return (
<span aria-hidden="true" className="relative size-3.5">
<span className="absolute left-0.5 top-0 size-2.5 rounded-[2px] border border-current" />
<span className="absolute bottom-0 right-0 size-2.5 rounded-[2px] border border-current bg-muted" />
</span>
);
}
function CloseGlyph(): ReactElement {
return (
<span aria-hidden="true" className="relative size-3.5">
<span className="absolute left-1/2 top-0 h-3.5 w-px -translate-x-1/2 rotate-45 rounded-full bg-current" />
<span className="absolute left-1/2 top-0 h-3.5 w-px -translate-x-1/2 -rotate-45 rounded-full bg-current" />
</span>
);
}
export function WindowTitlebar({
showSidebarSurface = false,
}: {
@ -128,7 +109,13 @@ export function WindowTitlebar({
}): ReactElement | null {
const [enabled] = useState(shouldUseCustomWindowTitlebar);
const [maximized, setMaximized] = useState(false);
const { pinned } = useSidebarPin();
const { pinned, togglePinned } = useSidebarPin();
const sidebarWidth = showSidebarSurface
? pinned
? "var(--studio-sidebar-expanded-width,17.5rem)"
: "var(--studio-sidebar-collapsed-width,3rem)"
: "0px";
const contentBorderLeft = `calc(${sidebarWidth} + 12px)`;
const refreshMaximized = useCallback(async () => {
if (!enabled) {
@ -239,29 +226,113 @@ export function WindowTitlebar({
return (
<>
<header
className="relative z-[60] flex h-[var(--studio-titlebar-height)] shrink-0 select-none items-center text-foreground"
className={cn(
"pointer-events-none absolute inset-x-0 top-0 z-[70] h-[var(--studio-custom-titlebar-height)] select-none text-foreground",
showSidebarSurface && "bg-sidebar text-sidebar-foreground",
)}
aria-label="Window titlebar"
>
{showSidebarSurface && (
<div
className={cn(
"h-full shrink-0 border-r border-sidebar-border dark:border-r-0",
pinned ? "bg-sidebar" : "bg-white dark:bg-background",
)}
style={{ width: pinned ? SIDEBAR_WIDTH : SIDEBAR_WIDTH_ICON }}
onMouseDown={handleDragMouseDown}
onDoubleClick={handleDragDoubleClick}
aria-hidden="true"
className="pointer-events-none absolute top-full h-3 w-px -translate-x-px bg-sidebar"
style={{ left: sidebarWidth }}
/>
)}
{showSidebarSurface && (
<div
aria-hidden="true"
className="pointer-events-none absolute top-full h-px bg-sidebar-border"
style={{ left: contentBorderLeft, right: 0 }}
/>
)}
{showSidebarSurface && (
<div
aria-hidden="true"
className="pointer-events-none absolute top-full size-3 -translate-x-px rounded-tl-[12px] border-l border-t border-sidebar-border bg-background"
style={{ left: sidebarWidth }}
/>
)}
{showSidebarSurface && (
<div
className={cn(
"pointer-events-auto absolute left-0 top-0 flex h-full min-w-0 items-center",
pinned ? "gap-2 px-3" : "justify-center",
)}
style={{ width: sidebarWidth }}
onMouseDown={handleDragMouseDown}
onDoubleClick={handleDragDoubleClick}
>
{pinned ? (
<>
<div className="flex min-w-0 flex-1 items-center gap-2">
<img
src="/rounded-512.png"
alt=""
aria-hidden="true"
draggable={false}
className="size-5 shrink-0 rounded-[6px] object-cover"
/>
<span className="min-w-0 truncate text-[13px] font-semibold leading-none tracking-[0.01em] text-nav-fg">
Unsloth Studio
</span>
</div>
<button
type="button"
title="Collapse sidebar"
aria-label="Collapse sidebar"
onMouseDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
togglePinned();
}}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-[10px] text-nav-icon-idle transition-colors hover:bg-nav-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<HugeiconsIcon
icon={LayoutAlignLeftIcon}
strokeWidth={1.75}
className="size-icon"
/>
</button>
</>
) : (
<button
type="button"
title="Expand sidebar"
aria-label="Expand sidebar"
onMouseDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
togglePinned();
}}
className="inline-flex size-8 items-center justify-center rounded-[10px] transition-colors hover:bg-nav-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<img
src="/rounded-512.png"
alt=""
aria-hidden="true"
draggable={false}
className="size-5 rounded-[6px] object-cover"
/>
<span className="sr-only">Expand sidebar</span>
</button>
)}
</div>
)}
<div
className="h-full min-w-0 flex-1 border-b border-border/35 bg-muted/35"
className="pointer-events-auto absolute top-0 h-full"
style={{
left: sidebarWidth,
right: "calc(var(--studio-window-control-inset,112px) + 0.5rem)",
}}
onMouseDown={handleDragMouseDown}
onDoubleClick={handleDragDoubleClick}
aria-hidden="true"
/>
<div
className="flex h-full shrink-0 items-center gap-0.5 border-b border-border/35 bg-muted/35 px-1"
className="pointer-events-auto absolute right-1 top-0 flex h-full items-center gap-0.5 px-1"
role="toolbar"
aria-label="Window controls"
>
@ -269,7 +340,11 @@ export function WindowTitlebar({
label="Minimize window"
onClick={() => runWindowAction((appWindow) => appWindow.minimize())}
>
<MinimizeGlyph />
<HugeiconsIcon
icon={MinusSignIcon}
strokeWidth={1.75}
className="size-[15px]"
/>
</WindowControlButton>
<WindowControlButton
label={maximized ? "Restore window" : "Maximize window"}
@ -277,14 +352,22 @@ export function WindowTitlebar({
runWindowAction((appWindow) => appWindow.toggleMaximize())
}
>
{maximized ? <RestoreGlyph /> : <MaximizeGlyph />}
<HugeiconsIcon
icon={maximized ? SquareSquareIcon : SquareIcon}
strokeWidth={1.75}
className="size-[14px]"
/>
</WindowControlButton>
<WindowControlButton
label="Close window"
onClick={() => runWindowAction((appWindow) => appWindow.close())}
className="hover:bg-destructive hover:text-destructive-foreground focus-visible:ring-destructive/70"
className="hover:bg-destructive/10 hover:text-destructive focus-visible:ring-destructive/70 dark:hover:bg-destructive/20"
>
<CloseGlyph />
<HugeiconsIcon
icon={Cancel01Icon}
strokeWidth={1.75}
className="size-[15px]"
/>
</WindowControlButton>
</div>
</header>

View file

@ -512,7 +512,7 @@ function CompareShell({
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col">
<div
data-tour="chat-compare-view"
className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col md:flex-row"
className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col pt-[var(--studio-content-top-inset,0px)] md:flex-row"
>
{children}
</div>
@ -650,11 +650,16 @@ function GeneralCompareHeader({
// Controlled so the body-portaled popover can't linger over another tab off-route.
const active = useChatActive();
const [selectorOpen, setSelectorOpen] = useState(false);
const { pinned } = useSidebar();
return (
<div
className={cn(
"flex h-[48px] shrink-0 items-start pt-[11px] gap-2 bg-background",
side === "left" ? "pl-12 pr-3 md:pl-2" : "pl-3 pr-12",
"pointer-events-none relative z-[65] flex h-[48px] shrink-0 items-start gap-2 bg-background pt-[var(--studio-chat-header-padding-top,11px)]",
side === "left"
? pinned
? "pl-12 pr-3 md:pl-2"
: "pl-12 pr-3 md:pl-[calc(0.5rem+max(0px,var(--studio-mac-traffic-light-inset,0px)-var(--sidebar-width-icon,3rem)))]"
: "pl-3 pr-[calc(3rem+var(--studio-chat-header-right-inset,var(--studio-window-control-inset,0px)))]",
)}
>
<ModelSelector
@ -667,7 +672,7 @@ function GeneralCompareHeader({
onModelsChange={onModelsChange}
deleteDisabled={deleteDisabled}
variant="ghost"
className="max-w-[80%] !h-[34px]"
className="pointer-events-auto max-w-[80%] !h-[var(--studio-chat-control-height,34px)]"
open={active && selectorOpen}
onOpenChange={(open) => setSelectorOpen(active && open)}
/>
@ -2049,7 +2054,7 @@ export function ChatPage({
() => setSettingsOpen(false),
[setSettingsOpen],
);
const { isMobile } = useSidebar();
const { isMobile, pinned } = useSidebar();
const enterCompare = useCallback(() => {
viewBeforeCompareRef.current = { ...search };
@ -2377,19 +2382,23 @@ export function ChatPage({
beneath it, instead of a hard cut. */}
{view.mode !== "compare" && (
<div
aria-hidden={true}
className="pointer-events-none absolute left-0 right-[10px] top-[48px] z-20 h-6 bg-gradient-to-b from-background to-[rgb(from_var(--background)_r_g_b/0)]"
aria-hidden
className="pointer-events-none absolute left-0 right-[10px] top-[calc(var(--studio-content-top-inset,0px)+var(--studio-chat-header-height,48px))] z-20 h-6 bg-gradient-to-b from-background to-transparent"
/>
)}
<div
className={cn(
"absolute top-0 left-0 right-[10px] z-30 flex h-[48px] shrink-0 items-start pt-[11px] pr-2 bg-background",
isMobile ? "pl-12 pr-1.5" : "pl-2",
"pointer-events-none absolute top-[var(--studio-content-top-inset,0px)] left-0 right-[10px] z-[66] flex h-[var(--studio-chat-header-height,48px)] shrink-0 items-start bg-background pt-[var(--studio-chat-header-padding-top,11px)] pr-[calc(0.5rem+var(--studio-chat-header-right-inset,var(--studio-window-control-inset,0px)))]",
isMobile
? "pl-12"
: pinned
? "pl-2"
: "pl-[calc(0.5rem+max(0px,var(--studio-mac-traffic-light-inset,0px)-var(--sidebar-width-icon,3rem)))]",
view.mode === "compare" &&
"right-[10px] left-auto w-auto bg-transparent pl-0 pr-2",
"right-[10px] left-auto w-auto bg-transparent pl-0 pr-[calc(0.5rem+var(--studio-chat-header-right-inset,var(--studio-window-control-inset,0px)))]",
)}
>
<div className="flex items-center gap-1">
<div className="pointer-events-auto flex items-center gap-1">
{view.mode !== "compare" && (
<ModelSelector
models={models}
@ -2409,13 +2418,23 @@ export function ChatPage({
triggerDataTour="chat-model-selector"
contentDataTour="chat-model-selector-popover"
showCloudIndicator={isExternalModel}
className="max-w-[62vw] !pr-3 sm:max-w-none !h-[34px]"
className="max-w-[62vw] !pr-3 sm:max-w-none !h-[var(--studio-chat-control-height,34px)]"
/>
)}
{incognito && view.mode === "single" && (
<div className="flex h-[var(--studio-chat-control-height,34px)] shrink-0 items-center gap-1.5 self-center rounded-full bg-primary/10 px-2.5 font-medium text-[13px] text-primary">
<HugeiconsIcon
icon={BubbleChatTemporaryIcon}
strokeWidth={2}
className="size-3.5"
/>
<span>Temporary</span>
</div>
)}
{view.mode !== "compare" && currentProjectId && (
<nav
aria-label="Project location"
className="flex h-[34px] min-w-0 items-center gap-1.5 self-center text-[13.5px] tracking-nav text-muted-foreground"
className="flex h-[var(--studio-chat-control-height,34px)] min-w-0 items-center gap-1.5 self-center text-[13.5px] tracking-nav text-muted-foreground"
>
<ProjectSwitcher
currentProject={currentProject}
@ -2474,7 +2493,7 @@ export function ChatPage({
</div>
) : null}
</div>
<div className="ml-auto flex items-center gap-2">
<div className="pointer-events-auto ml-auto flex items-center gap-2">
{view.mode === "single" && contextUsage ? (
<ContextUsageBar
used={contextUsage.totalTokens}
@ -2484,7 +2503,7 @@ export function ChatPage({
cacheWrites={contextUsage.cacheWriteTokens}
promptTokens={contextUsage.promptTokens}
completionTokens={contextUsage.completionTokens}
className="h-[34px]"
className="h-[var(--studio-chat-control-height,34px)]"
/>
) : null}
{view.mode === "single" && (
@ -2494,7 +2513,7 @@ export function ChatPage({
type="button"
onClick={toggleIncognito}
className={cn(
"flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"flex size-[var(--studio-chat-control-height,34px)] cursor-pointer items-center justify-center rounded-[12px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
incognito
? "bg-primary/10 text-primary hover:bg-primary/15"
: "text-nav-fg hover:bg-nav-surface-hover hover:text-black dark:hover:text-white",
@ -2524,7 +2543,7 @@ export function ChatPage({
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="flex h-[34px] w-[34px] translate-x-[2px] cursor-pointer items-center justify-center rounded-full text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex size-[var(--studio-chat-control-height,34px)] translate-x-[2px] cursor-pointer items-center justify-center rounded-[12px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Open run settings"
>
<HugeiconsIcon

View file

@ -1783,7 +1783,14 @@ export function ChatSettingsPanel({
return (
<aside
data-tour="chat-settings"
className={`relative z-50 shrink-0 h-full overflow-hidden bg-panel-surface text-panel-surface-fg font-heading ${open ? "w-[17rem] border-l border-sidebar-border" : "w-0"}`}
className={cn(
"relative z-50 shrink-0 overflow-hidden bg-panel-surface text-panel-surface-fg font-heading",
open ? "w-[17rem] border-l border-sidebar-border" : "w-0",
)}
style={{
height: "calc(100% - var(--studio-custom-titlebar-height, 0px))",
marginTop: "var(--studio-custom-titlebar-height, 0px)",
}}
>
<div className="h-full w-full">{settingsContent}</div>
</aside>

View file

@ -250,29 +250,33 @@ export function AboutTab() {
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.about.dangerZone")}>
<SettingsRow
destructive={true}
label={t("settings.about.shutDownStudio")}
description={t("settings.about.shutDownStudioDescription")}
>
<Button
variant="outline"
size="sm"
onClick={() => setShutdownOpen(true)}
className="text-destructive hover:text-destructive hover:border-destructive/60"
{!isTauri && (
<SettingsSection title={t("settings.about.dangerZone")}>
<SettingsRow
destructive={true}
label={t("settings.about.shutDownStudio")}
description={t("settings.about.shutDownStudioDescription")}
>
<HugeiconsIcon icon={Cancel01Icon} className="size-3.5 mr-1.5" />
{t("settings.about.shutDown")}
</Button>
</SettingsRow>
</SettingsSection>
<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" />
{t("settings.about.shutDown")}
</Button>
</SettingsRow>
</SettingsSection>
)}
<ShutdownDialog
open={shutdownOpen}
onOpenChange={setShutdownOpen}
onAfterShutdown={removeTrainingUnloadGuard}
/>
{!isTauri && (
<ShutdownDialog
open={shutdownOpen}
onOpenChange={setShutdownOpen}
onAfterShutdown={removeTrainingUnloadGuard}
/>
)}
</div>
);
}

View file

@ -37,6 +37,18 @@ $DefaultLlamaSource = "https://github.com/ggml-org/llama.cpp"
$DefaultLlamaTag = "latest"
$DefaultLlamaForceCompileRef = "master"
# Corporate-mirror / proxy escape hatch for the frontend npm/bun install (#6491).
# studio/frontend/.npmrc pins registry=https://registry.npmjs.org/ as a supply-chain
# lock, which overrides a corporate user's ~/.npmrc proxy and causes 403s behind a
# firewall. UNSLOTH_NPM_REGISTRY is a deliberate opt-in: when set we splat it as
# `--registry <url>` into every npm/bun install. `--registry` is the highest-precedence
# override for BOTH tools and leaves min-release-age / save-exact in force. Empty array
# (the default) splats to nothing, so normal installs are unchanged.
$NpmRegistryArgs = @()
if ($env:UNSLOTH_NPM_REGISTRY) {
$NpmRegistryArgs = @('--registry', $env:UNSLOTH_NPM_REGISTRY)
}
# Verbose can be enabled either by CLI flag or by UNSLOTH_VERBOSE=1.
$script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq '1')
foreach ($a in $args) {
@ -877,6 +889,41 @@ function substep {
Write-StudioStdoutMirror (" {0,-15}{1}" -f "", $Message)
}
function Show-NpmRegistryHint {
# Print actionable guidance when a frontend/OXC npm/bun install fails and the
# registry lock is the likely cause (corporate firewall/proxy). No-op once the
# user has opted in via UNSLOTH_NPM_REGISTRY. We never switch registries
# automatically -- we only guide.
if ($env:UNSLOTH_NPM_REGISTRY) { return }
$mirror = $env:NPM_CONFIG_REGISTRY
if (-not $mirror) {
# Read npm config from a dir with no project .npmrc so the frontend's pinned
# registry= does not mask the user's ~/.npmrc / global mirror.
$pushed = $false
try {
Push-Location ([System.IO.Path]::GetTempPath()) -ErrorAction Stop
$pushed = $true
$mirror = (& npm config get registry 2>$null | Out-String).Trim()
} catch { $mirror = "" } finally { if ($pushed) { Pop-Location } }
}
if ($mirror -in @("", "undefined", "null", "https://registry.npmjs.org", "https://registry.npmjs.org/")) {
$mirror = ""
}
Write-Host ""
step "frontend" "registry.npmjs.org looks blocked (corporate firewall/proxy?)" "Yellow"
if ($mirror) {
substep "Studio pins the public npm registry; your mirror is being ignored."
substep "Detected a registry in your npm config:"
substep " $mirror"
substep "Re-run pointing Studio at it:"
substep " `$env:UNSLOTH_NPM_REGISTRY='$mirror'; .\install.ps1 --local"
} else {
substep "If you use a private mirror/proxy, point Studio at it and re-run:"
substep " `$env:UNSLOTH_NPM_REGISTRY='https://your-mirror.example/api/npm/'; .\install.ps1 --local"
}
substep "(min-release-age and save-exact stay enforced.)"
}
# ─────────────────────────────────────────────
# Banner
# ─────────────────────────────────────────────
@ -2113,7 +2160,7 @@ if ($NeedNodeForSetup) {
substep "installing bun (faster frontend package installs)..."
$prevEAP_bun = $ErrorActionPreference
$ErrorActionPreference = "Continue"
Invoke-SetupCommand { npm install -g bun --allow-scripts=bun } | Out-Null
Invoke-SetupCommand { npm install -g bun --allow-scripts=bun @NpmRegistryArgs } | Out-Null
$ErrorActionPreference = $prevEAP_bun
Refresh-Environment
# Refresh-Environment rebuilds PATH (Machine;User;current), demoting the
@ -2173,7 +2220,7 @@ if ($NeedFrontendBuild -and -not $IsPipInstall) {
# the cache + retry once before falling back to npm.
if ($UseBun) {
Write-Host " Using bun for package install (faster)" -ForegroundColor DarkGray
$bunExit = Invoke-SetupCommand { bun install }
$bunExit = Invoke-SetupCommand { bun install @NpmRegistryArgs }
# On Windows, .bin/ entries vary by package manager:
# npm → tsc, tsc.cmd, tsc.ps1
# bun → tsc.exe, tsc.bunx
@ -2187,7 +2234,7 @@ if ($NeedFrontendBuild -and -not $IsPipInstall) {
Remove-Item "node_modules" -Recurse -Force -ErrorAction SilentlyContinue
}
Invoke-SetupCommand { bun pm cache rm } | Out-Null
$bunExit = Invoke-SetupCommand { bun install }
$bunExit = Invoke-SetupCommand { bun install @NpmRegistryArgs }
$hasTsc = (Test-Path "node_modules\.bin\tsc") -or (Test-Path "node_modules\.bin\tsc.cmd") -or (Test-Path "node_modules\.bin\tsc.exe") -or (Test-Path "node_modules\.bin\tsc.bunx")
$hasVite = (Test-Path "node_modules\.bin\vite") -or (Test-Path "node_modules\.bin\vite.cmd") -or (Test-Path "node_modules\.bin\vite.exe") -or (Test-Path "node_modules\.bin\vite.bunx")
if ($bunExit -ne 0 -or -not $hasTsc -or -not $hasVite) {
@ -2206,13 +2253,14 @@ if ($NeedFrontendBuild -and -not $IsPipInstall) {
}
}
if (-not $UseBun) {
$npmExit = Invoke-SetupCommand { npm install }
$npmExit = Invoke-SetupCommand { npm install @NpmRegistryArgs }
if ($npmExit -ne 0) {
Pop-Location
$ErrorActionPreference = $prevEAP_npm
foreach ($gi in $HiddenGitignores) { Rename-Item -Path "$gi._twbuild" -NewName (Split-Path $gi -Leaf) -Force -ErrorAction SilentlyContinue }
Write-Host "[ERROR] npm install failed (exit code $npmExit)" -ForegroundColor Red
Write-Host " Try running 'npm install' manually in frontend/ to see errors" -ForegroundColor Yellow
Show-NpmRegistryHint
exit 1
}
}
@ -2249,11 +2297,12 @@ if ((Test-Path $OxcValidatorDir) -and $NodeSource -ne "skip" -and (Get-Command n
$prevEAP_oxc = $ErrorActionPreference
$ErrorActionPreference = "Continue"
Push-Location $OxcValidatorDir
$oxcInstallExit = Invoke-SetupCommand { npm install }
$oxcInstallExit = Invoke-SetupCommand { npm install @NpmRegistryArgs }
if ($oxcInstallExit -ne 0) {
Pop-Location
$ErrorActionPreference = $prevEAP_oxc
Write-Host "[ERROR] OXC validator npm install failed (exit code $oxcInstallExit)" -ForegroundColor Red
Show-NpmRegistryHint
exit 1
}
Pop-Location

View file

@ -74,6 +74,62 @@ verbose_substep() {
return 0
}
# ── Corporate-mirror / proxy escape hatch for the frontend npm/bun install (#6491) ──
# studio/frontend/.npmrc pins registry=https://registry.npmjs.org/ as a supply-chain
# lock. A project-level pin overrides a corporate user's ~/.npmrc proxy, so the install
# hits npmjs.org directly and a firewall returns 403. UNSLOTH_NPM_REGISTRY is a
# deliberate opt-in: when set we thread it as `--registry <url>` into every npm/bun
# install. `--registry` is the highest-precedence override for BOTH tools and leaves
# min-release-age / save-exact in force. Empty array (the default) expands to nothing
# under `set -u`, so normal installs are unchanged.
_NPM_REGISTRY_ARGS=()
if [ -n "${UNSLOTH_NPM_REGISTRY:-}" ]; then
_NPM_REGISTRY_ARGS=(--registry "$UNSLOTH_NPM_REGISTRY")
fi
# Failure-path capture log consumed by _suggest_npm_registry. Set to a temp file
# around the npm/bun installs; "" elsewhere so unrelated run_quiet calls don't capture.
_CAPTURE_LOG=""
# Print actionable guidance when a frontend/OXC npm/bun install fails and the registry
# lock is the likely cause (corporate firewall/proxy). No-op once the user has opted in
# via UNSLOTH_NPM_REGISTRY. We never switch registries automatically -- we only guide.
# $1 = path to a captured install log (may be empty/missing).
_suggest_npm_registry() {
[ -n "${UNSLOTH_NPM_REGISTRY:-}" ] && return 0
local _log="${1:-}"
# If we captured output and it does NOT look like a registry/network problem, stay
# quiet -- the raw error already shown is more useful than a misleading hint.
if [ -n "$_log" ] && [ -s "$_log" ] \
&& ! grep -Eqi '40[13]|ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ConnectionRefused|failed to resolve|registry\.npmjs\.org|getaddrinfo|tunneling socket|network|proxy|self.?signed|unable to (get|verify)' "$_log"; then
return 0
fi
# Best-effort: surface a mirror the user already configured (env or ~/.npmrc).
# Read npm config from / (a dir with no project .npmrc) so the frontend's pinned
# registry= does not mask the user's ~/.npmrc / global mirror -- the caller is
# still inside studio/frontend when this runs.
local _mirror="${NPM_CONFIG_REGISTRY:-${npm_config_registry:-}}"
if [ -z "$_mirror" ] && command -v npm >/dev/null 2>&1; then
_mirror="$( (cd / 2>/dev/null && npm config get registry) 2>/dev/null || true )"
fi
case "$_mirror" in
""|undefined|null|https://registry.npmjs.org|https://registry.npmjs.org/) _mirror="" ;;
esac
printf '\n' >&2
step "frontend" "registry.npmjs.org looks blocked (corporate firewall/proxy?)" "$C_WARN" >&2
if [ -n "$_mirror" ]; then
substep "Studio pins the public npm registry; your mirror is being ignored." >&2
substep "Detected a registry in your npm config:" >&2
substep " $_mirror" >&2
substep "Re-run pointing Studio at it:" >&2
substep " UNSLOTH_NPM_REGISTRY=$_mirror ./install.sh --local" >&2
else
substep "If you use a private mirror/proxy, point Studio at it and re-run:" >&2
substep " UNSLOTH_NPM_REGISTRY=https://your-mirror.example/api/npm/ ./install.sh --local" >&2
fi
substep "(min-release-age and save-exact stay enforced.)" >&2
return 0
}
run_maybe_quiet() {
if _is_verbose; then
"$@"
@ -113,6 +169,7 @@ _run_quiet() {
local exit_code=$?
step "error" "$label failed (exit code $exit_code)" "$C_ERR" >&2
cat "$tmplog" >&2
if [ -n "${_CAPTURE_LOG:-}" ]; then cat "$tmplog" >> "$_CAPTURE_LOG" 2>/dev/null || true; fi
rm -f "$tmplog"
if [ "$on_fail" = "exit" ]; then
@ -647,7 +704,7 @@ elif [ "$NODE_SOURCE" = bundled ]; then
substep "installing bun..."
# --allow-scripts=bun: npm >=11.16 gates install scripts and bun's
# postinstall fetches its binary; without it the install is a broken stub.
if run_maybe_quiet npm install -g bun --allow-scripts=bun && command -v bun &>/dev/null; then
if run_maybe_quiet npm install -g bun --allow-scripts=bun "${_NPM_REGISTRY_ARGS[@]+"${_NPM_REGISTRY_ARGS[@]}"}" && command -v bun &>/dev/null; then
substep "bun installed ($(bun --version))"
else
substep "bun install skipped (npm will be used instead)"
@ -690,7 +747,7 @@ trap _restore_gitignores EXIT
_try_bun_install() {
local _log _exit_code=0
_log=$(mktemp)
bun install >"$_log" 2>&1 || _exit_code=$?
bun install "${_NPM_REGISTRY_ARGS[@]+"${_NPM_REGISTRY_ARGS[@]}"}" >"$_log" 2>&1 || _exit_code=$?
# bun may create .exe shims on Windows (Git Bash / MSYS2) instead of plain scripts
if [ "$_exit_code" -eq 0 ] \
@ -707,11 +764,15 @@ _try_bun_install() {
echo " bun install exited 0 but critical binaries are missing:"
fi
sed 's/^/ | /' "$_log" >&2
if [ -n "${_CAPTURE_LOG:-}" ]; then cat "$_log" >> "$_CAPTURE_LOG" 2>/dev/null || true; fi
rm -f "$_log"
rm -rf node_modules
return 1
}
# Capture install output (bun + npm fallback) so we can detect a registry block.
_FRONTEND_INSTALL_LOG=$(mktemp)
_CAPTURE_LOG="$_FRONTEND_INSTALL_LOG"
_bun_install_ok=false
if command -v bun &>/dev/null; then
substep "using bun for package install (faster)"
@ -728,12 +789,19 @@ if command -v bun &>/dev/null; then
fi
fi
if [ "$_bun_install_ok" = false ]; then
run_quiet_no_exit "npm install" npm install --no-fund --no-audit --loglevel=error
_npm_install_rc=$?
# `|| _npm_install_rc=$?` keeps this off `set -e`'s exit path (run_quiet_no_exit
# returns non-zero on failure) so the hint branch is reachable; it also captures
# the exact exit code. Mirrors the `|| BUILD_OK=false` idiom used below.
_npm_install_rc=0
run_quiet_no_exit "npm install" npm install --no-fund --no-audit --loglevel=error "${_NPM_REGISTRY_ARGS[@]+"${_NPM_REGISTRY_ARGS[@]}"}" || _npm_install_rc=$?
if [ "$_npm_install_rc" -ne 0 ]; then
_suggest_npm_registry "$_FRONTEND_INSTALL_LOG"
rm -f "$_FRONTEND_INSTALL_LOG"
exit "$_npm_install_rc"
fi
fi
_CAPTURE_LOG=""
rm -f "$_FRONTEND_INSTALL_LOG"
run_quiet "npm run build" npm run build
_restore_gitignores
@ -759,11 +827,19 @@ fi # end frontend build check
# Node, so do not run npm install against an unsuitable/absent system Node.
if [ -d "$_OXC_DIR" ] && [ "${NODE_SOURCE:-}" != skip ] && command -v npm &>/dev/null; then
cd "$_OXC_DIR"
run_quiet_no_exit "npm install (oxc validator runtime)" npm install --no-fund --no-audit --loglevel=error
_oxc_install_rc=$?
_OXC_INSTALL_LOG=$(mktemp)
_CAPTURE_LOG="$_OXC_INSTALL_LOG"
# `|| _oxc_install_rc=$?` keeps this off `set -e`'s exit path so the hint branch
# below is reachable; it also captures the exact exit code.
_oxc_install_rc=0
run_quiet_no_exit "npm install (oxc validator runtime)" npm install --no-fund --no-audit --loglevel=error "${_NPM_REGISTRY_ARGS[@]+"${_NPM_REGISTRY_ARGS[@]}"}" || _oxc_install_rc=$?
_CAPTURE_LOG=""
if [ "$_oxc_install_rc" -ne 0 ]; then
_suggest_npm_registry "$_OXC_INSTALL_LOG"
rm -f "$_OXC_INSTALL_LOG"
exit "$_oxc_install_rc"
fi
rm -f "$_OXC_INSTALL_LOG"
cd "$SCRIPT_DIR"
elif [ -d "$_OXC_DIR" ] && [ "${NODE_SOURCE:-}" != skip ]; then
# No npm on PATH: skip rather than abort; the backend Node resolver degrades

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

View file

@ -22,17 +22,21 @@
{
"label": "main",
"title": "Unsloth Studio (Desktop)",
"width": 690,
"height": 480,
"width": 760,
"height": 560,
"center": true,
"visible": false,
"resizable": false
"resizable": false,
"decorations": true,
"titleBarStyle": "Overlay",
"trafficLightPosition": { "x": 14, "y": 24 },
"hiddenTitle": true
}
]
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQ2RDhGMDMzNDNEMTlGMkQKUldRdG45RkRNL0RZMXZsaVo2TElUQlVHb1hVNWEyajhUOGFXeTdNVDFZTFdBVUtlZUh5L2wwWVYK",
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE0NjlBNkEwQTc0QjY0Q0UKUldUT1pFdW5vS1pwRkl3TXRrOGlYdDNMeTh1bEhSbURrM3IyT2lTK1BiekpTc0Z2SXZFeVNibDIK",
"endpoints": [
"https://github.com/unslothai/unsloth/releases/download/desktop-latest/latest.json"
],
@ -72,6 +76,9 @@
}
},
"linux": {
"appimage": {
"bundleMediaFramework": false
},
"deb": {
"postRemoveScript": "./linux/postremove.sh"
}

View file

@ -0,0 +1,31 @@
"""Run the install.sh UV_OVERRIDE space-safety shell test (issue #6503) under
pytest, so the auto-discovered CPU test job executes it. The dedicated
`Shell installer tests` CI job runs a fixed script list that this is not part
of, so without this wrapper the regression would only be covered locally via
tests/run_all.sh.
"""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
SHELL_TEST = REPO_ROOT / "tests" / "sh" / "test_install_uv_override_space.sh"
@pytest.mark.skipif(sys.platform == "win32", reason = "POSIX shell installer test")
@pytest.mark.skipif(shutil.which("bash") is None, reason = "bash not available")
def test_install_uv_override_space_shell():
assert SHELL_TEST.is_file(), f"missing shell test: {SHELL_TEST}"
proc = subprocess.run(
["bash", str(SHELL_TEST)],
capture_output = True,
text = True,
)
assert proc.returncode == 0, proc.stdout + proc.stderr
assert "ALL PASSED" in proc.stdout, proc.stdout + proc.stderr

View file

@ -1,9 +1,9 @@
"""_fix_pad_token dispatch in unsloth/tokenizer_utils.py.
It must delegate to unsloth_zoo's shared fix_pad_token when present (single
source of truth), and fall back to the narrow vision-token swap against an
older unsloth_zoo. Static + CPU-only: the two helpers are exec'd in isolation
so the test never imports torch / transformers / unsloth.
source of truth), and fall back to a no-op against an older unsloth_zoo. Static
+ CPU-only: _fix_pad_token is exec'd in isolation so the test never imports
torch / transformers / unsloth.
"""
import ast
@ -15,9 +15,6 @@ REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
TOK_PATH = os.path.join(REPO_ROOT, "unsloth", "tokenizer_utils.py")
WANTED = {
"_VISION_PAD_TOKENS",
"_SAFE_TEXT_PAD_TOKENS",
"_fix_vision_pad_token",
"_fix_pad_token",
}
@ -69,17 +66,17 @@ def test_fix_pad_token_none_is_noop():
assert ns["_fix_pad_token"](None) is None
def test_fix_pad_token_falls_back_without_shared_module(monkeypatch):
def test_fallback_keeps_pad_named_token(monkeypatch):
ns = _load_pad_helpers()
_block_shared_module(monkeypatch)
# Qwen3 text tokenizer shipping a vision pad_token -> narrow swap heals it.
# A pad-named token (e.g. <|vision_pad|>) is a valid pad -> fallback keeps it.
tok = FakeTok(
{"<|endoftext|>": 1, "<|im_end|>": 2, "<|vision_pad|>": 3},
pad = "<|vision_pad|>",
eos = "<|im_end|>",
)
ns["_fix_pad_token"](tok)
assert tok.pad_token == "<|endoftext|>"
assert tok.pad_token == "<|vision_pad|>"
assert tok.pad_token != tok.eos_token

View file

@ -15,6 +15,7 @@ sh "$TESTS_DIR/sh/test_resolve_cuda_archs.sh"
sh "$TESTS_DIR/sh/test_strixhalo_wsl_reroute.sh"
sh "$TESTS_DIR/sh/test_uninstall_shared_icon.sh"
sh "$TESTS_DIR/sh/test_torch_flavor.sh"
sh "$TESTS_DIR/sh/test_install_uv_override_space.sh"
echo ""
echo "=== Python tests ==="

View file

@ -0,0 +1,112 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
# uv splits UV_OVERRIDE on whitespace, so a repo cloned under a path with a space
# truncates it and aborts every later uv call (issue #6503). install.sh must hand
# uv a space-free copy. Exercises the real install.sh hardening block.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INSTALL_SH="$SCRIPT_DIR/../../install.sh"
PASS=0
FAIL=0
ok() { echo " PASS: $1"; PASS=$((PASS + 1)); }
bad() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
# Extract the UV_OVERRIDE hardening block (outer case ... esac plus the export)
# and run it directly, so the test tracks install.sh rather than a copy of it.
BLOCK=$(awk '
/case "[$]_OVERRIDES_FILE" in/ { grab = 1 }
grab { print }
grab && /export UV_OVERRIDE="[$]_OVERRIDES_FILE"/ { exit }
' "$INSTALL_SH")
if ! printf '%s' "$BLOCK" | grep -q 'export UV_OVERRIDE'; then
echo " FAIL: could not extract UV_OVERRIDE block from install.sh"
exit 1
fi
run_block() {
_OVERRIDES_FILE="$1"
_UV_OVERRIDE_TMPDIR=""
unset UV_OVERRIDE
eval "$BLOCK"
}
echo "=== test_install_uv_override_space ==="
# 1. Spaced path -> space-free copy with identical contents, temp dir tracked.
WORK=$(mktemp -d)
mkdir -p "$WORK/Open Source"
SRC="$WORK/Open Source/overrides-darwin-arm64.txt"
printf 'transformers>=4.57.6\n' > "$SRC"
run_block "$SRC"
case "$UV_OVERRIDE" in
*[[:space:]]*) bad "spaced path: UV_OVERRIDE still contains whitespace ($UV_OVERRIDE)" ;;
*) ok "spaced path: UV_OVERRIDE is whitespace-free" ;;
esac
[ "$UV_OVERRIDE" != "$SRC" ] && ok "spaced path: points at a copy" || bad "spaced path: not copied"
[ "$(cat "$UV_OVERRIDE" 2>/dev/null)" = "transformers>=4.57.6" ] \
&& ok "spaced path: copy contents identical" || bad "spaced path: contents differ"
{ [ -n "$_UV_OVERRIDE_TMPDIR" ] && [ -d "$_UV_OVERRIDE_TMPDIR" ]; } \
&& ok "spaced path: temp dir tracked for cleanup" || bad "spaced path: temp dir not tracked"
# The exit-trap cleanup (_on_install_exit) must then remove it.
[ -n "$_UV_OVERRIDE_TMPDIR" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true
[ ! -d "$_UV_OVERRIDE_TMPDIR" ] && ok "spaced path: temp dir removable" || bad "spaced path: temp dir lingers"
rm -rf "$WORK"
# 2. No-space path -> passthrough, no temp dir.
PLAIN=$(mktemp -d)
PSRC="$PLAIN/overrides-darwin-arm64.txt"
printf 'transformers>=4.57.6\n' > "$PSRC"
run_block "$PSRC"
[ "$UV_OVERRIDE" = "$PSRC" ] && ok "no-space path: UV_OVERRIDE unchanged" || bad "no-space path: changed ($UV_OVERRIDE)"
[ -z "$_UV_OVERRIDE_TMPDIR" ] && ok "no-space path: no temp dir created" || bad "no-space path: temp dir created"
rm -rf "$PLAIN"
# 3. TMPDIR itself contains a space -> fall back to the original path, no leak.
WORK2=$(mktemp -d)
mkdir -p "$WORK2/Open Source" "$WORK2/tmp dir"
SRC2="$WORK2/Open Source/overrides-darwin-arm64.txt"
printf 'transformers>=4.57.6\n' > "$SRC2"
RES=$( TMPDIR="$WORK2/tmp dir"; export TMPDIR; run_block "$SRC2"
printf 'UV_OVERRIDE=%s\nTMPDIR_VAR=%s\n' "$UV_OVERRIDE" "$_UV_OVERRIDE_TMPDIR" )
echo "$RES" | grep -qx "UV_OVERRIDE=$SRC2" \
&& ok "spaced TMPDIR: falls back to original path" || bad "spaced TMPDIR: did not fall back ($RES)"
echo "$RES" | grep -qx "TMPDIR_VAR=" \
&& ok "spaced TMPDIR: no temp dir tracked" || bad "spaced TMPDIR: temp dir tracked"
# mktemp may have created a dir under the spaced TMPDIR; it must not be leaked.
_leftover=$(find "$WORK2/tmp dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -n1)
[ -z "$_leftover" ] && ok "spaced TMPDIR: no leaked temp dir" || bad "spaced TMPDIR: leaked $_leftover"
rm -rf "$WORK2"
# 4. A tab in the path is whitespace uv also splits on -> copied like a space.
WORK3=$(mktemp -d)
TABDIR=$(printf 'Open\tSource')
mkdir -p "$WORK3/$TABDIR"
SRC3="$WORK3/$TABDIR/overrides-darwin-arm64.txt"
printf 'transformers>=4.57.6\n' > "$SRC3"
run_block "$SRC3"
case "$UV_OVERRIDE" in
*[[:space:]]*) bad "tab path: UV_OVERRIDE still contains whitespace" ;;
*) ok "tab path: UV_OVERRIDE is whitespace-free" ;;
esac
[ -n "$_UV_OVERRIDE_TMPDIR" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true
rm -rf "$WORK3"
# 5. install.sh must clear _UV_OVERRIDE_TMPDIR before registering the exit trap,
# so an inherited value can never reach the trap's rm -rf.
_init_line=$(grep -n '^_UV_OVERRIDE_TMPDIR=""' "$INSTALL_SH" | head -n1 | cut -d: -f1)
_trap_line=$(grep -n '^trap _on_install_exit EXIT' "$INSTALL_SH" | head -n1 | cut -d: -f1)
{ [ -n "$_init_line" ] && [ -n "$_trap_line" ] && [ "$_init_line" -lt "$_trap_line" ]; } \
&& ok "init: _UV_OVERRIDE_TMPDIR cleared before exit trap" \
|| bad "init: _UV_OVERRIDE_TMPDIR not cleared before exit trap (init=$_init_line trap=$_trap_line)"
echo ""
echo " PASS: $PASS"
echo " FAIL: $FAIL"
if [ "$FAIL" -gt 0 ]; then
echo "FAILED"
exit 1
fi
echo "ALL PASSED"

View file

@ -626,72 +626,22 @@ def _load_correct_tokenizer(
return fast_tokenizer
# Qwen3 text models share Qwen3-VL's vocab, so configs ship a vision pad_token;
# padding text-only training with one yields NaN losses (#3155, #4104).
_VISION_PAD_TOKENS = frozenset(
(
"<|vision_pad|>",
"<|image_pad|>",
"<|video_pad|>",
"<|audio_pad|>",
)
)
# Preference order; <unk> excluded since reusing it as pad masks real OOV tokens.
_SAFE_TEXT_PAD_TOKENS = ("<|endoftext|>", "<pad>", "[PAD]")
def _fix_vision_pad_token(tokenizer):
"""Swap a vision pad_token on a text-only tokenizer for a safe text token (#3155)."""
if tokenizer is None:
return tokenizer
if hasattr(tokenizer, "image_processor"):
return tokenizer
pad_token = getattr(tokenizer, "pad_token", None)
if pad_token is None or pad_token not in _VISION_PAD_TOKENS:
return tokenizer
get_vocab = getattr(tokenizer, "get_vocab", None)
if get_vocab is None:
return tokenizer
vocab = get_vocab()
if not isinstance(vocab, dict):
return tokenizer
new_pad_token = None
for candidate in _SAFE_TEXT_PAD_TOKENS:
if candidate in vocab and candidate != getattr(tokenizer, "eos_token", None):
new_pad_token = candidate
break
# Fall back to eos_token only if it is a distinct, non-vision token.
if new_pad_token is None:
eos_token = getattr(tokenizer, "eos_token", None)
if eos_token is not None and eos_token != pad_token and eos_token not in _VISION_PAD_TOKENS:
new_pad_token = eos_token
if new_pad_token is None:
return tokenizer
tokenizer.pad_token = new_pad_token
logger.warning(
f"Unsloth: pad_token was a vision token ({pad_token}) on a text-only "
f"model. Replaced with {new_pad_token} to avoid NaN losses."
)
return tokenizer
def _fix_pad_token(tokenizer):
"""Heal a bad/missing pad_token before chat-template repair.
Delegates to unsloth_zoo's shared fix_pad_token (single source of truth) when
available, falling back to the narrow vision-token swap against an older
unsloth_zoo. allow_add=False keeps this side-effect free: there is no model
here to resize embeddings, so a brand new pad token is never added - the later
model-aware patch_tokenizer call finishes the job and is idempotent.
Delegates to unsloth_zoo's shared fix_pad_token (single source of truth); against
an older unsloth_zoo without it, this is a no-op (a pad-named token like
<|vision_pad|> is already a valid pad). allow_add=False keeps this side-effect
free: there is no model here to resize embeddings, so a brand new pad token is
never added - the later model-aware patch_tokenizer call finishes the job and is
idempotent.
"""
if tokenizer is None:
return tokenizer
try:
from unsloth_zoo.pad_token import fix_pad_token
except Exception:
return _fix_vision_pad_token(tokenizer)
return tokenizer
fix_pad_token(tokenizer, allow_add = False)
return tokenizer