diff --git a/studio/backend/hub/routes/inventory.py b/studio/backend/hub/routes/inventory.py index fcfdd0ad14..4b6c179a2b 100644 --- a/studio/backend/hub/routes/inventory.py +++ b/studio/backend/hub/routes/inventory.py @@ -29,6 +29,7 @@ from hub.schemas.inventory import ( DeleteCachedModelResponse, GgufVariantsResponse, LocalModelListResponse, + ModelsFolderResponse, RecommendedFoldersResponse, RemoveScanFolderResponse, ScanFolderInfo, @@ -91,6 +92,11 @@ def browse_folders( return folder_browser.browse_folders_response(path, show_hidden) +@router.get("/models-folder", response_model = ModelsFolderResponse) +def get_models_folder(current_subject: str = Depends(get_current_subject)): + return local_inventory.get_models_folder_response() + + @router.get("/gguf-variants", response_model = GgufVariantsResponse) async def get_gguf_variants( repo_id: str = Query( diff --git a/studio/backend/hub/schemas/inventory.py b/studio/backend/hub/schemas/inventory.py index c333c7ca89..44ff545e76 100644 --- a/studio/backend/hub/schemas/inventory.py +++ b/studio/backend/hub/schemas/inventory.py @@ -284,3 +284,13 @@ class BrowseFoldersResponse(BaseModel): "they contain only files, no subdirectories)." ), ) + + +class ModelsFolderResponse(BaseModel): + """The directory where downloaded models are stored (the active HF hub + cache, honoring ``HF_HOME`` / ``HF_HUB_CACHE``).""" + + path: str = Field( + ..., + description = "Path to the model download directory.", + ) diff --git a/studio/backend/hub/services/models/local_inventory.py b/studio/backend/hub/services/models/local_inventory.py index 94e0913ac9..a3782efead 100644 --- a/studio/backend/hub/services/models/local_inventory.py +++ b/studio/backend/hub/services/models/local_inventory.py @@ -670,6 +670,31 @@ async def list_local_models_response(models_dir: str = "./models") -> LocalModel ) +def get_models_folder_response() -> dict: + """Return the directory where downloaded models are stored. + + This is the active HF hub cache (honors ``HF_HOME`` / ``HF_HUB_CACHE``); + the desktop app reveals it in the OS file manager. + """ + path = _resolve_hf_cache_dir() + # Create it if missing so "Open folder" works before the first download: + # HF builds the cache lazily, and studio only pre-creates the *default* + # dir, not a user's explicit HF_HOME / HF_HUB_CACHE. + try: + path.mkdir(parents = True, exist_ok = True) + except OSError as e: + raise HTTPException( + status_code = 500, + detail = f"Failed to create models folder: {path}: {e}", + ) from e + if not path.is_dir(): + raise HTTPException( + status_code = 500, + detail = f"Models folder path is not a directory: {path}", + ) + return {"path": str(path)} + + def get_scan_folders_response() -> dict: return {"folders": list_scan_folders()} diff --git a/studio/backend/hub/tests/test_model_services.py b/studio/backend/hub/tests/test_model_services.py index df97b9cf97..1eb7042e4e 100644 --- a/studio/backend/hub/tests/test_model_services.py +++ b/studio/backend/hub/tests/test_model_services.py @@ -181,6 +181,55 @@ def test_browse_folders_hides_sensitive_dirs(monkeypatch, tmp_path): assert ".ssh" not in names +def test_get_models_folder_response_creates_and_returns_dir(monkeypatch, tmp_path): + # The endpoint creates the cache dir on demand so the desktop "Open folder" + # action works even before the first download. + target = tmp_path / "hub" + monkeypatch.setattr(local_inventory, "_resolve_hf_cache_dir", lambda: target) + + response = local_inventory.get_models_folder_response() + + assert response == {"path": str(target)} + assert target.is_dir() + + +def test_get_models_folder_response_reports_create_failure(monkeypatch, tmp_path): + target = tmp_path / "hub" + target.write_text("not a directory") + monkeypatch.setattr(local_inventory, "_resolve_hf_cache_dir", lambda: target) + + with pytest.raises(HTTPException) as exc_info: + local_inventory.get_models_folder_response() + + assert exc_info.value.status_code == 500 + assert "Failed to create models folder" in exc_info.value.detail + + +def test_get_models_folder_response_requires_directory(monkeypatch, tmp_path): + class MissingPath: + def __init__(self, value: Path): + self.value = value + + def mkdir(self, *, parents: bool, exist_ok: bool): + assert parents is True + assert exist_ok is True + + def is_dir(self): + return False + + def __str__(self): + return str(self.value) + + target = MissingPath(tmp_path / "hub") + monkeypatch.setattr(local_inventory, "_resolve_hf_cache_dir", lambda: target) + + with pytest.raises(HTTPException) as exc_info: + local_inventory.get_models_folder_response() + + assert exc_info.value.status_code == 500 + assert "not a directory" in exc_info.value.detail + + def test_contained_link_path_confines_to_link_dir(tmp_path): link_dir = tmp_path / "ollama" / ".studio_links" / "abc123" diff --git a/studio/frontend/src/features/hub/catalog/on-device-folders-dialog.tsx b/studio/frontend/src/features/hub/catalog/on-device-folders-dialog.tsx index 1769f8861a..20a4eca4dd 100644 --- a/studio/frontend/src/features/hub/catalog/on-device-folders-dialog.tsx +++ b/studio/frontend/src/features/hub/catalog/on-device-folders-dialog.tsx @@ -23,11 +23,14 @@ import { listScanFolders, removeScanFolder, } from "@/features/hub/inventory"; +import { openModelsDir } from "@/features/native-intents/api"; +import { isTauri } from "@/lib/api-base"; import { cn } from "@/lib/utils"; import { Delete02Icon, FileSearchIcon, FolderAddIcon, + FolderExportIcon, FolderOpenIcon, FolderSearchIcon, PlusSignIcon, @@ -138,6 +141,16 @@ export function OnDeviceFoldersDialog({ [handleInventoryChanged, pending], ); + // Scan folders are arbitrary paths that may be moved or deleted after they + // were registered, so surface the command's failure as a toast. + const handleOpen = useCallback(async (folder: ScanFolderInfo) => { + try { + await openModelsDir(folder.path); + } catch (err) { + toast.error("Couldn't open location", { description: formatError(err) }); + } + }, []); + const handleRemove = useCallback( async (folder: ScanFolderInfo) => { const key = `remove:${folder.id}` as const; @@ -333,6 +346,27 @@ export function OnDeviceFoldersDialog({ + {isTauri ? ( + + + void handleOpen(folder)} + className="inline-flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + > + + + + + Open in file manager + + + ) : null} { export async function openPathToken(token: string): Promise { return invokeNative("open_path_token", { token }); } + +// Open a backend-resolved directory (e.g. the models/HF cache folder) in the +// OS file manager. The Tauri command validates the path is a real directory. +export async function openModelsDir(path: string): Promise { + return invokeNative("open_models_dir", { path }); +} diff --git a/studio/frontend/src/features/settings/api/models-folder.ts b/studio/frontend/src/features/settings/api/models-folder.ts new file mode 100644 index 0000000000..13e8c065c3 --- /dev/null +++ b/studio/frontend/src/features/settings/api/models-folder.ts @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { authFetch } from "@/features/auth"; +import { readFastApiError } from "@/lib/format-fastapi-error"; + +export type ModelsFolder = { + path: string; +}; + +// The path is resolved once at backend startup and never changes, so cache it +// and dedupe concurrent loads (same shape as the sibling settings loaders). +let cachedModelsFolder: ModelsFolder | null = null; +let inFlightModelsFolder: Promise | null = null; + +async function fetchModelsFolder(): Promise { + const res = await authFetch("/api/hub/models-folder"); + if (!res.ok) { + throw new Error( + await readFastApiError(res, "Failed to load models folder"), + ); + } + const data = (await res.json()) as { path: string }; + return { path: data.path }; +} + +export async function loadModelsFolder(): Promise { + if (cachedModelsFolder) { + return cachedModelsFolder; + } + inFlightModelsFolder ??= fetchModelsFolder() + .then((folder) => { + cachedModelsFolder = folder; + return folder; + }) + .finally(() => { + inFlightModelsFolder = null; + }); + return inFlightModelsFolder; +} diff --git a/studio/frontend/src/features/settings/tabs/general-tab.tsx b/studio/frontend/src/features/settings/tabs/general-tab.tsx index 64408c9e90..0ecd6af189 100644 --- a/studio/frontend/src/features/settings/tabs/general-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/general-tab.tsx @@ -13,6 +13,11 @@ import { import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { usePlatformStore } from "@/config/env"; +import { isTauri } from "@/lib/api-base"; +import { openModelsDir } from "@/features/native-intents/api"; +import { copyToClipboard } from "@/lib/copy-to-clipboard"; +import { toast } from "@/lib/toast"; +import { loadModelsFolder, type ModelsFolder } from "../api/models-folder"; import { resetOnboardingDone } from "@/features/auth"; import { useChatRuntimeStore } from "@/features/chat"; import { @@ -134,6 +139,7 @@ export function GeneralTab() { null, ); const [isSavingHelperPrecache, setIsSavingHelperPrecache] = useState(false); + const [modelsFolder, setModelsFolder] = useState(null); const draftRef = useRef(draftToken); useEffect(() => { @@ -199,6 +205,43 @@ export function GeneralTab() { }; }, [t]); + useEffect(() => { + let cancelled = false; + void loadModelsFolder() + .then((folder) => { + if (cancelled) return; + setModelsFolder(folder); + }) + .catch(() => { + // Non-critical: leave the row hidden if the path can't be resolved. + }); + return () => { + cancelled = true; + }; + }, []); + + // Desktop opens the folder in the OS file manager; the browser can't, so it + // falls back to copying the path (which is the info users actually want). + const handleModelsFolder = async () => { + const folder = modelsFolder; + if (!folder) return; + if (isTauri) { + try { + await openModelsDir(folder.path); + } catch (error) { + toast.error(t("settings.general.storage.openError"), { + description: error instanceof Error ? error.message : undefined, + }); + } + return; + } + if (await copyToClipboard(folder.path)) { + toast.success(t("settings.general.storage.copied")); + } else { + toast.error(t("settings.general.storage.copyError")); + } + }; + const saveHelperPrecache = async (enabled: boolean) => { setIsSavingHelperPrecache(true); setHelperPrecacheError(null); @@ -287,6 +330,33 @@ export function GeneralTab() { + {modelsFolder ? ( + + + + + {modelsFolder.path} + + void handleModelsFolder()} + > + {isTauri + ? t("settings.general.storage.openAction") + : t("settings.general.storage.copyAction")} + + + + + ) : null} + ) -> Vec { } } -/// Open the Unsloth Studio directory in the system file manager. -#[tauri::command] -pub fn open_logs_dir() -> Result<(), String> { - let home = dirs::home_dir().ok_or("Could not determine home directory")?; - let dir = home.join(".unsloth").join("studio"); - - if !dir.exists() { +/// Open an existing directory in the system file manager. Validates the path +/// up front so callers get a clean error instead of a raw OS failure. +fn open_existing_dir(dir: &std::path::Path) -> Result<(), String> { + if !dir.is_dir() { return Err(format!("Directory does not exist: {}", dir.display())); } + open::that(dir).map_err(|e| format!("Failed to open directory: {}", e)) +} - open::that(&dir).map_err(|e| format!("Failed to open directory: {}", e)) +/// Open the Unsloth Studio directory in the system file manager. +#[tauri::command] +pub fn open_logs_dir(window: tauri::WebviewWindow) -> Result<(), String> { + crate::native_intents::ensure_main_window(&window)?; + let home = dirs::home_dir().ok_or("Could not determine home directory")?; + open_existing_dir(&home.join(".unsloth").join("studio")) +} + +/// Open a models directory (resolved by the backend, e.g. the HF cache) in the +/// system file manager. +#[tauri::command] +pub fn open_models_dir(window: tauri::WebviewWindow, path: String) -> Result<(), String> { + crate::native_intents::ensure_main_window(&window)?; + open_existing_dir(std::path::Path::new(&path)) } /// Start the first-launch installation process. diff --git a/studio/src-tauri/src/main.rs b/studio/src-tauri/src/main.rs index 498cd81579..4ed12051ed 100644 --- a/studio/src-tauri/src/main.rs +++ b/studio/src-tauri/src/main.rs @@ -204,6 +204,7 @@ fn main() { commands::check_health, commands::get_server_logs, commands::open_logs_dir, + commands::open_models_dir, commands::start_backend_update, commands::start_managed_repair, commands::cancel_pending_elevation, diff --git a/studio/src-tauri/src/native_intents.rs b/studio/src-tauri/src/native_intents.rs index 30e11aa310..dccbba7083 100644 --- a/studio/src-tauri/src/native_intents.rs +++ b/studio/src-tauri/src/native_intents.rs @@ -258,7 +258,7 @@ fn prune_expired(inner: &mut NativeIntakeInner) { .retain(|intent| intent.path.expires_at_ms > now); } -fn ensure_main_window(window: &WebviewWindow) -> Result<(), String> { +pub(crate) fn ensure_main_window(window: &WebviewWindow) -> Result<(), String> { if window.label() == "main" { Ok(()) } else {