RunningHUB LLM node supports multiple image inputs.
This commit is contained in:
+156
-113
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user