diff --git a/README.md b/README.md
index 6588a36..1d5fadb 100644
--- a/README.md
+++ b/README.md
@@ -162,6 +162,7 @@ a clear message) when ComfyUI Manager is absent or there are no disabled package
- Summary bar with counts for each tier across all model types
- Sections per model type (checkpoints, vae, controlnet, …)
- Per-model table showing execution count, last used date, and status
+- Select one or more installed models and permanently delete them after confirmation
### API
diff --git a/__init__.py b/__init__.py
index cf66a7e..87bca5e 100644
--- a/__init__.py
+++ b/__init__.py
@@ -112,6 +112,32 @@ async def get_model_stats(request):
return web.json_response({"error": "internal error"}, status=500)
+@routes.post("/nodes-stats/models/delete")
+async def delete_models(request):
+ """Delete one or more currently installed model files.
+
+ The mapper validates each supplied (type, name) pair against ComfyUI's
+ current model folders before unlinking it, rather than trusting a path sent
+ by the browser.
+ """
+ try:
+ data = await request.json()
+ models = data.get("models")
+ if not isinstance(models, list) or not models:
+ return web.json_response({"error": "models must be a non-empty list"}, status=400)
+ if len(models) > 500:
+ return web.json_response({"error": "too many models"}, status=400)
+ results = await asyncio.get_event_loop().run_in_executor(
+ None, model_mapper.delete_models, models
+ )
+ deleted = [r for r in results if r.get("status") == "deleted"]
+ failed = [r for r in results if r.get("status") != "deleted"]
+ return web.json_response({"deleted": deleted, "failed": failed})
+ except Exception:
+ logger.error("nodes-stats: error deleting models", exc_info=True)
+ return web.json_response({"error": "internal error"}, status=500)
+
+
@routes.post("/nodes-stats/reset")
async def reset_stats(request):
try:
diff --git a/js/nodes_stats.js b/js/nodes_stats.js
index 6a0d7a6..c107f71 100644
--- a/js/nodes_stats.js
+++ b/js/nodes_stats.js
@@ -258,6 +258,7 @@ async function showStatsDialog(initialTab = "nodes") {
wireWorkflowButtons(dialog);
wireWhitelistButtons(dialog);
wireNativeEnableButtons(dialog);
+ wireModelDeleteControls(dialog);
switchTab(TABS.includes(initialTab) ? initialTab : "nodes");
@@ -432,6 +433,11 @@ function buildModelsTabContent(modelData) {
return html;
}
+ html += `
+ No models selected
+
+
`;
+
for (const group of modelData) {
if (group.models.length === 0) continue;
const title = group.model_type.charAt(0).toUpperCase() + group.model_type.slice(1).replace(/_/g, " ");
@@ -443,30 +449,114 @@ function buildModelsTabContent(modelData) {
}
function buildModelTable(models) {
- let html = `
+ let html = `
+
Model
-
Executions
-
Last Used
-
Status
+
Executions
+
Last Used
+
Status
+
`;
for (const m of models) {
const meta = STATUS_META[m.status] || STATUS_META.used;
const lastSeen = m.last_seen ? new Date(m.last_seen).toLocaleDateString() : "—";
+ const selectable = m.installed ? "" : " disabled";
+ const action = m.installed
+ ? ``
+ : `—`;
+
html += `
-
${escapeHtml(m.model_name)}
+
+
${escapeHtml(m.model_name)}
${m.count}
${lastSeen}
${meta.label}
+
${action}
`;
}
- html += `
`;
+ html += `
`;
return html;
}
+// Model deletion is intentionally separate from package disable: the selected
+// file is deleted permanently, so every request is confirmed and the backend
+// validates that it belongs to a configured ComfyUI model folder.
+function wireModelDeleteControls(dialog) {
+ const selectionCount = dialog.querySelector("#ns-model-selection-count");
+ const deleteSelected = dialog.querySelector("#ns-delete-selected-models");
+ if (!selectionCount || !deleteSelected) return;
+
+ const selectedModels = () => [...dialog.querySelectorAll(".ns-model-select:checked")].map((box) => ({
+ model_type: box.dataset.modelType,
+ model_name: box.dataset.modelName,
+ }));
+
+ const updateSelection = () => {
+ const selected = dialog.querySelectorAll(".ns-model-select:checked").length;
+ selectionCount.textContent = selected ? `${selected} model${selected === 1 ? "" : "s"} selected` : "No models selected";
+ deleteSelected.disabled = selected === 0;
+ dialog.querySelectorAll(".ns-model-select-all").forEach((master) => {
+ const choices = [...master.closest("table").querySelectorAll(".ns-model-select:not(:disabled)")];
+ const checked = choices.filter((box) => box.checked).length;
+ master.checked = choices.length > 0 && checked === choices.length;
+ master.indeterminate = checked > 0 && checked < choices.length;
+ master.disabled = choices.length === 0;
+ });
+ };
+
+ dialog.querySelectorAll(".ns-model-select").forEach((box) => box.addEventListener("change", updateSelection));
+ dialog.querySelectorAll(".ns-model-select-all").forEach((master) => master.addEventListener("change", () => {
+ master.closest("table").querySelectorAll(".ns-model-select:not(:disabled)").forEach((box) => {
+ box.checked = master.checked;
+ });
+ updateSelection();
+ }));
+ dialog.querySelectorAll(".ns-delete-model-btn").forEach((button) => button.addEventListener("click", (event) => {
+ event.stopPropagation();
+ deleteModels([{ model_type: button.dataset.modelType, model_name: button.dataset.modelName }], dialog);
+ }));
+ deleteSelected.addEventListener("click", () => deleteModels(selectedModels(), dialog));
+ updateSelection();
+}
+
+async function deleteModels(models, dialog) {
+ if (!models.length) return;
+ const label = models.length === 1 ? `"${models[0].model_name}"` : `${models.length} selected models`;
+ if (!confirm(`Permanently delete ${label}?\n\nThis removes the model files from disk and cannot be undone.`)) return;
+
+ const controls = dialog.querySelectorAll(".ns-model-select, .ns-model-select-all, .ns-delete-model-btn, #ns-delete-selected-models");
+ controls.forEach((control) => { control.disabled = true; });
+ try {
+ const response = await fetch("/nodes-stats/models/delete", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ models }),
+ });
+ let result = {};
+ try { result = await response.json(); } catch { /* use the HTTP status below */ }
+ if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`);
+
+ const deleted = result.deleted || [];
+ const failed = result.failed || [];
+ if (deleted.length) {
+ notify(`Deleted ${deleted.length} model${deleted.length === 1 ? "" : "s"}.`, "success");
+ showStatsDialog("models"); // Re-read folders; deleted files remain as historical entries.
+ }
+ if (failed.length) {
+ const names = failed.map((item) => item.model_name || item.message || "unknown").join(", ");
+ notify(`Could not delete: ${names}`, "error");
+ }
+ } catch (error) {
+ notify("Failed to delete model: " + error.message, "error");
+ } finally {
+ if (dialog.isConnected) controls.forEach((control) => { control.disabled = false; });
+ }
+}
+
// Render the Workflow tab from a classification result. `disabled` entries get
// re-enable actions (temporary trial or permanent); `missing` entries get an
// Install button that defers to ComfyUI Manager.
diff --git a/mapper.py b/mapper.py
index 8630bb7..6910fa8 100644
--- a/mapper.py
+++ b/mapper.py
@@ -132,6 +132,76 @@ class ModelMapper:
self._ensure()
return {k: sorted(v) for k, v in self._folder_files.items()}
+ def delete_models(self, models):
+ """Delete verified model files and return per-model results.
+
+ Each requested file must still be in the mapper's known model list and
+ resolve beneath the configured root for its model type. This keeps the
+ HTTP endpoint from being usable to remove arbitrary files.
+ """
+ self._ensure()
+ try:
+ import folder_paths
+ except ImportError:
+ return [{"status": "error", "message": "folder_paths unavailable"}]
+
+ results = []
+ seen = set()
+ for item in models:
+ if not isinstance(item, dict):
+ results.append({"status": "error", "message": "invalid model entry"})
+ continue
+ model_type = item.get("model_type")
+ model_name = item.get("model_name")
+ if not isinstance(model_type, str) or not isinstance(model_name, str):
+ results.append({
+ "model_type": model_type,
+ "model_name": model_name,
+ "status": "error",
+ "message": "invalid model entry",
+ })
+ continue
+ key = (model_type, model_name)
+ if key in seen:
+ continue
+ seen.add(key)
+ result = {"model_type": model_type, "model_name": model_name}
+
+ known = self._folder_files.get(model_type, frozenset())
+ if model_name not in known:
+ result.update(status="error", message="model is not installed")
+ results.append(result)
+ continue
+
+ try:
+ path = folder_paths.get_full_path(model_type, model_name)
+ roots = folder_paths.get_folder_paths(model_type)
+ path = os.path.abspath(path) if path else None
+ allowed = any(
+ os.path.commonpath([path, os.path.abspath(root)]) == os.path.abspath(root)
+ for root in roots
+ ) if path else False
+ except (OSError, TypeError, ValueError):
+ path = None
+ allowed = False
+
+ if not path or not allowed or not os.path.isfile(path):
+ result.update(status="error", message="model file not found in its configured folder")
+ results.append(result)
+ continue
+
+ try:
+ os.remove(path)
+ result["status"] = "deleted"
+ except OSError as exc:
+ result.update(status="error", message=str(exc))
+ results.append(result)
+
+ # The model-folder listing is now stale whether any deletion succeeded
+ # or not (another process could have changed it), so rebuild next time.
+ self.invalidate()
+ return results
+
def extract_models_from_prompt(self, prompt):
"""Scan a prompt dict and return (model_name, model_type) pairs.
diff --git a/tests/test_model_delete.py b/tests/test_model_delete.py
new file mode 100644
index 0000000..36969f8
--- /dev/null
+++ b/tests/test_model_delete.py
@@ -0,0 +1,66 @@
+from mapper import ModelMapper
+
+
+def _configure_model_paths(monkeypatch, tmp_path, filenames):
+ import folder_paths
+
+ models_root = tmp_path / "models"
+ models_root.mkdir()
+ for filename in filenames:
+ target = models_root / filename
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_bytes(b"model")
+
+ monkeypatch.setattr(folder_paths, "folder_names_and_paths", {"checkpoints": ([], {})})
+ monkeypatch.setattr(folder_paths, "get_filename_list", lambda model_type: filenames if model_type == "checkpoints" else [])
+ monkeypatch.setattr(folder_paths, "get_folder_paths", lambda model_type: [str(models_root)] if model_type == "checkpoints" else [])
+ monkeypatch.setattr(folder_paths, "get_full_path", lambda model_type, filename: str(models_root / filename))
+ return models_root
+
+
+def test_delete_models_removes_only_a_known_model(monkeypatch, tmp_path):
+ models_root = _configure_model_paths(monkeypatch, tmp_path, ["nested/model.safetensors"])
+ mapper = ModelMapper()
+
+ result = mapper.delete_models([{
+ "model_type": "checkpoints",
+ "model_name": "nested/model.safetensors",
+ }])
+
+ assert result == [{
+ "model_type": "checkpoints",
+ "model_name": "nested/model.safetensors",
+ "status": "deleted",
+ }]
+ assert not (models_root / "nested/model.safetensors").exists()
+
+
+def test_delete_models_rejects_unknown_name(monkeypatch, tmp_path):
+ models_root = _configure_model_paths(monkeypatch, tmp_path, ["known.safetensors"])
+ mapper = ModelMapper()
+
+ result = mapper.delete_models([{
+ "model_type": "checkpoints",
+ "model_name": "../outside.safetensors",
+ }])
+
+ assert result[0]["status"] == "error"
+ assert (models_root / "known.safetensors").exists()
+
+
+def test_delete_models_rejects_path_outside_model_root(monkeypatch, tmp_path):
+ models_root = _configure_model_paths(monkeypatch, tmp_path, ["known.safetensors"])
+ outside = tmp_path / "outside.safetensors"
+ outside.write_bytes(b"do not remove")
+ import folder_paths
+ monkeypatch.setattr(folder_paths, "get_full_path", lambda *_: str(outside))
+ mapper = ModelMapper()
+
+ result = mapper.delete_models([{
+ "model_type": "checkpoints",
+ "model_name": "known.safetensors",
+ }])
+
+ assert result[0]["status"] == "error"
+ assert (models_root / "known.safetensors").exists()
+ assert outside.exists()