diff --git a/studio/backend/models/models.py b/studio/backend/models/models.py index f67014a17..46ca4e378 100644 --- a/studio/backend/models/models.py +++ b/studio/backend/models/models.py @@ -213,3 +213,68 @@ class ScanFolderInfo(BaseModel): id: int = Field(..., description = "Database row ID") path: str = Field(..., description = "Normalized absolute path") created_at: str = Field(..., description = "ISO 8601 creation timestamp") + + +class BrowseEntry(BaseModel): + """A directory entry surfaced by the folder browser.""" + + name: str = Field(..., description = "Entry name (basename, not full path)") + has_models: bool = Field( + False, + description = ( + "Hint that the directory likely contains models " + "(*.gguf, *.safetensors, config.json, or HF-style " + "`models--*` subfolders). Used by the UI to highlight " + "promising candidates; the scanner itself is authoritative." + ), + ) + hidden: bool = Field( + False, + description = "Name starts with a dot (e.g. `.cache`)", + ) + + +class BrowseFoldersResponse(BaseModel): + """Response schema for the folder browser endpoint.""" + + current: str = Field(..., description = "Absolute path of the directory just listed") + parent: Optional[str] = Field( + None, + description = ( + "Parent directory of `current`, or null if `current` is the " + "filesystem root. The frontend uses this to render an `Up` row." + ), + ) + entries: List[BrowseEntry] = Field( + default_factory = list, + description = ( + "Subdirectories of `current`. Sorted with model-bearing " + "directories first, then alphabetically case-insensitive; " + "hidden entries come last within each group." + ), + ) + suggestions: List[str] = Field( + default_factory = list, + description = ( + "Handy starting points (home, HF cache, already-registered " + "scan folders). Rendered as quick-pick chips above the list." + ), + ) + truncated: bool = Field( + False, + description = ( + "True when the listing was capped because the directory had " + "more subfolders than the server is willing to enumerate in " + "one request. The UI should show a hint telling the user to " + "narrow their path." + ), + ) + model_files_here: int = Field( + 0, + description = ( + "Count of GGUF/safetensors files immediately inside " + "``current``. Used by the UI to surface a hint on leaf " + "model directories (which otherwise look `empty` because " + "they contain only files, no subdirectories)." + ), + ) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 4ce2d0787..9e7168eed 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -101,6 +101,8 @@ from models import ( ModelListResponse, ) from models.models import ( + BrowseEntry, + BrowseFoldersResponse, GgufVariantDetail, GgufVariantsResponse, ModelType, @@ -573,6 +575,529 @@ async def remove_scan_folder_endpoint( return {"ok": True} +# Heuristic ceiling on how many children to stat when checking whether a +# directory "looks like" it contains models. Keeps the browser snappy +# even when a directory has thousands of unrelated entries. +_BROWSE_MODEL_HINT_PROBE = 64 +# Hard cap on how many subdirectory entries we send back. Pointing the +# browser at something like ``/usr/lib`` or ``/proc`` must not stat-storm +# the process or send tens of thousands of rows to the client. +_BROWSE_ENTRY_CAP = 2000 + + +def _count_model_files(directory: Path, cap: int = 200) -> int: + """Count GGUF/safetensors files immediately inside *directory*. + Used to surface a count-hint on the response so the UI can tell + users that a leaf directory (no subdirs, only weights) is a valid + "Use this folder" target. + + Bounded by *visited entries*, not by *match count*: in directories + with many non-model files (or many subdirectories) the scan still + stops after ``cap`` entries so a UI hint never costs more than a + bounded directory walk. + """ + n = 0 + visited = 0 + try: + for f in directory.iterdir(): + visited += 1 + if visited > cap: + break + try: + if f.is_file(): + low = f.name.lower() + if low.endswith((".gguf", ".safetensors")): + n += 1 + except OSError: + continue + except PermissionError as e: + logger.debug("browse-folders: permission denied counting %s: %s", directory, e) + return 0 + except OSError as e: + logger.debug("browse-folders: OS error counting %s: %s", directory, e) + return 0 + return n + + +def _has_direct_model_signal(directory: Path) -> bool: + """Return True if *directory* has an immediate child that signals + it holds a model: a GGUF/safetensors/config.json file, or a + `models--*` subdir (HF hub cache). Bounded by + ``_BROWSE_MODEL_HINT_PROBE`` to stay fast.""" + try: + it = directory.iterdir() + except OSError: + return False + try: + for i, child in enumerate(it): + if i >= _BROWSE_MODEL_HINT_PROBE: + break + try: + name = child.name + if child.is_file(): + low = name.lower() + if low.endswith((".gguf", ".safetensors")): + return True + if low in ("config.json", "adapter_config.json"): + return True + elif child.is_dir() and name.startswith("models--"): + return True + except OSError: + continue + except OSError: + return False + return False + + +def _looks_like_model_dir(directory: Path) -> bool: + """Bounded heuristic used by the folder browser to flag directories + worth exploring. False negatives are fine; the real scanner is + authoritative. + + Three signals, cheapest first: + + 1. Directory name itself: ``models--*`` is the HuggingFace hub cache + layout (``blobs``/``refs``/``snapshots`` children wouldn't match + the file-level probes below). + 2. An immediate child is a weight file or config (handled by + :func:`_has_direct_model_signal`). + 3. A grandchild has a direct signal -- this catches the + ``publisher/model/weights.gguf`` layout used by LM Studio and + Ollama. We probe at most the first + ``_BROWSE_MODEL_HINT_PROBE`` child directories, each of which is + checked with a bounded :func:`_has_direct_model_signal` call, + so the total cost stays O(PROBE^2) worst-case. + """ + if directory.name.startswith("models--"): + return True + if _has_direct_model_signal(directory): + return True + # Grandchild probe: LM Studio / Ollama publisher/model layout. + try: + it = directory.iterdir() + except OSError: + return False + try: + for i, child in enumerate(it): + if i >= _BROWSE_MODEL_HINT_PROBE: + break + try: + if not child.is_dir(): + continue + except OSError: + continue + # Fast name check first + if child.name.startswith("models--"): + return True + if _has_direct_model_signal(child): + return True + except OSError: + return False + return False + + +def _build_browse_allowlist() -> list[Path]: + """Return the list of root directories the folder browser is allowed + to walk. The same list is used to seed the sidebar suggestion chips, + so chip targets are always reachable. + + Roots include the current user's HOME, the resolved HF cache dirs, + Studio's own outputs/exports/studio root, registered scan folders, + and well-known third-party local-LLM dirs (LM Studio, Ollama, + `~/models`). Each is added only if it currently resolves to a real + directory, so we never produce a "dead" sandbox boundary the user + can't navigate into. + """ + from utils.paths import ( + hf_default_cache_dir, + legacy_hf_cache_dir, + well_known_model_dirs, + ) + from storage.studio_db import list_scan_folders + + candidates: list[Path] = [] + + def _add(p: Optional[Path]) -> None: + if p is None: + return + try: + resolved = p.resolve() + except OSError: + return + if resolved.is_dir(): + candidates.append(resolved) + + _add(Path.home()) + _add(_resolve_hf_cache_dir()) + try: + _add(hf_default_cache_dir()) + except Exception: # noqa: BLE001 -- best-effort + pass + try: + _add(legacy_hf_cache_dir()) + except Exception: # noqa: BLE001 -- best-effort + pass + try: + from utils.paths import ( + exports_root, + outputs_root, + studio_root, + ) + + _add(studio_root()) + _add(outputs_root()) + _add(exports_root()) + except Exception as exc: # noqa: BLE001 -- best-effort + logger.debug("browse-folders: studio roots unavailable: %s", exc) + try: + for folder in list_scan_folders(): + p = folder.get("path") + if p: + _add(Path(p)) + except Exception as exc: # noqa: BLE001 -- best-effort + logger.debug("browse-folders: could not load scan folders: %s", exc) + try: + for p in well_known_model_dirs(): + _add(p) + except Exception as exc: # noqa: BLE001 -- best-effort + logger.debug("browse-folders: well-known dirs unavailable: %s", exc) + + # Dedupe while preserving order. + seen: set[str] = set() + deduped: list[Path] = [] + for p in candidates: + key = str(p) + if key in seen: + continue + seen.add(key) + deduped.append(p) + return deduped + + +def _is_path_inside_allowlist(target: Path, allowed_roots: list[Path]) -> bool: + """Return True if *target* equals or is a descendant of any allowed + root. The comparison uses ``os.path.realpath`` so symlinks cannot be + used to escape the sandbox. + """ + try: + target_real = os.path.realpath(str(target)) + except OSError: + return False + for root in allowed_roots: + try: + root_real = os.path.realpath(str(root)) + except OSError: + continue + if target_real == root_real or target_real.startswith(root_real + os.sep): + return True + return False + + +def _normalize_browse_request_path(path: Optional[str]) -> str: + """Normalize the browse request path lexically, without touching the FS.""" + if path is None or not path.strip(): + return os.path.normpath(str(Path.home())) + + expanded = os.path.expanduser(path.strip()) + if not os.path.isabs(expanded): + expanded = os.path.join(str(Path.cwd()), expanded) + return os.path.normpath(expanded) + + +def _browse_relative_parts(requested_path: str, root: Path) -> Optional[list[str]]: + """Return validated relative path components under ``root``.""" + root_text = os.path.normpath(str(root)) + try: + rel_text = os.path.relpath(requested_path, root_text) + except ValueError: + return None + + if rel_text == ".": + return [] + if rel_text == ".." or rel_text.startswith(f"..{os.sep}"): + return None + + parts = [part for part in rel_text.split(os.sep) if part not in ("", ".")] + altsep = os.altsep + for part in parts: + if part == ".." or os.sep in part or (altsep and altsep in part): + return None + return parts + + +def _match_browse_child(current: Path, name: str) -> Optional[Path]: + """Return the immediate child named ``name`` under ``current``.""" + try: + for child in current.iterdir(): + if child.name == name: + return child + except PermissionError: + raise HTTPException( + status_code = 403, + detail = f"Permission denied reading {current}", + ) from None + except OSError as exc: + raise HTTPException( + status_code = 500, + detail = f"Could not read {current}: {exc}", + ) from exc + return None + + +def _resolve_browse_target(path: Optional[str], allowed_roots: list[Path]) -> Path: + """Resolve a requested browse path by walking from trusted allowlist roots.""" + requested_path = _normalize_browse_request_path(path) + resolved_roots: list[Path] = [] + seen_roots: set[str] = set() + for root in sorted(allowed_roots, key = lambda p: len(str(p)), reverse = True): + try: + resolved = root.resolve() + except OSError: + continue + key = str(resolved) + if key in seen_roots: + continue + seen_roots.add(key) + resolved_roots.append(resolved) + + for root in resolved_roots: + parts = _browse_relative_parts(requested_path, root) + if parts is None: + continue + + current = root + for part in parts: + child = _match_browse_child(current, part) + if child is None: + raise HTTPException( + status_code = 404, + detail = f"Path does not exist: {requested_path}", + ) + try: + resolved_child = child.resolve() + except OSError as exc: + raise HTTPException( + status_code = 400, + detail = f"Invalid path: {exc}", + ) from exc + if not _is_path_inside_allowlist(resolved_child, resolved_roots): + raise HTTPException( + status_code = 403, + detail = ( + "Path is not in the browseable allowlist. Register it via " + "POST /api/models/scan-folders first, or pick a directory " + "under your home folder." + ), + ) + current = resolved_child + + if not current.is_dir(): + raise HTTPException( + status_code = 400, + detail = f"Not a directory: {current}", + ) + return current + + raise HTTPException( + status_code = 403, + detail = ( + "Path is not in the browseable allowlist. Register it via " + "POST /api/models/scan-folders first, or pick a directory " + "under your home folder." + ), + ) + + +@router.get("/browse-folders", response_model = BrowseFoldersResponse) +async def browse_folders( + path: Optional[str] = Query( + None, + description = ( + "Directory to list. If omitted, defaults to the current user's " + "home directory. Tilde (`~`) and relative paths are expanded. " + "Must resolve inside the allowlist of browseable roots (HOME, " + "HF cache, Studio dirs, registered scan folders, well-known " + "model dirs)." + ), + ), + show_hidden: bool = Query( + False, + description = "Include entries whose name starts with a dot", + ), + current_subject: str = Depends(get_current_subject), +): + """ + List immediate subdirectories of *path* for the Custom Folders picker. + + The frontend uses this to render a modal folder browser without needing + a native OS dialog (Studio is served over HTTP, so the browser can't + reveal absolute paths on the host). The endpoint is read-only and does + not create, move, or delete anything. It simply enumerates visible + subdirectories so the user can click their way to a folder and hand + the resulting string back to POST `/api/models/scan-folders`. + + Sandbox: requests are bounded to the allowlist returned by + :func:`_build_browse_allowlist` (HOME, HF cache, Studio dirs, + registered scan folders, well-known model dirs). Paths outside the + allowlist return 403 so users cannot probe ``/etc``, ``/proc``, + ``/root`` (when not HOME), or other sensitive system locations + even if the server process can read them. Symlinks are resolved + via ``os.path.realpath`` before the check, so symlink traversal + cannot escape the sandbox either. + + Sorting: directories that look like they hold models come first, then + plain directories, then hidden entries (if `show_hidden=true`). + """ + from utils.paths import hf_default_cache_dir, well_known_model_dirs + from storage.studio_db import list_scan_folders + + # Build the allowlist once -- both the sandbox check below and the + # suggestion chips use the same set, so chips are always navigable. + allowed_roots = _build_browse_allowlist() + + try: + target = _resolve_browse_target(path, allowed_roots) + except HTTPException: + requested_path = _normalize_browse_request_path(path) + if path is not None and path.strip(): + logger.warning( + "browse-folders: rejected path %r (normalized=%s)", + path, + requested_path, + ) + raise + + # Enumerate immediate subdirectories with a bounded cap so a stray + # query against ``/usr/lib`` or ``/proc`` can't stat-storm the process. + entries: list[BrowseEntry] = [] + truncated = False + visited = 0 + try: + it = target.iterdir() + except PermissionError: + raise HTTPException( + status_code = 403, + detail = f"Permission denied reading {target}", + ) + except OSError as exc: + raise HTTPException( + status_code = 500, + detail = f"Could not read {target}: {exc}", + ) + + try: + for child in it: + # Bound by *visited entries*, not by *appended entries*: in + # directories full of files (or hidden subdirs when + # ``show_hidden=False``) the cap on ``len(entries)`` would + # never trigger and we'd still stat every child. Counting + # visits keeps the worst-case work to ``_BROWSE_ENTRY_CAP`` + # iterdir/is_dir calls regardless of how many of them + # survive the filters below. + visited += 1 + if visited > _BROWSE_ENTRY_CAP: + truncated = True + break + try: + if not child.is_dir(): + continue + except OSError: + continue + name = child.name + is_hidden = name.startswith(".") + if is_hidden and not show_hidden: + continue + entries.append( + BrowseEntry( + name = name, + has_models = _looks_like_model_dir(child), + hidden = is_hidden, + ) + ) + except PermissionError as exc: + logger.debug( + "browse-folders: permission denied during enumeration of %s: %s", + target, + exc, + ) + except OSError as exc: + # Rare: iterdir succeeded but reading a specific entry failed. + logger.warning("browse-folders: partial enumeration of %s: %s", target, exc) + + # Model-bearing dirs first, then plain, then hidden; case-insensitive + # alphabetical within each bucket. + def _sort_key(e: BrowseEntry) -> tuple[int, str]: + bucket = 0 if e.has_models else (2 if e.hidden else 1) + return (bucket, e.name.lower()) + + entries.sort(key = _sort_key) + + # Parent is None at the filesystem root (`p.parent == p`) AND when + # the parent would step outside the sandbox -- otherwise the up-row + # would 403 on click. Users can still hop to other allowed roots + # via the suggestion chips below. + parent: Optional[str] + if target.parent == target or not _is_path_inside_allowlist( + target.parent, allowed_roots + ): + parent = None + else: + parent = str(target.parent) + + # Handy starting points for the quick-pick chips. + suggestions: list[str] = [] + seen_sug: set[str] = set() + + def _add_sug(p: Optional[Path]) -> None: + if p is None: + return + try: + resolved = str(p.resolve()) + except OSError: + return + if resolved in seen_sug: + return + if Path(resolved).is_dir(): + seen_sug.add(resolved) + suggestions.append(resolved) + + # Home always comes first -- it's the safe fallback when everything + # else is cold. + _add_sug(Path.home()) + # The HF cache root the process is actually using. + try: + _add_sug(hf_default_cache_dir()) + except Exception: + pass + # Already-registered scan folders (what the user has curated). + try: + for folder in list_scan_folders(): + _add_sug(Path(folder.get("path", ""))) + except Exception as exc: + logger.debug("browse-folders: could not load scan folders: %s", exc) + # Directories commonly used by other local-LLM tools: LM Studio + # (`~/.lmstudio/models` + legacy `~/.cache/lm-studio/models` + + # user-configured downloadsFolder from LM Studio's settings.json), + # Ollama (`~/.ollama/models` + common system paths + OLLAMA_MODELS + # env var), and generic user-choice spots (`~/models`, `~/Models`). + # Each helper only returns paths that currently exist so we never + # show dead chips. + try: + for p in well_known_model_dirs(): + _add_sug(p) + except Exception as exc: + logger.debug("browse-folders: could not load well-known dirs: %s", exc) + + return BrowseFoldersResponse( + current = str(target), + parent = parent, + entries = entries, + suggestions = suggestions, + truncated = truncated, + model_files_here = _count_model_files(target), + ) + + @router.get("/list") async def list_models( current_subject: str = Depends(get_current_subject), diff --git a/studio/backend/tests/test_browse_folders_route.py b/studio/backend/tests/test_browse_folders_route.py new file mode 100644 index 000000000..19a83987d --- /dev/null +++ b/studio/backend/tests/test_browse_folders_route.py @@ -0,0 +1,86 @@ +# 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 os +import sys +import types +from pathlib import Path + +import pytest +from fastapi import HTTPException + +# Keep this test runnable in lightweight environments where optional logging +# deps are not installed. +if "structlog" not in sys.modules: + + class _DummyLogger: + def __getattr__(self, _name): + return lambda *args, **kwargs: None + + sys.modules["structlog"] = types.SimpleNamespace( + BoundLogger = _DummyLogger, + get_logger = lambda *args, **kwargs: _DummyLogger(), + ) + +import routes.models as models_route + + +def test_resolve_browse_target_returns_allowed_directory(tmp_path): + allowed = tmp_path / "allowed" + target = allowed / "models" / "nested" + target.mkdir(parents = True) + + resolved = models_route._resolve_browse_target(str(target), [allowed]) + + assert resolved == target.resolve() + + +def test_resolve_browse_target_rejects_outside_allowlist(tmp_path): + allowed = tmp_path / "allowed" + disallowed = tmp_path / "disallowed" + allowed.mkdir() + disallowed.mkdir() + + with pytest.raises(HTTPException) as exc_info: + models_route._resolve_browse_target(str(disallowed), [allowed]) + + assert exc_info.value.status_code == 403 + + +def test_resolve_browse_target_rejects_file_path(tmp_path): + allowed = tmp_path / "allowed" + allowed.mkdir() + model_file = allowed / "model.gguf" + model_file.write_text("gguf") + + with pytest.raises(HTTPException) as exc_info: + models_route._resolve_browse_target(str(model_file), [allowed]) + + assert exc_info.value.status_code == 400 + + +def test_resolve_browse_target_allows_symlink_into_other_allowed_root(tmp_path): + home_root = tmp_path / "home" + scan_root = tmp_path / "scan" + target = scan_root / "nested" + home_root.mkdir() + target.mkdir(parents = True) + (home_root / "scan-link").symlink_to(scan_root, target_is_directory = True) + + resolved = models_route._resolve_browse_target( + str(home_root / "scan-link" / "nested"), + [home_root, scan_root], + ) + + assert resolved == target.resolve() + + +@pytest.mark.skipif(os.altsep is not None, reason = "POSIX-only path semantics") +def test_resolve_browse_target_allows_backslash_in_posix_segment(tmp_path): + allowed = tmp_path / "allowed" + target = allowed / r"dir\name" + target.mkdir(parents = True) + + resolved = models_route._resolve_browse_target(str(target), [allowed]) + + assert resolved == target.resolve() diff --git a/studio/backend/utils/paths/__init__.py b/studio/backend/utils/paths/__init__.py index 11709ae56..92191dccd 100644 --- a/studio/backend/utils/paths/__init__.py +++ b/studio/backend/utils/paths/__init__.py @@ -34,6 +34,7 @@ from .storage_roots import ( legacy_hf_cache_dir, hf_default_cache_dir, lmstudio_model_dirs, + well_known_model_dirs, ensure_dir, ensure_studio_directories, resolve_under_root, @@ -70,6 +71,7 @@ __all__ = [ "legacy_hf_cache_dir", "hf_default_cache_dir", "lmstudio_model_dirs", + "well_known_model_dirs", "ensure_dir", "ensure_studio_directories", "resolve_under_root", diff --git a/studio/backend/utils/paths/storage_roots.py b/studio/backend/utils/paths/storage_roots.py index 4841c5d0a..b52609b06 100644 --- a/studio/backend/utils/paths/storage_roots.py +++ b/studio/backend/utils/paths/storage_roots.py @@ -130,6 +130,51 @@ def lmstudio_model_dirs() -> list[Path]: return dirs +def well_known_model_dirs() -> list[Path]: + """Return directories commonly used by other local LLM tools. + + Used by the folder browser to offer quick-pick chips. Returns only + paths that exist on disk, so the UI never shows dead chips. Order + reflects a rough "likelihood the user has models here" -- LM Studio + and Ollama first, then the generic fallbacks. + """ + candidates: list[Path] = [] + + # LM Studio (reuses the logic above, including settings.json override) + candidates.extend(lmstudio_model_dirs()) + + # Ollama -- both the user-level and common system-wide install paths + # (https://github.com/ollama/ollama/issues/733). + ollama_env = os.environ.get("OLLAMA_MODELS") + if ollama_env: + candidates.append(Path(ollama_env).expanduser()) + candidates.append(Path.home() / ".ollama" / "models") + candidates.append(Path("/usr/share/ollama/.ollama/models")) + candidates.append(Path("/var/lib/ollama/.ollama/models")) + + # HF hub cache root (separate from the explicit HF cache chip) + candidates.append(Path.home() / ".cache" / "huggingface" / "hub") + + # Generic "my models" spots users tend to drop things into + for name in ("models", "Models"): + candidates.append(Path.home() / name) + + # Deduplicate while preserving order; keep only extant dirs + out: list[Path] = [] + seen: set[str] = set() + for p in candidates: + try: + resolved = str(p.resolve()) + except OSError: + continue + if resolved in seen: + continue + if Path(resolved).is_dir(): + seen.add(resolved) + out.append(Path(resolved)) + return out + + def _setup_cache_env() -> None: """Set cache environment variables for HuggingFace, uv, and vLLM. diff --git a/studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx b/studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx new file mode 100644 index 000000000..42bd1716a --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"use client"; + +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Spinner } from "@/components/ui/spinner"; +import { + type BrowseFoldersResponse, + browseFolders, +} from "@/features/chat/api/chat-api"; +import { cn } from "@/lib/utils"; +import { ArrowUp02Icon, Folder02Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +export interface FolderBrowserProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Called with the absolute path the user confirmed. */ + onSelect: (path: string) => void; + /** Optional initial directory. Defaults to the user's home on the server. */ + initialPath?: string; +} + +function splitBreadcrumb(path: string): { label: string; value: string }[] { + if (!path) return []; + // Distinguish path styles BEFORE normalizing separators. On POSIX + // backslashes are valid filename characters, so we cannot blindly + // rewrite ``\`` -> ``/`` -- doing so would mangle directory names + // like ``my\backup`` into ``my/backup`` and produce breadcrumb + // values that 404 on the server. Only Windows-style absolute paths + // (drive letter, or UNC ``\\server\share``) get the conversion. + const isWindowsDrive = /^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path); + const isUnc = /^\\\\/.test(path); + const isWindows = isWindowsDrive || isUnc; + const normalized = isWindows ? path.replace(/\\/g, "/") : path; + const segments = normalized.split("/"); + const parts: { label: string; value: string }[] = []; + + // POSIX absolute path: leading empty segment from split("/") + if (segments[0] === "") { + parts.push({ label: "/", value: "/" }); + let cur = ""; + for (const seg of segments.slice(1)) { + if (!seg) continue; + cur = `${cur}/${seg}`; + parts.push({ label: seg, value: cur }); + } + return parts; + } + + // Windows-ish drive path (C:, D:): first segment is the drive. Use + // ``C:/`` (drive-absolute) as the crumb value so clicking the drive + // root navigates to the root of the drive rather than the + // drive-relative current working directory on that drive (``C:`` + // alone resolves to ``CWD-on-C``, not ``C:\``). + if (/^[A-Za-z]:$/.test(segments[0])) { + const driveRoot = `${segments[0]}/`; + let cur = driveRoot; + parts.push({ label: segments[0], value: driveRoot }); + for (const seg of segments.slice(1)) { + if (!seg) continue; + cur = cur.endsWith("/") ? `${cur}${seg}` : `${cur}/${seg}`; + parts.push({ label: seg, value: cur }); + } + return parts; + } + + // Fallback: relative / UNC-ish. Render as-is as a single crumb. + return [{ label: path, value: path }]; +} + +export function FolderBrowser({ + open, + onOpenChange, + onSelect, + initialPath, +}: FolderBrowserProps) { + const [data, setData] = useState(null); + const [path, setPath] = useState(initialPath); + const [showHidden, setShowHidden] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + 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 actually cancels the + // backend enumeration instead of just discarding 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, but if the very first request (typically + // a typo'd or denylisted ``initialPath``) fails AND the + // browser is empty (no ``data`` to render against), fall + // back to the user's HOME so the modal is navigable instead + // of an irrecoverable dead end. + 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 (paranoia: shouldn't + // happen since the sandbox allowlist always includes HOME). + queueMicrotask(() => navigate(undefined, hidden)); + } + }) + .finally(() => { + if (!ctrl.signal.aborted) setLoading(false); + }); + }, + [], + ); + + // Fetch when the dialog opens. Only re-run when the dialog transitions + // closed -> open; subsequent navigation is driven by `navigate()` so we + // don't want `path` in the dependency list here. + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + if (!open) return; + // ``fallbackOnError``: if the user-supplied ``initialPath`` is bad + // (typo, denylisted, deleted) we recover into HOME instead of + // showing an empty modal with no breadcrumbs/entries. + navigate(initialPath, showHidden, { fallbackOnError: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const handleConfirm = useCallback(() => { + if (!path) return; + onSelect(path); + onOpenChange(false); + }, [onSelect, onOpenChange, path]); + + const crumbs = useMemo( + () => (data?.current ? splitBreadcrumb(data.current) : []), + [data?.current], + ); + + return ( + + + + + Browse for folder + + + + {/* Breadcrumb */} +
+ {crumbs.length === 0 ? ( + (loading…) + ) : ( + crumbs.map((c, i) => ( + + + {i < crumbs.length - 1 && ( + / + )} + + )) + )} +
+ + {/* Suggestions (quick-pick chips) */} + {data?.suggestions && data.suggestions.length > 0 && ( +
+ {data.suggestions.map((s) => ( + + ))} +
+ )} + + {/* Entry list */} +
+ {error && ( +
{error}
+ )} + {!error && loading && ( +
+ + Loading… +
+ )} + {!error && !loading && data && ( + <> + {/* Up row */} + {data.parent !== null && ( + + )} + {data.entries.length === 0 && !(data.model_files_here && data.model_files_here > 0) && ( +
+ (empty directory) +
+ )} + {data.model_files_here !== undefined && data.model_files_here > 0 && ( +
+ {data.model_files_here} model file{data.model_files_here === 1 ? "" : "s"} in this folder. Click "Use this folder" to scan it. +
+ )} + {data.truncated === true && ( +
+ Showing first {data.entries.length} entries. Narrow the path + to see more. +
+ )} + {data.entries.map((e) => ( + + ))} + + )} +
+ + {/* Footer */} + + +
+ + + + +
+
+
+
+ ); +} 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 dc4c210be..2f661c2e7 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -48,6 +48,7 @@ import type { VramFitStatus } from "@/lib/vram"; import { checkVramFit, estimateLoadingVram } from "@/lib/vram"; import { Add01Icon, Cancel01Icon, Folder02Icon, Search01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; +import { FolderBrowser } from "./folder-browser"; import { Trash2Icon } from "lucide-react"; import { type ReactNode, @@ -512,6 +513,7 @@ export function HubModelPicker({ const [folderError, setFolderError] = useState(null); const [showFolderInput, setShowFolderInput] = useState(false); const [folderLoading, setFolderLoading] = useState(false); + const [showFolderBrowser, setShowFolderBrowser] = useState(false); const refreshLocalModelsList = useCallback(() => { listLocalModels() @@ -537,11 +539,22 @@ export function HubModelPicker({ .catch(() => {}); }, []); - const handleAddFolder = useCallback(async () => { - const trimmed = folderInput.trim(); + const handleAddFolder = useCallback(async (overridePath?: string) => { + // Accept an explicit path so the folder browser can submit the + // chosen path in the same tick it calls `setFolderInput`; reading + // `folderInput` alone would race the state update. + const raw = overridePath !== undefined ? overridePath : folderInput; + const trimmed = raw.trim(); if (!trimmed || folderLoading) return; setFolderError(null); setFolderLoading(true); + // True when the request originated from the folder browser's + // ``onSelect`` (one-click "Use this folder"). In that flow the + // typed-input panel is closed, so the inline ``folderError`` + // paragraph is invisible. Surface failures via toast instead so + // the action doesn't appear to silently no-op when the backend + // rejects (denylisted path, sandbox 403, etc.). + const fromBrowser = overridePath !== undefined; try { const created = await addScanFolder(trimmed); // Backend returns existing row for duplicates, so deduplicate @@ -557,7 +570,11 @@ export function HubModelPicker({ // Background reconciliation with the server void refreshScanFolders(); } catch (e) { - setFolderError(e instanceof Error ? e.message : "Failed to add folder"); + const message = e instanceof Error ? e.message : "Failed to add folder"; + setFolderError(message); + if (fromBrowser) { + toast.error("Couldn't add folder", { description: message }); + } } finally { setFolderLoading(false); } @@ -984,30 +1001,42 @@ export function HubModelPicker({ {!showHfSection ? ( <> -
+
Custom Folders - +
+ + +
{/* Folder paths */} {scanFolders.map((f) => (
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" + className="shrink-0 rounded p-1 text-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive" > - +
))} @@ -1046,7 +1075,17 @@ export function HubModelPicker({ /> +
)} - {/* Empty state */} - {scanFolders.length === 0 && customFolderModels.length === 0 && !showFolderInput && ( - - )} + { + setFolderInput(picked); + setFolderError(null); + // One-click UX: the "Use this folder" button submits + // the scan folder directly. Pass the path explicitly + // because `folderInput` state hasn't flushed yet. + void handleAddFolder(picked); + }} + /> + {/* Models from custom folders */} {customFolderModels.map((m) => { diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index ddc0e9d39..9aacfc5af 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -247,6 +247,42 @@ export async function removeScanFolder(id: number): Promise { await parseJsonOrThrow(response); } +export interface BrowseEntry { + name: string; + has_models: boolean; + hidden: boolean; +} + +export interface BrowseFoldersResponse { + current: string; + parent: string | null; + entries: BrowseEntry[]; + suggestions: string[]; + truncated?: boolean; + model_files_here?: number; +} + +export async function browseFolders( + path?: string, + showHidden = false, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams(); + if (path !== undefined && path !== null) params.set("path", path); + if (showHidden) params.set("show_hidden", "true"); + const qs = params.toString(); + // Forward the AbortSignal through authFetch -> fetch so that a + // navigation cancelled in the FolderBrowser (rapid breadcrumb / row / + // hidden-toggle clicks) actually cancels the in-flight HTTP request + // server-side, instead of merely dropping the response client-side + // while the backend keeps walking large directory trees. + const response = await authFetch( + `/api/models/browse-folders${qs ? `?${qs}` : ""}`, + signal ? { signal } : undefined, + ); + return parseJsonOrThrow(response); +} + export async function listGgufVariants( repoId: string, hfToken?: string,