Compare commits

..
Author SHA1 Message Date
Vito 71b55bc630 chore: release 3.6.0 (#622)
Adds Recently used, Most used and Name Z-A sorting to the Prompt library and
fixes network-share readers, model unload and bad save requests (#620). Adds an
OS classifier.
2026-11-02 20:08:43 +00:00
Vito 4c9cb7f5ea Cleanup: dead code, small fixes, Library sorts, old-UI links (#620) (#621)
* refactor: remove dead and test-only code and the write-only prompt registry (#620)

Removes helpers nothing calls, test-only wrappers and their tests, the unused
config writer, three unused images, and the prompt registry the nodes wrote to
but nothing read, which grew with every run.

* fix: network-share readers, model unload off the loop, bad save requests (#620)

Every read-only SQLite open now uses one URI helper, so a library on a Windows
network share works in backups, restores, moves and history reads. Unloading a
model no longer blocks the server. A malformed /save body gets a 400, not a 500.

* feat(library): add Recently used, Most used and Name Z-A sorting (#620)

The old Dashboard's sorts the community asked for, on the Prompt library, in
saved views, and with paging through the whole list.

* feat(workspace): stop linking the old pages (#620)

They show a removal notice since 3.5.1. System no longer links them, the load
failure offers a reload and ComfyUI instead, and the README says they are going
away. The pages still work at their URLs.
2026-11-02 19:00:30 +00:00
Vito 56d1072e58 chore: release 3.5.1 (#617)
Ships the smaller Registry package and the notice that the old pages will
be removed in a future release (#615).
2026-11-02 17:23:26 +00:00
Vito e71d0ca6b6 Smaller Registry package and old-UI removal notice (#615) (#616)
* chore(package): leave development files out of the Registry package (#615)

The package held tests, tools, docs and images used only on GitHub. A
.comfyignore drops them, taking the download from 17.4 MB to about 3.9 MB, and
a test holds the rules to what ComfyUI needs at run time.

* feat(legacy): say the old pages will be removed in a future release (#615)

Each old page shows a notice with a link to where its job lives in the
workspace. The pages keep working.
2026-11-02 15:31:02 +00:00
Vito 6f442bf423 chore(deps-dev): flake8 7.5.0, pre-commit 4.7.0, coverage 7.17.0 (#614)
* chore(deps-dev): bump flake8 to 7.5.0 (#598)

* chore(deps-dev): update pre-commit to >=4.7.0 (#601)

* chore(deps-dev): update coverage to >=7.17.0 (#602)
2026-11-02 14:42:47 +00:00
Vito bf2fa3f50e chore: release 3.5.0 (#608)
Adds the thumbnail location and move (#320), Send to All (#542), skipped
tags and saved instructions (#431), the Pair category repair (#550) and
lower image memory use (#523). Raises the Pillow minimum to 10.1.0 (#604).
2026-11-01 22:13:00 +00:00
Vito 2e9322d5c4 fix(thumbnails): never let an older folder check overwrite a newer answer (#611) (#612)
Two refresh() callers could publish out of order. A check that looked
before the folder was removed could set "available" after a newer check
said "missing", and the state stayed wrong until the next probe. Each
check now has an order number, and an older answer is dropped.
2026-11-01 21:16:55 +00:00
Vito a1377cec36 ci: pin the Playwright image by digest (#609) (#610)
The v1.64.1-noble tag was re-pushed with Node 26, which fails the Node 24
guard in every browser job. Pin the last green image's digest so the
browsers, fonts and Node only change through a reviewed bump.
2026-11-01 19:58:19 +00:00
Vito fa98d0644d Match ComfyUI's dependency floors (#604) and fix the stalled store test race (#603) (#606)
* chore(deps): match ComfyUI's Pillow, aiohttp and numpy floors (#604)

PromptManager never fights ComfyUI on versions: the floors follow its
requirements.txt and Dependabot no longer raises them. The full suite passes on
Pillow 10.1.0 and aiohttp 3.11.8; numpy was tested at 1.26.4, the first with
Python 3.12 wheels. A test that left response bodies unread hung the aiohttp
client before 3.11.11, so it reads them now.

* test(thumbnails): the stalled store call test runs without the probe (#603)

note_failure() wakes the probe, which finds the healthy test folder again at
once, so the next lookup could do store I/O and the test failed intermittently.
2026-11-01 16:38:29 +00:00
88 changed files with 576 additions and 1139 deletions
+21
View File
@@ -0,0 +1,21 @@
# Left out of the Comfy Registry package (comfy node publish). Git clones still
# have everything. tests/test_package_contents.py holds these rules to what
# ComfyUI needs at run time; keep each one anchored to the root with a leading /.
# Development folders
/tests/
/tools/
/docs/
/.github/
# Screenshots used only by README-legacy.md on GitHub
/images/
# Development files
/TESTING.md
/Makefile
/requirements-dev.txt
/.coveragerc
/.flake8
/.pre-commit-config.yaml
/.gitignore
+5
View File
@@ -17,6 +17,11 @@ updates:
prefix: "deps(pip)"
include: "scope"
rebase-strategy: "auto"
# These floors follow ComfyUI's requirements.txt, never the newest release.
ignore:
- dependency-name: "Pillow"
- dependency-name: "aiohttp"
- dependency-name: "numpy"
# Optional: group non-breaking updates to reduce PR noise
groups:
python-minor-and-patch:
+1 -1
View File
@@ -80,7 +80,7 @@ jobs:
# pulled them from Ubuntu's apt mirror on every run, and three runs (#349, #378, #382) crawled at
# ~26 kB/s for 11-12 minutes until the job hit its limit after every test had passed.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001
timeout-minutes: 24
steps:
+3 -3
View File
@@ -53,7 +53,7 @@ jobs:
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
# tests/vendor/browser-image.test.mjs checks it). Browsers and fonts come from the image.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001
timeout-minutes: 20
# Public-repo ubuntu-latest runners have 4 vCPUs: three Playwright workers, each with its own test
@@ -121,7 +121,7 @@ jobs:
runs-on: ubuntu-latest
# Same image as the shards: the raw files name sources by the container's workspace path.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001
timeout-minutes: 10
steps:
@@ -192,7 +192,7 @@ jobs:
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
# tests/vendor/browser-image.test.mjs checks it). Browsers and fonts come from the image.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001
timeout-minutes: 20
# Public-repo ubuntu-latest runners have 4 vCPUs: three Playwright workers, each with its own test
+4 -4
View File
@@ -12,10 +12,10 @@ Prompt Manager saves your prompts as you generate, links each image back to the
## What's new: rebuilt from the ground up
- **A new UI.** One app, the workspace, replaces the separate dashboard, gallery and metadata pages. The old pages still work if you want them.
- **A new UI.** One app, the workspace, replaces the separate dashboard, gallery and metadata pages.
- **Classic and Nodes 2.0.** The nodes work in both ComfyUI renderers. CI starts a real ComfyUI and checks both.
- **Tested end to end.** Unit tests, browser tests in Chromium, Firefox, WebKit and touch emulation, and tests against a real ComfyUI.
- **Mac, Linux and Windows.** The Python suite runs on all three, on Python 3.12, 3.13 and 3.14.
- **Linux and Windows.** The Python suite runs on both, on Python 3.12, 3.13 and 3.14. macOS should work but is not tested.
## See it
@@ -212,9 +212,9 @@ Set `PROMPT_MANAGER_DATA_DIR` to an absolute folder to keep the library somewher
- **Older images:** a one-time notice offers a metadata scan that links them to prompts and negatives. Scans don't count as new runs.
- **Going back isn't supported.** Workflows are now stored once, and an older version shows new records without their workflow or prompt graph. Back up first in System > Backup & export. See [the old README's changelog](README-legacy.md#changelog) for the manual way back.
## The old pages still work
## The old pages will be removed
They stay at their URLs, and System's Overview section links to them:
They still work at their URLs for now and will be removed in a future release. Everything they do is moving to the workspace.
| Page | URL |
| --- | --- |
+19 -8
View File
@@ -77,7 +77,7 @@ PM_TEST_WORKERS=8 npm run --prefix tools/vendor test:coverage # whole
npm run --prefix tools/vendor test:coverage:shard -- 1/4 # node units, viewer suite, workflows 1/4
npm run --prefix tools/vendor test:coverage:shard -- 2/4 # workflows 2/4 (likewise 3/4, 4/4)
npm run --prefix tools/vendor coverage:merge # report and baseline check of every raw file
npm run --prefix tools/vendor test:workflows -- --project webkit --shard 2/2
npm run --prefix tools/vendor test:workflows -- --project webkit --shard 2/3
```
`coverage:merge` refuses an empty raw directory; it does not count shards, so run
@@ -111,8 +111,11 @@ distributions, browser system dependencies may need manual installation. For a
quick browser run, use `test:workflows -- --project chromium`.
CI runs the browser jobs inside the pinned image
`mcr.microsoft.com/playwright:v<@playwright/test version>-noble`
`mcr.microsoft.com/playwright:v<@playwright/test version>-noble@sha256:<digest>`
(`tests/vendor/browser-image.test.mjs` keeps the tag in step with the lockfile).
The digest is part of the pin, because Microsoft re-pushes tags: `v1.64.1-noble`
moved from Node 24 to Node 26 on 2026-11-01 (#609). To bump it, change the tag and
digest together and check the new image's Node against the Node 24 guard.
Concept comparisons measure geometry, and geometry follows the fonts a browser
falls back to: the concept names Inter, which neither the image nor a stock
runner ships. On a plain `ubuntu-latest` runner the fallback is DejaVu Sans,
@@ -190,8 +193,8 @@ waiting for the `frontend coverage` jobs.
The `viewerjs` check runs on every PR, including backend-only changes. It is an
aggregate job. It requires the four `frontend coverage (shard N/4)` jobs and
`frontend coverage (merge)` to succeed on every event. The four
`page workflows (firefox|webkit, shard N/2)` jobs run only in the merge queue,
`frontend coverage (merge)` to succeed on every event. The six
`page workflows (firefox|webkit, shard N/3)` jobs run only in the merge queue,
on `main` and on `workflow_dispatch` (see "Pull requests and the merge queue").
`viewerjs` requires them to be skipped on a pull request and to succeed
everywhere else. Any other failed, cancelled or skipped job fails it. Every browser job runs with `PM_TEST_WORKERS: 3` (the public
@@ -665,6 +668,10 @@ Browser tests: `workspace-stale-source.spec.mjs` (`/__test__/sources/reverse` an
`/__test__/scope-key/rotate`, both undone by every reset); the manual-Refresh
recovery specs in `workspace-images.spec.mjs` answer every rescope 409.
## Registry package
`comfy node publish` zips the tracked files minus `.comfyignore`, so a new development file or folder at the root ships until it is listed there. `tests/test_package_contents.py` keeps each rule anchored to the root, checks that development paths are left out and that nothing ComfyUI needs at run time is. Git clones are unaffected.
## Windows portability preflight
Before changing paths, SQLite, streams, restore or cleanup:
@@ -2728,7 +2735,7 @@ the grid) and D43 (sorted chips, checked against the concept with its chips sort
D44 needs no mapping: the comparison runs with no Refine filter active, so the button
reads "Filters" as the concept's does. Nothing else follows the grid: the legacy
"Current prompt library" link is removed from the shelf; the legacy Library stays at
`/prompt_manager/admin` by direct URL, linked only from System (#525, D165)
`/prompt_manager/admin` by direct URL (#525, #620)
(`tests/browser/workspace.spec.mjs`). `library comparison detects
mutations` injects `.card-footer { padding: 2px }` and removes `.card-meta .chip.red`.
@@ -3236,7 +3243,7 @@ npm run --prefix tools/vendor test:workflows -- history-concept.spec.mjs \
`#/inspect` ("Inspect a file") is a native workspace route; it no longer hands off to
the legacy metadata page, which stays at its URL
(`/prompt_manager/gallery.html`) linked only from System (#525, D165). The page is the concept's `#metadata`
(`/prompt_manager/gallery.html`) at its URL only (#525, #620). The page is the concept's `#metadata`
(app.js `renderMetadata` a740–748, selection a1715–1729; style.css 709–722, 1180–1194,
1543–1545, 1559–1564) in the concept's own classes (`.surface`, `.file-drop`,
`.spaced-20`, `.local-file-preview`, all from the synced `concept.css`), with no
@@ -4658,6 +4665,9 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
concept row's), `skipped tags add, preview, remove, save and reload (D202)`, `skipped
tags preview checks library tags only …` (workspace-system-tagging.spec) and
`suggestions omit skipped tags and say how many were hidden` (workspace-tagging.spec).
- **D205** (Prompt library, #620, beyond the concept): the sort menu appends Recently used, Most used and Name Z–A
after Oldest first. These are the old Dashboard's sorts, kept before its removal because the community asked for
them. A prompt never run sorts last in both use orders.
- **D203** (Auto-tagging, #431, beyond the concept): "Saved instructions" replaces the
concept's "LLM tag template" field (the mock's state 2): a `.setting-block`
(`data-setting="tag-instructions"`, `data-key="autotag_instructions"`) with the label,
@@ -7485,7 +7495,7 @@ Deploying this backend needs a ComfyUI restart (Python and route changes).
`#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no
longer hands off to the legacy tag manager. That page stays at
`/prompt_manager/admin#/tags` linked only from System (#525, D165); tag search and sort, the untagged
`/prompt_manager/admin#/tags` at its URL only (#525, #620); tag search and sort, the untagged
entry (D149) and multi-tag OR (the Library's Tag match, D150) moved to the workspace
(#444); bulk prompt delete stays on the legacy admin page (bulk tags: the Prompt
library's selection bar, D151). A prompt's category is changed in the Prompt library
@@ -7617,7 +7627,8 @@ The legacy pages (`/prompt_manager/admin`, `admin#/tags`, `gallery`, `gallery.ht
their URLs, and admin's Metadata and Gallery buttons still open their legacy pages (maintainer
ruling; pinned by `legacy-system-links.spec.mjs`).
- **D165** (beyond the concept, maintainer ruling 2026-10-30): System's Overview section ends
- **D165 retired** (#620): the old pages show their removal notice since 3.5.1, so System no longer links them and the load failure below offers a reload and ComfyUI. The original ruling:
**D165** (beyond the concept, maintainer ruling 2026-10-30): System's Overview section ends
with one small line, "Legacy pages: Dashboard · Tags · Gallery · Metadata viewer"
(`.legacy-pages`, new tabs), outside `.settings-layout`. The concept has no such line;
`mapOverviewConcept` adds it to the page tree.
-7
View File
@@ -291,13 +291,6 @@ class AnnotationOperations:
conn.execute(_BUMP_INDEX)
return conn.execute(_REVISION, (byte_id,)).fetchone()[0]
def _current_set(self, conn, sha256, byte_id, revision) -> dict:
return {
"sha256": sha256,
"tags": self._byte_tags(conn, byte_id),
"tag_revision": revision,
}
def read_image_tags(self, locator: str, sha256: str) -> dict:
"""The stored set of these exact bytes, or an empty set at revision 0.
+2 -22
View File
@@ -16,18 +16,17 @@ check, and none of them carries a server path.
import datetime
import logging
import ntpath
import os
import sqlite3
from pathlib import PurePosixPath, PureWindowsPath
from urllib.parse import quote
try:
from ..utils.hashing import register_prompt_functions
from .models import normalize_image_path
from .sqlite_uri import readonly_uri
except ImportError:
from utils.hashing import register_prompt_functions
from database.models import normalize_image_path
from database.sqlite_uri import readonly_uri
logger = logging.getLogger("prompt_manager.database.health")
@@ -89,25 +88,6 @@ def _counted(lines):
return _result(count, detail)
def readonly_uri(db_path, *, windows=None):
"""The ``mode=ro`` SQLite URI of *db_path*, on this platform or the given one.
pathlib spells a UNC path ``\\\\server\\share\\x.db`` as
``file://server/share/x.db``, a remote authority SQLite refuses to open.
With an empty authority (``file:////server/share/x.db``) SQLite hands
Windows the ``//server/share`` path, which it opens as the UNC path.
"""
if windows is None:
windows = os.name == "nt"
db_path = os.path.abspath(db_path)
if not windows:
return PurePosixPath(db_path).as_uri() + "?mode=ro"
path = PureWindowsPath(db_path)
if ntpath.splitdrive(str(path))[0].startswith("\\\\"):
return "file:" + quote("//" + path.as_posix()) + "?mode=ro"
return path.as_uri() + "?mode=ro"
def _integrity(conn, _roots):
rows = [
row[0] for row in conn.execute(f"PRAGMA integrity_check({INTEGRITY_LIMIT})")
+3
View File
@@ -97,6 +97,9 @@ _ORDERS = {
"newest": ("COALESCE(julianday(p.created_at),0)", "DESC"),
"oldest": ("COALESCE(julianday(p.created_at),0)", "ASC"),
"name": (_NAME, "ASC"),
"name-desc": (_NAME, "DESC"),
"most-used": ("COALESCE(p.run_count,0)", "DESC"),
"last-used": ("COALESCE(julianday(p.last_used_at),0)", "DESC"),
"rating": ("COALESCE(p.rating,0)", "DESC"),
}
# Pair archive scopes for variant and image reads, as conditions on alias pp.
+2 -2
View File
@@ -47,11 +47,11 @@ import sqlite3
import time
import types
import uuid
from pathlib import Path
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional, Tuple
from .models import BUSY_TIMEOUT_SECONDS, PromptModel
from .sqlite_uri import readonly_uri
try:
from ..utils import library_location as ll
@@ -322,7 +322,7 @@ def _verify_copy(temp: str, counts, documents) -> None:
"verify-failed",
f"{MESSAGES['verify-failed']} {detail}" if detail else None,
)
uri = Path(os.path.abspath(temp)).as_uri() + "?mode=ro"
uri = readonly_uri(temp)
try:
with contextlib.closing(sqlite3.connect(uri, uri=True)) as conn:
copied = _describe(conn)
+3 -4
View File
@@ -14,6 +14,7 @@ from contextlib import closing
from pathlib import Path
from typing import Optional, Set, Tuple
from .sqlite_uri import readonly_uri
from .managed_connection import (
ManagedConnection,
await_closed,
@@ -1199,7 +1200,7 @@ class PromptModel:
if header != SQLITE_HEADER:
return False, "Not a SQLite database file"
uri = Path(os.path.abspath(path)).as_uri() + "?mode=ro"
uri = readonly_uri(path)
try:
conn = sqlite3.connect(uri, uri=True)
except sqlite3.Error as e:
@@ -1306,9 +1307,7 @@ class PromptModel:
replacement (for example, incompatible page sizes in WAL mode),
propagate the error rather than copy files under other open owners.
"""
source = sqlite3.connect(
Path(src_path).resolve().as_uri() + "?mode=ro", uri=True
)
source = sqlite3.connect(readonly_uri(Path(src_path).resolve()), uri=True)
try:
target = self.get_connection()
# The target is written under backup's step too: mark it in use
+2 -1
View File
@@ -42,6 +42,7 @@ from typing import Dict, Optional
from . import workflow_documents as wd
from .execution_history_schema import rotate_history_epoch
from .safety_backup import take_safety_backup
from .sqlite_uri import readonly_uri
logger = logging.getLogger("prompt_manager.database.restore_stage")
@@ -114,7 +115,7 @@ def file_digest(path: str) -> str:
def _readonly(path: str) -> sqlite3.Connection:
return sqlite3.connect(Path(path).resolve().as_uri() + "?mode=ro", uri=True)
return sqlite3.connect(readonly_uri(Path(path).resolve()), uri=True)
def _schema(path: str) -> list:
+9 -1
View File
@@ -31,7 +31,15 @@ MAX_STATE_BYTES = 4096
MAX_TAGS = 20
MAX_TAG_LENGTH = 50
_IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating")
_LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
_LIBRARY_SORTS = (
"newest",
"oldest",
"name",
"name-desc",
"most-used",
"last-used",
"rating",
)
_MEDIA = ("all", "image", "video")
# How a Library view's Tags match (#444): every tag, any of them, or parents
# without tags ("untagged" never goes with tags; the listing refuses both).
+25
View File
@@ -0,0 +1,25 @@
"""The read-only SQLite URI of a path, safe for Windows network shares."""
import ntpath
import os
from pathlib import PurePosixPath, PureWindowsPath
from urllib.parse import quote
def readonly_uri(db_path, *, windows=None):
"""The ``mode=ro`` SQLite URI of *db_path*, on this platform or the given one.
pathlib spells a UNC path ``\\\\server\\share\\x.db`` as
``file://server/share/x.db``, a remote authority SQLite refuses to open.
With an empty authority (``file:////server/share/x.db``) SQLite hands
Windows the ``//server/share`` path, which it opens as the UNC path.
"""
if windows is None:
windows = os.name == "nt"
db_path = os.path.abspath(db_path)
if not windows:
return PurePosixPath(db_path).as_uri() + "?mode=ro"
path = PureWindowsPath(db_path)
if ntpath.splitdrive(str(path))[0].startswith("\\\\"):
return "file:" + quote("//" + path.as_posix()) + "?mode=ro"
return path.as_uri() + "?mode=ro"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

-14
View File
@@ -3,7 +3,6 @@ PromptManager: Main custom node implementation that extends CLIPTextEncode
with persistent prompt storage and search capabilities.
"""
import time
from typing import Any, Dict, Optional, Tuple
if __package__:
@@ -259,19 +258,6 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
if not save_to_library:
return result # #90: no metadata registration, no history text
# Register with ComfyUI integration for standard metadata compatibility
node_id = f"promptmanager_{int(time.time() * 1000)}"
self.comfyui_integration.register_prompt(
node_id,
encoding_text.strip(),
{
"category": category.strip() if category else None,
"tags": extended_tags,
"prompt_id": prompt_id,
"prepend_text": prepend_text.strip() if prepend_text else None,
"append_text": append_text.strip() if append_text else None,
},
)
record_runtime_context(
type(self).__name__, text, prepend_text, append_text, encoding_text
)
-22
View File
@@ -19,7 +19,6 @@ except ImportError:
try:
from .database.operations import PromptDatabase
from .utils.comfyui_integration import get_comfyui_integration
from .utils.image_monitor import get_image_monitor
from .utils.prompt_tracker import get_prompt_tracker
from .utils.hashing import generate_prompt_hash
@@ -38,7 +37,6 @@ except ImportError:
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from database.operations import PromptDatabase
from utils.comfyui_integration import get_comfyui_integration
from utils.image_monitor import get_image_monitor
from utils.prompt_tracker import get_prompt_tracker
from utils.hashing import generate_prompt_hash
@@ -86,7 +84,6 @@ class PromptManagerBase:
self.db = PromptDatabase()
self.prompt_tracker = get_prompt_tracker(self.db)
self.image_monitor = get_image_monitor(self.db, self.prompt_tracker)
self.comfyui_integration = get_comfyui_integration()
self._start_gallery_system()
self.logger.debug(f"{self.__class__.__name__} node initialization completed")
@@ -433,18 +430,6 @@ class PromptManagerBase:
self.logger.error(f"Error searching prompts: {e}")
return []
def search_prompts_api(self, search_text: str = "") -> List[Dict[str, Any]]:
"""API method for JavaScript UI to search prompts."""
return self._search_prompts(search_text=search_text)
def get_recent_prompts_api(self, limit: int = 20) -> List[Dict[str, Any]]:
"""API method for JavaScript UI to get recent prompts."""
try:
return self.db.get_recent_prompts(limit=limit)
except Exception as e:
self.logger.error(f"Error getting recent prompts: {e}")
return []
def _start_gallery_system(self):
"""Initialize and start the gallery monitoring system."""
try:
@@ -455,13 +440,6 @@ class PromptManagerBase:
self.logger.error(f"Failed to start gallery system: {e}")
self.logger.warning("Gallery features will be disabled")
def get_gallery_status(self) -> Dict[str, Any]:
"""Get status of the gallery system."""
return {
"image_monitor": self.image_monitor.get_status(),
"prompt_tracker": self.prompt_tracker.get_status(),
}
def cleanup_gallery_system(self):
"""Clean up gallery system resources.
-14
View File
@@ -2,7 +2,6 @@
import hashlib
import json
import time
from typing import Any, Dict, Optional, Tuple
if __package__:
@@ -173,19 +172,6 @@ class PromptManagerPair(PromptManagerFocused):
self.logger.warning(f"Could not save or track paired prompt: {error}")
if is_positive:
self._mark_untracked(prompt, unique_id)
try:
self.comfyui_integration.register_prompt(
f"promptmanager_pair_{time.time_ns()}",
positive,
{
"prompt_id": prompt_id,
"pair_id": pair_id if pair_used else None,
"negative_text": negative_text,
"category": category.strip() or None,
},
)
except Exception as error:
self.logger.warning(f"Could not register paired metadata: {error}")
record_runtime_context(
type(self).__name__,
text,
-14
View File
@@ -3,7 +3,6 @@ PromptManagerText: A text-only version of PromptManager that outputs STRING
without CLIP encoding, while maintaining all database and search features.
"""
import time
from typing import Any, Dict, Optional, Tuple
if __package__:
@@ -230,19 +229,6 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
if not save_to_library:
return result # #90: no metadata registration, no history text
# Register with ComfyUI integration for standard metadata compatibility
node_id = f"promptmanagertext_{int(time.time() * 1000)}"
self.comfyui_integration.register_prompt(
node_id,
final_text.strip(),
{
"category": category.strip() if category else None,
"tags": extended_tags,
"prompt_id": prompt_id,
"prepend_text": prepend_text.strip() if prepend_text else None,
"append_text": append_text.strip() if append_text else None,
},
)
record_runtime_context(
type(self).__name__, text, prepend_text, append_text, final_text
)
-47
View File
@@ -89,7 +89,6 @@ from .source_rescope import SourceRescopeMixin
try:
from ...database.operations import PromptDatabase
from ...utils.file_prompt import parse_comfyui_prompt
from ...utils.logging_config import get_logger
from ...utils.native_memory import release_free_memory
from ...utils.png_text import png_text
@@ -101,7 +100,6 @@ except ImportError:
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
)
from database.operations import PromptDatabase
from utils.file_prompt import parse_comfyui_prompt
from utils.logging_config import get_logger
from utils.native_memory import release_free_memory
from utils.png_text import png_text
@@ -254,23 +252,6 @@ async def _read_json_body(request, max_bytes=JSON_BODY_MAX_BYTES):
return data, None
class _LongJobGuard:
"""Async context manager handed out by ``_acquire_long_job``."""
def __init__(self, lock):
self._lock = lock
async def __aenter__(self):
# The caller checked ``locked()`` on this same loop iteration, so this
# acquire never waits.
await self._lock.acquire()
return self
async def __aexit__(self, exc_type, exc, tb):
self._lock.release()
return False
# ── Gzip compression middleware ────────────────────────────────────────
_GZIP_MIN_SIZE = 1024 # Only compress bodies larger than 1 KB
_GZIP_TYPES = frozenset(
@@ -426,10 +407,6 @@ class PromptManagerAPI(
self._workspace_card_cache = {}
# Favorite keys of each root's cached scan (images._favorite_listing).
self._favorite_listing_cache = {}
# Single-flight locks for long jobs, keyed by job family; see
# _acquire_long_job(). Created lazily so no lock binds to an event
# loop before the server's loop exists.
self._long_jobs: dict = {}
# This session's jobs (py/api/jobs.py): the metadata scan, and later
# every other long operation the Activity page tracks.
self._jobs = JobRegistry(self.logger)
@@ -486,26 +463,6 @@ class PromptManagerAPI(
_public_error = staticmethod(_public_error)
_read_json_body = staticmethod(_read_json_body)
def _acquire_long_job(self, name):
"""Claim the single-flight slot for a long job family, or None when busy.
Usage in a handler::
job = self._acquire_long_job("duplicates")
if job is None:
return self._long_job_busy_response("Duplicate scan")
async with job:
...
Families are free-form strings ("duplicates", "thumbnails",
"autotag", ...); the lock is released when the ``async with`` block
exits, including on exceptions. Must be called from the event loop.
"""
lock = self._long_jobs.setdefault(name, asyncio.Lock())
if lock.locked():
return None
return _LongJobGuard(lock)
@staticmethod
def _long_job_busy_response(label):
"""409 envelope for a long job that is already running."""
@@ -1079,7 +1036,3 @@ class PromptManagerAPI(
f"Unreadable image {os.path.basename(str(image_path))}: {e}"
)
return {}
def _parse_comfyui_prompt(self, metadata):
"""Parse ComfyUI and A1111 prompt data from metadata."""
return parse_comfyui_prompt(metadata)
+3 -1
View File
@@ -30,8 +30,10 @@ from .jobs import (
)
try:
from ...database.sqlite_uri import readonly_uri
from ...utils.parallel import map_parallel
except ImportError:
from database.sqlite_uri import readonly_uri
from utils.parallel import map_parallel
try:
@@ -399,7 +401,7 @@ def _inspect_restore_upload(path):
Returns:
None when acceptable, otherwise a human-readable reason (blocking).
"""
uri = Path(os.path.abspath(path)).as_uri() + "?mode=ro"
uri = readonly_uri(path)
try:
conn = sqlite3.connect(uri, uri=True)
except sqlite3.Error as e:
+2 -11
View File
@@ -943,16 +943,6 @@ class AutotagRoutesMixin:
return refusal.response
return web.json_response({"success": True, "job": job.summary()}, status=202)
def _gated_runner(self, owner):
"""Executor calls under the model gate *owner* holds (plain when None)."""
if owner is None:
return self._run_in_executor
async def run(func, *args, **kwargs):
return await model_gate.run(owner, func, *args, **kwargs)
return run
def _resolve_allowed_image_path(self, raw_path: str) -> Optional[str]:
"""Return the real path when it is a media file inside an output directory."""
if Path(str(raw_path)).suffix.lower() not in _MEDIA_EXTENSIONS:
@@ -1421,7 +1411,8 @@ class AutotagRoutesMixin:
)
model_type = service.get_loaded_model_type()
service.unload_model()
# Off the loop; a dropped request keeps the gate until it ends.
await model_gate.run(owner, service.unload_model)
finally:
model_gate.release(owner)
+3 -2
View File
@@ -45,7 +45,6 @@ import os
import sqlite3
import tempfile
from contextlib import closing
from pathlib import Path
from aiohttp import web
@@ -56,6 +55,7 @@ from .maintenance_plans import PlanExpired, PlanUsed
from .restore_plans import RestorePlanStore
try:
from ...database.sqlite_uri import readonly_uri
from ...database import restore_stage
from ...database.operations import PromptDatabase
from ...database.safety_backup import (
@@ -66,6 +66,7 @@ try:
safety_temp,
)
except ImportError:
from database.sqlite_uri import readonly_uri
from database import restore_stage
from database.operations import PromptDatabase
from database.safety_backup import (
@@ -186,7 +187,7 @@ def snapshot_corruption(path):
with open(path, "rb") as handle:
if handle.read(len(SQLITE_HEADER)) != SQLITE_HEADER:
return "Not a SQLite database file"
uri = Path(os.path.abspath(path)).as_uri() + "?mode=ro"
uri = readonly_uri(path)
with closing(sqlite3.connect(uri, uri=True)) as conn:
row = conn.execute("PRAGMA integrity_check").fetchone()
except (OSError, sqlite3.Error) as error:
+3 -1
View File
@@ -377,7 +377,9 @@ class LibraryRoutesMixin:
[parent["id"] for parent in result["items"]]
)
result["next_cursor"] = _encode(
result.pop("next_key"), scope, name=options["sort"] == "name"
result.pop("next_key"),
scope,
name=options["sort"] in ("name", "name-desc"),
)
return result, records
+33 -11
View File
@@ -69,6 +69,8 @@ _state_lock = threading.Lock()
_parsed = preview_location.ParsedLocation()
_available = True
_problem = None
# The newest check whose answer was published.
_published_order = 0
_keys = {}
_key_cache = {}
_clock = time.monotonic
@@ -167,10 +169,13 @@ def refresh():
if parsed.mode != FOLDER_MODE or parsed.problem is not None:
return parsed.mode == SOURCE_MODE
try:
available, problem, marker = _probe.check(parsed.path, parsed.id)
(available, problem, marker), order = _probe.ordered_check(
parsed.path, parsed.id
)
except (OSError, TimeoutError, RuntimeError):
available, problem, marker = False, "unreachable", None
_set_state(parsed, available, problem, marker)
order = _probe.latest_order()
_set_state(parsed, available, problem, marker, order)
return available
@@ -207,11 +212,16 @@ def mark_invalid():
_available, _problem = False, "invalid"
def _set_state(parsed, available, problem, marker):
global _available, _problem
def _set_state(parsed, available, problem, marker, order):
"""Publish a check's answer, unless a check that started later already
published (two callers can finish out of order, #611)."""
global _available, _problem, _published_order
with _state_lock:
if _parsed is not parsed:
return # the setting changed meanwhile
if order < _published_order:
return
_published_order = order
_available, _problem = available, problem
keys = dict((marker or {}).get("keys") or {})
if keys != _keys:
@@ -261,14 +271,25 @@ class _Probe:
self._stop = threading.Event()
self._thread = None
self._checking = None
self._order = 0
def check(self, path, expected_id):
"""``folder_state`` within IO_BUDGET; raises TimeoutError.
"""``folder_state`` within IO_BUDGET; see :meth:`ordered_check`."""
return self.ordered_check(path, expected_id)[0]
The answer comes from a check that started after this call: one
already running may have looked before what the caller just did, so
it is waited for (never run twice at once; a stuck one times out here)
and followed by a new one."""
def latest_order(self):
"""The order number of the check started last."""
with self._lock:
return self._order
def ordered_check(self, path, expected_id):
"""``(folder_state, order)`` within IO_BUDGET; raises TimeoutError.
``order`` grows with each check started, so answers can be published
in the order they looked. The answer comes from a check that started
after this call: one already running may have looked before what the
caller just did, so it is waited for (never run twice at once; a stuck
one times out here) and followed by a new one."""
key = (path, expected_id)
with self._lock:
earlier = self._running(key)
@@ -284,7 +305,7 @@ class _Probe:
self._checking = None
if "error" in pending[2]:
raise pending[2]["error"]
return pending[2]["value"]
return pending[2]["value"], pending[2]["order"]
def _running(self, key):
"""The check of *key* still running (lock held), else None."""
@@ -300,7 +321,8 @@ class _Probe:
def _start_check(self, path, expected_id):
"""Start one check on its own thread (lock held)."""
result = {}
self._order += 1
result = {"order": self._order}
done = threading.Event()
def run():
+9 -1
View File
@@ -727,7 +727,15 @@ class PromptRoutesMixin:
async def save_prompt(self, request):
"""Save a new prompt with metadata and duplicate detection."""
try:
data = await request.json()
data, error = await self._read_json_body(request)
if error is not None:
return error
for field in ("text", "category", "notes"):
if data.get(field) is not None and not isinstance(data[field], str):
return web.json_response(
{"success": False, "error": f"{field} must be text"},
status=400,
)
text = (data.get("text") or "").strip()
if not text:
-13
View File
@@ -632,10 +632,6 @@ class AutoThumbnailer:
"""True once the worker has nothing queued or in hand."""
return self._wait_for(lambda: not self._busy, timeout)
def wait_batch(self, size, timeout=None):
"""True once the worker holds a batch of *size* files."""
return self._wait_for(lambda: self._batch == size, timeout)
class _PreviewGate:
"""Admission of view requests on the event loop (#249 I2).
@@ -967,15 +963,6 @@ class AutoThumbnailMixin:
return "queued"
return "skipped"
def _capture_target(self, image_path):
"""``(src, dst, is_video)`` of a new output's preview now, or None."""
located = self._capture_source(image_path)
if located is None:
return None
src, root, rel, is_video = located
dst = DeferredPreview(root, rel).resolve()
return None if dst is None else (src, dst, is_video)
def _capture_source(self, image_path):
"""``(src, root, rel, is_video)`` of a media file under a current
root (not a preview, a link or in a trash folder), or None."""
+2 -2
View File
@@ -90,7 +90,7 @@ class ThumbnailOptions:
}
def thumbnail_options(raw, *, from_query=False):
def thumbnail_options(raw):
"""``(ThumbnailOptions, None)`` from a body or query, or ``(None, message)``.
An absent mode is ``missing``; the settings are the server's now.
@@ -136,7 +136,7 @@ class ThumbnailJobsMixin:
``mode=prune`` lists the removed sources' thumbnails (#320)."""
if request.query.get("mode") == "prune":
return await self.get_prune_plan()
options, error = thumbnail_options(request.query, from_query=True)
options, error = thumbnail_options(request.query)
if error:
return _invalid(error)
try:
-56
View File
@@ -78,27 +78,6 @@ def _canonical_path(path: str) -> str:
return os.path.normcase(os.path.realpath(path))
def write_private_json(path: str, data: Any) -> None:
"""Write ``data`` as JSON to ``path`` readable by the current user only.
config.json can hold an API key (integrations.lora_manager), so it is
created with mode 0600 and, on POSIX, an existing more open file is
tightened too. Windows has no POSIX modes; there the chmod is a best
effort that only affects the read-only bit. Parent directories are
created as needed. Raises OSError when the file cannot be written.
"""
import json
parent_dir = os.path.dirname(path)
if parent_dir:
os.makedirs(parent_dir, exist_ok=True)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
if os.name == "posix":
os.chmod(path, 0o600)
def _lexical_path(path: str) -> str:
"""``path`` normalised for comparison without touching the filesystem.
@@ -717,12 +696,6 @@ class AutotagConfig:
def suggestion_limit(cls) -> int:
return cls.SUGGESTION_LIMIT
@staticmethod
def valid_suggestion_limit(value: Any) -> bool:
return (
type(value) is int and 1 <= value <= MAX_SUGGESTION_LIMIT
) # bool, float and str are rejected
@classmethod
def all_instructions(cls) -> List[Dict[str, str]]:
"""Built-in first, then the saved entries (copies)."""
@@ -1063,35 +1036,6 @@ class PromptManagerConfig:
else:
config_logger.info(f"Config file not found: {config_path}, using defaults")
@classmethod
def save_to_file(cls, config_path: Optional[str] = None):
"""Save the current configuration to a JSON file.
Serializes the complete configuration (including gallery settings) to
a JSON file. Creates the directory structure if it doesn't exist.
Args:
config_path (str): Path where the JSON configuration file should be saved.
Parent directories will be created if they don't exist.
Defaults to :meth:`get_config_path`.
Raises:
The method handles all exceptions internally and logs errors rather
than propagating them.
Example:
PromptManagerConfig.save_to_file('backup_config.json')
PromptManagerConfig.save_to_file('/etc/comfyui/prompt_manager.json')
"""
if config_path is None:
config_path = cls.get_config_path()
try:
write_private_json(config_path, cls.get_config())
config_logger.info(f"Saved configuration to {config_path}")
except Exception as e:
config_logger.error(f"Error saving config to {config_path}: {e}")
@classmethod
def update_config(cls, new_config: Dict[str, Any]):
"""Update configuration attributes from a dictionary.
+3 -2
View File
@@ -1,10 +1,11 @@
[project]
name = "promptmanager"
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
version = "3.4.1"
version = "3.6.0"
license = {file = "LICENSE"}
requires-python = ">=3.12"
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
dependencies = ["watchdog>=2.1.0", "Pillow>=10.1.0"]
classifiers = ["Operating System :: OS Independent"]
[build-system]
requires = ["setuptools>=84.2.1"]
+7 -7
View File
@@ -1,18 +1,18 @@
# Runtime dependencies
# Runtime dependencies (Pillow, aiohttp and numpy floors match ComfyUI requirements.txt)
watchdog>=6.0.0
Pillow>=12.3.0
aiohttp>=3.14.5
Pillow>=10.1.0
aiohttp>=3.11.8
# Tests and coverage
coverage>=7.16.2
numpy>=2.2.6 # WD14 engine tests
coverage>=7.17.0
numpy>=1.25.0 # WD14 engine tests
# Code quality
black==26.10.1
flake8==7.4.1
flake8==7.5.0
# Security scanning
bandit[toml]>=1.9.4
# Pre-commit hooks
pre-commit>=4.6.2
pre-commit>=4.7.0
+1 -1
View File
@@ -1,4 +1,4 @@
# Runtime dependencies for PromptManager.
# aiohttp, numpy and torch come from ComfyUI itself and are not listed here.
watchdog>=2.1.0 # Output directory monitoring
Pillow>=8.0.0 # Image metadata extraction and thumbnails
Pillow>=10.1.0 # Image metadata extraction and thumbnails
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

@@ -0,0 +1,47 @@
// #615: each old page says it will be removed and links to where its job lives now. The page keeps working, and the
// notice takes its own room: nothing is pushed off a page whose layout fills the window.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
const PAGES = Object.freeze([
{ path: '/prompt_manager/admin', section: 'library', heading: 'Prompt library' },
{ path: '/prompt_manager/gallery', section: 'images', heading: 'Images' },
{ path: '/prompt_manager/gallery.html', section: 'inspect', heading: 'Inspect a file' },
]);
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset', {})).toBeOK();
await page.addInitScript(() => {
localStorage.setItem('pm_update_notice_dismissed', '3.2.0');
localStorage.setItem('gallerySettings', JSON.stringify({ checkThumbnailsAtStartup: false }));
});
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
});
for (const { path, section, heading } of PAGES) {
test(`${path} says it will be removed and links to ${section}`, async ({ page }) => {
await page.goto(path);
const notice = page.locator('#legacy-removal-notice');
await expect(notice).toBeVisible();
await expect(notice).toContainText('This page will be removed in a future release.');
await notice.getByRole('link', { name: 'Open the workspace' }).click();
await expect(page).toHaveURL(new RegExp(`/prompt_manager/workspace#/${section}$`));
await expect(page.locator('#workspace-main h1')).toHaveText(heading);
});
}
test('the notice leaves the metadata page filling the window', async ({ page }) => {
await page.goto('/prompt_manager/gallery.html');
await expect(page.locator('#legacy-removal-notice')).toBeVisible();
const { bottom, height, scroll } = await page.locator('#image-area').evaluate(area => ({
bottom: area.getBoundingClientRect().bottom,
height: window.innerHeight,
scroll: document.documentElement.scrollHeight,
}));
expect(bottom).toBeLessThanOrEqual(height);
expect(scroll).toBeLessThanOrEqual(height);
});
+3 -2
View File
@@ -21,8 +21,9 @@ export function mapLibraryConcept(reference) {
// Save view is the concept's own (saved views, #249 D16 retired).
const toolbar={...library.toolbar};
toolbar.items=toolbar.items.map(item=>{
// D14: the Library appends its Oldest first after the concept's sorts; D153 (#447) drops "Highest rated".
if(item.id==='sort')return {...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first']};
// D14: the Library appends its Oldest first after the concept's sorts; D153 (#447) drops "Highest rated"; D205
// (#620) appends the old Dashboard's three sorts.
if(item.id==='sort')return {...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first','Recently used','Most used','Name Z–A']};
// D36: the category list is the Library's real list, sorted alphabetically; the concept lists prompt order.
if(item.id==='category-filter')return {...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]};
return item;
+1 -3
View File
@@ -167,8 +167,6 @@ const IMAGES_COUNT_URL = '/prompt_manager/images/output?mode=workspace&limit=1';
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
function mapOverviewConcept(reference, overview, images) {
// D165: the discreet legacy-pages line follows the settings layout.
reference = { ...reference, tree: [...reference.tree, 'p.legacy-pages.small'] };
const relabel = { 'Database integrity': 'Not checked', 'Positive prompts': number(overview.library.positive_prompts),
'Negative variants': number(overview.library.negative_variants), Images: number(images) };
const rows = {
@@ -265,7 +263,7 @@ const UNMAPPED_PATHS = [
'columns.surfaces.0.rows.length', 'columns.surfaces.0.tree.4', 'columns.surfaces.0.tree.length',
'columns.surfaces.1.rows.0.description', 'columns.surfaces.1.rows.1.title', 'columns.surfaces.1.rows.2.description',
'heading.actions.0', 'heading.actions.length', 'metrics.items.3.value', 'nav.label',
'save.button.cursor', 'save.button.opacity', 'save.status.text', 'tree.3', 'tree.length',
'save.button.cursor', 'save.button.opacity', 'save.status.text',
].sort();
test('system comparison detects mutations', async ({ page, request }) => {
+1 -1
View File
@@ -191,7 +191,7 @@ test('library toolbar matches the concept and tags live in Refine this view',asy
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button','button']);
await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
// D153 (#447): no "Highest rated".
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Oldest first']);
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Oldest first','Recently used','Most used','Name Z–A']);
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0);
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0);
+1 -1
View File
@@ -205,7 +205,7 @@ test('a view stored under older rules still lists and opens with its usable valu
return (await response.json()).id;
};
const imagesId = await raw({ page: 'images', name: 'Old cats', state: JSON.stringify({ q: 'cat', sort: 'shuffle', layout: 'grid' }) });
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'name-desc' }) });
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'shuffle' }) });
expect(await listed(request)).toEqual([
{ id: imagesId, page: 'images', name: 'Old cats', state: { ...IMAGE_DEFAULTS, q: 'cat' } },
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }),
+11 -36
View File
@@ -135,34 +135,11 @@ test('/prompt_manager/web lands on the workspace Library and Back does not bounc
await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
});
// D165 (#525, maintainer ruling): a discreet line under System is the workspace's only visible way to the legacy pages.
test('System Overview links the legacy pages discreetly, and each one loads (#525)', async ({ page, context }) => {
// #620: the old pages are on their way out (notice since 3.5.1), so the workspace no longer links them.
test('System no longer links the old pages (#620)', async ({ page }) => {
await page.goto(`${workspace}#/system/overview`);
const line = page.locator('#workspace-main .legacy-pages');
await expect(line).toBeVisible();
await expect(line).toContainText('Legacy pages');
const links = line.getByRole('link');
await expect(links).toHaveCount(4);
for (const [name, url, ready] of [
['Dashboard', '/prompt_manager/admin', '#addPromptBtn'],
['Tags', '/prompt_manager/admin#/tags', '#addPromptBtn'],
['Gallery', '/prompt_manager/gallery', 'body'],
['Metadata viewer', '/prompt_manager/gallery.html', 'body'],
]) {
const opened = context.waitForEvent('page');
await line.getByRole('link', { name, exact: true }).click();
const tab = await opened;
await tab.waitForLoadState('domcontentloaded');
const target = new URL(tab.url());
expect(target.pathname + target.hash).toBe(url);
await expect(tab.locator(ready).first()).toBeAttached();
await tab.close();
}
// Only on System's Overview section, and gone with the page.
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
await expect(line).toBeHidden();
await page.goto(`${workspace}#/library`);
await expect(page.locator('#workspace-main .legacy-pages')).toHaveCount(0);
await expect(page.locator('#workspace-main h1')).toHaveText('System');
await expect(page.locator('#workspace-main a[href*="/admin"], #workspace-main a[href*="/gallery"]')).toHaveCount(0);
});
const bootFailure = page => page.locator('#workspace-boot-failure');
@@ -175,7 +152,7 @@ test('a healthy workspace never shows the load failure message (#525)', async ({
});
for (const [label, pattern] of [['the entry module', '**/js/workspace/index.mjs*'], ['a dependency', '**/js/workspace/state.mjs*']]) {
test(`when ${label} fails to load the page still offers the legacy pages (#525)`, async ({ page }) => {
test(`when ${label} fails to load the page offers a reload and ComfyUI (#525, #620)`, async ({ page }) => {
const pageErrors = [];
page.on('pageerror', error => pageErrors.push(error.message));
await page.route(pattern, route => route.abort());
@@ -183,19 +160,17 @@ for (const [label, pattern] of [['the entry module', '**/js/workspace/index.mjs*
const failure = bootFailure(page);
await expect(failure).toBeVisible();
await expect(failure).toHaveAttribute('role', 'alert');
const dashboard = failure.getByRole('link', { name: 'Dashboard', exact: true });
await expect(dashboard).toBeVisible();
await expect(failure.getByRole('link')).toHaveCount(4);
await dashboard.click();
await expect(page).toHaveURL(/\/prompt_manager\/admin$/);
await expect(failure.getByRole('link', { name: 'Reload the page', exact: true })).toHaveAttribute('href', 'workspace');
await expect(failure.getByRole('link', { name: 'ComfyUI', exact: true })).toHaveAttribute('href', '../');
await expect(failure.getByRole('link')).toHaveCount(2);
expect(pageErrors).toEqual([]);
});
}
test('when a module throws while starting the page still offers the legacy pages (#525)', async ({ page }) => {
test('when a module throws while starting the page still offers a reload (#525, #620)', async ({ page }) => {
await page.route('**/js/workspace/state.mjs*', route => route.fulfill({ contentType: 'text/javascript', body: "throw new Error('boot failure');" }));
await page.goto(`${workspace}#/library`);
await expect(bootFailure(page).getByRole('link', { name: 'Dashboard', exact: true })).toBeVisible();
await expect(bootFailure(page).getByRole('link', { name: 'Reload the page', exact: true })).toBeVisible();
// The broken module's own error is this test's premise, not a failure of the page.
page.workspaceErrors.length = 0;
});
@@ -569,7 +544,7 @@ test('System is native: the concept shell, section navigation, sections and the
// D92: no "Sample configuration" chip.
await expect(main.locator('.heading .chip')).toHaveCount(0);
expect(await main.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout', 'p.small legacy-pages']);
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout']);
const nav = main.getByRole('group', { name: 'System sections', exact: true });
await expect(nav.getByRole('button')).toHaveText(SYSTEM_SECTION_LABELS);
await expect(nav.getByRole('button', { name: 'Overview', exact: true })).toHaveAttribute('aria-pressed', 'true');
+1 -1
View File
@@ -143,7 +143,7 @@ test('the shelf no longer sorts by rating (D153)',async()=>{
const {createLibraryStream}=await source;const s=createLibraryStream({api:{get:async()=>page([])}});
await assert.rejects(s.reset({sort:'rating'}),/Invalid library sort/);
assert.throws(()=>createLibraryStream({api:{get:async()=>page([])},restore:{filters:{sort:'rating'},state:{}}}),/Invalid library sort/);
for(const sort of ['newest','oldest','name']) assert.equal(await s.reset({sort}),0);s.dispose();
for(const sort of ['newest','oldest','name','name-desc','most-used','last-used']) assert.equal(await s.reset({sort}),0);s.dispose();
});
test('Refine › Folder nests each folder under its exact parent, by path segment (#444 live B1)',async()=>{
const {libraryFolderOptions}=await source;assert.equal(typeof libraryFolderOptions,'function');
+4 -2
View File
@@ -32,7 +32,7 @@ test('a saved state always holds every page key and keeps only values its page a
{ ...full, q: '', folder: '', tags: [] });
assert.deepEqual(normalizeViewState('images', { ...full, category: 'bad\u0007', tags: [' '] }), { ...full, category: '', tags: [] });
// Library has no source or media, and no Name Z–A; its folder (#444) is the Library's own.
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'], tag_mode: 'all', folder: 'a/b' });
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'rating' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'], tag_mode: 'all', folder: 'a/b' });
assert.deepEqual(normalizeViewState('library', { sort: 'oldest' }), { ...LIBRARY_DEFAULTS, sort: 'oldest' });
// The returned tags are a copy.
const tags = ['one'];
@@ -244,5 +244,7 @@ test('a view saved under the retired Highest rated sort opens on Recently added
assert.equal(imagesFilters({ sort: 'rating' }).sort, 'newest');
assert.equal(libraryFilters({ sort: 'rating' }).sort, 'newest');
assert.equal(normalizeViewState('images', { sort: 'name-desc' }).sort, 'name-desc');
assert.equal(normalizeViewState('library', { sort: 'oldest' }).sort, 'oldest');
for (const sort of ['oldest', 'name-desc', 'most-used', 'last-used']) {
assert.equal(normalizeViewState('library', { sort }).sort, sort);
}
});
-7
View File
@@ -93,13 +93,6 @@ test('every Overview card opens its own native page, never a legacy one (#525)',
}
});
test('the legacy pages System links are the four that stay at their URLs (#525 D165)', async () => {
const { LEGACY_PAGES } = await source;
assert.deepEqual(LEGACY_PAGES.map(page => page.href), ['/prompt_manager/admin', '/prompt_manager/admin#/tags',
'/prompt_manager/gallery', '/prompt_manager/gallery.html']);
for (const page of LEGACY_PAGES) assert.ok(page.label);
});
test('a job of the Activity page is its own deep link', async () => {
const { resolveRoute, routeTitle, isHandoff } = await source;
const id = '0123456789abcdef'.repeat(2);
+26
View File
@@ -732,6 +732,32 @@ class TestAutotagErrorBodiesHideServerPaths(AutotagAPITestCase):
class TestUnloadAutotag(AutotagAPITestCase):
async def test_unload_runs_off_the_loop_and_holds_the_gate_until_done(self):
started = threading.Event()
release = threading.Event()
self.addCleanup(release.set)
threads = []
def unload():
threads.append(threading.get_ident())
started.set()
release.wait(5)
self.service._loaded_type = None
self.service._loaded_type = "gguf"
self.service.unload_model = unload
first = asyncio.ensure_future(
self.client.request("POST", "/prompt_manager/autotag/unload")
)
self.assertTrue(await asyncio.to_thread(started.wait, 5))
self.assertNotEqual(threads, [threading.get_ident()])
second = await self.client.request("POST", "/prompt_manager/autotag/unload")
self.assertEqual(second.status, 409)
release.set()
data = await (await first).json()
self.assertEqual(data["message"], "GGUF model unloaded successfully")
self.assertIsNone(model_gate.holder())
async def test_nothing_loaded(self):
resp = await self.client.request("POST", "/prompt_manager/autotag/unload")
data = await resp.json()
+3 -54
View File
@@ -343,7 +343,7 @@ class TestReadJsonBody(AioHTTPTestCase):
class TestLongJobLock(unittest.TestCase):
"""_acquire_long_job: one job per family at a time, released on exit."""
"""The refusal a busy long job answers with."""
def setUp(self):
database = MagicMock()
@@ -353,33 +353,6 @@ class TestLongJobLock(unittest.TestCase):
def _run(self, coro):
return asyncio.run(coro)
def test_second_acquire_is_refused_until_first_releases(self):
async def scenario():
first = self.api._acquire_long_job("duplicates")
self.assertIsNotNone(first)
async with first:
self.assertIsNone(self.api._acquire_long_job("duplicates"))
# Other families are independent
other = self.api._acquire_long_job("thumbnails")
self.assertIsNotNone(other)
async with other:
pass
again = self.api._acquire_long_job("duplicates")
self.assertIsNotNone(again)
async with again:
pass
self._run(scenario())
def test_lock_is_released_when_the_job_raises(self):
async def scenario():
with self.assertRaises(RuntimeError):
async with self.api._acquire_long_job("duplicates"):
raise RuntimeError("boom")
self.assertIsNotNone(self.api._acquire_long_job("duplicates"))
self._run(scenario())
def test_busy_response_is_409_with_envelope(self):
resp = self.api._long_job_busy_response("Duplicate scan")
self.assertEqual(resp.status, 409)
@@ -796,6 +769,7 @@ class TestRevalidatedCachingBehindMiddlewares(CoreRoutesTestCase):
self.assertEqual(resp.status, 200, path)
self.assertEqual(resp.headers.get("Content-Encoding"), "gzip", path)
self.assertEqual(resp.headers["Cache-Control"], "no-cache", path)
await resp.read() # aiohttp before 3.11.11 hangs on an unread body
again = await self.client.request(
"GET",
path,
@@ -806,6 +780,7 @@ class TestRevalidatedCachingBehindMiddlewares(CoreRoutesTestCase):
)
self.assertEqual(again.status, 304, path)
self.assertEqual(again.headers["Cache-Control"], "no-cache", path)
await again.read()
# ── instance helpers ──────────────────────────────────────────────────
@@ -951,32 +926,6 @@ class TestMetadataParsing(unittest.TestCase):
f.write(b"nope")
self.assertEqual(self.api._extract_comfyui_metadata(garbage), {})
def test_parse_a1111_parameters(self):
meta = {"parameters": "a cat\nNegative prompt: dog\nSteps: 20"}
parsed = self.api._parse_comfyui_prompt(meta)
self.assertEqual(parsed["positive_prompt"], "a cat")
self.assertEqual(parsed["negative_prompt"], "dog")
self.assertEqual(parsed["parameters"]["parameters"], meta["parameters"])
def test_parse_comfyui_fields(self):
meta = {
"prompt": json.dumps({"1": {"class_type": "X", "inputs": {}}}),
"workflow": "not json",
"steps": "20",
"cfg": "not-json-either",
}
parsed = self.api._parse_comfyui_prompt(meta)
self.assertIsInstance(parsed["prompt"], dict)
self.assertEqual(parsed["workflow"], "not json")
self.assertEqual(parsed["parameters"]["steps"], 20)
self.assertEqual(parsed["parameters"]["cfg"], "not-json-either")
parsed = self.api._parse_comfyui_prompt(
{"prompt": "plain text", "workflow": json.dumps({"nodes": []})}
)
self.assertEqual(parsed["prompt"], "plain text")
self.assertEqual(parsed["workflow"], {"nodes": []})
if __name__ == "__main__":
unittest.main()
+31
View File
@@ -42,6 +42,37 @@ class TestLibraryAPI(ImageAPITestCase):
self.assertEqual(len(media["images"]), 1)
self.assertEqual(media["images"][0]["path"], "same.png")
async def test_used_and_reverse_name_sorts_page_through_every_prompt(self):
ids = {text: self._save_prompt(text) for text in ("alpha", "beta", "gamma")}
with self.api.db.model.get_connection() as conn:
for text, runs, used in (
("alpha", 1, "2026-01-01T00:00:00+00:00"),
("beta", 9, "2026-02-01T00:00:00+00:00"),
("gamma", 0, None),
):
conn.execute(
"UPDATE prompts SET run_count=?, last_used_at=? WHERE id=?",
(runs, used, ids[text]),
)
conn.commit()
for sort, expected in (
("most-used", ["beta", "alpha", "gamma"]),
("last-used", ["beta", "alpha", "gamma"]),
("name-desc", ["gamma", "beta", "alpha"]),
):
with self.subTest(sort=sort):
seen, cursor = [], None
for _ in expected:
params = {"sort": sort, "limit": 1}
if cursor:
params["cursor"] = cursor
response, page = await self.get(**params)
self.assertEqual(response.status, 200)
seen.extend(item["text"] for item in page["items"])
cursor = page["next_cursor"]
self.assertEqual(seen, expected)
self.assertIsNone(cursor)
async def test_scoped_cursor_tampering_filters_and_continuation(self):
for text in ("one", "two", "three"):
self._save_prompt(text)
+22
View File
@@ -684,6 +684,28 @@ class TestTagMutationRoutes(RouteCoverageCase):
class TestSaveRoute(RouteCoverageCase):
async def test_a_malformed_body_is_refused_and_saves_nothing(self):
for body in (
[],
"text",
{"text": 123},
{"text": "fixture", "category": 123},
{"text": "fixture", "notes": 123},
{"text": "fixture", "tags": 123},
{"text": "fixture", "rating": "high"},
):
with self.subTest(body=body):
status, data = await self._json(
"POST", "/prompt_manager/save", json=body
)
self.assertEqual(status, 400)
self.assertFalse(data["success"])
status, data = await self._json(
"POST", "/prompt_manager/save", data=b"{not json"
)
self.assertEqual(status, 400)
self.assertEqual(self.api.db.search_prompts(text="fixture"), [])
async def test_invalid_rating_is_400(self):
status, data = await self._json(
"POST", "/prompt_manager/save", json={"text": "ok", "rating": 99}
-20
View File
@@ -9,7 +9,6 @@ import json
import ntpath
import os
import posixpath
import re
import sys
import tempfile
import threading
@@ -378,25 +377,6 @@ class TestValidation(unittest.TestCase):
self.assertTrue(text.endswith("."))
class TestNoLegacySaveCallers(unittest.TestCase):
def test_save_to_file_has_no_callers(self):
offenders = []
for folder in ("py", "utils", "database"):
for path in (REPO / folder).rglob("*.py"):
for number, line in enumerate(
path.read_text(encoding="utf-8").splitlines(), 1
):
code = line.split("#", 1)[0]
if (
re.search(r"\bsave_to_file\(", code)
and "def save_to_file" not in code
):
if "PromptManagerConfig.save_to_file('" in line:
continue # its own docstring example
offenders.append(f"{path.relative_to(REPO)}:{number}")
self.assertEqual(offenders, [])
try:
from tests.test_api_images import ImageAPITestCase
except ImportError: # discovered with tests/ as the top-level directory
-1
View File
@@ -197,7 +197,6 @@ class JobCase(unittest.TestCase):
node.logger = logging.getLogger("test.cached_runs")
node.prompt_tracker = mock.Mock()
self.trackers.append(node.prompt_tracker)
node.comfyui_integration = mock.Mock()
node._inject_lora_trigger_words = lambda text: text
node.pending_first_use = self.pending
node.pending_pair_first_use = self.pair_pending
-153
View File
@@ -1,153 +0,0 @@
"""Tests for utils.comfyui_integration."""
import os
import sys
import types
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.comfyui_integration import ComfyUIMetadataIntegration
class TestSaveImageNotPatched(unittest.TestCase):
"""Regression for #75: SaveImage must save each node's own prompt inputs.
The old SaveImage patch wrote the last-run PromptManager prompt into every
PromptManager node, so negative prompts were saved as copies of the positive.
"""
def setUp(self):
def save_images(
self_node,
images,
filename_prefix="ComfyUI",
prompt=None,
extra_pnginfo=None,
):
return {"prompt": prompt}
self.original_save_images = save_images
fake_nodes = types.ModuleType("nodes")
fake_nodes.SaveImage = type("SaveImage", (), {"save_images": save_images})
self._saved_nodes = sys.modules.get("nodes")
sys.modules["nodes"] = fake_nodes
self.fake_nodes = fake_nodes
ComfyUIMetadataIntegration._instance = None
def tearDown(self):
ComfyUIMetadataIntegration._instance = None
if self._saved_nodes is None:
sys.modules.pop("nodes", None)
else:
sys.modules["nodes"] = self._saved_nodes
def test_save_images_is_left_untouched(self):
ComfyUIMetadataIntegration()
self.assertIs(self.fake_nodes.SaveImage.save_images, self.original_save_images)
def test_registered_prompt_does_not_rewrite_saved_metadata(self):
integration = ComfyUIMetadataIntegration()
integration.register_prompt("pm_1", "positive final text", {})
prompt = {
"39": {"class_type": "PromptManager", "inputs": {"text": "a cat"}},
"40": {"class_type": "PromptManager", "inputs": {"text": "blurry"}},
}
result = self.fake_nodes.SaveImage().save_images([], prompt=prompt)
self.assertEqual(result["prompt"]["39"]["inputs"]["text"], "a cat")
self.assertEqual(result["prompt"]["40"]["inputs"]["text"], "blurry")
def test_prompt_registry_still_available_to_nodes(self):
integration = ComfyUIMetadataIntegration()
integration.register_prompt("pm_1", "hello", {})
self.assertEqual(integration.get_current_prompt_text("pm_1"), "hello")
import threading # noqa: E402
import unittest.mock as mock # noqa: E402
from utils import comfyui_integration # noqa: E402
from utils.comfyui_integration import get_comfyui_integration # noqa: E402
class IntegrationTestCase(unittest.TestCase):
def setUp(self):
ComfyUIMetadataIntegration._instance = None
comfyui_integration._integration_instance = None
# Patch the module's own ``time`` name rather than ``time.time`` globally:
# background threads from other tests (image monitor, watchdog) would
# otherwise tick this clock and make fresh registrations look stale.
self.clock = mock.patch.object(
comfyui_integration, "time", types.SimpleNamespace(time=self._now)
)
self.clock.start()
self.addCleanup(self.clock.stop)
self._ticks = 1000.0
self.integration = get_comfyui_integration()
def tearDown(self):
ComfyUIMetadataIntegration._instance = None
comfyui_integration._integration_instance = None
def _now(self):
self._ticks += 1.0
return self._ticks
class TestSingletonAndRegistry(IntegrationTestCase):
def test_singleton_keeps_registrations_across_constructions(self):
self.assertIs(get_comfyui_integration(), self.integration)
self.assertIs(ComfyUIMetadataIntegration(), self.integration)
self.integration.register_prompt("n1", "hello", {"category": "x"})
ComfyUIMetadataIntegration() # __init__ runs again: must not reset state
self.assertEqual(self.integration.get_current_prompt_text("n1"), "hello")
def test_latest_prompt_of_this_thread_wins_without_a_node_id(self):
self.integration.register_prompt("n1", "first", {})
self.integration.register_prompt("n2", "second", {})
self.assertEqual(self.integration.get_current_prompt_text(), "second")
self.assertEqual(self.integration.get_current_prompt_text("missing"), "second")
self.assertEqual(self.integration.get_current_prompt_text("n1"), "first")
def test_nothing_registered_returns_none(self):
self.assertIsNone(self.integration.get_current_prompt_text())
self.assertIsNone(self.integration.get_current_prompt_text("n1"))
class TestCrossThreadLookup(IntegrationTestCase):
def _register_in_thread(self, text):
worker = threading.Thread(
target=self.integration.register_prompt, args=("w1", text, {})
)
worker.start()
worker.join()
def test_recent_prompt_from_another_thread_is_used(self):
self._register_in_thread("from worker")
self.assertEqual(self.integration.get_current_prompt_text(), "from worker")
def test_stale_prompt_from_another_thread_is_ignored(self):
self._register_in_thread("from worker")
self._ticks += 400.0
self.assertIsNone(self.integration.get_current_prompt_text())
def test_global_entry_of_this_thread_is_found_without_thread_local(self):
self.integration.register_prompt("n1", "mine", {})
self.integration._thread_local = threading.local()
self.assertEqual(self.integration.get_current_prompt_text(), "mine")
def test_cleanup_removes_old_registrations_only(self):
self._register_in_thread("old")
self._ticks += 700.0
self.integration.register_prompt("n2", "new", {})
self.integration.cleanup_old_prompts(max_age_seconds=600)
texts = sorted(v["text"] for v in self.integration._current_prompts.values())
self.assertEqual(texts, ["new"])
self.integration.cleanup_old_prompts(max_age_seconds=600) # nothing to do
self.assertEqual(len(self.integration._current_prompts), 1)
if __name__ == "__main__":
unittest.main()
+4 -63
View File
@@ -213,7 +213,8 @@ class TestPromptManagerConfig(unittest.TestCase):
try:
# Modify a value
PromptManagerConfig.MAX_SEARCH_RESULTS = 42
PromptManagerConfig.save_to_file(tmp.name)
with open(tmp.name, "w", encoding="utf-8") as handle:
json.dump(PromptManagerConfig.get_config(), handle)
# Reset and reload
PromptManagerConfig.MAX_SEARCH_RESULTS = 100
@@ -240,55 +241,6 @@ class TestPromptManagerConfig(unittest.TestCase):
finally:
os.unlink(tmp.name)
def test_save_creates_directory(self):
tmp_dir = tempfile.mkdtemp()
nested_path = os.path.join(tmp_dir, "subdir", "config.json")
try:
PromptManagerConfig.save_to_file(nested_path)
self.assertTrue(os.path.exists(nested_path))
with open(nested_path) as f:
data = json.load(f)
self.assertIn("database", data)
finally:
import shutil
shutil.rmtree(tmp_dir)
def test_saved_file_is_private_to_the_user(self):
import stat
tmp_dir = tempfile.mkdtemp()
try:
path = os.path.join(tmp_dir, "config.json")
PromptManagerConfig.save_to_file(path)
mode = stat.S_IMODE(os.stat(path).st_mode)
if os.name == "posix":
self.assertEqual(mode, 0o600)
with open(path) as f:
self.assertIn("database", json.load(f))
# Overwriting an existing, more open file tightens it as well
os.chmod(path, 0o644)
PromptManagerConfig.save_to_file(path)
if os.name == "posix":
self.assertEqual(stat.S_IMODE(os.stat(path).st_mode), 0o600)
finally:
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
def test_saved_file_is_valid_json(self):
tmp = tempfile.NamedTemporaryFile(
delete=False, suffix=".json", dir=tempfile.gettempdir()
)
tmp.close()
try:
PromptManagerConfig.save_to_file(tmp.name)
with open(tmp.name) as f:
data = json.load(f)
self.assertIsInstance(data, dict)
finally:
os.unlink(tmp.name)
class TestIntegrationConfig(unittest.TestCase):
"""Test IntegrationConfig for LoRA Manager settings."""
@@ -404,18 +356,6 @@ class TestConfigPathOverride(unittest.TestCase):
os.environ[self.ENV] = custom
self.assertEqual(PromptManagerConfig.get_config_path(), custom)
def test_save_without_path_uses_env_override(self):
with tempfile.TemporaryDirectory() as tmpdir:
custom = os.path.join(tmpdir, "nested", "custom.json")
os.environ[self.ENV] = custom
PromptManagerConfig.RESULT_TIMEOUT = 42
PromptManagerConfig.save_to_file()
with open(custom) as f:
saved = json.load(f)
self.assertEqual(saved["web_ui"]["result_timeout"], 42)
def test_load_without_path_uses_env_override(self):
with tempfile.TemporaryDirectory() as tmpdir:
custom = os.path.join(tmpdir, "custom.json")
@@ -503,7 +443,8 @@ class TestAutotagConfig(unittest.TestCase):
PromptManagerConfig.update_config(
{"autotag": {"defaults": {"suggestion_limit": 25}}}
)
PromptManagerConfig.save_to_file(path)
with open(path, "w", encoding="utf-8") as handle:
json.dump(PromptManagerConfig.get_config(), handle)
self.config_class.SUGGESTION_LIMIT = 10
PromptManagerConfig.load_from_file(path)
self.assertEqual(self.config_class.suggestion_limit(), 25)
+36 -71
View File
@@ -15,12 +15,11 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@patch("prompt_manager_base.get_image_monitor")
@patch("prompt_manager_base.get_prompt_tracker")
@patch("prompt_manager_base.get_comfyui_integration")
@patch("prompt_manager_base.PromptDatabase")
class TestPromptManagerTextEmptyInput(unittest.TestCase):
"""Test PromptManagerText with empty/blank text inputs (issue #123)."""
def _make_node(self, mock_db_class, mock_integration, mock_tracker, mock_monitor):
def _make_node(self, mock_db_class, mock_tracker, mock_monitor):
mock_db = Mock()
mock_db_class.return_value = mock_db
mock_db.save_prompt.return_value = 1
@@ -30,73 +29,64 @@ class TestPromptManagerTextEmptyInput(unittest.TestCase):
return PromptManagerText()
def test_empty_string(self, mock_db, mock_integ, mock_tracker, mock_monitor):
def test_empty_string(self, mock_db, mock_tracker, mock_monitor):
"""Empty string should not raise UnboundLocalError."""
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
node = self._make_node(mock_db, mock_tracker, mock_monitor)
result = node.process_text(text="")
self.assertIsInstance(result, tuple)
self.assertEqual(result[0], "")
def test_whitespace_only(self, mock_db, mock_integ, mock_tracker, mock_monitor):
def test_whitespace_only(self, mock_db, mock_tracker, mock_monitor):
"""Whitespace-only string should not raise UnboundLocalError."""
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
node = self._make_node(mock_db, mock_tracker, mock_monitor)
result = node.process_text(text=" ")
self.assertIsInstance(result, tuple)
def test_none_coerced_empty(self, mock_db, mock_integ, mock_tracker, mock_monitor):
def test_none_coerced_empty(self, mock_db, mock_tracker, mock_monitor):
"""Falsy text value should not raise UnboundLocalError."""
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
node = self._make_node(mock_db, mock_tracker, mock_monitor)
# ComfyUI may pass empty string for unconnected inputs
result = node.process_text(text="", category="", tags="")
self.assertIsInstance(result, tuple)
def test_empty_text_with_prepend_append(
self, mock_db, mock_integ, mock_tracker, mock_monitor
):
def test_empty_text_with_prepend_append(self, mock_db, mock_tracker, mock_monitor):
"""Empty main text with prepend/append should produce single space, not
double."""
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
node = self._make_node(mock_db, mock_tracker, mock_monitor)
result = node.process_text(text="", prepend_text="before", append_text="after")
self.assertIsInstance(result, tuple)
self.assertEqual(result[0], "before after")
self.assertNotIn(" ", result[0])
def test_valid_text_still_works(
self, mock_db, mock_integ, mock_tracker, mock_monitor
):
def test_valid_text_still_works(self, mock_db, mock_tracker, mock_monitor):
"""Normal text input should continue to work correctly."""
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
node = self._make_node(mock_db, mock_tracker, mock_monitor)
result = node.process_text(text="a beautiful sunset")
self.assertIsInstance(result, tuple)
self.assertEqual(result[0], "a beautiful sunset")
def test_prepend_text_append_all_present(
self, mock_db, mock_integ, mock_tracker, mock_monitor
):
def test_prepend_text_append_all_present(self, mock_db, mock_tracker, mock_monitor):
"""All three parts should combine with single spaces."""
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
node = self._make_node(mock_db, mock_tracker, mock_monitor)
result = node.process_text(
text="main prompt", prepend_text="before", append_text="after"
)
self.assertEqual(result[0], "before main prompt after")
def test_only_prepend(self, mock_db, mock_integ, mock_tracker, mock_monitor):
def test_only_prepend(self, mock_db, mock_tracker, mock_monitor):
"""Prepend with empty text should output just prepend."""
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
node = self._make_node(mock_db, mock_tracker, mock_monitor)
result = node.process_text(text="", prepend_text="before")
self.assertEqual(result[0], "before")
@patch("prompt_manager_base.get_image_monitor")
@patch("prompt_manager_base.get_prompt_tracker")
@patch("prompt_manager_base.get_comfyui_integration")
@patch("prompt_manager_base.PromptDatabase")
class TestPromptManagerEmptyInput(unittest.TestCase):
"""Test PromptManager (CLIP variant) with empty/blank text inputs (issue #123)."""
def _make_node_and_clip(
self, mock_db_class, mock_integration, mock_tracker, mock_monitor
):
def _make_node_and_clip(self, mock_db_class, mock_tracker, mock_monitor):
mock_db = Mock()
mock_db_class.return_value = mock_db
mock_db.save_prompt.return_value = 1
@@ -110,30 +100,22 @@ class TestPromptManagerEmptyInput(unittest.TestCase):
return PromptManager(), mock_clip
def test_empty_string(self, mock_db, mock_integ, mock_tracker, mock_monitor):
def test_empty_string(self, mock_db, mock_tracker, mock_monitor):
"""Empty string should not raise UnboundLocalError."""
node, clip = self._make_node_and_clip(
mock_db, mock_integ, mock_tracker, mock_monitor
)
node, clip = self._make_node_and_clip(mock_db, mock_tracker, mock_monitor)
result = node.encode_prompt(clip=clip, text="")
self.assertIsInstance(result, tuple)
def test_whitespace_only(self, mock_db, mock_integ, mock_tracker, mock_monitor):
def test_whitespace_only(self, mock_db, mock_tracker, mock_monitor):
"""Whitespace-only string should not raise UnboundLocalError."""
node, clip = self._make_node_and_clip(
mock_db, mock_integ, mock_tracker, mock_monitor
)
node, clip = self._make_node_and_clip(mock_db, mock_tracker, mock_monitor)
result = node.encode_prompt(clip=clip, text=" ")
self.assertIsInstance(result, tuple)
def test_empty_text_with_prepend_append(
self, mock_db, mock_integ, mock_tracker, mock_monitor
):
def test_empty_text_with_prepend_append(self, mock_db, mock_tracker, mock_monitor):
"""Empty main text with prepend/append should produce single space, not
double."""
node, clip = self._make_node_and_clip(
mock_db, mock_integ, mock_tracker, mock_monitor
)
node, clip = self._make_node_and_clip(mock_db, mock_tracker, mock_monitor)
result = node.encode_prompt(
clip=clip, text="", prepend_text="before", append_text="after"
)
@@ -141,24 +123,16 @@ class TestPromptManagerEmptyInput(unittest.TestCase):
self.assertEqual(result[1], "before after")
self.assertNotIn(" ", result[1])
def test_valid_text_still_works(
self, mock_db, mock_integ, mock_tracker, mock_monitor
):
def test_valid_text_still_works(self, mock_db, mock_tracker, mock_monitor):
"""Normal text input should continue to work correctly."""
node, clip = self._make_node_and_clip(
mock_db, mock_integ, mock_tracker, mock_monitor
)
node, clip = self._make_node_and_clip(mock_db, mock_tracker, mock_monitor)
result = node.encode_prompt(clip=clip, text="a beautiful sunset")
self.assertIsInstance(result, tuple)
self.assertEqual(result[1], "a beautiful sunset")
def test_prepend_text_append_all_present(
self, mock_db, mock_integ, mock_tracker, mock_monitor
):
def test_prepend_text_append_all_present(self, mock_db, mock_tracker, mock_monitor):
"""All three parts should combine with single spaces."""
node, clip = self._make_node_and_clip(
mock_db, mock_integ, mock_tracker, mock_monitor
)
node, clip = self._make_node_and_clip(mock_db, mock_tracker, mock_monitor)
result = node.encode_prompt(
clip=clip, text="main prompt", prepend_text="before", append_text="after"
)
@@ -167,7 +141,6 @@ class TestPromptManagerEmptyInput(unittest.TestCase):
@patch("prompt_manager_base.get_image_monitor")
@patch("prompt_manager_base.get_prompt_tracker")
@patch("prompt_manager_base.get_comfyui_integration")
@patch("prompt_manager_base.PromptDatabase")
class TestConnectedTextInput(unittest.TestCase):
"""Test that externally connected text is preserved in output (issue #123).
@@ -177,7 +150,7 @@ class TestConnectedTextInput(unittest.TestCase):
value reaches Python; these tests verify Python handles it correctly.
"""
def _make_text_node(self, mock_db_class, mock_integ, mock_tracker, mock_monitor):
def _make_text_node(self, mock_db_class, mock_tracker, mock_monitor):
mock_db = Mock()
mock_db_class.return_value = mock_db
mock_db.save_prompt.return_value = 1
@@ -186,7 +159,7 @@ class TestConnectedTextInput(unittest.TestCase):
return PromptManagerText()
def _make_clip_node(self, mock_db_class, mock_integ, mock_tracker, mock_monitor):
def _make_clip_node(self, mock_db_class, mock_tracker, mock_monitor):
mock_db = Mock()
mock_db_class.return_value = mock_db
mock_db.save_prompt.return_value = 1
@@ -199,10 +172,10 @@ class TestConnectedTextInput(unittest.TestCase):
return PromptManager(), mock_clip
def test_text_node_connected_text_with_prepend_append(
self, mock_db, mock_integ, mock_tracker, mock_monitor
self, mock_db, mock_tracker, mock_monitor
):
"""Simulates screenshot: connected text + prepend + append must all appear."""
node = self._make_text_node(mock_db, mock_integ, mock_tracker, mock_monitor)
node = self._make_text_node(mock_db, mock_tracker, mock_monitor)
result = node.process_text(
text="This is the prompt",
prepend_text="This is prepend text",
@@ -213,21 +186,17 @@ class TestConnectedTextInput(unittest.TestCase):
"This is prepend text This is the prompt This is append text",
)
def test_text_node_connected_text_only(
self, mock_db, mock_integ, mock_tracker, mock_monitor
):
def test_text_node_connected_text_only(self, mock_db, mock_tracker, mock_monitor):
"""Connected text without prepend/append passes through unchanged."""
node = self._make_text_node(mock_db, mock_integ, mock_tracker, mock_monitor)
node = self._make_text_node(mock_db, mock_tracker, mock_monitor)
result = node.process_text(text="This is the prompt")
self.assertEqual(result[0], "This is the prompt")
def test_clip_node_connected_text_with_prepend_append(
self, mock_db, mock_integ, mock_tracker, mock_monitor
self, mock_db, mock_tracker, mock_monitor
):
"""Same scenario for PromptManager (CLIP variant)."""
node, clip = self._make_clip_node(
mock_db, mock_integ, mock_tracker, mock_monitor
)
node, clip = self._make_clip_node(mock_db, mock_tracker, mock_monitor)
result = node.encode_prompt(
clip=clip,
text="This is the prompt",
@@ -239,13 +208,9 @@ class TestConnectedTextInput(unittest.TestCase):
"This is prepend text This is the prompt This is append text",
)
def test_clip_node_connected_text_only(
self, mock_db, mock_integ, mock_tracker, mock_monitor
):
def test_clip_node_connected_text_only(self, mock_db, mock_tracker, mock_monitor):
"""Connected text without prepend/append passes through unchanged."""
node, clip = self._make_clip_node(
mock_db, mock_integ, mock_tracker, mock_monitor
)
node, clip = self._make_clip_node(mock_db, mock_tracker, mock_monitor)
result = node.encode_prompt(clip=clip, text="This is the prompt")
self.assertEqual(result[1], "This is the prompt")
-1
View File
@@ -324,7 +324,6 @@ class CaptureTests(unittest.TestCase):
node.db.save_prompt_pair.side_effect = RuntimeError("offline")
node.logger = logging.getLogger("test.capture.nodes")
node.prompt_tracker = Mock()
node.comfyui_integration = Mock()
node._inject_lora_trigger_words = lambda text: text + " TRIGGER"
clip = Mock()
clip.tokenize.side_effect = lambda text: text
-1
View File
@@ -24,7 +24,6 @@ class TestFocusedNode(unittest.TestCase):
def test_focused_encodes_composition_and_tracks_main_text_and_graph(self):
node = PromptManagerFocused.__new__(PromptManagerFocused)
node.logger = logging.getLogger("test.focused")
node.comfyui_integration = mock.Mock()
node._inject_lora_trigger_words = lambda text: text
node._track_prompt_execution = mock.Mock(return_value=42)
clip = mock.Mock()
+1 -29
View File
@@ -1,7 +1,7 @@
"""
Tests for hashing utilities.
Tests is_duplicate_prompt and generate_prompt_hash, which are
Tests and generate_prompt_hash, which are
currently untested. generate_prompt_hash is covered in test_basic.py
but we add edge cases here.
"""
@@ -15,7 +15,6 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from tests.background_stops import stop_tracker # noqa: E402
from utils.hashing import (
generate_prompt_hash,
is_duplicate_prompt,
STRIPPED_CODE_POINTS,
prompt_key,
register_prompt_functions,
@@ -69,33 +68,6 @@ class TestSingleHashImplementation(unittest.TestCase):
self.assertFalse(os.path.exists(os.path.join(ROOT, "restart_gallery.py")))
class TestIsDuplicatePrompt(unittest.TestCase):
"""Test is_duplicate_prompt function."""
def test_identical_texts_are_duplicates(self):
self.assertTrue(is_duplicate_prompt("hello", "hello"))
def test_case_insensitive(self):
self.assertTrue(is_duplicate_prompt("Hello World", "hello world"))
def test_whitespace_insensitive(self):
self.assertTrue(is_duplicate_prompt(" hello ", "hello"))
def test_different_texts_not_duplicates(self):
self.assertFalse(is_duplicate_prompt("text A", "text B"))
def test_threshold_parameter_accepted(self):
# threshold is unused but should not cause errors
self.assertTrue(is_duplicate_prompt("same", "same", threshold=0.5))
def test_empty_strings_are_duplicates(self):
self.assertTrue(is_duplicate_prompt("", ""))
def test_unicode_comparison(self):
self.assertTrue(is_duplicate_prompt("日本語", "日本語"))
self.assertFalse(is_duplicate_prompt("日本語", "中文"))
class TestGeneratePromptHashEdgeCases(unittest.TestCase):
"""Additional edge cases for generate_prompt_hash."""
+1 -12
View File
@@ -21,7 +21,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Mock ComfyUI server before importing anything that touches config. Only the
# first test module to run installs the stub; nothing else in sys.modules is
# replaced, so later modules (test_comfyui_integration) see the real package.
# replaced, so later modules see the real package.
_mock_server = MagicMock()
_mock_server.PromptServer.instance.routes = MagicMock()
sys.modules.setdefault("server", _mock_server)
@@ -881,17 +881,6 @@ class TestOutputListener(unittest.TestCase):
self.assertTrue(monitor.stop_monitoring())
class TestModuleIsolation(unittest.TestCase):
"""This module must not leave mocks behind for later test modules."""
def test_comfyui_integration_is_not_replaced_by_a_mock(self):
module = sys.modules.get("utils.comfyui_integration")
self.assertFalse(
isinstance(module, MagicMock),
"utils.comfyui_integration was replaced by a MagicMock in sys.modules",
)
class TestMonitorThreadSafety(unittest.TestCase):
"""Singleton creation must be thread-safe."""
-2
View File
@@ -406,8 +406,6 @@ class TestLegacyRoutesHoldTheRegistry(_LegacyHoldCase):
)
await self._busy(resp)
self.assertTrue(victim.exists())
# The duplicate family lock was not taken by the refused request.
self.assertIsNotNone(self.api._acquire_long_job(self.api.LONG_JOB_DUPLICATES))
async def test_lora_scan_refused_while_scan_runs(self):
from py.config import IntegrationConfig
+2 -21
View File
@@ -179,7 +179,6 @@ class NodeTestCase(unittest.TestCase):
self.node.logger = logging.getLogger("test.node")
self.node.prompt_tracker = mock.Mock()
self.node.image_monitor = mock.Mock()
self.node.comfyui_integration = mock.Mock()
def tearDown(self):
self.db.close_all() # Windows cannot unlink an open database file
@@ -189,20 +188,16 @@ class NodeTestCase(unittest.TestCase):
class TestBaseSaveAndSearch(NodeTestCase):
def test_init_wires_database_tracker_monitor_and_integration(self):
def test_init_wires_database_tracker_and_monitor(self):
with (
mock.patch.object(prompt_manager_base, "PromptDatabase") as db_cls,
mock.patch.object(prompt_manager_base, "get_prompt_tracker") as tracker,
mock.patch.object(prompt_manager_base, "get_image_monitor") as monitor,
mock.patch.object(
prompt_manager_base, "get_comfyui_integration"
) as integration,
):
node = PromptManagerBase()
self.assertIs(node.db, db_cls.return_value)
tracker.assert_called_once_with(db_cls.return_value)
monitor.assert_called_once_with(db_cls.return_value, tracker.return_value)
self.assertIs(node.comfyui_integration, integration.return_value)
monitor.return_value.start_monitoring.assert_called_once_with()
def test_saving_an_existing_prompt_updates_its_metadata(self):
@@ -242,31 +237,18 @@ class TestBaseSaveAndSearch(NodeTestCase):
self.node._save_prompt_to_database("a cat on a mat")
self.assertEqual(self.node._search_prompts(""), [])
self.assertEqual(self.node._search_prompts(" "), [])
self.assertEqual(len(self.node.search_prompts_api("cat")), 1)
self.assertEqual(len(self.node.get_recent_prompts_api(limit=5)["prompts"]), 1)
self.assertEqual(len(self.node._search_prompts("cat")), 1)
def test_search_and_recent_errors_return_empty_lists(self):
self.node.db = mock.Mock()
self.node.db.search_prompts.side_effect = RuntimeError("locked")
self.node.db.get_recent_prompts.side_effect = RuntimeError("locked")
with self.assertLogs("test.node", level="ERROR"):
self.assertEqual(self.node._search_prompts("cat"), [])
with self.assertLogs("test.node", level="ERROR"):
self.assertEqual(self.node.get_recent_prompts_api(), [])
def test_gallery_system_start_failure_is_logged(self):
self.node.image_monitor.start_monitoring.side_effect = OSError("no inotify")
with self.assertLogs("test.node", level="ERROR"):
self.node._start_gallery_system()
self.node.image_monitor.get_status.return_value = {"running": False}
self.node.prompt_tracker.get_status.return_value = {"active_prompts_count": 0}
self.assertEqual(
self.node.get_gallery_status(),
{
"image_monitor": {"running": False},
"prompt_tracker": {"active_prompts_count": 0},
},
)
self.node.cleanup_gallery_system()
self.node.image_monitor.stop_monitoring.assert_not_called()
@@ -376,7 +358,6 @@ class TestPromptManagerEncoding(NodeTestCase):
self.assertEqual(saved["text"], "a cat")
self.assertIn("prepend:best", saved["tags"])
self.assertIn("append:8k", saved["tags"])
self.node.comfyui_integration.register_prompt.assert_called_once()
def test_tracking_failure_does_not_break_encoding(self):
self.node._track_prompt_execution = mock.Mock(side_effect=RuntimeError("db"))
-1
View File
@@ -19,7 +19,6 @@ def bare(node_cls):
"""A node without its database, tracker or gallery side effects."""
node = node_cls.__new__(node_cls)
node.logger = __import__("logging").getLogger("test.wiring")
node.comfyui_integration = mock.Mock()
node._inject_lora_trigger_words = lambda text: text
node._track_prompt_execution = mock.Mock(return_value=42)
return node
+102
View File
@@ -0,0 +1,102 @@
"""What the Registry package leaves out (#615).
``comfy node publish`` zips the tracked files minus ``.comfyignore``. The checks
here hold the two ways that can go wrong: a development folder creeping back
into the download, or a rule that drops something ComfyUI needs at run time.
"""
import ast
import fnmatch
import re
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RUNTIME_DIRS = ("py", "database", "utils", "web")
DEVELOPMENT = (
"tests/test_package_contents.py",
"tools/vendor/package.json",
"docs/application-redesign.md",
".github/workflows/publish.yml",
"images/x.png",
"TESTING.md",
"requirements-dev.txt",
)
KEPT = (
"__init__.py",
"prompt_manager.py",
"pyproject.toml",
"requirements.txt",
"LICENSE",
"README.md",
"README-legacy.md",
"assets/readme/hero.webp",
)
def _patterns():
lines = (ROOT / ".comfyignore").read_text(encoding="utf-8").splitlines()
return [line.strip() for line in lines if line.strip() and line[0] != "#"]
def _ignored(path):
"""The rules this file allows: ``folder/`` from the root, or a root file."""
for pattern in _patterns():
if pattern.endswith("/"):
if path.startswith(pattern.lstrip("/")):
return True
elif fnmatch.fnmatchcase(path, pattern.lstrip("/")):
return True
return False
class TestPackageContents(unittest.TestCase):
def test_every_rule_is_anchored_to_the_root(self):
# An unanchored "tests/" would also drop web/js/tests, and "images/" a
# runtime folder of that name deeper down.
for pattern in _patterns():
self.assertTrue(pattern.startswith("/"), pattern)
self.assertNotIn("!", pattern)
def test_development_files_are_left_out(self):
for path in DEVELOPMENT:
self.assertTrue(_ignored(path), path)
def test_what_comfyui_needs_stays(self):
for path in KEPT:
self.assertFalse(_ignored(path), path)
for folder in RUNTIME_DIRS:
for file in (ROOT / folder).rglob("*"):
if file.is_file():
relative = file.relative_to(ROOT).as_posix()
self.assertFalse(_ignored(relative), relative)
def test_runtime_code_imports_nothing_left_out(self):
sources = list(ROOT.glob("*.py"))
for folder in ("py", "database", "utils"):
sources.extend((ROOT / folder).rglob("*.py"))
for source in sources:
tree = ast.parse(source.read_text(encoding="utf-8"))
for node in ast.walk(tree):
names = []
if isinstance(node, ast.Import):
names = [alias.name for alias in node.names]
elif isinstance(node, ast.ImportFrom) and not node.level:
names = [node.module or ""]
for name in names:
self.assertNotIn(name.split(".")[0], ("tests", "tools"), source)
def test_the_old_pages_name_their_replacement(self):
for page, section in (
("admin", "library"),
("gallery", "images"),
("metadata", "inspect"),
):
html = (ROOT / "web" / f"{page}.html").read_text(encoding="utf-8")
self.assertRegex(html, r'id="legacy-removal-notice"')
self.assertRegex(html, re.escape(f'href="workspace#/{section}"'))
if __name__ == "__main__":
sys.exit(unittest.main())
-1
View File
@@ -47,7 +47,6 @@ class TestPairExecution(unittest.TestCase):
self.node.db = self.db
self.node.logger = logging.getLogger("test.execution")
self.node.prompt_tracker = Mock()
self.node.comfyui_integration = Mock()
self.node._inject_lora_trigger_words = lambda text: text + " LORA"
self.extra = {"workflow": {"unchanged": True}, "sibling": {"keep": True}}
-1
View File
@@ -64,7 +64,6 @@ class TestPairUsageTracking(unittest.TestCase):
node.pending_pair_first_use = self.pair_pending
node._inject_lora_trigger_words = lambda text: text
node.prompt_tracker = Mock()
node.comfyui_integration = Mock()
return node
def execute(self, graph, text="cat", negative="blur"):
+50
View File
@@ -89,5 +89,55 @@ class TestProbeCheck(unittest.TestCase):
self.probe.check(*KEY)
class TestRefreshOrder(unittest.TestCase):
"""Only the newest check's answer is published (#611)."""
def test_an_older_check_never_overwrites_a_newer_answer(self):
parsed = preview_location.ParsedLocation(
mode=preview_store.FOLDER_MODE, path="/thumbs", id="x"
)
self.addCleanup(setattr, preview_store, "_available", preview_store._available)
self.addCleanup(setattr, preview_store, "_problem", preview_store._problem)
started = threading.Event()
release = threading.Event()
newer_published = threading.Event()
calls = []
publish = preview_store._set_state
def state(_path, _expected_id):
calls.append(1)
if len(calls) == 1:
started.set()
self.assertTrue(release.wait(WAIT))
return HERE
return GONE
def set_state(*args, **kwargs):
if args[1]: # the older check's "available" arrives last
self.assertTrue(newer_published.wait(WAIT))
publish(*args, **kwargs)
def newer():
preview_store.refresh()
newer_published.set()
with (
patch.object(preview_store, "_parsed", parsed),
patch.object(preview_location, "folder_state", state),
patch.object(preview_store, "_set_state", set_state),
):
first = threading.Thread(target=preview_store.refresh)
first.start()
self.assertTrue(started.wait(WAIT))
second = threading.Thread(target=newer)
second.start()
release.set()
second.join(WAIT)
first.join(WAIT)
self.assertFalse(first.is_alive() or second.is_alive())
self.assertFalse(preview_store._available)
self.assertEqual(preview_store._problem, "missing")
if __name__ == "__main__":
unittest.main()
-1
View File
@@ -162,7 +162,6 @@ class NodeCase(DatabaseCase):
node.db = self.db
node.logger = logging.getLogger("test.prompt_attribution.node")
node.prompt_tracker = self.tracker
node.comfyui_integration = mock.Mock()
node._inject_lora_trigger_words = lambda text: text
node.pending_first_use = PendingFirstUse()
node.pending_pair_first_use = PendingFirstUse()
+1 -5
View File
@@ -45,7 +45,6 @@ class TestPromptPairNode(unittest.TestCase):
self.node.db = self.db
self.node.logger = logging.getLogger("test.pair_node")
self.node.prompt_tracker = Mock()
self.node.comfyui_integration = Mock()
self.node._inject_lora_trigger_words = Mock(
side_effect=lambda text: text + " LORA"
)
@@ -241,12 +240,9 @@ class TestPromptPairNode(unittest.TestCase):
)
self.node.prompt_tracker.set_current_prompt.assert_not_called()
def test_invalid_text_does_not_write_and_integration_failures_do_not_block(self):
def test_invalid_text_does_not_write(self):
with self.assertRaises(ValueError):
self.node.encode_prompt(self.clip, [], negative_text="blur")
self.node.comfyui_integration.register_prompt.side_effect = RuntimeError(
"metadata unavailable"
)
result = self.node.encode_prompt(self.clip, "cat", negative_text="blur")
self.assertEqual(result[2:], ("cat LORA", "blur"))
-1
View File
@@ -294,7 +294,6 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase):
node.db = self.db
node.logger = __import__("logging").getLogger("test.prompt_usage")
node.prompt_tracker = mock.Mock()
node.comfyui_integration = mock.Mock()
node._inject_lora_trigger_words = lambda text: text
node.cached_runs = mock.Mock()
node.cached_runs.replayed.return_value = False
-1
View File
@@ -243,7 +243,6 @@ class TestNodesSaveNothing(NodeCase):
self.assertEqual(self.count_rows("prompt_pairs"), 0)
self.assertEqual(self.tracker.keyed_snapshot(), {})
self.assertEqual(self.tracker._prompt_queue, [])
node.comfyui_integration.register_prompt.assert_not_called()
if cls is PromptManagerText:
self.assertEqual(result, (POSITIVE,))
elif cls is PromptManagerPair:
+2 -2
View File
@@ -204,7 +204,7 @@ class TestSavedViewsApi(ImageAPITestCase):
{
"page": "library",
"name": "L",
"state": {**LIBRARY_STATE, "sort": "name-desc"},
"state": {**LIBRARY_STATE, "sort": "shuffle"},
},
]
for body in cases:
@@ -562,7 +562,7 @@ class TestSavedViewsSchema(unittest.TestCase):
conn.execute(
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
"updated_at) VALUES ('library', ?, ?, 6, 'x', 'x')",
(" " + "n" * 120, json.dumps({"sort": "name-desc", "q": 4})),
(" " + "n" * 120, json.dumps({"sort": "shuffle", "q": 4})),
)
conn.commit()
-1
View File
@@ -28,7 +28,6 @@ class TestSplitNode(unittest.TestCase):
def test_split_encodes_composition_and_persists_main_text_with_metadata(self):
node = PromptManagerSplit.__new__(PromptManagerSplit)
node.logger = logging.getLogger("test.split")
node.comfyui_integration = unittest.mock.Mock()
node._inject_lora_trigger_words = lambda text: text
node._track_prompt_execution = unittest.mock.Mock(return_value=42)
clip = unittest.mock.Mock()
+39
View File
@@ -0,0 +1,39 @@
"""Every read-only SQLite open uses one URI helper (#620).
pathlib writes a Windows network path as ``file://server/share/x.db``, which
SQLite refuses. A reader that builds its own URI breaks backups, restores,
library moves or history reads for a library on a network share.
"""
import re
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from database.sqlite_uri import readonly_uri # noqa: E402
class TestReadonlyUri(unittest.TestCase):
def test_a_network_path_has_an_empty_authority(self):
self.assertEqual(
readonly_uri(r"\\server\share\Comfy UI\prompts.db", windows=True),
"file:////server/share/Comfy%20UI/prompts.db?mode=ro",
)
def test_no_reader_builds_its_own_uri(self):
offenders = []
for folder in ("database", "py", "utils"):
for path in (ROOT / folder).rglob("*.py"):
if path.name == "sqlite_uri.py":
continue
text = path.read_text(encoding="utf-8")
if re.search(r"as_uri\(\)\s*\+\s*[\"']\?mode=ro", text):
offenders.append(path.relative_to(ROOT).as_posix())
self.assertEqual(offenders, [])
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -78,7 +78,7 @@ class TestDefaultsAndConfig(ConfigCase):
self.assertEqual(ts.current().format, "webp")
def test_the_never_read_gallery_thumbnail_size_is_gone(self):
# R1: older save_to_file wrote its default 256 into config.json; it
# R1: older versions wrote its default 256 into config.json; it
# must never size a preview.
self.assertFalse(hasattr(GalleryConfig, "THUMBNAIL_SIZE"))
PromptManagerConfig.update_config(
-43
View File
@@ -17,7 +17,6 @@ from utils.validators import (
validate_tags,
validate_category,
sanitize_input,
parse_tags_string,
validate_result_timeout,
validate_worker_threads,
)
@@ -125,48 +124,6 @@ class TestSanitizeInput(unittest.TestCase):
self.assertEqual(result, "a\nb\nc\nd")
class TestParseTagsString(unittest.TestCase):
"""Test parse_tags_string function."""
def test_simple_comma_separated(self):
result = parse_tags_string("tag1, tag2, tag3")
self.assertEqual(result, ["tag1", "tag2", "tag3"])
def test_extra_whitespace(self):
result = parse_tags_string(" tag1 , tag2 ")
self.assertEqual(result, ["tag1", "tag2"])
def test_empty_string_returns_empty_list(self):
self.assertEqual(parse_tags_string(""), [])
def test_none_returns_empty_list(self):
self.assertEqual(parse_tags_string(None), [])
def test_non_string_returns_empty_list(self):
self.assertEqual(parse_tags_string(123), [])
def test_single_tag(self):
self.assertEqual(parse_tags_string("solo"), ["solo"])
def test_deduplication(self):
result = parse_tags_string("tag1, tag1, tag2, tag1")
self.assertEqual(result, ["tag1", "tag2"])
def test_empty_segments_skipped(self):
result = parse_tags_string("tag1,,tag2,,,tag3")
self.assertEqual(result, ["tag1", "tag2", "tag3"])
def test_max_20_tags(self):
tags = ", ".join(f"tag{i}" for i in range(25))
result = parse_tags_string(tags)
self.assertEqual(len(result), 20)
def test_strips_null_bytes_from_tags(self):
result = parse_tags_string("clean\x00tag, normal")
self.assertIn("cleantag", result)
self.assertIn("normal", result)
class TestValidatePromptTextEdgeCases(unittest.TestCase):
"""Additional edge cases beyond test_basic.py coverage."""
+4 -2
View File
@@ -8,6 +8,8 @@ import { readFileSync } from 'node:fs';
const workflow = readFileSync(new URL('../../.github/workflows/viewerjs-check.yml', import.meta.url), 'utf8');
const lock = JSON.parse(readFileSync(new URL('../../tools/vendor/package-lock.json', import.meta.url), 'utf8'));
const version = lock.packages['node_modules/@playwright/test'].version;
// The tag alone is not a pin: v1.64.1-noble was re-pushed with Node 26 (#609). The digest fixes the image's contents.
const pinnedImage = `^ container:\\n image: mcr\\.microsoft\\.com/playwright:v${version.replaceAll('.', '\\.')}-noble@sha256:[0-9a-f]{64}$`;
function job(name) {
const match = workflow.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [a-z][a-z-]*:\\n|(?![\\s\\S]))`, 'm'));
@@ -18,7 +20,7 @@ function job(name) {
for (const name of ['frontend-coverage', 'browser-workflows']) {
test(`${name} runs in the pinned Playwright image matching @playwright/test`, () => {
const body = job(name);
assert.match(body, new RegExp(`^ container:\\n image: mcr\\.microsoft\\.com/playwright:v${version.replaceAll('.', '\\.')}-noble$`, 'm'));
assert.match(body, new RegExp(pinnedImage, 'm'));
assert.doesNotMatch(body, /playwright install/, 'browsers and their fonts come from the image, not a runner install');
// Toolcache interpreters are the runner's own builds: their entry points name host paths that do not exist in
// the container (setup-python failed with `spawn .../bin/pip ENOENT`). Use the image's Python and Node.
@@ -39,7 +41,7 @@ test('real ComfyUI compatibility runs in the pinned Playwright image on the revi
const match = testWorkflow.match(/^ {2}comfyui:\n([\s\S]*?)(?=^ {2}[a-z][a-z-]*:\n|(?![\s\S]))/m);
assert.ok(match, 'comfyui job exists');
const body = match[1];
assert.match(body, new RegExp(`^ container:\\n image: mcr\\.microsoft\\.com/playwright:v${version.replaceAll('.', '\\.')}-noble$`, 'm'));
assert.match(body, new RegExp(pinnedImage, 'm'));
assert.doesNotMatch(body, /playwright install/, 'Chromium and its system libraries come from the image, not apt');
assert.doesNotMatch(body, /apt-get|actions\/setup-(python|node)@/);
assert.match(body, /python3 -m venv --without-pip /, 'a user-space venv on the image Python');
+2 -2
View File
@@ -10,7 +10,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
const workflow = readFileSync(new URL('../../.github/workflows/viewerjs-check.yml', import.meta.url), 'utf8');
const PINNED_IMAGE = /^\s+image: mcr\.microsoft\.com\/playwright:v\d+\.\d+\.\d+-noble$/m;
const PINNED_IMAGE = /^\s+image: mcr\.microsoft\.com\/playwright:v\d+\.\d+\.\d+-noble@sha256:[0-9a-f]{64}$/m;
function jobs(text) {
const lines = text.split('\n');
@@ -237,7 +237,7 @@ test('every npm ci restores the npm download cache keyed on the vendor lockfile
assert.match(jobSteps[cacheAt], /hashFiles\('tools\/vendor\/package-lock\.json'\)/, `${id} keys the cache on the lockfile`);
assert.match(jobSteps[installAt], /npm ci --prefix tools\/vendor --ignore-scripts/, `${id} still installs without scripts`);
// The key names the job's own pinned image, so an image bump starts a fresh cache.
const image = /^ {6}image: mcr\.microsoft\.com\/playwright:(v\d+\.\d+\.\d+)-noble$/m.exec(body)?.[1];
const image = /^ {6}image: mcr\.microsoft\.com\/playwright:(v\d+\.\d+\.\d+)-noble@sha256:[0-9a-f]{64}$/m.exec(body)?.[1];
assert.ok(image, `${id} runs in the pinned Playwright image`);
assert.match(jobSteps[cacheAt], new RegExp(`key: npm-\\$\\{\\{ runner\\.os \\}\\}-playwright-${image.replaceAll('.', '\\.')}-\\$\\{\\{ hashFiles`),
`${id} keys the cache on its image ${image}`);
-220
View File
@@ -1,220 +0,0 @@
"""ComfyUI integration utilities for PromptManager.
This module tracks the prompts PromptManager nodes encode during execution so
other components (e.g. image/prompt linking) can look them up.
ComfyUI already writes every node's own inputs into saved image metadata, so
SaveImage is deliberately NOT patched. An earlier patch wrote the last-run prompt
into every PromptManager node, saving negative prompts as copies of the
positive (#75).
Key features:
- Thread-safe prompt registration and retrieval
- Cross-thread prompt context sharing
- Standard metadata format compatibility
- Automatic cleanup of old prompt registrations
The integration works by:
1. PromptManager nodes register their prompts during execution
2. Callers retrieve the current prompt text by node id or recency
Typical usage:
from utils.comfyui_integration import get_comfyui_integration
integration = get_comfyui_integration()
integration.register_prompt(node_id, prompt_text, metadata)
text = integration.get_current_prompt_text(node_id)
"""
import threading
import time
from typing import Dict, Any, Optional
try:
from .logging_config import get_logger
except ImportError:
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.logging_config import get_logger
class ComfyUIMetadataIntegration:
"""Integrates PromptManager with ComfyUI's standard metadata system.
This singleton class manages the integration between PromptManager custom nodes
and ComfyUI's standard metadata system. It ensures that prompts generated by
PromptManager appear in the standard ComfyUI metadata format for compatibility
with third-party tools and parsers.
The integration uses thread-local storage combined with global tracking to
handle prompt context across different execution threads, which is necessary
because ComfyUI executions may span multiple threads.
Key responsibilities:
- Register prompts from PromptManager nodes during execution
- Manage prompt lifecycle and cleanup
"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
"""Ensure singleton pattern with thread safety.
Returns:
The single instance of ComfyUIMetadataIntegration
"""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""Initialize the ComfyUI integration system.
Sets up prompt tracking data structures. Uses _initialized flag to
prevent duplicate initialization.
"""
if hasattr(self, "_initialized"):
return
self.logger = get_logger("prompt_manager.comfyui_integration")
self._current_prompts = {}
self._thread_local = threading.local()
self._initialized = True
def register_prompt(self, node_id: str, prompt_text: str, metadata: Dict[str, Any]):
"""
Register a prompt from PromptManager for inclusion in ComfyUI metadata.
This method is called by PromptManager nodes during execution to register
their prompt text for later inclusion in image metadata. The prompt is stored
in both thread-local and global storage to handle cross-thread access scenarios.
Args:
node_id: Unique identifier for this prompt (typically the node ID)
prompt_text: The actual prompt text that was encoded by PromptManager
metadata: Additional metadata from PromptManager (category, tags, etc.)
"""
thread_id = threading.current_thread().ident
# Store in thread-local storage
if not hasattr(self._thread_local, "prompts"):
self._thread_local.prompts = {}
self._thread_local.prompts[node_id] = {
"text": prompt_text,
"metadata": metadata,
"timestamp": time.time(),
"thread_id": thread_id,
}
# Also store globally for cross-thread access
with self._lock:
self._current_prompts[f"{thread_id}_{node_id}"] = {
"text": prompt_text,
"metadata": metadata,
"timestamp": time.time(),
"thread_id": thread_id,
}
self.logger.debug(
f"Registered prompt for node {node_id}: {prompt_text[:50]}..."
)
def get_current_prompt_text(self, node_id: str = None) -> Optional[str]:
"""
Get the current prompt text for metadata inclusion.
Retrieves the most appropriate prompt text for inclusion in image metadata.
Uses a fallback strategy: thread-local storage first, then global storage,
with recency checks to avoid stale prompts.
Args:
node_id: Optional specific node ID to retrieve prompt for
Returns:
The prompt text string if available, None if no suitable prompt found
"""
# First try thread-local storage
if hasattr(self._thread_local, "prompts"):
if node_id and node_id in self._thread_local.prompts:
return self._thread_local.prompts[node_id]["text"]
elif self._thread_local.prompts:
# Return the most recent prompt from this thread
latest = max(
self._thread_local.prompts.values(), key=lambda x: x["timestamp"]
)
return latest["text"]
# Fallback to global storage
thread_id = threading.current_thread().ident
with self._lock:
# Look for prompts from current thread
thread_prompts = {
k: v
for k, v in self._current_prompts.items()
if v["thread_id"] == thread_id
}
if thread_prompts:
latest = max(thread_prompts.values(), key=lambda x: x["timestamp"])
return latest["text"]
# Last resort: return the most recent prompt from any thread
if self._current_prompts:
latest = max(
self._current_prompts.values(), key=lambda x: x["timestamp"]
)
# Only return if it's recent (within last 5 minutes)
if time.time() - latest["timestamp"] < 300:
return latest["text"]
return None
def cleanup_old_prompts(self, max_age_seconds: int = 600):
"""
Clean up old prompt registrations.
Removes prompt registrations that are older than the specified age to
prevent memory leaks and ensure that only recent, relevant prompts are
used for metadata inclusion.
Args:
max_age_seconds: Maximum age in seconds before a prompt registration
is considered stale and removed (default: 600
seconds/10 minutes)
"""
current_time = time.time()
with self._lock:
old_keys = [
key
for key, prompt_data in self._current_prompts.items()
if current_time - prompt_data["timestamp"] > max_age_seconds
]
for key in old_keys:
del self._current_prompts[key]
if old_keys:
self.logger.debug(f"Cleaned up {len(old_keys)} old prompt registrations")
# Global instance
_integration_instance = None
def get_comfyui_integration() -> ComfyUIMetadataIntegration:
"""Get the global ComfyUI integration instance.
Returns:
The singleton ComfyUIMetadataIntegration instance, creating it if necessary
"""
global _integration_instance
if _integration_instance is None:
_integration_instance = ComfyUIMetadataIntegration()
return _integration_instance
+1 -33
View File
@@ -8,19 +8,15 @@ regardless of minor formatting differences.
Key features:
- SHA256-based prompt text hashing with normalization
- Content hashing for complex prompt metadata structures
- Duplicate detection utilities
- Consistent normalization (lowercase, trimmed whitespace)
Typical usage:
from utils.hashing import generate_prompt_hash, is_duplicate_prompt
from utils.hashing import generate_prompt_hash
hash1 = generate_prompt_hash("Beautiful landscape")
hash2 = generate_prompt_hash(" beautiful landscape ")
# hash1 == hash2 (normalization makes them identical)
if is_duplicate_prompt(text1, text2):
print("Duplicate prompts detected")
The hashing is designed for:
- Database deduplication (preventing duplicate prompt storage)
- Quick similarity checking
@@ -124,31 +120,3 @@ def stored_prompt_text_sql(expression: str) -> str:
character of Y, multi-byte ones included, so it strips what Python does.
"""
return f"trim({expression}, char({_STRIP_CODES}))"
def is_duplicate_prompt(text1: str, text2: str, threshold: float = 0.95) -> bool:
"""
Check if two prompts are likely duplicates using hash comparison.
Compares the normalized hashes of two prompt texts to determine if
they are functionally identical. This is an exact match after normalization,
not a similarity measure.
Args:
text1: First prompt text to compare
text2: Second prompt text to compare
threshold: Similarity threshold (not used - kept for API compatibility)
Returns:
True if the prompts have identical normalized hashes (i.e., are duplicates),
False otherwise
Note:
The threshold parameter is not used in the current implementation as
this performs exact hash matching rather than similarity scoring.
It's maintained for potential future fuzzy matching capabilities.
"""
hash1 = generate_prompt_hash(text1)
hash2 = generate_prompt_hash(text2)
return hash1 == hash2
+3 -4
View File
@@ -1,7 +1,6 @@
"""Restore publication checks for snapshot-owned history reads."""
import os
from pathlib import Path
import sqlite3
import threading
from typing import TYPE_CHECKING
@@ -10,11 +9,13 @@ if TYPE_CHECKING:
from ..database.operations import PromptDatabase
try:
from ..database.sqlite_uri import readonly_uri
from ..database.execution_history import (
HistoryEpochChanged,
subscribe_history_restore,
)
except ImportError:
from database.sqlite_uri import readonly_uri
from database.execution_history import (
HistoryEpochChanged,
subscribe_history_restore,
@@ -53,9 +54,7 @@ class HistoryReadFence:
self._check(generation, epoch)
# An API restore retires its previous model. Never reopen that owner,
# and never reuse the projection transaction's view of the epoch.
conn = sqlite3.connect(
Path(self._path).as_uri() + "?mode=ro", uri=True, timeout=0.25
)
conn = sqlite3.connect(readonly_uri(self._path), uri=True, timeout=0.25)
try:
row = conn.execute(
"SELECT storage_epoch FROM execution_history_metadata "
-27
View File
@@ -295,30 +295,3 @@ def sanitize_input(text: str) -> str:
result_lines.append(line)
return "\n".join(result_lines).strip()
def parse_tags_string(tags_string: str) -> List[str]:
"""
Parse a comma-separated tags string into a clean list.
Converts comma-separated tag strings into a clean, deduplicated list
of tags. Each tag is sanitized and trimmed.
Args:
tags_string: Comma-separated tags string (e.g., "tag1, tag2, tag3")
Returns:
List of unique, cleaned tag strings. Empty input returns empty list.
Limited to maximum 20 tags to prevent abuse.
"""
if not tags_string or not isinstance(tags_string, str):
return []
# Split by comma and clean each tag
tags = []
for tag in tags_string.split(","):
clean_tag = sanitize_input(tag).strip()
if clean_tag and clean_tag not in tags: # Avoid duplicates
tags.append(clean_tag)
return tags[:20] # Limit to 20 tags
+1
View File
@@ -20,6 +20,7 @@
<script src="lib/viewerjs/viewer.min.js"></script>
</head>
<body class="bg-pm-primary text-pm min-h-screen text-[13px]">
<div id="legacy-removal-notice" role="note" class="bg-pm-surface border-b border-pm px-4 py-2 text-pm" style="font-size: 13px;">This page will be removed in a future release. Its features are moving to the new workspace. <a href="workspace#/library" style="text-decoration: underline;">Open the workspace</a></div>
<div class="min-h-screen flex flex-col">
<!-- Header -->
<header class="bg-pm-surface border-b border-pm px-4 py-2" style="min-height: 2.5rem;">
+1
View File
@@ -28,6 +28,7 @@
</style>
</head>
<body class="bg-pm-primary text-pm min-h-screen">
<div id="legacy-removal-notice" role="note" class="bg-pm-surface border-b border-pm px-4 py-2 text-pm" style="font-size: 13px;">This page will be removed in a future release. Its features are moving to the new workspace. <a href="workspace#/images" style="text-decoration: underline;">Open the workspace</a></div>
<div class="min-h-screen flex flex-col">
<!-- Header -->
<header class="bg-pm-surface border-b border-pm px-4 py-2">
+1 -1
View File
@@ -11,7 +11,7 @@ function parameters(filters,limit) {
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'].includes(sort)) throw new TypeError('Invalid library sort');
if(!['newest','oldest','name','name-desc','most-used','last-used'].includes(sort)) throw new TypeError('Invalid library sort');
// #444: how Tags match (every tag, any of them, or parents no tag's Browse returns, which never goes with tags) and
// the output folder below a root.
const tagMode=filters.tagMode ?? 'all',folder=filters.folder ?? '';
+1 -1
View File
@@ -36,7 +36,7 @@ const selectedPairs=new Map();
const metadataDrafts=new Map();
const archivedVisible=new Map();
// The concept's sorts first, then the Library's appended Oldest first (D14); D153 (#447) drops "Highest rated".
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['oldest','Oldest first']]);
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['oldest','Oldest first'],['last-used','Recently used'],['most-used','Most used'],['name-desc','Name Z–A']]);
// Refine › Tag match (#444 D149, D150), in LIBRARY_TAG_MODES order.
const TAG_MATCH_OPTIONS=Object.freeze([['all','All of these tags'],['any','Any of these tags'],['untagged','Prompts without tags']]);
// A card's focusable controls in order: the D148 selection checkbox, then its buttons (#437).
+1 -1
View File
@@ -46,7 +46,7 @@ const FIELDS = Object.freeze({
collection: { fallback: '', accepts: value => typeof value === 'string' && /^(|[1-9][0-9]{0,8})$/.test(value) },
}),
library: Object.freeze({
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name'], 'newest'), tags,
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc', 'most-used', 'last-used'], 'newest'), tags,
// #444: how the Tags match ('untagged' never goes with tags; the listing refuses both) and the output folder
// ('/'-joined, below a root).
tag_mode: choice(['all', 'any', 'untagged'], 'all'), folder: text(1024),
-8
View File
@@ -25,14 +25,6 @@ export const routes = [
description: 'Configuration, maintenance and recovery—with browsing kept in the workspace.', action: 'Open System' },
];
/** D165 (#525): the legacy pages, which stay at their URLs; System's Overview section links them discreetly. */
export const LEGACY_PAGES = Object.freeze([
{ label: 'Dashboard', href: '/prompt_manager/admin' },
{ label: 'Tags', href: '/prompt_manager/admin#/tags' },
{ label: 'Gallery', href: '/prompt_manager/gallery' },
{ label: 'Metadata viewer', href: '/prompt_manager/gallery.html' },
].map(page => Object.freeze(page)));
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'review', 'system',
'collection']);
+1 -20
View File
@@ -12,7 +12,7 @@
* listeners and timers never outlive it. The drafts are module-level (Q10): they live for the page session.
*/
import { createButton, createScope } from '../shared/toolkit/core.js';
import { LEGACY_PAGES, SYSTEM_SECTIONS, navigateTo } from './state.mjs';
import { SYSTEM_SECTIONS, navigateTo } from './state.mjs';
import { createSystemDrafts, DRAFT_DOMAINS, INVALID_SETTING_CODE } from './system-state.mjs';
import { sectionNav } from './system-controls.mjs';
import { mountOverview } from './system-overview.mjs';
@@ -145,22 +145,6 @@ export const DELIVERED_SECTIONS = Object.freeze({
* under one of this tab's drafts flags that draft (D92's conflict status), including a change made while System was
* not mounted (the drafts outlive it, Q10).
*/
/** D165 (#525): one small line under System's Overview, the workspace's only visible way to the legacy pages. */
function legacyPagesLine() {
const line = el('p', 'small legacy-pages');
line.append('Legacy pages: ');
LEGACY_PAGES.forEach((page, index) => {
const link = document.createElement('a');
link.textContent = page.label;
link.href = window.ApiClient.resolveUrl(page.href);
link.target = '_blank';
link.rel = 'noopener';
if (index) line.append(' · ');
line.append(link);
});
return line;
}
export function mountSystem(main, api, scope, { section, activity, counts = null, sections = DELIVERED_SECTIONS,
navigate = navigateTo }) {
const store = systemDrafts(api);
@@ -187,7 +171,6 @@ export function mountSystem(main, api, scope, { section, activity, counts = null
saveRow.append(status, save);
layout.append(saveRow);
main.append(nav, layout);
const legacyPages = legacyPagesLine();
const paintStatus = () => {
status.textContent = store.statusText();
@@ -213,8 +196,6 @@ export function mountSystem(main, api, scope, { section, activity, counts = null
// Every section is delivered (S12), and the router only names known sections (state.mjs).
const nodes = sections[next](layout, { api, activity, scope: sectionScope, drafts: store, counts });
layout.prepend(...nodes);
if (next === 'overview') main.append(legacyPages);
else legacyPages.remove();
paintStatus();
}
show(section);
-1
View File
@@ -575,7 +575,6 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
}
.more-menu-list[hidden] { display: none; }
.more-menu-list > button { justify-content: flex-start; text-align: left; width: 100%; white-space: nowrap; }
.legacy-pages { margin: 16px 0 0; color: var(--muted); /* D165 */ }
/* D164 (#524): the prompt page has four heading actions and titles of any length, which the concept never shows. From
the concept's sidebar breakpoint up the actions keep one row and the title takes what is left, clamped to two lines
+3 -2
View File
@@ -361,8 +361,9 @@ Seed: ${currentWorkflowData.seed || 'Unknown'}`;
});
</script>
</head>
<body class="bg-pm-primary text-pm font-sans overflow-hidden">
<div class="h-screen flex">
<body class="bg-pm-primary text-pm font-sans overflow-hidden" style="display: flex; flex-direction: column; height: 100vh;">
<div id="legacy-removal-notice" role="note" class="bg-pm-surface border-b border-pm px-4 py-2 text-pm" style="font-size: 13px;">This page will be removed in a future release. Its features are moving to the new workspace. <a href="workspace#/inspect" style="text-decoration: underline;">Open the workspace</a></div>
<div class="flex" style="flex: 1; min-height: 0;">
<!-- Main Image Area -->
<div id="image-area" class="flex-1 flex items-center justify-center p-3 bg-pm-primary relative border-2 border-dashed border-transparent transition-colors min-w-0">
<!-- Upload prompt overlay -->
+2 -6
View File
@@ -60,12 +60,8 @@
</div>
<div id="workspace-toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<div id="workspace-boot-failure" role="alert" hidden style="position: fixed; inset: 16px 16px auto; z-index: 1000; padding: 16px; border: 1px solid #888; border-radius: 8px; background: #1b1b1f; color: #eee; font: 14px/1.5 system-ui, sans-serif;">
<p style="margin: 0;">The workspace couldn't load. Reload the page, or use a legacy page:
<a style="color: inherit;" href="admin">Dashboard</a> ·
<a style="color: inherit;" href="admin#/tags">Tags</a> ·
<a style="color: inherit;" href="gallery">Gallery</a> ·
<a style="color: inherit;" href="gallery.html">Metadata viewer</a></p>
<p style="margin: 0;">The workspace couldn't load. <a style="color: inherit;" href="workspace">Reload the page</a>, or go back to <a style="color: inherit;" href="../">ComfyUI</a>.</p>
</div>
<noscript><p>This workspace needs JavaScript. <a href="admin">Open the existing dashboard</a>.</p></noscript>
<noscript><p>This workspace needs JavaScript.</p></noscript>
</body>
</html>