diff --git a/install.ps1 b/install.ps1 index b64128c730..f036f7f8fc 100644 --- a/install.ps1 +++ b/install.ps1 @@ -799,9 +799,11 @@ exit 0 # even when install.ps1 is executed from PowerShell 7. $utf8Bom = New-Object System.Text.UTF8Encoding($true) [System.IO.File]::WriteAllText($launcherPs1, $launcherContent, $utf8Bom) + # shell.Run(cmd, 0, ...) already hides the window, so -WindowStyle Hidden + # is redundant; omitting it trims an AV-heuristic token (Kaspersky FP). $vbsContent = @" Set shell = CreateObject("WScript.Shell") -cmd = "powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""$launcherPs1""" +cmd = "powershell -NoProfile -ExecutionPolicy Bypass -File ""$launcherPs1""" shell.Run cmd, 0, False "@ # WSH handles UTF-16LE reliably for .vbs files with non-ASCII paths. @@ -1926,7 +1928,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.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 @@ -1940,7 +1942,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.7" unsloth-zoo } } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red @@ -1987,7 +1989,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.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 } @@ -1999,7 +2001,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.7" unsloth-zoo } } else { $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" } } @@ -2027,7 +2029,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.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 9ec46f2013..52324d1ef5 100755 --- a/install.sh +++ b/install.sh @@ -2432,7 +2432,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.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. @@ -2445,7 +2445,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.7" unsloth-zoo fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." @@ -2649,7 +2649,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.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 @@ -2667,7 +2667,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.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..." @@ -2699,7 +2699,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.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..." 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/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 80b9739cc1..57cc97f3b5 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 @@ -2351,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 @@ -2361,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")), @@ -2390,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: @@ -2438,7 +2472,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 +3539,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/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/models/inference.py b/studio/backend/models/inference.py index 04c63c2247..4e6a5937d8 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -416,7 +416,7 @@ class ImageUrl(BaseModel): """Image URL object — supports data URIs and remote URLs.""" url: str = Field(..., description = "data:image/png;base64,... or https://...") - detail: Optional[Literal["auto", "low", "high"]] = "auto" + detail: Optional[Literal["auto", "low", "high", "original"]] = "auto" class ImageContentPart(BaseModel): @@ -1125,7 +1125,7 @@ class ResponsesInputImagePart(BaseModel): type: Literal["input_image"] image_url: str = Field(..., description = "data:image/png;base64,... or https://...") - detail: Optional[Literal["auto", "low", "high"]] = "auto" + detail: Optional[Literal["auto", "low", "high", "original"]] = "auto" class ResponsesOutputTextPart(BaseModel): diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index e9353cd803..2f9ed35936 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -5309,15 +5309,72 @@ def _responses_message_text(content: Union[str, list]) -> str: return "\n".join(parts) -def _responses_tool_output_text(output: Union[str, list]) -> str: +def _responses_tool_output_content(output: Union[str, list]) -> Union[str, list]: """Return Chat Completions-safe content for a Responses tool result.""" if isinstance(output, str): return output if output.strip() else "(no output)" - if output: + if not output: + return "(no output)" + + text_parts: list[str] = [] + chat_parts: list = [] + has_multimodal = False + for part in output: + if not isinstance(part, dict): + return json.dumps(output) + part_type = part.get("type") + if part_type in ("input_text", "output_text", "text"): + text = part.get("text") + if text is None: + _raise_unsupported_openai_parameter( + "input", + "Responses function_call_output.output text parts require a text field.", + ) + text = str(text) + text_parts.append(text) + chat_parts.append(TextContentPart(type = "text", text = text)) + continue + if part_type == "input_image": + image_url = part.get("image_url") + if not isinstance(image_url, str) or not image_url: + if part.get("file_id"): + _raise_unsupported_openai_parameter( + "input", + "Responses function_call_output.output input_image parts with file_id are not supported by the local adapter. Use image_url instead.", + ) + _raise_unsupported_openai_parameter( + "input", + "Responses function_call_output.output input_image parts require an image_url string.", + ) + detail = part.get("detail", "auto") + if detail is None: + detail = "auto" + if detail not in ("auto", "low", "high", "original"): + _raise_unsupported_openai_parameter( + "input", + "Responses function_call_output.output input_image detail must be auto, low, high, or original.", + ) + chat_parts.append( + ImageContentPart( + type = "image_url", + image_url = ImageUrl(url = image_url, detail = detail), + ) + ) + has_multimodal = True + continue + if part_type == "input_file": + _raise_unsupported_openai_parameter( + "input", + "Responses function_call_output.output input_file parts are not supported by the local adapter.", + ) return json.dumps(output) - return "(no output)" + if has_multimodal: + return chat_parts + + text = "\n".join(text_parts) + return text if text.strip() else "(no output)" _RESPONSES_THINK_OPEN = "" @@ -5521,10 +5578,9 @@ def _normalise_responses_input(payload: ResponsesRequest) -> list[ChatMessage]: continue if isinstance(item, ResponsesFunctionCallOutputInputItem): - # Chat Completions `role="tool"` requires string content; serialize - # a Responses content-array output and keep empty outputs from - # tripping the stricter ChatMessage role validator. - output = _responses_tool_output_text(item.output) + # Flatten pure text arrays for broad template compatibility, and + # forward image URL outputs as real multimodal parts for vision models. + output = _responses_tool_output_content(item.output) messages.append( ChatMessage( role = "tool", 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/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/tests/test_responses_tool_passthrough.py b/studio/backend/tests/test_responses_tool_passthrough.py index ae7ff729bd..89155c2daf 100644 --- a/studio/backend/tests/test_responses_tool_passthrough.py +++ b/studio/backend/tests/test_responses_tool_passthrough.py @@ -36,6 +36,7 @@ import json import httpx import pytest +from fastapi import HTTPException from fastapi.responses import JSONResponse from pydantic import ValidationError @@ -60,7 +61,7 @@ from routes.inference import ( _build_chat_request, _chat_tool_calls_to_responses_output, _normalise_responses_input, - _responses_tool_output_text, + _responses_tool_output_content, _responses_non_streaming, _responses_stream, _translate_responses_tool_choice_to_chat, @@ -435,20 +436,144 @@ class TestNormaliseResponsesInputWithTools: assert sum(1 for m in msgs if m.role == "system") == 1 assert "A" in msgs[0].content and "B" in msgs[0].content - def test_content_array_output_serialised_to_json_string(self): + def test_content_array_text_output_flattens_to_tool_text(self): payload = ResponsesRequest( input = [ { "type": "function_call_output", "call_id": "call_1", - "output": [{"type": "output_text", "text": "ok"}], + "output": [{"type": "input_text", "text": "ok"}], } ], ) msgs = _normalise_responses_input(payload) assert msgs[0].role == "tool" - # Content is serialised so llama-server sees a string. - assert json.loads(msgs[0].content) == [{"type": "output_text", "text": "ok"}] + assert msgs[0].content == "ok" + + def test_content_array_image_output_becomes_multimodal_tool_content(self): + payload = ResponsesRequest( + input = [ + { + "type": "function_call_output", + "call_id": "call_1", + "output": [ + {"type": "input_text", "text": "see image"}, + { + "type": "input_image", + "image_url": "data:image/png;base64,AAA", + "detail": "high", + }, + ], + } + ], + ) + msgs = _normalise_responses_input(payload) + assert msgs[0].role == "tool" + assert msgs[0].tool_call_id == "call_1" + assert msgs[0].model_dump(exclude_none = True)["content"] == [ + {"type": "text", "text": "see image"}, + { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64,AAA", + "detail": "high", + }, + }, + ] + + chat_req = _build_chat_request(payload, msgs, stream = False) + assert chat_req.model_dump(exclude_none = True)["messages"][0]["content"] == [ + {"type": "text", "text": "see image"}, + { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64,AAA", + "detail": "high", + }, + }, + ] + + def test_content_array_image_output_allows_original_detail(self): + payload = ResponsesRequest( + input = [ + { + "type": "function_call_output", + "call_id": "call_1", + "output": [ + { + "type": "input_image", + "image_url": "https://example.com/screenshot.png", + "detail": "original", + }, + ], + } + ], + ) + msgs = _normalise_responses_input(payload) + assert msgs[0].model_dump(exclude_none = True)["content"] == [ + { + "type": "image_url", + "image_url": { + "url": "https://example.com/screenshot.png", + "detail": "original", + }, + }, + ] + + def test_content_array_file_id_image_output_rejected_clearly(self): + payload = ResponsesRequest( + input = [ + { + "type": "function_call_output", + "call_id": "call_1", + "output": [ + {"type": "input_text", "text": "see image"}, + {"type": "input_image", "file_id": "file_abc"}, + ], + } + ], + ) + with pytest.raises(HTTPException) as exc: + _normalise_responses_input(payload) + assert exc.value.status_code == 400 + assert "file_id" in str(exc.value.detail) + + def test_content_array_file_output_rejected_clearly(self): + payload = ResponsesRequest( + input = [ + { + "type": "function_call_output", + "call_id": "call_1", + "output": [ + {"type": "input_text", "text": "see file"}, + { + "type": "input_file", + "file_data": "data:application/pdf;base64,AAA", + "filename": "report.pdf", + }, + ], + } + ], + ) + with pytest.raises(HTTPException) as exc: + _normalise_responses_input(payload) + assert exc.value.status_code == 400 + assert "input_file" in str(exc.value.detail) + + def test_content_array_malformed_image_output_rejected_clearly(self): + payload = ResponsesRequest( + input = [ + { + "type": "function_call_output", + "call_id": "call_1", + "output": [{"type": "input_image", "detail": "high"}], + } + ], + ) + with pytest.raises(HTTPException) as exc: + _normalise_responses_input(payload) + assert exc.value.status_code == 400 + assert "image_url" in str(exc.value.detail) def test_empty_function_call_output_gets_no_output_sentinel(self): payload = ResponsesRequest( @@ -541,8 +666,8 @@ class TestNormaliseResponsesInputWithTools: assert msgs[0].content == "(no output)" def test_tool_output_serializer_preserves_non_empty_text(self): - assert _responses_tool_output_text("done") == "done" - assert _responses_tool_output_text(" done ") == " done " + assert _responses_tool_output_content("done") == "done" + assert _responses_tool_output_content(" done ") == " done " # ===================================================================== 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 { 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." 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, diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 23fe5e188b..d3ba923e69 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -45,8 +45,7 @@ import { Switch } from "@/components/ui/switch"; import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler"; import { cn } from "@/lib/utils"; import { - Archive01Icon, - ArchiveRestoreIcon, + Archive03Icon, ChefHatIcon, CursorInfo02Icon, DashboardCircleIcon, @@ -62,6 +61,8 @@ import { Logout05Icon, MoreVerticalIcon, Search01Icon, + PinIcon, + PinOffIcon, PlusSignIcon, PowerIcon, PencilEdit02Icon, @@ -95,11 +96,12 @@ import { moveChatItemToProject, renameChatItem, renameChatProject, - unarchiveChatItem, useChatRuntimeStore, useChatProjects, useChatSearchStore, useChatSidebarItems, + usePinnedChatsStore, + useChatPreferencesStore, type ProjectRecord, type SidebarItem, } from "@/features/chat"; @@ -297,16 +299,32 @@ export function AppSidebar() { const activeProjectId = isChatRoute ? ((search.project as string | undefined) ?? null) : null; - const { items: allChatItems, archivedItems: archivedChatItems } = - useChatSidebarItems({ - enabled: !isStudioRoute, - requireMessages: false, - }); - const recentChatItems = useMemo( - () => allChatItems.filter((item) => !item.projectId), - [allChatItems], + const { items: allChatItems } = useChatSidebarItems({ + enabled: !isStudioRoute, + requireMessages: false, + }); + const pinnedIds = usePinnedChatsStore((s) => s.pinnedIds); + const togglePinnedChat = usePinnedChatsStore((s) => s.togglePin); + const unpinChat = usePinnedChatsStore((s) => s.unpin); + const confirmDeleteChats = useChatPreferencesStore( + (s) => s.confirmDeleteChats, ); - const [archivedOpen, setArchivedOpen] = useState(false); + const pinnedIdSet = useMemo(() => new Set(pinnedIds), [pinnedIds]); + const recentChatItems = useMemo( + () => + allChatItems.filter( + (item) => !item.projectId && !pinnedIdSet.has(item.id), + ), + [allChatItems, pinnedIdSet], + ); + // Pinned chats, in pin order (most recent first). + const pinnedChatItems = useMemo(() => { + const byId = new Map(allChatItems.map((item) => [item.id, item])); + return pinnedIds + .map((id) => byId.get(id)) + .filter((item): item is SidebarItem => Boolean(item)); + }, [allChatItems, pinnedIds]); + const [pinnedOpen, setPinnedOpen] = useState(true); const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId); const setActiveThreadId = useChatRuntimeStore((s) => s.setActiveThreadId); const activeThreadId = isChatRoute @@ -382,6 +400,19 @@ export function AppSidebar() { }); } + // Shared chat delete: same error toast and pin cleanup whether or not the + // confirm dialog is used. + async function deleteChatWithCleanup(item: SidebarItem) { + try { + await handleDeleteThread(item); + unpinChat(item.id); + } catch (err) { + toast.error(translate("shell.toast.failedToDeleteChat"), { + description: err instanceof Error ? err.message : undefined, + }); + } + } + async function handleArchiveThread(item: SidebarItem) { try { await archiveChatItem(item, activeThreadId, (view) => { @@ -392,6 +423,19 @@ export function AppSidebar() { : { new: view.newThreadNonce }, }); }); + const toastId = toast( + , + { 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/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..119471da10 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 { @@ -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() {

