diff --git a/database/library.py b/database/library.py new file mode 100644 index 0000000..41a3ca1 --- /dev/null +++ b/database/library.py @@ -0,0 +1,297 @@ +"""Read-only positive-parent views over existing prompt and pair tables.""" + +_ELIGIBLE = """(EXISTS(SELECT 1 FROM prompt_pairs ep WHERE ep.positive_prompt_id=p.id) + OR EXISTS(SELECT 1 FROM generated_images eg WHERE eg.prompt_id=p.id) + OR NOT EXISTS(SELECT 1 FROM prompt_pairs en WHERE en.negative_prompt_id=p.id))""" +_ORDERS = { + "newest": ("COALESCE(julianday(p.created_at),0)", "DESC"), + "oldest": ("COALESCE(julianday(p.created_at),0)", "ASC"), + "name": ("LOWER(SUBSTR(p.text,1,160))", "ASC"), + "rating": ("COALESCE(p.rating,0)", "DESC"), +} + + +def _escape(value: str) -> str: + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +def _limit(value: int) -> None: + if type(value) is not int or not 1 <= value <= 200: + raise ValueError("limit must be between 1 and 200") + + +def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list]: + if not isinstance(text, str) or len(text) > 500: + raise ValueError("Search text must be a string of at most 500 characters") + if category is not None and (not isinstance(category, str) or len(category) > 200): + raise ValueError("Invalid library category") + if ( + not isinstance(tags, list) + or len(tags) > 30 + or any(not isinstance(tag, str) or not tag or len(tag) > 200 for tag in tags) + ): + raise ValueError("Invalid library tags") + parent, pair, parent_params, pair_params = [], ["pp.archived=0"], [], [] + if text: + parent.append("p.text LIKE ? ESCAPE '\\'") + parent_params.append(f"%{_escape(text)}%") + pair.append( + "(p.text LIKE ? ESCAPE '\\' OR n.text LIKE ? ESCAPE '\\' " + "OR pp.title LIKE ? ESCAPE '\\')" + ) + pair_params.extend([f"%{_escape(text)}%"] * 3) + if category is not None: + parent.append("p.category=?") + pair.append("pp.category=?") + parent_params.append(category) + pair_params.append(category) + for tag in tags: + parent.append( + "EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id " + "WHERE pt.prompt_id=p.id AND t.name=?)" + ) + pair.append( + "EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t ON t.id=pt.tag_id " + "WHERE pt.pair_id=pp.id AND t.name=?)" + ) + parent_params.append(tag) + pair_params.append(tag) + condition = ( + "(" + + (" AND ".join(parent) or "1=1") + + ( + ") OR EXISTS(SELECT 1 FROM prompt_pairs pp " + "LEFT JOIN prompts n ON n.id=pp.negative_prompt_id " + "WHERE pp.positive_prompt_id=p.id AND " + ) + + " AND ".join(pair) + + ")" + ) + return f"({_ELIGIBLE}) AND ({condition})", [*parent_params, *pair_params] + + +class LibraryOperations: + """Group reusable negative variants without changing legacy prompt queries.""" + + def _library_parents( + self, + where: str, + params: list, + suffix: str = "", + sort_expression: str | None = None, + ) -> list[dict]: + conn = self.model.get_connection() + rows = conn.execute( + "SELECT p.id,p.text,p.category,p.rating,p.created_at," + + (f"{sort_expression} AS _sort," if sort_expression else "") + + "(SELECT COUNT(*) FROM prompt_pairs v WHERE v.positive_prompt_id=p.id " + "AND v.archived=0) AS variant_count," + "(SELECT COUNT(*) FROM generated_images g " + "WHERE g.prompt_id=p.id) AS image_count " + "FROM prompts p WHERE " + where + suffix, + params, + ).fetchall() + result = [{**dict(row), "tags": []} for row in rows] + if result: + by_id = {row["id"]: row for row in result} + marks = ",".join("?" for _ in result) + for row in conn.execute( + "SELECT pt.prompt_id,t.name FROM prompt_tags pt " + "JOIN tags t ON t.id=pt.tag_id " + f"WHERE pt.prompt_id IN ({marks}) ORDER BY t.name", + list(by_id), + ): + by_id[row["prompt_id"]]["tags"].append(row["name"]) + return result + + def list_library( + self, + *, + text: str = "", + category: str | None = None, + tags: list[str] | None = None, + sort: str = "newest", + limit: int = 40, + after: dict | None = None, + ) -> dict: + _limit(limit) + if sort not in _ORDERS: + raise ValueError("Unknown library sort order") + where, params = _filter(text, category, [] if tags is None else tags) + conn = self.model.get_connection() + ceiling = conn.execute("SELECT COALESCE(MAX(id),0) FROM prompts").fetchone()[0] + if after is not None: + if ( + not isinstance(after, dict) + or set(after) != {"ceiling", "value", "id"} + or type(after["ceiling"]) is not int + or type(after["id"]) is not int + ): + raise ValueError("Invalid library cursor") + ceiling = after["ceiling"] + where += " AND p.id<=?" + params.append(ceiling) + total = conn.execute( + "SELECT COUNT(*) FROM prompts p WHERE " + where, params + ).fetchone()[0] + expression, direction = _ORDERS[sort] + if after is not None: + operator = ">" if direction == "ASC" else "<" + where += ( + f" AND ({expression}{operator}? OR " + f"({expression}=? AND p.id{operator}?))" + ) + params.extend([after["value"], after["value"], after["id"]]) + rows = self._library_parents( + where, + [*params, limit + 1], + f" ORDER BY {expression} {direction},p.id {direction} LIMIT ?", + sort_expression=expression, + ) + has_more = len(rows) > limit + rows = rows[:limit] + next_key = None + if has_more: + next_key = { + "ceiling": ceiling, + "value": rows[-1]["_sort"], + "id": rows[-1]["id"], + } + for row in rows: + row.pop("_sort") + return { + "items": rows, + "total": total, + "has_more": has_more, + "next_key": next_key, + } + + def get_library_positive( + self, + positive_id: int, + *, + variant_limit: int = 40, + variant_after: int | None = None, + ) -> dict | None: + _limit(variant_limit) + if variant_after is not None and ( + type(variant_after) is not int or variant_after <= 0 + ): + raise ValueError("Invalid variant cursor") + rows = self._library_parents(_ELIGIBLE + " AND p.id=?", [positive_id]) + if not rows: + return None + conn = self.model.get_connection() + where = "pp.positive_prompt_id=? AND pp.archived=0" + params = [positive_id] + variant_total = conn.execute( + "SELECT COUNT(*) FROM prompt_pairs pp WHERE " + where, params + ).fetchone()[0] + if variant_after is not None: + where += " AND pp.id variant_limit + variants = variants[:variant_limit] + by_id = {row["id"]: row for row in variants} + for row in variants: + row["tags"] = [] + row["explicit_empty"] = bool(row["explicit_empty"]) + if by_id: + marks = ",".join("?" for _ in by_id) + for row in conn.execute( + "SELECT pt.pair_id,t.name FROM prompt_pair_tags pt " + "JOIN tags t ON t.id=pt.tag_id " + f"WHERE pt.pair_id IN ({marks}) ORDER BY t.name", + list(by_id), + ): + by_id[row["pair_id"]]["tags"].append(row["name"]) + unassigned = conn.execute( + "SELECT COUNT(*) FROM generated_images g WHERE g.prompt_id=? " + "AND NOT EXISTS(SELECT 1 FROM image_prompt_pairs ip " + "WHERE ip.image_id=g.id)", + (positive_id,), + ).fetchone()[0] + return { + "positive": rows[0], + "variants": variants, + "unassigned_image_count": unassigned, + "variant_total": variant_total, + "variants_has_more": variants_has_more, + "variants_next_key": variants[-1]["id"] if variants_has_more else None, + } + + def get_library_options(self) -> dict: + rows = ( + self.model.get_connection() + .execute( + "SELECT category FROM (SELECT p.category AS category " + "FROM prompts p WHERE " + + _ELIGIBLE + + (" UNION SELECT category FROM prompt_pairs " "WHERE archived=0) ") + + "WHERE category IS NOT NULL AND TRIM(category)!='' " + "ORDER BY category COLLATE NOCASE,category" + ) + .fetchall() + ) + return {"categories": [row["category"] for row in rows]} + + def list_library_images( + self, + positive_id: int, + *, + pair_id: int | None = None, + limit: int = 40, + after: int | None = None, + ) -> dict: + _limit(limit) + conn = self.model.get_connection() + if ( + pair_id is not None + and not conn.execute( + "SELECT 1 FROM prompt_pairs WHERE id=? " + "AND positive_prompt_id=? AND archived=0", + (pair_id, positive_id), + ).fetchone() + ): + raise ValueError("Variant does not belong to this positive prompt") + where, params = "g.prompt_id=?", [positive_id] + if pair_id is not None: + where += ( + " AND EXISTS(SELECT 1 FROM image_prompt_pairs ip " + "WHERE ip.image_id=g.id AND ip.pair_id=?)" + ) + params.append(pair_id) + total = conn.execute( + "SELECT COUNT(*) FROM generated_images g WHERE " + where, params + ).fetchone()[0] + if after is not None: + if type(after) is not int or after <= 0: + raise ValueError("Invalid image cursor") + where += " AND g.id limit, + "next_key": rows[limit - 1]["id"] if len(rows) > limit else None, + } diff --git a/database/operations.py b/database/operations.py index 732d49b..588c07e 100644 --- a/database/operations.py +++ b/database/operations.py @@ -10,6 +10,7 @@ from typing import Optional, List, Dict, Any from .models import PromptModel, normalize_image_path, utc_now_iso from .pairs import PairDependencyError, PairOperations +from .library import LibraryOperations # Import logging system try: @@ -147,7 +148,7 @@ def _coerce_file_info(metadata: Any) -> tuple: ) -class PromptDatabase(PairOperations): +class PromptDatabase(PairOperations, LibraryOperations): """Database operations class for managing prompts.""" def __init__(self, db_path: Optional[str] = None): diff --git a/docs/application-redesign.md b/docs/application-redesign.md index dc9013c..f1fa0f0 100644 --- a/docs/application-redesign.md +++ b/docs/application-redesign.md @@ -8,7 +8,9 @@ code. This plan proposes implementation order and acceptance gates. - Foundation merged in [#198](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/198). - [#200](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/200) delivers native Images browsing, physical-file details, infinite scroll, Grid/List/Masonry, Viewer.js and video playback. The workspace opens on Images; original application entry points remain available. -- Prompt metadata, role-aware sending, tagging/review, library/history and System consolidation remain subsequent slices under #196. The physical-file inspector does not display prompt text until the shared prompt inspector/send flow is implemented. +- Shared role-aware sending and the physical-file prompt inspector merged in [#203](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/203) and [#206](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/206). +- [#208](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/208) adds the native Prompt Library: one positive parent, saved negative variants, full selected text and a shared filtered image gallery. It does not invent chronological runs from counters or scan dates. [Durable history storage](prompt-history-storage.md) must be designed and tested before the execution timeline. +- Execution history, tagging/review and System consolidation remain subsequent slices under #196. ## Scope and compatibility diff --git a/docs/prompt-history-storage.md b/docs/prompt-history-storage.md new file mode 100644 index 0000000..79b86ac --- /dev/null +++ b/docs/prompt-history-storage.md @@ -0,0 +1,46 @@ +# Durable Prompt History + +Status: future proposal tracked in [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209); the native library does not migrate execution storage. + +## What the current database proves + +- `prompts.run_count` and `prompt_pairs.run_count` are aggregate usage counters. + `utils/usage_tracking.py` counts known recipes when ComfyUI dequeues a job, + including cached node executions. Dequeue does not establish successful completion. +- `last_used_at` records the latest aggregate update. The legacy prompt migration + estimated usage from linked images, with a minimum of one; no flag distinguishes + those estimates from newer recorded uses (`database/models.py`). +- `generated_images` identifies linked files; distinct image IDs are image counts, + never run counts. Its default `generation_time` is database insertion time. +- `image_prompt_pairs` stores immutable association provenance. More than one + provenance row can describe the same image and recipe. Its `created_at` is the + association time, potentially an old-image scan. +- `prompt_pairs.created_at` is the saved recipe date. Execution PNG evidence in + `utils/pair_execution.py` records distinct text/composition values without a + durable job ID or timestamp. `PromptTracker` execution IDs remain process-local. + +The current UI can show saved negative variants, linked images and aggregate uses +with an estimate caveat. It cannot honestly show chronological execution dots, +repeat-run branches or completed-run counts. + +## Smallest useful future migration + +Add a durable `run_events` table using a stable host-session and ComfyUI job ID +plus recipe identity. Create started events at dequeue, rather than node encoding, +so cached executions remain visible. Retain nullable recipe references and immutable +raw/effective positive, negative and composition snapshots. Record completion, +failure and interruption only when observed; otherwise retain unknown status. + +Add a deduplicated `run_images(run_id, image_id)` relationship. Multiple output +images can belong to one run, and one physical file must not manufacture multiple +runs through repeated metadata evidence. Preserve aggregate counters and existing +image provenance. Do not manufacture historical events from counters, file dates +or scans. + +## Required migration and integration tests + +Verify old databases upgrade transactionally and idempotently, with rollback on +failure; unchanged counters and associations; cached repeat executions; failed or +cancelled jobs; batched outputs; duplicate callbacks; process restart; unknown +connected inputs; and metadata rescans that create no execution events. Cover real +Classic and Nodes 2.0 hosts before exposing run chronology. diff --git a/docs/screenshots/208/README.md b/docs/screenshots/208/README.md new file mode 100644 index 0000000..bb23ce6 --- /dev/null +++ b/docs/screenshots/208/README.md @@ -0,0 +1,13 @@ +# Native Library visual evidence + +Captured from the production workspace using disposable synthetic browser-test +fixtures. No personal images, library, or running ComfyUI state was accessed. +Solid-color media are intentional test images. + +- `library-dark.png`: grouped positive shelf, dark appearance. +- `library-light.png`: the same shelf, light appearance. +- `history-selected-negative.png`: selected negative directly below its positive, + saved-variant branches and the filtered shared gallery. +- `history-mobile.png`: the same history at a 390-pixel viewport. + +These screenshots document implemented behavior, rather than a design mockup. diff --git a/docs/screenshots/208/history-mobile.png b/docs/screenshots/208/history-mobile.png new file mode 100644 index 0000000..d3302ec Binary files /dev/null and b/docs/screenshots/208/history-mobile.png differ diff --git a/docs/screenshots/208/history-selected-negative.png b/docs/screenshots/208/history-selected-negative.png new file mode 100644 index 0000000..58a4b28 Binary files /dev/null and b/docs/screenshots/208/history-selected-negative.png differ diff --git a/docs/screenshots/208/library-dark.png b/docs/screenshots/208/library-dark.png new file mode 100644 index 0000000..7ecb887 Binary files /dev/null and b/docs/screenshots/208/library-dark.png differ diff --git a/docs/screenshots/208/library-light.png b/docs/screenshots/208/library-light.png new file mode 100644 index 0000000..2245080 Binary files /dev/null and b/docs/screenshots/208/library-light.png differ diff --git a/py/api/__init__.py b/py/api/__init__.py index d6b7bf2..c085e63 100644 --- a/py/api/__init__.py +++ b/py/api/__init__.py @@ -23,6 +23,7 @@ from PIL import Image from .prompts import PromptRoutesMixin from .pairs import PairRoutesMixin +from .library import LibraryRoutesMixin from .images import ImageRoutesMixin from .admin import AdminRoutesMixin from .logging_routes import LoggingRoutesMixin @@ -243,6 +244,7 @@ async def _gzip_middleware(request, handler): class PromptManagerAPI( PairRoutesMixin, + LibraryRoutesMixin, PromptRoutesMixin, ImageRoutesMixin, AdminRoutesMixin, @@ -399,6 +401,7 @@ class PromptManagerAPI( # ── Register domain-specific routes from mixins ─────────────── self._register_pair_routes(routes) + self._register_library_routes(routes) self._register_prompt_routes(routes) self._register_image_routes(routes) self._register_admin_routes(routes) diff --git a/py/api/library.py b/py/api/library.py new file mode 100644 index 0000000..2310670 --- /dev/null +++ b/py/api/library.py @@ -0,0 +1,186 @@ +"""Read-only native library with bounded, filter-scoped continuation cursors.""" + +import base64 +import binascii +import hashlib +import hmac +import json +import os +from pathlib import Path + +from aiohttp import web + +from .images import _canonical, _media_access_error +from .pairs import pair_response + +_SECRET = os.urandom(32) + + +def _scope(value: dict) -> str: + return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest() + + +def _encode(key: object, scope: str) -> str | None: + if key is None: + return None + payload = json.dumps( + {"scope": scope, "key": key}, separators=(",", ":"), allow_nan=False + ).encode() + signature = hmac.new(_SECRET, payload, hashlib.sha256).digest() + return base64.urlsafe_b64encode(signature + payload).decode().rstrip("=") + + +def _decode(token: str | None, scope: str) -> object: + if token is None: + return None + try: + if not 1 <= len(token) <= 4096: + raise ValueError + decoded = base64.b64decode( + token + "=" * (-len(token) % 4), altchars=b"-_", validate=True + ) + signature, payload = decoded[:32], decoded[32:] + if not hmac.compare_digest( + signature, hmac.new(_SECRET, payload, hashlib.sha256).digest() + ): + raise ValueError + value = json.loads(payload) + if set(value) != {"scope", "key"} or value["scope"] != scope: + raise ValueError + return value["key"] + except (ValueError, TypeError, binascii.Error, UnicodeError): + raise ValueError("Invalid library cursor; refresh the library") from None + + +def _positive_id(request: web.Request) -> int: + value = int(request.match_info["positive_id"]) + if not 1 <= value <= 2**63 - 1: + raise ValueError("Invalid positive prompt ID") + return value + + +def _page_limit(request: web.Request, key: str = "limit") -> int: + value = int(request.query.get(key, "40")) + if not 1 <= value <= 200: + raise ValueError("limit must be between 1 and 200") + return value + + +def _missing() -> web.Response: + return web.json_response( + {"success": False, "error": "Positive prompt not found"}, status=404 + ) + + +class LibraryRoutesMixin: + def _register_library_routes(self, routes: web.RouteTableDef) -> None: + routes.get("/prompt_manager/library")(self.list_library) + routes.get("/prompt_manager/library/options")(self.get_library_options) + routes.get("/prompt_manager/library/{positive_id}")(self.get_library_positive) + routes.get("/prompt_manager/library/{positive_id}/images")( + self.get_library_images + ) + + def _library_media(self, records: list[dict], roots: list[Path]) -> list[dict]: + page = [] + for record in records: + try: + raw_path = Path(record["image_path"]) + if not raw_path.is_absolute(): + continue + path = _canonical(raw_path) + denied = _media_access_error(path, roots) + if denied is not None or not path.is_file(): + continue + for index, root in enumerate(roots): + canonical_root = _canonical(root) + if path.is_relative_to(canonical_root): + page.append((path, 0, canonical_root, index)) + break + except (OSError, ValueError, RuntimeError): + continue + return self._format_workspace_page(page) + + @pair_response + async def get_library_options(self, request: web.Request) -> web.Response: + result = await self._run_in_executor(self.db.get_library_options) + return web.json_response({"success": True, **result}) + + @pair_response + async def list_library(self, request: web.Request) -> web.Response: + options = { + "text": request.query.get("text", ""), + "category": request.query.get("category"), + "tags": sorted(set(request.query.getall("tag", []))), + "sort": request.query.get("sort", "newest"), + } + scope = _scope({"kind": "parents", **options}) + result = await self._run_in_executor( + self.db.list_library, + **options, + limit=_page_limit(request), + after=_decode(request.query.get("cursor"), scope), + ) + roots = list(self._get_all_output_dirs()) + + def previews() -> None: + for parent in result["items"]: + images = self.db.list_library_images(parent["id"], limit=2) + parent["previews"] = self._library_media(images["images"], roots) + + await self._run_in_executor(previews) + result["next_cursor"] = _encode(result.pop("next_key"), scope) + return web.json_response({"success": True, **result}) + + @pair_response + async def get_library_positive(self, request: web.Request) -> web.Response: + positive = _positive_id(request) + scope = _scope({"kind": "variants", "positive": positive}) + result = await self._run_in_executor( + self.db.get_library_positive, + positive, + variant_limit=_page_limit(request, "variant_limit"), + variant_after=_decode(request.query.get("variant_cursor"), scope), + ) + if result is None: + return _missing() + roots = list(self._get_all_output_dirs()) + records = await self._run_in_executor( + self.db.list_library_images, result["positive"]["id"], limit=2 + ) + result["positive"]["previews"] = await self._run_in_executor( + self._library_media, records["images"], roots + ) + result["variants_next_cursor"] = _encode(result.pop("variants_next_key"), scope) + return web.json_response({"success": True, **result}) + + @pair_response + async def get_library_images(self, request: web.Request) -> web.Response: + positive = _positive_id(request) + pair = request.query.get("pair_id") + pair = int(pair) if pair is not None else None + if pair is not None and not 1 <= pair <= 2**63 - 1: + raise ValueError("Invalid variant ID") + roots = list(self._get_all_output_dirs()) + scope = _scope( + { + "kind": "images", + "positive": positive, + "pair": pair, + "roots": [str(_canonical(root)) for root in roots], + } + ) + if await self._run_in_executor(self.db.get_library_positive, positive) is None: + return _missing() + result = await self._run_in_executor( + self.db.list_library_images, + positive, + pair_id=pair, + limit=_page_limit(request), + after=_decode(request.query.get("cursor"), scope), + ) + result["images"] = await self._run_in_executor( + self._library_media, result["images"], roots + ) + result["next_cursor"] = _encode(result.pop("next_key"), scope) + return web.json_response({"success": True, **result}) diff --git a/tests/browser/server.py b/tests/browser/server.py index 1d3cd4d..dc1e783 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -82,6 +82,9 @@ def create_app(root: Path) -> web.Application: extra_images: int = 0, include_video: bool = False, prompt_inspector: bool = False, + native_library: bool = False, + extra_prompts: int = 0, + extra_variants: int = 0, ) -> None: with api.db.model.get_connection() as conn: for table in ( @@ -98,7 +101,14 @@ def create_app(root: Path) -> web.Application: shutil.rmtree(root / name) (root / name).mkdir() for index, (directory, text, tag) in enumerate( - [("output", "Cat portrait", "cats"), ("input", "Dog landscape", "dogs")] + [ + ("output", "Cat portrait", "cats"), + ( + "input", + "Cat portrait" if native_library else "Dog landscape", + "dogs", + ), + ] ): image_path = root / directory / "shared" / "same.png" image_path.parent.mkdir(exist_ok=True) @@ -111,7 +121,7 @@ def create_app(root: Path) -> web.Application: ) with Image.new("RGB", (64, 64), (index * 255, 64, 128)) as image: image.save(image_path, pnginfo=info) - if prompt_inspector: + if prompt_inspector or native_library: graph = { "1": {"class_type": "CLIPTextEncode", "inputs": {"text": text}}, "2": { @@ -143,6 +153,48 @@ def create_app(root: Path) -> web.Application: extra.parent.mkdir(exist_ok=True) shutil.copyfile(image_path, extra) api.db.link_image_to_prompt(pid, str(extra)) + if native_library: + positive = api.db.get_prompt_by_hash(generate_prompt_hash("Cat portrait")) + positive_id = positive["id"] + api.db.update_prompt_metadata( + positive_id, category="Animals", tags=["cats", "studio"] + ) + variants = [] + for index, negative in enumerate( + ["blurry, watermark", "low quality", "low contrast", ""] + ): + pair_id = api.db.save_prompt_pair( + "Cat portrait", + negative, + title=f"Portrait variant {index + 1}", + category="Portraits" if index == 0 else "Experiments", + tags=["studio"] if index == 0 else ["outdoors"], + ) + variants.append(pair_id) + with api.db.model.get_connection() as conn: + images = conn.execute( + "SELECT id FROM generated_images WHERE prompt_id=? ORDER BY id", + (positive_id,), + ).fetchall() + for index, image in enumerate(images[:2]): + api.db.link_image_to_pair( + variants[index], image["id"], {"source": "fixture", "anchor": index} + ) + api.db.link_image_to_pair( + variants[0], images[0]["id"], {"source": "fixture second evidence"} + ) + api.db.record_prompt_pair_use(variants[0], times=3) + for number in range(extra_variants): + api.db.save_prompt_pair("Cat portrait", f"Negative extra {number:04}") + legacy = root / "output" / "legacy-unassigned.png" + shutil.copyfile(root / "output" / "shared" / "same.png", legacy) + api.db.link_image_to_prompt(positive_id, str(legacy)) + api.db.save_prompt("Dog landscape", category="Animals", tags=["dogs"]) + api.db.save_prompt_pair("Dual role portrait", "Cat portrait") + for number in range(extra_prompts): + api.db.save_prompt( + f"Library extra {number:04}", category="Bulk", tags=["bulk"] + ) if prompt_inspector: for name, parameters in ( ( @@ -174,15 +226,33 @@ def create_app(root: Path) -> web.Application: extra = data.get("extra_images", 0) include_video = data.get("include_video", False) prompt_inspector = data.get("prompt_inspector", False) + native_library = data.get("native_library", False) + extra_prompts = data.get("extra_prompts", 0) + extra_variants = data.get("extra_variants", 0) if ( isinstance(extra, bool) or not isinstance(extra, int) or not 0 <= extra <= 200 or not isinstance(include_video, bool) or not isinstance(prompt_inspector, bool) + or not isinstance(native_library, bool) + or isinstance(extra_prompts, bool) + or not isinstance(extra_prompts, int) + or not 0 <= extra_prompts <= 200 + or isinstance(extra_variants, bool) + or not isinstance(extra_variants, int) + or not 0 <= extra_variants <= 200 ): raise web.HTTPBadRequest() - await api._run_in_executor(seed, extra, include_video, prompt_inspector) + await api._run_in_executor( + seed, + extra, + include_video, + prompt_inspector, + native_library, + extra_prompts, + extra_variants, + ) return web.json_response({"success": True}) @routes.post("/__test__/add-nested-image") diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs new file mode 100644 index 0000000..182f95b --- /dev/null +++ b/tests/browser/workspace-library.spec.mjs @@ -0,0 +1,170 @@ +import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs'; +import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; + +const route='/prompt_manager/workspace#/library'; +const cards=page=>page.locator('article[data-prompt-id]'); +const history=page=>page.getByRole('region',{name:'Prompt history',exact:true}); +const saved=page=>history(page).getByRole('region',{name:'Saved prompt texts',exact:true}); +const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true}); +const media=page=>gallery(page).locator('article[data-image-key]'); + +async function openHistory(page,text='Cat portrait') { + await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click(); + await expect(history(page)).toBeVisible(); +} +async function selectVariant(page,index) { + const choice=history(page).getByRole('button',{name:`Select negative variant ${index+1}`,exact:true}); + await choice.click();await expect(choice).toHaveAttribute('aria-pressed','true'); +} + +test.beforeEach(async({page,request,browserName})=>{ + await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true}})).toBeOK(); + page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message)); +}); +test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);}); + +test('library groups reusable negatives under one positive without exposing negative-only components as parents',async({page})=>{ + await page.goto(route);await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library'); + await expect(cards(page)).toHaveCount(3); + await expect(cards(page).filter({has:page.getByText('Cat portrait',{exact:true})})).toHaveCount(1); + await expect(cards(page).filter({has:page.getByText('Dual role portrait',{exact:true})})).toHaveCount(1); + await expect(cards(page).filter({hasText:'low contrast'})).toHaveCount(0); + const cat=cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}); + await expect(cat).toContainText(/4.*variants/i);await expect(cat).toContainText(/3.*images/i); + await expect(page.locator('#workspace-main')).not.toContainText(/\b\d+ (?:tracked )?runs\b/i); +}); + +test('history displays the selected full negative beneath its positive and filters one shared physical gallery',async({page,request})=>{ + const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait'); + const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json(); + await page.goto(route);await openHistory(page);await expect(media(page)).toHaveCount(3); + for(const negative of ['blurry, watermark','low quality','low contrast','']) { + const index=data.variants.findIndex(variant=>variant.negative_text===negative);await selectVariant(page,index); + const neg=saved(page).getByRole('region',{name:'Negative prompt',exact:true}); + await expect(neg).toContainText(negative||'Empty negative prompt'); + await expect(media(page)).toHaveCount(negative==='blurry, watermark'||negative==='low quality'?1:0); + const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true}); + await expect(positive).toContainText('Cat portrait'); + const order=await positive.evaluate((element)=>element.compareDocumentPosition(element.parentElement.querySelector('[aria-label="Negative prompt"]'))); + expect(order&4).toBeTruthy(); + } + await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(media(page)).toHaveCount(3); + expect(await media(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png'])); +}); + +test('search, category and tag filters preserve their constraints and return standalone positives',async({page})=>{ + await page.goto(route);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Dog landscape'); + await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape'); + await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill(''); + await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Portraits'); + await page.getByRole('textbox',{name:'Tags',exact:true}).fill('studio');await expect(cards(page)).toHaveCount(1); + await page.getByRole('textbox',{name:'Tags',exact:true}).fill('outdoors');await expect(cards(page)).toHaveCount(0); + await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('');await expect(cards(page)).toHaveCount(1); +}); + +test('library infinite loading keeps unique parents and keyboard-accessible history controls',async({page,request})=>{ + await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:125}})).toBeOK(); + await page.addInitScript(()=>{window.IntersectionObserver=undefined;}); + await page.goto(route);await expect(cards(page).first()).toBeVisible(); + const more=page.getByRole('button',{name:'Load more prompts',exact:true}); + expect(await cards(page).count()).toBeLessThan(128); + let hasMore=true; + while(hasMore) { + const nextPage=page.waitForResponse(response=>{const url=new URL(response.url());return url.pathname==='/prompt_manager/library'&&Boolean(url.searchParams.get('cursor'));}); + await more.focus();await page.keyboard.press('Enter'); + const appended=await (await nextPage).json();expect(appended.items.length).toBeGreaterThan(0);hasMore=appended.has_more; + if(hasMore)await expect(more).toHaveAttribute('aria-disabled','false'); + } + await expect(more).toBeHidden();expect(await cards(page).count()).toBeLessThan(80); + const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length); + await expect(page.getByRole('button',{name:/next page|previous page/i})).toHaveCount(0); + const firstId=await cards(page).first().getAttribute('data-prompt-id'); + const explore=page.locator(`article[data-prompt-id="${firstId}"]`).getByRole('link',{name:'Explore history',exact:true}); + await explore.focus();await expect(explore).toBeFocused();await page.keyboard.press('Enter');await expect(history(page)).toBeVisible(); +}); + +test('failed library reads retry while late search responses cannot overwrite newer results',async({page})=>{ + let fail=true;let release;const hold=new Promise(resolve=>{release=resolve;}); + await page.route('**/prompt_manager/library?*',async request=>{ + const url=new URL(request.request().url()); + if(fail)return request.fulfill({status:503,json:{success:false,error:'Unavailable'}}); + if(url.searchParams.get('text')==='Cat')await hold; + await request.continue().catch(()=>{}); + }); + await page.goto(route);await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeVisible(); + fail=false;await page.getByRole('button',{name:'Retry prompt library',exact:true}).click();await expect(cards(page)).toHaveCount(3); + const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});const oldRequest=page.waitForRequest(request=>new URL(request.url()).searchParams.get('text')==='Cat');await search.fill('Cat');await oldRequest;await search.fill('Dog'); + await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape'); +}); + +test('returning from history restores library context and mobile content stays inside the viewport',async({page})=>{ + await page.setViewportSize({width:390,height:844});await page.goto(route); + await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Animals'); + await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Cat portrait');await expect(cards(page)).toHaveCount(1); + await openHistory(page);await selectVariant(page,0); + await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait'); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true); + await history(page).getByRole('link',{name:'Back to library',exact:true}).click(); + await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1); + await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click(); + await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0); +}); + +test('history detail failures retry and returning to the list ignores late detail responses',async({page})=>{ + let fail=true;let release;const hold=new Promise(resolve=>{release=resolve;}); + await page.route(/\/prompt_manager\/library\/\d+(?:\?|$)/,async route=>{ + if(fail)return route.fulfill({status:503,json:{success:false,error:'Unavailable'}}); + await hold;await route.continue().catch(()=>{}); + }); + try { + await page.goto(route);await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click(); + await expect(page.getByRole('button',{name:'Retry prompt history',exact:true})).toBeVisible(); + fail=false;await page.getByRole('button',{name:'Retry prompt history',exact:true}).click(); + await page.getByRole('link',{name:'Back to library',exact:true}).click();release(); + await expect(cards(page)).toHaveCount(3);await expect(history(page)).toHaveCount(0); + } finally {release();} +}); + +test('history shares real image metadata, vendored Viewer and the acknowledged prompt sender',async({page,context,request})=>{ + const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview'); + await host.evaluate(async()=>{ + const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js'); + const target={id:'pair-1',label:'Library destination',type:'PromptManagerPair',roles:['positive','negative']}; + window.libraryApplied=[]; + window.libraryHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Library ComfyUI',adapter:{ + listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive??'Previous positive'}},snapshot:roles}), + apply:roles=>{window.libraryApplied.push(roles);return{targetId:target.id,values:roles};}, + }}); + }); + try { + await page.goto(route); + const parentId=await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getAttribute('data-prompt-id'); + const recipe=await(await request.get(`/prompt_manager/library/${parentId}`)).json(); + await openHistory(page);await selectVariant(page,recipe.variants.findIndex(variant=>variant.negative_text==='blurry, watermark')); + const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent(); + await saved(page).getByRole('button',{name:'Send prompt pair',exact:true}).click(); + const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); + const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true}); + await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({label:'Library ComfyUI'}); + await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Library destination'}); + await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await expect(sender).toContainText(chosenNegative||'(empty)'); + await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.'); + expect(await host.evaluate(()=>window.libraryApplied)).toEqual([{positive:'Cat portrait',negative:chosenNegative}]); + await sender.getByRole('button',{name:'Close',exact:true}).click(); + await media(page).first().getByRole('button',{name:'Open same.png',exact:true}).click(); + const inspector=page.getByRole('region',{name:'Image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait'); + await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible(); + await page.keyboard.press('Escape');await expect(page.locator('dialog[open]')).toHaveCount(0); + await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();await expect(page.locator('.viewer-container')).toHaveCount(0); + } finally {await host.evaluate(()=>window.libraryHost.dispose());await host.close();} +}); + +test('large variant histories append bounded pages without replacing the selected negative',async({page,request})=>{ + await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_variants:41}})).toBeOK(); + await page.goto(route);await openHistory(page); + const variants=history(page).getByRole('button',{name:/^Select negative variant \d+$/});await expect(variants).toHaveCount(40); + await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent(); + await history(page).getByRole('button',{name:'Load more negative variants',exact:true}).click();await expect(variants).toHaveCount(45); + await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true'); + await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(chosenNegative); +}); diff --git a/tests/comfyui/library-sender.spec.mjs b/tests/comfyui/library-sender.spec.mjs new file mode 100644 index 0000000..9d8285f --- /dev/null +++ b/tests/comfyui/library-sender.spec.mjs @@ -0,0 +1,46 @@ +import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; +import {installHostHooks} from './host-fixture.mjs'; + +installHostHooks(test,['PromptManagerPair']); + +test('native library sends the selected saved pair into a real ComfyUI destination',async({page,context,request},testInfo)=>{ + const positive=`Native library host ${testInfo.project.name}`;const negative=`Saved negative ${testInfo.project.name}`; + const response=await request.post('/prompt_manager/pairs',{data:{positive_text:positive,negative_text:negative,title:'Host saved pair',category:'Native host',tags:['library-host']}}); + expect(response.ok()).toBe(true); + const id=await page.evaluate(async()=>{ + const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);node.setSize([700,1000]); + app.canvas.ds.scale=.8;app.canvas.centerOnNode(node);return node.id; + }); + const editor=page.locator('.pm-pair');await editor.getByRole('textbox',{name:'Positive prompt',exact:true}).fill('Original positive'); + await editor.getByRole('textbox',{name:'Negative prompt',exact:true}).fill('Original negative'); + await editor.getByText('Composition · prepend / append',{exact:true}).click(); + await editor.getByRole('textbox',{name:'Prepend',exact:true}).fill('Keep trigger');await editor.getByRole('textbox',{name:'Append',exact:true}).fill('Keep detail'); + await page.evaluate(async()=>{ + const {app}=await import('/scripts/app.js');window.libraryEffects={queue:0,load:0}; + for(const [method,key]of [['queuePrompt','queue'],['loadGraphData','load']]){const original=app[method];app[method]=function(...args){window.libraryEffects[key]++;return original.apply(this,args);};} + }); + const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message)); + try { + await workspace.goto('/prompt_manager/workspace#/library'); + await workspace.getByRole('searchbox',{name:'Search prompts',exact:true}).fill(positive); + await workspace.locator('article[data-prompt-id]').filter({has:workspace.getByText(positive,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click(); + const history=workspace.getByRole('region',{name:'Prompt history',exact:true}); + await history.getByRole('button',{name:'Select negative variant 1',exact:true}).click(); + await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative); + await history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('button',{name:'Send prompt pair',exact:true}).click(); + const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); + const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({index:1}); + await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1}); + await sender.getByRole('button',{name:'Preview changes',exact:true}).click(); + await expect(sender).toContainText(positive);await expect(sender).toContainText(negative);await expect(sender).toContainText(`Keep trigger ${positive} Keep detail`); + await expect(editor.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Original positive'); + await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.'); + await expect(editor.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue(positive);await expect(editor.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue(negative); + const state=await page.evaluate(async id=>{ + const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);const serialized=node.serialize(); + return{inputs:(await app.graphToPrompt()).output[String(id)].inputs,serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),effects:window.libraryEffects}; + },id); + for(const field of ['inputs','serialized'])expect(state[field]).toMatchObject({text:positive,negative_text:negative,prepend_text:'Keep trigger',append_text:'Keep detail'}); + expect(state.effects).toEqual({queue:0,load:0});expect(errors).toEqual([]); + } finally {await workspace.close();} +}); diff --git a/tests/js/prompt-blocks.test.js b/tests/js/prompt-blocks.test.js new file mode 100644 index 0000000..566871a --- /dev/null +++ b/tests/js/prompt-blocks.test.js @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createScope} from '../../web/js/shared/toolkit/core.js'; +import * as module from '../../web/js/workspace/prompt-inspector.mjs'; + +function dom() { + class Element { + constructor(tag){this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this.classList={add:()=>{}};} + append(...children){for(const child of children){child.parent=this;this.children.push(child);}} + replaceChildren(...children){this.children=[];this.append(...children);} + setAttribute(key,value){this.attributes[key]=value;} + addEventListener(type,listener,{signal}={}){this.listeners.set(type,listener);signal?.addEventListener('abort',()=>this.listeners.delete(type),{once:true});} + closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&Object.hasOwn(this.dataset,'sendRole'))return this;if(selector==='[data-copy-role]'&&Object.hasOwn(this.dataset,'copyRole'))return this;if(selector==='[data-retry-prompts]'&&Object.hasOwn(this.dataset,'retryPrompts'))return this;return this.parent?.closest(selector);} + click(target){this.listeners.get('click')?.({target});} + } + const previous=global.document;global.document={createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag)}; + const walk=(root,predicate)=>[...(predicate(root)?[root]:[]),...root.children.flatMap(child=>walk(child,predicate))]; + return {Element,walk,restore:()=>{global.document=previous;}}; +} +const record=text=>({status:'resolved',text,source:'library'}); +test('shared prompt blocks send complete selected library values and explicit empty negatives',()=>{ + const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[]; + try{ + const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:record('\nfull positive'),negative:{status:'unresolved',message:'Choose a saved negative.'}}}); + assert.ok(f.walk(container,node=>node.textContent==='\nfull positive').length); + assert.equal(f.walk(container,node=>node.dataset.sendRole==='pair').length,0); + view.update({positive:record('Complete positive'),negative:record('')}); + const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair); + assert.deepEqual(sent,[{positive:'Complete positive',negative:''}]); + assert.equal(f.walk(container,node=>node.textContent==='Saved library prompt').length,2); + view.dispose();container.click(pair);assert.equal(sent.length,1); + }finally{scope.dispose();f.restore();} +}); +test('positive-only cards never display or send a hidden negative or pair',()=>{ + const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[]; + try{ + module.mountPromptBlocks(container,{parentScope:scope,roles:['positive'],prompts:{positive:record('positive'),negative:record('hidden')},sender:{open:roles=>sent.push(roles)}}); + assert.equal(f.walk(container,node=>node.attributes['aria-label']==='Negative prompt').length,0); + assert.equal(f.walk(container,node=>node.dataset.sendRole==='pair').length,0); + const button=f.walk(container,node=>node.dataset.sendRole==='positive')[0];container.click(button);assert.deepEqual(sent,[{positive:'positive'}]); + scope.dispose();container.click(button);assert.equal(sent.length,1); + }finally{scope.dispose();f.restore();} +}); + +test('disposed blocks ignore updates and aborted parents never mount content',()=>{ + const f=dom(),scope=createScope(),container=new f.Element('div'); + try{ + const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('original')}}); + view.dispose();view.update({positive:record('late')});assert.equal(f.walk(container,node=>node.textContent==='late').length,0); + scope.dispose();const other=new f.Element('div');module.mountPromptBlocks(other,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('late')}});assert.equal(other.children.length,0); + }finally{scope.dispose();f.restore();} +}); diff --git a/tests/js/workspace-images-lifecycle.test.js b/tests/js/workspace-images-lifecycle.test.js new file mode 100644 index 0000000..a7511e4 --- /dev/null +++ b/tests/js/workspace-images-lifecycle.test.js @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createScope} from '../../web/js/shared/toolkit/core.js'; +import {mountImages} from '../../web/js/workspace/images.mjs'; + +test('Images mounted after its parent is aborted creates no DOM, requests or observers',()=>{ + const parent=createScope();parent.dispose(); + const originals=new Map(['document','window','ResizeObserver','IntersectionObserver'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)])); + const touches=[]; + const forbidden=name=>new Proxy({}, {get(target,key){touches.push(`${name}.${String(key)}`);throw Error(`Disposed mount accessed ${name}.${String(key)}`);}}); + Object.defineProperty(globalThis,'document',{configurable:true,value:forbidden('document')}); + Object.defineProperty(globalThis,'window',{configurable:true,value:forbidden('window')}); + for(const key of ['ResizeObserver','IntersectionObserver']) Object.defineProperty(globalThis,key,{configurable:true,value:class {constructor(){touches.push(key);throw Error('Observer created after parent disposal');}}}); + const api={get(){touches.push('request');assert.fail('Request started after parent disposal');}}; + try { + let mounted; + assert.doesNotThrow(()=>{mounted=mountImages(forbidden('container'),api,parent,{embedded:true,fetchPage(){touches.push('page');assert.fail('Page requested after parent disposal');}});}); + assert.equal(typeof mounted.dispose,'function');mounted.dispose();mounted.dispose(); + assert.deepEqual(touches,[]); + } finally { + for(const [key,descriptor] of originals) {if(descriptor) Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];} + } +}); diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js index aa6c8ac..36b7897 100644 --- a/tests/js/workspace-images-state.test.js +++ b/tests/js/workspace-images-state.test.js @@ -159,3 +159,11 @@ test('malformed restored state is rejected before making requests',async()=>{ {filters:{root:-1},state:base}, {filters:null,state:base}]; for(const restore of invalid) assert.throws(()=>createImageStream({api:{get(){assert.fail('unexpected request');}},restore}),TypeError); }); + +test('custom collection pages reuse image-stream cancellation, cursors and identity checks',async()=>{ + const {createImageStream}=await source;const calls=[]; + const stream=createImageStream({fetchPage:async({parameters,signal})=>{calls.push({parameters,signal});return calls.length===1?page([image('linked.png')],2,'next'):page([image('other.png')],2);},batchSize:1}); + assert.equal(await stream.reset(),1);assert.equal(calls[0].parameters.get('limit'),'1');assert.ok(calls[0].signal instanceof AbortSignal); + assert.equal(await stream.loadMore(),1);assert.equal(calls[1].parameters.get('cursor'),'next');assert.equal(stream.state.loaded,2);stream.dispose(); + assert.throws(()=>createImageStream({api:{get:async()=>{}},fetchPage:'invalid'}),/page/i); +}); diff --git a/tests/js/workspace-library-state.test.js b/tests/js/workspace-library-state.test.js new file mode 100644 index 0000000..7efa59a --- /dev/null +++ b/tests/js/workspace-library-state.test.js @@ -0,0 +1,49 @@ +const test=require('node:test'); +const assert=require('node:assert/strict'); +const source=import('../../web/js/workspace/library-state.mjs'); +const item=id=>({id,text:`Positive ${id}`,category:'Art',tags:['blue'],rating:3,variant_count:2,image_count:4,previews:[]}); +const page=(items,cursor=null)=>({items,total:3,has_more:cursor!==null,next_cursor:cursor}); +test('parent stream builds literal filters and deduplicates stable identities across infinite pages',async()=>{ + const {createLibraryStream}=await source;const calls=[];const pending=[page([item(1),item(2)],'next'),page([item(2),item(3)])];const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}}); + assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'rating'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false); + const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'rating');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose(); +}); +test('reset aborts older requests and late success/failure cannot replace current parents',async()=>{ + const {createLibraryStream}=await source;const requests=[];const s=createLibraryStream({api:{get:(url,{signal})=>new Promise((resolve,reject)=>requests.push({signal,resolve,reject}))}}); + const first=s.reset({text:'first'});const second=s.reset({text:'second'});assert.equal(requests[0].signal.aborted,true);requests[1].resolve(page([item(2)]));await second;requests[0].resolve(page([item(1)]));await first;assert.deepEqual(s.state.items.map(row=>row.id),[2]); + const third=s.reset();s.dispose();requests[2].reject(Error('late'));await third;assert.equal(await s.loadMore(),0); +}); +test('failed pages retain cursor/data and can retry while duplicate load is guarded',async()=>{ + const {createLibraryStream}=await source;let calls=0,release;const s=createLibraryStream({api:{get:async()=>{calls++;if(calls===1)return page([item(1)],'next');if(calls===2)throw Error('offline');return new Promise(resolve=>release=resolve);}}}); + await s.reset();await s.loadMore();assert.equal(s.state.cursor,'next');assert.equal(s.state.items.length,1);assert.match(s.state.error.message,/offline/);const request=s.loadMore();assert.equal(await s.loadMore(),0);release(page([item(2)]));await request;assert.equal(s.state.error,null);assert.equal(s.state.items.length,2);s.dispose(); +}); +test('malformed/nonprogressing pages fail atomically and invalid filters never send requests',async()=>{ + const {createLibraryStream}=await source; + for(const malformed of [{},page([{...item(1),id:-1}]),{...page([]),has_more:true},page([{...item(1),text:null}])]){const s=createLibraryStream({api:{get:async()=>malformed}});await s.reset();assert.ok(s.state.error);assert.equal(s.state.items.length,0);s.dispose();} + const s=createLibraryStream({api:{get:async()=>page([],'same')}});await s.reset();await s.loadMore();assert.ok(s.state.error);s.dispose(); + const invalid=createLibraryStream({api:{get:()=>assert.fail()}});for(const filters of [{sort:'bad'},{text:2},{tags:'a'}])await assert.rejects(invalid.reset(filters));invalid.dispose(); +}); +test('library detail selection preserves full strings and distinguishes all, empty and unknown',async()=>{ + const {libraryPromptRoles}=await source;const detail={positive:item(1),variants:[{id:9,negative_text:'',explicit_empty:true},{id:10,negative_text:'long\nnegative',explicit_empty:false}]}; + const all=libraryPromptRoles(detail,null);assert.equal(all.positive.text,'Positive 1');assert.equal(all.negative.status,'unresolved');assert.equal(libraryPromptRoles(detail,9).negative.text,'');assert.equal(libraryPromptRoles(detail,10).negative.text,'long\nnegative');assert.throws(()=>libraryPromptRoles(detail,999)); +}); +test('detail reads validate identities, abort stale selection and expose retryable errors',async()=>{ + const {createLibraryDetail}=await source;const pending=[],states=[];const loader=createLibraryDetail({api:{get:(url,{signal})=>new Promise((resolve,reject)=>pending.push({url,signal,resolve,reject}))},onChange:state=>states.push(state)}); + const first=loader.load(1);const second=loader.load(2);assert.equal(pending[0].signal.aborted,true);pending[1].resolve({positive:item(2),variants:[],unassigned_image_count:0});await second;pending[0].resolve({positive:item(1),variants:[]});await first;assert.equal(states.at(-1).detail.positive.id,2); + const third=loader.load(3);pending[2].resolve({positive:item(4),variants:[]});await third;assert.ok(states.at(-1).error); + const fourth=loader.load(3);loader.dispose();const count=states.length;pending[3].reject(Error('late'));await fourth;assert.equal(states.length,count);await loader.load(5);assert.equal(pending.length,4); +}); +test('restored shelves keep filters, loaded parents and continuation without a fresh first page',async()=>{ + const {createLibraryStream}=await source;const old=createLibraryStream({api:{get:async()=>page([item(1)],'next')}});await old.reset({text:'retained',sort:'name'});old.dispose();const urls=[]; + const next=createLibraryStream({api:{get:async url=>{urls.push(url);return page([item(2)]);}},restore:{filters:{text:'retained',sort:'name'},state:old.state}});assert.equal(next.state.items.length,1);await next.loadMore();assert.match(urls[0],/text=retained/);assert.match(urls[0],/cursor=next/);assert.equal(next.state.items.length,2);next.dispose(); + assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,cursor:null,hasMore:false}}})); +}); +test('parent shelf window bounds DOM and pins focused rows without changing logical order',async()=>{ + const {libraryWindow}=await source;const first=libraryWindow(500,2,0,900);assert.equal(first[0],0);assert.ok(first.length<24); + const middle=libraryWindow(500,2,50000,900,0);assert.equal(middle[0],0);assert.ok(middle.some(index=>index>150));assert.ok(middle.length<24);assert.deepEqual(middle,[...middle].sort((a,b)=>a-b));assert.deepEqual(libraryWindow(3,2,0,900),[0,1,2]); +}); +test('negative variants append continuously without losing selected identity, and failures retry same cursor',async()=>{ + const {createLibraryDetail}=await source;const calls=[];let count=0;const v=id=>({id,negative_text:id===9?'':'bad',explicit_empty:id===9}); + const loader=createLibraryDetail({api:{get:async url=>{calls.push(url);count++;if(count===2)throw Error('offline');return {positive:item(1),variants:count===1?[v(9)]:[v(9),v(10)],variant_total:2,variants_has_more:count===1,variants_next_cursor:count===1?'next':null,unassigned_image_count:0};}},onChange:()=>{}}); + await loader.load(1);assert.equal(loader.state.detail.variants.length,1);await loader.loadMore();assert.match(loader.state.variantsError.message,/offline/);assert.equal(loader.state.detail.variants[0].id,9);await loader.loadMore();assert.deepEqual(loader.state.detail.variants.map(row=>row.id),[9,10]);assert.match(calls[1],/variant_cursor=next/);assert.equal(calls[1],calls[2]);assert.equal(await loader.loadMore(),0);loader.dispose(); +}); diff --git a/tests/js/workspace-state.test.js b/tests/js/workspace-state.test.js index a38f1e4..13d0bfa 100644 --- a/tests/js/workspace-state.test.js +++ b/tests/js/workspace-state.test.js @@ -47,3 +47,17 @@ test('router normalizes unknown routes, avoids duplicate renders, and disposes i win.dispatchEvent(new Event('hashchange')); assert.deepEqual(seen, ['images', 'library']); }); + +test('positive library deep links preserve the selected identity without duplicate route renders', async () => { + const {resolveRoute,mountRouter}=await source; + assert.equal(resolveRoute('#/library/12').positiveId,12); + for(const hash of ['#/library/0','#/library/-1','#/library/1.5','#/library/9007199254740992','#/library/1/extra']) assert.equal(resolveRoute(hash).id,'images'); + const win=new EventTarget();win.location={hash:'#/library/12'}; + win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}}; + const seen=[];const dispose=mountRouter(win,route=>seen.push([route.id,route.positiveId])); + assert.equal(win.location.hash,'#/library/12'); + win.dispatchEvent(new Event('hashchange'));assert.equal(seen.length,1); + win.location.hash='#/library/13';win.dispatchEvent(new Event('hashchange')); + win.location.hash='#/library';win.dispatchEvent(new Event('hashchange')); + assert.deepEqual(seen,[['library',12],['library',13],['library',undefined]]);dispose(); +}); diff --git a/tests/test_api_library.py b/tests/test_api_library.py new file mode 100644 index 0000000..eb88198 --- /dev/null +++ b/tests/test_api_library.py @@ -0,0 +1,180 @@ +"""Native library endpoints use bounded cursors and rooted media identities.""" + +import json +from unittest.mock import patch + +try: + from test_api_images import ImageAPITestCase, make_png +except ImportError: + from tests.test_api_images import ImageAPITestCase, make_png + + +class TestLibraryAPI(ImageAPITestCase): + async def get(self, path="", **params): + response = await self.client.get( + "/prompt_manager/library" + path, params=params + ) + return response, await response.json() + + async def test_list_detail_variant_images_and_safe_physical_previews(self): + pair = self.api.db.save_prompt_pair( + "Cat", "Noise", title="Original", tags=["studio"] + ) + positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"] + path = make_png(self.output_dir / "same.png") + image = self.api.db.link_image_to_prompt(positive, str(path)) + self.api.db.link_image_to_pair(pair, image, {"source": "one"}) + self.api.db.link_image_to_pair(pair, image, {"source": "two"}) + response, result = await self.get() + self.assertEqual(response.status, 200) + self.assertEqual(len(result["items"]), 1) + self.assertEqual(result["items"][0]["previews"][0]["root_index"], 0) + self.assertNotIn(str(self.comfy_dir), json.dumps(result)) + _, detail = await self.get(f"/{positive}") + self.assertEqual(detail["variants"][0]["negative_text"], "Noise") + self.assertEqual(detail["variants"][0]["id"], pair) + _, media = await self.get(f"/{positive}/images", pair_id=pair) + self.assertEqual(media["total"], 1) + self.assertEqual(len(media["images"]), 1) + self.assertEqual(media["images"][0]["path"], "same.png") + + async def test_scoped_cursor_tampering_filters_and_continuation(self): + for text in ("one", "two", "three"): + self._save_prompt(text) + _, first = await self.get(limit=1) + self._save_prompt("later") + _, second = await self.get(limit=1, cursor=first["next_cursor"]) + self.assertNotEqual(first["items"][0]["id"], second["items"][0]["id"]) + for params in ( + {"cursor": "tampered"}, + {"cursor": first["next_cursor"], "sort": "oldest"}, + {"cursor": first["next_cursor"], "text": "one"}, + {"limit": "no"}, + {"sort": "sql"}, + ): + response, result = await self.get(**params) + self.assertEqual(response.status, 400) + self.assertNotIn(str(self.comfy_dir), json.dumps(result)) + + async def test_outside_missing_and_unsupported_media_do_not_leak_or_scan(self): + positive = self._save_prompt("Cat") + outside = make_png(self.comfy_dir / "outside.png") + for path in ( + outside, + self.output_dir / "missing.png", + self.output_dir / "secret.txt", + ): + self.api.db.link_image_to_prompt(positive, str(path)) + with patch.object( + self.api, + "_scan_gallery_files_sync", + side_effect=AssertionError("scan"), + ): + _, result = await self.get(f"/{positive}/images", limit=2) + self.assertEqual(result["images"], []) + self.assertEqual(result["total"], 3) + self.assertTrue(result["has_more"]) + self.assertIsInstance(result["next_cursor"], str) + self.assertNotIn(str(self.comfy_dir), json.dumps(result)) + response, result = await self.get("/9999") + self.assertEqual(response.status, 404) + + async def test_static_options_route_includes_pair_categories(self): + self._save_prompt("Legacy", category="Legacy") + self.api.db.save_prompt_pair("Cat", "Noise", category="Pair-only") + response, result = await self.get("/options") + self.assertEqual(response.status, 200) + self.assertEqual( + result, {"success": True, "categories": ["Legacy", "Pair-only"]} + ) + + async def test_variant_cursor_is_positive_scoped_and_required_limits_are_bounded( + self, + ): + for index in range(3): + pair = self.api.db.save_prompt_pair("Cat", f"Neg {index}") + positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"] + other = self._save_prompt("Other") + _, first = await self.get(f"/{positive}", variant_limit=1) + self.assertEqual(len(first["variants"]), 1) + self.assertEqual(first["variant_total"], 3) + self.assertTrue(first["variants_has_more"]) + _, second = await self.get( + f"/{positive}", + variant_limit=1, + variant_cursor=first["variants_next_cursor"], + ) + self.assertNotEqual(first["variants"][0]["id"], second["variants"][0]["id"]) + response, _ = await self.get( + f"/{other}", variant_cursor=first["variants_next_cursor"] + ) + self.assertEqual(response.status, 400) + response, _ = await self.get(f"/{positive}", variant_limit=201) + self.assertEqual(response.status, 400) + + async def test_image_cursors_scope_variants_and_roots_with_real_collisions( + self, + ): + first = self.api.db.save_prompt_pair("Cat", "Blur") + second = self.api.db.save_prompt_pair("Cat", "Noise") + positive = self.api.db.get_prompt_pair(first)["positive_prompt_id"] + other = self.comfy_dir / "input" + other.mkdir() + self.api._get_all_output_dirs = lambda: [self.output_dir, other] + for root in (self.output_dir, other): + path = make_png(root / "same.png") + image = self.api.db.link_image_to_prompt(positive, str(path)) + self.api.db.link_image_to_pair(first, image, {"source": "test"}) + _, page = await self.get(f"/{positive}/images", pair_id=first, limit=1) + self.assertEqual(page["images"][0]["root_index"], 1) + self.assertTrue(page["has_more"]) + _, next_page = await self.get( + f"/{positive}/images", pair_id=first, limit=1, cursor=page["next_cursor"] + ) + self.assertEqual(next_page["images"][0]["root_index"], 0) + self.assertNotIn(str(self.comfy_dir), json.dumps(next_page)) + response, _ = await self.get( + f"/{positive}/images", pair_id=second, cursor=page["next_cursor"] + ) + self.assertEqual(response.status, 400) + self.api._get_all_output_dirs = lambda: [self.output_dir] + response, _ = await self.get( + f"/{positive}/images", pair_id=first, cursor=page["next_cursor"] + ) + self.assertEqual(response.status, 400) + + async def test_invalid_ids_limits_and_database_failures_are_safe(self): + positive = self._save_prompt("Cat") + for path, params in ( + ("/bad", {}), + ("/-1", {}), + ("/999999999999999999999999", {}), + (f"/{positive}/images", {"pair_id": "-1"}), + (f"/{positive}/images", {"pair_id": "999999999999999999999999"}), + ("", {"limit": "201"}), + ("", {"cursor": "x" * 4097}), + ): + response, _ = await self.get(path, **params) + self.assertEqual(response.status, 400, (path, params)) + with patch.object( + self.api.db, "list_library", side_effect=RuntimeError(str(self.comfy_dir)) + ): + response, result = await self.get() + self.assertEqual(response.status, 500) + self.assertNotIn(str(self.comfy_dir), json.dumps(result)) + + async def test_bounded_unicode_name_cursor_and_wildcard_search(self): + prefix = "猫" * 200 + self._save_prompt(prefix + "B") + self._save_prompt(prefix + "A") + self._save_prompt("literal % and _") + _, first = await self.get(sort="name", limit=1, text=prefix) + self.assertLess(len(first["next_cursor"]), 4096) + _, second = await self.get( + sort="name", limit=1, text=prefix, cursor=first["next_cursor"] + ) + self.assertNotEqual(first["items"][0]["id"], second["items"][0]["id"]) + _, literal = await self.get(text="%") + self.assertEqual( + [item["text"] for item in literal["items"]], ["literal % and _"] + ) diff --git a/tests/test_library.py b/tests/test_library.py new file mode 100644 index 0000000..5efcb17 --- /dev/null +++ b/tests/test_library.py @@ -0,0 +1,246 @@ +"""Grouped library reads retain component roles and association semantics.""" + +import tempfile +import unittest +from pathlib import Path + +from database.operations import PromptDatabase +from utils.hashing import generate_prompt_hash + + +class TestLibrary(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.db = PromptDatabase(str(Path(self.temp.name) / "library.db")) + + def tearDown(self): + self.db.close_all() + self.temp.cleanup() + + def save(self, text, **metadata): + return self.db.save_prompt( + text, prompt_hash=generate_prompt_hash(text), **metadata + ) + + def test_groups_positive_parents_excludes_negative_only_and_keeps_dual_once(self): + first = self.db.save_prompt_pair("Cat", "Blur") + self.db.save_prompt_pair("Cat", "Noise") + self.db.save_prompt_pair("Blur", "Watermark") + legacy = self.save("Legacy") + rows = self.db.list_library()["items"] + self.assertEqual({row["text"] for row in rows}, {"Cat", "Blur", "Legacy"}) + parent = next(row for row in rows if row["text"] == "Cat") + self.assertEqual(parent["variant_count"], 2) + self.assertEqual(len([row for row in rows if row["id"] == legacy]), 1) + self.db.archive_prompt_pair(first) + detail = self.db.get_library_positive(parent["id"]) + self.assertEqual(len(detail["variants"]), 1) + self.assertEqual(detail["positive"]["variant_count"], 1) + + def test_filters_match_all_constraints_on_same_parent_or_same_active_pair(self): + a = self.db.save_prompt_pair("Cat", "Blur", category="A", tags=["red"]) + self.db.save_prompt_pair("Cat", "Noise", category="B", tags=["blue"]) + self.assertEqual(self.db.list_library(category="A", tags=["blue"])["items"], []) + self.assertEqual( + self.db.list_library(text="Blur", category="A", tags=["red"])["total"], 1 + ) + self.db.archive_prompt_pair(a) + self.assertEqual(self.db.list_library(text="Blur")["total"], 0) + self.save("Legacy cat", category="A", tags=["red", "blue"]) + self.assertEqual( + self.db.list_library(category="A", tags=["red", "blue"])["total"], 1 + ) + self.assertEqual(self.db.list_library(text="%")["items"], []) + + def test_keyset_handles_insertion_deletion_ties_and_invalid_parameters(self): + for text in ("a", "b", "c", "d"): + self.save(text) + first = self.db.list_library(limit=2) + self.save("new") + second = self.db.list_library(limit=2, after=first["next_key"]) + self.assertEqual([row["text"] for row in second["items"]], ["b", "a"]) + self.assertFalse(second["has_more"]) + for options in ({"limit": 0}, {"sort": "sql"}, {"tags": [2]}, {"text": 2}): + with self.assertRaises(ValueError): + self.db.list_library(**options) + + def test_detail_distinguishes_explicit_empty_and_deduplicates_provenance_rows(self): + pair = self.db.save_prompt_pair("Cat", "") + positive = self.db.get_prompt_pair(pair)["positive_prompt_id"] + image = self.db.link_image_to_prompt( + positive, str(Path(self.temp.name) / "image.png") + ) + self.db.link_image_to_pair(pair, image, {"source": "one"}) + self.db.link_image_to_pair(pair, image, {"source": "two"}) + self.db.link_image_to_prompt( + positive, str(Path(self.temp.name) / "unassigned.png") + ) + detail = self.db.get_library_positive(positive) + self.assertEqual(detail["positive"]["image_count"], 2) + self.assertEqual(detail["unassigned_image_count"], 1) + self.assertEqual(detail["variants"][0]["negative_text"], "") + self.assertTrue(detail["variants"][0]["explicit_empty"]) + self.assertEqual(detail["variants"][0]["image_count"], 1) + page = self.db.list_library_images(positive, pair_id=pair) + self.assertEqual(len(page["images"]), 1) + self.assertEqual(page["total"], 1) + unrelated = self.db.save_prompt_pair("Dog", "") + with self.assertRaises(ValueError): + self.db.list_library_images(positive, pair_id=unrelated) + self.assertIsNone(self.db.get_library_positive(9999)) + + def test_options_include_active_pair_categories_not_negative_only_or_archived(self): + self.save("Legacy", category="Legacy category") + pair = self.db.save_prompt_pair("Cat", "Noise", category="Pair category") + negative = self.db.get_prompt_pair(pair)["negative_prompt_id"] + with self.db.model.get_connection() as connection: + connection.execute( + "UPDATE prompts SET category='Negative only' WHERE id=?", (negative,) + ) + archived = self.db.save_prompt_pair("Dog", "Blur", category="Archived category") + self.db.archive_prompt_pair(archived) + self.assertEqual( + self.db.get_library_options(), + {"categories": ["Legacy category", "Pair category"]}, + ) + + def test_variant_detail_is_bounded_and_cursor_continues_without_duplicates(self): + for index in range(85): + self.db.save_prompt_pair("Cat", f"Negative {index}", tags=[f"tag{index}"]) + positive = self.db.list_library()["items"][0]["id"] + first = self.db.get_library_positive(positive, variant_limit=40) + self.assertEqual(len(first["variants"]), 40) + self.assertEqual(first["variant_total"], 85) + self.assertTrue(first["variants_has_more"]) + second = self.db.get_library_positive( + positive, variant_limit=40, variant_after=first["variants_next_key"] + ) + third = self.db.get_library_positive( + positive, variant_after=second["variants_next_key"] + ) + all_variants = first["variants"] + second["variants"] + third["variants"] + self.assertEqual(len({item["id"] for item in all_variants}), 85) + self.assertFalse(third["variants_has_more"]) + self.assertTrue(all(len(item["tags"]) == 1 for item in all_variants)) + with self.assertRaises(ValueError): + self.db.get_library_positive(positive, variant_limit=201) + + def test_sorts_keysets_survive_anchor_deletion_and_insertions_without_writes(self): + import sqlite3 + + a = self.save("Alpha", rating=2, tags=["tag"]) + b = self.save("beta", rating=5) + c = self.save("charlie") + for sort, expected in ( + ("newest", [c, b, a]), + ("oldest", [a, b, c]), + ("name", [a, b, c]), + ("rating", [b, a, c]), + ): + with self.subTest(sort=sort): + first = self.db.list_library(sort=sort, limit=1) + second = self.db.list_library( + sort=sort, limit=1, after=first["next_key"] + ) + third = self.db.list_library( + sort=sort, limit=1, after=second["next_key"] + ) + self.assertEqual( + [part["items"][0]["id"] for part in (first, second, third)], + expected, + ) + first = self.db.list_library(sort="name", limit=1) + with self.db.model.get_connection() as connection: + connection.execute("DELETE FROM prompts WHERE id=?", (a,)) + self.save("between") + second = self.db.list_library(sort="name", after=first["next_key"]) + self.assertEqual([item["id"] for item in second["items"]], [b, c]) + connection = self.db.model.get_connection() + before = connection.total_changes + denied = { + sqlite3.SQLITE_INSERT, + sqlite3.SQLITE_UPDATE, + sqlite3.SQLITE_DELETE, + sqlite3.SQLITE_CREATE_TABLE, + sqlite3.SQLITE_ALTER_TABLE, + } + connection.set_authorizer( + lambda operation, *_: ( + sqlite3.SQLITE_DENY if operation in denied else sqlite3.SQLITE_OK + ) + ) + try: + self.db.list_library() + self.db.get_library_positive(b) + self.db.get_library_options() + self.db.list_library_images(b) + finally: + connection.set_authorizer(None) + self.assertEqual(connection.total_changes, before) + + def test_archived_associations_and_legacy_positive_roles_remain_distinct(self): + pair = self.db.save_prompt_pair("Cat", "Noise") + pair_data = self.db.get_prompt_pair(pair) + negative = pair_data["negative_prompt_id"] + self.db.link_image_to_prompt(negative, str(Path(self.temp.name) / "legacy.png")) + rows = self.db.list_library()["items"] + self.assertIn(negative, [row["id"] for row in rows]) + positive = pair_data["positive_prompt_id"] + image = self.db.link_image_to_prompt( + positive, str(Path(self.temp.name) / "pair.png") + ) + self.db.link_image_to_pair(pair, image, {"source": "test"}) + self.db.archive_prompt_pair(pair) + detail = self.db.get_library_positive(positive) + self.assertEqual(detail["variants"], []) + self.assertEqual(detail["positive"]["image_count"], 1) + self.assertEqual(detail["unassigned_image_count"], 0) + self.assertEqual(self.db.list_library_images(positive)["total"], 1) + with self.assertRaises(ValueError): + self.db.list_library_images(positive, pair_id=pair) + + def test_invalid_database_inputs_fail_before_queries(self): + for options in ( + {"category": 2}, + {"tags": [""]}, + {"text": "x" * 501}, + {"after": {}}, + {"after": {"ceiling": "x", "id": 1, "value": 1}}, + ): + with self.assertRaises(ValueError): + self.db.list_library(**options) + for operation in ( + lambda: self.db.get_library_positive(1, variant_after=0), + lambda: self.db.list_library_images(1, after=0), + ): + with self.assertRaises(ValueError): + operation() + + def test_cursor_key_comes_from_read_page_even_if_anchor_is_deleted_during_read( + self, + ): + from unittest.mock import patch + + self.save("a") + self.save("b") + original = self.db._library_parents + + def remove_anchor(*args, **kwargs): + rows = original(*args, **kwargs) + with self.db.model.get_connection() as connection: + connection.execute("DELETE FROM prompts WHERE id=?", (rows[0]["id"],)) + return rows + + with patch.object(self.db, "_library_parents", side_effect=remove_anchor): + first = self.db.list_library(limit=1) + second = self.db.list_library(after=first["next_key"]) + self.assertEqual([row["text"] for row in second["items"]], ["a"]) + + def test_options_preserve_exact_category_values_that_filters_accept(self): + self.save("Legacy", category=" Category ") + self.db.save_prompt_pair("Cat", "Noise", category=" Variant ") + options = self.db.get_library_options()["categories"] + self.assertEqual(options, [" Category ", " Variant "]) + self.assertTrue( + all(self.db.list_library(category=value)["total"] == 1 for value in options) + ) diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index ba3c41b..3806c1e 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -202,5 +202,17 @@ "branches": 89, "functions": 98, "statements": 98 + }, + "workspace/library-state.mjs": { + "lines": 98, + "branches": 94, + "functions": 98, + "statements": 98 + }, + "workspace/library.mjs": { + "lines": 94, + "branches": 80, + "functions": 93, + "statements": 94 } } diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs index deda993..63f543a 100644 --- a/web/js/workspace/images-state.mjs +++ b/web/js/workspace/images-state.mjs @@ -43,8 +43,10 @@ function validatePage(page, cursor) { } /** Keep metadata for backward scrolling; the view bounds rendered media independently. */ -export function createImageStream({ api, onChange = () => {}, batchSize = 40, restore }) { - if (typeof api?.get !== 'function') throw new TypeError('An API client is required'); +export function createImageStream({ api, onChange = () => {}, batchSize = 40, restore, fetchPage }) { + if (fetchPage !== undefined && typeof fetchPage !== 'function') throw new TypeError('Invalid image page adapter'); + if (!fetchPage && typeof api?.get !== 'function') throw new TypeError('An API client is required'); + const readPage = fetchPage || (({parameters, signal}) => api.get(`/prompt_manager/images/output?${parameters}`, {signal})); if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 200) { throw new TypeError('Invalid image batch size'); } @@ -86,7 +88,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re const parameters = new URLSearchParams(query); if (cursor !== null) parameters.set('cursor', cursor); try { - const page = await api.get(`/prompt_manager/images/output?${parameters}`, { signal: request.signal }); + const page = await readPage({parameters, signal:request.signal}); if (disposed || request !== controller) return 0; validatePage(page, cursor); const incoming = []; diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 1566cf8..8fc9ba5 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -17,26 +17,29 @@ function formatBytes(value) { let lastVisit = null; -export function mountImages(main, api, parentScope) { +export function mountImages(main, api, parentScope, {embedded = false, fetchPage} = {}) { const scope = createScope(); - parentScope.cleanup(() => scope.dispose()); + const dispose = () => scope.dispose(); + parentScope.signal.addEventListener('abort', dispose, {once:true}); + scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose)); + if (parentScope.signal.aborted) { scope.dispose(); return {dispose}; } const { resolveUrl } = window.ApiClient; const sender = createPromptSender({scope}); let detailInspector, previewInspector; scope.cleanup(() => { detailInspector?.dispose(); previewInspector?.dispose(); }); - const visit = lastVisit; + const visit = embedded ? null : lastVisit; let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer, viewer; let state, geometry = { positions: [], height: 0, maxHeight: 0 }, optionsRequest; const rendered = new Map(); const itemIndexes = new Map(); const filters = { root: 'all', q: '', subfolder: '', media: 'all', sort: 'newest', ...visit?.filters }; scope.cleanup(() => { - lastVisit = state ? { filters: {...filters}, state, layout, selection, scrollY: window.scrollY } : null; + if (!embedded) lastVisit = state ? { filters: {...filters}, state, layout, selection, scrollY: window.scrollY } : null; cancelAnimationFrame(restoreFrame); }); main.classList.add('images-main'); scope.cleanup(() => main.classList.remove('images-main')); - main.append(el('p', 'Browse your images, explore the details, keep your creative context.', 'intro')); + if (!embedded) main.append(el('p', 'Browse your images, explore the details, keep your creative context.', 'intro')); const filtersBar = el('div', undefined, 'image-filters'); const search = el('input'); search.value = filters.q; @@ -50,7 +53,7 @@ export function mountImages(main, api, parentScope) { createField('Folder', folder, 'image-folder'), createField('Media', media, 'image-media'), createField('Sort', sort, 'image-sort')); const optionsStatus = el('p', '', 'options-status muted'); optionsStatus.hidden = true; optionsStatus.setAttribute('role', 'alert'); - main.append(filtersBar, optionsStatus); + if (!embedded) main.append(filtersBar, optionsStatus); const toolbar = el('div', undefined, 'image-toolbar'); const count = el('p', 'Loading images…', 'muted'); const layouts = el('div', undefined, 'image-layouts'); @@ -86,7 +89,8 @@ export function mountImages(main, api, parentScope) { }); const currentGallery = el('a', 'Current gallery', 'workspace-link'); currentGallery.href = resolveUrl('/prompt_manager/gallery'); - toolbar.append(count, layouts, refresh, currentGallery); + toolbar.append(count, layouts); + if (!embedded) toolbar.append(refresh, currentGallery); main.append(toolbar); const body = el('div', undefined, 'image-workspace'); const collection = el('section', undefined, 'image-collection'); @@ -159,6 +163,7 @@ export function mountImages(main, api, parentScope) { download.href = resolveUrl(image.url); download.download = image.filename; const prompts = el('div'); const legacy = el('p', 'Open Current gallery for tagging.', 'muted'); + legacy.hidden = embedded; details.replaceChildren(el('p', 'SELECTED IMAGE', 'eyebrow'), el('h2', image.filename), thumbnail, preview, download, prompts, metadata, legacy); detailInspector = mountPromptInspector(prompts, {api, image, sender, parentScope:scope}); for (const [key, card] of rendered) { @@ -307,18 +312,18 @@ export function mountImages(main, api, parentScope) { for (let index = state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index); } state = next; - count.textContent = `${next.loaded.toLocaleString()} of ${next.total.toLocaleString()} images`; + count.textContent = embedded ? `${next.loaded.toLocaleString()} loaded · ${next.total.toLocaleString()} linked images` : `${next.loaded.toLocaleString()} of ${next.total.toLocaleString()} images`; if (changed) updateLayout(); message.textContent = next.error ? 'Images could not be loaded. Retry or refresh the collection.' : next.loading ? 'Loading images…' : ''; if (next.error) message.setAttribute('role', 'alert'); else message.setAttribute('role', 'status'); retry.hidden = !next.error; more.hidden = !next.hasMore || !!next.error; more.setAttribute('aria-disabled', String(next.loading)); - end.textContent = !next.loading && !next.error && !next.hasMore ? (next.loaded ? 'You’re all caught up.' : 'No matching images or media. Try changing the filters.') : ''; + end.textContent = !next.loading && !next.error && !next.hasMore ? (next.loaded ? 'You’re all caught up.' : embedded ? 'No linked media available for this selection.' : 'No matching images or media. Try changing the filters.') : ''; if (!selection && next.items.length) inspect(next.items[0]); scheduleLoad(); } - const stream = createImageStream({ api, onChange: renderState, restore: visit ? {filters, state:visit.state} : undefined }); + const stream = createImageStream({ api, fetchPage, onChange: renderState, restore: visit ? {filters, state:visit.state} : undefined }); scope.cleanup(() => { stream.dispose(); for (const video of body.querySelectorAll('video')) releaseVideo(video); @@ -374,7 +379,7 @@ export function mountImages(main, api, parentScope) { reset(); }); } - loadOptions(); + if (!embedded) loadOptions(); if (visit) { renderState(stream.state); const selected = stream.state.items.find(image => imageKey(image) === selection); @@ -384,4 +389,5 @@ export function mountImages(main, api, parentScope) { }); if (visit.state.loading || visit.state.error) stream.loadMore(); } else reset(); + return {dispose}; } diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 10cc5da..af347e6 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -3,6 +3,7 @@ import { createButton, createField, createScope, createSelect } from '../shared/ import { routes, mountRouter, summarizeStats } from './state.mjs'; import { mountWorkspaceTheme } from './theme.mjs'; import { mountImages } from './images.mjs'; +import { mountLibrary } from './library.mjs'; const { createApiClient, resolveUrl } = window.ApiClient; @@ -62,6 +63,10 @@ function mountWorkspace() { mountImages(main, api, view); return; } + if (route.id === 'library') { + mountLibrary(main, api, view, {positiveId:route.positiveId}); + return; + } if (route.id !== 'overview') { main.append(element('p', route.description, 'intro')); const panel = element('section', undefined, 'panel handoff'); diff --git a/web/js/workspace/library-state.mjs b/web/js/workspace/library-state.mjs new file mode 100644 index 0000000..fd2e8ed --- /dev/null +++ b/web/js/workspace/library-state.mjs @@ -0,0 +1,89 @@ +function validId(value) { return Number.isSafeInteger(value) && value > 0; } +function parent(value) { + return value && validId(value.id) && typeof value.text==='string'; +} +function parameters(filters,limit) { + const query=new URLSearchParams({limit:String(limit)}); + for(const key of ['text','category']) { + if(filters[key]!==undefined && typeof filters[key]!=='string') throw new TypeError('Invalid library filter'); + if(filters[key]) query.set(key,filters[key]); + } + if(filters.tags!==undefined && (!Array.isArray(filters.tags) || filters.tags.some(tag=>typeof tag!=='string'))) throw new TypeError('Invalid library tags'); + for(const tag of filters.tags || []) if(tag.trim()) query.append('tag',tag.trim()); + const sort=filters.sort || 'newest'; + if(!['newest','oldest','name','rating'].includes(sort)) throw new TypeError('Invalid library sort'); + query.set('sort',sort);return query; +} +function validatePage(page,cursor) { + if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page'); +} +export function createLibraryStream({api,onChange=()=>{},restore}) { + let state=Object.freeze({items:Object.freeze([]),total:0,cursor:null,hasMore:true,loading:false,error:null}); + let query=parameters({},40),request,disposed=false,seen=new Set(); + if(restore) { + query=parameters(restore.filters,40);const saved=restore.state; + validatePage({items:saved.items,total:saved.total,next_cursor:saved.cursor,has_more:saved.cursor!==null},null); + if(new Set(saved.items.map(item=>item.id)).size!==saved.items.length) throw new TypeError('Duplicate restored parent'); + state=Object.freeze({...saved,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id)); + } + const emit=patch=>{state=Object.freeze({...state,...patch});onChange(state);}; + async function loadMore() { + if(disposed || state.loading || !state.hasMore) return 0; + const current=request=new AbortController();const cursor=state.cursor; + emit({loading:true,error:null});const params=new URLSearchParams(query);if(cursor!==null) params.set('cursor',cursor); + try { + const page=await api.get(`/prompt_manager/library?${params}`,{signal:current.signal}); + if(disposed || current!==request) return 0; + validatePage(page,cursor);const incoming=[];const added=new Set(); + for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));} + for(const value of added) seen.add(value); + request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length; + } catch(error) {if(!disposed && current===request) {request=null;emit({loading:false,error});}return 0;} + } + return {get state(){return state;},loadMore,async reset(filters={}) { + if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,cursor:null,hasMore:true,loading:false,error:null});return loadMore(); + },dispose(){disposed=true;request?.abort();request=null;}}; +} +export function libraryPromptRoles(detail,pairId=null) { + const variant=pairId===null?null:detail.variants.find(item=>item.id===pairId); + if(pairId!==null && !variant) throw new TypeError('Saved negative variant is unavailable'); + return {positive:{status:'resolved',text:detail.positive.text,source:'library'},negative:variant?{status:'resolved',text:variant.negative_text,source:'library'}:{status:'unresolved',text:null,source:null,message:'Choose a saved negative variant to inspect or send it.'}}; +} +export function createLibraryDetail({api,onChange}) { + let request,disposed=false,id; + let state=Object.freeze({detail:null,loading:false,error:null,loadingVariants:false,variantsError:null}); + const emit=patch=>{state=Object.freeze({...state,...patch});onChange(state);}; + function validate(detail) { + if(!parent(detail.positive) || detail.positive.id!==id || !Array.isArray(detail.variants) || detail.variants.some(item=>!validId(item.id) || typeof item.negative_text!=='string' || typeof item.explicit_empty!=='boolean' || item.explicit_empty!==(item.negative_text==='')) || new Set(detail.variants.map(item=>item.id)).size!==detail.variants.length) throw new TypeError('Invalid prompt history'); + const total=detail.variant_total ?? detail.variants.length,more=detail.variants_has_more ?? false,cursor=detail.variants_next_cursor ?? null; + if(!Number.isSafeInteger(total) || total0)) || more!==(cursor!==null)) throw new TypeError('Invalid negative variant page'); + return {...detail,variant_total:total,variants_has_more:more,variants_next_cursor:cursor}; + } + async function fetch(append) { + if(disposed || (append && (!state.detail?.variants_has_more || state.loadingVariants || state.loading))) return 0; + request?.abort();const current=request=new AbortController();const previous=state.detail;const cursor=append?previous.variants_next_cursor:null; + emit(append?{loadingVariants:true,variantsError:null}:{detail:null,loading:true,error:null,loadingVariants:false,variantsError:null}); + try { + if(!validId(id)) throw new TypeError('Invalid positive prompt'); + const query=new URLSearchParams({variant_limit:'40'});if(cursor) query.set('variant_cursor',cursor); + const raw=await api.get(`/prompt_manager/library/${id}?${query}`,{signal:current.signal}); + if(disposed || request!==current) return 0; + const detail=validate(raw); + if(append) { + if(detail.positive.text!==previous.positive.text || (detail.variants_has_more && detail.variants_next_cursor===cursor)) throw new TypeError('Prompt history changed. Reload it before continuing.'); + const variants=new Map(previous.variants.map(item=>[item.id,item]));for(const item of detail.variants) if(!variants.has(item.id)) variants.set(item.id,item);detail.variants=[...variants.values()]; + } + request=null;emit({detail,loading:false,error:null,loadingVariants:false,variantsError:null});return detail.variants.length-(append?previous.variants.length:0); + } catch(error) {if(!disposed && request===current) {request=null;emit(append?{loadingVariants:false,variantsError:error}:{loading:false,error});}return 0;} + } + return {get state(){return state;},load(value){id=value;return fetch(false);},loadMore(){return fetch(true);},dispose(){disposed=true;request?.abort();}}; +} + +export function libraryWindow(count,columns,top,height,focused=-1) { + if(count<=120) return Array.from({length:count},(_,index)=>index); + const rows=Math.ceil(count/columns),first=Math.max(0,Math.floor(top/578)-2),last=Math.min(rows-1,Math.ceil((top+height)/578)+2); + const keep=new Set(); + for(let row=first;row<=last;row++) keep.add(row); + if(focused>=0 && focuseda-b).flatMap(row=>Array.from({length:columns},(_,column)=>row*columns+column).filter(index=>index :only-child { grid-column: 1 / -1; } +.library-main .library-art-empty { display: grid; place-content: center; min-inline-size: 0; font-size: 12px; color: var(--ca-muted); } +.library-main .library-card-content { min-block-size: 0; display: flex; flex-direction: column; gap: 10px; padding: 18px; } +.library-main .library-card-title { max-block-size: 72px; overflow: auto; flex-shrink: 0; display: flex; align-items: start; justify-content: space-between; gap: 12px; } +.library-main .library-card-title h2 { margin: 0; font-size: 16px; min-inline-size: 0; overflow-wrap: anywhere; } +.library-main .library-title-link { color: var(--ca-text); text-decoration: none; } +.library-main .library-title-link:hover { color: var(--ca-accent); } +.library-main .library-card-prompts { flex: 1 1 auto; overflow: auto; } +.library-main .library-card-prompts .prompt-block { margin: 0; } +.library-main .library-card-prompts .prompt-text { max-block-size: 100px; } +.library-main .library-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; font-size: 11px; color: var(--ca-muted); } +.library-main .library-chip { display: inline-block; max-inline-size: 100%; padding: 4px 8px; border: 1px solid var(--ca-line); border-radius: 5px; font-size: 11px; color: var(--ca-muted); overflow-wrap: anywhere; } +.library-main .library-chip-negative { color: var(--ca-error-text); border-color: color-mix(in srgb, var(--ca-error) 35%, var(--ca-line)); } +.library-main .library-tags { display: flex; flex-wrap: wrap; gap: 5px; max-block-size: 50px; overflow: auto; } +.library-main .library-card-footer { padding: 11px 18px; border-block-start: 1px solid var(--ca-line); display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 12px; } +.library-main .library-history-link { display: inline-flex; align-items: center; gap: 7px; color: var(--ca-accent); } +.library-main .library-history-link svg { inline-size: 18px; block-size: 18px; } +.library-main .library-row-spacer { grid-column: 1 / -1; pointer-events: none; } +.library-main .library-continuation { padding-block: 22px; display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } +.library-main .library-status { flex: 1 1 240px; color: var(--ca-muted); } +.library-main .library-back { display: inline-block; margin-block: 0 12px; color: var(--ca-accent); } +.library-main .library-surface { padding: 22px; background: var(--ca-surface); border: 1px solid var(--ca-line); border-radius: 7px; margin-block: 18px; } +.library-main .library-branches { padding: 18px 20px; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-field); margin-block: 16px; } +.library-main .library-positive-lane { padding-block-end: 12px; border-block-end: 2px solid var(--library-positive); color: var(--library-positive); font-size: 12px; } +.library-main .library-variant-lane { display: flex; align-items: center; gap: 16px; min-block-size: 57px; margin-inline-start: 18px; border-inline-start: 2px solid var(--ca-error); padding-inline-start: 18px; } +.library-main .library-variant-lane span { min-inline-size: 0; overflow-wrap: anywhere; font-size: 12px; color: var(--ca-muted); } +.ca-tools .library-main .library-variant-dot { flex: 0 0 29px; inline-size: 29px; min-block-size: 29px; padding: 0; border-radius: 50%; background: var(--ca-error); border: 2px solid var(--ca-error); color: white; } +.library-main .library-variant-dot[aria-pressed="true"] { outline: 3px solid var(--ca-focus); outline-offset: 4px; } +.library-main .library-variants > button[aria-pressed="true"] { border-color: var(--ca-focus); } +.library-main .library-shared-gallery { margin-block-start: 24px; } +.library-main .library-variant-context { color: var(--ca-muted); font-size: 12px; } +@media (min-width: 1700px) { .library-main .library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +@media (max-width: 700px) { main.library-main { padding: 20px 14px; } .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } .library-main .library-card-title { flex-wrap: wrap; gap: 6px; } .library-main .library-toolbar > button { inline-size: 100%; } } + +[data-ca-theme="light"] main.library-main { --library-positive: #14784f; } diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs new file mode 100644 index 0000000..eab6e44 --- /dev/null +++ b/web/js/workspace/library.mjs @@ -0,0 +1,203 @@ +import {createScope,createButton,createField,createSelect} from '../shared/toolkit/core.js'; +import {createPromptSender} from './prompt-sender.mjs'; +import {mountPromptBlocks} from './prompt-inspector.mjs'; +import {mountImages} from './images.mjs'; +import {createLibraryStream,createLibraryDetail,libraryPromptRoles,libraryWindow} from './library-state.mjs'; + +let shelfVisit=null; +const variantSelections=new Map(); +function el(tag,text,className) { + const node=document.createElement(tag);if(text!==undefined) node.textContent=text;if(className) node.className=className;return node; +} +function link(text,href,className) {const node=el('a',text,className);node.href=href;return node;} +function title(text) {const first=text.split('\n')[0].trim();return first.length>80?`${first.slice(0,80)}…`:first;} +function branchIcon() { + const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('viewBox','0 0 24 24');svg.setAttribute('aria-hidden','true');svg.setAttribute('fill','none');svg.setAttribute('stroke','currentColor');svg.setAttribute('stroke-width','1.8'); + const path=document.createElementNS('http://www.w3.org/2000/svg','path');path.setAttribute('d','M6 8v8m0-4h6a6 6 0 0 0 6-6M9 5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm0 14a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM21 3a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z');svg.append(path);return svg; +} + +export function mountLibrary(main,api,parentScope,{positiveId}={}) { + const scope=createScope();parentScope.cleanup(()=>scope.dispose()); + main.classList.add('library-main');scope.cleanup(()=>main.classList.remove('library-main')); + const sender=createPromptSender({scope}); + if(positiveId!==undefined) {mountHistory();return;} + mountShelf(); + + function mountShelf() { + const visit=shelfVisit;const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters}; + const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library'); + const introduction=el('p','One positive. Reusable negatives. Every experiment stays connected.','intro'); + const toolbar=el('search',undefined,'library-toolbar'); + const search=el('input');search.type='search';search.placeholder='Search prompts and saved negatives…';search.value=filters.text;search.maxLength=500; + const category=createSelect([['','All categories']], '');category.value=filters.category; + const tags=el('input');tags.type='text';tags.placeholder='Comma-separated tags';tags.value=filters.tags.join(', ');tags.maxLength=2000; + const sort=createSelect([['newest','Recently added'],['oldest','Oldest first'],['name','Prompt A–Z'],['rating','Highest rated']], '');sort.value=filters.sort; + const refresh=createButton('Refresh library'); + toolbar.append(createField('Search prompts',search,'library-search'),createField('Category',category,'library-category'),createField('Tags',tags,'library-tags'),createField('Sort',sort,'library-sort'),refresh); + const optionsStatus=el('p','','library-options-status');optionsStatus.hidden=true;optionsStatus.setAttribute('role','alert'); + const caption=el('p','','library-caption');caption.setAttribute('role','status'); + const grid=el('div',undefined,'library-grid');grid.setAttribute('role','list');grid.setAttribute('aria-label','Positive prompts'); + const message=el('p','','library-status');message.setAttribute('role','status'); + const continuation=el('div',undefined,'library-continuation'); + const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true; + const legacy=link('Current prompt library',window.ApiClient.resolveUrl('/prompt_manager/admin'),'workspace-link'); + continuation.append(message,retry,more);section.append(introduction,toolbar,optionsStatus,legacy,caption,grid,continuation);main.append(section); + const cards=new Map();let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1,restoreFrame=0; + const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2; + function removeCard(card) {card.blocks.dispose();card.node.remove();} + function cardFor(item,index) { + const article=el('article',undefined,'library-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem'); + const art=el('div',undefined,'library-card-art');art.setAttribute('aria-hidden','true'); + const previews=item.previews || []; + for(const image of previews.slice(0,2)) { + if(image.is_video && !image.thumbnail_url) art.append(el('span','Video preview','library-art-empty')); + else { + const img=el('img');img.src=window.ApiClient.resolveUrl(image.thumbnail_url || image.url);img.alt='';img.loading='lazy';img.decoding='async';img.width=image.width || 800;img.height=image.height || 600;art.append(img); + } + } + if(!previews.length) art.append(el('span','No available image previews','library-art-empty')); + const content=el('div',undefined,'library-card-content'); + const heading=el('header',undefined,'library-card-title'); + const name=link(title(item.text),`#/library/${item.id}`,'library-title-link');const h2=el('h2');h2.append(name); + heading.append(h2,el('span',item.category || 'Uncategorized','library-chip')); + const prompts=el('div',undefined,'library-card-prompts'); + const blocks=mountPromptBlocks(prompts,{prompts:{positive:{status:'resolved',text:item.text,source:'library'}},sender,parentScope:scope,roles:['positive']}); + const stats=el('div',undefined,'library-card-meta');stats.append(el('span',item.variant_count?`${item.variant_count} saved negative variants`:'No saved negatives','library-chip library-chip-negative'),el('span',`${item.image_count} linked images`)); + const vocabulary=el('div',undefined,'library-tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'library-chip')); + content.append(heading,prompts,stats,vocabulary); + const footer=el('footer',undefined,'library-card-footer');footer.append(el('span',item.rating?`${item.rating} / 5`:'Unrated','muted')); + const history=link('',`#/library/${item.id}`,'library-history-link');history.append(branchIcon(),el('span','Explore history'));footer.append(history); + article.append(art,content,footer);article.dataset.index=index;return {node:article,blocks}; + } + function renderWindow() { + if(!state || scope.signal.aborted) return; + const active=document.activeElement?.closest('article[data-prompt-id]'); + const focused=focusOverride>=0?focusOverride:active?Number(active.dataset.index):-1; + const size=columns();const top=Math.max(0,-grid.getBoundingClientRect().top); + const indexes=libraryWindow(state.items.length,size,top,window.innerHeight,focused);const keep=new Set(indexes.map(index=>state.items[index].id)); + for(const [id,card] of cards) if(!keep.has(id)) {removeCard(card);cards.delete(id);} + const children=[];let previousRow=-1; + for(const index of indexes) { + const row=Math.floor(index/size); + if(index%size===0 && row>previousRow+1) { + const gap=el('div',undefined,'library-row-spacer');gap.setAttribute('aria-hidden','true');gap.style.blockSize=`${(row-previousRow-1)*578-18}px`;children.push(gap); + } + let card=cards.get(state.items[index].id); + if(!card) {card=cardFor(state.items[index],index);cards.set(state.items[index].id,card);} + card.node.setAttribute('aria-posinset',index+1);card.node.setAttribute('aria-setsize',state.total);children.push(card.node);previousRow=row; + } + const remaining=Math.ceil(state.items.length/size)-previousRow-1; + if(remaining>0) {const gap=el('div',undefined,'library-row-spacer');gap.setAttribute('aria-hidden','true');gap.style.blockSize=`${remaining*578-18}px`;children.push(gap);} + // Remove obsolete spacers first: moving a focused card past an old spacer loses focus. + for(const child of [...grid.children]) if(!children.includes(child)) child.remove(); + let previous=null; + for(const child of children) {if(previous?previous.nextElementSibling!==child:grid.firstElementChild!==child) grid.insertBefore(child,previous?previous.nextElementSibling:grid.firstChild);previous=child;} + } + const schedule=()=>{if(!frame) frame=requestAnimationFrame(()=>{frame=0;renderWindow();});}; + function focusCard(index,last=false) { + if(index<0 || index>=state.items.length) return; + focusOverride=index;renderWindow();const card=cards.get(state.items[index].id).node; + const controls=card.querySelectorAll('a,button');(last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow(); + } + scope.listen(grid,'keydown',event=>{ + const card=event.target.closest('article[data-prompt-id]');if(!card) return; + const index=Number(card.dataset.index); + if(event.target.matches('a') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1));} + else if(event.key==='Tab') { + const controls=card.querySelectorAll('a,button');const next=index+(event.shiftKey?-1:1); + const edge=event.shiftKey?controls[0]:controls[controls.length-1]; + if(event.target===edge && state.items[next] && !cards.has(state.items[next].id)) {event.preventDefault();focusCard(next,event.shiftKey);} + } + }); + function render(next) { + state=next; + caption.textContent=`${next.items.length} of ${next.total} positive prompts · Images are grouped by positive prompt`; + message.textContent=next.error?'Prompt library could not be loaded. Retry or refresh.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':''; + message.setAttribute('role',next.error?'alert':'status');retry.hidden=!next.error;more.hidden=!next.hasMore || !!next.error;more.setAttribute('aria-disabled',String(next.loading)); + renderWindow();scheduleLoad(); + } + const stream=createLibraryStream({api,onChange:render,restore:visit?{filters,state:visit.state}:undefined}); + function maybeLoad() { + if(!observer || !state?.hasMore || state.loading || state.error || scope.signal.aborted) return; + const bounds=continuation.getBoundingClientRect();if(bounds.top<=window.innerHeight+250 && bounds.bottom>=0) stream.loadMore(); + } + function scheduleLoad() {if(!loadFrame) loadFrame=requestAnimationFrame(()=>{loadFrame=0;maybeLoad();});} + if(typeof IntersectionObserver==='function') {observer=new IntersectionObserver(entries=>{if(entries.some(entry=>entry.isIntersecting)) maybeLoad();},{rootMargin:'250px'});observer.observe(continuation);} + async function loadCategories() { + optionsRequest?.abort();const request=optionsRequest=new AbortController(); + try { + const data=await api.get('/prompt_manager/library/options',{signal:request.signal});if(scope.signal.aborted || optionsRequest!==request) return; + const values=[...new Set([...data.categories,filters.category].filter(Boolean))];category.replaceChildren(new Option('All categories',''),...values.map(value=>new Option(value,value)));category.value=filters.category;optionsStatus.hidden=true; + } catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent='Categories could not be loaded. Refresh library to retry.';optionsStatus.hidden=false;}} + } + const reset=()=>stream.reset(filters); + scope.listen(search,'input',scope.debounce(()=>{filters.text=search.value.trim();reset();},200)); + scope.listen(tags,'input',scope.debounce(()=>{filters.tags=[...new Set(tags.value.split(',').map(tag=>tag.trim()).filter(Boolean))];reset();},200)); + scope.listen(category,'change',()=>{filters.category=category.value;reset();});scope.listen(sort,'change',()=>{filters.sort=sort.value;reset();}); + scope.listen(refresh,'click',()=>{loadCategories();reset();});scope.listen(more,'click',()=>stream.loadMore());scope.listen(retry,'click',()=>stream.loadMore()); + scope.listen(window,'scroll',schedule);scope.listen(window,'resize',schedule); + scope.cleanup(()=>{shelfVisit=state?{filters:{...filters,tags:[...filters.tags]},state,scrollY:window.scrollY}:null;stream.dispose();optionsRequest?.abort();observer?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);cancelAnimationFrame(restoreFrame);for(const card of cards.values()) removeCard(card);cards.clear();}); + loadCategories();if(visit) {render(stream.state);restoreFrame=requestAnimationFrame(()=>{if(!scope.signal.aborted) {window.scrollTo(0,visit.scrollY);renderWindow();scheduleLoad();}});if(visit.state.loading || visit.state.error) stream.loadMore();} else reset(); + } + + function mountHistory() { + const history=el('section',undefined,'library-history');history.setAttribute('aria-label','Prompt history');main.append(history); + const back=link('Back to library','#/library','library-back'); + const body=el('div');history.append(back,body); + let blocks,galleryScope,detail,contentScope,updateBranches; + scope.cleanup(()=>{blocks?.dispose();galleryScope?.dispose();contentScope?.dispose();}); + const loader=createLibraryDetail({api,onChange:render});scope.cleanup(()=>loader.dispose()); + function render(state) { + if(detail && state.detail && !state.loading && !state.error) {detail=state.detail;updateBranches(state);return;} + contentScope?.dispose();contentScope=createScope();detail=null;updateBranches=null; + blocks?.dispose();blocks=null;galleryScope?.dispose();galleryScope=null;body.replaceChildren(); + if(state.loading || state.error) { + const message=el('p',state.loading?'Loading prompt history…':'Prompt history could not be loaded.','library-status');message.setAttribute('role',state.error?'alert':'status');body.append(message); + if(state.error) {const retry=createButton('Retry prompt history');contentScope.listen(retry,'click',()=>loader.load(positiveId));body.append(retry);}return; + } + detail=state.detail;main.querySelector('h1').textContent=title(detail.positive.text); + const subtitle=el('p','One positive prompt. Reusable negative variants. One shared gallery.','intro'); + const summary=el('article',undefined,'library-surface');summary.append(el('span',detail.positive.category || 'Uncategorized','library-chip')); + const promptContainer=el('div');promptContainer.setAttribute('role','region');promptContainer.setAttribute('aria-label','Saved prompt texts');summary.append(promptContainer); + const all=createButton('All images for this positive'); + const variants=el('section',undefined,'library-surface library-variants');variants.setAttribute('aria-label','Saved negative variants'); + variants.append(el('h2','Negative variants'),el('p','Choose a saved negative to inspect its full text and filter the shared gallery.','muted')); + const branches=el('div',undefined,'library-branches');branches.append(el('p','+ Positive prompt · unchanged','library-positive-lane')); + const choices=[]; + const variantCount=el('p','','muted');variantCount.setAttribute('role','status'); + const variantError=el('p','','muted');variantError.setAttribute('role','alert');variantError.hidden=true; + const more=createButton('Load more negative variants');const retry=createButton('Retry negative variants');retry.hidden=true; + contentScope.listen(more,'click',()=>loader.loadMore());contentScope.listen(retry,'click',()=>loader.loadMore()); + updateBranches=state=>{ + const known=new Set(choices.map(choice=>choice.variant.id)); + for(const [index,variant] of detail.variants.entries()) if(!known.has(variant.id)) { + const lane=el('div',undefined,'library-variant-lane');const button=createButton('−','library-variant-dot');button.setAttribute('aria-label',`Select negative variant ${index+1}`); + button.setAttribute('aria-pressed',String(variantSelections.get(positiveId)===variant.id)); + lane.append(button,el('span',`${variant.title || (variant.explicit_empty?'Explicitly empty negative':`Negative variant ${index+1}`)} · ${variant.image_count} linked images`));branches.append(lane);choices.push({button,variant}); + contentScope.listen(button,'click',()=>select(variant.id)); + } + variantCount.textContent=`${detail.variants.length} of ${detail.variant_total} saved negative variants`; + more.hidden=!detail.variants_has_more || !!state.variantsError;more.setAttribute('aria-disabled',String(state.loadingVariants));retry.hidden=!state.variantsError; + variantError.hidden=!state.variantsError;variantError.textContent=state.variantsError?'Negative variants could not be loaded. Retry to continue.':''; + }; + updateBranches(state); + if(!detail.variants.length) branches.append(el('p','No saved negative variants. Existing image associations remain available.','muted')); + const note=el('p','Saved combinations, not an execution timeline.','muted');variants.append(branches,variantCount,variantError,more,retry,note,all); + const context=el('p','','library-variant-context');context.setAttribute('role','status'); + const gallery=el('section',undefined,'library-shared-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(el('h2','Shared gallery'),context); + const images=el('div',undefined,'library-linked-images');gallery.append(images);let activePairId; + body.append(subtitle,summary,variants,gallery); + blocks=mountPromptBlocks(promptContainer,{prompts:libraryPromptRoles(detail),sender,parentScope:contentScope}); + function select(pairId) { + if(galleryScope && activePairId===pairId) return;activePairId=pairId; + variantSelections.set(positiveId,pairId);blocks.update(libraryPromptRoles(detail,pairId)); + for(const choice of choices) choice.button.setAttribute('aria-pressed',String(choice.variant.id===pairId));all.setAttribute('aria-pressed',String(pairId===null)); + context.textContent=pairId===null?'All images for this positive, including unassigned images. Choose a saved negative variant to inspect or send it.':`Negative variant ${detail.variants.findIndex(variant=>variant.id===pairId)+1} · same positive prompt`; + galleryScope?.dispose();galleryScope=createScope();images.replaceChildren(); + mountImages(images,api,galleryScope,{embedded:true,fetchPage:({parameters,signal})=>{const query=new URLSearchParams(parameters);if(pairId!==null) query.set('pair_id',String(pairId));return api.get(`/prompt_manager/library/${positiveId}/images?${query}`,{signal});}}); + } + contentScope.listen(all,'click',()=>select(null));const remembered=variantSelections.get(positiveId);select(detail.variants.some(variant=>variant.id===remembered)?remembered:null); + } + loader.load(positiveId); + } +} diff --git a/web/js/workspace/prompt-inspector.mjs b/web/js/workspace/prompt-inspector.mjs index bcd9bc8..a321abb 100644 --- a/web/js/workspace/prompt-inspector.mjs +++ b/web/js/workspace/prompt-inspector.mjs @@ -29,6 +29,7 @@ export function createInspectionLoader(api, onChange) { } const sources = { + library: 'Saved library prompt', execution: 'Recorded at execution', 'graph-inferred': 'Inferred from the saved workflow', 'embedded-parameters': 'Embedded generation parameters', @@ -46,14 +47,18 @@ function el(tag, text, className) { return node; } -export function mountPromptInspector(container, {api, image, sender, parentScope}) { +function childScope(parentScope) { const scope = createScope(); const dispose = () => scope.dispose(); parentScope.signal.addEventListener('abort', dispose, {once:true}); scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose)); + if (parentScope.signal.aborted) scope.dispose(); + return scope; +} + +export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry}) { + const scope = childScope(parentScope); container.classList.add('prompt-inspector'); - const loader = createInspectionLoader(api, render); - scope.cleanup(() => loader.dispose()); scope.listen(container, 'click', async event => { const send = event.target.closest('[data-send-role]'); if (send) { @@ -71,25 +76,26 @@ export function mountPromptInspector(container, {api, image, sender, parentScope if (!scope.signal.aborted) status.textContent = 'Copy unavailable. Select the prompt text to copy it.'; } } - if (event.target.closest('[data-retry-prompts]')) loader.load(image); + if (event.target.closest('[data-retry-prompts]')) onRetry?.(); }); let roles = {}; function render(state) { + if (scope.signal.aborted) return; roles = {}; container.replaceChildren(); if (state.loading || state.error) { const status = el('p', state.loading ? 'Loading prompts…' : 'Could not read prompts from this file.', 'muted'); status.setAttribute('role', state.error ? 'alert' : 'status'); container.append(status); - if (state.error) { + if (state.error && onRetry) { const retry = createButton('Retry prompt details'); retry.dataset.retryPrompts = ''; container.append(retry); } return; } - roles = promptRoles(state.prompts); - for (const role of ['positive', 'negative']) { + roles = Object.fromEntries(Object.entries(promptRoles(state.prompts)).filter(([role]) => visibleRoles.includes(role))); + for (const role of visibleRoles) { const title = `${role[0].toUpperCase() + role.slice(1)} prompt`; const record = state.prompts?.[role]; const block = el('section', undefined, `prompt-block prompt-${role}`); @@ -119,6 +125,16 @@ export function mountPromptInspector(container, {api, image, sender, parentScope container.append(pair); } } - loader.load(image); + render({prompts}); + return {update:prompts => render({prompts}), setState:render, dispose:() => scope.dispose()}; +} + +export function mountPromptInspector(container, {api, image, sender, parentScope}) { + const scope = childScope(parentScope); + let loader; + const blocks = mountPromptBlocks(container, {sender, parentScope:scope, onRetry:() => loader.load(image)}); + loader = createInspectionLoader(api, blocks.setState); + scope.cleanup(() => loader.dispose()); + if (!scope.signal.aborted) loader.load(image); return {dispose:() => scope.dispose()}; } diff --git a/web/js/workspace/state.mjs b/web/js/workspace/state.mjs index 55c7ca6..9ce1cfb 100644 --- a/web/js/workspace/state.mjs +++ b/web/js/workspace/state.mjs @@ -13,6 +13,10 @@ export const routes = [ ]; export function resolveRoute(hash) { + const match = /^#\/library\/([1-9]\d*)$/.exec(hash); + if (match && Number.isSafeInteger(Number(match[1]))) { + return {...routes.find(route => route.id === 'library'), positiveId:Number(match[1]), hash}; + } return routes.find(route => hash === `#/${route.id}`) || routes[0]; } @@ -29,9 +33,9 @@ export function mountRouter(win, onRoute) { let current; const update = () => { const route = resolveRoute(win.location.hash); - const hash = `#/${route.id}`; + const hash = route.hash || `#/${route.id}`; if (win.location.hash !== hash) win.history.replaceState(win.history.state, '', hash); - if (current === route) return; + if (current?.id === route.id && current?.positiveId === route.positiveId) return; current = route; onRoute(route); }; diff --git a/web/workspace.html b/web/workspace.html index b66a17e..3fe3de1 100644 --- a/web/workspace.html +++ b/web/workspace.html @@ -9,6 +9,7 @@ +