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:
+40
-68
@@ -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 = `
|
||||
<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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+40
-32
@@ -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
|
||||
? `<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;
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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", ""))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+95
-7
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user