From a3d0d09177babaada3b58cc00097022afde233b1 Mon Sep 17 00:00:00 2001 From: raahim arbaz Date: Mon, 1 Jun 2026 22:58:34 +0500 Subject: [PATCH] feat: add per-model deletion and fix reset onboarding redirect Lets users delete individual downloaded Whisper models from the model picker without wiping all models or being kicked back to onboarding. - model_manager: new delete_model() removing a single model's HF cache folder - server: delete_model RPC - api: deleteModel wrapper - SettingsTab: inline trash button on downloaded non-active models with confirm dialog; DangerZone only returns to onboarding when app data is deleted (models/CUDA-only stays on settings) Closes #26 --- src-pyloid/server.py | 7 ++ src-pyloid/services/model_manager.py | 59 ++++++++++ src-pyloid/tests/test_model_manager.py | 57 ++++++++++ src/components/SettingsTab.tsx | 151 ++++++++++++++++++------- src/lib/api.ts | 4 + 5 files changed, 240 insertions(+), 38 deletions(-) diff --git a/src-pyloid/server.py b/src-pyloid/server.py index 964e479..6f8c5da 100644 --- a/src-pyloid/server.py +++ b/src-pyloid/server.py @@ -558,6 +558,13 @@ async def clear_model_cache(): return result +@server.method() +async def delete_model(model_name: str): + """Delete a single cached Whisper model from the cache directory.""" + manager = get_model_manager() + return manager.delete_model(model_name) + + # ═══════════════════════════════════════════════════════════════════════════════ # Meetings feature — recordings + LLM config # Thin wrappers over AppController.meetings (MeetingsController). diff --git a/src-pyloid/services/model_manager.py b/src-pyloid/services/model_manager.py index 8e3acb2..d70902a 100644 --- a/src-pyloid/services/model_manager.py +++ b/src-pyloid/services/model_manager.py @@ -615,6 +615,65 @@ def clear_cache(self) -> dict: "error": str(e) } + def delete_model(self, model_name: str) -> dict: + """ + Delete a single cached Whisper model from the HuggingFace cache directory. + + Returns: + dict with: + - success: bool indicating if operation succeeded + - deleted_bytes: total bytes freed + - deleted_model: name of the model deleted, or None + - error: error message if failed + """ + import shutil + + repo_id = MODEL_REPOS.get(model_name) + if repo_id is None: + log.error("Refusing to delete unknown model", model=model_name) + return { + "success": False, + "deleted_bytes": 0, + "deleted_model": None, + "error": "unknown model", + } + + log.info("Deleting model", model=model_name) + + try: + cache_dir = Path.home() / ".cache" / "huggingface" / "hub" + cache_folder_name = f"models--{repo_id.replace('/', '--')}" + model_cache_path = cache_dir / cache_folder_name + + if not model_cache_path.exists(): + log.info("Model not cached, nothing to delete", model=model_name) + return { + "success": True, + "deleted_bytes": 0, + "deleted_model": None, + "error": None, + } + + size = sum(f.stat().st_size for f in model_cache_path.rglob("*") if f.is_file()) + log.info("Deleting model cache", model=model_name, path=str(model_cache_path), size_bytes=size) + shutil.rmtree(model_cache_path) + + return { + "success": True, + "deleted_bytes": size, + "deleted_model": model_name, + "error": None, + } + + except Exception as e: + log.error("Failed to delete model", model=model_name, error=str(e)) + return { + "success": False, + "deleted_bytes": 0, + "deleted_model": None, + "error": str(e), + } + # Singleton instance _model_manager: Optional[ModelManager] = None diff --git a/src-pyloid/tests/test_model_manager.py b/src-pyloid/tests/test_model_manager.py index d2640fc..ad757c8 100644 --- a/src-pyloid/tests/test_model_manager.py +++ b/src-pyloid/tests/test_model_manager.py @@ -496,3 +496,60 @@ def test_update_tracks_bytes_even_when_self_n_stays_zero(self): # tqdm's self.n may stay at 0 because disabled, but our counter must work assert bar._vf_n == 400, \ f"expected our counter to track bytes, got {bar._vf_n}" + + +class TestDeleteModel: + """Tests for single-model deletion. + + delete_model deletes only the HuggingFace cache folder for one model, + leaving every other model untouched (unlike clear_cache which wipes all). + HOME is redirected to a tmp dir so we never touch the real cache. + """ + + def _make_cached(self, home: Path, model_name: str, size: int = 4096) -> Path: + """Create a fake HF cache folder for a model with one file of `size`.""" + from services.model_manager import MODEL_REPOS + folder = "models--" + MODEL_REPOS[model_name].replace("/", "--") + path = home / ".cache" / "huggingface" / "hub" / folder + (path / "snapshots").mkdir(parents=True) + (path / "snapshots" / "model.bin").write_bytes(b"x" * size) + return path + + def test_delete_removes_only_target_model(self, tmp_path, monkeypatch): + from services.model_manager import ModelManager + + monkeypatch.setenv("HOME", str(tmp_path)) + base_path = self._make_cached(tmp_path, "base", size=4096) + small_path = self._make_cached(tmp_path, "small", size=8192) + + result = ModelManager().delete_model("base") + + assert result["success"] is True + assert result["deleted_model"] == "base" + assert result["deleted_bytes"] == 4096 + assert result["error"] is None + assert not base_path.exists() + # The other model must survive. + assert small_path.exists() + + def test_delete_is_idempotent_when_not_cached(self, tmp_path, monkeypatch): + from services.model_manager import ModelManager + + monkeypatch.setenv("HOME", str(tmp_path)) + + result = ModelManager().delete_model("base") + + assert result["success"] is True + assert result["deleted_bytes"] == 0 + assert result["deleted_model"] is None + + def test_delete_unknown_model_fails(self, tmp_path, monkeypatch): + from services.model_manager import ModelManager + + monkeypatch.setenv("HOME", str(tmp_path)) + + result = ModelManager().delete_model("bogus-model-xyz") + + assert result["success"] is False + assert result["error"] == "unknown model" + assert result["deleted_model"] is None diff --git a/src/components/SettingsTab.tsx b/src/components/SettingsTab.tsx index c96ee27..9918b8c 100644 --- a/src/components/SettingsTab.tsx +++ b/src/components/SettingsTab.tsx @@ -92,6 +92,13 @@ function formatModelSize(mb: number): string { return `${(mb / 1024).toFixed(1)} GB`; } +function formatBytes(bytes: number): string { + if (!bytes) return "0 MB"; + const mb = bytes / (1024 * 1024); + if (mb < 1000) return `${mb.toFixed(0)} MB`; + return `${(mb / 1024).toFixed(1)} GB`; +} + function shortenGpuName(name: string): string { return name.replace("NVIDIA ", "").replace(" Laptop GPU", ""); } @@ -147,29 +154,25 @@ export function SettingsTab() { .catch(() => setModelCacheDir(null)); }, []); - useEffect(() => { + const refreshModelStatus = useCallback(async () => { if (!options) return; - let cancelled = false; - const fetchAll = async () => { - const results = await Promise.all( - options.models.map(async (m) => { - try { - const info = await api.getModelInfo(m); - return [m, info.cached] as const; - } catch { - return [m, false] as const; - } - }) - ); - if (cancelled) return; - setModelStatus(Object.fromEntries(results)); - }; - fetchAll(); - return () => { - cancelled = true; - }; + const results = await Promise.all( + options.models.map(async (m) => { + try { + const info = await api.getModelInfo(m); + return [m, info.cached] as const; + } catch { + return [m, false] as const; + } + }) + ); + setModelStatus(Object.fromEntries(results)); }, [options]); + useEffect(() => { + refreshModelStatus(); + }, [refreshModelStatus]); + const updateSetting = useCallback( async (key: K, value: Settings[K]) => { const current = settingsRef.current; @@ -226,6 +229,23 @@ export function SettingsTab() { setPendingModel(null); }, []); + const handleDeleteModel = useCallback(async (model: string) => { + // Active model is guarded in the UI; this is belt-and-suspenders. + if (model === settingsRef.current?.model) return; + try { + const res = await api.deleteModel(model); + if (res.success) { + setModelStatus((prev) => ({ ...prev, [model]: false })); + toast.success(`Deleted ${model} — freed ${formatBytes(res.deleted_bytes)}`); + } else { + toast.error(res.error ?? "Failed to delete model"); + } + } catch (err) { + console.error("Failed to delete model:", err); + toast.error("Failed to delete model"); + } + }, []); + const validateHotkey = useCallback( async ( hotkey: string, @@ -312,6 +332,7 @@ export function SettingsTab() { currentModel={settings.model} statuses={modelStatus} onChange={handleModelChange} + onDelete={handleDeleteModel} /> @@ -463,7 +484,7 @@ export function SettingsTab() { tone="danger" description="Wipe local state and start over. None of this can be undone." > - +