Compare commits

..

12 Commits

Author SHA1 Message Date
Will Miao bf6a614e0d chore(release): bump version to v1.1.7 2026-07-13 22:18:16 +08:00
Will Miao feab01cd9c fix(preview): hide license icons for models without CivitAI metadata 2026-07-13 19:49:10 +08:00
Will Miao 966024e534 fix(registry): force re-registration on WS refresh to prevent timeout, demote empty-registry log to debug
- workflow_registry.js: add force param to refreshRegistry(), bypass fingerprint
  dedup when responding to lora_registry_refresh WS message. Without this, the
  backend's wait_for_all() times out after 0.5s because the frontend skips the
  register-nodes POST when the workflow fingerprint hasn't changed (common after
  ComfyUI restart with an empty or unchanged workflow).
- misc_handlers.py: demote 'No nodes registered after refresh' from WARNING to
  DEBUG — empty workflows are a normal operational state, not a warning-worthy
  condition.
2026-07-13 19:10:48 +08:00
Will Miao 2018722cc8 fix(registry): handle compound subgraph node IDs, add proactive node push from graph hooks
- Handle compound node IDs (e.g. "252:0") from expanded group subgraphs
  to fix 400 Bad Request on workflows with group nodes
- Frontend proactively pushes node data via afterConfigureGraph and
  LiteGraph hooks (onNodeAdded/onNodeRemoved/graphChanged), eliminating
  WebSocket round-trip latency for most "Send to Workflow" operations
- Add content-fingerprint dedup to skip duplicate register-nodes POSTs
- Fast-path cache returns immediately when tabs are registered (including
  0-node registrations), avoiding unnecessary WS refresh cycles
- Distinguish "Empty Registry" from other errors in standalone UI toast
- Reduce WS refresh timeout 2s→0.5s, add cooldown and lock to prevent
  concurrent refresh storms