+ + 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]); diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts index 9e0ec15e54..41964d9475 100644 --- a/studio/frontend/src/i18n/locales/en.ts +++ b/studio/frontend/src/i18n/locales/en.ts @@ -152,6 +152,13 @@ export const en = { description: "How your profile appears in Unsloth.", changePicture: "Change profile picture", displayName: "Display name", + nickname: "What should Unsloth call you?", + nicknamePlaceholder: "Nickname", + nicknameSaved: "Preferred name saved", + avatarShape: "Profile picture shape", + avatarShapeCircle: "Circle", + avatarShapeRounded: "Rounded", + chooseSloth: "Or pick a sloth", nameSaved: "Profile name saved", namePersistErrorTitle: "Could not persist profile name", namePersistErrorDescription: diff --git a/studio/frontend/src/i18n/locales/zh-CN.ts b/studio/frontend/src/i18n/locales/zh-CN.ts index 72dad973c6..e7e377564e 100644 --- a/studio/frontend/src/i18n/locales/zh-CN.ts +++ b/studio/frontend/src/i18n/locales/zh-CN.ts @@ -139,6 +139,13 @@ export const zhCN = { description: "更新你在 Unsloth 中显示的个人资料。", changePicture: "更换头像", displayName: "显示名称", + nickname: "Unsloth 应该怎么称呼你?", + nicknamePlaceholder: "昵称", + nicknameSaved: "称呼名称已保存", + avatarShape: "头像形状", + avatarShapeCircle: "圆形", + avatarShapeRounded: "圆角矩形", + chooseSloth: "或选择一只树懒", nameSaved: "个人资料名称已保存", namePersistErrorTitle: "无法持久保存个人资料名称", namePersistErrorDescription: diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css index 085ddb81f7..d0ca2d2426 100644 --- a/studio/frontend/src/index.css +++ b/studio/frontend/src/index.css @@ -578,6 +578,11 @@ .sidebar-row-action-glyph { @apply inline-flex size-6 items-center justify-center rounded-full text-sidebar-foreground/55; } + /* Secondary row action (the pinned-chat unpin button) sits just left of + the primary "…" options button. */ + .sidebar-row-action.is-unpin-action { + right: 1.875rem; + } /* Branch picker chevron buttons sit beside action bar icon buttons (size-8, rounded-full). Height + radius match for visual @@ -744,7 +749,7 @@ } .tooltip-compact { - @apply rounded-[9px] border-transparent bg-black px-2.5 py-1.5 text-[11px] font-medium leading-snug text-white shadow-md; + @apply rounded-[11px] border-transparent bg-black px-2.5 py-1.5 text-[11px] font-medium leading-snug text-white shadow-md; } /* Dialog popups: borderless; chatbox shadow in light, flat card @@ -1784,6 +1789,12 @@ padding-bottom: 14px !important; } +/* Downloading state shows a progress bar as the last row; give it a little + extra breathing room below the bar. */ +[data-sonner-toast][data-styled='true'].chat-model-load-toast:has([role='progressbar']) { + padding-bottom: 18px !important; +} + [data-sonner-toast][data-styled='true'].chat-model-loaded-toast [data-close-button] { top: calc(50% - 0.25px) !important; transform: translateY(-50%) !important; diff --git a/studio/frontend/src/lib/chevron-icons.ts b/studio/frontend/src/lib/chevron-icons.ts new file mode 100644 index 0000000000..b1a74e9953 --- /dev/null +++ b/studio/frontend/src/lib/chevron-icons.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import type { IconSvgElement } from "@hugeicons/react"; + +// Standard chevrons: straight-line shapes shared across dropdown triggers and +// submenu arrows so every menu indicator matches the composer's menus. +export const ChevronDownStandardIcon: IconSvgElement = [ + [ + "path", + { + d: "M5.99977 9.00005L11.9998 15L17.9998 9", + stroke: "currentColor", + strokeLinecap: "round", + strokeLinejoin: "round", + strokeWidth: "1.5", + key: "0", + }, + ], +]; + +export const ChevronRightStandardIcon: IconSvgElement = [ + [ + "path", + { + d: "M9 6L15 12L9 18", + stroke: "currentColor", + strokeLinecap: "round", + strokeLinejoin: "round", + strokeWidth: "1.5", + key: "0", + }, + ], +]; diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 219211034d..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) @@ -6436,13 +6440,19 @@ def validate_prebuilt_attempts( f"runtime_line={attempt.runtime_line} coverage_class={attempt.coverage_class}" ) - if existing_install_dir is not None and existing_install_matches_choice( - existing_install_dir, - host, - llama_tag = llama_tag, - release_tag = release_tag, - choice = attempt, - approved_checksums = approved_checksums, + if ( + existing_install_dir is not None + and existing_install_matches_choice( + existing_install_dir, + host, + llama_tag = llama_tag, + release_tag = release_tag, + choice = attempt, + approved_checksums = approved_checksums, + ) + # Skip a matching candidate unless it still needs the DiffusionGemma + # backfill re-extract (gated per-attempt, not per-plan). + and not diffusion_visual_server_backfill_needed(existing_install_dir, host, attempt) ): log( "existing llama.cpp install already matches fallback candidate " @@ -6490,6 +6500,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 +6563,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 +6581,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,6 +6611,7 @@ def install_prebuilt( release_tag = plan.release_tag, approved_checksums = plan.approved_checksums, initial_fallback_used = release_index > 0, + # Skip is gated per-attempt inside, so pass the dir always. existing_install_dir = install_dir, ) except ExistingInstallSatisfied: 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" diff --git a/tests/studio/install/test_launch_studio_launcher.py b/tests/studio/install/test_launch_studio_launcher.py new file mode 100644 index 0000000000..8ea9ca7d80 --- /dev/null +++ b/tests/studio/install/test_launch_studio_launcher.py @@ -0,0 +1,50 @@ +"""Guard install.ps1's launch-studio.vbs against re-introducing the AV-heuristic +shape: a WScript .vbs spawning a hidden, ExecutionPolicy-Bypass PowerShell.""" + +import re +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +INSTALL_PS1 = REPO_ROOT / "install.ps1" + + +def _vbs_block() -> str: + text = INSTALL_PS1.read_text(encoding = "utf-8") + m = re.search(r'\$vbsContent\s*=\s*@"\r?\n(.*?)\r?\n"@', text, re.S) + assert m, "could not locate the $vbsContent here-string in install.ps1" + return m.group(1) + + +def test_install_ps1_present(): + assert INSTALL_PS1.is_file(), f"missing {INSTALL_PS1}" + + +def test_vbs_does_not_pass_windowstyle_hidden(): + vbs = _vbs_block() + assert "-WindowStyle Hidden" not in vbs, ( + "launch-studio.vbs must not pass -WindowStyle Hidden to PowerShell: the " + "window is already hidden by shell.Run(cmd, 0, False); the redundant flag " + "only adds the hidden-PowerShell token that AV heuristics flag." + ) + + +def test_vbs_stays_windowless_via_shell_run(): + vbs = _vbs_block() + assert re.search(r"shell\.Run\s+cmd\s*,\s*0\s*,\s*False", vbs), ( + "launcher must remain windowless via shell.Run(cmd, 0, False) " + "(intWindowStyle 0 = hidden)." + ) + + +def test_vbs_keeps_bypass_and_file_invocation(): + # Bypass lets the unsigned local .ps1 run under the default Restricted policy. + vbs = _vbs_block() + assert "-ExecutionPolicy Bypass" in vbs + assert "-File" in vbs + assert "powershell" in vbs + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"])) 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 # =========================================================================== diff --git a/tests/test_studio_install_workspace_guard.py b/tests/test_studio_install_workspace_guard.py index 7d93645ca9..27035b522d 100644 --- a/tests/test_studio_install_workspace_guard.py +++ b/tests/test_studio_install_workspace_guard.py @@ -873,15 +873,20 @@ def test_llama_cpp_search_roots_handles_studio_root_oserror(): llama_cpp = ( REPO_ROOT / "studio" / "backend" / "core" / "inference" / "llama_cpp.py" ).read_text() - find_block_start = llama_cpp.index("_find_llama_server_binary") - find_block = llama_cpp[find_block_start : find_block_start + 4000] - assert ( - "except (ImportError, OSError, ValueError):" in find_block + + def _method_body(name: str) -> str: + # Whole method body (def to next sibling def), so the check survives the + # function growing past any fixed-size window. + start = llama_cpp.index(f"def {name}") + indent = " " * (start - llama_cpp.rfind("\n", 0, start) - 1) + nxt = llama_cpp.find(f"\n{indent}def ", start + 1) + return llama_cpp[start : nxt if nxt != -1 else len(llama_cpp)] + + assert "except (ImportError, OSError, ValueError):" in _method_body( + "_find_llama_server_binary" ), "_find_llama_server_binary must catch (ImportError, OSError, ValueError) from studio_root()" - kill_def_idx = llama_cpp.index("def _kill_orphaned_servers") - kill_block = llama_cpp[kill_def_idx : kill_def_idx + 4000] - assert ( - "except (ImportError, OSError, ValueError):" in kill_block + assert "except (ImportError, OSError, ValueError):" in _method_body( + "_kill_orphaned_servers" ), "sibling _kill_orphaned_servers must keep its (ImportError, OSError, ValueError) handler" 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 diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 8a0496c8f2..63199e5c50 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.7" __all__ = [ "SUPPORTS_BFLOAT16",