mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-24 06:21:26 -03:00
074d1f2e51
- Add Tag Autocomplete ON/OFF entry to the Prompt (LoraManager) node right-click menu, cross-referencing the slash commands - Show the current autocomplete state (/autocomplete or /noautocomplete hint) below the slash command list - Show a one-time dismissible tip in the suggestion dropdown on first use - Clarify toggle command labels (Turn autocomplete ON/OFF) and cross-link all three entry points in the settings tooltip - Share the setting write path via setLoraManagerSettingValue()
3135 lines
115 KiB
JavaScript
3135 lines
115 KiB
JavaScript
import { api } from "../../scripts/api.js";
|
||
import { app } from "../../scripts/app.js";
|
||
import { TextAreaCaretHelper } from "./textarea_caret_helper.js";
|
||
import {
|
||
WILDCARD_COMMANDS,
|
||
createWildcardEmptyStateItem,
|
||
createWildcardNoMatchesItem,
|
||
getWildcardInsertText,
|
||
getWildcardSearchEndpoint,
|
||
isWildcardCommand,
|
||
isWildcardInfoItem,
|
||
} from "./autocomplete_wildcards.js";
|
||
import {
|
||
getAutocompleteAppendCommaPreference,
|
||
getAutocompleteAutoFormatPreference,
|
||
getAutocompleteAcceptKeyPreference,
|
||
getLoraActiveFiltersAutocompletePreference,
|
||
getPromptTagAutocompletePreference,
|
||
getTagSpaceReplacementPreference,
|
||
setLoraManagerSettingValue,
|
||
} from "./settings.js";
|
||
import { showToast } from "./utils.js";
|
||
|
||
// localStorage key for the one-time "how to disable" hint in the dropdown
|
||
const FIRST_RUN_HINT_DISMISSED_KEY = 'lm:autocomplete-disable-tip-dismissed';
|
||
|
||
// Command definitions for category filtering
|
||
const TAG_COMMANDS = {
|
||
'/character': { categories: [4, 11], label: 'Character' },
|
||
'/artist': { categories: [1, 8], label: 'Artist' },
|
||
'/general': { categories: [0, 7], label: 'General' },
|
||
'/copyright': { categories: [3, 10], label: 'Copyright' },
|
||
'/meta': { categories: [5, 14], label: 'Meta' },
|
||
'/species': { categories: [12], label: 'Species' },
|
||
'/lore': { categories: [15], label: 'Lore' },
|
||
'/emb': { type: 'embedding', label: 'Embeddings' },
|
||
'/embedding': { type: 'embedding', label: 'Embeddings' },
|
||
...WILDCARD_COMMANDS,
|
||
// Autocomplete toggle commands - only show one based on current state
|
||
'/autocomplete': {
|
||
type: 'toggle_setting',
|
||
settingId: 'loramanager.prompt_tag_autocomplete',
|
||
value: true,
|
||
label: 'Turn autocomplete ON',
|
||
condition: () => !getPromptTagAutocompletePreference()
|
||
},
|
||
'/noautocomplete': {
|
||
type: 'toggle_setting',
|
||
settingId: 'loramanager.prompt_tag_autocomplete',
|
||
value: false,
|
||
label: 'Turn autocomplete OFF',
|
||
condition: () => getPromptTagAutocompletePreference()
|
||
},
|
||
};
|
||
|
||
// Command definitions for LoRA active-filters search
|
||
const LORAS_COMMANDS = {
|
||
'/activefilters': {
|
||
type: 'toggle_setting',
|
||
settingId: 'loramanager.lora_active_filters_autocomplete',
|
||
value: true,
|
||
label: 'Turn active filters search ON',
|
||
feedbackSummary: 'Active Filters Search: ON',
|
||
feedbackDetail: 'LoRA autocomplete now searches within the active filters of the LoRA Manager page.',
|
||
condition: () => !getLoraActiveFiltersAutocompletePreference()
|
||
},
|
||
'/noactivefilters': {
|
||
type: 'toggle_setting',
|
||
settingId: 'loramanager.lora_active_filters_autocomplete',
|
||
value: false,
|
||
label: 'Turn active filters search OFF',
|
||
feedbackSummary: 'Active Filters Search: OFF',
|
||
feedbackDetail: 'LoRA autocomplete searches the full library again.',
|
||
condition: () => getLoraActiveFiltersAutocompletePreference()
|
||
},
|
||
};
|
||
|
||
// Category display information
|
||
const CATEGORY_INFO = { 0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' },
|
||
1: { bg: 'rgba(255, 138, 139, 0.2)', text: '#ffc3c3', label: 'Artist' },
|
||
3: { bg: 'rgba(199, 151, 255, 0.2)', text: '#ddc9fb', label: 'Copyright' },
|
||
4: { bg: 'rgba(53, 198, 74, 0.2)', text: '#93e49a', label: 'Character' },
|
||
5: { bg: 'rgba(234, 208, 132, 0.2)', text: '#f7e7c3', label: 'Meta' },
|
||
7: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' },
|
||
8: { bg: 'rgba(255, 138, 139, 0.2)', text: '#ffc3c3', label: 'Artist' },
|
||
10: { bg: 'rgba(199, 151, 255, 0.2)', text: '#ddc9fb', label: 'Copyright' },
|
||
11: { bg: 'rgba(53, 198, 74, 0.2)', text: '#93e49a', label: 'Character' },
|
||
12: { bg: 'rgba(237, 137, 54, 0.2)', text: '#f6ad55', label: 'Species' },
|
||
14: { bg: 'rgba(234, 208, 132, 0.2)', text: '#f7e7c3', label: 'Meta' },
|
||
15: { bg: 'rgba(72, 187, 120, 0.2)', text: '#68d391', label: 'Lore' },
|
||
};
|
||
|
||
// Format post count with K/M suffix
|
||
function formatPostCount(count) {
|
||
if (count >= 1000000) {
|
||
return (count / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
||
} else if (count >= 1000) {
|
||
return (count / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
|
||
}
|
||
return count.toString();
|
||
}
|
||
|
||
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, '');
|
||
}
|
||
|
||
let _loraSyntaxFormatCache = null;
|
||
let _loraSyntaxFormatRefreshPromise = null;
|
||
|
||
function _getLoraSyntaxFormat() {
|
||
if (_loraSyntaxFormatCache !== null) {
|
||
return _loraSyntaxFormatCache;
|
||
}
|
||
return 'legacy';
|
||
}
|
||
|
||
async function _fetchLoraSyntaxFormat() {
|
||
try {
|
||
const response = await api.fetchApi('/lm/settings');
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
if (data.success && data.settings) {
|
||
_loraSyntaxFormatCache = data.settings.lora_syntax_format || 'legacy';
|
||
return;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
}
|
||
if (_loraSyntaxFormatCache === null) {
|
||
_loraSyntaxFormatCache = 'legacy';
|
||
}
|
||
}
|
||
|
||
function _triggerBackgroundRefresh() {
|
||
if (_loraSyntaxFormatRefreshPromise) {
|
||
return;
|
||
}
|
||
_loraSyntaxFormatRefreshPromise = _fetchLoraSyntaxFormat().finally(() => {
|
||
_loraSyntaxFormatRefreshPromise = null;
|
||
});
|
||
}
|
||
|
||
async function refreshLoraSyntaxFormat() {
|
||
await _fetchLoraSyntaxFormat();
|
||
}
|
||
|
||
function _initLoraSyntaxFormat() {
|
||
_triggerBackgroundRefresh();
|
||
}
|
||
_initLoraSyntaxFormat();
|
||
|
||
function _initLoraSyntaxFormatReactive() {
|
||
window.addEventListener('storage', (e) => {
|
||
if (e.key === 'lm:lora-syntax-format-changed') {
|
||
_triggerBackgroundRefresh();
|
||
}
|
||
});
|
||
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'visible') {
|
||
_triggerBackgroundRefresh();
|
||
}
|
||
});
|
||
}
|
||
_initLoraSyntaxFormatReactive();
|
||
|
||
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 escapePromptParentheses(text) {
|
||
// In ComfyUI's CLIP text encoder, bare parentheses are weight adjustment syntax.
|
||
// Tags containing literal parentheses must be escaped with backslash to prevent
|
||
// them from being interpreted as weight modifiers. e.g. "foo (bar)" → "foo \(bar\)"
|
||
return text.replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||
}
|
||
|
||
function formatAutocompleteInsertion(text = '') {
|
||
const trimmed = typeof text === 'string' ? text.trim() : '';
|
||
if (!trimmed) {
|
||
return '';
|
||
}
|
||
|
||
return getAutocompleteAppendCommaPreference() ? `${trimmed},` : `${trimmed} `;
|
||
}
|
||
|
||
function normalizeAutocompleteSegment(segment = '') {
|
||
return segment.replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
export function formatAutocompleteTextOnBlur(text = '') {
|
||
if (typeof text !== 'string') {
|
||
return '';
|
||
}
|
||
|
||
return text
|
||
.split('\n')
|
||
.map((line) => {
|
||
if (!line.trim()) {
|
||
return '';
|
||
}
|
||
|
||
const cleanedSegments = line
|
||
.split(',')
|
||
.map(normalizeAutocompleteSegment)
|
||
.filter(Boolean);
|
||
|
||
return cleanedSegments.join(', ');
|
||
})
|
||
.join('\n');
|
||
}
|
||
|
||
function shouldAcceptAutocompleteKey(key) {
|
||
const mode = getAutocompleteAcceptKeyPreference();
|
||
|
||
if (mode === 'tab_only') {
|
||
return key === 'Tab';
|
||
}
|
||
|
||
if (mode === 'enter_only') {
|
||
return key === 'Enter';
|
||
}
|
||
|
||
return key === 'Tab' || key === 'Enter';
|
||
}
|
||
|
||
function normalizeAutocompleteMatchText(text = '') {
|
||
return text.toLowerCase().replace(/[-_\s']/g, '');
|
||
}
|
||
|
||
const AUTOCOMPLETE_METADATA_VERSION = 1;
|
||
|
||
function createAutocompleteMetadataBase(textWidgetName = 'text') {
|
||
return {
|
||
version: AUTOCOMPLETE_METADATA_VERSION,
|
||
textWidgetName,
|
||
};
|
||
}
|
||
|
||
function createDefaultBehavior(modelType) {
|
||
return {
|
||
enablePreview: false,
|
||
async getInsertText(_instance, relativePath) {
|
||
const trimmed = relativePath?.trim() ?? '';
|
||
if (!trimmed) {
|
||
return '';
|
||
}
|
||
return formatAutocompleteInsertion(escapePromptParentheses(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 { directories, fileName } = splitRelativePath(relativePath);
|
||
const baseName = removeLoraExtension(fileName);
|
||
const folder = directories.length ? directories.join('/') + '/' : '';
|
||
const loraName = folder + baseName;
|
||
|
||
const resultName = _getLoraSyntaxFormat() === 'legacy'
|
||
? baseName
|
||
: loraName;
|
||
|
||
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 formatAutocompleteInsertion(`<lora:${resultName}:${strength}:${clipStrength}>`);
|
||
}
|
||
return formatAutocompleteInsertion(`<lora:${resultName}:${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 formatAutocompleteInsertion(`embedding:${folder}${trimmedName}`);
|
||
},
|
||
},
|
||
custom_words: {
|
||
enablePreview: false,
|
||
async getInsertText(_instance, relativePath) {
|
||
return formatAutocompleteInsertion(escapePromptParentheses(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) {
|
||
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 || instance.searchType === 'embeddings') {
|
||
const { directories, fileName } = splitRelativePath(relativePath);
|
||
const trimmedName = removeGeneralExtension(fileName);
|
||
const folder = directories.length ? `${directories.join('/')}/` : '';
|
||
return formatAutocompleteInsertion(`embedding:${folder}${trimmedName}`);
|
||
} else if (instance.searchType === 'wildcards' || isWildcardCommand(instance.activeCommand)) {
|
||
return formatAutocompleteInsertion(getWildcardInsertText(relativePath));
|
||
} else {
|
||
let tagText = relativePath;
|
||
|
||
if (getTagSpaceReplacementPreference()) {
|
||
tagText = tagText.replace(/_/g, ' ');
|
||
}
|
||
|
||
tagText = escapePromptParentheses(tagText);
|
||
|
||
return formatAutocompleteInsertion(tagText);
|
||
}
|
||
},
|
||
},
|
||
};
|
||
|
||
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: 100,
|
||
pageSize: 20,
|
||
visibleItems: 15, // Fixed at 15 items for balanced UX
|
||
itemHeight: 40,
|
||
minChars: 1,
|
||
debounceDelay: 200,
|
||
showPreview: this.behavior.enablePreview ?? false,
|
||
enableVirtualScroll: true,
|
||
...options
|
||
};
|
||
|
||
this.dropdown = null;
|
||
this.selectedIndex = -1;
|
||
this.hasManualSelection = false;
|
||
this.items = [];
|
||
this.debounceTimer = null;
|
||
this.isVisible = false;
|
||
this.currentSearchTerm = '';
|
||
this.wildcardMeta = null;
|
||
this.previewTooltip = null;
|
||
this.previewTooltipPromise = null;
|
||
this.searchType = null;
|
||
this.suppressAutocompleteOnce = false;
|
||
|
||
// Discoverability hints state
|
||
this.commandListFooter = null; // State hint shown below the slash command list
|
||
this.firstRunHint = null; // One-time "how to disable" bar inside the dropdown
|
||
|
||
// Virtual scrolling state
|
||
this.virtualScrollOffset = 0;
|
||
this.hasMoreItems = true;
|
||
this.isLoadingMore = false;
|
||
this.currentPage = 0;
|
||
this.scrollContainer = null;
|
||
this.contentContainer = null;
|
||
this.totalHeight = 0;
|
||
|
||
// Command mode state
|
||
this.activeCommand = null; // Current active command (e.g., { categories: [4, 11], label: 'Character' })
|
||
this.showingCommands = false; // Whether showing command list dropdown
|
||
|
||
// Initialize TextAreaCaretHelper
|
||
this.helper = new TextAreaCaretHelper(inputElement, () => app.canvas.ds.scale);
|
||
|
||
this.onInput = null;
|
||
this.onKeyDown = null;
|
||
this.onBlur = null;
|
||
this.onDocumentClick = null;
|
||
this.onScroll = 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: hidden;
|
||
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);
|
||
`;
|
||
|
||
if (this.options.enableVirtualScroll) {
|
||
// Create scroll container for virtual scrolling
|
||
this.scrollContainer = document.createElement('div');
|
||
this.scrollContainer.className = 'comfy-autocomplete-scroll-container';
|
||
this.scrollContainer.style.cssText = `
|
||
overflow-y: auto;
|
||
max-height: ${this.options.visibleItems * this.options.itemHeight}px;
|
||
position: relative;
|
||
`;
|
||
|
||
// Create content container for virtual items
|
||
this.contentContainer = document.createElement('div');
|
||
this.contentContainer.className = 'comfy-autocomplete-content';
|
||
this.contentContainer.style.cssText = `
|
||
position: relative;
|
||
width: 100%;
|
||
`;
|
||
|
||
this.scrollContainer.appendChild(this.contentContainer);
|
||
this.dropdown.appendChild(this.scrollContainer);
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
.comfy-autocomplete-scroll-container::-webkit-scrollbar {
|
||
width: 8px;
|
||
}
|
||
.comfy-autocomplete-scroll-container::-webkit-scrollbar-track {
|
||
background: rgba(40, 44, 52, 0.3);
|
||
border-radius: 4px;
|
||
}
|
||
.comfy-autocomplete-scroll-container::-webkit-scrollbar-thumb {
|
||
background: rgba(226, 232, 240, 0.2);
|
||
border-radius: 4px;
|
||
}
|
||
.comfy-autocomplete-scroll-container::-webkit-scrollbar-thumb:hover {
|
||
background: rgba(226, 232, 240, 0.4);
|
||
}
|
||
.comfy-autocomplete-loading {
|
||
padding: 12px;
|
||
text-align: center;
|
||
color: rgba(226, 232, 240, 0.5);
|
||
font-size: 12px;
|
||
}
|
||
`;
|
||
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) => {
|
||
if (this.suppressAutocompleteOnce) {
|
||
this.suppressAutocompleteOnce = false;
|
||
this.hide();
|
||
return;
|
||
}
|
||
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 = () => {
|
||
if (getAutocompleteAutoFormatPreference()) {
|
||
const formattedValue = formatAutocompleteTextOnBlur(this.inputElement.value);
|
||
if (formattedValue !== this.inputElement.value) {
|
||
this.inputElement.value = formattedValue;
|
||
this.suppressAutocompleteOnce = true;
|
||
this.inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
|
||
// Bind scroll event for virtual scrolling
|
||
if (this.options.enableVirtualScroll && this.scrollContainer) {
|
||
this.onScroll = () => {
|
||
this.handleScroll();
|
||
};
|
||
this.scrollContainer.addEventListener('scroll', this.onScroll);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 loras model type, check if we're in command mode (/activefilters, /noactivefilters)
|
||
if (this.modelType === 'loras') {
|
||
const commandResult = this._parseCommandInput(rawSearchTerm);
|
||
|
||
if (commandResult.showCommands) {
|
||
// Show command list dropdown
|
||
this.showingCommands = true;
|
||
this.activeCommand = null;
|
||
this.searchType = 'commands';
|
||
this._showCommandList(commandResult.commandFilter);
|
||
return;
|
||
} else if (commandResult.command?.type === 'toggle_setting') {
|
||
// Handle toggle setting command (/activefilters, /noactivefilters)
|
||
this._handleToggleSettingCommand(commandResult.command);
|
||
return;
|
||
} else if (commandResult.command) {
|
||
// Command is active, use filtered search
|
||
this.showingCommands = false;
|
||
this.activeCommand = null;
|
||
this.searchType = null;
|
||
searchTerm = commandResult.searchTerm || rawSearchTerm;
|
||
} else {
|
||
// No command - regular lora search
|
||
this.showingCommands = false;
|
||
this.activeCommand = null;
|
||
this.searchType = null;
|
||
searchTerm = rawSearchTerm;
|
||
}
|
||
}
|
||
|
||
// For prompt model type, check if we're searching embeddings, commands, or tags
|
||
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';
|
||
this.activeCommand = null;
|
||
this.showingCommands = false;
|
||
} else {
|
||
// Check for command mode FIRST (always runs, regardless of setting)
|
||
const commandResult = this._parseCommandInput(rawSearchTerm);
|
||
|
||
if (commandResult.showCommands) {
|
||
// Show command list dropdown
|
||
this.showingCommands = true;
|
||
this.activeCommand = null;
|
||
this.searchType = 'commands';
|
||
this._showCommandList(commandResult.commandFilter);
|
||
return;
|
||
} else if (commandResult.command?.type === 'toggle_setting') {
|
||
// Handle toggle setting command (/autocomplete, /noautocomplete)
|
||
this._handleToggleSettingCommand(commandResult.command);
|
||
return;
|
||
} else if (commandResult.command) {
|
||
// Command is active, use filtered search
|
||
this.showingCommands = false;
|
||
this.activeCommand = commandResult.command;
|
||
searchTerm = commandResult.searchTerm;
|
||
|
||
if (commandResult.command.type === 'embedding') {
|
||
// /emb or /embedding command
|
||
endpoint = '/lm/embeddings/relative-paths';
|
||
this.searchType = 'embeddings';
|
||
} else if (isWildcardCommand(commandResult.command)) {
|
||
endpoint = getWildcardSearchEndpoint();
|
||
this.searchType = 'wildcards';
|
||
} else {
|
||
// Category filter command
|
||
const categories = commandResult.command.categories.join(',');
|
||
endpoint = `/lm/custom-words/search?category=${categories}`;
|
||
this.searchType = 'custom_words';
|
||
}
|
||
} else if (getPromptTagAutocompletePreference()) {
|
||
// No command and setting enabled - regular tag search with enriched results
|
||
this.showingCommands = false;
|
||
this.activeCommand = null;
|
||
endpoint = '/lm/custom-words/search?enriched=true';
|
||
// Use full search term for query variation generation
|
||
// The search() method will generate multiple query variations including:
|
||
// - Original query (for natural language matching)
|
||
// - Underscore version (e.g., "looking_to_the_side" for "looking to the side")
|
||
// - Last token (for backward compatibility with continuous typing)
|
||
searchTerm = rawSearchTerm;
|
||
this.searchType = 'custom_words';
|
||
} else {
|
||
// No command and setting disabled - no autocomplete for direct typing.
|
||
// Re-enable discovery is covered by the command-list footer,
|
||
// the node context menu and the settings tooltip.
|
||
this.hide();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
const allowEmptyWildcardSearch =
|
||
this.modelType === 'prompt' &&
|
||
this.searchType === 'wildcards' &&
|
||
searchTerm.length === 0;
|
||
|
||
if (!allowEmptyWildcardSearch && searchTerm.length < this.options.minChars) {
|
||
this.hide();
|
||
return;
|
||
}
|
||
|
||
// Debounce the search
|
||
this.debounceTimer = setTimeout(() => {
|
||
this.search(searchTerm, endpoint);
|
||
}, this.options.debounceDelay);
|
||
}
|
||
|
||
getSearchTerm(value) {
|
||
return this.getActiveSearchRange(value).text;
|
||
}
|
||
|
||
getActiveSearchRange(value = null) {
|
||
const currentValue = typeof value === 'string' ? value : this.inputElement.value;
|
||
const caretPos = this.getCaretPosition();
|
||
const beforeCursor = this.helper.getBeforeCursor() ?? currentValue.substring(0, caretPos);
|
||
let start = this._getHardBoundaryStart(beforeCursor);
|
||
|
||
if (!getAutocompleteAppendCommaPreference()) {
|
||
const persistedBoundaryEnd = this._getPersistedBoundaryEnd(currentValue, caretPos);
|
||
if (persistedBoundaryEnd !== null && persistedBoundaryEnd > start) {
|
||
start = persistedBoundaryEnd;
|
||
}
|
||
}
|
||
|
||
const rawText = beforeCursor.substring(start);
|
||
const leadingWhitespaceLength = rawText.length - rawText.trimStart().length;
|
||
const trimmedStart = start + leadingWhitespaceLength;
|
||
const text = rawText.trim();
|
||
|
||
if (this.modelType === 'prompt') {
|
||
const tokenRange = this._getPromptTokenRange(rawText, trimmedStart, caretPos);
|
||
if (tokenRange) {
|
||
return {
|
||
start: tokenRange.start,
|
||
trimmedStart: tokenRange.trimmedStart,
|
||
end: caretPos,
|
||
beforeCursor,
|
||
rawText: tokenRange.rawText,
|
||
text: tokenRange.text,
|
||
tokenType: tokenRange.tokenType,
|
||
};
|
||
}
|
||
}
|
||
|
||
return {
|
||
start,
|
||
trimmedStart,
|
||
end: caretPos,
|
||
beforeCursor,
|
||
rawText,
|
||
text,
|
||
};
|
||
}
|
||
|
||
_getPromptTokenRange(rawText = '', trimmedStart = 0, caretPos = 0) {
|
||
const trimmedText = rawText.trim();
|
||
if (!trimmedText) {
|
||
return {
|
||
start: trimmedStart,
|
||
trimmedStart,
|
||
rawText: '',
|
||
text: '',
|
||
tokenType: 'empty',
|
||
};
|
||
}
|
||
|
||
const commandOffset = trimmedText.startsWith('/')
|
||
? 0
|
||
: trimmedText.lastIndexOf(' /');
|
||
if (commandOffset !== -1) {
|
||
const normalizedCommandOffset = commandOffset === 0 ? 0 : commandOffset + 1;
|
||
const commandText = trimmedText.slice(normalizedCommandOffset);
|
||
const commandStart = trimmedStart + normalizedCommandOffset;
|
||
return {
|
||
start: commandStart,
|
||
trimmedStart: commandStart,
|
||
rawText: commandText,
|
||
text: commandText,
|
||
tokenType: commandText === '/' ? 'empty_command_trigger' : 'command',
|
||
};
|
||
}
|
||
|
||
const wildcardMatch = trimmedText.match(/(?:^|\s)(__[\w\s.\-+/*\\]+?__)$/);
|
||
if (wildcardMatch) {
|
||
const wildcardText = wildcardMatch[1];
|
||
const wildcardOffset = trimmedText.lastIndexOf(wildcardText);
|
||
const wildcardStart = trimmedStart + wildcardOffset;
|
||
return {
|
||
start: wildcardStart,
|
||
trimmedStart: wildcardStart,
|
||
rawText: wildcardText,
|
||
text: '',
|
||
tokenType: 'wildcard_literal',
|
||
};
|
||
}
|
||
|
||
const embeddingOffset = trimmedText.search(/(?:^|\s)emb:[^\s]*$/i);
|
||
if (embeddingOffset !== -1) {
|
||
const normalizedEmbeddingOffset = trimmedText.slice(embeddingOffset).startsWith(' ')
|
||
? embeddingOffset + 1
|
||
: embeddingOffset;
|
||
const embeddingText = trimmedText.slice(normalizedEmbeddingOffset);
|
||
const embeddingStart = trimmedStart + normalizedEmbeddingOffset;
|
||
return {
|
||
start: embeddingStart,
|
||
trimmedStart: embeddingStart,
|
||
rawText: embeddingText,
|
||
text: embeddingText,
|
||
tokenType: 'embedding_literal',
|
||
};
|
||
}
|
||
|
||
return {
|
||
start: trimmedStart,
|
||
trimmedStart,
|
||
rawText,
|
||
text: trimmedText,
|
||
tokenType: 'tag_text',
|
||
};
|
||
}
|
||
|
||
_getHardBoundaryStart(beforeCursor = '') {
|
||
const lastComma = beforeCursor.lastIndexOf(',');
|
||
const lastAngle = beforeCursor.lastIndexOf('>');
|
||
const lastNewline = Math.max(beforeCursor.lastIndexOf('\n'), beforeCursor.lastIndexOf('\r'));
|
||
return Math.max(lastComma, lastAngle, lastNewline) + 1;
|
||
}
|
||
|
||
_getMetadataWidget() {
|
||
return this.inputElement?._autocompleteMetadataWidget
|
||
?? this.inputElement?._autocompleteHostWidget?.metadataWidget
|
||
?? null;
|
||
}
|
||
|
||
_getMetadataBase() {
|
||
return createAutocompleteMetadataBase(this.inputElement?._autocompleteTextWidgetName ?? 'text');
|
||
}
|
||
|
||
_getAutocompleteMetadata() {
|
||
const metadataWidget = this._getMetadataWidget();
|
||
const value = metadataWidget?.value;
|
||
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
return this._getMetadataBase();
|
||
}
|
||
|
||
return {
|
||
...this._getMetadataBase(),
|
||
...value,
|
||
};
|
||
}
|
||
|
||
_setAutocompleteMetadata(metadata = {}) {
|
||
const metadataWidget = this._getMetadataWidget();
|
||
if (!metadataWidget) {
|
||
return;
|
||
}
|
||
|
||
metadataWidget.value = {
|
||
...this._getMetadataBase(),
|
||
...metadata,
|
||
};
|
||
}
|
||
|
||
_clearLastAcceptedBoundary() {
|
||
const metadataWidget = this._getMetadataWidget();
|
||
if (!metadataWidget) {
|
||
return;
|
||
}
|
||
|
||
const metadata = this._getAutocompleteMetadata();
|
||
delete metadata.lastAccepted;
|
||
metadataWidget.value = metadata;
|
||
}
|
||
|
||
_storeLastAcceptedBoundary(boundary) {
|
||
this._setAutocompleteMetadata({ lastAccepted: boundary });
|
||
}
|
||
|
||
_getPersistedBoundaryEnd(currentValue, caretPos) {
|
||
const metadata = this._getAutocompleteMetadata();
|
||
const boundary = metadata?.lastAccepted;
|
||
|
||
if (!boundary || typeof boundary !== 'object') {
|
||
return null;
|
||
}
|
||
|
||
const { start, end, insertedText, textSnapshot } = boundary;
|
||
|
||
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) {
|
||
this._clearLastAcceptedBoundary();
|
||
return null;
|
||
}
|
||
|
||
if (end > currentValue.length || end > caretPos) {
|
||
this._clearLastAcceptedBoundary();
|
||
return null;
|
||
}
|
||
|
||
if (typeof insertedText !== 'string' || insertedText.length === 0) {
|
||
this._clearLastAcceptedBoundary();
|
||
return null;
|
||
}
|
||
|
||
if (currentValue.slice(start, end) !== insertedText) {
|
||
this._clearLastAcceptedBoundary();
|
||
return null;
|
||
}
|
||
|
||
if (typeof textSnapshot !== 'string' || currentValue.slice(0, end) !== textSnapshot) {
|
||
this._clearLastAcceptedBoundary();
|
||
return null;
|
||
}
|
||
|
||
return end;
|
||
}
|
||
|
||
/**
|
||
* Extract the last space-separated token from a search term
|
||
* Tag names don't contain spaces, so for tag autocomplete we only need the last token
|
||
* @param {string} term - The full search term (e.g., "hello 1gi")
|
||
* @returns {string} - The last token (e.g., "1gi"), or the original term if no spaces
|
||
*/
|
||
_getLastSpaceToken(term) {
|
||
const tokens = term.trim().split(/\s+/);
|
||
return tokens[tokens.length - 1] || term;
|
||
}
|
||
|
||
/**
|
||
* Generate query variations for better autocomplete matching
|
||
* Includes original query and normalized versions (spaces to underscores, etc.)
|
||
* @param {string} term - Original search term
|
||
* @returns {string[]} - Array of query variations
|
||
*/
|
||
_generateQueryVariations(term) {
|
||
if (!term || term.length < this.options.minChars) {
|
||
return [];
|
||
}
|
||
|
||
const variations = new Set();
|
||
const trimmed = term.trim();
|
||
|
||
// Always include original query
|
||
variations.add(trimmed);
|
||
variations.add(trimmed.toLowerCase());
|
||
|
||
// Add underscore version (Danbooru convention: spaces become underscores)
|
||
// e.g., "looking to the side" -> "looking_to_the_side"
|
||
if (trimmed.includes(' ')) {
|
||
const underscoreVersion = trimmed.replace(/ /g, '_');
|
||
variations.add(underscoreVersion);
|
||
variations.add(underscoreVersion.toLowerCase());
|
||
}
|
||
|
||
// Add no-space version for flexible matching
|
||
// e.g., "blue hair" -> "bluehair"
|
||
if (trimmed.includes(' ') || trimmed.includes('_')) {
|
||
const noSpaceVersion = trimmed.replace(/[ _]/g, '');
|
||
variations.add(noSpaceVersion);
|
||
variations.add(noSpaceVersion.toLowerCase());
|
||
}
|
||
|
||
// Add last token only (legacy behavior for continuous typing)
|
||
const lastToken = this._getLastSpaceToken(trimmed);
|
||
if (lastToken !== trimmed) {
|
||
variations.add(lastToken);
|
||
variations.add(lastToken.toLowerCase());
|
||
}
|
||
|
||
return Array.from(variations).filter(v => v.length >= this.options.minChars);
|
||
}
|
||
|
||
_normalizeQueryForRequest(term = '') {
|
||
return term.trim().toLowerCase();
|
||
}
|
||
|
||
_getQueriesToExecute(term = '') {
|
||
const queryVariations = this._generateQueryVariations(term);
|
||
const uniqueQueries = [];
|
||
const seen = new Set();
|
||
|
||
for (const query of queryVariations) {
|
||
const normalized = this._normalizeQueryForRequest(query);
|
||
if (!normalized || seen.has(normalized)) {
|
||
continue;
|
||
}
|
||
|
||
seen.add(normalized);
|
||
uniqueQueries.push(query);
|
||
|
||
if (uniqueQueries.length >= 4) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return uniqueQueries;
|
||
}
|
||
|
||
_containsInformationalItems() {
|
||
return this.items.some((item) => isWildcardInfoItem(item));
|
||
}
|
||
|
||
_isSelectableInfoItem(item) {
|
||
if (isWildcardInfoItem(item)) {
|
||
return true;
|
||
}
|
||
// Command items are not model paths — never show preview for them
|
||
return item && typeof item === 'object' && 'command' in item;
|
||
}
|
||
|
||
/**
|
||
* Get display text for an item (without extension for models)
|
||
* @param {string|Object} item - Item to get display text from
|
||
* @returns {string} - Display text without extension
|
||
*/
|
||
_getDisplayText(item) {
|
||
if (isWildcardInfoItem(item)) {
|
||
return item.title || item.description || 'Wildcards';
|
||
}
|
||
|
||
const itemText = typeof item === 'object' && item.tag_name ? item.tag_name : String(item);
|
||
// Remove extension for models to avoid matching/displaying .safetensors etc.
|
||
if (this.modelType === 'loras' || this.searchType === 'embeddings') {
|
||
return removeLoraExtension(itemText);
|
||
} else if (this.modelType === 'embeddings') {
|
||
return removeGeneralExtension(itemText);
|
||
}
|
||
return itemText;
|
||
}
|
||
|
||
/**
|
||
* Check if an item matches a search term
|
||
* Supports both string items and enriched items with tag_name property
|
||
* @param {string|Object} item - Item to check
|
||
* @param {string} searchTerm - Search term to match against
|
||
* @returns {Object} - { matched: boolean, isExactMatch: boolean }
|
||
*/
|
||
_matchItem(item, searchTerm) {
|
||
const itemText = this._getDisplayText(item);
|
||
const itemTextLower = itemText.toLowerCase();
|
||
const searchTermLower = searchTerm.toLowerCase();
|
||
|
||
// Exact match (case-insensitive)
|
||
if (itemTextLower === searchTermLower) {
|
||
return { matched: true, isExactMatch: true };
|
||
}
|
||
|
||
// Partial match (contains)
|
||
if (itemTextLower.includes(searchTermLower)) {
|
||
return { matched: true, isExactMatch: false };
|
||
}
|
||
|
||
// Symbol-insensitive match: remove common separators and retry
|
||
// e.g., "blue hair" can match "blue_hair" or "bluehair"
|
||
const normalizedItem = itemTextLower.replace(/[-_\s']/g, '');
|
||
const normalizedSearch = searchTermLower.replace(/[-_\s']/g, '');
|
||
if (normalizedItem.includes(normalizedSearch)) {
|
||
return { matched: true, isExactMatch: false };
|
||
}
|
||
|
||
return { matched: false, isExactMatch: false };
|
||
}
|
||
|
||
_getLiveSearchTermForAcceptance() {
|
||
const rawSearchTerm = this.getSearchTerm(this.inputElement.value);
|
||
|
||
if (this.modelType === 'embeddings') {
|
||
const match = rawSearchTerm.match(/^emb:(.*)$/i);
|
||
return (match?.[1] || '').trim();
|
||
}
|
||
|
||
if (this.modelType === 'loras') {
|
||
const commandResult = this._parseCommandInput(rawSearchTerm);
|
||
return commandResult.searchTerm ?? rawSearchTerm;
|
||
}
|
||
|
||
if (this.modelType === 'prompt') {
|
||
const embeddingMatch = rawSearchTerm.match(/^emb:(.*)$/i);
|
||
if (embeddingMatch) {
|
||
return (embeddingMatch[1] || '').trim();
|
||
}
|
||
|
||
const commandResult = this._parseCommandInput(rawSearchTerm);
|
||
return commandResult.searchTerm ?? rawSearchTerm;
|
||
}
|
||
|
||
return rawSearchTerm;
|
||
}
|
||
|
||
_getPreferredSelectedIndex(searchTerm = '') {
|
||
if (!this.items?.length) {
|
||
return -1;
|
||
}
|
||
|
||
if (this.showingCommands) {
|
||
if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
|
||
return this.selectedIndex;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
const trimmedSearchTerm = searchTerm.trim();
|
||
if (!trimmedSearchTerm) {
|
||
if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
|
||
return this.selectedIndex;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
const searchLower = trimmedSearchTerm.toLowerCase();
|
||
const normalizedSearch = normalizeAutocompleteMatchText(trimmedSearchTerm);
|
||
let bestIndex = -1;
|
||
let bestScore = -Infinity;
|
||
|
||
this.items.forEach((item, index) => {
|
||
const displayText = this._getDisplayText(item);
|
||
const textLower = displayText.toLowerCase();
|
||
const normalizedText = normalizeAutocompleteMatchText(displayText);
|
||
let score = -1;
|
||
|
||
if (textLower === searchLower) {
|
||
score = 5000;
|
||
} else if (normalizedText === normalizedSearch) {
|
||
score = 4500;
|
||
} else if (textLower.startsWith(searchLower)) {
|
||
score = 4000;
|
||
} else if (normalizedText.startsWith(normalizedSearch)) {
|
||
score = 3500;
|
||
} else if (textLower.includes(searchLower)) {
|
||
score = 3000;
|
||
} else if (normalizedText.includes(normalizedSearch)) {
|
||
score = 2500;
|
||
}
|
||
|
||
if (score > -1) {
|
||
score -= index;
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
bestIndex = index;
|
||
}
|
||
}
|
||
});
|
||
|
||
if (bestIndex !== -1) {
|
||
return bestIndex;
|
||
}
|
||
|
||
if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
|
||
return this.selectedIndex;
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
_getAcceptSelectionIndex(searchTerm = '') {
|
||
if (this.hasManualSelection && this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
|
||
return this.selectedIndex;
|
||
}
|
||
|
||
return this._getPreferredSelectedIndex(searchTerm);
|
||
}
|
||
|
||
/**
|
||
* Build a URL-encoded query string from the LoRA Manager page's active
|
||
* filters in localStorage, or null when not applicable.
|
||
*/
|
||
_getActiveLoraFilters() {
|
||
if (this.modelType !== 'loras' || !getLoraActiveFiltersAutocompletePreference()) {
|
||
return null;
|
||
}
|
||
try {
|
||
const params = new URLSearchParams();
|
||
|
||
const folder = localStorage.getItem('lora_manager_loras_activeFolder');
|
||
const recursiveRaw = localStorage.getItem('lora_manager_loras_recursiveSearch');
|
||
const recursive = recursiveRaw === null ? true : recursiveRaw.toLowerCase() === 'true';
|
||
|
||
if (folder && folder !== 'null') {
|
||
params.append('folder', folder);
|
||
} else if (!recursive) {
|
||
// Root folder with recursion disabled mirrors the page list,
|
||
// which matches only root-level files via folder=''.
|
||
params.append('folder', '');
|
||
}
|
||
|
||
const raw = localStorage.getItem('lora_manager_loras_filters');
|
||
if (raw) {
|
||
const filters = JSON.parse(raw);
|
||
|
||
if (Array.isArray(filters.baseModel)) {
|
||
filters.baseModel.forEach((m) => m && params.append('base_model', m));
|
||
}
|
||
|
||
if (filters.tags && typeof filters.tags === 'object') {
|
||
Object.entries(filters.tags).forEach(([tag, state]) => {
|
||
if (state === 'include') {
|
||
params.append('tag_include', tag);
|
||
} else if (state === 'exclude') {
|
||
params.append('tag_exclude', tag);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (filters.autoTags && typeof filters.autoTags === 'object') {
|
||
Object.entries(filters.autoTags).forEach(([tag, state]) => {
|
||
if (state === 'include') {
|
||
params.append('auto_tag_include', tag);
|
||
} else if (state === 'exclude') {
|
||
params.append('auto_tag_exclude', tag);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (Array.isArray(filters.modelTypes)) {
|
||
filters.modelTypes.forEach((t) => t && params.append('model_type', t));
|
||
}
|
||
|
||
if (filters.tagLogic) {
|
||
params.append('tag_logic', filters.tagLogic);
|
||
}
|
||
|
||
if (filters.license) {
|
||
if (filters.license.noCredit === 'include') {
|
||
params.append('credit_required', 'false');
|
||
} else if (filters.license.noCredit === 'exclude') {
|
||
params.append('credit_required', 'true');
|
||
}
|
||
if (filters.license.allowSelling === 'include') {
|
||
params.append('allow_selling_generated_content', 'true');
|
||
} else if (filters.license.allowSelling === 'exclude') {
|
||
params.append('allow_selling_generated_content', 'false');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Always send recursive in filter mode — its presence also signals
|
||
// the backend to run the filter pipeline (e.g. show_only_sfw) even
|
||
// when no concrete filter is set, matching the list endpoint.
|
||
params.append('recursive', String(recursive));
|
||
|
||
return params.toString();
|
||
} catch (error) {
|
||
console.warn('[Lora Manager] Failed to read active filters for autocomplete:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async search(term = '', endpoint = null) {
|
||
try {
|
||
this.currentSearchTerm = term;
|
||
|
||
// Save current search type to detect mode changes during async search
|
||
const searchTypeAtStart = this.searchType;
|
||
|
||
// Clear items before starting new search to avoid stale data
|
||
// This is critical for preventing command suggestions from persisting
|
||
// when switching from command mode to regular tag search
|
||
this.items = [];
|
||
this.wildcardMeta = null;
|
||
|
||
if (!endpoint) {
|
||
endpoint = `/lm/${this.modelType}/relative-paths`;
|
||
}
|
||
|
||
// Active-filter query params for loras (null when setting off or
|
||
// model type is not loras, so appending is safe for all types)
|
||
const activeFiltersQuery = this._getActiveLoraFilters();
|
||
|
||
// Generate multiple query variations for better matching, but avoid
|
||
// sending duplicate-equivalent requests that normalize to the same
|
||
// backend search term.
|
||
const queriesToExecute =
|
||
this.searchType === 'wildcards' && term.length === 0
|
||
? ['']
|
||
: this._getQueriesToExecute(term);
|
||
|
||
if (queriesToExecute.length === 0) {
|
||
this.items = [];
|
||
this.hide();
|
||
return;
|
||
}
|
||
|
||
// Execute all queries in parallel
|
||
const searchPromises = queriesToExecute.map(async (query) => {
|
||
const url = endpoint.includes('?')
|
||
? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`
|
||
: `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`;
|
||
const finalUrl = activeFiltersQuery ? `${url}&${activeFiltersQuery}` : url;
|
||
|
||
try {
|
||
const response = await api.fetchApi(finalUrl);
|
||
const data = await response.json();
|
||
return {
|
||
items: data.success ? (data.relative_paths || data.words || []) : [],
|
||
meta: data?.meta || null,
|
||
};
|
||
} catch (error) {
|
||
console.warn(`Search query failed for "${query}":`, error);
|
||
return {
|
||
items: [],
|
||
meta: null,
|
||
};
|
||
}
|
||
});
|
||
|
||
const resultsArrays = await Promise.all(searchPromises);
|
||
|
||
// Check if search type changed during async operation
|
||
// If so, skip updating items to prevent stale data from showing
|
||
if (this.searchType !== searchTypeAtStart) {
|
||
console.log('[Lora Manager] Search type changed during search, skipping update');
|
||
return;
|
||
}
|
||
|
||
// Merge and deduplicate results while preserving order from backend
|
||
// Backend returns results sorted by relevance, so we maintain that order
|
||
const seen = new Set();
|
||
const mergedItems = [];
|
||
|
||
for (const result of resultsArrays) {
|
||
if (!this.wildcardMeta && result?.meta) {
|
||
this.wildcardMeta = result.meta;
|
||
}
|
||
|
||
const resultArray = result?.items || [];
|
||
for (const item of resultArray) {
|
||
const itemKey = typeof item === 'object' && item.tag_name
|
||
? item.tag_name.toLowerCase()
|
||
: String(item).toLowerCase();
|
||
|
||
if (!seen.has(itemKey)) {
|
||
seen.add(itemKey);
|
||
mergedItems.push(item);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (this.searchType === 'wildcards' && mergedItems.length === 0) {
|
||
const meta = this.wildcardMeta || {};
|
||
this.items = meta.has_wildcards
|
||
? [createWildcardNoMatchesItem(term, meta)]
|
||
: [createWildcardEmptyStateItem(meta)];
|
||
this.hasMoreItems = false;
|
||
this.render();
|
||
this.show();
|
||
return;
|
||
}
|
||
|
||
// Use backend-sorted results directly without re-scoring
|
||
// Backend already ranks by: FTS5 bm25 score + post count + exact prefix boost
|
||
if (mergedItems.length > 0) {
|
||
this.items = mergedItems;
|
||
this.render();
|
||
this.show();
|
||
} else {
|
||
this.items = [];
|
||
this.hide();
|
||
}
|
||
} catch (error) {
|
||
console.error('Autocomplete search error:', error);
|
||
this.items = [];
|
||
this.hide();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Return the command map for the current model type.
|
||
* Lora model types get the active-filters toggle commands, all others
|
||
* keep the prompt tag commands.
|
||
*/
|
||
_getCommands() {
|
||
return this.modelType === 'loras' ? LORAS_COMMANDS : TAG_COMMANDS;
|
||
}
|
||
|
||
/**
|
||
* Parse command input to detect command mode
|
||
* @param {string} rawInput - Raw input text
|
||
* @returns {Object} - { showCommands, commandFilter, command, searchTerm }
|
||
*/
|
||
_parseCommandInput(rawInput) {
|
||
const trimmed = rawInput.trim();
|
||
|
||
// Check if input starts with "/"
|
||
if (!trimmed.startsWith('/')) {
|
||
return { showCommands: false, command: null, searchTerm: trimmed };
|
||
}
|
||
|
||
// Split into potential command and search term
|
||
const spaceIndex = trimmed.indexOf(' ');
|
||
|
||
if (spaceIndex === -1) {
|
||
// Still typing command (e.g., "/cha")
|
||
const partialCommand = trimmed.toLowerCase();
|
||
|
||
// Check for exact command match
|
||
if (this._getCommands()[partialCommand]) {
|
||
const cmd = this._getCommands()[partialCommand];
|
||
// Filter out toggle commands that don't meet their condition
|
||
if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) {
|
||
return { showCommands: false, command: null, searchTerm: '' };
|
||
}
|
||
return {
|
||
showCommands: false,
|
||
command: cmd,
|
||
searchTerm: '',
|
||
};
|
||
}
|
||
|
||
// Show command suggestions
|
||
return {
|
||
showCommands: true,
|
||
commandFilter: partialCommand.slice(1), // Remove leading "/"
|
||
command: null,
|
||
searchTerm: '',
|
||
};
|
||
}
|
||
|
||
// Command with search term (e.g., "/character miku")
|
||
const commandPart = trimmed.slice(0, spaceIndex).toLowerCase();
|
||
const searchPart = trimmed.slice(spaceIndex + 1).trim();
|
||
|
||
if (this._getCommands()[commandPart]) {
|
||
const cmd = this._getCommands()[commandPart];
|
||
// Filter out toggle commands that don't meet their condition
|
||
if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) {
|
||
return { showCommands: false, command: null, searchTerm: trimmed };
|
||
}
|
||
return {
|
||
showCommands: false,
|
||
command: cmd,
|
||
searchTerm: searchPart,
|
||
};
|
||
}
|
||
|
||
// Unknown command, treat as regular search
|
||
return { showCommands: false, command: null, searchTerm: trimmed };
|
||
}
|
||
|
||
/**
|
||
* Show the command list dropdown
|
||
* @param {string} filter - Optional filter for commands
|
||
*/
|
||
_showCommandList(filter = '') {
|
||
// Only show command list if we're in command mode
|
||
// This prevents stale command suggestions from appearing after switching to tag search
|
||
if (this.searchType !== 'commands' && this.showingCommands !== true) {
|
||
return;
|
||
}
|
||
|
||
const filterLower = filter.toLowerCase();
|
||
|
||
const commands = [];
|
||
|
||
for (const [cmd, info] of Object.entries(this._getCommands())) {
|
||
// Filter out toggle commands that don't meet their condition
|
||
if (info.type === 'toggle_setting' && info.condition) {
|
||
if (!info.condition()) continue;
|
||
}
|
||
|
||
if (!filter || cmd.slice(1).startsWith(filterLower)) {
|
||
commands.push({ command: cmd, ...info });
|
||
}
|
||
}
|
||
|
||
if (commands.length === 0) {
|
||
this.hide();
|
||
return;
|
||
}
|
||
|
||
this.items = commands;
|
||
this._renderCommandList();
|
||
this.show();
|
||
}
|
||
|
||
/**
|
||
* Render the command list dropdown
|
||
*/
|
||
_renderCommandList() {
|
||
// Clear command list items properly based on rendering mode
|
||
if (this.contentContainer) {
|
||
// Virtual scrolling mode - clear content container
|
||
this.contentContainer.innerHTML = '';
|
||
} else {
|
||
// Non-virtual scrolling mode - clear dropdown direct children
|
||
this.dropdown.innerHTML = '';
|
||
}
|
||
this.selectedIndex = -1;
|
||
this.hasManualSelection = false;
|
||
|
||
this.items.forEach((item, index) => {
|
||
const itemEl = document.createElement('div');
|
||
itemEl.className = 'comfy-autocomplete-item comfy-autocomplete-command';
|
||
itemEl.dataset.index = index.toString();
|
||
|
||
const cmdSpan = document.createElement('span');
|
||
cmdSpan.className = 'lm-autocomplete-command-name';
|
||
cmdSpan.textContent = item.command;
|
||
|
||
const labelSpan = document.createElement('span');
|
||
labelSpan.className = 'lm-autocomplete-command-label';
|
||
labelSpan.textContent = item.label;
|
||
|
||
itemEl.appendChild(cmdSpan);
|
||
itemEl.appendChild(labelSpan);
|
||
|
||
itemEl.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;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
height: ${this.options.itemHeight}px;
|
||
box-sizing: border-box;
|
||
`;
|
||
|
||
// Prevent textarea from losing focus - same fix as createItemElement
|
||
itemEl.addEventListener('mousedown', (e) => {
|
||
e.preventDefault();
|
||
});
|
||
|
||
itemEl.addEventListener('mouseenter', () => {
|
||
this.selectItem(index, { manual: true });
|
||
});
|
||
|
||
itemEl.addEventListener('click', () => {
|
||
this._insertCommand(item.command);
|
||
});
|
||
|
||
// Append to correct container based on rendering mode
|
||
if (this.contentContainer) {
|
||
this.contentContainer.appendChild(itemEl);
|
||
} else {
|
||
this.dropdown.appendChild(itemEl);
|
||
}
|
||
});
|
||
|
||
// Remove border from last item
|
||
const lastChild = this.contentContainer ? this.contentContainer.lastChild : this.dropdown.lastChild;
|
||
if (lastChild) {
|
||
lastChild.style.borderBottom = 'none';
|
||
}
|
||
|
||
// Auto-select immediately so accept keys remain stable.
|
||
// In virtual-scroll mode, calling selectItem() before the dropdown is
|
||
// visible can see a zero-height container and incorrectly replace the
|
||
// full command list with a partially virtualized slice.
|
||
if (this.items.length > 0) {
|
||
this.selectedIndex = 0;
|
||
this.hasManualSelection = false;
|
||
if (this.contentContainer) {
|
||
this._applyItemSelection(0);
|
||
} else {
|
||
this.selectItem(0);
|
||
}
|
||
}
|
||
|
||
// State hint below the command list (e.g. how to toggle autocomplete)
|
||
this._renderCommandListFooter();
|
||
|
||
// Update virtual scroll height for virtual scrolling mode
|
||
if (this.contentContainer) {
|
||
this.updateVirtualScrollHeight();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Render a state hint below the slash command list so the autocomplete
|
||
* toggle commands explain themselves. Only applies to prompt nodes.
|
||
*/
|
||
_renderCommandListFooter() {
|
||
this._removeCommandListFooter();
|
||
|
||
if (this.modelType !== 'prompt') {
|
||
return;
|
||
}
|
||
|
||
const enabled = getPromptTagAutocompletePreference();
|
||
const footer = document.createElement('div');
|
||
footer.className = 'lm-autocomplete-command-footer';
|
||
footer.textContent = enabled
|
||
? 'Tag autocomplete is ON — /noautocomplete to disable'
|
||
: 'Tag autocomplete is OFF — /autocomplete to enable';
|
||
footer.style.cssText = `
|
||
padding: 6px 12px;
|
||
font-size: 11px;
|
||
color: rgba(226, 232, 240, 0.5);
|
||
border-top: 1px solid rgba(226, 232, 240, 0.1);
|
||
white-space: nowrap;
|
||
`;
|
||
// Keep focus in the textarea when the hint is clicked
|
||
footer.addEventListener('mousedown', (e) => e.preventDefault());
|
||
|
||
this.dropdown.appendChild(footer);
|
||
this.commandListFooter = footer;
|
||
}
|
||
|
||
_removeCommandListFooter() {
|
||
if (this.commandListFooter) {
|
||
this.commandListFooter.remove();
|
||
this.commandListFooter = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show a one-time, dismissible hint inside the dropdown telling users how
|
||
* to disable tag autocomplete. Dismissal is persisted in localStorage.
|
||
*/
|
||
_maybeShowFirstRunHint() {
|
||
if (this.firstRunHint) {
|
||
return;
|
||
}
|
||
if (this.modelType !== 'prompt'
|
||
|| this.showingCommands
|
||
|| this.searchType !== 'custom_words'
|
||
|| this.activeCommand) {
|
||
return;
|
||
}
|
||
|
||
let dismissed = false;
|
||
try {
|
||
dismissed = localStorage.getItem(FIRST_RUN_HINT_DISMISSED_KEY) === '1';
|
||
} catch (e) {
|
||
// localStorage unavailable - fall through and show the hint
|
||
}
|
||
if (dismissed) {
|
||
return;
|
||
}
|
||
|
||
const hint = document.createElement('div');
|
||
hint.className = 'lm-autocomplete-first-run-hint';
|
||
hint.style.cssText = `
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
padding: 6px 12px;
|
||
font-size: 11px;
|
||
color: rgba(226, 232, 240, 0.6);
|
||
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
|
||
`;
|
||
|
||
const text = document.createElement('span');
|
||
text.textContent = 'Tip: type /noautocomplete to turn off these suggestions';
|
||
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.type = 'button';
|
||
closeBtn.textContent = '×';
|
||
closeBtn.title = 'Dismiss';
|
||
closeBtn.style.cssText = `
|
||
background: none;
|
||
border: none;
|
||
color: rgba(226, 232, 240, 0.5);
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
padding: 0 2px;
|
||
`;
|
||
closeBtn.addEventListener('click', () => {
|
||
try {
|
||
localStorage.setItem(FIRST_RUN_HINT_DISMISSED_KEY, '1');
|
||
} catch (e) {
|
||
}
|
||
this._removeFirstRunHint();
|
||
});
|
||
|
||
hint.appendChild(text);
|
||
hint.appendChild(closeBtn);
|
||
// Keep focus in the textarea when interacting with the hint
|
||
hint.addEventListener('mousedown', (e) => e.preventDefault());
|
||
|
||
this.dropdown.insertBefore(hint, this.dropdown.firstChild);
|
||
this.firstRunHint = hint;
|
||
}
|
||
|
||
_removeFirstRunHint() {
|
||
if (this.firstRunHint) {
|
||
this.firstRunHint.remove();
|
||
this.firstRunHint = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Insert a command into the input
|
||
* @param {string} command - The command to insert (e.g., "/character")
|
||
*/
|
||
_insertCommand(command) {
|
||
const currentValue = this.inputElement.value;
|
||
const activeRange = this.getActiveSearchRange(currentValue);
|
||
const commandStartPos = activeRange.trimmedStart;
|
||
|
||
// Insert command with trailing space
|
||
const insertText = command + ' ';
|
||
const newValue = currentValue.substring(0, commandStartPos) + insertText + currentValue.substring(activeRange.end);
|
||
const newCaretPos = commandStartPos + insertText.length;
|
||
|
||
this.inputElement.value = newValue;
|
||
|
||
// Trigger input event
|
||
const event = new Event('input', { bubbles: true });
|
||
this.inputElement.dispatchEvent(event);
|
||
|
||
this.hide();
|
||
|
||
// Focus and position cursor
|
||
this.inputElement.focus();
|
||
this.inputElement.setSelectionRange(newCaretPos, newCaretPos);
|
||
}
|
||
|
||
render() {
|
||
this.selectedIndex = -1;
|
||
this.hasManualSelection = false;
|
||
|
||
// Command-list state hints do not belong to regular search results
|
||
this._removeCommandListFooter();
|
||
|
||
// Reset virtual scroll state
|
||
this.virtualScrollOffset = 0;
|
||
this.currentPage = 0;
|
||
this.hasMoreItems = true;
|
||
this.isLoadingMore = false;
|
||
|
||
// Early return if no items to prevent empty dropdown
|
||
if (!this.items || this.items.length === 0) {
|
||
if (this.contentContainer) {
|
||
this.contentContainer.innerHTML = '';
|
||
} else {
|
||
this.dropdown.innerHTML = '';
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (this.options.enableVirtualScroll && this.contentContainer) {
|
||
// Use virtual scrolling - always update visible items to ensure content is fresh
|
||
// The dropdown visibility is controlled by show()/hide()
|
||
this.updateVirtualScrollHeight();
|
||
this.updateVisibleItems();
|
||
} else {
|
||
// Traditional rendering (fallback)
|
||
this.dropdown.innerHTML = '';
|
||
|
||
// Check if items are enriched (have tag_name, category, post_count) or command objects
|
||
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
|
||
const isCommand = this.items[0] && typeof this.items[0] === 'object' && 'command' in this.items[0];
|
||
|
||
this.items.forEach((itemData, index) => {
|
||
const item = this.createItemElement(itemData, index, isEnriched, isCommand);
|
||
this.dropdown.appendChild(item);
|
||
});
|
||
|
||
// Remove border from last item
|
||
if (this.dropdown.lastChild) {
|
||
this.dropdown.lastChild.style.borderBottom = 'none';
|
||
}
|
||
}
|
||
|
||
// Auto-select immediately so accept keys do not fall through
|
||
// to native focus traversal while the dropdown is visible.
|
||
if (this.items.length > 0) {
|
||
this.selectItem(0);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Render an enriched autocomplete item with category badge and post count
|
||
* @param {HTMLElement} itemEl - The item element to populate
|
||
* @param {Object} itemData - The enriched item data { tag_name, category, post_count, matched_alias? }
|
||
* @param {string} searchTerm - The current search term for highlighting
|
||
*/
|
||
_renderEnrichedItem(itemEl, itemData, searchTerm) {
|
||
// Create name span with highlighted match
|
||
const nameSpan = document.createElement('span');
|
||
nameSpan.className = 'lm-autocomplete-name';
|
||
|
||
// If matched via alias, show: "tag_name ← alias" with alias highlighted
|
||
if (itemData.matched_alias) {
|
||
const tagText = document.createTextNode(itemData.tag_name + ' ');
|
||
nameSpan.appendChild(tagText);
|
||
|
||
const aliasSpan = document.createElement('span');
|
||
aliasSpan.className = 'lm-matched-alias';
|
||
aliasSpan.innerHTML = '← ' + this.highlightMatch(itemData.matched_alias, searchTerm);
|
||
aliasSpan.style.cssText = `
|
||
font-size: 11px;
|
||
color: rgba(226, 232, 240, 0.5);
|
||
`;
|
||
nameSpan.appendChild(aliasSpan);
|
||
} else {
|
||
nameSpan.innerHTML = this.highlightMatch(itemData.tag_name, searchTerm);
|
||
}
|
||
|
||
nameSpan.style.cssText = `
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
`;
|
||
|
||
// Create meta container for count and badge
|
||
const metaSpan = document.createElement('span');
|
||
metaSpan.className = 'lm-autocomplete-meta';
|
||
metaSpan.style.cssText = `
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
`;
|
||
|
||
// Add post count
|
||
if (itemData.post_count > 0) {
|
||
const countSpan = document.createElement('span');
|
||
countSpan.className = 'lm-autocomplete-count';
|
||
countSpan.textContent = formatPostCount(itemData.post_count);
|
||
countSpan.style.cssText = `
|
||
font-size: 11px;
|
||
color: rgba(226, 232, 240, 0.5);
|
||
`;
|
||
metaSpan.appendChild(countSpan);
|
||
}
|
||
|
||
// Add category badge
|
||
const categoryInfo = CATEGORY_INFO[itemData.category];
|
||
if (categoryInfo) {
|
||
const badgeSpan = document.createElement('span');
|
||
badgeSpan.className = 'lm-autocomplete-category';
|
||
badgeSpan.textContent = categoryInfo.label;
|
||
badgeSpan.style.cssText = `
|
||
font-size: 10px;
|
||
padding: 2px 6px;
|
||
border-radius: 10px;
|
||
background: ${categoryInfo.bg};
|
||
color: ${categoryInfo.text};
|
||
white-space: nowrap;
|
||
`;
|
||
metaSpan.appendChild(badgeSpan);
|
||
}
|
||
|
||
itemEl.appendChild(nameSpan);
|
||
itemEl.appendChild(metaSpan);
|
||
}
|
||
|
||
_renderInformationalItem(itemEl, itemData) {
|
||
itemEl.classList.add('comfy-autocomplete-info-item');
|
||
itemEl.style.cssText = `
|
||
padding: 12px;
|
||
color: rgba(226, 232, 240, 0.88);
|
||
border-bottom: none;
|
||
cursor: default;
|
||
display: block;
|
||
white-space: normal;
|
||
height: auto;
|
||
`;
|
||
|
||
const title = document.createElement('div');
|
||
title.className = 'lm-autocomplete-info-title';
|
||
title.textContent = itemData.title || 'Wildcards';
|
||
title.style.cssText = `
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
margin-bottom: 6px;
|
||
`;
|
||
itemEl.appendChild(title);
|
||
|
||
const description = document.createElement('div');
|
||
description.className = 'lm-autocomplete-info-description';
|
||
description.textContent = itemData.description || '';
|
||
description.style.cssText = `
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
color: rgba(226, 232, 240, 0.72);
|
||
`;
|
||
itemEl.appendChild(description);
|
||
|
||
if (itemData.type === 'wildcard_no_matches') {
|
||
return;
|
||
}
|
||
|
||
const pathBlock = document.createElement('div');
|
||
pathBlock.style.cssText = `
|
||
margin-top: 10px;
|
||
padding: 8px 10px;
|
||
border-radius: 6px;
|
||
background: rgba(15, 23, 42, 0.6);
|
||
font-size: 11px;
|
||
line-height: 1.5;
|
||
`;
|
||
pathBlock.innerHTML = [
|
||
'<div style="font-weight: 600; margin-bottom: 4px;">Wildcards folder</div>',
|
||
`<code style="word-break: break-all; color: #dbeafe;">${itemData.wildcardsDir || '(unavailable)'}</code>`,
|
||
`<div style="margin-top: 6px; color: rgba(226, 232, 240, 0.68);">Supported formats: ${(itemData.supportedFormats || []).join(', ')}</div>`,
|
||
].join('');
|
||
itemEl.appendChild(pathBlock);
|
||
|
||
const examples = document.createElement('div');
|
||
examples.style.cssText = `
|
||
margin-top: 10px;
|
||
font-size: 11px;
|
||
line-height: 1.55;
|
||
color: rgba(226, 232, 240, 0.72);
|
||
`;
|
||
examples.innerHTML = [
|
||
'<div style="font-weight: 600; color: rgba(226, 232, 240, 0.88); margin-bottom: 4px;">Examples</div>',
|
||
'<div><code>animals/cat.txt</code> -> use <code>__animals/cat__</code></div>',
|
||
'<div><code>colors.yaml</code> with <code>palette: { warm: [red, orange] }</code> -> use <code>__palette/warm__</code></div>',
|
||
'<div style="margin-top: 6px;">Text files use one option per line. YAML/JSON use nested keys ending in string arrays.</div>',
|
||
].join('');
|
||
itemEl.appendChild(examples);
|
||
|
||
const actions = document.createElement('div');
|
||
actions.style.cssText = `
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 12px;
|
||
flex-wrap: wrap;
|
||
`;
|
||
|
||
const openButton = document.createElement('button');
|
||
openButton.type = 'button';
|
||
openButton.dataset.action = 'open-wildcards-folder';
|
||
openButton.textContent = 'Open wildcards folder';
|
||
openButton.style.cssText = `
|
||
border: 1px solid rgba(96, 165, 250, 0.45);
|
||
background: rgba(37, 99, 235, 0.18);
|
||
color: #dbeafe;
|
||
border-radius: 6px;
|
||
padding: 6px 10px;
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
`;
|
||
openButton.addEventListener('click', async (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
await this._openWildcardsFolder();
|
||
});
|
||
actions.appendChild(openButton);
|
||
|
||
const copyButton = document.createElement('button');
|
||
copyButton.type = 'button';
|
||
copyButton.dataset.action = 'copy-wildcards-path';
|
||
copyButton.textContent = 'Copy path';
|
||
copyButton.style.cssText = `
|
||
border: 1px solid rgba(226, 232, 240, 0.2);
|
||
background: rgba(148, 163, 184, 0.12);
|
||
color: rgba(226, 232, 240, 0.88);
|
||
border-radius: 6px;
|
||
padding: 6px 10px;
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
`;
|
||
copyButton.addEventListener('click', async (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
await this._copyWildcardPath(itemData.wildcardsDir || '');
|
||
});
|
||
actions.appendChild(copyButton);
|
||
|
||
itemEl.appendChild(actions);
|
||
}
|
||
|
||
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>',
|
||
);
|
||
}
|
||
|
||
async _openWildcardsFolder() {
|
||
try {
|
||
const response = await api.fetchApi('/lm/wildcards/open-location', { method: 'POST' });
|
||
const data = await response.json();
|
||
if (!response.ok || data?.success === false) {
|
||
throw new Error(data?.error || 'Failed to open wildcards folder');
|
||
}
|
||
|
||
if (data?.mode === 'clipboard' && data?.path) {
|
||
await this._copyWildcardPath(data.path);
|
||
return;
|
||
}
|
||
|
||
showToast({
|
||
severity: 'success',
|
||
summary: 'Wildcards folder',
|
||
detail: 'Opened wildcards folder.',
|
||
life: 2500,
|
||
});
|
||
} catch (error) {
|
||
console.error('[Lora Manager] Failed to open wildcards folder:', error);
|
||
showToast({
|
||
severity: 'error',
|
||
summary: 'Error',
|
||
detail: error?.message || 'Failed to open wildcards folder',
|
||
life: 3000,
|
||
});
|
||
}
|
||
}
|
||
|
||
async _copyWildcardPath(path) {
|
||
if (!path) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (navigator?.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(path);
|
||
}
|
||
showToast({
|
||
severity: 'info',
|
||
summary: 'Wildcards path',
|
||
detail: path,
|
||
life: 3000,
|
||
});
|
||
} catch (error) {
|
||
console.error('[Lora Manager] Failed to copy wildcards path:', error);
|
||
showToast({
|
||
severity: 'warn',
|
||
summary: 'Wildcards path',
|
||
detail: path,
|
||
life: 4000,
|
||
});
|
||
}
|
||
}
|
||
|
||
showPreviewForItem(relativePath, itemElement) {
|
||
if (!this.options.showPreview || !this.previewTooltip) return;
|
||
if (typeof relativePath !== 'string' || !relativePath) 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();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle scroll event for virtual scrolling and loading more items
|
||
*/
|
||
handleScroll() {
|
||
if (!this.scrollContainer || this.isLoadingMore) {
|
||
return;
|
||
}
|
||
|
||
const { scrollTop, scrollHeight, clientHeight } = this.scrollContainer;
|
||
const scrollBottom = scrollTop + clientHeight;
|
||
const threshold = this.options.itemHeight * 2; // Load more when within 2 items of bottom
|
||
|
||
// Check if we need to load more items
|
||
if (scrollBottom >= scrollHeight - threshold && this.hasMoreItems) {
|
||
this.loadMoreItems();
|
||
}
|
||
|
||
// Update visible items for virtual scrolling
|
||
if (this.options.enableVirtualScroll) {
|
||
this.updateVisibleItems();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load more items (pagination)
|
||
*/
|
||
async loadMoreItems() {
|
||
if (this.isLoadingMore || !this.hasMoreItems || this.showingCommands) {
|
||
return;
|
||
}
|
||
|
||
this.isLoadingMore = true;
|
||
this.currentPage++;
|
||
|
||
try {
|
||
// Show loading indicator
|
||
this.showLoadingIndicator();
|
||
|
||
// Get the current endpoint
|
||
let endpoint = `/lm/${this.modelType}/relative-paths`;
|
||
if (this.modelType === 'prompt') {
|
||
if (this.searchType === 'embeddings') {
|
||
endpoint = '/lm/embeddings/relative-paths';
|
||
} else if (this.searchType === 'wildcards') {
|
||
endpoint = getWildcardSearchEndpoint();
|
||
} else if (this.searchType === 'custom_words') {
|
||
if (this.activeCommand?.categories) {
|
||
const categories = this.activeCommand.categories.join(',');
|
||
endpoint = `/lm/custom-words/search?category=${categories}`;
|
||
} else {
|
||
endpoint = '/lm/custom-words/search?enriched=true';
|
||
}
|
||
}
|
||
}
|
||
|
||
const queriesToExecute = this._getQueriesToExecute(this.currentSearchTerm);
|
||
const offset = this.items.length;
|
||
|
||
// Active-filter query params for loras (null when setting off)
|
||
const activeFiltersQuery = this._getActiveLoraFilters();
|
||
|
||
// Execute all queries in parallel with offset
|
||
const searchPromises = queriesToExecute.map(async (query) => {
|
||
const url = endpoint.includes('?')
|
||
? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`
|
||
: `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`;
|
||
const finalUrl = activeFiltersQuery ? `${url}&${activeFiltersQuery}` : url;
|
||
|
||
try {
|
||
const response = await api.fetchApi(finalUrl);
|
||
const data = await response.json();
|
||
return data.success ? (data.relative_paths || data.words || []) : [];
|
||
} catch (error) {
|
||
console.warn(`Search query failed for "${query}":`, error);
|
||
return [];
|
||
}
|
||
});
|
||
|
||
const resultsArrays = await Promise.all(searchPromises);
|
||
|
||
// Merge and deduplicate results with existing items
|
||
const seen = new Set(this.items.map(item => {
|
||
const itemKey = typeof item === 'object' && item.tag_name
|
||
? item.tag_name.toLowerCase()
|
||
: String(item).toLowerCase();
|
||
return itemKey;
|
||
}));
|
||
const newItems = [];
|
||
|
||
for (const resultArray of resultsArrays) {
|
||
for (const item of resultArray) {
|
||
const itemKey = typeof item === 'object' && item.tag_name
|
||
? item.tag_name.toLowerCase()
|
||
: String(item).toLowerCase();
|
||
|
||
if (!seen.has(itemKey)) {
|
||
seen.add(itemKey);
|
||
newItems.push(item);
|
||
}
|
||
}
|
||
}
|
||
|
||
// If we got fewer items than requested, we've reached the end
|
||
if (newItems.length < this.options.pageSize) {
|
||
this.hasMoreItems = false;
|
||
}
|
||
|
||
// If we got new items, append them and re-render
|
||
// IMPORTANT: Do NOT re-sort! Backend already returns results sorted by relevance
|
||
if (newItems.length > 0) {
|
||
this.items.push(...newItems);
|
||
|
||
// Update render
|
||
if (this.options.enableVirtualScroll) {
|
||
this.updateVirtualScrollHeight();
|
||
this.updateVisibleItems();
|
||
} else {
|
||
this.render();
|
||
}
|
||
} else {
|
||
this.hasMoreItems = false;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading more items:', error);
|
||
this.hasMoreItems = false;
|
||
} finally {
|
||
this.isLoadingMore = false;
|
||
this.hideLoadingIndicator();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show loading indicator at the bottom of the list
|
||
*/
|
||
showLoadingIndicator() {
|
||
if (!this.contentContainer) return;
|
||
|
||
let loadingEl = this.contentContainer.querySelector('.comfy-autocomplete-loading');
|
||
if (!loadingEl) {
|
||
loadingEl = document.createElement('div');
|
||
loadingEl.className = 'comfy-autocomplete-loading';
|
||
loadingEl.textContent = 'Loading more...';
|
||
loadingEl.style.cssText = `
|
||
padding: 12px;
|
||
text-align: center;
|
||
color: rgba(226, 232, 240, 0.5);
|
||
font-size: 12px;
|
||
`;
|
||
this.contentContainer.appendChild(loadingEl);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Hide loading indicator
|
||
*/
|
||
hideLoadingIndicator() {
|
||
if (!this.contentContainer) return;
|
||
|
||
const loadingEl = this.contentContainer.querySelector('.comfy-autocomplete-loading');
|
||
if (loadingEl) {
|
||
loadingEl.remove();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update the total height of the virtual scroll container
|
||
*/
|
||
updateVirtualScrollHeight() {
|
||
if (!this.contentContainer || !this.scrollContainer) return;
|
||
|
||
if (this._containsInformationalItems()) {
|
||
this.totalHeight = 0;
|
||
this.contentContainer.style.height = 'auto';
|
||
this.scrollContainer.style.maxHeight = `${this.options.visibleItems * this.options.itemHeight}px`;
|
||
this.scrollContainer.style.overflowY = 'hidden';
|
||
return;
|
||
}
|
||
|
||
this.totalHeight = this.items.length * this.options.itemHeight;
|
||
this.contentContainer.style.height = `${this.totalHeight}px`;
|
||
|
||
// Adjust scroll container max-height based on actual content
|
||
// Only show scrollbar when content exceeds visibleItems limit
|
||
const maxHeight = this.options.visibleItems * this.options.itemHeight;
|
||
const shouldShowScrollbar = this.totalHeight > maxHeight;
|
||
|
||
this.scrollContainer.style.maxHeight = shouldShowScrollbar ? `${maxHeight}px` : `${this.totalHeight}px`;
|
||
this.scrollContainer.style.overflowY = shouldShowScrollbar ? 'auto' : 'hidden';
|
||
}
|
||
|
||
/**
|
||
* Update which items are visible based on scroll position
|
||
*/
|
||
updateVisibleItems() {
|
||
if (!this.scrollContainer || !this.contentContainer) return;
|
||
|
||
if (this._containsInformationalItems()) {
|
||
this.contentContainer.innerHTML = '';
|
||
if (this.items[0]) {
|
||
this.contentContainer.appendChild(
|
||
this.createItemElement(this.items[0], 0, false, false)
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const scrollTop = this.scrollContainer.scrollTop;
|
||
const containerHeight = this.scrollContainer.clientHeight;
|
||
|
||
// Calculate which items should be visible with a larger buffer for smoother rendering
|
||
// Use a fixed buffer of 5 items to ensure selected item is always rendered
|
||
const startIndex = Math.max(0, Math.floor(scrollTop / this.options.itemHeight) - 5);
|
||
const endIndex = Math.min(
|
||
this.items.length - 1,
|
||
Math.ceil((scrollTop + containerHeight) / this.options.itemHeight) + 5
|
||
);
|
||
|
||
// Clear current content
|
||
this.contentContainer.innerHTML = '';
|
||
|
||
// Create spacer for items before visible range
|
||
if (startIndex > 0) {
|
||
const topSpacer = document.createElement('div');
|
||
topSpacer.style.height = `${startIndex * this.options.itemHeight}px`;
|
||
this.contentContainer.appendChild(topSpacer);
|
||
}
|
||
|
||
// Render visible items
|
||
const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
|
||
const isCommand = this.items[0] && typeof this.items[0] === 'object' && 'command' in this.items[0];
|
||
|
||
for (let i = startIndex; i <= endIndex; i++) {
|
||
const itemData = this.items[i];
|
||
const itemEl = this.createItemElement(itemData, i, isEnriched, isCommand);
|
||
this.contentContainer.appendChild(itemEl);
|
||
}
|
||
|
||
// Create spacer for items after visible range
|
||
if (endIndex < this.items.length - 1) {
|
||
const bottomSpacer = document.createElement('div');
|
||
bottomSpacer.style.height = `${(this.items.length - 1 - endIndex) * this.options.itemHeight}px`;
|
||
this.contentContainer.appendChild(bottomSpacer);
|
||
}
|
||
|
||
// Re-apply selection styling after re-rendering
|
||
// This ensures the selected item remains highlighted even after DOM updates
|
||
if (this.selectedIndex >= startIndex && this.selectedIndex <= endIndex) {
|
||
const selectedEl = this.contentContainer.querySelector(`.comfy-autocomplete-item[data-index="${this.selectedIndex}"]`);
|
||
if (selectedEl) {
|
||
selectedEl.classList.add('comfy-autocomplete-item-selected');
|
||
selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create a single item element
|
||
*/
|
||
createItemElement(itemData, index, isEnriched, isCommand = false) {
|
||
const item = document.createElement('div');
|
||
item.className = 'comfy-autocomplete-item';
|
||
item.dataset.index = index.toString();
|
||
item.style.cssText = `
|
||
height: ${this.options.itemHeight}px;
|
||
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;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 8px;
|
||
box-sizing: border-box;
|
||
`;
|
||
|
||
// Check if this is a command object (override parameter if needed)
|
||
if (!isCommand && itemData && typeof itemData === 'object' && 'command' in itemData) {
|
||
isCommand = true;
|
||
}
|
||
|
||
if (isWildcardInfoItem(itemData)) {
|
||
this._renderInformationalItem(item, itemData);
|
||
} else if (isCommand) {
|
||
// Render command item
|
||
const cmdSpan = document.createElement('span');
|
||
cmdSpan.className = 'lm-autocomplete-command-name';
|
||
cmdSpan.textContent = itemData.command;
|
||
|
||
const labelSpan = document.createElement('span');
|
||
labelSpan.className = 'lm-autocomplete-command-label';
|
||
labelSpan.textContent = itemData.label;
|
||
|
||
item.appendChild(cmdSpan);
|
||
item.appendChild(labelSpan);
|
||
item.style.gap = '12px';
|
||
} else if (isEnriched) {
|
||
this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
|
||
} else {
|
||
const nameSpan = document.createElement('span');
|
||
nameSpan.className = 'lm-autocomplete-name';
|
||
// Use display text without extension for cleaner UI
|
||
const displayTextWithoutExt = this._getDisplayText(itemData);
|
||
nameSpan.innerHTML = this.highlightMatch(displayTextWithoutExt, this.currentSearchTerm);
|
||
nameSpan.style.cssText = `
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
`;
|
||
item.appendChild(nameSpan);
|
||
}
|
||
|
||
// Prevent textarea from losing focus when clicking dropdown items.
|
||
// Without this, the blur event fires before click, and the blur handler's
|
||
// formatAutocompleteTextOnBlur() modifies the text and triggers hide()
|
||
// via suppressAutocompleteOnce, removing this item from the DOM before
|
||
// the click handler can execute. This specifically breaks the case where
|
||
// the text has a comma not followed by a space (e.g. "<lora:X:1>,search").
|
||
item.addEventListener('mousedown', (e) => {
|
||
e.preventDefault();
|
||
});
|
||
|
||
// Hover and selection handlers
|
||
item.addEventListener('mouseenter', () => {
|
||
this.selectItem(index, { manual: true });
|
||
});
|
||
|
||
item.addEventListener('mouseleave', () => {
|
||
this.hidePreview();
|
||
});
|
||
|
||
// Click handler
|
||
item.addEventListener('click', () => {
|
||
if (isWildcardInfoItem(itemData)) {
|
||
return;
|
||
}
|
||
|
||
if (isCommand) {
|
||
this._insertCommand(itemData.command);
|
||
} else {
|
||
const insertPath = isEnriched ? itemData.tag_name : itemData;
|
||
this.insertSelection(insertPath);
|
||
}
|
||
});
|
||
|
||
return item;
|
||
}
|
||
|
||
show() {
|
||
if (!this.items || this.items.length === 0) {
|
||
this.hide();
|
||
return;
|
||
}
|
||
|
||
this._maybeShowFirstRunHint();
|
||
// For virtual scrolling, render items first so positionAtCursor can measure width correctly
|
||
if (this.options.enableVirtualScroll && this.contentContainer) {
|
||
this.dropdown.style.display = 'block';
|
||
this.isVisible = true;
|
||
// Skip updateVisibleItems if showing commands (already rendered by _renderCommandList)
|
||
if (!this.showingCommands) {
|
||
this.updateVisibleItems();
|
||
}
|
||
this.positionAtCursor();
|
||
} else {
|
||
// 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';
|
||
|
||
// Temporarily remove width constraints to allow content to expand naturally
|
||
// This prevents items.scrollWidth from being limited by a narrow container
|
||
const originalWidth = this.dropdown.style.width;
|
||
this.dropdown.style.width = 'auto';
|
||
this.dropdown.style.minWidth = '200px';
|
||
|
||
// Measure the content width
|
||
let maxWidth = 200; // minimum width
|
||
// For virtual scrolling, query items from contentContainer; otherwise from dropdown
|
||
const container = this.options.enableVirtualScroll && this.contentContainer
|
||
? this.contentContainer
|
||
: this.dropdown;
|
||
const items = container.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.minWidth = '';
|
||
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;
|
||
this.hasManualSelection = false;
|
||
this.showingCommands = false;
|
||
|
||
// Remove discoverability hints attached to the dropdown
|
||
this._removeCommandListFooter();
|
||
this._removeFirstRunHint();
|
||
|
||
// Clear items to prevent stale data from being displayed
|
||
// when autocomplete is shown again
|
||
this.items = [];
|
||
this.wildcardMeta = null;
|
||
|
||
// Clear content container to prevent stale items from showing
|
||
if (this.contentContainer) {
|
||
// Virtual scrolling mode - clear content container
|
||
this.contentContainer.innerHTML = '';
|
||
} else {
|
||
// Non-virtual scrolling mode - clear dropdown direct children
|
||
this.dropdown.innerHTML = '';
|
||
}
|
||
|
||
// Reset virtual scrolling state
|
||
this.virtualScrollOffset = 0;
|
||
this.currentPage = 0;
|
||
this.hasMoreItems = true;
|
||
this.isLoadingMore = false;
|
||
this.totalHeight = 0;
|
||
|
||
// Reset scroll position
|
||
if (this.scrollContainer) {
|
||
this.scrollContainer.scrollTop = 0;
|
||
}
|
||
|
||
// 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, { manual = false } = {}) {
|
||
// Remove previous selection
|
||
const container = this.options.enableVirtualScroll && this.contentContainer
|
||
? this.contentContainer
|
||
: this.dropdown;
|
||
const prevSelected = container.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;
|
||
this.hasManualSelection = manual;
|
||
|
||
// For virtual scrolling, we need to ensure the item is rendered
|
||
if (this.options.enableVirtualScroll && this.scrollContainer) {
|
||
// Calculate if the item is currently visible
|
||
const itemTop = index * this.options.itemHeight;
|
||
const itemBottom = itemTop + this.options.itemHeight;
|
||
const scrollTop = this.scrollContainer.scrollTop;
|
||
const containerHeight = this.scrollContainer.clientHeight;
|
||
const scrollBottom = scrollTop + containerHeight;
|
||
|
||
// If item is not visible, scroll to make it visible
|
||
if (itemTop < scrollTop || itemBottom > scrollBottom) {
|
||
// Scroll to position the item in the visible area
|
||
// Position item at 1/3 from top for better visibility
|
||
const targetScrollTop = Math.max(0, itemTop - containerHeight / 3);
|
||
this.scrollContainer.scrollTop = targetScrollTop;
|
||
|
||
// Re-render visible items after scroll
|
||
this.updateVisibleItems();
|
||
|
||
// Apply selection after DOM is updated
|
||
// Use setTimeout to ensure DOM has been re-rendered
|
||
setTimeout(() => {
|
||
this._applyItemSelection(index);
|
||
}, 0);
|
||
} else {
|
||
// Item is already visible, apply selection immediately
|
||
this._applyItemSelection(index);
|
||
}
|
||
} else {
|
||
// Traditional rendering
|
||
const item = container.children[index];
|
||
if (item) {
|
||
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 && !this._isSelectableInfoItem(this.items[index])) {
|
||
if (typeof this.behavior.showPreview === 'function') {
|
||
this.behavior.showPreview(this, this.items[index], item);
|
||
} else if (this.previewTooltip) {
|
||
this.showPreviewForItem(this.items[index], item);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Apply selection styling to an item (used after virtual scroll re-render)
|
||
* @param {number} index - Index of item to select
|
||
*/
|
||
_applyItemSelection(index) {
|
||
if (!this.contentContainer) return;
|
||
|
||
// Find the item element using data-index attribute
|
||
const selectedEl = this.contentContainer.querySelector(`.comfy-autocomplete-item[data-index="${index}"]`);
|
||
|
||
if (selectedEl) {
|
||
selectedEl.classList.add('comfy-autocomplete-item-selected');
|
||
selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
|
||
|
||
// Show preview for selected item
|
||
if (this.options.showPreview && !this._isSelectableInfoItem(this.items[index])) {
|
||
if (typeof this.behavior.showPreview === 'function') {
|
||
this.behavior.showPreview(this, this.items[index], selectedEl);
|
||
} else if (this.previewTooltip) {
|
||
this.showPreviewForItem(this.items[index], selectedEl);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
handleKeyDown(e) {
|
||
if (!this.isVisible) {
|
||
return;
|
||
}
|
||
|
||
switch (e.key) {
|
||
case 'ArrowDown':
|
||
e.preventDefault();
|
||
if (this.options.enableVirtualScroll && this.scrollContainer) {
|
||
// For virtual scrolling, handle boundary cases
|
||
if (this.selectedIndex >= this.items.length - 1) {
|
||
// Already at last item, try to load more
|
||
if (this.hasMoreItems && !this.isLoadingMore) {
|
||
this.loadMoreItems().then(() => {
|
||
// After loading more, select the next item
|
||
if (this.selectedIndex < this.items.length - 1) {
|
||
this.selectItem(this.selectedIndex + 1, { manual: true });
|
||
}
|
||
});
|
||
}
|
||
} else {
|
||
this.selectItem(this.selectedIndex + 1, { manual: true });
|
||
}
|
||
} else {
|
||
this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1), { manual: true });
|
||
}
|
||
break;
|
||
|
||
case 'ArrowUp':
|
||
e.preventDefault();
|
||
if (this.options.enableVirtualScroll && this.scrollContainer) {
|
||
// For virtual scrolling, handle top boundary
|
||
if (this.selectedIndex <= 0) {
|
||
// Already at first item, ensure it's selected
|
||
this.selectItem(0, { manual: true });
|
||
} else {
|
||
this.selectItem(this.selectedIndex - 1, { manual: true });
|
||
}
|
||
} else {
|
||
this.selectItem(Math.max(this.selectedIndex - 1, 0), { manual: true });
|
||
}
|
||
break;
|
||
|
||
case 'Enter':
|
||
case 'Tab':
|
||
if (!shouldAcceptAutocompleteKey(e.key)) {
|
||
break;
|
||
}
|
||
|
||
{
|
||
const liveSearchTerm = this._getLiveSearchTermForAcceptance();
|
||
const acceptIndex = this._getAcceptSelectionIndex(liveSearchTerm);
|
||
if (acceptIndex !== -1 && acceptIndex !== this.selectedIndex) {
|
||
this.selectItem(acceptIndex);
|
||
}
|
||
}
|
||
|
||
if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
|
||
e.preventDefault();
|
||
if (this.showingCommands) {
|
||
// Insert command
|
||
this._insertCommand(this.items[this.selectedIndex].command);
|
||
} else {
|
||
const selectedItem = this.items[this.selectedIndex];
|
||
if (isWildcardInfoItem(selectedItem)) {
|
||
if (selectedItem.type === 'wildcard_empty_state') {
|
||
this._openWildcardsFolder();
|
||
}
|
||
break;
|
||
}
|
||
|
||
// Insert selection (handle enriched items)
|
||
const insertPath = typeof selectedItem === 'object' && 'tag_name' in selectedItem
|
||
? selectedItem.tag_name
|
||
: selectedItem;
|
||
this.insertSelection(insertPath);
|
||
}
|
||
}
|
||
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 activeRange = this.getActiveSearchRange(currentValue);
|
||
const caretPos = activeRange.end;
|
||
const fullSearchTerm = activeRange.text;
|
||
let replaceStartPos = activeRange.trimmedStart;
|
||
|
||
// For regular tag autocomplete (no command), only replace the last space-separated token
|
||
// This allows "hello 1gi" + selecting "1girl" to become "hello 1girl, "
|
||
// However, if the user typed a multi-word phrase that matches a tag (e.g., "looking to the side"
|
||
// matching "looking_to_the_side"), replace the entire phrase instead of just the last word.
|
||
// Command mode (e.g., "/character miku") should replace the entire command+search
|
||
let searchTerm = fullSearchTerm;
|
||
if (this.modelType === 'prompt' && this.searchType === 'custom_words' && !this.activeCommand) {
|
||
// Check if the selectedItem exists and its tag_name matches the full search term
|
||
// when converted to underscore format (Danbooru convention)
|
||
const selectedItem = this.selectedIndex >= 0 ? this.items[this.selectedIndex] : null;
|
||
const selectedTagName = selectedItem && typeof selectedItem === 'object' && 'tag_name'
|
||
? selectedItem.tag_name
|
||
: null;
|
||
|
||
// Convert full search term to underscore format and check if it matches selected tag
|
||
// Normalize multiple spaces to single underscore for matching (e.g., "looking to the side" -> "looking_to_the_side")
|
||
const underscoreVersion = fullSearchTerm.replace(/ +/g, '_').toLowerCase();
|
||
const selectedTagLower = selectedTagName?.toLowerCase() ?? '';
|
||
|
||
// If multi-word search term is a prefix or suffix of the selected tag,
|
||
// replace the entire phrase. This handles cases where user types partial tag name.
|
||
// Examples:
|
||
// - "looking to the" -> "looking_to_the_side" (prefix match)
|
||
// - "to the side" -> "looking_to_the_side" (suffix match)
|
||
// - "looking to the side" -> "looking_to_the_side" (exact match)
|
||
if (fullSearchTerm.includes(' ') && (
|
||
selectedTagLower.startsWith(underscoreVersion) ||
|
||
selectedTagLower.endsWith(underscoreVersion) ||
|
||
underscoreVersion === selectedTagLower
|
||
)) {
|
||
searchTerm = fullSearchTerm;
|
||
replaceStartPos = activeRange.trimmedStart;
|
||
} else {
|
||
searchTerm = this._getLastSpaceToken(fullSearchTerm);
|
||
replaceStartPos = searchTerm === fullSearchTerm
|
||
? activeRange.trimmedStart
|
||
: caretPos - searchTerm.length;
|
||
}
|
||
}
|
||
|
||
// Only replace the search term, not everything after the last comma
|
||
const newValue = currentValue.substring(0, replaceStartPos) + insertText + currentValue.substring(caretPos);
|
||
const newCaretPos = replaceStartPos + insertText.length;
|
||
|
||
this.inputElement.value = newValue;
|
||
this._storeLastAcceptedBoundary({
|
||
start: replaceStartPos,
|
||
end: newCaretPos,
|
||
insertedText: insertText,
|
||
textSnapshot: newValue.substring(0, newCaretPos),
|
||
});
|
||
|
||
// 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 formatAutocompleteInsertion(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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle toggle setting command (e.g., /autocomplete, /activefilters)
|
||
* @param {Object} command - The toggle command with settingId and value
|
||
*/
|
||
async _handleToggleSettingCommand(command) {
|
||
const { settingId, value } = command;
|
||
|
||
try {
|
||
const success = await setLoraManagerSettingValue(settingId, value);
|
||
if (success) {
|
||
this._showToggleFeedback(command, value);
|
||
this._clearCurrentToken();
|
||
} else {
|
||
throw new Error('settings API unavailable');
|
||
}
|
||
} catch (error) {
|
||
console.error('[Lora Manager] Failed to toggle setting:', error);
|
||
showToast({
|
||
severity: 'error',
|
||
summary: 'Error',
|
||
detail: 'Failed to toggle autocomplete setting',
|
||
life: 3000
|
||
});
|
||
}
|
||
|
||
this.hide();
|
||
}
|
||
|
||
/**
|
||
* Show visual feedback for toggle action using toast
|
||
* @param {Object} command - The toggle command that was executed
|
||
* @param {boolean} enabled - New autocomplete state
|
||
*/
|
||
_showToggleFeedback(command, enabled) {
|
||
showToast({
|
||
severity: enabled ? 'success' : 'secondary',
|
||
summary: command.feedbackSummary || (enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled'),
|
||
detail: command.feedbackDetail || (enabled
|
||
? 'Tag autocomplete is now ON. Type to see suggestions.'
|
||
: 'Tag autocomplete is now OFF. Use /autocomplete or the node right-click menu to re-enable.'),
|
||
life: 3000
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Clear the current command token from input
|
||
* Preserves leading spaces after delimiters (e.g., "1girl, /emb" -> "1girl, ")
|
||
*/
|
||
_clearCurrentToken() {
|
||
const currentValue = this.inputElement.value;
|
||
const activeRange = this.getActiveSearchRange(currentValue);
|
||
const caretPos = activeRange.end;
|
||
const lastSegment = activeRange.rawText;
|
||
|
||
// Find the command start position, preserving leading spaces
|
||
// lastSegment includes leading spaces (e.g., " /emb"), find where command actually starts
|
||
const commandMatch = lastSegment.match(/^(\s*)(\/\w+)/);
|
||
if (commandMatch) {
|
||
// commandMatch[1] is leading spaces, commandMatch[2] is the command
|
||
const leadingSpaces = commandMatch[1].length;
|
||
// Keep the spaces by starting after them
|
||
const commandStartPos = activeRange.start + leadingSpaces;
|
||
|
||
// Skip trailing spaces when deleting
|
||
let endPos = caretPos;
|
||
while (endPos < currentValue.length && currentValue[endPos] === ' ') {
|
||
endPos++;
|
||
}
|
||
|
||
const newValue = currentValue.substring(0, commandStartPos) + currentValue.substring(endPos);
|
||
const newCaretPos = commandStartPos;
|
||
|
||
this.inputElement.value = newValue;
|
||
|
||
// Trigger input event to notify about the change
|
||
const event = new Event('input', { bubbles: true });
|
||
this.inputElement.dispatchEvent(event);
|
||
|
||
// Focus back to input and position cursor
|
||
this.inputElement.focus();
|
||
this.inputElement.setSelectionRange(newCaretPos, newCaretPos);
|
||
} else {
|
||
// Fallback: delete the whole last segment (original behavior)
|
||
const commandStartPos = activeRange.start;
|
||
|
||
let endPos = caretPos;
|
||
while (endPos < currentValue.length && currentValue[endPos] === ' ') {
|
||
endPos++;
|
||
}
|
||
|
||
const newValue = currentValue.substring(0, commandStartPos) + currentValue.substring(endPos);
|
||
const newCaretPos = commandStartPos;
|
||
|
||
this.inputElement.value = newValue;
|
||
|
||
const event = new Event('input', { bubbles: true });
|
||
this.inputElement.dispatchEvent(event);
|
||
|
||
this.inputElement.focus();
|
||
this.inputElement.setSelectionRange(newCaretPos, newCaretPos);
|
||
}
|
||
}
|
||
|
||
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 (this.onScroll && this.scrollContainer) {
|
||
this.scrollContainer.removeEventListener('scroll', this.onScroll);
|
||
this.onScroll = 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, refreshLoraSyntaxFormat };
|