feat: enhance embedding autocomplete with interactive test panel

- Add debug/test panel to the node with helpful usage hints
- Display counts of available embeddings and LoRAs
- Show sample items and status information
- Include clear instructions for triggering autocomplete
- Update display name with 🫶 branding
- Make node an OUTPUT_NODE to display information
This commit is contained in:
Vito Sansevero
2025-08-08 13:58:45 -07:00
parent 3e7734bbc3
commit 40b91fc44f
6 changed files with 493 additions and 455 deletions
+2 -1
View File
@@ -13,7 +13,8 @@ except ImportError:
from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
# Tell ComfyUI where to find our JavaScript extensions
WEB_DIRECTORY = "./web"
import os
WEB_DIRECTORY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
def setup_autocomplete_api():
"""Setup API routes for embedding autocomplete."""
+15 -19
View File
@@ -3,27 +3,23 @@ KikoTools package initialization and node registry
Handles automatic discovery and registration of all ComfyAssets tools
"""
from .tools.resolution_calculator import ResolutionCalculatorNode
from .tools.width_height_selector import WidthHeightSelectorNode
from .tools.seed_history import SeedHistoryNode
from .tools.sampler_combo import SamplerComboNode, SamplerComboCompactNode
from .tools.empty_latent_batch import EmptyLatentBatchNode
from .tools.kiko_save_image import KikoSaveImageNode
from .tools.image_to_multiple_of import ImageToMultipleOfNode
from .tools.image_scale_down_by import ImageScaleDownByNode
from .tools.gemini_prompt import GeminiPromptNode
from .tools.display_any import DisplayAnyNode
from .tools.display_text import DisplayTextNode
from .tools.kiko_film_grain import KikoFilmGrainNode
from .tools.xyz_helpers import (
SamplerSelectHelperNode,
SchedulerSelectHelperNode,
TextEncodeSamplerParamsNode,
FluxSamplerParamsNode,
PlotParametersNode,
LoRAFolderBatchNode,
)
from .tools.embedding_autocomplete import KikoEmbeddingAutocomplete
from .tools.empty_latent_batch import EmptyLatentBatchNode
from .tools.gemini_prompt import GeminiPromptNode
from .tools.image_scale_down_by import ImageScaleDownByNode
from .tools.image_to_multiple_of import ImageToMultipleOfNode
from .tools.kiko_film_grain import KikoFilmGrainNode
from .tools.kiko_save_image import KikoSaveImageNode
from .tools.resolution_calculator import ResolutionCalculatorNode
from .tools.sampler_combo import SamplerComboCompactNode, SamplerComboNode
from .tools.seed_history import SeedHistoryNode
from .tools.width_height_selector import WidthHeightSelectorNode
from .tools.xyz_helpers import (FluxSamplerParamsNode, LoRAFolderBatchNode,
PlotParametersNode, SamplerSelectHelperNode,
SchedulerSelectHelperNode,
TextEncodeSamplerParamsNode)
# ComfyUI node registration mappings
NODE_CLASS_MAPPINGS = {
@@ -69,7 +65,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"FluxSamplerParams": "Flux Sampler Parameters",
"PlotParameters+": "Plot Parameters",
"LoRAFolderBatch": "LoRA Folder Batch",
"KikoEmbeddingAutocomplete": "🫶 Embedding Autocomplete",
"KikoEmbeddingAutocomplete": "🫶 Embedding Autocomplete (Test Panel)",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+75 -18
View File
@@ -12,7 +12,7 @@ import folder_paths
class KikoEmbeddingAutocomplete:
"""Node that provides embedding autocomplete functionality."""
DISPLAY_NAME = "Embedding Autocomplete"
DISPLAY_NAME = "🫶 Embedding Autocomplete (Test Panel)"
CATEGORY = "ComfyAssets"
# Settings definition for the settings registry
@@ -54,37 +54,94 @@ class KikoEmbeddingAutocomplete:
@classmethod
def INPUT_TYPES(cls):
"""Define input types for the node."""
hints = """🫶 Embedding Autocomplete Test Panel
Try these triggers in the text field below:
• Type 'embedding:' to list all embeddings
• Type '<lora:' to list all LoRAs
• Start typing any name to filter results
• Use Tab or Enter to select, Escape to cancel
• Arrow keys to navigate suggestions
This autocomplete works in all prompt fields!"""
return {
"required": {},
"optional": {
"refresh": ("BOOLEAN", {"default": False}),
"required": {
"test_input": ("STRING", {
"multiline": True,
"default": hints,
"placeholder": "Type here to test autocomplete..."
}),
},
"optional": {
"refresh": ("BOOLEAN", {"default": False, "label_on": "Refresh Lists", "label_off": "Use Cache"}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
}
}
RETURN_TYPES = ("DICT",)
RETURN_NAMES = ("autocomplete_data",)
FUNCTION = "get_autocomplete_data"
RETURN_TYPES = ("STRING", "INT", "INT")
RETURN_NAMES = ("test_output", "embeddings_count", "loras_count")
FUNCTION = "process_autocomplete"
OUTPUT_NODE = True
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
return True
def __init__(self):
self.embeddings_cache = None
self.loras_cache = None
def get_autocomplete_data(self, refresh=False):
"""Get autocomplete data for embeddings and LoRAs.
def process_autocomplete(self, test_input, refresh=False, unique_id=None):
"""Process and display autocomplete information.
This node doesn't process data in the traditional sense - it provides
autocomplete data to the frontend JavaScript component.
This node serves as both a test panel and information display
for the embedding autocomplete functionality.
"""
if refresh or self.embeddings_cache is None:
self.refresh_cache()
return (
{
"embeddings": self.embeddings_cache,
"loras": self.loras_cache,
"timestamp": os.path.getmtime(folder_paths.base_path),
},
)
# Count available resources
embeddings_count = len(self.embeddings_cache)
loras_count = len(self.loras_cache)
# Generate informative output
output_lines = [
f"🫶 Embedding Autocomplete Status",
f"═" * 40,
f"📦 Embeddings found: {embeddings_count}",
f"🎨 LoRAs found: {loras_count}",
f"",
f"✅ Autocomplete is {'enabled' if self.embeddings_cache or self.loras_cache else 'ready (no resources found)'}",
f"",
f"Your test input:",
f"─" * 40,
test_input,
f"─" * 40,
f"",
f"💡 Tips:",
f"• This node enables autocomplete in ALL prompt fields",
f"• Settings available in ComfyUI Settings menu",
f"• Look for 🫶 Embedding Autocomplete options"
]
if embeddings_count > 0:
output_lines.append(f"")
output_lines.append(f"Sample embeddings (first 5):")
for emb in self.embeddings_cache[:5]:
output_lines.append(f" • {emb['name']}")
if loras_count > 0:
output_lines.append(f"")
output_lines.append(f"Sample LoRAs (first 5):")
for lora in self.loras_cache[:5]:
output_lines.append(f" • {lora['name']}")
output_text = "\n".join(output_lines)
return (output_text, embeddings_count, loras_count)
def refresh_cache(self):
"""Refresh the cache of embeddings and LoRAs."""
+2 -1
View File
@@ -14,7 +14,7 @@ def test_import():
"""Test that the module can be imported."""
from kikotools.tools.embedding_autocomplete import KikoEmbeddingAutocomplete
assert KikoEmbeddingAutocomplete is not None
assert KikoEmbeddingAutocomplete.DISPLAY_NAME == "Embedding Autocomplete"
assert KikoEmbeddingAutocomplete.DISPLAY_NAME == "🫶 Embedding Autocomplete (Test Panel)"
assert KikoEmbeddingAutocomplete.CATEGORY == "ComfyAssets"
@@ -44,6 +44,7 @@ def test_input_types():
input_types = KikoEmbeddingAutocomplete.INPUT_TYPES()
assert "required" in input_types
assert "optional" in input_types
assert "test_input" in input_types["required"]
assert "refresh" in input_types["optional"]
-416
View File
@@ -1,416 +0,0 @@
/**
* KikoTools Embedding Autocomplete
* Provides autocomplete functionality for embeddings and LoRAs in ComfyUI text inputs
*/
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
// Autocomplete class
class KikoEmbeddingAutocomplete {
constructor() {
this.suggestions = [];
this.currentIndex = -1;
this.dropdown = null;
this.activeWidget = null;
this.activeNode = null;
// Settings
this.settings = {
enabled: true,
triggerChars: 2,
maxSuggestions: 20,
showEmbeddings: true,
showLoras: true,
caseSensitive: false
};
// Load settings from ComfyUI
this.loadSettings();
}
loadSettings() {
this.settings.enabled = app.ui.settings.getSettingValue("kikotools.embedding_autocomplete.enabled", true);
this.settings.triggerChars = app.ui.settings.getSettingValue("kikotools.embedding_autocomplete.trigger_chars", 2);
this.settings.maxSuggestions = app.ui.settings.getSettingValue("kikotools.embedding_autocomplete.max_suggestions", 20);
this.settings.showEmbeddings = app.ui.settings.getSettingValue("kikotools.embedding_autocomplete.show_embeddings", true);
this.settings.showLoras = app.ui.settings.getSettingValue("kikotools.embedding_autocomplete.show_loras", true);
this.settings.caseSensitive = app.ui.settings.getSettingValue("kikotools.embedding_autocomplete.case_sensitive", false);
}
async fetchSuggestions(prefix) {
if (!this.settings.enabled || prefix.length < this.settings.triggerChars) {
return [];
}
try {
const params = new URLSearchParams({
prefix: prefix,
max: this.settings.maxSuggestions,
embeddings: this.settings.showEmbeddings,
loras: this.settings.showLoras,
case_sensitive: this.settings.caseSensitive
});
const response = await fetch(`/kikotools/autocomplete/suggestions?${params}`);
if (response.ok) {
return await response.json();
}
} catch (error) {
console.error("[KikoAutocomplete] Error fetching suggestions:", error);
}
return [];
}
extractCurrentWord(text, cursorPos) {
// Extract word at cursor position
const beforeCursor = text.substring(0, cursorPos);
// Look for special triggers
const embeddingMatch = beforeCursor.match(/embedding:([^,\s]*)$/);
if (embeddingMatch) {
return {
word: embeddingMatch[1],
start: beforeCursor.length - embeddingMatch[1].length,
type: "embedding"
};
}
const loraMatch = beforeCursor.match(/<lora:([^:>]*)$/);
if (loraMatch) {
return {
word: loraMatch[1],
start: beforeCursor.length - loraMatch[1].length,
type: "lora"
};
}
// General word extraction
const wordMatch = beforeCursor.match(/([^,\s]+)$/);
if (wordMatch) {
return {
word: wordMatch[1],
start: beforeCursor.length - wordMatch[1].length,
type: "general"
};
}
return null;
}
createDropdown() {
if (this.dropdown) {
this.removeDropdown();
}
this.dropdown = document.createElement("div");
this.dropdown.className = "kiko-autocomplete-dropdown";
this.dropdown.style.cssText = `
position: absolute;
background: var(--comfy-menu-bg, #353535);
border: 1px solid var(--border-color, #4e4e4e);
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
z-index: 10000;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
min-width: 200px;
`;
document.body.appendChild(this.dropdown);
}
removeDropdown() {
if (this.dropdown) {
this.dropdown.remove();
this.dropdown = null;
this.currentIndex = -1;
}
}
positionDropdown(textarea) {
if (!this.dropdown) return;
const rect = textarea.getBoundingClientRect();
// Simple positioning below the textarea
// In a more advanced version, we'd calculate caret position
this.dropdown.style.left = rect.left + "px";
this.dropdown.style.top = (rect.bottom + 2) + "px";
this.dropdown.style.width = rect.width + "px";
}
showSuggestions(suggestions, textarea, wordInfo) {
if (!suggestions || suggestions.length === 0) {
this.removeDropdown();
return;
}
this.suggestions = suggestions;
this.createDropdown();
this.positionDropdown(textarea);
// Populate dropdown
this.dropdown.innerHTML = "";
suggestions.forEach((suggestion, index) => {
const item = document.createElement("div");
item.className = "kiko-autocomplete-item";
item.style.cssText = `
padding: 5px 10px;
cursor: pointer;
color: var(--fg-color, #fff);
display: flex;
justify-content: space-between;
align-items: center;
`;
// Highlight matching part
const displayText = suggestion.display || suggestion.value;
const matchIndex = displayText.toLowerCase().indexOf(wordInfo.word.toLowerCase());
if (matchIndex >= 0 && wordInfo.word) {
const before = displayText.substring(0, matchIndex);
const match = displayText.substring(matchIndex, matchIndex + wordInfo.word.length);
const after = displayText.substring(matchIndex + wordInfo.word.length);
item.innerHTML = `
<span>${before}<strong style="color: var(--primary-color, #ff6b6b);">${match}</strong>${after}</span>
<span style="opacity: 0.6; font-size: 0.9em;">${suggestion.type}</span>
`;
} else {
item.innerHTML = `
<span>${displayText}</span>
<span style="opacity: 0.6; font-size: 0.9em;">${suggestion.type}</span>
`;
}
// Hover effect
item.addEventListener("mouseenter", () => {
this.selectIndex(index);
});
// Click to select
item.addEventListener("click", () => {
this.applySuggestion(textarea, wordInfo, suggestion);
});
this.dropdown.appendChild(item);
});
// Select first item by default
if (suggestions.length > 0) {
this.selectIndex(0);
}
}
selectIndex(index) {
// Remove previous selection
const items = this.dropdown?.querySelectorAll(".kiko-autocomplete-item");
if (!items) return;
items.forEach((item, i) => {
if (i === index) {
item.style.background = "var(--comfy-input-bg, #222)";
this.currentIndex = i;
} else {
item.style.background = "";
}
});
}
applySuggestion(textarea, wordInfo, suggestion) {
const text = textarea.value;
const beforeWord = text.substring(0, wordInfo.start);
const afterWord = text.substring(wordInfo.start + wordInfo.word.length);
// Determine what to insert based on context
let insertion = suggestion.value;
// Handle different insertion contexts
if (wordInfo.type === "embedding" && !suggestion.value.startsWith("embedding:")) {
insertion = "embedding:" + suggestion.value;
} else if (wordInfo.type === "lora" && !suggestion.value.startsWith("<lora:")) {
insertion = `<lora:${suggestion.value}:1.0>`;
}
// Apply the suggestion
textarea.value = beforeWord + insertion + afterWord;
// Position cursor after insertion
const newPos = beforeWord.length + insertion.length;
textarea.setSelectionRange(newPos, newPos);
// Trigger input event to update the widget
textarea.dispatchEvent(new Event("input", { bubbles: true }));
// Remove dropdown
this.removeDropdown();
}
attachToWidget(widget, node) {
const textarea = widget.inputEl;
if (!textarea || textarea.kikoAutocompleteAttached) return;
textarea.kikoAutocompleteAttached = true;
// Input handler with debouncing
let debounceTimer;
textarea.addEventListener("input", async (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const cursorPos = textarea.selectionStart;
const wordInfo = this.extractCurrentWord(textarea.value, cursorPos);
if (wordInfo && wordInfo.word.length >= this.settings.triggerChars) {
const suggestions = await this.fetchSuggestions(wordInfo.word);
this.showSuggestions(suggestions, textarea, wordInfo);
this.activeWidget = widget;
this.activeNode = node;
} else {
this.removeDropdown();
}
}, 200);
});
// Keyboard navigation
textarea.addEventListener("keydown", (e) => {
if (!this.dropdown || this.suggestions.length === 0) return;
switch(e.key) {
case "ArrowDown":
e.preventDefault();
this.selectIndex((this.currentIndex + 1) % this.suggestions.length);
break;
case "ArrowUp":
e.preventDefault();
this.selectIndex(this.currentIndex <= 0 ? this.suggestions.length - 1 : this.currentIndex - 1);
break;
case "Enter":
case "Tab":
if (this.currentIndex >= 0) {
e.preventDefault();
const cursorPos = textarea.selectionStart;
const wordInfo = this.extractCurrentWord(textarea.value, cursorPos);
if (wordInfo) {
this.applySuggestion(textarea, wordInfo, this.suggestions[this.currentIndex]);
}
}
break;
case "Escape":
e.preventDefault();
this.removeDropdown();
break;
}
});
// Hide on blur
textarea.addEventListener("blur", () => {
// Delay to allow click events to fire
setTimeout(() => this.removeDropdown(), 200);
});
}
}
// Initialize and register
app.registerExtension({
name: "kikotools.embeddingAutocomplete",
async init() {
// Add settings
app.ui.settings.addSetting({
id: "kikotools.embedding_autocomplete.enabled",
name: "🫶 Embedding Autocomplete: Enabled",
defaultValue: true,
type: "boolean",
onChange(value) {
if (window.kikoAutocomplete) {
window.kikoAutocomplete.settings.enabled = value;
}
}
});
app.ui.settings.addSetting({
id: "kikotools.embedding_autocomplete.trigger_chars",
name: "🫶 Embedding Autocomplete: Trigger After Characters",
defaultValue: 2,
type: "combo",
options: (value) => [
{ value: 1, text: "1", selected: value === 1 },
{ value: 2, text: "2", selected: value === 2 },
{ value: 3, text: "3", selected: value === 3 },
{ value: 4, text: "4", selected: value === 4 }
],
onChange(value) {
if (window.kikoAutocomplete) {
window.kikoAutocomplete.settings.triggerChars = value;
}
}
});
app.ui.settings.addSetting({
id: "kikotools.embedding_autocomplete.max_suggestions",
name: "🫶 Embedding Autocomplete: Maximum Suggestions",
defaultValue: 20,
type: "combo",
options: (value) => [
{ value: 10, text: "10", selected: value === 10 },
{ value: 20, text: "20", selected: value === 20 },
{ value: 30, text: "30", selected: value === 30 },
{ value: 50, text: "50", selected: value === 50 }
],
onChange(value) {
if (window.kikoAutocomplete) {
window.kikoAutocomplete.settings.maxSuggestions = value;
}
}
});
app.ui.settings.addSetting({
id: "kikotools.embedding_autocomplete.show_embeddings",
name: "🫶 Embedding Autocomplete: Show Embeddings",
defaultValue: true,
type: "boolean",
onChange(value) {
if (window.kikoAutocomplete) {
window.kikoAutocomplete.settings.showEmbeddings = value;
}
}
});
app.ui.settings.addSetting({
id: "kikotools.embedding_autocomplete.show_loras",
name: "🫶 Embedding Autocomplete: Show LoRAs",
defaultValue: true,
type: "boolean",
onChange(value) {
if (window.kikoAutocomplete) {
window.kikoAutocomplete.settings.showLoras = value;
}
}
});
},
async setup() {
// Create autocomplete instance
window.kikoAutocomplete = new KikoEmbeddingAutocomplete();
// Override STRING widget creation to add autocomplete
const 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) {
setTimeout(() => {
window.kikoAutocomplete.attachToWidget(widget, node);
}, 100);
}
return widget;
};
}
});
+399
View File
@@ -0,0 +1,399 @@
import { app } from "../../scripts/app.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.settings = {
enabled: true,
triggerChars: 2,
maxSuggestions: 20,
showEmbeddings: true,
showLoras: true
};
// Load settings from localStorage
this.loadSettings();
// Fetch available embeddings and LoRAs
this.fetchResources();
// Track active widgets
this.activeWidgets = new Set();
}
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 fetchResources() {
try {
// Fetch embeddings
if (this.settings.showEmbeddings) {
const embResponse = await fetch("/embeddings");
if (embResponse.ok) {
const data = await embResponse.json();
this.embeddings = Object.keys(data || {}).map(name => ({
name: name,
type: "embedding",
display: `embedding:${name}`
}));
}
}
// Fetch LoRAs
if (this.settings.showLoras) {
const loraResponse = await fetch("/models/loras");
if (loraResponse.ok) {
const data = await loraResponse.json();
this.loras = (data || []).map(name => ({
name: name,
type: "lora",
display: `<lora:${name}:1.0>`
}));
}
}
console.log(`[KikoTools] Loaded ${this.embeddings.length} embeddings and ${this.loras.length} LoRAs`);
} catch (error) {
console.error("[KikoTools] Error fetching resources:", error);
}
}
attachToWidget(widget, node) {
if (!widget.inputEl || this.activeWidgets.has(widget)) {
return;
}
this.activeWidgets.add(widget);
const textarea = widget.inputEl;
// Create suggestion container
const suggestionContainer = document.createElement("div");
suggestionContainer.className = "kikotools-autocomplete-suggestions";
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(suggestionContainer);
let currentSuggestions = [];
let selectedIndex = -1;
let currentPrefix = "";
let prefixStart = -1;
const hideSuggestions = () => {
suggestionContainer.style.display = "none";
selectedIndex = -1;
};
const showSuggestions = (suggestions, x, y) => {
if (!suggestions.length) {
hideSuggestions();
return;
}
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 === selectedIndex ? "background: var(--comfy-input-bg);" : ""}
`;
// Add type indicator
const typeSpan = document.createElement("span");
typeSpan.style.cssText = `
display: inline-block;
width: 60px;
color: ${item.type === "embedding" ? "#4CAF50" : "#2196F3"};
font-size: 0.9em;
`;
typeSpan.textContent = item.type === "embedding" ? "[EMB]" : "[LORA]";
const nameSpan = document.createElement("span");
nameSpan.textContent = item.name;
div.appendChild(typeSpan);
div.appendChild(nameSpan);
div.addEventListener("mouseenter", () => {
selectedIndex = index;
updateSelection();
});
div.addEventListener("click", () => {
insertSuggestion(item);
});
suggestionContainer.appendChild(div);
});
// Position the suggestions
const rect = textarea.getBoundingClientRect();
suggestionContainer.style.left = Math.min(x, rect.right - 200) + "px";
suggestionContainer.style.top = y + "px";
suggestionContainer.style.display = "block";
};
const updateSelection = () => {
const items = suggestionContainer.querySelectorAll(".kikotools-autocomplete-item");
items.forEach((item, index) => {
item.style.background = index === selectedIndex ? "var(--comfy-input-bg)" : "";
});
};
const insertSuggestion = (suggestion) => {
const text = textarea.value;
const before = text.substring(0, prefixStart);
const after = text.substring(textarea.selectionStart);
textarea.value = before + suggestion.display + after;
textarea.selectionStart = textarea.selectionEnd = prefixStart + suggestion.display.length;
hideSuggestions();
// Trigger change event
const event = new Event("input", { bubbles: true });
textarea.dispatchEvent(event);
};
const findPrefix = () => {
const text = textarea.value;
const cursor = textarea.selectionStart;
// Look for embedding or lora prefix
let start = cursor - 1;
while (start >= 0 && text[start] !== " " && text[start] !== "\n" && text[start] !== ",") {
start--;
}
start++;
const prefix = text.substring(start, cursor).toLowerCase();
// Check if we should show suggestions
if (prefix.length >= this.settings.triggerChars) {
currentPrefix = prefix;
prefixStart = start;
return true;
}
return false;
};
const getSuggestions = (prefix) => {
const suggestions = [];
if (this.settings.showEmbeddings) {
suggestions.push(...this.embeddings.filter(e =>
e.name.toLowerCase().includes(prefix) ||
e.display.toLowerCase().includes(prefix)
));
}
if (this.settings.showLoras) {
suggestions.push(...this.loras.filter(l =>
l.name.toLowerCase().includes(prefix) ||
l.display.toLowerCase().includes(prefix)
));
}
return suggestions.slice(0, this.settings.maxSuggestions);
};
// Event handlers
textarea.addEventListener("input", (e) => {
if (!this.settings.enabled) {
hideSuggestions();
return;
}
if (findPrefix()) {
currentSuggestions = getSuggestions(currentPrefix);
if (currentSuggestions.length > 0) {
// Get cursor position for suggestion placement
const rect = textarea.getBoundingClientRect();
const lineHeight = parseInt(window.getComputedStyle(textarea).lineHeight);
// Simple positioning below cursor
showSuggestions(currentSuggestions, rect.left, rect.bottom);
} else {
hideSuggestions();
}
} else {
hideSuggestions();
}
});
textarea.addEventListener("keydown", (e) => {
if (suggestionContainer.style.display === "none") {
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, currentSuggestions.length - 1);
updateSelection();
} else if (e.key === "ArrowUp") {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
updateSelection();
} else if (e.key === "Enter" || e.key === "Tab") {
if (selectedIndex >= 0 && selectedIndex < currentSuggestions.length) {
e.preventDefault();
insertSuggestion(currentSuggestions[selectedIndex]);
}
} else if (e.key === "Escape") {
e.preventDefault();
hideSuggestions();
}
});
textarea.addEventListener("blur", () => {
setTimeout(hideSuggestions, 200);
});
}
}
// Initialize and register
console.log("[KikoTools] Registering Embedding Autocomplete extension");
app.registerExtension({
name: "kikotools.embeddingAutocomplete",
async setup() {
console.log("[KikoTools] Setting up Embedding Autocomplete");
// Add settings
app.ui.settings.addSetting({
id: "kikotools.embeddingAutocomplete.enabled",
name: "Enabled",
category: ["KikoTools", "Embedding Autocomplete"],
defaultValue: true,
type: "boolean",
onChange(value) {
if (window.kikoAutocomplete) {
window.kikoAutocomplete.settings.enabled = value;
window.kikoAutocomplete.saveSettings();
}
}
});
app.ui.settings.addSetting({
id: "kikotools.embeddingAutocomplete.triggerChars",
name: "Trigger After Characters",
category: ["KikoTools", "Embedding Autocomplete"],
defaultValue: 2,
type: "combo",
options: [
{ value: 1, text: "1 character" },
{ value: 2, text: "2 characters" },
{ value: 3, text: "3 characters" },
{ value: 4, text: "4 characters" }
],
onChange(value) {
if (window.kikoAutocomplete) {
window.kikoAutocomplete.settings.triggerChars = value;
window.kikoAutocomplete.saveSettings();
}
}
});
app.ui.settings.addSetting({
id: "kikotools.embeddingAutocomplete.maxSuggestions",
name: "Maximum Suggestions",
category: ["KikoTools", "Embedding Autocomplete"],
defaultValue: 20,
type: "combo",
options: [
{ value: 10, text: "10" },
{ value: 20, text: "20" },
{ value: 30, text: "30" },
{ value: 50, text: "50" }
],
onChange(value) {
if (window.kikoAutocomplete) {
window.kikoAutocomplete.settings.maxSuggestions = value;
window.kikoAutocomplete.saveSettings();
}
}
});
app.ui.settings.addSetting({
id: "kikotools.embeddingAutocomplete.showEmbeddings",
name: "Show Embeddings",
category: ["KikoTools", "Embedding Autocomplete"],
defaultValue: true,
type: "boolean",
onChange(value) {
if (window.kikoAutocomplete) {
window.kikoAutocomplete.settings.showEmbeddings = value;
window.kikoAutocomplete.saveSettings();
window.kikoAutocomplete.fetchResources();
}
}
});
app.ui.settings.addSetting({
id: "kikotools.embeddingAutocomplete.showLoras",
name: "Show LoRAs",
category: ["KikoTools", "Embedding Autocomplete"],
defaultValue: true,
type: "boolean",
onChange(value) {
if (window.kikoAutocomplete) {
window.kikoAutocomplete.settings.showLoras = value;
window.kikoAutocomplete.saveSettings();
window.kikoAutocomplete.fetchResources();
}
}
});
// Create autocomplete instance
window.kikoAutocomplete = new KikoEmbeddingAutocomplete();
// Override STRING widget creation to add autocomplete
const 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) {
setTimeout(() => {
window.kikoAutocomplete.attachToWidget(widget, node);
}, 100);
}
return widget;
};
}
});