Files
ausboss-ComfyUI-AusBoss/js/lora_loader/index.js
T
ausboss 139082a9d9 Fix LoRA mismatch detection, LM Studio reasoning, Compare height; add pad stitcher
Six fixes found while testing every node against real workflows.

LoRA Loader: the "wrong base model" warning never fired on a modern stack.
It matched comfy's model class name against a table covering 8 of the 81
classes comfy ships, so Krea 2, Qwen, WAN, LTXV, Z-Image and friends read as
"unknown" and skipped the check entirely - an SD 1.5 LoRA on Krea 2 patched
nothing, changed nothing, and said nothing. It now measures the applied-patch
count across model and CLIP before and after each row and warns when a row
patched nothing, which needs no table, no LoRA metadata, and works on every
model family. base_model_family also stopped mining ss_sd_model_name: that is
the trainer's source filename, and reading substrings out of it labelled any
..._v1.safetensors as SD1.5 and anything containing xl as SDXL.

LM Studio Chat: reasoning returned in the server's own reasoning_content field
now reaches the thinking output. Only inline <think> tags were read, so with
gemma/qwen-style hybrids the whole reply landed in a field the node never
looked at and text came back blank. An empty answer now explains itself,
naming the max_tokens budget when the model ran out of room mid-thought.

Compare: the stage follows the node's height, not only its width. Its panel
declared a computeSize, and the widget layout gives any widget that defines one
a fixed height and leaves it out of the leftover-space split, so dragging the
node taller only added dead space under the image.

Pad Image / Load Image + Pad: a stitcher output, so an outpaint can put the
source back exactly. Padding now builds the same stitcher shape Crop For
Inpaint emits - the crop is simply the whole canvas - so the existing Stitch
Inpaint node serves both and no second stitch node has to exist. Appended
last, so saved workflows keep their links.

Settings menus: editing one row no longer reverts the others. Each save handed
back a value set rebuilt from stored defaults, silently replacing every other
row on a node whose workflow differed from those defaults.

LoRA templates popover: Save was clipped off the panel edge and each saved
row's delete x was stretched across the whole row - one blanket
.ausboss-lora-menu button rule outranked the specialized buttons inside it.
2026-08-17 20:47:27 -05:00

