feat(nodes): add Promp (LoraManager) node and autocomplete support

This commit is contained in:
Will Miao
2025-10-13 23:23:32 +08:00
parent 8c037465ba
commit 1e4d1b8f15
10 changed files with 546 additions and 292 deletions

View File

@@ -2,6 +2,7 @@ try: # pragma: no cover - import fallback for pytest collection
from .py.lora_manager import LoraManager
from .py.nodes.lora_loader import LoraManagerLoader, LoraManagerTextLoader
from .py.nodes.trigger_word_toggle import TriggerWordToggle
from .py.nodes.prompt import PromptLoraManager
from .py.nodes.lora_stacker import LoraStacker
from .py.nodes.save_image import SaveImage
from .py.nodes.debug_metadata import DebugMetadata
@@ -17,6 +18,7 @@ except ImportError: # pragma: no cover - allows running under pytest without pa
if str(package_root) not in sys.path:
sys.path.append(str(package_root))
PromptLoraManager = importlib.import_module("py.nodes.prompt").PromptLoraManager
LoraManager = importlib.import_module("py.lora_manager").LoraManager
LoraManagerLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerLoader
LoraManagerTextLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerTextLoader
@@ -29,6 +31,7 @@ except ImportError: # pragma: no cover - allows running under pytest without pa
init_metadata_collector = importlib.import_module("py.metadata_collector").init
NODE_CLASS_MAPPINGS = {
PromptLoraManager.NAME: PromptLoraManager,
LoraManagerLoader.NAME: LoraManagerLoader,
LoraManagerTextLoader.NAME: LoraManagerTextLoader,
TriggerWordToggle.NAME: TriggerWordToggle,

View File

@@ -666,6 +666,7 @@ NODE_EXTRACTORS = {
"LoraManagerLoader": LoraLoaderManagerExtractor,
# Conditioning
"CLIPTextEncode": CLIPTextEncodeExtractor,
"PromptLoraManager": CLIPTextEncodeExtractor,
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
"AdvancedCLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/BlenderNeko/ComfyUI_ADV_CLIP_emb

59
py/nodes/prompt.py Normal file
View File

@@ -0,0 +1,59 @@
from typing import Any, Optional
from nodes import CLIPTextEncode # type: ignore
class PromptLoraManager:
"""Encodes text (and optional trigger words) into CLIP conditioning."""
NAME = "Prompt (LoraManager)"
CATEGORY = "Lora Manager/conditioning"
DESCRIPTION = (
"Encodes a text prompt using a CLIP model into an embedding that can be used "
"to guide the diffusion model towards generating specific images."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": (
'STRING',
{
"multiline": True,
"pysssss.autocomplete": False,
"dynamicPrompts": True,
"tooltip": "The text to be encoded.",
},
),
"clip": (
'CLIP',
{"tooltip": "The CLIP model used for encoding the text."},
),
},
"optional": {
"trigger_words": (
'STRING',
{
"forceInput": True,
"tooltip": (
"Optional trigger words to prepend to the text before "
"encoding."
)
},
)
},
}
RETURN_TYPES = ('CONDITIONING', 'STRING',)
RETURN_NAMES = ('CONDITIONING', 'PROMPT',)
OUTPUT_TOOLTIPS = (
"A conditioning containing the embedded text used to guide the diffusion model.",
)
FUNCTION = "encode"
def encode(self, text: str, clip: Any, trigger_words: Optional[str] = None):
prompt = text
if trigger_words:
prompt = ", ".join([trigger_words, text])
conditioning = CLIPTextEncode().encode(clip, prompt)[0]
return (conditioning, prompt,)

View File

@@ -10,7 +10,7 @@ const {
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
CARET_HELPER_MODULE: new URL('../../../web/comfyui/textarea_caret_helper.js', import.meta.url).pathname,
PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/loras_widget_components.js', import.meta.url).pathname,
PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/preview_tooltip.js', import.meta.url).pathname,
AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname,
}));

View File

