Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72cf15455a | ||
|
|
aebb7d0d0d |
@@ -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,
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();}
|
||||
});
|
||||
@@ -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];}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user