import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; const NODE = "PROMPTFORGE_LoraLoader"; const MAX_ROWS = 120; const AUDIO_VIDEO = ["minimax_h3", "ltxv"]; let libraryRequest = null; function loadLibrary(refresh = false) { if (!libraryRequest || refresh) { libraryRequest = api.fetchApi("/promptforge/loras").then((response) => response.json()); } return libraryRequest; } function parseState(text) { try { const state = JSON.parse(text || "{}"); return { filter: state.filter || "all", items: Array.isArray(state.items) ? state.items : [] }; } catch { return { filter: "all", items: [] }; } } function el(tag, style = {}, props = {}) { const element = Object.assign(document.createElement(tag), props); Object.assign(element.style, style); return element; } const INPUT_STYLE = { background: "var(--comfy-input-bg)", color: "var(--input-text)", border: "1px solid var(--border-color)", borderRadius: "3px", padding: "1px 4px", boxSizing: "border-box" }; function numberInput(value, onChange, { width = "68px", placeholder = "" } = {}) { const input = el("input", { ...INPUT_STYLE, width, flex: "0 0 auto" }, { type: "number", step: 0.05, value: value ?? "", placeholder }); input.addEventListener("change", () => onChange(input.value === "" ? null : Number(input.value))); // the wheel nudges by 0.05 instead of zooming the canvas input.addEventListener("wheel", (event) => { event.preventDefault(); event.stopPropagation(); const next = Math.round(((Number(input.value) || 0) + (event.deltaY < 0 ? 0.05 : -0.05)) * 100) / 100; input.value = next; onChange(next); }, { passive: false }); return input; } const POPUP = { position: "fixed", zIndex: 10000, background: "var(--comfy-menu-bg, #222)", color: "var(--input-text, #ddd)", border: "1px solid var(--border-color, #555)", borderRadius: "6px", boxShadow: "0 4px 16px rgba(0,0,0,.5)", fontSize: "12px" }; let tip = null; function showTip(event, lines) { hideTip(); tip = el("div", { ...POPUP, padding: "6px 8px", maxWidth: "460px", whiteSpace: "pre-wrap", wordBreak: "break-word", pointerEvents: "none" }, { textContent: lines.join("\n") }); document.body.append(tip); const box = tip.getBoundingClientRect(); tip.style.left = Math.max(4, Math.min(event.clientX + 12, innerWidth - box.width - 8)) + "px"; tip.style.top = Math.max(4, Math.min(event.clientY + 16, innerHeight - box.height - 8)) + "px"; } function hideTip() { tip?.remove(); tip = null; } const INFO_CSS = ` .pf-info { width: min(980px, 94vw); max-height: 90vh; overflow-y: auto; padding: 16px 18px; box-sizing: border-box; font-size: 13px; line-height: 1.4; } .pf-info h2 { margin: 0 0 8px; font-size: 18px; } .pf-info .pf-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 12px; padding: 0; list-style: none; } .pf-info .pf-tags li { padding: 2px 10px; border-radius: 12px; background: rgba(255,255,255,.1); font-size: 12px; } .pf-info .pf-tags li.pf-base { background: #2a5d9a; color: #fff; } .pf-info table { width: 100%; border-collapse: collapse; margin-bottom: 14px; } .pf-info td { padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,.1); vertical-align: top; word-break: break-word; } .pf-info td:first-child { width: 130px; opacity: .65; white-space: nowrap; } .pf-info a { color: #6af; } .pf-info .pf-words { display: flex; flex-wrap: wrap; gap: 5px; } .pf-info .pf-word { padding: 1px 9px; border-radius: 4px; background: rgba(255,255,255,.1); cursor: pointer; border: 1px solid transparent; } .pf-info .pf-word.pf-on { border-color: #6af; background: rgba(80,140,255,.25); } .pf-info .pf-copy { margin-left: 8px; cursor: pointer; color: #6af; text-decoration: underline; } .pf-info .pf-text { white-space: pre-wrap; max-height: 150px; overflow-y: auto; } .pf-info .pf-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; margin: 0; padding: 0; list-style: none; } .pf-info figure { margin: 0; display: flex; flex-direction: column; gap: 4px; background: rgba(0,0,0,.25); border-radius: 6px; overflow: hidden; } .pf-info figure img, .pf-info figure video { width: 100%; max-height: 380px; object-fit: contain; background: #111; } .pf-info figcaption { padding: 4px 8px 8px; display: flex; flex-direction: column; gap: 2px; font-size: 11.5px; } .pf-info figcaption label { opacity: .6; margin-right: 4px; } .pf-info figcaption .pf-long { max-height: 3.9em; overflow: hidden; cursor: pointer; white-space: pre-wrap; word-break: break-word; } .pf-info figcaption .pf-long.pf-open { max-height: 260px; overflow-y: auto; } .pf-info .pf-close { position: sticky; float: right; top: 0; cursor: pointer; } `; async function showInfo(name) { if (!document.getElementById("pf-info-css")) document.head.append(el("style", {}, { id: "pf-info-css", textContent: INFO_CSS })); const info = await (await api.fetchApi("/promptforge/lora_info?name=" + encodeURIComponent(name))).json(); const backdrop = el("div", { position: "fixed", inset: "0", zIndex: 10001, background: "rgba(0,0,0,.55)", display: "flex", alignItems: "center", justifyContent: "center" }); const card = el("div", { ...POPUP, position: "relative" }, { className: "pf-info" }); const done = () => { backdrop.remove(); document.removeEventListener("keydown", onKey, true); }; const onKey = (event) => { if (event.key === "Escape") { event.stopPropagation(); done(); } }; document.addEventListener("keydown", onKey, true); backdrop.addEventListener("pointerdown", (event) => event.stopPropagation()); backdrop.addEventListener("click", (event) => { if (event.target === backdrop) done(); }); const close = el("button", {}, { className: "pf-close", textContent: "Close" }); close.addEventListener("click", done); card.append(close, el("h2", {}, { textContent: info.title || name })); const tags = el("ul", {}, { className: "pf-tags" }); const pill = (text, cls = "") => text && tags.append(el("li", {}, { className: cls, textContent: text })); pill(info.type); pill(info.base_model, "pf-base"); pill(info.version); pill(info.creator && "by " + info.creator); pill(info.downloads != null && "โ†“ " + info.downloads.toLocaleString()); pill(info.likes != null && "๐Ÿ‘ " + info.likes.toLocaleString()); card.append(tags); const table = el("table"); const row = (label, content) => { if (content == null || content === "" || (Array.isArray(content) && !content.length)) return null; const cell = el("td"); cell.append(...(Array.isArray(content) ? content : [content])); const line = el("tr"); line.append(el("td", {}, { textContent: label }), cell); table.append(line); return line; }; const text = (value) => el("div", {}, { className: "pf-text", textContent: value }); row("File", info.name); row("Hash (sha256)", info.sha256); if (info.size) row("Size", (info.size / 1024 ** 2).toFixed(0) + " MB"); if (info.url) row("Civitai", el("a", {}, { href: info.url, target: "_blank", rel: "noopener", textContent: "View on Civitai" })); const chipRow = (label, words, hint) => { const box = el("div", {}, { className: "pf-words" }); const status = el("span"); const update = () => { status.replaceChildren(); const picked = [...box.querySelectorAll(".pf-on")].map((chip) => chip.dataset.word); if (!picked.length) return; status.append(`${picked.length} selected`, el("span", {}, { className: "pf-copy", textContent: "Copy" })); status.lastChild.addEventListener("click", async () => { await navigator.clipboard.writeText(picked.join(", ")); status.textContent = "Copied โœ“"; }); }; words.forEach((word) => { const chip = el("span", {}, { className: "pf-word", textContent: word, title: hint }); chip.dataset.word = word; chip.addEventListener("click", () => { chip.classList.toggle("pf-on"); update(); }); box.append(chip); }); const all = el("span", {}, { className: "pf-copy", textContent: "Copy all" }); all.addEventListener("click", async () => { await navigator.clipboard.writeText(words.join(", ")); all.textContent = "Copied โœ“"; }); row(label, [box, el("div", { marginTop: "4px" }), status, all]); }; if (info.words?.length) chipRow("Trained words", info.words, "Click to select for copying"); if (info.training?.length) row("Training", text(info.training.map(([label, value]) => `${label}: ${value}`).join("\n"))); if (info.training_tags?.length) chipRow(info.words?.length ? "Common training tags" : "Training tags", info.training_tags, "A tag that is common in the training data, from the file itself. Click to select for copying"); row("Usage tips", text(typeof info.tips === "string" ? info.tips : JSON.stringify(info.tips))); row("Tags", (info.tags || []).join(", ")); row("Notes", info.notes && text(info.notes)); row("Description", info.description && text(info.description)); if (!table.children.length || (!info.words?.length && !info.description && !info.url && !info.images?.length)) row("Info", "No CivitAI information stored for this LoRA."); card.append(table); const gallery = el("ul", {}, { className: "pf-gallery" }); const shots = [...(info.preview ? [{ url: api.apiURL("/promptforge/lora_preview?name=" + encodeURIComponent(name)) }] : []), ...(info.images || [])]; for (const shot of shots) { const figure = el("figure"); const media = shot.video ? el("video", {}, { src: shot.url, loop: true, muted: true, controls: true, preload: "metadata" }) : el("img", {}, { src: shot.url, loading: "lazy", referrerPolicy: "no-referrer" }); figure.append(media); const caption = el("figcaption"); for (const [label, key] of [["seed", "seed"], ["steps", "steps"], ["cfg", "cfg"], ["sampler", "sampler"], ["model", "model"], ["positive", "positive"], ["negative", "negative"]]) { if (shot[key] == null || shot[key] === "") continue; const line = el("div"); const long = String(shot[key]).length > 80; const value = el("span", {}, { className: long ? "pf-long" : "", textContent: String(shot[key]) }); if (long) value.addEventListener("click", () => value.classList.toggle("pf-open")); line.append(el("label", {}, { textContent: label }), value); caption.append(line); } if (caption.children.length) figure.append(caption); gallery.append(el("li", {}, {})); gallery.lastChild.append(figure); } if (shots.length) card.append(gallery); backdrop.append(card); document.body.append(backdrop); } function buildList(node, stateWidget) { let state = parseState(stateWidget.value); let library = { loras: [], families: [] }; let search = ""; let activeOnly = false; // the frontend sizes `frame`; `root` fills it without ever influencing its size const frame = el("div", { position: "relative", width: "100%", height: "100%", minWidth: "0", overflow: "hidden", contain: "layout paint" }); const root = el("div", { position: "absolute", inset: "0", display: "flex", flexDirection: "column", gap: "4px", boxSizing: "border-box", overflow: "hidden", fontSize: "12px", color: "var(--input-text)" }); frame.append(root); frame.addEventListener("pointerdown", (event) => event.stopPropagation()); const bar = el("div", { display: "flex", gap: "4px" }); const searchBox = el("input", { flex: "1", minWidth: "0", background: "var(--comfy-input-bg)", color: "var(--input-text)", border: "1px solid var(--border-color)", borderRadius: "3px", padding: "3px 6px" }, { type: "search", placeholder: "Search LoRAs (name, folder, trigger words)..." }); const familyBox = el("select", { flex: "0 0 140px", width: "140px", background: "var(--comfy-input-bg)", color: "var(--input-text)", border: "1px solid var(--border-color)", borderRadius: "3px", maxWidth: "140px" }); const counter = el("span", { opacity: "0.7", whiteSpace: "nowrap", alignSelf: "center", flex: "0 0 auto", minWidth: "112px", textAlign: "right" }); const refresh = el("button", { cursor: "pointer" }, { textContent: "โ†ป", title: "Rescan the loras folders" }); const onlyLabel = el("label", { display: "flex", alignItems: "center", gap: "2px", whiteSpace: "nowrap" }); const onlyBox = el("input", {}, { type: "checkbox" }); onlyLabel.append(onlyBox, "on only"); bar.append(searchBox, familyBox, onlyLabel, counter, refresh); const list = el("div", { flex: "1", overflowY: "auto", overflowX: "hidden", minHeight: "0", minWidth: "0", border: "1px solid var(--border-color)", borderRadius: "3px" }); list.addEventListener("wheel", (event) => event.stopPropagation()); root.append(bar, list); const commit = () => { state.items = state.items.filter((item) => item.on || item.random || item.group || item.model !== 1 || item.clip != null || (item.part && item.part !== "all")); stateWidget.value = JSON.stringify(state); node.setDirtyCanvas?.(true, true); }; const byName = () => new Map(state.items.map((item) => [item.name, item])); const itemFor = (name) => { let item = state.items.find((entry) => entry.name === name); if (!item) { item = { name, on: false, model: 1, clip: null, random: false, min: 0.6, max: 1, group: "" }; state.items.push(item); } return item; }; function row(entry, item) { const line = el("div", { display: "flex", alignItems: "center", gap: "4px", padding: "2px 4px", borderBottom: "1px solid var(--border-color)", flexWrap: "nowrap", minWidth: "0", overflow: "hidden" }); const box = el("input", {}, { type: "checkbox", checked: !!item?.on }); box.addEventListener("change", () => { itemFor(entry.name).on = box.checked; commit(); render(); }); const label = el("span", { flex: "1 1 0", minWidth: "40px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", opacity: entry.missing ? "0.5" : "1" }, { textContent: entry.name.replace(/\.[^.]+$/, "") }); label.addEventListener("mouseenter", (event) => showTip(event, [entry.name, ...(entry.words?.length ? ["Trigger: " + entry.words.join(", ")] : [])])); label.addEventListener("mousemove", (event) => showTip(event, [entry.name, ...(entry.words?.length ? ["Trigger: " + entry.words.join(", ")] : [])])); label.addEventListener("mouseleave", hideTip); const infoButton = el("button", { cursor: "pointer", flex: "0 0 auto", padding: "0 5px", borderRadius: "50%", fontWeight: "bold", fontStyle: "italic" }, { textContent: "i", title: "LoRA information" }); infoButton.addEventListener("click", () => { hideTip(); showInfo(entry.name); }); line.append(box, label, infoButton); if (entry.family) { const tag = library.families.find((family) => family.key === entry.family); line.append(el("span", { opacity: "0.6", fontSize: "10px" }, { textContent: tag?.label || entry.family })); } if (entry.missing) line.append(el("span", { color: "#e66" }, { textContent: "missing" })); if (item?.on) { const set = (key) => (value) => { item[key] = value; commit(); }; const group = el("input", { ...INPUT_STYLE, width: "90px", flex: "0 0 auto" }, { value: item.group || "", placeholder: "group", title: "Items sharing a group name are alternatives: one is picked per run" }); group.addEventListener("change", () => { item.group = group.value.trim(); commit(); }); const dice = el("button", { cursor: "pointer", opacity: item.random ? "1" : "0.4" }, { textContent: "๐ŸŽฒ", title: "Random strength between min and max" }); dice.addEventListener("click", () => { item.random = !item.random; commit(); render(); }); line.append(group, dice); if (AUDIO_VIDEO.includes(entry.family)) { const part = el("select", { ...INPUT_STYLE, width: "64px", flex: "0 0 auto" }, { title: "Which layers of this audio+video model the LoRA changes" }); part.append(new Option("all", "all"), new Option("video", "video"), new Option("audio", "audio")); part.value = item.part || "all"; part.addEventListener("change", () => { item.part = part.value; commit(); }); line.append(part); } if (item.random) { line.append(numberInput(item.min, (v) => set("min")(v ?? 0)), "โ€“", numberInput(item.max, (v) => set("max")(v ?? 1))); } else { line.append(numberInput(item.model, (v) => set("model")(v ?? 1), { placeholder: "model" }), numberInput(item.clip, set("clip"), { placeholder: "= model" })); } } return line; } const wanted = () => (state.filter === "pipe" ? node.promptforgePipeFamily?.() : state.filter === "all" ? "" : state.filter); function render() { const scroll = list.scrollTop; const known = new Map(library.loras.map((entry) => [entry.name, entry])); const configured = byName(); const query = search.toLowerCase().split(/\s+/).filter(Boolean); list.replaceChildren(); const matches = library.loras.filter((entry) => { if (activeOnly && !configured.get(entry.name)?.on) return false; if (wanted() && entry.family !== wanted()) return false; const haystack = (entry.name + " " + (entry.words || []).join(" ")).toLowerCase(); return query.every((word) => haystack.includes(word)); }); const missing = state.items.filter((item) => item.on && !known.has(item.name)).map((item) => ({ name: item.name, family: "", words: [], missing: true })); [...missing, ...matches.slice(0, MAX_ROWS)].forEach((entry) => list.append(row(entry, configured.get(entry.name)))); if (matches.length > MAX_ROWS) list.append(el("div", { padding: "4px", opacity: "0.6" }, { textContent: `${matches.length - MAX_ROWS} more - narrow the search or pick a family` })); counter.textContent = `${state.items.filter((item) => item.on).length} on ยท ${matches.length} shown`; list.scrollTop = scroll; } function fillFamilies() { familyBox.replaceChildren(new Option("All families", "all"), new Option("Follow the connected loader", "pipe"), ...library.families.map((family) => new Option(family.label, family.key))); if (!library.families.length && state.filter !== "all" && state.filter !== "pipe") familyBox.append(new Option(state.filter, state.filter)); familyBox.value = state.filter; if (familyBox.value !== state.filter) { familyBox.value = "all"; if (library.families.length) state.filter = "all"; } } async function reload(refreshIndex = false) { library = await loadLibrary(refreshIndex); fillFamilies(); render(); } let timer; searchBox.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(() => { search = searchBox.value; render(); }, 120); }); familyBox.addEventListener("change", () => { state.filter = familyBox.value; commit(); render(); }); onlyBox.addEventListener("change", () => { activeOnly = onlyBox.checked; render(); }); refresh.addEventListener("click", () => reload(true)); const widget = node.addDOMWidget("lora_list", "promptforge_lora_list", frame, { serialize: false, getMinHeight: () => 380 }); // the frontend narrows widget.width from layout passes; the list should always span the node Object.defineProperty(widget, "width", { get: () => node.size[0], set: () => {}, configurable: true }); widget.refreshFilter = () => { if (state.filter === "pipe") render(); }; widget.restore = () => { state = parseState(stateWidget.value); fillFamilies(); render(); }; reload(); return widget; } app.registerExtension({ name: "PromptForge.LoraLoader", async beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name !== NODE) return; const onCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { onCreated?.apply(this, arguments); const stateWidget = this.widgets.find((widget) => widget.name === "loras"); stateWidget.hidden = true; stateWidget.computeSize = () => [0, -4]; this.promptforgeList = buildList(this, stateWidget); this.promptforgePipeFamily = () => { const link = app.graph.links[this.inputs?.find((input) => input.name === "pipe")?.link]; return app.graph.getNodeById(link?.origin_id)?.promptforgeFamily?.() ?? ""; }; this.promptforgeOnPipe = () => this.promptforgeList.refreshFilter(); const onResize = this.onResize; this.onResize = function (size) { size[0] = Math.max(size[0], 520); return onResize?.apply(this, arguments); }; this.setSize([Math.max(this.size[0], 620), Math.max(this.size[1], 560)]); }; const onConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { const result = onConfigure?.apply(this, arguments); this.promptforgeList?.restore(); return result; }; }, });