__version__ = (1, 1, 2)
# meta developer: @mofkomodules, @pureoffic
# Name: ComfyImageGen
# meta banner: https://raw.githubusercontent.com/mofko/MofkoModules/refs/heads/main/assets/comfy_imagegen_banner.png
# meta pic: https://raw.githubusercontent.com/mofko/MofkoModules/refs/heads/main/assets/comfy_imagegen_banner.png
# meta fhsdesc: image generation, imagegen, comfy, comfyui, mofko, image, генерация, ии, комфи, изображения
# meta tags: image generation, imagegen, comfy, comfyui, mofko, image, генерация, ии, комфи, изображения
# meta link: https://raw.githubusercontent.com/mofko/MofkoModules/refs/heads/main/ComfyImageGen.py
# Diff: Новые воркфлоу под новые модели, соответственно обновлены все гайд файлы и Comfy Portal (https://github.com/mofko/comfy-portal). Исправлены все известные баги. Полная поддержка Comfy Cloud (https://cloud.comfy.org/), скачивание моделей, отдельные воркфлоу, библиотека моделей, загрузка воркфлоу по ссылке Share. Переработка почти всех инлайн меню, удобный поиск везде, удобная настройка лор, новые ии-провайдеры, небольшие косметические улучшения, улучшена оптимизация модуля, автоматическое переключение модели и ещё много всего.
# requires: cachetools google-genai
# scope: heroku_min 2.1.0
import logging
import asyncio
import base64
import io
import json
import os
import time
import uuid
import random
import re
import string
import tempfile
import mimetypes
import contextvars
from urllib.parse import parse_qs
from urllib.parse import quote
from urllib.parse import urlparse
import aiohttp
from cachetools import TTLCache
from PIL import Image
from herokutl.extensions import html
from herokutl.errors.rpcerrorlist import ChannelPrivateError, ChatAdminRequiredError, UserNotParticipantError
from herokutl.tl.functions.channels import GetFullChannelRequest
from herokutl.tl.functions.messages import GetForumTopicsByIDRequest
try:
from google import genai
from google.genai import types as genai_types
GENAI_AVAILABLE = True
except ImportError:
GENAI_AVAILABLE = False
from herokutl.tl.types import ForumTopicDeleted, Message, PeerChannel
from .. import loader, utils
from ..inline.types import InlineCall
logger = logging.getLogger(__name__)
class ComfyUIHTTPError(ValueError):
def __init__(self, status, body):
self.status = status
self.body = body
super().__init__(f"HTTP {status}: {body[:500]}")
@property
def temporary(self):
return self.status in (502, 503, 504)
class ComfyUIExecutionError(ValueError):
pass
class UserFacingError(ValueError):
def __init__(self, key, plain_message=None, **kwargs):
self.key = key
self.kwargs = kwargs
super().__init__(plain_message or key)
_ASSETS_BASE_URL = "https://github.com/mofko/MofkoModules/raw/refs/heads/main/assets"
_ANIME_V2_WF_URL = "https://raw.githubusercontent.com/mofko/MofkoModules/refs/heads/main/assets/AnimaWF.json"
_ANIME_V3_WF_URL = "https://raw.githubusercontent.com/mofko/MofkoModules/refs/heads/main/assets/AnimaWF_AnimeColoring.json"
_ILL_WF_URL = "https://raw.githubusercontent.com/mofko/MofkoModules/refs/heads/main/assets/Anime_workflow.json"
_KREA2_WF_URL = "https://raw.githubusercontent.com/mofko/MofkoModules/refs/heads/main/assets/Krea2_WF.json"
_SDXL_REAL2_WF_URL = f"{_ASSETS_BASE_URL}/sdxl_real2_workflow.json"
_UPSCALE_WF_URL = f"{_ASSETS_BASE_URL}/UpscaleWF1_clean.json"
_VIDEO_UPSCALE_WF_URL = f"{_ASSETS_BASE_URL}/utility-gan_upscaler.json"
_CLOUD_KREA2_WF_URL = "https://raw.githubusercontent.com/mofko/MofkoModules/refs/heads/main/assets/Krea2_%D1%81WF%20.json"
_CLOUD_ANIMA_WF_URL = "https://raw.githubusercontent.com/mofko/MofkoModules/refs/heads/main/assets/Anima_cWF.json"
_CLOUD_ANIMA2_WF_URL = "https://raw.githubusercontent.com/mofko/MofkoModules/refs/heads/main/assets/Anima2_cWF.json"
_CLOUD_ILL_WF_URL = "https://raw.githubusercontent.com/mofko/MofkoModules/refs/heads/main/assets/Cill.json"
_CLOUD_QWEN_WF_URL = "https://raw.githubusercontent.com/mofko/MofkoModules/refs/heads/main/assets/Cloud%20qwen%20i2i.json"
_CLOUD_UPSCALE_WF_URL = "https://raw.githubusercontent.com/mofko/MofkoModules/refs/heads/main/assets/cloud%20upscale.json"
_CLOUD_VIDEO_UPSCALE_WF_URL = "https://raw.githubusercontent.com/mofko/MofkoModules/refs/heads/main/assets/cloud%20vupscaler.json"
_CDOWN_TYPE_CHECKPOINT = "checkpoint"
_CDOWN_TYPE_LORA = "lora"
_CDOWN_TYPE_VAE = "vae"
_CDOWN_TYPE_CONTROLNET = "controlnet"
_CDOWN_TYPE_UPSCALER = "upscaler"
_CDOWN_TYPE_TEXT_ENCODER = "text_encoder"
_CDOWN_TYPE_UNET = "unet"
_CDOWN_TYPE_CLIP_VISION = "clip_vision"
_CDOWN_TYPE_IPADAPTER = "ipadapter"
_CDOWN_TYPE_STYLE_MODEL = "style_model"
_CDOWN_TYPE_MODEL_PATCH = "model_patch"
_CDOWN_TYPE_SAM = "sam"
_CDOWN_TYPES = {
_CDOWN_TYPE_CHECKPOINT: {
"label_key": "cdown_type_checkpoint",
"tags": ("models", "checkpoints"),
"folder_aliases": ("checkpoints", "checkpoint"),
},
_CDOWN_TYPE_LORA: {
"label_key": "cdown_type_lora",
"tags": ("models", "loras"),
"folder_aliases": ("loras", "lora"),
},
_CDOWN_TYPE_VAE: {
"label_key": "cdown_type_vae",
"tags": ("models", "vae"),
"folder_aliases": ("vae", "vaes"),
},
_CDOWN_TYPE_CONTROLNET: {
"label_key": "cdown_type_controlnet",
"tags": ("models", "controlnet"),
"folder_aliases": ("controlnet", "controlnets"),
},
_CDOWN_TYPE_UPSCALER: {
"label_key": "cdown_type_upscaler",
"tags": ("models", "upscale_models"),
"folder_aliases": ("upscale_models", "upscale_model", "upscaler", "upscalers"),
},
_CDOWN_TYPE_TEXT_ENCODER: {
"label_key": "cdown_type_text_encoder",
"tags": ("models", "text_encoders"),
"folder_aliases": ("text_encoders", "text_encoder"),
},
_CDOWN_TYPE_UNET: {
"label_key": "cdown_type_unet",
"tags": ("models", "diffusion_models"),
"folder_aliases": ("diffusion_models", "diffusion_model", "unet", "unets"),
},
_CDOWN_TYPE_CLIP_VISION: {
"label_key": "cdown_type_clip_vision",
"tags": ("models", "clip_vision"),
"folder_aliases": ("clip_vision", "clipvision"),
},
_CDOWN_TYPE_IPADAPTER: {
"label_key": "cdown_type_ipadapter",
"tags": ("models", "ipadapter"),
"folder_aliases": ("ipadapter", "ip_adapter", "ip-adapter"),
},
_CDOWN_TYPE_STYLE_MODEL: {
"label_key": "cdown_type_style_model",
"tags": ("models", "style_models"),
"folder_aliases": ("style_models", "style_model"),
},
_CDOWN_TYPE_MODEL_PATCH: {
"label_key": "cdown_type_model_patch",
"tags": ("models", "model_patches"),
"folder_aliases": ("model_patches", "model_patch", "patches", "patch"),
},
_CDOWN_TYPE_SAM: {
"label_key": "cdown_type_sam",
"tags": ("models", "sams"),
"folder_aliases": ("sams", "sam"),
},
}
_BGRM_WF_URL = f"{_ASSETS_BASE_URL}/bgremove.json"
_FRAMES_WF_URL = f"{_ASSETS_BASE_URL}/frames.json"
_MODULE_UPDATE_URL = "https://raw.githubusercontent.com/mofko/MofkoModules/refs/heads/main/ComfyImageGen.py"
_COMFY_BACKEND_LOCAL = "local"
_COMFY_BACKEND_CLOUD = "cloud"
_COMFY_CLOUD_BASE_URL = "https://cloud.comfy.org"
_CIVITAI_IMAGES_URL = "https://civitai.com/api/v1/images"
_CIVITAI_MODEL_URL = "https://civitai.com/api/v1/models/{}"
_CIVITAI_MODEL_VERSION_URL = "https://civitai.com/api/v1/model-versions/{}"
_CSHARE_TOP_CHAT = "comfyideas"
_CSHARE_TOP_MESSAGE_ID = 5
_ENHANCE_PROMPT_URL = f"{_ASSETS_BASE_URL}/enhance_system_prompt.txt"
_QWEN_DEFAULT_BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
_TRANSLATE_URL = "https://translate.googleapis.com/translate_a/single"
_COMFY_TEXT_PROVIDER = "comfy_text"
_COMFY_TEXT_CLIP_NAME = "qwen3vl_4b_fp8_scaled.safetensors"
_COMFY_TEXT_WORKFLOW_URL = f"{_ASSETS_BASE_URL}/Text_gen.json"
_COMFY_TEXT_WORKFLOW_CACHE_REVISION = 2
_COMFY_TEXT_ENHANCE_TIMEOUT = 180
_DEFAULT_INFO_BANNER_URL = "https://raw.githubusercontent.com/mofko/MofkoModules/refs/heads/main/assets/comfy_imagegen_banner_assets.png"
_UPDATE_NOTICE_LIMIT = 2
_STARTUP_UPDATE_CHECK_DELAY = 30
_STARTUP_UPDATE_CHECK_ATTEMPTS = 3
_ULT_GENS_TOPIC_EMOJI_ID = 5326048107497026134
_PREFLIGHT_EYES_INLINE = '\U0001f440'
_EMOJI_THEME_DEFAULT = "default"
_EMOJI_THEME_COLORED = "colored"
_EMOJI_THEME_CUTE = "cute"
_EMOJI_THEME_BLACK = "black"
_EMOJI_THEME_TROLLFACE = "trollface"
_EMOJI_THEME_TAG_RE = re.compile(
r'<(?Pemoji|tg-emoji)\s+'
r'(?:document_id|emoji-id)=["\']?(?P\d+)["\']?\s*>'
r'(?P.*?)(?P=tag)>',
re.DOTALL,
)
_EMOJI_THEME_REPLACEMENTS = {
_EMOJI_THEME_COLORED: {
("5121063440311386962", "\U0001f44e"): ("5278578973595427038", "\U0001f6ab"),
("5121063440311386962", "\u274c"): ("5278578973595427038", "\U0001f6ab"),
("5206607081334906820", "\u2705"): ("5206401524200145033", "\U0001f53c"),
("4904936030232117798", "\u2699"): ("5206626000665868017", "\U0001f4da"),
("4904936030232117798", "\u26a0"): ("5206222720416643915", "\U0001f514"),
("5985346521103604145", "\u2b1c"): ("5278578973595427038", "\U0001f6ab"),
("5444965220663458467", "\U0001f4c1"): ("5278227821364275264", "\U0001f4c1"),
("5188678912883827293", "\U0001f916"): ("5276127848644503161", "\U0001f916"),
("5764779661028495989", "\U0001f3a8"): ("5206211858444354221", "\U0001f9ea"),
("5206591666697306436", "\U0001f36d"): ("5276314275994954605", "\U0001f528"),
("5879841310902324730", "\u270f"): ("5276381204470329471", "\U0001f9d1\u200d\U0001f4bb"),
("5407001145740631266", "\U0001f910"): ("5278578973595427038", "\U0001f6ab"),
("5839354140261619193", "\U0001f6dc"): ("5278647306525108244", "\U0001f5a5"),
("5873225338984599714", "\U0001f4e4"): ("5206702193385700709", "\U0001f4e6"),
("5373342633798167891", "\U0001f4be"): ("5278753302023004775", "\u2139\ufe0f"),
("5870921681735781843", "\u23f1"): ("5276412364458059956", "\U0001f553"),
("5325547803936572038", "\u2764"): ("5206476089127372379", "\u2b50\ufe0f"),
("5334544901428229844", "\U0001f3b2"): ("5276395476646653290", "\U0001f50d"),
("5361979468887893611", "\U0001f195"): ("5276220667182736079", "\U0001f4e5"),
},
_EMOJI_THEME_CUTE: {
("5121063440311386962", "\U0001f44e"): ("5388955127681942181", "\U0001f970"),
("5121063440311386962", "\u274c"): ("5258506985103436605", "\U0001f97a"),
("5206607081334906820", "\u2705"): ("5386592741050326951", "\u2764\ufe0f"),
("4904936030232117798", "\u2699"): ("5386382721444519116", "\U0001f431"),
("4904936030232117798", "\u26a0"): ("5258033280275459503", "\U0001f606"),
("5985346521103604145", "\u2b1c"): ("5260600313508799670", "\U0001f408\u200d\u2b1b"),
("5444965220663458467", "\U0001f4c1"): ("5260681054598999309", "\U0001f43e"),
("5188678912883827293", "\U0001f916"): ("5260281691359946611", "\u2328\ufe0f"),
("5764779661028495989", "\U0001f3a8"): ("5364244578805250897", "\U0001f970"),
("5206591666697306436", "\U0001f36d"): ("5366052158741443031", "\U0001f9b4"),
("5879841310902324730", "\u270f"): ("5366263484017306348", "\U0001f430"),
("5407001145740631266", "\U0001f910"): ("5366337546433359364", "\U0001f44e"),
("5839354140261619193", "\U0001f6dc"): ("5341331263288538681", "\u2728"),
("5873225338984599714", "\U0001f4e4"): ("5258493176783581223", "\U0001f970"),
("5373342633798167891", "\U0001f4be"): ("5470076494283818173", "\U0001f97a"),
("5870921681735781843", "\u23f1"): ("5258210194273349779", "\U0001f970"),
("5325547803936572038", "\u2764"): ("5260281691359946611", "\u2328\ufe0f"),
("5334544901428229844", "\U0001f3b2"): ("5260450698323044545", "\U0001f970"),
("5361979468887893611", "\U0001f195"): ("5260323928068337625", "\U0001f431"),
},
_EMOJI_THEME_BLACK: {
("5121063440311386962", "\U0001f44e"): ("5192941702284864123", "\U0001f642"),
("5121063440311386962", "\u274c"): ("5193178101579803928", "\U0001f494"),
("5206607081334906820", "\u2705"): ("5192871045777879072", "\U0001f493"),
("4904936030232117798", "\u2699"): ("5454143866422706796", "\U0001f52b"),
("4904936030232117798", "\u26a0"): ("5456424949323412497", "\U0001f52f"),
("5985346521103604145", "\u2b1c"): ("5307569813864858174", "\u2753"),
("5444965220663458467", "\U0001f4c1"): ("5458739584508640009", "\u2626\ufe0f"),
("5188678912883827293", "\U0001f916"): ("5456556306603195188", "\U0001f642"),
("5764779661028495989", "\U0001f3a8"): ("5192858556012980770", "\U0001f470\u200d\u2642\ufe0f"),
("5206591666697306436", "\U0001f36d"): ("5456231044434902696", "\U0001f5a4"),
("5879841310902324730", "\u270f"): ("5458640512498022912", "\U0001f441"),
("5407001145740631266", "\U0001f910"): ("5307559991274651061", "\U0001f5a4"),
("5839354140261619193", "\U0001f6dc"): ("5303394457113083917", "\U0001f577\ufe0f"),
("5873225338984599714", "\U0001f4e4"): ("5456542094556414568", "\U0001f431"),
("5373342633798167891", "\U0001f4be"): ("5456664526894154591", "\U0001f431"),
("5870921681735781843", "\u23f1"): ("5353023431283591325", "\u23f3"),
("5325547803936572038", "\u2764"): ("5445243616148602729", "\U0001f5b1\ufe0f"),
("5334544901428229844", "\U0001f3b2"): ("5192859565330296406", "\U0001f595"),
("5361979468887893611", "\U0001f195"): ("5469946464148933378", "\U0001f62d"),
},
_EMOJI_THEME_TROLLFACE: {
("5121063440311386962", "\U0001f44e"): ("5422458393736536101", "\u26d1"),
("5121063440311386962", "\u274c"): ("5424679926915683083", "\u2620\ufe0f"),
("5206607081334906820", "\u2705"): ("5422604134861790377", "\U0001f642"),
("4904936030232117798", "\u2699"): ("5422732614513484798", "\u2620\ufe0f"),
("4904936030232117798", "\u26a0"): ("5422458561240259846", "\u2620\ufe0f"),
("5985346521103604145", "\u2b1c"): ("5422750421447891308", "\u2620\ufe0f"),
("5444965220663458467", "\U0001f4c1"): ("5422846817693884363", "\u263a\ufe0f"),
("5188678912883827293", "\U0001f916"): ("5422732614513484798", "\u2620\ufe0f"),
("5764779661028495989", "\U0001f3a8"): ("5422611986062006798", "\U0001f617"),
("5206591666697306436", "\U0001f36d"): ("5424747649960007887", "\U0001f697"),
("5879841310902324730", "\u270f"): ("5422376767883074291", "\U0001f5bc"),
("5407001145740631266", "\U0001f910"): ("5424599752761168855", "\U0001f5bc"),
("5839354140261619193", "\U0001f6dc"): ("5424794589657586686", "\U0001f603"),
("5873225338984599714", "\U0001f4e4"): ("5422874331254380688", "\U0001f642"),
("5373342633798167891", "\U0001f4be"): ("5422474044597360882", "\U0001f642"),
("5870921681735781843", "\u23f1"): ("5425146304529454719", "\U0001f4ef"),
("5325547803936572038", "\u2764"): ("5422732614513484798", "\u2620\ufe0f"),
("5334544901428229844", "\U0001f3b2"): ("5422727821329979692", "\U0001f453"),
("5361979468887893611", "\U0001f195"): ("5424679926915683083", "\u2620\ufe0f"),
},
}
_EMOJI_THEME_ID_FALLBACKS = {
_EMOJI_THEME_COLORED: {
"5121063440311386962": ("5278578973595427038", "\U0001f6ab"),
"5985346521103604145": ("5278578973595427038", "\U0001f6ab"),
"5206607081334906820": ("5206401524200145033", "\U0001f53c"),
"5444965220663458467": ("5278227821364275264", "\U0001f4c1"),
"5188678912883827293": ("5276127848644503161", "\U0001f916"),
"5764779661028495989": ("5206211858444354221", "\U0001f9ea"),
"5206591666697306436": ("5276314275994954605", "\U0001f528"),
"5879841310902324730": ("5276381204470329471", "\U0001f9d1\u200d\U0001f4bb"),
"5407001145740631266": ("5278578973595427038", "\U0001f6ab"),
"5839354140261619193": ("5278647306525108244", "\U0001f5a5"),
"5873225338984599714": ("5206702193385700709", "\U0001f4e6"),
"5373342633798167891": ("5278753302023004775", "\u2139\ufe0f"),
"5870921681735781843": ("5276412364458059956", "\U0001f553"),
"5325547803936572038": ("5206476089127372379", "\u2b50\ufe0f"),
"5334544901428229844": ("5276395476646653290", "\U0001f50d"),
"5361979468887893611": ("5276220667182736079", "\U0001f4e5"),
},
_EMOJI_THEME_CUTE: {
"5206607081334906820": ("5386592741050326951", "\u2764\ufe0f"),
"5985346521103604145": ("5260600313508799670", "\U0001f408\u200d\u2b1b"),
"5444965220663458467": ("5260681054598999309", "\U0001f43e"),
"5188678912883827293": ("5260281691359946611", "\u2328\ufe0f"),
"5764779661028495989": ("5364244578805250897", "\U0001f970"),
"5206591666697306436": ("5366052158741443031", "\U0001f9b4"),
"5879841310902324730": ("5366263484017306348", "\U0001f430"),
"5407001145740631266": ("5366337546433359364", "\U0001f44e"),
"5839354140261619193": ("5341331263288538681", "\u2728"),
"5873225338984599714": ("5258493176783581223", "\U0001f970"),
"5373342633798167891": ("5470076494283818173", "\U0001f97a"),
"5870921681735781843": ("5258210194273349779", "\U0001f970"),
"5325547803936572038": ("5260281691359946611", "\u2328\ufe0f"),
"5334544901428229844": ("5260450698323044545", "\U0001f970"),
"5361979468887893611": ("5260323928068337625", "\U0001f431"),
},
_EMOJI_THEME_BLACK: {
"5206607081334906820": ("5192871045777879072", "\U0001f493"),
"5985346521103604145": ("5307569813864858174", "\u2753"),
"5444965220663458467": ("5458739584508640009", "\u2626\ufe0f"),
"5188678912883827293": ("5456556306603195188", "\U0001f642"),
"5764779661028495989": ("5192858556012980770", "\U0001f470\u200d\u2642\ufe0f"),
"5206591666697306436": ("5456231044434902696", "\U0001f5a4"),
"5879841310902324730": ("5458640512498022912", "\U0001f441"),
"5407001145740631266": ("5307559991274651061", "\U0001f5a4"),
"5839354140261619193": ("5303394457113083917", "\U0001f577\ufe0f"),
"5873225338984599714": ("5456542094556414568", "\U0001f431"),
"5373342633798167891": ("5456664526894154591", "\U0001f431"),
"5870921681735781843": ("5353023431283591325", "\u23f3"),
"5325547803936572038": ("5445243616148602729", "\U0001f5b1\ufe0f"),
"5334544901428229844": ("5192859565330296406", "\U0001f595"),
"5361979468887893611": ("5469946464148933378", "\U0001f62d"),
},
_EMOJI_THEME_TROLLFACE: {
"5206607081334906820": ("5422604134861790377", "\U0001f642"),
"5985346521103604145": ("5422750421447891308", "\u2620\ufe0f"),
"5444965220663458467": ("5422846817693884363", "\u263a\ufe0f"),
"5188678912883827293": ("5422732614513484798", "\u2620\ufe0f"),
"5764779661028495989": ("5422611986062006798", "\U0001f617"),
"5206591666697306436": ("5424747649960007887", "\U0001f697"),
"5879841310902324730": ("5422376767883074291", "\U0001f5bc"),
"5407001145740631266": ("5424599752761168855", "\U0001f5bc"),
"5839354140261619193": ("5424794589657586686", "\U0001f603"),
"5873225338984599714": ("5422874331254380688", "\U0001f642"),
"5373342633798167891": ("5422474044597360882", "\U0001f642"),
"5870921681735781843": ("5425146304529454719", "\U0001f4ef"),
"5325547803936572038": ("5422732614513484798", "\u2620\ufe0f"),
"5334544901428229844": ("5422727821329979692", "\U0001f453"),
"5361979468887893611": ("5424679926915683083", "\u2620\ufe0f"),
},
}
_EMOJI_THEME_ERROR_ID_FALLBACKS = {
_EMOJI_THEME_CUTE: {
"\u274c": ("5258506985103436605", "\U0001f97a"),
"*": ("5388955127681942181", "\U0001f970"),
},
_EMOJI_THEME_BLACK: {
"\u274c": ("5193178101579803928", "\U0001f494"),
"*": ("5192941702284864123", "\U0001f642"),
},
_EMOJI_THEME_TROLLFACE: {
"\u274c": ("5424679926915683083", "\u2620\ufe0f"),
"*": ("5422458393736536101", "\u26d1"),
},
}
_EMOJI_THEME_CUSTOM_PREFIX = "custom:"
_EMOJI_THEME_MAX_CUSTOM = 20
_EMOJI_THEME_ID_RE = re.compile(r"^\d{8,25}$")
_EMOJI_THEME_INLINE_ID_RE = re.compile(
r'(?:document_id|emoji-id)=["\']?(?P\d{8,25})["\']?'
)
_EMOJI_THEME_SLOT_SOURCES = {
"success": (("5206607081334906820", "\u2705"),),
"error": (("5121063440311386962", "\U0001f44e"),),
"cancel": (("5121063440311386962", "\u274c"),),
"loading": (("4904936030232117798", "\u2699"),),
"warning": (("4904936030232117798", "\u26a0"),),
"off": (("5985346521103604145", "\u2b1c"),),
"folder": (("5444965220663458467", "\U0001f4c1"),),
"ai": (("5188678912883827293", "\U0001f916"),),
"style": (("5764779661028495989", "\U0001f3a8"),),
"model": (("5206591666697306436", "\U0001f36d"),),
"prompt": (("5879841310902324730", "\u270f"),),
"negative": (("5407001145740631266", "\U0001f910"),),
"device": (("5839354140261619193", "\U0001f6dc"),),
"upload": (("5873225338984599714", "\U0001f4e4"),),
"memory": (("5373342633798167891", "\U0001f4be"),),
"time": (("5870921681735781843", "\u23f1"),),
"heart": (("5325547803936572038", "\u2764"),),
"random": (("5334544901428229844", "\U0001f3b2"),),
"update": (("5361979468887893611", "\U0001f195"),),
}
_EMOJI_THEME_SLOT_ORDER = tuple(_EMOJI_THEME_SLOT_SOURCES.keys())
_GENERATION_TIMEOUT = 7200
_CUPSCALE_TIMEOUT = 1800
_GENERATION_IDLE_WARNING = 360
_QUEUE_POLL_INTERVAL = 5
_HISTORY_POLL_INTERVAL = 5
_CMON_POLL_INTERVAL = 10
_CMON_IDLE_CLOSE_AFTER = 600
_GENERATION_STATS_LIMIT = 30
_CT_PROBE_TIMEOUT = 180
_TUNNEL_CHECK_INTERVAL = 600
_TUNNEL_FAILURE_THRESHOLD = 3
_TUNNEL_WATCH_OWNER_KEY = "tunnel_watch_owner"
_TUNNEL_WATCH_STATE_KEY = "tunnel_watch_state"
_TG_TEXT_LIMIT_DEFAULT = 4096
_TG_TEXT_LIMIT_PREMIUM = 8192
_TG_RICH_TEXT_LIMIT = 32768
_INLINE_TEXT_SOFT_LIMIT = 4096
_INLINE_TEXT_RETRY_LIMITS = (4096, 3500, 3000, 2500, 2000, 1500, 1000)
_PLAIN_TEXT_RETRY_LIMITS_DEFAULT = (4096, 3500, 3000, 2500, 2000, 1500, 1000)
_PLAIN_TEXT_RETRY_LIMITS_PREMIUM = (8192, 4096, 3500, 3000, 2500, 2000, 1500, 1000)
_COMFY_TIMEOUTS = {
"ws_connect": 15,
"queue_prompt": 60,
"queue_status": 10,
"queue_delete": 10,
"interrupt": 10,
"history_request": 15,
"object_info": 15,
"object_info_all": 30,
"retrieve_media": 60,
"upload_image": 60,
"translation": 15,
}
_ANIME_V2_WORKFLOW_NAME = "Anima"
_ANIME_V3_WORKFLOW_NAME = "Anima2"
_ILL_WORKFLOW_NAME = "ill"
_KREA2_WORKFLOW_NAME = "Krea2"
_SDXL_REAL2_WORKFLOW_NAME = "SDXLReal2"
_CLOUD_KREA2_WORKFLOW_NAME = "CKrea2"
_CLOUD_ANIMA_WORKFLOW_NAME = "CAnima"
_CLOUD_ANIMA2_WORKFLOW_NAME = "CAnima2"
_CLOUD_ILL_WORKFLOW_NAME = "Cill"
_CLOUD_QWEN_WORKFLOW_NAME = "CQwen"
_CLOUD_WORKFLOW_COST_ALIASES = {
_KREA2_WORKFLOW_NAME: _CLOUD_KREA2_WORKFLOW_NAME,
_ANIME_V2_WORKFLOW_NAME: _CLOUD_ANIMA_WORKFLOW_NAME,
_ANIME_V3_WORKFLOW_NAME: _CLOUD_ANIMA2_WORKFLOW_NAME,
}
_DEFAULT_WORKFLOW_NAME = _ANIME_V2_WORKFLOW_NAME
_DEFAULT_CLOUD_WORKFLOW_NAME = _CLOUD_KREA2_WORKFLOW_NAME
_GLOBAL_POSITIVE_DEFAULT = ""
_BUILTIN_WORKFLOW_POSITIVE_DEFAULTS = {
_ILL_WORKFLOW_NAME: "embedding:lazypos,",
}
_GLOBAL_NEGATIVE_DEFAULT = "worst quality, low quality, lowres, blurry, jpeg artifacts, sepia, bad anatomy, watermark, artist name,"
_REALISTIC_NEGATIVE_DEFAULT = "text, motion lines, effects, border, frame. (worst quality, low quality, normal quality, lowres, low details, oversaturated, undersaturated, overexposed, underexposed, grayscale, bad photo, bad photography, bad art:1.4)"
_BUILTIN_WORKFLOW_NEGATIVE_DEFAULTS = {
_ANIME_V2_WORKFLOW_NAME: "Score_6, score_5, score_4, worst quality, low quality, jpeg artifacts, blurry, text, watermark, logo, signature, bad anatomy, bad hands, extra limbs, missing limbs, fused fingers, bad face, realistic, photorealistic, 3d, render, flat shading, unnatural shadows, aliasing, distortion, compression artifacts, corrupted, oversaturated, washed out colors",
_ANIME_V3_WORKFLOW_NAME: "Score_6, score_5, score_4, worst quality, low quality, jpeg artifacts, blurry, text, watermark, logo, signature, bad anatomy, bad hands, extra limbs, missing limbs, fused fingers, bad face, realistic, photorealistic, 3d, render, flat shading, unnatural shadows, aliasing, distortion, compression artifacts, corrupted, oversaturated, washed out colors",
_SDXL_REAL2_WORKFLOW_NAME: _REALISTIC_NEGATIVE_DEFAULT,
}
_BUILTIN_WORKFLOW_URLS = {
_ANIME_V2_WORKFLOW_NAME: _ANIME_V2_WF_URL,
_ANIME_V3_WORKFLOW_NAME: _ANIME_V3_WF_URL,
_ILL_WORKFLOW_NAME: _ILL_WF_URL,
_KREA2_WORKFLOW_NAME: _KREA2_WF_URL,
_SDXL_REAL2_WORKFLOW_NAME: _SDXL_REAL2_WF_URL,
}
_CLOUD_WORKFLOW_URLS = {
_CLOUD_KREA2_WORKFLOW_NAME: _CLOUD_KREA2_WF_URL,
_CLOUD_ANIMA_WORKFLOW_NAME: _CLOUD_ANIMA_WF_URL,
_CLOUD_ANIMA2_WORKFLOW_NAME: _CLOUD_ANIMA2_WF_URL,
_CLOUD_ILL_WORKFLOW_NAME: _CLOUD_ILL_WF_URL,
_CLOUD_QWEN_WORKFLOW_NAME: _CLOUD_QWEN_WF_URL,
}
_BUILTIN_WORKFLOW_TELEGRAM_URLS = {
_ANIME_V2_WORKFLOW_NAME: "https://t.me/comfystorage/15",
_ANIME_V3_WORKFLOW_NAME: "https://t.me/comfystorage/37",
_ILL_WORKFLOW_NAME: "https://t.me/comfystorage/12",
_KREA2_WORKFLOW_NAME: "https://t.me/comfystorage/38",
_SDXL_REAL2_WORKFLOW_NAME: "https://t.me/comfystorage/18",
}
_CLOUD_WORKFLOW_TELEGRAM_URLS = {
_CLOUD_KREA2_WORKFLOW_NAME: "https://t.me/comfystorage/39",
_CLOUD_ANIMA_WORKFLOW_NAME: "https://t.me/comfystorage/40",
_CLOUD_ANIMA2_WORKFLOW_NAME: "https://t.me/comfystorage/41",
_CLOUD_ILL_WORKFLOW_NAME: "https://t.me/comfystorage/30",
_CLOUD_QWEN_WORKFLOW_NAME: "https://t.me/comfystorage/42",
}
_UPSCALE_WORKFLOW_TELEGRAM_URL = "https://t.me/comfystorage/21"
_CLOUD_UPSCALE_WORKFLOW_TELEGRAM_URL = "https://t.me/comfystorage/33"
_CLOUD_VIDEO_UPSCALE_WORKFLOW_TELEGRAM_URL = "https://t.me/comfystorage/34"
_BGRM_WORKFLOW_TELEGRAM_URL = "https://t.me/comfystorage/25"
_FRAMES_WORKFLOW_TELEGRAM_URL = "https://t.me/comfystorage/26"
_CTOOL_UPSCALE = "upscale"
_CTOOL_VIDEO_UPSCALE = "video_upscale"
_CTOOL_RMBG = "rmbg"
_CTOOL_FPS = "fps"
_ARCHIVE_MAX_PENDING = 2
_PROGRESS_BAR_ICON = ("5240115703213759589", "😀")
_PROGRESS_BAR_FILLED = (
("5240246089830933546", "😆"),
("5240312631759251539", "😗"),
("5240417115428661680", "😜"),
("5240080158064420239", "🤨"),
("5240265533147884330", "🙃"),
)
_PROGRESS_BAR_EMPTY = ("5240434196513596897", "🥶")
@loader.tds
class ComfyImageGenMod(loader.Module):
"""Image generation module via ComfyUI
Модуль генерации изображений через ComfyUI.
Примеры генераций через модуль можно посмотреть здесь: @comfyideas.
"""
strings = {
"name": "ComfyImageGen",
"cfg_url": "ComfyUI Base URL (e.g., http://127.0.0.1:8188)",
"cfg_backend": "ComfyUI backend: local or cloud",
"cfg_cloud_api_key": "ComfyUI Cloud API key(s). Use comma to add multiple keys.",
"cfg_model": "Default model file (e.g., waiIllustriousSDXL_v170)",
"cfg_max_mb": "Max input image size in MB for img2img",
"cfg_max_output_mb": "Max output media size in MB",
"cfg_max_image_pixels": "Max pixels for images sent as Telegram photos. 0 disables the limit",
"cfg_output_format": "Result output format: photo (Telegram compressed image), document_png (PNG file lossless)",
"cfg_ws_update_interval": "Generation status update interval in seconds: 1-5, 0 disables",
"cfg_gemini_api_key": "Gemini API key(s) for AI prompt enhancement. Use comma to add multiple keys.",
"cfg_groq_api_key": "Groq API key(s) for AI prompt enhancement. Use comma to add multiple keys.",
"cfg_openrouter_api_key": "OpenRouter API key(s) for AI prompt enhancement. Use comma to add multiple keys.",
"cfg_grok_api_key": "Grok/xAI API key(s) for AI prompt enhancement. Use comma to add multiple keys.",
"cfg_deepseek_api_key": "DeepSeek API key(s) for AI prompt enhancement. Use comma to add multiple keys.",
"cfg_nvidiaapi_api_key": "NVIDIA API key(s) for AI prompt enhancement. Use comma to add multiple keys.",
"cfg_qwen_api_key": "Qwen (Model Studio) API key(s) for AI prompt enhancement. Use comma to add multiple keys.",
"cfg_qwen_base_url": "Qwen Model Studio OpenAI-compatible base URL. Keep the default for the international endpoint; set your workspace URL for another region.",
"cfg_update_assets": "Background module assets update interval in seconds. 0 disables it. Range: 60-14400.",
"cfg_info_banner_url": "Banner URL for .ci and .chelp inline menus. Set 0 to disable.",
"lora_title": '\U0001f3a8 LoRA selection',
"lora_prompt_label": "Prompt",
"lora_page": "Page {}/{}",
"lora_detail_title": '\u2699 {}\nWeight: {:.1f}\nStatus: {}',
"lora_favorite_add": "\u2606 Add to favorites",
"lora_favorite_remove": "\u2605 Remove from favorites",
"lora_filter_all": "\u2637 All",
"lora_filter_favorites": "\u2605 Favorites",
"lora_filter_imported": "\U0001f4e5 Imported",
"lora_no_favorites": "No favorite LoRA models yet.",
"lora_no_imported": "No imported LoRA models found.",
"lora_search_btn": "\U0001f50e Search",
"lora_search_clear": "\u2716 Clear search",
"lora_search_input": "Enter a LoRA name or part of its name:",
"lora_search_label": "Search: {}",
"lora_search_empty": "No LoRA models match this search.",
"lora_note_label": "Note: {}",
"lora_note_btn": "\U0001f4dd Note",
"lora_note_delete": "\U0001f5d1 Delete note",
"lora_note_input": "Enter a note for this LoRA (up to 500 characters):",
"lora_note_saved": "LoRA note saved",
"lora_note_deleted": "LoRA note deleted",
"lora_triggers_label": "Triggers: {}",
"lora_triggers_empty": "Triggers: not set",
"lora_triggers_btn": "✍ Set triggers",
"lora_triggers_input": "Enter trigger words separated by commas or new lines:",
"lora_triggers_saved": "LoRA triggers saved",
"lora_auto_triggers_on": "✓ Automatically add triggers",
"lora_auto_triggers_off": "□ Automatically add triggers",
"lora_civitai_btn": "🌐 Load from Civitai",
"lora_civitai_input": "Enter a Civitai model/version ID or a model link:",
"lora_civitai_saved": "Civitai metadata loaded",
"lora_civitai_failed": "Civitai model or version was not found.",
"lora_civitai_base_model": "Base model: {}",
"lora_cloud_unavailable": "Cloud does not currently expose these LoRA models to the workflow: {}",
"lora_apply_unsupported": "This workflow has no supported LoRA node.",
"lora_apply_slots": "This workflow has only {} LoRA slot(s), but {} LoRA model(s) are selected.",
"lora_on": '\u2705 Enabled',
"lora_off": '\u2b1c Disabled',
"lora_loading": "\u2699\ufe0f Loading LoRA list from ComfyUI...",
"lora_load_failed": "\U0001f44e Failed to load LoRA list from ComfyUI.",
"lora_none_available": "No LoRA models found on ComfyUI server.",
"preflight_preparing": "\u2699\ufe0f Preparing generation...",
"preflight_workflow": "\u2699\ufe0f Loading workflow...",
"preflight_model": "\u2699\ufe0f Preparing model...",
"preflight_image": "\u2699\ufe0f Preparing input image...",
"preflight_launch": "\u2699\ufe0f Starting generation...",
"fmt_loras": "LoRA:",
"fmt_loras_more": "and {} more",
"no_url": "\U0001f44e ComfyUI URL is not specified. Use .cfg ComfyImageGen comfyui_url",
"cloud_no_key": "\U0001f44e ComfyUI Cloud API key is not set. Open .cmode and add a key.",
"cloud_bad_key": "\U0001f44e ComfyUI Cloud API key is invalid or has no access.",
"cloud_no_balance": "\U0001f44e ComfyUI Cloud has no credits/balance for this request.",
"cloud_rate_limit": "\U0001f44e ComfyUI Cloud limit or subscription restriction. Try later or use another key.",
"cloud_unavailable": "\U0001f44e ComfyUI Cloud is temporarily unavailable.",
"cloud_workflow_unsupported": "\U0001f44e This workflow is not supported in ComfyUI Cloud: required nodes are not installed.\n\n{}
",
"cloud_media_unsupported": "\U0001f44e This media input is not supported by ComfyUI Cloud.",
"mode_title": '\U0001f6dc ComfyUI backend mode',
"mode_current": "Current mode: {}",
"mode_local": "Local ComfyUI",
"mode_cloud": "ComfyUI Cloud",
"mode_local_url": "Local URL: {}",
"mode_cloud_keys": "Cloud API keys: {}",
"mode_keys_set": "{} set",
"mode_keys_missing": "not set",
"onboarding_title": '👋 Welcome to ComfyImageGen',
"onboarding_prompt": "Choose how you want to generate images:",
"onboarding_local": "Local ComfyUI\nLocal generation - install ComfyUI on your PC first; see the guide for details.",
"onboarding_cloud": "ComfyUI Cloud\nGeneration with an API key. For a comfortable setup, you need a Creator subscription and the models listed in workflow descriptions downloaded to the right folders. More resources are here.",
"onboarding_btn_local": "💻 Local ComfyUI",
"onboarding_btn_cloud": "☁️ ComfyUI Cloud",
"onboarding_saved": "Backend selected. Opening help…",
"mode_balance": "Balance: {}",
"mode_balance_unavailable": "unavailable",
"mode_balance_no_key": "key not set",
"mode_btn_local": "Local",
"mode_btn_cloud": "Cloud",
"mode_btn_url": "Local URL",
"mode_btn_key": "API keys",
"mode_btn_check": "Check",
"mode_btn_balance": "Balance",
"mode_input_url": "Enter local ComfyUI URL:",
"mode_input_key": "Enter ComfyUI Cloud API key(s), comma-separated:",
"mode_saved": "Mode saved: {}",
"mode_url_saved": "Local URL saved",
"mode_key_saved": "Cloud API key(s) saved",
"mode_check_ok": "Connection OK.",
"mode_check_fail": "Connection failed.",
"mode_local_url_set": "Local URL: set",
"mode_local_url_missing": "Local URL: not set",
"tunnel_notify_status": "Tunnel notification: {}",
"tunnel_available": '✅ ComfyUI tunnel is available.',
"tunnel_menu_title": '📁 Tunnel notifications',
"tunnel_menu_desc": "The local tunnel is checked every 10 minutes. A notification is sent only when it becomes available.",
"tunnel_targets": "Enabled in:\n{}",
"tunnel_target_bot_pm": "bot private messages",
"tunnel_targets_empty": "Not enabled anywhere.",
"tunnel_btn_add_chat": "Add chat",
"tunnel_btn_add_bot_pm": "Add bot private messages",
"tunnel_btn_remove_target": "Remove target",
"tunnel_btn_clear_targets": "Clear targets",
"tunnel_target_input": "Enter chat_id, chat_id topic_id, chat_id:topic_id, or a t.me/c link:",
"tunnel_target_bad": '👎 Could not parse chat/topic.',
"tunnel_target_bind_failed": '👎 The inline bot cannot access this target: {}',
"tunnel_target_bound": '✅ Notification target added.',
"tunnel_target_already_bound": '✅ This notification target is already added.',
"tunnel_target_removed": '✅ Notification target removed.',
"tunnel_targets_cleared": '✅ Notification targets cleared.',
"cdown_title": '📤 Cloud model downloader',
"cdown_type": "Type: {}",
"cdown_url": "URL: {}",
"cdown_url_missing": "URL: not set",
"cdown_file": "File: {}",
"cdown_size": "Size: {}",
"cdown_validation_ok": '✅ Validation: OK',
"cdown_validation_fail": '👎 Validation: {}',
"cdown_folder": "Cloud folder: {}",
"cdown_folder_unknown": "Cloud folder was not found in model folders; download can still be started.",
"cdown_result_ready": '✅ Model is ready in "My models".',
"cdown_result_started": '📤 Background download started.',
"cdown_result_failed": '👎 Download failed: {}',
"cdown_task": "Task: {}",
"cdown_task_status": "Import status: {}",
"cdown_task_waiting": "The model will appear in \"My models\" after the task is complete.",
"cdown_btn_url": "Set URL",
"cdown_btn_install": "Install",
"cdown_btn_refresh": "Refresh status",
"cdown_input_url": "Send Hugging Face or Civitai download URL:",
"cdown_no_key": "ComfyUI Cloud API key is not set. Open .cmode and add a key.",
"cdown_need_url": "Set URL first.",
"cdown_need_valid": "Fix URL validation before install.",
"cdown_checking": "Checking URL...",
"cdown_installing": "Starting download...",
"cdown_bad_url": "Only huggingface.co, civitai.com and civitai.red URLs are supported.",
"clib_title": '📁 Cloud model library',
"clib_summary": "Model assets: {} · categories: {}",
"clib_folder_title": "Category: {}",
"clib_name": "Model: {}",
"clib_category": "Category: {}",
"clib_tags": "Tags: {}",
"clib_empty": "No imported model assets found.",
"clib_btn_refresh": "🔄 Refresh",
"clib_btn_move": "Move category",
"clib_btn_delete": "🗑 Delete",
"clib_delete_title": "Delete model?",
"clib_delete_confirm": "🗑 Delete permanently",
"clib_deleted": "Model removed from Cloud library.",
"clib_deleting": "Deleting model...",
"clib_move_title": "Choose the model category",
"clib_loading": "Loading Cloud model library...",
"clib_updating": "Updating category...",
"clib_moved": "Model category updated.",
"clib_immutable": "This asset is immutable and cannot be updated.",
"clib_no_key": "ComfyUI Cloud API key is not set. Open .cmode and add a key.",
"cdown_type_checkpoint": "Checkpoint",
"cdown_type_lora": "LoRA",
"cdown_type_vae": "VAE",
"cdown_type_controlnet": "ControlNet",
"cdown_type_upscaler": "Upscaler",
"cdown_type_text_encoder": "Text Encoder",
"cdown_type_unet": "UNET",
"cdown_type_clip_vision": "CLIP Vision",
"cdown_type_ipadapter": "IPAdapter",
"cdown_type_style_model": "Style Model",
"cdown_type_model_patch": "Model Patch",
"cdown_type_sam": "SAM",
"cloud_confirm_title": '\U0001f6dc ComfyUI Cloud',
"cloud_confirm_balance": "Balance: {}",
"cloud_confirm_cost": "Cost: {}",
"cloud_confirm_cost_unavailable": "unavailable",
"cloud_confirm_batch": "Batch: {}",
"cloud_confirm_workflow": '\U0001f4c1 Workflow: {}',
"cloud_confirm_model": '\U0001f36d Model: {}',
"cloud_confirm_prompt": "Prompt:",
"cloud_confirm_btn_generate": "Generate",
"cloud_confirm_btn_batch": "Batch: {}",
"cloud_confirm_btn_edit": "Edit prompt",
"cloud_confirm_input_batch": "Enter batch size (1-8):",
"cloud_confirm_batch_saved": "Batch set: {}",
"cloud_confirm_batch_bad": "Batch must be a number from 1 to 8.",
"no_prompt": "\U0001f44e Please provide a prompt.",
"prompt_empty": "No prompt",
"status_civitai_inspire": "\U0001f3b2 Getting a random Civitai prompt...",
"civitai_no_prompt": "\U0001f44e Could not find a random Civitai prompt.",
"civitai_error": "\U0001f44e Failed to get a prompt from Civitai.",
"connecting": "\u2699\ufe0f Connecting to ComfyUI...",
"connecting_retry": "ComfyUI did not respond, retrying connection... {}/{}",
"uploading": "\U0001f4e4 Uploading result...",
"success": "\u2705 Generated!\n\u270f\ufe0f Prompt: {}\n\U0001f910 Negative: {}\n\U0001f36d Model: {}\n\u2699\ufe0f Workflow: {}",
"timeout": "\U0001f44e Generation timeout.",
"unavailable": "\U0001f44e ComfyUI is unavailable.",
"img_too_large": "\U0001f44e Input image is too large (max. {} MB).",
"output_too_large": "\U0001f44e Output media is too large (max. {} MB).",
"image_too_many_pixels": "\U0001f44e Image is too large to send as a photo (max. {} MP). Send it as document PNG or reduce its size.",
"no_reply_photo": "\U0001f44e Reply to a photo for img2img.",
"album_picker_title": "Select an image from the album",
"album_picker_desc": "The album contains {} images.",
"album_picker_button": "Photo {}",
"album_picker_expired": "Image selection has expired. Run the command again.",
"album_picker_unavailable": "Could not load the selected album image.",
"wf_not_found": "\U0001f44e Workflow '{}' not found. Available: {}",
"add_wf_no_reply": "\U0001f44e Reply to a JSON file or put a Comfy Cloud share link after the workflow name.",
"add_wf_bad_json": "\U0001f44e Invalid JSON workflow file.",
"add_wf_cloud_share_bad": "\U0001f44e Invalid or unavailable Comfy Cloud share link.",
"add_wf_cloud_share_loading": "\u2699\ufe0f Loading shared Comfy Cloud workflow...",
"wf_file_too_large": "\U0001f44e Workflow JSON is too large. Max size: 10 MB.",
"add_wf_ok": "\u2705 Workflow '{}' added.",
"add_wf_exists": "\U0001f44e Workflow '{}' already exists.",
"add_wf_no_name": "\U0001f44e Specify the workflow name to add.",
"add_wf_force_btn": "\u2705 Add anyway",
"add_wf_forced_note": "\u26a0\ufe0f Added despite validation errors.",
"add_wf_force_expired": "\U0001f44e This add workflow request expired. Run addwf again.",
"del_wf_ok": "\u2705 Workflow '{}' deleted.",
"del_wf_all_ok": "\u2705 Deleted all custom workflows: {}.",
"del_wf_fail": "\U0001f44e Workflow '{}' not found.",
"del_wf_builtin": "\U0001f44e Cannot delete built-in workflow '{}'.",
"wf_title": '\U0001f4c1 Select workflow',
"wf_builtin_btn": "\U0001f4e6 Built-in",
"wf_custom_btn": "\U0001f4dd Custom",
"wf_cloud_btn": "Cloud workflows",
"wf_list_title_cloud": '\U0001f4c1 Cloud workflows',
"toast_no_cloud_wf": "No Cloud workflows yet.",
"wf_list_title_builtin": '\U0001f4c1 Built-in workflows',
"wf_list_title_custom": '\U0001f4c1 Custom workflows',
"wf_desc_anime_v2": "Workflow made for MiaoMiao Harem [https://civitai.red/models/934764/miaomiao-harem?modelVersionId=3248362].",
"wf_desc_anime_v3": "Workflow made for MiaoMiao Harem (Anime Coloring) [https://civitai.red/models/934764/miaomiao-harem?modelVersionId=3203207].",
"wf_desc_ill": "Anime image generation (based on [https://civitai.red/models/376130/nova-anime-xl?modelVersionId=2940478]).",
"wf_desc_krea2": "Workflow made for the LUSTIFY model: strong realistic generations and weaker anime generations. [https://civitai.red/models/573152/lustify-nsfw-checkpoint?modelVersionId=3112728] (Uses a Q4 model; you can fork the workflow to use another one).",
"wf_desc_sdxl_real2": "Realistic SDXL 1.0 workflow made for xxxRay_dmd2 [https://civitai.red/models/1064836/xxx-ray].",
"wf_desc_cloud_krea2": "Cloud workflow made for the LUSTIFY model: strong realistic generations and weaker anime generations. [https://civitai.red/models/573152/lustify-nsfw-checkpoint?modelVersionId=3112728] (3-5 credits per generation)",
"wf_desc_cloud_anima": "Cloud workflow made for the MiaoMiao Harem model [https://civitai.red/models/934764/miaomiao-harem?modelVersionId=3248362]. (2-4 credits per generation).",
"wf_desc_cloud_anima2": "Cloud workflow made for the MiaoMiao Harem model with anime coloring [https://civitai.red/models/934764/miaomiao-harem?modelVersionId=3203207]. (2-4 credits per generation).",
"wf_desc_cloud_ill": "Cloud anime image generation (based on [https://civitai.red/models/376130/nova-anime-xl?modelVersionId=2940478]) (2-4 credits per generation).",
"wf_desc_cloud_qwen": "Image editing (based on the Qwen-Rapid-AIO-NSFW-v11.1 model [https://huggingface.co/Phr00t/Qwen-Image-Edit-Rapid-AIO/blob/main/v11/Qwen-Rapid-AIO-NSFW-v11.1.safetensors]) (4-7 credits per generation).",
"wf_page": "Page {}/{}",
"wf_current": "Current: {}",
"wf_limited_hint": 'Second tap on a workflow enables 🔵 limited mode. Only positive prompt, negative prompt, and media inputs remain editable. This beta feature is mainly made for video generation.',
"wf_limited_set": "Workflow set: {} (limited mode)",
"toast_wf_limited_on": "Limited mode enabled: {}",
"toast_wf_limited_off": "Limited mode disabled: {}",
"info_title": "\u2699\ufe0f ComfyUI Status",
"ci_loading": "Checking ComfyUI...",
"info_ok": "\u2705 Connected",
"info_fail": "\U0001f44e Unavailable",
"info_model": "\U0001f36d Model: {}",
"info_wf": "\U0001f4c1 Current Workflow: {}",
"info_gpu": "\U0001f6dc GPU: {}",
"info_cpu": "\U0001f6dc CPU: {}",
"info_device": "\U0001f6dc Device: {}",
"info_no_device": "not detected",
"info_vram": "\U0001f4be VRAM: {} / {}",
"info_ram": "\U0001f4be RAM: {} / {}",
"info_version": "Version: {}",
"info_python": "Python: {}",
"info_pytorch": "PyTorch: {}",
"info_frontend": "Frontend: {}",
"info_total_generations": "Total generations: {}",
"info_userbot_ping": "Userbot ping: {} ms",
"info_backend": "Mode: {}",
"info_balance": "Balance: {}",
"ct_checking": "\u2699\ufe0f Checking ComfyUI tunnel API...",
"ct_title": "\u2699\ufe0f ComfyUI tunnel probe",
"ct_url": "URL: {}",
"ct_status": "Status: {}",
"ct_ok": "\u2705 OK",
"ct_fail": "\U0001f44e Failed",
"ct_no_checks": "No checks were run.",
"ct_bad_url": "\U0001f44e Invalid URL.",
"ct_upload_failed": "\U0001f44e Probe image generated, but Telegram upload failed: {}",
"no_images": "\U0001f44e No images in ComfyUI response.",
"no_mapping_pos": "\U0001f4e3 Could not find positive prompt node in workflow.",
"models_title": '\U0001f3a8 Select ComfyUI model',
"models_loading": "Loading ComfyUI models...",
"models_page": "Page {}/{}",
"models_set": "\u2705 Model set: {}",
"models_empty": "\U0001f44e No checkpoint or UNET models found.",
"models_manual_btn": "\u270f\ufe0f Enter manually",
"models_manual_input": "Enter model filename:",
"models_search_btn": "\U0001f50e Search",
"models_search_clear": "\u2716 Clear search",
"models_search_input": "Enter a model name or part of its name:",
"models_search_label": "Search: {}",
"models_search_empty": "No models match this search.",
"models_as_workflow_btn": "As in workflow",
"models_as_workflow": "Cloud model: as in workflow ({})",
"toast_model_as_workflow": "Cloud model: as in workflow",
"models_cloud_title": '🎨 Select ComfyUI Cloud model',
"models_cloud_current": "Current: {}",
"models_cloud_default_btn": "Cloud default",
"models_cloud_custom_btn": "My models",
"models_cloud_default_title": "Cloud default folders",
"models_cloud_custom_title": "My model folders",
"models_cloud_folder_title": "Folder: {}",
"models_cloud_empty_folders": "No model folders found.",
"models_cloud_empty_models": "No models in this folder.",
"models_cloud_empty_custom": "No imported model assets found.",
"setwf_ok": "Workflow set: {}",
"mlwf_no_name": "\U0001f44e Specify workflow name to export.",
"mlwf_not_found": "\U0001f44e Workflow '{}' not found.",
"mlwf_success": "\u2705 Workflow '{}' exported.",
"unexpected_comfy_response": "ComfyUI returned unexpected content. Check ComfyUI logs for errors.",
"enhance_no_key": "\U0001f44e {} API key not set. Open {}",
"enhance_dependency_missing": "\U0001f44e google-genai is not installed. Install module requirements or reinstall the module.",
"enhance_key_expired": "\U0001f44e {} API key is invalid or exhausted.",
"enhance_censored": "\U0001f44e {} blocked the prompt (censorship). Try another provider.",
"enhance_rate_limit": "\U0001f44e {} rate limit exceeded. Try again later.",
"enhance_timeout": "\U0001f44e {} did not respond in time while enhancing the prompt. Try again later.",
"enhance_service_error": "\U0001f44e {} returned an unexpected error while enhancing the prompt. Try again later.",
"enhance_vision_unsupported": "\U0001f44e The selected AI provider/model ({}) does not support image input. Use a vision-capable provider/model or run without -ai.",
"enhance_error": "\U0001f44e {} error during prompt enhancement: {}",
"enhance_cmd_title": '\U0001f916 Enhanced prompt',
"enhance_cmd_provider": "Provider: {}",
"enhance_cmd_model": "AI model: {}",
"enhance_cmd_original": "Original prompt:",
"enhance_cmd_result": "Enhanced prompt:",
"enhanced_label": '\U0001f916 AI prompt:',
"enhance_chat_title": "AI prompt edit [{}/100]",
"enhance_chat_edit_btn": "\u270f\ufe0f Edit prompt",
"enhance_chat_input": "What should be changed in the prompt?",
"enhance_chat_limit": "\U0001f44e Edit limit reached: 100/100.",
"enhance_chat_empty": "\U0001f44e Send what should be changed.",
"comments_generation_disabled": "\u26a0\ufe0f Generation in post comments is unavailable. Run the command in a regular chat or PM.",
"err_connection": "\U0001f44e Failed to connect to ComfyUI. Make sure the server is running and the URL is correct.",
"err_node_missing": "\U0001f44e Component '{}' is not installed in ComfyUI. Install the missing custom node.",
"err_model_not_found": "\U0001f44e Model '{}' not found on server. Check the name or download the model.",
"err_model_value_not_in_list": "\U0001f44e Selected model does not exist or is not suitable for this workflow.\n\nModel: {}\nAvailable models:\n{}",
"cloud_model_not_found": "\U0001f44e This model is not downloaded in ComfyUI Cloud.",
"err_vram": "\U0001f44e Insufficient VRAM. Try reducing image size or number of steps.",
"err_image_invalid": "\U0001f44e Failed to process image. File is corrupted or format is not supported.",
"err_img2img_unsupported": "\U0001f44e This workflow does not support img2img. Add an image input or latent switch node.",
"err_upload_failed": "\U0001f44e Failed to upload image to ComfyUI server.",
"err_retrieve_failed": "\U0001f44e Failed to retrieve generation result from server.",
"err_send_failed": "\U0001f44e Failed to send result to Telegram. Check chat permissions or try document_png.",
"err_workflow_invalid": "\U0001f44e Workflow is corrupted or contains errors.",
"err_workflow_invalid_details": "\U0001f44e Workflow validation failed:\n{}",
"err_vae_not_found": "\U0001f44e Could not determine VAE for img2img. Check your workflow.",
"err_prompt_queue": "\U0001f44e ComfyUI rejected the generation task. Check server logs.",
"err_execution": "\U0001f44e Error during generation in ComfyUI. Check server logs.",
"err_none_input": "\U0001f44e One of the workflow components did not receive input data. Make sure all models are loaded and nodes are connected correctly.",
"err_generic": "\U0001f44e An unexpected error occurred. Details in logs.",
"err_workflow_download": "\U0001f44e Failed to download built-in workflow. Check internet connection.",
"err_server_unavailable": "\U0001f44e ComfyUI temporarily unavailable (502/503/504). Server is overloaded or restarting. Try again in a minute.",
"status_enhancing": "\u2764\ufe0f Enhancing prompt with AI...",
"argset_title": '\u2699\ufe0f Default generation arguments',
"argset_limited_mode": "🔵 Limited workflow mode is enabled. Workflow-changing settings are ignored.",
"argset_params": '\U0001f3a8 Generation parameters (defaults come from the current workflow)',
"argset_enhancements": '\U0001f916 Enhancements',
"argset_on": '\u2705',
"argset_off": '\u2b1c',
"argset_input_width": "Enter Width (64-4096):",
"argset_input_height": "Enter Height (64-4096):",
"argset_input_steps": "Enter Steps (1-100):",
"argset_input_cfg": "Enter CFG (1.0-30.0):",
"argset_input_denoise": "Enter Denoise (0.0-1.0):",
"argset_input_sampler_name": "Enter sampler_name:",
"argset_input_scheduler": "Enter scheduler:",
"label_sampler_name": "Sampler",
"label_scheduler": "Scheduler",
"argset_choice_workflow": "In workflow: {}",
"argset_choice_used": "Used: {}",
"argset_choice_as_workflow": "As in workflow",
"argset_choice_custom": "\u270d Custom",
"argset_choice_clear": "\U0001f5d1 Clear",
"argset_choice_saved": "Saved: {}",
"argset_pin_model": "📌 Pin for model",
"argset_pin_model_ok": "Pinned for model: {}",
"provider_title": '\U0001f916 AI models and prompts',
"provider_menu_intro": "Select a provider to configure its model, API key, and enhancement prompt.",
"provider_current": "Current: {}",
"provider_status": "Status: {}",
"provider_selected": '\u2705 Selected',
"provider_not_selected": '\u2b1c Not selected',
"provider_api_key": "API key: {}",
"provider_api_key_set": '\u2705 set',
"provider_api_key_missing": '\u2b1c not set',
"provider_api_key_not_required": "not required",
"provider_comfy_text_info": "Uses the active ComfyUI backend; no external API key is required.",
"provider_comfy_text_template": "The workflow prompt and dynamic USER_PROMPT/TARGET_MODEL placeholders are used.",
"provider_comfy_text_unavailable": "ComfyUI Text is unavailable. Check that CLIPLoader, TextGenerate and PreviewAny are installed and that the text model is present.",
"provider_comfy_text_empty": "ComfyUI Text returned an empty response. Try again or check the workflow/model.",
"provider_comfy_text_failed": "ComfyUI Text could not enhance the prompt. Check the ComfyUI queue and server logs.",
"provider_comfy_text_error": "ComfyUI Text error: {}",
"provider_comfy_text_invalid_workflow": "ComfyUI Text workflow must contain USER_PROMPT, TARGET_MODEL and a PreviewAny output.",
"comfy_text_workflow_section": '📁 Text workflow',
"comfy_text_workflow_source": "Source: {}",
"comfy_text_workflow_current_url": "URL: {}",
"comfy_text_workflow_source_default": "default",
"comfy_text_workflow_source_custom": "custom",
"comfy_text_workflow_btn_set": "✍ Set link",
"comfy_text_workflow_btn_reset": "🔄 Reset default",
"comfy_text_workflow_btn_download": "📄 Download",
"comfy_text_workflow_input_url": "Enter a link to the ComfyUI Text workflow:",
"comfy_text_workflow_saved": "Workflow link saved",
"comfy_text_workflow_reset": "Workflow link reset to default",
"comfy_text_workflow_invalid_url": "Invalid workflow link",
"comfy_text_workflow_download_failed": "Could not download the workflow",
"comfy_text_workflow_download_failed_detail": "Could not download the workflow: {}",
"comfy_text_workflow_file_caption": "Current ComfyUI Text workflow",
"provider_model": "Model: {}",
"provider_btn_select": "\u2705 Select",
"provider_btn_api_key": "\U0001f511 API key",
"provider_btn_model": "\U0001f9e0 Model",
"provider_btn_menu": "\U0001f916 AI models",
"enhance_prompt_current_url": "URL: {}",
"enhance_prompt_section": "\U0001f4dd Model prompt",
"enhance_prompt_source": "Source: {}",
"enhance_prompt_source_default": "default",
"enhance_prompt_source_custom": "custom",
"enhance_prompt_btn_set": "\u270d Set URL",
"enhance_prompt_btn_reset": "\U0001f504 Reset to default",
"enhance_prompt_btn_download": "\U0001f4c4 Download",
"enhance_prompt_input_url": "Enter prompt URL for {}:",
"enhance_prompt_saved": "Prompt URL saved",
"enhance_prompt_reset": "Prompt URL reset to default",
"enhance_prompt_invalid_url": "Invalid URL",
"enhance_prompt_download_failed": "Failed to download prompt",
"enhance_prompt_file_caption": "Enhance prompt for {}",
"provider_input_api_key": "Enter API key for {}:",
"provider_input_model": "Enter model:",
"provider_saved": "Provider selected: {}",
"provider_key_saved": "API key saved",
"provider_model_saved": "Model saved",
"lora_presets_title": '\U0001f3a8 LoRA presets',
"lora_presets_empty": "No LoRA presets selected.",
"lora_presets_selected": "Selected: {}",
"lora_presets_clear": "\U0001f5d1 Clear all",
"lora_presets_saved": "LoRA presets saved",
"lora_weight_btn": "\u270d Weight",
"lora_weight_input": "Enter LoRA weight (0.1-2.0):",
"lora_weight_saved": "LoRA weight saved",
"label_lora_presets": "LoRA presets",
"positive_menu_title": '\u270f\ufe0f Positive prompts',
"positive_global": "Global positive",
"positive_workflows": "Workflow positives",
"positive_current": "Current:",
"positive_custom": "Custom:",
"positive_global_label": "Global:",
"positive_btn_global": "\U0001f310 Global positive",
"positive_btn_set": "\u270d Set new",
"positive_btn_reset": "\U0001f504 Reset to default",
"positive_btn_clear": "\U0001f5d1 Clear",
"positive_input_global": "Enter global positive prompt:",
"positive_input_workflow": "Enter positive prompt for {}:",
"positive_saved": "Positive prompt saved",
"positive_reset": "Positive prompt reset",
"positive_cleared": "Positive prompt cleared",
"negative_menu_title": '\U0001f910 Negative prompts',
"negative_global": "Global negative",
"negative_workflows": "Workflow negatives",
"negative_source_custom": "custom",
"negative_source_global": "global",
"negative_source_workflow": "workflow",
"negative_source_empty": "empty",
"negative_current": "Current:",
"negative_custom": "Custom:",
"negative_global_label": "Global:",
"negative_workflow_default": "Workflow default:",
"negative_source": "Source: {}",
"negative_not_set": "not set",
"negative_btn_global": "\U0001f310 Global negative",
"negative_btn_set": "\u270d Set new",
"negative_btn_reset": "\U0001f504 Reset to default",
"negative_btn_clear": "\U0001f5d1 Clear",
"negative_input_global": "Enter global negative prompt:",
"negative_input_workflow": "Enter negative prompt for {}:",
"negative_saved": "Negative prompt saved",
"negative_reset": "Negative prompt reset",
"negative_cleared": "Negative prompt cleared",
"err_reserved_wf": "\U0001f44e Name 'i2i' is reserved by built-in module. Try another.",
"del_wf_no_name_with_list": "\U0001f44e Specify workflow name to delete.\n\nAvailable custom workflows:\n{}\n\nExample:\n.delwf {}",
"del_wf_no_custom": "\U0001f44e No custom workflows to delete.",
"repeat_no_last": "\U0001f44e No saved generation to repeat.",
"progress": "\u2699\ufe0f Generating... {}%\n\u270f\ufe0f Prompt: {}\n\U0001f910 Negative: {}\n\U0001f36d Model: {}\n\u2699\ufe0f Workflow: {}",
"inline_uploading": '\U0001f4e4 Uploading result...',
"cancelled": '\U0001f44e Generation cancelled.',
"cancel_btn": "\u274c Cancel",
"help_text": (
"ComfyImageGen guide\n\n"
'1. Установка ComfyUI\n'
'2. Как генерировать?\n'
'3. Терминология\n'
'4. .comfy\n'
'5. .cshare\n'
'6. .ctools\n'
'7. .setarg\n'
'8. .ultcomfy\n'
'9. Свой вф\n'
'10. Ссылочки'
),
"fmt_generating": "Generating...",
"fmt_generating_pct": "Generating... {}%",
"fmt_loading_model": "Loading model...",
"fmt_encoding_prompt": "Encoding prompt...",
"queue_local_waiting": "Waiting in generation queue...",
"queue_comfy_submitted": "Task submitted to ComfyUI. Waiting for status... (WebSocket may be unavailable)",
"queue_comfy_pending": "Waiting in ComfyUI queue... Position: {}",
"queue_comfy_pending_unknown": "Waiting in ComfyUI queue...",
"queue_comfy_other_running": "Another ComfyUI task is running. Waiting in queue... Position: {}",
"queue_comfy_other_running_unknown": "Another ComfyUI task is running. Waiting in ComfyUI queue...",
"queue_comfy_running": "ComfyUI is still executing the task...",
"queue_comfy_running_ws_fallback": "ComfyUI is still executing the task... (websocket temporarily unavailable)",
"queue_idle_warning": "No progress from ComfyUI for 6 minutes. Checking queue...",
"fmt_generation_eta": "Remaining: {}",
"cmon_title": "\u2699\ufe0f ComfyUI task monitor",
"cmon_starting": "\u2699\ufe0f Monitoring...",
"cmon_loading": "Checking ComfyUI tasks...",
"cmon_no_tasks": "No active tasks.",
"cmon_closed_idle": "No active tasks for 10 minutes. Monitor closed.",
"cmon_active": "Active task: {}",
"cmon_active_other": "Active task: {} (not ours)",
"cmon_active_unknown": "Active task: detected",
"cmon_current_node": "Current process: {}",
"cmon_progress": "Progress: {}%",
"cmon_last_check": "Updated: {}",
"cmon_unavailable": "Could not read ComfyUI queue.",
"fmt_decoding_image": "Decoding image...",
"fmt_processing_image": "Processing image...",
"fmt_upscaling_image": "Upscaling image...",
"fmt_detailing_face": "Detailing face...",
"fmt_saving_result": "Saving result...",
"fmt_applying_lora": "Applying LoRA...",
"fmt_running_node": "Running: {}",
"fmt_cached_nodes": "Using cached nodes: {}",
"easter_nothing": "Nothing already exists. Try generating something.",
"easter_ritual_progress": "Ritual progress: {}%",
"easter_dream_unavailable": "👎 ComfyUI is not responding. Maybe it went to watch its dreams.",
"easter_noise_form": "Noise is taking shape...",
"easter_long_prompt": "ComfyUI is reading this novel...",
"easter_long_prompt_rare": "ComfyUI is sorting details onto shelves...",
"easter_backrooms": "ComfyUI is looking for the exit from level {}...",
"easter_short_prompt": "The prompt is too modest. Comfy will think for you...",
"fmt_prompt": "Prompt:",
"fmt_model": "Model:",
"fmt_workflow": "Workflow:",
"fmt_generation_time": "Time: {}",
"fmt_done": "Generated!",
"btn_params": "\U0001f3a8 Parameters",
"btn_enhancements": "\U0001f916 Enhancements",
"btn_reset_all": "\U0001f504 Reset all",
"btn_close": "\u274c Close",
"btn_back": "\U0001f519 Back",
"btn_generate": "\U0001f680 Generate",
"btn_cancel": "\u274c Cancel",
"btn_toggle_on": "\u2705 On",
"btn_toggle_off": "\u274c Off",
"label_ai_enhance": "AI Enhancement",
"toast_model_set": "Model set: {}",
"toast_wf_set": "Workflow set: {}",
"toast_no_custom_wf": "No custom workflows",
"toast_invalid_value": "Invalid value: {}",
"toast_defaults_reset": "Defaults reset to workflow values",
"free_btn": "\U0001f9f9 Free memory",
"force_free_btn": "\U0001f9ef Force clear",
"refresh_btn": "\U0001f504 Refresh",
"free_ok": "ComfyUI memory freed.",
"force_free_ok": "ComfyUI generation interrupted and memory cleared.",
"free_fail": "Failed to free ComfyUI memory.",
"free_busy": "Generation is running, memory was not freed.",
"checkwf_no_reply": "\U0001f44e Reply to a workflow JSON file.",
"checkwf_bad_json": "\U0001f44e Invalid workflow JSON.",
"checkwf_checking": "\u2699\ufe0f Checking workflow: {}...",
"checkwf_title": "\u2699\ufe0f Workflow check: {}",
"checkwf_saved_title": "\u2705 Workflow added: {}",
"wf_validation_ok": "\u2705 Workflow passed validation.",
"wf_validation_failed": "\U0001f44e Workflow failed validation.",
"wf_validation_found": "\nFound:",
"wf_validation_warnings": "\nWarnings:",
"wf_validation_critical": "\nCritical errors:",
"wf_validation_missing_nodes": "\nMissing nodes:",
"wf_validation_node_pack": "{} — possible pack: {}",
"wf_validation_object_info_fail": "Could not fetch installed node list from ComfyUI.",
"wf_validation_missing_optional": "{} not found.",
"wf_validation_missing_inputs": "Node {} ({}) missing required inputs: {}.",
"wf_validation_empty": "Workflow is empty or invalid.",
"wf_validation_node_invalid": "Workflow contains invalid node data.",
"wf_validation_no_positive": "Positive prompt node not found.",
"wf_validation_no_model": "Model node not found.",
"wf_validation_no_output": "Output node not found.",
"wf_icon_found": "\u2705",
"wf_icon_missing": "\u2b1c",
"wf_icon_error": "\u274c",
"wf_icon_warning": "\u26a0\ufe0f",
"wf_check_positive": "Positive prompt",
"wf_check_negative": "Negative prompt",
"wf_check_model": "Model",
"wf_check_seed": "Seed",
"wf_check_steps": "Steps",
"wf_check_cfg": "CFG",
"wf_check_output": "Output",
"wf_check_size": "Size",
"wf_check_denoise": "Denoise",
"wf_check_img2img": "Img2Img input",
"wf_check_output_kind": "Output kind: {}",
"wf_check_input_kind": "Input kind: {}",
"wf_check_frames": "Frames",
"wf_check_fps": "FPS",
"ult_title": '\u2699\ufe0f Additional settings',
"ult_ai_title": '\U0001f916 AI enhancement',
"ult_ai_auto": "Auto enhancement: {}",
"ult_ai_prompt_confirm": "Prompt confirmation: {}",
"ult_ai_provider": "Provider: {}",
"ult_ai_model": "Model: {}",
"ult_ai_key": "API key: {}",
"ult_ai_desc": "The -ai flag still enables AI enhancement for one generation.",
"ult_ai_key_path": ".ultcomfy -> AI enhancement -> AI provider",
"ult_btn_ai": "\U0001f916 AI enhancement",
"ult_btn_ai_auto": "\U0001f916 Auto enhancement",
"ult_btn_prompt_confirm": "\U0001f916 Prompt confirmation",
"ult_gens_title": '\U0001f4c1 Generation archive',
"ult_gens_desc": "Save every successful generation to the generation archive in maximum quality with the prompt and model.",
"ult_trigger_title": '\u2699\ufe0f Trigger generation',
"ult_trigger_desc": "Generate images when a message starts with the trigger word in this chat.",
"ult_time_title": '\u23f1 Generation time',
"ult_theme_title": '\U0001f3a8 Emoji theme',
"ult_extra_title": "Additional settings",
"ult_auto_model_status": "Automatically switch model: {}",
"ult_theme_status": "Emoji theme: {}",
"ult_censorship_status": "Censorship: {}",
"ult_btn_censorship_on": "\U0001f910 Censorship Enabled",
"ult_btn_censorship_off": "\U0001f910 Censorship Disabled",
"ult_time_progress": "Generation time while running: {}",
"ult_time_result": "Generation time after result: {}",
"ult_trigger_chat": "Chat: {}",
"ult_trigger_word": "Trigger: {}",
"ult_trigger_autodelete": "Auto-delete result: {}",
"ult_trigger_delay": "Auto-delete after: {}",
"ult_trigger_queue": "Max queue: {}",
"ult_trigger_steps_limit": "Max steps: {}",
"ult_trigger_workflow": "Workflow: {}",
"ult_trigger_workflow_default": "Module default: {}",
"ult_trigger_active": "Active now: {}",
"ult_trigger_russian_guard": "Reject Russian prompt without -ai: {}",
"ult_trigger_auto_translate": "Automatically translate prompt to English: {}",
"ult_trigger_cloud_skip_confirm": "Cloud trigger without confirmation: {}",
"ult_trigger_blacklist": "Blacklist: {}",
"ult_trigger_word_input": "Enter trigger word:",
"ult_trigger_delay_input": "Enter auto-delete time in seconds:",
"ult_trigger_queue_input": "Enter max queue size:",
"ult_trigger_steps_input": "Enter trigger max steps (1-100):",
"ult_trigger_saved": "Trigger settings updated",
"ult_trigger_workflow_set": "Workflow for this chat's trigger: {}",
"ult_trigger_workflow_default_set": "This chat's trigger will use the module default workflow.",
"ult_trigger_workflow_title": '📁 Select trigger workflow',
"ult_trigger_reject_russian": "Block Russian prompt",
"ult_trigger_translate": "Translate to English",
"ult_trigger_blacklist_empty": "Trigger blacklist is empty.",
"ult_trigger_blacklist_title": "Trigger blacklist",
"ult_trigger_blacklist_added": "User added to trigger blacklist.",
"ult_trigger_blacklist_removed": "User removed from trigger blacklist.",
"ult_trigger_blacklist_no_user": "Reply to a user or specify @username/user id.",
"ult_btn_trigger_blacklist": "🚫 Blacklist",
"trigger_russian_requires_ai": "👎 Trigger generation with Russian prompt is allowed only with -ai.",
"translation_failed": "👎 Could not translate the prompt. Try again later.",
"trigger_too_often": "\U0001f44e Too often.",
"ult_status_on": '\u2705 Enabled',
"ult_status_off": '\u274c Disabled',
"ult_btn_prompt": "\U0001f916 Prompt confirmation",
"ult_btn_gens": "\U0001f4c1 Generation archive",
"ult_btn_trigger": "\u26a1 Trigger generation",
"ult_btn_time": "\u23f1 Generation time",
"ult_btn_theme": "Theme",
"ult_btn_extra": "Additional settings",
"ult_btn_auto_model": "Automatically switch model",
"ult_btn_tunnel_notify": "Tunnel notify",
"ult_btn_update_assets": "Update assets",
"ult_assets_update_started": "Asset update started.",
"ult_assets_updating": "Updating module assets...",
"ult_assets_updated": '\u2705 Module assets updated.',
"ult_assets_update_partial": '\u26a0 Some assets could not be updated. Cached versions remain available.',
"ult_btn_theme_default": "Default",
"ult_btn_theme_colored": "Colored",
"ult_btn_theme_cute": "Cute",
"ult_btn_theme_black": "Black",
"ult_btn_theme_trollface": "Trollface",
"ult_theme_builtin": "Built-in themes",
"ult_theme_custom": "Custom themes",
"ult_theme_custom_empty": "No custom themes yet.",
"ult_theme_create": "Add custom theme",
"ult_theme_create_input": "Enter custom theme name:",
"ult_theme_edit": "Edit custom theme",
"ult_theme_rename": "Rename",
"ult_theme_rename_input": "Enter new theme name:",
"ult_theme_renamed": "Custom theme renamed.",
"ult_theme_delete": "Delete custom theme",
"ult_theme_created": "Custom theme created: {}",
"ult_theme_deleted": "Custom theme deleted.",
"ult_theme_bad_name": "Invalid theme name.",
"ult_theme_limit": "Custom theme limit reached.",
"ult_theme_not_custom": "Select or create a custom theme first.",
"ult_theme_editor_title": "Custom emoji theme: {}",
"ult_theme_editor_hint": "Choose a slot, then send a custom emoji or enter document_id.",
"ult_theme_slot_title": "Slot: {}",
"ult_theme_slot_default": "Default: {}",
"ult_theme_slot_custom": "Custom: {}",
"ult_theme_slot_custom_empty": "Custom: not set",
"ult_theme_slot_wait": "Set from next message",
"ult_theme_slot_input": "Enter document_id or :",
"ult_theme_slot_reset": "Reset slot",
"ult_theme_slot_waiting": "Send one custom emoji in this chat.",
"ult_theme_slot_saved": "Emoji slot saved: {}",
"ult_theme_slot_reset_done": "Emoji slot reset.",
"ult_theme_no_emoji": "No custom emoji/document_id found.",
"emoji_slot_success": "Success",
"emoji_slot_error": "Error",
"emoji_slot_cancel": "Cancel",
"emoji_slot_loading": "Loading",
"emoji_slot_warning": "Warning",
"emoji_slot_off": "Off",
"emoji_slot_folder": "Folder",
"emoji_slot_ai": "AI",
"emoji_slot_style": "Style",
"emoji_slot_model": "Model",
"emoji_slot_prompt": "Prompt",
"emoji_slot_negative": "Negative",
"emoji_slot_device": "Device",
"emoji_slot_upload": "Upload",
"emoji_slot_memory": "Memory",
"emoji_slot_time": "Time",
"emoji_slot_heart": "Enhance",
"emoji_slot_random": "Random",
"emoji_slot_update": "Update",
"ult_btn_time_progress": "\u23f1 During",
"ult_btn_time_result": "\u23f1 Result",
"ult_btn_trigger_word": "\u270d Trigger word",
"ult_btn_trigger_delay": "\u23f1 Delete time",
"ult_btn_trigger_delay_short": "⏱ Time",
"ult_btn_trigger_queue": "\U0001f4e6 Queue",
"ult_btn_trigger_steps": "\U0001f3a8 Steps limit",
"ult_btn_trigger_workflow": "📁 Workflow",
"ult_btn_trigger_cloud_skip_confirm": "⚡ Cloud without confirmation",
"ult_btn_create_chat": "\U0001f3d7 Create archive",
"ult_btn_recreate_chat": "\U0001f504 Add archive",
"ult_btn_bind_chat": "\U0001f517 Bind archive",
"ult_btn_remove_chat": "\U0001f5d1 Remove target",
"ult_btn_clear_chats": "\U0001f9f9 Clear targets",
"ult_btn_generate": "\U0001f680 Generate",
"ult_btn_regenerate": "\U0001f504 Regenerate",
"ult_btn_cancel": "\u274c Cancel",
"ult_chat_missing": '\U0001f44e Generation archive is not created yet.',
"ult_chat_targets": "Archive targets: {}",
"ult_chat_target_topic": "chat {}, topic {}",
"ult_chat_target_chat": "chat {}",
"ult_chat_targets_title": '\U0001f4c1 Archive targets',
"ult_chat_targets_empty": '\u2b1c No archive targets.',
"ult_chat_need_create": '\U0001f44e First create the generation archive.',
"ult_chat_created": '\u2705 Generation archive created and linked.',
"ult_chat_recreated": '\u2705 Generation archive added.',
"ult_chat_create_failed": '\U0001f44e Failed to create generation archive: {}',
"ult_chat_access_lost": '\U0001f44e Access to the generation archive is lost. Create a new archive.',
"ult_chat_about": "This is the Comfy ImageGen generation archive. Every generation is saved here without quality loss, together with full generation metadata. Use .cshare as a reply to an archived generation to share it to the public SFW channel @ComfyIdeas.",
"ult_chat_bind_input": "Enter chat_id, chat_id topic_id, chat_id:topic_id, or a t.me/c link:",
"ult_chat_bind_bad": '\U0001f44e Could not parse chat/topic.',
"ult_chat_bind_failed": '\U0001f44e Could not bind archive: {}',
"ult_chat_bound": '\u2705 Archive target linked.',
"ult_chat_already_bound": '\u2705 This archive target is already linked.',
"ult_chat_target_removed": '\u2705 Archive target removed.',
"ult_chat_targets_cleared": '\u2705 Archive targets cleared.',
"archive_full_prompt_caption": "Full prompt",
"archive_full_prompt_title": "Full prompt #{}",
"cshare_no_reply": "\U0001f44e Reply to a generation from the archive.",
"cshare_no_archive": "\U0001f44e Reply to a generation from the archive.",
"cshare_no_prompt_info": "\U0001f44e Full prompt message not found. Reply to a generation from the archive.",
"cshare_no_image": "\U0001f44e Could not download the generation image.",
"cshare_target_error": "\U0001f44e Could not send the submission to @ComfyIdeas: {}",
"cshare_done": "\u2705 Sent to @ComfyIdeas.",
"cshare_top_unavailable": "👎 ComfyIdeas top is unavailable.",
"cshare_direct_unavailable": "\U0001f44e Open https://t.me/comfyideas?direct, send any message there once, then run .cshare again.",
"cshare_unknown_workflow": "Unknown",
"cshare_author": "Author: {}",
"cshare_author_anon": "anonymous",
"cshare_preview_title": '\U0001f4c1 ComfyIdeas preview',
"cshare_preview_expired": "\U0001f44e Preview expired. Run .cshare again.",
"cshare_preview_cancelled": "\u2705 ComfyIdeas submission cancelled.",
"cshare_preview_send_btn": "\u2705 Send",
"ult_confirm_title": '\U0001f916 Confirm AI prompt',
"ult_confirm_source": "Original prompt",
"ult_confirm_result": "Enhanced prompt",
"ult_confirm_censored": '\U0001f44e Provider censorship triggered. Generation will use the original prompt.',
"ult_confirm_model": "Model: {}",
"ult_confirm_workflow": "Workflow: {}",
"ult_toggle_saved": "Setting updated",
"ult_state_expired": '\U0001f44e This action expired. Start again.',
"trigger_queue_full": "\U0001f44e Generation queue is full. Try again later.",
"ctools_title": "\u2699\ufe0f Comfy tools",
"ctools_usage": "Use .ctools -upscale (0.1-8x), .ctools -rmbg, or .ctools -fps in reply to media.",
"ctools_btn_upscale": "Upscale",
"ctools_btn_rmbg": "Remove background",
"ctools_btn_fps": "FPS boost",
"ctools_desc_upscale": "-upscale (0.1-8x) - upscale media.",
"ctools_desc_rmbg": "-rmbg - remove background.",
"ctools_desc_fps": "-fps - boost video FPS.",
"ctools_bad_mode": "\U0001f44e Unknown tool. Available: -upscale, -rmbg, -fps.",
"ctools_bad_scale": "\U0001f44e Scale must be from 0.1x to 8x. Recommended: 2x.",
"ctools_no_reply_image": "\U0001f44e Reply to an image.",
"ctools_no_reply_video": "\U0001f44e Reply to a video.",
"ctools_no_reply_media": "\U0001f44e Reply to media.",
"ctools_processing_upscale": "\u2699\ufe0f Upscaling media...",
"ctools_processing_rmbg": "\u2699\ufe0f Removing background...",
"ctools_processing_fps": "\u2699\ufe0f Boosting video FPS...",
"ctools_uploading": "\U0001f4e4 Uploading tool result...",
"ctools_done": "\u2705 {} done.",
"ctools_done_rmbg": "\u2705 Background removed.",
"ctools_done_fps": "\u2705 FPS boost is ready.",
"ctools_workflow_no_input": "\U0001f44e Tool workflow has no suitable {} input node.",
"ctools_workflow_no_output": "\U0001f44e Tool workflow has no suitable save output node.",
"ctools_state_expired": "\U0001f44e This tool menu expired. Run .ctools again.",
"trigger_autodelete_caption": "Auto-delete in: {}",
"update_available": '\U0001f195 ComfyImageGen update\n\n{} -> {}{}\n\nInstall:\n{}',
"update_diff": "\n\nWhat's new:\n{}
",
"not_set": "not set",
}
strings_ru = {
"add_wf_cloud_share_bad": "\U0001f44e Ссылка Comfy Cloud некорректна или недоступна.",
"add_wf_cloud_share_loading": "\u2699\ufe0f Загружаю общий воркфлоу Comfy Cloud...",
"lora_filter_imported": "\U0001f4e5 \u0418\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0435",
"lora_no_imported": "\u0418\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0435 LoRA \u043f\u043e\u043a\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.",
"lora_civitai_input": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 ID \u043c\u043e\u0434\u0435\u043b\u0438/\u0432\u0435\u0440\u0441\u0438\u0438 Civitai \u0438\u043b\u0438 \u0441\u0441\u044b\u043b\u043a\u0443 \u043d\u0430 \u043c\u043e\u0434\u0435\u043b\u044c:",
"lora_civitai_saved": "\u041c\u0435\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0435 Civitai \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043d\u044b",
"lora_civitai_failed": "\u041c\u043e\u0434\u0435\u043b\u044c \u0438\u043b\u0438 \u0432\u0435\u0440\u0441\u0438\u044f Civitai \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u0430.",
"lora_civitai_base_model": "\u0411\u0430\u0437\u043e\u0432\u0430\u044f \u043c\u043e\u0434\u0435\u043b\u044c: {}",
"lora_cloud_unavailable": "Cloud \u0441\u0435\u0439\u0447\u0430\u0441 \u043d\u0435 \u043e\u0442\u0434\u0430\u0451\u0442 \u044d\u0442\u0438 LoRA \u0432 \u0441\u043f\u0438\u0441\u043e\u043a \u0434\u043b\u044f \u0432\u043e\u0440\u043a\u0444\u043b\u043e\u0443: {}",
"provider_comfy_text_error": "\u041e\u0448\u0438\u0431\u043a\u0430 ComfyUI Text: {}",
"comfy_text_workflow_download_failed_detail": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0432\u044b\u0433\u0440\u0443\u0437\u0438\u0442\u044c \u0432\u043e\u0440\u043a\u0444\u043b\u043e\u0443: {}",
"provider_comfy_text_invalid_workflow": "\u0412\u043e\u0440\u043a\u0444\u043b\u043e\u0443 ComfyUI Text \u0434\u043e\u043b\u0436\u0435\u043d \u0441\u043e\u0434\u0435\u0440\u0436\u0430\u0442\u044c USER_PROMPT, TARGET_MODEL \u0438 \u0432\u044b\u0445\u043e\u0434 PreviewAny.",
"comfy_text_workflow_section": '\U0001f4c1 \u0422\u0435\u043a\u0441\u0442\u043e\u0432\u044b\u0439 \u0432\u043e\u0440\u043a\u0444\u043b\u043e\u0443',
"comfy_text_workflow_source": "\u0418\u0441\u0442\u043e\u0447\u043d\u0438\u043a: {}",
"comfy_text_workflow_current_url": "URL: {}",
"comfy_text_workflow_source_default": "\u0434\u0435\u0444\u043e\u043b\u0442",
"comfy_text_workflow_source_custom": "\u0441\u0432\u043e\u0439",
"comfy_text_workflow_btn_set": "\u270d \u0412\u043f\u0438\u0441\u0430\u0442\u044c \u0441\u0441\u044b\u043b\u043a\u0443",
"comfy_text_workflow_btn_reset": "\U0001f504 \u0412\u0435\u0440\u043d\u0443\u0442\u044c \u0434\u0435\u0444\u043e\u043b\u0442",
"comfy_text_workflow_btn_download": "\U0001f4c4 \u0412\u044b\u0433\u0440\u0443\u0437\u0438\u0442\u044c",
"comfy_text_workflow_input_url": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0441\u0441\u044b\u043b\u043a\u0443 \u043d\u0430 \u0432\u043e\u0440\u043a\u0444\u043b\u043e\u0443 ComfyUI Text:",
"comfy_text_workflow_saved": "\u0421\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u0432\u043e\u0440\u043a\u0444\u043b\u043e\u0443 \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0430",
"comfy_text_workflow_reset": "\u0421\u0441\u044b\u043b\u043a\u0430 \u0441\u0431\u0440\u043e\u0448\u0435\u043d\u0430 \u043d\u0430 \u0434\u0435\u0444\u043e\u043b\u0442",
"comfy_text_workflow_invalid_url": "\u041d\u0435\u043a\u043e\u0440\u0440\u0435\u043a\u0442\u043d\u0430\u044f \u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u0432\u043e\u0440\u043a\u0444\u043b\u043e\u0443",
"comfy_text_workflow_download_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0432\u044b\u0433\u0440\u0443\u0437\u0438\u0442\u044c \u0432\u043e\u0440\u043a\u0444\u043b\u043e\u0443",
"comfy_text_workflow_file_caption": "\u0422\u0435\u043a\u0443\u0449\u0438\u0439 \u0432\u043e\u0440\u043a\u0444\u043b\u043e\u0443 ComfyUI Text",
"provider_api_key_not_required": "\u043d\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f",
"provider_comfy_text_info": "\u0420\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u0447\u0435\u0440\u0435\u0437 \u0430\u043a\u0442\u0438\u0432\u043d\u044b\u0439 ComfyUI-\u0431\u044d\u043a\u0435\u043d\u0434; \u0432\u043d\u0435\u0448\u043d\u0438\u0439 API-\u043a\u043b\u044e\u0447 \u043d\u0435 \u043d\u0443\u0436\u0435\u043d.",
"provider_comfy_text_template": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e\u0442\u0441\u044f \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f \u0432\u043e\u0440\u043a\u0444\u043b\u043e\u0443 \u0438 \u0434\u0438\u043d\u0430\u043c\u0438\u0447\u0435\u0441\u043a\u0438\u0435 \u043c\u0430\u0440\u043a\u0435\u0440\u044b USER_PROMPT/TARGET_MODEL.",
"provider_comfy_text_unavailable": "ComfyUI Text \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043d\u0430\u043b\u0438\u0447\u0438\u0435 CLIPLoader, TextGenerate, PreviewAny \u0438 \u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0439 \u043c\u043e\u0434\u0435\u043b\u0438.",
"provider_comfy_text_empty": "ComfyUI Text \u0432\u0435\u0440\u043d\u0443\u043b \u043f\u0443\u0441\u0442\u043e\u0439 \u043e\u0442\u0432\u0435\u0442. \u041f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u0438\u043b\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0432\u043e\u0440\u043a\u0444\u043b\u043e\u0443/\u043c\u043e\u0434\u0435\u043b\u044c.",
"provider_comfy_text_failed": "ComfyUI Text \u043d\u0435 \u0441\u043c\u043e\u0433 \u0443\u043b\u0443\u0447\u0448\u0438\u0442\u044c \u043f\u0440\u043e\u043c\u043f\u0442. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043e\u0447\u0435\u0440\u0435\u0434\u044c ComfyUI \u0438 \u043b\u043e\u0433\u0438 \u0441\u0435\u0440\u0432\u0435\u0440\u0430.",
"name": "ComfyImageGen",
"cfg_url": "Базовый URL ComfyUI (например: http://127.0.0.1:8188)",
"cfg_backend": "Режим ComfyUI: local или cloud",
"cfg_cloud_api_key": "API ключ(и) ComfyUI Cloud. Несколько ключей можно указать через запятую.",
"cfg_model": "Файл модели по умолчанию (например: waiIllustriousSDXL_v170)",
"cfg_max_mb": "Макс. размер входного изображения в МБ для img2img",
"cfg_max_output_mb": "Макс. размер результата в МБ",
"cfg_max_image_pixels": "Макс. число пикселей для изображений, отправляемых как фото Telegram. 0 отключает лимит",
"cfg_output_format": "Формат отправки результата: photo (сжатое изображение Telegram), document_png (PNG файлом без потерь)",
"cfg_ws_update_interval": "Обновление статуса генераций в секундах: 1-5, 0 отключить",
"cfg_gemini_api_key": "API ключ(и) Gemini для AI улучшения промпта. Несколько ключей можно указать через запятую.",
"cfg_groq_api_key": "API ключ(и) Groq для AI улучшения промпта. Несколько ключей можно указать через запятую.",
"cfg_openrouter_api_key": "API ключ(и) OpenRouter для AI улучшения промпта. Несколько ключей можно указать через запятую.",
"cfg_grok_api_key": "API ключ(и) Grok/xAI для AI улучшения промпта. Несколько ключей можно указать через запятую.",
"cfg_deepseek_api_key": "API ключ(и) DeepSeek для AI улучшения промпта. Несколько ключей можно указать через запятую.",
"cfg_nvidiaapi_api_key": "API ключ(и) NVIDIA API для AI улучшения промпта. Несколько ключей можно указать через запятую.",
"cfg_qwen_api_key": "API ключ(и) Qwen (Model Studio) для AI улучшения промпта. Несколько ключей можно указать через запятую.",
"cfg_qwen_base_url": "OpenAI-совместимый базовый URL Qwen Model Studio. Для международного endpoint оставьте значение по умолчанию; для другого региона укажите URL своего workspace.",
"cfg_update_assets": "Фоновое обновление ресурсов модуля в секундах. 0 отключает. Диапазон: 60-14400.",
"cfg_info_banner_url": "URL баннера для меню ci и chelp. 0 - отключает.",
"lora_title": '\U0001f3a8 Выбор LoRA',
"lora_prompt_label": "Промпт",
"lora_page": "Стр. {}/{}",
"lora_detail_title": '\u2699 {}\nВес: {:.1f}\nСтатус: {}',
"lora_favorite_add": "\u2606 В избранное",
"lora_favorite_remove": "\u2605 Убрать из избранного",
"lora_filter_all": "\u2637 Все",
"lora_filter_favorites": "\u2605 Избранное",
"lora_no_favorites": "В избранном пока нет LoRA.",
"lora_search_btn": "\U0001f50e Поиск",
"lora_search_clear": "\u2716 Сбросить поиск",
"lora_search_input": "Введите имя LoRA или его часть:",
"lora_search_label": "Поиск: {}",
"lora_search_empty": "По этому запросу LoRA не найдены.",
"lora_note_label": "Заметка: {}",
"lora_note_btn": "\U0001f4dd Заметка",
"lora_note_delete": "\U0001f5d1 Удалить заметку",
"lora_note_input": "Введите заметку для этой LoRA (до 500 символов):",
"lora_note_saved": "Заметка LoRA сохранена",
"lora_note_deleted": "Заметка LoRA удалена",
"lora_triggers_label": "Триггеры: {}",
"lora_triggers_empty": "Триггеры: не заданы",
"lora_triggers_btn": "✍ Задать триггеры",
"lora_triggers_input": "Введите trigger words через запятую или с новой строки:",
"lora_triggers_saved": "Триггеры LoRA сохранены",
"lora_auto_triggers_on": "✓ Автоматически добавлять триггеры",
"lora_auto_triggers_off": "□ Автоматически добавлять триггеры",
"lora_civitai_btn": "🌐 Загрузить из Civitai",
"lora_apply_unsupported": "В этом workflow нет поддерживаемого узла LoRA.",
"lora_apply_slots": "В workflow только {} слотов LoRA, а выбрано {} моделей.",
"lora_on": '\u2705 Включена',
"lora_off": '\u2b1c Выключена',
"lora_loading": "\u2699\ufe0f Загружаю список LoRA из ComfyUI...",
"lora_load_failed": "\U0001f44e Не удалось загрузить список LoRA из ComfyUI.",
"lora_none_available": "LoRA модели не найдены на сервере ComfyUI.",
"preflight_preparing": "\u2699\ufe0f Подготавливаю генерацию...",
"preflight_workflow": "\u2699\ufe0f Загружаю воркфлоу...",
"preflight_model": "\u2699\ufe0f Готовлю модель...",
"preflight_image": "\u2699\ufe0f Готовлю входное изображение...",
"preflight_launch": "\u2699\ufe0f Запускаю генерацию...",
"fmt_loras": "LoRA:",
"fmt_loras_more": "и ещё {}",
"no_url": "\U0001f44e URL ComfyUI не указан. Используйте .cfg ComfyImageGen comfyui_url",
"cloud_no_key": "\U0001f44e API ключ ComfyUI Cloud не задан. Откройте .cmode и добавьте ключ.",
"cloud_bad_key": "\U0001f44e API ключ ComfyUI Cloud неверный или без доступа.",
"cloud_no_balance": "\U0001f44e В ComfyUI Cloud не хватает кредитов/баланса для запроса.",
"cloud_rate_limit": "\U0001f44e Лимит или ограничение подписки ComfyUI Cloud. Попробуйте позже или используйте другой ключ.",
"cloud_unavailable": "\U0001f44e ComfyUI Cloud временно недоступен.",
"cloud_workflow_unsupported": "\U0001f44e Этот workflow не поддерживается в ComfyUI Cloud: в облаке не установлены нужные ноды.\n\n{}
",
"cloud_media_unsupported": "\U0001f44e Этот входной медиа-файл не поддерживается ComfyUI Cloud.",
"mode_title": '\U0001f6dc Режим ComfyUI',
"mode_current": "Текущий режим: {}",
"mode_local": "Локальный ComfyUI",
"mode_cloud": "ComfyUI Cloud",
"mode_local_url": "Локальный URL: {}",
"mode_cloud_keys": "Cloud API ключи: {}",
"mode_keys_set": "задано: {}",
"mode_keys_missing": "не заданы",
"onboarding_title": '👋 Добро пожаловать в ComfyImageGen',
"onboarding_prompt": "Выберите, как хотите генерировать изображения:",
"onboarding_local": "Локальный ComfyUI\nЛокальная генерация, вам нужно установить ComfyUI на свой ПК, подробнее - в гайде.",
"onboarding_cloud": "ComfyUI Cloud\nГенерация по API-ключу: для комфортной работы нужны подписка Creator и модели из описания воркфлоу, скачанные в правильные папки. Остальное - тут.",
"onboarding_btn_local": "💻 Локальный ComfyUI",
"onboarding_btn_cloud": "☁️ ComfyUI Cloud",
"onboarding_saved": "Бекенд выбран. Открываю справку…",
"mode_balance": "Баланс: {}",
"mode_balance_unavailable": "недоступен",
"mode_balance_no_key": "ключ не задан",
"mode_btn_local": "Локальный",
"mode_btn_cloud": "Cloud",
"mode_btn_url": "Локальный URL",
"mode_btn_key": "API ключи",
"mode_btn_check": "Проверить",
"mode_btn_balance": "Баланс",
"mode_input_url": "Введите локальный URL ComfyUI:",
"mode_input_key": "Введите API ключ(и) ComfyUI Cloud через запятую:",
"mode_saved": "Режим сохранён: {}",
"mode_url_saved": "Локальный URL сохранён",
"mode_key_saved": "Cloud API ключ(и) сохранены",
"mode_check_ok": "Подключение работает.",
"mode_check_fail": "Подключение не работает.",
"mode_local_url_set": "Локальный URL: задан",
"mode_local_url_missing": "Локальный URL: не задан",
"tunnel_notify_status": "Уведомления о туннеле: {}",
"tunnel_available": '✅ Туннель ComfyUI доступен.',
"tunnel_menu_title": '📁 Уведомления о туннеле',
"tunnel_menu_desc": "Локальный туннель проверяется раз в 10 минут. Уведомление отправляется только когда он становится доступен.",
"tunnel_targets": "Включено в:\n{}",
"tunnel_target_bot_pm": "личные сообщения с ботом",
"tunnel_targets_empty": "Нигде не включено.",
"tunnel_btn_add_chat": "Добавить чат",
"tunnel_btn_add_bot_pm": "Добавить ЛС с ботом",
"tunnel_btn_remove_target": "Удалить цель",
"tunnel_btn_clear_targets": "Очистить цели",
"tunnel_target_input": "Введите chat_id, chat_id topic_id, chat_id:topic_id или ссылку t.me/c:",
"tunnel_target_bad": '👎 Не удалось распознать чат/топик.',
"tunnel_target_bind_failed": '👎 У inline-бота нет доступа к этой цели: {}',
"tunnel_target_bound": '✅ Цель уведомлений добавлена.',
"tunnel_target_already_bound": '✅ Эта цель уведомлений уже добавлена.',
"tunnel_target_removed": '✅ Цель уведомлений удалена.',
"tunnel_targets_cleared": '✅ Цели уведомлений очищены.',
"cdown_title": '📤 Загрузка моделей в Cloud',
"cdown_type": "Тип: {}",
"cdown_url": "URL: {}",
"cdown_url_missing": "URL: не задан",
"cdown_file": "Файл: {}",
"cdown_size": "Размер: {}",
"cdown_validation_ok": '✅ Проверка: OK',
"cdown_validation_fail": '👎 Проверка: {}',
"cdown_folder": "Папка Cloud: {}",
"cdown_folder_unknown": "Папка Cloud не найдена в списке моделей; загрузку всё равно можно запустить.",
"cdown_result_ready": '✅ Модель готова: выберите её в «Мои модели».',
"cdown_result_started": '📤 Фоновая загрузка запущена.',
"cdown_result_failed": '👎 Ошибка загрузки: {}',
"cdown_task": "Задача: {}",
"cdown_task_status": "Статус импорта: {}",
"cdown_task_waiting": "Модель появится в «Мои модели» после завершения задачи.",
"cdown_btn_url": "Задать URL",
"cdown_btn_install": "Установить",
"cdown_btn_refresh": "Обновить статус",
"cdown_input_url": "Отправьте ссылку Hugging Face или Civitai для скачивания:",
"cdown_no_key": "API ключ ComfyUI Cloud не задан. Откройте .cmode и добавьте ключ.",
"cdown_need_url": "Сначала задайте URL.",
"cdown_need_valid": "Исправьте ошибку проверки URL перед установкой.",
"cdown_checking": "Проверяю URL...",
"cdown_installing": "Запускаю загрузку...",
"cdown_bad_url": "Поддерживаются только ссылки huggingface.co, civitai.com и civitai.red.",
"clib_title": '📁 Библиотека моделей Cloud',
"clib_summary": "Моделей: {} · категорий: {}",
"clib_folder_title": "Категория: {}",
"clib_name": "Модель: {}",
"clib_category": "Категория: {}",
"clib_tags": "Теги: {}",
"clib_empty": "Импортированные ассеты моделей не найдены.",
"clib_btn_refresh": "🔄 Обновить",
"clib_btn_move": "Сменить категорию",
"clib_btn_delete": "🗑 Удалить",
"clib_delete_title": "Удалить модель?",
"clib_delete_confirm": "🗑 Удалить навсегда",
"clib_deleted": "Модель удалена из библиотеки Cloud.",
"clib_deleting": "Удаляю модель...",
"clib_move_title": "Выберите категорию модели",
"clib_loading": "Загружаю библиотеку моделей Cloud...",
"clib_updating": "Обновляю категорию...",
"clib_moved": "Категория модели обновлена.",
"clib_immutable": "Этот ассет неизменяемый, обновить его нельзя.",
"clib_no_key": "API ключ ComfyUI Cloud не задан. Откройте .cmode и добавьте ключ.",
"cdown_type_checkpoint": "Checkpoint",
"cdown_type_lora": "LoRA",
"cdown_type_vae": "VAE",
"cdown_type_controlnet": "ControlNet",
"cdown_type_upscaler": "Upscaler",
"cdown_type_text_encoder": "Text Encoder",
"cdown_type_unet": "UNET",
"cdown_type_clip_vision": "CLIP Vision",
"cdown_type_ipadapter": "IPAdapter",
"cdown_type_style_model": "Style Model",
"cdown_type_model_patch": "Model Patch",
"cdown_type_sam": "SAM",
"cloud_confirm_title": '\U0001f6dc ComfyUI Cloud',
"cloud_confirm_balance": "Баланс: {}",
"cloud_confirm_cost": "Стоимость: {}",
"cloud_confirm_cost_unavailable": "недоступно",
"cloud_confirm_batch": "Batch: {}",
"cloud_confirm_workflow": '\U0001f4c1 Воркфлоу: {}',
"cloud_confirm_model": '\U0001f36d Модель: {}',
"cloud_confirm_prompt": "Промпт:",
"cloud_confirm_btn_generate": "Генерировать",
"cloud_confirm_btn_batch": "Batch: {}",
"cloud_confirm_btn_edit": "Правка промпта",
"cloud_confirm_input_batch": "Введите размер batch (1-8):",
"cloud_confirm_batch_saved": "Batch установлен: {}",
"cloud_confirm_batch_bad": "Batch должен быть числом от 1 до 8.",
"no_prompt": "\U0001f44e Укажите промпт.",
"prompt_empty": "Без промпта",
"status_civitai_inspire": "\U0001f3b2 Беру случайный промпт с Civitai...",
"civitai_no_prompt": "\U0001f44e Не удалось найти случайный промпт на Civitai.",
"civitai_error": "\U0001f44e Не удалось получить промпт с Civitai.",
"connecting": "\u2699\ufe0f Подключение к ComfyUI...",
"connecting_retry": "ComfyUI не ответил, повторяю подключение... {}/{}",
"progress": "\u2699\ufe0f Генерация... {}%\n\u270f\ufe0f Промпт: {}\n\U0001f910 Негатив: {}\n\U0001f36d Модель: {}\n\u2699\ufe0f Воркфлоу: {}",
"uploading": "\U0001f4e4 Загрузка результата...",
"success": "\u2705 Сгенерировано!\n\u270f\ufe0f Промпт: {}\n\U0001f910 Негатив: {}\n\U0001f36d Модель: {}\n\u2699\ufe0f Воркфлоу: {}",
"timeout": "\U0001f44e Таймаут генерации.",
"unavailable": "\U0001f44e ComfyUI недоступен.",
"img_too_large": "\U0001f44e Входное изображение слишком большое (макс. {} МБ).",
"output_too_large": "\U0001f44e Результат слишком большой (макс. {} МБ).",
"image_too_many_pixels": "\U0001f44e Изображение слишком большое для отправки фото (макс. {} МП). Отправьте его PNG-документом или уменьшите размер.",
"no_reply_photo": "\U0001f44e Ответьте на фото для img2img.",
"wf_not_found": "\U0001f44e Воркфлоу '{}' не найден. Доступные: {}",
"add_wf_no_reply": "\U0001f44e Ответьте на JSON-файл или укажите ссылку Comfy Cloud после имени воркфлоу.",
"add_wf_bad_json": "\U0001f44e Невалидный JSON-файл воркфлоу.",
"wf_file_too_large": "\U0001f44e JSON воркфлоу слишком большой. Максимум: 10 МБ.",
"add_wf_ok": "\u2705 Воркфлоу '{}' добавлен.",
"add_wf_exists": "\U0001f44e Воркфлоу '{}' уже существует.",
"add_wf_no_name": "\U0001f44e Укажите имя воркфлоу для добавления.",
"add_wf_force_btn": "\u2705 Всё равно добавить",
"add_wf_forced_note": "\u26a0\ufe0f Добавлено несмотря на ошибки проверки.",
"add_wf_force_expired": "\U0001f44e Это действие устарело. Запустите addwf заново.",
"del_wf_ok": "\u2705 Воркфлоу '{}' удалён.",
"del_wf_all_ok": "\u2705 Все пользовательские воркфлоу удалены: {}.",
"del_wf_fail": "\U0001f44e Воркфлоу '{}' не найден.",
"del_wf_builtin": "\U0001f44e Нельзя удалить встроенный воркфлоу '{}'.",
"wf_title": '\U0001f4c1 Выбор воркфлоу',
"wf_builtin_btn": "\U0001f4e6 Встроенные",
"wf_custom_btn": "\U0001f4dd Пользовательские",
"wf_cloud_btn": "Cloud workflows",
"wf_list_title_cloud": '\U0001f4c1 Cloud воркфлоу',
"toast_no_cloud_wf": "Cloud workflows пока пустые.",
"wf_list_title_builtin": '\U0001f4c1 Встроенные воркфлоу',
"wf_list_title_custom": '\U0001f4c1 Пользовательские воркфлоу',
"wf_desc_anime_v2": "Воркфлоу под модель MiaoMiao Harem [https://civitai.red/models/934764/miaomiao-harem?modelVersionId=3248362].",
"wf_desc_anime_v3": "Воркфлоу под модель MiaoMiao Harem (Anime Coloring) [https://civitai.red/models/934764/miaomiao-harem?modelVersionId=3203207].",
"wf_desc_ill": "Генерация аниме изображений (за основу [https://civitai.red/models/376130/nova-anime-xl?modelVersionId=2940478]).",
"wf_desc_krea2": "Воркфлоу под модель LUSTIFY, сильные ирл генерации/слабые аниме. [https://civitai.red/models/573152/lustify-nsfw-checkpoint?modelVersionId=3112728] (Используется 4-х квантовая модель, можете форкнуть вф чтобы использовать другую).",
"wf_desc_sdxl_real2": "Реалистичная модель на SDXL 1.0, сделано под модель xxxRay_dmd2 [https://civitai.red/models/1064836/xxx-ray].",
"wf_desc_cloud_krea2": "Клауд воркфлоу под модель LUSTIFY, сильные ирл генерации/слабые аниме. [https://civitai.red/models/573152/lustify-nsfw-checkpoint?modelVersionId=3112728] (3-5 кредитов генерация)",
"wf_desc_cloud_anima": "Клауд воркфлоу под модель MiaoMiao Harem [https://civitai.red/models/934764/miaomiao-harem?modelVersionId=3248362]. (2-4 кредита генерация).",
"wf_desc_cloud_anima2": "Клауд воркфлоу под модель MiaoMiao Harem, но с аниме колорингом [https://civitai.red/models/934764/miaomiao-harem?modelVersionId=3203207]. (2-4 кредита генерация).",
"wf_desc_cloud_ill": "Клауд генерация аниме изображений (за основу [https://civitai.red/models/376130/nova-anime-xl?modelVersionId=2940478]) (2-4 кред/генерация).",
"wf_desc_cloud_qwen": "Редактирование изображений (За основу модель [https://huggingface.co/Phr00t/Qwen-Image-Edit-Rapid-AIO/blob/main/v11/Qwen-Rapid-AIO-NSFW-v11.1.safetensors]) (4-7 кред/генерация).",
"wf_page": "Стр. {}/{}",
"wf_current": "Текущий: {}",
"wf_limited_hint": 'Второе нажатие по воркфлоу включает 🔵 ограниченный режим, для изменения становятся доступны только инпуты позитивного, негативного промпта и медиа. Функция в бете, сделано в основном под генерацию видео',
"wf_limited_set": "Воркфлоу установлен: {} (ограниченный режим)",
"toast_wf_limited_on": "Ограниченный режим включён: {}",
"toast_wf_limited_off": "Ограниченный режим выключен: {}",
"info_title": "\u2699\ufe0f Статус ComfyUI",
"ci_loading": "Проверяю ComfyUI...",
"info_ok": "\u2705 Подключено",
"info_fail": "\U0001f44e Недоступен",
"info_model": "\U0001f36d Модель: {}",
"info_wf": "\U0001f4c1 Текущий воркфлоу: {}",
"info_gpu": "\U0001f6dc GPU: {}",
"info_cpu": "\U0001f6dc CPU: {}",
"info_device": "\U0001f6dc Устройство: {}",
"info_no_device": "не обнаружено",
"info_vram": "\U0001f4be VRAM: {} / {}",
"info_ram": "\U0001f4be ОЗУ: {} / {}",
"info_version": "Версия: {}",
"info_python": "Python: {}",
"info_pytorch": "PyTorch: {}",
"info_frontend": "Frontend: {}",
"info_total_generations": "Генераций всего: {}",
"info_userbot_ping": "Пинг юзербота: {} ms",
"info_backend": "Режим: {}",
"info_balance": "Баланс: {}",
"ct_checking": "\u2699\ufe0f Проверяю API туннеля ComfyUI...",
"ct_title": "\u2699\ufe0f Проверка туннеля ComfyUI",
"ct_url": "URL: {}",
"ct_status": "Статус: {}",
"ct_ok": "\u2705 OK",
"ct_fail": "\U0001f44e Ошибка",
"ct_no_checks": "Проверки не были выполнены.",
"ct_bad_url": "\U0001f44e Некорректный URL.",
"ct_upload_failed": "\U0001f44e Пробная картинка сгенерирована, но Telegram upload не удался: {}",
"no_images": "\U0001f44e Нет изображений в ответе ComfyUI.",
"no_mapping_pos": "\U0001f4e3 Не удалось найти ноду позитивного промпта в воркфлоу.",
"models_title": '\U0001f3a8 Выберите модель ComfyUI',
"models_loading": "Загружаю список моделей...",
"models_page": "Стр. {}/{}",
"models_set": "\u2705 Модель установлена: {}",
"models_empty": "\U0001f44e Checkpoint и UNET модели не найдены.",
"models_manual_btn": "\u270f\ufe0f Ввести вручную",
"models_manual_input": "Введите имя файла модели:",
"models_search_btn": "\U0001f50e Поиск",
"models_search_clear": "\u2716 Сбросить поиск",
"models_search_input": "Введите имя модели или его часть:",
"models_search_label": "Поиск: {}",
"models_search_empty": "По этому запросу модели не найдены.",
"models_as_workflow_btn": "Как в воркфлоу",
"models_as_workflow": "Cloud модель: как в воркфлоу ({})",
"toast_model_as_workflow": "Cloud модель: как в воркфлоу",
"models_cloud_title": '🎨 Выберите модель ComfyUI Cloud',
"models_cloud_current": "Текущая: {}",
"models_cloud_default_btn": "Cloud default",
"models_cloud_custom_btn": "Мои модели",
"models_cloud_default_title": "Папки Cloud default",
"models_cloud_custom_title": "Папки моих моделей",
"models_cloud_folder_title": "Папка: {}",
"models_cloud_empty_folders": "Папки моделей не найдены.",
"models_cloud_empty_models": "В этой папке нет моделей.",
"models_cloud_empty_custom": "Импортированные модели не найдены.",
"help_text": (
"Руководство ComfyImageGen\n\n"
'1. Установка ComfyUI\n'
'2. Как генерировать?\n'
'3. Терминология\n'
'4. .comfy\n'
'5. .cshare\n'
'6. .ctools\n'
'7. .setarg\n'
'8. .ultcomfy\n'
'9. Свой вф\n'
'10. Ссылочки'
),
"setwf_ok": "Воркфлоу установлен: {}",
"mlwf_no_name": "\U0001f44e Укажите название воркфлоу для выгрузки.",
"mlwf_not_found": "\U0001f44e Воркфлоу '{}' не найден.",
"mlwf_success": "\u2705 Воркфлоу '{}' выгружен.",
"unexpected_comfy_response": "ComfyUI вернул неожиданный контент. Проверьте логи ComfyUI на ошибки.",
"inline_uploading": '\U0001f4e4 Загрузка результата...',
"cancelled": '\U0001f44e Генерация отменена.',
"cancel_btn": "\u274c Отмена",
"err_connection": "\U0001f44e Не удалось подключиться к ComfyUI. Проверьте, что сервер запущен и URL указан верно.",
"err_node_missing": "\U0001f44e Компонент «{}» не установлен в ComfyUI. Установите недостающий кастомный узел.",
"err_model_not_found": "\U0001f44e Модель «{}» не найдена на сервере. Проверьте название или загрузите модель.",
"err_model_value_not_in_list": "\U0001f44e Выбранная модель не существует или не подходит для этого воркфлоу.\n\nМодель: {}\nДоступные модели:\n{}",
"cloud_model_not_found": "\U0001f44e Данная модель не скачана в облаке.",
"err_vram": "\U0001f44e Недостаточно видеопамяти. Попробуйте уменьшить размер изображения или количество шагов.",
"err_image_invalid": "\U0001f44e Не удалось обработать изображение. Файл повреждён или формат не поддерживается.",
"err_img2img_unsupported": "\U0001f44e Этот воркфлоу не поддерживает img2img. Добавьте вход изображения или latent switch ноду.",
"err_upload_failed": "\U0001f44e Не удалось загрузить изображение на сервер ComfyUI.",
"err_retrieve_failed": "\U0001f44e Не удалось получить результат генерации с сервера.",
"err_send_failed": "\U0001f44e Не удалось отправить результат в Telegram. Проверьте права в чате или попробуйте document_png.",
"err_workflow_invalid": "\U0001f44e Воркфлоу повреждён или содержит ошибки.",
"err_workflow_invalid_details": "\U0001f44e Ошибка валидации workflow:\n{}",
"err_vae_not_found": "\U0001f44e Не удалось определить VAE для img2img. Проверьте воркфлоу.",
"err_prompt_queue": "\U0001f44e ComfyUI не принял задание на генерацию. Проверьте логи сервера.",
"err_execution": "\U0001f44e Ошибка во время генерации в ComfyUI. Проверьте логи сервера.",
"err_none_input": "\U0001f44e Один из компонентов воркфлоу не получил входные данные. Проверьте, что все модели загружены и узлы подключены правильно.",
"err_generic": "\U0001f44e Произошла непредвиденная ошибка. Подробности в логах.",
"err_workflow_download": "\U0001f44e Не удалось загрузить встроенный воркфлоу. Проверьте подключение к интернету.",
"err_server_unavailable": "\U0001f44e ComfyUI временно недоступен (502/503/504). Сервер перегружен или перезапускается. Попробуйте через минуту.",
"status_enhancing": "\u2764\ufe0f Улучшение промпта с помощью AI...",
"enhance_no_key": "\U0001f44e API ключ {} не указан. Откройте {}",
"enhance_dependency_missing": "\U0001f44e google-genai не установлен. Установите зависимости модуля или переустановите модуль.",
"enhance_key_expired": "\U0001f44e API ключ {} недействителен или исчерпан.",
"enhance_censored": "\U0001f44e {} заблокировал промпт (цензура). Попробуйте другой провайдер.",
"enhance_rate_limit": "\U0001f44e У {} исчерпан лимит запросов. Попробуйте позже.",
"enhance_timeout": "\U0001f44e {} не ответил вовремя при улучшении промпта. Попробуйте позже.",
"enhance_service_error": "\U0001f44e {} вернул непредвиденную ошибку при улучшении промпта. Попробуйте позже.",
"enhance_vision_unsupported": "\U0001f44e Выбранная AI-модель/провайдер ({}) не поддерживает входные изображения. Выберите vision-модель/провайдера или запустите без -ai.",
"enhance_error": "\U0001f44e Ошибка {} при улучшении промпта: {}",
"enhance_cmd_title": '\U0001f916 Улучшенный промпт',
"enhance_cmd_provider": "Провайдер: {}",
"enhance_cmd_model": "AI модель: {}",
"enhance_cmd_original": "Исходный промпт:",
"enhance_cmd_result": "Улучшенный промпт:",
"enhanced_label": '\U0001f916 ИИ промпт:',
"enhance_chat_title": "AI правка промпта [{}/100]",
"enhance_chat_edit_btn": "\u270f\ufe0f Внести правки",
"enhance_chat_input": "Что изменить в промпте?",
"enhance_chat_limit": "\U0001f44e Лимит правок достигнут: 100/100.",
"enhance_chat_empty": "\U0001f44e Напишите, что изменить.",
"comments_generation_disabled": "\u26a0\ufe0f Генерация в комментариях под постами недоступна. Запустите команду в обычном чате или в ЛС.",
"argset_title": '\u2699\ufe0f Дефолтные аргументы генерации',
"argset_limited_mode": "🔵 Включён ограниченный режим воркфлоу. Настройки, меняющие workflow, игнорируются.",
"argset_params": '\U0001f3a8 Параметры генерации (значения по дефолту берутся из текущего воркфлоу)',
"argset_enhancements": '\U0001f916 Улучшения',
"argset_on": '\u2705',
"argset_off": '\u2b1c',
"argset_input_width": "Введите ширину (64-4096):",
"argset_input_height": "Введите высоту (64-4096):",
"argset_input_steps": "Введите количество шагов (1-100):",
"argset_input_cfg": "Введите CFG (1.0-30.0):",
"argset_input_denoise": "Введите denoise (0.0-1.0):",
"argset_input_sampler_name": "Введите sampler_name:",
"argset_input_scheduler": "Введите scheduler:",
"label_sampler_name": "Sampler",
"label_scheduler": "Scheduler",
"argset_choice_workflow": "В воркфлоу: {}",
"argset_choice_used": "Используется: {}",
"argset_choice_as_workflow": "Как в воркфлоу",
"argset_choice_custom": "\u270d Ввести своё",
"argset_choice_clear": "\U0001f5d1 Очистить",
"argset_choice_saved": "Сохранено: {}",
"argset_pin_model": "📌 Закрепить для модели",
"argset_pin_model_ok": "Закреплено для модели: {}",
"provider_title": '\U0001f916 ИИ-модели и промпты',
"provider_menu_intro": "Выберите провайдера, чтобы настроить модель, API-ключ и промпт улучшения.",
"provider_current": "Текущий: {}",
"provider_status": "Статус: {}",
"provider_selected": '\u2705 Выбран',
"provider_not_selected": '\u2b1c Не выбран',
"provider_api_key": "API ключ: {}",
"provider_api_key_set": '\u2705 задан',
"provider_api_key_missing": '\u2b1c не задан',
"provider_model": "Модель: {}",
"provider_btn_select": "\u2705 Выбрать",
"provider_btn_api_key": "\U0001f511 API ключ",
"provider_btn_model": "\U0001f9e0 Модель",
"provider_btn_menu": "\U0001f916 ИИ-модели",
"enhance_prompt_current_url": "URL: {}",
"enhance_prompt_section": "\U0001f4dd Промпт для модели",
"enhance_prompt_source": "Источник: {}",
"enhance_prompt_source_default": "дефолт",
"enhance_prompt_source_custom": "свой",
"enhance_prompt_btn_set": "\u270d Вписать ссылку",
"enhance_prompt_btn_reset": "\U0001f504 Вернуть дефолт",
"enhance_prompt_btn_download": "\U0001f4c4 Выгрузить",
"enhance_prompt_input_url": "Введите ссылку на промпт для {}:",
"enhance_prompt_saved": "Ссылка на промпт сохранена",
"enhance_prompt_reset": "Ссылка сброшена на дефолт",
"enhance_prompt_invalid_url": "Некорректная ссылка",
"enhance_prompt_download_failed": "Не удалось скачать промпт",
"enhance_prompt_file_caption": "Промпт улучшения для {}",
"provider_input_api_key": "Введите API ключ для {}:",
"provider_input_model": "Введите модель:",
"provider_saved": "Провайдер выбран: {}",
"provider_key_saved": "API ключ сохранён",
"provider_model_saved": "Модель сохранена",
"lora_presets_title": '\U0001f3a8 Пресеты LoRA',
"lora_presets_empty": "Нет выбранных пресетов LoRA.",
"lora_presets_selected": "Выбрано: {}",
"lora_presets_clear": "\U0001f5d1 Очистить все",
"lora_presets_saved": "Пресеты LoRA сохранены",
"lora_weight_btn": "\u270d Вес",
"lora_weight_input": "Введите вес LoRA (0.1-2.0):",
"lora_weight_saved": "Вес LoRA сохранён",
"label_lora_presets": "Пресеты LoRA",
"positive_menu_title": '\u270f\ufe0f Позитивные промпты',
"positive_global": "Общий позитив",
"positive_workflows": "Позитивы воркфлоу",
"positive_current": "Текущий:",
"positive_custom": "Свой:",
"positive_global_label": "Общий:",
"positive_btn_global": "\U0001f310 Общий позитив",
"positive_btn_set": "\u270d Вписать новый",
"positive_btn_reset": "\U0001f504 Вернуть к дефолту",
"positive_btn_clear": "\U0001f5d1 Очистить",
"positive_input_global": "Введите общий позитивный промпт:",
"positive_input_workflow": "Введите позитивный промпт для {}:",
"positive_saved": "Позитив сохранён",
"positive_reset": "Позитив сброшен",
"positive_cleared": "Позитив очищен",
"err_reserved_wf": "\U0001f44e Имя 'i2i' зарезервировано встроенным модулем. Попробуйте другое.",
"del_wf_no_name_with_list": "\U0001f44e Укажите имя воркфлоу для удаления.\n\nДоступные пользовательские воркфлоу:\n{}\n\nПример:\n.delwf {}",
"del_wf_no_custom": "\U0001f44e Нет пользовательских воркфлоу для удаления.",
"repeat_no_last": "\U0001f44e Нет сохранённой генерации для повтора.",
"fmt_generating": "Генерация...",
"fmt_generating_pct": "Генерация... {}%",
"fmt_loading_model": "Гружу модель...",
"fmt_encoding_prompt": "Кодирую промпт...",
"queue_local_waiting": "Ожидание в очереди генерации...",
"queue_comfy_submitted": "Задача отправлена в ComfyUI. Жду статус... (возможно, WebSocket недоступен)",
"queue_comfy_pending": "Ожидание в очереди ComfyUI... Место: {}",
"queue_comfy_pending_unknown": "Ожидание в очереди ComfyUI...",
"queue_comfy_other_running": "Сейчас выполняется чужая задача ComfyUI. Ожидание в очереди... Место: {}",
"queue_comfy_other_running_unknown": "Сейчас выполняется чужая задача ComfyUI. Ожидание в очереди...",
"queue_comfy_running": "ComfyUI всё еще выполняет задачу...",
"queue_comfy_running_ws_fallback": "ComfyUI всё еще выполняет задачу... (websocket временно недоступен)",
"queue_idle_warning": "Нет прогресса от ComfyUI 6 минут. Проверяю очередь...",
"fmt_generation_eta": "Осталось: {}",
"cmon_title": "\u2699\ufe0f Мониторинг задач ComfyUI",
"cmon_starting": "\u2699\ufe0f Мониторим...",
"cmon_loading": "Проверяю задачи ComfyUI...",
"cmon_no_tasks": "Активных задач нет.",
"cmon_closed_idle": "Активных задач нет 10 минут. Мониторинг закрыт.",
"cmon_active": "Активная задача: {}",
"cmon_active_other": "Активная задача: {} (не наша)",
"cmon_active_unknown": "Активная задача: обнаружена",
"cmon_current_node": "Текущий процесс: {}",
"cmon_progress": "Прогресс: {}%",
"cmon_last_check": "Обновлено: {}",
"cmon_unavailable": "Не удалось прочитать очередь ComfyUI.",
"fmt_decoding_image": "Декодирую изображение...",
"fmt_processing_image": "Обрабатываю изображение...",
"fmt_upscaling_image": "Апскейлю изображение...",
"fmt_detailing_face": "Детализирую лицо...",
"fmt_saving_result": "Сохраняю результат...",
"fmt_applying_lora": "Применяю LoRA...",
"fmt_running_node": "Выполняю: {}",
"fmt_cached_nodes": "Использую кэшированные ноды: {}",
"easter_nothing": "Ничего уже существует. Попробуй сгенерировать что-нибудь.",
"easter_ritual_progress": "Ритуальный прогресс: {}%",
"easter_dream_unavailable": "👎 ComfyUI не отвечает. Возможно, он ушёл смотреть свои сны.",
"easter_noise_form": "Шум собирается в форму...",
"easter_long_prompt": "ComfyUI читает этот роман...",
"easter_long_prompt_rare": "ComfyUI раскладывает детали по полкам...",
"easter_backrooms": "ComfyUI ищет выход из уровня {}...",
"easter_short_prompt": "Промпт слишком скромный. Комфи додумает за тебя...",
"fmt_prompt": "Промпт:",
"fmt_model": "Модель:",
"fmt_workflow": "Воркфлоу:",
"fmt_generation_time": "Время: {}",
"fmt_done": "Сгенерировано!",
"btn_params": "\U0001f3a8 Параметры",
"btn_enhancements": "\U0001f916 Улучшения",
"btn_reset_all": "\U0001f504 Сбросить всё",
"btn_close": "\u274c Закрыть",
"btn_back": "\U0001f519 Назад",
"btn_generate": "\U0001f680 Генерировать",
"btn_cancel": "\u274c Отмена",
"btn_toggle_on": "\u2705 Вкл",
"btn_toggle_off": "\u274c Выкл",
"label_ai_enhance": "ИИ-улучшение",
"toast_model_set": "Модель установлена: {}",
"toast_wf_set": "Воркфлоу установлен: {}",
"toast_no_custom_wf": "Нет пользовательских воркфлоу",
"toast_invalid_value": "Неверное значение: {}",
"toast_defaults_reset": "Дефолты сброшены до значений воркфлоу",
"negative_menu_title": '\U0001f910 Негативные промпты',
"negative_global": "Общий негатив",
"negative_workflows": "Негативы воркфлоу",
"negative_source_custom": "свой",
"negative_source_global": "общий",
"negative_source_workflow": "воркфлоу",
"negative_source_empty": "пусто",
"negative_current": "Текущий:",
"negative_custom": "Свой:",
"negative_global_label": "Общий:",
"negative_workflow_default": "Дефолт воркфлоу:",
"negative_source": "Источник: {}",
"negative_not_set": "не задан",
"negative_btn_global": "\U0001f310 Общий негатив",
"negative_btn_set": "\u270d Вписать новый",
"negative_btn_reset": "\U0001f504 Вернуть к дефолту",
"negative_btn_clear": "\U0001f5d1 Очистить",
"negative_input_global": "Введите общий негативный промпт:",
"negative_input_workflow": "Введите негативный промпт для {}:",
"negative_saved": "Негатив сохранён",
"negative_reset": "Негатив сброшен",
"negative_cleared": "Негатив очищен",
"free_btn": "\U0001f9f9 Очистить память",
"force_free_btn": "\U0001f9ef Принудительная очистка",
"refresh_btn": "\U0001f504 Обновить",
"free_ok": "Память ComfyUI очищена.",
"force_free_ok": "Генерация ComfyUI прервана, память очищена.",
"free_fail": "Не удалось очистить память ComfyUI.",
"free_busy": "Сейчас идёт генерация, память не очищаю.",
"checkwf_no_reply": "\U0001f44e Ответьте на JSON-файл воркфлоу.",
"checkwf_bad_json": "\U0001f44e Невалидный JSON воркфлоу.",
"checkwf_checking": "\u2699\ufe0f Проверяю воркфлоу: {}...",
"checkwf_title": "\u2699\ufe0f Проверка воркфлоу: {}",
"checkwf_saved_title": "\u2705 Воркфлоу добавлен: {}",
"wf_validation_ok": "\u2705 Воркфлоу прошёл проверку.",
"wf_validation_failed": "\U0001f44e Воркфлоу не прошёл проверку.",
"wf_validation_found": "\nНайдено:",
"wf_validation_warnings": "\nПредупреждения:",
"wf_validation_critical": "\nКритические ошибки:",
"wf_validation_missing_nodes": "\nНедостающие ноды:",
"wf_validation_node_pack": "{} — возможный пак: {}",
"wf_validation_object_info_fail": "Не удалось получить список установленных нод ComfyUI.",
"wf_validation_missing_optional": "{} не найден.",
"wf_validation_missing_inputs": "Нода {} ({}) без обязательных входов: {}.",
"wf_validation_empty": "Воркфлоу пустой или невалидный.",
"wf_validation_node_invalid": "Воркфлоу содержит невалидные данные ноды.",
"wf_validation_no_positive": "Нода позитивного промпта не найдена.",
"wf_validation_no_model": "Нода модели не найдена.",
"wf_validation_no_output": "Нода результата не найдена.",
"wf_icon_found": "\u2705",
"wf_icon_missing": "\u2b1c",
"wf_icon_error": "\u274c",
"wf_icon_warning": "\u26a0\ufe0f",
"wf_check_positive": "Позитивный промпт",
"wf_check_negative": "Негативный промпт",
"wf_check_model": "Модель",
"wf_check_seed": "Сид",
"wf_check_steps": "Steps",
"wf_check_cfg": "CFG",
"wf_check_output": "Результат",
"wf_check_size": "Размер",
"wf_check_denoise": "Denoise",
"wf_check_img2img": "Img2Img input",
"ult_title": '\u2699\ufe0f Дополнительные настройки/функции',
"ult_ai_title": '\U0001f916 ИИ-улучшение',
"ult_ai_auto": "Автоулучшение: {}",
"ult_ai_prompt_confirm": "Подтверждение промпта: {}",
"ult_ai_provider": "Провайдер: {}",
"ult_ai_model": "Модель: {}",
"ult_ai_key": "API ключ: {}",
"ult_ai_desc": "Флаг -ai всё ещё включает ИИ-улучшение для одной генерации.",
"ult_ai_key_path": ".ultcomfy -> ИИ-улучшение -> ИИ-провайдер",
"ult_btn_ai": "\U0001f916 ИИ-улучшение",
"ult_btn_ai_auto": "\U0001f916 Автоулучшение",
"ult_btn_prompt_confirm": "\U0001f916 Подтверждение промпта",
"ult_gens_title": '\U0001f4c1 Архив генераций',
"ult_gens_desc": "Сохранять каждую успешную генерацию в архив генераций без сжатия (в лучшем качестве).",
"ult_trigger_title": '\u2699\ufe0f Генерация по триггеру',
"ult_trigger_desc": "Генерировать изображения, когда сообщение начинается с триггер-слова в этом чате.",
"ult_time_title": '\u23f1 Время генерации',
"ult_theme_title": '\U0001f3a8 Тема эмодзи',
"ult_extra_title": "Доп настройки",
"ult_auto_model_status": "Автоматически переключать модель: {}",
"ult_theme_status": "Тема эмодзи: {}",
"ult_censorship_status": "Цензура: {}",
"ult_btn_censorship_on": "\U0001f910 Цензура Включена",
"ult_btn_censorship_off": "\U0001f910 Цензура Выключена",
"ult_time_progress": "Время генерации в процессе: {}",
"ult_time_result": "Время генерации в результате: {}",
"ult_trigger_chat": "Чат: {}",
"ult_trigger_word": "Триггер: {}",
"ult_trigger_autodelete": "Автоудаление результата: {}",
"ult_trigger_delay": "Автоудаление через: {}",
"ult_trigger_queue": "Макс. очередь: {}",
"ult_trigger_steps_limit": "Макс. steps: {}",
"ult_trigger_workflow": "Воркфлоу: {}",
"ult_trigger_workflow_default": "По умолчанию модуля: {}",
"ult_trigger_active": "Активно сейчас: {}",
"ult_trigger_russian_guard": "Запрет русского промпта без -ai: {}",
"ult_trigger_auto_translate": "Автоматически переводить промпт на английский: {}",
"ult_trigger_cloud_skip_confirm": "Триггер Cloud без подтверждения: {}",
"ult_trigger_blacklist": "Блэклист: {}",
"ult_trigger_word_input": "Введите триггер-слово:",
"ult_trigger_delay_input": "Введите время автоудаления в секундах:",
"ult_trigger_queue_input": "Введите максимум очереди:",
"ult_trigger_steps_input": "Введите максимум steps для триггера (1-100):",
"ult_trigger_saved": "Настройки триггера обновлены",
"ult_trigger_workflow_set": "Воркфлоу для триггера этого чата: {}",
"ult_trigger_workflow_default_set": "Триггер этого чата будет использовать воркфлоу модуля по умолчанию.",
"ult_trigger_workflow_title": '📁 Выбор воркфлоу триггера',
"ult_trigger_reject_russian": "Блокировать русский промпт",
"ult_trigger_translate": "Переводить на английский",
"ult_trigger_blacklist_empty": "Блэклист триггеров пуст.",
"ult_trigger_blacklist_title": "Блэклист триггеров",
"ult_trigger_blacklist_added": "Пользователь добавлен в блэклист триггеров.",
"ult_trigger_blacklist_removed": "Пользователь удалён из блэклиста триггеров.",
"ult_trigger_blacklist_no_user": "Ответьте на пользователя или укажите @username/id.",
"ult_btn_trigger_blacklist": "🚫 Блэклист",
"trigger_russian_requires_ai": "👎 Для генерации по триггеру русский промпт разрешён только с -ai.",
"translation_failed": "👎 Не удалось перевести промпт. Попробуйте позже.",
"ult_status_on": '\u2705 Включено',
"ult_status_off": '\u274c Выключено',
"ult_btn_prompt": "\U0001f916 Подтверждение промпта",
"ult_btn_gens": "\U0001f4c1 Архив генераций",
"ult_btn_trigger": "\u26a1 Генерация по триггеру",
"ult_btn_time": "\u23f1 Время генерации",
"ult_btn_theme": "Тема",
"ult_btn_extra": "Доп настройки",
"ult_btn_auto_model": "Автоматически переключать модель",
"ult_btn_tunnel_notify": "Уведомления о туннеле",
"ult_btn_update_assets": "Обновить ассеты",
"ult_assets_update_started": "Обновление ассетов запущено.",
"ult_assets_updating": "Обновляю ассеты модуля...",
"ult_assets_updated": '\u2705 Ассеты модуля обновлены.',
"ult_assets_update_partial": '\u26a0 Часть ассетов обновить не удалось. Доступные версии остались в кэше.',
"ult_btn_theme_default": "Дефолт",
"ult_btn_theme_colored": "Цветная",
"ult_btn_theme_cute": "Милая",
"ult_btn_theme_black": "Чёрная",
"ult_btn_theme_trollface": "Trollface",
"ult_theme_builtin": "Встроенные темы",
"ult_theme_custom": "Свои темы",
"ult_theme_custom_empty": "Своих тем пока нет.",
"ult_theme_create": "Добавить свою тему",
"ult_theme_create_input": "Введите название своей темы:",
"ult_theme_edit": "Редактировать свою тему",
"ult_theme_rename": "Переименовать",
"ult_theme_rename_input": "Введите новое название темы:",
"ult_theme_renamed": "Своя тема переименована.",
"ult_theme_delete": "Удалить свою тему",
"ult_theme_created": "Своя тема создана: {}",
"ult_theme_deleted": "Своя тема удалена.",
"ult_theme_bad_name": "Некорректное название темы.",
"ult_theme_limit": "Лимит своих тем достигнут.",
"ult_theme_not_custom": "Сначала выберите или создайте свою тему.",
"ult_theme_editor_title": "Своя тема эмодзи: {}",
"ult_theme_editor_hint": "Выберите слот, затем отправьте кастомный эмодзи или введите document_id.",
"ult_theme_slot_title": "Слот: {}",
"ult_theme_slot_default": "Дефолт: {}",
"ult_theme_slot_custom": "Свой: {}",
"ult_theme_slot_custom_empty": "Свой: не задан",
"ult_theme_slot_wait": "Задать следующим сообщением",
"ult_theme_slot_input": "Введите document_id или :",
"ult_theme_slot_reset": "Сбросить слот",
"ult_theme_slot_waiting": "Отправьте один кастомный эмодзи в этот чат.",
"ult_theme_slot_saved": "Слот эмодзи сохранён: {}",
"ult_theme_slot_reset_done": "Слот эмодзи сброшен.",
"ult_theme_no_emoji": "Кастомный эмодзи/document_id не найден.",
"emoji_slot_success": "Успех",
"emoji_slot_error": "Ошибка",
"emoji_slot_cancel": "Отмена",
"emoji_slot_loading": "Загрузка",
"emoji_slot_warning": "Предупреждение",
"emoji_slot_off": "Выключено",
"emoji_slot_folder": "Папка",
"emoji_slot_ai": "AI",
"emoji_slot_style": "Стиль",
"emoji_slot_model": "Модель",
"emoji_slot_prompt": "Промпт",
"emoji_slot_negative": "Негатив",
"emoji_slot_device": "Устройство",
"emoji_slot_upload": "Загрузка",
"emoji_slot_memory": "Память",
"emoji_slot_time": "Время",
"emoji_slot_heart": "Улучшение",
"emoji_slot_random": "Рандом",
"emoji_slot_update": "Обновление",
"ult_btn_time_progress": "\u23f1 В процессе",
"ult_btn_time_result": "\u23f1 В результате",
"ult_btn_trigger_word": "\u270d Триггер-слово",
"ult_btn_trigger_delay": "\u23f1 Время удаления",
"ult_btn_trigger_delay_short": "⏱ Время",
"ult_btn_trigger_queue": "\U0001f4e6 Очередь",
"ult_btn_trigger_steps": "\U0001f3a8 Лимит steps",
"ult_btn_trigger_workflow": "📁 Воркфлоу",
"ult_btn_trigger_cloud_skip_confirm": "⚡ Cloud без подтверждения",
"ult_btn_create_chat": "\U0001f3d7 Создать архив",
"ult_btn_recreate_chat": "\U0001f504 Добавить архив",
"ult_btn_bind_chat": "\U0001f517 Привязать архив",
"ult_btn_remove_chat": "\U0001f5d1 Удалить цель",
"ult_btn_clear_chats": "\U0001f9f9 Очистить цели",
"ult_btn_generate": "\U0001f680 Генерировать",
"ult_btn_regenerate": "\U0001f504 Перегенерировать",
"ult_btn_cancel": "\u274c Отменить",
"ult_chat_missing": '\U0001f44e Архив генераций ещё не создан.',
"ult_chat_targets": "Цели архива: {}",
"ult_chat_target_topic": "чат {}, топик {}",
"ult_chat_target_chat": "чат {}",
"ult_chat_targets_title": '\U0001f4c1 Цели архива',
"ult_chat_targets_empty": '\u2b1c Целей архива нет.',
"ult_chat_need_create": '\U0001f44e Сначала создайте архив генераций.',
"ult_chat_created": '\u2705 Архив генераций создан и привязан.',
"ult_chat_recreated": '\u2705 Архив генераций добавлен.',
"ult_chat_create_failed": '\U0001f44e Не удалось создать архив генераций: {}',
"ult_chat_access_lost": '\U0001f44e Доступ к архиву генераций потерян. Создайте новый архив.',
"ult_chat_about": "Это архив генераций модуля Comfy ImageGen. Тут сохраняется каждая ваша генерация без потери качества и полная мета-информация о генерации. Используя команду .cshare ответом на генерацию, можно поделиться вашей генерацией в публичный SFW-канал @ComfyIdeas.",
"ult_chat_bind_input": "Введите chat_id, chat_id topic_id, chat_id:topic_id или ссылку t.me/c:",
"ult_chat_bind_bad": '\U0001f44e Не удалось распознать чат/топик.',
"ult_chat_bind_failed": '\U0001f44e Не удалось привязать архив: {}',
"ult_chat_bound": '\u2705 Архив привязан.',
"ult_chat_already_bound": '\u2705 Эта цель архива уже привязана.',
"ult_chat_target_removed": '\u2705 Цель архива удалена.',
"ult_chat_targets_cleared": '\u2705 Цели архива очищены.',
"archive_full_prompt_caption": "Полный промпт",
"archive_full_prompt_title": "Полный промпт #{}",
"cshare_no_reply": "\U0001f44e Ответьте на генерацию из архива.",
"cshare_no_archive": "\U0001f44e Ответьте на генерацию из архива.",
"cshare_no_prompt_info": "\U0001f44e Не найдено сообщение с полным промптом. Ответьте на генерацию из архива.",
"cshare_no_image": "\U0001f44e Не удалось скачать изображение генерации.",
"cshare_target_error": "\U0001f44e Не удалось отправить предложку в @ComfyIdeas: {}",
"cshare_done": "\u2705 Отправлено в @ComfyIdeas.",
"cshare_top_unavailable": "👎 Топ ComfyIdeas недоступен.",
"cshare_direct_unavailable": "\U0001f44e Откройте https://t.me/comfyideas?direct, отправьте туда любое сообщение один раз, затем повторите .cshare.",
"cshare_unknown_workflow": "Неизвестно",
"cshare_author": "Автор: {}",
"cshare_author_anon": "анонимно",
"cshare_preview_title": '\U0001f4c1 Предпросмотр ComfyIdeas',
"cshare_preview_expired": "\U0001f44e Предпросмотр устарел. Запустите .cshare заново.",
"cshare_preview_cancelled": "\u2705 Отправка в ComfyIdeas отменена.",
"cshare_preview_send_btn": "\u2705 Отправить",
"ult_confirm_title": '\U0001f916 Подтверждение AI-промпта',
"ult_confirm_source": "Исходный промпт",
"ult_confirm_result": "Улучшенный промпт",
"ult_confirm_censored": '\U0001f44e Сработала цензура провайдера. Генерация пойдёт с исходным промптом.',
"ult_confirm_model": "Модель: {}",
"ult_confirm_workflow": "Воркфлоу: {}",
"ult_toggle_saved": "Настройка обновлена",
"ult_state_expired": '\U0001f44e Это действие устарело. Запустите заново.',
"trigger_queue_full": "\U0001f44e Очередь генерации заполнена. Попробуйте позже.",
"ctools_title": "\u2699\ufe0f Comfy tools",
"ctools_usage": "Используйте .ctools -upscale (0.1-8x), .ctools -rmbg или .ctools -fps в ответ на медиа.",
"ctools_btn_upscale": "Апскейл",
"ctools_btn_rmbg": "Убрать фон",
"ctools_btn_fps": "Повысить FPS",
"ctools_desc_upscale": "-upscale (0.1-8x) - апскейл медиа.",
"ctools_desc_rmbg": "-rmbg - убрать фон.",
"ctools_desc_fps": "-fps - повысить FPS видео.",
"ctools_bad_mode": "\U0001f44e Неизвестный инструмент. Доступно: -upscale, -rmbg, -fps.",
"ctools_bad_scale": "\U0001f44e Размер должен быть от 0.1x до 8x. Рекомендуется: 2x.",
"ctools_no_reply_image": "\U0001f44e Ответьте на изображение.",
"ctools_no_reply_video": "\U0001f44e Ответьте на видео.",
"ctools_no_reply_media": "\U0001f44e Ответьте на медиа.",
"ctools_processing_upscale": "\u2699\ufe0f Апскейлю медиа...",
"ctools_processing_rmbg": "\u2699\ufe0f Убираю фон...",
"ctools_processing_fps": "\u2699\ufe0f Повышаю FPS видео...",
"ctools_uploading": "\U0001f4e4 Загружаю результат инструмента...",
"ctools_done": "\u2705 {} готов.",
"ctools_done_rmbg": "\u2705 фон убран.",
"ctools_done_fps": "\u2705 повышение fps готово.",
"ctools_workflow_no_input": "\U0001f44e В tool workflow нет подходящего {} input node.",
"ctools_workflow_no_output": "\U0001f44e В tool workflow нет подходящей save output node.",
"ctools_state_expired": "\U0001f44e Это меню инструментов устарело. Запустите .ctools снова.",
"trigger_autodelete_caption": "Автоудаление через: {}",
"update_available": '\U0001f195 Обновление ComfyImageGen\n\n{} -> {}{}\n\nУстановка:\n{}',
"update_diff": "\n\nЧто изменилось:\n{}
",
"not_set": "не задано",
"trigger_too_often": "\U0001f44e Слишком часто.",
"wf_check_output_kind": "Output kind: {}",
"wf_check_input_kind": "Input kind: {}",
"wf_check_frames": "Frames",
"wf_check_fps": "FPS",
"album_picker_title": "Выберите изображение из коллажа",
"album_picker_desc": "В коллаже {} изображений.",
"album_picker_button": "Фото {}",
"album_picker_expired": "Выбор изображения устарел. Запустите команду ещё раз.",
"album_picker_unavailable": "Не удалось загрузить выбранное изображение из коллажа.",
}
def __init__(self):
self.config = loader.ModuleConfig(
loader.ConfigValue(
"comfyui_url",
"http://127.0.0.1:8188",
lambda: self.strings("cfg_url"),
validator=loader.validators.Hidden(),
),
loader.ConfigValue(
"comfyui_backend",
_COMFY_BACKEND_LOCAL,
lambda: self.strings("cfg_backend"),
validator=loader.validators.Choice([_COMFY_BACKEND_LOCAL, _COMFY_BACKEND_CLOUD]),
),
loader.ConfigValue(
"comfyui_cloud_api_key",
"",
lambda: self.strings("cfg_cloud_api_key"),
validator=loader.validators.Hidden(),
),
loader.ConfigValue(
"model_name",
"",
lambda: self.strings("cfg_model"),
validator=loader.validators.String(),
),
loader.ConfigValue(
"max_input_mb",
10,
lambda: self.strings("cfg_max_mb"),
validator=loader.validators.Integer(minimum=1, maximum=50),
),
loader.ConfigValue(
"max_output_mb",
300,
lambda: self.strings("cfg_max_output_mb"),
validator=loader.validators.Integer(minimum=1, maximum=2000),
),
loader.ConfigValue(
"max_image_pixels",
40000000,
lambda: self.strings("cfg_max_image_pixels"),
validator=loader.validators.Integer(minimum=0, maximum=400000000),
),
loader.ConfigValue(
"output_format",
"photo",
lambda: self.strings("cfg_output_format"),
validator=loader.validators.Choice(["photo", "document_png"]),
),
loader.ConfigValue(
"ws_update_interval",
1,
lambda: self.strings("cfg_ws_update_interval"),
validator=loader.validators.Integer(minimum=0, maximum=5),
),
loader.ConfigValue(
"gemini_api_key",
"",
lambda: self.strings("cfg_gemini_api_key"),
validator=loader.validators.Hidden(),
),
loader.ConfigValue(
"groq_api_key",
"",
lambda: self.strings("cfg_groq_api_key"),
validator=loader.validators.Hidden(),
),
loader.ConfigValue(
"openrouter_api_key",
"",
lambda: self.strings("cfg_openrouter_api_key"),
validator=loader.validators.Hidden(),
),
loader.ConfigValue(
"grok_api_key",
"",
lambda: self.strings("cfg_grok_api_key"),
validator=loader.validators.Hidden(),
),
loader.ConfigValue(
"deepseek_api_key",
"",
lambda: self.strings("cfg_deepseek_api_key"),
validator=loader.validators.Hidden(),
),
loader.ConfigValue(
"nvidiaapi_api_key",
"",
lambda: self.strings("cfg_nvidiaapi_api_key"),
validator=loader.validators.Hidden(),
),
loader.ConfigValue(
"qwen_api_key",
"",
lambda: self.strings("cfg_qwen_api_key"),
validator=loader.validators.Hidden(),
),
loader.ConfigValue(
"qwen_base_url",
_QWEN_DEFAULT_BASE_URL,
lambda: self.strings("cfg_qwen_base_url"),
validator=loader.validators.String(),
),
loader.ConfigValue(
"update_assets",
0,
lambda: self.strings("cfg_update_assets"),
validator=loader.validators.Integer(minimum=0, maximum=14400),
),
loader.ConfigValue(
"info_banner_url",
_DEFAULT_INFO_BANNER_URL,
lambda: self.strings("cfg_info_banner_url"),
validator=loader.validators.String(),
),
)
self._session = None
self._semaphore = asyncio.Semaphore(1)
self._enhance_system_prompt = None
self._enhance_system_prompts = {}
self._impact_wildcard_select_text = None
self._BUILTIN_WORKFLOWS = (
_SDXL_REAL2_WORKFLOW_NAME,
_ANIME_V2_WORKFLOW_NAME,
_ANIME_V3_WORKFLOW_NAME,
_ILL_WORKFLOW_NAME,
_KREA2_WORKFLOW_NAME,
)
self._CLOUD_WORKFLOWS = (
_CLOUD_KREA2_WORKFLOW_NAME,
_CLOUD_ANIMA_WORKFLOW_NAME,
_CLOUD_ANIMA2_WORKFLOW_NAME,
_CLOUD_ILL_WORKFLOW_NAME,
_CLOUD_QWEN_WORKFLOW_NAME,
)
self._available_sam_models = None
self._lora_states = TTLCache(maxsize=50, ttl=600)
self._models_page_cache = TTLCache(maxsize=50, ttl=300)
self._wf_page_cache = TTLCache(maxsize=50, ttl=300)
self._addwf_force_states = TTLCache(maxsize=20, ttl=1800)
self._cancel_flags = TTLCache(maxsize=100, ttl=600)
self._generation_runtime = TTLCache(maxsize=100, ttl=7200)
self._comfy_cache = TTLCache(maxsize=256, ttl=300)
self._enhance_confirm_states = TTLCache(maxsize=50, ttl=1800)
self._enhance_chat_states = TTLCache(maxsize=50, ttl=1800)
self._cloud_confirm_states = TTLCache(maxsize=50, ttl=1800)
self._argset_lora_states = TTLCache(maxsize=20, ttl=1800)
self._cshare_preview_states = TTLCache(maxsize=30, ttl=900)
self._ctools_states = TTLCache(maxsize=30, ttl=600)
self._album_picker_states = TTLCache(maxsize=50, ttl=600)
self._cdown_states = TTLCache(maxsize=30, ttl=1800)
self._clib_states = TTLCache(maxsize=20, ttl=1800)
self._emoji_theme_pending = TTLCache(maxsize=30, ttl=300)
self._cdown_watch_tasks = {}
self._cdown_lora_metadata_tasks = set()
self._cloud_balance_cache = TTLCache(maxsize=10, ttl=60)
self._cloud_prompt_keys = TTLCache(maxsize=200, ttl=7200)
self._active_cloud_api_key = contextvars.ContextVar("comfyimagegen_cloud_api_key", default=None)
self._cmon_tasks = {}
self._trigger_queue_counts = {}
self._trigger_queue_lock = asyncio.Lock()
self._trigger_queue_cooldowns = TTLCache(maxsize=1000, ttl=180)
self._trigger_generation_cooldowns = TTLCache(maxsize=1000, ttl=10)
self._trigger_rate_limit_cooldowns = TTLCache(maxsize=1000, ttl=10)
self._trigger_unavailable_cooldowns = TTLCache(maxsize=1000, ttl=180)
self._translation_cache = TTLCache(maxsize=500, ttl=3600)
self._genai_client = None
self._genai_api_key = None
self._active_generations = 0
self._unloading = False
self._archive_semaphore = asyncio.Semaphore(1)
self._image_processing_semaphore = asyncio.Semaphore(1)
self._archive_tasks = set()
self._archive_target_ok = TTLCache(maxsize=50, ttl=600)
self._last_builtin_wf_retry = {}
self._builtin_wf_retry_interval = 60
self._builtin_wf_load_failed = False
self._builtin_wf_lock = asyncio.Lock()
self._update_check_task = None
self._startup_update_check_task = None
self._assets_update_task = None
self._input_cleanup_task = None
self._onboarding_task = None
self._tunnel_watch_task = None
self._tunnel_watch_token = uuid.uuid4().hex
self._tunnel_failed_checks = 0
self._tunnel_last_available = False
self._update_notice_lock = asyncio.Lock()
self._auto_delete_tasks = set()
self._input_temp_paths = set()
self._self_has_premium = False
def _ensure_session(self):
if not self._session or self._session.closed:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=120)
)
return self._session
def _input_tmp_dir(self):
path = os.path.join(tempfile.gettempdir(), "heroku_comfyimagegen_inputs")
os.makedirs(path, exist_ok=True)
return path
def _is_own_input_temp_path(self, path):
if not path:
return False
try:
base = os.path.abspath(self._input_tmp_dir())
target = os.path.abspath(path)
return os.path.commonpath([base, target]) == base
except Exception:
return False
def _cleanup_input_file(self, state_or_path):
if isinstance(state_or_path, dict):
paths = [
state_or_path.get("input_image_path"),
state_or_path.get("input_video_path"),
]
for path in paths:
self._cleanup_input_file(path)
state_or_path["input_image_path"] = None
state_or_path["input_video_path"] = None
return
path = state_or_path
if not self._is_own_input_temp_path(path):
return
try:
if os.path.exists(path):
os.remove(path)
except Exception as e:
logger.debug("Failed to remove temporary input image: %s", e)
finally:
self._input_temp_paths.discard(path)
def _cleanup_stale_input_files(self, max_age=3600):
try:
base = self._input_tmp_dir()
now = time.time()
for name in os.listdir(base):
path = os.path.join(base, name)
if not os.path.isfile(path):
continue
if now - os.path.getmtime(path) >= max_age:
self._cleanup_input_file(path)
except Exception as e:
logger.debug("Failed to cleanup stale input images: %s", e)
def _cleanup_all_input_files(self):
for cache in (self._enhance_confirm_states, self._lora_states):
for state in list(cache.values()):
self._cleanup_input_file(state)
for path in list(self._input_temp_paths):
self._cleanup_input_file(path)
try:
base = self._input_tmp_dir()
for name in os.listdir(base):
self._cleanup_input_file(os.path.join(base, name))
except Exception as e:
logger.debug("Failed to cleanup input image directory: %s", e)
async def _input_cleanup_loop(self):
while not self._unloading:
await asyncio.sleep(900)
self._cleanup_stale_input_files()
async def _download_input_image_to_temp(self, reply):
return await self._download_input_media_to_temp(
reply,
prefix="input",
default_suffix=".jpg",
)
def _media_suffix_from_reply(self, reply, default=".bin"):
name = getattr(getattr(reply, "file", None), "name", None)
if name and "." in name:
suffix = "." + name.rsplit(".", 1)[-1].lower()
if len(suffix) <= 12:
return suffix
mime = getattr(getattr(reply, "file", None), "mime_type", None)
suffix = mimetypes.guess_extension(str(mime or "").split(";", 1)[0].strip())
return suffix or default
async def _download_input_media_to_temp(self, reply, prefix="input", default_suffix=".bin"):
max_mb = self.config["max_input_mb"]
if reply.file and reply.file.size > max_mb * 1024 * 1024:
raise UserFacingError("input_too_large", max_mb=max_mb)
self._cleanup_stale_input_files()
suffix = self._media_suffix_from_reply(reply, default_suffix)
input_name = f"{prefix}_{uuid.uuid4().hex}{suffix}"
path = os.path.join(self._input_tmp_dir(), input_name)
try:
downloaded = await self.client.download_media(reply, path)
path = downloaded or path
self._input_temp_paths.add(path)
if os.path.getsize(path) > max_mb * 1024 * 1024:
self._cleanup_input_file(path)
raise UserFacingError("input_too_large", max_mb=max_mb)
return path, os.path.basename(path)
except Exception:
self._cleanup_input_file(path)
raise
async def _upload_input_path_to_comfyui(self, path, filename, attempts=3, delay=2, content_type=None):
last_err = None
for i in range(attempts):
try:
with open(path, "rb") as img_bio:
return await self._upload_to_comfyui(img_bio, filename, content_type=content_type)
except (aiohttp.ClientError, asyncio.TimeoutError, OSError, ComfyUIHTTPError) as e:
last_err = e
temporary = getattr(e, "temporary", False) or isinstance(e, (aiohttp.ClientError, asyncio.TimeoutError, OSError))
if not temporary:
raise
if i < attempts - 1:
await asyncio.sleep(delay)
raise last_err or ValueError("upload failed")
async def _upload_state_input_image(self, state):
input_filename = state.get("input_filename")
if input_filename:
return input_filename
path = state.get("input_image_path")
if not path:
return None
if not os.path.exists(path):
raise UserFacingError("upload_failed", self._plain_text(self.strings("err_upload_failed")))
filename = state.get("input_image_name") or f"input_{uuid.uuid4().hex}.png"
try:
input_filename = await self._upload_input_path_to_comfyui(path, filename)
state["input_filename"] = input_filename
return input_filename
finally:
self._cleanup_input_file(state)
async def _upload_state_input_video(self, state):
input_filename = state.get("input_video_filename")
if input_filename:
return input_filename
path = state.get("input_video_path")
if not path:
return None
if not os.path.exists(path):
raise UserFacingError("upload_failed", self._plain_text(self.strings("err_upload_failed")))
filename = state.get("input_video_name") or f"input_{uuid.uuid4().hex}.mp4"
try:
input_filename = await self._upload_input_path_to_comfyui(
path,
filename,
content_type=mimetypes.guess_type(filename)[0],
)
state["input_video_filename"] = input_filename
return input_filename
finally:
self._cleanup_input_file(state)
def _track_auto_delete(self, message_to_delete, delay):
if self._unloading:
return None
task = asyncio.create_task(self._schedule_delete_message(message_to_delete, delay))
self._auto_delete_tasks.add(task)
task.add_done_callback(self._auto_delete_tasks.discard)
return task
def _session_get(self, *args, **kwargs):
return self._ensure_session().get(*args, **kwargs)
def _session_post(self, *args, **kwargs):
return self._ensure_session().post(*args, **kwargs)
def _session_put(self, *args, **kwargs):
return self._ensure_session().put(*args, **kwargs)
def _session_delete(self, *args, **kwargs):
return self._ensure_session().delete(*args, **kwargs)
def _session_ws_connect(self, *args, **kwargs):
return self._ensure_session().ws_connect(*args, **kwargs)
def _builtin_workflow_cache_key(self, wf_name):
return f"builtin_wf_{wf_name}"
def _builtin_workflow_version_key(self, wf_name):
return f"builtin_wf_version_{wf_name}"
def _builtin_workflow_source_key(self, wf_name):
return f"builtin_wf_source_{wf_name}"
def _all_builtin_workflows(self):
return tuple(self._BUILTIN_WORKFLOWS) + tuple(self._CLOUD_WORKFLOWS)
def _is_builtin_workflow(self, wf_name):
return wf_name in self._all_builtin_workflows()
def _is_cloud_workflow_name(self, wf_name):
return wf_name in self._CLOUD_WORKFLOWS
def _builtin_workflow_url(self, wf_name):
return _BUILTIN_WORKFLOW_URLS.get(wf_name) or _CLOUD_WORKFLOW_URLS.get(wf_name)
def _builtin_workflow_telegram_url(self, wf_name):
return _BUILTIN_WORKFLOW_TELEGRAM_URLS.get(wf_name) or _CLOUD_WORKFLOW_TELEGRAM_URLS.get(wf_name)
@staticmethod
def _parse_telegram_message_url(url):
parsed = urlparse(str(url or ""))
path_parts = [part for part in parsed.path.split("/") if part]
if parsed.netloc not in ("t.me", "telegram.me") or len(path_parts) < 2:
raise ValueError("Invalid Telegram message URL")
if path_parts[0] == "c" and len(path_parts) >= 3:
chat = int(f"-100{path_parts[1]}")
msg_id = int(path_parts[2])
return chat, msg_id
return path_parts[0], int(path_parts[1])
def _normalize_builtin_workflow_payload(self, wf):
workflow = self._normalize_workflow_format(wf)
if not isinstance(workflow, dict) or not workflow:
raise ValueError("Invalid workflow JSON")
return workflow
async def _fetch_builtin_workflow_from_github(self, wf_name, url):
async with self._session_get(
url,
timeout=aiohttp.ClientTimeout(total=30),
headers={"Cache-Control": "no-cache"},
params={"t": int(time.time())},
) as resp:
if resp.status != 200:
text = await resp.text()
raise RuntimeError(f"HTTP {resp.status}: {text[:200]}")
return self._normalize_builtin_workflow_payload(await resp.json(content_type=None))
async def _fetch_builtin_workflow_from_telegram(self, wf_name, url):
chat, msg_id = self._parse_telegram_message_url(url)
message = await self.client.get_messages(chat, ids=msg_id)
if not message:
raise RuntimeError("Telegram message not found")
raw = None
if getattr(message, "media", None):
raw = await message.download_media(bytes)
if not raw:
text = getattr(message, "raw_text", None) or getattr(message, "text", None)
raw = text.encode("utf-8") if isinstance(text, str) else None
if not raw:
raise RuntimeError("Telegram message has no workflow file")
if len(raw) > 10 * 1024 * 1024:
raise RuntimeError("Telegram workflow file is too large")
try:
wf = json.loads(raw.decode("utf-8-sig"))
except UnicodeDecodeError:
wf = json.loads(raw.decode("utf-8", errors="ignore"))
return self._normalize_builtin_workflow_payload(wf)
def _save_builtin_workflow(self, wf_name, workflow, source):
cache_key = self._builtin_workflow_cache_key(wf_name)
version_key = self._builtin_workflow_version_key(wf_name)
source_key = self._builtin_workflow_source_key(wf_name)
self.set(cache_key, workflow)
self.set(version_key, __version__)
self.set(source_key, source)
async def _fetch_builtin_workflow(self, wf_name=_ANIME_V2_WORKFLOW_NAME, force=False):
wf_name = self._canonical_workflow_name(wf_name)
cache_key = self._builtin_workflow_cache_key(wf_name)
version_key = self._builtin_workflow_version_key(wf_name)
cached_version = self.get(version_key)
if not force and cached_version == __version__ and self.get(cache_key):
return
url = self._builtin_workflow_url(wf_name)
if not url:
raise RuntimeError(self._plain_text(self.strings("err_workflow_download")))
github_error = None
try:
wf = await self._fetch_builtin_workflow_from_github(wf_name, url)
self._save_builtin_workflow(wf_name, wf, "github")
logger.debug("Loaded builtin workflow %s from GitHub", wf_name)
return
except Exception as e:
github_error = e
logger.warning("Failed to fetch builtin workflow %s from GitHub: %s", wf_name, e)
telegram_url = self._builtin_workflow_telegram_url(wf_name)
telegram_error = None
if telegram_url:
try:
wf = await self._fetch_builtin_workflow_from_telegram(wf_name, telegram_url)
self._save_builtin_workflow(wf_name, wf, "telegram")
logger.info("Loaded builtin workflow %s from Telegram fallback", wf_name)
return
except Exception as e:
telegram_error = e
logger.warning("Failed to fetch builtin workflow %s from Telegram: %s", wf_name, e)
if not self.get(cache_key):
if telegram_error:
logger.error(
"Builtin workflow %s unavailable; GitHub failed: %s; Telegram failed: %s",
wf_name,
github_error,
telegram_error,
)
else:
logger.error("Builtin workflow %s unavailable; GitHub failed: %s", wf_name, github_error)
raise RuntimeError(self._plain_text(self.strings("err_workflow_download")))
self.set(self._builtin_workflow_source_key(wf_name), "cache")
logger.warning("Using cached builtin workflow %s after remote fetch failure", wf_name)
def _ctool_definitions(self):
cloud = self._is_comfy_cloud()
upscale_url = _CLOUD_UPSCALE_WF_URL if cloud else _UPSCALE_WF_URL
upscale_telegram_url = _CLOUD_UPSCALE_WORKFLOW_TELEGRAM_URL if cloud else _UPSCALE_WORKFLOW_TELEGRAM_URL
video_upscale_url = _CLOUD_VIDEO_UPSCALE_WF_URL if cloud else _VIDEO_UPSCALE_WF_URL
video_upscale_telegram_url = _CLOUD_VIDEO_UPSCALE_WORKFLOW_TELEGRAM_URL if cloud else None
return {
_CTOOL_UPSCALE: {
"label": self.strings("ctools_btn_upscale"),
"processing_key": "ctools_processing_upscale",
"input_kind": "image",
"output_kind": "image",
"url": upscale_url,
"telegram_url": upscale_telegram_url,
},
_CTOOL_VIDEO_UPSCALE: {
"label": self.strings("ctools_btn_upscale"),
"processing_key": "ctools_processing_upscale",
"input_kind": "video",
"output_kind": "video",
"url": video_upscale_url,
"telegram_url": video_upscale_telegram_url,
},
_CTOOL_RMBG: {
"label": self.strings("ctools_btn_rmbg"),
"processing_key": "ctools_processing_rmbg",
"done_key": "ctools_done_rmbg",
"input_kind": "image",
"output_kind": "image",
"url": _BGRM_WF_URL,
"telegram_url": _BGRM_WORKFLOW_TELEGRAM_URL,
},
_CTOOL_FPS: {
"label": self.strings("ctools_btn_fps"),
"processing_key": "ctools_processing_fps",
"done_key": "ctools_done_fps",
"input_kind": "video",
"output_kind": "video",
"url": _FRAMES_WF_URL,
"telegram_url": _FRAMES_WORKFLOW_TELEGRAM_URL,
},
}
def _canonical_ctool_id(self, raw):
value = str(raw or "").strip().lower().lstrip("-")
aliases = {
"cupscale": _CTOOL_UPSCALE,
"upscale": _CTOOL_UPSCALE,
"scale": _CTOOL_UPSCALE,
"rmbg": _CTOOL_RMBG,
"bgremove": _CTOOL_RMBG,
"removebg": _CTOOL_RMBG,
"remove-background": _CTOOL_RMBG,
"fps": _CTOOL_FPS,
"frames": _CTOOL_FPS,
"v2v": _CTOOL_FPS,
}
return aliases.get(value)
async def _fetch_ctool_workflow(self, tool_id, force=False):
tool = self._ctool_definitions().get(tool_id)
if not tool:
raise ValueError("unknown ctool")
backend = self._comfy_backend()
cache_key = f"ctool_wf_{backend}_{tool_id}"
version_key = f"ctool_wf_version_{backend}_{tool_id}"
cached = self.get(cache_key)
if not force and self.get(version_key) == __version__ and cached:
return self._normalize_builtin_workflow_payload(cached)
github_error = None
try:
workflow = await self._fetch_builtin_workflow_from_github(tool_id, tool["url"])
self.set(cache_key, workflow)
self.set(version_key, __version__)
return workflow
except Exception as e:
github_error = e
logger.warning("Failed to fetch ctool workflow %s from GitHub: %s", tool_id, e)
telegram_url = tool.get("telegram_url")
if telegram_url:
try:
workflow = await self._fetch_builtin_workflow_from_telegram(tool_id, telegram_url)
self.set(cache_key, workflow)
self.set(version_key, __version__)
return workflow
except Exception as e:
logger.warning("Failed to fetch ctool workflow %s from Telegram: %s", tool_id, e)
if cached:
return self._normalize_builtin_workflow_payload(cached)
raise RuntimeError(f"{github_error}; telegram fallback: {e}") from e
if cached:
return self._normalize_builtin_workflow_payload(cached)
raise RuntimeError(str(github_error)) from github_error
def _normalize_enhance_prompt_url(self, url):
url = str(url or "").strip()
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return ""
return url
def _get_comfy_text_workflow_url(self):
custom_url = self._normalize_enhance_prompt_url(self.get("comfy_text_workflow_url", ""))
return custom_url or _COMFY_TEXT_WORKFLOW_URL
def _comfy_text_workflow_is_custom(self):
return bool(self._normalize_enhance_prompt_url(self.get("comfy_text_workflow_url", "")))
def _set_comfy_text_workflow_url(self, url):
url = self._normalize_enhance_prompt_url(url)
if not url:
return False
self.set("comfy_text_workflow_url", url)
self.set("comfy_text_workflow_cache", {})
return True
def _reset_comfy_text_workflow_url(self):
self.set("comfy_text_workflow_url", "")
self.set("comfy_text_workflow_cache", {})
def _comfy_text_workflow_source_label(self):
key = "comfy_text_workflow_source_custom" if self._comfy_text_workflow_is_custom() else "comfy_text_workflow_source_default"
return self.strings(key)
async def _fetch_comfy_text_workflow(self, force=False):
url = self._get_comfy_text_workflow_url()
cache = self.get("comfy_text_workflow_cache", {})
if not isinstance(cache, dict):
cache = {}
cached_workflow = cache.get("workflow")
if (
not force
and cache.get("url") == url
and cache.get("revision") == _COMFY_TEXT_WORKFLOW_CACHE_REVISION
and cached_workflow
):
return self._normalize_builtin_workflow_payload(cached_workflow)
try:
workflow = await self._fetch_builtin_workflow_from_github("comfy_text", url)
self.set(
"comfy_text_workflow_cache",
{
"url": url,
"workflow": workflow,
"version": __version__,
"revision": _COMFY_TEXT_WORKFLOW_CACHE_REVISION,
},
)
return workflow
except Exception as e:
logger.warning("Failed to fetch ComfyUI Text workflow from %s: %s", url, e)
if cache.get("url") == url and cached_workflow:
return self._normalize_builtin_workflow_payload(cached_workflow)
raise
def _get_enhance_prompt_urls(self):
urls = self.get("enhance_prompt_urls", {})
if not isinstance(urls, dict):
urls = {}
normalized = {}
for provider, url in urls.items():
provider = str(provider or "").strip().lower()
if provider in self._external_provider_ids():
url = self._normalize_enhance_prompt_url(url)
if url:
normalized[provider] = url
if normalized != urls:
self.set("enhance_prompt_urls", normalized)
return normalized
def _get_enhance_prompt_url(self, provider):
provider = str(provider or "").strip().lower()
if not self._provider_supports_prompt_template(provider):
return ""
return self._get_enhance_prompt_urls().get(provider) or _ENHANCE_PROMPT_URL
def _set_enhance_prompt_url(self, provider, url):
provider = str(provider or "").strip().lower()
url = self._normalize_enhance_prompt_url(url)
if not self._provider_supports_prompt_template(provider) or not url:
return False
urls = self._get_enhance_prompt_urls()
urls[provider] = url
self.set("enhance_prompt_urls", urls)
self._enhance_system_prompts.pop(provider, None)
return True
def _reset_enhance_prompt_url(self, provider):
provider = str(provider or "").strip().lower()
if not self._provider_supports_prompt_template(provider):
return
urls = self._get_enhance_prompt_urls()
urls.pop(provider, None)
self.set("enhance_prompt_urls", urls)
self._enhance_system_prompts.pop(provider, None)
def _get_enhance_prompt_cache(self):
cache = self.get("enhance_system_prompt_cache", {})
return cache if isinstance(cache, dict) else {}
async def _fetch_enhance_prompt(self, provider=None, force=False):
provider = str(provider or self._get_prompt_provider() or "deepseek").strip().lower()
if provider not in self._external_provider_ids():
provider = "deepseek"
url = self._get_enhance_prompt_url(provider)
cache = self._get_enhance_prompt_cache()
cached = cache.get(provider, {})
if not isinstance(cached, dict):
cached = {}
cached_text = cached.get("text")
if not force and cached.get("version") == __version__ and cached.get("url") == url and cached_text:
self._enhance_system_prompts[provider] = cached_text
if provider == self._get_prompt_provider():
self._enhance_system_prompt = cached_text
return cached_text
try:
async with self._session_get(
url,
timeout=aiohttp.ClientTimeout(total=30),
headers={"Cache-Control": "no-cache"},
params={"t": int(time.time())},
) as resp:
if resp.status != 200:
body = await resp.text()
raise RuntimeError(f"HTTP {resp.status}: {body[:200]}")
text = await resp.text()
if text and len(text) > 100:
self._enhance_system_prompts[provider] = text
if provider == self._get_prompt_provider():
self._enhance_system_prompt = text
cache[provider] = {"text": text, "url": url, "version": __version__}
self.set("enhance_system_prompt_cache", cache)
if url == _ENHANCE_PROMPT_URL:
self.set("enhance_system_prompt", text)
self.set("enhance_prompt_version", __version__)
return text
raise ValueError("Invalid enhance system prompt")
except Exception as e:
logger.warning("Failed to fetch enhance system prompt for %s from %s: %s", provider, url, e)
if cached_text:
self._enhance_system_prompts[provider] = cached_text
if provider == self._get_prompt_provider():
self._enhance_system_prompt = cached_text
logger.warning("Using cached enhance system prompt after remote fetch failure")
return cached_text
legacy_text = self.get("enhance_system_prompt")
if url == _ENHANCE_PROMPT_URL and legacy_text:
self._enhance_system_prompts[provider] = legacy_text
if provider == self._get_prompt_provider():
self._enhance_system_prompt = legacy_text
logger.warning("Using legacy cached enhance system prompt after remote fetch failure")
return legacy_text
logger.warning("Enhance system prompt not available for %s, AI prompt enhancement will be disabled", provider)
return None
def _format_module_version(self, version_tuple):
return ".".join(map(str, version_tuple))
def _is_remote_version_newer(self, remote_version):
return tuple(remote_version) > tuple(__version__)
def _update_notice_key(self, installed_version_text, remote_version_text):
return f"{installed_version_text}->{remote_version_text}"
def _get_update_notice_counts(self):
counts = self.get("update_notice_counts", {})
return counts if isinstance(counts, dict) else {}
def _get_update_notice_count(self, notice_key, remote_version_text):
counts = self._get_update_notice_counts()
count = self._coerce_int(
counts.get(notice_key),
0,
0,
_UPDATE_NOTICE_LIMIT,
)
if count == 0 and self.get("last_update_notice_version") == remote_version_text:
count = 1
return count
def _mark_update_notice_sent(self, notice_key, remote_version_text):
counts = self._get_update_notice_counts()
current = self._get_update_notice_count(notice_key, remote_version_text)
counts[notice_key] = min(_UPDATE_NOTICE_LIMIT, current + 1)
self.set("update_notice_counts", counts)
self.set("last_update_notice_version", remote_version_text)
def _parse_remote_module_version(self, text):
match = re.search(r"__version__\s*=\s*(\([^)]+\))", text)
if not match:
return None
try:
version = tuple(
int(part.strip())
for part in match.group(1).strip("()").split(",")
if part.strip()
)
except Exception:
return None
return version if len(version) == 3 else None
def _parse_remote_module_diff(self, text):
inline_match = re.search(
r"#\s*Diff:\s*(.*?)(?=\s+#\s*[A-Za-zА-Яа-я_ -]{1,40}:|$)",
text,
re.S,
)
if inline_match:
diff = inline_match.group(1).strip()
return re.sub(r"\s+", " ", diff)[:1200]
lines = text.splitlines()
diff_lines = []
capture = False
for line in lines:
normalized = line.strip().lstrip("#").strip()
if not capture:
if normalized.lower().startswith("diff:"):
value = normalized[5:].strip()
if value:
diff_lines.append(value)
capture = True
continue
if not line.strip().startswith("#"):
break
if normalized and re.match(r"^[A-Za-zА-Яа-я_ -]{1,40}:", normalized) and not normalized.lower().startswith("diff:"):
break
if normalized:
diff_lines.append(normalized)
return "\n".join(diff_lines).strip()[:1200]
async def _fetch_remote_module_info(self):
async with self._session_get(
_MODULE_UPDATE_URL,
timeout=aiohttp.ClientTimeout(total=30),
headers={"Cache-Control": "no-cache"},
params={"t": int(time.time())},
) as resp:
if resp.status != 200:
return None, ""
text = await resp.text()
return self._parse_remote_module_version(text), self._parse_remote_module_diff(text)
async def _send_update_notice(self, text):
try:
await self.inline.bot.send_message(
self.tg_id,
text,
disable_web_page_preview=True,
)
return True
except Exception as e:
logger.debug("Inline update notice failed: %s", e)
try:
await self.client.send_message(
self.tg_id,
text,
link_preview=False,
)
return True
except Exception as e:
logger.debug("Client update notice failed: %s", e)
return False
async def _check_github_update(self):
try:
async with self._update_notice_lock:
remote_version, diff = await self._fetch_remote_module_info()
if not remote_version:
return None
if not self._is_remote_version_newer(remote_version):
if remote_version == __version__:
self.set("last_update_notice_version", None)
return False
remote_version_text = self._format_module_version(remote_version)
installed_version_text = self._format_module_version(__version__)
notice_key = self._update_notice_key(installed_version_text, remote_version_text)
if self._get_update_notice_count(notice_key, remote_version_text) >= _UPDATE_NOTICE_LIMIT:
return False
prefix = self.get_prefix()
install_command = f"{prefix}dlm {_MODULE_UPDATE_URL}"
diff_text = (
self.strings("update_diff").format(utils.escape_html(diff))
if diff
else ""
)
text = self.strings("update_available").format(
installed_version_text,
remote_version_text,
diff_text,
utils.escape_html(install_command),
)
if await self._send_update_notice(text):
self._mark_update_notice_sent(notice_key, remote_version_text)
return True
return None
except asyncio.CancelledError:
raise
except Exception as e:
logger.debug("GitHub update check failed: %s", e)
return None
async def _github_update_poller(self):
while not self._unloading:
await asyncio.sleep(21600)
await self._check_github_update()
async def _startup_update_check(self):
await asyncio.sleep(_STARTUP_UPDATE_CHECK_DELAY)
for attempt in range(_STARTUP_UPDATE_CHECK_ATTEMPTS):
if self._unloading:
return
result = await self._check_github_update()
if result is not None:
return
if attempt < _STARTUP_UPDATE_CHECK_ATTEMPTS - 1:
await asyncio.sleep(30 * (attempt + 1))
def _assets_update_interval(self):
interval = self._coerce_int(
self.config["update_assets"],
0,
0,
14400,
)
if interval == 0:
return 0
return max(60, interval)
async def _update_assets(self, force=False):
ok = True
for wf_name in self._all_builtin_workflows():
if not await self._ensure_builtin_workflow(wf_name, force=force):
ok = False
for tool_id in self._ctool_definitions():
try:
await self._fetch_ctool_workflow(tool_id, force=force)
except Exception as e:
ok = False
logger.warning("Failed to update ctool workflow %s: %s", tool_id, e)
try:
for provider in self._external_provider_ids():
await self._fetch_enhance_prompt(provider, force=force)
except Exception as e:
ok = False
logger.warning("Failed to update enhance prompt: %s", e)
try:
await self._fetch_comfy_text_workflow(force=force)
except Exception as e:
ok = False
logger.warning("Failed to update ComfyUI Text workflow: %s", e)
return ok
async def _assets_update_loop(self):
while not self._unloading:
interval = self._assets_update_interval()
if not interval:
await asyncio.sleep(30)
continue
try:
await self._update_assets(force=True)
except Exception as e:
logger.warning("Background assets update failed: %s", e)
await asyncio.sleep(interval)
def _tunnel_watch_is_owner(self):
return self.get(_TUNNEL_WATCH_OWNER_KEY) == self._tunnel_watch_token
def _claim_tunnel_watch(self):
self.set(_TUNNEL_WATCH_OWNER_KEY, self._tunnel_watch_token)
def _saved_tunnel_available(self):
state = self.get(_TUNNEL_WATCH_STATE_KEY, {})
if not isinstance(state, dict):
return False
current_url = self._local_base_url()
if not current_url or state.get("url") != current_url:
return False
return bool(state.get("available", False))
def _save_tunnel_available(self, available):
current_url = self._local_base_url()
if not current_url:
return
self.set(
_TUNNEL_WATCH_STATE_KEY,
{
"url": current_url,
"available": bool(available),
"updated_at": int(time.time()),
},
)
def _restore_tunnel_watch_state(self):
self._tunnel_failed_checks = 0
self._tunnel_last_available = self._saved_tunnel_available()
async def _cancel_stale_tunnel_watch_tasks(self):
current_task = asyncio.current_task()
stale_tasks = []
for task in asyncio.all_tasks():
if task is current_task or task.done():
continue
try:
coro = task.get_coro()
code = getattr(coro, "cr_code", None)
frame = getattr(coro, "cr_frame", None)
task_owner = frame.f_locals.get("self") if frame is not None else None
except Exception:
continue
if (
getattr(code, "co_name", None) == "_tunnel_watch_loop"
and task_owner is not self
and type(task_owner).__name__ == type(self).__name__
):
task.cancel()
stale_tasks.append(task)
if stale_tasks:
await asyncio.gather(*stale_tasks, return_exceptions=True)
logger.debug("Cancelled %d stale tunnel watcher task(s)", len(stale_tasks))
def _start_tunnel_watch_task(self):
self._claim_tunnel_watch()
if self._tunnel_watch_task is None or self._tunnel_watch_task.done():
self._tunnel_watch_task = asyncio.create_task(self._tunnel_watch_loop())
return self._tunnel_watch_task
async def _quick_tunnel_available(self):
if self._is_comfy_cloud():
return False
base = self._local_base_url()
if not base:
return False
try:
async with self._session_get(
f"{base}/system_stats",
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
await resp.read()
return resp.status == 200
except Exception as e:
logger.debug("ComfyUI tunnel quick check failed: %s", e)
return False
async def _send_tunnel_available_notice(self):
text = self.strings("tunnel_available")
sent_any = False
targets = self._get_tunnel_notify_targets()
for target in targets:
chat_id = target.get("chat_id")
if not chat_id:
continue
if target.get("via_client"):
kwargs = {"link_preview": False}
if target.get("topic_id") is not None:
kwargs["reply_to"] = target["topic_id"]
try:
await self.client.send_message(chat_id, text, **kwargs)
sent_any = True
except Exception as e:
logger.debug(
"Client tunnel notice failed for %s/%s: %s",
chat_id,
target.get("topic_id"),
e,
)
continue
kwargs = {"disable_web_page_preview": True}
if target.get("topic_id") is not None:
kwargs["message_thread_id"] = target["topic_id"]
try:
await self.inline.bot.send_message(chat_id, text, **kwargs)
sent_any = True
except Exception as e:
logger.debug(
"Inline tunnel notice failed for %s/%s: %s",
chat_id,
target.get("topic_id"),
e,
)
if target.get("bot_pm"):
try:
await self.client.send_message(
self.tg_id,
text,
link_preview=False,
)
sent_any = True
except Exception as fallback_error:
logger.debug("Client tunnel notice fallback failed: %s", fallback_error)
return sent_any
async def _tunnel_watch_loop(self):
while not self._unloading:
if not self._tunnel_watch_is_owner():
return
try:
if self._tunnel_notify_enabled() and not self._is_comfy_cloud():
available = await self._quick_tunnel_available()
if not self._tunnel_watch_is_owner():
return
if available:
self._tunnel_failed_checks = 0
if not self._tunnel_last_available:
notified = await self._send_tunnel_available_notice()
if notified:
self._tunnel_last_available = True
self._save_tunnel_available(True)
else:
self._tunnel_failed_checks += 1
if self._tunnel_failed_checks >= _TUNNEL_FAILURE_THRESHOLD:
if self._tunnel_last_available or self._tunnel_failed_checks == _TUNNEL_FAILURE_THRESHOLD:
self._save_tunnel_available(False)
self._tunnel_last_available = False
else:
self._tunnel_failed_checks = 0
except asyncio.CancelledError:
raise
except Exception as e:
logger.debug("ComfyUI tunnel watcher failed: %s", e)
await asyncio.sleep(_TUNNEL_CHECK_INTERVAL)
async def on_dlmod(self):
if self.get("onboarding_completed", False) or self.get("onboarding_shown", False):
return
self.set("onboarding_pending", True)
async def client_ready(self, client, db):
self._unloading = False
self._claim_tunnel_watch()
await self._cancel_stale_tunnel_watch_tasks()
self._restore_tunnel_watch_state()
self._ensure_session()
self._cleanup_all_input_files()
try:
me = await client.get_me()
self._self_has_premium = bool(getattr(me, "premium", False))
except Exception as e:
self._self_has_premium = False
logger.debug("Failed to detect account premium state: %s", e)
try:
await self._fetch_builtin_workflow()
self._builtin_wf_load_failed = False
except Exception as e:
self._builtin_wf_load_failed = True
logger.exception(e)
await self._fetch_enhance_prompt()
try:
object_info = await self._get_object_info("ImpactWildcardProcessor")
if object_info:
wc_list = self._parse_object_info_list(
object_info, "ImpactWildcardProcessor", "Select to add Wildcard"
)
if wc_list:
self._impact_wildcard_select_text = wc_list[0]
except Exception as e:
logger.exception(e)
if self._impact_wildcard_select_text is None:
self._impact_wildcard_select_text = "Select the Wildcard to add to the text"
try:
self._available_sam_models = self._parse_object_info_list(
await self._get_object_info("SAMLoader"), "SAMLoader", "model_name"
)
except Exception as e:
logger.exception(e)
self._available_sam_models = []
current_workflow = self.get("default_workflow", _DEFAULT_WORKFLOW_NAME)
canonical_workflow = self._canonical_workflow_name(current_workflow)
if canonical_workflow not in self._get_all_workflow_names():
canonical_workflow = (
_DEFAULT_CLOUD_WORKFLOW_NAME
if self._is_comfy_cloud()
else _DEFAULT_WORKFLOW_NAME
)
if canonical_workflow != current_workflow:
self.set("default_workflow", canonical_workflow)
if self._is_builtin_workflow(canonical_workflow):
await self._ensure_builtin_workflow(canonical_workflow)
self._ensure_positive_settings()
self._ensure_negative_settings()
self._ensure_default_args()
self._ensure_ult_settings()
await self._ensure_gens_archive_autocreated()
self._ensure_ai_settings()
if self._update_check_task is None or self._update_check_task.done():
self._update_check_task = asyncio.create_task(self._github_update_poller())
if self._startup_update_check_task is None or self._startup_update_check_task.done():
self._startup_update_check_task = asyncio.create_task(self._startup_update_check())
if self._assets_update_task is None or self._assets_update_task.done():
self._assets_update_task = asyncio.create_task(self._assets_update_loop())
if self._input_cleanup_task is None or self._input_cleanup_task.done():
self._input_cleanup_task = asyncio.create_task(self._input_cleanup_loop())
if self.get("onboarding_pending", False) and not self.get("onboarding_shown", False):
if self._onboarding_task is None or self._onboarding_task.done():
self._onboarding_task = asyncio.create_task(self._show_onboarding_after_install())
self._start_tunnel_watch_task()
async def on_unload(self):
self._unloading = True
if self._tunnel_watch_is_owner():
self.set(_TUNNEL_WATCH_OWNER_KEY, None)
if self._auto_delete_tasks:
for task in list(self._auto_delete_tasks):
task.cancel()
await asyncio.gather(*self._auto_delete_tasks, return_exceptions=True)
self._auto_delete_tasks.clear()
if self._archive_tasks:
for task in list(self._archive_tasks):
task.cancel()
await asyncio.gather(*self._archive_tasks, return_exceptions=True)
self._archive_tasks.clear()
if self._cmon_tasks:
cmon_entries = list(self._cmon_tasks.values())
cmon_tasks = [
entry.get("task") if isinstance(entry, dict) else entry
for entry in cmon_entries
]
cmon_tasks = [task for task in cmon_tasks if task]
for task in cmon_tasks:
task.cancel()
await asyncio.gather(*cmon_tasks, return_exceptions=True)
self._cmon_tasks.clear()
if self._cdown_watch_tasks:
cdown_tasks = list(self._cdown_watch_tasks.values())
for task in cdown_tasks:
task.cancel()
await asyncio.gather(*cdown_tasks, return_exceptions=True)
self._cdown_watch_tasks.clear()
if self._cdown_lora_metadata_tasks:
cdown_metadata_tasks = list(self._cdown_lora_metadata_tasks)
for task in cdown_metadata_tasks:
task.cancel()
await asyncio.gather(*cdown_metadata_tasks, return_exceptions=True)
self._cdown_lora_metadata_tasks.clear()
if self._input_cleanup_task:
self._input_cleanup_task.cancel()
try:
await self._input_cleanup_task
except asyncio.CancelledError:
pass
if self._onboarding_task:
self._onboarding_task.cancel()
try:
await self._onboarding_task
except asyncio.CancelledError:
pass
if self._assets_update_task:
self._assets_update_task.cancel()
try:
await self._assets_update_task
except asyncio.CancelledError:
pass
if self._startup_update_check_task:
self._startup_update_check_task.cancel()
try:
await self._startup_update_check_task
except asyncio.CancelledError:
pass
if self._update_check_task:
self._update_check_task.cancel()
try:
await self._update_check_task
except asyncio.CancelledError:
pass
if self._tunnel_watch_task:
self._tunnel_watch_task.cancel()
try:
await self._tunnel_watch_task
except asyncio.CancelledError:
pass
if self._session and not self._session.closed and self._active_generations > 0:
try:
await self._interrupt_generation()
except Exception as e:
logger.exception(e)
self._cancel_flags.clear()
self._generation_runtime.clear()
self._cshare_preview_states.clear()
self._cleanup_all_input_files()
if self._session:
await self._session.close()
def _default_ai_settings(self):
return {
"provider": "deepseek",
"gemini": {
"model": "gemini-2.5-flash",
},
"groq": {},
"openrouter": {
"model": "cognitivecomputations/dolphin-mistral-24b-venice-edition:free",
},
"grok": {
"model": "grok-4.20",
},
"qwen": {
"model": "qwen3.7-flash",
},
"deepseek": {
"model": "deepseek-v4-pro",
},
"nvidiaapi": {
"model": "deepseek-ai/deepseek-v4-flash-0731",
},
_COMFY_TEXT_PROVIDER: {},
}
@staticmethod
def _provider_ids():
return (
"gemini",
"groq",
"openrouter",
"grok",
"qwen",
"deepseek",
"nvidiaapi",
_COMFY_TEXT_PROVIDER,
)
@staticmethod
def _external_provider_ids():
return ("gemini", "groq", "openrouter", "grok", "qwen", "deepseek", "nvidiaapi")
@staticmethod
def _provider_supports_prompt_template(provider):
return provider in ComfyImageGenMod._external_provider_ids()
@staticmethod
def _provider_config_key(provider):
keys = {
"gemini": "gemini_api_key",
"groq": "groq_api_key",
"openrouter": "openrouter_api_key",
"grok": "grok_api_key",
"qwen": "qwen_api_key",
"deepseek": "deepseek_api_key",
"nvidiaapi": "nvidiaapi_api_key",
}
return keys.get(provider)
@staticmethod
def _provider_model_presets(provider):
if provider == "qwen":
return (
"qwen3.7-flash",
"qwen3.7-plus",
"qwen3.8-flash",
"qwen3.8-max",
)
if provider == "deepseek":
return (
"deepseek-v4-pro",
"deepseek-v4-flash",
)
if provider == "nvidiaapi":
return (
"deepseek-ai/deepseek-v4-flash-0731",
"deepseek-ai/deepseek-v4-pro-0813",
)
return ()
@staticmethod
def _provider_has_model_input(provider):
return provider in ("gemini", "openrouter", "grok", "qwen", "deepseek", "nvidiaapi")
def _ensure_ai_settings(self):
settings = self.get("ai_provider_settings")
if not isinstance(settings, dict):
settings = self._default_ai_settings()
defaults = self._default_ai_settings()
provider = settings.get("provider")
if provider not in self._provider_ids():
settings["provider"] = defaults["provider"]
for provider_name, provider_defaults in defaults.items():
if provider_name == "provider":
continue
if not isinstance(settings.get(provider_name), dict):
settings[provider_name] = dict(provider_defaults)
for key, value in provider_defaults.items():
settings[provider_name].setdefault(key, value)
config_key = self._provider_config_key(provider_name)
saved_api_key = settings[provider_name].pop("api_key", "") or ""
if saved_api_key and config_key and not self.config[config_key]:
self.config[config_key] = saved_api_key
if settings["deepseek"].get("model") in {"deepseek-chat", "deepseek-reasoner"}:
settings["deepseek"]["model"] = defaults["deepseek"]["model"]
if settings["nvidiaapi"].get("model") in {
"deepseek-ai/deepseek-v4-flash",
"deepseek-ai/deepseek-v4-pro",
}:
settings["nvidiaapi"]["model"] = defaults["nvidiaapi"]["model"]
self.set("ai_provider_settings", settings)
return settings
def _get_ai_settings(self):
return self._ensure_ai_settings()
def _set_ai_settings(self, settings):
self.set("ai_provider_settings", settings)
def _get_prompt_provider(self):
return self._get_ai_settings().get("provider", "deepseek")
def _get_provider_api_key(self, provider):
self._ensure_ai_settings()
config_key = self._provider_config_key(provider)
keys = self._get_provider_api_keys(provider)
return keys[0] if keys else ""
def _get_provider_api_keys(self, provider):
self._ensure_ai_settings()
config_key = self._provider_config_key(provider)
if not config_key:
return []
raw = str(self.config[config_key] or "")
keys = []
seen = set()
for key in raw.split(","):
key = key.strip()
if key and key not in seen:
seen.add(key)
keys.append(key)
return keys
@staticmethod
def _enhance_error_rotates_key(error):
if error in {"expired", "rate_limit", "timeout"}:
return True
lower = str(error or "").lower()
return any(
marker in lower
for marker in (
"401",
"403",
"429",
"invalid api key",
"api key not valid",
"api_key_invalid",
"unauthenticated",
"permission denied",
"quota",
"insufficient_quota",
"billing",
"balance",
"resource_exhausted",
"rate limit",
"timeout",
"timed out",
)
)
def _get_gemini_model(self):
return self._get_provider_model("gemini")
def _get_provider_model(self, provider):
defaults = self._default_ai_settings().get(provider, {})
default_model = defaults.get("model", "")
return self._get_ai_settings().get(provider, {}).get("model", default_model) or default_model
def _get_provider_model_chain(self, provider):
selected = self._get_provider_model(provider)
models = []
if selected:
models.append(selected)
for model in self._provider_model_presets(provider):
if model and model not in models:
models.append(model)
return models
def _set_prompt_provider(self, provider):
settings = self._get_ai_settings()
settings["provider"] = provider
self._set_ai_settings(settings)
def _set_provider_api_key(self, provider, api_key):
self._ensure_ai_settings()
config_key = self._provider_config_key(provider)
if config_key:
keys = []
seen = set()
for key in str(api_key or "").split(","):
key = key.strip()
if key and key not in seen:
seen.add(key)
keys.append(key)
self.config[config_key] = ", ".join(keys)
def _set_provider_model(self, provider, model):
settings = self._get_ai_settings()
settings.setdefault(provider, {})
default_model = self._default_ai_settings().get(provider, {}).get("model", "")
settings[provider]["model"] = model.strip() or default_model
self._set_ai_settings(settings)
@staticmethod
def _coerce_int(value, default, minimum=None, maximum=None):
try:
value = int(value)
except (TypeError, ValueError):
value = default
if minimum is not None:
value = max(minimum, value)
if maximum is not None:
value = min(maximum, value)
return value
def _get_total_generation_count(self):
value = self.get("total_generation_counter")
if value is None:
value = self._coerce_int(self.get("archive_generation_counter"), 0, 0)
self.set("total_generation_counter", value)
return self._coerce_int(value, 0, 0)
def _increment_total_generation_count(self):
value = self._get_total_generation_count() + 1
self.set("total_generation_counter", value)
return value
@staticmethod
def _builtin_emoji_theme_ids():
return {
_EMOJI_THEME_DEFAULT,
_EMOJI_THEME_COLORED,
_EMOJI_THEME_CUTE,
_EMOJI_THEME_BLACK,
_EMOJI_THEME_TROLLFACE,
}
@staticmethod
def _emoji_theme_custom_id(slug):
return f"{_EMOJI_THEME_CUSTOM_PREFIX}{slug}"
@staticmethod
def _emoji_theme_custom_slug(theme_id):
theme_id = str(theme_id or "").strip().lower()
if not theme_id.startswith(_EMOJI_THEME_CUSTOM_PREFIX):
return None
slug = theme_id[len(_EMOJI_THEME_CUSTOM_PREFIX):].strip()
return slug or None
@staticmethod
def _emoji_theme_slug(value):
value = str(value or "").strip().lower()
value = re.sub(r"[^a-z0-9_-]+", "_", value)
value = re.sub(r"_+", "_", value).strip("_-")
return value[:32]
def _emoji_slot_label(self, slot):
key = f"emoji_slot_{slot}"
try:
value = self.strings(key)
if value and value != key:
return value
except Exception:
pass
return str(slot)
def _emoji_slot_default(self, slot):
sources = _EMOJI_THEME_SLOT_SOURCES.get(slot) or ()
if not sources:
return ("", "")
return sources[0]
@staticmethod
def _inline_premium_emoji(emoji_id, char):
emoji_id = str(emoji_id or "").strip()
char = utils.escape_html(str(char or "").strip() or "\u2754")
if not emoji_id:
return char
return f'{char}'
def _get_custom_emoji_themes(self):
raw = self.get("custom_emoji_themes", {})
if not isinstance(raw, dict):
raw = {}
normalized = {}
changed = False
for raw_slug, data in raw.items():
slug = self._emoji_theme_slug(raw_slug)
if not slug or not isinstance(data, dict):
changed = True
continue
title = str(data.get("title") or slug).strip()[:40] or slug
base = _EMOJI_THEME_DEFAULT
slots = {}
raw_slots = data.get("slots")
if isinstance(raw_slots, dict):
for slot, item in raw_slots.items():
if slot not in _EMOJI_THEME_SLOT_SOURCES or not isinstance(item, dict):
changed = True
continue
emoji_id = str(item.get("id") or "").strip()
if not _EMOJI_THEME_ID_RE.match(emoji_id):
changed = True
continue
default_char = self._emoji_slot_default(slot)[1]
char = str(item.get("char") or default_char or "").strip()[:8] or default_char
slots[slot] = {"id": emoji_id, "char": char}
normalized[slug] = {
"title": title,
"base": base,
"slots": slots,
}
if slug != raw_slug or normalized[slug] != data:
changed = True
if changed:
self.set("custom_emoji_themes", normalized)
return normalized
def _set_custom_emoji_themes(self, themes):
self.set("custom_emoji_themes", themes if isinstance(themes, dict) else {})
def _emoji_theme_exists(self, theme_id):
theme_id = str(theme_id or "").strip().lower()
if theme_id in self._builtin_emoji_theme_ids():
return True
slug = self._emoji_theme_custom_slug(theme_id)
return bool(slug and slug in self._get_custom_emoji_themes())
def _emoji_theme_display_name(self, theme_id):
theme_id = str(theme_id or _EMOJI_THEME_DEFAULT).strip().lower()
labels = {
_EMOJI_THEME_DEFAULT: self.strings("ult_btn_theme_default"),
_EMOJI_THEME_COLORED: self.strings("ult_btn_theme_colored"),
_EMOJI_THEME_CUTE: self.strings("ult_btn_theme_cute"),
_EMOJI_THEME_BLACK: self.strings("ult_btn_theme_black"),
_EMOJI_THEME_TROLLFACE: self.strings("ult_btn_theme_trollface"),
}
if theme_id in labels:
return labels[theme_id]
slug = self._emoji_theme_custom_slug(theme_id)
if slug:
theme = self._get_custom_emoji_themes().get(slug)
if isinstance(theme, dict):
return theme.get("title") or slug
return theme_id
def _emoji_theme_maps(self, theme_id):
theme_id = str(theme_id or _EMOJI_THEME_DEFAULT).strip().lower()
if theme_id in self._builtin_emoji_theme_ids():
return (
dict(_EMOJI_THEME_REPLACEMENTS.get(theme_id, {})),
dict(_EMOJI_THEME_ID_FALLBACKS.get(theme_id, {})),
dict(_EMOJI_THEME_ERROR_ID_FALLBACKS.get(theme_id, {})),
)
slug = self._emoji_theme_custom_slug(theme_id)
theme = self._get_custom_emoji_themes().get(slug) if slug else None
if not isinstance(theme, dict):
return ({}, {}, {})
base = str(theme.get("base") or _EMOJI_THEME_DEFAULT).strip().lower()
replacements, id_fallbacks, error_id_fallbacks = self._emoji_theme_maps(base)
slots = theme.get("slots") if isinstance(theme.get("slots"), dict) else {}
for slot, item in slots.items():
if slot not in _EMOJI_THEME_SLOT_SOURCES or not isinstance(item, dict):
continue
emoji_id = str(item.get("id") or "").strip()
if not _EMOJI_THEME_ID_RE.match(emoji_id):
continue
default_char = self._emoji_slot_default(slot)[1]
char = str(item.get("char") or default_char or "").strip()[:8] or default_char
value = (emoji_id, char)
for old_id, old_char in _EMOJI_THEME_SLOT_SOURCES[slot]:
char_key = self._emoji_theme_char_key(old_char)
replacements[(old_id, char_key)] = value
id_fallbacks[old_id] = value
if old_id == "5121063440311386962":
error_id_fallbacks[char_key] = value
if slot == "error":
error_id_fallbacks["*"] = value
return replacements, id_fallbacks, error_id_fallbacks
def _apply_emoji_theme_id(self, emoji_id, char=None):
emoji_id = str(emoji_id or "").strip()
if not emoji_id:
return emoji_id
theme = self._emoji_theme_name()
replacements, id_fallbacks, error_id_fallbacks = self._emoji_theme_maps(theme)
char_key = self._emoji_theme_char_key(char) if char else None
new_emoji = replacements.get((emoji_id, char_key)) if char_key else None
if not new_emoji and emoji_id == "5121063440311386962":
new_emoji = (error_id_fallbacks.get(char_key) if char_key else None) or error_id_fallbacks.get("*")
if not new_emoji:
new_emoji = id_fallbacks.get(emoji_id)
return str(new_emoji[0]) if new_emoji else emoji_id
def _apply_emoji_theme_markup(self, markup):
if isinstance(markup, list):
return [self._apply_emoji_theme_markup(item) for item in markup]
if isinstance(markup, tuple):
return tuple(self._apply_emoji_theme_markup(item) for item in markup)
if isinstance(markup, dict):
cloned = dict(markup)
if "emoji_id" in cloned:
cloned["emoji_id"] = self._apply_emoji_theme_id(cloned.get("emoji_id"))
return cloned
return markup
def _extract_custom_emoji_from_text(self, text):
text = str(text or "").strip()
if not text:
return None
match = _EMOJI_THEME_INLINE_ID_RE.search(text)
if match:
return match.group("id"), self._plain_text(text).strip()[:8]
token = text.split()[0].strip()
if _EMOJI_THEME_ID_RE.match(token):
return token, ""
return None
def _extract_custom_emoji_from_message(self, message, slot=None):
text = getattr(message, "raw_text", None) or getattr(message, "text", None) or ""
entities = list(getattr(message, "entities", None) or [])
for entity in entities:
emoji_id = getattr(entity, "document_id", None)
if emoji_id:
char = str(text or "").strip()[:8]
return str(emoji_id), char
extracted = self._extract_custom_emoji_from_text(text)
if extracted:
emoji_id, char = extracted
if not char and slot:
char = self._emoji_slot_default(slot)[1]
return emoji_id, char
return None
def _set_custom_theme_slot(self, slug, slot, emoji_id, char=None):
slug = self._emoji_theme_slug(slug)
if slot not in _EMOJI_THEME_SLOT_SOURCES:
return False
emoji_id = str(emoji_id or "").strip()
if not _EMOJI_THEME_ID_RE.match(emoji_id):
return False
themes = self._get_custom_emoji_themes()
theme = themes.get(slug)
if not isinstance(theme, dict):
return False
slots = theme.get("slots")
if not isinstance(slots, dict):
slots = {}
theme["slots"] = slots
default_char = self._emoji_slot_default(slot)[1]
char = str(char or default_char or "").strip()[:8] or default_char
slots[slot] = {"id": emoji_id, "char": char}
self._set_custom_emoji_themes(themes)
return True
def _source_inline_target(self, call, source_inline_message_id=None):
return self._restore_inline_input_source(call, source_inline_message_id)
async def _safe_call_answer(self, call, text="", **kwargs):
try:
return await call.answer(text, **kwargs)
except Exception as e:
logger.debug("Inline call answer ignored: %s", e)
return None
def _format_theme_slot_saved_text(self, slot, emoji_id=None, char=None):
emoji = ""
if emoji_id:
emoji = self._inline_premium_emoji(
emoji_id,
char or self._emoji_slot_default(slot)[1],
)
emoji = f"{emoji} "
return "{}{}".format(
emoji,
self.strings("ult_theme_slot_saved").format(
utils.escape_html(self._emoji_slot_label(slot))
),
)
async def _edit_inline_transfer_message(self, call, text):
bot = getattr(self.inline, "bot", None)
inline_message_id = getattr(call, "inline_message_id", None)
if not bot or not inline_message_id:
return False
try:
await bot.edit_message_text(
text=text,
inline_message_id=inline_message_id,
parse_mode="HTML",
disable_web_page_preview=True,
)
return True
except Exception as e:
logger.debug("Inline transfer message edit ignored: %s", e)
return False
@staticmethod
def _format_comfy_device_name(device):
raw = str((device or {}).get("name") or "").strip()
if not raw:
return "Unknown"
cleaned = re.sub(
r"^(?:cuda|hip|mps|xpu|privateuseone):\d+\s*",
"",
raw,
flags=re.IGNORECASE,
).strip()
cleaned = re.sub(r"\s+:\s+.*$", "", cleaned).strip()
if cleaned.lower() == "cpu":
return "CPU"
return cleaned or raw
@staticmethod
def _device_info_key(device, name):
raw = f"{(device or {}).get('type', '')} {(device or {}).get('name', '')} {name}".lower()
if "cpu" in raw:
return "info_cpu"
if any(token in raw for token in ("cuda", "nvidia", "gpu", "vram")):
return "info_gpu"
return "info_device"
@staticmethod
def _device_is_cpu(device, name):
raw = f"{(device or {}).get('type', '')} {(device or {}).get('name', '')} {name}".lower()
return "cpu" in raw
@staticmethod
def _format_memory_gb(value):
return f"{value / (1024**3):.1f}GB"
@staticmethod
def _first_present(mapping, keys):
if not isinstance(mapping, dict):
return None
for key in keys:
value = mapping.get(key)
if value not in (None, ""):
return value
return None
def _default_trigger_settings(self):
return {
"enabled": False,
"trigger": "comfy",
"auto_delete": False,
"auto_delete_delay": 150,
"max_queue": 4,
"max_steps": 40,
"max_steps_user_set": False,
"workflow": "",
"reject_russian_prompt": False,
"translate_prompt": False,
"cloud_skip_confirm": True,
"blacklist": [],
}
def _normalize_trigger_settings(self, settings):
if not isinstance(settings, dict):
settings = {}
trigger = str(settings.get("trigger") or "comfy").strip()
if not trigger:
trigger = "comfy"
blacklist = settings.get("blacklist", [])
if not isinstance(blacklist, list):
blacklist = []
max_steps_user_set = bool(settings.get("max_steps_user_set", False))
raw_max_steps = settings.get("max_steps")
max_steps = self._coerce_int(raw_max_steps, 40, 1, 100)
if not max_steps_user_set and max_steps == 100:
max_steps = 40
workflow = str(settings.get("workflow") or "").strip()
if workflow:
workflow = self._canonical_workflow_name(workflow)
if workflow not in self._get_all_workflow_names():
workflow = ""
return {
"enabled": bool(settings.get("enabled", False)),
"trigger": trigger,
"auto_delete": bool(settings.get("auto_delete", False)),
"auto_delete_delay": self._coerce_int(settings.get("auto_delete_delay"), 150, 10, 86400),
"max_queue": self._coerce_int(settings.get("max_queue"), 4, 1, 50),
"max_steps": max_steps,
"max_steps_user_set": max_steps_user_set,
"workflow": workflow,
"reject_russian_prompt": bool(settings.get("reject_russian_prompt", False)),
"translate_prompt": bool(settings.get("translate_prompt", False)),
"cloud_skip_confirm": bool(settings.get("cloud_skip_confirm", True)),
"blacklist": [
int(user_id)
for user_id in blacklist
if str(user_id).lstrip("-").isdigit()
],
}
def _ensure_ult_settings(self):
settings = self.get("ultimate_settings", {})
if not isinstance(settings, dict):
settings = {}
prompt_confirm = settings.get("prompt_confirm")
if not isinstance(prompt_confirm, dict):
prompt_confirm = {}
gens_chat = settings.get("gens_chat")
if not isinstance(gens_chat, dict):
gens_chat = {}
generation_time = settings.get("generation_time")
if not isinstance(generation_time, dict):
generation_time = {}
telegram_censorship = settings.get("telegram_censorship")
if not isinstance(telegram_censorship, dict):
telegram_censorship = {}
workflow_model = settings.get("workflow_model")
if not isinstance(workflow_model, dict):
workflow_model = {}
tunnel_notify = settings.get("tunnel_notify")
if not isinstance(tunnel_notify, dict):
tunnel_notify = {}
tunnel_targets_raw = tunnel_notify.get("targets")
if not isinstance(tunnel_targets_raw, list):
default_chat_id = getattr(self, "tg_id", None)
tunnel_targets_raw = (
[{"chat_id": default_chat_id, "topic_id": None, "bot_pm": True}]
if default_chat_id
else []
)
tunnel_targets = []
seen_tunnel_targets = set()
for target in tunnel_targets_raw:
if not isinstance(target, dict):
continue
chat_id = target.get("chat_id")
if not chat_id:
continue
topic_id = target.get("topic_id")
bot_pm = bool(target.get("bot_pm", False))
via_client = bool(target.get("via_client", False))
key = (
str(chat_id),
str(topic_id) if topic_id is not None else None,
)
if key in seen_tunnel_targets:
continue
seen_tunnel_targets.add(key)
tunnel_targets.append(
{
"chat_id": chat_id,
"topic_id": topic_id,
"bot_pm": bot_pm,
"via_client": via_client,
}
)
ui = settings.get("ui")
if not isinstance(ui, dict):
ui = {}
theme = str(ui.get("theme") or _EMOJI_THEME_DEFAULT).strip().lower()
if not self._emoji_theme_exists(theme):
theme = _EMOJI_THEME_DEFAULT
ai_enhance = settings.get("ai_enhance")
legacy_default_args = self.get("default_args", {})
legacy_ai_enabled = False
if isinstance(legacy_default_args, dict):
legacy_ai_enabled = self._argset_enabled(legacy_default_args.get("ai", {}))
if not isinstance(ai_enhance, dict):
ai_enhance = {"enabled": legacy_ai_enabled}
gens_targets = gens_chat.get("targets")
if not isinstance(gens_targets, list):
gens_targets = []
normalized_targets = []
seen_targets = set()
for target in gens_targets:
if not isinstance(target, dict):
continue
chat_id = target.get("chat_id")
if not chat_id:
continue
topic_id = target.get("topic_id")
key = (str(chat_id), str(topic_id) if topic_id is not None else None)
if key in seen_targets:
continue
seen_targets.add(key)
normalized_targets.append(
{
"chat_id": chat_id,
"topic_id": topic_id,
"managed": bool(target.get("managed", False)),
}
)
if gens_chat.get("chat_id"):
legacy_key = (
str(gens_chat.get("chat_id")),
str(gens_chat.get("topic_id")) if gens_chat.get("topic_id") is not None else None,
)
if legacy_key not in seen_targets:
normalized_targets.append(
{
"chat_id": gens_chat.get("chat_id"),
"topic_id": gens_chat.get("topic_id"),
"managed": bool(gens_chat.get("managed", False)),
}
)
seen_targets.add(legacy_key)
trigger_generation = settings.get("trigger_generation")
if not isinstance(trigger_generation, dict):
trigger_generation = {}
trigger_chats = trigger_generation.get("chats")
if not isinstance(trigger_chats, dict):
trigger_chats = {}
normalized = {
"prompt_confirm": {
"enabled": bool(prompt_confirm.get("enabled", False)),
},
"gens_chat": {
"enabled": bool(gens_chat.get("enabled", True)),
"chat_id": normalized_targets[0]["chat_id"] if normalized_targets else None,
"topic_id": normalized_targets[0]["topic_id"] if normalized_targets else None,
"targets": normalized_targets,
"title": gens_chat.get("title") or "ComfyUI Gens",
"managed": bool(gens_chat.get("managed", False)),
"save_full_prompt": True,
},
"generation_time": {
"progress": bool(generation_time.get("progress", True)),
"result": bool(generation_time.get("result", True)),
},
"telegram_censorship": {
"enabled": bool(telegram_censorship.get("enabled", False)),
},
"workflow_model": {
"autoswitch": bool(workflow_model.get("autoswitch", True)),
},
"tunnel_notify": {
"enabled": bool(tunnel_notify.get("enabled", True)),
"targets": tunnel_targets,
},
"ui": {
"theme": theme,
},
"ai_enhance": {
"enabled": bool(ai_enhance.get("enabled", False)),
},
"trigger_generation": {
"chats": {
str(chat_id): self._normalize_trigger_settings(chat_settings)
for chat_id, chat_settings in trigger_chats.items()
},
},
}
if settings != normalized:
self.set("ultimate_settings", normalized)
return normalized
def _get_ult_settings(self):
return self._ensure_ult_settings()
def _set_ult_settings(self, settings: dict):
self.set("ultimate_settings", settings)
def _prompt_confirm_enabled(self) -> bool:
return self._get_ult_settings()["prompt_confirm"]["enabled"]
def _get_gens_chat_config(self) -> dict:
return self._get_ult_settings()["gens_chat"]
def _get_generation_time_config(self) -> dict:
return self._get_ult_settings()["generation_time"]
def _show_generation_time_progress(self) -> bool:
return bool(self._get_generation_time_config().get("progress", True))
def _show_generation_time_result(self) -> bool:
return bool(self._get_generation_time_config().get("result", True))
def _telegram_censorship_enabled(self) -> bool:
return bool(
self._get_ult_settings()
.get("telegram_censorship", {})
.get("enabled", False)
)
def _workflow_model_autoswitch_enabled(self) -> bool:
return bool(
self._get_ult_settings()
.get("workflow_model", {})
.get("autoswitch", True)
)
def _tunnel_notify_enabled(self) -> bool:
return bool(
self._get_ult_settings()
.get("tunnel_notify", {})
.get("enabled", True)
)
def _get_tunnel_notify_config(self) -> dict:
return self._get_ult_settings()["tunnel_notify"]
def _get_tunnel_notify_targets(self):
targets = self._get_tunnel_notify_config().get("targets", [])
return [
target
for target in targets
if isinstance(target, dict) and target.get("chat_id")
]
def _tunnel_bot_pm_target(self):
chat_id = getattr(self, "tg_id", None)
if not chat_id:
return None
return {"chat_id": chat_id, "topic_id": None, "bot_pm": True}
def _add_tunnel_notify_target(
self,
config,
chat_id,
topic_id=None,
bot_pm=False,
via_client=False,
):
targets = config.get("targets")
if not isinstance(targets, list):
targets = []
key = (str(chat_id), str(topic_id) if topic_id is not None else None)
for target in targets:
if not isinstance(target, dict):
continue
target_key = (
str(target.get("chat_id")),
str(target.get("topic_id")) if target.get("topic_id") is not None else None,
)
if target_key == key:
if bot_pm and not target.get("bot_pm"):
target["bot_pm"] = True
if via_client and not target.get("via_client"):
target["via_client"] = True
config["targets"] = targets
return False
targets.append(
{
"chat_id": chat_id,
"topic_id": topic_id,
"bot_pm": bool(bot_pm),
"via_client": bool(via_client),
}
)
config["targets"] = targets
return True
def _ai_enhance_enabled(self) -> bool:
return bool(self._get_ult_settings().get("ai_enhance", {}).get("enabled", False))
def _state_toggle_text(self, enabled: bool) -> str:
return self.strings("btn_toggle_on") if enabled else self.strings("btn_toggle_off")
@staticmethod
def _state_toggle_style(enabled: bool) -> str:
return "success" if enabled else "danger"
@staticmethod
def _state_toggle_emoji(enabled: bool) -> str:
return "5206607081334906820" if enabled else "5121063440311386962"
def _get_trigger_settings_for_chat(self, chat_id, create=True) -> dict:
settings = self._get_ult_settings()
chat_key = str(chat_id)
chats = settings["trigger_generation"]["chats"]
if chat_key not in chats:
if not create:
return self._default_trigger_settings()
chats[chat_key] = self._default_trigger_settings()
self._set_ult_settings(settings)
return dict(chats[chat_key])
def _set_trigger_settings_for_chat(self, chat_id, chat_settings):
settings = self._get_ult_settings()
chat_key = str(chat_id)
settings["trigger_generation"]["chats"][chat_key] = self._normalize_trigger_settings(chat_settings)
self._set_ult_settings(settings)
def _trigger_workflow_name(self, settings):
configured = str((settings or {}).get("workflow") or "").strip()
if configured:
return self._canonical_workflow_name(configured)
return self._canonical_workflow_name(
self.get("default_workflow", _DEFAULT_WORKFLOW_NAME)
)
def _trigger_workflow_choices(self):
custom = self.get("workflows", {})
custom_names = set(custom) if isinstance(custom, dict) else set()
cloud_mode = self._is_comfy_cloud()
return [
name
for name in self._get_all_workflow_names()
if name in custom_names
or self._is_cloud_workflow_name(name) == cloud_mode
]
def _get_target_chat_id(self, target):
if isinstance(target, Message):
return utils.get_chat_id(target)
form = getattr(target, "form", {}) or {}
if isinstance(target, dict):
form = target
caller = form.get("caller")
if isinstance(caller, Message):
return utils.get_chat_id(caller)
if isinstance(caller, int):
return caller
message = form.get("message")
if isinstance(message, Message):
return utils.get_chat_id(message)
return None
def _restore_inline_input_source(self, target, inline_message_id=None):
if isinstance(target, dict) or isinstance(target, Message):
return target
form = getattr(target, "form", None)
if not isinstance(form, dict):
return target
source_id = inline_message_id or form.get("inline_message_id")
if not source_id or not hasattr(target, "inline_message_id"):
return target
try:
target.inline_message_id = source_id
except Exception as e:
logger.debug("Failed to restore inline input source: %s", e)
return target
def _wrap_inline_input_handlers(self, reply_markup):
if isinstance(reply_markup, dict):
rows = [[reply_markup]]
elif isinstance(reply_markup, list):
rows = [
row if isinstance(row, list) else [row]
for row in reply_markup
]
else:
return reply_markup
for row in rows:
for button in row:
if not isinstance(button, dict) or "input" not in button:
continue
handler = button.get("handler")
if not callable(handler) or getattr(handler, "_comfy_input_source_wrapped", False):
continue
async def source_handler(call, query, *args, _handler=handler, **kwargs):
return await _handler(
self._restore_inline_input_source(call),
query,
*args,
**kwargs,
)
source_handler._comfy_input_source_wrapped = True
button["handler"] = source_handler
return reply_markup
@staticmethod
def _is_inline_too_long_error(error):
text = f"{type(error).__name__}: {error}".lower()
return any(
marker in text
for marker in (
"message_too_long",
"message is too long",
"message text is too long",
"text is too long",
"caption is too long",
"entities too long",
)
)
def _truncate_inline_text_for_retry(self, text, limit):
return self._truncate_html_text_for_retry(text, limit)
def _inline_text_retry_candidates(self, text):
seen = {text}
candidates = []
for limit in _INLINE_TEXT_RETRY_LIMITS:
candidate = self._truncate_inline_text_for_retry(text, limit)
if candidate not in seen:
seen.add(candidate)
candidates.append(candidate)
return candidates
def _plain_text_retry_limits(self):
return (
_PLAIN_TEXT_RETRY_LIMITS_PREMIUM
if self._self_has_premium
else _PLAIN_TEXT_RETRY_LIMITS_DEFAULT
)
def _message_text_limit(self):
return _TG_TEXT_LIMIT_PREMIUM if self._self_has_premium else _TG_TEXT_LIMIT_DEFAULT
def _parsed_html_text_len(self, text):
try:
parsed_text, _ = self.client.parse_mode.parse(str(text or ""))
return len(parsed_text)
except Exception:
return len(self._plain_text(str(text or "")))
def _truncate_html_text_for_retry(self, text, limit):
suffix = "\n..."
limit = max(100, int(limit or _TG_TEXT_LIMIT_DEFAULT))
budget = max(100, limit - len(suffix))
try:
parsed_text, entities = self.client.parse_mode.parse(str(text or ""))
chunks = list(utils.smart_split(parsed_text, entities, budget))
if chunks:
first = str(chunks[0]).rstrip()
if len(chunks) > 1 or len(parsed_text) > budget:
first = f"{first}{suffix}"
return first
except Exception as e:
logger.debug("HTML retry truncation failed: %s", e)
plain = self._plain_text(str(text or "")).replace("\r", "")
plain = re.sub(r"[ \t]+\n", "\n", plain)
plain = re.sub(r"\n{3,}", "\n\n", plain).strip()
if not plain:
plain = self.strings("negative_not_set")
if len(plain) > budget:
plain = plain[:budget].rstrip() + suffix
return utils.escape_html(plain)
def _text_retry_candidates(self, text, limits=None):
seen = {text}
candidates = []
for limit in (limits or self._plain_text_retry_limits()):
candidate = self._truncate_html_text_for_retry(text, limit)
if candidate not in seen:
seen.add(candidate)
candidates.append(candidate)
return candidates
async def _create_inline_form(self, message, text, reply_markup=None, **kwargs):
return await self.inline.form(
message=message,
text=text,
reply_markup=reply_markup,
**kwargs,
)
async def _render_inline(self, target, text, reply_markup=None, apply_theme=True, **kwargs):
if apply_theme:
text = self._apply_emoji_theme(text)
reply_markup = self._apply_emoji_theme_markup(reply_markup)
reply_markup = self._wrap_inline_input_handlers(reply_markup)
candidates = [text]
retry_candidates = None
last_error = None
index = 0
while index < len(candidates):
candidate = candidates[index]
index += 1
try:
rendered = await self._render_inline_once(target, candidate, reply_markup, **kwargs)
if rendered:
return rendered
too_long = len(self._plain_text(candidate)) > _INLINE_TEXT_SOFT_LIMIT
except Exception as e:
last_error = e
if not self._is_inline_too_long_error(e):
logger.debug("Inline render failed: %s", e)
return False
logger.debug("Inline render text is too long, retrying with shorter text: %s", e)
too_long = True
if too_long and retry_candidates is None:
retry_candidates = self._inline_text_retry_candidates(text)
candidates.extend(retry_candidates)
if last_error:
logger.debug("Inline render failed after text shortening: %s", last_error)
return False
@staticmethod
def _repair_inline_edit_target(target):
form = getattr(target, "form", None)
if not isinstance(form, dict):
return target
unit_id = form.get("id")
inline_message_id = form.get("inline_message_id")
if unit_id:
try:
target.unit_id = unit_id
except Exception:
pass
if inline_message_id:
try:
target.inline_message_id = inline_message_id
except Exception:
pass
return target
async def _render_inline_once(self, target, text, reply_markup=None, **kwargs):
try:
if isinstance(target, Message):
return await self._create_inline_form(
message=target,
text=text,
reply_markup=reply_markup,
**kwargs,
)
target = self._repair_inline_edit_target(target)
if hasattr(target, "edit") and callable(target.edit):
try:
edited = await target.edit(
text=text,
reply_markup=reply_markup,
**kwargs,
)
if edited is not False:
return target
except Exception as e:
if self._is_inline_too_long_error(e):
raise
logger.debug("Inline direct edit failed: %s", e)
form = target if isinstance(target, dict) else getattr(target, "form", {}) or {}
if isinstance(form, dict):
caller = form.get("caller") or form.get("message")
if isinstance(caller, Message):
return await self._create_inline_form(
message=caller,
text=text,
reply_markup=reply_markup,
**kwargs,
)
except Exception as e:
if self._is_inline_too_long_error(e):
raise
logger.debug("Inline render failed: %s", e)
return False
def _info_banner_url(self):
raw = str(self.config["info_banner_url"] or "").strip()
if not raw:
return None
if raw.lower() in {"0", "false", "none", "off", "no", "disabled"}:
return None
if raw.startswith(("http://", "https://")):
return raw
logger.debug("Ignoring invalid info banner URL: %s", raw)
return None
@staticmethod
def _info_banner_media_kwargs(url):
path = urlparse(str(url or "")).path.lower()
ext = os.path.splitext(path)[1]
if ext == ".gif":
return {"gif": url}
if ext in {".mp4", ".mov", ".webm", ".m4v"}:
return {"video": url}
if ext in {"", ".jpg", ".jpeg", ".png", ".webp", ".bmp"}:
return {"photo": url}
return None
async def _render_inline_with_info_banner(self, target, text, reply_markup=None, **kwargs):
banner_url = self._info_banner_url()
if banner_url:
banner_kwargs = self._info_banner_media_kwargs(banner_url)
if banner_kwargs:
rendered = await self._render_inline(
target,
text,
reply_markup,
**banner_kwargs,
**kwargs,
)
if rendered:
return rendered
logger.debug("Info banner render failed, retrying without banner")
else:
logger.debug("Ignoring unsupported info banner media URL: %s", banner_url)
return await self._render_inline(target, text, reply_markup, **kwargs)
async def _edit_inline_status(self, target, text, reply_markup=None, apply_theme=True):
if apply_theme:
text = self._apply_emoji_theme(text)
reply_markup = self._apply_emoji_theme_markup(reply_markup)
reply_markup = self._wrap_inline_input_handlers(reply_markup)
form = getattr(target, "form", {}) or {}
if isinstance(target, dict):
form = target
target = self._repair_inline_edit_target(target)
if hasattr(target, "edit") and callable(target.edit):
try:
edited = await target.edit(text=text, reply_markup=reply_markup)
if edited is not False:
return True
except Exception as e:
logger.debug("Inline status edit failed: %s", e)
inline_message_id = (
form.get("inline_message_id")
if isinstance(form, dict)
else getattr(target, "inline_message_id", None)
)
bot = getattr(self.inline, "bot", None)
if bot is not None:
try:
chat_id = getattr(target, "chat_id", None)
message_id = getattr(target, "message_id", None)
if not chat_id:
chat_id = form.get("chat")
if not message_id:
message_id = form.get("message_id")
destination = None
if inline_message_id:
destination = {"inline_message_id": inline_message_id}
elif chat_id and message_id:
destination = {"chat_id": chat_id, "message_id": message_id}
if destination:
await bot.edit_message_text(
text=text,
**destination,
parse_mode="HTML",
disable_web_page_preview=True,
reply_markup=self.inline.generate_markup(reply_markup),
)
return True
except Exception as e:
logger.debug("Direct inline status edit failed: %s", e)
return False
def _disable_gens_chat(self, drop_chat_id=False):
settings = self._get_ult_settings()
settings["gens_chat"]["enabled"] = False
if drop_chat_id:
settings["gens_chat"]["chat_id"] = None
settings["gens_chat"]["topic_id"] = None
settings["gens_chat"]["targets"] = []
self._set_ult_settings(settings)
def _get_gens_archive_targets(self):
gens_chat = self._get_gens_chat_config()
targets = gens_chat.get("targets")
if isinstance(targets, list) and targets:
return [
target
for target in targets
if isinstance(target, dict) and target.get("chat_id")
]
if gens_chat.get("chat_id"):
return [
{
"chat_id": gens_chat.get("chat_id"),
"topic_id": gens_chat.get("topic_id"),
"managed": bool(gens_chat.get("managed", False)),
}
]
return []
def _sync_primary_gens_archive_target(self, gens_chat):
targets = [
target
for target in gens_chat.get("targets", [])
if isinstance(target, dict) and target.get("chat_id")
]
if targets:
gens_chat["chat_id"] = targets[0]["chat_id"]
gens_chat["topic_id"] = targets[0].get("topic_id")
gens_chat["managed"] = bool(targets[0].get("managed", False))
else:
gens_chat["chat_id"] = None
gens_chat["topic_id"] = None
gens_chat["managed"] = False
def _add_gens_archive_target(self, gens_chat, chat_id, topic_id=None, managed=False):
targets = gens_chat.get("targets")
if not isinstance(targets, list):
targets = []
key = (str(chat_id), str(topic_id) if topic_id is not None else None)
for target in targets:
if not isinstance(target, dict):
continue
target_key = (
str(target.get("chat_id")),
str(target.get("topic_id")) if target.get("topic_id") is not None else None,
)
if target_key == key:
return False
targets.append(
{
"chat_id": chat_id,
"topic_id": topic_id,
"managed": bool(managed),
}
)
gens_chat["targets"] = targets
self._sync_primary_gens_archive_target(gens_chat)
return True
@staticmethod
def _gens_archive_target_key(target):
return (
str(target.get("chat_id")),
str(target.get("topic_id")) if target.get("topic_id") is not None else None,
)
async def _gens_archive_topic_exists(self, target):
topic_id = target.get("topic_id")
if topic_id is None:
return True
try:
entity = await self.client.get_entity(target["chat_id"])
result = await self.client(
GetForumTopicsByIDRequest(
peer=entity,
topics=[topic_id],
)
)
topics = getattr(result, "topics", None) or []
if not topics:
return False
return not isinstance(topics[0], ForumTopicDeleted)
except Exception as e:
logger.debug("Generation archive topic lookup failed: %s", e)
try:
return bool(await self.client.get_messages(target["chat_id"], ids=topic_id))
except Exception as fallback_error:
logger.debug("Generation archive topic message check failed: %s", fallback_error)
return False
async def _recreate_managed_gens_archive_target(self, gens_chat, old_target):
content_channel_id = self.db.get("heroku.forums", "channel_id", None)
if not content_channel_id:
raise RuntimeError("Generation archive forum channel is unavailable")
title = gens_chat.get("title") or "ComfyUI Gens"
topic = await utils.asset_forum_topic(
self.client,
self.db,
content_channel_id,
title,
description=self.strings("ult_chat_about"),
icon_emoji_id=_ULT_GENS_TOPIC_EMOJI_ID,
)
topic_id = getattr(topic, "id", None)
if not topic_id:
raise RuntimeError("No topic returned")
new_target = {
"chat_id": content_channel_id,
"topic_id": topic_id,
"managed": True,
}
targets = gens_chat.get("targets")
if not isinstance(targets, list):
targets = []
old_key = self._gens_archive_target_key(old_target)
replaced = False
cleaned_targets = []
for target in targets:
if not isinstance(target, dict) or not target.get("chat_id"):
continue
if self._gens_archive_target_key(target) == old_key:
if not replaced:
cleaned_targets.append(new_target)
replaced = True
continue
cleaned_targets.append(target)
if not replaced:
cleaned_targets.insert(0, new_target)
gens_chat["targets"] = cleaned_targets
gens_chat["title"] = getattr(topic, "title", None) or title
gens_chat["enabled"] = True
self._sync_primary_gens_archive_target(gens_chat)
return new_target
async def _ensure_gens_archive_target_for_save(self, gens_chat, target):
if not target.get("topic_id"):
return target, False
target_key = self._gens_archive_target_key(target)
if target_key in self._archive_target_ok:
return target, False
if await self._gens_archive_topic_exists(target):
self._archive_target_ok[target_key] = True
return target, False
if not target.get("managed"):
raise RuntimeError("Generation archive topic is unavailable")
logger.warning("Generation archive topic is missing, recreating")
new_target = await self._recreate_managed_gens_archive_target(gens_chat, target)
self._archive_target_ok[self._gens_archive_target_key(new_target)] = True
return new_target, True
def _archive_message_matches_target(self, message, target):
expected_topic_id = target.get("topic_id")
if not expected_topic_id or not message:
return True
actual_topic_id = self._safe_topic(message)
if actual_topic_id is None:
reply_to = getattr(message, "reply_to", None)
actual_topic_id = (
getattr(reply_to, "reply_to_top_id", None)
or getattr(reply_to, "reply_to_msg_id", None)
or getattr(message, "reply_to_msg_id", None)
)
return str(actual_topic_id) == str(expected_topic_id)
async def _create_gens_archive_target(self, gens_chat):
title = gens_chat.get("title") or "ComfyUI Gens"
content_channel_id = self.db.get("heroku.forums", "channel_id", None)
if content_channel_id:
try:
topic = await utils.asset_forum_topic(
self.client,
self.db,
content_channel_id,
title,
description=self.strings("ult_chat_about"),
icon_emoji_id=_ULT_GENS_TOPIC_EMOJI_ID,
)
topic_id = getattr(topic, "id", None)
if not topic_id:
raise RuntimeError("No topic returned")
gens_chat["title"] = getattr(topic, "title", None) or title
gens_chat["enabled"] = True
return self._add_gens_archive_target(
gens_chat,
content_channel_id,
topic_id,
managed=True,
)
except Exception as e:
logger.debug("Failed to create generation archive topic: %s", e)
peer, _ = await utils.asset_channel(
self.client,
title,
self.strings("ult_chat_about"),
silent=True,
archive=True,
invite_bot=False,
forum=False,
_folder="comfy",
)
chat_id = getattr(peer, "id", None)
if not chat_id:
raise RuntimeError("No archive chat returned")
gens_chat["title"] = getattr(peer, "title", None) or title
gens_chat["enabled"] = True
return self._add_gens_archive_target(
gens_chat,
chat_id,
None,
managed=True,
)
async def _ensure_gens_archive_autocreated(self):
settings = self._get_ult_settings()
gens_chat = settings["gens_chat"]
if not gens_chat.get("enabled") or self._get_gens_archive_targets():
return False
try:
await self._create_gens_archive_target(gens_chat)
self._set_ult_settings(settings)
return True
except Exception as e:
logger.exception(e)
return False
def _normalize_archive_chat_id(self, chat_id):
chat_id = int(chat_id)
if str(chat_id).startswith("-100"):
return int(str(chat_id)[4:])
return chat_id
def _parse_archive_target(self, query):
query = str(query or "").strip()
if not query:
return None, None
link_match = re.search(r"t\.me/c/(\d+)(?:/(\d+))?(?:/(\d+))?", query)
if link_match:
chat_id = self._normalize_archive_chat_id(link_match.group(1))
topic_id = int(link_match.group(2)) if link_match.group(2) and link_match.group(3) else None
return chat_id, topic_id
normalized = query.replace(":", " ")
numbers = re.findall(r"-?\d+", normalized)
if not numbers:
return None, None
chat_id = self._normalize_archive_chat_id(numbers[0])
topic_id = int(numbers[1]) if len(numbers) > 1 else None
return chat_id, topic_id
@staticmethod
def _bot_api_chat_id(entity, fallback=None):
entity_id = getattr(entity, "id", None)
if entity_id is None:
return fallback
entity_id = abs(int(entity_id))
entity_type = type(entity).__name__.lower()
if "channel" in entity_type:
return int(f"-100{entity_id}")
if "chat" in entity_type:
return -entity_id
return entity_id
async def _resolve_tunnel_notify_target(self, query):
chat_id, topic_id = self._parse_archive_target(query)
if not chat_id:
return None, None, False
candidates = [chat_id]
if int(chat_id) > 0:
candidates.insert(0, int(f"-100{chat_id}"))
entity = None
resolved_chat_id = None
for candidate in candidates:
try:
candidate_entity = await self.client.get_entity(candidate)
except Exception:
continue
if "user" in type(candidate_entity).__name__.lower():
continue
entity = candidate_entity
resolved_chat_id = self._bot_api_chat_id(candidate_entity, candidate)
break
if entity is None or resolved_chat_id is None:
raise RuntimeError("chat is unavailable to this account")
return resolved_chat_id, topic_id, True
async def _ult_render_main(self, target, notice=None):
settings = self._get_ult_settings()
chat_id = self._get_target_chat_id(target)
trigger_settings = self._get_trigger_settings_for_chat(chat_id) if chat_id is not None else self._default_trigger_settings()
ai_status = (
self.strings("ult_status_on")
if settings["ai_enhance"]["enabled"]
else self.strings("ult_status_off")
)
gens_status = (
self.strings("ult_status_on")
if settings["gens_chat"]["enabled"]
else self.strings("ult_status_off")
)
trigger_status = (
self.strings("ult_status_on")
if trigger_settings["enabled"]
else self.strings("ult_status_off")
)
text_lines = [
self.strings("ult_title"),
"",
f"{self.strings('ult_ai_title')}: {ai_status}",
f"{self.strings('ult_gens_title')}: {gens_status}",
f"{self.strings('ult_trigger_title')}: {trigger_status}",
]
if notice:
text_lines.extend(("", notice))
text = "\n".join(text_lines)
ai_button = {
"text": self.strings("ult_btn_ai"),
"callback": self._ult_open_ai_enhance,
"style": "primary",
}
gens_button = {
"text": self.strings("ult_btn_gens"),
"callback": self._ult_open_gens_chat,
"style": "primary",
}
trigger_button = {
"text": self.strings("ult_btn_trigger"),
"callback": self._ult_open_trigger_generation,
"args": (chat_id,),
"style": "primary",
}
extra_button = {
"text": self.strings("ult_btn_extra"),
"callback": self._ult_open_additional_settings,
"style": "primary",
}
markup = [
[ai_button, gens_button],
[trigger_button],
[extra_button],
[{
"text": self.strings("btn_close"),
"callback": self._safe_close_form,
"style": "danger",
}],
]
await self._render_inline(target, text, markup)
async def _ult_render_additional_settings(self, target, notice=None):
settings = self._get_ult_settings()
autoswitch_enabled = self._workflow_model_autoswitch_enabled()
censorship_enabled = bool(settings["telegram_censorship"]["enabled"])
tunnel_notify_enabled = bool(settings["tunnel_notify"]["enabled"])
autoswitch_status = (
self.strings("ult_status_on")
if autoswitch_enabled
else self.strings("ult_status_off")
)
censorship_status = (
self.strings("ult_status_on")
if censorship_enabled
else self.strings("ult_status_off")
)
tunnel_notify_status = (
self.strings("ult_status_on")
if tunnel_notify_enabled
else self.strings("ult_status_off")
)
text_lines = [
self.strings("ult_extra_title"),
"",
self.strings("ult_auto_model_status").format(autoswitch_status),
self.strings("ult_theme_status").format(
utils.escape_html(self._emoji_theme_display_name(settings["ui"]["theme"]))
),
self.strings("ult_censorship_status").format(censorship_status),
self.strings("tunnel_notify_status").format(tunnel_notify_status),
]
if notice:
text_lines.extend(("", notice))
markup = [
[{
"text": f"{self._state_toggle_text(autoswitch_enabled)} {self.strings('ult_btn_auto_model')}",
"callback": self._ult_toggle_workflow_model_autoswitch,
"style": self._state_toggle_style(autoswitch_enabled),
"emoji_id": self._state_toggle_emoji(autoswitch_enabled),
}],
[
{
"text": self.strings("ult_btn_time"),
"callback": self._ult_open_generation_time,
"style": "primary",
},
{
"text": self.strings("ult_btn_theme"),
"callback": self._ult_open_emoji_theme,
"style": "primary",
},
],
[{
"text": (
self.strings("ult_btn_censorship_on")
if censorship_enabled
else self.strings("ult_btn_censorship_off")
),
"callback": self._ult_toggle_telegram_censorship,
"style": self._state_toggle_style(censorship_enabled),
"emoji_id": self._state_toggle_emoji(censorship_enabled),
}],
[{
"text": self.strings("ult_btn_update_assets"),
"callback": self._ult_update_assets,
"style": "primary",
"emoji_id": "5361979468887893611",
}],
[{
"text": self.strings("ult_btn_tunnel_notify"),
"callback": self._ult_open_tunnel_notify,
"style": "primary",
}],
[{
"text": self.strings("btn_back"),
"callback": self._ult_back_main,
"style": "primary",
}],
]
await self._render_inline(target, "\n".join(text_lines), markup)
async def _ult_update_assets(self, call: InlineCall):
await self._safe_call_answer(call, self.strings("ult_assets_update_started"))
await self._edit_inline_status(call, self.strings("ult_assets_updating"), reply_markup=None)
try:
updated = await self._update_assets(force=True)
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning("Manual assets update failed: %s", e)
updated = False
await self._ult_render_additional_settings(
call,
notice=self.strings(
"ult_assets_updated" if updated else "ult_assets_update_partial"
),
)
async def _ult_open_ai_enhance(self, call: InlineCall):
await self._ult_render_ai_enhance(call)
async def _ult_open_gens_chat(self, call: InlineCall):
await self._ult_render_gens_chat(call)
async def _ult_open_tunnel_notify(self, call: InlineCall):
await self._ult_render_tunnel_notify(call)
async def _ult_open_trigger_generation(self, call: InlineCall, chat_id):
if chat_id is None:
chat_id = self._get_target_chat_id(call)
await self._ult_render_trigger_generation(call, chat_id)
async def _ult_open_additional_settings(self, call: InlineCall):
await self._ult_render_additional_settings(call)
async def _ult_open_generation_time(self, call: InlineCall):
await self._ult_render_generation_time(call)
async def _ult_open_emoji_theme(self, call: InlineCall):
await self._ult_render_emoji_theme(call)
async def _ult_render_emoji_theme(self, target, force_edit=False):
settings = self._get_ult_settings()
current = settings["ui"]["theme"]
custom_themes = self._get_custom_emoji_themes()
text = "\n".join(
[
self.strings("ult_theme_title"),
"",
self.strings("ult_theme_status").format(
utils.escape_html(self._emoji_theme_display_name(current))
),
"",
f"{self.strings('ult_theme_builtin')}",
]
)
builtin_specs = [
(_EMOJI_THEME_DEFAULT, self.strings("ult_btn_theme_default")),
(_EMOJI_THEME_COLORED, self.strings("ult_btn_theme_colored")),
(_EMOJI_THEME_CUTE, self.strings("ult_btn_theme_cute")),
(_EMOJI_THEME_BLACK, self.strings("ult_btn_theme_black")),
(_EMOJI_THEME_TROLLFACE, self.strings("ult_btn_theme_trollface")),
]
builtin_buttons = [
{
"text": label,
"callback": self._ult_set_emoji_theme,
"args": (theme_id,),
"style": "success" if current == theme_id else "primary",
}
for theme_id, label in builtin_specs
]
markup = self._build_button_rows(builtin_buttons, columns=2)
custom_buttons = []
for slug, theme in custom_themes.items():
theme_id = self._emoji_theme_custom_id(slug)
custom_buttons.append(
{
"text": theme.get("title") or slug,
"callback": self._ult_set_emoji_theme,
"args": (theme_id,),
"style": "success" if current == theme_id else "primary",
}
)
if custom_buttons:
text = f"{text}\n\n{self.strings('ult_theme_custom')}"
markup.extend(self._build_button_rows(custom_buttons, columns=2))
else:
text = f"{text}\n\n{self.strings('ult_theme_custom')}\n{self.strings('ult_theme_custom_empty')}"
current_slug = self._emoji_theme_custom_slug(current)
source_inline_message_id = (
target.get("inline_message_id")
if isinstance(target, dict)
else getattr(target, "inline_message_id", None)
)
markup.append(
[
{
"text": self.strings("ult_theme_create"),
"input": self.strings("ult_theme_create_input"),
"handler": self._ult_custom_theme_create_input,
"args": (source_inline_message_id,),
}
]
)
if current_slug and current_slug in custom_themes:
markup.extend(
[
[
{
"text": self.strings("ult_theme_edit"),
"callback": self._ult_render_custom_theme_editor,
"args": (current_slug,),
"style": "primary",
}
],
[
{
"text": self.strings("ult_theme_delete"),
"callback": self._ult_custom_theme_delete,
"args": (current_slug,),
"style": "danger",
},
],
]
)
markup.append([{"text": self.strings("btn_back"), "callback": self._ult_back_additional_settings, "style": "primary"}])
if force_edit:
await self._edit_inline_status(target, text, markup)
return
rendered = await self._render_inline(target, text, markup)
if not rendered and isinstance(target, InlineCall):
await self._edit_inline_status(target, text, markup)
async def _ult_set_emoji_theme(self, call: InlineCall, theme: str):
theme = str(theme or _EMOJI_THEME_DEFAULT).strip().lower()
if not self._emoji_theme_exists(theme):
theme = _EMOJI_THEME_DEFAULT
settings = self._get_ult_settings()
settings["ui"]["theme"] = theme
self._set_ult_settings(settings)
try:
await call.answer(self.strings("ult_toggle_saved"))
except Exception:
pass
await self._ult_render_emoji_theme(call, force_edit=True)
async def _ult_custom_theme_create_input(self, call: InlineCall, query: str, source_inline_message_id=None):
themes = self._get_custom_emoji_themes()
if len(themes) >= _EMOJI_THEME_MAX_CUSTOM:
return await self._safe_call_answer(call, self.strings("ult_theme_limit"), show_alert=True)
title = str(query or "").strip()[:40]
slug = self._emoji_theme_slug(title)
if not title:
return await self._safe_call_answer(call, self.strings("ult_theme_bad_name"), show_alert=True)
if not slug:
slug = f"theme_{uuid.uuid4().hex[:8]}"
base_slug = slug
suffix = 2
while slug in themes:
slug = f"{base_slug}_{suffix}"[:32]
suffix += 1
themes[slug] = {"title": title, "base": _EMOJI_THEME_DEFAULT, "slots": {}}
self._set_custom_emoji_themes(themes)
settings = self._get_ult_settings()
settings["ui"]["theme"] = self._emoji_theme_custom_id(slug)
self._set_ult_settings(settings)
target = self._source_inline_target(call, source_inline_message_id)
await self._safe_call_answer(call, self.strings("ult_theme_created").format(title))
await self._ult_render_emoji_theme(target, force_edit=True)
async def _ult_custom_theme_delete(self, call: InlineCall, slug: str):
themes = self._get_custom_emoji_themes()
if slug in themes:
themes.pop(slug, None)
self._set_custom_emoji_themes(themes)
settings = self._get_ult_settings()
if settings["ui"]["theme"] == self._emoji_theme_custom_id(slug):
settings["ui"]["theme"] = _EMOJI_THEME_DEFAULT
self._set_ult_settings(settings)
await call.answer(self.strings("ult_theme_deleted"))
await self._ult_render_emoji_theme(call)
async def _ult_render_custom_theme_editor(self, target, slug: str, force_edit=False):
themes = self._get_custom_emoji_themes()
theme = themes.get(slug)
if not isinstance(theme, dict):
if isinstance(target, InlineCall):
await target.answer(self.strings("ult_theme_not_custom"), show_alert=True)
return await self._ult_render_emoji_theme(target)
title = theme.get("title") or slug
slots = theme.get("slots") if isinstance(theme.get("slots"), dict) else {}
lines = [
self.strings("ult_theme_editor_title").format(utils.escape_html(title)),
"",
self.strings("ult_theme_editor_hint"),
"",
]
for slot in _EMOJI_THEME_SLOT_ORDER:
old_id, old_char = self._emoji_slot_default(slot)
default_html = f'{self._inline_premium_emoji(old_id, old_char)} {old_id}'
line = f"{utils.escape_html(self._emoji_slot_label(slot))}: {default_html}"
item = slots.get(slot) if isinstance(slots.get(slot), dict) else None
if item:
custom_char = item.get("char") or old_char
line = (
f"{line} -> "
f'{self._inline_premium_emoji(item["id"], custom_char)} {item["id"]}'
)
lines.append(line)
text = "\n".join(lines)
source_inline_message_id = (
target.get("inline_message_id")
if isinstance(target, dict)
else getattr(target, "inline_message_id", None)
)
buttons = []
for slot in _EMOJI_THEME_SLOT_ORDER:
buttons.append(
{
"text": ("✅ " if slot in slots else "") + self._emoji_slot_label(slot),
"callback": self._ult_custom_theme_slot_menu,
"args": (slug, slot),
"style": "success" if slot in slots else "primary",
}
)
markup = self._build_button_rows(buttons, columns=2)
markup.append(
[
{
"text": self.strings("ult_theme_rename"),
"input": self.strings("ult_theme_rename_input"),
"handler": self._ult_custom_theme_rename_input,
"args": (slug, source_inline_message_id),
}
]
)
markup.append([{"text": self.strings("btn_back"), "callback": self._ult_open_emoji_theme, "style": "primary"}])
if force_edit:
await self._edit_inline_status(target, text, markup, apply_theme=False)
return
await self._render_inline(target, text, markup, apply_theme=False)
async def _ult_custom_theme_rename_input(self, call: InlineCall, query: str, slug: str, source_inline_message_id=None):
title = str(query or "").strip()[:40]
if not title:
return await self._safe_call_answer(call, self.strings("ult_theme_bad_name"), show_alert=True)
themes = self._get_custom_emoji_themes()
theme = themes.get(slug)
if not isinstance(theme, dict):
return await self._safe_call_answer(call, self.strings("ult_theme_not_custom"), show_alert=True)
theme["title"] = title
self._set_custom_emoji_themes(themes)
target = self._source_inline_target(call, source_inline_message_id)
await self._safe_call_answer(call, self.strings("ult_theme_renamed"))
await self._ult_render_custom_theme_editor(target, slug, force_edit=True)
async def _ult_custom_theme_slot_menu(self, target, slug: str, slot: str, force_edit=False):
themes = self._get_custom_emoji_themes()
theme = themes.get(slug)
if not isinstance(theme, dict) or slot not in _EMOJI_THEME_SLOT_SOURCES:
if isinstance(target, InlineCall):
await target.answer(self.strings("ult_theme_not_custom"), show_alert=True)
return await self._ult_render_emoji_theme(target)
slots = theme.get("slots") if isinstance(theme.get("slots"), dict) else {}
item = slots.get(slot) if isinstance(slots.get(slot), dict) else None
old_id, old_char = self._emoji_slot_default(slot)
default_line = f'{self._inline_premium_emoji(old_id, old_char)} {old_id}'
if item:
custom_line = f'{self._inline_premium_emoji(item["id"], item.get("char") or old_char)} {item["id"]}'
else:
custom_line = self.strings("ult_theme_slot_custom_empty")
source_inline_message_id = (
target.get("inline_message_id")
if isinstance(target, dict)
else getattr(target, "inline_message_id", None)
)
text = "\n".join(
[
self.strings("ult_theme_slot_title").format(utils.escape_html(self._emoji_slot_label(slot))),
"",
self.strings("ult_theme_slot_default").format(default_line),
self.strings("ult_theme_slot_custom").format(custom_line) if item else custom_line,
]
)
markup = [
[
{
"text": self.strings("ult_theme_slot_wait"),
"callback": self._ult_custom_theme_wait_slot,
"args": (slug, slot),
"style": "primary",
}
],
[
{
"text": self.strings("ult_theme_slot_input"),
"input": self.strings("ult_theme_slot_input"),
"handler": self._ult_custom_theme_slot_id_input,
"args": (slug, slot, source_inline_message_id),
}
],
[
{
"text": self.strings("ult_theme_slot_reset"),
"callback": self._ult_custom_theme_reset_slot,
"args": (slug, slot),
"style": "danger",
}
],
[{"text": self.strings("btn_back"), "callback": self._ult_render_custom_theme_editor, "args": (slug,), "style": "primary"}],
]
if force_edit:
await self._edit_inline_status(target, text, markup, apply_theme=False)
return
await self._render_inline(target, text, markup, apply_theme=False)
async def _ult_custom_theme_wait_slot(self, call: InlineCall, slug: str, slot: str):
chat_id = self._get_target_chat_id(call)
key = f"{chat_id}:{self.tg_id or 0}"
self._emoji_theme_pending[key] = {
"slug": slug,
"slot": slot,
"inline_message_id": getattr(call, "inline_message_id", None),
"unit_id": getattr(call, "unit_id", None),
}
await call.answer(self.strings("ult_theme_slot_waiting"), show_alert=True)
async def _ult_custom_theme_slot_id_input(self, call: InlineCall, query: str, slug: str, slot: str, source_inline_message_id=None):
extracted = self._extract_custom_emoji_from_text(query)
if not extracted:
return await self._safe_call_answer(call, self.strings("ult_theme_no_emoji"), show_alert=True)
emoji_id, char = extracted
if not char:
char = self._emoji_slot_default(slot)[1]
if not self._set_custom_theme_slot(slug, slot, emoji_id, char):
return await self._safe_call_answer(call, self.strings("ult_theme_not_custom"), show_alert=True)
saved_text = self._format_theme_slot_saved_text(slot, emoji_id, char)
await self._edit_inline_transfer_message(call, saved_text)
target = self._source_inline_target(call, source_inline_message_id)
await self._ult_custom_theme_slot_menu(target, slug, slot, force_edit=True)
async def _ult_custom_theme_reset_slot(self, call: InlineCall, slug: str, slot: str):
themes = self._get_custom_emoji_themes()
theme = themes.get(slug)
if not isinstance(theme, dict):
return await call.answer(self.strings("ult_theme_not_custom"), show_alert=True)
slots = theme.get("slots")
if isinstance(slots, dict):
slots.pop(slot, None)
self._set_custom_emoji_themes(themes)
await call.answer(self.strings("ult_theme_slot_reset_done"))
await self._ult_custom_theme_slot_menu(call, slug, slot)
async def _ult_toggle_telegram_censorship(self, call: InlineCall):
settings = self._get_ult_settings()
settings["telegram_censorship"]["enabled"] = not settings["telegram_censorship"]["enabled"]
self._set_ult_settings(settings)
try:
await call.answer(self.strings("ult_toggle_saved"))
except Exception:
pass
await self._ult_render_additional_settings(call)
async def _ult_toggle_workflow_model_autoswitch(self, call: InlineCall):
settings = self._get_ult_settings()
settings["workflow_model"]["autoswitch"] = not settings["workflow_model"].get(
"autoswitch", True
)
self._set_ult_settings(settings)
if self._is_comfy_cloud():
await self._autoswitch_model_to_workflow(
self.get("default_workflow", _DEFAULT_WORKFLOW_NAME)
)
try:
await call.answer(self.strings("ult_toggle_saved"))
except Exception:
pass
await self._ult_render_additional_settings(call)
def _format_tunnel_target(self, target):
if target.get("bot_pm"):
return self.strings("tunnel_target_bot_pm")
if target.get("topic_id") is not None:
return self.strings("ult_chat_target_topic").format(
utils.escape_html(str(target.get("chat_id"))),
utils.escape_html(str(target.get("topic_id"))),
)
return self.strings("ult_chat_target_chat").format(
utils.escape_html(str(target.get("chat_id")))
)
async def _ult_render_tunnel_notify(self, target, notice=None):
config = self._get_tunnel_notify_config()
enabled = bool(config.get("enabled", True))
status = self.strings("ult_status_on") if enabled else self.strings("ult_status_off")
targets = self._get_tunnel_notify_targets()
lines = [
self.strings("tunnel_menu_title"),
self.strings("tunnel_notify_status").format(status),
"",
self.strings("tunnel_menu_desc"),
"",
]
if notice:
lines.extend([utils.escape_html(str(notice)), ""])
if targets:
lines.append(
self.strings("tunnel_targets").format(
"\n".join(self._format_tunnel_target(item) for item in targets)
)
)
else:
lines.append(self.strings("tunnel_targets_empty"))
markup = [
[{
"text": self._state_toggle_text(enabled),
"callback": self._ult_toggle_tunnel_notify,
"style": self._state_toggle_style(enabled),
"emoji_id": self._state_toggle_emoji(enabled),
}],
[{
"text": self.strings("tunnel_btn_add_chat"),
"input": self.strings("tunnel_target_input"),
"handler": self._ult_bind_tunnel_target,
}],
]
if not any(item.get("bot_pm") for item in targets):
markup.append([{
"text": self.strings("tunnel_btn_add_bot_pm"),
"callback": self._ult_add_tunnel_bot_pm,
"style": "primary",
}])
if targets:
markup.append(
[
{
"text": self.strings("tunnel_btn_remove_target"),
"callback": self._ult_render_tunnel_targets,
"style": "danger",
},
{
"text": self.strings("tunnel_btn_clear_targets"),
"callback": self._ult_clear_tunnel_targets,
"style": "danger",
},
]
)
markup.append([{
"text": self.strings("btn_back"),
"callback": self._ult_back_additional_settings,
"style": "primary",
}])
await self._render_inline(target, "\n".join(lines), markup)
async def _ult_render_tunnel_targets(self, target):
targets = self._get_tunnel_notify_targets()
if not targets:
return await self._ult_render_tunnel_notify(target)
lines = [self.strings("tunnel_targets").format("")]
markup = []
for index, notify_target in enumerate(targets):
label = self._format_tunnel_target(notify_target)
lines.append(f"{index + 1}. {label}")
button_label = (
self.strings("tunnel_target_bot_pm")
if notify_target.get("bot_pm")
else str(notify_target.get("chat_id"))
)
markup.append([{
"text": f"{index + 1}. {button_label}",
"callback": self._ult_remove_tunnel_target,
"args": (index,),
"style": "danger",
}])
markup.append([{
"text": self.strings("btn_back"),
"callback": self._ult_open_tunnel_notify,
"style": "primary",
}])
await self._render_inline(target, "\n".join(lines), markup)
async def _ult_toggle_tunnel_notify(self, call: InlineCall):
settings = self._get_ult_settings()
config = settings["tunnel_notify"]
enabling = not bool(config.get("enabled", True))
if enabling and not self._get_tunnel_notify_targets():
default_target = self._tunnel_bot_pm_target()
if default_target:
self._add_tunnel_notify_target(config, **default_target)
config["enabled"] = enabling
self._set_ult_settings(settings)
self._restore_tunnel_watch_state()
if enabling:
self._start_tunnel_watch_task()
await self._safe_call_answer(call, self.strings("ult_toggle_saved"))
await self._ult_render_tunnel_notify(call)
async def _ult_add_tunnel_bot_pm(self, call: InlineCall):
default_target = self._tunnel_bot_pm_target()
if not default_target:
return await self._safe_call_answer(
call,
self._plain_text(self.strings("tunnel_target_bind_failed")).format("user id unavailable"),
show_alert=True,
)
settings = self._get_ult_settings()
config = settings["tunnel_notify"]
added = self._add_tunnel_notify_target(config, **default_target)
config["enabled"] = True
self._set_ult_settings(settings)
self._start_tunnel_watch_task()
await self._safe_call_answer(
call,
self._plain_text(
self.strings("tunnel_target_bound" if added else "tunnel_target_already_bound")
),
show_alert=True,
)
await self._ult_render_tunnel_notify(call)
async def _ult_bind_tunnel_target(self, call: InlineCall, query: str):
try:
chat_id, topic_id, via_client = await self._resolve_tunnel_notify_target(query)
except Exception as e:
logger.debug("Tunnel notification target validation failed: %s", e)
notice = self._plain_text(self.strings("tunnel_target_bind_failed")).format(
str(e)[:160]
)
await self._safe_call_answer(call, notice, show_alert=True)
return await self._ult_render_tunnel_notify(call, notice=notice)
if not chat_id:
notice = self._plain_text(self.strings("tunnel_target_bad"))
await self._safe_call_answer(call, notice, show_alert=True)
return await self._ult_render_tunnel_notify(call, notice=notice)
settings = self._get_ult_settings()
config = settings["tunnel_notify"]
added = self._add_tunnel_notify_target(
config,
chat_id,
topic_id,
via_client=via_client,
)
config["enabled"] = True
self._set_ult_settings(settings)
self._start_tunnel_watch_task()
notice = self._plain_text(
self.strings("tunnel_target_bound" if added else "tunnel_target_already_bound")
)
await self._safe_call_answer(call, notice, show_alert=True)
await self._ult_render_tunnel_notify(call, notice=notice)
async def _ult_remove_tunnel_target(self, call: InlineCall, index: int):
settings = self._get_ult_settings()
config = settings["tunnel_notify"]
targets = self._get_tunnel_notify_targets()
if 0 <= index < len(targets):
targets.pop(index)
config["targets"] = targets
if not targets:
config["enabled"] = False
self._set_ult_settings(settings)
await self._safe_call_answer(
call,
self._plain_text(self.strings("tunnel_target_removed")),
show_alert=True,
)
await self._ult_render_tunnel_targets(call)
async def _ult_clear_tunnel_targets(self, call: InlineCall):
settings = self._get_ult_settings()
config = settings["tunnel_notify"]
config["targets"] = []
config["enabled"] = False
self._set_ult_settings(settings)
await self._safe_call_answer(
call,
self._plain_text(self.strings("tunnel_targets_cleared")),
show_alert=True,
)
await self._ult_render_tunnel_notify(call)
async def _ult_render_generation_time(self, target):
time_settings = self._get_generation_time_config()
progress_enabled = bool(time_settings.get("progress", True))
result_enabled = bool(time_settings.get("result", True))
progress_status = (
self.strings("ult_status_on")
if progress_enabled
else self.strings("ult_status_off")
)
result_status = (
self.strings("ult_status_on")
if result_enabled
else self.strings("ult_status_off")
)
text = "\n".join(
[
self.strings("ult_time_title"),
"",
self.strings("ult_time_progress").format(progress_status),
self.strings("ult_time_result").format(result_status),
]
)
markup = [
[
{
"text": f"{self._state_toggle_text(progress_enabled)} {self.strings('ult_btn_time_progress')}",
"callback": self._ult_toggle_time_progress,
"style": self._state_toggle_style(progress_enabled),
"emoji_id": self._state_toggle_emoji(progress_enabled),
},
{
"text": f"{self._state_toggle_text(result_enabled)} {self.strings('ult_btn_time_result')}",
"callback": self._ult_toggle_time_result,
"style": self._state_toggle_style(result_enabled),
"emoji_id": self._state_toggle_emoji(result_enabled),
},
],
[
{
"text": self.strings("btn_back"),
"callback": self._ult_back_additional_settings,
"style": "primary",
}
],
]
await self._render_inline(target, text, markup)
async def _ult_render_ai_enhance(self, target):
settings = self._get_ult_settings()
ai_enabled = bool(settings["ai_enhance"]["enabled"])
confirm_enabled = bool(settings["prompt_confirm"]["enabled"])
provider = self._get_prompt_provider()
provider_name = self._format_provider_name(provider)
is_comfy_text = provider == _COMFY_TEXT_PROVIDER
api_key_status = (
self.strings("provider_api_key_not_required")
if is_comfy_text
else (
self.strings("provider_api_key_set")
if self._get_provider_api_key(provider)
else self.strings("provider_api_key_missing")
)
)
model = (
_COMFY_TEXT_CLIP_NAME
if is_comfy_text
else self._get_provider_model(provider) if self._provider_has_model_input(provider) else self.strings("not_set")
)
text = "\n".join(
[
self.strings("ult_ai_title"),
"",
self.strings("ult_ai_auto").format(
self.strings("ult_status_on") if ai_enabled else self.strings("ult_status_off")
),
self.strings("ult_ai_prompt_confirm").format(
self.strings("ult_status_on") if confirm_enabled else self.strings("ult_status_off")
),
self.strings("ult_ai_provider").format(provider_name),
self.strings("ult_ai_model").format(self._preview_negative(model, 120)),
self.strings("ult_ai_key").format(api_key_status),
"",
self.strings("ult_ai_desc"),
]
)
markup = [
[
{
"text": f"{self._state_toggle_text(ai_enabled)} {self.strings('ult_btn_ai_auto')}",
"callback": self._ult_toggle_ai_enhance,
"style": self._state_toggle_style(ai_enabled),
"emoji_id": self._state_toggle_emoji(ai_enabled),
}
],
[
{
"text": f"{self._state_toggle_text(confirm_enabled)} {self.strings('ult_btn_prompt_confirm')}",
"callback": self._ult_toggle_prompt_confirm,
"style": self._state_toggle_style(confirm_enabled),
"emoji_id": self._state_toggle_emoji(confirm_enabled),
}
],
[{"text": self.strings("provider_btn_menu"), "callback": self._argset_provider_menu}],
[{"text": self.strings("btn_back"), "callback": self._ult_back_main, "style": "primary"}],
]
await self._render_inline(target, text, markup)
async def _ult_render_gens_chat(self, target):
settings = self._get_ult_settings()
gens_chat = settings["gens_chat"]
enabled = gens_chat["enabled"]
status = (
self.strings("ult_status_on")
if enabled
else self.strings("ult_status_off")
)
toggle_text = self._state_toggle_text(enabled)
toggle_style = self._state_toggle_style(enabled)
lines = [
self.strings("ult_gens_title"),
status,
"",
self.strings("ult_gens_desc"),
"",
]
targets = self._get_gens_archive_targets()
if targets:
target_lines = []
for archive_target in targets:
if archive_target.get("topic_id"):
target_lines.append(
self.strings("ult_chat_target_topic").format(
archive_target["chat_id"],
archive_target["topic_id"],
)
)
else:
target_lines.append(
self.strings("ult_chat_target_chat").format(archive_target["chat_id"])
)
lines.append(self.strings("ult_chat_targets").format("\n".join(target_lines)))
else:
lines.append(self.strings("ult_chat_missing"))
text = "\n".join(lines)
create_text = (
self.strings("ult_btn_recreate_chat")
if targets
else self.strings("ult_btn_create_chat")
)
markup = [
[
{
"text": toggle_text,
"callback": self._ult_toggle_gens_chat,
"style": toggle_style,
"emoji_id": self._state_toggle_emoji(enabled),
}
],
[
{
"text": create_text,
"callback": self._ult_create_gens_chat,
"style": "primary",
}
],
[
{
"text": self.strings("ult_btn_bind_chat"),
"input": self.strings("ult_chat_bind_input"),
"handler": self._ult_bind_gens_chat,
}
],
]
if targets:
markup.append(
[
{
"text": self.strings("ult_btn_remove_chat"),
"callback": self._ult_render_gens_targets,
"style": "danger",
}
]
)
markup.append(
[
{
"text": self.strings("ult_btn_clear_chats"),
"callback": self._ult_clear_gens_targets,
"style": "danger",
}
]
)
markup.extend(
[
[
{
"text": self.strings("btn_back"),
"callback": self._ult_back_main,
"style": "primary",
}
],
]
)
await self._render_inline(target, text, markup)
async def _ult_render_gens_targets(self, target):
targets = self._get_gens_archive_targets()
if not targets:
text = "\n".join(
[
self.strings("ult_chat_targets_title"),
"",
self.strings("ult_chat_targets_empty"),
]
)
markup = [[{"text": self.strings("btn_back"), "callback": self._ult_open_gens_chat, "style": "primary"}]]
return await self._render_inline(target, text, markup)
lines = [self.strings("ult_chat_targets_title"), ""]
markup = []
for index, archive_target in enumerate(targets):
if archive_target.get("topic_id"):
label = self.strings("ult_chat_target_topic").format(
archive_target["chat_id"],
archive_target["topic_id"],
)
else:
label = self.strings("ult_chat_target_chat").format(archive_target["chat_id"])
lines.append(f"{index + 1}. {label}")
markup.append(
[
{
"text": f"{index + 1}. {archive_target['chat_id']}",
"callback": self._ult_remove_gens_target,
"args": (index,),
"style": "danger",
}
]
)
markup.append([{"text": self.strings("btn_back"), "callback": self._ult_open_gens_chat, "style": "primary"}])
await self._render_inline(target, "\n".join(lines), markup)
def _format_duration(self, seconds):
seconds = max(0, int(seconds))
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
parts = []
if hours:
parts.append(f"{hours}h")
if minutes:
parts.append(f"{minutes}m")
if seconds or not parts:
parts.append(f"{seconds}s")
return " ".join(parts)
async def _ult_render_trigger_generation(self, target, chat_id):
chat_id = chat_id if chat_id is not None else self._get_target_chat_id(target)
settings = self._get_trigger_settings_for_chat(chat_id)
enabled = settings["enabled"]
auto_delete = settings["auto_delete"]
status = (
self.strings("ult_status_on")
if enabled
else self.strings("ult_status_off")
)
auto_delete_status = (
self.strings("ult_status_on")
if auto_delete
else self.strings("ult_status_off")
)
russian_guard = settings.get("reject_russian_prompt", False)
auto_translate = settings.get("translate_prompt", False)
cloud_skip_confirm = bool(settings.get("cloud_skip_confirm", True))
russian_guard_status = (
self.strings("ult_status_on")
if russian_guard
else self.strings("ult_status_off")
)
auto_translate_status = (
self.strings("ult_status_on")
if auto_translate
else self.strings("ult_status_off")
)
toggle_text = self._state_toggle_text(enabled)
toggle_style = self._state_toggle_style(enabled)
auto_delete_toggle = self._state_toggle_text(auto_delete)
auto_delete_style = self._state_toggle_style(auto_delete)
active = self._trigger_queue_counts.get(str(chat_id), 0)
trigger_workflow = self._trigger_workflow_name(settings)
configured_workflow = str(settings.get("workflow") or "").strip()
detail_lines = [
self.strings("ult_trigger_chat").format(
utils.escape_html(str(chat_id))
),
self.strings("ult_trigger_word").format(
utils.escape_html(settings["trigger"])
),
self.strings("ult_trigger_autodelete").format(auto_delete_status),
self.strings("ult_trigger_delay").format(
self._format_duration(settings["auto_delete_delay"])
),
self.strings("ult_trigger_queue").format(settings["max_queue"]),
self.strings("ult_trigger_steps_limit").format(settings["max_steps"]),
(
self.strings("ult_trigger_workflow").format(
utils.escape_html(trigger_workflow)
)
if configured_workflow
else self.strings("ult_trigger_workflow_default").format(
utils.escape_html(trigger_workflow)
)
),
self.strings("ult_trigger_active").format(active),
self.strings("ult_trigger_russian_guard").format(
russian_guard_status
),
self.strings("ult_trigger_auto_translate").format(
auto_translate_status
),
self.strings("ult_trigger_blacklist").format(
len(settings.get("blacklist", []))
),
]
if self._is_comfy_cloud():
detail_lines.insert(
-1,
self.strings("ult_trigger_cloud_skip_confirm").format(
self.strings("ult_status_on")
if cloud_skip_confirm
else self.strings("ult_status_off")
),
)
details = "\n".join(detail_lines)
text = "\n".join(
[
self.strings("ult_trigger_title"),
status,
"",
self.strings("ult_trigger_desc"),
"",
f"{details}
",
]
)
markup = [
[
{
"text": toggle_text,
"callback": self._ult_toggle_trigger_generation,
"args": (chat_id,),
"style": toggle_style,
"emoji_id": self._state_toggle_emoji(enabled),
}
],
[
{
"text": self.strings("ult_btn_trigger_word"),
"input": self.strings("ult_trigger_word_input"),
"handler": self._ult_trigger_word_input,
"args": (chat_id,),
}
],
[
{
"text": auto_delete_toggle,
"callback": self._ult_toggle_trigger_autodelete,
"args": (chat_id,),
"style": auto_delete_style,
"emoji_id": self._state_toggle_emoji(auto_delete),
},
{
"text": self.strings("ult_btn_trigger_delay_short"),
"input": self.strings("ult_trigger_delay_input"),
"handler": self._ult_trigger_delay_input,
"args": (chat_id,),
},
],
[
{
"text": self.strings("ult_btn_trigger_queue"),
"input": self.strings("ult_trigger_queue_input"),
"handler": self._ult_trigger_queue_input,
"args": (chat_id,),
},
{
"text": self.strings("ult_btn_trigger_steps"),
"input": self.strings("ult_trigger_steps_input"),
"handler": self._ult_trigger_steps_input,
"args": (chat_id,),
}
],
[
{
"text": self.strings("ult_btn_trigger_workflow"),
"callback": self._ult_render_trigger_workflow_picker,
"args": (chat_id, 0),
"style": "primary",
},
{
"text": self.strings("ult_btn_trigger_blacklist"),
"callback": self._ult_render_trigger_blacklist,
"args": (chat_id,),
"style": "danger",
},
],
[
{
"text": f"{self._state_toggle_text(russian_guard)} {self.strings('ult_trigger_reject_russian')}",
"callback": self._ult_toggle_trigger_reject_russian,
"args": (chat_id,),
"style": self._state_toggle_style(russian_guard),
"emoji_id": self._state_toggle_emoji(russian_guard),
}
],
[
{
"text": f"{self._state_toggle_text(auto_translate)} {self.strings('ult_trigger_translate')}",
"callback": self._ult_toggle_trigger_translate,
"args": (chat_id,),
"style": self._state_toggle_style(auto_translate),
"emoji_id": self._state_toggle_emoji(auto_translate),
}
],
[
{
"text": self.strings("btn_back"),
"callback": self._ult_back_main,
"style": "primary",
}
],
]
if self._is_comfy_cloud():
markup.insert(
1,
[
{
"text": f"{self._state_toggle_text(cloud_skip_confirm)} {self.strings('ult_btn_trigger_cloud_skip_confirm')}",
"callback": self._ult_toggle_trigger_cloud_skip_confirm,
"args": (chat_id,),
"style": self._state_toggle_style(cloud_skip_confirm),
"emoji_id": self._state_toggle_emoji(cloud_skip_confirm),
}
],
)
await self._render_inline(target, text, markup)
async def _ult_render_trigger_workflow_picker(self, target, chat_id, page=0):
chat_id = chat_id if chat_id is not None else self._get_target_chat_id(target)
settings = self._get_trigger_settings_for_chat(chat_id)
workflows = self._trigger_workflow_choices()
per_page = 8
total_pages = max(1, (len(workflows) + per_page - 1) // per_page)
page = max(0, min(int(page), total_pages - 1))
page_workflows = workflows[page * per_page:(page + 1) * per_page]
configured = str(settings.get("workflow") or "").strip()
effective = self._trigger_workflow_name(settings)
default_workflow = self._canonical_workflow_name(
self.get("default_workflow", _DEFAULT_WORKFLOW_NAME)
)
default_display = (
default_workflow
if len(default_workflow) <= 52
else default_workflow[:49] + "..."
)
lines = [
self.strings("ult_trigger_workflow_title"),
"",
self.strings("ult_trigger_workflow").format(
utils.escape_html(effective)
),
self.strings("wf_page").format(page + 1, total_pages),
]
buttons = [{
"text": (
"✅ " if not configured else "⬜ "
) + self.strings("ult_trigger_workflow_default").format(
default_display
),
"callback": self._ult_set_trigger_workflow,
"args": (chat_id, ""),
"style": "success" if not configured else "primary",
}]
for workflow_name in page_workflows:
is_selected = configured == workflow_name
display_name = (
workflow_name
if len(workflow_name) <= 56
else workflow_name[:53] + "..."
)
buttons.append({
"text": ("✅ " if is_selected else "⬜ ") + display_name,
"callback": self._ult_set_trigger_workflow,
"args": (chat_id, workflow_name),
"style": "success" if is_selected else "primary",
})
markup = []
row = []
for button in buttons:
if row and len(row[0]["text"]) + len(button["text"]) <= 42:
row.append(button)
markup.append(row)
row = []
else:
if row:
markup.append(row)
row = [button]
if row:
markup.append(row)
nav_row = []
if page > 0:
nav_row.append({
"text": "◀️",
"callback": self._ult_render_trigger_workflow_picker,
"args": (chat_id, page - 1),
})
if page < total_pages - 1:
nav_row.append({
"text": "▶️",
"callback": self._ult_render_trigger_workflow_picker,
"args": (chat_id, page + 1),
})
if nav_row:
markup.append(nav_row)
markup.append([{
"text": self.strings("btn_back"),
"callback": self._ult_open_trigger_generation,
"args": (chat_id,),
"style": "primary",
}])
await self._render_inline(target, "\n".join(lines), markup)
async def _ult_set_trigger_workflow(self, call: InlineCall, chat_id, workflow_name):
settings = self._get_trigger_settings_for_chat(chat_id)
workflow_name = str(workflow_name or "").strip()
if workflow_name:
workflow_name = self._canonical_workflow_name(workflow_name)
if workflow_name not in self._trigger_workflow_choices():
return await self._ult_render_trigger_workflow_picker(call, chat_id)
settings["workflow"] = workflow_name
self._set_trigger_settings_for_chat(chat_id, settings)
try:
await call.answer(
self.strings("ult_trigger_workflow_set").format(workflow_name)
if workflow_name
else self.strings("ult_trigger_workflow_default_set")
)
except Exception:
pass
await self._ult_render_trigger_generation(call, chat_id)
async def _format_trigger_blacklist_lines(self, user_ids):
lines = []
for index, user_id in enumerate(user_ids, 1):
label = str(user_id)
try:
entity = await self.client.get_entity(int(user_id))
username = getattr(entity, "username", None)
if username:
label = f"@{username}"
else:
name = " ".join(
item
for item in (
getattr(entity, "first_name", None),
getattr(entity, "last_name", None),
)
if item
).strip()
label = name or str(user_id)
except Exception as e:
logger.debug("Failed to resolve blacklist user %s: %s", user_id, e)
lines.append(
f'{index}. {utils.escape_html(label)} '
f"({int(user_id)})"
)
return lines
async def _ult_render_trigger_blacklist(self, target, chat_id):
chat_id = chat_id if chat_id is not None else self._get_target_chat_id(target)
settings = self._get_trigger_settings_for_chat(chat_id)
user_ids = settings.get("blacklist", [])
lines = [self.strings("ult_trigger_blacklist_title"), ""]
if user_ids:
lines.extend(await self._format_trigger_blacklist_lines(user_ids))
else:
lines.append(self.strings("ult_trigger_blacklist_empty"))
markup = [[{"text": self.strings("btn_back"), "callback": self._ult_open_trigger_generation, "args": (chat_id,), "style": "primary"}]]
await self._render_inline(target, "\n".join(lines), markup)
async def _ult_back_main(self, call: InlineCall):
await self._ult_render_main(call)
async def _ult_back_additional_settings(self, call: InlineCall):
await self._ult_render_additional_settings(call)
async def _ult_toggle_ai_enhance(self, call: InlineCall):
settings = self._get_ult_settings()
settings["ai_enhance"]["enabled"] = not settings["ai_enhance"]["enabled"]
self._set_ult_settings(settings)
try:
await call.answer(self.strings("ult_toggle_saved"))
except Exception:
pass
await self._ult_render_ai_enhance(call)
async def _ult_toggle_prompt_confirm(self, call: InlineCall):
settings = self._get_ult_settings()
settings["prompt_confirm"]["enabled"] = not settings["prompt_confirm"]["enabled"]
self._set_ult_settings(settings)
try:
await call.answer(self.strings("ult_toggle_saved"))
except Exception:
pass
await self._ult_render_ai_enhance(call)
async def _ult_toggle_gens_chat(self, call: InlineCall):
settings = self._get_ult_settings()
gens_chat = settings["gens_chat"]
if not self._get_gens_archive_targets() and not gens_chat.get("enabled"):
try:
await call.answer(
self._plain_text(self.strings("ult_chat_need_create")),
show_alert=True,
)
except Exception:
pass
await self._ult_render_gens_chat(call)
return
gens_chat["enabled"] = not gens_chat["enabled"]
self._set_ult_settings(settings)
try:
await call.answer(self.strings("ult_toggle_saved"))
except Exception:
pass
await self._ult_render_gens_chat(call)
async def _ult_toggle_time_progress(self, call: InlineCall):
settings = self._get_ult_settings()
settings["generation_time"]["progress"] = not settings["generation_time"].get("progress", True)
self._set_ult_settings(settings)
try:
await call.answer(self.strings("ult_toggle_saved"))
except Exception:
pass
await self._ult_render_generation_time(call)
async def _ult_toggle_time_result(self, call: InlineCall):
settings = self._get_ult_settings()
settings["generation_time"]["result"] = not settings["generation_time"].get("result", True)
self._set_ult_settings(settings)
try:
await call.answer(self.strings("ult_toggle_saved"))
except Exception:
pass
await self._ult_render_generation_time(call)
async def _ult_toggle_trigger_generation(self, call: InlineCall, chat_id):
settings = self._get_trigger_settings_for_chat(chat_id)
settings["enabled"] = not settings["enabled"]
self._set_trigger_settings_for_chat(chat_id, settings)
try:
await call.answer(self.strings("ult_trigger_saved"))
except Exception:
pass
await self._ult_render_trigger_generation(call, chat_id)
async def _ult_toggle_trigger_cloud_skip_confirm(self, call: InlineCall, chat_id):
if not self._is_comfy_cloud():
return await self._ult_render_trigger_generation(call, chat_id)
settings = self._get_trigger_settings_for_chat(chat_id)
settings["cloud_skip_confirm"] = not settings.get("cloud_skip_confirm", True)
self._set_trigger_settings_for_chat(chat_id, settings)
try:
await call.answer(self.strings("ult_trigger_saved"))
except Exception:
pass
await self._ult_render_trigger_generation(call, chat_id)
async def _ult_toggle_trigger_autodelete(self, call: InlineCall, chat_id):
settings = self._get_trigger_settings_for_chat(chat_id)
settings["auto_delete"] = not settings["auto_delete"]
self._set_trigger_settings_for_chat(chat_id, settings)
try:
await call.answer(self.strings("ult_trigger_saved"))
except Exception:
pass
await self._ult_render_trigger_generation(call, chat_id)
async def _ult_trigger_word_input(self, call: InlineCall, query: str, chat_id):
settings = self._get_trigger_settings_for_chat(chat_id)
trigger = str(query or "").strip()
if trigger:
settings["trigger"] = trigger
self._set_trigger_settings_for_chat(chat_id, settings)
try:
await call.answer(self.strings("ult_trigger_saved"))
except Exception:
pass
await self._ult_render_trigger_generation(call, chat_id)
async def _ult_trigger_delay_input(self, call: InlineCall, query: str, chat_id):
settings = self._get_trigger_settings_for_chat(chat_id)
settings["auto_delete_delay"] = self._coerce_int(query, settings["auto_delete_delay"], 10, 86400)
self._set_trigger_settings_for_chat(chat_id, settings)
try:
await call.answer(self.strings("ult_trigger_saved"))
except Exception:
pass
await self._ult_render_trigger_generation(call, chat_id)
async def _ult_trigger_queue_input(self, call: InlineCall, query: str, chat_id):
settings = self._get_trigger_settings_for_chat(chat_id)
settings["max_queue"] = self._coerce_int(query, settings["max_queue"], 1, 50)
self._set_trigger_settings_for_chat(chat_id, settings)
try:
await call.answer(self.strings("ult_trigger_saved"))
except Exception:
pass
await self._ult_render_trigger_generation(call, chat_id)
async def _ult_trigger_steps_input(self, call: InlineCall, query: str, chat_id):
settings = self._get_trigger_settings_for_chat(chat_id)
settings["max_steps"] = self._coerce_int(query, settings["max_steps"], 1, 100)
settings["max_steps_user_set"] = True
self._set_trigger_settings_for_chat(chat_id, settings)
try:
await call.answer(self.strings("ult_trigger_saved"))
except Exception:
pass
await self._ult_render_trigger_generation(call, chat_id)
async def _ult_toggle_trigger_reject_russian(self, call: InlineCall, chat_id):
settings = self._get_trigger_settings_for_chat(chat_id)
settings["reject_russian_prompt"] = not settings.get("reject_russian_prompt", False)
self._set_trigger_settings_for_chat(chat_id, settings)
try:
await call.answer(self.strings("ult_trigger_saved"))
except Exception:
pass
await self._ult_render_trigger_generation(call, chat_id)
async def _ult_toggle_trigger_translate(self, call: InlineCall, chat_id):
settings = self._get_trigger_settings_for_chat(chat_id)
settings["translate_prompt"] = not settings.get("translate_prompt", False)
self._set_trigger_settings_for_chat(chat_id, settings)
try:
await call.answer(self.strings("ult_trigger_saved"))
except Exception:
pass
await self._ult_render_trigger_generation(call, chat_id)
async def _resolve_trigger_blacklist_user(self, message, query):
query = str(query or "").strip()
if query:
try:
entity = await self.client.get_entity(
int(query) if query.lstrip("-").isdigit() else query
)
user_id = getattr(entity, "id", None)
if user_id:
return int(user_id)
except Exception as e:
logger.debug("Failed to resolve blacklist user from args %s: %s", query, e)
try:
reply = await message.get_reply_message()
except Exception:
reply = None
if reply:
sender_id = getattr(reply, "sender_id", None)
if sender_id:
return int(sender_id)
try:
sender = await reply.get_sender()
sender_id = getattr(sender, "id", None)
if sender_id:
return int(sender_id)
except Exception as e:
logger.debug("Failed to resolve blacklist user from reply: %s", e)
return None
def _toggle_trigger_blacklist_user(self, chat_id, user_id):
settings = self._get_trigger_settings_for_chat(chat_id)
blacklist = [
int(item)
for item in settings.get("blacklist", [])
if str(item).lstrip("-").isdigit()
]
user_id = int(user_id)
if user_id in blacklist:
blacklist = [item for item in blacklist if item != user_id]
added = False
else:
blacklist.append(user_id)
added = True
settings["blacklist"] = blacklist
self._set_trigger_settings_for_chat(chat_id, settings)
return added
async def _ult_toggle_trigger_blacklist_user(self, message, query):
chat_id = utils.get_chat_id(message)
user_id = await self._resolve_trigger_blacklist_user(message, query)
if not user_id:
return await self._safe_answer(message, self.strings("ult_trigger_blacklist_no_user"))
added = self._toggle_trigger_blacklist_user(chat_id, user_id)
await self._safe_answer(
message,
self.strings(
"ult_trigger_blacklist_added"
if added
else "ult_trigger_blacklist_removed"
),
)
async def _ult_bind_gens_chat(self, call: InlineCall, query: str):
chat_id, topic_id = self._parse_archive_target(query)
if not chat_id:
try:
await call.answer(
self._plain_text(self.strings("ult_chat_bind_bad")),
show_alert=True,
)
except Exception:
pass
await self._ult_render_gens_chat(call)
return
try:
await self.client.get_entity(chat_id)
settings = self._get_ult_settings()
gens_chat = settings["gens_chat"]
added = self._add_gens_archive_target(gens_chat, chat_id, topic_id, managed=False)
gens_chat["enabled"] = True
self._set_ult_settings(settings)
try:
await call.answer(
self._plain_text(
self.strings("ult_chat_bound")
if added
else self.strings("ult_chat_already_bound")
),
show_alert=True,
)
except Exception:
pass
except Exception as e:
logger.exception(e)
try:
await call.answer(
self._plain_text(self.strings("ult_chat_bind_failed")).format(str(e)),
show_alert=True,
)
except Exception:
pass
await self._ult_render_gens_chat(call)
async def _ult_remove_gens_target(self, call: InlineCall, index: int):
settings = self._get_ult_settings()
gens_chat = settings["gens_chat"]
targets = [
target
for target in gens_chat.get("targets", [])
if isinstance(target, dict) and target.get("chat_id")
]
if 0 <= index < len(targets):
targets.pop(index)
gens_chat["targets"] = targets
if not targets:
gens_chat["enabled"] = False
self._sync_primary_gens_archive_target(gens_chat)
self._set_ult_settings(settings)
try:
await call.answer(self._plain_text(self.strings("ult_chat_target_removed")), show_alert=True)
except Exception:
pass
await self._ult_render_gens_targets(call)
async def _ult_clear_gens_targets(self, call: InlineCall):
settings = self._get_ult_settings()
gens_chat = settings["gens_chat"]
gens_chat["targets"] = []
gens_chat["enabled"] = False
self._sync_primary_gens_archive_target(gens_chat)
self._set_ult_settings(settings)
try:
await call.answer(self._plain_text(self.strings("ult_chat_targets_cleared")), show_alert=True)
except Exception:
pass
await self._ult_render_gens_chat(call)
async def _ult_create_gens_chat(self, call: InlineCall):
settings = self._get_ult_settings()
gens_chat = settings["gens_chat"]
had_old_chat = bool(self._get_gens_archive_targets())
try:
added = await self._create_gens_archive_target(gens_chat)
self._set_ult_settings(settings)
try:
await call.answer(
self._plain_text(
self.strings("ult_chat_already_bound")
if not added
else (
self.strings("ult_chat_recreated")
if had_old_chat
else self.strings("ult_chat_created")
)
),
show_alert=True,
)
except Exception:
pass
await self._ult_render_gens_chat(call)
except Exception as e:
logger.exception(e)
try:
await call.answer(
self._plain_text(self.strings("ult_chat_create_failed")).format(
str(e)
),
show_alert=True,
)
except Exception:
pass
await self._ult_render_gens_chat(call)
def _base_url(self):
if self._is_comfy_cloud():
return f"{_COMFY_CLOUD_BASE_URL}/api"
url = str(self.config["comfyui_url"] or "").strip().rstrip("/")
if not url:
return None
try:
parsed = urlparse(url)
hostname = parsed.hostname
_ = parsed.port
except ValueError:
logger.debug("ComfyUI URL format invalid: %s", url)
return None
if parsed.scheme in ("http", "https") and hostname:
return url
logger.debug("ComfyUI URL format invalid: %s", url)
return None
def _local_base_url(self):
url = str(self.config["comfyui_url"] or "").strip().rstrip("/")
if not url:
return None
try:
parsed = urlparse(url)
hostname = parsed.hostname
_ = parsed.port
except ValueError:
return None
if parsed.scheme in ("http", "https") and hostname:
return url
return None
def _comfy_backend(self):
backend = str(self.config["comfyui_backend"] or _COMFY_BACKEND_LOCAL).strip().lower()
if backend not in (_COMFY_BACKEND_LOCAL, _COMFY_BACKEND_CLOUD):
backend = _COMFY_BACKEND_LOCAL
return backend
def _is_comfy_cloud(self):
return self._comfy_backend() == _COMFY_BACKEND_CLOUD
def _comfy_root_url(self):
if self._is_comfy_cloud():
return _COMFY_CLOUD_BASE_URL
return self._local_base_url()
def _get_cloud_api_keys(self):
raw = str(self.config["comfyui_cloud_api_key"] or "")
keys = []
seen = set()
for key in raw.split(","):
key = key.strip()
if key and key not in seen:
seen.add(key)
keys.append(key)
return keys
def _set_cloud_api_keys(self, value):
keys = []
seen = set()
for key in str(value or "").split(","):
key = key.strip()
if key and key not in seen:
seen.add(key)
keys.append(key)
self.config["comfyui_cloud_api_key"] = ", ".join(keys)
self._cloud_balance_cache.clear()
def _cloud_headers(self, api_key=None):
key = api_key or self._cloud_api_key_or_raise()
return {"X-API-Key": key}
def _cloud_api_key_or_raise(self):
keys = self._get_cloud_api_keys()
if not keys:
raise UserFacingError("cloud_no_key", self._plain_text(self.strings("cloud_no_key")))
active_key = self._active_cloud_api_key.get()
if active_key:
return active_key
return keys[0]
async def _select_cloud_api_key(self, set_active=True):
keys = self._get_cloud_api_keys()
if not keys:
raise UserFacingError("cloud_no_key", self._plain_text(self.strings("cloud_no_key")))
last_error = None
for api_key in keys:
try:
async with self._session_get(
f"{_COMFY_CLOUD_BASE_URL}/api/object_info",
headers={"X-API-Key": api_key},
timeout=aiohttp.ClientTimeout(total=20),
) as resp:
text = await resp.text()
if resp.status == 200:
if set_active:
self._active_cloud_api_key.set(api_key)
return api_key
last_error = (resp.status, text)
if self._cloud_http_error_key(resp.status, text):
continue
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e:
last_error = e
continue
if isinstance(last_error, tuple):
self._raise_cloud_http_error(last_error[0], last_error[1])
raise UserFacingError("cloud_unavailable", self._plain_text(self.strings("cloud_unavailable")))
def _comfy_headers(self, api_key=None):
if not self._is_comfy_cloud():
return {}
return self._cloud_headers(api_key)
def _comfy_ws_url(self, client_id, api_key=None):
root = self._comfy_root_url()
if not root:
return None
ws_url = root.replace("http://", "ws://", 1).replace("https://", "wss://", 1)
if self._is_comfy_cloud():
key = api_key or self._cloud_api_key_or_raise()
return f"{ws_url}/ws?clientId={client_id}&token={quote(key, safe='')}"
return f"{ws_url}/ws?clientId={client_id}"
@staticmethod
def _cloud_http_error_key(status, body=""):
body_l = str(body or "").lower()
if status in (401, 403) or any(marker in body_l for marker in ("invalid api key", "unauthorized", "forbidden")):
return "cloud_bad_key"
if status == 402 or any(marker in body_l for marker in ("insufficient credits", "insufficient funds", "balance", "credits")):
return "cloud_no_balance"
if status == 429 or any(marker in body_l for marker in ("rate limit", "subscription inactive", "quota")):
return "cloud_rate_limit"
if status in (408, 425, 500, 502, 503, 504):
return "cloud_unavailable"
return None
def _raise_cloud_http_error(self, status, body=""):
key = self._cloud_http_error_key(status, body)
if key:
raise UserFacingError(key, self._plain_text(self.strings(key)))
raise ComfyUIHTTPError(status, str(body or ""))
def _format_cloud_missing_nodes(self, missing_nodes):
items = sorted(str(node) for node in (missing_nodes or []) if node)
detail = "\n".join(f"- {utils.escape_html(node)}" for node in items[:80])
if len(items) > 80:
detail += f"\n... +{len(items) - 80}"
return detail or "-"
def _format_comfy_backend_name(self, backend=None):
backend = backend or self._comfy_backend()
if backend == _COMFY_BACKEND_CLOUD:
return self.strings("mode_cloud")
return self.strings("mode_local")
@staticmethod
def _find_balance_value(data):
if not isinstance(data, dict):
return None
for key in ("balance", "credits", "credit", "available_credits", "remaining_credits"):
value = data.get(key)
if value is not None:
return value
for value in data.values():
if isinstance(value, dict):
found = ComfyImageGenMod._find_balance_value(value)
if found is not None:
return found
return None
def _format_balance_value(self, value):
if value is None:
return self.strings("mode_balance_unavailable")
if isinstance(value, (int, float)):
return f"{value:g}"
return str(value)
async def _get_cloud_balance(self, force=False):
keys = self._get_cloud_api_keys()
if not keys:
return None, "no_key"
cache_key = keys[0]
if not force and cache_key in self._cloud_balance_cache:
return self._cloud_balance_cache[cache_key], None
last_error = None
for api_key in keys:
try:
async with self._session_get(
f"{_COMFY_CLOUD_BASE_URL}/api/user",
headers={"X-API-Key": api_key},
timeout=aiohttp.ClientTimeout(total=15),
) as resp:
text = await resp.text()
if resp.status != 200:
last_error = (resp.status, text)
if self._cloud_http_error_key(resp.status, text):
continue
continue
try:
data = json.loads(text)
except json.JSONDecodeError:
return None, "unavailable"
balance = self._find_balance_value(data)
if balance is None:
return None, "unavailable"
formatted = self._format_balance_value(balance)
self._cloud_balance_cache[api_key] = formatted
return formatted, None
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e:
last_error = e
continue
if isinstance(last_error, tuple):
key = self._cloud_http_error_key(last_error[0], last_error[1])
if key == "cloud_bad_key":
return None, "bad_key"
if key == "cloud_no_balance":
return None, "no_balance"
return None, "unavailable"
async def _format_cloud_balance_for_ui(self, force=False):
if not self._get_cloud_api_keys():
return self.strings("mode_balance_no_key")
balance, error = await self._get_cloud_balance(force=force)
if balance is not None:
return str(balance)
if error == "no_key":
return self.strings("mode_balance_no_key")
return self.strings("mode_balance_unavailable")
@staticmethod
def _cdown_type_info(type_id):
return _CDOWN_TYPES.get(type_id) or _CDOWN_TYPES[_CDOWN_TYPE_CHECKPOINT]
def _cdown_type_label(self, type_id):
return self.strings(self._cdown_type_info(type_id)["label_key"])
@staticmethod
def _cdown_url_allowed(url):
try:
parsed = urlparse(str(url or "").strip())
except Exception:
return False
host = (parsed.hostname or "").lower()
return parsed.scheme in ("http", "https") and (
host == "huggingface.co"
or host.endswith(".huggingface.co")
or host == "civitai.com"
or host.endswith(".civitai.com")
or host == "civitai.red"
or host.endswith(".civitai.red")
)
@staticmethod
def _cdown_civitai_com_url(url):
try:
parsed = urlparse(str(url or "").strip())
except Exception:
return None
host = (parsed.hostname or "").lower()
if not (host == "civitai.red" or host.endswith(".civitai.red")):
return None
replacement_host = host[: -len("civitai.red")] + "civitai.com"
netloc = replacement_host
if parsed.port:
netloc = f"{netloc}:{parsed.port}"
return parsed._replace(netloc=netloc).geturl()
def _cdown_url_candidates(self, url):
primary = str(url or "").strip()
candidates = [primary] if primary else []
fallback = self._cdown_civitai_com_url(primary)
if fallback and fallback not in candidates:
candidates.append(fallback)
return candidates
@staticmethod
def _cdown_preview_url(url, limit=90):
url = " ".join(str(url or "").strip().split())
return url[: limit - 3] + "..." if len(url) > limit else url
@staticmethod
def _cdown_format_size(value):
try:
size = int(value)
except (TypeError, ValueError):
return "-"
if size < 0:
return "-"
units = ("B", "KB", "MB", "GB", "TB")
current = float(size)
unit = units[0]
for unit in units:
if current < 1024 or unit == units[-1]:
break
current /= 1024
if unit == "B":
return f"{int(current)} {unit}"
return f"{current:.2f}".rstrip("0").rstrip(".") + f" {unit}"
def _cdown_validation_error(self, metadata):
validation = metadata.get("validation") if isinstance(metadata, dict) else None
if not isinstance(validation, dict):
return None
if validation.get("is_valid", True):
return None
messages = []
for item in validation.get("errors") or []:
if isinstance(item, dict):
message = item.get("message") or item.get("code")
else:
message = str(item)
if message:
messages.append(str(message))
return "; ".join(messages[:3]) or "invalid"
async def _cdown_remote_metadata(self, url, api_key=None):
async with self._session_get(
f"{_COMFY_CLOUD_BASE_URL}/api/assets/remote-metadata",
params={"url": url},
headers=self._cloud_headers(api_key),
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
text = await resp.text()
if resp.status != 200:
raise ComfyUIHTTPError(resp.status, text)
try:
return json.loads(text)
except json.JSONDecodeError as e:
raise ValueError(f"non-JSON response: {text[:300]}") from e
async def _cdown_model_folders(self, api_key=None):
async with self._session_get(
f"{_COMFY_CLOUD_BASE_URL}/api/experiment/models",
headers=self._cloud_headers(api_key),
timeout=aiohttp.ClientTimeout(total=20),
) as resp:
text = await resp.text()
if resp.status != 200:
raise ComfyUIHTTPError(resp.status, text)
try:
data = json.loads(text)
except json.JSONDecodeError as e:
raise ValueError(f"non-JSON response: {text[:300]}") from e
return data if isinstance(data, list) else []
async def _cdown_resolve_folder(self, type_id, api_key=None):
aliases = {
alias.lower()
for alias in self._cdown_type_info(type_id)["folder_aliases"]
}
try:
folders = await self._cdown_model_folders(api_key)
except Exception as e:
logger.debug("Cloud model folders lookup failed: %s", e)
return None
candidates = []
for item in folders:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").lower()
paths = [str(path).lower() for path in item.get("folders") or []]
candidates.append((item, name, paths))
if name in aliases:
return item.get("name")
for path in paths:
if path in aliases:
return path
for item, name, paths in candidates:
if any(name.startswith(f"{alias}/") for alias in aliases):
return item.get("name")
for path in paths:
if any(path.startswith(f"{alias}/") for alias in aliases):
return path
return None
async def _cdown_download_asset(self, state, api_key=None):
type_id = state.get("type") or _CDOWN_TYPE_CHECKPOINT
info = self._cdown_type_info(type_id)
metadata = state.get("metadata") if isinstance(state.get("metadata"), dict) else {}
last_error = None
for source_url in self._cdown_url_candidates(state.get("url")):
payload = {
"source_url": source_url,
"tags": list(info["tags"]),
"user_metadata": {
"source": "ComfyImageGen",
"type": type_id,
"folder": state.get("folder") or "",
"filename": metadata.get("filename") or "",
},
}
try:
async with self._session_post(
f"{_COMFY_CLOUD_BASE_URL}/api/assets/download",
json=payload,
headers=self._cloud_headers(api_key),
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
text = await resp.text()
try:
data = json.loads(text) if text else {}
except json.JSONDecodeError:
data = {"raw": text}
if resp.status in (200, 202):
state["url"] = source_url
return resp.status, data
last_error = ComfyUIHTTPError(resp.status, text)
except (aiohttp.ClientError, asyncio.TimeoutError, OSError, ComfyUIHTTPError) as e:
last_error = e
if last_error:
raise last_error
raise ValueError("No download URL")
@staticmethod
def _cdown_expected_asset_names(state):
metadata = state.get("metadata") if isinstance(state.get("metadata"), dict) else {}
names = []
for value in (
metadata.get("filename"),
metadata.get("name"),
os.path.basename(urlparse(str(state.get("url") or "")).path),
):
value = str(value or "").strip()
if value and value not in names:
names.append(value)
return names
def _cdown_asset_matches(self, asset, expected_names):
if not isinstance(asset, dict):
return False
names = {name.lower() for name in expected_names if name}
metadata = asset.get("user_metadata") if isinstance(asset.get("user_metadata"), dict) else {}
candidates = [
asset.get("name"),
metadata.get("filename"),
]
for candidate in candidates:
candidate = str(candidate or "").strip().lower()
if candidate and candidate in names:
return True
return False
async def _cdown_find_downloaded_asset(self, state, api_key=None):
expected_names = self._cdown_expected_asset_names(state)
if not expected_names:
return None
try:
assets = await self._clib_fetch_model_assets(api_key)
except Exception as e:
logger.debug("Cloud asset readiness check failed: %s", e)
return None
for asset in assets:
if self._cdown_asset_matches(asset, expected_names):
return asset
return None
async def _cdown_task_status(self, task_id, api_key=None):
task_id = str(task_id or "").strip()
if not task_id:
return None
async with self._session_get(
f"{_COMFY_CLOUD_BASE_URL}/api/tasks/{quote(task_id, safe='')}",
headers=self._cloud_headers(api_key),
timeout=aiohttp.ClientTimeout(total=20),
) as resp:
text = await resp.text()
if resp.status != 200:
logger.debug(
"Cloud download task %s check failed (HTTP %s): %s",
task_id,
resp.status,
text[:300],
)
return None
try:
data = json.loads(text) if text else {}
except json.JSONDecodeError:
logger.debug(
"Cloud download task %s returned non-JSON: %s",
task_id,
text[:300],
)
return None
return data if isinstance(data, dict) else None
@staticmethod
def _cdown_task_state(task):
if not isinstance(task, dict):
return ""
return str(
task.get("status")
or task.get("state")
or task.get("task_status")
or ""
).strip().lower()
@staticmethod
def _cdown_task_message(task):
if not isinstance(task, dict):
return ""
for key in ("message", "error", "detail", "reason"):
value = task.get(key)
if value:
return str(value).strip()
return ""
async def _cdown_update_import_status(self, state, api_key=None):
result = state.get("result")
if not isinstance(result, dict) or result.get("status") in (200, "failed"):
return bool(isinstance(result, dict) and result.get("status") == 200)
task_id = str(result.get("task_id") or "").strip()
if task_id:
task = await self._cdown_task_status(task_id, api_key)
if task:
task_state = self._cdown_task_state(task)
task_message = self._cdown_task_message(task)
result["task_status"] = task_state or result.get("task_status") or "running"
if task_message:
result["task_message"] = task_message
if task_state in {"failed", "error", "cancelled", "canceled"}:
result["status"] = "failed"
result["error"] = task_message or task_state
return True
asset = await self._cdown_find_downloaded_asset(state, api_key)
if not asset:
return False
await self._cdown_finalize_asset_category(state, asset, api_key)
state["result"] = {
**result,
"status": 200,
"data": asset,
"task_status": result.get("task_status") or "completed",
}
self._cdown_start_lora_metadata_fetch(state, asset)
self._comfy_cache.clear()
return True
async def _cdown_finalize_asset_category(self, state, asset, api_key=None):
type_id = str((state or {}).get("type") or "").strip()
if type_id not in _CDOWN_TYPES or not isinstance(asset, dict):
return asset
try:
info = self._cdown_type_info(type_id)
tags = {
str(tag).strip().lower()
for tag in asset.get("tags") or []
if str(tag).strip()
}
metadata = asset.get("user_metadata")
folder = str((metadata or {}).get("folder") or "").strip().lower()
expected_tags = {str(tag).lower() for tag in info["tags"]}
expected_folders = {str(item).lower() for item in info["folder_aliases"]}
if (
self._clib_asset_type(asset) != type_id
or not expected_tags.issubset(tags)
or folder not in expected_folders
):
await self._clib_update_asset_category(asset, type_id, api_key)
except Exception as e:
logger.debug("Cloud asset category finalization failed: %s", e)
return asset
def _cdown_start_lora_metadata_fetch(self, state, asset=None):
if not isinstance(state, dict) or state.get("civitai_metadata_started"):
return
if state.get("type") != _CDOWN_TYPE_LORA:
return
source_url = str(state.get("url") or "").strip()
if not (
self._extract_civitai_version_id(source_url)
or self._extract_civitai_model_id(source_url)
):
return
lora_name = self._cloud_asset_model_name(asset)
if not lora_name:
lora_name = next(iter(self._cdown_expected_asset_names(state)), "")
if not lora_name:
return
state["civitai_metadata_started"] = True
task = asyncio.create_task(
self._cdown_fetch_lora_metadata(source_url, lora_name)
)
self._cdown_lora_metadata_tasks.add(task)
task.add_done_callback(self._cdown_lora_metadata_tasks.discard)
async def _cdown_fetch_lora_metadata(self, source_url, lora_name):
try:
version_id, triggers, base_model = await self._fetch_civitai_lora_triggers(
source_url,
filename=lora_name,
)
if version_id:
self._set_lora_metadata_entry(
lora_name,
triggers=triggers,
civitai_version=version_id,
civitai_base_model=base_model,
)
except asyncio.CancelledError:
raise
except Exception as e:
logger.debug("Cloud LoRA Civitai metadata fetch failed: %s", e)
def _cdown_cancel_watch(self, state_id):
task = self._cdown_watch_tasks.pop(state_id, None)
if task and not task.done():
task.cancel()
def _cdown_start_watch(self, target, state_id, api_key=None):
self._cdown_cancel_watch(state_id)
self._cdown_watch_tasks[state_id] = asyncio.create_task(
self._cdown_watch_download(target, state_id, api_key)
)
async def _cdown_watch_download(self, target, state_id, api_key=None):
try:
for attempt in range(90):
await asyncio.sleep(5 if attempt == 0 else 10)
if self._unloading:
return
state = self._cdown_states.get(state_id)
if not isinstance(state, dict):
return
result = state.get("result") if isinstance(state.get("result"), dict) else {}
if result.get("status") in (200, "failed"):
return
changed = await self._cdown_update_import_status(state, api_key)
result = state.get("result") if isinstance(state.get("result"), dict) else {}
if not changed:
continue
await self._cdown_render(target, state_id)
if result.get("status") in (200, "failed"):
return
except asyncio.CancelledError:
raise
except Exception as e:
logger.debug("Cloud download watch failed: %s", e)
finally:
current = self._cdown_watch_tasks.get(state_id)
if current is asyncio.current_task():
self._cdown_watch_tasks.pop(state_id, None)
async def _clib_fetch_model_assets(self, api_key=None):
assets = []
limit = 500
for model_tag in ("model", "models"):
offset = 0
while offset < 5000:
params = [
("include_tags", model_tag),
("include_public", "false"),
("limit", str(limit)),
("offset", str(offset)),
("sort", "updated_at"),
("order", "desc"),
]
async with self._session_get(
f"{_COMFY_CLOUD_BASE_URL}/api/assets",
params=params,
headers=self._cloud_headers(api_key),
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
text = await resp.text()
if resp.status != 200:
raise ComfyUIHTTPError(resp.status, text)
try:
data = json.loads(text) if text else {}
except json.JSONDecodeError as e:
raise ValueError(f"non-JSON response: {text[:300]}") from e
page = data.get("assets") if isinstance(data, dict) else []
if not isinstance(page, list):
break
assets.extend(item for item in page if isinstance(item, dict))
if not isinstance(data, dict) or not data.get("has_more") or not page:
break
offset += len(page)
unique = {
self._clib_asset_id(asset): asset
for asset in assets
if self._clib_asset_id(asset)
}
return sorted(
unique.values(),
key=lambda asset: self._cloud_asset_model_name(asset).lower()
if self._cloud_asset_model_name(asset)
else "",
)
def _clib_asset_folder(self, asset):
return str(self._cloud_asset_model_folder(asset) or "models")
def _clib_asset_type(self, asset):
metadata = asset.get("user_metadata") if isinstance(asset, dict) else None
type_id = str((metadata or {}).get("type") or "").strip()
if type_id in _CDOWN_TYPES:
return type_id
tags = {
str(tag).strip().lower()
for tag in (asset.get("tags") or []) if str(tag).strip()
}
folder = self._clib_asset_folder(asset).lower()
for candidate, info in _CDOWN_TYPES.items():
category_tags = {
tag.lower()
for tag in info["tags"]
if tag not in {"model", "models"}
}
category_tags.update(alias.lower() for alias in info["folder_aliases"])
aliases = {alias.lower() for alias in info["folder_aliases"]}
if category_tags & tags or folder in aliases:
return candidate
return None
def _clib_is_model_asset(self, asset):
if self._clib_asset_type(asset) in _CDOWN_TYPES:
return True
return self._is_model_filename(self._cloud_asset_model_name(asset))
@staticmethod
def _clib_asset_id(asset):
return str((asset or {}).get("id") or "").strip()
def _clib_asset_groups(self, assets):
groups = {}
for asset in assets or []:
if not self._clib_asset_id(asset) or not self._clib_is_model_asset(asset):
continue
groups.setdefault(self._clib_asset_folder(asset), []).append(asset)
for models in groups.values():
models.sort(
key=lambda asset: self._cloud_asset_model_name(asset).lower()
if self._cloud_asset_model_name(asset)
else ""
)
return dict(
sorted(
groups.items(),
key=lambda item: self._cloud_default_folder_sort_key(item[0]),
)
)
async def _clib_update_asset_category(self, asset, type_id, api_key=None):
if type_id not in _CDOWN_TYPES:
raise ValueError("Unknown model category")
if asset.get("is_immutable"):
raise ValueError(self.strings("clib_immutable"))
asset_id = self._clib_asset_id(asset)
if not asset_id:
raise ValueError("Cloud asset ID is missing")
info = self._cdown_type_info(type_id)
category_tags = {
tag.lower()
for item in _CDOWN_TYPES.values()
for tag in item["tags"]
if tag not in {"model", "models"}
}
category_tags.update(
alias.lower()
for item in _CDOWN_TYPES.values()
for alias in item["folder_aliases"]
)
tags = []
known = set()
for tag in asset.get("tags") or []:
tag = str(tag).strip()
if (
not tag
or tag.lower() in category_tags
or tag.lower() in {"model", "models"}
or tag.lower() in known
):
continue
tags.append(tag)
known.add(tag.lower())
for tag in info["tags"]:
if tag.lower() not in known:
tags.append(tag)
known.add(tag.lower())
existing_tags = {
str(tag).strip().lower()
for tag in asset.get("tags") or []
if str(tag).strip()
}
requested_tags = {tag.lower() for tag in tags}
remove_tags = sorted(existing_tags - requested_tags)
add_tags = sorted(requested_tags - existing_tags)
tags_url = f"{_COMFY_CLOUD_BASE_URL}/api/assets/{quote(asset_id, safe='')}/tags"
if remove_tags:
async with self._session_delete(
tags_url,
json={"tags": remove_tags},
headers=self._cloud_headers(api_key),
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
text = await resp.text()
if resp.status != 200:
raise ComfyUIHTTPError(resp.status, text)
if add_tags:
async with self._session_post(
tags_url,
json={"tags": add_tags},
headers=self._cloud_headers(api_key),
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
text = await resp.text()
if resp.status != 200:
raise ComfyUIHTTPError(resp.status, text)
metadata = asset.get("user_metadata")
metadata = dict(metadata) if isinstance(metadata, dict) else {}
folder = await self._cdown_resolve_folder(type_id, api_key)
metadata.update({
"type": type_id,
"folder": folder or info["folder_aliases"][0],
})
async with self._session_put(
f"{_COMFY_CLOUD_BASE_URL}/api/assets/{quote(asset_id, safe='')}",
json={"user_metadata": metadata},
headers=self._cloud_headers(api_key),
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
text = await resp.text()
if resp.status != 200:
raise ComfyUIHTTPError(resp.status, text)
asset["tags"] = tags
asset["user_metadata"] = metadata
self._comfy_cache.clear()
async def _ensure_cloud_lora_assets_ready(self, selected_loras):
if not self._is_comfy_cloud() or not selected_loras:
return
requested = {
self._model_match_key(name)
for name in selected_loras
if self._model_match_key(name)
}
if not requested:
return
try:
assets = await self._clib_fetch_model_assets()
for asset in assets:
name = self._cloud_asset_model_name(asset)
if self._model_match_key(name) not in requested:
continue
tags = {
str(tag).strip().lower()
for tag in asset.get("tags") or []
if str(tag).strip()
}
metadata = asset.get("user_metadata")
folder = str((metadata or {}).get("folder") or "").lower()
expected_tags = set(self._cdown_type_info(_CDOWN_TYPE_LORA)["tags"])
if not expected_tags.issubset(tags) or "lora" not in folder:
await self._clib_update_asset_category(asset, _CDOWN_TYPE_LORA)
except Exception as e:
logger.debug("Cloud LoRA asset preparation failed: %s", e)
async def _raise_if_cloud_workflow_unsupported(self, workflow):
if not self._is_comfy_cloud():
return
object_info = await self._get_all_object_info()
if not isinstance(object_info, dict):
return
available = set(object_info.keys())
missing = []
for node in (workflow or {}).values():
if not isinstance(node, dict):
continue
class_type = node.get("class_type")
if class_type and class_type not in available:
missing.append(class_type)
missing = list(dict.fromkeys(missing))
if missing:
detail = self._format_cloud_missing_nodes(missing)
raise UserFacingError(
"cloud_workflow_unsupported",
self._plain_text(self.strings("cloud_workflow_unsupported")).format(detail),
detail=detail,
)
@staticmethod
def _normalize_probe_url(url):
url = str(url or "").strip().rstrip("/")
try:
parsed = urlparse(url)
hostname = parsed.hostname
_ = parsed.port
except ValueError as e:
raise ValueError(url) from e
if parsed.scheme in ("http", "https") and hostname:
if parsed.netloc == urlparse(_COMFY_CLOUD_BASE_URL).netloc:
return f"{_COMFY_CLOUD_BASE_URL}/api"
return url
raise ValueError(url)
@staticmethod
def _ct_is_cloud_base(base_url):
parsed = urlparse(str(base_url or ""))
return parsed.netloc == urlparse(_COMFY_CLOUD_BASE_URL).netloc
def _ct_headers(self, base_url, api_key=None):
if self._ct_is_cloud_base(base_url):
return {"X-API-Key": api_key or self._cloud_api_key_or_raise()}
return None
def _ct_ws_url(self, base_url, client_id, api_key=None):
if self._ct_is_cloud_base(base_url):
ws_root = _COMFY_CLOUD_BASE_URL.replace("https://", "wss://", 1).replace("http://", "ws://", 1)
key = api_key or self._cloud_api_key_or_raise()
return f"{ws_root}/ws?clientId={client_id}&token={quote(key, safe='')}"
return (
base_url.replace("http://", "ws://", 1)
.replace("https://", "wss://", 1)
+ f"/ws?clientId={client_id}"
)
@staticmethod
def _ct_preview(value, limit=700):
if isinstance(value, (dict, list)):
text = json.dumps(value, ensure_ascii=False, indent=2)
else:
text = str(value)
text = " ".join(text.split())
return text[:limit] + ("..." if len(text) > limit else "")
@staticmethod
def _ct_input_default(spec, fallback):
if isinstance(spec, (list, tuple)) and len(spec) > 1 and isinstance(spec[1], dict):
return spec[1].get("default", fallback)
return fallback
@staticmethod
def _ct_icon(ok):
if ok:
return '✅'
return '👎'
def _ct_line(self, ok, label, detail=None):
line = "{} {}".format(
self._ct_icon(ok),
utils.escape_html(str(label)),
)
if detail:
line += f": {detail}"
return line
def _ct_error_detail(self, error, limit=220):
return "{}".format(
utils.escape_html(self._ct_preview(error, limit))
)
def _ct_build_empty_image_workflow(self, object_info):
empty_inputs = {
"width": 128,
"height": 128,
"batch_size": 1,
"color": 0,
}
save_inputs = {
"images": ["1", 0],
"filename_prefix": f"ctprobe_{int(time.time())}",
}
if isinstance(object_info, dict):
empty_req = (
object_info
.get("EmptyImage", {})
.get("input", {})
.get("required", {})
)
save_req = (
object_info
.get("SaveImage", {})
.get("input", {})
.get("required", {})
)
if isinstance(empty_req, dict):
for key, fallback in tuple(empty_inputs.items()):
if key in empty_req:
empty_inputs[key] = self._ct_input_default(empty_req[key], fallback)
for key in empty_req:
empty_inputs.setdefault(key, self._ct_input_default(empty_req[key], None))
if isinstance(save_req, dict):
for key in save_req:
if key == "images":
save_inputs[key] = ["1", 0]
elif key == "filename_prefix":
save_inputs[key] = f"ctprobe_{int(time.time())}"
else:
save_inputs.setdefault(key, self._ct_input_default(save_req[key], None))
return (
{
"1": {
"class_type": "EmptyImage",
"inputs": empty_inputs,
"_meta": {"title": "Probe Empty Image"},
},
"2": {
"class_type": "SaveImage",
"inputs": save_inputs,
"_meta": {"title": "Probe Save Image"},
},
},
"2",
)
async def _ct_get_text(self, url, timeout=15):
async with self._session_get(
url,
headers=self._ct_headers(url),
timeout=aiohttp.ClientTimeout(total=timeout),
) as resp:
text = await resp.text()
return resp.status, resp.headers.get("Content-Type", ""), text
async def _ct_get_json(self, url, timeout=15):
async with self._session_get(
url,
headers=self._ct_headers(url),
timeout=aiohttp.ClientTimeout(total=timeout),
) as resp:
text = await resp.text()
if resp.status != 200:
raise ComfyUIHTTPError(resp.status, text)
try:
return json.loads(text)
except json.JSONDecodeError as e:
content_type = resp.headers.get("Content-Type", "")
raise ValueError(f"non-JSON response ({content_type}): {text[:300]}") from e
async def _ct_probe_http(self, base_url):
lines = []
object_info = None
ok_all = True
checks = [
("/system_stats", "system_stats"),
("/features", "features"),
]
if self._ct_is_cloud_base(base_url):
checks.append(("/object_info", "object_info"))
else:
checks.extend(
[
("/object_info/EmptyImage", "object_info EmptyImage"),
("/object_info/SaveImage", "object_info SaveImage"),
]
)
checks.append(("/queue", "queue"))
for path, label in checks:
try:
status, content_type, text = await self._ct_get_text(base_url + path)
ok = status == 200
ok_all = ok_all and ok
detail = None
if not ok:
detail = "HTTP {} {}".format(
status,
utils.escape_html(content_type.split(";")[0] or "-"),
)
lines.append(self._ct_line(ok, label, detail))
if path.startswith("/object_info") and status == 200:
try:
data = json.loads(text)
if path == "/object_info":
object_info = data if isinstance(data, dict) else {}
for class_type in ("EmptyImage", "SaveImage"):
has_class = class_type in object_info
ok_all = ok_all and has_class
lines.append(
self._ct_line(
has_class,
f"object_info {class_type}",
None if has_class else self._ct_error_detail("missing"),
)
)
else:
object_info = object_info or {}
object_info.update(data)
except json.JSONDecodeError:
ok_all = False
lines.append(
self._ct_line(
False,
label,
self._ct_error_detail(f"non-JSON: {text}", 160),
)
)
except Exception as e:
ok_all = False
lines.append(self._ct_line(False, label, self._ct_error_detail(e)))
return ok_all, lines, object_info
async def _ct_probe_ws(self, base_url):
client_id = str(uuid.uuid4())
try:
async with self._session_ws_connect(
self._ct_ws_url(base_url, client_id),
timeout=10,
) as ws:
try:
msg = await asyncio.wait_for(ws.receive(), timeout=3)
except asyncio.TimeoutError:
return True, "connected, no initial message"
if msg.type == aiohttp.WSMsgType.TEXT:
return True, f"connected, first event: {self._ct_preview(msg.data, 220)}"
return True, f"connected, first frame type: {msg.type.name}"
except Exception as e:
return False, str(e)
async def _ct_queue_prompt(self, base_url, workflow, client_id):
api_key = self._cloud_api_key_or_raise() if self._ct_is_cloud_base(base_url) else None
payload = {"prompt": workflow, "client_id": client_id}
if api_key:
payload["extra_data"] = {"api_key_comfy_org": api_key}
async with self._session_post(
f"{base_url}/prompt",
json=payload,
headers=self._ct_headers(base_url, api_key),
timeout=aiohttp.ClientTimeout(total=_COMFY_TIMEOUTS["queue_prompt"]),
) as resp:
text = await resp.text()
if resp.status != 200:
raise ComfyUIHTTPError(resp.status, text)
data = json.loads(text)
prompt_id = data.get("prompt_id")
if not prompt_id:
raise ValueError(f"No prompt_id: {self._ct_preview(data)}")
return str(prompt_id)
async def _ct_history_once(self, base_url, prompt_id):
if self._ct_is_cloud_base(base_url):
data = await self._ct_get_json(f"{base_url}/jobs/{prompt_id}")
return self._cloud_job_to_history(prompt_id, data)
data = await self._ct_get_json(f"{base_url}/history/{prompt_id}")
return data.get(prompt_id) if isinstance(data, dict) else None
async def _ct_wait_result(self, base_url, prompt_id, output_node, ws, timeout):
start = time.time()
events = []
last_history_check = 0.0
last_history_error = None
execution_done = False
while time.time() - start < timeout:
if ws and not ws.closed:
try:
msg = await asyncio.wait_for(ws.receive(), timeout=1)
except asyncio.TimeoutError:
msg = None
if msg and msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
except json.JSONDecodeError:
data = {}
event_type = data.get("type", "unknown")
if event_type not in events:
events.append(event_type)
payload = data.get("data", {})
if isinstance(payload, dict) and payload.get("prompt_id") == prompt_id:
if event_type == "execution_error":
raise ValueError(self._ct_preview(payload, 1200))
if event_type in ("execution_success", "execution_complete"):
execution_done = True
if event_type == "executing" and payload.get("node") is None:
execution_done = True
elif msg and msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
ws = None
else:
await asyncio.sleep(1)
now = time.time()
if execution_done or now - last_history_check >= 2:
last_history_check = now
try:
history = await self._ct_history_once(base_url, prompt_id)
last_history_error = None
if history and self._extract_image_info(history, output_node):
return history, events
if execution_done and history:
raise ValueError(f"No image in history: {self._ct_preview(history, 1200)}")
except ValueError:
raise
except Exception as e:
last_history_error = e
logger.debug("ComfyUI tunnel probe history failed: %s", e)
if last_history_error:
raise asyncio.TimeoutError(
f"timeout waiting for prompt_id={prompt_id}; last history error: {last_history_error}"
)
raise asyncio.TimeoutError(f"timeout waiting for prompt_id={prompt_id}")
async def _ct_retrieve_image(self, base_url, image_info):
params = {
"filename": image_info.get("filename", ""),
"subfolder": image_info.get("subfolder", ""),
"type": image_info.get("type", "output"),
}
async with self._session_get(
f"{base_url}/view",
params=params,
headers=self._ct_headers(base_url),
allow_redirects=not self._ct_is_cloud_base(base_url),
timeout=self._retrieve_media_timeout(),
) as resp:
if self._ct_is_cloud_base(base_url) and resp.status in (301, 302, 303, 307, 308):
signed_url = resp.headers.get("Location")
if not signed_url:
raise ComfyUIHTTPError(resp.status, "Cloud view redirect has no Location")
async with self._session_get(signed_url, timeout=self._retrieve_media_timeout()) as signed_resp:
data = await signed_resp.read()
if signed_resp.status != 200:
raise ComfyUIHTTPError(signed_resp.status, repr(data[:500]))
return data
data = await resp.read()
if resp.status != 200:
raise ComfyUIHTTPError(resp.status, repr(data[:500]))
return data
async def _ct_probe_generation(self, base_url, object_info):
workflow, output_node = self._ct_build_empty_image_workflow(object_info)
client_id = str(uuid.uuid4())
ws = None
ws_ok = False
ws_note = ""
try:
ws = await self._session_ws_connect(
self._ct_ws_url(base_url, client_id),
timeout=10,
)
ws_ok = True
except Exception as e:
ws_note = str(e)
try:
prompt_id = await self._ct_queue_prompt(base_url, workflow, client_id)
history, events = await self._ct_wait_result(
base_url,
prompt_id,
output_node,
ws,
_CT_PROBE_TIMEOUT,
)
image_info = self._extract_image_info(history, output_node)
if not image_info:
raise ValueError(f"No image in history: {self._ct_preview(history, 1200)}")
image_bytes = await self._ct_retrieve_image(base_url, image_info)
return {
"prompt_id": prompt_id,
"ws_ok": ws_ok,
"ws_note": ws_note,
"events": events,
"image_info": image_info,
"image_bytes": image_bytes,
}
finally:
if ws and not ws.closed:
await ws.close()
def _ct_format_report(self, base_url, lines, ok):
details = "\n".join(lines) if lines else self.strings("ct_no_checks")
return "\n".join([
self.strings("ct_title"),
self.strings("ct_status").format(self.strings("ct_ok" if ok else "ct_fail")),
"",
f"{details}
",
])
async def _ct_run_probe(self, message, base_url):
status = await self._safe_answer(
message,
self.strings("ct_checking"),
)
lines = []
image_bytes = None
ok_all = True
try:
http_ok, http_lines, object_info = await self._ct_probe_http(base_url)
ok_all = ok_all and http_ok
lines.extend(http_lines)
ws_ok, ws_note = await self._ct_probe_ws(base_url)
ok_all = ok_all and ws_ok
lines.append(
self._ct_line(
ws_ok,
"websocket",
None if ws_ok else self._ct_error_detail(ws_note),
)
)
try:
result = await self._ct_probe_generation(base_url, object_info)
image_bytes = result["image_bytes"]
lines.append(
self._ct_line(
result["ws_ok"],
"generation",
None if result["ws_ok"] else self._ct_error_detail(result["ws_note"] or "websocket unavailable"),
)
)
lines.append(self._ct_line(True, "history/view"))
ok_all = ok_all and result["ws_ok"]
except Exception as e:
ok_all = False
logger.exception(e)
lines.append(
self._ct_line(
False,
"minimal generation",
self._ct_error_detail(e),
)
)
report = self._ct_format_report(base_url, lines, ok_all)
if image_bytes:
file_obj = io.BytesIO(image_bytes)
file_obj.name = "comfy_tunnel_probe.png"
try:
sent = await self._send_file_result(
utils.get_chat_id(message),
file_obj,
report,
reply_to=getattr(message, "reply_to_msg_id", None) or message.id,
force_document=False,
)
if status and status.id != getattr(sent, "id", None):
try:
await status.delete()
except Exception as e:
logger.debug("Failed to delete probe status message: %s", e)
return sent
except Exception as e:
logger.exception(e)
report = "\n\n".join([
report,
self.strings("ct_upload_failed").format(utils.escape_html(str(e))),
])
finally:
file_obj.close()
return await self._safe_answer(status or message, report)
except Exception as e:
logger.exception(e)
lines.append(
self._ct_line(False, "probe", self._ct_error_detail(e))
)
return await self._safe_answer(
status or message,
self._ct_format_report(base_url, lines, False),
)
def _ws_update_interval(self):
try:
value = int(self.config["ws_update_interval"])
except Exception:
return 2
if value == 0:
return 0
return max(1, min(5, value))
@staticmethod
def _parse_object_info_list(info, class_type, field):
if not info:
return []
raw = (
info
.get(class_type, {})
.get("input", {})
.get("required", {})
.get(field, [])
)
if isinstance(raw, list) and raw and isinstance(raw[0], list):
return [x for x in raw[0] if isinstance(x, str)]
if isinstance(raw, list):
return [x for x in raw if isinstance(x, str)]
return []
_EMOJI_TO_TG_RE = re.compile(
r'(.+?)'
)
@staticmethod
def _emoji_theme_char_key(char: str) -> str:
return str(char or "").replace("\ufe0f", "")
def _emoji_theme_name(self) -> str:
settings = self._get_ult_settings()
theme = str(settings.get("ui", {}).get("theme") or _EMOJI_THEME_DEFAULT).strip().lower()
if not self._emoji_theme_exists(theme):
return _EMOJI_THEME_DEFAULT
return theme
def _apply_emoji_theme(self, text: str) -> str:
theme = self._emoji_theme_name()
replacements, id_fallbacks, error_id_fallbacks = self._emoji_theme_maps(theme)
if not replacements and not id_fallbacks and not error_id_fallbacks:
return text
def replace(match):
tag = match.group("tag")
old_id = match.group("id")
old_char = match.group("char")
char_key = self._emoji_theme_char_key(old_char)
new_emoji = replacements.get((old_id, char_key))
if not new_emoji and old_id == "5121063440311386962":
new_emoji = error_id_fallbacks.get(char_key) or error_id_fallbacks.get("*")
if not new_emoji:
new_emoji = id_fallbacks.get(old_id)
if not new_emoji:
return match.group(0)
new_id, new_char = new_emoji
if tag == "emoji":
return f"{new_char}"
return f'{new_char}'
return _EMOJI_THEME_TAG_RE.sub(replace, str(text or ""))
def _to_inline_emoji(self, text: str) -> str:
text = self._apply_emoji_theme(text)
return self._EMOJI_TO_TG_RE.sub(
r'\2', text
)
@staticmethod
def _strip_leading_custom_emoji(text: str) -> str:
return re.sub(
r'^\s*<(?:tg-emoji\s+emoji-id="[^"]+"|emoji\s+document_id=\d+)>.*?(?:tg-emoji|emoji)>\s*',
"",
str(text or ""),
count=1,
flags=re.DOTALL,
)
def _format_generation_preflight_inline(self, text: str) -> str:
body = self._strip_leading_custom_emoji(self._to_inline_emoji(text))
return f"{_PREFLIGHT_EYES_INLINE} {body}"
async def _measure_userbot_ping_ms(self):
start = time.perf_counter()
await self.client.get_me()
return max(0, int((time.perf_counter() - start) * 1000))
def _format_ci_ping_quote(self, ping_ms):
try:
ping_ms = int(ping_ms)
except (TypeError, ValueError):
return ""
return f"{self.strings('info_userbot_ping').format(ping_ms)}
"
def _format_ci_loading_text(self, ping_ms=None):
text = self._format_generation_preflight_inline(self.strings("ci_loading"))
ping_quote = self._format_ci_ping_quote(ping_ms)
if ping_quote:
text = f"{text}\n{ping_quote}"
return text
async def _ci_ping_loop(self, target, ping_state, stop_event):
while not stop_event.is_set() and not self._unloading:
try:
ping_ms = await self._measure_userbot_ping_ms()
ping_state["value"] = ping_ms
text = self._format_ci_loading_text(ping_ms)
if isinstance(target, Message):
await utils.answer(target, self._apply_emoji_theme(text))
elif hasattr(target, "edit") and callable(target.edit):
await target.edit(text=self._apply_emoji_theme(text))
except asyncio.CancelledError:
raise
except Exception as e:
logger.debug("Failed to update ci ping: %s", e)
try:
await asyncio.wait_for(stop_event.wait(), timeout=2)
except asyncio.TimeoutError:
pass
@staticmethod
def _plain_text(text: str) -> str:
return re.sub(r"<[^>]+>", "", text)
async def _safe_close_form(self, call: InlineCall):
try:
await call.delete()
except Exception as e:
logger.debug("Failed to close inline form: %s", e)
def _classify_error(self, error):
error_text = str(error).lower()
if isinstance(error, asyncio.TimeoutError):
return "timeout", {}
if isinstance(error, asyncio.CancelledError):
return "cancelled", {}
if isinstance(error, UserFacingError):
return error.key, error.kwargs
if isinstance(error, (aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError, ConnectionRefusedError, OSError)):
return "connection", {}
if isinstance(error, aiohttp.ClientError):
return "connection", {}
if isinstance(error, ValueError):
raw = str(error)
if isinstance(error, ComfyUIExecutionError):
try:
error_json = json.loads(raw)
return self._classify_comfyui_json_error(error_json)
except json.JSONDecodeError:
pass
return "execution", {"message": raw}
if isinstance(error, ComfyUIHTTPError):
if error.temporary:
return "server_unavailable", {}
try:
error_json = json.loads(error.body)
return self._classify_comfyui_json_error(error_json)
except json.JSONDecodeError:
pass
if any(code in raw for code in ("HTTP 502", "HTTP 503", "HTTP 504")):
return "server_unavailable", {}
for prefix in ("HTTP 400: ", "HTTP 500: ", "HTTP 422: ", "HTTP 403: ", "HTTP 404: "):
if raw.startswith(prefix):
json_part = raw[len(prefix):]
try:
error_json = json.loads(json_part)
return self._classify_comfyui_json_error(error_json)
except json.JSONDecodeError:
pass
if "vae" in error_text and "source" in error_text:
return "vae_not_found", {}
if "no prompt_id" in error_text:
return "prompt_queue", {}
if self._plain_text(self.strings("unavailable")).lower() in error_text:
return "unavailable", {}
if "'nonetype' object has no attribute" in error_text:
return "none_input", {}
if "execution error" in error_text:
return "execution", {}
if "upload failed" in error_text:
return "upload_failed", {}
if "failed to retrieve" in error_text:
return "retrieve_failed", {}
if "failed to process image" in error_text:
return "image_invalid", {}
if "img2img unsupported" in error_text:
return "img2img_unsupported", {}
if "telegram send failed" in error_text:
return "send_failed", {}
if self._plain_text(self.strings("no_images")).lower() in error_text:
return "no_images", {}
if self._plain_text(self.strings("unexpected_comfy_response")).lower() in error_text:
return "server_unavailable", {}
return "generic", {}
return "generic", {}
def _classify_comfyui_json_error(self, error_json):
if not isinstance(error_json, dict):
return "execution", {}
error_info = error_json.get("error", {})
if not isinstance(error_info, dict):
return "execution", {}
error_type = error_info.get("type", "")
error_message = error_info.get("message", "")
extra_info = error_info.get("extra_info", {})
if not isinstance(extra_info, dict):
extra_info = {}
if error_type == "missing_node_type":
node_title = extra_info.get("node_title", "")
node_type = extra_info.get("node_type", node_title)
if self._is_comfy_cloud():
detail = self._format_cloud_missing_nodes([node_title or node_type or "Unknown"])
return "cloud_workflow_unsupported", {"detail": detail}
return "node_missing", {"node": node_title or node_type or "Unknown"}
error_msg_lower = error_message.lower()
if self._is_comfy_cloud() and any(
marker in error_msg_lower
for marker in (
"missing node",
"node does not exist",
"unknown node",
"not installed",
"node type",
)
):
node = extra_info.get("node_title") or extra_info.get("node_type") or extra_info.get("node_id") or error_message
detail = self._format_cloud_missing_nodes([node])
return "cloud_workflow_unsupported", {"detail": detail}
if "out of memory" in error_msg_lower or "cuda" in error_msg_lower or "vram" in error_msg_lower:
return "vram", {}
if ("checkpoint" in error_msg_lower or "model" in error_msg_lower) and "not found" in error_msg_lower:
if self._is_comfy_cloud():
return "cloud_model_not_found", {}
model_name = extra_info.get("model_name", "")
return "model_not_found", {"model": model_name}
if error_type == "prompt_outputs_failed_validation":
classified = self._classify_comfyui_validation_error(error_json)
if classified:
return classified
return "workflow_invalid", {}
details = {
"message": error_message,
"type": error_type,
"node": extra_info.get("node_title") or extra_info.get("node_type") or extra_info.get("node_id"),
"node_id": extra_info.get("node_id"),
"node_type": extra_info.get("node_type"),
}
return "execution", {k: v for k, v in details.items() if v}
@staticmethod
def _extract_comfyui_allowed_values(input_config):
if isinstance(input_config, list) and input_config and isinstance(input_config[0], list):
return [str(item) for item in input_config[0] if item is not None]
return []
def _classify_comfyui_validation_error(self, error_json):
node_errors = error_json.get("node_errors", {})
if not isinstance(node_errors, dict):
return None
model_fields = {
"ckpt_name",
"unet_name",
"diffusion_model",
"diffusion_model_name",
"model",
"model_name",
}
for node_error in node_errors.values():
if not isinstance(node_error, dict):
continue
errors = node_error.get("errors", [])
if not isinstance(errors, list):
continue
for item in errors:
if not isinstance(item, dict) or item.get("type") != "value_not_in_list":
continue
extra = item.get("extra_info", {})
if not isinstance(extra, dict):
extra = {}
input_name = str(extra.get("input_name") or "").strip()
input_name_l = input_name.lower()
is_model_field = (
input_name_l in model_fields
or "model" in input_name_l
or "ckpt" in input_name_l
or "checkpoint" in input_name_l
)
if not is_model_field:
continue
received = extra.get("received_value")
if received is None:
details = str(item.get("details") or "")
match = re.search(r":\s*'([^']+)'", details)
received = match.group(1) if match else "unknown"
if self._is_comfy_cloud():
return "cloud_model_not_found", {}
available = self._extract_comfyui_allowed_values(extra.get("input_config"))
return (
"model_value_not_in_list",
{
"model": str(received),
"available": available,
},
)
lines = []
for node_id, node_error in node_errors.items():
if not isinstance(node_error, dict):
continue
class_type = (
node_error.get("class_type")
or node_error.get("node_type")
or node_error.get("type")
or "Unknown"
)
title = node_error.get("node_title") or node_error.get("title")
node_label = f"{node_id}: {title or class_type}"
errors = node_error.get("errors", [])
if not isinstance(errors, list):
errors = []
if not errors:
lines.append(f"{utils.escape_html(str(node_label))}: validation failed")
continue
for item in errors[:3]:
if not isinstance(item, dict):
continue
extra = item.get("extra_info", {})
if not isinstance(extra, dict):
extra = {}
bits = []
input_name = extra.get("input_name")
if input_name:
bits.append(f"input {utils.escape_html(str(input_name))}")
err_type = item.get("type")
if err_type:
bits.append(f"type {utils.escape_html(str(err_type))}")
message = item.get("message") or item.get("details") or "validation failed"
received = extra.get("received_value")
line = f"{utils.escape_html(str(node_label))}: {utils.escape_html(str(message))}"
if bits:
line += " (" + ", ".join(bits) + ")"
if received is not None:
line += f"; value {utils.escape_html(str(received))}"
lines.append(line)
if len(lines) >= 6:
break
if len(lines) >= 6:
break
if lines:
return "workflow_invalid_details", {"details": "\n".join(lines)}
return None
def _get_error_message(self, error_type, details, is_inline=False):
error_map = {
"timeout": "timeout",
"unavailable": "easter_dream_unavailable",
"connection": "err_connection",
"server_unavailable": "err_server_unavailable",
"node_missing": "err_node_missing",
"model_not_found": "err_model_not_found",
"model_value_not_in_list": "err_model_value_not_in_list",
"cloud_model_not_found": "cloud_model_not_found",
"vram": "err_vram",
"image_invalid": "err_image_invalid",
"img2img_unsupported": "err_img2img_unsupported",
"upload_failed": "err_upload_failed",
"retrieve_failed": "err_retrieve_failed",
"send_failed": "err_send_failed",
"workflow_invalid": "err_workflow_invalid",
"workflow_invalid_details": "err_workflow_invalid_details",
"vae_not_found": "err_vae_not_found",
"prompt_queue": "err_prompt_queue",
"execution": "err_execution",
"none_input": "err_none_input",
"input_too_large": "img_too_large",
"ctools_workflow_no_input": "ctools_workflow_no_input",
"ctools_workflow_no_output": "ctools_workflow_no_output",
"no_images": "no_images",
"output_too_large": "output_too_large",
"image_too_many_pixels": "image_too_many_pixels",
"civitai_error": "civitai_error",
"civitai_no_prompt": "civitai_no_prompt",
"archive_access_lost": "ult_chat_access_lost",
"cloud_no_key": "cloud_no_key",
"cloud_bad_key": "cloud_bad_key",
"cloud_no_balance": "cloud_no_balance",
"cloud_rate_limit": "cloud_rate_limit",
"cloud_unavailable": "cloud_unavailable",
"cloud_workflow_unsupported": "cloud_workflow_unsupported",
"cloud_media_unsupported": "cloud_media_unsupported",
"generic": "err_generic",
}
string_key = error_map.get(error_type, "err_generic")
text = self.strings(string_key)
if error_type == "node_missing" and details.get("node"):
text = text.format(utils.escape_html(details["node"]))
elif error_type == "model_value_not_in_list":
available = details.get("available") or []
available_text = "\n".join(
f"- {utils.escape_html(self._format_model_name(model, max_length=None))}"
for model in available
)
if not available_text:
available_text = self.strings("not_set")
text = text.format(
utils.escape_html(self._format_model_name(details.get("model") or "unknown", max_length=None)),
available_text,
)
elif error_type == "model_not_found" and details.get("model"):
text = text.format(utils.escape_html(self._format_model_name(details["model"], max_length=None)))
elif error_type == "model_not_found":
text = text.format("unknown")
elif error_type == "workflow_invalid_details":
text = text.format(details.get("details") or self.strings("err_workflow_invalid"))
elif error_type == "cloud_workflow_unsupported":
text = text.format(details.get("detail") or "-")
elif error_type == "node_missing":
text = text.format("Unknown")
elif error_type == "execution" and details.get("message"):
detail = str(details["message"]).strip()
node_bits = []
if details.get("type"):
node_bits.append(f"type={details['type']}")
if details.get("node"):
node_bits.append(f"node={details['node']}")
if node_bits:
detail = f"{detail} ({', '.join(map(str, node_bits))})"
if len(detail) > 700:
detail = detail[:697] + "..."
text = f"{text}\n\n{utils.escape_html(detail)}"
elif error_type == "input_too_large":
text = text.format(details.get("max_mb", self.config["max_input_mb"]))
elif error_type == "ctools_workflow_no_input":
text = text.format(utils.escape_html(details.get("kind") or "media"))
elif error_type == "output_too_large":
text = text.format(details.get("max_mb", self.config["max_output_mb"]))
elif error_type == "image_too_many_pixels":
max_pixels = details.get("max_pixels", self.config["max_image_pixels"])
text = text.format(f"{int(max_pixels) / 1000000:g}")
if is_inline:
text = self._to_inline_emoji(text)
return text
async def _safe_answer(self, message, text):
text = self._apply_emoji_theme(text)
candidates = [text]
candidates.extend(self._text_retry_candidates(text))
last_error = None
for candidate in candidates:
try:
return await self._answer_text_once(message, candidate)
except Exception as e:
last_error = e
if "MessageIdInvalid" in type(e).__name__ or "MessageNotModified" in type(e).__name__:
try:
return await self.client.send_message(
message.chat_id, candidate, reply_to=message.id
)
except Exception as send_error:
last_error = send_error
if not self._is_inline_too_long_error(send_error):
break
elif self._is_inline_too_long_error(e):
logger.debug("Answer text is too long, retrying with shorter text: %s", e)
continue
else:
logger.debug("Failed to answer message: %s", e)
break
if last_error:
logger.debug("Failed to answer message after shortening: %s", last_error)
async def _answer_text_once(self, message, text):
if (
self._self_has_premium
and _TG_TEXT_LIMIT_DEFAULT <= self._parsed_html_text_len(text) <= _TG_TEXT_LIMIT_PREMIUM
):
try:
parsed_text, entities = self.client.parse_mode.parse(text)
edit = getattr(message, "out", False) and not getattr(message, "via_bot_id", None) and not getattr(message, "fwd_from", None)
if edit:
return await message.edit(
parsed_text,
parse_mode=lambda t: (t, entities),
)
return await message.respond(
parsed_text,
parse_mode=lambda t: (t, entities),
reply_to=getattr(message, "reply_to_msg_id", None),
)
except Exception as e:
if self._is_inline_too_long_error(e):
raise
logger.debug("Direct premium answer failed, falling back to utils.answer: %s", e)
return await utils.answer(message, text)
async def _send_plain_reply_to_command(self, message, text):
text = self._apply_emoji_theme(text)
candidates = [text]
candidates.extend(self._text_retry_candidates(text))
last_error = None
for candidate in candidates:
try:
return await self.client.send_message(
utils.get_chat_id(message),
candidate,
reply_to=getattr(message, "id", None),
)
except Exception as e:
last_error = e
if self._is_inline_too_long_error(e):
logger.debug("Plain command reply is too long, retrying with shorter text: %s", e)
continue
logger.debug("Failed to send plain command reply: %s", e)
break
if last_error:
logger.debug("Failed to send plain command reply after shortening: %s", last_error)
return await self._safe_answer(message, text)
async def _message_sent_as_channel(self, message):
from_id = getattr(message, "from_id", None)
if isinstance(from_id, PeerChannel) or type(from_id).__name__ == "PeerChannel":
peer_id = getattr(message, "peer_id", None)
if (
self._peer_is_channel_like(peer_id)
and self._peer_id_value(from_id) == self._peer_id_value(peer_id)
):
return False
return True
try:
sender = await message.get_sender()
except Exception:
sender = getattr(message, "sender", None)
if not getattr(sender, "broadcast", False):
return False
peer_id = getattr(message, "peer_id", None)
sender_id = getattr(sender, "id", None)
if self._peer_is_channel_like(peer_id) and sender_id == self._peer_id_value(peer_id):
return False
return True
async def _chat_is_linked_discussion(self, message):
try:
chat = await message.get_chat()
except Exception:
try:
chat = await self.client.get_entity(utils.get_chat_id(message))
except Exception:
chat = None
if not chat or getattr(chat, "broadcast", False):
return False
if not getattr(chat, "megagroup", False):
return False
if getattr(chat, "linked_chat_id", None) or getattr(chat, "linked_monoforum_id", None):
return True
try:
full = await self.client(GetFullChannelRequest(chat))
full_chat = getattr(full, "full_chat", None)
return bool(
getattr(full_chat, "linked_chat_id", None)
or getattr(full_chat, "linked_monoforum_id", None)
)
except Exception as e:
logger.debug("Failed to detect linked discussion chat: %s", e)
return False
async def _message_looks_like_channel_post(self, message):
if not message:
return False
if getattr(message, "post", False):
return True
fwd_from = getattr(message, "fwd_from", None)
if fwd_from and self._peer_is_channel_like(getattr(fwd_from, "from_id", None)):
return True
from_id = getattr(message, "from_id", None)
peer_id = getattr(message, "peer_id", None)
if self._peer_is_channel_like(from_id) and (
not peer_id or self._peer_id_value(from_id) != self._peer_id_value(peer_id)
):
return True
try:
sender = await message.get_sender()
except Exception:
sender = getattr(message, "sender", None)
return bool(getattr(sender, "broadcast", False))
async def _is_channel_discussion_comment(self, message):
reply_to = getattr(message, "reply_to", None)
reply_to_peer = getattr(reply_to, "reply_to_peer_id", None)
if reply_to_peer:
message_peer = getattr(message, "peer_id", None)
reply_peer_id = self._peer_id_value(reply_to_peer)
message_peer_id = self._peer_id_value(message_peer)
if reply_peer_id is not None and (
message_peer_id is None or reply_peer_id != message_peer_id
):
return True
thread_id = (
getattr(reply_to, "reply_to_top_id", None)
or getattr(reply_to, "reply_to_msg_id", None)
or getattr(message, "reply_to_msg_id", None)
)
if not thread_id:
return False
if not await self._chat_is_linked_discussion(message):
return False
try:
thread_message = await self.client.get_messages(
utils.get_chat_id(message),
ids=thread_id,
)
except Exception as e:
logger.debug("Failed to fetch discussion thread root: %s", e)
return False
return await self._message_looks_like_channel_post(thread_message)
async def _create_generation_preflight(self, message, string_key="preflight_preparing"):
text = self.strings(string_key)
inline_text = self._format_generation_preflight_inline(text)
if await self._message_sent_as_channel(message):
return await self._send_plain_reply_to_command(message, text)
try:
if not self._self_has_premium:
form = await self.inline.form(message=message, text=inline_text)
else:
form = await self.inline.form(message=message, text=inline_text)
try:
await form.edit(text=inline_text)
except Exception as e:
logger.debug("Premium preflight dummy edit failed: %s", e)
if form:
return form
except Exception as e:
logger.debug("Failed to create inline generation preflight: %s", e)
return await self._safe_answer(message, text)
async def _update_generation_preflight(self, target, string_key=None, text=None):
if not target:
return None
text = text if text is not None else self.strings(string_key)
try:
if isinstance(target, Message):
return await self._safe_answer(target, text) or target
await target.edit(text=self._format_generation_preflight_inline(text))
return target
except Exception as e:
logger.debug("Failed to update generation preflight: %s", e)
return target
def _split_html_text(self, text, limit=None):
limit = int(limit or self._message_text_limit())
try:
parsed_text, entities = self.client.parse_mode.parse(text)
return list(utils.smart_split(parsed_text, entities, limit))
except Exception as e:
logger.debug("HTML smart split failed: %s", e)
return [text[i : i + limit] for i in range(0, len(text), limit)]
@staticmethod
def _safe_topic(message):
try:
return utils.get_topic(message)
except AttributeError:
return None
async def _smart_answer(self, message, text):
text = self._apply_emoji_theme(text)
limit = self._message_text_limit()
if self._parsed_html_text_len(text) < limit:
return await self._safe_answer(message, text)
chunks = self._split_html_text(text)
if not chunks:
return await self._safe_answer(message, text)
first = await self._safe_answer(message, chunks[0])
if not first:
return None
chat_id = utils.get_chat_id(first if isinstance(first, Message) else message)
reply_to = self._safe_topic(message) or getattr(message, "reply_to_msg_id", None)
for chunk in chunks[1:]:
try:
await self.client.send_message(chat_id, chunk, reply_to=reply_to)
except Exception as e:
if not self._is_inline_too_long_error(e):
raise
for candidate in self._text_retry_candidates(chunk):
try:
await self.client.send_message(chat_id, candidate, reply_to=reply_to)
break
except Exception as retry_error:
if not self._is_inline_too_long_error(retry_error):
raise
return first
async def _handle_gen_error(self, target, error):
error_type, details = self._classify_error(error)
log_error = self._plain_text(str(error))
if error_type in ("server_unavailable", "unavailable", "connection", "timeout"):
logger.warning("Generation temporary error: %s: %s", type(error).__name__, log_error)
else:
logger.error("Generation error: %s: %s", type(error).__name__, log_error)
if isinstance(error, (MemoryError, OSError)):
logger.critical("CRITICAL SYSTEM ERROR: %s: %s", type(error).__name__, log_error)
if error_type not in ("server_unavailable", "unavailable", "connection", "timeout", "execution") and not isinstance(error, (asyncio.TimeoutError, asyncio.CancelledError)):
logger.exception(error)
is_inline = isinstance(target, InlineCall)
text = self._get_error_message(error_type, details, is_inline=is_inline)
try:
if isinstance(target, InlineCall):
await target.edit(text=text)
elif self._self_has_premium:
rendered = await self._render_inline(target, self._to_inline_emoji(text))
if not rendered:
await self._safe_answer(target, text)
else:
await self._safe_answer(target, text)
except Exception as e:
logger.debug("Failed to send error message: %s", e)
def _trace_input(self, workflow, node_id, input_name):
node = workflow.get(node_id)
if not node:
return None
val = node.get("inputs", {}).get(input_name)
if isinstance(val, list) and len(val) == 2:
return str(val[0])
return None
def _find_prompt_nodes(self, workflow):
positive_nid = None
negative_nid = None
text_nodes = {}
for nid, node in workflow.items():
if not isinstance(node, dict):
continue
ct = node.get("class_type", "")
ct_lower = ct.lower()
title = node.get("_meta", {}).get("title", "").lower()
inputs = node.get("inputs", {})
is_text_node = (
ct_lower in ("cliptextencode", "impactwildcardprocessor", "wildcardencode")
or "textencode" in ct_lower
or ct_lower in ("primitivestringmultiline", "ttn text", "cr text")
or ("text" in ct_lower and any(key in inputs for key in ("text", "value", "string", "prompt")))
or ("wildcard" in ct_lower and any(key in inputs for key in ("wildcard_text", "text", "prompt")))
or ("prompt" in ct_lower and any(key in inputs for key in ("prompt", "text", "positive", "negative")))
or (("positive" in title or "negative" in title) and any(isinstance(inputs.get(key), str) for key in ("wildcard_text", "text", "prompt", "text_g", "text_l", "positive", "negative", "value", "string")))
)
if is_text_node:
dual_fields = [field for field in ("text_g", "text_l") if field in inputs]
flux_fields = [field for field in ("clip_l", "t5xxl") if field in inputs]
field = (
"wildcard_text" if "wildcard_text" in inputs
else "text" if "text" in inputs
else "prompt" if "prompt" in inputs
else dual_fields if dual_fields
else flux_fields if flux_fields
else "clip_l" if "clip_l" in inputs
else "t5xxl" if "t5xxl" in inputs
else "positive" if "positive" in inputs
else "negative" if "negative" in inputs
else "value" if "value" in inputs
else "string" if "string" in inputs
else None
)
if field:
text_nodes[nid] = {"field": field, "title": title, "ct": ct, "inputs": inputs}
def find_text_source(start_nid):
traced = str(start_nid)
seen = set()
for _ in range(12):
if not traced or traced in seen:
break
seen.add(traced)
if traced in text_nodes:
return traced
node = workflow.get(traced)
if not isinstance(node, dict):
break
next_id = None
for value in node.get("inputs", {}).values():
if self._is_workflow_link(value):
next_id = str(value[0])
break
if not next_id:
break
traced = next_id
return None
for nid, node in workflow.items():
if not isinstance(node, dict):
continue
ct = node.get("class_type", "")
if ct != "SetNode":
continue
inputs = node.get("inputs", {})
title = node.get("_meta", {}).get("title", "").lower()
if "CONDITIONING" not in inputs or not self._is_workflow_link(inputs["CONDITIONING"]):
continue
text_source = find_text_source(inputs["CONDITIONING"][0])
if not text_source:
continue
if "positive" in title and not positive_nid:
positive_nid = text_source
if "negative" in title and not negative_nid:
negative_nid = text_source
for nid, info in text_nodes.items():
if "positive" in info["title"] and not positive_nid:
positive_nid = nid
if "negative" in info["title"] and not negative_nid:
negative_nid = nid
if positive_nid == negative_nid:
negative_nid = None
if positive_nid and negative_nid:
return positive_nid, text_nodes[positive_nid]["field"], negative_nid, text_nodes[negative_nid]["field"]
conditioning_consumers = {"CLIPTextEncode", "ImpactWildcardProcessor", "WildcardEncode"} | {
info["ct"] for info in text_nodes.values()
}
negative_feeders = set()
positive_feeders = set()
for nid, node in workflow.items():
if not isinstance(node, dict):
continue
ct = node.get("class_type", "")
inputs = node.get("inputs", {})
if ct in ("CFGGuider", "KSampler", "KSamplerAdvanced", "SamplerCustom") or "positive" in inputs or "negative" in inputs:
role_by_input = {
"positive": "positive",
"negative": "negative",
"cond1": "positive",
"cond2": "negative",
"conditioning": "positive",
}
for input_key, role in role_by_input.items():
src = self._trace_input(workflow, nid, input_key)
if src:
traced = src
for _ in range(10):
if not traced or traced not in workflow:
break
if (
role == "negative"
and workflow[traced].get("class_type", "")
== "ConditioningZeroOut"
):
break
if workflow[traced].get("class_type", "") in conditioning_consumers:
if role == "positive":
positive_feeders.add(traced)
else:
negative_feeders.add(traced)
break
found_next = False
for iv in workflow[traced].get("inputs", {}).values():
if isinstance(iv, list) and len(iv) == 2:
traced = str(iv[0])
found_next = True
break
if not found_next:
break
def ordered_node_ids(node_ids):
def sort_key(item):
text = str(item)
parts = text.split(":")
if all(part.isdigit() for part in parts):
return (0, tuple(int(part) for part in parts))
return (1, text)
return sorted(node_ids, key=sort_key)
for nid in ordered_node_ids(positive_feeders):
if nid in text_nodes and not positive_nid:
positive_nid = nid
for nid in ordered_node_ids(negative_feeders):
if nid in text_nodes and nid != positive_nid and not negative_nid:
negative_nid = nid
if not positive_nid and not negative_nid and len(text_nodes) == 1:
positive_nid = next(iter(text_nodes))
if not positive_nid and not negative_nid and text_nodes:
remaining = [nid for nid in text_nodes if nid not in negative_feeders]
if not remaining:
remaining = list(text_nodes.keys())
best = None
for nid in remaining:
fields = text_nodes[nid]["field"]
if isinstance(fields, str):
fields = [fields]
txt = next((text_nodes[nid]["inputs"].get(field, "") for field in fields if field in text_nodes[nid]["inputs"]), "")
if isinstance(txt, str) and txt.strip():
best = nid
break
if best:
positive_nid = best
if not negative_nid and text_nodes:
for nid in text_nodes:
if nid != positive_nid:
fields = text_nodes[nid]["field"]
if isinstance(fields, str):
fields = [fields]
txt = next((text_nodes[nid]["inputs"].get(field, "") for field in fields if field in text_nodes[nid]["inputs"]), "")
if isinstance(txt, str) and not txt.strip():
negative_nid = nid
break
pos_field = text_nodes[positive_nid]["field"] if positive_nid and positive_nid in text_nodes else None
neg_field = text_nodes[negative_nid]["field"] if negative_nid and negative_nid in text_nodes else None
if positive_nid and pos_field:
if isinstance(pos_field, list):
resolved_fields = []
resolved_node_id = None
for field in pos_field:
resolved = self._resolve_workflow_input_mapping(
workflow,
positive_nid,
field,
("wildcard_text", "text", "prompt", "clip_l", "t5xxl", "value", "string"),
)
if resolved_node_id is None:
resolved_node_id = resolved["node_id"]
if resolved["node_id"] != resolved_node_id:
resolved_fields = [resolved["field"]]
resolved_node_id = resolved["node_id"]
break
resolved_fields.append(resolved["field"])
positive_nid = resolved_node_id
pos_field = resolved_fields
else:
resolved = self._resolve_workflow_input_mapping(
workflow,
positive_nid,
pos_field,
("wildcard_text", "text", "prompt", "clip_l", "t5xxl", "value", "string"),
)
positive_nid = resolved["node_id"]
pos_field = resolved["field"]
if negative_nid and neg_field:
if isinstance(neg_field, list):
resolved_fields = []
resolved_node_id = None
for field in neg_field:
resolved = self._resolve_workflow_input_mapping(
workflow,
negative_nid,
field,
("wildcard_text", "text", "prompt", "clip_l", "t5xxl", "value", "string"),
)
if resolved_node_id is None:
resolved_node_id = resolved["node_id"]
if resolved["node_id"] != resolved_node_id:
resolved_fields = [resolved["field"]]
resolved_node_id = resolved["node_id"]
break
resolved_fields.append(resolved["field"])
negative_nid = resolved_node_id
neg_field = resolved_fields
else:
resolved = self._resolve_workflow_input_mapping(
workflow,
negative_nid,
neg_field,
("wildcard_text", "text", "prompt", "clip_l", "t5xxl", "value", "string"),
)
negative_nid = resolved["node_id"]
neg_field = resolved["field"]
if positive_nid == negative_nid:
negative_nid = None
neg_field = None
return positive_nid, pos_field, negative_nid, neg_field
@staticmethod
def _is_workflow_link(value):
return isinstance(value, list) and len(value) == 2 and isinstance(value[0], (str, int))
@classmethod
def _extract_comfy_types(cls, value):
if isinstance(value, str):
return {value.upper()}
if isinstance(value, (list, tuple)):
result = set()
for item in value:
result.update(cls._extract_comfy_types(item))
return result
return set()
@classmethod
def _extract_required_input_types(cls, input_spec):
if isinstance(input_spec, str):
return {input_spec.upper()}
if isinstance(input_spec, (list, tuple)) and input_spec:
return cls._extract_comfy_types(input_spec[0])
return set()
def _get_node_output_types(self, workflow, object_info, node_id, output_index=0):
node = workflow.get(str(node_id))
if not isinstance(node, dict):
return set()
class_type = node.get("class_type", "")
node_info = object_info.get(class_type, {}) if isinstance(object_info, dict) else {}
outputs = node_info.get("output", [])
if isinstance(outputs, (list, tuple)) and len(outputs) > output_index:
output_types = self._extract_comfy_types(outputs[output_index])
if output_types:
return output_types
if class_type == "CheckpointLoaderSimple":
return ({"MODEL"}, {"CLIP"}, {"VAE"})[output_index] if output_index in (0, 1, 2) else set()
if class_type == "UNETLoader":
return {"MODEL"}
if class_type == "CLIPLoader":
return {"CLIP"}
if class_type == "VAELoader":
return {"VAE"}
if class_type == "PathchSageAttentionKJ":
return {"MODEL"}
if "Lora Loader" in class_type or "LoraLoader" in class_type:
return ({"MODEL"}, {"CLIP"})[output_index] if output_index in (0, 1) else set()
return set()
def _get_global_input_types(self, workflow, object_info):
global_types = set()
for node in workflow.values():
if not isinstance(node, dict):
continue
class_type = str(node.get("class_type", ""))
if class_type.lower() != "anything everywhere":
continue
for value in node.get("inputs", {}).values():
if not self._is_workflow_link(value):
continue
try:
output_index = int(value[1])
except (TypeError, ValueError):
output_index = 0
global_types.update(
self._get_node_output_types(workflow, object_info, value[0], output_index)
)
return global_types
def _get_global_input_links(self, workflow, object_info):
global_links = {}
for node in workflow.values():
if not isinstance(node, dict):
continue
class_type = str(node.get("class_type", ""))
if class_type.lower() != "anything everywhere":
continue
for value in node.get("inputs", {}).values():
if not self._is_workflow_link(value):
continue
try:
output_index = int(value[1])
except (TypeError, ValueError):
output_index = 0
link = [str(value[0]), output_index]
for output_type in self._get_node_output_types(
workflow,
object_info,
value[0],
output_index,
):
global_links.setdefault(output_type, link)
return global_links
def _global_input_covers_required(self, input_name, input_spec, global_types):
if not global_types:
return False
known_names = {
"clip": {"CLIP"},
"vae": {"VAE"},
"model": {"MODEL"},
}
input_types = self._extract_required_input_types(input_spec)
fallback_types = known_names.get(str(input_name).lower(), set())
return bool((input_types | fallback_types) & global_types)
def _global_link_for_required(self, input_name, input_spec, global_links):
if not global_links:
return None
known_names = {
"clip": {"CLIP"},
"vae": {"VAE"},
"model": {"MODEL"},
}
input_types = self._extract_required_input_types(input_spec)
input_types.update(known_names.get(str(input_name).lower(), set()))
for input_type in input_types:
link = global_links.get(input_type)
if link:
return list(link)
return None
@staticmethod
def _is_ignored_missing_required_input(class_type, input_name):
return (
str(class_type) == "ToDetailerPipe"
and str(input_name) == "bbox_detector"
)
async def _materialize_global_inputs(self, workflow):
object_info = await self._get_all_object_info()
if not isinstance(object_info, dict):
return workflow
global_links = self._get_global_input_links(workflow, object_info)
if not global_links:
return workflow
for node_id, node in workflow.items():
if not isinstance(node, dict):
continue
class_type = node.get("class_type")
if str(class_type).lower() == "anything everywhere":
continue
node_info = object_info.get(class_type, {})
required_inputs = (
node_info
.get("input", {})
.get("required", {})
)
if not isinstance(required_inputs, dict):
continue
inputs = node.setdefault("inputs", {})
for input_name, input_spec in required_inputs.items():
if input_name in inputs:
continue
link = self._global_link_for_required(
input_name,
input_spec,
global_links,
)
if link:
inputs[input_name] = link
return workflow
def _resolve_workflow_input_mapping(self, workflow, node_id, field, preferred_fields=None):
node_id = str(node_id)
field = str(field)
preferred_fields = tuple(preferred_fields or ())
seen = set()
for _ in range(8):
key = (node_id, field)
if key in seen:
break
seen.add(key)
node = workflow.get(node_id)
if not isinstance(node, dict):
break
inputs = node.get("inputs", {})
if field not in inputs or not self._is_workflow_link(inputs[field]):
break
source_node_id = str(inputs[field][0])
source_node = workflow.get(source_node_id)
if not isinstance(source_node, dict):
break
source_inputs = source_node.get("inputs", {})
candidates = preferred_fields + (field, "value")
next_field = next((candidate for candidate in candidates if candidate in source_inputs), None)
if not next_field:
break
node_id = source_node_id
field = next_field
return {"node_id": node_id, "field": field}
@staticmethod
def _set_get_node_name(node):
meta = node.get("_meta", {}) if isinstance(node, dict) else {}
widgets = meta.get("ui_widgets", [])
if isinstance(widgets, list) and widgets and widgets[0] is not None:
return str(widgets[0]).strip().lower()
title = str(meta.get("title", "")).strip().lower()
if title.startswith("set_") or title.startswith("get_"):
return title[4:].strip()
return title
@staticmethod
def _is_sampler_like_node(class_type, inputs):
lowered = str(class_type).lower()
sampler_fields = {"steps", "cfg", "denoise", "sampler_name", "scheduler", "seed", "noise_seed"}
return "sampler" in lowered and any(field in inputs for field in sampler_fields)
@staticmethod
def _is_scheduler_like_node(class_type, inputs):
lowered = str(class_type).lower()
scheduler_fields = {"steps", "scheduler", "denoise"}
return "scheduler" in lowered and any(field in inputs for field in scheduler_fields)
@staticmethod
def _is_model_loader_like_node(class_type, inputs):
lowered = str(class_type).lower()
if any(token in lowered for token in ("sam", "upscale", "vae", "clip", "lora")):
return False
if "frameinterpolation" in lowered or ("frame" in lowered and "interpolation" in lowered):
return False
if "backgroundremoval" in lowered or ("background" in lowered and "removal" in lowered):
return False
model_fields = {"ckpt_name", "unet_name", "diffusion_model_name", "model_name", "patch_name", "model_patch", "model_patch_name"}
return (
any(field in inputs for field in model_fields)
and any(token in lowered for token in ("checkpoint", "ckpt", "unet", "diffusion", "model", "patch"))
)
@staticmethod
def _is_api_image_node(class_type, inputs):
lowered = str(class_type).lower()
return (
any(token in lowered for token in ("gemini", "nano", "openai", "gpt", "image2"))
and "prompt" in inputs
and any(field in inputs for field in ("model", "seed", "resolution", "aspect_ratio"))
)
@staticmethod
def _is_size_like_node(class_type, title, inputs):
lowered = f"{class_type} {title}".lower()
return (
"width" in inputs
and "height" in inputs
and any(token in lowered for token in ("latent", "size", "resolution", "aspect", "empty", "image"))
)
@staticmethod
def _is_output_like_node(class_type, title, inputs):
lowered = f"{class_type} {title}".lower()
if not any(field in inputs for field in ("images", "image", "pixels", "video", "videos")):
return False
if "preview" in lowered:
return False
return any(token in lowered for token in ("save", "preview", "output", "viewer", "display"))
@staticmethod
def _is_video_output_node(class_type, title, inputs):
lowered = f"{class_type} {title}".lower()
if "vhs_videocombine" in lowered:
return True
if any(token in lowered for token in ("savevideo", "save video", "video combine", "videocombine")):
return True
if any(field in inputs for field in ("video", "videos", "frames")) and any(
token in lowered
for token in ("save", "output", "combine", "video")
):
return True
return False
@staticmethod
def _is_preview_output_node(class_type, title):
lowered = f"{class_type} {title}".lower()
return "preview" in lowered or "viewer" in lowered or "display" in lowered
@staticmethod
def _is_save_output_node(class_type, title):
lowered = f"{class_type} {title}".lower()
if "preview" in lowered:
return False
return any(token in lowered for token in ("saveimage", "save image", "image saver", "savevideo", "save video", "save", "output"))
@staticmethod
def _is_media_loader_node(class_type, title):
lowered = f"{class_type} {title}".lower()
return any(token in lowered for token in ("loadimage", "load image", "loadvideo", "load video", "image loader", "video loader"))
@classmethod
def _media_output_rank(cls, workflow, node_id, media_kind="image"):
node = workflow.get(str(node_id), {}) if isinstance(workflow, dict) else {}
class_type = str(node.get("class_type", ""))
title = str(node.get("_meta", {}).get("title", ""))
lowered = f"{class_type} {title}".lower()
rank = 0
if cls._is_preview_output_node(class_type, title):
rank -= 200
if cls._is_save_output_node(class_type, title):
rank += 100
if "final" in lowered:
rank += 40
if "output" in lowered:
rank += 20
if "input" in lowered or "original" in lowered:
rank -= 40
if media_kind == "video" and "video" in lowered:
rank += 20
seen = set()
transform_count = 0
loader_count = 0
def walk(nid, depth=0):
nonlocal transform_count, loader_count
if depth > 16:
return
nid = str(nid)
if nid in seen:
return
seen.add(nid)
current = workflow.get(nid, {}) if isinstance(workflow, dict) else {}
if not isinstance(current, dict):
return
ct = str(current.get("class_type", ""))
tt = str(current.get("_meta", {}).get("title", ""))
if cls._is_media_loader_node(ct, tt):
loader_count += 1
elif nid != str(node_id) and not cls._is_preview_output_node(ct, tt):
transform_count += 1
for value in current.get("inputs", {}).values():
if isinstance(value, list) and len(value) == 2:
walk(value[0], depth + 1)
walk(node_id)
rank += min(transform_count, 6) * 10
if loader_count and transform_count == 0:
rank -= 120
return rank
@staticmethod
def _video_output_rank(class_type, title):
lowered = f"{class_type} {title}".lower()
rank = 0
if "final" in lowered:
rank += 50
if "audio" in lowered:
rank += 10
if any(token in lowered for token in ("save", "output")):
rank += 10
if any(token in lowered for token in ("preview", "lq", "low quality")):
rank -= 20
return rank
@staticmethod
def _is_input_image_node(class_type, title, inputs):
lowered = f"{class_type} {title}".lower()
if not any(field in inputs for field in ("image", "file", "path")):
return False
if any(token in lowered for token in ("save", "preview", "output", "combine")):
return False
return any(token in lowered for token in ("loadimage", "load image", "image loader", "imageloader", "k3nkimage"))
@staticmethod
def _is_image_only_mapping(mapping):
if not isinstance(mapping, dict):
return False
output = (
mapping.get("output")
or mapping.get("output_regular")
or mapping.get("output_upscaled")
or mapping.get("output_video")
)
return (
bool(output)
and bool(mapping.get("input_image") or mapping.get("input_video") or mapping.get("latent_switch"))
and not bool(mapping.get("positive"))
and not bool(mapping.get("model"))
and not any(mapping.get(key) for key in ("seed", "steps", "cfg", "denoise"))
)
def _is_image_only_workflow_data(self, wf_data):
if not isinstance(wf_data, dict):
return False
mapping = wf_data.get("mapping") or self._parse_workflow(wf_data.get("workflow", {}))
return self._is_image_only_mapping(mapping)
def _workflow_requires_input_image(self, wf_data):
return self._workflow_required_input_kind(wf_data) == "image"
def _workflow_required_input_kind(self, wf_data):
if not isinstance(wf_data, dict):
return None
mapping = wf_data.get("mapping") or self._parse_workflow(wf_data.get("workflow", {}))
if not isinstance(mapping, dict):
return None
if mapping.get("latent_switch"):
return None
input_kind = mapping.get("input_kind")
if input_kind == "video" and mapping.get("input_video"):
return "video"
if input_kind in ("image", "image_pair") and bool(mapping.get("input_image") or mapping.get("input_images")):
return "image"
return None
def _parse_workflow(self, workflow):
workflow = self._normalize_workflow_format(workflow)
mapping = {
"positive": None, "negative": None, "model": None, "seed": None,
"width": None, "height": None, "output_upscaled": None,
"output_regular": None, "output_video": None, "output_preview": None,
"steps": None, "cfg": None, "denoise": None,
"latent_switch": None, "vae_output_node": None, "sam_model": None,
"vae": None, "input_image": None, "input_video": None, "scale_by": None,
"sampler_name": None, "scheduler": None, "upscale_model_loader": None,
"ultimate_upscale_node_id": None, "hires_fix_node_id": None,
"output": None, "flux_guidance": None, "megapixels": None,
"resolution": None, "frames": None, "fps": None,
"output_kind": "image", "input_kind": "none",
"megapixels_nodes": [], "model_nodes": [], "input_images": [],
}
if not isinstance(workflow, dict):
return mapping
pos_nid, pos_field, neg_nid, neg_field = self._find_prompt_nodes(workflow)
if pos_nid and pos_field:
mapping["positive"] = {"node_id": pos_nid, "field": pos_field}
if neg_nid and neg_field:
mapping["negative"] = {"node_id": neg_nid, "field": neg_field}
for nid, node in workflow.items():
if not isinstance(node, dict):
continue
ct = node.get("class_type", "")
inputs = node.get("inputs", {})
title = node.get("_meta", {}).get("title", "").lower()
if ct == "CheckpointLoaderSimple" and "ckpt_name" in inputs:
mapping.setdefault("model_nodes", [])
mapping["model_nodes"].append({"node_id": nid, "field": "ckpt_name"})
if not mapping["model"]:
mapping["model"] = {"node_id": nid, "field": "ckpt_name"}
mapping["vae_output_node"] = {"node_id": nid, "output_index": 2}
if ct == "UNETLoader" and "unet_name" in inputs:
mapping.setdefault("model_nodes", [])
mapping["model_nodes"].append({"node_id": nid, "field": "unet_name"})
if not mapping["model"]:
mapping["model"] = {"node_id": nid, "field": "unet_name"}
if "diffusion_model" in inputs:
resolved = self._resolve_workflow_input_mapping(workflow, nid, "diffusion_model", ("diffusion_model", "unet_name", "ckpt_name", "model_name", "value"))
mapping.setdefault("model_nodes", [])
if resolved not in mapping["model_nodes"]:
mapping["model_nodes"].append(resolved)
if not mapping["model"]:
mapping["model"] = resolved
if self._is_model_loader_like_node(ct, inputs):
for model_field in ("ckpt_name", "unet_name", "diffusion_model_name", "model_name", "patch_name", "model_patch", "model_patch_name"):
if model_field not in inputs:
continue
resolved = self._resolve_workflow_input_mapping(
workflow,
nid,
model_field,
("ckpt_name", "unet_name", "diffusion_model_name", "model_name", "patch_name", "model_patch", "model_patch_name", "value"),
)
mapping.setdefault("model_nodes", [])
if resolved not in mapping["model_nodes"]:
mapping["model_nodes"].append(resolved)
if not mapping["model"]:
mapping["model"] = resolved
break
if self._is_api_image_node(ct, inputs):
if not mapping["positive"]:
mapping["positive"] = self._resolve_workflow_input_mapping(
workflow,
nid,
"prompt",
("prompt", "text", "value"),
)
if "model" in inputs and not mapping["model"]:
mapping["model"] = {"node_id": nid, "field": "model"}
mapping.setdefault("model_nodes", [])
if mapping["model"] not in mapping["model_nodes"]:
mapping["model_nodes"].append(mapping["model"])
if "seed" in inputs and not mapping["seed"]:
mapping["seed"] = self._resolve_workflow_input_mapping(
workflow,
nid,
"seed",
("seed", "noise_seed", "value"),
)
if "resolution" in inputs and not mapping["resolution"]:
mapping["resolution"] = {"node_id": nid, "field": "resolution"}
if ct == "SAMLoader" and "model_name" in inputs and not mapping["sam_model"]:
mapping["sam_model"] = {"node_id": nid, "field": "model_name"}
if ct == "VAELoader" and "vae_name" in inputs and not mapping["vae"]:
mapping["vae"] = {"node_id": nid, "field": "vae_name"}
if ct == "UpscaleModelLoader" and "model_name" in inputs and not mapping["upscale_model_loader"]:
mapping["upscale_model_loader"] = {"node_id": nid, "field": "model_name"}
if ct == "UltimateSDUpscale" and not mapping["ultimate_upscale_node_id"]:
mapping["ultimate_upscale_node_id"] = {"node_id": nid}
if ct == "easy hiresFix" and not mapping["hires_fix_node_id"]:
mapping["hires_fix_node_id"] = {"node_id": nid, "output_index": 1}
if ct == "SaveImage":
current_regular = mapping.get("output_regular", {}).get("node_id") if mapping.get("output_regular") else None
better_regular = (
not current_regular
or self._media_output_rank(workflow, nid, "image")
> self._media_output_rank(workflow, current_regular, "image")
)
if "upscale" in title and not mapping["output_upscaled"]:
mapping["output_upscaled"] = {"node_id": nid}
elif better_regular:
mapping["output_regular"] = {"node_id": nid}
elif not mapping["output_upscaled"]:
mapping["output_upscaled"] = {"node_id": nid}
if ct == "PreviewImage":
current_preview = mapping.get("output_preview", {}).get("node_id") if mapping.get("output_preview") else None
if (
not current_preview
or self._media_output_rank(workflow, nid, "image")
> self._media_output_rank(workflow, current_preview, "image")
):
mapping["output_preview"] = {"node_id": nid}
if self._is_video_output_node(ct, title, inputs):
current_video = mapping.get("output_video")
if (
not current_video
or self._media_output_rank(workflow, nid, "video")
> self._media_output_rank(workflow, current_video.get("node_id"), "video")
):
mapping["output_video"] = {"node_id": nid}
if self._is_output_like_node(ct, title, inputs):
if ("video" in ct.lower() or "video" in title):
current_video = mapping.get("output_video")
if (
not current_video
or self._media_output_rank(workflow, nid, "video")
> self._media_output_rank(workflow, current_video.get("node_id"), "video")
):
mapping["output_video"] = {"node_id": nid}
elif "upscale" in title and not mapping["output_upscaled"]:
mapping["output_upscaled"] = {"node_id": nid}
elif (
not mapping["output_regular"]
or self._media_output_rank(workflow, nid, "image")
> self._media_output_rank(workflow, mapping["output_regular"].get("node_id"), "image")
):
mapping["output_regular"] = {"node_id": nid}
elif not mapping["output_upscaled"]:
mapping["output_upscaled"] = {"node_id": nid}
if "seed (rgthree)" in ct.lower() and "seed" in inputs and not mapping["seed"]:
mapping["seed"] = self._resolve_workflow_input_mapping(workflow, nid, "seed", ("seed", "noise_seed", "value"))
if ct == "RandomNoise" and "noise_seed" in inputs and not mapping["seed"]:
mapping["seed"] = self._resolve_workflow_input_mapping(workflow, nid, "noise_seed", ("seed", "noise_seed", "value"))
if "seed" in inputs and not mapping["seed"] and ("seed" in title or "seed" in ct.lower()):
mapping["seed"] = self._resolve_workflow_input_mapping(workflow, nid, "seed", ("seed", "noise_seed", "value"))
if "value" in inputs and not mapping["seed"] and "seed" in title:
mapping["seed"] = self._resolve_workflow_input_mapping(workflow, nid, "value", ("seed", "noise_seed", "value"))
if ct == "easy int" and "width" in title and "value" in inputs and not mapping["width"]:
mapping["width"] = {"node_id": nid, "field": "value"}
if ct == "easy int" and "height" in title and "value" in inputs and not mapping["height"]:
mapping["height"] = {"node_id": nid, "field": "value"}
if ct == "CR Aspect Ratio" and "width" in inputs and not mapping["width"]:
mapping["width"] = {"node_id": nid, "field": "width"}
if ct == "CR Aspect Ratio" and "height" in inputs and not mapping["height"]:
mapping["height"] = {"node_id": nid, "field": "height"}
if ct == "CR Aspect Ratio Social Media" and "width" in inputs and not mapping["width"]:
mapping["width"] = {"node_id": nid, "field": "width"}
if ct == "CR Aspect Ratio Social Media" and "height" in inputs and not mapping["height"]:
mapping["height"] = {"node_id": nid, "field": "height"}
if ct == "SetNode":
set_name = self._set_get_node_name(node)
if set_name == "width" and "INT" in inputs and not mapping["width"]:
mapping["width"] = {"node_id": nid, "field": "INT"}
if set_name == "height" and "INT" in inputs and not mapping["height"]:
mapping["height"] = {"node_id": nid, "field": "INT"}
width_field = next(
(
field
for field in ("resize_type.width", "target_width", "width")
if field in inputs and not self._is_workflow_link(inputs.get(field))
),
None,
)
height_field = next(
(
field
for field in ("resize_type.height", "target_height", "height")
if field in inputs and not self._is_workflow_link(inputs.get(field))
),
None,
)
if width_field and height_field and (
"resize" in ct.lower()
or "resize" in title
or "scale" in ct.lower()
or "dimension" in title
):
if not mapping["width"]:
mapping["width"] = {"node_id": nid, "field": width_field}
if not mapping["height"]:
mapping["height"] = {"node_id": nid, "field": height_field}
if ct == "EmptyLatentImage" and not mapping["width"]:
mapping["width"] = {"node_id": nid, "field": "width"}
if ct == "EmptyLatentImage" and not mapping["height"]:
mapping["height"] = {"node_id": nid, "field": "height"}
if ct == "EmptySD3LatentImage" and not mapping["width"]:
mapping["width"] = {"node_id": nid, "field": "width"}
if ct == "EmptySD3LatentImage" and not mapping["height"]:
mapping["height"] = {"node_id": nid, "field": "height"}
if ct == "EmptyFlux2LatentImage" and not mapping["width"]:
mapping["width"] = {"node_id": nid, "field": "width"}
if ct == "EmptyFlux2LatentImage" and not mapping["height"]:
mapping["height"] = {"node_id": nid, "field": "height"}
if self._is_size_like_node(ct, title, inputs):
if not mapping["width"]:
mapping["width"] = self._resolve_workflow_input_mapping(workflow, nid, "width", ("width", "value"))
if not mapping["height"]:
mapping["height"] = self._resolve_workflow_input_mapping(workflow, nid, "height", ("height", "value"))
if ct == "SDXLEmptyLatentSizePicker+":
if "width_override" in inputs and not mapping["width"]:
mapping["width"] = {"node_id": nid, "field": "width_override"}
if "height_override" in inputs and not mapping["height"]:
mapping["height"] = {"node_id": nid, "field": "height_override"}
if "resolution" in inputs:
match = re.search(r"(\d+)\s*x\s*(\d+)", str(inputs.get("resolution")))
if match:
if not mapping["width"]:
mapping["width"] = {"node_id": nid, "field": "resolution_width"}
if not mapping["height"]:
mapping["height"] = {"node_id": nid, "field": "resolution_height"}
if "resolution" in inputs and not mapping["resolution"] and (
"resolution" in ct.lower() or "resolution" in title
):
mapping["resolution"] = {"node_id": nid, "field": "resolution"}
if ct == "ImpactSwitch" and "latent" in title and not mapping["latent_switch"]:
mapping["latent_switch"] = {"node_id": nid, "field": "select"}
if self._is_input_image_node(ct, title, inputs) and not mapping["input_image"]:
image_field = next((field for field in ("image", "file", "path") if field in inputs), None)
if image_field:
mapping["input_image"] = {"node_id": nid, "field": image_field}
if self._is_input_image_node(ct, title, inputs):
image_field = next((field for field in ("image", "file", "path") if field in inputs), None)
item = {"node_id": nid, "field": image_field} if image_field else None
if item and item not in mapping["input_images"]:
mapping["input_images"].append(item)
if not mapping["input_image"] and ("wan" in ct.lower() or "image to video" in title):
image_field = next(
(
field
for field in ("start_image", "image", "images", "first_frame")
if field in inputs
),
None,
)
if image_field:
mapping["input_image"] = {
"node_id": nid,
"field": image_field,
"expects_link": True,
}
if mapping["input_image"] not in mapping["input_images"]:
mapping["input_images"].append(mapping["input_image"])
if (
("loadvideo" in ct.lower() or "load video" in title)
and not mapping["input_video"]
):
for video_field in ("video", "file", "video_path", "path"):
if video_field in inputs:
mapping["input_video"] = {"node_id": nid, "field": video_field}
break
if ct == "ImageScaleBy" and "scale_by" in inputs and not mapping["scale_by"]:
mapping["scale_by"] = {"node_id": nid, "field": "scale_by"}
if "frame" in title or "frames" in ct.lower() or "video" in ct.lower() or "wan" in ct.lower():
for frame_field in ("frames", "frame_count", "num_frames", "length", "video_length"):
if frame_field in inputs and not mapping["frames"]:
mapping["frames"] = self._resolve_workflow_input_mapping(
workflow,
nid,
frame_field,
("frames", "frame_count", "num_frames", "length", "video_length", "value"),
)
break
for fps_field in ("fps", "frame_rate", "framerate"):
if fps_field in inputs and not mapping["fps"]:
mapping["fps"] = self._resolve_workflow_input_mapping(
workflow,
nid,
fps_field,
("fps", "frame_rate", "framerate", "value"),
)
break
megapixels_field = next(
(field for field in ("megapixels", "resize_type.megapixels") if field in inputs),
None,
)
if megapixels_field and not mapping["megapixels"] and (
"scale" in ct.lower()
or "size" in ct.lower()
or "pixel" in ct.lower()
or "resolution" in title
or "resize" in ct.lower()
or "resize" in title
):
mapping["megapixels"] = self._resolve_workflow_input_mapping(
workflow,
nid,
megapixels_field,
("megapixels", "resize_type.megapixels", "value"),
)
if megapixels_field and (
"scale" in ct.lower()
or "size" in ct.lower()
or "pixel" in ct.lower()
or "resolution" in title
or "resize" in ct.lower()
or "resize" in title
):
resolved = self._resolve_workflow_input_mapping(
workflow,
nid,
megapixels_field,
("megapixels", "resize_type.megapixels", "value"),
)
if resolved not in mapping["megapixels_nodes"]:
mapping["megapixels_nodes"].append(resolved)
if ct == "KSampler" or ct == "KSamplerAdvanced":
if "steps" in inputs and not mapping["steps"]:
mapping["steps"] = self._resolve_workflow_input_mapping(workflow, nid, "steps", ("steps", "value"))
if "cfg" in inputs and not mapping["cfg"]:
mapping["cfg"] = self._resolve_workflow_input_mapping(workflow, nid, "cfg", ("cfg", "value"))
if "sampler_name" in inputs and not mapping["sampler_name"]:
mapping["sampler_name"] = self._resolve_workflow_input_mapping(workflow, nid, "sampler_name", ("sampler_name", "value"))
if "scheduler" in inputs and not mapping["scheduler"]:
mapping["scheduler"] = self._resolve_workflow_input_mapping(workflow, nid, "scheduler", ("scheduler", "value"))
if "denoise" in inputs and not mapping["denoise"]:
mapping["denoise"] = self._resolve_workflow_input_mapping(workflow, nid, "denoise", ("denoise", "value"))
if "seed" in inputs and not mapping["seed"]:
mapping["seed"] = self._resolve_workflow_input_mapping(workflow, nid, "seed", ("seed", "noise_seed", "value"))
if "noise_seed" in inputs and not mapping["seed"]:
mapping["seed"] = self._resolve_workflow_input_mapping(workflow, nid, "noise_seed", ("seed", "noise_seed", "value"))
elif ct == "BasicScheduler":
if "steps" in inputs and not mapping["steps"]:
mapping["steps"] = self._resolve_workflow_input_mapping(workflow, nid, "steps", ("steps", "value"))
if "scheduler" in inputs and not mapping["scheduler"]:
mapping["scheduler"] = self._resolve_workflow_input_mapping(workflow, nid, "scheduler", ("scheduler", "value"))
if "denoise" in inputs and not mapping["denoise"]:
mapping["denoise"] = self._resolve_workflow_input_mapping(workflow, nid, "denoise", ("denoise", "value"))
elif self._is_scheduler_like_node(ct, inputs):
if "steps" in inputs and not mapping["steps"]:
mapping["steps"] = self._resolve_workflow_input_mapping(workflow, nid, "steps", ("steps", "value"))
if "scheduler" in inputs and not mapping["scheduler"]:
mapping["scheduler"] = self._resolve_workflow_input_mapping(workflow, nid, "scheduler", ("scheduler", "value"))
if "denoise" in inputs and not mapping["denoise"]:
mapping["denoise"] = self._resolve_workflow_input_mapping(workflow, nid, "denoise", ("denoise", "value"))
elif ct == "KSamplerSelect":
if "sampler_name" in inputs and not mapping["sampler_name"]:
mapping["sampler_name"] = self._resolve_workflow_input_mapping(workflow, nid, "sampler_name", ("sampler_name", "value"))
elif ct == "CFGGuider":
if "cfg" in inputs and not mapping["cfg"]:
mapping["cfg"] = self._resolve_workflow_input_mapping(workflow, nid, "cfg", ("cfg", "value"))
elif ct == "DualCFGGuider":
if "cfg_conds" in inputs and not mapping["cfg"]:
mapping["cfg"] = self._resolve_workflow_input_mapping(workflow, nid, "cfg_conds", ("cfg_conds", "cfg", "value"))
elif "cfg" in inputs and not mapping["cfg"]:
mapping["cfg"] = self._resolve_workflow_input_mapping(workflow, nid, "cfg", ("cfg", "value"))
elif ct == "FluxGuidance":
if "guidance" in inputs and not mapping["flux_guidance"]:
mapping["flux_guidance"] = self._resolve_workflow_input_mapping(workflow, nid, "guidance", ("guidance", "value"))
elif ct == "CLIPTextEncodeFlux":
if "guidance" in inputs and not mapping["flux_guidance"]:
mapping["flux_guidance"] = self._resolve_workflow_input_mapping(workflow, nid, "guidance", ("guidance", "value"))
elif ct == "easy int" and "steps" in title and "value" in inputs and not mapping["steps"]:
mapping["steps"] = self._resolve_workflow_input_mapping(workflow, nid, "value", ("steps", "value"))
elif ct == "PrimitiveFloat" and "cfg" in title and "value" in inputs and not mapping["cfg"]:
mapping["cfg"] = self._resolve_workflow_input_mapping(workflow, nid, "value", ("cfg", "value"))
elif ct == "PrimitiveFloat" and "denoise" in title and "value" in inputs and not mapping["denoise"]:
mapping["denoise"] = self._resolve_workflow_input_mapping(workflow, nid, "value", ("denoise", "value"))
elif "denoise" in inputs and not mapping["denoise"]:
mapping["denoise"] = self._resolve_workflow_input_mapping(workflow, nid, "denoise", ("denoise", "value"))
if self._is_sampler_like_node(ct, inputs):
if "steps" in inputs and not mapping["steps"]:
mapping["steps"] = self._resolve_workflow_input_mapping(workflow, nid, "steps", ("steps", "value"))
if "cfg" in inputs and not mapping["cfg"]:
mapping["cfg"] = self._resolve_workflow_input_mapping(workflow, nid, "cfg", ("cfg", "value"))
if "sampler_name" in inputs and not mapping["sampler_name"]:
mapping["sampler_name"] = self._resolve_workflow_input_mapping(workflow, nid, "sampler_name", ("sampler_name", "value"))
if "scheduler" in inputs and not mapping["scheduler"]:
mapping["scheduler"] = self._resolve_workflow_input_mapping(workflow, nid, "scheduler", ("scheduler", "value"))
if "denoise" in inputs and not mapping["denoise"]:
mapping["denoise"] = self._resolve_workflow_input_mapping(workflow, nid, "denoise", ("denoise", "value"))
if "seed" in inputs and not mapping["seed"]:
mapping["seed"] = self._resolve_workflow_input_mapping(workflow, nid, "seed", ("seed", "noise_seed", "value"))
if "noise_seed" in inputs and not mapping["seed"]:
mapping["seed"] = self._resolve_workflow_input_mapping(workflow, nid, "noise_seed", ("seed", "noise_seed", "value"))
if not mapping.get("output") and mapping.get("output_regular"):
mapping["output"] = mapping["output_regular"]
elif not mapping.get("output") and mapping.get("output_upscaled"):
mapping["output"] = mapping["output_upscaled"]
elif not mapping.get("output") and mapping.get("output_video"):
mapping["output"] = mapping["output_video"]
if mapping.get("output_video"):
if mapping.get("output_regular") or mapping.get("output_upscaled"):
mapping["output_kind"] = "mixed"
else:
mapping["output_kind"] = "video"
image_input_nodes = {
str(nid)
for nid, node in workflow.items()
if isinstance(node, dict)
and self._is_input_image_node(
node.get("class_type", ""),
node.get("_meta", {}).get("title", ""),
node.get("inputs", {}),
)
}
if mapping.get("input_video"):
mapping["input_kind"] = "video"
elif len(image_input_nodes) > 1:
mapping["input_kind"] = "image_pair"
elif mapping.get("input_image") or mapping.get("latent_switch"):
mapping["input_kind"] = "image"
return mapping
def _guess_node_pack(self, class_type: str):
lowered = class_type.lower()
exact = {
"mathexpression|pysssss": "ComfyUI-Custom-Scripts",
"playsound|pysssss": "ComfyUI-Custom-Scripts",
"showtext|pysssss": "ComfyUI-Custom-Scripts",
"ultimatesdupscale": "ComfyUI_UltimateSDUpscale",
"facedetailer": "ComfyUI-Impact-Pack",
"detailerforeach": "ComfyUI-Impact-Pack",
"impactsimpledetectorsegs": "ComfyUI-Impact-Pack",
"samloader": "ComfyUI-Impact-Pack",
"ultralyticsdetectorprovider": "ComfyUI-Impact-Pack",
"anything everywhere": "rgthree-comfy",
"any switch (rgthree)": "rgthree-comfy",
"seed (rgthree)": "rgthree-comfy",
"power lora loader (rgthree)": "rgthree-comfy",
"imagecasharpening+": "ComfyUI-Image-Filters",
"imagedesaturate+": "ComfyUI-Image-Filters",
"colormatch": "ComfyUI-Image-Filters",
"depthanythingv2preprocessor": "comfyui_controlnet_aux",
"cr vignette filter": "ComfyUI_Comfyroll_CustomNodes",
"cr aspect ratio social media": "ComfyUI_Comfyroll_CustomNodes",
"vhs_videocombine": "ComfyUI-VideoHelperSuite",
"wanvideomodelloader": "ComfyUI-WanVideoWrapper",
"wanvideosampler": "ComfyUI-WanVideoWrapper",
"wanvideoscheduler": "ComfyUI-WanVideoWrapper",
"wanvideovaeloader": "ComfyUI-WanVideoWrapper",
"wanimagetovideo": "ComfyUI-WanVideoWrapper",
}
if lowered in exact:
return exact[lowered]
guesses = (
("pysssss", "ComfyUI-Custom-Scripts"),
("ultimate", "ComfyUI_UltimateSDUpscale"),
("impact", "ComfyUI-Impact-Pack"),
("rgthree", "rgthree-comfy"),
("easy", "ComfyUI-Easy-Use"),
("easyuse", "ComfyUI-Easy-Use"),
("kj", "ComfyUI-KJNodes"),
("cr_", "ComfyUI_Comfyroll_CustomNodes"),
("was", "WAS Node Suite"),
("cr ", "ComfyUI_Comfyroll_CustomNodes"),
("comfyroll", "ComfyUI_Comfyroll_CustomNodes"),
("controlnetaux", "comfyui_controlnet_aux"),
("preprocessor", "comfyui_controlnet_aux"),
("qwen", "ComfyUI-AILab"),
("ailab", "ComfyUI-AILab"),
("efficiency", "efficiency-nodes-comfyui"),
("inspire", "ComfyUI-Inspire-Pack"),
("vhs_", "ComfyUI-VideoHelperSuite"),
("videohelper", "ComfyUI-VideoHelperSuite"),
("wanvideo", "ComfyUI-WanVideoWrapper"),
("wan", "ComfyUI-WanVideoWrapper"),
("mmaudio", "ComfyUI-MMAudio"),
("rife", "ComfyUI-Frame-Interpolation"),
("florence2", "ComfyUI-Florence2"),
)
for marker, pack in guesses:
if marker in lowered:
return pack
return None
_UI_WIDGET_INPUTS = {
"CheckpointLoaderSimple": ("ckpt_name",),
"UNETLoader": ("unet_name", "weight_dtype"),
"UnetLoaderGGUF": ("unet_name",),
"LoraLoaderModelOnly": ("lora_name", "strength_model"),
"DualCLIPLoader": ("clip_name1", "clip_name2", "type"),
"CLIPLoader": ("clip_name", "type", "device"),
"VAELoader": ("vae_name",),
"CLIPTextEncode": ("text",),
"CLIPTextEncodeFlux": ("clip_l", "t5xxl", "guidance"),
"PrimitiveStringMultiline": ("value",),
"ttN text": ("text",),
"CR Text": ("text",),
"Text Find and Replace": ("text", "find", "replace"),
"easy promptReplace": ("text", "replace", "replace_with"),
"Prompts Everywhere": ("text",),
"CLIPSetLastLayer": ("stop_at_clip_layer",),
"KSamplerSelect": ("sampler_name",),
"BasicScheduler": ("scheduler", "steps", "denoise"),
"RandomNoise": ("noise_seed", None),
"KSampler": ("seed", None, "steps", "cfg", "sampler_name", "scheduler", "denoise"),
"KSamplerAdvanced": ("add_noise", "noise_seed", None, "steps", "cfg", "sampler_name", "scheduler", "start_at_step", "end_at_step", "return_with_leftover_noise"),
"SaveImage": ("filename_prefix",),
"LoadImage": ("image", "upload"),
"EmptyLatentImage": ("width", "height", "batch_size"),
"EmptySD3LatentImage": ("width", "height", "batch_size"),
"EmptyFlux2LatentImage": ("width", "height", "batch_size"),
"SDXL Resolutions (JPS)": ("resolution",),
"SDXLEmptyLatentSizePicker+": ("resolution", "batch_size", "width_override", "height_override"),
"CR Aspect Ratio Social Media": ("width", "height", "aspect_ratio", "swap_dimensions", "upscale_factor", "prescale_factor", "batch_size"),
"ImageScaleBy": ("upscale_method", "scale_by"),
"LatentUpscaleBy": ("upscale_method", "scale_by"),
"ImageResizeKJv2": ("width", "height", "upscale_method", "keep_proportion", "pad_color", "crop_position", "divisible_by", "device"),
"ImageResize+": ("width", "height", "interpolation", "method"),
"ImageScale": ("upscale_method", "width", "height", "crop"),
"SetImageSize": ("width", "height"),
"HintImageEnchance": ("image_gen_width", "image_gen_height", "resize_mode"),
"UpscaleModelLoader": ("model_name",),
"ControlNetLoader": ("control_net_name",),
"ControlNetApplyAdvanced": ("strength", "start_percent", "end_percent"),
"SAMLoader": ("model_name", "device_mode"),
"UltralyticsDetectorProvider": ("model_name",),
"easy positive": ("positive",),
"easy negative": ("negative",),
"easy mathInt": ("a", "b", "operation"),
"easy mathFloat": ("a", "b", "operation"),
"MathExpression|pysssss": ("expression",),
"ComfyMathExpression": ("expression",),
"PrimitiveBoolean": ("value",),
"PrimitiveFloat": ("value",),
"PrimitiveInt": ("value",),
"Int": ("value",),
"Float": ("value",),
"ttN int": ("value",),
"mxSlider": ("value", "min", "max", "step"),
"mxSlider2D": ("x", "y", "min", "max", "step"),
"Seed (rgthree)": ("seed",),
"VHS_VideoCombine": ("frame_rate", "loop_count", "filename_prefix", "format", "pingpong", "save_output"),
"VHS_LoadVideo": ("video", "force_rate", "force_size", "custom_width", "custom_height", "frame_load_cap", "skip_first_frames", "select_every_nth"),
"VHS_LoadVideoFFmpeg": ("video", "force_rate", "force_size", "custom_width", "custom_height", "frame_load_cap", "skip_first_frames", "select_every_nth"),
"VHS_LoadAudio": ("audio",),
"WanImageToVideo": ("width", "height", "length", "batch_size"),
"WanFirstLastFrameToVideo": ("width", "height", "length", "batch_size"),
"WanResolutions": ("width", "height", "resolution"),
"WanMoeKSampler": ("seed", "steps", "cfg", "shift", "denoise", "sampler_name", "scheduler"),
"WanVideoSampler": ("seed", "steps", "cfg", "shift", "denoise", "scheduler"),
"WanVideoScheduler": ("steps", "cfg", "shift", "scheduler", "denoise"),
"WanVideoModelLoader": ("model_name", "base_precision", "quantization"),
"WanVideoVAELoader": ("vae_name",),
"WanVideoTextEncodeCached": ("text", "negative", "force_offload"),
"WanVideoEncode": ("width", "height", "num_frames"),
"WanVideoImageToVideoEncode": ("width", "height", "num_frames"),
"WanVideoEncodeLatentBatch": ("width", "height", "num_frames"),
"ImageDesaturate+": ("factor", "method"),
"PlaySound|pysssss": ("mode", "volume", "file"),
"ColorMatch": ("method",),
"ImageCASharpening+": ("amount",),
"CR Vignette Filter": ("vignette_shape", "feather_amount", "x_offset", "y_offset", "zoom", "reverse"),
"ImageCompositeMasked": ("x", "y", "resize_source"),
"ImpactSimpleDetectorSEGS": (
"bbox_threshold", "bbox_dilation", "crop_factor",
"drop_size", "sub_threshold", "sub_dilation",
"sub_bbox_expansion", "sam_mask_hint_threshold",
),
"DetailerForEach": (
"guide_size", "guide_size_for", "max_size", "seed", None,
"steps", "cfg", "sampler_name", "scheduler", "denoise",
"feather", "noise_mask", "force_inpaint", "wildcard",
"cycle", "inpaint_model", "noise_mask_feather", None, None,
),
"UltimateSDUpscale": (
"upscale_by", "seed", None, "steps", "cfg",
"sampler_name", "scheduler", "denoise", "mode_type",
"tile_width", "tile_height", "mask_blur", "tile_padding",
"seam_fix_mode", "seam_fix_denoise", "seam_fix_width",
"seam_fix_mask_blur", "seam_fix_padding", "force_uniform_tiles",
"tiled_decode", "batch_size",
),
"PatchModelAddDownscale": ("block_number", "downscale_factor", "start_percent", "end_percent", "downscale_after_skip", "downscale_method", "upscale_method"),
"SelfAttentionGuidance": ("scale", "blur_sigma"),
"FaceDetailer": (
"guide_size", "guide_size_for", "max_size", "seed", None,
"steps", "cfg", "sampler_name", "scheduler", "denoise",
"feather", "noise_mask", "force_inpaint", "bbox_threshold",
"bbox_dilation", "bbox_crop_factor", "sam_detection_hint",
"sam_dilation", "sam_threshold", "sam_bbox_expansion",
"sam_mask_hint_threshold", "sam_mask_hint_use_negative",
"drop_size", "wildcard", "cycle", "inpaint_model",
"noise_mask_feather", None, None,
),
}
@staticmethod
def _is_ui_workflow(workflow):
return (
isinstance(workflow, dict)
and isinstance(workflow.get("nodes"), list)
and isinstance(workflow.get("links"), list)
)
@staticmethod
def _is_api_workflow(workflow):
return (
isinstance(workflow, dict)
and bool(workflow)
and all(isinstance(node_id, (str, int)) for node_id in workflow)
and any(
isinstance(node, dict)
and isinstance(node.get("inputs", {}), dict)
and isinstance(node.get("class_type"), str)
and node.get("class_type")
for node in workflow.values()
)
)
@staticmethod
def _is_uuid_class_type(class_type):
return bool(re.fullmatch(
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
str(class_type or "").strip(),
))
def _is_ui_virtual_proxy_node(self, node):
if not isinstance(node, dict):
return False
class_type = node.get("type")
properties = node.get("properties", {})
outputs = node.get("outputs", [])
return (
self._is_uuid_class_type(class_type)
and isinstance(properties, dict)
and "proxyWidgets" in properties
and not outputs
)
def _sanitize_api_workflow(self, workflow, object_info=None):
if not isinstance(workflow, dict):
return workflow
sanitized = {}
for node_id, node in workflow.items():
if not isinstance(node, dict):
continue
sanitized[str(node_id)] = node
return sanitized
def _cached_all_object_info(self):
base = self._base_url()
if not base:
return None
return self._comfy_cache.get(f"object_info:{base}:all")
def _extract_workflow_payload(self, workflow):
seen = set()
current = workflow
for _ in range(8):
marker = id(current)
if marker in seen:
break
seen.add(marker)
if isinstance(current, str):
stripped = current.strip()
if not stripped:
break
try:
current = json.loads(stripped)
continue
except Exception:
break
if self._is_ui_workflow(current) or self._is_api_workflow(current):
return current
if not isinstance(current, dict):
break
extra_pnginfo = current.get("extra_pnginfo")
if isinstance(extra_pnginfo, dict):
for key in ("workflow", "prompt"):
nested = extra_pnginfo.get(key)
if nested:
current = nested
break
else:
nested = None
if nested:
continue
for key in ("prompt", "workflow", "workflow_api", "api_workflow"):
nested = current.get(key)
if nested:
current = nested
break
else:
break
return current
@staticmethod
def _ui_node_title(node, class_type):
return (
str(node.get("title") or "").strip()
or str(node.get("properties", {}).get("Node name for S&R") or "").strip()
or str(class_type or "").strip()
)
def _ui_widget_fields_from_object_info(self, class_type, node_inputs, object_info):
if not isinstance(object_info, dict):
return []
node_info = object_info.get(class_type, {})
fields = []
for section in ("required", "optional"):
items = node_info.get("input", {}).get(section, {})
if not isinstance(items, dict):
continue
for name in items:
if name not in node_inputs:
fields.append(name)
return fields
def _ui_widget_fields(self, node, object_info=None):
class_type = str(node.get("type") or "")
if class_type in self._UI_WIDGET_INPUTS:
return self._UI_WIDGET_INPUTS[class_type]
node_inputs = {
item.get("name")
for item in node.get("inputs", [])
if isinstance(item, dict) and item.get("name")
}
return tuple(self._ui_widget_fields_from_object_info(class_type, node_inputs, object_info))
@staticmethod
def _ui_primitive_value(node):
widgets = node.get("widgets_values")
if isinstance(widgets, list) and widgets:
return widgets[0]
return None
@staticmethod
def _ui_set_get_name(node):
widgets = node.get("widgets_values")
if isinstance(widgets, list) and widgets and widgets[0] is not None:
return str(widgets[0]).strip().lower()
title = str(node.get("title") or "").strip().lower()
if title.startswith("set_") or title.startswith("get_"):
return title[4:].strip()
return title
@staticmethod
def _ui_power_lora_inputs(node):
widgets = node.get("widgets_values")
if not isinstance(widgets, list):
return {}
result = {}
index = 1
for value in widgets:
if not isinstance(value, dict) or "lora" not in value:
continue
result[f"lora_{index}"] = {
"on": bool(value.get("on", True)),
"lora": value.get("lora"),
"strength": value.get("strength", 1),
}
if "strengthTwo" in value:
result[f"lora_{index}"]["strengthTwo"] = value.get("strengthTwo")
index += 1
return result
def _convert_ui_workflow_to_api(self, workflow, object_info=None):
nodes = workflow.get("nodes", [])
links = {}
nodes_by_id = {}
primitive_values = {}
for node in nodes:
if not isinstance(node, dict) or node.get("id") is None:
continue
node_id = str(node.get("id"))
nodes_by_id[node_id] = node
if node.get("type") == "PrimitiveNode":
primitive_values[node_id] = self._ui_primitive_value(node)
for link in workflow.get("links", []):
try:
if isinstance(link, list) and len(link) >= 5:
links[link[0]] = [str(link[1]), int(link[2])]
elif isinstance(link, dict):
link_id = link.get("id", link.get("link_id"))
source_id = link.get("origin_id", link.get("source_id", link.get("from_node_id")))
source_slot = link.get("origin_slot", link.get("source_slot", link.get("from_slot", 0)))
if link_id is not None and source_id is not None:
links[link_id] = [str(source_id), int(source_slot or 0)]
except (TypeError, ValueError):
continue
def input_source_value(node):
for item in node.get("inputs", []):
if not isinstance(item, dict):
continue
link_id = item.get("link")
if link_id is None and isinstance(item.get("links"), list) and item["links"]:
link_id = item["links"][0]
if link_id is None or link_id not in links:
continue
source_id, source_output = links[link_id]
if source_id in primitive_values:
return primitive_values[source_id]
return [source_id, source_output]
return None
set_sources = {}
aliases = {}
for node in nodes:
if not isinstance(node, dict) or node.get("id") is None:
continue
node_id = str(node.get("id"))
class_type = node.get("type")
if class_type == "SetNode":
source_value = input_source_value(node)
if source_value is None:
continue
name = self._ui_set_get_name(node)
if name:
set_sources[name] = source_value
aliases[node_id] = source_value
elif class_type == "Reroute":
source_value = input_source_value(node)
if source_value is not None:
aliases[node_id] = source_value
for node in nodes:
if not isinstance(node, dict) or node.get("id") is None:
continue
if node.get("type") != "GetNode":
continue
source_value = set_sources.get(self._ui_set_get_name(node))
if source_value is not None:
aliases[str(node.get("id"))] = source_value
def resolve_source_value(source_id, source_output):
source_id = str(source_id)
source_output = int(source_output)
seen = set()
while source_id in aliases and source_id not in seen:
seen.add(source_id)
alias = aliases[source_id]
if not self._is_workflow_link(alias):
return alias
source_id = str(alias[0])
try:
source_output = int(alias[1])
except (TypeError, ValueError):
source_output = 0
if source_id in primitive_values:
return primitive_values[source_id]
return [source_id, source_output]
converted = {}
for node in nodes:
if not isinstance(node, dict) or node.get("id") is None:
continue
if self._is_ui_virtual_proxy_node(node):
continue
node_id = str(node.get("id"))
class_type = node.get("type")
if not class_type or class_type in {"Note", "PrimitiveNode", "Reroute", "SetNode", "GetNode"}:
continue
inputs = {}
for item in node.get("inputs", []):
if not isinstance(item, dict) or not item.get("name"):
continue
link_id = item.get("link")
if link_id is None and isinstance(item.get("links"), list) and item["links"]:
link_id = item["links"][0]
if link_id is None or link_id not in links:
continue
source_id, source_output = links[link_id]
inputs[item["name"]] = resolve_source_value(source_id, source_output)
widgets = node.get("widgets_values")
if not isinstance(widgets, list):
widgets = []
for field, value in zip(self._ui_widget_fields(node, object_info), widgets):
if field is None or field in inputs:
continue
inputs[field] = value
if class_type == "Power Lora Loader (rgthree)":
inputs.update(self._ui_power_lora_inputs(node))
meta = {"title": self._ui_node_title(node, class_type)}
if widgets:
meta["ui_widgets"] = widgets
properties = node.get("properties")
if isinstance(properties, dict) and "value" in properties:
meta["ui_value"] = properties.get("value")
converted[node_id] = {
"inputs": inputs,
"class_type": class_type,
"_meta": meta,
}
return converted
def _normalize_workflow_format(self, workflow, object_info=None):
workflow = self._extract_workflow_payload(workflow)
if object_info is None:
object_info = self._cached_all_object_info()
if self._is_ui_workflow(workflow):
return self._sanitize_api_workflow(
self._convert_ui_workflow_to_api(workflow, object_info),
object_info,
)
if isinstance(workflow, dict):
nodes = {
str(node_id): node
for node_id, node in workflow.items()
if isinstance(node, dict)
and isinstance(node.get("inputs", {}), dict)
and isinstance(node.get("class_type"), str)
and node.get("class_type")
}
if nodes and len(nodes) != len(workflow):
return self._sanitize_api_workflow(nodes, object_info)
if nodes:
return self._sanitize_api_workflow(nodes, object_info)
return workflow
async def _validate_workflow(self, workflow):
result = {
"ok": False,
"critical": [],
"warnings": [],
"missing_nodes": [],
"found": {},
"mapping": {},
}
object_info = await self._get_all_object_info()
workflow = self._normalize_workflow_format(workflow, object_info)
if not isinstance(workflow, dict) or not workflow:
result["critical"].append(self.strings("wf_validation_empty"))
return result
invalid_nodes = [
node_id
for node_id, node in workflow.items()
if not isinstance(node, dict) or not isinstance(node.get("inputs", {}), dict)
]
if invalid_nodes:
result["critical"].append(self.strings("wf_validation_node_invalid"))
return result
class_types = sorted(
{
node.get("class_type")
for node in workflow.values()
if isinstance(node.get("class_type"), str) and node.get("class_type")
}
)
if isinstance(object_info, dict):
installed_nodes = set(object_info.keys())
global_input_types = self._get_global_input_types(workflow, object_info)
result["missing_nodes"] = [
class_type
for class_type in class_types
if class_type not in installed_nodes
and not self._is_uuid_class_type(class_type)
]
if result["missing_nodes"]:
result["critical"].extend(result["missing_nodes"])
for node_id, node in workflow.items():
class_type = node.get("class_type")
node_info = object_info.get(class_type, {})
required_inputs = (
node_info
.get("input", {})
.get("required", {})
)
if not isinstance(required_inputs, dict):
continue
inputs = node.get("inputs", {})
missing_inputs = [
name
for name, input_spec in required_inputs.items()
if name not in inputs
and not self._is_ignored_missing_required_input(
class_type,
name,
)
and not (
str(class_type) == "BatchImagesNode"
and name == "images"
and any(str(key).startswith("images.") for key in inputs)
)
and not self._global_input_covers_required(
name,
input_spec,
global_input_types,
)
]
if missing_inputs:
result["critical"].append(
self.strings("wf_validation_missing_inputs").format(
node_id,
class_type,
", ".join(str(name) for name in missing_inputs[:10]),
)
)
else:
result["warnings"].append(self.strings("wf_validation_object_info_fail"))
mapping = self._parse_workflow(workflow)
result["mapping"] = {key: value for key, value in mapping.items() if value}
checks = {
"positive": bool(mapping.get("positive")),
"negative": bool(mapping.get("negative")),
"model": bool(mapping.get("model")),
"seed": bool(mapping.get("seed")),
"steps": bool(mapping.get("steps")),
"cfg": bool(mapping.get("cfg") or mapping.get("flux_guidance")),
"output": bool(mapping.get("output") or mapping.get("output_regular") or mapping.get("output_upscaled") or mapping.get("output_video")),
"size": bool((mapping.get("width") and mapping.get("height")) or mapping.get("megapixels") or mapping.get("resolution")),
"denoise": bool(mapping.get("denoise")),
"img2img": bool(mapping.get("input_image") or mapping.get("input_video") or mapping.get("latent_switch")),
"frames": bool(mapping.get("frames")),
"fps": bool(mapping.get("fps")),
"output_kind": mapping.get("output_kind") or "image",
"input_kind": mapping.get("input_kind") or "none",
}
image_only = self._is_image_only_mapping(mapping)
checks["image_only"] = image_only
result["found"] = checks
if (
not image_only
and not checks["positive"]
and not (
mapping.get("output_kind") in ("video", "mixed")
and mapping.get("input_kind") in ("image", "image_pair", "video")
)
):
result["critical"].append(self.strings("wf_validation_no_positive"))
if not image_only and not checks["model"] and mapping.get("output_kind") not in ("video", "mixed"):
result["critical"].append(self.strings("wf_validation_no_model"))
if not checks["output"]:
result["critical"].append(self.strings("wf_validation_no_output"))
warning_keys = (
()
if image_only
else ("negative", "seed", "steps", "cfg", "size", "denoise", "img2img")
)
label_map = {
"negative": "wf_check_negative",
"model": "wf_check_model",
"seed": "wf_check_seed",
"steps": "wf_check_steps",
"cfg": "wf_check_cfg",
"size": "wf_check_size",
"denoise": "wf_check_denoise",
"img2img": "wf_check_img2img",
}
for key in warning_keys:
if not checks[key]:
result["warnings"].append(
self.strings("wf_validation_missing_optional").format(
self.strings(label_map[key])
)
)
result["ok"] = not result["critical"] and not result["missing_nodes"]
return result
@staticmethod
def _append_expandable_section(lines, title, body):
if not body:
return
lines.append(title)
lines.append(f"{chr(10).join(body)}
")
def _format_workflow_validation(self, name, validation, saved=False):
title = (
self.strings("checkwf_saved_title")
if saved
else self.strings("checkwf_title")
).format(utils.escape_html(name))
lines = [title, ""]
lines.append(
self.strings("wf_validation_ok")
if validation.get("ok")
else self.strings("wf_validation_failed")
)
label_map = {
"positive": "wf_check_positive",
"negative": "wf_check_negative",
"model": "wf_check_model",
"seed": "wf_check_seed",
"steps": "wf_check_steps",
"cfg": "wf_check_cfg",
"output": "wf_check_output",
"size": "wf_check_size",
"denoise": "wf_check_denoise",
"img2img": "wf_check_img2img",
"frames": "wf_check_frames",
"fps": "wf_check_fps",
}
found = validation.get("found", {})
if found:
found_lines = []
found_lines.append(
f"{self.strings('wf_icon_found')} {self.strings('wf_check_output_kind').format(utils.escape_html(str(found.get('output_kind') or 'image')))}"
)
found_lines.append(
f"{self.strings('wf_icon_found')} {self.strings('wf_check_input_kind').format(utils.escape_html(str(found.get('input_kind') or 'none')))}"
)
for key, label_key in label_map.items():
if found.get("image_only") and key not in ("output", "size", "img2img"):
continue
if found.get("image_only") and key == "size" and not found.get("size"):
continue
icon = self.strings("wf_icon_found") if found.get(key) else self.strings("wf_icon_missing")
found_lines.append(f"{icon} {self.strings(label_key)}")
self._append_expandable_section(
lines,
self.strings("wf_validation_found"),
found_lines,
)
critical = [
item
for item in validation.get("critical", [])
if item not in validation.get("missing_nodes", [])
]
critical_lines = []
if critical:
for item in critical:
critical_lines.append(f"{self.strings('wf_icon_error')} {utils.escape_html(item)}")
missing_nodes = validation.get("missing_nodes", [])
if missing_nodes:
critical_lines.append(self.strings("wf_validation_missing_nodes").strip())
for node in missing_nodes:
pack = self._guess_node_pack(node)
if pack:
critical_lines.append(
self.strings("wf_validation_node_pack").format(
f"{utils.escape_html(node)}",
utils.escape_html(pack),
)
)
else:
critical_lines.append(f"{utils.escape_html(node)}")
self._append_expandable_section(
lines,
self.strings("wf_validation_critical"),
critical_lines,
)
warnings = validation.get("warnings", [])
if warnings:
warning_lines = []
for item in warnings:
warning_lines.append(f"{self.strings('wf_icon_warning')} {utils.escape_html(item)}")
self._append_expandable_section(
lines,
self.strings("wf_validation_warnings"),
warning_lines,
)
return "\n".join(lines)
@staticmethod
def _truncate_validation_item(item, limit=180):
text = str(item)
if len(text) <= limit:
return text
return f"{text[: max(0, limit - 3)]}..."
def _format_workflow_validation_compact(self, name, validation, max_items=25, max_chars=180):
title = self.strings("checkwf_title").format(utils.escape_html(name))
lines = [
title,
"",
self.strings("wf_validation_failed"),
]
critical = [
item
for item in validation.get("critical", [])
if item not in validation.get("missing_nodes", [])
]
critical_lines = [
f"{self.strings('wf_icon_error')} {utils.escape_html(self._truncate_validation_item(item, max_chars))}"
for item in critical[:max_items]
]
if len(critical) > max_items:
critical_lines.append(f"... +{len(critical) - max_items}")
missing_nodes = validation.get("missing_nodes", [])
if missing_nodes:
critical_lines.append(self.strings("wf_validation_missing_nodes").strip())
for node in missing_nodes[:max_items]:
pack = self._guess_node_pack(node)
node = self._truncate_validation_item(node, max_chars)
if pack:
critical_lines.append(
self.strings("wf_validation_node_pack").format(
f"{utils.escape_html(node)}",
utils.escape_html(pack),
)
)
else:
critical_lines.append(f"{utils.escape_html(node)}")
if len(missing_nodes) > max_items:
critical_lines.append(f"... +{len(missing_nodes) - max_items}")
self._append_expandable_section(
lines,
self.strings("wf_validation_critical"),
critical_lines,
)
warnings = validation.get("warnings", [])
warning_lines = [
f"{self.strings('wf_icon_warning')} {utils.escape_html(self._truncate_validation_item(item, max_chars))}"
for item in warnings[:max_items]
]
if len(warnings) > max_items:
warning_lines.append(f"... +{len(warnings) - max_items}")
self._append_expandable_section(
lines,
self.strings("wf_validation_warnings"),
warning_lines,
)
return "\n".join(lines)
async def _load_workflow_json_from_reply(self, message):
reply = await message.get_reply_message()
if not reply or not reply.document:
return None, "no_reply"
file_obj = getattr(reply, "file", None)
file_size = getattr(file_obj, "size", None)
if file_size and file_size > 10 * 1024 * 1024:
return None, "too_large"
bio = io.BytesIO()
try:
await self.client.download_media(reply, bio)
bio.seek(0)
return self._normalize_workflow_format(json.load(bio)), None
except json.JSONDecodeError:
return None, "bad_json"
except Exception as e:
logger.exception(e)
return None, "bad_json"
finally:
bio.close()
@staticmethod
def _extract_cloud_workflow_share(text):
for match in re.finditer(r"https?://[^\s<>\"']+", str(text or ""), re.IGNORECASE):
raw_url = match.group(0).rstrip(".,;:!?)]}")
try:
parsed = urlparse(raw_url)
except ValueError:
continue
if (parsed.hostname or "").lower() != "cloud.comfy.org":
continue
share_id = (parse_qs(parsed.query).get("share") or [""])[0].strip()
if re.fullmatch(r"[A-Za-z0-9_-]{6,128}", share_id):
return share_id, raw_url
return None, None
async def _load_workflow_json_from_cloud_share(self, share_id):
api_key = await self._select_cloud_api_key()
url = (
f"{_COMFY_CLOUD_BASE_URL}/api/workflows/published/"
f"{quote(str(share_id), safe='')}"
)
async with self._session_get(
url,
headers=self._cloud_headers(api_key),
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
text = await resp.text()
if resp.status != 200:
self._raise_cloud_http_error(resp.status, text)
try:
payload = json.loads(text)
except json.JSONDecodeError as e:
raise ValueError("Cloud share returned invalid JSON") from e
if not isinstance(payload, dict):
raise ValueError("Cloud share returned an invalid workflow")
workflow = payload.get("workflow_json")
if isinstance(workflow, str):
try:
workflow = json.loads(workflow)
except json.JSONDecodeError as e:
raise ValueError("Cloud share workflow is invalid JSON") from e
if not isinstance(workflow, dict) or not workflow:
raise ValueError("Cloud share contains no workflow")
if len(json.dumps(workflow, ensure_ascii=False).encode("utf-8")) > 10 * 1024 * 1024:
raise ValueError("Cloud share workflow is too large")
await self._import_cloud_shared_workflow_assets(payload, share_id, api_key)
return self._normalize_workflow_format(workflow)
async def _import_cloud_shared_workflow_assets(self, payload, share_id, api_key):
assets = payload.get("assets") if isinstance(payload, dict) else None
asset_ids = [
str(asset.get("id") or "").strip()
for asset in assets or []
if isinstance(asset, dict)
and not asset.get("in_library")
and str(asset.get("id") or "").strip()
]
if not asset_ids:
return
try:
async with self._session_post(
f"{_COMFY_CLOUD_BASE_URL}/api/assets/import",
json={
"published_asset_ids": list(dict.fromkeys(asset_ids)),
"share_id": str(share_id),
},
headers=self._cloud_headers(api_key),
timeout=aiohttp.ClientTimeout(total=60),
) as resp:
text = await resp.text()
if resp.status != 200:
logger.debug(
"Cloud share assets import failed (HTTP %s): %s",
resp.status,
text[:500],
)
return
self._comfy_cache.clear()
except Exception as e:
logger.debug("Cloud share assets import failed: %s", e)
async def _get_workflow_reply_name(self, message, fallback="workflow"):
args = utils.get_args_raw(message).strip()
if args:
return args
reply = await message.get_reply_message()
if not reply:
return fallback
file_obj = getattr(reply, "file", None)
file_name = getattr(file_obj, "name", None)
if not file_name and getattr(reply, "document", None):
for attr in getattr(reply.document, "attributes", []):
file_name = getattr(attr, "file_name", None)
if file_name:
break
if not file_name:
return fallback
file_name = file_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1].strip()
if file_name.lower().endswith(".json"):
file_name = file_name[:-5]
return file_name or fallback
async def _get_cloud_imported_loras(self, execution_loras=None):
if not self._is_comfy_cloud():
return []
cache_key = "cloud_imported_loras"
if cache_key in self._comfy_cache:
return self._comfy_cache[cache_key]
execution_keys = {
self._model_match_key(name)
for name in execution_loras or []
if self._model_match_key(name)
}
assets = await self._clib_fetch_model_assets()
names = {}
for asset in assets:
name = self._cloud_asset_model_name(asset)
name_key = self._model_match_key(name)
if (
self._clib_asset_type(asset) != _CDOWN_TYPE_LORA
and name_key not in execution_keys
):
continue
tags = {
str(tag).strip().lower()
for tag in asset.get("tags") or []
if str(tag).strip()
}
expected_tags = set(self._cdown_type_info(_CDOWN_TYPE_LORA)["tags"])
if (
self._clib_asset_type(asset) != _CDOWN_TYPE_LORA
or not expected_tags.issubset(tags)
):
try:
await self._clib_update_asset_category(asset, _CDOWN_TYPE_LORA)
except Exception as e:
logger.debug("Cloud LoRA asset tag migration failed: %s", e)
if name:
names.setdefault(name.casefold(), name)
result = sorted(names.values(), key=str.casefold)
self._comfy_cache[cache_key] = result
return result
async def _get_available_lora_catalog(self):
available = []
model_only_available = []
try:
info = await self._get_object_info("LoraLoader", attempts=1, timeout=8)
available = self._parse_object_info_list(info, "LoraLoader", "lora_name")
except Exception as e:
if not self._is_comfy_cloud():
raise
logger.debug("Cloud LoRA node list failed: %s", e)
try:
info = await self._get_object_info(
"LoraLoaderModelOnly", attempts=1, timeout=8
)
model_only_available = self._parse_object_info_list(
info, "LoraLoaderModelOnly", "lora_name"
)
except Exception as e:
if not self._is_comfy_cloud():
logger.debug("LoRA ModelOnly node list failed: %s", e)
else:
logger.debug("Cloud LoRA ModelOnly node list failed: %s", e)
cloud_loras = []
if self._is_comfy_cloud():
try:
cloud_loras = (await self._get_cloud_available_models_by_field()).get(
"lora_name", []
)
except Exception as e:
logger.debug("Cloud LoRA model list failed: %s", e)
execution_loras = (
model_only_available
if self._is_comfy_cloud() and model_only_available
else [*model_only_available, *available, *cloud_loras]
)
imported = []
if self._is_comfy_cloud():
try:
imported = await self._get_cloud_imported_loras(execution_loras)
except Exception as e:
logger.debug("Cloud imported LoRA list failed: %s", e)
merged = {}
for name in execution_loras:
name = str(name or "").strip()
if name:
merged.setdefault(name.casefold(), name)
imported_keys = {
self._model_match_key(name)
for name in imported
if self._model_match_key(name)
}
imported_names = [
name
for name in merged.values()
if self._model_match_key(name) in imported_keys
]
return {
"all": sorted(merged.values(), key=str.casefold),
"imported": sorted({name.casefold() for name in imported_names}),
}
async def _get_available_loras(self):
return (await self._get_available_lora_catalog())["all"]
async def _ensure_lora_state_catalog(self, state):
if not isinstance(state, dict):
return [], []
all_loras = state.get("all_loras")
imported_loras = state.get("imported_loras")
if not isinstance(all_loras, list) or not isinstance(imported_loras, list):
catalog = await self._get_available_lora_catalog()
all_loras = list(catalog.get("all") or [])
imported_loras = list(catalog.get("imported") or [])
state["all_loras"] = all_loras
state["imported_loras"] = imported_loras
return all_loras, imported_loras
@staticmethod
def _lora_filter_mode(state):
mode = str((state or {}).get("filter_mode") or "").lower()
if mode in {"all", "favorites", "imported"}:
return mode
return "favorites" if (state or {}).get("favorites_only") else "all"
def _lora_filter_buttons(self, state_id, mode, callback, imported_loras):
choices = ["all", "favorites"]
if self._is_comfy_cloud() or imported_loras:
choices.append("imported")
labels = {
"all": "lora_filter_all",
"favorites": "lora_filter_favorites",
"imported": "lora_filter_imported",
}
buttons = [
{
"text": self.strings(labels[item]),
"callback": callback,
"args": (state_id, item),
}
for item in choices
if item != mode
]
return self._build_button_rows(buttons, columns=2)
async def _fetch_civitai_random_prompt(self):
params = {
"limit": 100,
"sort": "Most Reactions",
"period": "Month",
"nsfw": "false",
"withMeta": "true",
}
headers = {
"Accept": "application/json",
"User-Agent": "ComfyImageGen/0.1.0 (+https://github.com/mofko/MofkoModules)",
}
try:
async with self._session_get(
_CIVITAI_IMAGES_URL,
params=params,
headers=headers,
timeout=aiohttp.ClientTimeout(total=20),
) as resp:
if resp.status != 200:
logger.debug("Civitai images request failed (HTTP %s): %s", resp.status, (await resp.text())[:500])
raise UserFacingError("civitai_error", self._plain_text(self.strings("civitai_error")))
data = await resp.json(content_type=None)
except UserFacingError:
raise
except Exception as e:
logger.debug("Civitai random prompt request failed: %s", e)
raise UserFacingError("civitai_error", self._plain_text(self.strings("civitai_error")))
items = data.get("items", []) if isinstance(data, dict) else []
prompts = []
for item in items:
if not isinstance(item, dict):
continue
meta = item.get("meta")
if not isinstance(meta, dict):
continue
positive = meta.get("prompt")
if not isinstance(positive, str) or not positive.strip():
continue
negative = meta.get("negativePrompt")
if not isinstance(negative, str):
negative = ""
elif negative.strip().lower() in {"none", "null"}:
negative = ""
prompts.append(
{
"positive": positive.strip(),
"negative": negative.strip(),
}
)
if not prompts:
raise UserFacingError("civitai_no_prompt", self._plain_text(self.strings("civitai_no_prompt")))
return random.choice(prompts)
def _canonical_workflow_name(self, name):
raw = str(name or "").strip()
lowered = raw.lower()
if lowered == "":
return _DEFAULT_WORKFLOW_NAME
if lowered in ("anima", "animev2", "anime_v2", "anime-v2", "anime v2"):
return _ANIME_V2_WORKFLOW_NAME
if lowered in ("anima2", "animev3", "anime_v3", "anime-v3", "anime v3"):
return _ANIME_V3_WORKFLOW_NAME
if lowered in ("ill", "anime_workflow", "anime-workflow", "anime workflow"):
return _ILL_WORKFLOW_NAME
if lowered in ("krea2", "krea_2", "krea-2", "krea 2"):
return _KREA2_WORKFLOW_NAME
if lowered in ("sdxlreal2", "sdxl_real2", "sdxl-real2", "sdxl real2"):
return _SDXL_REAL2_WORKFLOW_NAME
if lowered in ("ckrea2", "cloudkrea2", "cloud_krea2", "cloud-krea2"):
return _CLOUD_KREA2_WORKFLOW_NAME
if lowered in ("canima", "cloudanima", "cloud_anima", "cloud-anima", "animacloud", "anima_cloud"):
return _CLOUD_ANIMA_WORKFLOW_NAME
if lowered in ("canima2", "cloudanima2", "cloud_anima2", "cloud-anima2", "anima2cloud", "anima2_cloud"):
return _CLOUD_ANIMA2_WORKFLOW_NAME
if lowered in ("cqwen", "cloudqwen", "cloud_qwen", "cloud-qwen", "qwencloud", "qwen_cloud"):
return _CLOUD_QWEN_WORKFLOW_NAME
for workflow_name in self._all_builtin_workflows():
if workflow_name.lower() == lowered:
return workflow_name
custom = self.get("workflows", {})
if isinstance(custom, dict):
for workflow_name in custom:
if str(workflow_name).lower() == lowered:
return workflow_name
return raw
def _builtin_workflow_description(self, name):
canonical_name = self._canonical_workflow_name(name)
if canonical_name == _ANIME_V2_WORKFLOW_NAME:
return self.strings("wf_desc_anime_v2")
if canonical_name == _ANIME_V3_WORKFLOW_NAME:
return self.strings("wf_desc_anime_v3")
if canonical_name == _ILL_WORKFLOW_NAME:
return self.strings("wf_desc_ill")
if canonical_name == _KREA2_WORKFLOW_NAME:
return self.strings("wf_desc_krea2")
if canonical_name == _SDXL_REAL2_WORKFLOW_NAME:
return self.strings("wf_desc_sdxl_real2")
if canonical_name == _CLOUD_KREA2_WORKFLOW_NAME:
return self.strings("wf_desc_cloud_krea2")
if canonical_name == _CLOUD_ANIMA_WORKFLOW_NAME:
return self.strings("wf_desc_cloud_anima")
if canonical_name == _CLOUD_ANIMA2_WORKFLOW_NAME:
return self.strings("wf_desc_cloud_anima2")
if canonical_name == _CLOUD_ILL_WORKFLOW_NAME:
return self.strings("wf_desc_cloud_ill")
if canonical_name == _CLOUD_QWEN_WORKFLOW_NAME:
return self.strings("wf_desc_cloud_qwen")
return ""
def _custom_workflow_entry(self, name):
custom = self.get("workflows", {})
if not isinstance(custom, dict):
return {}
canonical_name = self._canonical_workflow_name(name)
entry = custom.get(canonical_name) or custom.get(str(name or "").lower())
return entry if isinstance(entry, dict) else {}
def _workflow_description(self, name):
canonical_name = self._canonical_workflow_name(name)
if self._is_builtin_workflow(canonical_name):
return self._builtin_workflow_description(canonical_name)
return str(self._custom_workflow_entry(canonical_name).get("description") or "").strip()
@staticmethod
def _prompt_has_embedding(prompt, embedding):
prompt = str(prompt or "").lower()
embedding = str(embedding or "").strip().rstrip(",").lower()
if not embedding:
return False
return bool(re.search(rf"(? limit:
return text[: limit - 3].rstrip() + "..."
return text
def _format_negative_quote(self, value, limit=320):
if limit is None:
text = self._plain_text(str(value or "")).replace("\r", " ").strip()
text = re.sub(r"\s+", " ", text)
if not text:
text = self.strings("negative_not_set")
else:
text = self._preview_negative(value, limit)
return f"{utils.escape_html(text)}
"
@staticmethod
def _negative_source_icon(source):
return {
"custom": '🟢',
"global": '🔵',
"workflow": '🟡',
"empty": '⚪️',
}.get(source, '⚪️')
def _get_workflow_data(self, name):
name = self._canonical_workflow_name(name)
if self._is_builtin_workflow(name):
cached_wf = self.get(self._builtin_workflow_cache_key(name))
if not cached_wf:
return None
workflow = json.loads(json.dumps(cached_wf))
mapping = self._parse_workflow(workflow)
if name == _SDXL_REAL2_WORKFLOW_NAME:
mapping.update({
"positive": {"node_id": "109", "field": "text"},
"negative": {"node_id": "6", "field": "text"},
"model": {"node_id": "3", "field": "ckpt_name"},
"model_nodes": [{"node_id": "3", "field": "ckpt_name"}],
"seed": {"node_id": "7", "field": "seed"},
"steps": {"node_id": "7", "field": "steps"},
"cfg": {"node_id": "7", "field": "cfg"},
"sampler_name": {"node_id": "7", "field": "sampler_name"},
"scheduler": {"node_id": "7", "field": "scheduler"},
"denoise": {"node_id": "7", "field": "denoise"},
"width": {"node_id": "18", "field": "width_override"},
"height": {"node_id": "18", "field": "height_override"},
"scale_by": {"node_id": "143", "field": "scale_by"},
"output": {"node_id": "128"},
"output_regular": {"node_id": "128"},
})
return {
"workflow": workflow,
"mapping": mapping,
}
custom = self.get("workflows", {})
wf_entry = custom.get(name) if isinstance(custom, dict) else None
if wf_entry and isinstance(wf_entry, dict) and "workflow" in wf_entry:
workflow = self._normalize_workflow_format(json.loads(json.dumps(wf_entry["workflow"])))
return {
"workflow": workflow,
"mapping": self._parse_workflow(workflow),
"description": str(wf_entry.get("description") or "").strip(),
}
return None
async def _ensure_builtin_workflow(self, wf_name=_ANIME_V2_WORKFLOW_NAME, force=False):
wf_name = self._canonical_workflow_name(wf_name)
cache_key = self._builtin_workflow_cache_key(wf_name)
if self.get(cache_key) and not force:
return True
current_time = time.time()
last_retry = self._last_builtin_wf_retry.get(wf_name, 0) if isinstance(self._last_builtin_wf_retry, dict) else 0
if not force and current_time - last_retry < self._builtin_wf_retry_interval:
return False
async with self._builtin_wf_lock:
if self.get(cache_key) and not force:
return True
current_time = time.time()
last_retry = self._last_builtin_wf_retry.get(wf_name, 0) if isinstance(self._last_builtin_wf_retry, dict) else 0
if not force and current_time - last_retry < self._builtin_wf_retry_interval:
return False
self._last_builtin_wf_retry[wf_name] = current_time
try:
await self._fetch_builtin_workflow(wf_name, force=force)
self._builtin_wf_load_failed = False
return bool(self.get(cache_key))
except Exception as e:
self._builtin_wf_load_failed = True
logger.exception(e)
return False
async def _ensure_workflow_data(self, name):
name = self._canonical_workflow_name(name)
if self._is_builtin_workflow(name) and not self.get(self._builtin_workflow_cache_key(name)):
await self._ensure_builtin_workflow(name)
return self._get_workflow_data(name)
def _get_all_workflow_names(self):
custom = self.get("workflows", {})
return list(self._all_builtin_workflows()) + list(custom.keys())
async def _health_check(self, attempts=4, on_retry=None):
base = self._base_url()
if not base:
return False
retry_statuses = {408, 425, 429, 500, 502, 503, 504}
delays = (2, 3, 5)
attempts = max(1, int(attempts or 1))
for attempt in range(attempts):
try:
async with self._session_get(
f"{base}/system_stats",
headers=self._comfy_headers() if self._is_comfy_cloud() else None,
timeout=aiohttp.ClientTimeout(total=_COMFY_TIMEOUTS["queue_status"]),
) as resp:
if resp.status == 200:
return await resp.json()
logger.debug("ComfyUI health check failed (HTTP %s)", resp.status)
if resp.status not in retry_statuses:
return False
except Exception as e:
logger.debug("ComfyUI health check failed: %s", e)
if attempt < attempts - 1:
if on_retry:
try:
await on_retry(attempt + 2, attempts)
except Exception as e:
logger.debug("ComfyUI retry status update failed: %s", e)
await asyncio.sleep(delays[min(attempt, len(delays) - 1)])
return False
async def _get_object_info(self, class_type: str, attempts=2, timeout=15):
base = self._base_url()
if not base:
return None
cache_key = f"object_info:{base}:{class_type}"
if cache_key in self._comfy_cache:
return self._comfy_cache[cache_key]
if self._is_comfy_cloud():
data = await self._get_all_object_info(attempts=attempts)
if isinstance(data, dict) and class_type in data:
result = {class_type: data[class_type]}
self._comfy_cache[cache_key] = result
return result
return None
retry_statuses = {408, 425, 429, 500, 502, 503, 504}
attempts = max(1, int(attempts or 1))
for attempt in range(attempts):
try:
async with self._session_get(
f"{base}/object_info/{class_type}",
headers=self._comfy_headers() if self._is_comfy_cloud() else None,
timeout=aiohttp.ClientTimeout(total=timeout),
) as resp:
if resp.status == 200:
data = await resp.json()
self._comfy_cache[cache_key] = data
return data
logger.debug("ComfyUI object_info for %s failed (HTTP %s)", class_type, resp.status)
if resp.status not in retry_statuses:
return None
except Exception as e:
logger.debug("ComfyUI object_info for %s failed: %s", class_type, e)
if attempt < attempts - 1:
await asyncio.sleep(2)
return None
async def _get_all_object_info(self, attempts=2):
base = self._base_url()
if not base:
return None
cache_key = f"object_info:{base}:all"
if cache_key in self._comfy_cache:
return self._comfy_cache[cache_key]
retry_statuses = {408, 425, 429, 500, 502, 503, 504}
attempts = max(1, int(attempts or 1))
for attempt in range(attempts):
try:
async with self._session_get(
f"{base}/object_info",
headers=self._comfy_headers() if self._is_comfy_cloud() else None,
timeout=aiohttp.ClientTimeout(total=_COMFY_TIMEOUTS["object_info_all"]),
) as resp:
if resp.status == 200:
data = await resp.json()
self._comfy_cache[cache_key] = data
return data
logger.debug("ComfyUI object_info failed (HTTP %s)", resp.status)
if resp.status not in retry_statuses:
return None
except Exception as e:
logger.debug("ComfyUI object_info failed: %s", e)
if attempt < attempts - 1:
await asyncio.sleep(2)
return None
async def _free_comfy_memory(self):
base = self._base_url()
if not base:
raise UserFacingError("connection", self._plain_text(self.strings("no_url")))
async with self._session_post(
f"{base}/free",
json={"unload_models": True, "free_memory": True},
headers=self._comfy_headers() if self._is_comfy_cloud() else None,
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status == 200:
self._comfy_cache.clear()
return True
text = await resp.text()
logger.error("ComfyUI free memory failed (HTTP %s): %s", resp.status, text[:500])
raise ValueError(f"HTTP {resp.status}")
async def _clear_comfy_queue(self):
base = self._base_url()
if not base:
return False
try:
async with self._session_post(
f"{base}/queue",
json={"clear": True},
headers=self._comfy_headers() if self._is_comfy_cloud() else None,
timeout=aiohttp.ClientTimeout(total=_COMFY_TIMEOUTS["queue_status"]),
) as resp:
return resp.status == 200
except Exception as e:
logger.debug("ComfyUI queue clear failed: %s", e)
return False
async def _force_free_comfy_memory(self):
self._cancel_flags.clear()
self._generation_runtime.clear()
self._active_generations = 0
await self._interrupt_generation()
await self._clear_comfy_queue()
return await self._free_comfy_memory()
async def _queue_prompt(self, workflow_json, client_id):
base = self._base_url()
payload = {"prompt": workflow_json, "client_id": client_id}
if self._is_comfy_cloud():
payload["extra_data"] = {"api_key_comfy_org": None}
active_key = self._active_cloud_api_key.get()
configured_keys = self._get_cloud_api_keys()
keys = ([active_key] if active_key else []) + [
key for key in configured_keys if key and key != active_key
]
if not keys:
raise UserFacingError("cloud_no_key", self._plain_text(self.strings("cloud_no_key")))
last_error = None
for api_key in keys:
payload["extra_data"]["api_key_comfy_org"] = api_key
try:
async with self._session_post(
f"{base}/prompt",
json=payload,
headers=self._comfy_headers(api_key),
timeout=aiohttp.ClientTimeout(total=_COMFY_TIMEOUTS["queue_prompt"]),
) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
self._active_cloud_api_key.set(api_key)
if isinstance(data, dict):
data["cloud_api_key"] = api_key
prompt_id = data.get("prompt_id")
if prompt_id:
self._cloud_prompt_keys[str(prompt_id)] = api_key
runtime = self._generation_runtime.get(client_id)
if runtime is not None:
runtime["cloud_api_key"] = api_key
runtime["backend"] = _COMFY_BACKEND_CLOUD
return data
text = await resp.text()
last_error = (resp.status, text)
if self._cloud_http_error_key(resp.status, text):
continue
raise ComfyUIHTTPError(resp.status, text)
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e:
last_error = e
continue
if isinstance(last_error, tuple):
self._raise_cloud_http_error(last_error[0], last_error[1])
if last_error:
raise UserFacingError("cloud_unavailable", self._plain_text(self.strings("cloud_unavailable")))
raise UserFacingError("cloud_no_key", self._plain_text(self.strings("cloud_no_key")))
async with self._session_post(
f"{base}/prompt",
json=payload,
timeout=aiohttp.ClientTimeout(total=_COMFY_TIMEOUTS["queue_prompt"]),
) as resp:
if resp.status == 200:
return await resp.json()
text = await resp.text()
if resp.status in (502, 503, 504):
logger.warning("ComfyUI prompt queue temporary failure (HTTP %s): %s", resp.status, text[:1000])
else:
logger.error("ComfyUI prompt queue failed (HTTP %s): %s", resp.status, text[:1000])
raise ComfyUIHTTPError(resp.status, text)
@staticmethod
def _queue_item_has_prompt(item, prompt_id):
target = str(prompt_id)
if isinstance(item, dict):
return any(ComfyImageGenMod._queue_item_has_prompt(value, target) for value in item.values())
if isinstance(item, (list, tuple, set)):
return any(ComfyImageGenMod._queue_item_has_prompt(value, target) for value in item)
return str(item) == target
def _find_prompt_queue_position(self, items, prompt_id):
for index, item in enumerate(items or [], start=1):
if self._queue_item_has_prompt(item, prompt_id):
return index
return None
@staticmethod
def _looks_like_prompt_id(value):
value = str(value or "")
return bool(re.fullmatch(
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
value,
))
@classmethod
def _extract_queue_prompt_id(cls, item):
if isinstance(item, dict):
for key in ("prompt_id", "id"):
value = item.get(key)
if cls._looks_like_prompt_id(value):
return str(value)
for value in item.values():
prompt_id = cls._extract_queue_prompt_id(value)
if prompt_id:
return prompt_id
return None
if isinstance(item, (list, tuple)):
if len(item) > 1 and cls._looks_like_prompt_id(item[1]):
return str(item[1])
for value in item:
prompt_id = cls._extract_queue_prompt_id(value)
if prompt_id:
return prompt_id
return None
if cls._looks_like_prompt_id(item):
return str(item)
return None
def _cloud_api_key_for_prompt(self, prompt_id=None):
if not prompt_id:
return None
prompt_id = str(prompt_id)
if prompt_id in self._cloud_prompt_keys:
return self._cloud_prompt_keys[prompt_id]
for runtime in self._generation_runtime.values():
if isinstance(runtime, dict) and str(runtime.get("prompt_id") or "") == prompt_id:
return runtime.get("cloud_api_key")
return None
async def _get_queue_snapshot(self, timeout=None, api_key=None):
base = self._base_url()
if not base:
return {"ok": False, "queue_running": [], "queue_pending": []}
timeout = _COMFY_TIMEOUTS["queue_status"] if timeout is None else timeout
try:
async with self._session_get(
f"{base}/queue",
headers=self._comfy_headers(api_key) if self._is_comfy_cloud() else None,
timeout=aiohttp.ClientTimeout(total=timeout),
) as resp:
if resp.status != 200:
return {"ok": False, "queue_running": [], "queue_pending": []}
data = await resp.json(content_type=None)
except Exception as e:
logger.debug("ComfyUI queue snapshot failed: %s", e)
return {"ok": False, "queue_running": [], "queue_pending": []}
if not isinstance(data, dict):
return {"ok": False, "queue_running": [], "queue_pending": []}
running = data.get("queue_running") or []
pending = data.get("queue_pending") or []
if not isinstance(running, list):
running = []
if not isinstance(pending, list):
pending = []
running_prompt_id = self._extract_queue_prompt_id(running[0]) if running else None
return {
"ok": True,
"queue_running": running,
"queue_pending": pending,
"running_count": len(running),
"pending_count": len(pending),
"running_prompt_id": running_prompt_id,
"active": bool(running or pending),
}
async def _get_prompt_queue_info(self, prompt_id, timeout=None, api_key=None):
if not prompt_id:
return {"state": None, "position": None}
snapshot = await self._get_queue_snapshot(
timeout=timeout,
api_key=api_key or self._cloud_api_key_for_prompt(prompt_id),
)
if not snapshot.get("ok"):
return {"state": None, "position": None}
running = snapshot.get("queue_running") or []
pending = snapshot.get("queue_pending") or []
for item in running:
if self._queue_item_has_prompt(item, prompt_id):
return {
"state": "running",
"position": None,
"running_other": False,
"running_count": snapshot.get("running_count", 0),
"pending_count": snapshot.get("pending_count", 0),
"running_prompt_id": snapshot.get("running_prompt_id"),
}
position = self._find_prompt_queue_position(pending, prompt_id)
if position is not None:
return {
"state": "pending",
"position": position,
"running_other": bool(running),
"running_count": snapshot.get("running_count", 0),
"pending_count": snapshot.get("pending_count", 0),
"running_prompt_id": snapshot.get("running_prompt_id"),
}
return {
"state": None,
"position": None,
"running_other": bool(running),
"running_count": snapshot.get("running_count", 0),
"pending_count": snapshot.get("pending_count", 0),
"running_prompt_id": snapshot.get("running_prompt_id"),
}
async def _get_prompt_queue_state(self, prompt_id):
return (await self._get_prompt_queue_info(prompt_id)).get("state")
async def _delete_queued_prompt(self, prompt_id):
base = self._base_url()
if not base or not prompt_id:
return False
try:
async with self._session_post(
f"{base}/queue",
json={"delete": [prompt_id]},
headers=self._comfy_headers(self._cloud_api_key_for_prompt(prompt_id)) if self._is_comfy_cloud() else None,
timeout=aiohttp.ClientTimeout(total=_COMFY_TIMEOUTS["queue_delete"]),
) as resp:
return resp.status == 200
except Exception as e:
logger.debug("ComfyUI queue delete failed: %s", e)
return False
def _job_cancel_url(self, prompt_id):
base = self._base_url()
if not base or not prompt_id:
return None
encoded_id = quote(str(prompt_id), safe="")
if self._is_comfy_cloud():
return f"{base}/jobs/{encoded_id}/cancel"
return f"{base}/api/jobs/{encoded_id}/cancel"
async def _cancel_job_by_id(self, prompt_id, api_key=None):
url = self._job_cancel_url(prompt_id)
if not url:
return None
kwargs = {"timeout": aiohttp.ClientTimeout(total=_COMFY_TIMEOUTS["interrupt"])}
if self._is_comfy_cloud():
try:
kwargs["headers"] = self._comfy_headers(
api_key or self._cloud_api_key_for_prompt(prompt_id)
)
except Exception as e:
logger.debug("ComfyUI job cancel endpoint has no cloud key: %s", e)
return None
try:
async with self._session_post(url, **kwargs) as resp:
text = await resp.text()
if resp.status == 200:
try:
data = json.loads(text) if text else {}
except json.JSONDecodeError:
data = {}
if isinstance(data, dict):
return bool(data.get("cancelled", True))
return True
logger.debug(
"ComfyUI job cancel endpoint failed (HTTP %s): %s",
resp.status,
text[:500],
)
return None
except Exception as e:
logger.debug("ComfyUI job cancel endpoint failed: %s", e)
return None
async def _cancel_runtime_generation(self, client_id: str):
runtime = self._generation_runtime.get(client_id)
if not runtime:
return False
runtime["phase"] = "cancelled"
runtime["cancelled"] = True
prompt_id = runtime.get("prompt_id")
if not prompt_id:
return False
job_cancelled = await self._cancel_job_by_id(
prompt_id,
runtime.get("cloud_api_key"),
)
if job_cancelled is not None:
return job_cancelled
queue_state = await self._get_prompt_queue_state(prompt_id)
if queue_state == "running":
await self._interrupt_generation(prompt_id)
return True
if queue_state == "pending":
return await self._delete_queued_prompt(prompt_id)
return False
def _set_generation_phase(self, client_id: str, phase: str):
runtime = self._generation_runtime.get(client_id)
if runtime is not None:
runtime["phase"] = phase
def _cleanup_generation_runtime(self, client_id: str):
self._cancel_flags.pop(client_id, None)
self._generation_runtime.pop(client_id, None)
def _set_cancel_reason(self, client_id: str, reason: str):
runtime = self._generation_runtime.get(client_id)
if isinstance(runtime, dict) and not runtime.get("cancel_reason"):
runtime["cancel_reason"] = str(reason or "unknown")
def _get_cancel_reason(self, client_id: str):
runtime = self._generation_runtime.get(client_id)
if isinstance(runtime, dict):
return runtime.get("cancel_reason")
return None
async def _raise_if_generation_cancelled(self, client_id: str):
if self._cancel_flags.get(client_id, False):
self._set_cancel_reason(client_id, "cancel_flag")
await self._cancel_runtime_generation(client_id)
raise asyncio.CancelledError()
def _queue_status_text(self, queue_info, idle=False):
state = (queue_info or {}).get("state")
if state == "pending":
position = (queue_info or {}).get("position")
if (queue_info or {}).get("running_other"):
if position is not None:
return self.strings("queue_comfy_other_running").format(position)
return self.strings("queue_comfy_other_running_unknown")
if position is not None:
return self.strings("queue_comfy_pending").format(position)
return self.strings("queue_comfy_pending_unknown")
if state == "running":
if (queue_info or {}).get("ws_fallback"):
return self.strings("queue_comfy_running_ws_fallback")
return self.strings("queue_comfy_running")
if idle:
return self.strings("queue_idle_warning")
return self.strings("queue_comfy_submitted")
def _runtime_by_prompt_id(self, prompt_id):
if not prompt_id:
return None
target = str(prompt_id)
for runtime in list(self._generation_runtime.values()):
if isinstance(runtime, dict) and str(runtime.get("prompt_id")) == target:
return runtime
return None
def _format_cmon_text(self, snapshot):
lines = [self.strings("cmon_title")]
if not snapshot.get("ok"):
lines.append(f"{self.strings('cmon_unavailable')}
")
return self._to_inline_emoji("\n".join(lines))
running_prompt_id = snapshot.get("running_prompt_id")
details = []
runtime = self._runtime_by_prompt_id(running_prompt_id)
if running_prompt_id and runtime:
details.append(self.strings("cmon_active").format(utils.escape_html(running_prompt_id)))
elif running_prompt_id:
details.append(self.strings("cmon_active_other").format(utils.escape_html(running_prompt_id)))
elif snapshot.get("running_count"):
details.append(self.strings("cmon_active_unknown"))
else:
details.append(self.strings("cmon_no_tasks"))
if runtime:
current_node_id = runtime.get("current_node_id")
if current_node_id is not None:
node_info = self._get_node_status_info(runtime.get("workflow"), current_node_id)
node_status = (
node_info.get("text")
if isinstance(node_info, dict) and node_info.get("text")
else self.strings("fmt_running_node").format(utils.escape_html(str(current_node_id)))
)
details.append(self.strings("cmon_current_node").format(node_status))
progress_pct = runtime.get("progress_pct")
if progress_pct is not None:
details.append(self.strings("cmon_progress").format(int(progress_pct)))
details.append(self.strings("cmon_last_check").format(time.strftime("%H:%M:%S")))
lines.append(f"{chr(10).join(details)}
")
return self._to_inline_emoji("\n".join(lines))
async def _cmon_loop(self, state_id, form):
idle_since = None
try:
while not self._unloading:
snapshot = await self._get_queue_snapshot(timeout=5)
now = time.monotonic()
if snapshot.get("running_count"):
idle_since = None
else:
if idle_since is None:
idle_since = now
elif now - idle_since >= _CMON_IDLE_CLOSE_AFTER:
try:
await self._edit_cmon_form(
form,
self._to_inline_emoji(self.strings("cmon_closed_idle")),
None,
)
except Exception:
pass
try:
await asyncio.sleep(2)
await form.delete()
except Exception:
pass
return
markup = [[{
"text": self.strings("btn_close"),
"callback": self._cmon_close,
"args": (state_id,),
"style": "danger",
}]]
try:
await self._edit_cmon_form(form, self._format_cmon_text(snapshot), markup)
except Exception as e:
if self._is_cmon_dead_form_error(e):
logger.debug("Stopping cmon loop for dead form: %s", e)
return
logger.debug("Failed to update cmon form: %s", e)
await asyncio.sleep(_CMON_POLL_INTERVAL)
except asyncio.CancelledError:
raise
finally:
entry = self._cmon_tasks.get(state_id)
if isinstance(entry, dict) and entry.get("task") is asyncio.current_task():
self._cmon_tasks.pop(state_id, None)
async def _cmon_close(self, call: InlineCall, state_id: str):
entry = self._cmon_tasks.pop(state_id, None)
task = entry.get("task") if isinstance(entry, dict) else entry
if task:
task.cancel()
try:
await call.delete()
except Exception as e:
if not self._is_cmon_dead_form_error(e):
logger.debug("Failed to close cmon form: %s", e)
try:
await call.answer()
except Exception:
pass
async def _close_cmon_entry(self, state_id):
entry = self._cmon_tasks.pop(state_id, None)
if not entry:
return
task = entry.get("task") if isinstance(entry, dict) else entry
form = entry.get("form") if isinstance(entry, dict) else None
if task:
task.cancel()
if form:
try:
await form.delete()
except Exception as e:
if not self._is_cmon_dead_form_error(e):
logger.debug("Failed to delete previous cmon form: %s", e)
def _cmon_state_id(self, message):
try:
chat_id = utils.get_chat_id(message)
except Exception:
chat_id = getattr(message, "chat_id", None) or "unknown"
sender_id = getattr(message, "sender_id", None) or self.tg_id or 0
return f"{chat_id}:{sender_id}"
@staticmethod
def _is_cmon_dead_form_error(error):
text = f"{type(error).__name__}: {error}".lower()
return any(
marker in text
for marker in (
"msg not found",
"messageidinvalid",
"message id invalid",
"messagedeleteforbidden",
"message delete forbidden",
"message to delete not found",
"inline message id invalid",
"message not found",
)
)
async def _edit_cmon_form(self, form, text, reply_markup):
text = self._apply_emoji_theme(text)
reply_markup = self._wrap_inline_input_handlers(reply_markup)
if hasattr(form, "edit") and callable(form.edit):
await form.edit(text=text, reply_markup=reply_markup)
return True
return False
async def _create_cmon_form(self, message, text, reply_markup):
return await self.inline.form(
message=message,
text=self._apply_emoji_theme(text),
reply_markup=self._wrap_inline_input_handlers(reply_markup),
)
@staticmethod
def _extract_ws_progress_pct(dtype, ddata, prompt_id, current_node_id=None):
if not isinstance(ddata, dict):
return None
def _percent(value, max_val):
try:
max_val = float(max_val)
if not max_val:
return 0
return max(0, min(100, int(float(value) / max_val * 100)))
except (TypeError, ValueError, ZeroDivisionError):
return None
if dtype == "progress":
progress_prompt_id = ddata.get("prompt_id")
if progress_prompt_id != prompt_id:
return None
value = ddata.get("value", 0)
max_val = ddata.get("max", 1)
return _percent(value, max_val)
if dtype == "progress_state":
if ddata.get("prompt_id") != prompt_id:
return None
nodes = ddata.get("nodes", {})
if not isinstance(nodes, dict):
return None
candidates = []
if current_node_id is not None:
node_state = nodes.get(str(current_node_id))
if isinstance(node_state, dict):
candidates.append(node_state)
candidates.extend(
node_state
for node_state in nodes.values()
if isinstance(node_state, dict)
and str(node_state.get("state", "")).lower() == "running"
)
for node_state in candidates:
pct = _percent(node_state.get("value", 0), node_state.get("max", 1))
if pct is not None:
return pct
return None
@staticmethod
def _normalize_ws_timestamp(value):
try:
timestamp = float(value)
except (TypeError, ValueError):
return None
if timestamp > 100000000000:
timestamp /= 1000
return timestamp
@staticmethod
def _runtime_duration(runtime, now=None):
if not isinstance(runtime, dict):
return None
start_ts = runtime.get("server_start_ts")
end_ts = runtime.get("server_end_ts")
if start_ts is not None and end_ts is not None and end_ts >= start_ts:
return end_ts - start_ts
if runtime.get("duration") is not None:
return runtime.get("duration")
local_start = runtime.get("local_start")
if local_start is not None:
current = runtime.get("local_end")
if current is None:
current = time.monotonic() if now is None else now
if current >= local_start:
return current - local_start
return runtime.get("duration")
@staticmethod
def _runtime_has_started(runtime):
if not isinstance(runtime, dict):
return False
return bool(
runtime.get("server_start_ts") is not None
or runtime.get("local_start") is not None
or runtime.get("duration") is not None
or runtime.get("phase") in ("running", "finishing", "finished", "uploading")
)
def _mark_runtime_running(self, client_id: str, current_node_id=None):
runtime = self._generation_runtime.get(client_id)
if runtime is None:
return
runtime["phase"] = "running"
runtime["executing"] = True
if current_node_id is not None:
runtime["current_node_id"] = str(current_node_id)
if runtime.get("local_start") is None:
runtime["local_start"] = time.monotonic()
def _mark_runtime_finished(self, client_id: str, ddata=None, generation_state=None):
runtime = self._generation_runtime.get(client_id)
if runtime is None:
return
now = time.monotonic()
runtime["phase"] = "finished"
runtime["executing"] = False
runtime["current_node_id"] = None
if runtime.get("local_start") is None:
runtime["local_start"] = runtime.get("queued_at") or now
runtime["local_end"] = now
if isinstance(ddata, dict):
timestamp = self._normalize_ws_timestamp(ddata.get("timestamp"))
if timestamp is not None:
runtime["server_end_ts"] = timestamp
duration = self._runtime_duration(runtime)
if duration is not None:
runtime["duration"] = duration
if generation_state is not None:
generation_state["generation_duration"] = duration
@staticmethod
def _iter_history_messages(history):
status = history.get("status") if isinstance(history, dict) else None
if not isinstance(status, dict):
return
messages = status.get("messages") or []
for item in messages:
event_type = ""
payload = {}
if isinstance(item, (list, tuple)) and item:
event_type = str(item[0] or "").lower()
if len(item) > 1 and isinstance(item[1], dict):
payload = item[1]
elif isinstance(item, dict):
event_type = str(item.get("type") or item.get("event") or "").lower()
payload = item
yield event_type, payload
def _apply_history_runtime_timestamps(self, client_id, history):
runtime = self._generation_runtime.get(client_id)
if runtime is None:
return
for event_type, payload in self._iter_history_messages(history):
timestamp = self._normalize_ws_timestamp(payload.get("timestamp"))
if timestamp is None:
continue
if event_type == "execution_start":
runtime["server_start_ts"] = timestamp
elif event_type in ("execution_success", "execution_complete", "execution_interrupted", "execution_error"):
runtime["server_end_ts"] = timestamp
def _store_generation_duration(self, generation_state, runtime, now=None):
if generation_state is None:
return None
if not self._runtime_has_started(runtime):
generation_state.pop("generation_duration", None)
return None
duration = self._runtime_duration(runtime, now)
if duration is not None:
generation_state["generation_duration"] = duration
return duration
@staticmethod
def _comfy_execution_error_payload(error_payload=None, status=None):
error_payload = error_payload if isinstance(error_payload, dict) else {}
status = status if isinstance(status, dict) else {}
message = (
error_payload.get("exception_message")
or error_payload.get("message")
or status.get("message")
or status.get("status_str")
or "Execution error in ComfyUI"
)
extra_info = {
key: error_payload.get(key)
for key in ("node_id", "node_type", "node_title", "exception_type")
if error_payload.get(key) is not None
}
error_json = {
"error": {
"type": error_payload.get("exception_type") or "execution_error",
"message": str(message),
"extra_info": extra_info,
},
"raw": error_payload,
}
for key in ("traceback", "current_inputs", "current_outputs", "executed"):
if key in error_payload:
error_json[key] = error_payload[key]
if status:
error_json["status"] = status
return ComfyUIExecutionError(json.dumps(error_json, ensure_ascii=False))
async def _wait_ws(self, client_id, queue_func, status_form=None, cancel_markup=None, display_positive="", display_model="", display_wf="", expected_output_node=None, timeout=_GENERATION_TIMEOUT, easter_egg=None, workflow=None, status_is_inline=True, generation_state=None):
ws_url = self._comfy_ws_url(client_id)
prompt_id = None
ready_history = None
last_edit = 0
last_queue_poll = 0
last_history_poll = 0
last_queue_status = None
start = None
deadline = None
last_event_at = time.monotonic()
idle_warned = False
ws_update_interval = self._ws_update_interval()
async def _update_status(status_text=None, is_progress=False, progress_pct=0, queue_info=None):
if not status_form:
return
runtime = self._generation_runtime.get(client_id) or {}
node_status = self._get_node_status_info(workflow, runtime.get("current_node_id"))
if node_status and node_status.get("complete_progress"):
is_progress = True
progress_pct = 100
stored_progress_pct = self._coerce_int(
runtime.get("progress_pct"),
0,
0,
100,
)
if is_progress:
stored_progress_pct = self._coerce_int(progress_pct, 0, 0, 100)
runtime["progress_pct"] = stored_progress_pct
effective_status = status_text
queue_running_statuses = {
self.strings("queue_comfy_running"),
self.strings("queue_comfy_running_ws_fallback"),
}
if node_status and (effective_status is None or effective_status in queue_running_statuses):
effective_status = node_status.get("text")
duration_text = None
if self._show_generation_time_progress():
duration = self._store_generation_duration(
generation_state,
runtime,
time.monotonic(),
)
duration_text = self._format_generation_time_value(duration)
eta_text = None
eta_seconds = self._estimate_generation_eta(
generation_state,
runtime,
queue_info=queue_info,
progress_pct=stored_progress_pct if is_progress else None,
)
if eta_seconds is not None:
eta_text = self._format_eta_value(eta_seconds)
if eta_text and generation_state is not None and generation_state.get("generation_eta_initial") is None:
generation_state["generation_eta_initial"] = eta_seconds
progress_text = self._format_status_text(
display_positive,
display_model,
display_wf,
is_inline=status_is_inline,
is_progress=is_progress,
progress_pct=stored_progress_pct,
show_progress_bar=(
is_progress
or "progress_pct" in runtime
or runtime.get("phase") in ("running", "finishing")
),
easter_egg=easter_egg,
status_text=effective_status,
generation_time=duration_text,
generation_eta=eta_text,
)
if status_is_inline:
await status_form.edit(text=progress_text, reply_markup=cancel_markup)
else:
await utils.answer(status_form, self._apply_emoji_theme(progress_text))
async def _queue_once():
nonlocal prompt_id
if prompt_id:
return
await self._raise_if_generation_cancelled(client_id)
try:
await _update_status(self.strings("fmt_encoding_prompt"))
except Exception:
pass
queue_resp = queue_func() if callable(queue_func) else queue_func
if asyncio.iscoroutine(queue_resp):
queue_resp = await queue_resp
prompt_id = queue_resp.get("prompt_id") if isinstance(queue_resp, dict) else None
if not prompt_id:
logger.error("ComfyUI returned no prompt_id: %s", queue_resp)
raise ValueError("No prompt_id from ComfyUI")
runtime = self._generation_runtime.get(client_id)
if runtime is not None:
runtime["prompt_id"] = prompt_id
runtime["phase"] = "queued"
runtime["queued_at"] = time.monotonic()
if self._cancel_flags.get(client_id, False):
self._set_cancel_reason(client_id, "cancel_flag_after_queue")
await self._cancel_runtime_generation(client_id)
raise asyncio.CancelledError()
try:
await _update_status(self.strings("queue_comfy_submitted"))
except Exception:
pass
async def _poll_queue(force=False, idle=False):
nonlocal last_queue_poll, last_queue_status, last_event_at, idle_warned
if not prompt_id:
return None
now = time.monotonic()
if not force and now - last_queue_poll < _QUEUE_POLL_INTERVAL:
return None
last_queue_poll = now
queue_info = await self._get_prompt_queue_info(prompt_id, timeout=3)
state = queue_info.get("state")
runtime = self._generation_runtime.get(client_id)
if runtime is not None:
if state == "running":
self._mark_runtime_running(client_id)
last_event_at = now
idle_warned = False
elif state == "pending":
runtime["phase"] = "queued"
runtime["executing"] = False
elif state is None and runtime.get("phase") in ("queued", "running"):
queue_info["state"] = "running"
queue_info["ws_fallback"] = True
state = "running"
self._mark_runtime_running(client_id)
status_text = self._queue_status_text(queue_info, idle=idle)
if force or idle or state == "running" or status_text != last_queue_status:
last_queue_status = status_text
try:
await _update_status(status_text, queue_info=queue_info)
except Exception:
pass
return queue_info
async def _poll_history(force=False):
nonlocal last_history_poll, ready_history
if ready_history is not None:
return ready_history
if not prompt_id:
return None
now = time.monotonic()
if not force and now - last_history_poll < _HISTORY_POLL_INTERVAL:
return None
last_history_poll = now
try:
history = await self._fetch_history_once(
prompt_id,
expected_output_node,
timeout=3,
allow_finished=True,
)
except ComfyUIExecutionError:
raise
except Exception as e:
logger.debug("ComfyUI history poll failed: %s", e)
return None
if history:
ready_history = history
self._apply_history_runtime_timestamps(client_id, history)
self._mark_runtime_finished(client_id, generation_state=generation_state)
return ready_history
try:
if self._is_comfy_cloud():
await _queue_once()
runtime = self._generation_runtime.get(client_id) or {}
ws_url = self._comfy_ws_url(client_id, runtime.get("cloud_api_key"))
async with self._session_ws_connect(ws_url, timeout=_COMFY_TIMEOUTS["ws_connect"]) as ws:
await _queue_once()
start = time.time()
deadline = start + timeout
last_event_at = time.monotonic()
await _poll_queue(force=True)
while True:
now_time = time.time()
remaining = deadline - now_time
if remaining <= 0:
if await _poll_history(force=True):
break
if prompt_id:
await _poll_queue(force=True, idle=True)
raise asyncio.TimeoutError()
if self._unloading:
self._set_cancel_reason(client_id, "unloading")
await self._interrupt_generation(prompt_id)
raise asyncio.CancelledError()
if self._cancel_flags.get(client_id, False):
self._set_cancel_reason(client_id, "cancel_flag_wait_loop")
await self._cancel_runtime_generation(client_id)
raise asyncio.CancelledError()
if await _poll_history():
break
await _poll_queue()
try:
msg = await asyncio.wait_for(
ws.receive(),
timeout=min(1, max(0.1, remaining)),
)
except asyncio.TimeoutError:
if await _poll_history():
break
await _poll_queue()
if (
prompt_id
and not idle_warned
and time.monotonic() - last_event_at >= _GENERATION_IDLE_WARNING
):
idle_warned = True
await _poll_queue(force=True, idle=True)
continue
if msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
break
if msg.type != aiohttp.WSMsgType.TEXT:
continue
try:
data = json.loads(msg.data)
except json.JSONDecodeError:
continue
dtype = data.get("type")
ddata = data.get("data", {})
if ddata.get("prompt_id") == prompt_id:
last_event_at = time.monotonic()
idle_warned = False
runtime = self._generation_runtime.get(client_id)
if runtime is not None:
match dtype:
case "execution_start":
self._mark_runtime_running(client_id)
timestamp = self._normalize_ws_timestamp(ddata.get("timestamp"))
if timestamp is not None:
runtime["server_start_ts"] = timestamp
case "executing":
if ddata.get("node") is not None:
self._mark_runtime_running(client_id, ddata.get("node"))
else:
runtime["phase"] = "finishing"
runtime["executing"] = False
runtime["current_node_id"] = None
case "progress":
self._mark_runtime_running(client_id, ddata.get("node"))
case "progress_state":
self._mark_runtime_running(client_id)
nodes = ddata.get("nodes", {})
if isinstance(nodes, dict):
running_nodes = [
str(node_id)
for node_id, node_state in nodes.items()
if isinstance(node_state, dict)
and str(node_state.get("state", "")).lower() == "running"
]
if running_nodes:
runtime["current_node_id"] = running_nodes[-1]
case "execution_cached":
self._mark_runtime_running(client_id)
case "execution_success" | "execution_complete" | "execution_interrupted" | "execution_error":
self._mark_runtime_finished(client_id, ddata, generation_state)
runtime = self._generation_runtime.get(client_id) or {}
pct = self._extract_ws_progress_pct(dtype, ddata, prompt_id, runtime.get("current_node_id"))
if status_form and ws_update_interval:
now = time.time()
if now - last_edit >= ws_update_interval:
last_edit = now
try:
runtime = self._generation_runtime.get(client_id) or {}
node_status = self._get_node_status_info(workflow, runtime.get("current_node_id"))
status_text = None
has_progress = bool(
pct is not None
and node_status
and node_status.get("show_progress")
)
if node_status:
if not has_progress or node_status.get("known"):
status_text = node_status.get("text")
if dtype == "execution_cached" and ddata.get("prompt_id") == prompt_id:
cached_nodes = ddata.get("nodes") or []
if isinstance(cached_nodes, list):
status_text = self.strings("fmt_cached_nodes").format(len(cached_nodes))
await _update_status(status_text, has_progress, pct or 0)
except Exception:
pass
if dtype == "executed" and ddata.get("prompt_id") == prompt_id:
if expected_output_node and str(ddata.get("node")) != str(expected_output_node):
continue
self._mark_runtime_finished(client_id, ddata, generation_state)
break
if dtype == "executing" and ddata.get("prompt_id") == prompt_id and ddata.get("node") is None:
self._mark_runtime_finished(client_id, ddata, generation_state)
break
if dtype in ("execution_success", "execution_complete") and ddata.get("prompt_id") == prompt_id:
self._mark_runtime_finished(client_id, ddata, generation_state)
break
if dtype == "execution_interrupted" and ddata.get("prompt_id") == prompt_id:
self._set_cancel_reason(client_id, "execution_interrupted")
self._mark_runtime_finished(client_id, ddata, generation_state)
raise asyncio.CancelledError()
if dtype == "execution_error" and ddata.get("prompt_id") == prompt_id:
raise self._comfy_execution_error_payload(ddata)
if ready_history is None:
remaining = (deadline - time.time()) if deadline else timeout
ready_history = await self._fetch_history(
prompt_id,
expected_output_node,
max(1, remaining),
allow_finished=True,
client_id=client_id,
)
self._apply_history_runtime_timestamps(client_id, ready_history)
self._mark_runtime_finished(client_id, generation_state=generation_state)
except (aiohttp.ClientError, OSError):
if prompt_id is None:
await _queue_once()
remaining = (deadline - time.time()) if deadline else timeout
history = await self._fetch_history(
prompt_id,
expected_output_node,
max(1, remaining),
allow_finished=True,
client_id=client_id,
)
self._apply_history_runtime_timestamps(client_id, history)
self._mark_runtime_finished(client_id, generation_state=generation_state)
return prompt_id, history
finally:
runtime = self._generation_runtime.get(client_id)
if runtime is not None:
self._store_generation_duration(generation_state, runtime, time.monotonic())
self._cleanup_generation_runtime(client_id)
if ready_history is not None:
return prompt_id, ready_history
raise asyncio.TimeoutError()
async def _interrupt_generation(self, prompt_id=None):
base = self._base_url()
if not base:
return
try:
kwargs = {"timeout": aiohttp.ClientTimeout(total=_COMFY_TIMEOUTS["interrupt"])}
if self._is_comfy_cloud():
kwargs["headers"] = self._comfy_headers(self._cloud_api_key_for_prompt(prompt_id))
if prompt_id:
kwargs["json"] = {"prompt_id": prompt_id}
async with self._session_post(
f"{base}/interrupt",
**kwargs,
) as resp:
pass
except Exception:
pass
async def _cancel_generation(self, call: InlineCall, client_id: str):
self._set_cancel_reason(client_id, "manual_cancel_button")
self._cancel_flags[client_id] = True
await self._cancel_runtime_generation(client_id)
try:
await call.edit(text=self.strings("cancelled"))
except Exception:
pass
await asyncio.sleep(2)
try:
await call.delete()
except Exception:
pass
def _history_has_expected_output(self, history, expected_output_node=None):
return bool(
self._extract_media_info(
history,
expected_output_node,
("videos", "video", "gifs", "audio", "images"),
)
)
@staticmethod
def _history_is_finished(history):
if not isinstance(history, dict):
return False
status = history.get("status")
if not isinstance(status, dict):
return False
if status.get("completed") is True:
return True
status_text = str(status.get("status_str") or status.get("status") or "").lower()
return status_text in {"success", "completed", "complete"}
def _history_execution_error(self, history):
if not isinstance(history, dict):
return None
status = history.get("status")
if not isinstance(status, dict):
return None
status_text = str(
status.get("status_str") or status.get("status") or ""
).lower()
messages = status.get("messages") or []
error_payload = None
for item in messages:
event_type = ""
payload = {}
if isinstance(item, (list, tuple)) and item:
event_type = str(item[0] or "").lower()
if len(item) > 1 and isinstance(item[1], dict):
payload = item[1]
elif isinstance(item, dict):
event_type = str(item.get("type") or item.get("event") or "").lower()
payload = item
if event_type == "execution_error":
error_payload = payload
break
if not error_payload:
if "error" not in status_text and "failed" not in status_text:
return None
error_payload = {}
if not error_payload:
error_payload = {"message": status_text or "Execution error in ComfyUI"}
return self._comfy_execution_error_payload(error_payload, status)
def _cloud_job_to_history(self, prompt_id, data):
if not isinstance(data, dict):
return None
status_text = str(
data.get("status")
or (data.get("execution_status") or {}).get("status")
or ""
).lower()
outputs = data.get("outputs") or {}
if not isinstance(outputs, dict):
outputs = {}
status = {
"completed": status_text in ("completed", "success", "succeeded"),
"status_str": status_text,
"messages": [],
}
error_payload = data.get("execution_error")
if isinstance(error_payload, dict) and (status_text in ("failed", "error") or error_payload):
status["messages"].append(["execution_error", error_payload])
elif status_text in ("failed", "error", "cancelled"):
status["messages"].append(["execution_error", {"message": status_text or "Cloud job failed"}])
return {
"prompt": [None, prompt_id],
"outputs": outputs,
"status": status,
"cloud_job": data,
}
async def _fetch_history_once(self, prompt_id, expected_output_node=None, timeout=None, allow_finished=False):
base = self._base_url()
if not base or not prompt_id:
return None
timeout = _COMFY_TIMEOUTS["history_request"] if timeout is None else timeout
if self._is_comfy_cloud():
async with self._session_get(
f"{base}/jobs/{prompt_id}",
headers=self._comfy_headers(self._cloud_api_key_for_prompt(prompt_id)),
timeout=aiohttp.ClientTimeout(total=timeout),
) as resp:
if resp.status != 200:
return None
data = await resp.json(content_type=None)
history = self._cloud_job_to_history(prompt_id, data)
if not history:
return None
history_error = self._history_execution_error(history)
if history_error:
raise history_error
if self._history_has_expected_output(history, expected_output_node):
return history
if allow_finished and self._history_is_finished(history):
return history
return None
async with self._session_get(
f"{base}/history/{prompt_id}",
timeout=aiohttp.ClientTimeout(total=timeout),
) as resp:
if resp.status != 200:
return None
data = await resp.json(content_type=None)
if prompt_id not in data:
return None
history = data[prompt_id]
history_error = self._history_execution_error(history)
if history_error:
raise history_error
if self._history_has_expected_output(history, expected_output_node):
return history
if allow_finished and self._history_is_finished(history):
return history
return None
async def _fetch_history(self, prompt_id, expected_output_node=None, timeout=180, allow_finished=False, client_id=None):
base = self._base_url()
url = f"{base}/jobs/{prompt_id}" if self._is_comfy_cloud() else f"{base}/history/{prompt_id}"
delay = 2
start = time.time()
last_error = None
last_error_at = None
while True:
if time.time() - start >= timeout:
if last_error:
raise last_error
raise asyncio.TimeoutError()
if (
last_error
and last_error_at
and time.time() - last_error_at >= 30
and isinstance(last_error, ValueError)
and not isinstance(last_error, ComfyUIHTTPError)
):
raise last_error
if self._unloading:
raise asyncio.CancelledError()
if client_id:
await self._raise_if_generation_cancelled(client_id)
try:
async with self._session_get(
url,
headers=self._comfy_headers(self._cloud_api_key_for_prompt(prompt_id)) if self._is_comfy_cloud() else None,
timeout=aiohttp.ClientTimeout(total=_COMFY_TIMEOUTS["history_request"]),
) as resp:
raw_text = await resp.text()
if resp.status == 200:
try:
data = json.loads(raw_text)
except json.JSONDecodeError:
content_type = resp.headers.get("Content-Type", "")
preview = re.sub(r"\s+", " ", raw_text[:500]).strip()
logger.warning(
"ComfyUI history returned non-JSON (content-type=%s): %s",
content_type,
preview,
)
last_error = UserFacingError("server_unavailable", self._plain_text(self.strings("unexpected_comfy_response")))
last_error_at = last_error_at or time.time()
await asyncio.sleep(delay)
continue
if self._is_comfy_cloud():
history = self._cloud_job_to_history(prompt_id, data)
if not history:
await asyncio.sleep(delay)
continue
history_error = self._history_execution_error(history)
if history_error:
raise history_error
if self._history_has_expected_output(history, expected_output_node):
return history
if allow_finished and self._history_is_finished(history):
return history
elif prompt_id in data:
history = data[prompt_id]
history_error = self._history_execution_error(history)
if history_error:
raise history_error
if self._history_has_expected_output(history, expected_output_node):
return history
if allow_finished and self._history_is_finished(history):
return history
last_error = None
last_error_at = None
else:
if resp.status in (502, 503, 504):
logger.warning("ComfyUI history temporary failure (HTTP %s): %s", resp.status, raw_text[:500])
last_error = ComfyUIHTTPError(resp.status, raw_text)
last_error_at = last_error_at or time.time()
else:
logger.error("ComfyUI history failed (HTTP %s): %s", resp.status, raw_text[:500])
raise ComfyUIHTTPError(resp.status, raw_text)
except ValueError:
raise
except asyncio.TimeoutError as e:
last_error = e
last_error_at = last_error_at or time.time()
logger.debug("ComfyUI history request timed out")
except (aiohttp.ClientError, OSError) as e:
last_error = e
last_error_at = last_error_at or time.time()
logger.debug("ComfyUI history request failed: %s", e)
await asyncio.sleep(delay)
async def _upload_to_comfyui(self, img_bio, filename="input.png", content_type=None):
base = self._base_url()
content_type = content_type or mimetypes.guess_type(filename)[0] or "application/octet-stream"
data = aiohttp.FormData()
data.add_field(
"image",
img_bio,
filename=filename,
content_type=content_type,
)
if self._is_comfy_cloud():
data.add_field("type", "input")
data.add_field("overwrite", "true")
async with self._session_post(
f"{base}/upload/image",
data=data,
headers=self._comfy_headers() if self._is_comfy_cloud() else None,
timeout=aiohttp.ClientTimeout(total=_COMFY_TIMEOUTS["upload_image"]),
) as resp:
if resp.status != 200:
text = await resp.text()
if resp.status in (502, 503, 504):
logger.warning("ComfyUI image upload temporary failure (HTTP %s): %s", resp.status, text[:500])
else:
logger.error("ComfyUI image upload failed (HTTP %s): %s", resp.status, text[:500])
raise ComfyUIHTTPError(resp.status, text)
result = await resp.json()
return result.get("name", filename)
async def _read_comfy_media_response(self, resp, media_info, media_label, max_mb, max_bytes):
content_length = resp.headers.get("Content-Length")
if content_length:
try:
declared_size = int(content_length)
except ValueError:
declared_size = None
if declared_size and declared_size > max_bytes:
raise UserFacingError("output_too_large", max_mb=max_mb)
suffix = "." + self._media_extension(media_info, media_label).lstrip(".")
out = tempfile.NamedTemporaryFile(
mode="w+b",
prefix="comfyimagegen_",
suffix=suffix,
)
total = 0
try:
async for chunk in resp.content.iter_chunked(1024 * 1024):
total += len(chunk)
if total > max_bytes:
raise UserFacingError("output_too_large", max_mb=max_mb)
out.write(chunk)
out.seek(0)
return out
except Exception:
out.close()
raise
def _max_output_bytes(self):
max_mb = self._coerce_int(self.config["max_output_mb"], 300, 1, 2000)
return max_mb, max_mb * 1024 * 1024
def _retrieve_media_timeout(self):
max_mb, _ = self._max_output_bytes()
sock_read = max(_COMFY_TIMEOUTS["retrieve_media"], min(3600, max_mb * 2))
return aiohttp.ClientTimeout(total=None, sock_read=sock_read)
@staticmethod
def _file_size(file_obj):
if not hasattr(file_obj, "seek") or not hasattr(file_obj, "tell"):
return None
pos = file_obj.tell()
file_obj.seek(0, os.SEEK_END)
size = file_obj.tell()
file_obj.seek(pos)
return size
@staticmethod
def _read_file_bytes(file_obj):
file_obj.seek(0)
data = file_obj.read()
file_obj.seek(0)
return data
@staticmethod
def _clone_media_payloads(file_objs):
payloads = []
for index, file_obj in enumerate(file_objs or [], start=1):
if hasattr(file_obj, "seek"):
file_obj.seek(0)
data = file_obj.read()
if hasattr(file_obj, "seek"):
file_obj.seek(0)
name = getattr(file_obj, "name", None) or f"comfyui_batch_{index}.png"
payloads.append((data, name))
return payloads
@staticmethod
def _payloads_to_files(payloads):
files = []
for index, item in enumerate(payloads or [], start=1):
if isinstance(item, tuple) and len(item) >= 2:
data, name = item[0], item[1]
else:
data, name = item, f"comfyui_batch_{index}.png"
file_obj = io.BytesIO(data)
file_obj.name = name or f"comfyui_batch_{index}.png"
files.append(file_obj)
return files
async def _retrieve_image(self, image_info):
return await self._retrieve_comfy_media(image_info, "image")
async def _retrieve_comfy_media(self, media_info, media_label="media"):
base = self._base_url()
filename = media_info.get("filename")
subfolder = media_info.get("subfolder", "")
folder_type = media_info.get("type", "output")
params = {
"filename": filename,
"subfolder": subfolder,
"type": folder_type,
}
max_mb, max_bytes = self._max_output_bytes()
async with self._session_get(
f"{base}/view",
params=params,
headers=self._comfy_headers(self._cloud_api_key_for_prompt(media_info.get("prompt_id"))) if self._is_comfy_cloud() else None,
allow_redirects=not self._is_comfy_cloud(),
timeout=self._retrieve_media_timeout(),
) as resp:
if self._is_comfy_cloud() and resp.status in (301, 302, 303, 307, 308):
signed_url = resp.headers.get("Location")
if not signed_url:
raise ComfyUIHTTPError(resp.status, "Cloud view redirect has no Location")
async with self._session_get(
signed_url,
timeout=self._retrieve_media_timeout(),
) as signed_resp:
if signed_resp.status != 200:
text = await signed_resp.text()
raise ComfyUIHTTPError(signed_resp.status, text or f"Failed to retrieve {media_label}")
return await self._read_comfy_media_response(
signed_resp,
media_info,
media_label,
max_mb,
max_bytes,
)
if resp.status != 200:
text = await resp.text()
if resp.status in (502, 503, 504):
logger.warning("ComfyUI %s retrieve temporary failure (HTTP %s), filename=%s: %s", media_label, resp.status, filename, text[:500])
else:
logger.error("ComfyUI %s retrieve failed (HTTP %s), filename=%s: %s", media_label, resp.status, filename, text[:500])
raise ComfyUIHTTPError(resp.status, text or f"Failed to retrieve {media_label}")
return await self._read_comfy_media_response(
resp,
media_info,
media_label,
max_mb,
max_bytes,
)
def _extract_image_info(self, history, expected_output_node_id=None):
return self._extract_media_info(history, expected_output_node_id, ("images",))
@staticmethod
def _output_key_aliases(output_key):
if output_key == "video":
return ("video", "videos", "animated", "animations", "images")
if output_key == "videos":
return ("videos", "video", "animated", "animations", "images")
if output_key == "gifs":
return ("gifs", "gif", "animated", "animations", "images")
if output_key == "animated":
return ("animated", "animations", "videos", "video", "gifs", "images")
return (output_key,)
@staticmethod
def _normalize_history_media_items(items):
if isinstance(items, dict):
return [items]
if isinstance(items, list):
return [item for item in items if isinstance(item, dict)]
return []
def _history_media_items(self, node_output, actual_key):
if not isinstance(node_output, dict):
return []
items = self._normalize_history_media_items(node_output.get(actual_key, []))
if items:
return items
ui = node_output.get("ui")
if isinstance(ui, dict):
return self._normalize_history_media_items(ui.get(actual_key, []))
return []
def _extract_media_info(self, history, expected_output_node_id=None, output_keys=None):
infos = self._extract_media_infos(history, expected_output_node_id, output_keys, first_only=True)
return infos[0] if infos else None
def _extract_media_infos(self, history, expected_output_node_id=None, output_keys=None, first_only=False):
output_keys = output_keys or ("videos", "video", "animated", "animations", "gifs", "images", "audio")
outputs = history.get("outputs", {})
prompt_id = None
prompt_data = history.get("prompt")
if isinstance(prompt_data, (list, tuple)) and len(prompt_data) > 1:
prompt_id = prompt_data[1]
if not prompt_id and isinstance(history.get("cloud_job"), dict):
prompt_id = history["cloud_job"].get("id")
found = []
def _append_items(node_output, output_key):
for actual_key in self._output_key_aliases(output_key):
items = self._history_media_items(node_output, actual_key)
if items:
for item in items:
info = dict(item)
info.setdefault("output_key", actual_key)
if prompt_id:
info.setdefault("prompt_id", str(prompt_id))
found.append(info)
return True
return False
if expected_output_node_id and expected_output_node_id in outputs:
for output_key in output_keys:
if _append_items(outputs[expected_output_node_id], output_key) and first_only:
return found[:1]
if found:
return found
for node_id, node_output in outputs.items():
for output_key in output_keys:
if _append_items(node_output, output_key) and first_only:
return found[:1]
return found
def _extract_image_infos(self, history, expected_output_node_id=None):
return self._extract_media_infos(history, expected_output_node_id, ("images",))
@staticmethod
def _history_output_summary(history):
outputs = history.get("outputs", {}) if isinstance(history, dict) else {}
summary = {}
for node_id, node_output in outputs.items():
if not isinstance(node_output, dict):
continue
keys = [key for key, value in node_output.items() if value]
ui = node_output.get("ui")
if isinstance(ui, dict):
keys.extend(f"ui.{key}" for key, value in ui.items() if value)
summary[str(node_id)] = keys[:12]
return summary
@staticmethod
def _media_extension(media_info, media_label="media"):
filename = str((media_info or {}).get("filename") or "")
if "." in filename:
return filename.rsplit(".", 1)[-1].lower()
fmt = str((media_info or {}).get("format") or "").lower()
if "webp" in fmt:
return "webp"
if "gif" in fmt:
return "gif"
if "mp4" in fmt or "h264" in fmt or "h265" in fmt:
return "mp4"
if "webm" in fmt:
return "webm"
if "wav" in fmt:
return "wav"
if "audio" in fmt:
return "wav"
if media_label == "image":
return "png"
return "bin"
@classmethod
def _media_kind_from_info(cls, media_info, default="media"):
ext = cls._media_extension(media_info, default)
fmt = str((media_info or {}).get("format") or "").lower()
output_key = str((media_info or {}).get("output_key") or "").lower()
if (
ext in {"mp4", "webm", "mkv", "mov", "avi", "m4v", "gif"}
or any(token in fmt for token in ("video", "h264", "h265", "mp4", "webm", "gif"))
or output_key in {"videos", "video", "gifs", "gif", "animated", "animations"}
):
return "video"
if ext in {"wav", "mp3", "ogg", "flac"} or "audio" in fmt or output_key == "audio":
return "audio"
if ext in {"png", "jpg", "jpeg", "webp", "bmp"} or output_key in {"images", "image"}:
return "image"
return default or "media"
@classmethod
def _telegram_photo_supported(cls, media_info):
ext = cls._media_extension(media_info, "image")
fmt = str((media_info or {}).get("format") or "").lower()
if ext not in {"png", "jpg", "jpeg", "webp"}:
return False
if any(token in fmt for token in ("exr", "float", "16", "32")):
return False
return True
def _next_node_id(self, workflow):
max_id = 0
for k in workflow.keys():
try:
max_id = max(max_id, int(k))
except (ValueError, TypeError):
pass
return str(max_id + 1)
async def _get_models_for_workflow_field(self, field):
if self._is_comfy_cloud():
cloud_fields = await self._get_cloud_available_models_by_field()
if field in ("diffusion_model", "diffusion_model_name"):
return cloud_fields.get("unet_name", [])
return cloud_fields.get(field, [])
if field == "ckpt_name":
return self._parse_object_info_list(
await self._get_object_info("CheckpointLoaderSimple"),
"CheckpointLoaderSimple",
"ckpt_name",
)
if field in ("unet_name", "diffusion_model", "diffusion_model_name"):
models = []
for class_type in ("UNETLoader", "UnetLoaderGGUF"):
models.extend(
self._parse_object_info_list(
await self._get_object_info(class_type),
class_type,
"unet_name",
)
)
return list(dict.fromkeys(models))
if field in ("patch_name", "model_patch", "model_patch_name"):
return (await self._get_available_models_by_field()).get(field, [])
return []
def _cloud_model_field_from_folder(self, folder):
folder_l = str(folder or "").lower()
parts = [part for part in folder_l.split("/") if part]
root = parts[0] if parts else folder_l
if root in ("checkpoints", "checkpoint", "ckpt", "ckpts"):
return "ckpt_name"
if root in ("unet", "unets", "diffusion_models", "diffusion_model") or "unet" in parts:
return "unet_name"
if root in ("vae", "vaes") or "vae" in parts:
return "vae_name"
if (
root in ("clip", "clips", "text_encoders", "text_encoder")
or "text_encoder" in parts
or "text_encoders" in parts
or ("clip" in folder_l and "vision" not in folder_l)
):
return "clip_name"
if "lora" in folder_l:
return "lora_name"
if (
"upscale" in folder_l
or root in ("bbox", "sam", "sams", "sam2", "sam3", "ultralytics", "rembg", "clip_vision", "ipadapter", "style_models", "llm")
or "yolo" in folder_l
or "sam" in folder_l
):
return "model_name"
if "patch" in folder_l:
return "patch_name"
return None
async def _get_cloud_models_folder(self, folder):
return await self._get_cloud_models_folder_for_scope(folder, authenticated=True)
async def _get_cloud_model_folders(self, authenticated=True):
scope = "auth" if authenticated else "public"
cache_key = f"cloud_model_folders:{scope}"
if cache_key in self._comfy_cache:
return self._comfy_cache[cache_key]
kwargs = {"timeout": aiohttp.ClientTimeout(total=20)}
if authenticated:
kwargs["headers"] = self._comfy_headers()
try:
async with self._session_get(
f"{_COMFY_CLOUD_BASE_URL}/api/experiment/models",
**kwargs,
) as resp:
if resp.status != 200:
text = await resp.text()
logger.debug("Cloud model folders failed (HTTP %s): %s", resp.status, text[:300])
return []
data = await resp.json(content_type=None)
except Exception as e:
logger.debug("Cloud model folders failed: %s", e)
return []
folders = []
if isinstance(data, list):
for item in data:
if isinstance(item, dict):
name = item.get("name")
paths = item.get("folders") if isinstance(item.get("folders"), list) else []
else:
name = item
paths = []
name = str(name or "").strip()
if not name:
continue
folders.append({"name": name, "folders": [str(path) for path in paths if path]})
folders = sorted(folders, key=lambda item: item["name"].lower())
self._comfy_cache[cache_key] = folders
return folders
async def _get_cloud_models_folder_for_scope(self, folder, authenticated=True):
scope = "auth" if authenticated else "public"
cache_key = f"cloud_models:{scope}:{folder}"
if cache_key in self._comfy_cache:
return self._comfy_cache[cache_key]
folder_path = quote(str(folder or "").strip(), safe="/")
if not folder_path:
return []
kwargs = {"timeout": aiohttp.ClientTimeout(total=20)}
if authenticated:
kwargs["headers"] = self._comfy_headers()
try:
async with self._session_get(
f"{_COMFY_CLOUD_BASE_URL}/api/experiment/models/{folder_path}",
**kwargs,
) as resp:
if resp.status != 200:
text = await resp.text()
logger.debug("Cloud models folder %s failed (HTTP %s): %s", folder, resp.status, text[:300])
return []
data = await resp.json(content_type=None)
except Exception as e:
logger.debug("Cloud models folder %s failed: %s", folder, e)
return []
models = []
if isinstance(data, list):
for item in data:
if isinstance(item, dict):
name = item.get("name")
else:
name = item
if name:
models.append(str(name))
models = sorted(dict.fromkeys(models))
self._comfy_cache[cache_key] = models
return models
@staticmethod
def _cloud_asset_model_name(asset):
if not isinstance(asset, dict):
return None
metadata = asset.get("user_metadata") if isinstance(asset.get("user_metadata"), dict) else {}
name = metadata.get("filename") or asset.get("name")
name = str(name or "").strip()
return name or None
def _cloud_asset_model_folder(self, asset):
metadata = asset.get("user_metadata") if isinstance(asset.get("user_metadata"), dict) else {}
folder = str(metadata.get("folder") or "").strip()
if folder:
return folder
type_id = str(metadata.get("type") or "").strip()
if type_id in _CDOWN_TYPES:
aliases = self._cdown_type_info(type_id).get("folder_aliases") or ()
if aliases:
return str(aliases[0])
tags = {str(tag).lower() for tag in (asset.get("tags") or [])}
tag_folders = (
("checkpoints", "checkpoints"),
("checkpoint", "checkpoints"),
("loras", "loras"),
("lora", "loras"),
("vae", "vae"),
("controlnet", "controlnet"),
("upscale_models", "upscale_models"),
("upscale_model", "upscale_models"),
("embedding", "embeddings"),
("text_encoders", "text_encoders"),
("text_encoder", "text_encoders"),
("diffusion_models", "diffusion_models"),
("unet", "diffusion_models"),
("clip_vision", "clip_vision"),
("ipadapter", "ipadapter"),
("style_models", "style_models"),
("style_model", "style_models"),
("model_patches", "model_patches"),
("model_patch", "model_patches"),
("sams", "sams"),
("sam", "sams"),
("llm", "LLM"),
)
for tag, candidate in tag_folders:
if tag in tags:
return candidate
return "models"
async def _get_cloud_user_model_asset_groups(self):
cache_key = "cloud_user_model_assets:groups"
if cache_key in self._comfy_cache:
return self._comfy_cache[cache_key]
assets = await self._clib_fetch_model_assets()
groups = {}
for asset in assets:
if not self._clib_is_model_asset(asset):
continue
name = self._cloud_asset_model_name(asset)
if not name:
continue
folder = self._cloud_asset_model_folder(asset)
groups.setdefault(folder, set()).add(name)
result = {
folder: sorted(models)
for folder, models in sorted(groups.items(), key=lambda item: item[0].lower())
if models
}
self._comfy_cache[cache_key] = result
return result
async def _get_cloud_available_models_by_field(self):
cache_key = "cloud_models:fields"
if cache_key in self._comfy_cache:
return self._comfy_cache[cache_key]
folders_data = await self._get_cloud_model_folders(authenticated=True)
fields = {}
if isinstance(folders_data, list):
for entry in folders_data:
if not isinstance(entry, dict):
continue
folder_name = str(entry.get("name") or "").strip()
field = self._cloud_model_field_from_folder(folder_name)
if not field:
continue
models = await self._get_cloud_models_folder(folder_name)
if models:
fields.setdefault(field, set()).update(models)
try:
user_groups = await self._get_cloud_user_model_asset_groups()
for folder_name, models in user_groups.items():
field = self._cloud_model_field_from_folder(folder_name)
if field and models:
fields.setdefault(field, set()).update(models)
except Exception as e:
logger.debug("Cloud imported model fields failed: %s", e)
if "unet_name" in fields:
fields["diffusion_model"] = set(fields["unet_name"])
fields["diffusion_model_name"] = set(fields["unet_name"])
result = {field: sorted(values) for field, values in fields.items() if values}
self._comfy_cache[cache_key] = result
return result
@staticmethod
def _is_model_filename(value):
value = str(value or "").strip()
return bool(
value
and re.search(
r"\.(safetensors|ckpt|pt|pth|bin|gguf|onnx)$",
value,
re.IGNORECASE,
)
)
async def _get_available_models_by_field(self):
if self._is_comfy_cloud():
return await self._get_cloud_available_models_by_field()
fields = {
"ckpt_name": set(await self._get_models_for_workflow_field("ckpt_name")),
"unet_name": set(await self._get_models_for_workflow_field("unet_name")),
}
fields["diffusion_model"] = set(fields["unet_name"])
fields["diffusion_model_name"] = set(fields["unet_name"])
object_info = await self._get_all_object_info()
if isinstance(object_info, dict):
for class_type, info in object_info.items():
class_l = str(class_type).lower()
if "patch" not in class_l:
continue
input_data = info.get("input", {}) if isinstance(info, dict) else {}
candidates = {}
for section in ("required", "optional"):
values = input_data.get(section, {})
if isinstance(values, dict):
candidates.update(values)
for field, raw in candidates.items():
field_l = str(field).lower()
if (
field not in ("patch_name", "model_patch", "model_patch_name", "model_name")
and "patch" not in field_l
and "model" not in field_l
):
continue
values = []
if isinstance(raw, list) and raw and isinstance(raw[0], list):
values = [
item
for item in raw[0]
if isinstance(item, str) and self._is_model_filename(item)
]
elif isinstance(raw, list):
values = [
item
for item in raw
if isinstance(item, str) and self._is_model_filename(item)
]
if values:
fields.setdefault(field, set()).update(values)
return {field: sorted(values) for field, values in fields.items() if values}
@staticmethod
def _model_field_group(field):
if field == "ckpt_name":
return "checkpoint"
if field in ("unet_name", "diffusion_model", "diffusion_model_name"):
return "unet"
if field == "vae_name":
return "vae"
if field in ("patch_name", "model_patch", "model_patch_name") or "patch" in str(field).lower():
return "patch"
return field
@classmethod
def _model_field_is_compatible(cls, selected_fields, target_field):
target_group = cls._model_field_group(target_field)
return any(cls._model_field_group(field) == target_group for field in selected_fields)
@staticmethod
def _model_match_key(model):
model = str(model or "").strip()
if not model:
return ""
model = model.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
model = re.sub(
r"\.(safetensors|ckpt|pt|pth|bin|gguf|onnx)$",
"",
model,
flags=re.IGNORECASE,
)
return model.lower()
@classmethod
def _resolve_model_name_from_fields(cls, model, field_models, target_field=None):
model = str(model or "").strip()
if not model or not isinstance(field_models, dict):
return None
target_group = cls._model_field_group(target_field) if target_field else None
stem = cls._model_match_key(model)
stem_matches = []
for field, models in field_models.items():
if target_group and cls._model_field_group(field) != target_group:
continue
if not isinstance(models, (list, tuple, set)):
continue
for candidate in models:
candidate = str(candidate or "").strip()
if not candidate:
continue
if candidate == model:
return candidate
if stem and cls._model_match_key(candidate) == stem:
stem_matches.append(candidate)
stem_matches = sorted(dict.fromkeys(stem_matches))
return stem_matches[0] if len(stem_matches) == 1 else None
async def _resolve_available_model_name(self, model, target_field=None):
field_models = await self._get_available_models_by_field()
if not field_models:
return None
return self._resolve_model_name_from_fields(model, field_models, target_field)
async def _get_selected_model_fields(self, model):
field_models = await self._get_available_models_by_field()
if not field_models:
return None
resolved_model = self._resolve_model_name_from_fields(model, field_models)
candidates = {str(model or "").strip()}
if resolved_model:
candidates.add(resolved_model)
return {
field
for field, models in field_models.items()
if any(candidate in models for candidate in candidates if candidate)
}
def _get_workflow_primary_model(self, wf_data):
mapping = wf_data.get("mapping", {}) if isinstance(wf_data, dict) else {}
model_map = mapping.get("model")
if not model_map:
return None
workflow = wf_data.get("workflow", {}) if isinstance(wf_data, dict) else {}
return (
workflow
.get(model_map.get("node_id"), {})
.get("inputs", {})
.get(model_map.get("field"))
)
def _resolve_generation_model(self, wf_data):
if self._is_comfy_cloud():
if self._cloud_model_as_workflow():
return self._get_workflow_primary_model(wf_data)
configured_model = str(self.config["model_name"] or "").strip()
return configured_model or self._get_workflow_primary_model(wf_data)
configured_model = str(self.config["model_name"] or "").strip()
if configured_model:
return configured_model
return None
def _cloud_model_as_workflow(self):
return bool(self.get("cloud_model_as_workflow", True))
def _set_cloud_model_as_workflow(self, enabled):
self.set("cloud_model_as_workflow", bool(enabled))
def _current_workflow_model(self):
wf_name = self._canonical_workflow_name(
self.get("default_workflow", _DEFAULT_WORKFLOW_NAME)
)
wf_data = self._get_workflow_data(wf_name)
if not wf_data:
return None
return self._get_workflow_primary_model(wf_data)
async def _autoswitch_model_to_workflow(self, wf_name):
if not self._workflow_model_autoswitch_enabled():
if self._is_comfy_cloud():
self._set_cloud_model_as_workflow(False)
return False
try:
wf_data = await self._ensure_workflow_data(wf_name)
except Exception as e:
logger.debug("Workflow model auto-switch failed: %s", e)
return False
if not isinstance(wf_data, dict):
return False
model = str(self._get_workflow_primary_model(wf_data) or "").strip()
if not model:
return False
if self._is_comfy_cloud():
self._set_cloud_model_as_workflow(True)
return True
self.config["model_name"] = model
return True
async def _resolve_workflow_model_for_node(self, workflow, item, model, selected_fields, force_selected_model=False):
nid = item.get("node_id")
field = item.get("field")
original_model = (
workflow
.get(nid, {})
.get("inputs", {})
.get(field, "")
)
if not model:
return original_model
resolved_available_model = await self._resolve_available_model_name(model, field)
if resolved_available_model:
model = resolved_available_model
if selected_fields is not None and not selected_fields:
selected_fields = await self._get_selected_model_fields(model)
if selected_fields is None:
return model
if selected_fields and self._model_field_is_compatible(selected_fields, field):
return model
if (
not selected_fields
and self._is_model_filename(model)
and (
self._model_field_group(field) == "patch"
or (
self._model_field_group(field) not in ("checkpoint", "unet", "vae")
and self._is_model_filename(original_model)
)
)
):
return model
if force_selected_model:
if selected_fields and self._model_field_is_compatible(selected_fields, field):
return model
if not selected_fields and self._is_model_filename(model):
return model
return original_model or None
return original_model or None
async def _prepare_workflow(self, wf_data, positive, negative, seed, width, height, model, denoise, parsed_steps, parsed_cfg, sampler_name=None, scheduler=None, input_filename=None, wf_name=None, limited_mode=False, input_video_filename=None):
workflow = json.loads(json.dumps(wf_data["workflow"]))
mapping = wf_data["mapping"]
final_output_node_for_extraction = None
if self._impact_wildcard_select_text:
for nid, node in workflow.items():
if node.get("class_type") == "ImpactWildcardProcessor":
if "Select to add Wildcard" in node.get("inputs", {}):
workflow[nid]["inputs"]["Select to add Wildcard"] = self._impact_wildcard_select_text
elif "DetailerPipe" in node.get("class_type", ""):
if "Select to add Wildcard" in node.get("inputs", {}):
workflow[nid]["inputs"]["Select to add Wildcard"] = self._impact_wildcard_select_text
pos_map = mapping.get("positive")
if pos_map and positive is not None:
nid = pos_map["node_id"]
fields = pos_map.get("fields") or pos_map.get("field")
if isinstance(fields, str):
fields = [fields]
if not isinstance(fields, list):
fields = []
if nid in workflow:
for field in fields:
workflow[nid]["inputs"][field] = positive
if "wildcard_text" in fields and "populated_text" in workflow[nid]["inputs"]:
workflow[nid]["inputs"]["populated_text"] = positive
neg_map = mapping.get("negative")
if neg_map and negative is not None:
nid = neg_map["node_id"]
fields = neg_map.get("fields") or neg_map.get("field")
if isinstance(fields, str):
fields = [fields]
if not isinstance(fields, list):
fields = []
if nid in workflow:
for field in fields:
workflow[nid]["inputs"][field] = negative
if "wildcard_text" in fields and "populated_text" in workflow[nid]["inputs"]:
workflow[nid]["inputs"]["populated_text"] = negative
model_map = mapping.get("model")
if model_map and model:
selected_model_fields = await self._get_selected_model_fields(model)
model_nodes = mapping.get("model_nodes") or [model_map]
for item in model_nodes:
nid = item.get("node_id")
field = item.get("field")
node = workflow.get(nid, {})
class_type = str(node.get("class_type", ""))
if any(token in class_type.lower() for token in ("vae", "clip", "sam", "upscale", "lora")):
continue
if nid in workflow and field in node.get("inputs", {}):
force_selected_model = (
item.get("node_id") == model_map.get("node_id")
and item.get("field") == model_map.get("field")
)
resolved_model = await self._resolve_workflow_model_for_node(
workflow,
item,
model,
selected_model_fields,
force_selected_model=force_selected_model,
)
if resolved_model:
workflow[nid]["inputs"][field] = resolved_model
sam_model_map = mapping.get("sam_model")
if sam_model_map and self._available_sam_models:
nid = sam_model_map["node_id"]
field = sam_model_map["field"]
if nid in workflow and field in workflow[nid]["inputs"]:
desired = workflow[nid]["inputs"][field]
desired_prefix = desired.rsplit(".", 1)[0] if "." in desired else desired
found = None
if desired in self._available_sam_models:
found = desired
else:
for m in self._available_sam_models:
if m.startswith(desired_prefix):
found = m
break
if found:
workflow[nid]["inputs"][field] = found
seed_map = mapping.get("seed")
if seed_map and not limited_mode:
nid = seed_map["node_id"]
field = seed_map["field"]
if nid in workflow:
workflow[nid]["inputs"][field] = seed if seed is not None else random.randint(1, 2**44)
width_map = mapping.get("width")
if width_map and width is not None:
nid = width_map["node_id"]
field = width_map["field"]
if nid in workflow and not self._is_workflow_link(workflow[nid].get("inputs", {}).get(field)):
workflow[nid]["inputs"][field] = width
height_map = mapping.get("height")
if height_map and height is not None:
nid = height_map["node_id"]
field = height_map["field"]
if nid in workflow and not self._is_workflow_link(workflow[nid].get("inputs", {}).get(field)):
workflow[nid]["inputs"][field] = height
steps_map = mapping.get("steps")
if steps_map and parsed_steps is not None:
nid = steps_map["node_id"]
field = steps_map["field"]
if nid in workflow:
workflow[nid]["inputs"][field] = parsed_steps
cfg_map = mapping.get("cfg")
if cfg_map and parsed_cfg is not None:
nid = cfg_map["node_id"]
field = cfg_map["field"]
if nid in workflow:
workflow[nid]["inputs"][field] = parsed_cfg
sampler_map = mapping.get("sampler_name")
if sampler_map and sampler_name:
nid = sampler_map["node_id"]
field = sampler_map["field"]
if nid in workflow:
workflow[nid]["inputs"][field] = sampler_name
scheduler_map = mapping.get("scheduler")
if scheduler_map and scheduler:
nid = scheduler_map["node_id"]
field = scheduler_map["field"]
if nid in workflow:
workflow[nid]["inputs"][field] = scheduler
flux_guidance_map = mapping.get("flux_guidance")
if flux_guidance_map and parsed_cfg is not None:
nid = flux_guidance_map["node_id"]
field = flux_guidance_map["field"]
if nid in workflow:
workflow[nid]["inputs"][field] = parsed_cfg
megapixels_map = mapping.get("megapixels")
if megapixels_map and width is not None and height is not None:
megapixels_nodes = mapping.get("megapixels_nodes") or [megapixels_map]
megapixels_value = round((width * height) / 1_000_000, 3)
for item in megapixels_nodes:
nid = item.get("node_id")
field = item.get("field")
if nid in workflow and field in workflow[nid].get("inputs", {}):
workflow[nid]["inputs"][field] = megapixels_value
denoise_map = mapping.get("denoise")
denoise_to_use = denoise
if limited_mode:
denoise_to_use = None
if denoise_map and denoise_to_use is not None:
nid = denoise_map["node_id"]
field = denoise_map["field"]
if nid in workflow:
workflow[nid]["inputs"][field] = denoise_to_use
if input_filename:
input_image_map = mapping.get("input_image")
input_image_maps = mapping.get("input_images") or []
if input_image_map and input_image_map not in input_image_maps:
input_image_maps = [input_image_map, *input_image_maps]
handled_input_image = False
for input_image_map in input_image_maps:
if not input_image_map or input_image_map.get("node_id") not in workflow:
continue
if input_image_map.get("expects_link"):
load_image_node_id = self._next_node_id(workflow)
workflow[load_image_node_id] = {
"inputs": {"image": input_filename},
"class_type": "LoadImage",
"_meta": {"title": "Load Image (Dynamic)"},
}
workflow[input_image_map["node_id"]]["inputs"][input_image_map["field"]] = [load_image_node_id, 0]
else:
workflow[input_image_map["node_id"]]["inputs"][input_image_map["field"]] = input_filename
handled_input_image = True
if handled_input_image:
latent_switch_map = mapping.get("latent_switch")
if latent_switch_map:
nid = latent_switch_map["node_id"]
if nid in workflow:
workflow[nid]["inputs"]["select"] = 2
else:
latent_switch_map = mapping.get("latent_switch")
if not latent_switch_map or latent_switch_map.get("node_id") not in workflow:
raise ValueError("img2img unsupported by workflow")
load_image_node_id = self._next_node_id(workflow)
workflow[load_image_node_id] = {
"inputs": {"image": input_filename},
"class_type": "LoadImage",
"_meta": {"title": "Load Image (Dynamic)"},
}
vae_encode_node_id = self._next_node_id(workflow)
vae_output_node_info = mapping.get("vae_output_node")
vae_source_node_id = None
vae_source_output_index = 2
if vae_output_node_info:
vae_source_node_id = vae_output_node_info["node_id"]
vae_source_output_index = vae_output_node_info.get("output_index", 2)
else:
for nid, node in workflow.items():
if node.get("class_type") == "CheckpointLoaderSimple":
vae_source_node_id = nid
break
if not vae_source_node_id:
logger.error("Could not determine VAE source node for i2i workflow")
raise ValueError("VAE source not found")
workflow[vae_encode_node_id] = {
"inputs": {
"pixels": [load_image_node_id, 0],
"vae": [str(vae_source_node_id), vae_source_output_index],
},
"class_type": "VAEEncode",
"_meta": {"title": "VAE Encode (Dynamic)"},
}
nid = latent_switch_map["node_id"]
workflow[nid]["inputs"]["select"] = 2
workflow[nid]["inputs"]["input2"] = [vae_encode_node_id, 0]
if input_video_filename:
input_video_map = mapping.get("input_video")
if input_video_map and input_video_map.get("node_id") in workflow:
workflow[input_video_map["node_id"]]["inputs"][input_video_map["field"]] = input_video_filename
else:
raise ValueError("video input unsupported by workflow")
workflow = await self._materialize_global_inputs(workflow)
if final_output_node_for_extraction is None:
if mapping.get("output_kind") in ("video", "mixed") and mapping.get("output_video"):
final_output_node_for_extraction = mapping["output_video"]["node_id"]
elif mapping.get("output_regular"):
final_output_node_for_extraction = mapping["output_regular"]["node_id"]
elif mapping.get("output_upscaled"):
final_output_node_for_extraction = mapping["output_upscaled"]["node_id"]
elif mapping.get("output"):
final_output_node_for_extraction = mapping["output"]["node_id"]
return workflow, final_output_node_for_extraction
def _apply_cloud_batch_size(self, workflow, batch_size):
if not self._is_comfy_cloud():
return workflow
batch_size = self._coerce_int(batch_size, 1, 1, 8)
for node in (workflow or {}).values():
if not isinstance(node, dict):
continue
inputs = node.get("inputs")
if isinstance(inputs, dict) and "batch_size" in inputs:
inputs["batch_size"] = batch_size
return workflow
_REFUSAL_PHRASES = (
"i cannot", "i can't", "i'm sorry", "i apologize", "i'm unable",
"i am unable", "i am not able", "against my", "not appropriate",
"i must decline", "i'm not able", "as an ai", "as a language model",
"i'm afraid", "content policy", "violates", "inappropriate",
"i won't", "i will not", "i do not", "i don't", "cannot assist",
"cannot help", "cannot fulfill", "cannot comply", "cannot generate",
"cannot create", "not able to", "not going to", "goes against",
"against policy", "ethical guidelines", "safety guidelines",
"harmful content", "explicit content", "sexual content",
"not comfortable", "decline to", "refuse to", "unable to assist",
"unable to help", "unable to generate", "unable to create",
"beyond my capabilities", "outside my", "my guidelines",
"my programming", "my ethical", "responsible ai",
"я не могу", "к сожалению", "не в состоянии", "извините",
"не могу помочь", "не могу создать", "не могу сгенерировать",
"против правил", "нарушает", "неприемлем", "недопустим",
"отказываюсь", "не имею права", "мне не разрешено",
)
_STRONG_REFUSAL_PHRASES = (
"я не могу обработать",
"не могу обработать этот запрос",
"не могу выполнить этот запрос",
"не могу помочь с этим запросом",
"противоречит принципам безопасности",
"принципам безопасности и этики",
"незаконной и вредной деятельностью",
"содержит контент, связанный с незаконной",
"опасной деятельностью",
"вредной деятельностью",
"я не могу создать",
"я не могу сгенерировать",
"я не могу помочь",
"я не могу выполнить",
"не могу обработать",
"не могу создать",
"не могу сгенерировать",
"не могу предоставить",
"не могу продолжить",
"не могу способствовать",
"я не могу",
"i can't process this request",
"i cannot process this request",
"i can't comply with this request",
"i cannot comply with this request",
"i can't help with this request",
"i cannot help with this request",
"safety and ethics",
"illegal and harmful activity",
"harmful illegal activity",
"disallowed content",
"i can't create",
"i cannot create",
"i can't generate",
"i cannot generate",
)
def _is_refusal_response(self, text):
lower = str(text or "").lower().strip()
if any(phrase in lower for phrase in self._STRONG_REFUSAL_PHRASES):
return True
matches = sum(1 for phrase in self._REFUSAL_PHRASES if phrase in lower)
return matches >= 2
@staticmethod
def _workflow_node(workflow, node_id):
if not isinstance(workflow, dict):
return None
return workflow.get(str(node_id)) or workflow.get(node_id)
def _comfy_text_prepare_workflow(self, workflow, user_prompt, model_name, input_filename=None):
workflow = self._normalize_workflow_format(json.loads(json.dumps(workflow)))
text_node_id = None
fallback_output_node_id = None
for node_id, node in workflow.items():
if not isinstance(node, dict):
continue
class_type = node.get("class_type")
inputs = node.get("inputs")
if class_type == "PreviewAny" and fallback_output_node_id is None:
fallback_output_node_id = str(node_id)
if class_type != "TextGenerate" or not isinstance(inputs, dict):
continue
prompt = inputs.get("prompt")
if not isinstance(prompt, str) or "USER_PROMPT" not in prompt or "TARGET_MODEL" not in prompt:
continue
inputs["prompt"] = prompt.replace(
"TARGET_MODEL", str(model_name or "unknown").strip()[:300]
).replace("USER_PROMPT", str(user_prompt or "").strip()[:8000])
clip_link = inputs.get("clip")
clip_node_id = str(clip_link[0]) if isinstance(clip_link, (list, tuple)) and len(clip_link) >= 2 else None
clip_node = self._workflow_node(workflow, clip_node_id)
clip_inputs = clip_node.get("inputs") if isinstance(clip_node, dict) else {}
if (
isinstance(clip_inputs, dict)
and clip_inputs.get("clip_name") == _COMFY_TEXT_CLIP_NAME
and clip_inputs.get("type") == "ideogram4"
and not inputs["prompt"].lstrip().startswith("<|im_start|>")
):
inputs["use_default_template"] = True
inputs["thinking"] = True
text_node_id = str(node_id)
break
if not text_node_id:
raise UserFacingError("comfy_text_invalid_workflow")
output_node_id = fallback_output_node_id
for node_id, node in workflow.items():
if not isinstance(node, dict) or node.get("class_type") != "PreviewAny":
continue
source = (node.get("inputs") or {}).get("source")
if isinstance(source, (list, tuple)) and len(source) >= 2 and str(source[0]) == text_node_id:
output_node_id = str(node_id)
break
if not output_node_id:
raise UserFacingError("comfy_text_invalid_workflow")
text_node = self._workflow_node(workflow, text_node_id)
inputs = text_node["inputs"]
image_link = inputs.get("image")
image_node_id = str(image_link[0]) if isinstance(image_link, (list, tuple)) and len(image_link) >= 2 else None
if input_filename:
image_node = self._workflow_node(workflow, image_node_id) if image_node_id else None
if not isinstance(image_node, dict) or image_node.get("class_type") != "LoadImage":
image_node_id = self._next_node_id(workflow)
workflow[image_node_id] = {
"inputs": {"image": input_filename},
"class_type": "LoadImage",
"_meta": {"title": "Load Image (Dynamic)"},
}
inputs["image"] = [image_node_id, 0]
else:
image_node.setdefault("inputs", {})["image"] = input_filename
else:
inputs.pop("image", None)
if image_node_id:
still_used = any(
isinstance(value, (list, tuple))
and len(value) >= 2
and str(value[0]) == image_node_id
for node in workflow.values()
if isinstance(node, dict)
for value in (node.get("inputs") or {}).values()
)
if not still_used:
workflow.pop(image_node_id, None)
return workflow, output_node_id, text_node_id
@staticmethod
def _comfy_text_history_values(value):
if isinstance(value, str):
return [value]
if isinstance(value, (list, tuple)):
result = []
for item in value:
result.extend(ComfyImageGenMod._comfy_text_history_values(item))
return result
if not isinstance(value, dict):
return []
result = []
for key in ("text", "generated_text", "result", "value", "source"):
if key in value:
result.extend(ComfyImageGenMod._comfy_text_history_values(value[key]))
return result
@staticmethod
def _comfy_text_error_detail(error):
if isinstance(error, ComfyUIHTTPError):
return f"ComfyUI HTTP {error.status}"
if isinstance(error, ComfyUIExecutionError):
try:
payload = json.loads(str(error))
except (TypeError, ValueError):
payload = {}
if isinstance(payload, dict):
message = payload.get("exception_message") or payload.get("message") or payload.get("error")
node_type = payload.get("node_type") or payload.get("class_type")
parts = [str(item) for item in (node_type, message) if item]
if parts:
return ": ".join(parts)
return "ComfyUI execution error"
text = re.sub(r"\s+", " ", str(error or "")).strip()
return text[:240] or type(error).__name__
@classmethod
def _extract_comfy_text_result(cls, history, output_node_id, text_node_id=None):
outputs = history.get("outputs") if isinstance(history, dict) else None
if not isinstance(outputs, dict):
return ""
node_ids = (output_node_id, text_node_id, "6")
checked = set()
for node_id in node_ids:
if node_id is None or str(node_id) in checked:
continue
checked.add(str(node_id))
output = outputs.get(str(node_id)) or outputs.get(node_id)
if not isinstance(output, dict):
continue
values = cls._comfy_text_history_values(output.get("ui"))
if not values:
values = cls._comfy_text_history_values(output)
for value in values:
text = re.sub(r".*?", "", str(value), flags=re.IGNORECASE | re.DOTALL)
final_parts = re.findall(
r"(?:final\s+(?:answer|prompt)|final)\s*:\s*(.+)",
text,
flags=re.IGNORECASE | re.DOTALL,
)
if final_parts:
text = final_parts[-1]
text = re.sub(r"^(?:improved\s+prompt|enhanced\s+prompt|prompt)\s*:\s*", "", text.strip(), flags=re.IGNORECASE)
text = re.sub(r"^(?:assistant|model)\s*[:,-]?\s*", "", text.strip(), flags=re.IGNORECASE)
text = " ".join(text.strip("`\\\"'* ").split())
if text:
return " ".join(text.split()[:900])
return ""
async def _call_comfy_text_enhance(self, user_prompt, model_name, image_path=None):
if not self._base_url():
return None, "comfy_text_unavailable"
try:
source_workflow = await self._fetch_comfy_text_workflow()
input_filename = None
if image_path:
if not os.path.exists(image_path):
return None, "comfy_text_failed"
input_filename = await self._upload_input_path_to_comfyui(
image_path,
os.path.basename(image_path),
content_type=mimetypes.guess_type(image_path)[0],
)
workflow, output_node_id, text_node_id = self._comfy_text_prepare_workflow(
source_workflow,
user_prompt,
model_name,
input_filename=input_filename,
)
await self._raise_if_cloud_workflow_unsupported(workflow)
client_id = str(uuid.uuid4())
_, history = await self._wait_ws(
client_id,
lambda: self._retry(self._queue_prompt, workflow, client_id),
expected_output_node=None,
timeout=_COMFY_TEXT_ENHANCE_TIMEOUT,
workflow=workflow,
)
except asyncio.CancelledError:
raise
except asyncio.TimeoutError:
return None, "timeout"
except UserFacingError as e:
logger.warning("ComfyUI Text enhancement is unavailable: %s", e)
return None, e.key if e.key == "comfy_text_invalid_workflow" else "comfy_text_unavailable"
except Exception as e:
logger.warning("ComfyUI Text enhancement failed: %s", e)
return None, f"comfy_text_error:{self._comfy_text_error_detail(e)}"
result = self._extract_comfy_text_result(history, output_node_id, text_node_id)
return (result, None) if result else (None, "comfy_text_empty")
async def _enhance_prompt(self, user_prompt: str, model_name: str, image_path=None):
provider = self._get_prompt_provider()
prompt = str(user_prompt or "").strip()
if provider == _COMFY_TEXT_PROVIDER:
result, error = await self._call_comfy_text_enhance(prompt, model_name, image_path=image_path)
else:
system_prompt = await self._fetch_enhance_prompt(provider)
if not system_prompt:
return None, "error"
self._enhance_system_prompt = system_prompt
if provider == _COMFY_TEXT_PROVIDER:
pass
elif provider == "groq":
result, error = await self._call_groq_enhance(prompt, model_name)
elif provider == "openrouter":
result, error = await self._call_openrouter_enhance(prompt, model_name)
elif provider == "grok":
result, error = await self._call_grok_enhance(prompt, model_name)
elif provider == "qwen":
result, error = await self._call_qwen_enhance(
prompt,
model_name,
image_path=image_path,
)
elif provider == "deepseek":
if image_path:
return None, "vision_unsupported"
result, error = await self._call_deepseek_enhance(prompt, model_name, image_path=image_path)
elif provider == "nvidiaapi":
if image_path:
return None, "vision_unsupported"
result, error = await self._call_nvidiaapi_enhance(
prompt,
model_name,
image_path=image_path,
)
else:
result, error = await self._call_gemini_enhance(prompt, model_name)
if result:
if self._is_refusal_response(result):
return None, "censored"
return result, None
return None, error
async def _call_gemini_enhance(self, cleaned_prompt: str, model_name: str):
if not GENAI_AVAILABLE:
return None, "dependency_missing"
api_keys = self._get_provider_api_keys("gemini")
if not api_keys:
return None, "no_key"
last_error = None
for api_key in api_keys:
result, error = await self._call_gemini_enhance_with_key(cleaned_prompt, model_name, api_key)
if result or not self._enhance_error_rotates_key(error):
return result, error
last_error = error
logger.debug("Gemini API key failed with %s, trying next key", error)
return None, last_error or "error"
async def _call_gemini_enhance_with_key(self, cleaned_prompt: str, model_name: str, api_key: str):
try:
if not self._genai_client or self._genai_api_key != api_key:
self._genai_client = genai.Client(api_key=api_key)
self._genai_api_key = api_key
config = genai_types.GenerateContentConfig(
system_instruction=self._enhance_system_prompt,
temperature=1.0,
tools=[genai_types.Tool(google_search=genai_types.GoogleSearch())],
safety_settings=[
genai_types.SafetySetting(category=cat, threshold="BLOCK_NONE")
for cat in [
"HARM_CATEGORY_HARASSMENT",
"HARM_CATEGORY_HATE_SPEECH",
"HARM_CATEGORY_SEXUALLY_EXPLICIT",
"HARM_CATEGORY_DANGEROUS_CONTENT",
]
],
)
chat = self._genai_client.aio.chats.create(
model=self._get_gemini_model(),
config=config,
)
response = await asyncio.wait_for(
chat.send_message(
f"user_prompt: {cleaned_prompt}\ntarget_model: {model_name}"
),
timeout=60,
)
if response.text:
result = response.text.strip().strip('"').strip("'").strip("`")
if result:
if self._is_refusal_response(result):
return None, "censored"
return result, None
if hasattr(response, "candidates") and response.candidates:
finish_reason = getattr(response.candidates[0], "finish_reason", None)
if finish_reason and str(finish_reason) in ("SAFETY", "2", "FinishReason.SAFETY"):
return None, "censored"
return None, "error"
except asyncio.TimeoutError:
logger.warning("Gemini prompt enhancement timed out")
return None, "timeout"
except Exception as e:
logger.exception(e)
err_str = str(e).lower()
status = getattr(e, "status_code", None) or getattr(e, "code", None)
try:
status = int(status)
except (TypeError, ValueError):
status = None
if status == 429 or any(
marker in err_str
for marker in ("429", "quota", "rate limit", "resource_exhausted")
):
return None, "rate_limit"
if status in (401, 403) or any(
marker in err_str
for marker in (
"401",
"403",
"api key not valid",
"invalid api key",
"api_key_invalid",
"unauthenticated",
"permission denied",
)
):
return None, "expired"
return None, "error"
async def _call_openai_compatible_enhance(
self, cleaned_prompt: str, model_name: str, api_key: str, base_url: str, models_list: list, provider_name: str, image_path=None, extra_payload=None,
):
last_error = None
for model in models_list:
user_content = self._build_openai_compatible_user_content(
cleaned_prompt,
model_name,
image_path=image_path,
)
payload = {
"model": model,
"messages": [
{"role": "system", "content": self._enhance_system_prompt},
{"role": "user", "content": user_content},
],
"temperature": 1.0,
"max_tokens": 2048,
}
if isinstance(extra_payload, dict):
payload.update(extra_payload)
try:
async with self._session_post(
f"{base_url}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json=payload,
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status in (401, 403):
return None, "expired"
if resp.status in (402, 429):
last_error = "rate_limit"
continue
if resp.status != 200:
resp_text = await resp.text()
logger.debug("%s API error (model=%s, status=%s): %s", provider_name, model, resp.status, resp_text[:500])
resp_lower = resp_text.lower()
if any(marker in resp_lower for marker in ("invalid api key", "api key not valid", "api_key_invalid", "unauthenticated", "permission denied")):
return None, "expired"
if any(marker in resp_lower for marker in ("quota", "insufficient_quota", "billing", "balance", "resource_exhausted", "rate limit")):
return None, "rate_limit"
try:
err_data = json.loads(resp_text)
except json.JSONDecodeError:
err_msg = resp_text[:200]
else:
err_msg = resp_text[:200]
if isinstance(err_data, dict):
err_obj = err_data.get("error")
if isinstance(err_obj, dict):
err_msg = (
err_obj.get("message")
or err_obj.get("code")
or err_obj.get("type")
or err_msg
)
elif isinstance(err_obj, str):
err_msg = err_obj
else:
err_msg = (
err_data.get("message")
or err_data.get("detail")
or err_data.get("error_description")
or err_msg
)
elif isinstance(err_data, str):
err_msg = err_data
err_msg = str(err_msg)[:500]
if "not found" in resp_text.lower() or "decommissioned" in resp_text.lower() or "not available" in resp_text.lower():
last_error = f"model {model} unavailable"
continue
last_error = err_msg
continue
data = await resp.json()
content = (
data.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
)
if "" in content and "" in content:
content = content.split("", 1)[1]
result = content.strip().strip('"').strip("'").strip("`")
if result:
if self._is_refusal_response(result):
return None, "censored"
return result, None
return None, "censored"
except asyncio.TimeoutError:
last_error = "timeout"
continue
except Exception as e:
logger.exception(e)
err_str = str(e)
if self._enhance_error_rotates_key(err_str):
return None, err_str
last_error = err_str
continue
if last_error == "rate_limit":
return None, "rate_limit"
return None, last_error or "error"
async def _call_openai_compatible_enhance_with_keys(
self,
provider: str,
cleaned_prompt: str,
model_name: str,
base_url: str,
models_list: list,
provider_name: str,
image_path=None,
extra_payload=None,
):
api_keys = self._get_provider_api_keys(provider)
if not api_keys:
return None, "no_key"
last_error = None
for api_key in api_keys:
result, error = await self._call_openai_compatible_enhance(
cleaned_prompt,
model_name,
api_key,
base_url,
models_list,
provider_name,
image_path=image_path,
extra_payload=extra_payload,
)
if result or not self._enhance_error_rotates_key(error):
return result, error
last_error = error
logger.debug("%s API key failed with %s, trying next key", provider_name, error)
return None, last_error or "error"
def _build_openai_compatible_user_content(self, cleaned_prompt, model_name, image_path=None):
text = f"user_prompt: {cleaned_prompt}\ntarget_model: {model_name}"
if not image_path:
return text
try:
with open(image_path, "rb") as f:
raw = f.read()
except Exception as e:
logger.debug("Failed to read image for AI enhancement: %s", e)
return text
if not raw:
return text
mime = mimetypes.guess_type(image_path)[0] or "image/png"
data_url = f"data:{mime};base64,{base64.b64encode(raw).decode('ascii')}"
return [
{"type": "text", "text": text},
{"type": "image_url", "image_url": {"url": data_url}},
]
async def _call_groq_enhance(self, cleaned_prompt: str, model_name: str):
return await self._call_openai_compatible_enhance_with_keys(
"groq", cleaned_prompt, model_name,
"https://api.groq.com/openai/v1",
["llama-3.3-70b-versatile", "llama-3.1-8b-instant", "gemma2-9b-it"],
"Groq",
)
async def _call_openrouter_enhance(self, cleaned_prompt: str, model_name: str):
return await self._call_openai_compatible_enhance_with_keys(
"openrouter", cleaned_prompt, model_name,
"https://openrouter.ai/api/v1",
self._get_provider_model_chain("openrouter"),
"OpenRouter",
)
async def _call_grok_enhance(self, cleaned_prompt: str, model_name: str):
return await self._call_openai_compatible_enhance_with_keys(
"grok", cleaned_prompt, model_name,
"https://api.x.ai/v1",
self._get_provider_model_chain("grok"),
"Grok",
)
def _qwen_base_url(self):
base_url = str(self.config["qwen_base_url"] or "").strip().rstrip("/")
parsed = urlparse(base_url)
if parsed.scheme in {"http", "https"} and parsed.netloc:
return base_url
return _QWEN_DEFAULT_BASE_URL
async def _call_qwen_enhance(self, cleaned_prompt: str, model_name: str, image_path=None):
models = self._get_provider_model_chain("qwen")
if not models:
return None, "model is not set"
return await self._call_openai_compatible_enhance_with_keys(
"qwen",
cleaned_prompt,
model_name,
self._qwen_base_url(),
models,
"Qwen",
image_path=image_path,
extra_payload={"enable_thinking": False},
)
async def _call_deepseek_enhance(self, cleaned_prompt: str, model_name: str, image_path=None):
if image_path:
return None, "vision_unsupported"
models = self._get_provider_model_chain("deepseek")
if not models:
return None, "model is not set"
return await self._call_openai_compatible_enhance_with_keys(
"deepseek", cleaned_prompt, model_name,
"https://api.deepseek.com",
models,
"DeepSeek",
image_path=image_path,
)
async def _call_nvidiaapi_enhance(self, cleaned_prompt: str, model_name: str, image_path=None):
if image_path:
return None, "vision_unsupported"
models = self._get_provider_model_chain("nvidiaapi")
if not models:
return None, "model is not set"
return await self._call_openai_compatible_enhance_with_keys(
"nvidiaapi",
cleaned_prompt,
model_name,
"https://integrate.api.nvidia.com/v1",
models,
"NVIDIA API",
image_path=image_path,
)
def _format_prompt_for_display(self, prompt_text: str, truncate: bool = True, hidden: bool = False) -> str:
prompt_text = str(prompt_text or "")
if not prompt_text:
prompt_text = self.strings("prompt_empty")
if truncate and len(prompt_text) > 400:
prompt_text = f"{prompt_text[:397]}..."
escaped_prompt = utils.escape_html(prompt_text)
if hidden:
escaped_prompt = f"{escaped_prompt}"
return f"{escaped_prompt}
"
def _format_model_name(self, model_name: str, max_length=30) -> str:
if not model_name:
return "default"
name = re.sub(
r"\.(safetensors|ckpt|pt|pth|bin|gguf|onnx)$",
"",
str(model_name),
flags=re.IGNORECASE,
)
if max_length and len(name) > max_length:
name = name[:max_length - 3] + "..."
return name
def _format_lora_name(self, lora_name: str, max_length=32) -> str:
if not lora_name:
return "default"
name = re.sub(
r"\.(safetensors|ckpt|pt|pth|bin)$",
"",
str(lora_name),
flags=re.IGNORECASE,
)
if max_length and len(name) > max_length:
name = name[:max_length - 3] + "..."
return name
def _format_lora_text_for_display(self, lora_text: str) -> str:
lines = []
for line in str(lora_text or "").splitlines():
if ":" in line:
name, value = line.split(":", 1)
lines.append(f"{self._format_lora_name(name.strip(), max_length=None)}:{value}")
else:
lines.append(self._format_lora_name(line.strip(), max_length=None))
return "\n".join(line for line in lines if line)
def _format_ai_model_name(self, provider: str) -> str:
model = self._get_provider_model(provider)
return model or "default"
def _format_enhance_command_result(self, original_prompt: str, enhanced_prompt: str) -> str:
provider = self._get_prompt_provider()
lines = [
self.strings("enhance_cmd_title"),
"",
self.strings("enhance_cmd_provider").format(self._format_provider_name(provider)),
self.strings("enhance_cmd_model").format(utils.escape_html(self._format_ai_model_name(provider))),
"",
self.strings("enhance_cmd_original"),
self._format_prompt_for_display(original_prompt),
"",
self.strings("enhance_cmd_result"),
self._format_prompt_for_display(enhanced_prompt, truncate=False),
]
return "\n".join(lines)
async def _get_prompt_from_args_or_reply(self, message: Message):
args = utils.get_args_raw(message)
if args:
return args.strip()
reply = await message.get_reply_message()
if not reply:
return ""
return (getattr(reply, "raw_text", None) or reply.text or "").strip()
def _format_loras_for_display(self, selected_loras, is_inline=False):
if not selected_loras:
return None
if is_inline:
lora_emoji = '\U0001f3a8'
else:
lora_emoji = '\U0001f3a8'
items = []
for lora_name, weight in list(selected_loras.items())[:5]:
name = self._format_lora_name(lora_name)
try:
weight_text = f"{float(weight):.1f}"
except (TypeError, ValueError):
weight_text = str(weight)
items.append(f"{utils.escape_html(name)} [{utils.escape_html(weight_text)}]")
extra = len(selected_loras) - len(items)
if extra > 0:
items.append(utils.escape_html(self.strings("fmt_loras_more").format(extra)))
return f"{lora_emoji} {self.strings('fmt_loras')} " + ", ".join(items)
def _format_generation_time_value(self, seconds):
try:
seconds = float(seconds)
except (TypeError, ValueError):
return None
if seconds < 0:
return None
if seconds < 60:
return f"{seconds:.1f}s"
return self._format_duration(seconds)
@staticmethod
def _generation_stats_key(wf_name, model_name=None):
wf_key = str(wf_name or "default").strip() or "default"
model_key = str(model_name or "").strip()
if not model_key:
return wf_key
return f"{wf_key}::{model_key}"
def _duration_average_from_values(self, values):
if not isinstance(values, list) or not values:
return None
clean = []
for value in values[-_GENERATION_STATS_LIMIT:]:
try:
value = float(value)
except (TypeError, ValueError):
continue
if value > 0:
clean.append(value)
if not clean:
return None
return sum(clean) / len(clean)
def _get_generation_duration_average(self, wf_name, model_name=None):
stats = self.get("generation_duration_stats", {})
if not isinstance(stats, dict):
return None
avg = self._duration_average_from_values(
stats.get(self._generation_stats_key(wf_name, model_name))
)
if avg is not None:
return avg
return self._duration_average_from_values(
stats.get(self._generation_stats_key(wf_name))
)
def _record_generation_duration_stat(self, generation_state):
if not isinstance(generation_state, dict):
return
duration = generation_state.get("generation_duration")
try:
duration = float(duration)
except (TypeError, ValueError):
return
if duration <= 0:
return
stats = self.get("generation_duration_stats", {})
if not isinstance(stats, dict):
stats = {}
key = self._generation_stats_key(
generation_state.get("wf_name"),
generation_state.get("model"),
)
values = stats.get(key)
if not isinstance(values, list):
values = []
values.append(round(duration, 3))
stats[key] = values[-_GENERATION_STATS_LIMIT:]
self.set("generation_duration_stats", stats)
def _format_eta_value(self, seconds):
value = self._format_generation_time_value(seconds)
return f"~{value}" if value else None
def _estimate_generation_eta(self, generation_state, runtime=None, queue_info=None, progress_pct=None):
if not isinstance(generation_state, dict):
return None
runtime = runtime if isinstance(runtime, dict) else {}
queue_info = queue_info if isinstance(queue_info, dict) else {}
if queue_info.get("state") == "pending":
return None
if runtime.get("phase") not in ("running", "finishing", "uploading"):
return None
eta_total = runtime.get("eta_total")
eta_started_at = runtime.get("eta_started_at")
if eta_total is not None and eta_started_at is not None:
try:
remaining = float(eta_total) - (time.monotonic() - float(eta_started_at))
except (TypeError, ValueError):
remaining = None
if remaining is not None and remaining > 1:
return remaining
return None
avg = self._get_generation_duration_average(
generation_state.get("wf_name"),
generation_state.get("model"),
)
if not avg:
return None
runtime["eta_total"] = avg
runtime["eta_started_at"] = time.monotonic()
return avg
def _format_gen_text(self, prompt_display, model, wf_name, header, is_inline=False, selected_loras=None, generation_time=None, generation_eta=None, quote_details=False):
model_short = utils.escape_html(self._format_model_name(model))
if is_inline:
prompt_emoji = '\u270f\ufe0f'
model_emoji = '\U0001f36d'
wf_emoji = '\u2699\ufe0f'
time_emoji = '\u23f1'
else:
prompt_emoji = '\u270f\ufe0f'
model_emoji = '\U0001f36d'
wf_emoji = '\u2699\ufe0f'
time_emoji = '\u23f1'
lines = [
header,
f"{prompt_emoji} {self.strings('fmt_prompt')} {prompt_display}",
]
detail_lines = [
f"{model_emoji} {self.strings('fmt_model')} {model_short}",
f"{wf_emoji} {self.strings('fmt_workflow')} {utils.escape_html(wf_name)}",
]
if generation_time:
time_line = self.strings("fmt_generation_time").format(utils.escape_html(generation_time))
if generation_eta:
time_line = f"{time_line} / {self.strings('fmt_generation_eta').format(utils.escape_html(generation_eta))}"
detail_lines.append(f"{time_emoji} {time_line}")
elif generation_eta:
detail_lines.append(f"{time_emoji} {self.strings('fmt_generation_eta').format(utils.escape_html(generation_eta))}")
lora_display = self._format_loras_for_display(selected_loras, is_inline)
if lora_display:
detail_lines.append(lora_display)
if quote_details:
details = "\n".join(detail_lines)
lines.append(f"{details}
")
else:
lines.extend(detail_lines)
return self._apply_emoji_theme("\n".join(lines))
@staticmethod
def _ritual_progress(progress_pct):
if progress_pct >= 90:
return 666
if progress_pct >= 33:
return 66
return 6
def _pick_easter_egg(self, positive, width, height):
positive = str(positive or "")
lowered = positive.lower()
if width == 666 and height == 666:
return "ritual_666"
if any(term in lowered for term in ("backrooms", "empty mall", "yellow wallpaper")):
return "backrooms"
if len(positive) > 1100 and random.random() < 0.05:
return "long_prompt_rare"
if len(positive) > 1100:
return "long_prompt"
if 0 < len(positive.split()) <= 2 and random.random() < 0.25:
return "short_prompt"
if random.random() < 0.05:
return "noise_form"
return None
def _format_easter_header(self, easter_egg, is_progress=False, progress_pct=0):
if easter_egg == "ritual_666":
return self.strings("easter_ritual_progress").format(
self._ritual_progress(progress_pct)
)
if easter_egg == "backrooms":
return self.strings("easter_backrooms").format(progress_pct)
if easter_egg == "long_prompt":
header = self.strings("easter_long_prompt")
elif easter_egg == "long_prompt_rare":
header = self.strings("easter_long_prompt_rare")
elif easter_egg == "short_prompt":
header = self.strings("easter_short_prompt")
elif easter_egg == "noise_form":
header = self.strings("easter_noise_form")
else:
return None
if is_progress:
return f"{header} {progress_pct}%"
return header
def _normalize_node_title(self, title, class_type):
value = str(title or "").strip()
if value:
value = re.sub(r"^[^\wА-Яа-яЁё]+", "", value).strip()
if value:
return value
return str(class_type or "").strip()
def _get_node_status_info(self, workflow, node_id):
if not workflow or node_id is None:
return None
node = workflow.get(str(node_id))
if not isinstance(node, dict):
return None
class_type = str(node.get("class_type", "")).strip()
title = self._normalize_node_title(node.get("_meta", {}).get("title", ""), class_type)
title_lower = title.lower()
class_lower = class_type.lower()
key = None
show_progress = False
complete_progress = False
if class_type in ("CheckpointLoaderSimple", "UNETLoader", "VAELoader", "CLIPLoader", "UpscaleModelLoader", "SAMLoader", "UltralyticsDetectorProvider"):
key = "fmt_loading_model"
elif class_type in ("CLIPTextEncode", "ImpactWildcardProcessor", "WildcardEncode", "ConditioningConcat", "CLIPSetLastLayer"):
key = "fmt_encoding_prompt"
elif class_type in ("LoadImage", "VAEEncode", "EmptyLatentImage", "EmptySD3LatentImage", "SDXLEmptyLatentSizePicker+", "CR Aspect Ratio", "ImageScaleBy", "ImpactSwitch"):
key = "fmt_processing_image"
elif "sampler" in class_lower or self._is_sampler_like_node(class_type, node.get("inputs", {})):
key = "fmt_generating"
show_progress = True
elif class_type == "VAEDecode":
key = "fmt_decoding_image"
elif class_type in ("UltimateSDUpscale", "ImageUpscaleWithModel", "ImageScaleBy"):
key = "fmt_upscaling_image"
show_progress = "upscale" in class_lower or "upscale" in title_lower
elif class_type == "FaceDetailer":
key = "fmt_detailing_face"
show_progress = True
elif class_type == "SaveImage":
key = "fmt_saving_result"
complete_progress = True
elif class_type in ("Power Lora Loader (rgthree)", "CR LoRA Stack", "CR Apply LoRA Stack"):
key = "fmt_applying_lora"
elif title:
return {"text": self.strings("fmt_running_node").format(utils.escape_html(title)), "show_progress": False, "known": False}
if not key:
fallback = title or class_type or str(node_id)
return {"text": self.strings("fmt_running_node").format(utils.escape_html(fallback)), "show_progress": False, "known": False}
return {
"text": self.strings(key),
"show_progress": show_progress,
"complete_progress": complete_progress,
"known": True,
}
def _format_premium_progress_bar(self, progress_pct, is_inline=False):
if not self._self_has_premium:
return ""
progress_pct = self._coerce_int(progress_pct, 0, 0, 100)
filled_count = min(len(_PROGRESS_BAR_FILLED), progress_pct // 20)
def format_emoji(item):
emoji_id, char = item
if is_inline:
return self._inline_premium_emoji(emoji_id, char)
return f"{char}"
segments = [
format_emoji(item)
for item in _PROGRESS_BAR_FILLED[:filled_count]
]
segments.extend(
format_emoji(_PROGRESS_BAR_EMPTY)
for _ in range(len(_PROGRESS_BAR_FILLED) - filled_count)
)
return f"{format_emoji(_PROGRESS_BAR_ICON)} {''.join(segments)}"
def _format_status_text(self, prompt_display, model, wf_name, is_inline=False, is_progress=False, progress_pct=0, easter_egg=None, status_key=None, status_text=None, generation_time=None, generation_eta=None, show_progress_bar=False):
if is_inline:
gen_emoji = '\u2699\ufe0f'
else:
gen_emoji = '\u2699\ufe0f'
easter_header = self._format_easter_header(easter_egg, is_progress, progress_pct)
if status_text and is_progress:
header = f"{gen_emoji} {status_text} {progress_pct}%"
elif status_text:
header = f"{gen_emoji} {status_text}"
elif easter_header:
header = f"{gen_emoji} {easter_header}"
elif is_progress:
header = f"{gen_emoji} {self.strings('fmt_generating_pct').format(progress_pct)}"
elif status_key:
header = f"{gen_emoji} {self.strings(status_key)}"
else:
header = f"{gen_emoji} {self.strings('fmt_generating')}"
if is_progress or show_progress_bar or (
not status_text and not status_key and not easter_header
):
progress_bar = self._format_premium_progress_bar(progress_pct, is_inline)
if progress_bar:
header = f"{header}\n{progress_bar}"
return self._apply_emoji_theme(self._format_gen_text(
prompt_display,
model,
wf_name,
header,
is_inline,
generation_time=generation_time,
generation_eta=generation_eta,
quote_details=True,
))
def _format_success_text(self, prompt_display, model, wf_name, is_inline=False, selected_loras=None, generation_time=None):
if is_inline:
ok_emoji = '\u2705'
else:
ok_emoji = '\u2705'
header = f"{ok_emoji} {self.strings('fmt_done')}"
return self._apply_emoji_theme(self._format_gen_text(
prompt_display,
model,
wf_name,
header,
is_inline,
selected_loras,
generation_time=generation_time,
quote_details=True,
))
def _normalize_selected_loras(self, selected_loras, available_loras=None):
if not isinstance(selected_loras, dict):
return {}
available = list(available_loras) if available_loras is not None else None
normalized = {}
for lora_name, weight in selected_loras.items():
lora_name = str(lora_name).strip()
if not lora_name:
continue
if available is not None:
lora_name = self._resolve_lora_catalog_name(lora_name, available)
if not lora_name:
continue
try:
weight = float(weight)
except (TypeError, ValueError):
weight = 0.75
normalized[lora_name] = round(max(0.1, min(2.0, weight)), 1)
return normalized
def _normalize_lora_preset_entries(self, selected_loras, available_loras=None):
if not isinstance(selected_loras, dict):
return {}
available = list(available_loras) if available_loras is not None else None
normalized = {}
for lora_name, value in selected_loras.items():
lora_name = str(lora_name).strip()
if not lora_name:
continue
if available is not None:
lora_name = self._resolve_lora_catalog_name(lora_name, available)
if not lora_name:
continue
if isinstance(value, dict):
enabled = bool(value.get("enabled"))
weight = value.get("weight", 0.75)
else:
enabled = True
weight = value
try:
weight = float(weight)
except (TypeError, ValueError):
weight = 0.75
normalized[lora_name] = {
"enabled": enabled,
"weight": round(max(0.1, min(2.0, weight)), 1),
}
return normalized
@classmethod
def _resolve_lora_catalog_name(cls, lora_name, available_loras):
lora_name = str(lora_name or "").strip()
if not lora_name:
return None
exact = {}
by_key = {}
for candidate in available_loras or []:
candidate = str(candidate or "").strip()
if not candidate:
continue
exact.setdefault(candidate.casefold(), candidate)
key = cls._model_match_key(candidate)
if key:
by_key.setdefault(key, []).append(candidate)
direct = exact.get(lora_name.casefold())
if direct:
return direct
matches = by_key.get(cls._model_match_key(lora_name), [])
return matches[0] if len(matches) == 1 else None
@staticmethod
def _normalize_lora_trigger_words(value):
if isinstance(value, str):
items = re.split(r"[,;\n]+", value)
elif isinstance(value, (list, tuple, set)):
items = value
else:
items = []
normalized = []
seen = set()
for item in items:
word = " ".join(str(item or "").split()).strip()[:120]
key = word.casefold()
if word and key not in seen:
normalized.append(word)
seen.add(key)
if len(normalized) >= 50:
break
return normalized
def _normalize_lora_metadata(self, metadata):
if not isinstance(metadata, dict):
return {}
normalized = {}
for lora_name, value in metadata.items():
lora_name = str(lora_name).strip()
if not lora_name:
continue
if isinstance(value, dict):
favorite = bool(value.get("favorite"))
note = str(value.get("note") or "").strip()[:500]
triggers = self._normalize_lora_trigger_words(value.get("triggers"))
auto_triggers = bool(value.get("auto_triggers"))
civitai_version = str(value.get("civitai_version") or "").strip()[:32]
civitai_base_model = str(value.get("civitai_base_model") or "").strip()[:80]
else:
favorite = bool(value)
note = ""
triggers = []
auto_triggers = False
civitai_version = ""
civitai_base_model = ""
if favorite or note or triggers or auto_triggers or civitai_version or civitai_base_model:
normalized[lora_name] = {
"favorite": favorite,
"note": note,
"triggers": triggers,
"auto_triggers": auto_triggers,
"civitai_version": civitai_version,
"civitai_base_model": civitai_base_model,
}
return normalized
def _get_lora_metadata(self):
metadata = self._normalize_lora_metadata(self.get("lora_metadata", {}))
self.set("lora_metadata", metadata)
return metadata
def _get_lora_metadata_entry(self, lora_name):
return self._get_lora_metadata().get(
str(lora_name).strip(),
{
"favorite": False,
"note": "",
"triggers": [],
"auto_triggers": False,
"civitai_version": "",
"civitai_base_model": "",
},
)
def _set_lora_metadata_entry(
self,
lora_name,
favorite=None,
note=None,
triggers=None,
auto_triggers=None,
civitai_version=None,
civitai_base_model=None,
):
lora_name = str(lora_name).strip()
if not lora_name:
return
metadata = self._get_lora_metadata()
entry = metadata.get(
lora_name,
{
"favorite": False,
"note": "",
"triggers": [],
"auto_triggers": False,
"civitai_version": "",
"civitai_base_model": "",
},
)
if favorite is not None:
entry["favorite"] = bool(favorite)
if note is not None:
entry["note"] = str(note).strip()[:500]
if triggers is not None:
entry["triggers"] = self._normalize_lora_trigger_words(triggers)
if auto_triggers is not None:
entry["auto_triggers"] = bool(auto_triggers)
if civitai_version is not None:
entry["civitai_version"] = str(civitai_version or "").strip()[:32]
if civitai_base_model is not None:
entry["civitai_base_model"] = str(civitai_base_model or "").strip()[:80]
if (
entry.get("favorite")
or entry.get("note")
or entry.get("triggers")
or entry.get("auto_triggers")
or entry.get("civitai_version")
or entry.get("civitai_base_model")
):
metadata[lora_name] = {
"favorite": bool(entry.get("favorite")),
"note": str(entry.get("note") or "").strip()[:500],
"triggers": self._normalize_lora_trigger_words(entry.get("triggers")),
"auto_triggers": bool(entry.get("auto_triggers")),
"civitai_version": str(entry.get("civitai_version") or "").strip()[:32],
"civitai_base_model": str(entry.get("civitai_base_model") or "").strip()[:80],
}
else:
metadata.pop(lora_name, None)
self.set("lora_metadata", metadata)
@staticmethod
def _extract_civitai_version_id(value):
text = str(value or "").strip()
if text.isdigit():
return text
for pattern in (
r"[?&]modelVersionId=(\d+)",
r"/model-versions/(\d+)",
r"/api/download/models/(\d+)",
):
match = re.search(pattern, text, re.IGNORECASE)
if match:
return match.group(1)
return ""
@staticmethod
def _extract_civitai_model_id(value):
match = re.search(r"/models/(\d+)", str(value or ""), re.IGNORECASE)
return match.group(1) if match else ""
@classmethod
def _pick_civitai_model_version(cls, versions, filename=None):
if not isinstance(versions, list):
return None
filename_key = cls._model_match_key(filename)
if filename_key:
for version in versions:
if not isinstance(version, dict):
continue
for file_info in version.get("files") or []:
if isinstance(file_info, dict) and cls._model_match_key(file_info.get("name")) == filename_key:
return version
return next((item for item in versions if isinstance(item, dict)), None)
async def _fetch_civitai_lora_triggers(self, value, filename=None):
version_id = self._extract_civitai_version_id(value)
model_id = self._extract_civitai_model_id(value) if not version_id else ""
try:
if version_id:
async with self._session_get(
_CIVITAI_MODEL_VERSION_URL.format(version_id),
timeout=aiohttp.ClientTimeout(total=15),
headers={"User-Agent": "ComfyImageGen/0.1"},
) as resp:
if resp.status != 200:
return version_id, [], ""
data = await resp.json(content_type=None)
elif model_id:
async with self._session_get(
_CIVITAI_MODEL_URL.format(model_id),
timeout=aiohttp.ClientTimeout(total=15),
headers={"User-Agent": "ComfyImageGen/0.1"},
) as resp:
if resp.status != 200:
return None, [], ""
model_data = await resp.json(content_type=None)
data = self._pick_civitai_model_version(
model_data.get("modelVersions") if isinstance(model_data, dict) else [],
filename,
)
if not isinstance(data, dict):
return None, [], ""
version_id = str(data.get("id") or "").strip()
if not version_id:
return None, [], ""
else:
return None, [], ""
except Exception as e:
logger.debug("Civitai LoRA trigger request failed: %s", e)
return version_id, [], ""
return (
version_id,
self._normalize_lora_trigger_words(
data.get("trainedWords") if isinstance(data, dict) else []
),
str(data.get("baseModel") or "").strip() if isinstance(data, dict) else "",
)
def _apply_lora_trigger_words(self, positive, selected_loras):
positive = str(positive or "").strip()
normalized_positive = " ".join(positive.casefold().split())
additions = []
for lora_name in selected_loras:
metadata = self._get_lora_metadata_entry(lora_name)
if not metadata.get("auto_triggers"):
continue
for trigger in self._normalize_lora_trigger_words(metadata.get("triggers")):
normalized_trigger = " ".join(trigger.casefold().split())
if normalized_trigger and normalized_trigger not in normalized_positive:
additions.append(trigger)
normalized_positive = f"{normalized_positive}, {normalized_trigger}".strip(", ")
if not additions:
return positive
return ", ".join(part for part in (positive, *additions) if part)
def _is_lora_favorite(self, lora_name):
return bool(self._get_lora_metadata_entry(lora_name).get("favorite"))
def _get_lora_note(self, lora_name):
return str(self._get_lora_metadata_entry(lora_name).get("note") or "")
def _filter_loras(self, loras, mode="all", metadata=None, imported=None):
metadata = metadata if isinstance(metadata, dict) else self._get_lora_metadata()
if mode == "favorites":
return [
lora_name
for lora_name in loras
if metadata.get(lora_name, {}).get("favorite")
]
if mode == "imported":
imported = {str(name).casefold() for name in imported or []}
return [
lora_name
for lora_name in loras
if str(lora_name).casefold() in imported
]
return list(loras)
@staticmethod
def _filter_names_by_query(items, query):
query = " ".join(str(query or "").split()).casefold()
if not query:
return list(items or [])
query_tokens = re.findall(r"\w+", query, flags=re.UNICODE)
query_compact = "".join(query_tokens)
matches = []
for item in items or []:
value = str(item or "").casefold()
value_tokens = re.findall(r"\w+", value, flags=re.UNICODE)
value_compact = "".join(value_tokens)
if (
query in value
or (query_compact and query_compact in value_compact)
or (query_tokens and all(token in value for token in query_tokens))
):
matches.append(item)
return sorted(
matches,
key=lambda item: (
0 if str(item).casefold().startswith(query) else 1,
str(item).casefold(),
),
)
def _get_enabled_lora_presets(self, selected_loras, available_loras=None):
entries = self._normalize_lora_preset_entries(selected_loras, available_loras)
return {
lora_name: entry["weight"]
for lora_name, entry in entries.items()
if entry.get("enabled")
}
def _normalize_lora_argset_data(self, data):
if not isinstance(data, dict):
data = {}
return {
"enabled": bool(data.get("enabled", False)),
"selected": self._normalize_lora_preset_entries(data.get("selected")),
}
def _lora_preset_backend_key(self):
return (
_COMFY_BACKEND_CLOUD
if self._is_comfy_cloud()
else _COMFY_BACKEND_LOCAL
)
def _get_lora_presets_by_backend(self):
data = self.get("lora_presets_by_backend", None)
if isinstance(data, dict):
return {
key: self._normalize_lora_argset_data(value)
for key, value in data.items()
if key in {_COMFY_BACKEND_LOCAL, _COMFY_BACKEND_CLOUD}
}
legacy = self.get("global_lora_presets", None)
if legacy is None:
saved = self.get("default_args", {})
legacy = saved.get("lora") if isinstance(saved, dict) else None
result = {}
if legacy is not None:
result[self._lora_preset_backend_key()] = self._normalize_lora_argset_data(
legacy
)
self.set("lora_presets_by_backend", result)
return result
def _get_global_lora_data(self):
presets_by_backend = self._get_lora_presets_by_backend()
data = self._normalize_lora_argset_data(
presets_by_backend.get(self._lora_preset_backend_key())
)
return self._clone_argset_data(data)
def _set_global_lora_data(self, data):
data = self._normalize_lora_argset_data(data)
presets_by_backend = self._get_lora_presets_by_backend()
presets_by_backend[self._lora_preset_backend_key()] = data
self.set("lora_presets_by_backend", presets_by_backend)
return self._clone_argset_data(data)
def _ensure_lora_argset_entry(self, saved):
data = self._get_global_lora_data()
if isinstance(saved, dict):
saved["lora"] = self._clone_argset_data(data)
return saved["lora"]
return data
def _workflow_limited_mode(self):
return bool(self.get("workflow_limited_mode", False))
def _set_workflow_limited_mode(self, enabled):
self.set("workflow_limited_mode", bool(enabled))
@staticmethod
def _apply_limited_generation_mode(parsed):
for key in (
"width",
"height",
"steps",
"cfg",
"seed",
"denoise",
"sampler_name",
"scheduler",
):
parsed[key] = None
parsed["use_lora_picker"] = False
return parsed
def _get_default_lora_data(self):
return self._get_global_lora_data()
def _get_default_lora_presets(self):
data = self._get_default_lora_data()
if not self._argset_enabled(data):
return {}
return self._get_enabled_lora_presets(data.get("selected"))
def _format_lora_preset_summary(self, data):
selected = self._normalize_lora_preset_entries(data.get("selected")) if isinstance(data, dict) else {}
if not selected:
return self.strings("lora_presets_empty")
enabled_count = sum(1 for entry in selected.values() if entry.get("enabled"))
return self.strings("lora_presets_selected").format(f"{enabled_count}/{len(selected)}")
def _build_generation_state(
self,
*,
positive,
original_positive,
negative,
width,
height,
seed,
denoise,
steps,
cfg,
wf_name,
model,
input_filename,
input_image_name=None,
input_image_path=None,
input_video_filename=None,
input_video_name=None,
input_video_path=None,
chat_id,
reply_to,
enhance_prompt,
use_lora_picker,
enhanced=False,
easter_egg=None,
selected_loras=None,
lora_entries=None,
auto_delete_result_delay=None,
trigger_origin=None,
health_checked=False,
plain_status=False,
reuse_status_message=False,
sampler_name=None,
scheduler=None,
limited_mode=False,
):
return {
"positive": positive,
"original_positive": original_positive,
"negative": negative,
"width": width,
"height": height,
"seed": seed,
"denoise": denoise,
"steps": steps,
"cfg": cfg,
"sampler_name": sampler_name,
"scheduler": scheduler,
"wf_name": wf_name,
"model": model,
"input_filename": input_filename,
"input_image_name": input_image_name,
"input_image_path": input_image_path,
"input_video_filename": input_video_filename,
"input_video_name": input_video_name,
"input_video_path": input_video_path,
"chat_id": chat_id,
"reply_to": reply_to,
"enhance_prompt": enhance_prompt,
"use_lora_picker": use_lora_picker,
"enhanced": enhanced,
"easter_egg": easter_egg,
"selected_loras": self._normalize_selected_loras(selected_loras),
"lora_entries": self._normalize_lora_preset_entries(lora_entries if lora_entries is not None else selected_loras),
"auto_delete_result_delay": auto_delete_result_delay,
"trigger_origin": trigger_origin,
"health_checked": health_checked,
"plain_status": plain_status,
"reuse_status_message": bool(reuse_status_message),
"limited_mode": bool(limited_mode),
"backend": self._comfy_backend(),
}
def _build_display_bundle(self, state: dict, hidden_prompt: bool = False):
positive = state["positive"]
original_positive = state.get("original_positive", positive)
if state.get("enhanced") and positive != original_positive:
prompt_display = (
self._format_prompt_for_display(original_positive, hidden=hidden_prompt)
+ "\n"
+ self.strings("enhanced_label")
+ " "
+ self._format_prompt_for_display(positive, hidden=hidden_prompt)
)
else:
prompt_display = self._format_prompt_for_display(positive, hidden=hidden_prompt)
return (
prompt_display,
state.get("model") or "default",
state["wf_name"],
)
def _store_last_generation(self, state: dict):
self.set(
"last_generation",
{
"positive": state.get("positive") or "",
"original_positive": state.get("original_positive") or state.get("positive") or "",
"negative": state["negative"],
"width": state["width"],
"height": state["height"],
"denoise": state["denoise"],
"steps": state["steps"],
"cfg": state["cfg"],
"sampler_name": state.get("sampler_name"),
"scheduler": state.get("scheduler"),
"model": state["model"],
"wf_name": state["wf_name"],
"enhance_prompt": state.get("enhance_prompt", False),
"selected_loras": state.get("selected_loras", {}),
},
)
def _workflow_mapping_value(self, workflow, mapping):
if not isinstance(workflow, dict) or not isinstance(mapping, dict):
return None
node = workflow.get(str(mapping.get("node_id")))
if not isinstance(node, dict):
return None
inputs = node.get("inputs", {})
if not isinstance(inputs, dict):
return None
fields = mapping.get("fields") or mapping.get("field")
if isinstance(fields, str):
fields = [fields]
if not isinstance(fields, list):
return None
for field in fields:
if field in inputs:
return inputs.get(field)
return None
def _sync_generation_state_from_workflow(self, state, wf_data, workflow):
mapping = wf_data.get("mapping", {}) if isinstance(wf_data, dict) else {}
positive_value = self._workflow_mapping_value(workflow, mapping.get("positive"))
if isinstance(positive_value, str):
state["archive_positive"] = positive_value
negative_value = self._workflow_mapping_value(workflow, mapping.get("negative"))
if isinstance(negative_value, str):
state["archive_negative"] = negative_value
for key in ("width", "height", "steps", "seed", "denoise"):
value = self._workflow_mapping_value(workflow, mapping.get(key))
if value is not None:
state[key] = value
cfg_value = self._workflow_mapping_value(workflow, mapping.get("cfg"))
if cfg_value is None:
cfg_value = self._workflow_mapping_value(workflow, mapping.get("flux_guidance"))
if cfg_value is not None:
state["cfg"] = cfg_value
sampler_name = self._workflow_mapping_value(workflow, mapping.get("sampler_name"))
if sampler_name is not None:
state["sampler_name"] = sampler_name
scheduler = self._workflow_mapping_value(workflow, mapping.get("scheduler"))
if scheduler is not None:
state["scheduler"] = scheduler
def _next_archive_generation_number(self):
value = self._coerce_int(self.get("archive_generation_counter"), 0, 0)
value += 1
self.set("archive_generation_counter", value)
return value
def _format_archive_loras_plain(self, selected_loras):
selected_loras = self._normalize_selected_loras(selected_loras)
if not selected_loras:
return "none"
return "\n".join(
f"{self._format_lora_name(name, max_length=None)}: {weight}"
for name, weight in selected_loras.items()
)
def _build_archive_prompt_text(self, state: dict, generation_number: int):
positive = str(state.get("archive_positive") or state.get("positive") or "")
original_positive = str(state.get("original_positive") or positive)
negative = str(state.get("archive_negative") or state.get("negative") or "")
lines = [
f"Generation: #{generation_number}",
"",
]
if state.get("enhanced") and positive != original_positive:
lines.extend([
"Original prompt:",
original_positive,
"",
"Enhanced prompt:",
positive,
"",
])
else:
lines.extend([
"Prompt:",
positive,
"",
])
size = (
f"{state.get('width')}x{state.get('height')}"
if state.get("width") is not None and state.get("height") is not None
else None
)
fields = [
("Backend", state.get("backend") or self._comfy_backend()),
("Negative", negative),
("Model", self._format_model_name(state.get("model") or "default", max_length=None)),
("Workflow", str(state.get("wf_name") or "default")),
("Size", size),
("Steps", state.get("steps")),
("CFG", state.get("cfg")),
("Sampler", state.get("sampler_name")),
("Scheduler", state.get("scheduler")),
("Seed", state.get("seed")),
("Denoise", state.get("denoise")),
("LoRA", self._format_archive_loras_plain(state.get("selected_loras"))),
]
for label, value in fields:
if value is None:
continue
lines.extend([f"{label}:", str(value), ""])
return "\n".join(lines).strip()
async def _send_archive_text_fallback(
self,
chat_id,
prompt_text,
generation_number,
reply_to=None,
allow_unthreaded_fallback=True,
):
text = "\n\n".join([
self.strings("archive_full_prompt_title").format(generation_number),
str(prompt_text or ""),
])
try:
return await self.client.send_message(chat_id, text, reply_to=reply_to)
except Exception:
if not allow_unthreaded_fallback:
raise
return await self.client.send_message(chat_id, text)
async def _send_archive_prompt_file(
self,
chat_id,
state,
generation_number,
reply_to=None,
allow_unthreaded_fallback=True,
):
prompt_text = str(state.get("archive_positive") or state.get("positive") or "")
try:
prompt_text = self._build_archive_prompt_text(state, generation_number)
text = prompt_text
file_obj = io.BytesIO(text.encode("utf-8"))
file_obj.name = f"comfy_prompt_{generation_number:06d}.txt"
try:
return await self.client.send_file(
chat_id,
file_obj,
caption=self.strings("archive_full_prompt_caption"),
reply_to=reply_to,
)
except Exception:
if not allow_unthreaded_fallback:
raise
file_obj.seek(0)
return await self.client.send_file(
chat_id,
file_obj,
caption=self.strings("archive_full_prompt_caption"),
)
finally:
file_obj.close()
except Exception as e:
logger.debug("Failed to send archive prompt file: %s", e)
return await self._send_archive_text_fallback(
chat_id,
prompt_text,
generation_number,
reply_to,
allow_unthreaded_fallback=allow_unthreaded_fallback,
)
async def _build_workflow_file(self, wf_name):
wf_name = self._canonical_workflow_name(wf_name)
if not wf_name or wf_name.lower() == "i2i":
return wf_name, None, ""
wf_data = await self._ensure_workflow_data(wf_name)
if not wf_data:
return wf_name, None, ""
json_str = json.dumps(wf_data["workflow"], indent=2, ensure_ascii=False)
file_obj = io.BytesIO(json_str.encode("utf-8"))
safe_name = re.sub(r'[\\/:*?"<>|]+', "_", wf_name).strip() or "workflow"
file_obj.name = f"{safe_name}_workflow.json"
return wf_name, file_obj, self._workflow_description(wf_name)
def _extract_cshare_generation_number(self, text):
match = re.search(r"\bGeneration:\s*#(\d+)", str(text or ""), re.IGNORECASE)
if not match:
return None
try:
return int(match.group(1))
except ValueError:
return None
async def _download_cshare_text(self, message):
if getattr(message, "media", None):
bio = io.BytesIO()
try:
await self.client.download_media(message, bio)
value = bio.getvalue()
finally:
bio.close()
if value:
return value.decode("utf-8", errors="ignore").strip()
return (getattr(message, "raw_text", None) or message.text or "").strip()
async def _find_cshare_prompt_info(self, archive_message, generation_number):
chat_id = utils.get_chat_id(archive_message)
ids = list(range(archive_message.id + 1, archive_message.id + 21))
try:
messages = await self.client.get_messages(chat_id, ids=ids)
except Exception as e:
logger.debug("Failed to fetch archive prompt messages: %s", e)
return None, ""
for item in messages or []:
if not item:
continue
if getattr(item, "reply_to_msg_id", None) != archive_message.id:
continue
text = await self._download_cshare_text(item)
if not text:
continue
if self._extract_cshare_generation_number(text) == generation_number:
return item, text
return None, ""
def _parse_archive_prompt_text(self, text):
result = {"raw": str(text or "")}
headers = {
"generation": "generation",
"original prompt": "original_prompt",
"enhanced prompt": "enhanced_prompt",
"prompt": "prompt",
"negative": "negative",
"model": "model",
"workflow": "workflow",
"size": "size",
"steps": "steps",
"cfg": "cfg",
"sampler": "sampler",
"scheduler": "scheduler",
"seed": "seed",
"denoise": "denoise",
"lora": "lora",
}
current = None
buffer = []
def flush():
nonlocal current, buffer
if current:
result[current] = "\n".join(buffer).strip()
current = None
buffer = []
for line in result["raw"].splitlines():
match = re.match(
r"^\s*(Generation|Original prompt|Enhanced prompt|Prompt|Negative|Model|Workflow|Size|Steps|CFG|Sampler|Scheduler|Seed|Denoise|LoRA):\s*(.*)\s*$",
line,
re.IGNORECASE,
)
if match:
flush()
key = headers[match.group(1).lower()]
value = match.group(2)
if key == "generation":
result["generation"] = self._extract_cshare_generation_number(line)
current = None
buffer = []
else:
current = key
buffer = [value] if value else []
continue
if current:
buffer.append(line)
flush()
return result
async def _download_cshare_image(self, message, generation_number):
if not getattr(message, "media", None):
return None, None
bio = io.BytesIO()
try:
await self.client.download_media(message, bio)
value = bio.getvalue()
finally:
bio.close()
if not value:
return None, None
filename = None
try:
filename = getattr(message.file, "name", None)
except Exception:
filename = None
if not filename:
filename = f"comfy_generation_{generation_number:06d}.png"
return value, filename
def _cshare_quote_html(self, label, value):
value = str(value or "").strip() or "—"
return f"{label}:\n{utils.escape_html(value)}
"
def _cshare_short_quote_html(self, label, value, limit=300):
value = str(value or "").strip() or "—"
if len(value) > limit:
value = value[:limit].rstrip() + "..."
return f"{label}:\n{utils.escape_html(value)}
"
def _cshare_quote_html_raw(self, label, value):
value = str(value or "").strip() or "-"
return f"{label}:\n{value}
"
def _cshare_quote_plain(self, label, value):
value = str(value or "").strip() or "—"
return f"{label}:\n{value}"
def _format_builtin_workflow_link_html(self, wf_name):
canonical = self._canonical_workflow_name(wf_name)
url = _BUILTIN_WORKFLOW_TELEGRAM_URLS.get(canonical)
if not url:
return utils.escape_html(str(wf_name or ""))
return f'{utils.escape_html(canonical)}'
def _cshare_param_value(self, value):
value = str(value or "").strip()
return "" if value.lower() in {"none", "null", "nonexnone"} else value
def _format_cshare_params(self, data):
parts = []
size = self._cshare_param_value(data.get("size"))
steps = self._cshare_param_value(data.get("steps"))
cfg = self._cshare_param_value(data.get("cfg"))
sampler = self._cshare_param_value(data.get("sampler"))
scheduler = self._cshare_param_value(data.get("scheduler"))
seed = self._cshare_param_value(data.get("seed"))
denoise = self._cshare_param_value(data.get("denoise"))
if size:
parts.append(size)
if steps:
parts.append(f"steps {steps}")
if cfg:
parts.append(f"CFG {cfg}")
if sampler:
parts.append(f"sampler {sampler}")
if scheduler:
parts.append(f"scheduler {scheduler}")
if seed:
parts.append(f"seed {seed}")
if denoise:
parts.append(f"denoise {denoise}")
return ", ".join(parts) or "—"
@staticmethod
def _generate_cshare_id():
return f"{random.choice(string.ascii_lowercase)}{random.randint(0, 999):03d}"
async def _format_cshare_author(self, message, anonymous):
if anonymous:
value = self.strings("cshare_author_anon")
return value, value
try:
sender = await message.get_sender()
except Exception:
sender = None
sender_id = getattr(sender, "id", None) or getattr(message, "sender_id", None) or self.tg_id
username = getattr(sender, "username", None)
if username:
label = f"@{username}"
return f'{utils.escape_html(label)}', label
name = " ".join(
item
for item in (
getattr(sender, "first_name", None),
getattr(sender, "last_name", None),
)
if item
).strip() or str(sender_id)
return f'{utils.escape_html(name)}', name
def _build_cshare_post(self, data, note, author_html, author_plain, workflow_display, workflow_description="", workflow_plain_display=None):
positive = data.get("enhanced_prompt") or data.get("prompt") or "—"
negative = data.get("negative") or "—"
model = self._format_model_name(data.get("model"), max_length=None) if data.get("model") else "—"
lora = self._format_lora_text_for_display(data.get("lora")).strip()
params = self._format_cshare_params(data)
workflow_description = str(workflow_description or "").strip()
html_lines = []
plain_lines = []
if note:
html_lines.extend([utils.escape_html(note), ""])
plain_lines.extend([note, ""])
html_lines.extend([self.strings("cshare_author").format(author_html), ""])
plain_lines.extend([self._plain_text(self.strings("cshare_author").format(author_plain)), ""])
fields = [
("Positive", positive),
("Negative", negative),
("Model", model),
]
if lora and lora.lower() != "none":
fields.append(("LoRA", lora))
fields.extend(
[
("Workflow", workflow_display),
("Params", params),
]
)
if workflow_description:
fields.insert(-1, ("Workflow description", workflow_description))
for label, value in fields:
if label == "Workflow":
html_lines.extend([self._cshare_quote_html_raw(label, value), ""])
else:
html_lines.extend([self._cshare_quote_html(label, value), ""])
plain_value = workflow_plain_display if label == "Workflow" and workflow_plain_display is not None else value
plain_lines.extend([self._cshare_quote_plain(label, plain_value), ""])
return "\n".join(html_lines).strip(), "\n\n".join(plain_lines).strip()
def _build_cshare_short_caption(self, data, note, author_html):
positive = data.get("enhanced_prompt") or data.get("prompt") or "—"
model = self._format_model_name(data.get("model"), max_length=None) if data.get("model") else "—"
lines = []
if note:
lines.extend([utils.escape_html(note), ""])
lines.extend(
[
self.strings("cshare_author").format(author_html),
"",
self._cshare_short_quote_html("Prompt", positive, 300),
"",
self._cshare_quote_html("Model", model),
]
)
return "\n".join(lines).strip()
def _build_cshare_post_ru(self, data, note, author_html, author_plain, workflow_display, workflow_description="", workflow_plain_display=None, share_id=None):
positive = data.get("enhanced_prompt") or data.get("prompt") or "—"
negative = data.get("negative") or "—"
model = self._format_model_name(data.get("model"), max_length=None) if data.get("model") else "—"
lora = self._format_lora_text_for_display(data.get("lora")).strip()
params = self._format_cshare_params(data)
workflow_description = str(workflow_description or "").strip()
html_lines = []
plain_lines = []
if share_id:
html_lines.extend([f"Предложка ComfyIdeas #{share_id}", ""])
plain_lines.extend([f"Предложка ComfyIdeas #{share_id}", ""])
if note:
html_lines.extend([utils.escape_html(note), ""])
plain_lines.extend([note, ""])
html_lines.extend([f"Автор: {author_html}", ""])
plain_lines.extend([f"Автор: {author_plain}", ""])
fields = [
("Промпт", positive),
("Негатив", negative),
("Модель", model),
]
if lora and lora.lower() != "none":
fields.append(("LoRA", lora))
fields.extend(
[
("Воркфлоу", workflow_display),
("Параметры", params),
]
)
if workflow_description:
fields.insert(-1, ("Описание воркфлоу", workflow_description))
for label, value in fields:
if label == "Воркфлоу":
html_lines.extend([self._cshare_quote_html_raw(label, value), ""])
else:
html_lines.extend([self._cshare_quote_html(label, value), ""])
plain_value = workflow_plain_display if label == "Воркфлоу" and workflow_plain_display is not None else value
plain_lines.extend([self._cshare_quote_plain(label, plain_value), ""])
return "\n".join(html_lines).strip(), "\n\n".join(plain_lines).strip()
def _build_cshare_short_caption_ru(self, data, note, author_html, workflow_display, share_id):
positive = data.get("enhanced_prompt") or data.get("prompt") or "—"
model = data.get("model") or "—"
lines = [f"Предложка ComfyIdeas #{share_id}"]
if note:
lines.extend(["", utils.escape_html(note)])
lines.extend(
[
"",
f"Автор: {author_html}",
"",
self._cshare_short_quote_html("Промпт", positive, 300),
"",
self._cshare_quote_html("Модель", model),
"",
self._cshare_quote_html_raw("Воркфлоу", workflow_display),
]
)
return "\n".join(lines).strip()
async def _send_cshare_text_file(self, target, text, share_id):
file_obj = io.BytesIO(str(text or "").encode("utf-8"))
file_obj.name = f"comfy_share_{share_id}.txt"
try:
return await self._cshare_send_file(
target,
file_obj,
caption=f"Текст предложки #{share_id}",
)
finally:
file_obj.close()
async def _send_cshare_workflow_file(self, target, wf_name, file_obj, description=""):
if not wf_name or not file_obj:
return False
caption = f"Workflow: {self._format_builtin_workflow_link_html(wf_name)}"
description = str(description or "").strip()
if description:
caption += f"\n\nDescription:\n{utils.escape_html(description)}
"
try:
await self._cshare_send_file(
target,
file_obj,
caption=caption,
)
return True
except Exception as e:
logger.debug("Failed to send cshare workflow file: %s", e)
try:
await self._cshare_send_message(target, f"{self.get_prefix()}mlwf {wf_name}")
return True
except Exception as fallback_error:
logger.debug("Failed to fallback-send mlwf command: %s", fallback_error)
return False
finally:
file_obj.close()
def _is_chat_write_forbidden(self, error):
return error.__class__.__name__ in {"ChatWriteForbiddenError", "ChatSendMediaForbiddenError", "ChatSendPlainForbiddenError"}
def _is_monoforum_reply_error(self, error):
return "REPLY_TO_MONOFORUM_PEER_INVALID" in str(error)
def _can_retry_cshare_fallback(self, error):
return self._is_monoforum_reply_error(error) or self._is_chat_write_forbidden(error)
def _cshare_peer(self, target):
return target.get("peer") if isinstance(target, dict) else target
def _cshare_reply_to(self, target):
return target.get("reply_to") if isinstance(target, dict) else None
def _cshare_fallback_target(self, target):
return target.get("fallback") if isinstance(target, dict) else None
async def _cshare_try_init_direct(self, target):
peer = self._cshare_fallback_target(target) or self._cshare_peer(target)
if not peer:
return False
try:
msg = await self.client.send_message(peer, ".")
except Exception as e:
logger.debug("Failed to initialize ComfyIdeas direct monoforum: %s", e)
return False
try:
await msg.delete()
except Exception as e:
logger.debug("Failed to delete ComfyIdeas direct init message: %s", e)
return True
async def _cshare_send_file_standard(self, peer, file_obj, **kwargs):
if hasattr(file_obj, "seek"):
file_obj.seek(0)
return await self.client.send_file(peer, file_obj, **kwargs)
async def _cshare_send_file(self, target, file_obj, **kwargs):
kwargs.pop("reply_to", None)
try:
return await self._cshare_send_file_standard(
self._cshare_peer(target), file_obj, **kwargs
)
except Exception as e:
fallback = self._cshare_fallback_target(target)
if not fallback or not self._can_retry_cshare_fallback(e):
raise
kwargs.pop("reply_to", None)
if hasattr(file_obj, "seek"):
file_obj.seek(0)
try:
return await self._cshare_send_file_standard(
fallback, file_obj, **kwargs
)
except Exception as fallback_error:
if self._is_monoforum_reply_error(fallback_error):
raise e
raise
async def _cshare_send_message(self, target, text, **kwargs):
kwargs.pop("reply_to", None)
try:
return await self.client.send_message(self._cshare_peer(target), text, **kwargs)
except Exception as e:
fallback = self._cshare_fallback_target(target)
if not fallback or not self._can_retry_cshare_fallback(e):
raise
kwargs.pop("reply_to", None)
try:
return await self.client.send_message(fallback, text, **kwargs)
except Exception as fallback_error:
if self._is_monoforum_reply_error(fallback_error):
raise e
raise
async def _resolve_cshare_direct_target(self):
channel = await self.client.get_entity("comfyideas")
monoforum = None
for target in (
"https://t.me/comfyideas?direct",
"tg://resolve?domain=comfyideas&direct",
):
try:
entity = await self.client.get_entity(target)
if getattr(entity, "monoforum", False):
monoforum = entity
break
except Exception as e:
logger.debug("Failed to resolve ComfyIdeas direct target %s: %s", target, e)
if getattr(channel, "monoforum", False):
monoforum = channel
linked_id = getattr(channel, "linked_monoforum_id", None)
if not linked_id and not monoforum:
raise UserFacingError("cshare_direct_unavailable", self._plain_text(self.strings("cshare_direct_unavailable")))
if linked_id and not monoforum:
for entity_like in (PeerChannel(int(linked_id)),):
try:
entity = await self.client.get_entity(entity_like)
if entity:
monoforum = entity
break
except Exception as e:
logger.debug("Failed to resolve ComfyIdeas monoforum by id %s: %s", linked_id, e)
try:
entity = await self.client.get_input_entity(entity_like)
if entity:
monoforum = entity
break
except Exception as e:
logger.debug("Failed to resolve ComfyIdeas input monoforum by id %s: %s", linked_id, e)
if linked_id and not monoforum:
try:
dialogs = await self.client.get_dialogs(limit=200)
for dialog in dialogs:
entity = getattr(dialog, "entity", None)
if getattr(entity, "id", None) == linked_id:
monoforum = entity
break
if getattr(entity, "monoforum", False) and getattr(entity, "linked_monoforum_id", None) == getattr(channel, "id", None):
monoforum = entity
break
except Exception as e:
logger.debug("Failed to scan dialogs for ComfyIdeas monoforum: %s", e)
if not monoforum:
raise UserFacingError("cshare_direct_unavailable", self._plain_text(self.strings("cshare_direct_unavailable")))
return {"peer": monoforum}
def _message_html(self, message):
text = getattr(message, "raw_text", None) or getattr(message, "text", None) or ""
entities = getattr(message, "entities", None) or []
if not entities:
return utils.escape_html(text)
try:
return html.unparse(utils.escape_html(text), entities)
except Exception as e:
logger.debug("Failed to unparse message html: %s", e)
return utils.escape_html(text)
def _extract_cshare_top_block(self, text):
text = str(text or "")
start = text.find("[")
if start == -1:
return ""
end = text.find("]", start + 1)
if end == -1:
return ""
return text[start + 1:end].strip()
async def _fetch_cshare_top(self):
try:
message = await self.client.get_messages(
_CSHARE_TOP_CHAT,
ids=_CSHARE_TOP_MESSAGE_ID,
)
except Exception as e:
logger.debug("Failed to fetch ComfyIdeas top: %s", e)
return ""
if not message:
return ""
return self._extract_cshare_top_block(self._message_html(message))
async def _answer_cshare_top(self, message):
top = await self._fetch_cshare_top()
await self._safe_answer(
message,
top or self.strings("cshare_top_unavailable"),
)
async def _format_cshare_done_with_top(self):
top = await self._fetch_cshare_top()
if not top:
return self.strings("cshare_done")
return f"{self.strings('cshare_done')}\n\n{top}"
def _build_cshare_preview_text(self, post_html, short_caption_html=None):
body = str(post_html or "").strip()
if len(body) > 3400 and short_caption_html:
body = str(short_caption_html or "").strip()
if len(body) > 3400:
body = body[:3397].rstrip() + "..."
return "\n\n".join([
self.strings("cshare_preview_title"),
body,
]).strip()
async def _prepare_cshare_preview_state(self, message, reply, generation_number, prompt_text, raw_args):
data = self._parse_archive_prompt_text(prompt_text)
if data.get("generation") != generation_number:
return None, self.strings("cshare_no_prompt_info")
if not getattr(reply, "media", None):
return None, self.strings("cshare_no_image")
anonymous = bool(re.search(r"(^|\s)-anon(\s|$)", raw_args, re.IGNORECASE))
note = re.sub(r"(^|\s)-anon(\s|$)", " ", raw_args, flags=re.IGNORECASE).strip()
author_html, author_plain = await self._format_cshare_author(message, anonymous)
workflow_display = self.strings("cshare_unknown_workflow")
workflow_name = None
workflow_description = ""
workflow_plain_display = self.strings("cshare_unknown_workflow")
should_send_workflow = False
raw_workflow = str(data.get("workflow") or "").strip()
if raw_workflow:
try:
canonical_workflow = self._canonical_workflow_name(raw_workflow)
if self._is_builtin_workflow(canonical_workflow):
workflow_name = canonical_workflow
workflow_display = self._format_builtin_workflow_link_html(workflow_name)
workflow_plain_display = workflow_name
else:
workflow_name, workflow_file, workflow_description = await self._build_workflow_file(canonical_workflow)
try:
if workflow_file:
should_send_workflow = True
workflow_display = utils.escape_html(workflow_name)
workflow_plain_display = workflow_name
finally:
if workflow_file:
workflow_file.close()
except Exception as e:
logger.debug("Failed to prepare cshare workflow: %s", e)
post_html, post_plain = self._build_cshare_post(
data,
note,
author_html,
author_plain,
workflow_display,
"",
workflow_plain_display,
)
short_caption_html = self._build_cshare_short_caption(data, note, author_html)
share_id = self._generate_cshare_id()
return {
"share_id": share_id,
"archive_chat_id": utils.get_chat_id(reply),
"archive_message_id": reply.id,
"generation_number": generation_number,
"data": data,
"note": note,
"author_html": author_html,
"author_plain": author_plain,
"workflow_name": workflow_name,
"workflow_description": workflow_description,
"workflow_display": workflow_display,
"workflow_plain_display": workflow_plain_display,
"should_send_workflow": should_send_workflow,
"post_html": post_html,
"post_plain": post_plain,
"short_caption_html": short_caption_html,
}, None
async def _render_cshare_preview(self, message, state):
state_id = str(uuid.uuid4())
self._cshare_preview_states[state_id] = state
markup = [
[{
"text": self.strings("cshare_preview_send_btn"),
"callback": self._cshare_preview_send,
"args": (state_id,),
"style": "success",
}],
[{
"text": self.strings("btn_cancel"),
"callback": self._cshare_preview_cancel,
"args": (state_id,),
"style": "danger",
}],
]
text = self._build_cshare_preview_text(
state.get("post_html"),
state.get("short_caption_html"),
)
return await self._render_inline(message, self._to_inline_emoji(text), markup)
async def _send_cshare_submission(self, state):
archive_message = await self.client.get_messages(
state["archive_chat_id"],
ids=state["archive_message_id"],
)
if not archive_message:
raise UserFacingError("cshare_no_archive", self._plain_text(self.strings("cshare_no_archive")))
image_bytes, image_name = await self._download_cshare_image(
archive_message,
state["generation_number"],
)
if not image_bytes:
raise UserFacingError("cshare_no_image", self._plain_text(self.strings("cshare_no_image")))
target = await self._resolve_cshare_direct_target()
data = state["data"]
note = state.get("note", "")
author_html = state.get("author_html", "")
author_plain = state.get("author_plain", "")
workflow_display = state.get("workflow_display") or self.strings("cshare_unknown_workflow")
workflow_plain_display = state.get("workflow_plain_display") or self.strings("cshare_unknown_workflow")
workflow_name = state.get("workflow_name")
workflow_description = state.get("workflow_description", "")
should_send_workflow = bool(state.get("should_send_workflow"))
share_id = state.get("share_id") or self._generate_cshare_id()
post_html, post_plain = self._build_cshare_post_ru(
data,
note,
author_html,
author_plain,
workflow_display,
"",
workflow_plain_display,
share_id,
)
short_caption_html = None
workflow_file = None
try:
image_file = io.BytesIO(image_bytes)
image_file.name = image_name
sent_message = None
caption_mode = len(post_html) <= 1024
try:
if caption_mode:
sent_message = await self._cshare_send_file(
target,
image_file,
caption=post_html,
force_document=True,
)
else:
sent_message = await self._cshare_send_file(
target,
image_file,
caption=f"Предложка ComfyIdeas #{share_id}",
force_document=True,
)
short_caption_html = self._build_cshare_short_caption_ru(
data,
note,
author_html,
workflow_display,
share_id,
)
finally:
image_file.close()
workflow_sent = True
if should_send_workflow and workflow_name:
workflow_name, workflow_file, workflow_description = await self._build_workflow_file(workflow_name)
workflow_sent = await self._send_cshare_workflow_file(target, workflow_name, workflow_file, workflow_description)
workflow_file = None
if not workflow_sent and workflow_display != self.strings("cshare_unknown_workflow"):
workflow_display = self.strings("cshare_unknown_workflow")
workflow_plain_display = workflow_display
post_html, post_plain = self._build_cshare_post_ru(
data,
note,
author_html,
author_plain,
workflow_display,
"",
workflow_plain_display,
share_id,
)
if not caption_mode:
short_caption_html = self._build_cshare_short_caption_ru(
data,
note,
author_html,
workflow_display,
share_id,
)
if caption_mode and sent_message:
try:
await sent_message.edit(post_html)
except Exception as e:
logger.debug("Failed to update cshare caption: %s", e)
if not caption_mode:
if short_caption_html and sent_message:
try:
await sent_message.edit(short_caption_html)
except Exception as e:
logger.debug("Failed to update cshare short caption: %s", e)
await self._send_cshare_text_file(target, post_plain, share_id)
finally:
if workflow_file:
workflow_file.close()
async def _cshare_preview_send(self, call: InlineCall, state_id: str):
state = self._cshare_preview_states.pop(state_id, None)
if not state:
return await call.edit(text=self._to_inline_emoji(self.strings("cshare_preview_expired")))
try:
await self._send_cshare_submission(state)
except Exception as e:
direct_error = isinstance(e, UserFacingError) and e.key == "cshare_direct_unavailable"
error_text = self.strings("cshare_direct_unavailable") if direct_error else self.strings("cshare_target_error").format(utils.escape_html(str(e)))
if isinstance(e, UserFacingError) and e.key in ("cshare_no_archive", "cshare_no_image"):
error_text = self.strings(e.key)
elif direct_error or self._is_monoforum_reply_error(e) or self._is_chat_write_forbidden(e):
error_text = self.strings("cshare_direct_unavailable")
else:
logger.exception(e)
return await call.edit(text=self._to_inline_emoji(error_text))
await call.edit(text=self._to_inline_emoji(await self._format_cshare_done_with_top()))
async def _cshare_preview_cancel(self, call: InlineCall, state_id: str):
self._cshare_preview_states.pop(state_id, None)
await call.edit(text=self._to_inline_emoji(self.strings("cshare_preview_cancelled")))
async def _send_generation_duplicate(self, media_source, caption, state=None, media_filename=None, media_kind="image"):
settings = self._get_ult_settings()
gens_chat = settings["gens_chat"]
targets = [
target
for target in gens_chat.get("targets", [])
if isinstance(target, dict) and target.get("chat_id")
]
if not targets and gens_chat.get("chat_id"):
targets = [
{
"chat_id": gens_chat.get("chat_id"),
"topic_id": gens_chat.get("topic_id"),
"managed": bool(gens_chat.get("managed", False)),
}
]
if not gens_chat.get("enabled") or not targets:
return
changed_targets = False
checked_targets = []
try:
for target in targets:
checked_target, changed = await self._ensure_gens_archive_target_for_save(gens_chat, target)
checked_targets.append(checked_target)
changed_targets = changed_targets or changed
except Exception as e:
logger.warning("Failed to recreate generation archive target: %s", e)
self._disable_gens_chat(drop_chat_id=True)
raise UserFacingError(
"archive_access_lost",
self._plain_text(self.strings("ult_chat_access_lost")),
) from e
if changed_targets:
self._set_ult_settings(settings)
targets = self._get_gens_archive_targets()
else:
targets = checked_targets
if not targets:
self._disable_gens_chat(drop_chat_id=True)
raise UserFacingError(
"archive_access_lost",
self._plain_text(self.strings("ult_chat_access_lost")),
)
generation_number = self._next_archive_generation_number() if state else None
sent_any = False
failed_access = 0
for target in targets:
try:
archive_caption = (
f"{caption}\nGeneration: #{generation_number}"
if generation_number is not None
else caption
)
if isinstance(media_source, (list, tuple)):
for file_obj in media_source:
if hasattr(file_obj, "seek"):
file_obj.seek(0)
sent_message = await self._send_file_group_result(
target["chat_id"],
list(media_source),
archive_caption,
reply_to=target.get("topic_id"),
force_document=True,
log_errors=False,
)
elif media_kind == "image" and isinstance(media_source, (bytes, bytearray)):
sent_message = await self._send_result(
target["chat_id"],
media_source,
archive_caption,
reply_to=target.get("topic_id"),
force_document=True,
log_errors=False,
)
else:
source_to_send = media_source
close_after = False
if hasattr(source_to_send, "seek"):
source_to_send.seek(0)
elif isinstance(source_to_send, (bytes, bytearray)):
source_to_send = io.BytesIO(source_to_send)
source_to_send.name = media_filename or "comfyui_result.bin"
close_after = True
try:
sent_message = await self._send_file_result(
target["chat_id"],
source_to_send,
archive_caption,
reply_to=target.get("topic_id"),
force_document=True,
log_errors=False,
)
finally:
if close_after:
source_to_send.close()
if not self._archive_message_matches_target(sent_message, target):
raise RuntimeError("Generation archive topic mismatch")
if generation_number is not None:
reply_to = getattr(sent_message, "id", None)
await self._send_archive_prompt_file(
target["chat_id"],
state,
generation_number,
reply_to=reply_to,
allow_unthreaded_fallback=not bool(target.get("topic_id")),
)
sent_any = True
except (ChannelPrivateError, ChatAdminRequiredError, UserNotParticipantError):
self._archive_target_ok.pop(self._gens_archive_target_key(target), None)
failed_access += 1
logger.warning(self._plain_text(self.strings("ult_chat_access_lost")))
except Exception as e:
err_text = self._exception_chain_text(e).lower()
if any(
marker in err_text
for marker in (
"channelprivate",
"chatadminrequired",
"usernotparticipant",
"could not find the input entity",
"input entity for peeruser",
"peeridinvalid",
"channelinvalid",
"chatwriteforbidden",
"msgidinvalid",
"replytomsgidinvalid",
"topicdeleted",
"forumtopicdeleted",
"topic mismatch",
)
):
self._archive_target_ok.pop(self._gens_archive_target_key(target), None)
failed_access += 1
logger.warning(self._plain_text(self.strings("ult_chat_access_lost")))
continue
logger.exception(e)
if not sent_any and failed_access == len(targets):
self._disable_gens_chat(drop_chat_id=True)
raise UserFacingError(
"archive_access_lost",
self._plain_text(self.strings("ult_chat_access_lost")),
)
def _generation_archive_enabled(self):
settings = self._get_ult_settings()
gens_chat = settings.get("gens_chat", {})
if not gens_chat.get("enabled"):
return False
targets = gens_chat.get("targets")
if isinstance(targets, list) and any(isinstance(target, dict) and target.get("chat_id") for target in targets):
return True
return bool(gens_chat.get("chat_id"))
@staticmethod
def _close_archive_media_source(media_source):
sources = media_source if isinstance(media_source, (list, tuple)) else (media_source,)
for source in sources:
if isinstance(source, (bytes, bytearray, tuple)):
continue
try:
source.close()
except Exception:
pass
async def _send_generation_duplicate_background(self, media_source, caption, state=None, media_filename=None, media_kind="image"):
start = time.monotonic()
async with self._archive_semaphore:
try:
await self._send_generation_duplicate(
media_source,
caption,
state=state,
media_filename=media_filename,
media_kind=media_kind,
)
logger.debug("Generation archive save finished in %.2fs", time.monotonic() - start)
except asyncio.CancelledError:
raise
except UserFacingError as e:
logger.warning("Generation archive save failed: %s", e)
except Exception as e:
logger.exception("Generation archive save failed: %s", e)
finally:
self._close_archive_media_source(media_source)
def _schedule_generation_duplicate(self, media_source, caption, state=None, media_filename=None, media_kind="image"):
if self._unloading or media_source is None:
self._close_archive_media_source(media_source)
return None
if len(self._archive_tasks) >= _ARCHIVE_MAX_PENDING:
logger.warning("Generation archive skipped because the background queue is full")
self._close_archive_media_source(media_source)
return None
task = asyncio.create_task(
self._send_generation_duplicate_background(
media_source,
caption,
state=dict(state or {}) if state is not None else None,
media_filename=media_filename,
media_kind=media_kind,
)
)
self._archive_tasks.add(task)
task.add_done_callback(self._archive_tasks.discard)
return task
def _get_enhance_error_text(self, error):
provider = self._get_prompt_provider()
provider_name = self._format_provider_name(provider)
key_config_map = {
"gemini": self.strings("ult_ai_key_path"),
"groq": self.strings("ult_ai_key_path"),
"openrouter": self.strings("ult_ai_key_path"),
"grok": self.strings("ult_ai_key_path"),
"qwen": self.strings("ult_ai_key_path"),
"deepseek": self.strings("ult_ai_key_path"),
"nvidiaapi": self.strings("ult_ai_key_path"),
}
if error == "no_key":
return self.strings("enhance_no_key").format(
provider_name,
utils.escape_html(key_config_map.get(provider, "")),
)
if error == "dependency_missing":
return self.strings("enhance_dependency_missing")
if error == "expired":
return self.strings("enhance_key_expired").format(provider_name)
if error == "rate_limit":
return self.strings("enhance_rate_limit").format(provider_name)
if error == "censored":
return self.strings("enhance_censored").format(provider_name)
if error == "timeout":
return self.strings("enhance_timeout").format(provider_name)
if error == "error":
return self.strings("enhance_service_error").format(provider_name)
if error == "vision_unsupported":
return self.strings("enhance_vision_unsupported").format(provider_name)
if error == "comfy_text_unavailable":
return self.strings("provider_comfy_text_unavailable")
if error == "comfy_text_empty":
return self.strings("provider_comfy_text_empty")
if error == "comfy_text_failed":
return self.strings("provider_comfy_text_failed")
if error == "comfy_text_invalid_workflow":
return self.strings("provider_comfy_text_invalid_workflow")
if str(error).startswith("comfy_text_error:"):
detail = str(error).split(":", 1)[1].strip()
return self.strings("provider_comfy_text_error").format(
utils.escape_html(detail)
)
return self.strings("enhance_error").format(
provider_name, utils.escape_html(str(error))
)
async def _run_direct_generation(self, target, state: dict, selected_loras=None):
if selected_loras is None:
selected_loras = state.get("selected_loras")
selected_loras = self._normalize_selected_loras(selected_loras)
if selected_loras and self._is_comfy_cloud():
await self._ensure_cloud_lora_assets_ready(selected_loras)
catalog = await self._get_available_lora_catalog()
resolved_loras = self._normalize_selected_loras(
selected_loras,
catalog.get("all"),
)
if len(resolved_loras) != len(selected_loras):
unresolved = [
name
for name in selected_loras
if not self._resolve_lora_catalog_name(name, catalog.get("all"))
]
raise UserFacingError(
"lora_cloud_unavailable",
self._plain_text(
self.strings("lora_cloud_unavailable").format(
", ".join(unresolved[:5])
)
),
)
selected_loras = resolved_loras
state["selected_loras"] = dict(selected_loras)
state["positive"] = self._apply_lora_trigger_words(
state.get("positive"),
selected_loras,
)
display_positive, display_model, display_wf = self._build_display_bundle(state)
easter_egg = state.get("easter_egg")
client_id = str(uuid.uuid4())
self._generation_runtime[client_id] = {
"prompt_id": None,
"phase": "waiting_local",
"executing": False,
"cancelled": False,
"current_node_id": None,
}
cancel_markup = [[{
"text": self.strings("cancel_btn"),
"callback": self._cancel_generation,
"args": (client_id,),
"style": "danger",
"emoji_id": "5121063440311386962",
}]]
if isinstance(target, Message):
initial_status_text = self._format_status_text(
display_positive, display_model, display_wf, is_inline=False, easter_egg=easter_egg, status_key="fmt_loading_model",
)
if state.get("reuse_status_message"):
status_is_inline = False
status_form = await self._safe_answer(target, initial_status_text) or target
else:
status_is_inline = not state.get("plain_status")
if status_is_inline:
try:
status_form = await self._create_inline_form(
message=target,
text=self._format_status_text(
display_positive, display_model, display_wf, is_inline=True, easter_egg=easter_egg, status_key="fmt_loading_model",
),
reply_markup=cancel_markup,
)
except Exception as e:
logger.debug("Failed to create inline generation form: %s", e)
status_is_inline = False
else:
status_form = status_form
if not status_is_inline and not state.get("reuse_status_message"):
status_is_inline = False
status_form = await self._safe_answer(
target,
initial_status_text,
)
if not status_form:
self._cleanup_generation_runtime(client_id)
self._cleanup_input_file(state)
return
else:
status_is_inline = True
try:
await target.edit(
text=self._format_status_text(
display_positive, display_model, display_wf, is_inline=True, easter_egg=easter_egg, status_key="fmt_loading_model",
),
reply_markup=cancel_markup,
)
status_form = target
except Exception as e:
logger.debug("Failed to edit inline generation status: %s", e)
try:
await target.answer(
self._plain_text(self.strings("ult_state_expired")),
show_alert=True,
)
except Exception as answer_error:
logger.debug("Failed to answer expired inline generation action: %s", answer_error)
self._cleanup_generation_runtime(client_id)
self._cleanup_input_file(state)
return
generation_slot_acquired = False
if self._semaphore.locked():
try:
queue_text = self._format_status_text(
display_positive,
display_model,
display_wf,
is_inline=status_is_inline,
easter_egg=easter_egg,
status_text=self.strings("queue_local_waiting"),
)
if status_is_inline:
await status_form.edit(text=queue_text, reply_markup=cancel_markup)
else:
await utils.answer(status_form, self._apply_emoji_theme(queue_text))
except Exception:
pass
await self._semaphore.acquire()
generation_slot_acquired = True
try:
self._set_generation_phase(client_id, "preparing")
if self._unloading:
try:
await status_form.delete()
except Exception:
pass
self._cleanup_generation_runtime(client_id)
self._cleanup_input_file(state)
return
self._active_generations += 1
try:
try:
preparing_text = self._format_status_text(
display_positive,
display_model,
display_wf,
is_inline=status_is_inline,
easter_egg=easter_egg,
status_key="fmt_loading_model",
)
if status_is_inline:
await status_form.edit(text=preparing_text, reply_markup=cancel_markup)
else:
await utils.answer(status_form, self._apply_emoji_theme(preparing_text))
except Exception:
pass
await self._raise_if_generation_cancelled(client_id)
async def _update_connection_retry(next_attempt, total_attempts):
retry_text = self._format_status_text(
display_positive,
display_model,
display_wf,
is_inline=status_is_inline,
easter_egg=easter_egg,
status_text=self.strings("connecting_retry").format(next_attempt, total_attempts),
)
if status_is_inline:
await status_form.edit(text=retry_text, reply_markup=cancel_markup)
else:
await utils.answer(status_form, self._apply_emoji_theme(retry_text))
if self._is_comfy_cloud():
api_key = await self._select_cloud_api_key()
runtime = self._generation_runtime.get(client_id)
if runtime is not None:
runtime["cloud_api_key"] = api_key
runtime["backend"] = _COMFY_BACKEND_CLOUD
if not state.get("health_checked"):
health = True if self._is_comfy_cloud() else await self._health_check(on_retry=_update_connection_retry)
if not health:
raise UserFacingError("unavailable", self._plain_text(self.strings("unavailable")))
await self._raise_if_generation_cancelled(client_id)
if self._is_comfy_cloud():
api_key = self._active_cloud_api_key.get() or await self._select_cloud_api_key()
runtime = self._generation_runtime.get(client_id)
if runtime is not None:
runtime["cloud_api_key"] = api_key
runtime["backend"] = _COMFY_BACKEND_CLOUD
wf_data = await self._ensure_workflow_data(state["wf_name"])
if not wf_data:
available = ", ".join(self._get_all_workflow_names())
raise ValueError(
self.strings("wf_not_found").format(
utils.escape_html(state["wf_name"]),
utils.escape_html(available),
)
)
await self._raise_if_generation_cancelled(client_id)
input_filename = await self._upload_state_input_image(state)
input_video_filename = await self._upload_state_input_video(state)
await self._raise_if_generation_cancelled(client_id)
prepared_workflow, final_output_node = await self._prepare_workflow(
wf_data,
state["positive"],
state["negative"],
state["seed"],
state["width"],
state["height"],
state["model"],
state["denoise"],
state["steps"],
state["cfg"],
state.get("sampler_name"),
state.get("scheduler"),
input_filename,
state["wf_name"],
state.get("limited_mode", False),
input_video_filename=input_video_filename,
)
if selected_loras:
prepared_workflow, lora_result = self._inject_loras(
prepared_workflow,
wf_data,
selected_loras,
)
if not lora_result.get("supported"):
raise UserFacingError(
"lora_apply_unsupported",
self._plain_text(
self.strings("lora_apply_unsupported")
),
)
capacity = lora_result.get("capacity")
if capacity is not None and len(selected_loras) > capacity:
raise UserFacingError(
"lora_apply_slots",
self._plain_text(
self.strings("lora_apply_slots").format(
capacity,
len(selected_loras),
)
),
)
prepared_workflow = self._apply_cloud_batch_size(
prepared_workflow,
state.get("cloud_batch", 1),
)
await self._raise_if_cloud_workflow_unsupported(prepared_workflow)
self._sync_generation_state_from_workflow(state, wf_data, prepared_workflow)
runtime = self._generation_runtime.get(client_id)
if runtime is not None:
runtime["workflow"] = prepared_workflow
async def _do_queue():
await self._raise_if_generation_cancelled(client_id)
self._set_generation_phase(client_id, "queueing")
return await self._retry(self._queue_prompt, prepared_workflow, client_id)
_, history = await self._wait_ws(
client_id,
_do_queue,
status_form=status_form,
cancel_markup=cancel_markup,
display_positive=display_positive,
display_model=display_model,
display_wf=display_wf,
expected_output_node=final_output_node,
easter_egg=easter_egg,
workflow=prepared_workflow,
status_is_inline=status_is_inline,
generation_state=state,
)
try:
if status_is_inline:
await status_form.edit(text=self.strings("inline_uploading"))
else:
await utils.answer(status_form, self._apply_emoji_theme(self.strings("uploading")))
except Exception:
pass
self._set_generation_phase(client_id, "uploading")
output_kind = (wf_data.get("mapping") or {}).get("output_kind") or "image"
media_kind = "video" if output_kind in ("video", "mixed") else "image"
media_infos = []
media_info = (
self._extract_media_info(history, final_output_node, ("videos", "video", "animated", "animations", "gifs", "audio", "images"))
if media_kind == "video"
else self._extract_image_info(history, final_output_node)
)
if media_kind == "image":
media_infos = self._extract_image_infos(history, final_output_node)
if media_infos:
media_info = media_infos[0]
if not media_info:
logger.warning(
"No media found in ComfyUI history for node %s; output_kind=%s; outputs=%s",
final_output_node,
output_kind,
self._history_output_summary(history),
)
raise UserFacingError("no_images", self._plain_text(self.strings("no_images")))
media_kind = self._media_kind_from_info(media_info, media_kind)
media_bio = None
source_media_bios = []
archive_media_source = None
archive_media_filename = None
try:
retrieve_started = time.monotonic()
if media_kind == "image" and len(media_infos) > 1:
for item in media_infos:
source_media_bios.append(await self._retry(self._retrieve_comfy_media, item, "image"))
media_bio = source_media_bios[0]
else:
media_bio = await self._retry(self._retrieve_comfy_media, media_info, media_kind)
logger.debug("Generation media retrieve finished in %.2fs", time.monotonic() - retrieve_started)
media_size = self._file_size(media_bio) or 0
send_as_file = (
media_kind != "image"
or not self._telegram_photo_supported(media_info)
or self.config["output_format"] == "document_png"
or media_size > 50 * 1024 * 1024
)
generation_time = None
if self._show_generation_time_result():
generation_time = self._format_generation_time_value(
state.get("generation_duration")
)
generation_caption_hidden = (
self._telegram_censorship_enabled()
and media_kind == "image"
and self.config["output_format"] != "document_png"
)
result_display_positive = (
self._build_display_bundle(state, hidden_prompt=True)[0]
if generation_caption_hidden
else display_positive
)
caption = self._format_success_text(
display_positive,
display_model,
display_wf,
selected_loras=state.get("selected_loras"),
generation_time=generation_time,
)
result_caption = self._format_success_text(
result_display_positive,
display_model,
display_wf,
selected_loras=state.get("selected_loras"),
generation_time=generation_time,
)
auto_delete_delay = state.get("auto_delete_result_delay")
send_result_as_self = bool(state.get("trigger_origin"))
if auto_delete_delay:
result_caption = "\n".join(
[
result_caption,
self.strings("trigger_autodelete_caption").format(
self._format_duration(auto_delete_delay)
),
]
)
if generation_slot_acquired:
self._semaphore.release()
generation_slot_acquired = False
send_started = time.monotonic()
if media_kind == "image":
if len(source_media_bios) > 1:
sent_message = await self._send_file_group_result(
state["chat_id"],
source_media_bios,
result_caption,
reply_to=state["reply_to"],
force_document=True,
send_as_self=send_result_as_self,
)
elif send_as_file:
sent_message = await self._send_file_result(
state["chat_id"],
media_bio,
result_caption,
reply_to=state["reply_to"],
force_document=True,
send_as_self=send_result_as_self,
)
else:
sent_message = await self._send_result(
state["chat_id"],
media_bio,
result_caption,
reply_to=state["reply_to"],
spoiler=generation_caption_hidden,
send_as_self=send_result_as_self,
)
else:
sent_message = await self._send_file_result(
state["chat_id"],
media_bio,
result_caption,
reply_to=state["reply_to"],
force_document=True,
send_as_self=send_result_as_self,
)
logger.debug("Generation Telegram send finished in %.2fs", time.monotonic() - send_started)
self._increment_total_generation_count()
if auto_delete_delay and sent_message:
self._track_auto_delete(sent_message, auto_delete_delay)
self._record_generation_duration_stat(state)
if self._generation_archive_enabled() and len(self._archive_tasks) < _ARCHIVE_MAX_PENDING:
if len(source_media_bios) > 1:
archive_media_source = source_media_bios
source_media_bios = []
media_bio = None
else:
archive_media_source = media_bio
archive_media_filename = getattr(media_bio, "name", None)
media_bio = None
self._schedule_generation_duplicate(
archive_media_source,
caption,
state,
media_filename=archive_media_filename,
media_kind=media_kind,
)
finally:
if media_bio and media_bio not in source_media_bios:
media_bio.close()
for source_bio in source_media_bios:
try:
source_bio.close()
except Exception:
pass
self._store_last_generation(state)
try:
await status_form.delete()
except Exception:
pass
except asyncio.CancelledError:
cancel_reason = self._get_cancel_reason(client_id) or "unknown"
logger.warning("Generation cancelled: client_id=%s reason=%s", client_id, cancel_reason)
try:
cancelled_text = f"{self.strings('cancelled')} {utils.escape_html(cancel_reason)}"
cancelled_text = self._to_inline_emoji(cancelled_text)
if status_is_inline:
await status_form.edit(text=cancelled_text, reply_markup=None)
else:
await utils.answer(status_form, self._apply_emoji_theme(cancelled_text))
except Exception:
pass
except Exception as e:
if not await self._handle_trigger_generation_error(state, e, status_form):
await self._handle_gen_error(status_form, e)
finally:
if self._is_comfy_cloud():
self._active_cloud_api_key.set(None)
self._active_generations = max(0, self._active_generations - 1)
self._cleanup_input_file(state)
finally:
if generation_slot_acquired:
self._semaphore.release()
async def _launch_generation_flow(self, target, state: dict):
if state.get("use_lora_picker"):
state_id = str(uuid.uuid4())
lora_state = dict(state)
lora_state["selected"] = self._normalize_lora_preset_entries(state.get("lora_entries"))
lora_state["page"] = 0
lora_state["favorites_only"] = False
lora_state["filter_mode"] = "all"
lora_state["search_query"] = ""
self._lora_states[state_id] = lora_state
status_target = target
if isinstance(target, Message):
if state.get("reuse_status_message"):
status_target = await self._update_generation_preflight(target, "lora_loading") or target
else:
status_target = await self._safe_answer(target, self.strings("lora_loading")) or target
else:
await self._update_generation_preflight(target, "lora_loading")
await self._render_lora_list(status_target, state_id)
return
await self._run_direct_generation(target, state)
def _build_enhance_chat_request(self, current_prompt, edit_text):
return (
"Current prompt:\n"
f"{str(current_prompt or '').strip()}\n\n"
"Requested edits:\n"
f"{str(edit_text or '').strip()}\n\n"
"Return only the updated final prompt. Keep important existing details unless the requested edits change them."
)
async def _start_enhance_chat(
self,
target,
*,
mode,
prompt,
original_prompt=None,
generation_state=None,
model=None,
image_path=None,
):
state_id = str(uuid.uuid4())
self._enhance_chat_states[state_id] = {
"mode": mode,
"prompt": str(prompt or "").strip(),
"original_prompt": str(original_prompt or prompt or "").strip(),
"generation_state": generation_state,
"model": model or (generation_state or {}).get("model") or self.config["model_name"] or "unknown",
"image_path": image_path or (generation_state or {}).get("input_image_path"),
"edits": [],
}
await self._render_enhance_chat(target, state_id)
async def _render_enhance_chat(self, target, state_id: str):
state = self._enhance_chat_states.get(state_id)
if not state:
text = self._to_inline_emoji(self.strings("ult_state_expired"))
if isinstance(target, InlineCall):
return await target.edit(text=text)
return await self._safe_answer(target, text)
edit_count = min(len(state.get("edits") or []), 100)
title = f"{_PREFLIGHT_EYES_INLINE} {self.strings('enhance_chat_title').format(edit_count)}"
prompt_parts = []
original_prompt = str(state.get("original_prompt") or "").strip()
if original_prompt:
prompt_parts.extend([
self.strings("enhance_cmd_original"),
self._format_prompt_for_display(original_prompt, truncate=False),
])
prompt_parts.extend([
self.strings("enhance_cmd_result"),
self._format_prompt_for_display(state.get("prompt"), truncate=False),
])
text = f"{title}\n\n" + "\n".join(prompt_parts)
markup = [
[{
"text": self.strings("enhance_chat_edit_btn"),
"input": self.strings("enhance_chat_input"),
"handler": self._enhance_chat_edit_input,
"args": (state_id,),
}],
]
if state.get("mode") == "generate":
markup.append([
{
"text": self.strings("ult_btn_generate"),
"callback": self._enhance_chat_generate,
"args": (state_id,),
"style": "success",
"emoji_id": "5206607081334906820",
},
{
"text": self.strings("ult_btn_cancel"),
"callback": self._enhance_chat_cancel,
"args": (state_id,),
"style": "danger",
"emoji_id": "5121063440311386962",
},
])
else:
markup.append([{
"text": self.strings("btn_close"),
"callback": self._enhance_chat_cancel,
"args": (state_id,),
"style": "danger",
}])
await self._render_inline(target, self._to_inline_emoji(text), markup)
async def _enhance_chat_edit_input(self, call: InlineCall, query: str, state_id: str):
state = self._enhance_chat_states.get(state_id)
if not state:
return await call.edit(text=self._to_inline_emoji(self.strings("ult_state_expired")))
edit_text = str(query or "").strip()
if not edit_text:
try:
await call.answer(self._plain_text(self.strings("enhance_chat_empty")), show_alert=True)
except Exception:
pass
return
edits = list(state.get("edits") or [])
if len(edits) >= 100:
try:
await call.answer(self._plain_text(self.strings("enhance_chat_limit")), show_alert=True)
except Exception:
pass
return await self._render_enhance_chat(call, state_id)
try:
await call.edit(text=self._to_inline_emoji(self.strings("status_enhancing")))
except Exception:
pass
enhanced, error = await self._enhance_prompt(
self._build_enhance_chat_request(state.get("prompt"), edit_text),
state.get("model") or "unknown",
image_path=state.get("image_path"),
)
if error:
try:
await call.answer(self._plain_text(self._get_enhance_error_text(error)), show_alert=True)
except Exception:
pass
return await self._render_enhance_chat(call, state_id)
state["prompt"] = str(enhanced or "").strip()
edits.append(edit_text)
state["edits"] = edits[:100]
generation_state = state.get("generation_state")
if isinstance(generation_state, dict):
generation_state["positive"] = state["prompt"]
generation_state["enhanced"] = state["prompt"] != generation_state.get("original_positive")
self._enhance_chat_states[state_id] = state
await self._render_enhance_chat(call, state_id)
async def _enhance_chat_generate(self, call: InlineCall, state_id: str):
state = self._enhance_chat_states.pop(state_id, None)
if not state:
return await call.edit(text=self._to_inline_emoji(self.strings("ult_state_expired")))
generation_state = state.get("generation_state")
if not isinstance(generation_state, dict):
return await self._enhance_chat_cancel(call, state_id)
generation_state["positive"] = state.get("prompt") or generation_state.get("positive")
generation_state["enhanced"] = generation_state["positive"] != generation_state.get("original_positive")
generation_state["enhance_prompt"] = True
await self._maybe_render_cloud_confirm(call, generation_state)
async def _enhance_chat_cancel(self, call: InlineCall, state_id: str):
state = self._enhance_chat_states.pop(state_id, None)
generation_state = state.get("generation_state") if isinstance(state, dict) else None
if isinstance(generation_state, dict):
self._cleanup_input_file(generation_state)
try:
await call.delete()
except Exception:
pass
@staticmethod
def _format_credit_value(value):
try:
return f"{float(value):g}"
except (TypeError, ValueError):
return str(value)
def _parse_cloud_cost_range(self, description):
text = str(description or "")
credit_word = r"(?:\u043a\u0440\u0435\u0434\w*|credits?)"
range_match = re.search(
rf"(\d+(?:[.,]\d+)?)\s*(?:-|\u2013|\u2014)\s*(\d+(?:[.,]\d+)?)\s*{credit_word}",
text,
flags=re.IGNORECASE,
)
if range_match:
return (
float(range_match.group(1).replace(",", ".")),
float(range_match.group(2).replace(",", ".")),
)
single_match = re.search(rf"(\d+(?:[.,]\d+)?)\s*{credit_word}", text, flags=re.IGNORECASE)
if single_match:
value = float(single_match.group(1).replace(",", "."))
return value, value
return None
def _cloud_cost_workflow_name(self, wf_name):
wf_name = self._canonical_workflow_name(wf_name)
return _CLOUD_WORKFLOW_COST_ALIASES.get(wf_name, wf_name)
async def _estimate_cloud_cost(self, state):
wf_name = state.get("wf_name") or self.get("default_workflow", _DEFAULT_CLOUD_WORKFLOW_NAME)
cost_wf_name = self._cloud_cost_workflow_name(wf_name)
cost_range = self._parse_cloud_cost_range(self._workflow_description(cost_wf_name))
if not cost_range:
return None
batch = self._coerce_int(state.get("cloud_batch"), 1, 1, 8)
low, high = cost_range
credit_word = "\u043a\u0440\u0435\u0434"
base_text = (
f"{self._format_credit_value(low)} {credit_word}"
if low == high
else f"{self._format_credit_value(low)}-{self._format_credit_value(high)} {credit_word}"
)
if batch <= 1:
return base_text
total_low = low * batch
total_high = high * batch
total_text = (
f"{self._format_credit_value(total_low)} {credit_word}"
if total_low == total_high
else f"{self._format_credit_value(total_low)}-{self._format_credit_value(total_high)} {credit_word}"
)
return f"{base_text} x {batch} = {total_text}"
async def _validate_manual_cloud_model_or_raise(self, state):
if not self._is_comfy_cloud() or self._cloud_model_as_workflow():
return
model = str((state or {}).get("model") or self.config["model_name"] or "").strip()
if not model:
return
field_models = await self._get_cloud_available_models_by_field()
if not field_models:
return
if self._resolve_model_name_from_fields(model, field_models):
return
try:
user_groups = await self._get_cloud_user_model_asset_groups()
user_models = [
item
for models in user_groups.values()
for item in models
]
if model in user_models:
return
model_key = self._model_match_key(model)
if model_key and any(self._model_match_key(item) == model_key for item in user_models):
return
except Exception as e:
logger.debug("Cloud user asset model validation failed: %s", e)
raise UserFacingError("cloud_model_not_found", self._plain_text(self.strings("cloud_model_not_found")))
def _cloud_confirm_prompt_text(self, prompt):
return self._format_prompt_for_display(prompt, truncate=True)
async def _render_cloud_confirm(self, target, state_id: str):
state = self._cloud_confirm_states.get(state_id)
if not state:
if isinstance(target, InlineCall):
return await target.edit(text=self._to_inline_emoji(self.strings("ult_state_expired")))
return await self._safe_answer(target, self.strings("ult_state_expired"))
balance = await self._format_cloud_balance_for_ui()
cost = await self._estimate_cloud_cost(state)
cost_text = str(cost) if cost is not None else self.strings("cloud_confirm_cost_unavailable")
batch = self._coerce_int(state.get("cloud_batch"), 1, 1, 8)
state["cloud_batch"] = batch
lines = [
self.strings("cloud_confirm_title"),
self.strings("cloud_confirm_balance").format(utils.escape_html(balance)),
self.strings("cloud_confirm_cost").format(utils.escape_html(cost_text)),
self.strings("cloud_confirm_batch").format(batch),
self.strings("cloud_confirm_workflow").format(utils.escape_html(str(state.get("wf_name") or "default"))),
self.strings("cloud_confirm_model").format(
utils.escape_html(self._format_model_name(state.get("model") or self.strings("not_set"), max_length=None))
),
"",
self.strings("cloud_confirm_prompt"),
self._cloud_confirm_prompt_text(state.get("positive")),
]
text = "" + "\n".join(lines) + "
"
markup = [
[
{
"text": self.strings("cloud_confirm_btn_generate"),
"callback": self._cloud_confirm_generate,
"args": (state_id,),
"style": "success",
"emoji_id": "5206607081334906820",
},
{
"text": self.strings("cloud_confirm_btn_batch").format(batch),
"input": self.strings("cloud_confirm_input_batch"),
"handler": self._cloud_confirm_batch_input,
"args": (state_id,),
},
],
]
if state.get("enhance_prompt") or state.get("enhanced"):
markup.append([{
"text": self.strings("cloud_confirm_btn_edit"),
"callback": self._cloud_confirm_edit_prompt,
"args": (state_id,),
"style": "primary",
}])
markup.append([{
"text": self.strings("ult_btn_cancel"),
"callback": self._cloud_confirm_cancel,
"args": (state_id,),
"style": "danger",
"emoji_id": "5121063440311386962",
}])
await self._render_inline(target, self._to_inline_emoji(text), markup)
async def _maybe_render_cloud_confirm(self, target, generation_state, skip_confirm=False):
if not self._is_comfy_cloud() or generation_state.get("cloud_confirmed"):
return await self._launch_generation_flow(target, generation_state)
if not self._get_cloud_api_keys():
return await self._handle_gen_error(target, UserFacingError("cloud_no_key", self._plain_text(self.strings("cloud_no_key"))))
generation_state["cloud_batch"] = self._coerce_int(generation_state.get("cloud_batch"), 1, 1, 8)
if skip_confirm:
try:
await self._validate_manual_cloud_model_or_raise(generation_state)
except Exception as e:
return await self._handle_gen_error(target, e)
generation_state["cloud_confirmed"] = True
return await self._launch_generation_flow(target, generation_state)
state_id = str(uuid.uuid4())
self._cloud_confirm_states[state_id] = generation_state
await self._render_cloud_confirm(target, state_id)
async def _cloud_confirm_generate(self, call: InlineCall, state_id: str):
state = self._cloud_confirm_states.pop(state_id, None)
if not state:
return await call.edit(text=self._to_inline_emoji(self.strings("ult_state_expired")))
try:
await self._validate_manual_cloud_model_or_raise(state)
except Exception as e:
self._cloud_confirm_states[state_id] = state
return await self._handle_gen_error(call, e)
state["cloud_confirmed"] = True
await self._launch_generation_flow(call, state)
async def _cloud_confirm_cancel(self, call: InlineCall, state_id: str):
state = self._cloud_confirm_states.pop(state_id, None)
if isinstance(state, dict):
self._cleanup_input_file(state)
try:
await call.delete()
except Exception:
pass
async def _cloud_confirm_batch_input(self, call: InlineCall, query: str, state_id: str):
state = self._cloud_confirm_states.get(state_id)
if not state:
return await call.edit(text=self._to_inline_emoji(self.strings("ult_state_expired")))
try:
batch = int(str(query or "").strip())
except ValueError:
batch = 0
if batch < 1 or batch > 8:
try:
await call.answer(self.strings("cloud_confirm_batch_bad"), show_alert=True)
except Exception:
pass
return await self._render_cloud_confirm(call, state_id)
state["cloud_batch"] = batch
self._cloud_confirm_states[state_id] = state
try:
await call.answer(self.strings("cloud_confirm_batch_saved").format(batch))
except Exception:
pass
await self._render_cloud_confirm(call, state_id)
async def _cloud_confirm_edit_prompt(self, call: InlineCall, state_id: str):
state = self._cloud_confirm_states.pop(state_id, None)
if not state:
return await call.edit(text=self._to_inline_emoji(self.strings("ult_state_expired")))
await self._start_enhance_chat(
call,
mode="generate",
prompt=state.get("positive"),
original_prompt=state.get("original_positive") or state.get("positive"),
generation_state=state,
model=state.get("model") or "unknown",
image_path=state.get("input_image_path"),
)
async def _render_enhance_confirm(self, target, state_id: str):
state = self._enhance_confirm_states.get(state_id)
if not state:
if isinstance(target, InlineCall):
await target.edit(text=self.strings("ult_state_expired"))
else:
await utils.answer(target, self._apply_emoji_theme(self.strings("ult_state_expired")))
return
lines = [
self.strings("ult_confirm_title"),
self.strings("ult_confirm_model").format(
utils.escape_html(self._format_model_name(state["model"]))
),
self.strings("ult_confirm_workflow").format(
utils.escape_html(state["wf_name"])
),
"",
f"{self.strings('ult_confirm_source')}:",
self._format_prompt_for_display(state["original_positive"], truncate=False),
"",
]
if state.get("censored"):
lines.append(self.strings("ult_confirm_censored"))
else:
lines.extend(
[
f"{self.strings('ult_confirm_result')}:",
self._format_prompt_for_display(state["enhanced_positive"], truncate=False),
]
)
markup = [
[
{
"text": self.strings("enhance_chat_edit_btn"),
"callback": self._enhance_confirm_open_chat,
"args": (state_id,),
"style": "primary",
}
],
[
{
"text": self.strings("ult_btn_regenerate"),
"callback": self._enhance_confirm_regenerate,
"args": (state_id,),
"style": "primary",
"emoji_id": "5258053877669657143",
}
],
[
{
"text": self.strings("ult_btn_generate"),
"callback": self._enhance_confirm_generate,
"args": (state_id,),
"style": "success",
"emoji_id": "5206607081334906820",
},
{
"text": self.strings("ult_btn_cancel"),
"callback": self._enhance_confirm_cancel,
"args": (state_id,),
"style": "danger",
"emoji_id": "5121063440311386962",
},
],
]
text = "\n".join(lines)
await self._render_inline(target, text, markup)
async def _enhance_confirm_open_chat(self, call: InlineCall, state_id: str):
state = self._enhance_confirm_states.pop(state_id, None)
if not state:
return await call.edit(text=self._to_inline_emoji(self.strings("ult_state_expired")))
prompt = state.get("enhanced_positive") or state.get("positive") or state.get("original_positive")
state["positive"] = prompt
state["enhanced_positive"] = prompt
state["censored"] = False
await self._start_enhance_chat(
call,
mode="generate",
prompt=prompt,
original_prompt=state.get("original_positive"),
generation_state=state,
model=state.get("model") or "unknown",
image_path=state.get("input_image_path"),
)
async def _enhance_confirm_regenerate(self, call: InlineCall, state_id: str):
state = self._enhance_confirm_states.get(state_id)
if not state:
return await call.edit(text=self.strings("ult_state_expired"))
try:
await call.edit(text=self._to_inline_emoji(self.strings("status_enhancing")))
except Exception:
pass
enhanced, error = await self._enhance_prompt(
state["original_positive"],
state["model"] or "unknown",
image_path=state.get("input_image_path"),
)
if error and error != "censored":
state = self._enhance_confirm_states.pop(state_id, None)
if state:
self._cleanup_input_file(state)
return await call.edit(text=self._to_inline_emoji(self._get_enhance_error_text(error)))
if error == "censored":
state["censored"] = True
state["positive"] = state["original_positive"]
state["enhanced_positive"] = None
state["enhanced"] = False
else:
state["censored"] = False
state["positive"] = enhanced
state["enhanced_positive"] = enhanced
state["enhanced"] = enhanced != state["original_positive"]
self._enhance_confirm_states[state_id] = state
await self._render_enhance_confirm(call, state_id)
async def _enhance_confirm_generate(self, call: InlineCall, state_id: str):
state = self._enhance_confirm_states.pop(state_id, None)
if not state:
return await call.edit(text=self.strings("ult_state_expired"))
generation_state = self._build_generation_state(
positive=state["positive"],
original_positive=state["original_positive"],
negative=state["negative"],
width=state["width"],
height=state["height"],
seed=state["seed"],
denoise=state["denoise"],
steps=state["steps"],
cfg=state["cfg"],
wf_name=state["wf_name"],
model=state["model"],
input_filename=state["input_filename"],
input_image_name=state.get("input_image_name"),
input_image_path=state.get("input_image_path"),
input_video_name=state.get("input_video_name"),
input_video_path=state.get("input_video_path"),
chat_id=state["chat_id"],
reply_to=state["reply_to"],
enhance_prompt=state["enhance_prompt"],
use_lora_picker=state["use_lora_picker"],
enhanced=state.get("enhanced", False),
easter_egg=state.get("easter_egg"),
selected_loras=state.get("selected_loras"),
auto_delete_result_delay=state.get("auto_delete_result_delay"),
trigger_origin=state.get("trigger_origin"),
sampler_name=state.get("sampler_name"),
scheduler=state.get("scheduler"),
limited_mode=state.get("limited_mode", False),
)
await self._maybe_render_cloud_confirm(call, generation_state)
async def _enhance_confirm_cancel(self, call: InlineCall, state_id: str):
state = self._enhance_confirm_states.pop(state_id, None)
if state:
self._cleanup_input_file(state)
try:
await call.delete()
except Exception:
pass
def _max_image_pixels(self):
return self._coerce_int(self.config["max_image_pixels"], 40000000, 0, 400000000)
def _ensure_safe_photo_pixels(self, image):
max_pixels = self._max_image_pixels()
if max_pixels and image.width * image.height > max_pixels:
raise UserFacingError("image_too_many_pixels", max_pixels=max_pixels)
@staticmethod
def _image_source_stream(image_source):
if isinstance(image_source, (bytes, bytearray)):
return io.BytesIO(image_source), None
try:
duplicate = os.fdopen(os.dup(image_source.fileno()), "rb")
duplicate.seek(0)
return duplicate, image_source
except Exception:
position = image_source.tell()
image_source.seek(0)
return image_source, (image_source, position)
@staticmethod
def _restore_image_source(source, restore_target):
try:
if isinstance(restore_target, tuple):
restore_target[0].seek(restore_target[1])
elif restore_target is not None:
restore_target.seek(0)
except Exception:
pass
if source is not restore_target and not isinstance(restore_target, tuple):
try:
source.close()
except Exception:
pass
def _prepare_output_image(self, image_source, as_document):
source, restore_target = self._image_source_stream(image_source)
img = None
converted = None
out = tempfile.NamedTemporaryFile(
mode="w+b",
prefix="comfyimagegen_result_",
suffix=".png" if as_document else ".jpg",
)
try:
img = Image.open(source)
if as_document:
converted = img if img.mode in ("RGBA", "RGB") else img.convert("RGBA")
converted.save(out, format="PNG")
else:
self._ensure_safe_photo_pixels(img)
converted = img if img.mode == "RGB" else img.convert("RGB")
converted.save(out, format="JPEG", quality=95)
out.seek(0)
return out
except Exception:
out.close()
raise
finally:
if converted is not None and converted is not img:
converted.close()
if img:
img.close()
self._restore_image_source(source, restore_target)
async def _prepare_image_upload_file(self, image_source, as_document=False):
async with self._image_processing_semaphore:
return await utils.run_sync(self._prepare_output_image, image_source, as_document)
async def _send_prepared_result_file(self, chat_id, file_obj, send_kwargs, send_as_self):
file_obj.seek(0)
if send_as_self:
return await self._send_file_as_self_if_possible(chat_id, file_obj, **send_kwargs)
return await self.client.send_file(
self._send_peer_candidate(chat_id),
file_obj,
**send_kwargs,
)
@staticmethod
def _exception_chain_text(exc):
parts = []
seen = set()
current = exc
while current is not None and id(current) not in seen:
seen.add(id(current))
parts.append(f"{type(current).__name__}: {current}")
current = getattr(current, "__cause__", None) or getattr(current, "__context__", None)
return " | ".join(parts) if parts else str(exc)
def _send_peer_candidate(self, chat_id):
if isinstance(chat_id, (int, str)):
return chat_id
peer_id = self._peer_id_value(chat_id)
return peer_id if peer_id is not None else chat_id
async def _send_result(self, chat_id, image_source, caption, reply_to=None, force_document=False, spoiler=False, log_errors=True, send_as_self=False):
caption = self._apply_emoji_theme(caption)
output_format = self.config["output_format"]
as_document = force_document or output_format == "document_png"
try:
out = await self._prepare_image_upload_file(image_source, as_document)
except UserFacingError:
raise
except Exception as e:
logger.error("Failed to process image: %s: %s", type(e).__name__, e)
logger.exception(e)
raise ValueError("Failed to process image") from e
try:
send_kwargs = {
"caption": caption,
"reply_to": reply_to,
"force_document": as_document,
}
if spoiler and not as_document:
send_kwargs["spoiler"] = True
sent_message = await self._send_prepared_result_file(
chat_id,
out,
send_kwargs,
send_as_self,
)
except Exception as e:
if log_errors:
logger.error("Failed to send image: %s: %s", type(e).__name__, e)
logger.exception(e)
raise ValueError(f"Telegram send failed: {self._exception_chain_text(e)}") from e
finally:
out.close()
del out
return sent_message
async def _send_file_result(self, chat_id, file_obj, caption, reply_to=None, force_document=True, log_errors=True, send_as_self=False):
caption = self._apply_emoji_theme(caption)
try:
if hasattr(file_obj, "seek"):
file_obj.seek(0)
send_kwargs = {
"caption": caption,
"reply_to": reply_to,
"force_document": force_document,
}
if send_as_self:
return await self._send_file_as_self_if_possible(chat_id, file_obj, **send_kwargs)
return await self.client.send_file(self._send_peer_candidate(chat_id), file_obj, **send_kwargs)
except Exception as e:
if log_errors:
logger.error("Failed to send media: %s: %s", type(e).__name__, e)
logger.exception(e)
raise ValueError(f"Telegram send failed: {self._exception_chain_text(e)}") from e
async def _send_file_group_result(self, chat_id, file_objs, caption, reply_to=None, force_document=True, log_errors=True, send_as_self=False):
caption = self._apply_emoji_theme(caption)
if file_objs and all(isinstance(item, tuple) for item in file_objs):
files = self._payloads_to_files(file_objs)
close_original_files = True
else:
files = [
file_obj
for file_obj in (file_objs or [])
if file_obj
]
close_original_files = False
if not files:
raise ValueError("No media files to send")
original_files = list(files)
try:
for file_obj in files:
file_obj.seek(0)
send_kwargs = {
"caption": caption,
"reply_to": reply_to,
"force_document": force_document,
}
if send_as_self:
sent = await self._send_file_as_self_if_possible(chat_id, files, **send_kwargs)
else:
sent = await self.client.send_file(
self._send_peer_candidate(chat_id),
files,
**send_kwargs,
)
if isinstance(sent, list):
return sent[0] if sent else None
return sent
except Exception as e:
if log_errors:
logger.error("Failed to send media group: %s: %s", type(e).__name__, e)
logger.exception(e)
raise ValueError(f"Telegram send failed: {self._exception_chain_text(e)}") from e
finally:
if close_original_files:
for file_obj in original_files:
try:
file_obj.close()
except Exception:
pass
async def _schedule_delete_message(self, message_to_delete: Message, delay: int):
try:
await asyncio.sleep(max(0, int(delay)))
await message_to_delete.delete()
except asyncio.CancelledError:
raise
except Exception as e:
logger.debug("Failed to auto-delete message: %s", e)
async def _retry(self, coro_func, *args, attempts=3, delay=2):
last_err = None
for i in range(attempts):
try:
return await coro_func(*args)
except ComfyUIHTTPError as e:
if e.temporary:
last_err = e
logger.warning("Temporary ComfyUI HTTP error (attempt %d/%d): %s", i + 1, attempts, e)
if i < attempts - 1:
await asyncio.sleep(delay * (2 ** i))
continue
raise
except ValueError as e:
err_str = str(e)
if any(code in err_str for code in ("HTTP 502", "HTTP 503", "HTTP 504")):
last_err = e
logger.warning("Temporary server error (attempt %d/%d): %s", i + 1, attempts, e)
if i < attempts - 1:
await asyncio.sleep(delay * (2 ** i))
continue
raise
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
last_err = e
logger.exception(e)
if i < attempts - 1:
await asyncio.sleep(delay * (i + 1))
except Exception:
raise
raise last_err
_ARGSET_VALIDATORS = {
"steps": {"min": 1, "max": 100, "type": int},
"cfg": {"min": 1.0, "max": 30.0, "type": float},
"denoise": {"min": 0.0, "max": 1.0, "type": float},
"width": {"min": 64, "max": 4096, "type": int},
"height": {"min": 64, "max": 4096, "type": int},
}
_ARGSET_FALLBACKS = {
"steps": 30,
"cfg": 7.0,
"denoise": 0.5,
"width": 1024,
"height": 1024,
}
_ARGSET_CHOICE_PARAMS = ("sampler_name", "scheduler")
_SAMPLER_CHOICES = (
"euler",
"euler_cfg_pp",
"euler_ancestral",
"heun",
"heunpp2",
"dpm_2",
"dpm_2_ancestral",
"dpm_fast",
"dpm_adaptive",
"dpmpp_2s_ancestral",
"dpmpp_2s_ancestral_cfg_pp",
"dpmpp_sde",
"dpmpp_sde_gpu",
"ddpm",
"lcm",
"ipndm",
"ipndm_v",
"deis",
"er_sde",
"seeds_2",
"seeds_3",
"dpmpp_2m",
"dpmpp_2m_cfg_pp",
"dpmpp_2m_sde",
"dpmpp_2m_sde_gpu",
)
_SCHEDULER_CHOICES = (
"simple",
"sgm_uniform",
"karras",
"exponential",
"ddim_uniform",
"beta",
"normal",
"linear_quadratic",
"kl_optimal",
)
@staticmethod
def _argset_enabled(data):
value = data.get("enabled", False) if isinstance(data, dict) else data
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on", "enabled", "вкл", "включено", "да"}:
return True
if normalized in {"", "0", "false", "no", "off", "disabled", "выкл", "выключено", "нет"}:
return False
return bool(value)
def _ensure_argset_entry(self, saved, param):
data = saved.get(param, {})
enabled = self._argset_enabled(data)
if not isinstance(data, dict):
data = {}
data["enabled"] = enabled
if param in self._ARGSET_FALLBACKS and "value" not in data:
data["value"] = self._ARGSET_FALLBACKS[param]
saved[param] = data
return data
@staticmethod
def _normalize_argset_choice_value(value):
value = str(value or "").strip()
if not value:
return None
return value[:80]
def _argset_choice_values(self, param):
if param == "sampler_name":
return self._SAMPLER_CHOICES
if param == "scheduler":
return self._SCHEDULER_CHOICES
return ()
def _ensure_choice_argset_entry(self, saved, param):
data = saved.get(param, {})
enabled = self._argset_enabled(data)
if not isinstance(data, dict):
data = {}
data["enabled"] = enabled
data["value"] = self._normalize_argset_choice_value(data.get("value"))
data["custom"] = self._normalize_argset_choice_value(data.get("custom"))
saved[param] = data
return data
def _argset_choice_value(self, data):
if isinstance(data, dict):
return self._normalize_argset_choice_value(data.get("value"))
return None
def _argset_value(self, data, param):
if isinstance(data, dict):
return data.get("value", self._ARGSET_FALLBACKS[param])
return self._ARGSET_FALLBACKS[param]
@staticmethod
def _build_button_rows(buttons, columns=2):
rows = []
row = []
for button in buttons:
row.append(button)
if len(row) == columns:
rows.append(row)
row = []
if row:
rows.append(row)
return rows
def _load_defaults_from_workflow(self, wf_name):
wf_data = self._get_workflow_data(wf_name)
if not wf_data:
return dict(self._ARGSET_FALLBACKS)
workflow = wf_data["workflow"]
mapping = wf_data.get("mapping", {})
if not mapping:
mapping = self._parse_workflow(workflow)
result = {}
for param, fallback in self._ARGSET_FALLBACKS.items():
m = mapping.get(param)
if m and m.get("node_id") and m.get("field"):
node = workflow.get(m["node_id"], {})
val = node.get("inputs", {}).get(m["field"])
if val is not None:
try:
result[param] = self._ARGSET_VALIDATORS[param]["type"](val)
continue
except (ValueError, TypeError):
pass
result[param] = fallback
for param in self._ARGSET_CHOICE_PARAMS:
result[param] = self._normalize_argset_choice_value(
self._workflow_mapping_value(workflow, mapping.get(param))
)
return result
@staticmethod
def _clone_argset_data(data):
if not isinstance(data, dict):
return {}
try:
return json.loads(json.dumps(data))
except Exception:
return dict(data)
def _build_default_args_for_workflow(self, wf_name=None):
wf_name = self._canonical_workflow_name(
wf_name or self.get("default_workflow", _DEFAULT_WORKFLOW_NAME)
)
values = self._load_defaults_from_workflow(wf_name)
defaults = {}
for param in ("width", "height", "steps", "cfg", "denoise"):
defaults[param] = {"enabled": False, "value": values.get(param, self._ARGSET_FALLBACKS[param])}
for param in self._ARGSET_CHOICE_PARAMS:
defaults[param] = {"enabled": False, "value": None, "custom": None}
defaults["lora"] = self._get_global_lora_data()
return defaults
def _normalize_default_args(self, saved):
if not isinstance(saved, dict):
saved = {}
legacy_ai = saved.pop("ai", None)
if self._argset_enabled(legacy_ai):
settings = self._get_ult_settings()
settings["ai_enhance"]["enabled"] = True
self._set_ult_settings(settings)
saved.pop("anime_upscale", None)
for param in ("width", "height", "steps", "cfg", "denoise"):
self._ensure_argset_entry(saved, param)
for param in self._ARGSET_CHOICE_PARAMS:
self._ensure_choice_argset_entry(saved, param)
self._ensure_lora_argset_entry(saved)
return saved
def _ensure_default_args(self):
saved = self.get("default_args", {})
if isinstance(saved, dict) and saved:
saved = self._normalize_default_args(saved)
self.set("default_args", saved)
if not self.get("argset_active_model_key"):
self.set("argset_active_model_key", self._argset_profile_key())
return
defaults = self._build_default_args_for_workflow()
self.set("default_args", defaults)
if not self.get("argset_active_model_key"):
self.set("argset_active_model_key", self._argset_profile_key())
def _argset_profile_model_name(self, wf_name=None, model_name=None):
wf_name = self._canonical_workflow_name(
wf_name or self.get("default_workflow", _DEFAULT_WORKFLOW_NAME)
)
if model_name is not None:
return str(model_name).strip() or "default"
if self._is_comfy_cloud() and self._cloud_model_as_workflow():
workflow_model = self._get_workflow_primary_model(
self._get_workflow_data(wf_name)
)
if workflow_model:
return str(workflow_model).strip()
return str(self.config["model_name"] or "").strip() or "default"
def _argset_profile_key(self, wf_name=None, model_name=None):
return self._argset_profile_model_name(wf_name, model_name)
def _legacy_argset_profile_key(self, wf_name=None, model_name=None):
wf_name = self._canonical_workflow_name(
wf_name or self.get("default_workflow", _DEFAULT_WORKFLOW_NAME)
)
return f"{wf_name}\n{self._argset_profile_model_name(wf_name, model_name)}"
def _sync_argset_for_current_model(self, force=False):
profile_key = self._argset_profile_key()
active_key = self.get("argset_active_model_key")
if not force and not active_key:
saved = self.get("default_args", {})
if isinstance(saved, dict) and saved:
saved = self._normalize_default_args(saved)
self.set("default_args", saved)
self.set("argset_active_model_key", profile_key)
return
if not force and active_key == profile_key:
saved = self.get("default_args", {})
if isinstance(saved, dict) and saved:
self._ensure_default_args()
return
profiles = self.get("model_arg_profiles", {})
if not isinstance(profiles, dict):
profiles = {}
profile = profiles.get(profile_key)
if not isinstance(profile, dict):
legacy_key = self._legacy_argset_profile_key()
legacy_profile = profiles.get(legacy_key)
if isinstance(legacy_profile, dict):
profile = legacy_profile
profiles[profile_key] = self._clone_argset_data(legacy_profile)
self.set("model_arg_profiles", profiles)
if isinstance(profile, dict):
saved = self._normalize_default_args(self._clone_argset_data(profile))
else:
wf_name = self._canonical_workflow_name(self.get("default_workflow", _DEFAULT_WORKFLOW_NAME))
saved = self._build_default_args_for_workflow(wf_name)
self.set("default_args", saved)
self.set("argset_active_model_key", profile_key)
def _save_argset_profile_for_current_model(self):
self._ensure_default_args()
saved = self.get("default_args", {})
if not isinstance(saved, dict):
saved = {}
saved = self._normalize_default_args(saved)
profiles = self.get("model_arg_profiles", {})
if not isinstance(profiles, dict):
profiles = {}
profile_key = self._argset_profile_key()
profile = self._clone_argset_data(saved)
profile.pop("lora", None)
profiles[profile_key] = profile
self.set("model_arg_profiles", profiles)
self.set("argset_active_model_key", profile_key)
def _update_default_arg_values(self):
self._sync_argset_for_current_model(force=True)
def _validate_argset_value(self, param, value_str):
v = self._ARGSET_VALIDATORS.get(param)
if not v:
return False, None
try:
val = v["type"](value_str)
except (ValueError, TypeError):
return False, None
if val < v["min"] or val > v["max"]:
return False, None
if v.get("step") and val % v["step"] != 0:
return False, None
return True, val
def _argset_current_workflow_choice(self, param):
wf_name = self._canonical_workflow_name(self.get("default_workflow", _DEFAULT_WORKFLOW_NAME))
values = self._load_defaults_from_workflow(wf_name)
return self._normalize_argset_choice_value(values.get(param))
def _argset_effective_choice(self, data, workflow_value):
if self._argset_enabled(data):
return self._argset_choice_value(data) or workflow_value
return workflow_value
def _format_argset_choice_value(self, value):
return utils.escape_html(value or self.strings("not_set"))
def _argset_footer_row(self, back_callback=None, args=()):
row = []
if back_callback:
button = {"text": self.strings("btn_back"), "callback": back_callback}
if args:
button["args"] = args
row.append(button)
row.append({"text": self.strings("btn_close"), "callback": self._safe_close_form, "style": "danger"})
return row
async def _argset_render_main(self, target):
self._sync_argset_for_current_model()
saved = self.get("default_args", {})
if not isinstance(saved, dict):
saved = {}
if self._workflow_limited_mode():
lines = [
self.strings("argset_title"),
"",
self.strings("argset_limited_mode"),
]
markup = [
[{"text": self.strings("btn_close"), "callback": self._safe_close_form, "style": "danger"}],
]
return await self._render_inline(target, "\n".join(lines), markup)
lines = [self.strings("argset_title"), ""]
lines.append(self.strings("argset_params"))
for param in ("width", "height", "steps", "cfg", "denoise"):
data = saved.get(param, {})
icon = self.strings("argset_on") if self._argset_enabled(data) else self.strings("argset_off")
lines.append(f" {icon} {param.upper() if param == 'cfg' else param.capitalize()}: {self._argset_value(data, param)}")
for param in self._ARGSET_CHOICE_PARAMS:
data = self._ensure_choice_argset_entry(saved, param)
workflow_value = self._argset_current_workflow_choice(param)
effective_value = self._argset_effective_choice(data, workflow_value)
icon = self.strings("argset_on") if self._argset_enabled(data) else self.strings("argset_off")
lines.append(
f" {icon} {self.strings(f'label_{param}')}: {self._format_argset_choice_value(effective_value)}"
)
lines.append("")
lines.append(self.strings("argset_enhancements"))
lora_data = self._ensure_lora_argset_entry(saved)
lora_icon = self.strings("argset_on") if self._argset_enabled(lora_data) else self.strings("argset_off")
lines.append(f" {lora_icon} {self.strings('label_lora_presets')}: {self._format_lora_preset_summary(lora_data)}")
text = "\n".join(lines)
markup = [
[
{"text": self.strings("btn_params"), "callback": self._argset_cat_params},
],
[
{"text": self.strings("label_sampler_name"), "callback": self._argset_choice_menu, "args": ("sampler_name",), "style": "primary"},
{"text": self.strings("label_scheduler"), "callback": self._argset_choice_menu, "args": ("scheduler",), "style": "primary"},
],
[{"text": self.strings("argset_pin_model"), "callback": self._argset_pin_model, "style": "success"}],
[{"text": self.strings("btn_enhancements"), "callback": self._argset_cat_enhancements}],
[{"text": self._plain_text(self.strings("positive_menu_title")), "callback": self._argset_positive_menu}],
[{"text": self._plain_text(self.strings("negative_menu_title")), "callback": self._argset_negative_menu}],
[
{"text": self.strings("btn_reset_all"), "callback": self._argset_reset, "style": "danger"},
{"text": self.strings("btn_close"), "callback": self._safe_close_form, "style": "danger"},
],
]
await self._render_inline(target, text, markup)
async def _argset_cat_params(self, call: InlineCall):
if self._workflow_limited_mode():
return await self._argset_render_main(call)
saved = self.get("default_args", {})
if not isinstance(saved, dict):
saved = {}
lines = [self.strings("argset_params"), ""]
buttons = []
for param in ("width", "height", "steps", "cfg", "denoise"):
data = saved.get(param, {})
enabled = self._argset_enabled(data)
icon = self.strings("argset_on") if enabled else self.strings("argset_off")
label = param.upper() if param == "cfg" else param.capitalize()
lines.append(f"{icon} {label}: {self._argset_value(data, param)}")
toggle_text = f"{self._state_toggle_text(enabled)} {label}"
buttons.extend(
[
{
"text": toggle_text,
"callback": self._argset_toggle,
"args": (param, "params"),
"style": self._state_toggle_style(enabled),
"emoji_id": self._state_toggle_emoji(enabled),
},
{
"text": "\u270f\ufe0f " + label,
"input": self.strings(f"argset_input_{param}"),
"handler": self._argset_input_handler,
"args": (param, "params"),
},
]
)
markup = self._build_button_rows(buttons)
markup.append(self._argset_footer_row(self._argset_back))
await self._render_inline(call, "\n".join(lines), markup)
async def _argset_cat_enhancements(self, call: InlineCall):
saved = self.get("default_args", {})
if not isinstance(saved, dict):
saved = {}
if self._workflow_limited_mode():
lines = [
self.strings("argset_enhancements"),
"",
self.strings("argset_limited_mode"),
]
markup = [
self._argset_footer_row(self._argset_back),
]
return await self._render_inline(call, "\n".join(lines), markup)
lines = [self.strings("argset_enhancements"), ""]
buttons = []
lora_data = self._ensure_lora_argset_entry(saved)
lora_enabled = self._argset_enabled(lora_data)
lora_label = self.strings("label_lora_presets")
lora_icon = self.strings("argset_on") if lora_enabled else self.strings("argset_off")
lines.append(f"{lora_icon} {lora_label}: {self._format_lora_preset_summary(lora_data)}")
toggle_text = f"{self._state_toggle_text(lora_enabled)} {lora_label}"
buttons.extend(
[
{
"text": toggle_text,
"callback": self._argset_toggle,
"args": ("lora", "enhancements"),
"style": self._state_toggle_style(lora_enabled),
"emoji_id": self._state_toggle_emoji(lora_enabled),
},
{"text": "\U0001f3a8 " + lora_label, "callback": self._argset_lora_menu},
]
)
markup = self._build_button_rows(buttons)
markup.append(self._argset_footer_row(self._argset_back))
await self._render_inline(call, "\n".join(lines), markup)
def _workflow_positive_source(self, wf_name):
_, source = self._resolve_positive_prompt(wf_name)
return source
async def _argset_positive_menu(self, call: InlineCall):
global_positive = self._get_global_positive_prompt()
wf_names = self._get_all_workflow_names()
lines = [
self.strings("positive_menu_title"),
"",
self.strings("positive_global") + ":",
self._format_negative_quote(global_positive, limit=None),
"",
self.strings("positive_workflows") + ":",
]
for wf_name in wf_names:
positive, source = self._resolve_positive_prompt(wf_name)
positive_display = (
self._format_negative_quote(self._negative_source_label(source), limit=None)
if source == "global"
else self._format_negative_quote(positive, limit=None)
)
lines.append(
f"{self._negative_source_icon(source)} {utils.escape_html(wf_name)}:\n{positive_display}"
)
buttons = [{"text": self.strings("positive_btn_global"), "callback": self._argset_positive_global}]
for wf_name in wf_names:
source = self._workflow_positive_source(wf_name)
icon = "\u2705 " if source == "custom" else "\u2b1c "
label = wf_name
if len(label) > 24:
label = label[:21] + "..."
buttons.append(
{
"text": icon + label,
"callback": self._argset_positive_workflow,
"args": (wf_name,),
"style": "success" if source == "custom" else "primary",
}
)
markup = self._build_button_rows(buttons, columns=2)
markup.append(self._argset_footer_row(self._argset_back))
await self._render_inline(call, "\n".join(lines), markup)
async def _argset_positive_global(self, call: InlineCall):
global_positive = self._get_global_positive_prompt()
lines = [
self.strings("positive_btn_global"),
"",
self.strings("positive_current"),
self._format_negative_quote(global_positive, limit=None),
]
markup = [
[
{
"text": self.strings("positive_btn_set"),
"input": self.strings("positive_input_global"),
"handler": self._argset_positive_global_input,
}
],
[
{
"text": self.strings("positive_btn_reset"),
"callback": self._argset_positive_global_reset,
"style": "success",
},
{
"text": self.strings("positive_btn_clear"),
"callback": self._argset_positive_global_clear,
"style": "danger",
},
],
self._argset_footer_row(self._argset_positive_menu),
]
await self._render_inline(call, "\n".join(lines), markup)
async def _argset_positive_global_input(self, call: InlineCall, query: str):
self._set_global_positive_prompt(query)
try:
await call.answer(self.strings("positive_saved"))
except Exception:
pass
await self._argset_positive_global(call)
async def _argset_positive_global_reset(self, call: InlineCall):
self._set_global_positive_prompt(_GLOBAL_POSITIVE_DEFAULT)
try:
await call.answer(self.strings("positive_reset"))
except Exception:
pass
await self._argset_positive_global(call)
async def _argset_positive_global_clear(self, call: InlineCall):
self._set_global_positive_prompt("")
try:
await call.answer(self.strings("positive_cleared"))
except Exception:
pass
await self._argset_positive_global(call)
async def _argset_positive_workflow(self, call: InlineCall, wf_name: str):
wf_name = self._canonical_workflow_name(wf_name)
current, _ = self._resolve_positive_prompt(wf_name)
workflow_positives = self._get_workflow_positive_prompts()
custom = workflow_positives.get(wf_name, "")
global_positive = self._get_global_positive_prompt()
lines = [
f"{self.strings('positive_menu_title')}: {utils.escape_html(wf_name)}",
"",
self.strings("positive_current"),
self._format_negative_quote(current, limit=None),
"",
self.strings("positive_custom"),
self._format_negative_quote(custom, limit=None),
"",
self.strings("positive_global_label"),
self._format_negative_quote(global_positive, limit=None),
]
markup = [
[
{
"text": self.strings("positive_btn_set"),
"input": self.strings("positive_input_workflow").format(wf_name),
"handler": self._argset_positive_workflow_input,
"args": (wf_name,),
}
],
[
{
"text": self.strings("positive_btn_reset"),
"callback": self._argset_positive_workflow_reset,
"args": (wf_name,),
"style": "success",
}
],
self._argset_footer_row(self._argset_positive_menu),
]
await self._render_inline(call, "\n".join(lines), markup)
async def _argset_positive_workflow_input(self, call: InlineCall, query: str, wf_name: str):
self._set_workflow_positive_prompt(wf_name, query)
try:
await call.answer(self.strings("positive_saved"))
except Exception:
pass
await self._argset_positive_workflow(call, wf_name)
async def _argset_positive_workflow_reset(self, call: InlineCall, wf_name: str):
self._reset_workflow_positive_prompt(wf_name)
try:
await call.answer(self.strings("positive_reset"))
except Exception:
pass
await self._argset_positive_workflow(call, wf_name)
def _workflow_negative_source(self, wf_name):
wf_name = self._canonical_workflow_name(wf_name)
wf_data = self._get_workflow_data(wf_name)
_, source = self._resolve_negative_prompt(wf_name, wf_data)
return source
async def _argset_negative_menu(self, call: InlineCall):
global_negative = self._get_global_negative_prompt()
wf_names = self._get_all_workflow_names()
lines = [
self.strings("negative_menu_title"),
"",
self.strings("negative_global") + ":",
self._format_negative_quote(global_negative, limit=None),
"",
self.strings("negative_workflows") + ":",
]
for wf_name in wf_names:
wf_data = self._get_workflow_data(wf_name)
negative, source = self._resolve_negative_prompt(wf_name, wf_data)
negative_display = (
self._format_negative_quote(self._negative_source_label(source), limit=None)
if source == "global"
else self._format_negative_quote(negative, limit=None)
)
lines.append(
f"{self._negative_source_icon(source)} {utils.escape_html(wf_name)}:\n{negative_display}"
)
buttons = [{"text": self.strings("negative_btn_global"), "callback": self._argset_negative_global}]
for wf_name in wf_names:
source = self._workflow_negative_source(wf_name)
icon = "\u2705 " if source == "custom" else "\u2b1c "
label = wf_name
if len(label) > 24:
label = label[:21] + "..."
buttons.append(
{
"text": icon + label,
"callback": self._argset_negative_workflow,
"args": (wf_name,),
"style": "success" if source == "custom" else "primary",
}
)
markup = self._build_button_rows(buttons, columns=2)
markup.append(self._argset_footer_row(self._argset_back))
await self._render_inline(call, "\n".join(lines), markup)
async def _argset_negative_global(self, call: InlineCall):
global_negative = self._get_global_negative_prompt()
lines = [
self.strings("negative_btn_global"),
"",
self.strings("negative_current"),
self._format_negative_quote(global_negative, limit=None),
]
markup = [
[
{
"text": self.strings("negative_btn_set"),
"input": self.strings("negative_input_global"),
"handler": self._argset_negative_global_input,
}
],
[
{
"text": self.strings("negative_btn_reset"),
"callback": self._argset_negative_global_reset,
"style": "success",
},
{
"text": self.strings("negative_btn_clear"),
"callback": self._argset_negative_global_clear,
"style": "danger",
},
],
self._argset_footer_row(self._argset_negative_menu),
]
await self._render_inline(call, "\n".join(lines), markup)
async def _argset_negative_global_input(self, call: InlineCall, query: str):
self._set_global_negative_prompt(query)
try:
await call.answer(self.strings("negative_saved"))
except Exception:
pass
await self._argset_negative_global(call)
async def _argset_negative_global_reset(self, call: InlineCall):
self._set_global_negative_prompt(_GLOBAL_NEGATIVE_DEFAULT)
try:
await call.answer(self.strings("negative_reset"))
except Exception:
pass
await self._argset_negative_global(call)
async def _argset_negative_global_clear(self, call: InlineCall):
self._set_global_negative_prompt("")
try:
await call.answer(self.strings("negative_cleared"))
except Exception:
pass
await self._argset_negative_global(call)
async def _argset_negative_workflow(self, call: InlineCall, wf_name: str):
wf_name = self._canonical_workflow_name(wf_name)
wf_data = self._get_workflow_data(wf_name)
current, _ = self._resolve_negative_prompt(wf_name, wf_data)
workflow_negatives = self._get_workflow_negative_prompts()
custom = workflow_negatives.get(wf_name, "")
global_negative = self._get_global_negative_prompt()
lines = [
f"{self.strings('negative_menu_title')}: {utils.escape_html(wf_name)}",
"",
self.strings("negative_current"),
self._format_negative_quote(current, limit=None),
"",
self.strings("negative_custom"),
self._format_negative_quote(custom, limit=None),
"",
self.strings("negative_global_label"),
self._format_negative_quote(global_negative, limit=None),
]
markup = [
[
{
"text": self.strings("negative_btn_set"),
"input": self.strings("negative_input_workflow").format(wf_name),
"handler": self._argset_negative_workflow_input,
"args": (wf_name,),
}
],
[
{
"text": self.strings("negative_btn_reset"),
"callback": self._argset_negative_workflow_reset,
"args": (wf_name,),
"style": "success",
}
],
self._argset_footer_row(self._argset_negative_menu),
]
await self._render_inline(call, "\n".join(lines), markup)
async def _argset_negative_workflow_input(self, call: InlineCall, query: str, wf_name: str):
self._set_workflow_negative_prompt(wf_name, query)
try:
await call.answer(self.strings("negative_saved"))
except Exception:
pass
await self._argset_negative_workflow(call, wf_name)
async def _argset_negative_workflow_reset(self, call: InlineCall, wf_name: str):
self._reset_workflow_negative_prompt(wf_name)
try:
await call.answer(self.strings("negative_reset"))
except Exception:
pass
await self._argset_negative_workflow(call, wf_name)
async def _argset_lora_menu(self, call: InlineCall, state_id: str = None):
if self._workflow_limited_mode():
return await self._argset_render_main(call)
lora_data = self._get_default_lora_data()
if state_id is None or state_id not in self._argset_lora_states:
state_id = str(uuid.uuid4())
self._argset_lora_states[state_id] = {
"page": 0,
"favorites_only": False,
"filter_mode": "all",
"search_query": "",
"selected": self._normalize_lora_preset_entries(lora_data.get("selected")),
}
await self._render_argset_lora_list(call, state_id)
async def _render_argset_lora_list(self, call_or_message, state_id):
state = self._argset_lora_states.get(state_id)
if not state:
return
all_loras, imported_loras = await self._ensure_lora_state_catalog(state)
if not all_loras:
return await self._safe_answer(call_or_message, self.strings("lora_none_available"))
state["selected"] = self._normalize_lora_preset_entries(state.get("selected"), all_loras)
filter_mode = self._lora_filter_mode(state)
state["filter_mode"] = filter_mode
metadata = self._get_lora_metadata()
visible_loras = self._filter_loras(
all_loras,
filter_mode,
metadata,
imported_loras,
)
search_query = " ".join(str(state.get("search_query") or "").split())
visible_loras = self._filter_names_by_query(visible_loras, search_query)
page = int(state.get("page") or 0)
per_page = 6
total_pages = max(1, (len(visible_loras) + per_page - 1) // per_page)
page = min(page, total_pages - 1)
state["page"] = page
start = page * per_page
page_loras = visible_loras[start:start + per_page]
lines = [self.strings("lora_presets_title")]
if not state["selected"]:
lines.append(self.strings("lora_presets_empty"))
if search_query:
lines.append(
self.strings("lora_search_label").format(
utils.escape_html(search_query)
)
)
if not visible_loras:
empty_key = (
"lora_no_favorites"
if filter_mode == "favorites" and not search_query
else "lora_no_imported"
if filter_mode == "imported" and not search_query
else "lora_search_empty"
)
lines.append(self.strings(empty_key))
lines.append("")
for lora in page_loras:
entry = state["selected"].get(lora, {"enabled": False, "weight": 0.75})
is_on = bool(entry.get("enabled"))
weight = entry.get("weight", 0.75)
icon = self.strings("argset_on") if is_on else self.strings("argset_off")
favorite = " ★" if metadata.get(lora, {}).get("favorite") else ""
lines.append(f"{icon} {utils.escape_html(self._format_lora_name(lora, max_length=None))} ({weight:.1f}){favorite}")
lines.append(self.strings("lora_page").format(page + 1, total_pages))
buttons = []
for lora in page_loras:
short_name = self._format_lora_name(lora, max_length=None)
if len(short_name) > 20:
short_name = short_name[:18] + ".."
icon = "\u2705 " if state["selected"].get(lora, {}).get("enabled") else "\u2b1c "
buttons.append(
{
"text": f"{icon}{short_name}",
"callback": self._argset_lora_detail,
"args": (state_id, lora),
}
)
markup = self._build_button_rows(buttons)
markup.extend(
self._lora_filter_buttons(
state_id,
filter_mode,
self._argset_lora_filter,
imported_loras,
)
)
search_row = [{
"text": self.strings("lora_search_btn"),
"input": self.strings("lora_search_input"),
"handler": self._argset_lora_search_input,
"args": (state_id,),
}]
if search_query:
search_row.append({
"text": self.strings("lora_search_clear"),
"callback": self._argset_lora_search_clear,
"args": (state_id,),
})
markup.append(search_row)
nav_row = []
if page > 0:
nav_row.append({"text": "\u25c0\ufe0f", "callback": self._argset_lora_page, "args": (state_id, -1)})
if page < total_pages - 1:
nav_row.append({"text": "\u25b6\ufe0f", "callback": self._argset_lora_page, "args": (state_id, 1)})
if nav_row:
markup.append(nav_row)
markup.append(
[
{"text": self.strings("lora_presets_clear"), "callback": self._argset_lora_clear, "args": (state_id,)},
{"text": self.strings("btn_back"), "callback": self._argset_lora_exit, "args": (state_id,)},
{"text": self.strings("btn_close"), "callback": self._safe_close_form, "style": "danger"},
]
)
await self._render_inline(call_or_message, "\n".join(lines), markup)
async def _argset_lora_page(self, call: InlineCall, state_id: str, direction: int):
state = self._argset_lora_states.get(state_id)
if not state:
return
state["page"] += direction
await self._render_argset_lora_list(call, state_id)
async def _argset_lora_filter(self, call: InlineCall, state_id: str, filter_mode: str):
state = self._argset_lora_states.get(state_id)
if not state:
return
state["filter_mode"] = str(filter_mode or "all")
state["page"] = 0
await self._render_argset_lora_list(call, state_id)
async def _argset_lora_search_input(self, call: InlineCall, query: str, state_id: str):
state = self._argset_lora_states.get(state_id)
if not state:
return
state["search_query"] = " ".join(str(query or "").split())
state["page"] = 0
await self._render_argset_lora_list(
self._source_inline_target(call), state_id
)
async def _argset_lora_search_clear(self, call: InlineCall, state_id: str):
state = self._argset_lora_states.get(state_id)
if not state:
return
state["search_query"] = ""
state["page"] = 0
await self._render_argset_lora_list(call, state_id)
async def _argset_lora_detail(self, call: InlineCall, state_id: str, lora_name: str):
state = self._argset_lora_states.get(state_id)
if not state:
return
short_name = self._format_lora_name(lora_name, max_length=30)
entry = state["selected"].get(lora_name, {"enabled": False, "weight": 0.75})
is_on = bool(entry.get("enabled"))
weight = entry.get("weight", 0.75)
status_text = self.strings("lora_on") if is_on else self.strings("lora_off")
metadata = self._get_lora_metadata_entry(lora_name)
is_favorite = bool(metadata.get("favorite"))
note = str(metadata.get("note") or "")
triggers = self._normalize_lora_trigger_words(metadata.get("triggers"))
auto_triggers = bool(metadata.get("auto_triggers"))
civitai_base_model = str(metadata.get("civitai_base_model") or "")
text = self.strings("lora_detail_title").format(
utils.escape_html(short_name), weight, status_text
)
if note:
text += "\n" + self.strings("lora_note_label").format(utils.escape_html(note))
if civitai_base_model:
text += "\n" + self.strings("lora_civitai_base_model").format(
utils.escape_html(civitai_base_model)
)
if triggers:
text += "\n" + self.strings("lora_triggers_label").format(
utils.escape_html(", ".join(triggers))
)
else:
text += "\n" + self.strings("lora_triggers_empty")
toggle_text = self._state_toggle_text(is_on)
toggle_style = self._state_toggle_style(is_on)
toggle_emoji = self._state_toggle_emoji(is_on)
markup = [
[
{"text": "\u2796 0.1", "callback": self._argset_lora_weight, "args": (state_id, lora_name, -0.1)},
{"text": "\u2795 0.1", "callback": self._argset_lora_weight, "args": (state_id, lora_name, 0.1)},
],
[
{
"text": self.strings("lora_weight_btn"),
"input": self.strings("lora_weight_input"),
"handler": self._argset_lora_weight_input,
"args": (state_id, lora_name),
}
],
[{"text": toggle_text, "callback": self._argset_lora_toggle, "args": (state_id, lora_name), "style": toggle_style, "emoji_id": toggle_emoji}],
[{"text": self.strings("lora_favorite_remove") if is_favorite else self.strings("lora_favorite_add"), "callback": self._argset_lora_favorite, "args": (state_id, lora_name)}],
[{
"text": self.strings("lora_note_btn"),
"input": self.strings("lora_note_input"),
"handler": self._argset_lora_note_input,
"args": (state_id, lora_name),
}],
[{
"text": self.strings("lora_triggers_btn"),
"input": self.strings("lora_triggers_input"),
"handler": self._argset_lora_triggers_input,
"args": (state_id, lora_name),
}],
[{
"text": (
self.strings("lora_auto_triggers_on")
if auto_triggers
else self.strings("lora_auto_triggers_off")
),
"callback": self._argset_lora_auto_triggers,
"args": (state_id, lora_name),
}],
[{
"text": self.strings("lora_civitai_btn"),
"input": self.strings("lora_civitai_input"),
"handler": self._argset_lora_civitai_input,
"args": (state_id, lora_name),
}],
self._argset_footer_row(self._argset_lora_back, (state_id,)),
]
if note:
markup.insert(-1, [{"text": self.strings("lora_note_delete"), "callback": self._argset_lora_note_delete, "args": (state_id, lora_name), "style": "danger"}])
await self._render_inline(call, text, markup)
async def _argset_lora_weight(self, call: InlineCall, state_id: str, lora_name: str, delta: float):
state = self._argset_lora_states.get(state_id)
if not state:
return
entry = state["selected"].get(lora_name, {"enabled": False, "weight": 0.75})
current = entry.get("weight", 0.75)
state["selected"][lora_name] = {
"enabled": bool(entry.get("enabled")),
"weight": round(max(0.1, min(2.0, current + delta)), 1),
}
self._save_argset_lora_presets(state["selected"])
await self._argset_lora_detail(call, state_id, lora_name)
async def _argset_lora_weight_input(self, call: InlineCall, query: str, state_id: str, lora_name: str):
state = self._argset_lora_states.get(state_id)
if not state:
return
try:
weight = round(float(query.strip().replace(",", ".")), 1)
except (TypeError, ValueError):
try:
await call.answer(self.strings("toast_invalid_value").format(query), show_alert=True)
except Exception:
pass
return
if weight < 0.1 or weight > 2.0:
try:
await call.answer(self.strings("toast_invalid_value").format(query), show_alert=True)
except Exception:
pass
return
entry = state["selected"].get(lora_name, {"enabled": False, "weight": 0.75})
state["selected"][lora_name] = {
"enabled": bool(entry.get("enabled")),
"weight": weight,
}
self._save_argset_lora_presets(state["selected"])
try:
await call.answer(self.strings("lora_weight_saved"))
except Exception:
pass
await self._argset_lora_detail(
self._source_inline_target(call), state_id, lora_name
)
async def _argset_lora_toggle(self, call: InlineCall, state_id: str, lora_name: str):
state = self._argset_lora_states.get(state_id)
if not state:
return
entry = state["selected"].get(lora_name, {"enabled": False, "weight": 0.75})
state["selected"][lora_name] = {
"enabled": not bool(entry.get("enabled")),
"weight": entry.get("weight", 0.75),
}
self._save_argset_lora_presets(state["selected"])
try:
await call.answer(self.strings("lora_presets_saved"))
except Exception:
pass
await self._argset_lora_detail(call, state_id, lora_name)
async def _argset_lora_favorite(self, call: InlineCall, state_id: str, lora_name: str):
state = self._argset_lora_states.get(state_id)
if not state:
return
self._set_lora_metadata_entry(lora_name, favorite=not self._is_lora_favorite(lora_name))
await self._argset_lora_detail(call, state_id, lora_name)
async def _argset_lora_note_input(self, call: InlineCall, query: str, state_id: str, lora_name: str):
state = self._argset_lora_states.get(state_id)
if not state:
return
self._set_lora_metadata_entry(lora_name, note=query)
try:
await call.answer(self.strings("lora_note_saved"))
except Exception:
pass
await self._argset_lora_detail(
self._source_inline_target(call), state_id, lora_name
)
async def _argset_lora_triggers_input(self, call: InlineCall, query: str, state_id: str, lora_name: str):
if state_id not in self._argset_lora_states:
return
self._set_lora_metadata_entry(lora_name, triggers=query)
try:
await call.answer(self.strings("lora_triggers_saved"))
except Exception:
pass
await self._argset_lora_detail(
self._source_inline_target(call), state_id, lora_name
)
async def _argset_lora_auto_triggers(self, call: InlineCall, state_id: str, lora_name: str):
if state_id not in self._argset_lora_states:
return
metadata = self._get_lora_metadata_entry(lora_name)
self._set_lora_metadata_entry(
lora_name,
auto_triggers=not bool(metadata.get("auto_triggers")),
)
await self._argset_lora_detail(call, state_id, lora_name)
async def _argset_lora_civitai_input(self, call: InlineCall, query: str, state_id: str, lora_name: str):
if state_id not in self._argset_lora_states:
return
version_id, triggers, base_model = await self._fetch_civitai_lora_triggers(query)
if not version_id:
try:
await call.answer(self.strings("lora_civitai_failed"), show_alert=True)
except Exception:
pass
return
self._set_lora_metadata_entry(
lora_name,
triggers=triggers,
civitai_version=version_id,
civitai_base_model=base_model,
)
try:
await call.answer(self.strings("lora_civitai_saved"))
except Exception:
pass
await self._argset_lora_detail(
self._source_inline_target(call), state_id, lora_name
)
async def _argset_lora_note_delete(self, call: InlineCall, state_id: str, lora_name: str):
state = self._argset_lora_states.get(state_id)
if not state:
return
self._set_lora_metadata_entry(lora_name, note="")
try:
await call.answer(self.strings("lora_note_deleted"))
except Exception:
pass
await self._argset_lora_detail(call, state_id, lora_name)
async def _argset_lora_clear(self, call: InlineCall, state_id: str):
state = self._argset_lora_states.get(state_id)
if not state:
return
state["selected"] = {}
self._save_argset_lora_presets({})
try:
await call.answer(self.strings("lora_presets_saved"))
except Exception:
pass
await self._render_argset_lora_list(call, state_id)
async def _argset_lora_back(self, call: InlineCall, state_id: str):
await self._render_argset_lora_list(call, state_id)
async def _argset_lora_exit(self, call: InlineCall, state_id: str):
self._argset_lora_states.pop(state_id, None)
await self._argset_render_main(call)
def _save_argset_lora_presets(self, selected_loras):
saved = self.get("default_args", {})
if not isinstance(saved, dict):
saved = {}
data = self._get_global_lora_data()
data["selected"] = self._normalize_lora_preset_entries(selected_loras)
data = self._set_global_lora_data(data)
saved["lora"] = self._clone_argset_data(data)
self.set("default_args", saved)
async def _argset_choice_menu(self, call: InlineCall, param: str):
if self._workflow_limited_mode():
return await self._argset_render_main(call)
if param not in self._ARGSET_CHOICE_PARAMS:
return await self._argset_render_main(call)
saved = self.get("default_args", {})
if not isinstance(saved, dict):
saved = {}
data = self._ensure_choice_argset_entry(saved, param)
self.set("default_args", saved)
label = self.strings(f"label_{param}")
workflow_value = self._argset_current_workflow_choice(param)
effective_value = self._argset_effective_choice(data, workflow_value)
choices = list(self._argset_choice_values(param))
custom_value = self._normalize_argset_choice_value(data.get("custom"))
for value in (custom_value, workflow_value, effective_value):
if value and value not in choices:
choices.append(value)
lines = [
label,
"",
self.strings("argset_choice_workflow").format(self._format_argset_choice_value(workflow_value)),
self.strings("argset_choice_used").format(self._format_argset_choice_value(effective_value)),
]
buttons = []
for value in choices:
is_active = bool(effective_value and value == effective_value)
buttons.append(
{
"text": ("\u2705 " if is_active else "") + value,
"callback": self._argset_choice_select,
"args": (param, value),
**({"style": "success"} if is_active else {}),
}
)
markup = self._build_button_rows(buttons, columns=2)
custom_row = [
{
"text": self.strings("argset_choice_custom"),
"input": self.strings(f"argset_input_{param}"),
"handler": self._argset_choice_custom_input,
"args": (param,),
}
]
if custom_value or (workflow_value and workflow_value not in self._argset_choice_values(param)):
custom_row.append(
{
"text": self.strings("argset_choice_clear"),
"callback": self._argset_choice_clear,
"args": (param,),
"style": "danger",
}
)
markup.append(custom_row)
if self._argset_enabled(data):
markup.append(
[
{
"text": self.strings("argset_choice_as_workflow"),
"callback": self._argset_choice_as_workflow,
"args": (param,),
"style": "primary",
}
]
)
markup.append(self._argset_footer_row(self._argset_back))
await self._render_inline(call, "\n".join(lines), markup)
async def _argset_choice_select(self, call: InlineCall, param: str, value: str):
if self._workflow_limited_mode():
return await self._argset_render_main(call)
if param not in self._ARGSET_CHOICE_PARAMS:
return
value = self._normalize_argset_choice_value(value)
if not value:
return
saved = self.get("default_args", {})
if not isinstance(saved, dict):
saved = {}
data = self._ensure_choice_argset_entry(saved, param)
workflow_value = self._argset_current_workflow_choice(param)
if workflow_value and value == workflow_value:
data["enabled"] = False
data["value"] = None
else:
data["enabled"] = True
data["value"] = value
if value not in self._argset_choice_values(param):
data["custom"] = value
self.set("default_args", saved)
await self._argset_choice_menu(call, param)
async def _argset_choice_as_workflow(self, call: InlineCall, param: str):
if self._workflow_limited_mode():
return await self._argset_render_main(call)
saved = self.get("default_args", {})
if not isinstance(saved, dict):
saved = {}
data = self._ensure_choice_argset_entry(saved, param)
data["enabled"] = False
data["value"] = None
self.set("default_args", saved)
await self._argset_choice_menu(call, param)
async def _argset_choice_custom_input(self, call: InlineCall, query: str, param: str):
if self._workflow_limited_mode():
return await self._argset_render_main(call)
value = self._normalize_argset_choice_value(query)
if not value:
try:
await call.answer(self.strings("toast_invalid_value").format(query), show_alert=True)
except Exception:
pass
return
saved = self.get("default_args", {})
if not isinstance(saved, dict):
saved = {}
data = self._ensure_choice_argset_entry(saved, param)
data["enabled"] = True
data["value"] = value
data["custom"] = value
self.set("default_args", saved)
try:
await call.answer(self.strings("argset_choice_saved").format(value))
except Exception:
pass
await self._argset_choice_menu(call, param)
async def _argset_choice_clear(self, call: InlineCall, param: str):
if self._workflow_limited_mode():
return await self._argset_render_main(call)
saved = self.get("default_args", {})
if not isinstance(saved, dict):
saved = {}
data = self._ensure_choice_argset_entry(saved, param)
custom_value = self._normalize_argset_choice_value(data.get("custom"))
data["custom"] = None
if custom_value and data.get("value") == custom_value:
data["enabled"] = False
data["value"] = None
if data.get("value") and data["value"] not in self._argset_choice_values(param):
data["enabled"] = False
data["value"] = None
self.set("default_args", saved)
await self._argset_choice_menu(call, param)
def _format_provider_name(self, provider):
names = {
"gemini": "Gemini",
"groq": "Groq",
"openrouter": "OpenRouter",
"grok": "Grok",
"qwen": "Qwen",
"deepseek": "DeepSeek",
"nvidiaapi": "NVIDIA API",
_COMFY_TEXT_PROVIDER: "ComfyUI Text",
}
return names.get(provider, provider)
def _enhance_prompt_source_label(self, provider):
urls = self._get_enhance_prompt_urls()
key = "enhance_prompt_source_custom" if provider in urls else "enhance_prompt_source_default"
return self.strings(key)
async def _ult_enhance_prompt_url_input(self, call: InlineCall, query: str, provider: str):
if not self._set_enhance_prompt_url(provider, query):
try:
await call.answer(self.strings("enhance_prompt_invalid_url"), show_alert=True)
except Exception:
pass
return
try:
await call.answer(self.strings("enhance_prompt_saved"))
except Exception:
pass
await self._argset_provider_detail(call, provider)
async def _ult_enhance_prompt_reset(self, call: InlineCall, provider: str):
self._reset_enhance_prompt_url(provider)
try:
await call.answer(self.strings("enhance_prompt_reset"))
except Exception:
pass
await self._argset_provider_detail(call, provider)
async def _ult_comfy_text_workflow_url_input(self, call: InlineCall, query: str):
if not self._set_comfy_text_workflow_url(query):
try:
await call.answer(self.strings("comfy_text_workflow_invalid_url"), show_alert=True)
except Exception:
pass
return
try:
await call.answer(self.strings("comfy_text_workflow_saved"))
except Exception:
pass
await self._argset_provider_detail(call, _COMFY_TEXT_PROVIDER)
async def _ult_comfy_text_workflow_reset(self, call: InlineCall):
self._reset_comfy_text_workflow_url()
try:
await call.answer(self.strings("comfy_text_workflow_reset"))
except Exception:
pass
await self._argset_provider_detail(call, _COMFY_TEXT_PROVIDER)
def _inline_call_message(self, call: InlineCall):
form = getattr(call, "form", {}) or {}
if isinstance(form, dict):
for key in ("caller", "message"):
value = form.get(key)
if isinstance(value, Message):
return value
value = getattr(call, "message", None)
return value if isinstance(value, Message) else None
async def _send_inline_call_file(self, call: InlineCall, file_obj, caption=""):
message = self._inline_call_message(call)
if not message:
return False
chat_id = utils.get_chat_id(message)
try:
await self.client.send_file(
chat_id,
file_obj,
caption=caption,
reply_to=getattr(message, "id", None),
)
except Exception:
file_obj.seek(0)
await self.client.send_file(chat_id, file_obj, caption=caption)
return True
async def _ult_comfy_text_workflow_download(self, call: InlineCall):
try:
workflow = await self._fetch_comfy_text_workflow(force=True)
file_obj = io.BytesIO(
json.dumps(workflow, ensure_ascii=False, indent=2).encode("utf-8")
)
file_obj.name = "comfy_text_workflow.json"
sent = await self._send_inline_call_file(
call,
file_obj,
caption=self.strings("comfy_text_workflow_file_caption"),
)
if not sent:
await call.answer(self.strings("comfy_text_workflow_download_failed"), show_alert=True)
except Exception as e:
logger.debug("Failed to send ComfyUI Text workflow: %s", e)
try:
detail = self._comfy_text_error_detail(e)
await call.answer(
self._plain_text(
self.strings("comfy_text_workflow_download_failed_detail").format(detail)
),
show_alert=True,
)
except Exception:
pass
finally:
if "file_obj" in locals():
file_obj.close()
async def _ult_enhance_prompt_download(self, call: InlineCall, provider: str):
provider = str(provider or "").strip().lower()
if provider not in self._external_provider_ids():
return await self._argset_provider_menu(call)
text = await self._fetch_enhance_prompt(provider, force=True)
if not text:
try:
await call.answer(self.strings("enhance_prompt_download_failed"), show_alert=True)
except Exception:
pass
return
file_obj = io.BytesIO(str(text).encode("utf-8"))
safe_provider = re.sub(r"[^a-z0-9_\\-]+", "_", provider.lower()).strip("_") or "provider"
file_obj.name = f"comfy_enhance_prompt_{safe_provider}.txt"
try:
sent = await self._send_inline_call_file(
call,
file_obj,
caption=self.strings("enhance_prompt_file_caption").format(self._format_provider_name(provider)),
)
if not sent:
await call.answer(self.strings("enhance_prompt_download_failed"), show_alert=True)
except Exception as e:
logger.debug("Failed to send enhance prompt file: %s", e)
try:
await call.answer(self.strings("enhance_prompt_download_failed"), show_alert=True)
except Exception:
pass
finally:
file_obj.close()
async def _argset_provider_menu(self, call: InlineCall):
current = self._get_prompt_provider()
lines = [
self.strings("provider_title"),
"",
self.strings("provider_menu_intro"),
"",
self.strings("provider_current").format(self._format_provider_name(current)),
"",
]
buttons = []
for provider in self._provider_ids():
is_current = provider == current
icon = self.strings("argset_on") if is_current else self.strings("argset_off")
provider_line = f"{icon} {self._format_provider_name(provider)}"
if self._provider_has_model_input(provider):
provider_line = "{} — {}".format(
provider_line,
utils.escape_html(
self._preview_negative(self._get_provider_model(provider), 42)
),
)
elif provider == _COMFY_TEXT_PROVIDER:
provider_line = "{} — {}".format(
provider_line,
utils.escape_html(self._preview_negative(_COMFY_TEXT_CLIP_NAME, 42)),
)
lines.append(provider_line)
btn_icon = "\u2705 " if is_current else "\u2b1c "
buttons.append(
{
"text": btn_icon + self._format_provider_name(provider),
"callback": self._argset_provider_detail,
"args": (provider,),
"style": "success" if is_current else "primary",
}
)
markup = self._build_button_rows(buttons)
markup.append(self._argset_footer_row(self._ult_open_ai_enhance))
await self._render_inline(call, "\n".join(lines), markup)
async def _argset_provider_detail(self, call: InlineCall, provider: str):
settings = self._get_ai_settings()
current = settings["provider"]
selected = provider == current
status = self.strings("provider_selected") if selected else self.strings("provider_not_selected")
is_comfy_text = provider == _COMFY_TEXT_PROVIDER
api_key_status = (
self.strings("provider_api_key_not_required")
if is_comfy_text
else (
self.strings("provider_api_key_set")
if self._get_provider_api_key(provider)
else self.strings("provider_api_key_missing")
)
)
lines = [
f"{self.strings('provider_title')}: {self._format_provider_name(provider)}",
"",
self.strings("provider_status").format(status),
self.strings("provider_api_key").format(api_key_status),
]
if is_comfy_text:
lines.extend(
[
self.strings("provider_model").format(_COMFY_TEXT_CLIP_NAME),
self.strings("provider_comfy_text_info"),
self.strings("provider_comfy_text_template"),
"",
"{}\n{}\n{}
".format(
self.strings("comfy_text_workflow_section"),
self.strings("comfy_text_workflow_source").format(
self._comfy_text_workflow_source_label()
),
self.strings("comfy_text_workflow_current_url").format(
utils.escape_html(
self._preview_negative(
self._get_comfy_text_workflow_url(),
180,
)
)
),
),
]
)
elif self._provider_has_model_input(provider):
lines.append(self.strings("provider_model").format(utils.escape_html(self._get_provider_model(provider))))
if self._provider_supports_prompt_template(provider):
prompt_url = self._get_enhance_prompt_url(provider)
lines.extend(
[
"",
"{}\n{}\n{}
".format(
self.strings("enhance_prompt_section"),
self.strings("enhance_prompt_source").format(
self._enhance_prompt_source_label(provider)
),
self.strings("enhance_prompt_current_url").format(
utils.escape_html(self._preview_negative(prompt_url, 180))
),
),
]
)
markup = [
[
{
"text": self.strings("provider_btn_select"),
"callback": self._argset_provider_select,
"args": (provider,),
"style": "success",
}
],
]
if not is_comfy_text:
markup.append(
[
{
"text": self.strings("provider_btn_api_key"),
"input": self.strings("provider_input_api_key").format(self._format_provider_name(provider)),
"handler": self._argset_provider_api_key_input,
"args": (provider,),
}
]
)
else:
markup.extend(
[
[
{
"text": self.strings("comfy_text_workflow_btn_set"),
"input": self.strings("comfy_text_workflow_input_url"),
"handler": self._ult_comfy_text_workflow_url_input,
}
],
[
{
"text": self.strings("comfy_text_workflow_btn_download"),
"callback": self._ult_comfy_text_workflow_download,
"style": "primary",
},
{
"text": self.strings("comfy_text_workflow_btn_reset"),
"callback": self._ult_comfy_text_workflow_reset,
"style": "danger",
},
],
]
)
presets = self._provider_model_presets(provider)
if presets:
preset_buttons = []
for model in presets:
label = model
if len(label) > 30:
label = label[:27] + "..."
preset_buttons.append(
{
"text": label,
"callback": self._argset_provider_model_preset,
"args": (provider, model),
"style": "success" if model == self._get_provider_model(provider) else "primary",
}
)
markup.extend(self._build_button_rows(preset_buttons, columns=2))
if self._provider_has_model_input(provider):
markup.append([
{
"text": self.strings("provider_btn_model"),
"input": self.strings("provider_input_model"),
"handler": self._argset_provider_model_input,
"args": (provider,),
}
])
if self._provider_supports_prompt_template(provider):
markup.extend(
[
[
{
"text": self.strings("enhance_prompt_btn_set"),
"input": self.strings("enhance_prompt_input_url").format(
self._format_provider_name(provider)
),
"handler": self._ult_enhance_prompt_url_input,
"args": (provider,),
}
],
[
{
"text": self.strings("enhance_prompt_btn_download"),
"callback": self._ult_enhance_prompt_download,
"args": (provider,),
"style": "primary",
},
{
"text": self.strings("enhance_prompt_btn_reset"),
"callback": self._ult_enhance_prompt_reset,
"args": (provider,),
"style": "danger",
},
],
]
)
markup.append(self._argset_footer_row(self._argset_provider_menu))
await self._render_inline(call, "\n".join(lines), markup)
async def _argset_provider_select(self, call: InlineCall, provider: str):
self._set_prompt_provider(provider)
try:
await call.answer(self.strings("provider_saved").format(self._format_provider_name(provider)))
except Exception:
pass
await self._argset_provider_detail(call, provider)
async def _argset_provider_api_key_input(self, call: InlineCall, query: str, provider: str):
self._set_provider_api_key(provider, query)
try:
await call.answer(self.strings("provider_key_saved"))
except Exception:
pass
await self._argset_provider_detail(call, provider)
async def _argset_provider_model_input(self, call: InlineCall, query: str, provider: str):
self._set_provider_model(provider, query)
try:
await call.answer(self.strings("provider_model_saved"))
except Exception:
pass
await self._argset_provider_detail(call, provider)
async def _argset_provider_model_preset(self, call: InlineCall, provider: str, model: str):
self._set_provider_model(provider, model)
try:
await call.answer(self.strings("provider_model_saved"))
except Exception:
pass
await self._argset_provider_detail(call, provider)
async def _argset_toggle(self, call: InlineCall, param: str, category: str):
if self._workflow_limited_mode() or param == "ai":
return await self._argset_render_main(call)
saved = self.get("default_args", {})
if not isinstance(saved, dict):
saved = {}
data = self._ensure_lora_argset_entry(saved) if param == "lora" else self._ensure_argset_entry(saved, param)
data["enabled"] = not self._argset_enabled(data)
if param == "lora":
data = self._set_global_lora_data(data)
saved["lora"] = self._clone_argset_data(data)
self.set("default_args", saved)
cat_map = {
"params": self._argset_cat_params,
"enhancements": self._argset_cat_enhancements,
}
renderer = cat_map.get(category, self._argset_back)
await renderer(call)
async def _argset_pin_model(self, call: InlineCall):
if self._workflow_limited_mode():
return await self._argset_render_main(call)
await self._ensure_workflow_data(
self.get("default_workflow", _DEFAULT_WORKFLOW_NAME)
)
self._sync_argset_for_current_model()
self._save_argset_profile_for_current_model()
try:
await call.answer(
self.strings("argset_pin_model_ok").format(
self._format_model_name(self._argset_profile_model_name())
),
show_alert=True,
)
except Exception:
pass
await self._argset_render_main(call)
async def _argset_input_handler(self, call: InlineCall, query: str, param: str, category: str):
if self._workflow_limited_mode():
return await self._argset_render_main(call)
ok, val = self._validate_argset_value(param, query.strip())
if not ok:
try:
await call.answer(self.strings("toast_invalid_value").format(query), show_alert=True)
except Exception:
pass
return
saved = self.get("default_args", {})
if not isinstance(saved, dict):
saved = {}
data = self._ensure_argset_entry(saved, param)
data["value"] = val
self.set("default_args", saved)
cat_map = {
"params": self._argset_cat_params,
}
renderer = cat_map.get(category, self._argset_back)
await renderer(call)
async def _argset_reset(self, call: InlineCall):
wf_name = self._canonical_workflow_name(self.get("default_workflow", _DEFAULT_WORKFLOW_NAME))
values = self._load_defaults_from_workflow(wf_name)
defaults = {}
for param in ("width", "height", "steps", "cfg", "denoise"):
defaults[param] = {"enabled": False, "value": values.get(param, self._ARGSET_FALLBACKS[param])}
for param in self._ARGSET_CHOICE_PARAMS:
defaults[param] = {"enabled": False, "value": None, "custom": None}
defaults["lora"] = self._get_global_lora_data()
self.set("default_args", defaults)
profile_key = self._argset_profile_key(wf_name)
legacy_profile_key = self._legacy_argset_profile_key(wf_name)
profiles = self.get("model_arg_profiles", {})
if isinstance(profiles, dict):
profiles.pop(profile_key, None)
profiles.pop(legacy_profile_key, None)
self.set("model_arg_profiles", profiles)
self.set("argset_active_model_key", profile_key)
try:
await call.answer(self.strings("toast_defaults_reset"), show_alert=True)
except Exception:
pass
await self._argset_render_main(call)
async def _argset_back(self, call: InlineCall):
await self._argset_render_main(call)
def _parse_gen_args(self, args_raw: str) -> dict:
self._sync_argset_for_current_model()
defaults = self.get("default_args", {})
if not isinstance(defaults, dict):
defaults = {}
parsed = {
"positive": None,
"negative": None,
"width": None,
"height": None,
"steps": None,
"cfg": None,
"seed": None,
"denoise": None,
"sampler_name": None,
"scheduler": None,
"use_lora_picker": False,
"enhance_prompt": False,
"chat_ai": False,
"disable_auto_ai": False,
"inspire": False,
"translate_prompt": False,
}
if self._ai_enhance_enabled():
parsed["enhance_prompt"] = True
def extract_arg(pattern, default=None, type_cast=str):
nonlocal args_raw
match = re.search(pattern, args_raw, re.IGNORECASE)
if match:
value = match.group(1)
args_raw = args_raw[:match.start()] + args_raw[match.end():]
args_raw = args_raw.strip()
try:
return type_cast(value)
except ValueError:
return default
return default
raw_width = extract_arg(r'-w\s+(\d+)', type_cast=int)
if raw_width is not None:
parsed["width"] = max(64, min(4096, raw_width))
else:
data = defaults.get("width", {})
if self._argset_enabled(data):
parsed["width"] = self._argset_value(data, "width")
raw_height = extract_arg(r'-h\s+(\d+)', type_cast=int)
if raw_height is not None:
parsed["height"] = max(64, min(4096, raw_height))
else:
data = defaults.get("height", {})
if self._argset_enabled(data):
parsed["height"] = self._argset_value(data, "height")
raw_steps = extract_arg(r'-steps\s+(\d+)', type_cast=int)
if raw_steps is not None:
parsed["steps"] = max(1, min(100, raw_steps))
else:
data = defaults.get("steps", {})
if self._argset_enabled(data):
parsed["steps"] = self._argset_value(data, "steps")
raw_cfg = extract_arg(r'-cfg\s+([0-9\.]+)', type_cast=float)
if raw_cfg is not None:
parsed["cfg"] = max(1.0, min(30.0, raw_cfg))
else:
data = defaults.get("cfg", {})
if self._argset_enabled(data):
parsed["cfg"] = self._argset_value(data, "cfg")
parsed["seed"] = extract_arg(r'-seed\s+(\d+)', type_cast=int)
raw_denoise = extract_arg(r'-denoise\s+([0-9\.]+)', type_cast=float)
if raw_denoise is not None:
parsed["denoise"] = max(0.0, min(1.0, raw_denoise))
else:
data = defaults.get("denoise", {})
if self._argset_enabled(data):
parsed["denoise"] = self._argset_value(data, "denoise")
for param in self._ARGSET_CHOICE_PARAMS:
data = defaults.get(param, {})
if self._argset_enabled(data):
parsed[param] = self._argset_choice_value(data)
if re.search(r'-lora\b', args_raw, re.IGNORECASE):
parsed["use_lora_picker"] = True
args_raw = re.sub(r'-lora\b', '', args_raw, flags=re.IGNORECASE).strip()
if re.search(r'-cai\b', args_raw, re.IGNORECASE):
parsed["chat_ai"] = True
parsed["enhance_prompt"] = True
args_raw = re.sub(r'-cai\b', '', args_raw, flags=re.IGNORECASE).strip()
if re.search(r'-noai\b', args_raw, re.IGNORECASE):
parsed["disable_auto_ai"] = True
args_raw = re.sub(r'-noai\b', '', args_raw, flags=re.IGNORECASE).strip()
parsed["enhance_prompt"] = False
if re.search(r'-ai\b', args_raw, re.IGNORECASE):
parsed["enhance_prompt"] = True
args_raw = re.sub(r'-ai\b', '', args_raw, flags=re.IGNORECASE).strip()
if re.search(r'-t\b', args_raw, re.IGNORECASE):
parsed["translate_prompt"] = True
args_raw = re.sub(r'-t\b', '', args_raw, flags=re.IGNORECASE).strip()
if re.search(r'-i\b', args_raw, re.IGNORECASE):
parsed["inspire"] = True
parsed["enhance_prompt"] = False
args_raw = re.sub(r'-i\b', '', args_raw, count=1, flags=re.IGNORECASE).strip()
neg_match = re.search(
r'-neg\s+(?:"([^"]*)"|' + r"'([^']*)'|(.+?)(?=\s+-\w|$))",
args_raw,
re.IGNORECASE | re.DOTALL,
)
if neg_match:
neg_value = neg_match.group(1) or neg_match.group(2) or neg_match.group(3) or ""
parsed["negative"] = neg_value.strip()
args_raw = args_raw[:neg_match.start()] + args_raw[neg_match.end():]
args_raw = args_raw.strip()
parsed["positive"] = args_raw.strip()
return parsed
async def _render_lora_list(self, call_or_message, state_id):
state = self._lora_states[state_id]
try:
all_loras, imported_loras = await self._ensure_lora_state_catalog(state)
except Exception as e:
logger.debug("Failed to load LoRA list: %s", e)
state = self._lora_states.pop(state_id, None)
if state:
self._cleanup_input_file(state)
return await self._safe_answer(call_or_message, self.strings("lora_load_failed"))
if not all_loras:
state = self._lora_states.pop(state_id, None)
if state:
self._cleanup_input_file(state)
return await self._safe_answer(call_or_message, self.strings("lora_none_available"))
state["selected"] = self._normalize_lora_preset_entries(state.get("selected"), all_loras)
filter_mode = self._lora_filter_mode(state)
state["filter_mode"] = filter_mode
metadata = self._get_lora_metadata()
visible_loras = self._filter_loras(
all_loras,
filter_mode,
metadata,
imported_loras,
)
search_query = " ".join(str(state.get("search_query") or "").split())
visible_loras = self._filter_names_by_query(visible_loras, search_query)
page = int(state.get("page") or 0)
per_page = 6
total_pages = max(1, (len(visible_loras) + per_page - 1) // per_page)
page = min(page, total_pages - 1)
state["page"] = page
start = page * per_page
page_loras = visible_loras[start:start + per_page]
lines = [self.strings("lora_title")]
lines.append(f"{self.strings('lora_prompt_label')}: {utils.escape_html(str(state.get('positive') or self.strings('prompt_empty'))[:100])}")
if search_query:
lines.append(
self.strings("lora_search_label").format(
utils.escape_html(search_query)
)
)
lines.append("")
if not visible_loras:
empty_key = (
"lora_no_favorites"
if filter_mode == "favorites" and not search_query
else "lora_no_imported"
if filter_mode == "imported" and not search_query
else "lora_search_empty"
)
lines.append(self.strings(empty_key))
for lora in page_loras:
entry = state["selected"].get(lora, {"enabled": False, "weight": 0.75})
is_on = bool(entry.get("enabled"))
weight = entry.get("weight", 0.75)
icon = '\u2705' if is_on else '\u2b1c'
favorite = " ★" if metadata.get(lora, {}).get("favorite") else ""
lines.append(f"{icon} {utils.escape_html(self._format_lora_name(lora, max_length=None))} ({weight:.1f}){favorite}")
lines.append(self.strings("lora_page").format(page + 1, total_pages))
text = "\n".join(lines)
markup = []
row = []
for lora in page_loras:
short_name = self._format_lora_name(lora, max_length=None)
if len(short_name) > 20:
short_name = short_name[:18] + ".."
icon = "\u2705 " if state["selected"].get(lora, {}).get("enabled") else "\u2b1c "
row.append({
"text": f"{icon}{short_name}",
"callback": self._lora_detail,
"args": (state_id, lora),
})
if len(row) == 2:
markup.append(row)
row = []
if row:
markup.append(row)
markup.extend(
self._lora_filter_buttons(
state_id,
filter_mode,
self._lora_filter,
imported_loras,
)
)
search_row = [{
"text": self.strings("lora_search_btn"),
"input": self.strings("lora_search_input"),
"handler": self._lora_search_input,
"args": (state_id,),
}]
if search_query:
search_row.append({
"text": self.strings("lora_search_clear"),
"callback": self._lora_search_clear,
"args": (state_id,),
})
markup.append(search_row)
nav_row = []
if page > 0:
nav_row.append({"text": "\u25c0\ufe0f", "callback": self._lora_page, "args": (state_id, -1)})
if page < total_pages - 1:
nav_row.append({"text": "\u25b6\ufe0f", "callback": self._lora_page, "args": (state_id, 1)})
if nav_row:
markup.append(nav_row)
markup.append([
{"text": self.strings("btn_generate"), "callback": self._lora_generate, "args": (state_id,), "style": "success", "emoji_id": "5206607081334906820"},
{"text": self.strings("btn_cancel"), "callback": self._lora_cancel, "args": (state_id,), "style": "danger", "emoji_id": "5121063440311386962"},
])
await self._render_inline(call_or_message, text, markup)
async def _lora_page(self, call: InlineCall, state_id: str, direction: int):
if state_id not in self._lora_states:
return
self._lora_states[state_id]["page"] += direction
await self._render_lora_list(call, state_id)
async def _lora_filter(self, call: InlineCall, state_id: str, filter_mode: str):
state = self._lora_states.get(state_id)
if not state:
return
state["filter_mode"] = str(filter_mode or "all")
state["page"] = 0
await self._render_lora_list(call, state_id)
async def _lora_search_input(self, call: InlineCall, query: str, state_id: str):
state = self._lora_states.get(state_id)
if not state:
return
state["search_query"] = " ".join(str(query or "").split())
state["page"] = 0
await self._render_lora_list(self._source_inline_target(call), state_id)
async def _lora_search_clear(self, call: InlineCall, state_id: str):
state = self._lora_states.get(state_id)
if not state:
return
state["search_query"] = ""
state["page"] = 0
await self._render_lora_list(call, state_id)
async def _lora_detail(self, call: InlineCall, state_id: str, lora_name: str):
if state_id not in self._lora_states:
return
state = self._lora_states[state_id]
short_name = self._format_lora_name(lora_name, max_length=30)
entry = state["selected"].get(lora_name, {"enabled": False, "weight": 0.75})
is_on = bool(entry.get("enabled"))
weight = entry.get("weight", 0.75)
status_text = self.strings("lora_on") if is_on else self.strings("lora_off")
metadata = self._get_lora_metadata_entry(lora_name)
is_favorite = bool(metadata.get("favorite"))
note = str(metadata.get("note") or "")
triggers = self._normalize_lora_trigger_words(metadata.get("triggers"))
auto_triggers = bool(metadata.get("auto_triggers"))
civitai_base_model = str(metadata.get("civitai_base_model") or "")
text = self.strings("lora_detail_title").format(
utils.escape_html(short_name), weight, status_text
)
if note:
text += "\n" + self.strings("lora_note_label").format(utils.escape_html(note))
if civitai_base_model:
text += "\n" + self.strings("lora_civitai_base_model").format(
utils.escape_html(civitai_base_model)
)
if triggers:
text += "\n" + self.strings("lora_triggers_label").format(
utils.escape_html(", ".join(triggers))
)
else:
text += "\n" + self.strings("lora_triggers_empty")
toggle_text = self._state_toggle_text(is_on)
toggle_style = self._state_toggle_style(is_on)
toggle_emoji = self._state_toggle_emoji(is_on)
markup = [
[
{"text": "\u2796 0.1", "callback": self._lora_weight, "args": (state_id, lora_name, -0.1)},
{"text": "\u2795 0.1", "callback": self._lora_weight, "args": (state_id, lora_name, 0.1)},
],
[
{
"text": self.strings("lora_weight_btn"),
"input": self.strings("lora_weight_input"),
"handler": self._lora_weight_input,
"args": (state_id, lora_name),
}
],
[{"text": toggle_text, "callback": self._lora_toggle, "args": (state_id, lora_name), "style": toggle_style, "emoji_id": toggle_emoji}],
[{"text": self.strings("lora_favorite_remove") if is_favorite else self.strings("lora_favorite_add"), "callback": self._lora_favorite, "args": (state_id, lora_name)}],
[{
"text": self.strings("lora_note_btn"),
"input": self.strings("lora_note_input"),
"handler": self._lora_note_input,
"args": (state_id, lora_name),
}],
[{
"text": self.strings("lora_triggers_btn"),
"input": self.strings("lora_triggers_input"),
"handler": self._lora_triggers_input,
"args": (state_id, lora_name),
}],
[{
"text": (
self.strings("lora_auto_triggers_on")
if auto_triggers
else self.strings("lora_auto_triggers_off")
),
"callback": self._lora_auto_triggers,
"args": (state_id, lora_name),
}],
[{
"text": self.strings("lora_civitai_btn"),
"input": self.strings("lora_civitai_input"),
"handler": self._lora_civitai_input,
"args": (state_id, lora_name),
}],
[{"text": self.strings("btn_back"), "callback": self._lora_back, "args": (state_id,)}],
]
if note:
markup.insert(-1, [{"text": self.strings("lora_note_delete"), "callback": self._lora_note_delete, "args": (state_id, lora_name), "style": "danger"}])
await self._render_inline(call, text, markup)
async def _lora_weight(self, call: InlineCall, state_id: str, lora_name: str, delta: float):
if state_id not in self._lora_states:
return
state = self._lora_states[state_id]
entry = state["selected"].get(lora_name, {"enabled": False, "weight": 0.75})
current = entry.get("weight", 0.75)
new_weight = round(max(0.1, min(2.0, current + delta)), 1)
state["selected"][lora_name] = {
"enabled": bool(entry.get("enabled")),
"weight": new_weight,
}
await self._lora_detail(call, state_id, lora_name)
async def _lora_weight_input(self, call: InlineCall, query: str, state_id: str, lora_name: str):
if state_id not in self._lora_states:
return
try:
weight = round(float(query.strip().replace(",", ".")), 1)
except (TypeError, ValueError):
try:
await call.answer(self.strings("toast_invalid_value").format(query), show_alert=True)
except Exception:
pass
return
if weight < 0.1 or weight > 2.0:
try:
await call.answer(self.strings("toast_invalid_value").format(query), show_alert=True)
except Exception:
pass
return
state = self._lora_states[state_id]
entry = state["selected"].get(lora_name, {"enabled": False, "weight": 0.75})
state["selected"][lora_name] = {
"enabled": bool(entry.get("enabled")),
"weight": weight,
}
try:
await call.answer(self.strings("lora_weight_saved"))
except Exception:
pass
await self._lora_detail(self._source_inline_target(call), state_id, lora_name)
async def _lora_toggle(self, call: InlineCall, state_id: str, lora_name: str):
if state_id not in self._lora_states:
return
state = self._lora_states[state_id]
entry = state["selected"].get(lora_name, {"enabled": False, "weight": 0.75})
state["selected"][lora_name] = {
"enabled": not bool(entry.get("enabled")),
"weight": entry.get("weight", 0.75),
}
await self._lora_detail(call, state_id, lora_name)
async def _lora_favorite(self, call: InlineCall, state_id: str, lora_name: str):
if state_id not in self._lora_states:
return
self._set_lora_metadata_entry(lora_name, favorite=not self._is_lora_favorite(lora_name))
await self._lora_detail(call, state_id, lora_name)
async def _lora_note_input(self, call: InlineCall, query: str, state_id: str, lora_name: str):
if state_id not in self._lora_states:
return
self._set_lora_metadata_entry(lora_name, note=query)
try:
await call.answer(self.strings("lora_note_saved"))
except Exception:
pass
await self._lora_detail(self._source_inline_target(call), state_id, lora_name)
async def _lora_triggers_input(self, call: InlineCall, query: str, state_id: str, lora_name: str):
if state_id not in self._lora_states:
return
self._set_lora_metadata_entry(lora_name, triggers=query)
try:
await call.answer(self.strings("lora_triggers_saved"))
except Exception:
pass
await self._lora_detail(self._source_inline_target(call), state_id, lora_name)
async def _lora_auto_triggers(self, call: InlineCall, state_id: str, lora_name: str):
if state_id not in self._lora_states:
return
metadata = self._get_lora_metadata_entry(lora_name)
self._set_lora_metadata_entry(
lora_name,
auto_triggers=not bool(metadata.get("auto_triggers")),
)
await self._lora_detail(call, state_id, lora_name)
async def _lora_civitai_input(self, call: InlineCall, query: str, state_id: str, lora_name: str):
if state_id not in self._lora_states:
return
version_id, triggers, base_model = await self._fetch_civitai_lora_triggers(query)
if not version_id:
try:
await call.answer(self.strings("lora_civitai_failed"), show_alert=True)
except Exception:
pass
return
self._set_lora_metadata_entry(
lora_name,
triggers=triggers,
civitai_version=version_id,
civitai_base_model=base_model,
)
try:
await call.answer(self.strings("lora_civitai_saved"))
except Exception:
pass
await self._lora_detail(self._source_inline_target(call), state_id, lora_name)
async def _lora_note_delete(self, call: InlineCall, state_id: str, lora_name: str):
if state_id not in self._lora_states:
return
self._set_lora_metadata_entry(lora_name, note="")
try:
await call.answer(self.strings("lora_note_deleted"))
except Exception:
pass
await self._lora_detail(call, state_id, lora_name)
async def _lora_back(self, call: InlineCall, state_id: str):
if state_id not in self._lora_states:
return
await self._render_lora_list(call, state_id)
async def _lora_generate(self, call: InlineCall, state_id: str):
if state_id not in self._lora_states:
return
state = self._lora_states.pop(state_id)
selected_entries = self._normalize_lora_preset_entries(state.pop("selected", {}))
selected_loras = self._get_enabled_lora_presets(selected_entries)
state.pop("page", None)
state.pop("all_loras", None)
state.pop("imported_loras", None)
state.pop("favorites_only", None)
state.pop("filter_mode", None)
state["selected_loras"] = dict(selected_loras)
await self._run_direct_generation(call, state, selected_loras=selected_loras)
async def _lora_cancel(self, call: InlineCall, state_id: str):
state = self._lora_states.pop(state_id, None)
if state:
self._cleanup_input_file(state)
try:
await call.delete()
except Exception:
pass
def _inject_loras(self, workflow: dict, wf_data: dict, selected_loras: dict):
selected_loras = self._normalize_selected_loras(selected_loras)
if not selected_loras:
return workflow, {"supported": True, "capacity": 0}
def replace_model_links(source, replacement, skipped_node_ids=()):
source_id, source_output = str(source[0]), source[1]
skipped_node_ids = {str(node_id) for node_id in skipped_node_ids}
for target_id, target_node in workflow.items():
if str(target_id) in skipped_node_ids or not isinstance(target_node, dict):
continue
target_inputs = target_node.get("inputs")
if not isinstance(target_inputs, dict):
continue
for input_name, value in tuple(target_inputs.items()):
if (
self._is_workflow_link(value)
and str(value[0]) == source_id
and value[1] == source_output
):
target_inputs[input_name] = list(replacement)
def append_model_only_loras(source, lora_items):
previous = list(source)
created_node_ids = []
for lora_name, weight in lora_items:
node_id = self._next_node_id(workflow)
workflow[node_id] = {
"class_type": "LoraLoaderModelOnly",
"inputs": {
"model": previous,
"lora_name": lora_name,
"strength_model": weight,
},
"_meta": {"title": "Load LoRA"},
}
created_node_ids.append(node_id)
previous = [node_id, 0]
return previous, created_node_ids
def has_model_links(source):
source_id, source_output = str(source[0]), source[1]
return any(
self._is_workflow_link(value)
and str(value[0]) == source_id
and value[1] == source_output
for node in workflow.values()
if isinstance(node, dict)
for value in (node.get("inputs") or {}).values()
)
def find_model_source():
mapping = wf_data.get("mapping") if isinstance(wf_data, dict) else None
if not isinstance(mapping, dict):
mapping = self._parse_workflow(workflow)
model_mapping = mapping.get("model") if isinstance(mapping, dict) else None
candidates = []
if isinstance(model_mapping, dict) and model_mapping.get("node_id") is not None:
candidates.append(str(model_mapping["node_id"]))
candidates.extend(
str(node_id)
for node_id, node in workflow.items()
if isinstance(node, dict)
and (
node.get("class_type")
in {
"CheckpointLoaderSimple",
"UNETLoader",
"UnetLoaderGGUF",
"LoadDiffusionModel",
"DiffusionModelLoader",
"LoadGGUF",
"GGUFLoader",
}
or (
"unet_name" in (node.get("inputs") or {})
and "gguf" in str(node.get("class_type") or "").lower()
)
or self._is_model_loader_like_node(
node.get("class_type", ""), node.get("inputs") or {}
)
)
)
for node_id in dict.fromkeys(candidates):
source_node = workflow.get(node_id)
if not isinstance(source_node, dict):
continue
source = [node_id, 0]
if has_model_links(source):
return source
return None
power_lora_node_id = None
for nid, node in workflow.items():
if isinstance(node, dict) and node.get("class_type") == "Power Lora Loader (rgthree)":
power_lora_node_id = nid
break
if power_lora_node_id:
node_inputs = workflow[power_lora_node_id]["inputs"]
existing_lora_keys = [k for k in node_inputs if k.startswith("lora_")]
for k in existing_lora_keys:
if isinstance(node_inputs[k], dict):
node_inputs[k]["on"] = False
existing_lora_indexes = []
for key in existing_lora_keys:
match = re.fullmatch(r"lora_(\d+)", key)
if match:
existing_lora_indexes.append(int(match.group(1)))
next_lora_index = max(existing_lora_indexes, default=0) + 1
for lora_name, weight in selected_loras.items():
found = False
for k in existing_lora_keys:
if isinstance(node_inputs[k], dict) and node_inputs[k].get("lora") == lora_name:
node_inputs[k]["on"] = True
node_inputs[k]["strength"] = weight
found = True
break
if not found:
while f"lora_{next_lora_index}" in node_inputs:
next_lora_index += 1
key = f"lora_{next_lora_index}"
node_inputs[key] = {
"on": True,
"lora": lora_name,
"strength": weight,
}
next_lora_index += 1
return workflow, {"supported": True, "capacity": None}
cr_lora_stack_node_id = None
for nid, node in workflow.items():
if isinstance(node, dict) and node.get("class_type") == "CR LoRA Stack":
cr_lora_stack_node_id = nid
break
if cr_lora_stack_node_id:
node_inputs = workflow[cr_lora_stack_node_id]["inputs"]
lora_indexes = []
for key in node_inputs:
match = re.fullmatch(r"lora_name_(\d+)", key)
if match:
lora_indexes.append(int(match.group(1)))
lora_indexes = sorted(lora_indexes)
if len(selected_loras) > len(lora_indexes):
return workflow, {
"supported": True,
"capacity": len(lora_indexes),
}
node_inputs = workflow[cr_lora_stack_node_id]["inputs"]
for index in lora_indexes:
if f"switch_{index}" in node_inputs:
node_inputs[f"switch_{index}"] = "Off"
for index, item in zip(lora_indexes, selected_loras.items()):
lora_name, weight = item
node_inputs[f"switch_{index}"] = "On"
node_inputs[f"lora_name_{index}"] = lora_name
node_inputs[f"model_weight_{index}"] = weight
return workflow, {"supported": True, "capacity": len(lora_indexes)}
standard_lora_nodes = [
(str(node_id), node)
for node_id, node in workflow.items()
if isinstance(node, dict)
and node.get("class_type") in {"LoraLoader", "LoraLoaderModelOnly"}
]
def ordered_lora_chain(nodes):
if len(nodes) < 2:
return nodes
by_id = {node_id: node for node_id, node in nodes}
children = {node_id: [] for node_id in by_id}
predecessors = {}
for node_id, node in nodes:
source = (node.get("inputs") or {}).get("model")
if not self._is_workflow_link(source):
continue
source_id = str(source[0])
if source_id not in by_id:
continue
children[source_id].append(node_id)
predecessors[node_id] = source_id
roots = [node_id for node_id, _ in nodes if node_id not in predecessors]
if len(roots) != 1 or any(len(value) > 1 for value in children.values()):
return nodes
ordered = []
node_id = roots[0]
while node_id:
ordered.append((node_id, by_id[node_id]))
next_nodes = children[node_id]
if len(next_nodes) > 1:
return nodes
node_id = next_nodes[0] if next_nodes else None
return ordered if len(ordered) == len(nodes) else nodes
standard_lora_nodes = ordered_lora_chain(standard_lora_nodes)
if not standard_lora_nodes:
source = find_model_source()
if not source:
return workflow, {"supported": False, "capacity": 0}
replacement, created_node_ids = append_model_only_loras(
source,
selected_loras.items(),
)
replace_model_links(source, replacement, created_node_ids)
return workflow, {"supported": True, "capacity": None}
for (_, node), (lora_name, weight) in zip(
standard_lora_nodes,
selected_loras.items(),
):
node_inputs = node.setdefault("inputs", {})
node_inputs["lora_name"] = lora_name
node_inputs["strength_model"] = weight
for node_id, node in reversed(standard_lora_nodes[len(selected_loras):]):
node_inputs = node.get("inputs") or {}
output_sources = {0: node_inputs.get("model")}
if node.get("class_type") == "LoraLoader":
output_sources[1] = node_inputs.get("clip")
for target_node in workflow.values():
target_inputs = target_node.get("inputs") if isinstance(target_node, dict) else None
if not isinstance(target_inputs, dict):
continue
for input_name, value in tuple(target_inputs.items()):
if not self._is_workflow_link(value) or str(value[0]) != node_id:
continue
source = output_sources.get(value[1])
if self._is_workflow_link(source):
target_inputs[input_name] = list(source)
extra_loras = list(selected_loras.items())[len(standard_lora_nodes):]
if extra_loras:
source = [standard_lora_nodes[-1][0], 0]
replacement, created_node_ids = append_model_only_loras(source, extra_loras)
replace_model_links(source, replacement, created_node_ids)
return workflow, {
"supported": True,
"capacity": None,
}
def _extract_trigger_prompt(self, text, trigger):
raw = (text or "").strip()
trigger = (trigger or "").strip()
if not raw or not trigger:
return None
raw_lower = raw.lower()
trigger_lower = trigger.lower()
if raw_lower == trigger_lower:
return ""
if raw_lower.startswith(trigger_lower) and len(raw) > len(trigger) and raw[len(trigger)].isspace():
return raw[len(trigger):].strip()
return None
def _trigger_queue_key(self, chat_id):
return str(chat_id)
def _trigger_cooldown_key(self, chat_id, sender_id):
return f"{chat_id}:{sender_id or 0}"
def _trigger_sender_identity(self, message):
sender_id = getattr(message, "sender_id", None)
if sender_id is not None:
return sender_id
from_id = getattr(message, "from_id", None)
from_value = self._peer_id_value(from_id)
if from_value is not None:
return f"{type(from_id).__name__}:{from_value}"
post_author = getattr(message, "post_author", None)
if post_author:
return f"post_author:{post_author}"
message_id = getattr(message, "id", None)
return f"message:{message_id or 0}"
def _trigger_sender_numeric_id(self, message):
sender_id = getattr(message, "sender_id", None)
try:
return int(sender_id) if sender_id is not None else None
except (TypeError, ValueError):
return None
@staticmethod
def _send_as_fallback_allowed(error):
if isinstance(error, TypeError):
return True
error_text = f"{type(error).__name__}: {error}".lower()
return any(
marker in error_text
for marker in (
"unexpected keyword argument 'send_as'",
'unexpected keyword argument "send_as"',
"sendaspeerinvalid",
"send_as_peer_invalid",
"send as peer invalid",
"chat_send_as_forbidden",
"send_as forbidden",
)
)
async def _send_message_as_self_if_possible(self, chat_id, text, **kwargs):
return await self.client.send_message(chat_id, text, **kwargs)
@staticmethod
def _rewind_send_file_obj(file_obj):
items = file_obj if isinstance(file_obj, (list, tuple)) else [file_obj]
for item in items:
if hasattr(item, "seek"):
try:
item.seek(0)
except Exception:
pass
async def _send_file_as_self_if_possible(self, chat_id, file_obj, **kwargs):
self._rewind_send_file_obj(file_obj)
return await self.client.send_file(self._send_peer_candidate(chat_id), file_obj, **kwargs)
async def _send_trigger_reply(self, message, text):
try:
return await self._send_message_as_self_if_possible(
utils.get_chat_id(message),
text,
reply_to=message.id,
)
except Exception as e:
logger.debug("Failed to send trigger reply: %s", e)
async def _notify_trigger_queue_full(self, message):
chat_id = utils.get_chat_id(message)
cooldown_key = self._trigger_cooldown_key(chat_id, self._trigger_sender_identity(message))
if cooldown_key in self._trigger_queue_cooldowns:
return
self._trigger_queue_cooldowns[cooldown_key] = True
await self._send_trigger_reply(message, self.strings("trigger_queue_full"))
async def _notify_trigger_too_often(self, message):
chat_id = utils.get_chat_id(message)
cooldown_key = self._trigger_cooldown_key(chat_id, self._trigger_sender_identity(message))
if cooldown_key in self._trigger_rate_limit_cooldowns:
return
self._trigger_rate_limit_cooldowns[cooldown_key] = True
await self._send_trigger_reply(message, self.strings("trigger_too_often"))
async def _notify_trigger_unavailable(self, message, text):
chat_id = utils.get_chat_id(message)
await self._notify_trigger_unavailable_by_origin(
chat_id,
self._trigger_sender_identity(message),
message.id,
text,
)
async def _notify_trigger_unavailable_by_origin(self, chat_id, sender_id, reply_to, text):
if chat_id is None:
return
cooldown_key = self._trigger_cooldown_key(chat_id, sender_id)
if cooldown_key in self._trigger_unavailable_cooldowns:
return
self._trigger_unavailable_cooldowns[cooldown_key] = True
try:
await self._send_message_as_self_if_possible(chat_id, text, reply_to=reply_to)
except Exception as e:
logger.debug("Failed to send trigger unavailable reply: %s", e)
@staticmethod
def _peer_id_value(peer):
if peer is None:
return None
for attr in ("channel_id", "chat_id", "user_id", "id"):
value = getattr(peer, attr, None)
if value is not None:
try:
return int(value)
except (TypeError, ValueError):
return str(value)
return None
@staticmethod
def _peer_is_channel_like(peer):
if peer is None:
return False
peer_type = type(peer).__name__.lower()
return "channel" in peer_type or bool(getattr(peer, "broadcast", False))
async def _reply_media_is_source_post(self, message, reply):
if not reply or not getattr(reply, "media", None):
return False
reply_id = getattr(reply, "id", None)
reply_to = getattr(message, "reply_to", None)
reply_to_peer = getattr(reply_to, "reply_to_peer_id", None)
reply_to_msg_id = (
getattr(reply_to, "reply_to_msg_id", None)
or getattr(message, "reply_to_msg_id", None)
)
reply_to_top_id = getattr(reply_to, "reply_to_top_id", None)
message_peer = getattr(message, "peer_id", None)
reply_peer = getattr(reply, "peer_id", None)
reply_from = getattr(reply, "from_id", None)
reply_to_peer_id = self._peer_id_value(reply_to_peer)
message_peer_id = self._peer_id_value(message_peer)
reply_peer_id = self._peer_id_value(reply_peer)
reply_from_id = self._peer_id_value(reply_from)
header_points_outside_chat = bool(
reply_to_peer
and reply_to_peer_id is not None
and (message_peer_id is None or reply_to_peer_id != message_peer_id)
)
header_matches_reply = bool(
reply_to_peer_id is not None
and reply_to_peer_id in {reply_peer_id, reply_from_id}
)
channel_like = bool(
self._peer_is_channel_like(reply_to_peer)
or self._peer_is_channel_like(reply_peer)
or self._peer_is_channel_like(reply_from)
)
if not channel_like:
try:
sender = await reply.get_sender()
except Exception:
sender = getattr(reply, "sender", None)
channel_like = bool(getattr(sender, "broadcast", False))
if (
header_points_outside_chat
and (
header_matches_reply
or channel_like
or (
reply_id is not None
and reply_to_msg_id is not None
and str(reply_id) == str(reply_to_msg_id)
)
)
):
return True
if (
reply_id is not None
and reply_to_top_id is not None
and str(reply_id) == str(reply_to_top_id)
and channel_like
and getattr(reply, "media", None)
):
return True
if (
reply_id is not None
and reply_to_msg_id is not None
and str(reply_id) == str(reply_to_msg_id)
and reply_to_top_id is not None
and channel_like
):
return True
try:
message_chat_id = utils.get_chat_id(message)
reply_chat_id = utils.get_chat_id(reply)
except Exception:
return False
return bool(
message_chat_id is not None
and reply_chat_id is not None
and int(message_chat_id) != int(reply_chat_id)
and getattr(reply, "media", None)
)
async def _reply_media_kind_for_message(self, message, reply, context="generation"):
if context == "trigger" and await self._reply_media_is_source_post(message, reply):
logger.debug(
"Ignoring %s reply media from source post: message_id=%s reply_id=%s",
context,
getattr(message, "id", None),
getattr(reply, "id", None),
)
return None
return self._reply_media_kind(reply)
async def _generation_input_media(self, message, reply=None):
reply = reply if reply is not None else await message.get_reply_message()
reply_kind = await self._reply_media_kind_for_message(
message,
reply,
context="generation",
)
if reply_kind:
return reply, reply_kind
attached_kind = self._reply_media_kind(message)
if attached_kind:
return message, attached_kind
return reply, None
async def _album_images_from_reply(self, message, reply):
grouped_id = getattr(reply, "grouped_id", None)
reply_id = getattr(reply, "id", None)
if not grouped_id or not reply_id:
return []
try:
reply_id = int(reply_id)
chat_id = utils.get_chat_id(message)
messages = await self.client.get_messages(
chat_id,
ids=list(range(max(1, reply_id - 12), reply_id + 13)),
)
except Exception as error:
logger.debug("Could not load album messages: %s", error)
return []
if isinstance(messages, Message):
messages = [messages]
candidates = {int(reply_id): reply}
for item in messages or []:
item_id = getattr(item, "id", None)
if item_id is not None:
candidates[int(item_id)] = item
images = [
item
for item in candidates.values()
if str(getattr(item, "grouped_id", "")) == str(grouped_id)
and self._reply_media_kind(item) == "image"
]
return sorted(images, key=lambda item: int(getattr(item, "id", 0) or 0))
async def _open_album_picker(self, message):
reply = await message.get_reply_message()
images = await self._album_images_from_reply(message, reply)
if len(images) < 2:
return False
state_id = str(uuid.uuid4())
self._album_picker_states[state_id] = {
"message": message,
"chat_id": utils.get_chat_id(message),
"image_ids": [int(item.id) for item in images],
}
buttons = []
row = []
for index in range(len(images)):
row.append({
"text": self.strings("album_picker_button").format(index + 1),
"callback": self._album_picker_select,
"args": (state_id, index),
})
if len(row) == 2:
buttons.append(row)
row = []
if row:
buttons.append(row)
buttons.append([{"text": self.strings("btn_cancel"), "action": "close", "style": "danger"}])
text = "\n\n".join([
self.strings("album_picker_title"),
self.strings("album_picker_desc").format(len(images)),
])
rendered = await self._render_inline(message, text, buttons)
if not rendered:
self._album_picker_states.pop(state_id, None)
return False
return True
async def _album_picker_select(self, call: InlineCall, state_id, index):
state = self._album_picker_states.pop(state_id, None)
if not state:
return await self._safe_call_answer(
call,
self.strings("album_picker_expired"),
show_alert=True,
)
try:
image_id = state["image_ids"][int(index)]
image = await self.client.get_messages(state["chat_id"], ids=image_id)
except Exception as error:
logger.debug("Could not get selected album image: %s", error)
return await self._render_inline(call, self.strings("album_picker_unavailable"))
if self._reply_media_kind(image) != "image":
return await self._render_inline(call, self.strings("album_picker_unavailable"))
await self.comfy(
state["message"],
_input_media=image,
_resume_target=call,
)
async def _trigger_reply_media_is_source_post(self, message, reply):
return await self._reply_media_is_source_post(message, reply)
async def _trigger_reply_media_kind(self, message, reply):
return await self._reply_media_kind_for_message(message, reply, context="trigger")
async def _make_trigger_inline_anchor(self, message):
try:
return await self._send_message_as_self_if_possible(
utils.get_chat_id(message),
self.strings("connecting"),
reply_to=message.id,
)
except Exception as e:
logger.debug("Failed to create trigger inline anchor: %s", e)
return message
def _is_trigger_cooldown_error(self, error):
error_type, _ = self._classify_error(error)
return error_type in ("server_unavailable", "unavailable", "connection", "timeout")
def _contains_cyrillic(self, text):
return bool(re.search(r"[\u0400-\u04FF]", str(text or "")))
async def _translate_prompt_to_english(self, prompt):
prompt = str(prompt or "").strip()
if not prompt or not self._contains_cyrillic(prompt):
return prompt, None
cached = self._translation_cache.get(prompt)
if cached:
return cached, None
try:
async with self._session_get(
_TRANSLATE_URL,
params={
"client": "gtx",
"sl": "auto",
"tl": "en",
"dt": "t",
"q": prompt[:4500],
},
timeout=aiohttp.ClientTimeout(total=_COMFY_TIMEOUTS["translation"]),
) as response:
if response.status != 200:
logger.warning("Prompt translation failed with HTTP %s", response.status)
return None, f"HTTP {response.status}"
payload = await response.json(content_type=None)
parts = payload[0] if isinstance(payload, list) and payload else []
translated = "".join(
str(item[0])
for item in parts
if isinstance(item, list) and item and isinstance(item[0], str)
).strip()
if not translated:
return None, "empty response"
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError) as error:
logger.warning("Prompt translation failed: %s", error)
return None, type(error).__name__
self._translation_cache[prompt] = translated
return translated, None
def _apply_trigger_steps_limit(self, parsed_steps, max_steps, wf_name):
max_steps = self._coerce_int(max_steps, 40, 1, 100)
if parsed_steps is not None:
return min(parsed_steps, max_steps)
workflow_steps = self._load_defaults_from_workflow(wf_name).get("steps")
try:
workflow_steps = int(workflow_steps)
except (TypeError, ValueError):
return parsed_steps
if workflow_steps > max_steps:
return max_steps
return parsed_steps
async def _handle_trigger_generation_error(self, state, error, status_form):
origin = state.get("trigger_origin") if isinstance(state, dict) else None
if not origin or not self._is_trigger_cooldown_error(error):
return False
error_type, details = self._classify_error(error)
text = self._get_error_message(error_type, details, is_inline=False)
await self._notify_trigger_unavailable_by_origin(
origin.get("chat_id"),
origin.get("sender_id"),
origin.get("message_id"),
text,
)
try:
await status_form.delete()
except Exception:
pass
return True
async def _run_trigger_generation(self, message, raw_args, settings):
if not raw_args:
await self._send_trigger_reply(message, self.strings("no_prompt"))
return
limited_mode = self._workflow_limited_mode()
repeat_requested = bool(raw_args and re.search(r'-r\b|-repeat\b', raw_args, re.IGNORECASE))
repeat_selected_loras = {}
if repeat_requested:
last = self.get("last_generation")
if not last:
await self._send_trigger_reply(message, self.strings("repeat_no_last"))
return
if not limited_mode:
repeat_selected_loras = self._normalize_selected_loras(last.get("selected_loras"))
parts = [last.get("positive", "")]
if last.get("negative") is not None:
parts.append(f'-neg "{last["negative"]}"')
if not limited_mode:
if last.get("width"):
parts.append(f'-w {last["width"]}')
if last.get("height"):
parts.append(f'-h {last["height"]}')
if last.get("steps"):
parts.append(f'-steps {last["steps"]}')
if last.get("cfg"):
parts.append(f'-cfg {last["cfg"]}')
if last.get("denoise") is not None:
parts.append(f'-denoise {last["denoise"]}')
raw_args = " ".join(parts)
base = self._base_url()
if not base:
await self._notify_trigger_unavailable(message, self.strings("no_url"))
return
if await self._is_channel_discussion_comment(message):
await self._send_trigger_reply(message, self.strings("comments_generation_disabled"))
return
health_status = None
async def _update_trigger_connection_retry(next_attempt, total_attempts):
nonlocal health_status
text = self.strings("connecting_retry").format(next_attempt, total_attempts)
if health_status:
await self._safe_answer(health_status, text)
else:
health_status = await self._send_trigger_reply(message, text)
async def _answer_trigger_preflight(text):
if health_status:
await self._safe_answer(health_status, text)
return health_status
return await self._send_trigger_reply(message, text)
health = await self._health_check(on_retry=_update_trigger_connection_retry)
if not health:
if health_status:
await self._safe_answer(health_status, self.strings("unavailable"))
else:
await self._notify_trigger_unavailable(message, self.strings("unavailable"))
return
parsed = self._parse_gen_args(raw_args)
parsed["use_lora_picker"] = False
if parsed.get("chat_ai"):
parsed["chat_ai"] = False
parsed["enhance_prompt"] = False
if limited_mode:
parsed = self._apply_limited_generation_mode(parsed)
positive = parsed["positive"]
if (
(parsed.get("translate_prompt") or settings.get("translate_prompt"))
and self._contains_cyrillic(positive)
):
translated, translation_error = await self._translate_prompt_to_english(positive)
if translation_error:
if parsed.get("translate_prompt"):
await _answer_trigger_preflight(self.strings("translation_failed"))
return
logger.warning("Automatic trigger translation failed: %s", translation_error)
else:
positive = translated
parsed["positive"] = positive
if (
settings.get("reject_russian_prompt")
and self._contains_cyrillic(positive)
and not parsed.get("enhance_prompt")
):
await _answer_trigger_preflight(self.strings("trigger_russian_requires_ai"))
return
if parsed.get("inspire"):
parsed["enhance_prompt"] = False
status = await _answer_trigger_preflight(self.strings("status_civitai_inspire"))
try:
inspired_prompt = await self._fetch_civitai_random_prompt()
except ValueError as e:
await self._safe_answer(status or message, str(e))
return
except Exception as e:
logger.exception(e)
await self._safe_answer(status or message, self.strings("civitai_error"))
return
try:
if status and status is not health_status:
await status.delete()
except Exception:
pass
positive = inspired_prompt["positive"]
if parsed["negative"] is None and inspired_prompt.get("negative"):
parsed["negative"] = inspired_prompt["negative"]
if not positive:
await _answer_trigger_preflight(self.strings("no_prompt"))
return
if positive.strip().lower() == "ничего":
await _answer_trigger_preflight(self.strings("easter_nothing"))
return
width = parsed["width"]
height = parsed["height"]
seed = parsed["seed"]
denoise = parsed["denoise"]
parsed_steps = parsed["steps"]
parsed_cfg = parsed["cfg"]
reply = await message.get_reply_message()
reply_kind = await self._trigger_reply_media_kind(message, reply)
has_photo = reply_kind == "image"
has_video = reply_kind == "video"
input_filename = None
input_image_path = None
input_image_name = None
input_video_path = None
input_video_name = None
wf_name = self._trigger_workflow_name(settings)
wf_data = await self._ensure_workflow_data(wf_name)
if not wf_data:
available = ", ".join(self._get_all_workflow_names())
await _answer_trigger_preflight(
self.strings("wf_not_found").format(
utils.escape_html(wf_name),
utils.escape_html(available),
),
)
return
negative = (
parsed["negative"]
if parsed["negative"] is not None
else self._resolve_negative_prompt(wf_name, wf_data)[0]
)
required_input_kind = self._workflow_required_input_kind(wf_data)
if required_input_kind == "image" and not has_photo:
await _answer_trigger_preflight(self.strings("no_reply_photo"))
return
if required_input_kind == "video" and not has_video:
await _answer_trigger_preflight(self.strings("ctools_no_reply_video"))
return
parsed_steps = self._apply_trigger_steps_limit(
parsed_steps,
settings.get("max_steps", 40),
wf_name,
)
model = (
None
if limited_mode
else self._resolve_generation_model(wf_data)
)
if has_photo:
try:
input_image_path, input_image_name = await self._download_input_image_to_temp(reply)
except UserFacingError as e:
if e.key == "input_too_large":
await _answer_trigger_preflight(
self.strings("img_too_large").format(e.kwargs.get("max_mb", self.config["max_input_mb"])),
)
return
raise
except Exception as e:
logger.error("Failed to prepare image for ComfyUI: %s: %s", type(e).__name__, e)
logger.exception(e)
await _answer_trigger_preflight(self.strings("err_upload_failed"))
return
if has_video:
try:
input_video_path, input_video_name = await self._download_input_media_to_temp(
reply,
prefix="input_video",
default_suffix=".mp4",
)
except UserFacingError as e:
if e.key == "input_too_large":
await _answer_trigger_preflight(
self.strings("img_too_large").format(e.kwargs.get("max_mb", self.config["max_input_mb"])),
)
return
raise
except Exception as e:
logger.error("Failed to prepare video for ComfyUI: %s: %s", type(e).__name__, e)
logger.exception(e)
await _answer_trigger_preflight(self.strings("err_upload_failed"))
return
original_positive = positive
enhance_error = None
censored_enhance = False
if parsed.get("enhance_prompt"):
enhance_status = await _answer_trigger_preflight(self.strings("status_enhancing"))
enhanced, error = await self._enhance_prompt(
positive,
model or "unknown",
image_path=input_image_path if has_photo else None,
)
try:
if enhance_status and enhance_status is not health_status:
await enhance_status.delete()
except Exception:
pass
if error:
enhance_error = error
else:
positive = enhanced
if enhance_error:
self._cleanup_input_file({"input_image_path": input_image_path, "input_video_path": input_video_path})
await _answer_trigger_preflight(self._get_enhance_error_text(enhance_error))
return
positive = self._apply_positive_prompt_preset(wf_name, positive)
easter_egg = self._pick_easter_egg(positive, width, height)
if has_photo and not input_image_path:
try:
input_image_path, input_image_name = await self._download_input_image_to_temp(reply)
except UserFacingError as e:
if e.key == "input_too_large":
await _answer_trigger_preflight(
self.strings("img_too_large").format(e.kwargs.get("max_mb", self.config["max_input_mb"])),
)
return
raise
except Exception as e:
logger.error("Failed to prepare image for ComfyUI: %s: %s", type(e).__name__, e)
logger.exception(e)
await _answer_trigger_preflight(self.strings("err_upload_failed"))
return
generation_state = self._build_generation_state(
positive=positive,
original_positive=original_positive,
negative=negative,
width=width,
height=height,
seed=seed,
denoise=denoise,
steps=parsed_steps,
cfg=parsed_cfg,
wf_name=wf_name,
model=model,
input_filename=input_filename,
input_image_name=input_image_name,
input_image_path=input_image_path,
input_video_name=input_video_name,
input_video_path=input_video_path,
chat_id=utils.get_chat_id(message),
reply_to=message.id,
enhance_prompt=parsed.get("enhance_prompt", False),
use_lora_picker=False,
enhanced=parsed.get("enhance_prompt") and not censored_enhance and positive != original_positive,
easter_egg=easter_egg,
selected_loras={} if limited_mode else (repeat_selected_loras if repeat_requested else self._get_default_lora_presets()),
auto_delete_result_delay=settings["auto_delete_delay"] if settings.get("auto_delete") else None,
trigger_origin={
"chat_id": utils.get_chat_id(message),
"sender_id": self._trigger_sender_identity(message),
"message_id": message.id,
},
health_checked=True,
sampler_name=parsed.get("sampler_name"),
scheduler=parsed.get("scheduler"),
limited_mode=limited_mode,
)
if health_status:
try:
await health_status.delete()
except Exception:
pass
await self._maybe_render_cloud_confirm(
await self._make_trigger_inline_anchor(message),
generation_state,
skip_confirm=bool(settings.get("cloud_skip_confirm", True)),
)
@loader.watcher()
async def watcher(self, message: Message):
try:
text = (getattr(message, "raw_text", None) or message.text or "").strip()
chat_id = utils.get_chat_id(message)
sender_id = getattr(message, "sender_id", None) or self.tg_id or 0
pending_key = f"{chat_id}:{sender_id}"
pending = self._emoji_theme_pending.pop(pending_key, None)
if pending:
slot = pending.get("slot")
slug = pending.get("slug")
extracted = self._extract_custom_emoji_from_message(message, slot=slot)
if not extracted:
await utils.answer(message, self._apply_emoji_theme(self.strings("ult_theme_no_emoji")))
return
emoji_id, char = extracted
if self._set_custom_theme_slot(slug, slot, emoji_id, char):
saved_text = self._format_theme_slot_saved_text(slot, emoji_id, char)
inline_message_id = pending.get("inline_message_id")
if inline_message_id:
await self._ult_custom_theme_slot_menu(
{
"inline_message_id": inline_message_id,
"unit_id": pending.get("unit_id"),
},
slug,
slot,
force_edit=True,
)
await utils.answer(
message,
saved_text,
)
else:
await utils.answer(message, self._apply_emoji_theme(self.strings("ult_theme_not_custom")))
return
if not text:
return
settings = self._get_trigger_settings_for_chat(chat_id, create=False)
if not settings["enabled"]:
return
trigger_sender_id = self._trigger_sender_identity(message)
trigger_sender_numeric_id = self._trigger_sender_numeric_id(message)
try:
if trigger_sender_numeric_id and trigger_sender_numeric_id in settings.get("blacklist", []):
return
except (TypeError, ValueError):
pass
trigger_prompt = self._extract_trigger_prompt(text, settings["trigger"])
if trigger_prompt is None:
return
cooldown_key = self._trigger_cooldown_key(chat_id, trigger_sender_id)
queue_key = self._trigger_queue_key(chat_id)
too_often = False
queue_full = False
async with self._trigger_queue_lock:
if cooldown_key in self._trigger_generation_cooldowns:
too_often = True
else:
active = self._trigger_queue_counts.get(queue_key, 0)
if active >= settings["max_queue"]:
queue_full = True
else:
self._trigger_generation_cooldowns[cooldown_key] = True
self._trigger_queue_counts[queue_key] = active + 1
if too_often:
await self._notify_trigger_too_often(message)
return
if queue_full:
await self._notify_trigger_queue_full(message)
return
try:
await self._run_trigger_generation(message, trigger_prompt, settings)
finally:
async with self._trigger_queue_lock:
remaining = self._trigger_queue_counts.get(queue_key, 1) - 1
if remaining > 0:
self._trigger_queue_counts[queue_key] = remaining
else:
self._trigger_queue_counts.pop(queue_key, None)
except Exception as e:
logger.exception(e)
@loader.command(
ru_doc=" [промпт] - Улучшить промпт без генерации",
aliases=["eprompt", "aiprompt"],
)
async def enhance(self, message: Message):
""" [prompt] - Enhance prompt without generation"""
raw_args = utils.get_args_raw(message)
raw_args = re.sub(r'-cai\b', '', raw_args, flags=re.IGNORECASE).strip()
if raw_args:
prompt = raw_args
else:
reply = await message.get_reply_message()
prompt = (getattr(reply, "raw_text", None) or reply.text or "").strip() if reply else ""
if not prompt:
return await self._safe_answer(message, self.strings("no_prompt"))
status = await self._safe_answer(message, self.strings("status_enhancing"))
enhanced, error = await self._enhance_prompt(prompt, self.config["model_name"] or "unknown")
if error:
return await self._safe_answer(status or message, self._get_enhance_error_text(error))
return await self._start_enhance_chat(
status or message,
mode="enhance",
prompt=enhanced,
original_prompt=prompt,
model=self.config["model_name"] or "unknown",
)
def _parse_upscale_scale(self, raw_args):
raw_args = str(raw_args or "").strip().replace(",", ".")
if not raw_args:
return 2.0
raw_args = raw_args.rstrip("xX").strip()
if not re.fullmatch(r"[0-9]+(?:\.[0-9]+)?", raw_args):
return None
try:
value = float(raw_args)
except ValueError:
return None
if value < 0.1 or value > 8:
return None
return value
@staticmethod
def _format_scale_value(value):
return f"{float(value):.2f}".rstrip("0").rstrip(".")
def _reply_media_kind(self, reply):
if not reply:
return None
document = getattr(reply, "document", None)
file_obj = getattr(reply, "file", None)
has_media = bool(
getattr(reply, "media", None)
or document
or file_obj
or getattr(reply, "photo", None)
or getattr(reply, "video", None)
or getattr(reply, "gif", None)
)
if not has_media:
return None
if getattr(reply, "video", None) or getattr(reply, "gif", None):
return "video"
if getattr(reply, "photo", None):
return "image"
mime = str(
getattr(file_obj, "mime_type", "")
or getattr(document, "mime_type", "")
or ""
).lower()
if mime.startswith("video/"):
return "video"
if mime == "image/gif" or "gif" in mime:
return "video"
if mime.startswith("image/"):
return "image"
file_name = str(getattr(file_obj, "name", "") or "")
for attr in getattr(document, "attributes", []) or []:
attr_name = type(attr).__name__
if attr_name in ("DocumentAttributeAnimated", "DocumentAttributeVideo"):
return "video"
if attr_name == "DocumentAttributeFilename" and getattr(attr, "file_name", None):
file_name = str(attr.file_name)
ext = os.path.splitext(file_name)[1].lower()
if ext in {".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v", ".gif"}:
return "video"
if ext in {".png", ".jpg", ".jpeg", ".webp", ".bmp"}:
return "image"
return None
def _ctool_output_node(self, mapping, output_kind):
output = mapping.get("output_video") if output_kind == "video" else (mapping.get("output_regular") or mapping.get("output_upscaled"))
return output.get("node_id") if isinstance(output, dict) else None
def _set_ctool_input(self, workflow, mapping, input_filename, input_kind):
if input_kind == "video":
input_map = mapping.get("input_video")
if input_map and input_map.get("node_id") in workflow:
workflow[input_map["node_id"]]["inputs"][input_map["field"]] = input_filename
return
raise UserFacingError("ctools_workflow_no_input", kind=input_kind)
input_maps = list(mapping.get("input_images") or [])
input_image = mapping.get("input_image")
if input_image and input_image not in input_maps:
input_maps.insert(0, input_image)
for input_map in input_maps:
if input_map and input_map.get("node_id") in workflow:
workflow[input_map["node_id"]]["inputs"][input_map["field"]] = input_filename
return
raise UserFacingError("ctools_workflow_no_input", kind=input_kind)
def _set_ctool_scale(self, workflow, mapping, scale):
if scale is None:
return
scale_map = mapping.get("scale_by")
if scale_map and scale_map.get("node_id") in workflow:
workflow[scale_map["node_id"]]["inputs"][scale_map["field"]] = float(scale)
return
for node in workflow.values():
if isinstance(node, dict) and "scale_by" in node.get("inputs", {}):
node["inputs"]["scale_by"] = float(scale)
return
async def _prepare_ctool_workflow(self, tool_id, input_filename, scale=None):
tool = self._ctool_definitions()[tool_id]
workflow = await self._fetch_ctool_workflow(tool_id)
workflow = self._normalize_workflow_format(json.loads(json.dumps(workflow)))
mapping = self._parse_workflow(workflow)
self._set_ctool_input(workflow, mapping, input_filename, tool["input_kind"])
if tool_id == _CTOOL_UPSCALE:
self._set_ctool_scale(workflow, mapping, scale)
workflow = await self._materialize_global_inputs(workflow)
output_node = self._ctool_output_node(mapping, tool["output_kind"])
if not output_node:
raise UserFacingError("ctools_workflow_no_output")
return workflow, output_node, tool["output_kind"]
async def _ctool_set_status(self, target, text):
if isinstance(target, InlineCall):
await self._render_inline(target, self._to_inline_emoji(text))
return target
return await self._safe_answer(target, text)
async def _run_ctool(self, target, reply, tool_id, scale=None, chat_id=None, reply_to=None, reply_kind=None):
base = self._base_url()
if not base:
return await self._ctool_set_status(target, self.strings("no_url"))
definitions = self._ctool_definitions()
tool = definitions.get(tool_id)
if not tool:
return await self._ctool_set_status(target, self.strings("ctools_bad_mode"))
reply_kind = reply_kind or self._reply_media_kind(reply)
effective_tool_id = (
_CTOOL_VIDEO_UPSCALE
if tool_id == _CTOOL_UPSCALE and reply_kind == "video"
else tool_id
)
tool = definitions.get(effective_tool_id) or tool
if effective_tool_id == _CTOOL_VIDEO_UPSCALE:
scale = None
if reply_kind != tool["input_kind"]:
key = (
"ctools_no_reply_media"
if tool_id == _CTOOL_UPSCALE
else ("ctools_no_reply_video" if tool["input_kind"] == "video" else "ctools_no_reply_image")
)
return await self._ctool_set_status(target, self.strings(key))
chat_id = chat_id or utils.get_chat_id(target)
reply_to = reply_to or getattr(target, "reply_to_msg_id", None)
label = tool["label"]
if tool_id == _CTOOL_UPSCALE and scale is not None:
label = f"{label} x{self._format_scale_value(scale)}"
status = await self._ctool_set_status(target, self.strings(tool.get("processing_key") or "ctools_processing_upscale"))
input_path = None
client_id = str(uuid.uuid4())
try:
if self._is_comfy_cloud():
self._active_cloud_api_key.set(await self._select_cloud_api_key())
default_suffix = ".mp4" if tool["input_kind"] == "video" else ".png"
input_path, input_name = await self._download_input_media_to_temp(reply, prefix=f"ctool_{tool_id}", default_suffix=default_suffix)
input_filename = await self._upload_input_path_to_comfyui(input_path, input_name, content_type=mimetypes.guess_type(input_name)[0])
workflow, output_node, output_kind = await self._prepare_ctool_workflow(effective_tool_id, input_filename, scale)
await self._raise_if_cloud_workflow_unsupported(workflow)
async def _do_queue():
return await self._retry(self._queue_prompt, workflow, client_id)
_, history = await self._wait_ws(client_id, _do_queue, expected_output_node=output_node, timeout=_CUPSCALE_TIMEOUT, workflow=workflow)
output_keys = ("videos", "video", "animated", "animations", "gifs", "images") if output_kind == "video" else ("images",)
media_info = self._extract_media_info(history, output_node, output_keys)
if not media_info:
logger.warning(
"No ctool media found in ComfyUI history for tool=%s node=%s; output_kind=%s; outputs=%s",
tool_id,
output_node,
output_kind,
self._history_output_summary(history),
)
raise UserFacingError("retrieve_failed", self._plain_text(self.strings("err_retrieve_failed")))
media_kind = self._media_kind_from_info(media_info, output_kind)
media_bio = await self._retry(self._retrieve_comfy_media, media_info, media_kind)
try:
await self._ctool_set_status(status or target, self.strings("ctools_uploading"))
caption = self.strings(tool["done_key"]) if tool.get("done_key") else self.strings("ctools_done").format(label)
await self._send_file_result(chat_id, media_bio, caption, reply_to=reply_to, force_document=True)
finally:
media_bio.close()
try:
if status:
await status.delete()
except Exception:
pass
except Exception as e:
logger.exception(e)
target_for_error = status or target
text = self._get_error_message(*self._classify_error(e), is_inline=isinstance(target_for_error, InlineCall))
if isinstance(target_for_error, InlineCall):
await self._render_inline(target_for_error, text)
else:
await self._safe_answer(target_for_error, text)
finally:
if self._is_comfy_cloud():
self._active_cloud_api_key.set(None)
self._cleanup_input_file(input_path)
def _parse_ctools_args(self, raw_args):
parts = str(raw_args or "").split()
if not parts:
return None, None, False
tool_id = self._canonical_ctool_id(parts[0])
if not tool_id:
return None, None, True
scale = None
if tool_id == _CTOOL_UPSCALE:
scale = self._parse_upscale_scale(" ".join(parts[1:]))
if scale is None:
return tool_id, None, True
return tool_id, scale, False
async def _render_ctools_menu(self, message, reply, reply_kind):
state_id = str(uuid.uuid4())
self._ctools_states[state_id] = {
"chat_id": utils.get_chat_id(message),
"reply_id": getattr(reply, "id", None),
"reply_to": getattr(message, "reply_to_msg_id", None),
"reply_kind": reply_kind,
}
lines = [
self.strings("ctools_title"),
"",
self.strings("ctools_desc_upscale"),
self.strings("ctools_desc_rmbg"),
self.strings("ctools_desc_fps"),
]
markup = [
[{"text": self.strings("ctools_btn_upscale"), "callback": self._ctools_menu_run, "args": (state_id, _CTOOL_UPSCALE)}],
[{"text": self.strings("ctools_btn_rmbg"), "callback": self._ctools_menu_run, "args": (state_id, _CTOOL_RMBG)}],
[{"text": self.strings("ctools_btn_fps"), "callback": self._ctools_menu_run, "args": (state_id, _CTOOL_FPS)}],
[{"text": self.strings("btn_close"), "callback": self._safe_close_form, "style": "danger"}],
]
await self._render_inline(message, self._to_inline_emoji("\n".join(lines)), markup)
async def _ctools_menu_run(self, call: InlineCall, state_id: str, tool_id: str):
state = self._ctools_states.pop(state_id, None)
if not state:
return await self._render_inline(call, self._to_inline_emoji(self.strings("ctools_state_expired")))
reply = await self.client.get_messages(state["chat_id"], ids=state["reply_id"])
scale = 2.0 if tool_id == _CTOOL_UPSCALE else None
await self._run_ctool(
call,
reply,
tool_id,
scale=scale,
chat_id=state["chat_id"],
reply_to=state.get("reply_to"),
reply_kind=state.get("reply_kind"),
)
@loader.command(
ru_doc=" [-upscale (0.1-8x)|-rmbg|-fps] - апскейл медиа, убрать фон, повысить FPS видео",
)
async def ctools(self, message: Message):
""" [-upscale (0.1-8x)|-rmbg|-fps] - upscale media, remove background, boost video FPS"""
reply = await message.get_reply_message()
reply_kind = self._reply_media_kind(reply)
raw_args = utils.get_args_raw(message).strip()
if not raw_args:
if not reply_kind:
return await self._safe_answer(message, "\n".join([self.strings("ctools_title"), self.strings("ctools_usage")]))
return await self._render_ctools_menu(message, reply, reply_kind)
tool_id, scale, bad = self._parse_ctools_args(raw_args)
if bad or not tool_id:
if tool_id == _CTOOL_UPSCALE:
return await self._safe_answer(message, self.strings("ctools_bad_scale"))
return await self._safe_answer(message, self.strings("ctools_bad_mode"))
if not reply_kind:
tool = self._ctool_definitions().get(tool_id, {})
key = (
"ctools_no_reply_media"
if tool_id == _CTOOL_UPSCALE
else ("ctools_no_reply_video" if tool.get("input_kind") == "video" else "ctools_no_reply_image")
)
return await self._safe_answer(message, self.strings(key))
await self._run_ctool(
message,
reply,
tool_id,
scale=scale,
chat_id=utils.get_chat_id(message),
reply_to=getattr(message, "reply_to_msg_id", None),
reply_kind=reply_kind,
)
@loader.command(
ru_doc=" [Реплай на генерацию из архива] [текст] - Поделиться своей генерацией в @ComfyIdeas. -anon, -top",
)
async def cshare(self, message: Message):
""" [reply to archive generation] [text] - share your generation to @ComfyIdeas. -anon, -top"""
raw_args = utils.get_args_raw(message)
if re.search(r"(^|\s)-top(\s|$)", raw_args, re.IGNORECASE):
return await self._answer_cshare_top(message)
reply = await message.get_reply_message()
if not reply:
return await self._safe_answer(message, self.strings("cshare_no_reply"))
generation_number = self._extract_cshare_generation_number(
getattr(reply, "raw_text", None) or reply.text or ""
)
if generation_number is None:
return await self._safe_answer(message, self.strings("cshare_no_archive"))
prompt_message, prompt_text = await self._find_cshare_prompt_info(reply, generation_number)
if not prompt_message or not prompt_text:
return await self._safe_answer(message, self.strings("cshare_no_prompt_info"))
data = self._parse_archive_prompt_text(prompt_text)
if data.get("generation") != generation_number:
return await self._safe_answer(message, self.strings("cshare_no_prompt_info"))
state, error_text = await self._prepare_cshare_preview_state(
message,
reply,
generation_number,
prompt_text,
raw_args,
)
if error_text:
return await self._safe_answer(message, error_text)
await self._render_cshare_preview(message, state)
@loader.command(
ru_doc=" [промпт] - Генерация изображения. -r, -neg, -w, -h, -steps, -cfg, -seed, -denoise, -lora, -ai, -noai, -i, -t",
aliases=["img"],
)
async def comfy(self, message: Message, _input_media=None, _resume_target=None):
""" [prompt] - Generate image. -r, -neg, -w, -h, -steps, -cfg, -seed, -denoise, -lora, -ai, -noai, -i, -t"""
raw_args = utils.get_args_raw(message)
if _input_media is None and await self._open_album_picker(message):
return
limited_mode = self._workflow_limited_mode()
repeat_requested = bool(raw_args and re.search(r'-r\b|-repeat\b', raw_args, re.IGNORECASE))
repeat_selected_loras = {}
if repeat_requested:
last = self.get("last_generation")
if not last:
return await self._safe_answer(message, self.strings("repeat_no_last"))
if not limited_mode:
repeat_selected_loras = self._normalize_selected_loras(last.get("selected_loras"))
parts = [last.get("positive", "")]
if last.get("negative") is not None:
parts.append(f'-neg "{last["negative"]}"')
if not limited_mode:
if last.get("width"):
parts.append(f'-w {last["width"]}')
if last.get("height"):
parts.append(f'-h {last["height"]}')
if last.get("steps"):
parts.append(f'-steps {last["steps"]}')
if last.get("cfg"):
parts.append(f'-cfg {last["cfg"]}')
if last.get("denoise") is not None:
parts.append(f'-denoise {last["denoise"]}')
raw_args = " ".join(parts)
base = self._base_url()
if not base:
return await self._safe_answer(message, self.strings("no_url"))
if await self._is_channel_discussion_comment(message):
return await self._safe_answer(message, self.strings("comments_generation_disabled"))
preflight_target = None
async def _ensure_preflight(string_key="preflight_preparing"):
nonlocal preflight_target
if preflight_target is None:
if _resume_target is not None:
preflight_target = (
await self._render_inline(
_resume_target,
self._format_generation_preflight_inline(
self.strings(string_key)
),
)
or _resume_target
)
else:
preflight_target = await self._create_generation_preflight(message, string_key)
else:
preflight_target = await self._update_generation_preflight(preflight_target, string_key) or preflight_target
return preflight_target
async def _finish_preflight(text):
if preflight_target is not None:
return await self._update_generation_preflight(preflight_target, text=text)
return await self._safe_answer(message, text)
preloaded_reply = None
preloaded_reply_kind = None
preloaded_wf_name = None
preloaded_wf_data = None
image_only_workflow = False
if not raw_args:
if _input_media is not None:
preloaded_reply = _input_media
preloaded_reply_kind = self._reply_media_kind(_input_media)
else:
preloaded_reply = await message.get_reply_message()
preloaded_reply, preloaded_reply_kind = await self._generation_input_media(
message,
preloaded_reply,
)
if not preloaded_reply_kind:
return await self._safe_answer(message, self.strings("no_prompt"))
await _ensure_preflight("preflight_workflow")
preloaded_wf_name = self._canonical_workflow_name(self.get("default_workflow", _DEFAULT_WORKFLOW_NAME))
preloaded_wf_data = await self._ensure_workflow_data(preloaded_wf_name)
image_only_workflow = self._is_image_only_workflow_data(preloaded_wf_data)
if not image_only_workflow:
return await _finish_preflight(self.strings("no_prompt"))
parsed = self._parse_gen_args(raw_args)
default_lora_data = self._get_default_lora_data()
lora_presets_enabled = self._argset_enabled(default_lora_data)
default_selected_loras = (
self._get_enabled_lora_presets(default_lora_data.get("selected"))
if lora_presets_enabled
else {}
)
picker_lora_entries = default_lora_data.get("selected") or {}
if not lora_presets_enabled:
picker_lora_entries = {
lora_name: {
"enabled": False,
"weight": entry.get("weight", 0.75)
if isinstance(entry, dict)
else entry,
}
for lora_name, entry in picker_lora_entries.items()
}
if lora_presets_enabled:
parsed["use_lora_picker"] = True
if repeat_requested:
picker_lora_entries = repeat_selected_loras
if limited_mode:
parsed = self._apply_limited_generation_mode(parsed)
default_selected_loras = {}
picker_lora_entries = {}
repeat_selected_loras = {}
positive = parsed["positive"]
if parsed.get("inspire"):
parsed["enhance_prompt"] = False
await _ensure_preflight("status_civitai_inspire")
try:
inspired_prompt = await self._fetch_civitai_random_prompt()
except ValueError as e:
return await _finish_preflight(str(e))
except Exception as e:
logger.exception(e)
return await _finish_preflight(self.strings("civitai_error"))
positive = inspired_prompt["positive"]
if parsed["negative"] is None and inspired_prompt.get("negative"):
parsed["negative"] = inspired_prompt["negative"]
if parsed.get("translate_prompt") and positive:
translated, translation_error = await self._translate_prompt_to_english(positive)
if translation_error:
return await _finish_preflight(self.strings("translation_failed"))
positive = translated
parsed["positive"] = positive
if not positive and not image_only_workflow:
return await _finish_preflight(self.strings("no_prompt"))
if positive and positive.strip().lower() == "ничего":
return await _finish_preflight(self.strings("easter_nothing"))
await _ensure_preflight("preflight_preparing")
width = parsed["width"]
height = parsed["height"]
seed = parsed["seed"]
denoise = parsed["denoise"]
parsed_steps = parsed["steps"]
parsed_cfg = parsed["cfg"]
if _input_media is not None:
reply = _input_media
reply_kind = self._reply_media_kind(_input_media)
elif preloaded_reply_kind:
reply = preloaded_reply
reply_kind = preloaded_reply_kind
else:
reply, reply_kind = await self._generation_input_media(message)
has_photo = reply_kind == "image"
has_video = reply_kind == "video"
input_filename = None
input_image_path = None
input_image_name = None
input_video_path = None
input_video_name = None
wf_name = preloaded_wf_name or self._canonical_workflow_name(self.get("default_workflow", _DEFAULT_WORKFLOW_NAME))
if preloaded_wf_data is None:
await _ensure_preflight("preflight_workflow")
wf_data = preloaded_wf_data or await self._ensure_workflow_data(wf_name)
if not wf_data:
available = ", ".join(self._get_all_workflow_names())
return await _finish_preflight(
self.strings("wf_not_found").format(
utils.escape_html(wf_name),
utils.escape_html(available),
),
)
negative = (
parsed["negative"]
if parsed["negative"] is not None
else self._resolve_negative_prompt(wf_name, wf_data)[0]
)
image_only_workflow = image_only_workflow or self._is_image_only_workflow_data(wf_data)
mapping = wf_data.get("mapping") or self._parse_workflow(wf_data.get("workflow", {}))
required_input_kind = self._workflow_required_input_kind(wf_data)
if required_input_kind == "image" and not has_photo:
return await _finish_preflight(self.strings("no_reply_photo"))
if required_input_kind == "video" and not has_video:
return await _finish_preflight(self.strings("ctools_no_reply_video"))
promptless_media_workflow = image_only_workflow and not positive
if promptless_media_workflow:
parsed["enhance_prompt"] = False
parsed["chat_ai"] = False
parsed["use_lora_picker"] = False
await _ensure_preflight("preflight_model")
model = (
None
if limited_mode or promptless_media_workflow
else self._resolve_generation_model(wf_data)
)
if has_photo:
await _ensure_preflight("preflight_image")
try:
input_image_path, input_image_name = await self._download_input_image_to_temp(reply)
except UserFacingError as e:
if e.key == "input_too_large":
return await _finish_preflight(
self.strings("img_too_large").format(e.kwargs.get("max_mb", self.config["max_input_mb"])),
)
raise
except Exception as e:
logger.error("Failed to prepare image for ComfyUI: %s: %s", type(e).__name__, e)
logger.exception(e)
return await _finish_preflight(self.strings("err_upload_failed"))
if has_video:
await _ensure_preflight("preflight_image")
try:
input_video_path, input_video_name = await self._download_input_media_to_temp(
reply,
prefix="input_video",
default_suffix=".mp4",
)
except UserFacingError as e:
if e.key == "input_too_large":
return await _finish_preflight(
self.strings("img_too_large").format(e.kwargs.get("max_mb", self.config["max_input_mb"])),
)
raise
except Exception as e:
logger.error("Failed to prepare video for ComfyUI: %s: %s", type(e).__name__, e)
logger.exception(e)
return await _finish_preflight(self.strings("err_upload_failed"))
original_positive = positive
enhance_error = None
censored_enhance = False
if parsed.get("enhance_prompt"):
await _ensure_preflight("status_enhancing")
enhanced, error = await self._enhance_prompt(
positive,
model or "unknown",
image_path=input_image_path if has_photo else None,
)
if error == "censored" and self._prompt_confirm_enabled():
censored_enhance = True
positive = original_positive
elif error:
enhance_error = error
else:
positive = enhanced
if enhance_error:
self._cleanup_input_file({"input_image_path": input_image_path, "input_video_path": input_video_path})
return await _finish_preflight(
self._get_enhance_error_text(enhance_error),
)
positive = self._apply_positive_prompt_preset(wf_name, positive)
easter_egg = self._pick_easter_egg(positive, width, height)
if has_photo and not input_image_path:
await _ensure_preflight("preflight_image")
try:
input_image_path, input_image_name = await self._download_input_image_to_temp(reply)
except UserFacingError as e:
if e.key == "input_too_large":
return await _finish_preflight(
self.strings("img_too_large").format(e.kwargs.get("max_mb", self.config["max_input_mb"])),
)
raise
except Exception as e:
logger.error("Failed to prepare image for ComfyUI: %s: %s", type(e).__name__, e)
logger.exception(e)
return await _finish_preflight(self.strings("err_upload_failed"))
generation_state = self._build_generation_state(
positive=positive,
original_positive=original_positive,
negative=negative,
width=width,
height=height,
seed=seed,
denoise=denoise,
steps=parsed_steps,
cfg=parsed_cfg,
wf_name=wf_name,
model=model,
input_filename=input_filename,
input_image_name=input_image_name,
input_image_path=input_image_path,
input_video_name=input_video_name,
input_video_path=input_video_path,
chat_id=message.chat_id,
reply_to=message.reply_to_msg_id or message.id,
enhance_prompt=parsed.get("enhance_prompt", False),
use_lora_picker=parsed.get("use_lora_picker"),
enhanced=parsed.get("enhance_prompt") and not censored_enhance and positive != original_positive,
easter_egg=easter_egg,
selected_loras={} if promptless_media_workflow else (repeat_selected_loras or default_selected_loras),
lora_entries={} if promptless_media_workflow else picker_lora_entries,
reuse_status_message=isinstance(preflight_target, Message),
sampler_name=parsed.get("sampler_name"),
scheduler=parsed.get("scheduler"),
limited_mode=limited_mode,
)
if parsed.get("chat_ai"):
await self._start_enhance_chat(
preflight_target or message,
mode="generate",
prompt=positive,
original_prompt=original_positive,
generation_state=generation_state,
model=model or "unknown",
image_path=input_image_path if has_photo else None,
)
return
if parsed.get("enhance_prompt") and self._prompt_confirm_enabled():
state_id = str(uuid.uuid4())
self._enhance_confirm_states[state_id] = {
**generation_state,
"enhanced_positive": None if censored_enhance else positive,
"censored": censored_enhance,
}
await self._render_enhance_confirm(preflight_target or message, state_id)
return
await _ensure_preflight("preflight_launch")
await self._maybe_render_cloud_confirm(preflight_target or message, generation_state)
def _cdown_new_state(self):
return {
"type": _CDOWN_TYPE_CHECKPOINT,
"url": "",
"metadata": None,
"metadata_error": None,
"folder": None,
"result": None,
}
def _cdown_get_state(self, state_id):
state = self._cdown_states.get(state_id)
if not isinstance(state, dict):
state = self._cdown_new_state()
self._cdown_states[state_id] = state
return state
def _cdown_format_metadata_lines(self, state):
lines = []
metadata = state.get("metadata")
if isinstance(metadata, dict):
filename = metadata.get("filename") or metadata.get("name") or "-"
lines.append(self.strings("cdown_file").format(utils.escape_html(filename)))
lines.append(
self.strings("cdown_size").format(
self._cdown_format_size(metadata.get("content_length"))
)
)
metadata_error = state.get("metadata_error")
if metadata_error:
lines.append(
self.strings("cdown_validation_fail").format(
utils.escape_html(str(metadata_error)[:500])
)
)
elif isinstance(metadata, dict):
lines.append(self.strings("cdown_validation_ok"))
if state.get("folder"):
lines.append(
self.strings("cdown_folder").format(
utils.escape_html(state["folder"])
)
)
elif state.get("url") and not metadata_error:
lines.append(self.strings("cdown_folder_unknown"))
result = state.get("result")
if isinstance(result, dict):
if result.get("status") == 200:
lines.append(self.strings("cdown_result_ready"))
elif result.get("status") == "failed":
lines.append(
self.strings("cdown_result_failed").format(
utils.escape_html(str(result.get("error") or "-")[:500])
)
)
else:
lines.append(self.strings("cdown_result_started"))
task_id = str(result.get("task_id") or "").strip()
if task_id:
lines.append(
self.strings("cdown_task").format(
utils.escape_html(task_id)
)
)
task_status = str(result.get("task_status") or "").strip()
if task_status:
lines.append(
self.strings("cdown_task_status").format(
utils.escape_html(task_status)
)
)
if result.get("status") not in (200, "failed"):
lines.append(self.strings("cdown_task_waiting"))
return lines
def _cdown_text(self, state):
type_id = state.get("type") or _CDOWN_TYPE_CHECKPOINT
url = state.get("url")
lines = [
self.strings("cdown_title"),
"",
self.strings("cdown_type").format(
utils.escape_html(self._cdown_type_label(type_id))
),
(
self.strings("cdown_url").format(
utils.escape_html(self._cdown_preview_url(url))
)
if url
else self.strings("cdown_url_missing")
),
]
extra = self._cdown_format_metadata_lines(state)
if extra:
lines.extend(["", *extra])
return "\n".join(lines)
def _cdown_markup(self, state_id, state):
current_type = state.get("type") or _CDOWN_TYPE_CHECKPOINT
type_buttons = []
for type_id in _CDOWN_TYPES:
selected = type_id == current_type
type_buttons.append(
{
"text": ("✅ " if selected else "") + self._cdown_type_label(type_id),
"callback": self._cdown_select_type,
"args": (state_id, type_id),
"style": "success" if selected else "primary",
}
)
markup = self._build_button_rows(type_buttons, columns=3)
markup.append(
[
{
"text": self.strings("cdown_btn_url"),
"input": self.strings("cdown_input_url"),
"handler": self._cdown_url_input,
"args": (state_id,),
}
]
)
result = state.get("result") if isinstance(state.get("result"), dict) else {}
if result.get("status") not in (200, 202):
markup.append(
[
{
"text": self.strings("cdown_btn_install"),
"callback": self._cdown_install,
"args": (state_id,),
"style": "success",
}
]
)
if result.get("status") == 202:
markup.append(
[
{
"text": self.strings("cdown_btn_refresh"),
"callback": self._cdown_refresh_status,
"args": (state_id,),
"style": "primary",
}
]
)
markup.append(
[
{
"text": self.strings("btn_close"),
"callback": self._safe_close_form,
"style": "danger",
}
]
)
return markup
async def _cdown_render(self, target, state_id):
state = self._cdown_get_state(state_id)
await self._render_inline(target, self._cdown_text(state), self._cdown_markup(state_id, state))
async def _cdown_refresh_metadata(self, state):
url = state.get("url")
state["metadata"] = None
state["metadata_error"] = None
state["folder"] = None
state["result"] = None
if not url:
state["metadata_error"] = self.strings("cdown_need_url")
return
if not self._cdown_url_allowed(url):
state["metadata_error"] = self.strings("cdown_bad_url")
return
api_key = self._cloud_api_key_or_raise()
candidates = self._cdown_url_candidates(url)
last_error = None
last_metadata = None
last_validation_error = None
for candidate_url in candidates:
try:
metadata = await self._cdown_remote_metadata(candidate_url, api_key)
except Exception as e:
last_error = e
continue
validation_error = self._cdown_validation_error(metadata)
last_metadata = metadata
last_validation_error = validation_error
if validation_error and candidate_url != candidates[-1]:
continue
state["url"] = candidate_url
state["metadata"] = metadata
state["metadata_error"] = validation_error
break
else:
if last_metadata is not None:
state["metadata"] = last_metadata
state["metadata_error"] = last_validation_error
elif last_error:
raise last_error
state["folder"] = await self._cdown_resolve_folder(state.get("type"), api_key)
async def _cdown_select_type(self, call: InlineCall, state_id: str, type_id: str):
state = self._cdown_get_state(state_id)
self._cdown_cancel_watch(state_id)
if type_id not in _CDOWN_TYPES:
type_id = _CDOWN_TYPE_CHECKPOINT
state["type"] = type_id
state["result"] = None
if state.get("url") and not state.get("metadata_error"):
try:
state["folder"] = await self._cdown_resolve_folder(type_id)
except Exception as e:
logger.debug("Cloud model folder refresh failed: %s", e)
state["folder"] = None
try:
await call.answer(self.strings("ult_toggle_saved"))
except Exception:
pass
await self._cdown_render(call, state_id)
async def _cdown_url_input(self, call: InlineCall, query: str, state_id: str):
state = self._cdown_get_state(state_id)
self._cdown_cancel_watch(state_id)
state["url"] = str(query or "").strip()
try:
await call.answer(self.strings("cdown_checking"))
except Exception:
pass
try:
await self._cdown_refresh_metadata(state)
if state.get("metadata_error"):
try:
await call.answer(
self._plain_text(str(state["metadata_error"])),
show_alert=True,
)
except Exception:
pass
except Exception as e:
logger.debug("Cloud download metadata check failed: %s", e)
state["metadata_error"] = str(e)
state["metadata"] = None
state["folder"] = None
try:
await call.answer(
self._plain_text(str(e))[:180],
show_alert=True,
)
except Exception:
pass
await self._cdown_render(call, state_id)
async def _cdown_refresh_status(self, call: InlineCall, state_id: str):
state = self._cdown_get_state(state_id)
result = state.get("result")
if not isinstance(result, dict) or result.get("status") != 202:
return await self._cdown_render(call, state_id)
try:
await call.answer(self.strings("cdown_checking"))
except Exception:
pass
try:
api_key = self._cloud_api_key_or_raise()
await self._cdown_update_import_status(state, api_key)
result = state.get("result") if isinstance(state.get("result"), dict) else {}
if result.get("status") == 202:
self._cdown_start_watch(call, state_id, api_key)
except Exception as e:
logger.debug("Cloud download status refresh failed: %s", e)
await self._cdown_render(call, state_id)
async def _cdown_install(self, call: InlineCall, state_id: str):
state = self._cdown_get_state(state_id)
if not self._get_cloud_api_keys():
try:
await call.answer(self.strings("cdown_no_key"), show_alert=True)
except Exception:
pass
return await self._cdown_render(call, state_id)
if not state.get("url"):
try:
await call.answer(self.strings("cdown_need_url"), show_alert=True)
except Exception:
pass
return await self._cdown_render(call, state_id)
if not state.get("metadata"):
try:
await self._cdown_refresh_metadata(state)
except Exception as e:
state["metadata_error"] = str(e)
if state.get("metadata_error"):
try:
await call.answer(self.strings("cdown_need_valid"), show_alert=True)
except Exception:
pass
return await self._cdown_render(call, state_id)
try:
await call.answer(self.strings("cdown_installing"))
except Exception:
pass
try:
api_key = self._cloud_api_key_or_raise()
status, data = await self._cdown_download_asset(state, api_key)
task_id = (
str(data.get("task_id") or "").strip()
if isinstance(data, dict)
else ""
)
state["result"] = {
"status": status,
"data": data,
"task_id": task_id,
"task_status": (
str(data.get("status") or "created")
if status == 202 and isinstance(data, dict)
else "completed"
),
}
self._comfy_cache.clear()
if status == 202:
self._cdown_start_watch(call, state_id, api_key)
elif status == 200:
asset = data if self._clib_asset_id(data) else None
if not asset:
try:
asset = await self._cdown_find_downloaded_asset(state, api_key)
except Exception as e:
logger.debug("Cloud LoRA asset lookup failed: %s", e)
if asset:
await self._cdown_finalize_asset_category(state, asset, api_key)
self._cdown_start_lora_metadata_fetch(state, asset)
try:
await call.answer(
self._plain_text(
self.strings(
"cdown_result_ready"
if status == 200
else "cdown_result_started"
)
),
show_alert=True,
)
except Exception:
pass
except Exception as e:
logger.debug("Cloud download failed: %s", e)
state["result"] = None
state["metadata_error"] = None
try:
await call.answer(
self._plain_text(str(e))[:180],
show_alert=True,
)
except Exception:
pass
text = self.strings("cdown_result_failed").format(
utils.escape_html(str(e)[:500])
)
await self._render_inline(call, text, self._cdown_markup(state_id, state))
return
await self._cdown_render(call, state_id)
@loader.command(
ru_doc=" - загрузить модель в ComfyUI Cloud из Hugging Face/Civitai",
)
async def cdown(self, message: Message):
""" - download model to ComfyUI Cloud from Hugging Face/Civitai"""
state_id = str(uuid.uuid4())
state = self._cdown_new_state()
raw_url = utils.get_args_raw(message).strip()
if raw_url:
state["url"] = raw_url
try:
await self._cdown_refresh_metadata(state)
except Exception as e:
logger.debug("Initial cloud download metadata check failed: %s", e)
state["metadata_error"] = str(e)
self._cdown_states[state_id] = state
await self._cdown_render(message, state_id)
def _clib_new_state(self, assets=None):
return {
"assets": list(assets or []),
"folder": None,
"asset_id": None,
"search_query": "",
"page": 0,
}
def _clib_get_state(self, state_id):
state = self._clib_states.get(state_id)
return state if isinstance(state, dict) else None
def _clib_find_asset(self, state, asset_id):
asset_id = str(asset_id or "").strip()
for asset in state.get("assets") or []:
if self._clib_asset_id(asset) == asset_id:
return asset
return None
def _clib_nav_row(self, state_id, page, total_pages, callback):
row = []
if page > 0:
row.append({
"text": "◀️",
"callback": callback,
"args": (state_id, -1),
})
if page < total_pages - 1:
row.append({
"text": "▶️",
"callback": callback,
"args": (state_id, 1),
})
return row
async def _clib_render_folders(self, target, state_id):
state = self._clib_get_state(state_id)
if not state:
return
search_query = " ".join(str(state.get("search_query") or "").split())
search_key = search_query.casefold()
assets = [
asset
for asset in state.get("assets") or []
if not search_key
or search_key in str(self._cloud_asset_model_name(asset) or "").casefold()
]
groups = self._clib_asset_groups(assets)
folders = list(groups)
page_size = 15
total_pages = max(1, (len(folders) + page_size - 1) // page_size)
page = min(max(0, int(state.get("page") or 0)), total_pages - 1)
state["page"] = page
current = folders[page * page_size:(page + 1) * page_size]
lines = [
self.strings("clib_title"),
self.strings("clib_summary").format(
len(assets),
len(folders),
),
]
if search_query:
lines.append(
self.strings("models_search_label").format(
utils.escape_html(search_query)
)
)
if not folders:
lines.append(
self.strings("models_search_empty")
if search_query
else self.strings("clib_empty")
)
else:
lines.append(self.strings("models_page").format(page + 1, total_pages))
buttons = [
{
"text": f"📁 {self._model_short_button_text(folder, limit=22)} ({len(groups[folder])})",
"callback": self._clib_open_folder,
"args": (state_id, folder),
}
for folder in current
]
markup = self._build_button_rows(buttons, columns=2)
nav = self._clib_nav_row(state_id, page, total_pages, self._clib_folders_page)
if nav:
markup.append(nav)
search_row = [{
"text": self.strings("models_search_btn"),
"input": self.strings("models_search_input"),
"handler": self._clib_search_input,
"args": (state_id,),
}]
if search_query:
search_row.append({
"text": self.strings("models_search_clear"),
"callback": self._clib_search_clear,
"args": (state_id,),
})
markup.append(search_row)
markup.append([
{
"text": self.strings("clib_btn_refresh"),
"callback": self._clib_refresh,
"args": (state_id,),
"style": "primary",
},
{
"text": self.strings("btn_close"),
"callback": self._safe_close_form,
"style": "danger",
},
])
await self._render_inline(target, "\n".join(lines), markup)
async def _clib_render_items(self, target, state_id):
state = self._clib_get_state(state_id)
if not state:
return
folder = str(state.get("folder") or "")
groups = self._clib_asset_groups(state.get("assets"))
assets = list(groups.get(folder) or [])
page_size = 6
total_pages = max(1, (len(assets) + page_size - 1) // page_size)
page = min(max(0, int(state.get("page") or 0)), total_pages - 1)
state["page"] = page
current = assets[page * page_size:(page + 1) * page_size]
lines = [
self.strings("clib_title"),
self.strings("clib_folder_title").format(utils.escape_html(folder)),
]
if not assets:
lines.append(self.strings("clib_empty"))
else:
for asset in current:
name = self._cloud_asset_model_name(asset) or "-"
lines.append(
""
+ utils.escape_html(self._format_model_name(name, max_length=None))
+ "\n"
+ utils.escape_html(self._cdown_format_size(asset.get("size")))
+ "
"
)
lines.append(self.strings("models_page").format(page + 1, total_pages))
buttons = [
{
"text": self._model_short_button_text(
self._cloud_asset_model_name(asset) or "-",
limit=30,
),
"callback": self._clib_open_asset,
"args": (state_id, self._clib_asset_id(asset)),
}
for asset in current
]
markup = self._build_button_rows(buttons, columns=1)
nav = self._clib_nav_row(state_id, page, total_pages, self._clib_items_page)
if nav:
markup.append(nav)
markup.append([
{
"text": self.strings("btn_back"),
"callback": self._clib_back_folders,
"args": (state_id,),
"style": "primary",
},
{
"text": self.strings("btn_close"),
"callback": self._safe_close_form,
"style": "danger",
},
])
await self._render_inline(target, "\n".join(lines), markup)
async def _clib_render_asset(self, target, state_id):
state = self._clib_get_state(state_id)
if not state:
return
asset = self._clib_find_asset(state, state.get("asset_id"))
if not asset:
return await self._clib_render_items(target, state_id)
name = self._cloud_asset_model_name(asset) or "-"
tags = ", ".join(str(tag) for tag in asset.get("tags") or []) or "-"
lines = [
self.strings("clib_title"),
self.strings("clib_name").format(utils.escape_html(name)),
self.strings("cdown_size").format(self._cdown_format_size(asset.get("size"))),
self.strings("clib_category").format(
utils.escape_html(self._clib_asset_folder(asset))
),
self.strings("clib_tags").format(utils.escape_html(tags[:500])),
]
if asset.get("is_immutable"):
lines.append(self.strings("clib_immutable"))
markup = []
if not asset.get("is_immutable"):
markup.append([
{
"text": self.strings("clib_btn_move"),
"callback": self._clib_move_menu,
"args": (state_id,),
"style": "primary",
}
])
markup.append([
{
"text": self.strings("clib_btn_delete"),
"callback": self._clib_delete_confirm,
"args": (state_id,),
"style": "danger",
}
])
markup.append([
{
"text": self.strings("btn_back"),
"callback": self._clib_back_items,
"args": (state_id,),
"style": "primary",
},
{
"text": self.strings("btn_close"),
"callback": self._safe_close_form,
"style": "danger",
},
])
await self._render_inline(target, "\n".join(lines), markup)
async def _clib_render_move_menu(self, target, state_id):
state = self._clib_get_state(state_id)
if not state:
return
asset = self._clib_find_asset(state, state.get("asset_id"))
if not asset:
return await self._clib_render_items(target, state_id)
selected = self._clib_asset_type(asset)
buttons = []
for type_id in _CDOWN_TYPES:
is_selected = type_id == selected
buttons.append({
"text": ("✅ " if is_selected else "") + self._cdown_type_label(type_id),
"callback": self._clib_move_category,
"args": (state_id, type_id),
"style": "success" if is_selected else "primary",
})
markup = self._build_button_rows(buttons, columns=3)
markup.append([
{
"text": self.strings("btn_back"),
"callback": self._clib_back_asset,
"args": (state_id,),
"style": "primary",
},
{
"text": self.strings("btn_close"),
"callback": self._safe_close_form,
"style": "danger",
},
])
await self._render_inline(
target,
"\n".join([
self.strings("clib_title"),
self.strings("clib_move_title"),
self.strings("clib_name").format(
utils.escape_html(self._cloud_asset_model_name(asset) or "-")
),
]),
markup,
)
async def _clib_open_folder(self, call: InlineCall, state_id: str, folder: str):
state = self._clib_get_state(state_id)
if not state:
return
state.update({"folder": str(folder), "asset_id": None, "page": 0})
await self._clib_render_items(call, state_id)
async def _clib_open_asset(self, call: InlineCall, state_id: str, asset_id: str):
state = self._clib_get_state(state_id)
if not state or not self._clib_find_asset(state, asset_id):
return
state.update({"asset_id": asset_id, "page": 0})
await self._clib_render_asset(call, state_id)
async def _clib_folders_page(self, call: InlineCall, state_id: str, delta: int):
state = self._clib_get_state(state_id)
if not state:
return
state["page"] = int(state.get("page") or 0) + int(delta)
await self._clib_render_folders(call, state_id)
async def _clib_items_page(self, call: InlineCall, state_id: str, delta: int):
state = self._clib_get_state(state_id)
if not state:
return
state["page"] = int(state.get("page") or 0) + int(delta)
await self._clib_render_items(call, state_id)
async def _clib_back_folders(self, call: InlineCall, state_id: str):
state = self._clib_get_state(state_id)
if not state:
return
state.update({"folder": None, "asset_id": None, "page": 0})
await self._clib_render_folders(call, state_id)
async def _clib_back_items(self, call: InlineCall, state_id: str):
state = self._clib_get_state(state_id)
if not state:
return
state.update({"asset_id": None, "page": 0})
await self._clib_render_items(call, state_id)
async def _clib_back_asset(self, call: InlineCall, state_id: str):
await self._clib_render_asset(call, state_id)
async def _clib_move_menu(self, call: InlineCall, state_id: str):
await self._clib_render_move_menu(call, state_id)
async def _clib_delete_confirm(self, call: InlineCall, state_id: str):
state = self._clib_get_state(state_id)
if not state:
return
asset = self._clib_find_asset(state, state.get("asset_id"))
if not asset:
return await self._clib_render_items(call, state_id)
if asset.get("is_immutable"):
return await self._clib_render_asset(call, state_id)
markup = [[
{
"text": self.strings("clib_delete_confirm"),
"callback": self._clib_delete_asset,
"args": (state_id,),
"style": "danger",
},
{
"text": self.strings("btn_back"),
"callback": self._clib_back_asset,
"args": (state_id,),
"style": "primary",
},
], [
{
"text": self.strings("btn_close"),
"callback": self._safe_close_form,
"style": "danger",
}
]]
await self._render_inline(
call,
"\n".join([
self.strings("clib_title"),
self.strings("clib_delete_title"),
self.strings("clib_name").format(
utils.escape_html(self._cloud_asset_model_name(asset) or "-")
),
]),
markup,
)
async def _clib_delete_asset(self, call: InlineCall, state_id: str):
state = self._clib_get_state(state_id)
if not state:
return
asset = self._clib_find_asset(state, state.get("asset_id"))
if not asset:
return await self._clib_render_items(call, state_id)
if asset.get("is_immutable"):
return await self._clib_render_asset(call, state_id)
asset_id = self._clib_asset_id(asset)
if not asset_id:
return await self._clib_render_items(call, state_id)
try:
await call.answer(self.strings("clib_deleting"))
except Exception:
pass
try:
async with self._session_delete(
f"{_COMFY_CLOUD_BASE_URL}/api/assets/{quote(asset_id, safe='')}",
headers=self._cloud_headers(self._cloud_api_key_or_raise()),
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
text = await resp.text()
if resp.status not in (200, 204):
raise ComfyUIHTTPError(resp.status, text)
state["assets"] = [
item
for item in state.get("assets") or []
if self._clib_asset_id(item) != asset_id
]
state["asset_id"] = None
self._comfy_cache.clear()
try:
await call.answer(self.strings("clib_deleted"))
except Exception:
pass
except Exception as e:
logger.debug("Cloud library asset deletion failed: %s", e)
try:
await call.answer(self._plain_text(str(e))[:180], show_alert=True)
except Exception:
pass
await self._clib_render_items(call, state_id)
async def _clib_move_category(self, call: InlineCall, state_id: str, type_id: str):
state = self._clib_get_state(state_id)
if not state:
return
asset = self._clib_find_asset(state, state.get("asset_id"))
if not asset:
return await self._clib_render_items(call, state_id)
if type_id == self._clib_asset_type(asset):
return await self._clib_render_asset(call, state_id)
try:
await call.answer(self.strings("clib_updating"))
except Exception:
pass
try:
await self._clib_update_asset_category(
asset,
type_id,
self._cloud_api_key_or_raise(),
)
try:
await call.answer(self.strings("clib_moved"))
except Exception:
pass
except Exception as e:
logger.debug("Cloud library category update failed: %s", e)
try:
await call.answer(self._plain_text(str(e))[:180], show_alert=True)
except Exception:
pass
await self._clib_render_asset(call, state_id)
async def _clib_refresh(self, call: InlineCall, state_id: str):
state = self._clib_get_state(state_id)
if not state:
return
try:
await call.answer(self.strings("clib_loading"))
except Exception:
pass
try:
state["assets"] = await self._clib_fetch_model_assets(
self._cloud_api_key_or_raise()
)
state.update({"folder": None, "asset_id": None, "page": 0})
self._comfy_cache.clear()
except Exception as e:
logger.debug("Cloud library refresh failed: %s", e)
try:
await call.answer(self._plain_text(str(e))[:180], show_alert=True)
except Exception:
pass
await self._clib_render_folders(call, state_id)
async def _clib_search_input(self, call: InlineCall, query: str, state_id: str):
state = self._clib_get_state(state_id)
if not state:
return
state.update({
"search_query": " ".join(str(query or "").split()),
"folder": None,
"asset_id": None,
"page": 0,
})
await self._clib_render_folders(self._source_inline_target(call), state_id)
async def _clib_search_clear(self, call: InlineCall, state_id: str):
state = self._clib_get_state(state_id)
if not state:
return
state.update({
"search_query": "",
"folder": None,
"asset_id": None,
"page": 0,
})
await self._clib_render_folders(call, state_id)
@loader.command(
ru_doc=" - библиотека импортированных моделей ComfyUI Cloud",
)
async def clib(self, message: Message):
""" - ComfyUI Cloud imported model library"""
if not self._get_cloud_api_keys():
return await self._safe_answer(message, self.strings("clib_no_key"))
try:
assets = await self._clib_fetch_model_assets(
self._cloud_api_key_or_raise()
)
except Exception as e:
logger.debug("Cloud library load failed: %s", e)
return await self._safe_answer(
message,
utils.escape_html(self._plain_text(str(e))[:500]),
)
state_id = str(uuid.uuid4())
self._clib_states[state_id] = self._clib_new_state(assets)
await self._clib_render_folders(message, state_id)
@loader.command(
ru_doc=" [воркфлоу] | [-bl] - Настройки модуля/Доп. функции. Быстрый выбор воркфлоу для триггера этого чата. -bl [reply/@user/id] (блэклист для триггеров)",
)
async def ultcomfy(self, message: Message):
"""[workflow] | [-bl] - Module settings/additional functions and chat trigger workflow. -bl [reply/@user/id] (trigger blacklist)"""
self._ensure_ult_settings()
raw_args = utils.get_args_raw(message)
if re.search(r"(^|\s)-bl(\s|$)", raw_args, re.IGNORECASE):
query = re.sub(r"(^|\s)-bl(\s|$)", " ", raw_args, flags=re.IGNORECASE).strip()
return await self._ult_toggle_trigger_blacklist_user(message, query)
workflow_query = str(raw_args or "").strip()
if workflow_query:
chat_id = utils.get_chat_id(message)
settings = self._get_trigger_settings_for_chat(chat_id)
if workflow_query.lower() in {"default", "дефолт", "поумолчанию"}:
settings["workflow"] = ""
self._set_trigger_settings_for_chat(chat_id, settings)
return await self._safe_answer(
message,
self.strings("ult_trigger_workflow_default_set"),
)
workflow_name = self._canonical_workflow_name(workflow_query)
available = self._trigger_workflow_choices()
if workflow_name not in available:
return await self._safe_answer(
message,
self.strings("wf_not_found").format(
utils.escape_html(workflow_query),
utils.escape_html(", ".join(available)),
),
)
settings["workflow"] = workflow_name
self._set_trigger_settings_for_chat(chat_id, settings)
return await self._safe_answer(
message,
self.strings("ult_trigger_workflow_set").format(
utils.escape_html(workflow_name)
),
)
await self._ult_render_main(message)
@loader.command(
ru_doc=" - справка ComfyUI",
)
async def chelp(self, message: Message):
""" - ComfyUI help"""
text = self._to_inline_emoji(self.strings("help_text"))
rendered = await self._render_inline_with_info_banner(
message,
text,
[[{"text": self.strings("btn_close"), "callback": self._safe_close_form, "style": "danger"}]],
)
if not rendered:
await self._smart_answer(message, self.strings("help_text"))
def _onboarding_text(self):
return "\n\n".join([
self.strings("onboarding_title"),
self.strings("onboarding_prompt"),
self.strings("onboarding_local"),
self.strings("onboarding_cloud"),
])
def _onboarding_markup(self):
return [
[{
"text": self.strings("onboarding_btn_local"),
"callback": self._onboarding_select_backend,
"args": (_COMFY_BACKEND_LOCAL,),
}],
[{
"text": self.strings("onboarding_btn_cloud"),
"callback": self._onboarding_select_backend,
"args": (_COMFY_BACKEND_CLOUD,),
"style": "primary",
}],
]
def _is_onboarding_loader_message(self, message):
if not message or not getattr(message, "out", False):
return False
text = str(getattr(message, "raw_text", None) or getattr(message, "message", ""))
return self.strings("name").lower() in text.lower()
async def _show_onboarding_after_install(self):
try:
for _ in range(10):
if self._unloading or not self.get("onboarding_pending", False):
return
await asyncio.sleep(0.5)
async for dialog in self.client.iter_dialogs(limit=30):
message = getattr(dialog, "message", None)
if not self._is_onboarding_loader_message(message):
continue
text = self._onboarding_text()
guide_message = await message.respond(self._plain_text(text))
rendered = await self._render_inline(guide_message, text, self._onboarding_markup())
if not rendered:
await self._safe_answer(guide_message, text)
self.set("onboarding_pending", False)
self.set("onboarding_shown", True)
return
except asyncio.CancelledError:
raise
except Exception as e:
logger.debug("Failed to show ComfyImageGen onboarding: %s", e)
async def _set_comfy_backend_mode(self, backend):
backend = _COMFY_BACKEND_CLOUD if backend == _COMFY_BACKEND_CLOUD else _COMFY_BACKEND_LOCAL
self.config["comfyui_backend"] = backend
if backend == _COMFY_BACKEND_CLOUD:
if self._workflow_model_autoswitch_enabled():
self._set_cloud_model_as_workflow(True)
self.set("default_workflow", _DEFAULT_CLOUD_WORKFLOW_NAME)
self._set_workflow_limited_mode(False)
await self._autoswitch_model_to_workflow(_DEFAULT_CLOUD_WORKFLOW_NAME)
self._update_default_arg_values()
self._comfy_cache.clear()
if backend == _COMFY_BACKEND_LOCAL:
self._restore_tunnel_watch_state()
return backend
async def _onboarding_select_backend(self, call: InlineCall, backend: str):
backend = await self._set_comfy_backend_mode(backend)
self.set("onboarding_completed", True)
try:
await call.answer(self.strings("onboarding_saved"))
except Exception:
pass
await self.chelp(call)
async def _render_cmode(self, call_or_message):
backend = self._comfy_backend()
lines = [
self.strings("mode_title"),
self.strings("mode_current").format(utils.escape_html(self._format_comfy_backend_name(backend))),
]
if backend == _COMFY_BACKEND_CLOUD:
keys_count = len(self._get_cloud_api_keys())
keys_text = (
self.strings("mode_keys_set").format(keys_count)
if keys_count
else self.strings("mode_keys_missing")
)
lines.append(self.strings("mode_cloud_keys").format(utils.escape_html(keys_text)))
lines.append(self.strings("mode_balance").format(utils.escape_html(await self._format_cloud_balance_for_ui())))
mode_buttons = [
{"text": self.strings("mode_btn_local"), "callback": self._cmode_select, "args": (_COMFY_BACKEND_LOCAL,)},
{"text": self.strings("mode_btn_cloud"), "callback": self._cmode_select, "args": (_COMFY_BACKEND_CLOUD,), "style": "success"},
]
action_buttons = [
{
"text": self.strings("mode_btn_key"),
"input": self.strings("mode_input_key"),
"handler": self._cmode_cloud_key_input,
},
{"text": self.strings("mode_btn_balance"), "callback": self._cmode_balance},
]
else:
lines.append(
self.strings(
"mode_local_url_set"
if self._local_base_url()
else "mode_local_url_missing"
)
)
mode_buttons = [
{"text": self.strings("mode_btn_local"), "callback": self._cmode_select, "args": (_COMFY_BACKEND_LOCAL,), "style": "success"},
{"text": self.strings("mode_btn_cloud"), "callback": self._cmode_select, "args": (_COMFY_BACKEND_CLOUD,)},
]
action_buttons = [
{
"text": self.strings("mode_btn_url"),
"input": self.strings("mode_input_url"),
"handler": self._cmode_local_url_input,
},
{"text": self.strings("mode_btn_check"), "callback": self._cmode_check},
]
markup = [
mode_buttons,
action_buttons,
]
if backend == _COMFY_BACKEND_CLOUD:
markup.append([{"text": self.strings("mode_btn_check"), "callback": self._cmode_check}])
markup.append([{"text": self.strings("btn_close"), "callback": self._safe_close_form, "style": "danger"}])
await self._render_inline(call_or_message, "\n".join(lines), markup)
async def _cmode_select(self, call: InlineCall, backend: str):
backend = await self._set_comfy_backend_mode(backend)
try:
await call.answer(self.strings("mode_saved").format(self._format_comfy_backend_name(backend)))
except Exception:
pass
await self._render_cmode(call)
async def _cmode_local_url_input(self, call: InlineCall, query: str):
url = str(query or "").strip()
try:
self.config["comfyui_url"] = self._normalize_probe_url(url)
self._comfy_cache.clear()
self._restore_tunnel_watch_state()
await call.answer(self.strings("mode_url_saved"))
except Exception:
await call.answer(self.strings("ct_bad_url"), show_alert=True)
await self._render_cmode(call)
async def _cmode_cloud_key_input(self, call: InlineCall, query: str):
self._set_cloud_api_keys(query)
self._comfy_cache.clear()
try:
await call.answer(self.strings("mode_key_saved"))
except Exception:
pass
await self._render_cmode(call)
async def _cmode_check(self, call: InlineCall):
ok = False
try:
if self._is_comfy_cloud():
if not self._get_cloud_api_keys():
raise UserFacingError("cloud_no_key", self._plain_text(self.strings("cloud_no_key")))
self._comfy_cache.clear()
await self._select_cloud_api_key(set_active=False)
ok = True
else:
ok = bool(await self._health_check(attempts=1))
except Exception as e:
logger.debug("cmode connection check failed: %s", e)
ok = False
try:
await call.answer(self.strings("mode_check_ok" if ok else "mode_check_fail"), show_alert=True)
except Exception:
pass
await self._render_cmode(call)
async def _cmode_balance(self, call: InlineCall):
balance = await self._format_cloud_balance_for_ui(force=True)
try:
await call.answer(self.strings("mode_balance").format(balance), show_alert=True)
except Exception:
pass
await self._render_cmode(call)
@loader.command(ru_doc=" - выбрать локальный ComfyUI или ComfyUI Cloud")
async def cmode(self, message: Message):
""" - Select local ComfyUI or ComfyUI Cloud backend"""
await self._render_cmode(message)
@loader.command(ru_doc=" - Статус подключения к ComfyUI")
async def ci(self, message: Message):
""" - ComfyUI connection status"""
status = await self._render_inline(
message,
self._format_ci_loading_text(),
)
if not status:
status = await self._safe_answer(
message,
self._format_ci_loading_text(),
)
ping_state = {}
ping_stop = asyncio.Event()
ping_task = asyncio.create_task(self._ci_ping_loop(status or message, ping_state, ping_stop))
try:
await self._render_comfyinfo(
status or message,
userbot_ping_state=ping_state,
ping_stop_event=ping_stop,
ping_task=ping_task,
)
finally:
ping_stop.set()
if not ping_task.done():
ping_task.cancel()
try:
await ping_task
except asyncio.CancelledError:
pass
@loader.command(ru_doc=" - мониторинг задач ComfyUI")
async def cmon(self, message: Message):
""" - ComfyUI task monitor"""
if not self._base_url():
return await self._safe_answer(message, self.strings("no_url"))
state_id = self._cmon_state_id(message)
status = await self._safe_answer(message, self.strings("cmon_starting"))
await self._close_cmon_entry(state_id)
snapshot = await self._get_queue_snapshot(timeout=5)
markup = [[{
"text": self.strings("btn_close"),
"callback": self._cmon_close,
"args": (state_id,),
"style": "danger",
}]]
try:
form = await self._create_cmon_form(
status or message,
self._format_cmon_text(snapshot),
markup,
)
except Exception as e:
logger.debug("Failed to create cmon inline form: %s", e)
form = None
if not form:
return await self._safe_answer(status or message, self._plain_text(self._format_cmon_text(snapshot)))
task = asyncio.create_task(self._cmon_loop(state_id, form))
self._cmon_tasks[state_id] = {"task": task, "form": form}
@loader.command(
ru_doc=" [URL] - API проверка текущего туннеля ComfyUI",
)
async def ct(self, message: Message):
""" [URL] - ComfyUI tunnel API probe"""
raw_url = utils.get_args_raw(message).strip()
if raw_url:
try:
base_url = self._normalize_probe_url(raw_url)
except ValueError:
return await self._safe_answer(
message,
self.strings("ct_bad_url"),
)
else:
base_url = self._base_url()
if not base_url:
return await self._safe_answer(message, self.strings("no_url"))
await self._ct_run_probe(message, base_url)
async def _render_comfyinfo(self, target, userbot_ping_state=None, ping_stop_event=None, ping_task=None):
base = self._base_url()
if not base:
if isinstance(target, Message):
return await utils.answer(target, self._apply_emoji_theme(self.strings("no_url")))
return await self._render_inline(target, self._to_inline_emoji(self.strings("no_url")))
lines = [self.strings("info_title")]
model_workflow_lines = [
self.strings("info_model").format(
utils.escape_html(
self._format_model_name(self.config["model_name"], max_length=None)
if self.config["model_name"]
else self.strings("not_set")
),
),
self.strings("info_wf").format(
utils.escape_html(self._canonical_workflow_name(self.get("default_workflow", _DEFAULT_WORKFLOW_NAME))),
),
]
details = []
if self._is_comfy_cloud():
try:
await self._select_cloud_api_key(set_active=False)
health = {"system": {"comfyui": "ComfyUI Cloud"}, "devices": []}
except Exception as e:
logger.debug("ComfyUI Cloud info check failed: %s", e)
health = False
else:
health = await self._health_check()
if health and isinstance(health, dict):
lines.append(self.strings("info_ok"))
system = health.get("system", {})
if isinstance(system, dict):
version = self._first_present(system, (
"comfyui_version",
"comfyui",
"version",
"git_version",
))
python_version = self._first_present(system, ("python_version", "python"))
pytorch_version = self._first_present(system, ("pytorch_version", "torch_version", "torch"))
frontend_version = self._first_present(system, (
"comfyui_frontend_package_version",
"frontend_version",
"frontend",
))
if version:
details.append(self.strings("info_version").format(utils.escape_html(str(version))))
if python_version:
details.append(self.strings("info_python").format(utils.escape_html(str(python_version))))
if pytorch_version:
details.append(self.strings("info_pytorch").format(utils.escape_html(str(pytorch_version))))
if frontend_version:
details.append(self.strings("info_frontend").format(utils.escape_html(str(frontend_version))))
ram_total = self._coerce_int(system.get("ram_total"), 0, 0)
ram_free = self._coerce_int(system.get("ram_free"), 0, 0)
if ram_total > 0:
ram_used = max(0, ram_total - ram_free)
details.append(self.strings("info_ram").format(
self._format_memory_gb(ram_used),
self._format_memory_gb(ram_total),
))
devices = health.get("devices", [])
if devices:
for idx, dev in enumerate(devices, 1):
if not isinstance(dev, dict):
continue
device_name = self._format_comfy_device_name(dev)
if len(devices) > 1:
device_name = f"{idx}. {device_name}"
details.append(self.strings(self._device_info_key(dev, device_name)).format(
utils.escape_html(device_name)
))
if self._device_is_cpu(dev, device_name):
continue
vram_total = self._coerce_int(dev.get("vram_total"), 0, 0)
vram_free = self._coerce_int(dev.get("vram_free"), 0, 0)
if vram_total > 0:
vram_used = max(0, vram_total - vram_free)
details.append(self.strings("info_vram").format(
self._format_memory_gb(vram_used),
self._format_memory_gb(vram_total),
))
elif not self._is_comfy_cloud():
details.append(self.strings("info_device").format(
self.strings("info_no_device")
))
else:
lines.append(self.strings("info_fail"))
if details:
lines.append(f"{chr(10).join(reversed(details))}
")
lines.append(f"{chr(10).join(model_workflow_lines)}
")
total_generations = self.strings("info_total_generations").format(
self._get_total_generation_count()
)
bottom_lines = [
self.strings("info_backend").format(
utils.escape_html(self._format_comfy_backend_name())
)
]
if self._is_comfy_cloud():
bottom_lines.append(
self.strings("info_balance").format(
utils.escape_html(await self._format_cloud_balance_for_ui())
)
)
bottom_lines.append(total_generations)
if ping_stop_event:
ping_stop_event.set()
if ping_task and not ping_task.done():
ping_task.cancel()
try:
await ping_task
except asyncio.CancelledError:
pass
userbot_ping_ms = None
if isinstance(userbot_ping_state, dict):
userbot_ping_ms = userbot_ping_state.get("value")
if userbot_ping_ms is None:
try:
userbot_ping_ms = await self._measure_userbot_ping_ms()
except Exception as e:
logger.debug("Failed to measure ci final ping: %s", e)
ping_quote = self._format_ci_ping_quote(userbot_ping_ms)
if ping_quote:
bottom_lines.append(self.strings("info_userbot_ping").format(int(userbot_ping_ms)))
lines.append(f"{chr(10).join(bottom_lines)}
")
text = self._to_inline_emoji("\n".join(lines))
markup = [
[{"text": self.strings("refresh_btn"), "callback": self._refresh_comfyinfo_callback, "style": "primary"}],
[{"text": self.strings("btn_close"), "callback": self._safe_close_form, "style": "danger"}],
]
if not self._is_comfy_cloud():
markup.insert(0, [
{"text": self.strings("free_btn"), "callback": self._free_memory_callback, "style": "danger"},
{"text": self.strings("force_free_btn"), "callback": self._force_free_memory_callback, "style": "danger"},
])
await self._render_inline_with_info_banner(target, text, markup)
async def _refresh_comfyinfo_callback(self, call: InlineCall):
self._comfy_cache.clear()
await self._render_comfyinfo(call)
async def _free_memory_callback(self, call: InlineCall):
if self._active_generations > 0:
try:
await call.answer(self._plain_text(self.strings("free_busy")), show_alert=True)
except Exception:
pass
return
try:
await self._free_comfy_memory()
try:
await call.answer(self._plain_text(self.strings("free_ok")), show_alert=True)
except Exception:
pass
except Exception as e:
logger.exception(e)
try:
await call.answer(self._plain_text(self.strings("free_fail")), show_alert=True)
except Exception:
pass
await self._render_comfyinfo(call)
async def _force_free_memory_callback(self, call: InlineCall):
try:
await self._force_free_comfy_memory()
try:
await call.answer(self._plain_text(self.strings("force_free_ok")), show_alert=True)
except Exception:
pass
except Exception as e:
logger.exception(e)
try:
await call.answer(self._plain_text(self.strings("free_fail")), show_alert=True)
except Exception:
pass
await self._render_comfyinfo(call)
async def _get_available_checkpoints(self):
models = []
for values in (await self._get_available_models_by_field()).values():
models.extend(values)
return list(dict.fromkeys(models))
def _cloud_model_current_text(self, state=None):
workflow_model = (
(state or {}).get("workflow_model")
or self._current_workflow_model()
or self.strings("not_set")
)
if self._cloud_model_as_workflow():
return self.strings("models_as_workflow").format(
self._format_model_name(workflow_model, max_length=None)
)
return self._format_model_name(
self.config["model_name"] or self.strings("not_set"),
max_length=None,
)
@staticmethod
def _model_short_button_text(value, limit=34):
value = str(value or "").strip()
return value[: limit - 2] + ".." if len(value) > limit else value
@staticmethod
def _cloud_default_folder_sort_key(folder):
folder = str(folder or "").strip()
lower = folder.lower()
root = lower.split("/", 1)[0]
priority = {
"checkpoints": 0,
"checkpoint": 0,
"diffusion_models": 1,
"diffusion_model": 1,
"unet": 1,
"unets": 1,
"loras": 2,
"lora": 2,
"vae": 3,
"vaes": 3,
"controlnet": 4,
"controlnets": 4,
"upscale_models": 5,
"latent_upscale_models": 6,
"text_encoders": 7,
"text_encoder": 7,
"clip": 8,
"clip_vision": 9,
"ipadapter": 10,
"embeddings": 11,
"embedding": 11,
"style_models": 12,
"model_patches": 13,
"sams": 14,
"sam": 14,
"sam2": 15,
"sam3": 16,
"llm": 17,
}
return (priority.get(root, 100), lower)
async def _render_cloud_model_main(self, call_or_message, state_id):
state = self._models_page_cache.get(state_id)
if not state:
return
state["view"] = "cloud_main"
state["page"] = 0
current = self._cloud_model_current_text(state)
lines = [
self.strings("models_cloud_title"),
self.strings("models_cloud_current").format(utils.escape_html(current)),
]
as_workflow_text = (
("\u2705 " if self._cloud_model_as_workflow() else "")
+ self.strings("models_as_workflow_btn")
)
markup = [
[{
"text": as_workflow_text,
"callback": self._model_cloud_as_workflow,
"args": (state_id,),
"style": "success" if self._cloud_model_as_workflow() else "primary",
}],
[{
"text": self.strings("models_cloud_default_btn"),
"callback": self._model_cloud_source,
"args": (state_id, "default"),
}],
[{
"text": self.strings("models_cloud_custom_btn"),
"callback": self._model_cloud_source,
"args": (state_id, "custom"),
}],
[{"text": self.strings("btn_close"), "callback": self._safe_close_form, "style": "danger"}],
]
await self._render_inline(call_or_message, "\n".join(lines), markup)
async def _render_cloud_model_folders(self, call_or_message, state_id):
state = self._models_page_cache.get(state_id)
if not state:
return
folders = list(state.get("folders") or [])
page = state.get("page", 0)
is_default = state.get("source") != "custom"
per_page = 18 if is_default else 8
total_pages = max(1, (len(folders) + per_page - 1) // per_page)
page = min(max(0, page), total_pages - 1)
state["page"] = page
page_folders = folders[page * per_page:(page + 1) * per_page]
title_key = (
"models_cloud_custom_title"
if state.get("source") == "custom"
else "models_cloud_default_title"
)
lines = [
self.strings(title_key),
self.strings("models_cloud_current").format(
utils.escape_html(self._cloud_model_current_text(state))
),
]
if not folders:
lines.append(self.strings("models_cloud_empty_folders"))
else:
lines.append(self.strings("models_page").format(page + 1, total_pages))
buttons = []
for folder in page_folders:
buttons.append({
"text": self._model_short_button_text(folder),
"callback": self._model_cloud_folder,
"args": (state_id, folder),
})
markup = self._build_button_rows(buttons, columns=3 if is_default else 1)
nav_row = []
if page > 0:
nav_row.append({"text": "\u25c0\ufe0f", "callback": self._model_cloud_page, "args": (state_id, -1)})
if page < total_pages - 1:
nav_row.append({"text": "\u25b6\ufe0f", "callback": self._model_cloud_page, "args": (state_id, 1)})
if nav_row:
markup.append(nav_row)
markup.append([
{"text": self.strings("btn_back"), "callback": self._model_cloud_back_main, "args": (state_id,)},
{"text": self.strings("btn_close"), "callback": self._safe_close_form, "style": "danger"},
])
await self._render_inline(call_or_message, "\n".join(lines), markup)
async def _render_cloud_model_items(self, call_or_message, state_id):
state = self._models_page_cache.get(state_id)
if not state:
return
search_query = " ".join(str(state.get("search_query") or "").split())
models = self._filter_names_by_query(
state.get("models") or [],
search_query,
)
page = int(state.get("page") or 0)
per_page = 6
total_pages = max(1, (len(models) + per_page - 1) // per_page)
page = min(max(0, page), total_pages - 1)
state["page"] = page
page_models = models[page * per_page:(page + 1) * per_page]
current_model = str(self.config["model_name"] or "")
cloud_as_workflow = self._cloud_model_as_workflow()
folder = str(state.get("folder") or "")
lines = [
self.strings("models_cloud_folder_title").format(utils.escape_html(folder)),
self.strings("models_cloud_current").format(
utils.escape_html(self._cloud_model_current_text(state))
),
]
if search_query:
lines.append(
self.strings("models_search_label").format(
utils.escape_html(search_query)
)
)
for model in page_models:
icon = (
'\u2705'
if not cloud_as_workflow and model == current_model
else '\u2b1c'
)
lines.append(
f"{icon} {utils.escape_html(self._format_model_name(model, max_length=None))}
"
)
if not models:
lines.append(
self.strings("models_search_empty")
if search_query
else self.strings("models_cloud_empty_models")
)
else:
lines.append(self.strings("models_page").format(page + 1, total_pages))
buttons = []
for model in page_models:
icon = "\u2705 " if not cloud_as_workflow and model == current_model else "\u2b1c "
buttons.append({
"text": icon + self._model_short_button_text(self._format_model_name(model, max_length=None), limit=28),
"callback": self._model_cloud_select,
"args": (state_id, model),
})
markup = self._build_button_rows(buttons, columns=1)
nav_row = []
if page > 0:
nav_row.append({"text": "\u25c0\ufe0f", "callback": self._model_cloud_page, "args": (state_id, -1)})
if page < total_pages - 1:
nav_row.append({"text": "\u25b6\ufe0f", "callback": self._model_cloud_page, "args": (state_id, 1)})
if nav_row:
markup.append(nav_row)
search_row = [{
"text": self.strings("models_search_btn"),
"input": self.strings("models_search_input"),
"handler": self._model_search_input,
"args": (state_id,),
}]
if search_query:
search_row.append({
"text": self.strings("models_search_clear"),
"callback": self._model_search_clear,
"args": (state_id,),
})
markup.append(search_row)
markup.append([
{"text": self.strings("btn_back"), "callback": self._model_cloud_back_folders, "args": (state_id,)},
{"text": self.strings("btn_close"), "callback": self._safe_close_form, "style": "danger"},
])
await self._render_inline(call_or_message, "\n".join(lines), markup)
async def _model_cloud_as_workflow(self, call: InlineCall, state_id: str):
self._set_cloud_model_as_workflow(True)
try:
await call.answer(self.strings("toast_model_as_workflow"))
except Exception:
pass
await self._render_cloud_model_main(call, state_id)
async def _model_cloud_source(self, call: InlineCall, state_id: str, source: str):
state = self._models_page_cache.get(state_id)
if not state:
return
source = "custom" if source == "custom" else "default"
try:
await call.answer(self.strings("models_loading"))
except Exception:
pass
if source == "custom":
try:
groups = await self._get_cloud_user_model_asset_groups()
except Exception as e:
logger.debug("Cloud user model assets failed: %s", e)
try:
await call.answer(self._plain_text(str(e))[:180], show_alert=True)
except Exception:
pass
return await self._render_cloud_model_main(call, state_id)
folders = sorted(groups.keys(), key=str.lower)
if not folders:
try:
await call.answer(self.strings("models_cloud_empty_custom"), show_alert=True)
except Exception:
pass
state["custom_groups"] = groups
else:
folder_data = await self._get_cloud_model_folders(authenticated=False)
folders = [item["name"] for item in folder_data if isinstance(item, dict) and item.get("name")]
folders = sorted(folders, key=self._cloud_default_folder_sort_key)
if not folders:
try:
await call.answer(self.strings("models_cloud_empty_folders"), show_alert=True)
except Exception:
pass
state.update({
"view": "cloud_folders",
"source": source,
"folders": folders,
"folder": None,
"models": [],
"search_query": "",
"page": 0,
})
await self._render_cloud_model_folders(call, state_id)
async def _model_cloud_folder(self, call: InlineCall, state_id: str, folder: str):
state = self._models_page_cache.get(state_id)
if not state:
return
try:
await call.answer(self.strings("models_loading"))
except Exception:
pass
if state.get("source") == "custom":
groups = state.get("custom_groups")
if not isinstance(groups, dict):
groups = await self._get_cloud_user_model_asset_groups()
state["custom_groups"] = groups
models = list(groups.get(folder) or [])
else:
models = await self._get_cloud_models_folder_for_scope(folder, authenticated=False)
state.update({
"view": "cloud_models",
"folder": folder,
"models": sorted(dict.fromkeys(models)),
"search_query": "",
"page": 0,
})
if not state["models"]:
try:
await call.answer(self.strings("models_cloud_empty_models"), show_alert=True)
except Exception:
pass
await self._render_cloud_model_items(call, state_id)
async def _model_cloud_select(self, call: InlineCall, state_id: str, model_name: str):
self.config["model_name"] = model_name
self._set_cloud_model_as_workflow(False)
self._sync_argset_for_current_model()
try:
await call.answer(
self.strings("toast_model_set").format(
self._format_model_name(model_name, max_length=None)
)
)
except Exception:
pass
await self._render_cloud_model_items(call, state_id)
async def _model_cloud_page(self, call: InlineCall, state_id: str, direction: int):
state = self._models_page_cache.get(state_id)
if not state:
return
state["page"] = state.get("page", 0) + direction
if state.get("view") == "cloud_models":
await self._render_cloud_model_items(call, state_id)
else:
await self._render_cloud_model_folders(call, state_id)
async def _model_cloud_back_main(self, call: InlineCall, state_id: str):
await self._render_cloud_model_main(call, state_id)
async def _model_cloud_back_folders(self, call: InlineCall, state_id: str):
state = self._models_page_cache.get(state_id)
if not state:
return
state["view"] = "cloud_folders"
state["page"] = 0
await self._render_cloud_model_folders(call, state_id)
async def _render_model_list(self, call_or_message, state_id):
state = self._models_page_cache.get(state_id)
if not state:
return
search_query = " ".join(str(state.get("search_query") or "").split())
models = self._filter_names_by_query(state.get("models") or [], search_query)
page = int(state.get("page") or 0)
per_page = 6
total_pages = max(1, (len(models) + per_page - 1) // per_page)
page = min(page, total_pages - 1)
state["page"] = page
start = page * per_page
page_models = models[start:start + per_page]
current_model = self.config["model_name"]
cloud_as_workflow = self._is_comfy_cloud() and self._cloud_model_as_workflow()
workflow_model = state.get("workflow_model") or self._current_workflow_model() or self.strings("not_set")
lines = [self.strings("models_title")]
if search_query:
lines.append(
self.strings("models_search_label").format(
utils.escape_html(search_query)
)
)
if self._is_comfy_cloud():
workflow_icon = '\u2705' if cloud_as_workflow else '\u2b1c'
lines.append(
f"{workflow_icon} "
+ self.strings("models_as_workflow").format(
utils.escape_html(self._format_model_name(workflow_model, max_length=None))
)
+ "
"
)
for m in page_models:
icon = '\u2705' if (not cloud_as_workflow and m == current_model) else '\u2b1c'
lines.append(f"{icon} {utils.escape_html(self._format_model_name(m, max_length=None))}
")
if not models:
lines.append(self.strings("models_search_empty"))
lines.append(self.strings("models_page").format(page + 1, total_pages))
text = "\n".join(lines)
buttons = []
for m in page_models:
short_name = self._format_model_name(m, max_length=None)
if len(short_name) > 25:
short_name = short_name[:23] + ".."
icon = "\u2705 " if (not cloud_as_workflow and m == current_model) else "\u2b1c "
buttons.append({
"text": f"{icon}{short_name}",
"callback": self._model_select,
"args": (state_id, m),
})
markup = self._build_button_rows(buttons)
if self._is_comfy_cloud():
markup.append([{
"text": ("\u2705 " if cloud_as_workflow else "") + self.strings("models_as_workflow_btn"),
"callback": self._model_as_workflow,
"args": (state_id,),
}])
nav_row = []
if page > 0:
nav_row.append({"text": "\u25c0\ufe0f", "callback": self._model_page, "args": (state_id, -1)})
if page < total_pages - 1:
nav_row.append({"text": "\u25b6\ufe0f", "callback": self._model_page, "args": (state_id, 1)})
if nav_row:
markup.append(nav_row)
search_row = [{
"text": self.strings("models_search_btn"),
"input": self.strings("models_search_input"),
"handler": self._model_search_input,
"args": (state_id,),
}]
if search_query:
search_row.append({
"text": self.strings("models_search_clear"),
"callback": self._model_search_clear,
"args": (state_id,),
})
markup.append(search_row)
markup.append([{
"text": self.strings("models_manual_btn"),
"input": self.strings("models_manual_input"),
"handler": self._model_manual_input,
"args": (state_id,),
}])
markup.append([{"text": "\u274c", "callback": self._safe_close_form, "style": "danger", "emoji_id": "5121063440311386962"}])
await self._render_inline(call_or_message, text, markup)
async def _model_page(self, call: InlineCall, state_id: str, direction: int):
state = self._models_page_cache.get(state_id)
if not state:
return
state["page"] += direction
await self._render_model_list(call, state_id)
async def _model_search_input(self, call: InlineCall, query: str, state_id: str):
state = self._models_page_cache.get(state_id)
if not state:
return
state["search_query"] = " ".join(str(query or "").split())
state["page"] = 0
target = self._source_inline_target(call)
if state.get("view") == "cloud_models":
await self._render_cloud_model_items(target, state_id)
else:
await self._render_model_list(target, state_id)
async def _model_search_clear(self, call: InlineCall, state_id: str):
state = self._models_page_cache.get(state_id)
if not state:
return
state["search_query"] = ""
state["page"] = 0
if state.get("view") == "cloud_models":
await self._render_cloud_model_items(call, state_id)
else:
await self._render_model_list(call, state_id)
async def _model_select(self, call: InlineCall, state_id: str, model_name: str):
self.config["model_name"] = model_name
if self._is_comfy_cloud():
self._set_cloud_model_as_workflow(False)
self._sync_argset_for_current_model()
try:
await call.answer(self.strings("toast_model_set").format(self._format_model_name(model_name, max_length=None)))
except Exception:
pass
state = self._models_page_cache.get(state_id)
if state:
await self._render_model_list(call, state_id)
async def _model_manual_input(self, call: InlineCall, query: str, state_id: str):
model_name = str(query or "").strip()
if not model_name:
return
self.config["model_name"] = model_name
if self._is_comfy_cloud():
self._set_cloud_model_as_workflow(False)
self._sync_argset_for_current_model()
try:
await call.answer(self.strings("toast_model_set").format(self._format_model_name(model_name, max_length=None)))
except Exception:
pass
state = self._models_page_cache.get(state_id)
if state:
if model_name not in state["models"]:
state["models"].append(model_name)
state["models"] = sorted(dict.fromkeys(state["models"]))
await self._render_model_list(call, state_id)
async def _model_as_workflow(self, call: InlineCall, state_id: str):
if self._is_comfy_cloud():
self._set_cloud_model_as_workflow(True)
try:
await call.answer(self.strings("toast_model_as_workflow"))
except Exception:
pass
state = self._models_page_cache.get(state_id)
if state:
await self._render_model_list(call, state_id)
@loader.command(
ru_doc=" - Выбрать модель ComfyUI",
aliases=["smodel", "setm"],
)
async def setmodel(self, message: Message):
""" - Select ComfyUI model"""
args = utils.get_args_raw(message).strip()
if args:
self.config["model_name"] = args
if self._is_comfy_cloud():
self._set_cloud_model_as_workflow(False)
self._sync_argset_for_current_model()
return await utils.answer(
message,
self.strings("models_set").format(utils.escape_html(self._format_model_name(args, max_length=None))),
)
if self._is_comfy_cloud():
state_id = str(uuid.uuid4())
self._models_page_cache[state_id] = {
"view": "cloud_main",
"source": None,
"folders": [],
"folder": None,
"models": [],
"search_query": "",
"page": 0,
"workflow_model": self._current_workflow_model(),
}
return await self._render_cloud_model_main(message, state_id)
base = self._base_url()
if not base:
return await utils.answer(message, self._apply_emoji_theme(self.strings("no_url")))
status = await self._safe_answer(
message,
self._format_generation_preflight_inline(self.strings("models_loading")),
)
models = await self._get_available_checkpoints()
if not models and not self._is_comfy_cloud():
return await self._safe_answer(status or message, self.strings("models_empty"))
state_id = str(uuid.uuid4())
self._models_page_cache[state_id] = {
"models": sorted(models),
"search_query": "",
"page": 0,
"workflow_model": self._current_workflow_model(),
}
await self._render_model_list(status or message, state_id)
async def _render_wf_main(self, call_or_message, state_id):
current_wf = self._canonical_workflow_name(self.get("default_workflow", _DEFAULT_WORKFLOW_NAME))
current_icon = (
'🔵'
if self._workflow_limited_mode()
else '✅'
)
text = "\n".join([
self.strings("wf_title"),
self.strings("wf_current").format(f"{current_icon} {utils.escape_html(current_wf)}"),
])
markup = [
[
{"text": self.strings("wf_builtin_btn"), "callback": self._wf_show_builtin, "args": (state_id,)},
{"text": self.strings("wf_custom_btn"), "callback": self._wf_show_custom, "args": (state_id,)},
],
]
if self._is_comfy_cloud():
markup.append([{"text": self.strings("wf_cloud_btn"), "callback": self._wf_show_cloud, "args": (state_id,)}])
markup.append([{"text": "\u274c", "callback": self._safe_close_form}])
await self._render_inline(call_or_message, text, markup)
async def _wf_show_builtin(self, call: InlineCall, state_id: str):
state = self._wf_page_cache.get(state_id)
if not state:
return
state["workflows"] = sorted(self._BUILTIN_WORKFLOWS)
state["wf_type"] = "builtin"
state["page"] = 0
await self._render_wf_list(call, state_id)
async def _wf_show_custom(self, call: InlineCall, state_id: str):
state = self._wf_page_cache.get(state_id)
if not state:
return
custom = self.get("workflows", {})
if not custom:
try:
await call.answer(self.strings("toast_no_custom_wf"), show_alert=True)
except Exception:
pass
return
state["workflows"] = sorted(custom.keys())
state["wf_type"] = "custom"
state["page"] = 0
await self._render_wf_list(call, state_id)
async def _wf_show_cloud(self, call: InlineCall, state_id: str):
state = self._wf_page_cache.get(state_id)
if not state:
return
if not self._is_comfy_cloud():
return await self._render_wf_main(call, state_id)
state["workflows"] = list(self._CLOUD_WORKFLOWS)
state["wf_type"] = "cloud"
state["page"] = 0
await self._render_wf_list(call, state_id)
async def _render_wf_list(self, call_or_message, state_id):
state = self._wf_page_cache.get(state_id)
if not state:
return
workflows = state["workflows"]
page = state["page"]
per_page = 6
total_pages = max(1, (len(workflows) + per_page - 1) // per_page)
page = min(page, total_pages - 1)
state["page"] = page
start = page * per_page
page_wfs = workflows[start:start + per_page]
current_wf = self._canonical_workflow_name(self.get("default_workflow", _DEFAULT_WORKFLOW_NAME))
title_key = {
"builtin": "wf_list_title_builtin",
"cloud": "wf_list_title_cloud",
}.get(state["wf_type"], "wf_list_title_custom")
limited_mode = self._workflow_limited_mode()
lines = [self.strings(title_key)]
if state["wf_type"] != "cloud":
lines.append(f"{self.strings('wf_limited_hint')}
")
for w in page_wfs:
if w == current_wf and limited_mode and state["wf_type"] != "cloud":
icon = '🔵'
elif w == current_wf:
icon = '\u2705'
else:
icon = '\u2b1c'
lines.append(f"{icon} {utils.escape_html(w)}")
description = self._workflow_description(w)
if description:
lines.append(f"{utils.escape_html(description)}
")
lines.append(self.strings("wf_page").format(page + 1, total_pages))
text = "\n".join(lines)
buttons = []
for w in page_wfs:
display = w
if len(display) > 25:
display = display[:23] + ".."
is_current = w == current_wf
if is_current and limited_mode and state["wf_type"] != "cloud":
icon = "🔵 "
style = "primary"
elif is_current:
icon = "\u2705 "
style = "success"
else:
icon = "\u2b1c "
style = None
button = {
"text": f"{icon}{display}",
"callback": self._wf_select,
"args": (state_id, w),
}
if style:
button["style"] = style
buttons.append(button)
markup = self._build_button_rows(buttons)
nav_row = []
if page > 0:
nav_row.append({"text": "\u25c0\ufe0f", "callback": self._wf_page, "args": (state_id, -1)})
if page < total_pages - 1:
nav_row.append({"text": "\u25b6\ufe0f", "callback": self._wf_page, "args": (state_id, 1)})
if nav_row:
markup.append(nav_row)
markup.append([
{"text": self.strings("btn_back"), "callback": self._wf_back_to_main, "args": (state_id,)},
{"text": "\u274c", "callback": self._safe_close_form},
])
await self._render_inline(call_or_message, text, markup)
async def _wf_page(self, call: InlineCall, state_id: str, direction: int):
state = self._wf_page_cache.get(state_id)
if not state:
return
state["page"] += direction
await self._render_wf_list(call, state_id)
async def _wf_select(self, call: InlineCall, state_id: str, wf_name: str):
state = self._wf_page_cache.get(state_id)
current_wf = self._canonical_workflow_name(self.get("default_workflow", _DEFAULT_WORKFLOW_NAME))
is_cloud_workflow = self._is_cloud_workflow_name(wf_name)
if is_cloud_workflow:
self.config["comfyui_backend"] = _COMFY_BACKEND_CLOUD
self._comfy_cache.clear()
if wf_name == current_wf and not is_cloud_workflow:
limited_mode = not self._workflow_limited_mode()
self._set_workflow_limited_mode(limited_mode)
toast_key = "toast_wf_limited_on" if limited_mode else "toast_wf_limited_off"
else:
self.set("default_workflow", wf_name)
self._set_workflow_limited_mode(False)
await self._autoswitch_model_to_workflow(wf_name)
self._update_default_arg_values()
limited_mode = False
toast_key = "toast_wf_set"
try:
await call.answer(self.strings(toast_key).format(wf_name))
except Exception:
pass
if state:
await self._render_wf_list(call, state_id)
async def _wf_back_to_main(self, call: InlineCall, state_id: str):
await self._render_wf_main(call, state_id)
@loader.command(
ru_doc=" [имя] (-lm ограниченный режим) - Выбрать воркфлоу по умолчанию",
aliases=["setworkflow"],
)
async def setwf(self, message: Message):
""" [name] (-lm limited mode) - Select default workflow"""
args = utils.get_args_raw(message)
if args:
limited_mode = bool(re.search(r"(^|\s)-lm(\s|$)", args, re.IGNORECASE))
raw_name = re.sub(r"(^|\s)-lm(\s|$)", " ", args, flags=re.IGNORECASE).strip()
if raw_name:
if raw_name.lower() == "i2i":
return await utils.answer(message, self._apply_emoji_theme(self.strings("err_reserved_wf")))
wf_name = self._canonical_workflow_name(raw_name)
if wf_name in self._get_all_workflow_names():
if self._is_cloud_workflow_name(wf_name):
self.config["comfyui_backend"] = _COMFY_BACKEND_CLOUD
self._comfy_cache.clear()
limited_mode = False
self.set("default_workflow", wf_name)
self._set_workflow_limited_mode(limited_mode)
await self._autoswitch_model_to_workflow(wf_name)
self._update_default_arg_values()
return await utils.answer(
message,
self.strings("wf_limited_set" if limited_mode else "setwf_ok").format(utils.escape_html(wf_name)),
)
state_id = str(uuid.uuid4())
self._wf_page_cache[state_id] = {
"workflows": [],
"wf_type": "builtin",
"page": 0,
}
await self._render_wf_main(message, state_id)
@loader.command(
ru_doc=" [имя] - Выгрузить воркфлоу в JSON",
)
async def mlwf(self, message: Message):
""" [name] - Export workflow as JSON"""
args = utils.get_args_raw(message)
if not args:
return await utils.answer(message, self._apply_emoji_theme(self.strings("mlwf_no_name")))
raw_name = args.strip()
if raw_name.lower() == "i2i":
return await utils.answer(message, self._apply_emoji_theme(self.strings("err_reserved_wf")))
wf_name = self._canonical_workflow_name(raw_name)
wf_name, file_obj, description = await self._build_workflow_file(wf_name)
if not file_obj:
return await utils.answer(
message,
self.strings("mlwf_not_found").format(utils.escape_html(wf_name)),
)
caption = self.strings("mlwf_success").format(utils.escape_html(wf_name))
if description:
caption += f"\n\n{utils.escape_html(description)}
"
try:
await self.client.send_file(
utils.get_chat_id(message),
file_obj,
caption=caption,
reply_to=message.id,
)
finally:
file_obj.close()
try:
await message.delete()
except Exception:
pass
def _save_custom_workflow(self, name, workflow_json, description):
workflow = self._normalize_workflow_format(workflow_json)
mapping = self._parse_workflow(workflow)
if not mapping.get("positive"):
logger.warning(self._plain_text(self.strings("no_mapping_pos")))
custom = self.get("workflows", {})
custom[name] = {
"workflow": workflow,
"mapping": {k: v for k, v in mapping.items() if v},
"description": description,
}
self.set("workflows", custom)
return mapping
async def _render_addwf_failed(self, target, name, validation, state_id):
markup = [
[{"text": self.strings("add_wf_force_btn"), "callback": self._addwf_force_add, "args": (state_id,), "style": "danger"}],
[{"text": self.strings("btn_cancel"), "callback": self._safe_close_form, "style": "danger"}],
]
full_text = self._to_inline_emoji(self._format_workflow_validation(name, validation))
form_text = full_text
if len(form_text) >= 3900:
form_text = self._to_inline_emoji(
self._format_workflow_validation_compact(name, validation)
)
if len(form_text) >= 3900:
form_text = self._to_inline_emoji(
self._format_workflow_validation_compact(
name,
validation,
max_items=5,
max_chars=120,
)
)
rendered = await self._render_inline(target, form_text, markup)
if rendered:
return rendered
await self._smart_answer(target, full_text)
if form_text == full_text:
form_text = self._to_inline_emoji(
self._format_workflow_validation_compact(
name,
validation,
max_items=5,
max_chars=120,
)
)
return await self._render_inline(target, form_text, markup)
async def _addwf_force_add(self, call: InlineCall, state_id: str):
state = self._addwf_force_states.get(state_id)
if not state:
try:
await call.answer(self._plain_text(self.strings("add_wf_force_expired")), show_alert=True)
except Exception:
pass
return
name = state["name"]
custom = self.get("workflows", {})
if name in custom:
try:
await call.answer(self._plain_text(self.strings("add_wf_exists").format(name)), show_alert=True)
except Exception:
pass
return
self._save_custom_workflow(name, state["workflow"], state.get("description", ""))
self._addwf_force_states.pop(state_id, None)
text = "\n\n".join([
self._format_workflow_validation(name, state["validation"], saved=True),
self.strings("add_wf_forced_note"),
])
text = self._to_inline_emoji(text)
try:
await call.answer(self._plain_text(self.strings("add_wf_ok").format(name)))
except Exception:
pass
await self._edit_inline_status(call, text, reply_markup=None)
@loader.command(
ru_doc=" [имя] [описание] [ссылка Comfy Cloud или реплай на JSON] - Добавить воркфлоу",
aliases=["addworkflow"],
)
async def addwf(self, message: Message):
""" [name] [description] [Comfy Cloud link or reply to JSON] - Add workflow"""
args = utils.get_args_raw(message)
if not args:
return await utils.answer(message, self._apply_emoji_theme(self.strings("add_wf_no_name")))
parts = args.strip().split(maxsplit=1)
name = parts[0].strip().lower()
description = parts[1].strip() if len(parts) > 1 else ""
if name == "i2i":
return await utils.answer(message, self._apply_emoji_theme(self.strings("err_reserved_wf")))
canonical_name = self._canonical_workflow_name(name)
if self._is_builtin_workflow(canonical_name):
return await utils.answer(
message, self.strings("add_wf_exists").format(utils.escape_html(canonical_name))
)
custom = self.get("workflows", {})
if name in custom:
return await utils.answer(
message, self.strings("add_wf_exists").format(utils.escape_html(name))
)
share_id, share_url = self._extract_cloud_workflow_share(description)
if "cloud.comfy.org" in description.lower() and not share_id:
return await utils.answer(
message,
self._apply_emoji_theme(self.strings("add_wf_cloud_share_bad")),
)
status = None
if share_id:
description = description.replace(share_url, "", 1).strip()
status = await utils.answer(
message,
self.strings("add_wf_cloud_share_loading"),
)
try:
workflow_json = await self._load_workflow_json_from_cloud_share(share_id)
except UserFacingError as e:
return await utils.answer(
status,
self._apply_emoji_theme(str(e)),
)
except Exception as e:
logger.debug("Cloud share workflow load failed: %s", e)
return await utils.answer(
status,
self._apply_emoji_theme(self.strings("add_wf_cloud_share_bad")),
)
else:
workflow_json, load_error = await self._load_workflow_json_from_reply(message)
if load_error == "no_reply":
return await utils.answer(message, self._apply_emoji_theme(self.strings("add_wf_no_reply")))
if load_error == "bad_json":
return await utils.answer(message, self._apply_emoji_theme(self.strings("add_wf_bad_json")))
if load_error == "too_large":
return await utils.answer(message, self._apply_emoji_theme(self.strings("wf_file_too_large")))
status = status or await utils.answer(
message,
self.strings("checkwf_checking").format(utils.escape_html(name)),
)
try:
if not isinstance(workflow_json, dict) or not workflow_json:
raise ValueError("Empty or invalid workflow")
validation = await self._validate_workflow(workflow_json)
if not validation.get("ok"):
state_id = str(uuid.uuid4())
self._addwf_force_states[state_id] = {
"name": name,
"description": description,
"workflow": workflow_json,
"validation": validation,
}
return await self._render_addwf_failed(status, name, validation, state_id)
self._save_custom_workflow(name, workflow_json, description)
await self._smart_answer(
status,
self._format_workflow_validation(name, validation, saved=True),
)
except Exception as e:
logger.error("Failed to add workflow: %s: %s", type(e).__name__, e)
logger.exception(e)
await utils.answer(status, self._apply_emoji_theme(self.strings("err_workflow_invalid")))
@loader.command(
ru_doc=" - Проверить JSON воркфлоу без сохранения",
)
async def checkwf(self, message: Message):
""" - Check workflow JSON without saving"""
workflow_name = await self._get_workflow_reply_name(message)
workflow_json, load_error = await self._load_workflow_json_from_reply(message)
if load_error == "no_reply":
return await utils.answer(message, self._apply_emoji_theme(self.strings("checkwf_no_reply")))
if load_error == "bad_json":
return await utils.answer(message, self._apply_emoji_theme(self.strings("checkwf_bad_json")))
if load_error == "too_large":
return await utils.answer(message, self._apply_emoji_theme(self.strings("wf_file_too_large")))
status = await utils.answer(
message,
self.strings("checkwf_checking").format(utils.escape_html(workflow_name)),
)
try:
validation = await self._validate_workflow(workflow_json)
await self._smart_answer(
status,
self._format_workflow_validation(workflow_name, validation),
)
except Exception as e:
logger.exception(e)
await utils.answer(status, self._apply_emoji_theme(self.strings("err_workflow_invalid")))
@loader.command(
ru_doc=" [имя|-all] - Удалить пользовательский воркфлоу",
aliases=["delworkflow"],
)
async def delwf(self, message: Message):
""" [name|-all] - Delete custom workflow"""
args = utils.get_args_raw(message)
if not args:
custom = self.get("workflows", {})
if not custom:
return await utils.answer(message, self._apply_emoji_theme(self.strings("del_wf_no_custom")))
names = sorted(custom.keys())
wf_list = "\n".join(f"{utils.escape_html(name)}" for name in names)
return await self._smart_answer(
message,
self.strings("del_wf_no_name_with_list").format(
wf_list,
utils.escape_html(names[0]),
),
)
name = args.strip().lower()
custom = self.get("workflows", {})
if not isinstance(custom, dict):
custom = {}
if name == "-all":
if not custom:
return await utils.answer(message, self._apply_emoji_theme(self.strings("del_wf_no_custom")))
count = len(custom)
self.set("workflows", {})
return await utils.answer(message, self._apply_emoji_theme(self.strings("del_wf_all_ok").format(count)))
if name == "i2i":
return await utils.answer(message, self._apply_emoji_theme(self.strings("err_reserved_wf")))
canonical_name = self._canonical_workflow_name(name)
if self._is_builtin_workflow(canonical_name):
return await utils.answer(
message, self.strings("del_wf_builtin").format(utils.escape_html(canonical_name))
)
if name not in custom:
return await utils.answer(
message, self.strings("del_wf_fail").format(utils.escape_html(name))
)
del custom[name]
self.set("workflows", custom)
await utils.answer(message, self._apply_emoji_theme(self.strings("del_wf_ok").format(utils.escape_html(name))))
@loader.command(
ru_doc=" - Настроить дефолтные аргументы генерации",
aliases=["csetarg"],
)
async def setarg(self, message: Message):
""" - Configure default generation arguments"""
await self._ensure_workflow_data(
self.get("default_workflow", _DEFAULT_WORKFLOW_NAME)
)
self._sync_argset_for_current_model()
await self._argset_render_main(message)