__version__ = (1, 1, 0) # 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, SendMediaRequest, SendMessageRequest 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 try: from herokutl.tl.types import ( DocumentAttributeFilename, InputMediaUploadedDocument, InputMediaUploadedPhoto, InputReplyToMessage, InputReplyToMonoForum, ) except ImportError: DocumentAttributeFilename = None InputMediaUploadedDocument = None InputMediaUploadedPhoto = None InputReplyToMessage = None InputReplyToMonoForum = None try: from herokutl.tl.types import InputPeerSelf except ImportError: InputPeerSelf = None 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" _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.*?)', 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, } _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,", } _FORBIDDEN_UPLOAD_CONSTRUCTOR_BYTES = tuple( value.to_bytes(4, "little") for value in ( 0xA2C0CF74, 0x449E0B51, 0x9308CE1B, 0x0D36BF79, 0xA59B102F, 0x9A5C33E5, 0x9FAB0D1A, 0xA929597A, 0xE320C158, 0xF8654027, ) ) _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.", "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_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_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.", "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_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_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.", "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", } 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._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._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, "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)), "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) 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") ) 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_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": 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 _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+)>.*?\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): safe_file = await self._prepare_safe_upload_file(file_obj) upload_file = safe_file or file_obj try: if hasattr(upload_file, "seek"): upload_file.seek(0) return await self.client.send_file(peer, upload_file, **kwargs) finally: if safe_file: safe_file.close() async def _cshare_send_file(self, target, file_obj, **kwargs): reply_to = self._cshare_reply_to(target) if reply_to is not None: try: return await self._cshare_send_file_raw(target, file_obj, reply_to, **kwargs) except Exception as e: if self._is_monoforum_reply_error(e) and await self._cshare_try_init_direct(target): if hasattr(file_obj, "seek"): file_obj.seek(0) try: return await self._cshare_send_file_raw(target, file_obj, reply_to, **kwargs) except Exception as retry_error: if not self._can_retry_cshare_fallback(retry_error): raise 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 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): reply_to = self._cshare_reply_to(target) if reply_to is not None: try: return await self._cshare_send_message_raw(target, text, reply_to, **kwargs) except Exception as e: if self._is_monoforum_reply_error(e) and await self._cshare_try_init_direct(target): try: return await self._cshare_send_message_raw(target, text, reply_to, **kwargs) except Exception as retry_error: if not self._can_retry_cshare_fallback(retry_error): raise 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 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 _response_message_from_updates(self, result, peer): candidates = [result, *(getattr(result, "updates", None) or [])] for candidate in candidates: message = getattr(candidate, "message", None) if message is not None: return message message_id = getattr(candidate, "id", None) if message_id is None: continue try: message = await self.client.get_messages(peer, ids=message_id) if message: return message except Exception as e: logger.debug("Could not resolve sent message %s: %s", message_id, e) return None async def _cshare_send_file_raw(self, target, file_obj, reply_to, **kwargs): if not (InputMediaUploadedDocument and DocumentAttributeFilename): raise UserFacingError("cshare_direct_unavailable", self._plain_text(self.strings("cshare_direct_unavailable"))) safe_file = await self._prepare_safe_upload_file(file_obj) upload_file = safe_file or file_obj try: if hasattr(upload_file, "seek"): upload_file.seek(0) filename = getattr(upload_file, "name", None) or "file.bin" uploaded = await self.client.upload_file(upload_file, file_name=filename) finally: if safe_file: safe_file.close() caption = kwargs.get("caption") or "" try: text, entities = self.client.parse_mode.parse(caption) except Exception: text, entities = caption, [] media = InputMediaUploadedDocument( file=uploaded, mime_type=mimetypes.guess_type(filename)[0] or "application/octet-stream", attributes=[DocumentAttributeFilename(filename)], ) peer = await self.client.get_input_entity(self._cshare_peer(target)) request = SendMediaRequest( peer=peer, media=media, message=text, random_id=random.getrandbits(63), reply_to=reply_to, entities=entities or [], ) result = await self.client(request) return await self._response_message_from_updates(result, peer) async def _cshare_send_message_raw(self, target, text, reply_to, **kwargs): try: parsed_text, entities = self.client.parse_mode.parse(text) except Exception: parsed_text, entities = text, [] peer = await self.client.get_input_entity(self._cshare_peer(target)) request = SendMessageRequest( peer=peer, message=parsed_text, random_id=random.getrandbits(63), no_webpage=not kwargs.get("link_preview", False), reply_to=reply_to, entities=entities or [], ) result = await self.client(request) return await self._response_message_from_updates(result, peer) 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"))) if InputReplyToMonoForum: try: try: self_peer = await self.client.get_input_entity(self.tg_id) except Exception: self_peer = await self.client.get_input_entity("me") return { "peer": monoforum, "reply_to": InputReplyToMonoForum(monoforum_peer_id=self_peer), "fallback": monoforum, } except Exception as e: logger.debug("Failed to resolve self input peer for ComfyIdeas monoforum: %s", e) 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) @staticmethod def _upload_contains_forbidden_constructor(file_obj): try: position = file_obj.tell() file_obj.seek(0) except Exception: return False tail = b"" offset_base = 0 try: while True: chunk = file_obj.read(1024 * 1024) if not chunk: return False payload = tail + chunk payload_offset = offset_base - len(tail) for pattern in _FORBIDDEN_UPLOAD_CONSTRUCTOR_BYTES: offset = payload.find(pattern) while offset >= 0: if (payload_offset + offset) % 4 == 0: return True offset = payload.find(pattern, offset + 1) tail = payload[-3:] offset_base += len(chunk) finally: try: file_obj.seek(position) except Exception: pass def _prepare_upload_retry_image(self, image_source, quality): img_buf, restore_target = self._image_source_stream(image_source) source = None converted = None out = tempfile.NamedTemporaryFile( mode="w+b", prefix="comfyimagegen_result_", suffix=".jpg", ) try: source = Image.open(img_buf) self._ensure_safe_photo_pixels(source) source.load() if source.mode == "RGBA": canvas = Image.new("RGBA", source.size, (255, 255, 255, 255)) try: canvas.alpha_composite(source) converted = canvas.convert("RGB") finally: canvas.close() else: converted = source.convert("RGB") converted.save(out, format="JPEG", quality=quality) out.seek(0) return out except Exception: out.close() raise finally: if converted: converted.close() if source: source.close() self._restore_image_source(img_buf, restore_target) async def _prepare_safe_upload_retry_image(self, image_source): last_output = None for quality in (94, 90, 86, 82, 78): output = await utils.run_sync( self._prepare_upload_retry_image, image_source, quality, ) if not self._upload_contains_forbidden_constructor(output): if last_output: last_output.close() return output if last_output: last_output.close() last_output = output return last_output async def _prepare_safe_upload_file(self, file_obj): if not self._upload_contains_forbidden_constructor(file_obj): return None try: return await self._prepare_safe_upload_retry_image(file_obj) except Exception as e: logger.debug("Safe upload conversion is unavailable for this file: %s", e) return None async def _prepare_image_upload_file(self, image_source, as_document=False, force_safe_retry=False): async with self._image_processing_semaphore: if force_safe_retry: return await self._prepare_safe_upload_retry_image(image_source), True output = await utils.run_sync(self._prepare_output_image, image_source, as_document) if not self._upload_contains_forbidden_constructor(output): return output, False output.close() return await self._prepare_safe_upload_retry_image(image_source), True def _is_forbidden_upload_error(self, error): return "forbidden raw tl constructor" in self._exception_chain_text(error).lower() 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, ) async def _send_spoiler_photo_result(self, chat_id, image_source, caption, reply_to=None, send_as_self=False): if not InputMediaUploadedPhoto: raise RuntimeError("InputMediaUploadedPhoto is unavailable") caption = self._apply_emoji_theme(caption) try: out, _ = await self._prepare_image_upload_file(image_source, False) 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: out.seek(0) uploaded = await self.client.upload_file(out, file_name=getattr(out, "name", "comfyui_result.jpg")) try: text, entities = self.client.parse_mode.parse(caption or "") except Exception: text, entities = caption or "", [] request_reply_to = reply_to if ( reply_to is not None and InputReplyToMessage and isinstance(reply_to, int) ): request_reply_to = InputReplyToMessage(reply_to_msg_id=reply_to) peer = await self.client.get_input_entity(chat_id) request_kwargs = { "peer": peer, "media": InputMediaUploadedPhoto(file=uploaded, spoiler=True), "message": text, "random_id": random.getrandbits(63), "reply_to": request_reply_to, "entities": entities or [], } if send_as_self and InputPeerSelf: request_kwargs["send_as"] = InputPeerSelf() try: request = SendMediaRequest(**request_kwargs) except TypeError: request_kwargs.pop("send_as", None) request = SendMediaRequest(**request_kwargs) result = await self.client(request) return await self._response_message_from_updates(result, peer) except Exception as e: logger.error("Failed to send spoiler photo: %s: %s", type(e).__name__, e) logger.exception(e) raise ValueError("Telegram send failed") from e finally: out.close() del out @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" if spoiler and not as_document: return await self._send_spoiler_photo_result(chat_id, image_source, caption, reply_to=reply_to, send_as_self=send_as_self) try: out, used_safe_upload_retry = 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, } try: sent_message = await self._send_prepared_result_file( chat_id, out, send_kwargs, send_as_self, ) except Exception as e: if not self._is_forbidden_upload_error(e) or used_safe_upload_retry: raise out.close() out, used_safe_upload_retry = await self._prepare_image_upload_file( image_source, as_document, force_safe_retry=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) safe_file = None try: safe_file = await self._prepare_safe_upload_file(file_obj) if safe_file: file_obj = safe_file 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 finally: if safe_file: safe_file.close() 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) safe_files = [] try: prepared_files = [] for file_obj in files: safe_file = await self._prepare_safe_upload_file(file_obj) if safe_file: safe_files.append(safe_file) prepared_files.append(safe_file) else: prepared_files.append(file_obj) files = prepared_files 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 for file_obj in safe_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, } 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'-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): if InputPeerSelf: try: return await self.client.send_message( chat_id, text, send_as=InputPeerSelf(), **kwargs, ) except Exception as e: if not self._send_as_fallback_allowed(e): raise logger.debug("send_as self is unavailable, retrying message normally: %s", e) 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): if InputPeerSelf: try: return await self.client.send_file( self._send_peer_candidate(chat_id), file_obj, send_as=InputPeerSelf(), **kwargs, ) except Exception as e: if not self._send_as_fallback_allowed(e): raise logger.debug("send_as self is unavailable, retrying file normally: %s", e) self._rewind_send_file_obj(file_obj) 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 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 _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 ""))) 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 ( 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", aliases=["img"], ) async def comfy(self, message: Message): """ [prompt] - Generate image. -r, -neg, -w, -h, -steps, -cfg, -seed, -denoise, -lora, -ai, -noai, -i""" raw_args = utils.get_args_raw(message) 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: 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: preloaded_reply = await message.get_reply_message() preloaded_reply_kind = await self._reply_media_kind_for_message( message, preloaded_reply, context="generation", ) 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 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"] reply = preloaded_reply or await message.get_reply_message() reply_kind = preloaded_reply_kind or await self._reply_media_kind_for_message( message, reply, context="generation", ) 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)