import requests import json import base64 import os import io import re import torch import random from PIL import Image, ImageOps import numpy as np import math 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]}), "seed": ("INT", {"default": -1, "min": -1, "max": 0xffffffffffffffff, "step": 1, "tooltip": "隨機種子控制(-1為隨機)。注意:圖像生成模型(如Gemini)不支援seed參數"}), }, "optional": { "custom_model": ("STRING", {"multiline": False, "default": "", "placeholder": "輸入自定義模型名稱 (選擇Add Custom Model...時使用)"}), "system_prompt": ("STRING",), "enable_resize": ("BOOLEAN", {"default": False, "tooltip": "啟用圖像尺寸調整功能"}), "target_width": ("INT", {"default": 1024, "min": 64, "max": 2048, "step": 8, "tooltip": "需要啟用resize才會生效"}), "target_height": ("INT", {"default": 1024, "min": 64, "max": 2048, "step": 8, "tooltip": "需要啟用resize才會生效"}), "resize_method": (["bicubic", "lanczos", "bilinear", "nearest"], {"default": "lanczos", "tooltip": "需要啟用resize才會生效"}), "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 _is_image_generation_model(self, model_name): """檢查是否為圖像生成模型""" image_generation_models = [ "gemini-2.5-flash-image-preview", "gemini-image", # 可能的其他變體 ] return any(img_model in model_name.lower() for img_model in image_generation_models) def _get_default_tensor_size(self, enable_resize, target_width, target_height): """獲取默認tensor尺寸""" if enable_resize: return (1, target_height, target_width, 3) else: return (1, 512, 512, 3) def _create_blank_image_tensor(self, width, height): """創建指定尺寸的空白圖像tensor(白色背景)""" # 創建白色圖像 (1.0 = 白色 in 0-1 range) blank_tensor = torch.ones(1, height, width, 3, dtype=torch.float32) return blank_tensor 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, enable_resize=False, target_width=None, target_height=None): """將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) # 只有在啟用resize且指定了目標尺寸時,才進行padding處理 if enable_resize and target_width is not None and target_height is not None: pil_image = self._pad_and_resize_image(pil_image, target_width, target_height) # 轉換為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, enable_resize=False, target_width=None, target_height=None, resize_method="lanczos"): """將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') # 只有在啟用resize且指定了目標尺寸時,才進行縮放處理 if enable_resize and target_width is not None and target_height is not None: pil_image = self._resize_image(pil_image, target_width, target_height, resize_method) # 轉換為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}") # 返回一個較大的默認圖像而不是1x1 return torch.zeros(1, 512, 512, 3) def _url_to_tensor(self, image_url, enable_resize=False, target_width=None, target_height=None, resize_method="lanczos"): """從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') # 只有在啟用resize且指定了目標尺寸時,才進行縮放處理 if enable_resize and target_width is not None and target_height is not None: pil_image = self._resize_image(pil_image, target_width, target_height, resize_method) # 轉換為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}") # 返回一個較大的默認圖像而不是1x1 return torch.zeros(1, 512, 512, 3) def _call_openrouter_api(self, api_key, model, messages, seed=None): """調用OpenRouter API""" headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "HTTP-Referer": "", "X-Title": "ComfyUI" } data = { "model": model, "messages": messages } # 檢查是否為圖像生成模型 is_image_generation_model = self._is_image_generation_model(model) # 對於圖像生成模型,添加特殊參數 if is_image_generation_model: # 確保包含image輸出模式 data["modalities"] = ["image", "text"] # 不設置max_tokens,讓模型有足夠空間生成圖像 # 只對非圖像生成模型設置seed參數 if seed is not None and not is_image_generation_model: data["seed"] = seed try: response = requests.post( "https://openrouter.ai/api/v1/chat/completions", headers=headers, data=json.dumps(data), timeout=60 ) if response.status_code == 200: result = response.json() return result 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 _pad_and_resize_image(self, pil_image, target_width, target_height): """使用比例縮放+padding的方式處理圖像到指定尺寸""" try: original_width, original_height = pil_image.size target_ratio = target_width / target_height original_ratio = original_width / original_height # 先根據比例關係決定如何縮放 if original_ratio > target_ratio: # 原圖較寬,以寬度為準縮放 scale_ratio = target_width / original_width new_width = target_width new_height = int(original_height * scale_ratio) else: # 原圖較高,以高度為準縮放 scale_ratio = target_height / original_height new_width = int(original_width * scale_ratio) new_height = target_height # 縮放圖像 pil_image = pil_image.resize((new_width, new_height), Image.LANCZOS) # 計算需要的padding pad_width = target_width - new_width pad_height = target_height - new_height # 應用padding(居中) if pad_width > 0 or pad_height > 0: left_pad = pad_width // 2 right_pad = pad_width - left_pad top_pad = pad_height // 2 bottom_pad = pad_height - top_pad # 使用黑色填充padding區域 pil_image = ImageOps.expand(pil_image, (left_pad, top_pad, right_pad, bottom_pad), fill=(0, 0, 0)) # 檢查是否需要進一步縮放(如果任一邊超過1536) current_width, current_height = pil_image.size if current_width > 1536 or current_height > 1536: # 保持比例縮放到1536以內 final_scale_ratio = min(1536 / current_width, 1536 / current_height) final_width = int(current_width * final_scale_ratio) final_height = int(current_height * final_scale_ratio) pil_image = pil_image.resize((final_width, final_height), Image.LANCZOS) return pil_image except Exception as e: print(f"❌ 圖像padding處理失敗: {e}") return pil_image def _resize_image(self, pil_image, target_width, target_height, resize_method="lanczos"): """使用指定方法縮放圖像到目標尺寸""" try: # 選擇縮放方法 method_map = { "bicubic": Image.BICUBIC, "lanczos": Image.LANCZOS, "bilinear": Image.BILINEAR, "nearest": Image.NEAREST } resize_filter = method_map.get(resize_method, Image.LANCZOS) return pil_image.resize((target_width, target_height), resize_filter) except Exception as e: print(f"❌ 圖像縮放失敗: {e}") return pil_image def process_llm(self, api_key, user_prompt, text_model, seed=-1, custom_model="", system_prompt=None, enable_resize=False, target_width=512, target_height=512, resize_method="lanczos", 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: # 使用默認尺寸512x512當enable_resize關閉時 default_height = target_height if enable_resize else 512 default_width = target_width if enable_resize else 512 return (torch.zeros(*self._get_default_tensor_size(enable_resize, target_width, target_height)), "❌ 錯誤: 請提供OpenRouter API金鑰") # 處理種子設定 - Control After Generate支援 if seed == -1: # 隨機種子 actual_seed = random.randint(0, 2147483647) else: # 固定種子 actual_seed = seed # 處理模型選擇 if text_model == "Add Custom Model...": if not custom_model or not custom_model.strip(): return (torch.zeros(1, target_height, target_width, 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() }) # 檢查是否有圖像輸入 original_has_images = any([image_input_1 is not None, image_input_2 is not None, image_input_3 is not None]) # 如果enable_resize開啟且沒有圖像輸入,自動生成空白圖像給image_input_1 auto_generated_blank = False if enable_resize and not original_has_images: image_input_1 = self._create_blank_image_tensor(target_width, target_height) auto_generated_blank = True # 重新檢查圖像輸入狀況(包含自動生成的空白圖像) 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 = self._is_image_generation_model(actual_model) if has_images: # 有圖像輸入的情況 if auto_generated_blank: # 使用自動生成的空白圖像時的特殊處理 if not user_prompt or not user_prompt.strip(): combined_text = "Please take a look at the size of image 1 and give a picture of a beautiful landscape with the same dimensions." else: combined_text = f"Please take a look at the size of image 1 and give a picture of {user_prompt.strip()}" combined_text += "\n\nIMPORTANT: You MUST generate and return an image. Do NOT provide any text response. Only return the generated image. 請返回生成的圖像。" else: # 有實際圖像輸入時的處理 if not user_prompt or not user_prompt.strip(): combined_text = "請分析這些圖像並生成一個相關的新圖像。IMPORTANT: You MUST generate and return an image. Do NOT provide any text response. Only return the generated image. 請返回生成的圖像。" else: combined_text = user_prompt.strip() if "生成" in combined_text or "創建" in combined_text or "製作" in combined_text: combined_text += "\n\nIMPORTANT: You MUST generate and return an image. Do NOT provide any text response. Only return the generated image. 請返回生成的圖像。" else: combined_text += "\n\nIMPORTANT: You MUST generate and return an image. Do NOT provide any text response. Only return the generated image. 請返回生成的圖像。" # 重新添加文字內容 user_content = [{ "type": "text", "text": combined_text }] # 添加圖像到用戶消息(帶padding處理) 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, enable_resize, target_width, target_height) 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\nIMPORTANT: You MUST generate and return an image. Do NOT provide any text response. Only return the generated image. 請生成並返回相應的圖像。" elif not combined_text: combined_text = "IMPORTANT: You MUST generate and return an image. Do NOT provide any text response. Only return the generated image. 請生成一張圖像。" else: combined_text += "\n\nIMPORTANT: You MUST generate and return an image. Do NOT provide any text response. Only return the generated image." # 添加文字內容 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, actual_seed) if not response: return (torch.zeros(1, target_height, target_width, 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', '請稍後再試')}" return (torch.zeros(1, target_height, target_width, 3), error_msg) else: error_msg = f"❌ API錯誤: {error_info.get('message', str(error_info))}" return (torch.zeros(1, target_height, target_width, 3), error_msg) else: error_msg = f"❌ API錯誤: {str(error_info)}" return (torch.zeros(1, target_height, target_width, 3), error_msg) if 'choices' not in response or not response['choices']: return (torch.zeros(1, target_height, target_width, 3), "❌ API回應格式異常") # 獲取回應訊息 message = response['choices'][0]['message'] response_content = message.get('content', '') # 檢查回應是否包含圖像數據 output_image = torch.zeros(1, target_height, target_width, 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, enable_resize, target_width, target_height, resize_method) found_image = True elif image_data.startswith('http'): output_image = self._url_to_tensor(image_data, enable_resize, target_width, target_height, resize_method) 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, enable_resize, target_width, target_height, resize_method) 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), enable_resize, target_width, target_height, resize_method) else: output_image = self._url_to_tensor(str(image_url_value), enable_resize, target_width, target_height, resize_method) 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, enable_resize, target_width, target_height, resize_method) else: output_image = self._url_to_tensor(url_value, enable_resize, target_width, target_height, resize_method) found_image = True # 處理直接包含 data 的格式 elif 'data' in image_data: output_image = self._base64_to_tensor(str(image_data['data']), enable_resize, target_width, target_height, resize_method) 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']+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], enable_resize, target_width, target_height, resize_method) 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, enable_resize, target_width, target_height, resize_method) 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)