add llm nodes

This commit is contained in:
dseditor
2025-09-02 22:50:25 +08:00
parent 7be3ba33d7
commit 434cc205c8
5 changed files with 515 additions and 0 deletions
+2
View File
@@ -15,6 +15,8 @@ The **ListHelper** collection is a comprehensive set of custom nodes for ComfyUI
1. [AudioListCombine](#audiolistcombine-node)
2. [NumberListGenerator](#numberlistgenerator-node)
3. [PromptSplitByDelimiter](#promptsplitbydelimiter-node)
4. OpenRouterLLM (For Free Bananas)
![Demo](Readme/demo6.jpg)
---
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"models": [
"google/gemini-2.5-flash-image-preview:free",
"google/gemma-3-27b-it:free",
"qwen/qwen2.5-vl-32b-instruct:free",
"mistralai/mistral-small-3.2-24b-instruct:free",
"meta-llama/llama-4-maverick:free",
"openai/gpt-oss-20b:free",
"cognitivecomputations/dolphin-mistral-24b-venice-edition:free",
"moonshotai/kimi-dev-72b:free",
"qwen/qwen2.5-vl-72b-instruct:free",
"moonshotai/kimi-k2:free",
"deepseek/deepseek-chat-v3.1:free"
]
}
+3
View File
@@ -24,6 +24,7 @@ from comfy.cli_args import args
from typing import List, Dict, Any, Tuple
from random import Random
from datetime import datetime
from .openrouter_llm import OpenRouterLLM
class AudioListGenerator:
@classmethod
@@ -1069,6 +1070,7 @@ NODE_CLASS_MAPPINGS = {
"LoadVideoPath": LoadVideoPath,
"SaveVideoPath": SaveVideoPath,
"FrameMatch": FrameMatch,
"OpenRouterLLM": OpenRouterLLM,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -1082,5 +1084,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"LoadVideoPath": "LoadVideoPath",
"SaveVideoPath": "SaveVideoPath",
"FrameMatch": "FrameMatch",
"OpenRouterLLM": "OpenRouter LLM",
}
+495
View File
@@ -0,0 +1,495 @@
import requests
import json
import base64
import os
import io
import re
import torch
from PIL import Image
import numpy as np
class OpenRouterLLM:
"""
OpenRouter LLM節點,用於處理OpenRouter的LLM類型
支援圖片和文字輸入輸出,API金鑰管理,動態模型管理
"""
@classmethod
def _load_models(cls):
"""載入模型列表從models.json檔案"""
models_file = os.path.join(os.path.dirname(__file__), "models.json")
try:
with open(models_file, 'r', encoding='utf-8') as f:
data = json.load(f)
return data.get('models', [])
except Exception as e:
print(f"⚠️ 載入models.json失敗: {e}")
# 返回默認模型列表
return [
"google/gemini-2.5-flash-image-preview:free",
"google/gemma-3-27b-it:free",
"qwen/qwen2.5-vl-32b-instruct:free",
"mistralai/mistral-small-3.2-24b-instruct:free",
"meta-llama/llama-4-maverick:free",
"openai/gpt-oss-20b:free",
"cognitivecomputations/dolphin-mistral-24b-venice-edition:free",
"moonshotai/kimi-dev-72b:free",
"qwen/qwen2.5-vl-72b-instruct:free",
"moonshotai/kimi-k2:free"
]
@classmethod
def _save_models(cls, models_list):
"""保存模型列表到models.json檔案"""
models_file = os.path.join(os.path.dirname(__file__), "models.json")
try:
with open(models_file, 'w', encoding='utf-8') as f:
json.dump({"models": models_list}, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"❌ 保存models.json失敗: {e}")
def _add_custom_model(self, model_name):
"""添加自定義模型到models.json檔案"""
try:
current_models = self._load_models()
# 檢查模型是否已存在
if model_name not in current_models:
current_models.append(model_name)
self._save_models(current_models)
print(f"✅ 已添加新模型: {model_name}")
else:
print(f"ℹ️ 模型已存在: {model_name}")
except Exception as e:
print(f"❌ 添加自定義模型失敗: {e}")
@classmethod
def INPUT_TYPES(cls):
# 從JSON檔案載入模型列表
text_models = cls._load_models()
# 在列表末尾添加"Add Custom Model..."選項
text_models_with_add = text_models + ["Add Custom Model..."]
return {
"required": {
"api_key": ("STRING", {"multiline": False, "default": "", "placeholder": "輸入您的OpenRouter API金鑰"}),
"user_prompt": ("STRING", {"multiline": True, "default": "Please analyze the provided content."}),
"text_model": (text_models_with_add, {"default": text_models_with_add[0]}),
},
"optional": {
"custom_model": ("STRING", {"multiline": False, "default": "", "placeholder": "輸入自定義模型名稱 (選擇Add Custom Model...時使用)"}),
"system_prompt": ("STRING", {"multiline": True, "default": ""}),
"image_input_1": ("IMAGE",),
"image_input_2": ("IMAGE",),
"image_input_3": ("IMAGE",),
}
}
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("image_output", "text_output")
FUNCTION = "process_llm"
CATEGORY = "ListHelper"
def __init__(self):
self.config_file = "config.json"
def _load_config(self):
"""載入配置文件"""
try:
if os.path.exists(self.config_file):
with open(self.config_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"載入配置文件失敗: {e}")
return {}
def _save_config(self, config):
"""保存配置文件"""
try:
with open(self.config_file, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"保存配置文件失敗: {e}")
def _get_api_key(self, api_key_input):
"""獲取API金鑰"""
config = self._load_config()
# 如果輸入了新的API金鑰,則更新配置
if api_key_input.strip():
config['openrouter_api_key'] = api_key_input.strip()
self._save_config(config)
return api_key_input.strip()
# 否則使用配置文件中的金鑰
return config.get('openrouter_api_key', '')
def _tensor_to_base64(self, tensor):
"""將ComfyUI圖像tensor轉換為base64編碼"""
# tensor shape: [H, W, C] (0-1 range)
if tensor.dim() == 4:
tensor = tensor.squeeze(0) # 移除批次維度
# 轉換為numpy並調整範圍到0-255
numpy_image = (tensor.cpu().numpy() * 255).astype(np.uint8)
# 轉換為PIL圖像
pil_image = Image.fromarray(numpy_image)
# 轉換為base64
buffer = io.BytesIO()
pil_image.save(buffer, format='PNG')
image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
return f"data:image/png;base64,{image_base64}"
def _base64_to_tensor(self, base64_str):
"""將base64編碼轉換為ComfyUI圖像tensor"""
try:
# 移除各種可能的前綴
prefixes = [
'data:image/png;base64,',
'data:image/jpeg;base64,',
'data:image/jpg;base64,',
]
original_str = base64_str
for prefix in prefixes:
if base64_str.startswith(prefix):
base64_str = base64_str[len(prefix):]
break
# 清理base64字符串(移除可能的換行符和空格)
base64_str = re.sub(r'[\n\r\s]', '', base64_str)
# 解碼base64
image_bytes = base64.b64decode(base64_str)
# 轉換為PIL圖像
pil_image = Image.open(io.BytesIO(image_bytes))
# 確保是RGB模式
if pil_image.mode != 'RGB':
pil_image = pil_image.convert('RGB')
# 轉換為numpy數組
numpy_image = np.array(pil_image).astype(np.float32) / 255.0
# 轉換為tensor並添加批次維度 [1, H, W, C]
tensor = torch.from_numpy(numpy_image).unsqueeze(0)
return tensor
except Exception as e:
print(f"❌ base64轉tensor失敗: {e}")
return torch.zeros(1, 1, 1, 3)
def _url_to_tensor(self, image_url):
"""從URL下載圖像並轉換為ComfyUI圖像tensor"""
try:
# 下載圖像
response = requests.get(image_url, timeout=30)
response.raise_for_status()
# 轉換為PIL圖像
pil_image = Image.open(io.BytesIO(response.content))
# 確保是RGB模式
if pil_image.mode != 'RGB':
pil_image = pil_image.convert('RGB')
# 轉換為numpy數組
numpy_image = np.array(pil_image).astype(np.float32) / 255.0
# 轉換為tensor並添加批次維度 [1, H, W, C]
tensor = torch.from_numpy(numpy_image).unsqueeze(0)
return tensor
except Exception as e:
print(f"❌ 從URL載入圖像失敗: {e}")
return torch.zeros(1, 1, 1, 3)
def _call_openrouter_api(self, api_key, model, messages):
"""調用OpenRouter API"""
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "",
"X-Title": "ComfyUI"
}
data = {
"model": model,
"messages": messages
}
try:
response = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers=headers,
data=json.dumps(data),
timeout=60
)
if response.status_code == 200:
return response.json()
elif response.status_code == 429:
# Rate limit錯誤
try:
error_data = response.json()
return {"error": {"code": 429, "message": error_data.get("error", {}).get("message", "Rate limit exceeded")}}
except:
return {"error": {"code": 429, "message": "Rate limit exceeded"}}
else:
try:
error_data = response.json()
return {"error": error_data.get("error", {"message": f"HTTP {response.status_code}"})}
except:
return {"error": {"message": f"HTTP {response.status_code}: {response.text[:200]}"}}
except Exception as e:
print(f"❌ API調用異常: {e}")
return None
def process_llm(self, api_key, user_prompt, text_model, custom_model="", system_prompt="",
image_input_1=None, image_input_2=None, image_input_3=None):
"""處理LLM請求"""
# 獲取API金鑰
actual_api_key = self._get_api_key(api_key)
if not actual_api_key:
return (torch.zeros(1, 1, 1, 3), "❌ 錯誤: 請提供OpenRouter API金鑰")
# 處理模型選擇
if text_model == "Add Custom Model...":
if not custom_model or not custom_model.strip():
return (torch.zeros(1, 1, 1, 3), "❌ 請在custom_model欄位輸入自定義模型名稱")
# 使用自定義模型
selected_model = custom_model.strip()
# 將新模型添加到models.json中
self._add_custom_model(selected_model)
else:
# 使用選擇的預設模型
selected_model = text_model
# 準備消息內容
messages = []
# 添加系統提示(如果有的話)
if system_prompt and system_prompt.strip():
messages.append({"role": "system", "content": system_prompt.strip()})
# 構建用戶消息
user_content = []
# 添加文字內容
if user_prompt and user_prompt.strip():
user_content.append({
"type": "text",
"text": user_prompt.strip()
})
# 檢查是否有圖像輸入
has_images = any([image_input_1 is not None, image_input_2 is not None, image_input_3 is not None])
# 確定使用的模型
if has_images:
# 有圖像輸入時,強制使用gemini-2.5-flash-image-preview:free
actual_model = "google/gemini-2.5-flash-image-preview:free"
else:
# 無圖像輸入時,使用用戶選擇的模型
actual_model = selected_model
# 檢查是否使用圖像生成模型(無論是否有圖像輸入)
is_image_model = actual_model == "google/gemini-2.5-flash-image-preview:free"
if has_images:
# 有圖像輸入的情況
# 為圖像生成添加特殊提示
if not user_prompt or not user_prompt.strip():
combined_text = "請分析這些圖像並生成一個相關的新圖像。請返回生成的圖像。"
else:
combined_text = user_prompt.strip()
if "生成" in combined_text or "創建" in combined_text or "製作" in combined_text:
combined_text += "\n\n請返回生成的圖像。"
# 重新添加文字內容
user_content = [{
"type": "text",
"text": combined_text
}]
# 添加圖像到用戶消息
for i, image_input in enumerate([image_input_1, image_input_2, image_input_3], 1):
if image_input is not None:
try:
base64_image = self._tensor_to_base64(image_input)
user_content.append({
"type": "image_url",
"image_url": {
"url": base64_image
}
})
except Exception as e:
print(f"❌ 處理圖像{i}失敗: {e}")
elif is_image_model:
# 純文字模式但使用圖像生成模型
combined_text = user_prompt.strip() if user_prompt else ""
if combined_text and ("生成" in combined_text or "創建" in combined_text or "製作" in combined_text or "畫" in combined_text or "繪製" in combined_text):
combined_text += "\n\n請生成並返回相應的圖像。"
elif not combined_text:
combined_text = "請生成一張圖像。"
# 添加文字內容
user_content.append({
"type": "text",
"text": combined_text
})
# 添加用戶消息
if len(user_content) > 1:
# 多媒體內容(包含圖像)
messages.append({
"role": "user",
"content": user_content
})
elif user_content:
# 純文字內容
messages.append({
"role": "user",
"content": user_content[0]["text"]
})
else:
# 空內容
messages.append({
"role": "user",
"content": "請分析提供的內容。"
})
# 調用API
response = self._call_openrouter_api(actual_api_key, actual_model, messages)
if not response:
return (torch.zeros(1, 1, 1, 3), "❌ API調用失敗")
# 檢查rate limit錯誤
if 'error' in response:
error_info = response['error']
if isinstance(error_info, dict) and 'code' in error_info:
if error_info['code'] == 429 or 'rate_limit' in str(error_info).lower():
error_msg = f"⚠️ Rate Limit達到: {error_info.get('message', '請稍後再試')}"
print(error_msg)
return (torch.zeros(1, 1, 1, 3), error_msg)
else:
error_msg = f"❌ API錯誤: {error_info.get('message', str(error_info))}"
return (torch.zeros(1, 1, 1, 3), error_msg)
else:
error_msg = f"❌ API錯誤: {str(error_info)}"
return (torch.zeros(1, 1, 1, 3), error_msg)
if 'choices' not in response or not response['choices']:
return (torch.zeros(1, 1, 1, 3), "❌ API回應格式異常")
# 獲取回應訊息
message = response['choices'][0]['message']
response_content = message.get('content', '')
# 檢查回應是否包含圖像數據
output_image = torch.zeros(1, 1, 1, 3) # 預設空圖像
output_text = response_content
found_image = False
# 首先檢查message.images字段
if 'images' in message and message['images']:
image_data = message['images'][0]
try:
if isinstance(image_data, str):
if image_data.startswith('data:image/'):
output_image = self._base64_to_tensor(image_data)
found_image = True
elif image_data.startswith('http'):
output_image = self._url_to_tensor(image_data)
found_image = True
else:
# 可能是純 base64,添加 data URL 前綴
full_data_url = f"data:image/png;base64,{image_data}"
output_image = self._base64_to_tensor(full_data_url)
found_image = True
elif isinstance(image_data, dict):
# 處理嵌套的 image_url 字典格式
if 'type' in image_data and image_data['type'] == 'image_url' and 'image_url' in image_data:
nested_image_url = image_data['image_url']
if isinstance(nested_image_url, dict) and 'url' in nested_image_url:
image_url_value = nested_image_url['url']
if str(image_url_value).startswith('data:image/'):
output_image = self._base64_to_tensor(str(image_url_value))
else:
output_image = self._url_to_tensor(str(image_url_value))
found_image = True
# 處理直接包含 url 的格式
elif 'url' in image_data:
url_value = str(image_data['url'])
if url_value.startswith('data:image/'):
output_image = self._base64_to_tensor(url_value)
else:
output_image = self._url_to_tensor(url_value)
found_image = True
# 處理直接包含 data 的格式
elif 'data' in image_data:
output_image = self._base64_to_tensor(str(image_data['data']))
found_image = True
except Exception as e:
print(f"❌ 處理圖像失敗: {e}")
found_image = False
# 如果在message.images中沒有找到圖像,檢查content中的嵌入圖像
if not found_image and is_image_model:
# 檢查URL和base64格式的圖像
url_patterns = [
r'https?://[^\s\)]+\.(?:png|jpg|jpeg|gif|webp)',
r'!\[.*?\]\((https?://[^\s\)]+\.(?:png|jpg|jpeg|gif|webp))\)',
r'<img[^>]+src=["\']([^"\']+)["\'][^>]*>',
]
base64_patterns = [
r'data:image/png;base64,([A-Za-z0-9+/=\n\r]+)',
r'data:image/jpeg;base64,([A-Za-z0-9+/=\n\r]+)',
r'data:image/jpg;base64,([A-Za-z0-9+/=\n\r]+)',
]
# 檢查URL格式
for pattern in url_patterns:
matches = re.findall(pattern, response_content, re.DOTALL)
if matches:
try:
output_image = self._url_to_tensor(matches[0])
found_image = True
output_text = re.sub(pattern, "[Generated Image]", response_content, flags=re.DOTALL)
break
except Exception as e:
continue
# 檢查base64格式
if not found_image:
for pattern in base64_patterns:
matches = re.findall(pattern, response_content, re.DOTALL)
if matches:
try:
clean_base64 = re.sub(r'[\n\r\s]', '', matches[0])
base64_image = f"data:image/png;base64,{clean_base64}"
output_image = self._base64_to_tensor(base64_image)
found_image = True
output_text = re.sub(pattern, "[Generated Image]", response_content, flags=re.DOTALL)
break
except Exception as e:
continue
return (output_image, output_text)