mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-14 09:43:22 -03:00
1677 lines
52 KiB
JavaScript
1677 lines
52 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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build a toast element (internal — not exported).
|
||
* @param {string} message - Already-resolved message text
|
||
* @param {string} type - Toast type (info/success/warning/error)
|
||
* @returns {HTMLElement} The toast element (not yet attached to the DOM)
|
||
*/
|
||
function createToastElement(message, type) {
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast toast-${type}`;
|
||
toast.textContent = message;
|
||
return toast;
|
||
}
|
||
|
||
/**
|
||
* Attach a toast to the shared container, position it, and schedule its
|
||
* dismissal (internal — not exported).
|
||
* @param {HTMLElement} toast - The toast element to display
|
||
* @param {number} durationMs - How long the toast stays visible
|
||
* @param {Function} [onDismiss] - Optional callback fired once when dismissal begins
|
||
* @returns {Function} Manual dismiss function (idempotent)
|
||
*/
|
||
function appendToast(toast, durationMs, onDismiss = null) {
|
||
// 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`;
|
||
|
||
let dismissed = false;
|
||
const dismiss = () => {
|
||
if (dismissed) return;
|
||
dismissed = true;
|
||
|
||
if (typeof onDismiss === 'function') {
|
||
onDismiss();
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
requestAnimationFrame(() => {
|
||
toast.classList.add('show');
|
||
setTimeout(dismiss, durationMs);
|
||
});
|
||
|
||
return dismiss;
|
||
}
|
||
|
||
export function showToast(key, params = {}, type = 'info', fallback = null) {
|
||
// Plain messages (contain spaces) are not i18n dot-notation keys — use verbatim
|
||
// to avoid spurious "Translation key not found" warnings from i18next
|
||
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
|
||
const message = isPlainMessage ? key : translate(key, params, fallback);
|
||
const toast = createToastElement(message, type);
|
||
|
||
// Set timeout based on type
|
||
let duration = 2000; // Default (info)
|
||
if (type === 'warning' || type === 'error') {
|
||
duration = 5000;
|
||
}
|
||
|
||
appendToast(toast, duration);
|
||
}
|
||
|
||
/**
|
||
* Show a toast with an action button (e.g. Undo) and an optional countdown.
|
||
* The message accepts the same key/plain-string contract as showToast, so
|
||
* callers may pass either an i18n key or an already-translated string.
|
||
* @param {string} key - i18n key or plain message
|
||
* @param {Object} [params] - i18n interpolation params
|
||
* @param {string} [type] - Toast type (info/success/warning/error)
|
||
* @param {Object} [options]
|
||
* @param {string} [options.actionText] - Label for the action button (button omitted when empty)
|
||
* @param {Function} [options.onAction] - Callback invoked at most once on button click
|
||
* @param {number} [options.durationMs=20000] - How long the toast stays visible
|
||
* @param {boolean} [options.countdown=true] - Show a ticking `(N)s` countdown
|
||
*/
|
||
export function showActionToast(key, params = {}, type = 'info', options = {}) {
|
||
const { actionText, onAction, durationMs = 20000, countdown = true } = options;
|
||
|
||
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
|
||
const message = isPlainMessage ? key : translate(key, params);
|
||
const toast = createToastElement(message, type);
|
||
|
||
let countdownInterval = null;
|
||
const clearCountdown = () => {
|
||
if (countdownInterval !== null) {
|
||
clearInterval(countdownInterval);
|
||
countdownInterval = null;
|
||
}
|
||
};
|
||
|
||
// The interval must be cleared on EVERY dismiss path (timeout, countdown end,
|
||
// manual button click) — the onDismiss hook covers the appendToast timeout path.
|
||
const dismiss = appendToast(toast, durationMs, clearCountdown);
|
||
|
||
let actionFired = false;
|
||
if (actionText) {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = 'toast-action-btn';
|
||
button.textContent = actionText;
|
||
button.addEventListener('click', (event) => {
|
||
event.preventDefault();
|
||
// Guard against double-click firing the action twice
|
||
if (actionFired) return;
|
||
actionFired = true;
|
||
|
||
clearCountdown();
|
||
if (typeof onAction === 'function') {
|
||
onAction();
|
||
}
|
||
dismiss();
|
||
});
|
||
toast.append(button);
|
||
}
|
||
|
||
if (countdown) {
|
||
const countdownEl = document.createElement('span');
|
||
countdownEl.className = 'toast-countdown';
|
||
let remainingSeconds = Math.max(0, Math.ceil(durationMs / 1000));
|
||
countdownEl.textContent = `(${remainingSeconds}s)`;
|
||
toast.append(countdownEl);
|
||
|
||
countdownInterval = setInterval(() => {
|
||
remainingSeconds -= 1;
|
||
countdownEl.textContent = `(${Math.max(remainingSeconds, 0)}s)`;
|
||
if (remainingSeconds <= 0) {
|
||
clearCountdown();
|
||
dismiss();
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
// Manual close button: hides the toast early without firing onAction. The
|
||
// backend undo window keeps running and the batch is purged when it expires.
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.type = 'button';
|
||
closeBtn.className = 'toast-close-btn';
|
||
closeBtn.textContent = '×';
|
||
closeBtn.setAttribute('aria-label', translate('common.actions.close'));
|
||
closeBtn.addEventListener('click', (event) => {
|
||
event.preventDefault();
|
||
clearCountdown();
|
||
dismiss();
|
||
});
|
||
toast.append(closeBtn);
|
||
}
|
||
|
||
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`;
|
||
// Clamp panel height to the viewport below the header
|
||
filterPanel.style.maxHeight = `calc(100vh - ${topPosition + 10}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 (<lora:...>) variants.
|
||
* Cleans up artifacts like leading ", ", double commas, and extra whitespace.
|
||
*/
|
||
export function stripLoraTags(text) {
|
||
return text
|
||
.replace(/<lora:[^>]*>/gi, '')
|
||
.replace(/<lora:[^&]*>/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 if (registryData.error === 'Empty Registry') {
|
||
showToast('uiHelpers.workflow.noSupportedNodes', {}, '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 (LGraphEventMode): 0 = Always, 2 = Never, 4 = Bypass
|
||
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', onComplete = null) {
|
||
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) {
|
||
const result = await sendLoraToNodes([nodeKeys[0]], loraNodes, loraSyntax, replaceMode, syntaxType);
|
||
if (result && typeof onComplete === 'function') onComplete();
|
||
return result;
|
||
}
|
||
|
||
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: async (selectedNodeIds) => {
|
||
const result = await sendLoraToNodes(selectedNodeIds, loraNodes, loraSyntax, replaceMode, syntaxType);
|
||
if (result && typeof onComplete === 'function') onComplete();
|
||
return result;
|
||
},
|
||
});
|
||
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, onComplete = null) {
|
||
const registry = await fetchWorkflowRegistry();
|
||
if (!registry) {
|
||
return false;
|
||
}
|
||
|
||
const textNodes = filterRegistryNodes(registry.nodes, (node) => {
|
||
if (!isNodeEnabled(node)) {
|
||
return false;
|
||
}
|
||
if (node.capabilities?.text_widget_connected === true) {
|
||
return false;
|
||
}
|
||
return (
|
||
node.capabilities?.has_text_widget === true ||
|
||
node.marker_role === "send_prompt_target"
|
||
);
|
||
});
|
||
|
||
const nodeKeys = Object.keys(textNodes);
|
||
if (nodeKeys.length === 0) {
|
||
showToast(
|
||
translate(
|
||
'uiHelpers.workflow.noPromptTargets',
|
||
{},
|
||
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
|
||
),
|
||
{},
|
||
'warning'
|
||
);
|
||
return false;
|
||
}
|
||
|
||
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 = async (selectedNodeIds) => {
|
||
const result = await sendTextToNodes(selectedNodeIds, textNodes, embeddingCode, 'append', messages);
|
||
if (result && typeof onComplete === 'function') onComplete();
|
||
return result;
|
||
};
|
||
|
||
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;
|
||
}
|
||
// A node whose text widget is backed by a connected input cannot have its
|
||
// text changed via the widget — execution reads the linked input.
|
||
if (node.capabilities?.text_widget_connected === true) {
|
||
return false;
|
||
}
|
||
return (
|
||
node.capabilities?.has_text_widget === true ||
|
||
node.marker_role === "send_prompt_target"
|
||
);
|
||
});
|
||
|
||
const nodeKeys = Object.keys(textNodes);
|
||
if (nodeKeys.length === 0) {
|
||
const defaultHint = translate(
|
||
'uiHelpers.workflow.noPromptTargets',
|
||
{},
|
||
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
|
||
);
|
||
showToast(options.missingNodesMessage || defaultHint, {}, 'warning');
|
||
return false;
|
||
}
|
||
|
||
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 = getWidgetNames(node);
|
||
const updates = findMatchingWidgets(widgetNames, raw, node.type_name);
|
||
|
||
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
|
||
});
|
||
}
|