Files
ausboss 9aefc53134 LoRA Loader: say why a Civitai lookup failed
The button still reads Civitai lookup failed, and a message now gives the
reason. A browser that cannot reach civitai.com at all names the usual
causes: a firewall, a VPN, or ComfyUI started with --disable-api-nodes.
2026-10-25 13:00:09 -05:00

3360 lines
144 KiB
JavaScript
Raw Permalink 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, notifyAusbossChange, showToast } from "../shared/index.mjs";
import { WIDGET_FRAME, fillNodeHeight, holdVueNodeMinWidth } from "../shared/panel_layout.mjs";
import { hideInputsInDef, hideWidget as collapseWidget } from "../shared/widget_visibility.mjs";
import {
closeSettingsMenu,
gearIconSvg,
loadSettings,
openSettingsMenu,
} from "../shared/settings_menu.mjs";
import { makeScrubInput } from "../shared/scrub_input.mjs";
import { isAppShortcut, keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { resetUnknownValues } from "../shared/widget_card_math.mjs";
import { boxRowInputs, boxRowsWidget, boxesOf, hostBox, rowsInputName, workflowNodes } from "../shared/lora_box.mjs";
import {
SETTINGS_PROPERTY as SWITCHES_PROPERTY, releaseStackGroup, releaseStackRow, renameStackGroup, stackGroupOwners, stackRowOwners,
} from "../shared/workflow_switches.mjs";
import {
BYPASS_MODE,
DEFAULT_STEP,
FINE_STEP,
MAX_ROWS,
MUTE_MODE,
STRENGTH_MAX,
STRENGTH_MIN,
appendFolderRows,
clampHighlight,
fitLoraLabel,
formatFileSize,
groupName,
groupNames,
groupRows,
hasGroupName,
hasTrigger,
MAX_GROUP_NAME,
applyTemplate,
cycleMasterToggle,
duplicateLoraKeys,
importNeedsSeparateStrengths,
importSummary,
isScrubbing,
loaderLoraEntries,
loraFolders,
loraPickerEntries,
loraStackKeyAction,
collectUpstreamLoaders,
collectDownstreamLoaders,
absorbChainIssue,
mergeImportedRows,
moveGroup,
moveHighlight,
moveRowInStack,
moveRowsToGroup,
newRow,
parseRows,
parseTemplates,
pickerWidth,
placeGroup,
placeRow,
removeTemplate,
renameGroup,
roundStrength,
resolveLoraName,
scrubValue,
serializeRows,
setGroupEnabled,
setGroupOff,
setStrength,
shortLoraName,
snapshotEnabled,
stackLayout,
strengthBarBackground,
strengthBarScale,
thumbPosition,
strengthOutOfRange,
summarizeRows,
templateFromRows,
toggleAllState,
toggleTrigger,
ungroupRows,
uniqueGroupName,
upsertTemplate,
} from "../shared/lora_stack.mjs";
import {
MAX_PICTURE_BYTES, civitaiButtonLabel, civitaiByHashUrl, civitaiFailureText, isCivitaiAddress,
parseCivitaiBody, pickCivitaiPicture, smallPictureUrl,
} from "../shared/civitai_lookup.mjs";
const NODE_CLASS = "AUSBOSS_NODES_LoraLoader";
// A workflow saved by a 1.x release holds the panel's own empty value where
// on_missing now sits (saved values come back by position); it loads as
// "error", the node's default and what an API prompt without it runs with.
const RESET_UNKNOWN = { on_missing: "error" };
const ROW_HEIGHT = 30;
const ROW_GAP = 6;
const ACTIONS_HEIGHT = 26;
const GROUP_HEIGHT = 36;
const STACK_PADDING = 8;
const BLANK_HEIGHT = 44;
const PANEL_PADDING = 10;
// How many of the slot band's pixels the panel climbs back up into. Three output slots reserve 66px of node height before the first
// widget, and the band's whole middle is empty - so the control bar rides
// there (like Pixaroma's controls do) and the row stack starts at the
// band's bottom edge. 52 puts the 36px bar at y 24..60, clear of the title
// and flush with the band.
const BAND_RECLAIM = 52;
// The one line that says where an inner loader's rows are set (see
// rowsSource), and the Workflow Switches node that can own a row.
const NOTE_HEIGHT = 16;
// A group's header row, how far its LoRAs sit in, and the node property
// that keeps which groups are folded.
const HEADER_HEIGHT = 26;
const GROUP_INDENT = 18;
const FOLDED_PROPERTY = "ausbossLoraFolded";
const SWITCHES_CLASS = "AUSBOSS_NODES_WorkflowSwitches";
// 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, scrub: true, step: 0.05, fineStep: 0.01, decimals: 2,
hint: "Strength a newly added LoRA starts at.",
},
{
key: "step", label: "Strength step", type: "number",
default: 0.05, min: 0.01, max: 1, scrub: true, step: 0.01, fineStep: 0.01, decimals: 2,
hint: "Scrub and arrow-key step. Shift always steps by 0.01.",
},
{
// persist:false - this mirrors THE OPEN NODE's linked state, so it must
// never become the stored default: new nodes always start unified. (An
// absorb can flip a node whose imported rows patch model and CLIP
// differently; that flip stays on that node.)
key: "separate_strengths", label: "Separate model / CLIP strength",
type: "toggle", default: false, persist: false,
hint: "Two strength boxes per row on this node. New nodes always start "
+ "unified.",
},
{
key: "strength_bars", label: "Strength bars", type: "toggle",
default: true,
hint: "Center-zero bar behind each name: teal grows right for positive "
+ "strength, red grows left for negative, scaled so the stack's "
+ "largest magnitude spans the field (never less than 1.0).",
},
{
key: "separator", label: "Trigger word separator", type: "text",
default: ", ", placeholder: '", "',
hint: "Joins the triggers output. This node now, new nodes later.",
},
{
key: "strict_missing", label: "Stop on missing LoRA", type: "toggle",
default: true,
hint: "On (the default): fail the run before sampling when an enabled row's "
+ "file cannot be found, with an error naming it. Off: skip that row with "
+ "a console warning and render without it. This node now, new nodes later.",
},
{
key: "hide_extension", label: "Hide file extension", type: "toggle",
default: true,
hint: "Show LoRA names without .safetensors.",
},
{
key: "hide_folders", label: "Hide folder names", type: "toggle",
default: true,
hint: "Rows show just the file name; the full path stays in the "
+ "tooltip and the picker keeps its folders.",
},
{
key: "civitai_lookup", label: "Civitai lookup button", type: "toggle",
default: true,
hint: "Offer a Fetch Civitai info button in the info card. Clicking it "
+ "asks civitai.com about that one file, from this browser, and saves "
+ "the answer next to the LoRA. Nothing is sent until you click.",
},
{
key: "civitai_picture", label: "Civitai picture", type: "choice",
options: ["off", "PG-13", "any"], default: "PG-13",
hint: "Fetch Civitai info also saves the LoRA's first picture next to "
+ "the file, so hovering the LoRA shows it. PG-13 skips pictures Civitai "
+ "rates R or above. A picture you already have is never replaced.",
},
{
key: "name_cut", label: "Long names", type: "choice",
options: ["end", "middle"], default: "end",
hint: "How a row shortens a name that does not fit. middle keeps the "
+ "end in view, where checkpoint numbers like _000004000 usually are.",
},
{
key: "name_scrub", label: "Scrub strength on the name", type: "toggle",
default: true,
hint: "Drag left/right on a row's name to change its model strength - "
+ "the bar rides along. A plain click still opens the picker.",
},
{
key: "thumbnails", label: "Preview thumbnails", type: "toggle",
default: true,
hint: "Poster image beside the picker while hovering a LoRA.",
},
];
// 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. Separate model / CLIP strengths put a second
// strength box and its gap on every row, so that mode's floor is wider by
// as much, and the name keeps the same room.
const PANEL_MIN_WIDTH = 320;
const SEPARATE_EXTRA_WIDTH = 72;
// The popups' font, shared with the picker's width measurement, and the
// picker's side paddings, from which an option's text width is worked out.
const POP_FONT = "12px system-ui";
const LIST_PAD_X = 4;
const OPTION_PAD_X = 8;
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 { width: 100%; height: 100%;
font: 12px system-ui; color: #d7dde2; pointer-events: none; }
/* The body is the panel's layout box: it fences every child and carries
the overflow clip. height: 100% tracks whatever box the frontend
allocates, so a short allocation shrinks the stack instead of
chopping its bottom border. */
/* pointer-events: the panel overlays the slot band's middle (see
BAND_RECLAIM), so everything down to the body passes clicks through to
the canvas - slot dots stay wirable, empty band areas still drag the
node - and the three real surfaces opt back in below. The wrapper rule
needs !important because the frontend writes pointer-events: auto
inline on its own wrapper each frame; pointer-events does not inherit,
so the interactive children stay clickable regardless. */
.dom-widget:has(> .ausboss-lora-panel) { pointer-events: none !important; }
.ausboss-lora-body { display: flex; flex-direction: column; gap: ${ROW_GAP}px;
box-sizing: border-box; width: 100%; height: 100%; padding: ${PANEL_PADDING}px;
overflow: hidden; pointer-events: none; }
.ausboss-lora-bargroup, .ausboss-lora-stack,
.ausboss-lora-actions { pointer-events: auto; }
.ausboss-lora-row { flex: none; display: flex; align-items: center; gap: 6px; height: ${ROW_HEIGHT}px; }
.ausboss-lora-row:focus-visible { outline: 2px solid ${BRAND}; outline-offset: 1px; border-radius: 4px; }
/* An off row dims its controls, not itself: the row's own opacity would
dim the focus outline with them. A dragged row is dimmed as one piece. */
.ausboss-lora-row.off > * { opacity: 0.45; }
.ausboss-lora-row.dragging { opacity: 0.55; }
.ausboss-lora-row.dragging > * { opacity: 1; }
/* Groups: a header row, then its LoRAs set in behind a thin line. The
header's switch shares the plain rows' switch column. */
.ausboss-lora-ghead { flex: none; display: flex; align-items: center; gap: 6px;
height: ${HEADER_HEIGHT}px; border-radius: 5px; padding-right: 4px; }
.ausboss-lora-ghead.drop { background: rgba(0,180,170,.16); box-shadow: inset 0 0 0 1px ${BRAND}; }
.ausboss-lora-ghead.dragging, .ausboss-lora-row.child.dragging { opacity: 0.45; }
.ausboss-lora-ghead.off .ausboss-lora-gname { opacity: 0.55; }
/* A group switched off: its rows keep their own switches, dimmed. */
.ausboss-lora-row.groupoff > * { opacity: 0.4; }
.ausboss-lora-row.groupoff.off > * { opacity: 0.25; }
.ausboss-lora-gfold { width: 16px; height: 20px; border: none; background: transparent;
color: #9ba2aa; cursor: pointer; padding: 0; display: grid; place-items: center; flex: none; }
.ausboss-lora-gfold:hover { color: ${BRAND}; }
.ausboss-lora-gfold svg { transition: transform .12s; }
.ausboss-lora-ghead.folded .ausboss-lora-gfold svg { transform: rotate(-90deg); }
.ausboss-lora-gicon { flex: none; color: #8fb9b4; display: grid; place-items: center; }
.ausboss-lora-gname { flex: 1 1 auto; min-width: 0; color: #dfe6ea; font: 600 12px system-ui;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ausboss-lora-gcount { flex: none; color: #7d868e; font: 11px system-ui; }
.ausboss-lora-gname-input { flex: 1 1 auto; min-width: 0; height: 22px; border: 1px solid ${BRAND};
border-radius: 4px; background: #23272c; color: #fff; font: 600 12px system-ui;
padding: 0 6px; outline: none; }
.ausboss-lora-row.child { margin-left: ${GROUP_INDENT}px; position: relative; }
.ausboss-lora-row.child::after { content: ""; position: absolute; left: -${GROUP_INDENT - 6}px;
top: -${ROW_GAP}px; bottom: 0; width: 1px; background: rgba(0,180,170,.35);
pointer-events: none; }
.ausboss-lora-row.folded-away { display: none; }
.ausboss-lora-row.selected { background: rgba(0,180,170,.16);
box-shadow: 0 0 0 1px rgba(0,180,170,.7); border-radius: 5px; }
.ausboss-lora-dropline { position: fixed; height: 2px; background: ${BRAND}; border-radius: 1px;
z-index: 10002; pointer-events: none; box-shadow: 0 0 0 1px rgba(0,0,0,.4); }
.ausboss-lora-dropline::before { content: ""; position: absolute; left: -4px; top: -3px;
width: 8px; height: 8px; border-radius: 50%; background: ${BRAND}; }
.ausboss-lora-dragghost { position: fixed; z-index: 10003; pointer-events: none; padding: 3px 10px;
border-radius: 5px; background: #23272c; border: 1px solid ${BRAND}; color: #dfe6ea;
font: 12px system-ui; box-shadow: 0 6px 18px rgba(0,0,0,.5); white-space: nowrap;
max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
.ausboss-lora-grip { width: 14px; height: 24px; border: none; background: transparent;
color: #5c646c; cursor: grab; flex: none; padding: 0; display: grid; place-items: center;
touch-action: none; }
.ausboss-lora-grip:hover { color: #9ba2aa; }
.ausboss-lora-row.dragging .ausboss-lora-grip { cursor: grabbing; }
/* A row a Workflow Switches switch sets: a lock in the grip's place, and a
thin line down its left edge. Clicking the lock takes the row over. */
.ausboss-lora-grip.locked { color: #8fdcd4; cursor: pointer; }
.ausboss-lora-grip.locked:hover { color: #c4f1ec; }
/* The line sits in the stack's padding, clear of the lock. */
.ausboss-lora-row.owned, .ausboss-lora-ghead.owned { position: relative; }
.ausboss-lora-row.owned::before, .ausboss-lora-ghead.owned::before { content: ""; position: absolute;
left: -6px; top: 4px; bottom: 4px; width: 2px; border-radius: 1px; background: ${BRAND}; }
/* Where an inner loader's rows are set when its box holds them. */
.ausboss-lora-onbox { flex: none; height: ${NOTE_HEIGHT}px; line-height: ${NOTE_HEIGHT}px;
text-align: center; color: #8fb9b4; font: 11px system-ui; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; pointer-events: auto; }
.ausboss-lora-panel.readonly .ausboss-lora-bargroup,
.ausboss-lora-panel.readonly .ausboss-lora-stack { pointer-events: none; opacity: .55; }
.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; }
/* Inside the stack container, pinned to its bottom edge: the auto margin
soaks up whatever free height the stack was handed. */
.ausboss-lora-actions { flex: none; display: flex; height: ${ACTIONS_HEIGHT}px;
margin-top: auto; }
/* The control bar is the panel's first row, sitting directly on top of
the stack; the bordered group inside it holds the controls as one
centered cluster. */
.ausboss-lora-bar { flex: none; display: flex; justify-content: center; }
/* The group stretches toward the panel width but stops short of the slot
labels it rides between (see BAND_RECLAIM); the summary flexes in the
middle so the controls spread instead of huddling. */
.ausboss-lora-bargroup { display: flex; align-items: center;
gap: 8px; min-width: 0; flex: 1 1 auto;
max-width: min(440px, calc(100% - 116px)); height: ${GROUP_HEIGHT}px; padding: 0 10px;
border: 1px solid #3a4047; border-radius: 7px; background: rgba(255,255,255,.05);
color: #9ba2aa; }
.ausboss-lora-gear { 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 { flex: 1 1 auto; min-height: 0; overflow: hidden;
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 { flex: 1 1 auto; display: flex; align-items: center; justify-content: center;
width: 100%; min-height: ${BLANK_HEIGHT}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; }
.ausboss-lora-strengthbox.out-of-range { 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-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;
touch-action: none; }
.ausboss-lora-name:hover { border-color: ${BRAND}; }
.ausboss-lora-name.missing { color: #ff8a80; }
.ausboss-lora-name.moved { border-style: dashed; border-color: #3c6663; }
.ausboss-lora-name.dup { border-color: #f0a11e;
box-shadow: inset 0 0 0 1px rgba(240,161,30,.4); }
.ausboss-lora-name.empty { color: #9ba2aa; font-style: italic; }
/* A name's text is fitted to this box by fitLabels (middle ellipsis);
the end ellipsis here only covers a box that has not been fitted. */
.ausboss-lora-label { display: block; overflow: hidden; text-overflow: ellipsis;
white-space: pre; }
.ausboss-lora-dir { opacity: .6; }
.ausboss-lora-strengthbox { display: flex; width: 66px; height: 24px; border: 1px solid #3a4047;
border-radius: 5px; background: #23272c; overflow: hidden; flex: none; }
.ausboss-lora-strengthbox:focus-within { border-color: ${BRAND}; }
.ausboss-lora-strength { flex: 1 1 auto; min-width: 0; height: 100%; border: none; padding: 0;
background: transparent; color: inherit; text-align: center; cursor: ew-resize;
user-select: none; }
.ausboss-lora-strength:focus { cursor: text; outline: none; user-select: text; }
.ausboss-lora-step { flex: none; width: 14px; display: flex; flex-direction: column;
border-left: 1px solid #3a4047; }
.ausboss-lora-step button { flex: 1 1 0; border: none; background: transparent; color: #9ba2aa;
cursor: pointer; padding: 0; display: grid; place-items: center; }
.ausboss-lora-step button:hover { color: ${BRAND}; background: rgba(255,255,255,.05); }
.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; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; text-align: center; }
.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: ${POP_FONT}; 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 ${LIST_PAD_X}px 6px;
scrollbar-gutter: stable; }
.ausboss-lora-option { padding: 5px ${OPTION_PAD_X}px; border-radius: 4px; cursor: pointer;
overflow: hidden; }
.ausboss-lora-folder-option, .ausboss-lora-folder { display: flex; align-items: center;
gap: 6px; font-weight: 600; padding-top: 2px; padding-bottom: 2px; }
.ausboss-lora-folder { padding-right: ${OPTION_PAD_X}px; color: #9ba2aa; }
.ausboss-lora-folder-option .ausboss-lora-label, .ausboss-lora-folder .ausboss-lora-label {
flex: 1 1 auto; min-width: 0; }
.ausboss-lora-folder-toggle { flex: none; width: 24px; height: 24px; display: grid;
place-items: center; padding: 0; border: none; border-radius: 4px; background: transparent;
color: #9ba2aa; cursor: pointer; }
.ausboss-lora-folder-toggle:hover { background: #3a4047; color: ${BRAND}; }
.ausboss-lora-folder-toggle:focus-visible { outline: 2px solid #9becf5; outline-offset: -2px; }
.ausboss-lora-folder-toggle[aria-expanded="false"] svg { transform: rotate(-90deg); }
.ausboss-lora-folder-count { flex: none; color: #9ba2aa; font-size: 11px; }
.ausboss-lora-picker-note { padding: 0 10px 8px; color: #9ba2aa; font-size: 11px; }
/* Tick boxes sit in the gap left of each file in the + LoRA list. */
.ausboss-lora-option.tickable { position: relative; display: flex; align-items: center; }
.ausboss-lora-option.tickable > .ausboss-lora-label { flex: 1 1 auto; min-width: 0; }
.ausboss-lora-tick { position: absolute; top: 50%; width: 14px; height: 14px; margin-top: -7px;
padding: 0; border: 1.5px solid #5c646c; border-radius: 3px; background: #23272c;
cursor: pointer; display: grid; place-items: center; }
.ausboss-lora-option:hover .ausboss-lora-tick { border-color: #9ba2aa; }
.ausboss-lora-tick:hover { border-color: ${BRAND} !important; }
.ausboss-lora-tick.on { background: ${BRAND}; border-color: ${BRAND}; }
.ausboss-lora-tick.on::after { content: ""; width: 7px; height: 4px; border: 2px solid #06211f;
border-top: 0; border-right: 0; transform: translate(0, -1px) rotate(-45deg); }
.ausboss-lora-option.ticked { background: rgba(0,180,170,.10); }
.ausboss-lora-option.instack { color: #7d868e; }
.ausboss-lora-instack { flex: none; padding-left: 8px; color: #7d868e; font-size: 11px;
white-space: nowrap; }
.ausboss-lora-tickbar { display: flex; align-items: center; gap: 8px; padding: 8px;
border-top: 1px solid #3a4047; }
.ausboss-lora-tickcount { flex: 1 1 auto; min-width: 0; color: #9ba2aa; font-size: 11px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ausboss-lora-tickclear { flex: none; height: 26px; border: 1px solid #3a4047; border-radius: 6px;
background: #23272c; color: #9ba2aa; padding: 0 10px; cursor: pointer; font: 12px system-ui; }
.ausboss-lora-tickclear:hover { border-color: #9ba2aa; color: #d7dde2; }
.ausboss-lora-option.highlight, .ausboss-lora-folder-option:hover { 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.indent { padding-left: 22px; }
.ausboss-lora-menu-sub { padding: 4px 8px 2px; color: #7d868e; font: 600 10px system-ui;
text-transform: uppercase; letter-spacing: .06em; }
.ausboss-lora-menu-rule { height: 1px; background: #3a4047; margin: 4px 2px; }
.ausboss-lora-menu button:disabled { opacity: 0.35; cursor: default; }
.ausboss-lora-menu > button.ausboss-lora-danger { color: #ff8585; }
.ausboss-lora-menu > button.ausboss-lora-delete-all { border-top: 1px solid #574047; margin-top: 6px; padding-top: 8px; }
.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-civitai-link { color: ${BRAND}; text-decoration: none; align-self: flex-start; }
.ausboss-lora-civitai-link:hover { text-decoration: underline; }
.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;
}
// ---------- long names ----------
// Names are cut in the middle, not at the end (fitLoraLabel in
// lora_stack.mjs): the end of a LoRA file name is what tells its
// checkpoints apart. A label keeps its whole text in data, so it can be
// refitted whenever its box changes width.
let measureContext = null;
function measureCanvas() {
measureContext ??= document.createElement("canvas").getContext("2d");
return measureContext;
}
// Widths are cached for the batch: labels in one folder share every
// shortened-folder candidate, so a long picker measures each only once.
function textMeasure(element) {
const context = measureCanvas();
if (!context) return null;
const style = getComputedStyle(element);
context.font = style.font
|| `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
const widths = new Map();
return (text) => {
let width = widths.get(text);
if (width === undefined) {
width = context.measureText(text).width;
widths.set(text, width);
}
return width;
};
}
function showLabel(label, { folder, name }) {
label.textContent = "";
if (folder) label.append(el("span", "ausboss-lora-dir", folder));
label.append(name);
}
// Plain text until fitLabels lays it out (it runs before the next paint).
function nameLabel(text) {
const label = el("span", "ausboss-lora-label", text);
label.dataset.ausbossFull = text;
return label;
}
// Every label in the list shares one font, so one measure serves them all.
// Widths are all read before any text is written: interleaving the two
// would lay the list out again for every label. A caller that already
// knows the labels' width passes it and no layout is read at all. A box
// that is not laid out yet (width 0) is left for its resize observer.
function fitLabels(labels, knownWidth) {
const list = [...labels];
if (!list.length) return;
const measure = textMeasure(list[0]);
if (!measure) return;
const widths = list.map((label) => knownWidth ?? label.clientWidth);
list.forEach((label, index) => {
if (widths[index] > 0) {
showLabel(label, fitLoraLabel(label.dataset.ausbossFull ?? "", widths[index] - 1, measure));
}
});
}
// Six-dot drag grip, hand-drawn like the shared gear so no glyph font is
// trusted to have it.
function gripIconSvg() {
const dots = [2, 7, 12].flatMap((y) => [2, 6].map((x) => `<circle cx="${x}" cy="${y}" r="1.3"/>`));
return `<svg width="8" height="14" viewBox="0 0 8 14" fill="currentColor" aria-hidden="true">${dots.join("")}</svg>`;
}
// A group header's folder.
function folderIconSvg() {
return '<svg width="15" height="13" viewBox="0 0 15 13" fill="none" stroke="currentColor" '
+ 'stroke-width="1.3" stroke-linejoin="round" aria-hidden="true">'
+ '<path d="M1.5 2.5a1 1 0 0 1 1-1h3.2l1.4 1.6h5.4a1 1 0 0 1 1 1v6.9a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1z"/></svg>';
}
// The grip of a row a switch sets.
function lockIconSvg() {
return '<svg width="11" height="13" viewBox="0 0 11 13" fill="none" stroke="currentColor" '
+ 'stroke-width="1.5" stroke-linecap="round" aria-hidden="true">'
+ '<rect x="1.2" y="5.6" width="8.6" height="6.4" rx="1.6" fill="currentColor" fill-opacity=".16"/>'
+ '<path d="M3.1 5.6V3.9a2.4 2.4 0 0 1 4.8 0v1.7"/></svg>';
}
// Stepper chevrons, hand-drawn for the same reason.
function chevronIconSvg(up) {
const points = up ? "1.5,4 4.5,1 7.5,4" : "1.5,1 4.5,4 7.5,1";
return `<svg width="9" height="5" viewBox="0 0 9 5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="${points}"/></svg>`;
}
// The shared collapse handles the sizing hooks; a text widget can also carry
// a DOM element on some frontends, so blank that too.
function hideWidget(widget) {
if (!widget) return;
collapseWidget(widget);
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");
if (!response.ok) throw new Error("Could not list LoRAs.");
const data = await response.json();
if (!Array.isArray(data.loras) || data.loras.some((name) => typeof name !== "string")) {
throw new Error("Could not list LoRAs.");
}
return 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() {
hideHoverThumb();
if (!activePopup) return;
activePopup.abort.abort();
activePopup.element.remove();
activePopup = null;
}
// ---------- hover thumbnail ----------
// Raised when a preview picture was saved since the page loaded, so the
// browser asks for the picture again instead of reusing a failed load.
let thumbVersion = 0;
function thumbUrl(name) {
const version = thumbVersion ? `&v=${thumbVersion}` : "";
return api.apiURL(`/ausboss/lora/thumb?name=${encodeURIComponent(name)}${version}`);
}
// A picture was saved: the next hover and the card show it.
function pictureSaved() {
thumbVersion += 1;
if (hoverThumb) delete hoverThumb.dataset.name;
}
// One floating preview image for the whole pack tab, shared by row hover and
// the picker. Fixed-positioned and pointer-events: none, so it can never
// shift layout or steal the cursor; it stays hidden until the image actually
// loads, so a LoRA without a sidecar preview shows nothing at all.
let hoverThumb = null;
function ensureHoverThumb() {
if (hoverThumb) return hoverThumb;
hoverThumb = el("img", "ausboss-lora-hoverthumb");
hoverThumb.alt = "";
hoverThumb.style.display = "none";
hoverThumb.addEventListener("load", () => {
if (hoverThumb.dataset.show === "1") hoverThumb.style.display = "";
});
hoverThumb.addEventListener("error", () => { hoverThumb.style.display = "none"; });
document.body.append(hoverThumb);
return hoverThumb;
}
function moveHoverThumb(x, y) {
if (!hoverThumb || hoverThumb.dataset.show !== "1") return;
const spot = thumbPosition(x, y, window.innerWidth, window.innerHeight);
hoverThumb.style.left = `${spot.left}px`;
hoverThumb.style.top = `${spot.top}px`;
}
function showHoverThumb(state, name, x, y) {
if (!name || state.settings?.thumbnails === false) return;
const thumb = ensureHoverThumb();
thumb.dataset.show = "1";
if (thumb.dataset.name !== name) {
thumb.dataset.name = name;
thumb.style.display = "none";
thumb.src = thumbUrl(effectiveLoraName(state, name));
} else if (thumb.complete && thumb.naturalWidth > 0) {
thumb.style.display = "";
}
moveHoverThumb(x, y);
}
function hideHoverThumb() {
if (!hoverThumb) return;
hoverThumb.dataset.show = "0";
hoverThumb.style.display = "none";
}
function openPopup(element, anchorRect, { width, onEscape } = {}) {
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(); onEscape?.(); } },
{ capture: true, signal: abort.signal }
);
activePopup = { element, abort };
return { place };
}
// Every commit is a user gesture and enters undo history; a drag passes
// record: false for its intermediate values and records once on release.
function commitRows(state, rows, { structural = false, record = true } = {}) {
// A card that only shows rows kept elsewhere changes nothing.
if (state.readOnly) {
renderRows(state);
return;
}
state.rows = rows;
state.widget.value = serializeRows(rows);
state.renderedValue = state.widget.value;
state.node.graph?.setDirtyCanvas(true, true);
if (structural) renderRows(state);
else updateRowValues(state);
if (record) notifyAusbossChange();
}
// A box shows its loader's rows the way the loader does: one strength per
// row, or model and CLIP apart.
function linked(state) {
return (state.box?.loader ?? state.node).properties?.ausbossLoraLinked !== false;
}
// Picker options honor only hide-extension: their folder context is the
// disambiguator (group headers, search prefixes), so it stays.
function displayName(state, name) {
return shortLoraName(name, {
hideFolders: false,
hideExtension: state.settings?.hide_extension !== false,
});
}
// A row's label (and the info card title) also hides the folder by
// default; the full path lives in the tooltip.
function rowLabel(state, name) {
return shortLoraName(name, {
hideFolders: state.settings?.hide_folders !== false,
hideExtension: state.settings?.hide_extension !== false,
});
}
// Each named row's fit against the install's lora list,
// cached per name; refreshAvailable clears the cache when the list changes.
function nameStatus(state, name) {
if (!name || !Array.isArray(state.available)) return null;
if (!state.nameStatus) state.nameStatus = new Map();
if (!state.nameStatus.has(name)) {
state.nameStatus.set(name, resolveLoraName(name, state.available));
}
return state.nameStatus.get(name);
}
// The name the SERVER should be asked about: a remapped row's saved path
// would 404 on the thumb/info routes (they may be served by the public
// pack's copy, which resolves strictly), so ask with the resolved one.
function effectiveLoraName(state, name) {
const status = nameStatus(state, name);
return status?.status === "remapped" ? status.name : name;
}
function refreshAvailable(state) {
fetchLoraList()
.then((names) => {
state.available = names;
state.nameStatus = new Map();
decorateRows(state);
// Rows whose range lookup 404ed under the stale path get a second
// chance under the resolved one.
for (const row of state.rows) {
if (!row.name) continue;
if (nameStatus(state, row.name)?.status === "remapped"
&& rangeCache.get(row.name) === null) {
rangeCache.delete(row.name);
ensureRange(state, row.name);
}
}
})
.catch(() => {});
}
// CSS pixels the panel needs to show every row with no scrolling. The add
// button lives inside the stack now, so its height (plus the gap above it)
// counts inside the stack box.
function panelHeight(state) {
const folded = foldedGroups(state);
let items = 0;
let height = 0;
for (const item of stackLayout(state.rows)) {
if (item.kind === "group") {
items += 1;
height += HEADER_HEIGHT;
} else if (!(item.group && folded.has(item.group))) {
items += 1;
height += ROW_HEIGHT;
}
}
const inner = items ? height + (items - 1) * ROW_GAP : BLANK_HEIGHT;
const stack = inner + ROW_GAP + ACTIONS_HEIGHT + STACK_PADDING * 2 + 2; // +2 border
const note = state.note ? NOTE_HEIGHT + ROW_GAP : 0;
return PANEL_PADDING * 2 + GROUP_HEIGHT + ROW_GAP + note + stack;
}
function panelMinWidth(state) {
return PANEL_MIN_WIDTH + (linked(state) ? 0 : SEPARATE_EXTRA_WIDTH);
}
function fitNode(state) {
const width = Math.max(panelMinWidth(state), state.node.size?.[0] || PANEL_MIN_WIDTH);
const height = state.node.computeSize
? state.node.computeSize()[1]
: panelHeight(state) + WIDGET_FRAME + 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(effectiveLoraName(state, 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);
const outside = strengthOutOfRange(row[key], range ?? null);
input.classList.toggle("out-of-range", outside);
// The border moved to the wrapper when the stepper arrived; tint it too.
input.closest(".ausboss-lora-strengthbox")?.classList.toggle("out-of-range", outside);
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 box = el("div", "ausboss-lora-strengthbox");
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;
input.dataset.ausbossStrength = key;
// A short name; the tooltip above stays as the longer description.
input.setAttribute("aria-label", key === "strength" ? "Model strength" : "CLIP strength");
input.__ausbossRead = () => state.rows[index]?.[key].toFixed(2) ?? "1.00";
input.__ausbossTint = () => state.rows[index] && applyRangeTint(input, state.rows[index], key);
ensureRange(state, state.rows[index].name);
const commitValue = (value, { record = true } = {}) => {
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, { record });
};
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), { record: false });
}
});
const endDrag = (event) => {
if (!drag) return;
try { input.releasePointerCapture(event.pointerId); } catch {}
const wasClick = !drag.scrubbed;
drag = null;
if (!wasClick) notifyAusbossChange();
if (wasClick) {
input.readOnly = false;
input.focus();
input.select();
}
};
input.addEventListener("pointerup", endDrag);
input.addEventListener("pointercancel", endDrag);
input.addEventListener("keydown", (event) => {
keepKeyInField(event);
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);
});
// Click-to-step arrows: the third way to set a strength, next to scrubbing
// and typing. Steps by the configured step (gear menu); Shift steps fine.
const steps = el("div", "ausboss-lora-step");
const stepButton = (direction) => {
const button = el("button");
button.type = "button";
button.tabIndex = -1;
button.title = `Strength ${direction > 0 ? "up" : "down"} one step. Shift = fine.`;
button.innerHTML = chevronIconSvg(direction > 0);
button.addEventListener("click", (event) => {
const coarse = state.settings?.step ?? DEFAULT_STEP;
commitValue(state.rows[index][key] + (event.shiftKey ? FINE_STEP : coarse) * direction);
input.value = state.rows[index][key].toFixed(2);
});
return button;
};
steps.append(stepButton(1), stepButton(-1));
box.append(input, steps);
applyRangeTint(input, state.rows[index], key);
return box;
}
// ---------- picker ----------
// Width of the longest label in the picker's font. Remembered across opens,
// so the next picker opens at the width its list will want instead of
// jumping when the list arrives.
let widestPickerLabel = 0;
// Folder browsing is a local preference, shared by the node's add/replace
// pickers. It never changes the workflow or enters its undo history.
const COLLAPSED_FOLDERS_KEY = "AusBoss.lora_loader.collapsedFolders";
let rememberedCollapsedFolders = new Set();
function loadCollapsedFolders() {
try {
const raw = window.localStorage?.getItem(COLLAPSED_FOLDERS_KEY);
const stored = raw == null ? null : JSON.parse(raw);
if (Array.isArray(stored)) rememberedCollapsedFolders = new Set(stored.filter((name) => typeof name === "string"));
} catch {}
return rememberedCollapsedFolders;
}
function saveCollapsedFolders(folders) {
rememberedCollapsedFolders = folders;
try { window.localStorage?.setItem(COLLAPSED_FOLDERS_KEY, JSON.stringify([...folders])); } catch {}
}
function widestLabel(labels) {
const context = measureCanvas();
if (!context) return 0;
context.font = POP_FONT;
let widest = 0;
for (const label of labels) widest = Math.max(widest, context.measureText(label).width);
return widest;
}
function openPicker(state, index, anchor, { keyboard = false } = {}) {
// + LoRA adds only after a choice, so cancelling leaves no empty row and
// a whole folder is one undo step. A row's own picker replaces one file.
const adding = index == null;
// Focus goes back to the stack only when the picker was opened from the
// keyboard. Opened or answered with the mouse, it leaves the keys alone.
const backToStack = (target) => { if (keyboard) focusStackRow(state, target); };
const pop = el("div");
const search = el("input", "ausboss-lora-search");
search.placeholder = "Search LoRAs...";
search.setAttribute("aria-label", "Search LoRAs");
const list = el("div", "ausboss-lora-list");
list.append(el("div", "ausboss-lora-empty", "Loading..."));
pop.append(search, list);
if (adding) pop.append(el("div", "ausboss-lora-picker-note",
"Click a file to add it. Tick boxes (or Ctrl-click) to add several at once. Click a folder name to add all of it. Skips files already in this stack."));
const anchorRect = anchor.getBoundingClientRect();
const fitWidth = () => pickerWidth(widestPickerLabel, anchorRect.width, window.innerWidth);
const originalRowId = state.rows[index]?.id;
const { place } = openPopup(pop, anchorRect, {
width: fitWidth(),
onEscape: () => backToStack(adding ? state.rows.length
: state.rows.findIndex((row) => row.id === originalRowId)),
});
search.focus();
let names = [];
let folders = new Map();
let options = [];
// Files ticked to add together, in the order they were ticked. They stay
// ticked through a search, so a set can be gathered from several searches.
const ticked = [];
const pathKey = (name) => String(name).replaceAll("\\", "/");
const inStack = () => new Set(state.rows.map((row) => pathKey(row.name)));
let loadError = false;
let highlight = -1;
const collapsedFolders = loadCollapsedFolders();
// Search reveals matches without losing the user's usual folded view.
let searchCollapsed = new Set();
const currentFolds = () => search.value.trim() ? searchCollapsed : collapsedFolders;
// after: "highlight" keeps the folder highlighted for the search box's
// arrow keys, "arrow" keeps focus on the fold arrow that was pressed with
// the keyboard, "search" hands the keys back to the search box.
const toggleFolder = (name, collapsed = !currentFolds().has(name), after = "highlight") => {
const folds = currentFolds();
if (collapsed) folds.add(name);
else folds.delete(name);
if (folds === collapsedFolders) saveCollapsedFolders(folds);
const scrollTop = list.scrollTop;
highlight = -1;
renderList();
if (after === "highlight" && adding) {
highlight = options.findIndex((entry) => entry.kind === "folder" && entry.name === name);
list.querySelector(`[data-ausboss-flat="${highlight}"]`)?.classList.add("highlight");
}
list.scrollTop = scrollTop;
if (after === "arrow") [...list.querySelectorAll(".ausboss-lora-folder-toggle")]
.find((button) => button.dataset.folder === name)?.focus({ preventScroll: true });
else if (after === "search") search.focus({ preventScroll: true });
};
const appendFolderToggle = (row, name) => {
const button = el("button", "ausboss-lora-folder-toggle");
const expanded = !currentFolds().has(name);
button.type = "button";
button.dataset.folder = name;
button.innerHTML = chevronIconSvg(false);
button.setAttribute("aria-expanded", String(expanded));
button.title = `${expanded ? "Collapse" : "Expand"} ${name}`;
button.setAttribute("aria-label", button.title);
button.addEventListener("keydown", keepKeyInField);
button.addEventListener("click", (event) => {
// Only the folder name adds files; its arrow is browse-only.
event.stopPropagation();
// A click made with a key (detail 0) stays on the arrow. A mouse click
// goes back to the search box, so typing keeps searching.
toggleFolder(name, !currentFolds().has(name), event.detail === 0 ? "arrow" : "search");
});
row.append(button);
};
const pick = (entry, { pointer = false } = {}) => {
if (!entry) return;
const { name, kind } = entry;
if (kind === "folder") {
const result = appendFolderRows(state.rows, folders.get(name) ?? [], state.settings?.default_strength ?? 1);
if (result.needed > result.room) {
loraToast(`This folder has ${result.needed} new LoRAs, but only ${result.room} of ${MAX_ROWS} rows are free. Nothing was added. Remove rows or choose a smaller folder.`);
return;
}
if (!result.added) {
loraToast("Every LoRA in this folder is already in this stack. Nothing was added.");
return;
}
const firstAdded = state.rows.length;
closePopup();
commitRows(state, result.rows, { structural: true });
fitNode(state);
loraToast(`Added ${result.added} LoRA${result.added === 1 ? "" : "s"} from ${name}.`
+ (result.skipped ? ` Skipped ${result.skipped} already in this stack.` : ""));
if (!pointer) backToStack(firstAdded);
return;
}
if (adding && state.rows.length >= MAX_ROWS) {
loraToast(`This stack already has ${MAX_ROWS} rows. Remove a row before adding another LoRA.`);
return;
}
closePopup();
const strength = roundStrength(state.settings?.default_strength ?? 1);
const rows = adding
? [...state.rows, { ...newRow(), name, strength, strength_clip: strength }]
: 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 });
if (adding) fitNode(state);
if (!pointer) backToStack(adding ? rows.length - 1 : index);
};
const addTicked = ({ pointer = false } = {}) => {
const result = appendFolderRows(state.rows, ticked, state.settings?.default_strength ?? 1);
if (result.needed > result.room) {
loraToast(`You ticked ${result.needed} new LoRAs, but only ${result.room} of ${MAX_ROWS} rows are free. Nothing was added. Untick some or remove rows.`);
return;
}
if (!result.added) {
loraToast("Every ticked LoRA is already in this stack. Nothing was added.");
return;
}
const firstAdded = state.rows.length;
closePopup();
commitRows(state, result.rows, { structural: true });
fitNode(state);
loraToast(`Added ${result.added} LoRA${result.added === 1 ? "" : "s"}.`
+ (result.skipped ? ` Skipped ${result.skipped} already in this stack.` : ""));
if (!pointer) backToStack(firstAdded);
};
// While anything is ticked, the bottom note becomes a bar that adds them.
let tickBar = null;
const updateTickBar = () => {
const note = pop.querySelector(".ausboss-lora-picker-note");
if (!ticked.length) {
tickBar?.remove();
tickBar = null;
if (note) note.hidden = false;
place();
return;
}
if (note) note.hidden = true;
if (!tickBar) {
tickBar = el("div", "ausboss-lora-tickbar");
const clear = el("button", "ausboss-lora-tickclear", "Clear");
clear.type = "button";
clear.title = "Untick every file";
clear.addEventListener("click", () => {
ticked.length = 0;
renderTicks();
search.focus({ preventScroll: true });
});
const add = el("button", "ausboss-lora-add");
add.type = "button";
add.addEventListener("click", () => addTicked({ pointer: true }));
tickBar.append(el("span", "ausboss-lora-tickcount"), clear, add);
pop.append(tickBar);
}
const count = ticked.length;
tickBar.querySelector(".ausboss-lora-tickcount").textContent = `${count} ticked`;
tickBar.querySelector(".ausboss-lora-add").textContent = `Add ${count} LoRA${count === 1 ? "" : "s"}`;
place();
};
const renderTicks = () => {
for (const option of list.querySelectorAll(".ausboss-lora-option.tickable")) {
const on = ticked.includes(option.title);
option.classList.toggle("ticked", on);
option.querySelector(".ausboss-lora-tick")?.classList.toggle("on", on);
}
updateTickBar();
};
// The last file ticked or unticked one by one: Shift ticks the run of
// files shown between it and the next one, as Shift-click does on rows.
let tickAnchor = null;
const toggleTick = (name) => {
const at = ticked.indexOf(name);
if (at >= 0) ticked.splice(at, 1);
else ticked.push(name);
tickAnchor = name;
renderTicks();
};
const tickRun = (name) => {
const shown = [...list.querySelectorAll(".ausboss-lora-option.tickable:not(.instack)")]
.map((option) => option.title);
const from = shown.indexOf(tickAnchor);
const to = shown.indexOf(name);
if (from < 0 || to < 0) {
toggleTick(name);
return;
}
for (const file of shown.slice(Math.min(from, to), Math.max(from, to) + 1)) {
if (!ticked.includes(file)) ticked.push(file);
}
tickAnchor = name;
renderTicks();
};
const tickWith = (event, name) => {
if (event.shiftKey && !event.ctrlKey && !event.metaKey) tickRun(name);
else toggleTick(name);
};
let lastPointer = null;
const pointerMoved = (event) => {
const moved = !lastPointer || lastPointer.x !== event.clientX || lastPointer.y !== event.clientY;
lastPointer = { x: event.clientX, y: event.clientY };
return moved;
};
const appendOption = (entry, flatIndex) => {
const { name, kind, label, depth, count } = entry;
const folder = kind === "folder";
const option = el("div", "ausboss-lora-option");
option.style.paddingLeft = `${OPTION_PAD_X + Math.min(4, depth) * 12 + (folder ? 0 : 30)}px`;
if (folder) appendFolderToggle(option, name);
option.append(nameLabel(folder ? label : displayName(state, label)));
option.title = name;
if (folder) {
option.classList.add("ausboss-lora-folder-option");
option.append(el("span", "ausboss-lora-folder-count", `Add all ${count}`));
option.title += ` — add all ${count} LoRAs in this folder, including nested folders. Files already in this stack are skipped.`;
}
if (flatIndex === highlight) option.classList.add("highlight");
if (!folder && name === state.rows[index]?.name) option.classList.add("current");
// Only + LoRA ticks. A file already in the stack says so and has no box.
const tickable = adding && !folder && !entry.inStack;
if (adding && !folder && entry.inStack) {
option.classList.add("tickable", "instack");
option.append(el("span", "ausboss-lora-instack", "in stack"));
} else if (tickable) {
option.classList.add("tickable");
const tick = el("button", "ausboss-lora-tick");
tick.type = "button";
tick.tabIndex = -1;
tick.title = "Tick to add with others";
tick.setAttribute("aria-label", `Tick ${name}`);
tick.style.left = `${OPTION_PAD_X + Math.min(4, depth) * 12 + 8}px`;
if (ticked.includes(name)) {
tick.classList.add("on");
option.classList.add("ticked");
}
tick.addEventListener("click", (event) => {
event.stopPropagation();
tickWith(event, name);
search.focus({ preventScroll: true });
});
option.prepend(tick);
// Shift-click would otherwise select text across the list.
option.addEventListener("mousedown", (event) => { if (event.shiftKey) event.preventDefault(); });
}
option.addEventListener("pointerenter", (event) => {
// Pointing at a folder never chooses it for Enter or for the Left and
// Right keys: a pointer that only rests on the list would otherwise
// add a whole folder. It lights up on hover (CSS); a click or the
// arrow keys choose it.
if (folder) {
hideHoverThumb();
return;
}
// The arrow keys redraw the list under a pointer that has not moved,
// and the browser then reports the pointer entering the new row. Only
// a real move takes the highlight, or the arrows could not leave it.
if (!pointerMoved(event)) return;
if (highlight !== flatIndex) {
// Keep the option under the pointer intact through the click.
list.querySelector(".ausboss-lora-option.highlight")?.classList.remove("highlight");
highlight = flatIndex;
option.classList.add("highlight");
}
showHoverThumb(state, name, event.clientX, event.clientY);
});
option.addEventListener("pointermove", (event) => {
if (folder || !pointerMoved(event)) return;
if (highlight !== flatIndex) {
list.querySelector(".ausboss-lora-option.highlight")?.classList.remove("highlight");
highlight = flatIndex;
option.classList.add("highlight");
}
showHoverThumb(state, name, event.clientX, event.clientY);
});
option.addEventListener("pointerleave", hideHoverThumb);
option.addEventListener("click", (event) => {
if (tickable && (event.ctrlKey || event.metaKey || event.shiftKey)) {
tickWith(event, name);
search.focus({ preventScroll: true });
return;
}
pick(entry, { pointer: true });
});
option.dataset.ausbossFlat = String(flatIndex);
list.append(option);
};
const renderList = () => {
const entries = loraPickerEntries(names, search.value, currentFolds());
if (adding) {
const have = inStack();
for (const entry of entries) if (entry.kind === "file") entry.inStack = have.has(pathKey(entry.name));
}
options = entries.filter((entry) => adding || entry.kind === "file");
if (highlight >= 0) highlight = clampHighlight(highlight, options.length);
list.textContent = "";
hideHoverThumb();
if (!entries.length) {
list.append(el("div", "ausboss-lora-empty",
loadError ? "Could not list LoRAs. Close this picker and try again."
: names.length ? "No matches." : "Put LoRA files in models/loras."));
place();
return;
}
let flatIndex = 0;
for (const entry of entries) {
if (!adding && entry.kind === "folder") {
const heading = el("div", "ausboss-lora-folder");
appendFolderToggle(heading, entry.name);
heading.append(nameLabel(entry.label));
heading.style.paddingLeft = `${OPTION_PAD_X + Math.min(4, entry.depth) * 12}px`;
heading.title = entry.name;
list.append(heading);
} else appendOption(entry, flatIndex++);
}
fitLabels(list.querySelectorAll(".ausboss-lora-label"));
list.querySelector(`[data-ausboss-flat="${highlight}"]`)?.scrollIntoView({ block: "nearest" });
if (adding) updateTickBar();
place();
};
search.addEventListener("input", () => { highlight = -1; searchCollapsed = new Set(); renderList(); });
search.addEventListener("keydown", (event) => {
keepKeyInField(event);
if (isAppShortcut(event)) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
highlight = moveHighlight(highlight, event.key === "ArrowDown" ? 1 : -1, options.length);
renderList();
} else if ((event.key === "ArrowLeft" || event.key === "ArrowRight")
&& options[highlight]?.kind === "folder") {
event.preventDefault();
toggleFolder(options[highlight].name, event.key === "ArrowLeft");
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
highlight = event.key === "Home" ? 0 : options.length - 1;
renderList();
} else if (event.key === "Enter" && adding && (event.ctrlKey || event.metaKey || event.shiftKey)) {
// Ctrl+Enter ticks the highlighted file and Shift+Enter the run up
// to it, as Ctrl-click and Shift-click do.
event.preventDefault();
const entry = options[highlight];
if (entry?.kind === "file" && !entry.inStack) tickWith(event, entry.name);
} else if (event.key === "Enter") {
event.preventDefault();
// With files ticked, Enter adds them all.
if (adding && ticked.length) {
addTicked();
return;
}
// Typing a filename then Enter keeps picking one file. A folder is
// selected deliberately by a click or by moving the highlight to it.
pick(options[highlight] ?? options.find((entry) => entry.kind === "file"));
}
});
fetchLoraList()
.then((fetched) => {
if (!pop.isConnected) return;
names = fetched;
folders = new Map(loraFolders(fetched).map(({ folder, names }) => [folder, names]));
state.available = fetched;
state.nameStatus = new Map();
widestPickerLabel = widestLabel(names.map((name) => displayName(state, name))) + (adding ? 110 : 30);
pop.style.width = `${fitWidth()}px`;
if (!adding) highlight = loraPickerEntries(names, "", currentFolds()).filter((entry) => entry.kind === "file")
.findIndex((entry) => entry.name === state.rows[index]?.name);
renderList();
})
.catch(() => {
if (!pop.isConnected) return;
loadError = true;
renderList();
});
}
// ---------- Civitai lookup ----------
// One click, one file: the server hashes the LoRA, this browser asks Civitai
// about that hash, and the server saves the answer beside the LoRA after
// checking it describes the same file. Returns false when Civitai does not
// know the file (unpublished or hidden models answer 404). `picture` is the
// Civitai picture gear option ("off", "PG-13" or "any").
async function lookUpOnCivitai(serverName, picture = "PG-13") {
const hashed = await (await api.fetchApi(
`/ausboss/lora/hash?name=${encodeURIComponent(serverName)}`
)).json();
if (!hashed.ok) throw new Error(hashed.error || "Could not hash this LoRA file.");
const response = await fetch(civitaiByHashUrl(hashed.sha256), {
credentials: "omit", redirect: "error", referrerPolicy: "no-referrer", cache: "no-store",
});
if (response.status === 404) return false;
if (!response.ok) throw new Error(`Civitai answered ${response.status}.`);
const info = parseCivitaiBody(await response.text());
const saved = await (await api.fetchApi("/ausboss/lora/civitai", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: serverName, info }),
})).json();
if (!saved.ok) throw new Error(saved.error || "Could not save the Civitai info.");
if (picture !== "off") {
await saveCivitaiPicture(serverName, info, picture === "any").catch((error) =>
loraToast(`Saved the Civitai info, but not its picture: ${civitaiFailureText(error)}`));
}
return true;
}
// The LoRA's first picture on Civitai, saved beside it as its preview. The
// browser downloads a 450-pixel copy from Civitai's picture server without
// cookies; the server checks it is a picture and never replaces one.
async function saveCivitaiPicture(serverName, info, anyRating) {
const picture = pickCivitaiPicture(info, anyRating);
if (!picture) {
loraToast(anyRating
? "Civitai has no picture of this LoRA to save."
: "Civitai has no picture of this LoRA rated PG-13 or milder.");
return;
}
const response = await fetch(smallPictureUrl(picture.url), {
credentials: "omit", referrerPolicy: "no-referrer", cache: "no-store",
});
if (!response.ok) throw new Error(`Civitai's picture server answered ${response.status}.`);
if (!isCivitaiAddress(response.url)) throw new Error("The picture came from somewhere other than Civitai.");
if (!(response.headers.get("content-type") || "").startsWith("image/")) {
throw new Error("Civitai did not send a picture.");
}
const blob = await response.blob();
if (blob.size > MAX_PICTURE_BYTES) throw new Error("The picture is too large.");
const saved = await (await api.fetchApi(`/ausboss/lora/picture?name=${encodeURIComponent(serverName)}`, {
method: "POST",
headers: { "Content-Type": blob.type || "application/octet-stream" },
body: blob,
})).json();
if (!saved.ok) throw new Error(saved.error || "Could not save the picture.");
pictureSaved();
}
// ---------- info card ----------
function openInfo(state, index, anchor) {
const row = state.rows[index];
if (!row.name) return;
// Every server call in the card goes through the resolved name, so the
// card works for a moved file too.
const serverName = effectiveLoraName(state, row.name);
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 = thumbUrl(serverName);
image.addEventListener("error", () => image.remove());
if (info.has_preview) card.append(image);
card.append(el("h4", "", info.civitai_title || rowLabel(state, row.name)));
if (info.base_model) card.append(el("div", "ausboss-lora-meta", `Base model: ${info.base_model}`));
const size = formatFileSize(info.size_bytes);
if (size) {
const when = Number.isFinite(info.mtime)
? ` · modified ${new Date(info.mtime * 1000).toLocaleDateString()}`
: "";
card.append(el("div", "ausboss-lora-meta", `${size}${when}`));
}
if (Number.isInteger(info.civitai_model_id)) {
const link = el("a", "ausboss-lora-civitai-link", "View on Civitai ↗");
const version = Number.isInteger(info.civitai_version_id)
? `?modelVersionId=${info.civitai_version_id}` : "";
link.href = `https://civitai.com/models/${info.civitai_model_id}${version}`;
link.target = "_blank";
link.rel = "noopener noreferrer";
card.append(link);
}
// Civitai info but no picture: say how to get one.
if (info.has_civitai && !info.has_preview && state.settings?.civitai_lookup !== false
&& state.settings?.civitai_picture !== "off") {
card.append(el("div", "ausboss-lora-meta", "No preview picture yet. Click Refresh Civitai info to save one."));
}
// One click can change other chips too: the same word can sit in two
// lists, and a phrase with commas shares its words with shorter chips.
const chipRefreshers = [];
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", hasTrigger(state.rows[index].triggers, word)
);
refresh();
chipRefreshers.push(refresh);
chip.addEventListener("click", () => {
const rows = state.rows.map((entry, i) =>
i === index ? { ...entry, triggers: toggleTrigger(entry.triggers, word) } : entry
);
commitRows(state, rows);
for (const refreshChip of chipRefreshers) refreshChip();
});
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 (state.settings?.civitai_lookup !== false) {
const fetchButton = el(
"button", "ausboss-lora-add ausboss-lora-fetch", civitaiButtonLabel(info.has_civitai)
);
fetchButton.type = "button";
fetchButton.title = "Asks civitai.com about this one file, from this browser.";
fetchButton.addEventListener("click", async () => {
fetchButton.disabled = true;
fetchButton.textContent = "Fetching...";
try {
const picture = info.has_preview ? "off" : (state.settings?.civitai_picture ?? "PG-13");
if (!(await lookUpOnCivitai(serverName, picture))) {
fetchButton.textContent = "Not found on Civitai";
return;
}
load();
} catch (error) {
fetchButton.textContent = "Civitai lookup failed";
fetchButton.title = civitaiFailureText(error);
loraToast(fetchButton.title);
}
});
card.append(fetchButton);
}
const range = el("div", "ausboss-lora-range");
range.append(el("span", "ausboss-lora-meta", "Suggested strength"));
// Each bound is optional: erase it for "any". Saved when a gesture ends.
const bounds = {};
const saveRange = async () => {
const payload = {
name: serverName,
words: info.custom_triggers || [],
min: bounds.min.get(),
max: bounds.max.get(),
};
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 {}
};
for (const key of ["min", "max"]) {
bounds[key] = makeScrubInput({
value: info.range?.[key] ?? null, allowEmpty: true, emptyStart: key === "min" ? 0 : 1,
placeholder: "any", min: STRENGTH_MIN, max: STRENGTH_MAX,
step: 0.05, fineStep: 0.01, decimals: 2, width: 76,
title: `Suggested ${key === "min" ? "lowest" : "highest"} strength; erase it for any.`,
onSettle: saveRange,
});
}
range.append(bounds.min.root, el("span", "ausboss-lora-meta", "to"), bounds.max.root);
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) => {
keepKeyInField(event);
if (event.key === "Enter" && !isAppShortcut(event)) 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: serverName, 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(serverName)}`)
.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) => {
keepKeyInField(event);
if (event.key === "Enter" && !isAppShortcut(event)) 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 menuItem(menu, label, action, disabled = false) {
const button = el("button", "", label);
button.type = "button";
button.disabled = disabled;
button.addEventListener("click", () => { closePopup(); action(); });
menu.append(button);
return button;
}
// A change that can add or empty a group changes the node's height.
function commitGroups(state, rows) {
commitRows(state, rows, { structural: true });
clearSelection(state);
fitNode(state);
}
// The red, trash-marked last item of both right-click menus: clears every
// row of this node after a confirmation.
function addDeleteAll(state, menu, event) {
menuItem(menu, "🗑 Delete all LoRAs…", () => {
const confirmation = el("div", "ausboss-lora-menu");
const message = el("div", "ausboss-lora-empty",
`Remove all ${state.rows.length} LoRA rows from this node? Files on disk are kept.`);
const cancel = el("button", "", "Cancel — keep LoRAs");
const remove = el("button", "ausboss-lora-danger", "🗑 Yes, delete all rows");
cancel.type = remove.type = "button";
cancel.addEventListener("click", closePopup);
remove.addEventListener("click", () => {
closePopup();
commitGroups(state, []);
notifyAusbossChange();
});
confirmation.append(message, cancel, remove);
openPopup(confirmation,
{ left: event.clientX, top: event.clientY, bottom: event.clientY, width: 0 },
{ width: 270 });
cancel.focus();
}).classList.add("ausboss-lora-danger", "ausboss-lora-delete-all");
}
function openRowMenu(state, index, event) {
const menu = el("div", "ausboss-lora-menu");
const item = (label, action, disabled = false) => menuItem(menu, label, action, disabled);
const row = state.rows[index];
const ids = menuRowIds(state, index);
const count = ids.length;
const chosen = state.rows.filter((entry) => ids.includes(entry.id));
const sameGroup = chosen.every((entry) => entry.group && entry.group === chosen[0].group)
? chosen[0].group : "";
item(count > 1 ? `Group these ${count} LoRAs` : "Group this LoRA", () => {
const name = uniqueGroupName(state.rows);
commitGroups(state, groupRows(state.rows, ids, name));
startGroupRename(state, name);
});
const others = groupNames(state.rows).filter((name) => name !== sameGroup);
if (others.length) {
menu.append(el("div", "ausboss-lora-menu-sub", count > 1 ? `Move ${count} to group` : "Move to group"));
for (const name of others) {
item(name, () => commitGroups(state, moveRowsToGroup(state.rows, ids, name))).classList.add("indent");
}
}
if (sameGroup) {
item(count > 1 ? `Take ${count} out of ${sameGroup}` : `Take out of ${sameGroup}`,
() => commitGroups(state, ungroupRows(state.rows, ids)));
}
menu.append(el("div", "ausboss-lora-menu-rule"));
const insertEmpty = (position) => {
if (state.rows.length >= MAX_ROWS) return;
const strength = roundStrength(state.settings?.default_strength ?? 1);
const rows = state.rows.slice();
// A row added next to a grouped one joins its group.
const blank = { ...newRow(), strength, strength_clip: strength };
if (row.group) blank.group = row.group;
rows.splice(position, 0, blank);
commitRows(state, rows, { structural: true });
fitNode(state);
};
item("Insert empty LoRA above", () => insertEmpty(index), state.rows.length >= MAX_ROWS);
item("Insert empty LoRA below", () => insertEmpty(index + 1), state.rows.length >= MAX_ROWS);
item("Move up", () => commitGroups(state, moveRowInStack(state.rows, index, -1)),
index === 0 && !row.group);
item("Move down", () => commitGroups(state, moveRowInStack(state.rows, index, 1)),
index === state.rows.length - 1 && !row.group);
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(count > 1 ? `Remove these ${count} LoRAs` : "Remove this LoRA", () => {
commitGroups(state, state.rows.filter((entry) => !ids.includes(entry.id)));
}).classList.add("ausboss-lora-danger");
addDeleteAll(state, menu, event);
openPopup(menu, { left: event.clientX, top: event.clientY, bottom: event.clientY, width: 0 });
}
// Right-click on the stack around the rows: what applies to the whole stack.
function openStackMenu(state, event) {
const menu = el("div", "ausboss-lora-menu");
const item = (label, action, disabled = false) => menuItem(menu, label, action, disabled);
const add = state.panel.querySelector(".ausboss-lora-add, .ausboss-lora-blank");
item("+ Add LoRAs…", () => openPicker(state, null, add), !add || state.rows.length >= MAX_ROWS);
const ids = state.rows.filter((row) => selectedIds(state).has(row.id)).map((row) => row.id);
if (ids.length) {
item(ids.length > 1 ? `Group the ${ids.length} selected LoRAs` : "Group the selected LoRA", () => {
const name = uniqueGroupName(state.rows);
commitGroups(state, groupRows(state.rows, ids, name));
startGroupRename(state, name);
});
} else if (state.rows.length) {
item("Group LoRAs: Ctrl-click rows first", () => {}, true);
}
const groups = groupNames(state.rows);
if (groups.length) {
const folded = foldedGroups(state);
const allFolded = groups.every((name) => folded.has(name));
item(allFolded ? "Open all groups" : "Fold all groups", () => {
saveFolded(state, new Set(allFolded ? [] : groups));
renderRows(state);
fitNode(state);
notifyAusbossChange();
});
}
if (state.rows.length) addDeleteAll(state, menu, event);
openPopup(menu, { left: event.clientX, top: event.clientY, bottom: event.clientY, width: 0 });
}
function openGroupMenu(state, group, event) {
const menu = el("div", "ausboss-lora-menu");
const item = (label, action, disabled = false) => menuItem(menu, label, action, disabled);
const inside = state.rows.filter((row) => row.group === group);
const members = inside.map((row) => row.id);
const folded = foldedGroups(state).has(group);
const setAll = (enabled) => {
const rows = setGroupEnabled(state.rows, group, enabled);
state.toggleSnapshot = snapshotEnabled(rows);
commitRows(state, rows, { structural: true });
};
item("Rename group", () => startGroupRename(state, group));
item(folded ? "Open group" : "Fold group", () => setGroupFolded(state, group, !folded));
item("Turn all LoRAs in it on", () => setAll(true), inside.every((row) => row.enabled));
item("Turn all LoRAs in it off", () => setAll(false), inside.every((row) => !row.enabled));
item("Move group up", () => commitGroups(state, moveGroup(state.rows, group, -1)),
state.rows[0]?.group === group);
item("Move group down", () => commitGroups(state, moveGroup(state.rows, group, 1)),
state.rows.at(-1)?.group === group);
item("Ungroup (keeps the LoRAs)", () => commitGroups(state, ungroupRows(state.rows, members)));
item(`Remove group and its ${members.length} LoRA${members.length === 1 ? "" : "s"}`,
() => commitGroups(state, state.rows.filter((row) => row.group !== group)))
.classList.add("ausboss-lora-danger");
addDeleteAll(state, menu, event);
openPopup(menu, { left: event.clientX, top: event.clientY, bottom: event.clientY, width: 0 });
}
// ---------- groups ----------
// Folded groups, by name, kept with the node so a workflow opens the way
// it was saved.
function foldedGroups(state) {
const saved = state.node.properties?.[FOLDED_PROPERTY];
return new Set(Array.isArray(saved) ? saved.filter((name) => typeof name === "string") : []);
}
function saveFolded(state, folded, rows = state.rows) {
const live = new Set(groupNames(rows));
const names = [...folded].filter((name) => live.has(name));
state.node.properties ??= {};
if (names.length) state.node.properties[FOLDED_PROPERTY] = names;
else delete state.node.properties[FOLDED_PROPERTY];
}
function setGroupFolded(state, group, folded) {
const names = foldedGroups(state);
if (folded) names.add(group);
else names.delete(group);
saveFolded(state, names);
renderRows(state);
fitNode(state);
notifyAusbossChange();
}
function groupHeaderElement(state, group) {
return [...state.panel.querySelectorAll(".ausboss-lora-ghead")]
.find((head) => head.dataset.group === group) ?? null;
}
// The name turns into a text box in place. Enter or a click away keeps the
// new name; Escape keeps the old one.
function startGroupRename(state, from) {
const label = groupHeaderElement(state, from)?.querySelector(".ausboss-lora-gname");
if (!label) return;
const input = el("input", "ausboss-lora-gname-input");
input.value = from;
input.maxLength = MAX_GROUP_NAME;
input.setAttribute("aria-label", "Group name");
label.replaceWith(input);
input.focus();
input.select();
let done = false;
const finish = (keep) => {
if (done) return;
done = true;
const to = groupName(input.value);
if (!keep || !to || to === from) {
renderRows(state);
return;
}
if (hasGroupName(state.rows, to, from)) {
loraToast(`There is already a group called "${to}". Pick another name.`);
renderRows(state);
return;
}
const rows = renameGroup(state.rows, from, to);
const folded = foldedGroups(state);
if (folded.delete(from)) folded.add(to);
saveFolded(state, folded, rows);
renameOwnedGroup(state, from, to);
commitRows(state, rows, { structural: true });
};
input.addEventListener("keydown", (event) => {
keepKeyInField(event);
if (event.key === "Enter") {
event.preventDefault();
finish(true);
} else if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
finish(false);
}
});
input.addEventListener("blur", () => finish(true));
}
function groupHeader(state, stack, item, folded) {
const { group, on, total } = item;
const off = item.off;
const head = el("div", `ausboss-lora-ghead${folded ? " folded" : ""}${off || !on ? " off" : ""}`);
head.dataset.group = group;
const grip = el("button", "ausboss-lora-grip");
grip.type = "button";
grip.title = "Drag to move the whole group";
grip.innerHTML = gripIconSvg();
// A click on the lock of a group a switch sets takes the group over.
grip.addEventListener("click", () => {
if (grip.dataset.ausbossDragged) {
delete grip.dataset.ausbossDragged;
return;
}
takeGroupOver(state, group);
});
grip.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
event.preventDefault();
hideHoverThumb();
const members = new Set(state.rows.filter((row) => row.group === group).map((row) => row.id));
const elements = [head, ...[...stack.querySelectorAll(".ausboss-lora-row")]
.filter((element) => members.has(element.dataset.rowId))];
startStackDrag(state, stack, elements, grip, event, { kind: "group", group, label: group });
});
// The group's own switch: off stops every LoRA in it, and each keeps its
// own switch for when the group comes back on.
const toggle = el("button", `ausboss-lora-toggle${off ? "" : " on"}`);
toggle.type = "button";
toggle.title = "Turn this group on or off. Each LoRA in it keeps its own switch.";
toggle.setAttribute("role", "switch");
toggle.setAttribute("aria-checked", String(!off));
toggle.addEventListener("click", () => {
const rows = setGroupOff(state.rows, group, !off);
state.toggleSnapshot = snapshotEnabled(rows);
commitRows(state, rows, { structural: true });
});
const fold = el("button", "ausboss-lora-gfold");
fold.type = "button";
fold.innerHTML = chevronIconSvg(false);
fold.title = folded ? `Open ${group}` : `Fold ${group}`;
fold.setAttribute("aria-label", fold.title);
fold.setAttribute("aria-expanded", String(!folded));
fold.addEventListener("click", () => setGroupFolded(state, group, !folded));
const icon = el("span", "ausboss-lora-gicon");
icon.innerHTML = folderIconSvg();
const name = el("span", "ausboss-lora-gname", group);
name.title = `${group}. Double-click to rename; right-click for more.`;
head.addEventListener("dblclick", (event) => {
if (event.target.closest("button, input")) return;
event.stopPropagation();
startGroupRename(state, group);
});
head.addEventListener("contextmenu", (event) => {
event.preventDefault();
event.stopPropagation();
openGroupMenu(state, group, event);
});
head.append(grip, toggle, fold, icon, name,
el("span", "ausboss-lora-gcount", off ? "group off" : `${on}/${total} on`));
return head;
}
// Where a dragged row would land. Over a group's header it goes in at the
// group's end, and between a group's rows it goes in there. Above the
// header or below the group's last row it lands outside the group.
function rowDropTarget(state, stack, id, y) {
const items = [...stack.querySelectorAll(".ausboss-lora-row, .ausboss-lora-ghead")]
.filter((item) => item.dataset.rowId !== id && !item.classList.contains("folded-away"));
const box = stack.getBoundingClientRect();
const left = box.left + STACK_PADDING;
for (const item of items) {
if (!item.classList.contains("ausboss-lora-ghead")) continue;
const rect = item.getBoundingClientRect();
// The header's top third is the gap above the group.
if (y >= rect.top + rect.height / 3 && y <= rect.bottom + 2) return { header: item, group: item.dataset.group, beforeId: null };
}
let at = items.findIndex((item) => {
const rect = item.getBoundingClientRect();
return y < rect.top + rect.height / 2;
});
if (at < 0) at = items.length;
const above = items[at - 1];
const below = items[at];
const lineY = below ? below.getBoundingClientRect().top - ROW_GAP / 2
: above ? above.getBoundingClientRect().bottom + ROW_GAP / 2 : box.top + STACK_PADDING;
const rowOf = (item) => item?.classList.contains("ausboss-lora-row")
? state.rows.find((row) => row.id === item.dataset.rowId) : null;
const belowRow = rowOf(below);
if (belowRow?.group) return { group: belowRow.group, beforeId: belowRow.id, lineY, lineX: left + GROUP_INDENT };
let beforeId = belowRow?.id ?? null;
if (below && !belowRow) {
// Above a header: before that group's first row. Above its own group's
// header, the dragged row may be that first row: it stays where it is.
const own = state.rows.findIndex((row) => row.id === id);
beforeId = state.rows.find((row) => row.group === below.dataset.group && row.id !== id)?.id
?? state.rows[own + 1]?.id ?? null;
}
return { group: "", beforeId, lineY, lineX: left };
}
// Where a dragged group would land: only between whole items, never
// inside another group.
function groupDropTarget(state, stack, group, y) {
const blocks = [];
for (const item of stack.querySelectorAll(".ausboss-lora-row, .ausboss-lora-ghead")) {
if (item.classList.contains("folded-away")) continue;
const rect = item.getBoundingClientRect();
if (item.classList.contains("ausboss-lora-ghead")) {
const name = item.dataset.group;
blocks.push({ group: name, top: rect.top, bottom: rect.bottom,
firstId: state.rows.find((row) => row.group === name)?.id });
continue;
}
const row = state.rows.find((entry) => entry.id === item.dataset.rowId);
if (row?.group && blocks.length) blocks.at(-1).bottom = rect.bottom;
else if (row) blocks.push({ group: "", top: rect.top, bottom: rect.bottom, firstId: row.id });
}
const others = blocks.filter((block) => block.group !== group);
let at = others.findIndex((block) => y < (block.top + block.bottom) / 2);
if (at < 0) at = others.length;
const box = stack.getBoundingClientRect();
const lineY = others[at] ? others[at].top - ROW_GAP / 2
: others[at - 1] ? others[at - 1].bottom + ROW_GAP / 2 : box.top + STACK_PADDING;
return { beforeId: others[at]?.firstId ?? null, lineY, lineX: box.left + STACK_PADDING };
}
// Drag a row by its grip, or a group by its header's grip. A line shows
// where it will land, or the group header it will join lights up. The
// stack changes once, on release, so an abandoned drag changes nothing.
function startStackDrag(state, stack, elements, handle, event, what) {
const abort = new AbortController();
const startX = event.clientX;
const startY = event.clientY;
let ghost = null;
let line = null;
let target = null;
const clearDrop = () => stack.querySelectorAll(".ausboss-lora-ghead.drop")
.forEach((head) => head.classList.remove("drop"));
const show = (x, y) => {
clearDrop();
target = what.kind === "row" ? rowDropTarget(state, stack, what.id, y)
: groupDropTarget(state, stack, what.group, y);
ghost.style.left = `${x + 14}px`;
ghost.style.top = `${y - 12}px`;
if (target.header) {
target.header.classList.add("drop");
line.hidden = true;
return;
}
const box = stack.getBoundingClientRect();
line.hidden = false;
line.style.left = `${target.lineX}px`;
line.style.width = `${Math.max(12, box.right - STACK_PADDING - target.lineX)}px`;
line.style.top = `${target.lineY - 1}px`;
};
const finish = (commit) => {
abort.abort();
if (!ghost) return;
ghost.remove();
line.remove();
clearDrop();
for (const element of elements) element.classList.remove("dragging");
if (!commit || !target) return;
const rows = what.kind === "row" ? placeRow(state.rows, what.id, target)
: placeGroup(state.rows, what.group, target.beforeId);
if (serializeRows(rows) === serializeRows(state.rows)) return;
handle.dataset.ausbossDragged = "1";
if (groupNames(rows).length !== groupNames(state.rows).length) commitGroups(state, rows);
else commitRows(state, rows, { structural: true });
};
// The gesture listens on the window, capture phase, like every drag on
// this panel: nothing is reparented while it runs.
window.addEventListener("pointermove", (moveEvent) => {
if (!ghost) {
if (Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) < 4) return;
for (const element of elements) element.classList.add("dragging");
ghost = el("div", "ausboss-lora-dragghost", what.label);
line = el("div", "ausboss-lora-dropline");
document.body.append(ghost, line);
}
show(moveEvent.clientX, moveEvent.clientY);
}, { capture: true, signal: abort.signal });
window.addEventListener("pointerup", () => finish(true), { capture: true, signal: abort.signal });
window.addEventListener("pointercancel", () => finish(false), { capture: true, signal: abort.signal });
}
// ---------- selection ----------
// Ctrl/Cmd-click adds or drops one row, Shift-click a run; the row menu
// then acts on all of them. A plain click anywhere clears it.
function selectedIds(state) {
state.selected ??= new Set();
return state.selected;
}
function paintSelection(state) {
const chosen = selectedIds(state);
state.panel.querySelectorAll(".ausboss-lora-row").forEach((element) => {
element.classList.toggle("selected", chosen.has(element.dataset.rowId));
});
}
function clearSelection(state) {
if (!state.selected?.size) return;
state.selected.clear();
paintSelection(state);
}
function selectRow(state, id, run) {
const chosen = selectedIds(state);
const order = state.rows.map((row) => row.id);
const from = order.indexOf(state.selectAnchor);
if (run && from >= 0) {
const to = order.indexOf(id);
for (const rowId of order.slice(Math.min(from, to), Math.max(from, to) + 1)) chosen.add(rowId);
} else {
if (chosen.has(id)) chosen.delete(id);
else chosen.add(id);
state.selectAnchor = id;
}
paintSelection(state);
}
// The rows a row's menu acts on: the whole selection when the row is in it.
function menuRowIds(state, index) {
const id = state.rows[index]?.id;
const chosen = selectedIds(state);
if (chosen.size > 1 && chosen.has(id)) {
return state.rows.filter((row) => chosen.has(row.id)).map((row) => row.id);
}
clearSelection(state);
return [id];
}
let selectionWatched = false;
function watchSelections() {
if (selectionWatched) return;
selectionWatched = true;
document.addEventListener("pointerdown", (event) => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) return;
if (activePopup?.element.contains(event.target)) return;
for (const state of liveCards) clearSelection(state);
}, true);
window.addEventListener("keydown", (event) => {
if (event.key === "Escape") for (const state of liveCards) clearSelection(state);
}, true);
}
// ---------- rows a switch sets ----------
// Every Workflow Switches node on a graph, with its settings.
function switchBoards(graph) {
return (graph?.nodes ?? graph?._nodes ?? [])
.filter((node) => node?.type === SWITCHES_CLASS)
.map((node) => ({ node, settings: node.properties?.[SWITCHES_PROPERTY] }));
}
// The switches that set one row. A switch addresses the node that keeps the
// rows (state.owner): the loader itself, or the box that holds them.
function rowOwners(state, row) {
const owner = state.owner;
if (!owner?.node?.graph || !row?.name) return [];
return stackRowOwners(switchBoards(owner.node.graph), owner.node.id, owner.name, row.name);
}
function ownerNames(owners) {
return [...new Set(owners.map((owner) => `"${owner.title}"`))].join(", ");
}
// The switches that set one group of the stack (its own on/off).
function groupOwners(state, group) {
const owner = state.owner;
if (!owner?.node?.graph || !group) return [];
return stackGroupOwners(switchBoards(owner.node.graph), owner.node.id, owner.name, group);
}
// Put a lock in the grip's place on every row and group a switch sets.
// Cheap to call often: the rows are only touched when who sets them changed.
function refreshLocks(state) {
const owners = state.rows.map((row) => rowOwners(state, row));
const groups = groupNames(state.rows).map((group) => [group, ownerNames(groupOwners(state, group))]);
const signature = owners.map(ownerNames).join("|") + "#" + groups.map((pair) => pair.join("=")).join("|");
if (signature === state.lockSignature) return;
state.lockSignature = signature;
const groupLocks = new Map(groups);
state.panel.querySelectorAll(".ausboss-lora-ghead").forEach((head) => {
const grip = head.querySelector(".ausboss-lora-grip");
if (!grip) return;
const names = groupLocks.get(head.dataset.group) ?? "";
head.classList.toggle("owned", Boolean(names));
grip.classList.toggle("locked", Boolean(names));
grip.innerHTML = names ? lockIconSvg() : gripIconSvg();
grip.title = names
? `This group is turned on and off by ${names}. The LoRAs in it stay yours. Click the lock to take the group over: the switch then leaves it alone. Drag to move the whole group.`
: "Drag to move the whole group";
});
const rows = state.panel.querySelectorAll(".ausboss-lora-row");
rows.forEach((rowElement, index) => {
const grip = rowElement.querySelector(".ausboss-lora-grip");
if (!grip) return;
const names = ownerNames(owners[index] ?? []);
rowElement.classList.toggle("owned", Boolean(names));
grip.classList.toggle("locked", Boolean(names));
grip.innerHTML = names ? lockIconSvg() : gripIconSvg();
grip.title = names
? `Set by ${names}. Click the lock to take this row over: the switch then leaves it alone. Drag to reorder.`
: "Drag to reorder";
});
}
// Take a row back from the switches that set it: they forget it, keep the
// rest of what they do, and stop reading "changed" because of it.
function takeRowOver(state, index) {
const row = state.rows[index];
const owners = rowOwners(state, row);
if (!owners.length) return;
for (const owner of owners) {
owner.node.properties ??= {};
owner.node.properties[SWITCHES_PROPERTY] = releaseStackRow(
owner.node.properties[SWITCHES_PROPERTY], owner.id, state.owner.node.id, state.owner.name, row.name,
);
}
refreshLocks(state);
app.canvas?.setDirty?.(true, true);
notifyAusbossChange();
loraToast(`${rowLabel(state, row.name)} is yours now: ${ownerNames(owners)} no longer sets it.`);
}
// Take a group back from the switches that turn it on and off.
function takeGroupOver(state, group) {
const owners = groupOwners(state, group);
if (!owners.length) return;
for (const owner of owners) {
owner.node.properties ??= {};
owner.node.properties[SWITCHES_PROPERTY] = releaseStackGroup(
owner.node.properties[SWITCHES_PROPERTY], owner.id, state.owner.node.id, state.owner.name, group,
);
}
refreshLocks(state);
app.canvas?.setDirty?.(true, true);
notifyAusbossChange();
loraToast(`The group ${group} is yours now: ${ownerNames(owners)} no longer turns it on and off.`);
}
// A renamed group stays with the switches that turn it on and off.
function renameOwnedGroup(state, from, to) {
const owner = state.owner;
if (!owner?.node?.graph) return;
for (const board of switchBoards(owner.node.graph)) {
if (!stackGroupOwners([board], owner.node.id, owner.name, from).length) continue;
board.node.properties[SWITCHES_PROPERTY] = renameStackGroup(board.settings, owner.node.id, owner.name, from, to);
}
}
// ---------- rendering ----------
// Per-row decoration. The strength bar behind each name (inline styles,
// not CSS classes - every bar's geometry is data-driven; the pure math
// lives in js/shared/lora_stack.mjs), the amber duplicate ring,
// and the moved/missing tint against the install's list. Runs on every
// value update - the bar scale can change whenever any row's strength does.
function decorateRows(state) {
const showBars = state.settings?.strength_bars !== false;
const scale = strengthBarScale(state.rows);
const dups = duplicateLoraKeys(state.rows);
state.panel.querySelectorAll(".ausboss-lora-name").forEach((button, index) => {
const row = state.rows[index];
if (!row?.name) {
button.style.background = "";
button.classList.remove("dup", "moved", "missing");
return;
}
const notes = [row.name];
if (showBars) {
button.style.background = strengthBarBackground(row.strength, scale);
notes.push(`Model strength ${row.strength.toFixed(2)}; `
+ `bar edges are ±${scale.toFixed(2)}`);
} else {
button.style.background = "";
}
const dup = dups.has(row.name.toLowerCase());
button.classList.toggle("dup", dup);
if (dup) notes.push("Duplicate: another row loads this same LoRA.");
const status = nameStatus(state, row.name);
button.classList.toggle("moved", status?.status === "remapped");
button.classList.toggle(
"missing",
status?.status === "missing" || status?.status === "ambiguous",
);
if (status?.status === "remapped") {
notes.push(`Not at its saved path; the run will use "${status.name}".`);
} else if (status?.status === "missing") {
notes.push(
state.onMissingWidget?.value === "skip"
? "No matching file in models/loras - this row is skipped at run time "
+ "(Stop on missing LoRA is off)."
: "No matching file in models/loras - the run stops before sampling. Fix "
+ "the row, or turn Stop on missing LoRA off in the gear menu to skip it.",
);
} else if (status?.status === "ambiguous") {
notes.push("Several files match this name - pick one to settle it.");
}
button.title = notes.join("\n");
});
}
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");
}
decorateRows(state);
}
// ---------- absorb the loader chain ----------
// Loader recognition and graph traversal share one tested source in lora_stack.mjs.
// Circular reconnect arrow, hand-drawn like the shared gear so no glyph
// font is trusted to have it.
function reloadIconSvg() {
return (
'<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" ' +
'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M13.4 8a5.4 5.4 0 1 1-1.8-4"/><polyline points="13.6 1.4 13.6 4.6 10.4 4.6"/></svg>'
);
}
// Re-check every row against a FRESH lora list and repair
// what it can. A row whose file moved (or was restored after being missing)
// reconnects by rewriting the row to the resolved path - exactly what
// deleting and re-adding the row did by hand. Wired to the bar's reconnect
// button and to ComfyUI's own R refresh; quiet mode only toasts when it
// actually repaired something.
async function runReconnect(state, { quiet = false } = {}) {
let list;
try {
list = await fetchLoraList();
} catch {
if (!quiet) loraToast("Could not refresh the LoRA list.");
return;
}
state.available = list;
state.nameStatus = new Map();
let repaired = 0;
const rows = state.rows.map((row) => {
if (!row.name) return row;
const fit = resolveLoraName(row.name, list);
if (fit.status !== "remapped") return row;
repaired += 1;
rangeCache.delete(row.name);
return { ...row, name: fit.name };
});
const unresolved = rows.filter(
(row) => row.name && resolveLoraName(row.name, list).status !== "exact",
).length;
if (repaired) {
commitRows(state, rows, { structural: true });
notifyAusbossChange();
} else {
decorateRows(state);
}
if (!quiet || repaired) {
const lead = repaired
? `Reconnected ${repaired} LoRA path${repaired === 1 ? "" : "s"}`
: "LoRA list refreshed";
if (unresolved) {
loraToast(`${lead}; ${unresolved} row${unresolved === 1 ? "" : "s"} still missing.`);
} else {
loraToast(repaired ? `${lead}.` : `${lead}; every row points at a real file.`);
}
}
}
function loraToast(detail) {
showToast({ summary: "LoRA Loader \u{1F18E}", detail, life: 7000 });
}
async function runImportChain(state) {
// A bypassed or muted loader is not shaping the current render; importing
// its rows would change the image. Leave it alone.
const isActive = (loader) =>
loader.mode !== BYPASS_MODE && loader.mode !== MUTE_MODE;
const upstream = collectUpstreamLoaders(state.node);
const downstream = collectDownstreamLoaders(state.node);
const issue = absorbChainIssue(state.node, upstream, downstream);
if (issue) { loraToast(issue); return; }
const activeUp = upstream.filter(isActive);
const activeDown = downstream.filter(isActive);
const sources = [...activeUp, ...activeDown];
// A snapshot cannot reproduce values calculated by another node at run time.
if (sources.some((loader) => loader.inputs?.some((input) => input.link != null
&& !["MODEL", "CLIP"].includes(String(input.type).toUpperCase())))) {
loraToast("A source loader has linked LoRA settings. Disconnect those settings before absorbing; source loaders remain active.");
return;
}
const entriesOf = (loader) => loaderLoraEntries({
type: String(loader.type ?? loader.comfyClass ?? ""),
widgets: (loader.widgets ?? []).map((widget) => ({
name: widget.name,
value: widget.value,
})),
})?.map(row => ({ ...row, strength_clip: loader.inputs?.some(input =>
String(input.type).toUpperCase() === "CLIP" && input.link != null) ? row.strength_clip : 0 })) ?? [];
// Upstream walks nearest-first, so reversed = the order the chain applies
// them; downstream nearest-first already IS its apply order.
const upEntries = activeUp.slice().reverse().flatMap(entriesOf);
const downEntries = activeDown.flatMap(entriesOf);
if (!upEntries.length && !downEntries.length) {
loraToast(importSummary({}));
return;
}
let available = [];
try {
available = await fetchLoraList();
state.available = available;
state.nameStatus = new Map();
} catch {
// Offline: names import verbatim; the rows tint and the run-time
// resolver gets the next chance.
}
let remapped = 0;
let missing = 0;
let ambiguous = 0;
const resolve = (item) => {
if (!available.length) return { ...item };
const fit = resolveLoraName(item.name, available);
if (fit.status === "remapped") remapped += 1;
else if (fit.status === "missing") missing += 1;
else if (fit.status === "ambiguous") ambiguous += 1;
return { ...item, name: fit.name };
};
const upResolved = upEntries.map(resolve);
const downResolved = downEntries.map(resolve);
// Preserve upstream → this stack → downstream application order, including
// repeated files: the same LoRA in two loaders represents two applications.
const upMerge = mergeImportedRows(state.rows, upResolved,
{ position: "before", deduplicate: false });
const downMerge = mergeImportedRows(upMerge.rows, downResolved,
{ position: "after", deduplicate: false });
// Never bypass a source if any of its rows would be lost to the stack limit.
if (state.rows.length + upMerge.added + downMerge.added > MAX_ROWS) {
loraToast(`Absorb needs more than ${MAX_ROWS} rows. Free some stack rows first; source loaders remain active.`);
return;
}
// Rows that patch model and CLIP differently need both boxes visible, or
// the difference is invisible and lost on the first scrub. The flip is
// node-local (unified stays the default for new nodes) and the toast
// names it so it never reads as a settings change.
let separated = false;
if (importNeedsSeparateStrengths([...upResolved, ...downResolved])
&& linked(state)) {
state.node.properties.ausbossLoraLinked = false;
separated = true;
}
const absorbed = [...activeUp, ...activeDown];
for (const loader of absorbed) loader.mode = BYPASS_MODE;
commitRows(state, downMerge.rows, { structural: true });
fitNode(state);
notifyAusbossChange();
loraToast(importSummary({
added: upMerge.added + downMerge.added,
skipped: upMerge.skipped + downMerge.skipped,
bypassed: absorbed.length,
remapped,
missing,
ambiguous,
}) + (separated
? " An imported row patches model and CLIP differently, so this node "
+ "now shows both strength boxes."
: ""));
}
function openSettings(state, anchor) {
// The import action rides the gear menu but is per-node and one-shot, so
// the schema is composed per call; persistence still uses SETTINGS_SCHEMA.
const menuSchema = [
...SETTINGS_SCHEMA,
{
key: "_import_chain",
label: "Absorb chain LoRAs",
type: "action",
button: "Absorb",
hint: "Pull every LoRA loader wired into this node's model chain - "
+ "upstream and downstream - into this stack and bypass those "
+ "nodes; the graph keeps computing the same thing.",
onAction: () => {
closeSettingsMenu();
runImportChain(state);
},
},
];
// Inside a subgraph the rows can show on the box's face instead; first in
// the menu there, since it is what a loader in a box is usually set up for.
if (state.node.graph?.isRootGraph === false) {
const onBox = Boolean(rowsInputName(state.node));
menuSchema.unshift({
key: "_box_rows",
label: "Rows on the box",
type: "action",
button: onBox ? "Take back" : "Show",
hint: onBox
? "The rows show on the box this subgraph sits in. Take back puts them "
+ "inside this loader again, as the box you came in through holds them; "
+ "every copy of the box then runs those rows."
: "Show these rows on the face of the box this subgraph sits in, where "
+ "they can be switched on and off and set by Workflow Switches. Each "
+ "copy of the box keeps its own rows.",
onAction: () => {
closeSettingsMenu();
if (onBox) rowsBackInside(state);
else showRowsOnBox(state);
},
});
}
openSettingsMenu({
scope: SETTINGS_SCOPE,
schema: menuSchema,
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 } : {}),
...(state.onMissingWidget
? { strict_missing: state.onMissingWidget.value === "error" }
: {}),
},
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;
}
if ((key === "strict_missing" || key === null) && state.onMissingWidget) {
state.onMissingWidget.value = values.strict_missing ? "error" : "skip";
}
renderRows(state);
fitNode(state);
notifyAusbossChange();
},
});
}
// Only keys dispatched inside this panel participate. No window/document
// listener and no canvas selection heuristic: Tab, application shortcuts,
// typing, and focus in every other node keep their existing behavior.
function focusStackRow(state, index) {
const rows = [...state.panel.querySelectorAll(".ausboss-lora-row")];
const target = index >= 0 && index < rows.length ? rows[index]
: state.panel.querySelector(".ausboss-lora-add:not(:disabled)") ?? rows.at(-1);
hideHoverThumb();
target?.focus({ preventScroll: true });
}
// Up and Down step over the rows of a folded group.
function visibleRowIndex(state, from, to) {
const rows = [...state.panel.querySelectorAll(".ausboss-lora-row")];
const step = to < from ? -1 : 1;
let at = to;
while (at >= 0 && at < rows.length && rows[at].classList.contains("folded-away")) at += step;
return at < 0 ? from : at;
}
// Is the browser showing keyboard focus on this element? A control that was
// clicked with the mouse has focus too, without the outline. Focus is only
// ever handed to a row from an element that shows it: otherwise a mouse click
// would leave a row holding the keys with nothing on screen to say so.
function showsKeyboardFocus(element) {
try {
return Boolean(element?.matches?.(":focus-visible"));
} catch {
return false;
}
}
function installStackKeyboard(state) {
state.panel.addEventListener("keydown", (event) => {
const target = event.target;
if (!state.panel.contains(document.activeElement) || event.defaultPrevented
|| event.isComposing || event.keyCode === 229) return;
const rowElement = target.closest?.(".ausboss-lora-row");
const atAdd = Boolean(target.closest?.(".ausboss-lora-add, .ausboss-lora-blank"));
if (!rowElement && !atAdd) return;
const field = target.closest?.("input, textarea, select, [contenteditable]");
const editing = Boolean(field && (field.isContentEditable
|| field.tagName !== "INPUT" || !field.readOnly));
if (event.key === "Escape" && !editing && !event.ctrlKey && !event.metaKey && !event.altKey) {
event.preventDefault();
event.stopPropagation();
document.activeElement?.blur();
return;
}
// A row's own buttons and boxes keep Enter and Space (the switch turns
// the row off, the name opens its picker). A strength field keeps its
// editing keys; arrows only browse it while idle.
const onControl = Boolean(rowElement && target !== rowElement);
const index = rowElement ? [...state.panel.querySelectorAll(".ausboss-lora-row")].indexOf(rowElement)
: state.rows.length;
const action = loraStackKeyAction(event, { index, rowCount: state.rows.length, atAdd, editing, onControl });
const heldToggle = rowElement && !onControl && event.key === " " && event.repeat && !editing
&& !event.ctrlKey && !event.metaKey && !event.altKey;
if (!action && !heldToggle) return;
event.preventDefault();
event.stopPropagation();
if (!action) return; // Holding Space never toggles twice or starts canvas panning.
// "block" (Delete, Backspace) ends here: the key is kept from ComfyUI.
if (action.kind === "focus") {
focusStackRow(state, visibleRowIndex(state, index, action.index));
} else if (action.kind === "pick") {
if (atAdd && state.rows.length >= MAX_ROWS) return;
openPicker(state, atAdd ? null : index,
atAdd ? state.panel.querySelector(".ausboss-lora-add") : rowElement.querySelector(".ausboss-lora-name"),
{ keyboard: true });
} else if (action.kind === "toggle") {
const rows = state.rows.map((row, i) => i === index ? { ...row, enabled: !row.enabled } : row);
state.toggleSnapshot = snapshotEnabled(rows);
commitRows(state, rows, { structural: true });
focusStackRow(state, index);
} else if (action.kind === "strength") {
const strengthField = field ?? target.closest?.(".ausboss-lora-strengthbox")?.querySelector("input");
const key = strengthField?.dataset.ausbossStrength === "strength_clip" ? "strength_clip" : "strength";
const step = action.fine ? FINE_STEP : state.settings?.step ?? DEFAULT_STEP;
const value = roundStrength(state.rows[index][key] + action.direction * step);
if (value === state.rows[index][key]) return;
const rows = key === "strength"
? setStrength(state.rows, index, value, linked(state))
: state.rows.map((row, i) => i === index ? { ...row, strength_clip: value } : row);
commitRows(state, rows);
}
}, { capture: true });
}
function renderRows(state) {
const panel = state.panel;
// The rebuild below drops focus. It is put back only for keyboard focus:
// after a mouse click on a switch the keys stay with ComfyUI, as before.
const focused = panel.contains(document.activeElement) && showsKeyboardFocus(document.activeElement)
? document.activeElement : null;
const focusedRow = focused?.closest?.(".ausboss-lora-row") ?? null;
const focusedRowId = focusedRow?.dataset.rowId;
const focusedIndex = focusedRow ? [...panel.querySelectorAll(".ausboss-lora-row")].indexOf(focusedRow) : -1;
const wasAddFocused = Boolean(focused?.matches?.(".ausboss-lora-add, .ausboss-lora-blank"));
panel.textContent = "";
const body = el("div", "ausboss-lora-body");
panel.append(body);
const addRowAndPick = (event) => {
if (state.rows.length >= MAX_ROWS) return;
// detail is 0 for a click made with Enter or Space.
openPicker(state, null, event.currentTarget, { keyboard: event.detail === 0 });
};
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 saves your setup and turns all on; "
+ "on turns all off; off brings your saved setup back";
// The master pill remembers the mixed setup it destroys,
// so an accidental click is always one more click from home.
master.addEventListener("click", () => {
const cycled = cycleMasterToggle(state.rows, state.toggleSnapshot);
state.toggleSnapshot = cycled.snapshot;
commitRows(state, cycled.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 recheck = el("button", "ausboss-lora-gear");
recheck.type = "button";
recheck.title = "Re-check LoRA files: refresh the list and reconnect rows "
+ "whose files moved or came back";
recheck.innerHTML = reloadIconSvg();
recheck.addEventListener("click", () => runReconnect(state));
const gear = el("button", "ausboss-lora-gear");
gear.type = "button";
gear.title = "LoRA Loader settings";
gear.innerHTML = gearIconSvg();
gear.addEventListener("click", () => openSettings(state, gear));
const group = el("div", "ausboss-lora-bargroup");
// On a box only the rows themselves: templates and the settings stay on
// the loader inside.
if (state.box) group.append(master, el("span", "ausboss-lora-summary", summarizeRows(state.rows)), recheck);
else group.append(templates, master, el("span", "ausboss-lora-summary", summarizeRows(state.rows)), recheck, gear);
bar.append(group);
body.append(bar);
panel.classList.toggle("readonly", Boolean(state.readOnly));
if (state.note) {
const note = el("div", "ausboss-lora-onbox", state.note);
note.title = state.note;
body.append(note);
}
const stack = el("div", "ausboss-lora-stack");
// Rows and group headers open their own menus; the space around them
// opens the stack's, never the browser's Copy / Select all.
stack.addEventListener("contextmenu", (event) => {
event.preventDefault();
event.stopPropagation();
openStackMenu(state, event);
});
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);
}
const folded = foldedGroups(state);
const chosen = selectedIds(state);
for (const id of [...chosen]) if (!state.rows.some((row) => row.id === id)) chosen.delete(id);
const headers = new Map(stackLayout(state.rows)
.filter((item) => item.kind === "group").map((item) => [item.start, item]));
state.rows.forEach((row, index) => {
const header = headers.get(index);
if (header) stack.append(groupHeader(state, stack, header, folded.has(header.group)));
const rowElement = el("div", "ausboss-lora-row");
if (row.group) rowElement.classList.add("child");
if (row.group && folded.has(row.group)) rowElement.classList.add("folded-away");
if (chosen.has(row.id)) rowElement.classList.add("selected");
rowElement.tabIndex = 0;
rowElement.dataset.rowId = row.id;
rowElement.setAttribute("role", "group");
rowElement.setAttribute("aria-label", row.name || "Empty LoRA row");
rowElement.setAttribute("aria-keyshortcuts", "ArrowUp ArrowDown ArrowLeft ArrowRight Shift+ArrowLeft Shift+ArrowRight Space Enter Escape");
rowElement.title = "Up/Down chooses a row, Space toggles it, and Left/Right changes strength. "
+ "Hold Shift for smaller steps; Enter picks a file.";
if (!row.enabled) rowElement.classList.add("off");
if (row.group_off) rowElement.classList.add("groupoff");
const grip = el("button", "ausboss-lora-grip");
grip.type = "button";
grip.title = "Drag to reorder";
grip.innerHTML = gripIconSvg();
grip.addEventListener("pointerdown", (event) => {
// A lone row can still be dragged out of its group.
if (event.button !== 0 || (state.rows.length < 2 && !row.group)) return;
event.preventDefault();
hideHoverThumb();
startStackDrag(state, stack, [rowElement], grip, event,
{ kind: "row", id: row.id, label: row.name ? rowLabel(state, row.name) : "Empty LoRA row" });
});
// A click on the lock of a row a switch sets takes the row over (a drag
// that moved the row is no click).
grip.addEventListener("click", () => {
if (grip.dataset.ausbossDragged) {
delete grip.dataset.ausbossDragged;
return;
}
takeRowOver(state, index);
});
const toggle = el("button", `ausboss-lora-toggle${row.enabled ? " on" : ""}`);
toggle.type = "button";
toggle.title = "Enable or disable this LoRA";
// The pill only shows its state by colour and position.
toggle.setAttribute("role", "switch");
toggle.setAttribute("aria-checked", String(row.enabled));
toggle.addEventListener("click", () => {
const rows = state.rows.map((entry, i) =>
i === index ? { ...entry, enabled: !entry.enabled } : entry
);
// Every hand-made toggle refreshes the master pill's memory.
state.toggleSnapshot = snapshotEnabled(rows);
commitRows(state, rows, { structural: true });
});
const name = el("button", "ausboss-lora-name");
name.append(row.name
? nameLabel(rowLabel(state, row.name))
: el("span", "ausboss-lora-label", "choose a LoRA..."));
// The visible text may be shortened; a screen reader gets the whole name.
if (row.name) name.setAttribute("aria-label", rowLabel(state, row.name));
name.type = "button";
name.title = row.name || "Pick a LoRA from models/loras";
if (!row.name) name.classList.add("empty");
// The name is also a scrub surface - the bar behind it
// makes it the natural drag target for model strength. The shared dead
// zone keeps a plain click a click, which still opens the picker; a
// scrub that ends on the button swallows the click it fires.
let nameDrag = null;
name.addEventListener("pointerdown", (event) => {
if (event.button !== 0 || !row.name
|| state.settings?.name_scrub === false) return;
nameDrag = { x: event.clientX, y: event.clientY,
start: state.rows[index].strength, scrubbed: false };
name.setPointerCapture(event.pointerId);
});
name.addEventListener("pointermove", (event) => {
if (!nameDrag) {
showHoverThumb(state, row.name, event.clientX, event.clientY);
return;
}
const dx = event.clientX - nameDrag.x;
const dy = event.clientY - nameDrag.y;
if (!nameDrag.scrubbed && isScrubbing(dx, dy)) {
nameDrag.scrubbed = true;
hideHoverThumb();
}
if (nameDrag.scrubbed) {
commitRows(state, setStrength(
state.rows,
index,
scrubValue(nameDrag.start, dx, event.shiftKey, state.settings?.step),
linked(state),
), { record: false });
}
});
const endNameDrag = (event) => {
if (!nameDrag) return;
try { name.releasePointerCapture(event.pointerId); } catch {}
if (nameDrag.scrubbed) {
name.dataset.ausbossScrubbed = "1";
notifyAusbossChange();
}
nameDrag = null;
};
name.addEventListener("pointerup", endNameDrag);
name.addEventListener("pointercancel", endNameDrag);
name.addEventListener("click", (event) => {
if (name.dataset.ausbossScrubbed) {
delete name.dataset.ausbossScrubbed;
return;
}
openPicker(state, index, name, { keyboard: event.detail === 0 });
});
name.addEventListener("pointerenter", (event) =>
showHoverThumb(state, row.name, event.clientX, event.clientY));
name.addEventListener("pointerleave", hideHoverThumb);
rowElement.append(grip, toggle, name, strengthBox(state, index, "strength"));
if (!linked(state)) rowElement.append(strengthBox(state, index, "strength_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);
});
// Ctrl/Cmd-click or Shift-click selects the row instead of using the
// control under the pointer. The strength boxes keep their own clicks,
// and Shift on the name can still start a fine scrub: its click decides.
const modified = (event) => event.ctrlKey || event.metaKey || event.shiftKey;
rowElement.addEventListener("pointerdown", (event) => {
if (event.button !== 0 || !modified(event) || event.target.closest(".ausboss-lora-strengthbox")) return;
if (!event.ctrlKey && !event.metaKey && event.target.closest(".ausboss-lora-name")) return;
event.preventDefault();
event.stopPropagation();
}, { capture: true });
rowElement.addEventListener("click", (event) => {
if (!modified(event) || event.target.closest(".ausboss-lora-strengthbox")
|| name.dataset.ausbossScrubbed) return;
event.preventDefault();
event.stopPropagation();
selectRow(state, row.id, event.shiftKey && !event.ctrlKey && !event.metaKey);
}, { capture: true });
stack.append(rowElement);
});
// The add button lives INSIDE the stack container and pins to its bottom
// edge (margin-top: auto), so it stays put however tall the node is
// dragged - the stack absorbs the free height above it.
const actions = el("div", "ausboss-lora-actions");
const add = el("button", "ausboss-lora-add", "+ LoRA");
add.type = "button";
add.disabled = state.rows.length >= MAX_ROWS;
add.addEventListener("click", addRowAndPick);
add.title = "Pick one LoRA, or click a folder name to add all its LoRAs.";
actions.append(add);
stack.append(actions);
body.append(stack);
decorateRows(state);
state.lockSignature = null;
refreshLocks(state);
// Names refit whenever the stack's box changes - the node was resized,
// or the panel was laid out for the first time. The first observation
// arrives before the next paint, so an unfitted name never shows.
state.nameObserver ??= new ResizeObserver(() => {
holdVueNodeMinWidth(state.panel, panelMinWidth(state));
fitRowNames(state);
});
state.nameObserver.disconnect();
state.nameObserver.observe(stack);
if (focusedRowId) {
const index = state.rows.findIndex((row) => row.id === focusedRowId);
focusStackRow(state, index >= 0 ? index : Math.min(focusedIndex, state.rows.length));
} else if (wasAddFocused) focusStackRow(state, state.rows.length);
}
// Rows cut a long name at the end (the label's CSS ellipsis) unless the
// gear's "Long names" asks for the middle. The picker always keeps the end
// in view: that is where a search tells checkpoints apart.
function fitRowNames(state) {
const labels = state.panel.querySelectorAll(".ausboss-lora-name > [data-ausboss-full]");
if (state.settings?.name_cut === "middle") {
fitLabels(labels);
return;
}
for (const label of labels) {
if (label.textContent !== label.dataset.ausbossFull) label.textContent = label.dataset.ausbossFull;
}
}
// ---------- rows on a box ----------
// The box the user last came in through, per subgraph. An inner loader
// whose rows are set on the box follows that box's copy of them.
const enteredBoxes = new WeakMap();
function watchEnteredBoxes() {
const canvas = app.canvas?.canvas;
if (!canvas || canvas.__ausbossLoraEntered) return;
canvas.__ausbossLoraEntered = true;
canvas.addEventListener("subgraph-opened", (event) => {
const { subgraph, fromNode } = event.detail ?? {};
if (subgraph && fromNode) enteredBoxes.set(subgraph, fromNode);
});
}
function rootGraph() {
return app.rootGraph ?? app.graph;
}
function isLoader(node) {
return node?.type === NODE_CLASS || node?.comfyClass === NODE_CLASS;
}
function boxTitle(box) {
return String(box?.title || "the box").replace(/\s*🆎\s*$/u, "");
}
// Where a card's rows are kept, and so what it shows and edits:
// - a box card: the box's own copy of the rows of its loader inside;
// - a loader whose rows come in from its box: that box's copy, when it is
// known which box (the one the user came in through, or the only one),
// else its own rows, read only, since each box sets its own;
// - any other loader: its own rows.
// `owner` is the node and control a Workflow Switches switch addresses.
function rowsSource(state) {
const node = state.node;
if (state.box) {
return { widget: boxRowsWidget(node, state.box.inputName), owner: { node, name: state.box.inputName } };
}
const own = node.widgets?.find((item) => item.name === "loras") ?? null;
const fed = rowsInputName(node);
if (!fed) return { widget: own, owner: { node, name: "loras" } };
const box = hostBox(node.graph, boxesOf(node.graph, workflowNodes(rootGraph())), enteredBoxes.get(node.graph));
const widget = box && boxRowsWidget(box, fed);
if (!widget) {
return {
widget: own, owner: { node, name: "loras" }, readOnly: true,
note: "Each box that holds this subgraph sets these rows on its face.",
};
}
return { widget, owner: { node: box, name: fed }, note: `These rows are set on the box "${boxTitle(box)}".` };
}
// Follow the rows to wherever they are kept now. Returns true when the card
// was drawn again.
function syncRows(state) {
const source = rowsSource(state);
const widget = source.widget;
if (!widget) return false;
// The box's raw copy hides under its card; the frontend may hand the box
// a new copy object at any time (a reload, an undo).
if (state.box) hideWidget(widget);
const moved = widget !== state.widget || source.note !== state.note
|| Boolean(source.readOnly) !== Boolean(state.readOnly)
|| source.owner.node !== state.owner?.node || source.owner.name !== state.owner?.name;
state.widget = widget;
state.owner = source.owner;
state.readOnly = Boolean(source.readOnly);
state.note = source.note;
if (!moved && widget.value === state.renderedValue) return false;
state.rows = parseRows(widget.value);
state.renderedValue = widget.value;
state.toggleSnapshot = snapshotEnabled(state.rows);
renderRows(state);
fitNode(state);
return true;
}
// Every card on the page, so one redraw hook can keep them current in both
// renderers (Nodes 2.0 does not draw a node on the canvas), and every box
// that is watched for rows coming up to it.
const liveCards = new Set();
const watchedBoxes = new Set();
let redrawTimer = null;
let hookedCanvas = null;
function ensureRedrawHook() {
const canvas = app.canvas;
if (!canvas || canvas === hookedCanvas) return;
hookedCanvas = canvas;
chainCallback(canvas, "onDrawForeground", () => {
if (redrawTimer !== null) return;
redrawTimer = setTimeout(() => {
redrawTimer = null;
const shown = app.canvas?.getCurrentGraph?.() ?? app.canvas?.graph;
for (const box of watchedBoxes) if (box.graph === shown) syncBoxCards(box);
for (const state of liveCards) {
if (state.node.graph !== shown) continue;
syncRows(state);
refreshLocks(state);
}
}, 250);
});
}
// Bring a loader's rows up to its box: the frontend's own way of showing a
// setting on a box, a subgraph input linked to the rows. Every box of this
// subgraph then keeps its own copy, starting from these rows.
function showRowsOnBox(state) {
const node = state.node;
const subgraph = node.graph;
const slot = node.inputs?.find((input) => input.name === "loras");
const inputNode = subgraph?.inputNode;
if (!slot || typeof inputNode?.connectByType !== "function" || typeof subgraph.removeInput !== "function") {
loraToast("This ComfyUI version cannot show the rows on the box.");
return;
}
const before = new Set(subgraph.inputs ?? []);
// -1 makes a new subgraph input for the first input of this type, which
// on this node is the rows (the inputs only ever grow at the end).
inputNode.connectByType(-1, node, slot.type || "STRING");
const added = (subgraph.inputs ?? []).find((input) => !before.has(input));
if (!added || rowsInputName(node) !== added.name) {
if (added) subgraph.removeInput(added);
loraToast("The rows could not be shown on the box.");
return;
}
for (const box of boxesOf(subgraph, workflowNodes(rootGraph()))) syncBoxCards(box);
syncRows(state);
app.canvas?.setDirty?.(true, true);
notifyAusbossChange();
loraToast("The rows now show on the box. Each copy of the box keeps its own rows.");
}
// Put the rows back inside the loader. It keeps the rows of the box the
// user came in through; every copy of the box runs those from then on.
function rowsBackInside(state) {
const node = state.node;
const subgraph = node.graph;
const name = rowsInputName(node);
const input = (subgraph?.inputs ?? []).find((item) => item.name === name);
if (!input) return;
const own = node.widgets?.find((item) => item.name === "loras");
const shown = rowsSource(state).widget;
const keep = shown && shown !== own ? shown.value : null;
// A box whose input is wired from outside keeps its input; only the link
// inside goes, as the frontend does it.
const wired = boxesOf(subgraph, workflowNodes(rootGraph())).some((box) => {
const index = (box.inputs ?? []).findIndex((item) => item.name === name);
return index >= 0 && box.isInputConnected?.(index);
});
if (wired) input.disconnect();
else subgraph.removeInput(input);
if (own && typeof keep === "string") own.value = keep;
syncRows(state);
app.canvas?.setDirty?.(true, true);
notifyAusbossChange();
loraToast("The rows are back inside the loader.");
}
// A box shows one card for each loader inside whose rows came up to it.
function syncBoxCards(box) {
if (!box?.isSubgraphNode?.() || !box.graph) return;
box.__ausbossLoraCards ??= new Map();
const cards = box.__ausbossLoraCards;
const wanted = new Set();
for (const { name, loader } of boxRowInputs(box, isLoader)) {
if (!boxRowsWidget(box, name)) continue;
wanted.add(name);
const card = cards.get(name);
if (card) card.box.loader = loader;
else {
const state = mountCard(box, { box: { inputName: name, loader } });
if (state) cards.set(name, state);
}
}
for (const [name, state] of cards) {
if (wanted.has(name)) continue;
cards.delete(name);
unmountCard(state);
}
}
function watchBox(box) {
if (!box?.isSubgraphNode?.() || box.__ausbossLoraWatch) return;
const abort = new AbortController();
box.__ausbossLoraWatch = abort;
watchedBoxes.add(box);
const later = () => requestAnimationFrame(() => syncBoxCards(box));
for (const type of ["widget-promoted", "widget-demoted", "input-added", "removing-input"]) {
box.subgraph?.events?.addEventListener?.(type, later, { signal: abort.signal });
}
chainCallback(box, "onConfigure", later);
chainCallback(box, "onRemoved", () => {
abort.abort();
watchedBoxes.delete(box);
for (const state of box.__ausbossLoraCards?.values() ?? []) unmountCard(state, { resize: false });
box.__ausbossLoraCards?.clear();
});
later();
}
function unmountCard(state, { resize = true } = {}) {
liveCards.delete(state);
state.disposed = true;
state.nameObserver?.disconnect();
closePopup();
try {
state.node.removeWidget?.(state.domWidget);
} catch {
state.domWidget?.onRemove?.();
}
if (resize) {
state.node.setSize?.([state.node.size[0], state.node.computeSize?.()[1] ?? state.node.size[1]]);
state.node.setDirtyCanvas?.(true, true);
}
}
// ---------- node install ----------
// The row card, on a LoRA Loader or on a box that holds one (`box`:
// { inputName, loader }). Returns its state.
function mountCard(node, { box = null, separatorWidget = null, onMissingWidget = null } = {}) {
installStyles();
const settings = loadSettings(SETTINGS_SCOPE, SETTINGS_SCHEMA);
// Unified strengths are the hard default: separate_strengths is per-node
// (persist:false in the schema), so any stored value is legacy noise from
// before it stopped persisting - never a reason to split a fresh node.
settings.separate_strengths = false;
const panel = el("div", "ausboss-lora-panel");
// A box card sits under the box's own slots: a box's slots are whatever
// its subgraph has, so there is no empty band to climb into.
const reclaim = box ? 0 : BAND_RECLAIM;
const state = { node, box, widget: null, owner: null, panel, rows: [], settings, renderedValue: null, reclaim };
const source = rowsSource(state);
if (!source.widget) return null;
state.widget = source.widget;
state.owner = source.owner;
state.note = source.note;
state.readOnly = Boolean(source.readOnly);
if (box) hideWidget(source.widget);
state.rows = parseRows(state.widget.value);
state.renderedValue = state.widget.value;
state.toggleSnapshot = snapshotEnabled(state.rows);
installStackKeyboard(state);
state.separatorWidget = separatorWidget;
// A missing file reads the way its loader is set to treat it.
state.onMissingWidget = box ? box.loader?.widgets?.find((item) => item.name === "on_missing") : onMissingWidget;
const domWidget = node.addDOMWidget(box ? `ausboss_lora_rows:${box.inputName}` : "ausboss_lora_rows", "ausboss_lora_rows", panel, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => panelHeight(state) + WIDGET_FRAME - reclaim,
});
keepDomWidgetWidthAuto(domWidget);
// Not saved with the workflow either: options.serialize only keeps it out
// of the prompt, and saved values come back by position.
domWidget.serialize = false;
state.domWidget = domWidget;
// fillNodeHeight, not a pinned computeSize: the panel joins the layout's
// free-space split, follows the node when it is dragged taller (the add
// button stays on the bottom edge), and a floor that carries WIDGET_FRAME
// guarantees the element box never comes up short of the rows. The width
// minimum rides the same call so the node cannot shrink to where the row's
// fixed-width controls would hang past its right edge. The floor sheds
// the reclaimed pixels because they come out of the slot band, not out of
// the space below it. exactMinWidth: the floor is the node's own width,
// without the frontend's padding on top (panel_layout.mjs), so a compact
// stack really goes down to it.
fillNodeHeight(domWidget, {
minWidth: () => panelMinWidth(state),
minHeight: () => panelHeight(state) + WIDGET_FRAME - reclaim,
minNodeSize: [PANEL_MIN_WIDTH, 160],
exactMinWidth: true,
});
if (reclaim) {
// Climb the panel up into the slot band. The layout
// assigns this widget a y below the last output slot and a height from
// the free-space split; these getters show the element BAND_RECLAIM
// higher and taller than assigned, in canvas units, so the offset scales
// with zoom and the panel's bottom edge stays exactly where the layout
// put it. Writes keep landing in the backing values, so the layout's own
// accounting never sees the shift.
let assignedY = Number(domWidget.y) || 0;
Object.defineProperty(domWidget, "y", {
configurable: true,
get: () => Math.max(14, assignedY - reclaim),
set: (value) => { assignedY = Number(value) || 0; },
});
// The wrapper's height still comes from the layout (its computedHeight
// feeds node sizing, so it cannot be inflated without a feedback loop);
// the y pin moves the whole box up, leaving it BAND_RECLAIM short at the
// bottom. The panel stretches past its wrapper by exactly that much -
// inner CSS pixels are pre-transform canvas units, so this stays correct
// at every zoom.
panel.style.height = `calc(100% + ${reclaim}px)`;
}
renderRows(state);
refreshAvailable(state);
node.setSize?.([Math.max(336, node.size?.[0] || 336), node.computeSize?.()[1] || 220]);
liveCards.add(state);
ensureRedrawHook();
watchSelections();
// Workflow restore and undo may commit values after onConfigure. Reconcile
// from the widget after that commit and before the next visible frame.
const sync = () => { if (!state.disposed) syncRows(state); };
chainCallback(node, "onConfigure", function () {
queueMicrotask(() => { sync(); if (!state.disposed) refreshAvailable(state); });
requestAnimationFrame(() => { sync(); if (!state.disposed) fitNode(state); });
});
chainCallback(node, "onDrawForeground", sync);
// Another node may rewrite the rows (the Workflow Note's Use it, a
// Workflow Switches switch). Nodes 2.0 does not draw this node on the
// canvas, so follow the change itself.
chainCallback(node, "onWidgetChanged", function (name) {
if (name === state.widget?.name || name === "loras") sync();
});
chainCallback(node, "onRemoved", () => {
liveCards.delete(state);
closePopup();
state.nameObserver?.disconnect();
});
// ComfyUI's R refresh re-reads node definitions; ride it so files that
// moved or came back reconnect without touching the node by hand.
chainCallback(node, "refreshComboInNode", function () {
if (!state.disposed) runReconnect(state, { quiet: true });
});
return state;
}
function installLoraNode(node) {
const widget = node.widgets?.find((item) => item.name === "loras");
if (!widget) return;
hideWidget(widget);
if (node.properties && node.properties.ausbossLoraLinked === undefined) {
node.properties.ausbossLoraLinked = true;
}
const settings = loadSettings(SETTINGS_SCOPE, SETTINGS_SCHEMA);
// 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;
}
// Same arrangement for the missing-file policy: a hidden standard combo
// carries "skip" | "error" through save/load and the API format.
const onMissingWidget = node.widgets?.find((item) => item.name === "on_missing");
if (onMissingWidget) {
hideWidget(onMissingWidget);
onMissingWidget.value = settings.strict_missing ? "error" : "skip";
}
const state = mountCard(node, { separatorWidget, onMissingWidget });
if (state) node.__ausbossLoraState = state;
}
app.registerExtension({
name: "AusBoss.LoraLoader",
setup() {
watchEnteredBoxes();
ensureRedrawHook();
watchSelections();
},
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_CLASS) return;
hideInputsInDef(nodeData, ["loras", "trigger_separator", "on_missing"]);
chainCallback(nodeType.prototype, "onNodeCreated", function () {
installLoraNode(this);
});
chainCallback(nodeType.prototype, "onConfigure", function () {
resetUnknownValues(this.widgets, RESET_UNKNOWN);
});
},
// A box (subgraph node) shows the rows of a loader inside once they come
// up to it, from the loader's gear or the frontend's own widget list.
nodeCreated(node) {
if (node?.isSubgraphNode?.()) watchBox(node);
},
loadedGraphNode(node) {
if (node?.isSubgraphNode?.()) watchBox(node);
},
});