Files
ComfyUI-Lora-Manager/static/js/utils/uiHelpers.js
Will Miao 3c83e78d9f feat(ui): auto-newline after pasting URL in download and batch-import textareas
Extract auto-newline-on-paste logic into shared setupAutoNewlineOnPaste() utility in uiHelpers.js.
Apply it to both the Download modal (modelUrl) and Batch Import modal (batchUrlInput)
textarea, so users can paste multiple URLs in succession without manually pressing Enter.
2026-07-02 10:53:33 +08:00

1522 lines
46 KiB
JavaScript

import { translate } from './i18nHelpers.js';
import { state, getCurrentPageState } from '../state/index.js';
import { getStorageItem, setStorageItem } from './storageHelpers.js';
import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js';
import { eventManager } from './EventManager.js';
import { bannerService } from '../managers/BannerService.js';
import { modalManager } from '../managers/ModalManager.js';
import { buildCivitaiUrl, normalizeCivitaiPageHost } from './civitaiUtils.js';
import { resolveSamplerScheduler, findMatchingWidgets } from './genParamsMapper.js';
const CIVITAI_HOST_INFO_BANNER_ID = 'civitai-host-preference';
const CIVITAI_HOST_INFO_BANNER_SEEN_KEY = 'civitai_host_info_banner_seen';
function getPreferredCivitaiHost() {
return normalizeCivitaiPageHost(state?.global?.settings?.civitai_host);
}
function maybeRegisterCivitaiHostInfoBanner() {
if (getStorageItem(CIVITAI_HOST_INFO_BANNER_SEEN_KEY, false)) {
return;
}
setStorageItem(CIVITAI_HOST_INFO_BANNER_SEEN_KEY, true);
bannerService.registerBanner(CIVITAI_HOST_INFO_BANNER_ID, {
id: CIVITAI_HOST_INFO_BANNER_ID,
title: translate(
'settings.civitaiHostBanner.title',
{},
'Civitai host preference available'
),
content: translate(
'settings.civitaiHostBanner.content',
{},
'Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.'
),
actions: [
{
text: translate('settings.civitaiHostBanner.openSettings', {}, 'Open Settings'),
icon: 'fas fa-cog',
action: 'open-settings-modal',
type: 'primary',
},
],
dismissible: true,
priority: 70,
onRegister: (bannerElement) => {
const button = bannerElement.querySelector('.banner-action[data-action="open-settings-modal"]');
if (button) {
button.addEventListener('click', (event) => {
event.preventDefault();
modalManager.showModal('settingsModal');
});
}
},
});
}
export function openCivitaiUrl(url) {
if (!url) {
return null;
}
maybeRegisterCivitaiHostInfoBanner();
return window.open(url, '_blank', 'noopener,noreferrer');
}
async function copyExampleImagesValue(value, successKey, fallbackKey, paramsKey = 'path') {
if (!value) {
return false;
}
const params = { [paramsKey]: value };
try {
await navigator.clipboard.writeText(value);
showToast(successKey, params, 'success');
return true;
} catch (clipboardErr) {
console.warn('Clipboard API not available:', clipboardErr);
showToast(fallbackKey, params, 'info');
return false;
}
}
function tryOpenExternalUri(uri) {
try {
const openedWindow = window.open(uri, '_blank', 'noopener,noreferrer');
return openedWindow !== null;
} catch (error) {
console.warn('Failed to open external URI:', error);
return false;
}
}
/**
* Utility function to copy text to clipboard with fallback for older browsers
* @param {string} text - The text to copy to clipboard
* @param {string} successMessage - Optional success message to show in toast
* @returns {Promise<boolean>} - Promise that resolves to true if copy was successful
/**
* Utility function to copy text to clipboard with fallback for older browsers
* @param {string} text - The text to copy to clipboard
* @param {string} successMessage - Optional success message to show in toast
* @returns {Promise<boolean>} - Promise that resolves to true if copy was successful
*/
export async function copyToClipboard(text, successMessage = null) {
const defaultSuccessMessage = successMessage || translate('uiHelpers.clipboard.copied', {}, 'Copied to clipboard');
try {
// Modern clipboard API
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
// Fallback for older browsers
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'absolute';
textarea.style.left = '-99999px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
if (defaultSuccessMessage) {
showToast('uiHelpers.clipboard.copied', {}, 'success');
}
return true;
} catch (err) {
console.error('Copy failed:', err);
showToast('uiHelpers.clipboard.copyFailed', {}, 'error');
return false;
}
}
export function showToast(key, params = {}, type = 'info', fallback = null) {
const message = translate(key, params, fallback);
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
// Get or create toast container
let toastContainer = document.querySelector('.toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.className = 'toast-container';
document.body.append(toastContainer);
}
toastContainer.append(toast);
// Calculate vertical position for stacked toasts
const existingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
const toastIndex = existingToasts.indexOf(toast);
const topOffset = 20; // Base offset from top
const spacing = 10; // Space between toasts
// Set position based on existing toasts
toast.style.top = `${topOffset + (toastIndex * (toast.offsetHeight || 60 + spacing))}px`;
requestAnimationFrame(() => {
toast.classList.add('show');
// Set timeout based on type
let timeout = 2000; // Default (info)
if (type === 'warning' || type === 'error') {
timeout = 5000;
}
setTimeout(() => {
toast.classList.remove('show');
toast.addEventListener('transitionend', () => {
toast.remove();
// Reposition remaining toasts
if (toastContainer) {
const remainingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
remainingToasts.forEach((t, index) => {
t.style.top = `${topOffset + (index * (t.offsetHeight || 60 + spacing))}px`;
});
// Remove container if empty
if (remainingToasts.length === 0) {
toastContainer.remove();
}
}
});
}, timeout);
});
}
export function restoreFolderFilter() {
const activeFolder = getStorageItem('activeFolder');
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
if (folderTag) {
folderTag.classList.add('active');
filterByFolder(activeFolder);
}
}
const CYCLE_ORDER = ['auto', 'light', 'dark'];
const PRESET_NAMES = ['default', 'nord', 'midnight', 'monokai', 'dracula', 'solarized'];
export { CYCLE_ORDER, PRESET_NAMES };
export function initTheme() {
const savedTheme = getStorageItem('theme') || 'auto';
// Migrate deprecated presets
let savedPreset = getStorageItem('theme_preset');
if (savedPreset === 'gruvbox') {
savedPreset = 'midnight';
setStorageItem('theme_preset', 'midnight');
}
applyTheme(savedTheme);
applyPreset(savedPreset || 'default');
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const currentTheme = getStorageItem('theme') || 'auto';
if (currentTheme === 'auto') {
applyTheme('auto');
}
});
}
export function toggleTheme() {
const currentTheme = getStorageItem('theme') || 'auto';
const currentIndex = CYCLE_ORDER.indexOf(currentTheme);
const nextIndex = (currentIndex + 1) % CYCLE_ORDER.length;
const newTheme = CYCLE_ORDER[nextIndex];
setStorageItem('theme', newTheme);
applyTheme(newTheme);
document.body.style.display = 'none';
document.body.offsetHeight;
document.body.style.display = '';
return newTheme;
}
export function cyclePreset() {
const currentPreset = getStorageItem('theme_preset') || 'default';
const currentIndex = PRESET_NAMES.indexOf(currentPreset);
const nextIndex = (currentIndex + 1) % PRESET_NAMES.length;
const newPreset = PRESET_NAMES[nextIndex];
setStorageItem('theme_preset', newPreset);
applyPreset(newPreset);
return newPreset;
}
export function setPreset(name) {
if (!PRESET_NAMES.includes(name)) return;
setStorageItem('theme_preset', name);
applyPreset(name);
}
function applyTheme(theme) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const htmlElement = document.documentElement;
htmlElement.removeAttribute('data-theme');
if (theme === 'dark' || (theme === 'auto' && prefersDark)) {
htmlElement.setAttribute('data-theme', 'dark');
document.body.dataset.theme = 'dark';
} else {
htmlElement.setAttribute('data-theme', 'light');
document.body.dataset.theme = 'light';
}
updateThemeToggleIcons(theme);
}
function applyPreset(preset) {
document.documentElement.setAttribute('data-theme-preset', preset);
}
function updateThemeToggleIcons(theme) {
const themeToggle = document.querySelector('.theme-toggle');
if (!themeToggle) return;
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
themeToggle.classList.add(`theme-${theme}`);
}
function filterByFolder(folderPath) {
document.querySelectorAll('.model-card').forEach(card => {
card.style.display = card.dataset.folder === folderPath ? '' : 'none';
});
}
export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) {
const url = buildCivitaiUrl({
modelId: civitaiId,
versionId,
modelName,
host: getPreferredCivitaiHost(),
});
if (url) {
openCivitaiUrl(url);
}
}
export function openCivitai(filePath) {
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
? window.CSS.escape(filePath)
: filePath.replace(/["\\]/g, '\\$&');
const loraCard = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
if (!loraCard) return;
const metaData = JSON.parse(loraCard.dataset.meta);
const civitaiId = metaData.modelId;
const versionId = metaData.id;
const modelName = loraCard.dataset.name;
openCivitaiByMetadata(civitaiId, versionId, modelName);
}
/**
* Open a Hugging Face model page in a new tab
* @param {string} hfUrl - The Hugging Face URL
*/
export function openHuggingFace(hfUrl) {
if (!hfUrl) return;
window.open(hfUrl, '_blank', 'noopener,noreferrer');
}
/**
* Dynamically positions the search options panel and filter panel
* based on the current layout and folder tags container height
*/
export function updatePanelPositions() {
const searchOptionsPanel = document.getElementById('searchOptionsPanel');
const filterPanel = document.getElementById('filterPanel');
if (!searchOptionsPanel && !filterPanel) return;
// Get the header element
const header = document.querySelector('.app-header');
if (!header) return;
// Calculate the position based on the bottom of the header
const headerRect = header.getBoundingClientRect();
const topPosition = headerRect.bottom + 5; // Add 5px padding
// Set the positions
if (searchOptionsPanel) {
searchOptionsPanel.style.top = `${topPosition}px`;
}
if (filterPanel) {
filterPanel.style.top = `${topPosition}px`;
}
// Adjust panel horizontal position based on the search container
const searchContainer = document.querySelector('.header-search');
if (searchContainer) {
const searchRect = searchContainer.getBoundingClientRect();
// Position the search options panel aligned with the search container
if (searchOptionsPanel) {
searchOptionsPanel.style.right = `${window.innerWidth - searchRect.right}px`;
}
// Position the filter panel aligned with the filter button
if (filterPanel) {
const filterButton = document.getElementById('filterButton');
if (filterButton) {
const filterRect = filterButton.getBoundingClientRect();
filterPanel.style.right = `${window.innerWidth - filterRect.right}px`;
}
}
}
}
export function initBackToTop() {
const button = document.getElementById('backToTopBtn');
if (!button) return;
// Get the scrollable container
const scrollContainer = document.querySelector('.page-content');
// Show/hide button based on scroll position
const toggleBackToTop = () => {
const scrollThreshold = window.innerHeight * 0.3;
if (scrollContainer.scrollTop > scrollThreshold) {
button.classList.add('visible');
} else {
button.classList.remove('visible');
}
};
// Smooth scroll to top
button.addEventListener('click', () => {
scrollContainer.scrollTo({
top: 0,
behavior: 'smooth'
});
});
// Listen for scroll events on the scrollable container
scrollContainer.addEventListener('scroll', toggleBackToTop);
// Initial check
toggleBackToTop();
}
export function getNSFWLevelName(level) {
if (level === 0) return 'Unknown';
if (level >= 32) return 'Blocked';
if (level >= 16) return 'XXX';
if (level >= 8) return 'X';
if (level >= 4) return 'R';
if (level >= 2) return 'PG13';
if (level >= 1) return 'PG';
return 'Unknown';
}
function parseUsageTipNumber(value) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
export function getLoraStrengthsFromUsageTips(usageTips = {}) {
const parsedStrength = parseUsageTipNumber(usageTips.strength);
const clipStrengthSource = usageTips.clip_strength ?? usageTips.clipStrength;
const parsedClipStrength = parseUsageTipNumber(clipStrengthSource);
return {
strength: parsedStrength !== null ? parsedStrength : 1,
hasStrength: parsedStrength !== null,
clipStrength: parsedClipStrength,
hasClipStrength: parsedClipStrength !== null,
};
}
export function buildLoraSyntax(fileName, usageTips = {}) {
const { strength, hasStrength, clipStrength, hasClipStrength } = getLoraStrengthsFromUsageTips(usageTips);
const effectiveName = state.global.settings?.lora_syntax_format === 'legacy'
? fileName.split('/').pop()
: fileName;
if (hasClipStrength) {
const modelStrength = hasStrength ? strength : 1;
return `<lora:${effectiveName}:${modelStrength}:${clipStrength}>`;
}
return `<lora:${effectiveName}:${strength}>`;
}
export function copyLoraSyntax(card) {
const usageTips = JSON.parse(card.dataset.usage_tips || "{}");
const folder = card.dataset.folder || '';
const loraName = folder ? `${folder}/${card.dataset.file_name}` : card.dataset.file_name;
const baseSyntax = buildLoraSyntax(loraName, usageTips);
// Check if trigger words should be included
const includeTriggerWords = state.global.settings.include_trigger_words;
if (!includeTriggerWords) {
const message = translate('uiHelpers.lora.syntaxCopied', {}, 'LoRA syntax copied to clipboard');
copyToClipboard(baseSyntax, message);
return;
}
// Get trigger words from metadata
const meta = card.dataset.meta ? JSON.parse(card.dataset.meta) : null;
const trainedWords = meta?.trainedWords;
if (
!trainedWords ||
!Array.isArray(trainedWords) ||
trainedWords.length === 0
) {
const message = translate('uiHelpers.lora.syntaxCopiedNoTriggerWords', {}, 'LoRA syntax copied to clipboard (no trigger words found)');
copyToClipboard(baseSyntax, message);
return;
}
let finalSyntax = baseSyntax;
if (trainedWords.length === 1) {
// Single group: append trigger words to the same line
const triggers = trainedWords[0]
.split(",")
.map((word) => word.trim())
.filter((word) => word);
if (triggers.length > 0) {
finalSyntax = `${baseSyntax}, ${triggers.join(", ")}`;
}
const message = translate('uiHelpers.lora.syntaxCopiedWithTriggerWords', {}, 'LoRA syntax with trigger words copied to clipboard');
copyToClipboard(finalSyntax, message);
} else {
// Multiple groups: format with separators
const groups = trainedWords
.map((group) => {
const triggers = group
.split(",")
.map((word) => word.trim())
.filter((word) => word);
return triggers.join(", ");
})
.filter((group) => group);
if (groups.length > 0) {
// Use separator between all groups except the first
finalSyntax = baseSyntax + ", " + groups[0];
for (let i = 1; i < groups.length; i++) {
finalSyntax += `\n${"-".repeat(17)}\n${groups[i]}`;
}
}
const message = translate('uiHelpers.lora.syntaxCopiedWithTriggerWordGroups', {}, 'LoRA syntax with trigger word groups copied to clipboard');
copyToClipboard(finalSyntax, message);
}
}
/**
* Strip <lora:...> tags from prompt text and clean up residual punctuation/whitespace.
* Handles both unescaped (<lora:...>) and HTML-escaped (&lt;lora:...&gt;) variants.
* Cleans up artifacts like leading ", ", double commas, and extra whitespace.
*/
export function stripLoraTags(text) {
return text
.replace(/<lora:[^>]*>/gi, '')
.replace(/&lt;lora:[^&]*&gt;/gi, '')
.replace(/,(\s*,)+/g, ',')
.replace(/^,\s*/, '')
.replace(/,\s*$/, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
async function fetchWorkflowRegistry() {
try {
const response = await fetch('/api/lm/get-registry');
const registryData = await response.json();
if (!registryData.success) {
if (registryData.error === 'Standalone Mode Active') {
showToast('toast.general.cannotInteractStandalone', {}, 'warning');
} else {
showToast('toast.general.failedWorkflowInfo', {}, 'error');
}
return null;
}
return registryData.data;
} catch (error) {
console.error('Failed to get registry:', error);
showToast('uiHelpers.workflow.communicationFailed', {}, 'error');
return null;
}
}
function filterRegistryNodes(nodes = {}, predicate) {
if (typeof nodes !== 'object' || nodes === null) {
return {};
}
return Object.fromEntries(
Object.entries(nodes).filter(([, node]) => {
try {
return predicate(node);
} catch (error) {
console.warn('Failed to evaluate registry node predicate', error);
return false;
}
}),
);
}
function getWidgetNames(node) {
if (!node) {
return [];
}
if (Array.isArray(node.widget_names)) {
return node.widget_names;
}
if (node.capabilities && Array.isArray(node.capabilities.widget_names)) {
return node.capabilities.widget_names;
}
return [];
}
function isNodeEnabled(node) {
if (!node) {
return false;
}
// ComfyUI node mode: 0 = Normal/Enabled, others = Always/Never/OnEvent
return node.mode === undefined || node.mode === 0;
}
function isAbsolutePath(path) {
if (typeof path !== 'string') {
return false;
}
return path.startsWith('/') || path.startsWith('\\') || /^[a-zA-Z]:[\\/]/.test(path);
}
async function ensureRelativeModelPath(modelPath, collectionType) {
if (!modelPath || !isAbsolutePath(modelPath)) {
return modelPath;
}
const fileName = modelPath.split(/[/\\]/).pop();
if (!fileName) {
return modelPath;
}
// Remove model file extension (.safetensors, .ckpt, .pt, .bin) for cleaner matching
// Backend removes extensions from paths before matching, so search term should not include extension
const searchTerm = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
try {
const response = await fetch(`/api/lm/${collectionType}/relative-paths?search=${encodeURIComponent(searchTerm)}&limit=10`);
if (!response.ok) {
return modelPath;
}
const data = await response.json();
const relativePaths = Array.isArray(data?.relative_paths) ? data.relative_paths : [];
if (relativePaths.length === 0) {
return modelPath;
}
const exactMatch = relativePaths.find((path) => path.endsWith(fileName));
return exactMatch || relativePaths[0] || modelPath;
} catch (error) {
console.warn('LoRA Manager: failed to resolve relative path for model', error);
return modelPath;
}
}
/**
* Sends LoRA syntax to the active ComfyUI workflow
* @param {string} loraSyntax - The LoRA syntax to send
* @param {boolean} replaceMode - Whether to replace existing LoRAs (true) or append (false)
* @param {string} syntaxType - The type of syntax ('lora' or 'recipe')
* @returns {Promise<boolean>} - Whether the operation was successful
*/
export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntaxType = 'lora') {
const registry = await fetchWorkflowRegistry();
if (!registry) {
return false;
}
const loraNodes = filterRegistryNodes(registry.nodes, (node) => {
if (!isNodeEnabled(node)) {
return false;
}
if (node.capabilities && typeof node.capabilities === 'object') {
if (node.capabilities.supports_lora === true) {
return true;
}
}
return typeof node.type === 'number' && node.type > 0;
});
const nodeKeys = Object.keys(loraNodes);
if (nodeKeys.length === 0) {
showToast('uiHelpers.workflow.noSupportedNodes', {}, 'warning');
return false;
}
if (nodeKeys.length === 1) {
return await sendLoraToNodes([nodeKeys[0]], loraNodes, loraSyntax, replaceMode, syntaxType);
}
const actionType =
syntaxType === 'recipe'
? translate('uiHelpers.nodeSelector.recipe', {}, 'Recipe')
: translate('uiHelpers.nodeSelector.lora', {}, 'LoRA');
const actionMode = replaceMode
? translate('uiHelpers.nodeSelector.replace', {}, 'Replace')
: translate('uiHelpers.nodeSelector.append', {}, 'Append');
showNodeSelector(loraNodes, {
actionType,
actionMode,
onSend: (selectedNodeIds) =>
sendLoraToNodes(selectedNodeIds, loraNodes, loraSyntax, replaceMode, syntaxType),
});
return true;
}
export async function sendModelPathToWorkflow(modelPath, options) {
const {
widgetName,
collectionType = 'checkpoints',
actionTypeText = 'Checkpoint',
successMessage = 'Updated workflow node',
failureMessage = 'Failed to update workflow node',
missingNodesMessage = 'No compatible nodes available in the current workflow',
missingTargetMessage = 'No target node selected',
} = options;
if (!widgetName) {
console.warn('LoRA Manager: widget name is required to send model to workflow');
return false;
}
const relativePath = await ensureRelativeModelPath(modelPath, collectionType);
const registry = await fetchWorkflowRegistry();
if (!registry) {
return false;
}
const targetNodes = filterRegistryNodes(registry.nodes, (node) => {
if (!isNodeEnabled(node)) {
return false;
}
const widgetNames = getWidgetNames(node);
return widgetNames.includes(widgetName);
});
const nodeKeys = Object.keys(targetNodes);
if (nodeKeys.length === 0) {
showToast(missingNodesMessage, {}, 'warning');
return false;
}
const actionType = actionTypeText;
const actionMode = translate('uiHelpers.nodeSelector.replace', {}, 'Replace');
const messages = {
successMessage,
failureMessage,
missingTargetMessage,
};
const handleSend = (selectedNodeIds) =>
sendWidgetValueToNodes(selectedNodeIds, targetNodes, widgetName, relativePath, messages);
if (nodeKeys.length === 1) {
return await handleSend([nodeKeys[0]]);
}
showNodeSelector(targetNodes, {
actionType,
actionMode,
onSend: handleSend,
});
return true;
}
/**
* Send LoRA to specific nodes
* @param {Array|undefined} nodeIds - Array of node IDs or undefined for desktop mode
* @param {string} loraSyntax - The LoRA syntax to send
* @param {boolean} replaceMode - Whether to replace existing LoRAs
* @param {string} syntaxType - The type of syntax ('lora' or 'recipe')
*/
function resolveNodeReference(nodeKey, nodesMap) {
if (!nodeKey) {
return null;
}
const directMatch = nodesMap?.[nodeKey];
if (directMatch) {
return {
node_id: directMatch.id,
graph_id: directMatch.graph_id ?? null,
};
}
if (typeof nodeKey === 'string' && nodeKey.includes(':')) {
const [graphId, ...rest] = nodeKey.split(':');
const nodeIdPart = rest.join(':');
const numericNodeId = Number(nodeIdPart);
return {
node_id: Number.isNaN(numericNodeId) ? nodeIdPart : numericNodeId,
graph_id: graphId || null,
};
}
const numericId = Number(nodeKey);
return {
node_id: Number.isNaN(numericId) ? nodeKey : numericId,
graph_id: null,
};
}
async function sendLoraToNodes(nodeIds, nodesMap, loraSyntax, replaceMode, syntaxType) {
try {
// Call the backend API to update the lora code
const requestBody = {
lora_code: loraSyntax,
mode: replaceMode ? 'replace' : 'append'
};
if (Array.isArray(nodeIds)) {
const references = nodeIds
.map((nodeKey) => resolveNodeReference(nodeKey, nodesMap))
.filter((reference) => reference && reference.node_id !== undefined);
if (references.length > 0) {
requestBody.node_ids = references;
}
} else if (nodeIds) {
const reference = resolveNodeReference(nodeIds, nodesMap);
if (reference) {
requestBody.node_ids = [reference];
}
}
const response = await fetch('/api/lm/update-lora-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
const result = await response.json();
if (result.success) {
// Use different toast messages based on syntax type
if (syntaxType === 'recipe') {
const messageKey = replaceMode ?
'uiHelpers.workflow.recipeReplaced' :
'uiHelpers.workflow.recipeAdded';
showToast(messageKey, {}, 'success');
} else {
const messageKey = replaceMode ?
'uiHelpers.workflow.loraReplaced' :
'uiHelpers.workflow.loraAdded';
showToast(messageKey, {}, 'success');
}
return true;
} else {
const messageKey = syntaxType === 'recipe' ?
'uiHelpers.workflow.recipeFailedToSend' :
'uiHelpers.workflow.loraFailedToSend';
showToast(messageKey, {}, 'error');
return false;
}
} catch (error) {
console.error('Failed to send to workflow:', error);
const messageKey = syntaxType === 'recipe' ?
'uiHelpers.workflow.recipeFailedToSend' :
'uiHelpers.workflow.loraFailedToSend';
showToast(messageKey, {}, 'error');
return false;
}
}
async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, messages = {}) {
const {
successMessage = 'Updated workflow node',
failureMessage = 'Failed to update workflow node',
missingTargetMessage = 'No target node selected',
silent = false,
} = messages;
const targetIds = Array.isArray(nodeIds) ? nodeIds : [];
if (targetIds.length === 0) {
if (!silent) showToast(missingTargetMessage, {}, 'warning');
return false;
}
const references = targetIds
.map((nodeKey) => resolveNodeReference(nodeKey, nodesMap))
.filter((reference) => reference && reference.node_id !== undefined);
if (references.length === 0) {
if (!silent) 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: widgetName,
value,
node_ids: references,
}),
});
const result = await response.json();
if (result.success) {
if (!silent) showToast(successMessage, {}, 'success');
return true;
}
const errorMessage = result?.error || failureMessage;
if (!silent) showToast(errorMessage, {}, 'error');
return false;
} catch (error) {
console.error('Failed to send widget value to workflow:', error);
if (!silent) showToast(failureMessage, {}, 'error');
return false;
}
}
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({
action: 'inject_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) {
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 ||
node.marker_role === "send_prompt_target"
);
});
const nodeKeys = Object.keys(textNodes);
if (nodeKeys.length === 0) {
showToast('uiHelpers.workflow.noMatchingNodes', {}, 'warning');
return false;
}
const messages = {
successMessage: translate('uiHelpers.workflow.embeddingAdded', {}, '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, 'append', messages);
if (nodeKeys.length === 1) {
return await handleSend([nodeKeys[0]]);
}
const actionType = translate('uiHelpers.nodeSelector.embedding', {}, 'Embedding');
showNodeSelector(textNodes, {
actionType,
actionMode: '',
onSend: handleSend,
});
return true;
}
/**
* Send prompt text to workflow text-capable nodes (replaces existing content).
* Uses the same target node discovery as sendEmbeddingToWorkflow.
* @param {string} promptText - The prompt/negative prompt text to send
* @param {Object} [options] - Optional messages overrides
* @param {string} [options.actionTypeText] - Label for the action type (default "Prompt")
* @param {string} [options.successMessage] - Success toast message
* @param {string} [options.failureMessage] - Failure toast message
* @param {string} [options.missingNodesMessage] - No nodes warning message
* @param {string} [options.missingTargetMessage] - No target selected warning message
* @returns {Promise<boolean>} Whether the send succeeded
*/
export async function sendPromptToWorkflow(promptText, options = {}) {
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 ||
node.marker_role === "send_prompt_target"
);
});
const nodeKeys = Object.keys(textNodes);
if (nodeKeys.length === 0) {
showToast(options.missingNodesMessage || 'uiHelpers.workflow.noMatchingNodes', {}, 'warning');
return false;
}
const messages = {
successMessage: options.successMessage || translate('uiHelpers.workflow.promptSent', {}, 'Prompt sent to workflow'),
failureMessage: options.failureMessage || translate('uiHelpers.workflow.promptFailed', {}, 'Failed to send prompt'),
missingTargetMessage: options.missingTargetMessage || translate('uiHelpers.workflow.noTargetNodeSelected', {}, 'No target node selected'),
};
const handleSend = (selectedNodeIds) =>
sendTextToNodes(selectedNodeIds, textNodes, promptText, 'replace', messages);
if (nodeKeys.length === 1) {
return await handleSend([nodeKeys[0]]);
}
const actionType = options.actionTypeText || translate('uiHelpers.nodeSelector.prompt', {}, 'Prompt');
showNodeSelector(textNodes, {
actionType,
actionMode: translate('uiHelpers.nodeSelector.replace', {}, 'Replace'),
onSend: handleSend,
});
return true;
}
/**
* Send generation parameters (seed, steps, cfg, sampler, scheduler) to
* workflow nodes that have been marked with "Send Gen Params Target".
*
* @param {Object} genParams - Raw gen_params from recipe or showcase metadata
* @returns {Promise<boolean>} Whether the send succeeded
*/
export async function sendGenParamsToWorkflow(genParams) {
if (!genParams || typeof genParams !== 'object') {
showToast('No generation parameters to send', {}, 'warning');
return false;
}
// 1. Extract relevant params (skip prompt, negative_prompt, clip_skip, denoising_strength)
const raw = {
seed: genParams.seed,
steps: genParams.steps,
cfg: genParams.cfg_scale,
};
// 2. Resolve sampler/scheduler
const resolved = resolveSamplerScheduler(genParams.sampler);
if (resolved) {
if (resolved.sampler) raw.sampler = resolved.sampler;
if (resolved.scheduler) raw.scheduler = resolved.scheduler;
}
// Check if we have anything to send
const hasAny = Object.values(raw).some(v => v !== undefined && v !== null && v !== '');
if (!hasAny) {
showToast('No sendable parameters found', {}, 'warning');
return false;
}
// 3. Fetch workflow registry
const registry = await fetchWorkflowRegistry();
if (!registry) {
return false;
}
// 4. Filter nodes by marker_role === "send_gen_params"
const targetNodes = filterRegistryNodes(registry.nodes, (node) => {
return node.marker_role === 'send_gen_params' && isNodeEnabled(node);
});
const nodeKeys = Object.keys(targetNodes);
if (nodeKeys.length === 0) {
showToast(
'No node marked as Send Gen Params Target.\nRight-click a node in ComfyUI → Mark as → Send Gen Params Target',
{},
'warning'
);
return false;
}
// 5. For each candidate node, find matching widgets
// Also collect widget_names from registry for matching
const sendToNode = async (nodeIds) => {
const targetIds = Array.isArray(nodeIds) ? nodeIds : [nodeIds];
let allSuccess = true;
let totalSent = 0;
let totalFailed = 0;
for (const nodeKey of targetIds) {
const node = targetNodes[nodeKey];
if (!node) continue;
const widgetNames = node.widget_names || [];
const updates = findMatchingWidgets(widgetNames, raw);
if (updates.length === 0) {
showToast(`Node "${node.title || node.type}" has no matching widgets for these parameters`, {}, 'warning');
allSuccess = false;
continue;
}
// Send each widget value sequentially
for (const update of updates) {
const success = await sendWidgetValueToNodes(
[nodeKey],
targetNodes,
update.widgetName,
update.value,
{
silent: true,
}
);
if (success) {
totalSent++;
} else {
totalFailed++;
allSuccess = false;
}
}
}
// Show single summary toast
if (totalSent > 0 && totalFailed === 0) {
showToast(`Sent ${totalSent} parameter${totalSent > 1 ? 's' : ''} to workflow`, {}, 'success');
} else if (totalFailed > 0 && totalSent > 0) {
showToast(`Partially updated (${totalSent} ok, ${totalFailed} failed)`, {}, 'warning');
} else if (totalFailed > 0) {
showToast('Failed to update parameters', {}, 'error');
}
return allSuccess;
};
// 6. If multiple nodes, show node selector; otherwise send directly
if (nodeKeys.length === 1) {
return await sendToNode([nodeKeys[0]]);
}
showNodeSelector(targetNodes, {
actionType: 'Gen Params',
actionMode: 'Update',
onSend: sendToNode,
enableSendAll: true,
});
return true;
}
// Global variable to track active node selector state
let nodeSelectorState = {
isActive: false,
clickHandler: null,
selectorClickHandler: null,
currentNodes: {},
onSend: null,
enableSendAll: true,
};
/**
* Show node selector popup near mouse position
* @param {Object} nodes - Registry nodes data
* @param {Object} options - Configuration for display and actions
* @param {string} options.actionType - Display label for the action type (e.g. LoRA)
* @param {string} options.actionMode - Display label for the action mode (e.g. Replace)
* @param {Function} options.onSend - Callback invoked with selected node ids
* @param {boolean} [options.enableSendAll=true] - Whether to show the "send to all" option
*/
function showNodeSelector(nodes, options = {}) {
const selector = document.getElementById('nodeSelector');
if (!selector) return;
// Clean up any existing state
hideNodeSelector();
const safeNodes = nodes || {};
const onSend = typeof options.onSend === 'function' ? options.onSend : null;
if (!onSend) {
console.warn('LoRA Manager: node selector invoked without send handler');
return;
}
nodeSelectorState.currentNodes = safeNodes;
nodeSelectorState.onSend = onSend;
nodeSelectorState.enableSendAll = options.enableSendAll !== false;
// Generate node list HTML with icons and proper colors
const nodeItems = Object.entries(safeNodes)
.sort(([, a], [, b]) => a.type - b.type || a.id - b.id)
.map(([nodeKey, node]) => {
const iconClass = NODE_TYPE_ICONS[node.type] || 'fas fa-question-circle';
const bgColor = node.bgcolor || DEFAULT_NODE_COLOR;
const graphLabel = node.graph_name ? ` (${node.graph_name})` : '';
return `
<div class="node-item" data-node-id="${nodeKey}">
<div class="node-icon-indicator" style="background-color: ${bgColor}">
<i class="${iconClass}"></i>
</div>
<span>#${node.id}${graphLabel} ${node.title}</span>
</div>
`;
}).join('');
// Add header with action mode indicator
const actionType = options.actionType ?? translate('uiHelpers.nodeSelector.lora', {}, 'LoRA');
const actionMode = options.actionMode ?? translate('uiHelpers.nodeSelector.replace', {}, 'Replace');
const selectTargetNodeText = translate('uiHelpers.nodeSelector.selectTargetNode', {}, 'Select target node');
const sendToAllText = translate('uiHelpers.nodeSelector.sendToAll', {}, 'Send to All');
const sendAllMarkup = nodeSelectorState.enableSendAll
? `
<div class="node-item send-all-item" data-action="send-all">
<div class="node-icon-indicator all-nodes">
<i class="fas fa-broadcast-tower"></i>
</div>
<span>${sendToAllText}</span>
</div>`
: '';
selector.innerHTML = `
<div class="node-selector-header">
<span class="selector-action-type">${actionMode} ${actionType}</span>
<span class="selector-instruction">${selectTargetNodeText}</span>
</div>
${nodeItems}
${sendAllMarkup}
`;
// Position near mouse
positionNearMouse(selector);
// Show selector
selector.style.display = 'block';
nodeSelectorState.isActive = true;
// Update event manager state
eventManager.setState('nodeSelectorActive', true);
// Setup event listeners with proper cleanup through event manager
setupNodeSelectorEvents(selector);
}
/**
* Setup event listeners for node selector using event manager
* @param {HTMLElement} selector - The selector element
*/
function setupNodeSelectorEvents(selector) {
// Clean up any existing event listeners
cleanupNodeSelectorEvents();
// Register click outside handler with event manager
eventManager.addHandler('click', 'nodeSelector-outside', (e) => {
if (!selector.contains(e.target)) {
hideNodeSelector();
return true; // Stop propagation
}
}, {
priority: 200, // High priority to handle before other click handlers
onlyWhenNodeSelectorActive: true
});
// Register node selection handler with event manager
eventManager.addHandler('click', 'nodeSelector-selection', async (e) => {
const nodeItem = e.target.closest('.node-item');
if (!nodeItem) return false; // Continue with other handlers
const onSend = nodeSelectorState.onSend;
if (typeof onSend !== 'function') {
hideNodeSelector();
return true;
}
e.stopPropagation();
const action = nodeItem.dataset.action;
const nodeId = nodeItem.dataset.nodeId;
const nodes = nodeSelectorState.currentNodes || {};
try {
if (action === 'send-all') {
if (!nodeSelectorState.enableSendAll) {
return true;
}
const allNodeIds = Object.keys(nodes);
await onSend(allNodeIds);
} else if (nodeId) {
await onSend([nodeId]);
}
} finally {
hideNodeSelector();
}
return true; // Stop propagation
}, {
priority: 150, // High priority but lower than outside click
targetSelector: '#nodeSelector',
onlyWhenNodeSelectorActive: true
});
}
/**
* Clean up node selector event listeners
*/
function cleanupNodeSelectorEvents() {
// Remove event handlers from event manager
eventManager.removeHandler('click', 'nodeSelector-outside');
eventManager.removeHandler('click', 'nodeSelector-selection');
// Clear legacy references
nodeSelectorState.clickHandler = null;
nodeSelectorState.selectorClickHandler = null;
}
/**
* Hide node selector
*/
function hideNodeSelector() {
const selector = document.getElementById('nodeSelector');
if (selector) {
selector.style.display = 'none';
selector.innerHTML = ''; // Clear content to prevent memory leaks
}
// Clean up event listeners
cleanupNodeSelectorEvents();
nodeSelectorState.isActive = false;
nodeSelectorState.currentNodes = {};
nodeSelectorState.onSend = null;
nodeSelectorState.enableSendAll = true;
// Update event manager state
eventManager.setState('nodeSelectorActive', false);
}
/**
* Position element near mouse cursor
* @param {HTMLElement} element - Element to position
*/
function positionNearMouse(element) {
// Get current mouse position from last mouse event or use default
const mouseX = window.lastMouseX || window.innerWidth / 2;
const mouseY = window.lastMouseY || window.innerHeight / 2;
// Show element temporarily to get dimensions
element.style.visibility = 'hidden';
element.style.display = 'block';
const rect = element.getBoundingClientRect();
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
// Calculate position with offset from mouse
let x = mouseX + 10;
let y = mouseY + 10;
// Ensure element doesn't go offscreen
if (x + rect.width > viewportWidth) {
x = mouseX - rect.width - 10;
}
if (y + rect.height > viewportHeight) {
y = mouseY - rect.height - 10;
}
// Apply position
element.style.left = `${x}px`;
element.style.top = `${y}px`;
element.style.visibility = 'visible';
}
/**
* Initialize mouse tracking for positioning elements
*/
export function initializeMouseTracking() {
// Register mouse tracking with event manager
eventManager.addHandler('mousemove', 'uiHelpers-mouseTracking', (e) => {
window.lastMouseX = e.clientX;
window.lastMouseY = e.clientY;
}, {
priority: 10 // Low priority since this is just tracking
});
}
// Initialize mouse tracking when module loads
initializeMouseTracking();
/**
* Opens the example images folder for a specific model
* @param {string} modelHash - The SHA256 hash of the model
*/
export async function openExampleImagesFolder(modelHash) {
try {
const response = await fetch('/api/lm/open-example-images-folder', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
model_hash: modelHash
})
});
const result = await response.json();
if (result.success) {
if (result.mode === 'clipboard' && result.path) {
await copyExampleImagesValue(
result.path,
'uiHelpers.exampleImages.copiedPath',
'uiHelpers.exampleImages.clipboardFallback',
'path'
);
return true;
}
if (result.mode === 'uri' && result.uri) {
const opened = tryOpenExternalUri(result.uri);
if (!opened) {
await copyExampleImagesValue(
result.uri,
'uiHelpers.exampleImages.copiedUri',
'uiHelpers.exampleImages.uriClipboardFallback',
'uri'
);
} else {
showToast('uiHelpers.exampleImages.opened', {}, 'success');
}
return true;
}
showToast('uiHelpers.exampleImages.opened', {}, 'success');
return true;
} else {
showToast('uiHelpers.exampleImages.failedToOpen', {}, 'error');
return false;
}
} catch (error) {
console.error('Failed to open example images folder:', error);
showToast('uiHelpers.exampleImages.failedToOpen', {}, 'error');
return false;
}
}
/**
* Set up a paste handler on a textarea that automatically appends a newline
* after pasted content that looks like a URL (http/https). This lets users
* paste multiple URLs one after another without manually pressing Enter.
* @param {string} textareaId - The id of the textarea element
*/
export function setupAutoNewlineOnPaste(textareaId) {
const el = document.getElementById(textareaId);
if (!el || el.tagName !== 'TEXTAREA') return;
el.addEventListener('paste', (e) => {
const pastedText = (e.clipboardData || window.clipboardData).getData('text');
// Only apply to text that starts with http:// or https://
if (/^https?:\/\//.test(pastedText) && !pastedText.endsWith('\n')) {
e.preventDefault();
const start = el.selectionStart;
const end = el.selectionEnd;
const text = el.value;
const before = text.substring(0, start);
const after = text.substring(end);
// Append newline after the pasted URL
const modifiedText = pastedText + '\n';
el.value = before + modifiedText + after;
// Move cursor to just after the inserted text
const newCursorPos = start + modifiedText.length;
el.selectionStart = el.selectionEnd = newCursorPos;
// Trigger input event so any listeners stay in sync
el.dispatchEvent(new Event('input', { bubbles: true }));
}
// Non-URL text or text already ending with \n — let default paste happen
});
}