Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src-pyloid/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
59 changes: 59 additions & 0 deletions src-pyloid/services/model_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions src-pyloid/tests/test_model_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
151 changes: 113 additions & 38 deletions src/components/SettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", "");
}
Expand Down Expand Up @@ -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 <K extends keyof Settings>(key: K, value: Settings[K]) => {
const current = settingsRef.current;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -312,6 +332,7 @@ export function SettingsTab() {
currentModel={settings.model}
statuses={modelStatus}
onChange={handleModelChange}
onDelete={handleDeleteModel}
/>
</SectionBlock>

Expand Down Expand Up @@ -463,7 +484,7 @@ export function SettingsTab() {
tone="danger"
description="Wipe local state and start over. None of this can be undone."
>
<DangerZone />
<DangerZone onModelsCleared={refreshModelStatus} />
</Section>

<footer className="pt-8 border-t border-border flex items-center justify-between font-mono text-[11px] text-cream-muted/60">
Expand Down Expand Up @@ -722,11 +743,13 @@ function ModelPicker({
currentModel,
statuses,
onChange,
onDelete,
}: {
models: string[];
currentModel: string;
statuses: Record<string, boolean>;
onChange: (m: string) => void;
onDelete: (m: string) => void;
}) {
return (
<div className="border border-border rounded-md overflow-hidden bg-surface">
Expand All @@ -736,18 +759,14 @@ function ModelPicker({
const isActive = model === currentModel;
const cacheState =
cached === undefined ? "unknown" : cached ? "cached" : "absent";
const canDelete = cacheState === "cached" && !isActive;
return (
<button
<div
key={model}
type="button"
onClick={() => onChange(model)}
aria-pressed={isActive}
className={cn(
"w-full text-left transition-colors flex items-stretch group",
"transition-colors flex items-stretch group",
i > 0 && "border-t border-border",
isActive
? "bg-accent-500/[0.04] hover:bg-accent-500/[0.06]"
: "hover:bg-secondary/40"
isActive ? "bg-accent-500/[0.04]" : "hover:bg-secondary/40"
)}
>
<div
Expand All @@ -757,7 +776,12 @@ function ModelPicker({
)}
aria-hidden
/>
<div className="flex-1 flex items-center gap-4 px-5 py-4 min-w-0">
<button
type="button"
onClick={() => onChange(model)}
aria-pressed={isActive}
className="flex-1 text-left flex items-center gap-4 pl-5 pr-3 py-4 min-w-0"
>
<ModelStatusDot active={isActive} cached={cacheState} />
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-3 flex-wrap">
Expand Down Expand Up @@ -785,14 +809,58 @@ function ModelPicker({
<DotMeter label="speed" value={meta.speed} />
<DotMeter label="accuracy" value={meta.accuracy} />
</div>
</div>
</button>
</button>
{canDelete && <ModelDeleteButton model={model} onDelete={onDelete} />}
</div>
);
})}
</div>
);
}

function ModelDeleteButton({
model,
onDelete,
}: {
model: string;
onDelete: (m: string) => void;
}) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<button
type="button"
className="flex-shrink-0 self-stretch px-4 flex items-center text-cream-muted/40 hover:text-destructive hover:bg-destructive/[0.06] transition-colors"
aria-label={`Delete ${model}`}
title={`Delete ${model} from disk`}
>
<Trash2 className="w-4 h-4" />
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="font-display">
Delete {model}?
</AlertDialogTitle>
<AlertDialogDescription>
Removes the downloaded model from disk to free up space. You can
re-download it anytime by selecting it again.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(model)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

function ModelStatusDot({
active,
cached,
Expand Down Expand Up @@ -1197,7 +1265,7 @@ function PathRow({
);
}

function DangerZone() {
function DangerZone({ onModelsCleared }: { onModelsCleared: () => void }) {
const [deleteAppData, setDeleteAppData] = useState(true);
const [deleteModels, setDeleteModels] = useState(false);
const [deleteCudaLibs, setDeleteCudaLibs] = useState(false);
Expand All @@ -1217,11 +1285,18 @@ function DangerZone() {
const message =
parts.length > 0 ? `Deleted: ${parts.join(", ")}` : "Nothing deleted";

toast.success(`${message} — returning to setup`);
setTimeout(() => {
window.location.hash = "/onboarding";
window.location.reload();
}, 500);
// Resetting app data wipes settings/onboarding, so we must return to
// setup. Deleting only models/CUDA leaves the app usable — stay put.
if (deleteAppData) {
toast.success(`${message} — returning to setup`);
setTimeout(() => {
window.location.hash = "/onboarding";
window.location.reload();
}, 500);
} else {
toast.success(message);
if (deleteModels) onModelsCleared();
}
} catch (err) {
console.error("Failed to delete data:", err);
toast.error("Failed to delete data");
Expand Down
4 changes: 4 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ export const api = {
return rpc.call("clear_model_cache");
},

async deleteModel(modelName: string): Promise<{ success: boolean; deleted_bytes: number; deleted_model: string | null; error: string | null }> {
return rpc.call("delete_model", { model_name: modelName });
},

async getModelCacheDir(): Promise<{ path: string }> {
return rpc.call("get_model_cache_dir");
},
Expand Down
Loading