mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-14 09:43:22 -03:00
feat(workflow): exclude text-capable nodes with connected text from send targets
CLIP Text Encode and friends whose text widget is backed by a connected input cannot have their text changed via the widget (execution reads the linked input), so sending to them was a silent no-op. - Registry: compute text_widget_connected capability from the widget's backing input link state; has_text_widget drops to false when wired; include the flag in the registration fingerprint so link changes re-register the affected nodes - Registry: hook link connect/disconnect (graph events on new litegraph, onAfterChange fallback for classic) on root and subgraphs, plus subgraph-created for future subgraphs - applyWidgetUpdate: skip inject_text when the target widget is connected and self-heal the registry instead of writing a value that is ignored - Web UI: drop text_widget_connected nodes from prompt/embedding send candidates; show a Mark as -> Send Prompt Target hint toast when no candidates remain (new uiHelpers.workflow.noPromptTargets key, synced to all locales; zh-CN/zh-TW translated) - Extract shared resolveTextWidget() used by both the candidate-set logic and the write path so the two cannot drift apart - Tests: workflow registry connection-state registration, subgraph handling, fingerprint re-registration, inject_text write/skip paths, setup link-change hooks; uiHelpers candidate filtering and hint toast
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, chainCallback, getLinkFromGraph } from "./utils.js";
|
||||
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, getChildGraphs, chainCallback, getLinkFromGraph } from "./utils.js";
|
||||
import { ensureLmStyles } from "./lm_styles_loader.js";
|
||||
|
||||
const DEBOUNCE_DELAY = 500;
|
||||
@@ -155,6 +155,54 @@ function tryResolvePrimitiveConnection(node, widgetName) {
|
||||
return isPrimitiveNodeType(originNode) ? originNode : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the widget that "send prompt" targets on `node`: the first
|
||||
* string-typed widget, falling back to the first non-hidden widget.
|
||||
* Shared by `isPromptWidgetConnected` and `applyWidgetUpdate` so the
|
||||
* candidate-set logic and the write path cannot drift apart.
|
||||
*
|
||||
* @param {Object} node - LiteGraph node instance
|
||||
* @returns {Object|null} - the target widget, or null when none is suitable
|
||||
*/
|
||||
function resolveTextWidget(node) {
|
||||
if (!node || !Array.isArray(node.widgets)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const TEXT_TYPES = new Set(["string", "customtext"]);
|
||||
return (
|
||||
node.widgets.find((w) => {
|
||||
const t = typeof w?.type === "string" ? w.type.toLowerCase() : "";
|
||||
return TEXT_TYPES.has(t) || t.includes("string");
|
||||
}) ??
|
||||
node.widgets.find((w) => w?.name && !w.name.startsWith("_")) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the widget that "send prompt" would update on `node` is backed by
|
||||
* a connected input — ComfyUI execution reads the linked input, so updating
|
||||
* the widget would be a silent no-op. Such nodes must not be offered as
|
||||
* prompt/embedding send targets.
|
||||
*
|
||||
* @param {Object} node - LiteGraph node instance
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPromptWidgetConnected(node) {
|
||||
if (!node || !Array.isArray(node.inputs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetWidget = resolveTextWidget(node);
|
||||
if (!targetWidget?.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const slotIndex = findInputSlotForWidget(node, targetWidget.name);
|
||||
return slotIndex >= 0 && node.inputs[slotIndex]?.link != null;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "LoraManager.WorkflowRegistry",
|
||||
|
||||
@@ -209,6 +257,45 @@ app.registerExtension({
|
||||
this._log("failed to chain LiteGraph hooks: %s", e.message);
|
||||
}
|
||||
|
||||
// Link connect/disconnect changes whether a widget is externally driven
|
||||
// (e.g. CLIP Text Encode "text" wired to another node), which affects
|
||||
// the text-send candidate set — re-register on connection changes.
|
||||
const hookLinkChanges = (targetGraph) => {
|
||||
if (!targetGraph) {
|
||||
return false;
|
||||
}
|
||||
if (typeof targetGraph.events?.addEventListener === "function") {
|
||||
targetGraph.events.addEventListener("node:slot-links:changed", () =>
|
||||
scheduleRefresh("link")
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// Classic litegraph: structural edits (incl. link connect/disconnect)
|
||||
// flow through beforeChange/afterChange.
|
||||
chainCallback(targetGraph, "onAfterChange", () => scheduleRefresh("afterChange"));
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
if (hookLinkChanges(graph)) {
|
||||
hooksInstalled += 1;
|
||||
// Links wired inside a subgraph dispatch on that subgraph's own
|
||||
// graph events, not the root's — hook existing and future subgraphs.
|
||||
for (const subgraph of getChildGraphs(graph)) {
|
||||
if (hookLinkChanges(subgraph)) {
|
||||
hooksInstalled += 1;
|
||||
}
|
||||
}
|
||||
graph.events?.addEventListener?.("subgraph-created", (event) => {
|
||||
if (hookLinkChanges(event?.subgraph)) {
|
||||
hooksInstalled += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this._log("failed to hook graph link changes: %s", e.message);
|
||||
}
|
||||
|
||||
if (typeof api.addEventListener === "function") {
|
||||
try {
|
||||
api.addEventListener("graphChanged", () => scheduleRefresh("graphChanged"));
|
||||
@@ -251,6 +338,14 @@ app.registerExtension({
|
||||
const hasTextWidget = TEXT_CAPABLE_CLASSES.has(node.comfyClass);
|
||||
const markerRole = node.properties?.lm_marker_role ?? null;
|
||||
|
||||
// A prompt-capable node whose text widget is wired to another
|
||||
// node cannot have its text changed via the widget — execution
|
||||
// reads the linked input. Drop it from text-send candidates.
|
||||
const textWidgetConnected =
|
||||
hasTextWidget || markerRole === "send_prompt_target"
|
||||
? isPromptWidgetConnected(node)
|
||||
: false;
|
||||
|
||||
if (!supportsLora && !hasTargetWidget && !hasTextWidget && !markerRole) {
|
||||
continue;
|
||||
}
|
||||
@@ -275,7 +370,8 @@ app.registerExtension({
|
||||
marker_role: markerRole,
|
||||
capabilities: {
|
||||
supports_lora: supportsLora,
|
||||
has_text_widget: hasTextWidget,
|
||||
has_text_widget: hasTextWidget && !textWidgetConnected,
|
||||
text_widget_connected: textWidgetConnected,
|
||||
widget_names: widgetNames,
|
||||
},
|
||||
});
|
||||
@@ -286,8 +382,10 @@ app.registerExtension({
|
||||
// Content-based dedup: skip POST if identical to last sent payload,
|
||||
// unless forced (e.g. responding to a lora_registry_refresh WS message
|
||||
// where the backend explicitly requests a re-registration).
|
||||
// text_widget_connected is part of the fingerprint so that link
|
||||
// connect/disconnect changes re-register the affected nodes.
|
||||
const fingerprint = JSON.stringify(
|
||||
workflowNodes.map(n => `${n.graph_id}:${n.node_id}|${n.marker_role ?? ""}|${n.mode ?? 0}`).sort()
|
||||
workflowNodes.map(n => `${n.graph_id}:${n.node_id}|${n.marker_role ?? ""}|${n.mode ?? 0}|${n.capabilities.text_widget_connected}`).sort()
|
||||
);
|
||||
if (!force && fingerprint === this._lastFingerprint) {
|
||||
return;
|
||||
@@ -349,28 +447,30 @@ app.registerExtension({
|
||||
let targetWidget = null;
|
||||
|
||||
if (action === "inject_text") {
|
||||
// Find the first text-capable widget by type.
|
||||
// Normalise to lowercase for case-insensitive matching.
|
||||
const TEXT_TYPES = new Set(["string", "customtext"]);
|
||||
targetWidget = node.widgets.find((w) => {
|
||||
const t = typeof w?.type === "string" ? w.type.toLowerCase() : "";
|
||||
if (TEXT_TYPES.has(t)) return true;
|
||||
// Broad fallback for unknown composite types.
|
||||
if (t.includes("string")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
targetWidget = resolveTextWidget(node);
|
||||
if (!targetWidget) {
|
||||
// Last resort: pick the first widget that is not a hidden/internal type
|
||||
targetWidget = node.widgets.find((w) => w?.name && !w.name.startsWith("_"));
|
||||
if (!targetWidget) {
|
||||
console.warn(
|
||||
"LoRA Manager: no suitable widget for inject_text on node",
|
||||
node.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.warn(
|
||||
"LoRA Manager: no suitable widget for inject_text on node",
|
||||
node.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// The widget is backed by a connected input: ComfyUI execution
|
||||
// reads the linked value, so updating the widget is a no-op.
|
||||
// Guard against stale registry entries (e.g. a link was just
|
||||
// connected before the registry refresh debounce elapsed).
|
||||
const slotIndex = findInputSlotForWidget(node, targetWidget.name);
|
||||
if (slotIndex >= 0 && node.inputs[slotIndex]?.link != null) {
|
||||
console.warn(
|
||||
"LoRA Manager: widget '%s' on node %d is connected to an input; widget value cannot be changed",
|
||||
targetWidget.name,
|
||||
node.id
|
||||
);
|
||||
// Self-heal the registry so the node drops out of the
|
||||
// send-target list instead of being offered again.
|
||||
this.refreshRegistry(true);
|
||||
return;
|
||||
}
|
||||
} else if (widgetName) {
|
||||
// Legacy: find widget by name
|
||||
|
||||
Reference in New Issue
Block a user