docs(pairs): document live validation and retain renderer screenshots (#193)

This commit is contained in:
Vito Sansevero
2026-10-17 07:02:45 -07:00
parent f430266a5d
commit 28eb8aaf4d
10 changed files with 50 additions and 13 deletions
+38 -2
View File
@@ -1,8 +1,8 @@
# Reusable positive and negative prompts
Tracking: [#193](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/193).
This work builds on the Split node in PR #192. Keep the feature unmerged until
maintainer live testing is complete.
This work builds on the merged Split node in PR #192. Keep the feature unmerged
until maintainer live testing is complete.
## Storage contract
@@ -55,3 +55,39 @@ Keep all existing coverage floors and test suites. Record regression failures
before implementing each behavior. Tests use disposable state and the managed
CPU host; never mutate the maintainer's running ComfyUI or personal database.
Python changes require a ComfyUI restart. Live approval is required before merge.
## Live testing
1. Download a database backup from Admin before the first restart. The additive
schema migration runs on startup; existing workflows and prompt IDs stay valid.
2. Restart ComfyUI, reload the browser, and add **Prompt Manager (+/-)** from
**ComfyAssets / Prompts**. Connect CLIP and the two conditioning outputs to the
sampler's positive and negative inputs.
3. Enter both texts, save a pair, then keep positive unchanged and change negative.
Recent should show both combinations with distinct negative excerpts. Selecting
one previews both; **Use pair** loads both together.
4. Test category/tag autocomplete and metadata opt-in, resizing, light/dark,
switching Classic / Nodes 2.0 both ways, and workflow reload. Connected fields
stay authoritative; run the node to save connected metadata values.
5. Queue the same recipe twice and change only negative once. Usage should count
dequeued jobs even when ComfyUI caches the node. Prepend, append and LoRA triggers
affect positive only. Gallery text is labelled as recorded during execution or
recovered from workflow metadata.
6. Run an output metadata scan to enrich existing images. Repeat it: pairs and
links must remain deduplicated, with unchanged results rather than new links.
Conflicting or ambiguous files need review and are preserved.
Fresh executions embed bounded text evidence in PNG metadata. A cached node may
not emit fresh evidence, so its graph-derived text is explicitly marked inferred.
Multiple distinct batch records without an image anchor remain unresolved.
Rescanning after interruption is safe through idempotent per-file writes; this
version does not persist a filesystem scan cursor across server restarts.
The API can request cancellation with `POST /prompt_manager/scan/cancel`; the
existing dashboard Hide button continues to hide progress without cancelling.
Pair APIs are under `/prompt_manager/pairs`: POST saves a combination, GET lists
and filters, GET/PATCH/DELETE `/{id}` reads, edits metadata or removes an unused
pair. Changing text creates another combination. Null metadata updates preserve
existing values; empty strings/lists clear supported fields. Archive a pair with
`PATCH {"archived":true}`. GET `/export` provides versioned JSON containing both
texts and pair metadata; this is an export format, not a JSON import endpoint.
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+12 -11
View File
@@ -1,6 +1,7 @@
"""Reusable ordered prompt combinations, separate from legacy prompt CRUD."""
from functools import wraps
from typing import Callable
from aiohttp import web
@@ -24,9 +25,9 @@ except ImportError:
)
def pair_response(handler):
def pair_response(handler: Callable) -> Callable:
@wraps(handler)
async def wrapped(self, request):
async def wrapped(self, request: web.Request) -> web.Response:
try:
return await handler(self, request)
except PairDependencyError as exc:
@@ -67,19 +68,19 @@ def metadata_fields(data: dict) -> dict:
return fields
async def object_body(request) -> dict:
async def object_body(request: web.Request) -> dict:
data = await request.json()
if not isinstance(data, dict):
raise ValueError("Expected a JSON object")
return data
def missing_pair():
def missing_pair() -> web.Response:
return web.json_response({"success": False, "error": "Pair not found"}, status=404)
class PairRoutesMixin:
def _register_pair_routes(self, routes) -> None:
def _register_pair_routes(self, routes: web.RouteTableDef) -> None:
routes.get("/prompt_manager/pairs/export")(self.export_prompt_pairs)
routes.get("/prompt_manager/pairs")(self.list_prompt_pairs)
routes.post("/prompt_manager/pairs")(self.save_prompt_pair)
@@ -88,7 +89,7 @@ class PairRoutesMixin:
routes.delete("/prompt_manager/pairs/{pair_id}")(self.delete_prompt_pair)
@pair_response
async def save_prompt_pair(self, request):
async def save_prompt_pair(self, request: web.Request) -> web.Response:
data = await object_body(request)
positive = data.get("positive_text")
negative = data.get("negative_text")
@@ -107,7 +108,7 @@ class PairRoutesMixin:
return web.json_response({"success": True, "pair": pair})
@pair_response
async def get_prompt_pair(self, request):
async def get_prompt_pair(self, request: web.Request) -> web.Response:
pair = await self._run_in_executor(
self.db.get_prompt_pair, int(request.match_info["pair_id"])
)
@@ -118,7 +119,7 @@ class PairRoutesMixin:
)
@pair_response
async def list_prompt_pairs(self, request):
async def list_prompt_pairs(self, request: web.Request) -> web.Response:
limit, offset = parse_page_params(request.query)
archived = request.query.get("include_archived", "false")
if archived not in ("true", "false"):
@@ -136,7 +137,7 @@ class PairRoutesMixin:
return web.json_response({"success": True, **result})
@pair_response
async def update_prompt_pair(self, request):
async def update_prompt_pair(self, request: web.Request) -> web.Response:
pair_id = int(request.match_info["pair_id"])
data = await object_body(request)
allowed = {"title", "category", "tags", "rating", "notes", "archived"}
@@ -161,14 +162,14 @@ class PairRoutesMixin:
return web.json_response({"success": True, "pair": pair})
@pair_response
async def delete_prompt_pair(self, request):
async def delete_prompt_pair(self, request: web.Request) -> web.Response:
deleted = await self._run_in_executor(
self.db.delete_prompt_pair, int(request.match_info["pair_id"])
)
return web.json_response({"success": True}) if deleted else missing_pair()
@pair_response
async def export_prompt_pairs(self, request):
async def export_prompt_pairs(self, request: web.Request) -> web.Response:
pairs = []
offset = 0
while True: