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:
Will Miao
2026-08-12 16:50:07 +08:00
parent 5c2b2aedcc
commit 94e3f54571
15 changed files with 640 additions and 27 deletions
+1
View File
@@ -1726,6 +1726,7 @@
"recipeReplaced": "Rezept im Workflow ersetzt",
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "Kein Zielknoten ausgewählt",
"modelUpdated": "Modell im Workflow aktualisiert",
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
+1
View File
@@ -1726,6 +1726,7 @@
"recipeReplaced": "Recipe replaced in workflow",
"recipeFailedToSend": "Failed to send recipe to workflow",
"noMatchingNodes": "No compatible nodes available in the current workflow",
"noPromptTargets": "No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "No target node selected",
"modelUpdated": "Model updated in workflow",
"modelFailed": "Failed to update model node",
+1
View File
@@ -1726,6 +1726,7 @@
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
"modelFailed": "Error al actualizar nodo de modelo",
+1
View File
@@ -1726,6 +1726,7 @@
"recipeReplaced": "Recipe remplacée dans le workflow",
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "Aucun nœud cible sélectionné",
"modelUpdated": "Modèle mis à jour dans le workflow",
"modelFailed": "Échec de la mise à jour du nœud modèle",
+1
View File
@@ -1726,6 +1726,7 @@
"recipeReplaced": "מתכון הוחלף ב-workflow",
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "לא נבחר צומת יעד",
"modelUpdated": "מודל עודכן ב-workflow",
"modelFailed": "עדכון צומת המודל נכשל",
+1
View File
@@ -1726,6 +1726,7 @@
"recipeReplaced": "レシピがワークフローで置換されました",
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "ターゲットノードが選択されていません",
"modelUpdated": "モデルがワークフローで更新されました",
"modelFailed": "モデルノードの更新に失敗しました",
+1
View File
@@ -1726,6 +1726,7 @@
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
"modelFailed": "모델 노드 업데이트 실패",
+1
View File
@@ -1726,6 +1726,7 @@
"recipeReplaced": "Рецепт заменён в workflow",
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "Целевой узел не выбран",
"modelUpdated": "Модель обновлена в workflow",
"modelFailed": "Не удалось обновить узел модели",
+1
View File
@@ -1726,6 +1726,7 @@
"recipeReplaced": "配方已替换到工作流",
"recipeFailedToSend": "发送配方到工作流失败",
"noMatchingNodes": "当前工作流中没有兼容的节点",
"noPromptTargets": "工作流中没有兼容的 prompt 目标节点。\n在 ComfyUI 中右键节点 → Mark as → Send Prompt Target",
"noTargetNodeSelected": "未选择目标节点",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型节点失败",
+1
View File
@@ -1726,6 +1726,7 @@
"recipeReplaced": "配方已取代於工作流",
"recipeFailedToSend": "傳送配方到工作流失敗",
"noMatchingNodes": "目前工作流程中沒有相容的節點",
"noPromptTargets": "工作流中沒有相容的 prompt 目標節點。\n在 ComfyUI 中右鍵節點 → Mark as → Send Prompt Target",
"noTargetNodeSelected": "未選擇目標節點",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型節點失敗",
+23 -2
View File
@@ -1092,6 +1092,9 @@ export async function sendEmbeddingToWorkflow(embeddingCode, onComplete = null)
if (!isNodeEnabled(node)) {
return false;
}
if (node.capabilities?.text_widget_connected === true) {
return false;
}
return (
node.capabilities?.has_text_widget === true ||
node.marker_role === "send_prompt_target"
@@ -1100,7 +1103,15 @@ export async function sendEmbeddingToWorkflow(embeddingCode, onComplete = null)
const nodeKeys = Object.keys(textNodes);
if (nodeKeys.length === 0) {
showToast('uiHelpers.workflow.noMatchingNodes', {}, 'warning');
showToast(
translate(
'uiHelpers.workflow.noPromptTargets',
{},
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
),
{},
'warning'
);
return false;
}
@@ -1152,6 +1163,11 @@ export async function sendPromptToWorkflow(promptText, options = {}) {
if (!isNodeEnabled(node)) {
return false;
}
// A node whose text widget is backed by a connected input cannot have its
// text changed via the widget — execution reads the linked input.
if (node.capabilities?.text_widget_connected === true) {
return false;
}
return (
node.capabilities?.has_text_widget === true ||
node.marker_role === "send_prompt_target"
@@ -1160,7 +1176,12 @@ export async function sendPromptToWorkflow(promptText, options = {}) {
const nodeKeys = Object.keys(textNodes);
if (nodeKeys.length === 0) {
showToast(options.missingNodesMessage || 'uiHelpers.workflow.noMatchingNodes', {}, 'warning');
const defaultHint = translate(
'uiHelpers.workflow.noPromptTargets',
{},
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
);
showToast(options.missingNodesMessage || defaultHint, {}, 'warning');
return false;
}
@@ -0,0 +1,246 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
const { APP_MODULE, API_MODULE, STYLES_MODULE, REGISTRY_MODULE, appMock, apiMock, registeredExtensions } =
vi.hoisted(() => {
const registeredExtensions = [];
const appMock = {
graph: null,
registerExtension: (ext) => registeredExtensions.push(ext),
};
const apiMock = {
clientId: "client-1",
initialClientId: null,
addEventListener: vi.fn(),
};
return {
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
API_MODULE: new URL("../../../scripts/api.js", import.meta.url).pathname,
STYLES_MODULE: new URL("../../../web/comfyui/lm_styles_loader.js", import.meta.url).pathname,
REGISTRY_MODULE: new URL("../../../web/comfyui/workflow_registry.js", import.meta.url).pathname,
appMock,
apiMock,
registeredExtensions,
};
});
vi.mock(APP_MODULE, () => ({ app: appMock }));
vi.mock(API_MODULE, () => ({ api: apiMock }));
vi.mock(STYLES_MODULE, () => ({ ensureLmStyles: vi.fn() }));
function createTextEncodeNode({ linked = false, id = 1 } = {}) {
const textWidget = { name: "text", type: "customtext", value: "old prompt", callback: null };
return {
id,
comfyClass: "CLIPTextEncode",
title: "CLIP Text Encode",
mode: 0,
properties: {},
widgets: [textWidget, { name: "clip", type: "combo" }],
widgets_values: ["old prompt", "clip-1"],
inputs: [
{ name: "text", type: "STRING", widget: textWidget, link: linked ? 101 : null },
{ name: "clip", type: "CLIP", link: null },
],
setDirtyCanvas: vi.fn(),
graph: null,
};
}
function createSubgraph({ id = "sub-1", nodes = [] } = {}) {
const graph = {
id,
_nodes: nodes,
_subgraphs: new Map(),
getNodeById: vi.fn((nodeId) => nodes.find((n) => n.id === nodeId) ?? null),
events: { addEventListener: vi.fn() },
};
for (const node of nodes) {
node.graph = graph;
}
return graph;
}
function createGraph({ nodes = [], subgraphs = [] } = {}) {
const graph = {
id: "root",
_nodes: nodes,
_subgraphs: new Map(),
getNodeById: vi.fn((nodeId) => nodes.find((n) => n.id === nodeId) ?? null),
events: { addEventListener: vi.fn() },
};
for (const subgraph of subgraphs) {
graph._subgraphs.set(subgraph.id, subgraph);
}
for (const node of nodes) {
node.graph = graph;
}
return graph;
}
function lastRegisterPayload(fetchMock) {
const calls = fetchMock.mock.calls.filter(
([url]) => url === "/api/lm/register-nodes"
);
expect(calls.length).toBeGreaterThan(0);
return JSON.parse(calls[calls.length - 1][1].body);
}
describe("LoraManager.WorkflowRegistry", () => {
let extension;
let fetchMock;
beforeEach(async () => {
vi.resetModules();
registeredExtensions.length = 0;
appMock.graph = null;
apiMock.addEventListener.mockClear();
fetchMock = vi.fn().mockResolvedValue({ ok: true });
global.fetch = fetchMock;
await import(REGISTRY_MODULE);
extension = registeredExtensions.find(
(ext) => ext.name === "LoraManager.WorkflowRegistry"
);
expect(extension).toBeDefined();
});
afterEach(() => {
delete global.fetch;
});
describe("refreshRegistry", () => {
it("registers an unconnected CLIPTextEncode as a text target", async () => {
appMock.graph = createGraph({ nodes: [createTextEncodeNode()] });
await extension.refreshRegistry(true);
const body = lastRegisterPayload(fetchMock);
expect(body.nodes).toHaveLength(1);
expect(body.nodes[0].capabilities.has_text_widget).toBe(true);
expect(body.nodes[0].capabilities.text_widget_connected).toBe(false);
});
it("excludes a CLIPTextEncode whose text input is connected", async () => {
appMock.graph = createGraph({ nodes: [createTextEncodeNode({ linked: true })] });
await extension.refreshRegistry(true);
const body = lastRegisterPayload(fetchMock);
expect(body.nodes).toHaveLength(1);
expect(body.nodes[0].capabilities.has_text_widget).toBe(false);
expect(body.nodes[0].capabilities.text_widget_connected).toBe(true);
});
it("registers connection state for nodes inside subgraphs", async () => {
const inner = createTextEncodeNode({ linked: true, id: 7 });
const subgraph = createSubgraph({ id: "sub-1", nodes: [inner] });
appMock.graph = createGraph({ subgraphs: [subgraph] });
await extension.refreshRegistry(true);
const body = lastRegisterPayload(fetchMock);
expect(body.nodes).toHaveLength(1);
expect(body.nodes[0].graph_id).toBe("sub-1");
expect(body.nodes[0].node_id).toBe(7);
expect(body.nodes[0].capabilities.text_widget_connected).toBe(true);
});
it("re-registers when text_widget_connected changes (fingerprint)", async () => {
const node = createTextEncodeNode();
appMock.graph = createGraph({ nodes: [node] });
await extension.refreshRegistry(true);
await extension.refreshRegistry();
expect(
fetchMock.mock.calls.filter(([url]) => url === "/api/lm/register-nodes")
).toHaveLength(1);
node.inputs[0].link = 101;
await extension.refreshRegistry();
const body = lastRegisterPayload(fetchMock);
expect(body.nodes[0].capabilities.text_widget_connected).toBe(true);
});
});
describe("applyWidgetUpdate (inject_text)", () => {
it("updates the widget value when the text input is not connected", async () => {
const node = createTextEncodeNode();
const callback = vi.fn();
node.widgets[0].callback = callback;
appMock.graph = createGraph({ nodes: [node] });
extension.flashWidget = vi.fn();
await extension.applyWidgetUpdate({
node_id: 1,
action: "inject_text",
value: "hello",
mode: "replace",
});
expect(node.widgets[0].value).toBe("hello");
expect(node.widgets_values[0]).toBe("hello");
expect(callback).toHaveBeenCalledWith("hello");
});
it("skips inject_text when the target widget is connected and self-heals the registry", async () => {
const node = createTextEncodeNode({ linked: true });
appMock.graph = createGraph({ nodes: [node] });
extension.flashWidget = vi.fn();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await extension.applyWidgetUpdate({
node_id: 1,
graph_id: "root",
action: "inject_text",
value: "new prompt",
mode: "replace",
});
expect(node.widgets[0].value).toBe("old prompt");
expect(node.widgets_values[0]).toBe("old prompt");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("connected to an input"),
expect.anything(),
expect.anything()
);
await vi.waitFor(() => {
expect(
fetchMock.mock.calls.some(([url]) => url === "/api/lm/register-nodes")
).toBe(true);
});
warnSpy.mockRestore();
});
});
describe("setup link-change hooks", () => {
it("hooks root events, existing subgraphs, and future subgraphs", () => {
const subgraph = createSubgraph({ id: "sub-1", nodes: [] });
const graph = createGraph({ subgraphs: [subgraph] });
appMock.graph = graph;
extension.setup();
expect(graph.events.addEventListener).toHaveBeenCalledWith(
"node:slot-links:changed",
expect.any(Function)
);
expect(graph.events.addEventListener).toHaveBeenCalledWith(
"subgraph-created",
expect.any(Function)
);
expect(subgraph.events.addEventListener).toHaveBeenCalledWith(
"node:slot-links:changed",
expect.any(Function)
);
const createdHandler = graph.events.addEventListener.mock.calls.find(
([name]) => name === "subgraph-created"
)[1];
const laterSubgraph = createSubgraph({ id: "sub-2", nodes: [] });
createdHandler({ subgraph: laterSubgraph });
expect(laterSubgraph.events.addEventListener).toHaveBeenCalledWith(
"node:slot-links:changed",
expect.any(Function)
);
});
});
});
+232
View File
@@ -315,6 +315,238 @@ describe('UI helper DOM utilities', () => {
]);
});
it('excludes prompt targets whose text widget is connected to an input', async () => {
const registryResponse = {
success: true,
data: {
node_count: 4,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Free Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: false,
widget_names: ['text', 'clip'],
},
},
'root:2': {
id: 2,
graph_id: 'root',
graph_name: null,
title: 'Wired Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: true,
widget_names: ['text', 'clip'],
},
},
'root:3': {
id: 3,
graph_id: 'root',
graph_name: null,
title: 'Marked But Wired',
type: 'KSampler',
mode: 0,
marker_role: 'send_prompt_target',
capabilities: {
has_text_widget: false,
text_widget_connected: true,
widget_names: ['seed'],
},
},
'root:4': {
id: 4,
graph_id: 'root',
graph_name: null,
title: 'Free Text 2',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: false,
widget_names: ['text', 'clip'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
const { sendPromptToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendPromptToWorkflow('a cat');
expect(result).toBe(true);
const nodeLabels = Array.from(
document.querySelectorAll('#nodeSelector .node-item[data-node-id] span')
).map((span) => span.textContent.trim());
expect(nodeLabels).toEqual(['#1 Free Text', '#4 Free Text 2']);
});
it('returns false when the only prompt target has its text widget connected', async () => {
const registryResponse = {
success: true,
data: {
node_count: 1,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Wired Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: true,
widget_names: ['text', 'clip'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
translateMock.mockReturnValue(
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
);
const { sendPromptToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendPromptToWorkflow('a cat');
expect(result).toBe(false);
expect(document.querySelectorAll('#nodeSelector .node-item').length).toBe(0);
const toast = document.querySelector('.toast-container .toast');
expect(toast).not.toBeNull();
expect(toast.textContent).toContain('Mark as');
expect(toast.textContent).toContain('Send Prompt Target');
});
it('shows the mark-as hint when no embedding target is available', async () => {
const registryResponse = {
success: true,
data: {
node_count: 1,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Wired Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: true,
widget_names: ['text', 'clip'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
translateMock.mockReturnValue(
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
);
const { sendEmbeddingToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendEmbeddingToWorkflow('embeddingcode');
expect(result).toBe(false);
const toast = document.querySelector('.toast-container .toast');
expect(toast).not.toBeNull();
expect(toast.textContent).toContain('Send Prompt Target');
});
it('keeps unconnected marker targets in the prompt candidate list', async () => {
const registryResponse = {
success: true,
data: {
node_count: 2,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Marked Target',
type: 'KSampler',
mode: 0,
marker_role: 'send_prompt_target',
capabilities: {
has_text_widget: false,
text_widget_connected: false,
widget_names: ['seed'],
},
},
'root:2': {
id: 2,
graph_id: 'root',
graph_name: null,
title: 'Marked Target 2',
type: 'KSampler',
mode: 0,
marker_role: 'send_prompt_target',
capabilities: {
has_text_widget: false,
text_widget_connected: false,
widget_names: ['seed'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
const { sendPromptToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendPromptToWorkflow('a cat');
expect(result).toBe(true);
const nodeLabels = Array.from(
document.querySelectorAll('#nodeSelector .node-item[data-node-id] span')
).map((span) => span.textContent.trim());
expect(nodeLabels).toEqual(['#1 Marked Target', '#2 Marked Target 2']);
});
it('opens Civitai links using the preferred host and registers the first-use banner once', async () => {
const openSpy = vi.fn();
globalThis.window.open = openSpy;
+5 -1
View File
@@ -36,7 +36,11 @@ function isMapLike(collection) {
return collection && typeof collection.entries === "function" && typeof collection.values === "function";
}
function getChildGraphs(graph) {
/**
* Return the subgraph LGraph instances nested under `graph`, across both
* Map-like and plain-object `_subgraphs` containers.
*/
export function getChildGraphs(graph) {
if (!graph || !graph._subgraphs) {
return [];
}
+124 -24
View File
@@ -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