Files
ComfyAssets-ComfyUI_PromptM…/prompt_manager.py
T
Vito 00284d92da feat: LoraManager integration — trigger words & example import (#52) (#136)
* feat: add LoRA utility module for LoraManager integration (#52)

Detection, metadata reading, trigger word cache, and prompt injection
for ComfyUI-Lora-Manager. All functions return empty results when
LoraManager is not installed.

* feat: add IntegrationConfig for opt-in third-party integrations (#52)

* feat(api): add LoraManager integration endpoints (#52)

Detection, enable/disable, scan/import, trigger word lookup,
and cache refresh endpoints under /prompt_manager/lora/*.

* feat: inject LoRA trigger words into prompts at encoding time (#52)

When enabled, scans for <lora:NAME:WEIGHT> tags in prompt text and
appends trigger words from LoraManager metadata. Behind config toggle.

* feat(ui): add LoraManager integration settings and import UI (#52)

Integrations section in settings with auto-detection badge, enable
toggle, trigger word toggle, and Import LoRA Data button.

* fix: detect LoraManager with case-insensitive directory scan (#52)

* fix: simplify LoraManager heuristic to match real install structure (#52)

* fix: find ComfyUI root via folder_paths and handle symlinked installs (#52)

* fix(ui): add pointer-events-none to toggle switch overlays (#52)

The styled div was intercepting clicks meant for the sr-only checkbox
input, preventing toggle switches from being clickable.

* fix: use lora-manager category and tag for imported LoRA data (#52)

* fix: use rglob to find metadata in lora subdirectories (#52)

* feat: discover LoRA dirs from extra_model_paths.yaml and folder_paths (#52)

Only models/loras under the ComfyUI root was checked. Now also parses
extra_model_paths.yaml and uses folder_paths.get_folder_paths('loras')
at runtime to find all configured LoRA directories.

* fix: use example prompts instead of trigger words, serve LoRA preview images (#52)

- Prompt text now uses civitai example prompts (images[].meta.prompt)
  when available, falling back to model name instead of trigger words
- Image serving now allows paths within LoRA directories when the
  integration is enabled, fixing 403 errors on preview images

* fix: handle null civitai field, link all preview images (#52)

- Guard against civitai: null in metadata (was crashing the scan)
- Link all local preview images per LoRA, not just the first
- Add get_civitai_image_urls() for future remote image support

* feat: reimport clears previous lora-manager data first (#52)

Clicking Import LoRA Data now deletes all existing lora-manager
category prompts before scanning, ensuring a clean reimport.

* feat: download civitai example images during LoRA import (#52)

Images from civitai.images[] are downloaded to data/lora_images/ cache
and linked to prompts alongside local preview files. Cached files are
reused on reimport. Image serving allows the cache directory.

* feat: add CivitAI API key setting for authenticated image downloads (#52)

Most civitai example images (especially NSFW) require authentication.
Adds API key field to the Integrations settings panel, passed as
Bearer token when downloading example images.

* feat(ui): add progress modal for LoRA import (#52)

Closes settings modal and shows a dedicated progress modal with
progress bar, status text, and processed/imported counts during
LoRA import. Auto-closes after completion.

* fix: reduce download timeout to 5s, update progress per LoRA (#52)

5K images at 15s timeout was painfully slow. Reduced to 5s fail-fast.
Progress now updates for every LoRA with image count, not every 5th.

* perf: use 512px thumbnails and parallel downloads for LoRA images (#52)

Full-size civitai images averaged 5.6MB each (27GB total for 5K images).
Now requests /width=512/ thumbnails (~50-100KB) and downloads 8 in
parallel per LoRA. Expected speedup: ~100x smaller + 8x parallel.

* fix: resize downloaded images to 512px thumbnails locally (#52)

Civitai CDN returns 401 for /width=N/ thumbnail URLs with API key auth.
Instead, download the original and resize to 512px via PIL before saving.
Reduces disk usage from ~5MB to ~30-50KB per image.

* docs: update README for v3.2.1 LoRA Manager integration (#52)

- Add LoRA Manager integration section with setup guide and CivitAI key docs
- Add folder filter section with rescan note for existing libraries
- Add v3.2.1 changelog entry, split from v3.2.0
- Add WIP notice for LoRA Manager feature
- Update AutoTag section to include WD14 models
- Fix stale references (KikoTextEncode, outdated file structure)
- Remove dated v2 development note
- Add screenshots for settings, integration, and filtered results
- Fix code review items: remove unused constant, add comments to empty excepts
- Bump version to 3.2.1

* test: add unit tests for LoRA integration and folder filter (#52)

- test_lora_utils.py: 32 tests covering trigger word extraction, example
  prompts, image URLs, metadata parsing, cache dir, TriggerWordCache
- test_lora_database.py: 17 tests covering delete_prompts_by_category,
  folder filter search, get_prompt_subfolders, LoRA import workflow
- test_config.py: 7 new IntegrationConfig tests for structure, enable,
  partial update, reset, and roundtrip

* fix: remove unused MagicMock import in test_lora_utils (#52)
2026-04-03 18:26:23 -07:00

259 lines
9.2 KiB
Python

"""
PromptManager: Main custom node implementation that extends CLIPTextEncode
with persistent prompt storage and search capabilities.
"""
import time
from typing import Any, Tuple
try:
from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict
except ImportError:
# Fallback for older ComfyUI versions
class ComfyNodeABC:
pass
class IO:
STRING = "STRING"
CLIP = "CLIP"
CONDITIONING = "CONDITIONING"
InputTypeDict = dict
try:
from .prompt_manager_base import PromptManagerBase
except ImportError:
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from prompt_manager_base import PromptManagerBase
class PromptManager(PromptManagerBase, ComfyNodeABC):
"""
A ComfyUI custom node that functions like CLIPTextEncode but adds:
- Persistent storage of all prompts in SQLite database
- Search and retrieval capabilities
- Metadata management (categories, tags, ratings, notes)
- Duplicate detection via SHA256 hashing
"""
def __init__(self):
super().__init__(logger_name="prompt_manager.node")
@classmethod
def INPUT_TYPES(cls) -> InputTypeDict:
return {
"required": {
"text": (
IO.STRING,
{
"multiline": True,
"dynamicPrompts": True,
"tooltip": "The text prompt to be encoded and saved to database.",
},
),
"clip": (
IO.CLIP,
{"tooltip": "The CLIP model used for encoding the text."},
),
},
"optional": {
"category": (
IO.STRING,
{
"default": "",
"tooltip": "Optional category for organizing prompts (e.g., 'landscapes', 'portraits')",
},
),
"tags": (
IO.STRING,
{
"default": "",
"tooltip": "Comma-separated tags for the prompt (e.g., 'anime, detailed, sunset')",
},
),
"search_text": (
IO.STRING,
{
"default": "",
"tooltip": "Search for past prompts containing this text",
},
),
"prepend_text": (
IO.STRING,
{
"tooltip": "Text to prepend to the main prompt (connected STRING nodes will be added before the main text)"
},
),
"append_text": (
IO.STRING,
{
"tooltip": "Text to append to the main prompt (connected STRING nodes will be added after the main text)"
},
),
},
}
RETURN_TYPES = (IO.CONDITIONING, IO.STRING)
OUTPUT_TOOLTIPS = (
"A conditioning containing the embedded text used to guide the diffusion model.",
"The final combined text string (with prepend/append applied) that was encoded.",
)
FUNCTION = "encode_prompt"
OUTPUT_NODE = True
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
DESCRIPTION = (
"Encodes a text prompt using a CLIP model into an embedding that can be used to guide "
"the diffusion model towards generating specific images. Additionally saves all prompts "
"to a local SQLite database with optional metadata for search and retrieval."
)
def encode_prompt(
self,
clip,
text: str,
category: str = "",
tags: str = "",
search_text: str = "",
prepend_text: str = "",
append_text: str = "",
) -> Tuple[Any]:
"""
Encode the text prompt and save it to the database.
Args:
clip: The CLIP model for encoding
text: The text prompt to encode
category: Optional category for organization
tags: Comma-separated tags
search_text: Text to search for in past prompts
prepend_text: Text to prepend to the main prompt
append_text: Text to append to the main prompt
Returns:
Tuple containing the conditioning for the diffusion model and the final text string
Raises:
RuntimeError: If clip input is invalid
"""
# Combine prepend, main text, and append text
parts = []
if prepend_text and prepend_text.strip():
parts.append(prepend_text.strip())
if text:
parts.append(text)
if append_text and append_text.strip():
parts.append(append_text.strip())
final_text = " ".join(parts)
# Inject LoRA trigger words if integration is enabled
final_text = self._inject_lora_trigger_words(final_text)
# Use the combined text for encoding
encoding_text = final_text
# For database storage, save the original main text with metadata about prepend/append
storage_text = text
# Validate CLIP model
if clip is None:
error_msg = (
"ERROR: clip input is invalid: None\n\n"
"If the clip is from a checkpoint loader node your checkpoint does not "
"contain a valid clip or text encoder model."
)
self.logger.error("CLIP validation failed: clip input is None")
raise RuntimeError(error_msg)
# Save prompt to database and set execution context for gallery tracking
prompt_id = None
extended_tags = []
if storage_text and storage_text.strip():
self.logger.debug(f"Processing prompt text: {storage_text[:100]}...")
# Add prepend/append info to tags if they exist
extended_tags = self._parse_tags(tags) or []
if prepend_text and prepend_text.strip():
extended_tags.append(f"prepend:{prepend_text.strip()[:50]}")
if append_text and append_text.strip():
extended_tags.append(f"append:{append_text.strip()[:50]}")
try:
prompt_id = self._save_prompt_to_database(
text=storage_text.strip(),
category=category.strip() if category else None,
tags=extended_tags if extended_tags else None,
)
# Set current prompt for image tracking
if prompt_id:
execution_id = self.prompt_tracker.set_current_prompt(
prompt_text=encoding_text.strip(),
additional_data={
"category": category.strip() if category else None,
"tags": extended_tags,
"prompt_id": prompt_id,
"prepend_text": (
prepend_text.strip() if prepend_text else None
),
"append_text": append_text.strip() if append_text else None,
"final_text": encoding_text.strip(),
},
)
self.logger.debug(
f"Set execution context: {execution_id} for prompt ID: {prompt_id}"
)
except Exception as e:
# Log error but don't fail the encoding
self.logger.warning(f"Failed to save prompt to database: {e}")
# Perform standard CLIP text encoding using the combined text
self.logger.debug(
f"Performing CLIP text encoding on combined text: {encoding_text[:100]}..."
)
tokens = clip.tokenize(encoding_text)
conditioning = clip.encode_from_tokens_scheduled(tokens)
# Register with ComfyUI integration for standard metadata compatibility
node_id = f"promptmanager_{int(time.time() * 1000)}"
self.comfyui_integration.register_prompt(
node_id,
encoding_text.strip(),
{
"category": category.strip() if category else None,
"tags": extended_tags,
"prompt_id": prompt_id,
"prepend_text": prepend_text.strip() if prepend_text else None,
"append_text": append_text.strip() if append_text else None,
},
)
self.logger.info(f"CLIP encoding completed, text: {repr(encoding_text)[:80]}")
return (conditioning, encoding_text)
@classmethod
def IS_CHANGED(
cls,
clip,
text="",
category="",
tags="",
search_text="",
prepend_text="",
append_text="",
**kwargs,
):
"""
ComfyUI method to determine if node needs re-execution.
Returns a hash of input values that affect the conditioning output.
This enables proper branch execution - only re-execute when inputs change.
"""
import hashlib
combined = f"{text}|{prepend_text}|{append_text}"
return hashlib.sha256(combined.encode()).hexdigest()