Revert "Feat/model picker per model config (#6647)"
This reverts commit 8cbdfbe355.
This commit is contained in:
parent
8cbdfbe355
commit
1c7bce427e
62 changed files with 2126 additions and 4476 deletions
|
|
@ -160,7 +160,6 @@ class CachedRepoBase(BaseModel):
|
|||
repo_id: str
|
||||
size_bytes: int = 0
|
||||
cache_path: Optional[str] = None
|
||||
last_modified: Optional[float] = None
|
||||
partial: bool = False
|
||||
partial_transport: Optional[str] = None
|
||||
inventory_id: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ from hub.services.models.common import (
|
|||
_is_checkpoint_weight_name,
|
||||
_is_gguf_filename,
|
||||
_is_main_gguf_filename,
|
||||
_is_mmproj_filename,
|
||||
_is_transformers_safetensors_weight_name,
|
||||
_local_inventory_id,
|
||||
_prefer_complete_larger,
|
||||
|
|
@ -126,34 +125,6 @@ def _repo_has_gguf_files(repo_info) -> bool:
|
|||
return _repo_gguf_size_bytes(repo_info) > 0
|
||||
|
||||
|
||||
def _blob_mtime(file_obj) -> float:
|
||||
ts = getattr(file_obj, "blob_last_modified", None)
|
||||
if isinstance(ts, (int, float)) and ts > 0:
|
||||
return float(ts)
|
||||
blob_path = getattr(file_obj, "blob_path", None)
|
||||
if blob_path:
|
||||
try:
|
||||
return float(Path(blob_path).stat().st_mtime)
|
||||
except OSError:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def _repo_gguf_last_modified(repo_info) -> float:
|
||||
latest = 0.0
|
||||
for revision in repo_info.revisions:
|
||||
for f in revision.files:
|
||||
if _is_main_gguf_filename(f.file_name):
|
||||
latest = max(latest, _blob_mtime(f))
|
||||
return latest
|
||||
|
||||
|
||||
def _repo_has_mmproj(repo_info) -> bool:
|
||||
return any(
|
||||
_is_mmproj_filename(f.file_name) for revision in repo_info.revisions for f in revision.files
|
||||
)
|
||||
|
||||
|
||||
def _cached_repo_file_name(file_obj) -> str:
|
||||
file_path = getattr(file_obj, "file_path", None)
|
||||
if file_path:
|
||||
|
|
@ -295,7 +266,6 @@ def _scan_cached_gguf() -> list[dict]:
|
|||
continue
|
||||
key = repo_id.lower()
|
||||
existing = seen_lower.get(key)
|
||||
last_modified = _repo_gguf_last_modified(repo_info)
|
||||
row = {
|
||||
"repo_id": repo_id,
|
||||
"size_bytes": max(total_size, variant_state_size),
|
||||
|
|
@ -305,9 +275,6 @@ def _scan_cached_gguf() -> list[dict]:
|
|||
# per-variant detail lives on GgufVariantDetail.
|
||||
"partial_transport": None,
|
||||
}
|
||||
last_modified = max(last_modified, (existing or {}).get("last_modified", 0.0))
|
||||
if last_modified > 0:
|
||||
row["last_modified"] = last_modified
|
||||
row.update(
|
||||
_cache_inventory_fields(
|
||||
repo_id,
|
||||
|
|
@ -316,12 +283,8 @@ def _scan_cached_gguf() -> list[dict]:
|
|||
requires_variant = True,
|
||||
)
|
||||
)
|
||||
if _repo_has_mmproj(repo_info):
|
||||
row["capabilities"]["supports_vision"] = True
|
||||
if _prefer_cache_row(row, existing):
|
||||
seen_lower[key] = row
|
||||
elif last_modified > existing.get("last_modified", 0.0):
|
||||
existing["last_modified"] = last_modified
|
||||
except Exception as e:
|
||||
repo_label = getattr(repo_info, "repo_id", "<unknown>")
|
||||
logger.warning(f"Skipping cached GGUF repo {repo_label}: {e}")
|
||||
|
|
@ -349,14 +312,13 @@ class _CachedNonGgufPayload(NamedTuple):
|
|||
size_bytes: int
|
||||
has_runnable_weights: bool
|
||||
model_format: ModelFormat
|
||||
last_modified: float
|
||||
|
||||
|
||||
def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload:
|
||||
all_weight_blobs: dict[str, tuple[int, float]] = {}
|
||||
adapter_blobs: dict[str, tuple[int, float]] = {}
|
||||
safetensors_blobs: dict[str, tuple[int, float]] = {}
|
||||
checkpoint_blobs: dict[str, tuple[int, float]] = {}
|
||||
all_weight_blobs: dict[str, int] = {}
|
||||
adapter_blobs: dict[str, int] = {}
|
||||
safetensors_blobs: dict[str, int] = {}
|
||||
checkpoint_blobs: dict[str, int] = {}
|
||||
has_config = False
|
||||
has_adapter_config = False
|
||||
has_adapter_weights = False
|
||||
|
|
@ -364,15 +326,12 @@ def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload:
|
|||
has_transformers_safetensors = False
|
||||
has_checkpoint = False
|
||||
|
||||
def _record_blob(
|
||||
target: dict[str, tuple[int, float]], file_obj, rev_id: str, file_name: str
|
||||
) -> None:
|
||||
def _record_blob(target: dict[str, int], file_obj, rev_id: str, file_name: str) -> None:
|
||||
blob_path = getattr(file_obj, "blob_path", None)
|
||||
size = int(file_obj.size_on_disk or 0)
|
||||
key = str(blob_path) if blob_path else f"{rev_id}:{file_name}"
|
||||
value = (size, _blob_mtime(file_obj))
|
||||
target[key] = value
|
||||
all_weight_blobs[key] = value
|
||||
target[key] = size
|
||||
all_weight_blobs[key] = size
|
||||
|
||||
for revision in repo_info.revisions:
|
||||
rev_id = getattr(revision, "commit_hash", None) or str(id(revision))
|
||||
|
|
@ -416,19 +375,18 @@ def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload:
|
|||
or "unknown"
|
||||
)
|
||||
if model_format == "adapter":
|
||||
selected_blobs = adapter_blobs
|
||||
size_bytes = sum(adapter_blobs.values())
|
||||
elif model_format == "safetensors":
|
||||
selected_blobs = safetensors_blobs
|
||||
size_bytes = sum(safetensors_blobs.values())
|
||||
elif model_format == "checkpoint":
|
||||
selected_blobs = checkpoint_blobs
|
||||
size_bytes = sum(checkpoint_blobs.values())
|
||||
else:
|
||||
selected_blobs = all_weight_blobs
|
||||
size_bytes = sum(all_weight_blobs.values())
|
||||
|
||||
return _CachedNonGgufPayload(
|
||||
size_bytes = sum(size for size, _mtime in selected_blobs.values()),
|
||||
size_bytes = size_bytes,
|
||||
has_runnable_weights = model_format != "unknown",
|
||||
model_format = model_format,
|
||||
last_modified = max((mtime for _size, mtime in selected_blobs.values()), default = 0.0),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -550,12 +508,6 @@ def _scan_cached_models() -> list[dict]:
|
|||
),
|
||||
**_cached_model_local_metadata(repo_path),
|
||||
}
|
||||
last_modified = max(
|
||||
payload.last_modified,
|
||||
(existing or {}).get("last_modified", 0.0),
|
||||
)
|
||||
if last_modified > 0:
|
||||
row["last_modified"] = last_modified
|
||||
row.update(
|
||||
_cache_inventory_fields(
|
||||
repo_id,
|
||||
|
|
@ -565,8 +517,6 @@ def _scan_cached_models() -> list[dict]:
|
|||
)
|
||||
if _prefer_cache_row(row, existing):
|
||||
seen_lower[key] = row
|
||||
elif last_modified > existing.get("last_modified", 0.0):
|
||||
existing["last_modified"] = last_modified
|
||||
except Exception as e:
|
||||
repo_label = getattr(repo_info, "repo_id", "<unknown>")
|
||||
logger.warning(f"Skipping cached model repo {repo_label}: {e}")
|
||||
|
|
|
|||
|
|
@ -313,7 +313,6 @@ from hub.routes import (
|
|||
inventory_router as hub_inventory_router,
|
||||
datasets_router as hub_datasets_router,
|
||||
)
|
||||
from picker.routes import templates_router as picker_templates_router
|
||||
from hub.schemas.downloads import TransportCapabilities
|
||||
from hub.utils.download_registry import (
|
||||
get_download_transport_capabilities,
|
||||
|
|
@ -746,7 +745,6 @@ _BODY_PROTECTED_PREFIXES = (
|
|||
"/v1/completions",
|
||||
"/p/",
|
||||
"/api/inference",
|
||||
"/api/picker",
|
||||
"/api/data-recipe",
|
||||
"/api/datasets",
|
||||
"/api/hub",
|
||||
|
|
@ -977,7 +975,6 @@ app.include_router(rag_router, prefix = "/api/rag", tags = ["rag"])
|
|||
app.include_router(training_history_router, prefix = "/api/train", tags = ["training-history"])
|
||||
app.include_router(hub_inventory_router, prefix = "/api/hub", tags = ["hub"])
|
||||
app.include_router(hub_datasets_router, prefix = "/api/hub/datasets", tags = ["hub"])
|
||||
app.include_router(picker_templates_router, prefix = "/api/picker", tags = ["picker"])
|
||||
|
||||
# Re-wrap client-error responses on the /v1/* surface into OpenAI/Anthropic
|
||||
# error envelopes; non-/v1 paths keep FastAPI's default {"detail": ...} shape.
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
from .templates import router as templates_router
|
||||
|
||||
__all__ = ["templates_router"]
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Query
|
||||
|
||||
from auth.authentication import get_current_subject
|
||||
from hub.dependencies import get_hf_token
|
||||
|
||||
from ..schemas import (
|
||||
ModelTemplateResponse,
|
||||
ValidateChatTemplateRequest,
|
||||
ValidateChatTemplateResponse,
|
||||
)
|
||||
from ..service import read_default_chat_template, validate_chat_template
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/validate-chat-template", response_model = ValidateChatTemplateResponse)
|
||||
async def validate_chat_template_route(
|
||||
body: ValidateChatTemplateRequest = Body(...),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
) -> ValidateChatTemplateResponse:
|
||||
return await asyncio.to_thread(validate_chat_template, body.template)
|
||||
|
||||
|
||||
@router.get("/chat-template/{model_name:path}", response_model = ModelTemplateResponse)
|
||||
async def get_default_chat_template_route(
|
||||
model_name: str,
|
||||
gguf_variant: Optional[str] = Query(None),
|
||||
hf_token: Optional[str] = Depends(get_hf_token),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
) -> ModelTemplateResponse:
|
||||
template = await asyncio.to_thread(
|
||||
read_default_chat_template, model_name, hf_token, gguf_variant
|
||||
)
|
||||
return ModelTemplateResponse(model_name = model_name, chat_template = template)
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
# Mirror the frontend's 64 KiB chat-template contract (per-model-config.ts) at
|
||||
# the API boundary so a direct caller cannot make Jinja parse an oversized
|
||||
# template. MaxBodyMiddleware only caps the whole request body, not this field.
|
||||
MAX_CHAT_TEMPLATE_BYTES = 65_536
|
||||
|
||||
|
||||
class ValidateChatTemplateRequest(BaseModel):
|
||||
template: str = Field(default = "")
|
||||
|
||||
@field_validator("template")
|
||||
@classmethod
|
||||
def _enforce_template_size(cls, value: str) -> str:
|
||||
if len(value.encode("utf-8")) > MAX_CHAT_TEMPLATE_BYTES:
|
||||
raise ValueError(f"Chat template exceeds the {MAX_CHAT_TEMPLATE_BYTES}-byte limit.")
|
||||
return value
|
||||
|
||||
|
||||
class ValidateChatTemplateResponse(BaseModel):
|
||||
valid: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ModelTemplateResponse(BaseModel):
|
||||
model_name: str
|
||||
chat_template: Optional[str] = None
|
||||
|
|
@ -1,361 +0,0 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hub.services.models.folder_browser import (
|
||||
_build_browse_allowlist,
|
||||
_is_path_inside_allowlist,
|
||||
)
|
||||
from hub.utils.gguf import iter_hf_cache_snapshots
|
||||
from utils.models.gguf_metadata import read_gguf_chat_template
|
||||
from utils.models.model_config import (
|
||||
_extract_quant_label,
|
||||
_is_big_endian_gguf_path,
|
||||
_is_mmproj,
|
||||
_is_mtp_drafter,
|
||||
)
|
||||
from utils.paths.path_utils import (
|
||||
is_local_path,
|
||||
normalize_path,
|
||||
resolve_cached_repo_id_case,
|
||||
)
|
||||
|
||||
from .schemas import ValidateChatTemplateResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_VALID_REPO_ID = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
def _is_valid_repo_id(repo_id: str) -> bool:
|
||||
return bool(_VALID_REPO_ID.fullmatch(repo_id))
|
||||
|
||||
|
||||
_TOKENIZER_CONFIG_PATHS = ("tokenizer_config.json", "LLM/tokenizer_config.json")
|
||||
_JINJA_TEMPLATE_PATHS = ("chat_template.jinja", "LLM/chat_template.jinja")
|
||||
_PROCESSOR_TEMPLATE_PATHS = ("chat_template.json", "LLM/chat_template.json")
|
||||
|
||||
|
||||
def _leaf_inside_allowlist(path: Path, allow_roots: Optional[list[Path]]) -> bool:
|
||||
# Block symlinked children from escaping the validated directory
|
||||
# (realpath-checked). None = trusted caller (HF cache / remote download).
|
||||
return allow_roots is None or _is_path_inside_allowlist(path, allow_roots)
|
||||
|
||||
|
||||
def validate_chat_template(template: str) -> ValidateChatTemplateResponse:
|
||||
text = (template or "").strip()
|
||||
if not text:
|
||||
return ValidateChatTemplateResponse(valid = True, error = None)
|
||||
# Import Jinja lazily: it is optional at runtime (e.g. GGUF-only installs),
|
||||
# so a missing dependency must not crash API startup through this module.
|
||||
try:
|
||||
from jinja2 import TemplateError
|
||||
from jinja2.ext import Extension
|
||||
from jinja2.sandbox import ImmutableSandboxedEnvironment
|
||||
except ImportError:
|
||||
return ValidateChatTemplateResponse(valid = True, error = None)
|
||||
|
||||
class _GenerationTag(Extension):
|
||||
# Accept Transformers' {% generation %}...{% endgeneration %} assistant
|
||||
# mask tag so a pasted HF chat template validates (we only parse it).
|
||||
tags = {"generation"}
|
||||
|
||||
def parse(self, parser):
|
||||
next(parser.stream)
|
||||
return parser.parse_statements(["name:endgeneration"], drop_needle = True)
|
||||
|
||||
try:
|
||||
env = ImmutableSandboxedEnvironment(
|
||||
trim_blocks = True,
|
||||
lstrip_blocks = True,
|
||||
extensions = ["jinja2.ext.loopcontrols", _GenerationTag],
|
||||
)
|
||||
env.parse(text)
|
||||
return ValidateChatTemplateResponse(valid = True, error = None)
|
||||
except TemplateError as exc:
|
||||
message = getattr(exc, "message", None) or str(exc)
|
||||
lineno = getattr(exc, "lineno", None)
|
||||
if lineno:
|
||||
message = f"Line {lineno}: {message}"
|
||||
return ValidateChatTemplateResponse(valid = False, error = message)
|
||||
except Exception as exc:
|
||||
return ValidateChatTemplateResponse(valid = False, error = str(exc))
|
||||
|
||||
|
||||
def _chat_template_from_tokenizer_config(config: dict) -> Optional[str]:
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
raw = config.get("chat_template")
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return raw
|
||||
if isinstance(raw, list):
|
||||
fallback: Optional[str] = None
|
||||
for entry in raw:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
template = entry.get("template")
|
||||
if not isinstance(template, str):
|
||||
continue
|
||||
if entry.get("name") == "default":
|
||||
return template
|
||||
if fallback is None:
|
||||
fallback = template
|
||||
return fallback
|
||||
return None
|
||||
|
||||
|
||||
def _chat_template_from_jinja_file(
|
||||
dir_path: Path, allow_roots: Optional[list[Path]] = None
|
||||
) -> Optional[str]:
|
||||
for rel in _JINJA_TEMPLATE_PATHS:
|
||||
template_file = dir_path / rel
|
||||
if not template_file.exists() or not _leaf_inside_allowlist(template_file, allow_roots):
|
||||
continue
|
||||
try:
|
||||
template = template_file.read_text(encoding = "utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
if template.strip():
|
||||
return template
|
||||
return None
|
||||
|
||||
|
||||
def _chat_template_from_processor_payload(payload: object) -> Optional[str]:
|
||||
# processor chat_template.json may be the template string itself or a
|
||||
# {name: template} map, not only a tokenizer_config-shaped object.
|
||||
if isinstance(payload, str):
|
||||
return payload if payload.strip() else None
|
||||
template = _chat_template_from_tokenizer_config(payload) # type: ignore[arg-type]
|
||||
if template:
|
||||
return template
|
||||
if isinstance(payload, dict):
|
||||
# Named-template map: prefer "default", else the first non-empty entry
|
||||
# (mirrors the tokenizer-config list fallback).
|
||||
default = payload.get("default")
|
||||
if isinstance(default, str) and default.strip():
|
||||
return default
|
||||
for value in payload.values():
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _chat_template_from_processor_json(
|
||||
dir_path: Path, allow_roots: Optional[list[Path]] = None
|
||||
) -> Optional[str]:
|
||||
for rel in _PROCESSOR_TEMPLATE_PATHS:
|
||||
config_file = dir_path / rel
|
||||
if not config_file.exists() or not _leaf_inside_allowlist(config_file, allow_roots):
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(config_file.read_text(encoding = "utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
template = _chat_template_from_processor_payload(payload)
|
||||
if template:
|
||||
return template
|
||||
return None
|
||||
|
||||
|
||||
def _chat_template_from_tokenizer_dir(
|
||||
dir_path: Path, allow_roots: Optional[list[Path]] = None
|
||||
) -> Optional[str]:
|
||||
jinja = _chat_template_from_jinja_file(dir_path, allow_roots)
|
||||
if jinja:
|
||||
return jinja
|
||||
for rel in _TOKENIZER_CONFIG_PATHS:
|
||||
config_file = dir_path / rel
|
||||
if not config_file.exists() or not _leaf_inside_allowlist(config_file, allow_roots):
|
||||
continue
|
||||
try:
|
||||
config = json.loads(config_file.read_text(encoding = "utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
template = _chat_template_from_tokenizer_config(config)
|
||||
if template:
|
||||
return template
|
||||
return _chat_template_from_processor_json(dir_path, allow_roots)
|
||||
|
||||
|
||||
_GGUF_SCAN_MAX_DEPTH = 2
|
||||
|
||||
|
||||
def _iter_ggufs(dir_path: Path) -> list[Path]:
|
||||
if dir_path == dir_path.parent:
|
||||
return []
|
||||
root = str(dir_path)
|
||||
found: list[Path] = []
|
||||
for current, dirs, files in os.walk(root, followlinks = False):
|
||||
rel = os.path.relpath(current, root)
|
||||
depth = 0 if rel == os.curdir else rel.count(os.sep) + 1
|
||||
if depth >= _GGUF_SCAN_MAX_DEPTH:
|
||||
dirs[:] = []
|
||||
for name in files:
|
||||
if not name.lower().endswith(".gguf") or _is_mmproj(name):
|
||||
continue
|
||||
path = Path(current) / name
|
||||
try:
|
||||
rel = path.relative_to(dir_path).as_posix()
|
||||
except ValueError:
|
||||
rel = name
|
||||
quant = _extract_quant_label(rel)
|
||||
if _is_mtp_drafter(rel) or _is_big_endian_gguf_path(rel, quant):
|
||||
continue
|
||||
found.append(path)
|
||||
return found
|
||||
|
||||
|
||||
def _variant_matches(relative_path: str, needle: str) -> bool:
|
||||
quant = _extract_quant_label(relative_path).lower()
|
||||
if quant == needle:
|
||||
return True
|
||||
prefix = f"{needle}-"
|
||||
if not quant.startswith(prefix):
|
||||
return False
|
||||
suffix = quant[len(prefix) :]
|
||||
if not suffix.endswith("bpw"):
|
||||
return False
|
||||
value = suffix[:-3]
|
||||
return bool(value) and value.replace(".", "", 1).isdigit()
|
||||
|
||||
|
||||
def _find_gguf_in_dir(dir_path: Path, gguf_variant: Optional[str]) -> Optional[Path]:
|
||||
try:
|
||||
ggufs = sorted(_iter_ggufs(dir_path))
|
||||
except OSError:
|
||||
return None
|
||||
if not ggufs:
|
||||
return None
|
||||
needle = (gguf_variant or "").strip().lower()
|
||||
if needle:
|
||||
for path in ggufs:
|
||||
try:
|
||||
relative = path.relative_to(dir_path).as_posix()
|
||||
except ValueError:
|
||||
relative = path.name
|
||||
if _variant_matches(relative, needle):
|
||||
return path
|
||||
return None
|
||||
try:
|
||||
return max(ggufs, key = lambda path: path.stat().st_size)
|
||||
except OSError:
|
||||
return ggufs[0]
|
||||
|
||||
|
||||
def _chat_template_from_dir(
|
||||
dir_path: Path,
|
||||
gguf_variant: Optional[str] = None,
|
||||
allow_roots: Optional[list[Path]] = None,
|
||||
) -> Optional[str]:
|
||||
def from_gguf() -> Optional[str]:
|
||||
gguf = _find_gguf_in_dir(dir_path, gguf_variant)
|
||||
if gguf is None or not _leaf_inside_allowlist(gguf, allow_roots):
|
||||
return None
|
||||
return read_gguf_chat_template(str(gguf))
|
||||
|
||||
# Sidecar tokenizer files (chat_template.jinja / tokenizer_config.json) are
|
||||
# the model author's maintained template and supersede the GGUF's embedded
|
||||
# copy, which can be stale. The variant only selects which GGUF to fall back
|
||||
# to, so keep tokenizer-first precedence whether or not a variant is given.
|
||||
return _chat_template_from_tokenizer_dir(dir_path, allow_roots) or from_gguf()
|
||||
|
||||
|
||||
def read_default_chat_template(
|
||||
model_name: str,
|
||||
hf_token: Optional[str] = None,
|
||||
gguf_variant: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
if not isinstance(model_name, str) or not model_name.strip():
|
||||
return None
|
||||
name = model_name.strip()
|
||||
|
||||
if is_local_path(name):
|
||||
try:
|
||||
target = Path(normalize_path(name)).expanduser()
|
||||
allow_roots = _build_browse_allowlist()
|
||||
if not _is_path_inside_allowlist(target, allow_roots):
|
||||
logger.debug("Refused chat template read outside allowed folders: %s", name)
|
||||
return None
|
||||
if name.lower().endswith(".gguf"):
|
||||
# Prefer a maintained sidecar template (chat_template.jinja /
|
||||
# tokenizer_config.json) next to the file over the GGUF's embedded
|
||||
# copy, matching the tokenizer-first precedence used for directory
|
||||
# and variant selections.
|
||||
sidecar = _chat_template_from_tokenizer_dir(target.parent, allow_roots)
|
||||
if sidecar:
|
||||
return sidecar
|
||||
return read_gguf_chat_template(str(target))
|
||||
return _chat_template_from_dir(target, gguf_variant, allow_roots)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not read local chat template for %s: %s", name, exc)
|
||||
return None
|
||||
|
||||
if not _is_valid_repo_id(name):
|
||||
return None
|
||||
|
||||
resolved = resolve_cached_repo_id_case(name)
|
||||
|
||||
try:
|
||||
# Resolve within each cached revision, newest first. A revision's
|
||||
# maintained sidecar (chat_template.jinja / tokenizer_config.json)
|
||||
# supersedes its own embedded GGUF copy, but a newer revision must not be
|
||||
# overridden by an older revision's sidecar, so precedence stays
|
||||
# per-snapshot rather than searching all sidecars globally first.
|
||||
for snapshot in iter_hf_cache_snapshots(resolved):
|
||||
template = _chat_template_from_dir(snapshot, gguf_variant)
|
||||
if template:
|
||||
return template
|
||||
except Exception as exc:
|
||||
logger.debug("Could not read cached chat template for %s: %s", resolved, exc)
|
||||
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
def _download_text(rel: str) -> Optional[str]:
|
||||
try:
|
||||
path = hf_hub_download(resolved, rel, token = hf_token)
|
||||
return Path(path).read_text(encoding = "utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
for rel in _JINJA_TEMPLATE_PATHS:
|
||||
template = _download_text(rel)
|
||||
if template and template.strip():
|
||||
return template
|
||||
|
||||
for rel in _TOKENIZER_CONFIG_PATHS:
|
||||
raw = _download_text(rel)
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
config = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
template = _chat_template_from_tokenizer_config(config)
|
||||
if template:
|
||||
return template
|
||||
|
||||
for rel in _PROCESSOR_TEMPLATE_PATHS:
|
||||
raw = _download_text(rel)
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
template = _chat_template_from_processor_payload(payload)
|
||||
if template:
|
||||
return template
|
||||
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.debug("Could not fetch chat template for %s: %s", resolved, exc)
|
||||
return None
|
||||
|
|
@ -314,7 +314,6 @@ def test_cached_model_scan_keeps_local_safetensors_repo(monkeypatch, tmp_path):
|
|||
file_name = "model.safetensors",
|
||||
size_on_disk = 100,
|
||||
blob_path = str(repo_path / "blobs" / "modelsha"),
|
||||
blob_last_modified = 3_000.0,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
|
@ -337,51 +336,6 @@ def test_cached_model_scan_keeps_local_safetensors_repo(monkeypatch, tmp_path):
|
|||
assert rows[0]["repo_id"] == "Org/SafeTensorRepo"
|
||||
assert rows[0]["model_format"] == "safetensors"
|
||||
assert rows[0]["size_bytes"] == 100
|
||||
assert rows[0]["last_modified"] == 3_000.0
|
||||
|
||||
|
||||
def test_cached_gguf_scan_keeps_download_timestamp(monkeypatch, tmp_path):
|
||||
repo_path = tmp_path / "models--Org--GgufRepo"
|
||||
repo = SimpleNamespace(
|
||||
repo_id = "Org/GgufRepo",
|
||||
repo_type = "model",
|
||||
repo_path = repo_path,
|
||||
revisions = [
|
||||
SimpleNamespace(
|
||||
files = [
|
||||
SimpleNamespace(
|
||||
file_name = "model-Q4_K_M.gguf",
|
||||
size_on_disk = 100,
|
||||
blob_path = None,
|
||||
blob_last_modified = 5_000.0,
|
||||
),
|
||||
]
|
||||
)
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
CI,
|
||||
"all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [repo])],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
CI.hf_cache_scan,
|
||||
"is_gguf_repo_partial",
|
||||
lambda *args, **kwargs: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
CI,
|
||||
"_gguf_variant_state_summary",
|
||||
lambda _repo_id: (False, 0),
|
||||
)
|
||||
|
||||
rows = CI._scan_cached_gguf()
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["repo_id"] == "Org/GgufRepo"
|
||||
assert rows[0]["model_format"] == "gguf"
|
||||
assert rows[0]["size_bytes"] == 100
|
||||
assert rows[0]["last_modified"] == 5_000.0
|
||||
|
||||
|
||||
# ── hf_hub_download_with_xet_fallback force_download bypass (X2/F2) ───
|
||||
|
|
|
|||
|
|
@ -1,162 +0,0 @@
|
|||
# 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 json
|
||||
|
||||
from picker.service import (
|
||||
_chat_template_from_dir,
|
||||
_chat_template_from_tokenizer_config,
|
||||
_chat_template_from_tokenizer_dir,
|
||||
_find_gguf_in_dir,
|
||||
_iter_ggufs,
|
||||
read_default_chat_template,
|
||||
validate_chat_template,
|
||||
)
|
||||
|
||||
|
||||
def test_iter_ggufs_skips_gguf_companions(tmp_path):
|
||||
mtp_dir = tmp_path / "MTP"
|
||||
mtp_dir.mkdir()
|
||||
main = tmp_path / "model-Q8_0.gguf"
|
||||
main.write_bytes(b"")
|
||||
(tmp_path / "mmproj-F16.gguf").write_bytes(b"")
|
||||
(tmp_path / "mtp-model-Q8_0.gguf").write_bytes(b"")
|
||||
(mtp_dir / "model-Q8_0-MTP.gguf").write_bytes(b"")
|
||||
(tmp_path / "model-Q8_0-be.gguf").write_bytes(b"")
|
||||
|
||||
assert _iter_ggufs(tmp_path) == [main]
|
||||
|
||||
|
||||
def test_find_gguf_in_dir_matches_quant_label(tmp_path):
|
||||
mtp_dir = tmp_path / "MTP"
|
||||
mtp_dir.mkdir()
|
||||
main = tmp_path / "model-Q8_0.gguf"
|
||||
main.write_bytes(b"")
|
||||
(mtp_dir / "model-Q8_0-MTP.gguf").write_bytes(b"")
|
||||
(tmp_path / "model-Q4_K_M.gguf").write_bytes(b"")
|
||||
|
||||
assert _find_gguf_in_dir(tmp_path, "Q8_0") == main
|
||||
assert _find_gguf_in_dir(tmp_path, "Q4_K") is None
|
||||
|
||||
|
||||
def test_find_gguf_in_dir_without_variant_prefers_largest_model(tmp_path):
|
||||
smaller = tmp_path / "a-model-Q4_K_M.gguf"
|
||||
larger = tmp_path / "z-model-Q8_0.gguf"
|
||||
smaller.write_bytes(b"0")
|
||||
larger.write_bytes(b"00")
|
||||
|
||||
assert _find_gguf_in_dir(tmp_path, None) == larger
|
||||
|
||||
|
||||
def test_find_gguf_in_dir_matches_bpw_variant_base_label(tmp_path):
|
||||
target = tmp_path / "model-IQ4_XS-3.53bpw.gguf"
|
||||
target.write_bytes(b"")
|
||||
(tmp_path / "model-Q4_K_M.gguf").write_bytes(b"")
|
||||
|
||||
assert _find_gguf_in_dir(tmp_path, "IQ4_XS") == target
|
||||
assert _find_gguf_in_dir(tmp_path, "IQ4_XS-3.53bpw") == target
|
||||
assert _find_gguf_in_dir(tmp_path, "Q4_K") is None
|
||||
|
||||
|
||||
def test_validate_chat_template_accepts_valid_and_empty():
|
||||
assert validate_chat_template("{{ messages[0].content }}").valid is True
|
||||
assert validate_chat_template("").valid is True
|
||||
assert validate_chat_template(" ").valid is True
|
||||
|
||||
|
||||
def test_validate_chat_template_reports_syntax_error_with_line():
|
||||
result = validate_chat_template("{% if %}{% endif %}")
|
||||
assert result.valid is False
|
||||
assert result.error is not None
|
||||
assert result.error.startswith("Line ")
|
||||
|
||||
|
||||
def test_chat_template_from_tokenizer_config_reads_string():
|
||||
assert _chat_template_from_tokenizer_config({"chat_template": "HELLO"}) == "HELLO"
|
||||
assert _chat_template_from_tokenizer_config({"chat_template": " "}) is None
|
||||
assert _chat_template_from_tokenizer_config({}) is None
|
||||
|
||||
|
||||
def test_chat_template_from_tokenizer_config_prefers_named_default():
|
||||
config = {
|
||||
"chat_template": [
|
||||
{"name": "tool_use", "template": "TOOL"},
|
||||
{"name": "default", "template": "DEFAULT"},
|
||||
]
|
||||
}
|
||||
assert _chat_template_from_tokenizer_config(config) == "DEFAULT"
|
||||
|
||||
|
||||
def test_chat_template_from_tokenizer_config_falls_back_to_first_entry():
|
||||
config = {
|
||||
"chat_template": [
|
||||
{"name": "tool_use", "template": "TOOL"},
|
||||
{"name": "other", "template": "OTHER"},
|
||||
]
|
||||
}
|
||||
assert _chat_template_from_tokenizer_config(config) == "TOOL"
|
||||
|
||||
|
||||
def test_chat_template_from_tokenizer_dir_prefers_jinja_file(tmp_path):
|
||||
(tmp_path / "chat_template.jinja").write_text("FROM_JINJA", encoding = "utf-8")
|
||||
(tmp_path / "tokenizer_config.json").write_text(
|
||||
json.dumps({"chat_template": "FROM_CONFIG"}), encoding = "utf-8"
|
||||
)
|
||||
assert _chat_template_from_tokenizer_dir(tmp_path) == "FROM_JINJA"
|
||||
|
||||
|
||||
def test_chat_template_from_tokenizer_dir_reads_tokenizer_config(tmp_path):
|
||||
(tmp_path / "tokenizer_config.json").write_text(
|
||||
json.dumps({"chat_template": "FROM_CONFIG"}), encoding = "utf-8"
|
||||
)
|
||||
assert _chat_template_from_tokenizer_dir(tmp_path) == "FROM_CONFIG"
|
||||
|
||||
|
||||
def test_chat_template_from_dir_without_variant_prefers_tokenizer(tmp_path):
|
||||
(tmp_path / "tokenizer_config.json").write_text(
|
||||
json.dumps({"chat_template": "FROM_CONFIG"}), encoding = "utf-8"
|
||||
)
|
||||
assert _chat_template_from_dir(tmp_path) == "FROM_CONFIG"
|
||||
|
||||
|
||||
def test_chat_template_from_dir_with_variant_still_prefers_tokenizer(tmp_path, monkeypatch):
|
||||
(tmp_path / "tokenizer_config.json").write_text(
|
||||
json.dumps({"chat_template": "FROM_CONFIG"}), encoding = "utf-8"
|
||||
)
|
||||
(tmp_path / "model-Q4_K_M.gguf").write_bytes(b"")
|
||||
monkeypatch.setattr("picker.service.read_gguf_chat_template", lambda _path: "FROM_GGUF")
|
||||
# Selecting a variant must not flip precedence to the embedded GGUF template.
|
||||
assert _chat_template_from_dir(tmp_path, "Q4_K_M") == "FROM_CONFIG"
|
||||
|
||||
|
||||
def test_chat_template_from_dir_with_variant_falls_back_to_gguf(tmp_path, monkeypatch):
|
||||
(tmp_path / "model-Q4_K_M.gguf").write_bytes(b"")
|
||||
monkeypatch.setattr("picker.service.read_gguf_chat_template", lambda _path: "FROM_GGUF")
|
||||
# With no tokenizer sidecar, the embedded GGUF template is still the fallback.
|
||||
assert _chat_template_from_dir(tmp_path, "Q4_K_M") == "FROM_GGUF"
|
||||
|
||||
|
||||
def test_chat_template_from_dir_returns_none_when_absent(tmp_path):
|
||||
assert _chat_template_from_dir(tmp_path) is None
|
||||
|
||||
|
||||
def test_read_default_chat_template_direct_gguf_prefers_sidecar(tmp_path, monkeypatch):
|
||||
gguf = tmp_path / "model-Q4_K_M.gguf"
|
||||
gguf.write_bytes(b"")
|
||||
(tmp_path / "tokenizer_config.json").write_text(
|
||||
json.dumps({"chat_template": "FROM_CONFIG"}), encoding = "utf-8"
|
||||
)
|
||||
monkeypatch.setattr("picker.service._build_browse_allowlist", lambda: [tmp_path])
|
||||
monkeypatch.setattr("picker.service.read_gguf_chat_template", lambda _path: "FROM_GGUF")
|
||||
# A directly selected .gguf file must prefer a maintained sidecar template
|
||||
# over its embedded copy, matching directory/variant precedence.
|
||||
assert read_default_chat_template(str(gguf)) == "FROM_CONFIG"
|
||||
|
||||
|
||||
def test_read_default_chat_template_direct_gguf_falls_back_to_embedded(tmp_path, monkeypatch):
|
||||
gguf = tmp_path / "model-Q4_K_M.gguf"
|
||||
gguf.write_bytes(b"")
|
||||
monkeypatch.setattr("picker.service._build_browse_allowlist", lambda: [tmp_path])
|
||||
monkeypatch.setattr("picker.service.read_gguf_chat_template", lambda _path: "FROM_GGUF")
|
||||
# With no sidecar next to the file, the embedded GGUF template is the fallback.
|
||||
assert read_default_chat_template(str(gguf)) == "FROM_GGUF"
|
||||
|
|
@ -50,8 +50,6 @@ _CACHE_MAX_ENTRIES = 4096
|
|||
# keyed by (file cache key, wanted key). None = key absent / file unreadable.
|
||||
_BOOL_CACHE: Dict[Tuple[_CacheKey, str], Optional[bool]] = {}
|
||||
|
||||
_STRING_CACHE: Dict[Tuple[_CacheKey, str], Optional[str]] = {}
|
||||
|
||||
# Native training context length (``{arch}.context_length``). None = absent /
|
||||
# unreadable. Lets the UI show the real context ceiling before a model loads.
|
||||
_CONTEXT_CACHE: Dict[_CacheKey, Optional[int]] = {}
|
||||
|
|
@ -355,83 +353,6 @@ def _read_gguf_bool(path: str, wanted_key: str) -> Optional[bool]:
|
|||
return result
|
||||
|
||||
|
||||
def _parse_gguf_string(path: str, wanted_key: str) -> Optional[str]:
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(24)
|
||||
if len(head) < 24:
|
||||
return None
|
||||
magic, _version, _tcount, kv_count = struct.unpack("<IIQQ", head)
|
||||
if magic != _GGUF_MAGIC:
|
||||
return None
|
||||
|
||||
for _ in range(kv_count):
|
||||
try:
|
||||
klen_bytes = f.read(8)
|
||||
if len(klen_bytes) < 8:
|
||||
break
|
||||
klen = struct.unpack("<Q", klen_bytes)[0]
|
||||
if klen > 1 << 20:
|
||||
break
|
||||
kbytes = f.read(klen)
|
||||
if len(kbytes) < klen:
|
||||
break
|
||||
key = kbytes.decode("utf-8", "replace")
|
||||
vt_bytes = f.read(4)
|
||||
if len(vt_bytes) < 4:
|
||||
break
|
||||
vtype = struct.unpack("<I", vt_bytes)[0]
|
||||
|
||||
if key == wanted_key and vtype == 8:
|
||||
slen_bytes = f.read(8)
|
||||
if len(slen_bytes) < 8:
|
||||
break
|
||||
slen = struct.unpack("<Q", slen_bytes)[0]
|
||||
if slen > 1 << 22:
|
||||
break
|
||||
sbytes = f.read(slen)
|
||||
if len(sbytes) < slen:
|
||||
break
|
||||
return sbytes.decode("utf-8", "replace")
|
||||
if not _skip_gguf_value(f, vtype):
|
||||
break
|
||||
except (struct.error, UnicodeDecodeError):
|
||||
break
|
||||
except OSError as e:
|
||||
logger.debug(f"_parse_gguf_string: cannot open {path}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"_parse_gguf_string: parse failure on {path}: {e}")
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _read_gguf_string(path: str, wanted_key: str) -> Optional[str]:
|
||||
fkey = _cache_key(path)
|
||||
if fkey is None:
|
||||
return None
|
||||
ckey = (fkey, wanted_key)
|
||||
with _CACHE_LOCK:
|
||||
if ckey in _STRING_CACHE:
|
||||
return _STRING_CACHE[ckey]
|
||||
result = _parse_gguf_string(path, wanted_key)
|
||||
with _CACHE_LOCK:
|
||||
while len(_STRING_CACHE) >= _CACHE_MAX_ENTRIES:
|
||||
try:
|
||||
_STRING_CACHE.pop(next(iter(_STRING_CACHE)))
|
||||
except StopIteration:
|
||||
break
|
||||
_STRING_CACHE[ckey] = result
|
||||
return result
|
||||
|
||||
|
||||
def read_gguf_chat_template(path: str) -> Optional[str]:
|
||||
template = _read_gguf_string(path, "tokenizer.chat_template")
|
||||
if isinstance(template, str) and template.strip():
|
||||
return template
|
||||
return None
|
||||
|
||||
|
||||
def read_mmproj_audio_capability(path: str) -> Optional[bool]:
|
||||
"""``clip.has_audio_encoder`` from an mmproj GGUF (e.g. Gemma 4's
|
||||
gemma4ua): ``True``/``False`` if present, ``None`` if absent/unreadable.
|
||||
|
|
|
|||
|
|
@ -195,6 +195,9 @@ function RootLayout() {
|
|||
chatRuntime.setActiveThreadId(null);
|
||||
chatRuntime.setActiveProjectId(null);
|
||||
chatRuntime.setIncognito(false);
|
||||
// Detach the staging UI but keep any in-flight download running, like Hub.
|
||||
if (chatRuntime.pendingSelection)
|
||||
chatRuntime.abandonStagedModel({ keepDownload: true });
|
||||
void navigate({
|
||||
to: "/chat",
|
||||
search: { new: crypto.randomUUID() },
|
||||
|
|
@ -217,6 +220,10 @@ function RootLayout() {
|
|||
chatRuntime.setActiveProjectId(null);
|
||||
chatRuntime.setActiveThreadId(null);
|
||||
chatRuntime.setIncognito(false);
|
||||
// Leaving chat must not kill an in-flight download: detach the staging UI
|
||||
// but keep the transfer running in the manager, like a Hub download.
|
||||
if (chatRuntime.pendingSelection)
|
||||
chatRuntime.abandonStagedModel({ keepDownload: true });
|
||||
}, [isChatRoute]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
|
@ -11,7 +10,7 @@ import {
|
|||
} from "@/components/ui/popover";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { isCustomProviderType } from "@/features/chat";
|
||||
import { isCustomProviderType } from "@/features/chat/external-providers";
|
||||
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
|
|
@ -34,11 +33,7 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
type PerModelConfig,
|
||||
resolveInitialConfig,
|
||||
} from "../model-config/per-model-config";
|
||||
import { ModelConfigPage } from "./model-config-page";
|
||||
import { Input } from "../ui/input";
|
||||
import { HubModelPicker, hasDownloadedModels } from "./model-selector/pickers";
|
||||
import { PillTabs } from "./model-selector/pill-tabs";
|
||||
import {
|
||||
|
|
@ -50,7 +45,6 @@ import type {
|
|||
ExternalModelOption,
|
||||
LoraModelOption,
|
||||
ModelOption,
|
||||
ModelPickTarget,
|
||||
ModelSelectorChangeMeta,
|
||||
} from "./model-selector/types";
|
||||
|
||||
|
|
@ -128,10 +122,6 @@ interface ModelSelectorProps {
|
|||
value?: string;
|
||||
defaultValue?: string;
|
||||
activeGgufVariant?: string | null;
|
||||
activeModelConfig?: PerModelConfig | null;
|
||||
activeGgufContextLength?: number | null;
|
||||
selectedConfig?: PerModelConfig | null;
|
||||
selectedGgufVariant?: string | null;
|
||||
onValueChange?: (value: string, meta: ModelSelectorChangeMeta) => void;
|
||||
onEject?: () => void;
|
||||
onFoldersChange?: () => void;
|
||||
|
|
@ -295,8 +285,7 @@ function saveLastHubSection(section: HubSection): void {
|
|||
// when they have downloads, else Recommended.
|
||||
function defaultHubSection(): HubSection {
|
||||
return (
|
||||
loadLastHubSection() ??
|
||||
(hasDownloadedModels() ? "downloaded" : "recommended")
|
||||
loadLastHubSection() ?? (hasDownloadedModels() ? "downloaded" : "recommended")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -319,11 +308,6 @@ function ModelSelectorContent({
|
|||
loraModels,
|
||||
externalModels,
|
||||
value,
|
||||
activeGgufVariant,
|
||||
activeModelConfig,
|
||||
activeGgufContextLength,
|
||||
selectedConfig,
|
||||
selectedGgufVariant,
|
||||
onSelect,
|
||||
onEject,
|
||||
onFoldersChange,
|
||||
|
|
@ -339,11 +323,6 @@ function ModelSelectorContent({
|
|||
loraModels: LoraModelOption[];
|
||||
externalModels: ExternalModelOption[];
|
||||
value?: string;
|
||||
activeGgufVariant?: string | null;
|
||||
activeModelConfig?: PerModelConfig | null;
|
||||
activeGgufContextLength?: number | null;
|
||||
selectedConfig?: PerModelConfig | null;
|
||||
selectedGgufVariant?: string | null;
|
||||
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
|
||||
onEject?: () => void;
|
||||
onFoldersChange?: () => void;
|
||||
|
|
@ -412,10 +391,6 @@ function ModelSelectorContent({
|
|||
const effectiveHubSection: HubSection =
|
||||
hubSection === "connected" && !hasExternal ? "recommended" : hubSection;
|
||||
|
||||
const [configTarget, setConfigTarget] = useState<ModelPickTarget | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// The picker below remounts on each open, but this tab state does not, so a
|
||||
// persisted selection that lands in lora/external after async load would
|
||||
// reopen on Hub. Re-derive the default tab on the open edge.
|
||||
|
|
@ -427,9 +402,6 @@ function ModelSelectorContent({
|
|||
// user has downloads, else their last section.
|
||||
setHubSection(wantsConnectedDefault ? "connected" : defaultHubSection());
|
||||
}
|
||||
if (!open && wasOpen.current) {
|
||||
setConfigTarget(null);
|
||||
}
|
||||
wasOpen.current = open;
|
||||
}, [
|
||||
open,
|
||||
|
|
@ -480,29 +452,6 @@ function ModelSelectorContent({
|
|||
}
|
||||
}
|
||||
|
||||
const visibleConfigTarget = open ? configTarget : null;
|
||||
const openConfigPage = (id: string, meta: ModelSelectorChangeMeta) => {
|
||||
const leaf = id.includes("/") ? id.slice(id.lastIndexOf("/") + 1) : id;
|
||||
setConfigTarget({
|
||||
id,
|
||||
displayName: meta.ggufVariant ? `${leaf} · ${meta.ggufVariant}` : leaf,
|
||||
ggufVariant: meta.ggufVariant ?? null,
|
||||
isGguf: meta.isGguf ?? Boolean(meta.ggufVariant),
|
||||
meta,
|
||||
});
|
||||
};
|
||||
const handlePick = (id: string, meta: ModelSelectorChangeMeta) => {
|
||||
if (meta.source === "external") {
|
||||
onSelect(id, meta);
|
||||
return;
|
||||
}
|
||||
const resolved = resolveInitialConfig(id, meta.ggufVariant);
|
||||
onSelect(id, {
|
||||
...meta,
|
||||
...(resolved.remembered ? { config: resolved.config } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PopoverContent
|
||||
align="start"
|
||||
|
|
@ -510,17 +459,12 @@ function ModelSelectorContent({
|
|||
data-tour={dataTour}
|
||||
onKeyDown={handlePickerEntryKeyDown}
|
||||
className={cn(
|
||||
"unsloth-model-selector-menu menu-soft-surface ring-0 max-w-[calc(100vw-1rem)] min-w-0 gap-0",
|
||||
visibleConfigTarget
|
||||
? "w-[min(468px,calc(100vw-1rem))] px-4 pt-4 pb-4"
|
||||
: cn(
|
||||
"pt-4 pb-0 pl-4",
|
||||
// Sized so the left-packed row keeps uniform gaps and the last
|
||||
// dropdown's right gap matches the pill's left gap (pl-4 vs pr-4).
|
||||
hasExternal
|
||||
? "w-[min(614px,calc(100vw-1rem))] pr-4"
|
||||
: "w-[min(506px,calc(100vw-1rem))] pr-2",
|
||||
),
|
||||
"unsloth-model-selector-menu menu-soft-surface ring-0 max-w-[calc(100vw-1rem)] min-w-0 gap-0 pt-4 pb-0 pl-4",
|
||||
// Sized so the left-packed row keeps uniform gaps and the last dropdown's
|
||||
// right gap matches the pill's left gap (pl-4 vs pr-4).
|
||||
hasExternal
|
||||
? "w-[min(614px,calc(100vw-1rem))] pr-4"
|
||||
: "w-[min(506px,calc(100vw-1rem))] pr-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
@ -533,42 +477,6 @@ function ModelSelectorContent({
|
|||
skipDelayDuration={0}
|
||||
disableHoverableContent={true}
|
||||
>
|
||||
{visibleConfigTarget ? (
|
||||
<ModelConfigPage
|
||||
key={`${visibleConfigTarget.id}::${visibleConfigTarget.ggufVariant ?? ""}`}
|
||||
target={visibleConfigTarget}
|
||||
onBack={() => setConfigTarget(null)}
|
||||
onRun={(config) =>
|
||||
onSelect(visibleConfigTarget.id, {
|
||||
...visibleConfigTarget.meta,
|
||||
config,
|
||||
forceReload: true,
|
||||
})
|
||||
}
|
||||
loadedConfig={
|
||||
value === visibleConfigTarget.id &&
|
||||
(activeGgufVariant ?? null) ===
|
||||
(visibleConfigTarget.ggufVariant ?? null)
|
||||
? (activeModelConfig ?? null)
|
||||
: null
|
||||
}
|
||||
loadedContextLength={
|
||||
value === visibleConfigTarget.id &&
|
||||
(activeGgufVariant ?? null) ===
|
||||
(visibleConfigTarget.ggufVariant ?? null)
|
||||
? (activeGgufContextLength ?? null)
|
||||
: null
|
||||
}
|
||||
initialConfig={
|
||||
value === visibleConfigTarget.id &&
|
||||
(selectedGgufVariant ?? null) ===
|
||||
(visibleConfigTarget.ggufVariant ?? null)
|
||||
? (selectedConfig ?? null)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{tabs.length > 1 ? (
|
||||
<PillTabs
|
||||
ariaLabel="Model source"
|
||||
|
|
@ -586,14 +494,13 @@ function ModelSelectorContent({
|
|||
loraModels={fineTunedModels}
|
||||
externalModels={externalModels}
|
||||
value={value}
|
||||
onSelect={handlePick}
|
||||
onSelect={onSelect}
|
||||
onFoldersChange={onFoldersChange}
|
||||
onBrowseHub={onBrowseHub}
|
||||
onModelsChange={onModelsChange}
|
||||
onConfigure={openConfigPage}
|
||||
deleteDisabled={deleteDisabled}
|
||||
onEject={hasSelection && onEject ? onEject : undefined}
|
||||
section={effectiveHubSection}
|
||||
onEject={hasSelection && onEject ? onEject : undefined}
|
||||
sectionToggle={
|
||||
<PillTabs
|
||||
ariaLabel="Hub section"
|
||||
|
|
@ -631,8 +538,10 @@ function ModelSelectorContent({
|
|||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Hub renders Eject inline as the last list row; other tabs keep the
|
||||
footer button. */}
|
||||
{effectiveTab !== "hub" && hasSelection && onEject ? (
|
||||
<div className="mt-1.5 border-t border-border/70 pt-1.5 pb-2">
|
||||
<div className="mt-1.5 pt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEject}
|
||||
|
|
@ -644,8 +553,6 @@ function ModelSelectorContent({
|
|||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</PopoverContent>
|
||||
);
|
||||
|
|
@ -658,10 +565,6 @@ export function ModelSelector({
|
|||
value,
|
||||
defaultValue,
|
||||
activeGgufVariant,
|
||||
activeModelConfig,
|
||||
activeGgufContextLength,
|
||||
selectedConfig,
|
||||
selectedGgufVariant,
|
||||
onValueChange,
|
||||
onEject,
|
||||
onFoldersChange,
|
||||
|
|
@ -790,11 +693,6 @@ export function ModelSelector({
|
|||
loraModels={loraModels}
|
||||
externalModels={externalModels}
|
||||
value={selected}
|
||||
activeGgufVariant={activeGgufVariant}
|
||||
activeModelConfig={activeModelConfig}
|
||||
activeGgufContextLength={activeGgufContextLength}
|
||||
selectedConfig={selectedConfig}
|
||||
selectedGgufVariant={selectedGgufVariant}
|
||||
onSelect={handleSelect}
|
||||
onEject={onEject ? handleEject : undefined}
|
||||
onFoldersChange={onFoldersChange}
|
||||
|
|
@ -14,7 +14,10 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { type BrowseFoldersResponse, browseFolders } from "@/features/chat";
|
||||
import {
|
||||
type BrowseFoldersResponse,
|
||||
browseFolders,
|
||||
} from "@/features/chat/api/chat-api";
|
||||
import { ChevronUpStandardIcon } from "@/lib/chevron-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Folder02Icon } from "@hugeicons/core-free-icons";
|
||||
|
|
@ -87,43 +90,47 @@ export function FolderBrowser({
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
function navigate(
|
||||
target: string | undefined,
|
||||
hidden: boolean,
|
||||
opts?: { fallbackOnError?: boolean },
|
||||
) {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Forward the signal so cancelled navigation aborts the backend
|
||||
// enumeration, not just the response.
|
||||
browseFolders(target, hidden, ctrl.signal)
|
||||
.then((res) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
setData(res);
|
||||
setPath(res.current);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
// Surface the error; if the first request (e.g. a bad initialPath)
|
||||
// fails, fall back to HOME so the modal stays navigable.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
if (opts?.fallbackOnError && target !== undefined) {
|
||||
// Re-issue without a target -> backend defaults to HOME.
|
||||
// Don't recurse if HOME itself fails (allowlist always has HOME).
|
||||
queueMicrotask(() => navigate(undefined, hidden));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) setLoading(false);
|
||||
});
|
||||
}
|
||||
const navigate = useCallback(
|
||||
(
|
||||
target: string | undefined,
|
||||
hidden: boolean,
|
||||
opts?: { fallbackOnError?: boolean },
|
||||
) => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Forward the signal so cancelled navigation aborts the backend
|
||||
// enumeration, not just the response.
|
||||
browseFolders(target, hidden, ctrl.signal)
|
||||
.then((res) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
setData(res);
|
||||
setPath(res.current);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
// Surface the error; if the first request (e.g. a bad initialPath)
|
||||
// fails, fall back to HOME so the modal stays navigable.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
if (opts?.fallbackOnError && target !== undefined) {
|
||||
// Re-issue without a target -> backend defaults to HOME.
|
||||
// Don't recurse if HOME itself fails (allowlist always has HOME).
|
||||
queueMicrotask(() => navigate(undefined, hidden));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) setLoading(false);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Fetch only on closed -> open; later navigation is driven by `navigate()`,
|
||||
// so `path` is deliberately kept out of the dependency list.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// fallbackOnError: recover into HOME if initialPath is bad, rather than
|
||||
|
|
@ -140,7 +147,7 @@ export function FolderBrowser({
|
|||
|
||||
const crumbs = useMemo(
|
||||
() => (data?.current ? splitBreadcrumb(data.current) : []),
|
||||
[data],
|
||||
[data?.current],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
// 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 { DeleteConfirmDialog } from "@/features/hub";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { DeleteConfirmDialog } from "@/features/hub/catalog/download-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Delete02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { type ReactNode, useCallback, useState } from "react";
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
interface ModelDeleteActionProps {
|
||||
ariaLabel: string;
|
||||
|
|
@ -63,8 +63,7 @@ export function ModelDeleteAction({
|
|||
disabled={disabled}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-destructive/10 hover:text-destructive",
|
||||
disabled &&
|
||||
"cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground/60",
|
||||
disabled && "cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground/60",
|
||||
buttonClassName,
|
||||
)}
|
||||
>
|
||||
|
|
@ -6,16 +6,24 @@ import {
|
|||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Settings02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
||||
/** Gear button on a downloaded quant row. Stages the model into the Run
|
||||
* settings sidebar (always, regardless of the Load-on-selection toggle) so the
|
||||
* user can set load options, then click Load model. */
|
||||
export function ModelLoadSettingsAction({
|
||||
ariaLabel,
|
||||
onConfigure,
|
||||
repoId,
|
||||
quant,
|
||||
maxContext,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
onConfigure: () => void;
|
||||
repoId: string;
|
||||
quant: string;
|
||||
maxContext?: number | null;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip delayDuration={0}>
|
||||
|
|
@ -24,7 +32,12 @@ export function ModelLoadSettingsAction({
|
|||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConfigure();
|
||||
useChatRuntimeStore.getState().stageModel({
|
||||
id: repoId,
|
||||
ggufVariant: quant,
|
||||
isDownloaded: true,
|
||||
contextLength: maxContext ?? null,
|
||||
});
|
||||
}}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
|
|
@ -1,20 +1,12 @@
|
|||
// 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 {
|
||||
UpdateConfirmDialog,
|
||||
ggufVariantsMatch,
|
||||
subscribeJobListeners,
|
||||
} from "@/features/hub";
|
||||
import { subscribeJobListeners } from "@/features/hub/download-manager";
|
||||
import { UpdateConfirmDialog } from "@/features/hub/catalog/download-card";
|
||||
import { ggufVariantsMatch } from "@/features/hub/lib/model-identity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ModelUpdateActionProps {
|
||||
|
|
@ -50,10 +42,10 @@ export function ModelUpdateAction({
|
|||
}: ModelUpdateActionProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Refresh the caller when this repo+variant's download finishes so the "update available" cue
|
||||
// clears. A ref keeps the subscription stable across renders.
|
||||
const onUpdatedRef = useRef(onUpdated);
|
||||
useEffect(() => {
|
||||
onUpdatedRef.current = onUpdated;
|
||||
}, [onUpdated]);
|
||||
onUpdatedRef.current = onUpdated;
|
||||
useEffect(() => {
|
||||
return subscribeJobListeners("model", repoId, {
|
||||
onComplete: (completedVariant) => {
|
||||
|
|
@ -91,8 +83,7 @@ export function ModelUpdateAction({
|
|||
disabled={disabled}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-amber-500/10 hover:text-amber-700 dark:hover:bg-amber-500/15 dark:hover:text-amber-300",
|
||||
disabled &&
|
||||
"cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground/60",
|
||||
disabled && "cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground/60",
|
||||
buttonClassName,
|
||||
)}
|
||||
>
|
||||
|
|
@ -40,8 +40,7 @@ export function loadedAt(times: ModelLoadTimes, id: string): number {
|
|||
export function useModelLoadTimes(currentValue?: string): ModelLoadTimes {
|
||||
const [times, setTimes] = useState<ModelLoadTimes>(() => readLoadTimes());
|
||||
useEffect(() => {
|
||||
if (!currentValue) return;
|
||||
queueMicrotask(() => setTimes(recordModelLoaded(currentValue)));
|
||||
if (currentValue) setTimes(recordModelLoaded(currentValue));
|
||||
}, [currentValue]);
|
||||
return times;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -78,8 +78,7 @@ export function PillTabs({
|
|||
onValueChange(tabs[next].value);
|
||||
e.currentTarget.parentElement
|
||||
?.querySelectorAll<HTMLElement>('button[role="tab"]')
|
||||
.item(next)
|
||||
?.focus();
|
||||
[next]?.focus();
|
||||
}}
|
||||
onClick={() => onValueChange(tab.value)}
|
||||
className={cn(
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
// Per-model pre-load inference settings, persisted in localStorage so the load
|
||||
// dialog can offer "Remember settings for <model>".
|
||||
|
||||
const KEY = "unsloth_load_settings";
|
||||
|
||||
export interface RememberedLoadSettings {
|
||||
contextLength: number | null;
|
||||
kvCacheDtype: string | null;
|
||||
speculativeType: string | null;
|
||||
specDraftNMax: number | null;
|
||||
tensorParallel: boolean;
|
||||
}
|
||||
|
||||
// Storage key for a pick's remembered settings. The remembered knobs are
|
||||
// VRAM-budget driven (context override, KV-cache dtype, tensor-parallel), so the
|
||||
// right values differ per quant. An HF repo collapses all its GGUF variants into
|
||||
// one `id`, so fold the variant in to scope settings per quant. Local .gguf
|
||||
// paths key by their file path (already file-specific); native drag-drop files
|
||||
// key by display label, so same-named files in different folders share an entry.
|
||||
export function rememberedLoadSettingsKey(selection: {
|
||||
id: string;
|
||||
ggufVariant?: string | null;
|
||||
}): string {
|
||||
return selection.ggufVariant
|
||||
? `${selection.id}::${selection.ggufVariant}`
|
||||
: selection.id;
|
||||
}
|
||||
|
||||
function readAll(): Record<string, RememberedLoadSettings> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEY) ?? "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeAll(all: Record<string, RememberedLoadSettings>) {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(all));
|
||||
} catch {
|
||||
// Ignore quota / unavailable storage.
|
||||
}
|
||||
}
|
||||
|
||||
export function loadRememberedLoadSettings(
|
||||
key: string,
|
||||
): RememberedLoadSettings | null {
|
||||
return readAll()[key] ?? null;
|
||||
}
|
||||
|
||||
export function saveRememberedLoadSettings(
|
||||
key: string,
|
||||
settings: RememberedLoadSettings,
|
||||
) {
|
||||
const all = readAll();
|
||||
all[key] = settings;
|
||||
writeAll(all);
|
||||
}
|
||||
|
||||
export function clearRememberedLoadSettings(key: string) {
|
||||
const all = readAll();
|
||||
if (key in all) {
|
||||
delete all[key];
|
||||
writeAll(all);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { PerModelConfig } from "../../model-config/per-model-config";
|
||||
|
||||
export interface ModelOption {
|
||||
id: string;
|
||||
|
|
@ -37,18 +36,6 @@ export interface ModelSelectorChangeMeta {
|
|||
/** Direct local .gguf file picked without a variant (custom folder / LM
|
||||
* Studio). Marks it as a GGUF source for the deferred-load staging flow. */
|
||||
isGguf?: boolean;
|
||||
config?: PerModelConfig;
|
||||
forceReload?: boolean;
|
||||
/** Native path token so an active-model reload can reopen a file-picked GGUF. */
|
||||
nativePathToken?: string;
|
||||
}
|
||||
|
||||
export interface ModelPickTarget {
|
||||
id: string;
|
||||
displayName: string;
|
||||
ggufVariant?: string | null;
|
||||
isGguf: boolean;
|
||||
meta: ModelSelectorChangeMeta;
|
||||
}
|
||||
|
||||
export interface DeletedModelRef {
|
||||
|
|
@ -2,7 +2,10 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { getAuthToken } from "@/features/auth";
|
||||
import { resolveInitialConfig } from "@/features/model-picker";
|
||||
import {
|
||||
loadRememberedLoadSettings,
|
||||
rememberedLoadSettingsKey,
|
||||
} from "@/components/assistant-ui/model-selector/remembered-load-settings";
|
||||
import { projectHasSources } from "@/features/rag/api/rag-api";
|
||||
import { apiUrl } from "@/lib/api-base";
|
||||
import { parseParamCountB } from "@/lib/model-size";
|
||||
|
|
@ -1517,25 +1520,27 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
return false;
|
||||
}
|
||||
const currentStore = useChatRuntimeStore.getState();
|
||||
const { config } = resolveInitialConfig(candidate.id, candidate.ggufVariant);
|
||||
const remembered = loadRememberedLoadSettings(
|
||||
rememberedLoadSettingsKey({
|
||||
id: candidate.id,
|
||||
ggufVariant: candidate.ggufVariant,
|
||||
}),
|
||||
);
|
||||
const effectiveMaxSeqLength = resolveLoadMaxSeqLength({
|
||||
modelId: candidate.id,
|
||||
ggufVariant: candidate.ggufVariant,
|
||||
isGguf: candidate.kind === "gguf",
|
||||
customContextLength: config.customContextLength,
|
||||
customContextLength: remembered?.contextLength ?? null,
|
||||
ggufContextLength: null,
|
||||
currentCheckpoint: currentStore.params.checkpoint,
|
||||
activeGgufVariant: currentStore.activeGgufVariant,
|
||||
maxSeqLength: config.maxSeqLength ?? candidate.maxSeqLength,
|
||||
maxSeqLength: candidate.maxSeqLength,
|
||||
presetSource: currentStore.activePresetSource,
|
||||
});
|
||||
const effectiveSpeculativeType =
|
||||
config.speculativeType ?? specSettings.speculativeType;
|
||||
remembered?.speculativeType ?? specSettings.speculativeType;
|
||||
const effectiveSpecDraftNMax =
|
||||
config.specDraftNMax ?? specSettings.specDraftNMax;
|
||||
const effectiveChatTemplateOverride = config.chatTemplateOverride?.trim()
|
||||
? config.chatTemplateOverride
|
||||
: null;
|
||||
remembered?.specDraftNMax ?? specSettings.specDraftNMax;
|
||||
if (
|
||||
!(await canAutoLoad({
|
||||
model_path: candidate.id,
|
||||
|
|
@ -1558,18 +1563,12 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
is_lora: false,
|
||||
gguf_variant: candidate.ggufVariant,
|
||||
trust_remote_code: trustRemoteCode,
|
||||
chat_template_override: effectiveChatTemplateOverride,
|
||||
cache_type_kv: config.kvCacheDtype,
|
||||
cache_type_kv: remembered?.kvCacheDtype ?? null,
|
||||
speculative_type: effectiveSpeculativeType,
|
||||
spec_draft_n_max: effectiveSpecDraftNMax,
|
||||
tensor_parallel: config.tensorParallel,
|
||||
tensor_parallel: remembered?.tensorParallel ?? false,
|
||||
});
|
||||
// Only persist the global preference when the value came from the global
|
||||
// settings. A per-model config's choice must stay load-local, or autoloading
|
||||
// a remembered model on startup would rewrite the global default.
|
||||
if (config.speculativeType == null) {
|
||||
saveSpeculativeType(effectiveSpeculativeType);
|
||||
}
|
||||
saveSpeculativeType(effectiveSpeculativeType);
|
||||
useChatRuntimeStore
|
||||
.getState()
|
||||
.setCheckpoint(candidate.id, candidate.ggufVariant ?? undefined);
|
||||
|
|
@ -1579,9 +1578,6 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
);
|
||||
store.setParams({
|
||||
...store.params,
|
||||
...(candidate.kind === "gguf"
|
||||
? {}
|
||||
: { maxSeqLength: effectiveMaxSeqLength }),
|
||||
maxTokens:
|
||||
candidate.kind === "gguf"
|
||||
? loadResp.context_length ?? 131072
|
||||
|
|
@ -1618,11 +1614,8 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
tensorParallel: loadResp.tensor_parallel ?? false,
|
||||
loadedTensorParallel: loadResp.tensor_parallel ?? false,
|
||||
defaultChatTemplate: loadResp.chat_template ?? null,
|
||||
chatTemplateOverride: effectiveChatTemplateOverride,
|
||||
loadedChatTemplateOverride: effectiveChatTemplateOverride,
|
||||
// Retain the saved requested context so re-saving the config keeps the
|
||||
// override; null stays null (auto/VRAM-fit).
|
||||
customContextLength: config.customContextLength,
|
||||
chatTemplateOverride: null,
|
||||
loadedChatTemplateOverride: null,
|
||||
loadedIsMultimodal: isMultimodalResponse(loadResp),
|
||||
loadedIsDiffusion: loadResp.is_diffusion ?? false,
|
||||
...resolveLoadedSpeculativeSettings(loadResp),
|
||||
|
|
@ -1641,9 +1634,8 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
tensorParallel: loadResp.tensor_parallel ?? false,
|
||||
loadedTensorParallel: loadResp.tensor_parallel ?? false,
|
||||
defaultChatTemplate: loadResp.chat_template ?? null,
|
||||
chatTemplateOverride: effectiveChatTemplateOverride,
|
||||
loadedChatTemplateOverride: effectiveChatTemplateOverride,
|
||||
customContextLength: null,
|
||||
chatTemplateOverride: null,
|
||||
loadedChatTemplateOverride: null,
|
||||
...resolveLoadedSpeculativeSettings(loadResp),
|
||||
loadedIsMultimodal: isMultimodalResponse(loadResp),
|
||||
loadedIsDiffusion: loadResp.is_diffusion ?? false,
|
||||
|
|
|
|||
|
|
@ -2,18 +2,16 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import {
|
||||
applyModelLoadConfigToRuntime,
|
||||
currentRuntimePerModelConfig,
|
||||
type DeletedModelRef,
|
||||
type ExternalModelOption,
|
||||
type LoraModelOption,
|
||||
type ModelOption,
|
||||
ModelSelector,
|
||||
type ModelSelectorChangeMeta,
|
||||
type PerModelConfig,
|
||||
resolveInitialConfig,
|
||||
SidebarModelConfig,
|
||||
} from "@/features/model-picker";
|
||||
} from "@/components/assistant-ui/model-selector";
|
||||
import {
|
||||
loadRememberedLoadSettings,
|
||||
rememberedLoadSettingsKey,
|
||||
} from "@/components/assistant-ui/model-selector/remembered-load-settings";
|
||||
import { ProjectComposer, Thread } from "@/components/assistant-ui/thread";
|
||||
import { CopyableErrorChip } from "@/components/ui/copyable-error-chip";
|
||||
import {
|
||||
|
|
@ -29,10 +27,10 @@ import {
|
|||
} from "@/components/ui/resizable";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { useLatestRef } from "@/features/hub/hooks/use-latest-ref";
|
||||
import {
|
||||
DOWNLOAD_KIND,
|
||||
downloadManager,
|
||||
useRepoDownload,
|
||||
} from "@/features/hub/download-manager";
|
||||
import {
|
||||
type NativeIntent,
|
||||
|
|
@ -95,6 +93,7 @@ import {
|
|||
renameChatItem,
|
||||
useChatSidebarItems,
|
||||
} from "./hooks/use-chat-sidebar-items";
|
||||
import { useStagedModelPreparation } from "./hooks/use-staged-model-preparation";
|
||||
import {
|
||||
clearTrainingCompareHandoff,
|
||||
getTrainingCompareHandoff,
|
||||
|
|
@ -129,8 +128,10 @@ import {
|
|||
hasGgufSource,
|
||||
isDownloadableHubRepo,
|
||||
loadOptionalBool,
|
||||
pendingSelectionMatches,
|
||||
useChatRuntimeStore,
|
||||
} from "./stores/chat-runtime-store";
|
||||
import type { PendingModelSelection } from "./stores/chat-runtime-store";
|
||||
import { useChatPreferencesStore } from "./stores/chat-preferences-store";
|
||||
import { useExternalProvidersStore } from "./stores/external-providers-store";
|
||||
import { buildChatTourSteps } from "./tour";
|
||||
|
|
@ -384,7 +385,6 @@ type CompareModelSelection = {
|
|||
id: string;
|
||||
isLora: boolean;
|
||||
ggufVariant?: string;
|
||||
config?: PerModelConfig;
|
||||
};
|
||||
|
||||
function modelMatchesDeleted(
|
||||
|
|
@ -645,8 +645,6 @@ function GeneralCompareHeader({
|
|||
loraModels,
|
||||
externalModels,
|
||||
value,
|
||||
selectedConfig,
|
||||
selectedGgufVariant,
|
||||
onValueChange,
|
||||
onFoldersChange,
|
||||
onModelsChange,
|
||||
|
|
@ -657,11 +655,9 @@ function GeneralCompareHeader({
|
|||
loraModels: LoraModelOption[];
|
||||
externalModels: ExternalModelOption[];
|
||||
value: string;
|
||||
selectedConfig?: PerModelConfig | null;
|
||||
selectedGgufVariant?: string | null;
|
||||
onValueChange: (
|
||||
id: string,
|
||||
meta: ModelSelectorChangeMeta,
|
||||
meta: { isLora: boolean; ggufVariant?: string },
|
||||
) => void;
|
||||
onFoldersChange?: () => void;
|
||||
onModelsChange?: (deletedModel?: DeletedModelRef) => void;
|
||||
|
|
@ -688,8 +684,6 @@ function GeneralCompareHeader({
|
|||
loraModels={loraModels}
|
||||
externalModels={externalModels}
|
||||
value={value}
|
||||
selectedConfig={selectedConfig}
|
||||
selectedGgufVariant={selectedGgufVariant}
|
||||
onValueChange={onValueChange}
|
||||
onFoldersChange={onFoldersChange}
|
||||
onModelsChange={onModelsChange}
|
||||
|
|
@ -817,14 +811,11 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
loraModels={loraModels}
|
||||
externalModels={externalModels}
|
||||
value={model1.id}
|
||||
selectedConfig={model1.config}
|
||||
selectedGgufVariant={model1.ggufVariant}
|
||||
onValueChange={(id, meta) =>
|
||||
setModel1({
|
||||
id,
|
||||
isLora: meta.isLora,
|
||||
ggufVariant: meta.ggufVariant,
|
||||
config: meta.config,
|
||||
})
|
||||
}
|
||||
onFoldersChange={onFoldersChange}
|
||||
|
|
@ -847,14 +838,11 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
loraModels={loraModels}
|
||||
externalModels={externalModels}
|
||||
value={model2.id}
|
||||
selectedConfig={model2.config}
|
||||
selectedGgufVariant={model2.ggufVariant}
|
||||
onValueChange={(id, meta) =>
|
||||
setModel2({
|
||||
id,
|
||||
isLora: meta.isLora,
|
||||
ggufVariant: meta.ggufVariant,
|
||||
config: meta.config,
|
||||
})
|
||||
}
|
||||
onFoldersChange={onFoldersChange}
|
||||
|
|
@ -1248,13 +1236,6 @@ export function validateChatSearch(search: Record<string, unknown>): ChatSearch
|
|||
};
|
||||
}
|
||||
|
||||
type PendingHubAutoLoad = {
|
||||
selection: SelectedModelInput;
|
||||
contextKey: string;
|
||||
originCheckpoint: string;
|
||||
originGgufVariant: string | null;
|
||||
};
|
||||
|
||||
// `search` comes from RootLayout (not useSearch) so ChatPage stays mounted off-route
|
||||
// (keeping an in-flight generation alive), frozen to the last /chat search. `active`
|
||||
// is false off-route: close body-portaled surfaces and stop route-specific listeners
|
||||
|
|
@ -1267,6 +1248,30 @@ export function ChatPage({
|
|||
|
||||
const settingsOpen = useChatRuntimeStore((s) => s.settingsPanelOpen);
|
||||
const setSettingsOpen = useChatRuntimeStore((s) => s.setSettingsPanelOpen);
|
||||
// Deferred-load staging: downloads a staged GGUF (if needed) and reads its
|
||||
// header context so the sheet can show the context slider before the load.
|
||||
// autoLoad picks instead load the cached file as soon as the download ends;
|
||||
// selectModel is defined below, so the load runs through a ref.
|
||||
const autoLoadStagedRef = useRef<
|
||||
((pending: PendingModelSelection) => void) | null
|
||||
>(null);
|
||||
const stagedDownload = useStagedModelPreparation({
|
||||
onAutoLoad: (pending) => autoLoadStagedRef.current?.(pending),
|
||||
});
|
||||
// Abandon a staged pick: the store action cancels its in-flight download and
|
||||
// reverts the edited knobs, so nothing lingers after the user walks away.
|
||||
const abandonStaged = useCallback(() => {
|
||||
useChatRuntimeStore.getState().abandonStagedModel();
|
||||
}, []);
|
||||
// Detach a staged pick on navigation without cancelling its download: the
|
||||
// transfer keeps running in the manager and lands in cache, like Hub.
|
||||
const detachStaged = useCallback(() => {
|
||||
useChatRuntimeStore.getState().abandonStagedModel({ keepDownload: true });
|
||||
}, []);
|
||||
// Tracks whether the chat page is still mounted, so a staged-load failure that
|
||||
// resolves after the user left chat doesn't resurrect the abandoned pick.
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => () => void (mountedRef.current = false), []);
|
||||
const incognito = useChatRuntimeStore((s) => s.incognito);
|
||||
const setIncognito = useChatRuntimeStore((s) => s.setIncognito);
|
||||
const incognitoLabel = incognito
|
||||
|
|
@ -1358,9 +1363,6 @@ export function ChatPage({
|
|||
const ggufContextLength = useChatRuntimeStore(
|
||||
(state) => state.ggufContextLength,
|
||||
);
|
||||
const ggufNativeContextLength = useChatRuntimeStore(
|
||||
(state) => state.ggufNativeContextLength,
|
||||
);
|
||||
const contextUsage = useChatRuntimeStore((state) => state.contextUsage);
|
||||
const modelsFromStore = useChatRuntimeStore((state) => state.models);
|
||||
const lorasFromStore = useChatRuntimeStore((state) => state.loras);
|
||||
|
|
@ -1438,82 +1440,37 @@ export function ChatPage({
|
|||
refreshRef.current = refresh;
|
||||
selectModelRef.current = selectModel;
|
||||
}, [refresh, selectModel]);
|
||||
const rememberedConfigFor = useCallback(
|
||||
(selection: {
|
||||
id: string;
|
||||
ggufVariant?: string | null;
|
||||
source?: string;
|
||||
}) => {
|
||||
if (selection.source === "external") return null;
|
||||
const resolved = resolveInitialConfig(selection.id, selection.ggufVariant);
|
||||
return resolved.remembered ? resolved.config : null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
// Load a cached autoLoad pick once its download finishes. The sheet was never
|
||||
// opened, so on a load failure just drop the orphaned staged knobs. The knobs
|
||||
// were already seeded on stage, so keepSpeculative only when a config was
|
||||
// saved -- otherwise the standing speculative preference should win.
|
||||
autoLoadStagedRef.current = (pending) => {
|
||||
const remembered = loadRememberedLoadSettings(
|
||||
rememberedLoadSettingsKey(pending),
|
||||
);
|
||||
void selectModel({
|
||||
...pending,
|
||||
isDownloaded: true,
|
||||
forceReload: true,
|
||||
keepSpeculative: remembered != null,
|
||||
throwOnError: true,
|
||||
}).catch(() => {
|
||||
const store = useChatRuntimeStore.getState();
|
||||
// selectModel only clears pendingSelection on success, so a failed
|
||||
// auto-load leaves our staged pick (and its edited load knobs) behind.
|
||||
// Abandon it when it is still the active stage; otherwise just revert the
|
||||
// settings if the stage was already cleared by something else.
|
||||
if (pendingSelectionMatches(store.pendingSelection, pending)) {
|
||||
store.abandonStagedModel();
|
||||
} else if (!store.pendingSelection) {
|
||||
store.resetModelSettingsToLoaded();
|
||||
}
|
||||
});
|
||||
};
|
||||
const isExternalModel = useMemo(
|
||||
() => isExternalModelId(inferenceParams.checkpoint),
|
||||
[inferenceParams.checkpoint],
|
||||
);
|
||||
const runtimeCustomContextLength = useChatRuntimeStore(
|
||||
(s) => s.customContextLength,
|
||||
);
|
||||
const runtimeKvCacheDtype = useChatRuntimeStore((s) => s.kvCacheDtype);
|
||||
const runtimeSpeculativeType = useChatRuntimeStore((s) => s.speculativeType);
|
||||
const runtimeSpecDraftNMax = useChatRuntimeStore((s) => s.specDraftNMax);
|
||||
const runtimeTensorParallel = useChatRuntimeStore((s) => s.tensorParallel);
|
||||
const runtimeChatTemplateOverride = useChatRuntimeStore(
|
||||
(s) => s.chatTemplateOverride,
|
||||
);
|
||||
const activeModelConfig = useMemo<PerModelConfig | null>(() => {
|
||||
if (!inferenceParams.checkpoint || isExternalModel) return null;
|
||||
const activeModelIsGguf =
|
||||
activeGgufVariant != null ||
|
||||
ggufContextLength != null ||
|
||||
inferenceParams.checkpoint.toLowerCase().endsWith(".gguf");
|
||||
return {
|
||||
customContextLength: runtimeCustomContextLength ?? null,
|
||||
maxSeqLength: activeModelIsGguf ? null : inferenceParams.maxSeqLength,
|
||||
kvCacheDtype: runtimeKvCacheDtype ?? null,
|
||||
speculativeType: runtimeSpeculativeType ?? "auto",
|
||||
specDraftNMax: runtimeSpecDraftNMax ?? null,
|
||||
tensorParallel: runtimeTensorParallel ?? false,
|
||||
chatTemplateOverride: runtimeChatTemplateOverride ?? null,
|
||||
};
|
||||
}, [
|
||||
inferenceParams.checkpoint,
|
||||
inferenceParams.maxSeqLength,
|
||||
isExternalModel,
|
||||
activeGgufVariant,
|
||||
ggufContextLength,
|
||||
runtimeCustomContextLength,
|
||||
runtimeKvCacheDtype,
|
||||
runtimeSpeculativeType,
|
||||
runtimeSpecDraftNMax,
|
||||
runtimeTensorParallel,
|
||||
runtimeChatTemplateOverride,
|
||||
]);
|
||||
const activeModelIsGguf = useMemo(() => {
|
||||
const checkpoint = inferenceParams.checkpoint;
|
||||
if (!checkpoint || isExternalModel) return false;
|
||||
return (
|
||||
activeGgufVariant != null ||
|
||||
ggufContextLength != null ||
|
||||
checkpoint.toLowerCase().endsWith(".gguf")
|
||||
);
|
||||
}, [
|
||||
inferenceParams.checkpoint,
|
||||
isExternalModel,
|
||||
activeGgufVariant,
|
||||
ggufContextLength,
|
||||
]);
|
||||
const activeModelIsLora = useMemo(() => {
|
||||
const checkpoint = inferenceParams.checkpoint;
|
||||
if (!checkpoint || isExternalModel) return false;
|
||||
const model = modelsFromStore.find((entry) => entry.id === checkpoint);
|
||||
if (model) return model.isLora;
|
||||
const lora = lorasFromStore.find((entry) => entry.id === checkpoint);
|
||||
return lora?.exportType === "lora";
|
||||
}, [inferenceParams.checkpoint, isExternalModel, modelsFromStore, lorasFromStore]);
|
||||
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
|
||||
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
|
||||
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
|
||||
|
|
@ -1826,21 +1783,75 @@ export function ChatPage({
|
|||
closeArtifactSurface();
|
||||
}, [activeThreadId, closeArtifactSurface, selectedArtifact, view]);
|
||||
|
||||
const hasActiveModel = Boolean(inferenceParams.checkpoint);
|
||||
// Abandon a staged (not-yet-loaded) pick when the chat context actually
|
||||
// changes — switching threads, leaving single view, or starting a new chat /
|
||||
// project — so a stale Load button can't resurface in a different context.
|
||||
// New Chat keeps activeThreadId null and only bumps the `new` search nonce, so
|
||||
// the key includes the route identity, not just the thread. Mirrors the
|
||||
// incognito reset pattern. (Route exit is handled in __root.tsx, which runs
|
||||
// after this unmounts.) Clear only on a real change, never on mount: staging
|
||||
// from the Hub sets pendingSelection then navigates here, and clearing on
|
||||
// mount would wipe it. Comparing the previous context (rather than a first-run
|
||||
// flag) is also safe under StrictMode's double-invoke and component remounts.
|
||||
const chatContextKey = `${view.mode}|${activeThreadId ?? ""}|${search.new ?? ""}|${search.project ?? ""}`;
|
||||
const [pendingHubAutoLoad, setPendingHubAutoLoad] =
|
||||
useState<PendingHubAutoLoad | null>(null);
|
||||
const chatContextKeyRef = useLatestRef(chatContextKey);
|
||||
const prevChatContextRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const prev = prevChatContextRef.current;
|
||||
prevChatContextRef.current = chatContextKey;
|
||||
if (prev === null || prev === chatContextKey) return;
|
||||
detachStaged();
|
||||
}, [chatContextKey, detachStaged]);
|
||||
|
||||
const hasActiveModel = Boolean(inferenceParams.checkpoint);
|
||||
// Load immediately, or — when "Load on selection" is off — stage the pick so
|
||||
// its load options can be set first. Shared by the main selector, native
|
||||
// drag-drop/picker, and the dropped-file chip (the Hub stages via the store).
|
||||
const stageOrLoad = useCallback(
|
||||
async (selection: SelectedModelInput) => {
|
||||
const store = useChatRuntimeStore.getState();
|
||||
// An un-cached HF repo (GGUF variant or a full non-GGUF snapshot) downloads
|
||||
// through the manager first (global indicator), then auto-loads. Everything
|
||||
// else -- cached picks, local/native files, LoRA, external -- loads now.
|
||||
const wantManagerDownload =
|
||||
isDownloadableHubRepo(selection) && !selection.isDownloaded;
|
||||
if (
|
||||
(!hasGgufSource(selection) && !wantManagerDownload) ||
|
||||
(store.loadOnSelection && selection.isDownloaded)
|
||||
) {
|
||||
// Detach any staged pick first so its edited knobs (e.g. a custom
|
||||
// context length) don't leak into this immediate load -- resolveLoad
|
||||
// reads customContextLength before checking the target is GGUF. Detach
|
||||
// (not abandon) keeps its download running.
|
||||
detachStaged();
|
||||
// Load-on-selection skips the sheet, so seed the saved knobs here the
|
||||
// way the sheet's restore effect would; the switch would otherwise reset
|
||||
// the remembered speculative choice (keepSpeculative below prevents it).
|
||||
const remembered = hasGgufSource(selection)
|
||||
? loadRememberedLoadSettings(rememberedLoadSettingsKey(selection))
|
||||
: null;
|
||||
if (remembered) store.applyRememberedLoadSettings(remembered);
|
||||
await selectModel(
|
||||
remembered ? { ...selection, keepSpeculative: true } : selection,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Loads can't queue behind each other, but a download is independent: if
|
||||
// the pick needs downloading, start it in the manager so it runs alongside
|
||||
// the load. Nothing to download (already on device) just waits.
|
||||
if (store.modelLoading) {
|
||||
// Both an uncached non-GGUF snapshot (wantManagerDownload) and an
|
||||
// uncached remote GGUF quant download through the manager, so either can
|
||||
// run in the background while another model loads. wantManagerDownload
|
||||
// excludes GGUF by design, so the GGUF case is checked separately.
|
||||
const wantBackgroundDownload =
|
||||
wantManagerDownload ||
|
||||
(selection.source === "hub" &&
|
||||
hasGgufSource(selection) &&
|
||||
!selection.isDownloaded);
|
||||
// The model currently loading already downloads as part of its own load
|
||||
// (the /load flow fetches before setting the checkpoint), so re-picking
|
||||
// it must not kick off a second transfer against the same cache.
|
||||
const isLoadingThisPick =
|
||||
!!loadingModel &&
|
||||
normalizeModelRef(loadingModel.id) ===
|
||||
|
|
@ -1851,6 +1862,11 @@ export function ChatPage({
|
|||
description: "It's downloading as part of the load in progress.",
|
||||
});
|
||||
} else if (wantBackgroundDownload) {
|
||||
// Only claim the download started once a job is actually created. A
|
||||
// transport conflict records state that is only resolvable from the
|
||||
// Hub download card, so point the user there instead of showing a
|
||||
// success toast for a transfer that never began; "busy" and "error"
|
||||
// already surface their own toasts.
|
||||
const outcome = await downloadManager.requestStart({
|
||||
kind: DOWNLOAD_KIND.MODEL,
|
||||
repoId: selection.id,
|
||||
|
|
@ -1867,11 +1883,6 @@ export function ChatPage({
|
|||
description:
|
||||
"An earlier partial download used a different transport. Open the Hub tab to resume or restart it.",
|
||||
});
|
||||
} else if (outcome === "busy") {
|
||||
toast.info("Download already in progress", {
|
||||
description:
|
||||
"Another download for this model is still running. Reselect it once that finishes to load it.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast.info("Another model is already loading", {
|
||||
|
|
@ -1880,118 +1891,23 @@ export function ChatPage({
|
|||
}
|
||||
return;
|
||||
}
|
||||
const wantManagerStage =
|
||||
wantManagerDownload ||
|
||||
(selection.source === "hub" &&
|
||||
hasGgufSource(selection) &&
|
||||
!selection.isDownloaded);
|
||||
if (wantManagerStage) {
|
||||
setPendingHubAutoLoad({
|
||||
selection,
|
||||
contextKey: chatContextKey,
|
||||
originCheckpoint: store.params.checkpoint,
|
||||
originGgufVariant: store.activeGgufVariant,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPendingHubAutoLoad(null);
|
||||
const previousConfig = currentRuntimePerModelConfig({
|
||||
includeMaxSeqLength: true,
|
||||
});
|
||||
const hasAppliedConfig = applyModelLoadConfigToRuntime(
|
||||
selection.config ?? rememberedConfigFor(selection),
|
||||
);
|
||||
await selectModel({
|
||||
...selection,
|
||||
...(hasAppliedConfig ? { keepSpeculative: true } : {}),
|
||||
previousConfig,
|
||||
// Detach the prior staged pick (keeping its download) before rebinding, so
|
||||
// a second pick downloads alongside the first instead of cancelling it.
|
||||
detachStaged();
|
||||
store.stageModel({
|
||||
id: selection.id,
|
||||
isLora: selection.isLora,
|
||||
ggufVariant: selection.ggufVariant,
|
||||
isDownloaded: selection.isDownloaded,
|
||||
expectedBytes: selection.expectedBytes,
|
||||
nativePathToken: selection.nativePathToken,
|
||||
isGguf: selection.isGguf,
|
||||
isHubRepo: wantManagerDownload || undefined,
|
||||
autoLoad: store.loadOnSelection,
|
||||
});
|
||||
},
|
||||
[selectModel, loadingModel, rememberedConfigFor, chatContextKey],
|
||||
[detachStaged, selectModel, loadingModel],
|
||||
);
|
||||
useRepoDownload({
|
||||
kind: DOWNLOAD_KIND.MODEL,
|
||||
repoId: pendingHubAutoLoad?.selection.id ?? "__hub_autoload_idle__",
|
||||
activeVariant: pendingHubAutoLoad?.selection.ggufVariant ?? null,
|
||||
onComplete: (variant) => {
|
||||
const pending = pendingHubAutoLoad;
|
||||
if (
|
||||
!pending ||
|
||||
(pending.selection.ggufVariant ?? null) !== (variant ?? null)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setPendingHubAutoLoad(null);
|
||||
const store = useChatRuntimeStore.getState();
|
||||
if (
|
||||
!active ||
|
||||
pending.contextKey !== chatContextKey ||
|
||||
normalizeModelRef(pending.originCheckpoint) !==
|
||||
normalizeModelRef(store.params.checkpoint) ||
|
||||
pending.originGgufVariant !== store.activeGgufVariant
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void stageOrLoad({ ...pending.selection, isDownloaded: true });
|
||||
},
|
||||
onError: (variant) => {
|
||||
if (
|
||||
pendingHubAutoLoad &&
|
||||
(pendingHubAutoLoad.selection.ggufVariant ?? null) === (variant ?? null)
|
||||
) {
|
||||
setPendingHubAutoLoad(null);
|
||||
}
|
||||
},
|
||||
onCancelled: (variant) => {
|
||||
if (
|
||||
pendingHubAutoLoad &&
|
||||
(pendingHubAutoLoad.selection.ggufVariant ?? null) === (variant ?? null)
|
||||
) {
|
||||
setPendingHubAutoLoad(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
const pending = pendingHubAutoLoad;
|
||||
if (!pending) return;
|
||||
let active = true;
|
||||
void (async () => {
|
||||
const outcome = await downloadManager.requestStart({
|
||||
kind: DOWNLOAD_KIND.MODEL,
|
||||
repoId: pending.selection.id,
|
||||
variant: pending.selection.ggufVariant ?? null,
|
||||
expectedBytes: pending.selection.expectedBytes ?? 0,
|
||||
});
|
||||
if (!active) return;
|
||||
if (outcome === "started") {
|
||||
toast.info("Downloading model", {
|
||||
description: "It'll load automatically once the download finishes.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (outcome === "conflict") {
|
||||
// Keep pendingHubAutoLoad bound so this surface's cleanup does not wipe
|
||||
// the conflict just recorded by requestStart (which the toast points the
|
||||
// user to); resolving it from the Hub completes the download and this
|
||||
// surface's onComplete auto-loads, mirroring the "started" branch.
|
||||
toast.info("Resume this download from the Hub", {
|
||||
description:
|
||||
"An earlier partial download used a different transport. Open the Hub tab to resume or restart it.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (outcome === "busy") {
|
||||
toast.info("Download already in progress", {
|
||||
description:
|
||||
"Another download for this model is still running. Reselect it once that finishes to load it.",
|
||||
});
|
||||
}
|
||||
setPendingHubAutoLoad((current) => (current === pending ? null : current));
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [pendingHubAutoLoad]);
|
||||
const loadNativeModelIntent = useCallback(
|
||||
async (intent: NativeIntent, loadingDescription: string) => {
|
||||
const label =
|
||||
|
|
@ -2004,11 +1920,6 @@ export function ChatPage({
|
|||
forceReload: true,
|
||||
throwOnError: true,
|
||||
});
|
||||
// Record when this file lease expires so a later reload can prompt
|
||||
// re-selection instead of reusing a token the host has already pruned.
|
||||
useChatRuntimeStore.setState({
|
||||
activeNativePathExpiresAtMs: intent.path.expiresAtMs ?? null,
|
||||
});
|
||||
useNativeIntentStore.getState().clearModelIntent(intent.id);
|
||||
},
|
||||
[stageOrLoad],
|
||||
|
|
@ -2054,20 +1965,28 @@ export function ChatPage({
|
|||
const handleCheckpointChange = useCallback(
|
||||
(
|
||||
value: string,
|
||||
meta?: ModelSelectorChangeMeta,
|
||||
meta?: {
|
||||
source?: string;
|
||||
isLora: boolean;
|
||||
ggufVariant?: string;
|
||||
isDownloaded?: boolean;
|
||||
expectedBytes?: number;
|
||||
isGguf?: boolean;
|
||||
},
|
||||
) => {
|
||||
const store = useChatRuntimeStore.getState();
|
||||
const currentCheckpoint = store.params.checkpoint;
|
||||
const currentVariant = store.activeGgufVariant;
|
||||
if (!value) return;
|
||||
setPendingHubAutoLoad(null);
|
||||
const isSameLoadedModel =
|
||||
value === currentCheckpoint &&
|
||||
(meta?.ggufVariant ?? null) === (currentVariant ?? null);
|
||||
if (isSameLoadedModel && !meta?.forceReload) {
|
||||
if (
|
||||
!value ||
|
||||
(value === currentCheckpoint &&
|
||||
(meta?.ggufVariant ?? null) === (currentVariant ?? null))
|
||||
)
|
||||
return;
|
||||
}
|
||||
if (meta?.source === "external" || isExternalModelId(value)) {
|
||||
// Switching to an external model abandons any staged local pick: cancel
|
||||
// its download too (setCheckpoint below only clears the pending + knobs).
|
||||
abandonStaged();
|
||||
const selectedExternal = parseExternalModelId(value);
|
||||
const selectedProvider = selectedExternal
|
||||
? externalProvidersForChat.find(
|
||||
|
|
@ -2239,17 +2158,19 @@ export function ChatPage({
|
|||
source: meta?.source,
|
||||
isLora: meta?.isLora,
|
||||
ggufVariant: meta?.ggufVariant,
|
||||
isDownloaded: meta?.isDownloaded || isSameLoadedModel,
|
||||
isDownloaded: meta?.isDownloaded,
|
||||
expectedBytes: meta?.expectedBytes,
|
||||
isGguf: meta?.isGguf,
|
||||
config: meta?.config,
|
||||
nativePathToken: meta?.nativePathToken,
|
||||
forceReload: isSameLoadedModel || undefined,
|
||||
};
|
||||
// "Load on selection" off: stage the model and open settings so its
|
||||
// load knobs (tensor parallel, context length…) can be set, then it
|
||||
// loads once via the sheet's Load button. The currently loaded model
|
||||
// stays put until the user commits.
|
||||
await stageOrLoad(selection);
|
||||
})();
|
||||
},
|
||||
[
|
||||
abandonStaged,
|
||||
activeThreadId,
|
||||
externalProvidersForChat,
|
||||
modelsFromStore,
|
||||
|
|
@ -2257,44 +2178,6 @@ export function ChatPage({
|
|||
view,
|
||||
],
|
||||
);
|
||||
const handleReloadActiveModel = useCallback(
|
||||
(config: PerModelConfig) => {
|
||||
const checkpoint = inferenceParams.checkpoint;
|
||||
if (!checkpoint) return;
|
||||
const runtime = useChatRuntimeStore.getState();
|
||||
const nativeToken = runtime.activeNativePathToken;
|
||||
const nativeExpiry = runtime.activeNativePathExpiresAtMs;
|
||||
// A file-picked GGUF is reachable only via its native path token, which
|
||||
// the desktop host prunes after a TTL. Reusing an expired token makes the
|
||||
// reload fail with an opaque error, so prompt the user to re-select the
|
||||
// file instead.
|
||||
if (nativeToken && nativeExpiry != null && Date.now() >= nativeExpiry) {
|
||||
toast.error("This local model file's access has expired.", {
|
||||
description: "Re-select the model file to reload it.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleCheckpointChange(checkpoint, {
|
||||
source: "local",
|
||||
isLora: activeModelIsLora,
|
||||
ggufVariant: activeGgufVariant ?? undefined,
|
||||
// Without the native token the reload validates the display label as a
|
||||
// repo and fails.
|
||||
nativePathToken: nativeToken ?? undefined,
|
||||
isGguf: activeModelIsGguf,
|
||||
isDownloaded: true,
|
||||
config,
|
||||
forceReload: true,
|
||||
});
|
||||
},
|
||||
[
|
||||
inferenceParams.checkpoint,
|
||||
activeGgufVariant,
|
||||
activeModelIsLora,
|
||||
activeModelIsGguf,
|
||||
handleCheckpointChange,
|
||||
],
|
||||
);
|
||||
const handleEject = useCallback(() => {
|
||||
void (async () => {
|
||||
if (await ejectModel()) {
|
||||
|
|
@ -2697,8 +2580,6 @@ export function ChatPage({
|
|||
externalModels={externalModels}
|
||||
value={inferenceParams.checkpoint}
|
||||
activeGgufVariant={activeGgufVariant}
|
||||
activeModelConfig={activeModelConfig}
|
||||
activeGgufContextLength={ggufContextLength}
|
||||
onValueChange={handleCheckpointChange}
|
||||
onEject={handleEject}
|
||||
onFoldersChange={refreshLocalModels}
|
||||
|
|
@ -2752,12 +2633,7 @@ export function ChatPage({
|
|||
<NativeModelChip
|
||||
intent={pendingNativeModelIntent}
|
||||
nativeReadsDisabled={!nativePathLeasesSupported}
|
||||
onLoad={() =>
|
||||
loadNativeModelIntent(
|
||||
pendingNativeModelIntent,
|
||||
"Loading selected local GGUF model.",
|
||||
)
|
||||
}
|
||||
onLoad={(selection) => stageOrLoad(selection)}
|
||||
/>
|
||||
) : null}
|
||||
{loadingModel && loadToastDismissed ? (
|
||||
|
|
@ -2914,22 +2790,13 @@ export function ChatPage({
|
|||
open={active && settingsOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSettingsOpen(open);
|
||||
// Closing the sheet abandons a staged (not-yet-loaded) pick: cancel its
|
||||
// download and revert the staged knobs so nothing lingers as a dirty
|
||||
// edit (or a background download) on the loaded model.
|
||||
if (!open) abandonStaged();
|
||||
}}
|
||||
params={inferenceParams}
|
||||
onParamsChange={setInferenceParams}
|
||||
modelConfig={
|
||||
view.mode !== "compare" && activeModelConfig && !modelLoading ? (
|
||||
<SidebarModelConfig
|
||||
modelId={inferenceParams.checkpoint}
|
||||
ggufVariant={activeGgufVariant ?? null}
|
||||
isGguf={activeModelIsGguf}
|
||||
nativeContextLength={ggufNativeContextLength}
|
||||
loadedContextLength={ggufContextLength}
|
||||
loadedConfig={activeModelConfig}
|
||||
onReload={handleReloadActiveModel}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
isExternalModel={isExternalModel}
|
||||
providerCapabilities={activeProviderCapabilities}
|
||||
activeExternalProvider={activeExternalProvider}
|
||||
|
|
@ -2941,6 +2808,62 @@ export function ChatPage({
|
|||
);
|
||||
}}
|
||||
externalProviderType={activeExternalProviderType}
|
||||
loadingModel={loadingModel}
|
||||
onReloadModel={() => {
|
||||
const state = useChatRuntimeStore.getState();
|
||||
if (state.params.checkpoint) {
|
||||
selectModel({
|
||||
id: state.params.checkpoint,
|
||||
ggufVariant: state.activeGgufVariant ?? undefined,
|
||||
forceReload: true,
|
||||
isDownloaded: true,
|
||||
loadingDescription: "Reloading with updated chat template.",
|
||||
});
|
||||
}
|
||||
}}
|
||||
onLoadPendingModel={() => {
|
||||
const pending = useChatRuntimeStore.getState().pendingSelection;
|
||||
if (!pending) return;
|
||||
const keyAtLoad = chatContextKey;
|
||||
// forceReload: the staged model isn't loaded yet, so bypass the
|
||||
// same-checkpoint dedupe. keepSpeculative: honor the speculative mode
|
||||
// set on the sidebar.
|
||||
void selectModel({
|
||||
...pending,
|
||||
forceReload: true,
|
||||
keepSpeculative: true,
|
||||
throwOnError: true,
|
||||
}).catch(() => {
|
||||
// Recoverable failure (expired token, gated repo, OOM…): the pick is
|
||||
// cleared only on success, so it normally stays staged with edited
|
||||
// knobs intact — nothing to restore.
|
||||
const store = useChatRuntimeStore.getState();
|
||||
// Still staged (this pick, or a newer one queued meanwhile): leave it.
|
||||
if (store.pendingSelection) return;
|
||||
// Cleared mid-load (sheet closed / switched chats). Re-stage only if
|
||||
// the staged-load is still wanted: same chat context, sheet still
|
||||
// open, page still mounted.
|
||||
const stillWanted =
|
||||
mountedRef.current &&
|
||||
store.settingsPanelOpen &&
|
||||
chatContextKeyRef.current === keyAtLoad;
|
||||
if (stillWanted) {
|
||||
store.setPendingSelection(pending);
|
||||
} else {
|
||||
// Abandoned (closed the sheet / switched chats / left chat): drop
|
||||
// the orphaned staged knob edits so they don't linger as dirty
|
||||
// settings over the loaded model.
|
||||
store.resetModelSettingsToLoaded();
|
||||
}
|
||||
});
|
||||
}}
|
||||
stagedDownloadFraction={stagedDownload.progress?.fraction ?? null}
|
||||
onCancelStagedDownload={() =>
|
||||
stagedDownload.cancelDownload(
|
||||
useChatRuntimeStore.getState().pendingSelection?.ggufVariant ??
|
||||
null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ChatActiveContext.Provider>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -30,6 +30,7 @@ import {
|
|||
import { formatEta, formatRate } from "../utils/format-transfer";
|
||||
import {
|
||||
isLocalModelPath,
|
||||
pendingSelectionMatches,
|
||||
readPersistedSpeculativeType,
|
||||
resolveToolsEnabledOnLoad,
|
||||
saveSpeculativeType,
|
||||
|
|
@ -52,10 +53,7 @@ import {
|
|||
isMultimodalResponse,
|
||||
} from "../types/api";
|
||||
import { isExternalModelId } from "../external-providers";
|
||||
import {
|
||||
applyPerModelConfigToRuntime,
|
||||
type PerModelConfig,
|
||||
} from "@/features/model-picker";
|
||||
import { cancelStagedModelDownload } from "@/features/hub";
|
||||
import type {
|
||||
ChatLoraSummary,
|
||||
ChatModelSummary,
|
||||
|
|
@ -80,10 +78,9 @@ export type SelectedModelInput = {
|
|||
isGguf?: boolean;
|
||||
throwOnError?: boolean;
|
||||
/** Keep the current speculative-decoding choice across the model switch
|
||||
* instead of resetting it to the standing preference. */
|
||||
* instead of resetting it to the standing preference. Set by the deferred
|
||||
* ("Load on selection") Load, where the user picked it for this model. */
|
||||
keepSpeculative?: boolean;
|
||||
config?: PerModelConfig;
|
||||
previousConfig?: PerModelConfig;
|
||||
};
|
||||
|
||||
// Approved fingerprints by checkpoint, so a rollback after a failed switch can resend
|
||||
|
|
@ -458,11 +455,27 @@ export function useChatModelRuntime() {
|
|||
typeof selection === "string" ? false : selection.throwOnError ?? false;
|
||||
const keepSpeculative =
|
||||
typeof selection === "string" ? false : selection.keepSpeculative ?? false;
|
||||
// Picking/loading any model abandons a staged (deferred) selection.
|
||||
// Before the early-returns below so even a no-op re-select clears the
|
||||
// stage.
|
||||
const staged = useChatRuntimeStore.getState().pendingSelection;
|
||||
if (staged) {
|
||||
// Loading a DIFFERENT model abandons this stage. Loading the staged pick
|
||||
// ITSELF keeps it so the sidebar can show its load settings (context, KV
|
||||
// cache, …) during the load. Cleared on success below; on failure it's
|
||||
// left staged so the user can retry (see onLoadPendingModel's catch).
|
||||
const loadingStagedPick = pendingSelectionMatches(staged, {
|
||||
id: modelId,
|
||||
ggufVariant,
|
||||
nativePathToken,
|
||||
});
|
||||
if (!loadingStagedPick) {
|
||||
cancelStagedModelDownload(staged);
|
||||
useChatRuntimeStore.getState().setPendingSelection(null);
|
||||
}
|
||||
}
|
||||
const currentVariant = useChatRuntimeStore.getState().activeGgufVariant;
|
||||
if (!forceReload && (!modelId || (params.checkpoint === modelId && (ggufVariant ?? null) === (currentVariant ?? null)))) {
|
||||
if (typeof selection !== "string" && selection.previousConfig) {
|
||||
applyPerModelConfigToRuntime(selection.previousConfig);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A load is already in flight. If it's this exact pick (id + GGUF variant +
|
||||
|
|
@ -475,9 +488,6 @@ export function useChatModelRuntime() {
|
|||
// every entry point is covered, not just the staged Load button.
|
||||
const inFlightLoad = loadingModelRef.current;
|
||||
if (inFlightLoad) {
|
||||
if (typeof selection !== "string" && selection.previousConfig) {
|
||||
applyPerModelConfigToRuntime(selection.previousConfig);
|
||||
}
|
||||
const loadingSamePick =
|
||||
inFlightLoad.id === modelId &&
|
||||
(inFlightLoad.ggufVariant ?? null) === (ggufVariant ?? null) &&
|
||||
|
|
@ -572,24 +582,20 @@ export function useChatModelRuntime() {
|
|||
previousModel?.isGguf === true
|
||||
|| previousVariant != null
|
||||
|| (previousCheckpoint?.toLowerCase().endsWith(".gguf") ?? false);
|
||||
// Roll back to the previous model's own context. previousConfig was
|
||||
// snapshotted before this load pre-applied the next model's config to
|
||||
// the shared store, so params.maxSeqLength may already be the next
|
||||
// model's value; fall back to it only when no snapshot exists.
|
||||
const previousMaxSeqLength =
|
||||
(typeof selection !== "string"
|
||||
? selection.previousConfig?.maxSeqLength
|
||||
: null) ?? maxSeqLength;
|
||||
const rollbackMaxSeqLength = previousIsGguf
|
||||
? (stateBeforeUnload.ggufContextLength ?? 0)
|
||||
: previousMaxSeqLength;
|
||||
: maxSeqLength;
|
||||
const hfToken = stateBeforeUnload.hfToken || null;
|
||||
const previousModelRequiresTrustRemoteCode =
|
||||
stateBeforeUnload.modelRequiresTrustRemoteCode;
|
||||
const previousActiveNativePathToken =
|
||||
stateBeforeUnload.activeNativePathToken;
|
||||
// Snapshot the load settings at click time, before the awaits below
|
||||
// (validation, the trust dialog, unload).
|
||||
// (validation, the trust dialog, unload). For a staged Load these knobs
|
||||
// stay editable and a sheet-close revert (abandonStagedModel) can fire
|
||||
// mid-load; reading them live just before loadModel would let the load
|
||||
// use post-click values. The model-switch speculative reset below
|
||||
// updates this snapshot in lock-step so non-staged loads are unchanged.
|
||||
const loadChatTemplateOverride = stateBeforeUnload.chatTemplateOverride;
|
||||
const loadKvCacheDtype = stateBeforeUnload.kvCacheDtype;
|
||||
const loadCustomContextLength = stateBeforeUnload.customContextLength;
|
||||
|
|
@ -740,12 +746,7 @@ export function useChatModelRuntime() {
|
|||
// The load applied this spec mode, so persist the user's standing
|
||||
// preference now (the requested intent, not the resolved echo;
|
||||
// saveSpeculativeType keeps only the universal auto/ngram/off).
|
||||
// Skip for a per-model/one-off config (keepSpeculative): that choice
|
||||
// is model-specific and must not overwrite the global default, or a
|
||||
// later model with no saved config would start from it instead of Auto.
|
||||
if (!keepSpeculative) {
|
||||
saveSpeculativeType(loadSpeculativeType);
|
||||
}
|
||||
saveSpeculativeType(loadSpeculativeType);
|
||||
|
||||
const currentParams = useChatRuntimeStore.getState().params;
|
||||
setParams(
|
||||
|
|
@ -781,11 +782,9 @@ export function useChatModelRuntime() {
|
|||
const reportedNativeCtx = loadResponse.is_gguf
|
||||
? (loadResponse.native_context_length ?? null)
|
||||
: null;
|
||||
// Retain the user's requested context so re-opening or re-saving
|
||||
// the config keeps the intended override, not the backend's
|
||||
// effective (auto-fit) context; null stays null so an auto-fit
|
||||
// never becomes a stored override.
|
||||
const keepCustomCtx = loadCustomContextLength;
|
||||
// A successful reload has applied settings, so clear pending custom
|
||||
// context state and display the backend-reported effective context.
|
||||
const keepCustomCtx = null;
|
||||
const reasoningAlwaysOn = loadResponse.reasoning_always_on ?? false;
|
||||
const reasoningStyle = loadResponse.reasoning_style ?? "enable_thinking";
|
||||
const supportsReasoning = loadResponse.supports_reasoning ?? false;
|
||||
|
|
@ -902,6 +901,25 @@ export function useChatModelRuntime() {
|
|||
recordLastLocalModelLoad({ id: modelId, kind: "model" });
|
||||
}
|
||||
}
|
||||
// A successful load owns the shared (pick-unscoped) settings fields,
|
||||
// so any surviving stage is stale: the just-loaded pick itself, or a
|
||||
// pick queued for a different model mid-load whose knobs this load
|
||||
// overwrote. Drop it. Only a DIFFERENT pick's download needs
|
||||
// cancelling; the loaded pick's is already consumed, and cancelling
|
||||
// it inside its post-complete linger window would flicker its card.
|
||||
const staleStage = useChatRuntimeStore.getState().pendingSelection;
|
||||
if (staleStage) {
|
||||
if (
|
||||
!pendingSelectionMatches(staleStage, {
|
||||
id: modelId,
|
||||
ggufVariant,
|
||||
nativePathToken,
|
||||
})
|
||||
) {
|
||||
cancelStagedModelDownload(staleStage);
|
||||
}
|
||||
useChatRuntimeStore.getState().setPendingSelection(null);
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip rollback if user cancelled -- model is already being unloaded.
|
||||
if (abortCtrl.signal.aborted) throw error;
|
||||
|
|
@ -936,23 +954,11 @@ export function useChatModelRuntime() {
|
|||
// Restore the previous model in the split mode it was running,
|
||||
// not the default layer split.
|
||||
tensor_parallel: stateBeforeUnload.loadedTensorParallel ?? false,
|
||||
// Restore its KV cache dtype and chat template too, so the
|
||||
// rollback runs the model as it was, not with backend defaults.
|
||||
cache_type_kv: stateBeforeUnload.loadedKvCacheDtype ?? null,
|
||||
chat_template_override:
|
||||
stateBeforeUnload.loadedChatTemplateOverride ?? null,
|
||||
// Restore its speculative-decoding config too. Omitting these
|
||||
// reloaded the model at backend defaults (speculation off)
|
||||
// while the UI still showed it enabled.
|
||||
speculative_type: stateBeforeUnload.loadedSpeculativeType ?? null,
|
||||
spec_draft_n_max: stateBeforeUnload.loadedSpecDraftNMax ?? null,
|
||||
});
|
||||
useChatRuntimeStore.setState({
|
||||
activeNativePathToken: previousActiveNativePathToken ?? null,
|
||||
speculativeType: stateBeforeUnload.loadedSpeculativeType ?? null,
|
||||
loadedSpeculativeType: stateBeforeUnload.loadedSpeculativeType ?? null,
|
||||
specDraftNMax: stateBeforeUnload.loadedSpecDraftNMax ?? null,
|
||||
loadedSpecDraftNMax: stateBeforeUnload.loadedSpecDraftNMax ?? null,
|
||||
loadedSpeculativeType: null,
|
||||
loadedSpecDraftNMax: null,
|
||||
});
|
||||
await refresh();
|
||||
} catch {
|
||||
|
|
@ -1271,9 +1277,6 @@ export function useChatModelRuntime() {
|
|||
resetLoadingUi();
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof selection !== "string" && selection.previousConfig) {
|
||||
applyPerModelConfigToRuntime(selection.previousConfig);
|
||||
}
|
||||
if (abortCtrl.signal.aborted) return; // User cancelled, nothing to report
|
||||
resetLoadingUi();
|
||||
const message =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
// 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 { useCallback, useEffect } from "react";
|
||||
|
||||
import { useRepoDownload } from "@/features/hub/download-manager/use-repo-download";
|
||||
import type { DownloadJob } from "@/features/hub/download-manager/use-repo-download";
|
||||
import { useLatestRef } from "@/features/hub/hooks/use-latest-ref";
|
||||
|
||||
import { fetchGgufContextLength } from "../api/chat-api";
|
||||
import {
|
||||
isPendingGguf,
|
||||
pendingSelectionMatches,
|
||||
useChatRuntimeStore,
|
||||
} from "../stores/chat-runtime-store";
|
||||
import type { PendingModelSelection } from "../stores/chat-runtime-store";
|
||||
|
||||
/**
|
||||
* Drives the deferred ("Load on selection" off) staging flow for a GGUF:
|
||||
* download the file if needed (HF repo) or read it in place (native drag-drop /
|
||||
* picked file), then read its header context length so the settings sheet can
|
||||
* show the real context slider before the single GPU load. The staged context
|
||||
* lands on `pendingSelection.contextLength` (scoped to the staged model, never
|
||||
* the loaded model's `ggufContextLength`). Returns the live download job so the
|
||||
* sheet can render progress / cancel. Mount once on the chat page.
|
||||
*/
|
||||
export function useStagedModelPreparation(opts?: {
|
||||
/** Load the cached file once an autoLoad pick's download completes. */
|
||||
onAutoLoad?: (pending: PendingModelSelection) => void;
|
||||
}): DownloadJob {
|
||||
const pendingId = useChatRuntimeStore((s) => s.pendingSelection?.id ?? null);
|
||||
const pendingVariant = useChatRuntimeStore(
|
||||
(s) => s.pendingSelection?.ggufVariant ?? null,
|
||||
);
|
||||
const pendingNativeToken = useChatRuntimeStore(
|
||||
(s) => s.pendingSelection?.nativePathToken ?? null,
|
||||
);
|
||||
// Only GGUF picks (HF variant or native file) have a header worth reading.
|
||||
const pendingIsGguf = useChatRuntimeStore((s) =>
|
||||
isPendingGguf(s.pendingSelection),
|
||||
);
|
||||
// Non-GGUF HF repos download a full snapshot (variant null) but have no header.
|
||||
const pendingIsHubRepo = useChatRuntimeStore(
|
||||
(s) => s.pendingSelection?.isHubRepo ?? false,
|
||||
);
|
||||
const pendingDownloaded = useChatRuntimeStore(
|
||||
(s) => s.pendingSelection?.isDownloaded ?? false,
|
||||
);
|
||||
const pendingHasContext = useChatRuntimeStore(
|
||||
(s) => s.pendingSelection?.contextLength != null,
|
||||
);
|
||||
const setPendingSelection = useChatRuntimeStore((s) => s.setPendingSelection);
|
||||
const onAutoLoadRef = useLatestRef(opts?.onAutoLoad);
|
||||
|
||||
// A failed or cancelled autoLoad download has no sheet to retry from, so drop
|
||||
// the staged pick rather than leave it waiting on a load that won't come.
|
||||
const handleAutoLoadAbort = useCallback((variant: string | null) => {
|
||||
const latest = useChatRuntimeStore.getState().pendingSelection;
|
||||
if (
|
||||
latest?.autoLoad &&
|
||||
(latest.ggufVariant ?? null) === (variant ?? null)
|
||||
) {
|
||||
useChatRuntimeStore.getState().abandonStagedModel();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchContextMetadata = useCallback(async () => {
|
||||
const current = useChatRuntimeStore.getState().pendingSelection;
|
||||
if (!current?.id || !isPendingGguf(current)) return;
|
||||
const { id, ggufVariant, nativePathToken } = current;
|
||||
try {
|
||||
const contextLength = await fetchGgufContextLength({
|
||||
model_path: id,
|
||||
gguf_variant: ggufVariant,
|
||||
hf_token: useChatRuntimeStore.getState().hfToken || null,
|
||||
nativePathToken,
|
||||
});
|
||||
// Apply only if the same model is still staged (the user may have switched
|
||||
// picks or loaded/cancelled while the request was in flight).
|
||||
const latest = useChatRuntimeStore.getState().pendingSelection;
|
||||
if (
|
||||
latest &&
|
||||
contextLength != null &&
|
||||
pendingSelectionMatches(latest, { id, ggufVariant, nativePathToken })
|
||||
) {
|
||||
setPendingSelection({ ...latest, contextLength });
|
||||
}
|
||||
} catch {
|
||||
// Leave contextLength null: the context slider stays hidden and the user
|
||||
// can still load (context fills in from the load response afterwards).
|
||||
}
|
||||
}, [setPendingSelection]);
|
||||
|
||||
const job = useRepoDownload({
|
||||
kind: "model",
|
||||
// useRepoDownload must be called unconditionally; an idle repo id keeps it
|
||||
// inert until something is staged.
|
||||
repoId: pendingId ?? "__staged_idle__",
|
||||
activeVariant: pendingVariant,
|
||||
onComplete: (variant) => {
|
||||
// autoLoad picks load the cached file now; staged picks read the header so
|
||||
// the sheet's context slider can show before a manual load.
|
||||
const latest = useChatRuntimeStore.getState().pendingSelection;
|
||||
if (
|
||||
latest?.autoLoad &&
|
||||
(latest.ggufVariant ?? null) === (variant ?? null)
|
||||
) {
|
||||
onAutoLoadRef.current?.(latest);
|
||||
return;
|
||||
}
|
||||
void fetchContextMetadata();
|
||||
},
|
||||
onError: handleAutoLoadAbort,
|
||||
onCancelled: handleAutoLoadAbort,
|
||||
});
|
||||
|
||||
// job.requestStartDownload's identity changes per render; hold it in a ref so
|
||||
// the staging effect re-runs only when the staged model itself changes.
|
||||
const startDownloadRef = useLatestRef(job.requestStartDownload);
|
||||
const fetchMetadataRef = useLatestRef(fetchContextMetadata);
|
||||
|
||||
useEffect(() => {
|
||||
// GGUF picks (header worth reading) and uncached non-GGUF hub repos (full
|
||||
// snapshot, no header) both run here; everything else is loaded directly.
|
||||
if (
|
||||
!pendingId ||
|
||||
(!pendingIsGguf && !pendingIsHubRepo) ||
|
||||
pendingHasContext
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Native files and already-downloaded HF files are local: read the header
|
||||
// now. Otherwise download first (a GGUF variant, or a null-variant snapshot
|
||||
// for a hub repo); onComplete then reads the header or auto-loads.
|
||||
if (pendingNativeToken || pendingDownloaded) {
|
||||
void fetchMetadataRef.current();
|
||||
} else {
|
||||
const expectedBytes =
|
||||
useChatRuntimeStore.getState().pendingSelection?.expectedBytes ?? 0;
|
||||
void startDownloadRef.current(pendingVariant, expectedBytes);
|
||||
}
|
||||
}, [
|
||||
pendingId,
|
||||
pendingVariant,
|
||||
pendingNativeToken,
|
||||
pendingIsGguf,
|
||||
pendingIsHubRepo,
|
||||
pendingDownloaded,
|
||||
pendingHasContext,
|
||||
startDownloadRef,
|
||||
fetchMetadataRef,
|
||||
]);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
|
@ -3,23 +3,11 @@
|
|||
|
||||
export { ChatPage, validateChatSearch, type ChatSearch } from "./chat-page";
|
||||
export {
|
||||
addScanFolder,
|
||||
browseFolders,
|
||||
deleteCachedModel,
|
||||
deleteFineTunedModel,
|
||||
fetchGgufContextLength,
|
||||
getInferenceStatus,
|
||||
listGgufVariants,
|
||||
listLocalModels,
|
||||
listRecommendedFolders,
|
||||
listScanFolders,
|
||||
loadModel,
|
||||
removeScanFolder,
|
||||
type BrowseFoldersResponse,
|
||||
type CachedGgufRepo,
|
||||
type CachedModelRepo,
|
||||
type LocalModelInfo,
|
||||
type ScanFolderInfo,
|
||||
} from "./api/chat-api";
|
||||
export type { GgufVariantDetail } from "./types/api";
|
||||
export {
|
||||
|
|
@ -29,10 +17,6 @@ export {
|
|||
type Preset,
|
||||
} from "./chat-settings-sheet";
|
||||
export { useChatRuntimeStore } from "./stores/chat-runtime-store";
|
||||
export {
|
||||
normalizeSpeculativeType,
|
||||
readPersistedSpeculativeType,
|
||||
} from "./stores/chat-runtime-store";
|
||||
export {
|
||||
preferFullToolOutput,
|
||||
toolOutputKey,
|
||||
|
|
@ -53,11 +37,9 @@ export {
|
|||
} from "./hooks/use-chat-model-runtime";
|
||||
export {
|
||||
customProviderDisplayName,
|
||||
isCustomProviderType,
|
||||
isExternalModelId,
|
||||
parseExternalModelId,
|
||||
} from "./external-providers";
|
||||
export { ApiProviderLogo } from "./api-provider-logo";
|
||||
export { useExternalProvidersStore } from "./stores/external-providers-store";
|
||||
export { ChatSearchDialog } from "./components/chat-search-dialog";
|
||||
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";
|
||||
|
|
|
|||
|
|
@ -204,6 +204,11 @@ export function applyActiveModelStatusToStore(
|
|||
ggufContextLength: currentGgufContextLength,
|
||||
ggufMaxContextLength,
|
||||
ggufNativeContextLength,
|
||||
// A non-GGUF status must also drop a stale native-path token: without this the
|
||||
// isGguf OR (activeGgufVariant || activeNativePathToken || ggufContextLength)
|
||||
// stays true after switching from a native GGUF to a transformers model, so a
|
||||
// Codex-only detection would auto-select for a model its preflight rejects. A real
|
||||
// GGUF load reports is_gguf: true, so its token is preserved (the load path owns it).
|
||||
...(status.is_gguf ? {} : { activeNativePathToken: null }),
|
||||
modelRequiresTrustRemoteCode: status.requires_trust_remote_code ?? false,
|
||||
defaultChatTemplate: nextDefaultChatTemplate,
|
||||
|
|
|
|||
|
|
@ -79,11 +79,6 @@ import { KnowledgeBaseComposerButton } from "@/features/rag/components/knowledge
|
|||
import { NewProjectDialog } from "./components/new-project-dialog";
|
||||
import { useChatProjects } from "./hooks/use-chat-projects";
|
||||
import { confirmRemoteCodeIfNeeded } from "@/features/security";
|
||||
import {
|
||||
normalizeMaxSeqLength,
|
||||
resolveInitialConfig,
|
||||
type PerModelConfig,
|
||||
} from "@/features/model-picker";
|
||||
import {
|
||||
confirmTransformersUpgradeIfNeeded,
|
||||
useTransformersUpgradeDialogStore,
|
||||
|
|
@ -495,24 +490,8 @@ type CompareModelSelection = {
|
|||
id: string;
|
||||
isLora: boolean;
|
||||
ggufVariant?: string;
|
||||
config?: PerModelConfig;
|
||||
};
|
||||
|
||||
function cleanCompareChatTemplate(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
return value?.trim() ? value : null;
|
||||
}
|
||||
|
||||
function resolveCompareSpecDraftNMax(
|
||||
speculativeType: string | null,
|
||||
value: number | null,
|
||||
): number | null {
|
||||
return speculativeType === "mtp" || speculativeType === "mtp+ngram"
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
// Tool icon plus an X overlay CSS reveals on hover when the pill is active.
|
||||
function PillGlyph({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
|
|
@ -1045,11 +1024,13 @@ export function SharedComposer({
|
|||
const store = useChatRuntimeStore.getState();
|
||||
const maxSeqLength = store.params.maxSeqLength;
|
||||
const trustRemoteCode = store.params.trustRemoteCode ?? false;
|
||||
const fallbackTensorParallel = store.tensorParallel;
|
||||
const chatTemplateOverride = store.chatTemplateOverride;
|
||||
const effectiveChatTemplateOverride = chatTemplateOverride?.trim()
|
||||
? chatTemplateOverride
|
||||
: null;
|
||||
const specSettings = resolveSpeculativeSettingsForLoad({
|
||||
usePersistedPreference: true,
|
||||
});
|
||||
let loadedFromConfig = false;
|
||||
|
||||
function modelDisplayName(id: string): string {
|
||||
const parts = id.split("/");
|
||||
|
|
@ -1065,53 +1046,21 @@ export function SharedComposer({
|
|||
sel: CompareModelSelection,
|
||||
): Promise<string> {
|
||||
const currentStore = useChatRuntimeStore.getState();
|
||||
const config = sel.config ?? null;
|
||||
// This pane's effective config: an explicit selection config, else the
|
||||
// remembered store config for this model/quant (never the other pane's).
|
||||
// A model with no saved config resolves to all-null defaults, so every
|
||||
// setting below still falls through to its session default.
|
||||
const resolved = config
|
||||
? { config, remembered: true }
|
||||
: resolveInitialConfig(sel.id, sel.ggufVariant ?? null);
|
||||
const ownConfig = resolved.config;
|
||||
const ownRemembered = resolved.remembered;
|
||||
// Mirror single-view resolveLoadMaxSeqLength: a GGUF pane with no
|
||||
// explicit context loads at native (0 -> n_ctx_train), not the session
|
||||
// maxSeqLength, which would silently shrink the picker's shown context.
|
||||
const isGgufLoad =
|
||||
(sel.ggufVariant ?? null) != null ||
|
||||
sel.id.toLowerCase().endsWith(".gguf");
|
||||
const effectiveMaxSeqLength =
|
||||
ownConfig.customContextLength ??
|
||||
normalizeMaxSeqLength(ownConfig.maxSeqLength) ??
|
||||
(isGgufLoad ? 0 : maxSeqLength);
|
||||
const effectiveChatTemplateOverride = cleanCompareChatTemplate(
|
||||
ownConfig.chatTemplateOverride,
|
||||
);
|
||||
const effectiveSpeculativeType =
|
||||
ownConfig.speculativeType ?? specSettings.speculativeType;
|
||||
const effectiveSpecDraftNMax = ownRemembered
|
||||
? resolveCompareSpecDraftNMax(
|
||||
effectiveSpeculativeType,
|
||||
ownConfig.specDraftNMax,
|
||||
)
|
||||
: specSettings.specDraftNMax;
|
||||
const effectiveTensorParallel = ownRemembered
|
||||
? ownConfig.tensorParallel
|
||||
: fallbackTensorParallel;
|
||||
let loadTrustRemoteCode = trustRemoteCode;
|
||||
let approvedRemoteCodeFingerprint: string | null = null;
|
||||
const isAlreadyActive =
|
||||
currentStore.params.checkpoint === sel.id &&
|
||||
(currentStore.activeGgufVariant ?? null) ===
|
||||
(sel.ggufVariant ?? null);
|
||||
if (isAlreadyActive && !config && !loadedFromConfig) {
|
||||
// Already loaded (gate passed at first load): skip a redundant reload that would
|
||||
// re-trigger the gate without the approval fingerprint and fail for HIGH custom code.
|
||||
if (isAlreadyActive) {
|
||||
return "ready";
|
||||
}
|
||||
const validation = await validateModel({
|
||||
model_path: sel.id,
|
||||
hf_token: currentStore.hfToken || null,
|
||||
max_seq_length: effectiveMaxSeqLength,
|
||||
max_seq_length: maxSeqLength,
|
||||
load_in_4bit: true,
|
||||
is_lora: sel.isLora,
|
||||
gguf_variant: sel.ggufVariant ?? null,
|
||||
|
|
@ -1165,24 +1114,19 @@ export function SharedComposer({
|
|||
const resp = await loadModel({
|
||||
model_path: sel.id,
|
||||
hf_token: useChatRuntimeStore.getState().hfToken || null,
|
||||
max_seq_length: effectiveMaxSeqLength,
|
||||
max_seq_length: maxSeqLength,
|
||||
load_in_4bit: true,
|
||||
is_lora: sel.isLora,
|
||||
gguf_variant: sel.ggufVariant ?? null,
|
||||
trust_remote_code: loadTrustRemoteCode,
|
||||
approved_remote_code_fingerprint: approvedRemoteCodeFingerprint,
|
||||
chat_template_override: effectiveChatTemplateOverride,
|
||||
cache_type_kv: ownConfig.kvCacheDtype ?? null,
|
||||
speculative_type: effectiveSpeculativeType,
|
||||
spec_draft_n_max: effectiveSpecDraftNMax,
|
||||
tensor_parallel: effectiveTensorParallel,
|
||||
speculative_type: specSettings.speculativeType,
|
||||
spec_draft_n_max: specSettings.specDraftNMax,
|
||||
// Honor the Tensor Parallelism toggle on compare loads too.
|
||||
tensor_parallel: currentStore.tensorParallel,
|
||||
});
|
||||
// Keep a compare pane's per-model speculative choice load-local: only
|
||||
// persist the global preference when it came from the global settings,
|
||||
// matching the single-model load path.
|
||||
if (ownConfig.speculativeType == null) {
|
||||
saveSpeculativeType(effectiveSpeculativeType);
|
||||
}
|
||||
saveSpeculativeType(specSettings.speculativeType);
|
||||
upgradeUnloadedActive = false;
|
||||
const store = useChatRuntimeStore.getState();
|
||||
store.setCheckpoint(
|
||||
|
|
@ -1198,38 +1142,11 @@ export function SharedComposer({
|
|||
...reasoningCapsFromLoad(resp),
|
||||
supportsPreserveThinking: resp.supports_preserve_thinking ?? false,
|
||||
supportsTools: resp.supports_tools ?? false,
|
||||
kvCacheDtype: resp.cache_type_kv ?? null,
|
||||
loadedKvCacheDtype: resp.cache_type_kv ?? null,
|
||||
tensorParallel: resp.tensor_parallel ?? false,
|
||||
loadedTensorParallel: resp.tensor_parallel ?? false,
|
||||
defaultChatTemplate: resp.chat_template ?? null,
|
||||
chatTemplateOverride: effectiveChatTemplateOverride,
|
||||
loadedChatTemplateOverride: effectiveChatTemplateOverride,
|
||||
loadedIsMultimodal: isMultimodalResponse(resp),
|
||||
// Record the context this pane actually loaded with, mirroring the
|
||||
// single-model load path, so that when the last-loaded pane becomes
|
||||
// the active model the settings UI and any subsequent reload or save
|
||||
// use its context instead of the previous/default one.
|
||||
customContextLength: isGgufLoad
|
||||
? (ownConfig.customContextLength ?? null)
|
||||
: null,
|
||||
ggufContextLength: resp.is_gguf ? (resp.context_length ?? null) : null,
|
||||
ggufNativeContextLength: resp.is_gguf
|
||||
? (resp.native_context_length ?? null)
|
||||
: null,
|
||||
ggufMaxContextLength: resp.is_gguf
|
||||
? (resp.max_context_length ?? null)
|
||||
: null,
|
||||
...resolveLoadedSpeculativeSettings(resp),
|
||||
});
|
||||
if (!isGgufLoad) {
|
||||
// Non-GGUF panes carry their context in params.maxSeqLength.
|
||||
store.setParams({
|
||||
...store.params,
|
||||
maxSeqLength: effectiveMaxSeqLength,
|
||||
});
|
||||
}
|
||||
loadedFromConfig = config != null;
|
||||
// Sync the models[] entry with the load response so attach/send gates
|
||||
// read fresh capabilities. /api/models/list can lag a model's actual
|
||||
// state (e.g. a GGUF whose mmproj arrived after the snapshot).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
// 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 { mirrorHfTokenInto, useHfTokenStore } from "@/features/hub";
|
||||
import type { RememberedLoadSettings } from "@/components/assistant-ui/model-selector/remembered-load-settings";
|
||||
import {
|
||||
cancelStagedModelDownload,
|
||||
mirrorHfTokenInto,
|
||||
useHfTokenStore,
|
||||
} from "@/features/hub";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { create } from "zustand";
|
||||
import { isExternalModelId, parseExternalModelId } from "../external-providers";
|
||||
|
|
@ -37,6 +42,7 @@ export const CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY =
|
|||
"unsloth_chat_allow_artifact_network_access";
|
||||
export const CHAT_MCP_ENABLED_KEY = "unsloth_chat_mcp_enabled";
|
||||
export const CHAT_CONFIRM_TOOL_CALLS_KEY = "unsloth_chat_confirm_tool_calls";
|
||||
export const CHAT_LOAD_ON_SELECTION_KEY = "unsloth_chat_load_on_selection";
|
||||
export const CHAT_EXPAND_QUANTIZATIONS_KEY =
|
||||
"unsloth_chat_expand_quantizations";
|
||||
export const CHAT_SHOW_ALL_QUANTIZATIONS_KEY =
|
||||
|
|
@ -491,7 +497,34 @@ export function saveSpeculativeType(value: string | null): void {
|
|||
}
|
||||
}
|
||||
|
||||
/** A pick is a GGUF: HF variant, native file, or a direct local .gguf. */
|
||||
/** A local model staged for a deferred load (see `pendingSelection`). Shape is
|
||||
* a subset of the load hook's `SelectedModelInput`, structurally assignable. */
|
||||
export type PendingModelSelection = {
|
||||
id: string;
|
||||
isLora?: boolean;
|
||||
ggufVariant?: string;
|
||||
isDownloaded?: boolean;
|
||||
expectedBytes?: number;
|
||||
/** Native (drag-drop / picked-from-disk) GGUF: the path token used to read
|
||||
* the header and to load. Absent for HF-repo models. */
|
||||
nativePathToken?: string;
|
||||
/** Direct local .gguf file (custom folder / LM Studio): a GGUF source even
|
||||
* though it carries neither an HF variant nor a native path token. */
|
||||
isGguf?: boolean;
|
||||
/** Native context length read from the GGUF header once the file is local.
|
||||
* Scoped here (not the shared `ggufContextLength`) so a staged model's
|
||||
* metadata never pollutes the currently-loaded model's context display. */
|
||||
contextLength?: number | null;
|
||||
/** "Load on selection" on + un-cached GGUF: download via the manager (global
|
||||
* indicator) without opening the sheet, then load once the download finishes. */
|
||||
autoLoad?: boolean;
|
||||
/** Uncached non-GGUF HF repo: download the full snapshot via the manager
|
||||
* (variant null) the same way GGUF picks download a variant. */
|
||||
isHubRepo?: boolean;
|
||||
};
|
||||
|
||||
/** A pick is a GGUF (HF variant, native file, or a direct local .gguf) and so
|
||||
* has pre-load options worth staging. Works on a selection or a staged pick. */
|
||||
export function hasGgufSource(x: {
|
||||
ggufVariant?: string;
|
||||
nativePathToken?: string;
|
||||
|
|
@ -529,6 +562,30 @@ export function isDownloadableHubRepo(x: {
|
|||
);
|
||||
}
|
||||
|
||||
export function isPendingGguf(pending: PendingModelSelection | null): boolean {
|
||||
return pending != null && hasGgufSource(pending);
|
||||
}
|
||||
|
||||
/** Whether `pending` refers to the same model as `pick` (id + GGUF variant +
|
||||
* native path token, optionals null-normalized). Native ids are display labels
|
||||
* that can collide, so the token must match too — id alone can land on the
|
||||
* wrong file. */
|
||||
export function pendingSelectionMatches(
|
||||
pending: PendingModelSelection | null,
|
||||
pick: {
|
||||
id: string;
|
||||
ggufVariant?: string | null;
|
||||
nativePathToken?: string | null;
|
||||
},
|
||||
): boolean {
|
||||
return (
|
||||
pending != null &&
|
||||
pending.id === pick.id &&
|
||||
(pending.ggufVariant ?? null) === (pick.ggufVariant ?? null) &&
|
||||
(pending.nativePathToken ?? null) === (pick.nativePathToken ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
type ChatRuntimeStore = {
|
||||
settingsHydrated: boolean;
|
||||
params: InferenceParams;
|
||||
|
|
@ -686,6 +743,10 @@ type ChatRuntimeStore = {
|
|||
tensorParallel: boolean;
|
||||
/** Backend-reported tensor-parallel state; null until first hydrated. */
|
||||
loadedTensorParallel: boolean | null;
|
||||
/** Persisted: when false, picking a local model stages it as
|
||||
* `pendingSelection` (and opens settings) instead of loading immediately,
|
||||
* so load settings can be set before the single load. */
|
||||
loadOnSelection: boolean;
|
||||
/** Persisted: expand every On Device GGUF repo's quantizations by default
|
||||
* instead of waiting for a click. */
|
||||
expandQuantizations: boolean;
|
||||
|
|
@ -694,6 +755,9 @@ type ChatRuntimeStore = {
|
|||
/** Persisted, shared by the chat model selector and the Hub page: list only
|
||||
* models whose size fits this device's memory budget. */
|
||||
fitOnDeviceOnly: boolean;
|
||||
/** A local model picked while `loadOnSelection` is off: staged, not loaded.
|
||||
* The settings sheet shows its load knobs and a Load button. */
|
||||
pendingSelection: PendingModelSelection | null;
|
||||
loadedIsMultimodal: boolean;
|
||||
/** Active model is a block-diffusion model (DiffusionGemma): drives the
|
||||
* denoising-canvas artifact auto-render. */
|
||||
|
|
@ -730,10 +794,6 @@ type ChatRuntimeStore = {
|
|||
} | null;
|
||||
modelLoading: boolean;
|
||||
activeNativePathToken: string | null;
|
||||
// Wall-clock expiry (ms) of the active native path token. The desktop host
|
||||
// prunes file leases after a TTL, so a reload checks this to prompt
|
||||
// re-selection instead of reusing a dead token.
|
||||
activeNativePathExpiresAtMs: number | null;
|
||||
hydratePersistedSettings: () => Promise<void>;
|
||||
setModelLoading: (loading: boolean) => void;
|
||||
setModelRequiresTrustRemoteCode: (required: boolean) => void;
|
||||
|
|
@ -811,9 +871,33 @@ type ChatRuntimeStore = {
|
|||
setNudgeToolCalls: (enabled: boolean) => void;
|
||||
setMaxToolCallsPerMessage: (value: number) => void;
|
||||
setToolCallTimeout: (value: number) => void;
|
||||
setKvCacheDtype: (dtype: string | null) => void;
|
||||
setSpeculativeType: (type: string | null) => void;
|
||||
setSpecDraftNMax: (value: number | null) => void;
|
||||
/** Revert the editable load knobs to the loaded model's baseline (or defaults
|
||||
* when nothing is loaded). Used by the settings-sheet Reset button and to
|
||||
* start each deferred-staging session clean so one staged pick's settings
|
||||
* don't leak onto the next. */
|
||||
resetModelSettingsToLoaded: () => void;
|
||||
/** Seed the editable load knobs from a model's remembered settings. Shared by
|
||||
* the settings sheet's restore effect and the "Load on selection" paths,
|
||||
* which skip the sheet but must still honor a saved config. */
|
||||
applyRememberedLoadSettings: (settings: RememberedLoadSettings) => void;
|
||||
setTensorParallel: (value: boolean) => void;
|
||||
setLoadOnSelection: (value: boolean) => void;
|
||||
setExpandQuantizations: (value: boolean) => void;
|
||||
setShowAllQuantizations: (value: boolean) => void;
|
||||
setFitOnDeviceOnly: (value: boolean) => void;
|
||||
setPendingSelection: (selection: PendingModelSelection | null) => void;
|
||||
/** Stage a pick for a deferred load: revert knobs to the loaded baseline,
|
||||
* record the selection, and open the settings sheet. */
|
||||
stageModel: (selection: PendingModelSelection) => void;
|
||||
/** Abandon a staged pick without loading: revert knobs to the loaded baseline
|
||||
* and clear the pending selection. Cancels its in-flight download too, unless
|
||||
* `keepDownload` is set (navigation keeps the transfer running, like Hub). */
|
||||
abandonStagedModel: (opts?: { keepDownload?: boolean }) => void;
|
||||
setCustomContextLength: (v: number | null) => void;
|
||||
setChatTemplateOverride: (template: string | null) => void;
|
||||
setPendingAudio: (base64: string, name: string) => void;
|
||||
clearPendingAudio: () => void;
|
||||
setPendingImageEditReference: (
|
||||
|
|
@ -1015,6 +1099,23 @@ function setScalarSettingVersion<K extends ScalarSettingKey>(
|
|||
saveSettingsPatch({ [key]: value });
|
||||
}
|
||||
|
||||
/** The "revert to the loaded model" baseline for the editable load knobs.
|
||||
* Shared by resetModelSettingsToLoaded (full revert) and stageModel (which
|
||||
* overrides speculative to start a fresh pick from the standing default). */
|
||||
function loadedBaselineSettings(s: ChatRuntimeStore) {
|
||||
const hasLoadedModel = Boolean(s.params.checkpoint);
|
||||
return {
|
||||
customContextLength: null,
|
||||
kvCacheDtype: s.loadedKvCacheDtype,
|
||||
tensorParallel: s.loadedTensorParallel ?? false,
|
||||
speculativeType: hasLoadedModel
|
||||
? s.loadedSpeculativeType
|
||||
: readPersistedSpeculativeType(),
|
||||
specDraftNMax: hasLoadedModel ? s.loadedSpecDraftNMax : null,
|
||||
chatTemplateOverride: s.loadedChatTemplateOverride,
|
||||
};
|
||||
}
|
||||
|
||||
export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
||||
settingsHydrated: false,
|
||||
// Hydrate the last external checkpoint so the external picker survives a
|
||||
|
|
@ -1112,9 +1213,11 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
loadedSpecDraftNMax: null,
|
||||
tensorParallel: false,
|
||||
loadedTensorParallel: null,
|
||||
loadOnSelection: loadBool(CHAT_LOAD_ON_SELECTION_KEY, true),
|
||||
expandQuantizations: loadBool(CHAT_EXPAND_QUANTIZATIONS_KEY, false),
|
||||
showAllQuantizations: loadBool(CHAT_SHOW_ALL_QUANTIZATIONS_KEY, true),
|
||||
fitOnDeviceOnly: loadBool(MODELS_FIT_ON_DEVICE_ONLY_KEY, false),
|
||||
pendingSelection: null,
|
||||
loadedIsMultimodal: false,
|
||||
loadedIsDiffusion: false,
|
||||
customContextLength: null,
|
||||
|
|
@ -1132,7 +1235,6 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
contextUsage: null,
|
||||
modelLoading: false,
|
||||
activeNativePathToken: null,
|
||||
activeNativePathExpiresAtMs: null,
|
||||
hydratePersistedSettings: async () => {
|
||||
if (get().settingsHydrated) {
|
||||
return;
|
||||
|
|
@ -1251,6 +1353,13 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
// Clear stale per-turn usage on model change; the relaxed external-provider
|
||||
// render gate would otherwise show old counters until the next completion.
|
||||
const checkpointChanged = state.params.checkpoint !== modelId;
|
||||
const pendingToClear =
|
||||
checkpointChanged && state.params.checkpoint
|
||||
? state.pendingSelection
|
||||
: null;
|
||||
if (pendingToClear) {
|
||||
cancelStagedModelDownload(pendingToClear);
|
||||
}
|
||||
// Clamp maxTokens to the new model's cap when switching into an external
|
||||
// model so a value carried over from a local session doesn't exceed the
|
||||
// slider's max.
|
||||
|
|
@ -1278,6 +1387,14 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
},
|
||||
activeGgufVariant: ggufVariant ?? null,
|
||||
...(checkpointChanged ? { contextUsage: null } : {}),
|
||||
// Switching away from a loaded model (e.g. picking an external provider)
|
||||
// abandons any staged pick, so its Load button and edited knobs don't
|
||||
// linger over the newly active model. Same revert as abandonStagedModel.
|
||||
// Guarded on a non-empty current checkpoint: an establishing set from a
|
||||
// background status sync (empty -> active) must not wipe a fresh stage.
|
||||
...(pendingToClear
|
||||
? { ...loadedBaselineSettings(state), pendingSelection: null }
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
setActiveThreadId: (activeThreadId) =>
|
||||
|
|
@ -1291,6 +1408,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
// clear any stored external selection so the next refresh doesn't snap
|
||||
// back to a model the user intentionally cleared.
|
||||
saveLastExternalCheckpoint(null);
|
||||
cancelStagedModelDownload(get().pendingSelection);
|
||||
return set((state) => ({
|
||||
params: {
|
||||
...state.params,
|
||||
|
|
@ -1298,7 +1416,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
},
|
||||
activeGgufVariant: null,
|
||||
activeNativePathToken: null,
|
||||
activeNativePathExpiresAtMs: null,
|
||||
pendingSelection: null,
|
||||
ggufContextLength: null,
|
||||
ggufMaxContextLength: null,
|
||||
ggufNativeContextLength: null,
|
||||
|
|
@ -1631,6 +1749,25 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
);
|
||||
return { toolCallTimeout };
|
||||
}),
|
||||
setKvCacheDtype: (kvCacheDtype) => set({ kvCacheDtype }),
|
||||
setSpeculativeType: (speculativeType) => set({ speculativeType }),
|
||||
setSpecDraftNMax: (specDraftNMax) => set({ specDraftNMax }),
|
||||
setTensorParallel: (tensorParallel) => set({ tensorParallel }),
|
||||
resetModelSettingsToLoaded: () => set((s) => loadedBaselineSettings(s)),
|
||||
applyRememberedLoadSettings: (settings) =>
|
||||
// Coalesce every field: a blob persisted by an older/newer build can omit
|
||||
// keys, and a raw spread would push `undefined` into fields typed non-null.
|
||||
set({
|
||||
customContextLength: settings.contextLength ?? null,
|
||||
kvCacheDtype: settings.kvCacheDtype ?? null,
|
||||
speculativeType: settings.speculativeType ?? "auto",
|
||||
specDraftNMax: settings.specDraftNMax ?? null,
|
||||
tensorParallel: settings.tensorParallel ?? false,
|
||||
}),
|
||||
setLoadOnSelection: (loadOnSelection) => {
|
||||
saveBool(CHAT_LOAD_ON_SELECTION_KEY, loadOnSelection);
|
||||
set({ loadOnSelection });
|
||||
},
|
||||
setExpandQuantizations: (expandQuantizations) => {
|
||||
saveBool(CHAT_EXPAND_QUANTIZATIONS_KEY, expandQuantizations);
|
||||
set({ expandQuantizations });
|
||||
|
|
@ -1643,6 +1780,39 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
saveBool(MODELS_FIT_ON_DEVICE_ONLY_KEY, fitOnDeviceOnly);
|
||||
set({ fitOnDeviceOnly });
|
||||
},
|
||||
setPendingSelection: (pendingSelection) => set({ pendingSelection }),
|
||||
stageModel: (selection) => {
|
||||
// Refuse staging mid-load: post-load cleanup would silently drop the queued
|
||||
// pick. stageOrLoad toasts first for callers that can.
|
||||
if (get().modelLoading) return;
|
||||
// Rebinding to a new pick keeps the prior pick's download running so the
|
||||
// user can queue multiple downloads at once (Hub-style).
|
||||
set((s) => {
|
||||
return {
|
||||
...loadedBaselineSettings(s),
|
||||
pendingSelection: selection,
|
||||
// autoLoad downloads silently and loads on completion, so keep the sheet shut.
|
||||
settingsPanelOpen: !selection.autoLoad,
|
||||
// Speculative starts from the standing default, not the loaded model's
|
||||
// mode, so a fresh pick doesn't inherit (and then carry, via the staged
|
||||
// Load's keepSpeculative) a forced MTP mode onto a model that may lack it.
|
||||
speculativeType: readPersistedSpeculativeType(),
|
||||
specDraftNMax: null,
|
||||
};
|
||||
});
|
||||
},
|
||||
abandonStagedModel: (opts) => {
|
||||
const { pendingSelection } = get();
|
||||
if (!pendingSelection) return;
|
||||
// Cancel the staged pick's in-flight download (centralized for every abandon
|
||||
// path: sheet close, thread switch, route exit, new chat). `keepDownload`
|
||||
// opts out so navigation leaves the transfer running, like a Hub download.
|
||||
if (!opts?.keepDownload) cancelStagedModelDownload(pendingSelection);
|
||||
set((s) => ({ ...loadedBaselineSettings(s), pendingSelection: null }));
|
||||
},
|
||||
setCustomContextLength: (customContextLength) => set({ customContextLength }),
|
||||
setChatTemplateOverride: (chatTemplateOverride) =>
|
||||
set({ chatTemplateOverride }),
|
||||
setPendingAudio: (base64, name) =>
|
||||
set({ pendingAudioBase64: base64, pendingAudioName: name }),
|
||||
clearPendingAudio: () =>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FolderBrowser } from "@/components/assistant-ui/model-selector/folder-browser";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
InputGroup,
|
||||
|
|
@ -16,7 +17,6 @@ import {
|
|||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { FolderBrowser } from "@/features/model-picker";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
ArrowRight01Icon,
|
||||
|
|
@ -28,17 +28,17 @@ import {
|
|||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import type { ExportLogEntry } from "../api/export-api";
|
||||
import {
|
||||
EXPORT_METHODS,
|
||||
type ExportMethod,
|
||||
findMergedFormat,
|
||||
} from "../constants";
|
||||
import type { ExportLogEntry } from "../api/export-api";
|
||||
import { getExportLogLineClass } from "../lib/log-style";
|
||||
import {
|
||||
type ExportDestination,
|
||||
selectExportProgressPercent,
|
||||
useExportRuntimeStore,
|
||||
type ExportDestination,
|
||||
} from "../stores/export-runtime-store";
|
||||
|
||||
function useElapsedSeconds(startedAt: number | null, running: boolean): number {
|
||||
|
|
@ -165,9 +165,7 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
|
|||
|
||||
const isExporting = run.isExporting;
|
||||
const isTerminal =
|
||||
run.phase === "success" ||
|
||||
run.phase === "error" ||
|
||||
run.phase === "canceled";
|
||||
run.phase === "success" || run.phase === "error" || run.phase === "canceled";
|
||||
const showConfig = run.phase === "idle";
|
||||
// Gate the log area on the active run's method (from the store) as well as the
|
||||
// local form selection, so it stays visible after navigating away and back
|
||||
|
|
@ -199,9 +197,7 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
|
|||
setFollowTail(nearBottom);
|
||||
};
|
||||
|
||||
const methodTitle = EXPORT_METHODS.find(
|
||||
(m) => m.value === exportMethod,
|
||||
)?.title;
|
||||
const methodTitle = EXPORT_METHODS.find((m) => m.value === exportMethod)?.title;
|
||||
const summary = run.summary;
|
||||
const summaryBaseModel = summary?.baseModelName ?? baseModelName;
|
||||
const summaryCheckpoint = summary?.checkpointLabel ?? checkpoint;
|
||||
|
|
@ -294,10 +290,7 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
|
|||
onClick={() => setFolderBrowserOpen(true)}
|
||||
aria-label="Browse save folder"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={FolderSearchIcon}
|
||||
className="size-4"
|
||||
/>
|
||||
<HugeiconsIcon icon={FolderSearchIcon} className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Browse</TooltipContent>
|
||||
|
|
@ -308,8 +301,8 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
|
|||
<>Default: {defaultSaveDirectory}</>
|
||||
) : (
|
||||
<>
|
||||
Paste an absolute path if the folder browser cannot reach
|
||||
the drive.
|
||||
Paste an absolute path if the folder browser cannot reach the
|
||||
drive.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
|
@ -417,10 +410,7 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
|
|||
: [];
|
||||
const showLabels = items.length > 1;
|
||||
return items.map((o, i) => (
|
||||
<div
|
||||
key={`${o.path}-${i}`}
|
||||
className="flex min-w-0 flex-col gap-0.5"
|
||||
>
|
||||
<div key={`${o.path}-${i}`} className="flex min-w-0 flex-col gap-0.5">
|
||||
{showLabels && o.label ? (
|
||||
<span className="text-xs text-emerald-700/80 dark:text-emerald-300/80">
|
||||
{o.label}
|
||||
|
|
@ -441,22 +431,14 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
|
|||
|
||||
{run.phase === "canceled" && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300">
|
||||
<HugeiconsIcon
|
||||
icon={CancelCircleIcon}
|
||||
className="mt-0.5 size-4 shrink-0"
|
||||
/>
|
||||
<span>
|
||||
Export canceled. Training and inference were not affected.
|
||||
</span>
|
||||
<HugeiconsIcon icon={CancelCircleIcon} className="mt-0.5 size-4 shrink-0" />
|
||||
<span>Export canceled. Training and inference were not affected.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run.phase === "error" && run.error && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<HugeiconsIcon
|
||||
icon={AlertCircleIcon}
|
||||
className="size-4 mt-0.5 shrink-0"
|
||||
/>
|
||||
<HugeiconsIcon icon={AlertCircleIcon} className="size-4 mt-0.5 shrink-0" />
|
||||
<span>{run.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -465,21 +447,15 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
|
|||
<div className="rounded-xl bg-muted/50 p-3 text-xs text-muted-foreground flex flex-col gap-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Base Model</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{summaryBaseModel}
|
||||
</span>
|
||||
<span className="font-medium text-foreground">{summaryBaseModel}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{isAdapter ? "Checkpoint" : "Model"}</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{summaryCheckpoint}
|
||||
</span>
|
||||
<span className="font-medium text-foreground">{summaryCheckpoint}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Export Method</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{summaryMethodLabel}
|
||||
</span>
|
||||
<span className="font-medium text-foreground">{summaryMethodLabel}</span>
|
||||
</div>
|
||||
{summaryMethod === "merged" && summaryFormats.length > 0 && (
|
||||
<div className="flex justify-between gap-3">
|
||||
|
|
@ -508,12 +484,7 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
|
|||
</span>
|
||||
{summaryMethod === "gguf" && run.quantTotal > 1 && (
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||
Quant{" "}
|
||||
{Math.min(
|
||||
run.quantIndex + (isExporting ? 1 : 0),
|
||||
run.quantTotal,
|
||||
)}{" "}
|
||||
of {run.quantTotal}
|
||||
Quant {Math.min(run.quantIndex + (isExporting ? 1 : 0), run.quantTotal)} of {run.quantTotal}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-full border border-border/60 px-2.5 py-1 text-[10px] font-medium tabular-nums text-muted-foreground">
|
||||
|
|
@ -535,10 +506,7 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
|
|||
}
|
||||
/>
|
||||
{run.stage && (
|
||||
<p
|
||||
className="truncate text-[11px] text-muted-foreground/80"
|
||||
title={run.stage}
|
||||
>
|
||||
<p className="truncate text-[11px] text-muted-foreground/80" title={run.stage}>
|
||||
{run.stage}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -588,7 +556,10 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
|
|||
) : (
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{run.logLines.map((entry, idx) => (
|
||||
<div key={idx} className={getExportLogLineClass(entry)}>
|
||||
<div
|
||||
key={idx}
|
||||
className={getExportLogLineClass(entry)}
|
||||
>
|
||||
{formatLogLine(entry)}
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// 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 { ModelDeleteAction } from "@/components/assistant-ui/model-selector/model-delete-action";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
|
|
@ -8,22 +9,18 @@ import {
|
|||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
type GgufVariantDetail,
|
||||
deleteCachedDataset,
|
||||
deleteCachedModel,
|
||||
deleteCachedDataset,
|
||||
formatLocalUpdated,
|
||||
listGgufVariants,
|
||||
useGgufVariantsCacheVersion,
|
||||
} from "@/features/hub";
|
||||
import {
|
||||
classifyUnslothSupport,
|
||||
formatBytes,
|
||||
formatRelativeShort,
|
||||
ggufVariantDisplayLabel,
|
||||
modelIdsMatch,
|
||||
useHfTokenStore,
|
||||
} from "@/features/hub";
|
||||
import { ModelDeleteAction } from "@/features/model-picker";
|
||||
} from "@/features/hub/inventory";
|
||||
import { classifyUnslothSupport } from "@/features/hub/hooks/use-hub-model-search";
|
||||
import { formatBytes, formatRelativeShort } from "@/features/hub/lib/format";
|
||||
import { ggufVariantDisplayLabel } from "@/features/hub/lib/gguf-variant-sort";
|
||||
import { modelIdsMatch } from "@/features/hub/lib/model-identity";
|
||||
import { cn, formatCompact } from "@/lib/utils";
|
||||
import { useHfTokenStore } from "@/features/hub/stores/hf-token-store";
|
||||
import {
|
||||
Download01Icon,
|
||||
FavouriteIcon,
|
||||
|
|
@ -42,7 +39,6 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { paramLabelFromId } from "../lib/view-models";
|
||||
import type {
|
||||
CachedInventoryRow,
|
||||
DiscoverRow,
|
||||
|
|
@ -50,6 +46,7 @@ import type {
|
|||
} from "../types";
|
||||
import { OwnerAvatar } from "./owner-avatar";
|
||||
import { AccessGlyphs } from "./shared";
|
||||
import { paramLabelFromId } from "../lib/view-models";
|
||||
|
||||
const COARSE_POINTER =
|
||||
typeof window !== "undefined" &&
|
||||
|
|
@ -145,15 +142,15 @@ function CachedSizeChipLive({
|
|||
);
|
||||
|
||||
const rows: Array<{ label: string; size_bytes: number }> | null =
|
||||
needsVariantFetch
|
||||
? currentVariantState.status === "loaded" &&
|
||||
!needsVariantFetch
|
||||
? [{ label: repoId, size_bytes: totalBytes }]
|
||||
: currentVariantState.status === "loaded" &&
|
||||
currentVariantState.variants.length > 0
|
||||
? currentVariantState.variants.map((variant) => ({
|
||||
label: ggufVariantDisplayLabel(variant),
|
||||
size_bytes: variant.size_bytes,
|
||||
}))
|
||||
: null
|
||||
: [{ label: repoId, size_bytes: totalBytes }];
|
||||
: null;
|
||||
const variantMessage =
|
||||
currentVariantState.status === "loading"
|
||||
? "Loading downloaded variants..."
|
||||
|
|
@ -278,9 +275,7 @@ function CatalogRow({
|
|||
)}
|
||||
/>
|
||||
<CatalogRowInteractiveContext.Provider value={interactive}>
|
||||
<div
|
||||
className={cn("pointer-events-none relative", card && "z-[1] w-full")}
|
||||
>
|
||||
<div className={cn("pointer-events-none relative", card && "z-[1] w-full")}>
|
||||
{children}
|
||||
</div>
|
||||
</CatalogRowInteractiveContext.Provider>
|
||||
|
|
@ -658,9 +653,7 @@ export const InventoryRow = memo(function InventoryRow({
|
|||
<div className="hidden shrink-0 items-center gap-1.5 sm:flex">
|
||||
{/* Format already shows as the status dot, so the pill stays neutral. */}
|
||||
{formatLabel && <span className="hub-chip">{formatLabel}</span>}
|
||||
{paramLabel && (
|
||||
<span className="hub-chip tabular-nums">{paramLabel}</span>
|
||||
)}
|
||||
{paramLabel && <span className="hub-chip tabular-nums">{paramLabel}</span>}
|
||||
{quantLabel && (
|
||||
<span className="hub-chip font-mono text-[10.5px] uppercase">
|
||||
{quantLabel}
|
||||
|
|
@ -704,7 +697,9 @@ export const InventoryRow = memo(function InventoryRow({
|
|||
const compactMarkers =
|
||||
partialRepoId || unsupported ? (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{partialRepoId && <StatusDot tone="warning" label="Partial download" />}
|
||||
{partialRepoId && (
|
||||
<StatusDot tone="warning" label="Partial download" />
|
||||
)}
|
||||
{unsupported && (
|
||||
<StatusDot tone="danger" label="May not be supported yet" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// 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 { FolderBrowser } from "@/components/assistant-ui/model-selector/folder-browser";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -21,11 +22,9 @@ import {
|
|||
addScanFolder,
|
||||
listScanFolders,
|
||||
removeScanFolder,
|
||||
} from "@/features/hub";
|
||||
import { FolderBrowser } from "@/features/model-picker";
|
||||
import { openModelsDir } from "@/features/native-intents";
|
||||
} from "@/features/hub/inventory";
|
||||
import { openModelsDir } from "@/features/native-intents/api";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Delete02Icon,
|
||||
|
|
@ -39,6 +38,7 @@ import {
|
|||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
function pathTail(path: string): string {
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
|
|
@ -123,9 +123,7 @@ export function OnDeviceFoldersDialog({
|
|||
setPath("");
|
||||
mutationVersionRef.current += 1;
|
||||
setFolders((current) => {
|
||||
const withoutDuplicate = current.filter(
|
||||
(row) => row.id !== folder.id,
|
||||
);
|
||||
const withoutDuplicate = current.filter((row) => row.id !== folder.id);
|
||||
return [...withoutDuplicate, folder];
|
||||
});
|
||||
toast.success("Location added", {
|
||||
|
|
@ -186,12 +184,9 @@ export function OnDeviceFoldersDialog({
|
|||
overlayClassName="bg-black/20 backdrop-blur-none"
|
||||
>
|
||||
<DialogHeader className="border-b border-border/60 px-5 py-4">
|
||||
<DialogTitle className="text-[15px]">
|
||||
On-device locations
|
||||
</DialogTitle>
|
||||
<DialogTitle className="text-[15px]">On-device locations</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Hugging Face model folders, GGUF files, and adapters are indexed
|
||||
here.
|
||||
Hugging Face model folders, GGUF files, and adapters are indexed here.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -347,7 +342,9 @@ export function OnDeviceFoldersDialog({
|
|||
</p>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<p className="block w-full truncate font-mono text-[10.5px] text-muted-foreground">
|
||||
<p
|
||||
className="block w-full truncate font-mono text-[10.5px] text-muted-foreground"
|
||||
>
|
||||
{folder.path}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -375,10 +372,7 @@ export function OnDeviceFoldersDialog({
|
|||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
className="tooltip-compact"
|
||||
>
|
||||
<TooltipContent side="left" className="tooltip-compact">
|
||||
Open in file manager
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -403,10 +397,7 @@ export function OnDeviceFoldersDialog({
|
|||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
className="tooltip-compact"
|
||||
>
|
||||
<TooltipContent side="left" className="tooltip-compact">
|
||||
Remove from list
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
// 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 { DOWNLOAD_KIND } from "./constants";
|
||||
import {
|
||||
createDownloadManagerInitialState,
|
||||
jobKeyOf,
|
||||
removeJob,
|
||||
selectActiveJob,
|
||||
setState,
|
||||
useDownloadManagerStore,
|
||||
} from "./download-manager-state";
|
||||
import { resetDownloadApiAdapterState } from "./download-api-adapter";
|
||||
import {
|
||||
|
|
@ -65,6 +69,25 @@ export const downloadManager: DownloadManagerController = {
|
|||
dismiss: removeJob,
|
||||
};
|
||||
|
||||
/** Cancel the in-flight download for a staged model pick. No-op when nothing is
|
||||
* downloading (e.g. a native/local file that was never fetched). Lets non-React
|
||||
* callers (the chat store's abandon paths) stop a staged transfer without the
|
||||
* useRepoDownload hook. */
|
||||
export function cancelStagedModelDownload(
|
||||
pending: { id: string; ggufVariant?: string | null } | null,
|
||||
): void {
|
||||
if (!pending) return;
|
||||
const variant = pending.ggufVariant ?? null;
|
||||
const activeJob = selectActiveJob(
|
||||
useDownloadManagerStore.getState(),
|
||||
DOWNLOAD_KIND.MODEL,
|
||||
pending.id,
|
||||
variant,
|
||||
);
|
||||
void downloadManager.cancel(
|
||||
activeJob?.key ?? jobKeyOf(DOWNLOAD_KIND.MODEL, pending.id, variant),
|
||||
);
|
||||
}
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.dispose(() => {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export {
|
|||
} from "./constants";
|
||||
export {
|
||||
__resetDownloadManagerForTests,
|
||||
cancelStagedModelDownload,
|
||||
clearCompletedInventoryHint,
|
||||
downloadManager,
|
||||
hydrateDownloadManager,
|
||||
|
|
|
|||
|
|
@ -1,32 +1,34 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import {
|
||||
loadRememberedLoadSettings,
|
||||
rememberedLoadSettingsKey,
|
||||
} from "@/components/assistant-ui/model-selector/remembered-load-settings";
|
||||
import { hfModelFitsDevice } from "@/components/assistant-ui/model-selector/recommended-fit";
|
||||
import { useHubInventory } from "@/features/hub/inventory";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useGpuInfo } from "@/hooks/use-gpu-info";
|
||||
import {
|
||||
type HfModelSearchChannel,
|
||||
type HfSortDirection,
|
||||
type HfSortKey,
|
||||
} from "@/features/hub/hooks/use-hub-model-search";
|
||||
import { useOnlineStatus } from "@/features/hub/hooks/use-online-status";
|
||||
import { useHubInfiniteScroll } from "@/features/hub/hooks/use-hub-infinite-scroll";
|
||||
import { ggufVariantsMatch, modelIdsMatch } from "@/features/hub/lib/model-identity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import {
|
||||
hfApiToken,
|
||||
useHfTokenStore,
|
||||
} from "@/features/hub/stores/hf-token-store";
|
||||
import {
|
||||
getInferenceStatus,
|
||||
isExternalModelId,
|
||||
useChatModelRuntime,
|
||||
useChatRuntimeStore,
|
||||
} from "@/features/chat";
|
||||
import { useHubInventory } from "@/features/hub";
|
||||
import type {
|
||||
HfModelSearchChannel,
|
||||
HfSortDirection,
|
||||
HfSortKey,
|
||||
} from "@/features/hub";
|
||||
import { useOnlineStatus } from "@/features/hub";
|
||||
import { useHubInfiniteScroll } from "@/features/hub";
|
||||
import { ggufVariantsMatch, modelIdsMatch } from "@/features/hub";
|
||||
import { hfApiToken, useHfTokenStore } from "@/features/hub";
|
||||
import {
|
||||
applyModelLoadConfigToRuntime,
|
||||
currentRuntimePerModelConfig,
|
||||
hfModelFitsDevice,
|
||||
resolveInitialConfig,
|
||||
} from "@/features/model-picker";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useGpuInfo } from "@/hooks/use-gpu-info";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import {
|
||||
useCallback,
|
||||
|
|
@ -36,17 +38,10 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ExternalLinkConfirmDialog } from "./catalog/external-link-confirm-dialog";
|
||||
import { HubDetailView } from "./catalog/hub-detail-view";
|
||||
import { HubFeed } from "./catalog/hub-feed";
|
||||
import { HubTopBar } from "./catalog/hub-top-bar";
|
||||
import {
|
||||
ModelsCatalog,
|
||||
type ModelsCatalogHandlers,
|
||||
type ModelsCatalogPagination,
|
||||
type ModelsCatalogState,
|
||||
} from "./catalog/models-catalog";
|
||||
import { ModelsHeader } from "./catalog/models-header";
|
||||
import { HubFeed } from "./catalog/hub-feed";
|
||||
import { OwnerScopeToggle } from "./catalog/owner-scope-toggle";
|
||||
import {
|
||||
type AllModelsView,
|
||||
HubListHeader,
|
||||
|
|
@ -54,9 +49,16 @@ import {
|
|||
InventorySortControl,
|
||||
ResultListHeader,
|
||||
} from "./catalog/models-table";
|
||||
import {
|
||||
ModelsCatalog,
|
||||
type ModelsCatalogHandlers,
|
||||
type ModelsCatalogPagination,
|
||||
type ModelsCatalogState,
|
||||
} from "./catalog/models-catalog";
|
||||
import { ModelsHeader } from "./catalog/models-header";
|
||||
import { ModelsToolbar } from "./catalog/models-toolbar";
|
||||
import { ExternalLinkConfirmDialog } from "./catalog/external-link-confirm-dialog";
|
||||
import { OnDeviceFoldersDialog } from "./catalog/on-device-folders-dialog";
|
||||
import { OwnerScopeToggle } from "./catalog/owner-scope-toggle";
|
||||
import { useDiscoverSearch } from "./hooks/use-discover-search";
|
||||
import { useFeedWriteBack } from "./hooks/use-feed-write-back";
|
||||
import { useHubFeed } from "./hooks/use-hub-feed";
|
||||
|
|
@ -566,15 +568,15 @@ export function ModelsPage() {
|
|||
const deferredCapabilityFilter = useDeferredValue(capabilityFilter);
|
||||
|
||||
const hasQuery = deferredDebouncedQuery.trim() !== "";
|
||||
const mode: DiscoverMode = isModelDiscover
|
||||
? hasQuery
|
||||
const mode: DiscoverMode = !isModelDiscover
|
||||
? "search"
|
||||
: hasQuery
|
||||
? "search"
|
||||
: urlSection != null
|
||||
? "channel-list"
|
||||
: sortBrowseActive
|
||||
? "search"
|
||||
: "feed"
|
||||
: "search";
|
||||
: "feed";
|
||||
const isFeedMode = mode === "feed";
|
||||
const isChannelListMode = mode === "channel-list";
|
||||
const isSortBrowseMode =
|
||||
|
|
@ -765,10 +767,7 @@ export function ModelsPage() {
|
|||
}
|
||||
return merged;
|
||||
}, [isFeedMode, feedTrendingRows, filteredDiscoverRows]);
|
||||
const feedResults = useMemo(
|
||||
() => feedRows.map((row) => row.result),
|
||||
[feedRows],
|
||||
);
|
||||
const feedResults = useMemo(() => feedRows.map((row) => row.result), [feedRows]);
|
||||
const selectionDiscoverRows = isFeedMode ? feedRows : discoverRows;
|
||||
const selectionFilteredDiscoverRows = isFeedMode
|
||||
? feedRows
|
||||
|
|
@ -1109,22 +1108,50 @@ export function ModelsPage() {
|
|||
(opts: ModelLoadOptions, isDownloaded: boolean) => {
|
||||
if (!selectedModel) return;
|
||||
const runId = selectedModel.resource.runId;
|
||||
const resolvedConfig = resolveInitialConfig(runId, opts.ggufVariant);
|
||||
const rememberedConfig = resolvedConfig.remembered
|
||||
? resolvedConfig.config
|
||||
: null;
|
||||
const previousConfig = currentRuntimePerModelConfig({
|
||||
includeMaxSeqLength: true,
|
||||
});
|
||||
const hasAppliedConfig = applyModelLoadConfigToRuntime(rememberedConfig);
|
||||
// "Load on selection" off: stage GGUF picks instead of loading, so the
|
||||
// chat page's staging flow can read the header and show the load options.
|
||||
// Non-GGUF models have nothing to configure pre-load, so they load now.
|
||||
if (
|
||||
!useChatRuntimeStore.getState().loadOnSelection &&
|
||||
(opts.ggufVariant != null || selectedModel.isGguf)
|
||||
) {
|
||||
useChatRuntimeStore.getState().stageModel({
|
||||
id: runId,
|
||||
ggufVariant: opts.ggufVariant,
|
||||
isGguf: selectedModel.isGguf,
|
||||
isDownloaded,
|
||||
expectedBytes: opts.expectedBytes,
|
||||
});
|
||||
openNewChat();
|
||||
return;
|
||||
}
|
||||
// Detach any leftover staged pick first so its edited knobs (e.g. a custom
|
||||
// context length) don't leak into this load -- mirrors the chat page's
|
||||
// detachStaged(); keepDownload keeps any staged download running.
|
||||
useChatRuntimeStore.getState().abandonStagedModel({ keepDownload: true });
|
||||
// Load-on-selection skips the chat sheet, so seed this GGUF pick's saved
|
||||
// load knobs here the way the sheet's restore effect would; otherwise the
|
||||
// remembered config is silently ignored on the Hub run path. keepSpeculative
|
||||
// then honors the restored speculative choice across the switch.
|
||||
const remembered =
|
||||
opts.ggufVariant != null || selectedModel.isGguf
|
||||
? loadRememberedLoadSettings(
|
||||
rememberedLoadSettingsKey({
|
||||
id: runId,
|
||||
ggufVariant: opts.ggufVariant,
|
||||
}),
|
||||
)
|
||||
: null;
|
||||
if (remembered) {
|
||||
useChatRuntimeStore.getState().applyRememberedLoadSettings(remembered);
|
||||
}
|
||||
void selectModel({
|
||||
id: runId,
|
||||
ggufVariant: opts.ggufVariant,
|
||||
isDownloaded,
|
||||
expectedBytes: opts.expectedBytes,
|
||||
keepSpeculative: hasAppliedConfig,
|
||||
keepSpeculative: remembered != null,
|
||||
throwOnError: true,
|
||||
previousConfig,
|
||||
})
|
||||
.then(() => {
|
||||
// Read fresh: the load is async, so the checkpoint may have changed.
|
||||
|
|
@ -1348,18 +1375,16 @@ export function ModelsPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
const ownerToggle = isDatasetMode ? undefined : (
|
||||
const ownerToggle = !isDatasetMode ? (
|
||||
<OwnerScopeToggle value={ownerScope} onChange={setOwnerScope} />
|
||||
);
|
||||
) : undefined;
|
||||
// Compact pill so it stays beside the view-mode tabs even in the narrow
|
||||
// split pane instead of dropping to its own row.
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pt-6">
|
||||
{isChannelListMode ? (
|
||||
<HubListHeader
|
||||
title={
|
||||
channelSection ? HUB_SECTION_TITLE[channelSection] : "Models"
|
||||
}
|
||||
title={channelSection ? HUB_SECTION_TITLE[channelSection] : "Models"}
|
||||
count={listCount}
|
||||
view={allModelsView}
|
||||
onViewChange={setAllModelsView}
|
||||
|
|
|
|||
|
|
@ -1,65 +1,9 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export {
|
||||
cancelModelDownload,
|
||||
downloadManager,
|
||||
jobKeyOf,
|
||||
subscribeJobListeners,
|
||||
useDownloadManagerStore,
|
||||
type TransportConflictInfo,
|
||||
} from "./download-manager";
|
||||
export {
|
||||
useHubInventory,
|
||||
type CachedInventoryRow,
|
||||
type GgufVariantDetail,
|
||||
type HubInventory,
|
||||
type HubInventoryKind,
|
||||
type InventoryRow,
|
||||
type LocalInventoryRow,
|
||||
type LocalSource,
|
||||
type ScanFolderInfo,
|
||||
addScanFolder,
|
||||
deleteCachedDataset,
|
||||
deleteCachedModel,
|
||||
formatLocalUpdated,
|
||||
listGgufVariants,
|
||||
listScanFolders,
|
||||
removeScanFolder,
|
||||
useGgufVariantsCacheVersion,
|
||||
} from "./inventory";
|
||||
export {
|
||||
type HfModelResult,
|
||||
type HfModelSearchChannel,
|
||||
type HfSortDirection,
|
||||
type HfSortKey,
|
||||
useHubModelSearch,
|
||||
} from "./hooks/use-hub-model-search";
|
||||
export { useOnlineStatus } from "./hooks/use-online-status";
|
||||
export { useHubInfiniteScroll } from "./hooks/use-hub-infinite-scroll";
|
||||
export { cancelStagedModelDownload } from "./download-manager";
|
||||
export {
|
||||
getHfToken,
|
||||
hfApiToken,
|
||||
mirrorHfTokenInto,
|
||||
useHfTokenStore,
|
||||
} from "./stores/hf-token-store";
|
||||
export { looksLikeLocalPath } from "./lib/local-path";
|
||||
export { hubTokenHeader } from "./lib/hub-token-header";
|
||||
export {
|
||||
ggufVariantsMatch,
|
||||
modelIdsMatch,
|
||||
normalizeGgufVariantIdentity,
|
||||
normalizeModelIdentity,
|
||||
} from "./lib/model-identity";
|
||||
export { formatBytes, formatRelativeShort } from "./lib/format";
|
||||
export { ggufVariantDisplayLabel } from "./lib/gguf-variant-sort";
|
||||
export {
|
||||
DeleteConfirmDialog,
|
||||
UpdateConfirmDialog,
|
||||
} from "./catalog/download-card";
|
||||
export { HubOptionMenu, type HubOption } from "./catalog/hub-option-menu";
|
||||
export { DotTag } from "./catalog/dot-tag";
|
||||
export { TransportConflictDialog } from "./catalog/transport-conflict-dialog";
|
||||
export { TrainIcon } from "./components/train-icon";
|
||||
export { isHiddenModelId } from "./lib/hidden-models";
|
||||
export { classifyUnslothSupport } from "./lib/unsloth-support";
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ export interface CachedGgufRepo {
|
|||
capabilities?: BackendModelCapabilities | null;
|
||||
size_bytes: number;
|
||||
cache_path?: string;
|
||||
last_modified?: number | null;
|
||||
partial?: boolean;
|
||||
partial_transport?: string | null;
|
||||
pipeline_tag?: string | null;
|
||||
|
|
@ -66,7 +65,6 @@ export interface CachedModelRepo {
|
|||
capabilities?: BackendModelCapabilities | null;
|
||||
size_bytes: number;
|
||||
cache_path?: string;
|
||||
last_modified?: number | null;
|
||||
partial?: boolean;
|
||||
partial_transport?: string | null;
|
||||
pipeline_tag?: string | null;
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ export interface CachedInventoryRow {
|
|||
capabilities: ModelInventoryCapabilities;
|
||||
bytes: number;
|
||||
cachePath?: string | null;
|
||||
lastModified?: number | null;
|
||||
partial?: boolean;
|
||||
partialTransport?: string | null;
|
||||
pipelineTag?: string | null;
|
||||
|
|
@ -66,8 +65,6 @@ export interface LocalInventoryRow {
|
|||
title: string;
|
||||
source: LocalSource;
|
||||
sourceLabel: string;
|
||||
modelId?: string | null;
|
||||
displayName?: string;
|
||||
path: string;
|
||||
isGguf: boolean;
|
||||
modelFormat: ModelInventoryFormat;
|
||||
|
|
|
|||
|
|
@ -176,7 +176,6 @@ export function buildCachedInventoryRow(
|
|||
runtime?: string | null;
|
||||
format_variant?: string | null;
|
||||
capabilities?: BackendModelCapabilities | null;
|
||||
last_modified?: number | null;
|
||||
},
|
||||
fallbackFormat: ModelInventoryFormat,
|
||||
): CachedInventoryRow {
|
||||
|
|
@ -211,12 +210,6 @@ export function buildCachedInventoryRow(
|
|||
),
|
||||
bytes: row.size_bytes,
|
||||
cachePath: row.cache_path ?? null,
|
||||
lastModified:
|
||||
typeof row.last_modified === "number" &&
|
||||
Number.isFinite(row.last_modified) &&
|
||||
row.last_modified > 0
|
||||
? row.last_modified
|
||||
: null,
|
||||
partial: row.partial ?? false,
|
||||
partialTransport: row.partial_transport ?? null,
|
||||
pipelineTag: row.pipeline_tag ?? null,
|
||||
|
|
@ -279,8 +272,6 @@ export function buildLocalInventoryRows(
|
|||
title,
|
||||
source: model.source,
|
||||
sourceLabel: localSourceLabel(model.source),
|
||||
modelId: model.model_id ?? null,
|
||||
displayName: model.display_name,
|
||||
path: model.path,
|
||||
isGguf: modelFormat === "gguf",
|
||||
modelFormat,
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
// 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 { getModelConfig } from "@/features/training";
|
||||
|
||||
export async function fetchModelMaxPositionEmbeddings(
|
||||
modelName: string,
|
||||
hfToken?: string | null,
|
||||
signal?: AbortSignal,
|
||||
): Promise<number | null> {
|
||||
const config = await getModelConfig(
|
||||
modelName,
|
||||
signal,
|
||||
hfToken?.trim() || undefined,
|
||||
);
|
||||
const value = config.max_position_embeddings;
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? Math.floor(value)
|
||||
: null;
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
// 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 { hubTokenHeader } from "@/features/hub";
|
||||
import { readFastApiError } from "@/lib/format-fastapi-error";
|
||||
|
||||
export interface ValidateChatTemplateResult {
|
||||
valid: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
async function parseJsonOrThrow<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
throw new Error(await readFastApiError(response));
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function validateChatTemplate(
|
||||
template: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ValidateChatTemplateResult> {
|
||||
const response = await authFetch("/api/picker/validate-chat-template", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ template }),
|
||||
signal,
|
||||
});
|
||||
return parseJsonOrThrow<ValidateChatTemplateResult>(response);
|
||||
}
|
||||
|
||||
export async function fetchDefaultChatTemplate(
|
||||
modelName: string,
|
||||
ggufVariant?: string | null,
|
||||
hfToken?: string | null,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | null> {
|
||||
const query = ggufVariant
|
||||
? `?gguf_variant=${encodeURIComponent(ggufVariant)}`
|
||||
: "";
|
||||
const response = await authFetch(
|
||||
`/api/picker/chat-template/${encodeURIComponent(modelName)}${query}`,
|
||||
{ headers: hubTokenHeader(hfToken), signal },
|
||||
);
|
||||
const data = await parseJsonOrThrow<{
|
||||
model_name: string;
|
||||
chat_template: string | null;
|
||||
}>(response);
|
||||
return data.chat_template ?? null;
|
||||
}
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
// 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 { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useRef, useState } from "react";
|
||||
import { validateChatTemplate } from "../api/templates";
|
||||
import {
|
||||
MAX_CHAT_TEMPLATE_BYTES,
|
||||
chatTemplateByteLength,
|
||||
isChatTemplateWithinLimit,
|
||||
} from "../model-config/per-model-config";
|
||||
|
||||
interface ChatTemplateEditorDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
value: string | null;
|
||||
defaultTemplate: string | null;
|
||||
defaultLoading: boolean;
|
||||
onSave: (override: string | null) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function ChatTemplateEditorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
value,
|
||||
defaultTemplate,
|
||||
defaultLoading,
|
||||
onSave,
|
||||
readOnly = false,
|
||||
}: ChatTemplateEditorDialogProps) {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validating, setValidating] = useState(false);
|
||||
// Bumped whenever the dialog closes so a validation still in flight cannot
|
||||
// apply a template the user has already dismissed.
|
||||
const validationToken = useRef(0);
|
||||
const renderedDraft = draft ?? value ?? defaultTemplate ?? "";
|
||||
|
||||
const byteLength = chatTemplateByteLength(renderedDraft);
|
||||
const overLimit = !isChatTemplateWithinLimit(renderedDraft);
|
||||
const matchesDefault =
|
||||
defaultTemplate != null && renderedDraft === defaultTemplate;
|
||||
|
||||
const handleClose = () => {
|
||||
validationToken.current += 1;
|
||||
setDraft(null);
|
||||
setError(null);
|
||||
setValidating(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (renderedDraft.trim().length === 0 || matchesDefault) {
|
||||
onSave(null);
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
if (overLimit) {
|
||||
setError("Template exceeds the size limit.");
|
||||
return;
|
||||
}
|
||||
setValidating(true);
|
||||
const token = validationToken.current;
|
||||
try {
|
||||
const result = await validateChatTemplate(renderedDraft);
|
||||
// Dialog was closed (or reopened) while validating; drop the result so a
|
||||
// discarded template is never applied.
|
||||
if (token !== validationToken.current) {
|
||||
return;
|
||||
}
|
||||
if (!result.valid) {
|
||||
setError(result.error ?? "Invalid Jinja template.");
|
||||
return;
|
||||
}
|
||||
onSave(renderedDraft);
|
||||
handleClose();
|
||||
} catch {
|
||||
if (token === validationToken.current) {
|
||||
setError("Could not validate the template.");
|
||||
}
|
||||
} finally {
|
||||
if (token === validationToken.current) {
|
||||
setValidating(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen) {
|
||||
onOpenChange(true);
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="corner-squircle dialog-soft-surface sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{readOnly ? "Chat Template" : "Edit Chat Template"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{readOnly
|
||||
? "This is the model's chat template. Custom templates apply to GGUF models for now, so it is view only for safetensors models."
|
||||
: "Override the model's chat template with custom Jinja. The change applies when the model loads. Saving an empty template or one that matches the default clears the override."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
value={renderedDraft}
|
||||
onChange={(event) => {
|
||||
if (readOnly) return;
|
||||
setDraft(event.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
readOnly={readOnly}
|
||||
className="min-h-[20rem] max-h-[50vh] overflow-y-auto border-0 font-mono text-xs leading-5 corner-squircle focus-visible:ring-0"
|
||||
rows={14}
|
||||
spellCheck={false}
|
||||
placeholder={defaultLoading ? "Loading model default..." : ""}
|
||||
/>
|
||||
{readOnly ? null : (
|
||||
<div className="flex items-center justify-between gap-3 px-0.5 text-[11px]">
|
||||
<span
|
||||
className={overLimit ? "text-amber-500" : "text-muted-foreground"}
|
||||
>
|
||||
{byteLength.toLocaleString()} /{" "}
|
||||
{MAX_CHAT_TEMPLATE_BYTES.toLocaleString()} bytes
|
||||
</span>
|
||||
{error ? (
|
||||
<span className="truncate text-red-500" title={error}>
|
||||
{error}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="flex-wrap gap-2 sm:justify-between">
|
||||
{readOnly ? (
|
||||
<div className="flex w-full justify-end">
|
||||
<Button type="button" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setDraft(defaultTemplate ?? "")}
|
||||
disabled={
|
||||
defaultLoading || renderedDraft === (defaultTemplate ?? "")
|
||||
}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{defaultLoading ? (
|
||||
<Spinner className="size-3.5" />
|
||||
) : (
|
||||
"Reset to default"
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="ghost" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={validating || overLimit}
|
||||
>
|
||||
{validating ? "Validating..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,742 +0,0 @@
|
|||
// 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 { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { InfoHint } from "@/components/ui/info-hint";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
fetchGgufContextLength,
|
||||
readPersistedSpeculativeType,
|
||||
useChatRuntimeStore,
|
||||
} from "@/features/chat";
|
||||
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import {
|
||||
useDefaultChatTemplate,
|
||||
useModelMaxPositionEmbeddings,
|
||||
} from "../hooks/use-model-defaults";
|
||||
import { perModelConfigsEqual } from "../model-config/apply-per-model-config";
|
||||
import {
|
||||
DEFAULT_PER_MODEL_CONFIG,
|
||||
KV_CACHE_DTYPES,
|
||||
MAX_SEQ_LENGTH_MAX,
|
||||
MAX_SEQ_LENGTH_MIN,
|
||||
MAX_SEQ_LENGTH_STEP,
|
||||
MTP_SPECULATIVE_TYPES,
|
||||
type PerModelConfig,
|
||||
SPECULATIVE_TYPES,
|
||||
deletePerModelConfig,
|
||||
isDefaultConfig,
|
||||
normalizeMaxSeqLength,
|
||||
resolveInitialConfig,
|
||||
savePerModelConfig,
|
||||
} from "../model-config/per-model-config";
|
||||
import { ChatTemplateEditorDialog } from "./chat-template-editor-dialog";
|
||||
import type { ModelPickTarget } from "./model-selector/types";
|
||||
import { NumericValueInput } from "./numeric-value-input";
|
||||
|
||||
const ROW_CLASS = "flex min-h-8 items-center justify-between gap-3";
|
||||
const LABEL_CLASS =
|
||||
"min-w-0 truncate text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg";
|
||||
const LABEL_CLASS_WRAP =
|
||||
"min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg";
|
||||
const CONTROL_SURFACE =
|
||||
"rounded-full border-transparent bg-black/[0.04] dark:bg-white/[0.05] hover:bg-black/[0.06] dark:hover:bg-white/[0.1]";
|
||||
const SELECT_TRIGGER_CLASS = `grid h-8 min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 ${CONTROL_SURFACE} pl-3 pr-2 py-0 text-[13px]! font-medium text-nav-fg focus-visible:ring-0 focus-visible:border-transparent [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate [&>svg]:shrink-0`;
|
||||
const NUMBER_INPUT_CLASS = `h-8 w-[92px] ${CONTROL_SURFACE} pl-3 pr-2 py-0 text-right text-[13px] font-medium text-nav-fg outline-none focus-visible:ring-0`;
|
||||
|
||||
const KV_CACHE_DTYPE_DEFAULT = "f16";
|
||||
const SPECULATIVE_TYPE_LABELS: Record<(typeof SPECULATIVE_TYPES)[number], string> =
|
||||
{
|
||||
auto: "Auto",
|
||||
mtp: "MTP",
|
||||
ngram: "Ngram",
|
||||
"mtp+ngram": "MTP+Ngram",
|
||||
off: "Off",
|
||||
};
|
||||
|
||||
function hasNonDefaultAdvanced(config: PerModelConfig): boolean {
|
||||
return (
|
||||
config.kvCacheDtype != null ||
|
||||
(config.speculativeType ?? "auto") !== "auto" ||
|
||||
config.specDraftNMax != null ||
|
||||
config.tensorParallel ||
|
||||
config.chatTemplateOverride != null
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCustomContextLength(
|
||||
value: number,
|
||||
native: number | null,
|
||||
): number | null {
|
||||
return native != null && value === native ? null : value;
|
||||
}
|
||||
|
||||
function ChatTemplateSetting({
|
||||
config,
|
||||
onEditTemplate,
|
||||
readOnly = false,
|
||||
}: {
|
||||
config: PerModelConfig;
|
||||
onEditTemplate: () => void;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={ROW_CLASS}>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className={LABEL_CLASS}>Chat Template</span>
|
||||
<InfoHint>
|
||||
{readOnly
|
||||
? "Preview the model's chat template. Custom overrides apply to GGUF models for now."
|
||||
: "Override the model's chat template with custom Jinja. Applies when the model loads."}
|
||||
</InfoHint>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{readOnly ? null : (
|
||||
<span className="text-[12px] text-muted-foreground">
|
||||
{config.chatTemplateOverride ? "Custom" : "Default"}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={`h-8 px-3 text-[13px] ${CONTROL_SURFACE}`}
|
||||
onClick={onEditTemplate}
|
||||
>
|
||||
{readOnly ? "View" : "Edit"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MaxSeqLengthSetting({
|
||||
value,
|
||||
max,
|
||||
inputMax,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
max: number;
|
||||
inputMax: number;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className={ROW_CLASS}>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className={LABEL_CLASS}>Max Seq Length</span>
|
||||
<InfoHint>
|
||||
Maximum context window size in tokens. Applies when the model loads.
|
||||
</InfoHint>
|
||||
</div>
|
||||
<NumericValueInput
|
||||
value={value}
|
||||
min={MAX_SEQ_LENGTH_MIN}
|
||||
max={inputMax}
|
||||
step={MAX_SEQ_LENGTH_STEP}
|
||||
onChange={onChange}
|
||||
ariaLabel="Max Seq Length"
|
||||
className={NUMBER_INPUT_CLASS}
|
||||
size={8}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
min={MAX_SEQ_LENGTH_MIN}
|
||||
max={max}
|
||||
step={MAX_SEQ_LENGTH_STEP}
|
||||
value={[value]}
|
||||
onValueChange={([next]) => onChange(next)}
|
||||
className="panel-slider"
|
||||
aria-label="Max Seq Length"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clampMaxSeqLength(value: number, max: number): number {
|
||||
const normalized = normalizeMaxSeqLength(value) ?? MAX_SEQ_LENGTH_MIN;
|
||||
return Math.max(MAX_SEQ_LENGTH_MIN, Math.min(max, normalized));
|
||||
}
|
||||
|
||||
function GgufAdvancedSettings({
|
||||
config,
|
||||
update,
|
||||
isMtp,
|
||||
speculativeFallback,
|
||||
onEditTemplate,
|
||||
}: {
|
||||
config: PerModelConfig;
|
||||
update: (patch: Partial<PerModelConfig>) => void;
|
||||
isMtp: boolean;
|
||||
speculativeFallback: string;
|
||||
onEditTemplate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className={ROW_CLASS}>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className={LABEL_CLASS}>KV Cache Dtype</span>
|
||||
<InfoHint>
|
||||
Lower KV cache precision to save VRAM at the cost of some quality.
|
||||
f16/bf16 are full precision; q8_0/q5_1/q4_1 are quantized.
|
||||
</InfoHint>
|
||||
</div>
|
||||
<Select
|
||||
value={config.kvCacheDtype ?? KV_CACHE_DTYPE_DEFAULT}
|
||||
onValueChange={(v) =>
|
||||
update({ kvCacheDtype: v === KV_CACHE_DTYPE_DEFAULT ? null : v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
animateRadius={false}
|
||||
icon={ChevronDownStandardIcon}
|
||||
iconClassName="size-3.5"
|
||||
className={`w-[92px] ${SELECT_TRIGGER_CLASS}`}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="menu-soft-surface ring-0 border-0 rounded-lg">
|
||||
<SelectItem value={KV_CACHE_DTYPE_DEFAULT}>
|
||||
{KV_CACHE_DTYPE_DEFAULT}
|
||||
</SelectItem>
|
||||
{KV_CACHE_DTYPES.map((dtype) => (
|
||||
<SelectItem key={dtype} value={dtype}>
|
||||
{dtype}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className={ROW_CLASS}>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className={LABEL_CLASS_WRAP}>Speculative Decoding</span>
|
||||
<InfoHint>
|
||||
Faster generation with no accuracy hit. Auto picks MTP / ngram based
|
||||
on the model and platform. Pick a strategy to force it.
|
||||
</InfoHint>
|
||||
</div>
|
||||
<Select
|
||||
value={config.speculativeType ?? speculativeFallback}
|
||||
onValueChange={(v) =>
|
||||
update({
|
||||
speculativeType: v,
|
||||
specDraftNMax:
|
||||
v === "mtp" || v === "mtp+ngram" ? config.specDraftNMax : null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
animateRadius={false}
|
||||
icon={ChevronDownStandardIcon}
|
||||
iconClassName="size-3.5"
|
||||
className={`w-[124px] shrink-0 ${SELECT_TRIGGER_CLASS}`}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="menu-soft-surface ring-0 border-0 rounded-lg">
|
||||
{SPECULATIVE_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{SPECULATIVE_TYPE_LABELS[type]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isMtp && (
|
||||
<div className={ROW_CLASS}>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className={LABEL_CLASS}>Draft Tokens</span>
|
||||
<InfoHint>
|
||||
Max MTP draft tokens per step. Leave blank for the platform
|
||||
default (2 on GPU, 3 on CPU/Mac).
|
||||
</InfoHint>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={16}
|
||||
step={1}
|
||||
value={config.specDraftNMax ?? ""}
|
||||
placeholder="auto"
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value;
|
||||
if (raw === "") {
|
||||
update({ specDraftNMax: null });
|
||||
return;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
update({ specDraftNMax: Math.max(1, Math.min(16, parsed)) });
|
||||
}
|
||||
}}
|
||||
aria-label="Speculative decoding draft tokens"
|
||||
className={NUMBER_INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={ROW_CLASS}>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className={LABEL_CLASS}>Tensor Parallelism</span>
|
||||
<InfoHint>
|
||||
No effect on a single GPU. On multi-GPU setups, improves tokens/sec
|
||||
for dense models. MoE models don't benefit.
|
||||
</InfoHint>
|
||||
</div>
|
||||
<Switch
|
||||
className="panel-switch shrink-0"
|
||||
checked={config.tensorParallel}
|
||||
onCheckedChange={(checked) => update({ tensorParallel: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChatTemplateSetting config={config} onEditTemplate={onEditTemplate} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelConfigPageProps {
|
||||
target: ModelPickTarget;
|
||||
onBack?: () => void;
|
||||
onRun: (config: PerModelConfig) => void;
|
||||
loadedConfig?: PerModelConfig | null;
|
||||
loadedContextLength?: number | null;
|
||||
initialConfig?: PerModelConfig | null;
|
||||
variant?: "page" | "sidebar";
|
||||
}
|
||||
|
||||
export function ModelConfigPage({
|
||||
target,
|
||||
onBack,
|
||||
onRun,
|
||||
loadedConfig = null,
|
||||
loadedContextLength = null,
|
||||
initialConfig = null,
|
||||
variant = "page",
|
||||
}: ModelConfigPageProps) {
|
||||
const rememberId = useId();
|
||||
const isActiveModel = loadedConfig != null;
|
||||
const runtimeMaxSeqLength = useChatRuntimeStore((s) => s.params.maxSeqLength);
|
||||
const hfToken = useChatRuntimeStore((s) => s.hfToken);
|
||||
const loadedDefaultChatTemplate = useChatRuntimeStore(
|
||||
(s) => s.defaultChatTemplate,
|
||||
);
|
||||
const loadedMaxContextLength = useChatRuntimeStore(
|
||||
(s) => s.ggufMaxContextLength,
|
||||
);
|
||||
// Only the active model seeds its max sequence length from the loaded
|
||||
// runtime params. A different, unloaded model with no saved config seeds the
|
||||
// app default instead, so opening its settings and loading does not inherit
|
||||
// the currently loaded model's context.
|
||||
const [initialMaxSeqLength] = useState(() =>
|
||||
isActiveModel ? (normalizeMaxSeqLength(runtimeMaxSeqLength) ?? 4096) : 4096,
|
||||
);
|
||||
const resolveInitial = () => {
|
||||
const resolved = resolveInitialConfig(target.id, target.ggufVariant);
|
||||
if (loadedConfig) {
|
||||
return { config: loadedConfig, remembered: resolved.remembered };
|
||||
}
|
||||
if (initialConfig) {
|
||||
return {
|
||||
config: initialConfig,
|
||||
remembered:
|
||||
resolved.remembered &&
|
||||
perModelConfigsEqual(initialConfig, resolved.config),
|
||||
};
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
const [initial] = useState(resolveInitial);
|
||||
const [config, setConfig] = useState<PerModelConfig>(() => initial.config);
|
||||
const [remember, setRemember] = useState(() => initial.remembered);
|
||||
const [savedRemember, setSavedRemember] = useState(() => initial.remembered);
|
||||
const [speculativeFallback] = useState(readPersistedSpeculativeType);
|
||||
const [templateOpen, setTemplateOpen] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(() =>
|
||||
hasNonDefaultAdvanced(config),
|
||||
);
|
||||
const templateDefaults = useDefaultChatTemplate(
|
||||
target.id,
|
||||
target.ggufVariant,
|
||||
templateOpen,
|
||||
);
|
||||
const modelMaxPosition = useModelMaxPositionEmbeddings(
|
||||
target.id,
|
||||
!target.isGguf,
|
||||
);
|
||||
const hasLoadedDefaultTemplate =
|
||||
isActiveModel && loadedDefaultChatTemplate != null;
|
||||
const resolvedDefaultTemplate = hasLoadedDefaultTemplate
|
||||
? loadedDefaultChatTemplate
|
||||
: templateDefaults.template;
|
||||
const resolvedDefaultLoading = hasLoadedDefaultTemplate
|
||||
? false
|
||||
: templateDefaults.loading;
|
||||
|
||||
const update = (patch: Partial<PerModelConfig>) =>
|
||||
setConfig((current) => ({ ...current, ...patch }));
|
||||
|
||||
const contextFetchKey =
|
||||
target.isGguf && target.meta.contextLength == null
|
||||
? `${target.id}\n${target.ggufVariant ?? ""}\n${hfToken || ""}`
|
||||
: null;
|
||||
const [fetchedContextLength, setFetchedContextLength] = useState<{
|
||||
key: string;
|
||||
value: number | null;
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
if (contextFetchKey == null) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void fetchGgufContextLength({
|
||||
model_path: target.id,
|
||||
gguf_variant: target.ggufVariant ?? null,
|
||||
hf_token: hfToken || null,
|
||||
})
|
||||
.then((contextLength) => {
|
||||
if (!cancelled) {
|
||||
setFetchedContextLength({
|
||||
key: contextFetchKey,
|
||||
value: contextLength,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setFetchedContextLength({ key: contextFetchKey, value: null });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [contextFetchKey, target.id, target.ggufVariant, hfToken]);
|
||||
|
||||
const isMtp =
|
||||
config.speculativeType != null &&
|
||||
MTP_SPECULATIVE_TYPES.has(config.speculativeType);
|
||||
const nativeContextLength =
|
||||
target.meta.contextLength ??
|
||||
(fetchedContextLength?.key === contextFetchKey
|
||||
? fetchedContextLength.value
|
||||
: null);
|
||||
const activeLoadedContext =
|
||||
isActiveModel && target.isGguf ? loadedContextLength : null;
|
||||
const minContext = 128;
|
||||
const maxContext = Math.max(
|
||||
minContext,
|
||||
Math.max(
|
||||
nativeContextLength ?? 0,
|
||||
activeLoadedContext ?? 0,
|
||||
config.customContextLength ?? 0,
|
||||
) || 32768,
|
||||
);
|
||||
const contextValue = Math.min(
|
||||
Math.max(
|
||||
config.customContextLength ??
|
||||
activeLoadedContext ??
|
||||
nativeContextLength ??
|
||||
maxContext,
|
||||
minContext,
|
||||
),
|
||||
maxContext,
|
||||
);
|
||||
const setContextLength = (v: number) =>
|
||||
update({
|
||||
customContextLength:
|
||||
nativeContextLength != null && v === nativeContextLength ? null : v,
|
||||
});
|
||||
const baseline = loadedConfig ?? DEFAULT_PER_MODEL_CONFIG;
|
||||
const atBaseline = perModelConfigsEqual(config, baseline);
|
||||
const contextAtDefault =
|
||||
!target.isGguf ||
|
||||
(nativeContextLength == null
|
||||
? config.customContextLength == null
|
||||
: contextValue === nativeContextLength);
|
||||
const atDefault =
|
||||
contextAtDefault &&
|
||||
perModelConfigsEqual(
|
||||
{ ...config, customContextLength: null },
|
||||
DEFAULT_PER_MODEL_CONFIG,
|
||||
);
|
||||
const nativeMaxSeqLength =
|
||||
normalizeMaxSeqLength(modelMaxPosition.maxPositionEmbeddings) ??
|
||||
MAX_SEQ_LENGTH_MAX;
|
||||
const maxSeqLengthValue =
|
||||
normalizeMaxSeqLength(config.maxSeqLength) ??
|
||||
clampMaxSeqLength(initialMaxSeqLength, nativeMaxSeqLength);
|
||||
const maxSeqLengthMax = Math.max(nativeMaxSeqLength, maxSeqLengthValue);
|
||||
const runtimeConfig = target.isGguf
|
||||
? {
|
||||
...config,
|
||||
// Persist the user's intent collapsed against native (not the loaded
|
||||
// context, which equals the override): an explicit sub-native value is
|
||||
// kept, "at native" becomes null. null stays null (auto/VRAM-fit).
|
||||
customContextLength:
|
||||
config.customContextLength == null
|
||||
? null
|
||||
: resolveCustomContextLength(
|
||||
config.customContextLength,
|
||||
nativeContextLength,
|
||||
),
|
||||
}
|
||||
: {
|
||||
...config,
|
||||
maxSeqLength: maxSeqLengthValue,
|
||||
};
|
||||
const rememberChanged = remember !== savedRemember;
|
||||
const persistenceOnly = isActiveModel && atBaseline && rememberChanged;
|
||||
const primaryActionLabel = persistenceOnly
|
||||
? remember
|
||||
? "Save settings"
|
||||
: "Forget settings"
|
||||
: isActiveModel
|
||||
? "Reload model"
|
||||
: "Load model";
|
||||
|
||||
const handleRun = () => {
|
||||
const defaultConfig = isDefaultConfig(runtimeConfig);
|
||||
let saveFailed = false;
|
||||
if (remember) {
|
||||
saveFailed = !savePerModelConfig(
|
||||
target.id,
|
||||
target.ggufVariant,
|
||||
runtimeConfig,
|
||||
);
|
||||
} else {
|
||||
saveFailed = !deletePerModelConfig(target.id, target.ggufVariant);
|
||||
}
|
||||
if (persistenceOnly) {
|
||||
if (saveFailed) {
|
||||
toast.error("Couldn't save settings for this model.");
|
||||
return;
|
||||
}
|
||||
const nextRemember = remember && !defaultConfig;
|
||||
setSavedRemember(nextRemember);
|
||||
setRemember(nextRemember);
|
||||
toast.success(
|
||||
nextRemember
|
||||
? "Settings saved."
|
||||
: remember
|
||||
? "Default settings kept."
|
||||
: "Settings forgotten.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (saveFailed) {
|
||||
toast.error("Couldn't save these settings, loading with them anyway.");
|
||||
}
|
||||
onRun(runtimeConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{variant === "page" && (
|
||||
<div className="flex items-center gap-2.5 pb-4">
|
||||
{onBack && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="nav-icon-btn shrink-0 text-nav-icon-idle hover:bg-panel-surface-hover hover:text-black dark:hover:text-white"
|
||||
aria-label="Back to model list"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowLeft01Icon}
|
||||
className="size-4"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[10px] font-semibold uppercase leading-none tracking-wider text-muted-foreground">
|
||||
Run settings
|
||||
</div>
|
||||
<div className="mt-1.5 truncate text-[14px] font-semibold leading-tight text-nav-fg">
|
||||
{target.displayName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3.5">
|
||||
{target.isGguf && (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className={ROW_CLASS}>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className={LABEL_CLASS}>Context Length</span>
|
||||
<InfoHint>
|
||||
Tokens of context to allocate. Higher uses more VRAM.
|
||||
{nativeContextLength != null
|
||||
? ` This model's native context is ${nativeContextLength.toLocaleString()} tokens.`
|
||||
: ""}
|
||||
</InfoHint>
|
||||
</div>
|
||||
<NumericValueInput
|
||||
value={contextValue}
|
||||
min={minContext}
|
||||
max={maxContext}
|
||||
step={1}
|
||||
onChange={setContextLength}
|
||||
ariaLabel="Context Length"
|
||||
className={NUMBER_INPUT_CLASS}
|
||||
size={8}
|
||||
/>
|
||||
</div>
|
||||
{nativeContextLength != null ? (
|
||||
<Slider
|
||||
min={minContext}
|
||||
max={maxContext}
|
||||
step={128}
|
||||
value={[contextValue]}
|
||||
onValueChange={([v]) => setContextLength(v)}
|
||||
className="panel-slider"
|
||||
aria-label="Context Length"
|
||||
/>
|
||||
) : null}
|
||||
{isActiveModel &&
|
||||
loadedMaxContextLength != null &&
|
||||
contextValue > loadedMaxContextLength && (
|
||||
<p className="text-[11px] text-amber-500">
|
||||
Exceeds estimated VRAM capacity (
|
||||
{loadedMaxContextLength.toLocaleString()} tokens). The model
|
||||
may use system RAM.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAdvanced && (
|
||||
<GgufAdvancedSettings
|
||||
config={config}
|
||||
update={update}
|
||||
isMtp={isMtp}
|
||||
speculativeFallback={speculativeFallback}
|
||||
onEditTemplate={() => setTemplateOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={ROW_CLASS}>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-muted-foreground">
|
||||
Advanced settings
|
||||
</span>
|
||||
<InfoHint>
|
||||
Extra options for how the model loads. Most setups don't need
|
||||
these.
|
||||
</InfoHint>
|
||||
</div>
|
||||
<Switch
|
||||
className="panel-switch shrink-0"
|
||||
checked={showAdvanced}
|
||||
onCheckedChange={setShowAdvanced}
|
||||
aria-label="Show advanced settings"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!target.isGguf && (
|
||||
<>
|
||||
<MaxSeqLengthSetting
|
||||
value={maxSeqLengthValue}
|
||||
max={maxSeqLengthMax}
|
||||
inputMax={MAX_SEQ_LENGTH_MAX}
|
||||
onChange={(value) =>
|
||||
update({
|
||||
maxSeqLength: clampMaxSeqLength(value, MAX_SEQ_LENGTH_MAX),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ChatTemplateSetting
|
||||
config={config}
|
||||
onEditTemplate={() => setTemplateOpen(true)}
|
||||
readOnly={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
variant === "sidebar"
|
||||
? "mt-4 flex flex-col gap-3 border-t border-border/60 pt-4"
|
||||
: "mt-4 flex items-center justify-between gap-3 border-t border-border/60 pt-4"
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Checkbox
|
||||
id={rememberId}
|
||||
checked={remember}
|
||||
onCheckedChange={(checked) => setRemember(checked === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={rememberId}
|
||||
className="cursor-pointer select-none truncate text-[13px] text-nav-fg"
|
||||
>
|
||||
Remember for this model
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
variant === "sidebar"
|
||||
? "flex items-center justify-end gap-2"
|
||||
: "flex shrink-0 items-center gap-2"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={atDefault}
|
||||
onClick={() =>
|
||||
setConfig({
|
||||
...DEFAULT_PER_MODEL_CONFIG,
|
||||
customContextLength:
|
||||
target.isGguf && nativeContextLength != null
|
||||
? nativeContextLength
|
||||
: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={isActiveModel && atBaseline && !rememberChanged}
|
||||
onClick={handleRun}
|
||||
>
|
||||
{primaryActionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChatTemplateEditorDialog
|
||||
open={templateOpen}
|
||||
onOpenChange={setTemplateOpen}
|
||||
value={config.chatTemplateOverride}
|
||||
defaultTemplate={resolvedDefaultTemplate}
|
||||
defaultLoading={resolvedDefaultLoading}
|
||||
readOnly={!target.isGguf}
|
||||
onSave={(override) => update({ chatTemplateOverride: override })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
// 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 { cn } from "@/lib/utils";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
export function snapToStep(
|
||||
value: number,
|
||||
step: number,
|
||||
min?: number,
|
||||
max?: number,
|
||||
): number {
|
||||
const lo = min ?? Number.NEGATIVE_INFINITY;
|
||||
const hi = max ?? Number.POSITIVE_INFINITY;
|
||||
const clamped = Math.min(Math.max(value, lo), hi);
|
||||
const stepStr = String(step);
|
||||
const decimals = stepStr.includes(".") ? stepStr.split(".")[1].length : 0;
|
||||
const base = Number.isFinite(lo) ? lo : 0;
|
||||
const snapped = base + Math.round((clamped - base) / step) * step;
|
||||
const reclamped = Math.min(Math.max(snapped, lo), hi);
|
||||
return Number(reclamped.toFixed(decimals));
|
||||
}
|
||||
|
||||
function sanitizeNumeric(raw: string, allowNegative: boolean): string {
|
||||
const sign = allowNegative && raw.startsWith("-") ? "-" : "";
|
||||
const [head, ...rest] = raw.replace(/[^\d.]/g, "").split(".");
|
||||
const tail = rest.length > 0 ? `.${rest.join("")}` : "";
|
||||
return `${sign}${head}${tail}`;
|
||||
}
|
||||
|
||||
export function NumericValueInput({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
onChange,
|
||||
displayValue,
|
||||
className,
|
||||
ariaLabel,
|
||||
size: sizeAttr,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step: number;
|
||||
onChange: (v: number) => void;
|
||||
displayValue?: string;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
size?: number;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const cancelBlurCommitRef = useRef(false);
|
||||
|
||||
const commit = (raw: string) => {
|
||||
const parsed = Number.parseFloat(raw);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return;
|
||||
}
|
||||
const final = snapToStep(parsed, step, min, max);
|
||||
if (final !== value) {
|
||||
onChange(final);
|
||||
}
|
||||
};
|
||||
|
||||
const displayed = focused ? draft : (displayValue ?? String(value));
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
disabled={disabled}
|
||||
size={sizeAttr}
|
||||
style={{
|
||||
boxSizing: "content-box",
|
||||
width: `calc(${Math.max(displayed.length, 4)}ch + 2px)`,
|
||||
}}
|
||||
value={displayed}
|
||||
aria-label={ariaLabel}
|
||||
onFocus={(e) => {
|
||||
cancelBlurCommitRef.current = false;
|
||||
setDraft(String(value));
|
||||
setFocused(true);
|
||||
const target = e.currentTarget;
|
||||
requestAnimationFrame(() => target.select());
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (cancelBlurCommitRef.current) {
|
||||
cancelBlurCommitRef.current = false;
|
||||
} else {
|
||||
commit(draft);
|
||||
}
|
||||
setFocused(false);
|
||||
}}
|
||||
onChange={(e) =>
|
||||
setDraft(sanitizeNumeric(e.target.value, (min ?? 0) < 0))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === "Escape") {
|
||||
cancelBlurCommitRef.current = true;
|
||||
setDraft(String(value));
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className={cn(className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
// 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 { useMemo } from "react";
|
||||
import type { PerModelConfig } from "../model-config/per-model-config";
|
||||
import { ModelConfigPage } from "./model-config-page";
|
||||
import type { ModelPickTarget } from "./model-selector/types";
|
||||
|
||||
interface SidebarModelConfigProps {
|
||||
modelId: string;
|
||||
ggufVariant: string | null;
|
||||
isGguf: boolean;
|
||||
nativeContextLength: number | null;
|
||||
loadedContextLength: number | null;
|
||||
loadedConfig: PerModelConfig;
|
||||
onReload: (config: PerModelConfig) => void;
|
||||
}
|
||||
|
||||
const TRAILING_SEPARATORS = /[\\/]+$/;
|
||||
|
||||
function leafName(id: string): string {
|
||||
const trimmed = id.replace(TRAILING_SEPARATORS, "");
|
||||
const separator = Math.max(
|
||||
trimmed.lastIndexOf("/"),
|
||||
trimmed.lastIndexOf("\\"),
|
||||
);
|
||||
return separator >= 0 ? trimmed.slice(separator + 1) : trimmed;
|
||||
}
|
||||
|
||||
function hashString(value: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash = (Math.imul(hash, 33) ^ value.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function configSignature(config: PerModelConfig): string {
|
||||
return [
|
||||
config.customContextLength ?? "",
|
||||
config.maxSeqLength ?? "",
|
||||
config.kvCacheDtype ?? "",
|
||||
config.speculativeType ?? "",
|
||||
config.specDraftNMax ?? "",
|
||||
config.tensorParallel ? "1" : "0",
|
||||
config.chatTemplateOverride == null
|
||||
? ""
|
||||
: `${config.chatTemplateOverride.length}:${hashString(config.chatTemplateOverride)}`,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export function SidebarModelConfig({
|
||||
modelId,
|
||||
ggufVariant,
|
||||
isGguf,
|
||||
nativeContextLength,
|
||||
loadedContextLength,
|
||||
loadedConfig,
|
||||
onReload,
|
||||
}: SidebarModelConfigProps) {
|
||||
const target = useMemo<ModelPickTarget>(() => {
|
||||
const leaf = leafName(modelId);
|
||||
return {
|
||||
id: modelId,
|
||||
displayName: ggufVariant ? `${leaf} · ${ggufVariant}` : leaf,
|
||||
ggufVariant,
|
||||
isGguf,
|
||||
meta: {
|
||||
source: "local",
|
||||
isLora: false,
|
||||
ggufVariant: ggufVariant ?? undefined,
|
||||
isGguf,
|
||||
isDownloaded: true,
|
||||
contextLength: nativeContextLength,
|
||||
},
|
||||
};
|
||||
}, [modelId, ggufVariant, isGguf, nativeContextLength]);
|
||||
|
||||
return (
|
||||
<ModelConfigPage
|
||||
key={`${modelId}::${ggufVariant ?? ""}::${configSignature(loadedConfig)}`}
|
||||
target={target}
|
||||
onRun={onReload}
|
||||
loadedConfig={loadedConfig}
|
||||
loadedContextLength={loadedContextLength}
|
||||
variant="sidebar"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
// 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 { useHfTokenStore } from "@/features/hub";
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchModelMaxPositionEmbeddings } from "../api/model-metadata";
|
||||
import { fetchDefaultChatTemplate } from "../api/templates";
|
||||
|
||||
export interface DefaultChatTemplateState {
|
||||
template: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ModelMaxPositionState {
|
||||
maxPositionEmbeddings: number | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const TEMPLATE_CACHE_MAX_ENTRIES = 50;
|
||||
const templateCache = new Map<string, string | null>();
|
||||
const maxPositionCache = new Map<string, number | null>();
|
||||
|
||||
function cacheTemplate(key: string, template: string | null): void {
|
||||
templateCache.delete(key);
|
||||
templateCache.set(key, template);
|
||||
while (templateCache.size > TEMPLATE_CACHE_MAX_ENTRIES) {
|
||||
const oldest = templateCache.keys().next().value;
|
||||
if (oldest === undefined) {
|
||||
break;
|
||||
}
|
||||
templateCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function cacheMaxPosition(key: string, value: number | null): void {
|
||||
maxPositionCache.delete(key);
|
||||
maxPositionCache.set(key, value);
|
||||
while (maxPositionCache.size > TEMPLATE_CACHE_MAX_ENTRIES) {
|
||||
const oldest = maxPositionCache.keys().next().value;
|
||||
if (oldest === undefined) {
|
||||
break;
|
||||
}
|
||||
maxPositionCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
export function useDefaultChatTemplate(
|
||||
modelId: string | null,
|
||||
ggufVariant: string | null | undefined,
|
||||
enabled: boolean,
|
||||
): DefaultChatTemplateState {
|
||||
const token = useHfTokenStore((s) => s.token);
|
||||
const cacheKey =
|
||||
enabled && modelId ? `${modelId}::${ggufVariant ?? ""}::${token}` : null;
|
||||
const [fetched, setFetched] = useState<{
|
||||
key: string;
|
||||
state: DefaultChatTemplateState;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (cacheKey == null || !modelId || templateCache.has(cacheKey)) {
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
fetchDefaultChatTemplate(modelId, ggufVariant, token, controller.signal)
|
||||
.then((template) => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
// Cache the terminal result, including a null "no default template",
|
||||
// so reopening the viewer for such a model reuses it instead of
|
||||
// re-running the backend/Hugging Face lookup every time.
|
||||
cacheTemplate(cacheKey, template);
|
||||
setFetched({
|
||||
key: cacheKey,
|
||||
state: { template, loading: false, error: null },
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setFetched({
|
||||
key: cacheKey,
|
||||
state: {
|
||||
template: null,
|
||||
loading: false,
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to load template",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [cacheKey, modelId, ggufVariant, token]);
|
||||
|
||||
if (cacheKey == null) {
|
||||
return { template: null, loading: false, error: null };
|
||||
}
|
||||
if (templateCache.has(cacheKey)) {
|
||||
return {
|
||||
template: templateCache.get(cacheKey) ?? null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
if (fetched?.key === cacheKey) {
|
||||
return fetched.state;
|
||||
}
|
||||
return { template: null, loading: true, error: null };
|
||||
}
|
||||
|
||||
export function useModelMaxPositionEmbeddings(
|
||||
modelId: string | null,
|
||||
enabled: boolean,
|
||||
): ModelMaxPositionState {
|
||||
const token = useHfTokenStore((s) => s.token);
|
||||
const cacheKey = enabled && modelId ? `${modelId}::${token}` : null;
|
||||
const [fetched, setFetched] = useState<{
|
||||
key: string;
|
||||
state: ModelMaxPositionState;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (cacheKey == null || !modelId || maxPositionCache.has(cacheKey)) {
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
fetchModelMaxPositionEmbeddings(modelId, token, controller.signal)
|
||||
.then((maxPositionEmbeddings) => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
cacheMaxPosition(cacheKey, maxPositionEmbeddings);
|
||||
setFetched({
|
||||
key: cacheKey,
|
||||
state: { maxPositionEmbeddings, loading: false, error: null },
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setFetched({
|
||||
key: cacheKey,
|
||||
state: {
|
||||
maxPositionEmbeddings: null,
|
||||
loading: false,
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to load model metadata",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [cacheKey, modelId, token]);
|
||||
|
||||
if (cacheKey == null) {
|
||||
return { maxPositionEmbeddings: null, loading: false, error: null };
|
||||
}
|
||||
if (maxPositionCache.has(cacheKey)) {
|
||||
return {
|
||||
maxPositionEmbeddings: maxPositionCache.get(cacheKey) ?? null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
if (fetched?.key === cacheKey) {
|
||||
return fetched.state;
|
||||
}
|
||||
return { maxPositionEmbeddings: null, loading: true, error: null };
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export { ModelSelector } from "./components/model-selector";
|
||||
export { FolderBrowser } from "./components/model-selector/folder-browser";
|
||||
export { ModelDeleteAction } from "./components/model-selector/model-delete-action";
|
||||
export { hfModelFitsDevice } from "./components/model-selector/recommended-fit";
|
||||
export {
|
||||
NumericValueInput,
|
||||
snapToStep,
|
||||
} from "./components/numeric-value-input";
|
||||
export { SidebarModelConfig } from "./components/sidebar-model-config";
|
||||
export type {
|
||||
DeletedModelRef,
|
||||
ExternalModelOption,
|
||||
LoraModelOption,
|
||||
ModelOption,
|
||||
ModelSelectorChangeMeta,
|
||||
} from "./components/model-selector";
|
||||
export {
|
||||
applyModelLoadConfigToRuntime,
|
||||
applyPerModelConfigToRuntime,
|
||||
currentRuntimePerModelConfig,
|
||||
perModelConfigsEqual,
|
||||
} from "./model-config/apply-per-model-config";
|
||||
export {
|
||||
normalizeMaxSeqLength,
|
||||
type PerModelConfig,
|
||||
resolveInitialConfig,
|
||||
} from "./model-config/per-model-config";
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
// 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 {
|
||||
CachedGgufRepo,
|
||||
CachedModelRepo,
|
||||
LocalModelInfo,
|
||||
} from "@/features/chat";
|
||||
import {
|
||||
type CachedInventoryRow,
|
||||
type LocalInventoryRow,
|
||||
type LocalSource,
|
||||
isHiddenModelId,
|
||||
useHubInventory,
|
||||
} from "@/features/hub";
|
||||
import { useMemo } from "react";
|
||||
|
||||
const PICKER_LOCAL_SOURCES: ReadonlySet<LocalSource> = new Set([
|
||||
"lmstudio",
|
||||
"models_dir",
|
||||
"custom",
|
||||
]);
|
||||
|
||||
function isCompleteCachedRow(row: CachedInventoryRow): boolean {
|
||||
return !row.partial && !row.liveDownload;
|
||||
}
|
||||
|
||||
function toCachedGgufRepo(row: CachedInventoryRow): CachedGgufRepo {
|
||||
return {
|
||||
repo_id: row.repoId,
|
||||
size_bytes: row.bytes,
|
||||
cache_path: row.cachePath ?? "",
|
||||
last_modified: row.lastModified ?? undefined,
|
||||
has_vision: row.capabilities.supportsVision,
|
||||
};
|
||||
}
|
||||
|
||||
function toCachedModelRepo(row: CachedInventoryRow): CachedModelRepo {
|
||||
return {
|
||||
repo_id: row.repoId,
|
||||
size_bytes: row.bytes,
|
||||
last_modified: row.lastModified ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function toLocalModelInfo(row: LocalInventoryRow): LocalModelInfo {
|
||||
return {
|
||||
id: row.loadId,
|
||||
display_name: row.displayName ?? row.title,
|
||||
path: row.path,
|
||||
source: row.source as LocalModelInfo["source"],
|
||||
model_id: row.modelId ?? row.repoId,
|
||||
model_format: row.modelFormat,
|
||||
updated_at: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatPickerInventory {
|
||||
cachedGguf: CachedGgufRepo[];
|
||||
cachedModels: CachedModelRepo[];
|
||||
cachedReady: boolean;
|
||||
localModels: LocalModelInfo[];
|
||||
refreshInventory: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useChatPickerInventory(
|
||||
options: { enabled?: boolean } = {},
|
||||
): ChatPickerInventory {
|
||||
const inventory = useHubInventory({
|
||||
kind: "models",
|
||||
enabled: options.enabled,
|
||||
includeLocal: true,
|
||||
});
|
||||
|
||||
const cachedGguf = useMemo(
|
||||
() =>
|
||||
inventory.cachedRows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.modelFormat === "gguf" &&
|
||||
isCompleteCachedRow(row) &&
|
||||
!isHiddenModelId(row.repoId),
|
||||
)
|
||||
.map(toCachedGgufRepo),
|
||||
[inventory.cachedRows],
|
||||
);
|
||||
const cachedModels = useMemo(
|
||||
() =>
|
||||
inventory.cachedRows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.modelFormat !== "gguf" &&
|
||||
isCompleteCachedRow(row) &&
|
||||
!isHiddenModelId(row.repoId),
|
||||
)
|
||||
.map(toCachedModelRepo),
|
||||
[inventory.cachedRows],
|
||||
);
|
||||
const localModels = useMemo(
|
||||
() =>
|
||||
inventory.localRows
|
||||
.filter(
|
||||
(row) =>
|
||||
PICKER_LOCAL_SOURCES.has(row.source) &&
|
||||
!isHiddenModelId(row.modelId, row.repoId, row.path),
|
||||
)
|
||||
.map(toLocalModelInfo),
|
||||
[inventory.localRows],
|
||||
);
|
||||
|
||||
return {
|
||||
cachedGguf,
|
||||
cachedModels,
|
||||
cachedReady: inventory.downloadedReady,
|
||||
localModels,
|
||||
refreshInventory: inventory.refreshInventory,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
// 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 {
|
||||
defaultInferenceParams,
|
||||
normalizeSpeculativeType,
|
||||
readPersistedSpeculativeType,
|
||||
useChatRuntimeStore,
|
||||
} from "@/features/chat";
|
||||
import {
|
||||
DEFAULT_PER_MODEL_CONFIG,
|
||||
type PerModelConfig,
|
||||
normalizeMaxSeqLength,
|
||||
} from "./per-model-config";
|
||||
|
||||
function cleanTemplate(value: string | null | undefined): string | null {
|
||||
return value?.trim() ? value : null;
|
||||
}
|
||||
|
||||
export function applyPerModelConfigToRuntime(config: PerModelConfig): void {
|
||||
// Fall back to the standing default when the model has no saved
|
||||
// maxSeqLength. maxSeqLength is the only per-model field carried on
|
||||
// params (the rest are reset below), so without this a model with no
|
||||
// remembered config would inherit the previously loaded model's value.
|
||||
const maxSeqLength =
|
||||
normalizeMaxSeqLength(config.maxSeqLength) ??
|
||||
defaultInferenceParams.maxSeqLength;
|
||||
const store = useChatRuntimeStore.getState();
|
||||
if (maxSeqLength !== store.params.maxSeqLength) {
|
||||
store.setParams({ ...store.params, maxSeqLength });
|
||||
}
|
||||
useChatRuntimeStore.setState({
|
||||
customContextLength: config.customContextLength ?? null,
|
||||
kvCacheDtype: config.kvCacheDtype ?? null,
|
||||
speculativeType:
|
||||
normalizeSpeculativeType(config.speculativeType) ??
|
||||
readPersistedSpeculativeType(),
|
||||
specDraftNMax: config.specDraftNMax ?? null,
|
||||
tensorParallel: config.tensorParallel ?? false,
|
||||
chatTemplateOverride: cleanTemplate(config.chatTemplateOverride),
|
||||
});
|
||||
}
|
||||
|
||||
export function applyModelLoadConfigToRuntime(
|
||||
config: PerModelConfig | null | undefined,
|
||||
): boolean {
|
||||
const hasConfig = config != null;
|
||||
applyPerModelConfigToRuntime(config ?? DEFAULT_PER_MODEL_CONFIG);
|
||||
return hasConfig;
|
||||
}
|
||||
|
||||
export function currentRuntimePerModelConfig(
|
||||
options: { includeMaxSeqLength?: boolean } = {},
|
||||
): PerModelConfig {
|
||||
const s = useChatRuntimeStore.getState();
|
||||
return {
|
||||
customContextLength: s.customContextLength ?? null,
|
||||
maxSeqLength: options.includeMaxSeqLength
|
||||
? normalizeMaxSeqLength(s.params.maxSeqLength)
|
||||
: null,
|
||||
kvCacheDtype: s.kvCacheDtype ?? null,
|
||||
speculativeType: normalizeSpeculativeType(s.speculativeType),
|
||||
specDraftNMax: s.specDraftNMax ?? null,
|
||||
tensorParallel: s.tensorParallel ?? false,
|
||||
chatTemplateOverride: cleanTemplate(s.chatTemplateOverride),
|
||||
};
|
||||
}
|
||||
|
||||
export function perModelConfigsEqual(
|
||||
a: PerModelConfig,
|
||||
b: PerModelConfig,
|
||||
): boolean {
|
||||
return (
|
||||
(a.customContextLength ?? null) === (b.customContextLength ?? null) &&
|
||||
normalizeMaxSeqLength(a.maxSeqLength) ===
|
||||
normalizeMaxSeqLength(b.maxSeqLength) &&
|
||||
(a.kvCacheDtype ?? null) === (b.kvCacheDtype ?? null) &&
|
||||
normalizeSpeculativeType(a.speculativeType) ===
|
||||
normalizeSpeculativeType(b.speculativeType) &&
|
||||
(a.specDraftNMax ?? null) === (b.specDraftNMax ?? null) &&
|
||||
Boolean(a.tensorParallel) === Boolean(b.tensorParallel) &&
|
||||
cleanTemplate(a.chatTemplateOverride) ===
|
||||
cleanTemplate(b.chatTemplateOverride)
|
||||
);
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
// 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 {
|
||||
normalizeGgufVariantIdentity,
|
||||
normalizeModelIdentity,
|
||||
} from "@/features/hub";
|
||||
|
||||
export {
|
||||
normalizeGgufVariantIdentity,
|
||||
normalizeModelIdentity,
|
||||
} from "@/features/hub";
|
||||
|
||||
const MODEL_STORAGE_KEY_PREFIX = "v2:";
|
||||
|
||||
type ParsedModelStorageKey = {
|
||||
modelId: string;
|
||||
ggufVariant: string;
|
||||
};
|
||||
|
||||
function parseVersionedModelStorageKey(
|
||||
key: string,
|
||||
): ParsedModelStorageKey | null {
|
||||
if (!key.startsWith(MODEL_STORAGE_KEY_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(key.slice(MODEL_STORAGE_KEY_PREFIX.length));
|
||||
if (
|
||||
!Array.isArray(parsed) ||
|
||||
parsed.length !== 2 ||
|
||||
typeof parsed[0] !== "string" ||
|
||||
typeof parsed[1] !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { modelId: parsed[0], ggufVariant: parsed[1] };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function modelStorageKey(
|
||||
modelId: string,
|
||||
ggufVariant?: string | null,
|
||||
): string {
|
||||
return `${MODEL_STORAGE_KEY_PREFIX}${JSON.stringify([
|
||||
normalizeModelIdentity(modelId),
|
||||
normalizeGgufVariantIdentity(ggufVariant),
|
||||
])}`;
|
||||
}
|
||||
|
||||
export function modelIdFromStorageKey(key: string): string | null {
|
||||
const parsed = parseVersionedModelStorageKey(key);
|
||||
if (parsed) {
|
||||
return parsed.modelId;
|
||||
}
|
||||
const separator = key.lastIndexOf("::");
|
||||
return separator >= 0 ? key.slice(0, separator) : null;
|
||||
}
|
||||
|
||||
export function ggufVariantFromStorageKey(key: string): string | null {
|
||||
const parsed = parseVersionedModelStorageKey(key);
|
||||
if (parsed) {
|
||||
return parsed.ggufVariant;
|
||||
}
|
||||
const separator = key.lastIndexOf("::");
|
||||
return separator >= 0 ? key.slice(separator + 2) : null;
|
||||
}
|
||||
|
|
@ -1,571 +0,0 @@
|
|||
// 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 {
|
||||
ggufVariantFromStorageKey,
|
||||
modelIdFromStorageKey,
|
||||
modelStorageKey,
|
||||
normalizeGgufVariantIdentity,
|
||||
normalizeModelIdentity,
|
||||
} from "./model-identity";
|
||||
|
||||
export interface PerModelConfig {
|
||||
customContextLength: number | null;
|
||||
maxSeqLength: number | null;
|
||||
kvCacheDtype: string | null;
|
||||
speculativeType: string | null;
|
||||
specDraftNMax: number | null;
|
||||
tensorParallel: boolean;
|
||||
chatTemplateOverride: string | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_PER_MODEL_CONFIG: PerModelConfig = {
|
||||
customContextLength: null,
|
||||
maxSeqLength: null,
|
||||
kvCacheDtype: null,
|
||||
speculativeType: null,
|
||||
specDraftNMax: null,
|
||||
tensorParallel: false,
|
||||
chatTemplateOverride: null,
|
||||
};
|
||||
|
||||
export const MAX_SEQ_LENGTH_MIN = 128;
|
||||
export const MAX_SEQ_LENGTH_MAX = 1048576;
|
||||
export const MAX_SEQ_LENGTH_STEP = 128;
|
||||
|
||||
export const KV_CACHE_DTYPES = ["bf16", "q8_0", "q5_1", "q4_1"] as const;
|
||||
const VALID_KV_CACHE_DTYPES = new Set<string>(KV_CACHE_DTYPES);
|
||||
|
||||
export const SPECULATIVE_TYPES = [
|
||||
"auto",
|
||||
"mtp",
|
||||
"ngram",
|
||||
"mtp+ngram",
|
||||
"off",
|
||||
] as const;
|
||||
export const MTP_SPECULATIVE_TYPES: ReadonlySet<string> = new Set([
|
||||
"mtp",
|
||||
"mtp+ngram",
|
||||
]);
|
||||
|
||||
const STORAGE_KEY = "unsloth_model_configs";
|
||||
const LEGACY_STORAGE_KEY = "unsloth_load_settings";
|
||||
const LEGACY_MIGRATION_FLAG = "unsloth_model_configs_migrated";
|
||||
const STORAGE_SCHEMA_VERSION = 1;
|
||||
const MAX_ENTRIES = 500;
|
||||
const MAX_PER_MODEL_CONFIG_STORAGE_BYTES = 1024 * 1024;
|
||||
export const MAX_CHAT_TEMPLATE_BYTES = 65_536;
|
||||
|
||||
type StoredPerModelConfig = PerModelConfig & {
|
||||
version: typeof STORAGE_SCHEMA_VERSION;
|
||||
};
|
||||
type StoredMap = Record<string, PerModelConfig | StoredPerModelConfig>;
|
||||
type RawConfig = Partial<PerModelConfig> & { version?: unknown };
|
||||
|
||||
const STORED_CONFIG_FIELDS = new Set([
|
||||
"version",
|
||||
"customContextLength",
|
||||
"maxSeqLength",
|
||||
"kvCacheDtype",
|
||||
"speculativeType",
|
||||
"specDraftNMax",
|
||||
"tensorParallel",
|
||||
"chatTemplateOverride",
|
||||
]);
|
||||
|
||||
function canonicalizeSpeculativeType(value: string): string | null {
|
||||
const s = value.trim().toLowerCase();
|
||||
if (!s) {
|
||||
return null;
|
||||
}
|
||||
if (s === "auto" || s === "default") {
|
||||
return "auto";
|
||||
}
|
||||
if (s === "off") {
|
||||
return "off";
|
||||
}
|
||||
if (s === "mtp" || s === "draft-mtp") {
|
||||
return "mtp";
|
||||
}
|
||||
if (s === "ngram" || s === "ngram-mod" || s === "ngram-simple") {
|
||||
return "ngram";
|
||||
}
|
||||
if (s === "mtp+ngram") {
|
||||
return "mtp+ngram";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeMaxSeqLength(value: unknown): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
const snapped = Math.round(value / MAX_SEQ_LENGTH_STEP) * MAX_SEQ_LENGTH_STEP;
|
||||
return Math.max(MAX_SEQ_LENGTH_MIN, Math.min(MAX_SEQ_LENGTH_MAX, snapped));
|
||||
}
|
||||
|
||||
function canUseStorage(): boolean {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
function serializedByteLength(value: string): number {
|
||||
return typeof TextEncoder !== "undefined"
|
||||
? new TextEncoder().encode(value).byteLength
|
||||
: value.length;
|
||||
}
|
||||
|
||||
export function chatTemplateByteLength(value: string): number {
|
||||
return serializedByteLength(value);
|
||||
}
|
||||
|
||||
export function isChatTemplateWithinLimit(value: string): boolean {
|
||||
return chatTemplateByteLength(value) <= MAX_CHAT_TEMPLATE_BYTES;
|
||||
}
|
||||
|
||||
function serializedMapSize(map: StoredMap): number {
|
||||
return serializedByteLength(JSON.stringify(map));
|
||||
}
|
||||
|
||||
function serializedMapEntrySize(key: string, value: StoredMap[string]): number {
|
||||
return (
|
||||
serializedByteLength(JSON.stringify(key)) +
|
||||
1 +
|
||||
serializedByteLength(JSON.stringify(value))
|
||||
);
|
||||
}
|
||||
|
||||
function deleteOldestEvictableEntry(
|
||||
map: StoredMap,
|
||||
protectedKeys?: ReadonlySet<string>,
|
||||
): { key: string; value: StoredMap[string] } | null {
|
||||
for (const key of Object.keys(map)) {
|
||||
// Never evict a future-schema entry an older client cannot interpret,
|
||||
// matching the save/delete guards.
|
||||
if (
|
||||
protectedKeys?.has(key) ||
|
||||
storedConfigVersion(map[key]) > STORAGE_SCHEMA_VERSION
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const value = map[key];
|
||||
delete map[key];
|
||||
return { key, value };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function enforceStorageBudget(
|
||||
map: StoredMap,
|
||||
protectedKeys?: ReadonlySet<string>,
|
||||
): boolean {
|
||||
let entryCount = Object.keys(map).length;
|
||||
while (entryCount > MAX_ENTRIES) {
|
||||
if (!deleteOldestEvictableEntry(map, protectedKeys)) {
|
||||
return false;
|
||||
}
|
||||
entryCount -= 1;
|
||||
}
|
||||
let bytes = serializedMapSize(map);
|
||||
while (bytes > MAX_PER_MODEL_CONFIG_STORAGE_BYTES) {
|
||||
const removed = deleteOldestEvictableEntry(map, protectedKeys);
|
||||
if (!removed) {
|
||||
return false;
|
||||
}
|
||||
bytes -=
|
||||
serializedMapEntrySize(removed.key, removed.value) +
|
||||
(entryCount > 1 ? 1 : 0);
|
||||
entryCount -= 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function storedConfigVersion(raw: unknown): number {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return 0;
|
||||
}
|
||||
const version = (raw as RawConfig).version;
|
||||
return typeof version === "number" && Number.isFinite(version) ? version : 0;
|
||||
}
|
||||
|
||||
let legacyMigrationChecked = false;
|
||||
|
||||
function parseLegacyModelKey(
|
||||
key: string,
|
||||
): { modelId: string; ggufVariant: string | null } | null {
|
||||
const separator = key.lastIndexOf("::");
|
||||
if (separator >= 0) {
|
||||
const modelId = key.slice(0, separator);
|
||||
return modelId
|
||||
? { modelId, ggufVariant: key.slice(separator + 2) || null }
|
||||
: null;
|
||||
}
|
||||
return key ? { modelId: key, ggufVariant: null } : null;
|
||||
}
|
||||
|
||||
function legacyEntryToConfig(raw: Record<string, unknown>): PerModelConfig {
|
||||
return normalizeV1({
|
||||
customContextLength:
|
||||
typeof raw.contextLength === "number" ? raw.contextLength : null,
|
||||
maxSeqLength: null,
|
||||
kvCacheDtype:
|
||||
typeof raw.kvCacheDtype === "string" ? raw.kvCacheDtype : null,
|
||||
speculativeType:
|
||||
typeof raw.speculativeType === "string" ? raw.speculativeType : null,
|
||||
specDraftNMax:
|
||||
typeof raw.specDraftNMax === "number" ? raw.specDraftNMax : null,
|
||||
tensorParallel:
|
||||
typeof raw.tensorParallel === "boolean" ? raw.tensorParallel : false,
|
||||
chatTemplateOverride: null,
|
||||
});
|
||||
}
|
||||
|
||||
function mergeLegacyEntries(
|
||||
map: StoredMap,
|
||||
legacy: Record<string, unknown>,
|
||||
): string[] {
|
||||
const addedKeys: string[] = [];
|
||||
for (const [legacyKey, value] of Object.entries(legacy)) {
|
||||
if (!value || typeof value !== "object") {
|
||||
continue;
|
||||
}
|
||||
const parsedKey = parseLegacyModelKey(legacyKey);
|
||||
if (!parsedKey) {
|
||||
continue;
|
||||
}
|
||||
const migrated = legacyEntryToConfig(value as Record<string, unknown>);
|
||||
const key = modelStorageKey(parsedKey.modelId, parsedKey.ggufVariant);
|
||||
if (isDefaultConfig(migrated) || Object.hasOwn(map, key)) {
|
||||
continue;
|
||||
}
|
||||
map[key] = toStoredConfig(migrated);
|
||||
addedKeys.push(key);
|
||||
}
|
||||
return addedKeys;
|
||||
}
|
||||
|
||||
function migrateLegacyLoadSettingsOnce(): void {
|
||||
if (legacyMigrationChecked || !canUseStorage()) {
|
||||
return;
|
||||
}
|
||||
legacyMigrationChecked = true;
|
||||
try {
|
||||
if (localStorage.getItem(LEGACY_MIGRATION_FLAG)) {
|
||||
return;
|
||||
}
|
||||
let legacy: unknown = null;
|
||||
try {
|
||||
legacy = JSON.parse(localStorage.getItem(LEGACY_STORAGE_KEY) ?? "null");
|
||||
} catch {
|
||||
legacy = null;
|
||||
}
|
||||
if (!legacy || typeof legacy !== "object" || Array.isArray(legacy)) {
|
||||
localStorage.setItem(LEGACY_MIGRATION_FLAG, "1");
|
||||
return;
|
||||
}
|
||||
const map = readMapRaw();
|
||||
const migratedKeys = mergeLegacyEntries(
|
||||
map,
|
||||
legacy as Record<string, unknown>,
|
||||
);
|
||||
if (migratedKeys.length === 0) {
|
||||
localStorage.setItem(LEGACY_MIGRATION_FLAG, "1");
|
||||
return;
|
||||
}
|
||||
// Protect the just-migrated entries during eviction. If the budget cannot
|
||||
// fit them (e.g. storage is full of future-schema records an older client
|
||||
// cannot evict), leave the flag unset so migration retries once space frees
|
||||
// up rather than marking it complete and dropping the migrated config.
|
||||
if (!enforceStorageBudget(map, new Set(migratedKeys))) {
|
||||
return;
|
||||
}
|
||||
if (writeMap(map)) {
|
||||
localStorage.setItem(LEGACY_MIGRATION_FLAG, "1");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to migrate legacy load settings:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function readMapRaw(): StoredMap {
|
||||
if (!canUseStorage()) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
return parsed as StoredMap;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readMap(): StoredMap {
|
||||
migrateLegacyLoadSettingsOnce();
|
||||
return readMapRaw();
|
||||
}
|
||||
|
||||
function writeMap(map: StoredMap): boolean {
|
||||
if (!canUseStorage()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn("Failed to persist per-model config:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function warnDroppedFields(raw: Record<string, unknown>, version: number): void {
|
||||
if (!import.meta.env?.DEV) {
|
||||
return;
|
||||
}
|
||||
const dropped = Object.keys(raw).filter(
|
||||
(key) => !STORED_CONFIG_FIELDS.has(key),
|
||||
);
|
||||
if (dropped.length > 0) {
|
||||
console.warn("Dropped unknown per-model config fields:", dropped);
|
||||
}
|
||||
if (version > STORAGE_SCHEMA_VERSION) {
|
||||
console.warn("Per-model config schema is newer than this app:", version);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeV1(partial: RawConfig): PerModelConfig {
|
||||
const rawSpecType =
|
||||
typeof partial.speculativeType === "string"
|
||||
? canonicalizeSpeculativeType(partial.speculativeType)
|
||||
: null;
|
||||
const speculativeType = rawSpecType ?? DEFAULT_PER_MODEL_CONFIG.speculativeType;
|
||||
const specDraftNMax =
|
||||
speculativeType != null &&
|
||||
MTP_SPECULATIVE_TYPES.has(speculativeType) &&
|
||||
typeof partial.specDraftNMax === "number" &&
|
||||
Number.isFinite(partial.specDraftNMax)
|
||||
? Math.max(1, Math.min(16, Math.round(partial.specDraftNMax)))
|
||||
: null;
|
||||
return {
|
||||
customContextLength:
|
||||
typeof partial.customContextLength === "number" &&
|
||||
Number.isFinite(partial.customContextLength) &&
|
||||
partial.customContextLength > 0
|
||||
? Math.floor(partial.customContextLength)
|
||||
: null,
|
||||
maxSeqLength: normalizeMaxSeqLength(partial.maxSeqLength),
|
||||
kvCacheDtype:
|
||||
typeof partial.kvCacheDtype === "string" &&
|
||||
VALID_KV_CACHE_DTYPES.has(partial.kvCacheDtype)
|
||||
? partial.kvCacheDtype
|
||||
: null,
|
||||
speculativeType,
|
||||
specDraftNMax,
|
||||
tensorParallel:
|
||||
typeof partial.tensorParallel === "boolean"
|
||||
? partial.tensorParallel
|
||||
: DEFAULT_PER_MODEL_CONFIG.tensorParallel,
|
||||
chatTemplateOverride:
|
||||
typeof partial.chatTemplateOverride === "string" &&
|
||||
isChatTemplateWithinLimit(partial.chatTemplateOverride)
|
||||
? partial.chatTemplateOverride
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalize(raw: unknown): PerModelConfig {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return normalizeV1({});
|
||||
}
|
||||
const partial = raw as RawConfig;
|
||||
const version =
|
||||
typeof partial.version === "number" && Number.isFinite(partial.version)
|
||||
? partial.version
|
||||
: 0;
|
||||
warnDroppedFields(raw as Record<string, unknown>, version);
|
||||
return normalizeV1(partial);
|
||||
}
|
||||
|
||||
function toStoredConfig(config: PerModelConfig): StoredPerModelConfig {
|
||||
return {
|
||||
version: STORAGE_SCHEMA_VERSION,
|
||||
...normalize(config),
|
||||
};
|
||||
}
|
||||
|
||||
function legacyModelStorageKey(
|
||||
modelId: string,
|
||||
ggufVariant?: string | null,
|
||||
): string {
|
||||
return `${modelId}::${ggufVariant ?? ""}`;
|
||||
}
|
||||
|
||||
function storageKeysForModelVariant(
|
||||
modelId: string,
|
||||
ggufVariant?: string | null,
|
||||
): string[] {
|
||||
const key = modelStorageKey(modelId, ggufVariant);
|
||||
const legacyKey = legacyModelStorageKey(modelId, ggufVariant);
|
||||
return key === legacyKey ? [key] : [key, legacyKey];
|
||||
}
|
||||
|
||||
function configKeyMatchesModelVariant(
|
||||
key: string,
|
||||
modelId: string,
|
||||
ggufVariant?: string | null,
|
||||
): boolean {
|
||||
const storedModelId = modelIdFromStorageKey(key);
|
||||
if (!storedModelId) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalizeModelIdentity(storedModelId) === normalizeModelIdentity(modelId) &&
|
||||
normalizeGgufVariantIdentity(ggufVariantFromStorageKey(key)) ===
|
||||
normalizeGgufVariantIdentity(ggufVariant)
|
||||
);
|
||||
}
|
||||
|
||||
function findConfigKeyForModelVariant(
|
||||
map: StoredMap,
|
||||
modelId: string,
|
||||
ggufVariant?: string | null,
|
||||
): string | null {
|
||||
for (const key of storageKeysForModelVariant(modelId, ggufVariant)) {
|
||||
if (Object.hasOwn(map, key)) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(map)) {
|
||||
if (configKeyMatchesModelVariant(key, modelId, ggufVariant)) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasFutureConfigForModelVariant(
|
||||
map: StoredMap,
|
||||
modelId: string,
|
||||
ggufVariant?: string | null,
|
||||
): boolean {
|
||||
for (const key of Object.keys(map)) {
|
||||
if (
|
||||
configKeyMatchesModelVariant(key, modelId, ggufVariant) &&
|
||||
storedConfigVersion(map[key]) > STORAGE_SCHEMA_VERSION
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function deleteConfigEntriesForModelVariant(
|
||||
map: StoredMap,
|
||||
modelId: string,
|
||||
ggufVariant?: string | null,
|
||||
): boolean {
|
||||
let changed = false;
|
||||
for (const key of Object.keys(map)) {
|
||||
if (!configKeyMatchesModelVariant(key, modelId, ggufVariant)) {
|
||||
continue;
|
||||
}
|
||||
delete map[key];
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function loadPerModelConfig(
|
||||
modelId: string,
|
||||
ggufVariant?: string | null,
|
||||
): PerModelConfig | null {
|
||||
const map = readMap();
|
||||
const key = findConfigKeyForModelVariant(map, modelId, ggufVariant);
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
// Never apply a future-schema record an older client cannot interpret,
|
||||
// matching the save/delete/evict guards.
|
||||
if (storedConfigVersion(map[key]) > STORAGE_SCHEMA_VERSION) {
|
||||
return null;
|
||||
}
|
||||
return normalize(map[key]);
|
||||
}
|
||||
|
||||
export function isDefaultConfig(config: PerModelConfig): boolean {
|
||||
return (
|
||||
config.customContextLength == null &&
|
||||
config.maxSeqLength == null &&
|
||||
(config.kvCacheDtype ?? null) === DEFAULT_PER_MODEL_CONFIG.kvCacheDtype &&
|
||||
config.speculativeType === DEFAULT_PER_MODEL_CONFIG.speculativeType &&
|
||||
config.specDraftNMax == null &&
|
||||
Boolean(config.tensorParallel) ===
|
||||
Boolean(DEFAULT_PER_MODEL_CONFIG.tensorParallel) &&
|
||||
(config.chatTemplateOverride ?? null) === null
|
||||
);
|
||||
}
|
||||
|
||||
export function savePerModelConfig(
|
||||
modelId: string,
|
||||
ggufVariant: string | null | undefined,
|
||||
config: PerModelConfig,
|
||||
): boolean {
|
||||
if (
|
||||
typeof config.chatTemplateOverride === "string" &&
|
||||
!isChatTemplateWithinLimit(config.chatTemplateOverride)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalize(config);
|
||||
const map = readMap();
|
||||
if (hasFutureConfigForModelVariant(map, modelId, ggufVariant)) {
|
||||
return false;
|
||||
}
|
||||
if (isDefaultConfig(normalized)) {
|
||||
const changed = deleteConfigEntriesForModelVariant(
|
||||
map,
|
||||
modelId,
|
||||
ggufVariant,
|
||||
);
|
||||
return changed ? writeMap(map) : true;
|
||||
}
|
||||
const [key] = storageKeysForModelVariant(modelId, ggufVariant);
|
||||
deleteConfigEntriesForModelVariant(map, modelId, ggufVariant);
|
||||
map[key] = toStoredConfig(normalized);
|
||||
if (!enforceStorageBudget(map, new Set([key]))) {
|
||||
return false;
|
||||
}
|
||||
return writeMap(map);
|
||||
}
|
||||
|
||||
export function deletePerModelConfig(
|
||||
modelId: string,
|
||||
ggufVariant?: string | null,
|
||||
): boolean {
|
||||
const map = readMap();
|
||||
// Mirror savePerModelConfig: never let an older client destroy a
|
||||
// future-schema entry it cannot interpret.
|
||||
if (hasFutureConfigForModelVariant(map, modelId, ggufVariant)) {
|
||||
return false;
|
||||
}
|
||||
if (!deleteConfigEntriesForModelVariant(map, modelId, ggufVariant)) {
|
||||
return true;
|
||||
}
|
||||
return writeMap(map);
|
||||
}
|
||||
|
||||
export function resolveInitialConfig(
|
||||
modelId: string,
|
||||
ggufVariant?: string | null,
|
||||
): { config: PerModelConfig; remembered: boolean } {
|
||||
const saved = loadPerModelConfig(modelId, ggufVariant);
|
||||
if (saved) {
|
||||
return { config: saved, remembered: true };
|
||||
}
|
||||
return { config: { ...DEFAULT_PER_MODEL_CONFIG }, remembered: false };
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ import {
|
|||
Folder01Icon,
|
||||
McpServerIcon,
|
||||
PencilRulerIcon,
|
||||
Settings02Icon,
|
||||
ShieldBanIcon,
|
||||
Upload01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
|
|
@ -195,6 +196,10 @@ export function ChatTab() {
|
|||
const hydratePersistedSettings = useChatRuntimeStore(
|
||||
(state) => state.hydratePersistedSettings,
|
||||
);
|
||||
const loadOnSelection = useChatRuntimeStore((state) => state.loadOnSelection);
|
||||
const setLoadOnSelection = useChatRuntimeStore(
|
||||
(state) => state.setLoadOnSelection,
|
||||
);
|
||||
const expandQuantizations = useChatRuntimeStore(
|
||||
(state) => state.expandQuantizations,
|
||||
);
|
||||
|
|
@ -328,6 +333,40 @@ export function ChatTab() {
|
|||
</header>
|
||||
|
||||
<SettingsSection title="Select model settings">
|
||||
<SettingsRow
|
||||
label="Load on selection"
|
||||
alignTop={true}
|
||||
description={
|
||||
<span>
|
||||
On: Unsloth auto-picks the best settings and loads it.
|
||||
<br />
|
||||
Off: opens Run settings to customize, then load.
|
||||
<br />
|
||||
The gear always opens Run settings:{" "}
|
||||
<span className="ml-2 inline-flex items-center gap-3 align-middle">
|
||||
<span className="font-mono text-xs text-foreground">
|
||||
Q4_K_M
|
||||
</span>
|
||||
<span className="text-[9px] font-medium text-green-400">
|
||||
downloaded
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">16 GB</span>
|
||||
<span className="inline-flex size-4 items-center justify-center rounded bg-black/[0.06] dark:bg-white/[0.08]">
|
||||
<HugeiconsIcon
|
||||
icon={Settings02Icon}
|
||||
strokeWidth={1.75}
|
||||
className="size-2.5 text-muted-foreground/80"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
checked={loadOnSelection}
|
||||
onCheckedChange={setLoadOnSelection}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label="Expand quantizations"
|
||||
description={
|
||||
|
|
|
|||
|
|
@ -92,10 +92,9 @@ const PREFS_KEYS: string[] = [
|
|||
"unsloth_chat_inference_params",
|
||||
"unsloth_chat_collapsible_state",
|
||||
"unsloth_chat_preferences",
|
||||
"unsloth_model_configs",
|
||||
"unsloth_model_configs_migrated",
|
||||
"unsloth_load_settings",
|
||||
// Model selector settings ("Select model settings" group)
|
||||
"unsloth_chat_load_on_selection",
|
||||
"unsloth_chat_expand_quantizations",
|
||||
"unsloth_chat_show_all_quantizations",
|
||||
"unsloth_models_fit_on_device_only",
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ export { HfDatasetSubsetSplitSelectors } from "./components/hf-dataset-subset-sp
|
|||
export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-store";
|
||||
export { listLocalDatasets, uploadTrainingDataset } from "./api/datasets-api";
|
||||
export type { LocalDatasetInfo } from "./types/datasets";
|
||||
export { getModelConfig, listLocalModels } from "./api/models-api";
|
||||
export type { LocalModelInfo, ModelConfigResponse } from "./api/models-api";
|
||||
export { listLocalModels } from "./api/models-api";
|
||||
export type { LocalModelInfo } from "./api/models-api";
|
||||
export type {
|
||||
TrainingPhase,
|
||||
TrainingViewData,
|
||||
|
|
|
|||
|
|
@ -678,16 +678,10 @@ with sync_playwright() as p:
|
|||
last_assistant = page.locator('[data-role="assistant"]').last
|
||||
last_assistant.hover()
|
||||
page.wait_for_timeout(400)
|
||||
# Exclude disabled controls: the picker's new disabled "Reload model"
|
||||
# button also matches and sorts first, so .first would target it.
|
||||
regen_btn = (
|
||||
page.get_by_role(
|
||||
"button",
|
||||
name = re.compile(r"(reload|regenerate)", re.I),
|
||||
)
|
||||
.and_(page.locator("button:not([disabled])"))
|
||||
.first
|
||||
)
|
||||
regen_btn = page.get_by_role(
|
||||
"button",
|
||||
name = re.compile(r"(reload|regenerate)", re.I),
|
||||
).first
|
||||
if regen_btn.count() > 0:
|
||||
regen_btn.click()
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -10,14 +10,7 @@ from pathlib import Path
|
|||
|
||||
WORKDIR = Path(__file__).resolve().parents[2]
|
||||
MODEL_SELECTOR = (
|
||||
WORKDIR
|
||||
/ "studio"
|
||||
/ "frontend"
|
||||
/ "src"
|
||||
/ "features"
|
||||
/ "model-picker"
|
||||
/ "components"
|
||||
/ "model-selector.tsx"
|
||||
WORKDIR / "studio" / "frontend" / "src" / "components" / "assistant-ui" / "model-selector.tsx"
|
||||
)
|
||||
APP_SIDEBAR = WORKDIR / "studio" / "frontend" / "src" / "components" / "app-sidebar.tsx"
|
||||
|
||||
|
|
@ -41,12 +34,10 @@ def test_model_selector_trigger_label_uses_leading_tight():
|
|||
|
||||
def test_sidebar_account_block_uses_leading_tight():
|
||||
src = _read(APP_SIDEBAR)
|
||||
# Match the account-block parent div regardless of its layout/spacing
|
||||
# utilities (e.g. min-w-0, flex-1, the gap-* class); this guard is about the
|
||||
# leading-* class immediately before the collapsible visibility utility, not
|
||||
# the surrounding flex plumbing.
|
||||
# Match the account-block parent div regardless of its gap utility; this
|
||||
# guard is about the leading-* class, not the spacing.
|
||||
pattern = re.compile(
|
||||
r'<div\s+className="flex\b[^"]*\bflex-col\b[^"]*\bgap-\S+\s+(\S+)\s+group-data-\[collapsible=icon\]:hidden">',
|
||||
r'<div\s+className="flex\s+flex-col\s+gap-\S+\s+(\S+)\s+group-data-\[collapsible=icon\]:hidden">',
|
||||
)
|
||||
matches = pattern.findall(src)
|
||||
assert matches, "could not find sidebar account-block parent div"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue