diff --git a/README.md b/README.md index cf692ea..f1681da 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ ComfyUI-KikoTools provides carefully crafted, production-ready nodes grouped und | [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | AI-powered image analysis and prompt generation | 🧠 Prompts | | [🔍 Display Any](#-display-any) | Universal debugging tool for any data type | 👁️ Display | | [🖼️ Image to Multiple Of](#️-image-to-multiple-of) | Adjust dimensions to multiples for compatibility | 🖼️ Resolution | +| [🔤 Embedding Autocomplete](#-embedding-autocomplete) | Smart autocomplete for embeddings, LoRAs, and tags | ✍️ Text | ### 🧰 xyz-helpers Tools @@ -311,6 +312,45 @@ Unified interface for text encoding and sampler parameter management. - Quick template-based generation - Batch prompt processing +### 🔤 Embedding Autocomplete + +**Intelligent autocomplete for embeddings, LoRAs, and custom tags in text prompts.** + +
+ Embedding Autocomplete + LoRA Autocomplete + Tag Autocomplete +
+ +This feature is an enhanced fork of the autocomplete functionality from [ComfyUI-Custom-Scripts](https://github.com/pythongosssss/ComfyUI-Custom-Scripts) by pythongosssss. We've modernized the codebase, fixed existing bugs, and added robust security features. + +**Key Features:** +- **Smart Triggers**: Type `embedding:` for embeddings, ` 0: + print(f"[KikoTools] Sample embedding: {embeddings[0]}") + print(f"[KikoTools] Returning {len(embeddings)} embeddings with paths") + return web.json_response(embeddings) + except Exception as e: + print(f"[KikoTools] Error getting embeddings: {e}") + import traceback + + traceback.print_exc() + return web.json_response([]) + + @PromptServer.instance.routes.get("/kikotools/autocomplete/loras") + async def get_loras(request): + """API endpoint for getting list of LoRAs.""" + print("[KikoTools] LoRA endpoint called") + try: + lora_files = folder_paths.get_filename_list("loras") + print(f"[KikoTools] Found {len(lora_files)} LoRA files") + # Return LoRAs with paths + loras = [] + for f in lora_files: + clean_path = os.path.splitext(f)[0] + loras.append( + { + "name": os.path.basename(clean_path), + "path": clean_path, + "file": f, + } + ) + print(f"[KikoTools] Returning {len(loras)} LoRAs") + return web.json_response(loras) + except Exception as e: + print(f"[KikoTools] Error getting LoRAs: {e}") + import traceback + + traceback.print_exc() + return web.json_response([]) + + print("[KikoTools] Autocomplete API endpoints registered successfully") + print( + "[KikoTools] Routes available: /kikotools/autocomplete/embeddings and /kikotools/autocomplete/loras" + ) + +except ImportError as e: + print(f"[KikoTools] Could not import server components: {e}") +except Exception as e: + print(f"[KikoTools] Unexpected error setting up API: {e}") + import traceback + + traceback.print_exc() + +# API endpoints are registered above at module import time def get_version(): diff --git a/examples/ac-emb.png b/examples/ac-emb.png new file mode 100644 index 0000000..8f5bccd Binary files /dev/null and b/examples/ac-emb.png differ diff --git a/examples/ac-lora.png b/examples/ac-lora.png new file mode 100644 index 0000000..f9160f1 Binary files /dev/null and b/examples/ac-lora.png differ diff --git a/examples/ac-tag.png b/examples/ac-tag.png new file mode 100644 index 0000000..e2c6a43 Binary files /dev/null and b/examples/ac-tag.png differ diff --git a/kikotools/__init__.py b/kikotools/__init__.py index 82b675d..cb3046b 100644 --- a/kikotools/__init__.py +++ b/kikotools/__init__.py @@ -16,10 +16,14 @@ 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) +from .tools.xyz_helpers import ( + FluxSamplerParamsNode, + LoRAFolderBatchNode, + PlotParametersNode, + SamplerSelectHelperNode, + SchedulerSelectHelperNode, + TextEncodeSamplerParamsNode, +) # ComfyUI node registration mappings NODE_CLASS_MAPPINGS = { @@ -65,7 +69,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "FluxSamplerParams": "Flux Sampler Parameters", "PlotParameters+": "Plot Parameters", "LoRAFolderBatch": "LoRA Folder Batch", - "KikoEmbeddingAutocomplete": "🫶 Embedding Autocomplete (Test Panel)", + "KikoEmbeddingAutocomplete": "🫶 Embedding Autocomplete Configuration", } __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/kikotools/tools/embedding_autocomplete/node.py b/kikotools/tools/embedding_autocomplete/node.py index c8718c2..05b129f 100644 --- a/kikotools/tools/embedding_autocomplete/node.py +++ b/kikotools/tools/embedding_autocomplete/node.py @@ -4,15 +4,14 @@ Provides autocomplete functionality for embeddings and LoRAs in text inputs. """ import os -import json -from typing import Dict, List, Any, Optional +from typing import Dict, List, Any import folder_paths class KikoEmbeddingAutocomplete: """Node that provides embedding autocomplete functionality.""" - DISPLAY_NAME = "🫶 Embedding Autocomplete (Test Panel)" + DISPLAY_NAME = "🫶 Embedding Autocomplete Settings" CATEGORY = "ComfyAssets" # Settings definition for the settings registry @@ -20,72 +19,67 @@ class KikoEmbeddingAutocomplete: "enabled": { "type": "boolean", "default": True, - "description": "Enable embedding autocomplete", - }, - "trigger_chars": { - "type": "combo", - "default": 2, - "options": [1, 2, 3, 4], - "description": "Number of characters before showing suggestions", - }, - "max_suggestions": { - "type": "combo", - "default": 20, - "options": [10, 20, 30, 50], - "description": "Maximum number of suggestions to show", + "description": "Enable autocomplete", }, "show_embeddings": { "type": "boolean", "default": True, - "description": "Show embeddings in suggestions", + "description": "Show embeddings in autocomplete", }, "show_loras": { "type": "boolean", "default": True, - "description": "Show LoRAs in suggestions", + "description": "Show LoRAs in autocomplete", }, - "case_sensitive": { + "embedding_trigger": { + "type": "text", + "default": "embedding:", + "description": "Trigger text for embeddings (e.g., 'embedding:', 'emb:', or custom)", + }, + "lora_trigger": { + "type": "text", + "default": " 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) + # This node doesn't actually process anything + # It's just a visual indicator that autocomplete is available + return () def refresh_cache(self): """Refresh the cache of embeddings and LoRAs.""" print("[KikoEmbeddingAutocomplete] Refreshing cache...") self.embeddings_cache = self.get_embeddings() self.loras_cache = self.get_loras() - print(f"[KikoEmbeddingAutocomplete] Cached {len(self.embeddings_cache)} embeddings, {len(self.loras_cache)} LoRAs") + print( + f"[KikoEmbeddingAutocomplete] Cached {len(self.embeddings_cache)} embeddings, {len(self.loras_cache)} LoRAs" + ) def get_embeddings(self) -> List[Dict[str, Any]]: """Get list of available embeddings.""" @@ -158,7 +115,9 @@ This autocomplete works in all prompt fields!""" try: print("[KikoEmbeddingAutocomplete] Getting embeddings list...") embedding_files = folder_paths.get_filename_list("embeddings") - print(f"[KikoEmbeddingAutocomplete] Found {len(embedding_files)} embedding files") + print( + f"[KikoEmbeddingAutocomplete] Found {len(embedding_files)} embedding files" + ) for file in embedding_files: name = os.path.splitext(file)[0] embeddings.append( @@ -212,7 +171,7 @@ This autocomplete works in all prompt fields!""" # Return combined modification time return os.path.getmtime(embeddings_path) + os.path.getmtime(loras_path) - except: + except Exception: return 0 diff --git a/tests/conftest.py b/tests/conftest.py index c4769ef..b552639 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,10 +3,17 @@ pytest configuration and fixtures for ComfyUI-KikoTools testing Provides mock ComfyUI environments and test data """ +import sys import pytest import torch from unittest.mock import MagicMock +# Mock folder_paths module before any imports that might use it +sys.modules["folder_paths"] = MagicMock() +sys.modules["folder_paths"].get_filename_list = MagicMock(return_value=[]) +sys.modules["folder_paths"].get_folder_paths = MagicMock(return_value=["/mock/path"]) +sys.modules["folder_paths"].base_path = "/mock/base" + @pytest.fixture def mock_image_tensor(): diff --git a/tests/test_embedding_autocomplete.py b/tests/test_embedding_autocomplete.py index 6338c82..900bdbf 100644 --- a/tests/test_embedding_autocomplete.py +++ b/tests/test_embedding_autocomplete.py @@ -4,77 +4,84 @@ import sys from unittest.mock import MagicMock # Mock ComfyUI's folder_paths module -sys.modules['folder_paths'] = MagicMock() -sys.modules['folder_paths'].get_filename_list = MagicMock(return_value=[]) -sys.modules['folder_paths'].get_folder_paths = MagicMock(return_value=["/mock/path"]) -sys.modules['folder_paths'].base_path = "/mock/base" +sys.modules["folder_paths"] = MagicMock() +sys.modules["folder_paths"].get_filename_list = MagicMock(return_value=[]) +sys.modules["folder_paths"].get_folder_paths = MagicMock(return_value=["/mock/path"]) +sys.modules["folder_paths"].base_path = "/mock/base" 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 (Test Panel)" + assert ( + KikoEmbeddingAutocomplete.DISPLAY_NAME + == "🫶 Embedding Autocomplete Settings" + ) assert KikoEmbeddingAutocomplete.CATEGORY == "ComfyAssets" def test_settings_defined(): """Test that settings are properly defined.""" from kikotools.tools.embedding_autocomplete import KikoEmbeddingAutocomplete - + settings = KikoEmbeddingAutocomplete.SETTINGS assert "enabled" in settings - assert "trigger_chars" in settings + assert "min_chars" in settings # Changed from trigger_chars assert "max_suggestions" in settings assert "show_embeddings" in settings assert "show_loras" in settings - assert "case_sensitive" in settings - + assert "embedding_trigger" in settings + assert "lora_trigger" in settings + assert "quick_trigger" in settings + assert "sort_by_directory" in settings + # Check settings structure assert settings["enabled"]["type"] == "boolean" assert settings["enabled"]["default"] is True - assert settings["trigger_chars"]["type"] == "combo" - assert settings["trigger_chars"]["options"] == [1, 2, 3, 4] + assert settings["min_chars"]["type"] == "combo" + assert settings["min_chars"]["options"] == [1, 2, 3, 4, 5] def test_input_types(): """Test INPUT_TYPES class method.""" from kikotools.tools.embedding_autocomplete import KikoEmbeddingAutocomplete - + 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"] + assert "hidden" in input_types + assert input_types["required"] == {} # No required inputs + assert "unique_id" in input_types["hidden"] def test_api_suggestions(): """Test the API suggestions method.""" from kikotools.tools.embedding_autocomplete.node import KikoEmbeddingAutocompleteAPI - + # Mock folder_paths to return some test files - sys.modules['folder_paths'].get_filename_list = MagicMock( - side_effect=lambda x: ["test1.pt", "test2.safetensors"] if x == "embeddings" else ["lora1.pt", "lora2.safetensors"] + sys.modules["folder_paths"].get_filename_list = MagicMock( + side_effect=lambda x: ( + ["test1.pt", "test2.safetensors"] + if x == "embeddings" + else ["lora1.pt", "lora2.safetensors"] + ) ) - + # Test with embeddings suggestions = KikoEmbeddingAutocompleteAPI.get_suggestions( - prefix="test", - include_embeddings=True, - include_loras=False + prefix="test", include_embeddings=True, include_loras=False ) - + assert len(suggestions) == 2 assert suggestions[0]["type"] == "embedding" assert suggestions[0]["name"] == "test1" - + # Test with LoRAs suggestions = KikoEmbeddingAutocompleteAPI.get_suggestions( - prefix="lora", - include_embeddings=False, - include_loras=True + prefix="lora", include_embeddings=False, include_loras=True ) - + assert len(suggestions) == 2 assert suggestions[0]["type"] == "lora" assert " 5: + print(f" ... and {len(files)-5} more files") + if dirs: + print(f" Subdirectories: {dirs}") + + except ImportError as e: + print(f"Could not import folder_paths: {e}") +else: + print(f"ComfyUI not found at {comfyui_path}") diff --git a/web/kikoEmbeddingAutocomplete.js b/web/kikoEmbeddingAutocomplete.js index 483b769..b8f2e0e 100644 --- a/web/kikoEmbeddingAutocomplete.js +++ b/web/kikoEmbeddingAutocomplete.js @@ -5,15 +5,25 @@ import { ComfyWidgets } from "../../scripts/widgets.js"; // KikoEmbeddingAutocomplete - Provides autocomplete for embeddings and LoRAs in text widgets class KikoEmbeddingAutocomplete { constructor() { - console.log("[KikoAutocomplete] Initializing..."); this.embeddings = []; this.loras = []; + this.customWords = []; this.settings = { enabled: true, - triggerChars: 2, - maxSuggestions: 20, showEmbeddings: true, - showLoras: true + showLoras: true, + showCustomWords: true, + embeddingTrigger: "embedding:", + loraTrigger: " { + 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}` + }; + }); - // Fetch remaining pages if any - if (totalPages > 1) { - // Check if api.getEmbeddings accepts page parameter - for (let page = 2; page <= totalPages; page++) { - try { - console.log(`[KikoAutocomplete] Fetching page ${page}...`); - - // Try using the api method with page parameter - const pageData = await api.getEmbeddings(page); - if (pageData && pageData.items) { - allEmbeddings.push(...pageData.items); - console.log(`[KikoAutocomplete] Page ${page} loaded: ${pageData.items.length} items`); - } else { - console.warn(`[KikoAutocomplete] Page ${page} has no items`); + useCustomEndpoint = false; // Success, don't use fallback + } + } catch (customError) { + // Custom endpoint not available, will use fallback + } + + // Only fallback if custom endpoint failed + if (useCustomEndpoint) { + try { + const embeddingsResponse = await api.getEmbeddings(); + + let allEmbeddings = []; + + // Check if this is a paginated response + if (embeddingsResponse && embeddingsResponse.items && Array.isArray(embeddingsResponse.items)) { + // This is a paginated response - get all pages + const totalPages = embeddingsResponse.total_pages || 1; + + // Add items from first page + allEmbeddings = [...embeddingsResponse.items]; + + // Fetch remaining pages if any + if (totalPages > 1) { + // Check if api.getEmbeddings accepts page parameter + for (let page = 2; page <= totalPages; page++) { + try { + + // Try using the api method with page parameter + const pageData = await api.getEmbeddings(page); + if (pageData && pageData.items) { + allEmbeddings.push(...pageData.items); + } else { + break; + } + } catch (err) { + // If pagination doesn't work, just use what we have break; } - } catch (err) { - console.warn(`[KikoAutocomplete] Could not fetch page ${page}:`, err); - // If pagination doesn't work, just use what we have - console.log("[KikoAutocomplete] Pagination not supported, using first page only"); - break; } } - } - - console.log(`[KikoAutocomplete] Collected ${allEmbeddings.length} embeddings from paginated response`); - - // Process the embeddings - console.log("[KikoAutocomplete] Sample item structure:", allEmbeddings[0]); - this.embeddings = allEmbeddings.map((item, index) => { - let name = ""; - // Extract name from different possible formats - if (typeof item === 'string') { - name = item; - } else if (item && typeof item === 'object') { - // Based on the logs, items have file_name and model_name properties - name = item.file_name || item.model_name || item.name || item.filename; + // Process the embeddings + this.embeddings = allEmbeddings.map((item, index) => { + let fullPath = ""; - // Log first item to verify - if (index === 0) { - console.log("[KikoAutocomplete] Using name from item:", name, "from properties:", Object.keys(item)); + // Extract name from different possible formats + if (typeof item === 'string') { + fullPath = item; + } else if (item && typeof item === 'object') { + // Based on the logs, items have file_name and model_name properties + fullPath = item.file_name || item.model_name || item.name || item.filename; } - } + + // Clean up the path - remove file extensions but keep directory structure + const cleanPath = String(fullPath || "").replace(/\.(pt|safetensors|ckpt|bin)$/i, ''); + + // Extract just the filename (without path) for display/search purposes + const nameOnly = cleanPath.split('/').pop() || cleanPath; + + // Only include if we have a valid name + if (cleanPath && cleanPath !== "undefined") { + return { + name: nameOnly, // Just the name for searching + fullPath: cleanPath, // Full path including subdirectory + type: "embedding", + display: `embedding:${cleanPath}`, // Show full path in display + value: `embedding:${cleanPath}` // Use full path in value + }; + } + return null; + }).filter(item => item !== null); // Remove any null entries + } + // Check if it's a simple object with embedding names as keys (old format) + else if (embeddingsResponse && typeof embeddingsResponse === 'object' && !Array.isArray(embeddingsResponse)) { + // Try to extract embeddings from object keys + const keys = Object.keys(embeddingsResponse); - // Clean up the name - remove file extensions if present - const cleanName = String(name || "").replace(/\.(pt|safetensors|ckpt|bin)$/i, ''); - - // Only include if we have a valid name - if (cleanName && cleanName !== "undefined") { - return { - name: cleanName, - type: "embedding", - display: `embedding:${cleanName}`, - value: `embedding:${cleanName}` - }; + // If the object has standard pagination keys, it's not the embeddings object + 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}` + }; + }); + } else { + this.embeddings = []; } - return null; - }).filter(item => item !== null); // Remove any null entries - } - // Check if it's a simple object with embedding names as keys (old format) - else if (embeddingsResponse && typeof embeddingsResponse === 'object' && !Array.isArray(embeddingsResponse)) { - // Try to extract embeddings from object keys - const keys = Object.keys(embeddingsResponse); - - // If the object has standard pagination keys, it's not the embeddings object - if (!keys.includes('items') && !keys.includes('total')) { - console.log("[KikoAutocomplete] Object format detected, using keys as embedding names"); - this.embeddings = keys.map(name => { - const cleanName = name.replace(/\.(pt|safetensors|ckpt|bin)$/i, ''); - return { - name: cleanName, - type: "embedding", - display: `embedding:${cleanName}`, - value: `embedding:${cleanName}` - }; - }); } else { - console.warn("[KikoAutocomplete] Unexpected embeddings format"); this.embeddings = []; } - } else { - console.warn("[KikoAutocomplete] Unexpected embeddings response format:", typeof embeddingsResponse); + } catch (e) { this.embeddings = []; } - - console.log(`[KikoAutocomplete] Processed ${this.embeddings.length} embeddings:`, this.embeddings.slice(0, 5)); - } catch (e) { - console.error("[KikoAutocomplete] Error fetching embeddings:", e); - this.embeddings = []; } } // Fetch LoRAs - try multiple methods if (this.settings.showLoras) { - console.log("[KikoAutocomplete] Fetching LoRAs..."); // Method 1: Try our custom endpoint try { const response = await fetch("/kikotools/autocomplete/loras"); - console.log("[KikoAutocomplete] Custom LoRA endpoint status:", response.status); if (response.ok) { - const loras = await response.json(); - console.log("[KikoAutocomplete] Raw LoRAs response:", loras); - this.loras = loras.map(name => ({ - name: name, - type: "lora", - display: ``, - value: `` - })); - console.log(`[KikoAutocomplete] Processed ${this.loras.length} LoRAs from custom endpoint`); + 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: `` + }; + }); } else { throw new Error("Custom endpoint not available"); } } catch (e) { - console.log("[KikoAutocomplete] Custom endpoint failed, trying alternative method..."); // Method 2: Try using the object list API try { @@ -195,7 +222,6 @@ class KikoEmbeddingAutocomplete { // Look for LoraLoader node to get available loras if (objectInfo.LoraLoader?.input?.required?.lora_name?.[0]) { const loraNames = objectInfo.LoraLoader.input.required.lora_name[0]; - console.log("[KikoAutocomplete] Found LoRAs from object_info:", loraNames.slice(0, 5)); this.loras = loraNames.map(name => { const cleanName = name.replace(/\.(pt|safetensors|ckpt|bin)$/i, ''); return { @@ -205,28 +231,144 @@ class KikoEmbeddingAutocomplete { value: `` }; }); - console.log(`[KikoAutocomplete] Processed ${this.loras.length} LoRAs from object_info`); } } } catch (e2) { - console.error("[KikoAutocomplete] Could not fetch LoRAs:", e2); this.loras = []; } } } - console.log(`[KikoAutocomplete] Total loaded: ${this.embeddings.length} embeddings and ${this.loras.length} LoRAs`); - - // Store in window for debugging + // Store reference for debugging if needed window.kikoDebug = { embeddings: this.embeddings, loras: this.loras, + customWords: this.customWords, autocomplete: this }; - console.log("[KikoAutocomplete] Debug info available at window.kikoDebug"); } catch (error) { - console.error("[KikoAutocomplete] Fatal error in fetchResources:", error); + // Silent fail - resources will be empty + } + } + + async loadCustomWords(url) { + if (!url) return; + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to load custom words: ${response.status}`); + } + + const text = await response.text(); + + // Security check: Validate content is safe plain text + // Check for potential script injections or executable code + const dangerousPatterns = [ + //gi, // HTML script tags + //gi, // iframes + /javascript:/gi, // javascript: protocol + /\bon(click|load|error|mouseover|mouseout|focus|blur|change|submit)\s*=/gi, // Specific event handlers only + //gi, // embed tags + //gi, // object tags + /import\s+[\s\S]*?from/gi, // ES6 imports + /require\s*\(/gi, // CommonJS requires + /eval\s*\(/gi, // eval statements + /new\s+Function\s*\(/gi, // Function constructor + /\.innerHTML\s*=/gi, // innerHTML assignments + /document\.\w+/gi, // document manipulation + /window\.\w+/gi, // window manipulation + /(__proto__|\.prototype\.|\.constructor\s*\()/gi // Prototype pollution attempts - more specific + ]; + + for (const pattern of dangerousPatterns) { + if (pattern.test(text)) { + throw new Error("Security Warning: File contains potentially dangerous code patterns. Only plain text files with words/tags are allowed."); + } + } + + // Additional check: File should not be too large (limit to 10MB) + if (text.length > 10 * 1024 * 1024) { + throw new Error("File too large. Maximum size is 10MB."); + } + + const lines = text.split('\n'); + + // Parse custom words - format is typically "word,frequency" or just "word" + this.customWords = []; + let invalidLines = 0; + let validLines = 0; + const maxLineLength = 500; // Reasonable max length for a word/tag + + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + // Skip lines that are too long (likely not valid words) + if (trimmed.length > maxLineLength) { + invalidLines++; + continue; + } + + // Check for suspicious patterns in individual lines + // Be more lenient - only check for actual code patterns, not simple brackets + if ((trimmed.includes('') || + trimmed.includes('eval(') || + trimmed.includes('document.') || + trimmed.includes('window.')) { + invalidLines++; + continue; + } + + // Split by comma to handle frequency data if present + const parts = trimmed.split(','); + const word = parts[0].trim(); + const frequency = parts[1] ? parseInt(parts[1]) : 0; + + // Validate word contains only safe characters + // Allow alphanumeric (including starting with numbers), spaces, underscores, hyphens, and common punctuation + // Also allow Japanese characters and other unicode letters for international tags + // Include semicolons, equals, brackets for tags like "steins;gate" or special formatting + 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 + }); + validLines++; + } else if (word) { + invalidLines++; + } + } + + // Skip warning for invalid lines + + // Sort by frequency if available, otherwise alphabetically + this.customWords.sort((a, b) => { + if (a.frequency !== b.frequency) { + return b.frequency - a.frequency; // Higher frequency first + } + return a.name.localeCompare(b.name); + }); + + + // Store in debug info + if (window.kikoDebug) { + window.kikoDebug.customWords = this.customWords; + } + + // Return count for notification + return this.customWords.length; + + } catch (error) { + return 0; } } @@ -234,8 +376,6 @@ class KikoEmbeddingAutocomplete { if (!widget.inputEl || this.activeWidgets.has(widget)) { return; } - - console.log("[KikoAutocomplete] Attaching to widget:", widget, "on node:", node?.title); this.activeWidgets.add(widget); const textarea = widget.inputEl; @@ -263,7 +403,9 @@ class KikoEmbeddingAutocomplete { const hideSuggestions = () => { suggestionContainer.style.display = "none"; + suggestionContainer.innerHTML = ""; // Clear content to prevent artifacts selectedIndex = -1; + currentSuggestions = []; }; const showSuggestions = (suggestions, x, y) => { @@ -285,16 +427,45 @@ class KikoEmbeddingAutocomplete { // Add type indicator const typeSpan = document.createElement("span"); + let typeColor = "#2196F3"; // Default for LORA + 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: ${item.type === "embedding" ? "#4CAF50" : "#2196F3"}; + color: ${typeColor}; font-size: 0.9em; `; - typeSpan.textContent = item.type === "embedding" ? "[EMB]" : "[LORA]"; + typeSpan.textContent = typeText; const nameSpan = document.createElement("span"); - nameSpan.textContent = item.name; + // For embeddings with paths, show the full path structure + if (item.type === "embedding" && item.fullPath && item.fullPath.includes('/')) { + // Show subdirectory in gray, filename in normal color + 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); @@ -323,6 +494,24 @@ class KikoEmbeddingAutocomplete { items.forEach((item, index) => { item.style.background = index === selectedIndex ? "var(--comfy-input-bg)" : ""; }); + + // Auto-scroll to keep selected item visible + if (selectedIndex >= 0 && items[selectedIndex]) { + const selectedItem = items[selectedIndex]; + const containerRect = suggestionContainer.getBoundingClientRect(); + const itemRect = selectedItem.getBoundingClientRect(); + + // Check if item is below visible area + if (itemRect.bottom > containerRect.bottom) { + // Scroll down to show the item + suggestionContainer.scrollTop += itemRect.bottom - containerRect.bottom + 5; + } + // Check if item is above visible area + else if (itemRect.top < containerRect.top) { + // Scroll up to show the item + suggestionContainer.scrollTop -= containerRect.top - itemRect.top + 5; + } + } }; const insertSuggestion = (suggestion) => { @@ -334,13 +523,46 @@ class KikoEmbeddingAutocomplete { let insertText = ""; let replaceLength = currentPrefix.length; - // Check if we're completing an embedding (singular or plural) - if (textBefore.match(/embeddings?:([a-zA-Z0-9_-]*)$/)) { - insertText = suggestion.name; // Just the name, not the full "embedding:name" + // Apply replace underscores setting for custom words + if (suggestion.type === "custom" && this.settings.replaceUnderscores) { + // Create a modified suggestion with underscores replaced + suggestion = { + ...suggestion, + name: suggestion.name.replace(/_/g, ' '), + value: suggestion.value.replace(/_/g, ' '), + display: suggestion.display.replace(/_/g, ' ') + }; + } + + // Check if we're completing an embedding after the trigger + 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_\/-]*)$/))) { + // For embeddings, use the full path (including subdirectory) + insertText = suggestion.fullPath || suggestion.name; // Use full path if available } // Check if we're completing a lora - else if (textBefore.match(/"; // Complete the lora syntax + else if (suggestion.type === "lora") { + const loraTrigger = this.settings.loraTrigger || ""; + } else { + // Insert full lora syntax + insertText = suggestion.value || suggestion.display; + } + } + // Check if we're replacing a word that starts with "em" (embedding autocomplete) + else if (currentPrefix && textBefore.match(/\b(em|emb|embe|embed|embeddi|embeddin|embedding)$/i)) { + // User typed a partial "embedding" word, replace it with the full embedding syntax + const match = textBefore.match(/\b(em|emb|embe|embed|embeddi|embeddin|embedding)$/i); + insertText = suggestion.value || suggestion.display; + prefixStart = cursor - match[1].length; + replaceLength = match[1].length; } // General insertion else { @@ -357,8 +579,23 @@ class KikoEmbeddingAutocomplete { const before = text.substring(0, prefixStart); const after = text.substring(prefixStart + replaceLength); - textarea.value = before + insertText + after; - textarea.selectionStart = textarea.selectionEnd = prefixStart + insertText.length; + // Add comma if auto-insert comma is enabled and appropriate + let finalInsertText = insertText; + if (this.settings.autoInsertComma && (suggestion.type === "custom" || suggestion.type === "embedding" || suggestion.type === "lora")) { + // Check if we should add a comma + // Add comma if: not already followed by comma, and either at end of text or followed by non-empty content + const trimmedAfter = after.trim(); + const nextChar = trimmedAfter.charAt(0); + + // Add comma unless already followed by comma or punctuation + if (nextChar !== ',' && nextChar !== '.' && nextChar !== ';') { + // Add comma and space + finalInsertText += ', '; + } + } + + textarea.value = before + finalInsertText + after; + textarea.selectionStart = textarea.selectionEnd = prefixStart + finalInsertText.length; hideSuggestions(); @@ -372,40 +609,59 @@ class KikoEmbeddingAutocomplete { const cursor = textarea.selectionStart; const textBefore = text.substring(0, cursor); - if (this.debug) { - console.log("[KikoAutocomplete] findPrefix - textBefore:", textBefore); - } - // Check for embedding: or embeddings: trigger (both singular and plural) - const embeddingMatch = textBefore.match(/embeddings?:([a-zA-Z0-9_-]*)$/); + // Check for custom 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) { currentPrefix = embeddingMatch[1].toLowerCase(); prefixStart = cursor - embeddingMatch[1].length; - console.log("[KikoAutocomplete] Found embedding trigger, prefix:", currentPrefix); return "embedding"; } - // Check for = 0 && /[a-zA-Z0-9_-]/.test(text[start])) { start--; } start++; - const prefix = text.substring(start, cursor); + const word = text.substring(start, cursor); - // Check if we should show suggestions for general matching - if (prefix.length >= this.settings.triggerChars) { - currentPrefix = prefix.toLowerCase(); + // Check if we should show suggestions + if (word.length >= this.settings.minChars) { + currentPrefix = word.toLowerCase(); prefixStart = start; + + // Check for quick trigger (e.g., "em" for embeddings) + const quickTrigger = this.settings.quickTrigger; + if (quickTrigger && word.toLowerCase().startsWith(quickTrigger.toLowerCase())) { + return "embedding-word"; + } + + // For other words, show general matches return "general"; } @@ -415,23 +671,29 @@ class KikoEmbeddingAutocomplete { const getSuggestions = (prefix, type) => { const suggestions = []; - // If type is embedding, only show embeddings + // If type is embedding (after "embedding:"), only show embeddings if (type === "embedding" && this.settings.showEmbeddings) { suggestions.push(...this.embeddings.filter(e => - prefix === "" || e.name.toLowerCase().includes(prefix) + prefix === "" || e.name.toLowerCase().includes(prefix) || (e.fullPath && e.fullPath.toLowerCase().includes(prefix)) )); } + // If type is embedding-word (user typed "em", "emb", etc), ONLY show embeddings + else if (type === "embedding-word" && this.settings.showEmbeddings) { + // When user types "em", "emb", "embe", etc. we want to show ALL embeddings + // because they're trying to trigger the embedding autocomplete + suggestions.push(...this.embeddings); + } // If type is lora, only show loras else if (type === "lora" && this.settings.showLoras) { suggestions.push(...this.loras.filter(l => prefix === "" || l.name.toLowerCase().includes(prefix) )); } - // For general matching, show both + // For general matching, show embeddings, loras, and custom words else if (type === "general") { if (this.settings.showEmbeddings) { suggestions.push(...this.embeddings.filter(e => - e.name.toLowerCase().includes(prefix) + e.name.toLowerCase().includes(prefix) || (e.fullPath && e.fullPath.toLowerCase().includes(prefix)) )); } @@ -440,22 +702,47 @@ class KikoEmbeddingAutocomplete { l.name.toLowerCase().includes(prefix) )); } + + if (this.settings.showCustomWords) { + suggestions.push(...this.customWords.filter(w => + w.name.toLowerCase().includes(prefix) + )); + } } // Sort suggestions - prioritize exact matches and starts-with matches 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(); - // Exact match + // Exact match on name if (aName === prefix) return -1; if (bName === prefix) return 1; - // Starts with + // Exact match on full path + if (aPath === prefix) return -1; + if (bPath === prefix) return 1; + + // Starts with on name if (aName.startsWith(prefix) && !bName.startsWith(prefix)) return -1; if (!aName.startsWith(prefix) && bName.startsWith(prefix)) return 1; - // Alphabetical + // Starts with on path + if (aPath.startsWith(prefix) && !bPath.startsWith(prefix)) return -1; + if (!aPath.startsWith(prefix) && bPath.startsWith(prefix)) return 1; + + // Group by directory if enabled + 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); }); @@ -501,7 +788,8 @@ class KikoEmbeddingAutocomplete { e.preventDefault(); selectedIndex = Math.max(selectedIndex - 1, 0); updateSelection(); - } else if (e.key === "Enter" || e.key === "Tab") { + } else if ((e.key === "Enter" && this.settings.insertOnEnter) || + (e.key === "Tab" && this.settings.insertOnTab)) { if (selectedIndex >= 0 && selectedIndex < currentSuggestions.length) { e.preventDefault(); insertSuggestion(currentSuggestions[selectedIndex]); @@ -515,117 +803,667 @@ class KikoEmbeddingAutocomplete { textarea.addEventListener("blur", () => { setTimeout(hideSuggestions, 200); }); + + // Cleanup on widget destruction + if (widget.onRemoved) { + const originalOnRemoved = widget.onRemoved; + widget.onRemoved = () => { + hideSuggestions(); + if (suggestionContainer && suggestionContainer.parentNode) { + suggestionContainer.parentNode.removeChild(suggestionContainer); + } + this.activeWidgets.delete(widget); + originalOnRemoved.call(widget); + }; + } else { + widget.onRemoved = () => { + hideSuggestions(); + if (suggestionContainer && suggestionContainer.parentNode) { + suggestionContainer.parentNode.removeChild(suggestionContainer); + } + this.activeWidgets.delete(widget); + }; + } + } +} + +// Settings Dialog - Clean, modern interface +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 + content.innerHTML = ` +
+

General Settings

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+

Trigger Settings

+ +
+ + + + Text that triggers embedding autocomplete (e.g., "embedding:", "emb:", "e:") + +
+ +
+ + + + Text that triggers LoRA autocomplete (e.g., "<lora:", "lora:", "l:") + +
+
+ +
+

Display Settings

+ +
+ + + + Number of suggestions to show (5-100) + +
+ +
+ + + + Minimum characters before showing suggestions (1-10) + +
+
+ +
+

Insertion Settings

+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+
+
+ +
+

Custom Words

+ +
+ +
+ + +
+ + File Format Requirements:
+ • Plain text file only (no HTML, JavaScript, or code)
+ • One word/tag per line
+ • Optional format: word,frequency (comma-separated)
+ • Allowed characters: letters, numbers, spaces, underscores, hyphens, basic punctuation
+ • Maximum file size: 10MB
+ • Example: landscape or landscape,5000 +
+
+
+
+ `; + + // 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; + `; + cancelBtn.onmouseover = () => cancelBtn.style.background = "var(--comfy-input-bg-hover, #2a2a2a)"; + cancelBtn.onmouseout = () => cancelBtn.style.background = "var(--comfy-input-bg, #1a1a1a)"; + + 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; + + // Settings will be loaded after adding to DOM + + return backdrop; + } + + showCustomStatus(message, type = "info") { + const statusEl = document.getElementById("kiko-custom-words-status"); + if (!statusEl) return; + + statusEl.textContent = message; + statusEl.style.display = "block"; + + // Set color based on type + switch(type) { + case "success": + statusEl.style.color = "#4CAF50"; + break; + case "error": + statusEl.style.color = "#f44336"; + break; + case "warning": + statusEl.style.color = "#FFA500"; + break; + default: + statusEl.style.color = "#888"; + } + } + + loadSettings() { + if (!this.autocomplete) return; + + const settings = this.autocomplete.settings; + + // Make sure elements exist before trying to set values + 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"), + groupByDir: document.getElementById("kiko-group-by-dir"), + embeddingTrigger: document.getElementById("kiko-embedding-trigger"), + loraTrigger: document.getElementById("kiko-lora-trigger"), + maxSuggestions: document.getElementById("kiko-max-suggestions"), + minChars: document.getElementById("kiko-min-chars"), + customWordsUrl: document.getElementById("kiko-custom-words-url"), + loadCustomBtn: document.getElementById("kiko-load-custom-words"), + customStatus: document.getElementById("kiko-custom-words-status"), + autoComma: document.getElementById("kiko-auto-comma"), + replaceUnderscores: document.getElementById("kiko-replace-underscores"), + insertTab: document.getElementById("kiko-insert-tab"), + insertEnter: document.getElementById("kiko-insert-enter") + }; + + // Check if all elements exist + if (!elements.enabled) { + console.error("[KikoAutocomplete] Settings elements not found in DOM"); + return; + } + + // Set values + 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; + if (elements.groupByDir) elements.groupByDir.checked = settings.sortByDirectory !== false; + if (elements.embeddingTrigger) elements.embeddingTrigger.value = settings.embeddingTrigger || "embedding:"; + if (elements.loraTrigger) elements.loraTrigger.value = settings.loraTrigger || " { + const url = elements.customWordsUrl.value.trim(); + if (!url) { + this.showCustomStatus("Please enter a URL", "error"); + return; + } + + // Show loading state + elements.loadCustomBtn.disabled = true; + elements.loadCustomBtn.textContent = "Loading..."; + this.showCustomStatus("Loading custom words...", "info"); + + try { + const count = await this.autocomplete.loadCustomWords(url); + if (count > 0) { + this.showCustomStatus(`Successfully loaded ${count.toLocaleString()} custom words`, "success"); + // Save the URL if successful + this.autocomplete.settings.customWordsUrl = url; + this.autocomplete.saveSettings(); + } else { + this.showCustomStatus("No words found in the file", "warning"); + } + } catch (error) { + this.showCustomStatus(`Error loading custom words: ${error.message}`, "error"); + } finally { + elements.loadCustomBtn.disabled = false; + elements.loadCustomBtn.textContent = "Load"; + } + }; + } + + // Show current custom words count if any + if (this.autocomplete.customWords && this.autocomplete.customWords.length > 0) { + this.showCustomStatus(`${this.autocomplete.customWords.length.toLocaleString()} custom words loaded`, "info"); + } + } + + save() { + if (!this.autocomplete) return; + + // Gather settings + const newSettings = { + 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, + sortByDirectory: document.getElementById("kiko-group-by-dir").checked, + embeddingTrigger: document.getElementById("kiko-embedding-trigger").value || "embedding:", + loraTrigger: document.getElementById("kiko-lora-trigger").value || " this.close(), 1000); + } + + showMessage(message) { + const msgEl = document.createElement("div"); + msgEl.textContent = message; + msgEl.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: #4CAF50; + color: white; + padding: 12px 20px; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); + z-index: 100000; + animation: slideIn 0.3s ease; + `; + + // Add animation + const style = document.createElement("style"); + style.textContent = ` + @keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + `; + document.head.appendChild(style); + + document.body.appendChild(msgEl); + + setTimeout(() => { + msgEl.style.animation = "slideIn 0.3s ease reverse"; + setTimeout(() => { + msgEl.remove(); + style.remove(); + }, 300); + }, 2000); + } + + show() { + if (!this.element) { + this.createDialog(); + } + document.body.appendChild(this.element); + + // Load settings after adding to DOM + setTimeout(() => this.loadSettings(), 10); + + // Add open animation + this.dialog.style.animation = "fadeIn 0.2s ease"; + this.element.style.animation = "fadeIn 0.2s ease"; + + const style = document.createElement("style"); + style.textContent = ` + @keyframes fadeIn { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } + } + `; + document.head.appendChild(style); + this.animStyle = style; + } + + close() { + if (this.element) { + this.element.remove(); + } + if (this.animStyle) { + this.animStyle.remove(); + } } } // 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 + // Create autocomplete instance first window.kikoAutocomplete = new KikoEmbeddingAutocomplete(); + // Simple button to open settings dialog (following rgthree's pattern) + app.ui.settings.addSetting({ + id: "kikotools.embeddingAutocomplete", + name: "🫶 Embedding Autocomplete Configuration", + tooltip: "Configure autocomplete settings", + category: ["kikotools", "🫶 Embedding Autocomplete Configuration"], + defaultValue: null, + type: () => { + // Create a simple row with a button to open the dialog + 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(window.kikoAutocomplete); + dialog.show(); + }; + + tdButton.appendChild(button); + tr.appendChild(tdLabel); + tr.appendChild(tdButton); + + return tr; + } + }); + // Override STRING widget creation to add autocomplete const originalStringWidget = ComfyWidgets.STRING; - console.log("[KikoAutocomplete] Original STRING widget:", originalStringWidget); ComfyWidgets.STRING = function(node, inputName, inputData, app) { - console.log("[KikoAutocomplete] Creating STRING widget for:", inputName, "multiline:", inputData[1]?.multiline); const widget = originalStringWidget.apply(this, arguments); // Only attach to multiline text widgets (prompts) if (inputData[1]?.multiline && widget.inputEl) { - console.log("[KikoAutocomplete] Will attach to multiline widget:", inputName); setTimeout(() => { window.kikoAutocomplete.attachToWidget(widget, node); }, 100); @@ -635,19 +1473,16 @@ app.registerExtension({ }; // Also attach to existing multiline widgets - console.log("[KikoAutocomplete] Looking for existing widgets to attach to..."); setTimeout(() => { app.graph._nodes.forEach(node => { if (node.widgets) { node.widgets.forEach(widget => { // Check for text widgets with textareas if (widget.type === "customtext" && widget.inputEl && widget.inputEl.tagName === "TEXTAREA") { - console.log("[KikoAutocomplete] Found existing textarea widget on node:", node.title, "widget:", widget.name); window.kikoAutocomplete.attachToWidget(widget, node); } // Also check for widgets that might be text inputs but not customtext else if (widget.inputEl && widget.inputEl.tagName === "TEXTAREA" && !window.kikoAutocomplete.activeWidgets.has(widget)) { - console.log("[KikoAutocomplete] Found textarea widget (non-customtext) on node:", node.title, "widget:", widget.name); window.kikoAutocomplete.attachToWidget(widget, node); } });