diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index 25fb5be5..794d5de5 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -3086,6 +3086,7 @@ class NodeRegistryHandler: data = await request.json() widget_name = data.get("widget_name") value = data.get("value") + mode = data.get("mode", "replace") node_ids = data.get("node_ids") if not isinstance(widget_name, str) or not widget_name: @@ -3133,6 +3134,7 @@ class NodeRegistryHandler: "id": parsed_node_id, "widget_name": widget_name, "value": value, + "mode": mode, } if graph_identifier is not None: diff --git a/static/js/components/ContextMenu/BulkContextMenu.js b/static/js/components/ContextMenu/BulkContextMenu.js index 6be0c42b..064f9497 100644 --- a/static/js/components/ContextMenu/BulkContextMenu.js +++ b/static/js/components/ContextMenu/BulkContextMenu.js @@ -51,21 +51,33 @@ export class BulkContextMenu extends BaseContextMenu { reimportMetadataItem.style.display = config.reimportMetadata ? 'flex' : 'none'; } + const isEmbeddings = currentModelType === 'embeddings'; if (sendToWorkflowAppendItem) { sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none'; } if (sendToWorkflowReplaceItem) { - sendToWorkflowReplaceItem.style.display = config.sendToWorkflow ? 'flex' : 'none'; + sendToWorkflowReplaceItem.style.display = (config.sendToWorkflow && !isEmbeddings) ? 'flex' : 'none'; } if (copyAllItem) { copyAllItem.style.display = config.copyAll ? 'flex' : 'none'; } - // Submenu parent visibility + // Submenu parent - for embeddings, collapse into a direct item (no replace choice) const sendToWorkflowSubmenu = this.menu.querySelector('[data-has-submenu="send-to-workflow"]'); if (sendToWorkflowSubmenu) { const hasWorkflowActions = config.sendToWorkflow || config.copyAll; - sendToWorkflowSubmenu.style.display = hasWorkflowActions ? 'flex' : 'none'; + if (isEmbeddings && config.sendToWorkflow && !config.copyAll) { + sendToWorkflowSubmenu.classList.remove('has-submenu'); + sendToWorkflowSubmenu.removeAttribute('data-has-submenu'); + sendToWorkflowSubmenu.dataset.action = 'send-to-workflow-append'; + const arrow = sendToWorkflowSubmenu.querySelector('.submenu-arrow'); + if (arrow) arrow.style.display = 'none'; + const submenu = sendToWorkflowSubmenu.querySelector('.context-submenu'); + if (submenu) submenu.style.display = 'none'; + sendToWorkflowSubmenu.style.display = 'flex'; + } else { + sendToWorkflowSubmenu.style.display = hasWorkflowActions ? 'flex' : 'none'; + } } if (refreshAllItem) { diff --git a/static/js/components/ContextMenu/EmbeddingContextMenu.js b/static/js/components/ContextMenu/EmbeddingContextMenu.js index 1998eacd..8e0e2097 100644 --- a/static/js/components/ContextMenu/EmbeddingContextMenu.js +++ b/static/js/components/ContextMenu/EmbeddingContextMenu.js @@ -3,6 +3,7 @@ import { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js'; import { moveManager } from '../../managers/MoveManager.js'; import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js'; +import { sendEmbeddingToWorkflow } from '../../utils/uiHelpers.js'; export class EmbeddingContextMenu extends BaseContextMenu { constructor() { @@ -51,6 +52,13 @@ export class EmbeddingContextMenu extends BaseContextMenu { this.currentCard.querySelector('.fa-copy').click(); } break; + case 'sendtoworkflow': { + const folder = this.currentCard.dataset.folder || ''; + const name = this.currentCard.dataset.file_name || ''; + const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`; + sendEmbeddingToWorkflow(embeddingCode, false); + break; + } case 'refresh-metadata': // Refresh metadata from CivitAI apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath); diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js index e0b12e12..985d17d5 100644 --- a/static/js/components/shared/ModelCard.js +++ b/static/js/components/shared/ModelCard.js @@ -1,4 +1,4 @@ -import { showToast, openCivitai, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js'; +import { showToast, openCivitai, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js'; import { state, getCurrentPageState } from '../../state/index.js'; import { showModelModal } from './ModelModal.js'; import { toggleShowcase } from './showcase/ShowcaseView.js'; @@ -216,6 +216,11 @@ function handleSendToWorkflow(card, replaceMode, modelType) { missingNodesMessage, missingTargetMessage, }); + } else if (modelType === MODEL_TYPES.EMBEDDING) { + const folder = card.dataset.folder || ''; + const name = card.dataset.file_name || ''; + const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`; + sendEmbeddingToWorkflow(embeddingCode, false); } else { showToast('modelCard.sendToWorkflow.checkpointNotImplemented', {}, 'info'); } @@ -230,8 +235,11 @@ function handleCopyAction(card, modelType) { const message = translate('modelCard.actions.checkpointNameCopied', {}, 'Checkpoint name copied'); copyToClipboard(checkpointName, message); } else if (modelType === MODEL_TYPES.EMBEDDING) { - const embeddingName = card.dataset.file_name; - copyToClipboard(embeddingName, 'Embedding name copied'); + const folder = card.dataset.folder || ''; + const name = card.dataset.file_name || ''; + const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`; + const message = translate('modelCard.actions.embeddingNameCopied', {}, 'Embedding syntax copied'); + copyToClipboard(embeddingCode, message); } } diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js index 9fd5eeeb..0ead097e 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -1,4 +1,4 @@ -import { showToast, openCivitai, sendLoraToWorkflow, sendModelPathToWorkflow, buildLoraSyntax } from '../../utils/uiHelpers.js'; +import { showToast, openCivitai, sendLoraToWorkflow, sendEmbeddingToWorkflow, sendModelPathToWorkflow, buildLoraSyntax } from '../../utils/uiHelpers.js'; import { modalManager } from '../../managers/ModalManager.js'; import { MODEL_TYPES } from '../../api/apiConfig.js'; import { @@ -648,6 +648,10 @@ export async function showModelModal(model, modelType) { if (modelType === 'checkpoints' && modelWithFullData.sub_type) { activeModalElement.dataset.subType = modelWithFullData.sub_type; } + // Store folder for embedding models + if (modelType === 'embeddings' && modelWithFullData.folder) { + activeModalElement.dataset.folder = modelWithFullData.folder; + } } updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable); const versionsTabController = initVersionsTab({ @@ -1188,9 +1192,10 @@ async function handleSendToWorkflow(target, modelType) { missingTargetMessage, }); } else if (modelType === 'embeddings') { - // For Embedding: Send as LoRA syntax (embedding name only) - const embeddingSyntax = ``; - await sendLoraToWorkflow(embeddingSyntax, false, 'embedding'); + const folder = modalElement?.dataset?.folder || ''; + const name = currentFileName.replace(/\.[^.]+$/, ''); + const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`; + await sendEmbeddingToWorkflow(embeddingCode, false); } } diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index ddcfa665..ce3a9c60 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -1,5 +1,5 @@ import { state, getCurrentPageState } from '../state/index.js'; -import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js'; +import { showToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js'; import { updateCardsForBulkMode } from '../components/shared/ModelCard.js'; import { modalManager } from './ModalManager.js'; import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; @@ -47,7 +47,7 @@ export class BulkManager { }, [MODEL_TYPES.EMBEDDING]: { addTags: true, - sendToWorkflow: false, + sendToWorkflow: true, copyAll: false, refreshAll: true, checkUpdates: true, @@ -504,13 +504,17 @@ export class BulkManager { } async sendAllModelsToWorkflow(replaceMode = false) { - if (state.currentPageType !== MODEL_TYPES.LORA) { - showToast('toast.loras.sendOnlyForLoras', {}, 'warning'); + if (state.selectedModels.size === 0) { + showToast('toast.models.noModelsSelected', {}, 'warning'); return; } - if (state.selectedModels.size === 0) { - showToast('toast.loras.noLorasSelected', {}, 'warning'); + if (state.currentPageType === MODEL_TYPES.EMBEDDING) { + return this._sendAllEmbeddingsToWorkflow(); + } + + if (state.currentPageType !== MODEL_TYPES.LORA) { + showToast('toast.loras.sendOnlyForLoras', {}, 'warning'); return; } @@ -542,6 +546,28 @@ export class BulkManager { await sendLoraToWorkflow(loraSyntaxes.join(', '), replaceMode, 'lora'); } + async _sendAllEmbeddingsToWorkflow() { + const embeddingCodes = []; + for (const filepath of state.selectedModels) { + const escapedPath = CSS.escape(filepath); + const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`); + if (card) { + const folder = card.dataset.folder || ''; + const name = card.dataset.file_name || ''; + const code = folder ? `embedding:${folder}/${name}` : `embedding:${name}`; + embeddingCodes.push(code); + } + } + + if (embeddingCodes.length === 0) { + showToast('No valid embedding data found', {}, 'warning'); + return; + } + + const joinedCode = embeddingCodes.join(', '); + await sendEmbeddingToWorkflow(joinedCode, false); + } + showBulkDeleteModal() { if (state.selectedModels.size === 0) { showToast('toast.models.noModelsSelected', {}, 'warning'); diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index db4c5ea7..deab5f08 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -866,6 +866,108 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess } } +async function sendTextToNodes(nodeIds, nodesMap, text, mode, messages = {}) { + const { + successMessage = 'Updated workflow node', + failureMessage = 'Failed to update workflow node', + missingTargetMessage = 'No target node selected', + } = messages; + + const targetIds = Array.isArray(nodeIds) ? nodeIds : []; + if (targetIds.length === 0) { + showToast(missingTargetMessage, {}, 'warning'); + return false; + } + + const references = targetIds + .map((nodeKey) => resolveNodeReference(nodeKey, nodesMap)) + .filter((reference) => reference && reference.node_id !== undefined); + + if (references.length === 0) { + showToast(missingTargetMessage, {}, 'warning'); + return false; + } + + try { + const response = await fetch('/api/lm/update-node-widget', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + widget_name: 'text', + value: text, + mode: mode || 'append', + node_ids: references, + }), + }); + + const result = await response.json(); + if (result.success) { + showToast(successMessage, {}, 'success'); + return true; + } + + const errorMessage = result?.error || failureMessage; + showToast(errorMessage, {}, 'error'); + return false; + } catch (error) { + console.error('Failed to send text to workflow:', error); + showToast(failureMessage, {}, 'error'); + return false; + } +} + +export async function sendEmbeddingToWorkflow(embeddingCode, replaceMode = false) { + const registry = await fetchWorkflowRegistry(); + if (!registry) { + return false; + } + + const textNodes = filterRegistryNodes(registry.nodes, (node) => { + if (!isNodeEnabled(node)) { + return false; + } + return node.capabilities?.has_text_widget === true; + }); + + const nodeKeys = Object.keys(textNodes); + if (nodeKeys.length === 0) { + showToast('uiHelpers.workflow.noMatchingNodes', {}, 'warning'); + return false; + } + + const mode = replaceMode ? 'replace' : 'append'; + const messages = { + successMessage: translate( + replaceMode ? 'uiHelpers.workflow.embeddingReplaced' : 'uiHelpers.workflow.embeddingAdded', + {}, + replaceMode ? 'Embedding replaced in workflow' : 'Embedding added to workflow' + ), + failureMessage: translate('uiHelpers.workflow.embeddingFailed', {}, 'Failed to add embedding'), + missingTargetMessage: translate('uiHelpers.workflow.noTargetNodeSelected', {}, 'No target node selected'), + }; + + const handleSend = (selectedNodeIds) => + sendTextToNodes(selectedNodeIds, textNodes, embeddingCode, mode, messages); + + if (nodeKeys.length === 1) { + return await handleSend([nodeKeys[0]]); + } + + const actionType = translate('uiHelpers.nodeSelector.embedding', {}, 'Embedding'); + const actionMode = replaceMode + ? translate('uiHelpers.nodeSelector.replace', {}, 'Replace') + : translate('uiHelpers.nodeSelector.append', {}, 'Append'); + + showNodeSelector(textNodes, { + actionType, + actionMode, + onSend: handleSend, + }); + return true; +} + // Global variable to track active node selector state let nodeSelectorState = { isActive: false, diff --git a/templates/embeddings.html b/templates/embeddings.html index aa51daaf..c6272104 100644 --- a/templates/embeddings.html +++ b/templates/embeddings.html @@ -16,6 +16,7 @@
{{ t('loras.contextMenu.copyFilename') }}
+
{{ t('checkpoints.contextMenu.sendToWorkflow') }}
{{ t('loras.contextMenu.openExamples') }}
diff --git a/web/comfyui/workflow_registry.js b/web/comfyui/workflow_registry.js index f8c5dad7..fffcbaeb 100644 --- a/web/comfyui/workflow_registry.js +++ b/web/comfyui/workflow_registry.js @@ -10,6 +10,13 @@ const LORA_NODE_CLASSES = new Set([ const TARGET_WIDGET_NAMES = new Set(["ckpt_name", "unet_name"]); +// Node classes whose "text" widget is a prompt text input (not LoRA syntax, notes, etc.) +const TEXT_CAPABLE_CLASSES = new Set([ + "Prompt (LoraManager)", + "Text (LoraManager)", + "CLIPTextEncode", +]); + app.registerExtension({ name: "LoraManager.WorkflowRegistry", @@ -41,8 +48,9 @@ app.registerExtension({ const supportsLora = LORA_NODE_CLASSES.has(node.comfyClass); const hasTargetWidget = widgetNames.some((name) => TARGET_WIDGET_NAMES.has(name)); + const hasTextWidget = TEXT_CAPABLE_CLASSES.has(node.comfyClass); - if (!supportsLora && !hasTargetWidget) { + if (!supportsLora && !hasTargetWidget && !hasTextWidget) { continue; } @@ -65,6 +73,7 @@ app.registerExtension({ mode: node.mode, capabilities: { supports_lora: supportsLora, + has_text_widget: hasTextWidget, widget_names: widgetNames, }, }); @@ -95,6 +104,7 @@ app.registerExtension({ const graphId = message?.graph_id; const widgetName = message?.widget_name; const value = message?.value; + const mode = message?.mode ?? "replace"; if (nodeId == null || !widgetName) { console.warn("LoRA Manager: invalid widget update payload", message); @@ -127,15 +137,22 @@ app.registerExtension({ } const widget = node.widgets[widgetIndex]; - widget.value = value; + let newValue = value; + + if (mode === "append") { + const separator = widget.value && widget.value.length > 0 ? " " : ""; + newValue = widget.value + separator + value; + } + + widget.value = newValue; if (Array.isArray(node.widgets_values) && node.widgets_values.length > widgetIndex) { - node.widgets_values[widgetIndex] = value; + node.widgets_values[widgetIndex] = newValue; } if (typeof widget.callback === "function") { try { - widget.callback(value); + widget.callback(newValue); } catch (callbackError) { console.error("LoRA Manager: widget callback failed", callbackError); }