Files
ComfyAssets-ComfyUI_PromptM…/utils/prompt_graph.py
T
Vito 6c7e4d5b35 feat: release 3.2.4 — recently used sorting, run counts, positive-prompt image linking (#152)
* 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
2026-10-07 13:52:09 -07:00

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