diff --git a/__init__.py b/__init__.py index e7b0c81..4a84084 100644 --- a/__init__.py +++ b/__init__.py @@ -182,6 +182,7 @@ from .nodes.pdf.FL_TextToPDF import FL_TextToPDF # PROMPTING NODES from .nodes.prompting.FL_MadLibGenerator import FL_MadLibGenerator +from .nodes.prompting.FL_GenerateText import FL_GenerateText from .nodes.prompting.FL_Prompt import FL_PromptBasic from .nodes.prompting.FL_PromptMulti import FL_PromptMulti from .nodes.prompting.FL_PromptSelector import FL_PromptSelector @@ -287,6 +288,7 @@ NODE_CLASS_MAPPINGS = { "FL_DepthBlur": FL_DepthBlur, "FL_RandomNumber": FL_RandomNumber, "FL_PromptSelector": FL_PromptSelector, + "FL_GenerateText": FL_GenerateText, "FL_Shadertoy": FL_Shadertoy, "FL_PixelArtShader": FL_PixelArtShader, "FL_InfiniteZoom": FL_InfiniteZoom, @@ -494,6 +496,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "FL_DepthBlur": "FL Depth Blur", "FL_RandomNumber": "FL Random Number", "FL_PromptSelector": "FL Prompt Selector", + "FL_GenerateText": "FL Generate Text", "FL_PromptSelectorBasic": "FL Prompt Selector Basic", "FL_Shadertoy": "FL Shadertoy", "FL_PixelArtShader": "FL Pixel Art", diff --git a/nodes/ksamplers/FL_KsamplerPlus.py b/nodes/ksamplers/FL_KsamplerPlus.py index 2602bba..2b9fa1e 100644 --- a/nodes/ksamplers/FL_KsamplerPlus.py +++ b/nodes/ksamplers/FL_KsamplerPlus.py @@ -207,6 +207,9 @@ class FL_KsamplerPlus: overlap_width = int(base_slice_width * overlap) samples = None # We'll initialize this later when we know the correct number of channels + # Nested secondary streams are sampled with the first spatial tile, then held fixed. + sampled_latent_samples = latent_samples + secondary_sampled = False def process_slice(y, x): y_start = max(0, y * base_slice_height - overlap_height) @@ -273,16 +276,22 @@ class FL_KsamplerPlus: batch_negative, y_start, x_start, tile_h, tile_w, vae_scale_factors) # Build proper latent dict preserving noise_mask - tile_samples = replace_primary_tensor(latent_samples.to(device=device), batch_latents) + tile_samples = replace_primary_tensor(sampled_latent_samples.to(device=device), batch_latents) tile_latent = {"samples": tile_samples} sliced_noise_mask = batch_sections[0][7] - if sliced_noise_mask is not None or latent_samples.is_nested: + if latent_samples.is_nested and secondary_sampled: tile_latent["noise_mask"] = primary_only_noise_mask(tile_samples, sliced_noise_mask) + elif sliced_noise_mask is not None: + tile_latent["noise_mask"] = replace_primary_tensor(noise_mask.to(device=device), sliced_noise_mask) processed_batch = common_ksampler(model, seed + i, steps, cfg, sampler_name, scheduler, batch_positive, batch_negative, tile_latent, denoise=denoise)[0] + if latent_samples.is_nested and not secondary_sampled: + sampled_latent_samples = processed_batch["samples"] + secondary_sampled = True + processed_samples = primary_tensor(processed_batch["samples"]) processed_sections = torch.split(processed_samples, b, dim=0) @@ -327,7 +336,7 @@ class FL_KsamplerPlus: if latent_samples.is_nested: samples = samples.to(device=primary_samples.device, dtype=primary_samples.dtype) - output_samples = replace_primary_tensor(latent_samples, samples) + output_samples = replace_primary_tensor(sampled_latent_samples, samples) output_image = None if vae is not None: output_image = safe_vae_decode(vae, {"samples": output_samples}, node_name="FL_KsamplerPlus") diff --git a/nodes/ksamplers/FL_KsamplerPlusV2.py b/nodes/ksamplers/FL_KsamplerPlusV2.py index 2d89e9b..bedc04c 100644 --- a/nodes/ksamplers/FL_KsamplerPlusV2.py +++ b/nodes/ksamplers/FL_KsamplerPlusV2.py @@ -219,6 +219,9 @@ class FL_KsamplerPlusV2: overlap_width = int(base_slice_width * overlap) samples = None # We'll initialize this later when we know the correct number of channels + # Nested secondary streams are sampled with the first spatial tile, then held fixed. + sampled_latent_samples = latent_samples + secondary_sampled = False # We're splitting all conditioning between slices @@ -281,16 +284,22 @@ class FL_KsamplerPlusV2: batch_positive = positive * len(batch_sections) batch_negative = negative * len(batch_sections) - tile_samples = replace_primary_tensor(latent_samples.to(device=device), batch_latents) + tile_samples = replace_primary_tensor(sampled_latent_samples.to(device=device), batch_latents) tile_latent = {"samples": tile_samples} sliced_noise_mask = batch_sections[0][7] - if sliced_noise_mask is not None or latent_samples.is_nested: + if latent_samples.is_nested and secondary_sampled: tile_latent["noise_mask"] = primary_only_noise_mask(tile_samples, sliced_noise_mask) + elif sliced_noise_mask is not None: + tile_latent["noise_mask"] = replace_primary_tensor(noise_mask.to(device=device), sliced_noise_mask) processed_batch = common_ksampler(model, seed + i, steps, cfg, sampler_name, scheduler, batch_positive, batch_negative, tile_latent, denoise=denoise)[0] + if latent_samples.is_nested and not secondary_sampled: + sampled_latent_samples = processed_batch["samples"] + secondary_sampled = True + processed_samples = primary_tensor(processed_batch["samples"]) processed_sections = torch.split(processed_samples, b, dim=0) @@ -324,7 +333,7 @@ class FL_KsamplerPlusV2: if latent_samples.is_nested: samples = samples.to(device=primary_samples.device, dtype=primary_samples.dtype) - output_samples = replace_primary_tensor(latent_samples, samples) + output_samples = replace_primary_tensor(sampled_latent_samples, samples) output_image = None if vae is not None: output_image = safe_vae_decode(vae, {"samples": output_samples}, node_name="FL_KsamplerPlusV2") diff --git a/nodes/prompting/FL_GenerateText.py b/nodes/prompting/FL_GenerateText.py new file mode 100644 index 0000000..27efc29 --- /dev/null +++ b/nodes/prompting/FL_GenerateText.py @@ -0,0 +1,68 @@ +QWEN_CHAT_START = "<|im_start|>" +QWEN_CHAT_END = "<|im_end|>" +QWEN_NO_THINK = "\n\n\n\n" + + +def format_qwen_chat(system_prompt, prompt, thinking=False): + chat = ( + f"{QWEN_CHAT_START}system\n{system_prompt}{QWEN_CHAT_END}\n" + f"{QWEN_CHAT_START}user\n{prompt}{QWEN_CHAT_END}\n" + f"{QWEN_CHAT_START}assistant\n" + ) + if not thinking: + chat += QWEN_NO_THINK + return chat + + +class FL_GenerateText: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "clip": ("CLIP", {"tooltip": "A complete Qwen3 or Qwen3-VL text encoder with generation weights."}), + "system_prompt": ("STRING", { + "multiline": True, + "default": "You are a helpful assistant.", + }), + "prompt": ("STRING", { + "multiline": True, + "default": "", + "dynamicPrompts": True, + }), + "max_length": ("INT", {"default": 512, "min": 1, "max": 32768}), + "sampling": (["on", "off"], {"default": "on"}), + "temperature": ("FLOAT", {"default": 0.7, "min": 0.01, "max": 2.0, "step": 0.01}), + "top_k": ("INT", {"default": 64, "min": 0, "max": 1000}), + "top_p": ("FLOAT", {"default": 0.95, "min": 0.0, "max": 1.0, "step": 0.01}), + "min_p": ("FLOAT", {"default": 0.05, "min": 0.0, "max": 1.0, "step": 0.01}), + "repetition_penalty": ("FLOAT", {"default": 1.05, "min": 0.0, "max": 5.0, "step": 0.01}), + "presence_penalty": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 5.0, "step": 0.01}), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), + "thinking": ("BOOLEAN", {"default": False}), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("generated_text",) + FUNCTION = "generate" + OUTPUT_NODE = True + CATEGORY = "🏵️Fill Nodes/Prompting" + DESCRIPTION = "Generate text locally with a complete Qwen3 or Qwen3-VL text encoder. H3's truncated conditioning encoder cannot generate text." + + def generate(self, clip, system_prompt, prompt, max_length, sampling, temperature, top_k, top_p, min_p, repetition_penalty, presence_penalty, seed, thinking): + chat = format_qwen_chat(system_prompt, prompt, thinking=thinking) + tokens = clip.tokenize(chat, skip_template=True, min_length=1) + generated_ids = clip.generate( + tokens, + do_sample=sampling == "on", + max_length=max_length, + temperature=temperature, + top_k=top_k, + top_p=top_p, + min_p=min_p, + repetition_penalty=repetition_penalty, + presence_penalty=presence_penalty, + seed=seed, + ) + generated_text = clip.decode(generated_ids) + return {"ui": {"generated_text": [generated_text]}, "result": (generated_text,)} diff --git a/pyproject.toml b/pyproject.toml index 20a26bf..7ef188d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui_fill-nodes" description = "Fill-Nodes is a versatile collection of custom nodes for ComfyUI that extends functionality across multiple domains. Features include advanced image processing (pixelation, slicing, masking), visual effects generation (glitch, halftone, pixel art), comprehensive file handling (PDF creation/extraction, Google Drive integration), AI model interfaces (GPT, DALL-E, Hugging Face), utility nodes for workflow enhancement, and specialized tools for video processing, captioning, and batch operations. The pack provides both practical workflow solutions and creative tools within a unified node collection." -version = "2.28.6" +version = "2.28.7" license = {file = "LICENSE"} dependencies = [ "librosa", diff --git a/tests/test_generate_text.py b/tests/test_generate_text.py new file mode 100644 index 0000000..34fdcca --- /dev/null +++ b/tests/test_generate_text.py @@ -0,0 +1,144 @@ +import importlib.util +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).parents[1] +MODULE_PATH = ROOT / "nodes" / "prompting" / "FL_GenerateText.py" +SPEC = importlib.util.spec_from_file_location("fl_generate_text", MODULE_PATH) +generate_text = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(generate_text) + + +class FakeClip: + def __init__(self): + self.tokenize_call = None + self.generate_call = None + + def tokenize(self, text, **kwargs): + self.tokenize_call = (text, kwargs) + return {"qwen3vl_8b": [[(1, 1.0)]]} + + def generate(self, tokens, **kwargs): + self.generate_call = (tokens, kwargs) + return [101, 102] + + def decode(self, token_ids): + self.decoded_ids = token_ids + return "generated answer" + + +class GenerateTextTests(unittest.TestCase): + def test_formats_qwen_system_and_user_roles(self): + formatted = generate_text.format_qwen_chat("System {rules}", "User request") + + self.assertEqual( + formatted, + "<|im_start|>system\nSystem {rules}<|im_end|>\n" + "<|im_start|>user\nUser request<|im_end|>\n" + "<|im_start|>assistant\n\n\n\n\n", + ) + + def test_thinking_mode_does_not_prime_an_empty_thought(self): + formatted = generate_text.format_qwen_chat("System", "User", thinking=True) + + self.assertTrue(formatted.endswith("<|im_start|>assistant\n")) + self.assertNotIn("", formatted) + + def test_generates_with_serialized_chat_and_sampling_controls(self): + clip = FakeClip() + result = generate_text.FL_GenerateText().generate( + clip=clip, + system_prompt="Follow the system role.", + prompt="Write a prompt.", + max_length=384, + sampling="on", + temperature=0.6, + top_k=48, + top_p=0.9, + min_p=0.04, + repetition_penalty=1.1, + presence_penalty=0.2, + seed=17, + thinking=False, + ) + + tokenized_text, tokenize_kwargs = clip.tokenize_call + self.assertIn("<|im_start|>system\nFollow the system role.<|im_end|>", tokenized_text) + self.assertIn("<|im_start|>user\nWrite a prompt.<|im_end|>", tokenized_text) + self.assertEqual(tokenize_kwargs, {"skip_template": True, "min_length": 1}) + self.assertEqual(clip.generate_call[0], {"qwen3vl_8b": [[(1, 1.0)]]}) + self.assertEqual( + clip.generate_call[1], + { + "do_sample": True, + "max_length": 384, + "temperature": 0.6, + "top_k": 48, + "top_p": 0.9, + "min_p": 0.04, + "repetition_penalty": 1.1, + "presence_penalty": 0.2, + "seed": 17, + }, + ) + self.assertEqual(clip.decoded_ids, [101, 102]) + self.assertEqual(result, {"ui": {"generated_text": ["generated answer"]}, "result": ("generated answer",)}) + + def test_sampling_can_be_disabled(self): + clip = FakeClip() + generate_text.FL_GenerateText().generate( + clip, "System", "User", 32, "off", 0.7, 64, 0.95, 0.05, 1.05, 0.0, 0, False, + ) + + self.assertFalse(clip.generate_call[1]["do_sample"]) + + def test_node_contract(self): + inputs = generate_text.FL_GenerateText.INPUT_TYPES()["required"] + + self.assertEqual(inputs["clip"][0], "CLIP") + self.assertEqual(inputs["system_prompt"][0], "STRING") + self.assertEqual(inputs["prompt"][0], "STRING") + self.assertEqual(generate_text.FL_GenerateText.RETURN_TYPES, ("STRING",)) + self.assertEqual(generate_text.FL_GenerateText.RETURN_NAMES, ("generated_text",)) + self.assertTrue(generate_text.FL_GenerateText.OUTPUT_NODE) + + +class GenerateTextFrontendTests(unittest.TestCase): + def test_custom_editor_preserves_backend_widget_ownership(self): + script = (ROOT / "web" / "nodes" / "prompting" / "FL_GenerateText.js").read_text(encoding="utf-8") + + for behavior in ( + 'comfyClass !== NODE_CLASS', + 'data-field="system_prompt"', + 'data-field="prompt"', + "setWidgetValue(this.node, this.widgets[name], value)", + 'serialize: false', + 'data-action="apply"', + 'data-action="apply-generate"', + 'data-action="cancel"', + 'data-action="copy"', + 'data-action="clear"', + 'data-action="generate"', + 'container-name: flgt-node', + 'class="flgt-workspace"', + 'grid-template-columns: minmax(210px, .8fr)', + 'DEFAULT_NODE_SIZE = [1080, 520]', + 'UI_FIELDS = [...BACKEND_FIELDS, "control_after_generate"]', + 'await app.queuePrompt(0, 1)', + 'this.resizeObserver = new ResizeObserver', + "panel.showOutput(executionText(message))", + 'api.addEventListener("executing"', + 'api.addEventListener("execution_error"', + "removeInstance(this)", + ): + with self.subTest(behavior=behavior): + self.assertIn(behavior, script) + + self.assertNotIn("localStorage", script) + self.assertNotIn("fetch(", script) + self.assertNotIn('widget.type = "converted-widget"', script) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ksampler_plus_nested.py b/tests/test_ksampler_plus_nested.py new file mode 100644 index 0000000..be12adc --- /dev/null +++ b/tests/test_ksampler_plus_nested.py @@ -0,0 +1,102 @@ +import importlib.util +import pathlib +import sys +import types +import unittest +from unittest import mock + +import torch + +import comfy.nested_tensor + + +ROOT = pathlib.Path(__file__).parents[1] +PACKAGE = "fl_ksampler_plus_tests" + + +def load_sampler(module_name): + package = sys.modules.get(PACKAGE) + if package is None: + package = types.ModuleType(PACKAGE) + package.__path__ = [str(ROOT / "nodes" / "ksamplers")] + sys.modules[PACKAGE] = package + + path = ROOT / "nodes" / "ksamplers" / f"{module_name}.py" + spec = importlib.util.spec_from_file_location(f"{PACKAGE}.{module_name}", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class Model: + def get_model_object(self, name): + raise KeyError(name) + + +class KSamplerPlusNestedTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.modules = ( + load_sampler("FL_KsamplerPlus"), + load_sampler("FL_KsamplerPlusV2"), + ) + + def test_samples_audio_once_and_preserves_it_across_video_tiles(self): + for module in self.modules: + with self.subTest(module=module.__name__): + calls = [] + + def sample_tile(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent, denoise): + video, audio = latent["samples"].unbind() + calls.append(latent) + if len(calls) == 1: + self.assertNotIn("noise_mask", latent) + audio = torch.full_like(audio, 7) + else: + torch.testing.assert_close(audio, torch.full_like(audio, 7)) + _, audio_mask = latent["noise_mask"].unbind() + torch.testing.assert_close(audio_mask, torch.zeros_like(audio_mask)) + return ({"samples": comfy.nested_tensor.NestedTensor((torch.full_like(video, len(calls)), audio))},) + + video = torch.zeros((1, 24, 1, 4, 4)) + audio = torch.zeros((1, 32, 2, 8)) + latent = {"samples": comfy.nested_tensor.NestedTensor((video, audio))} + sampler = getattr(module, module.__name__.rsplit(".", 1)[-1])() + arguments = { + "model": Model(), + "positive": [], + "negative": [], + "seed": 1, + "steps": 1, + "cfg": 1.0, + "sampler_name": "euler", + "scheduler": "simple", + "denoise": 1.0, + "input_type": "latent", + "x_slices": 2, + "y_slices": 1, + "overlap": 0.0, + "batch_size": 1, + "use_sliced_conditioning": False, + "latent_image": latent, + } + if module.__name__.endswith("V2"): + arguments.update(conditioning_strength=1.0, debug_mode=False) + + with ( + mock.patch.object(module.comfy.model_management, "get_torch_device", return_value=torch.device("cpu")), + mock.patch.object(module, "common_ksampler", side_effect=sample_tile), + ): + result = sampler.sample(**arguments) + + output_video, output_audio = result[3]["samples"].unbind() + self.assertEqual(len(calls), 2) + torch.testing.assert_close(output_video[..., :2], torch.ones_like(output_video[..., :2])) + torch.testing.assert_close(output_video[..., 2:], torch.full_like(output_video[..., 2:], 2)) + torch.testing.assert_close(output_audio, torch.full_like(output_audio, 7)) + torch.testing.assert_close(audio, torch.zeros_like(audio)) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/nodes/prompting/FL_GenerateText.js b/web/nodes/prompting/FL_GenerateText.js new file mode 100644 index 0000000..8f8f4be --- /dev/null +++ b/web/nodes/prompting/FL_GenerateText.js @@ -0,0 +1,895 @@ +import { app } from "../../../../scripts/app.js"; +import { api } from "../../../../scripts/api.js"; + +const NODE_CLASS = "FL_GenerateText"; +const LAYOUT_VERSION = 2; +const DEFAULT_NODE_SIZE = [1080, 520]; +const MIN_NODE_SIZE = [760, 440]; +const INSTANCES = new Map(); + +const BACKEND_FIELDS = [ + "system_prompt", + "prompt", + "max_length", + "sampling", + "temperature", + "top_k", + "top_p", + "min_p", + "repetition_penalty", + "presence_penalty", + "seed", + "thinking", +]; +const UI_FIELDS = [...BACKEND_FIELDS, "control_after_generate"]; +const INTEGER_FIELDS = new Set(["max_length", "top_k", "seed"]); +const FLOAT_FIELDS = new Set(["temperature", "top_p", "min_p", "repetition_penalty", "presence_penalty"]); + +const STYLES = ` + .flgt-host { + container-name: flgt-node; + container-type: inline-size; + height: 100%; + min-height: 410px; + width: 100%; + } + .flgt-console { + --flgt-bg: var(--comfy-menu-bg, #111318); + --flgt-panel: var(--comfy-input-bg, #181b22); + --flgt-border: var(--border-color, #343946); + --flgt-text: var(--input-text, #edf0f7); + --flgt-muted: var(--descrip-text, #8f98aa); + background: + radial-gradient(circle at 15% 0%, rgba(124, 58, 237, .13), transparent 27%), + radial-gradient(circle at 85% 0%, rgba(37, 99, 235, .11), transparent 29%), + var(--flgt-bg); + border: 1px solid var(--flgt-border); + border-radius: 12px; + box-sizing: border-box; + color: var(--flgt-text); + display: grid; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + gap: 10px; + grid-template-rows: auto minmax(230px, 1fr) auto; + height: 100%; + min-height: 410px; + overflow: hidden; + padding: 11px; + position: relative; + width: 100%; + } + .flgt-console * { box-sizing: border-box; } + .flgt-console button, + .flgt-console input, + .flgt-console select, + .flgt-console textarea, + .flgt-modal button, + .flgt-modal input, + .flgt-modal select, + .flgt-modal textarea { font: inherit; } + .flgt-header { + align-items: center; + display: flex; + gap: 10px; + min-height: 38px; + } + .flgt-brand { + align-items: center; + background: linear-gradient(135deg, #7c3aed, #2563eb); + border-radius: 8px; + box-shadow: 0 5px 18px rgba(37, 99, 235, .24); + color: white; + display: flex; + font-size: 12px; + font-weight: 800; + height: 30px; + justify-content: center; + letter-spacing: .05em; + width: 34px; + } + .flgt-heading { min-width: 150px; } + .flgt-title { font-size: 13px; font-weight: 750; line-height: 1.1; } + .flgt-subtitle { color: var(--flgt-muted); font-size: 9px; margin-top: 3px; } + .flgt-badge { + background: rgba(59, 130, 246, .12); + border: 1px solid rgba(96, 165, 250, .34); + border-radius: 999px; + color: #bfdbfe; + font-size: 9px; + font-weight: 700; + letter-spacing: .06em; + padding: 4px 7px; + text-transform: uppercase; + } + .flgt-status { + align-items: center; + color: var(--flgt-muted); + display: flex; + font-size: 10px; + gap: 6px; + margin-left: auto; + min-width: 96px; + } + .flgt-status::before { + background: #71717a; + border-radius: 50%; + box-shadow: 0 0 0 3px rgba(113, 113, 122, .13); + content: ""; + height: 7px; + width: 7px; + } + .flgt-status.ready::before { background: #60a5fa; box-shadow: 0 0 0 3px rgba(96, 165, 250, .13); } + .flgt-status.running { color: #fde68a; } + .flgt-status.running::before { + animation: flgt-pulse 1s ease-in-out infinite; + background: #fbbf24; + box-shadow: 0 0 0 3px rgba(251, 191, 36, .14); + } + .flgt-status.complete { color: #86efac; } + .flgt-status.complete::before { background: #4ade80; box-shadow: 0 0 0 3px rgba(74, 222, 128, .14); } + .flgt-status.error { color: #fca5a5; } + .flgt-status.error::before { background: #f87171; box-shadow: 0 0 0 3px rgba(248, 113, 113, .14); } + .flgt-elapsed { color: var(--flgt-muted); font-variant-numeric: tabular-nums; min-width: 34px; } + @keyframes flgt-pulse { 50% { opacity: .45; transform: scale(.82); } } + .flgt-actions { display: flex; gap: 6px; } + .flgt-button { + align-items: center; + background: rgba(39, 39, 42, .86); + border: 1px solid #4b5160; + border-radius: 7px; + color: #f4f4f5; + cursor: pointer; + display: inline-flex; + font-size: 10px; + font-weight: 650; + gap: 5px; + justify-content: center; + min-height: 27px; + padding: 5px 9px; + } + .flgt-button:hover:not(:disabled) { background: #343945; border-color: #687083; } + .flgt-button:disabled { cursor: default; opacity: .45; } + .flgt-button.primary { + background: linear-gradient(135deg, #6d28d9, #2563eb); + border-color: #6366f1; + box-shadow: 0 4px 14px rgba(67, 56, 202, .22); + } + .flgt-button.primary:hover:not(:disabled) { background: linear-gradient(135deg, #7c3aed, #3b82f6); } + .flgt-button.small { min-height: 22px; padding: 3px 7px; } + .flgt-workspace { + display: grid; + gap: 9px; + grid-template-columns: minmax(210px, .8fr) minmax(260px, 1.12fr) minmax(290px, 1.2fr); + min-height: 0; + } + .flgt-card { + background: rgba(19, 22, 28, .9); + border: 1px solid var(--flgt-border); + border-radius: 9px; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + position: relative; + } + .flgt-card::before { content: ""; height: 2px; left: 0; position: absolute; right: 0; top: 0; } + .flgt-card.system::before { background: linear-gradient(90deg, #a855f7, #7c3aed); } + .flgt-card.user::before { background: linear-gradient(90deg, #3b82f6, #06b6d4); } + .flgt-card.output::before { background: linear-gradient(90deg, #10b981, #84cc16); } + .flgt-card-head { + align-items: center; + border-bottom: 1px solid rgba(82, 82, 91, .45); + display: flex; + gap: 7px; + min-height: 34px; + padding: 7px 9px 6px; + } + .flgt-role { + border-radius: 5px; + font-size: 9px; + font-weight: 800; + letter-spacing: .08em; + padding: 3px 5px; + text-transform: uppercase; + } + .system .flgt-role { background: rgba(168, 85, 247, .14); color: #d8b4fe; } + .user .flgt-role { background: rgba(59, 130, 246, .14); color: #bfdbfe; } + .output .flgt-role { background: rgba(16, 185, 129, .14); color: #a7f3d0; } + .flgt-count { color: var(--flgt-muted); font-size: 9px; margin-left: auto; } + .flgt-card textarea { + background: transparent; + border: 0; + color: var(--flgt-text); + flex: 1 1 auto; + font-size: 11px; + line-height: 1.5; + min-height: 0; + outline: none; + padding: 10px; + resize: none; + width: 100%; + } + .flgt-card textarea::placeholder { color: #626979; } + .flgt-card:focus-within { border-color: #6366f1; box-shadow: 0 0 0 1px rgba(99, 102, 241, .35); } + .flgt-output-body { + color: var(--flgt-text); + flex: 1 1 auto; + font-size: 11px; + line-height: 1.5; + min-height: 0; + overflow: auto; + padding: 10px; + white-space: pre-wrap; + word-break: break-word; + } + .flgt-output-body.empty { color: var(--flgt-muted); font-style: italic; } + .flgt-output-foot { + align-items: center; + border-top: 1px solid rgba(82, 82, 91, .35); + color: var(--flgt-muted); + display: flex; + font-size: 8px; + justify-content: space-between; + min-height: 24px; + padding: 4px 8px; + } + .flgt-output-actions { display: flex; gap: 4px; margin-left: 4px; } + .flgt-deck { + background: rgba(19, 22, 28, .92); + border: 1px solid var(--flgt-border); + border-radius: 9px; + padding: 7px 9px 8px; + } + .flgt-deck-head { + align-items: center; + color: var(--flgt-muted); + display: flex; + font-size: 8px; + font-weight: 700; + justify-content: space-between; + letter-spacing: .08em; + margin-bottom: 5px; + text-transform: uppercase; + } + .flgt-deck-row { display: grid; gap: 10px; grid-template-columns: minmax(390px, 1.2fr) minmax(540px, 1.8fr); } + .flgt-control-group { display: grid; gap: 6px; } + .flgt-control-group.primary { grid-template-columns: 1fr .8fr .92fr .78fr .8fr; } + .flgt-control-group.sampling { grid-template-columns: repeat(6, minmax(64px, 1fr)); transition: opacity .15s ease; } + .flgt-control-group.sampling.disabled { opacity: .38; } + .flgt-control { display: flex; flex-direction: column; gap: 2px; min-width: 0; } + .flgt-control span { color: var(--flgt-muted); font-size: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .flgt-control input, + .flgt-control select, + .flgt-modal input, + .flgt-modal select, + .flgt-modal textarea { + background: var(--comfy-input-bg, #101218); + border: 1px solid #3c4250; + border-radius: 5px; + color: var(--flgt-text, #edf0f7); + outline: none; + } + .flgt-control input, + .flgt-control select { height: 25px; min-width: 0; padding: 2px 5px; width: 100%; } + .flgt-control input:focus, + .flgt-control select:focus, + .flgt-modal input:focus, + .flgt-modal select:focus, + .flgt-modal textarea:focus { border-color: #6366f1; box-shadow: 0 0 0 1px rgba(99, 102, 241, .42); } + .flgt-control input:disabled { color: #737b8d; } + .flgt-modal-backdrop { + align-items: center; + background: rgba(3, 5, 9, .78); + display: flex; + inset: 0; + justify-content: center; + padding: 24px; + position: fixed; + z-index: 10000; + } + .flgt-modal { + --flgt-text: var(--input-text, #edf0f7); + background: var(--comfy-menu-bg, #14171d); + border: 1px solid var(--border-color, #424856); + border-radius: 12px; + box-shadow: 0 24px 80px rgba(0, 0, 0, .58); + color: var(--flgt-text); + display: flex; + flex-direction: column; + gap: 13px; + max-height: calc(100vh - 48px); + max-width: 1120px; + overflow: auto; + padding: 16px; + width: min(1120px, calc(100vw - 48px)); + } + .flgt-modal-head, .flgt-modal-actions { align-items: center; display: flex; justify-content: space-between; } + .flgt-modal h2 { font-size: 16px; margin: 0; } + .flgt-modal-subtitle { color: var(--descrip-text, #8f98aa); font-size: 10px; margin-top: 3px; } + .flgt-modal-editor { display: grid; gap: 10px; grid-template-columns: minmax(0, .82fr) minmax(0, 1.18fr); } + .flgt-modal-field { display: flex; flex-direction: column; gap: 5px; min-height: 320px; } + .flgt-modal-label { color: var(--descrip-text, #8f98aa); font-size: 9px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } + .flgt-modal-field textarea { flex: 1 1 auto; line-height: 1.5; min-height: 285px; padding: 10px; resize: vertical; width: 100%; } + .flgt-modal-controls { display: grid; gap: 10px; grid-template-columns: minmax(390px, 1.2fr) minmax(520px, 1.8fr); } + .flgt-modal-actions { border-top: 1px solid var(--border-color, #343946); gap: 7px; justify-content: flex-end; padding-top: 11px; } + @container flgt-node (max-width: 940px) { + .flgt-console { grid-template-rows: auto minmax(370px, 1fr) auto; } + .flgt-workspace { grid-template-columns: minmax(210px, .85fr) minmax(300px, 1.15fr); } + .flgt-card.output { grid-column: 1 / -1; min-height: 150px; } + .flgt-deck-row { grid-template-columns: 1fr; } + } + @container flgt-node (max-width: 680px) { + .flgt-header { flex-wrap: wrap; } + .flgt-status { margin-left: 0; } + .flgt-actions { margin-left: auto; } + .flgt-workspace { grid-template-columns: 1fr; } + .flgt-card.output { grid-column: auto; } + .flgt-control-group.primary { grid-template-columns: repeat(3, minmax(70px, 1fr)); } + .flgt-control-group.sampling { grid-template-columns: repeat(3, minmax(70px, 1fr)); } + } + @media (max-width: 760px) { + .flgt-modal-editor { grid-template-columns: 1fr; } + .flgt-modal-field { min-height: 210px; } + .flgt-modal-field textarea { min-height: 180px; } + .flgt-modal-controls { grid-template-columns: 1fr; } + } +`; + +function injectStyles() { + if (document.getElementById("flgt-styles")) return; + const style = document.createElement("style"); + style.id = "flgt-styles"; + style.textContent = STYLES; + document.head.appendChild(style); +} + +function nodeKey(id) { + return String(id ?? ""); +} + +function eventNode(detail) { + if (detail && typeof detail === "object") return detail.node ?? detail.node_id; + return detail; +} + +function findWidget(node, name) { + return node.widgets?.find((widget) => widget.name === name); +} + +function hideWidget(widget) { + if (!widget) return; + widget.computeSize = () => [0, -4]; + widget.draw = () => {}; + if (widget.element) widget.element.style.display = "none"; +} + +function setWidgetValue(node, widget, value) { + if (!widget || Object.is(widget.value, value)) return; + widget.value = value; + widget.callback?.call(widget, value); + node.graph?.change?.(); + node.setDirtyCanvas?.(true, false); +} + +function executionText(message) { + const value = message?.generated_text ?? message?.ui?.generated_text; + if (Array.isArray(value)) return value.length ? String(value[0] ?? "") : ""; + return value == null ? "" : String(value); +} + +function selectOptions(name) { + if (name === "sampling") return ''; + if (name === "thinking") return ''; + if (name === "control_after_generate") { + return ''; + } + return ""; +} + +function controlMarkup(name, label, attributes = "") { + const select = ["sampling", "thinking", "control_after_generate"].includes(name); + const field = select + ? `` + : ``; + return ``; +} + +function primaryControlsMarkup() { + return `
+ ${controlMarkup("max_length", "Max tokens", 'min="1" max="32768" step="1"')} + ${controlMarkup("sampling", "Sampling")} + ${controlMarkup("seed", "Seed", 'min="0" step="1"')} + ${controlMarkup("control_after_generate", "After run")} + ${controlMarkup("thinking", "Thinking")} +
`; +} + +function samplingControlsMarkup() { + return `
+ ${controlMarkup("temperature", "Temperature", 'min="0.01" max="2" step="0.01" data-sampling-control')} + ${controlMarkup("top_k", "Top K", 'min="0" max="1000" step="1" data-sampling-control')} + ${controlMarkup("top_p", "Top P", 'min="0" max="1" step="0.01" data-sampling-control')} + ${controlMarkup("min_p", "Min P", 'min="0" max="1" step="0.01" data-sampling-control')} + ${controlMarkup("repetition_penalty", "Repetition", 'min="0" max="5" step="0.01" data-sampling-control')} + ${controlMarkup("presence_penalty", "Presence", 'min="0" max="5" step="0.01" data-sampling-control')} +
`; +} + +function modalControlsMarkup() { + return `
+ ${primaryControlsMarkup()} + ${samplingControlsMarkup()} +
`; +} + +function readControl(element, name) { + if (name === "thinking") return element.value === "true"; + if (INTEGER_FIELDS.has(name)) return Number.parseInt(element.value, 10); + if (FLOAT_FIELDS.has(name)) return Number.parseFloat(element.value); + return element.value; +} + +async function copyText(text) { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch (_error) { + } + } + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); +} + +function applyWideLayout(node, force = false) { + node.min_size = [...MIN_NODE_SIZE]; + window.requestAnimationFrame(() => { + if (!node.graph) return; + if (force || node.size[0] < MIN_NODE_SIZE[0]) { + node.setSize([ + Math.max(DEFAULT_NODE_SIZE[0], node.size[0]), + Math.max(DEFAULT_NODE_SIZE[1], Math.min(node.size[1], 620)), + ]); + } + }); +} + +function registerInstance(panel) { + for (const [key, instance] of INSTANCES) { + if (instance === panel) INSTANCES.delete(key); + } + INSTANCES.set(nodeKey(panel.node.id), panel); +} + +function instanceForNode(id) { + const key = nodeKey(id); + const direct = INSTANCES.get(key); + if (direct) return direct; + for (const panel of INSTANCES.values()) { + if (nodeKey(panel.node.id) === key) { + registerInstance(panel); + return panel; + } + } + return null; +} + +class GenerateTextPanel { + constructor(node, host, widgets) { + this.node = node; + this.host = host; + this.widgets = widgets; + this.output = ""; + this.modal = null; + this.active = false; + this.startedAt = null; + this.timer = null; + this.cleanups = []; + this.build(); + this.bind(); + this.syncFromWidgets(); + this.updateConnection(); + this.observeSize(); + } + + build() { + this.host.className = "flgt-host"; + this.host.innerHTML = `
+
+ FL +
Generate Text
Local system + user chat workspace
+ Qwen3 chat + Full LM required + Connect CLIP + +
+ + +
+
+
+
+
System0 chars
+ +
+
+
User0 chars
+ +
+
+
+ Output + 0 chars +
+ + +
+
+
Run the workflow to generate text.
+
Ephemeral previewSTRING output remains workflow-owned
+
+
+
+
Generation controlsSampling enabled
+
${primaryControlsMarkup()}${samplingControlsMarkup()}
+
+
`; + this.root = this.host.firstElementChild; + this.statusEl = this.root.querySelector('[data-role="status"]'); + this.elapsedEl = this.root.querySelector('[data-role="elapsed"]'); + this.outputEl = this.root.querySelector('[data-role="output"]'); + this.copyButton = this.root.querySelector('[data-action="copy"]'); + this.clearButton = this.root.querySelector('[data-action="clear"]'); + this.generateButton = this.root.querySelector('[data-action="generate"]'); + this.controls = new Map(UI_FIELDS.map((name) => [name, this.root.querySelector(`[data-field="${name}"]`)])); + } + + listen(element, event, handler) { + if (!element) return; + element.addEventListener(event, handler); + this.cleanups.push(() => element.removeEventListener(event, handler)); + } + + bind() { + this.listen(this.root, "pointerdown", (event) => event.stopPropagation()); + this.listen(this.root, "wheel", (event) => event.stopPropagation()); + this.listen(this.root, "keydown", (event) => event.stopPropagation()); + + for (const [name, element] of this.controls) { + const eventName = name === "system_prompt" || name === "prompt" ? "input" : "change"; + this.listen(element, eventName, () => { + const value = readControl(element, name); + if (typeof value === "number" && !Number.isFinite(value)) return; + setWidgetValue(this.node, this.widgets[name], value); + if (name === "system_prompt" || name === "prompt") this.updateCount(name, value); + if (name === "sampling") this.updateSamplingControls(); + }); + } + + this.listen(this.root.querySelector('[data-action="focus"]'), "click", () => this.openFocusMode()); + this.listen(this.generateButton, "click", () => this.queueGeneration()); + this.listen(this.copyButton, "click", async () => { + if (!this.output) return; + await copyText(this.output); + this.copyButton.textContent = "Copied"; + window.setTimeout(() => { this.copyButton.textContent = "Copy"; }, 900); + }); + this.listen(this.clearButton, "click", () => this.clearOutput()); + } + + observeSize() { + if (typeof ResizeObserver === "undefined") return; + this.resizeObserver = new ResizeObserver(([entry]) => { + const width = entry.contentRect.width; + this.root.dataset.layout = width >= 940 ? "wide" : width >= 680 ? "medium" : "compact"; + }); + this.resizeObserver.observe(this.host); + } + + syncFromWidgets() { + for (const [name, element] of this.controls) { + if (!element) continue; + const value = this.widgets[name]?.value; + element.value = name === "thinking" ? String(Boolean(value)) : String(value ?? ""); + } + this.updateCount("system_prompt", this.widgets.system_prompt?.value ?? ""); + this.updateCount("prompt", this.widgets.prompt?.value ?? ""); + this.updateSamplingControls(); + } + + updateCount(name, value) { + const count = this.root.querySelector(`[data-count="${name}"]`); + if (count) count.textContent = `${String(value).length} chars`; + } + + updateSamplingControls(root = this.root) { + const enabled = root.querySelector('[data-field="sampling"]')?.value === "on"; + const group = root.querySelector('[data-role="sampling-controls"]'); + group?.classList.toggle("disabled", !enabled); + for (const control of root.querySelectorAll("[data-sampling-control]")) control.disabled = !enabled; + const hint = root.querySelector('[data-role="sampling-hint"]'); + if (hint) hint.textContent = enabled ? "Sampling enabled" : "Deterministic / sampling disabled"; + } + + clipConnected() { + return this.node.inputs?.find((input) => input.name === "clip")?.link != null; + } + + updateConnection() { + if (this.active) return; + if (this.clipConnected()) this.setStatus("Ready", "ready"); + else this.setStatus("Connect CLIP"); + } + + setStatus(text, state = "") { + this.statusEl.textContent = text; + this.statusEl.title = text; + this.statusEl.className = `flgt-status${state ? ` ${state}` : ""}`; + } + + startTimer(reset = false) { + if (reset || this.startedAt == null) this.startedAt = Date.now(); + window.clearInterval(this.timer); + const update = () => { + const elapsed = Math.max(0, Date.now() - this.startedAt); + this.elapsedEl.textContent = `${(elapsed / 1000).toFixed(1)}s`; + }; + update(); + this.timer = window.setInterval(update, 100); + } + + stopTimer() { + window.clearInterval(this.timer); + this.timer = null; + } + + setActive(active) { + this.active = active; + this.generateButton.disabled = active; + this.generateButton.textContent = active ? "Working..." : "Generate"; + } + + async queueGeneration() { + if (this.active) return; + if (!this.clipConnected()) { + this.fail("Connect a generation-capable CLIP first"); + return; + } + this.setActive(true); + this.setStatus("Queued", "running"); + this.startTimer(true); + try { + await app.queuePrompt(0, 1); + } catch (error) { + this.fail(error?.message || "Could not queue workflow"); + } + } + + beginExecution() { + this.setActive(true); + this.setStatus("Generating", "running"); + this.startTimer(false); + } + + markCached() { + this.setActive(false); + this.stopTimer(); + this.setStatus("Cached", "complete"); + } + + fail(message) { + this.setActive(false); + this.stopTimer(); + this.setStatus(message || "Generation failed", "error"); + } + + showOutput(text) { + this.output = text; + this.setActive(false); + this.stopTimer(); + if (text) { + this.outputEl.textContent = text; + this.outputEl.classList.remove("empty"); + this.copyButton.disabled = false; + this.clearButton.disabled = false; + } else { + this.outputEl.textContent = "The model returned an empty response."; + this.outputEl.classList.add("empty"); + this.copyButton.disabled = true; + this.clearButton.disabled = false; + } + this.updateCount("output", text); + this.setStatus("Complete", "complete"); + } + + clearOutput() { + this.output = ""; + this.outputEl.textContent = "Run the workflow to generate text."; + this.outputEl.classList.add("empty"); + this.copyButton.disabled = true; + this.clearButton.disabled = true; + this.updateCount("output", ""); + this.elapsedEl.textContent = ""; + this.updateConnection(); + } + + openFocusMode() { + if (this.modal) return; + const backdrop = document.createElement("div"); + backdrop.className = "flgt-modal-backdrop"; + backdrop.innerHTML = ``; + document.body.appendChild(backdrop); + this.modal = backdrop; + const dialog = backdrop.firstElementChild; + const controls = new Map(UI_FIELDS.map((name) => [name, dialog.querySelector(`[data-field="${name}"]`)])); + for (const [name, element] of controls) { + if (!element) continue; + const value = this.widgets[name]?.value; + element.value = name === "thinking" ? String(Boolean(value)) : String(value ?? ""); + } + this.updateSamplingControls(dialog); + + const close = () => this.closeFocusMode(); + const apply = () => { + for (const [name, element] of controls) { + if (!element) continue; + const value = readControl(element, name); + if (typeof value === "number" && !Number.isFinite(value)) continue; + setWidgetValue(this.node, this.widgets[name], value); + } + this.syncFromWidgets(); + }; + for (const button of dialog.querySelectorAll('[data-action="cancel"]')) button.addEventListener("click", close); + dialog.querySelector('[data-field="sampling"]')?.addEventListener("change", () => this.updateSamplingControls(dialog)); + dialog.querySelector('[data-action="apply"]').addEventListener("click", () => { apply(); close(); }); + dialog.querySelector('[data-action="apply-generate"]').addEventListener("click", () => { apply(); close(); this.queueGeneration(); }); + backdrop.addEventListener("pointerdown", (event) => { + event.stopPropagation(); + if (event.target === backdrop) close(); + }); + dialog.addEventListener("keydown", (event) => event.stopPropagation()); + this.modalKeyHandler = (event) => { + if (event.key === "Escape") close(); + }; + document.addEventListener("keydown", this.modalKeyHandler, true); + controls.get("prompt")?.focus(); + } + + closeFocusMode() { + if (!this.modal) return; + document.removeEventListener("keydown", this.modalKeyHandler, true); + this.modal.remove(); + this.modal = null; + this.modalKeyHandler = null; + } + + dispose() { + this.closeFocusMode(); + this.stopTimer(); + this.resizeObserver?.disconnect(); + for (const cleanup of this.cleanups) cleanup(); + this.cleanups = []; + this.host.remove(); + } +} + +function removeInstance(node) { + const panel = node._flGenerateTextPanel; + if (!panel) return; + node._flGenerateTextPanel = null; + for (const [key, instance] of INSTANCES) { + if (instance === panel) INSTANCES.delete(key); + } + panel.dispose(); +} + +app.registerExtension({ + name: "ComfyUI.FL_GenerateText", + nodeCreated(node) { + const comfyClass = node.constructor?.comfyClass || ""; + if (comfyClass !== NODE_CLASS) return; + + injectStyles(); + const previousLayout = Number(node.properties?.flGenerateTextLayoutVersion || 0); + node.properties = node.properties || {}; + node.properties.flGenerateTextLayoutVersion = LAYOUT_VERSION; + + const widgets = Object.fromEntries(UI_FIELDS.map((name) => [name, findWidget(node, name)])); + for (const widget of Object.values(widgets)) hideWidget(widget); + + const host = document.createElement("div"); + const domWidget = node.addDOMWidget("fl_generate_text_console", "fl-generate-text", host, { + getMinHeight: () => 410, + hideOnZoom: false, + serialize: false, + }); + + const panel = new GenerateTextPanel(node, host, widgets); + node._flGenerateTextPanel = panel; + window.setTimeout(() => { + if (node._flGenerateTextPanel !== panel) return; + if (!node.graph) { + removeInstance(node); + return; + } + registerInstance(panel); + applyWideLayout(node, previousLayout < LAYOUT_VERSION); + }, 0); + + const originalOnExecuted = node.onExecuted; + node.onExecuted = function (message) { + originalOnExecuted?.apply(this, arguments); + panel.showOutput(executionText(message)); + }; + + const originalOnConfigure = node.onConfigure; + node.onConfigure = function () { + const result = originalOnConfigure?.apply(this, arguments); + this.properties = this.properties || {}; + const needsMigration = Number(this.properties.flGenerateTextLayoutVersion || 0) < LAYOUT_VERSION; + this.properties.flGenerateTextLayoutVersion = LAYOUT_VERSION; + for (const widget of Object.values(widgets)) hideWidget(widget); + window.setTimeout(() => { + panel.syncFromWidgets(); + registerInstance(panel); + applyWideLayout(this, needsMigration); + }, 0); + return result; + }; + + const originalOnConnectionsChange = node.onConnectionsChange; + node.onConnectionsChange = function () { + const result = originalOnConnectionsChange?.apply(this, arguments); + panel.updateConnection(); + return result; + }; + + const originalOnRemoved = node.onRemoved; + node.onRemoved = function () { + removeInstance(this); + return originalOnRemoved?.apply(this, arguments); + }; + domWidget.onRemove = () => removeInstance(node); + }, +}); + +api.addEventListener("executing", (event) => { + instanceForNode(eventNode(event.detail))?.beginExecution(); +}); + +api.addEventListener("execution_cached", (event) => { + const nodes = Array.isArray(event.detail?.nodes) ? event.detail.nodes : []; + for (const nodeId of nodes) instanceForNode(nodeId)?.markCached(); +}); + +api.addEventListener("execution_error", (event) => { + const detail = event.detail || {}; + instanceForNode(eventNode(detail))?.fail(detail.exception_message || detail.exception_type); +}); + +api.addEventListener("execution_interrupted", () => { + for (const panel of INSTANCES.values()) { + if (panel.active) panel.fail("Interrupted"); + } +});