feat(models): add bulk model deletion controls

This commit is contained in:
Ethanfel
2026-07-29 20:00:23 +02:00
parent 228542c71a
commit 54f9a3ae29
5 changed files with 259 additions and 6 deletions
+1
View File
@@ -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 - Summary bar with counts for each tier across all model types
- Sections per model type (checkpoints, vae, controlnet, …) - Sections per model type (checkpoints, vae, controlnet, …)
- Per-model table showing execution count, last used date, and status - Per-model table showing execution count, last used date, and status
- Select one or more installed models and permanently delete them after confirmation
### API ### API
+26
View File
@@ -112,6 +112,32 @@ async def get_model_stats(request):
return web.json_response({"error": "internal error"}, status=500) 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") @routes.post("/nodes-stats/reset")
async def reset_stats(request): async def reset_stats(request):
try: try:
+96 -6
View File
@@ -258,6 +258,7 @@ async function showStatsDialog(initialTab = "nodes") {
wireWorkflowButtons(dialog); wireWorkflowButtons(dialog);
wireWhitelistButtons(dialog); wireWhitelistButtons(dialog);
wireNativeEnableButtons(dialog); wireNativeEnableButtons(dialog);
wireModelDeleteControls(dialog);
switchTab(TABS.includes(initialTab) ? initialTab : "nodes"); switchTab(TABS.includes(initialTab) ? initialTab : "nodes");
@@ -432,6 +433,11 @@ function buildModelsTabContent(modelData) {
return html; return html;
} }
html += `<div id="ns-model-actions" style="display:flex;align-items:center;justify-content:flex-end;gap:10px;flex-wrap:wrap;margin:-4px 0 12px;">
<span id="ns-model-selection-count" style="color:#888;font-size:12px;">No models selected</span>
<button id="ns-delete-selected-models" class="ns-btn" disabled>Delete selected</button>
</div>`;
for (const group of modelData) { for (const group of modelData) {
if (group.models.length === 0) continue; if (group.models.length === 0) continue;
const title = group.model_type.charAt(0).toUpperCase() + group.model_type.slice(1).replace(/_/g, " "); 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) { function buildModelTable(models) {
let html = `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;"> let html = `<div style="max-width:100%;overflow-x:auto;margin-bottom:12px;"><table style="width:100%;min-width:620px;table-layout:fixed;border-collapse:collapse;">
<thead><tr style="color:#888;text-align:left;border-bottom:1px solid #333;"> <thead><tr style="color:#888;text-align:left;border-bottom:1px solid #333;">
<th style="padding:6px 8px;width:28px;"><input class="ns-model-select-all" type="checkbox" title="Select all installed models in this group" aria-label="Select all installed models in this group"></th>
<th style="padding:6px 8px;">Model</th> <th style="padding:6px 8px;">Model</th>
<th style="padding:6px 8px;text-align:right;">Executions</th> <th style="padding:6px 8px;width:82px;text-align:right;">Executions</th>
<th style="padding:6px 8px;">Last Used</th> <th style="padding:6px 8px;width:76px;">Last Used</th>
<th style="padding:6px 8px;">Status</th> <th style="padding:6px 8px;width:112px;">Status</th>
<th style="padding:6px 8px;width:68px;text-align:right;"></th>
</tr></thead><tbody>`; </tr></thead><tbody>`;
for (const m of models) { for (const m of models) {
const meta = STATUS_META[m.status] || STATUS_META.used; const meta = STATUS_META[m.status] || STATUS_META.used;
const lastSeen = m.last_seen ? new Date(m.last_seen).toLocaleDateString() : "—"; const lastSeen = m.last_seen ? new Date(m.last_seen).toLocaleDateString() : "—";
const selectable = m.installed ? "" : " disabled";
const action = m.installed
? `<button class="ns-btn ns-delete-model-btn" data-model-type="${escapeAttr(m.model_type)}" data-model-name="${escapeAttr(m.model_name)}">Delete</button>`
: `<span style="color:#555;">—</span>`;
html += `<tr class="ns-row-${m.status}" style="border-bottom:1px solid #222;"> html += `<tr class="ns-row-${m.status}" style="border-bottom:1px solid #222;">
<td style="padding:6px 8px;color:#fff;">${escapeHtml(m.model_name)}</td> <td style="padding:6px 8px;"><input class="ns-model-select" type="checkbox" data-model-type="${escapeAttr(m.model_type)}" data-model-name="${escapeAttr(m.model_name)}" aria-label="Select ${escapeAttr(m.model_name)}"${selectable}></td>
<td style="padding:6px 8px;color:#fff;overflow-wrap:anywhere;word-break:break-word;">${escapeHtml(m.model_name)}</td>
<td style="padding:6px 8px;text-align:right;">${m.count}</td> <td style="padding:6px 8px;text-align:right;">${m.count}</td>
<td style="padding:6px 8px;color:#888;">${lastSeen}</td> <td style="padding:6px 8px;color:#888;">${lastSeen}</td>
<td style="padding:6px 8px;"><span style="color:${meta.color};font-size:11px;">${meta.label}</span></td> <td style="padding:6px 8px;"><span style="color:${meta.color};font-size:11px;">${meta.label}</span></td>
<td style="padding:6px 8px;text-align:right;white-space:nowrap;">${action}</td>
</tr>`; </tr>`;
} }
html += `</tbody></table>`; html += `</tbody></table></div>`;
return 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 // Render the Workflow tab from a classification result. `disabled` entries get
// re-enable actions (temporary trial or permanent); `missing` entries get an // re-enable actions (temporary trial or permanent); `missing` entries get an
// Install button that defers to ComfyUI Manager. // Install button that defers to ComfyUI Manager.
+70
View File
@@ -132,6 +132,76 @@ class ModelMapper:
self._ensure() self._ensure()
return {k: sorted(v) for k, v in self._folder_files.items()} 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): def extract_models_from_prompt(self, prompt):
"""Scan a prompt dict and return (model_name, model_type) pairs. """Scan a prompt dict and return (model_name, model_type) pairs.
+66
View File
@@ -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()