Files
ComfyAssets-ComfyUI_PromptM…/prompt_search_list.py
T
Vito 8146250274 feat: PromptSearchList batch workflow support & image linking v3.2.2 (#139)
* fix(search): PromptSearchList crash, filters, and batch output (#52)

- Fix empty list crash (OUTPUT_IS_LIST requires at least one element)
- Add partial tag matching (LIKE instead of exact)
- Add preview and count outputs for batch visibility
- Add skip_multipart filter for Clip_N video prompts
- Add LoRA-only prompt filter
- Collapse newlines for StringOutputList compatibility

* feat(db): add tag_partial parameter for LIKE tag matching (#52)

Allows search_prompts to use LIKE matching for tags instead of exact.
Used by PromptSearchList for discovery-style search.
Existing callers default to exact matching (unchanged behavior).

* feat(tracker): add FIFO prompt queue for batch image linking (#52)

Batch workflows encode all prompts first, then save all images later.
The queue preserves encode order so each image links to the correct
prompt by position (pop on save matches push on encode).

* fix(monitor): multi-strategy image-to-prompt linking (#52)

Previous approach relied on live prompt tracker state, which was
always stale for batch workflows (all images linked to last prompt).

New 5-strategy priority chain:
1. FIFO queue pop (batch-correct by position)
2. Metadata-based DB lookup (extracts prompt from PNG)
3. Prompt snapshot at file-creation time
4. Live prompt tracker
5. Most recent DB prompt fallback

* test: add tests for prompt queue and search filters (#52)

- 6 queue tests: FIFO ordering, batch simulation, exhaustion
- 15 filter tests: newline collapse, Clip_ filter, LoRA-only filter
- Fix _make_tracker helper to include queue fields

* chore: bump version to 3.2.2

* fix: address code review — regex backtracking and queue expiry (#52)

- Replace backtracking-prone LoRA-only regex with subtraction approach
  (re.sub removes tags, check if anything remains). Fixes CodeQL alert.
- Add expiry check to pop_next_prompt — skips stale entries to prevent
  incorrect linking from accumulated queue entries across sessions.
- Cleanup thread now also prunes expired queue entries.
2026-04-10 12:49:59 -07:00

233 lines
7.7 KiB
Python

"""
PromptSearchList: A ComfyUI node that searches prompts and outputs results as a list.
This node enables batch processing workflows by outputting prompt texts with
OUTPUT_IS_LIST=True, allowing direct connection to nodes that accept list inputs.
"""
import re
from typing import Any, Dict, List, Tuple
try:
from .utils.logging_config import get_logger
except ImportError:
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from utils.logging_config import get_logger
try:
from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict
except ImportError:
class ComfyNodeABC:
pass
class IO:
STRING = "STRING"
INT = "INT"
InputTypeDict = dict
try:
from .database.operations import PromptDatabase
except ImportError:
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from database.operations import PromptDatabase
class PromptSearchList(ComfyNodeABC):
"""
A ComfyUI node that searches the prompt database and outputs matching
prompts as a list for batch processing workflows.
Features:
- Search by text content
- Filter by category, tags, and minimum rating
- Outputs as OUTPUT_IS_LIST for batch node compatibility
- Read-only operation (no database writes)
"""
def __init__(self):
self.logger = get_logger("prompt_search_list.node")
self.logger.debug("Initializing PromptSearchList node")
self.db = PromptDatabase()
@classmethod
def INPUT_TYPES(cls) -> InputTypeDict:
return {
"required": {},
"optional": {
"text": (
IO.STRING,
{
"default": "",
"tooltip": "Search text to match against prompt content",
},
),
"category": (
IO.STRING,
{
"default": "",
"tooltip": "Filter by specific category",
},
),
"tags": (
IO.STRING,
{
"default": "",
"tooltip": "Comma-separated list of tags to filter by (partial match)",
},
),
"min_rating": (
IO.INT,
{
"default": 0,
"min": 0,
"max": 5,
"tooltip": "Minimum rating (0-5) to include in results",
},
),
"limit": (
IO.INT,
{
"default": 50,
"min": 1,
"max": 1000,
"tooltip": "Maximum number of results to return",
},
),
"skip_multipart": (
"BOOLEAN",
{
"default": True,
"tooltip": "Skip prompts containing Clip_1/Clip_2/etc. multi-part markers",
},
),
},
}
RETURN_TYPES = (IO.STRING, IO.STRING, IO.INT)
RETURN_NAMES = ("prompts", "preview", "count")
OUTPUT_IS_LIST = (True, False, False)
OUTPUT_TOOLTIPS = (
"List of prompt texts matching the search criteria.",
"Preview of all found prompts (for display).",
"Number of prompts found.",
)
FUNCTION = "search"
OUTPUT_NODE = True
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
DESCRIPTION = (
"Searches the prompt database and outputs matching prompts as a list. "
"Use this node to retrieve stored prompts for batch processing workflows. "
"'prompts' output sends each prompt individually to downstream nodes (batch). "
"'preview' output shows all found prompts as one text block. "
"'count' output shows how many prompts were found."
)
def search(
self,
text: str = "",
category: str = "",
tags: str = "",
min_rating: int = 0,
limit: int = 50,
skip_multipart: bool = True,
):
"""
Search for prompts matching the given criteria.
Args:
text: Search text to match against prompt content
category: Filter by specific category
tags: Comma-separated list of tags to filter by
min_rating: Minimum rating (0-5) to include
limit: Maximum number of results to return
Returns:
Dict with 'ui' display info and 'result' tuple
"""
try:
# Parse tags from comma-separated string
tags_list = None
if tags and tags.strip():
tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
# Perform search with partial tag matching for discovery
results = self.db.search_prompts(
text=text.strip() if text and text.strip() else None,
category=category.strip() if category and category.strip() else None,
tags=tags_list,
tag_partial=True,
rating_min=min_rating if min_rating > 0 else None,
limit=limit,
)
# Extract prompt text, collapsing newlines to spaces so downstream
# nodes that split on \n (e.g. StringOutputList) treat each DB
# entry as a single prompt.
prompt_texts = [
" ".join(r["text"].split()) for r in results if r.get("text")
]
# Filter out multi-part prompts (Clip_1/Clip_2 markers)
if skip_multipart:
prompt_texts = [
p for p in prompt_texts if not re.search(r"Clip_\d+", p)
]
# Filter out prompts that are only LoRA tags with no actual content.
# Uses subtraction instead of repeated groups to avoid backtracking.
prompt_texts = [
p for p in prompt_texts if re.sub(r"<lora:[^>]+>", "", p).strip()
]
count = len(prompt_texts)
# Build a preview: numbered list of truncated prompts
preview_lines = []
for i, p in enumerate(prompt_texts, 1):
truncated = p[:120] + "..." if len(p) > 120 else p
preview_lines.append(f"[{i}] {truncated}")
preview = "\n".join(preview_lines) if preview_lines else "No results found"
# Must return at least one element — ComfyUI's slice_dict
# indexes into OUTPUT_IS_LIST outputs and crashes on empty lists.
if not prompt_texts:
self.logger.info("Search returned no results")
return {
"ui": {"text": ["No results found"]},
"result": ([""], preview, count),
}
return {
"ui": {"text": [f"Found {count} prompts"]},
"result": (prompt_texts, preview, count),
}
except Exception as e:
self.logger.error(f"Search error: {e}", exc_info=True)
return {
"ui": {"text": [f"Search error: {e}"]},
"result": ([""], f"Error: {e}", 0),
}
@classmethod
def IS_CHANGED(
cls, text="", category="", tags="", min_rating=0, limit=50, skip_multipart=True
):
"""
Always re-execute to get fresh results from the database.
The database contents may have changed since the last execution,
so we always return a unique value to trigger re-execution.
"""
import time
return time.time()