Merge remote-tracking branch 'origin/main' into pr-5748-head
This commit is contained in:
commit
dec240ade7
74 changed files with 1879 additions and 530 deletions
14
install.ps1
14
install.ps1
|
|
@ -799,9 +799,11 @@ exit 0
|
|||
# even when install.ps1 is executed from PowerShell 7.
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($launcherPs1, $launcherContent, $utf8Bom)
|
||||
# shell.Run(cmd, 0, ...) already hides the window, so -WindowStyle Hidden
|
||||
# is redundant; omitting it trims an AV-heuristic token (Kaspersky FP).
|
||||
$vbsContent = @"
|
||||
Set shell = CreateObject("WScript.Shell")
|
||||
cmd = "powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""$launcherPs1"""
|
||||
cmd = "powershell -NoProfile -ExecutionPolicy Bypass -File ""$launcherPs1"""
|
||||
shell.Run cmd, 0, False
|
||||
"@
|
||||
# WSH handles UTF-16LE reliably for .vbs files with non-ASCII paths.
|
||||
|
|
@ -1926,7 +1928,7 @@ shell.Run cmd, 0, False
|
|||
if ($SkipTorch) {
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.5" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.7" unsloth-zoo }
|
||||
if ($baseInstallExit -eq 0) {
|
||||
# Resolve pydantic WITH deps so pip pins pydantic-core
|
||||
# to the matching version (no-torch-runtime.txt below
|
||||
|
|
@ -1940,7 +1942,7 @@ shell.Run cmd, 0, False
|
|||
}
|
||||
}
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.5" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.7" unsloth-zoo }
|
||||
}
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
|
|
@ -1987,7 +1989,7 @@ shell.Run cmd, 0, False
|
|||
if ($SkipTorch) {
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.5" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.7" unsloth-zoo }
|
||||
if ($baseInstallExit -eq 0) {
|
||||
# Same pydantic-with-deps trick as the migrated branch.
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython pydantic }
|
||||
|
|
@ -1999,7 +2001,7 @@ shell.Run cmd, 0, False
|
|||
}
|
||||
}
|
||||
} elseif ($StudioLocalInstall) {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.5" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.7" unsloth-zoo }
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
|
||||
}
|
||||
|
|
@ -2027,7 +2029,7 @@ shell.Run cmd, 0, False
|
|||
Write-TauriLog "STEP" "Installing unsloth"
|
||||
substep "installing unsloth (this may take a few minutes)..."
|
||||
if ($StudioLocalInstall) {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.6.5" --torch-backend=auto }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.6.7" --torch-backend=auto }
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
||||
|
|
|
|||
10
install.sh
10
install.sh
|
|
@ -2432,7 +2432,7 @@ if [ "$_MIGRATED" = true ]; then
|
|||
# to prevent transitive torch resolution.
|
||||
run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
|
||||
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
||||
"unsloth>=2026.6.5" unsloth-zoo
|
||||
"unsloth>=2026.6.7" unsloth-zoo
|
||||
# Resolve pydantic WITH deps so pip pins pydantic-core to the
|
||||
# matching version (no-torch-runtime.txt below is --no-deps).
|
||||
# All transitive deps are torch-free.
|
||||
|
|
@ -2445,7 +2445,7 @@ if [ "$_MIGRATED" = true ]; then
|
|||
else
|
||||
run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
|
||||
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
||||
"unsloth>=2026.6.5" unsloth-zoo
|
||||
"unsloth>=2026.6.7" unsloth-zoo
|
||||
fi
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
substep "overlaying local repo (editable)..."
|
||||
|
|
@ -2649,7 +2649,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
|
||||
--upgrade-package unsloth --upgrade-package unsloth-zoo \
|
||||
"unsloth>=2026.6.5" unsloth-zoo
|
||||
"unsloth>=2026.6.7" unsloth-zoo
|
||||
# Same pydantic-with-deps trick as the migrated branch.
|
||||
run_install_cmd "install pydantic (with deps for compatible core)" \
|
||||
uv pip install --python "$_VENV_PY" pydantic
|
||||
|
|
@ -2667,7 +2667,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
|
|||
fi
|
||||
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \
|
||||
--upgrade-package unsloth "unsloth>=2026.6.5" unsloth-zoo
|
||||
--upgrade-package unsloth "unsloth>=2026.6.7" unsloth-zoo
|
||||
substep "overlaying local repo (editable)..."
|
||||
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
||||
substep "overlaying unsloth-zoo from git main..."
|
||||
|
|
@ -2699,7 +2699,7 @@ else
|
|||
tauri_log "STEP" "Installing Unsloth"
|
||||
substep "installing unsloth (this may take a few minutes)..."
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
||||
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.6.5" --torch-backend=auto
|
||||
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.6.7" --torch-backend=auto
|
||||
substep "overlaying local repo (editable)..."
|
||||
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
||||
substep "overlaying unsloth-zoo from git main..."
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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, "
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
65
studio/backend/tests/test_default_output_dir_name.py
Normal file
65
studio/backend/tests/test_default_output_dir_name.py
Normal 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)
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
||||
|
||||
# =====================================================================
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -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 : [],
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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]")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
36
studio/frontend/src/features/profile/sloth-avatars.ts
Normal file
36
studio/frontend/src/features/profile/sloth-avatars.ts
Normal 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",
|
||||
];
|
||||
|
|
@ -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" },
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
"{confirmingDelete?.title}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -139,6 +139,13 @@ export const zhCN = {
|
|||
description: "更新你在 Unsloth 中显示的个人资料。",
|
||||
changePicture: "更换头像",
|
||||
displayName: "显示名称",
|
||||
nickname: "Unsloth 应该怎么称呼你?",
|
||||
nicknamePlaceholder: "昵称",
|
||||
nicknameSaved: "称呼名称已保存",
|
||||
avatarShape: "头像形状",
|
||||
avatarShapeCircle: "圆形",
|
||||
avatarShapeRounded: "圆角矩形",
|
||||
chooseSloth: "或选择一只树懒",
|
||||
nameSaved: "个人资料名称已保存",
|
||||
namePersistErrorTitle: "无法持久保存个人资料名称",
|
||||
namePersistErrorDescription:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
34
studio/frontend/src/lib/chevron-icons.ts
Normal file
34
studio/frontend/src/lib/chevron-icons.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
130
studio/setup.sh
130
studio/setup.sh
|
|
@ -386,6 +386,70 @@ if [[ "$keynames" == *$'\nCOLAB_'* ]]; then
|
|||
IS_COLAB=true
|
||||
fi
|
||||
|
||||
# Resolve studio home + ownership marker before the llama-only split: the
|
||||
# llama.cpp section needs STUDIO_HOME / _STUDIO_HOME_IS_CUSTOM, but
|
||||
# UNSLOTH_STUDIO_LLAMA_ONLY=1 ('unsloth studio update') skips the base install.
|
||||
# UNSLOTH_STUDIO_HOME (or STUDIO_HOME alias) overrides the install root
|
||||
# (mirrors install.sh). UNSLOTH_STUDIO_HOME wins when both are set.
|
||||
_studio_override_var=""
|
||||
_studio_override="${UNSLOTH_STUDIO_HOME:-}"
|
||||
if [ -n "$_studio_override" ]; then
|
||||
_studio_override_var="UNSLOTH_STUDIO_HOME"
|
||||
else
|
||||
_studio_override="${STUDIO_HOME:-}"
|
||||
[ -n "$_studio_override" ] && _studio_override_var="STUDIO_HOME"
|
||||
fi
|
||||
# Strip whitespace so " " is treated as unset (matches Python .strip()).
|
||||
_studio_override=$(printf '%s' "$_studio_override" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||
case "$_studio_override" in
|
||||
"~") _studio_override="$HOME" ;;
|
||||
"~/"*) _studio_override="$HOME/${_studio_override#'~/'}" ;;
|
||||
esac
|
||||
if [ -n "$_studio_override" ]; then
|
||||
# setup.sh runs against an existing install (via 'unsloth studio update');
|
||||
# a typo in the override must fail fast instead of materializing an
|
||||
# empty workspace dir. Mirrors setup.ps1 behavior.
|
||||
if [ ! -d "$_studio_override" ]; then
|
||||
echo "ERROR: $_studio_override_var=$_studio_override does not exist." >&2
|
||||
echo " Run install.sh to create the install root before 'unsloth studio update'." >&2
|
||||
exit 1
|
||||
fi
|
||||
[ -w "$_studio_override" ] || { echo "ERROR: $_studio_override_var=$_studio_override is not writable." >&2; exit 1; }
|
||||
STUDIO_HOME="$(CDPATH= cd -P -- "$_studio_override" && pwd -P)" || exit 1
|
||||
else
|
||||
STUDIO_HOME="$HOME/.unsloth/studio"
|
||||
fi
|
||||
VENV_DIR="$STUDIO_HOME/unsloth_studio"
|
||||
VENV_T5_530_DIR="$STUDIO_HOME/.venv_t5_530"
|
||||
VENV_T5_550_DIR="$STUDIO_HOME/.venv_t5_550"
|
||||
VENV_T5_510_DIR="$STUDIO_HOME/.venv_t5_510"
|
||||
|
||||
_STUDIO_OWNED_MARKER=".unsloth-studio-owned"
|
||||
_LEGACY_STUDIO_HOME="$HOME/.unsloth/studio"
|
||||
_studio_home_canon="$STUDIO_HOME"
|
||||
if [ -d "$_studio_home_canon" ]; then
|
||||
_studio_home_canon=$(CDPATH= cd -P -- "$_studio_home_canon" 2>/dev/null && pwd -P) \
|
||||
|| _studio_home_canon="$STUDIO_HOME"
|
||||
fi
|
||||
if [ -d "$_LEGACY_STUDIO_HOME" ]; then
|
||||
_LEGACY_STUDIO_HOME=$(CDPATH= cd -P -- "$_LEGACY_STUDIO_HOME" 2>/dev/null && pwd -P) \
|
||||
|| _LEGACY_STUDIO_HOME="$HOME/.unsloth/studio"
|
||||
fi
|
||||
_STUDIO_HOME_IS_CUSTOM=false
|
||||
if [ "$_studio_home_canon" != "$_LEGACY_STUDIO_HOME" ]; then
|
||||
_STUDIO_HOME_IS_CUSTOM=true
|
||||
fi
|
||||
_assert_studio_owned_or_absent() {
|
||||
_aso_dir="$1"
|
||||
_aso_label="$2"
|
||||
[ -d "$_aso_dir" ] || return 0
|
||||
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ] && [ ! -f "$_aso_dir/$_STUDIO_OWNED_MARKER" ]; then
|
||||
echo "ERROR: $_aso_dir already exists and is not marked as a Studio-owned $_aso_label." >&2
|
||||
echo " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$_LLAMA_ONLY" != "1" ]; then
|
||||
# ── Detect whether frontend needs building ──
|
||||
# Skip if SKIP_STUDIO_FRONTEND=1 (Tauri desktop app bundles its own frontend),
|
||||
|
|
@ -605,40 +669,6 @@ if [ -d "$SCRIPT_DIR/backend/core/data_recipe/oxc-validator" ] && command -v npm
|
|||
fi
|
||||
|
||||
# ── Python venv + deps ──
|
||||
# UNSLOTH_STUDIO_HOME (or STUDIO_HOME alias) overrides the install root
|
||||
# (mirrors install.sh). UNSLOTH_STUDIO_HOME wins when both are set.
|
||||
_studio_override_var=""
|
||||
_studio_override="${UNSLOTH_STUDIO_HOME:-}"
|
||||
if [ -n "$_studio_override" ]; then
|
||||
_studio_override_var="UNSLOTH_STUDIO_HOME"
|
||||
else
|
||||
_studio_override="${STUDIO_HOME:-}"
|
||||
[ -n "$_studio_override" ] && _studio_override_var="STUDIO_HOME"
|
||||
fi
|
||||
# Strip whitespace so " " is treated as unset (matches Python .strip()).
|
||||
_studio_override=$(printf '%s' "$_studio_override" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||
case "$_studio_override" in
|
||||
"~") _studio_override="$HOME" ;;
|
||||
"~/"*) _studio_override="$HOME/${_studio_override#'~/'}" ;;
|
||||
esac
|
||||
if [ -n "$_studio_override" ]; then
|
||||
# setup.sh runs against an existing install (via 'unsloth studio update');
|
||||
# a typo in the override must fail fast instead of materializing an
|
||||
# empty workspace dir. Mirrors setup.ps1 behavior.
|
||||
if [ ! -d "$_studio_override" ]; then
|
||||
echo "ERROR: $_studio_override_var=$_studio_override does not exist." >&2
|
||||
echo " Run install.sh to create the install root before 'unsloth studio update'." >&2
|
||||
exit 1
|
||||
fi
|
||||
[ -w "$_studio_override" ] || { echo "ERROR: $_studio_override_var=$_studio_override is not writable." >&2; exit 1; }
|
||||
STUDIO_HOME="$(CDPATH= cd -P -- "$_studio_override" && pwd -P)" || exit 1
|
||||
else
|
||||
STUDIO_HOME="$HOME/.unsloth/studio"
|
||||
fi
|
||||
VENV_DIR="$STUDIO_HOME/unsloth_studio"
|
||||
VENV_T5_530_DIR="$STUDIO_HOME/.venv_t5_530"
|
||||
VENV_T5_550_DIR="$STUDIO_HOME/.venv_t5_550"
|
||||
VENV_T5_510_DIR="$STUDIO_HOME/.venv_t5_510"
|
||||
|
||||
[ -d "$REPO_ROOT/.venv" ] && rm -rf "$REPO_ROOT/.venv"
|
||||
[ -d "$REPO_ROOT/.venv_overlay" ] && rm -rf "$REPO_ROOT/.venv_overlay"
|
||||
|
|
@ -757,38 +787,6 @@ fi
|
|||
# Gemma 4 models need transformers>=5.5.0; Gemma 4 Unified needs 5.10.x.
|
||||
# Pre-install into separate directories to avoid runtime pip overhead.
|
||||
# The training subprocess prepends the appropriate dir to sys.path.
|
||||
#
|
||||
# Runs outside the _SKIP_PYTHON_DEPS gate so that upgrades from legacy
|
||||
# single .venv_t5 are always migrated to the tiered layout.
|
||||
# why: in env-override mode $STUDIO_HOME is user-chosen; require the
|
||||
# ownership marker before rm -rf so unrelated dirs survive. Gated on the
|
||||
# canonical comparison so an override pointing at the legacy default still
|
||||
# behaves like a default install.
|
||||
_STUDIO_OWNED_MARKER=".unsloth-studio-owned"
|
||||
_LEGACY_STUDIO_HOME="$HOME/.unsloth/studio"
|
||||
_studio_home_canon="$STUDIO_HOME"
|
||||
if [ -d "$_studio_home_canon" ]; then
|
||||
_studio_home_canon=$(CDPATH= cd -P -- "$_studio_home_canon" 2>/dev/null && pwd -P) \
|
||||
|| _studio_home_canon="$STUDIO_HOME"
|
||||
fi
|
||||
if [ -d "$_LEGACY_STUDIO_HOME" ]; then
|
||||
_LEGACY_STUDIO_HOME=$(CDPATH= cd -P -- "$_LEGACY_STUDIO_HOME" 2>/dev/null && pwd -P) \
|
||||
|| _LEGACY_STUDIO_HOME="$HOME/.unsloth/studio"
|
||||
fi
|
||||
_STUDIO_HOME_IS_CUSTOM=false
|
||||
if [ "$_studio_home_canon" != "$_LEGACY_STUDIO_HOME" ]; then
|
||||
_STUDIO_HOME_IS_CUSTOM=true
|
||||
fi
|
||||
_assert_studio_owned_or_absent() {
|
||||
_aso_dir="$1"
|
||||
_aso_label="$2"
|
||||
[ -d "$_aso_dir" ] || return 0
|
||||
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ] && [ ! -f "$_aso_dir/$_STUDIO_OWNED_MARKER" ]; then
|
||||
echo "ERROR: $_aso_dir already exists and is not marked as a Studio-owned $_aso_label." >&2
|
||||
echo " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
_target_has_pkg_version() {
|
||||
_thpv_dir="$1"
|
||||
_thpv_pkg="$2"
|
||||
|
|
|
|||
50
tests/studio/install/test_launch_studio_launcher.py
Normal file
50
tests/studio/install/test_launch_studio_launcher.py
Normal 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"]))
|
||||
|
|
@ -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
|
||||
# ===========================================================================
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue