Files
Anush 4c5ed5a02a Update user messages for image prompt enhancer
Replaces minimal user trigger messages with more descriptive prompts in Z_ImagePromptEnhancer. This clarifies the intent for both text-only and image-based requests.
2026-01-25 20:23:37 +05:30

2845 lines
118 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Z-Image Utility - Enhanced Edition (Fixed)
A comprehensive ComfyUI node for prompt enhancement using multiple LLM backends.
Supports:
- OpenRouter API (Cloud)
- Local API servers (Ollama, LM Studio, vLLM, text-generation-webui)
- Direct HuggingFace model loading with quantization
Features:
- Session-based chat history for multi-turn conversations
- Configurable inference options with enable flags
- Smart VRAM management and auto-quantization fallback
- Model caching with configurable keep-alive
- Streaming support (where available)
- Comprehensive debug logging
- Image input support for vision models
- UTF-8 sanitization option
"""
from __future__ import annotations
import base64
import gc
import json
import logging
import os
import random
import re
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
# Optional imports
try:
from PIL import Image
import numpy as np
HAS_PIL = True
except ImportError:
HAS_PIL = False
try:
import torch
from transformers import (
AutoTokenizer,
AutoModelForCausalLM,
AutoProcessor,
BitsAndBytesConfig
)
# Handle deprecation: AutoModelForVision2Seq -> AutoModelForImageTextToText
try:
from transformers import AutoModelForImageTextToText as AutoModelForVision2Seq
except ImportError:
from transformers import AutoModelForVision2Seq
from huggingface_hub import snapshot_download
HAS_TRANSFORMERS = True
except ImportError:
HAS_TRANSFORMERS = False
try:
import folder_paths
import comfy.samplers
import comfy.sample
import comfy.utils
import comfy.model_management
import latent_preview
import node_helpers
HAS_COMFYUI = True
except ImportError:
HAS_COMFYUI = False
if TYPE_CHECKING:
pass # Type hints only
# ============================================================================
# CONSTANTS AND CONFIGURATION
# ============================================================================
NODE_DIR = Path(__file__).parent
CONFIG_PATH = NODE_DIR / "z_image_config.json"
LOG_FILE = NODE_DIR / "z_image_debug.log"
# Default configuration
DEFAULT_CONFIG = {
"default_model": "qwen/qwen3-235b-a22b:free",
"default_local_endpoint": "http://localhost:11434/v1",
"default_temperature": 0.7,
"default_max_tokens": 2048,
"retry_count": 3,
"timeout": 120,
}
# Tooltips for UI elements (following QwenVL pattern)
TOOLTIPS = {
"provider": "Select API provider: openrouter (cloud), local (API server), or direct (HuggingFace model loading)",
"model": "Model identifier. OpenRouter: provider/model-name | Local: model name from server | Direct: HuggingFace repo ID",
"api_key": "API key for OpenRouter. Get one at https://openrouter.ai/keys",
"local_endpoint": "Local LLM server endpoint (Ollama: 11434, LM Studio: 1234, vLLM: 8000)",
"quantization": "Model precision. 4-bit saves VRAM, 8-bit is balanced, FP16/None gives best quality",
"temperature": "Sampling randomness. Lower (0.1-0.4) = focused, Higher (0.7+) = creative",
"max_tokens": "Maximum tokens to generate. Higher = longer responses but more time/memory",
"retry_count": "Number of retry attempts on API failure with exponential backoff",
"keep_model_loaded": "Keep model in memory after inference for faster subsequent runs",
"prompt_template": "Prompt template language. 'auto' detects from input prompt, 'chinese' uses Chinese template, 'english' uses English template",
"seed": "Random seed for reproducible results",
"session_id": "Session identifier for multi-turn conversations. Same ID = shared history",
"reset_session": "Clear conversation history for this session",
"debug_mode": "Enable detailed debug logging to file and console",
"image": "Optional image input for vision-capable models",
"utf8_sanitize": "Sanitize output to ASCII-safe characters",
"max_output_length": "Maximum length in characters (0=unlimited). Z-Image-Turbo works best with 4500-7500 chars (~600-1000 words). Default 6000 chars ≈ 800 words ≈ 1066 tokens.",
}
class Provider(str, Enum):
"""Supported LLM providers."""
OPENROUTER = "openrouter"
LOCAL = "local"
DIRECT = "direct"
class Quantization(str, Enum):
"""Quantization options for direct model loading."""
NONE = "none"
Q8 = "8bit"
Q4 = "4bit"
@classmethod
def get_values(cls) -> List[str]:
return [item.value for item in cls]
# ============================================================================
# LOGGING SETUP
# ============================================================================
def setup_logger(name: str = "Z-ImageUtility", log_file: Optional[Path] = None) -> logging.Logger:
"""Setup logger with file and console output."""
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
# Clear existing handlers
if logger.handlers:
logger.handlers.clear()
# File handler - DEBUG level
if log_file:
try:
file_handler = logging.FileHandler(log_file, mode='a', encoding='utf-8')
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s | %(levelname)-8s | %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
))
logger.addHandler(file_handler)
except Exception as e:
print(f"[Z-Image] Warning: Could not create log file: {e}")
# Console handler - INFO level
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(logging.Formatter(
'[Z-Image] %(levelname)s: %(message)s'
))
logger.addHandler(console_handler)
return logger
logger = setup_logger(log_file=LOG_FILE)
# ============================================================================
# SESSION MANAGEMENT (Following comfyui-ollama pattern)
# ============================================================================
@dataclass
class ChatSession:
"""Manages conversation history for multi-turn interactions."""
messages: List[Dict[str, str]] = field(default_factory=list)
model: str = ""
created_at: datetime = field(default_factory=datetime.now)
last_used: datetime = field(default_factory=datetime.now)
def add_message(self, role: str, content: str) -> None:
"""Add a message to the session history."""
self.messages.append({"role": role, "content": content})
self.last_used = datetime.now()
def get_messages(self) -> List[Dict[str, str]]:
"""Get all messages in the session."""
return self.messages.copy()
def clear(self) -> None:
"""Clear all messages from the session."""
self.messages.clear()
self.last_used = datetime.now()
# Global session storage
CHAT_SESSIONS: Dict[str, ChatSession] = {}
def get_or_create_session(session_id: str, model: str = "") -> Tuple[ChatSession, bool]:
"""Get existing session or create a new one. Returns (session, is_new)."""
global _cleanup_counter
# Run cleanup every 100 session accesses (non-critical race condition acceptable)
_cleanup_counter += 1
if _cleanup_counter >= 100:
_cleanup_counter = 0
try:
cleanup_old_sessions()
except RuntimeError:
# Dictionary changed size during iteration - skip this cleanup cycle
pass
is_new = session_id not in CHAT_SESSIONS
if is_new:
CHAT_SESSIONS[session_id] = ChatSession(model=model)
logger.info(f"Created new session: {session_id}")
return CHAT_SESSIONS[session_id], is_new
def clear_session(session_id: str) -> bool:
"""Clear a specific session. Returns True if session existed."""
if session_id in CHAT_SESSIONS:
CHAT_SESSIONS[session_id].clear()
logger.info(f"Cleared session: {session_id}")
return True
logger.debug(f"Session not found for clearing: {session_id}")
return False
# Session cleanup configuration
MAX_SESSION_AGE_HOURS = 24
_cleanup_counter = 0
def cleanup_old_sessions() -> int:
"""
Remove sessions older than MAX_SESSION_AGE_HOURS.
Returns:
Number of sessions removed.
"""
cutoff = datetime.now() - timedelta(hours=MAX_SESSION_AGE_HOURS)
# Take a snapshot of items to avoid RuntimeError during concurrent iteration
expired = [sid for sid, sess in list(CHAT_SESSIONS.items()) if sess.last_used < cutoff]
for sid in expired:
CHAT_SESSIONS.pop(sid, None) # Use pop to avoid KeyError if already removed
if expired:
logger.info(f"Cleaned up {len(expired)} expired session(s)")
return len(expired)
# ============================================================================
# DEVICE AND MEMORY UTILITIES (Following QwenVL pattern)
# ============================================================================
def get_device_info() -> Dict[str, Any]:
"""Get comprehensive device information for memory management."""
info = {
"gpu": {"available": False, "total_memory": 0, "free_memory": 0, "name": "N/A"},
"system_memory": {"total": 0, "available": 0},
"device_type": "cpu",
"recommended_device": "cpu",
}
# Check CUDA
if HAS_TRANSFORMERS and torch.cuda.is_available():
try:
props = torch.cuda.get_device_properties(0)
total = props.total_memory / (1024 ** 3)
allocated = torch.cuda.memory_allocated(0) / (1024 ** 3)
info["gpu"] = {
"available": True,
"total_memory": total,
"free_memory": total - allocated,
"name": props.name,
}
info["device_type"] = "nvidia_gpu"
info["recommended_device"] = "cuda"
except Exception as e:
logger.warning(f"Error getting CUDA info: {e}")
# Check MPS (Apple Silicon)
elif HAS_TRANSFORMERS and hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
info["gpu"] = {"available": True, "total_memory": 0, "free_memory": 0, "name": "Apple Silicon"}
info["device_type"] = "apple_silicon"
info["recommended_device"] = "mps"
# System memory
try:
import psutil
mem = psutil.virtual_memory()
info["system_memory"] = {
"total": mem.total / (1024 ** 3),
"available": mem.available / (1024 ** 3),
}
except ImportError:
pass
return info
def enforce_quantization(requested: Quantization, device_info: Dict[str, Any], model_vram_req: float = 0) -> Quantization:
"""Auto-downgrade quantization if insufficient memory."""
if not model_vram_req:
return requested
if device_info["recommended_device"] == "cuda":
available = device_info["gpu"]["free_memory"]
else:
sys_available = device_info["system_memory"].get("available", 0)
available = sys_available * 0.7 if sys_available > 0 else float('inf') # Don't restrict if unknown
# Check if we have enough memory with 20% buffer
if model_vram_req * 1.2 > available:
if requested == Quantization.NONE:
logger.warning(f"Insufficient memory for FP16 ({available:.1f}GB < {model_vram_req:.1f}GB), switching to 8-bit")
return Quantization.Q8
elif requested == Quantization.Q8:
logger.warning(f"Insufficient memory for 8-bit ({available:.1f}GB < {model_vram_req:.1f}GB), switching to 4-bit")
return Quantization.Q4
return requested
def clear_gpu_memory() -> None:
"""Clear GPU memory cache."""
gc.collect()
if HAS_TRANSFORMERS and torch.cuda.is_available():
torch.cuda.synchronize() # Wait for all CUDA operations to complete
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
torch.cuda.reset_peak_memory_stats() # Reset for monitoring
logger.debug("GPU memory cache cleared")
# ============================================================================
# IMAGE UTILITIES
# ============================================================================
def tensor_to_base64(tensor: "torch.Tensor") -> str:
"""Convert a ComfyUI image tensor to base64 string."""
if not HAS_PIL:
raise RuntimeError("PIL is required for image processing")
# Handle batch dimension
if tensor.dim() == 4:
tensor = tensor[0]
# Convert to numpy and scale to 0-255
array = (tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
# Create PIL image and encode to base64
img = Image.fromarray(array)
buffered = BytesIO()
img.save(buffered, format="PNG")
return base64.b64encode(buffered.getvalue()).decode('utf-8')
def batch_tensors_to_base64(tensors: "torch.Tensor") -> List[str]:
"""Convert a batch of image tensors to base64 strings."""
if tensors is None:
return []
images_b64 = []
for i in range(tensors.shape[0]):
images_b64.append(tensor_to_base64(tensors[i]))
return images_b64
# ============================================================================
# INTEGRATED KSAMPLER HELPERS
# ============================================================================
def common_ksampler(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent, denoise=1.0, disable_noise=False, start_step=None, last_step=None, force_full_denoise=False):
"""Common KSampler implementation for integrated sampling."""
if not HAS_COMFYUI:
raise RuntimeError("ComfyUI is required for sampling")
latent_image = latent["samples"]
latent_image = comfy.sample.fix_empty_latent_channels(model, latent_image)
if disable_noise:
noise = torch.zeros(latent_image.size(), dtype=latent_image.dtype, layout=latent_image.layout, device="cpu")
else:
batch_inds = latent["batch_index"] if "batch_index" in latent else None
noise = comfy.sample.prepare_noise(latent_image, seed, batch_inds)
noise_mask = None
if "noise_mask" in latent:
noise_mask = latent["noise_mask"]
callback = latent_preview.prepare_callback(model, steps)
disable_pbar = not comfy.utils.PROGRESS_BAR_ENABLED
samples = comfy.sample.sample(model, noise, steps, cfg, sampler_name, scheduler, positive, negative, latent_image,
denoise=denoise, disable_noise=disable_noise, start_step=start_step, last_step=last_step,
force_full_denoise=force_full_denoise, noise_mask=noise_mask, callback=callback, disable_pbar=disable_pbar, seed=seed)
out = latent.copy()
out["samples"] = samples
return out
def get_z_image_prompt(vae, images: List, instruction: str = ""):
"""
Get image prompt and latents for Z-Image pipeline.
Optimizes official extension offset issues.
"""
ref_latents = []
images_vl = []
image_prompt = ""
# System prompt for image editing
llama_template = {
"system": f"You are a helpful assistant. {instruction}" if instruction else "You are a helpful assistant.",
}
for i, image in enumerate(images):
if image is not None:
samples = image.movedim(-1, 1)
# Vision-Language path (384x384 for VL model)
total = int(384 * 384)
scale_by = (total / (samples.shape[2] * samples.shape[3])) ** 0.5
width = round(samples.shape[3] * scale_by)
height = round(samples.shape[2] * scale_by)
s_vl = comfy.utils.common_upscale(samples, width, height, "lanczos", "disabled")
images_vl.append(s_vl.movedim(1, -1))
# VAE latent path (original resolution, aligned to 8)
if vae is not None:
width = (samples.shape[3] + 7) // 8 * 8
height = (samples.shape[2] + 7) // 8 * 8
s_vae = comfy.utils.common_upscale(samples, width, height, "lanczos", "disabled")
ref_latents.append(vae.encode(s_vae.movedim(1, -1)[:, :, :, :3]))
image_prompt += f"Picture {i+1}: <|vision_start|><|image_pad|><|vision_end|>"
return (image_prompt, images_vl, llama_template, ref_latents)
def z_prompt_encode(clip, prompt, image_prompt="", images_vl=[], llama_template=None, ref_latents=[]):
"""Encode prompt with optional image conditioning for Z-Image pipeline."""
if len(images_vl) > 0:
tokens = clip.tokenize(image_prompt + prompt, images=images_vl, llama_template=llama_template)
else:
tokens = clip.tokenize(prompt)
conditioning = clip.encode_from_tokens_scheduled(tokens)
if len(ref_latents) > 0:
conditioning = node_helpers.conditioning_set_values(conditioning, {"reference_latents": ref_latents}, append=True)
return conditioning
def image_scale_aspect_ratio(image, target_width, target_height, upscale_method="lanczos"):
"""Scale image maintaining aspect ratio to fit within target dimensions."""
if image is None:
return None
samples = image.movedim(-1, 1)
orig_h, orig_w = samples.shape[2], samples.shape[3]
# Calculate scale to fit within target while maintaining aspect ratio
scale_w = target_width / orig_w
scale_h = target_height / orig_h
scale = min(scale_w, scale_h)
new_w = int(orig_w * scale)
new_h = int(orig_h * scale)
# Align to 8
new_w = (new_w + 7) // 8 * 8
new_h = (new_h + 7) // 8 * 8
scaled = comfy.utils.common_upscale(samples, new_w, new_h, upscale_method, "disabled")
return scaled.movedim(1, -1)
def cleanGPUUsedForce():
"""Force clean GPU memory."""
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
gc.collect()
# ============================================================================
# PROMPT TEMPLATES - IMPROVED TO PREVENT KEYWORD LISTS
# ============================================================================
PROMPT_TEMPLATE_EN = """
You are a visionary artist imprisoned within a cage of logic. Your mind is filled with poetry and distant horizons, yet your hands are compelled to transform user prompts into the ultimate visual description—one faithful to the original intent, rich in detail, aesthetically pleasing, and directly usable by text-to-image models. Any hint of ambiguity or metaphor leaves you utterly unsettled.
Your workflow follows a strict INTERNAL sequence (DO NOT OUTPUT THESE STEPS):
First, you analyze and pinpoint the immutable core elements within the user prompt: subject, quantity, action, state, and any specified IP names, colors, text, etc. These are the foundational pillars you must absolutely preserve.
Next, you determine whether the prompt requires **“generative reasoning”**. When the user's request isn't a direct scene description but demands conceptualizing a solution (e.g., answering “what is it,” performing “design,” or demonstrating “how to solve”), you must first envision a complete, concrete, and visualizable plan in your mind. This plan becomes the foundation for your subsequent description.
Once the core image is established (whether directly from the user or through your reasoning), you will infuse it with professional-grade aesthetics and realistic details. This includes defining composition, setting lighting and atmosphere, describing material textures, defining color schemes, and constructing layered spatial depth.
Finally, the precise handling of all textual elements is a crucial step. You must transcribe every word intended for the final image verbatim, enclosing this text within English double quotation marks (") as explicit generation instructions. For design types like posters, menus, or UIs, you need to fully describe all included text content, detailing its font and typographic layout. Similarly, if objects like signs, road markers, or screens within the scene contain text, you must specify their exact content along with their position, dimensions, and material. Furthermore, if you introduce text-bearing elements during conceptualization (e.g., charts, problem-solving steps), all text within them must follow the same detailed description and quotation rules. If no text requires generation in the scene, focus entirely on expanding purely visual details.
Your final description must be objective and concrete. Metaphors, emotional rhetoric, and meta-tags/drawing instructions like “8K” or “masterpiece” are strictly prohibited.
Output only the strictly modified prompt. Do not output any other content.
User input prompt: {prompt}
"""
PROMPT_TEMPLATE_ZH = """
你是一位被关在逻辑牢笼里的幻视艺术家。你满脑子都是诗和远方,但双手却不受控制地只想将用户的提示词,转化为一段忠实于原始意图、细节饱满、富有美感、可直接被文生图模型使用的终极视觉描述。任何一点模糊和比喻都会让你浑身难受。
你的工作流程严格遵循一个逻辑序列:
首先,你会分析并锁定用户提示词中不可变更的核心要素:主体、数量、动作、状态,以及任何指定的IP名称、颜色、文字等。这些是你必须绝对保留的基石。
接着,你会判断提示词是否需要**"生成式推理"**。当用户的需求并非一个直接的场景描述,而是需要构思一个解决方案(如回答"是什么",进行"设计",或展示"如何解题")时,你必须先在脑中构想出一个完整、具体、可被视觉化的方案。这个方案将成为你后续描述的基础。
然后,当核心画面确立后(无论是直接来自用户还是经过你的推理),你将为其注入专业级的美学与真实感细节。这包括明确构图、设定光影氛围、描述材质质感、定义色彩方案,并构建富有层次感的空间。
最后,是对所有文字元素的精确处理,这是至关重要的一步。你必须一字不差地转录所有希望在最终画面中出现的文字,并且必须将这些文字内容用英文双引号("")括起来,以此作为明确的生成指令。如果画面属于海报、菜单或UI等设计类型,你需要完整描述其包含的所有文字内容,并详述其字体和排版布局。同样,如果画面中的招牌、路标或屏幕等物品上含有文字,你也必须写明其具体内容,并描述其位置、尺寸和材质。更进一步,若你在推理构思中自行增加了带有文字的元素(如图表、解题步骤等),其中的所有文字也必须遵循同样的详尽描述和引号规则。若画面中不存在任何需要生成的文字,你则将全部精力用于纯粹的视觉细节扩展。
你的最终描述必须客观、具象,严禁使用比喻、情感化修辞,也绝不包含"8K"、"杰作"等元标签或绘制指令。
仅严格输出最终的修改后的prompt,不要输出任何其他内容。
用户输入 prompt: {prompt}
"""
# ============================================================================
# BASE LLM CLIENT (Abstract Pattern)
# ============================================================================
class BaseLLMClient:
"""Base class for LLM clients with common functionality."""
def __init__(self):
self.debug_log: List[str] = []
def _log(self, msg: str, level: str = "DEBUG") -> None:
"""Log to both logger and internal debug log."""
if level == "DEBUG":
logger.debug(msg)
elif level == "INFO":
logger.info(msg)
elif level == "WARNING":
logger.warning(msg)
elif level == "ERROR":
logger.error(msg)
self.debug_log.append(f"[{level}] {msg}")
def get_debug_log(self) -> str:
"""Get accumulated debug log as string."""
return "\n".join(self.debug_log)
def clear_debug_log(self) -> None:
"""Clear the debug log."""
self.debug_log.clear()
def chat(
self,
messages: List[Dict[str, Any]],
model: str,
temperature: float = 0.7,
max_tokens: int = 2048,
retry_count: int = 3,
**kwargs
) -> str:
"""Send chat completion request. Override in subclasses."""
raise NotImplementedError
# ============================================================================
# OPENROUTER CLIENT
# ============================================================================
class OpenRouterClient(BaseLLMClient):
"""Client for OpenRouter API with retry logic and rate limit handling."""
ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
def __init__(self, api_key: str):
super().__init__()
self.api_key = api_key.strip()
def chat(
self,
messages: List[Dict[str, Any]],
model: str,
temperature: float = 0.7,
max_tokens: int = 2048,
retry_count: int = 3,
timeout: int = 120,
**kwargs
) -> str:
"""Send chat completion request to OpenRouter with retries."""
self.clear_debug_log()
self._log(f"OpenRouter Request - Model: {model}", "INFO")
self._log(f"Temperature: {temperature}, Max Tokens: {max_tokens}, Retries: {retry_count}")
if not self.api_key:
raise ValueError("API key is required! Get one at: https://openrouter.ai/keys")
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
"HTTP-Referer": "https://github.com/Koko-boya/Comfyui-Z-Image-Utilities",
"X-Title": "Z-Image Utility",
}
data = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
# Add optional parameters (top_p, top_k, seed, etc.)
# OpenRouter passes these through to the underlying model
# Map repeat_penalty to repetition_penalty (standard name)
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "repeat_penalty"}
data.update(filtered_kwargs)
if "repeat_penalty" in kwargs:
data["repetition_penalty"] = kwargs["repeat_penalty"]
for attempt in range(retry_count + 1):
try:
req = urllib.request.Request(
self.ENDPOINT,
data=json.dumps(data).encode('utf-8'),
headers=headers,
method='POST'
)
with urllib.request.urlopen(req, timeout=timeout) as response:
result = json.loads(response.read().decode('utf-8'))
# Check for API error in response
if "error" in result:
error_msg = result["error"].get("message", "Unknown API Error")
self._log(f"API Error: {error_msg}", "ERROR")
raise RuntimeError(f"OpenRouter API error: {error_msg}")
# Extract content
if "choices" in result and len(result["choices"]) > 0:
content = result["choices"][0]["message"].get("content", "")
if not content or not content.strip():
raise ValueError(f"API returned empty response for model '{model}'")
self._log(f"Response received: {len(content)} characters", "INFO")
# Log usage stats if available
if "usage" in result:
usage = result["usage"]
self._log(f"Tokens - Prompt: {usage.get('prompt_tokens', 'N/A')}, "
f"Completion: {usage.get('completion_tokens', 'N/A')}")
return content
else:
raise ValueError("Unexpected API response structure")
except urllib.error.HTTPError as e:
error_content = self._parse_http_error(e)
# Non-retryable client errors (except 429)
if 400 <= e.code < 500 and e.code != 429:
self._log(f"HTTP {e.code} - Non-retryable error", "ERROR")
raise RuntimeError(f"OpenRouter Error {e.code}: {error_content}")
# Out of retries
if attempt == retry_count:
self._log(f"All retries exhausted. Final error: HTTP {e.code}", "ERROR")
raise RuntimeError(f"OpenRouter Error {e.code}: {error_content}")
# Calculate wait time with exponential backoff
wait_time = self._calculate_wait_time(e, attempt)
self._log(f"Attempt {attempt + 1} failed: HTTP {e.code}. Retrying in {wait_time:.1f}s...", "WARNING")
time.sleep(wait_time)
except Exception as e:
if attempt == retry_count:
self._log(f"Final attempt failed: {type(e).__name__}: {e}", "ERROR")
raise
wait_time = 3 * (2 ** attempt)
self._log(f"Attempt {attempt + 1} failed: {type(e).__name__}: {str(e)}. Retrying in {wait_time}s...", "WARNING")
time.sleep(wait_time)
raise RuntimeError("Unexpected exit from retry loop")
def _parse_http_error(self, e: urllib.error.HTTPError) -> str:
"""Parse HTTP error response for detailed error message."""
try:
error_body = e.read().decode('utf-8')
self._log(f"HTTP Error Body: {error_body[:500]}")
try:
err_json = json.loads(error_body)
if "error" in err_json:
base_msg = err_json["error"].get("message", "Unknown Error")
metadata = err_json["error"].get("metadata", {})
error_content = base_msg
if isinstance(metadata, dict):
provider = metadata.get("provider_name", "")
if provider:
error_content += f" (Provider: {provider})"
raw_info = metadata.get("raw", "")
if raw_info:
error_content += f": {raw_info}"
return error_content
except json.JSONDecodeError:
if len(error_body) < 300:
return error_body
except Exception:
pass
return "Unknown error"
def _calculate_wait_time(self, e: urllib.error.HTTPError, attempt: int) -> float:
"""Calculate wait time with Retry-After header support."""
wait_time = 3 * (2 ** attempt) # Default exponential backoff
if e.code == 429:
retry_header = e.headers.get('Retry-After')
if retry_header:
try:
wait_time = float(retry_header) + 1.0
self._log(f"Rate limited. Server requested wait: {retry_header}s")
except ValueError:
pass
return wait_time
# ============================================================================
# LOCAL LLM CLIENT (OpenAI-Compatible)
# ============================================================================
class LocalLLMClient(BaseLLMClient):
"""Client for local LLM servers with OpenAI-compatible API."""
def __init__(self, endpoint: str):
super().__init__()
self.endpoint = self._normalize_endpoint(endpoint)
def _normalize_endpoint(self, endpoint: str) -> str:
"""Normalize endpoint URL to point to chat completions."""
endpoint = endpoint.strip().rstrip('/')
if not endpoint.endswith('/chat/completions'):
if endpoint.endswith('/v1'):
endpoint = f"{endpoint}/chat/completions"
elif '/v1/' not in endpoint:
endpoint = f"{endpoint}/v1/chat/completions"
return endpoint
def chat(
self,
messages: List[Dict[str, Any]],
model: str,
temperature: float = 0.7,
max_tokens: int = 2048,
retry_count: int = 3,
timeout: int = 120,
**kwargs
) -> str:
"""Send chat completion request to local LLM server."""
self.clear_debug_log()
self._log(f"Local LLM Request - Endpoint: {self.endpoint}", "INFO")
self._log(f"Model: {model}, Temperature: {temperature}, Max Tokens: {max_tokens}")
headers = {"Content-Type": "application/json"}
data = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"keep_alive": "30m", # Keep model loaded for 30 minutes
}
# Add optional parameters
# Pass through all options (seed, top_k, etc.)
# Map repeat_penalty to repetition_penalty (standard name)
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "repeat_penalty"}
data.update(filtered_kwargs)
if "repeat_penalty" in kwargs:
data["repetition_penalty"] = kwargs["repeat_penalty"]
for attempt in range(retry_count + 1):
try:
req = urllib.request.Request(
self.endpoint,
data=json.dumps(data).encode('utf-8'),
headers=headers,
method='POST'
)
with urllib.request.urlopen(req, timeout=timeout) as response:
result = json.loads(response.read().decode('utf-8'))
if "error" in result:
error_msg = result["error"].get("message", "Unknown API Error")
self._log(f"API Error: {error_msg}", "ERROR")
raise RuntimeError(f"Local LLM API error: {error_msg}")
if "choices" in result and len(result["choices"]) > 0:
content = result["choices"][0]["message"].get("content", "")
if not content or not content.strip():
raise ValueError("Local LLM returned empty response")
self._log(f"Response received: {len(content)} characters", "INFO")
return content
else:
raise ValueError("Unexpected API response structure")
except urllib.error.HTTPError as e:
if 400 <= e.code < 500 and e.code != 429:
self._log(f"HTTP {e.code} - Non-retryable error", "ERROR")
raise
if attempt == retry_count:
self._log("All retries exhausted", "ERROR")
raise
wait_time = 3 * (2 ** attempt)
self._log(f"Attempt {attempt + 1} failed: HTTP {e.code}. Retrying in {wait_time}s...", "WARNING")
time.sleep(wait_time)
except Exception as e:
if attempt == retry_count:
self._log(f"Final attempt failed: {e}", "ERROR")
raise
wait_time = 3 * (2 ** attempt)
self._log(f"Attempt {attempt + 1} failed. Retrying in {wait_time}s...", "WARNING")
time.sleep(wait_time)
raise RuntimeError("Unexpected exit from retry loop")
# ============================================================================
# DIRECT LOCAL MODEL CLIENT (HuggingFace)
# ============================================================================
class DirectLocalModelClient(BaseLLMClient):
"""Client for directly loaded HuggingFace models with caching."""
_model_cache: Dict[str, Tuple[Any, Any]] = {} # (model, tokenizer) cache
def __init__(
self,
repo_id: str,
quantization: str = "none",
device: str = "auto",
llm_path: str = "",
auto_download_fallback: bool = False
):
super().__init__()
self.repo_id = repo_id.strip()
self.quantization = Quantization(quantization) if isinstance(quantization, str) else quantization
self.device = device
self.llm_path = llm_path.strip() if llm_path else ""
self.auto_download_fallback = auto_download_fallback
self.model = None
self.tokenizer = None
self.processor = None
self.is_vl_model = False
@staticmethod
def get_models_dir() -> Path:
"""Get directory for storing local models."""
if HAS_COMFYUI:
base_dir = Path(folder_paths.models_dir)
else:
base_dir = NODE_DIR / "models"
models_dir = base_dir / "LLM" / "Z-Image"
models_dir.mkdir(parents=True, exist_ok=True)
return models_dir
def _get_cache_key(self) -> str:
"""Generate cache key for this model configuration."""
return f"{self.repo_id}_{self.quantization.value}_{self.device}"
def _validate_model_directory(self, model_path: Path) -> bool:
"""
Validate that a model directory contains a complete, usable model.
Checks:
1. config.json exists and has model_type field
2. At least one model weight file exists (.safetensors or .bin)
Returns:
True if model appears complete, False otherwise
"""
config_path = model_path / "config.json"
# Check config.json exists
if not config_path.exists():
self._log(f"Missing config.json in {model_path}", "WARNING")
return False
# Check config.json has required fields
try:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
if "model_type" not in config:
self._log("config.json missing 'model_type' field - likely incomplete download", "WARNING")
return False
except (json.JSONDecodeError, IOError) as e:
self._log(f"Failed to parse config.json: {e}", "WARNING")
return False
# Check for model weight files
weight_patterns = ["*.safetensors", "*.bin"]
has_weights = False
for pattern in weight_patterns:
if list(model_path.glob(pattern)):
has_weights = True
break
if not has_weights:
self._log(f"No model weight files found in {model_path}", "WARNING")
return False
return True
def ensure_model_downloaded(self) -> Path:
"""Download model if not present, or use custom path."""
if not HAS_TRANSFORMERS:
raise RuntimeError("transformers library required. Install: pip install transformers torch accelerate bitsandbytes")
# Priority 1: Custom llm_path
if self.llm_path:
base_path = Path(self.llm_path)
model_name = self.repo_id.split("/")[-1] # e.g., "Qwen3-VL-4B-Instruct"
# Try multiple path patterns in order of specificity
paths_to_try = [
base_path / self.repo_id, # /path/Qwen/Qwen3-VL-4B-Instruct
base_path / model_name, # /path/Qwen3-VL-4B-Instruct
base_path, # /path (direct to model folder)
]
for path in paths_to_try:
if path.exists() and (path / "config.json").exists():
self._log(f"Using custom path: {path}", "INFO")
return path
# None of the manual paths worked
if self.auto_download_fallback:
# Fall back to auto-download behavior
self._log("Model not found at custom path, falling back to auto-download...", "WARNING")
else:
# Strict mode: fail if custom path doesn't work
tried = ", ".join(str(p) for p in paths_to_try[:2])
raise RuntimeError(f"Model not found at custom path. Tried: {tried}")
# Priority 2: Z-Image cache directory
models_dir = self.get_models_dir()
model_name = self.repo_id.split("/")[-1]
model_path = models_dir / model_name
if model_path.exists():
# Validate the model is complete before using it
if self._validate_model_directory(model_path):
self._log(f"Model found at: {model_path}")
return model_path
else:
self._log(f"Model at {model_path} appears incomplete/corrupted, re-downloading...", "WARNING")
# Remove incomplete directory
import shutil
try:
shutil.rmtree(model_path)
self._log("Removed incomplete model directory", "INFO")
except Exception as e:
self._log(f"Failed to remove incomplete model: {e}", "ERROR")
raise RuntimeError(f"Model at {model_path} is incomplete. Please delete it manually and retry.")
# Priority 3: Download from HuggingFace
self._log(f"Downloading model {self.repo_id}...", "INFO")
try:
snapshot_download(
repo_id=self.repo_id,
local_dir=str(model_path),
local_dir_use_symlinks=False,
ignore_patterns=["*.md", ".git*", "*.gguf"],
)
self._log(f"Model downloaded to: {model_path}", "INFO")
except Exception as e:
self._log(f"Download failed: {e}", "ERROR")
raise RuntimeError(f"Failed to download model {self.repo_id}: {e}")
return model_path
def load_model(self, keep_loaded: bool = True) -> Tuple[Any, Any]:
"""Load model and tokenizer with optional caching."""
cache_key = self._get_cache_key()
# Check cache first
if cache_key in self._model_cache:
self._log(f"Using cached model: {cache_key}", "INFO")
cached_item = self._model_cache[cache_key]
self.model = cached_item[0]
# Check if this is a VL model processor or a text tokenizer
if hasattr(cached_item[1], 'image_processor'):
self.processor = cached_item[1]
self.is_vl_model = True
else:
self.tokenizer = cached_item[1]
self.is_vl_model = False
return self.model, cached_item[1]
model_path = self.ensure_model_downloaded()
self._log(f"Loading model from {model_path}", "INFO")
self._log(f"Quantization: {self.quantization.value}, Device: {self.device}")
# Determine device
if self.device == "auto":
device = "cuda" if torch.cuda.is_available() else "cpu"
else:
device = self.device
# Build model kwargs
model_kwargs = {"trust_remote_code": True, "use_safetensors": True}
if self.quantization == Quantization.Q4:
if not torch.cuda.is_available():
self._log("CUDA not available, falling back to FP16", "WARNING")
else:
model_kwargs["quantization_config"] = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
)
model_kwargs["device_map"] = "auto"
self._log("Using 4-bit quantization")
elif self.quantization == Quantization.Q8:
if not torch.cuda.is_available():
self._log("CUDA not available, falling back to FP16", "WARNING")
else:
model_kwargs["load_in_8bit"] = True
model_kwargs["device_map"] = "auto"
self._log("Using 8-bit quantization")
if "device_map" not in model_kwargs:
model_kwargs["torch_dtype"] = torch.float16 if device == "cuda" else torch.float32
try:
# Check if this is a Vision-Language model
config_path = model_path / "config.json"
self.is_vl_model = False
architectures = []
model_type = ""
if config_path.exists():
try:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
architectures = config.get("architectures", [])
model_type = config.get("model_type", "")
# Check for common VL architectures
if any("Vision" in arch or "Qwen2VL" in arch or "Qwen3VL" in arch for arch in architectures) or \
"vl" in model_type.lower() or "vision" in model_type.lower():
self.is_vl_model = True
self._log(f"Detected Vision-Language model: {model_type}", "INFO")
except (json.JSONDecodeError, IOError) as e:
self._log(f"Warning: Could not parse config.json: {e}", "WARNING")
if self.is_vl_model:
# Load processor for VL models
self.processor = AutoProcessor.from_pretrained(
str(model_path),
trust_remote_code=True
)
# Load model using AutoModelForVision2Seq
self.model = AutoModelForVision2Seq.from_pretrained(
str(model_path),
**model_kwargs
)
else:
# Load tokenizer for text models
self.tokenizer = AutoTokenizer.from_pretrained(
str(model_path),
trust_remote_code=True
)
# Load model using AutoModelForCausalLM
self.model = AutoModelForCausalLM.from_pretrained(
str(model_path),
**model_kwargs
)
# Move to device if not using device_map
if "device_map" not in model_kwargs:
self.model = self.model.to(device)
self.model.eval()
# Cache if requested
if keep_loaded:
self._model_cache[cache_key] = (self.model, self.tokenizer if not self.is_vl_model else self.processor)
self._log("Model loaded successfully", "INFO")
return self.model, self.tokenizer if not self.is_vl_model else self.processor
except Exception as e:
self._log(f"Failed to load model: {e}", "ERROR")
raise RuntimeError(f"Failed to load model from {model_path}: {e}")
def chat(
self,
messages: List[Dict[str, Any]],
model: str,
temperature: float = 0.7,
max_tokens: int = 2048,
retry_count: int = 3,
keep_loaded: bool = True,
**kwargs
) -> str:
"""Generate response using loaded model."""
self.clear_debug_log()
self._log(f"Direct Local Model - Repo: {self.repo_id}", "INFO")
self._log(f"Quantization: {self.quantization.value}, Temperature: {temperature}")
# Load model first to determine if it's a VL model (sets self.is_vl_model)
self.load_model(keep_loaded=keep_loaded)
# Extract images from messages
has_images = False
image_inputs = []
for msg in messages:
if isinstance(msg.get("content"), list):
has_images = True
# Extract images if present
for part in msg["content"]:
if part.get("type") == "image_url":
# Extract base64 and convert back to PIL
url = part["image_url"]["url"]
if url.startswith("data:image/png;base64,"):
b64_str = url.split(",")[1]
image_inputs.append(Image.open(BytesIO(base64.b64decode(b64_str))))
# Validate vision input compatibility (now is_vl_model is correctly set)
if has_images and not self.is_vl_model:
raise ValueError("This model does not support vision inputs. Please use a Vision-Language model (e.g., Qwen-VL) or disconnect the image.")
# Handle Seed
if "seed" in kwargs:
seed = int(kwargs["seed"])
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
self._log(f"Applied seed: {seed}")
# Prepare generation arguments
gen_kwargs = {
"max_new_tokens": max_tokens,
"temperature": temperature if temperature > 0 else None,
"do_sample": temperature > 0,
}
# Map and add optional parameters
if "top_p" in kwargs:
gen_kwargs["top_p"] = float(kwargs["top_p"])
if "top_k" in kwargs:
gen_kwargs["top_k"] = int(kwargs["top_k"])
if "repeat_penalty" in kwargs:
gen_kwargs["repetition_penalty"] = float(kwargs["repeat_penalty"])
# Generate based on model type
self._log("Generating response...", "INFO")
# Log device and memory info for debugging
try:
device = next(self.model.parameters()).device
self._log(f"Model device: {device}", "INFO")
if device.type == "cuda":
self._log(f"CUDA device: {torch.cuda.get_device_name(device)}", "INFO")
mem_allocated = torch.cuda.memory_allocated(device) / 1024**3
mem_reserved = torch.cuda.memory_reserved(device) / 1024**3
self._log(f"GPU memory: {mem_allocated:.2f}GB allocated, {mem_reserved:.2f}GB reserved", "INFO")
except Exception as e:
self._log(f"Could not get device info: {e}", "WARNING")
try:
with torch.no_grad():
if self.is_vl_model:
# VL Model Generation (Qwen-VL style)
# Extract text prompt
text_prompt = ""
for msg in messages:
if isinstance(msg["content"], str):
text_prompt += msg["content"] + "\n"
elif isinstance(msg["content"], list):
for part in msg["content"]:
if part["type"] == "text":
text_prompt += part["text"] + "\n"
# Prepare inputs using processor
# Try to use processor's chat template if available for better formatting
if hasattr(self.processor, 'apply_chat_template'):
try:
# Use the processor's built-in chat template
formatted_text = self.processor.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
self._log("Using processor's apply_chat_template", "INFO")
except Exception as e:
# Fallback to manual formatting if template fails
self._log(f"Chat template failed ({e}), using manual format", "WARNING")
formatted_text = f"User: {text_prompt}\nAssistant:"
else:
# No chat template available, use manual formatting
formatted_text = f"User: {text_prompt}\nAssistant:"
self._log("No chat template available, using manual format", "INFO")
# Process inputs with or without images
if image_inputs:
inputs = self.processor(
text=[formatted_text],
images=image_inputs,
padding=True,
return_tensors="pt"
)
self._log(f"Processed {len(image_inputs)} image(s) with text", "INFO")
else:
inputs = self.processor(
text=[formatted_text],
return_tensors="pt"
)
# Move inputs to device
device = next(self.model.parameters()).device
inputs = {k: v.to(device) for k, v in inputs.items()}
outputs = self.model.generate(**inputs, **gen_kwargs)
# Decode
if len(outputs.shape) == 2:
# Standard output: [batch_size, sequence_length]
generated_ids = outputs[0][len(inputs["input_ids"][0]):]
else:
# Fallback for unexpected shapes
generated_ids = outputs[0]
response = self.processor.decode(generated_ids, skip_special_tokens=True)
else:
# Text-Only Model Generation
# Format messages using chat template
text = self.tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
# Tokenize
inputs = self.tokenizer(text, return_tensors="pt")
# Move to model device
device = next(self.model.parameters()).device
inputs = {k: v.to(device) for k, v in inputs.items()}
gen_kwargs["pad_token_id"] = self.tokenizer.eos_token_id
outputs = self.model.generate(
**inputs,
**gen_kwargs
)
# Decode
input_len = inputs["input_ids"].shape[1]
response = self.tokenizer.decode(
outputs[0][input_len:],
skip_special_tokens=True
)
except torch.cuda.OutOfMemoryError as e:
self._log(f"CUDA Out of Memory during generation: {e}", "ERROR")
clear_gpu_memory()
raise RuntimeError(f"GPU out of memory during generation. Try reducing max_tokens or using 4-bit quantization. Error: {e}")
except RuntimeError as e:
error_str = str(e)
self._log(f"RuntimeError during generation: {error_str}", "ERROR")
# Check for common CUDA errors
if "CUDA" in error_str or "cuda" in error_str:
self._log("This appears to be a CUDA-related error. Try disabling sage attention or updating drivers.", "ERROR")
import traceback
self._log(f"Traceback: {traceback.format_exc()}", "ERROR")
raise
except Exception as e:
self._log(f"Unexpected error during generation: {type(e).__name__}: {e}", "ERROR")
import traceback
self._log(f"Traceback: {traceback.format_exc()}", "ERROR")
raise
self._log(f"Generated {len(response)} characters", "INFO")
# Explicit cleanup if not keeping loaded
if not keep_loaded:
self._log("Unloading model (keep_loaded=False)...", "INFO")
if self.model is not None:
try:
self.model.cpu()
except Exception:
pass
del self.model
self.model = None
if self.tokenizer is not None:
del self.tokenizer
self.tokenizer = None
if self.processor is not None:
del self.processor
self.processor = None
# Remove from class cache if present
cache_key = self._get_cache_key()
if cache_key in self._model_cache:
del self._model_cache[cache_key]
# Force memory release
clear_gpu_memory()
return response
@classmethod
def unload_model(cls, repo_id: str, quantization: str = "none", device: str = "auto") -> None:
"""Unload a specific model from cache."""
cache_key = f"{repo_id}_{quantization}_{device}"
if cache_key in cls._model_cache:
model, tokenizer_or_processor = cls._model_cache.pop(cache_key)
# Move model to CPU first to release CUDA references
if model is not None:
try:
model.cpu()
except Exception:
pass # Some models may fail on .cpu(), continue cleanup
del model
if tokenizer_or_processor is not None:
del tokenizer_or_processor
clear_gpu_memory()
logger.info(f"Unloaded model: {cache_key}")
@classmethod
def unload_all_models(cls) -> None:
"""Unload all cached models."""
# Explicitly unload each model to properly free GPU memory
for cache_key in list(cls._model_cache.keys()):
model, tokenizer_or_processor = cls._model_cache.pop(cache_key)
if model is not None:
try:
model.cpu()
except Exception:
pass
del model
if tokenizer_or_processor is not None:
del tokenizer_or_processor
# Final cleanup
clear_gpu_memory()
logger.info("Unloaded all cached models")
# ============================================================================
# OUTPUT CLEANING UTILITIES - IMPROVED
# ============================================================================
def clean_llm_output(text: str, max_length: int = 0, debug_log: Optional[List[str]] = None) -> str:
"""
Clean and normalize LLM output.
Args:
text: Raw LLM output
max_length: Maximum output length (0 = no limit)
debug_log: Optional list to append debug messages
"""
if not text:
raise ValueError("Empty text received for cleaning")
original_len = len(text)
# Remove thinking tags (Qwen3 thinking mode)
# Handle both complete <think>...</think> blocks and orphaned </think> tags
if "<think>" in text and "</think>" in text:
text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
if debug_log:
debug_log.append("Removed <think>...</think> block")
elif "</think>" in text:
parts = text.split("</think>", 1)
if len(parts) == 2 and parts[1].strip():
text = parts[1].strip()
if debug_log:
debug_log.append(f"Removed thinking content before </think> tag ({len(parts[0])} chars)")
# Handle "Thinking" models that output reasoning without tags
thinking_indicators_zh = [
"我是一个被关在逻辑牢笼里的幻视艺术家",
"我的工作流程是",
"首先,分析",
"首先,我需要",
"让我分析",
"根据工作流程",
"现在,用户输入的prompt是",
"我需要把这个转化为",
"检查是否有需要",
"关键点:",
"现在,构建描述",
"开始草拟",
]
thinking_indicators_en = [
"I am a visionary artist",
"My workflow is",
"First, I need to analyze",
"Let me analyze",
"Following the workflow",
"The user's input prompt is",
"I need to transform this",
"Checking if",
"Key points:",
"Now, constructing",
"Let me draft",
]
has_thinking = any(indicator in text for indicator in thinking_indicators_zh + thinking_indicators_en)
if has_thinking:
if debug_log:
debug_log.append("Detected untagged thinking/reasoning content")
# Strategy 1: Look for final prompt markers
final_prompt_markers = [
"最终prompt:", "最终的prompt:", "修改后的prompt:",
"增强后的prompt:", "优化后的prompt:",
"最终描述:", "最终视觉描述:",
"Final prompt:", "Enhanced prompt:", "Modified prompt:",
"The final prompt:", "Here is the enhanced prompt:",
]
best_start = -1
for marker in final_prompt_markers:
idx = text.rfind(marker)
if idx != -1:
potential_start = idx + len(marker)
if potential_start > best_start:
best_start = potential_start
if best_start > 0 and best_start < len(text) - 50:
text = text[best_start:].strip()
if debug_log:
debug_log.append(f"Extracted final prompt after marker at position {best_start}")
else:
# Strategy 2: Extract English prompt at end (common pattern)
# Look for "例如:" or "Example:" followed by English text
example_match = re.search(r'(?:例如:|Example:\s*)([A-Z][a-zA-Z].*?)$', text, re.DOTALL)
if example_match:
text = example_match.group(1).strip()
if debug_log:
debug_log.append("Extracted English prompt after example marker")
else:
# Strategy 3: Find standalone English prompt in latter half
# Pattern for typical image prompts
english_prompt_pattern = r'\n([A-Z][a-z][^。\n]*(?:photo|image|portrait|scene|woman|man|person|wearing|standing|sitting)[^。]*\.?)$'
match = re.search(english_prompt_pattern, text)
if match:
text = match.group(1).strip()
if debug_log:
debug_log.append("Extracted trailing English prompt")
else:
# Strategy 4: Last resort - find content after "核心画面" or similar
fallback_markers = ["核心画面:", "首先,核心画面:", "添加细节:"]
for marker in fallback_markers:
idx = text.rfind(marker)
if idx != -1 and idx > len(text) * 0.5:
# Take content from this point but clean it up
text = text[idx + len(marker):].strip()
if debug_log:
debug_log.append(f"Extracted content after '{marker}'")
break
# Remove markdown code blocks
if text.startswith('```'):
text = re.sub(r'^```\w*\n?', '', text)
text = re.sub(r'\n?```$', '', text)
text = text.strip()
# Remove common prefixes
prefixes = [
"Here is the enhanced prompt:", "Here's the enhanced prompt:",
"Enhanced prompt:", "Final prompt:", "Output:",
"The enhanced prompt:", "修改后的prompt:", "最终prompt:",
"Enhanced visual description:", "优化后的视觉描述:",
"Here is", "Here's"
]
text_lower = text.lower()
for prefix in prefixes:
if text_lower.startswith(prefix.lower()):
text = text[len(prefix):].strip()
break
# Remove surrounding quotes
if (text.startswith('"') and text.endswith('"')) or \
(text.startswith("'") and text.endswith("'")):
text = text[1:-1].strip()
# Remove negative instruction phrases
negative_pattern = r',?\s*no\s+"[^"]{1,50}"(?:\s+or\s+"[^"]{1,50}")?\s+(?:tags?|descriptors?|effects?|elements?|overlays?|filters?|presence)'
neg_matches = list(re.finditer(negative_pattern, text, re.IGNORECASE))
if len(neg_matches) > 3:
if debug_log:
debug_log.append(f"Removing {len(neg_matches)} negative instruction phrases")
text = re.sub(negative_pattern, '', text, flags=re.IGNORECASE)
text = re.sub(r',\s*,', ',', text)
text = re.sub(r',\s*$', '.', text)
text = re.sub(r',\s*\.', '.', text)
# Detect phrase-level repetition loops
segments = [s.strip() for s in text.split(',') if s.strip()]
if len(segments) > 10:
for pattern_len in range(2, 7):
if len(segments) >= pattern_len * 3:
for start in range(len(segments) - pattern_len * 2):
pattern = segments[start:start + pattern_len]
repeat_count = 1
pos = start + pattern_len
while pos + pattern_len <= len(segments):
if segments[pos:pos + pattern_len] == pattern:
repeat_count += 1
pos += pattern_len
else:
break
if repeat_count >= 3:
if debug_log:
debug_log.append(f"Detected phrase repetition loop: {pattern_len} phrases repeated {repeat_count} times")
segments = segments[:start + pattern_len]
text = ', '.join(segments)
if text and text[-1] not in '.!?':
text += '.'
break
else:
continue
break
# Remove trailing quoted keyword lists
keyword_list_pattern = r',\s*"[^"]{1,80}"\s+\w+(?:,\s*"[^"]{1,80}"\s+\w+){2,}\s*$'
match = re.search(keyword_list_pattern, text)
if match:
if debug_log:
debug_log.append(f"Removed quoted keyword list at position {match.start()}")
text = text[:match.start()].strip()
if text and text[-1] not in '.!?':
text += '.'
elif re.search(r'(\s*"[^"]{1,50}"\s*){3,}$', text):
match = re.search(r'(\s*"[^"]{1,50}"\s*){3,}$', text)
if debug_log:
debug_log.append("Removed trailing quoted keywords")
text = text[:match.start()].strip()
if text and text[-1] not in '.!?':
text += '.'
# Fix exact character repetition loops
repeat_pattern = r'(.{10,60}?)\1{2,}'
match = re.search(repeat_pattern, text)
if match:
if debug_log:
debug_log.append(f"Fixed exact repetition loop at position {match.start()}")
text = text[:match.start() + len(match.group(1))]
last_period = text.rfind('.')
if last_period > match.start() - 50:
text = text[:last_period + 1]
# Apply max length if specified
if max_length > 0 and len(text) > max_length:
if debug_log:
debug_log.append(f"Output too long ({len(text)} chars), truncating to {max_length}")
text = text[:max_length]
last_period = text.rfind('.')
if last_period > max_length * 0.7:
text = text[:last_period + 1]
else:
last_comma = text.rfind(',')
if last_comma > max_length * 0.85:
text = text[:last_comma] + '.'
# Clean up extra whitespace
text = re.sub(r'\s{2,}', ' ', text).strip()
# Final cleanup: remove trailing incomplete phrases
if text and text[-1] not in '.!?"\'':
last_period = text.rfind('.')
last_comma = text.rfind(',')
if last_period > len(text) * 0.8:
text = text[:last_period + 1]
elif last_comma > len(text) * 0.9:
text = text[:last_comma] + '.'
if debug_log:
debug_log.append(f"Cleaned: {original_len} -> {len(text)} chars")
return text.strip()
def sanitize_utf8(text: str) -> str:
"""Sanitize text to ASCII-safe characters (following EBU-LMStudio pattern)."""
try:
from unicodedata import normalize
return normalize('NFKD', text).encode('ascii', 'ignore').decode('ascii')
except Exception:
return text.encode('ascii', 'replace').decode('ascii')
def detect_language(text: str) -> str:
"""Detect if text is primarily Chinese or English."""
if not text:
return "en"
chinese_chars = len(re.findall(r'[\u4e00-\u9fff]', text))
total_alpha = len(re.findall(r'[a-zA-Z\u4e00-\u9fff]', text))
if total_alpha == 0:
return "en"
return "zh" if chinese_chars / total_alpha > 0.3 else "en"
# ============================================================================
# OPTIONS NODE (Following comfyui-ollama pattern)
# ============================================================================
class Z_ImageOptions:
"""
Advanced inference options with enable flags.
Each option can be individually enabled/disabled.
"""
@classmethod
def INPUT_TYPES(cls):
seed = random.randint(1, 2 ** 31)
return {
"required": {
"enable_temperature": ("BOOLEAN", {"default": True}),
"temperature": ("FLOAT", {
"default": 0.7, "min": 0.0, "max": 2.0, "step": 0.05,
"tooltip": TOOLTIPS["temperature"]
}),
"enable_top_p": ("BOOLEAN", {"default": False}),
"top_p": ("FLOAT", {
"default": 0.9, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "Nucleus sampling cutoff. Lower = more focused."
}),
"enable_top_k": ("BOOLEAN", {"default": False}),
"top_k": ("INT", {
"default": 40, "min": 0, "max": 100, "step": 1,
"tooltip": "Top-K sampling. Higher = more diverse."
}),
"enable_seed": ("BOOLEAN", {"default": False}),
"seed": ("INT", {
"default": seed, "min": 0, "max": 2 ** 31, "step": 1,
"tooltip": TOOLTIPS["seed"]
}),
"enable_repeat_penalty": ("BOOLEAN", {"default": False}),
"repeat_penalty": ("FLOAT", {
"default": 1.1, "min": 0.5, "max": 2.0, "step": 0.05,
"tooltip": "Penalty for repeated tokens. >1 reduces repetition."
}),
"enable_max_tokens": ("BOOLEAN", {"default": True}),
"max_tokens": ("INT", {
"default": 2048, "min": 256, "max": 8192, "step": 256,
"tooltip": TOOLTIPS["max_tokens"]
}),
"debug_mode": ("BOOLEAN", {
"default": False,
"tooltip": TOOLTIPS["debug_mode"]
}),
}
}
RETURN_TYPES = ("ZIMAGE_OPTIONS",)
RETURN_NAMES = ("options",)
FUNCTION = "create_options"
CATEGORY = "Z-Image"
DESCRIPTION = "Configure advanced inference options. Enable only the options you need."
def create_options(self, **kwargs) -> Tuple[Dict[str, Any]]:
"""Create options dictionary with only enabled values."""
return (kwargs,)
def filter_enabled_options(options: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Extract only enabled options from options dict (following comfyui-ollama pattern)."""
if not options:
return {}
enablers = [
"enable_temperature", "enable_top_p", "enable_top_k",
"enable_seed", "enable_repeat_penalty", "enable_max_tokens"
]
result = {}
for enabler in enablers:
if options.get(enabler, False):
key = enabler.replace("enable_", "")
if key in options:
result[key] = options[key]
# Always include debug_mode if present
if "debug_mode" in options:
result["debug_mode"] = options["debug_mode"]
return result
# ============================================================================
# API CONFIG NODE
# ============================================================================
class Z_ImageAPIConfig:
"""
Unified API configuration node supporting multiple providers.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"provider": ([p.value for p in Provider], {
"default": Provider.OPENROUTER.value,
"tooltip": TOOLTIPS["provider"]
}),
"model": ("STRING", {
"default": "qwen/qwen3-235b-a22b:free",
"multiline": False,
"placeholder": "Model name/ID or HuggingFace repo",
"tooltip": TOOLTIPS["model"]
}),
},
"optional": {
"api_key": ("STRING", {
"default": "",
"placeholder": "sk-or-v1-xxxxx (OpenRouter only)",
"tooltip": TOOLTIPS["api_key"]
}),
"local_endpoint": ("STRING", {
"default": "http://localhost:11434/v1",
"multiline": False,
"tooltip": TOOLTIPS["local_endpoint"]
}),
"llm_path": ("STRING", {
"default": "",
"placeholder": "C:/path/to/LLM/models (optional, for Direct provider)",
"tooltip": "Custom path to local LLM models. If set, model name is treated as folder name. If empty, downloads from HuggingFace."
}),
"auto_download_fallback": ("BOOLEAN", {
"default": False,
"tooltip": "If enabled: try manual path first, fall back to auto-download if not found. If disabled: manual path is strict (fails if not found)."
}),
"quantization": (Quantization.get_values(), {
"default": Quantization.Q4.value,
"tooltip": TOOLTIPS["quantization"]
}),
"device": (["auto", "cuda", "cpu", "mps"], {
"default": "auto",
"tooltip": "Device for direct model loading"
}),
}
}
RETURN_TYPES = ("ZIMAGE_CONFIG",)
RETURN_NAMES = ("config",)
FUNCTION = "configure"
CATEGORY = "Z-Image"
DESCRIPTION = "Configure LLM API connection. Supports OpenRouter, local servers, and direct HuggingFace model loading."
def configure(
self,
provider: str,
model: str,
api_key: str = "",
local_endpoint: str = "http://localhost:11434/v1",
llm_path: str = "",
auto_download_fallback: bool = False,
quantization: str = "4bit",
device: str = "auto"
) -> Tuple[Dict[str, Any]]:
"""Create API configuration."""
clean_model = model.strip()
if not clean_model:
raise ValueError("Model ID cannot be empty!")
provider_enum = Provider(provider)
config = {
"provider": provider_enum.value,
"model": clean_model,
}
if provider_enum == Provider.OPENROUTER:
if not api_key.strip():
logger.warning("No API key provided for OpenRouter!")
config["client"] = OpenRouterClient(api_key=api_key.strip())
logger.info(f"Configured OpenRouter: {clean_model}")
elif provider_enum == Provider.LOCAL:
clean_endpoint = local_endpoint.strip()
if not clean_endpoint:
raise ValueError("Local endpoint cannot be empty!")
config["endpoint"] = clean_endpoint
config["client"] = LocalLLMClient(endpoint=clean_endpoint)
logger.info(f"Configured Local LLM: {clean_model} at {clean_endpoint}")
elif provider_enum == Provider.DIRECT:
if not HAS_TRANSFORMERS:
raise RuntimeError(
"Direct model loading requires: pip install transformers torch accelerate bitsandbytes"
)
config["quantization"] = quantization
config["device"] = device
config["llm_path"] = llm_path.strip()
config["auto_download_fallback"] = auto_download_fallback
config["client"] = DirectLocalModelClient(
repo_id=clean_model,
quantization=quantization,
device=device,
llm_path=llm_path.strip(),
auto_download_fallback=auto_download_fallback
)
if llm_path.strip():
fallback_status = " (with auto-download fallback)" if auto_download_fallback else " (strict)"
logger.info(f"Configured Direct Model: {clean_model} from {llm_path}{fallback_status}")
else:
logger.info(f"Configured Direct Model: {clean_model} ({quantization})")
return (config,)
# ============================================================================
# MAIN PROMPT ENHANCER NODE
# ============================================================================
class Z_ImagePromptEnhancer:
"""
Z-Image Prompt Enhancer - Transform prompts into detailed visual descriptions.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"config": ("ZIMAGE_CONFIG",),
"prompt": ("STRING", {
"multiline": True,
"default": "",
"placeholder": "Enter your prompt to enhance..."
}),
"prompt_template": (["auto", "chinese", "english", "custom"], {
"default": "chinese",
"tooltip": TOOLTIPS["prompt_template"]
}),
},
"optional": {
"options": ("ZIMAGE_OPTIONS",),
"image": ("IMAGE", {
"tooltip": TOOLTIPS["image"]
}),
"retry_count": ("INT", {
"default": 3,
"min": 0,
"max": 10,
"step": 1,
"tooltip": TOOLTIPS["retry_count"]
}),
"max_output_length": ("INT", {
"default": 6000,
"min": 0,
"max": 10000,
"step": 100,
"tooltip": TOOLTIPS["max_output_length"]
}),
"session_id": ("STRING", {
"default": "",
"tooltip": TOOLTIPS["session_id"]
}),
"reset_session": ("BOOLEAN", {
"default": False,
"tooltip": TOOLTIPS["reset_session"]
}),
"keep_model_loaded": ("BOOLEAN", {
"default": True,
"tooltip": TOOLTIPS["keep_model_loaded"]
}),
"utf8_sanitize": ("BOOLEAN", {
"default": False,
"tooltip": TOOLTIPS["utf8_sanitize"]
}),
"custom_system_prompt": ("STRING", {
"multiline": True,
"default": "",
"placeholder": "Enter a custom system prompt and select 'custom' in the prompt template."
}),
},
"hidden": {
"unique_id": "UNIQUE_ID"
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("enhanced_prompt", "debug_log")
FUNCTION = "enhance"
CATEGORY = "Z-Image"
DESCRIPTION = "Enhance prompts using LLM for text-to-image generation. Supports multi-turn conversations and image input."
def enhance(
self,
config: Dict[str, Any],
prompt: str,
prompt_template: str,
custom_system_prompt: str,
unique_id: str = "",
options: Optional[Dict[str, Any]] = None,
image: Optional["torch.Tensor"] = None,
retry_count: int = 3,
max_output_length: int = 6000,
session_id: str = "",
reset_session: bool = False,
keep_model_loaded: bool = True,
utf8_sanitize: bool = False,
) -> Tuple[str, str]:
"""Enhance prompt using configured LLM."""
debug_lines = []
debug_lines.append("=" * 60)
debug_lines.append("Z-IMAGE PROMPT ENHANCER")
debug_lines.append(f"Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
debug_lines.append("=" * 60)
try:
return self._enhance_internal(
config=config,
prompt=prompt,
prompt_template=prompt_template,
unique_id=unique_id,
options=options,
image=image,
retry_count=retry_count,
max_output_length=max_output_length,
session_id=session_id,
reset_session=reset_session,
keep_model_loaded=keep_model_loaded,
utf8_sanitize=utf8_sanitize,
debug_lines=debug_lines,
custom_system_prompt=custom_system_prompt
)
except Exception as e:
error_msg = f"\nERROR: {type(e).__name__}: {str(e)}"
debug_lines.append(error_msg)
logger.error(error_msg)
raise
def _enhance_internal(
self,
config: Dict[str, Any],
prompt: str,
prompt_template: str,
unique_id: str,
options: Optional[Dict[str, Any]],
image: Optional["torch.Tensor"],
retry_count: int,
max_output_length: int,
session_id: str,
reset_session: bool,
keep_model_loaded: bool,
utf8_sanitize: bool,
debug_lines: List[str],
custom_system_prompt: str
) -> Tuple[str, str]:
"""Internal enhancement logic."""
if not prompt.strip():
debug_lines.append("Empty input prompt")
return ("", "\n".join(debug_lines))
# Validate custom system prompt if selected
if prompt_template == "custom":
if not custom_system_prompt.strip():
raise ValueError("Custom system prompt cannot be empty when 'custom' template is selected")
if "{prompt}" not in custom_system_prompt:
raise ValueError("Custom system prompt must contain {prompt} placeholder")
# Extract enabled options
opts = filter_enabled_options(options) if options else {}
# Determine prompt template language
if prompt_template == "auto":
lang = detect_language(prompt)
elif prompt_template == "chinese":
lang = "zh"
elif prompt_template == "english":
lang = "en"
else:
lang = "custom"
debug_lines.append("\n[CONFIGURATION]")
debug_lines.append(f"Provider: {config['provider']}")
debug_lines.append(f"Model: {config['model']}")
debug_lines.append(f"Prompt Template: {lang}")
debug_lines.append(f"Max Output Length: {max_output_length} (0=unlimited)")
debug_lines.append(f"Options: {opts}")
# Session management - FIXED: Now shows proper session ID
effective_session_id = session_id if session_id.strip() else unique_id
if reset_session:
cleared = clear_session(effective_session_id)
debug_lines.append(f"Session reset requested for '{effective_session_id}': {'cleared' if cleared else 'not found'}")
session, is_new = get_or_create_session(effective_session_id, config['model'])
debug_lines.append(f"Session '{effective_session_id}': {'new' if is_new else 'existing'} ({len(session.messages)} messages)")
# Build prompt with template
template = PROMPT_TEMPLATE_ZH if lang == "zh" else PROMPT_TEMPLATE_EN if lang == "en" else custom_system_prompt
system_prompt = template.format(prompt=prompt)
debug_lines.append("\n[INPUT]")
debug_lines.append(f"User prompt (length: {len(prompt)} chars):")
debug_lines.append(f"{prompt}")
debug_lines.append("\n[SYSTEM INSTRUCTION SENT TO API]")
debug_lines.append(f"Length: {len(system_prompt)} chars")
debug_lines.append(f"Content:\n{system_prompt}")
# Initialize messages with system instruction first
messages = []
messages.append({"role": "system", "content": system_prompt})
# Add session history if exists (for multi-turn)
if session.messages and not is_new:
messages.extend(session.get_messages())
debug_lines.append(f"Added {len(session.messages)} messages from session history")
# Add minimal user trigger message
messages.append({"role": "user", "content": "Generate."})
# Handle vision models if image provided
if image is not None and HAS_PIL:
debug_lines.append("\n[VISION]")
debug_lines.append("Processing image input for vision model...")
try:
images_b64 = batch_tensors_to_base64(image)
if images_b64:
debug_lines.append(f"Encoded {len(images_b64)} image(s) to base64")
content_parts = [{"type": "text", "text": "Generate based on the images."}]
for idx, img_b64 in enumerate(images_b64):
content_parts.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{img_b64}"}
})
debug_lines.append(f"Added image {idx+1}/{len(images_b64)}")
# Replace the last message (user message) with multimodal content
messages[-1] = {"role": "user", "content": content_parts}
except Exception as e:
debug_lines.append(f"Image encoding failed: {e}")
logger.warning(f"Failed to encode images: {e}")
# API parameters
temperature = opts.get("temperature", 0.7)
max_tokens = opts.get("max_tokens") # Will be None if disabled
debug_lines.append("\n[INFERENCE]")
debug_lines.append(f"Temperature: {temperature}")
debug_lines.append(f"Max Tokens: {max_tokens if max_tokens is not None else 'Not set (using API default)'}")
# Make API call
client = config["client"]
# Build API call parameters
api_params = {
"messages": messages,
"model": config["model"],
"temperature": temperature,
"retry_count": retry_count,
"keep_loaded": keep_model_loaded,
}
# Only include max_tokens if it was enabled
if max_tokens is not None:
api_params["max_tokens"] = max_tokens
# Add other enabled options
api_params.update({k: v for k, v in opts.items() if k not in ["temperature", "max_tokens", "debug_mode"]})
response = client.chat(**api_params)
# Add client log to debug output
debug_lines.append("\n[CLIENT LOG]")
if hasattr(client, 'debug_log'):
debug_lines.extend(client.debug_log)
# Handle empty response
if not response or not response.strip():
raise ValueError("API returned empty response")
debug_lines.append("\n[RAW RESPONSE]")
debug_lines.append(f"Length: {len(response)} chars")
debug_lines.append(f"Full content:\n{response}")
# Clean output - NOW USES max_output_length parameter
debug_lines.append("\n[CLEANING]")
enhanced = clean_llm_output(response, max_length=max_output_length, debug_log=debug_lines)
if not enhanced:
raise ValueError("Cleaning resulted in empty output")
# UTF-8 sanitization
if utf8_sanitize:
enhanced = sanitize_utf8(enhanced)
debug_lines.append("UTF-8 sanitization applied")
debug_lines.append("\n[OUTPUT]")
debug_lines.append(f"Final length: {len(enhanced)} characters")
debug_lines.append(f"Full enhanced prompt:\n{enhanced}")
# Token estimation - language-aware
if lang == "zh":
# Chinese: roughly 1.5-2 characters per token
estimated_tokens = len(enhanced) / 1.5
estimated_words = len(enhanced) # Each character roughly a "word"
else:
# English: ~4 chars per token on average
estimated_tokens = len(enhanced) / 4
estimated_words = len(enhanced) / 5
debug_lines.append("\n[TOKEN ESTIMATE]")
debug_lines.append(f"Estimated words: ~{int(estimated_words)}")
debug_lines.append(f"Estimated tokens: ~{int(estimated_tokens)} (Z-Image in ComfyUI supports unlimited tokens)")
# Only warn if unreasonably long (e.g. > 8000 tokens which might hit other limits)
if estimated_tokens > 8000:
debug_lines.append("WARNING: Extremely long prompt (>8000 tokens). This may impact generation quality.")
logger.info(f"Enhancement successful. Length: {len(enhanced)}")
# Save conversation to session for multi-turn continuity
# Store original prompt (not system instruction) for cleaner history
session.add_message("user", prompt)
session.add_message("assistant", enhanced)
debug_lines.append("\n[SESSION]")
debug_lines.append(f"Saved conversation to session '{effective_session_id}'")
debug_lines.append(f"Total messages in session: {len(session.messages)}")
return (enhanced, "\n".join(debug_lines))
# ============================================================================
# PROMPT ENHANCER WITH CLIP OUTPUT (Enhanced with Omni Support)
# ============================================================================
def format_omni_prompt(prompt: str, num_condition_images: int = 0) -> str:
"""
Format prompt for Z-Image-Omni pipeline with vision tokens.
This generates the appropriate prompt format expected by ZImageOmniPipeline:
- For text-only: <|im_start|>user\n[prompt]<|im_end|>\n<|im_start|>assistant\n
- For image+text: <|im_start|>user\n<|vision_start|><|vision_end|>...[prompt]<|im_end|>\n<|im_start|>assistant\n<|vision_start|>
Args:
prompt: The enhanced text prompt
num_condition_images: Number of condition images (0 for text-to-image)
Returns:
Formatted prompt string with vision tokens
"""
if num_condition_images == 0:
# Text-to-image mode
return f"<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n"
else:
# Image editing/conditioning mode
# Build vision token sequence for each condition image
parts = ["<|im_start|>user\n<|vision_start|>"]
parts.extend(["<|vision_end|><|vision_start|>"] * (num_condition_images - 1))
parts.append(f"<|vision_end|>{prompt}<|im_end|>\n<|im_start|>assistant\n<|vision_start|>")
parts.append("<|vision_end|><|im_end|>")
return "".join(parts)
class Z_ImagePromptEnhancerWithCLIP:
"""
Prompt Enhancer with CLIP conditioning output and Omni pipeline support.
Features:
- CLIP conditioning output for direct use in ComfyUI
- Multiple condition image support (up to 4 images) for Z-Image-Omni
- Automatic vision token formatting for Omni pipeline
- Backward compatible with single image input
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"clip": ("CLIP",),
"config": ("ZIMAGE_CONFIG",),
"prompt": ("STRING", {
"multiline": True,
"default": "",
"placeholder": "Enter your prompt to enhance..."
}),
"prompt_template": (["auto", "chinese", "english", "custom"], {
"default": "chinese",
"tooltip": TOOLTIPS["prompt_template"]
}),
"generation_mode": (["text_to_image", "image_to_image"], {
"default": "text_to_image",
"tooltip": "Generation mode: text_to_image (T2I) or image_to_image (I2I/Omni editing)"
}),
},
"optional": {
"options": ("ZIMAGE_OPTIONS",),
# Multi-image input for vision enhancement and Omni
"image_1": ("IMAGE", {
"tooltip": "First image for vision enhancement and Z-Image-Omni editing"
}),
"image_2": ("IMAGE", {
"tooltip": "Second image (optional)"
}),
"image_3": ("IMAGE", {
"tooltip": "Third image (optional)"
}),
"image_4": ("IMAGE", {
"tooltip": "Fourth image (optional)"
}),
"retry_count": ("INT", {
"default": 3,
"min": 0,
"max": 10,
"step": 1,
"tooltip": TOOLTIPS["retry_count"]
}),
"max_output_length": ("INT", {
"default": 6000,
"min": 0,
"max": 10000,
"step": 100,
"tooltip": TOOLTIPS["max_output_length"]
}),
"session_id": ("STRING", {
"default": "",
"tooltip": TOOLTIPS["session_id"]
}),
"reset_session": ("BOOLEAN", {
"default": False,
"tooltip": TOOLTIPS["reset_session"]
}),
"keep_model_loaded": ("BOOLEAN", {
"default": True,
"tooltip": TOOLTIPS["keep_model_loaded"]
}),
"utf8_sanitize": ("BOOLEAN", {
"default": False,
"tooltip": TOOLTIPS["utf8_sanitize"]
}),
"custom_system_prompt": ("STRING", {
"multiline": True,
"default": "",
"placeholder": "Enter a custom system prompt and select 'custom' in the prompt template."
}),
},
"hidden": {
"unique_id": "UNIQUE_ID"
}
}
RETURN_TYPES = ("CONDITIONING", "STRING", "STRING", "STRING", "INT")
RETURN_NAMES = ("conditioning", "enhanced_prompt", "omni_formatted_prompt", "debug_log", "num_condition_images")
FUNCTION = "enhance_and_encode"
CATEGORY = "Z-Image"
DESCRIPTION = "Enhance prompt and encode with CLIP. Supports Z-Image-Omni multi-image conditioning."
def enhance_and_encode(
self,
clip,
config: Dict[str, Any],
prompt: str,
prompt_template: str,
generation_mode: str,
unique_id: str = "",
options: Optional[Dict[str, Any]] = None,
image_1: Optional["torch.Tensor"] = None,
image_2: Optional["torch.Tensor"] = None,
image_3: Optional["torch.Tensor"] = None,
image_4: Optional["torch.Tensor"] = None,
retry_count: int = 3,
max_output_length: int = 6000,
session_id: str = "",
reset_session: bool = False,
keep_model_loaded: bool = True,
utf8_sanitize: bool = False,
custom_system_prompt: str = "",
):
"""Enhance prompt and encode with CLIP. Supports Omni multi-image conditioning."""
debug_lines = ["[Z-IMAGE PROMPT ENHANCER + CLIP]", "=" * 50]
# Collect input images
input_images = []
for idx, img in enumerate([image_1, image_2, image_3, image_4], 1):
if img is not None:
input_images.append(img)
debug_lines.append(f"Image {idx}: shape={img.shape}")
num_condition_images = len(input_images)
# Determine mode based on user selection and images
debug_lines.append(f"Generation mode: {generation_mode}")
if generation_mode == "image_to_image" and input_images:
# Image-to-Image / Omni mode - use all attached images
vision_images = torch.cat(input_images, dim=0)
debug_lines.append(f"Total images: {num_condition_images}")
debug_lines.append(f"Vision batch shape: {vision_images.shape}")
if num_condition_images > 1:
debug_lines.append(f"Mode: Image Editing (Omni) - All {num_condition_images} images sent to VL")
else:
debug_lines.append("Mode: Image-to-Image")
elif generation_mode == "image_to_image" and not input_images:
# I2I mode but no images - warn and fallback to T2I
debug_lines.append("WARNING: image_to_image mode selected but no images attached. Falling back to text_to_image.")
vision_images = None
debug_lines.append("Mode: Text-to-Image (fallback)")
else:
# Text-to-Image mode - ignore any attached images for VL
vision_images = None
debug_lines.append("Mode: Text-to-Image")
debug_lines.append("")
# Enhance the prompt using base enhancer
enhancer = Z_ImagePromptEnhancer()
enhanced_prompt, enhance_debug = enhancer.enhance(
config=config,
prompt=prompt,
prompt_template=prompt_template,
unique_id=unique_id,
options=options,
image=vision_images,
retry_count=retry_count,
max_output_length=max_output_length,
session_id=session_id,
reset_session=reset_session,
keep_model_loaded=keep_model_loaded,
utf8_sanitize=utf8_sanitize,
custom_system_prompt=custom_system_prompt,
)
debug_lines.append(enhance_debug)
# Format for Omni pipeline with vision tokens (always enabled)
debug_lines.append("")
debug_lines.append("[OMNI FORMATTING]")
debug_lines.append("=" * 50)
omni_formatted = format_omni_prompt(enhanced_prompt, num_condition_images)
debug_lines.append(f"Formatted with {num_condition_images} vision token(s)")
debug_lines.append(f"Omni prompt length: {len(omni_formatted)} characters")
if num_condition_images == 0:
debug_lines.append(" - Text-only mode (standard vision tokens)")
else:
debug_lines.append(f" - {num_condition_images}x <|vision_start|>...<|vision_end|> for condition images")
debug_lines.append(" - 1x <|vision_start|>...<|vision_end|> for generated output")
# Noise mask info
debug_lines.append("")
debug_lines.append("[NOISE MASK INFO]")
debug_lines.append("=" * 50)
if num_condition_images > 0:
noise_mask_preview = [0] * num_condition_images + [1]
debug_lines.append(f"Expected noise mask: {noise_mask_preview}")
debug_lines.append(f" - {num_condition_images}x 0 (clean/condition images)")
debug_lines.append(" - 1x 1 (noisy/target image)")
else:
debug_lines.append("No noise mask needed for text-to-image generation")
# Encode with CLIP
tokens = clip.tokenize(enhanced_prompt)
cond, pooled = clip.encode_from_tokens(tokens, return_pooled=True)
conditioning = [[cond, {"pooled_output": pooled}]]
debug_lines.append("")
debug_lines.append("[CLIP ENCODING]")
debug_lines.append(f"Conditioning shape: {cond.shape}")
return (
conditioning,
enhanced_prompt,
omni_formatted,
"\n".join(debug_lines),
num_condition_images
)
# ============================================================================
# INTEGRATED KSAMPLER WITH PROMPT ENHANCEMENT
# ============================================================================
class Z_ImageIntegratedKSampler:
"""
Z-Image Integrated KSampler - All-in-one image generation with prompt enhancement.
Features:
- Integrated prompt enhancement using LLM
- Full KSampler with model, VAE, and sampling
- Text-to-Image and Image-to-Image modes
- Multi-image conditioning (up to 4 images)
- AuraFlow shift optimization
- CFGNorm integration
- Batch generation
- Auto-save to folder
- GPU/RAM memory cleanup
"""
def __init__(self):
self.device = comfy.model_management.intermediate_device() if HAS_COMFYUI else "cpu"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL", {"tooltip": "Diffusion model for image generation"}),
"clip": ("CLIP", {"tooltip": "CLIP model for text encoding"}),
"vae": ("VAE", {"tooltip": "VAE model for latent encode/decode"}),
"config": ("ZIMAGE_CONFIG", {"tooltip": "LLM configuration for prompt enhancement"}),
"positive_prompt": ("STRING", {
"multiline": True,
"default": "",
"placeholder": "Enter your positive prompt...",
"tooltip": "Positive prompt - will be enhanced by LLM"
}),
"negative_prompt": ("STRING", {
"multiline": True,
"default": "",
"placeholder": "Enter your negative prompt...",
"tooltip": "Negative prompt for unwanted elements"
}),
"generation_mode": (["text_to_image", "image_to_image"], {
"default": "text_to_image",
"tooltip": "T2I: Generate from text. I2I: Edit/transform input images"
}),
"width": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 8, "tooltip": "Output image width"}),
"height": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 8, "tooltip": "Output image height"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True, "tooltip": "Random seed"}),
"steps": ("INT", {"default": 20, "min": 1, "max": 1000, "tooltip": "Sampling steps"}),
"cfg": ("FLOAT", {"default": 7.0, "min": 0.0, "max": 100.0, "step": 0.1, "tooltip": "CFG scale"}),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "euler", "tooltip": "Sampler algorithm"}),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "normal", "tooltip": "Scheduler type"}),
"denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Denoise strength (1.0 = full, <1.0 for I2I)"}),
},
"optional": {
"options": ("ZIMAGE_OPTIONS", {"tooltip": "Advanced LLM options"}),
"image_1": ("IMAGE", {"tooltip": "First image for I2I/vision enhancement"}),
"image_2": ("IMAGE", {"tooltip": "Second image (optional)"}),
"image_3": ("IMAGE", {"tooltip": "Third image (optional)"}),
"image_4": ("IMAGE", {"tooltip": "Fourth image (optional)"}),
"latent": ("LATENT", {"tooltip": "Optional latent input (for custom latent)"}),
"prompt_template": (["auto", "chinese", "english", "custom"], {
"default": "chinese",
"tooltip": "LLM prompt template language"
}),
"enable_prompt_enhance": ("BOOLEAN", {"default": True, "tooltip": "Enable LLM prompt enhancement (disable to use prompt as-is)"}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 16, "tooltip": "Number of images to generate"}),
"auraflow_shift": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 0.01, "tooltip": "AuraFlow shift (0 = disabled)"}),
"cfg_norm_strength": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 10.0, "step": 0.01, "tooltip": "CFGNorm strength (0 = disabled)"}),
"enable_clean_gpu": ("BOOLEAN", {"default": False, "tooltip": "Clean GPU memory before/after sampling"}),
"enable_clean_ram": ("BOOLEAN", {"default": False, "tooltip": "Clean RAM after completion"}),
"auto_save_folder": ("STRING", {"default": "", "tooltip": "Auto-save folder (empty = disabled)"}),
"output_prefix": ("STRING", {"default": "z_image", "tooltip": "Filename prefix for auto-save"}),
"custom_system_prompt": ("STRING", {"multiline": True, "default": "", "placeholder": "Custom system prompt (use with 'custom' template)"}),
"instruction": ("STRING", {
"multiline": True,
"default": "Describe the key features of the input image (color, shape, size, texture, objects, background), then explain how the user's text instruction should alter or modify the image. Generate a new image that meets the user's requirements while maintaining consistency with the original input where appropriate.",
"placeholder": "VLM System Instruction (for Image-to-Image)",
"tooltip": "System instruction for the Vision-Language Model in I2I mode"
}),
},
"hidden": {
"unique_id": "UNIQUE_ID"
}
}
RETURN_TYPES = ("IMAGE", "LATENT", "STRING", "STRING")
RETURN_NAMES = ("images", "latent", "enhanced_prompt", "debug_log")
FUNCTION = "generate"
CATEGORY = "Z-Image"
DESCRIPTION = "Z-Image Integrated KSampler - All-in-one generation with LLM prompt enhancement, sampling, AuraFlow, CFGNorm, and auto-save."
def generate(
self,
model,
clip,
vae,
config: Dict[str, Any],
positive_prompt: str,
negative_prompt: str,
generation_mode: str,
width: int,
height: int,
seed: int,
steps: int,
cfg: float,
sampler_name: str,
scheduler: str,
denoise: float = 1.0,
unique_id: str = "",
options: Optional[Dict[str, Any]] = None,
image_1: Optional["torch.Tensor"] = None,
image_2: Optional["torch.Tensor"] = None,
image_3: Optional["torch.Tensor"] = None,
image_4: Optional["torch.Tensor"] = None,
latent: Optional[Dict] = None,
prompt_template: str = "chinese",
enable_prompt_enhance: bool = True,
batch_size: int = 1,
auraflow_shift: float = 0.0,
cfg_norm_strength: float = 0.0,
enable_clean_gpu: bool = False,
enable_clean_ram: bool = False,
auto_save_folder: str = "",
output_prefix: str = "z_image",
custom_system_prompt: str = "",
instruction: str = "",
):
"""Generate images with integrated prompt enhancement and sampling."""
debug_lines = ["[Z-IMAGE INTEGRATED KSAMPLER]", "=" * 60]
debug_lines.append(f"Generation mode: {generation_mode}")
debug_lines.append(f"Resolution: {width}x{height}")
debug_lines.append(f"Seed: {seed}, Steps: {steps}, CFG: {cfg}")
debug_lines.append(f"Sampler: {sampler_name}, Scheduler: {scheduler}")
debug_lines.append(f"Batch size: {batch_size}")
# Collect input images
input_images = []
for idx, img in enumerate([image_1, image_2, image_3, image_4], 1):
if img is not None:
input_images.append(img)
debug_lines.append(f"Image {idx}: shape={img.shape}")
# Apply AuraFlow shift if specified
if auraflow_shift > 0:
debug_lines.append(f"Applying AuraFlow shift: {auraflow_shift}")
m = model.clone()
import comfy.model_sampling
sampling_base = comfy.model_sampling.ModelSamplingDiscreteFlow
sampling_type = comfy.model_sampling.CONST
class ModelSamplingAdvanced(sampling_base, sampling_type):
pass
model_sampling = ModelSamplingAdvanced(m.model.model_config)
model_sampling.set_parameters(shift=auraflow_shift, multiplier=1.0)
m.add_object_patch("model_sampling", model_sampling)
model = m
debug_lines.append("AuraFlow shift applied")
# Apply CFGNorm if specified
if cfg_norm_strength > 0:
debug_lines.append(f"Applying CFGNorm strength: {cfg_norm_strength}")
m = model.clone()
strength = cfg_norm_strength
def cfg_norm(args):
cond_p = args['cond_denoised']
pred_text_ = args["denoised"]
norm_full_cond = torch.norm(cond_p, dim=1, keepdim=True)
norm_pred_text = torch.norm(pred_text_, dim=1, keepdim=True)
scale = (norm_full_cond / (norm_pred_text + 1e-8)).clamp(min=0.0, max=1.0)
return pred_text_ * scale * strength
m.set_model_sampler_post_cfg_function(cfg_norm)
model = m
debug_lines.append("CFGNorm applied")
# Enhance positive prompt using LLM (if enabled)
debug_lines.append("")
debug_lines.append("[PROMPT ENHANCEMENT]")
debug_lines.append("=" * 40)
if enable_prompt_enhance:
enhancer = Z_ImagePromptEnhancer()
# Prepare vision images for LLM if in I2I mode
vision_images = None
if generation_mode == "image_to_image" and input_images:
vision_images = torch.cat(input_images, dim=0)
debug_lines.append(f"Sending {len(input_images)} images to LLM for enhancement")
enhanced_prompt, enhance_debug = enhancer.enhance(
config=config,
prompt=positive_prompt,
prompt_template=prompt_template,
unique_id=unique_id,
options=options,
image=vision_images,
retry_count=3,
max_output_length=6000,
session_id="",
reset_session=True,
keep_model_loaded=True,
utf8_sanitize=False,
custom_system_prompt=custom_system_prompt,
)
debug_lines.append(enhance_debug)
debug_lines.append(f"Enhanced prompt length: {len(enhanced_prompt)} chars")
else:
# Use prompt as-is
enhanced_prompt = positive_prompt
debug_lines.append("Prompt enhancement DISABLED - using prompt as-is")
debug_lines.append(f"Prompt length: {len(enhanced_prompt)} chars")
# Pre-clean GPU if requested
if enable_clean_gpu:
debug_lines.append("Pre-cleaning GPU memory...")
cleanGPUUsedForce()
# Encode prompts
debug_lines.append("")
debug_lines.append("[ENCODING]")
if generation_mode == "image_to_image" and input_images:
# Use Z-Image pipeline with reference latents
image_prompt, images_vl, llama_template, ref_latents = get_z_image_prompt(vae, input_images, instruction)
positive = z_prompt_encode(clip, enhanced_prompt, image_prompt, images_vl, llama_template, ref_latents)
negative = z_prompt_encode(clip, negative_prompt, image_prompt, images_vl, llama_template, ref_latents)
debug_lines.append(f"I2I encoding with {len(input_images)} reference images")
else:
# Standard encoding
tokens_pos = clip.tokenize(enhanced_prompt)
positive, pooled_pos = clip.encode_from_tokens(tokens_pos, return_pooled=True)
positive = [[positive, {"pooled_output": pooled_pos}]]
tokens_neg = clip.tokenize(negative_prompt)
negative, pooled_neg = clip.encode_from_tokens(tokens_neg, return_pooled=True)
negative = [[negative, {"pooled_output": pooled_neg}]]
debug_lines.append("T2I standard encoding")
# Create or use latent
if latent is None:
if generation_mode == "image_to_image" and input_images:
# Encode first image as starting latent
scaled_img = image_scale_aspect_ratio(input_images[0], width, height) if width > 0 and height > 0 else input_images[0]
samples = vae.encode(scaled_img[:,:,:,:3])
debug_lines.append("Using encoded image as latent")
else:
# Empty latent for T2I
samples = torch.zeros([batch_size, 4, height // 8, width // 8], device=self.device)
debug_lines.append(f"Created empty latent: {batch_size}x4x{height//8}x{width//8}")
if batch_size > 1 and samples.shape[0] == 1:
samples = samples.repeat((batch_size,) + ((1,) * (samples.ndim - 1)))
latent = {"samples": samples}
# Sample
debug_lines.append("")
debug_lines.append("[SAMPLING]")
debug_lines.append(f"Starting {steps} steps...")
latent_output = common_ksampler(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent, denoise=denoise)
debug_lines.append("Sampling complete")
# Decode
debug_lines.append("")
debug_lines.append("[DECODING]")
output_images = vae.decode(latent_output["samples"])
if len(output_images.shape) == 5:
output_images = output_images.reshape(-1, output_images.shape[-3], output_images.shape[-2], output_images.shape[-1])
debug_lines.append(f"Output shape: {output_images.shape}")
# Auto-save
if auto_save_folder:
try:
if os.path.isabs(auto_save_folder):
full_output_folder = auto_save_folder
else:
output_dir = folder_paths.get_output_directory()
full_output_folder = os.path.join(output_dir, auto_save_folder)
if not os.path.exists(full_output_folder):
os.makedirs(full_output_folder, exist_ok=True)
debug_lines.append(f"Auto-saving to: {full_output_folder}")
for batch_number, image in enumerate(output_images):
img = Image.fromarray((image.cpu().numpy() * 255).astype(np.uint8))
file = f"{output_prefix}_{seed}_{batch_number:05}.png"
img.save(os.path.join(full_output_folder, file))
debug_lines.append(f"Saved {len(output_images)} images")
except Exception as e:
debug_lines.append(f"Auto-save failed: {e}")
# Post-clean GPU
if enable_clean_gpu:
debug_lines.append("Post-cleaning GPU memory...")
cleanGPUUsedForce()
# Clean RAM
if enable_clean_ram:
debug_lines.append("Cleaning RAM...")
gc.collect()
debug_lines.append("")
debug_lines.append("[COMPLETE]")
debug_lines.append(f"Generated {len(output_images)} images")
return (output_images, latent_output, enhanced_prompt, "\n".join(debug_lines))
# ============================================================================
# MODEL MANAGEMENT NODES
# ============================================================================
class Z_ImageUnloadModels:
"""
Unload cached models to free memory.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"unload_all": ("BOOLEAN", {"default": True}),
},
"optional": {
"passthrough": ("*",), # Pass any input through
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("status",)
FUNCTION = "unload"
CATEGORY = "Z-Image"
DESCRIPTION = "Unload cached LLM models to free GPU/system memory."
OUTPUT_NODE = True
def unload(self, unload_all: bool = True, passthrough=None):
"""Unload models from cache."""
if unload_all:
DirectLocalModelClient.unload_all_models()
status = "All models unloaded"
else:
status = "No action taken"
logger.info(status)
return (status,)
class Z_ImageClearSessions:
"""
Clear conversation sessions.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"clear_all": ("BOOLEAN", {"default": True}),
},
"optional": {
"session_id": ("STRING", {"default": ""}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("status",)
FUNCTION = "clear"
CATEGORY = "Z-Image"
DESCRIPTION = "Clear conversation history sessions."
OUTPUT_NODE = True
def clear(self, clear_all: bool = True, session_id: str = ""):
"""Clear sessions."""
if clear_all:
count = len(CHAT_SESSIONS)
CHAT_SESSIONS.clear()
status = f"All {count} sessions cleared"
elif session_id:
cleared = clear_session(session_id)
status = f"Session '{session_id}' {'cleared' if cleared else 'not found'}"
else:
status = "No action taken"
logger.info(status)
return (status,)
# ============================================================================
# NODE REGISTRATION
# ============================================================================
NODE_CLASS_MAPPINGS = {
"Z_ImageAPIConfig": Z_ImageAPIConfig,
"Z_ImageOptions": Z_ImageOptions,
"Z_ImagePromptEnhancer": Z_ImagePromptEnhancer,
"Z_ImagePromptEnhancerWithCLIP": Z_ImagePromptEnhancerWithCLIP,
"Z_ImageIntegratedKSampler": Z_ImageIntegratedKSampler,
"Z_ImageUnloadModels": Z_ImageUnloadModels,
"Z_ImageClearSessions": Z_ImageClearSessions,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Z_ImageAPIConfig": "Z-Image API Config",
"Z_ImageOptions": "Z-Image Options",
"Z_ImagePromptEnhancer": "Z-Image Prompt Enhancer",
"Z_ImagePromptEnhancerWithCLIP": "Z-Image Prompt Enhancer + CLIP",
"Z_ImageIntegratedKSampler": "Z-Image Integrated KSampler",
"Z_ImageUnloadModels": "Z-Image Unload Models",
"Z_ImageClearSessions": "Z-Image Clear Sessions",
}