- All [LM:Registry] logs at DEBUG level
2026-07-13 18:02:26 +08:00
Will Miao 9d85c2a44a fix(ui): prevent tags widget from auto-resizing in Vue mode when tags change 2026-07-13 14:55:40 +08:00
Will Miao 03dd047e62 fix(download): return 200 instead of 500 when user cancels download 2026-07-13 11:47:48 +08:00
Will Miao 86b547c1e0 fix(locales): add missing downloadStopped key to toast.downloads section 2026-07-13 11:35:48 +08:00
Will Miao bab9752c8b fix(download): close modal before progress overlay and fix downloadId ReferenceError on cancel 2026-07-13 11:29:47 +08:00
Will Miao 774cc1be86 fix(download): use file ID for exact match, add debug logging for multi-file selection (#1023)
- Frontend: send file.id in file_params, use null instead of hardcoded defaults
- Backend: priority matching (ID exact → primary → lenient metadata)
- Lenient metadata: only compare fields present on both sides (fixes GGUF size mismatch)
- Add debug logs at key points: entry, file_params received, match result, anomaly signals
2026-07-13 11:15:03 +08:00
Will Miao 234b73c8a2 feat(ui): add cancel button to download progress modal 2026-07-13 09:40:53 +08:00
Will Miao abd06c48f4 fix(settings): reject checkpoints↔unet path overlap in extra folder paths with inline error UI
Changes:
- Backend: _validate_folder_paths() now checks checkpoints↔unet overlap
  within the same library using os.path.realpath() for symlink resolution
- Backend: set() calls _validate_folder_paths() for both folder_paths and
  extra_folder_paths before writing
- Backend: extracted _normalize_path_set() helper to eliminate duplicated
  normalization logic
- Frontend: inline error display with red border + error message below the
  conflicting input, no save triggered
- Frontend: path normalization (strip trailing slash, lowercase) in pre-check
  to reduce false negatives vs backend realpath
- Frontend: asymmetric error UX — message only on the user-edited side,
  red border on the pre-existing conflict side
- CSS: has-error styles with hardcoded rgba fallback for older browsers
- i18n: checkpointUnetOverlap + checkpointUnetOverlapInline keys added to
  all 10 locale files
2026-07-13 08:22:40 +08:00
Will Miao 6ca411e4e4 fix(ui): make loras widget fixed-size with user-controlled node resize
Remove dynamic height calculation that auto-resized the node when
LoRAs are added or removed. The widget now stays at the size the user
sets via the node resize handle, scrolling when content overflows.

- Drop updateWidgetHeight() and hardcoded entry-count height math
- Set --comfy-widget-min-height once (200px) instead of recalculating
- In Vue mode: add contain:layout+size to break the ResizeObserver
  feedback loop that forced node growth with content (CSS via
  .lm-loras-container.lm-vue-node scoped to vueNodesMode only)
- Remove unused "Node 2.0: Maximum visible LoRA entries" setting
2026-07-12 22:35:58 +08:00
32 changed files with 1085 additions and 501 deletions
+301 -285
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.",
"saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
"validation": {
"duplicatePath": "Dieser Pfad ist bereits konfiguriert"
"duplicatePath": "Dieser Pfad ist bereits konfiguriert",
"checkpointUnetOverlap": "Derselbe Pfad kann nicht für Checkpoints und Diffusionsmodelle verwendet werden: {paths}",
"checkpointUnetOverlapInline": "Dieser Pfad wird bereits für einen anderen Modelltyp verwendet. Bitte verwenden Sie separate Ordner für Checkpoints und Diffusionsmodelle."
}
},
"priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "Download wird vorbereitet...",
"downloadedPreview": "Vorschaubild heruntergeladen",
"downloadingFile": "{type}-Datei wird heruntergeladen",
"finalizing": "Download wird abgeschlossen..."
"finalizing": "Download wird abgeschlossen...",
"cancelling": "Download wird abgebrochen...",
"cancelled": "Download abgebrochen"
},
"progress": {
"currentFile": "Aktuelle Datei:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "Beispielbilder {action} abgeschlossen",
"imagesFailed": "Beispielbilder {action} fehlgeschlagen",
"loadError": "Fehler beim Laden der Downloads: {message}",
"downloadError": "Download-Fehler: {message}"
"downloadError": "Download-Fehler: {message}",
"downloadStopped": "Download abgebrochen"
},
"import": {
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "Extra folder paths updated. Restart required to apply changes.",
"saveError": "Failed to update extra folder paths: {message}",
"validation": {
"duplicatePath": "This path is already configured"
"duplicatePath": "This path is already configured",
"checkpointUnetOverlap": "Cannot use the same path for both checkpoints and diffusion models: {paths}",
"checkpointUnetOverlapInline": "This path is also used for a different model type. Use separate folders for checkpoints and diffusion models."
}
},
"priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "Preparing download...",
"downloadedPreview": "Downloaded preview image",
"downloadingFile": "Downloading {type} file",
"finalizing": "Finalizing download..."
"finalizing": "Finalizing download...",
"cancelling": "Cancelling download...",
"cancelled": "Download cancelled"
},
"progress": {
"currentFile": "Current file:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "Example images {action} completed",
"imagesFailed": "Example images {action} failed",
"loadError": "Error loading downloads: {message}",
"downloadError": "Download error: {message}"
"downloadError": "Download error: {message}",
"downloadStopped": "Download cancelled"
},
"import": {
"folderTreeFailed": "Failed to load folder tree",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "Rutas de carpetas adicionales actualizadas. Se requiere reinicio para aplicar los cambios.",
"saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
"validation": {
"duplicatePath": "Esta ruta ya está configurada"
"duplicatePath": "Esta ruta ya está configurada",
"checkpointUnetOverlap": "No se puede usar la misma ruta para checkpoints y modelos de difusión: {paths}",
"checkpointUnetOverlapInline": "Esta ruta ya se usa para otro tipo de modelo. Use carpetas separadas para checkpoints y modelos de difusión."
}
},
"priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "Preparando descarga...",
"downloadedPreview": "Imagen de vista previa descargada",
"downloadingFile": "Descargando archivo de {type}",
"finalizing": "Finalizando descarga..."
"finalizing": "Finalizando descarga...",
"cancelling": "Cancelando descarga...",
"cancelled": "Descarga cancelada"
},
"progress": {
"currentFile": "Archivo actual:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "Imágenes de ejemplo {action} completadas",
"imagesFailed": "Imágenes de ejemplo {action} fallidas",
"loadError": "Error al cargar descargas: {message}",
"downloadError": "Error de descarga: {message}"
"downloadError": "Error de descarga: {message}",
"downloadStopped": "Descarga cancelada"
},
"import": {
"folderTreeFailed": "Error al cargar árbol de carpetas",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "Chemins de dossiers supplémentaires mis à jour. Redémarrage requis pour appliquer les changements.",
"saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}",
"validation": {
"duplicatePath": "Ce chemin est déjà configuré"
"duplicatePath": "Ce chemin est déjà configuré",
"checkpointUnetOverlap": "Impossible d'utiliser le même chemin pour les checkpoints et les modèles de diffusion : {paths}",
"checkpointUnetOverlapInline": "Ce chemin est déjà utilisé pour un autre type de modèle. Utilisez des dossiers séparés pour les checkpoints et les modèles de diffusion."
}
},
"priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "Préparation du téléchargement...",
"downloadedPreview": "Image d'aperçu téléchargée",
"downloadingFile": "Téléchargement du fichier {type}",
"finalizing": "Finalisation du téléchargement..."
"finalizing": "Finalisation du téléchargement...",
"cancelling": "Annulation du téléchargement...",
"cancelled": "Téléchargement annulé"
},
"progress": {
"currentFile": "Fichier actuel :",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "Images d'exemple {action} terminées",
"imagesFailed": "Images d'exemple {action} échouées",
"loadError": "Erreur lors du chargement des téléchargements : {message}",
"downloadError": "Erreur de téléchargement : {message}"
"downloadError": "Erreur de téléchargement : {message}",
"downloadStopped": "Téléchargement annulé"
},
"import": {
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "נתיבי תיקיות נוספים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
"validation": {
"duplicatePath": "נתיב זה כבר מוגדר"
"duplicatePath": "נתיב זה כבר מוגדר",
"checkpointUnetOverlap": "לא ניתן להשתמש באותו נתיב עבור checkpoints ומודלי דיפוזיה: {paths}",
"checkpointUnetOverlapInline": "הנתיב הזה כבר נמצא בשימוש עבור סוג מודל אחר. יש להשתמש בתיקיות נפרדות עבור checkpoints ומודלי דיפוזיה."
}
},
"priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "מכין הורדה...",
"downloadedPreview": "תמונת תצוגה מקדימה הורדה",
"downloadingFile": "מוריד קובץ {type}",
"finalizing": "מסיים הורדה..."
"finalizing": "מסיים הורדה...",
"cancelling": "מבטל הורדה...",
"cancelled": "ההורדה בוטלה"
},
"progress": {
"currentFile": "הקובץ הנוכחי:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "{action} תמונות הדוגמה הושלם",
"imagesFailed": "{action} תמונות הדוגמה נכשל",
"loadError": "שגיאה בטעינת הורדות: {message}",
"downloadError": "שגיאת הורדה: {message}"
"downloadError": "שגיאת הורדה: {message}",
"downloadStopped": "ההורדה בוטלה"
},
"import": {
"folderTreeFailed": "טעינת עץ התיקיות נכשלה",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "追加フォルダーパスを更新しました。変更を適用するには再起動が必要です。",
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
"validation": {
"duplicatePath": "このパスはすでに設定されています"
"duplicatePath": "このパスはすでに設定されています",
"checkpointUnetOverlap": "checkpoints と diffusion models に同じパスは使用できません:{paths}",
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。checkpoints と diffusion models には別々のフォルダを使用してください。"
}
},
"priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "ダウンロードを準備中...",
"downloadedPreview": "プレビュー画像をダウンロードしました",
"downloadingFile": "{type}ファイルをダウンロード中",
"finalizing": "ダウンロードを完了中..."
"finalizing": "ダウンロードを完了中...",
"cancelling": "ダウンロードをキャンセル中...",
"cancelled": "ダウンロードをキャンセルしました"
},
"progress": {
"currentFile": "現在のファイル:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "例画像 {action} が完了しました",
"imagesFailed": "例画像 {action} が失敗しました",
"loadError": "ダウンロード読み込みエラー:{message}",
"downloadError": "ダウンロードエラー:{message}"
"downloadError": "ダウンロードエラー:{message}",
"downloadStopped": "ダウンロードをキャンセルしました"
},
"import": {
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
"saveError": "추가 폴다 경로 업데이트 실패: {message}",
"validation": {
"duplicatePath": "이 경로는 이미 구성되어 있습니다"
"duplicatePath": "이 경로는 이미 구성되어 있습니다",
"checkpointUnetOverlap": "checkpoints와 diffusion models에 동일한 경로를 사용할 수 없습니다: {paths}",
"checkpointUnetOverlapInline": "이 경로는 다른 모델 유형에 이미 사용 중입니다. checkpoints와 diffusion models에 별도의 폴더를 사용하세요."
}
},
"priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "다운로드 준비 중...",
"downloadedPreview": "미리보기 이미지 다운로드됨",
"downloadingFile": "{type} 파일 다운로드 중",
"finalizing": "다운로드 완료 중..."
"finalizing": "다운로드 완료 중...",
"cancelling": "다운로드 취소 중...",
"cancelled": "다운로드가 취소되었습니다"
},
"progress": {
"currentFile": "현재 파일:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "예시 이미지 {action}이(가) 완료되었습니다",
"imagesFailed": "예시 이미지 {action}이(가) 실패했습니다",
"loadError": "다운로드 로딩 오류: {message}",
"downloadError": "다운로드 오류: {message}"
"downloadError": "다운로드 오류: {message}",
"downloadStopped": "다운로드가 취소되었습니다"
},
"import": {
"folderTreeFailed": "폴더 트리 로딩 실패",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "Дополнительные пути к папкам обновлены. Требуется перезапуск для применения изменений.",
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
"validation": {
"duplicatePath": "Этот путь уже настроен"
"duplicatePath": "Этот путь уже настроен",
"checkpointUnetOverlap": "Нельзя использовать один и тот же путь для checkpoints и diffusion models: {paths}",
"checkpointUnetOverlapInline": "Этот путь уже используется для другого типа модели. Используйте отдельные папки для checkpoints и diffusion models."
}
},
"priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "Подготовка загрузки...",
"downloadedPreview": "Превью изображение загружено",
"downloadingFile": "Загрузка файла {type}",
"finalizing": "Завершение загрузки..."
"finalizing": "Завершение загрузки...",
"cancelling": "Отмена загрузки...",
"cancelled": "Загрузка отменена"
},
"progress": {
"currentFile": "Текущий файл:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "Примеры изображений {action} завершены",
"imagesFailed": "Примеры изображений {action} не удались",
"loadError": "Ошибка загрузки downloads: {message}",
"downloadError": "Ошибка загрузки: {message}"
"downloadError": "Ошибка загрузки: {message}",
"downloadStopped": "Загрузка отменена"
},
"import": {
"folderTreeFailed": "Не удалось загрузить дерево папок",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "额外文件夹路径已更新,需要重启才能生效。",
"saveError": "更新额外文件夹路径失败:{message}",
"validation": {
"duplicatePath": "此路径已配置"
"duplicatePath": "此路径已配置",
"checkpointUnetOverlap": "checkpoints 和 diffusion models 不能使用相同的路径:{paths}",
"checkpointUnetOverlapInline": "此路径已被用于另一种模型类型。请为 checkpoints 和 diffusion models 使用不同的文件夹。"
}
},
"priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "正在准备下载...",
"downloadedPreview": "预览图片已下载",
"downloadingFile": "正在下载 {type} 文件",
"finalizing": "正在完成下载..."
"finalizing": "正在完成下载...",
"cancelling": "取消下载中...",
"cancelled": "下载已取消"
},
"progress": {
"currentFile": "当前文件:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "示例图片{action}完成",
"imagesFailed": "示例图片{action}失败",
"loadError": "加载下载项出错:{message}",
"downloadError": "下载错误:{message}"
"downloadError": "下载错误:{message}",
"downloadStopped": "下载已取消"
},
"import": {
"folderTreeFailed": "加载文件夹树失败",
+8 -3
View File
@@ -505,7 +505,9 @@
"saveSuccess": "額外資料夾路徑已更新,需要重啟才能生效。",
"saveError": "更新額外資料夾路徑失敗:{message}",
"validation": {
"duplicatePath": "此路徑已設定"
"duplicatePath": "此路徑已設定",
"checkpointUnetOverlap": "checkpoints 和 diffusion models 不能使用相同的路徑:{paths}",
"checkpointUnetOverlapInline": "此路徑已被用於另一種模型類型。請為 checkpoints 和 diffusion models 使用不同的資料夾。"
}
},
"priorityTags": {
@@ -1205,7 +1207,9 @@
"preparing": "準備下載中...",
"downloadedPreview": "已下載預覽圖片",
"downloadingFile": "正在下載 {type} 檔案",
"finalizing": "完成下載中..."
"finalizing": "完成下載中...",
"cancelling": "取消下載中...",
"cancelled": "下載已取消"
},
"progress": {
"currentFile": "目前檔案:",
@@ -2013,7 +2017,8 @@
"imagesCompleted": "範例圖片{action}完成",
"imagesFailed": "範例圖片{action}失敗",
"loadError": "載入下載時發生錯誤:{message}",
"downloadError": "下載錯誤:{message}"
"downloadError": "下載錯誤:{message}",
"downloadStopped": "下載已取消"
},
"import": {
"folderTreeFailed": "載入資料夾樹狀結構失敗",
+111 -32
View File
@@ -573,12 +573,18 @@ class NodeRegistry:
tab_nodes[nd["unique_id"]] = nd
async with self._lock:
prev_count = len(self._tab_nodes.get(sid, {}))
self._tab_nodes[sid] = tab_nodes
self._waiting_clients.discard(sid)
if not self._waiting_clients:
self._ready.set()
total_tabs = len(self._tab_nodes)
logger.debug("Registered %s nodes from client %s", len(nodes), sid)
if len(nodes) != prev_count or len(nodes) > 0:
logger.debug(
"[LM:Registry] stored %s nodes (was %s) for client %s (total tabs: %s)",
len(nodes), prev_count, sid, total_tabs,
)
def prepare_for_refresh(self, active_sids: list[str]) -> None:
"""Set the list of client IDs we expect to hear from during the next refresh cycle."""
@@ -601,10 +607,17 @@ class NodeRegistry:
longer connected."""
async with self._lock:
# Garbage-collect stale entries (disconnected tabs)
stale_sids = []
if active_sids is not None:
for sid in list(self._tab_nodes):
if sid not in active_sids:
stale_sids.append(sid)
del self._tab_nodes[sid]
if stale_sids:
logger.debug(
"[LM:Registry] GC pruned %s disconnected tabs: %s",
len(stale_sids), stale_sids,
)
merged: dict[str, dict] = {}
tab_info: dict[str, dict] = {}
@@ -3116,6 +3129,8 @@ class NodeRegistryHandler:
self._node_registry = node_registry
self._prompt_server = prompt_server
self._standalone_mode = standalone_mode
self._refresh_lock = asyncio.Lock()
self._last_slow_path_ts: float = 0.0
async def register_nodes(self, request: web.Request) -> web.Response:
try:
@@ -3162,7 +3177,12 @@ class NodeRegistryHandler:
)
graph_name = node.get("graph_name")
try:
node["node_id"] = int(node_id)
# Handle compound node IDs from expanded group subgraphs,
# e.g. "252:0" → 0 (parent scope is already in graph_id)
if isinstance(node_id, str) and ":" in node_id:
node["node_id"] = int(node_id.rsplit(":", 1)[-1])
else:
node["node_id"] = int(node_id)
except (TypeError, ValueError):
return web.json_response(
{
@@ -3203,42 +3223,101 @@ class NodeRegistryHandler:
status=503,
)
# Snapshot of currently-connected ComfyUI tabs
active_sids = list(self._prompt_server.instance.sockets.keys())
self._node_registry.prepare_for_refresh(active_sids)
try:
self._prompt_server.instance.send_sync("lora_registry_refresh", {})
logger.debug(
"Sent registry refresh request (expecting %s clients)", len(active_sids)
)
except Exception as exc:
logger.error("Failed to send registry refresh message: %s", exc)
return web.json_response(
{
"success": False,
"error": "Communication Error",
"message": f"Failed to communicate with ComfyUI frontend: {exc}",
},
status=500,
)
if not await self._node_registry.wait_for_all(timeout=2.0):
logger.warning(
"Registry refresh timeout after 2s (%s/%s clients responded)",
len(active_sids) - self._node_registry.pending_client_count,
len(active_sids),
)
# Re-read current sockets after the wait: a tab may have connected
# while we were waiting, and we don't want to garbage-collect it.
current_sids = set(self._prompt_server.instance.sockets.keys())
# Fast path: if the frontend has already pushed node data (via
# afterConfigureGraph / graphChanged hooks), return it immediately
# without triggering a WebSocket round-trip.
registry_info = await self._node_registry.get_merged_registry(
active_sids=current_sids
)
if registry_info["tab_count"] > 0:
logger.debug(
"[LM:Registry] fast path: %s nodes across %s tabs %s",
registry_info["node_count"],
registry_info["tab_count"],
dict(registry_info.get("tabs", {})),
)
return web.json_response({"success": True, "data": registry_info})
# Slow path: registry is empty — trigger refresh via WebSocket.
# Serialize with an async lock so concurrent callers don't all
# trigger separate WS refresh cycles. The second caller will
# re-check the fast path and (usually) find populated data.
async with self._refresh_lock:
# Re-check after acquiring the lock — another concurrent call
# may have populated the cache while we were waiting.
registry_info = await self._node_registry.get_merged_registry(
active_sids=current_sids
)
if registry_info["tab_count"] > 0:
logger.debug(
"[LM:Registry] fast path after lock wait: %s nodes across %s tabs",
registry_info["node_count"],
registry_info["tab_count"],
)
return web.json_response({"success": True, "data": registry_info})
# Cooldown: if the slow path ran recently (< 2 s) and
# returned empty, skip another WS round-trip.
elapsed = time.monotonic() - self._last_slow_path_ts
if elapsed < 2.0:
logger.debug(
"[LM:Registry] slow path cooldown (%.1fs since last refresh), returning empty",
elapsed,
)
return web.json_response(
{
"success": False,
"error": "Empty Registry",
"message": "No workflow nodes found — ensure ComfyUI is open and the extension is loaded.",
},
status=408,
)
logger.debug(
"[LM:Registry] slow path: cache empty, triggering WS refresh (%s connected tabs: %s)",
len(current_sids), list(current_sids)[:5],
)
active_sids = list(current_sids)
self._node_registry.prepare_for_refresh(active_sids)
try:
self._prompt_server.instance.send_sync("lora_registry_refresh", {})
logger.debug(
"Sent registry refresh request (expecting %s clients)", len(active_sids)
)
except Exception as exc:
logger.error("Failed to send registry refresh message: %s", exc)
return web.json_response(
{
"success": False,
"error": "Communication Error",
"message": f"Failed to communicate with ComfyUI frontend: {exc}",
},
status=500,
)
if not await self._node_registry.wait_for_all(timeout=0.5):
logger.warning(
"Registry refresh timeout after 0.5s (%s/%s clients responded)",
len(active_sids) - self._node_registry.pending_client_count,
len(active_sids),
)
# Re-read current sockets after the wait: a tab may have connected
# while we were waiting, and we don't want to garbage-collect it.
current_sids = set(self._prompt_server.instance.sockets.keys())
registry_info = await self._node_registry.get_merged_registry(
active_sids=current_sids
)
self._last_slow_path_ts = time.monotonic()
if registry_info["node_count"] == 0:
logger.warning("No nodes registered after refresh")
logger.debug(
"[LM:Registry] refresh OK — %s connected tab(s) but 0 compatible nodes found",
registry_info["tab_count"],
)
return web.json_response(
{
"success": False,
+14 -3
View File
@@ -1313,9 +1313,20 @@ class ModelQueryHandler:
}
if include_license_flags:
model_data = await self._service.get_model_info_by_name(model_name)
license_flags = (model_data or {}).get("license_flags")
if license_flags is not None:
response_payload["license_flags"] = int(license_flags)
# Only return license_flags when real CivitAI model license
# data exists. This mirrors ModelModal's guard
# (modelData?.civitai?.model) so the preview tooltip never
# shows misleading license icons for HF or other models
# without actual license metadata.
civitai_data = (model_data or {}).get("civitai") or {}
has_license_data = (
isinstance(civitai_data, dict)
and isinstance(civitai_data.get("model"), dict)
)
if has_license_data:
license_flags = (model_data or {}).get("license_flags")
if license_flags is not None:
response_payload["license_flags"] = int(license_flags)
# Include the user's license icon style preference so the
# ComfyUI tooltip can pick the right set without a separate
# API call.
+62 -14
View File
@@ -230,6 +230,12 @@ class DownloadManager:
Returns:
Dict with download result
"""
logger.debug(
"[download] download_from_civitai called: model_id=%s, model_version_id=%s, "
"source=%s, file_params=%s",
model_id, model_version_id, source, file_params,
)
# Validate that at least one identifier is provided
if not model_id and not model_version_id:
return {
@@ -250,6 +256,7 @@ class DownloadManager:
"source": source,
"file_params": copy.deepcopy(file_params) if file_params is not None else None,
"progress": 0,
"status": "queued",
"transfer_backend": self._get_model_download_backend(),
"bytes_downloaded": 0,
@@ -289,8 +296,8 @@ class DownloadManager:
return result
except asyncio.CancelledError:
return {
"success": False,
"error": "Download was cancelled",
"success": True,
"cancelled": True,
"download_id": task_id,
}
finally:
@@ -1421,14 +1428,35 @@ class DownloadManager:
# If file_params is provided, try to find matching file
if file_params and model_version_id:
target_file_id = file_params.get("id")
target_type = file_params.get("type", "Model")
target_format = file_params.get("format", "SafeTensor")
target_size = file_params.get("size", "full")
target_format = file_params.get("format")
target_size = file_params.get("size")
target_fp = file_params.get("fp")
is_primary = file_params.get("isPrimary", False)
if is_primary:
# Find primary file
logger.debug(
"[download] file_params received: id=%s, type=%s, format=%s, size=%s, fp=%s, isPrimary=%s, "
"model_version_id=%s, total_files=%d",
target_file_id, target_type, target_format, target_size, target_fp, is_primary,
model_version_id, len(files),
)
if target_file_id:
target_id_str = str(target_file_id)
for f in files:
f_id = f.get("id")
if str(f_id) == target_id_str:
file_info = f
logger.debug(
"[download] MATCH by ID: id=%s name='%s'",
f_id, f.get("name"),
)
break
if not file_info:
logger.debug("[download] No file found with id=%s", target_file_id)
elif is_primary:
file_info = next(
(
f
@@ -1439,28 +1467,41 @@ class DownloadManager:
None,
)
else:
# Match by metadata
# Lenient metadata match: only compare fields present on both sides
for f in files:
f_type = f.get("type", "")
f_meta = f.get("metadata", {})
# Check type match
if f_type != target_type:
continue
# Check metadata match
if f_meta.get("format") != target_format:
f_meta = f.get("metadata", {})
f_format = f_meta.get("format") or f.get("format")
f_size = f_meta.get("size") or f.get("size")
f_fp = f_meta.get("fp") or f.get("fp")
if target_format and f_format != target_format:
continue
if f_meta.get("size") != target_size:
if target_size and f_size and f_size != target_size:
continue
if target_fp and f_meta.get("fp") != target_fp:
if target_fp and f_fp and f_fp != target_fp:
continue
file_info = f
break
if not file_info:
logger.debug(
"[download] No match found via file_params — falling back to primary file lookup",
)
elif not file_params:
logger.debug(
"[download] No file_params provided (null/None) — will use primary file lookup. "
"model_version_id=%s, total_files=%d",
model_version_id, len(files),
)
# Fallback to primary file if no match found
if not file_info:
logger.debug("[download] Looking for primary file as fallback")
file_info = next(
(
f
@@ -1469,6 +1510,13 @@ class DownloadManager:
),
None,
)
if file_info:
logger.debug(
"[download] Fallback primary file selected: id=%s, name=%s",
file_info.get("id"), file_info.get("name"),
)
else:
logger.debug("[download] No primary file found in fallback lookup")
if not file_info:
return {"success": False, "error": "No suitable file found in metadata"}
+46 -1
View File
@@ -630,12 +630,37 @@ class SettingsManager:
return False
@staticmethod
def _normalize_path_set(paths: Iterable[str]) -> set[str]:
"""Normalize an iterable of paths for set-based overlap comparison.
Resolves symlinks via ``os.path.realpath`` when the path exists on disk,
then applies ``os.path.normcase`` + ``os.path.normpath`` for consistent
cross-platform comparison. Non-string / empty entries are skipped.
"""
result: set[str] = set()
for p in paths:
if not isinstance(p, str):
continue
stripped = p.strip()
if not stripped:
continue
if os.path.exists(stripped):
stripped = os.path.normpath(os.path.realpath(stripped))
result.add(os.path.normcase(stripped))
return result
def _validate_folder_paths(
self,
library_name: str,
folder_paths: Mapping[str, Iterable[str]],
) -> None:
"""Ensure folder paths do not overlap with other libraries."""
"""Ensure folder paths do not overlap with other libraries.
Also detects checkpoints unet path overlap within the same library
(including via symlink resolution), which is a configuration error since
these model types must use separate physical folders.
"""
libraries = self.settings.get("libraries", {})
normalized_new: Dict[str, Dict[str, str]] = {}
for key, values in folder_paths.items():
@@ -673,6 +698,22 @@ class SettingsManager:
f"Folder path(s) {collisions} already assigned to library '{other_name}'"
)
# Checkpoints ↔ unet overlap within the same library
ckpt_paths = folder_paths.get("checkpoints", []) or []
unet_paths = folder_paths.get("unet", []) or []
if ckpt_paths and unet_paths:
ckpt_real = self._normalize_path_set(ckpt_paths)
unet_real = self._normalize_path_set(unet_paths)
overlap = ckpt_real & unet_real
if overlap:
collisions = ", ".join(sorted(overlap))
raise ValueError(
f"Path(s) {collisions} are configured for both "
f"'checkpoints' and 'unet' (diffusion models). "
f"These model types must use separate physical folders. "
f"Please remove one of the conflicting entries."
)
def _update_active_library_entry(
self,
*,
@@ -1547,8 +1588,12 @@ class SettingsManager:
portable_switch_pending = True
self._prepare_portable_switch(value)
if key == "folder_paths" and isinstance(value, Mapping):
active_name = self.get_active_library_name()
self._validate_folder_paths(active_name, value)
self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type]
elif key == "extra_folder_paths" and isinstance(value, Mapping):
active_name = self.get_active_library_name()
self._validate_folder_paths(active_name, value)
self._update_active_library_entry(extra_folder_paths=value) # type: ignore[arg-type]
elif key == "default_lora_root":
self._update_active_library_entry(default_lora_root=str(value))
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.1.6"
version = "1.1.7"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",
@@ -1562,6 +1562,29 @@ input:checked + .toggle-slider:before {
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
}
.extra-folder-path-row .path-controls .extra-folder-path-input.has-error {
border-color: var(--lora-error);
background-color: rgba(220, 53, 69, 0.08);
background-color: rgba(from var(--lora-error) r g b / 0.08);
}
.extra-folder-path-row .path-controls .extra-folder-path-input.has-error:focus {
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.15);
box-shadow: 0 0 0 2px rgba(from var(--lora-error) r g b / 0.15);
}
.extra-folder-path-error {
color: var(--lora-error);
font-size: 0.8em;
margin-top: 4px;
line-height: 1.4;
display: none;
}
.extra-folder-path-error.visible {
display: block;
}
.extra-folder-path-row .path-controls .remove-path-btn {
width: 32px;
height: 32px;
+12
View File
@@ -112,6 +112,18 @@ export class BaseModelApiClient {
}
}
async cancelDownload(downloadId) {
try {
const response = await fetch(
`${DOWNLOAD_ENDPOINTS.cancelGet}?download_id=${encodeURIComponent(downloadId)}`
);
return await response.json();
} catch (error) {
console.error('Error cancelling download:', error);
return { success: false, error: error.message };
}
}
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
const pageState = this.getPageState();
@@ -196,6 +196,17 @@ export class BulkMissingLoraDownloadManager {
let completedDownloads = 0;
let failedDownloads = 0;
let currentLoraProgress = 0;
let cancelled = false;
loadingManager.showCancelButton(async () => {
if (cancelled) return;
cancelled = true;
try {
await this.loraApiClient.cancelDownload(batchDownloadId);
} catch (e) {
console.error('Cancel request failed:', e);
}
});
// Set up WebSocket message handler
ws.onmessage = (event) => {
@@ -207,6 +218,11 @@ export class BulkMissingLoraDownloadManager {
return;
}
if (data.status === 'cancelled') {
cancelled = true;
return;
}
// Process progress updates
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
currentLoraProgress = data.progress;
@@ -249,6 +265,8 @@ export class BulkMissingLoraDownloadManager {
// Download each LoRA sequentially
for (let i = 0; i < lorasToDownload.length; i++) {
if (cancelled) break;
const lora = lorasToDownload[i];
currentLoraProgress = 0;
@@ -275,11 +293,13 @@ export class BulkMissingLoraDownloadManager {
modelId,
versionId,
loraRoot,
'', // Empty relative path, use default paths
'',
useDefaultPaths,
batchDownloadId
);
if (cancelled) break;
if (!response.success) {
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
failedDownloads++;
@@ -288,8 +308,10 @@ export class BulkMissingLoraDownloadManager {
updateProgress(100, completedDownloads, '');
}
} catch (error) {
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
failedDownloads++;
if (!cancelled) {
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
failedDownloads++;
}
}
}
@@ -300,7 +322,10 @@ export class BulkMissingLoraDownloadManager {
loadingManager.hide();
// Show completion message
if (failedDownloads === 0) {
if (cancelled) {
showToast('toast.downloads.downloadStopped', {}, 'info',
`Download cancelled. ${completedDownloads} item(s) completed.`);
} else if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
showToast('toast.loras.downloadPartialSuccess', {
+123 -23
View File
@@ -728,14 +728,23 @@ export class DownloadManager {
confirmFileSelection() {
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked');
if (!selectedRadio) return;
if (!selectedRadio) {
console.warn('[download] confirmFileSelection: no radio button checked');
return;
}
const version = this.currentVersion;
if (!version) return;
if (!version) {
console.warn('[download] confirmFileSelection: no currentVersion set');
return;
}
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
console.log('[download] confirmFileSelection: selected file id=%s, name="%s", type="%s", metadata=%o',
this.selectedFile?.id, this.selectedFile?.name, this.selectedFile?.type, this.selectedFile?.metadata);
document.getElementById('fileSelectionStep').style.display = 'none';
document.getElementById('locationStep').style.display = 'block';
this.proceedToLocationContent();
@@ -872,16 +881,26 @@ export class DownloadManager {
const displayName = versionName || `#${versionId}`;
let ws = null;
let updateProgress = () => { };
let cancelled = false;
const downloadId = Date.now().toString();
try {
this.loadingManager.restoreProgressBar();
updateProgress = this.loadingManager.showDownloadProgress(1);
updateProgress(0, 0, displayName);
const downloadId = Date.now().toString();
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
this.loadingManager.showCancelButton(async () => {
if (cancelled) return;
cancelled = true;
try {
await this.apiClient.cancelDownload(downloadId);
} catch (e) {
console.error('Cancel request failed:', e);
}
});
ws.onmessage = event => {
const data = JSON.parse(event.data);
@@ -890,6 +909,12 @@ export class DownloadManager {
return;
}
if (data.status === 'cancelled') {
cancelled = true;
this.loadingManager.setStatus(translate('modals.download.status.cancelled', {}, 'Download cancelled'));
return;
}
if (data.status === 'progress' && data.download_id === downloadId) {
const metrics = {
bytesDownloaded: data.bytes_downloaded,
@@ -928,6 +953,10 @@ export class DownloadManager {
fileParams
);
if (cancelled) {
return false;
}
if (response?.skipped) {
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
updateProgress(100, 0, displayName);
@@ -968,8 +997,12 @@ export class DownloadManager {
return true;
} catch (error) {
console.error('Failed to download model version:', error);
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
if (cancelled) {
console.log('Download cancelled by user:', downloadId);
} else {
console.error('Failed to download model version:', error);
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
}
return false;
} finally {
try {
@@ -989,16 +1022,33 @@ export class DownloadManager {
const totalFiles = this.hfSelectedFiles.length;
const updateProgress = this.loadingManager.showDownloadProgress(totalFiles);
let cancelled = false;
let currentDownloadId = null;
this.loadingManager.showCancelButton(async () => {
if (cancelled) return;
cancelled = true;
if (currentDownloadId) {
try {
await this.apiClient.cancelDownload(currentDownloadId);
} catch (e) {
console.error('Cancel request failed:', e);
}
}
});
try {
let completedDownloads = 0;
for (let i = 0; i < totalFiles; i++) {
if (cancelled) break;
const filename = this.hfSelectedFiles[i];
updateProgress(0, completedDownloads, filename);
this.loadingManager.setStatus(`Downloading ${filename}...`);
const downloadId = Date.now().toString() + '_' + i;
currentDownloadId = Date.now().toString() + '_' + i;
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${currentDownloadId}`);
try {
await new Promise((resolve, reject) => {
@@ -1006,12 +1056,13 @@ export class DownloadManager {
ws.onerror = reject;
});
// Capture completed count at WS creation time so progress
// updates arriving after completedDownloads increments still
// show the correct "N / total" position.
const snapshotCompleted = completedDownloads;
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.status === 'cancelled') {
cancelled = true;
return;
}
if (data.status === 'progress') {
const metrics = {
bytesDownloaded: data.bytes_downloaded,
@@ -1029,9 +1080,11 @@ export class DownloadManager {
modelRoot,
relativePath: targetFolder,
useDefaultPaths,
download_id: downloadId,
download_id: currentDownloadId,
});
if (cancelled) break;
if (response?.success) {
completedDownloads++;
updateProgress(100, completedDownloads, filename);
@@ -1041,13 +1094,19 @@ export class DownloadManager {
}
}
showToast('toast.loras.downloadCompleted', {}, 'success');
// Reload page data — model is already in scanner cache via backend
if (cancelled) {
showToast('toast.downloads.downloadStopped', {}, 'info',
`Download cancelled. ${completedDownloads} item(s) completed.`);
} else {
showToast('toast.loras.downloadCompleted', {}, 'success');
}
await resetAndReload(true);
return true;
} catch (error) {
console.error('Failed to download HF model:', error);
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
if (!cancelled) {
console.error('Failed to download HF model:', error);
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
}
return false;
} finally {
this.loadingManager.hide();
@@ -1426,12 +1485,23 @@ export class DownloadManager {
}
const fileParams = this.selectedFile ? {
id: this.selectedFile.id,
type: this.selectedFile.type || 'Model',
format: this.selectedFile.metadata?.format || 'SafeTensor',
size: this.selectedFile.metadata?.size || 'full',
fp: this.selectedFile.metadata?.fp,
format: this.selectedFile.metadata?.format || null,
size: this.selectedFile.metadata?.size || null,
fp: this.selectedFile.metadata?.fp || null,
} : null;
if (fileParams) {
console.log('[download] startDownload (single): fileParams built from selectedFile — id=%s, type=%s, format=%s, size=%s, fp=%s',
fileParams.id, fileParams.type, fileParams.format, fileParams.size, fileParams.fp);
} else {
console.log('[download] startDownload (single): this.selectedFile is null — no file selection, will download primary/default file. version=%s has %d files',
this.currentVersion?.id, (this.currentVersion?.files || []).length);
}
modalManager.closeModal('downloadModal');
return this.executeDownloadWithProgress({
modelId: this.modelId,
versionId: this.currentVersion.id,
@@ -1470,11 +1540,27 @@ export class DownloadManager {
let completedDownloads = 0;
let failedDownloads = 0;
let cancelled = false;
loadingManager.showCancelButton(async () => {
if (cancelled) return;
cancelled = true;
try {
await this.apiClient.cancelDownload(batchDownloadId);
} catch (e) {
console.error('Cancel request failed:', e);
}
});
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'download_id') return;
if (data.status === 'cancelled') {
cancelled = true;
return;
}
if (data.status === 'progress' && data.download_id?.startsWith(batchDownloadId)) {
const current = downloadItems[completedDownloads + failedDownloads];
const name = current?.selectedVersion?.name || current?.displayName || current?.filename || `#${completedDownloads + failedDownloads + 1}`;
@@ -1493,6 +1579,8 @@ export class DownloadManager {
});
for (let i = 0; i < downloadItems.length; i++) {
if (cancelled) break;
const item = downloadItems[i];
const name = item.displayName || item.filename || (item.selectedVersion?.name || `Model #${item.modelId}`);
const isHf = item.source === 'huggingface';
@@ -1503,7 +1591,6 @@ export class DownloadManager {
try {
let response;
if (isHf) {
// Per-file WebSocket for real-time progress
const downloadId = Date.now().toString() + '_hf_' + i;
const wsHf = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
try {
@@ -1537,6 +1624,8 @@ export class DownloadManager {
wsHf.close();
}
} else {
console.log('[download] batch download: fileParams NOT passed for modelId=%s, versionId=%s — backend will use primary file',
item.modelId, item.selectedVersion?.id);
response = await this.apiClient.downloadModel(
item.modelId,
item.selectedVersion.id,
@@ -1548,6 +1637,8 @@ export class DownloadManager {
);
}
if (cancelled) break;
if (!response.success) {
failedDownloads++;
} else {
@@ -1555,15 +1646,20 @@ export class DownloadManager {
updateProgress(100, completedDownloads, '');
}
} catch (err) {
console.error(`Failed to download ${name}:`, err);
failedDownloads++;
if (!cancelled) {
console.error(`Failed to download ${name}:`, err);
failedDownloads++;
}
}
}
ws.close();
loadingManager.hide();
if (failedDownloads === 0) {
if (cancelled) {
showToast('toast.downloads.downloadStopped', {}, 'info',
`Download cancelled. ${completedDownloads} item(s) completed.`);
} else if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
showToast('toast.loras.downloadPartialSuccess', {
@@ -1581,6 +1677,10 @@ export class DownloadManager {
modelRoot = '',
targetFolder = ''
} = {}) {
console.warn('[download] downloadVersionWithDefaults: NO fileParams will be sent — backend will always use primary file. '
+ 'modelType=%s, modelId=%s, versionId=%s, versionName="%s"',
modelType, modelId, versionId, versionName);
try {
this.apiClient = getModelApiClient(modelType);
} catch (error) {
+4
View File
@@ -281,6 +281,10 @@ export class LoadingManager {
// Initialize transfer stats with empty data
updateTransferStats();
if (this.cancelButton) {
this.loadingContent.appendChild(this.cancelButton);
}
// Return update function
return (currentProgress, currentIndex = 0, currentName = '', metrics = {}) => {
// Update current item progress
+85 -1
View File
@@ -1693,13 +1693,15 @@ export class SettingsManager {
<input type="text" class="extra-folder-path-input"
placeholder="${translate('settings.extraFolderPaths.pathPlaceholder', {}, '/path/to/models')}" value="${path}"
onblur="settingsManager.updateExtraFolderPaths('${modelType}')"
onfocus="settingsManager.clearExtraFolderPathError(this)"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
<button type="button" class="remove-path-btn"
onclick="this.parentElement.parentElement.remove(); settingsManager.updateExtraFolderPaths('${modelType}')"
onclick="settingsManager.removeExtraFolderPathRow(this, '${modelType}')"
title="${translate('common.actions.delete', {}, 'Delete')}">
<i class="fas fa-times"></i>
</button>
</div>
<div class="extra-folder-path-error"></div>
`;
container.appendChild(row);
@@ -1713,7 +1715,63 @@ export class SettingsManager {
}
}
clearExtraFolderPathError(input) {
input.classList.remove('has-error');
const row = input.closest('.extra-folder-path-row');
if (row) {
const errEl = row.querySelector('.extra-folder-path-error');
if (errEl) {
errEl.classList.remove('visible');
errEl.textContent = '';
}
}
}
_clearAllExtraFolderPathErrors() {
document.querySelectorAll('.extra-folder-path-input.has-error').forEach((input) => {
input.classList.remove('has-error');
});
document.querySelectorAll('.extra-folder-path-error.visible').forEach((el) => {
el.classList.remove('visible');
el.textContent = '';
});
}
_markExtraFolderPathsError(modelType, overlappingPaths, showMessage = false) {
const container = document.getElementById(`extraFolderPaths-${modelType}`);
if (!container) return;
const inputs = container.querySelectorAll('.extra-folder-path-input');
inputs.forEach((input) => {
const val = input.value.trim();
if (val && overlappingPaths.includes(val)) {
input.classList.add('has-error');
if (showMessage) {
const row = input.closest('.extra-folder-path-row');
if (row) {
const errEl = row.querySelector('.extra-folder-path-error');
if (errEl) {
errEl.textContent = translate('settings.extraFolderPaths.validation.checkpointUnetOverlapInline', {}, 'This path is also used for a different model type. Use separate folders for checkpoints and diffusion models.');
errEl.classList.add('visible');
}
}
}
}
});
}
removeExtraFolderPathRow(btn, modelType) {
const row = btn.closest('.extra-folder-path-row');
if (row) {
row.remove();
this.updateExtraFolderPaths(modelType);
}
}
async updateExtraFolderPaths(changedModelType) {
// Clear previous errors
this._clearAllExtraFolderPathErrors();
const extraFolderPaths = {};
// Collect paths for all model types
@@ -1734,6 +1792,32 @@ export class SettingsManager {
extraFolderPaths[modelType] = paths;
});
// Client-side pre-check: checkpoints and unet must not share the same path.
// Normalise paths to reduce false negatives vs the backend's realpath + normcase.
const normalise = (p) => p.replace(/[/\\]+$/, '').toLowerCase();
const ckptSet = new Set((extraFolderPaths.checkpoints || []).map(normalise));
const unetSet = new Set((extraFolderPaths.unet || []).map(normalise));
const ckptOverlap = (extraFolderPaths.checkpoints || []).filter(p => p && unetSet.has(normalise(p)));
const unetOverlap = (extraFolderPaths.unet || []).filter(p => p && ckptSet.has(normalise(p)));
const hasOverlap = ckptOverlap.length > 0 || unetOverlap.length > 0;
if (hasOverlap) {
// Error message only on the side the user just edited.
// The other side gets red border only (passive conflict indicator).
if (changedModelType === 'checkpoints') {
this._markExtraFolderPathsError('checkpoints', ckptOverlap, true);
this._markExtraFolderPathsError('unet', unetOverlap, false);
} else if (changedModelType === 'unet') {
this._markExtraFolderPathsError('unet', unetOverlap, true);
this._markExtraFolderPathsError('checkpoints', ckptOverlap, false);
} else {
// Pre-existing conflict from direct config edit — mark both without messages
this._markExtraFolderPathsError('checkpoints', ckptOverlap, false);
this._markExtraFolderPathsError('unet', unetOverlap, false);
}
return;
}
// Check if paths have actually changed
const currentPaths = state.global.settings.extra_folder_paths || {};
const pathsChanged = JSON.stringify(currentPaths) !== JSON.stringify(extraFolderPaths);
+29 -8
View File
@@ -168,6 +168,18 @@ export class DownloadManager {
let failedDownloads = 0;
let accessFailures = 0;
let currentLoraProgress = 0;
let cancelled = false;
this.importManager.loadingManager.showCancelButton(async () => {
if (cancelled) return;
cancelled = true;
try {
const loraClient = getModelApiClient(MODEL_TYPES.LORA);
await loraClient.cancelDownload(batchDownloadId);
} catch (e) {
console.error('Cancel request failed:', e);
}
});
// Set up progress tracking for current download
ws.onmessage = (event) => {
@@ -179,6 +191,11 @@ export class DownloadManager {
return;
}
if (data.status === 'cancelled') {
cancelled = true;
return;
}
// Process progress updates for our current active download
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
// Update current LoRA progress
@@ -221,6 +238,8 @@ export class DownloadManager {
const useDefaultPaths = getStorageItem('use_default_path_loras', false);
for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) {
if (cancelled) break;
const lora = this.importManager.downloadableLoRAs[i];
// Reset current LoRA progress for new download
@@ -241,15 +260,13 @@ export class DownloadManager {
batchDownloadId
);
if (cancelled) break;
if (!response.success) {
console.error(`Failed to download LoRA ${lora.name}: ${response.error}`);
failedDownloads++;
// Continue with next download
} else {
completedDownloads++;
// Update progress to show completion of current LoRA
updateProgress(100, completedDownloads, '');
if (completedDownloads + failedDownloads < this.importManager.downloadableLoRAs.length) {
@@ -259,9 +276,10 @@ export class DownloadManager {
}
}
} catch (downloadError) {
console.error(`Error downloading LoRA ${lora.name}:`, downloadError);
failedDownloads++;
// Continue with next download
if (!cancelled) {
console.error(`Error downloading LoRA ${lora.name}:`, downloadError);
failedDownloads++;
}
}
}
@@ -269,7 +287,10 @@ export class DownloadManager {
ws.close();
// Show appropriate completion message based on results
if (failedDownloads === 0) {
if (cancelled) {
showToast('toast.downloads.downloadStopped', {}, 'info',
`Download cancelled. ${completedDownloads} item(s) completed.`);
} else if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
if (accessFailures > 0) {
+2
View File
@@ -552,6 +552,8 @@ async function fetchWorkflowRegistry() {
if (!registryData.success) {
if (registryData.error === 'Standalone Mode Active') {
showToast('toast.general.cannotInteractStandalone', {}, 'warning');
} else if (registryData.error === 'Empty Registry') {
showToast('uiHelpers.workflow.noSupportedNodes', {}, 'warning');
} else {
showToast('toast.general.failedWorkflowInfo', {}, 'error');
}
@@ -112,6 +112,10 @@
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide" target="_blank">
Priority Tags Configuration Guide
<span class="new-content-badge inline">{{ t('help.documentation.newBadge') }}</span>
<li>
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/AI-Provider-Setup" target="_blank">
AI Provider Setup
<span class="new-content-badge inline">{{ t('help.documentation.newBadge') }}</span>
</a>
</li>
</ul>
+48
View File
@@ -728,6 +728,54 @@ async def test_register_nodes_includes_capabilities():
assert stored_node["widget_names"] == ["ckpt_name"]
@pytest.mark.asyncio
async def test_register_nodes_accepts_compound_node_ids():
"""Subgraph nodes from expanded group nodes have compound IDs like '252:0'."""
node_registry = NodeRegistry()
handler = NodeRegistryHandler(
node_registry=node_registry,
prompt_server=FakePromptServer,
standalone_mode=False,
)
request = FakeRequest(
json_data={
"nodes": [
{
"node_id": "252:0",
"graph_id": "252",
"type": "CheckpointLoaderSimple",
"title": "Checkpoint Loader (subgraph)",
},
{
"node_id": "252:1",
"graph_id": "252",
"type": "CLIPLoader",
"title": "CLIP Loader (subgraph)",
},
],
"client_id": "test-client-1",
}
)
response = await handler.register_nodes(request)
payload = json.loads(response.text)
assert response.status == 200
assert payload["success"] is True
assert "2 nodes registered" in payload["message"]
registry = await node_registry.get_merged_registry()
assert registry["node_count"] == 2
nodes_map = registry["nodes"]
assert "252:0" in nodes_map
assert "252:1" in nodes_map
assert nodes_map["252:0"]["id"] == 0
assert nodes_map["252:0"]["graph_id"] == "252"
assert nodes_map["252:1"]["id"] == 1
@pytest.mark.asyncio
async def test_update_node_widget_sends_payload():
send_calls: list[tuple[str, dict]] = []
+17
View File
@@ -120,10 +120,27 @@
outline: none;
}
/* Vue node mode: prevent content from pushing node size via ResizeObserver.
contain:size breaks the feedback loop the container's intrinsic size
is determined solely by CSS, not by how many LoRAs are inside. */
.lm-loras-container.lm-vue-node {
height: 100%;
min-height: var(--comfy-widget-min-height, 200px);
contain: layout size;
}
.lm-loras-container:focus {
outline: none;
}
/* Vue node mode: prevent content from pushing node size via ResizeObserver.
Same technique as .lm-loras-container.lm-vue-node above. */
.comfy-tags-container.lm-vue-node {
height: 100%;
min-height: var(--comfy-widget-min-height, 150px);
contain: layout size;
}
.lm-lora-empty-state {
text-align: center;
padding: 20px 0;
+8 -26
View File
@@ -2,19 +2,14 @@ import { createToggle, createArrowButton, createDragHandle, updateEntrySelection
import {
parseLoraValue,
formatLoraValue,
updateWidgetHeight,
shouldShowClipEntry,
syncClipStrengthIfCollapsed,
LORA_ENTRY_HEIGHT,
HEADER_HEIGHT,
CONTAINER_PADDING,
EMPTY_CONTAINER_HEIGHT
syncClipStrengthIfCollapsed
} from "./loras_widget_utils.js";
import { initDrag, createContextMenu, initHeaderDrag, initReorderDrag, handleKeyboardNavigation } from "./loras_widget_events.js";
import { forwardMiddleMouseToCanvas, forwardWheelToCanvas, enableListWheelScroll } from "./utils.js";
import { PreviewTooltip } from "./preview_tooltip.js";
import { ensureLmStyles } from "./lm_styles_loader.js";
import { getStrengthStepPreference, getLoraWidgetMaxVisibleLoras } from "./settings.js";
import { getStrengthStepPreference } from "./settings.js";
export function addLorasWidget(node, name, opts, callback) {
ensureLmStyles();
@@ -29,15 +24,13 @@ export function addLorasWidget(node, name, opts, callback) {
// Set initial height using CSS variables approach
const defaultHeight = 200;
// In Vue/node-2.0 mode, cap the widget height so it shows at most N entries.
// This prevents content from driving the node size beyond the cap.
// canvas/legacy mode is unaffected.
// Set a fixed minimum height so the node has a reasonable starting size.
// Adding or removing LoRAs does NOT change the node size — the container
// scrolls when content exceeds the allocated space.
container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`);
if (typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode) {
const maxLoras = getLoraWidgetMaxVisibleLoras();
const gap = 5; // flex gap from .lm-loras-container CSS
const maxH = CONTAINER_PADDING + HEADER_HEIGHT + maxLoras * LORA_ENTRY_HEIGHT + maxLoras * gap;
container.style.maxHeight = `${maxH}px`;
container.style.setProperty('--comfy-widget-max-height', `${maxH}px`);
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);
@@ -210,9 +203,6 @@ export function addLorasWidget(node, name, opts, callback) {
emptyMessage.textContent = "No LoRAs added";
emptyMessage.className = "lm-lora-empty-state";
container.appendChild(emptyMessage);
// Set fixed height for empty state
updateWidgetHeight(container, EMPTY_CONTAINER_HEIGHT, defaultHeight, node);
return;
}
@@ -259,9 +249,6 @@ export function addLorasWidget(node, name, opts, callback) {
// Initialize the header drag functionality
initHeaderDrag(header, widget, renderLoras);
// Track the total visible entries for height calculation
let totalVisibleEntries = lorasData.length;
// Render each lora entry
lorasData.forEach((loraData) => {
const { name, strength, clipStrength, active } = loraData;
@@ -533,7 +520,6 @@ export function addLorasWidget(node, name, opts, callback) {
// If expanded, show the clip entry
if (isExpanded) {
totalVisibleEntries++;
const clipEl = document.createElement("div");
clipEl.className = "lm-lora-clip-entry";
@@ -657,10 +643,6 @@ export function addLorasWidget(node, name, opts, callback) {
}
});
// Calculate height based on number of loras and fixed sizes
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(totalVisibleEntries, 12) * LORA_ENTRY_HEIGHT);
updateWidgetHeight(container, calculatedHeight, defaultHeight, node);
// After all LoRA elements are created, apply selection state as the last step
// This ensures the selection state is not overwritten
container.querySelectorAll('.lm-lora-entry').forEach(entry => {
-24
View File
@@ -1,12 +1,5 @@
import { app } from "../../scripts/app.js";
// Fixed sizes for component calculations
export const LORA_ENTRY_HEIGHT = 40; // Height of a single lora entry
export const CLIP_ENTRY_HEIGHT = 40; // Height of a clip entry
export const HEADER_HEIGHT = 32; // Height of the header section
export const CONTAINER_PADDING = 12; // Top and bottom padding
export const EMPTY_CONTAINER_HEIGHT = 100; // Height when no loras are present
// Parse LoRA entries from value
export function parseLoraValue(value) {
if (!value) return [];
@@ -18,23 +11,6 @@ export function formatLoraValue(loras) {
return loras;
}
// Function to update widget height consistently
export function updateWidgetHeight(container, height, defaultHeight, node) {
// Ensure minimum height
const finalHeight = Math.max(defaultHeight, height);
// Update CSS variables
container.style.setProperty('--comfy-widget-min-height', `${finalHeight}px`);
container.style.setProperty('--comfy-widget-height', `${finalHeight}px`);
// Force node to update size after a short delay to ensure DOM is updated
if (node) {
setTimeout(() => {
node.setDirtyCanvas(true, true);
}, 10);
}
}
// Determine if clip entry should be shown - now based on expanded property or initial diff values
export function shouldShowClipEntry(loraData) {
// If expanded property exists, use that
-43
View File
@@ -39,9 +39,6 @@ const NEW_TAB_ZOOM_LEVEL = 0.8;
const STRENGTH_STEP_SETTING_ID = "loramanager.strength_step";
const STRENGTH_STEP_DEFAULT = 0.05;
const LORA_WIDGET_MAX_VISIBLE_SETTING_ID = "loramanager.lora_widget_max_visible_loras";
const LORA_WIDGET_MAX_VISIBLE_DEFAULT = 12;
// ============================================================================
// Helper Functions
// ============================================================================
@@ -363,32 +360,6 @@ const getStrengthStepPreference = (() => {
};
})();
const getLoraWidgetMaxVisibleLoras = (() => {
let settingsUnavailableLogged = false;
return () => {
const settingManager = app?.extensionManager?.setting;
if (!settingManager || typeof settingManager.get !== "function") {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: settings API unavailable, using default max visible loras.");
settingsUnavailableLogged = true;
}
return LORA_WIDGET_MAX_VISIBLE_DEFAULT;
}
try {
const value = settingManager.get(LORA_WIDGET_MAX_VISIBLE_SETTING_ID);
return value ?? LORA_WIDGET_MAX_VISIBLE_DEFAULT;
} catch (error) {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: unable to read max visible loras setting, using default.", error);
settingsUnavailableLogged = true;
}
return LORA_WIDGET_MAX_VISIBLE_DEFAULT;
}
};
})();
// ============================================================================
// Register Extension with All Settings
// ============================================================================
@@ -492,19 +463,6 @@ app.registerExtension({
tooltip: "Step size for adjusting LoRA strength via arrow buttons or keyboard (default: 0.05)",
category: ["LoRA Manager", "LoRA Widget", "Strength Step"],
},
{
id: LORA_WIDGET_MAX_VISIBLE_SETTING_ID,
name: "Node 2.0: Maximum visible LoRA entries",
type: "slider",
attrs: {
min: 3,
max: 50,
step: 1,
},
defaultValue: LORA_WIDGET_MAX_VISIBLE_DEFAULT,
tooltip: "When using Node 2.0 rendering, limit the loras widget height to show at most this many entries (default: 12). Excess entries are accessible via scrollbar.",
category: ["LoRA Manager", "LoRA Widget", "Max Visible"],
},
],
async setup() {
await loadWorkflowOptions();
@@ -591,5 +549,4 @@ export {
getUsageStatisticsPreference,
getNewTabTemplatePreference,
getStrengthStepPreference,
getLoraWidgetMaxVisibleLoras,
};
+11
View File
@@ -1,6 +1,7 @@
import { app } from "../../scripts/app.js";
import { forwardMiddleMouseToCanvas, forwardWheelToCanvas } from "./utils.js";
import { copyToClipboard } from "./loras_widget_utils.js";
import { ensureLmStyles } from "./lm_styles_loader.js";
const MIN_HEIGHT = 150;
const GROUP_EDITOR_ID = "lm-trigger-group-editor";
@@ -696,6 +697,16 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
outline: "none",
});
// Set a fixed minimum height so the node has a reasonable starting size.
// Adding or removing tags does NOT change the node size — the container
// scrolls when content exceeds the allocated space.
ensureLmStyles();
container.style.setProperty("--comfy-widget-min-height", `${MIN_HEIGHT}px`);
if (typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode) {
container.classList.add("lm-vue-node");
}
const initialTagsData = opts?.defaultVal || [];
function renderSimpleTag(tagData, index, widget, showStrengthInfo) {
+75 -6
View File
@@ -1,8 +1,10 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { getAllGraphNodes, getNodeReference, getNodeFromGraph } from "./utils.js";
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, chainCallback } from "./utils.js";
import { ensureLmStyles } from "./lm_styles_loader.js";
const DEBOUNCE_DELAY = 500;
const LORA_NODE_CLASSES = new Set([
"Lora Loader (LoraManager)",
"Lora Stacker (LoraManager)",
@@ -79,22 +81,77 @@ app.registerExtension({
setup() {
ensureLmStyles();
this._log("extension initialized, clientId=%s", api.clientId ?? api.initialClientId ?? "(pending)");
api.addEventListener("lora_registry_refresh", () => {
this.refreshRegistry();
this.refreshRegistry(true);
});
api.addEventListener("lm_widget_update", (event) => {
this.applyWidgetUpdate(event?.detail ?? {});
});
// React to marker changes from the Node Marker extension
window.addEventListener("lm_marker_changed", () => {
this.refreshRegistry();
});
this._hookGraphChanges();
},
async refreshRegistry() {
async afterConfigureGraph(_missingNodeTypes, _app) {
this._log("afterConfigureGraph: workflow loaded (%s missing types)", _missingNodeTypes?.length ?? 0);
await this.refreshRegistry();
},
_hookGraphChanges() {
const graph = app.graph;
if (!graph) {
this._log("app.graph not available, skipping proactive hooks");
return;
}
let hooksInstalled = 0;
const scheduleRefresh = (source) => {
if (this._debounceTimer != null) {
clearTimeout(this._debounceTimer);
}
this._debounceTimer = setTimeout(() => {
this._debounceTimer = null;
this.refreshRegistry();
}, DEBOUNCE_DELAY);
};
try {
chainCallback(graph, "onNodeAdded", () => scheduleRefresh("onNodeAdded"));
chainCallback(graph, "onNodeRemoved", () => scheduleRefresh("onNodeRemoved"));
hooksInstalled += 2;
} catch (e) {
this._log("failed to chain LiteGraph hooks: %s", e.message);
}
if (typeof api.addEventListener === "function") {
try {
api.addEventListener("graphChanged", () => scheduleRefresh("graphChanged"));
hooksInstalled += 1;
} catch (_e) {
// graphChanged may not be available on older ComfyUI versions
}
}
this._log("%s proactive hooks installed on graph", hooksInstalled);
},
_log(format, ...args) {
const ts = new Date().toISOString().slice(11, 23);
let msg = format;
for (const arg of args) {
msg = msg.replace(/%s/g, String(arg));
}
console.debug(`[LM:Registry ${ts}] ${msg}`);
},
async refreshRegistry(force = false) {
try {
const workflowNodes = [];
const nodeEntries = getAllGraphNodes(app.graph);
@@ -115,7 +172,6 @@ app.registerExtension({
const hasTextWidget = TEXT_CAPABLE_CLASSES.has(node.comfyClass);
const markerRole = node.properties?.lm_marker_role ?? null;
// Skip nodes with no relevant capability UNLESS they are marked
if (!supportsLora && !hasTargetWidget && !hasTextWidget && !markerRole) {
continue;
}
@@ -146,6 +202,19 @@ app.registerExtension({
});
}
const clientId = api.clientId ?? api.initialClientId ?? "";
// Content-based dedup: skip POST if identical to last sent payload,
// unless forced (e.g. responding to a lora_registry_refresh WS message
// where the backend explicitly requests a re-registration).
const fingerprint = JSON.stringify(
workflowNodes.map(n => `${n.graph_id}:${n.node_id}|${n.marker_role ?? ""}|${n.mode ?? 0}`).sort()
);
if (!force && fingerprint === this._lastFingerprint) {
return;
}
this._lastFingerprint = fingerprint;
const response = await fetch("/api/lm/register-nodes", {
method: "POST",
headers: {
@@ -153,7 +222,7 @@ app.registerExtension({
},
body: JSON.stringify({
nodes: workflowNodes,
client_id: api.clientId ?? api.initialClientId ?? "",
client_id: clientId,
}),
});