1033 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback, keepDomWidgetWidthAuto } from "../shared/index.mjs";
import {
gearIconSvg,
loadSettings,
openSettingsMenu,
} from "../shared/settings_menu.mjs";
import {
DEFAULT_STEP,
FINE_STEP,
MAX_ROWS,
clampHighlight,
commonFolderPrefix,
filterLoras,
groupByFolder,
highlightedName,
applyTemplate,
isScrubbing,
moveHighlight,
moveRow,
newRow,
parseRows,
parseTemplates,
removeTemplate,
roundStrength,
scrubValue,
serializeRows,
setStrength,
strengthOutOfRange,
summarizeRows,
templateFromRows,
toggleAllRows,
toggleAllState,
toggleTrigger,
upsertTemplate,
} from "../shared/lora_stack.mjs";
const NODE_CLASS = "AUSBOSS_NODES_LoraLoader";
const ROW_HEIGHT = 30;
const ROW_GAP = 6;
const ACTIONS_HEIGHT = 26;
const BAR_HEIGHT = 24;
const STACK_PADDING = 8;
const BLANK_HEIGHT = 44;
const PANEL_PADDING = 10;
// Per-node-type preferences behind the gear button; persisted in
// localStorage so they follow the user across workflows.
const SETTINGS_SCOPE = "lora_loader";
const SETTINGS_SCHEMA = [
{
key: "default_strength", label: "Default strength", type: "number",
default: 1, min: -10, max: 10,
hint: "Strength a newly added LoRA starts at.",
},
{
key: "step", label: "Strength step", type: "number",
default: 0.05, min: 0.01, max: 1,
hint: "Scrub and arrow-key step. Shift always steps by 0.01.",
},
{
key: "separate_strengths", label: "Separate model / CLIP strength",
type: "toggle", default: false,
hint: "Two strength boxes per row. Applies to this node and to new ones.",
},
{
key: "separator", label: "Trigger word separator", type: "text",
default: ", ", placeholder: '", "',
hint: "Joins the trigger_words output. This node now, new nodes later.",
},
{
key: "hide_extension", label: "Hide file extension", type: "toggle",
default: true,
hint: "Show LoRA names without .safetensors.",
},
{
key: "thumbnails", label: "Preview thumbnails", type: "toggle",
default: true,
hint: "Poster image beside the picker while hovering a LoRA.",
},
{
key: "civitai_lookup", label: "Civitai lookup button", type: "toggle",
default: true,
hint: "Offer the online lookup inside the info card.",
},
];
// Narrowest node at which a row still works: toggle + picker + strength +
// info + gaps + padding. Enforced through the DOM widget's layout minimum,
// so the node cannot be resized to where the fixed-width row controls would
// hang past the right edge.
const PANEL_MIN_WIDTH = 320;
function installStyles() {
if (document.getElementById("ausboss-lora-styles")) return;
const style = document.createElement("style");
style.id = "ausboss-lora-styles";
style.textContent = `
.ausboss-lora-panel, .ausboss-lora-panel *,
.ausboss-lora-pop, .ausboss-lora-pop *,
.ausboss-lora-hoverthumb { box-sizing: border-box; }
.ausboss-lora-panel { display: flex; flex-direction: column; gap: ${ROW_GAP}px;
width: 100%; padding: ${PANEL_PADDING}px; font: 12px system-ui; color: #d7dde2;
overflow: hidden; }
.ausboss-lora-row { display: flex; align-items: center; gap: 6px; height: ${ROW_HEIGHT}px; }
.ausboss-lora-row.off { opacity: 0.45; }
.ausboss-lora-toggle { width: 30px; height: 16px; border-radius: 8px; border: none;
background: #3a4047; cursor: pointer; position: relative; flex: none; padding: 0; }
.ausboss-lora-toggle::after { content: ""; position: absolute; top: 2px; left: 2px;
width: 12px; height: 12px; border-radius: 50%; background: #9ba2aa; transition: left .12s; }
.ausboss-lora-toggle.on { background: ${BRAND}; }
.ausboss-lora-toggle.on::after { left: 16px; background: #fff; }
.ausboss-lora-toggle.mixed { background: #4d6763; }
.ausboss-lora-toggle.mixed::after { left: 9px; background: #cfd6da; }
.ausboss-lora-actions { display: flex; height: ${ACTIONS_HEIGHT}px; }
.ausboss-lora-bar { display: flex; align-items: center; gap: 8px; height: ${BAR_HEIGHT}px;
padding: 0 2px; color: #9ba2aa; }
.ausboss-lora-gear { margin-left: auto; width: 24px; height: 24px; border: 1px solid #3a4047;
border-radius: 5px; background: #23272c; color: #9ba2aa; cursor: pointer;
display: grid; place-items: center; flex: none; padding: 0; }
.ausboss-lora-gear:hover { border-color: ${BRAND}; color: ${BRAND}; }
.ausboss-lora-stack { display: flex; flex-direction: column; gap: ${ROW_GAP}px;
padding: ${STACK_PADDING}px; border: 1px solid rgba(0,180,170,.22); border-radius: 6px;
background: rgba(0,0,0,.38); }
.ausboss-lora-blank { display: flex; align-items: center; justify-content: center;
width: 100%; height: ${BLANK_HEIGHT - 2}px; border: 1px dashed #3a4047;
border-radius: 5px; background: transparent; color: #9ba2aa; font: italic 12px system-ui;
text-align: center; padding: 0 10px; cursor: pointer; }
.ausboss-lora-blank:hover { border-color: ${BRAND}; color: ${BRAND}; }
.ausboss-lora-strength.out-of-range { color: #ffb26b; border-color: #7a5230; }
.ausboss-lora-folder { padding: 6px 8px 2px; color: #9ba2aa; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.04em; }
.ausboss-lora-hoverthumb { position: fixed; z-index: 10001; max-width: 180px;
max-height: 180px; border-radius: 6px; border: 1px solid #3a4047;
box-shadow: 0 8px 28px rgba(0,0,0,.5); pointer-events: none; background: #1c1f23; }
.ausboss-lora-range { display: flex; align-items: center; gap: 6px; }
.ausboss-lora-range input { width: 56px; height: 24px; border: 1px solid #3a4047;
border-radius: 5px; background: #23272c; color: inherit; text-align: center; outline: none; }
.ausboss-lora-range input:focus { border-color: ${BRAND}; }
.ausboss-lora-name { flex: 1 1 auto; min-width: 0; height: 24px; border: 1px solid #3a4047;
border-radius: 5px; background: #23272c; color: inherit; text-align: left; padding: 0 8px;
cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ausboss-lora-name:hover { border-color: ${BRAND}; }
.ausboss-lora-name.missing { color: #ff8a80; }
.ausboss-lora-name.empty { color: #9ba2aa; font-style: italic; }
.ausboss-lora-strength { width: 52px; height: 24px; border: 1px solid #3a4047; border-radius: 5px;
background: #23272c; color: inherit; text-align: center; cursor: ew-resize; flex: none;
user-select: none; }
.ausboss-lora-strength:focus { cursor: text; border-color: ${BRAND}; outline: none;
user-select: text; }
.ausboss-lora-info { width: 24px; height: 24px; border: 1px solid #3a4047; border-radius: 5px;
background: #23272c; color: #9ba2aa; cursor: pointer; flex: none;
font: 700 11px system-ui; }
.ausboss-lora-info:hover { border-color: ${BRAND}; color: ${BRAND}; }
.ausboss-lora-add { flex: none; height: ${ACTIONS_HEIGHT}px; border: 1px solid ${BRAND};
border-radius: 6px; background: ${BRAND}; color: #06211f; font: 600 12px system-ui;
cursor: pointer; padding: 0 12px; white-space: nowrap; box-shadow: 0 1px 0 rgba(0,0,0,.35); }
.ausboss-lora-add:hover { background: #00c9be; border-color: #00c9be; }
.ausboss-lora-add:active { transform: translateY(1px); box-shadow: none; }
.ausboss-lora-add:focus-visible { outline: 2px solid #9becf5; outline-offset: 1px; }
.ausboss-lora-add:disabled { opacity: 0.4; cursor: default; }
.ausboss-lora-actions .ausboss-lora-add { flex: 1 1 auto; }
.ausboss-lora-summary { color: #9ba2aa; flex: 1 1 auto; text-align: right; }
.ausboss-lora-pop { position: fixed; z-index: 10000; background: #1c1f23;
border: 1px solid #3a4047; border-radius: 7px; box-shadow: 0 8px 28px rgba(0,0,0,.5);
font: 12px system-ui; color: #d7dde2; display: flex; flex-direction: column; }
.ausboss-lora-search { margin: 8px; height: 26px; border: 1px solid #3a4047; border-radius: 5px;
background: #23272c; color: inherit; padding: 0 8px; outline: none; }
.ausboss-lora-search:focus { border-color: ${BRAND}; }
.ausboss-lora-list { overflow-y: auto; max-height: 48vh; padding: 0 4px 6px; }
.ausboss-lora-option { padding: 5px 8px; border-radius: 4px; cursor: pointer; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.ausboss-lora-option.highlight { background: #2c3238; }
.ausboss-lora-option.current { color: ${BRAND}; }
.ausboss-lora-empty { padding: 10px; color: #9ba2aa; font-style: italic; }
.ausboss-lora-menu { min-width: 140px; padding: 4px; }
/* Direct children only - those are the plain list rows. Buttons that sit
inside a wrapper row (Save, apply, delete) are specialized and keep their
own look; as a blanket descendant rule this outranked them and forced
width:100%, which clipped the Save button and stretched the delete x. */
.ausboss-lora-menu > button { display: block; width: 100%; text-align: left; border: none;
background: transparent; color: inherit; padding: 6px 8px; border-radius: 4px; cursor: pointer; }
.ausboss-lora-menu > button:hover:not(:disabled) { background: #2c3238; }
.ausboss-lora-menu button:disabled { opacity: 0.35; cursor: default; }
.ausboss-lora-templates { min-width: 240px; padding: 8px; gap: 6px; display: flex;
flex-direction: column; }
.ausboss-lora-template-row { display: flex; align-items: center; gap: 4px; }
.ausboss-lora-template-row > button:first-child { flex: 1 1 auto; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; text-align: left; border: none;
background: transparent; color: inherit; padding: 6px 8px; border-radius: 4px; cursor: pointer; }
.ausboss-lora-template-row > button:first-child:hover { background: #2c3238; }
.ausboss-lora-template-x { flex: none; width: 22px; height: 22px; border: none; border-radius: 4px;
background: transparent; color: #9ba2aa; cursor: pointer; font-size: 14px; line-height: 1; }
.ausboss-lora-template-x:hover { background: #2c3238; color: #ff8a80; }
.ausboss-lora-card { width: 300px; padding: 10px; gap: 8px; }
.ausboss-lora-card img { max-width: 100%; max-height: 180px; object-fit: contain;
border-radius: 5px; align-self: center; }
.ausboss-lora-card h4 { margin: 0; font-size: 12px; color: #fff; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.ausboss-lora-meta { color: #9ba2aa; }
.ausboss-lora-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.ausboss-lora-chip { border: 1px solid #3a4047; border-radius: 10px; background: #23272c;
color: inherit; cursor: pointer; padding: 2px 8px; font-size: 11px; }
.ausboss-lora-chip.active { border-color: ${BRAND}; color: ${BRAND}; }
.ausboss-lora-fetch { align-self: flex-start; }
.ausboss-lora-custom { display: flex; gap: 6px; align-items: center; }
.ausboss-lora-custom input { flex: 1 1 auto; min-width: 0; height: ${ACTIONS_HEIGHT}px;
border: 1px solid #3a4047; box-sizing: border-box;
border-radius: 5px; background: #23272c; color: inherit; padding: 0 8px; outline: none; }
.ausboss-lora-custom input:focus { border-color: ${BRAND}; }
`;
document.head.append(style);
}
function el(tag, className, text) {
const element = document.createElement(tag);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
function stripExtension(name) {
return name.replace(/\.(safetensors|sft|ckpt|pt)$/i, "");
}
function hideWidget(widget) {
if (!widget || widget.__ausbossHidden) return;
widget.__ausbossHidden = true;
widget.hidden = true;
widget.computeSize = () => [0, -4];
widget.computeLayoutSize = () => ({ minHeight: 0, maxHeight: 0, minWidth: 0 });
if (widget.element) widget.element.style.display = "none";
if (widget.inputEl) widget.inputEl.style.display = "none";
}
async function fetchLoraList() {
const response = await api.fetchApi("/ausboss/lora/list");
const data = await response.json();
return Array.isArray(data.loras) ? data.loras : [];
}
// One shared popup slot: opening any popup closes the previous one, and every
// listener hangs off one AbortController so teardown cannot leak.
let activePopup = null;
function closePopup() {
if (!activePopup) return;
activePopup.abort.abort();
activePopup.element.remove();
activePopup = null;
}
function openPopup(element, anchorRect, { width } = {}) {
closePopup();
const abort = new AbortController();
element.classList.add("ausboss-lora-pop");
if (width) element.style.width = `${width}px`;
document.body.append(element);
const place = () => {
const popRect = element.getBoundingClientRect();
let left = Math.min(anchorRect.left, window.innerWidth - popRect.width - 8);
let top = anchorRect.bottom + 4;
if (top + popRect.height > window.innerHeight - 8) {
top = Math.max(8, anchorRect.top - popRect.height - 4);
}
element.style.left = `${Math.max(8, left)}px`;
element.style.top = `${top}px`;
};
place();
requestAnimationFrame(place);
document.addEventListener(
"pointerdown",
(event) => { if (!element.contains(event.target)) closePopup(); },
{ capture: true, signal: abort.signal }
);
window.addEventListener(
"keydown",
(event) => { if (event.key === "Escape") { event.stopPropagation(); closePopup(); } },
{ capture: true, signal: abort.signal }
);
activePopup = { element, abort };
return { place };
}
function commitRows(state, rows, { structural = false } = {}) {
state.rows = rows;
state.widget.value = serializeRows(rows);
state.node.graph?.setDirtyCanvas(true, true);
if (structural) renderRows(state);
else updateRowValues(state);
}
function linked(state) {
return state.node.properties?.ausbossLoraLinked !== false;
}
// Display names honor the hide-extension preference everywhere at once.
function displayName(state, name) {
return state.settings?.hide_extension === false ? name : stripExtension(name);
}
function panelHeight(state) {
const inner = state.rows.length
? state.rows.length * (ROW_HEIGHT + ROW_GAP) - ROW_GAP
: BLANK_HEIGHT;
const stack = inner + STACK_PADDING * 2 + 2; // +2 for the stack border
return (
PANEL_PADDING * 2 +
ACTIONS_HEIGHT + ROW_GAP +
BAR_HEIGHT + ROW_GAP +
stack
);
}
function fitNode(state) {
const width = Math.max(320, state.node.size?.[0] || 320);
const height = state.node.computeSize ? state.node.computeSize()[1] : panelHeight(state) + 80;
state.node.setSize?.([width, height]);
state.node.graph?.setDirtyCanvas(true, true);
}
// ---------- suggested strength ranges ----------
// One cached range per LoRA name; null = known-absent, undefined = not yet
// fetched. Filled lazily from the info route so rows can tint out-of-range
// strengths without a request per render.
const rangeCache = new Map();
const rangeFetches = new Set();
function ensureRange(state, name) {
if (!name || rangeCache.has(name) || rangeFetches.has(name)) return;
rangeFetches.add(name);
api.fetchApi(`/ausboss/lora/info?name=${encodeURIComponent(name)}`)
.then((response) => response.json())
.then((data) => {
rangeCache.set(name, data.ok ? data.info.range ?? null : null);
updateRowValues(state);
})
.catch(() => rangeCache.set(name, null))
.finally(() => rangeFetches.delete(name));
}
function applyRangeTint(input, row, key) {
const range = rangeCache.get(row.name);
input.classList.toggle("out-of-range", strengthOutOfRange(row[key], range ?? null));
input.title = range && (range.min !== null || range.max !== null)
? `Suggested range ${range.min ?? "any"} to ${range.max ?? "any"}. ` + input.dataset.baseTitle
: input.dataset.baseTitle;
}
// ---------- strength boxes ----------
function strengthBox(state, index, key) {
const input = el("input", "ausboss-lora-strength");
input.value = state.rows[index][key].toFixed(2);
input.dataset.baseTitle = key === "strength"
? "Model strength. Drag left/right to scrub, click to type, arrows to step. Shift = fine."
: "CLIP strength. Drag left/right to scrub, click to type, arrows to step. Shift = fine.";
input.readOnly = true;
ensureRange(state, state.rows[index].name);
applyRangeTint(input, state.rows[index], key);
const commitValue = (value, structural = false) => {
let rows = state.rows;
if (key === "strength") rows = setStrength(rows, index, value, linked(state));
else rows = rows.map((row, i) => (i === index ? { ...row, strength_clip: roundStrength(value) } : row));
commitRows(state, rows, { structural });
};
let drag = null;
input.addEventListener("pointerdown", (event) => {
if (event.button !== 0 || !input.readOnly) return;
drag = { x: event.clientX, y: event.clientY, start: state.rows[index][key], scrubbed: false };
input.setPointerCapture(event.pointerId);
event.preventDefault();
});
input.addEventListener("pointermove", (event) => {
if (!drag) return;
const dx = event.clientX - drag.x;
const dy = event.clientY - drag.y;
if (!drag.scrubbed && isScrubbing(dx, dy)) drag.scrubbed = true;
if (drag.scrubbed) {
commitValue(scrubValue(drag.start, dx, event.shiftKey, state.settings?.step));
}
});
const endDrag = (event) => {
if (!drag) return;
try { input.releasePointerCapture(event.pointerId); } catch {}
const wasClick = !drag.scrubbed;
drag = null;
if (wasClick) {
input.readOnly = false;
input.focus();
input.select();
}
};
input.addEventListener("pointerup", endDrag);
input.addEventListener("pointercancel", endDrag);
input.addEventListener("keydown", (event) => {
event.stopPropagation();
if (event.key === "Enter") input.blur();
else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
const coarse = state.settings?.step ?? DEFAULT_STEP;
const step = (event.shiftKey ? FINE_STEP : coarse) * (event.key === "ArrowUp" ? 1 : -1);
commitValue(state.rows[index][key] + step);
input.value = state.rows[index][key].toFixed(2);
input.select();
}
});
input.addEventListener("blur", () => {
if (!input.readOnly) {
const number = Number(input.value);
if (Number.isFinite(number)) commitValue(number);
input.readOnly = true;
}
input.value = state.rows[index][key].toFixed(2);
});
return input;
}
// ---------- picker ----------
function openPicker(state, index, anchor) {
const pop = el("div");
const search = el("input", "ausboss-lora-search");
search.placeholder = "Search LoRAs...";
const list = el("div", "ausboss-lora-list");
list.append(el("div", "ausboss-lora-empty", "Loading..."));
pop.append(search, list);
const anchorRect = anchor.getBoundingClientRect();
const { place } = openPopup(pop, anchorRect, { width: Math.max(260, Math.min(380, anchorRect.width + 80)) });
search.focus();
let names = [];
let filtered = [];
let highlight = -1;
// One floating poster image per picker; repositioned beside the popup on
// hover, hidden when a LoRA has no preview file.
const hoverThumb = el("img", "ausboss-lora-hoverthumb");
hoverThumb.alt = "";
hoverThumb.style.display = "none";
hoverThumb.addEventListener("error", () => { hoverThumb.style.display = "none"; });
pop.append(hoverThumb);
const showThumb = (name, optionRect) => {
if (state.settings?.thumbnails === false) return;
const popRect = pop.getBoundingClientRect();
const left = popRect.right + 188 <= window.innerWidth ? popRect.right + 6 : popRect.left - 188;
hoverThumb.style.left = `${Math.max(4, left)}px`;
hoverThumb.style.top = `${Math.min(optionRect.top, window.innerHeight - 190)}px`;
hoverThumb.style.display = "";
hoverThumb.src = api.apiURL(`/ausboss/lora/thumb?name=${encodeURIComponent(name)}`);
};
const pick = (name) => {
if (!name) return;
closePopup();
const rows = state.rows.map((row, i) =>
// A different file means different trigger words: clear the old ones.
i === index ? { ...row, name, triggers: name === row.name ? row.triggers : "" } : row
);
commitRows(state, rows, { structural: true });
};
const appendOption = (name, flatIndex, label) => {
const option = el("div", "ausboss-lora-option", displayName(state, label));
option.title = name;
if (flatIndex === highlight) option.classList.add("highlight");
if (name === state.rows[index].name) option.classList.add("current");
option.addEventListener("pointerenter", () => {
if (highlight !== flatIndex) {
highlight = flatIndex;
renderList();
}
showThumb(name, option.getBoundingClientRect());
});
option.addEventListener("click", () => pick(name));
option.dataset.ausbossFlat = String(flatIndex);
list.append(option);
};
const renderList = () => {
filtered = filterLoras(names, search.value);
highlight = clampHighlight(highlight, filtered.length);
list.textContent = "";
hoverThumb.style.display = "none";
if (!filtered.length) {
list.append(el("div", "ausboss-lora-empty",
names.length ? "No matches." : "Put LoRA files in models/loras."));
return;
}
const searching = search.value.trim() !== "";
if (searching) {
// Flat results: strip whatever folder prefix every match shares.
const prefix = commonFolderPrefix(filtered);
filtered.forEach((name, i) => appendOption(name, i, name.slice(prefix.length)));
} else {
// Browse view: bucket by top folder with quiet headers. The grouped
// display order becomes the keyboard order, so `filtered` is rebuilt
// to match and arrow/Enter navigation stays consistent with what's
// on screen (root files can interleave folders in sorted order).
const groups = groupByFolder(filtered);
filtered = groups.flatMap((group) => group.names);
highlight = clampHighlight(highlight, filtered.length);
let flatIndex = 0;
for (const group of groups) {
if (group.folder) list.append(el("div", "ausboss-lora-folder", group.folder));
for (const name of group.names) {
const label = group.folder ? name.slice(group.folder.length + 1) : name;
appendOption(name, flatIndex, label);
flatIndex += 1;
}
}
}
list.querySelector(`[data-ausboss-flat="${highlight}"]`)?.scrollIntoView({ block: "nearest" });
place();
};
search.addEventListener("input", () => { highlight = -1; renderList(); });
search.addEventListener("keydown", (event) => {
event.stopPropagation();
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
highlight = moveHighlight(highlight, event.key === "ArrowDown" ? 1 : -1, filtered.length);
renderList();
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
highlight = event.key === "Home" ? 0 : filtered.length - 1;
renderList();
} else if (event.key === "Enter") {
event.preventDefault();
pick(highlightedName(filtered, highlight) ?? filtered[0]);
}
});
// Always refetch on open: a stale session cache is how renamed files keep
// haunting pickers.
fetchLoraList()
.then((fetched) => {
names = fetched;
const currentIndex = filterLoras(names, "").indexOf(state.rows[index].name);
highlight = currentIndex >= 0 ? currentIndex : -1;
renderList();
})
.catch(() => {
list.textContent = "";
list.append(el("div", "ausboss-lora-empty", "Could not list LoRAs."));
});
}
// ---------- info card ----------
function openInfo(state, index, anchor) {
const row = state.rows[index];
if (!row.name) return;
const card = el("div", "ausboss-lora-card");
card.append(el("div", "ausboss-lora-empty", "Loading..."));
openPopup(card, anchor.getBoundingClientRect());
const render = (info) => {
card.textContent = "";
const image = el("img");
image.alt = "";
image.src = api.apiURL(`/ausboss/lora/thumb?name=${encodeURIComponent(row.name)}`);
image.addEventListener("error", () => image.remove());
if (info.has_preview) card.append(image);
card.append(el("h4", "", info.civitai_title || displayName(state, row.name)));
if (info.base_model) card.append(el("div", "ausboss-lora-meta", `Base model: ${info.base_model}`));
const chipSection = (label, words) => {
if (!words?.length) return;
card.append(el("div", "ausboss-lora-meta", label));
const chips = el("div", "ausboss-lora-chips");
for (const word of words) {
const chip = el("button", "ausboss-lora-chip", word);
chip.type = "button";
chip.title = "Toggle this trigger word into the row's output";
const refresh = () => chip.classList.toggle(
"active",
state.rows[index].triggers.toLowerCase().split(",").map((part) => part.trim()).includes(word.toLowerCase())
);
refresh();
chip.addEventListener("click", () => {
const rows = state.rows.map((entry, i) =>
i === index ? { ...entry, triggers: toggleTrigger(entry.triggers, word) } : entry
);
commitRows(state, rows);
refresh();
});
chips.append(chip);
}
card.append(chips);
};
chipSection("From the file", info.file_triggers);
chipSection("From Civitai", info.civitai_triggers);
chipSection("Your words", info.custom_triggers);
if (!info.has_civitai && state.settings?.civitai_lookup !== false) {
const fetchButton = el("button", "ausboss-lora-add ausboss-lora-fetch", "Fetch Civitai info");
fetchButton.type = "button";
fetchButton.addEventListener("click", async () => {
fetchButton.disabled = true;
fetchButton.textContent = "Fetching...";
try {
const response = await api.fetchApi("/ausboss/lora/civitai", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: row.name }),
});
const data = await response.json();
if (!data.ok) throw new Error(data.error || "fetch failed");
load();
} catch (error) {
fetchButton.textContent = "Not found on Civitai";
}
});
card.append(fetchButton);
}
const range = el("div", "ausboss-lora-range");
range.append(el("span", "ausboss-lora-meta", "Suggested strength"));
const bound = (key, placeholder) => {
const inputEl = el("input");
inputEl.type = "number";
inputEl.step = "0.05";
inputEl.placeholder = placeholder;
const current = info.range?.[key];
if (current !== null && current !== undefined) inputEl.value = String(current);
inputEl.addEventListener("keydown", (event) => {
event.stopPropagation();
if (event.key === "Enter") inputEl.blur();
});
inputEl.addEventListener("change", async () => {
const minValue = Number(range.querySelector("[data-bound=min]").value);
const maxValue = Number(range.querySelector("[data-bound=max]").value);
const payload = {
name: row.name,
words: info.custom_triggers || [],
min: range.querySelector("[data-bound=min]").value === "" ? null : minValue,
max: range.querySelector("[data-bound=max]").value === "" ? null : maxValue,
};
try {
await api.fetchApi("/ausboss/lora/triggers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
rangeCache.delete(row.name);
ensureRange(state, row.name);
} catch {}
});
inputEl.dataset.bound = key;
return inputEl;
};
range.append(bound("min", "min"), el("span", "ausboss-lora-meta", "to"), bound("max", "max"));
range.title = "Advisory range for this LoRA; out-of-range strengths tint orange on the row.";
card.append(range);
const custom = el("div", "ausboss-lora-custom");
const wordInput = el("input");
wordInput.placeholder = "Add your own trigger word...";
wordInput.addEventListener("keydown", (event) => {
event.stopPropagation();
if (event.key === "Enter") addButton.click();
});
const addButton = el("button", "ausboss-lora-add", "Add");
addButton.type = "button";
addButton.addEventListener("click", async () => {
const word = wordInput.value.trim();
if (!word) return;
wordInput.value = "";
const words = [...(info.custom_triggers || []), word];
try {
await api.fetchApi("/ausboss/lora/triggers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: row.name, words }),
});
} catch {}
const rows = state.rows.map((entry, i) =>
i === index ? { ...entry, triggers: toggleTrigger(entry.triggers, word) } : entry
);
commitRows(state, rows);
load();
});
custom.append(wordInput, addButton);
card.append(custom);
};
const load = () => {
api.fetchApi(`/ausboss/lora/info?name=${encodeURIComponent(row.name)}`)
.then((response) => response.json())
.then((data) => {
if (!data.ok) throw new Error(data.error);
render(data.info);
})
.catch(() => {
card.textContent = "";
card.append(el("div", "ausboss-lora-empty", "Could not read LoRA info."));
});
};
load();
}
// ---------- templates ----------
const TEMPLATES_KEY = "AusBoss.lora_loader.templates";
function loadTemplates() {
try {
return parseTemplates(window.localStorage?.getItem(TEMPLATES_KEY) ?? "[]");
} catch {
return [];
}
}
function saveTemplates(list) {
try {
window.localStorage?.setItem(TEMPLATES_KEY, JSON.stringify(list));
} catch {}
}
function openTemplates(state, anchor) {
const menu = el("div", "ausboss-lora-menu ausboss-lora-templates");
const rebuild = () => {
menu.textContent = "";
const saveRow = el("div", "ausboss-lora-custom");
const nameInput = el("input");
nameInput.placeholder = "Save current as...";
nameInput.title = "Name this stack; saving over an existing name replaces it";
const saveButton = el("button", "ausboss-lora-add", "Save");
saveButton.type = "button";
const save = () => {
const template = templateFromRows(nameInput.value, state.rows);
if (!template) return;
saveTemplates(upsertTemplate(loadTemplates(), template));
nameInput.value = "";
rebuild();
};
saveButton.addEventListener("click", save);
nameInput.addEventListener("keydown", (event) => {
event.stopPropagation();
if (event.key === "Enter") save();
});
saveRow.append(nameInput, saveButton);
menu.append(saveRow);
const templates = loadTemplates();
if (!templates.length) {
menu.append(el("div", "ausboss-lora-empty", "No saved templates yet."));
return;
}
for (const template of templates) {
const row = el("div", "ausboss-lora-template-row");
const apply = el("button", "", template.name);
apply.type = "button";
apply.title = `Replace this node's rows with "${template.name}" (${template.rows.length} LoRA${template.rows.length === 1 ? "" : "s"})`;
apply.addEventListener("click", () => {
closePopup();
commitRows(state, applyTemplate(template), { structural: true });
fitNode(state);
});
const remove = el("button", "ausboss-lora-template-x", "×");
remove.type = "button";
remove.title = `Delete "${template.name}"`;
remove.addEventListener("click", (event) => {
event.stopPropagation();
saveTemplates(removeTemplate(loadTemplates(), template.name));
rebuild();
});
row.append(apply, remove);
menu.append(row);
}
};
rebuild();
openPopup(menu, anchor.getBoundingClientRect(), { width: 240 });
menu.querySelector("input")?.focus();
}
// ---------- row menu ----------
function openRowMenu(state, index, event) {
const menu = el("div", "ausboss-lora-menu");
const item = (label, action, disabled = false) => {
const button = el("button", "", label);
button.type = "button";
button.disabled = disabled;
button.addEventListener("click", () => { closePopup(); action(); });
menu.append(button);
};
item("Move up", () => commitRows(state, moveRow(state.rows, index, -1), { structural: true }), index === 0);
item("Move down", () => commitRows(state, moveRow(state.rows, index, 1), { structural: true }),
index === state.rows.length - 1);
item("Duplicate", () => {
const copy = { ...state.rows[index], id: newRow().id };
const rows = state.rows.slice();
rows.splice(index + 1, 0, copy);
commitRows(state, rows, { structural: true });
fitNode(state);
}, state.rows.length >= MAX_ROWS);
item("Remove", () => {
const rows = state.rows.slice();
rows.splice(index, 1);
commitRows(state, rows, { structural: true });
fitNode(state);
});
openPopup(menu, { left: event.clientX, top: event.clientY, bottom: event.clientY, width: 0 });
}
// ---------- rendering ----------
function updateRowValues(state) {
state.panel.querySelectorAll(".ausboss-lora-strength").forEach((input) => {
if (input.readOnly && input.__ausbossRead) input.value = input.__ausbossRead();
if (input.__ausbossTint) input.__ausbossTint();
});
const summary = state.panel.querySelector(".ausboss-lora-summary");
if (summary) summary.textContent = summarizeRows(state.rows);
const master = state.panel.querySelector(".ausboss-lora-master");
if (master) {
const overall = toggleAllState(state.rows);
master.classList.toggle("on", overall === "on");
master.classList.toggle("mixed", overall === "mixed");
}
}
function openSettings(state, anchor) {
openSettingsMenu({
scope: SETTINGS_SCOPE,
schema: SETTINGS_SCHEMA,
anchor: anchor.getBoundingClientRect(),
title: "LoRA Loader settings",
// The menu reflects THIS node's live state where the two overlap.
initial: {
...state.settings,
separate_strengths: !linked(state),
...(state.separatorWidget ? { separator: state.separatorWidget.value } : {}),
},
onChange: (values, key) => {
state.settings = values;
// Two settings also steer this node's own serialized state, so the
// change takes effect here immediately, not just on future nodes.
if (key === "separate_strengths" || key === null) {
state.node.properties.ausbossLoraLinked = !values.separate_strengths;
if (!values.separate_strengths) {
commitRows(
state,
state.rows.map((row) => ({ ...row, strength_clip: row.strength })),
);
}
}
if ((key === "separator" || key === null) && state.separatorWidget) {
state.separatorWidget.value = values.separator;
}
renderRows(state);
fitNode(state);
},
});
}
function renderRows(state) {
const panel = state.panel;
panel.textContent = "";
const addRowAndPick = () => {
if (state.rows.length >= MAX_ROWS) return;
const strength = roundStrength(state.settings?.default_strength ?? 1);
const row = { ...newRow(), strength, strength_clip: strength };
commitRows(state, [...state.rows, row], { structural: true });
fitNode(state);
const pickers = state.panel.querySelectorAll(".ausboss-lora-name");
pickers[pickers.length - 1]?.click();
};
const actions = el("div", "ausboss-lora-actions");
const add = el("button", "ausboss-lora-add", "+ Add LoRA");
add.type = "button";
add.disabled = state.rows.length >= MAX_ROWS;
add.addEventListener("click", addRowAndPick);
actions.append(add);
panel.append(actions);
const bar = el("div", "ausboss-lora-bar");
const overall = toggleAllState(state.rows);
const master = el(
"button",
`ausboss-lora-toggle ausboss-lora-master${overall === "on" ? " on" : overall === "mixed" ? " mixed" : ""}`
);
master.type = "button";
master.title = "Toggle every LoRA: mixed or off turns all on, on turns all off";
master.addEventListener("click", () => {
commitRows(state, toggleAllRows(state.rows), { structural: true });
});
const templates = el("button", "ausboss-lora-gear", "▤");
templates.type = "button";
templates.title = "LoRA templates: save this stack, or apply a saved one";
templates.addEventListener("click", () => openTemplates(state, templates));
const gear = el("button", "ausboss-lora-gear");
gear.type = "button";
gear.title = "LoRA Loader settings";
gear.innerHTML = gearIconSvg();
gear.addEventListener("click", () => openSettings(state, gear));
bar.append(master, el("span", "ausboss-lora-summary", summarizeRows(state.rows)), templates, gear);
panel.append(bar);
const stack = el("div", "ausboss-lora-stack");
if (!state.rows.length) {
const blank = el("button", "ausboss-lora-blank", "No LoRAs yet");
blank.type = "button";
blank.title = "Add a LoRA";
blank.addEventListener("click", addRowAndPick);
stack.append(blank);
}
state.rows.forEach((row, index) => {
const rowElement = el("div", "ausboss-lora-row");
if (!row.enabled) rowElement.classList.add("off");
const toggle = el("button", `ausboss-lora-toggle${row.enabled ? " on" : ""}`);
toggle.type = "button";
toggle.title = "Enable or disable this LoRA";
toggle.addEventListener("click", () => {
const rows = state.rows.map((entry, i) =>
i === index ? { ...entry, enabled: !entry.enabled } : entry
);
commitRows(state, rows, { structural: true });
});
const name = el("button", "ausboss-lora-name", row.name ? displayName(state, row.name) : "choose a LoRA...");
name.type = "button";
name.title = row.name || "Pick a LoRA from models/loras";
if (!row.name) name.classList.add("empty");
name.addEventListener("click", () => openPicker(state, index, name));
const strength = strengthBox(state, index, "strength");
strength.__ausbossRead = () => state.rows[index]?.strength.toFixed(2) ?? "1.00";
strength.__ausbossTint = () => state.rows[index] && applyRangeTint(strength, state.rows[index], "strength");
rowElement.append(toggle, name, strength);
if (!linked(state)) {
const clip = strengthBox(state, index, "strength_clip");
clip.__ausbossRead = () => state.rows[index]?.strength_clip.toFixed(2) ?? "1.00";
clip.__ausbossTint = () => state.rows[index] && applyRangeTint(clip, state.rows[index], "strength_clip");
rowElement.append(clip);
}
const info = el("button", "ausboss-lora-info", "i");
info.type = "button";
info.title = "Preview, base model, and trigger words";
info.addEventListener("click", () => openInfo(state, index, info));
rowElement.append(info);
rowElement.addEventListener("contextmenu", (event) => {
event.preventDefault();
event.stopPropagation();
openRowMenu(state, index, event);
});
stack.append(rowElement);
});
panel.append(stack);
}
// ---------- node install ----------
function installLoraNode(node) {
installStyles();
const widget = node.widgets?.find((item) => item.name === "loras");
if (!widget) return;
hideWidget(widget);
const settings = loadSettings(SETTINGS_SCOPE, SETTINGS_SCHEMA);
const panel = el("div", "ausboss-lora-panel");
const state = { node, widget, panel, rows: parseRows(widget.value), settings };
node.__ausbossLoraState = state;
if (node.properties && node.properties.ausbossLoraLinked === undefined) {
node.properties.ausbossLoraLinked = !settings.separate_strengths;
}
// The separator rides a hidden standard widget so save/load and the API
// format see it; a workflow restore overwrites this seed right after.
const separatorWidget = node.widgets?.find((item) => item.name === "trigger_separator");
if (separatorWidget) {
hideWidget(separatorWidget);
separatorWidget.value = settings.separator;
state.separatorWidget = separatorWidget;
}
const domWidget = node.addDOMWidget("ausboss_lora_rows", "ausboss_lora_rows", panel, {
serialize: false,
hideOnZoom: false,
});
keepDomWidgetWidthAuto(domWidget);
// The same minimum through every sizing path the frontends consult -
// legacy computeSize, modern computeLayoutSize, and the resize clamp -
// so the panel and the node can never disagree about how narrow is legal.
// Without the layout minimum, the node could be dragged below the panel's
// floor while the panel held its width, and the row's fixed-width strength
// and info controls hung past the node's right edge.
domWidget.computeSize = (width) => [
Math.max(PANEL_MIN_WIDTH, Number(width || node.size?.[0] || PANEL_MIN_WIDTH)),
panelHeight(state),
];
domWidget.computeLayoutSize = () => ({
minWidth: PANEL_MIN_WIDTH,
minHeight: panelHeight(state),
});
domWidget.options.minNodeSize = [PANEL_MIN_WIDTH, 160];
renderRows(state);
node.setSize?.([Math.max(336, node.size?.[0] || 336), node.computeSize?.()[1] || 220]);
// Workflow restore lands widget values after creation: re-read then.
chainCallback(node, "onConfigure", function () {
state.rows = parseRows(widget.value);
renderRows(state);
requestAnimationFrame(() => fitNode(state));
});
chainCallback(node, "onRemoved", () => closePopup());
}
app.registerExtension({
name: "AusBoss.LoraLoader",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_CLASS) return;
chainCallback(nodeType.prototype, "onNodeCreated", function () {
installLoraNode(this);
});
},
});