mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-07 14:30:15 -03:00
Add /af and /noaf toggle commands (plus /activefilters aliases) to the
loras autocomplete widget. When enabled (default off), suggestions are
matched within the active filters (folder, base model, tags, auto-tags,
license, tag logic) persisted by the LoRA Manager page in localStorage,
keeping the match pool consistent with the list endpoint, including the
global show_only_sfw setting.
Backend: /lm/{prefix}/relative-paths accepts the filter query params and
pre-filters the scanner cache with ModelFilterSet. The presence of the
recursive param signals the filter pipeline to run even without concrete
filters so global settings stay in parity with the list endpoint.
3026 lines
112 KiB
JavaScript
3026 lines
112 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,
|
|
} from "./settings.js";
|
|
import { showToast } from "./utils.js";
|
|
|
|
// 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
|
|
'/ac': {
|
|
type: 'toggle_setting',
|
|
settingId: 'loramanager.prompt_tag_autocomplete',
|
|
value: true,
|
|
label: 'Autocomplete: ON',
|
|
condition: () => !getPromptTagAutocompletePreference()
|
|
},
|
|
'/noac': {
|
|
type: 'toggle_setting',
|
|
settingId: 'loramanager.prompt_tag_autocomplete',
|
|
value: false,
|
|
label: 'Autocomplete: OFF',
|
|
condition: () => getPromptTagAutocompletePreference()
|
|
},
|
|
};
|
|
|
|
// Command definitions for LoRA active-filters search
|
|
// Aliases (/activefilters, /noactivefilters) mirror /emb ↔ /embedding
|
|
const LORAS_COMMANDS = {
|
|
'/af': {
|
|
type: 'toggle_setting',
|
|
settingId: 'loramanager.lora_active_filters_autocomplete',
|
|
value: true,
|
|
label: 'Active Filters: ON',
|
|
feedbackSummary: 'Active Filters Search: ON',
|
|
feedbackDetail: 'LoRA autocomplete now searches within the active filters of the LoRA Manager page.',
|
|
condition: () => !getLoraActiveFiltersAutocompletePreference()
|
|
},
|
|
'/noaf': {
|
|
type: 'toggle_setting',
|
|
settingId: 'loramanager.lora_active_filters_autocomplete',
|
|
value: false,
|
|
label: 'Active Filters: OFF',
|
|
feedbackSummary: 'Active Filters Search: OFF',
|
|
feedbackDetail: 'LoRA autocomplete searches the full library again.',
|
|
condition: () => getLoraActiveFiltersAutocompletePreference()
|
|
},
|
|
'/activefilters': {
|
|
type: 'toggle_setting',
|
|
settingId: 'loramanager.lora_active_filters_autocomplete',
|
|
value: true,
|
|
label: 'Active Filters: 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: 'Active Filters: 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;
|
|
|
|
// 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 (/af, /noaf)
|
|
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 (/af, /noaf)
|
|
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 (/ac, /noac)
|
|
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
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Update virtual scroll height for virtual scrolling mode
|
|
if (this.contentContainer) {
|
|
this.updateVirtualScrollHeight();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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 (/ac, /noac)
|
|
* @param {Object} command - The toggle command with settingId and value
|
|
*/
|
|
async _handleToggleSettingCommand(command) {
|
|
const { settingId, value } = command;
|
|
|
|
try {
|
|
// Use ComfyUI's setting API to update global setting
|
|
const settingManager = app?.extensionManager?.setting;
|
|
if (settingManager && typeof settingManager.set === 'function') {
|
|
await settingManager.set(settingId, value);
|
|
this._showToggleFeedback(command, value);
|
|
this._clearCurrentToken();
|
|
} else {
|
|
// Fallback: use legacy settings API
|
|
const setting = app.ui.settings.settingsById?.[settingId];
|
|
if (setting) {
|
|
app.ui.settings.setSettingValue(settingId, value);
|
|
this._showToggleFeedback(command, value);
|
|
this._clearCurrentToken();
|
|
}
|
|
}
|
|
} 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 /ac to re-enable.'),
|
|
life: 3000
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clear the current command token from input
|
|
* Preserves leading spaces after delimiters (e.g., "1girl, /ac" -> "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., " /ac"), 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 };
|