Files
jeremie louvaert 4048b9a8e0 v1.4.1: Hash Vault Browser — keybinding + sidebar tab
Three ways to open the browser now:
- Ctrl+Shift+H keybinding from anywhere in ComfyUI
- Left-sidebar "Hash Vault" tab with entry count + Open Browser button
- Top menu (Extensions → AKURATE: Hash Vault Browser) — unchanged from v1.4.0

Sidebar registration guarded on app.extensionManager.registerSidebarTab so
older frontends fall back cleanly to menu + keybinding only.

No backend changes — pure JS polish.
2026-04-24 09:08:19 +02:00

339 lines
12 KiB
JavaScript

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}`);
}
}
function renderSidebarTab(el) {
el.innerHTML = "";
el.style.padding = "16px";
el.style.color = "#ddd";
el.style.fontFamily = "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
const title = document.createElement("div");
title.textContent = "Hash Vault";
title.style.fontSize = "14px";
title.style.fontWeight = "600";
title.style.marginBottom = "4px";
const sub = document.createElement("div");
sub.textContent = "Loading…";
sub.style.fontSize = "12px";
sub.style.color = "#888";
sub.style.marginBottom = "14px";
const btn = document.createElement("button");
btn.textContent = "Open Browser";
btn.style.cssText = `
width: 100%; padding: 8px 12px; font-size: 13px;
background: #2a2a2a; color: #eee; border: 1px solid #3a3a3a;
border-radius: 4px; cursor: pointer; font-family: inherit;
`;
btn.addEventListener("mouseenter", () => { btn.style.borderColor = "#4a90e2"; });
btn.addEventListener("mouseleave", () => { btn.style.borderColor = "#3a3a3a"; });
btn.addEventListener("click", openBrowser);
const hint = document.createElement("div");
hint.textContent = "Shortcut: Ctrl+Shift+H";
hint.style.cssText = "font-size: 11px; color: #666; margin-top: 10px; text-align: center;";
el.append(title, sub, btn, hint);
// Populate count asynchronously
fetch(LIST_URL)
.then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
.then((d) => {
const n = d.count ?? (d.entries || []).length;
sub.textContent = `${n} cached ${n === 1 ? "entry" : "entries"}`;
})
.catch(() => { sub.textContent = "Vault list unavailable"; });
}
app.registerExtension({
name: "akurate.HashVaultBrowser",
commands: [
{
id: "akurate.HashVaultBrowser.open",
label: "AKURATE: Hash Vault Browser",
icon: "pi pi-database",
function: openBrowser,
},
],
keybindings: [
{
combo: { key: "H", ctrl: true, shift: true },
commandId: "akurate.HashVaultBrowser.open",
},
],
menuCommands: [
{
path: ["Extensions"],
commands: ["akurate.HashVaultBrowser.open"],
},
],
setup() {
// Register sidebar tab. Guard in case the frontend is older than
// extensionManager.registerSidebarTab — fall back to menu-only.
try {
if (app.extensionManager?.registerSidebarTab) {
app.extensionManager.registerSidebarTab({
id: "akurate.HashVaultBrowser.tab",
icon: "pi pi-database",
title: "Hash Vault",
tooltip: "AKURATE Hash Vault Browser",
type: "custom",
render: renderSidebarTab,
});
}
} catch (e) {
console.warn("[akurate.HashVaultBrowser] sidebar tab registration failed:", e);
}
},
});