import { api } from "../../scripts/api.js"; import { app } from "../../scripts/app.js"; import { TextAreaCaretHelper } from "./textarea_caret_helper.js"; import { getPromptTagAutocompletePreference, getTagSpaceReplacementPreference } from "./settings.js"; import { showToast } from "./utils.js"; // Command definitions for category filtering const TAG_COMMANDS = { '/character': { categories: [4, 11], label: 'Character' }, '/char': { 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' }, // 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() }, }; // 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, ''); } function parseSearchTokens(term = '') { const include = []; const exclude = []; term.split(/\s+/).forEach((rawTerm) => { const token = rawTerm.trim(); if (!token) { return; } if (token.startsWith('-') && token.length > 1) { exclude.push(token.slice(1).toLowerCase()); } else { include.push(token.toLowerCase()); } }); return { include, exclude }; } function createDefaultBehavior(modelType) { return { enablePreview: false, async getInsertText(_instance, relativePath) { const trimmed = relativePath?.trim() ?? ''; if (!trimmed) { return ''; } return `${trimmed}, `; }, }; } const MODEL_BEHAVIORS = { loras: { enablePreview: true, init(instance) { if (!instance.options.showPreview) { return; } instance.initPreviewTooltip({ modelType: instance.modelType }); }, showPreview(instance, relativePath, itemElement) { if (!instance.previewTooltip) { return; } instance.showPreviewForItem(relativePath, itemElement); }, hidePreview(instance) { if (!instance.previewTooltip) { return; } instance.previewTooltip.hide(); }, destroy(instance) { if (instance.previewTooltip) { instance.previewTooltip.cleanup(); instance.previewTooltip = null; } }, async getInsertText(_instance, relativePath) { const fileName = removeLoraExtension(splitRelativePath(relativePath).fileName); let strength = 1.0; let hasStrength = false; let clipStrength = null; try { const response = await api.fetchApi(`/lm/loras/usage-tips-by-path?relative_path=${encodeURIComponent(relativePath)}`); if (response.ok) { const data = await response.json(); if (data.success && data.usage_tips) { try { const usageTips = JSON.parse(data.usage_tips); const parsedStrength = parseUsageTipNumber(usageTips.strength); if (parsedStrength !== null) { strength = parsedStrength; hasStrength = true; } const clipSource = usageTips.clip_strength ?? usageTips.clipStrength; const parsedClipStrength = parseUsageTipNumber(clipSource); if (parsedClipStrength !== null) { clipStrength = parsedClipStrength; if (!hasStrength) { strength = 1.0; } } } catch (parseError) { console.warn('Failed to parse usage tips JSON:', parseError); } } } } catch (error) { console.warn('Failed to fetch usage tips:', error); } if (clipStrength !== null) { return `, `; } return `, `; } }, embeddings: { enablePreview: true, init(instance) { if (!instance.options.showPreview) { return; } instance.initPreviewTooltip({ modelType: instance.modelType }); }, async getInsertText(_instance, relativePath) { const { directories, fileName } = splitRelativePath(relativePath); const trimmedName = removeGeneralExtension(fileName); const folder = directories.length ? `${directories.join('/')}/` : ''; return `embedding:${folder}${trimmedName}, `; }, }, custom_words: { enablePreview: false, async getInsertText(_instance, relativePath) { return `${relativePath}, `; }, }, prompt: { enablePreview: true, init(instance) { if (!instance.options.showPreview) { return; } instance.initPreviewTooltip({ modelType: 'embeddings' }); }, showPreview(instance, relativePath, itemElement) { if (!instance.previewTooltip || instance.searchType !== 'embeddings') { return; } instance.showPreviewForItem(relativePath, itemElement); }, hidePreview(instance) { if (!instance.previewTooltip) { 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 `embedding:${folder}${trimmedName}, `; } else { let tagText = relativePath; if (getTagSpaceReplacementPreference()) { tagText = tagText.replace(/_/g, ' '); } return `${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: 20, minChars: 1, debounceDelay: 200, showPreview: this.behavior.enablePreview ?? false, ...options }; this.dropdown = null; this.selectedIndex = -1; this.items = []; this.debounceTimer = null; this.isVisible = false; this.currentSearchTerm = ''; this.previewTooltip = null; this.previewTooltipPromise = null; this.searchType = null; // 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.init(); } init() { this.createDropdown(); this.bindEvents(); } createDropdown() { this.dropdown = document.createElement('div'); this.dropdown.className = 'comfy-autocomplete-dropdown'; // Apply new color scheme this.dropdown.style.cssText = ` position: absolute; z-index: 10000; overflow-y: visible; background-color: rgba(40, 44, 52, 0.95); border: 1px solid rgba(226, 232, 240, 0.2); border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); display: none; min-width: 200px; width: auto; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); `; // Custom scrollbar styles with new color scheme const style = document.createElement('style'); style.textContent = ` .comfy-autocomplete-dropdown::-webkit-scrollbar { width: 8px; } .comfy-autocomplete-dropdown::-webkit-scrollbar-track { background: rgba(40, 44, 52, 0.3); border-radius: 4px; } .comfy-autocomplete-dropdown::-webkit-scrollbar-thumb { background: rgba(226, 232, 240, 0.2); border-radius: 4px; } .comfy-autocomplete-dropdown::-webkit-scrollbar-thumb:hover { background: rgba(226, 232, 240, 0.4); } `; document.head.appendChild(style); // Append to body to avoid overflow issues document.body.appendChild(this.dropdown); if (typeof this.behavior.init === 'function') { this.behavior.init(this); } } initPreviewTooltip(options = {}) { if (this.previewTooltip || this.previewTooltipPromise) { return; } // Dynamically import and create preview tooltip this.previewTooltipPromise = import('./preview_tooltip.js').then(module => { const config = { modelType: this.modelType, ...options }; this.previewTooltip = new module.PreviewTooltip(config); }).catch(err => { console.warn('Failed to load preview tooltip:', err); }).finally(() => { this.previewTooltipPromise = null; }); } bindEvents() { // Handle input changes this.onInput = (e) => { this.handleInput(e.target.value); }; this.inputElement.addEventListener('input', this.onInput); // Handle keyboard navigation this.onKeyDown = (e) => { this.handleKeyDown(e); }; this.inputElement.addEventListener('keydown', this.onKeyDown); // Handle focus out to hide dropdown this.onBlur = () => { // Delay hiding to allow for clicks on dropdown items setTimeout(() => { this.hide(); }, 150); }; this.inputElement.addEventListener('blur', this.onBlur); // Handle clicks outside to hide dropdown this.onDocumentClick = (e) => { if (!this.dropdown) { return; } const target = e.target; if (!(target instanceof Node)) { return; } if (!this.dropdown.contains(target) && target !== this.inputElement) { this.hide(); } }; document.addEventListener('click', this.onDocumentClick); // Mark this element as having autocomplete events bound this.inputElement._autocompleteEventsBound = true; } /** * Check if the autocomplete is valid (input element is in DOM and events are bound) */ isValid() { return this.inputElement && document.body.contains(this.inputElement) && this.inputElement._autocompleteEventsBound === true; } /** * Check if events need to be rebound (element exists but events not bound) */ needsRebind() { return this.inputElement && document.body.contains(this.inputElement) && this.inputElement._autocompleteEventsBound !== true; } /** * Rebind events to the input element (useful after Vue moves the element) */ rebindEvents() { // Remove old listeners if they exist if (this.onInput) { this.inputElement.removeEventListener('input', this.onInput); } if (this.onKeyDown) { this.inputElement.removeEventListener('keydown', this.onKeyDown); } if (this.onBlur) { this.inputElement.removeEventListener('blur', this.onBlur); } // Rebind all events this.bindEvents(); console.log('[Lora Manager] Autocomplete events rebound'); } /** * Refresh the TextAreaCaretHelper (useful after element properties change) */ refreshHelper() { if (this.inputElement && document.body.contains(this.inputElement)) { this.helper = new TextAreaCaretHelper(this.inputElement, () => app.canvas.ds.scale); } } handleInput(value = '') { // Clear previous debounce timer if (this.debounceTimer) { clearTimeout(this.debounceTimer); } // Get the search term (text after last comma / '>') const rawSearchTerm = this.getSearchTerm(value); let searchTerm = rawSearchTerm; let endpoint = `/lm/${this.modelType}/relative-paths`; // For embeddings, only trigger autocomplete when the current token // starts with the explicit "emb:" prefix. This avoids interrupting // normal prompt typing while still allowing quick manual triggering. if (this.modelType === 'embeddings') { const match = rawSearchTerm.match(/^emb:(.*)$/i); if (!match) { this.hide(); return; } searchTerm = (match[1] || '').trim(); } // For prompt model type, check if we're searching embeddings, 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 { // 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; } } } if (searchTerm.length < this.options.minChars) { this.hide(); return; } // Debounce the search this.debounceTimer = setTimeout(() => { this.search(searchTerm, endpoint); }, this.options.debounceDelay); } getSearchTerm(value) { // Use helper to get text before cursor for more accurate positioning const beforeCursor = this.helper.getBeforeCursor(); if (!beforeCursor) { return ''; } // Split on comma and '>' delimiters only (do not split on spaces) const segments = beforeCursor.split(/[,\>]+/); // Return the last non-empty segment as search term const lastSegment = segments[segments.length - 1] || ''; return lastSegment.trim(); } /** * 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); } /** * 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 = typeof item === 'object' && item.tag_name ? item.tag_name : String(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 }; } async search(term = '', endpoint = null) { try { this.currentSearchTerm = term; if (!endpoint) { endpoint = `/lm/${this.modelType}/relative-paths`; } // Generate multiple query variations for better matching const queryVariations = this._generateQueryVariations(term); if (queryVariations.length === 0) { this.items = []; this.hide(); return; } // Limit the number of parallel queries to avoid overwhelming the server const queriesToExecute = queryVariations.slice(0, 4); // 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}`; try { const response = await api.fetchApi(url); 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 const seen = new Set(); const mergedItems = []; 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); mergedItems.push(item); } } } // Score and sort results: exact matches first, then by match quality const scoredItems = mergedItems.map(item => { let bestScore = -1; let isExact = false; for (const query of queriesToExecute) { const match = this._matchItem(item, query); if (match.matched) { // Higher score for exact matches const score = match.isExactMatch ? 1000 : 100; if (score > bestScore) { bestScore = score; isExact = match.isExactMatch; } } } return { item, score: bestScore, isExact }; }); // Sort by score (descending), exact matches first scoredItems.sort((a, b) => { if (b.isExact !== a.isExact) { return b.isExact ? 1 : -1; } return b.score - a.score; }); // Extract just the items const sortedItems = scoredItems.map(s => s.item); if (sortedItems.length > 0) { this.items = sortedItems; this.render(); this.show(); } else { this.items = []; this.hide(); } } catch (error) { console.error('Autocomplete search error:', error); this.items = []; this.hide(); } } /** * 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 (TAG_COMMANDS[partialCommand]) { const cmd = TAG_COMMANDS[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., "/char miku") const commandPart = trimmed.slice(0, spaceIndex).toLowerCase(); const searchPart = trimmed.slice(spaceIndex + 1).trim(); if (TAG_COMMANDS[commandPart]) { const cmd = TAG_COMMANDS[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 = '') { const filterLower = filter.toLowerCase(); // Get unique commands (avoid duplicates like /char and /character) const seenLabels = new Set(); const commands = []; for (const [cmd, info] of Object.entries(TAG_COMMANDS)) { if (seenLabels.has(info.label)) continue; // 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)) { seenLabels.add(info.label); 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() { this.dropdown.innerHTML = ''; this.selectedIndex = -1; this.items.forEach((item, index) => { const itemEl = document.createElement('div'); itemEl.className = 'comfy-autocomplete-item comfy-autocomplete-command'; 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; `; itemEl.addEventListener('mouseenter', () => { this.selectItem(index); }); itemEl.addEventListener('click', () => { this._insertCommand(item.command); }); this.dropdown.appendChild(itemEl); }); // Remove border from last item if (this.dropdown.lastChild) { this.dropdown.lastChild.style.borderBottom = 'none'; } // Auto-select first item if (this.items.length > 0) { setTimeout(() => this.selectItem(0), 100); } } /** * Insert a command into the input * @param {string} command - The command to insert (e.g., "/char") */ _insertCommand(command) { const currentValue = this.inputElement.value; const caretPos = this.getCaretPosition(); // Find the start of the current command being typed const beforeCursor = currentValue.substring(0, caretPos); const segments = beforeCursor.split(/[,\>]+/); const lastSegment = segments[segments.length - 1] || ''; let commandStartPos = caretPos - lastSegment.length; // Preserve leading space if the last segment starts with a space // This handles cases like "1girl, /character" where we want to keep the space // after the comma instead of replacing it if (lastSegment.length > 0 && lastSegment[0] === ' ') { // Move start position past the leading space to preserve it commandStartPos = commandStartPos + 1; } // Insert command with trailing space const insertText = command + ' '; const newValue = currentValue.substring(0, commandStartPos) + insertText + currentValue.substring(caretPos); 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.dropdown.innerHTML = ''; this.selectedIndex = -1; // Early return if no items to prevent empty dropdown if (!this.items || this.items.length === 0) { return; } // Check if items are enriched (have tag_name, category, post_count) const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0]; this.items.forEach((itemData, index) => { const item = document.createElement('div'); item.className = 'comfy-autocomplete-item'; // Get the display text and path for insertion const displayText = isEnriched ? itemData.tag_name : itemData; const insertPath = isEnriched ? itemData.tag_name : itemData; if (isEnriched) { // Render enriched item with category badge and post count this._renderEnrichedItem(item, itemData, this.currentSearchTerm); } else { // Create highlighted content for simple items, wrapped in a span // to prevent flex layout from breaking up the text const nameSpan = document.createElement('span'); nameSpan.className = 'lm-autocomplete-name'; nameSpan.innerHTML = this.highlightMatch(displayText, this.currentSearchTerm); nameSpan.style.cssText = ` flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; `; item.appendChild(nameSpan); } // Apply item styles with new color scheme item.style.cssText = ` padding: 8px 12px; cursor: pointer; color: rgba(226, 232, 240, 0.8); border-bottom: 1px solid rgba(226, 232, 240, 0.1); transition: all 0.2s ease; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; position: relative; display: flex; justify-content: space-between; align-items: center; gap: 8px; `; // Hover and selection handlers item.addEventListener('mouseenter', () => { this.selectItem(index); }); item.addEventListener('mouseleave', () => { this.hidePreview(); }); // Click handler item.addEventListener('click', () => { this.insertSelection(insertPath); }); this.dropdown.appendChild(item); }); // Remove border from last item if (this.dropdown.lastChild) { this.dropdown.lastChild.style.borderBottom = 'none'; } // Auto-select the first item with a small delay if (this.items.length > 0) { setTimeout(() => { this.selectItem(0); }, 100); } } /** * 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); } 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, '$1', ); } showPreviewForItem(relativePath, itemElement) { if (!this.options.showPreview || !this.previewTooltip) return; // Extract filename without extension for preview const fileName = relativePath.split(/[/\\]/).pop(); const loraName = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, ''); // Get item position for tooltip positioning const rect = itemElement.getBoundingClientRect(); const x = rect.right + 10; const y = rect.top; this.previewTooltip.show(loraName, x, y, true); // Pass true for fromAutocomplete flag } hidePreview() { if (!this.options.showPreview) { return; } if (typeof this.behavior.hidePreview === 'function') { this.behavior.hidePreview(this); } else if (this.previewTooltip) { this.previewTooltip.hide(); } } show() { if (!this.items || this.items.length === 0) { this.hide(); return; } // Position dropdown at cursor position using TextAreaCaretHelper this.positionAtCursor(); this.dropdown.style.display = 'block'; this.isVisible = true; } positionAtCursor() { const position = this.helper.getCursorOffset(); this.dropdown.style.left = (position.left ?? 0) + "px"; this.dropdown.style.top = (position.top ?? 0) + "px"; this.dropdown.style.maxHeight = (window.innerHeight - position.top) + "px"; // Adjust width to fit content // Temporarily show the dropdown to measure content width const originalDisplay = this.dropdown.style.display; this.dropdown.style.display = 'block'; this.dropdown.style.visibility = 'hidden'; // Measure the content width let maxWidth = 200; // minimum width const items = this.dropdown.querySelectorAll('.comfy-autocomplete-item'); items.forEach(item => { const itemWidth = item.scrollWidth + 24; // Add padding maxWidth = Math.max(maxWidth, itemWidth); }); // Set the width and restore visibility this.dropdown.style.width = Math.min(maxWidth, 400) + 'px'; // Cap at 400px this.dropdown.style.visibility = 'visible'; this.dropdown.style.display = originalDisplay; } getCaretPosition() { return this.inputElement.selectionStart || 0; } hide() { if (!this.dropdown) { return; } this.dropdown.style.display = 'none'; this.isVisible = false; this.selectedIndex = -1; this.showingCommands = false; // Hide preview tooltip this.hidePreview(); // Clear selection styles from all items const items = this.dropdown.querySelectorAll('.comfy-autocomplete-item'); items.forEach(item => { item.classList.remove('comfy-autocomplete-item-selected'); item.style.backgroundColor = ''; }); } selectItem(index) { // Remove previous selection const prevSelected = this.dropdown.querySelector('.comfy-autocomplete-item-selected'); if (prevSelected) { prevSelected.classList.remove('comfy-autocomplete-item-selected'); prevSelected.style.backgroundColor = ''; } // Add new selection if (index >= 0 && index < this.items.length) { this.selectedIndex = index; const item = this.dropdown.children[index]; item.classList.add('comfy-autocomplete-item-selected'); item.style.backgroundColor = 'rgba(66, 153, 225, 0.2)'; // Scroll into view if needed item.scrollIntoView({ block: 'nearest' }); // Show preview for selected item if (this.options.showPreview) { if (typeof this.behavior.showPreview === 'function') { this.behavior.showPreview(this, this.items[index], item); } else if (this.previewTooltip) { this.showPreviewForItem(this.items[index], item); } } } } handleKeyDown(e) { if (!this.isVisible) { return; } switch (e.key) { case 'ArrowDown': e.preventDefault(); this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1)); break; case 'ArrowUp': e.preventDefault(); this.selectItem(Math.max(this.selectedIndex - 1, 0)); break; case 'Enter': e.preventDefault(); if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) { if (this.showingCommands) { // Insert command this._insertCommand(this.items[this.selectedIndex].command); } else { // Insert selection (handle enriched items) const selectedItem = this.items[this.selectedIndex]; 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 caretPos = this.getCaretPosition(); // Use getSearchTerm to get the current search term before cursor const beforeCursor = currentValue.substring(0, caretPos); const fullSearchTerm = this.getSearchTerm(beforeCursor); // For regular tag autocomplete (no command), only replace the last space-separated token // This allows "hello 1gi" + selecting "1girl" to become "hello 1girl, " // Command mode (e.g., "/char miku") should replace the entire command+search let searchTerm = fullSearchTerm; if (this.modelType === 'prompt' && this.searchType === 'custom_words' && !this.activeCommand) { searchTerm = this._getLastSpaceToken(fullSearchTerm); } const searchStartPos = caretPos - searchTerm.length; // Only replace the search term, not everything after the last comma const newValue = currentValue.substring(0, searchStartPos) + insertText + currentValue.substring(caretPos); const newCaretPos = searchStartPos + insertText.length; this.inputElement.value = newValue; // Trigger input event to notify about the change const event = new Event('input', { bubbles: true }); this.inputElement.dispatchEvent(event); this.hide(); // Focus back to input and position cursor this.inputElement.focus(); this.inputElement.setSelectionRange(newCaretPos, newCaretPos); } async getInsertText(relativePath) { if (typeof this.behavior.getInsertText === 'function') { try { const result = await this.behavior.getInsertText(this, relativePath); if (typeof result === 'string' && result.length > 0) { return result; } } catch (error) { console.warn('Failed to format autocomplete insertion:', error); } } const trimmed = typeof relativePath === 'string' ? relativePath.trim() : ''; if (!trimmed) { return ''; } return `${trimmed}, `; } /** * Check if the autocomplete instance is still valid * (input element exists and is in the DOM) * @returns {boolean} */ isValid() { return this.inputElement && document.body.contains(this.inputElement); } /** * Refresh the TextAreaCaretHelper to update cached measurements * Useful after element is moved in DOM (e.g., Vue mode switch) */ refreshCaretHelper() { if (this.inputElement && document.body.contains(this.inputElement)) { this.helper = new TextAreaCaretHelper(this.inputElement, () => app.canvas.ds.scale); } } /** * 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(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(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 {boolean} enabled - New autocomplete state */ _showToggleFeedback(enabled) { showToast({ severity: enabled ? 'success' : 'secondary', summary: enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled', detail: 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 caretPos = this.inputElement.selectionStart; // Find the command text before cursor const beforeCursor = currentValue.substring(0, caretPos); const segments = beforeCursor.split(/[,\>]+/); const lastSegment = segments[segments.length - 1] || ''; // 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 = caretPos - lastSegment.length + 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 = caretPos - lastSegment.length; 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 (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 };