mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-11 13:19:24 -03:00
feat(embedding): send embedding to workflow + fix copy button format
- Fix copy button on embedding cards to copy 'embedding:folder/name' format - Add send-embedding-to-workflow for Prompt (LoraManager), Text (LoraManager), and CLIPTextEncode nodes, appending embedding code to text content - Extend workflow registry to register text-capable nodes by comfyClass (not generic widget name 'text') to avoid false matches - Add mode parameter to update_node_widget API/event for append support - Fix single/bulk context menus: single shows plain 'Send to Workflow', bulk collapses submenu into direct action for embeddings (append-only)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -51,22 +51,34 @@ 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;
|
||||
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) {
|
||||
refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = `<embed:${currentFileName}:1>`;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<div class="context-menu-separator menu-section-break"></div>
|
||||
<!-- Workflow -->
|
||||
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>
|
||||
<div class="context-menu-item" data-action="sendtoworkflow"><i class="fas fa-paper-plane"></i> {{ t('checkpoints.contextMenu.sendToWorkflow') }}</div>
|
||||
<div class="context-menu-separator menu-section-break"></div>
|
||||
<!-- Media / Preview -->
|
||||
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user