From 94e3f545710247a20218952e62baa5cd646211b6 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Wed, 12 Aug 2026 16:50:07 +0800 Subject: [PATCH] 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 --- locales/de.json | 1 + locales/en.json | 1 + locales/es.json | 1 + locales/fr.json | 1 + locales/he.json | 1 + locales/ja.json | 1 + locales/ko.json | 1 + locales/ru.json | 1 + locales/zh-CN.json | 1 + locales/zh-TW.json | 1 + static/js/utils/uiHelpers.js | 25 +- tests/frontend/core/workflowRegistry.test.js | 246 +++++++++++++++++++ tests/frontend/utils/uiHelpers.dom.test.js | 232 +++++++++++++++++ web/comfyui/utils.js | 6 +- web/comfyui/workflow_registry.js | 148 +++++++++-- 15 files changed, 640 insertions(+), 27 deletions(-) create mode 100644 tests/frontend/core/workflowRegistry.test.js diff --git a/locales/de.json b/locales/de.json index f7bb9d4e..79568314 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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", diff --git a/locales/en.json b/locales/en.json index 5b2ad3bf..2b146c0f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/es.json b/locales/es.json index 930a3c35..17e7a617 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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", diff --git a/locales/fr.json b/locales/fr.json index e025f0f3..c11e8c6e 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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", diff --git a/locales/he.json b/locales/he.json index 43506be9..cf2bbd06 100644 --- a/locales/he.json +++ b/locales/he.json @@ -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": "עדכון צומת המודל נכשל", diff --git a/locales/ja.json b/locales/ja.json index 59dc669f..815d9668 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -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": "モデルノードの更新に失敗しました", diff --git a/locales/ko.json b/locales/ko.json index 1505a9b8..72f51e35 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -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": "모델 노드 업데이트 실패", diff --git a/locales/ru.json b/locales/ru.json index 2fe872c4..91a30d6c 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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": "Не удалось обновить узел модели", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 71b08cd9..425c3120 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1726,6 +1726,7 @@ "recipeReplaced": "配方已替换到工作流", "recipeFailedToSend": "发送配方到工作流失败", "noMatchingNodes": "当前工作流中没有兼容的节点", + "noPromptTargets": "工作流中没有兼容的 prompt 目标节点。\n在 ComfyUI 中右键节点 → Mark as → Send Prompt Target", "noTargetNodeSelected": "未选择目标节点", "modelUpdated": "模型已更新到工作流", "modelFailed": "更新模型节点失败", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index e600b745..90e120c9 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1726,6 +1726,7 @@ "recipeReplaced": "配方已取代於工作流", "recipeFailedToSend": "傳送配方到工作流失敗", "noMatchingNodes": "目前工作流程中沒有相容的節點", + "noPromptTargets": "工作流中沒有相容的 prompt 目標節點。\n在 ComfyUI 中右鍵節點 → Mark as → Send Prompt Target", "noTargetNodeSelected": "未選擇目標節點", "modelUpdated": "模型已更新到工作流", "modelFailed": "更新模型節點失敗", diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index e944af4e..5686e597 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -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; } diff --git a/tests/frontend/core/workflowRegistry.test.js b/tests/frontend/core/workflowRegistry.test.js new file mode 100644 index 00000000..34ac8bde --- /dev/null +++ b/tests/frontend/core/workflowRegistry.test.js @@ -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) + ); + }); + }); +}); diff --git a/tests/frontend/utils/uiHelpers.dom.test.js b/tests/frontend/utils/uiHelpers.dom.test.js index 60c5737a..41c509a0 100644 --- a/tests/frontend/utils/uiHelpers.dom.test.js +++ b/tests/frontend/utils/uiHelpers.dom.test.js @@ -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 = '
'; + + 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 = '
'; + 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 = '
'; + 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 = '
'; + + 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; diff --git a/web/comfyui/utils.js b/web/comfyui/utils.js index cba599fb..a117fb91 100644 --- a/web/comfyui/utils.js +++ b/web/comfyui/utils.js @@ -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 []; } diff --git a/web/comfyui/workflow_registry.js b/web/comfyui/workflow_registry.js index 6e218f6f..0a360f55 100644 --- a/web/comfyui/workflow_registry.js +++ b/web/comfyui/workflow_registry.js @@ -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