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)