From d7dd5556bb2ebba49ba8538862f64cc83b2a2ca9 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 12 Jun 2026 10:56:09 -0700 Subject: [PATCH 01/18] Studio: backfill the DiffusionGemma visual-server on a tag-matching update (#6267) * Studio: backfill the DiffusionGemma visual-server on a tag-matching update An install made before the visual-server entered the copy allowlist (#6254) matches on tag yet lacks the binary, so the update skip never backfills it and DiffusionGemma fails with "runner not found". Re-extract the bundle when a matching install is missing it, gated to the fork bundles that ship it so upstream installs never thrash and so it self-limits once the binary lands. * Studio: make the visual-server backfill bypass the existing-install short-circuit The tag-matching skip is checked twice: once in install_prebuilt and again inside validate_prebuilt_attempts via existing_install_matches_choice, which only compares metadata and primary executables, not the DiffusionGemma visual-server. So a stale install missing that binary still raised ExistingInstallSatisfied and skipped the reinstall. Pass existing_install_dir as None when a backfill is needed so the bundle actually re-extracts. --- studio/install_llama_prebuilt.py | 62 ++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 219211034d..46aa14b041 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -6490,6 +6490,31 @@ def validate_prebuilt_attempts( raise PrebuiltFallback("no prebuilt bundle passed validation") +def diffusion_visual_server_backfill_needed( + install_dir: Path, host: HostInfo, choice: AssetChoice +) -> bool: + """True when an existing install matches the tag but lacks the DiffusionGemma + visual-server the chosen bundle ships. An install made before the visual-server + entered the copy allowlist matches on tag yet is missing the binary, so the + tag-match skip never backfills it (DiffusionGemma then fails with "runner not + found"). Gated to the fork ("published") bundles that actually carry it, so + upstream installs -- which never ship it -- can't thrash on repeated updates. + Once a re-extract lands the binary this returns False, so it self-limits.""" + if choice.source_label != "published": + return False + name = "llama-diffusion-gemma-visual-server" + (".exe" if host.is_windows else "") + if name not in runtime_patterns_for_choice(choice): + return False + for cand in ( + install_dir / name, + install_dir / "build" / "bin" / name, + install_dir / "build" / "bin" / "Release" / name, + ): + if cand.is_file(): + return False + return True + + def install_prebuilt( install_dir: Path, llama_tag: str, @@ -6528,11 +6553,17 @@ def install_prebuilt( ) if release_plans and existing_install_matches_plan(install_dir, host, release_plans[0]): current = release_plans[0] - log( - "existing llama.cpp install already matches selected release " - f"{current.release_tag} upstream_tag={current.llama_tag}; skipping download and install" - ) - return + if diffusion_visual_server_backfill_needed(install_dir, host, current.attempts[0]): + log( + f"existing install matches {current.release_tag} but is missing the " + "DiffusionGemma visual-server; re-extracting the bundle to backfill it" + ) + else: + log( + "existing llama.cpp install already matches selected release " + f"{current.release_tag} upstream_tag={current.llama_tag}; skipping download and install" + ) + return with tempfile.TemporaryDirectory(prefix = "unsloth-llama-prebuilt-") as tmp: work_dir = Path(tmp) probe_path = work_dir / "stories260K.gguf" @@ -6540,12 +6571,19 @@ def install_prebuilt( release_count = len(release_plans) for release_index, plan in enumerate(release_plans): choice = plan.attempts[0] + backfill = diffusion_visual_server_backfill_needed(install_dir, host, choice) if existing_install_matches_plan(install_dir, host, plan): - log( - "existing llama.cpp install already matches fallback release " - f"{plan.release_tag} upstream_tag={plan.llama_tag}; skipping reinstall" - ) - return + if backfill: + log( + f"existing install matches fallback {plan.release_tag} but is missing " + "the DiffusionGemma visual-server; re-extracting to backfill it" + ) + else: + log( + "existing llama.cpp install already matches fallback release " + f"{plan.release_tag} upstream_tag={plan.llama_tag}; skipping reinstall" + ) + return log( "selected " f"{choice.name} ({choice.source_label}) from published release " @@ -6563,7 +6601,9 @@ def install_prebuilt( release_tag = plan.release_tag, approved_checksums = plan.approved_checksums, initial_fallback_used = release_index > 0, - existing_install_dir = install_dir, + # a backfill must reinstall, so do not let the inner + # existing-install match short-circuit the re-extract + existing_install_dir = None if backfill else install_dir, ) except ExistingInstallSatisfied: return From 8c0e75367363cadcd178042756b346172d1f3e9e Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 12 Jun 2026 11:09:23 -0700 Subject: [PATCH 02/18] Update _utils.py --- unsloth/models/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 977ac74689..0dd46a036b 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2026.6.5" +__version__ = "2026.6.6" __all__ = [ "SUPPORTS_BFLOAT16", From cd270e2878ea63de1569a04fd2a7156b5d0966a1 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 12 Jun 2026 11:20:07 -0700 Subject: [PATCH 03/18] Studio: keep llama-server discovery from crashing on an access-denied candidate (#6268) * Studio: keep llama-server discovery from crashing on an access-denied candidate _find_llama_server_binary probed candidates with Path.is_file(), which raises PermissionError (WinError 5) when a path exists but is momentarily inaccessible (antivirus lock, an install replace in flight, an elevated-install ACL), aborting model validation. Treat a denied-but-present path as the real binary so discovery returns it; absent paths still skip. * Retry a transiently locked binary instead of returning a denied path Returning a still-denied path only moved the PermissionError to the next is_file() (probe_server_capabilities). Retry briefly so a transient lock clears and discovery returns an accessible path; on a persistent lock return nothing rather than a path downstream cannot stat. * Studio: do not fall back to another llama-server when a pinned one is locked A denied LLAMA_SERVER_PATH made discovery skip the explicit pin and run a lower-priority managed or PATH binary, so a load could silently use a stale or incompatible server. Split the probe into a file/absent/denied status: when the pinned path exists but stays access-denied, warn and stop rather than falling back to a different executable. * Studio: never downgrade past a denied pinned or managed llama-server Extend the no-fallback rule beyond LLAMA_SERVER_PATH: a present-but-denied UNSLOTH_LLAMA_CPP_PATH or managed ($STUDIO_HOME/llama.cpp, ~/.unsloth/llama.cpp) binary now reports temporarily-unavailable instead of silently launching a lower-priority legacy or PATH server. Shared _scan_pinned/_unavailable helpers; legacy in-tree and PATH stay genuine fallbacks (a denied candidate there just continues). * Studio: let diffusion asset lookup use a locked llama-server path for its dir DiffusionGemma does not run llama-server; _find_diffusion_assets only needs the install dir to find the adjacent llama-diffusion-gemma-visual-server. The no-fallback rule returning None on a transiently locked llama-server therefore hid an available visual-server and raised 'runner not found'. Add an include_denied option so diffusion lookup gets the locked path (its dir is all it needs), while inference keeps the no-denied-path, no-downgrade behavior. * Studio: report a locked llama-server as temporarily unavailable, not missing When the pinned/managed binary stays access-denied through the retries, discovery returns None and load_model raised 'binary not found', a terminal error that points users at reinstalling rather than retrying a transient AV/install lock. Reuse include_denied to detect the locked path and raise a distinct temporarily-unavailable, retry message instead. * Studio: GGUF preflight treats a locked llama-server as present The pre-download preflight (and so /api/inference/validate) used the default discovery, which returns None for a transiently access-denied binary, so it raised 'binary not found' for a binary that merely needs the lock to clear. Use include_denied so the existence check counts a locked binary as present; the load itself still reports a still-locked binary as temporarily unavailable. --- studio/backend/core/inference/llama_cpp.py | 124 +++++++++++++------- studio/backend/utils/models/model_config.py | 7 +- 2 files changed, 87 insertions(+), 44 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 80b9739cc1..16f6376aab 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -1071,7 +1071,7 @@ class LlamaCppBackend: # ── Binary discovery ────────────────────────────────────────── @staticmethod - def _find_llama_server_binary() -> Optional[str]: + def _find_llama_server_binary(*, include_denied: bool = False) -> Optional[str]: """ Locate the llama-server binary. @@ -1088,28 +1088,70 @@ class LlamaCppBackend: """ binary_name = "llama-server.exe" if sys.platform == "win32" else "llama-server" + def _file_status(p: Path) -> str: + # "file", "absent", or "denied" (exists but stays access-denied + # across a short retry: Windows AV/ACL or an install replace in + # flight). is_file() raises PermissionError (WinError 5) instead of + # returning False for the locked case, so never treat it as missing. + for _ in range(5): + try: + return "file" if p.is_file() else "absent" + except PermissionError: + time.sleep(0.2) + except OSError: + return "absent" + return "denied" + + def _is_file(p: Path) -> bool: + return _file_status(p) == "file" + + def _layout_candidates(d: Path) -> list: + # build layouts probed under a llama.cpp dir, highest priority first + cands = [d / binary_name, d / "build" / "bin" / binary_name] + if sys.platform == "win32": + cands.append(d / "build" / "bin" / "Release" / binary_name) + return cands + + def _unavailable(p: object) -> None: + # a pinned or managed binary that exists but is access-denied: report + # it instead of silently downgrading to a lower-priority llama-server + logger.warning( + f"llama-server at {p} exists but is access-denied (antivirus or " + "an in-flight install); not falling back to another binary, " + "retry once it is released" + ) + return None + + def _scan_pinned(paths: list): + # first existing candidate wins -> (path, None); a present-but-denied + # one -> (None, denied_path) so the caller reports it rather than + # skipping to a lower-priority location. include_denied returns the + # locked path instead: diffusion asset lookup only needs its dir. + for p in paths: + st = _file_status(p) + if st == "file": + return str(p), None + if st == "denied": + return (str(p), None) if include_denied else (None, p) + return None, None + # 1. Env var: direct path to binary env_path = os.environ.get("LLAMA_SERVER_PATH") - if env_path and Path(env_path).is_file(): - return env_path + if env_path: + hit, locked = _scan_pinned([Path(env_path)]) + if locked is not None: + return _unavailable(locked) + if hit: + return hit # 1b. UNSLOTH_LLAMA_CPP_PATH: custom llama.cpp install dir custom_llama_cpp = os.environ.get("UNSLOTH_LLAMA_CPP_PATH") if custom_llama_cpp: - custom_dir = Path(custom_llama_cpp) - # Root dir (make builds) - root_bin = custom_dir / binary_name - if root_bin.is_file(): - return str(root_bin) - # build/bin/ (cmake on Linux) - cmake_bin = custom_dir / "build" / "bin" / binary_name - if cmake_bin.is_file(): - return str(cmake_bin) - # build/bin/Release/ (cmake on Windows) - if sys.platform == "win32": - win_bin = custom_dir / "build" / "bin" / "Release" / binary_name - if win_bin.is_file(): - return str(win_bin) + hit, locked = _scan_pinned(_layout_candidates(Path(custom_llama_cpp))) + if locked is not None: + return _unavailable(locked) + if hit: + return hit # 2-4. Match installer layout: env-mode -> $STUDIO_HOME/llama.cpp; # default/HOME-redirect -> ~/.unsloth/llama.cpp (sibling of studio). @@ -1141,31 +1183,18 @@ class LlamaCppBackend: _seen_roots.add(k) _unique_roots.append(r) for unsloth_home in _unique_roots: - home_root = unsloth_home / binary_name - if home_root.is_file(): - return str(home_root) - home_linux = unsloth_home / "build" / "bin" / binary_name - if home_linux.is_file(): - return str(home_linux) - if sys.platform == "win32": - home_win = unsloth_home / "build" / "bin" / "Release" / binary_name - if home_win.is_file(): - return str(home_win) + hit, locked = _scan_pinned(_layout_candidates(unsloth_home)) + if locked is not None: + return _unavailable(locked) + if hit: + return hit - # 5-6. Legacy: in-tree build (older setup.sh / setup.ps1) + # 5-6. Legacy: in-tree build (older setup.sh / setup.ps1). A fallback, + # so a denied candidate here just continues (no no-fallback halt). project_root = Path(__file__).resolve().parents[4] - # Root dir (make builds) - root_path = project_root / "llama.cpp" / binary_name - if root_path.is_file(): - return str(root_path) - # build/bin/ (cmake builds) - build_path = project_root / "llama.cpp" / "build" / "bin" / binary_name - if build_path.is_file(): - return str(build_path) - if sys.platform == "win32": - win_path = project_root / "llama.cpp" / "build" / "bin" / "Release" / binary_name - if win_path.is_file(): - return str(win_path) + for p in _layout_candidates(project_root / "llama.cpp"): + if _is_file(p): + return str(p) # 7. System PATH system_path = shutil.which("llama-server") @@ -1174,7 +1203,7 @@ class LlamaCppBackend: # 8. Legacy: extracted to bin/ bin_path = project_root / "bin" / binary_name - if bin_path.is_file(): + if _is_file(bin_path): return str(bin_path) return None @@ -2438,7 +2467,9 @@ class LlamaCppBackend: visual_bin = os.environ.get("DG_VISUAL_BIN") if not visual_bin: name = "llama-diffusion-gemma-visual-server" + (".exe" if os.name == "nt" else "") - base = self._find_llama_server_binary() + # include_denied: a transiently locked llama-server still pins the + # install dir so the adjacent visual-server can be found + base = self._find_llama_server_binary(include_denied = True) if base: base_dir = Path(base).parent for cand in ( @@ -3503,6 +3534,15 @@ class LlamaCppBackend: ) if not binary: + # distinguish a transiently locked binary (antivirus / in-flight + # install) from a missing one so the user retries, not reinstalls + locked = self._find_llama_server_binary(include_denied = True) + if locked: + raise RuntimeError( + f"llama-server at {locked} is temporarily unavailable " + "(access-denied; antivirus or an in-flight install). " + "Retry the load once it is released." + ) raise RuntimeError( "llama-server binary not found. " "Run setup.sh to build it, install llama.cpp, " diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index f91b8fb8c9..7245343eeb 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -2361,10 +2361,13 @@ class ModelConfig: # Does the HF repo contain GGUF files? gguf_filename = detect_gguf_model_remote(identifier, hf_token = hf_token) if gguf_filename: - # Preflight: verify llama-server binary exists before a multi-GB download + # Preflight: verify llama-server binary exists before a multi-GB + # download. include_denied: a transiently locked binary still + # exists (the lock clears long before the download finishes; the + # load itself reports a still-locked binary distinctly). from core.inference.llama_cpp import LlamaCppBackend - if not LlamaCppBackend._find_llama_server_binary(): + if not LlamaCppBackend._find_llama_server_binary(include_denied = True): raise RuntimeError( "llama-server binary not found — cannot load GGUF models. " "Run setup.sh to build it, or set LLAMA_SERVER_PATH." From 2b76c6855a023d1bdd492c75193f83f66fe47562 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 12 Jun 2026 11:31:07 -0700 Subject: [PATCH 04/18] Bump install.sh / install.ps1 pin to unsloth>=2026.6.6 (#6270) --- install.ps1 | 10 +++++----- install.sh | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/install.ps1 b/install.ps1 index fa0d160a33..5baf76b9fa 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1921,7 +1921,7 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.5" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.6" unsloth-zoo } if ($baseInstallExit -eq 0) { # Resolve pydantic WITH deps so pip pins pydantic-core # to the matching version (no-torch-runtime.txt below @@ -1935,7 +1935,7 @@ shell.Run cmd, 0, False } } } else { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.5" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.6" unsloth-zoo } } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red @@ -1982,7 +1982,7 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.5" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.6" unsloth-zoo } if ($baseInstallExit -eq 0) { # Same pydantic-with-deps trick as the migrated branch. $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython pydantic } @@ -1994,7 +1994,7 @@ shell.Run cmd, 0, False } } } elseif ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.5" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.6" unsloth-zoo } } else { $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" } } @@ -2022,7 +2022,7 @@ shell.Run cmd, 0, False Write-TauriLog "STEP" "Installing unsloth" substep "installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.6.5" --torch-backend=auto } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.6.6" --torch-backend=auto } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit) diff --git a/install.sh b/install.sh index a9a74ac4e6..05b8e1e92b 100755 --- a/install.sh +++ b/install.sh @@ -2422,7 +2422,7 @@ if [ "$_MIGRATED" = true ]; then # to prevent transitive torch resolution. run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.6.5" unsloth-zoo + "unsloth>=2026.6.6" unsloth-zoo # Resolve pydantic WITH deps so pip pins pydantic-core to the # matching version (no-torch-runtime.txt below is --no-deps). # All transitive deps are torch-free. @@ -2435,7 +2435,7 @@ if [ "$_MIGRATED" = true ]; then else run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.6.5" unsloth-zoo + "unsloth>=2026.6.6" unsloth-zoo fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." @@ -2639,7 +2639,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --upgrade-package unsloth --upgrade-package unsloth-zoo \ - "unsloth>=2026.6.5" unsloth-zoo + "unsloth>=2026.6.6" unsloth-zoo # Same pydantic-with-deps trick as the migrated branch. run_install_cmd "install pydantic (with deps for compatible core)" \ uv pip install --python "$_VENV_PY" pydantic @@ -2657,7 +2657,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then fi elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \ - --upgrade-package unsloth "unsloth>=2026.6.5" unsloth-zoo + --upgrade-package unsloth "unsloth>=2026.6.6" unsloth-zoo substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." @@ -2689,7 +2689,7 @@ else tauri_log "STEP" "Installing Unsloth" substep "installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.6.5" --torch-backend=auto + run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.6.6" --torch-backend=auto substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." From 502d4ade1cc2211c2febbc4f29f1628cff18e2e0 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:01:18 -0700 Subject: [PATCH 05/18] Tidy update banner and auth button spacing (#6279) * Fix update banner button spacing * Reduce auth submit button horizontal padding * Wrap web update banner actions on narrow widths --------- Co-authored-by: Unsloth --- .../src/components/llama-update-banner.tsx | 8 ++-- .../src/components/web/update-banner.tsx | 39 ++++++++++--------- .../features/auth/components/auth-form.tsx | 2 +- 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/studio/frontend/src/components/llama-update-banner.tsx b/studio/frontend/src/components/llama-update-banner.tsx index 8f8bc5ca7f..1aa9fc4e60 100644 --- a/studio/frontend/src/components/llama-update-banner.tsx +++ b/studio/frontend/src/components/llama-update-banner.tsx @@ -203,11 +203,11 @@ export function LlamaUpdateBanner({ /> ) : ( -
+
- + {/* wrap + right-align so buttons stack instead of clipping on very narrow banners */} +
+ + +
diff --git a/studio/frontend/src/features/auth/components/auth-form.tsx b/studio/frontend/src/features/auth/components/auth-form.tsx index f32ef6abcf..731993077f 100644 --- a/studio/frontend/src/features/auth/components/auth-form.tsx +++ b/studio/frontend/src/features/auth/components/auth-form.tsx @@ -443,7 +443,7 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null { , + { closeButton: true }, + ); } catch (err) { toast.error("Failed to archive chat", { description: err instanceof Error ? err.message : undefined, @@ -399,16 +443,6 @@ export function AppSidebar() { } } - async function handleUnarchiveThread(item: SidebarItem) { - try { - await unarchiveChatItem(item); - } catch (err) { - toast.error("Failed to unarchive chat", { - description: err instanceof Error ? err.message : undefined, - }); - } - } - type RenameTarget = | { kind: "chat"; item: SidebarItem; current: string } | { kind: "project"; project: ProjectRecord; current: string } @@ -417,6 +451,19 @@ export function AppSidebar() { null, ); const [renameDraft, setRenameDraft] = useState(""); + // Skips the inline rename input's blur-commit when Enter/Escape already handled it. + const skipRenameBlurRef = useRef(false); + // Optimistic title shown while the debounced sidebar refresh catches up after + // a rename, so the old name does not flash back in. + const [pendingRename, setPendingRename] = useState<{ + id: string; + title: string; + } | null>(null); + useEffect(() => { + if (!pendingRename) return; + const match = allChatItems.find((i) => i.id === pendingRename.id); + if (match && match.title === pendingRename.title) setPendingRename(null); + }, [allChatItems, pendingRename]); const [creatingProject, setCreatingProject] = useState(false); const [projectNameDraft, setProjectNameDraft] = useState(""); const [projectCreateMoveTarget, setProjectCreateMoveTarget] = @@ -447,9 +494,11 @@ export function AppSidebar() { if (!target || !renameDirty) return; setRenamingTarget(null); if (target.kind === "chat") { + setPendingRename({ id: target.item.id, title: renameTrimmed }); try { await renameChatItem(target.item, renameTrimmed); } catch (err) { + setPendingRename(null); toast.error(translate("shell.toast.failedToRenameChat"), { description: err instanceof Error ? err.message : undefined, }); @@ -476,6 +525,33 @@ export function AppSidebar() { } } + // Inline chat rename commits on Enter or blur, cancels on Escape. + function handleInlineRenameKeyDown( + event: React.KeyboardEvent, + ) { + if (event.key === "Enter") { + event.preventDefault(); + skipRenameBlurRef.current = true; + // Commit when changed; otherwise just close, so a no-op Enter does not + // leave the row stuck as an input with its blur suppressed. + if (renameDirty) void commitRename(); + else setRenamingTarget(null); + } else if (event.key === "Escape") { + event.preventDefault(); + skipRenameBlurRef.current = true; + setRenamingTarget(null); + } + } + + function handleInlineRenameBlur() { + if (skipRenameBlurRef.current) { + skipRenameBlurRef.current = false; + return; + } + if (renameDirty) void commitRename(); + else setRenamingTarget(null); + } + type DeleteTarget = | { kind: "chat"; item: SidebarItem } | { kind: "project"; project: ProjectRecord } @@ -497,13 +573,7 @@ export function AppSidebar() { target.kind === "project" && deleteProjectFiles; setConfirmingDelete(null); if (target.kind === "chat") { - try { - await handleDeleteThread(target.item); - } catch (err) { - toast.error(translate("shell.toast.failedToDeleteChat"), { - description: err instanceof Error ? err.message : undefined, - }); - } + await deleteChatWithCleanup(target.item); return; } if (target.kind === "project") { @@ -584,6 +654,7 @@ export function AppSidebar() { item: SidebarItem, variant: "project" | "recent", ) { + const isPinned = pinnedIdSet.has(item.id); const itemClass = variant === "project" ? "group/project-chat-item relative" @@ -594,13 +665,43 @@ export function AppSidebar() { : "sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"; const buttonClass = cn( "sidebar-nav-btn h-[33px] cursor-pointer rounded-full pr-4 text-[14.5px] leading-[19px] tracking-nav font-medium", - // pl-3.5 starts the title at the same x as the Recents label text. - variant === "project" ? "pl-[39px]" : "pl-3.5", + // pl-3 (12px) plus the content's pl-1 (4px) lines the title up with the + // Recents label text at 16px. + variant === "project" ? "pl-[39px]" : "pl-3", variant === "project" ? "group-hover/project-chat-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-8" - : "group-hover/recent-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8", + : isPinned + ? // Pinned rows show an extra unpin button on hover, so reserve more room. + "group-hover/recent-item:pr-16 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8" + : "group-hover/recent-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8", ); + const isRenamingThis = + renamingTarget?.kind === "chat" && renamingTarget.item.id === item.id; + + // Inline rename edits the title in place as a rounded pill, no dialog. + if (isRenamingThis) { + return ( + + setRenameDraft(event.target.value)} + onKeyDown={handleInlineRenameKeyDown} + onBlur={handleInlineRenameBlur} + onFocus={(event) => event.currentTarget.select()} + maxLength={120} + aria-label={translate("shell.dialog.renameChat.placeholder")} + className={cn( + // No pill or box; edit in place as plain highlighted text. + "text-foreground h-[33px] w-full border-0 bg-transparent pr-4 text-[14.5px] leading-[19px] font-medium tracking-nav outline-none", + variant === "project" ? "pl-[39px]" : "pl-3", + )} + /> + + ); + } + return ( - {item.title} + + {pendingRename?.id === item.id ? pendingRename.title : item.title} + @@ -651,6 +754,10 @@ export function AppSidebar() { Rename + togglePinnedChat(item.id)}> + + {isPinned ? "Unpin chat" : "Pin chat"} + @@ -727,19 +834,46 @@ export function AppSidebar() { + void handleArchiveThread(item)}> - + Archive setConfirmingDelete({ kind: "chat", item })} + onSelect={() => + confirmDeleteChats + ? setConfirmingDelete({ kind: "chat", item }) + : void deleteChatWithCleanup(item) + } > Delete + {isPinned ? ( + + + + + + Unpin + + + ) : null} ); } @@ -976,21 +1110,20 @@ export function AppSidebar() { - {!isStudioRoute && ( - + {/* Pinned chats: own section above Recents */} + {!isStudioRoute && pinnedChatItems.length > 0 && ( + -
- - {t("shell.navigation.recents")} - - -
+ + Pinned + +
- {recentChatItems.map((item) => + {pinnedChatItems.map((item) => renderChatSidebarItem(item, "recent"), )} @@ -1000,78 +1133,21 @@ export function AppSidebar() {
)} - {/* Archived chats — hidden on Studio + when nothing is archived */} - {!isStudioRoute && archivedChatItems.length > 0 && ( - + {!isStudioRoute && ( + - Archived + {t("shell.navigation.recents")} - + - {archivedChatItems.map((item) => ( - - { - navigate({ - to: "/chat", - search: - item.type === "single" - ? { thread: item.id } - : { compare: item.id }, - }); - closeMobileIfOpen(); - }} - > - {item.title} - - - - - - - openRenameChat(item)}> - - Rename - - void handleUnarchiveThread(item)}> - - Unarchive - - setConfirmingDelete({ kind: "chat", item })} - > - - Delete - - - - - ))} + {recentChatItems.map((item) => + renderChatSidebarItem(item, "recent"), + )} @@ -1252,23 +1328,24 @@ export function AppSidebar() { : t("shell.navigation.darkMode")} - { - const tourId = getTourId(pathname); - if (!tourId) return; - window.dispatchEvent( - new CustomEvent(TOUR_OPEN_EVENT, { - detail: { id: tourId }, - }), - ); - }} - > - - {t("shell.navigation.guidedTour")} - + {getTourId(pathname) && ( + { + const tourId = getTourId(pathname); + if (!tourId) return; + window.dispatchEvent( + new CustomEvent(TOUR_OPEN_EVENT, { + detail: { id: tourId }, + }), + ); + }} + > + + {t("shell.navigation.guidedTour")} + + )} - + useSettingsDialogStore.getState().openDialog("about")} > @@ -1390,7 +1467,7 @@ export function AppSidebar() { { if (!open) setRenamingTarget(null); }} diff --git a/studio/frontend/src/components/assistant-ui/model-selector.tsx b/studio/frontend/src/components/assistant-ui/model-selector.tsx index b97c34152f..80090bfd63 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector.tsx @@ -13,13 +13,13 @@ import { usePlatformStore } from "@/config/env"; import { isCustomProviderType } from "@/features/chat/external-providers"; import { cn } from "@/lib/utils"; import { - ArrowDown01Icon, CloudIcon, DashboardSquare01Icon, FolderSearchIcon, RemoveCircleIcon, Search01Icon, } from "@hugeicons/core-free-icons"; +import { ChevronDownStandardIcon } from "@/lib/chevron-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { type KeyboardEvent, useMemo, useState } from "react"; import { Input } from "../ui/input"; @@ -151,9 +151,11 @@ function ModelSelectorTrigger({ "rounded-full border border-border/60 hover:bg-[#ececec] dark:hover:bg-[#2d2e32]", variant === "ghost" && "rounded-full hover:bg-[#ececec] dark:hover:bg-[#2d2e32]", variant === "muted" && "rounded-full bg-muted hover:bg-muted/80", - size === "sm" && "h-8 px-2.5 text-xs", - size === "default" && "h-9 px-3 text-sm", - size === "lg" && "h-10 px-3.5 text-sm", + // More left padding than right; the chevron is pulled close to the + // label (below) so the trigger reads balanced around the text. + size === "sm" && "h-8 pl-3 pr-1.5 text-xs", + size === "default" && "h-9 pl-4 pr-2 text-sm", + size === "lg" && "h-10 pl-4.5 pr-2.5 text-sm", className, )} > @@ -185,9 +187,9 @@ function ModelSelectorTrigger({ )} - + diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index b63915fa35..9ae9f3c165 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -783,13 +783,14 @@ const ThreadWelcome: FC<{ threadId?: string | null; }> = ({ hideComposer, threadId }) => { const displayName = useUserProfileStore((s) => s.displayName); + const nickname = useUserProfileStore((s) => s.nickname); const [welcome, setWelcome] = useState(DEFAULT_WELCOME); useEffect(() => { - // First name only, for a natural greeting; blank falls back to no name. - const name = displayName.trim().split(/\s+/)[0] ?? ""; + // Prefer the nickname; otherwise first name only. Blank falls back to none. + const name = nickname.trim() || (displayName.trim().split(/\s+/)[0] ?? ""); setWelcome(buildWelcome(new Date().getHours(), name)); - }, [displayName]); + }, [displayName, nickname]); const currentEmojiSrc = `Sloth emojis/${welcome.sloth}`; diff --git a/studio/frontend/src/components/ui/alert-dialog.tsx b/studio/frontend/src/components/ui/alert-dialog.tsx index 97f4be7f44..d9d1f1ece8 100644 --- a/studio/frontend/src/components/ui/alert-dialog.tsx +++ b/studio/frontend/src/components/ui/alert-dialog.tsx @@ -37,7 +37,7 @@ function AlertDialogOverlay({ @@ -186,7 +190,7 @@ export function HubOptionMenu({ collisionPadding={12} onCloseAutoFocus={(event) => event.preventDefault()} className={cn( - "hub-menu-instant menu-soft-surface w-max min-w-[var(--radix-popover-trigger-width)] max-w-[min(var(--radix-popover-content-available-width),calc(100vw-1rem))] rounded-[22px] px-2.5 py-2 ring-0", + "hub-menu-instant menu-soft-surface w-max min-w-[var(--radix-popover-trigger-width)] max-w-[min(var(--radix-popover-content-available-width),calc(100vw-1rem))] rounded-[21px] px-[9px] py-2 ring-0", contentClassName, )} > @@ -215,14 +219,14 @@ export function HubOptionMenu({ }} onPointerEnter={() => activateIndex(index)} className={cn( - "relative flex w-full min-w-0 cursor-pointer select-none items-center rounded-[12px] py-2 pr-8 pl-3 text-left text-sm leading-snug outline-none transition-colors", + "relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2.5 rounded-[12px] py-2 px-3 text-left text-sm leading-snug outline-none transition-colors", )} > {option.label} {selectedOption && ( - + )} diff --git a/studio/frontend/src/features/hub/catalog/models-catalog-rows.tsx b/studio/frontend/src/features/hub/catalog/models-catalog-rows.tsx index cc87fc23ce..0eada7e046 100644 --- a/studio/frontend/src/features/hub/catalog/models-catalog-rows.tsx +++ b/studio/frontend/src/features/hub/catalog/models-catalog-rows.tsx @@ -232,7 +232,7 @@ function CatalogRow({ data-active={active || undefined} onPointerEnter={arm} onFocusCapture={arm} - className="catalog-row group/row relative block w-full select-none overflow-hidden rounded-[14px] pl-3 pr-2.5 py-2.5 text-left" + className="catalog-row group/row relative block w-full select-none overflow-hidden rounded-[14px] pl-3 pr-4 py-2.5 text-left" > + + + +
+ +
+ {(["circle", "rounded"] as const).map((shape) => ( + + ))} +
+
+ +
+ +
+ {SLOTH_AVATARS.map((path) => { + const url = publicAssetUrl(path); + const selected = avatarDataUrl === url; + // Readable accessible name from the filename, e.g. "sloth yay". + const label = + path.split("/").pop()?.replace(/\.png$/i, "").replace(/^large\s+/i, "").trim() ?? + "sloth"; + return ( + + ); + })} +
+
+ {imageError ? (

{imageError} diff --git a/studio/frontend/src/features/profile/components/user-avatar.tsx b/studio/frontend/src/features/profile/components/user-avatar.tsx index 62e37f5133..e2c7dec856 100644 --- a/studio/frontend/src/features/profile/components/user-avatar.tsx +++ b/studio/frontend/src/features/profile/components/user-avatar.tsx @@ -3,12 +3,18 @@ import { cn } from "@/lib/utils"; import { avatarBgStyle, initialsFromName } from "../utils/avatar-initials"; +import { + useUserProfileStore, + type AvatarShape, +} from "../stores/user-profile-store"; type UserAvatarProps = { name: string; imageUrl: string | null; size: "sm" | "md" | "lg"; className?: string; + /** Override the stored shape preference (defaults to the user's setting). */ + shape?: AvatarShape; }; const SIZE: Record<"sm" | "md" | "lg", string> = { @@ -18,12 +24,20 @@ const SIZE: Record<"sm" | "md" | "lg", string> = { lg: "size-[106px] text-[1.65rem]", }; -export function UserAvatar({ name, imageUrl, size, className }: UserAvatarProps) { +// Percentage radius keeps the rounded-rectangle proportional across sizes. +const SHAPE: Record = { + circle: "rounded-full", + rounded: "rounded-[22%]", +}; + +export function UserAvatar({ name, imageUrl, size, className, shape }: UserAvatarProps) { const label = initialsFromName(name); + const storedShape = useUserProfileStore((s) => s.avatarShape); + const shapeClass = SHAPE[shape ?? storedShape]; if (imageUrl) { return ( - + ); @@ -33,7 +47,8 @@ export function UserAvatar({ name, imageUrl, size, className }: UserAvatarProps) s.displayName); + const nickname = useUserProfileStore((s) => s.nickname); const avatarDataUrl = useUserProfileStore((s) => s.avatarDataUrl); const sessionSub = decodeJwtSubject(getAuthToken()); const dn = displayName.trim(); + // Name to address the user by: nickname, else first name, else login id. + const addressName = nickname.trim() || dn.split(/\s+/)[0] || sessionSub || ""; return { sessionSub, displayTitle: dn || "Unsloth", + addressName, avatarDataUrl, }; } diff --git a/studio/frontend/src/features/profile/sloth-avatars.ts b/studio/frontend/src/features/profile/sloth-avatars.ts new file mode 100644 index 0000000000..b6ac132377 --- /dev/null +++ b/studio/frontend/src/features/profile/sloth-avatars.ts @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +// Curated list of sloth emoji stickers offered as profile pictures. +// +// The full `public/Sloth emojis` folder has ~38 PNGs, but many are non-square +// or carry heavy whitespace on one or more edges, which crops badly inside the +// round avatar frame. This list is the subset that is (a) effectively square +// (aspect ratio within ~10% of 1:1) and (b) low-whitespace on every edge, so +// each one fills the avatar circle cleanly. Exact duplicates are de-duped. +// +// Paths are relative to the public folder; resolve with `publicAssetUrl(...)` +// before using as an src so spaces and subpath deploys are handled. +export const SLOTH_AVATARS: readonly string[] = [ + "Sloth emojis/large sloth yay.png", + "Sloth emojis/large sloth heart.png", + "Sloth emojis/large sloth wave.png", + "Sloth emojis/large sloth thumbs.png", + "Sloth emojis/large sloth cheeky.png", + "Sloth emojis/large sloth glasses.png", + "Sloth emojis/large sloth fire.png", + "Sloth emojis/large sloth drink.png", + "Sloth emojis/large sloth sad.png", + "Sloth emojis/Large sloth Question mark.png", + "Sloth emojis/sloth shy large.png", + "Sloth emojis/sloth shock large.png", + "Sloth emojis/sloth sir large.png", + "Sloth emojis/sloth huglove large.png", + "Sloth emojis/sloth headphones.png", + "Sloth emojis/sloth pc square.png", + "Sloth emojis/sloth on phone.png", + "Sloth emojis/sloth magnify final.png", + "Sloth emojis/Sloth loca pc.png", + "Sloth emojis/UnSloth GPU Front square.png", + "Sloth emojis/UnSloth Sparkling large.png", +]; diff --git a/studio/frontend/src/features/profile/stores/user-profile-store.ts b/studio/frontend/src/features/profile/stores/user-profile-store.ts index 5bbb4d11c9..709f4b5c1c 100644 --- a/studio/frontend/src/features/profile/stores/user-profile-store.ts +++ b/studio/frontend/src/features/profile/stores/user-profile-store.ts @@ -4,20 +4,32 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; +export type AvatarShape = "circle" | "rounded"; + export interface UserProfileState { displayName: string; + // Preferred name used to address the user (greetings, etc.). + nickname: string; avatarDataUrl: string | null; + // Avatar outline: full circle or rounded rectangle. + avatarShape: AvatarShape; setDisplayName: (displayName: string) => void; + setNickname: (nickname: string) => void; setAvatarDataUrl: (avatarDataUrl: string | null) => void; + setAvatarShape: (avatarShape: AvatarShape) => void; } export const useUserProfileStore = create()( persist( (set) => ({ displayName: "", + nickname: "", avatarDataUrl: null, + avatarShape: "circle", setDisplayName: (displayName) => set({ displayName }), + setNickname: (nickname) => set({ nickname }), setAvatarDataUrl: (avatarDataUrl) => set({ avatarDataUrl }), + setAvatarShape: (avatarShape) => set({ avatarShape }), }), { name: "unsloth_user_profile" }, ), diff --git a/studio/frontend/src/features/profile/utils/resize-image-file.ts b/studio/frontend/src/features/profile/utils/resize-image-file.ts index 7df5f45972..77ead49dea 100644 --- a/studio/frontend/src/features/profile/utils/resize-image-file.ts +++ b/studio/frontend/src/features/profile/utils/resize-image-file.ts @@ -2,6 +2,9 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 const MAX_EDGE = 256; +// Smallest edge we shrink a transparent image to before giving up on WebP. +const MIN_EDGE = 96; +const EDGE_STEP = 32; const MAX_BYTES = 380_000; const MAX_DATA_URL_LENGTH = Math.floor(MAX_BYTES * 1.35); const JPEG_QUALITY_START = 0.88; @@ -50,6 +53,33 @@ function encodeCanvasWithinLimit( return dataUrl.length <= MAX_DATA_URL_LENGTH ? dataUrl : null; } +// Lossless PNG keeps alpha and, unlike WebP encoding, works in every browser +// (Safari cannot encode WebP). Size is controlled only by dimensions. +function encodePngWithinLimit(canvas: HTMLCanvasElement): string | null { + const dataUrl = canvas.toDataURL("image/png"); + if (!dataUrl.startsWith("data:image/png")) return null; + return dataUrl.length <= MAX_DATA_URL_LENGTH ? dataUrl : null; +} + +// Draw the image onto a canvas scaled to fit within maxEdge. +function drawScaled( + img: HTMLImageElement, + w: number, + h: number, + maxEdge: number, +): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; cw: number; ch: number } { + const scale = Math.min(1, maxEdge / Math.max(w, h)); + const cw = Math.max(1, Math.round(w * scale)); + const ch = Math.max(1, Math.round(h * scale)); + const canvas = document.createElement("canvas"); + canvas.width = cw; + canvas.height = ch; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Canvas not available"); + ctx.drawImage(img, 0, 0, cw, ch); + return { canvas, ctx, cw, ch }; +} + /** Downscale the image, preserving transparency, to stay localStorage-friendly. */ export async function resizeImageFileToDataUrl(file: File): Promise { const img = await loadImage(file); @@ -57,24 +87,24 @@ export async function resizeImageFileToDataUrl(file: File): Promise { const h = img.naturalHeight; if (!w || !h) throw new Error("Invalid image dimensions"); - const scale = Math.min(1, MAX_EDGE / Math.max(w, h)); - const cw = Math.max(1, Math.round(w * scale)); - const ch = Math.max(1, Math.round(h * scale)); + const base = drawScaled(img, w, h, MAX_EDGE); + const hasTransparency = canvasHasTransparency(base.ctx, base.cw, base.ch); - const canvas = document.createElement("canvas"); - canvas.width = cw; - canvas.height = ch; - const ctx = canvas.getContext("2d"); - if (!ctx) throw new Error("Canvas not available"); - ctx.drawImage(img, 0, 0, cw, ch); - - const hasTransparency = canvasHasTransparency(ctx, cw, ch); if (hasTransparency) { - const webpDataUrl = encodeCanvasWithinLimit(canvas, "image/webp", WEBP_QUALITY_START); - if (webpDataUrl) return webpDataUrl; + // Keep alpha, shrinking to fit. WebP is smallest where supported; PNG is + // the universal fallback (Safari cannot encode WebP). Never JPEG, which + // would paint a background behind a transparent image. + for (let edge = MAX_EDGE; edge >= MIN_EDGE; edge -= EDGE_STEP) { + const { canvas } = edge === MAX_EDGE ? base : drawScaled(img, w, h, edge); + const webpDataUrl = encodeCanvasWithinLimit(canvas, "image/webp", WEBP_QUALITY_START); + if (webpDataUrl) return webpDataUrl; + const pngDataUrl = encodePngWithinLimit(canvas); + if (pngDataUrl) return pngDataUrl; + } + throw new Error("Image is still too large after compression. Try a smaller file."); } - const jpegDataUrl = encodeCanvasWithinLimit(canvas, "image/jpeg", JPEG_QUALITY_START); + const jpegDataUrl = encodeCanvasWithinLimit(base.canvas, "image/jpeg", JPEG_QUALITY_START); if (jpegDataUrl) return jpegDataUrl; throw new Error("Image is still too large after compression. Try a smaller file."); diff --git a/studio/frontend/src/features/settings/components/archived-chats-dialog.tsx b/studio/frontend/src/features/settings/components/archived-chats-dialog.tsx new file mode 100644 index 0000000000..836b4d25d4 --- /dev/null +++ b/studio/frontend/src/features/settings/components/archived-chats-dialog.tsx @@ -0,0 +1,218 @@ +// 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 { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + deleteChatItem, + unarchiveChatItem, + useChatPreferencesStore, + useChatRuntimeStore, + useChatSidebarItems, + type SidebarItem, +} from "@/features/chat"; +import { toast } from "@/lib/toast"; +import { ArchiveRestoreIcon, Delete02Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; +import { useState } from "react"; +import { useSettingsDialogStore } from "../stores/settings-dialog-store"; + +function formatCreatedAt(ms: number): string { + return new Date(ms).toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +export function ArchivedChatsDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { archivedItems } = useChatSidebarItems({ requireMessages: false }); + const navigate = useNavigate(); + const closeSettings = useSettingsDialogStore((s) => s.closeDialog); + const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId); + // Open chat id from the route. Compare panes do not write the store, so the + // pair id only lives in the search params; mirror how the sidebar reads it. + const openChatId = useRouterState({ + select: (s) => { + if (!s.location.pathname.startsWith("/chat")) return undefined; + const search = s.location.search as Record; + return search.thread ?? search.compare ?? storeThreadId ?? undefined; + }, + }); + const confirmDeleteChats = useChatPreferencesStore( + (s) => s.confirmDeleteChats, + ); + const [confirmingDelete, setConfirmingDelete] = useState( + null, + ); + + // Open an archived chat: leave it archived, just navigate to it. + function openChat(item: SidebarItem) { + navigate({ + to: "/chat", + search: + item.type === "single" ? { thread: item.id } : { compare: item.id }, + }); + onOpenChange(false); + closeSettings(); + } + + async function handleUnarchive(item: SidebarItem) { + try { + await unarchiveChatItem(item); + toast.success("Chat unarchived"); + } catch (err) { + toast.error("Failed to unarchive chat", { + description: err instanceof Error ? err.message : undefined, + }); + } + } + + async function handleDelete(item: SidebarItem) { + try { + // Pass the open chat id (single or compare) so deleting it resets nav. + await deleteChatItem(item, openChatId, (view) => { + navigate({ + to: "/chat", + search: item.projectId + ? { project: item.projectId } + : { new: view.newThreadNonce }, + }); + }); + toast.success("Chat deleted"); + } catch (err) { + toast.error("Failed to delete chat", { + description: err instanceof Error ? err.message : undefined, + }); + } + } + + function requestDelete(item: SidebarItem) { + if (confirmDeleteChats) setConfirmingDelete(item); + else void handleDelete(item); + } + + return ( +

+ + + Archived chats + + + {archivedItems.length === 0 ? ( +

+ No archived chats. +

+ ) : ( +
+
+ Name + Date created + +
+ {archivedItems.map((item) => ( +
+ + + {formatCreatedAt(item.createdAt)} + + + + + +
+ ))} +
+ )} +
+ + { + if (!o) setConfirmingDelete(null); + }} + > + + + Delete chat + + Delete{" "} + + "{confirmingDelete?.title}" + + ? This cannot be undone. + + + + Cancel + { + const item = confirmingDelete; + setConfirmingDelete(null); + if (item) void handleDelete(item); + }} + > + Delete + + + + +
+ ); +} diff --git a/studio/frontend/src/features/settings/components/studio-version-section.tsx b/studio/frontend/src/features/settings/components/studio-version-section.tsx new file mode 100644 index 0000000000..9ede7cd35d --- /dev/null +++ b/studio/frontend/src/features/settings/components/studio-version-section.tsx @@ -0,0 +1,70 @@ +// 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 { getAuthToken } from "@/features/auth"; +import { useT } from "@/i18n"; +import { apiUrl } from "@/lib/api-base"; +import { useEffect, useState } from "react"; +import { SettingsRow } from "./settings-row"; +import { SettingsSection } from "./settings-section"; + +type ApiObject = Record; + +async function fetchStudioVersions(): Promise<{ + packageVersion: string | null; + studioVersion: string | null; +}> { + try { + const token = getAuthToken(); + const headers = new Headers(); + if (token) headers.set("Authorization", `Bearer ${token}`); + const res = await fetch(apiUrl("/api/health"), { headers }); + if (!res.ok) { + return { packageVersion: null, studioVersion: null }; + } + const data = (await res.json()) as ApiObject; + const packageVersion = data.version; + const studioVersion = data.studio_version; + return { + packageVersion: + typeof packageVersion === "string" ? packageVersion : null, + studioVersion: typeof studioVersion === "string" ? studioVersion : null, + }; + } catch { + return { packageVersion: null, studioVersion: null }; + } +} + +// Shared "Unsloth" version block, shown in both General and About. +export function StudioVersionSection() { + const t = useT(); + const [packageVersion, setPackageVersion] = useState("dev"); + const [studioVersion, setStudioVersion] = useState("dev"); + + useEffect(() => { + let canceled = false; + fetchStudioVersions().then((next) => { + if (canceled) return; + if (next.packageVersion) setPackageVersion(next.packageVersion); + if (next.studioVersion) setStudioVersion(next.studioVersion); + }); + return () => { + canceled = true; + }; + }, []); + + return ( + + + + {studioVersion} + + + + + {packageVersion} + + + + ); +} diff --git a/studio/frontend/src/features/settings/stores/settings-dialog-store.ts b/studio/frontend/src/features/settings/stores/settings-dialog-store.ts index 1f4ac9c372..4f275926de 100644 --- a/studio/frontend/src/features/settings/stores/settings-dialog-store.ts +++ b/studio/frontend/src/features/settings/stores/settings-dialog-store.ts @@ -20,11 +20,24 @@ interface SettingsDialogState { // previous-focus capture, leaving focus on after close. We restore // explicitly via onCloseAutoFocus. opener: HTMLElement | null; + // Set when something asks to jump straight to the archived chats list (the + // archive toast). ChatTab consumes it to open the dialog, then clears it. + archivedChatsRequested: boolean; openDialog: (tab?: SettingsTab) => void; + openArchivedChats: () => void; + consumeArchivedChatsRequest: () => void; closeDialog: () => void; setActiveTab: (tab: SettingsTab) => void; } +function captureOpener(): HTMLElement | null { + return typeof document !== "undefined" && + document.activeElement instanceof HTMLElement && + document.activeElement !== document.body + ? document.activeElement + : null; +} + const ACTIVE_TAB_KEY = "unsloth_settings_active_tab"; function loadInitialTab(): SettingsTab { @@ -53,17 +66,21 @@ export const useSettingsDialogStore = create((set) => ({ open: false, activeTab: loadInitialTab(), opener: null, + archivedChatsRequested: false, openDialog: (tab) => set((state) => ({ open: true, activeTab: tab ?? state.activeTab, - opener: - typeof document !== "undefined" && - document.activeElement instanceof HTMLElement && - document.activeElement !== document.body - ? document.activeElement - : null, + opener: captureOpener(), })), + openArchivedChats: () => + set({ + open: true, + activeTab: "chat", + archivedChatsRequested: true, + opener: captureOpener(), + }), + consumeArchivedChatsRequest: () => set({ archivedChatsRequested: false }), // Do NOT clear `opener` here. onCloseAutoFocus runs on the next render // pass after `open: false` lands, so the opener must still be readable // from the store at that point. The next openDialog() overwrites it. diff --git a/studio/frontend/src/features/settings/tabs/about-tab.tsx b/studio/frontend/src/features/settings/tabs/about-tab.tsx index 83ab95a99b..15684d9b3b 100644 --- a/studio/frontend/src/features/settings/tabs/about-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/about-tab.tsx @@ -19,6 +19,7 @@ import { HugeiconsIcon } from "@hugeicons/react"; import { useEffect, useState } from "react"; import { SettingsRow } from "../components/settings-row"; import { SettingsSection } from "../components/settings-section"; +import { StudioVersionSection } from "../components/studio-version-section"; import { type UpdateInstallSource, UpdateStudioInstructions, @@ -44,31 +45,6 @@ function isUpdateInstallSource(value: unknown): value is UpdateInstallSource { ); } -async function fetchStudioVersions(): Promise<{ - packageVersion: string | null; - studioVersion: string | null; -}> { - try { - const token = getAuthToken(); - const headers = new Headers(); - if (token) headers.set("Authorization", `Bearer ${token}`); - const res = await fetch(apiUrl("/api/health"), { headers }); - if (!res.ok) { - return { packageVersion: null, studioVersion: null }; - } - const data = (await res.json()) as ApiObject; - const packageVersion = data.version; - const studioVersion = data.studio_version; - return { - packageVersion: - typeof packageVersion === "string" ? packageVersion : null, - studioVersion: typeof studioVersion === "string" ? studioVersion : null, - }; - } catch { - return { packageVersion: null, studioVersion: null }; - } -} - async function fetchInstallSource(): Promise { if (isTauri) { return "unknown"; @@ -99,8 +75,6 @@ export function AboutTab() { const deviceType = usePlatformStore((s) => s.deviceType); const defaultShell = deviceType === "windows" ? "windows" : "unix"; const [shutdownOpen, setShutdownOpen] = useState(false); - const [packageVersion, setPackageVersion] = useState("dev"); - const [studioVersion, setStudioVersion] = useState("dev"); const [installSource, setInstallSource] = useState< UpdateInstallSource | "loading" >("loading"); @@ -108,18 +82,6 @@ export function AboutTab() { useEffect(() => { let canceled = false; - fetchStudioVersions().then((nextVersions) => { - if (canceled) { - return; - } - if (nextVersions.packageVersion) { - setPackageVersion(nextVersions.packageVersion); - } - if (nextVersions.studioVersion) { - setStudioVersion(nextVersions.studioVersion); - } - }); - fetchInstallSource().then((nextInstallSource) => { if (!canceled) { setInstallSource(nextInstallSource); @@ -142,18 +104,7 @@ export function AboutTab() {

- - - - {studioVersion} - - - - - {packageVersion} - - - +
diff --git a/studio/frontend/src/features/settings/tabs/chat-tab.tsx b/studio/frontend/src/features/settings/tabs/chat-tab.tsx index c5b1ec8fda..8a832a3bc4 100644 --- a/studio/frontend/src/features/settings/tabs/chat-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/chat-tab.tsx @@ -30,6 +30,7 @@ import { downloadChatExport, importConversationsFromFile, useChatRuntimeStore, + useChatPreferencesStore, type PlusMenuItemId, usePlusMenuPrefsStore, } from "@/features/chat"; @@ -50,6 +51,8 @@ import { useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; import { SettingsRow } from "../components/settings-row"; import { SettingsSection } from "../components/settings-section"; +import { ArchivedChatsDialog } from "../components/archived-chats-dialog"; +import { useSettingsDialogStore } from "../stores/settings-dialog-store"; // Adjustable "+" menu items shown in settings, in display order. Icons mirror // the ones used in the composer + menu itself. @@ -134,7 +137,21 @@ export function ChatTab() { const plusPins = usePlusMenuPrefsStore((state) => state.pins); const togglePlusPin = usePlusMenuPrefsStore((state) => state.togglePin); const [confirmOpen, setConfirmOpen] = useState(false); + const [archivedOpen, setArchivedOpen] = useState(false); const [count, setCount] = useState(null); + const archivedChatsRequested = useSettingsDialogStore( + (s) => s.archivedChatsRequested, + ); + const consumeArchivedChatsRequest = useSettingsDialogStore( + (s) => s.consumeArchivedChatsRequest, + ); + + // Open the archived list when the archive toast asked to jump here. + useEffect(() => { + if (!archivedChatsRequested) return; + setArchivedOpen(true); + consumeArchivedChatsRequest(); + }, [archivedChatsRequested, consumeArchivedChatsRequest]); const [exporting, setExporting] = useState(false); const [clearing, setClearing] = useState(false); const collapseHtmlArtifacts = useChatRuntimeStore( @@ -152,6 +169,12 @@ export function ChatTab() { const hydratePersistedSettings = useChatRuntimeStore( (state) => state.hydratePersistedSettings, ); + const confirmDeleteChats = useChatPreferencesStore( + (state) => state.confirmDeleteChats, + ); + const setConfirmDeleteChats = useChatPreferencesStore( + (state) => state.setConfirmDeleteChats, + ); useEffect(() => { void countAllChats().then(setCount); @@ -303,6 +326,29 @@ export function ChatTab() { + + + + + + + + + + diff --git a/studio/frontend/src/features/settings/tabs/general-tab.tsx b/studio/frontend/src/features/settings/tabs/general-tab.tsx index 1c3d9f7c7b..c8cc27649d 100644 --- a/studio/frontend/src/features/settings/tabs/general-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/general-tab.tsx @@ -37,6 +37,7 @@ import { useEffect, useRef, useState } from "react"; import { Eye, EyeOff } from "lucide-react"; import { SettingsRow } from "../components/settings-row"; import { SettingsSection } from "../components/settings-section"; +import { StudioVersionSection } from "../components/studio-version-section"; // Keys cleared by "Reset all local preferences". // NEVER include auth/session keys here — clearing them would log the user out @@ -59,6 +60,7 @@ const PREFS_KEYS: string[] = [ "unsloth_tool_call_timeout", "unsloth_chat_inference_params", "unsloth_chat_collapsible_state", + "unsloth_chat_preferences", // Chat presets "unsloth_chat_custom_presets", "unsloth_chat_active_preset", @@ -252,6 +254,8 @@ export function GeneralTab() {

+ + Date: Sat, 13 Jun 2026 03:59:15 -0700 Subject: [PATCH 10/18] Upgrade setuptools and wheel in the auto-install command (#6282) The generated install command builds unsloth from git with --no-build-isolation, so pip uses the environment's existing setuptools rather than the pinned build-system requirement. On setuptools < 77 the PEP 639 license string in pyproject.toml fails to validate and the install aborts. Upgrade setuptools and wheel up front so the source build always has PEP 639 support. --- unsloth/_auto_install.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unsloth/_auto_install.py b/unsloth/_auto_install.py index f6deefeb33..d34e09e89c 100644 --- a/unsloth/_auto_install.py +++ b/unsloth/_auto_install.py @@ -40,4 +40,4 @@ else: raise RuntimeError(f"Torch = {v} too new!") if v > V('2.6.9') and cuda not in ("11.8", "12.6", "12.8", "13.0"): raise RuntimeError(f"CUDA = {cuda} not supported!") if v >= V('2.10.0') and cuda not in ("12.6", "12.8", "13.0"): raise RuntimeError(f"Torch 2.10 requires CUDA 12.6, 12.8, or 13.0! Got CUDA = {cuda}") x = x.format(cuda.replace(".", ""), "-ampere" if False else "") # is_ampere is broken due to flash-attn -print(f'pip install --upgrade pip && pip install --no-deps git+https://github.com/unslothai/unsloth-zoo.git && pip install "unsloth[{x}] @ git+https://github.com/unslothai/unsloth.git" --no-build-isolation') \ No newline at end of file +print(f'pip install --upgrade pip setuptools wheel && pip install --no-deps git+https://github.com/unslothai/unsloth-zoo.git && pip install "unsloth[{x}] @ git+https://github.com/unslothai/unsloth.git" --no-build-isolation') \ No newline at end of file From 368b19b23775f9822e2fcff2c256d65b81f9fcfc Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sat, 13 Jun 2026 04:06:17 -0700 Subject: [PATCH 11/18] Studio: fix training output dir escaping outputs root for models on another drive (#6293) * Studio: derive training output dir from model basename for local-drive models A LoRA/QLoRA run started from a model loaded by absolute path (common when models live on a non-system drive, e.g. G:\modelsAI\...\gemma-4-12B-it) seeded the default output dir with that full path. resolve_output_dir then raised "path escapes root ... is not under the studio outputs folder", so training could not start from a model stored off the system drive. Add default_run_dir_name(): Hugging Face repo ids keep their namespace (org/model becomes org_model), while local paths collapse to their final component so an absolute source path can no longer leak into the output dir. Use it at the three worker derivation sites and add a regression test. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: cap run dir name length and drop redundant resolve Apply PR review feedback: length-cap the auto-generated output dir component so an unusually long model name stays under the filesystem name limit, and drop the redundant double resolve_output_dir at the embedding site so all three derivation sites match. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/training/worker.py | 15 +++-- .../tests/test_default_output_dir_name.py | 65 +++++++++++++++++++ studio/backend/utils/paths/__init__.py | 2 + studio/backend/utils/paths/storage_roots.py | 18 +++++ 4 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 studio/backend/tests/test_default_output_dir_name.py diff --git a/studio/backend/core/training/worker.py b/studio/backend/core/training/worker.py index 1120744a2d..4a6d182da8 100644 --- a/studio/backend/core/training/worker.py +++ b/studio/backend/core/training/worker.py @@ -1687,12 +1687,12 @@ def _run_mlx_training(event_queue, stop_queue, config): warmup_steps = 5 # ── 5. Build output dir ── + # Resolve to ~/.unsloth/studio/outputs/ so the export page finds it + from utils.paths import resolve_output_dir, ensure_dir, default_run_dir_name + output_dir = config.get("output_dir", "") if not output_dir: - output_dir = f"{model_name.replace('/', '_')}_{int(time.time())}" - # Resolve to ~/.unsloth/studio/outputs/ so the export page finds it - from utils.paths import resolve_output_dir, ensure_dir - + output_dir = f"{default_run_dir_name(model_name)}_{int(time.time())}" output_dir = str(resolve_output_dir(output_dir)) ensure_dir(Path(output_dir)) @@ -2450,6 +2450,7 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) -> resolve_output_dir, resolve_tensorboard_dir, datasets_root, + default_run_dir_name, ) import transformers @@ -2773,7 +2774,7 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) -> resume_from_checkpoint ) if not output_dir: - output_dir = f"{model_name.replace('/', '_')}_{int(time.time())}" + output_dir = f"{default_run_dir_name(model_name)}_{int(time.time())}" output_dir = str(resolve_output_dir(output_dir)) ensure_dir(Path(output_dir)) @@ -2924,7 +2925,7 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) -> from datasets import Dataset from utils.datasets.cache_safe import load_dataset_cache_safe as load_dataset from transformers import TrainerCallback - from utils.paths import datasets_root, resolve_output_dir + from utils.paths import datasets_root, resolve_output_dir, default_run_dir_name except ImportError as e: event_queue.put( { @@ -3182,7 +3183,7 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) -> resume_from_checkpoint ) if not output_dir: - output_dir = str(resolve_output_dir(f"{model_name.replace('/', '_')}_{int(time.time())}")) + output_dir = f"{default_run_dir_name(model_name)}_{int(time.time())}" output_dir = str(resolve_output_dir(output_dir)) num_epochs = config.get("num_epochs", 2) diff --git a/studio/backend/tests/test_default_output_dir_name.py b/studio/backend/tests/test_default_output_dir_name.py new file mode 100644 index 0000000000..d8a7f5ae21 --- /dev/null +++ b/studio/backend/tests/test_default_output_dir_name.py @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Auto-generated training output dir names stay inside outputs_root. + +Regression for local-model training: a model loaded by absolute path (e.g. +``G:\\modelsAI\\...\\gemma-4-12B-it`` on a non-system drive) used to seed the +default run dir with that full path, so ``resolve_output_dir`` raised +``path escapes root`` because the result was not under ``/outputs``. +""" + +import importlib.util +from pathlib import Path + +import pytest + + +_BACKEND_DIR = Path(__file__).resolve().parent.parent + + +def _load_storage_roots(): + path = _BACKEND_DIR / "utils/paths/storage_roots.py" + spec = importlib.util.spec_from_file_location("storage_roots_under_test", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_repo_id_keeps_namespace(): + sr = _load_storage_roots() + assert sr.default_run_dir_name("unsloth/gemma-3-4b") == "unsloth_gemma-3-4b" + assert sr.default_run_dir_name("gemma-3-4b") == "gemma-3-4b" + + +def test_local_paths_collapse_to_basename(): + sr = _load_storage_roots() + assert sr.default_run_dir_name(r"G:\modelsAI\gguf\test\gemma-4-12B-it") == "gemma-4-12B-it" + assert sr.default_run_dir_name("/data/models/gemma-3-4b") == "gemma-3-4b" + assert sr.default_run_dir_name("~/models/gemma-3-4b") == "gemma-3-4b" + assert sr.default_run_dir_name("C:/Users/me/models/gemma-3-4b") == "gemma-3-4b" + + +def test_empty_falls_back_to_model(): + sr = _load_storage_roots() + assert sr.default_run_dir_name("") == "model" + assert sr.default_run_dir_name(" ") == "model" + + +def test_very_long_name_is_capped(): + sr = _load_storage_roots() + name = sr.default_run_dir_name("a" * 500) + assert 0 < len(name) <= 200 + + +def test_derived_name_resolves_under_outputs_root(tmp_path, monkeypatch): + sr = _load_storage_roots() + outputs = tmp_path / "outputs" + outputs.mkdir() + monkeypatch.setattr(sr, "outputs_root", lambda: outputs) + + name = sr.default_run_dir_name(r"G:\modelsAI\gguf\test\gemma-4-12B-it") + resolved = sr.resolve_output_dir(f"{name}_1781327234") + assert resolved == outputs / "gemma-4-12B-it_1781327234" + # No escape: the absolute G: source no longer leaks into the output path. + assert "modelsAI" not in str(resolved) diff --git a/studio/backend/utils/paths/__init__.py b/studio/backend/utils/paths/__init__.py index eff9b64678..e812fa9e61 100644 --- a/studio/backend/utils/paths/__init__.py +++ b/studio/backend/utils/paths/__init__.py @@ -41,6 +41,7 @@ from .storage_roots import ( ensure_dir, ensure_studio_directories, resolve_under_root, + default_run_dir_name, resolve_output_dir, resolve_export_dir, resolve_export_write_dir, @@ -88,6 +89,7 @@ __all__ = [ "ensure_dir", "ensure_studio_directories", "resolve_under_root", + "default_run_dir_name", "resolve_output_dir", "resolve_export_dir", "resolve_export_write_dir", diff --git a/studio/backend/utils/paths/storage_roots.py b/studio/backend/utils/paths/storage_roots.py index d336bc2e71..c718f38ffb 100644 --- a/studio/backend/utils/paths/storage_roots.py +++ b/studio/backend/utils/paths/storage_roots.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import os +import re import sys from pathlib import Path, PurePosixPath, PureWindowsPath import tempfile @@ -384,6 +385,23 @@ def resolve_under_root( return candidate +def default_run_dir_name(model_name: str) -> str: + # Folder-safe run name for an auto-created output dir. Repo ids keep their + # namespace (org/model -> org_model); local paths (incl. G:\dir\model) + # collapse to their final component so an absolute source can't escape + # outputs_root. Length-capped to stay under the filesystem name limit. + raw = str(model_name or "").strip() + is_path = ( + "\\" in raw + or raw.startswith(("/", "~", ".")) + or os.path.isabs(raw) + or (len(raw) >= 2 and raw[1] == ":") + ) + base = PureWindowsPath(raw).name if is_path else raw.replace("/", "_") + base = re.sub(r"[^A-Za-z0-9._-]+", "_", base)[:200].strip("._-") + return base or "model" + + def resolve_output_dir(path_value: str | None = None) -> Path: return resolve_under_root( path_value, From bd9324b634356ecc281297c91dc9cc71f13fd558 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Sat, 13 Jun 2026 04:14:36 -0700 Subject: [PATCH 12/18] Studio: offer llama.cpp update for same-base mix builds on source installs (#6280) The update banner is driven by update_available from /api/llama/update-status. Prebuilt (marker) installs decide this via freshness.is_behind, which is mix-aware: a release at the same upstream base build but with a new -mix- suffix counts as behind. Source-build installs went through _source_build_status, which compared only the numeric base build (installed_build < latest_build). When a new prebuilt shared the installed upstream base (our usual mix re-tag at the same base), it returned update_available=False and the banner never showed. This is the common macOS case, where a failed prebuilt fetch falls back to a source build that lacks the mix patches. Make the source-build path mix-aware to match the marker path: same base plus a -mix- tag now offers the update (and displays the mix tag), a bare same-base rebuild does not, and the downgrade guard and unknown -version fallback are unchanged. --- studio/backend/tests/test_llama_cpp_update.py | 33 +++++++++++++++ studio/backend/utils/llama_cpp_update.py | 40 ++++++++++++++----- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/studio/backend/tests/test_llama_cpp_update.py b/studio/backend/tests/test_llama_cpp_update.py index e207306c91..326b3dc6aa 100644 --- a/studio/backend/tests/test_llama_cpp_update.py +++ b/studio/backend/tests/test_llama_cpp_update.py @@ -233,6 +233,39 @@ def test_status_source_build_suppressed_when_newer(monkeypatch, tmp_path): assert st["installed_tag"] == "b9600" +def test_status_source_build_offers_same_base_mix(monkeypatch, tmp_path): + # The reported banner bug: a source build at the same upstream base as a new + # Unsloth prebuilt that adds a mix- suffix. The base build numbers match + # (9596 == 9596) but the mix carries extra patches the source build lacks, so + # the update must still surface -- mirroring the marker path's is_behind. + binary = tmp_path / "llama.cpp" / "build" / "bin" / "llama-server" + binary.parent.mkdir(parents = True) + binary.write_text("stub") + monkeypatch.setattr(upd, "_find_binary", lambda: str(binary)) + _prebuilt(monkeypatch, release_tag = "b9596-mix-e6f2453", llama_tag = "b9596") + monkeypatch.setattr(upd, "_installed_build_number", lambda b: 9596) + st = upd.get_update_status() + assert st["supported"] is True + assert st["update_available"] is True + assert st["source_build"] is True + assert st["installed_tag"] == "b9596" + assert st["latest_tag"] == "b9596-mix-e6f2453" + + +def test_status_source_build_same_base_bare_not_offered(monkeypatch, tmp_path): + # Same base, but the prebuilt is a bare rebuild (no mix suffix): nothing extra + # to gain, so do not nag. + binary = tmp_path / "llama.cpp" / "build" / "bin" / "llama-server" + binary.parent.mkdir(parents = True) + binary.write_text("stub") + monkeypatch.setattr(upd, "_find_binary", lambda: str(binary)) + _prebuilt(monkeypatch, release_tag = "b9596", llama_tag = "b9596") + monkeypatch.setattr(upd, "_installed_build_number", lambda b: 9596) + st = upd.get_update_status() + assert st["update_available"] is False + assert st["latest_tag"] == "b9596" + + def test_status_source_build_skips_probe_while_job_runs(monkeypatch, tmp_path): # While the updater swaps the tree, status polls must not exec the binary # being replaced (on Windows that exec can fail the installer's os.replace); diff --git a/studio/backend/utils/llama_cpp_update.py b/studio/backend/utils/llama_cpp_update.py index 8b90d36bc5..3518247d7e 100644 --- a/studio/backend/utils/llama_cpp_update.py +++ b/studio/backend/utils/llama_cpp_update.py @@ -37,6 +37,7 @@ from utils.llama_cpp_freshness import ( _INSTALL_MARKER_NAME, check_prebuilt_freshness, latest_published_release, + parse_base_build, read_install_marker, reset_caches, ) @@ -220,23 +221,42 @@ def _source_build_status(binary: str, *, force_refresh: bool) -> Optional[dict]: res = _resolve_prebuilt_for_host(force_refresh = force_refresh) if not res or not res.get("prebuilt_available"): return None - # llama_tag is the upstream build (bNNNN, what --version reports); release_tag - # can be a fork wrapper tag, so compare/display against llama_tag. - latest = res.get("llama_tag") or res.get("release_tag") - if not latest: + # llama_tag is the upstream base (bNNNN, what --version reports); release_tag + # is the full tag, either a same-base mix (bNNNN-mix-) or a fork wrapper + # (e.g. v1.0). Compare the numeric base against llama_tag. + base_tag = res.get("llama_tag") or res.get("release_tag") + release_tag = res.get("release_tag") + if not base_tag: return None # No resolvable install root (e.g. a pinned LLAMA_SERVER_PATH we cannot # manage) means an apply would not take effect, so do not offer. if _llama_install_root(binary) is None: return None installed_build = _installed_build_number(binary) - m = re.search(r"(\d+)", latest) - latest_build = int(m.group(1)) if m else None - # Suppress only when the source build is reliably newer/equal; unknown - # version (the involuntary source-build case) is treated as behind. - update_available = ( - installed_build is None or latest_build is None or installed_build < latest_build + latest_build = parse_base_build(base_tag) + # A same-base mix adds patches the bare base lacks, so it is newer even at an + # unchanged build number (the marker path's is_behind already does this). The + # bNNNN anchor keeps a fork wrapper tag from being read as a mix. + latest_is_mix = ( + isinstance(release_tag, str) + and latest_build is not None + and parse_base_build(release_tag) == latest_build + and release_tag.strip() != f"b{latest_build}" ) + if installed_build is None or latest_build is None: + # Unknown installed/latest version (the involuntary source-build case): + # treat as behind so we still offer the prebuilt. + update_available = True + elif installed_build < latest_build: + update_available = True + elif installed_build == latest_build: + # Same upstream base: offer the extra-patch mix, never a bare rebuild. + update_available = latest_is_mix + else: + # Source build newer than the latest prebuilt: downgrade guard. + update_available = False + # Display the mix tag when that's what makes it newer; otherwise the base. + latest = release_tag if latest_is_mix else base_tag with _job_lock: job = dict(_job) return { From 42eb241b507594aa7c991c9816a32c2064ec2565 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sat, 13 Jun 2026 04:14:53 -0700 Subject: [PATCH 13/18] Studio: resolve studio home before the llama-only setup split (#6289) UNSLOTH_STUDIO_LLAMA_ONLY=1, the llama.cpp-only fast path used by 'unsloth studio update', skipped the base-install block where STUDIO_HOME, _STUDIO_HOME_IS_CUSTOM, _STUDIO_OWNED_MARKER and _assert_studio_owned_or_absent are defined. setup.sh runs under 'set -euo pipefail', so the llama.cpp section aborted with '_STUDIO_HOME_IS_CUSTOM: unbound variable' before installing anything. Hoist the studio-home and ownership resolution above the llama-only guard so both paths share it. Full installs are unchanged: the values are computed earlier but identically. --- studio/setup.sh | 130 ++++++++++++++++++++++++------------------------ 1 file changed, 64 insertions(+), 66 deletions(-) diff --git a/studio/setup.sh b/studio/setup.sh index 613d311708..4fba761cef 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -386,6 +386,70 @@ if [[ "$keynames" == *$'\nCOLAB_'* ]]; then IS_COLAB=true fi +# Resolve studio home + ownership marker before the llama-only split: the +# llama.cpp section needs STUDIO_HOME / _STUDIO_HOME_IS_CUSTOM, but +# UNSLOTH_STUDIO_LLAMA_ONLY=1 ('unsloth studio update') skips the base install. +# UNSLOTH_STUDIO_HOME (or STUDIO_HOME alias) overrides the install root +# (mirrors install.sh). UNSLOTH_STUDIO_HOME wins when both are set. +_studio_override_var="" +_studio_override="${UNSLOTH_STUDIO_HOME:-}" +if [ -n "$_studio_override" ]; then + _studio_override_var="UNSLOTH_STUDIO_HOME" +else + _studio_override="${STUDIO_HOME:-}" + [ -n "$_studio_override" ] && _studio_override_var="STUDIO_HOME" +fi +# Strip whitespace so " " is treated as unset (matches Python .strip()). +_studio_override=$(printf '%s' "$_studio_override" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') +case "$_studio_override" in + "~") _studio_override="$HOME" ;; + "~/"*) _studio_override="$HOME/${_studio_override#'~/'}" ;; +esac +if [ -n "$_studio_override" ]; then + # setup.sh runs against an existing install (via 'unsloth studio update'); + # a typo in the override must fail fast instead of materializing an + # empty workspace dir. Mirrors setup.ps1 behavior. + if [ ! -d "$_studio_override" ]; then + echo "ERROR: $_studio_override_var=$_studio_override does not exist." >&2 + echo " Run install.sh to create the install root before 'unsloth studio update'." >&2 + exit 1 + fi + [ -w "$_studio_override" ] || { echo "ERROR: $_studio_override_var=$_studio_override is not writable." >&2; exit 1; } + STUDIO_HOME="$(CDPATH= cd -P -- "$_studio_override" && pwd -P)" || exit 1 +else + STUDIO_HOME="$HOME/.unsloth/studio" +fi +VENV_DIR="$STUDIO_HOME/unsloth_studio" +VENV_T5_530_DIR="$STUDIO_HOME/.venv_t5_530" +VENV_T5_550_DIR="$STUDIO_HOME/.venv_t5_550" +VENV_T5_510_DIR="$STUDIO_HOME/.venv_t5_510" + +_STUDIO_OWNED_MARKER=".unsloth-studio-owned" +_LEGACY_STUDIO_HOME="$HOME/.unsloth/studio" +_studio_home_canon="$STUDIO_HOME" +if [ -d "$_studio_home_canon" ]; then + _studio_home_canon=$(CDPATH= cd -P -- "$_studio_home_canon" 2>/dev/null && pwd -P) \ + || _studio_home_canon="$STUDIO_HOME" +fi +if [ -d "$_LEGACY_STUDIO_HOME" ]; then + _LEGACY_STUDIO_HOME=$(CDPATH= cd -P -- "$_LEGACY_STUDIO_HOME" 2>/dev/null && pwd -P) \ + || _LEGACY_STUDIO_HOME="$HOME/.unsloth/studio" +fi +_STUDIO_HOME_IS_CUSTOM=false +if [ "$_studio_home_canon" != "$_LEGACY_STUDIO_HOME" ]; then + _STUDIO_HOME_IS_CUSTOM=true +fi +_assert_studio_owned_or_absent() { + _aso_dir="$1" + _aso_label="$2" + [ -d "$_aso_dir" ] || return 0 + if [ "$_STUDIO_HOME_IS_CUSTOM" = true ] && [ ! -f "$_aso_dir/$_STUDIO_OWNED_MARKER" ]; then + echo "ERROR: $_aso_dir already exists and is not marked as a Studio-owned $_aso_label." >&2 + echo " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." >&2 + exit 1 + fi +} + if [ "$_LLAMA_ONLY" != "1" ]; then # ── Detect whether frontend needs building ── # Skip if SKIP_STUDIO_FRONTEND=1 (Tauri desktop app bundles its own frontend), @@ -605,40 +669,6 @@ if [ -d "$SCRIPT_DIR/backend/core/data_recipe/oxc-validator" ] && command -v npm fi # ── Python venv + deps ── -# UNSLOTH_STUDIO_HOME (or STUDIO_HOME alias) overrides the install root -# (mirrors install.sh). UNSLOTH_STUDIO_HOME wins when both are set. -_studio_override_var="" -_studio_override="${UNSLOTH_STUDIO_HOME:-}" -if [ -n "$_studio_override" ]; then - _studio_override_var="UNSLOTH_STUDIO_HOME" -else - _studio_override="${STUDIO_HOME:-}" - [ -n "$_studio_override" ] && _studio_override_var="STUDIO_HOME" -fi -# Strip whitespace so " " is treated as unset (matches Python .strip()). -_studio_override=$(printf '%s' "$_studio_override" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') -case "$_studio_override" in - "~") _studio_override="$HOME" ;; - "~/"*) _studio_override="$HOME/${_studio_override#'~/'}" ;; -esac -if [ -n "$_studio_override" ]; then - # setup.sh runs against an existing install (via 'unsloth studio update'); - # a typo in the override must fail fast instead of materializing an - # empty workspace dir. Mirrors setup.ps1 behavior. - if [ ! -d "$_studio_override" ]; then - echo "ERROR: $_studio_override_var=$_studio_override does not exist." >&2 - echo " Run install.sh to create the install root before 'unsloth studio update'." >&2 - exit 1 - fi - [ -w "$_studio_override" ] || { echo "ERROR: $_studio_override_var=$_studio_override is not writable." >&2; exit 1; } - STUDIO_HOME="$(CDPATH= cd -P -- "$_studio_override" && pwd -P)" || exit 1 -else - STUDIO_HOME="$HOME/.unsloth/studio" -fi -VENV_DIR="$STUDIO_HOME/unsloth_studio" -VENV_T5_530_DIR="$STUDIO_HOME/.venv_t5_530" -VENV_T5_550_DIR="$STUDIO_HOME/.venv_t5_550" -VENV_T5_510_DIR="$STUDIO_HOME/.venv_t5_510" [ -d "$REPO_ROOT/.venv" ] && rm -rf "$REPO_ROOT/.venv" [ -d "$REPO_ROOT/.venv_overlay" ] && rm -rf "$REPO_ROOT/.venv_overlay" @@ -757,38 +787,6 @@ fi # Gemma 4 models need transformers>=5.5.0; Gemma 4 Unified needs 5.10.x. # Pre-install into separate directories to avoid runtime pip overhead. # The training subprocess prepends the appropriate dir to sys.path. -# -# Runs outside the _SKIP_PYTHON_DEPS gate so that upgrades from legacy -# single .venv_t5 are always migrated to the tiered layout. -# why: in env-override mode $STUDIO_HOME is user-chosen; require the -# ownership marker before rm -rf so unrelated dirs survive. Gated on the -# canonical comparison so an override pointing at the legacy default still -# behaves like a default install. -_STUDIO_OWNED_MARKER=".unsloth-studio-owned" -_LEGACY_STUDIO_HOME="$HOME/.unsloth/studio" -_studio_home_canon="$STUDIO_HOME" -if [ -d "$_studio_home_canon" ]; then - _studio_home_canon=$(CDPATH= cd -P -- "$_studio_home_canon" 2>/dev/null && pwd -P) \ - || _studio_home_canon="$STUDIO_HOME" -fi -if [ -d "$_LEGACY_STUDIO_HOME" ]; then - _LEGACY_STUDIO_HOME=$(CDPATH= cd -P -- "$_LEGACY_STUDIO_HOME" 2>/dev/null && pwd -P) \ - || _LEGACY_STUDIO_HOME="$HOME/.unsloth/studio" -fi -_STUDIO_HOME_IS_CUSTOM=false -if [ "$_studio_home_canon" != "$_LEGACY_STUDIO_HOME" ]; then - _STUDIO_HOME_IS_CUSTOM=true -fi -_assert_studio_owned_or_absent() { - _aso_dir="$1" - _aso_label="$2" - [ -d "$_aso_dir" ] || return 0 - if [ "$_STUDIO_HOME_IS_CUSTOM" = true ] && [ ! -f "$_aso_dir/$_STUDIO_OWNED_MARKER" ]; then - echo "ERROR: $_aso_dir already exists and is not marked as a Studio-owned $_aso_label." >&2 - echo " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." >&2 - exit 1 - fi -} _target_has_pkg_version() { _thpv_dir="$1" _thpv_pkg="$2" From 4736f83447b9c223d040a0795bd77927c9f38fd0 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Sat, 13 Jun 2026 04:15:12 -0700 Subject: [PATCH 14/18] Studio: force-refresh the llama.cpp update check so new builds are not masked by the 24h cache (#6278) Co-authored-by: Unsloth --- studio/frontend/src/hooks/use-llama-update-check.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/studio/frontend/src/hooks/use-llama-update-check.ts b/studio/frontend/src/hooks/use-llama-update-check.ts index 9532ee0735..73e12f9bff 100644 --- a/studio/frontend/src/hooks/use-llama-update-check.ts +++ b/studio/frontend/src/hooks/use-llama-update-check.ts @@ -68,6 +68,11 @@ async function fetchStatus( } } +// Update probes force a refresh so a newly published build is not masked by the +// backend's 24h release cache (the banner would otherwise lag up to a day). The +// job-progress poll below stays cached; it only reads local job state. +const recheckStatus = () => fetchStatus(true); + interface UseLlamaUpdateCheckOptions { enabled?: boolean; } @@ -161,13 +166,13 @@ export function useLlamaUpdateCheck({ let canceled = false; const firstTimer = setTimeout(() => { - fetchStatus().then((s) => { + recheckStatus().then((s) => { if (!canceled) surfaceIfAvailable(s); }); }, FIRST_CHECK_DELAY_MS); const reminder = setInterval(() => { - fetchStatus().then((s) => { + recheckStatus().then((s) => { if (!canceled) surfaceIfAvailable(s); }); }, REMINDER_INTERVAL_MS); @@ -194,7 +199,7 @@ export function useLlamaUpdateCheck({ if (snoozeTimer.current) clearTimeout(snoozeTimer.current); snoozeTimer.current = setTimeout(() => { snoozeTimer.current = null; - fetchStatus().then(surfaceIfAvailable); + recheckStatus().then(surfaceIfAvailable); }, SNOOZE_DELAY_MS); }, [surfaceIfAvailable]); From a9a38da454bae1620fa76ac22605a8d1b60ad1e6 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sat, 13 Jun 2026 04:22:50 -0700 Subject: [PATCH 15/18] Studio: decide diffusion routing before the SWA resolver (#6299) * Studio: decide diffusion routing before the SWA resolver Loading a DiffusionGemma GGUF could fail with "llama-server failed to start. Check that the GGUF file is valid" while llama-diffusion-cli ran the same model fine. _read_gguf_metadata set self._is_diffusion after calling _resolve_swa_pattern, both inside one try/except. The resolver reaches into transformers/HF, which can raise for an architecture transformers does not know (diffusion-gemma); the shared except then swallowed it and left _is_diffusion False, so the model was routed to plain llama-server instead of the diffusion runner. It only reproduced where the SWA pattern was not already cached/inline. Set _is_diffusion right after the KV parse loop (before the resolver) so a resolver error can no longer drop the routing, and skip the resolver for diffusion models, which do not use Studio's SWA pattern. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/llama_cpp.py | 33 +++++++++++++--------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 16f6376aab..57cc97f3b5 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -2380,6 +2380,17 @@ class LlamaCppBackend: # what we have. break + # Decide diffusion routing before the SWA resolver below: it can raise on an arch transformers + # does not know, which would otherwise drop a DiffusionGemma model to plain llama-server. + self._is_diffusion = bool( + (arch and arch.lower().startswith("diffusion")) or canvas_seen + ) + if self._is_diffusion: + logger.info( + f"GGUF metadata: diffusion model detected (architecture={arch}); " + "will serve via the diffusion runner" + ) + # Expand a scalar period straight from the GGUF first. if ( self._sliding_window_pattern is None @@ -2390,9 +2401,14 @@ class LlamaCppBackend: (i + 1) % sliding_window_pattern_period != 0 for i in range(self._n_layers) ] - # Otherwise hand off to the resolver (cache / bootstrap / - # transformers / HF); see `_resolve_swa_pattern`. - if self._sliding_window_pattern is None and self._sliding_window and self._n_layers: + # Otherwise hand off to the resolver (cache / bootstrap / transformers / HF). Diffusion models + # skip it: they do not use Studio's SWA pattern and the resolver can raise for them. + if ( + self._sliding_window_pattern is None + and self._sliding_window + and self._n_layers + and not self._is_diffusion + ): hf_repo_candidates = ( general.get("general.source.huggingface.repository"), _hf_repo_from_url(general.get("general.source.url")), @@ -2419,17 +2435,6 @@ class LlamaCppBackend: hf_repo_candidates, ) - # Block-diffusion models (DiffusionGemma) report a diffusion arch - # and/or a diffusion.canvas_length KV; they need the diffusion runner. - self._is_diffusion = bool( - (arch and arch.lower().startswith("diffusion")) or canvas_seen - ) - if self._is_diffusion: - logger.info( - f"GGUF metadata: diffusion model detected (architecture={arch}); " - "will serve via the diffusion runner" - ) - if self._context_length: logger.info(f"GGUF metadata: context_length={self._context_length}") if self._chat_template: From b32ef2ef865eb89ce5e9075e84e5e35832c91a5f Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sat, 13 Jun 2026 04:43:27 -0700 Subject: [PATCH 16/18] Bug fixes --- pyproject.toml | 6 +++--- unsloth/models/_utils.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 16f662d6ba..eb3cebdeab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ triton = [ ] huggingfacenotorch = [ - "unsloth_zoo>=2026.6.4", + "unsloth_zoo>=2026.6.5", "wheel>=0.42.0", "packaging", "numpy", @@ -92,7 +92,7 @@ huggingfacenotorch = [ ] huggingface = [ "unsloth[huggingfacenotorch]", - "unsloth_zoo>=2026.6.4", + "unsloth_zoo>=2026.6.5", "torchvision", "unsloth[triton]", ] @@ -582,7 +582,7 @@ colab-ampere-torch220 = [ "flash-attn>=2.6.3 ; ('linux' in sys_platform)", ] colab-new = [ - "unsloth_zoo>=2026.6.4", + "unsloth_zoo>=2026.6.5", "packaging", "tyro", "transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0", diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 0dd46a036b..4b2d0acec9 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2026.6.6" +__version__ = "2026.6.7" __all__ = [ "SUPPORTS_BFLOAT16", From 8febe2ca8f3797094f9742882cb96a7f67ddde4c Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sat, 13 Jun 2026 04:56:38 -0700 Subject: [PATCH 17/18] Bump install.sh / install.ps1 pin to unsloth>=2026.6.7 (#6301) --- install.ps1 | 10 +++++----- install.sh | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/install.ps1 b/install.ps1 index 88939ce627..dccd8e4fc1 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1923,7 +1923,7 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.6" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.7" unsloth-zoo } if ($baseInstallExit -eq 0) { # Resolve pydantic WITH deps so pip pins pydantic-core # to the matching version (no-torch-runtime.txt below @@ -1937,7 +1937,7 @@ shell.Run cmd, 0, False } } } else { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.6" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.7" unsloth-zoo } } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red @@ -1984,7 +1984,7 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.6" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.7" unsloth-zoo } if ($baseInstallExit -eq 0) { # Same pydantic-with-deps trick as the migrated branch. $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython pydantic } @@ -1996,7 +1996,7 @@ shell.Run cmd, 0, False } } } elseif ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.6" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.7" unsloth-zoo } } else { $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" } } @@ -2024,7 +2024,7 @@ shell.Run cmd, 0, False Write-TauriLog "STEP" "Installing unsloth" substep "installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.6.6" --torch-backend=auto } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.6.7" --torch-backend=auto } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit) diff --git a/install.sh b/install.sh index 05b8e1e92b..5e08680628 100755 --- a/install.sh +++ b/install.sh @@ -2422,7 +2422,7 @@ if [ "$_MIGRATED" = true ]; then # to prevent transitive torch resolution. run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.6.6" unsloth-zoo + "unsloth>=2026.6.7" unsloth-zoo # Resolve pydantic WITH deps so pip pins pydantic-core to the # matching version (no-torch-runtime.txt below is --no-deps). # All transitive deps are torch-free. @@ -2435,7 +2435,7 @@ if [ "$_MIGRATED" = true ]; then else run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.6.6" unsloth-zoo + "unsloth>=2026.6.7" unsloth-zoo fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." @@ -2639,7 +2639,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --upgrade-package unsloth --upgrade-package unsloth-zoo \ - "unsloth>=2026.6.6" unsloth-zoo + "unsloth>=2026.6.7" unsloth-zoo # Same pydantic-with-deps trick as the migrated branch. run_install_cmd "install pydantic (with deps for compatible core)" \ uv pip install --python "$_VENV_PY" pydantic @@ -2657,7 +2657,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then fi elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \ - --upgrade-package unsloth "unsloth>=2026.6.6" unsloth-zoo + --upgrade-package unsloth "unsloth>=2026.6.7" unsloth-zoo substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." @@ -2689,7 +2689,7 @@ else tauri_log "STEP" "Installing Unsloth" substep "installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.6.6" --torch-backend=auto + run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.6.7" --torch-backend=auto substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." From a3ca5d2a5a1e261b7ac56df74237c03f934ce857 Mon Sep 17 00:00:00 2001 From: oobabooga Date: Sun, 14 Jun 2026 02:06:31 -0300 Subject: [PATCH 18/18] Studio: don't silently fall back to a CPU prebuilt on NVIDIA Linux GPU hosts (#6310) --- studio/install_llama_prebuilt.py | 12 +++-- tests/studio/install/test_selection_logic.py | 53 ++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index aab6d641fe..b33d0b6325 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -5932,9 +5932,9 @@ def resolve_install_attempts( def _linux_published_attempts(host: HostInfo, bundle: PublishedReleaseBundle) -> list[AssetChoice]: """Build the install attempts for a fork Linux host from a manifest-described - bundle: CUDA (with a CPU fallback), per-gfx ROCm, or CPU. Same selection the - upstream filename path used, just sourced from the manifest instead of - reconstructed from asset names.""" + bundle: CUDA, per-gfx ROCm, or (non-GPU) CPU. Same selection the upstream + filename path used, just sourced from the manifest instead of reconstructed + from asset names.""" attempts: list[AssetChoice] = [] if host.has_usable_nvidia: # Prefer the cudart major Studio loads at runtime (torch's bundled @@ -5949,7 +5949,7 @@ def _linux_published_attempts(host: HostInfo, bundle: PublishedReleaseBundle) -> ) if selection is not None: attempts.extend(selection.attempts) - if host.has_rocm and not host.has_usable_nvidia: + elif host.has_rocm: # Use the fork's own per-gfx ROCm bundle (hash-approved, ships the full # ROCm runtime). Do NOT append the CPU asset for ROCm-only hosts: if no # bundle covers the GPU we want validate_prebuilt_attempts to raise @@ -5959,6 +5959,10 @@ def _linux_published_attempts(host: HostInfo, bundle: PublishedReleaseBundle) -> if published_rocm is not None: attempts.append(published_rocm) else: + # CPU-only host. A usable-NVIDIA host never reaches here -- if its CUDA + # selection produced nothing we want an empty attempt list so the caller + # source-builds with CUDA, not a CPU-only binary silently installed on a + # GPU host (mirrors the ROCm branch, and Windows NVIDIA). cpu_choice = published_asset_choice_for_kind(bundle, "linux-cpu") if cpu_choice is not None: attempts.append(cpu_choice) diff --git a/tests/studio/install/test_selection_logic.py b/tests/studio/install/test_selection_logic.py index 4e8beeb888..0282370fe0 100644 --- a/tests/studio/install/test_selection_logic.py +++ b/tests/studio/install/test_selection_logic.py @@ -2611,6 +2611,59 @@ class TestDirectLinuxNvidiaCpuGate: assert [a.install_kind for a in plan.attempts] == ["linux-cpu"] +class TestLinuxPublishedAttemptsNvidiaCpuGate: + """Live fork-manifest path (_linux_published_attempts): an NVIDIA host whose + CUDA selection finds nothing must NOT be handed the manifest's CPU bundle -- + the attempt list stays empty so the caller source-builds with CUDA instead of + silently installing a CPU-only binary on a GPU host. CPU-only hosts still get + the CPU bundle. Mirrors the ROCm policy and TestDirectLinuxNvidiaCpuGate (the + latter covers direct_linux_release_plan, which is off the live path, this the + live path).""" + + def _cpu_only_bundle(self): + return make_release( + [ + make_artifact( + "app-b8508-linux-x64-cpu.tar.gz", + install_kind = "linux-cpu", + runtime_line = None, + coverage_class = None, + supported_sms = [], + min_sm = None, + max_sm = None, + bundle_profile = None, + rank = 1000, + ), + ] + ) + + def test_nvidia_host_without_cuda_line_gets_no_cpu_attempt(self, monkeypatch): + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "detect_torch_cuda_runtime_preference", + lambda host: CudaRuntimePreference(runtime_line = None, selection_log = []), + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "detected_linux_runtime_lines", + lambda: (["cuda13"], {"cuda13": ["/usr/local/cuda/lib64"]}), + ) + host = make_host(driver_cuda_version = (13, 1), compute_caps = ["100"]) + attempts = INSTALL_LLAMA_PREBUILT._linux_published_attempts(host, self._cpu_only_bundle()) + assert attempts == [] + + def test_cpu_host_gets_cpu_attempt(self): + host = make_host( + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + attempts = INSTALL_LLAMA_PREBUILT._linux_published_attempts(host, self._cpu_only_bundle()) + assert [a.install_kind for a in attempts] == ["linux-cpu"] + + # =========================================================================== # N.1d. published_windows_cuda_attempts -- version-dynamic ordering seed # ===========================================================================