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:
Will Miao
2026-06-11 22:41:42 +08:00
parent 84e9fe2dfb
commit d87863b423
9 changed files with 201 additions and 20 deletions

View File

@@ -3086,6 +3086,7 @@ class NodeRegistryHandler:
data = await request.json() data = await request.json()
widget_name = data.get("widget_name") widget_name = data.get("widget_name")
value = data.get("value") value = data.get("value")
mode = data.get("mode", "replace")
node_ids = data.get("node_ids") node_ids = data.get("node_ids")
if not isinstance(widget_name, str) or not widget_name: if not isinstance(widget_name, str) or not widget_name:
@@ -3133,6 +3134,7 @@ class NodeRegistryHandler:
"id": parsed_node_id, "id": parsed_node_id,
"widget_name": widget_name, "widget_name": widget_name,
"value": value, "value": value,
"mode": mode,
} }
if graph_identifier is not None: if graph_identifier is not None:

View File

@@ -51,21 +51,33 @@ export class BulkContextMenu extends BaseContextMenu {
reimportMetadataItem.style.display = config.reimportMetadata ? 'flex' : 'none'; reimportMetadataItem.style.display = config.reimportMetadata ? 'flex' : 'none';
} }
const isEmbeddings = currentModelType === 'embeddings';
if (sendToWorkflowAppendItem) { if (sendToWorkflowAppendItem) {
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none'; sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
} }
if (sendToWorkflowReplaceItem) { if (sendToWorkflowReplaceItem) {
sendToWorkflowReplaceItem.style.display = config.sendToWorkflow ? 'flex' : 'none'; sendToWorkflowReplaceItem.style.display = (config.sendToWorkflow && !isEmbeddings) ? 'flex' : 'none';
} }
if (copyAllItem) { if (copyAllItem) {
copyAllItem.style.display = config.copyAll ? 'flex' : 'none'; 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"]'); const sendToWorkflowSubmenu = this.menu.querySelector('[data-has-submenu="send-to-workflow"]');
if (sendToWorkflowSubmenu) { if (sendToWorkflowSubmenu) {
const hasWorkflowActions = config.sendToWorkflow || config.copyAll; 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) { if (refreshAllItem) {

View File

@@ -3,6 +3,7 @@ import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js'; import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
import { moveManager } from '../../managers/MoveManager.js'; import { moveManager } from '../../managers/MoveManager.js';
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js'; import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
import { sendEmbeddingToWorkflow } from '../../utils/uiHelpers.js';
export class EmbeddingContextMenu extends BaseContextMenu { export class EmbeddingContextMenu extends BaseContextMenu {
constructor() { constructor() {
@@ -51,6 +52,13 @@ export class EmbeddingContextMenu extends BaseContextMenu {
this.currentCard.querySelector('.fa-copy').click(); this.currentCard.querySelector('.fa-copy').click();
} }
break; 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': case 'refresh-metadata':
// Refresh metadata from CivitAI // Refresh metadata from CivitAI
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath); apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);

View File

@@ -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 { state, getCurrentPageState } from '../../state/index.js';
import { showModelModal } from './ModelModal.js'; import { showModelModal } from './ModelModal.js';
import { toggleShowcase } from './showcase/ShowcaseView.js'; import { toggleShowcase } from './showcase/ShowcaseView.js';
@@ -216,6 +216,11 @@ function handleSendToWorkflow(card, replaceMode, modelType) {
missingNodesMessage, missingNodesMessage,
missingTargetMessage, 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 { } else {
showToast('modelCard.sendToWorkflow.checkpointNotImplemented', {}, 'info'); showToast('modelCard.sendToWorkflow.checkpointNotImplemented', {}, 'info');
} }
@@ -230,8 +235,11 @@ function handleCopyAction(card, modelType) {
const message = translate('modelCard.actions.checkpointNameCopied', {}, 'Checkpoint name copied'); const message = translate('modelCard.actions.checkpointNameCopied', {}, 'Checkpoint name copied');
copyToClipboard(checkpointName, message); copyToClipboard(checkpointName, message);
} else if (modelType === MODEL_TYPES.EMBEDDING) { } else if (modelType === MODEL_TYPES.EMBEDDING) {
const embeddingName = card.dataset.file_name; const folder = card.dataset.folder || '';
copyToClipboard(embeddingName, 'Embedding name copied'); 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);
} }
} }

View File

@@ -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 { modalManager } from '../../managers/ModalManager.js';
import { MODEL_TYPES } from '../../api/apiConfig.js'; import { MODEL_TYPES } from '../../api/apiConfig.js';
import { import {
@@ -648,6 +648,10 @@ export async function showModelModal(model, modelType) {
if (modelType === 'checkpoints' && modelWithFullData.sub_type) { if (modelType === 'checkpoints' && modelWithFullData.sub_type) {
activeModalElement.dataset.subType = 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); updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable);
const versionsTabController = initVersionsTab({ const versionsTabController = initVersionsTab({
@@ -1188,9 +1192,10 @@ async function handleSendToWorkflow(target, modelType) {
missingTargetMessage, missingTargetMessage,
}); });
} else if (modelType === 'embeddings') { } else if (modelType === 'embeddings') {
// For Embedding: Send as LoRA syntax (embedding name only) const folder = modalElement?.dataset?.folder || '';
const embeddingSyntax = `<embed:${currentFileName}:1>`; const name = currentFileName.replace(/\.[^.]+$/, '');
await sendLoraToWorkflow(embeddingSyntax, false, 'embedding'); const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
await sendEmbeddingToWorkflow(embeddingCode, false);
} }
} }

View File

@@ -1,5 +1,5 @@
import { state, getCurrentPageState } from '../state/index.js'; 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 { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
@@ -47,7 +47,7 @@ export class BulkManager {
}, },
[MODEL_TYPES.EMBEDDING]: { [MODEL_TYPES.EMBEDDING]: {
addTags: true, addTags: true,
sendToWorkflow: false, sendToWorkflow: true,
copyAll: false, copyAll: false,
refreshAll: true, refreshAll: true,
checkUpdates: true, checkUpdates: true,
@@ -504,13 +504,17 @@ export class BulkManager {
} }
async sendAllModelsToWorkflow(replaceMode = false) { async sendAllModelsToWorkflow(replaceMode = false) {
if (state.currentPageType !== MODEL_TYPES.LORA) { if (state.selectedModels.size === 0) {
showToast('toast.loras.sendOnlyForLoras', {}, 'warning'); showToast('toast.models.noModelsSelected', {}, 'warning');
return; return;
} }
if (state.selectedModels.size === 0) { if (state.currentPageType === MODEL_TYPES.EMBEDDING) {
showToast('toast.loras.noLorasSelected', {}, 'warning'); return this._sendAllEmbeddingsToWorkflow();
}
if (state.currentPageType !== MODEL_TYPES.LORA) {
showToast('toast.loras.sendOnlyForLoras', {}, 'warning');
return; return;
} }
@@ -542,6 +546,28 @@ export class BulkManager {
await sendLoraToWorkflow(loraSyntaxes.join(', '), replaceMode, 'lora'); 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() { showBulkDeleteModal() {
if (state.selectedModels.size === 0) { if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning'); showToast('toast.models.noModelsSelected', {}, 'warning');

View File

@@ -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 // Global variable to track active node selector state
let nodeSelectorState = { let nodeSelectorState = {
isActive: false, isActive: false,

View File

@@ -16,6 +16,7 @@
<div class="context-menu-separator menu-section-break"></div> <div class="context-menu-separator menu-section-break"></div>
<!-- Workflow --> <!-- 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="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> <div class="context-menu-separator menu-section-break"></div>
<!-- Media / Preview --> <!-- Media / Preview -->
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div> <div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div>

View File

@@ -10,6 +10,13 @@ const LORA_NODE_CLASSES = new Set([
const TARGET_WIDGET_NAMES = new Set(["ckpt_name", "unet_name"]); 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({ app.registerExtension({
name: "LoraManager.WorkflowRegistry", name: "LoraManager.WorkflowRegistry",
@@ -41,8 +48,9 @@ app.registerExtension({
const supportsLora = LORA_NODE_CLASSES.has(node.comfyClass); const supportsLora = LORA_NODE_CLASSES.has(node.comfyClass);
const hasTargetWidget = widgetNames.some((name) => TARGET_WIDGET_NAMES.has(name)); 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; continue;
} }
@@ -65,6 +73,7 @@ app.registerExtension({
mode: node.mode, mode: node.mode,
capabilities: { capabilities: {
supports_lora: supportsLora, supports_lora: supportsLora,
has_text_widget: hasTextWidget,
widget_names: widgetNames, widget_names: widgetNames,
}, },
}); });
@@ -95,6 +104,7 @@ app.registerExtension({
const graphId = message?.graph_id; const graphId = message?.graph_id;
const widgetName = message?.widget_name; const widgetName = message?.widget_name;
const value = message?.value; const value = message?.value;
const mode = message?.mode ?? "replace";
if (nodeId == null || !widgetName) { if (nodeId == null || !widgetName) {
console.warn("LoRA Manager: invalid widget update payload", message); console.warn("LoRA Manager: invalid widget update payload", message);
@@ -127,15 +137,22 @@ app.registerExtension({
} }
const widget = node.widgets[widgetIndex]; 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) { 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") { if (typeof widget.callback === "function") {
try { try {
widget.callback(value); widget.callback(newValue);
} catch (callbackError) { } catch (callbackError) {
console.error("LoRA Manager: widget callback failed", callbackError); console.error("LoRA Manager: widget callback failed", callbackError);
} }