Files
2025-12-05 13:39:57 +08:00

243 lines
12 KiB
Python

#
# Author: Zeknes (Original), with async and multi-output modifications
# Repo: https://github.com/Zeknes/ComfyUI-LLMChat
# Version: 3.0 (Async + Multi-output)
#
# This version uses aiohttp for asynchronous network requests and
# splits the AI's output into three distinct parts:
# 1. thinking: Content within <think>...</think> tags.
# 2. result: Content outside the tags.
# 3. response: The full, raw output from the AI.
#
import torch
import numpy as np
from PIL import Image
import base64
import io
import requests
import aiohttp
import asyncio
import re
class LLMChatNode:
"""
An asynchronous, multi-output custom node for interacting with OpenAI and Ollama LLMs.
"""
def __init__(self):
self.ollama_models = self._get_ollama_models_sync("http://127.0.0.1:11434")
def _get_ollama_models_sync(self, base_url: str):
try_url = f"{base_url.rstrip('/')}/api/tags"
try:
response = requests.get(try_url, timeout=3)
response.raise_for_status()
models = response.json().get("models", [])
return [model["name"] for model in models] if models else ["default (is Ollama running?)"]
except requests.exceptions.RequestException:
return ["default (error fetching models)"]
@classmethod
def INPUT_TYPES(cls):
instance = cls()
return {
"required": {
"mode": (["OpenAI", "Ollama", "Smart"],),
"openai_base_url": ("STRING", {"default": "https://openrouter.ai/api/v1"}),
"api_key": ("STRING", {"default": ""}),
"openai_model": ("STRING", {"default": "google/gemini-2.5-flash"}),
"ollama_base_url": ("STRING", {"default": "http://127.0.0.1:11434"}),
"ollama_model": (instance.ollama_models,),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
"max_retries": ("INT", {"default": 3, "min": 1, "max": 10}),
"fail_words": ("STRING", {"default": "", "multiline": False}),
"system_prompt": ("STRING", {"default": "You are an image description generation expert. Your description is in natural language, no markdown or any other format. You output the description directly, no explanation. If you need to think, use <think>your thoughts here</think> tags.", "multiline": True}),
"user_prompt": ("STRING", {"default": "Describe the image.", "multiline": True}),
},
"optional": {"image": ("IMAGE",)}
}
# V3 Change: Updated return types and names
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("thinking", "result", "response")
FUNCTION = "execute"
CATEGORY = "LLMChat"
@staticmethod
def _parse_response(raw_response: str):
"""
Parses the raw AI response to extract 'thinking' and 'result' parts.
Args:
raw_response: The full string output from the language model.
Returns:
A tuple containing (thinking_text, result_text).
"""
thinking_text = ""
# Use regex to find all occurrences of the think block
# re.DOTALL allows '.' to match newlines
matches = list(re.finditer(r"<think>(.*?)</think>", raw_response, re.DOTALL))
if matches:
# Join the content of all think blocks
thinking_parts = [match.group(1).strip() for match in matches]
thinking_text = "\n\n".join(thinking_parts)
# The result is the raw response with all think blocks removed
result_text = re.sub(r"<think>.*?</think>", "", raw_response, flags=re.DOTALL).strip()
else:
# No think tags found, so the entire response is the result
thinking_text = ""
result_text = raw_response.strip()
return thinking_text, result_text
async def _call_api_async(self, session, url, headers, data, max_retries, service_name, fail_words=None):
last_exception = None
last_error_details = None
# Increase timeout for batch image processing
timeout = aiohttp.ClientTimeout(total=180) # 3 minutes for large images or batch processing
for attempt in range(max_retries):
response_text = None
try:
async with session.post(url, headers=headers, json=data, timeout=timeout) as response:
response_text = await response.text() # Get raw response first
response.raise_for_status()
response_json = await response.json()
content = None
if service_name == "OpenAI":
if "choices" not in response_json or not response_json["choices"]:
print(f"[ERROR] OpenAI Raw Response: {response_text}")
raise KeyError("API response is missing 'choices' field or it's empty.")
content = response_json["choices"][0]["message"]["content"]
elif service_name == "Ollama":
content = response_json["message"]["content"]
# Check for fail_words in the response
if fail_words and fail_words.strip():
has_fail_word, fail_word = self._check_fail_words(content, fail_words)
if has_fail_word:
# Treat fail_word as an error, just like other errors
raise ValueError(f"Response contains fail word '{fail_word}'")
return content
except Exception as e:
last_exception = e
error_type = type(e).__name__
error_msg = str(e)
print(f"[ERROR] {service_name} Attempt {attempt + 1}/{max_retries} failed")
print(f"[ERROR] Error Type: {error_type}")
print(f"[ERROR] Error Message: {error_msg}")
# For fail_word errors, show response preview; for API errors, show raw response
if "fail word" in error_msg.lower() and response_text:
# Parse response to get actual content
try:
import json
resp_json = json.loads(response_text)
if service_name == "OpenAI" and "choices" in resp_json:
content_preview = resp_json["choices"][0]["message"]["content"][:200]
elif service_name == "Ollama" and "message" in resp_json:
content_preview = resp_json["message"]["content"][:200]
else:
content_preview = response_text[:200]
print(f"[ERROR] Response preview: {content_preview}...")
except:
pass
# Always try to print the raw API response for non-fail-word errors
if response_text and "fail word" not in error_msg.lower():
print(f"[ERROR] {service_name} Raw API Response: {response_text}")
last_error_details = f"{error_type}: {error_msg}\nRaw API Response: {response_text}"
else:
last_error_details = f"{error_type}: {error_msg}"
if attempt < max_retries - 1:
await asyncio.sleep(1)
error_result = f"Error: {service_name} failed after {max_retries} retries.\nLast Error Details:\n{last_error_details if last_error_details else str(last_exception)}"
print(f"[ERROR] {error_result}")
return error_result
def _check_fail_words(self, response: str, fail_words: str):
"""Check if response contains any fail words. Returns (has_fail_word, fail_word) tuple."""
if not fail_words or not fail_words.strip():
return False, None
fail_word_list = [word.strip() for word in fail_words.split() if word.strip()]
for fail_word in fail_word_list:
if fail_word.lower() in response.lower():
return True, fail_word
return False, None
async def execute(self, mode, openai_base_url, api_key, openai_model, ollama_base_url, ollama_model, seed, max_retries, fail_words, system_prompt, user_prompt, image=None):
img_base64 = None
if image is not None:
try:
i = 255. * image.cpu().numpy().squeeze()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
buffered = io.BytesIO()
img.save(buffered, format="PNG")
img_base64 = base64.b64encode(buffered.getvalue()).decode('utf-8')
except Exception as e:
raise Exception(f"Error processing image: {e}")
openai_messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": [{"type": "text", "text": user_prompt}]}]
if img_base64:
openai_messages[1]["content"].append({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_base64}"}})
ollama_messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt, "images": [img_base64] if img_base64 else []}]
raw_response = ""
async with aiohttp.ClientSession() as session:
if mode == "OpenAI":
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
data = {"model": openai_model, "messages": openai_messages, "seed": seed}
url = f"{openai_base_url.rstrip('/')}/chat/completions"
raw_response = await self._call_api_async(session, url, headers, data, max_retries, "OpenAI", fail_words)
elif mode == "Ollama":
data = {"model": ollama_model, "messages": ollama_messages, "stream": False, "options": {"seed": seed}}
url = f"{ollama_base_url.rstrip('/')}/api/chat"
raw_response = await self._call_api_async(session, url, None, data, max_retries, "Ollama", fail_words)
elif mode == "Smart":
print("Smart Mode: Trying OpenAI first...")
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
data = {"model": openai_model, "messages": openai_messages, "seed": seed}
url = f"{openai_base_url.rstrip('/')}/chat/completions"
raw_response = await self._call_api_async(session, url, headers, data, max_retries, "OpenAI", fail_words)
# Check if OpenAI failed after all retries
if "error" in raw_response.lower():
print("[INFO] OpenAI failed after all retries. Smart Mode: Falling back to Ollama...")
data = {"model": ollama_model, "messages": ollama_messages, "stream": False, "options": {"seed": seed}}
url = f"{ollama_base_url.rstrip('/')}/api/chat"
raw_response = await self._call_api_async(session, url, None, data, max_retries, "Ollama", fail_words)
if "error" in raw_response.lower():
print(f"[ERROR] Ollama also failed: {raw_response}")
if "error" in raw_response.lower():
print(f"[ERROR] API call failed with error response: {raw_response}")
raise Exception(raw_response)
# V3 Change: Parse the response and return three distinct outputs
thinking, result = self._parse_response(raw_response)
return (thinking, result, raw_response)
# --- Node Registration ---
NODE_CLASS_MAPPINGS = {
"LLMChat": LLMChatNode
}
NODE_DISPLAY_NAME_MAPPINGS = {
"LLMChat": "LLM Chat"
}