From 8e6e44976c1f387447bb793fa56e2b9fbac67aad Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 7 Sep 2026 14:05:04 +0100 Subject: [PATCH] Add per-LoRA variant trigger file selection in loader settings. Support optional companion txt files named lora.Variant.txt alongside the default lora.txt, with toggles in the Grid and List LoRA loader settings dialog to merge character/concept tags into the active trigger list. Co-authored-by: Cursor --- js/grid_lora_loader.js | 108 ++++------- js/list_lora_loader.js | 72 +++---- js/lora_info.js | 14 ++ js/lora_loader_settings.js | 373 +++++++++++++++++++++++++++++++++++++ lora_browser.py | 6 + nodes/grid_lora_loader.py | 44 ++++- nodes/list_lora_loader.py | 3 +- nodes/lora_utils.py | 102 +++++++++- 8 files changed, 611 insertions(+), 111 deletions(-) create mode 100644 js/lora_loader_settings.js diff --git a/js/grid_lora_loader.js b/js/grid_lora_loader.js index a49ae06..0bb7691 100644 --- a/js/grid_lora_loader.js +++ b/js/grid_lora_loader.js @@ -1,6 +1,16 @@ import { app } from "../../scripts/app.js"; -import { bindLoraContextMenu, fetchLoraTriggers } from "./lora_info.js"; +import { bindLoraContextMenu, fetchLoraTriggerVariants } from "./lora_info.js"; import { bindPreviewImage, replaceWithPreviewPlaceholder } from "./lora_preview_cache.js"; +import { + createEmptyTriggerEntry, + entryTriggerStates, + entryVariantStates, + formatEnabledTriggers, + initEntryTriggers, + loadEntryTriggerData, + LoraEntrySettingsDialog, + restoreTriggerEntryFromWidget, +} from "./lora_loader_settings.js"; const NODE_NAME = "GridLoraLoader"; const API_BASE = "/crashutils/loras"; @@ -530,28 +540,6 @@ function trimNsfwBrowsePath(path) { return parts.slice(0, nsfwIndex).join("/"); } -function initEntryTriggers(entry, triggers) { - entry.triggers = [...(triggers || [])]; - entry.triggerOn = entry.triggerOn || {}; - for (const word of entry.triggers) { - if (entry.triggerOn[word] === undefined) { - entry.triggerOn[word] = true; - } - } - for (const word of Object.keys(entry.triggerOn)) { - if (!entry.triggers.includes(word)) { - delete entry.triggerOn[word]; - } - } -} - -function entryTriggerStates(entry) { - return (entry.triggers || []).map((word) => ({ - word, - on: entry.triggerOn?.[word] !== false, - })); -} - function isTagEnabledForEntry(entry, word) { return entry.triggerOn?.[word] !== false; } @@ -1064,7 +1052,8 @@ class GridLoraLoaderUI { if (!item?.path || !el || (item.triggers && item.triggers.length)) { return; } - const triggers = await fetchLoraTriggers(item.path); + const data = await fetchLoraTriggerVariants(item.path); + const triggers = data.default || []; if (!triggers.length) { return; } @@ -1082,25 +1071,22 @@ class GridLoraLoaderUI { return; } - this.selected.set(loraPath, { + const entry = createEmptyTriggerEntry({ on: true, lora: loraPath, name: loraName, strength: 1.0, - triggers: item.triggers || [], - triggerOn: {}, }); - initEntryTriggers(this.selected.get(loraPath), item.triggers || []); - if (!item.triggers?.length) { - fetchLoraTriggers(loraPath).then((triggers) => { - const entry = this.selected.get(loraPath); - if (!entry || !triggers.length) return; - initEntryTriggers(entry, triggers); - this.renderSelected(); - this.renderTags(); - this.syncWidgets(); - }); - } + initEntryTriggers(entry, item.triggers || []); + entry.defaultTriggers = item.triggers || []; + this.selected.set(loraPath, entry); + + loadEntryTriggerData(entry).then(() => { + if (!this.selected.has(loraPath)) return; + this.renderSelected(); + this.renderTags(); + this.syncWidgets(); + }); this.syncWidgets(); this.renderSelected(); @@ -1143,15 +1129,15 @@ class GridLoraLoaderUI { el.innerHTML = ` ${formatStrength(entry.strength)} - -
${entry.name || entry.lora.split("/").pop()}
+ +
${entry.name || entry.lora.split("/").pop()}
`; bindPreviewImage(el.querySelector("img"), entry.lora, replaceWithPreviewPlaceholder); el.querySelector(".cog-btn").addEventListener("click", (e) => { e.stopPropagation(); - this.promptStrength(entry, e); + this.openSettings(entry); }); el.querySelector(".cog-btn").addEventListener("pointerdown", (e) => e.stopPropagation()); @@ -1276,21 +1262,15 @@ class GridLoraLoaderUI { } } - promptStrength(entry, event) { - app.canvas.prompt( - "LoRA Strength", - entry.strength, - (value) => { - const num = Number(value); - if (!Number.isNaN(num)) { - entry.strength = num; - this.syncWidgets(); - this.renderSelected(); - this.node.setDirtyCanvas(true, true); - } + openSettings(entry) { + new LoraEntrySettingsDialog(entry, { + onApply: () => { + this.syncWidgets(); + this.renderSelected(); + this.renderTags(); + this.node.setDirtyCanvas(true, true); }, - event - ); + }).open(); } syncWidgets() { @@ -1312,6 +1292,7 @@ class GridLoraLoaderUI { lora: entry.lora, strength: entry.strength, triggerStates: entryTriggerStates(entry), + variantStates: entryVariantStates(entry), }) ); } @@ -1321,7 +1302,7 @@ class GridLoraLoaderUI { async refreshSelectedTriggers() { const tasks = [...this.selected.values()].map(async (entry) => { - initEntryTriggers(entry, await fetchLoraTriggers(entry.lora)); + await loadEntryTriggerData(entry); }); await Promise.all(tasks); this.renderSelected(); @@ -1334,22 +1315,13 @@ class GridLoraLoaderUI { for (const value of widgetValues || []) { if (value && typeof value === "object" && value.lora) { const name = value.lora.split("/").pop().replace(/\.[^.]+$/, ""); - const entry = { + const entry = createEmptyTriggerEntry({ on: value.on !== false, lora: value.lora, name, strength: value.strength ?? 1.0, - triggers: [], - triggerOn: {}, - }; - if (Array.isArray(value.triggerStates)) { - entry.triggers = value.triggerStates.map((item) => item.word).filter(Boolean); - entry.triggerOn = Object.fromEntries( - value.triggerStates - .filter((item) => item?.word) - .map((item) => [item.word, item.on !== false]) - ); - } + }); + restoreTriggerEntryFromWidget(entry, value); this.selected.set(value.lora, entry); } } diff --git a/js/list_lora_loader.js b/js/list_lora_loader.js index d1da74e..4abf04b 100644 --- a/js/list_lora_loader.js +++ b/js/list_lora_loader.js @@ -1,6 +1,16 @@ import { app } from "../../scripts/app.js"; -import { bindLoraContextMenu, fetchLoraTriggers } from "./lora_info.js"; +import { bindLoraContextMenu, fetchLoraTriggerVariants } from "./lora_info.js"; import { bindPreviewImage, replaceWithPreviewPlaceholder } from "./lora_preview_cache.js"; +import { + createEmptyTriggerEntry, + entryTriggerStates, + entryVariantStates, + formatEnabledTriggers, + initEntryTriggers, + loadEntryTriggerData, + LoraEntrySettingsDialog, + restoreTriggerEntryFromWidget, +} from "./lora_loader_settings.js"; const NODE_NAME = "ListLoraLoader"; const API_BASE = "/crashutils/loras"; @@ -787,7 +797,8 @@ class ListLoraLoaderUI { if (!item?.path || !tagsEl || (item.triggers && item.triggers.length)) { return; } - const triggers = await fetchLoraTriggers(item.path); + const data = await fetchLoraTriggerVariants(item.path); + const triggers = data.default || []; if (!triggers.length) { return; } @@ -804,21 +815,21 @@ class ListLoraLoaderUI { return; } - this.selected.set(loraPath, { + const entry = createEmptyTriggerEntry({ on: true, lora: loraPath, name: item.name, strength: 1.0, - triggers: item.triggers || [], }); - if (!item.triggers?.length) { - fetchLoraTriggers(loraPath).then((triggers) => { - const entry = this.selected.get(loraPath); - if (!entry || !triggers.length) return; - entry.triggers = triggers; - this.renderSelected(); - }); - } + initEntryTriggers(entry, item.triggers || []); + entry.defaultTriggers = item.triggers || []; + this.selected.set(loraPath, entry); + + loadEntryTriggerData(entry).then(() => { + if (!this.selected.has(loraPath)) return; + this.renderSelected(); + this.syncWidgets(); + }); this.syncWidgets(); this.renderSelected(); @@ -856,7 +867,7 @@ class ListLoraLoaderUI { el.classList.add("disabled"); } - const tags = formatTriggers(entry.triggers); + const tags = formatTriggers(formatEnabledTriggers(entry)); const tagsHtml = tags ? `
${escapeHtml(tags)}
` : `
`; @@ -870,12 +881,12 @@ class ListLoraLoaderUI {
${escapeHtml(entry.name || entry.lora.split("/").pop())}
${tagsHtml} - + `; el.querySelector(".cog-btn").addEventListener("click", (e) => { e.stopPropagation(); - this.promptStrength(entry, e); + this.openSettings(entry); }); el.querySelector(".cog-btn").addEventListener("pointerdown", (e) => e.stopPropagation()); @@ -925,21 +936,14 @@ class ListLoraLoaderUI { return el; } - promptStrength(entry, event) { - app.canvas.prompt( - "LoRA Strength", - entry.strength, - (value) => { - const num = Number(value); - if (!Number.isNaN(num)) { - entry.strength = num; - this.syncWidgets(); - this.renderSelected(); - this.node.setDirtyCanvas(true, true); - } + openSettings(entry) { + new LoraEntrySettingsDialog(entry, { + onApply: () => { + this.syncWidgets(); + this.renderSelected(); + this.node.setDirtyCanvas(true, true); }, - event - ); + }).open(); } syncWidgets() { @@ -958,6 +962,8 @@ class ListLoraLoaderUI { on: entry.on, lora: entry.lora, strength: entry.strength, + triggerStates: entryTriggerStates(entry), + variantStates: entryVariantStates(entry), }) ); } @@ -967,10 +973,11 @@ class ListLoraLoaderUI { async refreshSelectedTriggers() { const tasks = [...this.selected.values()].map(async (entry) => { - entry.triggers = await fetchLoraTriggers(entry.lora); + await loadEntryTriggerData(entry); }); await Promise.all(tasks); this.renderSelected(); + this.syncWidgets(); } loadFromWidgetValues(widgetValues, properties) { @@ -978,13 +985,14 @@ class ListLoraLoaderUI { for (const value of widgetValues || []) { if (value && typeof value === "object" && value.lora) { const name = value.lora.split("/").pop().replace(/\.[^.]+$/, ""); - this.selected.set(value.lora, { + const entry = createEmptyTriggerEntry({ on: value.on !== false, lora: value.lora, name, strength: value.strength ?? 1.0, - triggers: [], }); + restoreTriggerEntryFromWidget(entry, value); + this.selected.set(value.lora, entry); } } this.loadFromProperties(properties ?? this.node.properties); diff --git a/js/lora_info.js b/js/lora_info.js index cdfb4c8..0dd5a64 100644 --- a/js/lora_info.js +++ b/js/lora_info.js @@ -320,6 +320,20 @@ export async function fetchLoraTriggers(loraPath) { } } +export async function fetchLoraTriggerVariants(loraPath) { + if (!loraPath) return { default: [], variants: [] }; + try { + const res = await fetch(`${INFO_API}/trigger-variants?path=${encodeURIComponent(loraPath)}`); + const data = await res.json(); + return { + default: data.default || [], + variants: data.variants || [], + }; + } catch { + return { default: [], variants: [] }; + } +} + export function showLoraInfoDialog(loraPath) { if (!loraPath) return; new LoraInfoDialog(loraPath).open(); diff --git a/js/lora_loader_settings.js b/js/lora_loader_settings.js new file mode 100644 index 0000000..3e482e6 --- /dev/null +++ b/js/lora_loader_settings.js @@ -0,0 +1,373 @@ +import { fetchLoraTriggerVariants } from "./lora_info.js"; + +let stylesInjected = false; + +const SETTINGS_STYLES = ` +.crash-lora-settings-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.55); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} +.crash-lora-settings-dialog { + background: #2a2a2a; + border: 1px solid #666; + border-radius: 8px; + width: min(480px, 96vw); + max-height: 86vh; + display: flex; + flex-direction: column; + color: #ddd; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 12px; + box-shadow: 0 12px 40px rgba(0,0,0,0.45); +} +.crash-lora-settings-dialog * { box-sizing: border-box; } +.crash-lora-settings-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid #555; + background: #333; +} +.crash-lora-settings-header h2 { + margin: 0; + font-size: 14px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.crash-lora-settings-close { + border: none; + background: #444; + color: #ddd; + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; +} +.crash-lora-settings-close:hover { background: #555; color: #fff; } +.crash-lora-settings-body { + overflow: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 14px; +} +.crash-lora-settings-field label { + display: flex; + align-items: center; + gap: 8px; + color: #ccc; +} +.crash-lora-settings-field input[type="number"] { + width: 80px; + padding: 4px 6px; + border: 1px solid #555; + border-radius: 4px; + background: #1a1a1a; + color: #eee; +} +.crash-lora-settings-section h3 { + margin: 0 0 8px; + font-size: 11px; + font-weight: 600; + color: #aaa; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.crash-lora-settings-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.crash-lora-settings-chip { + border: 1px solid #555; + border-radius: 999px; + background: #1f1f1f; + color: #aaa; + font-size: 10px; + line-height: 1.2; + padding: 3px 8px; + cursor: pointer; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: border-color 0.15s, background 0.15s, color 0.15s, opacity 0.15s; +} +.crash-lora-settings-chip.on { + border-color: #6c6; + background: #243024; + color: #cfc; +} +.crash-lora-settings-chip.off { + opacity: 0.45; +} +.crash-lora-settings-chip:hover { + border-color: #8af; +} +.crash-lora-settings-empty { + color: #666; + font-style: italic; + font-size: 11px; +} +.crash-lora-settings-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid #555; + background: #333; +} +.crash-lora-settings-btn { + border: 1px solid #666; + background: #3a3a3a; + color: #eee; + border-radius: 4px; + padding: 5px 12px; + cursor: pointer; + font-size: 11px; +} +.crash-lora-settings-btn.primary { + border-color: #6c6; + background: #243024; +} +.crash-lora-settings-btn:hover { + background: #4a4a4a; + border-color: #8af; +} +`; + +function injectSettingsStyles() { + if (stylesInjected) return; + stylesInjected = true; + const style = document.createElement("style"); + style.textContent = SETTINGS_STYLES; + document.head.appendChild(style); +} + +export function initEntryTriggers(entry, triggers) { + entry.triggers = [...(triggers || [])]; + entry.triggerOn = entry.triggerOn || {}; + for (const word of entry.triggers) { + if (entry.triggerOn[word] === undefined) { + entry.triggerOn[word] = true; + } + } + for (const word of Object.keys(entry.triggerOn)) { + if (!entry.triggers.includes(word)) { + delete entry.triggerOn[word]; + } + } +} + +export function recomputeEntryTriggers(entry) { + const merged = []; + const seen = new Set(); + const addTerms = (list) => { + for (const word of list || []) { + const key = word.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + merged.push(word); + } + }; + + addTerms(entry.defaultTriggers); + for (const variant of entry.variantFiles || []) { + if (entry.variantOn?.[variant.id] === true) { + addTerms(variant.triggers); + } + } + initEntryTriggers(entry, merged); +} + +export function entryTriggerStates(entry) { + return (entry.triggers || []).map((word) => ({ + word, + on: entry.triggerOn?.[word] !== false, + })); +} + +export function entryVariantStates(entry) { + return (entry.variantFiles || []).map((variant) => ({ + id: variant.id, + on: entry.variantOn?.[variant.id] === true, + })); +} + +export function formatEnabledTriggers(entry) { + return (entry.triggers || []).filter((word) => entry.triggerOn?.[word] !== false); +} + +export async function loadEntryTriggerData(entry) { + const data = await fetchLoraTriggerVariants(entry.lora); + entry.defaultTriggers = data.default || []; + entry.variantFiles = data.variants || []; + entry.variantOn = entry.variantOn || {}; + recomputeEntryTriggers(entry); +} + +export function createEmptyTriggerEntry(base = {}) { + return { + defaultTriggers: [], + variantFiles: [], + variantOn: {}, + triggers: [], + triggerOn: {}, + ...base, + }; +} + +export function restoreTriggerEntryFromWidget(entry, value) { + if (Array.isArray(value.variantStates)) { + entry.variantOn = Object.fromEntries( + value.variantStates.filter((item) => item?.id).map((item) => [item.id, item.on === true]) + ); + } + if (Array.isArray(value.triggerStates)) { + entry.triggers = value.triggerStates.map((item) => item.word).filter(Boolean); + entry.triggerOn = Object.fromEntries( + value.triggerStates.filter((item) => item?.word).map((item) => [item.word, item.on !== false]) + ); + } +} + +export class LoraEntrySettingsDialog { + constructor(entry, { onApply } = {}) { + injectSettingsStyles(); + this.entry = entry; + this.onApply = onApply; + this.closed = false; + + this.overlay = document.createElement("div"); + this.overlay.className = "crash-lora-settings-overlay"; + this.overlay.innerHTML = ` + + `; + + this.titleEl = this.overlay.querySelector("h2"); + this.strengthInput = this.overlay.querySelector(".strength-input"); + this.variantSection = this.overlay.querySelector(".variant-section"); + this.variantChipsEl = this.overlay.querySelector(".variant-chips"); + this.tagChipsEl = this.overlay.querySelector(".tag-chips"); + + this.overlay.querySelector(".crash-lora-settings-close").addEventListener("click", () => this.close(false)); + this.overlay.querySelector(".cancel-btn").addEventListener("click", () => this.close(false)); + this.overlay.querySelector(".apply-btn").addEventListener("click", () => this.close(true)); + this.overlay.addEventListener("click", (e) => { + if (e.target === this.overlay) this.close(false); + }); + + this.strengthInput.value = String(this.entry.strength ?? 1.0); + this.titleEl.textContent = this.entry.name || this.entry.lora.split("/").pop() || "LoRA Settings"; + this.renderVariantChips(); + this.renderTagChips(); + } + + renderVariantChips() { + const variants = this.entry.variantFiles || []; + this.variantSection.style.display = variants.length ? "" : "none"; + this.variantChipsEl.innerHTML = ""; + + if (variants.length === 0) { + return; + } + + for (const variant of variants) { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = `crash-lora-settings-chip${this.entry.variantOn?.[variant.id] === true ? " on" : " off"}`; + chip.textContent = variant.label || variant.id; + chip.title = variant.label || variant.id; + chip.addEventListener("click", (e) => { + e.stopPropagation(); + const enabled = this.entry.variantOn?.[variant.id] === true; + this.entry.variantOn[variant.id] = !enabled; + recomputeEntryTriggers(this.entry); + this.renderVariantChips(); + this.renderTagChips(); + }); + this.variantChipsEl.appendChild(chip); + } + } + + renderTagChips() { + this.tagChipsEl.innerHTML = ""; + const tags = this.entry.triggers || []; + + if (tags.length === 0) { + this.tagChipsEl.innerHTML = `
No trigger tags available
`; + return; + } + + for (const word of tags) { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = `crash-lora-settings-chip${this.entry.triggerOn?.[word] !== false ? " on" : " off"}`; + chip.textContent = word; + chip.title = word; + chip.addEventListener("click", (e) => { + e.stopPropagation(); + this.entry.triggerOn[word] = this.entry.triggerOn?.[word] === false; + this.renderTagChips(); + }); + this.tagChipsEl.appendChild(chip); + } + } + + open() { + document.body.appendChild(this.overlay); + this.strengthInput.focus(); + this.strengthInput.select(); + } + + close(apply) { + if (this.closed) return; + this.closed = true; + + if (apply) { + const num = Number(this.strengthInput.value); + if (!Number.isNaN(num)) { + this.entry.strength = num; + } + this.onApply?.(); + } + + this.overlay.remove(); + } +} diff --git a/lora_browser.py b/lora_browser.py index a6eba32..e240ba8 100644 --- a/lora_browser.py +++ b/lora_browser.py @@ -12,6 +12,7 @@ from .nodes.lora_utils import ( _resolve_lora_full_path, find_lora_preview_path, get_lora_triggers, + get_lora_trigger_variants, lora_has_preview, normalize_lora_path, ) @@ -349,6 +350,11 @@ def register_routes(routes): path = normalize_lora_path(request.rel_url.query.get("path", "")) return web.json_response({"triggers": get_lora_triggers(path)}) + @routes.get("/crashutils/loras/trigger-variants") + async def lora_trigger_variants(request): + path = normalize_lora_path(request.rel_url.query.get("path", "")) + return web.json_response(get_lora_trigger_variants(path)) + @routes.get("/crashutils/loras/info") async def lora_info(request): path = normalize_lora_path(request.rel_url.query.get("path", "")) diff --git a/nodes/grid_lora_loader.py b/nodes/grid_lora_loader.py index bc48c07..70b17ea 100644 --- a/nodes/grid_lora_loader.py +++ b/nodes/grid_lora_loader.py @@ -1,7 +1,13 @@ from nodes import LoraLoader from .flexible_input import FlexibleOptionalInputType, any_type -from .lora_utils import get_lora_by_filename, get_lora_triggers, normalize_lora_path +from .lora_utils import ( + get_lora_by_filename, + get_lora_triggers, + get_lora_variant_triggers_from_full_path, + normalize_lora_path, + _resolve_lora_full_path, +) class GridLoraLoader: @@ -85,6 +91,38 @@ class GridLoraLoader: return (model, clip, ", ".join(trigger_terms)) + @staticmethod + def _collect_trigger_terms(value: dict, lora_file: str) -> list[str]: + full_path = _resolve_lora_full_path(lora_file) + terms: list[str] = [] + seen: set[str] = set() + + def add_terms(items: list[str]) -> None: + for term in items: + if not term: + continue + key = str(term).casefold() + if key in seen: + continue + seen.add(key) + terms.append(str(term)) + + add_terms(get_lora_triggers(lora_file, full_path=full_path)) + + variant_states = value.get("variantStates") + if isinstance(variant_states, list) and full_path: + for item in variant_states: + if not isinstance(item, dict): + continue + if not item.get("on"): + continue + variant_id = item.get("id") + if not variant_id: + continue + add_terms(get_lora_variant_triggers_from_full_path(full_path, str(variant_id))) + + return terms + @staticmethod def _enabled_trigger_terms(value: dict, lora_file: str) -> list[str]: trigger_states = value.get("triggerStates") @@ -98,11 +136,11 @@ class GridLoraLoader: word = item.get("word") if word: terms.append(str(word)) - if terms: + if terms or trigger_states: return terms triggers = value.get("triggers") if isinstance(triggers, list) and triggers: return [str(term) for term in triggers if term] - return get_lora_triggers(lora_file) + return GridLoraLoader._collect_trigger_terms(value, lora_file) diff --git a/nodes/list_lora_loader.py b/nodes/list_lora_loader.py index 26bd19f..e695eec 100644 --- a/nodes/list_lora_loader.py +++ b/nodes/list_lora_loader.py @@ -14,7 +14,8 @@ class ListLoraLoader(GridLoraLoader): "Visual LoRA loader with folder browser and vertical list view. " "Each LoRA shows a thumbnail, name, and trigger keywords. " "Select multiple LoRAs, toggle each individually, and set per-LoRA strength. " - "Outputs trigger keywords from companion .txt files (same name as the LoRA)." + "Outputs trigger keywords from the primary .txt file plus optional variant .txt files " + "(e.g. lora1.Character Name.txt) selected in the LoRA settings dialog." ) @classmethod diff --git a/nodes/lora_utils.py b/nodes/lora_utils.py index e9a3b0d..c23c734 100644 --- a/nodes/lora_utils.py +++ b/nodes/lora_utils.py @@ -178,22 +178,95 @@ def _read_rgthree_triggers(full_path: str) -> list[str]: return _dedupe_terms(terms) +def _lora_stem_from_full_path(full_path: str) -> str: + return os.path.splitext(os.path.basename(full_path))[0] + + +def _find_variant_trigger_files(full_path: str) -> list[dict]: + """Find variant trigger files named like lora1.Character Name.txt alongside the LoRA.""" + if not full_path or not os.path.isfile(full_path): + return [] + + directory = os.path.dirname(full_path) + stem = _lora_stem_from_full_path(full_path) + prefix = f"{stem}." + variants: list[dict] = [] + + try: + for entry in os.scandir(directory): + if not entry.is_file(): + continue + if not entry.name.lower().endswith(".txt"): + continue + name_no_ext = os.path.splitext(entry.name)[0] + if not name_no_ext.casefold().startswith(prefix.casefold()): + continue + variant_name = name_no_ext[len(stem) + 1 :] + if not variant_name: + continue + variants.append( + { + "id": variant_name, + "label": variant_name, + "path": entry.path, + } + ) + except OSError: + pass + + variants.sort(key=lambda item: item["label"].casefold()) + return variants + + +def _read_triggers_from_txt_path(txt_path: str) -> list[str]: + if not txt_path or not os.path.isfile(txt_path): + return [] + content = _read_text_file(txt_path) + if content is None: + return [] + return parse_trigger_content(content) + + def get_lora_triggers_from_full_path(full_path: str) -> list[str]: - """Read trigger keywords using a known on-disk LoRA path.""" + """Read default trigger keywords using a known on-disk LoRA path.""" if not full_path or not os.path.isfile(full_path): return [] txt_path = _find_trigger_txt_path(full_path) if txt_path is not None: - content = _read_text_file(txt_path) - if content is not None: - terms = parse_trigger_content(content) - if terms: - return terms + terms = _read_triggers_from_txt_path(txt_path) + if terms: + return terms return _read_rgthree_triggers(full_path) +def get_lora_variant_triggers_from_full_path(full_path: str, variant_id: str) -> list[str]: + if not full_path or not variant_id: + return [] + + for variant in _find_variant_trigger_files(full_path): + if variant["id"].casefold() == str(variant_id).casefold(): + return _read_triggers_from_txt_path(variant["path"]) + return [] + + +def get_lora_trigger_variants_from_full_path(full_path: str) -> dict: + """Return default triggers and optional variant trigger files for a LoRA.""" + default = get_lora_triggers_from_full_path(full_path) if full_path else [] + variants = [] + for variant in _find_variant_trigger_files(full_path): + triggers = _read_triggers_from_txt_path(variant["path"]) + variants.append( + { + "id": variant["id"], + "label": variant["label"], + "triggers": triggers, + } + ) + return {"default": default, "variants": variants} + + def get_lora_by_filename(file_path: str, lora_paths=None) -> str | None: lora_paths = lora_paths if lora_paths is not None else folder_paths.get_filename_list("loras") file_path = normalize_lora_path(file_path) @@ -241,7 +314,7 @@ def lora_has_preview(lora_relative_path: str, *, full_path: str | None = None) - def get_lora_triggers(lora_relative_path: str = "", *, full_path: str | None = None) -> list[str]: - """Read trigger keywords from a companion .txt file alongside the LoRA.""" + """Read default trigger keywords from the primary companion .txt file alongside the LoRA.""" if full_path and os.path.isfile(full_path): return get_lora_triggers_from_full_path(full_path) @@ -253,3 +326,18 @@ def get_lora_triggers(lora_relative_path: str = "", *, full_path: str | None = N return [] return get_lora_triggers_from_full_path(resolved) + + +def get_lora_trigger_variants(lora_relative_path: str = "", *, full_path: str | None = None) -> dict: + """Read default and variant trigger keyword files for a LoRA.""" + if full_path and os.path.isfile(full_path): + return get_lora_trigger_variants_from_full_path(full_path) + + if not lora_relative_path: + return {"default": [], "variants": []} + + resolved = _resolve_lora_full_path(lora_relative_path) + if resolved is None: + return {"default": [], "variants": []} + + return get_lora_trigger_variants_from_full_path(resolved)