fix(trigger-word-toggle): compact group editing for #907

This commit is contained in:
Will Miao
2026-04-21 10:29:49 +08:00
parent ef4923fd94
commit 79dd9a1b29
5 changed files with 1366 additions and 506 deletions

View File

@@ -76,6 +76,9 @@ class TriggerWordToggleLM:
# Filter out empty strings and return as set # Filter out empty strings and return as set
return set(word for word in words if word) return set(word for word in words if word)
def _group_has_child_items(self, item):
return isinstance(item, dict) and isinstance(item.get("items"), list)
def process_trigger_words( def process_trigger_words(
self, self,
id, id,
@@ -112,7 +115,11 @@ class TriggerWordToggleLM:
if isinstance(trigger_data, list): if isinstance(trigger_data, list):
if group_mode: if group_mode:
if allow_strength_adjustment: if any(self._group_has_child_items(item) for item in trigger_data):
filtered_groups = self._process_group_items(
trigger_data, allow_strength_adjustment
)
elif allow_strength_adjustment:
parsed_items = [ parsed_items = [
self._parse_trigger_item( self._parse_trigger_item(
item, allow_strength_adjustment item, allow_strength_adjustment
@@ -174,6 +181,41 @@ class TriggerWordToggleLM:
return (filtered_triggers,) return (filtered_triggers,)
def _process_group_items(self, trigger_data, allow_strength_adjustment):
filtered_groups = []
for item in trigger_data:
group = self._parse_trigger_item(item, allow_strength_adjustment)
if not group["text"] or not group["active"]:
continue
raw_items = item.get("items") if isinstance(item, dict) else None
if isinstance(raw_items, list):
active_items = []
for raw_item in raw_items:
child = self._parse_trigger_item(
raw_item, allow_strength_adjustment=False
)
if child["text"] and child["active"]:
active_items.append(child["text"])
if not active_items:
continue
group_text = ", ".join(active_items)
else:
group_text = group["text"]
filtered_groups.append(
self._format_word_output(
group_text,
group["strength"],
allow_strength_adjustment,
)
)
return filtered_groups
def _parse_trigger_item(self, item, allow_strength_adjustment): def _parse_trigger_item(self, item, allow_strength_adjustment):
text = (item.get("text") or "").strip() text = (item.get("text") or "").strip()
active = bool(item.get("active", False)) active = bool(item.get("active", False))

View File

@@ -99,6 +99,99 @@ def test_duplicate_groups_respect_active_state():
assert filtered == "A, B" assert filtered == "A, B"
def test_group_mode_can_exclude_individual_tags_within_active_group():
node = TriggerWordToggleLM()
trigger_data = [
{
"text": "outfit red, outfit blue, smiling",
"active": True,
"strength": None,
"highlighted": False,
"items": [
{"text": "outfit red", "active": True, "highlighted": False},
{"text": "outfit blue", "active": False, "highlighted": False},
{"text": "smiling", "active": True, "highlighted": False},
],
}
]
(filtered,) = node.process_trigger_words(
id="node",
group_mode=True,
default_active=True,
allow_strength_adjustment=False,
orinalMessage="outfit red, outfit blue, smiling",
toggle_trigger_words=trigger_data,
)
assert filtered == "outfit red, smiling"
def test_group_mode_keeps_group_strength_when_individual_tags_are_excluded():
node = TriggerWordToggleLM()
trigger_data = [
{
"text": "(outfit red, outfit blue, smiling:1.15)",
"active": True,
"strength": 1.15,
"highlighted": False,
"items": [
{"text": "outfit red", "active": True, "highlighted": False},
{"text": "outfit blue", "active": False, "highlighted": False},
{"text": "smiling", "active": True, "highlighted": False},
],
}
]
(filtered,) = node.process_trigger_words(
id="node",
group_mode=True,
default_active=True,
allow_strength_adjustment=True,
orinalMessage="outfit red, outfit blue, smiling",
toggle_trigger_words=trigger_data,
)
assert filtered == "(outfit red, smiling:1.15)"
def test_group_mode_omits_group_when_all_children_are_disabled():
node = TriggerWordToggleLM()
trigger_data = [
{
"text": "A, B",
"active": True,
"strength": None,
"highlighted": False,
"items": [
{"text": "A", "active": False, "highlighted": False},
{"text": "B", "active": False, "highlighted": False},
],
},
{
"text": "C, D",
"active": True,
"strength": None,
"highlighted": False,
"items": [
{"text": "C", "active": True, "highlighted": False},
{"text": "D", "active": True, "highlighted": False},
],
},
]
(filtered,) = node.process_trigger_words(
id="node",
group_mode=True,
default_active=True,
allow_strength_adjustment=False,
orinalMessage="A, B,, C, D",
toggle_trigger_words=trigger_data,
)
assert filtered == "C, D"
def test_trigger_words_override_different_from_original(): def test_trigger_words_override_different_from_original():
node = TriggerWordToggleLM() node = TriggerWordToggleLM()
trigger_data = [ trigger_data = [

View File

@@ -580,6 +580,38 @@ body.lm-lora-reordering * {
margin: 6px 0; margin: 6px 0;
} }
.lm-trigger-count-badge {
font-variant-numeric: tabular-nums;
}
.lm-trigger-count-badge--edited {
color: rgba(226, 232, 240, 0.92);
}
.lm-trigger-group-edit-button {
transition: opacity 0.18s ease, color 0.18s ease;
}
.lm-trigger-group-edit-button:hover {
color: rgba(255, 255, 255, 0.98);
}
.lm-trigger-group-editor {
background: rgba(26, 32, 44, 0.95);
border: 1px solid rgba(66, 153, 225, 0.24);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(6px);
}
.lm-trigger-group-editor__title {
color: rgba(226, 232, 240, 0.96);
}
.lm-trigger-group-editor__subtitle,
.lm-trigger-group-editor__empty {
color: rgba(226, 232, 240, 0.72);
}
/* Autocomplete styling */ /* Autocomplete styling */
.lm-autocomplete-name { .lm-autocomplete-name {
flex: 1; flex: 1;

File diff suppressed because it is too large Load Diff

View File

@@ -4,50 +4,81 @@ import { CONVERTED_TYPE, getNodeFromGraph } from "./utils.js";
import { addTagsWidget } from "./tags_widget.js"; import { addTagsWidget } from "./tags_widget.js";
import { getWheelSensitivity } from "./settings.js"; import { getWheelSensitivity } from "./settings.js";
// TriggerWordToggle extension for ComfyUI function normalizeTagText(text) {
app.registerExtension({ return typeof text === "string" ? text.trim().toLowerCase() : "";
name: "LoraManager.TriggerWordToggle", }
setup() { function splitTopLevelCommas(text) {
// Add message handler to listen for messages from Python if (typeof text !== "string" || !text.trim()) {
api.addEventListener("trigger_word_update", (event) => { return [];
const { id, graph_id: graphId, message } = event.detail; }
this.handleTriggerWordUpdate(id, graphId, message);
});
},
async nodeCreated(node) { const parts = [];
if (node.comfyClass === "TriggerWord Toggle (LoraManager)") { let current = "";
// Enable widget serialization let depth = 0;
node.serialize_widgets = true;
node.addInput("trigger_words", 'string', { for (const char of text) {
"shape": 7 // 7 is the shape of the optional input if (char === "(") {
}); depth += 1;
current += char;
continue;
}
if (char === ")") {
depth = Math.max(0, depth - 1);
current += char;
continue;
}
if (char === "," && depth === 0) {
const trimmed = current.trim();
if (trimmed) {
parts.push(trimmed);
}
current = "";
continue;
}
current += char;
}
// Wait for node to be properly initialized const trimmed = current.trim();
requestAnimationFrame(async () => { if (trimmed) {
// Get the wheel sensitivity setting parts.push(trimmed);
const wheelSensitivity = getWheelSensitivity(); }
const groupModeWidget = node.widgets[0];
const defaultActiveWidget = node.widgets[1];
const strengthAdjustmentWidget = node.widgets[2];
const initialStrengthAdjustment = Boolean(strengthAdjustmentWidget?.value);
// Get the widget object directly from the returned object return parts;
const result = addTagsWidget(node, "toggle_trigger_words", { }
defaultVal: []
}, null, wheelSensitivity, {
allowStrengthAdjustment: initialStrengthAdjustment
});
node.tagWidget = result.widget; function isGroupTag(tag) {
node.tagWidget.allowStrengthAdjustment = initialStrengthAdjustment; return Array.isArray(tag?.items);
}
const normalizeTagText = (text) => function parseSerializedText(text) {
(typeof text === 'string' ? text.trim().toLowerCase() : ''); const normalizedText = typeof text === "string" ? text.trim() : "";
const strengthMatch = normalizedText.match(/^\((.+):([\d.]+)\)$/);
if (!strengthMatch) {
return {
text: normalizedText,
strength: null,
};
}
const collectHighlightTokens = (wordsArray) => { const parsedStrength = Number(strengthMatch[2]);
return {
text: strengthMatch[1].trim(),
strength: Number.isFinite(parsedStrength) ? parsedStrength : null,
};
}
function cloneTag(tag) {
if (!isGroupTag(tag)) {
return { ...tag };
}
return {
...tag,
items: tag.items.map((item) => ({ ...item })),
};
}
function collectHighlightTokens(wordsArray) {
const tokens = new Set(); const tokens = new Set();
const addToken = (text) => { const addToken = (text) => {
@@ -58,7 +89,7 @@ app.registerExtension({
}; };
wordsArray.forEach((rawWord) => { wordsArray.forEach((rawWord) => {
if (typeof rawWord !== 'string') { if (typeof rawWord !== "string") {
return; return;
} }
@@ -67,22 +98,148 @@ app.registerExtension({
const groupParts = rawWord.split(/,{2,}/); const groupParts = rawWord.split(/,{2,}/);
groupParts.forEach((groupPart) => { groupParts.forEach((groupPart) => {
addToken(groupPart); addToken(groupPart);
groupPart.split(',').forEach(addToken); splitTopLevelCommas(groupPart).forEach(addToken);
}); });
rawWord.split(',').forEach(addToken); splitTopLevelCommas(rawWord).forEach(addToken);
}); });
return tokens; return tokens;
}; }
function buildLegacyTagState(existingTags, allowStrengthAdjustment) {
return existingTags.reduce((acc, tag) => {
const parsed = parseSerializedText(tag.text);
const key = parsed.text;
if (!acc[key]) {
acc[key] = [];
}
acc[key].push({
active: tag.active,
strength:
allowStrengthAdjustment
? (tag.strength !== undefined && tag.strength !== null ? tag.strength : parsed.strength)
: null,
});
return acc;
}, {});
}
function buildGroupState(existingTags, allowStrengthAdjustment) {
return existingTags.reduce((acc, tag) => {
const parsed = parseSerializedText(tag.text);
const key = parsed.text;
if (!acc[key]) {
acc[key] = [];
}
const itemState = {};
if (Array.isArray(tag.items)) {
tag.items.forEach((item) => {
const itemKey = item.text;
if (!itemState[itemKey]) {
itemState[itemKey] = [];
}
itemState[itemKey].push({
active: item.active,
});
});
} else {
splitTopLevelCommas(tag.text).forEach((itemText) => {
if (!itemState[itemText]) {
itemState[itemText] = [];
}
itemState[itemText].push({
active: tag.active,
});
});
}
acc[key].push({
active: tag.active,
strength:
allowStrengthAdjustment
? (tag.strength !== undefined && tag.strength !== null ? tag.strength : parsed.strength)
: null,
itemState,
});
return acc;
}, {});
}
function consumeQueuedState(stateMap, key) {
const queue = stateMap[key];
if (queue && queue.length > 0) {
return queue.shift();
}
return null;
}
app.registerExtension({
name: "LoraManager.TriggerWordToggle",
setup() {
api.addEventListener("trigger_word_update", (event) => {
const { id, graph_id: graphId, message } = event.detail;
this.handleTriggerWordUpdate(id, graphId, message);
});
},
async nodeCreated(node) {
if (node.comfyClass !== "TriggerWord Toggle (LoraManager)") {
return;
}
node.serialize_widgets = true;
node.addInput("trigger_words", "string", {
shape: 7,
});
requestAnimationFrame(async () => {
const wheelSensitivity = getWheelSensitivity();
const groupModeWidget = node.widgets[0];
const defaultActiveWidget = node.widgets[1];
const strengthAdjustmentWidget = node.widgets[2];
const initialStrengthAdjustment = Boolean(strengthAdjustmentWidget?.value);
const result = addTagsWidget(node, "toggle_trigger_words", {
defaultVal: [],
}, null, wheelSensitivity, {
allowStrengthAdjustment: initialStrengthAdjustment,
});
node.tagWidget = result.widget;
node.tagWidget.allowStrengthAdjustment = initialStrengthAdjustment;
const applyHighlightState = () => { const applyHighlightState = () => {
if (!node.tagWidget) return; if (!node.tagWidget) {
return;
}
const highlightSet = node._highlightedTriggerWords || new Set(); const highlightSet = node._highlightedTriggerWords || new Set();
const updatedTags = (node.tagWidget.value || []).map(tag => ({ const updatedTags = (node.tagWidget.value || []).map((tag) => {
...tag, if (Array.isArray(tag.items)) {
highlighted: highlightSet.size > 0 && highlightSet.has(normalizeTagText(tag.text)) const items = tag.items.map((item) => ({
...item,
highlighted: highlightSet.size > 0 && highlightSet.has(normalizeTagText(item.text)),
})); }));
return {
...tag,
items,
highlighted:
highlightSet.size > 0 &&
(highlightSet.has(normalizeTagText(tag.text)) ||
items.some((item) => item.highlighted)),
};
}
return {
...tag,
highlighted: highlightSet.size > 0 && highlightSet.has(normalizeTagText(tag.text)),
};
});
node.tagWidget.value = updatedTags; node.tagWidget.value = updatedTags;
}; };
@@ -104,14 +261,12 @@ app.registerExtension({
node.applyTriggerHighlightState = applyHighlightState; node.applyTriggerHighlightState = applyHighlightState;
// Add hidden widget to store original message const hiddenWidget = node.addWidget("text", "orinalMessage", "");
const hiddenWidget = node.addWidget('text', 'orinalMessage', '');
hiddenWidget.type = CONVERTED_TYPE; hiddenWidget.type = CONVERTED_TYPE;
hiddenWidget.hidden = true; hiddenWidget.hidden = true;
hiddenWidget.computeSize = () => [0, -4]; hiddenWidget.computeSize = () => [0, -4];
node.originalMessageWidget = hiddenWidget; node.originalMessageWidget = hiddenWidget;
// Restore saved value if exists
const tagWidgetIndex = node.widgets.indexOf(result.widget); const tagWidgetIndex = node.widgets.indexOf(result.widget);
const originalMessageWidgetIndex = node.widgets.indexOf(hiddenWidget); const originalMessageWidgetIndex = node.widgets.indexOf(hiddenWidget);
if (node.widgets_values && node.widgets_values.length > 0) { if (node.widgets_values && node.widgets_values.length > 0) {
@@ -132,6 +287,7 @@ app.registerExtension({
requestAnimationFrame(() => node.applyTriggerHighlightState?.()); requestAnimationFrame(() => node.applyTriggerHighlightState?.());
groupModeWidget.callback = (value) => { groupModeWidget.callback = (value) => {
node.tagWidget?.closeGroupEditor?.();
if (node.originalMessageWidget?.value) { if (node.originalMessageWidget?.value) {
this.updateTagsBasedOnMode( this.updateTagsBasedOnMode(
node, node,
@@ -140,26 +296,41 @@ app.registerExtension({
Boolean(strengthAdjustmentWidget?.value) Boolean(strengthAdjustmentWidget?.value)
); );
} }
};
defaultActiveWidget.callback = (value) => {
if (!node.tagWidget || !node.tagWidget.value) {
return;
} }
// Add callback for default_active widget const updatedTags = node.tagWidget.value.map((tag) => {
defaultActiveWidget.callback = (value) => { if (!Array.isArray(tag.items)) {
// Set all existing tags' active state to the new value return {
if (node.tagWidget && node.tagWidget.value) {
const updatedTags = node.tagWidget.value.map(tag => ({
...tag, ...tag,
active: value active: value,
})); };
}
return {
...tag,
active: value,
items: tag.items.map((item) => ({
...item,
active: value,
})),
};
});
node.tagWidget.value = updatedTags; node.tagWidget.value = updatedTags;
node.applyTriggerHighlightState?.(); node.applyTriggerHighlightState?.();
} };
}
if (strengthAdjustmentWidget) { if (strengthAdjustmentWidget) {
strengthAdjustmentWidget.callback = (value) => { strengthAdjustmentWidget.callback = (value) => {
const allowStrengthAdjustment = Boolean(value); const allowStrengthAdjustment = Boolean(value);
if (node.tagWidget) { if (node.tagWidget) {
node.tagWidget.allowStrengthAdjustment = allowStrengthAdjustment; node.tagWidget.allowStrengthAdjustment = allowStrengthAdjustment;
node.tagWidget.closeGroupEditor?.();
} }
this.updateTagsBasedOnMode( this.updateTagsBasedOnMode(
node, node,
@@ -170,28 +341,33 @@ app.registerExtension({
}; };
} }
// Override the serializeValue method to properly format trigger words with strength
const originalSerializeValue = result.widget.serializeValue;
result.widget.serializeValue = function() { result.widget.serializeValue = function() {
const value = this.value || []; const value = this.value || [];
// Transform the values to include strength in the proper format return value.map((tag) => {
const transformedValue = value.map(tag => { if (Array.isArray(tag.items)) {
// If strength is defined (even if it's 1.0), format as {text: "(original_text:strength)", ...} return {
...tag,
text:
tag.strength !== undefined && tag.strength !== null
? `(${tag.text}:${tag.strength.toFixed(2)})`
: tag.text,
items: tag.items.map((item) => ({ ...item })),
};
}
if (tag.strength !== undefined && tag.strength !== null) { if (tag.strength !== undefined && tag.strength !== null) {
return { return {
...tag, ...tag,
text: `(${tag.text}:${tag.strength.toFixed(2)})` text: `(${tag.text}:${tag.strength.toFixed(2)})`,
}; };
} }
return tag; return tag;
}); });
return transformedValue;
}; };
}); });
}
}, },
// Handle trigger word updates from Python
handleTriggerWordUpdate(id, graphId, message) { handleTriggerWordUpdate(id, graphId, message) {
const node = getNodeFromGraph(graphId, id); const node = getNodeFromGraph(graphId, id);
if (!node || node.comfyClass !== "TriggerWord Toggle (LoraManager)") { if (!node || node.comfyClass !== "TriggerWord Toggle (LoraManager)") {
@@ -199,13 +375,11 @@ app.registerExtension({
return; return;
} }
// Store the original message for mode switching
if (node.originalMessageWidget) { if (node.originalMessageWidget) {
node.originalMessageWidget.value = message; node.originalMessageWidget.value = message;
} }
if (node.tagWidget) { if (node.tagWidget) {
// Parse tags based on current group mode
const groupMode = node.widgets[0] ? node.widgets[0].value : false; const groupMode = node.widgets[0] ? node.widgets[0].value : false;
const allowStrengthAdjustment = Boolean(node.widgets[2]?.value); const allowStrengthAdjustment = Boolean(node.widgets[2]?.value);
node.tagWidget.allowStrengthAdjustment = allowStrengthAdjustment; node.tagWidget.allowStrengthAdjustment = allowStrengthAdjustment;
@@ -213,85 +387,60 @@ app.registerExtension({
} }
}, },
// Update tags display based on group mode
updateTagsBasedOnMode(node, message, groupMode, allowStrengthAdjustment = false) { updateTagsBasedOnMode(node, message, groupMode, allowStrengthAdjustment = false) {
if (!node.tagWidget) return; if (!node.tagWidget) {
return;
}
node.tagWidget.closeGroupEditor?.();
node.tagWidget.allowStrengthAdjustment = allowStrengthAdjustment; node.tagWidget.allowStrengthAdjustment = allowStrengthAdjustment;
const existingTags = node.tagWidget.value || []; const existingTags = (node.tagWidget.value || []).map(cloneTag);
const existingTagState = existingTags.reduce((acc, tag) => {
const key = tag.text;
if (!acc[key]) {
acc[key] = [];
}
acc[key].push({
active: tag.active,
strength: allowStrengthAdjustment ? tag.strength : null,
});
return acc;
}, {});
const consumeExistingState = (text) => {
const states = existingTagState[text];
if (states && states.length > 0) {
return states.shift();
}
return null;
};
// Get default active state from the widget
const defaultActive = node.widgets[1] ? node.widgets[1].value : true; const defaultActive = node.widgets[1] ? node.widgets[1].value : true;
let tagArray = []; let tagArray = [];
if (groupMode) { if (groupMode) {
if (message.trim() === '') { const existingGroupState = buildGroupState(existingTags, allowStrengthAdjustment);
tagArray = []; const groups = message.trim()
} ? (message.includes(",,") ? message.split(/,{2,}/) : [message])
// Group mode: split by ',,' and treat each group as a single tag .map((group) => group.trim())
else if (message.includes(',,')) { .filter(Boolean)
const groups = message.split(/,{2,}/); // Match 2 or more consecutive commas : [];
tagArray = groups
.map(group => group.trim()) tagArray = groups.map((group) => {
.filter(group => group) const existing = consumeQueuedState(existingGroupState, group);
.map(group => { const itemState = existing?.itemState || {};
// Check if this group already exists with strength info const items = splitTopLevelCommas(group).map((itemText) => {
const existing = consumeExistingState(group); const savedItem = consumeQueuedState(itemState, itemText);
return {
text: itemText,
active: savedItem ? savedItem.active : defaultActive,
highlighted: false,
strength: null,
};
});
return { return {
text: group, text: group,
// Use existing values if available, otherwise use defaults
active: existing ? existing.active : defaultActive, active: existing ? existing.active : defaultActive,
strength: existing ? existing.strength : null highlighted: false,
strength: existing ? existing.strength : null,
items,
}; };
}); });
} else { } else {
// If no ',,' delimiter, treat the entire message as one group const existingTagState = buildLegacyTagState(existingTags, allowStrengthAdjustment);
const existing = consumeExistingState(message.trim()); tagArray = splitTopLevelCommas(message).map((word) => {
tagArray = [{ const existing = consumeQueuedState(existingTagState, word);
text: message.trim(),
// Use existing values if available, otherwise use defaults
active: existing ? existing.active : defaultActive,
strength: existing ? existing.strength : null
}];
}
} else {
// Normal mode: split by commas and treat each word as a separate tag
tagArray = message
.split(',')
.map(word => word.trim())
.filter(word => word)
.map(word => {
// Check if this word already exists with strength info
const existing = consumeExistingState(word);
return { return {
text: word, text: word,
// Use existing values if available, otherwise use defaults
active: existing ? existing.active : defaultActive, active: existing ? existing.active : defaultActive,
strength: existing ? existing.strength : null highlighted: false,
strength: existing ? existing.strength : null,
}; };
}); });
} }
node.tagWidget.value = tagArray; node.tagWidget.value = tagArray;
node.applyTriggerHighlightState?.(); node.applyTriggerHighlightState?.();
} },
}); });