feat: 新增 ZenMux API 连接节点(厂商→模型级联选择,选项含价格标注)
- 一个节点调用 ZenMux 聚合平台全部文本类模型(20 厂商 / 138 模型) - vendor→model 二级下拉:前端 web/zenmux_cascade.js 按厂商实时收窄模型列表 - 模型选项后缀标注输入/输出价格(USD/百万token),如 [入$0.2/M 出$1.25/M] - 默认模型 openai/gpt-5.4-nano;具备 api_key/system/user prompt/seed/ temperature/top_p/max_tokens/多模态图像×6/detail/base_url/proxy 等完整参数 - 模型清单随包分发(models_snapshot.json),离线可列出; build_snapshot.py 可随时重拉最新价格 - VALIDATE_INPUTS 宽松校验:价格快照更新后旧工作流的模型标签仍可解析执行 - base_url 默认值沿用仓库约定不含 "://",规避 ComfyUI 前端吞协议片段的老问题 - 导出 WEB_DIRECTORY 以装载前端级联脚本 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a826b42576
commit
ca61c48dbd
@@ -24,6 +24,7 @@ Rui-Node🐶 是一个功能丰富的 ComfyUI 节点集合,提供图像处理
|
||||
### 🤖 AI模型类
|
||||
- [千问编辑图像生成 / Qwen Edit Image Generation](#4-千问编辑图像生成--qwen-edit-image-generation)
|
||||
- [SDMatte 精细抠图 / SDMatte Interactive Matting](#17-sdmatte-精细抠图--sdmatte-interactive-matting)
|
||||
- [ZenMux API 连接 / ZenMux API Connector](#18-zenmux-api-连接--zenmux-api-connector)
|
||||
|
||||
### 📝 文本处理类
|
||||
- [镜头分词器 / Shot Splitter](#5-镜头分词器--shot-splitter)
|
||||
@@ -710,6 +711,46 @@ ComfyUI-SDMatte 传 `aux_input="trimap"`,把模型推到了没训练过的输
|
||||
|
||||
---
|
||||
|
||||
### 18. ZenMux API 连接 / ZenMux API Connector
|
||||
|
||||
**分类**: `Rui-Node🐶/AI模型🤖`
|
||||
|
||||
**功能描述**:
|
||||
连接 [ZenMux](https://zenmux.ai) 聚合平台(OpenAI 兼容协议),一个节点即可调用其收录的**所有文本类模型**(Anthropic、OpenAI、Google、DeepSeek、Qwen 等 20 家厂商、130+ 模型)。支持文本生成与多模态图像理解(最多 6 张图)。
|
||||
|
||||
**特色功能**:
|
||||
- **厂商 → 模型二级选择**: 先在 `vendor` 下拉选厂商,`model` 下拉会自动收窄为该厂商的模型(由前端脚本 `web/zenmux_cascade.js` 联动)
|
||||
- **价格直接标在选项上**: 每个模型后缀形如 `[入$0.2/M 出$1.25/M]`,即输入/输出每百万 token 的美元价格,选型时一目了然
|
||||
- **离线可用的模型清单**: 模型与价格来自随包分发的 `zenmux/models_snapshot.json`;价格有变动时运行 `python zenmux/build_snapshot.py` 即可重新拉取更新
|
||||
- **旧工作流兼容**: 价格快照更新后,旧工作流里保存的带旧价格标签仍能正确解析出模型 id,不会失效
|
||||
|
||||
**输入参数**:
|
||||
- `api_key` (STRING): ZenMux 平台的 API Key(在 zenmux.ai 控制台获取)
|
||||
- `vendor` (选择): 厂商筛选,默认 `openai`
|
||||
- `model` (选择): 模型(带价格标注),默认 `openai/gpt-5.4-nano`
|
||||
- `system_prompt` (STRING): 系统提示词
|
||||
- `user_prompt` (STRING): 用户提示词
|
||||
- `seed` (INT): 随机种子
|
||||
- `temperature` (FLOAT, 可选): 采样温度,默认 0.7,范围 0.0 ~ 2.0
|
||||
- `top_p` (FLOAT, 可选): 核采样阈值,默认 1.0
|
||||
- `max_tokens` (INT, 可选): 最大输出 token 数,默认 1024
|
||||
- `image_1` ~ `image_6` (IMAGE, 可选): 多模态图像输入(所选模型需支持 image 输入)
|
||||
- `detail` (选择, 可选): 图像分析细节等级,auto/low/high
|
||||
- `image_max_size` (INT, 可选): 发送前图像最长边缩放上限,默认 1024
|
||||
- `base_url` (STRING, 可选): API 地址,默认 `zenmux.ai/api/v1`(无需写 `https://`,节点会自动补全)
|
||||
- `proxy_url` (STRING, 可选): HTTP/HTTPS 代理地址,如 `127.0.0.1:7890`
|
||||
|
||||
**输出**:
|
||||
- `text` (STRING): 模型生成的文本内容
|
||||
- `model_id` (STRING): 实际调用的模型 id(如 `openai/gpt-5.4-nano`),便于下游记录
|
||||
|
||||
**使用场景**:
|
||||
- 一个 Key 试遍多家厂商的模型,横向对比效果与成本
|
||||
- 按预算选型:价格就写在下拉列表里,直接挑便宜的
|
||||
- 调用 Claude / GPT / Gemini / DeepSeek 等做文本生成或图像理解
|
||||
|
||||
---
|
||||
|
||||
## 🐕 关于 Rui-Node🐶
|
||||
|
||||
Rui-Node🐶 致力于为 ComfyUI 用户提供实用、高效的节点工具集。🐶 是我们的项目标志,代表着忠诚、友好和可靠。
|
||||
|
||||
+9
-1
@@ -41,6 +41,9 @@ except Exception as _e:
|
||||
print("[Ruinode] 如需使用,请安装:pip install diffusers transformers safetensors scipy opencv-python")
|
||||
SDMATTE_NODE_CLASS_MAPPINGS = {}
|
||||
SDMATTE_NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
# 新增:ZenMux API 连接节点(聚合平台,厂商→模型二级选择,标签含价格)
|
||||
from .zenmux import NODE_CLASS_MAPPINGS as ZENMUX_NODE_CLASS_MAPPINGS
|
||||
from .zenmux import NODE_DISPLAY_NAME_MAPPINGS as ZENMUX_NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
# 合并节点映射字典
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
@@ -61,6 +64,7 @@ NODE_CLASS_MAPPINGS.update(OPENAI_NODE_CLASS_MAPPINGS)
|
||||
NODE_CLASS_MAPPINGS.update(COLORMATCHER_NODE_CLASS_MAPPINGS)
|
||||
NODE_CLASS_MAPPINGS.update(IMAGESPLITTER_NODE_CLASS_MAPPINGS)
|
||||
NODE_CLASS_MAPPINGS.update(SDMATTE_NODE_CLASS_MAPPINGS)
|
||||
NODE_CLASS_MAPPINGS.update(ZENMUX_NODE_CLASS_MAPPINGS)
|
||||
|
||||
# 合并节点显示名称映射
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
@@ -81,5 +85,9 @@ NODE_DISPLAY_NAME_MAPPINGS.update(OPENAI_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(COLORMATCHER_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(IMAGESPLITTER_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(SDMATTE_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(ZENMUX_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
# 前端扩展目录(ZenMux 节点的「厂商→模型」级联脚本等)
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// ZenMux 节点「厂商 → 模型」二级级联
|
||||
// ==================================
|
||||
// 后端把全量模型标签(含价格)都放进 model 下拉以通过校验;
|
||||
// 本扩展在前端按 vendor 收窄 model 的候选列表。
|
||||
// 约定:模型标签第一个空格前是 "vendor/model" 形式的 id,
|
||||
// 故 label.split("/")[0] 即厂商名,与 vendor 下拉的取值同源。
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "Ruinode.ZenMuxCascade",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
|
||||
if (nodeData.name !== "ZenMuxAPINode") return;
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated?.apply(this, arguments);
|
||||
|
||||
const vendorW = this.widgets?.find((w) => w.name === "vendor");
|
||||
const modelW = this.widgets?.find((w) => w.name === "model");
|
||||
if (!vendorW || !modelW) return r;
|
||||
|
||||
// 全量标签快照(此时 options.values 是后端给的完整列表)
|
||||
const allLabels = (modelW.options.values || []).slice();
|
||||
const vendorOf = (label) => String(label).split("/")[0];
|
||||
|
||||
// resetIfMissing:当前值不在收窄后的列表时是否重置为列表首项。
|
||||
// 手动切厂商 → 重置;加载旧工作流 → 保留(旧价格标签后端能解析)。
|
||||
const applyFilter = (vendor, resetIfMissing) => {
|
||||
const filtered = allLabels.filter((l) => vendorOf(l) === vendor);
|
||||
modelW.options.values = filtered.length ? filtered : allLabels.slice();
|
||||
if (resetIfMissing && !modelW.options.values.includes(modelW.value)) {
|
||||
modelW.value = modelW.options.values[0];
|
||||
modelW.callback?.(modelW.value);
|
||||
}
|
||||
this.setDirtyCanvas?.(true, true);
|
||||
};
|
||||
|
||||
const origCallback = vendorW.callback;
|
||||
vendorW.callback = function (value) {
|
||||
const r2 = origCallback?.apply(this, arguments);
|
||||
applyFilter(value, true);
|
||||
return r2;
|
||||
};
|
||||
|
||||
// 新建节点:按默认厂商先收窄一次
|
||||
applyFilter(vendorW.value, false);
|
||||
|
||||
// 加载已保存的工作流:configure 恢复 widget 值后再按真实厂商收窄,
|
||||
// 但不动用户保存的 model 选择
|
||||
const onConfigure = this.onConfigure;
|
||||
this.onConfigure = function () {
|
||||
const r3 = onConfigure?.apply(this, arguments);
|
||||
applyFilter(vendorW.value, false);
|
||||
return r3;
|
||||
};
|
||||
|
||||
return r;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ZenMux API 连接节点子包。"""
|
||||
from .zenmux_node import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
@@ -0,0 +1,91 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
从 ZenMux 拉取模型列表,提炼成节点需要的精简快照。
|
||||
|
||||
运行:python build_snapshot.py
|
||||
产出:models_snapshot.json(随节点分发,保证离线可用)
|
||||
|
||||
只保留文本类模型(输出模态含 text),并预先算好基础输入/输出价格,
|
||||
避免节点运行时每次都解析 ZenMux 那套分段定价结构。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
# Windows GBK 控制台打印中文/特殊字符会崩,强制 UTF-8
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
API = "https://zenmux.ai/api/v1/models"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
OUT = os.path.join(HERE, "models_snapshot.json")
|
||||
|
||||
|
||||
def base_price(pricings, key):
|
||||
"""取某项价格的基础段(prompt_tokens 从 0 起的那一段),单位 USD / 百万 token。"""
|
||||
seg = (pricings or {}).get(key)
|
||||
if not seg:
|
||||
return None
|
||||
vals = [s for s in seg if isinstance(s, dict) and "value" in s]
|
||||
if not vals:
|
||||
return None
|
||||
base = None
|
||||
for s in vals:
|
||||
cond = s.get("conditions", {}).get("prompt_tokens", {})
|
||||
if cond.get("gte", 0) in (0, None):
|
||||
base = s
|
||||
break
|
||||
base = base or vals[0]
|
||||
try:
|
||||
return float(base["value"])
|
||||
except (TypeError, ValueError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def build(raw_models):
|
||||
out = []
|
||||
for m in raw_models:
|
||||
out_mod = m.get("output_modalities", [])
|
||||
if "text" not in out_mod: # 只要文本类模型
|
||||
continue
|
||||
pr = m.get("pricings") or {}
|
||||
out.append({
|
||||
"id": m["id"],
|
||||
"display_name": m.get("display_name", m["id"]),
|
||||
# 厂商一律取模型 id 的前缀("openai/gpt-5.4-nano" -> "openai"),
|
||||
# 与前端级联 JS 的过滤规则(label.split("/")[0])保持同一真源。
|
||||
"vendor": m["id"].split("/")[0] if "/" in m["id"] else m.get("owned_by", "other"),
|
||||
"input_price": base_price(pr, "prompt"),
|
||||
"output_price": base_price(pr, "completion"),
|
||||
"context_length": m.get("context_length"),
|
||||
"input_modalities": m.get("input_modalities", []),
|
||||
})
|
||||
# 按厂商、再按 id 排序,方便前端级联展示
|
||||
out.sort(key=lambda x: (x["vendor"].lower(), x["id"].lower()))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
print(f"拉取 {API} ...")
|
||||
r = requests.get(API, timeout=30)
|
||||
r.raise_for_status()
|
||||
data = r.json()["data"]
|
||||
models = build(data)
|
||||
payload = {
|
||||
"source": API,
|
||||
"count": len(models),
|
||||
"models": models,
|
||||
}
|
||||
with open(OUT, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=1)
|
||||
print(f"写入 {OUT}:{len(models)} 个文本模型")
|
||||
vendors = sorted(set(m["vendor"] for m in models))
|
||||
print(f"厂商 {len(vendors)} 个:{', '.join(vendors)}")
|
||||
assert any(m["id"] == "openai/gpt-5.4-nano" for m in models), "默认模型缺失!"
|
||||
print("默认模型 openai/gpt-5.4-nano 存在 ✓")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,103 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ZenMux 模型注册表
|
||||
=================
|
||||
从随包分发的 models_snapshot.json 加载模型清单,为节点提供:
|
||||
|
||||
- all_vendors() 厂商列表(模型 id 的前缀,如 "openai")
|
||||
- all_model_labels() 全量「带价格」下拉标签,按厂商 → 模型排序
|
||||
- default_model_label() 默认模型 openai/gpt-5.4-nano 对应的标签
|
||||
- label_to_model_id() 从下拉标签解析真实 model id(对旧标签/纯 id 也兼容)
|
||||
|
||||
标签格式(价格单位:USD / 百万 token):
|
||||
openai/gpt-5.4-nano [入$0.04/M 出$0.32/M]
|
||||
标签第一个空格前恒为 model id,前端级联 JS 与后端解析都依赖这一点。
|
||||
|
||||
快照可用 build_snapshot.py 随时重新拉取更新。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
DEFAULT_MODEL_ID = "openai/gpt-5.4-nano"
|
||||
|
||||
_SNAPSHOT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"models_snapshot.json")
|
||||
|
||||
|
||||
def _fmt_price(v):
|
||||
"""10.0 -> '10',0.04 -> '0.04',None -> '?'。"""
|
||||
if v is None:
|
||||
return "?"
|
||||
try:
|
||||
s = f"{float(v):.4f}".rstrip("0").rstrip(".")
|
||||
return s if s else "0"
|
||||
except (TypeError, ValueError):
|
||||
return "?"
|
||||
|
||||
|
||||
def _make_label(m):
|
||||
return (f"{m['id']} "
|
||||
f"[入${_fmt_price(m.get('input_price'))}/M "
|
||||
f"出${_fmt_price(m.get('output_price'))}/M]")
|
||||
|
||||
|
||||
def _load_models():
|
||||
"""读取快照;文件缺失/损坏时退化为只含默认模型的最小清单。"""
|
||||
try:
|
||||
with open(_SNAPSHOT_PATH, "r", encoding="utf-8") as f:
|
||||
models = json.load(f)["models"]
|
||||
if models:
|
||||
return models
|
||||
except Exception as e:
|
||||
print(f"[Rui-Node] ZenMux 模型快照加载失败({e}),退化为最小模型清单。"
|
||||
f"可运行 zenmux/build_snapshot.py 重新生成。")
|
||||
return [{
|
||||
"id": DEFAULT_MODEL_ID,
|
||||
"vendor": DEFAULT_MODEL_ID.split("/")[0],
|
||||
"input_price": None,
|
||||
"output_price": None,
|
||||
}]
|
||||
|
||||
|
||||
# 模块加载时构建一次即可(ComfyUI 启动时构建节点定义)
|
||||
_MODELS = _load_models()
|
||||
_LABELS = [_make_label(m) for m in _MODELS]
|
||||
_LABEL_TO_ID = {lb: m["id"] for lb, m in zip(_LABELS, _MODELS)}
|
||||
_KNOWN_IDS = {m["id"] for m in _MODELS}
|
||||
_VENDORS = sorted({m.get("vendor") or m["id"].split("/")[0] for m in _MODELS},
|
||||
key=str.lower)
|
||||
|
||||
|
||||
def all_vendors():
|
||||
return list(_VENDORS)
|
||||
|
||||
|
||||
def all_model_labels():
|
||||
return list(_LABELS)
|
||||
|
||||
|
||||
def default_model_label():
|
||||
for lb, m in zip(_LABELS, _MODELS):
|
||||
if m["id"] == DEFAULT_MODEL_ID:
|
||||
return lb
|
||||
return _LABELS[0]
|
||||
|
||||
|
||||
def label_to_model_id(label):
|
||||
"""
|
||||
从下拉标签解析 model id。三层兼容:
|
||||
1. 当前标签精确命中;
|
||||
2. 旧版工作流里存的标签(价格已变动)→ 取第一个空格前的 id 段;
|
||||
3. 用户直接填了纯 model id → 原样返回。
|
||||
解析不出(空串等)返回 None,由调用方兜底到默认模型。
|
||||
"""
|
||||
s = (label or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
if s in _LABEL_TO_ID:
|
||||
return _LABEL_TO_ID[s]
|
||||
head = s.split(" ")[0].split("\t")[0]
|
||||
if head in _KNOWN_IDS:
|
||||
return head
|
||||
# 未收录的 id(快照偏旧但模型真实存在)也放行,交给服务端判定
|
||||
return head if head else None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ZenMux API 连接节点
|
||||
===================
|
||||
通过 ZenMux 聚合平台(https://zenmux.ai)调用其收录的所有文本类模型。
|
||||
|
||||
特性:
|
||||
- 模型选择做成「厂商 → 模型」两级级联:先在 vendor 下拉里选厂商,
|
||||
再在 model 下拉里选该厂商的模型(联动由配套前端 zenmux_cascade.js 完成)。
|
||||
- 每个模型选项后面直接标注输入/输出价格(USD / 百万 token)。
|
||||
- 具备常规 API 节点的完整参数:api_key、system/user prompt、seed、
|
||||
temperature、top_p、max_tokens、以及可选的多模态图像输入与代理。
|
||||
- 默认模型 openai/gpt-5.4-nano。
|
||||
- 随节点分发 models_snapshot.json,无网络也能列出模型;价格与列表可用
|
||||
build_snapshot.py 重新拉取更新。
|
||||
|
||||
注:ZenMux 采用 OpenAI 兼容协议,chat 端点为
|
||||
https://zenmux.ai/api/v1/chat/completions
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from .model_registry import (
|
||||
DEFAULT_MODEL_ID,
|
||||
default_model_label,
|
||||
all_model_labels,
|
||||
all_vendors,
|
||||
label_to_model_id,
|
||||
)
|
||||
|
||||
# ZenMux 平台固定地址(OpenAI 兼容)
|
||||
DEFAULT_BASE_URL = "https://zenmux.ai/api/v1"
|
||||
# 输入框默认值不带 "://"——本仓库实测 ComfyUI 前端会吞掉文本框里的
|
||||
# 协议片段(见 openai_node.py 的同款处理),后端 _build_chat_url 会自动补 https。
|
||||
DEFAULT_BASE_URL_INPUT = "zenmux.ai/api/v1"
|
||||
|
||||
|
||||
def _clear_proxy_env():
|
||||
"""清除可能干扰 requests 的代理环境变量(仅本模块加载时执行一次)。"""
|
||||
for key in ('HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'):
|
||||
os.environ.pop(key, None)
|
||||
|
||||
|
||||
_clear_proxy_env()
|
||||
|
||||
|
||||
def _build_chat_url(base_url: str) -> str:
|
||||
"""由 base_url 拼出 chat/completions 端点,容忍用户填了/没填结尾斜杠。"""
|
||||
s = (base_url or "").strip()
|
||||
if not s:
|
||||
s = DEFAULT_BASE_URL
|
||||
# 补协议
|
||||
if not re.match(r'^https?://', s, flags=re.IGNORECASE):
|
||||
s = 'https://' + s.lstrip('/')
|
||||
s = s.rstrip('/')
|
||||
# 用户可能已经把 /chat/completions 填进去了
|
||||
if s.lower().endswith('/chat/completions'):
|
||||
return s
|
||||
return s + '/chat/completions'
|
||||
|
||||
|
||||
def _build_proxy_url(raw_proxy: str) -> str:
|
||||
"""把 '127.0.0.1:7890' 或 'http://127.0.0.1:7890' 统一成带协议的地址。"""
|
||||
s = (raw_proxy or "").strip()
|
||||
if not s:
|
||||
return ''
|
||||
if re.match(r'^https?://', s, flags=re.IGNORECASE):
|
||||
return s
|
||||
s = re.sub(r'^https?\s*:\s*/*/?\s*', '', s, flags=re.IGNORECASE).strip('/')
|
||||
return ('http://' + s) if s else ''
|
||||
|
||||
|
||||
class ZenMuxNode:
|
||||
"""ZenMux API 连接节点。"""
|
||||
|
||||
# ────────── 输入定义 ──────────
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
vendors = all_vendors()
|
||||
model_labels = all_model_labels()
|
||||
default_label = default_model_label()
|
||||
return {
|
||||
"required": {
|
||||
"api_key": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
}),
|
||||
# vendor 只用于前端级联筛选;后端不依赖它(真实模型从 model 解析)。
|
||||
# 默认选中 openai。
|
||||
"vendor": (vendors, {
|
||||
"default": "openai" if "openai" in vendors else (vendors[0] if vendors else "openai"),
|
||||
}),
|
||||
# model 的候选是「全量」模型标签(含价格),保证 ComfyUI 后端校验通过;
|
||||
# 前端 JS 会按 vendor 把可见项收窄到该厂商。
|
||||
"model": (model_labels, {
|
||||
"default": default_label,
|
||||
}),
|
||||
"system_prompt": ("STRING", {
|
||||
"default": "You are a helpful assistant.",
|
||||
"multiline": True,
|
||||
}),
|
||||
"user_prompt": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
}),
|
||||
"seed": ("INT", {
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 0xffffffffffffffff,
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"temperature": ("FLOAT", {
|
||||
"default": 0.7,
|
||||
"min": 0.0,
|
||||
"max": 2.0,
|
||||
"step": 0.1,
|
||||
}),
|
||||
"top_p": ("FLOAT", {
|
||||
"default": 1.0,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.05,
|
||||
}),
|
||||
"max_tokens": ("INT", {
|
||||
"default": 1024,
|
||||
"min": 1,
|
||||
"max": 200000,
|
||||
}),
|
||||
# 多模态图像输入(模型需支持 image 输入才有意义)
|
||||
"image_1": ("IMAGE",),
|
||||
"image_2": ("IMAGE",),
|
||||
"image_3": ("IMAGE",),
|
||||
"image_4": ("IMAGE",),
|
||||
"image_5": ("IMAGE",),
|
||||
"image_6": ("IMAGE",),
|
||||
"detail": (["auto", "low", "high"], {
|
||||
"default": "auto",
|
||||
}),
|
||||
"image_max_size": ("INT", {
|
||||
"default": 1024,
|
||||
"min": 256,
|
||||
"max": 4096,
|
||||
"step": 64,
|
||||
}),
|
||||
# 高级:一般无需改动,留空即用官方地址(无需写 https://,会自动补全)
|
||||
"base_url": ("STRING", {
|
||||
"default": DEFAULT_BASE_URL_INPUT,
|
||||
"multiline": False,
|
||||
}),
|
||||
"proxy_url": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("text", "model_id")
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "Rui-Node🐶/AI模型🤖"
|
||||
|
||||
# ────────── 宽松校验 ──────────
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, vendor, model):
|
||||
"""
|
||||
接管 vendor / model 两个下拉的校验,替代 ComfyUI 内置的
|
||||
「值必须在候选列表里」检查:价格快照更新后,旧工作流里保存的
|
||||
标签(带旧价格)不再逐字匹配新列表,但只要能解析出 model id
|
||||
就应放行,避免整个工作流被判为无效。
|
||||
"""
|
||||
if label_to_model_id(model) is None:
|
||||
return f"无法从 '{model}' 解析出 ZenMux 模型 id"
|
||||
return True
|
||||
|
||||
# ────────── 图像编码 ──────────
|
||||
@staticmethod
|
||||
def _encode_image(img_tensor, max_size):
|
||||
"""把单张图像张量 [H,W,C](0~1)编码为 base64 JPEG。"""
|
||||
img_np = np.clip(img_tensor.cpu().numpy(), 0, 1)
|
||||
pil = Image.fromarray((img_np * 255).astype(np.uint8), 'RGB')
|
||||
w, h = pil.size
|
||||
if max(w, h) > max_size:
|
||||
r = max_size / max(w, h)
|
||||
pil = pil.resize((max(1, int(w * r)), max(1, int(h * r))), Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
pil.save(buf, format='JPEG', quality=85)
|
||||
return base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||
|
||||
# ────────── 主函数 ──────────
|
||||
def generate(
|
||||
self,
|
||||
api_key,
|
||||
vendor,
|
||||
model,
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
seed,
|
||||
temperature=0.7,
|
||||
top_p=1.0,
|
||||
max_tokens=1024,
|
||||
image_1=None,
|
||||
image_2=None,
|
||||
image_3=None,
|
||||
image_4=None,
|
||||
image_5=None,
|
||||
image_6=None,
|
||||
detail="auto",
|
||||
image_max_size=1024,
|
||||
base_url=DEFAULT_BASE_URL,
|
||||
proxy_url="",
|
||||
):
|
||||
# ---- 1. 从下拉标签解析真实 model id ----
|
||||
model_id = label_to_model_id(model) or DEFAULT_MODEL_ID
|
||||
chat_url = _build_chat_url(base_url)
|
||||
print(f"[Rui-Node] ZenMux -> {chat_url} model={model_id}")
|
||||
|
||||
if not (api_key or "").strip():
|
||||
return ("(错误:未填写 api_key,请在节点里填入 ZenMux 的 API Key)", model_id)
|
||||
|
||||
# ---- 2. 收集图像 ----
|
||||
images = [
|
||||
img for img in (image_1, image_2, image_3, image_4, image_5, image_6)
|
||||
if img is not None
|
||||
]
|
||||
|
||||
# ---- 3. 构造 messages ----
|
||||
messages = []
|
||||
if system_prompt and system_prompt.strip():
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
|
||||
if images:
|
||||
parts = []
|
||||
if user_prompt and user_prompt.strip():
|
||||
parts.append({"type": "text", "text": user_prompt})
|
||||
for img in images:
|
||||
b64 = self._encode_image(img[0], image_max_size)
|
||||
parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{b64}",
|
||||
"detail": detail,
|
||||
},
|
||||
})
|
||||
if not parts:
|
||||
parts.append({"type": "text", "text": " "})
|
||||
messages.append({"role": "user", "content": parts})
|
||||
else:
|
||||
text = (user_prompt or "").strip()
|
||||
if not text:
|
||||
return ("(错误:未提供图片也未提供提示词,请至少填写 user_prompt)", model_id)
|
||||
messages.append({"role": "user", "content": text})
|
||||
|
||||
# ---- 4. 请求 ----
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key.strip()}",
|
||||
}
|
||||
payload = {
|
||||
"model": model_id,
|
||||
"messages": messages,
|
||||
"seed": seed,
|
||||
"temperature": temperature,
|
||||
"top_p": top_p,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
|
||||
proxies = None
|
||||
p = _build_proxy_url(proxy_url)
|
||||
if p:
|
||||
proxies = {"http": p, "https": p}
|
||||
|
||||
resp = None
|
||||
try:
|
||||
resp = requests.post(chat_url, headers=headers, json=payload,
|
||||
proxies=proxies, timeout=180)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("choices"):
|
||||
msg = data["choices"][0].get("message", {})
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list): # 少数模型返回分段内容
|
||||
content = "".join(
|
||||
seg.get("text", "") for seg in content if isinstance(seg, dict)
|
||||
)
|
||||
return (content or "", model_id)
|
||||
return (f"API 返回格式异常: {json.dumps(data, ensure_ascii=False)[:800]}", model_id)
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
return (f"连接失败(请检查网络/代理): {e}", model_id)
|
||||
except requests.exceptions.Timeout:
|
||||
return ("请求超时(180s),请检查网络或 ZenMux 服务状态。", model_id)
|
||||
except requests.exceptions.HTTPError:
|
||||
code = resp.status_code if resp is not None else "?"
|
||||
body = resp.text[:600] if resp is not None else ""
|
||||
return (f"HTTP 错误 {code}: {body}", model_id)
|
||||
except Exception as e:
|
||||
return (f"请求异常: {type(e).__name__}: {e}", model_id)
|
||||
|
||||
|
||||
# ────────── ComfyUI 注册 ──────────
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ZenMuxAPINode": ZenMuxNode,
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ZenMuxAPINode": "ZenMux API 连接 / ZenMux API Connector",
|
||||
}
|
||||
Reference in New Issue
Block a user