RunningHUB LLM node supports multiple image inputs.

This commit is contained in:
青涩吴
2026-08-24 14:29:54 +08:00
parent a0b360e51d
commit bfd7269f28
2 changed files with 273 additions and 113 deletions
+156 -113
View File
@@ -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")
+117
View File
@@ -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();
},
});
};
},
});