Merge remote-tracking branch 'origin/main' into pr-5748-head

This commit is contained in:
danielhanchen 2026-06-14 08:11:01 +00:00
commit dec240ade7
74 changed files with 1879 additions and 530 deletions

View file

@ -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)

View file

@ -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..."

View file

@ -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",

View file

@ -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, "

View file

@ -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)

View file

@ -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):

View file

@ -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 = "<think>"
@ -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",

View file

@ -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 ``<studio>/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)

View file

@ -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-<sha> 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);

View file

@ -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 "
# =====================================================================

View file

@ -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-<sha>) 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 {

View file

@ -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."

View file

@ -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",

View file

@ -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,

View file

@ -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(
<button
type="button"
onClick={() => {
toast.dismiss(toastId);
useSettingsDialogStore.getState().openArchivedChats();
}}
className="w-full cursor-pointer text-left"
>
You can view archived chats in Settings
</button>,
{ 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<HTMLInputElement>,
) {
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 (
<SidebarMenuItem key={item.id} className={itemClass}>
<input
autoFocus
value={renameDraft}
onChange={(event) => 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",
)}
/>
</SidebarMenuItem>
);
}
return (
<SidebarMenuItem key={item.id} className={itemClass}>
<SidebarMenuButton
@ -626,7 +727,9 @@ export function AppSidebar() {
closeMobileIfOpen();
}}
>
<span className="truncate">{item.title}</span>
<span className="truncate">
{pendingRename?.id === item.id ? pendingRename.title : item.title}
</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -651,6 +754,10 @@ export function AppSidebar() {
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => togglePinnedChat(item.id)}>
<HugeiconsIcon icon={isPinned ? PinOffIcon : PinIcon} strokeWidth={1.75} className="size-icon" />
<span>{isPinned ? "Unpin chat" : "Pin chat"}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<HugeiconsIcon icon={FolderExportIcon} strokeWidth={1.75} className="size-icon" />
@ -727,19 +834,46 @@ export function AppSidebar() {
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void handleArchiveThread(item)}>
<HugeiconsIcon icon={Archive01Icon} strokeWidth={1.75} className="size-icon" />
<HugeiconsIcon icon={Archive03Icon} strokeWidth={1.75} className="size-icon" />
<span>Archive</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => setConfirmingDelete({ kind: "chat", item })}
onSelect={() =>
confirmDeleteChats
? setConfirmingDelete({ kind: "chat", item })
: void deleteChatWithCleanup(item)
}
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isPinned ? (
<Tooltip>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
unpinChat(item.id);
}}
aria-label="Unpin chat"
className={cn(actionClass, "is-unpin-action")}
>
<span className="sidebar-row-action-glyph">
<HugeiconsIcon icon={PinOffIcon} strokeWidth={1.75} className="size-4" />
</span>
</button>
</TooltipPrimitive.Trigger>
<TooltipContent side="bottom" sideOffset={6} className="tooltip-compact">
Unpin
</TooltipContent>
</Tooltip>
) : null}
</SidebarMenuItem>
);
}
@ -976,21 +1110,20 @@ export function AppSidebar() {
</SidebarGroup>
</Collapsible>
{!isStudioRoute && (
<Collapsible open={chatOpen} onOpenChange={setChatOpen} asChild>
{/* Pinned chats: own section above Recents */}
{!isStudioRoute && pinnedChatItems.length > 0 && (
<Collapsible open={pinnedOpen} onOpenChange={setPinnedOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
<div className="flex w-full items-center group/sb-collap">
<CollapsibleTrigger className="cursor-pointer flex flex-1 items-center gap-1 min-w-0">
{t("shell.navigation.recents")}
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
</CollapsibleTrigger>
</div>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center gap-1 group/sb-collap">
Pinned
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="pl-1 pr-1.5">
<SidebarMenu>
{recentChatItems.map((item) =>
{pinnedChatItems.map((item) =>
renderChatSidebarItem(item, "recent"),
)}
</SidebarMenu>
@ -1000,78 +1133,21 @@ export function AppSidebar() {
</Collapsible>
)}
{/* Archived chats — hidden on Studio + when nothing is archived */}
{!isStudioRoute && archivedChatItems.length > 0 && (
<Collapsible open={archivedOpen} onOpenChange={setArchivedOpen} asChild>
{!isStudioRoute && (
<Collapsible open={chatOpen} onOpenChange={setChatOpen} asChild>
<SidebarGroup className="group-data-[collapsible=icon]:hidden px-0 py-0">
<SidebarGroupLabel className={cn("sidebar-sticky-label sidebar-sticky-label-following", scrolled && "is-scrolled")} asChild>
<CollapsibleTrigger className="cursor-pointer flex w-full items-center gap-1 group/sb-collap">
Archived
{t("shell.navigation.recents")}
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent className="px-1.5">
<SidebarGroupContent className="pl-1 pr-1.5">
<SidebarMenu>
{archivedChatItems.map((item) => (
<SidebarMenuItem key={item.id} className="group/archived-item relative">
<SidebarMenuButton
data-testid="archived-thread"
data-thread-type={item.type}
data-thread-id={item.id}
isActive={activeThreadId === item.id}
className="sidebar-nav-btn h-[33px] cursor-pointer rounded-full pl-3.5 pr-4 group-hover/archived-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/archived-item:pr-8 text-[14.5px] leading-[19px] tracking-nav font-medium text-muted-foreground"
onClick={() => {
navigate({
to: "/chat",
search:
item.type === "single"
? { thread: item.id }
: { compare: item.id },
});
closeMobileIfOpen();
}}
>
<span className="truncate">{item.title}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
aria-label="Archived chat options"
className="sidebar-row-action group-hover/archived-item:opacity-100 group-hover/archived-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"
>
<span className="sidebar-row-action-glyph">
<HugeiconsIcon icon={MoreVerticalIcon} strokeWidth={1.75} className="size-icon" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="start"
sideOffset={0}
className="unsloth-plus-menu menu-flat-destructive w-52"
>
<DropdownMenuItem onSelect={() => openRenameChat(item)}>
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleUnarchiveThread(item)}>
<HugeiconsIcon icon={ArchiveRestoreIcon} strokeWidth={1.75} className="size-icon" />
<span>Unarchive</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => setConfirmingDelete({ kind: "chat", item })}
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.75} className="size-icon" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
{recentChatItems.map((item) =>
renderChatSidebarItem(item, "recent"),
)}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
@ -1252,23 +1328,24 @@ export function AppSidebar() {
: t("shell.navigation.darkMode")}
</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!getTourId(pathname)}
onSelect={() => {
const tourId = getTourId(pathname);
if (!tourId) return;
window.dispatchEvent(
new CustomEvent(TOUR_OPEN_EVENT, {
detail: { id: tourId },
}),
);
}}
>
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-icon" />
<span>{t("shell.navigation.guidedTour")}</span>
</DropdownMenuItem>
{getTourId(pathname) && (
<DropdownMenuItem
onSelect={() => {
const tourId = getTourId(pathname);
if (!tourId) return;
window.dispatchEvent(
new CustomEvent(TOUR_OPEN_EVENT, {
detail: { id: tourId },
}),
);
}}
>
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-icon" />
<span>{t("shell.navigation.guidedTour")}</span>
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator className="mx-2.5! my-2.5! h-0! border-t border-border/70 bg-transparent!" />
<DropdownMenuSeparator className="mx-1! my-2.5! h-0! border-t border-border/70 bg-transparent!" />
<DropdownMenuItem
onSelect={() => useSettingsDialogStore.getState().openDialog("about")}
>
@ -1390,7 +1467,7 @@ export function AppSidebar() {
</DialogContent>
</Dialog>
<Dialog
open={renamingTarget !== null}
open={renamingTarget !== null && renamingTarget.kind !== "chat"}
onOpenChange={(open) => {
if (!open) setRenamingTarget(null);
}}

View file

@ -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({
</span>
)}
</span>
<span className="flex size-4 shrink-0 items-center justify-center">
<span className="-ml-1 flex size-4 shrink-0 items-center justify-center">
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
strokeWidth={1.75}
className="size-3.5 text-muted-foreground"
/>

View file

@ -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<Welcome>(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}`;

View file

@ -203,11 +203,11 @@ export function LlamaUpdateBanner({
/>
</div>
) : (
<div className="mt-4 flex flex-wrap items-center justify-between gap-y-2">
<div className="mt-4 flex flex-wrap items-center justify-end gap-x-1 gap-y-2">
<Button
size="sm"
variant="ghost"
className="-ml-2 h-auto rounded-full px-2.5 py-2 text-[13px] font-medium text-foreground"
className="h-auto rounded-full px-3 py-2 text-[13px] font-medium text-foreground"
onClick={snooze}
data-testid="llama-update-snooze-button"
>
@ -215,8 +215,8 @@ export function LlamaUpdateBanner({
</Button>
<Button
size="sm"
// ml offsets the pill's filled edge so visual gaps stay equal
className="ml-2.5 h-auto rounded-full px-3.5 py-2 text-[13px]"
// -mr optically aligns the filled pill's edge with the card padding
className="-mr-1 h-auto rounded-full px-3.5 py-2 text-[13px]"
onClick={handleUpdate}
data-testid="llama-update-button"
>

View file

@ -37,7 +37,7 @@ function AlertDialogOverlay({
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50",
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-[2px] fixed inset-0 z-50",
className,
)}
{...props}

View file

@ -53,7 +53,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 inset-0 isolate z-50",
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/30 supports-backdrop-filter:backdrop-blur-[2px] duration-100 inset-0 isolate z-50",
position === "fixed" ? "fixed" : "absolute",
className,
)}
@ -89,7 +89,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/5 grid max-w-[calc(100%-2rem)] gap-6 rounded-4xl p-6 text-sm ring-1 duration-100 sm:max-w-md top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2",
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/5 grid max-w-[calc(100%-2rem)] gap-6 rounded-4xl px-7 pt-8 pb-7 text-sm ring-1 duration-100 sm:max-w-md top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2",
position === "fixed" ? "fixed" : "absolute",
className,
)}
@ -100,7 +100,7 @@ function DialogContent({
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-4 right-4"
className="absolute top-5 right-5"
size="icon-sm"
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
@ -158,7 +158,7 @@ function DialogTitle({
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
className={cn("font-heading text-lg leading-none font-semibold", className)}
{...props}
/>
);

View file

@ -5,8 +5,8 @@ import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import type * as React from "react";
import { Tick02Icon } from "@/lib/tick-icon";
import { ChevronRightStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import { ArrowRight01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
function DropdownMenu({
@ -184,7 +184,7 @@ function DropdownMenuSeparator({
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border/50 -mx-1 my-1 h-px", className)}
className={cn("bg-border/50 mx-1 my-1 h-px", className)}
{...props}
/>
);
@ -232,8 +232,8 @@ function DropdownMenuSubTrigger({
>
{children}
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2}
icon={ChevronRightStandardIcon}
strokeWidth={1.5}
className="ml-auto size-[12px]"
/>
</DropdownMenuPrimitive.SubTrigger>

View file

@ -8,7 +8,7 @@ import type * as React from "react";
import { Tick02Icon } from "@/lib/tick-icon";
import { cn } from "@/lib/utils";
import { ArrowRight01Icon } from "@hugeicons/core-free-icons";
import { ChevronRightStandardIcon } from "@/lib/chevron-icons";
import { HugeiconsIcon } from "@hugeicons/react";
function Menubar({
@ -241,8 +241,8 @@ function MenubarSubTrigger({
>
{children}
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2}
icon={ChevronRightStandardIcon}
strokeWidth={1.5}
className="ml-auto size-[12px]"
/>
</MenubarPrimitive.SubTrigger>

View file

@ -44,7 +44,7 @@ function SheetOverlay({
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs inset-0 z-50",
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/30 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-[2px] inset-0 z-50",
position === "fixed" ? "fixed" : "absolute",
className,
)}

View file

@ -137,24 +137,27 @@ export function WebUpdateBanner({
>
Release notes
</a>
<Button
size="sm"
variant="ghost"
className="h-auto rounded-full px-2.5 py-2 text-[13px] font-medium text-foreground"
onClick={snooze}
data-testid="web-update-snooze-button"
>
Remind me later
</Button>
<Button
size="sm"
// ml offsets the pill's filled edge so visual gaps stay equal
className="ml-2.5 h-auto rounded-full px-3.5 py-2 text-[13px]"
onClick={handleCopyCommand}
data-testid="web-update-copy-button"
>
{copied ? "Copied" : "Copy command"}
</Button>
{/* wrap + right-align so buttons stack instead of clipping on very narrow banners */}
<div className="flex flex-wrap items-center justify-end gap-x-1 gap-y-2">
<Button
size="sm"
variant="ghost"
className="h-auto rounded-full px-3 py-2 text-[13px] font-medium text-foreground"
onClick={snooze}
data-testid="web-update-snooze-button"
>
Remind me later
</Button>
<Button
size="sm"
// -mr optically aligns the filled pill's edge with the card padding
className="-mr-1 h-auto rounded-full px-3.5 py-2 text-[13px]"
onClick={handleCopyCommand}
data-testid="web-update-copy-button"
>
{copied ? "Copied" : "Copy command"}
</Button>
</div>
</div>
</div>
</motion.div>

View file

@ -443,7 +443,7 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
<Button
type="submit"
className="mx-auto flex w-fit px-8"
className="mx-auto flex w-fit px-4"
disabled={
loading ||
statusLoading ||

View file

@ -53,12 +53,12 @@ import { useIsMobile } from "@/hooks/use-mobile";
import { useLlamaUpdateCheck } from "@/hooks/use-llama-update-check";
import { cn } from "@/lib/utils";
import {
ArrowDown01Icon,
ArrowTurnBackwardIcon,
Edit03Icon,
InformationCircleIcon,
LayoutAlignRightIcon,
} from "@hugeicons/core-free-icons";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { ChevronDown, ExternalLink } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
@ -911,7 +911,7 @@ export function ChatSettingsPanel({
>
<SelectTrigger
animateRadius={false}
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
iconClassName="size-3.5"
className="grid h-7 w-[64px] min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-full border-transparent bg-black/[0.04] dark:bg-white/[0.05] hover:bg-black/[0.06] dark:hover:bg-white/[0.1] pl-3 pr-2 py-0 text-[13px]! font-medium text-nav-fg focus-visible:ring-0 focus-visible:border-transparent [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate [&>svg]:shrink-0"
>
@ -951,7 +951,7 @@ export function ChatSettingsPanel({
>
<SelectTrigger
animateRadius={false}
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
iconClassName="size-3.5"
className="grid h-7 w-[124px] min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-full border-transparent bg-black/[0.04] dark:bg-white/[0.05] hover:bg-black/[0.06] dark:hover:bg-white/[0.1] pl-3 pr-2 py-0 text-[13px]! font-medium text-nav-fg focus-visible:ring-0 focus-visible:border-transparent [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate [&>svg]:shrink-0"
data-test-id="speculative-type-select"
@ -1172,7 +1172,7 @@ export function ChatSettingsPanel({
aria-hidden="true"
>
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
className="size-3.5"
strokeWidth={2}
/>
@ -1547,10 +1547,7 @@ export function ChatSettingsPanel({
setSystemPromptEditorOpen(nextOpen);
}}
>
<DialogContent
className="corner-squircle dialog-soft-surface sm:max-w-3xl"
overlayClassName="bg-background/35 supports-backdrop-filter:backdrop-blur-[1px]"
>
<DialogContent className="corner-squircle dialog-soft-surface sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Edit System Prompt</DialogTitle>
<DialogDescription>
@ -1827,10 +1824,7 @@ function ChatTemplateFields() {
</div>
</div>
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
<DialogContent
className="corner-squircle dialog-soft-surface sm:max-w-3xl"
overlayClassName="bg-background/35 supports-backdrop-filter:backdrop-blur-[1px]"
>
<DialogContent className="corner-squircle dialog-soft-surface sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Edit Chat Template</DialogTitle>
<DialogDescription>

View file

@ -50,8 +50,8 @@ export function ModelLoadDescription({
<div className="flex h-full shrink-0 items-center self-center">
<Spinner className="size-3.5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
{title ? <p className="text-foreground leading-5 font-semibold">{title}</p> : null}
<div className="flex min-w-0 flex-1 flex-col justify-center">
{title ? <p className="text-foreground leading-tight font-semibold">{title}</p> : null}
{hasProgress ? (
<div className="w-full pt-1">
<div className="flex items-center justify-between gap-2 text-[10px] font-medium tracking-[0.08em] text-muted-foreground/80">

View file

@ -8,11 +8,9 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
ArrowDown01Icon,
Folder01Icon,
} from "@hugeicons/core-free-icons";
import { Folder01Icon } from "@hugeicons/core-free-icons";
import { Tick02Icon } from "@/lib/tick-icon";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { ReactElement } from "react";
import type { ProjectRecord } from "../types";
@ -60,7 +58,7 @@ export function ProjectSwitcher({
</span>
<span className="flex size-4 shrink-0 items-center justify-center">
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
strokeWidth={1.75}
className="size-3.5 text-muted-foreground"
aria-hidden={true}
@ -72,7 +70,7 @@ export function ProjectSwitcher({
side="bottom"
align="start"
sideOffset={0}
className="app-user-menu menu-soft-surface ring-0 min-w-56 max-w-72 max-h-72 py-2 font-heading rounded-[14px] border-0"
className="unsloth-plus-menu ring-0 min-w-56 max-w-72 max-h-72 font-heading"
>
{showLoadingRow ? (
<DropdownMenuItem disabled={true} className="text-muted-foreground">

View file

@ -18,6 +18,8 @@ export {
} from "./chat-settings-sheet";
export { useChatRuntimeStore } from "./stores/chat-runtime-store";
export { useChatSearchStore } from "./stores/chat-search-store";
export { usePinnedChatsStore } from "./stores/pinned-chats-store";
export { useChatPreferencesStore } from "./stores/chat-preferences-store";
export {
PLUS_MENU_ORDER,
usePlusMenuPrefsStore,

View file

@ -609,7 +609,10 @@ function createStudioDbAdapter(
}
return {
remoteId: thread.id,
status: thread.archived ? "archived" : "regular",
// Always regular: archive state is owned by the app's own controls.
// Reporting archived here makes assistant-ui unarchive a chat the
// moment it is opened.
status: "regular",
title: thread.title,
};
},
@ -658,8 +661,10 @@ function createStudioDbAdapter(
},
async unarchive(remoteId: string) {
// No-op on archive state: the app owns it via the sidebar menu and the
// archived chats settings dialog. assistant-ui calls this when an
// archived chat is opened, which must not unarchive it.
await ensureStoredChatThread(remoteId);
await updateStoredChatThread(remoteId, { archived: false });
},
async delete(remoteId: string) {

View file

@ -0,0 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
import { persist } from "zustand/middleware";
// Client-side chat UI prefs kept in localStorage, not the chat DB.
// confirmDeleteChats: when off, deleting a chat skips the confirm dialog.
export interface ChatPreferencesState {
confirmDeleteChats: boolean;
setConfirmDeleteChats: (value: boolean) => void;
}
export const useChatPreferencesStore = create<ChatPreferencesState>()(
persist(
(set) => ({
confirmDeleteChats: true,
setConfirmDeleteChats: (confirmDeleteChats) =>
set({ confirmDeleteChats }),
}),
{
name: "unsloth_chat_preferences",
merge: (persisted, current) => {
const saved = persisted as Partial<ChatPreferencesState> | undefined;
return {
...current,
confirmDeleteChats: saved?.confirmDeleteChats ?? true,
};
},
},
),
);

View file

@ -0,0 +1,42 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
import { persist } from "zustand/middleware";
// Client-side pin state for chats, keyed by stable chat id. Kept in
// localStorage, not the chat DB. New pins are prepended so the most
// recently pinned chat sorts first in the Pinned section.
export interface PinnedChatsState {
pinnedIds: string[];
togglePin: (id: string) => void;
unpin: (id: string) => void;
}
export const usePinnedChatsStore = create<PinnedChatsState>()(
persist(
(set) => ({
pinnedIds: [],
togglePin: (id) =>
set((state) => ({
pinnedIds: state.pinnedIds.includes(id)
? state.pinnedIds.filter((x) => x !== id)
: [id, ...state.pinnedIds],
})),
unpin: (id) =>
set((state) => ({
pinnedIds: state.pinnedIds.filter((x) => x !== id),
})),
}),
{
name: "unsloth_pinned_chats",
merge: (persisted, current) => {
const saved = persisted as Partial<PinnedChatsState> | undefined;
return {
...current,
pinnedIds: Array.isArray(saved?.pinnedIds) ? saved.pinnedIds : [],
};
},
},
),
);

View file

@ -400,7 +400,7 @@ export function DataRecipesPage(): ReactElement {
return (
<div className="min-h-[calc(100dvh-var(--studio-titlebar-height,0px))] bg-background">
<main className="mx-auto w-full max-w-7xl px-6 py-8">
<main className="mx-auto w-full max-w-7xl px-5 py-8 sm:px-9">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-[30px] font-semibold leading-[1.04] tracking-[-0.028em] text-foreground sm:text-[34px]">

View file

@ -583,7 +583,7 @@ export function ExportPage() {
// ---- Render ----
return (
<div className="min-h-[calc(100dvh-var(--studio-titlebar-height,0px))] bg-background">
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6">
<main className="mx-auto max-w-7xl px-5 py-8 sm:px-9">
<GuidedTour {...tour.tourProps} />
<div className="mb-8 flex flex-col gap-0.5">

View file

@ -39,11 +39,11 @@ export function ExternalLinkConfirmDialog() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia>
<AlertDialogMedia className="size-12">
<HugeiconsIcon
icon={LinkSquare02Icon}
strokeWidth={1.75}
className="text-muted-foreground"
className="size-5 text-muted-foreground"
/>
</AlertDialogMedia>
<AlertDialogTitle>Open external link</AlertDialogTitle>

View file

@ -26,9 +26,9 @@ import {
normalizeGgufVariantIdentity,
} from "../lib/model-identity";
import { cn } from "@/lib/utils";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { useHfTokenStore } from "../stores/hf-token-store";
import {
ArrowDown01Icon,
Delete02Icon,
Download01Icon,
InformationCircleIcon,
@ -666,7 +666,7 @@ export function GgufDownloadCard({
e.preventDefault();
setOpen((o) => !o);
}}
className="hub-menu-trigger flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-[12px] px-3 text-left transition-colors hover:bg-foreground/[0.04] data-[state=open]:bg-foreground/[0.06] dark:hover:bg-white/[0.1] dark:data-[state=open]:bg-white/[0.06]"
className="hub-menu-trigger flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-full px-3 text-left transition-colors hover:bg-foreground/[0.04] data-[state=open]:bg-foreground/[0.06] dark:hover:bg-white/[0.1] dark:data-[state=open]:bg-white/[0.06]"
>
{selected ? (
<QuantBadge
@ -713,7 +713,7 @@ export function GgufDownloadCard({
</span>
)}
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
strokeWidth={1.25}
className="ml-0.5 size-3.5 shrink-0"
/>
@ -831,7 +831,11 @@ export function GgufDownloadCard({
</>
) : selected?.downloaded ? (
<>
<HugeiconsIcon icon={PlayIcon} strokeWidth={1.75} />
<HugeiconsIcon
icon={PlayIcon}
strokeWidth={1.75}
className="translate-x-px"
/>
Run
</>
) : (

View file

@ -7,8 +7,8 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Tick02Icon } from "@/lib/tick-icon";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import { ArrowDown01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type KeyboardEvent,
@ -156,7 +156,7 @@ export function HubOptionMenu<T extends string>({
aria-label={ariaLabel}
title={title}
className={cn(
"field-trigger hub-menu-trigger field-soft field-filter inline-flex h-9 shrink-0 cursor-pointer items-center justify-between gap-2 rounded-full px-3 text-[12.5px] transition-colors",
"field-trigger hub-menu-trigger field-soft field-filter inline-flex h-9 shrink-0 cursor-pointer items-center justify-between gap-0.5 rounded-full pl-3 pr-2 text-[12.5px] transition-colors",
"focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0",
className,
)}
@ -167,14 +167,18 @@ export function HubOptionMenu<T extends string>({
}
}}
>
<span className="flex min-w-0 items-center gap-2 truncate">
{triggerContent ?? selected?.triggerLabel ?? selected?.label ?? value}
<span className="flex min-w-0 items-center gap-2">
{triggerContent ?? (
<span className="min-w-0 truncate">
{selected?.triggerLabel ?? selected?.label ?? value}
</span>
)}
</span>
{showChevron && (
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
strokeWidth={1.5}
className="size-3.5 shrink-0 text-muted-foreground"
className="size-3 shrink-0 text-muted-foreground"
/>
)}
</button>
@ -186,7 +190,7 @@ export function HubOptionMenu<T extends string>({
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<T extends string>({
}}
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",
)}
>
<span className="flex min-w-0 flex-1 items-center gap-2.5 overflow-hidden whitespace-normal break-words">
{option.label}
</span>
{selectedOption && (
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<span className="pointer-events-none flex size-4 shrink-0 items-center justify-center">
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2}

View file

@ -28,9 +28,9 @@ import { ggufVariantsMatch } from "../lib/model-identity";
import { cn } from "@/lib/utils";
import { confirmExternalLink } from "../stores/external-link-confirm";
import { useHfTokenStore } from "../stores/hf-token-store";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import {
Alert02Icon,
ArrowDown01Icon,
CubeIcon,
PencilEdit02Icon,
PlayIcon,
@ -349,7 +349,7 @@ export function LocalOnDeviceCard({
</span>
)}
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
strokeWidth={1.5}
className="size-3 shrink-0"
/>

View file

@ -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"
>
<button
type="button"

View file

@ -273,7 +273,7 @@ export const ModelsCatalog = memo(function ModelsCatalog({
// inactive pane's scrollTop to 0 and corrupt our mirror. Visibility +
// pointer-events-none hides it while preserving native scroll state.
const scrollPaneClassName =
"absolute inset-0 min-h-0 overflow-y-auto pb-6 pl-5 pr-3 pt-0 [overflow-anchor:none] [scrollbar-gutter:stable] [scrollbar-width:thin]";
"absolute inset-0 min-h-0 overflow-y-auto pb-6 pl-4 pr-3 pt-0 [overflow-anchor:none] [scrollbar-gutter:stable] [scrollbar-width:thin]";
const discoverActive = tab === "discover";
const downloadedActive = tab === "downloaded";
const discoverInactiveHeight = Math.max(

View file

@ -77,7 +77,7 @@ export function ModelsHeader({
subtitle={
isDataset
? "Discover, download, and train on datasets locally."
: "Discover, download, and run inference models locally."
: "Discover, download, and run models locally."
}
/>

View file

@ -91,7 +91,11 @@ export const ModelsToolbar = memo(function ModelsToolbar({
const channelValue: ChannelOptionValue = activeChannelId ?? "all";
const formatOptions = useMemo<HubOption<ModelFormatFilter>[]>(
() =>
FORMAT_FILTER_OPTIONS.map((option) => ({
FORMAT_FILTER_OPTIONS.filter(
// Downloaded inventory rows are never tagged mlx, so only Discover can
// match the MLX filter; hide it elsewhere to avoid an empty list.
(option) => option.value !== "mlx" || tab === "discover",
).map((option) => ({
value: option.value,
triggerLabel: option.label,
label: (
@ -102,11 +106,14 @@ export const ModelsToolbar = memo(function ModelsToolbar({
{option.value === "checkpoint" && (
<span className="inline-block size-1.5 shrink-0 rounded-full bg-format-checkpoint" />
)}
{option.value === "mlx" && (
<span className="inline-block size-1.5 shrink-0 rounded-full bg-format-mlx" />
)}
{option.label}
</>
),
})),
[],
[tab],
);
const capabilityOptions = useMemo<HubOption<CapabilityFilter>[]>(
() =>
@ -154,10 +161,10 @@ export const ModelsToolbar = memo(function ModelsToolbar({
"focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-border",
);
return (
<div className="flex min-w-0 flex-col gap-2 lg:flex-row lg:flex-wrap lg:items-center">
<div className="flex min-w-0 flex-col gap-2 lg:flex-row lg:flex-nowrap lg:items-center">
<div
className={cn(
"hub-menu-trigger hub-tab-toggle relative inline-flex h-9 w-full shrink-0 items-center rounded-full lg:w-[240px]",
"hub-menu-trigger hub-tab-toggle relative inline-flex h-9 w-full shrink-0 items-center rounded-full lg:w-[220px]",
)}
role="radiogroup"
aria-label="View"
@ -290,7 +297,7 @@ export const ModelsToolbar = memo(function ModelsToolbar({
</div>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2 lg:flex-[0_1_auto] lg:justify-end">
<div className="flex min-w-0 flex-wrap items-center gap-2 lg:flex-none lg:flex-nowrap lg:justify-end">
{tab === "downloaded" && !isDataset && (
<Tooltip>
<TooltipTrigger asChild>
@ -322,7 +329,7 @@ export const ModelsToolbar = memo(function ModelsToolbar({
options={formatOptions}
onValueChange={onFormatFilterChange}
ariaLabel="Format filter"
className={cn(triggerBase, "min-w-[124px]")}
className={cn(triggerBase, "w-[128px]")}
/>
)}
@ -332,7 +339,7 @@ export const ModelsToolbar = memo(function ModelsToolbar({
options={capabilityOptions}
onValueChange={onCapabilityFilterChange}
ariaLabel="Capability filter"
className={cn(triggerBase, "min-w-[136px]")}
className={cn(triggerBase, "w-[128px]")}
/>
)}
@ -342,7 +349,7 @@ export const ModelsToolbar = memo(function ModelsToolbar({
options={sortOptions}
onValueChange={onSortChange}
ariaLabel="Sort models"
className={cn(triggerBase, "min-w-[140px]")}
className={cn(triggerBase, "w-[128px]")}
/>
)}

View file

@ -87,10 +87,10 @@ export function HfTokenIndicator({ showLabel = false }: HfTokenIndicatorProps =
onClick={() => openDialog("general")}
aria-label={ariaLabel}
className={cn(
"inline-flex h-[26px] items-center justify-center px-2.5 text-[11.5px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"inline-flex size-[26px] items-center justify-center rounded-full text-[11.5px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
hasToken
? "hub-tag-soft text-muted-foreground hover:text-foreground/80"
: "rounded-full bg-destructive text-destructive-foreground hover:bg-destructive/90",
: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
)}
>
<HugeiconsIcon

View file

@ -9,9 +9,9 @@ import {
import { hasAuthToken, mustChangePassword } from "@/features/auth/session";
import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import {
Alert02Icon,
ArrowDown01Icon,
Cancel01Icon,
CheckmarkCircle02Icon,
Download01Icon,
@ -237,7 +237,7 @@ export function DownloadManagerPanel() {
className="inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-[7px] text-muted-foreground transition-colors hover:bg-foreground/[0.06] hover:text-foreground dark:hover:bg-white/[0.1]"
>
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
strokeWidth={1.75}
className="size-3.5"
/>

View file

@ -52,6 +52,8 @@ import {
import { inventoryRowMatches, tokenizeQuery } from "./lib/inventory-search";
import {
buildDiscoverRows,
detectResultFormat,
isUnslothFinetunable,
matchesCapability,
matchesFormat,
} from "./lib/view-models";
@ -388,8 +390,10 @@ export function ModelsPage() {
discoverRows.filter((row) => {
if (isDatasetMode) return true;
return (
matchesFormat(row.result.isGguf, deferredFormatFilter) &&
matchesCapability(row.capabilities, deferredCapabilityFilter)
matchesFormat(detectResultFormat(row.result), deferredFormatFilter) &&
matchesCapability(row.capabilities, deferredCapabilityFilter) &&
(!activeChannel?.finetunableOnly ||
isUnslothFinetunable(row.result))
);
}),
[
@ -397,6 +401,7 @@ export function ModelsPage() {
isDatasetMode,
deferredFormatFilter,
deferredCapabilityFilter,
activeChannel,
],
);
@ -721,7 +726,7 @@ export function ModelsPage() {
return (
<div className="hub-page flex h-full min-h-0 flex-col">
<div className="mx-auto flex w-full max-w-[1180px] flex-1 min-h-0 flex-col gap-6 px-5 pt-8 pb-16 sm:px-9 sm:pt-10 sm:pb-24">
<div className="mx-auto flex w-full max-w-[1180px] flex-1 min-h-0 flex-col gap-6 px-5 pt-8 pb-6 sm:px-9 sm:pt-10 sm:pb-8">
<ModelsHeader
cachedCount={effectiveCachedRows.length}
localCount={effectiveLocalRows.length}
@ -759,7 +764,7 @@ export function ModelsPage() {
<div className="flex min-h-0 flex-1 flex-col lg:grid lg:grid-cols-[360px_minmax(0,1fr)] xl:grid-cols-[400px_minmax(0,1fr)] 2xl:grid-cols-[440px_minmax(0,1fr)]">
<div
className={cn(
"hub-side-surface flex min-h-0 min-w-0 flex-1 flex-col border-b border-border lg:flex-initial lg:border-b-0 lg:border-r",
"hub-side-surface flex min-h-0 min-w-0 flex-1 flex-col border-b border-sidebar-border lg:flex-initial lg:border-b-0 lg:border-r lg:border-sidebar-border",
mobileInspectorOpen && "hidden lg:flex",
)}
>

View file

@ -15,6 +15,7 @@
--format-gguf: #60a5fa;
--format-checkpoint: #f472b6;
--format-adapter: #8b5cf6;
--format-mlx: #f59e0b;
--status-warning: #eab308;
--status-danger: #ef4444;
--status-success: #11b686;
@ -24,6 +25,7 @@
--format-gguf: #60a5fa;
--format-checkpoint: #f472b6;
--format-adapter: #a78bfa;
--format-mlx: #fbbf24;
--status-warning: #fbbf24;
--status-danger: #ef4444;
--status-success: #11b686;
@ -33,6 +35,7 @@
--color-format-gguf: var(--format-gguf);
--color-format-checkpoint: var(--format-checkpoint);
--color-format-adapter: var(--format-adapter);
--color-format-mlx: var(--format-mlx);
--color-status-warning: var(--status-warning);
--color-status-danger: var(--status-danger);
--color-status-success: var(--status-success);
@ -734,7 +737,7 @@ html:not(.dark) .hub-page [data-hub-scroll="true"] {
}
html.dark .hub-page [data-hub-scroll="true"] {
scrollbar-color: oklch(0.55 0 0 / 0.16) transparent;
scrollbar-color: oklch(0.72 0 0 / 0.3) transparent;
}
html:not(.dark) .hub-page [data-hub-scroll="true"]::-webkit-scrollbar-thumb {
@ -742,7 +745,7 @@ html:not(.dark) .hub-page [data-hub-scroll="true"]::-webkit-scrollbar-thumb {
}
html.dark .hub-page [data-hub-scroll="true"]::-webkit-scrollbar-thumb {
background: oklch(0.55 0 0 / 0.16);
background: oklch(0.72 0 0 / 0.3);
}
/* While a modal dialog is open (delete confirmation, transport-mode swap, the

View file

@ -26,6 +26,8 @@ export interface ChannelPreset {
idSuffix?: string;
format: ModelFormatFilter;
sort: HfSortKey;
// Keep only formats Unsloth can fine-tune (drops fp8, nvfp4, w4a16, etc.).
finetunableOnly?: boolean;
}
export const CHANNEL_PRESETS: readonly ChannelPreset[] = [
@ -33,7 +35,7 @@ export const CHANNEL_PRESETS: readonly ChannelPreset[] = [
id: "unsloth-trending",
label: "Unsloth Trending",
icon: SparklesIcon,
hint: "Most trending models published by Unsloth.",
hint: "Trending Unsloth models.",
owner: "unsloth",
format: "gguf",
sort: "trendingScore",
@ -42,7 +44,7 @@ export const CHANNEL_PRESETS: readonly ChannelPreset[] = [
id: "unsloth-latest",
label: "Latest Unsloth",
icon: NewReleasesIcon,
hint: "Freshly released models from the Unsloth channel.",
hint: "Newest Unsloth releases.",
owner: "unsloth",
format: "all",
sort: "lastModified",
@ -51,12 +53,11 @@ export const CHANNEL_PRESETS: readonly ChannelPreset[] = [
id: "unsloth-safetensors",
label: "Fine-tune ready",
icon: SlidersHorizontalIcon,
hint: "Latest Unsloth bnb-4bit checkpoints ready to fine-tune.",
hint: "Checkpoints ready to fine-tune.",
owner: "unsloth",
query: "bnb-4bit",
idSuffix: "-bnb-4bit",
format: "checkpoint",
sort: "lastModified",
finetunableOnly: true,
},
];

View file

@ -3,12 +3,13 @@
export type FormatFilterModelFormat =
| "gguf"
| "mlx"
| "safetensors"
| "adapter"
| "checkpoint"
| "unknown";
export type FormatFilterValue = "all" | "gguf" | "checkpoint";
export type FormatFilterValue = "all" | "gguf" | "checkpoint" | "mlx";
export function matchesFormat(
modelFormat: boolean | FormatFilterModelFormat | null | undefined,
@ -22,5 +23,40 @@ export function matchesFormat(
: "safetensors"
: modelFormat;
if (formatFilter === "gguf") return normalized === "gguf";
if (formatFilter === "mlx") return normalized === "mlx";
return normalized === "safetensors" || normalized === "checkpoint";
}
// Discover results carry an isGguf flag plus HF tags/library. MLX repos are
// tagged "mlx" or use library_name "mlx", so detect those before falling back
// to safetensors.
export function detectResultFormat(result: {
isGguf: boolean;
tags?: string[];
libraryName?: string;
}): FormatFilterModelFormat {
if (result.isGguf) return "gguf";
if (
result.libraryName?.toLowerCase() === "mlx" ||
result.tags?.some((tag) => tag.toLowerCase() === "mlx")
) {
return "mlx";
}
return "safetensors";
}
// Inference-only quant formats Unsloth cannot fine-tune. Matched on the repo
// name since the search listing often omits the quant config.
const NON_FINETUNABLE_NAME =
/(?:^|[-_/.])(?:fp8|nvfp4|mxfp4|w4a16|w8a8|w8a16|int4|int8|gptq|awq|mobile|litert|tflite)(?:[-_/.]|$)/i;
// Quant methods Unsloth can fine-tune: full precision (none) or bitsandbytes.
const FINETUNABLE_QUANT = new Set(["bitsandbytes", "bnb", "bnb_4bit"]);
export function isUnslothFinetunable(result: {
id: string;
quantMethod?: string;
}): boolean {
if (NON_FINETUNABLE_NAME.test(result.id)) return false;
const quant = result.quantMethod?.toLowerCase();
return !quant || FINETUNABLE_QUANT.has(quant);
}

View file

@ -18,7 +18,11 @@ import {
} from "./model-capabilities";
import { ownerOf, repoOf } from "@/features/hub/lib/format";
import { estimateSizeFromDtypes, isGgufLike } from "./hf-model-meta";
export { matchesFormat } from "./format-filters";
export {
matchesFormat,
detectResultFormat,
isUnslothFinetunable,
} from "./format-filters";
export {
formatLocalUpdated,
localSourceLabel,
@ -46,6 +50,7 @@ export const FORMAT_FILTER_OPTIONS: ReadonlyArray<{
{ value: "all", label: "All formats" },
{ value: "gguf", label: "GGUF" },
{ value: "checkpoint", label: "Checkpoints" },
{ value: "mlx", label: "MLX" },
];
export function formatPipelineTag(tag: string | undefined): string | null {

View file

@ -24,7 +24,7 @@ export type ResourceTypeFilter = "models" | "datasets";
export type HubModelType = "text" | "vision" | "audio" | "embeddings";
export type ModelFormatFilter = "all" | "gguf" | "checkpoint";
export type ModelFormatFilter = "all" | "gguf" | "checkpoint" | "mlx";
export type CapabilityFilter = "all" | CapabilityKey;

View file

@ -1,15 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { publicAssetUrl } from "@/components/mascot-img";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getAuthToken } from "@/features/auth";
import { cn } from "@/lib/utils";
import { useT } from "@/i18n";
import { toastError, toastSuccess } from "@/shared/toast";
import { Camera01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useMemo, useRef, useState } from "react";
import { SLOTH_AVATARS } from "../sloth-avatars";
import { decodeJwtSubject } from "../utils/jwt-subject";
import { resizeImageFileToDataUrl } from "../utils/resize-image-file";
import { useUserProfileStore } from "../stores/user-profile-store";
@ -17,7 +20,11 @@ import { UserAvatar } from "./user-avatar";
const PROFILE_STORAGE_KEY = "unsloth_user_profile";
function readPersistedProfile(): { displayName: string; avatarDataUrl: string | null } | null {
function readPersistedProfile(): {
displayName: string;
nickname: string;
avatarDataUrl: string | null;
} | null {
try {
const raw = window.localStorage.getItem(PROFILE_STORAGE_KEY);
if (!raw) return null;
@ -27,10 +34,15 @@ function readPersistedProfile(): { displayName: string; avatarDataUrl: string |
// Zustand persist shape: { state: {...}, version }
const maybeState = "state" in parsed ? (parsed as { state?: unknown }).state : parsed;
if (!maybeState || typeof maybeState !== "object") return null;
const state = maybeState as { displayName?: unknown; avatarDataUrl?: unknown };
const state = maybeState as {
displayName?: unknown;
nickname?: unknown;
avatarDataUrl?: unknown;
};
return {
displayName: typeof state.displayName === "string" ? state.displayName : "",
nickname: typeof state.nickname === "string" ? state.nickname : "",
avatarDataUrl: typeof state.avatarDataUrl === "string" ? state.avatarDataUrl : null,
};
} catch {
@ -41,12 +53,17 @@ function readPersistedProfile(): { displayName: string; avatarDataUrl: string |
export function ProfilePersonalizationPanel() {
const t = useT();
const displayName = useUserProfileStore((s) => s.displayName);
const nickname = useUserProfileStore((s) => s.nickname);
const avatarDataUrl = useUserProfileStore((s) => s.avatarDataUrl);
const setDisplayName = useUserProfileStore((s) => s.setDisplayName);
const setNickname = useUserProfileStore((s) => s.setNickname);
const setAvatarDataUrl = useUserProfileStore((s) => s.setAvatarDataUrl);
const avatarShape = useUserProfileStore((s) => s.avatarShape);
const setAvatarShape = useUserProfileStore((s) => s.setAvatarShape);
const [imageError, setImageError] = useState<string | null>(null);
const [draftName, setDraftName] = useState(displayName);
const [draftNickname, setDraftNickname] = useState(nickname);
const fileInputRef = useRef<HTMLInputElement>(null);
const sessionSub = decodeJwtSubject(getAuthToken()) ?? "";
@ -55,6 +72,10 @@ export function ProfilePersonalizationPanel() {
() => draftName.trim() !== displayName.trim(),
[draftName, displayName],
);
const hasNicknameChanges = useMemo(
() => draftNickname.trim() !== nickname.trim(),
[draftNickname, nickname],
);
const saveName = () => {
const trimmed = draftName.trim();
@ -73,21 +94,42 @@ export function ProfilePersonalizationPanel() {
}
};
const saveNickname = () => {
const trimmed = draftNickname.trim();
if (trimmed !== draftNickname) setDraftNickname(trimmed);
if (trimmed !== nickname) {
setNickname(trimmed);
const persisted = readPersistedProfile();
if (persisted && persisted.nickname === trimmed) {
toastSuccess(t("settings.profile.nicknameSaved"));
} else {
toastError(
t("settings.profile.namePersistErrorTitle"),
t("settings.profile.namePersistErrorDescription"),
);
}
}
};
// Persist an avatar value (data URL or asset URL) and toast the result.
const applyAvatar = (value: string) => {
setAvatarDataUrl(value);
const persisted = readPersistedProfile();
if (persisted && persisted.avatarDataUrl === value) {
toastSuccess(t("settings.profile.photoUpdated"));
} else {
toastError(
t("settings.profile.photoPersistErrorTitle"),
t("settings.profile.photoPersistErrorDescription"),
);
}
};
const onPickFile = async (file: File | undefined) => {
if (!file) return;
setImageError(null);
try {
const dataUrl = await resizeImageFileToDataUrl(file);
setAvatarDataUrl(dataUrl);
const persisted = readPersistedProfile();
if (persisted && persisted.avatarDataUrl === dataUrl) {
toastSuccess(t("settings.profile.photoUpdated"));
} else {
toastError(
t("settings.profile.photoPersistErrorTitle"),
t("settings.profile.photoPersistErrorDescription"),
);
}
applyAvatar(await resizeImageFileToDataUrl(file));
} catch (e) {
const message =
e instanceof Error ? e.message : t("settings.profile.imageUseError");
@ -96,6 +138,12 @@ export function ProfilePersonalizationPanel() {
}
};
// Use a bundled sloth sticker as the avatar.
const pickSloth = (path: string) => {
setImageError(null);
applyAvatar(publicAssetUrl(path));
};
return (
<div className="mx-auto flex w-full max-w-[640px] flex-col items-center gap-6 rounded-2xl border border-border/70 bg-muted/10 px-8 py-7">
<div className="relative">
@ -151,6 +199,90 @@ export function ProfilePersonalizationPanel() {
</div>
</div>
<div className="flex w-full max-w-[560px] flex-col gap-2">
<Label htmlFor="profile-nickname" className="text-xs font-medium text-muted-foreground">
{t("settings.profile.nickname")}
</Label>
<div className="flex items-center gap-2">
<Input
id="profile-nickname"
type="text"
value={draftNickname}
onChange={(e) => setDraftNickname(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
saveNickname();
}
}}
autoComplete="off"
placeholder={t("settings.profile.nicknamePlaceholder")}
className="h-10 min-w-0 flex-1 rounded-full text-sm"
/>
<Button type="button" size="sm" className="h-10 px-5" onClick={saveNickname} disabled={!hasNicknameChanges}>
{t("common.save")}
</Button>
</div>
</div>
<div className="flex w-full max-w-[560px] flex-col gap-2">
<Label className="text-xs font-medium text-muted-foreground">
{t("settings.profile.avatarShape")}
</Label>
<div className="inline-flex w-fit items-center gap-1 rounded-full border border-border/70 bg-muted/40 p-1">
{(["circle", "rounded"] as const).map((shape) => (
<button
key={shape}
type="button"
onClick={() => setAvatarShape(shape)}
aria-pressed={avatarShape === shape}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-medium transition-colors",
avatarShape === shape
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{shape === "circle"
? t("settings.profile.avatarShapeCircle")
: t("settings.profile.avatarShapeRounded")}
</button>
))}
</div>
</div>
<div className="flex w-full max-w-[560px] flex-col gap-2">
<Label className="text-xs font-medium text-muted-foreground">
{t("settings.profile.chooseSloth")}
</Label>
<div className="grid grid-cols-7 gap-2 sm:grid-cols-9">
{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 (
<button
key={path}
type="button"
onClick={() => pickSloth(path)}
aria-pressed={selected}
aria-label={label}
title={label}
className={cn(
"relative aspect-square overflow-hidden rounded-full bg-muted ring-1 ring-border transition hover:ring-primary/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selected && "ring-2 ring-primary",
)}
>
<img src={url} alt="" loading="lazy" className="size-full object-cover" />
</button>
);
})}
</div>
</div>
{imageError ? (
<p className="w-full text-xs text-destructive" role="alert">
{imageError}

View file

@ -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<AvatarShape, string> = {
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 (
<span className={cn("relative inline-flex shrink-0 overflow-hidden rounded-full", SIZE[size], className)}>
<span className={cn("relative inline-flex shrink-0 overflow-hidden bg-transparent", shapeClass, SIZE[size], className)}>
<img src={imageUrl} alt="" className="size-full object-cover" />
</span>
);
@ -33,7 +47,8 @@ export function UserAvatar({ name, imageUrl, size, className }: UserAvatarProps)
<span
style={avatarBgStyle()}
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full font-semibold text-white",
"inline-flex shrink-0 items-center justify-center font-semibold text-white",
shapeClass,
SIZE[size],
className,
)}

View file

@ -7,13 +7,17 @@ import { useUserProfileStore } from "../stores/user-profile-store";
export function useEffectiveProfile() {
const displayName = useUserProfileStore((s) => 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,
};
}

View file

@ -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 <img> 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",
];

View file

@ -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<UserProfileState>()(
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" },
),

View file

@ -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<string> {
const img = await loadImage(file);
@ -57,24 +87,24 @@ export async function resizeImageFileToDataUrl(file: File): Promise<string> {
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.");

View file

@ -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<string, string | undefined>;
return search.thread ?? search.compare ?? storeThreadId ?? undefined;
},
});
const confirmDeleteChats = useChatPreferencesStore(
(s) => s.confirmDeleteChats,
);
const [confirmingDelete, setConfirmingDelete] = useState<SidebarItem | null>(
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Archived chats</DialogTitle>
</DialogHeader>
{archivedItems.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No archived chats.
</p>
) : (
<div className="max-h-[60vh] overflow-y-auto">
<div className="flex items-center gap-4 border-b border-border/60 px-1 pb-2 text-xs font-semibold text-foreground">
<span className="flex-1">Name</span>
<span className="w-32 shrink-0">Date created</span>
<span className="w-16 shrink-0" />
</div>
{archivedItems.map((item) => (
<div
key={item.id}
className="group flex items-center gap-4 border-b border-border/40 px-1 py-2.5 text-sm last:border-0"
>
<button
type="button"
onClick={() => openChat(item)}
className="min-w-0 flex-1 truncate text-left text-primary hover:underline"
title={item.title}
>
{item.title}
</button>
<span className="w-32 shrink-0 text-muted-foreground tabular-nums">
{formatCreatedAt(item.createdAt)}
</span>
<span className="flex w-16 shrink-0 items-center justify-end gap-1">
<button
type="button"
onClick={() => void handleUnarchive(item)}
aria-label="Unarchive chat"
title="Unarchive"
className="inline-flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<HugeiconsIcon
icon={ArchiveRestoreIcon}
strokeWidth={1.75}
className="size-4"
/>
</button>
<button
type="button"
onClick={() => requestDelete(item)}
aria-label="Delete chat"
title="Delete"
className="inline-flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<HugeiconsIcon
icon={Delete02Icon}
strokeWidth={1.75}
className="size-4"
/>
</button>
</span>
</div>
))}
</div>
)}
</DialogContent>
<AlertDialog
open={confirmingDelete !== null}
onOpenChange={(o) => {
if (!o) setConfirmingDelete(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete chat</AlertDialogTitle>
<AlertDialogDescription>
Delete{" "}
<span className="font-medium text-foreground">
&quot;{confirmingDelete?.title}&quot;
</span>
? This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
const item = confirmingDelete;
setConfirmingDelete(null);
if (item) void handleDelete(item);
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}

View file

@ -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<string, unknown>;
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 (
<SettingsSection title="Unsloth">
<SettingsRow label={t("settings.about.studioVersion")}>
<code className="font-mono text-xs text-muted-foreground">
{studioVersion}
</code>
</SettingsRow>
<SettingsRow label={t("settings.about.packageVersion")}>
<code className="font-mono text-xs text-muted-foreground">
{packageVersion}
</code>
</SettingsRow>
</SettingsSection>
);
}

View file

@ -20,11 +20,24 @@ interface SettingsDialogState {
// previous-focus capture, leaving focus on <body> 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<SettingsDialogState>((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.

View file

@ -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<UpdateInstallSource> {
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() {
</p>
</header>
<SettingsSection title="Unsloth">
<SettingsRow label={t("settings.about.studioVersion")}>
<code className="font-mono text-xs text-muted-foreground">
{studioVersion}
</code>
</SettingsRow>
<SettingsRow label={t("settings.about.packageVersion")}>
<code className="font-mono text-xs text-muted-foreground">
{packageVersion}
</code>
</SettingsRow>
</SettingsSection>
<StudioVersionSection />
<SettingsSection title={t("settings.about.updates")}>
<div className="py-2">

View file

@ -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<number | null>(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() {
</SettingsSection>
<SettingsSection title={t("settings.chat.data")}>
<SettingsRow
label="Archived chats"
description="View and manage chats you have archived."
>
<Button
variant="outline"
size="sm"
onClick={() => setArchivedOpen(true)}
>
Manage
</Button>
</SettingsRow>
<SettingsRow
label="Confirm before deleting"
description="Ask for confirmation before a chat is deleted. Turn off to delete instantly."
>
<Switch
checked={confirmDeleteChats}
onCheckedChange={setConfirmDeleteChats}
/>
</SettingsRow>
<SettingsRow
label={t("settings.chat.exportHistory")}
description={t("settings.chat.exportHistoryDescription")}
@ -405,6 +451,8 @@ export function ChatTab() {
<SettingsRow
destructive
// divide-y already draws the row separator; drop the extra border.
className="border-t-0 mt-0 pt-3"
label={t("settings.chat.clearAllChats")}
description={
count === null
@ -429,6 +477,8 @@ export function ChatTab() {
</SettingsRow>
</SettingsSection>
<ArchivedChatsDialog open={archivedOpen} onOpenChange={setArchivedOpen} />
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="max-w-md">
<DialogHeader>

View file

@ -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() {
</p>
</header>
<StudioVersionSection />
<SettingsSection title={t("settings.general.account")}>
<SettingsRow
label={t("settings.general.huggingFaceToken")}

View file

@ -68,6 +68,11 @@ async function fetchStatus(
}
}
// Update probes force a refresh so a newly published build is not masked by the
// backend's 24h release cache (the banner would otherwise lag up to a day). The
// job-progress poll below stays cached; it only reads local job state.
const recheckStatus = () => fetchStatus(true);
interface UseLlamaUpdateCheckOptions {
enabled?: boolean;
}
@ -161,13 +166,13 @@ export function useLlamaUpdateCheck({
let canceled = false;
const firstTimer = setTimeout(() => {
fetchStatus().then((s) => {
recheckStatus().then((s) => {
if (!canceled) surfaceIfAvailable(s);
});
}, FIRST_CHECK_DELAY_MS);
const reminder = setInterval(() => {
fetchStatus().then((s) => {
recheckStatus().then((s) => {
if (!canceled) surfaceIfAvailable(s);
});
}, REMINDER_INTERVAL_MS);
@ -194,7 +199,7 @@ export function useLlamaUpdateCheck({
if (snoozeTimer.current) clearTimeout(snoozeTimer.current);
snoozeTimer.current = setTimeout(() => {
snoozeTimer.current = null;
fetchStatus().then(surfaceIfAvailable);
recheckStatus().then(surfaceIfAvailable);
}, SNOOZE_DELAY_MS);
}, [surfaceIfAvailable]);

View file

@ -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:

View file

@ -139,6 +139,13 @@ export const zhCN = {
description: "更新你在 Unsloth 中显示的个人资料。",
changePicture: "更换头像",
displayName: "显示名称",
nickname: "Unsloth 应该怎么称呼你?",
nicknamePlaceholder: "昵称",
nicknameSaved: "称呼名称已保存",
avatarShape: "头像形状",
avatarShapeCircle: "圆形",
avatarShapeRounded: "圆角矩形",
chooseSloth: "或选择一只树懒",
nameSaved: "个人资料名称已保存",
namePersistErrorTitle: "无法持久保存个人资料名称",
namePersistErrorDescription:

View file

@ -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;

View file

@ -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",
},
],
];

View file

@ -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:

View file

@ -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"

View file

@ -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"]))

View file

@ -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
# ===========================================================================

View file

@ -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"

View file

@ -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')
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')

View file

@ -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",