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 = `
+
+
+
+
+
+
+
+
Character / Concept Tags
+
+
+
+
+
+
+ `;
+
+ 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)