Files
2026-08-15 18:06:43 +08:00

644 lines
24 KiB
Python

import os
import sys
import json
import psutil
import pynvml
import logging
import hashlib
import traceback
import collections
import nodes
import folder_paths
import comfy.model_management
from aiohttp import web
from nodes import LoraLoader
from server import PromptServer
from comfy_api.internal import _ComfyNodeInternal
try:
pynvml.nvmlInit()
pynvml_initialized = True
except Exception:
pynvml_initialized = False
class AppBuilder:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"config_json": ("STRING", {"default": '{}'}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
"prompt": "PROMPT"
}
}
RETURN_TYPES = tuple(["*"])
RETURN_NAMES = tuple(["any"])
FUNCTION = "main"
CATEGORY = "AppBuilder"
def main(self, **kwargs):
return ()
class AppBuilderBypasser:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"group_name": ("STRING", {"default": ""}),
"bypasser_name": ("STRING", {"default": ""}),
},
}
RETURN_TYPES = ("BYPASSER",)
RETURN_NAMES = ("bypasser",)
FUNCTION = "main"
CATEGORY = "AppBuilder"
DESCRIPTION = "Turn your workflow into an easy-to-use web application."
def main(self, **kwargs):
return ()
class AppBuilderAdv:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"config_json": ("STRING", {
"default": '{"string":{"type": "string","name": "Usage","placeholder": "Click [⚙️ Configuration Panel] button to generate the widgets you need.","multiline":true,"tooltip":"You can delete this sample widget"}}'
}),
"live_preview": ("BOOLEAN", {"default": False}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
"prompt": "PROMPT"
}
}
RETURN_TYPES = ("parameters",)
RETURN_NAMES = ("parameters",)
FUNCTION = "execute"
CATEGORY = "AppBuilder"
DESCRIPTION = "Turn your workflow into an easy-to-use web application."
@classmethod
def VALIDATE_INPUTS(cls, **kwargs):
return True
def execute(self, config_json, unique_id=None, prompt=None, **kwargs):
try:
config = json.loads(config_json)
keys = list(config.keys())[:32]
results = {}
raw_inputs = {}
if prompt is not None and unique_id is not None:
node_info = prompt.get(str(unique_id), {})
raw_inputs = node_info.get("inputs", {})
for key in keys:
params = config[key]
val = raw_inputs.get(key, kwargs.get(key, params.get("default", None)))
expected_type = params.get("type", "STRING").upper()
precision = params.get("precision", None)
if val is not None:
try:
if expected_type == "INT": val = int(round(float(val)))
elif expected_type == "FLOAT":
val = float(val)
if precision is not None: val = round(val, int(precision))
elif expected_type == "BOOLEAN": val = bool(val)
except: pass
results[key] = val
bundle = {
"data": results,
"config": {k: config[k] for k in keys}
}
return (bundle,)
except Exception:
raise
class ParametersUnpacker:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"parameters": ("parameters",),
}
}
RETURN_TYPES = tuple(["*"] * 32)
RETURN_NAMES = tuple(["*"] * 32)
FUNCTION = "unpack"
CATEGORY = "AppBuilder"
def unpack(self, parameters=None):
try:
data = parameters["data"]
config = parameters["config"]
results = []
for key in config.keys():
results.append(data.get(key, None))
return tuple(results)
except Exception:
raise
class AppBuilderLoraStack:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"lora_stack": ("STRING", {"default": '[{"lora_name":"None","strength":1.0,"enabled":true}]'}),
},
"optional": {
"model": ("MODEL",),
"clip": ("CLIP",),
}
}
RETURN_TYPES = ("MODEL", "CLIP")
RETURN_NAMES = ("MODEL", "CLIP")
FUNCTION = "apply_lora_stack"
CATEGORY = "AppBuilder"
def apply_lora_stack(self, lora_stack, model=None, clip=None):
try:
loras = json.loads(lora_stack)
except Exception:
loras = []
if not isinstance(loras, list):
loras = []
current_model = model
current_clip = clip
lora_loader = LoraLoader()
for lora_info in loras:
if not isinstance(lora_info, dict):
continue
# 1. 过滤未开启的 Lora
if not lora_info.get("enabled", True):
continue
lora_name = lora_info.get("lora_name")
if not lora_name or lora_name == "None":
continue
# 2. 提取强度 (学习 rgthree:支持 Model 强度与 CLIP 强度解耦)
strength_model = float(lora_info.get("strength", 1.0))
# 如果前端没传 strength_clip,默认使用与 strength_model 相同的值
strength_clip = float(lora_info.get("strength_clip", strength_model))
# 如果强度全部为 0,跳过融合以节省算力
if strength_model == 0 and strength_clip == 0:
continue
# 3. 校验文件是否存在
lora_path = folder_paths.get_full_path("loras", lora_name)
if not lora_path:
logging.warning(f"[AppBuilderLoraStack] Warning: LoRA file '{lora_name}' not found!")
continue
# 4. 调用官方 LoraLoader 执行安全融合
try:
if current_model is not None or current_clip is not None:
current_model, current_clip = lora_loader.load_lora(
current_model,
current_clip,
lora_name,
strength_model,
strength_clip
)
except Exception as e:
logging.error(f"[AppBuilderLoraStack] Error loading LoRA '{lora_name}': {e}")
return (current_model, current_clip)
@PromptServer.instance.routes.get("/appbuilder/ls/{folder}")
async def get_models_list(request):
folder = request.match_info.get("folder")
if folder in folder_paths.folder_names_and_paths.keys():
files = folder_paths.get_filename_list(folder)
return web.json_response(files)
return web.json_response([], status=404)
log_buffer = collections.deque(maxlen=420)
log_counter = 0
class ComfyUIAppViewLogHandler(logging.Handler):
def emit(self, record):
global log_counter
try:
msg = self.format(record)
if msg.strip():
log_counter += 1
log_buffer.append((log_counter, msg))
try:
PromptServer.instance.send_sync("appbuilder_log", {"id": log_counter, "text": msg})
except Exception:
pass
except Exception:
self.handleError(record)
root_logger = logging.getLogger()
log_handler = ComfyUIAppViewLogHandler()
log_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
root_logger.addHandler(log_handler)
class LogStreamWrapper:
def __init__(self, original_stream):
self.original_stream = original_stream
def write(self, data):
global log_counter
self.original_stream.write(data)
is_progress = "\r" in data or "%|" in data or "it/s" in data or "s/it" in data
if is_progress:
clean_data = data.replace("\r", "").strip()
if clean_data:
log_counter += 1
log_buffer.append((log_counter, clean_data))
try:
PromptServer.instance.send_sync("appbuilder_log", {"id": log_counter, "text": clean_data})
except Exception:
pass
def flush(self):
self.original_stream.flush()
sys.stdout = LogStreamWrapper(sys.stdout)
sys.stderr = LogStreamWrapper(sys.stderr)
@PromptServer.instance.routes.get("/appbuilder/logs")
async def get_captured_logs(request):
try:
after_id = int(request.query.get("after", 0))
except ValueError:
after_id = 0
new_logs = []
for log_id, line in log_buffer:
if log_id > after_id:
new_logs.append({"id": log_id, "text": line})
return web.json_response(new_logs)
def get_app_workflows_dir():
base_dir = os.path.join(folder_paths.get_user_directory(), "default")
app_dir = os.path.join(base_dir, "workflows", "app")
if not os.path.exists(app_dir):
os.makedirs(app_dir, exist_ok=True)
return app_dir
@PromptServer.instance.routes.get("/appbuilder/workflows")
async def list_app_workflows(request):
try:
target_dir = get_app_workflows_dir()
files = []
for f in os.listdir(target_dir):
if f.endswith(".json"):
full_path = os.path.join(target_dir, f)
stat = os.stat(full_path)
files.append({
"name": f,
"mtime": stat.st_mtime,
"size": stat.st_size
})
return web.json_response(files)
except Exception as e:
return web.json_response([], status=500)
@PromptServer.instance.routes.get("/appbuilder/workflows/get")
async def get_app_workflow_content(request):
try:
filename = request.query.get("file")
if not filename or ".." in filename or "/" in filename or "\\" in filename:
return web.json_response({"error": "Invalid filename"}, status=400)
target_dir = get_app_workflows_dir()
full_path = os.path.join(target_dir, filename)
if os.path.exists(full_path):
with open(full_path, "r", encoding="utf-8") as f:
data = json.load(f)
return web.json_response(data)
return web.json_response({"error": "File not found"}, status=404)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/appbuilder/workflows/save")
async def save_app_workflow(request):
try:
data = await request.json()
filename = data.get("filename")
content = data.get("content")
if not filename or not content:
return web.json_response({"error": "Missing filename or content"}, status=400)
target_path = os.path.join(get_app_workflows_dir(), filename)
with open(target_path, "w", encoding="utf-8") as f:
if isinstance(content, dict):
json.dump(content, f, indent=2, ensure_ascii=False)
else:
f.write(str(content))
return web.json_response({"status": "success", "filename": filename})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
IGNORE = {'.git', '__pycache__', 'node_modules', '.venv', 'venv'}
EXTS = {'.py', '.safetensors', '.ckpt', '.pt', '.bin', '.pth', '.onnx', '.sft'}
def scan_dir(path, file_list):
try:
for entry in os.scandir(path):
name = entry.name
if name.startswith('.') or name in IGNORE:
continue
try:
if entry.is_dir(follow_symlinks=False):
scan_dir(entry.path, file_list)
else:
ext = os.path.splitext(name)[1].lower()
if ext in EXTS:
stat = entry.stat(follow_symlinks=False)
file_list.append(f"{entry.path}_{stat.st_mtime_ns}_{stat.st_size}")
except OSError:
continue
except OSError:
pass
_BOOT_SIGNATURE = str(os.getpid())
def get_nodes_environment_hash():
"""检测模型和节点的物理更新 (mtime & size)"""
hasher = hashlib.md5()
hasher.update(_BOOT_SIGNATURE.encode())
file_list = []
try:
custom_node_paths = folder_paths.get_folder_paths("custom_nodes")
except Exception:
custom_node_paths = []
for custom_dir in custom_node_paths:
if os.path.isdir(custom_dir):
scan_dir(custom_dir, file_list)
visited_dirs = set()
for type_name, paths_tuple in folder_paths.folder_names_and_paths.items():
for base_path in paths_tuple[0]:
if not os.path.isdir(base_path):
continue
for root, dirs, _ in os.walk(base_path, followlinks=True):
real_root = os.path.realpath(root)
if real_root in visited_dirs:
dirs[:] = []
continue
visited_dirs.add(real_root)
try:
stat = os.stat(root)
file_list.append(f"dir_{root}_{stat.st_mtime_ns}")
except OSError:
pass
for item in sorted(file_list):
hasher.update(item.encode())
return hasher.hexdigest()
def safe_get_node_info(node_class):
# 2. 完整移植官方 node_info 逻辑 (支持 V1 节点协议、中文名、描述气泡等)
try:
if hasattr(nodes, "NODE_CLASS_MAPPINGS") and node_class in nodes.NODE_CLASS_MAPPINGS:
obj_class = nodes.NODE_CLASS_MAPPINGS[node_class]
# 支持 ComfyUI 内部 V1 节点新协议
if issubclass(obj_class, _ComfyNodeInternal):
return obj_class.GET_NODE_INFO_V1()
info = {}
info['input'] = obj_class.INPUT_TYPES()
info['input_order'] = {key: list(value.keys()) for (key, value) in obj_class.INPUT_TYPES().items()}
info['is_input_list'] = getattr(obj_class, "INPUT_IS_LIST", False)
info['output'] = obj_class.RETURN_TYPES
info['output_is_list'] = getattr(obj_class, "OUTPUT_IS_LIST", [False] * len(obj_class.RETURN_TYPES))
info['output_name'] = getattr(obj_class, "RETURN_NAMES", info['output'])
info['name'] = node_class
info['display_name'] = nodes.NODE_DISPLAY_NAME_MAPPINGS.get(node_class, node_class)
info['description'] = getattr(obj_class, "DESCRIPTION", "")
info['python_module'] = getattr(obj_class, "RELATIVE_PYTHON_MODULE", "nodes")
info['category'] = getattr(obj_class, "CATEGORY", "sd")
info['output_node'] = getattr(obj_class, "OUTPUT_NODE", False)
info['has_intermediate_output'] = getattr(obj_class, "HAS_INTERMEDIATE_OUTPUT", False)
info['output_tooltips'] = getattr(obj_class, "OUTPUT_TOOLTIPS", None)
info['deprecated'] = getattr(obj_class, "DEPRECATED", False)
info['experimental'] = getattr(obj_class, "EXPERIMENTAL", False)
info['dev_only'] = getattr(obj_class, "DEV_ONLY", False)
if hasattr(obj_class, 'API_NODE'):
info['api_node'] = obj_class.API_NODE
info['search_aliases'] = getattr(obj_class, 'SEARCH_ALIASES', [])
if hasattr(obj_class, 'ESSENTIALS_CATEGORY'):
info['essentials_category'] = obj_class.ESSENTIALS_CATEGORY
return info
except Exception as e:
logging.warning(f"[AppBuilder] Failed to get full node_info for '{node_class}': {e}")
return None
# ===== 内存智能缓存变量 =====
_GLOBAL_OBJECT_INFO_CACHE = None
_GLOBAL_ENV_HASH = None
def get_cached_object_info():
global _GLOBAL_OBJECT_INFO_CACHE, _GLOBAL_ENV_HASH
current_hash = get_nodes_environment_hash()
# 如果强刷、未缓存、缓存长度为 0、或 Hash 变动,强制刷新
if _GLOBAL_OBJECT_INFO_CACHE is None or len(_GLOBAL_OBJECT_INFO_CACHE) == 0 or current_hash != _GLOBAL_ENV_HASH:
try:
if hasattr(nodes, "asset_seeder") and hasattr(nodes.asset_seeder, "start"):
nodes.asset_seeder.start(roots=("models", "input", "output"))
except Exception:
pass
out = {}
if hasattr(nodes, "NODE_CLASS_MAPPINGS"):
for x in list(nodes.NODE_CLASS_MAPPINGS.keys()):
info = safe_get_node_info(x)
if info:
out[x] = info
if len(out) > 0:
_GLOBAL_OBJECT_INFO_CACHE = out
_GLOBAL_ENV_HASH = current_hash
#logging.info(f"[AppBuilder] Object Info Cache Updated! {len(out)} nodes loaded successfully. (Hash: {current_hash[:8]})")
else:
logging.error("[AppBuilder] Warning: NODE_CLASS_MAPPINGS is empty or failed to load!")
return _GLOBAL_OBJECT_INFO_CACHE or {}, _GLOBAL_ENV_HASH
# ===== 🚀 智能按需 + 自动感知的 API 路由 =====
@PromptServer.instance.routes.get("/appbuilder/fast_object_info")
async def get_fast_object_info_smart(request):
try:
app_file = request.query.get("app")
# 1. 智能获取全局最新信息 (若无变动直接返回 0ms 内存缓存)
all_info, env_hash = get_cached_object_info()
# 2. 如果指定了 app 配置文件,做“按需过滤” (15MB 降到 ~10KB)
if app_file:
clean_filename = os.path.basename(app_file)
if not clean_filename.endswith(".json"):
clean_filename += ".json"
app_path = os.path.join(get_app_workflows_dir(), clean_filename)
if os.path.exists(app_path):
with open(app_path, "r", encoding="utf-8") as f:
wf_data = json.load(f)
used_types = set(n.get("type") for n in wf_data.get("nodes", []) if n.get("type"))
filtered_info = {k: v for k, v in all_info.items() if k in used_types}
response = web.json_response(filtered_info)
response.headers['ETag'] = f'"{env_hash}"'
response.headers['Cache-Control'] = 'no-cache'
return response
# 3. 兜底返回完整数据
response = web.json_response(all_info)
response.headers['ETag'] = f'"{env_hash}"'
response.headers['Cache-Control'] = 'no-cache'
return response
except Exception as e:
logging.error(f"[AppBuilder] fast_object_info error: {e}")
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.get("/appbuilder/system_stats")
async def system_stats(request):
# 1. CPU 利用率与核心数 (非阻塞获取,耗时 0ms)
cpu_utilization = round(psutil.cpu_percent(interval=None), 1)
cpu_count = psutil.cpu_count(logical=True) # 逻辑 CPU 核心数
# 2. 内存 RAM
virtual_mem = psutil.virtual_memory()
ram_total = virtual_mem.total
ram_free = virtual_mem.available
ram_used = ram_total - ram_free
ram_utilization = round(virtual_mem.percent, 1)
# 3. NVIDIA GPU 信息 (使用 NVML 独立查询,绝不与 aimdo/VBAR 冲撞)
device_entries = []
if pynvml_initialized:
try:
device_count = pynvml.nvmlDeviceGetCount()
for i in range(device_count):
handle = pynvml.nvmlDeviceGetHandleByIndex(i)
# 获取显卡名称
gpu_name = pynvml.nvmlDeviceGetName(handle)
if isinstance(gpu_name, bytes):
gpu_name = gpu_name.decode('utf-8', errors='ignore')
# 获取显存(走 nvml.dll,避开 CUDA Driver API 碰撞)
mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle)
vram_total = mem_info.total
vram_used = mem_info.used
vram_free = mem_info.free
vram_utilization = round((vram_used / vram_total) * 100, 1) if vram_total > 0 else 0
# 获取 GPU 核心利用率与温度
gpu_utilization = 0
gpu_temperature = 0
try:
gpu_utilization = pynvml.nvmlDeviceGetUtilizationRates(handle).gpu
gpu_temperature = pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU)
except Exception:
pass
device_entries.append({
"name": gpu_name,
"type": "cuda",
"index": i,
"gpu_utilization": gpu_utilization,
"gpu_temperature": gpu_temperature,
"vram_total": vram_total,
"vram_free": vram_free,
"vram_used": vram_used,
"vram_utilization": vram_utilization
})
except Exception:
pass
# 4. 返回 JSON 数据
return web.json_response({
"system": {
"cpu_utilization": cpu_utilization, # CPU 利用率 (例如: 25.5)
"cpu_count": cpu_count, # CPU 核心数 (例如: 16)
"ram_total": ram_total, # 总内存 (字节)
"ram_free": ram_free, # 可用内存 (字节)
"ram_used": ram_used, # 已用内存 (字节)
"ram_utilization": ram_utilization # 内存利用率 (例如: 45.2)
},
"devices": device_entries
})
NODE_CLASS_MAPPINGS = {
"AppBuilder": AppBuilder,
"AppBuilderBypasser": AppBuilderBypasser,
"AppBuilderAdv": AppBuilderAdv,
"ParametersUnpacker": ParametersUnpacker,
"AppBuilderLoraStack": AppBuilderLoraStack,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AppBuilder": "AppBuilder",
"AppBuilderBypasser": "AppBuilder Bypasser",
"AppBuilderAdv": "AppBuilder (Advanced)",
"ParametersUnpacker": "Parameters Unpacker",
"AppBuilderLoraStack": "AppBuilder Lora Stack",
}
WEB_DIRECTORY = "./web"
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
@PromptServer.instance.routes.get("/apps")
async def serve_app_manager(request):
html_path = os.path.join(CURRENT_DIR, "web", "app_manager.html")
if os.path.exists(html_path):
return web.FileResponse(html_path)
return web.Response(status=404, text="app_manager.html not found")
@PromptServer.instance.routes.get("/app_view.html")
async def serve_app_view(request):
html_path = os.path.join(CURRENT_DIR, "web", "app_view.html")
if os.path.exists(html_path):
return web.FileResponse(html_path)
return web.Response(status=404, text="app_view.html not found")
@PromptServer.instance.routes.get("/i18n/{filename}")
async def serve_i18n_files(request):
filename = request.match_info['filename']
json_path = os.path.join(CURRENT_DIR, "web", "i18n", filename)
if os.path.exists(json_path):
return web.FileResponse(json_path, headers={"Cache-Control": "no-cache"})
return web.Response(status=404, text=f"'i18n/{filename}' not found")