import os import sys import time import atexit import hashlib import logging import requests import threading from collections import defaultdict import traceback import asyncio from watchdog.observers.polling import PollingObserver as Observer from watchdog.events import FileSystemEventHandler from aiohttp import web import folder_paths from nodes import load_custom_node from comfy_execution import caching from server import PromptServer import json import nodes from .Nodes.Terminal import * RELOADED_CLASS_TYPES: dict = {} CUSTOM_NODE_ROOT: list[str] = folder_paths.folder_names_and_paths["custom_nodes"][0] CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json") def load_exclude_modules() -> set[str]: try: with open(CONFIG_PATH, 'r', encoding='utf-8') as f: config = json.load(f) return set(config.get("exclude_modules", set())) except Exception as e: print(f"\033[91m[LG_HotReload] Error loading config: {str(e)}\033[0m") return set() def save_exclude_modules(modules: set[str]): try: with open(CONFIG_PATH, 'w', encoding='utf-8') as f: json.dump({"exclude_modules": list(modules)}, f, indent=4) print(f"\033[92m[LG_HotReload] Exclude modules config saved\033[0m") except Exception as e: print(f"\033[91m[LG_HotReload] Error saving config: {str(e)}\033[0m") EXCLUDE_MODULES: set[str] = load_exclude_modules() @PromptServer.instance.routes.get("/hotreload/get_exclude_modules") async def get_exclude_modules(request): return web.json_response({"exclude_modules": list(EXCLUDE_MODULES)}) @PromptServer.instance.routes.post("/hotreload/update_exclude_modules") async def update_exclude_modules(request): try: data = await request.json() modules = set(data.get("exclude_modules", [])) global EXCLUDE_MODULES EXCLUDE_MODULES = modules save_exclude_modules(modules) return web.json_response({"status": "success"}) except Exception as e: return web.json_response({"status": "error", "message": str(e)}, status=500) @PromptServer.instance.routes.get("/hotreload/get_all_modules") async def get_all_modules(request): try: modules = [] for item in os.listdir(CUSTOM_NODE_ROOT[0]): item_path = os.path.join(CUSTOM_NODE_ROOT[0], item) if os.path.isdir(item_path) and not item.startswith('.'): if os.path.exists(os.path.join(item_path, '__init__.py')): modules.append(item) return web.json_response({"modules": modules}) except Exception as e: return web.json_response({"status": "error", "message": str(e)}, status=500) @PromptServer.instance.routes.get("/extensions/{module_name}/{path:.*}") async def dynamic_extensions_handler(request): """处理动态加载的插件的WEB_DIRECTORY文件访问""" module_name = request.match_info['module_name'] file_path = request.match_info['path'] # 优先从 EXTENSION_WEB_DIRS 中查找(自定义节点,支持热更新) if module_name in nodes.EXTENSION_WEB_DIRS: web_dir = nodes.EXTENSION_WEB_DIRS[module_name] full_path = os.path.join(web_dir, file_path) if os.path.isfile(full_path): return web.FileResponse(full_path) # 如果不在 EXTENSION_WEB_DIRS 中,尝试从 ComfyUI 前端包的 extensions 目录查找(系统扩展如 core) # PromptServer.instance.web_root 指向前端包的静态文件目录 # 例如:E:\python3.11\Lib\site-packages\comfyui_frontend_package\static if hasattr(PromptServer.instance, 'web_root') and PromptServer.instance.web_root: web_root = PromptServer.instance.web_root full_path = os.path.join(web_root, "extensions", module_name, file_path) if os.path.isfile(full_path): return web.FileResponse(full_path) # 如果都找不到,返回404 raise web.HTTPNotFound() # 存储动态路由映射 DYNAMIC_API_ROUTES = {} def is_module_match(handler_module: str, module_name: str, sys_module_name: str = None) -> bool: """ 检查 handler 的模块名是否匹配目标模块 Args: handler_module: handler.__module__ 的值 module_name: 模块名(如 "comfyui-clear-screen") sys_module_name: sys.modules 中的模块名(路径中的 "." 被替换为 "_x_") Returns: 是否匹配 """ if not handler_module: return False # 直接匹配 if module_name == handler_module: return True # 匹配 sys_module_name(如果提供) if sys_module_name and sys_module_name == handler_module: return True # 匹配各种路径格式 patterns = [ f"custom_nodes.{module_name}", f"custom_nodes\\{module_name}", f"custom_nodes/{module_name}", f"\\{module_name}", f"/{module_name}", ] for pattern in patterns: if handler_module.endswith(pattern) or pattern in handler_module: return True # 如果 sys_module_name 提供了,也检查它的各种格式 if sys_module_name: if sys_module_name in handler_module or handler_module in sys_module_name: return True return False @PromptServer.instance.routes.get("/api/{path:.*}") async def dynamic_api_handler(request): """动态处理热更新的API路由""" path = "/" + request.match_info['path'] # 重构完整路径 method = request.method.upper() # 查找动态注册的处理器 route_key = f"{method}:{path}" if route_key in DYNAMIC_API_ROUTES: handler = DYNAMIC_API_ROUTES[route_key] return await handler(request) # 如果没找到动态路由,让系统继续处理 raise web.HTTPNotFound() def register_module_routes(module_name, sys_module_name=None): """注册模块的所有路由到动态路由表""" # 清理旧路由 - 从 DYNAMIC_API_ROUTES(通过模块路径匹配) keys_to_remove = [] for route_key, handler in DYNAMIC_API_ROUTES.items(): if hasattr(handler, '__module__'): handler_module = handler.__module__ # 使用统一的匹配函数 if is_module_match(handler_module, module_name, sys_module_name): keys_to_remove.append(route_key) for key in keys_to_remove: del DYNAMIC_API_ROUTES[key] # 注册新路由到动态路由表 registered_count = 0 for route in PromptServer.instance.routes: if hasattr(route, 'handler') and hasattr(route.handler, '__module__'): handler_module = route.handler.__module__ # 使用统一的匹配函数 if is_module_match(handler_module, module_name, sys_module_name): route_key = f"{route.method}:{route.path}" DYNAMIC_API_ROUTES[route_key] = route.handler registered_count += 1 if (HOTRELOAD_EXCLUDE := os.getenv("HOTRELOAD_EXCLUDE", None)) is not None: EXCLUDE_MODULES.update(x for x in HOTRELOAD_EXCLUDE.split(',') if x) HOTRELOAD_OBSERVE_ONLY: set[str] = set(x for x in os.getenv("HOTRELOAD_OBSERVE_ONLY", '').split(',') if x) HOTRELOAD_EXTENSIONS: set[str] = set(x.strip() for x in os.getenv("HOTRELOAD_EXTENSIONS", '.py').split(',') if x) try: DEBOUNCE_TIME: float = float(os.getenv("HOTRELOAD_DEBOUNCE_TIME", 1.0)) except ValueError: DEBOUNCE_TIME = 1.0 def hash_file(file_path: str) -> str: try: with open(file_path, 'rb') as f: return hashlib.md5(f.read()).hexdigest() except Exception as e: logging.error(f"Error reading file {file_path}: {e}") return None def is_hidden_file_windows(file_path: str) -> bool: try: import ctypes attribute = ctypes.windll.kernel32.GetFileAttributesW(file_path) if attribute == -1: return False return attribute & 0x2 != 0 except Exception as e: logging.error(f"Error checking if file is hidden on Windows: {e}") return False def is_hidden_file(file_path: str) -> bool: file_path = os.path.abspath(file_path) if sys.platform.startswith('win'): while file_path and file_path != os.path.dirname(file_path): if is_hidden_file_windows(file_path): return True file_path = os.path.dirname(file_path) else: while file_path and file_path != os.path.dirname(file_path): if os.path.basename(file_path).startswith('.'): return True file_path = os.path.dirname(file_path) return False def dfs(item_list: list, searches: set) -> bool: for item in item_list: if isinstance(item, (frozenset, tuple)) and dfs(item, searches): return True elif item in searches: return True return False class DebouncedHotReloader(FileSystemEventHandler): def __init__(self, delay: float = 1.0): super().__init__() self.__delay: float = delay self.__last_modified: defaultdict[str, float] = defaultdict(float) self.__reload_timers: dict[str, threading.Timer] = {} self.__hashes: dict[str, str] = {} self.__lock: threading.Lock = threading.Lock() # 添加最后成功重载时间记录 self.__last_successful_reload: defaultdict[float] = defaultdict(float) self.__successful_reload_cooldown = 5.0 # 成功重载后的冷却时间(秒) def __reload(self, module_name: str) -> web.Response: with self.__lock: try: print(f'\n\033[94m[LG_HotReload] 开始重载模块: {module_name}\033[0m') # 计算 sys_module_name(与 load_custom_node 中的逻辑一致) # load_custom_node 会将路径中的 "." 替换为 "_x_" module_path_for_sys = os.path.join(CUSTOM_NODE_ROOT[0], module_name) sys_module_name = module_path_for_sys.replace(".", "_x_") original_routes_count = len(PromptServer.instance.routes) # 收集需要保留的路由 routes_to_keep = [] routes_removed_count = 0 for route in PromptServer.instance.routes: should_remove = False if hasattr(route, 'handler') and hasattr(route.handler, '__module__'): handler_module = route.handler.__module__ # 使用统一的匹配函数 if is_module_match(handler_module, module_name, sys_module_name): should_remove = True routes_removed_count += 1 route_key = f"{route.method}:{route.path}" if not should_remove: routes_to_keep.append(route) # 重建路由表 if routes_removed_count > 0: # 由于RouteTableDef不支持直接删除路由,我们采用替换策略 # 直接使用_items清理路由 try: PromptServer.instance.routes._items.clear() PromptServer.instance.routes._items.extend(routes_to_keep) except Exception as e: print(f'\033[91m[LG_HotReload] 路由清理失败: {str(e)}\033[0m') traceback.print_exc() else: print(f'\033[96m[LG_HotReload] 未发现需要清理的路由\033[0m') module_path = os.path.join(CUSTOM_NODE_ROOT[0], module_name) # 收集需要重新加载的所有模块 modules_to_reload = set() for name, module in list(sys.modules.items()): if hasattr(module, '__file__') and module.__file__ and \ module.__file__.startswith(module_path): modules_to_reload.add(name) # 删除所有相关模块 for name in modules_to_reload: if name in sys.modules: del sys.modules[name] # 重新加载自定义节点 # 追踪load_custom_node前的路由数量 routes_before_load = len(PromptServer.instance.routes) try: # 使用 asyncio.run 来同步调用异步函数 success = asyncio.run(load_custom_node(module_path)) except Exception as e: print(f'\033[91m[LG_HotReload] 调用 load_custom_node 失败: {str(e)}\033[0m') success = False # 追踪load_custom_node后的路由数量 routes_after_load = len(PromptServer.instance.routes) if not success: print(f'\033[91m[LG_HotReload] 加载模块失败: {module_name}\033[0m') return web.Response(text='FAILED') # 关键步骤:同步新路由到 aiohttp 的 router # 通过直接替换 handler 来实现热重载 try: # 获取新添加的路由 new_routes_count = routes_after_load - routes_before_load if new_routes_count > 0 and hasattr(PromptServer.instance, 'app') and PromptServer.instance.app: new_routes = list(PromptServer.instance.routes)[-new_routes_count:] router = PromptServer.instance.app.router for route in new_routes: if hasattr(route, 'method') and hasattr(route, 'path') and hasattr(route, 'handler'): if hasattr(route.handler, '__module__'): handler_module = route.handler.__module__ if is_module_match(handler_module, module_name, sys_module_name): handler_id = id(route.handler) # 查找并替换旧的 handler for resource in list(router._resources): resource_path = getattr(resource, '_path', None) or getattr(resource, 'canonical', None) # 匹配路径(包括 /api 前缀的版本) if resource_path and (resource_path == route.path or resource_path == f"/api{route.path}"): for route_obj in resource: if hasattr(route_obj, 'handler') and hasattr(route_obj.handler, '__module__'): route_handler_module = route_obj.handler.__module__ if is_module_match(route_handler_module, module_name, sys_module_name): # 直接替换 handler(保留路由缓存结构) if hasattr(route_obj, '_handler'): old_id = id(route_obj._handler) route_obj._handler = route.handler except Exception as e: print(f'\033[91m[LG_HotReload] 路由同步失败: {str(e)}\033[0m') traceback.print_exc() # 确保模块被正确注册到sys.modules中 try: import importlib.util # 构建完整的模块名(包含custom_nodes前缀) full_module_name = f"custom_nodes.{module_name}" if os.path.isfile(module_path): # 处理单个.py文件 spec = importlib.util.spec_from_file_location(full_module_name, module_path) else: # 处理模块目录 init_path = os.path.join(module_path, '__init__.py') spec = importlib.util.spec_from_file_location(full_module_name, init_path) if spec: # 追踪模块注册前的路由数量 routes_before_register = len(PromptServer.instance.routes) # load_custom_node已经执行了模块代码,这里只需要注册到sys.modules # 获取已经加载的模块(通过load_custom_node加载) loaded_module = None # 尝试从sys.modules中找到已加载的模块 for mod_name, mod in sys.modules.items(): if (hasattr(mod, '__file__') and mod.__file__ and mod.__file__.startswith(module_path)): loaded_module = mod break if loaded_module: # 使用已加载的模块,避免重复执行 sys.modules[full_module_name] = loaded_module sys.modules[module_name] = loaded_module else: # 如果找不到已加载的模块,则正常加载(备用方案) module = importlib.util.module_from_spec(spec) sys.modules[full_module_name] = module sys.modules[module_name] = module spec.loader.exec_module(module) except Exception as e: print(f'\033[91m[LG_HotReload] 重新注册模块失败: {str(e)}\033[0m') traceback.print_exc() # 确保节点被正确注册到全局的 NODE_CLASS_MAPPINGS 中 module = sys.modules.get(module_name) if module and hasattr(module, 'NODE_CLASS_MAPPINGS'): # 先清理旧的节点映射 for name in list(nodes.NODE_CLASS_MAPPINGS.keys()): if name in module.NODE_CLASS_MAPPINGS: del nodes.NODE_CLASS_MAPPINGS[name] # 重新注册节点 for name, node_cls in module.NODE_CLASS_MAPPINGS.items(): nodes.NODE_CLASS_MAPPINGS[name] = node_cls node_cls.RELATIVE_PYTHON_MODULE = f"custom_nodes.{module_name}" if hasattr(module, 'NODE_DISPLAY_NAME_MAPPINGS'): nodes.NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS) # 更新节点类型 if module and hasattr(module, 'NODE_CLASS_MAPPINGS'): for key in module.NODE_CLASS_MAPPINGS.keys(): RELOADED_CLASS_TYPES[key] = 3 # 重新注册API路由(到动态路由表) register_module_routes(module_name, sys_module_name) print(f'\033[92m[LG_HotReload] 模块重载成功: {module_name}\033[0m') return web.Response(text='OK') except Exception as e: logging.error(f"Failed to reload module {module_name}: {e}") traceback.print_exc() return web.Response(text='FAILED') def on_created(self, event): if event.is_directory: return self.handle_file_event(event.src_path) def on_deleted(self, event): if event.is_directory: return self.handle_file_event(event.src_path) def handle_file_event(self, file_path: str): if not any(ext == '*' for ext in HOTRELOAD_EXTENSIONS): if not any(file_path.endswith(ext) for ext in HOTRELOAD_EXTENSIONS): return if is_hidden_file(file_path): return relative_path: str = os.path.relpath(file_path, CUSTOM_NODE_ROOT[0]) root_dir: str = relative_path.split(os.path.sep)[0] if HOTRELOAD_OBSERVE_ONLY and root_dir not in HOTRELOAD_OBSERVE_ONLY: return elif root_dir in EXCLUDE_MODULES: return self.schedule_reload(root_dir, file_path) def on_modified(self, event): if event.is_directory: return self.handle_file_event(event.src_path) def schedule_reload(self, module_name: str, file_path: str): current_time: float = time.time() self.__last_modified[module_name] = current_time with self.__lock: if module_name in self.__reload_timers: self.__reload_timers[module_name].cancel() timer = threading.Timer( self.__delay, self.check_and_reload, args=[module_name, current_time, file_path] ) self.__reload_timers[module_name] = timer timer.start() def check_and_reload(self, module_name: str, scheduled_time: float, file_path: str): with self.__lock: if self.__last_modified[module_name] != scheduled_time: return # 检查是否在冷却期内 current_time = time.time() if (current_time - self.__last_successful_reload[module_name]) < self.__successful_reload_cooldown: print(f"\033[93m[LG_HotReload] Module {module_name} was recently reloaded, skipping...\033[0m") return try: # 获取重载前的节点信息 old_nodes = set() old_module = sys.modules.get(module_name) if old_module and hasattr(old_module, 'NODE_CLASS_MAPPINGS'): old_nodes = set(old_module.NODE_CLASS_MAPPINGS.keys()) # 重载模块 self.__reload(module_name) # 添加调试信息 print(f'\033[94m[LG_HotReload] 检查节点注册状态:\033[0m') module = sys.modules.get(module_name) if module and hasattr(module, 'NODE_CLASS_MAPPINGS'): for node_class in module.NODE_CLASS_MAPPINGS.keys(): if node_class in nodes.NODE_CLASS_MAPPINGS: print(f'\033[92m[LG_HotReload] 节点 {node_class} 已成功注册\033[0m') else: print(f'\033[91m[LG_HotReload] 节点 {node_class} 注册失败\033[0m') # 获取重载后的节点信息 new_nodes = set() if module and hasattr(module, 'NODE_CLASS_MAPPINGS'): new_nodes = set(module.NODE_CLASS_MAPPINGS.keys()) # 计算节点变化 added_nodes = new_nodes - old_nodes removed_nodes = old_nodes - new_nodes updated_nodes = new_nodes & old_nodes # 确定文件变更类型 action = "deleted" if not os.path.exists(file_path) else "added" if file_path not in self.__hashes else "modified" # 发送更新消息给前端 update_message = { "type": "hot_reload_update", "data": { "module": module_name, "action": action, "file": file_path, "timestamp": time.time(), "changes": { "added": list(added_nodes), "removed": list(removed_nodes), "updated": list(updated_nodes) } } } if hasattr(PromptServer.instance, "send_sync"): PromptServer.instance.send_sync( "hot_reload_update", update_message["data"] ) self.__last_successful_reload[module_name] = time.time() print(f'\033[92m[LG_HotReload] Successfully reloaded module: {module_name}\033[0m') except requests.RequestException as e: print(f'\033[91m[LG_HotReload] Reload failed: {e}\033[0m') except Exception as e: print(f'\033[91m[LG_HotReload] Error occurred: {e}\033[0m') traceback.print_exc() class HotReloaderService: def __init__(self, delay: float = 1.0): self.__observer: Observer = None self.__reloader: DebouncedHotReloader = DebouncedHotReloader(delay) def start(self): self.__observer = Observer() self.__observer.schedule(self.__reloader, CUSTOM_NODE_ROOT[0], recursive=True) self.__observer.start() def stop(self): if self.__observer: self.__observer.stop() self.__observer.join() def monkeypatch(): original_set_prompt = caching.BasicCache.set_prompt def set_prompt(self, dynprompt, node_ids, is_changed_cache): if not hasattr(self, 'cache_key_set'): RELOADED_CLASS_TYPES.clear() return original_set_prompt(self, dynprompt, node_ids, is_changed_cache) found_keys = [] for key, item_list in self.cache_key_set.keys.items(): if dfs(item_list, RELOADED_CLASS_TYPES): found_keys.append(key) if len(found_keys): for value_key in list(RELOADED_CLASS_TYPES.keys()): RELOADED_CLASS_TYPES[value_key] -= 1 if RELOADED_CLASS_TYPES[value_key] == 0: del RELOADED_CLASS_TYPES[value_key] for key in found_keys: cache_key = self.cache_key_set.get_data_key(key) if cache_key and cache_key in self.cache: del self.cache[cache_key] del self.cache_key_set.keys[key] del self.cache_key_set.subcache_keys[key] return original_set_prompt(self, dynprompt, node_ids, is_changed_cache) caching.HierarchicalCache.set_prompt = set_prompt def setup(): logging.info("[LG_HotReload] Monkey patching comfy_execution.caching.BasicCache") monkeypatch() hot_reloader_service = HotReloaderService(delay=DEBOUNCE_TIME) atexit.register(hot_reloader_service.stop) hot_reloader_service.start() setup() WEB_DIRECTORY = "./web" NODE_CLASS_MAPPINGS = {"HotReload_Terminal": HotReload_Terminal} NODE_DISPLAY_NAME_MAPPINGS = {"HotReload_Terminal": "Terminal"}