Merge remote-tracking branch 'origin/main' into woa-nvidia-wsl-fallback
This commit is contained in:
commit
0a2b94de71
36 changed files with 1816 additions and 150 deletions
|
|
@ -657,11 +657,32 @@ def detect_reasoning_flags(
|
|||
return flags
|
||||
|
||||
|
||||
# Gemma 4 ships MTP as a separate drafter (no "-mtp" in the name). Gemma 3n
|
||||
# ships no drafter, so it is excluded -- it takes the normal non-MTP path.
|
||||
_GEMMA_MTP_FAMILY_RE = re.compile(r"gemma[-_]?4[-_]", re.IGNORECASE)
|
||||
|
||||
|
||||
def _is_gemma_mtp_family(name: Optional[str]) -> bool:
|
||||
"""Match Gemma 4 by name."""
|
||||
return bool(name) and bool(_GEMMA_MTP_FAMILY_RE.search(name))
|
||||
|
||||
|
||||
def _is_gemma_mtp_name(model_identifier: Optional[str], gguf_path: Optional[str] = None) -> bool:
|
||||
"""Match Gemma 4 by id or GGUF filename."""
|
||||
return _is_gemma_mtp_family(model_identifier) or _is_gemma_mtp_family(
|
||||
Path(gguf_path).name if gguf_path else None
|
||||
)
|
||||
|
||||
|
||||
def _is_mtp_model_name(model_identifier: Optional[str], gguf_path: Optional[str] = None) -> bool:
|
||||
"""Name-based MTP detector. Fallback for the metadata signal."""
|
||||
for cand in (model_identifier, Path(gguf_path).name if gguf_path else None):
|
||||
if cand and "-mtp" in cand.lower():
|
||||
return True
|
||||
# Recognise Gemma 4 too, so a failed drafter download surfaces a
|
||||
# fallback reason instead of silently defaulting.
|
||||
if cand and _is_gemma_mtp_family(cand):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -851,6 +872,23 @@ def _auto_mode_drops_mtp(
|
|||
return req_mode == "auto" and size_b is not None and size_b < _MTP_MIN_SIZE_B
|
||||
|
||||
|
||||
def _mla_mtp_auto_enabled() -> bool:
|
||||
"""Whether Auto may pick embedded MTP for an MLA model (GLM-5.2/DeepSeek/Kimi).
|
||||
|
||||
Off by default: llama.cpp's MLA/DSA MTP path keeps a duplicated full target-KV
|
||||
context and recomputes the sparse-attention indexer every draft step, so it runs
|
||||
~2x slower than no speculation (GLM-5.2 bench: 27 vs 45 tok/s, flat across draft
|
||||
depth and 96-100% acceptance) -- the opposite of the vLLM/SGLang speedup on the
|
||||
same model. Set UNSLOTH_MLA_MTP_ENABLED=1 to let Auto promote MLA MTP again once
|
||||
that path is optimized upstream. Forced mtp / mtp+ngram ignore this gate."""
|
||||
return os.environ.get("UNSLOTH_MLA_MTP_ENABLED", "0").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
|
||||
|
||||
def _extra_args_set_spec_type(extra_args: Optional[Iterable[str]]) -> bool:
|
||||
"""User passed --spec-type / --spec-default? llama-server takes one
|
||||
--spec-type (comma-separated to chain), so suppress auto-emit."""
|
||||
|
|
@ -1357,6 +1395,11 @@ class LlamaCppBackend:
|
|||
def gguf_path(self) -> Optional[str]:
|
||||
return self._gguf_path
|
||||
|
||||
@property
|
||||
def hf_repo(self) -> Optional[str]:
|
||||
"""HF repo of the loaded model, or None for local/native file loads."""
|
||||
return self._hf_repo
|
||||
|
||||
@property
|
||||
def mtp_draft_path(self) -> Optional[str]:
|
||||
return self._mtp_draft_path
|
||||
|
|
@ -2823,12 +2866,16 @@ class LlamaCppBackend:
|
|||
drafter_path: Optional[str] = None,
|
||||
draft_weights_bytes: int = 0,
|
||||
n_parallel: int = 1,
|
||||
mtp_keeps_target_ctx: bool = True,
|
||||
) -> Optional[int]:
|
||||
"""MTP draft reserve at ``n_ctx`` = draft KV (grows with ctx) + separate-
|
||||
drafter weights + (MLA only) a duplicated target KV context. The verify
|
||||
buffer rides in the ctx-fit headroom (no tuned constant). None when the
|
||||
draft KV can't be sized (caller keeps the flat fallback).
|
||||
``draft_weights_bytes`` is the drafter file size (0 for embedded)."""
|
||||
drafter weights + (MTP + MLA only) a duplicated target KV context. The
|
||||
verify buffer rides in the ctx-fit headroom (no tuned constant). None when
|
||||
the draft KV can't be sized (caller keeps the flat fallback).
|
||||
``draft_weights_bytes`` is the drafter file size (0 for embedded).
|
||||
``mtp_keeps_target_ctx`` is True for MTP draft modes (which keep the
|
||||
duplicated target context) and False for separate-drafter spec modes
|
||||
(draft-simple/draft-eagle3), which do not."""
|
||||
draft_kv = self._mtp_draft_kv_bytes(
|
||||
n_ctx,
|
||||
drafter_path = drafter_path,
|
||||
|
|
@ -2837,16 +2884,19 @@ class LlamaCppBackend:
|
|||
n_parallel = n_parallel,
|
||||
)
|
||||
weights = max(0, draft_weights_bytes)
|
||||
# MLA models (GLM-5.x, DeepSeek, Kimi-K2) keep a *second* full copy of the
|
||||
# target model's KV context for MTP draft verification -- llama.cpp's
|
||||
# MLA models (GLM-5.x, DeepSeek, Kimi-K2) under MTP keep a *second* full copy
|
||||
# of the target model's KV context for draft verification -- llama.cpp's
|
||||
# `ctx_tgt=yes` -- allocated at f16 regardless of the main cache type. It is
|
||||
# ~the main KV again and dwarfs the embedded draft head (GLM-5.2 @ 1M ctx:
|
||||
# a ~2 GiB head next to a ~89 GiB target copy), so omitting it lets auto-fit
|
||||
# pick a context that fits on paper but OOMs cublasCreate at the first
|
||||
# decode. Non-MLA MTP (Qwen/Gemma) keeps no such copy, so this is gated
|
||||
# strictly on MLA (kv_lora_rank present) and leaves those models unchanged.
|
||||
# decode. Gated on both MLA (kv_lora_rank present) and the engaged mode
|
||||
# actually being MTP: non-MLA MTP (Qwen/Gemma) keeps no such copy, and the
|
||||
# separate-drafter spec modes (draft-simple/draft-eagle3) load a small
|
||||
# distinct drafter with its own KV -- already counted in draft_kv/weights --
|
||||
# rather than duplicating the target, so they must not be charged for it.
|
||||
target_ctx_copy = 0
|
||||
if self._kv_lora_rank is not None:
|
||||
if mtp_keeps_target_ctx and self._kv_lora_rank is not None:
|
||||
target_ctx_copy = self._estimate_kv_cache_bytes(n_ctx, "f16", n_parallel = n_parallel)
|
||||
if draft_kv is None:
|
||||
# KV unsized (exotic/remote drafter): still reserve known weights + any
|
||||
|
|
@ -3789,11 +3839,31 @@ class LlamaCppBackend:
|
|||
return None
|
||||
|
||||
target: Optional[str] = None
|
||||
try:
|
||||
from huggingface_hub import list_repo_files
|
||||
target = pick(list_repo_files(hf_repo, token = hf_token))
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not list repo files for {label}: {e}")
|
||||
from huggingface_hub import list_repo_files
|
||||
|
||||
# Retry a transient listing blip; permanent repo/auth errors and offline
|
||||
# mode are not retried (offline raises at once -> fall through to cache).
|
||||
for attempt in range(3):
|
||||
if self._cancel_event.is_set():
|
||||
return None
|
||||
try:
|
||||
target = pick(list_repo_files(hf_repo, token = hf_token))
|
||||
break
|
||||
except Exception as e:
|
||||
if type(e).__name__ in (
|
||||
"RepositoryNotFoundError",
|
||||
"GatedRepoError",
|
||||
"RevisionNotFoundError",
|
||||
"EntryNotFoundError",
|
||||
"OfflineModeIsEnabled",
|
||||
):
|
||||
logger.debug(f"Could not list repo files for {label}: {e}")
|
||||
break
|
||||
logger.debug(
|
||||
f"Could not list repo files for {label} " f"(attempt {attempt + 1}/3): {e}"
|
||||
)
|
||||
if attempt < 2:
|
||||
self._cancel_event.wait(2**attempt)
|
||||
|
||||
if target is None:
|
||||
try:
|
||||
|
|
@ -4807,6 +4877,12 @@ class LlamaCppBackend:
|
|||
self._nextn_predict_layers
|
||||
or _is_mtp_model_name(model_identifier, model_path)
|
||||
or bool(mtp_draft_path)
|
||||
) and not (
|
||||
# Drafterless Gemma falls back to ngram-mod; reserve no
|
||||
# drafter VRAM for it (mirrors the launch resolver).
|
||||
_is_gemma_mtp_name(model_identifier, model_path)
|
||||
and not mtp_draft_path
|
||||
and not self._nextn_predict_layers
|
||||
)
|
||||
_mtp_binary_ok = True
|
||||
_mtp_probe_raised = False
|
||||
|
|
@ -4818,25 +4894,31 @@ class LlamaCppBackend:
|
|||
except Exception:
|
||||
_mtp_binary_ok = False
|
||||
_mtp_probe_raised = True
|
||||
_mtp_will_engage = bool(
|
||||
_user_mtp_via_extras
|
||||
or _user_draft_via_extras
|
||||
or (
|
||||
not _extra_args_set_spec_type(extra_args)
|
||||
and _mtp_model_for_fit
|
||||
and (
|
||||
_mtp_effective in ("mtp", "mtp+ngram")
|
||||
or (_mtp_effective == "auto" and not _mtp_sub_3b_for_fit)
|
||||
)
|
||||
and (
|
||||
_mtp_binary_ok
|
||||
# Reserve on a raised (uncached) probe too: it re-probes in
|
||||
# _build_speculative_flags and may still engage MTP (embedded
|
||||
# head or separate drafter -- _mtp_model_for_fit covers both).
|
||||
or _mtp_probe_raised
|
||||
)
|
||||
_auto_studio_mtp = (
|
||||
not _extra_args_set_spec_type(extra_args)
|
||||
and _mtp_model_for_fit
|
||||
and (
|
||||
_mtp_effective in ("mtp", "mtp+ngram")
|
||||
or (_mtp_effective == "auto" and not _mtp_sub_3b_for_fit)
|
||||
)
|
||||
and (
|
||||
_mtp_binary_ok
|
||||
# Reserve on a raised (uncached) probe too: it re-probes in
|
||||
# _build_speculative_flags and may still engage MTP (embedded
|
||||
# head or separate drafter -- _mtp_model_for_fit covers both).
|
||||
or _mtp_probe_raised
|
||||
)
|
||||
)
|
||||
_mtp_will_engage = bool(
|
||||
_user_mtp_via_extras or _user_draft_via_extras or _auto_studio_mtp
|
||||
)
|
||||
# The duplicated full target-KV copy (ctx_tgt) is an MTP-only
|
||||
# cost: the MTP head runs a second context over the target
|
||||
# model's own KV geometry. The separate-drafter spec modes
|
||||
# (draft-simple/draft-eagle3, reached via _user_draft_via_extras)
|
||||
# load a small distinct drafter with its own KV and keep no such
|
||||
# copy, so only charge it when the engaged mode is truly MTP.
|
||||
_engaged_is_mtp = bool(_user_mtp_via_extras or _auto_studio_mtp)
|
||||
|
||||
# Effective draft depth: extras win (last-wins at launch), else
|
||||
# the field, else the platform default (2 GPU / 3 CPU).
|
||||
|
|
@ -4905,6 +4987,7 @@ class LlamaCppBackend:
|
|||
drafter_path = _mtp_draft_for_budget,
|
||||
draft_weights_bytes = _mtp_draft_weights,
|
||||
n_parallel = n_parallel,
|
||||
mtp_keeps_target_ctx = _engaged_is_mtp,
|
||||
)
|
||||
is not None
|
||||
):
|
||||
|
|
@ -4920,6 +5003,7 @@ class LlamaCppBackend:
|
|||
_dp: Optional[str] = _mtp_draft_for_budget,
|
||||
_w: int = _mtp_draft_weights,
|
||||
_np: int = n_parallel,
|
||||
_mtp: bool = _engaged_is_mtp,
|
||||
) -> int:
|
||||
v = self._estimate_mtp_overhead_bytes(
|
||||
ctx,
|
||||
|
|
@ -4929,6 +5013,7 @@ class LlamaCppBackend:
|
|||
drafter_path = _dp,
|
||||
draft_weights_bytes = _w,
|
||||
n_parallel = _np,
|
||||
mtp_keeps_target_ctx = _mtp,
|
||||
)
|
||||
return v if v is not None else 0
|
||||
|
||||
|
|
@ -6169,6 +6254,24 @@ class LlamaCppBackend:
|
|||
_mtp_too_small = (
|
||||
_mtp_size_b is not None and _mtp_size_b < _MTP_MIN_SIZE_B and not bool(mtp_draft_path)
|
||||
)
|
||||
# Drafterless Gemma (name-only MTP, no embedded head): emitting MTP
|
||||
# would abort llama-server, so every mode below falls back instead.
|
||||
_mtp_drafter_missing = (
|
||||
_is_gemma_mtp_name(model_identifier, model_path)
|
||||
and not mtp_draft_path
|
||||
and not self._nextn_predict_layers
|
||||
)
|
||||
# Embedded MTP head on an MLA model (GLM-5.2/DeepSeek/Kimi, detected by
|
||||
# kv_lora_rank): llama.cpp's MLA/DSA MTP path is ~2x slower than no spec,
|
||||
# so Auto drops it (override via the Settings dropdown / forced mtp, or
|
||||
# UNSLOTH_MLA_MTP_ENABLED=1). Separate drafters (Gemma, mtp_draft_path) and
|
||||
# non-MLA embedded heads (Qwen, no kv_lora_rank) are unaffected.
|
||||
_auto_mla_embedded_mtp = (
|
||||
bool(self._nextn_predict_layers)
|
||||
and self._kv_lora_rank is not None
|
||||
and not bool(mtp_draft_path)
|
||||
and not _mla_mtp_auto_enabled()
|
||||
)
|
||||
|
||||
if user_owns_spec_type:
|
||||
# User --spec-type wins outright; suppress auto-emit to avoid a
|
||||
|
|
@ -6263,6 +6366,20 @@ class LlamaCppBackend:
|
|||
logger.info("Spec decoding: ngram-mod")
|
||||
return True
|
||||
|
||||
def _fallback_drafter_not_found() -> None:
|
||||
"""Drafterless Gemma: use ngram-mod (or spec-default) and record why."""
|
||||
logger.warning(
|
||||
"Model %s is MTP-capable but no drafter or head was found; "
|
||||
"falling back. Check network or run `unsloth studio update`.",
|
||||
model_identifier,
|
||||
)
|
||||
if self.probe_server_capabilities(binary).get("supports_ngram_mod"):
|
||||
_emit_ngram_mod()
|
||||
else:
|
||||
flags.append("--spec-default")
|
||||
self._speculative_type = "default"
|
||||
self._spec_fallback_reason = "drafter_not_found"
|
||||
|
||||
if effective_mode == "off":
|
||||
return flags # nothing to emit
|
||||
if effective_mode == "ngram-simple":
|
||||
|
|
@ -6283,6 +6400,10 @@ class LlamaCppBackend:
|
|||
flags.append("--spec-default")
|
||||
self._speculative_type = "default"
|
||||
return flags
|
||||
if _mtp_drafter_missing:
|
||||
# Drafterless: draft-mtp would abort llama-server, so fall back.
|
||||
_fallback_drafter_not_found()
|
||||
return flags
|
||||
if _mtp_too_small:
|
||||
logger.warning(
|
||||
f"Forcing MTP on a {_mtp_size_b:.1f}B model; "
|
||||
|
|
@ -6301,6 +6422,10 @@ class LlamaCppBackend:
|
|||
)
|
||||
_emit_ngram_mod()
|
||||
return flags
|
||||
if _mtp_drafter_missing:
|
||||
# No head/drafter: keep ngram-mod, drop the draft-mtp chain.
|
||||
_fallback_drafter_not_found()
|
||||
return flags
|
||||
if _mtp_too_small:
|
||||
logger.warning(
|
||||
f"Forcing MTP+Ngram on a {_mtp_size_b:.1f}B model; "
|
||||
|
|
@ -6312,14 +6437,42 @@ class LlamaCppBackend:
|
|||
|
||||
# effective_mode == "auto": the promotion path. llama.cpp #22673:
|
||||
# MTP is compatible with mmproj, so there's no vision gate.
|
||||
if is_mtp_model and not _mtp_too_small:
|
||||
# GPU: MTP-only. CPU/Mac: chain ngram-mod + MTP.
|
||||
_emit_mtp(chain_ngram = not gpus)
|
||||
if _auto_mla_embedded_mtp:
|
||||
# MLA embedded-MTP (GLM-5.2 et al.): the MTP path regresses vs spec-off
|
||||
# on llama.cpp today, so Auto drops it and falls back to ngram-mod (or
|
||||
# spec-off if unsupported), mirroring the sub-3B branch. Forced mtp /
|
||||
# mtp+ngram (handled above) still engage; UNSLOTH_MLA_MTP_ENABLED=1
|
||||
# re-enables this promotion once upstream optimizes the path.
|
||||
self._spec_fallback_reason = "mla_mtp_disabled"
|
||||
_mla_caps = self.probe_server_capabilities(binary)
|
||||
if _mla_caps.get("supports_ngram_mod"):
|
||||
logger.info(
|
||||
"Auto: MLA embedded-MTP model detected; llama.cpp's MLA/DSA "
|
||||
"MTP path is slower than no speculation, so using ngram-mod "
|
||||
"instead. Override via the Studio Speculative Decoding "
|
||||
"dropdown or UNSLOTH_MLA_MTP_ENABLED=1."
|
||||
)
|
||||
_emit_ngram_mod()
|
||||
else:
|
||||
logger.info(
|
||||
"Auto: MLA embedded-MTP model detected; disabling speculative "
|
||||
"decoding (this llama-server does not advertise ngram-mod). "
|
||||
"Override via the dropdown or UNSLOTH_MLA_MTP_ENABLED=1."
|
||||
)
|
||||
# spec-off: emit nothing, mirroring the sub-3B no-ngram path.
|
||||
elif is_mtp_model and not _mtp_too_small:
|
||||
if _mtp_drafter_missing:
|
||||
# Name-only MTP, drafter did not resolve (download failed/absent).
|
||||
_fallback_drafter_not_found()
|
||||
else:
|
||||
# GPU: MTP-only. CPU/Mac: chain ngram-mod + MTP.
|
||||
_emit_mtp(chain_ngram = not gpus)
|
||||
elif is_mtp_model and _mtp_too_small:
|
||||
# Sub-3B fallback: drop the MTP draft head, keep ngram-mod when
|
||||
# the binary supports it.
|
||||
_small_caps = self.probe_server_capabilities(binary)
|
||||
if _small_caps.get("supports_ngram_mod"):
|
||||
if _mtp_drafter_missing:
|
||||
_fallback_drafter_not_found()
|
||||
elif self.probe_server_capabilities(binary).get("supports_ngram_mod"):
|
||||
logger.info(
|
||||
f"MTP GGUF detected but model size {_mtp_size_b:.1f}B "
|
||||
"is below the 3B speedup threshold; using ngram-mod "
|
||||
|
|
@ -6409,6 +6562,16 @@ class LlamaCppBackend:
|
|||
if req_mode != backend_mode:
|
||||
return False
|
||||
|
||||
# Prior HF load fell back with drafter_not_found; a same-settings reload
|
||||
# must retry the download in load_model, not dedupe to the stale fallback
|
||||
# (HF loads resolve the drafter there, so gguf_path is None here).
|
||||
if (
|
||||
self._spec_fallback_reason == "drafter_not_found"
|
||||
and gguf_path is None
|
||||
and req_mode in ("auto", "mtp", "mtp+ngram")
|
||||
):
|
||||
return False
|
||||
|
||||
# spec_draft_n_max only matters when an MTP variant is engaged. Compare
|
||||
# on the resolved spec so an Auto request promoted to draft-mtp still
|
||||
# bounces a reload when n_max changes.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from hub.schemas.inventory import (
|
|||
DeleteCachedModelResponse,
|
||||
GgufVariantsResponse,
|
||||
LocalModelListResponse,
|
||||
ModelsFolderResponse,
|
||||
RecommendedFoldersResponse,
|
||||
RemoveScanFolderResponse,
|
||||
ScanFolderInfo,
|
||||
|
|
@ -91,6 +92,11 @@ def browse_folders(
|
|||
return folder_browser.browse_folders_response(path, show_hidden)
|
||||
|
||||
|
||||
@router.get("/models-folder", response_model = ModelsFolderResponse)
|
||||
def get_models_folder(current_subject: str = Depends(get_current_subject)):
|
||||
return local_inventory.get_models_folder_response()
|
||||
|
||||
|
||||
@router.get("/gguf-variants", response_model = GgufVariantsResponse)
|
||||
async def get_gguf_variants(
|
||||
repo_id: str = Query(
|
||||
|
|
|
|||
|
|
@ -284,3 +284,13 @@ class BrowseFoldersResponse(BaseModel):
|
|||
"they contain only files, no subdirectories)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ModelsFolderResponse(BaseModel):
|
||||
"""The directory where downloaded models are stored (the active HF hub
|
||||
cache, honoring ``HF_HOME`` / ``HF_HUB_CACHE``)."""
|
||||
|
||||
path: str = Field(
|
||||
...,
|
||||
description = "Path to the model download directory.",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -670,6 +670,31 @@ async def list_local_models_response(models_dir: str = "./models") -> LocalModel
|
|||
)
|
||||
|
||||
|
||||
def get_models_folder_response() -> dict:
|
||||
"""Return the directory where downloaded models are stored.
|
||||
|
||||
This is the active HF hub cache (honors ``HF_HOME`` / ``HF_HUB_CACHE``);
|
||||
the desktop app reveals it in the OS file manager.
|
||||
"""
|
||||
path = _resolve_hf_cache_dir()
|
||||
# Create it if missing so "Open folder" works before the first download:
|
||||
# HF builds the cache lazily, and studio only pre-creates the *default*
|
||||
# dir, not a user's explicit HF_HOME / HF_HUB_CACHE.
|
||||
try:
|
||||
path.mkdir(parents = True, exist_ok = True)
|
||||
except OSError as e:
|
||||
raise HTTPException(
|
||||
status_code = 500,
|
||||
detail = f"Failed to create models folder: {path}: {e}",
|
||||
) from e
|
||||
if not path.is_dir():
|
||||
raise HTTPException(
|
||||
status_code = 500,
|
||||
detail = f"Models folder path is not a directory: {path}",
|
||||
)
|
||||
return {"path": str(path)}
|
||||
|
||||
|
||||
def get_scan_folders_response() -> dict:
|
||||
return {"folders": list_scan_folders()}
|
||||
|
||||
|
|
|
|||
|
|
@ -181,6 +181,55 @@ def test_browse_folders_hides_sensitive_dirs(monkeypatch, tmp_path):
|
|||
assert ".ssh" not in names
|
||||
|
||||
|
||||
def test_get_models_folder_response_creates_and_returns_dir(monkeypatch, tmp_path):
|
||||
# The endpoint creates the cache dir on demand so the desktop "Open folder"
|
||||
# action works even before the first download.
|
||||
target = tmp_path / "hub"
|
||||
monkeypatch.setattr(local_inventory, "_resolve_hf_cache_dir", lambda: target)
|
||||
|
||||
response = local_inventory.get_models_folder_response()
|
||||
|
||||
assert response == {"path": str(target)}
|
||||
assert target.is_dir()
|
||||
|
||||
|
||||
def test_get_models_folder_response_reports_create_failure(monkeypatch, tmp_path):
|
||||
target = tmp_path / "hub"
|
||||
target.write_text("not a directory")
|
||||
monkeypatch.setattr(local_inventory, "_resolve_hf_cache_dir", lambda: target)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
local_inventory.get_models_folder_response()
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert "Failed to create models folder" in exc_info.value.detail
|
||||
|
||||
|
||||
def test_get_models_folder_response_requires_directory(monkeypatch, tmp_path):
|
||||
class MissingPath:
|
||||
def __init__(self, value: Path):
|
||||
self.value = value
|
||||
|
||||
def mkdir(self, *, parents: bool, exist_ok: bool):
|
||||
assert parents is True
|
||||
assert exist_ok is True
|
||||
|
||||
def is_dir(self):
|
||||
return False
|
||||
|
||||
def __str__(self):
|
||||
return str(self.value)
|
||||
|
||||
target = MissingPath(tmp_path / "hub")
|
||||
monkeypatch.setattr(local_inventory, "_resolve_hf_cache_dir", lambda: target)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
local_inventory.get_models_folder_response()
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert "not a directory" in exc_info.value.detail
|
||||
|
||||
|
||||
def test_contained_link_path_confines_to_link_dir(tmp_path):
|
||||
link_dir = tmp_path / "ollama" / ".studio_links" / "abc123"
|
||||
|
||||
|
|
|
|||
|
|
@ -412,8 +412,13 @@ class InferenceStatusResponse(BaseModel):
|
|||
"(auto on an MTP model, or forced mtp / mtp+ngram). "
|
||||
"'binary_no_mtp' / 'binary_outdated' -> a newer prebuilt would "
|
||||
"re-enable it (show the update affordance); 'runtime_error' -> the "
|
||||
"current build could not run it. None when MTP engaged or was not "
|
||||
"requested."
|
||||
"current build could not run it; 'drafter_not_found' -> the model's "
|
||||
"separate MTP drafter could not be resolved; 'mla_mtp_disabled' -> "
|
||||
"an Auto-mode policy downgrade: the model is MLA (GLM-5.2 et al.) "
|
||||
"whose llama.cpp MTP path runs slower than no speculation, so Auto "
|
||||
"used ngram-mod or spec-off instead -- updating won't help; choose "
|
||||
"MTP in Settings (or set UNSLOTH_MLA_MTP_ENABLED=1) to force it. "
|
||||
"None when MTP engaged or was not requested."
|
||||
),
|
||||
)
|
||||
llama_cpp_prebuilt_stale: bool = Field(
|
||||
|
|
|
|||
|
|
@ -1829,6 +1829,16 @@ def _request_matches_loaded_settings(
|
|||
backend_mode = llama_backend.requested_spec_mode or "auto"
|
||||
if req_mode != backend_mode:
|
||||
return False
|
||||
# Prior HF load fell back with drafter_not_found: a same-settings reload must
|
||||
# retry the download, not dedupe to the stale fallback. HF only (hf_repo set);
|
||||
# local/native loads have no download to retry (handled by the path compare).
|
||||
if (
|
||||
llama_backend.hf_repo
|
||||
and llama_backend.spec_fallback_reason == "drafter_not_found"
|
||||
and req_mode in ("auto", "mtp", "mtp+ngram")
|
||||
and not _extra_args_set_spec_type(effective_extra)
|
||||
):
|
||||
return False
|
||||
# spec_draft_n_max only matters with an MTP variant; None means "platform
|
||||
# default" and matches whatever the backend chose.
|
||||
if backend_mode in ("mtp", "mtp+ngram") and request.spec_draft_n_max is not None:
|
||||
|
|
@ -2206,7 +2216,9 @@ async def load_model(
|
|||
and llama_backend.model_identifier.lower() == model_identifier.lower()
|
||||
# Match runtime settings so Apply isn't dropped (#5401).
|
||||
and _request_matches_loaded_settings(
|
||||
request, llama_backend, effective_chat_template_override
|
||||
request,
|
||||
llama_backend,
|
||||
effective_chat_template_override,
|
||||
)
|
||||
# Skip if a prior audio probe failed -- let load_model retry.
|
||||
and getattr(llama_backend, "_audio_probed", True)
|
||||
|
|
@ -3127,9 +3139,13 @@ async def generate_stream(
|
|||
log = logger,
|
||||
)
|
||||
|
||||
cancel_event = threading.Event()
|
||||
|
||||
async def stream():
|
||||
gen = None
|
||||
completed = False
|
||||
try:
|
||||
for chunk in backend.generate_chat_response(
|
||||
gen = backend.generate_chat_response(
|
||||
messages = request.messages,
|
||||
system_prompt = request.system_prompt,
|
||||
image = image,
|
||||
|
|
@ -3138,14 +3154,35 @@ async def generate_stream(
|
|||
top_k = request.top_k,
|
||||
max_new_tokens = request.max_new_tokens,
|
||||
repetition_penalty = request.repetition_penalty,
|
||||
):
|
||||
cancel_event = cancel_event,
|
||||
)
|
||||
_DONE = object()
|
||||
while True:
|
||||
chunk = await asyncio.to_thread(next, gen, _DONE)
|
||||
if chunk is _DONE:
|
||||
break
|
||||
yield f"data: {json.dumps({'content': chunk})}\n\n"
|
||||
completed = True
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
except asyncio.CancelledError:
|
||||
cancel_event.set()
|
||||
backend.reset_generation_state()
|
||||
raise
|
||||
except Exception as e:
|
||||
cancel_event.set()
|
||||
backend.reset_generation_state()
|
||||
logger.error(f"Error during generation: {e}", exc_info = True)
|
||||
yield f"data: {json.dumps({'error': _friendly_error(e)})}\n\n"
|
||||
finally:
|
||||
if not completed and not cancel_event.is_set():
|
||||
cancel_event.set()
|
||||
backend.reset_generation_state()
|
||||
if gen is not None:
|
||||
try:
|
||||
await asyncio.to_thread(gen.close)
|
||||
except (RuntimeError, ValueError):
|
||||
pass
|
||||
|
||||
return StreamingResponse(
|
||||
stream(),
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ from core.inference.llama_cpp import (
|
|||
_extra_args_set_any_flag,
|
||||
_extra_args_set_spec_type,
|
||||
_is_mtp_model_name,
|
||||
_mla_mtp_auto_enabled,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1329,6 +1330,282 @@ def test_forced_mtp_ngram_on_non_mtp_model_keeps_ngram(monkeypatch):
|
|||
assert backend.requested_spec_mode == "mtp+ngram"
|
||||
|
||||
|
||||
# ── Auto drops embedded MTP for MLA models (GLM-5.2 et al.) ───────────
|
||||
#
|
||||
# llama.cpp's MLA/DSA MTP path runs ~2x slower than no speculation (GLM-5.2
|
||||
# bench), so Auto downgrades it to ngram-mod (or spec-off). The clean
|
||||
# metadata separator from non-MLA MTP (Qwen, kept on draft-mtp) is
|
||||
# self._kv_lora_rank. Forced mtp / mtp+ngram and separate drafters (Gemma)
|
||||
# stay on draft-mtp; UNSLOTH_MLA_MTP_ENABLED=1 re-enables Auto promotion.
|
||||
|
||||
# GLM-5.2's repo name has no "MTP" marker, so its MTP signal is metadata-only
|
||||
# (nextn_predict_layers) -- exactly the embedded-MLA case we gate.
|
||||
_GLM_MLA_MODEL = "unsloth/GLM-5.2-GGUF"
|
||||
|
||||
|
||||
def _mla_resolver_backend(
|
||||
monkeypatch,
|
||||
*,
|
||||
ngram_supported = True,
|
||||
kv_lora_rank = 512,
|
||||
nextn = 1,
|
||||
):
|
||||
"""Resolver backend posing as an embedded-MTP MLA model (kv_lora_rank set)."""
|
||||
backend = _resolver_backend(monkeypatch, ngram_supported = ngram_supported)
|
||||
backend._nextn_predict_layers = nextn
|
||||
backend._kv_lora_rank = kv_lora_rank
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gpus", [True, False])
|
||||
def test_auto_mla_embedded_mtp_falls_back_to_ngram(monkeypatch, gpus):
|
||||
# Auto + MLA embedded MTP + ngram supported -> ngram-mod on BOTH platforms
|
||||
# (the CPU chain ngram-mod,draft-mtp is dropped: no draft-mtp for MLA).
|
||||
backend = _mla_resolver_backend(monkeypatch)
|
||||
flags = backend._build_speculative_flags(
|
||||
speculative_type = "auto",
|
||||
spec_draft_n_max = None,
|
||||
extra_args = None,
|
||||
model_identifier = _GLM_MLA_MODEL,
|
||||
model_path = None,
|
||||
gpus = gpus,
|
||||
binary = "/fake/llama-server",
|
||||
)
|
||||
parsed = _flags_dict(flags)
|
||||
assert parsed.get("--spec-type") == "ngram-mod"
|
||||
assert "--spec-draft-n-max" not in parsed
|
||||
assert "--spec-ngram-mod-n-match" in parsed
|
||||
assert backend.speculative_type == "ngram-mod"
|
||||
assert backend.requested_spec_mode == "auto"
|
||||
assert backend.spec_fallback_reason == "mla_mtp_disabled"
|
||||
assert backend.spec_draft_n_max is None
|
||||
|
||||
|
||||
def test_auto_mla_embedded_mtp_no_ngram_disables_spec(monkeypatch):
|
||||
# Auto + MLA embedded MTP + no ngram-mod support -> emit nothing (spec-off),
|
||||
# mirroring the sub-3B no-ngram path. Still flagged as a policy downgrade.
|
||||
backend = _mla_resolver_backend(monkeypatch, ngram_supported = False)
|
||||
flags = backend._build_speculative_flags(
|
||||
speculative_type = "auto",
|
||||
spec_draft_n_max = None,
|
||||
extra_args = None,
|
||||
model_identifier = _GLM_MLA_MODEL,
|
||||
model_path = None,
|
||||
gpus = True,
|
||||
binary = "/fake/llama-server",
|
||||
)
|
||||
assert "--spec-type" not in flags
|
||||
assert backend.speculative_type is None
|
||||
assert backend.requested_spec_mode == "auto"
|
||||
assert backend.spec_fallback_reason == "mla_mtp_disabled"
|
||||
|
||||
|
||||
def test_auto_non_mla_embedded_mtp_keeps_draft_mtp(monkeypatch):
|
||||
# Auto + embedded MTP + NON-MLA (kv_lora_rank None, e.g. Qwen) -> unchanged:
|
||||
# still draft-mtp at the platform default. No policy downgrade.
|
||||
backend = _mla_resolver_backend(monkeypatch, kv_lora_rank = None)
|
||||
flags = backend._build_speculative_flags(
|
||||
speculative_type = "auto",
|
||||
spec_draft_n_max = None,
|
||||
extra_args = None,
|
||||
model_identifier = _MTP_MODEL,
|
||||
model_path = None,
|
||||
gpus = True,
|
||||
binary = "/fake/llama-server",
|
||||
)
|
||||
parsed = _flags_dict(flags)
|
||||
assert parsed.get("--spec-type") == "draft-mtp"
|
||||
assert parsed.get("--spec-draft-n-max") == "2"
|
||||
assert backend.speculative_type == "draft-mtp"
|
||||
assert backend.spec_fallback_reason is None
|
||||
|
||||
|
||||
def test_auto_mla_separate_drafter_keeps_mtp(monkeypatch):
|
||||
# Auto + MLA + a separate drafter (mtp_draft_path) -> the drafter exemption
|
||||
# wins over the MLA gate: still draft-mtp (Gemma-style external drafter is
|
||||
# not the slow embedded MLA/DSA path).
|
||||
backend = _mla_resolver_backend(monkeypatch)
|
||||
flags = backend._build_speculative_flags(
|
||||
speculative_type = "auto",
|
||||
spec_draft_n_max = None,
|
||||
extra_args = None,
|
||||
model_identifier = _GLM_MLA_MODEL,
|
||||
model_path = None,
|
||||
gpus = True,
|
||||
binary = "/fake/llama-server",
|
||||
mtp_draft_path = "/fake/mtp-draft.gguf",
|
||||
)
|
||||
parsed = _flags_dict(flags)
|
||||
assert parsed.get("--spec-type") == "draft-mtp"
|
||||
assert backend.speculative_type == "draft-mtp"
|
||||
assert backend.spec_fallback_reason is None
|
||||
|
||||
|
||||
def test_auto_non_mtp_mla_model_unaffected(monkeypatch):
|
||||
# Auto + MLA but NO embedded MTP head (kv_lora_rank set, nextn None, e.g.
|
||||
# GLM-4.7-Flash) -> non-MTP default; no accidental ngram drop.
|
||||
backend = _mla_resolver_backend(monkeypatch, nextn = None)
|
||||
flags = backend._build_speculative_flags(
|
||||
speculative_type = "auto",
|
||||
spec_draft_n_max = None,
|
||||
extra_args = None,
|
||||
model_identifier = "unsloth/GLM-4.7-Flash-GGUF",
|
||||
model_path = None,
|
||||
gpus = True,
|
||||
binary = "/fake/llama-server",
|
||||
)
|
||||
assert "--spec-default" in flags
|
||||
assert "ngram-mod" not in flags
|
||||
assert backend.speculative_type == "default"
|
||||
assert backend.spec_fallback_reason is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode, expect_spec_type, expect_n_max",
|
||||
[
|
||||
("mtp", "draft-mtp", "2"),
|
||||
("mtp+ngram", "ngram-mod,draft-mtp", "2"),
|
||||
],
|
||||
)
|
||||
def test_forced_mtp_on_mla_still_engages(monkeypatch, mode, expect_spec_type, expect_n_max):
|
||||
# Explicit override engages the deliberately-slower MTP route on MLA models,
|
||||
# regardless of the Auto gate. No policy downgrade reason.
|
||||
backend = _mla_resolver_backend(monkeypatch)
|
||||
flags = backend._build_speculative_flags(
|
||||
speculative_type = mode,
|
||||
spec_draft_n_max = None,
|
||||
extra_args = None,
|
||||
model_identifier = _GLM_MLA_MODEL,
|
||||
model_path = None,
|
||||
gpus = True,
|
||||
binary = "/fake/llama-server",
|
||||
)
|
||||
parsed = _flags_dict(flags)
|
||||
assert parsed.get("--spec-type") == expect_spec_type
|
||||
assert parsed.get("--spec-draft-n-max") == expect_n_max
|
||||
assert backend.speculative_type == "draft-mtp"
|
||||
assert backend.requested_spec_mode == mode
|
||||
assert backend.spec_fallback_reason is None
|
||||
|
||||
|
||||
def test_env_flag_reenables_auto_mla_mtp(monkeypatch):
|
||||
# UNSLOTH_MLA_MTP_ENABLED=1 -> Auto promotes MLA embedded MTP to draft-mtp
|
||||
# again (the forward hook for when llama.cpp optimizes the path).
|
||||
monkeypatch.setenv("UNSLOTH_MLA_MTP_ENABLED", "1")
|
||||
backend = _mla_resolver_backend(monkeypatch)
|
||||
flags = backend._build_speculative_flags(
|
||||
speculative_type = "auto",
|
||||
spec_draft_n_max = None,
|
||||
extra_args = None,
|
||||
model_identifier = _GLM_MLA_MODEL,
|
||||
model_path = None,
|
||||
gpus = True,
|
||||
binary = "/fake/llama-server",
|
||||
)
|
||||
parsed = _flags_dict(flags)
|
||||
assert parsed.get("--spec-type") == "draft-mtp"
|
||||
assert backend.speculative_type == "draft-mtp"
|
||||
assert backend.spec_fallback_reason is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "true", "yes", "on", "TRUE", "On"])
|
||||
def test_mla_mtp_auto_enabled_truthy_values(monkeypatch, value):
|
||||
monkeypatch.setenv("UNSLOTH_MLA_MTP_ENABLED", value)
|
||||
assert _mla_mtp_auto_enabled() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "false", "no", "off", "", " ", "bogus"])
|
||||
def test_mla_mtp_auto_disabled_default_and_falsy(monkeypatch, value):
|
||||
monkeypatch.setenv("UNSLOTH_MLA_MTP_ENABLED", value)
|
||||
assert _mla_mtp_auto_enabled() is False
|
||||
|
||||
|
||||
def test_mla_mtp_auto_disabled_when_unset(monkeypatch):
|
||||
monkeypatch.delenv("UNSLOTH_MLA_MTP_ENABLED", raising = False)
|
||||
assert _mla_mtp_auto_enabled() is False
|
||||
|
||||
|
||||
def test_read_gguf_metadata_captures_kv_lora_rank(tmp_path):
|
||||
# GLM-5.2-style header: MLA (kv_lora_rank) + embedded MTP (nextn) populate
|
||||
# both fields, so the Auto gate sees an MLA embedded-MTP model.
|
||||
gguf = _write_minimal_gguf(
|
||||
tmp_path / "model.gguf",
|
||||
arch = "glm-dsa",
|
||||
nextn = 1,
|
||||
extra_uint32 = {
|
||||
"glm-dsa.block_count": 4,
|
||||
"glm-dsa.attention.kv_lora_rank": 512,
|
||||
},
|
||||
)
|
||||
backend = LlamaCppBackend()
|
||||
backend._read_gguf_metadata(str(gguf))
|
||||
assert backend._nextn_predict_layers == 1
|
||||
assert backend._kv_lora_rank == 512
|
||||
|
||||
|
||||
def test_read_gguf_metadata_qwen_mtp_has_no_kv_lora_rank(tmp_path):
|
||||
# Qwen MTP header: embedded MTP but non-MLA, so kv_lora_rank stays None and
|
||||
# Auto keeps it on draft-mtp.
|
||||
gguf = _write_minimal_gguf(
|
||||
tmp_path / "model.gguf",
|
||||
arch = "qwen35moe",
|
||||
nextn = 1,
|
||||
extra_uint32 = {"qwen35moe.block_count": 4},
|
||||
)
|
||||
backend = LlamaCppBackend()
|
||||
backend._read_gguf_metadata(str(gguf))
|
||||
assert backend._nextn_predict_layers == 1
|
||||
assert backend._kv_lora_rank is None
|
||||
|
||||
|
||||
def test_reload_skip_auto_mla_ngram_is_idempotent():
|
||||
# A GLM model resolved to ngram-mod under Auto must not churn: a duplicate
|
||||
# Auto /load at the same settings is already-satisfied.
|
||||
backend = _mtp_backend(
|
||||
_model_identifier = _GLM_MLA_MODEL,
|
||||
_speculative_type = "ngram-mod",
|
||||
_requested_spec_mode = "auto",
|
||||
)
|
||||
assert (
|
||||
backend._already_in_target_state(
|
||||
gguf_path = None,
|
||||
model_identifier = _GLM_MLA_MODEL,
|
||||
hf_variant = "Q4_K_M",
|
||||
n_ctx = 8192,
|
||||
cache_type_kv = None,
|
||||
speculative_type = "auto",
|
||||
chat_template_override = None,
|
||||
extra_args = None,
|
||||
is_vision = False,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_reload_forced_mtp_bounces_auto_mla():
|
||||
# Overriding Auto (ngram-mod) with a forced mtp request must reload (to the
|
||||
# slower draft-mtp route), not dedup against the running ngram-mod server.
|
||||
backend = _mtp_backend(
|
||||
_model_identifier = _GLM_MLA_MODEL,
|
||||
_speculative_type = "ngram-mod",
|
||||
_requested_spec_mode = "auto",
|
||||
)
|
||||
assert (
|
||||
backend._already_in_target_state(
|
||||
gguf_path = None,
|
||||
model_identifier = _GLM_MLA_MODEL,
|
||||
hf_variant = "Q4_K_M",
|
||||
n_ctx = 8192,
|
||||
cache_type_kv = None,
|
||||
speculative_type = "mtp",
|
||||
chat_template_override = None,
|
||||
extra_args = None,
|
||||
is_vision = False,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
# ── Full named-repo resolver matrix (the shipping Studio families) ─────
|
||||
#
|
||||
# Locks auto / off / forced-mtp routing for every Qwen3.5 (MTP + plain) and
|
||||
|
|
@ -1376,6 +1653,8 @@ _REAL_REPO_MATRIX = [
|
|||
|
||||
def _resolve_real(monkeypatch, repo, drafter, mode):
|
||||
backend = _resolver_backend(monkeypatch)
|
||||
if "qwen" in repo.lower() and "-mtp" in repo.lower():
|
||||
backend._nextn_predict_layers = 1
|
||||
flags = backend._build_speculative_flags(
|
||||
speculative_type = mode,
|
||||
spec_draft_n_max = None,
|
||||
|
|
@ -1566,3 +1845,126 @@ def test_spec_fallback_reason_reset_on_off(monkeypatch):
|
|||
binary = "/fake/llama-server",
|
||||
)
|
||||
assert backend.spec_fallback_reason is None
|
||||
|
||||
|
||||
def test_is_gemma_mtp_family():
|
||||
from core.inference.llama_cpp import _is_gemma_mtp_family
|
||||
|
||||
assert _is_gemma_mtp_family("unsloth/gemma-4-E4B-it-GGUF") is True
|
||||
assert _is_gemma_mtp_family("unsloth/gemma-4-12b-it-GGUF") is True
|
||||
# gemma-3n ships no separate drafter, so it is not a drafter family.
|
||||
assert _is_gemma_mtp_family("unsloth/gemma-3n-E2B-it-GGUF") is False
|
||||
assert _is_gemma_mtp_family("unsloth/Qwen3.5-35B-A3B-MTP-GGUF") is False
|
||||
assert _is_gemma_mtp_family("unsloth/llama-3-8b") is False
|
||||
|
||||
|
||||
def test_gemma_3n_without_drafter_is_not_mtp(monkeypatch):
|
||||
# gemma-3n ships no drafter; it must take the normal non-MTP path, not
|
||||
# drafter_not_found (which would make every reload retry a missing drafter).
|
||||
backend = _resolver_backend(monkeypatch)
|
||||
backend._build_speculative_flags(
|
||||
speculative_type = "auto",
|
||||
spec_draft_n_max = None,
|
||||
extra_args = None,
|
||||
model_identifier = "unsloth/gemma-3n-E4B-it-GGUF",
|
||||
model_path = None,
|
||||
gpus = True,
|
||||
binary = "/fake/llama-server",
|
||||
mtp_draft_path = None,
|
||||
)
|
||||
assert backend.spec_fallback_reason is None
|
||||
|
||||
|
||||
def test_spec_fallback_reason_drafter_not_found(monkeypatch):
|
||||
# Drafterless Gemma should fall back to ngram-mod + drafter_not_found.
|
||||
backend = _resolver_backend(monkeypatch)
|
||||
flags = backend._build_speculative_flags(
|
||||
speculative_type = "auto",
|
||||
spec_draft_n_max = None,
|
||||
extra_args = None,
|
||||
model_identifier = "unsloth/gemma-4-E4B-it-GGUF",
|
||||
model_path = None,
|
||||
gpus = True,
|
||||
binary = "/fake/llama-server",
|
||||
mtp_draft_path = None, # Drafter download failed
|
||||
)
|
||||
parsed = _flags_dict(flags)
|
||||
assert parsed.get("--spec-type") == "ngram-mod"
|
||||
assert backend.speculative_type == "ngram-mod"
|
||||
assert backend.spec_fallback_reason == "drafter_not_found"
|
||||
|
||||
|
||||
def test_is_gemma_mtp_name_none_safe():
|
||||
# model_identifier=None (local load) must not raise; recognise via filename.
|
||||
from core.inference.llama_cpp import _is_gemma_mtp_family, _is_gemma_mtp_name
|
||||
|
||||
assert _is_gemma_mtp_family(None) is False
|
||||
assert _is_gemma_mtp_name(None, "/models/gemma-4-E4B-it-Q4_K_M.gguf") is True
|
||||
assert _is_gemma_mtp_name("unsloth/Qwen3.5-4B-MTP-GGUF", None) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["mtp", "mtp+ngram"])
|
||||
def test_forced_mtp_gemma_without_drafter_falls_back(monkeypatch, mode):
|
||||
# Forced MTP on a drafterless Gemma must fall back, not emit draft-mtp.
|
||||
backend = _resolver_backend(monkeypatch)
|
||||
flags = backend._build_speculative_flags(
|
||||
speculative_type = mode,
|
||||
spec_draft_n_max = None,
|
||||
extra_args = None,
|
||||
model_identifier = "unsloth/gemma-4-E4B-it-GGUF",
|
||||
model_path = None,
|
||||
gpus = True,
|
||||
binary = "/fake/llama-server",
|
||||
mtp_draft_path = None,
|
||||
)
|
||||
parsed = _flags_dict(flags)
|
||||
assert parsed.get("--spec-type") == "ngram-mod"
|
||||
assert "--model-draft" not in parsed
|
||||
assert backend.spec_fallback_reason == "drafter_not_found"
|
||||
|
||||
|
||||
def test_local_gemma_gguf_without_identifier_falls_back(monkeypatch):
|
||||
# Local Gemma GGUF (family only in filename) must not crash; falls back.
|
||||
backend = _resolver_backend(monkeypatch)
|
||||
flags = backend._build_speculative_flags(
|
||||
speculative_type = "auto",
|
||||
spec_draft_n_max = None,
|
||||
extra_args = None,
|
||||
model_identifier = None,
|
||||
model_path = "/models/gemma-4-E4B-it-Q4_K_M.gguf",
|
||||
gpus = True,
|
||||
binary = "/fake/llama-server",
|
||||
mtp_draft_path = None,
|
||||
)
|
||||
parsed = _flags_dict(flags)
|
||||
assert parsed.get("--spec-type") == "ngram-mod"
|
||||
assert backend.spec_fallback_reason == "drafter_not_found"
|
||||
|
||||
|
||||
def _drafter_not_found_kwargs():
|
||||
return dict(
|
||||
model_identifier = "unsloth/gemma-4-E4B-it-GGUF",
|
||||
hf_variant = "Q4_K_M",
|
||||
n_ctx = 8192,
|
||||
cache_type_kv = None,
|
||||
speculative_type = "auto",
|
||||
chat_template_override = None,
|
||||
extra_args = None,
|
||||
is_vision = False,
|
||||
gguf_path = None, # HF load: drafter resolves inside load_model
|
||||
)
|
||||
|
||||
|
||||
def test_already_in_target_state_retries_after_hf_drafter_not_found():
|
||||
# Recoverable drafter_not_found must not dedupe; reload re-attempts download.
|
||||
backend = _mtp_backend(
|
||||
_model_identifier = "unsloth/gemma-4-E4B-it-GGUF",
|
||||
_speculative_type = "ngram-mod",
|
||||
_spec_fallback_reason = "drafter_not_found",
|
||||
_mtp_draft_path = None,
|
||||
_gguf_path = None,
|
||||
)
|
||||
assert backend._already_in_target_state(**_drafter_not_found_kwargs()) is False
|
||||
# Sanity: with no fallback reason the same request still dedupes (matches).
|
||||
ok = _mtp_backend(_model_identifier = "unsloth/gemma-4-E4B-it-GGUF", _gguf_path = None)
|
||||
assert ok._already_in_target_state(**_drafter_not_found_kwargs()) is True
|
||||
|
|
|
|||
|
|
@ -172,6 +172,22 @@ class TestMlaTargetCtxReserve:
|
|||
ctx = 131072
|
||||
assert mla._estimate_mtp_overhead_bytes(ctx) > non._estimate_mtp_overhead_bytes(ctx)
|
||||
|
||||
def test_separate_drafter_mode_drops_target_copy(self):
|
||||
# The duplicated target context is MTP-only. draft-simple / draft-eagle3
|
||||
# load a small separate drafter with its own KV (counted in the draft KV)
|
||||
# and keep no target copy, so even on an MLA model the reserve must drop
|
||||
# the f16 copy when mtp_keeps_target_ctx=False -- which is what the loader
|
||||
# threads for those modes. The default (True) keeps the MTP copy.
|
||||
b = _make_mla_backend()
|
||||
ctx = 262144
|
||||
mtp = b._estimate_mtp_overhead_bytes(ctx) # default True == MTP draft
|
||||
separate = b._estimate_mtp_overhead_bytes(ctx, mtp_keeps_target_ctx = False)
|
||||
# Separate-drafter overhead is exactly the draft KV (no target copy)...
|
||||
assert separate == b._mtp_draft_kv_bytes(ctx)
|
||||
# ...and the MTP reserve is that plus the full f16 target copy.
|
||||
assert mtp == separate + b._estimate_kv_cache_bytes(ctx, "f16")
|
||||
assert mtp > separate
|
||||
|
||||
|
||||
class TestMlaFitPreventsOom:
|
||||
"""The corrected reserve must actually lower the auto-fit context so the
|
||||
|
|
@ -200,7 +216,7 @@ class TestMlaFitPreventsOom:
|
|||
self.MODEL_BYTES,
|
||||
mtp_engaged = True,
|
||||
total_mib = self.TOTAL_MIB,
|
||||
mtp_overhead_fn = lambda c: (b._mtp_draft_kv_bytes(c) or 0),
|
||||
mtp_overhead_fn = lambda c: b._mtp_draft_kv_bytes(c) or 0,
|
||||
)
|
||||
assert draft_only == self.REQ_CTX # reproduces the over-advertised context
|
||||
assert with_copy < self.REQ_CTX # corrected reserve backs the context off
|
||||
|
|
|
|||
|
|
@ -829,6 +829,20 @@ class TestExtraArgsMtpDetection:
|
|||
"request.tensor_parallel,llama_backend.tensor_parallel)" in body
|
||||
)
|
||||
|
||||
def test_route_matcher_retries_after_drafter_not_found(self):
|
||||
# drafter_not_found must not report "already loaded" or the reload never
|
||||
# retries the download (#6459). Read source: importing routes pulls deps.
|
||||
routes_src = (
|
||||
Path(__file__).resolve().parent.parent / "routes" / "inference.py"
|
||||
).read_text()
|
||||
start = routes_src.index("def _request_matches_loaded_settings")
|
||||
end = routes_src.index("\ndef ", start + 1)
|
||||
body = "".join(routes_src[start:end].split())
|
||||
assert 'llama_backend.spec_fallback_reason=="drafter_not_found"' in body
|
||||
assert "not_extra_args_set_spec_type(effective_extra)" in body
|
||||
# HF-only (hf_repo): local/native loads have no download to retry.
|
||||
assert "llama_backend.hf_repo" in body
|
||||
|
||||
def test_extra_args_main_cache_type_heavier_axis(self):
|
||||
# Asymmetric --cache-type-k/-v must budget the heavier axis (extras win
|
||||
# per axis at launch), not the last-wins single type that under-reserves.
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ def test_detect_safetensors_features_none_template_returns_all_false():
|
|||
"supports_reasoning": False,
|
||||
"reasoning_style": "enable_thinking",
|
||||
"reasoning_always_on": False,
|
||||
"reasoning_effort_levels": [],
|
||||
"supports_preserve_thinking": False,
|
||||
"supports_tools": False,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -365,6 +365,11 @@ def test_runtime_recovery_reloads_without_mtp(monkeypatch):
|
|||
while b._spec_fallback_reason != "runtime_error" and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
assert b._spec_fallback_reason == "runtime_error"
|
||||
# The reload thread clears the single-flight flag in its finally, a beat after
|
||||
# it sets the fallback reason -- wait for that instead of racing the thread.
|
||||
deadline = time.monotonic() + 2
|
||||
while b._mtp_runtime_fallback_in_progress and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
assert b._mtp_runtime_fallback_in_progress is False
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import {
|
|||
listPromptEntries,
|
||||
type PromptEntry,
|
||||
} from "@/features/chat/api/prompts-api";
|
||||
import { useChatPreferencesStore } from "@/features/chat/stores/chat-preferences-store";
|
||||
import { useChatProjects } from "@/features/chat/hooks/use-chat-projects";
|
||||
import { NewProjectDialog } from "@/features/chat/components/new-project-dialog";
|
||||
import { parseExternalModelId } from "@/features/chat/external-providers";
|
||||
|
|
@ -135,6 +136,7 @@ import {
|
|||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
Columns2Icon,
|
||||
CornerDownRightIcon,
|
||||
GitBranchIcon,
|
||||
GlobeIcon,
|
||||
HeadphonesIcon,
|
||||
|
|
@ -178,12 +180,33 @@ type PromptQueueUIEntry = {
|
|||
total: number;
|
||||
};
|
||||
|
||||
type PromptQueueUIItemStatus = "queued" | "next" | "waiting" | "running";
|
||||
|
||||
type PromptQueueUIItem = {
|
||||
id: string;
|
||||
prompt: string;
|
||||
position: number;
|
||||
total: number;
|
||||
status: PromptQueueUIItemStatus;
|
||||
threadIds: string[];
|
||||
canEdit: boolean;
|
||||
canRemove: boolean;
|
||||
};
|
||||
|
||||
interface PromptQueueUIState {
|
||||
byThreadId: Record<string, PromptQueueUIEntry>;
|
||||
current: number;
|
||||
total: number;
|
||||
items: PromptQueueUIItem[];
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
const usePromptQueueUI = create<PromptQueueUIState>(() => ({
|
||||
byThreadId: {},
|
||||
current: 0,
|
||||
total: 0,
|
||||
items: [],
|
||||
isRunning: false,
|
||||
}));
|
||||
|
||||
type PromptQueueTarget = {
|
||||
|
|
@ -195,8 +218,10 @@ type PromptQueueTarget = {
|
|||
};
|
||||
|
||||
type PromptQueueItem = {
|
||||
id: string;
|
||||
prompt: string;
|
||||
target: PromptQueueTarget;
|
||||
dispatched: boolean;
|
||||
};
|
||||
|
||||
const PROMPT_QUEUE_INDEXING_RETRY_MS = 500;
|
||||
|
|
@ -214,6 +239,10 @@ function compactIds(ids: Array<string | null | undefined>) {
|
|||
return Array.from(new Set(ids.filter((id): id is string => Boolean(id))));
|
||||
}
|
||||
|
||||
function createPromptQueueItemId() {
|
||||
return `prompt-queue-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
function stopPromptQueueSubscription({
|
||||
resetRunningState = true,
|
||||
}: {
|
||||
|
|
@ -228,7 +257,7 @@ function stopPromptQueueSubscription({
|
|||
}
|
||||
}
|
||||
|
||||
function resetPromptQueue(showToast = false) {
|
||||
function resetPromptQueue() {
|
||||
promptQueueGeneration += 1;
|
||||
promptQueueIsRunning = false;
|
||||
promptQueueItems = [];
|
||||
|
|
@ -240,16 +269,10 @@ function resetPromptQueue(showToast = false) {
|
|||
}
|
||||
stopPromptQueueSubscription();
|
||||
syncPromptQueueUI();
|
||||
if (showToast) {
|
||||
toast.success("Prompt queue complete");
|
||||
}
|
||||
}
|
||||
|
||||
function queueToastDescription(prompt: string) {
|
||||
return prompt.length > 80 ? `${prompt.slice(0, 80)}...` : prompt;
|
||||
}
|
||||
|
||||
function appendQueuedPrompt(item: PromptQueueItem) {
|
||||
item.dispatched = true;
|
||||
syncPromptQueueUI();
|
||||
item.target.append(item.prompt);
|
||||
}
|
||||
|
|
@ -335,8 +358,10 @@ async function dispatchQueuedPrompt(
|
|||
|
||||
function createQueuedPrompt(prompt: string, target: PromptQueueTarget) {
|
||||
return {
|
||||
id: createPromptQueueItemId(),
|
||||
prompt,
|
||||
target,
|
||||
dispatched: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -368,13 +393,62 @@ function findPromptQueueEntry(
|
|||
return null;
|
||||
}
|
||||
|
||||
function canEditPromptQueueItem(item: PromptQueueItem) {
|
||||
return !item.dispatched;
|
||||
}
|
||||
|
||||
function canRemovePromptQueueItem(item: PromptQueueItem) {
|
||||
return !item.dispatched;
|
||||
}
|
||||
|
||||
function promptQueueItemMatchesThreadIds(
|
||||
item: PromptQueueUIItem,
|
||||
threadIds: string[],
|
||||
) {
|
||||
return item.threadIds.some((threadId) => threadIds.includes(threadId));
|
||||
}
|
||||
|
||||
function syncPromptQueueUI() {
|
||||
if (!promptQueueIsRunning || promptQueueItems.length === 0) {
|
||||
usePromptQueueUI.setState({ byThreadId: {} });
|
||||
usePromptQueueUI.setState({
|
||||
byThreadId: {},
|
||||
current: 0,
|
||||
total: 0,
|
||||
items: [],
|
||||
isRunning: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeItemIndex = Math.max(promptQueueIndex, 0);
|
||||
const total = promptQueueItems.length;
|
||||
const current = promptQueueIndex >= 0 ? Math.min(activeItemIndex + 1, total) : 0;
|
||||
const items = promptQueueItems
|
||||
.map((item, index): PromptQueueUIItem | null => {
|
||||
if (index < activeItemIndex || item.dispatched) {
|
||||
return null;
|
||||
}
|
||||
const threadIds = getPromptQueueTargetIds(item.target);
|
||||
const isActive = promptQueueIndex >= 0 && index === activeItemIndex;
|
||||
const status: PromptQueueUIItemStatus = item.dispatched
|
||||
? "running"
|
||||
: isActive
|
||||
? promptQueueWaitingForTargetIdle
|
||||
? "waiting"
|
||||
: "next"
|
||||
: "queued";
|
||||
return {
|
||||
id: item.id,
|
||||
prompt: item.prompt,
|
||||
position: index + 1,
|
||||
total,
|
||||
status,
|
||||
threadIds,
|
||||
canEdit: canEditPromptQueueItem(item),
|
||||
canRemove: canRemovePromptQueueItem(item),
|
||||
};
|
||||
})
|
||||
.filter((item): item is PromptQueueUIItem => Boolean(item));
|
||||
const groups: Array<{
|
||||
ids: Set<string>;
|
||||
current: number;
|
||||
|
|
@ -423,7 +497,80 @@ function syncPromptQueueUI() {
|
|||
});
|
||||
}
|
||||
|
||||
usePromptQueueUI.setState({ byThreadId });
|
||||
usePromptQueueUI.setState({
|
||||
byThreadId,
|
||||
current,
|
||||
total,
|
||||
items,
|
||||
isRunning: true,
|
||||
});
|
||||
}
|
||||
|
||||
function editPromptQueueItem(itemId: string, prompt: string) {
|
||||
const nextPrompt = prompt.trim();
|
||||
if (!nextPrompt) {
|
||||
return false;
|
||||
}
|
||||
const itemIndex = promptQueueItems.findIndex(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (itemIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
const item = promptQueueItems[itemIndex];
|
||||
if (!canEditPromptQueueItem(item)) {
|
||||
return false;
|
||||
}
|
||||
item.prompt = nextPrompt;
|
||||
syncPromptQueueUI();
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearPromptQueueRetryTimer() {
|
||||
if (!promptQueueRetryTimer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(promptQueueRetryTimer);
|
||||
promptQueueRetryTimer = null;
|
||||
}
|
||||
|
||||
function removePromptQueueItem(itemId: string) {
|
||||
const itemIndex = promptQueueItems.findIndex((item) => item.id === itemId);
|
||||
if (itemIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
const item = promptQueueItems[itemIndex];
|
||||
if (!canRemovePromptQueueItem(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const wasActive =
|
||||
promptQueueIndex >= 0 && itemIndex === Math.max(promptQueueIndex, 0);
|
||||
promptQueueItems.splice(itemIndex, 1);
|
||||
if (promptQueueItems.length === 0) {
|
||||
resetPromptQueue();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemIndex < promptQueueIndex) {
|
||||
promptQueueIndex -= 1;
|
||||
}
|
||||
if (wasActive && promptQueueIndex >= promptQueueItems.length) {
|
||||
resetPromptQueue();
|
||||
return true;
|
||||
}
|
||||
|
||||
syncPromptQueueUI();
|
||||
if (wasActive) {
|
||||
clearPromptQueueRetryTimer();
|
||||
promptQueueWaitingForTargetIdle = false;
|
||||
promptQueuePrevStoreRunning = false;
|
||||
const next = promptQueueItems[promptQueueIndex];
|
||||
if (next) {
|
||||
scheduleQueuedPromptDispatch(next, 50);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isPromptQueueTargetRunning(
|
||||
|
|
@ -456,15 +603,12 @@ function isActivePromptQueueTargetRunning(
|
|||
function advancePromptQueue() {
|
||||
const nextIndex = promptQueueIndex + 1;
|
||||
if (nextIndex >= promptQueueItems.length) {
|
||||
resetPromptQueue(true);
|
||||
resetPromptQueue();
|
||||
return;
|
||||
}
|
||||
promptQueueIndex = nextIndex;
|
||||
syncPromptQueueUI();
|
||||
const next = promptQueueItems[nextIndex];
|
||||
toast(`Prompt ${nextIndex + 1} / ${promptQueueItems.length}`, {
|
||||
description: queueToastDescription(next.prompt),
|
||||
});
|
||||
promptQueueWaitingForTargetIdle = false;
|
||||
promptQueuePrevStoreRunning = false;
|
||||
scheduleQueuedPromptDispatch(next, 100);
|
||||
|
|
@ -529,9 +673,6 @@ function startPromptQueue(
|
|||
...filtered.map((prompt) => createQueuedPrompt(prompt, target)),
|
||||
);
|
||||
syncPromptQueueUI();
|
||||
toast.success("Added to prompt queue", {
|
||||
description: `${filtered.length} prompt${filtered.length === 1 ? "" : "s"} queued.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -547,12 +688,6 @@ function startPromptQueue(
|
|||
promptQueueIsRunning = true;
|
||||
promptQueuePrevStoreRunning = shouldWaitForCurrentRun;
|
||||
syncPromptQueueUI();
|
||||
toast(
|
||||
shouldWaitForCurrentRun ? "Prompt queued" : `Prompt 1 / ${filtered.length}`,
|
||||
{
|
||||
description: queueToastDescription(filtered[0]),
|
||||
},
|
||||
);
|
||||
startPromptQueueSubscription();
|
||||
if (!shouldWaitForCurrentRun) {
|
||||
const first = promptQueueItems[0];
|
||||
|
|
@ -563,10 +698,15 @@ function startPromptQueue(
|
|||
}
|
||||
|
||||
function stopPromptQueueRun() {
|
||||
const activeTarget = promptQueueItems[Math.max(promptQueueIndex, 0)]?.target;
|
||||
const activeItem = promptQueueItems[Math.max(promptQueueIndex, 0)];
|
||||
const activeTarget = activeItem?.target;
|
||||
const shouldCancelActiveRun = Boolean(activeItem?.dispatched);
|
||||
resetPromptQueue();
|
||||
if (!shouldCancelActiveRun) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
activeTarget?.cancel();
|
||||
activeTarget.cancel();
|
||||
} catch {
|
||||
// The active run may have already ended.
|
||||
}
|
||||
|
|
@ -1018,6 +1158,29 @@ const ThreadComposerDock: FC<{
|
|||
onHeightChange?: (height: number | null) => void;
|
||||
}> = ({ disabled, threadId, onHeightChange }) => {
|
||||
const { overlay } = useGeneratedImageOverlay();
|
||||
const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
|
||||
const threadListItemId = useAuiState(
|
||||
({ threadListItem }) => threadListItem.id,
|
||||
);
|
||||
const threadListItemRemoteId = useAuiState(
|
||||
({ threadListItem }) => threadListItem.remoteId,
|
||||
);
|
||||
const promptQueueThreadIds = compactIds([
|
||||
threadListItemId,
|
||||
threadListItemRemoteId,
|
||||
threadId,
|
||||
activeThreadId,
|
||||
]);
|
||||
const queueVisible = usePromptQueueUI(
|
||||
(s) =>
|
||||
Boolean(findPromptQueueEntry(s, promptQueueThreadIds)) &&
|
||||
s.items.some((item) =>
|
||||
promptQueueItemMatchesThreadIds(item, promptQueueThreadIds),
|
||||
),
|
||||
);
|
||||
const showModelDisclaimer = useChatPreferencesStore(
|
||||
(s) => s.showModelDisclaimer,
|
||||
);
|
||||
|
||||
// Report dock height so the viewport reserves matching scroll space when
|
||||
// attachments or multiline input grow the composer.
|
||||
|
|
@ -1046,7 +1209,12 @@ const ThreadComposerDock: FC<{
|
|||
{/* Fade the top edge so scrolling text is not cut off by a hard line. */}
|
||||
<div
|
||||
aria-hidden={true}
|
||||
className="absolute inset-x-0 bottom-0 top-[10px] bg-gradient-to-t from-background from-[calc(100%_-_28px)] to-transparent"
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 bg-gradient-to-t from-background from-[calc(100%_-_28px)] to-transparent",
|
||||
queueVisible
|
||||
? "h-32 backdrop-blur-[1px] [mask-image:linear-gradient(to_top,black_0%,black_58%,transparent_100%)]"
|
||||
: "top-[10px]",
|
||||
)}
|
||||
/>
|
||||
<div className="relative px-5 pb-2">
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-(--thread-max-width)">
|
||||
|
|
@ -1056,9 +1224,11 @@ const ThreadComposerDock: FC<{
|
|||
menuSide="top"
|
||||
/>
|
||||
</div>
|
||||
<p className="composer-footer-note">
|
||||
LLMs can make mistakes. Double-check responses.
|
||||
</p>
|
||||
{showModelDisclaimer && (
|
||||
<p className="composer-footer-note">
|
||||
LLMs can make mistakes. Double-check responses.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1743,14 +1913,15 @@ const Composer: FC<{
|
|||
aria-disabled={disabled}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<PromptQueueStack queueThreadIds={promptQueueThreadIds} />
|
||||
{isTauri ? (
|
||||
// Phase 1 native model owns Tauri local-path drops. Restore browser
|
||||
// attachment drops in Tauri once Phase 1d adds token bridging.
|
||||
<div className="aui-composer-attachment-dropzone unsloth-composer-surface">
|
||||
<div className="aui-composer-attachment-dropzone unsloth-composer-surface relative z-10">
|
||||
{composerContent}
|
||||
</div>
|
||||
) : (
|
||||
<ComposerPrimitive.AttachmentDropzone className="group/dropzone aui-composer-attachment-dropzone unsloth-composer-surface relative">
|
||||
<ComposerPrimitive.AttachmentDropzone className="group/dropzone aui-composer-attachment-dropzone unsloth-composer-surface relative z-10">
|
||||
{composerContent}
|
||||
{/* Gemini-style drop affordance, shown while a file is dragged over
|
||||
the composer. Absolute + pointer-events-none so the outline adds
|
||||
|
|
@ -2991,6 +3162,184 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
|
|||
);
|
||||
};
|
||||
|
||||
function promptQueueStatusLabel(status: PromptQueueUIItemStatus) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "Running now";
|
||||
case "waiting":
|
||||
return "Waiting";
|
||||
case "next":
|
||||
return "Next";
|
||||
case "queued":
|
||||
return "Queued";
|
||||
default: {
|
||||
const exhaustiveStatus: never = status;
|
||||
throw new Error(`Unhandled prompt queue status: ${exhaustiveStatus}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PromptQueueStack: FC<{ queueThreadIds: string[] }> = ({
|
||||
queueThreadIds,
|
||||
}) => {
|
||||
const queueEntry = usePromptQueueUI((s) =>
|
||||
findPromptQueueEntry(s, queueThreadIds),
|
||||
);
|
||||
const items = usePromptQueueUI((s) => s.items);
|
||||
const [editingItemId, setEditingItemId] = useState<string | null>(null);
|
||||
const [draftPrompt, setDraftPrompt] = useState("");
|
||||
const editInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const visibleItems = items.filter((item) =>
|
||||
promptQueueItemMatchesThreadIds(item, queueThreadIds),
|
||||
);
|
||||
const editingItem = visibleItems.find((item) => item.id === editingItemId);
|
||||
const editingItemCanEdit = editingItem?.canEdit ?? false;
|
||||
const activeEditingItemId = editingItem ? editingItemId : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeEditingItemId) {
|
||||
return;
|
||||
}
|
||||
editInputRef.current?.focus();
|
||||
editInputRef.current?.select();
|
||||
}, [activeEditingItemId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingItemId || editingItemCanEdit) {
|
||||
return;
|
||||
}
|
||||
setEditingItemId(null);
|
||||
setDraftPrompt("");
|
||||
}, [editingItemCanEdit, editingItemId]);
|
||||
|
||||
if (!queueEntry || visibleItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { current, total } = queueEntry;
|
||||
|
||||
const startEditing = (item: PromptQueueUIItem) => {
|
||||
if (!item.canEdit) {
|
||||
return;
|
||||
}
|
||||
setEditingItemId(item.id);
|
||||
setDraftPrompt(item.prompt);
|
||||
};
|
||||
const saveEditing = () => {
|
||||
if (!activeEditingItemId) {
|
||||
return;
|
||||
}
|
||||
if (editPromptQueueItem(activeEditingItemId, draftPrompt)) {
|
||||
setEditingItemId(null);
|
||||
setDraftPrompt("");
|
||||
}
|
||||
};
|
||||
const cancelEditing = () => {
|
||||
setEditingItemId(null);
|
||||
setDraftPrompt("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative z-0 mx-7 mb-[-8px] max-h-[28vh] overflow-y-auto rounded-t-[18px] rounded-b-none border border-border/45 bg-background/90 px-5 py-2 text-muted-foreground shadow-none backdrop-blur-md dark:bg-card/85"
|
||||
aria-label={`Prompt queue, ${current} of ${total}`}
|
||||
>
|
||||
<div className="divide-y divide-border/25">
|
||||
{visibleItems.map((item, visibleIndex) => {
|
||||
const isEditing = item.id === activeEditingItemId;
|
||||
const visiblePosition = visibleIndex + 1;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn("min-h-10", isEditing ? "h-auto" : "h-10")}
|
||||
aria-label={`${promptQueueStatusLabel(item.status)} prompt ${visiblePosition} of ${visibleItems.length}: ${item.prompt}`}
|
||||
>
|
||||
{isEditing ? (
|
||||
<div className="grid min-h-10 grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2.5 py-1">
|
||||
<textarea
|
||||
ref={editInputRef}
|
||||
value={draftPrompt}
|
||||
rows={1}
|
||||
onChange={(event) =>
|
||||
setDraftPrompt(event.currentTarget.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
saveEditing();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelEditing();
|
||||
}
|
||||
}}
|
||||
className="max-h-20 min-h-8 min-w-0 resize-none rounded-md border border-border/45 bg-transparent px-2 py-1.5 text-sm leading-5 text-foreground outline-none transition-colors focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35"
|
||||
aria-label={`Edit queued prompt ${visiblePosition}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground"
|
||||
onClick={cancelEditing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={draftPrompt.trim().length === 0}
|
||||
onClick={saveEditing}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-10 grid-cols-[minmax(0,1fr)_auto_2rem] items-center gap-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<CornerDownRightIcon className="size-4 shrink-0 text-muted-foreground/50" />
|
||||
<div className="truncate text-sm text-muted-foreground">
|
||||
{item.prompt}
|
||||
</div>
|
||||
</div>
|
||||
{item.canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-[5.25rem] justify-center gap-1 px-0 text-sm font-normal text-muted-foreground/80 hover:text-foreground"
|
||||
onClick={() => startEditing(item)}
|
||||
>
|
||||
<HugeiconsIcon icon={Edit03Icon} strokeWidth={2} />
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
<TooltipIconButton
|
||||
tooltip="Remove from queue"
|
||||
side="bottom"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="col-start-3 size-7 justify-self-center text-muted-foreground/70 hover:text-destructive"
|
||||
aria-label={`Remove queued prompt ${visiblePosition}`}
|
||||
disabled={!item.canRemove}
|
||||
onClick={() => removePromptQueueItem(item.id)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} />
|
||||
</TooltipIconButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ComposerRightControls: FC<{
|
||||
disabled?: boolean;
|
||||
queueDisabled?: boolean;
|
||||
|
|
@ -3014,8 +3363,6 @@ const ComposerRightControls: FC<{
|
|||
findPromptQueueEntry(s, queueThreadIds),
|
||||
);
|
||||
const isQueueRunning = Boolean(queueEntry);
|
||||
const queueCurrent = queueEntry?.current ?? 0;
|
||||
const queueTotal = queueEntry?.total ?? 0;
|
||||
return (
|
||||
<div className="aui-composer-action-wrapper flex shrink-0 items-center gap-1.5">
|
||||
<ReasoningToggle side={menuSide} />
|
||||
|
|
@ -3043,14 +3390,6 @@ const ComposerRightControls: FC<{
|
|||
</TooltipIconButton>
|
||||
</ComposerPrimitive.StopDictation>
|
||||
</ComposerPrimitive.If>
|
||||
{isQueueRunning ? (
|
||||
<span
|
||||
className="ml-1 flex h-7 items-center rounded-full bg-primary/10 px-2 text-[11px] font-semibold text-primary"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="tabular-nums">Queue {queueCurrent}/{queueTotal}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<AuiIf condition={({ thread }) => !thread.isRunning && !isQueueRunning}>
|
||||
<ComposerPrimitive.Send asChild={true}>
|
||||
<TooltipIconButton
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ import {
|
|||
loadOptionalBool,
|
||||
useChatRuntimeStore,
|
||||
} from "./stores/chat-runtime-store";
|
||||
import { useChatPreferencesStore } from "./stores/chat-preferences-store";
|
||||
import { useExternalProvidersStore } from "./stores/external-providers-store";
|
||||
import { buildChatTourSteps } from "./tour";
|
||||
import { ArtifactSurface } from "./artifacts/artifact-surface";
|
||||
|
|
@ -492,6 +493,9 @@ function CompareShell({
|
|||
children: ReactElement;
|
||||
composer: ReactElement;
|
||||
}): ReactElement {
|
||||
const showModelDisclaimer = useChatPreferencesStore(
|
||||
(s) => s.showModelDisclaimer,
|
||||
);
|
||||
return (
|
||||
<CompareHandlesProvider handlesRef={handlesRef}>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col">
|
||||
|
|
@ -503,9 +507,11 @@ function CompareShell({
|
|||
</div>
|
||||
<div className="shrink-0 bg-background pl-5 pr-5 md:pr-[30px] pb-2 pt-1">
|
||||
<div className="mx-auto w-full max-w-[48rem]">{composer}</div>
|
||||
<p className="composer-footer-note">
|
||||
LLMs can make mistakes. Double-check responses.
|
||||
</p>
|
||||
{showModelDisclaimer && (
|
||||
<p className="composer-footer-note">
|
||||
LLMs can make mistakes. Double-check responses.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CompareHandlesProvider>
|
||||
|
|
|
|||
|
|
@ -1005,12 +1005,16 @@ export function ChatSettingsPanel({
|
|||
speculativeType === "mtp+ngram") && (
|
||||
<div className="rounded-lg bg-amber-500/[0.08] px-3 py-2 text-[12px] leading-[1.4] text-nav-fg/80">
|
||||
<p>
|
||||
{specFallbackReason === "runtime_error"
|
||||
{specFallbackReason === "mla_mtp_disabled"
|
||||
? "MTP is disabled by default for this model architecture because it currently runs slower than standard decoding. Select MTP above to force it."
|
||||
: specFallbackReason === "runtime_error"
|
||||
? "MTP could not start for this model on the installed llama.cpp build, so it is running without speculative decoding."
|
||||
: "MTP is not available in the installed llama.cpp build, so this model is running without it." +
|
||||
(llamaUpdateStatus?.update_available
|
||||
? " Update llama.cpp to enable it."
|
||||
: "")}
|
||||
: specFallbackReason === "drafter_not_found"
|
||||
? "This model supports MTP, but its drafter file could not be downloaded, so MTP is off and it falls back to n-gram speculative decoding where the llama.cpp build supports it. Check your network connection or Hugging Face access, then reload the model to retry the drafter."
|
||||
: "MTP is not available in the installed llama.cpp build, so this model is running without it." +
|
||||
(llamaUpdateStatus?.update_available
|
||||
? " Update llama.cpp to enable it."
|
||||
: "")}
|
||||
</p>
|
||||
{mtpUpdatable && llamaUpdateStatus?.update_available && (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ 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.
|
||||
// showModelDisclaimer: when off, hide the "LLMs can make mistakes" footer note.
|
||||
export interface ChatPreferencesState {
|
||||
confirmDeleteChats: boolean;
|
||||
setConfirmDeleteChats: (value: boolean) => void;
|
||||
showModelDisclaimer: boolean;
|
||||
setShowModelDisclaimer: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const useChatPreferencesStore = create<ChatPreferencesState>()(
|
||||
|
|
@ -17,6 +20,9 @@ export const useChatPreferencesStore = create<ChatPreferencesState>()(
|
|||
confirmDeleteChats: true,
|
||||
setConfirmDeleteChats: (confirmDeleteChats) =>
|
||||
set({ confirmDeleteChats }),
|
||||
showModelDisclaimer: true,
|
||||
setShowModelDisclaimer: (showModelDisclaimer) =>
|
||||
set({ showModelDisclaimer }),
|
||||
}),
|
||||
{
|
||||
name: "unsloth_chat_preferences",
|
||||
|
|
@ -25,6 +31,7 @@ export const useChatPreferencesStore = create<ChatPreferencesState>()(
|
|||
return {
|
||||
...current,
|
||||
confirmDeleteChats: saved?.confirmDeleteChats ?? true,
|
||||
showModelDisclaimer: saved?.showModelDisclaimer ?? true,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -196,7 +196,10 @@ export interface InferenceStatusResponse {
|
|||
/**
|
||||
* Why MTP was disabled on the loaded model despite being requested.
|
||||
* "binary_no_mtp" / "binary_outdated" -> updating llama.cpp would re-enable
|
||||
* it; "runtime_error" -> the current build could not run it. Null otherwise.
|
||||
* it; "runtime_error" -> the current build could not run it;
|
||||
* "mla_mtp_disabled" -> an Auto-mode policy downgrade for MLA models
|
||||
* (GLM-5.2 et al.) whose llama.cpp MTP path is slower than no speculation
|
||||
* (updating won't help; choose MTP in Settings to force it). Null otherwise.
|
||||
*/
|
||||
spec_fallback_reason?: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { useEffect, useRef, useState } from "react";
|
|||
import { useShallow } from "zustand/react/shallow";
|
||||
import { EXPORT_METHODS, type ExportMethod } from "../constants";
|
||||
import type { ExportLogEntry } from "../api/export-api";
|
||||
import { getExportLogLineClass } from "../lib/log-style";
|
||||
import {
|
||||
selectExportProgressPercent,
|
||||
useExportRuntimeStore,
|
||||
|
|
@ -520,22 +521,16 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
|
|||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-words">
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{run.logLines.map((entry, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={
|
||||
entry.stream === "stderr"
|
||||
? "text-rose-300/90"
|
||||
: entry.stream === "status"
|
||||
? "text-sky-300/90"
|
||||
: ""
|
||||
}
|
||||
className={getExportLogLineClass(entry)}
|
||||
>
|
||||
{formatLogLine(entry)}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
40
studio/frontend/src/features/export/lib/log-style.ts
Normal file
40
studio/frontend/src/features/export/lib/log-style.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// 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 { ExportLogEntry } from "../api/export-api";
|
||||
|
||||
type ExportLogTone = "stdout" | "stderr" | "status" | "warning";
|
||||
|
||||
const WARNING_LINE_PATTERNS = [
|
||||
/Skipping import of cpp extensions due to incompatible torch version/i,
|
||||
/Please see GitHub issue #2919 for more info/i,
|
||||
/torch_dtype is deprecated!\s*Use dtype instead!/i,
|
||||
] as const;
|
||||
|
||||
function isWarningLine(line: string): boolean {
|
||||
return WARNING_LINE_PATTERNS.some((pattern) => pattern.test(line));
|
||||
}
|
||||
|
||||
export function getExportLogTone(entry: ExportLogEntry): ExportLogTone {
|
||||
if (entry.stream === "status") {
|
||||
return "status";
|
||||
}
|
||||
if (isWarningLine(entry.line)) {
|
||||
return "warning";
|
||||
}
|
||||
return entry.stream === "stderr" ? "stderr" : "stdout";
|
||||
}
|
||||
|
||||
export function getExportLogLineClass(entry: ExportLogEntry): string {
|
||||
const tone = getExportLogTone(entry);
|
||||
if (tone === "stderr") {
|
||||
return "text-rose-300/90";
|
||||
}
|
||||
if (tone === "status") {
|
||||
return "text-sky-300/90";
|
||||
}
|
||||
if (tone === "warning") {
|
||||
return "text-status-warning";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import { cn } from "@/lib/utils";
|
|||
import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
|
|
@ -102,13 +104,79 @@ export function CardCarousel<T>({
|
|||
[stepPx],
|
||||
);
|
||||
|
||||
// Click-and-drag panning (mouse only; touch/pen keep native scrolling).
|
||||
const drag = useRef<{ id: number; x: number; left: number; moved: boolean } | null>(
|
||||
null,
|
||||
);
|
||||
const suppressClick = useRef(false);
|
||||
|
||||
const onPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
suppressClick.current = false;
|
||||
const el = scrollerRef.current;
|
||||
if (!el || e.pointerType !== "mouse" || e.button !== 0) return;
|
||||
drag.current = { id: e.pointerId, x: e.clientX, left: el.scrollLeft, moved: false };
|
||||
}, []);
|
||||
|
||||
const onPointerMove = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const d = drag.current;
|
||||
const el = scrollerRef.current;
|
||||
if (!d || !el || e.pointerId !== d.id) return;
|
||||
// Primary button no longer held: the press ended off the scroller, so no
|
||||
// pointerup reached us. Drop the stale drag instead of scrolling on hover.
|
||||
if ((e.buttons & 1) === 0) {
|
||||
if (d.moved) el.style.scrollSnapType = "";
|
||||
drag.current = null;
|
||||
return;
|
||||
}
|
||||
const dx = e.clientX - d.x;
|
||||
// Ignore tiny moves so plain clicks still register.
|
||||
if (!d.moved && Math.abs(dx) < 5) return;
|
||||
if (!d.moved) {
|
||||
d.moved = true;
|
||||
// Snap fights the per-frame scrollLeft writes; disable it while dragging.
|
||||
el.style.scrollSnapType = "none";
|
||||
el.setPointerCapture(d.id);
|
||||
}
|
||||
el.scrollLeft = d.left - dx;
|
||||
}, []);
|
||||
|
||||
const endDrag = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const d = drag.current;
|
||||
if (!d || e.pointerId !== d.id) return;
|
||||
if (d.moved) {
|
||||
// A drag just happened: swallow the click it would fire on a card.
|
||||
suppressClick.current = true;
|
||||
const el = scrollerRef.current;
|
||||
// Restore snap so the row settles on a card after the drag.
|
||||
if (el) el.style.scrollSnapType = "";
|
||||
el?.releasePointerCapture?.(d.id);
|
||||
}
|
||||
drag.current = null;
|
||||
}, []);
|
||||
|
||||
const onClickCapture = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!suppressClick.current) return;
|
||||
suppressClick.current = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
onScroll={updateArrows}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onClickCapture={onClickCapture}
|
||||
// Stop the avatar image from starting a native drag during a pan.
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
aria-label={ariaLabel}
|
||||
className="hub-carousel flex snap-x gap-4 overflow-x-auto pb-4 pt-2"
|
||||
// px-2 + -mx-2 give card shadows room so the edge cards aren't clipped;
|
||||
// scroll-px-2 keeps snap-start aligned with the heading.
|
||||
className="hub-carousel -mx-2 flex cursor-grab snap-x scroll-px-2 gap-4 overflow-x-auto px-2 pb-4 pt-2 select-none active:cursor-grabbing"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -23,11 +23,14 @@ import {
|
|||
listScanFolders,
|
||||
removeScanFolder,
|
||||
} from "@/features/hub/inventory";
|
||||
import { openModelsDir } from "@/features/native-intents/api";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Delete02Icon,
|
||||
FileSearchIcon,
|
||||
FolderAddIcon,
|
||||
FolderExportIcon,
|
||||
FolderOpenIcon,
|
||||
FolderSearchIcon,
|
||||
PlusSignIcon,
|
||||
|
|
@ -138,6 +141,16 @@ export function OnDeviceFoldersDialog({
|
|||
[handleInventoryChanged, pending],
|
||||
);
|
||||
|
||||
// Scan folders are arbitrary paths that may be moved or deleted after they
|
||||
// were registered, so surface the command's failure as a toast.
|
||||
const handleOpen = useCallback(async (folder: ScanFolderInfo) => {
|
||||
try {
|
||||
await openModelsDir(folder.path);
|
||||
} catch (err) {
|
||||
toast.error("Couldn't open location", { description: formatError(err) });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
async (folder: ScanFolderInfo) => {
|
||||
const key = `remove:${folder.id}` as const;
|
||||
|
|
@ -333,6 +346,27 @@ export function OnDeviceFoldersDialog({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{isTauri ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Open ${folder.path}`}
|
||||
onClick={() => void handleOpen(folder)}
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={FolderExportIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-4"
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="tooltip-compact">
|
||||
Open in file manager
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@
|
|||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
width: 56px;
|
||||
width: 44px;
|
||||
opacity: 0;
|
||||
transition: opacity 240ms ease;
|
||||
}
|
||||
|
|
@ -157,7 +157,9 @@
|
|||
}
|
||||
|
||||
.hub-page .hub-carousel-fade-left {
|
||||
left: 0;
|
||||
/* -8px matches the scroller's -mx-2 bleed so the opaque edge sits on the
|
||||
clip edge and no card peeks out beside the fade. */
|
||||
left: -8px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--background),
|
||||
|
|
@ -167,7 +169,8 @@
|
|||
}
|
||||
|
||||
.hub-page .hub-carousel-fade-right {
|
||||
right: 0;
|
||||
/* Mirror of fade-left: offset by the -mx-2 bleed to reach the clip edge. */
|
||||
right: -8px;
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
var(--background),
|
||||
|
|
|
|||
|
|
@ -44,3 +44,9 @@ export async function revealPathToken(token: string): Promise<void> {
|
|||
export async function openPathToken(token: string): Promise<void> {
|
||||
return invokeNative<void>("open_path_token", { token });
|
||||
}
|
||||
|
||||
// Open a backend-resolved directory (e.g. the models/HF cache folder) in the
|
||||
// OS file manager. The Tauri command validates the path is a real directory.
|
||||
export async function openModelsDir(path: string): Promise<void> {
|
||||
return invokeNative<void>("open_models_dir", { path });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// 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 { ReactNode } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -10,22 +9,20 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@/components/ui/toggle-group";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type RagAutoInject,
|
||||
type RagMode,
|
||||
useChatRuntimeStore,
|
||||
} from "@/features/chat/stores/chat-runtime-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const MODE_LABEL: Record<RagMode, string> = {
|
||||
hybrid: "Hybrid",
|
||||
|
|
@ -92,6 +89,7 @@ function SliderRow({
|
|||
disabled={disabled}
|
||||
onValueChange={([v]) => onChange(v)}
|
||||
aria-label={label}
|
||||
className="panel-slider"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -152,6 +150,7 @@ export function RetrievalSettingsSection() {
|
|||
step={1}
|
||||
onValueChange={([value]) => setRagTopK(value)}
|
||||
aria-label="Number of passages to retrieve"
|
||||
className="panel-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -175,7 +174,9 @@ export function RetrievalSettingsSection() {
|
|||
value={ragAutoInject}
|
||||
onValueChange={(value) => {
|
||||
// Radix clears on re-click; ignore empty so one stays selected.
|
||||
if (value) setRagAutoInject(value as RagAutoInject);
|
||||
if (value) {
|
||||
setRagAutoInject(value as RagAutoInject);
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
aria-label="Auto-retrieve documents"
|
||||
|
|
|
|||
40
studio/frontend/src/features/settings/api/models-folder.ts
Normal file
40
studio/frontend/src/features/settings/api/models-folder.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// 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 { authFetch } from "@/features/auth";
|
||||
import { readFastApiError } from "@/lib/format-fastapi-error";
|
||||
|
||||
export type ModelsFolder = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
// The path is resolved once at backend startup and never changes, so cache it
|
||||
// and dedupe concurrent loads (same shape as the sibling settings loaders).
|
||||
let cachedModelsFolder: ModelsFolder | null = null;
|
||||
let inFlightModelsFolder: Promise<ModelsFolder> | null = null;
|
||||
|
||||
async function fetchModelsFolder(): Promise<ModelsFolder> {
|
||||
const res = await authFetch("/api/hub/models-folder");
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
await readFastApiError(res, "Failed to load models folder"),
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as { path: string };
|
||||
return { path: data.path };
|
||||
}
|
||||
|
||||
export async function loadModelsFolder(): Promise<ModelsFolder> {
|
||||
if (cachedModelsFolder) {
|
||||
return cachedModelsFolder;
|
||||
}
|
||||
inFlightModelsFolder ??= fetchModelsFolder()
|
||||
.then((folder) => {
|
||||
cachedModelsFolder = folder;
|
||||
return folder;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightModelsFolder = null;
|
||||
});
|
||||
return inFlightModelsFolder;
|
||||
}
|
||||
|
|
@ -187,6 +187,12 @@ export function ChatTab() {
|
|||
const setConfirmDeleteChats = useChatPreferencesStore(
|
||||
(state) => state.setConfirmDeleteChats,
|
||||
);
|
||||
const showModelDisclaimer = useChatPreferencesStore(
|
||||
(state) => state.showModelDisclaimer,
|
||||
);
|
||||
const setShowModelDisclaimer = useChatPreferencesStore(
|
||||
(state) => state.setShowModelDisclaimer,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void countAllChats().then(setCount);
|
||||
|
|
@ -310,6 +316,15 @@ export function ChatTab() {
|
|||
/>
|
||||
</SettingsRow>
|
||||
))}
|
||||
<SettingsRow
|
||||
label={t("settings.chat.modelDisclaimer")}
|
||||
description={t("settings.chat.modelDisclaimerDescription")}
|
||||
>
|
||||
<Switch
|
||||
checked={showModelDisclaimer}
|
||||
onCheckedChange={setShowModelDisclaimer}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings.chat.artifacts.title")}>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ import {
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { openModelsDir } from "@/features/native-intents/api";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { loadModelsFolder, type ModelsFolder } from "../api/models-folder";
|
||||
import { resetOnboardingDone } from "@/features/auth";
|
||||
import { useChatRuntimeStore } from "@/features/chat";
|
||||
import {
|
||||
|
|
@ -134,6 +139,7 @@ export function GeneralTab() {
|
|||
null,
|
||||
);
|
||||
const [isSavingHelperPrecache, setIsSavingHelperPrecache] = useState(false);
|
||||
const [modelsFolder, setModelsFolder] = useState<ModelsFolder | null>(null);
|
||||
|
||||
const draftRef = useRef(draftToken);
|
||||
useEffect(() => {
|
||||
|
|
@ -199,6 +205,43 @@ export function GeneralTab() {
|
|||
};
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadModelsFolder()
|
||||
.then((folder) => {
|
||||
if (cancelled) return;
|
||||
setModelsFolder(folder);
|
||||
})
|
||||
.catch(() => {
|
||||
// Non-critical: leave the row hidden if the path can't be resolved.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Desktop opens the folder in the OS file manager; the browser can't, so it
|
||||
// falls back to copying the path (which is the info users actually want).
|
||||
const handleModelsFolder = async () => {
|
||||
const folder = modelsFolder;
|
||||
if (!folder) return;
|
||||
if (isTauri) {
|
||||
try {
|
||||
await openModelsDir(folder.path);
|
||||
} catch (error) {
|
||||
toast.error(t("settings.general.storage.openError"), {
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (await copyToClipboard(folder.path)) {
|
||||
toast.success(t("settings.general.storage.copied"));
|
||||
} else {
|
||||
toast.error(t("settings.general.storage.copyError"));
|
||||
}
|
||||
};
|
||||
|
||||
const saveHelperPrecache = async (enabled: boolean) => {
|
||||
setIsSavingHelperPrecache(true);
|
||||
setHelperPrecacheError(null);
|
||||
|
|
@ -287,6 +330,33 @@ export function GeneralTab() {
|
|||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
{modelsFolder ? (
|
||||
<SettingsSection title={t("settings.general.storage.sectionTitle")}>
|
||||
<SettingsRow
|
||||
label={t("settings.general.storage.modelsFolder")}
|
||||
description={t("settings.general.storage.modelsFolderDescription")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
title={modelsFolder.path}
|
||||
className="max-w-[280px] truncate font-mono text-xs text-muted-foreground"
|
||||
>
|
||||
{modelsFolder.path}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleModelsFolder()}
|
||||
>
|
||||
{isTauri
|
||||
? t("settings.general.storage.openAction")
|
||||
: t("settings.general.storage.copyAction")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
<SettingsSection title={t("settings.general.chatDefaults")}>
|
||||
<SettingsRow
|
||||
label={t("settings.general.autoTitleNewChats")}
|
||||
|
|
|
|||
|
|
@ -136,6 +136,17 @@ export const en = {
|
|||
maxUploadSizeDescription:
|
||||
"Default is {defaultSize} MB.",
|
||||
},
|
||||
storage: {
|
||||
sectionTitle: "Storage",
|
||||
modelsFolder: "Models folder",
|
||||
modelsFolderDescription:
|
||||
"Where downloaded models are stored.",
|
||||
openAction: "Open",
|
||||
copyAction: "Copy path",
|
||||
copied: "Path copied",
|
||||
openError: "Couldn't open the folder",
|
||||
copyError: "Couldn't copy the path",
|
||||
},
|
||||
resetPreferences: {
|
||||
sectionTitle: "Danger zone",
|
||||
label: "Reset all local preferences",
|
||||
|
|
@ -197,6 +208,9 @@ export const en = {
|
|||
chat: {
|
||||
title: "Chat",
|
||||
description: "Manage chat history stored on this device.",
|
||||
modelDisclaimer: "Show model disclaimer",
|
||||
modelDisclaimerDescription:
|
||||
'Show "LLMs can make mistakes" under the chat box.',
|
||||
artifacts: {
|
||||
title: "Canvas",
|
||||
collapseHtmlBlocks: "Collapse HTML blocks",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import subprocess
|
|||
import sys
|
||||
import sysconfig
|
||||
import tempfile
|
||||
import textwrap
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -1401,6 +1402,7 @@ VERBOSE: bool = os.environ.get("UNSLOTH_VERBOSE", "0") == "1"
|
|||
# Update _TOTAL if you add/remove steps in install_python_stack().
|
||||
_STEP: int = 0
|
||||
_TOTAL: int = 0 # set at runtime in install_python_stack() based on platform
|
||||
_PROGRESS_LINE_ACTIVE: bool = False
|
||||
|
||||
# -- Paths --------------------------------------------------------------
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
|
|
@ -1486,6 +1488,7 @@ _HAS_COLOR = _stdout_supports_color()
|
|||
# 2-space indent, 15-char label (dim), then value.
|
||||
_LABEL = "deps"
|
||||
_COL = 15
|
||||
_INDENT = 2
|
||||
|
||||
|
||||
def _green(msg: str) -> str:
|
||||
|
|
@ -1517,15 +1520,38 @@ def _step(
|
|||
color_fn = None,
|
||||
) -> None:
|
||||
"""Print a single step line in the column format."""
|
||||
global _PROGRESS_LINE_ACTIVE
|
||||
if color_fn is None:
|
||||
color_fn = _green
|
||||
padded = label[:_COL]
|
||||
_safe_print(f" {_dim(padded)}{' ' * (_COL - len(padded))}{color_fn(value)}")
|
||||
plain_prefix_width = _INDENT + _COL
|
||||
prefix = f"{' ' * _INDENT}{_dim(padded)}{' ' * (_COL - len(padded))}"
|
||||
wrap_width = max(
|
||||
24,
|
||||
shutil.get_terminal_size((100, 20)).columns - plain_prefix_width,
|
||||
)
|
||||
lines = textwrap.wrap(
|
||||
value,
|
||||
width = wrap_width,
|
||||
break_long_words = False,
|
||||
break_on_hyphens = False,
|
||||
) or [""]
|
||||
if _PROGRESS_LINE_ACTIVE and not VERBOSE:
|
||||
try:
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.flush()
|
||||
except OSError:
|
||||
pass
|
||||
_PROGRESS_LINE_ACTIVE = False
|
||||
_safe_print(f"{prefix}{color_fn(lines[0])}")
|
||||
continuation_prefix = " " * plain_prefix_width
|
||||
for line in lines[1:]:
|
||||
_safe_print(f"{continuation_prefix}{color_fn(line)}")
|
||||
|
||||
|
||||
def _progress(label: str) -> None:
|
||||
"""Print an in-place progress bar aligned to the step column layout."""
|
||||
global _STEP
|
||||
global _STEP, _PROGRESS_LINE_ACTIVE
|
||||
_STEP += 1
|
||||
if VERBOSE:
|
||||
return
|
||||
|
|
@ -1537,6 +1563,7 @@ def _progress(label: str) -> None:
|
|||
try:
|
||||
sys.stdout.write(f"\r {_dim(_LABEL)}{pad}[{bar}] {_STEP:2}/{_TOTAL} {label:<20}{end}")
|
||||
sys.stdout.flush()
|
||||
_PROGRESS_LINE_ACTIVE = end == ""
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -968,6 +968,9 @@ elif [ "$_setup_amd_detected" = true ]; then
|
|||
substep "ROCm: $_setup_rocm_root"
|
||||
[ -n "$_setup_rocm_ver" ] && substep "hipconfig: $_setup_rocm_ver"
|
||||
[ -n "$_setup_mkt" ] && [ -n "$_setup_gfx" ] && substep "GPU: $_setup_mkt"
|
||||
elif [ "$(uname -s 2>/dev/null)" = "Darwin" ] && [ "$(uname -m 2>/dev/null)" = "arm64" ]; then
|
||||
# Apple Silicon: llama.cpp builds with Metal over unified memory, so not a CPU-only host.
|
||||
step "gpu" "Apple Silicon (Metal, unified memory)"
|
||||
else
|
||||
step "gpu" "none (chat-only / GGUF)" "$C_WARN"
|
||||
substep "Training and GPU inference require an NVIDIA or AMD ROCm GPU."
|
||||
|
|
|
|||
|
|
@ -321,17 +321,29 @@ pub fn get_server_logs(state: tauri::State<'_, BackendState>) -> Vec<String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Open the Unsloth Studio directory in the system file manager.
|
||||
#[tauri::command]
|
||||
pub fn open_logs_dir() -> Result<(), String> {
|
||||
let home = dirs::home_dir().ok_or("Could not determine home directory")?;
|
||||
let dir = home.join(".unsloth").join("studio");
|
||||
|
||||
if !dir.exists() {
|
||||
/// Open an existing directory in the system file manager. Validates the path
|
||||
/// up front so callers get a clean error instead of a raw OS failure.
|
||||
fn open_existing_dir(dir: &std::path::Path) -> Result<(), String> {
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("Directory does not exist: {}", dir.display()));
|
||||
}
|
||||
open::that(dir).map_err(|e| format!("Failed to open directory: {}", e))
|
||||
}
|
||||
|
||||
open::that(&dir).map_err(|e| format!("Failed to open directory: {}", e))
|
||||
/// Open the Unsloth Studio directory in the system file manager.
|
||||
#[tauri::command]
|
||||
pub fn open_logs_dir(window: tauri::WebviewWindow) -> Result<(), String> {
|
||||
crate::native_intents::ensure_main_window(&window)?;
|
||||
let home = dirs::home_dir().ok_or("Could not determine home directory")?;
|
||||
open_existing_dir(&home.join(".unsloth").join("studio"))
|
||||
}
|
||||
|
||||
/// Open a models directory (resolved by the backend, e.g. the HF cache) in the
|
||||
/// system file manager.
|
||||
#[tauri::command]
|
||||
pub fn open_models_dir(window: tauri::WebviewWindow, path: String) -> Result<(), String> {
|
||||
crate::native_intents::ensure_main_window(&window)?;
|
||||
open_existing_dir(std::path::Path::new(&path))
|
||||
}
|
||||
|
||||
/// Start the first-launch installation process.
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ fn main() {
|
|||
commands::check_health,
|
||||
commands::get_server_logs,
|
||||
commands::open_logs_dir,
|
||||
commands::open_models_dir,
|
||||
commands::start_backend_update,
|
||||
commands::start_managed_repair,
|
||||
commands::cancel_pending_elevation,
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@ fn prune_expired(inner: &mut NativeIntakeInner) {
|
|||
.retain(|intent| intent.path.expires_at_ms > now);
|
||||
}
|
||||
|
||||
fn ensure_main_window(window: &WebviewWindow) -> Result<(), String> {
|
||||
pub(crate) fn ensure_main_window(window: &WebviewWindow) -> Result<(), String> {
|
||||
if window.label() == "main" {
|
||||
Ok(())
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue