import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; import { ComfyWidgets } from "../../scripts/widgets.js"; // KikoEmbeddingAutocomplete - Provides autocomplete for embeddings and LoRAs in text widgets class KikoEmbeddingAutocomplete { constructor() { this.embeddings = []; this.loras = []; this.customWords = []; this.settings = { enabled: true, showEmbeddings: true, showLoras: true, showCustomWords: true, embeddingTrigger: "embedding:", loraTrigger: " this.fetchResources(), 500); this.fetchResourcesDebounced(); // Load custom words if URL is set if (this.settings.customWordsUrl && this.settings.showCustomWords) { this.loadCustomWords(this.settings.customWordsUrl); } // Single suggestion container for all widgets this.suggestionContainer = null; this.currentWidget = null; // Cleanup on page unload window.addEventListener('beforeunload', () => this.cleanup()); } debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } cleanup() { // Cancel all pending fetches this.pendingFetches.forEach(controller => { try { controller.abort(); } catch (e) { // Ignore abort errors } }); this.pendingFetches.clear(); // Clean up suggestion container if (this.suggestionContainer && this.suggestionContainer.parentNode) { this.suggestionContainer.parentNode.removeChild(this.suggestionContainer); this.suggestionContainer = null; } // Note: WeakMap and WeakSet will automatically clean up when widgets are garbage collected } loadSettings() { try { const stored = localStorage.getItem("kikotools.embedding_autocomplete.settings"); if (stored) { Object.assign(this.settings, JSON.parse(stored)); } } catch (error) { console.error("[KikoTools] Error loading autocomplete settings:", error); } } saveSettings() { try { localStorage.setItem("kikotools.embedding_autocomplete.settings", JSON.stringify(this.settings)); } catch (error) { console.error("[KikoTools] Error saving autocomplete settings:", error); } } async fetchWithAbort(url, options = {}) { const controller = new AbortController(); this.pendingFetches.add(controller); try { const response = await fetch(url, { ...options, signal: controller.signal }); this.pendingFetches.delete(controller); return response; } catch (error) { this.pendingFetches.delete(controller); if (error.name === 'AbortError') { return null; } throw error; } } async fetchResources() { try { // Fetch embeddings if (this.settings.showEmbeddings) { await this.fetchEmbeddings(); } // Fetch LoRAs if (this.settings.showLoras) { await this.fetchLoras(); } } catch (error) { console.error("[KikoTools] Error fetching resources:", error); } } async fetchEmbeddings() { // Try custom endpoint first try { const response = await this.fetchWithAbort("/kikotools/autocomplete/embeddings"); if (response && response.ok) { const embeddingsData = await response.json(); this.embeddings = embeddingsData.map(item => { const fullPath = item.path || item.file_name || item.model_name; const nameOnly = item.name || fullPath.split('/').pop(); return { name: nameOnly, fullPath: fullPath, type: "embedding", display: `embedding:${fullPath}`, value: `embedding:${fullPath}` }; }); return; } } catch (e) { // Custom endpoint failed, try fallback } // Fallback to ComfyUI API try { const embeddingsResponse = await api.getEmbeddings(); let allEmbeddings = []; // Handle paginated response if (embeddingsResponse && embeddingsResponse.items && Array.isArray(embeddingsResponse.items)) { allEmbeddings = [...embeddingsResponse.items]; const totalPages = embeddingsResponse.total_pages || 1; // Fetch remaining pages for (let page = 2; page <= totalPages && page <= 10; page++) { // Limit to 10 pages try { const pageData = await api.getEmbeddings(page); if (pageData && pageData.items) { allEmbeddings.push(...pageData.items); } } catch (err) { break; } } this.embeddings = allEmbeddings.map(item => { let fullPath = ""; if (typeof item === 'string') { fullPath = item; } else if (item && typeof item === 'object') { fullPath = item.file_name || item.model_name || item.name || item.filename; } const cleanPath = String(fullPath || "").replace(/\.(pt|safetensors|ckpt|bin)$/i, ''); const nameOnly = cleanPath.split('/').pop() || cleanPath; if (cleanPath && cleanPath !== "undefined") { return { name: nameOnly, fullPath: cleanPath, type: "embedding", display: `embedding:${cleanPath}`, value: `embedding:${cleanPath}` }; } return null; }).filter(item => item !== null); } // Handle object format (old API) else if (embeddingsResponse && typeof embeddingsResponse === 'object' && !Array.isArray(embeddingsResponse)) { const keys = Object.keys(embeddingsResponse); if (!keys.includes('items') && !keys.includes('total')) { this.embeddings = keys.map(fullPath => { const cleanPath = fullPath.replace(/\.(pt|safetensors|ckpt|bin)$/i, ''); const nameOnly = cleanPath.split('/').pop() || cleanPath; return { name: nameOnly, fullPath: cleanPath, type: "embedding", display: `embedding:${cleanPath}`, value: `embedding:${cleanPath}` }; }); } } } catch (e) { this.embeddings = []; } } async fetchLoras() { // Try custom endpoint first try { const response = await this.fetchWithAbort("/kikotools/autocomplete/loras"); if (response && response.ok) { const lorasData = await response.json(); this.loras = lorasData.map(item => { const path = item.path || item.name; const nameOnly = item.name || path.split('/').pop(); return { name: nameOnly, fullPath: path, type: "lora", display: ``, value: `` }; }); return; } } catch (e) { // Custom endpoint failed, try fallback } // Fallback to object info API try { const response = await this.fetchWithAbort("/object_info"); if (response && response.ok) { const objectInfo = await response.json(); if (objectInfo.LoraLoader?.input?.required?.lora_name?.[0]) { const loraNames = objectInfo.LoraLoader.input.required.lora_name[0]; this.loras = loraNames.map(name => { const cleanName = name.replace(/\.(pt|safetensors|ckpt|bin)$/i, ''); return { name: cleanName, fullPath: cleanName, type: "lora", display: ``, value: `` }; }); } } } catch (e) { this.loras = []; } } async loadCustomWords(url) { if (!url) return 0; try { const response = await this.fetchWithAbort(url); if (!response || !response.ok) { throw new Error(`Failed to load custom words: ${response?.status || 'aborted'}`); } const text = await response.text(); // Security validation - detect potentially dangerous patterns // Note: This is defense-in-depth for text files, not HTML rendering const dangerousPatterns = [ // Script tags - catch all variations including bypass attempts with whitespace // Matches: , , etc. /<\s*\/?script[^>]*>/gi, // Script tags (opening/closing with any attributes/whitespace) // Other dangerous HTML tags /<\s*iframe[^>]*>/gi, /<\s*embed[^>]*>/gi, /<\s*object[^>]*>/gi, // JavaScript protocol and event handlers /javascript:/gi, /data:text\/html/gi, /\bon\w+\s*=/gi, // Matches any event handler (onclick, onload, etc.) // Code execution patterns /import\s+/gi, /require\s*\(/gi, /eval\s*\(/gi, /new\s+Function\s*\(/gi, /\.innerHTML\s*=/gi, // DOM manipulation /document\./gi, /window\./gi, // Prototype pollution /__proto__/gi, /\.prototype\./gi, /\.constructor\s*\(/gi ]; for (const pattern of dangerousPatterns) { if (pattern.test(text)) { throw new Error("Security Warning: File contains potentially dangerous code patterns."); } } // Size check if (text.length > 10 * 1024 * 1024) { throw new Error("File too large. Maximum size is 10MB."); } const lines = text.split('\n'); this.customWords = []; const maxLineLength = 500; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.length > maxLineLength) continue; // Security check for individual lines if ((trimmed.includes('') || trimmed.includes('eval(') || trimmed.includes('document.') || trimmed.includes('window.')) { continue; } const parts = trimmed.split(','); const word = parts[0].trim(); const frequency = parts[1] ? parseInt(parts[1]) : 0; const safeWordPattern = /^[a-zA-Z0-9_\s\-:()'\.\!\?,\/\+\*&@#;=\[\]]+$/; if (word && safeWordPattern.test(word) && word.length > 0) { this.customWords.push({ name: word, fullPath: word, type: "custom", display: word, value: word, frequency: frequency }); } } // Sort by frequency and name this.customWords.sort((a, b) => { if (a.frequency !== b.frequency) { return b.frequency - a.frequency; } return a.name.localeCompare(b.name); }); return this.customWords.length; } catch (error) { console.error("[KikoTools] Error loading custom words:", error); return 0; } } createSuggestionContainer() { if (this.suggestionContainer) return; this.suggestionContainer = document.createElement("div"); this.suggestionContainer.className = "kikotools-autocomplete-suggestions"; this.suggestionContainer.style.cssText = ` position: absolute; z-index: 10000; background: var(--comfy-menu-bg); border: 1px solid var(--border-color); border-radius: 4px; max-height: 200px; overflow-y: auto; display: none; min-width: 200px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); `; document.body.appendChild(this.suggestionContainer); } attachToWidget(widget, node) { if (!widget.inputEl || this.activeWidgets.has(widget)) { return; } this.activeWidgets.add(widget); const textarea = widget.inputEl; // Create suggestion container if it doesn't exist if (!this.suggestionContainer) { this.createSuggestionContainer(); } // Store widget-specific state const widgetState = { currentSuggestions: [], selectedIndex: -1, currentPrefix: "", prefixStart: -1 }; // Event handler functions const handlers = { hideSuggestions: () => { if (this.suggestionContainer) { this.suggestionContainer.style.display = "none"; this.suggestionContainer.innerHTML = ""; } widgetState.selectedIndex = -1; widgetState.currentSuggestions = []; this.currentWidget = null; }, showSuggestions: (suggestions, x, y) => { if (!suggestions.length) { handlers.hideSuggestions(); return; } if (!this.suggestionContainer) { this.createSuggestionContainer(); } this.currentWidget = widget; this.suggestionContainer.innerHTML = ""; suggestions.forEach((item, index) => { const div = document.createElement("div"); div.className = "kikotools-autocomplete-item"; div.style.cssText = ` padding: 5px 10px; cursor: pointer; color: var(--fg-color); ${index === widgetState.selectedIndex ? "background: var(--comfy-input-bg);" : ""} `; // Add type indicator const typeSpan = document.createElement("span"); let typeColor = "#2196F3"; let typeText = "[LORA]"; if (item.type === "embedding") { typeColor = "#4CAF50"; typeText = "[EMB]"; } else if (item.type === "custom") { typeColor = "#FFA500"; typeText = "[TAG]"; } typeSpan.style.cssText = ` display: inline-block; width: 60px; color: ${typeColor}; font-size: 0.9em; `; typeSpan.textContent = typeText; const nameSpan = document.createElement("span"); if (item.type === "embedding" && item.fullPath && item.fullPath.includes('/')) { const parts = item.fullPath.split('/'); const dir = parts.slice(0, -1).join('/'); const file = parts[parts.length - 1]; const dirSpan = document.createElement("span"); dirSpan.style.cssText = "color: #888; font-size: 0.9em;"; dirSpan.textContent = dir + "/"; const fileSpan = document.createElement("span"); fileSpan.textContent = file; nameSpan.appendChild(dirSpan); nameSpan.appendChild(fileSpan); } else { nameSpan.textContent = item.name; } div.appendChild(typeSpan); div.appendChild(nameSpan); div.addEventListener("mouseenter", () => { widgetState.selectedIndex = index; handlers.updateSelection(); }); div.addEventListener("click", () => { handlers.insertSuggestion(item); }); this.suggestionContainer.appendChild(div); }); // Position the suggestions const rect = textarea.getBoundingClientRect(); this.suggestionContainer.style.left = Math.min(x, rect.right - 200) + "px"; this.suggestionContainer.style.top = y + "px"; this.suggestionContainer.style.display = "block"; }, updateSelection: () => { const items = this.suggestionContainer.querySelectorAll(".kikotools-autocomplete-item"); items.forEach((item, index) => { item.style.background = index === widgetState.selectedIndex ? "var(--comfy-input-bg)" : ""; }); // Auto-scroll to keep selected item visible if (widgetState.selectedIndex >= 0 && items[widgetState.selectedIndex]) { const selectedItem = items[widgetState.selectedIndex]; const containerRect = this.suggestionContainer.getBoundingClientRect(); const itemRect = selectedItem.getBoundingClientRect(); if (itemRect.bottom > containerRect.bottom) { this.suggestionContainer.scrollTop += itemRect.bottom - containerRect.bottom + 5; } else if (itemRect.top < containerRect.top) { this.suggestionContainer.scrollTop -= containerRect.top - itemRect.top + 5; } } }, insertSuggestion: (suggestion) => { const text = textarea.value; const cursor = textarea.selectionStart; const textBefore = text.substring(0, cursor); let insertText = ""; let replaceLength = widgetState.currentPrefix.length; // Apply replace underscores setting for custom words if (suggestion.type === "custom" && this.settings.replaceUnderscores) { suggestion = { ...suggestion, name: suggestion.name.replace(/_/g, ' '), value: suggestion.value.replace(/_/g, ' '), display: suggestion.display.replace(/_/g, ' ') }; } // Check context and determine what to insert const embTrigger = this.settings.embeddingTrigger || "embedding:"; const embRegex = new RegExp(embTrigger.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + "([a-zA-Z0-9_\\/-]*)$"); if (textBefore.match(embRegex) || (embTrigger === "embedding:" && textBefore.match(/embeddings:([a-zA-Z0-9_\/-]*)$/))) { insertText = suggestion.fullPath || suggestion.name; } else if (suggestion.type === "lora") { const loraTrigger = this.settings.loraTrigger || ""; } else { insertText = suggestion.value || suggestion.display; } } else if (widgetState.currentPrefix && textBefore.match(/\b(em|emb|embe|embed|embeddi|embeddin|embedding)$/i)) { const match = textBefore.match(/\b(em|emb|embe|embed|embeddi|embeddin|embedding)$/i); insertText = suggestion.value || suggestion.display; widgetState.prefixStart = cursor - match[1].length; replaceLength = match[1].length; } else { insertText = suggestion.value || suggestion.display; let wordStart = cursor - widgetState.currentPrefix.length; while (wordStart > 0 && /[a-zA-Z0-9_-]/.test(text[wordStart - 1])) { wordStart--; } widgetState.prefixStart = wordStart; replaceLength = cursor - wordStart; } const before = text.substring(0, widgetState.prefixStart); const after = text.substring(widgetState.prefixStart + replaceLength); // Add comma if enabled let finalInsertText = insertText; if (this.settings.autoInsertComma && (suggestion.type === "custom" || suggestion.type === "embedding" || suggestion.type === "lora")) { const trimmedAfter = after.trim(); const nextChar = trimmedAfter.charAt(0); if (nextChar !== ',' && nextChar !== '.' && nextChar !== ';') { finalInsertText += ', '; } } textarea.value = before + finalInsertText + after; textarea.selectionStart = textarea.selectionEnd = widgetState.prefixStart + finalInsertText.length; handlers.hideSuggestions(); // Trigger change event const event = new Event("input", { bubbles: true }); textarea.dispatchEvent(event); }, findPrefix: () => { const text = textarea.value; const cursor = textarea.selectionStart; const textBefore = text.substring(0, cursor); // Check for embedding trigger const embTrigger = this.settings.embeddingTrigger || "embedding:"; const embRegex = new RegExp(embTrigger.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + "([a-zA-Z0-9_\\/-]*)$"); const embeddingMatch = textBefore.match(embRegex); if (embeddingMatch) { widgetState.currentPrefix = embeddingMatch[1].toLowerCase(); widgetState.prefixStart = cursor - embeddingMatch[1].length; return "embedding"; } // Check for plural embeddings if (embTrigger === "embedding:") { const pluralMatch = textBefore.match(/embeddings:([a-zA-Z0-9_\/-]*)$/); if (pluralMatch) { widgetState.currentPrefix = pluralMatch[1].toLowerCase(); widgetState.prefixStart = cursor - pluralMatch[1].length; return "embedding"; } } // Check for LoRA trigger const loraTrigger = this.settings.loraTrigger || "= 0 && /[a-zA-Z0-9_-]/.test(text[start])) { start--; } start++; const word = text.substring(start, cursor); if (word.length >= this.settings.minChars) { widgetState.currentPrefix = word.toLowerCase(); widgetState.prefixStart = start; const quickTrigger = this.settings.quickTrigger; if (quickTrigger && word.toLowerCase().startsWith(quickTrigger.toLowerCase())) { return "embedding-word"; } return "general"; } return false; }, getSuggestions: (prefix, type) => { const suggestions = []; if (type === "embedding" && this.settings.showEmbeddings) { suggestions.push(...this.embeddings.filter(e => prefix === "" || e.name.toLowerCase().includes(prefix) || (e.fullPath && e.fullPath.toLowerCase().includes(prefix)) )); } else if (type === "embedding-word" && this.settings.showEmbeddings) { suggestions.push(...this.embeddings); } else if (type === "lora" && this.settings.showLoras) { suggestions.push(...this.loras.filter(l => prefix === "" || l.name.toLowerCase().includes(prefix) )); } else if (type === "general") { if (this.settings.showEmbeddings) { suggestions.push(...this.embeddings.filter(e => e.name.toLowerCase().includes(prefix) || (e.fullPath && e.fullPath.toLowerCase().includes(prefix)) )); } if (this.settings.showLoras) { suggestions.push(...this.loras.filter(l => l.name.toLowerCase().includes(prefix) )); } if (this.settings.showCustomWords) { suggestions.push(...this.customWords.filter(w => w.name.toLowerCase().includes(prefix) )); } } // Sort suggestions suggestions.sort((a, b) => { const aName = a.name.toLowerCase(); const bName = b.name.toLowerCase(); const aPath = (a.fullPath || a.name).toLowerCase(); const bPath = (b.fullPath || b.name).toLowerCase(); if (aName === prefix) return -1; if (bName === prefix) return 1; if (aPath === prefix) return -1; if (bPath === prefix) return 1; if (aName.startsWith(prefix) && !bName.startsWith(prefix)) return -1; if (!aName.startsWith(prefix) && bName.startsWith(prefix)) return 1; if (aPath.startsWith(prefix) && !bPath.startsWith(prefix)) return -1; if (!aPath.startsWith(prefix) && bPath.startsWith(prefix)) return 1; if (this.settings.sortByDirectory) { const aDir = aPath.includes('/') ? aPath.split('/')[0] : ''; const bDir = bPath.includes('/') ? bPath.split('/')[0] : ''; if (aDir !== bDir) { return aDir.localeCompare(bDir); } } return aName.localeCompare(bName); }); return suggestions.slice(0, this.settings.maxSuggestions); }, onInput: (e) => { if (!this.settings.enabled) { handlers.hideSuggestions(); return; } const triggerType = handlers.findPrefix(); if (triggerType) { widgetState.currentSuggestions = handlers.getSuggestions(widgetState.currentPrefix, triggerType); if (widgetState.currentSuggestions.length > 0) { const rect = textarea.getBoundingClientRect(); handlers.showSuggestions(widgetState.currentSuggestions, rect.left, rect.bottom); } else { handlers.hideSuggestions(); } } else { handlers.hideSuggestions(); } }, onKeydown: (e) => { if (!this.suggestionContainer || this.suggestionContainer.style.display === "none" || this.currentWidget !== widget) { return; } if (e.key === "ArrowDown") { e.preventDefault(); widgetState.selectedIndex = Math.min(widgetState.selectedIndex + 1, widgetState.currentSuggestions.length - 1); handlers.updateSelection(); } else if (e.key === "ArrowUp") { e.preventDefault(); widgetState.selectedIndex = Math.max(widgetState.selectedIndex - 1, 0); handlers.updateSelection(); } else if ((e.key === "Enter" && this.settings.insertOnEnter) || (e.key === "Tab" && this.settings.insertOnTab)) { if (widgetState.selectedIndex >= 0 && widgetState.selectedIndex < widgetState.currentSuggestions.length) { e.preventDefault(); handlers.insertSuggestion(widgetState.currentSuggestions[widgetState.selectedIndex]); } } else if (e.key === "Escape") { e.preventDefault(); handlers.hideSuggestions(); } }, onBlur: () => { // Use requestAnimationFrame to allow click events to fire first requestAnimationFrame(() => { requestAnimationFrame(() => { if (this.currentWidget === widget) { handlers.hideSuggestions(); } }); }); }, onScroll: () => { // Hide suggestions when scrolling if (this.currentWidget === widget) { handlers.hideSuggestions(); } } }; // Add event listeners textarea.addEventListener("input", handlers.onInput); textarea.addEventListener("keydown", handlers.onKeydown); textarea.addEventListener("blur", handlers.onBlur); textarea.addEventListener("scroll", handlers.onScroll); // Store cleanup function const cleanup = () => { textarea.removeEventListener("input", handlers.onInput); textarea.removeEventListener("keydown", handlers.onKeydown); textarea.removeEventListener("blur", handlers.onBlur); textarea.removeEventListener("scroll", handlers.onScroll); handlers.hideSuggestions(); this.activeWidgets.delete(widget); this.widgetCleanupMap.delete(widget); }; this.widgetCleanupMap.set(widget, cleanup); // Handle widget removal if (widget.onRemoved) { const originalOnRemoved = widget.onRemoved; widget.onRemoved = () => { cleanup(); originalOnRemoved.call(widget); }; } else { widget.onRemoved = cleanup; } } detachFromWidget(widget) { const cleanup = this.widgetCleanupMap.get(widget); if (cleanup) { cleanup(); } } } // Settings Dialog class KikoEmbeddingAutocompleteDialog { constructor(autocompleteInstance) { this.autocomplete = autocompleteInstance; this.element = null; } createDialog() { // Create dialog backdrop const backdrop = document.createElement("div"); backdrop.className = "kiko-dialog-backdrop"; backdrop.style.cssText = ` position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.7); z-index: 99998; display: flex; align-items: center; justify-content: center; `; // Create dialog container const dialog = document.createElement("div"); dialog.className = "kiko-dialog"; dialog.style.cssText = ` background: var(--comfy-menu-bg, #202020); border: 1px solid var(--border-color, #4e4e4e); border-radius: 8px; width: 600px; max-width: 90vw; max-height: 80vh; display: flex; flex-direction: column; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); color: var(--fg-color, #ffffff); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; `; // Header const header = document.createElement("div"); header.style.cssText = ` padding: 20px; border-bottom: 1px solid var(--border-color, #4e4e4e); display: flex; align-items: center; justify-content: space-between; `; const title = document.createElement("h2"); title.textContent = "🫶 Embedding Autocomplete Settings"; title.style.cssText = ` margin: 0; font-size: 20px; font-weight: 600; color: var(--fg-color, #ffffff); `; const closeBtn = document.createElement("button"); closeBtn.innerHTML = "✕"; closeBtn.style.cssText = ` background: transparent; border: none; color: var(--fg-color, #ffffff); font-size: 24px; cursor: pointer; padding: 0; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; opacity: 0.7; transition: opacity 0.2s; `; closeBtn.onmouseover = () => closeBtn.style.opacity = "1"; closeBtn.onmouseout = () => closeBtn.style.opacity = "0.7"; header.appendChild(title); header.appendChild(closeBtn); // Content const content = document.createElement("div"); content.style.cssText = ` padding: 20px; overflow-y: auto; flex: 1; `; // Create settings sections (simplified for brevity - same as original) content.innerHTML = `

