diff --git a/locales/de.json b/locales/de.json index 9f83ccc1..5b64b267 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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", diff --git a/locales/en.json b/locales/en.json index 16c3cb11..9b57e510 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/es.json b/locales/es.json index 730126a0..8121ce00 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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", diff --git a/locales/fr.json b/locales/fr.json index 928e4142..784c4fd3 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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", diff --git a/locales/he.json b/locales/he.json index 0cd9aee7..caec9515 100644 --- a/locales/he.json +++ b/locales/he.json @@ -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": "תיקיית תמונות הדוגמה נפתחה", diff --git a/locales/ja.json b/locales/ja.json index 60c60363..7151d4ee 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -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": "例画像フォルダが開かれました", diff --git a/locales/ko.json b/locales/ko.json index 529c6bc1..467aa3b6 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -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": "예시 이미지 폴더가 열렸습니다", diff --git a/locales/ru.json b/locales/ru.json index fae25ebc..0b6acab5 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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": "Папка с примерами изображений открыта", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 23cd5e12..648e8c08 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -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": "示例图片文件夹已打开", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 1af2b0f7..f7bff8b6 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -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": "範例圖片資料夾已開啟", diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index 72465e84..81241290 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -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")), } # ------------------------------------------------------------------ diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 6749a570..ba64dee4 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -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 diff --git a/static/css/components/menu.css b/static/css/components/menu.css index 24833c52..4bb8e25a 100644 --- a/static/css/components/menu.css +++ b/static/css/components/menu.css @@ -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; diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 4a5bf565..574c8e30 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -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'); diff --git a/static/js/state/index.js b/static/js/state/index.js index faa7c377..a21798c4 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -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. diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index 278f70e6..f175a08a 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -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 + ? `` + : ''; return `
@@ -1430,6 +1480,7 @@ function showNodeSelector(nodes, options = {}) {
#${node.id}${graphLabel} ${node.title} + ${excludeButton} `; }).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 diff --git a/templates/components/modals/settings/general.html b/templates/components/modals/settings/general.html index f29282f9..7560918e 100644 --- a/templates/components/modals/settings/general.html +++ b/templates/components/modals/settings/general.html @@ -360,4 +360,35 @@ + + +
+ {{ sm.subsection_header('settings.sections.sendToWorkflow') }} + +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+
diff --git a/tests/frontend/utils/interceptModeChange.test.js b/tests/frontend/utils/interceptModeChange.test.js index 960ebec8..9cebcb39 100644 --- a/tests/frontend/utils/interceptModeChange.test.js +++ b/tests/frontend/utils/interceptModeChange.test.js @@ -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); + }); + }); }); diff --git a/tests/frontend/utils/uiHelpers.dom.test.js b/tests/frontend/utils/uiHelpers.dom.test.js index db282d9e..1530d683 100644 --- a/tests/frontend/utils/uiHelpers.dom.test.js +++ b/tests/frontend/utils/uiHelpers.dom.test.js @@ -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 = '
'; + + 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 = '
'; + + 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 = '
'; + + 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; diff --git a/tests/routes/test_misc_routes.py b/tests/routes/test_misc_routes.py index ea1b625c..5b545676 100644 --- a/tests/routes/test_misc_routes.py +++ b/tests/routes/test_misc_routes.py @@ -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() diff --git a/web/comfyui/node_marker.js b/web/comfyui/node_marker.js index 0cdff7b5..1e2cd1f0 100644 --- a/web/comfyui/node_marker.js +++ b/web/comfyui/node_marker.js @@ -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), + }, ]; } diff --git a/web/comfyui/utils.js b/web/comfyui/utils.js index 21849982..e84000af 100644 --- a/web/comfyui/utils.js +++ b/web/comfyui/utils.js @@ -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); } }, }); diff --git a/web/comfyui/workflow_registry.js b/web/comfyui/workflow_registry.js index 5c71045a..d1e40a87 100644 --- a/web/comfyui/workflow_registry.js +++ b/web/comfyui/workflow_registry.js @@ -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;