v1.4.0: Hash Vault Browser modal gallery

New feature: modal gallery UI over the vault, accessible from the top menu
bar under Extensions → AKURATE: Hash Vault Browser. Reads all sidecar
metadata + thumbnails shipped in v1.3.0 and presents them as a filterable
grid. No graph node required.

Backend: new GET /akurate/hash_vault/list route walks output/hash_vault/,
reads every {hash}.json sidecar, returns JSON sorted by last_accessed_at
desc with pt_size enrichment. Thumbnails reuse ComfyUI's built-in /view
route (type=output&subfolder=hash_vault) — zero new thumbnail endpoint.

Frontend: single web/hash_vault_browser.js (vanilla JS, no framework).
Modal with CSS grid, live substring filter on label+hash, click-to-copy
hash with toast, lazy-loaded thumbnails, relative-age display, ESC and
backdrop-click dismiss.

__init__.py now declares WEB_DIRECTORY and imports the route module.

Deferred to v1.5+: load-into-workflow action, delete, bulk ops, sort
controls, pagination. Current vault (57 entries, 2.33 GB .pt, 3.7 MB
thumbs) loads instantly so none of those are urgent.
This commit is contained in:
jeremie louvaert
2026-04-24 08:56:06 +02:00
parent 84e074766d
commit 04e31f5b77
5 changed files with 354 additions and 4 deletions
+2 -1
View File
@@ -32,7 +32,8 @@ ComfyUI's native caching often breaks with external API nodes (dynamic timestamp
- **Device-Portable Caching:** All tensors are saved to CPU and loaded with `map_location="cpu"`, so cache files work regardless of GPU configuration.
- **Atomic Writes:** Cache files are written to a temp file first, then atomically replaced — preventing corruption from interrupted writes.
- **Concurrent-Safe:** File locking on every cache read/write operation.
- **Sidecar Metadata (v1.3.0):** Every saved entry gets a `{hash_key}.json` sidecar with human-readable `label`, `created_at`, `last_accessed_at`, and payload summary. Image outputs also get a `{hash_key}.thumb.png` 256px preview. Sidecar data is decoupled from the hash, so editing a `label` never invalidates cache. This is what the upcoming Hash Vault Browser indexes.
- **Sidecar Metadata (v1.3.0):** Every saved entry gets a `{hash_key}.json` sidecar with human-readable `label`, `created_at`, `last_accessed_at`, and payload summary. Image outputs also get a `{hash_key}.thumb.png` 256px preview. Sidecar data is decoupled from the hash, so editing a `label` never invalidates cache. This is what the Hash Vault Browser indexes.
- **Hash Vault Browser (v1.4.0):** A modal gallery over the entire vault. Open from `Extensions → AKURATE → Hash Vault Browser` in the ComfyUI menu. Shows every cached entry as a card with thumbnail, label, relative age, and `.pt` size. Live substring filter on label + hash. Click a card to copy its hash to clipboard. Sorted by last-accessed desc. No load-into-workflow action in v0.1 — that's v1.5+.
### Sidecar Label Input
+5 -2
View File
@@ -1,5 +1,8 @@
from .api_optimizer_nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
from . import hash_vault_browser # registers the /akurate/hash_vault/list route
__all__ =['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
WEB_DIRECTORY = "./web"
print("\033[34m[ComfyUI-API-Optimizer] \033[92mLoaded Cost Tracker & Deterministic Hash Vault\033[0m")
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
print("\033[34m[ComfyUI-API-Optimizer] \033[92mLoaded Cost Tracker, Hash Vault, and Browser\033[0m")
+77
View File
@@ -0,0 +1,77 @@
"""Hash Vault Browser backend route.
Registers GET /akurate/hash_vault/list which walks output/hash_vault/, reads
every {hash}.json sidecar, and returns a JSON array sorted by last_accessed_at
desc. Thumbnails are served by ComfyUI's built-in /view route using
type=output&subfolder=hash_vault&filename={hash}.thumb.png — no extra route
needed on our side.
The .pt files themselves are never read here; they are opaque + expensive to
load, and the sidecar already carries everything the browser UI needs.
"""
from __future__ import annotations
import json
import os
from typing import Any
try:
import folder_paths
except ImportError:
folder_paths = None
try:
from server import PromptServer
from aiohttp import web
except ImportError:
PromptServer = None
web = None
def _vault_dir() -> str | None:
if folder_paths is None:
return None
return os.path.join(folder_paths.get_output_directory(), "hash_vault")
def _list_entries() -> list[dict[str, Any]]:
"""Walk hash_vault/, read sidecars, return enriched entries sorted by last_accessed_at desc."""
vault = _vault_dir()
if not vault or not os.path.isdir(vault):
return []
entries: list[dict[str, Any]] = []
for name in os.listdir(vault):
if not name.endswith(".json"):
continue
sidecar_path = os.path.join(vault, name)
hash_key = name[:-5] # strip .json
try:
with open(sidecar_path, "r", encoding="utf-8") as f:
sidecar = json.load(f)
except (OSError, json.JSONDecodeError):
continue
pt_path = os.path.join(vault, f"{hash_key}.pt")
pt_size = os.path.getsize(pt_path) if os.path.exists(pt_path) else 0
sidecar["pt_size"] = pt_size
sidecar["pt_exists"] = pt_size > 0
entries.append(sidecar)
entries.sort(key=lambda e: e.get("last_accessed_at", ""), reverse=True)
return entries
if PromptServer is not None and web is not None:
routes = PromptServer.instance.routes
@routes.get("/akurate/hash_vault/list")
async def hash_vault_list(request):
try:
data = _list_entries()
return web.json_response({"entries": data, "count": len(data)})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-api-optimizer"
description = "API cost tracking, deterministic hash caching, and lazy execution bypass for cloud API workflows"
version = "1.3.0"
version = "1.4.0"
license = { file = "LICENSE" }
[project.urls]
+269
View File
@@ -0,0 +1,269 @@
import { app } from "../../scripts/app.js";
const LIST_URL = "/akurate/hash_vault/list";
const THUMB_URL = (hash, name) =>
`/view?filename=${encodeURIComponent(name)}&type=output&subfolder=hash_vault&rand=${hash.slice(0, 8)}`;
const STYLE = `
.akurate-hv-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.82);
z-index: 10000; display: flex; align-items: center; justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.akurate-hv-modal {
background: #1a1a1a; color: #eee; border: 1px solid #333; border-radius: 8px;
width: 92vw; height: 90vh; display: flex; flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
}
.akurate-hv-header {
padding: 14px 20px; border-bottom: 1px solid #333;
display: flex; align-items: center; gap: 16px;
}
.akurate-hv-header h2 { margin: 0; font-size: 16px; font-weight: 500; }
.akurate-hv-count { color: #888; font-size: 13px; }
.akurate-hv-search {
flex: 1; background: #0f0f0f; color: #eee; border: 1px solid #333;
border-radius: 4px; padding: 6px 10px; font-size: 13px;
font-family: inherit; outline: none;
}
.akurate-hv-search:focus { border-color: #4a90e2; }
.akurate-hv-close {
background: transparent; color: #aaa; border: 1px solid #333;
border-radius: 4px; padding: 4px 10px; cursor: pointer; font-size: 14px;
}
.akurate-hv-close:hover { background: #333; color: #fff; }
.akurate-hv-body {
flex: 1; overflow-y: auto; padding: 16px 20px;
}
.akurate-hv-grid {
display: grid; gap: 14px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.akurate-hv-card {
background: #111; border: 1px solid #2a2a2a; border-radius: 6px;
overflow: hidden; cursor: pointer; transition: border-color 0.12s, transform 0.12s;
display: flex; flex-direction: column;
}
.akurate-hv-card:hover { border-color: #4a90e2; transform: translateY(-1px); }
.akurate-hv-thumb {
width: 100%; aspect-ratio: 1 / 1; background: #0a0a0a;
display: flex; align-items: center; justify-content: center;
color: #555; font-size: 36px;
}
.akurate-hv-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.akurate-hv-meta {
padding: 8px 10px; font-size: 12px; line-height: 1.35;
display: flex; flex-direction: column; gap: 2px; min-height: 54px;
}
.akurate-hv-label { color: #eee; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.akurate-hv-label.empty { color: #666; font-style: italic; font-weight: 400; }
.akurate-hv-sub { color: #888; font-size: 11px; }
.akurate-hv-empty {
text-align: center; color: #666; padding: 60px 20px; font-size: 14px;
}
.akurate-hv-toast {
position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%);
background: #2a2a2a; color: #eee; border: 1px solid #4a90e2;
padding: 10px 18px; border-radius: 6px; font-size: 13px;
z-index: 10001; opacity: 0; transition: opacity 0.2s;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.akurate-hv-toast.show { opacity: 1; }
`;
function injectStyle() {
if (document.getElementById("akurate-hv-style")) return;
const el = document.createElement("style");
el.id = "akurate-hv-style";
el.textContent = STYLE;
document.head.appendChild(el);
}
function formatBytes(n) {
if (!n) return "—";
if (n < 1024) return `${n} B`;
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
return `${(n / 1024 ** 3).toFixed(2)} GB`;
}
function relativeAge(isoString) {
if (!isoString) return "unknown";
const then = new Date(isoString).getTime();
const now = Date.now();
const secs = Math.max(0, (now - then) / 1000);
if (secs < 60) return "just now";
if (secs < 3600) return `${Math.round(secs / 60)}m ago`;
if (secs < 86400) return `${Math.round(secs / 3600)}h ago`;
if (secs < 86400 * 30) return `${Math.round(secs / 86400)}d ago`;
if (secs < 86400 * 365) return `${Math.round(secs / (86400 * 30))}mo ago`;
return `${Math.round(secs / (86400 * 365))}y ago`;
}
function showToast(text) {
const toast = document.createElement("div");
toast.className = "akurate-hv-toast";
toast.textContent = text;
document.body.appendChild(toast);
requestAnimationFrame(() => toast.classList.add("show"));
setTimeout(() => {
toast.classList.remove("show");
setTimeout(() => toast.remove(), 250);
}, 1600);
}
function buildCard(entry) {
const card = document.createElement("div");
card.className = "akurate-hv-card";
const thumb = document.createElement("div");
thumb.className = "akurate-hv-thumb";
if (entry.thumbnail) {
const img = document.createElement("img");
img.src = THUMB_URL(entry.hash_key, entry.thumbnail);
img.loading = "lazy";
img.onerror = () => {
thumb.innerHTML = "";
thumb.textContent = "⋄";
};
thumb.appendChild(img);
} else {
const kind = entry.payload?.kind || "?";
thumb.textContent = kind === "dict" ? "{…}" : kind === "tensor" ? "⊞" : "⋄";
}
card.appendChild(thumb);
const meta = document.createElement("div");
meta.className = "akurate-hv-meta";
const label = document.createElement("div");
label.className = "akurate-hv-label";
if (entry.label) {
label.textContent = entry.label;
} else {
label.classList.add("empty");
label.textContent = entry.hash_key.slice(0, 12);
}
const sub = document.createElement("div");
sub.className = "akurate-hv-sub";
sub.textContent = `${relativeAge(entry.last_accessed_at)} · ${formatBytes(entry.pt_size)}`;
meta.appendChild(label);
meta.appendChild(sub);
card.appendChild(meta);
card.addEventListener("click", () => {
navigator.clipboard.writeText(entry.hash_key).then(
() => showToast(`Hash copied: ${entry.hash_key.slice(0, 12)}…`),
() => showToast("Copy failed — selection blocked"),
);
});
return card;
}
function buildModal(entries) {
const backdrop = document.createElement("div");
backdrop.className = "akurate-hv-backdrop";
const modal = document.createElement("div");
modal.className = "akurate-hv-modal";
const header = document.createElement("div");
header.className = "akurate-hv-header";
const title = document.createElement("h2");
title.textContent = "Hash Vault Browser";
const count = document.createElement("span");
count.className = "akurate-hv-count";
const search = document.createElement("input");
search.className = "akurate-hv-search";
search.placeholder = "Filter by label…";
search.type = "text";
const close = document.createElement("button");
close.className = "akurate-hv-close";
close.textContent = "Close";
header.append(title, count, search, close);
const body = document.createElement("div");
body.className = "akurate-hv-body";
const grid = document.createElement("div");
grid.className = "akurate-hv-grid";
body.appendChild(grid);
modal.append(header, body);
backdrop.appendChild(modal);
function render(filter) {
const needle = (filter || "").trim().toLowerCase();
grid.innerHTML = "";
let shown = 0;
for (const entry of entries) {
if (needle) {
const hay = (entry.label || "").toLowerCase() + " " + entry.hash_key.toLowerCase();
if (!hay.includes(needle)) continue;
}
grid.appendChild(buildCard(entry));
shown++;
}
if (shown === 0) {
const empty = document.createElement("div");
empty.className = "akurate-hv-empty";
empty.textContent = entries.length === 0
? "Vault is empty — save something via Hash Vault (Save API Result) first."
: "No entries match the filter.";
grid.appendChild(empty);
}
count.textContent = needle ? `${shown} of ${entries.length}` : `${entries.length} entries`;
}
function dismiss() {
backdrop.remove();
document.removeEventListener("keydown", onKey);
}
function onKey(e) {
if (e.key === "Escape") dismiss();
}
close.addEventListener("click", dismiss);
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) dismiss(); });
document.addEventListener("keydown", onKey);
search.addEventListener("input", () => render(search.value));
document.body.appendChild(backdrop);
search.focus();
render("");
}
async function openBrowser() {
injectStyle();
try {
const resp = await fetch(LIST_URL);
if (!resp.ok) {
showToast(`Vault list failed: HTTP ${resp.status}`);
return;
}
const data = await resp.json();
buildModal(data.entries || []);
} catch (e) {
showToast(`Vault list failed: ${e.message}`);
}
}
app.registerExtension({
name: "akurate.HashVaultBrowser",
commands: [
{
id: "akurate.HashVaultBrowser.open",
label: "AKURATE: Hash Vault Browser",
icon: "pi pi-database",
function: openBrowser,
},
],
menuCommands: [
{
path: ["Extensions"],
commands: ["akurate.HashVaultBrowser.open"],
},
],
});