General Settings

`; // Footer const footer = document.createElement("div"); footer.style.cssText = ` padding: 15px 20px; border-top: 1px solid var(--border-color, #4e4e4e); display: flex; justify-content: flex-end; gap: 10px; `; const saveBtn = document.createElement("button"); saveBtn.textContent = "Save Settings"; saveBtn.style.cssText = ` padding: 8px 20px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s; `; saveBtn.onmouseover = () => saveBtn.style.background = "#45a049"; saveBtn.onmouseout = () => saveBtn.style.background = "#4CAF50"; const cancelBtn = document.createElement("button"); cancelBtn.textContent = "Cancel"; cancelBtn.style.cssText = ` padding: 8px 20px; background: var(--comfy-input-bg, #1a1a1a); color: var(--fg-color, #ffffff); border: 1px solid var(--border-color, #4e4e4e); border-radius: 4px; cursor: pointer; font-size: 14px; transition: background 0.2s; `; footer.appendChild(cancelBtn); footer.appendChild(saveBtn); // Assemble dialog dialog.appendChild(header); dialog.appendChild(content); dialog.appendChild(footer); backdrop.appendChild(dialog); // Event handlers closeBtn.onclick = () => this.close(); cancelBtn.onclick = () => this.close(); backdrop.onclick = (e) => { if (e.target === backdrop) this.close(); }; saveBtn.onclick = () => this.save(); this.element = backdrop; this.dialog = dialog; return backdrop; } loadSettings() { if (!this.autocomplete) return; const settings = this.autocomplete.settings; // Set values (simplified for brevity) const elements = { enabled: document.getElementById("kiko-enabled"), showEmbeddings: document.getElementById("kiko-show-embeddings"), showLoras: document.getElementById("kiko-show-loras"), showCustom: document.getElementById("kiko-show-custom") }; if (elements.enabled) elements.enabled.checked = settings.enabled !== false; if (elements.showEmbeddings) elements.showEmbeddings.checked = settings.showEmbeddings !== false; if (elements.showLoras) elements.showLoras.checked = settings.showLoras !== false; if (elements.showCustom) elements.showCustom.checked = settings.showCustomWords !== false; } save() { if (!this.autocomplete) return; // Gather settings const newSettings = { ...this.autocomplete.settings, enabled: document.getElementById("kiko-enabled").checked, showEmbeddings: document.getElementById("kiko-show-embeddings").checked, showLoras: document.getElementById("kiko-show-loras").checked, showCustomWords: document.getElementById("kiko-show-custom").checked }; // Update autocomplete settings Object.assign(this.autocomplete.settings, newSettings); this.autocomplete.saveSettings(); // Refresh resources if needed if (newSettings.showEmbeddings || newSettings.showLoras) { this.autocomplete.fetchResourcesDebounced(); } // Close dialog this.close(); } show() { if (!this.element) { this.createDialog(); } document.body.appendChild(this.element); // Load settings after adding to DOM setTimeout(() => this.loadSettings(), 10); } close() { if (this.element && this.element.parentNode) { this.element.parentNode.removeChild(this.element); } } } // Initialize and register let autocompleteInstance = null; let originalStringWidget = null; app.registerExtension({ name: "kikotools.embeddingAutocomplete", async setup() { // Clean up any existing instance if (autocompleteInstance) { autocompleteInstance.cleanup(); } // Create new autocomplete instance autocompleteInstance = new KikoEmbeddingAutocomplete(); // Store reference for debugging (but not in global window to avoid conflicts) if (!window.kikotools) { window.kikotools = {}; } window.kikotools.autocomplete = autocompleteInstance; // Add settings button app.ui.settings.addSetting({ id: "kikotools.embeddingAutocomplete", name: "🫶 Embedding Autocomplete Configuration", tooltip: "Configure autocomplete settings", category: ["kikotools", "🫶 Embedding Autocomplete Configuration"], defaultValue: null, type: () => { const tr = document.createElement("tr"); tr.className = "kikotools-settings-row"; const tdLabel = document.createElement("td"); tdLabel.innerHTML = `
🫶 Embedding Autocomplete Configuration
`; const tdButton = document.createElement("td"); const button = document.createElement("button"); button.textContent = "Open Settings"; button.className = "kiko-settings-button"; button.style.cssText = ` padding: 8px 20px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s; `; button.onmouseover = () => button.style.background = "#45a049"; button.onmouseout = () => button.style.background = "#4CAF50"; button.onclick = () => { const dialog = new KikoEmbeddingAutocompleteDialog(autocompleteInstance); dialog.show(); }; tdButton.appendChild(button); tr.appendChild(tdLabel); tr.appendChild(tdButton); return tr; } }); // Override STRING widget creation if (!originalStringWidget) { originalStringWidget = ComfyWidgets.STRING; } ComfyWidgets.STRING = function(node, inputName, inputData, app) { const widget = originalStringWidget.apply(this, arguments); // Only attach to multiline text widgets (prompts) if (inputData[1]?.multiline && widget.inputEl) { // Use requestAnimationFrame to ensure DOM is ready requestAnimationFrame(() => { if (autocompleteInstance) { autocompleteInstance.attachToWidget(widget, node); } }); } return widget; }; // Attach to existing multiline widgets const attachToExistingWidgets = () => { if (!autocompleteInstance) return; app.graph._nodes.forEach(node => { if (node.widgets) { node.widgets.forEach(widget => { if (widget.inputEl && widget.inputEl.tagName === "TEXTAREA" && !autocompleteInstance.activeWidgets.has(widget)) { autocompleteInstance.attachToWidget(widget, node); } }); } }); }; // Wait for graph to be ready requestAnimationFrame(() => { requestAnimationFrame(() => { attachToExistingWidgets(); }); }); // Re-attach when new nodes are added const originalAddNode = app.graph.add; app.graph.add = function(...args) { const result = originalAddNode.apply(this, args); requestAnimationFrame(() => attachToExistingWidgets()); return result; }; }, async beforeUnload() { // Clean up before page unload if (autocompleteInstance) { autocompleteInstance.cleanup(); } } });