diff --git a/docs/reusable-prompt-pairs.md b/docs/reusable-prompt-pairs.md index d1b0b3d..cd77932 100644 --- a/docs/reusable-prompt-pairs.md +++ b/docs/reusable-prompt-pairs.md @@ -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. diff --git a/docs/screenshots/paired-toolkit/classic-pair-dark.png b/docs/screenshots/paired-toolkit/classic-pair-dark.png new file mode 100644 index 0000000..00ffcf9 Binary files /dev/null and b/docs/screenshots/paired-toolkit/classic-pair-dark.png differ diff --git a/docs/screenshots/paired-toolkit/classic-pair-library-preview.png b/docs/screenshots/paired-toolkit/classic-pair-library-preview.png new file mode 100644 index 0000000..49fd994 Binary files /dev/null and b/docs/screenshots/paired-toolkit/classic-pair-library-preview.png differ diff --git a/docs/screenshots/paired-toolkit/classic-pair-light.png b/docs/screenshots/paired-toolkit/classic-pair-light.png new file mode 100644 index 0000000..ef75be6 Binary files /dev/null and b/docs/screenshots/paired-toolkit/classic-pair-light.png differ diff --git a/docs/screenshots/paired-toolkit/classic-pair-narrow.png b/docs/screenshots/paired-toolkit/classic-pair-narrow.png new file mode 100644 index 0000000..1c9fbc3 Binary files /dev/null and b/docs/screenshots/paired-toolkit/classic-pair-narrow.png differ diff --git a/docs/screenshots/paired-toolkit/nodes2-pair-dark.png b/docs/screenshots/paired-toolkit/nodes2-pair-dark.png new file mode 100644 index 0000000..e8aa68e Binary files /dev/null and b/docs/screenshots/paired-toolkit/nodes2-pair-dark.png differ diff --git a/docs/screenshots/paired-toolkit/nodes2-pair-library-preview.png b/docs/screenshots/paired-toolkit/nodes2-pair-library-preview.png new file mode 100644 index 0000000..c5bf104 Binary files /dev/null and b/docs/screenshots/paired-toolkit/nodes2-pair-library-preview.png differ diff --git a/docs/screenshots/paired-toolkit/nodes2-pair-light.png b/docs/screenshots/paired-toolkit/nodes2-pair-light.png new file mode 100644 index 0000000..03b08c2 Binary files /dev/null and b/docs/screenshots/paired-toolkit/nodes2-pair-light.png differ diff --git a/docs/screenshots/paired-toolkit/nodes2-pair-narrow.png b/docs/screenshots/paired-toolkit/nodes2-pair-narrow.png new file mode 100644 index 0000000..417af7c Binary files /dev/null and b/docs/screenshots/paired-toolkit/nodes2-pair-narrow.png differ diff --git a/py/api/pairs.py b/py/api/pairs.py index 5dfbce7..775f0e8 100644 --- a/py/api/pairs.py +++ b/py/api/pairs.py @@ -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: