diff --git a/install.ps1 b/install.ps1 index 0c36046195..ead4e7368d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -749,7 +749,6 @@ shell.Run cmd, 0, False } else { step "gpu" "none (chat-only / GGUF)" "Yellow" substep "Training and GPU inference require an NVIDIA GPU with drivers installed." "Yellow" - substep "https://www.nvidia.com/Download/index.aspx" "Yellow" } # ── Choose the correct PyTorch index URL based on driver CUDA version ── @@ -777,10 +776,10 @@ shell.Run cmd, 0, False # ── Print CPU-only hint when no GPU detected ── if (-not $SkipTorch -and $TorchIndexUrl -like "*/cpu") { Write-Host "" - Write-Host " NOTE: No NVIDIA GPU detected." -ForegroundColor Yellow - Write-Host " Installing CPU-only PyTorch. If you only need GGUF chat/inference," - Write-Host " re-run with --no-torch for a faster, lighter install:" - Write-Host " .\install.ps1 --no-torch" + substep "No NVIDIA GPU detected." "Yellow" + substep "Installing CPU-only PyTorch. If you only need GGUF chat/inference," "Yellow" + substep "re-run with --no-torch for a faster, lighter install:" "Yellow" + substep ".\install.ps1 --no-torch" "Yellow" Write-Host "" } @@ -820,7 +819,7 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.16" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.18" unsloth-zoo } if ($baseInstallExit -eq 0) { $NoTorchReq = Find-NoTorchRuntimeFile if ($NoTorchReq) { @@ -828,7 +827,7 @@ shell.Run cmd, 0, False } } } else { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.16" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.18" unsloth-zoo } } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red @@ -858,7 +857,7 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.3.16" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.3.18" unsloth-zoo } if ($baseInstallExit -eq 0) { $NoTorchReq = Find-NoTorchRuntimeFile if ($NoTorchReq) { @@ -866,7 +865,7 @@ shell.Run cmd, 0, False } } } elseif ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.16" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.18" unsloth-zoo } } else { $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "$PackageName" } } @@ -887,7 +886,7 @@ shell.Run cmd, 0, False # Fallback: GPU detection failed to produce a URL -- let uv resolve torch substep "installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.3.16" --torch-backend=auto } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.3.18" --torch-backend=auto } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red return diff --git a/install.sh b/install.sh index 9ea80bc161..5d8f8c68cf 100755 --- a/install.sh +++ b/install.sh @@ -1029,7 +1029,7 @@ if [ "$_MIGRATED" = true ]; then # to prevent transitive torch resolution. run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.3.16" unsloth-zoo + "unsloth>=2026.3.18" unsloth-zoo _NO_TORCH_RT="$(_find_no_torch_runtime)" if [ -n "$_NO_TORCH_RT" ]; then run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" @@ -1037,7 +1037,7 @@ if [ "$_MIGRATED" = true ]; then else run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.3.16" unsloth-zoo + "unsloth>=2026.3.18" unsloth-zoo fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." @@ -1059,7 +1059,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --upgrade-package unsloth --upgrade-package unsloth-zoo \ - "unsloth>=2026.3.16" unsloth-zoo + "unsloth>=2026.3.18" unsloth-zoo _NO_TORCH_RT="$(_find_no_torch_runtime)" if [ -n "$_NO_TORCH_RT" ]; then run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" @@ -1070,7 +1070,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then fi elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \ - --upgrade-package unsloth "unsloth>=2026.3.16" unsloth-zoo + --upgrade-package unsloth "unsloth>=2026.3.18" unsloth-zoo substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps else @@ -1081,7 +1081,7 @@ else # Fallback: GPU detection failed to produce a URL -- let uv resolve torch substep "installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.3.16" --torch-backend=auto + run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.3.18" --torch-backend=auto substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps else diff --git a/pyproject.toml b/pyproject.toml index b06131021a..b876c19467 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,7 @@ huggingfacenotorch = [ ] huggingface = [ "unsloth[huggingfacenotorch]", - "unsloth_zoo>=2026.3.6", + "unsloth_zoo>=2026.3.7", "torchvision", "unsloth[triton]", ] @@ -578,7 +578,7 @@ colab-ampere-torch220 = [ "flash-attn>=2.6.3 ; ('linux' in sys_platform)", ] colab-new = [ - "unsloth_zoo>=2026.3.6", + "unsloth_zoo>=2026.3.7", "packaging", "tyro", "transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.3.0", diff --git a/studio/backend/core/inference/_html_to_md.py b/studio/backend/core/inference/_html_to_md.py new file mode 100644 index 0000000000..d96b8168e2 --- /dev/null +++ b/studio/backend/core/inference/_html_to_md.py @@ -0,0 +1,439 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Minimal HTML-to-Markdown converter using only the standard library. + +Replaces the external ``html2text`` (GPL-3.0) dependency with a ~250-line +``html.parser.HTMLParser`` subclass. Covers headings, links, bold/italic, +lists, tables, blockquotes, code blocks, and entity decoding. +""" + +from __future__ import annotations + +import html +import re +from html.parser import HTMLParser + +__all__ = ["html_to_markdown"] + +_SKIP_TAGS = frozenset({"script", "style", "head", "noscript", "svg", "math"}) +_BLOCK_TAGS = frozenset( + { + "p", + "div", + "section", + "article", + "header", + "footer", + "main", + "aside", + "nav", + "figure", + "figcaption", + "details", + "summary", + "dl", + "dt", + "dd", + } +) +_HEADING_TAGS = frozenset({"h1", "h2", "h3", "h4", "h5", "h6"}) +_INLINE_EMPHASIS = {"strong": "**", "b": "**", "em": "*", "i": "*"} + + +class _MarkdownRenderer(HTMLParser): + """HTMLParser subclass that emits Markdown tokens into a list.""" + + def __init__(self): + super().__init__(convert_charrefs = False) + self._out: list[str] = [] + self._skip_depth: int = 0 + + # Link state + self._link_href: str | None = None + self._link_text_parts: list[str] = [] + self._in_link: bool = False + + # List state + self._list_stack: list[str] = [] # "ul" or "ol" + self._ol_counter: list[int] = [] + + # Table state + self._in_table: bool = False + self._current_row: list[str] = [] + self._cell_parts: list[str] = [] + self._in_cell: bool = False + self._header_row_done: bool = False + self._row_has_th: bool = False + self._is_first_row: bool = False + + # Pre/code state + self._in_pre: bool = False + self._pre_parts: list[str] = [] + self._in_inline_code: bool = False + + # Blockquote state -- stack of output buffers so nested + # blockquotes each collect their own content and get prefixed + # with the correct number of ">" markers on close. + self._bq_stack: list[list[str]] = [] + + # ------------------------------------------------------------------ + def _emit(self, text: str) -> None: + if self._in_link: + self._link_text_parts.append(text) + elif self._in_cell: + self._cell_parts.append(text) + elif self._in_pre: + self._pre_parts.append(text) + elif self._bq_stack: + self._bq_stack[-1].append(text) + else: + self._out.append(text) + + # ------------------------------------------------------------------ + def _prefix_blockquote(self, content: str) -> str: + """Prefix every line of *content* with ``> ``.""" + # Strip trailing whitespace first, then collapse blank lines + content = re.sub(r"[ \t]+$", "", content, flags = re.MULTILINE) + content = re.sub(r"\n{3,}", "\n\n", content).strip() + if not content: + return "" + lines = content.split("\n") + prefixed: list[str] = [] + for line in lines: + if line.strip(): + prefixed.append("> " + line) + else: + prefixed.append(">") + return "\n".join(prefixed) + + # ------------------------------------------------------------------ + # Table helpers -- flush open cells and rows so that HTML with + # omitted optional end tags (, ) does not lose data. + # ------------------------------------------------------------------ + def _finish_cell(self) -> None: + if not self._in_cell: + return + self._in_cell = False + cell_text = "".join(self._cell_parts).strip().replace("\n", " ") + cell_text = cell_text.replace("|", "\\|") + self._current_row.append(cell_text) + self._cell_parts = [] + + def _finish_row(self) -> None: + if not self._current_row: + return + line = "| " + " | ".join(self._current_row) + " |" + self._emit(line + "\n") + if not self._header_row_done and (self._row_has_th or self._is_first_row): + sep = "| " + " | ".join("---" for _ in self._current_row) + " |" + self._emit(sep + "\n") + self._header_row_done = True + self._is_first_row = False + self._current_row = [] + self._row_has_th = False + + # ------------------------------------------------------------------ + # Link text helper -- normalize whitespace so block-level content + # inside an does not produce multiline Markdown link labels. + # ------------------------------------------------------------------ + def _finish_link(self) -> None: + text = re.sub(r"\s+", " ", "".join(self._link_text_parts)).strip() + href = self._link_href or "" + self._in_link = False + if href and text: + self._emit(f"[{text}]({href})") + elif text: + self._emit(text) + + # ------------------------------------------------------------------ + # Tag handlers + # ------------------------------------------------------------------ + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + tag = tag.lower() + + if tag in _SKIP_TAGS: + self._skip_depth += 1 + return + if self._skip_depth: + return + + attr_dict = dict(attrs) + + if tag in _HEADING_TAGS: + level = int(tag[1]) + self._emit("\n\n" + "#" * level + " ") + + elif tag == "a": + self._link_href = attr_dict.get("href") + self._link_text_parts = [] + self._in_link = True + + elif tag in _INLINE_EMPHASIS: + self._emit(_INLINE_EMPHASIS[tag]) + + elif tag == "br": + self._emit("\n") + + elif tag in _BLOCK_TAGS: + self._emit("\n\n") + + elif tag == "hr": + self._emit("\n\n---\n\n") + + elif tag == "blockquote": + self._emit("\n\n") + self._bq_stack.append([]) + + elif tag == "ul": + self._list_stack.append("ul") + self._emit("\n") + + elif tag == "ol": + self._list_stack.append("ol") + start_attr = attr_dict.get("start") + try: + start = int(start_attr) if start_attr is not None else 1 + except (ValueError, TypeError): + start = 1 + self._ol_counter.append(start - 1) + self._emit("\n") + + elif tag == "li": + indent = " " * max(0, len(self._list_stack) - 1) + if self._list_stack and self._list_stack[-1] == "ol": + if self._ol_counter: + self._ol_counter[-1] += 1 + self._emit(f"\n{indent}{self._ol_counter[-1]}. ") + else: + self._emit(f"\n{indent}1. ") + else: + self._emit(f"\n{indent}* ") + + elif tag == "pre": + self._pre_parts = [] + self._in_pre = True + + elif tag == "code" and not self._in_pre: + self._in_inline_code = True + self._emit("`") + + elif tag == "table": + self._in_table = True + self._header_row_done = False + self._is_first_row = True + self._emit("\n\n") + + elif tag == "tr": + # Flush any open cell/row from a previous row that may + # have omitted its optional or end tags. + self._finish_cell() + self._finish_row() + + elif tag in ("th", "td"): + # Flush any open cell (handles omitted /
spans
+ if self._in_inline_code:
+ self._emit(data)
+ return
+ # Collapse all whitespace (including newlines) per HTML rules
+ text = re.sub(r"\s+", " ", data)
+ # Suppress whitespace-only text nodes between table structural
+ # elements (indentation from source HTML) to prevent leading
+ # spaces from breaking Markdown table row alignment.
+ if self._in_table and not self._in_cell and not text.strip():
+ return
+ self._emit(text)
+
+ def handle_entityref(self, name: str) -> None:
+ if self._skip_depth:
+ return
+ self._emit(html.unescape(f"&{name};"))
+
+ def handle_charref(self, name: str) -> None:
+ if self._skip_depth:
+ return
+ self._emit(html.unescape(f"{name};"))
+
+ # ------------------------------------------------------------------
+ # Flush pending buffers (handles truncated HTML from capped fetches)
+ # ------------------------------------------------------------------
+ def flush_pending(self) -> None:
+ """Flush any open side-buffers into ``_out``.
+
+ Called after ``close()`` to recover content from truncated HTML
+ where closing tags were never seen (common when ``_fetch_page_text``
+ caps the download by byte count).
+ """
+ # Flush innermost buffers first so their content propagates outward.
+
+ if self._in_link:
+ self._finish_link()
+
+ if self._in_inline_code:
+ self._in_inline_code = False
+ self._emit("`")
+
+ self._finish_cell()
+ self._finish_row()
+
+ if self._in_pre:
+ raw = "".join(self._pre_parts)
+ self._in_pre = False
+ block = "```\n" + raw + "\n```"
+ self._emit("\n\n" + block + "\n\n")
+
+ # Flatten any open blockquote buffers (innermost first)
+ while self._bq_stack:
+ content = "".join(self._bq_stack.pop())
+ prefixed = self._prefix_blockquote(content)
+ if not prefixed:
+ continue
+ if self._bq_stack:
+ self._bq_stack[-1].append("\n\n" + prefixed + "\n\n")
+ else:
+ self._out.append("\n\n" + prefixed + "\n\n")
+
+
+# ------------------------------------------------------------------
+# Post-processing
+# ------------------------------------------------------------------
+def _cleanup(text: str) -> str:
+ """Normalize whitespace and blank lines in the final output.
+
+ Preserves content inside fenced code blocks verbatim so that
+ intentional blank lines in ```` content are not collapsed.
+ """
+ lines = text.split("\n")
+ out: list[str] = []
+ in_fence = False
+ blank_run = 0
+
+ for line in lines:
+ stripped = line.rstrip(" \t")
+ if stripped.startswith("```"):
+ in_fence = not in_fence
+ blank_run = 0
+ out.append(stripped)
+ continue
+
+ if in_fence:
+ # Preserve code block content exactly as-is
+ out.append(line)
+ continue
+
+ if not stripped:
+ blank_run += 1
+ if blank_run <= 1:
+ out.append("")
+ continue
+
+ blank_run = 0
+ out.append(stripped)
+
+ return "\n".join(out).strip()
+
+
+# ------------------------------------------------------------------
+# Public API
+# ------------------------------------------------------------------
+def html_to_markdown(source_html: str) -> str:
+ """Convert an HTML string to Markdown.
+
+ Handles headings, links, bold/italic, lists (ordered and unordered),
+ tables, blockquotes, code blocks, and HTML entities. ``]*>",
- "",
- raw_html,
- flags = _re.DOTALL | _re.IGNORECASE,
- )
- text = _re.sub(
- r"]*>", "", text, flags = _re.DOTALL | _re.IGNORECASE
- )
- text = _re.sub(r"<[^>]+>", " ", text)
- text = _re.sub(r"\s+", " ", text).strip()
+ text = html_to_markdown(raw_html)
if not text:
return "(page returned no readable text)"
diff --git a/studio/backend/main.py b/studio/backend/main.py
index c18f18a743..ad19ee9679 100644
--- a/studio/backend/main.py
+++ b/studio/backend/main.py
@@ -121,13 +121,13 @@ async def lifespan(app: FastAPI):
if storage.ensure_default_admin():
bootstrap_pw = storage.get_bootstrap_password()
app.state.bootstrap_password = bootstrap_pw
+
+ bootstrap_path = storage.DB_PATH.parent / ".bootstrap_password"
print("\n" + "=" * 60)
print("DEFAULT ADMIN ACCOUNT CREATED")
- print(
- "Sign in with the seeded credentials and change the password immediately:\n"
- )
print(f" username: {storage.DEFAULT_ADMIN_USERNAME}")
- print(f" password: {bootstrap_pw}\n")
+ print(f" password saved to: {bootstrap_path}")
+ print(" Open the Studio UI to sign in and change it.")
print("=" * 60 + "\n")
else:
app.state.bootstrap_password = storage.get_bootstrap_password()
diff --git a/studio/backend/models/models.py b/studio/backend/models/models.py
index 046e36137d..f67014a17b 100644
--- a/studio/backend/models/models.py
+++ b/studio/backend/models/models.py
@@ -165,7 +165,7 @@ class LocalModelInfo(BaseModel):
id: str = Field(..., description = "Identifier to use for loading/training")
display_name: str = Field(..., description = "Display label")
path: str = Field(..., description = "Local path where model data was discovered")
- source: Literal["models_dir", "hf_cache", "lmstudio"] = Field(
+ source: Literal["models_dir", "hf_cache", "lmstudio", "custom"] = Field(
...,
description = "Discovery source",
)
@@ -197,3 +197,19 @@ class LocalModelListResponse(BaseModel):
default_factory = list,
description = "Discovered local/cached models",
)
+
+
+class AddScanFolderRequest(BaseModel):
+ """Request body for adding a custom scan folder."""
+
+ path: str = Field(
+ ..., description = "Absolute or relative directory path to scan for models"
+ )
+
+
+class ScanFolderInfo(BaseModel):
+ """A registered custom model scan folder."""
+
+ id: int = Field(..., description = "Database row ID")
+ path: str = Field(..., description = "Normalized absolute path")
+ created_at: str = Field(..., description = "ISO 8601 creation timestamp")
diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py
index 348ffbf6ea..445cf0e7f4 100644
--- a/studio/backend/routes/models.py
+++ b/studio/backend/routes/models.py
@@ -94,7 +94,13 @@ from models import (
LoRAInfo,
ModelListResponse,
)
-from models.models import GgufVariantDetail, GgufVariantsResponse, ModelType
+from models.models import (
+ GgufVariantDetail,
+ GgufVariantsResponse,
+ ModelType,
+ ScanFolderInfo,
+ AddScanFolderRequest,
+)
from models.responses import (
LoRABaseModelResponse,
VisionCheckResponse,
@@ -128,21 +134,32 @@ def _resolve_hf_cache_dir() -> Path:
return Path.home() / ".cache" / "huggingface" / "hub"
-def _scan_models_dir(models_dir: Path) -> List[LocalModelInfo]:
+def _scan_models_dir(
+ models_dir: Path,
+ *,
+ limit: int | None = None,
+) -> List[LocalModelInfo]:
if not models_dir.exists() or not models_dir.is_dir():
return []
found: List[LocalModelInfo] = []
for child in models_dir.iterdir():
- if not child.is_dir():
+ if limit is not None and len(found) >= limit:
+ break
+ try:
+ if not child.is_dir():
+ continue
+ has_model_files = (
+ (child / "config.json").exists()
+ or (child / "adapter_config.json").exists()
+ or any(child.glob("*.safetensors"))
+ or any(child.glob("*.bin"))
+ or any(child.glob("*.gguf"))
+ )
+ except OSError:
+ # Skip individual children that are unreadable (permissions, broken
+ # symlinks, etc.) rather than failing the entire scan.
continue
- has_model_files = (
- (child / "config.json").exists()
- or (child / "adapter_config.json").exists()
- or any(child.glob("*.safetensors"))
- or any(child.glob("*.bin"))
- or any(child.glob("*.gguf"))
- )
if not has_model_files:
continue
try:
@@ -159,21 +176,24 @@ def _scan_models_dir(models_dir: Path) -> List[LocalModelInfo]:
),
)
# Also scan for standalone .gguf files directly in the models directory
- for gguf_file in models_dir.glob("*.gguf"):
- if gguf_file.is_file():
- try:
- updated_at = gguf_file.stat().st_mtime
- except OSError:
- updated_at = None
- found.append(
- LocalModelInfo(
- id = str(gguf_file),
- display_name = gguf_file.stem,
- path = str(gguf_file),
- source = "models_dir",
- updated_at = updated_at,
- ),
- )
+ if limit is None or len(found) < limit:
+ for gguf_file in models_dir.glob("*.gguf"):
+ if limit is not None and len(found) >= limit:
+ break
+ if gguf_file.is_file():
+ try:
+ updated_at = gguf_file.stat().st_mtime
+ except OSError:
+ updated_at = None
+ found.append(
+ LocalModelInfo(
+ id = str(gguf_file),
+ display_name = gguf_file.stem,
+ path = str(gguf_file),
+ source = "models_dir",
+ updated_at = updated_at,
+ ),
+ )
return found
@@ -221,63 +241,69 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
found: List[LocalModelInfo] = []
for child in lm_dir.iterdir():
- if not child.is_dir():
- if child.suffix == ".gguf" and child.is_file():
- try:
- updated_at = child.stat().st_mtime
- except OSError:
- updated_at = None
- found.append(
- LocalModelInfo(
- id = str(child),
- display_name = child.stem,
- path = str(child),
- source = "lmstudio",
- updated_at = updated_at,
- ),
- )
- continue
+ try:
+ if not child.is_dir():
+ if child.suffix == ".gguf" and child.is_file():
+ try:
+ updated_at = child.stat().st_mtime
+ except OSError:
+ updated_at = None
+ found.append(
+ LocalModelInfo(
+ id = str(child),
+ display_name = child.stem,
+ path = str(child),
+ source = "lmstudio",
+ updated_at = updated_at,
+ ),
+ )
+ continue
- # child is a publisher directory — scan its sub-directories
- for model_dir in child.iterdir():
- if model_dir.is_dir():
- has_model = (
- any(model_dir.glob("*.gguf"))
- or (model_dir / "config.json").exists()
- or any(model_dir.glob("*.safetensors"))
- )
- if not has_model:
+ # child is a publisher directory -- scan its sub-directories
+ for model_dir in child.iterdir():
+ try:
+ if model_dir.is_dir():
+ has_model = (
+ any(model_dir.glob("*.gguf"))
+ or (model_dir / "config.json").exists()
+ or any(model_dir.glob("*.safetensors"))
+ )
+ if not has_model:
+ continue
+ model_id = f"{child.name}/{model_dir.name}"
+ try:
+ updated_at = model_dir.stat().st_mtime
+ except OSError:
+ updated_at = None
+ found.append(
+ LocalModelInfo(
+ id = str(model_dir),
+ model_id = model_id,
+ display_name = model_dir.name,
+ path = str(model_dir),
+ source = "lmstudio",
+ updated_at = updated_at,
+ ),
+ )
+ elif model_dir.suffix == ".gguf" and model_dir.is_file():
+ try:
+ updated_at = model_dir.stat().st_mtime
+ except OSError:
+ updated_at = None
+ found.append(
+ LocalModelInfo(
+ id = str(model_dir),
+ model_id = f"{child.name}/{model_dir.stem}",
+ display_name = model_dir.stem,
+ path = str(model_dir),
+ source = "lmstudio",
+ updated_at = updated_at,
+ ),
+ )
+ except OSError:
continue
- model_id = f"{child.name}/{model_dir.name}"
- try:
- updated_at = model_dir.stat().st_mtime
- except OSError:
- updated_at = None
- found.append(
- LocalModelInfo(
- id = str(model_dir),
- model_id = model_id,
- display_name = model_dir.name,
- path = str(model_dir),
- source = "lmstudio",
- updated_at = updated_at,
- ),
- )
- elif model_dir.suffix == ".gguf" and model_dir.is_file():
- try:
- updated_at = model_dir.stat().st_mtime
- except OSError:
- updated_at = None
- found.append(
- LocalModelInfo(
- id = str(model_dir),
- model_id = f"{child.name}/{model_dir.stem}",
- display_name = model_dir.stem,
- path = str(model_dir),
- source = "lmstudio",
- updated_at = updated_at,
- ),
- )
+ except OSError:
+ continue
return found
@@ -351,10 +377,39 @@ async def list_local_models(
for lm_dir in lm_dirs:
local_models += _scan_lmstudio_dir(lm_dir)
+ # Scan user-added custom folders (cap per-folder to avoid unbounded scans)
+ from storage.studio_db import list_scan_folders
+
+ _MAX_MODELS_PER_FOLDER = 200
+ try:
+ custom_folders = list_scan_folders()
+ except Exception as e:
+ logger.warning("Could not load custom scan folders: %s", e)
+ custom_folders = []
+ for folder in custom_folders:
+ folder_path = Path(folder["path"])
+ try:
+ custom_models = (
+ _scan_models_dir(folder_path, limit = _MAX_MODELS_PER_FOLDER)
+ + _scan_hf_cache(folder_path)
+ + _scan_lmstudio_dir(folder_path)
+ )[:_MAX_MODELS_PER_FOLDER]
+ except OSError as e:
+ logger.warning("Skipping unreadable scan folder %s: %s", folder_path, e)
+ continue
+ local_models += [
+ m.model_copy(update = {"source": "custom"}) for m in custom_models
+ ]
+
+ # Deduplicate models, but always keep custom folder entries so they
+ # appear in the "Custom Folders" UI section even when the same model
+ # also exists in the HF cache or default models directory. Use a
+ # (id, source) key for custom entries to avoid collisions.
deduped: dict[str, LocalModelInfo] = {}
for model in local_models:
- if model.id not in deduped:
- deduped[model.id] = model
+ key = f"{model.id}\x00custom" if model.source == "custom" else model.id
+ if key not in deduped:
+ deduped[key] = model
models = sorted(
deduped.values(),
@@ -376,6 +431,46 @@ async def list_local_models(
)
+@router.get("/scan-folders")
+async def get_scan_folders(
+ current_subject: str = Depends(get_current_subject),
+):
+ """List all registered custom model scan folders."""
+ from storage.studio_db import list_scan_folders
+
+ return {"folders": list_scan_folders()}
+
+
+@router.post("/scan-folders", response_model = ScanFolderInfo, status_code = 201)
+async def add_scan_folder_endpoint(
+ body: AddScanFolderRequest,
+ current_subject: str = Depends(get_current_subject),
+):
+ """Register a new directory to scan for local models."""
+ from storage.studio_db import add_scan_folder
+
+ try:
+ folder = add_scan_folder(body.path)
+ except ValueError as e:
+ logger.warning("Scan folder rejected: %s (path=%s)", e, body.path)
+ raise HTTPException(status_code = 400, detail = str(e))
+ logger.info("Scan folder added: %s", folder.get("path"))
+ return folder
+
+
+@router.delete("/scan-folders/{folder_id}")
+async def remove_scan_folder_endpoint(
+ folder_id: int,
+ current_subject: str = Depends(get_current_subject),
+):
+ """Remove a registered custom scan folder."""
+ from storage.studio_db import remove_scan_folder
+
+ remove_scan_folder(folder_id)
+ logger.info("Scan folder removed: id=%s", folder_id)
+ return {"ok": True}
+
+
@router.get("/list")
async def list_models(
current_subject: str = Depends(get_current_subject),
diff --git a/studio/backend/storage/studio_db.py b/studio/backend/storage/studio_db.py
index 4af19df42b..89f75632ef 100644
--- a/studio/backend/storage/studio_db.py
+++ b/studio/backend/storage/studio_db.py
@@ -12,14 +12,46 @@ raw sqlite3, per-function connections. Enhancements over auth:
import json
import logging
+import os
+import platform
import sqlite3
import threading
+from datetime import datetime, timezone
logger = logging.getLogger(__name__)
from typing import Optional
+
from utils.paths import studio_db_path, ensure_dir
+
+def _denied_path_prefixes() -> list[str]:
+ """Platform-aware denylist of system directories."""
+ system = platform.system()
+ if system == "Linux":
+ return ["/proc", "/sys", "/dev", "/etc", "/boot", "/run"]
+ if system == "Darwin":
+ # realpath() resolves /etc -> /private/etc, /tmp -> /private/tmp on macOS,
+ # so include the /private variants to avoid bypasses.
+ return [
+ "/System",
+ "/Library",
+ "/dev",
+ "/etc",
+ "/private/etc",
+ "/tmp",
+ "/private/tmp",
+ "/var",
+ "/private/var",
+ ]
+ if system == "Windows":
+ win = os.environ.get("SystemRoot", r"C:\Windows")
+ pf = os.environ.get("ProgramFiles", r"C:\Program Files")
+ pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
+ return [os.path.normcase(p) for p in [win, pf, pf86]]
+ return []
+
+
_schema_lock = threading.Lock()
_schema_ready = False
@@ -67,6 +99,19 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_metrics_run_id ON training_metrics(run_id)"
)
+ # Use COLLATE NOCASE on Windows so C:\Models and c:\models dedup via the
+ # UNIQUE constraint. On Linux/macOS (case-sensitive FS) keep the default
+ # BINARY collation so /Models and /models remain distinct.
+ collation = "COLLATE NOCASE" if platform.system() == "Windows" else ""
+ conn.execute(
+ f"""
+ CREATE TABLE IF NOT EXISTS scan_folders (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ path TEXT NOT NULL UNIQUE {collation},
+ created_at TEXT NOT NULL
+ )
+ """
+ )
def get_connection() -> sqlite3.Connection:
@@ -343,8 +388,6 @@ def delete_run(id: str) -> None:
def cleanup_orphaned_runs() -> None:
"""Mark any 'running' rows as errored on startup (server restarted mid-training)."""
- from datetime import datetime, timezone
-
conn = get_connection()
try:
conn.execute(
@@ -360,3 +403,86 @@ def cleanup_orphaned_runs() -> None:
conn.commit()
finally:
conn.close()
+
+
+def list_scan_folders() -> list[dict]:
+ conn = get_connection()
+ try:
+ rows = conn.execute(
+ "SELECT id, path, created_at FROM scan_folders ORDER BY created_at"
+ ).fetchall()
+ return [dict(row) for row in rows]
+ finally:
+ conn.close()
+
+
+def add_scan_folder(path: str) -> dict:
+ """Add a directory to the custom scan folder list. Returns the row."""
+ if not path or not path.strip():
+ raise ValueError("Path cannot be empty")
+ normalized = os.path.realpath(os.path.expanduser(path.strip()))
+
+ # Validate the path is an existing, readable directory before persisting.
+ if not os.path.exists(normalized):
+ raise ValueError("Path does not exist")
+ if not os.path.isdir(normalized):
+ raise ValueError("Path must be a directory, not a file")
+ if not os.access(normalized, os.R_OK | os.X_OK):
+ raise ValueError("Path is not readable")
+
+ # On Windows, use normcase for denylist comparison but store the
+ # original-cased path so downstream consumers see the native
+ # drive-letter casing the user expects (e.g. C:\Models, not c:\models).
+ is_win = platform.system() == "Windows"
+ check = os.path.normcase(normalized) if is_win else normalized
+ for prefix in _denied_path_prefixes():
+ if check == prefix or check.startswith(prefix + os.sep):
+ raise ValueError(f"Path under {prefix} is not allowed")
+
+ conn = get_connection()
+ try:
+ now = datetime.now(timezone.utc).isoformat()
+ # On Windows, use case-insensitive lookup so C:\Models and c:\models
+ # dedup correctly while preserving the originally-stored casing.
+ if is_win:
+ existing = conn.execute(
+ "SELECT id, path, created_at FROM scan_folders WHERE path = ? COLLATE NOCASE",
+ (normalized,),
+ ).fetchone()
+ else:
+ existing = conn.execute(
+ "SELECT id, path, created_at FROM scan_folders WHERE path = ?",
+ (normalized,),
+ ).fetchone()
+ if existing is not None:
+ return dict(existing)
+ try:
+ conn.execute(
+ "INSERT INTO scan_folders (path, created_at) VALUES (?, ?)",
+ (normalized, now),
+ )
+ conn.commit()
+ except sqlite3.IntegrityError:
+ pass # duplicate -- fall through to SELECT
+ # Use the same collation as the pre-check so we find the row even
+ # when a concurrent writer stored it with different casing (Windows).
+ fallback_sql = (
+ "SELECT id, path, created_at FROM scan_folders WHERE path = ? COLLATE NOCASE"
+ if is_win
+ else "SELECT id, path, created_at FROM scan_folders WHERE path = ?"
+ )
+ row = conn.execute(fallback_sql, (normalized,)).fetchone()
+ if row is None:
+ raise ValueError("Folder was concurrently removed")
+ return dict(row)
+ finally:
+ conn.close()
+
+
+def remove_scan_folder(id: int) -> None:
+ conn = get_connection()
+ try:
+ conn.execute("DELETE FROM scan_folders WHERE id = ?", (id,))
+ conn.commit()
+ finally:
+ conn.close()
diff --git a/studio/backend/tests/tool_calling_benchmark_results.md b/studio/backend/tests/tool_calling_benchmark_results.md
deleted file mode 100644
index c2b0687895..0000000000
--- a/studio/backend/tests/tool_calling_benchmark_results.md
+++ /dev/null
@@ -1,62 +0,0 @@
-# GGUF Tool Calling Benchmark Results
-
-Prompt: "List and categorize all the songs that charted #3 on the Billboard Hot 100 in 2015."
-10 runs per configuration, web search + code execution + thinking enabled.
-GPU: NVIDIA B200, CUDA_VISIBLE_DEVICES=2.
-
-Ground truth: 4 songs peaked at #3 in 2015 -- "Love Me like You Do" (Ellie Goulding), "Earned It" (The Weeknd), "Watch Me" (Silento), "Drag Me Down" (One Direction).
-
-## Cartesian Grid: Model x Quant x KV Cache
-
-| Model | Quant | KV Cache | OK/10 | Avg Time | Avg Tools | XML Leaks | URL Fetch | Peak3 Avg | All 4/4 | Best Songs |
-|-------|-------|----------|-------|----------|-----------|-----------|-----------|-----------|---------|------------|
-| 4B | UD-Q4_K_XL | f16 | 10/10 | 9.8s | 3.5 | 0/10 | 4/10 | 0.8/4 | 2/10 | 9 |
-| 4B | UD-Q4_K_XL | bf16 | 10/10 | 10.6s | 4.5 | 0/10 | 4/10 | 0.4/4 | 1/10 | 5 |
-| 4B | Q8_0 | f16 | 10/10 | 4.9s | 2.4 | 0/10 | 8/10 | 0.4/4 | 1/10 | 5 |
-| 4B | Q8_0 | bf16 | 10/10 | 8.0s | 3.0 | 0/10 | 5/10 | 0.0/4 | 0/10 | 0 |
-| 9B | UD-Q4_K_XL | f16 | 10/10 | 6.7s | 2.0 | 0/10 | 5/10 | 0.0/4 | 0/10 | 3 |
-| 9B | UD-Q4_K_XL | bf16 | 9/10 | 49.5s | 2.4 | 0/10 | 5/10 | 0.0/4 | 0/10 | 1 |
-| 9B | Q8_0 | f16 | 10/10 | 7.4s | 2.5 | 0/10 | 5/10 | 0.0/4 | 0/10 | 2 |
-| 9B | Q8_0 | bf16 | 10/10 | 10.4s | 2.7 | 0/10 | 6/10 | 1.0/4 | 2/10 | 15 |
-| **27B** | **UD-Q4_K_XL** | **bf16** | **9/10** | **131.1s** | **13.8** | **0/10** | **7/10** | **2.7/4** | **6/10** | **27** |
-| 27B | UD-Q4_K_XL | f16 | 7/10 | 201.6s | 14.1 | 0/10 | 8/10 | 2.0/4 | 5/10 | 26 |
-| 27B | Q8_0 | f16 | 4/10 | 312.5s | 16.0 | 1/10 | 10/10 | 2.4/4 | 6/10 | 28 |
-| 27B | Q8_0 | bf16 | 5/10 | 258.4s | 16.5 | 2/10 | 10/10 | 0.9/4 | 1/10 | 27 |
-| 35B-A3B | UD-Q4_K_XL | f16 | 3/10 | 353.6s | 14.7 | 1/10 | 6/10 | 1.2/4 | 3/10 | 27 |
-| 35B-A3B | UD-Q4_K_XL | bf16 | 3/10 | 356.2s | 17.2 | 1/10 | 8/10 | 1.6/4 | 4/10 | 27 |
-| 35B-A3B | Q8_0 | f16 | 2/10 | 372.1s | 17.6 | 1/10 | 7/10 | 1.2/4 | 3/10 | 26 |
-| 35B-A3B | Q8_0 | bf16 | 6/10 | 267.7s | 17.5 | 1/10 | 8/10 | 2.4/4 | 6/10 | 27 |
-
-**Column definitions:**
-- **Peak3 Avg**: Average number of correct peak-#3 songs found per run (out of 4)
-- **All 4/4**: Runs where all 4 correct songs were identified
-- **Best Songs**: Maximum number of Billboard 2015 songs mentioned in any single run (out of 31 tracked)
-- **URL Fetch**: Runs where the model used web_search with `url` parameter to fetch full page content
-
-## Key Findings
-
-1. **27B UD-Q4_K_XL + bf16 KV is the sweet spot.** 6/10 runs found all 4 correct songs, 0 XML leaks, 131s average. Best balance of accuracy, speed, and reliability.
-
-2. **Larger models use tools more effectively.** 27B and 35B-A3B models used 13-17 tool calls per query (vs 2-4 for 4B/9B), performing multiple searches and URL fetches to find the answer.
-
-3. **27B Q8_0 had the highest raw accuracy (6/10 all-4/4) but lower reliability** -- only 4/10 OK runs due to timeouts on long agentic chains. The UD-Q4_K_XL quant is more practical.
-
-4. **4B models were fastest (5-10s) but least accurate.** They occasionally found all 4 songs (2/10 best case) when they happened to fetch the right Wikipedia page.
-
-5. **9B was surprisingly weaker than 4B on this task.** It used fewer tool calls and rarely extracted song data from fetched pages. The 9B model may need higher temperature or different prompting for this specific task type.
-
-6. **35B-A3B had reliability issues.** Most runs timed out or errored due to slow per-token generation with many tool iterations. When it completed (2-6/10 OK), accuracy was comparable to 27B.
-
-7. **bf16 KV cache had mixed effects.** For 27B it improved both speed (131s vs 202s) and accuracy (6/10 vs 5/10 all-4/4). For smaller models it had no consistent benefit.
-
-8. **XML leaks are nearly eliminated.** 0/10 for all 4B and 9B configs, and only 1-2/10 for the largest models (which generate much more text in complex agentic loops).
-
-## Before vs After (4B UD-Q4_K_XL, f16 KV)
-
-| Metric | Before Changes | After Changes |
-|--------|---------------|---------------|
-| XML leaks | 10/10 | 0/10 |
-| URL fetches | 0/10 | 4/10 |
-| Peak3 accuracy | 0.0/4 | 0.8/4 |
-| Runs with all 4 songs | 0/10 | 2/10 |
-| Avg time | 12.3s | 9.8s |
diff --git a/studio/backend/utils/inference/inference_config.py b/studio/backend/utils/inference/inference_config.py
index a0377f9869..9efc281b0b 100644
--- a/studio/backend/utils/inference/inference_config.py
+++ b/studio/backend/utils/inference/inference_config.py
@@ -17,6 +17,7 @@ import structlog
from loggers import get_logger
from utils.models.model_config import load_model_defaults
+from utils.paths import is_local_path, normalize_path
logger = get_logger(__name__)
@@ -93,8 +94,28 @@ def _has_specific_yaml(model_identifier: str) -> bool:
if model_identifier.lower() in _REVERSE_MODEL_MAPPING:
return True
- # Check for exact filename match
- model_filename = model_identifier.replace("/", "_") + ".yaml"
+ # For local filesystem paths (e.g. C:\Users\...\model on Windows),
+ # normalize backslashes so Path().parts splits correctly on POSIX/WSL,
+ # then try matching the last 1-2 path components against the registry
+ # (mirrors the logic in load_model_defaults).
+ _is_local = is_local_path(model_identifier)
+ _normalized = normalize_path(model_identifier) if _is_local else model_identifier
+
+ if _is_local:
+ parts = Path(_normalized).parts
+ for depth in (2, 1):
+ if len(parts) >= depth:
+ suffix = "/".join(parts[-depth:])
+ if suffix.lower() in _REVERSE_MODEL_MAPPING:
+ return True
+ _lookup = Path(_normalized).name
+ else:
+ _lookup = model_identifier
+
+ # Check for exact filename match (basename for local paths to avoid
+ # passing absolute paths into rglob which raises
+ # "Non-relative patterns are unsupported" on Windows).
+ model_filename = _lookup.replace("/", "_") + ".yaml"
for config_path in defaults_dir.rglob(model_filename):
if config_path.is_file():
return True
diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py
index 5de3fd2cf9..f7d9b33542 100644
--- a/studio/backend/utils/models/model_config.py
+++ b/studio/backend/utils/models/model_config.py
@@ -1420,17 +1420,20 @@ def load_model_defaults(model_name: str) -> Dict[str, Any]:
return config
# If model_name is a local path (e.g. /home/.../Spark-TTS-0.5B/LLM from
- # adapter_config.json), try matching the last 1-2 path components against
- # the registry (e.g. "Spark-TTS-0.5B/LLM").
- if model_name not in _REVERSE_MODEL_MAPPING and (
- model_name.startswith("/") or model_name.startswith(".")
- ):
- parts = Path(model_name).parts
+ # adapter_config.json, or C:\Users\...\model on Windows), try matching
+ # the last 1-2 path components against the registry
+ # (e.g. "Spark-TTS-0.5B/LLM").
+ _is_local_path = is_local_path(model_name)
+ # Normalize Windows backslash paths so Path().parts splits correctly
+ # on POSIX/WSL hosts (pathlib treats backslashes as literals on Linux).
+ _normalized = normalize_path(model_name) if _is_local_path else model_name
+ if model_name.lower() not in _REVERSE_MODEL_MAPPING and _is_local_path:
+ parts = Path(_normalized).parts
for depth in [2, 1]:
if len(parts) >= depth:
suffix = "/".join(parts[-depth:])
- if suffix in _REVERSE_MODEL_MAPPING:
- canonical_file = _REVERSE_MODEL_MAPPING[suffix]
+ if suffix.lower() in _REVERSE_MODEL_MAPPING:
+ canonical_file = _REVERSE_MODEL_MAPPING[suffix.lower()]
for config_path in defaults_dir.rglob(canonical_file):
if config_path.is_file():
with open(config_path, "r", encoding = "utf-8") as f:
@@ -1440,8 +1443,12 @@ def load_model_defaults(model_name: str) -> Dict[str, Any]:
)
return config
- # Try exact model name match (for backward compatibility)
- model_filename = model_name.replace("/", "_") + ".yaml"
+ # Try exact model name match (for backward compatibility).
+ # For local filesystem paths, use only the directory basename to
+ # avoid passing absolute paths (e.g. C:\...) into rglob which
+ # raises "Non-relative patterns are unsupported" on Windows.
+ _lookup_name = Path(_normalized).name if _is_local_path else model_name
+ model_filename = _lookup_name.replace("/", "_") + ".yaml"
# Search in subfolders and root
for config_path in defaults_dir.rglob(model_filename):
if config_path.is_file():
diff --git a/studio/frontend/src/components/assistant-ui/model-selector.tsx b/studio/frontend/src/components/assistant-ui/model-selector.tsx
index 0332b3ca8a..441c2b48e4 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector.tsx
+++ b/studio/frontend/src/components/assistant-ui/model-selector.tsx
@@ -34,6 +34,7 @@ interface ModelSelectorProps {
activeGgufVariant?: string | null;
onValueChange?: (value: string, meta: ModelSelectorChangeMeta) => void;
onEject?: () => void;
+ onFoldersChange?: () => void;
variant?: "outline" | "ghost" | "muted";
size?: "sm" | "default" | "lg";
className?: string;
@@ -100,6 +101,7 @@ function ModelSelectorContent({
value,
onSelect,
onEject,
+ onFoldersChange,
className,
dataTour,
}: {
@@ -108,6 +110,7 @@ function ModelSelectorContent({
value?: string;
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
onEject?: () => void;
+ onFoldersChange?: () => void;
className?: string;
dataTour?: string;
}) {
@@ -124,7 +127,7 @@ function ModelSelectorContent({
)}
>
{chatOnly ? (
-
+
) : (
@@ -133,7 +136,7 @@ function ModelSelectorContent({
-
+
@@ -171,6 +174,7 @@ export function ModelSelector({
activeGgufVariant,
onValueChange,
onEject,
+ onFoldersChange,
variant = "outline",
size = "default",
className,
@@ -253,6 +257,7 @@ export function ModelSelector({
value={selected}
onSelect={handleSelect}
onEject={onEject ? handleEject : undefined}
+ onFoldersChange={onFoldersChange}
className={contentClassName}
dataTour={contentDataTour}
/>
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx
index b64f850f10..74ca2542d4 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx
+++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx
@@ -18,10 +18,24 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { deleteCachedModel, listCachedGguf, listCachedModels, listGgufVariants, listLocalModels } from "@/features/chat/api/chat-api";
-import type { CachedGgufRepo, CachedModelRepo, LocalModelInfo } from "@/features/chat/api/chat-api";
-import type { GgufVariantDetail } from "@/features/chat/types/api";
import { usePlatformStore } from "@/config/env";
+import {
+ type ScanFolderInfo,
+ addScanFolder,
+ deleteCachedModel,
+ listCachedGguf,
+ listCachedModels,
+ listGgufVariants,
+ listLocalModels,
+ listScanFolders,
+ removeScanFolder,
+} from "@/features/chat/api/chat-api";
+import type {
+ CachedGgufRepo,
+ CachedModelRepo,
+ LocalModelInfo,
+} from "@/features/chat/api/chat-api";
+import type { GgufVariantDetail } from "@/features/chat/types/api";
import {
useDebouncedValue,
useGpuInfo,
@@ -32,10 +46,16 @@ import {
import { cn, formatCompact } from "@/lib/utils";
import type { VramFitStatus } from "@/lib/vram";
import { checkVramFit, estimateLoadingVram } from "@/lib/vram";
-import { Search01Icon } from "@hugeicons/core-free-icons";
+import { Add01Icon, Cancel01Icon, Folder02Icon, Search01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Trash2Icon } from "lucide-react";
-import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import {
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
import { toast } from "sonner";
import type {
LoraModelOption,
@@ -107,23 +127,22 @@ function ModelRow({
className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent",
selected && "bg-accent/60",
- exceeds && "opacity-50",
)}
>
{label}
{vramStatus === "exceeds" && (
- OOM
+ OOM
)}
{vramStatus === "tight" && (
- TIGHT
+ TIGHT
)}
{meta ? (
{meta}
@@ -135,7 +154,7 @@ function ModelRow({
if (vramTooltipText) {
return (
- {content}
+ {content}
{label}
{vramTooltipText}
@@ -147,7 +166,7 @@ function ModelRow({
if (tooltipText) {
return (
- {content}
+ {content}
{tooltipText}
@@ -192,7 +211,9 @@ function GgufVariantExpander({
})
.catch((err) => {
if (canceled) return;
- setError(err instanceof Error ? err.message : "Failed to load variants");
+ setError(
+ err instanceof Error ? err.message : "Failed to load variants",
+ );
})
.finally(() => {
if (!canceled) setLoading(false);
@@ -204,7 +225,9 @@ function GgufVariantExpander({
}, [repoId]);
// Covers Unix absolute (/), Windows drive (C:\, D:/), UNC (\\server), relative (./, ../), tilde (~/)
- const isLocalPath = /^(\/|\.{1,2}[\\\/]|~[\\\/]|[A-Za-z]:[\\\/]|\\\\)/.test(repoId);
+ const isLocalPath = /^(\/|\.{1,2}[\\\/]|~[\\\/]|[A-Za-z]:[\\\/]|\\\\)/.test(
+ repoId,
+ );
const handleVariantClick = useCallback(
(quant: string, downloaded?: boolean, sizeBytes?: number) => {
@@ -223,13 +246,13 @@ function GgufVariantExpander({
// fits = model <= 0.7 * total GPU memory
// tight = model > 0.7 * GPU but <= 0.7 * GPU + 0.7 * system RAM (--fit uses CPU offload)
// oom = model > 0.7 * GPU + 0.7 * system RAM
- const gpuBudgetGb = (gpuGb ?? 0) * 0.70;
- const totalBudgetGb = gpuBudgetGb + (systemRamGb ?? 0) * 0.70;
+ const gpuBudgetGb = (gpuGb ?? 0) * 0.7;
+ const totalBudgetGb = gpuBudgetGb + (systemRamGb ?? 0) * 0.7;
const getGgufFit = useCallback(
(sizeBytes: number): "fits" | "tight" | "oom" => {
if (!gpuGb || gpuGb <= 0) return "fits";
- const gb = sizeBytes / (1024 ** 3);
+ const gb = sizeBytes / 1024 ** 3;
if (gb <= 0 || gb <= gpuBudgetGb) return "fits";
if (gb <= totalBudgetGb) return "tight";
return "oom";
@@ -242,7 +265,8 @@ function GgufVariantExpander({
const effectiveRecommended = useMemo(() => {
if (!variants || !gpuGb || gpuGb <= 0) return defaultVariant;
const defaultV = variants.find((v) => v.quant === defaultVariant);
- if (defaultV && getGgufFit(defaultV.size_bytes) !== "oom") return defaultVariant;
+ if (defaultV && getGgufFit(defaultV.size_bytes) !== "oom")
+ return defaultVariant;
// Default is OOM -- pick largest non-OOM variant (best quality that fits)
const fitting = variants.filter((v) => getGgufFit(v.size_bytes) !== "oom");
if (fitting.length > 0) {
@@ -276,7 +300,9 @@ function GgufVariantExpander({
// fits: largest first (best quality that fits in GPU)
// tight/OOM: smallest first (closest to fitting, fastest to run)
const fitsInGpu = aTier === 0 || aTier === 2;
- return fitsInGpu ? b.size_bytes - a.size_bytes : a.size_bytes - b.size_bytes;
+ return fitsInGpu
+ ? b.size_bytes - a.size_bytes
+ : a.size_bytes - b.size_bytes;
});
}, [variants, effectiveRecommended, getGgufFit]);
@@ -290,9 +316,7 @@ function GgufVariantExpander({
}
if (error) {
- return (
- {error}
- );
+ return {error};
}
if (!sortedVariants || sortedVariants.length === 0) {
@@ -321,13 +345,15 @@ function GgufVariantExpander({
))}
- {!chatOnly && cachedModels.map((c) => (
-
-
- onSelect(c.repo_id, { source: "hub", isLora: false, isDownloaded: true })}
- vramStatus={null}
- />
+ {!chatOnly &&
+ cachedModels.map((c) => (
+
+
+
+ onSelect(c.repo_id, {
+ source: "hub",
+ isLora: false,
+ isDownloaded: true,
+ })
+ }
+ vramStatus={null}
+ />
+
+ {
+ e.stopPropagation();
+ setDeleteTarget(c.repo_id);
+ }}
+ className="shrink-0 rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-destructive/10 hover:text-destructive"
+ >
+
+
- { e.stopPropagation(); setDeleteTarget(c.repo_id); }}
- className="shrink-0 rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-destructive/10 hover:text-destructive"
- >
-
-
-
- ))}
+ ))}
>
) : null}
@@ -733,13 +908,21 @@ export function HubModelPicker({
{
if (isGguf) {
- setExpandedGguf((prev) => (prev === m.id ? null : m.id));
+ setExpandedGguf((prev) =>
+ prev === m.id ? null : m.id,
+ );
} else {
- onSelect(m.id, { source: "local", isLora: false, isDownloaded: true });
+ onSelect(m.id, {
+ source: "local",
+ isLora: false,
+ isDownloaded: true,
+ });
}
}}
vramStatus={null}
@@ -749,7 +932,140 @@ export function HubModelPicker({
repoId={m.id}
onSelect={onSelect}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
- systemRamGb={gpu.available ? gpu.systemRamAvailableGb : undefined}
+ systemRamGb={
+ gpu.available ? gpu.systemRamAvailableGb : undefined
+ }
+ />
+ )}
+
+ );
+ })}
+ >
+ ) : null}
+
+ {!showHfSection ? (
+ <>
+
+
+ Custom Folders
+
+ {
+ setShowFolderInput((open) => {
+ if (open) { setFolderInput(""); setFolderError(null); }
+ return !open;
+ });
+ }}
+ className="rounded p-0.5 text-muted-foreground/60 transition-colors hover:text-foreground"
+ >
+
+
+
+
+ {/* Folder paths */}
+ {scanFolders.map((f) => (
+
+
+
+ {f.path}
+
+ handleRemoveFolder(f.id)}
+ aria-label={`Remove folder ${f.path}`}
+ className="shrink-0 rounded p-0.5 text-muted-foreground/40 opacity-100 md:opacity-0 md:group-hover:opacity-100 focus-visible:opacity-100 transition-opacity hover:text-destructive"
+ >
+
+
+
+ ))}
+
+ {/* Add folder input */}
+ {showFolderInput && (
+
+
+
+ { setFolderInput(e.target.value); setFolderError(null); }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") { e.preventDefault(); handleAddFolder(); }
+ if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setShowFolderInput(false); setFolderInput(""); setFolderError(null); }
+ }}
+ placeholder="/path/to/models"
+ className="h-6 min-w-0 flex-1 rounded border border-border/50 bg-transparent px-1.5 font-mono text-[10px] text-foreground outline-none placeholder:text-muted-foreground/40 focus:border-foreground/20"
+ disabled={folderLoading}
+ autoFocus={true}
+ />
+
+ Add
+
+
+ {folderError && (
+ {folderError}
+ )}
+
+ )}
+
+ {/* Empty state */}
+ {scanFolders.length === 0 && customFolderModels.length === 0 && !showFolderInput && (
+ setShowFolderInput(true)}
+ className="px-2.5 pb-1.5 text-left text-[10px] text-muted-foreground/60 transition-colors hover:text-muted-foreground"
+ >
+ + Add a folder to scan for local models
+
+ )}
+
+ {/* Models from custom folders */}
+ {customFolderModels.map((m) => {
+ const isGguf =
+ isGgufRepo(m.id) ||
+ isGgufRepo(m.display_name) ||
+ m.path.endsWith(".gguf");
+ return (
+
+ {
+ if (isGguf) {
+ setExpandedGguf((prev) =>
+ prev === m.id ? null : m.id,
+ );
+ } else {
+ onSelect(m.id, {
+ source: "local",
+ isLora: false,
+ isDownloaded: true,
+ });
+ }
+ }}
+ vramStatus={null}
+ />
+ {expandedGguf === m.id && (
+
)}
@@ -775,16 +1091,25 @@ export function HubModelPicker({
meta={
isGgufRepo(id)
? "GGUF"
- : vram?.detail ?? extractParamLabel(id)
+ : (vram?.detail ?? extractParamLabel(id))
}
selected={value === id}
onClick={() => handleModelClick(id)}
- vramStatus={isGgufRepo(id) ? null : vram?.status ?? null}
+ vramStatus={
+ isGgufRepo(id) ? null : (vram?.status ?? null)
+ }
vramEst={isGgufRepo(id) ? undefined : vram?.est}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
/>
{expandedGguf === id && (
-
+
)}
);
@@ -813,16 +1138,25 @@ export function HubModelPicker({
meta={
isGgufRepo(id)
? "GGUF"
- : vram?.detail ?? extractParamLabel(id)
+ : (vram?.detail ?? extractParamLabel(id))
}
selected={value === id}
onClick={() => handleModelClick(id)}
- vramStatus={isGgufRepo(id) ? null : vram?.status ?? null}
+ vramStatus={
+ isGgufRepo(id) ? null : (vram?.status ?? null)
+ }
vramEst={isGgufRepo(id) ? undefined : vram?.est}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
/>
{expandedGguf === id && (
-
+
)}
);
@@ -832,7 +1166,9 @@ export function HubModelPicker({
{showHfSection ? (
<>
- {(hfIds.length > 0 || isLoading) && Hugging Face }
+ {(hfIds.length > 0 || isLoading) && (
+ Hugging Face
+ )}
{hfIds.length === 0 && !isLoading ? (
filteredRecommendedIds.length === 0 ? (
@@ -849,16 +1185,25 @@ export function HubModelPicker({
meta={
isGgufRepo(id)
? "GGUF"
- : metricsById.get(id) ?? extractParamLabel(id)
+ : (metricsById.get(id) ?? extractParamLabel(id))
}
selected={value === id}
onClick={() => handleModelClick(id)}
- vramStatus={isGgufRepo(id) ? null : vram?.status ?? null}
+ vramStatus={
+ isGgufRepo(id) ? null : (vram?.status ?? null)
+ }
vramEst={isGgufRepo(id) ? undefined : vram?.est}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
/>
{expandedGguf === id && (
-
+
)}
);
@@ -875,12 +1220,23 @@ export function HubModelPicker({
- { if (!open && !deleting) setDeleteTarget(null); }}>
+ {
+ if (!open && !deleting) setDeleteTarget(null);
+ }}
+ >
Delete cached model?
- This will remove {deleteTarget?.includes("::") ? `${deleteTarget.split("::")[0]} (${deleteTarget.split("::")[1]})` : deleteTarget} from disk. You can re-download it later.
+ This will remove{" "}
+
+ {deleteTarget?.includes("::")
+ ? `${deleteTarget.split("::")[0]} (${deleteTarget.split("::")[1]})`
+ : deleteTarget}
+ {" "}
+ from disk. You can re-download it later.
@@ -888,7 +1244,10 @@ export function HubModelPicker({
{ e.preventDefault(); handleDeleteConfirm(); }}
+ onClick={(e) => {
+ e.preventDefault();
+ handleDeleteConfirm();
+ }}
>
{deleting ? "Deleting..." : "Yes"}
@@ -917,7 +1276,8 @@ export function LoraModelPicker({
loraModels
.map((model) => ({
...model,
- baseModel: model.baseModel || model.description || "Unknown base model",
+ baseModel:
+ model.baseModel || model.description || "Unknown base model",
}))
.sort((a, b) => {
const baseCmp = a.baseModel.localeCompare(b.baseModel);
@@ -941,7 +1301,9 @@ export function LoraModelPicker({
const out = new Map();
for (const model of normalized) {
- const searchText = normalizeForSearch(`${model.name} ${model.baseModel} ${model.id}`);
+ const searchText = normalizeForSearch(
+ `${model.name} ${model.baseModel} ${model.id}`,
+ );
if (needle && !searchText.includes(needle)) continue;
const key = model.baseModel || "Unknown base model";
@@ -989,15 +1351,27 @@ export function LoraModelPicker({
const isExported = adapter.source === "exported";
const isMerged = adapter.exportType === "merged";
const isGguf = adapter.exportType === "gguf";
- const isLocalGgufDir = isLocal && (isGgufRepo(adapter.id) || isGgufRepo(adapter.name));
+ const isLocalGgufDir =
+ isLocal &&
+ (isGgufRepo(adapter.id) || isGgufRepo(adapter.name));
const tag = isLocal
- ? isLocalGgufDir ? "GGUF" : "Local"
+ ? isLocalGgufDir
+ ? "GGUF"
+ : "Local"
: isGguf
? "GGUF"
: isExported
- ? isMerged ? "Merged" : "LoRA"
+ ? isMerged
+ ? "Merged"
+ : "LoRA"
: "LoRA";
- const meta = isLocal ? (isLocalGgufDir ? "GGUF" : "Local") : isExported ? `${tag} · Exported` : tag;
+ const meta = isLocal
+ ? isLocalGgufDir
+ ? "GGUF"
+ : "Local"
+ : isExported
+ ? `${tag} · Exported`
+ : tag;
return (
{
if (isLocalGgufDir) {
- setExpandedGguf((prev) => (prev === adapter.id ? null : adapter.id));
+ setExpandedGguf((prev) =>
+ prev === adapter.id ? null : adapter.id,
+ );
} else {
onSelect(adapter.id, {
- source: isLocal ? "local" : isExported ? "exported" : "lora",
+ source: isLocal
+ ? "local"
+ : isExported
+ ? "exported"
+ : "lora",
isLora: !isLocal && !isMerged && !isGguf,
isDownloaded: true,
});
@@ -1017,7 +1397,9 @@ export function LoraModelPicker({
}}
tooltipText={
<>
- {adapter.name}
+
+ {adapter.name}
+
{adapter.id}
@@ -1029,7 +1411,9 @@ export function LoraModelPicker({
repoId={adapter.id}
onSelect={onSelect}
gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
- systemRamGb={gpu.available ? gpu.systemRamAvailableGb : undefined}
+ systemRamGb={
+ gpu.available ? gpu.systemRamAvailableGb : undefined
+ }
/>
)}
diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx
index d688822815..aae027c74b 100644
--- a/studio/frontend/src/components/assistant-ui/thread.tsx
+++ b/studio/frontend/src/components/assistant-ui/thread.tsx
@@ -437,21 +437,40 @@ const CodeToolsToggle: FC = () => {
const ToolStatusDisplay: FC = () => {
const toolStatus = useChatRuntimeStore((s) => s.toolStatus);
+ const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
const [elapsed, setElapsed] = useState(0);
+ const [visible, setVisible] = useState(false);
useEffect(() => {
if (!toolStatus) {
setElapsed(0);
+ if (!isThreadRunning) {
+ setVisible(false);
+ }
return;
}
+
setElapsed(0);
+
+ // Debounce badge visibility by 300ms when the badge is not
+ // already on screen. Once visible from a prior tool, consecutive
+ // tools show immediately so the badge does not flicker. Fast
+ // tool calls that all complete under 300ms never show the badge.
+ let showTimer: ReturnType | undefined;
+ if (!visible) {
+ showTimer = setTimeout(() => setVisible(true), 300);
+ }
+
const interval = setInterval(() => {
setElapsed((prev) => prev + 1);
}, 1000);
- return () => clearInterval(interval);
- }, [toolStatus]);
+ return () => {
+ clearInterval(interval);
+ if (showTimer) clearTimeout(showTimer);
+ };
+ }, [toolStatus, isThreadRunning]);
- if (!toolStatus) return null;
+ if (!toolStatus || !visible) return null;
const isRunning = toolStatus.startsWith("Running");
const StatusIcon = isRunning ? TerminalIcon : GlobeIcon;
return (
diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts
index bb603b90c4..7bdd76296b 100644
--- a/studio/frontend/src/features/chat/api/chat-api.ts
+++ b/studio/frontend/src/features/chat/api/chat-api.ts
@@ -129,7 +129,7 @@ export interface LocalModelInfo {
id: string;
display_name: string;
path: string;
- source: "models_dir" | "hf_cache" | "lmstudio";
+ source: "models_dir" | "hf_cache" | "lmstudio" | "custom";
model_id?: string | null;
updated_at?: number | null;
}
@@ -174,6 +174,34 @@ export async function deleteCachedModel(repoId: string, variant?: string): Promi
await parseJsonOrThrow(response);
}
+export interface ScanFolderInfo {
+ id: number;
+ path: string;
+ created_at: string;
+}
+
+export async function listScanFolders(): Promise {
+ const response = await authFetch("/api/models/scan-folders");
+ const data = await parseJsonOrThrow<{ folders: ScanFolderInfo[] }>(response);
+ return data.folders;
+}
+
+export async function addScanFolder(path: string): Promise {
+ const response = await authFetch("/api/models/scan-folders", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path }),
+ });
+ return parseJsonOrThrow(response);
+}
+
+export async function removeScanFolder(id: number): Promise {
+ const response = await authFetch(`/api/models/scan-folders/${id}`, {
+ method: "DELETE",
+ });
+ await parseJsonOrThrow(response);
+}
+
export async function listGgufVariants(
repoId: string,
hfToken?: string,
diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx
index 07b52ebc30..8d0a9649b4 100644
--- a/studio/frontend/src/features/chat/chat-page.tsx
+++ b/studio/frontend/src/features/chat/chat-page.tsx
@@ -8,7 +8,6 @@ import {
} from "@/components/assistant-ui/model-selector";
import { Thread } from "@/components/assistant-ui/thread";
import { Button } from "@/components/ui/button";
-import { SidebarProvider, SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
import {
Sheet,
SheetContent,
@@ -16,7 +15,17 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import {
+ SidebarProvider,
+ SidebarTrigger,
+ useSidebar,
+} from "@/components/ui/sidebar";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { cn } from "@/lib/utils";
import {
ColumnInsertIcon,
@@ -36,7 +45,6 @@ import {
useState,
} from "react";
import { toast } from "sonner";
-import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { listLocalModels } from "./api/chat-api";
import { ChatSettingsPanel } from "./chat-settings-sheet";
import { ContextUsageBar } from "./components/context-usage-bar";
@@ -48,16 +56,16 @@ import {
getTrainingCompareHandoff,
} from "./lib/training-compare-handoff";
import { ChatRuntimeProvider } from "./runtime-provider";
-import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import {
type CompareHandle,
CompareHandlesProvider,
RegisterCompareHandle,
SharedComposer,
} from "./shared-composer";
+import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import { ThreadSidebar } from "./thread-sidebar";
-import type { ChatView, MessageRecord } from "./types";
import { buildChatTourSteps } from "./tour";
+import type { ChatView, MessageRecord } from "./types";
type LoraCandidate = {
id: string;
@@ -101,7 +109,9 @@ function messageHasImage(message: MessageRecord): boolean {
if (contentParts.some((part) => part.type === "image")) {
return true;
}
- const attachments = Array.isArray(message.attachments) ? message.attachments : [];
+ const attachments = Array.isArray(message.attachments)
+ ? message.attachments
+ : [];
for (const attachment of attachments) {
const parts = Array.isArray(attachment.content) ? attachment.content : [];
for (const part of parts as Array<{ type?: string }>) {
@@ -152,12 +162,25 @@ const CompareContent = memo(function CompareContent({
pairId,
models,
loraModels,
-}: { pairId: string; models: ModelOption[]; loraModels: LoraModelOption[] }): ReactElement {
+ onFoldersChange,
+}: {
+ pairId: string;
+ models: ModelOption[];
+ loraModels: LoraModelOption[];
+ onFoldersChange?: () => void;
+}): ReactElement {
const isLoraCompare = useIsLoraCompare();
- return isLoraCompare
- ?
- : ;
+ return isLoraCompare ? (
+
+ ) : (
+
+ );
});
/** Fast path: same model, adapter on/off, simultaneous generation. */
@@ -179,7 +202,9 @@ const LoraCompareContent = memo(function LoraCompareContent({
setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
});
- return () => { isActive = false; };
+ return () => {
+ isActive = false;
+ };
}, [pairId]);
return (
@@ -196,7 +221,11 @@ const LoraCompareContent = memo(function LoraCompareContent({
-
+
@@ -209,7 +238,11 @@ const LoraCompareContent = memo(function LoraCompareContent({
-
+
@@ -229,7 +262,13 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
pairId,
models,
loraModels,
-}: { pairId: string; models: ModelOption[]; loraModels: LoraModelOption[] }): ReactElement {
+ onFoldersChange,
+}: {
+ pairId: string;
+ models: ModelOption[];
+ loraModels: LoraModelOption[];
+ onFoldersChange?: () => void;
+}): ReactElement {
const handlesRef = useRef>({});
const [model1ThreadId, setModel1ThreadId] = useState();
const [model2ThreadId, setModel2ThreadId] = useState();
@@ -241,7 +280,10 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
isLora: false,
ggufVariant: globalGgufVariant ?? undefined,
});
- const [model2, setModel2] = useState({ id: "", isLora: false });
+ const [model2, setModel2] = useState({
+ id: "",
+ isLora: false,
+ });
useEffect(() => {
let isActive = true;
@@ -252,13 +294,19 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
.then((threads) => {
if (!isActive) return;
setModel1ThreadId(
- threads.find((t) => t.modelType === "model1" || t.modelType === "base")?.id,
+ threads.find(
+ (t) => t.modelType === "model1" || t.modelType === "base",
+ )?.id,
);
setModel2ThreadId(
- threads.find((t) => t.modelType === "model2" || t.modelType === "lora")?.id,
+ threads.find(
+ (t) => t.modelType === "model2" || t.modelType === "lora",
+ )?.id,
);
});
- return () => { isActive = false; };
+ return () => {
+ isActive = false;
+ };
}, [pairId]);
return (
@@ -277,7 +325,14 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
models={models}
loraModels={loraModels}
value={model1.id}
- onValueChange={(id, meta) => setModel1({ id, isLora: meta.isLora, ggufVariant: meta.ggufVariant })}
+ onValueChange={(id, meta) =>
+ setModel1({
+ id,
+ isLora: meta.isLora,
+ ggufVariant: meta.ggufVariant,
+ })
+ }
+ onFoldersChange={onFoldersChange}
variant="ghost"
size="sm"
className="max-w-[50%]"
@@ -303,7 +358,14 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
models={models}
loraModels={loraModels}
value={model2.id}
- onValueChange={(id, meta) => setModel2({ id, isLora: meta.isLora, ggufVariant: meta.ggufVariant })}
+ onValueChange={(id, meta) =>
+ setModel2({
+ id,
+ isLora: meta.isLora,
+ ggufVariant: meta.ggufVariant,
+ })
+ }
+ onFoldersChange={onFoldersChange}
variant="ghost"
size="sm"
className="max-w-[50%]"
@@ -322,7 +384,11 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
-
+
@@ -364,8 +430,7 @@ function InlineSidebar({
data-sidebar="sidebar"
className={cn(
"bg-muted/70 text-sidebar-foreground h-full overflow-hidden rounded-2xl corner-squircle transition-[width] duration-200 ease-linear",
- !collapsed &&
- side === "right" && "border-l border-sidebar-border/70",
+ !collapsed && side === "right" && "border-l border-sidebar-border/70",
collapsed ? "w-0" : "w-(--sidebar-width)",
)}
>
@@ -381,7 +446,11 @@ function TopBarActions({
onNewThread,
onNewCompare,
showCompare,
-}: { onNewThread: () => void; onNewCompare: () => void; showCompare: boolean }) {
+}: {
+ onNewThread: () => void;
+ onNewCompare: () => void;
+ showCompare: boolean;
+}) {
const { state } = useSidebar();
if (state !== "collapsed") {
return null;
@@ -424,8 +493,12 @@ export function ChatPage(): ReactElement {
);
const inferenceParams = useChatRuntimeStore((state) => state.params);
const setInferenceParams = useChatRuntimeStore((state) => state.setParams);
- const activeGgufVariant = useChatRuntimeStore((state) => state.activeGgufVariant);
- const ggufContextLength = useChatRuntimeStore((state) => state.ggufContextLength);
+ const activeGgufVariant = useChatRuntimeStore(
+ (state) => state.activeGgufVariant,
+ );
+ const ggufContextLength = useChatRuntimeStore(
+ (state) => state.ggufContextLength,
+ );
const contextUsage = useChatRuntimeStore((state) => state.contextUsage);
const autoTitle = useChatRuntimeStore((state) => state.autoTitle);
const setAutoTitle = useChatRuntimeStore((state) => state.setAutoTitle);
@@ -441,8 +514,7 @@ export function ChatPage(): ReactElement {
loadingModel,
loadProgress,
loadToastDismissed,
- } =
- useChatModelRuntime();
+ } = useChatModelRuntime();
const refreshRef = useRef(refresh);
const selectModelRef = useRef(selectModel);
@@ -455,11 +527,24 @@ export function ChatPage(): ReactElement {
}, [inferenceParams.checkpoint]);
const handleCheckpointChange = useCallback(
- (value: string, meta?: { isLora: boolean; ggufVariant?: string; isDownloaded?: boolean; expectedBytes?: number }) => {
+ (
+ value: string,
+ meta?: {
+ isLora: boolean;
+ ggufVariant?: string;
+ isDownloaded?: boolean;
+ expectedBytes?: number;
+ },
+ ) => {
const store = useChatRuntimeStore.getState();
const currentCheckpoint = store.params.checkpoint;
const currentVariant = store.activeGgufVariant;
- if (!value || (value === currentCheckpoint && (meta?.ggufVariant ?? null) === (currentVariant ?? null))) return;
+ if (
+ !value ||
+ (value === currentCheckpoint &&
+ (meta?.ggufVariant ?? null) === (currentVariant ?? null))
+ )
+ return;
void (async () => {
let showImageCompatibilityWarning = false;
if (view.mode === "single" && activeThreadId) {
@@ -471,7 +556,9 @@ export function ChatPage(): ReactElement {
.toArray();
if (messages.length > 0) {
const hasImage = messages.some(messageHasImage);
- const targetModel = modelsFromStore.find((model) => model.id === value);
+ const targetModel = modelsFromStore.find(
+ (model) => model.id === value,
+ );
showImageCompatibilityWarning =
hasImage && targetModel?.isVision === false;
}
@@ -499,20 +586,14 @@ export function ChatPage(): ReactElement {
const handleEject = useCallback(() => {
void ejectModel();
}, [ejectModel]);
- const handleNewThread = useCallback(
- () => {
- useChatRuntimeStore.getState().setActiveThreadId(null);
- setView({ mode: "single", newThreadNonce: crypto.randomUUID() });
- },
- [],
- );
- const handleNewCompare = useCallback(
- () => {
- setView({ mode: "compare", pairId: crypto.randomUUID() });
- useChatRuntimeStore.getState().setContextUsage(null);
- },
- [],
- );
+ const handleNewThread = useCallback(() => {
+ useChatRuntimeStore.getState().setActiveThreadId(null);
+ setView({ mode: "single", newThreadNonce: crypto.randomUUID() });
+ }, []);
+ const handleNewCompare = useCallback(() => {
+ setView({ mode: "compare", pairId: crypto.randomUUID() });
+ useChatRuntimeStore.getState().setContextUsage(null);
+ }, []);
const openModelSelector = useCallback(() => {
setModelSelectorLocked(true);
@@ -556,18 +637,17 @@ export function ChatPage(): ReactElement {
.first()
.then((msg) => {
const saved = msg?.metadata as Record | undefined;
- const usage = saved?.contextUsage as typeof store.contextUsage | undefined;
+ const usage = saved?.contextUsage as
+ | typeof store.contextUsage
+ | undefined;
if (usage) store.setContextUsage(usage);
});
}
}, [viewBeforeCompare]);
- const handleThreadSelect = useCallback(
- (nextView: ChatView) => {
- setView(nextView);
- },
- [],
- );
+ const handleThreadSelect = useCallback((nextView: ChatView) => {
+ setView(nextView);
+ }, []);
const models = useMemo(
() =>
@@ -581,6 +661,37 @@ export function ChatPage(): ReactElement {
const [localModels, setLocalModels] = useState([]);
+ const refreshLocalModels = useCallback(() => {
+ void listLocalModels()
+ .then((res) => {
+ setLocalModels(
+ res.models
+ .filter(
+ (m) =>
+ m.source === "lmstudio" ||
+ m.source === "models_dir" ||
+ m.source === "custom",
+ )
+ .map((m) => ({
+ id: m.id,
+ name:
+ m.source === "lmstudio" && m.model_id
+ ? m.model_id
+ : m.display_name,
+ baseModel:
+ m.source === "lmstudio"
+ ? "LM Studio"
+ : m.source === "custom"
+ ? "Custom Folders"
+ : "Local models",
+ updatedAt: m.updated_at ?? undefined,
+ source: "local" as const,
+ })),
+ );
+ })
+ .catch(() => {});
+ }, []);
+
const loraModels = useMemo(() => {
const fromLoras = lorasFromStore.map((lora) => ({
id: lora.id,
@@ -596,20 +707,8 @@ export function ChatPage(): ReactElement {
useEffect(() => {
if (getTrainingCompareHandoff()) return;
void refresh();
- void listLocalModels().then((res) => {
- setLocalModels(
- res.models
- .filter((m) => m.source === "lmstudio" || m.source === "models_dir")
- .map((m) => ({
- id: m.id,
- name: m.source === "lmstudio" && m.model_id ? m.model_id : m.display_name,
- baseModel: m.source === "lmstudio" ? "LM Studio" : "Local models",
- updatedAt: m.updated_at ?? undefined,
- source: "local" as const,
- })),
- );
- }).catch(() => {});
- }, [refresh]);
+ refreshLocalModels();
+ }, [refresh, refreshLocalModels]);
useEffect(() => {
const handoff = getTrainingCompareHandoff();
@@ -649,7 +748,10 @@ export function ChatPage(): ReactElement {
console.info("[chat-handoff] no lora match, loading base", {
id: handoff.baseModel,
});
- await selectModelRef.current({ id: handoff.baseModel, isLora: false });
+ await selectModelRef.current({
+ id: handoff.baseModel,
+ isLora: false,
+ });
if (canceled) return;
} else {
console.warn("[chat-handoff] no lora/base match found", {
@@ -751,6 +853,7 @@ export function ChatPage(): ReactElement {
activeGgufVariant={activeGgufVariant}
onValueChange={handleCheckpointChange}
onEject={handleEject}
+ onFoldersChange={refreshLocalModels}
variant="ghost"
open={modelSelectorOpen}
onOpenChange={handleModelSelectorOpenChange}
@@ -767,9 +870,11 @@ export function ChatPage(): ReactElement {
? "Loading model…"
: "Downloading model…"
}
- title={loadingModel.isDownloaded
- ? `Loading ${loadingModel.displayName} from cache.`
- : `Loading ${loadingModel.displayName}. This may include downloading.`}
+ title={
+ loadingModel.isDownloaded
+ ? `Loading ${loadingModel.displayName} from cache.`
+ : `Loading ${loadingModel.displayName}. This may include downloading.`
+ }
progressPercent={loadProgress?.percent}
progressLabel={loadProgress?.label}
onStop={cancelLoading}
@@ -809,7 +914,13 @@ export function ChatPage(): ReactElement {
newThreadNonce={view.newThreadNonce}
/>
) : (
-
+
)}
diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx
index 3f3557b34f..6276e5c2e8 100644
--- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx
+++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx
@@ -1,16 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { Slider } from "@/components/ui/slider";
-import { Textarea } from "@/components/ui/textarea";
-import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -20,6 +10,25 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import { Slider } from "@/components/ui/slider";
+import { Switch } from "@/components/ui/switch";
+import { Textarea } from "@/components/ui/textarea";
+import { useIsMobile } from "@/hooks/use-mobile";
import {
ArrowDown01Icon,
CodeIcon,
@@ -33,22 +42,13 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion } from "motion/react";
-import {
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet";
-import { useIsMobile } from "@/hooks/use-mobile";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
+import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import {
DEFAULT_INFERENCE_PARAMS,
type InferenceParams,
} from "./types/runtime";
-import { useChatRuntimeStore } from "./stores/chat-runtime-store";
-import { Switch } from "@/components/ui/switch";
export const defaultInferenceParams = DEFAULT_INFERENCE_PARAMS;
export type { InferenceParams } from "./types/runtime";
@@ -174,7 +174,11 @@ function loadCollapsibleState(): Record {
const raw = localStorage.getItem(COLLAPSIBLE_STATE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
+ if (
+ typeof parsed !== "object" ||
+ parsed === null ||
+ Array.isArray(parsed)
+ ) {
return {};
}
return Object.fromEntries(
@@ -277,12 +281,16 @@ export function ChatSettingsPanel({
const isMobile = useIsMobile();
const isGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null;
const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
- const ggufMaxContextLength = useChatRuntimeStore((s) => s.ggufMaxContextLength);
+ const ggufMaxContextLength = useChatRuntimeStore(
+ (s) => s.ggufMaxContextLength,
+ );
const kvCacheDtype = useChatRuntimeStore((s) => s.kvCacheDtype);
const setKvCacheDtype = useChatRuntimeStore((s) => s.setKvCacheDtype);
const loadedKvCacheDtype = useChatRuntimeStore((s) => s.loadedKvCacheDtype);
const customContextLength = useChatRuntimeStore((s) => s.customContextLength);
- const setCustomContextLength = useChatRuntimeStore((s) => s.setCustomContextLength);
+ const setCustomContextLength = useChatRuntimeStore(
+ (s) => s.setCustomContextLength,
+ );
const ctxDisplayValue = customContextLength ?? ggufContextLength ?? "";
const ctxMaxValue = ggufMaxContextLength ?? ggufContextLength ?? null;
@@ -292,7 +300,9 @@ export function ChatSettingsPanel({
const [customPresets, setCustomPresets] = useState(() =>
loadSavedCustomPresets(),
);
- const [activePreset, setActivePreset] = useState(() => loadSavedActivePreset());
+ const [activePreset, setActivePreset] = useState(() =>
+ loadSavedActivePreset(),
+ );
const [savePresetOpen, setSavePresetOpen] = useState(false);
const [presetNameDraft, setPresetNameDraft] = useState("");
const presets = useMemo(
@@ -417,325 +427,354 @@ export function ChatSettingsPanel({
{/* mt-4 matches the Playground sidebar gap (SidebarHeader py-3 + SidebarGroup pt-1) */}
-
-
-
-
- Save
-
- deletePreset(activePreset)}
- disabled={isBuiltinPreset}
- className="flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
- title={
- isBuiltinPreset
- ? "Built-in presets cannot be deleted"
- : "Delete selected preset"
- }
- >
-
- Delete
-
-
-
-
-
-
-
+
-
-
- {isGguf && (
- <>
-
-
- Context Length
- {
- const raw = e.target.value;
- if (raw === "") {
- setCustomContextLength(null);
- return;
- }
- const v = parseInt(raw, 10);
- if (!Number.isNaN(v) && v >= 0) {
- const maxCtx = ctxMaxValue ?? Infinity;
- const clamped = Math.min(v, maxCtx);
- setCustomContextLength(clamped === (ggufContextLength ?? 0) ? null : clamped);
- }
- }}
- />
-
-
+
+
+
+
+
+ {isGguf && (
+ <>
+
+
+ Context Length
+ {
- setCustomContextLength(v === (ggufContextLength ?? 0) ? null : v);
+ className="h-6 w-[100px] text-right text-xs tabular-nums"
+ onChange={(e) => {
+ const raw = e.target.value;
+ if (raw === "") {
+ setCustomContextLength(null);
+ return;
+ }
+ const v = Number.parseInt(raw, 10);
+ if (!Number.isNaN(v) && v >= 0) {
+ const maxCtx =
+ ctxMaxValue ?? Number.POSITIVE_INFINITY;
+ const clamped = Math.min(v, maxCtx);
+ setCustomContextLength(
+ clamped === (ggufContextLength ?? 0)
+ ? null
+ : clamped,
+ );
+ }
}}
/>
-
-
- KV Cache Dtype
-
- Quantize KV cache to reduce VRAM.
-
-
-
-
- {modelSettingsDirty && (
-
- onReloadModel?.()}
- className="rounded-md bg-primary px-2.5 py-1 text-[11px] font-medium text-primary-foreground transition-colors hover:bg-primary/90"
- >
- Apply
-
- {
- setCustomContextLength(null);
- setKvCacheDtype(loadedKvCacheDtype);
- }}
- className="rounded-md border px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent"
- >
- Reset
-
-
- )}
- >
- )}
- {!isGguf && params.checkpoint && (
-
-
- Enable custom code
-
- Allow models with custom code (e.g. Nemotron). Only enable if sure.
-
-
- {
+ setCustomContextLength(
+ v === (ggufContextLength ?? 0) ? null : v,
+ );
+ }}
/>
- )}
-
-
-
-
-
-
-
-
-
-
-
- {!isGguf && (
-
- )}
- = ggufContextLength
- ? "Max"
- : undefined
- }
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ KV Cache Dtype
+
+ Quantize KV cache to reduce VRAM.
+
+
+
+
+ {modelSettingsDirty && (
+
+ onReloadModel?.()}
+ className="rounded-md bg-primary px-2.5 py-1 text-[11px] font-medium text-primary-foreground transition-colors hover:bg-primary/90"
+ >
+ Apply
+
+ {
+ setCustomContextLength(null);
+ setKvCacheDtype(loadedKvCacheDtype);
+ }}
+ className="rounded-md border px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent"
+ >
+ Reset
+
+
+ )}
+ >
+ )}
+ {!isGguf && params.checkpoint && (
- Auto title
+ Enable custom code
- Generate short title after reply.
+ Allow models with custom code (e.g. Nemotron). Only enable
+ if sure.
-
-
-
+ )}
+
+
-
-
-