Compare commits

...
Author SHA1 Message Date
Vito Sansevero 3c4d3b733b Merge remote-tracking branch 'origin/main' into feat/330-s13-pr3 2026-10-28 06:01:30 -07:00
Vito Sansevero 7f6bac5392 Merge remote-tracking branch 'origin/main' into feat/330-s13-pr3 2026-10-28 05:05:10 -07:00
Vito Sansevero eb6b4284f0 feat(gallery): remove Default View Mode from Page options (#330)
System > Appearance's Default layout is the one default-layout control, as
the S13 design's G5 says. The legacy gallery's Page options no longer show
or save a default view and Reset no longer resets it. A stored legacy
defaultViewMode still picks the view the gallery opens in (grid otherwise),
and Save and Reset keep that key for the workspace-preferences migration.
2026-10-28 04:17:38 -07:00
Vito Sansevero 9e3c7bedd9 Merge branch 'feat/330-s13-pr2' into feat/330-s13-pr3
# Conflicts:
#	tests/browser/legacy-system-links.spec.mjs
2026-10-27 21:58:05 -07:00
Vito Sansevero 0e5fa52e76 Merge remote-tracking branch 'origin/main' into feat/330-s13-pr2
# Conflicts:
#	TESTING.md
#	tests/browser/legacy-system-links.spec.mjs
#	web/admin.html
#	web/js/admin.js
2026-10-27 21:49:00 -07:00
Vito Sansevero d2da8fc7f5 fix(gallery): let the header buttons wrap on a narrow screen
With Page options beside Settings the header no longer fits a phone's width;
it wrapped off the page and covered the Page options dialog in the touch runs.
2026-10-27 20:57:43 -07:00
Vito Sansevero f78716fc5d test(legacy): a lazy thumbnail load is not a System link's request 2026-10-27 19:58:41 -07:00
Vito Sansevero dfa4b3355f test(legacy): check each System link sends no request in its own test
The no-request check runs inside each link's test, so a slow page no longer
times out the whole table, and it ignores lazy thumbnail loads the layout
starts on its own.
2026-10-27 19:58:41 -07:00
Vito Sansevero 727a8dfd3a fix(gallery): keep the default view in Page options and save viewer choices alone
System › Appearance's Default layout is the workspace's own key, so the legacy
gallery's Default View Mode stays a page option instead of losing its only
control. The viewer's mute and loop buttons remember just that choice instead of
saving the Page options dialog's unfilled inputs over the stored page options.
The pending-thumbnails toast names System › Sources & scans.
2026-10-27 19:51:29 -07:00
Vito Sansevero bd5682e908 docs(system): name System as the home for settings and maintenance
README and TESTING.md send settings, maintenance, backups, diagnostics, the
gallery's tools and auto-tag model downloads to their System section and name
each legacy page's Page options.
2026-10-27 19:34:31 -07:00
Vito Sansevero e2d7cb1719 refactor(autotag): manage tagging models in System
In both auto-tag dialogs a model that is not downloaded shows a Manage in
System link to System › Auto-tagging (a new tab) instead of a Download button,
and the gallery's Unload Model becomes the same link. The legacy download
dialog and the download, cancel and unload methods go (rows B29–B31); the batch
and review stay. A test hook marks a fake model loaded.
2026-10-27 19:33:21 -07:00
Vito Sansevero 6527b6fe1d refactor(gallery): open System for settings and maintenance
The gallery's Settings opens System › Appearance in a new tab. Its settings
dialog becomes Page options (a new header button) with only the page's own
settings: thumbnail startup check, lazy loading, items per page, grid columns,
infinite scrolling, debug mode and API timeout. Save and Reset keep every other
stored key, so the Appearance values System saves survive (S13-13). Generate
Thumbnails, Clear cache, Rescan and Find duplicates leave the page; Page options
links System › Maintenance and Sources & scans (rows B21–B28).
2026-10-27 19:28:49 -07:00
Vito Sansevero 97e8b9b680 test(admin): open Page options after the saved default is read
Drops the leftover LoraManager section comment above the Page options save.
2026-10-27 19:09:21 -07:00
Vito Sansevero 9909bc5faa Merge branch 'feat/330-s13-pr1' into feat/330-s13-pr2 2026-10-27 19:08:01 -07:00
Vito Sansevero 8800defb98 test(legacy): find a removed modal id passed to showModal and settle the links spec
The removal check also finds an id passed as a string to a ...Modal() call and no
longer reads data-id= as id=. The no-request test waits for the page's own
start-up reads to finish, the sort check compares as the server sorts, and the
kept-flow test checks the added prompt shows in the list.
2026-10-27 19:07:44 -07:00
Vito Sansevero 341b813fd7 refactor(admin): open System for backup and restore
Backup DB and Restore DB open System › Backup & export in a new tab, where a
restore is staged, reviewed and run as an Activity job. The one-click Restore
dialog and its methods go (rows B19–B20); the scan dialog's quick backup stays.
The removed browser tests' data checks live on at the route: a new round trip
test restores every prompt with its tags and image links.
2026-10-27 18:49:59 -07:00
Vito Sansevero dae6b3f784 refactor(admin): open System for maintenance
Maintenance opens System › Maintenance in a new tab, where each operation runs
as a reviewed Activity job. The one-click legacy Database Maintenance dialog
and its methods go (rows B15–B18); formatFileSize stays for the image modal.
2026-10-27 18:45:10 -07:00
Vito Sansevero ba2803dbbf refactor(admin): open System for settings and keep page options
Settings opens System › Overview in a new tab. The settings dialog becomes Page
options (opened from the list header) with only the Infinite Scrolling default,
and its Save posts {"infinite_scroll": …} alone, so a stale legacy form can no
longer overwrite scan folders or other values edited in System (#330 S13-9).
Scan folders, monitoring status, auto-hide, display mode, worker threads,
LoraManager and the LoRA import leave the page (rows B8–B14).
2026-10-27 18:44:30 -07:00
Vito Sansevero 0abdcbc814 refactor(admin): open System for Diagnostics and logs
The admin page's Diagnostics and View Logs buttons become links that open
System › Diagnostics & logs in a new tab. The legacy Diagnostics and System Logs
dialogs and their methods go; TESTING.md lists them against their covered
capabilities (rows B1–B7).
2026-10-27 18:41:38 -07:00
Vito Sansevero c0d27772b4 test(legacy): prove each removed legacy control has native coverage
TESTING.md gains the "Legacy System UI removed by S13" table (no rows yet).
legacy-coverage.test.mjs fails when a removal cites a capability that is not
covered, copies another native test, lists an id or method twice, or leaves
the id or method on its page. legacy-system-links.spec.mjs holds the System
link table and guards the kept admin flows.
2026-10-27 18:18:18 -07:00
Vito Sansevero b26ae112e6 test(legacy): snapshot the routes and the settings response
Pins every registered route and the key paths of GET /prompt_manager/settings
while System S13 (#330) moves the legacy pages' tools to native System. Recorded
on the unchanged main; cutover (#334) removes it.
2026-10-27 18:13:52 -07:00
13 changed files with 384 additions and 1539 deletions
+29 -38
View File
@@ -56,7 +56,7 @@ Both nodes include the complete PromptManager feature set:
- **📊 Analytics**: Track prompt usage patterns and effectiveness over time
- **🔬 Workflow Analysis**: Extract and analyze ComfyUI workflow data from PNG images
- **📋 Metadata Viewer**: Standalone tool for analyzing ComfyUI-generated images
- **🛠️ System Management**: Built-in diagnostics, backup/restore, and maintenance tools
- **🛠️ System Management**: Built-in diagnostics, backup/restore, and maintenance tools in System
- **🏷️ AI AutoTag**: Automatically tag your image collection using WD14 or JoyCaption vision models
- **🔗 LoRA Manager Integration**: Import LoRA metadata and preview images from [ComfyUI-Lora-Manager](https://github.com/willmiao/ComfyUI-Lora-Manager)
@@ -206,14 +206,11 @@ match Classic and Focused.
- **Responsive Design**: Works on desktop, tablet, and mobile devices
- **Real-time Search**: Instant search results as you type
- **Bulk Operations**: Edit multiple prompts simultaneously with batch tools
- **Settings Panel**: Configure behavior and display options
- **System Management**: Complete toolset for database operations:
- **Settings and System tools live in System**: the dashboard's **Settings** opens System › Overview, **Maintenance** opens System › Maintenance, **Backup DB** and **Restore DB** open System › Backup & export, and **Diagnostics** and **View Logs** open System › Diagnostics & logs, each in a new tab. System reviews every destructive operation before it runs it as an Activity job.
- **Page options**: the list header's **Page options** keeps the prompt list's Infinite Scrolling default
- **Dashboard tools**:
- **🔍 Metadata Viewer**: Launch standalone PNG analysis tool
- **📸 Scan Images**: Bulk scan output directories for ComfyUI images
- **💾 Backup/Restore**: Database backup and recovery operations
- **🔧 Maintenance**: Database optimization and cleanup tools including orphaned prompt removal
- **📋 Logs**: Comprehensive logging system with download capability
- **🔍 Diagnostics**: System health checks and troubleshooting
- **📸 Scan Images**: Bulk scan output directories for ComfyUI images (with a quick backup first)
- **Export Tools**: Download prompts and metadata in various formats
- **Progress Tracking**: Real-time progress indicators for long operations
- **Error Handling**: Graceful error display with recovery suggestions
@@ -333,14 +330,11 @@ The comprehensive gallery system provides complete access to all your ComfyUI ou
#### **⚙️ Gallery Settings**
Configure your viewing experience in the settings panel:
The gallery's **Settings** opens System › Appearance in a new tab: image quality, details on cards, collapsed viewer details, video autoplay/mute/loop, metadata loading and file paths are set there and apply to this page too.
- **Display Settings**: Default view, items per page, grid columns, media info, infinite scrolling
- **Performance Settings**: Image quality, lazy loading
- **Video Settings**: Autoplay, mute by default, loop videos
- **Thumbnail Management**: Generate optimized thumbnails for faster loading (runs on the configured worker threads; a run that stops at the per-run cap tells you how many remain)
- **Find Duplicate Images**: Hashes every file on the worker threads with a progress bar; close the window and it keeps running, results wait in a pill
- **Rescan Output Folder**: Drops the server-side listing cache and rewalks the folders
The gallery's **Page options** keep the page's own settings: the thumbnail check at startup, lazy loading, items per page, grid columns, infinite scrolling, debug mode and API timeout. Reset puts back only these.
Thumbnail generation, the server thumbnail and browser cache clears, refreshing the file listing and finding duplicate images are in System › Sources & scans and System › Maintenance (Page options links both); each runs as an Activity job you can follow after closing the page.
#### **🖼️ Image Viewing Experience**
@@ -398,7 +392,7 @@ Import existing ComfyUI images into your database:
- Any errors or issues encountered
5. **Access imported data** through the normal gallery interface
The scan reads PNG text chunks and A1111-style parameters, and, when `ffprobe` is installed, the prompt ComfyUI's video savers embed in MP4/MOV/WebM files. Files are read on the number of **worker threads** set in Settings → Performance (default: half your cores), which makes a large library several times faster.
The scan reads PNG text chunks and A1111-style parameters, and, when `ffprobe` is installed, the prompt ComfyUI's video savers embed in MP4/MOV/WebM files. Files are read on the number of **worker threads** set in System › Sources & scans (default: half your cores), which makes a large library several times faster.
### 🏷️ AI AutoTag
@@ -414,7 +408,7 @@ Automatically tag your entire image collection using AI vision models:
1. **AutoTag (Batch Mode)**: Tag your entire collection automatically
- Click **"🏷️ AutoTag"** in the admin dashboard
- Choose your model type (FP16 or GGUF)
- Choose your model type (FP16 or GGUF). A model that is not downloaded yet shows **Manage in System**: download (or unload) it in System › Auto-tagging, then reopen the dialog
- Select how to handle already-tagged images:
- **Skip images with existing tags**: Preserve your meticulous manual tagging work
- **Re-tag all images**: Overwrite existing tags with fresh AI analysis
@@ -445,7 +439,7 @@ Adjust the system prompt to match your tagging style:
#### **Requirements**
- Sufficient VRAM for the chosen model (FP16 requires more, GGUF is lighter)
- Models are downloaded automatically on first use
- Models are downloaded in System › Auto-tagging
- Feature requests and improvements are welcome!
### 📁 Folder Filter
@@ -470,18 +464,18 @@ If you use [ComfyUI-Lora-Manager](https://github.com/willmiao/ComfyUI-Lora-Manag
#### **Enabling the Integration**
1. Open the admin dashboard and click **Settings**
2. Scroll to the **Integrations** section — PromptManager will auto-detect if LoRA Manager is installed
1. Open System › Integrations (the admin dashboard's **Settings** opens System)
2. PromptManager auto-detects whether LoRA Manager is installed
![LoRA Enabled](images/pm-lora-enabled.png)
3. Toggle the **LoraManager** switch to enable
4. Optionally enable **Auto-inject trigger words** to automatically append LoRA trigger words when `<lora:name:weight>` is detected in your prompts
5. Click **Save Settings** (requires a ComfyUI restart to take effect)
5. Click **Save preferences** (requires a ComfyUI restart to take effect)
#### **Importing LoRA Data**
Click **Import LoRA Data** in the settings panel to start the import. A progress popup shows real-time status as each LoRA is processed:
Click **Review import** in System › Integrations; Start runs the import as an Activity job:
- Reads LoRA Manager's own settings the way LoRA Manager does: from the folder in `LORA_MANAGER_SETTINGS_DIR`, from the plugin's `settings.json` in portable mode, or from your user config folder, and only the keys that say where its files are
- Finds each LoRA's metadata in both storage modes: beside the model (the default) or in the central sidecar folder, and imports only LoRAs whose model file still exists in a LoRA folder (the review says how many metadata files it skipped)
@@ -502,7 +496,7 @@ A CivitAI API key is **required if your library contains NSFW LoRAs** — CivitA
To add your key:
1. Get your API key from [civitai.com/user/account](https://civitai.com/user/account)
2. Paste it into the **CivitAI API Key** field in the Integrations settings
2. Paste it into the **CivitAI API key** field in System › Integrations
3. Save and re-import to download any previously skipped images
The key is sent only over HTTPS to CivitAI's own hosts. CivitAI serves images through a redirect to its storage host; PromptManager follows such a redirect only over HTTPS to a host on CivitAI's own `civitai.com`, `civitai.red` or `civitai.green` domains, at most three hops, and never sends the key with it. When downloads fail, the import's status line says how many; when none could be downloaded, it starts with a warning. The server log has one warning per failed image with the reason (HTTP status, refused redirect, size cap, unreadable image, timeout or network error).
@@ -517,23 +511,20 @@ The comprehensive web interface provides:
- **Filter Options**: Filter by category, tags, rating, and date
- **Bulk Operations**: Select multiple prompts for batch editing
- **Export Tools**: Download your prompt collection
- **Settings Panel**: Configure auto-save and display options
- **Diagnostics**: System health checks and troubleshooting
- **Settings**: System (new tab) for settings, maintenance, backups and diagnostics; **Page options** for the page's own display settings
### 🔧 Database Maintenance
Keep your database optimized with built-in maintenance tools:
Keep your database optimized in System › Maintenance (the admin dashboard's **Maintenance** opens it in a new tab):
1. **Open the admin dashboard** and click **"🔧 Maintenance"**
2. **Select maintenance operations**:
- **Remove Duplicates**: Find and merge duplicate prompts while preserving images
- **Vacuum Database**: Optimize database file size and performance
- **Clean Orphaned Images**: Remove image records for missing files
- **🚮 Prune Orphaned Prompts**: Remove prompts that have no linked images
- **Check Consistency**: Verify database integrity and relationships
- **Database Statistics**: Generate detailed database reports
3. **Run selected operations** and monitor progress in real-time
4. **Review results** showing detailed information about each operation
- **Duplicate prompt text**: merge duplicate prompts while preserving images
- **Optimize database**: reclaim unused SQLite space
- **Missing-file links**: remove image records for missing files
- **Unused prompt components**: remove prompts nothing uses
- **Run health checks**: verify database integrity and relationships (the Health report)
- **Database size and prompt counts**: System › Diagnostics & logs (Database) and the Overview's Statistics
Each operation opens a review that shows what it will change and the safety backup it takes, then runs as an Activity job.
### Database Location
@@ -835,8 +826,8 @@ Before upgrading or changing this setting:
1. Identify the database your current installation actually uses and confirm its
library contents. Check both the configured path and the prior fallback
`<extension>/prompts.db`; keep track of which existing file you intend to use.
2. Preserve a consistent backup of the active database with the dashboard's
Backup/Restore backup action or SQLite's online backup API. Do not copy only
2. Preserve a consistent backup of the active database with
System › Backup & export's Back up or SQLite's online backup API. Do not copy only
the main file of a live SQLite database: committed data may still be in its
`-wal` file. For a filesystem copy, stop ComfyUI cleanly first and preserve any
remaining `-wal` file together with the main database.
+36 -11
View File
@@ -2534,6 +2534,14 @@ production hub `#/overview` is unchanged (D28); its System card opens
the pressed section button and disposes the previous section's scope, so its reads,
listeners and dialogs never outlive it.
System is the home for settings and maintenance (#330 System S13). The legacy pages keep
their prompt list, tags, Scan Images, auto-tag batch and review and gallery browsing; their
Settings, Diagnostics, View Logs, Maintenance, Backup DB, Restore DB, the gallery's
thumbnail, cache, rescan and duplicate tools and the auto-tag model downloads open the
System section that owns them in a new tab (`legacy-system-links.spec.mjs`). Each page
keeps only its own settings in Page options. What left the legacy pages is listed in
"Legacy System UI removed by S13".
Drafts (`system-state.mjs`) live for the page session (Q10). Save writes the dirty
browser fields, then the host fields (`POST /prompt_manager/settings` with
`expected_revision`, #220), then the runtime fields. A failed domain keeps its drafts and
@@ -4148,6 +4156,9 @@ auto-tag / review", which the Images selection bar owns, not a System section. A
is not `covered` names none (`—`). Test is the exact title of a test that runs, then its
file. Anchors are line numbers on `main` at this PR.
S13 (#330) removed the legacy copy of the `covered` rows the legacy pages linked to System;
"Legacy System UI removed by S13" below lists every removed element against its row.
`tests/vendor/legacy-coverage.test.mjs` fails on a row with any of these problems:
- no owner, or a status outside the three;
- a malformed Native control cell;
@@ -4228,7 +4239,11 @@ opens `workspace#/system/<id>` in a new tab) and removes the legacy copy of a ca
only when its row above is `covered`. One row per removed capability: Capability is the
exact Capability cell of its coverage row, Page is `admin` or `gallery`, Removed ids and
Removed methods list what went (`—` for none), and Native test copies that row's Test
cell. An id that stays on a System link is not listed, only its handler.
cell. An id that stays on a System link is not listed, only its handler. The gallery's
Default View Mode left Page options too: System › Appearance's Default layout is the one
default-layout control. Nothing writes the legacy `defaultViewMode` any more; a stored
value still picks the view the legacy gallery opens in (grid otherwise), and Page options
Save and Reset keep it.
`tests/vendor/legacy-coverage.test.mjs` fails when a row cites a capability that is not
`covered` or copies another test, when an id or method is listed twice for one page, or
@@ -4260,6 +4275,18 @@ legacy page workflows), not by this check.
| Maintenance select all / clear all / run | admin | `#selectAllMaintenanceBtn`, `#clearAllMaintenanceBtn` | `selectAllMaintenance()`, `clearAllMaintenance()` | plan dialog shows the server's scope, protected, backup policy and media lines with the backup box (D100); Cancel writes nothing (workspace-system-maintenance.spec.mjs) |
| Database backup | admin | — | `backupDatabase()` | Back up downloads through the api client and revokes the object URL (D103) (workspace-system-backups.spec.mjs) |
| Restore from file | admin | `#restoreModal`, `#restoreFileInput`, `#confirmRestore`, `#cancelRestore` | `showRestoreModal()`, `confirmRestore()` | Review restore stages the chosen file and shows the real summary with the backup box (D105) (workspace-system-backups.spec.mjs) |
| Gallery thumbnail generation | gallery | `#generateThumbnailsBtn`, `#thumbnailStatus`, `#thumbnailProgress`, `#progressText`, `#progressPercent`, `#cancelThumbnailsBtn`, `#progressBar`, `#progressDetails`, `#progressETA` | `generateThumbnails()`, `generateThumbnailsWithProgress()` | Start closes the dialog, toasts, and opens the job in Activity (workspace-system-sources.spec.mjs) |
| Default view mode, image quality | gallery | `#defaultViewModeSelect`, `#imageQualitySelect` | — | defaults apply to a new visit but not to the current view or a saved view (workspace-system.spec.mjs) |
| Image info on cards; sidebar collapsed | gallery | `#showImageInfoToggle`, `#sidebarCollapsedByDefaultToggle` | — | added rows reach their consumers (workspace-system.spec.mjs) |
| Video autoplay, mute, loop | gallery | `#videoAutoplayToggle`, `#videoMuteToggle`, `#videoLoopToggle` | — | video defaults reach the viewer (workspace-system.spec.mjs) |
| Auto-load metadata, cache metadata, show file paths | gallery | `#autoLoadMetadataToggle`, `#cacheMetadataToggle`, `#showFilePathsToggle` | — | metadata off shows an explicit load and loads once on request (workspace-system.spec.mjs) |
| Browser cache clear | gallery | `#clearCacheBtn` | `clearCache()` | Metadata cache reviews and clears only owned browser entries when there are some (D102) (workspace-system-maintenance.spec.mjs) |
| Clear server thumbnails | gallery | — | — | Thumbnail cache opens the S5 dialog (workspace-system-maintenance.spec.mjs) |
| Refresh file listing | gallery | `#rescanFolderBtn` | `rescanFolder()` | Refresh files calls /gallery/rescan and toasts its counts (workspace-system-sources.spec.mjs) |
| Duplicate image scan, review, delete | gallery | `#scanDuplicatesBtn`, `#dupScanPill`, `#dupScanPillText`, `#duplicatesModal`, `#closeDuplicatesBtn`, `#duplicatesScanStatus`, `#dupProgressText`, `#dupProgressBar`, `#duplicatesContent`, `#duplicatesList`, `#duplicatesFooter`, `#duplicatesCount`, `#selectAllBtn`, `#removeDuplicatesBtn` | `scanDuplicates()`, `attachDuplicateStream()`, `resumeDuplicateScan()`, `applyDuplicateEvent()`, `renderDuplicateProgress()`, `setDuplicateButton()`, `showDuplicatePill()`, `hideDuplicatePill()`, `openDuplicatesModal()`, `showDuplicatesModal()`, `hideDuplicatesModal()`, `displayDuplicates()`, `selectAllDuplicates()`, `removeDuplicates()` | Find duplicates starts a job; its view links Review groups; Keep radios build a duplicate_files plan (D101) (workspace-system-maintenance.spec.mjs) |
| Auto-tag model download | admin | `#autoTagDownloadModal`, `#downloadModelName`, `#downloadStatus`, `#downloadProgressPercent`, `#downloadProgressBar`, `#cancelDownloadBtn` | `downloadModel()`, `cancelDownload()` | Download starts the job and Activity shows it without Cancel (workspace-system-tagging.spec.mjs) |
| Auto-tag model download | gallery | `#autoTagDownloadModal`, `#downloadModelName`, `#downloadStatus`, `#downloadProgressPercent`, `#downloadProgressBar`, `#cancelDownloadBtn` | `downloadModel()`, `cancelDownload()` | Download starts the job and Activity shows it without Cancel (workspace-system-tagging.spec.mjs) |
| Unload model | gallery | — | `unloadModel()` | Unload is enabled only while a model is loaded, and a held gate's refusal names the operation (workspace-system-tagging.spec.mjs) |
### Browser cache ownership (#222)
@@ -4278,17 +4305,15 @@ page writes a key that is neither a preference nor inventoried, writes one it ca
CacheStorage or IndexedDB; `the storage scan reports every way of writing browser
storage` pins those patterns. It skips `web/lib` (third-party builds) and the pinned
Toolkit build (`web/js/shared/toolkit`, never hand-edited, verified by `npm run check`;
its storage belongs to the Toolkit repository and a new pin is reviewed on its own). The
gallery's server thumbnail clear stays its own request and runs first: refused or
unreachable, it changes nothing and says so (the old code toasted success when the
request failed); refused browser storage is reported, never hidden
(`tests/js/legacy-refusals.test.js`; the browser case `the gallery cache clear keeps
preferences and other tools caches` in `workflows.spec.mjs`).
its storage belongs to the Toolkit repository and a new pin is reviewed on its own). Since
System S13 (#330) the legacy gallery no longer offers Clear cache: System › Maintenance's
Metadata cache and Thumbnail cache reviews own both parts (removal table above), and
`cache-ownership.js` serves the native review.
The legacy duplicates dialog's View Full now names its source (`?root=<index>`, with the
path URL-encoded) when the gallery's output folder is a listed source; before, the serve
route opened the first source holding a file at that path
(`test_view_full_names_the_gallery_folder_source`).
The duplicates dialog's View Full named its source (`?root=<index>`, with the path
URL-encoded) when the gallery's output folder is a listed source
(`test_view_full_names_the_gallery_folder_source`); S13 removed the legacy dialog, and the
route keeps that behaviour for System › Maintenance's duplicate review.
### Library location contract (#249 System S6b)
+3 -79
View File
@@ -1,11 +1,9 @@
// #328: the legacy admin and gallery auto-tag tools keep working beside System › Auto-tagging. The model download is
// the `model_download` Activity job; the pages' stream attaches to it. No model is ever downloaded: the fixture's
// fake download waits until the test releases it (`/__test__/tagging/download`).
// #328: the legacy admin and gallery auto-tag batch and review keep working beside System › Auto-tagging. Since #330
// the pages download and unload models only through System (legacy-system-links.spec.mjs); a batch never downloads
// one. The fixture's fake download (`/__test__/tagging/download`) is held so nothing is ever fetched.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
const TITLE = 'Tagging model download · WD14 ViT';
async function post(request, url, data) {
await expect(await request.post(url, data === undefined ? {} : { data })).toBeOK();
}
@@ -33,61 +31,6 @@ test.afterEach(async ({ page, request, browserName }) => {
await post(request, '/__test__/jobs/restart');
});
async function startDownload(page, request) {
await page.locator('#autoTagBtn').click();
const status = page.locator('#wd14VitModelStatus');
await status.getByRole('button', { name: 'Download', exact: true }).click();
const modal = page.locator('#autoTagDownloadModal');
await expect(modal).toBeVisible();
await expect(page.locator('#downloadStatus')).toHaveText('Downloading…');
await expect(page.locator('#downloadProgressBar')).toHaveClass(/animate-pulse/);
await expect(modal).toContainText('The download continues in Activity.');
await expect(page.locator('#cancelDownloadBtn')).toHaveText('Close');
const running = (await downloads(request)).filter(job => job.state === 'running');
expect(running.map(job => [job.title, job.cancellable])).toEqual([[TITLE, false]]);
return { modal, status };
}
test('admin download modal closes on complete and Activity lists the job without Cancel', async ({ page, request }) => {
await page.goto('/prompt_manager/admin');
const { modal, status } = await startDownload(page, request);
await page.goto('/prompt_manager/workspace#/activity');
const item = page.locator('#workspace-main section.surface > .activity-item')
.filter({ has: page.getByRole('link', { name: new RegExp(`${TITLE}$`) }) });
await expect(item.locator('.chip')).toHaveText('Running');
await expect(item.locator('progress')).not.toHaveAttribute('value');
await expect(item.getByRole('button', { name: 'Cancel job', exact: true })).toHaveCount(0);
// Back on the admin page a second download of the same model attaches to the running job.
await page.goto('/prompt_manager/admin');
await startDownload(page, request);
await post(request, '/__test__/tagging/download', { mode: 'succeed' });
await expect(modal).toBeHidden();
await expect(status).toContainText('Downloaded');
expect((await downloads(request)).map(job => [job.title, job.state])).toEqual([[TITLE, 'finished']]);
});
test('gallery download modal closes on complete', async ({ page, request }) => {
await page.goto('/prompt_manager/gallery');
const { modal, status } = await startDownload(page, request);
await post(request, '/__test__/tagging/download', { mode: 'succeed' });
await expect(modal).toBeHidden();
await expect(page.locator('#autoTagModal')).toBeVisible();
await expect(status).toContainText('Downloaded');
expect((await downloads(request)).map(job => job.state)).toEqual(['finished']);
});
test('a failed download closes the modal and says so', async ({ page, request }) => {
await page.goto('/prompt_manager/gallery');
const { modal, status } = await startDownload(page, request);
await post(request, '/__test__/tagging/download', { mode: 'fail' });
await expect(modal).toBeHidden();
await expect(page.getByText('Download failed: Download failed')).toBeVisible();
await expect(status.getByRole('button', { name: 'Download', exact: true })).toBeVisible();
expect((await downloads(request)).map(job => job.state)).toEqual(['failed']);
});
// (wd14-vit is not downloaded in these tests: beforeEach marks it so the download tests can offer it.)
// The gallery's batch reader keyed on `data.status` values the server never sends, so its modal never moved or
// closed (#328 Problem 5). It now reads `type` frames as the admin page does.
@@ -171,25 +114,6 @@ test('admin review opens the first image', async ({ page }) => {
await expect(page.getByText('Failed to start review mode')).toHaveCount(0);
});
test('gallery unload works and is refused while the gate is held', async ({ page, request }) => {
await page.goto('/prompt_manager/gallery');
await runGalleryBatch(page); // keeps the model in memory
await page.locator('#autoTagBtn').click();
const unload = page.locator('#unloadModelBtn');
await expect(unload).toBeVisible();
// A held download owns the model gate: Unload is refused with the server's error.
const held = await request.post('/prompt_manager/jobs/model-download', { data: { model_type: 'gguf' } });
expect(held.status()).toBe(202);
await unload.click();
await expect(page.getByText(/^Failed to unload model: A tagging model is busy/)).toBeVisible();
await post(request, '/__test__/tagging/download', { mode: 'succeed' });
await expect.poll(async () => (await downloads(request)).map(job => job.state)).toEqual(['finished']);
await unload.click();
await expect(page.getByText('WD14-SWINV2 model unloaded successfully')).toBeVisible();
});
/** The admin review lists every row, videos too: Skip any image that is not one until the confirmation shows. */
async function untilAutoTagged(page, confirm) {
// Each image's skip check answers before its confirmation or its error shows.
+109 -19
View File
@@ -15,6 +15,22 @@ const LABELS = Object.freeze({
diagnostics: 'Diagnostics & logs',
});
async function openPageOptions(page) {
await page.locator('#pageOptionsBtn').click();
await expect(page.locator('#settingsModal')).toBeVisible();
}
/** The auto-tag modal with its model rows read from /autotag/models. */
async function openAutoTag(page) {
const read = page.waitForResponse(item => item.url().endsWith('/prompt_manager/autotag/models'));
await page.locator('#autoTagBtn').click();
await read;
await expect(page.locator('#autoTagModal')).toBeVisible();
}
const notDownloaded = (page, request) => post(request, '/__test__/tagging/models', { unavailable: ['gguf'] });
const modelLoaded = (page, request) => post(request, '/__test__/tagging/loaded', { model: 'wd14-vit' });
// One row per System link of the design's §2 map: the page, the control's label, how to find it, the section it opens
// and what a test must arrange before the control shows.
const LINKS = Object.freeze([
@@ -22,6 +38,17 @@ const LINKS = Object.freeze([
{ page: 'admin', label: 'Maintenance', locate: page => page.locator('#maintenanceBtn'), section: 'maintenance' },
{ page: 'admin', label: 'Backup DB', locate: page => page.locator('#backupBtn'), section: 'backups' },
{ page: 'admin', label: 'Restore DB', locate: page => page.locator('#restoreBtn'), section: 'backups' },
{ page: 'gallery', label: 'Settings', locate: page => page.locator('#settingsBtn'), section: 'appearance' },
{ page: 'admin', label: 'Manage in System (model row)', arrange: notDownloaded, reveal: openAutoTag,
locate: page => page.locator('#ggufModelStatus a'), section: 'tagging' },
{ page: 'gallery', label: 'Manage in System (model row)', arrange: notDownloaded, reveal: openAutoTag,
locate: page => page.locator('#ggufModelStatus a'), section: 'tagging' },
{ page: 'gallery', label: 'Manage in System (Unload Model)', arrange: modelLoaded, reveal: openAutoTag,
locate: page => page.locator('#unloadModelBtn'), section: 'tagging' },
{ page: 'gallery', label: 'Page options Maintenance', reveal: openPageOptions,
locate: page => page.locator('#pageOptionsMaintenanceLink'), section: 'maintenance' },
{ page: 'gallery', label: 'Page options Sources & scans', reveal: openPageOptions,
locate: page => page.locator('#pageOptionsSourcesLink'), section: 'sources' },
{ page: 'admin', label: 'Diagnostics', locate: page => page.locator('#diagnosticsBtn'), section: 'diagnostics' },
{ page: 'admin', label: 'View Logs', locate: page => page.locator('#logsBtn'), section: 'diagnostics' },
]);
@@ -58,17 +85,31 @@ async function openLink(page, request, link) {
return control;
}
// Each row opens its section in a new tab and sends no request from the legacy page (the design's `a System link sends
// no request from the legacy page`, checked per row so one slow page never times out the whole table).
for (const link of LINKS) {
test(`legacy ${link.page} ${link.label} opens System ${link.section}`, async ({ page, context, request }) => {
// The gallery's thumbnail startup check (K9) reads its status on a timer of its own; it is off here.
await page.addInitScript(() => {
localStorage.setItem('gallerySettings', JSON.stringify({ checkThumbnailsAtStartup: false }));
});
const control = await openLink(page, request, link);
const legacyUrl = page.url();
await expect(control).toHaveAttribute('href', `workspace#/system/${link.section}`);
await expect(control).toHaveAttribute('target', '_blank');
await expect(control).toHaveAttribute('rel', 'noopener');
// The page's own start-up reads (list, film strips, settings) finish first; then the click must add none.
// Lazy thumbnails may still load whenever the layout changes; they are not the click's.
await page.waitForLoadState('networkidle');
const sent = [];
const record = item => { if (item.resourceType() !== 'image') sent.push(`${item.method()} ${item.url()}`); };
page.on('request', record);
const opened = context.waitForEvent('page');
await control.click();
const system = await opened;
await expect(system).toHaveURL(new RegExp(`/prompt_manager/workspace#/system/${link.section}$`));
page.off('request', record);
expect(sent, 'a System link sends no request from the legacy page').toEqual([]);
// System's page heading is "System"; the open section is the pressed button of its section switcher.
const main = system.locator('#workspace-main');
await expect(main.getByRole('heading', { level: 1, name: 'System', exact: true })).toBeVisible();
@@ -79,25 +120,6 @@ for (const link of LINKS) {
});
}
test('a System link sends no request from the legacy page', async ({ page, context, request }) => {
for (const link of LINKS) {
const control = await openLink(page, request, link);
// The page's own start-up reads (list, film strips, settings) finish first; then the click must add none.
await page.waitForLoadState('networkidle');
const sent = [];
// Lazy thumbnails may still load whenever the layout changes; they are not the click's.
const record = item => { if (item.resourceType() !== 'image') sent.push(`${item.method()} ${item.url()}`); };
page.on('request', record);
const opened = context.waitForEvent('page');
await control.click();
const system = await opened;
await system.waitForLoadState('domcontentloaded');
page.off('request', record);
expect(sent, `${link.page} ${link.label}`).toEqual([]);
await system.close();
}
});
test('kept admin flows still work', async ({ page, context }) => {
await page.goto(PAGES.admin);
const texts = page.locator('#resultsList .prompt-text');
@@ -194,3 +216,71 @@ test('Page options keeps the page-only settings admin', async ({ page, request }
await page.locator('#pageOptionsBtn').click();
await expect(page.locator('#infiniteScrollDefault')).toBeChecked();
});
test('Page options keeps the page-only settings gallery', async ({ page }) => {
// Native Appearance keys and the startup prompt's own keys are in the shared object before the page opens.
const stored = { imageQuality: 'high', showImageInfo: false, videoMute: false,
autoLoadMetadata: false, workspaceLayout: 'masonry', thumbnailPromptDismissed: 'sig', thumbnailsGenerated: true,
checkThumbnailsAtStartup: false, defaultViewMode: 'list' };
await page.addInitScript(value => {
if (!sessionStorage.getItem('seeded')) {
sessionStorage.setItem('seeded', '1');
localStorage.setItem('gallerySettings', value);
}
}, JSON.stringify(stored));
const listed = page.waitForResponse(item => item.url().includes('/prompt_manager/images/output?'));
await page.goto(PAGES.gallery);
await listed;
// With no Page options control, a stored default view still opens the page in it.
await expect(page.locator('#listViewBtn')).toHaveClass(/bg-pm-accent/);
await openPageOptions(page);
const dialog = page.locator('#settingsModal');
await expect(dialog.locator('h3')).toHaveText('Page options');
// Exactly the page-only settings (G5); the default view is System › Appearance's Default layout.
expect(await dialog.locator('input, select, textarea').evaluateAll(fields => fields.map(field => field.id))).toEqual([
'checkThumbnailsAtStartup', 'lazyLoadingToggle', 'defaultLimitSelect', 'gridColumnsSelect',
'infiniteScrollToggle', 'debugModeToggle', 'apiTimeoutInput']);
await expect(dialog.getByText('Default View Mode')).toHaveCount(0);
await page.locator('#defaultLimitSelect').selectOption('50');
await page.locator('#debugModeToggle').check();
await page.locator('#saveSettingsBtn').click();
await expect(dialog).toBeHidden();
const read = () => page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings')));
let saved = await read();
expect(saved).toMatchObject({ ...stored, defaultLimit: 50, debugMode: true });
// Reset puts back only the page options' defaults; the Appearance, startup-prompt and default-view keys stay.
await openPageOptions(page);
page.once('dialog', dialog => dialog.accept());
await page.locator('#resetSettingsBtn').click();
await expect(page.getByText('Page options reset to defaults')).toBeVisible();
await expect(page.locator('#defaultLimitSelect')).toHaveValue('100');
saved = await read();
expect(saved).toMatchObject({ defaultViewMode: 'list', imageQuality: 'high', showImageInfo: false, videoMute: false,
autoLoadMetadata: false, workspaceLayout: 'masonry', thumbnailPromptDismissed: 'sig', thumbnailsGenerated: true,
defaultLimit: 100, debugMode: false, checkThumbnailsAtStartup: true, lazyLoading: true, gridColumns: 8,
infiniteScroll: false, apiTimeout: 30 });
});
test('the legacy auto-tag modal stays open after Manage in System', async ({ page, context, request }) => {
for (const legacy of ['admin', 'gallery']) {
await post(request, '/__test__/tagging/models', { unavailable: ['gguf'] });
await page.goto(PAGES[legacy]);
await openAutoTag(page);
const link = page.locator('#ggufModelStatus').getByRole('link', { name: 'Manage in System', exact: true });
await expect(link).toHaveAttribute('title', 'Opens System › Auto-tagging in a new tab');
// A downloaded model keeps its status text.
await expect(page.locator('#wd14VitModelStatus')).toHaveText('\u2713 Downloaded');
const opened = context.waitForEvent('page');
await link.click();
const system = await opened;
await expect(system).toHaveURL(/#\/system\/tagging$/);
await expect(page.locator('#autoTagModal')).toBeVisible();
await system.close();
// Downloaded in System meanwhile: reopening the modal reads the models again (CUJ 4).
await post(request, '/__test__/tagging/models', { unavailable: [] });
await page.goto(PAGES[legacy]);
await openAutoTag(page);
await expect(page.locator('#ggufModelStatus')).toHaveText('\u2713 Downloaded');
}
});
+1 -123
View File
@@ -104,33 +104,6 @@ for (const leave of ['beforeunload', 'pagehide']) {
});
}
// #222: the gallery's Clear cache clears the server's thumbnails by its own request and keeps this browser's
// preferences and other tools' storage; nothing of Prompt Manager's is cached in a browser, and the toast says so.
test('the gallery cache clear keeps preferences and other tools caches', async ({ page }) => {
await page.addInitScript(() => {
if (sessionStorage.getItem('seeded')) return;
sessionStorage.setItem('seeded', '1');
localStorage.setItem('pm_workspace_theme', 'light');
localStorage.setItem('other-tool-setting', 'keep');
localStorage.setItem('pm_other_tool', 'keep');
});
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
await page.evaluate(async () => { await (await caches.open('other-tool-gallery-thumbnails')).put('/x', new Response('x')); });
const clears = [];
page.on('request', req => { if (req.url().includes('/clear-thumbnails')) clears.push(req.method()); });
page.once('dialog', dialog => dialog.accept());
await page.locator('#settingsBtn').click();
await page.locator('#clearCacheBtn').click();
await expect(page.getByText("Thumbnails cleared; original images untouched. Nothing of Prompt Manager's is cached in this browser."))
.toBeVisible();
expect(clears).toEqual(['POST']);
expect(await page.evaluate(async () => ({
theme: localStorage.getItem('pm_workspace_theme'), other: localStorage.getItem('other-tool-setting'),
prefixed: localStorage.getItem('pm_other_tool'), notice: localStorage.getItem('pm_update_notice_dismissed'),
caches: await caches.keys(),
}))).toEqual({ theme: 'light', other: 'keep', prefixed: 'keep', notice: '3.2.0', caches: ['other-tool-gallery-thumbnails'] });
});
// #379: the reviewed tags are that image's own tags; no prompt changes.
test('actual gallery review generates and persists tags for the selected root\'s image', async ({ page, request }) => {
@@ -226,7 +199,7 @@ test('gallery infinite scroll appends a real backend page while retaining existi
await expect(await request.post('/__test__/reset', { data: { extra_images: 108 } })).toBeOK();
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(100);
await page.locator('#settingsBtn').click();
await page.locator('#pageOptionsBtn').click();
await page.locator('#infiniteScrollToggle').check();
await page.locator('#saveSettingsBtn').click();
await expect(page.locator('#settingsModal')).toBeHidden();
@@ -288,98 +261,3 @@ test('prompt deletion requires confirmation and can retry after a network failur
await expect(await request.get(url)).toBeOK();
}
});
// The duplicate scan reads the output root only, but the fixture's input root holds a different
// shared/same.png. A bare /images/serve/<path> URL tries every gallery root in turn, so once the
// output copy is deleted it answers 200 with the input copy: check each file in its own root.
const serveIn = (root, path) => `/prompt_manager/images/serve/${path.split(/[\\/]/).map(encodeURIComponent).join('/')}?root=${root}`;
const rootOf = url => new URL(url, 'http://gallery.test').searchParams.get('root');
// Which copy the scan keeps depends on file times: the fixture stamps shared/same.png with the
// wall clock, and the kernel can stamp its bulk copies a fraction of a millisecond earlier.
// listOrder rewrites the finished scan's groups so a test can pick the order it needs.
async function openDuplicateScan(page, request, listOrder = null) {
await expect(await request.post('/__test__/reset', { data: { extra_images: 2 } })).toBeOK();
if (listOrder) {
await page.route('**/prompt_manager/scan_duplicates/stream', async route => {
const response = await route.fetch();
const body = (await response.text()).replace(/^data: (.*)$/gm, (line, json) => {
const event = JSON.parse(json);
if (event.type !== 'complete') return line;
for (const group of event.duplicates) group.images = listOrder(group.images);
return `data: ${JSON.stringify(event)}`;
});
await route.fulfill({ response, body });
});
}
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(4);
const originals = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original));
const output = rootOf(originals.find(url => url.includes('/bulk/')));
const input = rootOf(originals.find(url => url.includes('/shared/same.png') && rootOf(url) !== output));
expect(output).not.toBeNull();
expect(input).not.toBeNull();
await page.locator('#settingsBtn').click();
await page.locator('#scanDuplicatesBtn').click();
await expect(page.locator('#duplicatesCount')).toHaveText('1');
const candidates = page.locator('.duplicate-checkbox');
await expect(candidates).toHaveCount(2);
return { originals, output, input, candidates };
}
async function expectOnlySelectedGone(page, request, { originals, output, input }, selected, retained) {
expect((await request.get(serveIn(output, selected))).status()).toBe(404);
await expect(await request.get(serveIn(output, retained))).toBeOK();
await expect(await request.get(serveIn(input, 'shared/same.png'))).toBeOK();
await page.reload();
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(3);
const remaining = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original));
expect(remaining).toHaveLength(originals.length - 1);
for (const url of remaining) {
expect(originals).toContain(url);
await expect(await request.get(url)).toBeOK();
}
}
test('duplicate cleanup cancels safely and deletes only the selected file after a retry', async ({ page, request }) => {
const scan = await openDuplicateScan(page, request);
const { output, candidates } = scan;
const selected = await candidates.first().getAttribute('data-image-path');
const retained = await candidates.last().getAttribute('data-image-path');
await candidates.first().check();
let deletes = 0;
page.on('request', request => { if (request.url().endsWith('/delete_duplicate_images')) deletes++; });
confirmNextDialog(page, false, 'permanently delete 1 duplicate image files');
await page.locator('#removeDuplicatesBtn').click();
expect(deletes).toBe(0);
await expect(await request.get(serveIn(output, selected))).toBeOK();
await page.route('**/prompt_manager/delete_duplicate_images', route => route.abort('failed'), { times: 1 });
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
await page.locator('#removeDuplicatesBtn').click();
await expect(page.getByText('Failed to delete duplicate images', { exact: true })).toBeVisible();
await expect(page.locator('#removeDuplicatesBtn')).toBeEnabled();
await expect(await request.get(serveIn(output, selected))).toBeOK();
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
const deleted = page.waitForResponse(response => response.url().endsWith('/delete_duplicate_images'));
await page.locator('#removeDuplicatesBtn').click();
expect((await (await deleted).json())).toMatchObject({ success: true, deleted_count: 1, failed_count: 0 });
await expect(page.locator('#duplicatesModal')).toBeHidden();
await expectOnlySelectedGone(page, request, scan, selected, retained);
});
test('deleting a duplicate whose path another gallery root also holds removes only the scanned copy', async ({ page, request }) => {
// The order CI saw: a bulk copy stamped first, so shared/same.png is the first Delete candidate.
const ciOrder = ['bulk/0000.png', 'shared/same.png', 'bulk/0001.png'];
const rank = image => ciOrder.indexOf(image.path.replace(/\\/g, '/'));
const scan = await openDuplicateScan(page, request, images => [...images].sort((a, b) => rank(a) - rank(b)));
const selected = await scan.candidates.first().getAttribute('data-image-path');
const retained = await scan.candidates.last().getAttribute('data-image-path');
expect(selected.replace(/\\/g, '/')).toBe('shared/same.png');
await scan.candidates.first().check();
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
const deleted = page.waitForResponse(response => response.url().endsWith('/delete_duplicate_images'));
await page.locator('#removeDuplicatesBtn').click();
expect((await (await deleted).json())).toMatchObject({ success: true, deleted_count: 1, failed_count: 0 });
await expect(page.locator('#duplicatesModal')).toBeHidden();
await expectOnlySelectedGone(page, request, scan, selected, retained);
});
@@ -247,6 +247,22 @@ def register_tagging_commands(
model.unavailable = set(data["unavailable"])
return web.json_response({"success": True})
@routes.post("/__test__/tagging/loaded")
async def loaded(request: web.Request) -> web.Response:
"""Mark one fake model loaded (``{"model": "<key>"}``), as a batch that
kept it in memory leaves it; ``/autotag/models`` then reports
``model_loaded``. Reset unloads it."""
data = await request.json() if request.can_read_body else None
if (
request.query
or not isinstance(data, dict)
or set(data) != {"model"}
or data["model"] not in MODELS
):
raise web.HTTPBadRequest()
model.loaded = data["model"]
return web.json_response({"success": True})
@routes.post("/__test__/tagging/download")
async def download_mode(request: web.Request) -> web.Response:
"""Switch the model's download to a fake (never a real download).
+85 -19
View File
@@ -76,35 +76,101 @@ test("the workspace keys have their own defaults and validation", () => {
assert.equal(normalizeGallerySettings({ defaultViewMode: "masonry" }).defaultViewMode, "grid");
});
test("a legacy gallery Save keeps the workspace keys and every other stored key", () => {
// #330 S13: the legacy gallery's Page options edit only the page-only keys (PAGE_OPTION_KEYS); System › Appearance owns the rest, so a Save
// or a Reset from the legacy page keeps every other stored key.
function legacyGallery(stored, controls, extra = {}) {
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const settings = require("../../web/js/gallery-settings.js");
const storage = new Map([["gallerySettings", JSON.stringify({ workspaceLayout: "masonry", workspaceDensity: "compact", someOtherTool: 1 })]]);
const controls = {
lazyLoadingToggle: { checked: true }, imageQualitySelect: { value: "high" }, defaultViewModeSelect: { value: "list" },
defaultLimitSelect: { value: "200" }, gridColumnsSelect: { value: "6" }, showImageInfoToggle: { checked: false },
autoLoadMetadataToggle: { checked: true }, cacheMetadataToggle: { checked: true }, showFilePathsToggle: { checked: false },
sidebarCollapsedByDefaultToggle: { checked: true }, infiniteScrollToggle: { checked: false }, debugModeToggle: { checked: false },
apiTimeoutInput: { value: "30" }, checkThumbnailsAtStartup: { checked: true }, videoAutoplayToggle: { checked: true },
videoMuteToggle: { checked: false }, videoLoopToggle: { checked: true },
};
const storage = new Map([["gallerySettings", JSON.stringify(stored)]]);
const context = vm.createContext({
document: { addEventListener() {}, getElementById: id => controls[id] },
window: {}, console: { debug() {}, warn() {}, error() {} }, GallerySettings: settings,
localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) },
localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value),
removeItem: key => storage.delete(key) },
...extra,
});
const file = path.join(__dirname, "../../web/js/gallery.js");
const Manager = vm.runInContext(`${fs.readFileSync(file, "utf8")}\nGalleryManager;`, context, { filename: file });
const manager = Object.create(Manager.prototype);
Object.assign(manager, { showNotification() {}, hideSettings() {}, applySettings() {} });
const calls = [];
Object.assign(manager, { showNotification: (message, type) => calls.push([type, message]), hideSettings() {},
applySettings: applied => calls.push(["apply", applied.lazyLoading]) });
return { manager, storage, calls, saved: () => JSON.parse(storage.get("gallerySettings")) };
}
const PAGE_OPTION_CONTROLS = () => ({
checkThumbnailsAtStartup: { checked: false }, lazyLoadingToggle: { checked: false },
defaultLimitSelect: { value: "200" },
gridColumnsSelect: { value: "6" }, infiniteScrollToggle: { checked: true }, debugModeToggle: { checked: true },
apiTimeoutInput: { value: "45" },
});
test("a legacy gallery Save keeps the workspace keys and every other stored key", () => {
const { manager, saved } = legacyGallery({ workspaceLayout: "masonry", workspaceDensity: "compact", someOtherTool: 1,
imageQuality: "high", showImageInfo: false, videoMute: false, thumbnailPromptDismissed: "sig", defaultViewMode: "list" },
PAGE_OPTION_CONTROLS());
manager.saveSettings();
const saved = JSON.parse(storage.get("gallerySettings"));
assert.equal(saved.workspaceLayout, "masonry");
assert.equal(saved.workspaceDensity, "compact");
assert.equal(saved.someOtherTool, 1);
assert.equal(saved.defaultViewMode, "list");
assert.equal(saved.showImageInfo, false);
assert.equal(saved.videoMute, false);
const result = saved();
assert.equal(result.workspaceLayout, "masonry");
assert.equal(result.workspaceDensity, "compact");
assert.equal(result.someOtherTool, 1);
assert.equal(result.imageQuality, "high");
assert.equal(result.showImageInfo, false);
assert.equal(result.videoMute, false);
assert.equal(result.thumbnailPromptDismissed, "sig");
// Page options no longer edit the default view (S13 G5), so a stored one stays.
assert.equal(result.defaultViewMode, "list");
// The page-only keys come from the dialog.
assert.equal(result.lazyLoading, false);
assert.equal(result.debugMode, true);
assert.equal(result.checkThumbnailsAtStartup, false);
assert.equal(result.defaultLimit, 200);
assert.equal(result.gridColumns, 6);
assert.equal(result.infiniteScroll, true);
assert.equal(result.apiTimeout, 45);
});
test("a legacy gallery Reset restores only the page options and keeps every other stored key", () => {
const controls = PAGE_OPTION_CONTROLS();
const { manager, saved, calls } = legacyGallery({ workspaceLayout: "masonry", imageQuality: "high", videoMute: false,
thumbnailPromptDismissed: "sig", thumbnailsGenerated: true, lazyLoading: false, debugMode: true, defaultLimit: 200,
gridColumns: 6, infiniteScroll: true, apiTimeout: 45, checkThumbnailsAtStartup: false, defaultViewMode: "list" }, controls,
{ confirm: message => (calls.push(["confirm", message]), true) });
manager.resetSettings();
const result = saved();
const { DEFAULT_SETTINGS } = require("../../web/js/gallery-settings.js");
for (const key of ["checkThumbnailsAtStartup", "lazyLoading", "defaultLimit", "gridColumns",
"infiniteScroll", "debugMode", "apiTimeout"]) {
assert.equal(result[key], DEFAULT_SETTINGS[key], key);
}
// The default view is not a page option (S13 G5): Reset keeps a stored one.
assert.equal(result.defaultViewMode, "list");
assert.equal(result.workspaceLayout, "masonry");
assert.equal(result.imageQuality, "high");
assert.equal(result.videoMute, false);
assert.equal(result.thumbnailPromptDismissed, "sig");
assert.equal(result.thumbnailsGenerated, true);
// The dialog shows the defaults again and the page applies them.
assert.equal(controls.lazyLoadingToggle.checked, DEFAULT_SETTINGS.lazyLoading);
assert.equal(controls.apiTimeoutInput.value, DEFAULT_SETTINGS.apiTimeout);
assert.deepEqual(calls, [["confirm", "Reset these page options to their defaults?"], ["apply", DEFAULT_SETTINGS.lazyLoading],
["success", "Page options reset to defaults"]]);
});
test("Page options no longer hold the default view; System › Appearance's Default layout is the one control", () => {
const { manager } = legacyGallery({}, {});
assert.deepEqual([...manager.constructor.PAGE_OPTION_KEYS], ["checkThumbnailsAtStartup", "lazyLoading", "defaultLimit",
"gridColumns", "infiniteScroll", "debugMode", "apiTimeout"]);
});
test("a viewer mute or loop click remembers only that key and never reads the Page options dialog", () => {
const { manager, saved, calls } = legacyGallery({ defaultLimit: 200, lazyLoading: false, videoMute: true }, {});
manager.storeSetting("videoMute", false);
const result = saved();
assert.equal(result.videoMute, false);
assert.equal(result.defaultLimit, 200);
assert.equal(result.lazyLoading, false);
assert.deepEqual(calls, []);
});
+15 -12
View File
@@ -112,11 +112,12 @@ test('saving gallery settings retains the remembered thumbnail dismissal', () =>
});
// #249 System S5: the stream's work is now an Activity job that outlives the page's connection, so the gallery's
// Cancel asks the server to stop the job. The stream's first frame, `job {id, started}`, names the job; the gallery
// Cancel asks the server to stop the job. Since #330 the startup dialog is the gallery's only thumbnail stream (System
// › Sources & scans generates the rest). The stream's first frame, `job {id, started}`, names the job; the gallery
// cancels by that id, only a job this stream started, and a Cancel before the id waits for it (review I1).
const JOB = 'b'.repeat(32);
function streamFixture({ modal = false, postFails = false } = {}) {
function streamFixture({ postFails = false } = {}) {
const posts = [];
const warnings = [];
const gets = [];
@@ -146,18 +147,20 @@ function streamFixture({ modal = false, postFails = false } = {}) {
return { success: true };
} } });
const emit = (type, data) => (listeners[type] || []).forEach(handler => handler({ data: JSON.stringify(data) }));
// The dialog's Cancel runs the handler and from then on reads as cancelled, as the startup dialog does.
let cancelled = false;
const ui = { addStatusMessage() {}, progressText: element(), progressPercent: element(), progressBar: element(),
progressDetails: element(), progressETA: element(), isCancelled: () => false,
onCancel: handler => { ui.cancel = handler; } };
const result = modal ? manager.generateThumbnailsWithProgressInModal(ui)
: manager.generateThumbnailsWithProgress({ quality: 'medium' });
progressDetails: element(), progressETA: element(), isCancelled: () => cancelled,
onCancel: handler => { ui.cancel = () => { cancelled = true; handler(); }; } };
const result = manager.generateThumbnailsWithProgressInModal(ui);
const outcome = result.then(value => ({ value }), error => ({ error: error.message }));
const cancel = () => (modal ? ui.cancel() : elements.cancelThumbnailsBtn.click());
const cancel = () => ui.cancel();
const settle = () => new Promise(resolve => setImmediate(resolve));
return { emit, source, posts, gets, warnings, outcome, cancel, settle };
}
const cancelUrl = id => `/prompt_manager/jobs/${id}/cancel`;
const CANCELLED = 'Thumbnail generation cancelled; previews already written are kept';
test('Cancel before the job id arrives cancels that job once it does', async () => {
const f = streamFixture();
@@ -165,8 +168,8 @@ test('Cancel before the job id arrives cancels that job once it does', async ()
await f.settle();
assert.deepEqual(f.posts, []);
assert.equal(f.source.closed, 0); // kept open until the stream names its job
assert.deepEqual(await f.outcome, { error: 'Thumbnail generation cancelled by user' });
f.emit('status', { phase: 'scanning', message: 'Scanning…' });
assert.deepEqual(await f.outcome, { error: CANCELLED });
// The job frame is the stream's first (the dialog closes the stream on any later frame once cancelled).
f.emit('job', { id: JOB, started: true });
await f.settle();
assert.equal(JSON.stringify(f.posts), JSON.stringify([[cancelUrl(JOB), {}]]));
@@ -185,7 +188,7 @@ test('Cancel after the id cancels it at once, and a double Cancel posts once', a
await f.settle();
assert.equal(JSON.stringify(f.posts), JSON.stringify([[cancelUrl(JOB), {}]]));
assert.equal(f.source.closed, 1);
assert.deepEqual(await f.outcome, { error: 'Thumbnail generation cancelled by user' });
assert.deepEqual(await f.outcome, { error: CANCELLED });
assert.deepEqual(f.gets, []);
});
@@ -216,11 +219,11 @@ test('a cancel post that fails is reported, not thrown', async () => {
await f.settle();
assert.equal(f.posts.length, 1);
assert.equal(f.warnings.length, 1);
assert.deepEqual(await f.outcome, { error: 'Thumbnail generation cancelled by user' });
assert.deepEqual(await f.outcome, { error: CANCELLED });
});
test('the startup dialog cancels its own job by id too', async () => {
const f = streamFixture({ modal: true });
const f = streamFixture();
f.cancel();
await f.settle();
assert.deepEqual(f.posts, []);
-81
View File
@@ -65,87 +65,6 @@ function load(file, className, fetchUrl, extra = {}, dom = elements()) {
const BUSY = 'Scan for metadata already running';
test('gallery cache clear reports a refused server clear and never claims success', async () => {
const calls = [];
const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager', async url => {
calls.push(url);
return jsonResponse(409, { success: false, error: BUSY });
});
page.getSettings = () => ({ thumbnailsGenerated: true });
dom.getElementById('thumbnailStatus').textContent = 'Generated';
await page.clearCache();
assert.deepEqual(calls, ['/prompt_manager/images/clear-thumbnails']);
assert.deepEqual(notifications, [['error', `Thumbnails were not cleared: ${BUSY}`]]);
assert.equal(dom.nodes.thumbnailStatus.textContent, 'Generated');
assert.equal(storage.get('gallerySettings'), undefined); // thumbnailsGenerated is not reset
});
test('gallery cache clear says what it cleared and keeps preferences and other data (#222)', async () => {
const calls = [];
const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager', async url => {
calls.push(url);
return jsonResponse(200, { success: true, cleared_count: 3 });
});
page.getSettings = () => ({ thumbnailsGenerated: true });
await page.clearCache();
// The server's thumbnails are cleared by their own explicit call; nothing Prompt Manager owns is cached in this
// browser, so no browser entry is removed and the toast says exactly that.
assert.deepEqual(calls, ['/prompt_manager/images/clear-thumbnails']);
assert.deepEqual(notifications, [['success',
"Thumbnails cleared; original images untouched. Nothing of Prompt Manager's is cached in this browser."]]);
assert.equal(dom.nodes.thumbnailStatus.textContent, 'Not generated');
assert.equal(JSON.parse(storage.get('gallerySettings')).thumbnailsGenerated, false);
assert.equal(storage.get('userData'), 'keep');
assert.equal(storage.get('pm_cached'), '1');
assert.equal(storage.get('pm_workspace_theme'), 'light');
assert.equal(storage.get('pm_update_notice_dismissed'), '3.3.0');
});
test("gallery cache clear never deletes another tool's gallery or thumbnail caches (#222)", async () => {
const names = new Set(['comfy-gallery', 'thumbnail-cache']);
const caches = { keys: async () => [...names], delete: async name => names.delete(name) };
const { page, notifications } = load('gallery.js', 'GalleryManager',
async () => jsonResponse(200, { success: true }), { caches, window: { caches } });
page.getSettings = () => ({});
await page.clearCache();
assert.deepEqual([...names], ['comfy-gallery', 'thumbnail-cache']);
assert.equal(notifications.at(-1)[0], 'success');
});
test('gallery cache clear reports a lost server clear instead of success', async () => {
const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager', async () => {
throw new TypeError('Failed to fetch');
});
page.getSettings = () => ({ thumbnailsGenerated: true });
dom.getElementById('thumbnailStatus').textContent = 'Generated';
await page.clearCache();
assert.deepEqual(notifications, [['error', 'Thumbnails were not cleared: the server could not be reached.']]);
assert.equal(dom.nodes.thumbnailStatus.textContent, 'Generated');
assert.equal(storage.get('gallerySettings'), undefined);
});
test('gallery cache clear says so when browser storage is denied', async () => {
const denied = { get length() { throw new Error('SecurityError'); }, key() { throw new Error('SecurityError'); },
getItem() { throw new Error('SecurityError'); }, setItem() { throw new Error('SecurityError'); },
removeItem() { throw new Error('SecurityError'); } };
const { page, notifications } = load('gallery.js', 'GalleryManager',
async () => jsonResponse(200, { success: true }), { localStorage: denied });
page.getSettings = () => ({});
await page.clearCache();
assert.deepEqual(notifications, [['warning',
'Thumbnails cleared; original images untouched. This browser refused access to its storage, so nothing there was cleared.']]);
});
test('admin settings save shows why config.json refused it', async () => {
const message = 'config.json cannot be read, so nothing was saved. Fix or remove the file, then save again.';
const { page, notifications } = load('admin.js', 'PromptAdmin',
-30
View File
@@ -988,36 +988,6 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr
</div>
</div>
<!-- Auto Tag Download Modal -->
<div id="autoTagDownloadModal" class="fixed inset-0 bg-black/60 backdrop-blur-[2px] hidden items-center justify-center z-50">
<div class="bg-pm-surface rounded-pm-lg p-4 max-w-md w-full mx-4 border border-pm shadow-pm">
<h3 class="text-sm font-semibold text-pm mb-4">Downloading Model</h3>
<div class="mb-3">
<p class="text-pm-secondary text-xs">Downloading:</p>
<p id="downloadModelName" class="text-pm text-xs font-medium">GGUF Model</p>
</div>
<div class="mb-4">
<div class="flex justify-between text-xs text-pm-secondary mb-1">
<span id="downloadStatus">Preparing...</span>
<span id="downloadProgressPercent">0%</span>
</div>
<div class="w-full bg-pm-input rounded-full h-1.5">
<div id="downloadProgressBar" class="bg-pm-accent h-1.5 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<p class="text-pm-secondary text-xs mt-2">The download continues in Activity.</p>
</div>
<div class="flex justify-center">
<button id="cancelDownloadBtn"
class="px-3 py-1.5 text-pm-error bg-transparent hover:bg-pm-error-tint border border-pm text-xs font-medium rounded-pm-sm transition-colors">
Close
</button>
</div>
</div>
</div>
<script src="js/comfy-metadata.js"></script>
<script src="js/prompt-list-sort.js"></script>
<script src="js/view-router.js"></script>
+27 -249
View File
@@ -8,8 +8,6 @@
<script src="js/html-escape.js"></script>
<script src="js/image-helpers.js"></script>
<script src="js/gallery-settings.js"></script>
<script src="js/cache-ownership.js"></script>
<script src="js/scan-progress.js"></script>
<script src="js/api-client.js"></script>
<script src="js/png-metadata.js"></script>
<script src="js/list-state.js"></script>
@@ -63,7 +61,7 @@
<button id="thumbMdBtn" class="px-2 py-1 bg-pm-accent text-pm-accent-fg text-xs rounded-pm-sm transition-colors" title="Medium thumbnails">M</button>
<button id="thumbLgBtn" class="px-2 py-1 bg-pm-input hover:bg-pm-hover text-pm text-xs rounded-pm-sm transition-colors" title="Large thumbnails">L</button>
</div>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<button id="refreshBtn"
class="px-3 py-1 bg-pm-accent hover:bg-pm-accent-hover text-pm-accent-fg text-xs font-medium rounded-pm-sm transition-colors">
Refresh
@@ -76,9 +74,14 @@
class="px-3 py-1 bg-pm-input hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors">
List View
</button>
<button id="settingsBtn"
class="px-3 py-1 bg-pm-input hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors">
<a id="settingsBtn" href="workspace#/system/appearance" target="_blank" rel="noopener"
title="Opens System › Appearance in a new tab"
class="px-3 py-1 bg-pm-input hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors">
Settings
</a>
<button id="pageOptionsBtn" type="button"
class="px-3 py-1 bg-pm-input hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors">
Page options
</button>
<button id="autoTagBtn"
class="px-3 py-1 bg-pm-accent hover:bg-pm-accent-hover text-pm-accent-fg text-xs font-medium rounded-pm-sm transition-colors">
@@ -176,7 +179,7 @@
<div class="bg-pm-surface rounded-pm-md max-w-2xl w-full max-h-[90vh] overflow-hidden mx-4 border border-pm">
<div class="p-3 border-b border-pm">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-pm">Gallery Settings</h3>
<h3 class="text-sm font-semibold text-pm">Page options</h3>
<button id="closeSettingsBtn" class="p-2 rounded-pm-sm hover:bg-pm-hover transition-colors">
<svg class="w-4 h-4 text-pm-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
@@ -190,25 +193,9 @@
<div class="space-y-3">
<h4 class="text-sm font-semibold text-pm">Performance Settings</h4>
<!-- Thumbnail Generation -->
<div class="bg-pm-input rounded-pm-sm p-3">
<div class="flex items-center justify-between mb-2">
<div>
<h5 class="text-pm text-xs font-medium">Generate Thumbnails</h5>
<p class="text-xs text-pm-secondary">Create optimized thumbnails for faster loading</p>
</div>
<button id="generateThumbnailsBtn" class="px-3 py-1 bg-pm-accent hover:bg-pm-accent-hover text-pm-accent-fg text-xs rounded-pm-sm transition-colors">
Generate
</button>
</div>
<div class="text-xs text-pm-muted mb-2">
Status: <span id="thumbnailStatus">Not generated</span>
<div class="text-xs text-pm-muted mt-1">
Thumbnails speed up gallery loading. Original images always used for viewing and metadata.
</div>
</div>
<div class="bg-pm-input rounded-pm-sm p-3 space-y-2">
<!-- Check thumbnails at startup -->
<div class="flex items-center justify-between py-2 border-t border-pm">
<div class="flex items-center justify-between">
<div>
<label class="text-xs text-pm-secondary">Check thumbnails at startup</label>
<p class="text-xs text-pm-muted">Prompt to generate thumbnails if missing when opening gallery</p>
@@ -218,43 +205,9 @@
<div class="w-11 h-6 bg-pm-input peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-pm-accent rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-pm after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-pm-accent"></div>
</label>
</div>
<!-- Progress bar (hidden by default) -->
<div id="thumbnailProgress" class="hidden">
<div class="flex items-center justify-between text-xs text-pm-secondary mb-1">
<span id="progressText">Processing...</span>
<div class="flex items-center gap-2">
<span id="progressPercent">0%</span>
<button id="cancelThumbnailsBtn" class="text-pm-error hover:text-pm-error text-xs px-2 py-1 bg-pm-error-tint rounded-pm-sm hidden">
Cancel
</button>
</div>
</div>
<div class="w-full bg-pm-input rounded-full h-2">
<div id="progressBar" class="bg-pm-accent h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div class="flex items-center justify-between text-xs text-pm-muted mt-1">
<span id="progressDetails">Scanning images...</span>
<span id="progressETA" class="text-pm-muted"></span>
</div>
</div>
</div>
<!-- Image Loading -->
<div class="bg-pm-input rounded-pm-sm p-3">
<h5 class="text-pm text-xs font-medium mb-2">Image Loading</h5>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-xs text-pm-secondary">Lazy Loading</label>
<input type="checkbox" id="lazyLoadingToggle" checked class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
</div>
<div class="flex items-center justify-between">
<label class="text-xs text-pm-secondary">Image Quality</label>
<select id="imageQualitySelect" class="px-2 py-1 bg-pm-input border border-pm rounded-pm-sm text-pm text-xs">
<option value="high">High (Original)</option>
<option value="medium" selected>Medium (Optimized)</option>
<option value="low">Low (Fast)</option>
</select>
</div>
<div class="flex items-center justify-between">
<label class="text-xs text-pm-secondary">Lazy Loading</label>
<input type="checkbox" id="lazyLoadingToggle" checked class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
</div>
</div>
</div>
@@ -264,13 +217,6 @@
<h4 class="text-sm font-semibold text-pm">Display Settings</h4>
<div class="bg-pm-input rounded-pm-sm p-3 space-y-2">
<div class="flex items-center justify-between">
<label class="text-xs text-pm-secondary">Default View Mode</label>
<select id="defaultViewModeSelect" class="px-2 py-1 bg-pm-input border border-pm rounded-pm-sm text-pm text-xs">
<option value="grid" selected>Grid View</option>
<option value="list">List View</option>
</select>
</div>
<div class="flex items-center justify-between">
<label class="text-xs text-pm-secondary">Items per Page</label>
<select id="defaultLimitSelect" class="px-2 py-1 bg-pm-input border border-pm rounded-pm-sm text-pm text-xs">
@@ -289,14 +235,6 @@
<option value="10">10 Columns</option>
</select>
</div>
<div class="flex items-center justify-between">
<label class="text-xs text-pm-secondary">Show Media Info</label>
<input type="checkbox" id="showImageInfoToggle" checked class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
</div>
<div class="flex items-center justify-between">
<label class="text-xs text-pm-secondary">Collapse Sidebar by Default</label>
<input type="checkbox" id="sidebarCollapsedByDefaultToggle" class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-xs text-pm-secondary">Infinite Scrolling</label>
@@ -305,78 +243,6 @@
<input type="checkbox" id="infiniteScrollToggle" class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
</div>
</div>
<!-- Video Settings -->
<div class="bg-pm-input rounded-pm-sm p-3 space-y-2">
<h5 class="text-pm text-xs font-medium mb-2">Video Settings</h5>
<div class="flex items-center justify-between">
<label class="text-xs text-pm-secondary">Autoplay Videos</label>
<input type="checkbox" id="videoAutoplayToggle" class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
</div>
<div class="flex items-center justify-between">
<label class="text-xs text-pm-secondary">Mute Videos by Default</label>
<input type="checkbox" id="videoMuteToggle" checked class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
</div>
<div class="flex items-center justify-between">
<label class="text-xs text-pm-secondary">Loop Videos</label>
<input type="checkbox" id="videoLoopToggle" checked class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
</div>
</div>
</div>
<!-- Metadata Settings -->
<div class="space-y-3">
<h4 class="text-sm font-semibold text-pm">Metadata Settings</h4>
<div class="bg-pm-input rounded-pm-sm p-3 space-y-2">
<div class="flex items-center justify-between">
<label class="text-xs text-pm-secondary">Auto-load Metadata</label>
<input type="checkbox" id="autoLoadMetadataToggle" checked class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
</div>
<div class="flex items-center justify-between">
<label class="text-xs text-pm-secondary">Cache Metadata</label>
<input type="checkbox" id="cacheMetadataToggle" checked class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
</div>
<div class="flex items-center justify-between">
<label class="text-xs text-pm-secondary">Show File Paths</label>
<input type="checkbox" id="showFilePathsToggle" checked class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
</div>
</div>
</div>
<!-- Maintenance -->
<div class="space-y-3">
<h4 class="text-sm font-semibold text-pm">Maintenance</h4>
<div class="bg-pm-input rounded-pm-sm p-3 space-y-2">
<div class="flex items-center justify-between">
<div>
<h5 class="text-pm text-xs font-medium">Clear Cache</h5>
<p class="text-xs text-pm-secondary">Clear cached metadata and thumbnails</p>
</div>
<button id="clearCacheBtn" class="px-3 py-1 bg-pm-error hover:bg-pm-error text-pm text-xs rounded-pm-sm transition-colors">
Clear
</button>
</div>
<div class="flex items-center justify-between">
<div>
<h5 class="text-pm text-xs font-medium">Rescan Output Folder</h5>
<p class="text-xs text-pm-secondary">Refresh the image list from disk</p>
</div>
<button id="rescanFolderBtn" class="px-3 py-1 bg-pm-accent hover:bg-pm-accent-hover text-pm-accent-fg text-xs rounded-pm-sm transition-colors">
Rescan
</button>
</div>
<div class="flex items-center justify-between">
<div>
<h5 class="text-pm text-xs font-medium">Find Duplicate Images</h5>
<p class="text-xs text-pm-secondary">Scan for duplicate images based on file content</p>
</div>
<button id="scanDuplicatesBtn" class="px-3 py-1 bg-pm-warning hover:bg-pm-warning text-pm text-xs rounded-pm-sm transition-colors">
Scan
</button>
</div>
</div>
</div>
<!-- Advanced Settings -->
@@ -395,6 +261,14 @@
</div>
</div>
<p class="text-xs text-pm-secondary">Thumbnails, cache, file listing and duplicates are in System.
<a id="pageOptionsMaintenanceLink" href="workspace#/system/maintenance" target="_blank" rel="noopener"
title="Opens System › Maintenance in a new tab" class="text-pm-accent hover:underline">Maintenance</a>
·
<a id="pageOptionsSourcesLink" href="workspace#/system/sources" target="_blank" rel="noopener"
title="Opens System › Sources &amp; scans in a new tab" class="text-pm-accent hover:underline">Sources &amp; scans</a>
</p>
</div>
<div class="p-3 border-t border-pm flex justify-end gap-2">
<button id="resetSettingsBtn" class="px-3 py-1 bg-pm-input hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors">
@@ -407,71 +281,6 @@
</div>
</div>
<!-- Minimized duplicate scan: shown while it runs with its modal hidden, or when results are waiting -->
<button id="dupScanPill" type="button" title="Show duplicate scan"
class="hidden fixed bottom-4 right-4 z-40 px-3 py-1.5 bg-pm-surface border border-pm shadow-pm rounded-pm-md text-xs text-pm hover:bg-pm-hover transition-colors">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-pm-accent mr-1.5 align-middle animate-pulse"></span><span id="dupScanPillText" class="align-middle">Scanning...</span>
</button>
<!-- Duplicates Modal -->
<div id="duplicatesModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-pm-surface rounded-pm-md max-w-4xl w-full max-h-[90vh] overflow-hidden mx-4 border border-pm">
<div class="p-3 border-b border-pm">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-pm">Duplicate Images Found</h3>
<button id="closeDuplicatesBtn" class="p-2 rounded-pm-sm hover:bg-pm-hover transition-colors">
<svg class="w-4 h-4 text-pm-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<div class="p-3 overflow-y-auto max-h-[calc(90vh-200px)]">
<div id="duplicatesScanStatus" class="py-6">
<div class="max-w-md mx-auto bg-pm-input rounded-pm-md p-3">
<div class="flex items-center justify-between mb-1.5">
<span class="text-pm text-xs font-medium">Scanning for duplicate images</span>
<span id="dupProgressText" class="text-pm-secondary text-xs">Starting...</span>
</div>
<div class="w-full bg-pm-primary rounded-full h-1.5">
<div id="dupProgressBar" class="bg-pm-accent h-1.5 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<p class="text-[11px] text-pm-muted mt-2">Every file is hashed, so this takes a while on a large library. You can close this window; the scan keeps running and the results will be waiting.</p>
</div>
</div>
<div id="duplicatesContent" class="hidden">
<div class="mb-3 p-3 bg-pm-error-tint border border-pm-accent rounded-pm-sm">
<div class="flex items-start">
<svg class="w-4 h-4 text-pm-error mt-0.5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
<div>
<h4 class="text-pm-error text-xs font-medium">Warning: Removing duplicate images cannot be undone!</h4>
<p class="text-pm-secondary text-xs mt-1">When you remove a duplicate image, the selected file will be permanently deleted from disk. Choose carefully which images to keep.</p>
</div>
</div>
</div>
<div id="duplicatesList" class="space-y-3">
<!-- Duplicates will be populated here -->
</div>
</div>
</div>
<div id="duplicatesFooter" class="hidden p-3 border-t border-pm flex justify-between items-center">
<div class="text-xs text-pm-secondary">
<span id="duplicatesCount">0</span> duplicate image groups found
</div>
<div class="flex gap-2">
<button id="selectAllBtn" class="px-3 py-1 bg-pm-input hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors">
Select All to Delete
</button>
<button id="removeDuplicatesBtn" class="px-3 py-1 bg-pm-error hover:bg-pm-error text-pm text-xs font-medium rounded-pm-sm transition-colors">
Delete Selected
</button>
</div>
</div>
</div>
</div>
<!-- Auto Tag Modal -->
<div id="autoTagModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-pm-surface rounded-pm-md p-3 max-w-lg w-full mx-4 border border-pm">
@@ -549,9 +358,11 @@
<span class="text-pm-secondary text-xs ml-2">(Faster processing, uses ~4-16GB VRAM)</span>
</div>
</div>
<button id="unloadModelBtn" class="px-3 py-1 bg-pm-error hover:bg-pm-error text-pm text-xs rounded-pm-sm transition-colors hidden">
Unload Model
</button>
<a id="unloadModelBtn" href="workspace#/system/tagging" target="_blank" rel="noopener"
title="Unload the model in System › Auto-tagging (opens a new tab)"
class="px-3 py-1 bg-pm-error hover:bg-pm-error text-pm text-xs rounded-pm-sm transition-colors hidden">
Manage in System
</a>
</label>
<p id="modelLoadedStatus" class="text-pm-muted text-xs mt-1 hidden">
<span class="text-pm-success">●</span> Model currently loaded: <span id="loadedModelType">none</span>
@@ -711,39 +522,6 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr
</div>
</div>
<!-- Auto Tag Download Modal -->
<div id="autoTagDownloadModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-pm-surface rounded-pm-md p-3 max-w-md w-full mx-4 border border-pm">
<h3 class="text-sm font-semibold text-pm mb-3">Downloading Model</h3>
<!-- Model Name -->
<div class="mb-3">
<p class="text-pm-secondary text-xs">Downloading:</p>
<p id="downloadModelName" class="text-pm text-xs font-medium">GGUF Model</p>
</div>
<!-- Progress Bar -->
<div class="mb-3">
<div class="flex justify-between text-xs text-pm-secondary mb-1">
<span id="downloadStatus">Preparing...</span>
<span id="downloadProgressPercent">0%</span>
</div>
<div class="w-full bg-pm-input rounded-full h-2">
<div id="downloadProgressBar" class="bg-pm-accent h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<p class="text-pm-secondary text-xs mt-2">The download continues in Activity.</p>
</div>
<!-- Cancel Button -->
<div class="flex justify-center">
<button id="cancelDownloadBtn"
class="px-3 py-1 bg-pm-error hover:bg-pm-error text-pm text-xs font-medium rounded-pm-sm transition-colors">
Close
</button>
</div>
</div>
</div>
<!-- Add Prompt Modal -->
<div id="addPromptModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-pm-surface rounded-pm-md p-3 max-w-2xl w-full mx-4 border border-pm max-h-[90vh] overflow-y-auto">
+9 -75
View File
@@ -213,7 +213,6 @@
document.getElementById("skipReviewBtn").addEventListener("click", () => this.skipReviewImage());
document.getElementById("applyReviewBtn").addEventListener("click", () => this.applyReviewTags());
document.getElementById("cancelAutoTagBtn").addEventListener("click", () => this.cancelAutoTag());
document.getElementById("cancelDownloadBtn").addEventListener("click", () => this.cancelDownload());
// Re-tag confirmation modal buttons
document.getElementById("retagSkipBtn").addEventListener("click", () => this.handleRetagChoice('skip'));
@@ -2184,7 +2183,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
// State for AutoTag feature
autoTagState = {
eventSource: null,
downloadEventSource: null,
reviewImages: [],
reviewIndex: 0,
currentTags: [],
@@ -2598,11 +2596,15 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
span.textContent = '\u2713 Downloaded';
el.appendChild(span);
} else {
const btn = document.createElement('button');
btn.className = 'px-3 py-1 bg-pm-accent hover:bg-pm-accent-hover text-pm text-xs rounded transition-colors';
btn.textContent = 'Download';
btn.addEventListener('click', () => window.admin.downloadModel(modelKey));
el.appendChild(btn);
// Download lives in System › Auto-tagging (#330); the batch refuses a model that is not downloaded.
const link = document.createElement('a');
link.href = 'workspace#/system/tagging';
link.target = '_blank';
link.rel = 'noopener';
link.title = 'Opens System › Auto-tagging in a new tab';
link.className = 'px-3 py-1 bg-pm-accent hover:bg-pm-accent-hover text-pm text-xs rounded transition-colors';
link.textContent = 'Manage in System';
el.appendChild(link);
}
}
@@ -2645,74 +2647,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
}
async downloadModel(modelType) {
const modelNames = {
'gguf': 'GGUF Model',
'hf': 'HuggingFace Model',
'wd14-swinv2': 'WD14 SwinV2',
'wd14-vit': 'WD14 ViT'
};
const modelName = modelNames[modelType] || modelType;
document.getElementById('downloadModelName').textContent = modelName;
document.getElementById('downloadStatus').textContent = 'Preparing...';
document.getElementById('downloadProgressPercent').textContent = '0%';
document.getElementById('downloadProgressBar').style.width = '0%';
document.getElementById('downloadProgressBar').classList.remove('animate-pulse');
this.hideModal("autoTagModal");
this.showModal("autoTagDownloadModal");
try {
this.autoTagState.downloadEventSource = SseStream.connect(`/prompt_manager/autotag/download/${modelType}`, { method: 'POST' });
this.autoTagState.downloadEventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
// The server sends `type` (progress / complete / error); the download is an
// Activity job with no byte progress, so the bar is indeterminate (#328).
if (data.type === 'progress') {
document.getElementById('downloadStatus').textContent = 'Downloading\u2026';
document.getElementById('downloadProgressPercent').textContent = '';
const bar = document.getElementById('downloadProgressBar');
bar.style.width = '100%';
bar.classList.add('animate-pulse');
} else if (data.type === 'complete') {
this.autoTagState.downloadEventSource.close();
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.checkAutoTagModels();
this.showNotification('Model downloaded successfully!', 'success');
} else if (data.type === 'error') {
this.autoTagState.downloadEventSource.close();
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.showNotification(`Download failed: ${data.message}`, 'error');
}
};
this.autoTagState.downloadEventSource.onerror = () => {
this.autoTagState.downloadEventSource.close();
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.showNotification('Download connection lost', 'error');
};
} catch (error) {
console.error('Download error:', error);
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.showNotification('Failed to start download', 'error');
}
}
cancelDownload() {
if (this.autoTagState.downloadEventSource) {
this.autoTagState.downloadEventSource.close();
this.autoTagState.downloadEventSource = null;
}
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
}
async startAutoTag() {
const modelType = document.querySelector('input[name="autoTagModel"]:checked').value;
const prompt = document.getElementById('autoTagPrompt').value;
+54 -803
View File
File diff suppressed because it is too large Load Diff