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:
+2
-1
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user