@@ -3221,6 +3221,143 @@ def execute(cls, unet_name: str, weight_dtype: str, disable_dynamic_vram: bool)
32213221 return io .NodeOutput (model )
32223222
32233223
3224+ class TextGenerateQwen35SystemPrompt (io .ComfyNode ):
3225+ """
3226+ TextGenerate variant for Qwen3.5 models with custom system message support.
3227+ Builds the chat template via string concatenation (never .format()) so any
3228+ characters in user input — including { } \\ and control sequences — are safe.
3229+ """
3230+
3231+ @classmethod
3232+ def define_schema (cls ):
3233+ sampling_options = [
3234+ io .DynamicCombo .Option (
3235+ key = "on" ,
3236+ inputs = [
3237+ io .Float .Input ("temperature" , default = 0.7 , min = 0.01 , max = 2.0 , step = 0.000001 ),
3238+ io .Int .Input ("top_k" , default = 64 , min = 0 , max = 1000 ),
3239+ io .Float .Input ("top_p" , default = 0.95 , min = 0.0 , max = 1.0 , step = 0.01 ),
3240+ io .Float .Input ("min_p" , default = 0.05 , min = 0.0 , max = 1.0 , step = 0.01 ),
3241+ io .Float .Input ("repetition_penalty" , default = 1.05 , min = 0.0 , max = 5.0 , step = 0.01 ),
3242+ io .Int .Input ("seed" , default = 0 , min = 0 , max = 0xffffffffffffffff ),
3243+ io .Float .Input ("presence_penalty" , optional = True , default = 0.0 , min = 0.0 , max = 5.0 , step = 0.01 ),
3244+ ]
3245+ ),
3246+ io .DynamicCombo .Option (
3247+ key = "off" ,
3248+ inputs = []
3249+ ),
3250+ ]
3251+
3252+ return io .Schema (
3253+ node_id = "TextGenerateQwen35SystemPrompt" ,
3254+ display_name = "Text Generate Qwen3.5 (System Prompt)" ,
3255+ category = "advanced/textgen" ,
3256+ search_aliases = ["LLM" , "VLM" , "qwen" , "qwen35" , "system prompt" , "textgen" ],
3257+ inputs = [
3258+ io .Clip .Input ("clip" ),
3259+ io .String .Input ("prompt" , multiline = True , dynamic_prompts = False , default = "" ,
3260+ tooltip = "User message. All characters including { } are safe — template uses concatenation not .format()." ),
3261+ io .String .Input ("system_message" , multiline = True , dynamic_prompts = False , default = "" ,
3262+ tooltip = "System message injected before the user turn. Leave empty to skip the system block entirely." ),
3263+ io .Image .Input ("image" , optional = True ),
3264+ io .Int .Input ("max_length" , default = 512 , min = 1 , max = 8192 ),
3265+ io .DynamicCombo .Input ("sampling_mode" , options = sampling_options , display_name = "Sampling Mode" ),
3266+ io .Boolean .Input ("thinking" , optional = True , default = False ,
3267+ tooltip = "Enable thinking mode. When False, suppresses thinking with <think>\\ n</think>\\ n." ),
3268+ ],
3269+ outputs = [
3270+ io .String .Output (display_name = "generated_text" ),
3271+ ],
3272+ )
3273+
3274+ @classmethod
3275+ def _build_prompt (cls , prompt : str , system_message : str , has_image : bool , thinking : bool ) -> str :
3276+ """
3277+ Build the full Qwen3.5 chat-template string using concatenation only.
3278+ No .format() / %-formatting — user-supplied strings are never interpolated,
3279+ so { } and any other characters are completely safe.
3280+
3281+ Template structure (matches qwen35.py Qwen35ImageTokenizer):
3282+ [<|im_start|>system\\ n{system_message}<|im_end|>\\ n] <- optional
3283+ <|im_start|>user\\ n
3284+ [<|vision_start|><|image_pad|><|vision_end|>] <- if image
3285+ {prompt}<|im_end|>\\ n
3286+ <|im_start|>assistant\\ n
3287+ [<think>\\ n</think>\\ n] <- if not thinking
3288+ """
3289+ result = ""
3290+
3291+ # Optional system block
3292+ if system_message and system_message .strip ():
3293+ result = (
3294+ "<|im_start|>system\n "
3295+ + system_message
3296+ + "<|im_end|>\n "
3297+ )
3298+
3299+ # User block — image token placed before text per qwen35.py:761
3300+ result += "<|im_start|>user\n "
3301+ if has_image :
3302+ result += "<|vision_start|><|image_pad|><|vision_end|>"
3303+ result += prompt + "<|im_end|>\n "
3304+
3305+ # Assistant block
3306+ result += "<|im_start|>assistant\n "
3307+
3308+ # Suppress thinking unless thinking mode requested (matches qwen35.py:784-785)
3309+ if not thinking :
3310+ result += "<think>\n </think>\n "
3311+
3312+ return result
3313+
3314+ @classmethod
3315+ def execute (cls , clip , prompt , system_message , max_length , sampling_mode ,
3316+ image = None , thinking = False ) -> io .NodeOutput :
3317+
3318+ formatted_prompt = cls ._build_prompt (
3319+ prompt = prompt ,
3320+ system_message = system_message ,
3321+ has_image = image is not None ,
3322+ thinking = thinking ,
3323+ )
3324+
3325+ # skip_template=True because we built the full template ourselves.
3326+ # The tokenizer detects <|im_start|> prefix and skips its own template (qwen35.py:769).
3327+ tokens = clip .tokenize (
3328+ formatted_prompt ,
3329+ image = image ,
3330+ skip_template = True ,
3331+ min_length = 1 ,
3332+ thinking = thinking ,
3333+ )
3334+
3335+ do_sample = sampling_mode .get ("sampling_mode" ) == "on"
3336+ temperature = sampling_mode .get ("temperature" , 1.0 )
3337+ top_k = sampling_mode .get ("top_k" , 50 )
3338+ top_p = sampling_mode .get ("top_p" , 1.0 )
3339+ min_p = sampling_mode .get ("min_p" , 0.0 )
3340+ seed = sampling_mode .get ("seed" , None )
3341+ repetition_penalty = sampling_mode .get ("repetition_penalty" , 1.0 )
3342+ presence_penalty = sampling_mode .get ("presence_penalty" , 0.0 )
3343+
3344+ generated_ids = clip .generate (
3345+ tokens ,
3346+ do_sample = do_sample ,
3347+ max_length = max_length ,
3348+ temperature = temperature ,
3349+ top_k = top_k ,
3350+ top_p = top_p ,
3351+ min_p = min_p ,
3352+ repetition_penalty = repetition_penalty ,
3353+ presence_penalty = presence_penalty ,
3354+ seed = seed ,
3355+ )
3356+
3357+ generated_text = clip .decode (generated_ids , skip_special_tokens = True )
3358+ return io .NodeOutput (generated_text )
3359+
3360+
32243361class SamplingUtils (ComfyExtension ):
32253362 @override
32263363 async def get_node_list (self ) -> list [type [io .ComfyNode ]]:
@@ -3268,6 +3405,7 @@ async def get_node_list(self) -> list[type[io.ComfyNode]]:
32683405 StaticInt ,
32693406 RandIntRange ,
32703407 UNETLoaderAsync ,
3408+ TextGenerateQwen35SystemPrompt ,
32713409 ]
32723410
32733411
0 commit comments