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 <cursoragent@cursor.com>
This commit is contained in:
Chris
2026-09-07 14:05:04 +01:00
co-authored by Cursor
parent dd0b22fc32
commit 8e6e44976c
8 changed files with 611 additions and 111 deletions
+33 -61
View File
@@ -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);
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 = `
<img alt="" loading="lazy" />
<span class="strength-badge" title="LoRA strength">${formatStrength(entry.strength)}</span>
<button class="cog-btn" title="Set strength">⚙</button>
<div class="item-label" title="${formatTriggers(entry.triggers) || entry.lora}">${entry.name || entry.lora.split("/").pop()}</div>
<button class="cog-btn" title="LoRA settings">⚙</button>
<div class="item-label" title="${formatTriggers(formatEnabledTriggers(entry)) || entry.lora}">${entry.name || entry.lora.split("/").pop()}</div>
`;
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;
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);
}
}
+35 -27
View File
@@ -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;
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
? `<div class="row-tags" title="${escapeHtml(tags)}">${escapeHtml(tags)}</div>`
: `<div class="row-tags empty"></div>`;
@@ -870,12 +881,12 @@ class ListLoraLoaderUI {
<div class="row-name" title="${escapeHtml(entry.lora)}">${escapeHtml(entry.name || entry.lora.split("/").pop())}</div>
${tagsHtml}
</div>
<button class="cog-btn" title="Set strength">⚙</button>
<button class="cog-btn" title="LoRA settings">⚙</button>
`;
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;
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);
+14
View File
@@ -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();
+373
View File
@@ -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 = `
<div class="crash-lora-settings-dialog" role="dialog" aria-modal="true">
<div class="crash-lora-settings-header">
<h2>LoRA Settings</h2>
<button type="button" class="crash-lora-settings-close" title="Close">✕</button>
</div>
<div class="crash-lora-settings-body">
<div class="crash-lora-settings-field">
<label>
Strength
<input type="number" class="strength-input" step="0.05" />
</label>
</div>
<div class="crash-lora-settings-section variant-section">
<h3>Character / Concept Tags</h3>
<div class="crash-lora-settings-chips variant-chips"></div>
</div>
<div class="crash-lora-settings-section tag-section">
<h3>Trigger Tags</h3>
<div class="crash-lora-settings-chips tag-chips"></div>
</div>
</div>
<div class="crash-lora-settings-footer">
<button type="button" class="crash-lora-settings-btn cancel-btn">Cancel</button>
<button type="button" class="crash-lora-settings-btn primary apply-btn">Apply</button>
</div>
</div>
`;
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 = `<div class="crash-lora-settings-empty">No trigger tags available</div>`;
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();
}
}
+6
View File
@@ -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", ""))
+41 -3
View File
@@ -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)
+2 -1
View File
@@ -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
+93 -5
View File
@@ -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)
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)