Compare commits

...
4 Commits
Author SHA1 Message Date
Luna_K 3f8381ffee Fix prompt tracking through conditioning transforms 2026-08-12 19:16:30 +08:00
Will Miao 1ca99294c9 feat(delete): shorten undo window to 20s and make undo toast dismissible 2026-08-12 19:15:03 +08:00
Will Miao 680f0a57f5 fix(update): resolve template path when updating to a different base model (#1059)
Version-tab updates reused the current version's folder, so updating a LoRA
to a version with a different base model (e.g. Illustrious -> Anima) ignored
the download path template and landed in the old version's directory.

When the target version's base model differs from the current local version
and a path template is configured, re-resolve the template under the same
model root. The backend keeps an explicitly provided root when
use_save_dir_as_root is set, so regular downloads still use the default root.
2026-08-12 18:45:54 +08:00
Will Miao 94e3f54571 feat(workflow): exclude text-capable nodes with connected text from send targets
CLIP Text Encode and friends whose text widget is backed by a connected
input cannot have their text changed via the widget (execution reads the
linked input), so sending to them was a silent no-op.

- Registry: compute text_widget_connected capability from the widget's
  backing input link state; has_text_widget drops to false when wired;
  include the flag in the registration fingerprint so link changes
  re-register the affected nodes
- Registry: hook link connect/disconnect (graph events on new litegraph,
  onAfterChange fallback for classic) on root and subgraphs, plus
  subgraph-created for future subgraphs
- applyWidgetUpdate: skip inject_text when the target widget is connected
  and self-heal the registry instead of writing a value that is ignored
- Web UI: drop text_widget_connected nodes from prompt/embedding send
  candidates; show a Mark as -> Send Prompt Target hint toast when no
  candidates remain (new uiHelpers.workflow.noPromptTargets key, synced
  to all locales; zh-CN/zh-TW translated)
- Extract shared resolveTextWidget() used by both the candidate-set
  logic and the write path so the two cannot drift apart
- Tests: workflow registry connection-state registration, subgraph
  handling, fingerprint re-registration, inject_text write/skip paths,
  setup link-change hooks; uiHelpers candidate filtering and hint toast
2026-08-12 16:50:07 +08:00
27 changed files with 1342 additions and 172 deletions
+10 -9
View File
@@ -1277,13 +1277,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "Gibt {size} frei",
"title": "Modell löschen",
"message": "Sind Sie sicher, dass Sie dieses Modell und alle zugehörigen Dateien löschen möchten?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "Die Datei wird nach 20 Sekunden endgültig gelöscht, sofern Sie nicht rückgängig machen."
},
"deleteRecipe": {
"recoverableWarning": "Diese Aktion kann 30 Sekunden lang rückgängig gemacht werden."
"recoverableWarning": "Diese Aktion kann 20 Sekunden lang rückgängig gemacht werden."
},
"excludeModel": {
"title": "Modell ausschließen",
@@ -1726,6 +1726,7 @@
"recipeReplaced": "Rezept im Workflow ersetzt",
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "Kein Zielknoten ausgewählt",
"modelUpdated": "Modell im Workflow aktualisiert",
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
@@ -2119,12 +2120,12 @@
"copyFailed": "Kopieren fehlgeschlagen"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "Rückgängig",
"deleted": "Gelöscht: {name}",
"deletedBulk": "{count} Element(e) gelöscht",
"expired": "Undo-Fenster abgelaufen. Das Element wurde endgültig gelöscht.",
"failed": "Rückgängig machen fehlgeschlagen: {error}",
"restored": "Element wiederhergestellt"
},
"virtual": {
"loadFailed": "Fehler beim Laden der Elemente",
+3 -2
View File
@@ -1280,10 +1280,10 @@
"freesSpace": "Frees {size}",
"title": "Delete Model",
"message": "Are you sure you want to delete this model and all associated files?",
"recoverableWarning": "This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "This will permanently delete the file after 20 seconds unless you undo."
},
"deleteRecipe": {
"recoverableWarning": "This action can be undone for 30 seconds."
"recoverableWarning": "This action can be undone for 20 seconds."
},
"excludeModel": {
"title": "Exclude Model",
@@ -1726,6 +1726,7 @@
"recipeReplaced": "Recipe replaced in workflow",
"recipeFailedToSend": "Failed to send recipe to workflow",
"noMatchingNodes": "No compatible nodes available in the current workflow",
"noPromptTargets": "No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "No target node selected",
"modelUpdated": "Model updated in workflow",
"modelFailed": "Failed to update model node",
+10 -9
View File
@@ -1277,13 +1277,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "Libera {size}",
"title": "Eliminar modelo",
"message": "¿Estás seguro de que quieres eliminar este modelo y todos los archivos asociados?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "El archivo se eliminará permanentemente después de 20 segundos a menos que deshaga la acción."
},
"deleteRecipe": {
"recoverableWarning": "Esta acción se puede deshacer durante 30 segundos."
"recoverableWarning": "Esta acción se puede deshacer durante 20 segundos."
},
"excludeModel": {
"title": "Excluir modelo",
@@ -1726,6 +1726,7 @@
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
"modelFailed": "Error al actualizar nodo de modelo",
@@ -2119,12 +2120,12 @@
"copyFailed": "Error al copiar"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "Deshacer",
"deleted": "Eliminado: {name}",
"deletedBulk": "{count} elemento(s) eliminado(s)",
"expired": "La ventana de deshacer ha caducado. El elemento se eliminó permanentemente.",
"failed": "No se pudo deshacer: {error}",
"restored": "Elemento restaurado"
},
"virtual": {
"loadFailed": "Error al cargar elementos",
+10 -9
View File
@@ -1277,13 +1277,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "Libère {size}",
"title": "Supprimer le modèle",
"message": "Êtes-vous sûr de vouloir supprimer ce modèle et tous les fichiers associés ?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "Le fichier sera définitivement supprimé après 20 secondes, sauf si vous annulez."
},
"deleteRecipe": {
"recoverableWarning": "Cette action peut être annulée pendant 30 secondes."
"recoverableWarning": "Cette action peut être annulée pendant 20 secondes."
},
"excludeModel": {
"title": "Exclure le modèle",
@@ -1726,6 +1726,7 @@
"recipeReplaced": "Recipe remplacée dans le workflow",
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "Aucun nœud cible sélectionné",
"modelUpdated": "Modèle mis à jour dans le workflow",
"modelFailed": "Échec de la mise à jour du nœud modèle",
@@ -2119,12 +2120,12 @@
"copyFailed": "Échec de la copie"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "Annuler",
"deleted": "Supprimé : {name}",
"deletedBulk": "{count} élément(s) supprimé(s)",
"expired": "La fenêtre d'annulation a expiré. L'élément a été définitivement supprimé.",
"failed": "Échec de l'annulation : {error}",
"restored": "Élément restauré"
},
"virtual": {
"loadFailed": "Échec du chargement des éléments",
+10 -9
View File
@@ -1277,13 +1277,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "מפנה {size}",
"title": "מחק מודל",
"message": "האם אתה בטוח שברצונך למחוק מודל זה וכל הקבצים הנלווים?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "הקובץ יימחק לצמיתות לאחר 20 שניות, אלא אם תבטלו את הפעולה."
},
"deleteRecipe": {
"recoverableWarning": "ניתן לבטל פעולה זו תוך 30 שניות."
"recoverableWarning": "ניתן לבטל פעולה זו תוך 20 שניות."
},
"excludeModel": {
"title": "החרג מודל",
@@ -1726,6 +1726,7 @@
"recipeReplaced": "מתכון הוחלף ב-workflow",
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "לא נבחר צומת יעד",
"modelUpdated": "מודל עודכן ב-workflow",
"modelFailed": "עדכון צומת המודל נכשל",
@@ -2119,12 +2120,12 @@
"copyFailed": "ההעתקה נכשלה"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "בטל",
"deleted": "נמחק: {name}",
"deletedBulk": "{count} פריטים נמחקו",
"expired": "חלון הביטול פג. הפריט נמחק לצמיתות.",
"failed": "הביטול נכשל: {error}",
"restored": "הפריט שוחזר"
},
"virtual": {
"loadFailed": "טעינת הפריטים נכשלה",
+10 -9
View File
@@ -1277,13 +1277,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "{size} を解放します",
"title": "モデルを削除",
"message": "このモデルと関連するすべてのファイルを削除してもよろしいですか?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "元に戻さない場合、このファイルは20秒後に完全に削除されます。"
},
"deleteRecipe": {
"recoverableWarning": "この操作は30秒以内であれば元に戻せます。"
"recoverableWarning": "この操作は20秒以内であれば元に戻せます。"
},
"excludeModel": {
"title": "モデルを除外",
@@ -1726,6 +1726,7 @@
"recipeReplaced": "レシピがワークフローで置換されました",
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "ターゲットノードが選択されていません",
"modelUpdated": "モデルがワークフローで更新されました",
"modelFailed": "モデルノードの更新に失敗しました",
@@ -2119,12 +2120,12 @@
"copyFailed": "コピーに失敗しました"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "元に戻す",
"deleted": "{name} を削除しました",
"deletedBulk": "{count} 個のアイテムを削除しました",
"expired": "元に戻せる時間が経過しました。アイテムは完全に削除されました。",
"failed": "元に戻せませんでした: {error}",
"restored": "アイテムを復元しました"
},
"virtual": {
"loadFailed": "アイテムの読み込みに失敗しました",
+10 -9
View File
@@ -1277,13 +1277,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "{size} 확보",
"title": "모델 삭제",
"message": "이 모델과 모든 관련 파일을 삭제하시겠습니까?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "실행 취소하지 않으면 20초 후에 파일이 영구적으로 삭제됩니다."
},
"deleteRecipe": {
"recoverableWarning": "이 작업은 30초 이내에 실행 취소할 수 있습니다."
"recoverableWarning": "이 작업은 20초 이내에 실행 취소할 수 있습니다."
},
"excludeModel": {
"title": "모델 제외",
@@ -1726,6 +1726,7 @@
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
"modelFailed": "모델 노드 업데이트 실패",
@@ -2119,12 +2120,12 @@
"copyFailed": "복사 실패"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "실행 취소",
"deleted": "{name} 삭제됨",
"deletedBulk": "{count}개 항목 삭제됨",
"expired": "실행 취소 기간이 만료되었습니다. 항목이 영구적으로 삭제되었습니다.",
"failed": "실행 취소 실패: {error}",
"restored": "항목이 복원되었습니다"
},
"virtual": {
"loadFailed": "항목 로딩 실패",
+10 -9
View File
@@ -1277,13 +1277,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "Освобождает {size}",
"title": "Удалить модель",
"message": "Вы уверены, что хотите удалить эту модель и все связанные файлы?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "Файл будет удалён навсегда через 20 секунд, если вы не отмените действие."
},
"deleteRecipe": {
"recoverableWarning": "Это действие можно отменить в течение 30 секунд."
"recoverableWarning": "Это действие можно отменить в течение 20 секунд."
},
"excludeModel": {
"title": "Исключить модель",
@@ -1726,6 +1726,7 @@
"recipeReplaced": "Рецепт заменён в workflow",
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "Целевой узел не выбран",
"modelUpdated": "Модель обновлена в workflow",
"modelFailed": "Не удалось обновить узел модели",
@@ -2119,12 +2120,12 @@
"copyFailed": "Копирование не удалось"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "Отменить",
"deleted": "Удалено: {name}",
"deletedBulk": "Удалено: {count} шт.",
"expired": "Время отмены истекло. Элемент был удалён навсегда.",
"failed": "Не удалось отменить: {error}",
"restored": "Элемент восстановлен"
},
"virtual": {
"loadFailed": "Не удалось загрузить элементы",
+10 -9
View File
@@ -1277,13 +1277,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "释放 {size}",
"title": "删除模型",
"message": "你确定要删除此模型及所有相关文件吗?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "如果不撤销,文件将在 20 秒后被永久删除。"
},
"deleteRecipe": {
"recoverableWarning": "此操作可在 30 秒内撤销。"
"recoverableWarning": "此操作可在 20 秒内撤销。"
},
"excludeModel": {
"title": "排除模型",
@@ -1726,6 +1726,7 @@
"recipeReplaced": "配方已替换到工作流",
"recipeFailedToSend": "发送配方到工作流失败",
"noMatchingNodes": "当前工作流中没有兼容的节点",
"noPromptTargets": "工作流中没有兼容的 prompt 目标节点。\n在 ComfyUI 中右键节点 → Mark as → Send Prompt Target",
"noTargetNodeSelected": "未选择目标节点",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型节点失败",
@@ -2119,12 +2120,12 @@
"copyFailed": "复制失败"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "撤销",
"deleted": "已删除 {name}",
"deletedBulk": "已删除 {count} 个项目",
"expired": "撤销窗口已过期,项目已被永久删除。",
"failed": "撤销失败:{error}",
"restored": "项目已恢复"
},
"virtual": {
"loadFailed": "加载项目失败",
+10 -9
View File
@@ -1277,13 +1277,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "釋放 {size}",
"title": "刪除模型",
"message": "您確定要刪除此模型及所有相關檔案嗎?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "如果未復原,檔案將在 20 秒後被永久刪除。"
},
"deleteRecipe": {
"recoverableWarning": "此操作可在 30 秒內復原。"
"recoverableWarning": "此操作可在 20 秒內復原。"
},
"excludeModel": {
"title": "排除模型",
@@ -1726,6 +1726,7 @@
"recipeReplaced": "配方已取代於工作流",
"recipeFailedToSend": "傳送配方到工作流失敗",
"noMatchingNodes": "目前工作流程中沒有相容的節點",
"noPromptTargets": "工作流中沒有相容的 prompt 目標節點。\n在 ComfyUI 中右鍵節點 → Mark as → Send Prompt Target",
"noTargetNodeSelected": "未選擇目標節點",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型節點失敗",
@@ -2119,12 +2120,12 @@
"copyFailed": "複製失敗"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "復原",
"deleted": "已刪除 {name}",
"deletedBulk": "已刪除 {count} 個項目",
"expired": "復原視窗已過期,項目已被永久刪除。",
"failed": "復原失敗:{error}",
"restored": "項目已還原"
},
"virtual": {
"loadFailed": "載入項目失敗",
+30 -13
View File
@@ -40,7 +40,7 @@ class GenericNodeExtractor(NodeMetadataExtractor):
* ``MODEL`` output: common input fields (ckpt_name, unet_name, etc.)
are checked for a model file name and stored as checkpoint metadata.
* ``CONDITIONING`` output: common text input fields are checked for
prompt text and stored as prompt metadata.
prompt text, and conditioning inputs are tracked through transforms.
"""
# Input field names that carry a model path in loader-style nodes.
@@ -73,7 +73,7 @@ class GenericNodeExtractor(NodeMetadataExtractor):
_store_checkpoint_metadata(metadata, node_id, name)
return
# — CONDITIONING encoder detection (CLIPTextEncode, Flux, custom)
# — CONDITIONING encoder / transform detection
if "CONDITIONING" in return_types or any("CONDITIONING" in str(t) for t in return_types):
text = None
for field in GenericNodeExtractor._TEXT_FIELDS:
@@ -81,12 +81,18 @@ class GenericNodeExtractor(NodeMetadataExtractor):
if val and isinstance(val, str) and val.strip():
text = val.strip()
break
if text:
prompt_data = metadata.setdefault(PROMPTS, {})
prompt_data[node_id] = {
"text": text,
"node_id": node_id,
}
input_conditionings = [
value
for input_name, value in inputs.items()
if input_name.startswith("conditioning") and value is not None
]
if text or input_conditionings:
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
if text:
prompt_metadata["text"] = text
if input_conditionings:
prompt_metadata["orig_conditionings"] = input_conditionings
@staticmethod
def update(node_id, outputs, metadata, return_types=None):
@@ -98,11 +104,22 @@ class GenericNodeExtractor(NodeMetadataExtractor):
return
if node_id not in metadata.get(PROMPTS, {}):
return
if outputs and isinstance(outputs, list) and len(outputs) > 0:
if isinstance(outputs[0], tuple) and len(outputs[0]) > 0:
cond = outputs[0][0]
if cond is not None:
metadata[PROMPTS][node_id]["conditioning"] = cond
output_tuple = _first_output_tuple(outputs)
if not output_tuple or len(output_tuple) < 1:
return
output_conditioning = output_tuple[0]
if output_conditioning is None:
return
prompt_metadata = metadata[PROMPTS][node_id]
prompt_metadata["conditioning"] = output_conditioning
_record_conditioning_source(
metadata,
node_id,
output_conditioning,
prompt_metadata.get("orig_conditionings", []),
)
class CheckpointLoaderExtractor(NodeMetadataExtractor):
@staticmethod
+1
View File
@@ -83,6 +83,7 @@ class DownloadCoordinator:
save_dir=payload.get("model_root"),
relative_path=payload.get("relative_path", ""),
use_default_paths=payload.get("use_default_paths", False),
use_save_dir_as_root=payload.get("use_save_dir_as_root", False),
progress_callback=progress_callback,
download_id=download_id,
source=payload.get("source"),
+45 -30
View File
@@ -217,6 +217,7 @@ class DownloadManager:
download_id: str | None = None,
source: str | None = None,
file_params: Dict[str, Any] | None = None,
use_save_dir_as_root: bool = False,
) -> Dict[str, Any]:
"""Download model from Civitai with task tracking and concurrency control
@@ -257,6 +258,7 @@ class DownloadManager:
"save_dir": save_dir,
"relative_path": relative_path,
"use_default_paths": bool(use_default_paths),
"use_save_dir_as_root": bool(use_save_dir_as_root),
"source": source,
"file_params": copy.deepcopy(file_params) if file_params is not None else None,
"progress": 0,
@@ -287,6 +289,7 @@ class DownloadManager:
use_default_paths,
source,
file_params,
use_save_dir_as_root,
)
)
@@ -321,6 +324,7 @@ class DownloadManager:
use_default_paths: bool = False,
source: str | None = None,
file_params: Dict[str, Any] | None = None,
use_save_dir_as_root: bool = False,
):
"""Execute download with semaphore to limit concurrency"""
# Update status to waiting
@@ -401,6 +405,7 @@ class DownloadManager:
),
source,
file_params,
use_save_dir_as_root=use_save_dir_as_root,
)
# Update status based on result
@@ -621,6 +626,7 @@ class DownloadManager:
"save_dir": info.get("save_dir"),
"relative_path": info.get("relative_path", ""),
"use_default_paths": bool(info.get("use_default_paths", False)),
"use_save_dir_as_root": bool(info.get("use_save_dir_as_root", False)),
"source": info.get("source"),
"file_params": copy.deepcopy(info.get("file_params")),
"transfer_backend": info.get("transfer_backend", "aria2"),
@@ -643,6 +649,7 @@ class DownloadManager:
"save_dir": record.get("save_dir"),
"relative_path": record.get("relative_path", ""),
"use_default_paths": bool(record.get("use_default_paths", False)),
"use_save_dir_as_root": bool(record.get("use_save_dir_as_root", False)),
"source": record.get("source"),
"file_params": copy.deepcopy(record.get("file_params")),
"progress": record.get("progress", 0),
@@ -1001,6 +1008,7 @@ class DownloadManager:
bool(restored.get("use_default_paths", False)),
restored.get("source"),
restored.get("file_params"),
bool(restored.get("use_save_dir_as_root", False)),
)
)
continue
@@ -1134,6 +1142,7 @@ class DownloadManager:
transfer_backend: str = "python",
source: str | None = None,
file_params: Dict[str, Any] | None = None,
use_save_dir_as_root: bool = False,
) -> Dict[str, Any]:
"""Wrapper for original download_from_civitai implementation"""
try:
@@ -1362,36 +1371,41 @@ class DownloadManager:
# Handle use_default_paths
if use_default_paths:
settings_manager = get_settings_manager()
# Set save_dir based on model type
if model_type == "checkpoint":
if is_diffusion_model:
default_path = settings_manager.get("default_unet_root")
error_msg = "Default unet root path not set in settings"
else:
default_path = settings_manager.get("default_checkpoint_root")
error_msg = "Default checkpoint root path not set in settings"
if not default_path:
return {
"success": False,
"error": error_msg,
}
save_dir = default_path
elif model_type == "lora":
default_path = settings_manager.get("default_lora_root")
if not default_path:
return {
"success": False,
"error": "Default lora root path not set in settings",
}
save_dir = default_path
elif model_type == "embedding":
default_path = settings_manager.get("default_embedding_root")
if not default_path:
return {
"success": False,
"error": "Default embedding root path not set in settings",
}
save_dir = default_path
# With use_save_dir_as_root, an explicitly provided save_dir is kept
# as the base root and the path template is resolved underneath it.
# Otherwise fall back to the configured default root, which keeps the
# classic "download to default root" behavior for regular downloads.
if not save_dir or not use_save_dir_as_root:
# Set save_dir based on model type
if model_type == "checkpoint":
if is_diffusion_model:
default_path = settings_manager.get("default_unet_root")
error_msg = "Default unet root path not set in settings"
else:
default_path = settings_manager.get("default_checkpoint_root")
error_msg = "Default checkpoint root path not set in settings"
if not default_path:
return {
"success": False,
"error": error_msg,
}
save_dir = default_path
elif model_type == "lora":
default_path = settings_manager.get("default_lora_root")
if not default_path:
return {
"success": False,
"error": "Default lora root path not set in settings",
}
save_dir = default_path
elif model_type == "embedding":
default_path = settings_manager.get("default_embedding_root")
if not default_path:
return {
"success": False,
"error": "Default embedding root path not set in settings",
}
save_dir = default_path
# Calculate relative path using template
relative_path = self._calculate_relative_path(version_info, model_type)
@@ -2761,6 +2775,7 @@ class DownloadManager:
bool(persisted.get("use_default_paths", False)),
persisted.get("source"),
persisted.get("file_params"),
bool(persisted.get("use_save_dir_as_root", False)),
),
)
except Exception as exc:
+1 -1
View File
@@ -43,7 +43,7 @@ from ..utils import settings_paths
logger = logging.getLogger(__name__)
# Undo window in seconds before a staged batch becomes purge-eligible.
PENDING_DELETE_TTL_SECONDS = 30
PENDING_DELETE_TTL_SECONDS = 20
# Hidden staging directory name placed inside each deleted model's own folder
# (sibling of the model artifacts) and under the settings dir for recipes.
PENDING_DELETE_DIR_NAME = ".lm-pending-delete"
+18
View File
@@ -107,6 +107,24 @@
white-space: nowrap;
}
.toast-close-btn {
flex-shrink: 0;
padding: 0 4px;
background: transparent;
color: var(--text-color);
border: none;
border-radius: 4px;
font-size: 1.1em;
line-height: 1;
opacity: 0.5;
cursor: pointer;
transition: opacity 0.2s ease;
}
.toast-close-btn:hover {
opacity: 1;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.toast {
+2 -1
View File
@@ -1233,7 +1233,7 @@ export class BaseModelApiClient {
}
}
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId, source = null, fileParams = null) {
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId, source = null, fileParams = null, useSaveDirAsRoot = false) {
try {
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
method: 'POST',
@@ -1244,6 +1244,7 @@ export class BaseModelApiClient {
model_root: modelRoot,
relative_path: relativePath,
use_default_paths: useDefaultPaths,
use_save_dir_as_root: useSaveDirAsRoot,
download_id: downloadId,
...(source ? { source } : {}),
...(fileParams ? { file_params: fileParams } : {})
@@ -1307,15 +1307,41 @@ export function initVersionsTab({
});
}
async function resolveDownloadPathFromCurrentVersion() {
function getCurrentInLibraryVersion() {
if (!normalizedCurrentVersionId || !controller.record?.versions) {
return null;
}
const currentVersion = controller.record.versions.find(
return controller.record.versions.find(
v => v.versionId === normalizedCurrentVersionId && v.isInLibrary && v.filePath
);
if (!currentVersion?.filePath) {
) || null;
}
function getDownloadPathTemplate() {
try {
const singularType = modelType.replace(/s$/, '');
const templates = state.global?.settings?.download_path_templates;
return (templates && templates[singularType]) || '';
} catch (error) {
return '';
}
}
function shouldResolveTemplatePath(targetVersion, pathInfo) {
if (!getDownloadPathTemplate() || !pathInfo?.modelRoot) {
return false;
}
const currentVersion = getCurrentInLibraryVersion();
const currentBase = normalizeBaseModelName(currentVersion?.baseModel);
const targetBase = normalizeBaseModelName(targetVersion?.baseModel);
if (!currentBase || !targetBase || currentBase === targetBase) {
return false;
}
return true;
}
async function resolveDownloadPathFromCurrentVersion() {
const currentVersion = getCurrentInLibraryVersion();
if (!currentVersion) {
return null;
}
@@ -1372,10 +1398,13 @@ export function initVersionsTab({
try {
const pathInfo = await resolveDownloadPathFromCurrentVersion();
const resolveTemplatePath = shouldResolveTemplatePath(version, pathInfo);
const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, {
versionName: version.name || `#${version.versionId}`,
modelRoot: pathInfo?.modelRoot || '',
targetFolder: pathInfo?.targetFolder || '',
targetFolder: resolveTemplatePath ? '' : (pathInfo?.targetFolder || ''),
useDefaultPaths: resolveTemplatePath ? true : null,
useSaveDirAsRoot: resolveTemplatePath,
});
if (success) {
+9 -5
View File
@@ -912,6 +912,7 @@ export class DownloadManager {
modelRoot = '',
targetFolder = '',
useDefaultPaths = false,
useSaveDirAsRoot = false,
source = null,
fileParams = null,
closeModal = false,
@@ -923,7 +924,7 @@ export class DownloadManager {
}
const displayName = versionName || `#${versionId}`;
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, source, fileParams, closeModal: false };
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false };
let ws = null;
let updateProgress = () => { };
let cancelled = false;
@@ -995,7 +996,8 @@ export class DownloadManager {
useDefaultPaths,
downloadId,
source,
fileParams
fileParams,
useSaveDirAsRoot
);
if (cancelled) {
@@ -1809,7 +1811,9 @@ export class DownloadManager {
versionName = '',
source = null,
modelRoot = '',
targetFolder = ''
targetFolder = '',
useDefaultPaths = null,
useSaveDirAsRoot = false
} = {}) {
console.warn('[download] downloadVersionWithDefaults: NO fileParams will be sent — backend will always use primary file. '
+ 'modelType=%s, modelId=%s, versionId=%s, versionName="%s"',
@@ -1824,14 +1828,14 @@ export class DownloadManager {
this.modelId = modelId ? modelId.toString() : null;
this.source = source;
const useDefaultPaths = !modelRoot;
return this.executeDownloadWithProgress({
modelId,
versionId,
versionName,
modelRoot: modelRoot || '',
targetFolder: targetFolder || '',
useDefaultPaths,
useDefaultPaths: useDefaultPaths ?? !modelRoot,
useSaveDirAsRoot,
source,
closeModal: false,
});
+39 -4
View File
@@ -236,11 +236,11 @@ export function showToast(key, params = {}, type = 'info', fallback = null) {
* @param {Object} [options]
* @param {string} [options.actionText] - Label for the action button (button omitted when empty)
* @param {Function} [options.onAction] - Callback invoked at most once on button click
* @param {number} [options.durationMs=30000] - How long the toast stays visible
* @param {number} [options.durationMs=20000] - How long the toast stays visible
* @param {boolean} [options.countdown=true] - Show a ticking `(N)s` countdown
*/
export function showActionToast(key, params = {}, type = 'info', options = {}) {
const { actionText, onAction, durationMs = 30000, countdown = true } = options;
const { actionText, onAction, durationMs = 20000, countdown = true } = options;
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
const message = isPlainMessage ? key : translate(key, params);
@@ -295,6 +295,20 @@ export function showActionToast(key, params = {}, type = 'info', options = {}) {
}
}, 1000);
}
// Manual close button: hides the toast early without firing onAction. The
// backend undo window keeps running and the batch is purged when it expires.
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'toast-close-btn';
closeBtn.textContent = '×';
closeBtn.setAttribute('aria-label', translate('common.actions.close'));
closeBtn.addEventListener('click', (event) => {
event.preventDefault();
clearCountdown();
dismiss();
});
toast.append(closeBtn);
}
export function restoreFolderFilter() {
@@ -1092,6 +1106,9 @@ export async function sendEmbeddingToWorkflow(embeddingCode, onComplete = null)
if (!isNodeEnabled(node)) {
return false;
}
if (node.capabilities?.text_widget_connected === true) {
return false;
}
return (
node.capabilities?.has_text_widget === true ||
node.marker_role === "send_prompt_target"
@@ -1100,7 +1117,15 @@ export async function sendEmbeddingToWorkflow(embeddingCode, onComplete = null)
const nodeKeys = Object.keys(textNodes);
if (nodeKeys.length === 0) {
showToast('uiHelpers.workflow.noMatchingNodes', {}, 'warning');
showToast(
translate(
'uiHelpers.workflow.noPromptTargets',
{},
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
),
{},
'warning'
);
return false;
}
@@ -1152,6 +1177,11 @@ export async function sendPromptToWorkflow(promptText, options = {}) {
if (!isNodeEnabled(node)) {
return false;
}
// A node whose text widget is backed by a connected input cannot have its
// text changed via the widget — execution reads the linked input.
if (node.capabilities?.text_widget_connected === true) {
return false;
}
return (
node.capabilities?.has_text_widget === true ||
node.marker_role === "send_prompt_target"
@@ -1160,7 +1190,12 @@ export async function sendPromptToWorkflow(promptText, options = {}) {
const nodeKeys = Object.keys(textNodes);
if (nodeKeys.length === 0) {
showToast(options.missingNodesMessage || 'uiHelpers.workflow.noMatchingNodes', {}, 'warning');
const defaultHint = translate(
'uiHelpers.workflow.noPromptTargets',
{},
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
);
showToast(options.missingNodesMessage || defaultHint, {}, 'warning');
return false;
}
@@ -0,0 +1,210 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
MODEL_VERSIONS_MODULE,
API_FACTORY_MODULE,
DOWNLOAD_MANAGER_MODULE,
UI_HELPERS_MODULE,
STATE_MODULE,
I18N_HELPERS_MODULE,
UTILS_MODULE,
} = vi.hoisted(() => ({
MODEL_VERSIONS_MODULE: new URL('../../../static/js/components/shared/ModelVersionsTab.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
UTILS_MODULE: new URL('../../../static/js/components/shared/utils.js', import.meta.url).pathname,
}));
const downloadVersionWithDefaults = vi.fn();
vi.mock(DOWNLOAD_MANAGER_MODULE, () => ({
downloadManager: {
downloadVersionWithDefaults,
},
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
openCivitaiUrl: vi.fn(),
}));
const stateMock = {
global: {
settings: {
autoplay_on_hover: false,
version_grouping: 'any',
download_path_templates: {
lora: '{base_model}/{first_tag}',
checkpoint: '{base_model}/{first_tag}',
embedding: '{base_model}/{first_tag}',
},
},
},
};
vi.mock(STATE_MODULE, () => ({
state: stateMock,
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));
vi.mock(UTILS_MODULE, () => ({
formatFileSize: vi.fn(() => '1 MB'),
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(),
}));
const LORA_ROOT = '/models/loras';
function buildRecord(targetBaseModel = 'Anima') {
return {
success: true,
record: {
shouldIgnore: false,
inLibraryVersionIds: [10],
versions: [
{
versionId: 10,
name: 'v1.0',
baseModel: 'Illustrious',
sizeBytes: 1024,
isInLibrary: true,
shouldIgnore: false,
filePath: `${LORA_ROOT}/Illustrious/works/file.safetensors`,
},
{
versionId: 11,
name: 'v1.1',
baseModel: targetBaseModel,
sizeBytes: 2048,
isInLibrary: false,
shouldIgnore: false,
},
],
},
};
}
async function renderAndClickDownload({ currentVersionId = 10, record = null } = {}) {
const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE);
const controller = initVersionsTab({
modalId: 'model-versions-modal',
modelType: 'loras',
modelId: 123,
currentVersionId,
});
await controller.load();
const downloadButton = document.querySelector(
'.model-version-row[data-version-id="11"] [data-version-action="download"]'
);
downloadButton?.click();
await new Promise(resolve => setTimeout(resolve, 0));
return controller;
}
describe('ModelVersionsTab update download path resolution', () => {
let getModelApiClient;
let fetchModelUpdateVersions;
let fetchModelRoots;
beforeEach(async () => {
vi.resetModules();
downloadVersionWithDefaults.mockReset();
downloadVersionWithDefaults.mockResolvedValue(true);
document.body.innerHTML = `
<div id="model-versions-modal">
<div id="versions-tab">
<div class="model-versions-tab"></div>
</div>
</div>
`;
stateMock.global.settings.version_grouping = 'any';
stateMock.global.settings.download_path_templates.lora = '{base_model}/{first_tag}';
({ getModelApiClient } = await import(API_FACTORY_MODULE));
fetchModelUpdateVersions = vi.fn();
fetchModelRoots = vi.fn();
fetchModelRoots.mockResolvedValue({ roots: [LORA_ROOT] });
getModelApiClient.mockReturnValue({
fetchModelUpdateVersions,
fetchModelRoots,
setModelUpdateIgnore: vi.fn(),
setVersionUpdateIgnore: vi.fn(),
deleteModel: vi.fn(),
});
});
afterEach(() => {
document.body.innerHTML = '';
});
it('keeps the current folder when the target version has the same base model', async () => {
fetchModelUpdateVersions.mockResolvedValue(buildRecord('Illustrious'));
await renderAndClickDownload();
expect(downloadVersionWithDefaults).toHaveBeenCalledWith(
'loras', 123, 11,
expect.objectContaining({
modelRoot: LORA_ROOT,
targetFolder: 'Illustrious/works',
useDefaultPaths: null,
useSaveDirAsRoot: false,
})
);
});
it('resolves the template path when the target base model differs and a template is configured', async () => {
fetchModelUpdateVersions.mockResolvedValue(buildRecord());
await renderAndClickDownload();
expect(downloadVersionWithDefaults).toHaveBeenCalledWith(
'loras', 123, 11,
expect.objectContaining({
modelRoot: LORA_ROOT,
targetFolder: '',
useDefaultPaths: true,
useSaveDirAsRoot: true,
})
);
});
it('keeps the current folder when the target base model differs but no template is configured', async () => {
stateMock.global.settings.download_path_templates.lora = '';
fetchModelUpdateVersions.mockResolvedValue(buildRecord());
await renderAndClickDownload();
expect(downloadVersionWithDefaults).toHaveBeenCalledWith(
'loras', 123, 11,
expect.objectContaining({
modelRoot: LORA_ROOT,
targetFolder: 'Illustrious/works',
useDefaultPaths: null,
useSaveDirAsRoot: false,
})
);
});
it('falls back to default paths when no local version exists', async () => {
fetchModelUpdateVersions.mockResolvedValue(buildRecord());
await renderAndClickDownload({ currentVersionId: null });
expect(downloadVersionWithDefaults).toHaveBeenCalledWith(
'loras', 123, 11,
expect.objectContaining({
modelRoot: '',
targetFolder: '',
useDefaultPaths: null,
useSaveDirAsRoot: false,
})
);
});
});
@@ -0,0 +1,246 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
const { APP_MODULE, API_MODULE, STYLES_MODULE, REGISTRY_MODULE, appMock, apiMock, registeredExtensions } =
vi.hoisted(() => {
const registeredExtensions = [];
const appMock = {
graph: null,
registerExtension: (ext) => registeredExtensions.push(ext),
};
const apiMock = {
clientId: "client-1",
initialClientId: null,
addEventListener: vi.fn(),
};
return {
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
API_MODULE: new URL("../../../scripts/api.js", import.meta.url).pathname,
STYLES_MODULE: new URL("../../../web/comfyui/lm_styles_loader.js", import.meta.url).pathname,
REGISTRY_MODULE: new URL("../../../web/comfyui/workflow_registry.js", import.meta.url).pathname,
appMock,
apiMock,
registeredExtensions,
};
});
vi.mock(APP_MODULE, () => ({ app: appMock }));
vi.mock(API_MODULE, () => ({ api: apiMock }));
vi.mock(STYLES_MODULE, () => ({ ensureLmStyles: vi.fn() }));
function createTextEncodeNode({ linked = false, id = 1 } = {}) {
const textWidget = { name: "text", type: "customtext", value: "old prompt", callback: null };
return {
id,
comfyClass: "CLIPTextEncode",
title: "CLIP Text Encode",
mode: 0,
properties: {},
widgets: [textWidget, { name: "clip", type: "combo" }],
widgets_values: ["old prompt", "clip-1"],
inputs: [
{ name: "text", type: "STRING", widget: textWidget, link: linked ? 101 : null },
{ name: "clip", type: "CLIP", link: null },
],
setDirtyCanvas: vi.fn(),
graph: null,
};
}
function createSubgraph({ id = "sub-1", nodes = [] } = {}) {
const graph = {
id,
_nodes: nodes,
_subgraphs: new Map(),
getNodeById: vi.fn((nodeId) => nodes.find((n) => n.id === nodeId) ?? null),
events: { addEventListener: vi.fn() },
};
for (const node of nodes) {
node.graph = graph;
}
return graph;
}
function createGraph({ nodes = [], subgraphs = [] } = {}) {
const graph = {
id: "root",
_nodes: nodes,
_subgraphs: new Map(),
getNodeById: vi.fn((nodeId) => nodes.find((n) => n.id === nodeId) ?? null),
events: { addEventListener: vi.fn() },
};
for (const subgraph of subgraphs) {
graph._subgraphs.set(subgraph.id, subgraph);
}
for (const node of nodes) {
node.graph = graph;
}
return graph;
}
function lastRegisterPayload(fetchMock) {
const calls = fetchMock.mock.calls.filter(
([url]) => url === "/api/lm/register-nodes"
);
expect(calls.length).toBeGreaterThan(0);
return JSON.parse(calls[calls.length - 1][1].body);
}
describe("LoraManager.WorkflowRegistry", () => {
let extension;
let fetchMock;
beforeEach(async () => {
vi.resetModules();
registeredExtensions.length = 0;
appMock.graph = null;
apiMock.addEventListener.mockClear();
fetchMock = vi.fn().mockResolvedValue({ ok: true });
global.fetch = fetchMock;
await import(REGISTRY_MODULE);
extension = registeredExtensions.find(
(ext) => ext.name === "LoraManager.WorkflowRegistry"
);
expect(extension).toBeDefined();
});
afterEach(() => {
delete global.fetch;
});
describe("refreshRegistry", () => {
it("registers an unconnected CLIPTextEncode as a text target", async () => {
appMock.graph = createGraph({ nodes: [createTextEncodeNode()] });
await extension.refreshRegistry(true);
const body = lastRegisterPayload(fetchMock);
expect(body.nodes).toHaveLength(1);
expect(body.nodes[0].capabilities.has_text_widget).toBe(true);
expect(body.nodes[0].capabilities.text_widget_connected).toBe(false);
});
it("excludes a CLIPTextEncode whose text input is connected", async () => {
appMock.graph = createGraph({ nodes: [createTextEncodeNode({ linked: true })] });
await extension.refreshRegistry(true);
const body = lastRegisterPayload(fetchMock);
expect(body.nodes).toHaveLength(1);
expect(body.nodes[0].capabilities.has_text_widget).toBe(false);
expect(body.nodes[0].capabilities.text_widget_connected).toBe(true);
});
it("registers connection state for nodes inside subgraphs", async () => {
const inner = createTextEncodeNode({ linked: true, id: 7 });
const subgraph = createSubgraph({ id: "sub-1", nodes: [inner] });
appMock.graph = createGraph({ subgraphs: [subgraph] });
await extension.refreshRegistry(true);
const body = lastRegisterPayload(fetchMock);
expect(body.nodes).toHaveLength(1);
expect(body.nodes[0].graph_id).toBe("sub-1");
expect(body.nodes[0].node_id).toBe(7);
expect(body.nodes[0].capabilities.text_widget_connected).toBe(true);
});
it("re-registers when text_widget_connected changes (fingerprint)", async () => {
const node = createTextEncodeNode();
appMock.graph = createGraph({ nodes: [node] });
await extension.refreshRegistry(true);
await extension.refreshRegistry();
expect(
fetchMock.mock.calls.filter(([url]) => url === "/api/lm/register-nodes")
).toHaveLength(1);
node.inputs[0].link = 101;
await extension.refreshRegistry();
const body = lastRegisterPayload(fetchMock);
expect(body.nodes[0].capabilities.text_widget_connected).toBe(true);
});
});
describe("applyWidgetUpdate (inject_text)", () => {
it("updates the widget value when the text input is not connected", async () => {
const node = createTextEncodeNode();
const callback = vi.fn();
node.widgets[0].callback = callback;
appMock.graph = createGraph({ nodes: [node] });
extension.flashWidget = vi.fn();
await extension.applyWidgetUpdate({
node_id: 1,
action: "inject_text",
value: "hello",
mode: "replace",
});
expect(node.widgets[0].value).toBe("hello");
expect(node.widgets_values[0]).toBe("hello");
expect(callback).toHaveBeenCalledWith("hello");
});
it("skips inject_text when the target widget is connected and self-heals the registry", async () => {
const node = createTextEncodeNode({ linked: true });
appMock.graph = createGraph({ nodes: [node] });
extension.flashWidget = vi.fn();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await extension.applyWidgetUpdate({
node_id: 1,
graph_id: "root",
action: "inject_text",
value: "new prompt",
mode: "replace",
});
expect(node.widgets[0].value).toBe("old prompt");
expect(node.widgets_values[0]).toBe("old prompt");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("connected to an input"),
expect.anything(),
expect.anything()
);
await vi.waitFor(() => {
expect(
fetchMock.mock.calls.some(([url]) => url === "/api/lm/register-nodes")
).toBe(true);
});
warnSpy.mockRestore();
});
});
describe("setup link-change hooks", () => {
it("hooks root events, existing subgraphs, and future subgraphs", () => {
const subgraph = createSubgraph({ id: "sub-1", nodes: [] });
const graph = createGraph({ subgraphs: [subgraph] });
appMock.graph = graph;
extension.setup();
expect(graph.events.addEventListener).toHaveBeenCalledWith(
"node:slot-links:changed",
expect.any(Function)
);
expect(graph.events.addEventListener).toHaveBeenCalledWith(
"subgraph-created",
expect.any(Function)
);
expect(subgraph.events.addEventListener).toHaveBeenCalledWith(
"node:slot-links:changed",
expect.any(Function)
);
const createdHandler = graph.events.addEventListener.mock.calls.find(
([name]) => name === "subgraph-created"
)[1];
const laterSubgraph = createSubgraph({ id: "sub-2", nodes: [] });
createdHandler({ subgraph: laterSubgraph });
expect(laterSubgraph.events.addEventListener).toHaveBeenCalledWith(
"node:slot-links:changed",
expect.any(Function)
);
});
});
});
+1 -1
View File
@@ -67,7 +67,7 @@ describe('translate() with real en.json locale', () => {
installWindowI18n();
expect(translate('modals.deleteModel.recoverableWarning')).toBe(
'This will permanently delete the file after 30 seconds unless you undo.',
'This will permanently delete the file after 20 seconds unless you undo.',
);
expect(translate('modals.deleteModel.freesSpace', { size: '1.2 MB' })).toBe(
'Frees 1.2 MB',
+263 -3
View File
@@ -133,14 +133,14 @@ describe('UI helper DOM utilities', () => {
const countdown = toast.querySelector('.toast-countdown');
expect(countdown).not.toBeNull();
expect(countdown.textContent).toBe('(30s)');
expect(countdown.textContent).toBe('(20s)');
// Ticking one second updates the countdown text
vi.advanceTimersByTime(1000);
expect(countdown.textContent).toBe('(29s)');
expect(countdown.textContent).toBe('(19s)');
// Drain remaining timers so no state leaks into other tests
vi.advanceTimersByTime(30000);
vi.advanceTimersByTime(20000);
});
it('invokes onAction once and dismisses immediately when the button is clicked', async () => {
@@ -186,6 +186,34 @@ describe('UI helper DOM utilities', () => {
expect(onAction).toHaveBeenCalledTimes(1);
});
it('dismisses the toast via the close button without firing onAction', async () => {
vi.useFakeTimers();
translateMock.mockReturnValue('Deleted Demo Model');
const { showActionToast } = await import(UI_HELPERS_MODULE);
const onAction = vi.fn();
showActionToast('toast.undo.deleted', {}, 'success', {
actionText: 'Undo',
onAction,
});
const toast = document.querySelector('.toast-container .toast');
const countdown = toast.querySelector('.toast-countdown');
toast.querySelector('.toast-close-btn').click();
expect(onAction).not.toHaveBeenCalled();
expect(toast.classList.contains('show')).toBe(false);
// Advancing past the full duration must not tick the countdown further,
// throw, or re-dismiss the already-dismissed toast
vi.advanceTimersByTime(60000);
expect(countdown.textContent).toBe('(20s)');
toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
expect(document.querySelector('.toast-container .toast')).toBeNull();
});
it('dismisses the toast when the countdown reaches zero', async () => {
vi.useFakeTimers();
translateMock.mockReturnValue('Deleted Demo Model');
@@ -315,6 +343,238 @@ describe('UI helper DOM utilities', () => {
]);
});
it('excludes prompt targets whose text widget is connected to an input', async () => {
const registryResponse = {
success: true,
data: {
node_count: 4,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Free Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: false,
widget_names: ['text', 'clip'],
},
},
'root:2': {
id: 2,
graph_id: 'root',
graph_name: null,
title: 'Wired Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: true,
widget_names: ['text', 'clip'],
},
},
'root:3': {
id: 3,
graph_id: 'root',
graph_name: null,
title: 'Marked But Wired',
type: 'KSampler',
mode: 0,
marker_role: 'send_prompt_target',
capabilities: {
has_text_widget: false,
text_widget_connected: true,
widget_names: ['seed'],
},
},
'root:4': {
id: 4,
graph_id: 'root',
graph_name: null,
title: 'Free Text 2',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: false,
widget_names: ['text', 'clip'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
const { sendPromptToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendPromptToWorkflow('a cat');
expect(result).toBe(true);
const nodeLabels = Array.from(
document.querySelectorAll('#nodeSelector .node-item[data-node-id] span')
).map((span) => span.textContent.trim());
expect(nodeLabels).toEqual(['#1 Free Text', '#4 Free Text 2']);
});
it('returns false when the only prompt target has its text widget connected', async () => {
const registryResponse = {
success: true,
data: {
node_count: 1,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Wired Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: true,
widget_names: ['text', 'clip'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
translateMock.mockReturnValue(
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
);
const { sendPromptToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendPromptToWorkflow('a cat');
expect(result).toBe(false);
expect(document.querySelectorAll('#nodeSelector .node-item').length).toBe(0);
const toast = document.querySelector('.toast-container .toast');
expect(toast).not.toBeNull();
expect(toast.textContent).toContain('Mark as');
expect(toast.textContent).toContain('Send Prompt Target');
});
it('shows the mark-as hint when no embedding target is available', async () => {
const registryResponse = {
success: true,
data: {
node_count: 1,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Wired Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: true,
widget_names: ['text', 'clip'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
translateMock.mockReturnValue(
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
);
const { sendEmbeddingToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendEmbeddingToWorkflow('embeddingcode');
expect(result).toBe(false);
const toast = document.querySelector('.toast-container .toast');
expect(toast).not.toBeNull();
expect(toast.textContent).toContain('Send Prompt Target');
});
it('keeps unconnected marker targets in the prompt candidate list', async () => {
const registryResponse = {
success: true,
data: {
node_count: 2,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Marked Target',
type: 'KSampler',
mode: 0,
marker_role: 'send_prompt_target',
capabilities: {
has_text_widget: false,
text_widget_connected: false,
widget_names: ['seed'],
},
},
'root:2': {
id: 2,
graph_id: 'root',
graph_name: null,
title: 'Marked Target 2',
type: 'KSampler',
mode: 0,
marker_role: 'send_prompt_target',
capabilities: {
has_text_widget: false,
text_widget_connected: false,
widget_names: ['seed'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
const { sendPromptToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendPromptToWorkflow('a cat');
expect(result).toBe(true);
const nodeLabels = Array.from(
document.querySelectorAll('#nodeSelector .node-item[data-node-id] span')
).map((span) => span.textContent.trim());
expect(nodeLabels).toEqual(['#1 Marked Target', '#2 Marked Target 2']);
});
it('opens Civitai links using the preferred host and registers the first-use banner once', async () => {
const openSpy = vi.fn();
globalThis.window.open = openSpy;
@@ -471,6 +471,172 @@ def test_conditioning_provenance_recovers_combined_controlnet_prompts(
assert params["negative_prompt"] == "low quality"
def test_conditioning_provenance_recovers_transformed_switched_prompts(
metadata_registry, monkeypatch
):
prompt_graph = {
"encode_pos": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "expected positive", "clip": ["clip", 0]},
},
"encode_other_pos": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "wrong positive", "clip": ["clip", 0]},
},
"encode_neg": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "expected negative", "clip": ["clip", 0]},
},
"encode_other_neg": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "wrong negative", "clip": ["clip", 0]},
},
"enhancer": {
"class_type": "KreaSeedVarianceEnhancer",
"inputs": {"conditioning": ["encode_pos", 0]},
},
"zero_out": {
"class_type": "ConditioningZeroOut",
"inputs": {"conditioning": ["encode_neg", 0]},
},
"positive_switch": {
"class_type": "ComfySwitchNode",
"inputs": {
"switch": True,
"on_false": ["encode_other_pos", 0],
"on_true": ["enhancer", 0],
},
},
"negative_switch": {
"class_type": "ComfySwitchNode",
"inputs": {
"switch": True,
"on_false": ["encode_other_neg", 0],
"on_true": ["zero_out", 0],
},
},
"sampler": {
"class_type": "ClownsharKSampler_Beta",
"inputs": {
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": ["positive_switch", 0],
"negative": ["negative_switch", 0],
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
},
}
prompt = SimpleNamespace(original_prompt=prompt_graph)
positive_conditioning = object()
other_positive_conditioning = object()
negative_conditioning = object()
other_negative_conditioning = object()
enhanced_conditioning = object()
zeroed_conditioning = object()
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
metadata_registry.start_collection("prompt-transformed-switch")
metadata_registry.set_current_prompt(prompt)
for node_id, text, conditioning in (
("encode_pos", "expected positive", positive_conditioning),
("encode_other_pos", "wrong positive", other_positive_conditioning),
("encode_neg", "expected negative", negative_conditioning),
("encode_other_neg", "wrong negative", other_negative_conditioning),
):
metadata_registry.record_node_execution(
node_id, "CLIPTextEncode", {"text": text}, None
)
metadata_registry.update_node_execution(
node_id, "CLIPTextEncode", [(conditioning,)]
)
metadata_registry.record_node_execution(
"enhancer",
"KreaSeedVarianceEnhancer",
{"conditioning": positive_conditioning},
None,
return_types=("CONDITIONING", "STRING"),
)
metadata_registry.update_node_execution(
"enhancer",
"KreaSeedVarianceEnhancer",
[(enhanced_conditioning, "diagnostics")],
return_types=("CONDITIONING", "STRING"),
)
metadata_registry.record_node_execution(
"zero_out",
"ConditioningZeroOut",
{"conditioning": negative_conditioning},
None,
return_types=("CONDITIONING",),
)
metadata_registry.update_node_execution(
"zero_out",
"ConditioningZeroOut",
[(zeroed_conditioning,)],
return_types=("CONDITIONING",),
)
metadata_registry.record_node_execution(
"positive_switch",
"ComfySwitchNode",
{
"switch": True,
"on_false": other_positive_conditioning,
"on_true": enhanced_conditioning,
},
None,
)
metadata_registry.update_node_execution(
"positive_switch", "ComfySwitchNode", [(enhanced_conditioning,)]
)
metadata_registry.record_node_execution(
"negative_switch",
"ComfySwitchNode",
{
"switch": True,
"on_false": other_negative_conditioning,
"on_true": zeroed_conditioning,
},
None,
)
metadata_registry.update_node_execution(
"negative_switch", "ComfySwitchNode", [(zeroed_conditioning,)]
)
metadata_registry.record_node_execution(
"sampler",
"ClownsharKSampler_Beta",
{
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": enhanced_conditioning,
"negative": zeroed_conditioning,
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
None,
)
metadata = metadata_registry.get_metadata("prompt-transformed-switch")
params = MetadataProcessor.extract_generation_params(metadata)
assert params["prompt"] == "expected positive"
assert params["negative_prompt"] == "expected negative"
def test_conditioning_provenance_recovers_kj_set_get_prompts(
metadata_registry, monkeypatch
):
@@ -233,6 +233,58 @@ async def test_successful_download_uses_defaults(
assert captured["download_urls"] == ["https://example.invalid/file.safetensors"]
@pytest.mark.asyncio
async def test_download_keeps_save_dir_when_use_save_dir_as_root(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""use_default_paths with use_save_dir_as_root resolves the template under
the provided save_dir instead of switching to the default root."""
manager = DownloadManager()
captured = {}
async def fake_execute_download(
self,
*,
download_urls,
save_dir,
metadata,
version_info,
relative_path,
progress_callback,
model_type,
download_id,
transfer_backend=None,
):
captured.update(
{
"save_dir": Path(save_dir),
"relative_path": relative_path,
"model_type": model_type,
}
)
return {"success": True}
monkeypatch.setattr(
DownloadManager, "_execute_download", fake_execute_download, raising=False
)
custom_root = tmp_path / "custom_root"
result = await manager.download_from_civitai(
model_version_id=99,
save_dir=str(custom_root),
use_default_paths=True,
use_save_dir_as_root=True,
progress_callback=None,
source=None,
)
assert result["success"] is True
assert captured["relative_path"] == "MappedModel/fantasy"
assert captured["save_dir"] == custom_root / "MappedModel" / "fantasy"
assert captured["model_type"] == "lora"
@pytest.mark.asyncio
async def test_successful_download_schedules_auto_example_images(
monkeypatch, scanners, metadata_provider, tmp_path
@@ -618,6 +670,7 @@ async def test_resume_download_restores_persisted_aria2_task(monkeypatch, tmp_pa
use_default_paths=False,
source=None,
file_params=None,
use_save_dir_as_root=False,
):
created.update(
{
@@ -1037,6 +1090,7 @@ async def test_download_uses_captured_backend_when_settings_change(
transfer_backend="python",
source=None,
file_params=None,
use_save_dir_as_root=False,
):
captured["transfer_backend"] = transfer_backend
return {"success": True}
+5 -1
View File
@@ -36,7 +36,11 @@ function isMapLike(collection) {
return collection && typeof collection.entries === "function" && typeof collection.values === "function";
}
function getChildGraphs(graph) {
/**
* Return the subgraph LGraph instances nested under `graph`, across both
* Map-like and plain-object `_subgraphs` containers.
*/
export function getChildGraphs(graph) {
if (!graph || !graph._subgraphs) {
return [];
}
+124 -24
View File
@@ -1,6 +1,6 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, chainCallback, getLinkFromGraph } from "./utils.js";
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, getChildGraphs, chainCallback, getLinkFromGraph } from "./utils.js";
import { ensureLmStyles } from "./lm_styles_loader.js";
const DEBOUNCE_DELAY = 500;
@@ -155,6 +155,54 @@ function tryResolvePrimitiveConnection(node, widgetName) {
return isPrimitiveNodeType(originNode) ? originNode : null;
}
/**
* Resolve the widget that "send prompt" targets on `node`: the first
* string-typed widget, falling back to the first non-hidden widget.
* Shared by `isPromptWidgetConnected` and `applyWidgetUpdate` so the
* candidate-set logic and the write path cannot drift apart.
*
* @param {Object} node - LiteGraph node instance
* @returns {Object|null} - the target widget, or null when none is suitable
*/
function resolveTextWidget(node) {
if (!node || !Array.isArray(node.widgets)) {
return null;
}
const TEXT_TYPES = new Set(["string", "customtext"]);
return (
node.widgets.find((w) => {
const t = typeof w?.type === "string" ? w.type.toLowerCase() : "";
return TEXT_TYPES.has(t) || t.includes("string");
}) ??
node.widgets.find((w) => w?.name && !w.name.startsWith("_")) ??
null
);
}
/**
* True when the widget that "send prompt" would update on `node` is backed by
* a connected input ComfyUI execution reads the linked input, so updating
* the widget would be a silent no-op. Such nodes must not be offered as
* prompt/embedding send targets.
*
* @param {Object} node - LiteGraph node instance
* @returns {boolean}
*/
function isPromptWidgetConnected(node) {
if (!node || !Array.isArray(node.inputs)) {
return false;
}
const targetWidget = resolveTextWidget(node);
if (!targetWidget?.name) {
return false;
}
const slotIndex = findInputSlotForWidget(node, targetWidget.name);
return slotIndex >= 0 && node.inputs[slotIndex]?.link != null;
}
app.registerExtension({
name: "LoraManager.WorkflowRegistry",
@@ -209,6 +257,45 @@ app.registerExtension({
this._log("failed to chain LiteGraph hooks: %s", e.message);
}
// Link connect/disconnect changes whether a widget is externally driven
// (e.g. CLIP Text Encode "text" wired to another node), which affects
// the text-send candidate set — re-register on connection changes.
const hookLinkChanges = (targetGraph) => {
if (!targetGraph) {
return false;
}
if (typeof targetGraph.events?.addEventListener === "function") {
targetGraph.events.addEventListener("node:slot-links:changed", () =>
scheduleRefresh("link")
);
return true;
}
// Classic litegraph: structural edits (incl. link connect/disconnect)
// flow through beforeChange/afterChange.
chainCallback(targetGraph, "onAfterChange", () => scheduleRefresh("afterChange"));
return true;
};
try {
if (hookLinkChanges(graph)) {
hooksInstalled += 1;
// Links wired inside a subgraph dispatch on that subgraph's own
// graph events, not the root's — hook existing and future subgraphs.
for (const subgraph of getChildGraphs(graph)) {
if (hookLinkChanges(subgraph)) {
hooksInstalled += 1;
}
}
graph.events?.addEventListener?.("subgraph-created", (event) => {
if (hookLinkChanges(event?.subgraph)) {
hooksInstalled += 1;
}
});
}
} catch (e) {
this._log("failed to hook graph link changes: %s", e.message);
}
if (typeof api.addEventListener === "function") {
try {
api.addEventListener("graphChanged", () => scheduleRefresh("graphChanged"));
@@ -251,6 +338,14 @@ app.registerExtension({
const hasTextWidget = TEXT_CAPABLE_CLASSES.has(node.comfyClass);
const markerRole = node.properties?.lm_marker_role ?? null;
// A prompt-capable node whose text widget is wired to another
// node cannot have its text changed via the widget — execution
// reads the linked input. Drop it from text-send candidates.
const textWidgetConnected =
hasTextWidget || markerRole === "send_prompt_target"
? isPromptWidgetConnected(node)
: false;
if (!supportsLora && !hasTargetWidget && !hasTextWidget && !markerRole) {
continue;
}
@@ -275,7 +370,8 @@ app.registerExtension({
marker_role: markerRole,
capabilities: {
supports_lora: supportsLora,
has_text_widget: hasTextWidget,
has_text_widget: hasTextWidget && !textWidgetConnected,
text_widget_connected: textWidgetConnected,
widget_names: widgetNames,
},
});
@@ -286,8 +382,10 @@ app.registerExtension({
// 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).
// text_widget_connected is part of the fingerprint so that link
// connect/disconnect changes re-register the affected nodes.
const fingerprint = JSON.stringify(
workflowNodes.map(n => `${n.graph_id}:${n.node_id}|${n.marker_role ?? ""}|${n.mode ?? 0}`).sort()
workflowNodes.map(n => `${n.graph_id}:${n.node_id}|${n.marker_role ?? ""}|${n.mode ?? 0}|${n.capabilities.text_widget_connected}`).sort()
);
if (!force && fingerprint === this._lastFingerprint) {
return;
@@ -349,28 +447,30 @@ app.registerExtension({
let targetWidget = null;
if (action === "inject_text") {
// Find the first text-capable widget by type.
// Normalise to lowercase for case-insensitive matching.
const TEXT_TYPES = new Set(["string", "customtext"]);
targetWidget = node.widgets.find((w) => {
const t = typeof w?.type === "string" ? w.type.toLowerCase() : "";
if (TEXT_TYPES.has(t)) return true;
// Broad fallback for unknown composite types.
if (t.includes("string")) {
return true;
}
return false;
});
targetWidget = resolveTextWidget(node);
if (!targetWidget) {
// Last resort: pick the first widget that is not a hidden/internal type
targetWidget = node.widgets.find((w) => w?.name && !w.name.startsWith("_"));
if (!targetWidget) {
console.warn(
"LoRA Manager: no suitable widget for inject_text on node",
node.id
);
return;
}
console.warn(
"LoRA Manager: no suitable widget for inject_text on node",
node.id
);
return;
}
// The widget is backed by a connected input: ComfyUI execution
// reads the linked value, so updating the widget is a no-op.
// Guard against stale registry entries (e.g. a link was just
// connected before the registry refresh debounce elapsed).
const slotIndex = findInputSlotForWidget(node, targetWidget.name);
if (slotIndex >= 0 && node.inputs[slotIndex]?.link != null) {
console.warn(
"LoRA Manager: widget '%s' on node %d is connected to an input; widget value cannot be changed",
targetWidget.name,
node.id
);
// Self-heal the registry so the node drops out of the
// send-target list instead of being offered again.
this.refreshRegistry(true);
return;
}
} else if (widgetName) {
// Legacy: find widget by name