diff --git a/__init__.py b/__init__.py index b8dcde3..494238a 100644 --- a/__init__.py +++ b/__init__.py @@ -1,37 +1,44 @@ # 从各节点文件导入映射 -from .ollama_node import ( +from .node.ollama_node import ( NODE_CLASS_MAPPINGS as OLLAMA_NODE_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as OLLAMA_DISPLAY_NAMES ) -from .online_api import ( +from .node.online_api import ( NODE_CLASS_MAPPINGS as LLM_ONLINE_API, NODE_DISPLAY_NAME_MAPPINGS as LLM_ONLINE_API_DISPLAY_NAMES ) -from .string_utils import ( +from .node.string_utils import ( NODE_CLASS_MAPPINGS as STRING_UTILS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as STRING_UTILS_DISPLAY_NAMES ) -from .qiniu_uploader import ( +from .node.qiniu_uploader import ( NODE_CLASS_MAPPINGS as QINIU_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as QINIU_DISPLAY_NAMES ) +from .node.img_utils import ( + NODE_CLASS_MAPPINGS as IMG_UTIL_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as IMG_UTIL_DISPLAY_NAMES +) + # 合并所有映射 NODE_CLASS_MAPPINGS = { **OLLAMA_NODE_MAPPINGS, **LLM_ONLINE_API, **STRING_UTILS_MAPPINGS, - **QINIU_MAPPINGS + **QINIU_MAPPINGS, + **IMG_UTIL_MAPPINGS } NODE_DISPLAY_NAME_MAPPINGS = { **OLLAMA_DISPLAY_NAMES, **LLM_ONLINE_API_DISPLAY_NAMES, **STRING_UTILS_DISPLAY_NAMES, - **QINIU_DISPLAY_NAMES + **QINIU_DISPLAY_NAMES, + **IMG_UTIL_DISPLAY_NAMES } # 定义公开接口 diff --git a/node/img_utils.py b/node/img_utils.py new file mode 100644 index 0000000..60d1d69 --- /dev/null +++ b/node/img_utils.py @@ -0,0 +1,54 @@ +from PIL import Image, ImageOps +from PIL.PngImagePlugin import PngInfo +import requests +import torch +import numpy as np + +def pil2tensor(images: Image.Image | list[Image.Image]) -> torch.Tensor: + """Converts a PIL Image or a list of PIL Images to a tensor.""" + + def single_pil2tensor(image: Image.Image) -> torch.Tensor: + np_image = np.array(image).astype(np.float32) / 255.0 + if np_image.ndim == 2: # Grayscale + return torch.from_numpy(np_image).unsqueeze(0) # (1, H, W) + else: # RGB or RGBA + return torch.from_numpy(np_image).unsqueeze(0) # (1, H, W, C) + + if isinstance(images, Image.Image): + return single_pil2tensor(images) + else: + return torch.cat([single_pil2tensor(img) for img in images], dim=0) + +class LoadImageFromUrl: + """Load an image from the given URL""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "url": ( + "STRING", + { + "default": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Example.jpg/800px-Example.jpg" + }, + ), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "load" + CATEGORY = "云服务/加载网络图片" + + def load(self, url): + # get the image from the url + image = Image.open(requests.get(url, stream=True).raw) + image = ImageOps.exif_transpose(image) + return (pil2tensor(image),) + +NODE_CLASS_MAPPINGS = { + "LoadImageFromUrl": LoadImageFromUrl +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "LoadImageFromUrl": "☁️ 加载图片" +} \ No newline at end of file diff --git a/ollama_node.py b/node/ollama_node.py similarity index 97% rename from ollama_node.py rename to node/ollama_node.py index 3545155..d54583b 100644 --- a/ollama_node.py +++ b/node/ollama_node.py @@ -1,231 +1,231 @@ -import requests -import json -import logging -import re -import os -import threading -from threading import Lock -from typing import Optional, List, Union, Dict, Any - -# ---------------------------- -# 日志系统配置 -# ---------------------------- -desktop_path = os.path.join(os.path.expanduser("~"), "Desktop") -log_path = os.path.join(desktop_path, "ollama_comfyui.log") - -# 强制删除旧日志 -try: - if os.path.exists(log_path): - os.remove(log_path) -except Exception as e: - print(f"旧日志删除失败: {str(e)}") - -# 日志初始化 -logging.basicConfig( - filename=log_path, - level=logging.DEBUG, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - filemode='w' -) -logger = logging.getLogger(__name__) -logger.addHandler(logging.StreamHandler()) # 控制台输出 - -class ComfyUI_LLM_Ollama: - """ - Ollama LLM集成节点 - - 特性: - - 支持流式响应生成 - - 上下文记忆管理 - - 多线程安全访问 - - 动态模型列表加载 - - 详细的日志记录 - """ - # 在类定义顶部添加 - WEB_DIRECTORY = "./js" - - # 类级共享状态 - _conn_lock = Lock() - _api_lock = Lock() - _connection_checked = False - _connection_status = False - _available_models = ["llama3", "deepseek-r1:7b"] # 默认值 - - # 类级日志器 ✅ 修正点1 - logger = logging.getLogger("ComfyUI-Ollama") - - @classmethod - def INPUT_TYPES(cls): - """动态生成输入配置""" - # 在类加载时尝试获取可用模型 - if not cls._connection_checked: - cls._check_connection() - - return { - "required": { - "prompt": ("STRING", { - "multiline": True, - "default": "请用简洁的语言回答...", - "dynamicPrompts": False, - "lineCount": 4 # ✅ 初始显示4行高度 - }), - "model": (cls._available_models, {"default": "deepseek-r1:7b"}), - "temperature": ("FLOAT", { - "default": 0.7, - "min": 0.0, - "max": 2.0, - "step": 0.1, - "display": "slider" - }), - "max_tokens": ("INT", { - "default": 1024, - "min": 1, - "max": 4096, - "step": 64, - "display": "number" - }), - "hide_thoughts": ("BOOLEAN", {"default": False}), - }, - "optional": { - "context": ("STRING", {"forceInput": True}), - "system_message": ("STRING", { - "multiline": True, - "default": "你是有帮助的AI助手", - "lazy": True, - "lineCount": 3 # ✅ 初始显示3行高度 - }), - "stop_sequences": ("STRING", { - "default": "", - "lazy": True - }), - } - } - - RETURN_TYPES = ("STRING", "STRING") - RETURN_NAMES = ("response", "context") - FUNCTION = "generate" - CATEGORY = "LLM" - OUTPUT_NODE = False - - def __init__(self): - self.ollama_url = "http://localhost:11434" - self.headers = {"Content-Type": "application/json"} - self.timeout = 120 - self._init_logging() # ✅ 修正点2:实例初始化日志配置 - - def _init_logging(self): - """初始化日志系统""" - self.logger = logging.getLogger("ComfyUI-Ollama") - self.logger.propagate = False - if not self.logger.handlers: - handler = logging.StreamHandler() - formatter = logging.Formatter('[%(name)s] %(levelname)s - %(message)s') - handler.setFormatter(formatter) - self.logger.addHandler(handler) - self.logger.setLevel(logging.INFO) - - @classmethod - def _check_connection(cls): - """类方法使用类级日志器 ✅ 修正点3""" - with cls._conn_lock: - if not cls._connection_checked: - try: - # 使用类级日志器 - cls.logger.info(f"🛠 正在检查Ollama连接...") - - response = requests.get( - "http://localhost:11434/api/tags", - timeout=10 - ) - if response.status_code == 200: - models = response.json().get("models", []) - cls._available_models = [m["name"] for m in models] - cls._connection_status = True - cls.logger.info(f"✅ 可用模型: {cls._available_models}") - else: - cls.logger.warning(f"连接失败,状态码:{response.status_code}") - except Exception as e: - cls.logger.error(f"连接异常:{str(e)}") - finally: - cls._connection_checked = True - - def _build_payload(self, **kwargs): - """构造API请求负载""" - stop_sequences = [s.strip() for s in kwargs['stop_sequences'].split(',')] if kwargs['stop_sequences'] else [] - - return { - "model": kwargs['model'], - "prompt": kwargs['prompt'], - "system": kwargs.get('system_message', '你是有帮助的AI助手'), - "context": self._parse_context(kwargs.get('context')), - "options": { - "temperature": kwargs['temperature'], - "num_predict": kwargs['max_tokens'], - "stop": stop_sequences, - } - } - - def _parse_context(self, context: Optional[str]) -> List: - """解析上下文数据""" - try: - return json.loads(context) if context else [] - except json.JSONDecodeError: - self.logger.warning("上下文解析失败,使用空上下文") - return [] - - def _clean_response(self, text: str, hide_thoughts: bool) -> str: - """响应后处理""" - if hide_thoughts: - return re.sub(r'.*?', '', text, flags=re.DOTALL).strip() - return text.strip() - - def generate(self, **kwargs): - """主执行方法""" - if not self._connection_status: - return ("Ollama服务不可用,请检查", "") - - with self._api_lock: - try: - payload = self._build_payload(**kwargs) - self.logger.debug(f"请求参数:{json.dumps(payload, indent=2)}") - - response_text = "" - context = [] - - with requests.post( - f"{self.ollama_url}/api/generate", - json=payload, - headers=self.headers, - stream=True, - timeout=self.timeout - ) as response: - response.raise_for_status() - - for line in response.iter_lines(): - if line: - data = json.loads(line.decode('utf-8')) - response_text += data.get("response", "") - if data.get("done"): - context = data.get("context", []) - - cleaned_response = self._clean_response(response_text, kwargs['hide_thoughts']) - self.logger.info(f"📥 响应长度: {len(cleaned_response)}字符") - return (cleaned_response, json.dumps(context)) - - except requests.RequestException as e: - error_msg = f"API请求失败: {str(e)}" - self.logger.error(error_msg) - return (error_msg, "") - except Exception as e: - error_msg = f"处理错误: {str(e)}" - self.logger.exception(error_msg) - return (error_msg, "") - -# 节点注册 -NODE_CLASS_MAPPINGS = { - "ComfyUI_LLM_Ollama": ComfyUI_LLM_Ollama -} - -NODE_DISPLAY_NAME_MAPPINGS = { - "ComfyUI_LLM_Ollama": "🤖 Ollama LLM" +import requests +import json +import logging +import re +import os +import threading +from threading import Lock +from typing import Optional, List, Union, Dict, Any + +# ---------------------------- +# 日志系统配置 +# ---------------------------- +desktop_path = os.path.join(os.path.expanduser("~"), "Desktop") +log_path = os.path.join(desktop_path, "ollama_comfyui.log") + +# 强制删除旧日志 +try: + if os.path.exists(log_path): + os.remove(log_path) +except Exception as e: + print(f"旧日志删除失败: {str(e)}") + +# 日志初始化 +logging.basicConfig( + filename=log_path, + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + filemode='w' +) +logger = logging.getLogger(__name__) +logger.addHandler(logging.StreamHandler()) # 控制台输出 + +class ComfyUI_LLM_Ollama: + """ + Ollama LLM集成节点 + + 特性: + - 支持流式响应生成 + - 上下文记忆管理 + - 多线程安全访问 + - 动态模型列表加载 + - 详细的日志记录 + """ + # 在类定义顶部添加 + WEB_DIRECTORY = "./js" + + # 类级共享状态 + _conn_lock = Lock() + _api_lock = Lock() + _connection_checked = False + _connection_status = False + _available_models = ["llama3", "deepseek-r1:7b"] # 默认值 + + # 类级日志器 ✅ 修正点1 + logger = logging.getLogger("ComfyUI-Ollama") + + @classmethod + def INPUT_TYPES(cls): + """动态生成输入配置""" + # 在类加载时尝试获取可用模型 + if not cls._connection_checked: + cls._check_connection() + + return { + "required": { + "prompt": ("STRING", { + "multiline": True, + "default": "请用简洁的语言回答...", + "dynamicPrompts": False, + "lineCount": 4 # ✅ 初始显示4行高度 + }), + "model": (cls._available_models, {"default": "deepseek-r1:7b"}), + "temperature": ("FLOAT", { + "default": 0.7, + "min": 0.0, + "max": 2.0, + "step": 0.1, + "display": "slider" + }), + "max_tokens": ("INT", { + "default": 1024, + "min": 1, + "max": 4096, + "step": 64, + "display": "number" + }), + "hide_thoughts": ("BOOLEAN", {"default": False}), + }, + "optional": { + "context": ("STRING", {"forceInput": True}), + "system_message": ("STRING", { + "multiline": True, + "default": "你是有帮助的AI助手", + "lazy": True, + "lineCount": 3 # ✅ 初始显示3行高度 + }), + "stop_sequences": ("STRING", { + "default": "", + "lazy": True + }), + } + } + + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("response", "context") + FUNCTION = "generate" + CATEGORY = "LLM" + OUTPUT_NODE = False + + def __init__(self): + self.ollama_url = "http://localhost:11434" + self.headers = {"Content-Type": "application/json"} + self.timeout = 120 + self._init_logging() # ✅ 修正点2:实例初始化日志配置 + + def _init_logging(self): + """初始化日志系统""" + self.logger = logging.getLogger("ComfyUI-Ollama") + self.logger.propagate = False + if not self.logger.handlers: + handler = logging.StreamHandler() + formatter = logging.Formatter('[%(name)s] %(levelname)s - %(message)s') + handler.setFormatter(formatter) + self.logger.addHandler(handler) + self.logger.setLevel(logging.INFO) + + @classmethod + def _check_connection(cls): + """类方法使用类级日志器 ✅ 修正点3""" + with cls._conn_lock: + if not cls._connection_checked: + try: + # 使用类级日志器 + cls.logger.info(f"🛠 正在检查Ollama连接...") + + response = requests.get( + "http://localhost:11434/api/tags", + timeout=10 + ) + if response.status_code == 200: + models = response.json().get("models", []) + cls._available_models = [m["name"] for m in models] + cls._connection_status = True + cls.logger.info(f"✅ 可用模型: {cls._available_models}") + else: + cls.logger.warning(f"连接失败,状态码:{response.status_code}") + except Exception as e: + cls.logger.error(f"连接异常:{str(e)}") + finally: + cls._connection_checked = True + + def _build_payload(self, **kwargs): + """构造API请求负载""" + stop_sequences = [s.strip() for s in kwargs['stop_sequences'].split(',')] if kwargs['stop_sequences'] else [] + + return { + "model": kwargs['model'], + "prompt": kwargs['prompt'], + "system": kwargs.get('system_message', '你是有帮助的AI助手'), + "context": self._parse_context(kwargs.get('context')), + "options": { + "temperature": kwargs['temperature'], + "num_predict": kwargs['max_tokens'], + "stop": stop_sequences, + } + } + + def _parse_context(self, context: Optional[str]) -> List: + """解析上下文数据""" + try: + return json.loads(context) if context else [] + except json.JSONDecodeError: + self.logger.warning("上下文解析失败,使用空上下文") + return [] + + def _clean_response(self, text: str, hide_thoughts: bool) -> str: + """响应后处理""" + if hide_thoughts: + return re.sub(r'.*?', '', text, flags=re.DOTALL).strip() + return text.strip() + + def generate(self, **kwargs): + """主执行方法""" + if not self._connection_status: + return ("Ollama服务不可用,请检查", "") + + with self._api_lock: + try: + payload = self._build_payload(**kwargs) + self.logger.debug(f"请求参数:{json.dumps(payload, indent=2)}") + + response_text = "" + context = [] + + with requests.post( + f"{self.ollama_url}/api/generate", + json=payload, + headers=self.headers, + stream=True, + timeout=self.timeout + ) as response: + response.raise_for_status() + + for line in response.iter_lines(): + if line: + data = json.loads(line.decode('utf-8')) + response_text += data.get("response", "") + if data.get("done"): + context = data.get("context", []) + + cleaned_response = self._clean_response(response_text, kwargs['hide_thoughts']) + self.logger.info(f"📥 响应长度: {len(cleaned_response)}字符") + return (cleaned_response, json.dumps(context)) + + except requests.RequestException as e: + error_msg = f"API请求失败: {str(e)}" + self.logger.error(error_msg) + return (error_msg, "") + except Exception as e: + error_msg = f"处理错误: {str(e)}" + self.logger.exception(error_msg) + return (error_msg, "") + +# 节点注册 +NODE_CLASS_MAPPINGS = { + "ComfyUI_LLM_Ollama": ComfyUI_LLM_Ollama +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "ComfyUI_LLM_Ollama": "🤖 Ollama LLM" } \ No newline at end of file diff --git a/online_api.py b/node/online_api.py similarity index 100% rename from online_api.py rename to node/online_api.py diff --git a/qiniu_uploader.py b/node/qiniu_uploader.py similarity index 100% rename from qiniu_uploader.py rename to node/qiniu_uploader.py diff --git a/string_utils.py b/node/string_utils.py similarity index 100% rename from string_utils.py rename to node/string_utils.py diff --git a/qiniu_config.json b/qiniu_config.json new file mode 100644 index 0000000..33ff87f --- /dev/null +++ b/qiniu_config.json @@ -0,0 +1,8 @@ +{ + "access_key": "GRvkqEByqR6JZ6tSrIYPQUmxVnwuJMcQLWM4fQOk", + "secret_key": "4ryqDs__yUkx4grSVQpMg_ZaLbRrR6N55VcQZCrD", + "bucket_name": "dyaigc", + "input_dir": "input", + "output_dir": "output", + "domain": "swqqsa5wv.hb-bkt.clouddn.com" +} \ No newline at end of file diff --git a/qiniu_config.json.example b/qiniu_config.json.example new file mode 100644 index 0000000..b1c2a7e --- /dev/null +++ b/qiniu_config.json.example @@ -0,0 +1,8 @@ +{ + "access_key": "", + "secret_key": "", + "bucket_name": "dyaigc", + "input_dir": "input", + "output_dir": "output", + "domain": "swqqsa5wv.hb-bkt.clouddn.com" +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 4da8c99..e0b9ddd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ qiniu pillow numpy openai +requests +torch