From 6a60059cf0ee83ce55e07418dbddf05ab55cf43b Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 4 Mar 2026 19:34:42 +0100 Subject: [PATCH] refactor: simplify lora_text to plain text input with name resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the autocomplete JS class (was ~155 lines). Instead, resolve short LoRA names server-side: typing "Milena_20260216180133" matches against installed LoRAs by filename stem, so it resolves to e.g. "ZImageTurbo/character/Milena_20260216180133.safetensors". No Lora Manager dependency needed — just plain text input with smart matching. Co-Authored-By: Claude Opus 4.6 --- js/lora_stack_dynamic.js | 195 +-------------------------------------- lora_optimizer.py | 90 ++++++++++++++---- 2 files changed, 71 insertions(+), 214 deletions(-) diff --git a/js/lora_stack_dynamic.js b/js/lora_stack_dynamic.js index 0c7dea9..3551f00 100644 --- a/js/lora_stack_dynamic.js +++ b/js/lora_stack_dynamic.js @@ -84,7 +84,7 @@ function updateVisibility(node) { const LM_LORAS_LIST_URL = "/api/lm/loras/list"; const LM_BASE_MODELS_URL = "/api/lm/loras/base-models?limit=100"; -const LM_AUTOCOMPLETE_URL = "/api/lm/loras/relative-paths"; + const PAGE_SIZE = 100; async function fetchJson(url) { @@ -227,187 +227,6 @@ function applyLoraFilter(node, baseModel, fullLoraList, loraBaseModelMap) { app.canvas?.setDirty?.(true, true); } -// --- LoRA Text Autocomplete (requires ComfyUI-Lora-Manager) --- - -class LoraTextAutocomplete { - constructor(textareaEl) { - this.textarea = textareaEl; - this.items = []; - this.selectedIdx = -1; - this._debounceTimer = null; - - this.dropdown = document.createElement("div"); - this.dropdown.style.cssText = - "position:absolute;z-index:9999;background:#1a1a2e;border:1px solid #555;" + - "max-height:200px;overflow-y:auto;display:none;font-size:13px;" + - "border-radius:4px;box-shadow:0 4px 12px rgba(0,0,0,0.4);"; - document.body.appendChild(this.dropdown); - - this._onInput = this._handleInput.bind(this); - this._onKeyDown = this._handleKeyDown.bind(this); - this._onBlur = () => setTimeout(() => this._hide(), 150); - this.textarea.addEventListener("input", this._onInput); - this.textarea.addEventListener("keydown", this._onKeyDown); - this.textarea.addEventListener("blur", this._onBlur); - } - - _getCurrentLine() { - const val = this.textarea.value; - const pos = this.textarea.selectionStart; - const start = val.lastIndexOf("\n", pos - 1) + 1; - let end = val.indexOf("\n", pos); - if (end === -1) end = val.length; - return { text: val.slice(start, end), start, end }; - } - - _handleInput() { - clearTimeout(this._debounceTimer); - this._debounceTimer = setTimeout(async () => { - const { text } = this._getCurrentLine(); - // Strip trailing :N or :N:N for search term - const term = text.replace(/:[^:]*$/, "").replace(/:[^:]*$/, "").trim(); - if (term.length < 2) { - this._hide(); - return; - } - await this._search(term); - }, 200); - } - - async _search(term) { - try { - const params = new URLSearchParams({ search: term, limit: "20" }); - const data = await fetchJson(`${LM_AUTOCOMPLETE_URL}?${params}`); - this.items = data.relative_paths || data || []; - if (!Array.isArray(this.items)) this.items = []; - } catch { - this.items = []; - } - if (this.items.length === 0) { - this._hide(); - return; - } - this.selectedIdx = 0; - this._render(term); - this._show(); - } - - _render(searchTerm) { - this.dropdown.innerHTML = ""; - const lowerTerm = searchTerm.toLowerCase(); - this.items.forEach((path, idx) => { - const row = document.createElement("div"); - row.style.cssText = - "padding:4px 8px;cursor:pointer;white-space:nowrap;color:#e0e0e0;"; - if (idx === this.selectedIdx) { - row.style.background = "#3a3a5e"; - } - // Highlight matching portion - const lowerPath = path.toLowerCase(); - const matchIdx = lowerPath.indexOf(lowerTerm); - if (matchIdx >= 0) { - const before = path.slice(0, matchIdx); - const match = path.slice(matchIdx, matchIdx + searchTerm.length); - const after = path.slice(matchIdx + searchTerm.length); - row.innerHTML = - `${this._esc(before)}${this._esc(match)}${this._esc(after)}`; - } else { - row.textContent = path; - } - row.addEventListener("mousedown", (e) => { - e.preventDefault(); - this._insert(path); - }); - row.addEventListener("mouseenter", () => { - this.selectedIdx = idx; - this._render(searchTerm); - }); - this.dropdown.appendChild(row); - }); - } - - _esc(str) { - const d = document.createElement("span"); - d.textContent = str; - return d.innerHTML; - } - - _show() { - const rect = this.textarea.getBoundingClientRect(); - this.dropdown.style.left = `${rect.left + window.scrollX}px`; - this.dropdown.style.top = `${rect.bottom + window.scrollY + 2}px`; - this.dropdown.style.width = `${rect.width}px`; - this.dropdown.style.display = "block"; - } - - _hide() { - this.dropdown.style.display = "none"; - this.items = []; - this.selectedIdx = -1; - } - - _insert(path) { - const { start, end } = this._getCurrentLine(); - const val = this.textarea.value; - this.textarea.value = val.slice(0, start) + path + val.slice(end); - const newPos = start + path.length; - this.textarea.setSelectionRange(newPos, newPos); - this.textarea.dispatchEvent(new Event("input", { bubbles: true })); - this._hide(); - } - - _handleKeyDown(e) { - if (this.dropdown.style.display === "none") return; - - if (e.key === "ArrowDown") { - e.preventDefault(); - this.selectedIdx = (this.selectedIdx + 1) % this.items.length; - this._render(this._getCurrentLine().text.replace(/:[^:]*$/, "").replace(/:[^:]*$/, "").trim()); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - this.selectedIdx = (this.selectedIdx - 1 + this.items.length) % this.items.length; - this._render(this._getCurrentLine().text.replace(/:[^:]*$/, "").replace(/:[^:]*$/, "").trim()); - } else if (e.key === "Enter" && this.selectedIdx >= 0) { - e.preventDefault(); - this._insert(this.items[this.selectedIdx]); - } else if (e.key === "Escape") { - e.preventDefault(); - this._hide(); - } - } - - destroy() { - clearTimeout(this._debounceTimer); - this.textarea.removeEventListener("input", this._onInput); - this.textarea.removeEventListener("keydown", this._onKeyDown); - this.textarea.removeEventListener("blur", this._onBlur); - this.dropdown.remove(); - } -} - -async function initLoraTextAutocomplete(node, retries = 0) { - const textWidget = findWidget(node, "lora_text"); - if (!textWidget) return; - - // widget.inputEl may not exist yet (lazy DOM creation) - if (!textWidget.inputEl) { - if (retries < 20) { - setTimeout(() => initLoraTextAutocomplete(node, retries + 1), 500); - } - return; - } - - // Probe Lora Manager availability - try { - await fetchJson(LM_BASE_MODELS_URL); - } catch { - return; // No Lora Manager — text input works, just no autocomplete - } - - const ac = new LoraTextAutocomplete(textWidget.inputEl); - node._loraTextAutocomplete = ac; -} - // --- Node Registration --- app.registerExtension({ @@ -426,19 +245,7 @@ app.registerExtension({ updateVisibility(node); // Initialize base model filter after visibility is set initBaseModelFilter(node); - // Initialize autocomplete for lora_text widget - initLoraTextAutocomplete(node); }, 100); - - // Cleanup on node removal - const origOnRemoved = node.onRemoved; - node.onRemoved = function () { - if (this._loraTextAutocomplete) { - this._loraTextAutocomplete.destroy(); - this._loraTextAutocomplete = null; - } - if (origOnRemoved) origOnRemoved.call(this); - }; }, }); diff --git a/lora_optimizer.py b/lora_optimizer.py index 2234826..ab5ed79 100644 --- a/lora_optimizer.py +++ b/lora_optimizer.py @@ -122,14 +122,12 @@ class LoRAStackDynamic: "lora_text": ("STRING", { "multiline": True, "default": "", - "placeholder": "path/to/lora.safetensors:0.8\npath/to/another.safetensors:0.8:0.5", + "placeholder": "Milena_20260216180133:0.8\npath/to/lora.safetensors:0.8:0.5", "pysssss.autocomplete": False, "dynamicPrompts": False, - "tooltip": "Type LoRA paths directly, one per line. " - "Format: path.safetensors (strength 1.0), " - "path.safetensors:0.8 (both strengths), or " - "path.safetensors:0.8:0.5 (model:clip). " - "Autocomplete available when ComfyUI-Lora-Manager is installed." + "tooltip": "Type LoRA names or paths, one per line. " + "Short names (e.g. Milena_20260216180133) are matched against installed LoRAs. " + "Format: name (strength 1.0), name:0.8 (both strengths), or name:0.8:0.5 (model:clip)." }), "lora_stack": ("LORA_STACK", {"tooltip": "Connect another LoRA Stack node here to add even more LoRAs to the list."}), "base_model_filter": (["All"], { @@ -145,11 +143,57 @@ class LoRAStackDynamic: CATEGORY = "loaders/lora" DESCRIPTION = "Dynamic LoRA stacker with adjustable slot count and optional per-LoRA CLIP strength" + @staticmethod + def _resolve_lora_name(name): + """Resolve a short LoRA name to its full relative path. + + Accepts full relative paths (returned as-is) or short names like + 'Milena_20260216180133' which are matched against installed LoRAs + by filename (with or without extension). + """ + lora_list = folder_paths.get_filename_list("loras") + + # Exact match (full relative path) + if name in lora_list: + return name + + # Match by filename stem (without extension) + name_lower = name.lower() + for lora_path in lora_list: + filename = os.path.basename(lora_path) + stem = os.path.splitext(filename)[0] + if stem.lower() == name_lower: + return lora_path + + # Match by filename with extension + for lora_path in lora_list: + filename = os.path.basename(lora_path) + if filename.lower() == name_lower: + return lora_path + + # Substring match on filename stem (if unique) + matches = [] + for lora_path in lora_list: + stem = os.path.splitext(os.path.basename(lora_path))[0] + if name_lower in stem.lower(): + matches.append(lora_path) + if len(matches) == 1: + return matches[0] + + logger = logging.getLogger("LoRAOptimizer") + if len(matches) > 1: + logger.warning(f"lora_text: '{name}' matches multiple LoRAs: {matches[:5]}... using first match") + return matches[0] + + logger.warning(f"lora_text: '{name}' not found in installed LoRAs, skipping") + return None + @staticmethod def _parse_lora_text(lora_text): - """Parse multiline text into LoRA entries. + """Parse multiline text into LoRA entries with name resolution. Formats (one per line): + Milena_20260216180133 -> resolved to full path, strength 1.0 path/to/lora.safetensors -> (path, 1.0, 1.0, "all") path/to/lora.safetensors:0.8 -> (path, 0.8, 0.8, "all") path/to/lora.safetensors:0.8:0.5 -> (path, 0.8, 0.5, "all") @@ -163,29 +207,35 @@ class LoRAStackDynamic: if not line or line.startswith("#"): continue + name = line + model_str = 1.0 + clip_str = 1.0 + # Try 3-part split: path:model_str:clip_str parts = line.rsplit(":", 2) if len(parts) == 3: try: clip_str = float(parts[2]) model_str = float(parts[1]) - entries.append((parts[0].strip(), model_str, clip_str, "all")) - continue + name = parts[0].strip() except ValueError: pass - # Try 2-part split: path:strength - parts = line.rsplit(":", 1) - if len(parts) == 2: - try: - strength = float(parts[1]) - entries.append((parts[0].strip(), strength, strength, "all")) - continue - except ValueError: - pass + # Try 2-part split: path:strength (only if 3-part didn't match) + if name == line: + parts = line.rsplit(":", 1) + if len(parts) == 2: + try: + strength = float(parts[1]) + model_str = strength + clip_str = strength + name = parts[0].strip() + except ValueError: + pass - # No numeric suffix — entire line is the path, strength 1.0 - entries.append((line.strip(), 1.0, 1.0, "all")) + resolved = LoRAStackDynamic._resolve_lora_name(name) + if resolved: + entries.append((resolved, model_str, clip_str, "all")) return entries