* chore: start 3.2.4 * feat: track prompt usage (run_count, last_used_at) with server-side sorting - Migration adds last_used_at and run_count; existing prompts are estimated from linked images (count, newest image time, else created_at). The backfill only touches NULL rows, so it self-heals rows written after a downgrade or an interrupted upgrade. - Every node run records a use, including re-runs of an existing prompt; a tracking failure is logged and never blocks generation. - search_prompts/get_recent_prompts accept a whitelisted sort key (last_used, created, run_count, rating, text); default stays newest first. Refs #90 * feat: sort param on /recent and /search; microsecond last_used_at - /prompt_manager/recent and /search accept ?sort= (whitelisted in the DB layer; unknown values fall back to newest first). - last_used_at is written with microseconds from Python so a re-run in the same millisecond as another save still sorts first; ordering stays correct against millisecond values from the backfill. Refs #90 * feat: server-side sort in admin UI with Recently Used / Most Used and run badges - New web/js/prompt-list-sort.js owns the sort options, validation and request URLs; admin.js fills the dropdown from it and re-fetches from the server on change, so every sort spans all pages (the old client-side sort only reordered the current page). - Default sort is Recently Used; prompt cards show a run-count badge with the last-used time on hover. - The node's Recent button now lists recently used prompts. - Contract test keeps the UI sort keys and the node's key in sync with the server whitelist. Refs #90 * fix: link images and count runs by the positive prompt, not node execution With a negative-prompt PromptManager node in the workflow, every executed PromptManager node pushed onto the image-linking queue, so images could be linked to the negative prompt or to a previous run. ComfyUI also skips unchanged nodes, so re-runs of the same prompt were never counted. - utils/prompt_graph.py finds the PromptManager nodes feeding a sampler's positive input (KSampler, CFG/Basic guiders, ControlNet and combine pass-throughs, PromptManagerText into encoders), falling back to all PromptManager nodes when none can be traced. - Runs are counted by a ComfyUI on-prompt hook when a workflow is queued; prompts not yet saved are counted when the node first saves them. - Nodes receive the queued graph (hidden PROMPT/UNIQUE_ID). Only the run's positive prompt becomes the current prompt; only batch items with linked text are pushed onto the FIFO queue. - The image monitor links from the image's own metadata first, using the positive node, and falls back to the queue for batch items. Refs #90 * fix: address review — count cached runs of linked text, keep repeated pending runs - Text from known pure string nodes (core PrimitiveString/StringConcatenate, WAS Text Concatenate/Multiline, pysssss ShowText) is resolved at queue time, so cached re-runs of such prompts are counted and their images link from metadata. Unknown nodes are never guessed; the node compares its real text with the queue-time resolution and falls back to counting and queueing itself on any mismatch. - A new prompt queued several times before its first run keeps every pending run; the first save records all of them. - Registration flag is a threading.Event; consistent unittest imports; logging instead of print in the init fallback. * refactor: unquote PendingFirstUse annotation so Tuple usage is visible
179 lines
6.4 KiB
Python
179 lines
6.4 KiB
Python
"""Find which PromptManager nodes feed a sampler's positive input in a ComfyUI graph.
|
|
|
|
Works on the API-format prompt graph ({node_id: {"class_type", "inputs"}}) that
|
|
ComfyUI queues and embeds in saved images. Usage counting and image linking use it
|
|
so that negative-prompt PromptManager nodes never count as "the prompt" of a run.
|
|
|
|
Pure functions with no ComfyUI imports, so they are unit-testable. Mirrors the
|
|
link-tracing rules of web/js/comfy-metadata.js.
|
|
"""
|
|
|
|
import re
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
PROMPT_MANAGER_TYPES = frozenset({"PromptManager", "PromptManagerText"})
|
|
|
|
# Upper bound on nodes expanded per graph; queued and embedded graphs are untrusted
|
|
MAX_VISITS = 500
|
|
|
|
# Inputs that carry positive conditioning or prompt text towards a sampler
|
|
_CONDITIONING_KEY = re.compile(r"^conditioning(_\w+)?$")
|
|
_TEXT_KEY = re.compile(r"^(text|string|prompt)(_?[a-z0-9]+)?$", re.IGNORECASE)
|
|
|
|
|
|
def _is_link(value: Any) -> bool:
|
|
return (
|
|
isinstance(value, (list, tuple))
|
|
and len(value) == 2
|
|
and isinstance(value[0], (str, int))
|
|
and isinstance(value[1], int)
|
|
)
|
|
|
|
|
|
def _inputs(node: Any) -> Dict[str, Any]:
|
|
inputs = node.get("inputs") if isinstance(node, dict) else None
|
|
return inputs if isinstance(inputs, dict) else {}
|
|
|
|
|
|
def _positive_roots(graph: Dict[str, Any]) -> List[Any]:
|
|
"""Links into samplers' positive inputs (KSampler, CFGGuider, BasicGuider, ...)."""
|
|
roots = []
|
|
for node in graph.values():
|
|
inputs = _inputs(node)
|
|
if not _is_link(inputs.get("model")):
|
|
continue
|
|
if _is_link(inputs.get("positive")) and _is_link(inputs.get("negative")):
|
|
roots.append(inputs["positive"])
|
|
elif (
|
|
_is_link(inputs.get("conditioning"))
|
|
and "guider" in str(node.get("class_type", "")).lower()
|
|
):
|
|
roots.append(inputs["conditioning"])
|
|
return roots
|
|
|
|
|
|
def _upstream_links(inputs: Dict[str, Any]) -> List[Any]:
|
|
"""Inputs to follow from a pass-through or encoder node, never `negative`."""
|
|
links = []
|
|
for key, value in inputs.items():
|
|
if not _is_link(value):
|
|
continue
|
|
if key == "positive" or _CONDITIONING_KEY.match(key) or _TEXT_KEY.match(key):
|
|
links.append(value)
|
|
return links
|
|
|
|
|
|
def positive_prompt_nodes(graph: Any) -> List[str]:
|
|
"""Ids of PromptManager/PromptManagerText nodes that feed a positive input.
|
|
|
|
Ordered by discovery, each id once. Malformed graphs yield an empty list.
|
|
"""
|
|
if not isinstance(graph, dict):
|
|
return []
|
|
|
|
found: List[str] = []
|
|
seen = set()
|
|
stack = list(reversed(_positive_roots(graph)))
|
|
while stack and len(seen) < MAX_VISITS:
|
|
node_id = str(stack.pop()[0])
|
|
if node_id in seen:
|
|
continue
|
|
seen.add(node_id)
|
|
node = graph.get(node_id)
|
|
if not isinstance(node, dict):
|
|
continue
|
|
if node.get("class_type") in PROMPT_MANAGER_TYPES:
|
|
found.append(node_id)
|
|
continue
|
|
stack.extend(reversed(_upstream_links(_inputs(node))))
|
|
return found
|
|
|
|
|
|
def run_prompt_nodes(graph: Any) -> List[str]:
|
|
"""Prompt nodes that represent a run: the positive ones, if any can be found.
|
|
|
|
When no PromptManager node can be traced to a positive input (an unrecognised
|
|
custom sampler, say), every PromptManager node counts, as before 3.2.4, so an
|
|
exotic workflow never silently loses usage counting or image linking.
|
|
"""
|
|
positive = positive_prompt_nodes(graph)
|
|
if positive or not isinstance(graph, dict):
|
|
return positive
|
|
return [
|
|
str(node_id)
|
|
for node_id, node in graph.items()
|
|
if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES
|
|
]
|
|
|
|
|
|
def _join_text_inputs(
|
|
inputs: Dict[str, Any], keys: List[str], resolve
|
|
) -> Optional[str]:
|
|
"""WAS-style join: resolve each key, optionally strip, skip empty, join by delimiter."""
|
|
delimiter = inputs.get("delimiter", " ")
|
|
if not isinstance(delimiter, str):
|
|
return None
|
|
clean = str(inputs.get("clean_whitespace", "true")).lower() == "true"
|
|
parts = []
|
|
for key in keys:
|
|
value = resolve(inputs.get(key, ""))
|
|
if value is None:
|
|
return None
|
|
value = value.strip() if clean else value
|
|
if value:
|
|
parts.append(value)
|
|
return delimiter.join(parts)
|
|
|
|
|
|
def _resolve_string_node(
|
|
class_type: str, inputs: Dict[str, Any], resolve
|
|
) -> Optional[str]:
|
|
"""Output of a known pure string node, or None when the type isn't known."""
|
|
if class_type in ("PrimitiveString", "PrimitiveStringMultiline"):
|
|
return resolve(inputs.get("value"))
|
|
if class_type in ("ShowText|pysssss", "Text Multiline", "String Literal"):
|
|
return resolve(inputs.get("text", inputs.get("string")))
|
|
if class_type == "StringConcatenate":
|
|
a, b = resolve(inputs.get("string_a", "")), resolve(inputs.get("string_b", ""))
|
|
delimiter = inputs.get("delimiter", "")
|
|
if a is None or b is None or not isinstance(delimiter, str):
|
|
return None
|
|
return f"{a}{delimiter}{b}"
|
|
if class_type == "Text Concatenate":
|
|
keys = sorted(k for k in inputs if re.fullmatch(r"text_[a-z]", k))
|
|
return _join_text_inputs(inputs, keys, resolve)
|
|
return None
|
|
|
|
|
|
def resolve_text(graph: Any, node_id: str) -> Optional[str]:
|
|
"""A prompt node's `text` as it will be at run time, if knowable from the graph.
|
|
|
|
Typed text, or text produced by known pure string nodes (core primitives and
|
|
StringConcatenate, WAS Text Concatenate/Multiline, pysssss ShowText). Anything
|
|
else, including PromptSearchList batches, returns None rather than a guess.
|
|
Callers compare the result with the text the node actually received.
|
|
"""
|
|
if not isinstance(graph, dict):
|
|
return None
|
|
seen = set()
|
|
|
|
def resolve(value: Any) -> Optional[str]:
|
|
if isinstance(value, str):
|
|
return value
|
|
if not _is_link(value) or len(seen) >= MAX_VISITS:
|
|
return None
|
|
source_id = str(value[0])
|
|
if source_id in seen:
|
|
return None
|
|
seen.add(source_id)
|
|
source = graph.get(source_id)
|
|
if not isinstance(source, dict):
|
|
return None
|
|
return _resolve_string_node(
|
|
str(source.get("class_type", "")), _inputs(source), resolve
|
|
)
|
|
|
|
seen.add(str(node_id))
|
|
text = resolve(_inputs(graph.get(str(node_id))).get("text"))
|
|
return text.strip() if isinstance(text, str) and text.strip() else None
|