Skip to content

Commit 30a3ba9

Browse files
committed
Add TextGenerate node with system message input.
1 parent 4664978 commit 30a3ba9

1 file changed

Lines changed: 138 additions & 0 deletions

File tree

__init__.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
32243361
class 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

Comments
 (0)