feat(workflow): user-controlled send-to-workflow target filtering

- Global exclusion lists (node classes + title patterns) in settings,
  applied at the shared filterRegistryNodes choke point; settings UI
  section is hidden in standalone mode
- Per-node "Exclude from Send Targets" context-menu toggle persisted
  in the workflow JSON (lm_exclude_send)
- Quick-exclude button on node selector items writes the comfy class
  to the exclusion list
- Precedence: per-node exclusion > explicit marker > global lists
- interceptModeChange now supports multiple listeners; registry hooks
  mode changes so mute/bypass refreshes candidates immediately
This commit is contained in:
Will Miao
2026-10-18 09:42:44 +08:00
parent 43b26f7cc9
commit f8ecc00f6b
23 changed files with 820 additions and 43 deletions
+18 -3
View File
@@ -399,7 +399,8 @@
"autoOrganize": "Auto-Organisierung",
"metadata": "Metadaten",
"sidecarStorage": "Sidecar-Speicherung",
"proxySettings": "Proxy-Einstellungen"
"proxySettings": "Proxy-Einstellungen",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
},
"nav": {
"general": "Allgemein",
@@ -453,6 +454,18 @@
"saveFailed": "Übersprungene Pfade konnten nicht gespeichert werden: {message}"
}
},
"sendToWorkflowExclusions": {
"nodeClasses": {
"label": "[TODO: Translate] Excluded node types",
"placeholder": "[TODO: Translate] Example: CheckpointLoaderSimple, MyCustomNode",
"help": "[TODO: Translate] ComfyUI node class names that never appear as send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
},
"titlePatterns": {
"label": "[TODO: Translate] Excluded node title patterns",
"placeholder": "[TODO: Translate] Example: negative, preview",
"help": "[TODO: Translate] Nodes whose title contains any of these text fragments (case-insensitive) are hidden from send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
}
},
"backup": {
"autoEnabled": "Automatische Backups",
"autoEnabledHelp": "Erstellt einmal täglich einen lokalen Schnappschuss und behält die neuesten Schnappschüsse gemäß der Aufbewahrungsrichtlinie.",
@@ -2322,7 +2335,8 @@
"embeddingAdded": "Embedding zum Workflow hinzugefügt",
"embeddingFailed": "Fehler beim Hinzufügen des Embeddings",
"promptSent": "Prompt an Workflow gesendet",
"promptFailed": "Fehler beim Senden des Prompts"
"promptFailed": "Fehler beim Senden des Prompts",
"nodeTypeExcluded": "[TODO: Translate] Node type \"{type}\" excluded from send targets"
},
"nodeSelector": {
"recipe": "Rezept",
@@ -2332,7 +2346,8 @@
"replace": "Ersetzen",
"append": "Anhängen",
"selectTargetNode": "Zielknoten auswählen",
"sendToAll": "An alle senden"
"sendToAll": "An alle senden",
"excludeNodeType": "[TODO: Translate] Never suggest this node type"
},
"exampleImages": {
"opened": "Beispielbilder-Ordner geöffnet",
+18 -3
View File
@@ -399,7 +399,8 @@
"autoOrganize": "Auto-organize",
"metadata": "Metadata",
"sidecarStorage": "Sidecar Storage",
"proxySettings": "Proxy Settings"
"proxySettings": "Proxy Settings",
"sendToWorkflow": "Send to Workflow"
},
"nav": {
"general": "General",
@@ -453,6 +454,18 @@
"saveFailed": "Unable to save skip paths: {message}"
}
},
"sendToWorkflowExclusions": {
"nodeClasses": {
"label": "Excluded node types",
"placeholder": "Example: CheckpointLoaderSimple, MyCustomNode",
"help": "ComfyUI node class names that never appear as send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
},
"titlePatterns": {
"label": "Excluded node title patterns",
"placeholder": "Example: negative, preview",
"help": "Nodes whose title contains any of these text fragments (case-insensitive) are hidden from send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
}
},
"backup": {
"autoEnabled": "Automatic backups",
"autoEnabledHelp": "Create a local snapshot once per day and keep the latest snapshots according to the retention policy.",
@@ -2322,7 +2335,8 @@
"embeddingAdded": "Embedding added to workflow",
"embeddingFailed": "Failed to add embedding",
"promptSent": "Prompt sent to workflow",
"promptFailed": "Failed to send prompt"
"promptFailed": "Failed to send prompt",
"nodeTypeExcluded": "Node type \"{type}\" excluded from send targets"
},
"nodeSelector": {
"recipe": "Recipe",
@@ -2332,7 +2346,8 @@
"replace": "Replace",
"append": "Append",
"selectTargetNode": "Select target node",
"sendToAll": "Send to All"
"sendToAll": "Send to All",
"excludeNodeType": "Never suggest this node type"
},
"exampleImages": {
"opened": "Example images folder opened",
+18 -3
View File
@@ -399,7 +399,8 @@
"autoOrganize": "Organización automática",
"metadata": "Metadatos",
"sidecarStorage": "Almacenamiento de archivos sidecar",
"proxySettings": "Configuración de proxy"
"proxySettings": "Configuración de proxy",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
},
"nav": {
"general": "General",
@@ -453,6 +454,18 @@
"saveFailed": "No se pudieron guardar las rutas a omitir: {message}"
}
},
"sendToWorkflowExclusions": {
"nodeClasses": {
"label": "[TODO: Translate] Excluded node types",
"placeholder": "[TODO: Translate] Example: CheckpointLoaderSimple, MyCustomNode",
"help": "[TODO: Translate] ComfyUI node class names that never appear as send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
},
"titlePatterns": {
"label": "[TODO: Translate] Excluded node title patterns",
"placeholder": "[TODO: Translate] Example: negative, preview",
"help": "[TODO: Translate] Nodes whose title contains any of these text fragments (case-insensitive) are hidden from send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
}
},
"backup": {
"autoEnabled": "Copias de seguridad automáticas",
"autoEnabledHelp": "Crea una instantánea local una vez al día y conserva las más recientes según la política de retención.",
@@ -2322,7 +2335,8 @@
"embeddingAdded": "Embedding añadido al workflow",
"embeddingFailed": "Error al añadir el embedding",
"promptSent": "Prompt enviado al workflow",
"promptFailed": "Error al enviar el prompt"
"promptFailed": "Error al enviar el prompt",
"nodeTypeExcluded": "[TODO: Translate] Node type \"{type}\" excluded from send targets"
},
"nodeSelector": {
"recipe": "Receta",
@@ -2332,7 +2346,8 @@
"replace": "Reemplazar",
"append": "Añadir",
"selectTargetNode": "Seleccionar nodo de destino",
"sendToAll": "Enviar a todos"
"sendToAll": "Enviar a todos",
"excludeNodeType": "[TODO: Translate] Never suggest this node type"
},
"exampleImages": {
"opened": "Carpeta de imágenes de ejemplo abierta",
+18 -3
View File
@@ -399,7 +399,8 @@
"autoOrganize": "Organisation automatique",
"metadata": "Métadonnées",
"sidecarStorage": "Stockage des fichiers sidecar",
"proxySettings": "Paramètres du proxy"
"proxySettings": "Paramètres du proxy",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
},
"nav": {
"general": "Général",
@@ -453,6 +454,18 @@
"saveFailed": "Impossible d'enregistrer les chemins à ignorer : {message}"
}
},
"sendToWorkflowExclusions": {
"nodeClasses": {
"label": "[TODO: Translate] Excluded node types",
"placeholder": "[TODO: Translate] Example: CheckpointLoaderSimple, MyCustomNode",
"help": "[TODO: Translate] ComfyUI node class names that never appear as send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
},
"titlePatterns": {
"label": "[TODO: Translate] Excluded node title patterns",
"placeholder": "[TODO: Translate] Example: negative, preview",
"help": "[TODO: Translate] Nodes whose title contains any of these text fragments (case-insensitive) are hidden from send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
}
},
"backup": {
"autoEnabled": "Sauvegardes automatiques",
"autoEnabledHelp": "Crée un instantané local une fois par jour et conserve les plus récents selon la politique de rétention.",
@@ -2322,7 +2335,8 @@
"embeddingAdded": "Embedding ajouté au workflow",
"embeddingFailed": "Échec de l'ajout de l'embedding",
"promptSent": "Prompt envoyé au workflow",
"promptFailed": "Échec de l'envoi du prompt"
"promptFailed": "Échec de l'envoi du prompt",
"nodeTypeExcluded": "[TODO: Translate] Node type \"{type}\" excluded from send targets"
},
"nodeSelector": {
"recipe": "Recipe",
@@ -2332,7 +2346,8 @@
"replace": "Remplacer",
"append": "Ajouter",
"selectTargetNode": "Sélectionner le nœud cible",
"sendToAll": "Envoyer à tous"
"sendToAll": "Envoyer à tous",
"excludeNodeType": "[TODO: Translate] Never suggest this node type"
},
"exampleImages": {
"opened": "Dossier d'images d'exemple ouvert",
+18 -3
View File
@@ -399,7 +399,8 @@
"autoOrganize": "ארגון אוטומטי",
"metadata": "מטא-נתונים",
"sidecarStorage": "אחסון קובצי לוואי",
"proxySettings": "הגדרות פרוקסי"
"proxySettings": "הגדרות פרוקסי",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
},
"nav": {
"general": "כללי",
@@ -453,6 +454,18 @@
"saveFailed": "לא ניתן לשמור נתיבי דילוג: {message}"
}
},
"sendToWorkflowExclusions": {
"nodeClasses": {
"label": "[TODO: Translate] Excluded node types",
"placeholder": "[TODO: Translate] Example: CheckpointLoaderSimple, MyCustomNode",
"help": "[TODO: Translate] ComfyUI node class names that never appear as send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
},
"titlePatterns": {
"label": "[TODO: Translate] Excluded node title patterns",
"placeholder": "[TODO: Translate] Example: negative, preview",
"help": "[TODO: Translate] Nodes whose title contains any of these text fragments (case-insensitive) are hidden from send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
}
},
"backup": {
"autoEnabled": "גיבויים אוטומטיים",
"autoEnabledHelp": "יוצר צילום מצב מקומי פעם ביום ושומר את הצילומים האחרונים לפי מדיניות השמירה.",
@@ -2322,7 +2335,8 @@
"embeddingAdded": "Embedding נוסף ל-workflow",
"embeddingFailed": "הוספת Embedding נכשלה",
"promptSent": "פרומפט נשלחה ל-workflow",
"promptFailed": "שליחת הפרומפט נכשלה"
"promptFailed": "שליחת הפרומפט נכשלה",
"nodeTypeExcluded": "[TODO: Translate] Node type \"{type}\" excluded from send targets"
},
"nodeSelector": {
"recipe": "מתכון",
@@ -2332,7 +2346,8 @@
"replace": "החלף",
"append": "הוסף",
"selectTargetNode": "בחר צומת יעד",
"sendToAll": "שלח לכל"
"sendToAll": "שלח לכל",
"excludeNodeType": "[TODO: Translate] Never suggest this node type"
},
"exampleImages": {
"opened": "תיקיית תמונות הדוגמה נפתחה",
+18 -3
View File
@@ -399,7 +399,8 @@
"autoOrganize": "自動整理",
"metadata": "メタデータ",
"sidecarStorage": "サイドカーファイルの保存",
"proxySettings": "プロキシ設定"
"proxySettings": "プロキシ設定",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
},
"nav": {
"general": "一般",
@@ -453,6 +454,18 @@
"saveFailed": "スキップパスの保存に失敗しました:{message}"
}
},
"sendToWorkflowExclusions": {
"nodeClasses": {
"label": "[TODO: Translate] Excluded node types",
"placeholder": "[TODO: Translate] Example: CheckpointLoaderSimple, MyCustomNode",
"help": "[TODO: Translate] ComfyUI node class names that never appear as send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
},
"titlePatterns": {
"label": "[TODO: Translate] Excluded node title patterns",
"placeholder": "[TODO: Translate] Example: negative, preview",
"help": "[TODO: Translate] Nodes whose title contains any of these text fragments (case-insensitive) are hidden from send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
}
},
"backup": {
"autoEnabled": "自動バックアップ",
"autoEnabledHelp": "1日1回ローカルのスナップショットを作成し、保持ポリシーに従って最新のものを残します。",
@@ -2322,7 +2335,8 @@
"embeddingAdded": "Embeddingをワークフローに追加しました",
"embeddingFailed": "Embeddingの追加に失敗しました",
"promptSent": "プロンプトをワークフローに送信しました",
"promptFailed": "プロンプトの送信に失敗しました"
"promptFailed": "プロンプトの送信に失敗しました",
"nodeTypeExcluded": "[TODO: Translate] Node type \"{type}\" excluded from send targets"
},
"nodeSelector": {
"recipe": "レシピ",
@@ -2332,7 +2346,8 @@
"replace": "置換",
"append": "追加",
"selectTargetNode": "ターゲットノードを選択",
"sendToAll": "すべてに送信"
"sendToAll": "すべてに送信",
"excludeNodeType": "[TODO: Translate] Never suggest this node type"
},
"exampleImages": {
"opened": "例画像フォルダが開かれました",
+18 -3
View File
@@ -399,7 +399,8 @@
"autoOrganize": "자동 정리",
"metadata": "메타데이터",
"sidecarStorage": "사이드카 파일 저장",
"proxySettings": "프록시 설정"
"proxySettings": "프록시 설정",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
},
"nav": {
"general": "일반",
@@ -453,6 +454,18 @@
"saveFailed": "건너뛰기 경로를 저장할 수 없습니다: {message}"
}
},
"sendToWorkflowExclusions": {
"nodeClasses": {
"label": "[TODO: Translate] Excluded node types",
"placeholder": "[TODO: Translate] Example: CheckpointLoaderSimple, MyCustomNode",
"help": "[TODO: Translate] ComfyUI node class names that never appear as send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
},
"titlePatterns": {
"label": "[TODO: Translate] Excluded node title patterns",
"placeholder": "[TODO: Translate] Example: negative, preview",
"help": "[TODO: Translate] Nodes whose title contains any of these text fragments (case-insensitive) are hidden from send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
}
},
"backup": {
"autoEnabled": "자동 백업",
"autoEnabledHelp": "하루에 한 번 로컬 스냅샷을 만들고 보존 정책에 따라 최신 스냅샷을 유지합니다.",
@@ -2322,7 +2335,8 @@
"embeddingAdded": "Embedding을 워크플로에 추가했습니다",
"embeddingFailed": "Embedding 추가 실패",
"promptSent": "프롬프트를 워크플로에 보냈습니다",
"promptFailed": "프롬프트 보내기 실패"
"promptFailed": "프롬프트 보내기 실패",
"nodeTypeExcluded": "[TODO: Translate] Node type \"{type}\" excluded from send targets"
},
"nodeSelector": {
"recipe": "레시피",
@@ -2332,7 +2346,8 @@
"replace": "교체",
"append": "추가",
"selectTargetNode": "대상 노드 선택",
"sendToAll": "모두에게 전송"
"sendToAll": "모두에게 전송",
"excludeNodeType": "[TODO: Translate] Never suggest this node type"
},
"exampleImages": {
"opened": "예시 이미지 폴더가 열렸습니다",
+18 -3
View File
@@ -399,7 +399,8 @@
"autoOrganize": "Автоорганизация",
"metadata": "Метаданные",
"sidecarStorage": "Хранилище sidecar-файлов",
"proxySettings": "Настройки прокси"
"proxySettings": "Настройки прокси",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
},
"nav": {
"general": "Общее",
@@ -453,6 +454,18 @@
"saveFailed": "Не удалось сохранить пути для пропуска: {message}"
}
},
"sendToWorkflowExclusions": {
"nodeClasses": {
"label": "[TODO: Translate] Excluded node types",
"placeholder": "[TODO: Translate] Example: CheckpointLoaderSimple, MyCustomNode",
"help": "[TODO: Translate] ComfyUI node class names that never appear as send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
},
"titlePatterns": {
"label": "[TODO: Translate] Excluded node title patterns",
"placeholder": "[TODO: Translate] Example: negative, preview",
"help": "[TODO: Translate] Nodes whose title contains any of these text fragments (case-insensitive) are hidden from send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
}
},
"backup": {
"autoEnabled": "Автоматические резервные копии",
"autoEnabledHelp": "Создаёт локальный снимок раз в день и хранит последние снимки согласно политике хранения.",
@@ -2322,7 +2335,8 @@
"embeddingAdded": "Embedding добавлен в workflow",
"embeddingFailed": "Не удалось добавить embedding",
"promptSent": "Промпт отправлен в workflow",
"promptFailed": "Не удалось отправить промпт"
"promptFailed": "Не удалось отправить промпт",
"nodeTypeExcluded": "[TODO: Translate] Node type \"{type}\" excluded from send targets"
},
"nodeSelector": {
"recipe": "Рецепт",
@@ -2332,7 +2346,8 @@
"replace": "Заменить",
"append": "Добавить",
"selectTargetNode": "Выберите целевой узел",
"sendToAll": "Отправить во все"
"sendToAll": "Отправить во все",
"excludeNodeType": "[TODO: Translate] Never suggest this node type"
},
"exampleImages": {
"opened": "Папка с примерами изображений открыта",
+18 -3
View File
@@ -399,7 +399,8 @@
"autoOrganize": "自动整理",
"metadata": "元数据",
"sidecarStorage": "附属文件存储",
"proxySettings": "代理设置"
"proxySettings": "代理设置",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
},
"nav": {
"general": "通用",
@@ -453,6 +454,18 @@
"saveFailed": "无法保存跳过路径:{message}"
}
},
"sendToWorkflowExclusions": {
"nodeClasses": {
"label": "[TODO: Translate] Excluded node types",
"placeholder": "[TODO: Translate] Example: CheckpointLoaderSimple, MyCustomNode",
"help": "[TODO: Translate] ComfyUI node class names that never appear as send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
},
"titlePatterns": {
"label": "[TODO: Translate] Excluded node title patterns",
"placeholder": "[TODO: Translate] Example: negative, preview",
"help": "[TODO: Translate] Nodes whose title contains any of these text fragments (case-insensitive) are hidden from send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
}
},
"backup": {
"autoEnabled": "自动备份",
"autoEnabledHelp": "每天创建一次本地快照,并按保留策略保留最新快照。",
@@ -2322,7 +2335,8 @@
"embeddingAdded": "Embedding 已追加到工作流",
"embeddingFailed": "添加 Embedding 失败",
"promptSent": "提示词已发送到工作流",
"promptFailed": "提示词发送失败"
"promptFailed": "提示词发送失败",
"nodeTypeExcluded": "[TODO: Translate] Node type \"{type}\" excluded from send targets"
},
"nodeSelector": {
"recipe": "配方",
@@ -2332,7 +2346,8 @@
"replace": "替换",
"append": "追加",
"selectTargetNode": "选择目标节点",
"sendToAll": "全部发送"
"sendToAll": "全部发送",
"excludeNodeType": "[TODO: Translate] Never suggest this node type"
},
"exampleImages": {
"opened": "示例图片文件夹已打开",
+18 -3
View File
@@ -399,7 +399,8 @@
"autoOrganize": "自動整理",
"metadata": "中繼資料",
"sidecarStorage": "附屬檔案儲存",
"proxySettings": "代理設定"
"proxySettings": "代理設定",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
},
"nav": {
"general": "通用",
@@ -453,6 +454,18 @@
"saveFailed": "無法儲存跳過路徑:{message}"
}
},
"sendToWorkflowExclusions": {
"nodeClasses": {
"label": "[TODO: Translate] Excluded node types",
"placeholder": "[TODO: Translate] Example: CheckpointLoaderSimple, MyCustomNode",
"help": "[TODO: Translate] ComfyUI node class names that never appear as send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
},
"titlePatterns": {
"label": "[TODO: Translate] Excluded node title patterns",
"placeholder": "[TODO: Translate] Example: negative, preview",
"help": "[TODO: Translate] Nodes whose title contains any of these text fragments (case-insensitive) are hidden from send-to-workflow targets. Separate multiple entries with commas or semicolons. Nodes explicitly marked in ComfyUI (right-click → Mark as) are exempt."
}
},
"backup": {
"autoEnabled": "自動備份",
"autoEnabledHelp": "每天建立一次本地快照,並依保留政策保留最新快照。",
@@ -2322,7 +2335,8 @@
"embeddingAdded": "Embedding 已附加到工作流",
"embeddingFailed": "傳送 Embedding 到工作流失敗",
"promptSent": "提示詞已發送到工作流",
"promptFailed": "提示詞發送失敗"
"promptFailed": "提示詞發送失敗",
"nodeTypeExcluded": "[TODO: Translate] Node type \"{type}\" excluded from send targets"
},
"nodeSelector": {
"recipe": "配方",
@@ -2332,7 +2346,8 @@
"replace": "取代",
"append": "附加",
"selectTargetNode": "選擇目標節點",
"sendToAll": "全部傳送"
"sendToAll": "全部傳送",
"excludeNodeType": "[TODO: Translate] Never suggest this node type"
},
"exampleImages": {
"opened": "範例圖片資料夾已開啟",
+1
View File
@@ -617,6 +617,7 @@ class NodeRegistry:
"widget_names": widget_names,
"mode": node.get("mode"),
"marker_role": node.get("marker_role"),
"exclude_send": bool(node.get("exclude_send")),
}
# ------------------------------------------------------------------
+4
View File
@@ -140,6 +140,10 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"price_check_ttl_hours": 24,
"auto_organize_exclusions": [],
"metadata_refresh_skip_paths": [],
# Send-to-Workflow target filtering: candidate nodes whose comfy class or
# title matches these lists are hidden from the target selector. Nodes
# explicitly marked via right-click ("Mark as") are exempt.
"send_to_workflow_exclusions": {"node_classes": [], "title_patterns": []},
"skip_previously_downloaded_model_versions": False,
"download_skip_base_models": [],
# Ordered per-type rules routing downloads to a non-default root by base
+24
View File
@@ -290,6 +290,30 @@
text-align: center;
}
/* Per-item "exclude this node type" action in the node selector */
.node-item-exclude {
margin-left: auto;
flex-shrink: 0;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
opacity: 0;
transition: opacity 0.15s, background-color 0.15s;
}
.node-item:hover .node-item-exclude {
opacity: 1;
}
.node-item-exclude:hover {
background: rgba(0, 0, 0, 0.25);
color: var(--text-on-accent);
}
/* Node Selector Header */
.node-selector-header {
padding: 10px 15px;
+96
View File
@@ -226,6 +226,10 @@ export class SettingsManager {
backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths
);
merged.send_to_workflow_exclusions = this.normalizeSendToWorkflowExclusions(
backendSettings?.send_to_workflow_exclusions ?? defaults.send_to_workflow_exclusions
);
merged.skip_previously_downloaded_model_versions =
backendSettings?.skip_previously_downloaded_model_versions
?? defaults.skip_previously_downloaded_model_versions;
@@ -278,6 +282,14 @@ export class SettingsManager {
return [];
}
normalizeSendToWorkflowExclusions(value) {
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
return {
node_classes: this.normalizePatternList(source.node_classes),
title_patterns: this.normalizePatternList(source.title_patterns),
};
}
getAvailableDownloadSkipBaseModels() {
// Use dynamic base models if available, fallback to hardcoded
const models = getMappableBaseModelsDynamic();
@@ -588,6 +600,18 @@ export class SettingsManager {
});
}
for (const inputId of ['sendToWorkflowExcludedClasses', 'sendToWorkflowExcludedTitles']) {
const input = document.getElementById(inputId);
if (input) {
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.saveSendToWorkflowExclusions();
}
});
}
}
const downloadSkipBaseModelsContainer = document.getElementById('downloadSkipBaseModelsContainer');
if (downloadSkipBaseModelsContainer) {
downloadSkipBaseModelsContainer.addEventListener('change', (event) => {
@@ -1175,6 +1199,27 @@ export class SettingsManager {
metadataRefreshSkipPathsError.textContent = '';
}
const sendToWorkflowExclusions = this.normalizeSendToWorkflowExclusions(
state.global.settings.send_to_workflow_exclusions
);
const sendToWorkflowExcludedClassesInput = document.getElementById('sendToWorkflowExcludedClasses');
if (sendToWorkflowExcludedClassesInput) {
sendToWorkflowExcludedClassesInput.value = sendToWorkflowExclusions.node_classes.join(', ');
}
const sendToWorkflowExcludedClassesError = document.getElementById('sendToWorkflowExcludedClassesError');
if (sendToWorkflowExcludedClassesError) {
sendToWorkflowExcludedClassesError.textContent = '';
}
const sendToWorkflowExcludedTitlesInput = document.getElementById('sendToWorkflowExcludedTitles');
if (sendToWorkflowExcludedTitlesInput) {
sendToWorkflowExcludedTitlesInput.value = sendToWorkflowExclusions.title_patterns.join(', ');
}
const sendToWorkflowExcludedTitlesError = document.getElementById('sendToWorkflowExcludedTitlesError');
if (sendToWorkflowExcludedTitlesError) {
sendToWorkflowExcludedTitlesError.textContent = '';
}
this.updateSendToWorkflowSectionVisibility();
this.renderDownloadSkipBaseModels();
const downloadSkipBaseModelsError = document.getElementById('downloadSkipBaseModelsError');
if (downloadSkipBaseModelsError) {
@@ -4269,6 +4314,57 @@ export class SettingsManager {
}
}
/**
* Show or hide the Send to Workflow settings section. The feature targets a
* live ComfyUI workflow, so the section is hidden in standalone mode.
*/
updateSendToWorkflowSectionVisibility() {
const subsection = document.getElementById('sendToWorkflowSubsection');
if (subsection) {
subsection.style.display = state.global.settings.standalone_mode ? 'none' : '';
}
}
async saveSendToWorkflowExclusions() {
const classesInput = document.getElementById('sendToWorkflowExcludedClasses');
const titlesInput = document.getElementById('sendToWorkflowExcludedTitles');
if (!classesInput && !titlesInput) return;
const normalized = {
node_classes: this.normalizePatternList(classesInput ? classesInput.value : ''),
title_patterns: this.normalizePatternList(titlesInput ? titlesInput.value : ''),
};
const current = this.normalizeSendToWorkflowExclusions(
state.global.settings.send_to_workflow_exclusions
);
const unchanged =
normalized.node_classes.join('|') === current.node_classes.join('|') &&
normalized.title_patterns.join('|') === current.title_patterns.join('|');
if (unchanged) {
return;
}
try {
await this.saveSetting('send_to_workflow_exclusions', normalized);
if (classesInput) {
classesInput.value = normalized.node_classes.join(', ');
}
if (titlesInput) {
titlesInput.value = normalized.title_patterns.join(', ');
}
showToast(
'toast.settings.settingsUpdated',
{ setting: translate('settings.sections.sendToWorkflow') },
'success'
);
} catch (error) {
console.error('Failed to save send-to-workflow exclusions:', error);
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
async saveAutoOrganizeExclusions() {
const input = document.getElementById('autoOrganizeExclusions');
const errorElement = document.getElementById('autoOrganizeExclusionsError');
+2
View File
@@ -63,6 +63,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
price_check_ttl_hours: 24,
auto_organize_exclusions: [],
metadata_refresh_skip_paths: [],
send_to_workflow_exclusions: { node_classes: [], title_patterns: [] },
skip_previously_downloaded_model_versions: false,
download_skip_base_models: [],
download_root_rules: { loras: [], checkpoints: [], unet: [], embeddings: [] },
@@ -91,6 +92,7 @@ export function createDefaultSettings() {
default_other_roots: {},
download_root_rules: { loras: [], checkpoints: [], unet: [], embeddings: [] },
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'],
send_to_workflow_exclusions: { node_classes: [], title_patterns: [] },
// Standalone-only fields populated by GET /api/lm/settings; in plugin
// mode the backend omits folder_paths/folder_path_schema and these
// defaults apply.
+114
View File
@@ -728,6 +728,48 @@ async function fetchWorkflowRegistry() {
}
}
/**
* Check whether a registry node is excluded from send-to-workflow targets.
*
* Precedence:
* 1. Per-node exclusion (right-click → "Exclude from Send Targets") always wins.
* 2. An explicit per-node marker ("Mark as →") exempts the node from the
* global exclusion lists — the user pointed at this exact node.
* 3. Global exclusion lists from settings: exact comfy-class matches and
* case-insensitive title substrings.
*/
function isNodeExcludedFromSendTargets(node) {
if (!node) {
return false;
}
if (node.exclude_send === true) {
return true;
}
if (node.marker_role) {
return false;
}
const exclusions = state?.global?.settings?.send_to_workflow_exclusions;
if (!exclusions || typeof exclusions !== 'object') {
return false;
}
const nodeClasses = Array.isArray(exclusions.node_classes) ? exclusions.node_classes : [];
const nodeClass = node.comfy_class || node.type_name;
if (nodeClass && nodeClasses.includes(nodeClass)) {
return true;
}
const titlePatterns = Array.isArray(exclusions.title_patterns) ? exclusions.title_patterns : [];
const title = typeof node.title === 'string' ? node.title.toLowerCase() : '';
return titlePatterns.some((pattern) => {
if (typeof pattern !== 'string' || !pattern.trim()) {
return false;
}
return title.includes(pattern.trim().toLowerCase());
});
}
function filterRegistryNodes(nodes = {}, predicate) {
if (typeof nodes !== 'object' || nodes === null) {
return {};
@@ -735,6 +777,9 @@ function filterRegistryNodes(nodes = {}, predicate) {
return Object.fromEntries(
Object.entries(nodes).filter(([, node]) => {
if (isNodeExcludedFromSendTargets(node)) {
return false;
}
try {
return predicate(node);
} catch (error) {
@@ -1423,6 +1468,11 @@ function showNodeSelector(nodes, options = {}) {
const iconClass = NODE_TYPE_ICONS[node.type] || 'fas fa-question-circle';
const bgColor = node.bgcolor || DEFAULT_NODE_COLOR;
const graphLabel = node.graph_name ? ` (${node.graph_name})` : '';
const nodeClass = node.comfy_class || node.type_name || '';
const excludeTitle = translate('uiHelpers.nodeSelector.excludeNodeType', {}, 'Never suggest this node type');
const excludeButton = nodeClass
? `<button type="button" class="node-item-exclude" data-exclude-class="${nodeClass.replace(/"/g, '&quot;')}" title="${excludeTitle}"><i class="fas fa-eye-slash"></i></button>`
: '';
return `
<div class="node-item" data-node-id="${nodeKey}">
@@ -1430,6 +1480,7 @@ function showNodeSelector(nodes, options = {}) {
<i class="${iconClass}"></i>
</div>
<span>#${node.id}${graphLabel} ${node.title}</span>
${excludeButton}
</div>
`;
}).join('');
@@ -1473,6 +1524,62 @@ function showNodeSelector(nodes, options = {}) {
setupNodeSelectorEvents(selector);
}
/**
* Add a comfy node class to the send-to-workflow exclusion list (persisted via
* settings), then drop matching items from the open node selector.
*/
async function excludeNodeClassFromSelector(comfyClass) {
if (!comfyClass) {
return;
}
const current = state?.global?.settings?.send_to_workflow_exclusions;
const updated = {
node_classes: Array.isArray(current?.node_classes) ? [...current.node_classes] : [],
title_patterns: Array.isArray(current?.title_patterns) ? [...current.title_patterns] : [],
};
if (!updated.node_classes.includes(comfyClass)) {
updated.node_classes.push(comfyClass);
}
try {
const response = await fetch('/api/lm/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ send_to_workflow_exclusions: updated }),
});
const data = await response.json();
if (!response.ok || data.success === false) {
throw new Error(data?.error || 'Failed to save setting');
}
} catch (error) {
console.error('Failed to save send-to-workflow exclusion:', error);
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
return;
}
state.global.settings.send_to_workflow_exclusions = updated;
showToast('uiHelpers.workflow.nodeTypeExcluded', { type: comfyClass }, 'success');
// Remove matching candidates from the open selector.
const nodes = nodeSelectorState.currentNodes || {};
for (const [nodeKey, node] of Object.entries(nodes)) {
if ((node.comfy_class || node.type_name) === comfyClass) {
delete nodes[nodeKey];
}
}
document.querySelectorAll('#nodeSelector .node-item-exclude').forEach((button) => {
if (button.dataset.excludeClass === comfyClass) {
button.closest('.node-item')?.remove();
}
});
if (Object.keys(nodes).length === 0) {
hideNodeSelector();
showToast('uiHelpers.workflow.noSupportedNodes', {}, 'warning');
}
}
/**
* Setup event listeners for node selector using event manager
* @param {HTMLElement} selector - The selector element
@@ -1494,6 +1601,13 @@ function setupNodeSelectorEvents(selector) {
// Register node selection handler with event manager
eventManager.addHandler('click', 'nodeSelector-selection', async (e) => {
const excludeButton = e.target.closest('.node-item-exclude');
if (excludeButton) {
e.stopPropagation();
await excludeNodeClassFromSelector(excludeButton.dataset.excludeClass);
return true; // Stop propagation
}
const nodeItem = e.target.closest('.node-item');
if (!nodeItem) return false; // Continue with other handlers
@@ -360,4 +360,35 @@
</div>
</div>
</div>
<!-- Send to Workflow (hidden in standalone mode: no ComfyUI to send to) -->
<div class="settings-subsection" id="sendToWorkflowSubsection">
{{ sm.subsection_header('settings.sections.sendToWorkflow') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="sendToWorkflowExcludedClasses">
{{ t('settings.sendToWorkflowExclusions.nodeClasses.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.sendToWorkflowExclusions.nodeClasses.help') }}"></i>
</label>
</div>
</div>
<textarea id="sendToWorkflowExcludedClasses" class="priority-tags-input auto-organize-exclusions-input" placeholder="{{ t('settings.sendToWorkflowExclusions.nodeClasses.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="sendToWorkflowExcludedClassesError"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="sendToWorkflowExcludedTitles">
{{ t('settings.sendToWorkflowExclusions.titlePatterns.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.sendToWorkflowExclusions.titlePatterns.help') }}"></i>
</label>
</div>
</div>
<textarea id="sendToWorkflowExcludedTitles" class="priority-tags-input auto-organize-exclusions-input" placeholder="{{ t('settings.sendToWorkflowExclusions.titlePatterns.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="sendToWorkflowExcludedTitlesError"></div>
</div>
</div>
</div>
@@ -96,4 +96,39 @@ describe("interceptModeChange", () => {
expect(descriptor.configurable).toBe(true);
});
});
describe("multiple listeners", () => {
it("notifies every registered listener while intercepting only once", () => {
const node = { mode: 0 };
const first = vi.fn();
const second = vi.fn();
interceptModeChange(node, first);
interceptModeChange(node, second);
node.mode = 2;
expect(first).toHaveBeenCalledWith(2, 0);
expect(second).toHaveBeenCalledWith(2, 0);
// The second registration must not replace the accessor: the value is
// still tracked across registrations.
node.mode = 4;
expect(first).toHaveBeenCalledWith(4, 2);
expect(second).toHaveBeenCalledWith(4, 2);
});
it("keeps notifying remaining listeners when one of them throws", () => {
const node = { mode: 0 };
const failing = vi.fn(() => {
throw new Error("boom");
});
const healthy = vi.fn();
interceptModeChange(node, failing);
interceptModeChange(node, healthy);
node.mode = 2;
expect(failing).toHaveBeenCalledWith(2, 0);
expect(healthy).toHaveBeenCalledWith(2, 0);
expect(node.mode).toBe(2);
});
});
});
+194
View File
@@ -575,6 +575,200 @@ describe('UI helper DOM utilities', () => {
expect(nodeLabels).toEqual(['#1 Marked Target', '#2 Marked Target 2']);
});
it('hides node classes listed in send_to_workflow_exclusions', async () => {
const { state } = await import(STATE_MODULE);
state.global = {
settings: {
send_to_workflow_exclusions: {
node_classes: ['PulIDFluxModelLoader'],
title_patterns: [],
},
},
};
const makeLoaderNode = (id, title, comfyClass) => ({
id,
graph_id: 'root',
graph_name: null,
title,
type: 0,
type_name: comfyClass,
comfy_class: comfyClass,
mode: 0,
marker_role: null,
capabilities: { widget_names: ['ckpt_name'] },
});
const registryResponse = {
success: true,
data: {
node_count: 3,
nodes: {
'root:1': makeLoaderNode(1, 'Main Checkpoint', 'CheckpointLoaderSimple'),
'root:2': makeLoaderNode(2, 'Auxiliary Loader', 'PulIDFluxModelLoader'),
'root:3': makeLoaderNode(3, 'Backup Checkpoint', 'CheckpointLoaderSimple'),
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
const { sendModelPathToWorkflow } = await import(UI_HELPERS_MODULE);
try {
const result = await sendModelPathToWorkflow('foo/model.safetensors', {
widgetName: 'ckpt_name',
});
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 Main Checkpoint', '#3 Backup Checkpoint']);
} finally {
delete state.global;
}
});
it('hides nodes whose title matches an exclusion pattern (case-insensitive)', async () => {
const { state } = await import(STATE_MODULE);
state.global = {
settings: {
send_to_workflow_exclusions: {
node_classes: [],
title_patterns: ['negative'],
},
},
};
const makeTextNode = (id, title) => ({
id,
graph_id: 'root',
graph_name: null,
title,
type: 'CLIPTextEncode',
comfy_class: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: false,
widget_names: ['text', 'clip'],
},
});
const registryResponse = {
success: true,
data: {
node_count: 2,
nodes: {
'root:1': makeTextNode(1, 'Positive Prompt'),
'root:2': makeTextNode(2, 'Negative Prompt'),
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
const { sendPromptToWorkflow } = await import(UI_HELPERS_MODULE);
try {
await sendPromptToWorkflow('a cat');
const nodeLabels = Array.from(
document.querySelectorAll('#nodeSelector .node-item[data-node-id] span')
).map((span) => span.textContent.trim());
expect(nodeLabels).toEqual([]);
// Single remaining target sends directly without opening the selector.
const sendCalls = global.fetch.mock.calls.filter(
([url]) => url === '/api/lm/update-node-widget'
);
expect(sendCalls).toHaveLength(1);
} finally {
delete state.global;
}
});
it('keeps explicitly marked nodes exempt from global exclusions, while per-node exclusion always wins', async () => {
const { state } = await import(STATE_MODULE);
state.global = {
settings: {
send_to_workflow_exclusions: {
node_classes: ['KSampler'],
title_patterns: [],
},
},
};
const makeNode = (id, title, overrides = {}) => ({
id,
graph_id: 'root',
graph_name: null,
title,
type: 'KSampler',
comfy_class: 'KSampler',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: false,
text_widget_connected: false,
widget_names: ['seed'],
},
...overrides,
});
const registryResponse = {
success: true,
data: {
node_count: 3,
nodes: {
// Class is excluded, but the explicit marker wins.
'root:1': makeNode(1, 'Marked Target', { marker_role: 'send_prompt_target' }),
// No marker: filtered out by the class exclusion.
'root:2': makeNode(2, 'Plain KSampler'),
// Marker present, but per-node exclusion beats everything.
'root:3': makeNode(3, 'Marked But Excluded', {
marker_role: 'send_prompt_target',
exclude_send: true,
}),
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
const { sendPromptToWorkflow } = await import(UI_HELPERS_MODULE);
try {
await sendPromptToWorkflow('a cat');
// Only node 1 survives → single-target direct send, no selector.
const sendCalls = global.fetch.mock.calls.filter(
([url]) => url === '/api/lm/update-node-widget'
);
expect(sendCalls).toHaveLength(1);
const body = JSON.parse(sendCalls[0][1].body);
expect(body.node_ids).toEqual([{ node_id: 1, graph_id: 'root' }]);
} finally {
delete state.global;
}
});
it('opens Civitai links using the preferred host and registers the first-use banner once', async () => {
const openSpy = vi.fn();
globalThis.window.open = openSpy;
+41
View File
@@ -1221,6 +1221,47 @@ async def test_register_nodes_defaults_graph_name_to_none():
assert stored_node["graph_name"] is None
@pytest.mark.asyncio
async def test_register_nodes_passes_through_exclude_send():
node_registry = NodeRegistry()
handler = NodeRegistryHandler(
node_registry=node_registry,
prompt_server=FakePromptServer, # pyright: ignore[reportArgumentType]
standalone_mode=False,
)
request = FakeRequest(
json_data={
"nodes": [
{
"node_id": 10,
"graph_id": "root",
"type": "CLIPTextEncode",
"title": "Excluded Encoder",
"exclude_send": True,
},
{
"node_id": 11,
"graph_id": "root",
"type": "CLIPTextEncode",
"title": "Normal Encoder",
},
],
"client_id": "test-client-1",
}
)
response = await handler.register_nodes(request) # pyright: ignore[reportArgumentType]
payload = _json_payload(response)
assert payload["success"] is True
registry = await node_registry.get_merged_registry()
nodes = registry["nodes"]
assert nodes["root:10"]["exclude_send"] is True
assert nodes["root:11"]["exclude_send"] is False
@pytest.mark.asyncio
async def test_register_nodes_includes_capabilities():
node_registry = NodeRegistry()
+30
View File
@@ -7,6 +7,10 @@ import { app } from "../../scripts/app.js";
// Roles are stored in ``node.properties.lm_marker_role`` and automatically
// persist with the workflow JSON.
//
// A separate "Exclude from Send Targets" toggle stores
// ``node.properties.lm_exclude_send``: such nodes never appear as send
// targets, regardless of capabilities or markers.
//
// Two categories:
// send_* – consumed by the standalone UI's "Send to Workflow" feature
// meta_* – consumed by the metadata processor to override heuristic inference
@@ -104,6 +108,27 @@ function triggerRegistryRefresh() {
window.dispatchEvent(new CustomEvent("lm_marker_changed"));
}
// ---- Send-target exclusion toggle -------------------------------------------
function isExcludedFromSend(node) {
return node?.properties?.lm_exclude_send === true;
}
function toggleExcludeFromSend(node) {
if (!node) return;
node.properties = node.properties || {};
if (node.properties.lm_exclude_send) {
delete node.properties.lm_exclude_send;
} else {
node.properties.lm_exclude_send = true;
}
if (typeof node.setDirtyCanvas === "function") {
node.setDirtyCanvas(true, true);
}
triggerRegistryRefresh();
}
// ---- Submenu builder --------------------------------------------------------
function buildSubmenuOptions(node) {
@@ -137,6 +162,7 @@ function buildSubmenuOptions(node) {
}
function buildMenuItems(node) {
const excluded = isExcludedFromSend(node);
return [
null,
{
@@ -146,6 +172,10 @@ function buildMenuItems(node) {
options: buildSubmenuOptions(node),
},
},
{
content: `${excluded ? "\u2713 " : ""}\uD83D\uDEAB Exclude from Send Targets`,
callback: () => toggleExcludeFromSend(node),
},
];
}
+37 -2
View File
@@ -217,10 +217,45 @@ function findModeAccessor(node) {
* When a prototype accessor exists we delegate to it and only observe the
* change; on legacy frontends we keep the value in a closure as before.
*
* Multiple listeners can be registered on the same node; the accessor is
* intercepted only once and every change is broadcast to all listeners.
*
* @param {Object} node - The litegraph node instance
* @param {(newMode: number, oldMode: number) => void} onModeChange - Called when the mode actually changes
*/
export function interceptModeChange(node, onModeChange) {
if (!node || typeof onModeChange !== "function") {
return;
}
if (!Array.isArray(node._lmModeChangeListeners)) {
Object.defineProperty(node, "_lmModeChangeListeners", {
configurable: true,
writable: true,
value: [],
});
}
node._lmModeChangeListeners.push(onModeChange);
if (node._lmModeChangeIntercepted) {
return;
}
Object.defineProperty(node, "_lmModeChangeIntercepted", {
configurable: true,
writable: true,
value: true,
});
const notify = (newMode, oldMode) => {
for (const listener of node._lmModeChangeListeners ?? []) {
try {
listener(newMode, oldMode);
} catch (listenerError) {
console.error("LoRA Manager: mode change listener failed", listenerError);
}
}
};
const delegate = findModeAccessor(node);
if (delegate && typeof delegate.get === "function" && typeof delegate.set === "function") {
@@ -233,7 +268,7 @@ export function interceptModeChange(node, onModeChange) {
const oldValue = delegate.get.call(this);
delegate.set.call(this, value);
if (oldValue !== value) {
onModeChange(value, oldValue);
notify(value, oldValue);
}
},
});
@@ -250,7 +285,7 @@ export function interceptModeChange(node, onModeChange) {
const oldValue = currentMode;
currentMode = value;
if (oldValue !== value) {
onModeChange(value, oldValue);
notify(value, oldValue);
}
},
});
+31 -11
View File
@@ -1,7 +1,7 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { lmUrl } from "./base_path.js";
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, getChildGraphs, chainCallback, getLinkFromGraph } from "./utils.js";
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, getChildGraphs, chainCallback, getLinkFromGraph, interceptModeChange } from "./utils.js";
import { ensureLmStyles } from "./lm_styles_loader.js";
const DEBOUNCE_DELAY = 500;
@@ -244,15 +244,7 @@ app.registerExtension({
let hooksInstalled = 0;
const scheduleRefresh = (source) => {
if (this._debounceTimer != null) {
clearTimeout(this._debounceTimer);
}
this._debounceTimer = setTimeout(() => {
this._debounceTimer = null;
this.refreshRegistry();
}, DEBOUNCE_DELAY);
};
const scheduleRefresh = (source) => this._scheduleRefresh(source);
try {
chainCallback(graph, "onNodeAdded", () => scheduleRefresh("onNodeAdded"));
@@ -313,6 +305,30 @@ app.registerExtension({
this._log("%s proactive hooks installed on graph", hooksInstalled);
},
_scheduleRefresh(source) {
if (this._debounceTimer != null) {
clearTimeout(this._debounceTimer);
}
this._debounceTimer = setTimeout(() => {
this._debounceTimer = null;
this.refreshRegistry();
}, DEBOUNCE_DELAY);
},
/**
* Observe mute/bypass toggles on a candidate node so the registry's
* `mode` field (consumed by the web UI's enabled-filter) stays fresh.
* interceptModeChange supports multiple listeners per node, so this
* composes with the Lora Loader's own mode observation.
*/
_ensureModeHook(node) {
if (!node || node.__lmRegistryModeHooked) {
return;
}
node.__lmRegistryModeHooked = true;
interceptModeChange(node, () => this._scheduleRefresh("mode"));
},
_log(format, ...args) {
const ts = new Date().toISOString().slice(11, 23);
let msg = format;
@@ -342,6 +358,7 @@ app.registerExtension({
const hasTargetWidget = widgetNames.some((name) => TARGET_WIDGET_NAMES.has(name));
const hasTextWidget = TEXT_CAPABLE_CLASSES.has(node.comfyClass);
const markerRole = node.properties?.lm_marker_role ?? null;
const excludeSend = node.properties?.lm_exclude_send === true;
// A prompt-capable node whose text widget is wired to another
// node cannot have its text changed via the widget — execution
@@ -360,6 +377,8 @@ app.registerExtension({
continue;
}
this._ensureModeHook(node);
const graphName =
typeof graph?.name === "string" && graph.name.trim() ? graph.name : null;
@@ -373,6 +392,7 @@ app.registerExtension({
comfy_class: node.comfyClass,
mode: node.mode,
marker_role: markerRole,
exclude_send: excludeSend,
capabilities: {
supports_lora: supportsLora,
has_text_widget: hasTextWidget && !textWidgetConnected,
@@ -390,7 +410,7 @@ app.registerExtension({
// 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}|${n.capabilities.text_widget_connected}`).sort()
workflowNodes.map(n => `${n.graph_id}:${n.node_id}|${n.marker_role ?? ""}|${n.exclude_send ? 1 : 0}|${n.mode ?? 0}|${n.capabilities.text_widget_connected}`).sort()
);
if (!force && fingerprint === this._lastFingerprint) {
return;