@@ -15,15 +15,136 @@ function parseUsageTipNumber(value) {
return null;
}
function splitRelativePath(relativePath = '') {
const parts = relativePath.split(/[/\\]+/).filter(Boolean);
const fileName = parts.pop() ?? '';
return {
directories: parts,
fileName,
};
}
function removeGeneralExtension(fileName = '') {
return fileName.replace(/\.[^.]+$/, '');
}
function removeLoraExtension(fileName = '') {
return fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
}
function createDefaultBehavior(modelType) {
return {
enablePreview: false,
async getInsertText(_instance, relativePath) {
const trimmed = relativePath?.trim() ?? '';
if (!trimmed) {
return '';
}
return `${trimmed}, `;
},
};
}
const MODEL_BEHAVIORS = {
loras: {
enablePreview: true,
init(instance) {
if (!instance.options.showPreview) {
return;
}
instance.initPreviewTooltip({ modelType: instance.modelType });
},
showPreview(instance, relativePath, itemElement) {
if (!instance.previewTooltip) {
return;
}
instance.showPreviewForItem(relativePath, itemElement);
},
hidePreview(instance) {
if (!instance.previewTooltip) {
return;
}
instance.previewTooltip.hide();
},
destroy(instance) {
if (instance.previewTooltip) {
instance.previewTooltip.cleanup();
instance.previewTooltip = null;
}
},
async getInsertText(_instance, relativePath) {
const fileName = removeLoraExtension(splitRelativePath(relativePath).fileName);
let strength = 1.0;
let hasStrength = false;
let clipStrength = null;
try {
const response = await api.fetchApi(`/lm/loras/usage-tips-by-path?relative_path=${encodeURIComponent(relativePath)}`);
if (response.ok) {
const data = await response.json();
if (data.success && data.usage_tips) {
try {
const usageTips = JSON.parse(data.usage_tips);
const parsedStrength = parseUsageTipNumber(usageTips.strength);
if (parsedStrength !== null) {
strength = parsedStrength;
hasStrength = true;
}
const clipSource = usageTips.clip_strength ?? usageTips.clipStrength;
const parsedClipStrength = parseUsageTipNumber(clipSource);
if (parsedClipStrength !== null) {
clipStrength = parsedClipStrength;
if (!hasStrength) {
strength = 1.0;
}
}
} catch (parseError) {
console.warn('Failed to parse usage tips JSON:', parseError);
}
}
}
} catch (error) {
console.warn('Failed to fetch usage tips:', error);
}
if (clipStrength !== null) {
return `<lora:${fileName}:${strength}:${clipStrength}>, `;
}
return `<lora:${fileName}:${strength}>, `;
}
},
embeddings: {
enablePreview: true,
init(instance) {
if (!instance.options.showPreview) {
return;
}
instance.initPreviewTooltip({ modelType: instance.modelType });
},
async getInsertText(_instance, relativePath) {
const { directories, fileName } = splitRelativePath(relativePath);
const trimmedName = removeGeneralExtension(fileName);
const folder = directories.length ? `${directories.join('\\')}\\` : '';
return `embedding:${folder}${trimmedName}, `;
},
},
};
function getModelBehavior(modelType) {
return MODEL_BEHAVIORS[modelType] ?? createDefaultBehavior(modelType);
}
class AutoComplete {
constructor(inputElement, modelType = 'loras', options = {}) {
this.inputElement = inputElement;
this.modelType = modelType;
this.behavior = getModelBehavior(modelType);
this.options = {
maxItems: 20,
minChars: 1,
debounceDelay: 200,
showPreview: true,
showPreview: this.behavior.enablePreview ?? false,
...options
};
@@ -34,6 +155,7 @@ class AutoComplete {
this.isVisible = false;
this.currentSearchTerm = '';
this.previewTooltip = null;
this.previewTooltipPromise = null;
// Initialize TextAreaCaretHelper
this.helper = new TextAreaCaretHelper(inputElement, () => app.canvas.ds.scale);
@@ -88,19 +210,24 @@ class AutoComplete {
// Append to body to avoid overflow issues
document.body.appendChild(this.dropdown);
// Initialize preview tooltip if needed
if (this.options.showPreview && this.modelType === 'loras') {
this.initPreviewTooltip();
if (typeof this.behavior.init === 'function') {
this.behavior.init(this);
}
}
initPreviewTooltip() {
initPreviewTooltip(options = {}) {
if (this.previewTooltip || this.previewTooltipPromise) {
return;
}
// Dynamically import and create preview tooltip
import('./loras_widget_components.js').then(module => {
this.previewTooltip = new module.PreviewTooltip();
this.previewTooltipPromise = import('./preview_tooltip.js').then(module => {
const config = { modelType: this.modelType, ...options };
this.previewTooltip = new module.PreviewTooltip(config);
}).catch(err => {
console.warn('Failed to load preview tooltip:', err);
}).finally(() => {
this.previewTooltipPromise = null;
});
}
@@ -220,7 +347,6 @@ class AutoComplete {
// Hover and selection handlers
item.addEventListener('mouseenter', () => {
this.selectItem(index);
this.showPreviewForItem(relativePath, item);
});
item.addEventListener('mouseleave', () => {
@@ -256,7 +382,7 @@ class AutoComplete {
}
showPreviewForItem(relativePath, itemElement) {
if (!this.previewTooltip) return;
if (!this.options.showPreview || !this.previewTooltip) return;
// Extract filename without extension for preview
const fileName = relativePath.split(/[/\\]/).pop();
@@ -271,7 +397,12 @@ class AutoComplete {
}
hidePreview() {
if (this.previewTooltip) {
if (!this.options.showPreview) {
return;
}
if (typeof this.behavior.hidePreview === 'function') {
this.behavior.hidePreview(this);
} else if (this.previewTooltip) {
this.previewTooltip.hide();
}
}
@@ -354,7 +485,11 @@ class AutoComplete {
// Show preview for selected item
if (this.options.showPreview) {
this.showPreviewForItem(this.items[index], item);
if (typeof this.behavior.showPreview === 'function') {
this.behavior.showPreview(this, this.items[index], item);
} else if (this.previewTooltip) {
this.showPreviewForItem(this.items[index], item);
}
}
}
}
@@ -390,47 +525,12 @@ class AutoComplete {
}
async insertSelection(relativePath) {
// Extract just the filename for LoRA name
const fileName = relativePath.split(/[/\\]/).pop().replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
// Get usage tips and extract strength information
let strength = 1.0; // Default strength
let hasStrength = false;
let clipStrength = null;
try {
const response = await api.fetchApi(`/lm/loras/usage-tips-by-path?relative_path=${encodeURIComponent(relativePath)}`);
if (response.ok) {
const data = await response.json();
if (data.success && data.usage_tips) {
try {
const usageTips = JSON.parse(data.usage_tips);
const parsedStrength = parseUsageTipNumber(usageTips.strength);
if (parsedStrength !== null) {
strength = parsedStrength;
hasStrength = true;
}
const clipSource = usageTips.clip_strength ?? usageTips.clipStrength;
const parsedClipStrength = parseUsageTipNumber(clipSource);
if (parsedClipStrength !== null) {
clipStrength = parsedClipStrength;
if (!hasStrength) {
strength = 1.0;
}
}
} catch (parseError) {
console.warn('Failed to parse usage tips JSON:', parseError);
}
}
}
} catch (error) {
console.warn('Failed to fetch usage tips:', error);
const insertText = await this.getInsertText(relativePath);
if (!insertText) {
this.hide();
return;
}
// Format the LoRA code with strength values
const loraCode = clipStrength !== null
? `<lora:${fileName}:${strength}:${clipStrength}>, `
: `<lora:${fileName}:${strength}>, `;
const currentValue = this.inputElement.value;
const caretPos = this.getCaretPosition();
@@ -440,8 +540,8 @@ class AutoComplete {
const searchStartPos = caretPos - searchTerm.length;
// Only replace the search term, not everything after the last comma
const newValue = currentValue.substring(0, searchStartPos) + loraCode + currentValue.substring(caretPos);
const newCaretPos = searchStartPos + loraCode.length;
const newValue = currentValue.substring(0, searchStartPos) + insertText + currentValue.substring(caretPos);
const newCaretPos = searchStartPos + insertText.length;
this.inputElement.value = newValue;
@@ -455,18 +555,42 @@ class AutoComplete {
this.inputElement.focus();
this.inputElement.setSelectionRange(newCaretPos, newCaretPos);
}
async getInsertText(relativePath) {
if (typeof this.behavior.getInsertText === 'function') {
try {
const result = await this.behavior.getInsertText(this, relativePath);
if (typeof result === 'string' && result.length > 0) {
return result;
}
} catch (error) {
console.warn('Failed to format autocomplete insertion:', error);
}
}
const trimmed = typeof relativePath === 'string' ? relativePath.trim() : '';
if (!trimmed) {
return '';
}
return `${trimmed}, `;
}
destroy() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (this.previewTooltip) {
if (typeof this.behavior.destroy === 'function') {
this.behavior.destroy(this);
} else if (this.previewTooltip) {
this.previewTooltip.cleanup();
this.previewTooltip = null;
}
this.previewTooltipPromise = null;
if (this.dropdown && this.dropdown.parentNode) {
this.dropdown.parentNode.removeChild(this.dropdown);
this.dropdown = null;
}
// Remove event listeners would be added here if we tracked them

View File

@@ -1,4 +1,4 @@
import { createToggle, createArrowButton, PreviewTooltip, createDragHandle, updateEntrySelection, createExpandButton, updateExpandButtonState } from "./loras_widget_components.js";
import { createToggle, createArrowButton, createDragHandle, updateEntrySelection, createExpandButton, updateExpandButtonState } from "./loras_widget_components.js";
import {
parseLoraValue,
formatLoraValue,
@@ -12,6 +12,7 @@ import {
} from "./loras_widget_utils.js";
import { initDrag, createContextMenu, initHeaderDrag, initReorderDrag, handleKeyboardNavigation } from "./loras_widget_events.js";
import { forwardMiddleMouseToCanvas } from "./utils.js";
import { PreviewTooltip } from "./preview_tooltip.js";
export function addLorasWidget(node, name, opts, callback) {
// Create container for loras
@@ -39,7 +40,7 @@ export function addLorasWidget(node, name, opts, callback) {
const defaultValue = opts?.defaultVal || [];
// Create preview tooltip instance
const previewTooltip = new PreviewTooltip();
const previewTooltip = new PreviewTooltip({ modelType: "loras" });
// Selection state - only one LoRA can be selected at a time
let selectedLora = null;
@@ -705,4 +706,4 @@ export function addLorasWidget(node, name, opts, callback) {
};
return { minWidth: 400, minHeight: defaultHeight, widget };
}
}

View File

@@ -1,5 +1,3 @@
import { api } from "../../scripts/api.js";
// Function to create toggle element
export function createToggle(active, onChange) {
const toggle = document.createElement("div");
@@ -217,229 +215,6 @@ export function createMenuItem(text, icon, onClick) {
return menuItem;
}
// Preview tooltip class
export class PreviewTooltip {
constructor() {
this.element = document.createElement('div');
Object.assign(this.element.style, {
position: 'fixed',
zIndex: 9999,
background: 'rgba(0, 0, 0, 0.85)',
borderRadius: '6px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
display: 'none',
overflow: 'hidden',
maxWidth: '300px',
pointerEvents: 'none', // Prevent interference with autocomplete
});
document.body.appendChild(this.element);
this.hideTimeout = null;
this.isFromAutocomplete = false;
// Modified event listeners for autocomplete compatibility
this.globalClickHandler = (e) => {
// Don't hide if click is on autocomplete dropdown
if (!e.target.closest('.comfy-autocomplete-dropdown')) {
this.hide();
}
};
document.addEventListener('click', this.globalClickHandler);
this.globalScrollHandler = () => this.hide();
document.addEventListener('scroll', this.globalScrollHandler, true);
}
async show(loraName, x, y, fromAutocomplete = false) {
try {
// Clear previous hide timer
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
this.hideTimeout = null;
}
// Track if this is from autocomplete
this.isFromAutocomplete = fromAutocomplete;
// Don't redisplay the same lora preview
if (this.element.style.display === 'block' && this.currentLora === loraName) {
this.position(x, y);
return;
}
this.currentLora = loraName;
// Get preview URL
const response = await api.fetchApi(`/lm/loras/preview-url?name=${encodeURIComponent(loraName)}`, {
method: 'GET'
});
if (!response.ok) {
throw new Error('Failed to fetch preview URL');
}
const data = await response.json();
if (!data.success || !data.preview_url) {
throw new Error('No preview available');
}
// Clear existing content
while (this.element.firstChild) {
this.element.removeChild(this.element.firstChild);
}
// Create media container with relative positioning
const mediaContainer = document.createElement('div');
Object.assign(mediaContainer.style, {
position: 'relative',
maxWidth: '300px',
maxHeight: '300px',
});
const isVideo = data.preview_url.endsWith('.mp4');
const mediaElement = isVideo ? document.createElement('video') : document.createElement('img');
Object.assign(mediaElement.style, {
maxWidth: '300px',
maxHeight: '300px',
objectFit: 'contain',
display: 'block',
});
if (isVideo) {
mediaElement.autoplay = true;
mediaElement.loop = true;
mediaElement.muted = true;
mediaElement.controls = false;
}
// Create name label with absolute positioning
const nameLabel = document.createElement('div');
nameLabel.textContent = loraName;
Object.assign(nameLabel.style, {
position: 'absolute',
bottom: '0',
left: '0',
right: '0',
padding: '8px',
color: 'white',
fontSize: '13px',
fontFamily: "'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif",
background: 'linear-gradient(transparent, rgba(0, 0, 0, 0.8))',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
textAlign: 'center',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
});
mediaContainer.appendChild(mediaElement);
mediaContainer.appendChild(nameLabel);
this.element.appendChild(mediaContainer);
// Show element with opacity 0 first to get dimensions
this.element.style.opacity = '0';
this.element.style.display = 'block';
// Wait for media to load before positioning
const waitForLoad = () => {
return new Promise((resolve) => {
if (isVideo) {
if (mediaElement.readyState >= 2) { // HAVE_CURRENT_DATA
resolve();
} else {
mediaElement.addEventListener('loadeddata', resolve, { once: true });
mediaElement.addEventListener('error', resolve, { once: true });
}
} else {
if (mediaElement.complete) {
resolve();
} else {
mediaElement.addEventListener('load', resolve, { once: true });
mediaElement.addEventListener('error', resolve, { once: true });
}
}
// Set a timeout to prevent hanging
setTimeout(resolve, 1000);
});
};
// Set source after setting up load listeners
mediaElement.src = data.preview_url;
// Wait for content to load, then position and show
await waitForLoad();
// Small delay to ensure layout is complete
requestAnimationFrame(() => {
this.position(x, y);
this.element.style.transition = 'opacity 0.15s ease';
this.element.style.opacity = '1';
});
} catch (error) {
console.warn('Failed to load preview:', error);
}
}
position(x, y) {
// Ensure preview box doesn't exceed viewport boundaries
const rect = this.element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = x + 10; // Default 10px offset to the right of mouse
let top = y + 10; // Default 10px offset below mouse
// Check right boundary
if (left + rect.width > viewportWidth) {
left = x - rect.width - 10;
}
// Check bottom boundary
if (top + rect.height > viewportHeight) {
top = y - rect.height - 10;
}
// Ensure minimum distance from edges
left = Math.max(10, Math.min(left, viewportWidth - rect.width - 10));
top = Math.max(10, Math.min(top, viewportHeight - rect.height - 10));
Object.assign(this.element.style, {
left: `${left}px`,
top: `${top}px`
});
}
hide() {
// Use fade-out effect
if (this.element.style.display === 'block') {
this.element.style.opacity = '0';
this.hideTimeout = setTimeout(() => {
this.element.style.display = 'none';
this.currentLora = null;
this.isFromAutocomplete = false;
// Stop video playback
const video = this.element.querySelector('video');
if (video) {
video.pause();
}
this.hideTimeout = null;
}, 150);
}
}
cleanup() {
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
}
// Remove event listeners properly
document.removeEventListener('click', this.globalClickHandler);
document.removeEventListener('scroll', this.globalScrollHandler, true);
this.element.remove();
}
}
// Function to create expand/collapse button
export function createExpandButton(isExpanded, onClick) {
const button = document.createElement("button");

View File

@@ -0,0 +1,258 @@
import { api } from "../../scripts/api.js";
/**
* Lightweight preview tooltip that can display images or videos for different model types.
*/
export class PreviewTooltip {
constructor(options = {}) {
const {
modelType = "loras",
previewUrlResolver,
displayNameFormatter,
} = options;
this.modelType = modelType;
this.previewUrlResolver =
typeof previewUrlResolver === "function"
? previewUrlResolver
: (name) => this.defaultPreviewUrlResolver(name);
this.displayNameFormatter =
typeof displayNameFormatter === "function"
? displayNameFormatter
: (name) => name;
this.element = document.createElement("div");
Object.assign(this.element.style, {
position: "fixed",
zIndex: 9999,
background: "rgba(0, 0, 0, 0.85)",
borderRadius: "6px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
display: "none",
overflow: "hidden",
maxWidth: "300px",
pointerEvents: "none",
});
document.body.appendChild(this.element);
this.hideTimeout = null;
this.isFromAutocomplete = false;
this.currentModelName = null;
this.globalClickHandler = (event) => {
if (!event.target.closest(".comfy-autocomplete-dropdown")) {
this.hide();
}
};
document.addEventListener("click", this.globalClickHandler);
this.globalScrollHandler = () => this.hide();
document.addEventListener("scroll", this.globalScrollHandler, true);
}
async defaultPreviewUrlResolver(modelName) {
const response = await api.fetchApi(
`/lm/${this.modelType}/preview-url?name=${encodeURIComponent(modelName)}`,
{ method: "GET" }
);
if (!response.ok) {
throw new Error("Failed to fetch preview URL");
}
const data = await response.json();
if (!data.success || !data.preview_url) {
throw new Error("No preview available");
}
return {
previewUrl: data.preview_url,
displayName: data.display_name ?? modelName,
};
}
async resolvePreviewData(modelName) {
const raw = await this.previewUrlResolver(modelName);
if (!raw) {
throw new Error("No preview data returned");
}
if (typeof raw === "string") {
return {
previewUrl: raw,
displayName: this.displayNameFormatter(modelName),
};
}
const { previewUrl, displayName } = raw;
if (!previewUrl) {
throw new Error("No preview URL available");
}
return {
previewUrl,
displayName:
displayName !== undefined
? displayName
: this.displayNameFormatter(modelName),
};
}
async show(modelName, x, y, fromAutocomplete = false) {
try {
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
this.hideTimeout = null;
}
this.isFromAutocomplete = fromAutocomplete;
if (
this.element.style.display === "block" &&
this.currentModelName === modelName
) {
this.position(x, y);
return;
}
this.currentModelName = modelName;
const { previewUrl, displayName } = await this.resolvePreviewData(
modelName
);
while (this.element.firstChild) {
this.element.removeChild(this.element.firstChild);
}
const mediaContainer = document.createElement("div");
Object.assign(mediaContainer.style, {
position: "relative",
maxWidth: "300px",
maxHeight: "300px",
});
const isVideo = previewUrl.endsWith(".mp4");
const mediaElement = isVideo
? document.createElement("video")
: document.createElement("img");
Object.assign(mediaElement.style, {
maxWidth: "300px",
maxHeight: "300px",
objectFit: "contain",
display: "block",
});
if (isVideo) {
mediaElement.autoplay = true;
mediaElement.loop = true;
mediaElement.muted = true;
mediaElement.controls = false;
}
const nameLabel = document.createElement("div");
nameLabel.textContent = displayName;
Object.assign(nameLabel.style, {
position: "absolute",
bottom: "0",
left: "0",
right: "0",
padding: "8px",
color: "white",
fontSize: "13px",
fontFamily:
"'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif",
background: "linear-gradient(transparent, rgba(0, 0, 0, 0.8))",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
textAlign: "center",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
});
mediaContainer.appendChild(mediaElement);
mediaContainer.appendChild(nameLabel);
this.element.appendChild(mediaContainer);
this.element.style.opacity = "0";
this.element.style.display = "block";
const waitForLoad = () =>
new Promise((resolve) => {
if (isVideo) {
if (mediaElement.readyState >= 2) {
resolve();
} else {
mediaElement.addEventListener("loadeddata", resolve, {
once: true,
});
mediaElement.addEventListener("error", resolve, { once: true });
}
} else if (mediaElement.complete) {
resolve();
} else {
mediaElement.addEventListener("load", resolve, { once: true });
mediaElement.addEventListener("error", resolve, { once: true });
}
setTimeout(resolve, 1000);
});
mediaElement.src = previewUrl;
await waitForLoad();
requestAnimationFrame(() => {
this.position(x, y);
this.element.style.transition = "opacity 0.15s ease";
this.element.style.opacity = "1";
});
} catch (error) {
console.warn("Failed to load preview:", error);
}
}
position(x, y) {
const rect = this.element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = x + 10;
let top = y + 10;
if (left + rect.width > viewportWidth) {
left = x - rect.width - 10;
}
if (top + rect.height > viewportHeight) {
top = y - rect.height - 10;
}
left = Math.max(10, Math.min(left, viewportWidth - rect.width - 10));
top = Math.max(10, Math.min(top, viewportHeight - rect.height - 10));
Object.assign(this.element.style, {
left: `${left}px`,
top: `${top}px`,
});
}
hide() {
if (this.element.style.display === "block") {
this.element.style.opacity = "0";
this.hideTimeout = setTimeout(() => {
this.element.style.display = "none";
this.currentModelName = null;
this.isFromAutocomplete = false;
const video = this.element.querySelector("video");
if (video) {
video.pause();
}
this.hideTimeout = null;
}, 150);
}
}
cleanup() {
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
}
document.removeEventListener("click", this.globalClickHandler);
document.removeEventListener("scroll", this.globalScrollHandler, true);
this.element.remove();
}
}

29
web/comfyui/prompt.js Normal file
View File

@@ -0,0 +1,29 @@
import { app } from "../../scripts/app.js";
import { chainCallback, setupInputWidgetWithAutocomplete } from "./utils.js";
app.registerExtension({
name: "LoraManager.Prompt",
async beforeRegisterNodeDef(nodeType) {
if (nodeType.comfyClass === "Prompt (LoraManager)") {
chainCallback(nodeType.prototype, "onNodeCreated", function () {
this.serialize_widgets = true;
const textWidget = this.widgets?.[0];
if (!textWidget) {
return;
}
const originalCallback =
typeof textWidget.callback === "function" ? textWidget.callback : null;
textWidget.callback = setupInputWidgetWithAutocomplete(
this,
textWidget,
originalCallback,
"embeddings"
);
});
}
},
});

View File

@@ -383,27 +383,31 @@ export function mergeLoras(lorasText, lorasArr) {
* @param {Object} node - The node instance
* @param {Object} inputWidget - The input widget to add autocomplete to
* @param {Function} originalCallback - The original callback function
* @param {string} [modelType='loras'] - The model type used by the autocomplete API
* @param {Object} [autocompleteOptions] - Additional options for the autocomplete instance
* @returns {Function} Enhanced callback function with autocomplete
*/
export function setupInputWidgetWithAutocomplete(node, inputWidget, originalCallback) {
export function setupInputWidgetWithAutocomplete(node, inputWidget, originalCallback, modelType = 'loras', autocompleteOptions = {}) {
let autocomplete = null;
const defaultOptions = {
maxItems: 20,
minChars: 1,
debounceDelay: 200,
};
const mergedOptions = { ...defaultOptions, ...autocompleteOptions };
// Enhanced callback that initializes autocomplete and calls original callback
const enhancedCallback = (value) => {
// Initialize autocomplete on first callback if not already done
if (!autocomplete && inputWidget.inputEl) {
autocomplete = new AutoComplete(inputWidget.inputEl, 'loras', {
maxItems: 20,
minChars: 1,
debounceDelay: 200
});
autocomplete = new AutoComplete(inputWidget.inputEl, modelType, mergedOptions);
// Store reference for cleanup
node.autocomplete = autocomplete;
}
// Call the original callback
if (originalCallback) {
originalCallback(value);
if (typeof originalCallback === "function") {
originalCallback.call(node, value);
}
};
@@ -457,4 +461,4 @@ export function forwardMiddleMouseToCanvas(container) {
app.canvas.processMouseUp(event);
}
});
}
}