Compare commits

...
31 changed files with 2069 additions and 29 deletions
+321
View File
@@ -0,0 +1,321 @@
"""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(p.text)", "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<?"
params.append(variant_after)
variants = [
dict(row)
for row in conn.execute(
"SELECT pp.id,COALESCE(n.text,'') AS negative_text,"
"pp.category,pp.title,"
"pp.negative_prompt_id IS NULL AS explicit_empty,"
"(SELECT COUNT(DISTINCT image_id) FROM image_prompt_pairs ip "
"WHERE ip.pair_id=pp.id) AS image_count "
"FROM prompt_pairs pp "
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id "
"WHERE " + where + " ORDER BY pp.id DESC LIMIT ?",
[*params, variant_limit + 1],
)
]
variants_has_more = len(variants) > 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 get_library_previews(self, positive_ids: list[int]) -> dict[int, list[dict]]:
"""Read at most two links per parent in one bounded page query."""
if (
not isinstance(positive_ids, list)
or len(positive_ids) > 200
or any(type(value) is not int or value <= 0 for value in positive_ids)
):
raise ValueError("Invalid preview parent IDs")
if not positive_ids:
return {}
result = {value: [] for value in positive_ids}
marks = ",".join("?" for _ in positive_ids)
rows = self.model.get_connection().execute(
"WITH ranked AS (SELECT id,prompt_id,image_path,ROW_NUMBER() OVER "
"(PARTITION BY prompt_id ORDER BY id DESC) AS position "
f"FROM generated_images WHERE prompt_id IN ({marks})) "
"SELECT id,prompt_id,image_path FROM ranked WHERE position<=2 "
"ORDER BY prompt_id,id DESC",
positive_ids,
)
for row in rows:
result[row["prompt_id"]].append(dict(row))
return result
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<?"
params.append(after)
rows = conn.execute(
"SELECT g.id,g.image_path FROM generated_images g WHERE "
+ where
+ " ORDER BY g.id DESC LIMIT ?",
[*params, limit + 1],
).fetchall()
return {
"images": [dict(row) for row in rows[:limit]],
"total": total,
"has_more": len(rows) > limit,
"next_key": rows[limit - 1]["id"] if len(rows) > limit else None,
}
+2 -1
View File
@@ -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):
+3 -1
View File
@@ -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
+46
View File
@@ -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.
+13
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

+3
View File
@@ -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)
+255
View File
@@ -0,0 +1,255 @@
"""Read-only native library with bounded, filter-scoped continuation cursors."""
import base64
import binascii
import hashlib
import hmac
import json
import os
import time
from collections import OrderedDict
from collections.abc import Callable
from pathlib import Path
from aiohttp import web
from .images import _canonical, _media_access_error
from .pairs import pair_response
_SECRET = os.urandom(32)
class _NameCursorStore:
"""Full lexical keys stay off URLs, bounded by age, count and UTF-8 bytes."""
def __init__(
self,
ttl: int = 3600,
max_entries: int = 128,
max_bytes: int = 8 * 1024 * 1024,
clock: Callable[[], float] = time.monotonic,
) -> None:
self.clock = clock
self.ttl = ttl
self.max_entries = max_entries
self.max_bytes = max_bytes
self.entries = OrderedDict()
self.size = 0
def _remove(self, reference: str) -> None:
_expires, _scope, raw = self.entries.pop(reference)
self.size -= len(raw)
def _prune(self) -> None:
now = self.clock()
for reference, (expires, _scope, _raw) in list(self.entries.items()):
if expires <= now:
self._remove(reference)
def put(self, key: object, scope: str) -> str:
raw = json.dumps(key, ensure_ascii=False, allow_nan=False).encode()
if len(raw) > min(512 * 1024, self.max_bytes):
raise ValueError("Prompt name exceeds the cursor limit")
self._prune()
while self.entries and (
len(self.entries) >= self.max_entries
or self.size + len(raw) > self.max_bytes
):
self._remove(next(iter(self.entries)))
reference = base64.urlsafe_b64encode(os.urandom(24)).decode()
self.entries[reference] = (self.clock() + self.ttl, scope, raw)
self.size += len(raw)
return reference
def get(self, reference: str, scope: str) -> object:
self._prune()
entry = self.entries.get(reference)
if entry is None or entry[1] != scope:
raise ValueError("Name cursor expired; refresh the library")
self.entries.move_to_end(reference)
return json.loads(entry[2])
_NAME_CURSORS = _NameCursorStore()
def _scope(value: dict) -> str:
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
def _encode(key: object, scope: str, *, name: bool = False) -> str | None:
if key is None:
return None
value = (
{"scope": scope, "reference": _NAME_CURSORS.put(key, scope)}
if name
else {"scope": scope, "key": key}
)
payload = json.dumps(value, 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) not in ({"scope", "key"}, {"scope", "reference"})
or value["scope"] != scope
):
raise ValueError
if "reference" in value:
return _NAME_CURSORS.get(value["reference"], scope)
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:
records = self.db.get_library_previews(
[parent["id"] for parent in result["items"]]
)
for parent in result["items"]:
parent["previews"] = self._library_media(records[parent["id"]], roots)
await self._run_in_executor(previews)
result["next_cursor"] = _encode(
result.pop("next_key"), scope, name=options["sort"] == "name"
)
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})
+73 -3
View File
@@ -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")
+170
View File
@@ -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);
});
+46
View File
@@ -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();}
});
+52
View File
@@ -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('<literal>\nfull positive'),negative:{status:'unresolved',message:'Choose a saved negative.'}}});
assert.ok(f.walk(container,node=>node.textContent==='<literal>\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();}
});
@@ -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];}
}
});
+8
View File
@@ -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);
});
+49
View File
@@ -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();
});
+14
View File
@@ -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();
});
+284
View File
@@ -0,0 +1,284 @@
"""Native library endpoints use bounded cursors and rooted media identities."""
import json
import unittest
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 _"]
)
async def test_maximum_parent_page_uses_bounded_sql_queries_for_previews(self):
for index in range(200):
self._save_prompt(f"Parent {index}")
statements = []
original = self.api.db.model.get_connection
def trace_connection():
connection = original()
connection.set_trace_callback(statements.append)
return connection
with patch.object(
self.api.db.model, "get_connection", side_effect=trace_connection
):
response, result = await self.get(limit=200)
self.assertEqual(response.status, 200)
self.assertEqual(len(result["items"]), 200)
selects = [
query
for query in statements
if query.lstrip().upper().startswith(("SELECT", "WITH"))
]
self.assertLessEqual(len(selects), 5)
async def test_long_name_cursor_stays_small_and_preserves_full_sorting(self):
prefix = "猫" * 10_000
self._save_prompt(prefix + "Z")
alpha = self._save_prompt(prefix + "A")
_, first = await self.get(sort="name", limit=1)
self.assertEqual(first["items"][0]["id"], alpha)
self.assertLess(len(first["next_cursor"]), 512)
with self.api.db.model.get_connection() as connection:
connection.execute("DELETE FROM prompts WHERE id=?", (alpha,))
_, second = await self.get(sort="name", cursor=first["next_cursor"])
self.assertEqual([item["text"] for item in second["items"]], [prefix + "Z"])
async def test_name_cursor_expiry_and_eviction_require_refresh_without_large_urls(
self,
):
from py.api import library as module
for text in ("Alpha", "Beta", "Gamma"):
self._save_prompt(text)
now = [100]
store = module._NameCursorStore(max_entries=1, clock=lambda: now[0])
with patch.object(module, "_NAME_CURSORS", store):
_, first = await self.get(sort="name", limit=1)
now[0] = 3699
response, _ = await self.get(sort="name", cursor=first["next_cursor"])
self.assertEqual(response.status, 200)
now[0] = 3700
response, result = await self.get(sort="name", cursor=first["next_cursor"])
self.assertEqual(response.status, 400)
self.assertIn("refresh", result["error"])
response, fresh = await self.get(sort="name", limit=1)
self.assertEqual(response.status, 200)
_, replacement = await self.get(sort="name", limit=1)
self.assertNotEqual(fresh["next_cursor"], replacement["next_cursor"])
response, result = await self.get(sort="name", cursor=fresh["next_cursor"])
self.assertEqual(response.status, 400)
self.assertIn("refresh", result["error"])
response, _ = await self.get(
sort="name", cursor=replacement["next_cursor"], text="Alpha"
)
self.assertEqual(response.status, 400)
async def test_batch_preview_hydration_keeps_two_real_images_per_parent(self):
for parent_index in range(2):
positive = self._save_prompt(f"Parent {parent_index}")
for index in range(3):
path = make_png(self.output_dir / f"{parent_index}-{index}.png")
self.api.db.link_image_to_prompt(positive, str(path))
_, result = await self.get()
self.assertEqual(len(result["items"]), 2)
for item in result["items"]:
self.assertEqual(len(item["previews"]), 2)
self.assertTrue(item["previews"][0]["path"].endswith("-2.png"))
self.assertTrue(item["previews"][1]["path"].endswith("-1.png"))
self.assertTrue(all(image["root_index"] == 0 for image in item["previews"]))
class TestNameCursorBudget(unittest.TestCase):
def test_lru_byte_budget_scope_and_oversize_keys_are_bounded(self):
from py.api.library import _NameCursorStore
store = _NameCursorStore(max_entries=2, max_bytes=40)
one = store.put("one", "scope")
two = store.put("two", "scope")
self.assertEqual(store.get(one, "scope"), "one")
three = store.put("three", "scope")
with self.assertRaises(ValueError):
store.get(two, "scope")
self.assertEqual(store.get(three, "scope"), "three")
with self.assertRaises(ValueError):
store.get(one, "other")
self.assertLessEqual(store.size, store.max_bytes)
larger = store.put("x" * 35, "scope")
self.assertEqual(len(store.entries), 1)
self.assertEqual(store.get(larger, "scope"), "x" * 35)
with self.assertRaises(ValueError):
store.put("x" * 41, "scope")
self.assertEqual(len(store.entries), 1)
+299
View File
@@ -0,0 +1,299 @@
"""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.get_library_previews([b])
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)
)
def test_name_sort_uses_full_long_text_and_keeps_deleted_anchor_key(self):
prefix = "shared prefix " * 30
self.save(prefix + "Zulu")
alpha = self.save(prefix + "Alpha")
self.save(prefix + "Middle")
first = self.db.list_library(sort="name", limit=1)
self.assertEqual(first["items"][0]["id"], alpha)
self.assertEqual(first["next_key"]["value"], (prefix + "Alpha").lower())
with self.db.model.get_connection() as connection:
connection.execute("DELETE FROM prompts WHERE id=?", (alpha,))
rest = self.db.list_library(sort="name", after=first["next_key"])
self.assertEqual(
[item["text"] for item in rest["items"]],
[prefix + "Middle", prefix + "Zulu"],
)
def test_batch_previews_return_two_newest_links_per_parent_in_one_select(self):
parents = [self.save(f"Parent {index}") for index in range(200)]
expected = {}
for parent in parents[:3]:
ids = [
self.db.link_image_to_prompt(
parent, str(Path(self.temp.name) / f"{parent}-{index}.png")
)
for index in range(3)
]
expected[parent] = ids[-1:0:-1]
connection = self.db.model.get_connection()
statements = []
connection.set_trace_callback(statements.append)
try:
previews = self.db.get_library_previews(parents)
finally:
connection.set_trace_callback(None)
selects = [
query
for query in statements
if query.lstrip().upper().startswith(("SELECT", "WITH"))
]
self.assertEqual(len(selects), 1)
self.assertEqual(
{
parent: [image["id"] for image in images]
for parent, images in previews.items()
if images
},
expected,
)
self.assertEqual(self.db.get_library_previews([]), {})
with self.assertRaises(ValueError):
self.db.get_library_previews(parents + [9999])
+12
View File
@@ -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
}
}
+5 -3
View File
@@ -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 = [];
+17 -11
View File
@@ -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};
}
+5
View File
@@ -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');
+89
View File
@@ -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) || total<detail.variants.length || typeof more!=='boolean' || !(cursor===null || (typeof cursor==='string' && cursor.length>0)) || 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 && focused<count) keep.add(Math.floor(focused/columns));
return [...keep].sort((a,b)=>a-b).flatMap(row=>Array.from({length:columns},(_,column)=>row*columns+column).filter(index=>index<count));
}
+46
View File
@@ -0,0 +1,46 @@
main.library-main { --library-positive: #6bd6a4; max-inline-size: none; padding: 28px; }
.library-main :where(.library-shelf, .library-history, .library-card, .library-card-content, .library-card-prompts, .library-linked-images) { min-inline-size: 0; }
.library-main .library-toolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; margin-block: 20px 16px; }
.library-main .library-toolbar .ca-field { display: flex; flex: 1 1 140px; flex-direction: column; align-items: stretch; gap: 5px; }
.library-main .library-toolbar .ca-field:first-child { flex: 3 1 250px; }
.library-main .library-toolbar :where(input, select) { inline-size: 100%; min-inline-size: 0; min-block-size: 36px; }
.library-main .library-caption { display: flex; justify-content: space-between; font-size: 12px; color: var(--ca-muted); margin-block: 16px; }
.library-main .library-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.library-main .library-card { box-sizing: border-box; block-size: 560px; display: grid; grid-template-rows: 170px minmax(0, 1fr) auto; border: 1px solid var(--ca-line); border-radius: 8px; background: var(--ca-surface); }
.library-main .library-card-art { display: grid; grid-template-columns: 2fr 1fr; gap: 3px; block-size: 170px; border-radius: 7px 7px 0 0; background: var(--ca-field); overflow: clip; }
.library-main .library-card-art img { display: block; inline-size: 100%; block-size: 170px; aspect-ratio: 4 / 3; object-fit: cover; }
.library-main .library-card-art > :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; }
+203
View File
@@ -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);
}
}
+24 -8
View File
@@ -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()};
}
+6 -2
View File
@@ -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);
};
+1
View File
@@ -9,6 +9,7 @@
<link rel="stylesheet" href="js/workspace/style.css">
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
<link rel="stylesheet" href="js/workspace/images.css">
<link rel="stylesheet" href="js/workspace/library.css">
<script src="lib/viewerjs/viewer.min.js" defer></script>
<script type="module" src="js/workspace/index.mjs"></script>
</head>