From 2f344e9e1a859dbf0437eea1bec6be1e2a136fac Mon Sep 17 00:00:00 2001
From: LAOGOU-666 <441059767@qq.com>
Date: Thu, 20 Mar 2025 02:51:06 +0800
Subject: [PATCH] update
---
__init__.py | 28 +
py/lgutils.py | 140 +++++
py/trans.py | 498 ++++++++++++++++
web/groupexecutor.js | 527 +++++++++++++++++
web/groupexecutorUI.js | 969 +++++++++++++++++++++++++++++++
web/groupexecutorqueuemanager.js | 187 ++++++
web/groupexecutorsender.js | 284 +++++++++
7 files changed, 2633 insertions(+)
create mode 100644 __init__.py
create mode 100644 py/lgutils.py
create mode 100644 py/trans.py
create mode 100644 web/groupexecutor.js
create mode 100644 web/groupexecutorUI.js
create mode 100644 web/groupexecutorqueuemanager.js
create mode 100644 web/groupexecutorsender.js
diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000..a90ab2c
--- /dev/null
+++ b/__init__.py
@@ -0,0 +1,28 @@
+from .py.lgutils import *
+from .py.trans import *
+
+WEB_DIRECTORY = "web"
+
+NODE_CLASS_MAPPINGS = {
+ "GroupExecutorSingle": GroupExecutorSingle,
+ "GroupExecutorSender": GroupExecutorSender,
+ "GroupExecutorRepeater": GroupExecutorRepeater,
+ "MemoryCleanup": MemoryCleanup,
+ "LG_ImageSender": LG_ImageSender,
+ "LG_ImageReceiver": LG_ImageReceiver,
+ "ImageListSplitter": ImageListSplitter,
+ "MaskListSplitter": MaskListSplitter,
+ "ImageListRepeater": ImageListRepeater,
+ "MaskListRepeater": MaskListRepeater,
+}
+NODE_DISPLAY_NAME_MAPPINGS = {
+ "GroupExecutorSingle": "🎈GroupExecutorSingle",
+ "GroupExecutorSender": "🎈GroupExecutorSender",
+ "GroupExecutorRepeater": "🎈GroupExecutorRepeater",
+ "MemoryCleanup": "🎈Memory-Cleanup",
+ "LG_ImageSender": "🎈LG_ImageSender",
+ "LG_ImageReceiver": "🎈LG_ImageReceiver",
+ "ImageListSplitter": "🎈List-Image-Splitter",
+ "MaskListSplitter": "🎈List-Mask-Splitter",
+ "ImageListRepeater": "🎈List-Image-Repeater",
+}
diff --git a/py/lgutils.py b/py/lgutils.py
new file mode 100644
index 0000000..b7f2ade
--- /dev/null
+++ b/py/lgutils.py
@@ -0,0 +1,140 @@
+from server import PromptServer
+class GroupExecutorSingle:
+ """单组执行节点"""
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "group_name": ("STRING", {"default": "", "multiline": False}),
+ "repeat_count": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}),
+ "delay_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 60.0, "step": 0.1}),
+ },
+ "optional": {
+ "signal": ("SIGNAL",),
+ },
+ "hidden": {
+ "unique_id": "UNIQUE_ID"
+ }
+ }
+
+ RETURN_TYPES = ("SIGNAL",)
+ FUNCTION = "execute_group"
+ CATEGORY = "🎈LAOGOU"
+
+ def execute_group(self, group_name, repeat_count, delay_seconds, unique_id, signal=None):
+ try:
+
+ current_execution = {
+ "group_name": group_name,
+ "repeat_count": repeat_count,
+ "delay_seconds": delay_seconds
+ }
+
+ # 如果有信号输入
+ if signal is not None:
+ if isinstance(signal, list):
+
+ signal.append(current_execution)
+ return (signal,)
+ else:
+ return ([signal, current_execution],)
+
+ return (current_execution,)
+
+ except Exception as e:
+ return ({"error": str(e)},)
+
+class GroupExecutorSender:
+ """执行信号发送节点"""
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "signal": ("SIGNAL",),
+ },
+ "hidden": {
+ "unique_id": "UNIQUE_ID"
+ }
+ }
+
+ RETURN_TYPES = ()
+ FUNCTION = "execute"
+ CATEGORY = "🎈LAOGOU"
+ OUTPUT_NODE = True
+
+ def execute(self, signal, unique_id):
+ try:
+ if not signal:
+ raise ValueError("没有收到执行信号")
+
+ execution_list = signal if isinstance(signal, list) else [signal]
+
+ PromptServer.instance.send_sync(
+ "execute_group_list", {
+ "node_id": unique_id,
+ "execution_list": execution_list
+ }
+ )
+
+ return ()
+
+ except Exception as e:
+ print(f"执行错误: {str(e)}")
+ return ()
+
+class GroupExecutorRepeater:
+ """执行列表重复处理节点"""
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "signal": ("SIGNAL",),
+ "repeat_count": ("INT", {
+ "default": 1,
+ "min": 1,
+ "max": 100,
+ "step": 1
+ }),
+ "group_delay": ("FLOAT", {
+ "default": 0.0,
+ "min": 0.0,
+ "max": 300.0,
+ "step": 0.1
+ }),
+ },
+ }
+
+ RETURN_TYPES = ("SIGNAL",)
+ FUNCTION = "repeat"
+ CATEGORY = "🎈LAOGOU"
+
+ def repeat(self, signal, repeat_count, group_delay):
+ try:
+ if not signal:
+ raise ValueError("没有收到执行信号")
+
+ execution_list = signal if isinstance(signal, list) else [signal]
+
+ repeated_list = []
+ for i in range(repeat_count):
+
+ repeated_list.extend(execution_list)
+
+ if i < repeat_count - 1:
+
+ repeated_list.append({
+ "group_name": "__delay__",
+ "repeat_count": 1,
+ "delay_seconds": group_delay
+ })
+
+ return (repeated_list,)
+
+ except Exception as e:
+ print(f"重复处理错误: {str(e)}")
+ return ([],)
+
+
diff --git a/py/trans.py b/py/trans.py
new file mode 100644
index 0000000..c9c289e
--- /dev/null
+++ b/py/trans.py
@@ -0,0 +1,498 @@
+from server import PromptServer
+import os
+import sys
+import time
+import torch
+import numpy as np
+from PIL import Image
+import folder_paths
+
+class AnyType(str):
+ """用于表示任意类型的特殊类,在类型比较时总是返回相等"""
+ def __eq__(self, _) -> bool:
+ return True
+
+ def __ne__(self, __value: object) -> bool:
+ return False
+
+any = AnyType("*")
+
+class MemoryCleanup:
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "anything": (any, {}),
+ "offload_model": ("BOOLEAN", {"default": True}),
+ "offload_cache": ("BOOLEAN", {"default": True}),
+ },
+ "optional": {},
+ "hidden": {
+ "unique_id": "UNIQUE_ID",
+ "extra_pnginfo": "EXTRA_PNGINFO",
+ }
+ }
+
+ RETURN_TYPES = (any,)
+ RETURN_NAMES = ("output",)
+ OUTPUT_NODE = True
+ FUNCTION = "empty_cache"
+ CATEGORY = "Memory Management"
+
+ def empty_cache(self, anything, offload_model, offload_cache, unique_id=None, extra_pnginfo=None):
+ try:
+ # 发送信号到前端
+ PromptServer.instance.send_sync("memory_cleanup", {
+ "type": "cleanup_request",
+ "data": {
+ "unload_models": offload_model,
+ "free_memory": offload_cache
+ }
+ })
+ print("已发送内存清理信号")
+
+ except Exception as e:
+ print(f"发送内存清理信号出错: {str(e)}")
+
+ return (anything,)
+
+class LG_ImageSender:
+ def __init__(self):
+ self.output_dir = folder_paths.get_temp_directory()
+ self.type = "temp"
+ self.compress_level = 1
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "images": ("IMAGE", {"tooltip": "要发送的图像"}),
+ "filename_prefix": ("STRING", {"default": "lg_send"}),
+ "link_id": ("INT", {"default": 1, "min": 0, "max": sys.maxsize, "step": 1, "tooltip": "发送端连接ID"}),
+ "trigger_always": ("BOOLEAN", {"default": False, "tooltip": "开启后每次都会触发"})
+ },
+ "optional": {
+ "masks": ("MASK", {"tooltip": "要发送的遮罩"})
+ },
+ "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
+ }
+
+ RETURN_TYPES = ()
+ OUTPUT_NODE = True
+ FUNCTION = "save_images"
+ CATEGORY = "🎈LAOGOU"
+ INPUT_IS_LIST = True
+
+ @classmethod
+ def IS_CHANGED(s, images, filename_prefix, link_id, trigger_always, masks=None, prompt=None, extra_pnginfo=None):
+ if isinstance(trigger_always, list):
+ trigger_always = trigger_always[0]
+
+ if trigger_always:
+ return float("NaN")
+
+ # 同时考虑图像和遮罩的变化
+ hash_value = hash(str(images) + str(masks))
+ return hash_value
+
+ def save_images(self, images, filename_prefix, link_id, trigger_always, masks=None, prompt=None, extra_pnginfo=None):
+ timestamp = int(time.time() * 1000)
+ results = list()
+
+ # 获取实际的值
+ filename_prefix = filename_prefix[0] if isinstance(filename_prefix, list) else filename_prefix
+ link_id = link_id[0] if isinstance(link_id, list) else link_id
+ trigger_always = trigger_always[0] if isinstance(trigger_always, list) else trigger_always
+
+ for idx, image_batch in enumerate(images):
+ try:
+ image = image_batch.squeeze()
+ # 转换为PIL图像
+ rgb_image = Image.fromarray(np.clip(255. * image.cpu().numpy(), 0, 255).astype(np.uint8))
+
+ # 获取对应的遮罩或创建空遮罩
+ if masks is not None and idx < len(masks):
+ mask = masks[idx].squeeze()
+ mask_img = Image.fromarray(np.clip(255. * (1 - mask.cpu().numpy()), 0, 255).astype(np.uint8))
+ else:
+ mask_img = Image.new('L', rgb_image.size, 255)
+
+ # 合并RGB和遮罩为RGBA
+ r, g, b = rgb_image.convert('RGB').split()
+ rgba_image = Image.merge('RGBA', (r, g, b, mask_img))
+
+ # 保存RGBA图像
+ filename = f"{filename_prefix}_{link_id}_{timestamp}_{idx}.png"
+ file_path = os.path.join(self.output_dir, filename)
+ print(f"[ImageSender] 发送图像: {filename}")
+
+ rgba_image.save(file_path, compress_level=self.compress_level)
+
+ results.append({
+ "filename": filename,
+ "subfolder": "",
+ "type": self.type
+ })
+
+ except Exception as e:
+ print(f"[ImageSender] 处理图像 {idx+1} 时出错: {str(e)}")
+ import traceback
+ traceback.print_exc()
+ continue
+
+ if results:
+ PromptServer.instance.send_sync("img-send", {
+ "link_id": link_id,
+ "images": results
+ })
+
+ return { "ui": { "images": results } }
+
+class LG_ImageReceiver:
+ @classmethod
+ def INPUT_TYPES(s):
+ temp_dir = folder_paths.get_temp_directory()
+ files = [f for f in os.listdir(temp_dir) if os.path.isfile(os.path.join(temp_dir, f))]
+
+ return {
+ "required": {
+ "image": ("STRING", {"default": "", "multiline": False, "tooltip": "多个文件名用逗号分隔"}),
+ "link_id": ("INT", {"default": 1, "min": 0, "max": sys.maxsize, "step": 1, "tooltip": "发送端连接ID"}),
+ }
+ }
+
+ CATEGORY = "🎈LAOGOU"
+ RETURN_TYPES = ("IMAGE", "MASK")
+ RETURN_NAMES = ("images", "masks")
+ OUTPUT_IS_LIST = (True, True)
+ FUNCTION = "load_image"
+
+ def load_image(self, image, link_id):
+ image_files = [x.strip() for x in image.split(',') if x.strip()]
+ print(f"[ImageReceiver] 加载图像: {image_files}")
+
+ output_images = []
+ output_masks = []
+
+ if not image_files:
+ empty_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
+ empty_mask = torch.zeros((1, 64, 64), dtype=torch.float32)
+ return ([empty_image], [empty_mask])
+
+ temp_dir = folder_paths.get_temp_directory()
+
+ for img_file in image_files:
+ try:
+ img_path = os.path.join(temp_dir, img_file)
+
+ if not os.path.exists(img_path):
+ continue
+
+ # 加载RGBA图像
+ img = Image.open(img_path)
+
+ if img.mode == 'RGBA':
+ # 分离RGB和Alpha通道
+ r, g, b, a = img.split()
+ # 合并RGB通道
+ rgb_image = Image.merge('RGB', (r, g, b))
+ # 转换为tensor
+ image = np.array(rgb_image).astype(np.float32) / 255.0
+ image = torch.from_numpy(image)[None,]
+ # 转换alpha为mask,并确保维度是 (B, H, W)
+ mask = np.array(a).astype(np.float32) / 255.0
+ mask = torch.from_numpy(mask)[None,] # 添加batch维度
+ # 反转遮罩值
+ mask = 1.0 - mask
+ else:
+ # 如果不是RGBA,按原来的方式处理
+ image = np.array(img.convert('RGB')).astype(np.float32) / 255.0
+ image = torch.from_numpy(image)[None,]
+ mask = torch.zeros((1, image.shape[1], image.shape[2]), dtype=torch.float32, device="cpu")
+
+ output_images.append(image)
+ output_masks.append(mask)
+
+ except Exception as e:
+ print(f"[ImageReceiver] 处理文件 {img_file} 时出错: {str(e)}")
+ import traceback
+ traceback.print_exc()
+ continue
+
+ return (output_images, output_masks)
+
+class ImageListSplitter:
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "images": ("IMAGE",),
+ "indices": ("STRING", {
+ "default": "",
+ "multiline": False,
+ "tooltip": "输入要提取的图片索引,用逗号分隔,如:0,1,3,4"
+ }),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ RETURN_NAMES = ("images",)
+ FUNCTION = "split_images"
+ CATEGORY = "🎈LAOGOU"
+
+ INPUT_IS_LIST = True
+ OUTPUT_IS_LIST = (True,) # (images,)
+
+ def split_images(self, images, indices):
+ try:
+ # 解析索引字符串
+ try:
+ if isinstance(indices, list):
+ indices = indices[0] if indices else ""
+ indices = [int(idx.strip()) for idx in indices.split(',') if idx.strip()]
+ except ValueError:
+ print("[ImageSplitter] 索引格式错误,请使用逗号分隔的数字")
+ return ([],)
+
+ # 确保images是列表
+ if not isinstance(images, list):
+ images = [images]
+
+ # 处理批量图片的情况
+ if len(images) == 1 and len(images[0].shape) == 4: # [B, H, W, C]
+ batch_images = images[0]
+ total_images = batch_images.shape[0]
+ print(f"[ImageSplitter] 检测到批量图片,总数: {total_images}")
+
+ selected_images = []
+ for idx in indices:
+ if 0 <= idx < total_images:
+ # 保持批次维度,使用unsqueeze确保维度为 [1, H, W, C]
+ img = batch_images[idx].unsqueeze(0)
+ selected_images.append(img)
+ print(f"[ImageSplitter] 从批量中选择第 {idx} 张图片")
+ else:
+ print(f"[ImageSplitter] 索引 {idx} 超出批量范围 0-{total_images-1}")
+
+ if not selected_images:
+ return ([],)
+ return (selected_images,)
+
+ # 处理图片列表的情况
+ total_images = len(images)
+ print(f"[ImageSplitter] 检测到图片列表,总数: {total_images}")
+
+ if total_images == 0:
+ print("[ImageSplitter] 没有输入图片")
+ return ([],)
+
+ selected_images = []
+ for idx in indices:
+ if 0 <= idx < total_images:
+ selected_image = images[idx]
+ # 确保输出维度为 [1, H, W, C]
+ if len(selected_image.shape) == 3: # [H, W, C]
+ selected_image = selected_image.unsqueeze(0)
+ selected_images.append(selected_image)
+ print(f"[ImageSplitter] 从列表中选择第 {idx} 张图片")
+ else:
+ print(f"[ImageSplitter] 索引 {idx} 超出列表范围 0-{total_images-1}")
+
+ if not selected_images:
+ return ([],)
+ return (selected_images,)
+
+ except Exception as e:
+ print(f"[ImageSplitter] 处理出错: {str(e)}")
+ return ([],)
+
+class MaskListSplitter:
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "masks": ("MASK",),
+ "indices": ("STRING", {
+ "default": "",
+ "multiline": False,
+ "tooltip": "输入要提取的遮罩索引,用逗号分隔,如:0,1,3,4"
+ }),
+ },
+ }
+
+ RETURN_TYPES = ("MASK",)
+ RETURN_NAMES = ("masks",)
+ FUNCTION = "split_masks"
+ CATEGORY = "🎈LAOGOU"
+
+ INPUT_IS_LIST = True
+ OUTPUT_IS_LIST = (True,) # (masks,)
+
+ def split_masks(self, masks, indices):
+ try:
+ # 解析索引字符串
+ try:
+ if isinstance(indices, list):
+ indices = indices[0] if indices else ""
+ indices = [int(idx.strip()) for idx in indices.split(',') if idx.strip()]
+ except ValueError:
+ print("[MaskSplitter] 索引格式错误,请使用逗号分隔的数字")
+ return ([],)
+
+ # 确保masks是列表
+ if not isinstance(masks, list):
+ masks = [masks]
+
+ # 处理批量遮罩的情况
+ if len(masks) == 1 and len(masks[0].shape) == 3: # [B, H, W]
+ batch_masks = masks[0]
+ total_masks = batch_masks.shape[0]
+ print(f"[MaskSplitter] 检测到批量遮罩,总数: {total_masks}")
+
+ selected_masks = []
+ for idx in indices:
+ if 0 <= idx < total_masks:
+ selected_masks.append(batch_masks[idx].unsqueeze(0))
+ print(f"[MaskSplitter] 从批量中选择第 {idx} 个遮罩")
+ else:
+ print(f"[MaskSplitter] 索引 {idx} 超出批量范围 0-{total_masks-1}")
+
+ if not selected_masks:
+ return ([],)
+ return (selected_masks,)
+
+ # 处理遮罩列表的情况
+ total_masks = len(masks)
+ print(f"[MaskSplitter] 检测到遮罩列表,总数: {total_masks}")
+
+ if total_masks == 0:
+ print("[MaskSplitter] 没有输入遮罩")
+ return ([],)
+
+ selected_masks = []
+ for idx in indices:
+ if 0 <= idx < total_masks:
+ selected_mask = masks[idx]
+ if len(selected_mask.shape) == 2: # [H, W]
+ selected_mask = selected_mask.unsqueeze(0)
+ elif len(selected_mask.shape) != 3: # 不是 [B, H, W]
+ print(f"[MaskSplitter] 不支持的遮罩维度: {selected_mask.shape}")
+ continue
+ selected_masks.append(selected_mask)
+ print(f"[MaskSplitter] 从列表中选择第 {idx} 个遮罩")
+ else:
+ print(f"[MaskSplitter] 索引 {idx} 超出列表范围 0-{total_masks-1}")
+
+ if not selected_masks:
+ return ([],)
+ return (selected_masks,)
+
+ except Exception as e:
+ print(f"[MaskSplitter] 处理出错: {str(e)}")
+ return ([],)
+
+class ImageListRepeater:
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "images": ("IMAGE",),
+ "repeat_times": ("INT", {
+ "default": 1,
+ "min": 1,
+ "max": 100,
+ "step": 1,
+ "tooltip": "每张图片重复的次数"
+ }),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ RETURN_NAMES = ("images",)
+ FUNCTION = "repeat_images"
+ CATEGORY = "🎈LAOGOU"
+
+ INPUT_IS_LIST = True
+ OUTPUT_IS_LIST = (True,)
+
+ def repeat_images(self, images, repeat_times):
+ try:
+ # 处理 repeat_times 参数
+ if isinstance(repeat_times, list):
+ repeat_times = repeat_times[0] if repeat_times else 1
+
+ # 确保images是列表
+ if not isinstance(images, list):
+ images = [images]
+
+ if len(images) == 0:
+ print("[ImageRepeater] 没有输入图片")
+ return ([],)
+
+ # 创建重复后的图片列表
+ repeated_images = []
+ for idx, img in enumerate(images):
+ for _ in range(int(repeat_times)): # 确保 repeat_times 是整数
+ repeated_images.append(img)
+ print(f"[ImageRepeater] 图片 {idx} 重复 {repeat_times} 次")
+
+ print(f"[ImageRepeater] 输入 {len(images)} 张图片,输出 {len(repeated_images)} 张图片")
+ return (repeated_images,)
+
+ except Exception as e:
+ print(f"[ImageRepeater] 处理出错: {str(e)}")
+ return ([],)
+
+class MaskListRepeater:
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "masks": ("MASK",),
+ "repeat_times": ("INT", {
+ "default": 1,
+ "min": 1,
+ "max": 100,
+ "step": 1,
+ "tooltip": "每张遮罩重复的次数"
+ }),
+ },
+ }
+
+ RETURN_TYPES = ("MASK",)
+ RETURN_NAMES = ("masks",)
+ FUNCTION = "repeat_masks"
+ CATEGORY = "🎈LAOGOU"
+
+ INPUT_IS_LIST = True
+ OUTPUT_IS_LIST = (True,)
+
+ def repeat_masks(self, masks, repeat_times):
+ try:
+ # 处理 repeat_times 参数
+ if isinstance(repeat_times, list):
+ repeat_times = repeat_times[0] if repeat_times else 1
+
+ # 确保masks是列表
+ if not isinstance(masks, list):
+ masks = [masks]
+
+ if len(masks) == 0:
+ print("[MaskRepeater] 没有输入遮罩")
+ return ([],)
+
+ # 创建重复后的遮罩列表
+ repeated_masks = []
+ for idx, mask in enumerate(masks):
+ for _ in range(int(repeat_times)): # 确保 repeat_times 是整数
+ repeated_masks.append(mask)
+ print(f"[MaskRepeater] 遮罩 {idx} 重复 {repeat_times} 次")
+
+ print(f"[MaskRepeater] 输入 {len(masks)} 个遮罩,输出 {len(repeated_masks)} 个遮罩")
+ return (repeated_masks,)
+
+ except Exception as e:
+ print(f"[MaskRepeater] 处理出错: {str(e)}")
+ return ([],)
+
+
diff --git a/web/groupexecutor.js b/web/groupexecutor.js
new file mode 100644
index 0000000..e7f48bc
--- /dev/null
+++ b/web/groupexecutor.js
@@ -0,0 +1,527 @@
+import { app } from "../../scripts/app.js";
+import { ComfyWidgets } from "../../scripts/widgets.js";
+import { api } from "../../scripts/api.js";
+
+class BaseNode extends LGraphNode {
+ static defaultComfyClass = "BaseNode";
+ constructor(title, comfyClass) {
+ super(title);
+ this.isVirtualNode = false;
+ this.configuring = false;
+ this.__constructed__ = false;
+ this.widgets = this.widgets || [];
+ this.properties = this.properties || {};
+
+ this.comfyClass = comfyClass || this.constructor.comfyClass || BaseNode.defaultComfyClass;
+ setTimeout(() => {
+ this.checkAndRunOnConstructed();
+ });
+ }
+
+ checkAndRunOnConstructed() {
+ if (!this.__constructed__) {
+ this.onConstructed();
+ }
+ return this.__constructed__;
+ }
+
+ onConstructed() {
+ if (this.__constructed__) return false;
+ this.type = this.type ?? undefined;
+ this.__constructed__ = true;
+ return this.__constructed__;
+ }
+
+ configure(info) {
+ this.configuring = true;
+ super.configure(info);
+ for (const w of this.widgets || []) {
+ w.last_y = w.last_y || 0;
+ }
+ this.configuring = false;
+ }
+ static setUp() {
+ if (!this.type) {
+ throw new Error(`Missing type for ${this.name}: ${this.title}`);
+ }
+ LiteGraph.registerNodeType(this.type, this);
+ if (this._category) {
+ this.category = this._category;
+ }
+ }
+}
+
+class GroupExecutorNode extends BaseNode {
+ static type = "GroupExecutor";
+ static title = "Group Executor";
+ static category = "🎈LAOGOU";
+ static _category = "🎈LAOGOU";
+
+ constructor(title = GroupExecutorNode.title) {
+ super(title, null);
+
+ this.isVirtualNode = true;
+ this.addProperty("groupCount", 1, "int");
+ this.addProperty("groups", [], "array");
+ this.addProperty("isExecuting", false, "boolean");
+ this.addProperty("repeatCount", 1, "int");
+ this.addProperty("delaySeconds", 0, "number"); // 新增:延迟时间属性
+
+ // 组数量组件
+ const groupCountWidget = ComfyWidgets["INT"](this, "groupCount", ["INT", {
+ min: 1,
+ max: 10,
+ step: 1,
+ default: 1
+ }], app);
+
+ // 重复次数组件
+ const repeatCountWidget = ComfyWidgets["INT"](this, "repeatCount", ["INT", {
+ min: 1,
+ max: 100,
+ step: 1,
+ default: 1,
+ label: "Repeat Count",
+ tooltip: "执行重复次数"
+ }], app);
+
+ // 新增:延迟时间组件
+ const delayWidget = ComfyWidgets["FLOAT"](this, "delaySeconds", ["FLOAT", {
+ min: 0,
+ max: 300, // 最大300秒
+ step: 0.1,
+ default: 0,
+ label: "Delay (s)",
+ tooltip: "队列之间的延迟时间(秒)"
+ }], app);
+
+ // 调整组件顺序
+ if (repeatCountWidget.widget && delayWidget.widget) {
+ const widgets = [repeatCountWidget.widget, delayWidget.widget];
+ widgets.forEach((widget, index) => {
+ const widgetIndex = this.widgets.indexOf(widget);
+ if (widgetIndex !== -1) {
+ const w = this.widgets.splice(widgetIndex, 1)[0];
+ this.widgets.splice(1 + index, 0, w);
+ }
+ });
+ }
+
+ // 监听值变化
+ groupCountWidget.widget.callback = (v) => {
+ this.properties.groupCount = Math.max(1, Math.min(10, parseInt(v) || 1));
+ this.updateGroupWidgets();
+ };
+
+ repeatCountWidget.widget.callback = (v) => {
+ this.properties.repeatCount = Math.max(1, Math.min(100, parseInt(v) || 1));
+ };
+
+ delayWidget.widget.callback = (v) => {
+ this.properties.delaySeconds = Math.max(0, Math.min(300, parseFloat(v) || 0));
+ };
+
+ // 添加执行按钮
+ this.addWidget("button", "Execute Groups", "Execute", () => {
+ this.executeGroups();
+ });
+
+ // 添加取消按钮(放在执行按钮后面)
+ this.addWidget("button", "Cancel", "Cancel", () => {
+ this.cancelExecution();
+ });
+
+ // 添加取消状态属性
+ this.addProperty("isCancelling", false, "boolean");
+
+ // 初始化组选择下拉框
+ this.updateGroupWidgets();
+
+ // 监听画布变化以更新组列表
+ const self = this;
+ app.canvas.onDrawBackground = (() => {
+ const original = app.canvas.onDrawBackground;
+ return function() {
+ self.updateGroupList();
+ return original?.apply(this, arguments);
+ };
+ })();
+
+ // 保存原始标题
+ this.originalTitle = title;
+ }
+
+ // 获取所有组名称
+ getGroupNames() {
+ return [...app.graph._groups].map(g => g.title).sort();
+ }
+
+ // 获取指定组的输出节点
+ getGroupOutputNodes(groupName) {
+ // 找到指定名称的组
+ const group = app.graph._groups.find(g => g.title === groupName);
+ if (!group) {
+ console.warn(`[GroupExecutor] 未找到名为 "${groupName}" 的组`);
+ return [];
+ }
+
+ // 获取组内的所有节点
+ const groupNodes = [];
+ for (const node of app.graph._nodes) {
+ if (!node || !node.pos) continue;
+ if (LiteGraph.overlapBounding(group._bounding, node.getBounding())) {
+ groupNodes.push(node);
+ }
+ }
+ group._nodes = groupNodes;
+
+ // 获取输出节点
+ return this.getOutputNodes(group._nodes);
+ }
+
+ // 获取输出节点
+ getOutputNodes(nodes) {
+ return nodes.filter((n) => {
+ return n.mode !== LiteGraph.NEVER &&
+ n.constructor.nodeData?.output_node === true;
+ });
+ }
+
+ // 更新组选择下拉框
+ updateGroupWidgets() {
+ // 保留已有的组选择,只初始化新增的
+ const currentGroups = [...this.properties.groups];
+ this.properties.groups = new Array(this.properties.groupCount).fill("").map((_, i) =>
+ currentGroups[i] || ""
+ );
+
+ // 1. 首先移除所有组选择下拉框
+ this.widgets = this.widgets.filter(w =>
+ w.name === "groupCount" ||
+ w.name === "repeatCount" ||
+ w.name === "delaySeconds" ||
+ w.name === "Execute Groups" ||
+ w.name === "Cancel"
+ );
+
+ // 2. 获取执行按钮和取消按钮的引用
+ const executeButton = this.widgets.find(w => w.name === "Execute Groups");
+ const cancelButton = this.widgets.find(w => w.name === "Cancel");
+
+ // 3. 如果存在这些按钮,先从widgets中移除
+ if (executeButton) {
+ this.widgets = this.widgets.filter(w => w.name !== "Execute Groups");
+ }
+ if (cancelButton) {
+ this.widgets = this.widgets.filter(w => w.name !== "Cancel");
+ }
+
+ // 4. 添加组选择下拉框
+ const groupNames = this.getGroupNames();
+ for (let i = 0; i < this.properties.groupCount; i++) {
+ const widget = this.addWidget(
+ "combo",
+ `Group #${i + 1}`,
+ this.properties.groups[i] || "",
+ (v) => {
+ this.properties.groups[i] = v;
+ },
+ {
+ values: groupNames
+ }
+ );
+ }
+
+ // 5. 最后重新添加执行按钮和取消按钮
+ if (executeButton) {
+ this.widgets.push(executeButton);
+ }
+ if (cancelButton) {
+ this.widgets.push(cancelButton);
+ }
+
+ // 6. 更新节点大小
+ this.size = this.computeSize();
+ }
+
+ // 更新组列表
+ updateGroupList() {
+ const groups = this.getGroupNames();
+ this.widgets.forEach(w => {
+ if (w.type === "combo") {
+ w.options.values = groups;
+ }
+ });
+ }
+
+ // 添加延迟函数
+ async delay(seconds) {
+ if (seconds <= 0) return;
+ return new Promise(resolve => setTimeout(resolve, seconds * 1000));
+ }
+
+ // 更新状态显示
+ updateStatus(text) {
+ // 更新节点标题来显示状态
+ this.title = `${this.originalTitle} - ${text}`;
+ this.setDirtyCanvas(true, true);
+ }
+
+ // 重置状态显示
+ resetStatus() {
+ this.title = this.originalTitle;
+ this.setDirtyCanvas(true, true);
+ }
+
+ // 修改取消方法
+ async cancelExecution() {
+ if (!this.properties.isExecuting) {
+ console.warn('[GroupExecutor] 没有正在执行的任务');
+ return;
+ }
+
+ try {
+ this.properties.isCancelling = true;
+ this.updateStatus("已取消");
+
+ // 中断当前执行的队列
+ await fetch('/interrupt', { method: 'POST' });
+
+ // 不需要清理待执行队列,因为通过 isCancelling 标志会阻止新队列的添加
+
+ } catch (error) {
+ console.error('[GroupExecutor] 取消执行时出错:', error);
+ this.updateStatus(`取消失败: ${error.message}`);
+ }
+ }
+
+ // 修改执行方法,添加取消检查
+ async executeGroups() {
+ if (this.properties.isExecuting) {
+ console.warn('[GroupExecutor] 已有执行任务在进行中');
+ return;
+ }
+
+ this.properties.isExecuting = true;
+ this.properties.isCancelling = false;
+ const totalSteps = this.properties.repeatCount * this.properties.groupCount;
+ let currentStep = 0;
+
+ try {
+ for (let repeat = 0; repeat < this.properties.repeatCount; repeat++) {
+ for (let i = 0; i < this.properties.groupCount; i++) {
+ // 检查是否已取消
+ if (this.properties.isCancelling) {
+ console.log('[GroupExecutor] 执行被用户取消');
+ // 中断当前执行的队列
+ await fetch('/interrupt', { method: 'POST' });
+ this.updateStatus("已取消");
+ setTimeout(() => this.resetStatus(), 2000);
+ return;
+ }
+
+ const groupName = this.properties.groups[i];
+ if (!groupName) continue;
+
+ currentStep++;
+ this.updateStatus(
+ `${currentStep}/${totalSteps} - ${groupName}`
+ );
+
+ const outputNodes = this.getGroupOutputNodes(groupName);
+ if (outputNodes && outputNodes.length > 0) {
+ try {
+ if (!rgthree || !rgthree.queueOutputNodes) {
+ throw new Error('rgthree.queueOutputNodes 不可用');
+ }
+
+ const nodeIds = outputNodes.map(n => n.id);
+
+ try {
+ // 在每个队列执行前检查取消状态
+ if (this.properties.isCancelling) {
+ return;
+ }
+ await rgthree.queueOutputNodes(nodeIds);
+ await this.waitForQueue();
+ } catch (queueError) {
+ // 在回退执行前检查取消状态
+ if (this.properties.isCancelling) {
+ return;
+ }
+ console.warn(`[GroupExecutorSender] rgthree执行失败,使用默认方式:`, queueError);
+ for (const n of outputNodes) {
+ if (this.properties.isCancelling) {
+ return;
+ }
+ if (n.triggerQueue) {
+ await n.triggerQueue();
+ await this.waitForQueue();
+ }
+ }
+ }
+
+ if (i < this.properties.groupCount - 1) {
+ // 延迟前检查是否取消
+ if (this.properties.isCancelling) {
+ return;
+ }
+ this.updateStatus(
+ `等待 ${this.properties.delaySeconds}s...`
+ );
+ await this.delay(this.properties.delaySeconds);
+ }
+ } catch (error) {
+ console.error(`[GroupExecutor] 执行组 ${groupName} 时发生错误:`, error);
+ throw error;
+ }
+ }
+ }
+
+ if (repeat < this.properties.repeatCount - 1) {
+ // 重复前检查是否取消
+ if (this.properties.isCancelling) {
+ return;
+ }
+ await this.delay(this.properties.delaySeconds);
+ }
+ }
+
+ if (!this.properties.isCancelling) {
+ this.updateStatus("完成");
+ setTimeout(() => this.resetStatus(), 2000);
+ }
+ } catch (error) {
+ console.error('[GroupExecutor] 执行错误:', error);
+ this.updateStatus(`错误: ${error.message}`);
+ app.ui.dialog.show(`执行错误: ${error.message}`);
+ } finally {
+ this.properties.isExecuting = false;
+ this.properties.isCancelling = false;
+ }
+ }
+
+ // 获取队列状态
+ async getQueueStatus() {
+ try {
+ const response = await fetch('/queue');
+ const data = await response.json();
+
+ return {
+ isRunning: data.queue_running.length > 0,
+ isPending: data.queue_pending.length > 0,
+ runningCount: data.queue_running.length,
+ pendingCount: data.queue_pending.length,
+ // 保存原始数据用于调试
+ rawRunning: data.queue_running,
+ rawPending: data.queue_pending
+ };
+ } catch (error) {
+ console.error('[GroupExecutor] 获取队列状态失败:', error);
+ // 返回默认状态而不是 null,避免空值判断
+ return {
+ isRunning: false,
+ isPending: false,
+ runningCount: 0,
+ pendingCount: 0,
+ rawRunning: [],
+ rawPending: []
+ };
+ }
+ }
+
+ // 等待队列完成
+ async waitForQueue() {
+ return new Promise((resolve, reject) => {
+ const checkQueue = async () => {
+ try {
+ const status = await this.getQueueStatus();
+
+ // 只有当运行队列和等待队列都为空时才算完成
+ if (!status.isRunning && !status.isPending) {
+ // 额外等待100ms确保状态完全更新
+ setTimeout(resolve, 100);
+ return;
+ }
+
+ // 继续检查队列状态,使用较短的间隔以提高响应性
+ setTimeout(checkQueue, 500);
+ } catch (error) {
+ console.warn(`[GroupExecutor] 检查队列状态失败:`, error);
+ // 发生错误时继续检查,而不是中断
+ setTimeout(checkQueue, 500);
+ }
+ };
+
+ // 开始检查队列
+ checkQueue();
+ });
+ }
+
+ // 修改计算节点大小方法以适应新按钮
+ computeSize() {
+ const widgetHeight = 28;
+ const padding = 4;
+ const width = 200;
+ const height = (this.properties.groupCount + 4) * widgetHeight + padding * 2; // +4 包含重复次数组件和取消按钮
+ return [width, height];
+ }
+
+ static setUp() {
+ LiteGraph.registerNodeType(this.type, this);
+ this.category = this._category;
+ }
+
+ // 序列化节点数据
+ serialize() {
+ const data = super.serialize();
+ data.properties = {
+ ...data.properties,
+ groupCount: parseInt(this.properties.groupCount) || 1,
+ groups: [...this.properties.groups],
+ isExecuting: this.properties.isExecuting,
+ repeatCount: parseInt(this.properties.repeatCount) || 1,
+ delaySeconds: parseFloat(this.properties.delaySeconds) || 0
+ };
+ return data;
+ }
+
+ // 反序列化节点数据
+ configure(info) {
+ super.configure(info);
+
+ if (info.properties) {
+ this.properties.groupCount = parseInt(info.properties.groupCount) || 1;
+ this.properties.groups = info.properties.groups ? [...info.properties.groups] : [];
+ this.properties.isExecuting = info.properties.isExecuting ?? false;
+ this.properties.repeatCount = parseInt(info.properties.repeatCount) || 1;
+ this.properties.delaySeconds = parseFloat(info.properties.delaySeconds) || 0;
+ }
+
+ this.widgets.forEach(w => {
+ if (w.name === "groupCount") {
+ w.value = this.properties.groupCount;
+ } else if (w.name === "repeatCount") {
+ w.value = this.properties.repeatCount;
+ } else if (w.name === "delaySeconds") {
+ w.value = this.properties.delaySeconds;
+ }
+ });
+
+ if (!this.configuring) {
+ this.updateGroupWidgets();
+ }
+ }
+}
+
+
+app.registerExtension({
+ name: "GroupExecutor",
+ registerCustomNodes() {
+ GroupExecutorNode.setUp();
+ }
+});
+
+
+
+
diff --git a/web/groupexecutorUI.js b/web/groupexecutorUI.js
new file mode 100644
index 0000000..56669ee
--- /dev/null
+++ b/web/groupexecutorUI.js
@@ -0,0 +1,969 @@
+import { app } from "../../scripts/app.js";
+import { api } from "../../scripts/api.js";
+
+
+class GroupExecutorUI {
+ // 使用默认值初始化静态属性
+ static DOCK_MARGIN_X = 0;
+ static DOCK_MARGIN_Y = 40;
+
+ constructor() {
+ this.container = null;
+ this.isExecuting = false;
+ this.isCancelling = false;
+ this.groups = [];
+ this.position = { x: 0, y: 0 };
+ this.isDragging = false;
+ this.dragOffset = { x: 0, y: 0 };
+ // 从静态属性获取边距值
+ this.DOCK_MARGIN_X = GroupExecutorUI.DOCK_MARGIN_X;
+ this.DOCK_MARGIN_Y = GroupExecutorUI.DOCK_MARGIN_Y;
+
+ this.createUI();
+ this.attachEvents();
+
+ // 保存实例引用
+ this.container.instance = this;
+ }
+
+ createUI() {
+ // 创建主容器
+ this.container = document.createElement('div');
+ this.container.className = 'group-executor-ui';
+
+ // 设置初始位置,使用当前的边距值
+ this.container.style.top = `${this.DOCK_MARGIN_Y}px`;
+ this.container.style.right = `${this.DOCK_MARGIN_X}px`;
+
+ this.container.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ // 添加样式
+ const style = document.createElement('style');
+ style.textContent = `
+ .group-executor-ui {
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ width: 300px !important; /* 强制固定宽度 */
+ min-width: 300px;
+ max-width: 300px;
+ background: #2a2a2a;
+ border: 1px solid #444;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.2);
+ z-index: 1000;
+ font-family: Arial, sans-serif;
+ color: #fff;
+ user-select: none; /* 禁止文字选择 */
+ }
+
+ .ge-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 12px;
+ background: #333;
+ border-radius: 8px 8px 0 0;
+ cursor: move;
+ width: 100%;
+ box-sizing: border-box;
+ }
+
+ .ge-controls button {
+ background: none;
+ border: none;
+ color: #fff;
+ margin-left: 8px;
+ cursor: pointer;
+ font-size: 16px;
+ }
+
+ .ge-content {
+ padding: 12px;
+ }
+
+ .ge-row {
+ display: flex;
+ align-items: center;
+ margin-bottom: 12px;
+ }
+
+ .ge-row label {
+ flex: 1;
+ margin-right: 12px;
+ }
+
+ .ge-row input {
+ width: 100px;
+ padding: 4px 8px;
+ background: #333;
+ border: 1px solid #444;
+ color: #fff;
+ border-radius: 4px;
+ }
+
+ .ge-groups-container {
+ margin-bottom: 12px;
+ }
+
+ .ge-group-select {
+ width: 100%;
+ margin-bottom: 8px;
+ padding: 4px 8px;
+ background: #333;
+ border: 1px solid #444;
+ color: #fff;
+ border-radius: 4px;
+ }
+
+ .ge-buttons {
+ display: flex;
+ gap: 8px;
+ }
+
+ .ge-buttons button {
+ flex: 1;
+ padding: 8px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-weight: bold;
+ }
+
+ .ge-execute-btn {
+ background: #4CAF50;
+ color: white;
+ }
+
+ .ge-execute-btn:disabled {
+ background: #2a5a2d;
+ cursor: not-allowed;
+ }
+
+ .ge-cancel-btn {
+ background: #f44336;
+ color: white;
+ }
+
+ .ge-cancel-btn:disabled {
+ background: #7a2520;
+ cursor: not-allowed;
+ }
+
+ .ge-status {
+ margin: 12px 0;
+ padding: 8px;
+ background: #333;
+ border-radius: 4px;
+ min-height: 20px;
+ text-align: center;
+ position: relative;
+ overflow: hidden; /* 确保进度条不会溢出 */
+ }
+
+ .ge-status::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ height: 100%;
+ width: var(--progress, 0%); /* 使用CSS变量控制宽度 */
+ background: rgba(36, 145, 235, 0.8); /* 绿色半透明背景 */
+ transition: width 0.3s ease;
+ z-index: 0;
+ }
+
+ .ge-status span {
+ position: relative;
+ z-index: 1; /* 确保文字在进度条上层 */
+ }
+
+ .ge-minimized {
+ width: auto !important;
+ min-width: auto;
+ }
+
+ .ge-minimized .ge-content {
+ display: none;
+ }
+
+ .ge-dock-menu {
+ position: absolute;
+ background: #333;
+ border: 1px solid #444;
+ border-radius: 4px;
+ padding: 4px 0;
+ z-index: 1001; // 确保菜单在最上层
+ visibility: hidden; // 改用 visibility 代替 display
+ opacity: 0;
+ transition: opacity 0.2s;
+ }
+
+ .ge-dock-menu.visible {
+ visibility: visible;
+ opacity: 1;
+ }
+
+ .ge-dock-menu button {
+ display: block;
+ width: 100%;
+ padding: 4px 12px;
+ background: none;
+ border: none;
+ color: #fff;
+ text-align: left;
+ cursor: pointer;
+ }
+
+ .ge-dock-menu button:hover {
+ background: #444;
+ }
+
+ .ge-title {
+ flex: 1;
+ pointer-events: none; /* 让鼠标事件穿透到父元素 */
+ white-space: nowrap; /* 防止文字换行 */
+ overflow: hidden; /* 防止文字溢出 */
+ text-overflow: ellipsis; /* 文字溢出时显示省略号 */
+ }
+
+ .ge-config-row {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 12px;
+ }
+
+ .ge-config-select {
+ flex: 1;
+ padding: 4px 8px;
+ background: #333;
+ border: 1px solid #444;
+ color: #fff;
+ border-radius: 4px;
+ }
+
+ .ge-save-config,
+ .ge-delete-config {
+ background: #333;
+ border: 1px solid #444;
+ color: #fff;
+ padding: 4px 8px;
+ border-radius: 4px;
+ cursor: pointer;
+ }
+
+ .ge-save-config:hover,
+ .ge-delete-config:hover {
+ background: #444;
+ }
+
+ .ge-delete-config:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ `;
+
+ document.head.appendChild(style);
+ document.body.appendChild(this.container);
+ }
+
+ attachEvents() {
+ // 拖拽功能
+ const header = this.container.querySelector('.ge-header');
+ header.addEventListener('mousedown', (e) => {
+ // 检查点击的不是控制按钮
+ if (!e.target.matches('.ge-controls button')) {
+ this.isDragging = true;
+ const rect = this.container.getBoundingClientRect();
+ this.dragOffset = {
+ x: e.clientX - rect.left,
+ y: e.clientY - rect.top
+ };
+ }
+ });
+
+ document.addEventListener('mousemove', (e) => {
+ if (this.isDragging) {
+ const x = e.clientX - this.dragOffset.x;
+ const y = e.clientY - this.dragOffset.y;
+ this.container.style.left = `${x}px`;
+ this.container.style.top = `${y}px`;
+ }
+ });
+
+ document.addEventListener('mouseup', () => {
+ this.isDragging = false;
+ });
+
+ // 停靠按钮
+ const dockBtn = this.container.querySelector('.ge-dock-btn');
+ dockBtn.addEventListener('click', () => {
+ this.showDockMenu(dockBtn);
+ });
+
+ // 最小化按钮
+ const minimizeBtn = this.container.querySelector('.ge-minimize-btn');
+ minimizeBtn.addEventListener('click', () => {
+ this.container.classList.toggle('ge-minimized');
+ minimizeBtn.textContent = this.container.classList.contains('ge-minimized') ? '+' : '-';
+ });
+
+ // 关闭按钮
+ const closeBtn = this.container.querySelector('.ge-close-btn');
+ closeBtn.addEventListener('click', () => {
+ this.container.remove();
+ });
+
+ // 组数量变化
+ const groupCountInput = this.container.querySelector('.ge-group-count');
+ groupCountInput.addEventListener('change', () => {
+ this.updateGroupSelects(parseInt(groupCountInput.value));
+ });
+
+ // 执行按钮
+ const executeBtn = this.container.querySelector('.ge-execute-btn');
+ executeBtn.addEventListener('click', () => {
+ this.executeGroups();
+ });
+
+ // 取消按钮
+ const cancelBtn = this.container.querySelector('.ge-cancel-btn');
+ cancelBtn.addEventListener('click', () => {
+ this.cancelExecution();
+ });
+
+ // 初始化组选择
+ this.updateGroupSelects(1);
+
+ // 添加窗口大小变化的监听
+ window.addEventListener('resize', () => {
+ this.ensureInViewport();
+ });
+
+ // 配置相关事件
+ const deleteConfigBtn = this.container.querySelector('.ge-delete-config');
+ const saveConfigBtn = this.container.querySelector('.ge-save-config');
+ const configSelect = this.container.querySelector('.ge-config-select');
+
+ // 更新删除按钮状态
+ const updateDeleteButton = () => {
+ deleteConfigBtn.disabled = !configSelect.value;
+ };
+
+ // 配置选择事件
+ configSelect.addEventListener('change', () => {
+ updateDeleteButton();
+ if (configSelect.value) {
+ this.loadConfig(configSelect.value);
+ }
+ });
+
+ // 保存配置事件
+ saveConfigBtn.addEventListener('click', () => {
+ this.saveCurrentConfig();
+ });
+
+ // 删除配置事件
+ deleteConfigBtn.addEventListener('click', () => {
+ const configName = configSelect.value;
+ if (configName) {
+ this.deleteConfig(configName);
+ }
+ });
+
+ // 初始化删除按钮状态
+ updateDeleteButton();
+
+ // 初始化加载配置列表
+ this.loadConfigs();
+ }
+
+ showDockMenu(button) {
+ // 移除已存在的菜单
+ const existingMenu = document.querySelector('.ge-dock-menu');
+ if (existingMenu) {
+ existingMenu.remove();
+ return;
+ }
+
+ const menu = document.createElement('div');
+ menu.className = 'ge-dock-menu';
+ menu.innerHTML = `
+
+
+
+
+ `;
+
+ // 将菜单添加到容器内部而不是 body
+ this.container.appendChild(menu);
+
+ // 计算菜单位置
+ const buttonRect = button.getBoundingClientRect();
+ const containerRect = this.container.getBoundingClientRect();
+
+ menu.style.left = `${buttonRect.left - containerRect.left}px`;
+ menu.style.top = `${buttonRect.bottom - containerRect.top + 5}px`;
+
+ // 使用 requestAnimationFrame 确保过渡动画正常工作
+ requestAnimationFrame(() => {
+ menu.classList.add('visible');
+ });
+
+ menu.addEventListener('click', (e) => {
+ const position = e.target.dataset.position;
+ if (position) {
+ this.dockTo(position);
+ menu.classList.remove('visible');
+ setTimeout(() => menu.remove(), 200); // 等待过渡动画完成后移除
+ }
+ });
+
+ // 点击其他地方关闭菜单
+ const closeMenu = (e) => {
+ if (!menu.contains(e.target) && e.target !== button) {
+ menu.classList.remove('visible');
+ setTimeout(() => menu.remove(), 200);
+ document.removeEventListener('click', closeMenu);
+ }
+ };
+
+ // 延迟添加点击监听,避免立即触发
+ setTimeout(() => {
+ document.addEventListener('click', closeMenu);
+ }, 0);
+ }
+
+ dockTo(position) {
+ const style = this.container.style;
+ style.transition = 'all 0.3s ease';
+
+ // 使用实例的边距值
+ const marginX = this.DOCK_MARGIN_X;
+ const marginY = this.DOCK_MARGIN_Y;
+
+ switch (position) {
+ case 'top-left':
+ style.top = `${marginY}px`;
+ style.left = `${marginX}px`;
+ style.right = 'auto';
+ style.bottom = 'auto';
+ break;
+ case 'top-right':
+ style.top = `${marginY}px`;
+ style.right = `${marginX}px`;
+ style.left = 'auto';
+ style.bottom = 'auto';
+ break;
+ case 'bottom-left':
+ style.bottom = `${marginY}px`;
+ style.left = `${marginX}px`;
+ style.right = 'auto';
+ style.top = 'auto';
+ break;
+ case 'bottom-right':
+ style.bottom = `${marginY}px`;
+ style.right = `${marginX}px`;
+ style.left = 'auto';
+ style.top = 'auto';
+ break;
+ }
+
+ setTimeout(() => {
+ style.transition = '';
+ }, 300);
+ }
+
+ updateGroupSelects(count) {
+ const container = this.container.querySelector('.ge-groups-container');
+ container.innerHTML = '';
+
+ const groupNames = this.getGroupNames();
+
+ for (let i = 0; i < count; i++) {
+ const select = document.createElement('select');
+ select.className = 'ge-group-select';
+ select.innerHTML = `
+
+ ${groupNames.map(name => ``).join('')}
+ `;
+ container.appendChild(select);
+ }
+ }
+
+ getGroupNames() {
+ return [...app.graph._groups].map(g => g.title).sort();
+ }
+
+ updateStatus(text, progress = null) {
+ const status = this.container.querySelector('.ge-status');
+ status.innerHTML = `${text}`; // 将文本包装在 span 中
+
+ if (progress !== null) {
+ status.style.setProperty('--progress', `${progress}%`);
+ }
+ }
+
+ async executeGroups() {
+ if (this.isExecuting) {
+ console.warn('[GroupExecutorUI] 已有执行任务在进行中');
+ return;
+ }
+
+ const executeBtn = this.container.querySelector('.ge-execute-btn');
+ const cancelBtn = this.container.querySelector('.ge-cancel-btn');
+ const groupSelects = [...this.container.querySelectorAll('.ge-group-select')];
+ const repeatCount = parseInt(this.container.querySelector('.ge-repeat-count').value);
+ const delaySeconds = parseFloat(this.container.querySelector('.ge-delay').value);
+
+ this.isExecuting = true;
+ this.isCancelling = false;
+ executeBtn.disabled = true;
+ cancelBtn.disabled = false;
+
+ const selectedGroups = groupSelects.map(select => select.value).filter(Boolean);
+ const totalSteps = repeatCount * selectedGroups.length;
+ let currentStep = 0;
+
+ try {
+ for (let repeat = 0; repeat < repeatCount; repeat++) {
+ for (let i = 0; i < selectedGroups.length; i++) {
+ if (this.isCancelling) {
+ console.log('[GroupExecutorUI] 执行被用户取消');
+ await fetch('/interrupt', { method: 'POST' });
+ this.updateStatus("已取消");
+ break;
+ }
+
+ const groupName = selectedGroups[i];
+ currentStep++;
+ const progress = (currentStep / totalSteps) * 100;
+ this.updateStatus(`${currentStep}/${totalSteps} - ${groupName}`, progress);
+
+ try {
+ await this.executeGroup(groupName);
+
+ if (i < selectedGroups.length - 1 && delaySeconds > 0) {
+ this.updateStatus(`等待 ${delaySeconds}s...`);
+ await this.delay(delaySeconds);
+ }
+ } catch (error) {
+ throw new Error(`执行组 "${groupName}" 失败: ${error.message}`);
+ }
+ }
+
+ if (repeat < repeatCount - 1 && !this.isCancelling) {
+ await this.delay(delaySeconds);
+ }
+ }
+
+ if (!this.isCancelling) {
+ this.updateStatus("完成");
+ }
+ } catch (error) {
+ console.error('[GroupExecutorUI] 执行错误:', error);
+ this.updateStatus(`错误: ${error.message}`);
+ app.ui.dialog.show(`执行错误: ${error.message}`);
+ } finally {
+ this.isExecuting = false;
+ this.isCancelling = false;
+ executeBtn.disabled = false;
+ cancelBtn.disabled = true;
+ }
+ }
+
+ async executeGroup(groupName) {
+ const group = app.graph._groups.find(g => g.title === groupName);
+ if (!group) {
+ throw new Error(`未找到名为 "${groupName}" 的组`);
+ }
+
+ const outputNodes = [];
+ for (const node of app.graph._nodes) {
+ if (!node || !node.pos) continue;
+ if (LiteGraph.overlapBounding(group._bounding, node.getBounding())) {
+ if (node.mode !== LiteGraph.NEVER && node.constructor.nodeData?.output_node === true) {
+ outputNodes.push(node);
+ }
+ }
+ }
+
+ if (outputNodes.length === 0) {
+ throw new Error(`组 "${groupName}" 中没有找到输出节点`);
+ }
+
+ const nodeIds = outputNodes.map(n => n.id);
+
+ try {
+ if (!rgthree || !rgthree.queueOutputNodes) {
+ throw new Error('rgthree.queueOutputNodes 不可用');
+ }
+ await rgthree.queueOutputNodes(nodeIds);
+ await this.waitForQueue();
+ } catch (queueError) {
+ console.warn(`[GroupExecutorUI] rgthree执行失败,使用默认方式:`, queueError);
+ for (const n of outputNodes) {
+ if (this.isCancelling) return;
+ if (n.triggerQueue) {
+ await n.triggerQueue();
+ await this.waitForQueue();
+ }
+ }
+ }
+ }
+
+ async cancelExecution() {
+ if (!this.isExecuting) {
+ console.warn('[GroupExecutorUI] 没有正在执行的任务');
+ return;
+ }
+
+ try {
+ this.isCancelling = true;
+ this.updateStatus("已取消", 0); // 重置进度条为0
+ await fetch('/interrupt', { method: 'POST' });
+ } catch (error) {
+ console.error('[GroupExecutorUI] 取消执行时出错:', error);
+ this.updateStatus(`取消失败: ${error.message}`, 0); // 错误时也重置进度条
+ }
+ }
+
+ async getQueueStatus() {
+ try {
+ const response = await fetch('/queue');
+ const data = await response.json();
+
+ return {
+ isRunning: data.queue_running.length > 0,
+ isPending: data.queue_pending.length > 0,
+ runningCount: data.queue_running.length,
+ pendingCount: data.queue_pending.length,
+ // 保存原始数据用于调试
+ rawRunning: data.queue_running,
+ rawPending: data.queue_pending
+ };
+ } catch (error) {
+ console.error('[GroupExecutor] 获取队列状态失败:', error);
+ // 返回默认状态而不是 null,避免空值判断
+ return {
+ isRunning: false,
+ isPending: false,
+ runningCount: 0,
+ pendingCount: 0,
+ rawRunning: [],
+ rawPending: []
+ };
+ }
+ }
+
+ // 等待队列完成
+ async waitForQueue() {
+ return new Promise((resolve, reject) => {
+ const checkQueue = async () => {
+ try {
+ const status = await this.getQueueStatus();
+
+ // 只有当运行队列和等待队列都为空时才算完成
+ if (!status.isRunning && !status.isPending) {
+ // 额外等待100ms确保状态完全更新
+ setTimeout(resolve, 100);
+ return;
+ }
+
+ // 继续检查队列状态,使用较短的间隔以提高响应性
+ setTimeout(checkQueue, 500);
+ } catch (error) {
+ console.warn(`[GroupExecutor] 检查队列状态失败:`, error);
+ // 发生错误时继续检查,而不是中断
+ setTimeout(checkQueue, 500);
+ }
+ };
+
+ // 开始检查队列
+ checkQueue();
+ });
+ }
+
+ async delay(seconds) {
+ if (seconds <= 0) return;
+ return new Promise(resolve => setTimeout(resolve, seconds * 1000));
+ }
+
+ // 添加一个方法来确保UI不会超出视窗
+ ensureInViewport() {
+ const rect = this.container.getBoundingClientRect();
+ const windowWidth = window.innerWidth;
+ const windowHeight = window.innerHeight;
+
+ // 应用边距限制
+ if (this.container.style.right !== 'auto') {
+ this.container.style.right = `${this.DOCK_MARGIN_X}px`;
+ }
+ if (this.container.style.left !== 'auto') {
+ this.container.style.left = `${this.DOCK_MARGIN_X}px`;
+ }
+ if (this.container.style.top !== 'auto') {
+ this.container.style.top = `${this.DOCK_MARGIN_Y}px`;
+ }
+ if (this.container.style.bottom !== 'auto') {
+ this.container.style.bottom = `${this.DOCK_MARGIN_Y}px`;
+ }
+ }
+
+ // 添加配置相关方法
+ async loadConfigs() {
+ try {
+ const response = await api.fetchApi('/group_executor/configs', {
+ method: 'GET'
+ });
+
+ const result = await response.json();
+ if (result.status === "error") {
+ throw new Error(result.message);
+ }
+
+ const select = this.container.querySelector('.ge-config-select');
+ select.innerHTML = `
+
+ ${result.configs.map(config => ``).join('')}
+ `;
+ } catch (error) {
+ console.error('[GroupExecutor] 加载配置失败:', error);
+ app.ui.dialog.show('加载配置失败: ' + error.message);
+ }
+ }
+
+ async saveCurrentConfig() {
+ const configName = prompt('请输入配置名称:', '新配置');
+ if (!configName) return;
+
+ const config = {
+ name: configName,
+ groups: [...this.container.querySelectorAll('.ge-group-select')]
+ .map(select => select.value)
+ .filter(Boolean),
+ repeatCount: parseInt(this.container.querySelector('.ge-repeat-count').value),
+ delay: parseFloat(this.container.querySelector('.ge-delay').value)
+ };
+
+ try {
+ const response = await api.fetchApi('/group_executor/configs', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(config)
+ });
+
+ const result = await response.json();
+ if (result.status === "error") {
+ throw new Error(result.message);
+ }
+
+ await this.loadConfigs();
+ app.ui.dialog.show('配置保存成功');
+ } catch (error) {
+ console.error('[GroupExecutor] 保存配置失败:', error);
+ app.ui.dialog.show('保存配置失败: ' + error.message);
+ }
+ }
+
+ async loadConfig(configName) {
+ try {
+ const response = await api.fetchApi(`/group_executor/configs/${configName}`, {
+ method: 'GET',
+ cache: 'no-store' // 禁用缓存
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ const config = await response.json();
+
+ // 更新组数量
+ const groupCountInput = this.container.querySelector('.ge-group-count');
+ groupCountInput.value = config.groups.length;
+ await this.updateGroupSelects(config.groups.length);
+
+ // 设置组选择
+ const selects = this.container.querySelectorAll('.ge-group-select');
+ config.groups.forEach((group, index) => {
+ if (selects[index]) selects[index].value = group;
+ });
+
+ // 设置其他参数
+ this.container.querySelector('.ge-repeat-count').value = config.repeatCount;
+ this.container.querySelector('.ge-delay').value = config.delay;
+ } catch (error) {
+ console.error('加载配置失败:', error);
+ app.ui.dialog.show('加载配置失败: ' + error.message);
+ }
+ }
+
+ // 添加删除配置方法
+ async deleteConfig(configName) {
+ if (!configName) return;
+
+ if (!confirm(`确定要删除配置 "${configName}" 吗?`)) {
+ return;
+ }
+
+ try {
+ const response = await api.fetchApi(`/group_executor/configs/${configName}`, {
+ method: 'DELETE'
+ });
+
+ const result = await response.json();
+ if (result.status === "error") {
+ throw new Error(result.message);
+ }
+
+ await this.loadConfigs();
+ app.ui.dialog.show('配置已删除');
+ } catch (error) {
+ console.error('[GroupExecutor] 删除配置失败:', error);
+ app.ui.dialog.show('删除配置失败: ' + error.message);
+ }
+ }
+}
+
+// 修改扩展注册部分
+app.registerExtension({
+ name: "GroupExecutorUI",
+ async setup() {
+ // 等待 UI 设置初始化完成
+ await app.ui.settings.setup;
+
+ // 添加设置选项
+ app.ui.settings.addSetting({
+ id: "GroupExecutor.enabled",
+ name: "显示组执行器按钮",
+ type: "boolean",
+ defaultValue: true,
+ onChange: (value) => {
+ const btn = document.querySelector('.group-executor-btn');
+ if (btn) {
+ btn.style.display = value ? 'block' : 'none';
+ }
+ }
+ });
+
+ app.ui.settings.addSetting({
+ id: "GroupExecutor.marginX",
+ name: "组执行器水平边距",
+ type: "number",
+ defaultValue: 0,
+ min: 0,
+ max: 100,
+ step: 1,
+ onChange: (value) => {
+ GroupExecutorUI.DOCK_MARGIN_X = value;
+ document.querySelectorAll('.group-executor-ui').forEach(el => {
+ const instance = el.instance;
+ if (instance) {
+ instance.DOCK_MARGIN_X = value;
+ instance.ensureInViewport();
+ }
+ });
+ }
+ });
+
+ app.ui.settings.addSetting({
+ id: "GroupExecutor.marginY",
+ name: "组执行器垂直边距",
+ type: "number",
+ defaultValue: 20,
+ min: 0,
+ max: 100,
+ step: 1,
+ onChange: (value) => {
+ GroupExecutorUI.DOCK_MARGIN_Y = value;
+ document.querySelectorAll('.group-executor-ui').forEach(el => {
+ const instance = el.instance;
+ if (instance) {
+ instance.DOCK_MARGIN_Y = value;
+ instance.ensureInViewport();
+ }
+ });
+ }
+ });
+
+ try {
+ // 尝试使用新版UI
+ const btn = new (await import("../../scripts/ui/components/button.js")).ComfyButton({
+ icon: "layers-outline",
+ action: () => {
+ new GroupExecutorUI();
+ },
+ tooltip: "组执行器",
+ content: "组执行器",
+ classList: "comfyui-button comfyui-menu-mobile-collapse group-executor-btn"
+ }).element;
+
+ // 根据设置决定是否显示按钮
+ const enabled = app.ui.settings.getSettingValue("GroupExecutor.enabled", true);
+ btn.style.display = enabled ? 'block' : 'none';
+
+ app.menu?.actionsGroup.element.after(btn);
+ } catch {
+ // 传统UI方式
+ const menu = document.querySelector(".comfy-menu");
+ const clearButton = document.getElementById("comfy-clear-button");
+
+ const groupExecutorButton = document.createElement("button");
+ groupExecutorButton.textContent = "组执行器";
+ groupExecutorButton.classList.add("group-executor-btn");
+
+ // 根据设置决定是否显示按钮
+ const enabled = app.ui.settings.getSettingValue("GroupExecutor.enabled", true);
+ groupExecutorButton.style.display = enabled ? 'block' : 'none';
+
+ groupExecutorButton.addEventListener("click", () => {
+ new GroupExecutorUI();
+ });
+
+ if (clearButton) {
+ menu.insertBefore(groupExecutorButton, clearButton);
+ } else {
+ menu.appendChild(groupExecutorButton);
+ }
+ }
+ }
+});
+
+
diff --git a/web/groupexecutorqueuemanager.js b/web/groupexecutorqueuemanager.js
new file mode 100644
index 0000000..f50526c
--- /dev/null
+++ b/web/groupexecutorqueuemanager.js
@@ -0,0 +1,187 @@
+import { app } from "../../scripts/app.js";
+import { api } from "../../scripts/api.js";
+
+app.registerExtension({
+ name: "GroupExecutorQueueManager",
+ async beforeRegisterNodeDef(nodeType, nodeData, app) {
+ // 检查是否已经重写过 fetchApi
+ if (api.fetchApi._isGroupExecutorQueueManager) {
+ return;
+ }
+
+ // 保存原始的 api.fetchApi
+ const originalFetchApi = api.fetchApi;
+
+ // 递归收集相关节点
+ function collectRelatedNodes(prompt, nodeId, relevantNodes) {
+ if (!prompt[nodeId] || relevantNodes.has(nodeId)) return;
+ relevantNodes.add(nodeId);
+
+ // 收集输入节点
+ const node = prompt[nodeId];
+ if (node.inputs) {
+ Object.values(node.inputs).forEach(input => {
+ if (input && input.length > 0) {
+ collectRelatedNodes(prompt, input[0], relevantNodes);
+ }
+ });
+ }
+ }
+
+ // 重写 api.fetchApi
+ const newFetchApi = async function(url, options = {}) {
+ // 只拦截 POST /prompt 请求
+ if (url === '/prompt' && options.method === 'POST') {
+ const requestData = JSON.parse(options.body);
+
+ // 如果是 GroupExecutorSender 的内部请求,直接放行
+ if (requestData.extra_data?.isGroupExecutorRequest) {
+ return originalFetchApi.call(api, url, options);
+ }
+
+ const prompt = requestData.prompt;
+
+ // 检查是否存在 GroupExecutorSender 节点
+ const hasGroupExecutor = Object.values(prompt).some(node =>
+ node.class_type === "GroupExecutorSender"
+ );
+
+ if (hasGroupExecutor) {
+ // 找出所有 GroupExecutorSender 相关的节点
+ const relevantNodes = new Set();
+
+ for (const [nodeId, node] of Object.entries(prompt)) {
+ if (node.class_type === "GroupExecutorSender") {
+ collectRelatedNodes(prompt, nodeId, relevantNodes);
+ }
+ }
+
+ // 创建过滤后的 prompt
+ const filteredPrompt = {};
+ for (const nodeId of relevantNodes) {
+ if (prompt[nodeId]) {
+ filteredPrompt[nodeId] = prompt[nodeId];
+ }
+ }
+
+ // 修改请求数据,添加标记表示这是内部请求
+ const modifiedOptions = {
+ ...options,
+ body: JSON.stringify({
+ ...requestData,
+ prompt: filteredPrompt,
+ extra_data: {
+ ...requestData.extra_data,
+ isGroupExecutorRequest: true
+ }
+ })
+ };
+
+ return originalFetchApi.call(api, url, modifiedOptions);
+ }
+ }
+
+ return originalFetchApi.call(api, url, options);
+ };
+
+ // 标记新的 fetchApi 函数
+ newFetchApi._isGroupExecutorQueueManager = true;
+
+ // 替换 api.fetchApi
+ api.fetchApi = newFetchApi;
+ }
+});
+
+
+
+api.addEventListener("img-send", async ({ detail }) => {
+ if (detail.images.length === 0) return;
+
+ // 构建所有图像的文件名列表,用逗号连接
+ const filenames = detail.images.map(data => data.filename).join(', ');
+
+ // 查找所有LG_ImageReceiver节点
+ for (const node of app.graph._nodes) {
+ if (node.type === "LG_ImageReceiver") {
+ let isLinked = false;
+
+ // 检查link_id是否匹配
+ const linkWidget = node.widgets.find(w => w.name === "link_id");
+ if (linkWidget.value === detail.link_id) {
+ isLinked = true;
+ }
+
+ if (isLinked) {
+ // 更新image widget的值
+ if (node.widgets[0]) {
+ node.widgets[0].value = filenames;
+ if (node.widgets[0].callback) {
+ node.widgets[0].callback(filenames);
+ }
+ }
+
+ // 加载所有预览图像
+ Promise.all(detail.images.map(imageData => {
+ return new Promise((resolve) => {
+ const img = new Image();
+ img.onload = () => resolve(img);
+ img.src = `/view?filename=${encodeURIComponent(imageData.filename)}&type=${imageData.type}${app.getPreviewFormatParam()}`;
+ });
+ })).then(loadedImages => {
+ node.imgs = loadedImages;
+ node.size[1] = Math.max(200, 100 + loadedImages.length * 100);
+ app.canvas.setDirty(true);
+ });
+ }
+ }
+ }
+});
+
+// 注册节点扩展
+app.registerExtension({
+ name: "Comfy.LG_Image",
+ async beforeRegisterNodeDef(nodeType, nodeData, app) {
+ if (nodeData.name === "LG_ImageReceiver") {
+ const onExecuted = nodeType.prototype.onExecuted;
+ nodeType.prototype.onExecuted = function (message) {
+ onExecuted?.apply(this, arguments);
+ };
+ }
+ },
+});
+
+
+
+
+
+app.registerExtension({
+ name: "memory.cleanup",
+ init() {
+ // 监听来自后端的内存清理信号
+ api.addEventListener("memory_cleanup", ({ detail }) => {
+ if (detail.type === "cleanup_request") {
+ console.log("收到内存清理请求");
+
+ // 发送清理请求到 /free 接口
+ fetch("/free", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(detail.data)
+ })
+ .then(response => {
+ if (response.ok) {
+ console.log("内存清理请求已发送");
+ } else {
+ console.error("内存清理请求失败");
+ }
+ })
+ .catch(error => {
+ console.error("发送内存清理请求出错:", error);
+ });
+ }
+ });
+ }
+});
+
diff --git a/web/groupexecutorsender.js b/web/groupexecutorsender.js
new file mode 100644
index 0000000..b17f6ea
--- /dev/null
+++ b/web/groupexecutorsender.js
@@ -0,0 +1,284 @@
+import { app } from "../../scripts/app.js";
+import { api } from "../../scripts/api.js";
+
+app.registerExtension({
+ name: "GroupExecutorSender",
+ async beforeRegisterNodeDef(nodeType, nodeData, app) {
+ if (nodeData.name === "GroupExecutorSender") {
+ // 添加状态显示相关的属性
+ nodeType.prototype.onNodeCreated = function() {
+ this.properties = {
+ ...this.properties,
+ isExecuting: false,
+ statusText: "",
+ showStatus: false
+ };
+ this.size = this.computeSize();
+ };
+
+ // 重写绘制方法
+ const onDrawForeground = nodeType.prototype.onDrawForeground;
+ nodeType.prototype.onDrawForeground = function(ctx) {
+ const r = onDrawForeground?.apply?.(this, arguments);
+
+ if (!this.flags.collapsed && this.properties.showStatus) {
+ const text = this.properties.statusText;
+ if (text) {
+ ctx.save();
+
+ // 设置字体
+ ctx.font = "bold 30px sans-serif";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+
+ // 设置颜色
+ ctx.fillStyle = this.properties.isExecuting ? "dodgerblue" : "limegreen";
+
+ // 计算中心位置
+ const centerX = this.size[0] / 2;
+ const centerY = this.size[1] / 2 + 10; // 稍微向下偏移以避开输入端口
+
+ // 绘制文本
+ ctx.fillText(text, centerX, centerY);
+
+ ctx.restore();
+ }
+ }
+
+ return r;
+ };
+
+ // 更新节点大小计算
+ nodeType.prototype.computeSize = function() {
+ return [400, 100]; // 固定宽度和高度
+ };
+
+ // 更新状态显示方法
+ nodeType.prototype.updateStatus = function(text) {
+ this.properties.statusText = text;
+ this.properties.showStatus = true;
+ this.setDirtyCanvas(true, true);
+ };
+
+ // 重置状态显示方法
+ nodeType.prototype.resetStatus = function() {
+ this.properties.statusText = "";
+ this.properties.showStatus = false;
+ this.setDirtyCanvas(true, true);
+ };
+
+ // 获取指定组的输出节点
+ nodeType.prototype.getGroupOutputNodes = function(groupName) {
+ // 找到指定名称的组
+ const group = app.graph._groups.find(g => g.title === groupName);
+ if (!group) {
+ console.warn(`[GroupExecutorSender] 未找到名为 "${groupName}" 的组`);
+ return [];
+ }
+
+ // 获取组内的所有节点
+ const groupNodes = [];
+ for (const node of app.graph._nodes) {
+ if (!node || !node.pos) continue;
+ if (LiteGraph.overlapBounding(group._bounding, node.getBounding())) {
+ groupNodes.push(node);
+ }
+ }
+ group._nodes = groupNodes;
+
+ // 获取输出节点
+ return this.getOutputNodes(group._nodes);
+ };
+
+ // 获取输出节点
+ nodeType.prototype.getOutputNodes = function(nodes) {
+ return nodes.filter((n) => {
+ return n.mode !== LiteGraph.NEVER &&
+ n.constructor.nodeData?.output_node === true;
+ });
+ };
+
+ // 获取队列状态
+ nodeType.prototype.getQueueStatus = async function() {
+ try {
+ const response = await api.fetchApi('/queue');
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ const data = await response.json();
+
+ // 确保数据包含必要的字段
+ const queueRunning = data.queue_running || [];
+ const queuePending = data.queue_pending || [];
+
+ return {
+ isRunning: queueRunning.length > 0,
+ isPending: queuePending.length > 0,
+ runningCount: queueRunning.length,
+ pendingCount: queuePending.length,
+ rawRunning: queueRunning,
+ rawPending: queuePending
+ };
+ } catch (error) {
+ console.error('[GroupExecutorSender] 获取队列状态失败:', error);
+ // 返回默认状态而不是 null
+ return {
+ isRunning: false,
+ isPending: false,
+ runningCount: 0,
+ pendingCount: 0,
+ rawRunning: [],
+ rawPending: []
+ };
+ }
+ };
+
+ // 等待队列完成
+ nodeType.prototype.waitForQueue = async function() {
+ return new Promise((resolve, reject) => {
+ const checkQueue = async () => {
+ try {
+ const status = await this.getQueueStatus();
+
+ // 只有当运行队列和等待队列都为空时才算完成
+ if (!status.isRunning && !status.isPending) {
+ // 额外等待100ms确保状态完全更新
+ setTimeout(resolve, 100);
+ return;
+ }
+
+ // 继续检查队列状态,使用较短的间隔以提高响应性
+ setTimeout(checkQueue, 500);
+ } catch (error) {
+ console.warn(`[GroupExecutorSender] 检查队列状态失败:`, error);
+ // 发生错误时继续检查,而不是中断
+ setTimeout(checkQueue, 500);
+ }
+ };
+
+ // 开始检查队列
+ checkQueue();
+ });
+ };
+
+ // 监听执行列表请求
+ api.addEventListener("execute_group_list", async ({ detail }) => {
+ if (!detail || !detail.node_id || !Array.isArray(detail.execution_list)) {
+ console.error('[GroupExecutorSender] 收到无效的执行数据:', detail);
+ return;
+ }
+
+ const node = app.graph._nodes_by_id[detail.node_id];
+ if (!node) {
+ console.error(`[GroupExecutorSender] 未找到节点: ${detail.node_id}`);
+ return;
+ }
+
+ try {
+ const executionList = detail.execution_list;
+ console.log(`[GroupExecutorSender] 收到执行列表:`, executionList);
+
+ if (node.properties.isExecuting) {
+ console.warn('[GroupExecutorSender] 已有执行任务在进行中');
+ return;
+ }
+
+ node.properties.isExecuting = true;
+ // 计算实际任务数(排除延迟组)
+ let totalTasks = executionList.filter(item => item.group_name !== "__delay__").length;
+ let currentTask = 0;
+
+ try {
+ for (const execution of executionList) {
+ const group_name = execution.group_name || '';
+ const repeat_count = parseInt(execution.repeat_count) || 1;
+ const delay_seconds = parseFloat(execution.delay_seconds) || 0;
+
+ if (!group_name) {
+ console.warn('[GroupExecutorSender] 跳过无效的组名称:', execution);
+ continue;
+ }
+
+ // 处理延迟组
+ if (group_name === "__delay__") {
+ if (delay_seconds > 0) {
+ node.updateStatus(
+ `等待下一组 ${delay_seconds}s...`
+ );
+ await new Promise(resolve => setTimeout(resolve, delay_seconds * 1000));
+ }
+ continue;
+ }
+
+ // 更新当前任务计数
+ currentTask++;
+ const progress = (currentTask / totalTasks) * 100;
+ node.updateStatus(
+ `执行组: ${group_name} (${currentTask}/${totalTasks})`,
+ progress
+ );
+
+ try {
+ const outputNodes = node.getGroupOutputNodes(group_name);
+ if (!outputNodes || !outputNodes.length) {
+ throw new Error(`组 "${group_name}" 中没有找到输出节点`);
+ }
+
+ const nodeIds = outputNodes.map(n => n.id);
+
+ if (rgthree?.queueOutputNodes) {
+ try {
+ await rgthree.queueOutputNodes(nodeIds);
+ await node.waitForQueue();
+ } catch (queueError) {
+ console.warn(`[GroupExecutorSender] rgthree执行失败,使用默认方式:`, queueError);
+ for (const n of outputNodes) {
+ if (n.triggerQueue) {
+ await n.triggerQueue();
+ await node.waitForQueue();
+ }
+ }
+ }
+ } else {
+ for (const n of outputNodes) {
+ if (n.triggerQueue) {
+ await n.triggerQueue();
+ await node.waitForQueue();
+ }
+ }
+ }
+
+ if (delay_seconds > 0 && currentTask < totalTasks) {
+ node.updateStatus(
+ `执行组: ${group_name} (${currentTask}/${totalTasks}) - 等待 ${delay_seconds}s`,
+ progress
+ );
+ await new Promise(resolve => setTimeout(resolve, delay_seconds * 1000));
+ }
+ } catch (error) {
+ throw new Error(`执行组 "${group_name}" 失败: ${error.message}`);
+ }
+ }
+
+ // 执行完成后保留最后的状态显示
+ node.updateStatus(`执行完成 (${totalTasks}/${totalTasks})`, 100);
+
+ } catch (error) {
+ console.error('[GroupExecutorSender] 执行错误:', error);
+ node.updateStatus(`错误: ${error.message}`);
+ app.ui.dialog.show(`执行错误: ${error.message}`);
+ } finally {
+ node.properties.isExecuting = false;
+ }
+
+ } catch (error) {
+ console.error(`[GroupExecutorSender] 执行失败:`, error);
+ app.ui.dialog.show(`执行错误: ${error.message}`);
+ node.updateStatus(`错误: ${error.message}`);
+ node.properties.isExecuting = false;
+ }
+ });
+ }
+ }
+});
+