根因:ComfyUI的动态提示系统或第三方扩展(如cg-use-everywhere) 会将STRING控件中的'//'视为注释语法,导致'://'及其后的 主机名+路径被彻底丢弃,仅靠补回'//'无法恢复已丢失的内容。 解决方案: - 将原来的单个api_url输入拆分为protocol下拉框 + 地址字符串 - protocol下拉框提供 https:// 和 http:// 选项(下拉框不受文本处理影响) - api_url默认值不再包含'://',从根源避免被吞 - 新增_build_url()函数智能拼接URL,兼容多种用户输入格式 - 即使地址被完全清空也会回退到OpenAI默认地址 - 添加控制台日志输出实际请求地址,便于调试 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
278 lines
8.7 KiB
Python
278 lines
8.7 KiB
Python
import numpy as np
|
||
import requests
|
||
import json
|
||
import base64
|
||
import io
|
||
import os
|
||
import re
|
||
from PIL import Image
|
||
|
||
# 快速解决方案:清除可能导致连接错误的代理环境变量
|
||
# Fast solution: Clear proxy environment variables that might cause connection errors
|
||
# 许多用户在使用 requests 库连接 OpenAI API 时会遇到 ProxyError
|
||
# 这是因为 Python 环境可能读取了不正确的系统代理设置
|
||
# Many users encounter ProxyError when connecting to OpenAI API with requests
|
||
# This is because the Python environment might read incorrect system proxy settings
|
||
os.environ['HTTP_PROXY'] = ''
|
||
os.environ['HTTPS_PROXY'] = ''
|
||
os.environ['http_proxy'] = ''
|
||
os.environ['https_proxy'] = ''
|
||
|
||
|
||
def _build_url(protocol: str, api_url: str) -> str:
|
||
"""
|
||
将协议和地址拼接为完整 URL。
|
||
兼容多种用户输入格式:
|
||
- 'api.openai.com/v1/chat/completions' (标准,无协议前缀)
|
||
- 'https://api.openai.com/v1/chat/completions' (用户手动写了完整 URL)
|
||
- 'https:api.openai.com/...' (被 ComfyUI 吞掉了 //)
|
||
- 'http://127.0.0.1:5000/v1/chat/completions' (本地服务)
|
||
"""
|
||
url = api_url.strip()
|
||
|
||
# 如果用户手动输入了完整 URL(含协议),直接修复并使用
|
||
if re.match(r'^https?://.+', url):
|
||
return url
|
||
|
||
# 去掉可能残留的被截断的协议前缀,如 "https:" "http:" "https://" "http://"
|
||
url = re.sub(r'^https?:/*', '', url)
|
||
|
||
# 去掉首尾多余的斜杠
|
||
url = url.strip('/')
|
||
|
||
if not url:
|
||
# 全部被吞了,回退到 OpenAI 默认地址
|
||
url = 'api.openai.com/v1/chat/completions'
|
||
|
||
return f"{protocol}{url}"
|
||
|
||
|
||
class OpenAINode:
|
||
"""
|
||
OpenAI API 节点:
|
||
支持连接 OpenAI 及其兼容 API(如 DeepSeek, Moonshot 等),
|
||
支持文本生成和多模态图像理解。
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"protocol": (["https://", "http://"], {
|
||
"default": "https://"
|
||
}),
|
||
"api_url": ("STRING", {
|
||
"default": "api.openai.com/v1/chat/completions",
|
||
"multiline": False,
|
||
"placeholder": "api.openai.com/v1/chat/completions"
|
||
}),
|
||
"api_key": ("STRING", {
|
||
"default": "",
|
||
"multiline": False
|
||
}),
|
||
"model": ("STRING", {
|
||
"default": "gpt-4o",
|
||
"multiline": False
|
||
}),
|
||
"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": {
|
||
"image_1": ("IMAGE",),
|
||
"image_2": ("IMAGE",),
|
||
"image_3": ("IMAGE",),
|
||
"image_4": ("IMAGE",),
|
||
"image_5": ("IMAGE",),
|
||
"image_6": ("IMAGE",),
|
||
"temperature": ("FLOAT", {
|
||
"default": 0.3,
|
||
"min": 0.0,
|
||
"max": 2.0,
|
||
"step": 0.1
|
||
}),
|
||
"max_tokens": ("INT", {
|
||
"default": 500,
|
||
"min": 1,
|
||
"max": 8192
|
||
}),
|
||
"detail": (["low", "high", "auto"], {
|
||
"default": "auto"
|
||
}),
|
||
"image_max_size": ("INT", {
|
||
"default": 1024,
|
||
"min": 256,
|
||
"max": 4096,
|
||
"step": 64
|
||
}),
|
||
"proxy_url": ("STRING", {
|
||
"default": "",
|
||
"multiline": False,
|
||
"placeholder": "e.g., http://127.0.0.1:7890"
|
||
}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("STRING",)
|
||
RETURN_NAMES = ("text",)
|
||
FUNCTION = "generate_content"
|
||
CATEGORY = "Rui-Node🐶/AI模型🤖"
|
||
|
||
def _encode_image_tensor(self, img_tensor, image_max_size):
|
||
img_np = img_tensor.cpu().numpy()
|
||
img_np = np.clip(img_np, 0, 1)
|
||
img_pil = Image.fromarray((img_np * 255).astype(np.uint8), 'RGB')
|
||
width, height = img_pil.size
|
||
if max(width, height) > image_max_size:
|
||
ratio = image_max_size / max(width, height)
|
||
img_pil = img_pil.resize(
|
||
(max(1, int(width * ratio)), max(1, int(height * ratio))),
|
||
Image.LANCZOS
|
||
)
|
||
buffered = io.BytesIO()
|
||
img_pil.save(buffered, format="JPEG", quality=85)
|
||
return base64.b64encode(buffered.getvalue()).decode('utf-8')
|
||
|
||
def generate_content(
|
||
self,
|
||
protocol,
|
||
api_url,
|
||
api_key,
|
||
model,
|
||
system_prompt,
|
||
user_prompt,
|
||
seed,
|
||
image_1=None,
|
||
image_2=None,
|
||
image_3=None,
|
||
image_4=None,
|
||
image_5=None,
|
||
image_6=None,
|
||
temperature=0.3,
|
||
max_tokens=500,
|
||
detail="auto",
|
||
image_max_size=1024,
|
||
proxy_url=""
|
||
):
|
||
"""
|
||
调用 OpenAI API 生成内容。
|
||
- 有图像时:使用多模态格式(image_url + text)发送请求
|
||
- 无图像时:使用纯文本格式发送请求,实现普通对话
|
||
"""
|
||
|
||
# ---- 拼接完整 URL ----
|
||
full_url = _build_url(protocol, api_url)
|
||
print(f"[Rui-Node🐶 OpenAI] 请求地址: {full_url}")
|
||
|
||
all_images = [
|
||
img for img in [
|
||
image_1,
|
||
image_2,
|
||
image_3,
|
||
image_4,
|
||
image_5,
|
||
image_6,
|
||
] if img is not None
|
||
]
|
||
|
||
# ---- 构造 messages ----
|
||
messages = [
|
||
{"role": "system", "content": system_prompt}
|
||
]
|
||
|
||
if all_images:
|
||
# 多模态模式:图像 + 文本
|
||
user_content = []
|
||
|
||
if user_prompt:
|
||
user_content.append({
|
||
"type": "text",
|
||
"text": user_prompt
|
||
})
|
||
|
||
for image in all_images:
|
||
img_tensor = image[0]
|
||
img_base64 = self._encode_image_tensor(img_tensor, image_max_size)
|
||
user_content.append({
|
||
"type": "image_url",
|
||
"image_url": {
|
||
"url": f"data:image/jpeg;base64,{img_base64}",
|
||
"detail": detail
|
||
}
|
||
})
|
||
|
||
if not user_content:
|
||
user_content.append({
|
||
"type": "text",
|
||
"text": " "
|
||
})
|
||
|
||
messages.append({
|
||
"role": "user",
|
||
"content": user_content
|
||
})
|
||
else:
|
||
# 纯文本模式:无图像,直接发送文本对话
|
||
prompt_text = user_prompt.strip() if user_prompt else ""
|
||
if not prompt_text:
|
||
return ("(错误:未提供图片也未提供提示词,请至少输入 user_prompt)",)
|
||
|
||
messages.append({
|
||
"role": "user",
|
||
"content": prompt_text
|
||
})
|
||
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {api_key}"
|
||
}
|
||
|
||
payload = {
|
||
"model": model,
|
||
"messages": messages,
|
||
"seed": seed,
|
||
"temperature": temperature,
|
||
"max_tokens": max_tokens,
|
||
}
|
||
|
||
proxies = None
|
||
if proxy_url and proxy_url.strip():
|
||
proxies = {
|
||
"http": proxy_url,
|
||
"https": proxy_url
|
||
}
|
||
|
||
try:
|
||
response = requests.post(full_url, headers=headers, json=payload, proxies=proxies, timeout=60)
|
||
response.raise_for_status()
|
||
result = response.json()
|
||
|
||
if "choices" in result and len(result["choices"]) > 0:
|
||
content = result["choices"][0]["message"]["content"]
|
||
return (content,)
|
||
else:
|
||
return (f"Error: API response format unexpected. Response: {json.dumps(result)}",)
|
||
|
||
except Exception as e:
|
||
return (f"Error calling OpenAI API: {str(e)}",)
|
||
|
||
# 节点映射字典
|
||
NODE_CLASS_MAPPINGS = {
|
||
"OpenAIAPINode": OpenAINode
|
||
}
|
||
|
||
# 节点显示名称映射
|
||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||
"OpenAIAPINode": "OpenAI API 连接 / OpenAI API Connector"
|
||
}
|