mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-14 01:33:21 -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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "עדכון צומת המודל נכשל",
|
||||
|
||||
@@ -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": "モデルノードの更新に失敗しました",
|
||||
|
||||
@@ -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": "모델 노드 업데이트 실패",
|
||||
|
||||
@@ -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": "Не удалось обновить узел модели",
|
||||
|
||||
@@ -1726,6 +1726,7 @@
|
||||
"recipeReplaced": "配方已替换到工作流",
|
||||
"recipeFailedToSend": "发送配方到工作流失败",
|
||||
"noMatchingNodes": "当前工作流中没有兼容的节点",
|
||||
"noPromptTargets": "工作流中没有兼容的 prompt 目标节点。\n在 ComfyUI 中右键节点 → Mark as → Send Prompt Target",
|
||||
"noTargetNodeSelected": "未选择目标节点",
|
||||
"modelUpdated": "模型已更新到工作流",
|
||||
"modelFailed": "更新模型节点失败",
|
||||
|
||||
@@ -1726,6 +1726,7 @@
|
||||
"recipeReplaced": "配方已取代於工作流",
|
||||
"recipeFailedToSend": "傳送配方到工作流失敗",
|
||||
"noMatchingNodes": "目前工作流程中沒有相容的節點",
|
||||
"noPromptTargets": "工作流中沒有相容的 prompt 目標節點。\n在 ComfyUI 中右鍵節點 → Mark as → Send Prompt Target",
|
||||
"noTargetNodeSelected": "未選擇目標節點",
|
||||
"modelUpdated": "模型已更新到工作流",
|
||||
"modelFailed": "更新模型節點失敗",
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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