Compare commits

..

14 Commits

Author SHA1 Message Date
Will Miao
e04c22f83f fix(widgets): allow text selection in LoraInfoWidget description tab 2026-07-17 18:33:59 +08:00
Will Miao
681cc13e90 fix(widgets): persist LoRA entry selection and active tab across save/load 2026-07-17 18:27:34 +08:00
Will Miao
090e0297d4 fix(downloader): hold session lock in retry paths to prevent session close race
Refactor _create_session() to make-before-break: snapshot old session,
assign new one first, then close old.  Previously, concurrent download
retries called _create_session() without the session lock (violating its
docstring contract) and closed the old session while other coroutines
held active references — causing aiohttp to raise "NoneType has no
attribute connect" when dereferencing the torn-down connector.

Also wrap the two _create_session() calls in the integrity-retry and
network-retry paths with self._session_lock to match the locking
discipline used by the session property and refresh_session().
2026-07-17 17:21:05 +08:00
Will Miao
6f71335be4 feat(widgets): add Description tab to LoraInfoWidget with dual-mode rendering support
- Add Notes/Description tab switching with tab state persistence in widget value
- Lazy-load model description and version description from /lm/loras/metadata
- Render CivitAI HTML descriptions inline via v-html
- Auto-fetch description when LoRA selection changes while on Description tab
- Fix Vue mode height containment via contain:layout size (lm-vue-node class)
- Fix scroll wheel isolation: widget scroll vs canvas zoom in both render modes
- Add docs/comfyui-dual-mode-widgets.md with widget rendering patterns
2026-07-17 15:04:47 +08:00
Will Miao
7f51812c1e feat(nodes): add LoRA Syntax → Path node (#1015) 2026-07-16 19:57:29 +08:00
Will Miao
a9dc4d7b9d fix(widgets): reuse orphaned DOM containers after undo/redo in Vue render mode
In ComfyUI Vue render mode, WidgetDOM.vue reuses its component instance
during undo/redo without re-calling mountWidgetElement(), leaving newly
created widget containers detached from the DOM.

- AutocompleteTextWidget: scan for empty containers by ID prefix and reuse
- Loras widget: scan for empty .lm-loras-container elements and reuse
- Prevent duplicate event listeners by guarding listener setup on new
  containers only
- Keep container in DOM on cleanup (clearChildren instead of remove)
  so it can be found and reused by the next factory invocation
2026-07-16 18:54:00 +08:00
Will Miao
5d50ddb5d4 fix(ui): exit bulk mode after send-to-workflow completes 2026-07-16 18:54:00 +08:00
Will Miao
f86198d234 fix(loras): include folder prefix in context menu and bulk send-to-workflow
When using full path lora syntax, the context menu (single/bulk)
and bulk copy actions were passing only the file basename to
buildLoraSyntax(), ignoring the folder prefix. This caused the
output to look like legacy A1111 format even when full path mode
was enabled.

Aligns all entry points with ModelCard.handleSendToWorkflow(),
which correctly includes the folder prefix.

Also fixes selectAllVisibleModels() to cache the folder field,
preventing missing prefix on select-all-then-send flows.
2026-07-16 18:54:00 +08:00
Will Miao
ffe65d983c feat(api): add GET endpoints for update-lora-code and update-node-widget
Add GET variants of the two POST endpoints used by the send-to-workflow
feature. Parameters are read from query string instead of JSON body,
supporting both simple repeated node_id params and JSON-encoded node_ids
for complex graph references.
2026-07-15 21:49:22 +08:00
Will Miao
b0b5be913c fix(downloads): reject re-insertion of download_ids already in history
In add_to_queue, check download_history before INSERT OR IGNORE.  Without
this check, a fire-and-forget /queue/complete failure on the extension side
would allow the same download_id to be re-inserted after complete_download()
deleted it from the queue — creating phantom queued entries for already-
finished downloads.
2026-07-15 19:12:22 +08:00
Will Miao
01efcbc584 fix(loras): allow toggle deselect on LoRA entry click 2026-07-14 18:23:06 +08:00
Will Miao
02c249917a fix(recipe): ensure custom recipes_path is added to preview allowed roots on startup 2026-07-14 18:15:20 +08:00
Will Miao
419bbc90b2 feat(lora-info): add Lora Info display node
Add a pure frontend node that shows filename and editable notes for
a selected LoRA. Connect any output from a LoRA Loader/Stacker/Randomizer/
WanVideoSelect to the lora_source input — selecting a LoRA in the source
widget updates the info display automatically.

- Python node (LoraInfoLM): display-only, no workflow execution
- Vue widget: filename label, auto-sizing notes textarea, save button
  with ComfyUI toast feedback on save
- Frontend extension: wire-based selection propagation with stale-response
  race guard; clears display on wire disconnect
- Backend: get-notes endpoint now returns file_path alongside notes;
  matching supports full-path lora syntax; fix NoneType crash in
  trigger words endpoint; document cache file_name invariant
- Wired into all four lora widget nodes (Loader, Stacker, Randomizer,
  WanVideoSelect)
2026-07-14 18:00:31 +08:00
willmiao
b0c4510fdb docs: auto-update supporters list in README 2026-07-13 14:18:36 +00:00
31 changed files with 3208 additions and 454 deletions

View File

@@ -102,6 +102,7 @@ npm run test:coverage # Generate coverage report
- ComfyUI: `app.registerExtension()`, `node.addDOMWidget(name, type, element, options)`
- Event handlers via `addEventListener` or widget callbacks
- Shared utilities: `web/comfyui/utils.js`
- Dual-mode rendering patterns (canvas vs Vue): see `docs/comfyui-dual-mode-widgets.md`
### Vue Composables Pattern

File diff suppressed because one or more lines are too long

View File

@@ -15,6 +15,8 @@ try: # pragma: no cover - import fallback for pytest collection
from .py.nodes.lora_pool import LoraPoolLM
from .py.nodes.lora_randomizer import LoraRandomizerLM
from .py.nodes.lora_cycler import LoraCyclerLM
from .py.nodes.lora_info import LoraInfoLM
from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath
from .py.metadata_collector import init as init_metadata_collector
except (
ImportError
@@ -56,6 +58,10 @@ except (
"py.nodes.lora_randomizer"
).LoraRandomizerLM
LoraCyclerLM = importlib.import_module("py.nodes.lora_cycler").LoraCyclerLM
LoraInfoLM = importlib.import_module("py.nodes.lora_info").LoraInfoLM
LoraSyntaxToPath = importlib.import_module(
"py.nodes.lora_syntax_to_path"
).LoraSyntaxToPath
init_metadata_collector = importlib.import_module("py.metadata_collector").init
NODE_CLASS_MAPPINGS = {
@@ -75,6 +81,8 @@ NODE_CLASS_MAPPINGS = {
LoraPoolLM.NAME: LoraPoolLM,
LoraRandomizerLM.NAME: LoraRandomizerLM,
LoraCyclerLM.NAME: LoraCyclerLM,
LoraInfoLM.NAME: LoraInfoLM,
LoraSyntaxToPath.NAME: LoraSyntaxToPath,
}
WEB_DIRECTORY = "./web/comfyui"

View File

@@ -0,0 +1,65 @@
# ComfyUI Dual-Mode Widget Rendering
ComfyUI custom node widgets render in one of two modes. Patterns that work in one often fail silently in the other. Test both.
## Mode Detection
```js
typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode
```
In Vue SFCs, `window.LiteGraph` is unavailable — pass as a prop from `main.ts`.
## Canvas Mode Layout
Uses `computeLayoutSize()` + `distributeSpace()` to allocate widget height within the node. Widgets with `computeLayoutSize` participate in space distribution; those with `computeSize` have fixed height.
- `getMinHeight()` in `addDOMWidget` options → minimum widget height
- `widget.computeLayoutSize()``{ minHeight, minWidth, maxHeight? }`
- Avoid `getMaxHeight()` unless the widget genuinely needs a fixed cap (prevents user resize)
## Vue Mode Layout
Uses CSS Grid (`grid-template-rows`) + `ResizeObserver`. The ResizeObserver watches the widget's DOM and feeds back into grid row sizing. This creates a feedback loop: content grows → row resizes → more space for content → content reflows/grows → row resizes again.
### Height Containment
The fix: `contain: layout size` on the widget root. This tells the browser the element's intrinsic size is CSS-determined, not driven by descendant content. The ResizeObserver sees a stable size and the loop is broken.
```css
.widget-root.lm-vue-node {
height: 100%;
min-height: var(--comfy-widget-min-height, 200px);
contain: layout size;
}
```
Existing examples: `.lm-loras-container.lm-vue-node` and `.comfy-tags-container.lm-vue-node` in `web/comfyui/lm_styles.css`.
**Do NOT** fix height issues with `maxHeight`, `getMaxHeight()`, or inline `max-height` — these prevent the user from resizing the node.
## Scroll Wheel Isolation
Both modes need to distinguish "user wants to scroll widget content" from "user wants to zoom canvas".
**Canvas mode:** Add `@wheel` on widget root. Check `event.target.closest(selector)` for scrollable sub-areas. If scrollable → `event.stopPropagation()`. Otherwise → `app.canvas.processMouseWheel(event)`.
**Vue mode:** Add CSS class `lm-wheel-scrollable` to scrollable elements. The global capture-phase hook in `web/comfyui/utils.js` (`enableListWheelScroll`) detects wheel events on marked elements and manually scrolls them via `element.scrollTop`, consuming the event before canvas zoom sees it.
## DOM Structure
`main.ts` creates an outer `<div>` container, then `vueApp.mount(container)`. The Vue app renders its own root element inside.
- `container.id` / `container.style.*` → outer element
- Vue scoped `<style>``[data-v-hash]` applies only to Vue root
Classes needed by scoped Vue CSS must go on the Vue root element. Pass data as props and bind with `:class` rather than manipulating the DOM from `main.ts`.
## Serialization
For stateful widgets that need workflow persistence:
- `serialize: true` in `addDOMWidget` options
- `serializeValue()` → state snapshot (called on workflow save)
- `onSetValue(v)` → restore state (called on workflow load)
- Always handle missing keys in restored value for backward compatibility with old workflows

View File

@@ -208,6 +208,12 @@ class Config:
if not isinstance(library_config, dict):
return
# Always read recipes_path — it is independent of extra folder paths
# and must be set before any early returns below.
recipes_path = library_config.get("recipes_path", "")
if isinstance(recipes_path, str) and recipes_path:
self.recipes_path = recipes_path
extra_folder_paths = library_config.get("extra_folder_paths")
if not isinstance(extra_folder_paths, dict):
return
@@ -233,10 +239,6 @@ class Config:
extra_embedding
)
recipes_path = library_config.get("recipes_path", "")
if isinstance(recipes_path, str) and recipes_path:
self.recipes_path = recipes_path
if self.extra_loras_roots:
logger.info(
"Found extra LoRA roots:"

45
py/nodes/lora_info.py Normal file
View File

@@ -0,0 +1,45 @@
"""Lora Info display node — pure frontend node for showing selected LoRA info.
This node does NOT participate in workflow execution. Its single optional
"lora_source" input exists solely as a wire-connection anchor so that the
frontend can traverse the graph and push selection data to connected info nodes.
"""
from __future__ import annotations
class LoraInfoLM:
"""Display node that shows filename and notes for the selected LoRA."""
NAME = "Lora Info (LoraManager)"
CATEGORY = "Lora Manager/utils"
DESCRIPTION = (
"Displays information (filename, notes) about the currently selected "
"LoRA. Connect any output from a LoRA Loader or Stacker to the "
"lora_source input, then select a LoRA in the source widget — the "
"info updates automatically. Does not affect workflow execution."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
}
RETURN_TYPES = ()
RETURN_NAMES = ()
OUTPUT_NODE = False
FUNCTION = "noop"
def noop(self, **kwargs):
# This node is display-only — no workflow execution needed.
return ()
NODE_CLASS_MAPPINGS = {
LoraInfoLM.NAME: LoraInfoLM,
}
NODE_DISPLAY_NAME_MAPPINGS = {
LoraInfoLM.NAME: "Lora Info (LoraManager)",
}

View File

@@ -1,6 +1,5 @@
import importlib
import logging
import re
import comfy.sd # type: ignore
import comfy.utils # type: ignore
@@ -14,6 +13,7 @@ from .utils import (
extract_lora_name,
get_loras_list,
nunchaku_load_lora,
parse_lora_syntax,
)
logger = logging.getLogger(__name__)
@@ -189,25 +189,10 @@ class LoraTextLoaderLM:
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
FUNCTION = "load_loras_from_text"
def parse_lora_syntax(self, text):
"""Parse LoRA syntax from text input."""
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
matches = re.findall(pattern, text, re.IGNORECASE)
loras = []
for match in matches:
model_strength = float(match[1])
loras.append({
"name": match[0],
"model_strength": model_strength,
"clip_strength": float(match[2]) if match[2] else model_strength,
})
return loras
def load_loras_from_text(self, model, lora_syntax, clip=None, lora_stack=None):
"""Load LoRAs based on text syntax input."""
lora_entries = _collect_stack_entries(lora_stack)
for lora in self.parse_lora_syntax(lora_syntax):
for lora in parse_lora_syntax(lora_syntax):
lora_path, trigger_words = get_lora_info_absolute(lora["name"])
lora_entries.append({
"name": lora["name"],

View File

@@ -0,0 +1,62 @@
"""Node to resolve `<lora:name:strength>` syntax to absolute file system paths.
Takes the loaded_loras / active_loras STRING output from LoraLoaderLM or
LoraStackerLM and resolves each lora name to its absolute path on disk via
the scanner cache. Unknown names are returned as-is.
"""
import logging
from ..utils.utils import get_lora_info_absolute
from .utils import parse_lora_syntax
logger = logging.getLogger(__name__)
class LoraSyntaxToPath:
NAME = "LoRA Syntax → Path (LoraManager)"
CATEGORY = "Lora Manager/utils"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"lora_syntax": (
"STRING",
{
"forceInput": True,
"multiline": True,
"tooltip": (
"<lora:name:strength> formatted text from "
"loaded_loras / active_loras output"
),
},
),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("paths",)
FUNCTION = "resolve"
def resolve(self, lora_syntax: str) -> tuple[str]:
"""Parse <lora:...> syntax and resolve each name to its absolute path."""
if not lora_syntax or not lora_syntax.strip():
logger.info("Received empty lora_syntax input")
return ("",)
parsed = parse_lora_syntax(lora_syntax)
if not parsed:
logger.info("No valid <lora:...> entries found in input")
return ("",)
paths: list[str] = []
for entry in parsed:
try:
absolute_path, _ = get_lora_info_absolute(entry["name"])
paths.append(absolute_path)
except Exception:
logger.warning("Failed to resolve lora '%s', skipping", entry["name"])
continue
return ("\n".join(paths),)

View File

@@ -36,6 +36,7 @@ any_type = AnyType("*")
# Common methods extracted from lora_loader.py and lora_stacker.py
import os
import re
import logging
import copy
import sys
@@ -69,6 +70,25 @@ def extract_lora_name(lora_path):
return apply_lora_syntax_format(name_no_ext)
def parse_lora_syntax(text: str) -> list[dict]:
"""Parse <lora:name:strength> syntax from text input into a list of dicts.
Each entry contains: name, model_strength, clip_strength.
Supports both ``<lora:name:strength>`` and ``<lora:name:model_strength:clip_strength>``.
"""
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
matches = re.findall(pattern, text, re.IGNORECASE)
loras = []
for match in matches:
model_strength = float(match[1])
loras.append({
"name": match[0],
"model_strength": model_strength,
"clip_strength": float(match[2]) if match[2] else model_strength,
})
return loras
def get_loras_list(kwargs):
"""Helper to extract loras list from either old or new kwargs format"""
if "loras" not in kwargs:

View File

@@ -1784,6 +1784,124 @@ class LoraCodeHandler:
logger.error("Failed to update lora code: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_update_lora_code(self, request: web.Request) -> web.Response:
"""GET version of update_lora_code — reads parameters from query string.
Query params:
lora_code (required) — the LoRA syntax to send
mode (optional) — "append" (default) or "replace"
node_id (repeatable) — target node id(s), e.g. node_id=3&node_id=5
node_ids (optional) — JSON-encoded array for complex references with graph_id:
[{"node_id":3,"graph_id":"g1"}, ...]
"""
try:
node_ids_raw = request.query.get("node_ids")
node_id_list = request.query.getall("node_id", [])
lora_code = request.query.get("lora_code", "")
mode = request.query.get("mode", "append")
if not lora_code:
return web.json_response(
{"success": False, "error": "Missing lora_code parameter"},
status=400,
)
node_ids = None
if node_ids_raw:
try:
node_ids = json.loads(node_ids_raw)
except (json.JSONDecodeError, TypeError):
return web.json_response(
{"success": False, "error": "node_ids must be a valid JSON array"},
status=400,
)
if not isinstance(node_ids, list) or not node_ids:
return web.json_response(
{"success": False, "error": "node_ids must be a non-empty JSON array"},
status=400,
)
elif node_id_list:
node_ids = node_id_list
results = []
if node_ids is None:
try:
self._prompt_server.instance.send_sync(
"lora_code_update",
{"id": -1, "lora_code": lora_code, "mode": mode},
)
results.append({"node_id": "broadcast", "success": True})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error broadcasting lora code: %s", exc)
results.append(
{"node_id": "broadcast", "success": False, "error": str(exc)}
)
else:
for entry in node_ids:
node_identifier = entry
graph_identifier = None
if isinstance(entry, dict):
node_identifier = entry.get("node_id")
graph_identifier = entry.get("graph_id")
if node_identifier is None:
results.append(
{
"node_id": node_identifier,
"graph_id": graph_identifier,
"success": False,
"error": "Missing node_id parameter",
}
)
continue
try:
parsed_node_id = int(node_identifier)
except (TypeError, ValueError):
parsed_node_id = node_identifier
payload = {
"id": parsed_node_id,
"lora_code": lora_code,
"mode": mode,
}
if graph_identifier is not None:
payload["graph_id"] = str(graph_identifier)
try:
self._prompt_server.instance.send_sync(
"lora_code_update",
payload,
)
results.append(
{
"node_id": parsed_node_id,
"graph_id": payload.get("graph_id"),
"success": True,
}
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Error sending lora code to node %s (graph %s): %s",
parsed_node_id,
graph_identifier,
exc,
)
results.append(
{
"node_id": parsed_node_id,
"graph_id": payload.get("graph_id"),
"success": False,
"error": str(exc),
}
)
return web.json_response({"success": True, "results": results})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to update lora code (GET): %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class TrainedWordsHandler:
async def get_trained_words(self, request: web.Request) -> web.Response:
@@ -3431,6 +3549,130 @@ class NodeRegistryHandler:
logger.error("Failed to update node widget: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_update_node_widget(self, request: web.Request) -> web.Response:
"""GET version of update_node_widget — reads parameters from query string.
Query params:
widget_name (optional) — the widget name to update (required unless action is set)
action (optional) — alternative action, e.g. "inject_text" (required unless widget_name is set)
value (required) — the value to set
mode (optional) — "replace" (default) or "append"
node_id (repeatable) — target node id(s), e.g. node_id=3&node_id=5
node_ids (optional) — JSON-encoded array for complex references:
[{"node_id":3,"graph_id":"g1"}, ...]
"""
try:
widget_name = request.query.get("widget_name")
action = request.query.get("action")
value = request.query.get("value")
mode = request.query.get("mode", "replace")
node_ids_raw = request.query.get("node_ids")
node_id_list = request.query.getall("node_id", [])
if not action and (not isinstance(widget_name, str) or not widget_name):
return web.json_response(
{
"success": False,
"error": "Missing parameter: provide either 'action' or 'widget_name'",
},
status=400,
)
if not isinstance(value, str) or not value:
return web.json_response(
{"success": False, "error": "Missing value parameter"}, status=400
)
node_ids = None
if node_ids_raw:
try:
node_ids = json.loads(node_ids_raw)
except (json.JSONDecodeError, TypeError):
return web.json_response(
{"success": False, "error": "node_ids must be a valid JSON array"},
status=400,
)
if not isinstance(node_ids, list) or not node_ids:
return web.json_response(
{"success": False, "error": "node_ids must be a non-empty JSON array"},
status=400,
)
elif node_id_list:
node_ids = node_id_list
if not isinstance(node_ids, list) or not node_ids:
return web.json_response(
{"success": False, "error": "node_ids must be a non-empty list"},
status=400,
)
results = []
for entry in node_ids:
node_identifier = entry
graph_identifier = None
if isinstance(entry, dict):
node_identifier = entry.get("node_id")
graph_identifier = entry.get("graph_id")
if node_identifier is None:
results.append(
{
"node_id": node_identifier,
"graph_id": graph_identifier,
"success": False,
"error": "Missing node_id parameter",
}
)
continue
try:
parsed_node_id = int(node_identifier)
except (TypeError, ValueError):
parsed_node_id = node_identifier
payload: dict = {
"id": parsed_node_id,
"value": value,
"mode": mode,
}
if action:
payload["action"] = action
if widget_name:
payload["widget_name"] = widget_name
if graph_identifier is not None:
payload["graph_id"] = str(graph_identifier)
try:
self._prompt_server.instance.send_sync("lm_widget_update", payload)
results.append(
{
"node_id": parsed_node_id,
"graph_id": payload.get("graph_id"),
"success": True,
}
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Error sending widget update to node %s (graph %s): %s",
parsed_node_id,
graph_identifier,
exc,
)
results.append(
{
"node_id": parsed_node_id,
"graph_id": payload.get("graph_id"),
"success": False,
"error": str(exc),
}
)
return web.json_response({"success": True, "results": results})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to update node widget (GET): %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class MiscHandlerSet:
"""Aggregate handlers into a lookup compatible with the registrar."""
@@ -3497,10 +3739,12 @@ class MiscHandlerSet:
"update_usage_stats": self.usage_stats.update_usage_stats,
"get_usage_stats": self.usage_stats.get_usage_stats,
"update_lora_code": self.lora_code.update_lora_code,
"get_update_lora_code": self.lora_code.get_update_lora_code,
"get_trained_words": self.trained_words.get_trained_words,
"get_model_example_files": self.model_examples.get_model_example_files,
"register_nodes": self.node_registry.register_nodes,
"update_node_widget": self.node_registry.update_node_widget,
"get_update_node_widget": self.node_registry.get_update_node_widget,
"get_registry": self.node_registry.get_registry,
"check_model_exists": self.model_library.check_model_exists,
"check_models_exist": self.model_library.check_models_exist,

View File

@@ -1275,9 +1275,13 @@ class ModelQueryHandler:
text=f"{self._service.model_type.capitalize()} file name is required",
status=400,
)
notes = await self._service.get_model_notes(model_name)
if notes is not None:
return web.json_response({"success": True, "notes": notes})
result = await self._service.get_model_notes(model_name)
if result is not None:
return web.json_response({
"success": True,
"notes": result["notes"],
"file_path": result["file_path"],
})
return web.json_response(
{
"success": False,

View File

@@ -39,10 +39,12 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
RouteDefinition("GET", "/api/lm/update-lora-code", "get_update_lora_code"),
RouteDefinition("GET", "/api/lm/trained-words", "get_trained_words"),
RouteDefinition("GET", "/api/lm/model-example-files", "get_model_example_files"),
RouteDefinition("POST", "/api/lm/register-nodes", "register_nodes"),
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
RouteDefinition("GET", "/api/lm/update-node-widget", "get_update_node_widget"),
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
RouteDefinition("GET", "/api/lm/check-models-exist", "check_models_exist"),

View File

@@ -955,13 +955,21 @@ class BaseModelService(ABC):
return unified_tree
async def get_model_notes(self, model_name: str) -> Optional[str]:
"""Get notes for a specific model file"""
async def get_model_notes(self, model_name: str) -> Optional[dict]:
"""Get notes and file_path for a specific model file.
Supports both simple names (``OWSMianne_ANIMA_V1``) and full-path
syntax (``Anima/character/OWSMianne_ANIMA_V1``).
"""
cache = await self.scanner.get_cached_data()
for model in cache.raw_data:
if model["file_name"] == model_name:
return model.get("notes", "")
file_name = model.get("file_name", "")
if file_name == model_name or model_name.endswith("/" + file_name) or model_name.endswith("\\" + file_name):
return {
"notes": model.get("notes", ""),
"file_path": model.get("file_path", ""),
}
return None

View File

@@ -154,13 +154,23 @@ class DownloadQueueService:
"""Insert a new download into the queue.
Returns the inserted row as a dict (or an empty dict if the
download_id already exists).
download_id already exists in the queue or has a terminal
record in history).
"""
now = time.time()
file_params_json = json.dumps(file_params) if file_params is not None else None
async with self._lock:
conn = self._get_conn()
# Reject download_ids that already have a terminal record in history.
history_row = conn.execute(
"SELECT 1 FROM download_history WHERE download_id = ? LIMIT 1",
(download_id,),
).fetchone()
if history_row is not None:
return {}
conn.execute(
"""
INSERT OR IGNORE INTO download_queue (

View File

@@ -270,14 +270,14 @@ class Downloader:
Note: This is private and caller MUST hold self._session_lock.
"""
# Close existing session if any
if self._session is not None:
try:
await self._session.close()
except Exception as e: # pragma: no cover
logger.warning(f"Error closing previous session: {e}")
finally:
self._session = None
# Snapshot and clear old session reference before creating the new
# one. This ensures self._session is always valid (or None, which
# triggers a fresh creation) and avoids a race where concurrent
# requests hold a reference to a session whose connector has been
# torn down by a premature close() call — the root cause of the
# intermittent "NoneType has no attribute connect" crash.
old_session = self._session
self._session = None
# Check for app-level proxy settings
proxy_url = None # http(s) proxy, passed via the per-request `proxy=` kwarg
@@ -372,6 +372,13 @@ class Downloader:
self._proxy_url = proxy_url
self._session_created_at = datetime.now()
# Close the previous session now that the replacement is live.
if old_session is not None:
try:
await old_session.close()
except Exception as e: # pragma: no cover
logger.warning(f"Error closing previous session: {e}")
logger.debug(
"Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s",
bool(proxy_url),
@@ -753,7 +760,8 @@ class Downloader:
else:
resume_offset = 0
total_size = 0
await self._create_session()
async with self._session_lock:
await self._create_session()
continue
return False, integrity_error
@@ -843,7 +851,8 @@ class Downloader:
logger.info(f"Will resume from byte {resume_offset}")
# Refresh session to get new connection
await self._create_session()
async with self._session_lock:
await self._create_session()
continue
else:
logger.error(f"Max retries exceeded for download: {e}")

View File

@@ -271,12 +271,16 @@ class LoraService(BaseModelService):
return letters
async def get_lora_trigger_words(self, lora_name: str) -> List[str]:
"""Get trigger words for a specific LoRA file"""
"""Get trigger words for a specific LoRA file.
Supports both simple names and full-path syntax.
"""
cache = await self.scanner.get_cached_data()
for lora in cache.raw_data:
if lora["file_name"] == lora_name:
civitai_data = lora.get("civitai", {})
file_name = lora.get("file_name", "")
if file_name == lora_name or lora_name.endswith("/" + file_name) or lora_name.endswith("\\" + file_name):
civitai_data = lora.get("civitai") or {}
return civitai_data.get("trainedWords", [])
return []

View File

@@ -227,6 +227,11 @@ class ModelScanner:
entry: Dict[str, Any] = {
'file_path': normalized_path,
# file_name is always stored WITHOUT extension (e.g. "OWSMianne_ANIMA_V1",
# not "OWSMianne_ANIMA_V1.safetensors"). All upstream population points
# (MetadataManager, from_civitai_info, download manager, etc.) strip the
# extension via os.path.splitext before writing. Code consuming this field
# should match against names that are likewise extension-free.
'file_name': get_value('file_name', '') or '',
'model_name': get_value('model_name', '') or '',
'folder': normalized_folder,

View File

@@ -152,7 +152,9 @@ export class LoraContextMenu extends BaseContextMenu {
sendLoraToWorkflow(replaceMode) {
const card = this.currentCard;
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
const loraSyntax = buildLoraSyntax(card.dataset.file_name, usageTips);
const folder = card.dataset.folder || '';
const loraName = folder ? `${folder}/${card.dataset.file_name}` : card.dataset.file_name;
const loraSyntax = buildLoraSyntax(loraName, usageTips);
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
}

View File

@@ -397,6 +397,7 @@ export class BulkManager {
const updated = {
...existing,
fileName: card.dataset.file_name ?? existing.fileName,
folder: card.dataset.folder ?? existing.folder,
usageTips: card.dataset.usage_tips ?? existing.usageTips,
modelName: card.dataset.name ?? existing.modelName,
};
@@ -494,7 +495,8 @@ export class BulkManager {
if (metadata) {
const usageTips = JSON.parse(metadata.usageTips || '{}');
loraSyntaxes.push(buildLoraSyntax(metadata.fileName, usageTips));
const loraName = metadata.folder ? `${metadata.folder}/${metadata.fileName}` : metadata.fileName;
loraSyntaxes.push(buildLoraSyntax(loraName, usageTips));
} else {
missingLoras.push(filepath);
}
@@ -537,7 +539,8 @@ export class BulkManager {
if (metadata) {
const usageTips = JSON.parse(metadata.usageTips || '{}');
loraSyntaxes.push(buildLoraSyntax(metadata.fileName, usageTips));
const loraName = metadata.folder ? `${metadata.folder}/${metadata.fileName}` : metadata.fileName;
loraSyntaxes.push(buildLoraSyntax(loraName, usageTips));
} else {
missingLoras.push(filepath);
}
@@ -553,7 +556,8 @@ export class BulkManager {
return;
}
await sendLoraToWorkflow(loraSyntaxes.join(', '), replaceMode, 'lora');
const exitBulkMode = () => { if (state.bulkMode) this.toggleBulkMode(); };
await sendLoraToWorkflow(loraSyntaxes.join(', '), replaceMode, 'lora', exitBulkMode);
}
async _sendAllEmbeddingsToWorkflow() {
@@ -575,7 +579,8 @@ export class BulkManager {
}
const joinedCode = embeddingCodes.join(', ');
await sendEmbeddingToWorkflow(joinedCode);
const exitBulkMode = () => { if (state.bulkMode) this.toggleBulkMode(); };
await sendEmbeddingToWorkflow(joinedCode, exitBulkMode);
}
showBulkDeleteModal() {
@@ -674,6 +679,7 @@ export class BulkManager {
const modelId = this.parseModelId(item?.civitai?.modelId);
metadataCache.set(item.file_path, {
fileName: item.file_name,
folder: item.folder || '',
usageTips: item.usage_tips || '{}',
modelName: item.name || item.file_name,
...(modelId !== null ? { modelId } : {})

View File

@@ -656,7 +656,7 @@ async function ensureRelativeModelPath(modelPath, collectionType) {
* @param {string} syntaxType - The type of syntax ('lora' or 'recipe')
* @returns {Promise<boolean>} - Whether the operation was successful
*/
export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntaxType = 'lora') {
export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntaxType = 'lora', onComplete = null) {
const registry = await fetchWorkflowRegistry();
if (!registry) {
return false;
@@ -681,7 +681,9 @@ export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntax
}
if (nodeKeys.length === 1) {
return await sendLoraToNodes([nodeKeys[0]], loraNodes, loraSyntax, replaceMode, syntaxType);
const result = await sendLoraToNodes([nodeKeys[0]], loraNodes, loraSyntax, replaceMode, syntaxType);
if (result && typeof onComplete === 'function') onComplete();
return result;
}
const actionType =
@@ -695,8 +697,11 @@ export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntax
showNodeSelector(loraNodes, {
actionType,
actionMode,
onSend: (selectedNodeIds) =>
sendLoraToNodes(selectedNodeIds, loraNodes, loraSyntax, replaceMode, syntaxType),
onSend: async (selectedNodeIds) => {
const result = await sendLoraToNodes(selectedNodeIds, loraNodes, loraSyntax, replaceMode, syntaxType);
if (result && typeof onComplete === 'function') onComplete();
return result;
},
});
return true;
}
@@ -967,7 +972,7 @@ async function sendTextToNodes(nodeIds, nodesMap, text, mode, messages = {}) {
}
}
export async function sendEmbeddingToWorkflow(embeddingCode) {
export async function sendEmbeddingToWorkflow(embeddingCode, onComplete = null) {
const registry = await fetchWorkflowRegistry();
if (!registry) {
return false;
@@ -995,8 +1000,11 @@ export async function sendEmbeddingToWorkflow(embeddingCode) {
missingTargetMessage: translate('uiHelpers.workflow.noTargetNodeSelected', {}, 'No target node selected'),
};
const handleSend = (selectedNodeIds) =>
sendTextToNodes(selectedNodeIds, textNodes, embeddingCode, 'append', messages);
const handleSend = async (selectedNodeIds) => {
const result = await sendTextToNodes(selectedNodeIds, textNodes, embeddingCode, 'append', messages);
if (result && typeof onComplete === 'function') onComplete();
return result;
};
if (nodeKeys.length === 1) {
return await handleSend([nodeKeys[0]]);

View File

@@ -0,0 +1,638 @@
<template>
<div class="lora-info-widget" :class="{ 'lm-vue-node': isVueMode }" @wheel="onWheel">
<template v-if="loraName">
<!-- Tab bar -->
<div class="lora-info-tabs">
<label
class="lora-info-tab"
:class="{ active: activeTab === 'notes' }"
>
<input
type="radio"
v-model="activeTab"
value="notes"
class="lora-info-tab-input"
/>
<span class="lora-info-tab-label">Notes</span>
</label>
<label
class="lora-info-tab"
:class="{ active: activeTab === 'description' }"
>
<input
type="radio"
v-model="activeTab"
value="description"
class="lora-info-tab-input"
@change="onDescriptionTabActivated"
/>
<span class="lora-info-tab-label">Description</span>
</label>
</div>
<!-- Notes tab content -->
<div v-show="activeTab === 'notes'" class="tab-content notes-tab">
<div class="info-field">
<label class="info-label">Filename</label>
<div class="lora-filename">{{ loraName }}</div>
</div>
<div class="info-field notes-field">
<label class="info-label">Notes</label>
<textarea
v-model="notes"
class="lora-notes lm-wheel-scrollable"
placeholder="Add notes about this LoRA..."
:disabled="saving"
></textarea>
</div>
<button
class="save-btn"
:disabled="notes === originalNotes || saving"
@click="saveNotes"
>
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
<!-- Description tab content -->
<div v-show="activeTab === 'description'" class="tab-content description-tab lm-wheel-scrollable">
<!-- Loading state -->
<div v-if="descriptionLoading" class="description-state">
<i class="fas fa-spinner fa-spin"></i>
<span>Loading description...</span>
</div>
<!-- Error state -->
<div v-else-if="descriptionError" class="description-state error">
<span>Failed to load description</span>
</div>
<!-- Empty state (loaded but no content) -->
<div v-else-if="!hasDescription" class="description-state placeholder">
<span>No description available</span>
</div>
<!-- Description content -->
<div v-else class="description-content">
<div v-if="versionDescription" class="description-section">
<label class="info-label">About this version</label>
<div class="description-text" v-html="versionDescription"></div>
</div>
<div v-if="modelDescription" class="description-section">
<label class="info-label">Model Description</label>
<div class="description-text" v-html="modelDescription"></div>
</div>
</div>
</div>
</template>
<div v-else class="placeholder">No LoRA selected</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed, watch } from 'vue'
interface LoraInfoWidget {
serializeValue?: () => Promise<unknown>
value?: unknown
onSetValue?: (v: unknown) => void
callback?: unknown
options?: {
getValue?: () => LoraInfoWidgetValue
setValue?: (v: unknown) => void
}
node?: { widgets?: Array<{ id?: string }>; widgets_values?: Array<unknown> }
id?: string
_setLoraInfo?: (data: { name: string; notes: string; filePath: string; activeTab?: string } | null) => void
__pendingLoraInfo?: { name: string; notes: string; filePath: string; activeTab?: string } | null
}
interface LoraInfoWidgetValue {
name?: string
notes?: string
filePath?: string
activeTab?: string
}
const props = defineProps<{
widget: LoraInfoWidget
node: { id: number }
api: { fetchApi: (url: string, options?: RequestInit) => Promise<Response> }
app: { extensionManager: { toast: { add: (opts: Record<string, unknown>) => void } } }
isVueMode?: boolean
}>()
const loraName = ref<string>('')
const notes = ref<string>('')
const originalNotes = ref<string>('')
const filePath = ref<string>('')
const saving = ref<boolean>(false)
const activeTab = ref<string>('notes')
// Description tab state
const versionDescription = ref<string>('')
const modelDescription = ref<string>('')
const descriptionLoading = ref<boolean>(false)
const descriptionError = ref<boolean>(false)
const descriptionLoaded = ref<boolean>(false)
const hasDescription = computed(() =>
!!(versionDescription.value || modelDescription.value)
)
// Reset and auto-fetch description state when the LoRA selection changes
watch(filePath, (newPath) => {
descriptionLoaded.value = false
descriptionError.value = false
versionDescription.value = ''
modelDescription.value = ''
if (newPath && activeTab.value === 'description') {
fetchDescription()
}
})
function onDescriptionTabActivated() {
if (!descriptionLoaded.value && filePath.value) {
fetchDescription()
}
}
async function fetchDescription() {
if (descriptionLoading.value || !filePath.value) return
descriptionLoading.value = true
descriptionError.value = false
try {
const response = await props.api.fetchApi(
`/lm/loras/metadata?file_path=${encodeURIComponent(filePath.value)}`,
{ method: 'GET' }
)
if (!response.ok) {
throw new Error(`Failed to fetch metadata: ${response.statusText}`)
}
const data = await response.json()
if (data.success && data.metadata) {
versionDescription.value = data.metadata.description || ''
modelDescription.value = data.metadata.model?.description || ''
descriptionLoaded.value = true
} else {
// Successful response but no metadata — treat as empty, not error
descriptionLoaded.value = true
}
} catch (e) {
console.error('[LoraInfoWidget] Failed to fetch description:', e)
descriptionError.value = true
// Don't set descriptionLoaded — allow retry on next tab switch
} finally {
descriptionLoading.value = false
}
}
async function saveNotes() {
if (notes.value === originalNotes.value || saving.value) return
if (!filePath.value) return
saving.value = true
try {
const response = await props.api.fetchApi('/lm/loras/save-metadata', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath.value, notes: notes.value })
})
const result = await response.json()
if (result.success) {
props.app.extensionManager.toast.add({
severity: 'success',
summary: 'Saved',
detail: 'Notes updated successfully',
life: 2000
})
originalNotes.value = notes.value
} else {
props.app.extensionManager.toast.add({
severity: 'error',
summary: 'Error',
detail: result.message || result.error || 'Failed to save notes',
life: 3000
})
}
} catch (e) {
console.error('[LoraInfoWidget] Failed to save notes:', e)
props.app.extensionManager.toast.add({
severity: 'error',
summary: 'Error',
detail: (e as Error).message || 'Failed to save notes',
life: 3000
})
} finally {
saving.value = false
}
}
function onWheel(event: WheelEvent) {
const target = event.target as HTMLElement | null
if (!target) return
const comfyApp = (window as unknown as { app?: { canvas?: { processMouseWheel?: (e: WheelEvent) => void } } }).app
if (!comfyApp?.canvas?.processMouseWheel) return
// Always pass pinch-to-zoom to canvas
if (event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
comfyApp.canvas.processMouseWheel(event)
return
}
// Horizontal scroll: pass to canvas
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) {
event.preventDefault()
event.stopPropagation()
comfyApp.canvas.processMouseWheel(event)
return
}
// Check if the target is inside a scrollable area (notes textarea or description tab)
const scrollableEl = target.closest('.lora-notes, .description-tab') as HTMLElement | null
if (scrollableEl) {
const canScrollY = scrollableEl.scrollHeight > scrollableEl.clientHeight
if (canScrollY) {
// Let native scroll handle it, but stop propagation to prevent canvas zoom
event.stopPropagation()
return
}
}
// Forward to canvas for zoom
event.preventDefault()
event.stopPropagation()
comfyApp.canvas.processMouseWheel(event)
}
onMounted(() => {
// Build current state snapshot for serialization
const buildValue = (): LoraInfoWidgetValue => ({
name: loraName.value,
notes: notes.value,
filePath: filePath.value,
activeTab: activeTab.value,
})
// Set value from external source (workflow load, paste, etc.)
const applyValue = (v: unknown) => {
if (v && typeof v === 'object') {
const data = v as LoraInfoWidgetValue
// Set activeTab before filePath so the filePath watcher sees the correct tab
// and triggers fetchDescription() when restoring description tab
if (data.activeTab !== undefined) activeTab.value = data.activeTab
if (data.name !== undefined) loraName.value = data.name
if (data.notes !== undefined) {
notes.value = data.notes
originalNotes.value = data.notes
}
if (data.filePath !== undefined) filePath.value = data.filePath
}
}
// ComponentWidgetImpl.value getter/setter delegates to options.getValue/options.setValue.
// These must be set for workflow JSON persistence (LGraphNode.serialize/configure) to work.
props.widget.options.getValue = buildValue
props.widget.options.setValue = applyValue
// Also set serializeValue for prompt/API serialization path (executionUtil.ts)
props.widget.serializeValue = async () => buildValue()
// Handle external value updates (e.g., loading workflow, paste)
props.widget.onSetValue = applyValue
// Restore from saved value. Because configure() may call widget.value = data
// before onMounted fires (and before options.setValue is assigned), we check
// widgets_values directly in case the value was already pushed.
const widgetIndex = props.widget.node?.widgets?.findIndex(
(w: { id?: string }) => w.id === props.widget.id
)
let restored = false
if (widgetIndex !== undefined && widgetIndex >= 0) {
const savedValue = props.widget.node?.widgets_values?.[widgetIndex]
if (savedValue && typeof savedValue === 'object') {
applyValue(savedValue)
restored = true
}
}
// Fallback: if configure() ran after onMounted, widget.value (via options.getValue)
// already has the saved data. Only use this path if the widgets_values lookup didn't restore.
if (!restored && props.widget.value && typeof props.widget.value === 'object') {
applyValue(props.widget.value)
}
// Expose setLoraInfo on the widget object for external callers (e.g., lora_info.js).
// Accepts null to clear the display (when selection is deselected).
props.widget._setLoraInfo = (data: { name: string; notes: string; filePath: string; activeTab?: string } | null) => {
if (data) {
loraName.value = data.name
notes.value = data.notes
originalNotes.value = data.notes
filePath.value = data.filePath
// Preserve existing activeTab unless explicitly provided
if (data.activeTab !== undefined) {
activeTab.value = data.activeTab
}
} else {
loraName.value = ''
notes.value = ''
originalNotes.value = ''
filePath.value = ''
// Do NOT reset activeTab on deselection — user's tab preference persists
}
}
// Consume any data pushed before the Vue component mounted (race condition fix)
if (props.widget.__pendingLoraInfo) {
props.widget._setLoraInfo(props.widget.__pendingLoraInfo)
delete props.widget.__pendingLoraInfo
}
})
</script>
<style scoped>
.lora-info-widget {
padding: 12px;
background: rgba(40, 44, 52, 0.6);
border-radius: 4px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
}
/* Vue node mode: prevent content from pushing node size via ResizeObserver.
contain:layout size tells the browser the element's intrinsic size is
determined solely by CSS — not by descendant content. This breaks the
feedback loop where content grows → ResizeObserver resizes → content
reflows → repeat. Same technique used by tags_widget.js + lm_styles.css. */
.lora-info-widget.lm-vue-node {
contain: layout size;
}
/* ── Tab bar ── */
.lora-info-tabs {
display: flex;
gap: 0;
margin-bottom: 10px;
border-bottom: 1px solid var(--border-color, #444);
flex-shrink: 0;
}
.lora-info-tab {
flex: 1;
text-align: center;
cursor: pointer;
padding: 6px 0;
position: relative;
}
.lora-info-tab-input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.lora-info-tab-label {
font-size: 12px;
font-weight: 500;
color: var(--fg-color, #fff);
opacity: 0.5;
transition: opacity 0.15s;
}
.lora-info-tab:hover .lora-info-tab-label {
opacity: 0.75;
}
.lora-info-tab.active .lora-info-tab-label {
opacity: 1;
}
.lora-info-tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 25%;
right: 25%;
height: 2px;
background: rgba(66, 153, 225, 0.8);
border-radius: 1px;
}
/* ── Tab content ── */
.tab-content {
flex: 1;
min-height: 0;
overflow: hidden;
}
.notes-tab {
display: flex;
flex-direction: column;
}
.description-tab {
display: flex;
flex-direction: column;
overflow-y: auto;
min-height: 0;
}
/* ── Info fields (shared) ── */
.info-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-color, #fff);
opacity: 0.6;
}
.lora-filename {
font-size: 13px;
font-weight: 500;
color: var(--fg-color, #fff);
word-break: break-all;
margin-bottom: 8px;
/* Override node-level grab cursor and user-select:none from .lg-node.cursor-grab */
cursor: auto;
user-select: text;
-webkit-user-select: text;
}
.notes-field {
flex: 1;
min-height: 0;
}
.lora-notes {
width: 100%;
flex: 1;
min-height: 60px;
padding: 8px;
border-radius: 4px;
border: 1px solid var(--border-color, #444);
background: var(--comfy-input-bg, #333);
color: var(--fg-color, #fff);
font-size: 12px;
resize: none;
box-sizing: border-box;
font-family: inherit;
outline: none;
}
.lora-notes:focus {
border-color: var(--comfy-input-border, #444);
}
.lora-notes:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.save-btn {
width: 100%;
margin-top: 8px;
padding: 6px 12px;
border-radius: 4px;
border: 1px solid rgba(66, 153, 225, 0.4);
background: rgba(66, 153, 225, 0.15);
color: var(--fg-color, #fff);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
box-sizing: border-box;
flex-shrink: 0;
}
.save-btn:hover:not(:disabled) {
background: rgba(66, 153, 225, 0.25);
border-color: rgba(66, 153, 225, 0.6);
}
.save-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
background: rgba(66, 153, 225, 0.05);
border-color: rgba(226, 232, 240, 0.1);
}
/* ── Description states ── */
.description-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px 16px;
color: var(--fg-color, #fff);
opacity: 0.5;
font-size: 12px;
min-height: 0;
flex-shrink: 0;
}
.description-state.error {
opacity: 0.7;
color: #f87171;
}
/* ── Description content ── */
.description-content {
min-height: 0;
}
.description-section {
margin-bottom: 14px;
}
.description-section:last-child {
margin-bottom: 0;
}
.description-text {
padding: 8px 0;
font-size: 12px;
line-height: 1.5;
color: var(--fg-color, #fff);
opacity: 0.85;
word-break: break-word;
/* Override node-level grab cursor and user-select:none from .lg-node.cursor-grab */
cursor: auto;
user-select: text;
-webkit-user-select: text;
}
.description-text :deep(p) {
margin: 0 0 8px 0;
}
.description-text :deep(p:last-child) {
margin-bottom: 0;
}
.description-text :deep(a) {
color: rgba(66, 153, 225, 0.9);
}
.description-text :deep(ul),
.description-text :deep(ol) {
padding-left: 20px;
margin: 4px 0;
}
.description-text :deep(h1),
.description-text :deep(h2),
.description-text :deep(h3) {
font-size: 13px;
margin: 10px 0 4px 0;
font-weight: 600;
opacity: 0.95;
}
.description-text :deep(code) {
background: rgba(255, 255, 255, 0.08);
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
}
.description-text :deep(img) {
max-width: 100%;
border-radius: 4px;
}
/* ── Placeholder (shared) ── */
.placeholder {
font-style: italic;
color: rgba(226, 232, 240, 0.5);
text-align: center;
padding: 16px 0;
font-size: 12px;
}
/* ── Spinner (Font Awesome) ── */
.fa-spinner {
animation: fa-spin 1s linear infinite;
}
@keyframes fa-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>

View File

@@ -5,6 +5,7 @@ import LoraRandomizerWidget from '@/components/LoraRandomizerWidget.vue'
import LoraCyclerWidget from '@/components/LoraCyclerWidget.vue'
import JsonDisplayWidget from '@/components/JsonDisplayWidget.vue'
import AutocompleteTextWidget from '@/components/AutocompleteTextWidget.vue'
import LoraInfoWidget from '@/components/LoraInfoWidget.vue'
import { createVueWidgetCleanup } from './vue-widget-cleanup'
import type { LoraPoolConfig, RandomizerConfig, CyclerConfig } from './composables/types'
import {
@@ -23,6 +24,8 @@ const LORA_CYCLER_WIDGET_MIN_HEIGHT = 408
const LORA_CYCLER_WIDGET_MAX_HEIGHT = LORA_CYCLER_WIDGET_MIN_HEIGHT
const JSON_DISPLAY_WIDGET_MIN_WIDTH = 300
const JSON_DISPLAY_WIDGET_MIN_HEIGHT = 200
const LORA_INFO_WIDGET_MIN_WIDTH = 300
const LORA_INFO_WIDGET_MIN_HEIGHT = 200
const AUTOCOMPLETE_TEXT_WIDGET_MIN_HEIGHT = 60
const AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT = 100
// Per-modelType min size hints for node initial sizing.
@@ -71,7 +74,7 @@ function forwardMiddleMouseToCanvas(container: HTMLElement) {
})
}
const vueApps = new Map<number, VueApp>()
const vueApps = new Map<number | string, VueApp>()
let autocompleteTextWidgetInstanceId = 0
export function createAutocompleteTextWidgetInstanceId() {
@@ -402,7 +405,6 @@ function createJsonDisplayWidget(node) {
return { widget }
}
// Store nodeData options per widget type for autocomplete widgets
const widgetInputOptions: Map<string, { placeholder?: string }> = new Map()
function getSerializableWidgetNames(node: any): string[] {
@@ -642,6 +644,75 @@ if (app.ui?.settings) {
}, 100)
}
// @ts-ignore
function createLoraInfoWidget(node: any) {
const container = document.createElement('div')
container.id = `lora-info-widget-${node.id}`
container.style.width = '100%'
container.style.height = '100%'
container.style.display = 'flex'
container.style.flexDirection = 'column'
container.style.overflow = 'hidden'
forwardMiddleMouseToCanvas(container)
let internalValue: { name?: string; notes?: string; filePath?: string; activeTab?: string } | undefined
const widget = node.addDOMWidget(
'lora_info_display',
'LORA_INFO_DISPLAY',
container,
{
getValue() {
return internalValue
},
setValue(v: { name?: string; notes?: string; filePath?: string; activeTab?: string }) {
internalValue = v
if (typeof widget.onSetValue === 'function') {
widget.onSetValue(v)
}
},
serialize: true,
getMinHeight() {
return LORA_INFO_WIDGET_MIN_HEIGHT
}
}
)
const vueApp = createApp(LoraInfoWidget, {
widget,
node,
api,
app,
isVueMode: typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode,
})
vueApp.use(PrimeVue, {
unstyled: true,
ripple: false
})
vueApp.mount(container)
vueApps.set(node.id + 40000, vueApp) // Offset to avoid collision
widget.computeLayoutSize = () => {
const minWidth = LORA_INFO_WIDGET_MIN_WIDTH
const minHeight = LORA_INFO_WIDGET_MIN_HEIGHT
return { minHeight, minWidth }
}
widget.onRemove = () => {
const vueApp = vueApps.get(node.id + 40000)
if (vueApp) {
vueApp.unmount()
vueApps.delete(node.id + 40000)
}
}
return { widget }
}
// Factory function for creating autocomplete text widgets
// @ts-ignore
function createAutocompleteTextWidgetFactory(
@@ -651,16 +722,30 @@ function createAutocompleteTextWidgetFactory(
inputOptions: { placeholder?: string } = {}
) {
const metadataWidgetName = `__lm_autocomplete_meta_${widgetName}`
const instanceId = createAutocompleteTextWidgetInstanceId()
const container = document.createElement('div')
container.id = `autocomplete-text-widget-${instanceId}`
container.style.width = '100%'
container.style.height = '100%'
container.style.display = 'flex'
container.style.flexDirection = 'column'
container.style.overflow = 'hidden'
forwardMiddleMouseToCanvas(container)
let container: HTMLElement | null = null
const existingContainers = document.querySelectorAll<HTMLElement>(
'[id^="autocomplete-text-widget-"]'
)
for (const el of existingContainers) {
if (el.children.length === 0) {
container = el
break
}
}
if (!container) {
const instanceId = String(createAutocompleteTextWidgetInstanceId())
container = document.createElement('div')
container.id = `autocomplete-text-widget-${instanceId}`
container.style.width = '100%'
container.style.height = '100%'
container.style.display = 'flex'
container.style.flexDirection = 'column'
container.style.overflow = 'hidden'
forwardMiddleMouseToCanvas(container)
}
// Store textarea reference on the container element so cloned widgets can access it
// This is necessary because when widgets are promoted to subgraph nodes,
@@ -739,15 +824,10 @@ function createAutocompleteTextWidgetFactory(
})
vueApp.mount(container)
const appKey = instanceId
const appKey = container.id
vueApps.set(appKey, vueApp)
if (maxHeight) {
// Set only minHeight as a true minimum — remove maxHeight so the
// textarea can grow when the user resizes it in app mode (where
// [&_textarea]:resize-y applies). Graph mode (canvas & Vue render)
// is unaffected because LiteGraph's layout system still governs
// the widget area size.
container.style.minHeight = `${AUTOCOMPLETE_TEXT_WIDGET_MIN_HEIGHT}px`
}
@@ -759,10 +839,14 @@ function createAutocompleteTextWidgetFactory(
)
}
widget.onRemove = createVueWidgetCleanup(vueApp, () => {
const vueCleanup = createVueWidgetCleanup(vueApp, () => {
vueApps.delete(appKey)
})
widget.onRemove = () => {
vueCleanup()
}
// Return minWidth/minHeight hints so ComfyUI's _initialMinSize mechanism
// sets a sensible initial node width (and height for prompt/embeddings).
// loras modelType retains its existing height constraints (getMaxHeight: 100).
@@ -804,7 +888,75 @@ app.registerExtension({
updateDownstreamLoaders(node)
} : null
return addLorasWidgetCache(node, 'loras', { isRandomizerNode }, callback)
const opts: { isRandomizerNode?: boolean; onSelectionChange?: (selection: any) => void } = {
isRandomizerNode,
}
if (isRandomizerNode) {
opts.onSelectionChange = async (selection: any) => {
if (!selection?.name || !selection?.active) return
// Walk outputs to find directly connected Lora Info nodes
const infoNodes: any[] = []
if (node.outputs) {
for (const output of node.outputs) {
if (!output?.links?.length) continue
for (const linkId of output.links) {
const links = node.graph?.links
if (!links) continue
const link = Array.isArray(links) ? links[linkId] : links.get?.(linkId)
if (!link) continue
const targetNode = node.graph?.getNodeById?.(link.target_id)
if (targetNode?.comfyClass === 'Lora Info (LoraManager)') {
infoNodes.push(targetNode)
}
}
}
}
if (infoNodes.length === 0) return
// Bump request token to guard against stale async responses
for (const infoNode of infoNodes) {
infoNode.__loraInfoReqId = (infoNode.__loraInfoReqId || 0) + 1
}
const reqIdSnapshot = new Map<any, number>()
for (const infoNode of infoNodes) {
reqIdSnapshot.set(infoNode, infoNode.__loraInfoReqId)
}
// Fetch notes via the real ComfyUI api
let infoData: any
try {
const response = await api.fetchApi(
`/lm/loras/get-notes?name=${encodeURIComponent(selection.name)}`,
{ method: 'GET' }
)
if (response?.ok) {
const data = await response.json()
infoData = {
name: selection.name,
notes: data?.notes || '',
filePath: data?.file_path || '',
}
} else {
infoData = { name: selection.name, notes: '[Error loading notes]', filePath: '' }
}
} catch {
infoData = { name: selection.name, notes: '[Error loading notes]', filePath: '' }
}
for (const infoNode of infoNodes) {
if (infoNode.__loraInfoReqId !== reqIdSnapshot.get(infoNode)) {
continue
}
if (typeof infoNode._setLoraInfo === 'function') {
infoNode._setLoraInfo(infoData)
}
}
}
}
return addLorasWidgetCache(node, 'loras', opts, callback)
},
// Autocomplete text widget for LoRAs (used by Lora Loader, Lora Stacker, WanVideo Lora Select)
// @ts-ignore
@@ -823,7 +975,7 @@ app.registerExtension({
AUTOCOMPLETE_TEXT_PROMPT(node) {
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {}
return createAutocompleteTextWidgetFactory(node, 'text', 'prompt', options)
}
},
}
},
@@ -868,9 +1020,7 @@ app.registerExtension({
info.widgets_values = [...(info.widgets_values ?? []), null]
}
const result = originalConfigure?.apply(this, arguments)
return result
return originalConfigure?.apply(this, arguments)
}
}
@@ -903,5 +1053,17 @@ app.registerExtension({
createJsonDisplayWidget(this)
}
}
// Add the Lora Info display widget
if (nodeData.name === 'Lora Info (LoraManager)') {
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, [])
// Create the lora info display widget
createLoraInfoWidget(this)
}
}
}
})

View File

@@ -0,0 +1,417 @@
/**
* Tests for LoraInfoWidget — tab switching, lazy description loading,
* state serialization roundtrip, and activeTab persistence.
*/
import { nextTick } from 'vue'
import { shallowMount } from '@vue/test-utils'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import LoraInfoWidget from '@/components/LoraInfoWidget.vue'
import { setupFetchMock, resetFetchMock } from '../setup'
// ── Helpers ──
function createMockFetchApi(overrides: {
response?: unknown
ok?: boolean
error?: string
} = {}) {
const { response = { success: true, metadata: {} }, ok = true } = overrides
return vi.fn().mockResolvedValue({
ok,
json: () => Promise.resolve(response),
})
}
function createMockToast() {
return { add: vi.fn() }
}
function createMockWidget(value?: unknown) {
type PendingInfo = { name: string; notes: string; filePath: string; activeTab?: string } | null
const widget = {
serializeValue: (async () => null) as () => Promise<unknown>,
value: (value ?? undefined) as unknown,
onSetValue: undefined as unknown as ((v: unknown) => void),
_setLoraInfo: undefined as unknown as (data: Record<string, unknown> | null) => void,
__pendingLoraInfo: undefined as unknown as PendingInfo | undefined,
}
return widget
}
interface MountOptions {
initialValue?: Record<string, unknown>
}
type TestWidget = ReturnType<typeof createMockWidget>
function mountWidget(options: MountOptions = {}) {
const fetchApi = createMockFetchApi()
const widget = createMockWidget(options.initialValue)
const node = { id: 1 }
const app = { extensionManager: { toast: createMockToast() } }
const wrapper = shallowMount(LoraInfoWidget, {
props: { widget, node, api: { fetchApi }, app },
})
return { wrapper, widget: widget as TestWidget, fetchApi, app }
}
// ── Tests ──
describe('LoraInfoWidget', () => {
beforeEach(() => {
setupFetchMock()
})
afterEach(() => {
resetFetchMock()
})
describe('initial state', () => {
it('shows placeholder when no LoRA is selected', () => {
const { wrapper } = mountWidget()
expect(wrapper.text()).toContain('No LoRA selected')
})
it('shows Notes tab by default when LoRA is set', async () => {
const { wrapper, widget } = mountWidget()
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
expect(wrapper.text()).toContain('test.safetensors')
expect(wrapper.find('.notes-tab').isVisible()).toBe(true)
expect(wrapper.find('.description-tab').isVisible()).toBe(false)
})
})
describe('tab switching', () => {
it('switches to Description tab and back to Notes', async () => {
const { wrapper, widget } = mountWidget()
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
const tabs = wrapper.findAll('.lora-info-tab')
// Click Description tab
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
expect(tabs[1].classes()).toContain('active')
expect(wrapper.text()).toContain('No description available')
// Switch back to Notes
const notesTab = wrapper.findAll('.lora-info-tab-input')[0]
await notesTab.setValue('notes')
await nextTick()
expect(tabs[0].classes()).toContain('active')
expect(wrapper.text()).toContain('test.safetensors')
})
})
describe('description lazy loading', () => {
it('fetches metadata when Description tab is activated', async () => {
const fetchApi = createMockFetchApi({
response: {
success: true,
metadata: {
description: '<p>Version desc</p>',
model: { description: '<p>Model desc</p>' },
},
},
})
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
// Switch to Description tab
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
await nextTick() // flush async fetch
expect(fetchApi).toHaveBeenCalledWith(
expect.stringContaining('/lm/loras/metadata'),
expect.objectContaining({ method: 'GET' })
)
expect(wrapper.html()).toContain('Version desc')
expect(wrapper.html()).toContain('Model desc')
})
it('shows loading state while fetching', async () => {
// Use a never-resolving promise to simulate loading
const fetchApi = vi.fn().mockReturnValue(new Promise(() => {}))
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
expect(wrapper.text()).toContain('Loading description')
})
it('shows error state when fetch fails', async () => {
const fetchApi = vi.fn().mockRejectedValue(new Error('Network error'))
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
await nextTick()
expect(wrapper.text()).toContain('Failed to load description')
})
it('shows empty state when metadata has no descriptions', async () => {
const fetchApi = createMockFetchApi({
response: {
success: true,
metadata: {
description: '',
model: {},
},
},
})
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
await nextTick()
expect(wrapper.text()).toContain('No description available')
})
it('caches description and does not re-fetch on second activation', async () => {
const fetchApi = createMockFetchApi({
response: {
success: true,
metadata: {
description: '<p>Version desc</p>',
model: { description: '<p>Model desc</p>' },
},
},
})
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
// First activation
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
await nextTick()
expect(fetchApi).toHaveBeenCalledTimes(1)
// Switch away and back
const notesTab = wrapper.findAll('.lora-info-tab-input')[0]
await notesTab.setValue('notes')
await nextTick()
await descriptionTab.setValue('description')
await nextTick()
// Should NOT have called fetch again
expect(fetchApi).toHaveBeenCalledTimes(1)
})
it('re-fetches when LoRA selection changes', async () => {
const fetchApi = createMockFetchApi({
response: {
success: true,
metadata: {
description: '<p>Version desc</p>',
model: { description: '<p>Model desc</p>' },
},
},
})
const widget = createMockWidget()
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi },
app: { extensionManager: { toast: createMockToast() } },
},
})
widget._setLoraInfo!({ name: 'first.safetensors', notes: '', filePath: '/path/first.safetensors' })
await nextTick()
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
await nextTick()
expect(fetchApi).toHaveBeenCalledTimes(1)
// Select a different LoRA — resets description state
widget._setLoraInfo!({ name: 'second.safetensors', notes: '', filePath: '/path/second.safetensors' })
await nextTick()
// Should show loading again (not cached)
await descriptionTab.setValue('description')
await nextTick()
await nextTick()
expect(fetchApi).toHaveBeenCalledTimes(2)
})
})
describe('serialization roundtrip', () => {
it('serializeValue includes activeTab', async () => {
const { wrapper, widget } = mountWidget()
widget._setLoraInfo!({ name: 'test.safetensors', notes: 'my notes', filePath: '/path/test.safetensors' })
await nextTick()
// Switch to Description tab
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
const serialized = await widget.serializeValue!()
expect(serialized).toMatchObject({
name: 'test.safetensors',
notes: 'my notes',
filePath: '/path/test.safetensors',
activeTab: 'description',
})
})
it('onSetValue restores activeTab from workflow value', async () => {
const { wrapper } = mountWidget({
initialValue: {
name: 'saved.safetensors',
notes: 'saved notes',
filePath: '/path/saved.safetensors',
activeTab: 'description',
},
})
await nextTick()
// Description tab should be visible (activeTab restored to 'description')
expect(wrapper.find('.description-tab').isVisible()).toBe(true)
expect(wrapper.text()).toContain('saved.safetensors')
})
it('defaults to notes tab when activeTab is missing in saved value', async () => {
const { wrapper } = mountWidget({
initialValue: {
name: 'legacy.safetensors',
notes: 'legacy notes',
filePath: '/path/legacy.safetensors',
// No activeTab — legacy workflow
},
})
await nextTick()
expect(wrapper.find('.notes-tab').isVisible()).toBe(true)
})
})
describe('_setLoraInfo race condition guard', () => {
it('consumes __pendingLoraInfo pushed before mount', async () => {
const widget = createMockWidget()
widget.__pendingLoraInfo = {
name: 'pending.safetensors',
notes: 'pending notes',
filePath: '/path/pending.safetensors',
}
const wrapper = shallowMount(LoraInfoWidget, {
props: {
widget,
node: { id: 1 },
api: { fetchApi: createMockFetchApi() },
app: { extensionManager: { toast: createMockToast() } },
},
})
await nextTick()
expect(widget.__pendingLoraInfo).toBeUndefined()
expect(wrapper.text()).toContain('pending.safetensors')
})
it('preserves activeTab when _setLoraInfo called with null (deselection)', async () => {
const { wrapper, widget } = mountWidget()
widget._setLoraInfo!({ name: 'test.safetensors', notes: '', filePath: '/path/test.safetensors' })
await nextTick()
// Switch to Description tab
const descriptionTab = wrapper.findAll('.lora-info-tab-input')[1]
await descriptionTab.setValue('description')
await nextTick()
// Deselect — template shows placeholder (no tab bar rendered)
widget._setLoraInfo!(null)
await nextTick()
// Placeholder shown
expect(wrapper.text()).toContain('No LoRA selected')
// Re-select — activeTab should still be 'description'
widget._setLoraInfo!({ name: 'second.safetensors', notes: '', filePath: '/path/second.safetensors' })
await nextTick()
const tabs = wrapper.findAll('.lora-info-tab')
expect(tabs[1].classes()).toContain('active')
})
})
})

182
web/comfyui/lora_info.js Normal file
View File

@@ -0,0 +1,182 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import {
getLinkFromGraph,
chainCallback,
} from "./utils.js";
const LORA_INFO_CLASS = "Lora Info (LoraManager)";
/**
* Find Lora Info nodes directly connected to the given node's outputs.
* Mirrors the getConnectedTriggerToggleNodes pattern from utils.js.
* @param {object} node - The source node to check outputs from
* @returns {object[]} Array of connected Lora Info node instances
*/
export function getConnectedLoraInfoNodes(node) {
const connectedNodes = [];
if (!node?.outputs) {
return connectedNodes;
}
for (const output of node.outputs) {
if (!output?.links?.length) {
continue;
}
for (const linkId of output.links) {
const link = getLinkFromGraph(node.graph, linkId);
if (!link) {
continue;
}
const targetNode = node.graph?.getNodeById?.(link.target_id);
if (targetNode && targetNode.comfyClass === LORA_INFO_CLASS) {
connectedNodes.push(targetNode);
}
}
}
return connectedNodes;
}
/**
* Fetch notes for the selected lora and push them to all directly connected
* Lora Info nodes (no recursive chain traversal — only direct connections).
* @param {object} node - The source LoRA Loader/Stacker node
* @param {object|null} selection - The current lora selection {name, active, entry}
*/
export async function updateConnectedLoraInfoNodes(node, selection) {
if (!node) {
return;
}
const infoNodes = getConnectedLoraInfoNodes(node);
if (infoNodes.length === 0) {
return;
}
// No selection or inactive — clear the display on all connected info nodes
if (!selection?.name || !selection?.active) {
for (const infoNode of infoNodes) {
infoNode.__loraInfoReqId = (infoNode.__loraInfoReqId || 0) + 1;
if (typeof infoNode._setLoraInfo === "function") {
infoNode._setLoraInfo(null);
} else {
infoNode.__pendingLoraInfo = null;
}
}
return;
}
// Bump request token on each info node to guard against stale async responses
for (const infoNode of infoNodes) {
infoNode.__loraInfoReqId = (infoNode.__loraInfoReqId || 0) + 1;
}
const reqIdSnapshot = new Map();
for (const infoNode of infoNodes) {
reqIdSnapshot.set(infoNode, infoNode.__loraInfoReqId);
}
// Fetch notes for the selected lora
try {
const response = await api.fetchApi(
`/lm/loras/get-notes?name=${encodeURIComponent(selection.name)}`,
{ method: "GET" }
);
if (!response?.ok) {
throw new Error(`Failed to fetch notes for ${selection.name}`);
}
const data = await response.json();
const infoData = {
name: selection.name,
notes: data?.notes || "",
filePath: data?.file_path || "",
};
for (const infoNode of infoNodes) {
// Discard if a newer request has been issued for this node
if (infoNode.__loraInfoReqId !== reqIdSnapshot.get(infoNode)) {
continue;
}
if (typeof infoNode._setLoraInfo === "function") {
infoNode._setLoraInfo(infoData);
} else {
infoNode.__pendingLoraInfo = infoData;
}
}
} catch (error) {
console.error("Error fetching notes for lora info:", error);
const errorData = {
name: selection.name,
notes: "[Error loading notes]",
filePath: "",
};
for (const infoNode of infoNodes) {
if (infoNode.__loraInfoReqId !== reqIdSnapshot.get(infoNode)) {
continue;
}
if (typeof infoNode._setLoraInfo === "function") {
infoNode._setLoraInfo(errorData);
} else {
infoNode.__pendingLoraInfo = errorData;
}
}
}
}
app.registerExtension({
name: "LoraManager.LoraInfo",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== LORA_INFO_CLASS) {
return;
}
chainCallback(nodeType.prototype, "onNodeCreated", function () {
// Add wire-only input for receiving connections from LoRA nodes
this.addInput("lora_source", "*", { shape: 7 });
// Forward lora info data to the Vue widget when available.
this._setLoraInfo = function (data) {
const widget = this.widgets?.find(
(w) => w.type === "LORA_INFO_DISPLAY"
);
if (widget) {
if (typeof widget._setLoraInfo === "function") {
widget._setLoraInfo(data);
} else {
widget.__pendingLoraInfo = data;
}
}
};
});
// When the lora_source wire is disconnected, clear the display.
const origOnConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function (type, index, connected, link_info) {
if (origOnConnectionsChange) {
origOnConnectionsChange.apply(this, arguments);
}
// type 1 = input connection change; disconnected = !connected
if (type === 1 && !connected) {
const input = this.inputs?.[index];
if (input?.name === "lora_source") {
// Check if any lora_source input still has a connection
const hasLoraSourceConnection = this.inputs?.some(
(inp) => inp.name === "lora_source" && inp.link != null
);
if (!hasLoraSourceConnection) {
this._setLoraInfo?.(null);
}
}
}
};
},
});

View File

@@ -13,6 +13,7 @@ import {
import { addLorasWidget } from "./loras_widget.js";
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
import { applySelectionHighlight } from "./trigger_word_highlight.js";
import { updateConnectedLoraInfoNodes } from "./lora_info.js";
app.registerExtension({
name: "LoraManager.LoraLoader",
@@ -185,8 +186,10 @@ app.registerExtension({
this,
"loras",
{
onSelectionChange: (selection) =>
applySelectionHighlight(this, selection),
onSelectionChange: (selection) => {
applySelectionHighlight(this, selection);
updateConnectedLoraInfoNodes(this, selection);
},
},
(value) => {
// Prevent recursive calls

View File

@@ -11,6 +11,7 @@ import {
import { addLorasWidget } from "./loras_widget.js";
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
import { applySelectionHighlight } from "./trigger_word_highlight.js";
import { updateConnectedLoraInfoNodes } from "./lora_info.js";
app.registerExtension({
name: "LoraManager.LoraStacker",
@@ -64,8 +65,10 @@ app.registerExtension({
this,
"loras",
{
onSelectionChange: (selection) =>
applySelectionHighlight(this, selection),
onSelectionChange: (selection) => {
applySelectionHighlight(this, selection);
updateConnectedLoraInfoNodes(this, selection);
},
},
(value) => {
// Prevent recursive calls

View File

@@ -14,12 +14,31 @@ import { getStrengthStepPreference } from "./settings.js";
export function addLorasWidget(node, name, opts, callback) {
ensureLmStyles();
// Create container for loras
const container = document.createElement("div");
container.className = "lm-loras-container";
// Create container for loras — search for an empty container already
// in the DOM first. During undo/redo in ComfyUI Vue render mode,
// WidgetDOM.vue reuses its component without re-calling
// mountWidgetElement(), so we must reuse the existing DOM element
// instead of creating an orphaned replacement.
let container = null;
let reuseExisting = false;
const existingContainers = document.querySelectorAll('.lm-loras-container');
for (const el of existingContainers) {
if (el.children.length === 0) {
container = el;
reuseExisting = true;
break;
}
}
forwardMiddleMouseToCanvas(container);
forwardWheelToCanvas(container);
if (!container) {
container = document.createElement("div");
container.className = "lm-loras-container";
}
if (!reuseExisting) {
forwardMiddleMouseToCanvas(container);
forwardWheelToCanvas(container);
}
// Set initial height using CSS variables approach
const defaultHeight = 200;
@@ -29,10 +48,8 @@ export function addLorasWidget(node, name, opts, callback) {
// scrolls when content exceeds the allocated space.
container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`);
if (typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode) {
if (!reuseExisting && typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode) {
container.classList.add('lm-vue-node');
// Window capture-phase hook: scroll the widget instead of zooming the canvas
// when the wheel is over a scrollable loras list.
enableListWheelScroll(container);
}
@@ -280,8 +297,8 @@ export function addLorasWidget(node, name, opts, callback) {
e.preventDefault();
e.stopPropagation();
selectLora(name);
container.focus(); // Focus container for keyboard events
selectLora(name === selectedLora ? null : name);
container.focus();
});
// Conditionally create drag handle OR lock button
@@ -694,7 +711,11 @@ export function addLorasWidget(node, name, opts, callback) {
// Create widget with new DOM Widget API
const widget = node.addDOMWidget(name, "custom", container, {
getValue: function() {
return widgetValue;
return widgetValue.map(lora => {
const entry = { ...lora };
entry.selected = lora.name === selectedLora;
return entry;
});
},
setValue: function(v) {
// Remove duplicates by keeping the last occurrence of each lora name
@@ -721,6 +742,15 @@ export function addLorasWidget(node, name, opts, callback) {
});
widgetValue = updatedValue;
// Restore selection state when loading a saved workflow
if (!selectedLora) {
const selectedEntry = updatedValue.find(lora => lora.selected);
if (selectedEntry) {
selectedLora = selectedEntry.name;
}
}
renderLoras(widgetValue, widget);
},
hideOnZoom: true,
@@ -732,9 +762,10 @@ export function addLorasWidget(node, name, opts, callback) {
widget.callback = callback;
widget.onRemove = () => {
container.remove();
while (container.firstChild) {
container.removeChild(container.firstChild);
}
previewTooltip.cleanup();
// Remove keyboard event listener
container.removeEventListener('keydown', handleKeyboardNavigation);
};

View File

@@ -438,6 +438,7 @@ export function mergeLoras(lorasText, lorasArr) {
active: lora.active !== undefined ? lora.active : true,
expanded: lora.expanded !== undefined ? lora.expanded : false,
clipStrength: lora.clipStrength !== undefined ? lora.clipStrength : parsedLoras[lora.name].clipStrength,
selected: !!lora.selected,
});
usedNames.add(lora.name);
}
@@ -451,6 +452,7 @@ export function mergeLoras(lorasText, lorasArr) {
strength: parsedLoras[name].strength,
active: true,
clipStrength: parsedLoras[name].clipStrength,
selected: false,
});
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -9,6 +9,7 @@ import {
} from "./utils.js";
import { addLorasWidget } from "./loras_widget.js";
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
import { updateConnectedLoraInfoNodes } from "./lora_info.js";
app.registerExtension({
name: "LoraManager.WanVideoLoraSelect",
@@ -63,7 +64,11 @@ app.registerExtension({
}
});
const result = addLorasWidget(this, "loras", {}, (value) => {
const result = addLorasWidget(this, "loras", {
onSelectionChange: (selection) => {
updateConnectedLoraInfoNodes(this, selection);
},
}, (value) => {
// Prevent recursive calls
if (isUpdating) return;
isUpdating = true;