Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c4d3b733b | ||
|
|
7f6bac5392 | ||
|
|
eb6b4284f0 | ||
|
|
9e3c7bedd9 | ||
|
|
0e5fa52e76 | ||
|
|
d2da8fc7f5 | ||
|
|
f78716fc5d | ||
|
|
dfa4b3355f | ||
|
|
727a8dfd3a | ||
|
|
bd5682e908 | ||
|
|
e2d7cb1719 | ||
|
|
6527b6fe1d | ||
|
|
97e8b9b680 | ||
|
|
9909bc5faa | ||
|
|
8800defb98 | ||
|
|
341b813fd7 | ||
|
|
dae6b3f784 | ||
|
|
ba2803dbbf | ||
|
|
0abdcbc814 | ||
|
|
c0d27772b4 | ||
|
|
b26ae112e6 |
@@ -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
|
||||
|
||||

|
||||
|
||||
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
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 & scans in a new tab" class="text-pm-accent hover:underline">Sources & 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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user