refactor: simplify lora_text to plain text input with name resolution
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
3e1eb45065
commit
6a60059cf0
+1
-194
@@ -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)}<b style="color:#7ca8ff">${this._esc(match)}</b>${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);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+70
-20
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user