Files
ComfyUI-Lora-Manager/web/comfyui/autocomplete.js
Will Miao 6142b3dc0c feat: consolidate ComfyUI settings and add custom words autocomplete toggle
Create unified settings.js extension to centralize all Lora Manager ComfyUI
settings registration, eliminating code duplication across multiple files.

Add new setting "Enable Custom Words Autocomplete in Prompt Nodes" (enabled
by default) to control custom words autocomplete in prompt node text widgets.
When disabled, only 'emb:' prefix triggers embeddings autocomplete.

Changes:
- Create web/comfyui/settings.js with all three settings:
  * Trigger Word Wheel Sensitivity (existing)
  * Auto path correction (existing)
  * Enable Custom Words Autocomplete in Prompt Nodes (new)
- Refactor autocomplete.js to respect the new setting
- Update trigger_word_toggle.js to import from settings.js
- Update usage_stats.js to import from settings.js
2026-01-25 12:53:41 +08:00

841 lines
28 KiB
JavaScript

import { api } from "../../scripts/api.js";
import { app } from "../../scripts/app.js";
import { TextAreaCaretHelper } from "./textarea_caret_helper.js";
import { getPromptCustomWordsAutocompletePreference } from "./settings.js";
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;
}
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 parseSearchTokens(term = '') {
const include = [];
const exclude = [];
term.split(/\s+/).forEach((rawTerm) => {
const token = rawTerm.trim();
if (!token) {
return;
}
if (token.startsWith('-') && token.length > 1) {
exclude.push(token.slice(1).toLowerCase());
} else {
include.push(token.toLowerCase());
}
});
return { include, exclude };
}
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}, `;
},
},
custom_words: {
enablePreview: false,
async getInsertText(_instance, relativePath) {
return `${relativePath}, `;
},
},
prompt: {
enablePreview: true,
init(instance) {
if (!instance.options.showPreview) {
return;
}
instance.initPreviewTooltip({ modelType: 'embeddings' });
},
showPreview(instance, relativePath, itemElement) {
if (!instance.previewTooltip || instance.searchType !== 'embeddings') {
return;
}
instance.showPreviewForItem(relativePath, itemElement);
},
hidePreview(instance) {
if (!instance.previewTooltip || instance.searchType !== 'embeddings') {
return;
}
instance.previewTooltip.hide();
},
destroy(instance) {
if (instance.previewTooltip) {
instance.previewTooltip.cleanup();
instance.previewTooltip = null;
}
},
async getInsertText(instance, relativePath) {
const rawSearchTerm = instance.getSearchTerm(instance.inputElement.value);
const match = rawSearchTerm.match(/^emb:(.*)$/i);
if (match) {
const { directories, fileName } = splitRelativePath(relativePath);
const trimmedName = removeGeneralExtension(fileName);
const folder = directories.length ? `${directories.join('\\')}\\` : '';
return `embedding:${folder}${trimmedName}, `;
} else {
return `${relativePath}, `;
}
},
},
};
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: this.behavior.enablePreview ?? false,
...options
};
this.dropdown = null;
this.selectedIndex = -1;
this.items = [];
this.debounceTimer = null;
this.isVisible = false;
this.currentSearchTerm = '';
this.previewTooltip = null;
this.previewTooltipPromise = null;
this.searchType = null;
// Initialize TextAreaCaretHelper
this.helper = new TextAreaCaretHelper(inputElement, () => app.canvas.ds.scale);
this.onInput = null;
this.onKeyDown = null;
this.onBlur = null;
this.onDocumentClick = null;
this.init();
}
init() {
this.createDropdown();
this.bindEvents();
}
createDropdown() {
this.dropdown = document.createElement('div');
this.dropdown.className = 'comfy-autocomplete-dropdown';
// Apply new color scheme
this.dropdown.style.cssText = `
position: absolute;
z-index: 10000;
overflow-y: visible;
background-color: rgba(40, 44, 52, 0.95);
border: 1px solid rgba(226, 232, 240, 0.2);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: none;
min-width: 200px;
width: auto;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
`;
// Custom scrollbar styles with new color scheme
const style = document.createElement('style');
style.textContent = `
.comfy-autocomplete-dropdown::-webkit-scrollbar {
width: 8px;
}
.comfy-autocomplete-dropdown::-webkit-scrollbar-track {
background: rgba(40, 44, 52, 0.3);
border-radius: 4px;
}
.comfy-autocomplete-dropdown::-webkit-scrollbar-thumb {
background: rgba(226, 232, 240, 0.2);
border-radius: 4px;
}
.comfy-autocomplete-dropdown::-webkit-scrollbar-thumb:hover {
background: rgba(226, 232, 240, 0.4);
}
`;
document.head.appendChild(style);
// Append to body to avoid overflow issues
document.body.appendChild(this.dropdown);
if (typeof this.behavior.init === 'function') {
this.behavior.init(this);
}
}
initPreviewTooltip(options = {}) {
if (this.previewTooltip || this.previewTooltipPromise) {
return;
}
// Dynamically import and create preview tooltip
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;
});
}
bindEvents() {
// Handle input changes
this.onInput = (e) => {
this.handleInput(e.target.value);
};
this.inputElement.addEventListener('input', this.onInput);
// Handle keyboard navigation
this.onKeyDown = (e) => {
this.handleKeyDown(e);
};
this.inputElement.addEventListener('keydown', this.onKeyDown);
// Handle focus out to hide dropdown
this.onBlur = () => {
// Delay hiding to allow for clicks on dropdown items
setTimeout(() => {
this.hide();
}, 150);
};
this.inputElement.addEventListener('blur', this.onBlur);
// Handle clicks outside to hide dropdown
this.onDocumentClick = (e) => {
if (!this.dropdown) {
return;
}
const target = e.target;
if (!(target instanceof Node)) {
return;
}
if (!this.dropdown.contains(target) && target !== this.inputElement) {
this.hide();
}
};
document.addEventListener('click', this.onDocumentClick);
// Mark this element as having autocomplete events bound
this.inputElement._autocompleteEventsBound = true;
}
/**
* Check if the autocomplete is valid (input element is in DOM and events are bound)
*/
isValid() {
return this.inputElement &&
document.body.contains(this.inputElement) &&
this.inputElement._autocompleteEventsBound === true;
}
/**
* Check if events need to be rebound (element exists but events not bound)
*/
needsRebind() {
return this.inputElement &&
document.body.contains(this.inputElement) &&
this.inputElement._autocompleteEventsBound !== true;
}
/**
* Rebind events to the input element (useful after Vue moves the element)
*/
rebindEvents() {
// Remove old listeners if they exist
if (this.onInput) {
this.inputElement.removeEventListener('input', this.onInput);
}
if (this.onKeyDown) {
this.inputElement.removeEventListener('keydown', this.onKeyDown);
}
if (this.onBlur) {
this.inputElement.removeEventListener('blur', this.onBlur);
}
// Rebind all events
this.bindEvents();
console.log('[Lora Manager] Autocomplete events rebound');
}
/**
* Refresh the TextAreaCaretHelper (useful after element properties change)
*/
refreshHelper() {
if (this.inputElement && document.body.contains(this.inputElement)) {
this.helper = new TextAreaCaretHelper(this.inputElement, () => app.canvas.ds.scale);
}
}
handleInput(value = '') {
// Clear previous debounce timer
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
// Get the search term (text after last comma / '>')
const rawSearchTerm = this.getSearchTerm(value);
let searchTerm = rawSearchTerm;
let endpoint = `/lm/${this.modelType}/relative-paths`;
// For embeddings, only trigger autocomplete when the current token
// starts with the explicit "emb:" prefix. This avoids interrupting
// normal prompt typing while still allowing quick manual triggering.
if (this.modelType === 'embeddings') {
const match = rawSearchTerm.match(/^emb:(.*)$/i);
if (!match) {
this.hide();
return;
}
searchTerm = (match[1] || '').trim();
}
// For prompt model type, check if we're searching embeddings or custom words
if (this.modelType === 'prompt') {
const match = rawSearchTerm.match(/^emb:(.*)$/i);
if (match) {
// User typed "emb:" prefix - always allow embeddings search
endpoint = '/lm/embeddings/relative-paths';
searchTerm = (match[1] || '').trim();
this.searchType = 'embeddings';
} else if (getPromptCustomWordsAutocompletePreference()) {
// Setting enabled - allow custom words search
endpoint = '/lm/custom-words/search';
searchTerm = rawSearchTerm;
this.searchType = 'custom_words';
} else {
// Setting disabled - no autocomplete for non-emb: terms
this.hide();
return;
}
}
if (searchTerm.length < this.options.minChars) {
this.hide();
return;
}
// Debounce the search
this.debounceTimer = setTimeout(() => {
this.search(searchTerm, endpoint);
}, this.options.debounceDelay);
}
getSearchTerm(value) {
// Use helper to get text before cursor for more accurate positioning
const beforeCursor = this.helper.getBeforeCursor();
if (!beforeCursor) {
return '';
}
// Split on comma and '>' delimiters only (do not split on spaces)
const segments = beforeCursor.split(/[,\>]+/);
// Return the last non-empty segment as search term
const lastSegment = segments[segments.length - 1] || '';
return lastSegment.trim();
}
async search(term = '', endpoint = null) {
try {
this.currentSearchTerm = term;
if (!endpoint) {
endpoint = `/lm/${this.modelType}/relative-paths`;
}
const url = endpoint.includes('?')
? `${endpoint}&search=${encodeURIComponent(term)}&limit=${this.options.maxItems}`
: `${endpoint}?search=${encodeURIComponent(term)}&limit=${this.options.maxItems}`;
const response = await api.fetchApi(url);
const data = await response.json();
// Support both response formats:
// 1. Model endpoint format: { success: true, relative_paths: [...] }
// 2. Custom words format: { success: true, words: [...] }
if (data.success) {
const items = data.relative_paths || data.words || [];
if (items.length > 0) {
this.items = items;
this.render();
this.show();
} else {
this.items = [];
this.hide();
}
} else {
this.items = [];
this.hide();
}
} catch (error) {
console.error('Autocomplete search error:', error);
this.items = [];
this.hide();
}
}
render() {
this.dropdown.innerHTML = '';
this.selectedIndex = -1;
// Early return if no items to prevent empty dropdown
if (!this.items || this.items.length === 0) {
return;
}
this.items.forEach((relativePath, index) => {
const item = document.createElement('div');
item.className = 'comfy-autocomplete-item';
// Create highlighted content
const highlightedContent = this.highlightMatch(relativePath, this.currentSearchTerm);
item.innerHTML = highlightedContent;
// Apply item styles with new color scheme
item.style.cssText = `
padding: 8px 12px;
cursor: pointer;
color: rgba(226, 232, 240, 0.8);
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
transition: all 0.2s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
`;
// Hover and selection handlers
item.addEventListener('mouseenter', () => {
this.selectItem(index);
});
item.addEventListener('mouseleave', () => {
this.hidePreview();
});
// Click handler
item.addEventListener('click', () => {
this.insertSelection(relativePath);
});
this.dropdown.appendChild(item);
});
// Remove border from last item
if (this.dropdown.lastChild) {
this.dropdown.lastChild.style.borderBottom = 'none';
}
// Auto-select the first item with a small delay
if (this.items.length > 0) {
setTimeout(() => {
this.selectItem(0);
}, 100); // 50ms delay
}
}
highlightMatch(text, searchTerm) {
const { include } = parseSearchTokens(searchTerm);
const sanitizedTokens = include
.filter(Boolean)
.map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
if (!sanitizedTokens.length) {
return text;
}
const regex = new RegExp(`(${sanitizedTokens.join('|')})`, 'gi');
return text.replace(
regex,
'<span style="background-color: rgba(66, 153, 225, 0.3); color: white; padding: 1px 2px; border-radius: 2px;">$1</span>',
);
}
showPreviewForItem(relativePath, itemElement) {
if (!this.options.showPreview || !this.previewTooltip) return;
// Extract filename without extension for preview
const fileName = relativePath.split(/[/\\]/).pop();
const loraName = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
// Get item position for tooltip positioning
const rect = itemElement.getBoundingClientRect();
const x = rect.right + 10;
const y = rect.top;
this.previewTooltip.show(loraName, x, y, true); // Pass true for fromAutocomplete flag
}
hidePreview() {
if (!this.options.showPreview) {
return;
}
if (typeof this.behavior.hidePreview === 'function') {
this.behavior.hidePreview(this);
} else if (this.previewTooltip) {
this.previewTooltip.hide();
}
}
show() {
if (!this.items || this.items.length === 0) {
this.hide();
return;
}
// Position dropdown at cursor position using TextAreaCaretHelper
this.positionAtCursor();
this.dropdown.style.display = 'block';
this.isVisible = true;
}
positionAtCursor() {
const position = this.helper.getCursorOffset();
this.dropdown.style.left = (position.left ?? 0) + "px";
this.dropdown.style.top = (position.top ?? 0) + "px";
this.dropdown.style.maxHeight = (window.innerHeight - position.top) + "px";
// Adjust width to fit content
// Temporarily show the dropdown to measure content width
const originalDisplay = this.dropdown.style.display;
this.dropdown.style.display = 'block';
this.dropdown.style.visibility = 'hidden';
// Measure the content width
let maxWidth = 200; // minimum width
const items = this.dropdown.querySelectorAll('.comfy-autocomplete-item');
items.forEach(item => {
const itemWidth = item.scrollWidth + 24; // Add padding
maxWidth = Math.max(maxWidth, itemWidth);
});
// Set the width and restore visibility
this.dropdown.style.width = Math.min(maxWidth, 400) + 'px'; // Cap at 400px
this.dropdown.style.visibility = 'visible';
this.dropdown.style.display = originalDisplay;
}
getCaretPosition() {
return this.inputElement.selectionStart || 0;
}
hide() {
if (!this.dropdown) {
return;
}
this.dropdown.style.display = 'none';
this.isVisible = false;
this.selectedIndex = -1;
// Hide preview tooltip
this.hidePreview();
// Clear selection styles from all items
const items = this.dropdown.querySelectorAll('.comfy-autocomplete-item');
items.forEach(item => {
item.classList.remove('comfy-autocomplete-item-selected');
item.style.backgroundColor = '';
});
}
selectItem(index) {
// Remove previous selection
const prevSelected = this.dropdown.querySelector('.comfy-autocomplete-item-selected');
if (prevSelected) {
prevSelected.classList.remove('comfy-autocomplete-item-selected');
prevSelected.style.backgroundColor = '';
}
// Add new selection
if (index >= 0 && index < this.items.length) {
this.selectedIndex = index;
const item = this.dropdown.children[index];
item.classList.add('comfy-autocomplete-item-selected');
item.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
// Scroll into view if needed
item.scrollIntoView({ block: 'nearest' });
// Show preview for selected item
if (this.options.showPreview) {
if (typeof this.behavior.showPreview === 'function') {
this.behavior.showPreview(this, this.items[index], item);
} else if (this.previewTooltip) {
this.showPreviewForItem(this.items[index], item);
}
}
}
}
handleKeyDown(e) {
if (!this.isVisible) {
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
this.selectItem(Math.max(this.selectedIndex - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
this.insertSelection(this.items[this.selectedIndex]);
}
break;
case 'Escape':
e.preventDefault();
this.hide();
break;
}
}
async insertSelection(relativePath) {
const insertText = await this.getInsertText(relativePath);
if (!insertText) {
this.hide();
return;
}
const currentValue = this.inputElement.value;
const caretPos = this.getCaretPosition();
// Use getSearchTerm to get the current search term before cursor
const beforeCursor = currentValue.substring(0, caretPos);
const searchTerm = this.getSearchTerm(beforeCursor);
const searchStartPos = caretPos - searchTerm.length;
// Only replace the search term, not everything after the last comma
const newValue = currentValue.substring(0, searchStartPos) + insertText + currentValue.substring(caretPos);
const newCaretPos = searchStartPos + insertText.length;
this.inputElement.value = newValue;
// Trigger input event to notify about the change
const event = new Event('input', { bubbles: true });
this.inputElement.dispatchEvent(event);
this.hide();
// Focus back to input and position cursor
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}, `;
}
/**
* Check if the autocomplete instance is still valid
* (input element exists and is in the DOM)
* @returns {boolean}
*/
isValid() {
return this.inputElement && document.body.contains(this.inputElement);
}
/**
* Refresh the TextAreaCaretHelper to update cached measurements
* Useful after element is moved in DOM (e.g., Vue mode switch)
*/
refreshCaretHelper() {
if (this.inputElement && document.body.contains(this.inputElement)) {
this.helper = new TextAreaCaretHelper(this.inputElement, () => app.canvas.ds.scale);
}
}
destroy() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (this.onInput) {
this.inputElement.removeEventListener('input', this.onInput);
this.onInput = null;
}
if (this.onKeyDown) {
this.inputElement.removeEventListener('keydown', this.onKeyDown);
this.onKeyDown = null;
}
if (this.onBlur) {
this.inputElement.removeEventListener('blur', this.onBlur);
this.onBlur = null;
}
if (this.onDocumentClick) {
document.removeEventListener('click', this.onDocumentClick);
this.onDocumentClick = null;
}
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;
}
}
}
export { AutoComplete };