diff --git a/core/runninghub_llm.py b/core/runninghub_llm.py index 8027b4f..fdda39c 100644 --- a/core/runninghub_llm.py +++ b/core/runninghub_llm.py @@ -1,13 +1,14 @@ """ RunningHUB LLM 节点 通过 RunningHub OpenAI 兼容接口调用多厂商模型(DeepSeek / Gemini / Doubao 等) -支持可选图片输入(OpenAI Vision 兼容的 Base64 data URL) +支持动态多图输入(OpenAI Vision 兼容的 Base64 data URL) """ import base64 import json import logging import ssl +import time import urllib.error import urllib.request from io import BytesIO @@ -15,12 +16,15 @@ from io import BytesIO import numpy as np from PIL import Image +from cozy_comfyui import InputType, deep_merge +from cozy_comfyui.lexicon import Lexicon from cozy_comfyui.node import CozyBaseNode logger = logging.getLogger(__name__) # RunningHub LLM OpenAI 兼容 base_url 默认值 DEFAULT_BASE_URL = "https://llm.runninghub.ai/v1" +MAX_IMAGE_INPUTS = 10 # 各厂商模型共用同一 Chat Completions 协议,仅 model 字段不同(按厂商归类) MODEL_CHOICES = [ @@ -92,89 +96,132 @@ MODEL_CHOICES = [ class RunningHubLlmNode(CozyBaseNode): """ RunningHUB LLM - 对接 RunningHub LLM Chat Completions API,支持多模型、reasoning_effort 与单图视觉输入 + 对接 RunningHub LLM Chat Completions API,支持多模型、reasoning_effort 与动态多图视觉输入 """ NAME = "RunningHUB LLM" FUNCTION = "run" @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "api_key": ("STRING", { - "default": "", - "multiline": False, - "placeholder": "输入 RunningHub API Key (可带或不带 Bearer)" - }), - "base_url": ("STRING", { - "default": DEFAULT_BASE_URL, - "multiline": False, - "placeholder": "如 https://llm.runninghub.ai/v1" - }), - "model": (MODEL_CHOICES, { - "default": "deepseek/deepseek-v4-flash" - }), - # 非空时优先生效,便于接入下拉列表未收录的模型 - "custom_model": ("STRING", { - "default": "", - "multiline": False, - "placeholder": "自定义模型 ID(有值则覆盖上方下拉)" - }), - # extra_body.reasoning_effort;官方示例默认 none(非思考) - "reasoning_effort": (["none", "low", "high", "max"], { - "default": "none" - }), - "system_instruction": ("STRING", { - "default": "You are a helpful assistant", - "multiline": True, - "placeholder": "输入系统提示词" - }), - "user_input": ("STRING", { - "default": "", - "multiline": True, - "placeholder": "输入用户的提问" - }), - "max_tokens": ("INT", { - "default": 2048, - "min": 1, - "max": 131072, - "step": 1 - }), - "temperature": ("FLOAT", { - "default": 1.0, - "min": 0.0, - "max": 2.0, - "step": 0.1 - }), - "top_p": ("FLOAT", { - "default": 1.0, - "min": 0.0, - "max": 1.0, - "step": 0.05 - }), - "presence_penalty": ("FLOAT", { - "default": 0.0, - "min": -2.0, - "max": 2.0, - "step": 0.1 - }), - "frequency_penalty": ("FLOAT", { - "default": 0.0, - "min": -2.0, - "max": 2.0, - "step": 0.1 - }), - }, - "optional": { - # 视觉模型通过 OpenAI 兼容的 image_url(Base64 data URL)传图 - "image": ("IMAGE",), - }, + def INPUT_TYPES(cls) -> InputType: + # 动态图像输入:连上 image_1 后由前端追加 image_2 ... + dyn_inputs = { + "image_1": ("IMAGE", { + "tooltip": "Image input. When connected, one more input slot is added." + }) } + # 新版 ComfyUI 校验动态端口时,用容器绕过静态 optional 定义限制 + try: + import inspect + + stack = inspect.stack() + if len(stack) > 2 and stack[2].function == "get_input_info": + + class ImageContainer: + def __contains__(self, item): + return item.startswith("image_") + + def __getitem__(self, key): + if key.startswith("image_"): + return ("IMAGE", {"tooltip": "Dynamic image input"}) + raise KeyError(key) + + dyn_inputs = ImageContainer() + except Exception: + pass + + d = super().INPUT_TYPES() + + if hasattr(dyn_inputs, "__getitem__") and hasattr(dyn_inputs, "__contains__"): + optional_inputs = {} + else: + optional_inputs = ( + dict(dyn_inputs) + if isinstance(dyn_inputs, dict) + else {"image_1": ("IMAGE", {"tooltip": "Image input"})} + ) + + d = deep_merge( + d, + { + "required": { + "api_key": ("STRING", { + "default": "", + "multiline": False, + "placeholder": "输入 RunningHub API Key (可带或不带 Bearer)" + }), + "base_url": ("STRING", { + "default": DEFAULT_BASE_URL, + "multiline": False, + "placeholder": "如 https://llm.runninghub.ai/v1" + }), + "model": (MODEL_CHOICES, { + "default": "deepseek/deepseek-v4-flash" + }), + # 非空时优先生效,便于接入下拉列表未收录的模型 + "custom_model": ("STRING", { + "default": "", + "multiline": False, + "placeholder": "自定义模型 ID(有值则覆盖上方下拉)" + }), + # extra_body.reasoning_effort;官方示例默认 none(非思考) + "reasoning_effort": (["none", "low", "high", "max"], { + "default": "none" + }), + "system_instruction": ("STRING", { + "default": "You are a helpful assistant", + "multiline": True, + "placeholder": "输入系统提示词" + }), + "user_input": ("STRING", { + "default": "", + "multiline": True, + "placeholder": "输入用户的提问" + }), + "max_tokens": ("INT", { + "default": 2048, + "min": 1, + "max": 131072, + "step": 1 + }), + "temperature": ("FLOAT", { + "default": 1.0, + "min": 0.0, + "max": 2.0, + "step": 0.1 + }), + "top_p": ("FLOAT", { + "default": 1.0, + "min": 0.0, + "max": 1.0, + "step": 0.05 + }), + "presence_penalty": ("FLOAT", { + "default": 0.0, + "min": -2.0, + "max": 2.0, + "step": 0.1 + }), + "frequency_penalty": ("FLOAT", { + "default": 0.0, + "min": -2.0, + "max": 2.0, + "step": 0.1 + }), + }, + "optional": optional_inputs, + }, + ) + return Lexicon._parse(d) + RETURN_TYPES = ("STRING", "STRING", "STRING") RETURN_NAMES = ("text", "reasoning", "raw_response") + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + return time.time() + @staticmethod def _extract_param(value, default): # 兼容 ComfyUI 的列表传参 @@ -213,7 +260,19 @@ class RunningHubLlmNode(CozyBaseNode): image_b64 = base64.b64encode(buffer.getvalue()).decode("utf-8") return f"data:image/jpeg;base64,{image_b64}" - def _build_messages(self, system_instruction, user_input, image=None): + def _collect_image_urls(self, kw): + image_urls = [] + for i in range(1, MAX_IMAGE_INPUTS + 1): + image_key = f"image_{i}" + if image_key not in kw or kw[image_key] is None: + continue + try: + image_urls.append(self._image_to_data_url(kw[image_key])) + except Exception as e: + logger.error(f"[RunningHUB LLM] 图像 {image_key} 转换失败: {e}") + return image_urls + + def _build_messages(self, system_instruction, user_input, image_urls): messages = [] if system_instruction: messages.append({ @@ -221,14 +280,15 @@ class RunningHubLlmNode(CozyBaseNode): "content": system_instruction }) - if image is not None: + if image_urls: content = [] if user_input: content.append({"type": "text", "text": user_input}) - content.append({ - "type": "image_url", - "image_url": {"url": self._image_to_data_url(image)}, - }) + for url in image_urls: + content.append({ + "type": "image_url", + "image_url": {"url": url}, + }) messages.append({"role": "user", "content": content}) elif user_input: messages.append({ @@ -238,39 +298,22 @@ class RunningHubLlmNode(CozyBaseNode): return messages - def run( - self, - api_key, - base_url, - model, - custom_model, - reasoning_effort, - system_instruction, - user_input, - max_tokens, - temperature, - top_p, - presence_penalty, - frequency_penalty, - image=None, - ): - api_key = str(self._extract_param(api_key, "")) - base_url = str(self._extract_param(base_url, DEFAULT_BASE_URL)).strip() - model = str(self._extract_param(model, "deepseek/deepseek-v4-flash")) - custom_model = str(self._extract_param(custom_model, "")).strip() + def run(self, **kw): + api_key = str(self._extract_param(kw.get("api_key"), "")) + base_url = str(self._extract_param(kw.get("base_url"), DEFAULT_BASE_URL)).strip() + model = str(self._extract_param(kw.get("model"), "deepseek/deepseek-v4-flash")) + custom_model = str(self._extract_param(kw.get("custom_model"), "")).strip() # 自定义模型非空时覆盖下拉选项 if custom_model: model = custom_model - reasoning_effort = str(self._extract_param(reasoning_effort, "none")) - system_instruction = str(self._extract_param(system_instruction, "")) - user_input = str(self._extract_param(user_input, "")) - max_tokens = int(self._extract_param(max_tokens, 2048)) - temperature = float(self._extract_param(temperature, 1.0)) - top_p = float(self._extract_param(top_p, 1.0)) - presence_penalty = float(self._extract_param(presence_penalty, 0.0)) - frequency_penalty = float(self._extract_param(frequency_penalty, 0.0)) - if isinstance(image, list): - image = image[0] if image else None + reasoning_effort = str(self._extract_param(kw.get("reasoning_effort"), "none")) + system_instruction = str(self._extract_param(kw.get("system_instruction"), "")) + user_input = str(self._extract_param(kw.get("user_input"), "")) + max_tokens = int(self._extract_param(kw.get("max_tokens"), 2048)) + temperature = float(self._extract_param(kw.get("temperature"), 1.0)) + top_p = float(self._extract_param(kw.get("top_p"), 1.0)) + presence_penalty = float(self._extract_param(kw.get("presence_penalty"), 0.0)) + frequency_penalty = float(self._extract_param(kw.get("frequency_penalty"), 0.0)) if not api_key: raise ValueError("请提供有效的 RunningHub API Key") @@ -278,11 +321,12 @@ class RunningHubLlmNode(CozyBaseNode): if not base_url: raise ValueError("请提供有效的 base_url") - if not user_input and not system_instruction and image is None: + image_urls = self._collect_image_urls(kw) + if not user_input and not system_instruction and not image_urls: raise ValueError("请提供系统提示词、用户输入或图片") endpoint = self._build_chat_completions_url(base_url) - messages = self._build_messages(system_instruction, user_input, image) + messages = self._build_messages(system_instruction, user_input, image_urls) # 与官方 OpenAI SDK 示例对齐;reasoning_effort 对应 extra_body payload_dict = { @@ -298,10 +342,9 @@ class RunningHubLlmNode(CozyBaseNode): } try: - has_image = image is not None logger.info( f"[RunningHUB LLM] 开始请求 {model} " - f"effort={reasoning_effort} has_image={has_image} (URL: {endpoint})" + f"effort={reasoning_effort} images={len(image_urls)} (URL: {endpoint})" ) payload = json.dumps(payload_dict).encode("utf-8") diff --git a/web/nodes/runninghub_llm.js b/web/nodes/runninghub_llm.js new file mode 100644 index 0000000..c6c9cb4 --- /dev/null +++ b/web/nodes/runninghub_llm.js @@ -0,0 +1,117 @@ +/** + * RunningHUB LLM 节点前端实现 + * 支持动态图像输入端口(image_1 连线后出现 image_2 ...) + */ + +import { app } from "../../../scripts/app.js"; +import { nodeFitHeightRobustly } from "../util.js"; + +const NODE_NAME = "RunningHUB LLM"; +const MAX_IMAGE_INPUTS = 10; + +app.registerExtension({ + name: "WBLESS.RunningHubLlm", + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== NODE_NAME) return; + + const originalOnNodeCreated = nodeType.prototype.onNodeCreated; + + nodeType.prototype.onNodeCreated = function () { + originalOnNodeCreated?.apply(this, arguments); + this.title = NODE_NAME; + this.manageImageInputs(); + nodeFitHeightRobustly(this); + }; + + nodeType.prototype.getDynamicImageInputs = function () { + return this.inputs?.filter((input) => input.name.startsWith("image_")) || []; + }; + + // 已连接数 + 1 个空槽,最多 MAX_IMAGE_INPUTS + nodeType.prototype.manageImageInputs = function () { + const dynamicInputs = this.getDynamicImageInputs(); + const connectedCount = dynamicInputs.reduce( + (acc, input) => acc + (input.link !== null ? 1 : 0), + 0 + ); + const desiredCount = Math.min(connectedCount + 1, MAX_IMAGE_INPUTS); + let currentCount = dynamicInputs.length; + + while (currentCount < desiredCount) { + this.addInput(`image_${currentCount + 1}`, "IMAGE"); + currentCount++; + } + + while (currentCount > desiredCount && currentCount > 1) { + const lastInput = this.inputs[this.inputs.length - 1]; + if (lastInput?.name.startsWith("image_") && lastInput.link === null) { + this.removeInput(this.inputs.length - 1); + currentCount--; + } else { + break; + } + } + + if (this.getDynamicImageInputs().length === 0) { + this.addInput("image_1", "IMAGE"); + } + + let imageInputIndex = 1; + this.inputs.forEach((input) => { + if (input.name.startsWith("image_")) { + input.name = `image_${imageInputIndex}`; + input.label = input.name; + imageInputIndex++; + } + }); + + nodeFitHeightRobustly(this); + }; + + const originalOnConnectionsChange = nodeType.prototype.onConnectionsChange; + nodeType.prototype.onConnectionsChange = function (type, index, connected, link_info) { + originalOnConnectionsChange?.apply(this, arguments); + + // type === 1 表示输入端连线变化 + if (type === 1) { + const input = this.inputs[index]; + if (input?.name.startsWith("image_")) { + setTimeout(() => { + this.manageImageInputs(); + }, 10); + } + } + }; + + const originalOnConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (info) { + originalOnConfigure?.apply(this, arguments); + setTimeout(() => { + this.manageImageInputs(); + }, 100); + }; + + const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function (_, options) { + originalGetExtraMenuOptions?.apply(this, arguments); + + options.push({ + content: "显示所有图像输入", + callback: () => { + const currentCount = this.getDynamicImageInputs().length; + for (let i = currentCount; i < MAX_IMAGE_INPUTS; i++) { + this.addInput(`image_${i + 1}`, "IMAGE"); + } + this.manageImageInputs(); + }, + }); + + options.push({ + content: "重新管理图像输入端口", + callback: () => { + this.manageImageInputs(); + }, + }); + }; + }, +});