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/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index b64f850f10..cf8b4cd54e 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,20 @@ 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 { + 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 { useDebouncedValue, useGpuInfo, @@ -35,7 +45,13 @@ import { checkVramFit, estimateLoadingVram } from "@/lib/vram"; import { 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, @@ -135,7 +151,7 @@ function ModelRow({ if (vramTooltipText) { return ( - {content} + {content} {label} {vramTooltipText} @@ -147,7 +163,7 @@ function ModelRow({ if (tooltipText) { return ( - {content} + {content} {tooltipText} @@ -192,7 +208,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 +222,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 +243,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 +262,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 +297,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 +313,7 @@ function GgufVariantExpander({ } if (error) { - return ( -
{error}
- ); + return
{error}
; } if (!sortedVariants || sortedVariants.length === 0) { @@ -321,7 +342,9 @@ 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} + /> +
+
- -
- ))} + ))} ) : null} @@ -733,13 +843,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 +867,54 @@ 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 && customFolderModels.length > 0 ? ( + <> + 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 +940,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 +987,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 +1015,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 +1034,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 +1069,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 +1093,10 @@ export function HubModelPicker({ { e.preventDefault(); handleDeleteConfirm(); }} + onClick={(e) => { + e.preventDefault(); + handleDeleteConfirm(); + }} > {deleting ? "Deleting..." : "Yes"} @@ -917,7 +1125,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 +1150,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 +1200,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 +1246,9 @@ export function LoraModelPicker({ }} tooltipText={ <> - {adapter.name} + + {adapter.name} + {adapter.id} @@ -1029,7 +1260,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/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..a47a2c6d92 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,22 @@ const CompareContent = memo(function CompareContent({ pairId, models, loraModels, -}: { pairId: string; models: ModelOption[]; loraModels: LoraModelOption[] }): ReactElement { +}: { + pairId: string; + models: ModelOption[]; + loraModels: LoraModelOption[]; +}): ReactElement { const isLoraCompare = useIsLoraCompare(); - return isLoraCompare - ? - : ; + return isLoraCompare ? ( + + ) : ( + + ); }); /** Fast path: same model, adapter on/off, simultaneous generation. */ @@ -179,7 +199,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 +218,11 @@ const LoraCompareContent = memo(function LoraCompareContent({
- + @@ -209,7 +235,11 @@ const LoraCompareContent = memo(function LoraCompareContent({
- + @@ -229,7 +259,11 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ pairId, models, loraModels, -}: { pairId: string; models: ModelOption[]; loraModels: LoraModelOption[] }): ReactElement { +}: { + pairId: string; + models: ModelOption[]; + loraModels: LoraModelOption[]; +}): ReactElement { const handlesRef = useRef>({}); const [model1ThreadId, setModel1ThreadId] = useState(); const [model2ThreadId, setModel2ThreadId] = useState(); @@ -241,7 +275,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 +289,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 +320,13 @@ 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, + }) + } variant="ghost" size="sm" className="max-w-[50%]" @@ -303,7 +352,13 @@ 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, + }) + } variant="ghost" size="sm" className="max-w-[50%]" @@ -322,7 +377,11 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
- +
@@ -364,8 +423,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 +439,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 +486,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 +507,7 @@ export function ChatPage(): ReactElement { loadingModel, loadProgress, loadToastDismissed, - } = - useChatModelRuntime(); + } = useChatModelRuntime(); const refreshRef = useRef(refresh); const selectModelRef = useRef(selectModel); @@ -455,11 +520,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 +549,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 +579,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 +630,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 +654,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 +700,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 +741,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", { @@ -767,9 +862,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 +906,12 @@ export function ChatPage(): ReactElement { newThreadNonce={view.newThreadNonce} /> ) : ( - + )} @@ -832,6 +934,7 @@ export function ChatPage(): ReactElement { }); } }} + onFoldersChange={refreshLocalModels} /> diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index 3f3557b34f..6e62c7f9c5 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,11 +10,31 @@ 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, Delete02Icon, FloppyDiskIcon, + FolderSearchIcon, PencilEdit01Icon, Settings02Icon, SlidersHorizontalIcon, @@ -33,22 +43,19 @@ 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 { useCallback, useEffect, useMemo, useState } from "react"; +import { + type ScanFolderInfo, + addScanFolder, + listScanFolders, + removeScanFolder, +} from "./api/chat-api"; +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 +181,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( @@ -255,6 +266,108 @@ function CollapsibleSection({ ); } +function ModelFoldersSection({ + onFoldersChange, +}: { onFoldersChange?: () => void }) { + const [folders, setFolders] = useState([]); + const [input, setInput] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const refresh = useCallback(() => { + listScanFolders() + .then(setFolders) + .catch(() => {}); + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const handleAdd = async () => { + const trimmed = input.trim(); + if (!trimmed) return; + setError(null); + setLoading(true); + try { + await addScanFolder(trimmed); + setInput(""); + refresh(); + onFoldersChange?.(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to add folder"); + } finally { + setLoading(false); + } + }; + + const handleRemove = async (id: number) => { + try { + await removeScanFolder(id); + onFoldersChange?.(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to remove folder"); + } finally { + refresh(); + } + }; + + return ( + +
+ {folders.length > 0 && ( +
+ {folders.map((f) => ( +
+ + {f.path} + + +
+ ))} +
+ )} +
+ { + setInput(e.target.value); + setError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") handleAdd(); + }} + placeholder="/path/to/models" + className="h-7 flex-1 text-xs font-mono" + disabled={loading} + /> + +
+ {error &&

{error}

} +
+
+ ); +} + interface ChatSettingsPanelProps { open: boolean; onOpenChange?: (open: boolean) => void; @@ -263,6 +376,7 @@ interface ChatSettingsPanelProps { autoTitle: boolean; onAutoTitleChange: (enabled: boolean) => void; onReloadModel?: () => void; + onFoldersChange?: () => void; } export function ChatSettingsPanel({ @@ -273,16 +387,21 @@ export function ChatSettingsPanel({ autoTitle, onAutoTitleChange, onReloadModel, + onFoldersChange, }: ChatSettingsPanelProps) { 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 +411,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 +538,356 @@ export function ChatSettingsPanel({
{/* mt-4 matches the Playground sidebar gap (SidebarHeader py-3 + SidebarGroup pt-1) */}
-
- - - -
-
- -
-