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:
+18
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "範例圖片資料夾已開啟",
|
||||
|
||||
@@ -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")),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, '"')}" 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user