From e9fe5864424de73c92c84d8d2943fb010b7b663d Mon Sep 17 00:00:00 2001 From: Vito Date: Fri, 16 Oct 2026 06:14:06 -0700 Subject: [PATCH] fix: repair prompt edit hashes, auto-tag review, and stale UI responses (#180) * fix: keep edited prompt hashes consistent Fixes #176 * fix: align gallery auto-tag review with scoped image APIs Fixes #177 * fix: keep Tags prompt results on the current filter Fixes #178 * fix: ignore stale metadata in admin image viewers Fixes #179 * test: compare canonical gallery paths on macOS --- database/operations.py | 10 +++- py/api/autotag_routes.py | 16 ++++++ py/api/prompts.py | 6 ++ tests/js/admin-metadata.test.js | 50 +++++++++++++++++ tests/js/gallery-review.test.js | 88 ++++++++++++++++++++++++++++++ tests/js/tags-prompts-race.test.js | 71 ++++++++++++++++++++++++ tests/test_api_autotag.py | 69 +++++++++++++++++++++++ tests/test_api_prompts.py | 46 ++++++++++++++++ tools/vendor/tests/viewer.spec.mjs | 62 +++++++++++++++++++++ web/js/admin.js | 16 ++++++ web/js/gallery.js | 26 ++++++++- web/js/image-helpers.js | 3 + web/js/tags-page.js | 30 +++++++--- 13 files changed, 482 insertions(+), 11 deletions(-) create mode 100644 tests/js/admin-metadata.test.js create mode 100644 tests/js/gallery-review.test.js create mode 100644 tests/js/tags-prompts-race.test.js diff --git a/database/operations.py b/database/operations.py index e037826..c501d5b 100644 --- a/database/operations.py +++ b/database/operations.py @@ -13,12 +13,14 @@ from .models import PromptModel, normalize_image_path, utc_now_iso # Import logging system try: from ..utils.logging_config import get_logger + from ..utils.hashing import generate_prompt_hash except ImportError: import sys current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, current_dir) from utils.logging_config import get_logger + from utils.hashing import generate_prompt_hash # Subquery to fetch tags from junction table, embedded in SELECT statements @@ -1854,16 +1856,20 @@ class PromptDatabase: } def update_prompt_text(self, prompt_id: int, new_text: str) -> bool: - """Update only the text of a prompt. + """Update prompt text and its deduplication hash atomically. Returns: True if a row was updated. + + Raises: + sqlite3.IntegrityError: Another prompt already has the new hash. """ with self.model.get_connection() as conn: cursor = conn.execute( - "UPDATE prompts SET text = ?, updated_at = ? WHERE id = ?", + "UPDATE prompts SET text = ?, hash = ?, updated_at = ? WHERE id = ?", ( new_text, + generate_prompt_hash(new_text), datetime.datetime.now(datetime.timezone.utc).isoformat(), prompt_id, ), diff --git a/py/api/autotag_routes.py b/py/api/autotag_routes.py index dd3019c..50d62b2 100644 --- a/py/api/autotag_routes.py +++ b/py/api/autotag_routes.py @@ -484,6 +484,22 @@ class AutotagRoutesMixin: if not raw_path or not isinstance(raw_path, str): return None, None, self._bad_request("image_id or path is required") + if "root_index" in data: + root_index = data["root_index"] + if isinstance(root_index, bool) or not isinstance(root_index, int): + return None, None, self._bad_request("root_index must be an integer") + roots = self._get_all_output_dirs() + if not 0 <= root_index < len(roots): + return None, None, self._forbidden(raw_path) + root = roots[root_index] + if Path(raw_path).is_absolute() or ".." in raw_path.replace( + "\\", "/" + ).split("/"): + return None, None, self._forbidden(raw_path) + raw_path = str(Path(root) / raw_path) + if not path_is_within(raw_path, root): + return None, None, self._forbidden(raw_path) + resolved = self._resolve_allowed_image_path(raw_path) if resolved is None: return None, None, self._forbidden(raw_path) diff --git a/py/api/prompts.py b/py/api/prompts.py index 4f4b401..0f5df1f 100644 --- a/py/api/prompts.py +++ b/py/api/prompts.py @@ -3,6 +3,7 @@ import datetime import json import os +import sqlite3 from aiohttp import web from urllib.parse import quote @@ -839,6 +840,11 @@ class PromptRoutesMixin: {"success": False, "error": "Prompt not found"}, status=404 ) + except sqlite3.IntegrityError: + return web.json_response( + {"success": False, "error": "Another prompt already has this text"}, + status=409, + ) except ValueError: return web.json_response( {"success": False, "error": "Invalid prompt ID"}, status=400 diff --git a/tests/js/admin-metadata.test.js b/tests/js/admin-metadata.test.js new file mode 100644 index 0000000..68abb6d --- /dev/null +++ b/tests/js/admin-metadata.test.js @@ -0,0 +1,50 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +function fixture() { + const panel = { innerHTML: '', isConnected: true }; + const requests = new Map(); + const context = vm.createContext({ document: { readyState: 'loading', addEventListener() {}, + getElementById: id => id === 'metadata-content' ? panel : null }, + window: {}, console: { error() {} }, + }); + const Admin = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/admin.js'), 'utf8') + '\nPromptAdmin;', context); + const manager = Object.create(Admin.prototype); + manager.extractImageMetadata = url => new Promise((resolve, reject) => requests.set(url, { resolve, reject })); + manager.updateMetadataSidebar = metadata => { panel.innerHTML = metadata?.imagePath || 'No metadata'; }; + manager.hideModal = () => {}; + const load = url => manager.loadMetadataForImage({ getAttribute: () => url }); + return { manager, panel, requests, load }; +} +test('admin metadata and copy actions stay on the selected image', async () => { + const { manager, panel, requests, load } = fixture(); + const old = load('old.png'); + const current = load('current.png'); + requests.get('current.png').resolve({ imagePath: 'current.png' }); await current; + requests.get('old.png').resolve({ imagePath: 'old.png' }); await old; + assert.equal(panel.innerHTML, 'current.png'); + assert.equal(manager.currentMetadata.imagePath, 'current.png'); +}); +test('navigation clears old action data and late errors cannot replace current metadata', async () => { + const { manager, panel, requests, load } = fixture(); + manager.currentMetadata = { imagePath: 'previous.png' }; + const old = load('old.png'); + assert.equal(manager.currentMetadata, null); + const current = load('current.png'); + requests.get('current.png').resolve({ imagePath: 'current.png' }); await current; + requests.get('old.png').reject(new Error('old error')); await old; + assert.equal(panel.innerHTML, 'current.png'); +}); +for (const close of ['close gallery', 'detach sidebar']) { + test(`${close} prevents pending admin metadata from restoring copy state`, async () => { + const { manager, panel, requests, load } = fixture(); + const pending = load('old.png'); + if (close === 'close gallery') manager.closeGallery(); + else panel.isConnected = false; + requests.get('old.png').resolve({ imagePath: 'old.png' }); await pending; + assert.equal(manager.currentMetadata, null); + assert.notEqual(panel.innerHTML, 'old.png'); + }); +} diff --git a/tests/js/gallery-review.test.js b/tests/js/gallery-review.test.js new file mode 100644 index 0000000..96a2600 --- /dev/null +++ b/tests/js/gallery-review.test.js @@ -0,0 +1,88 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ImageHelpers = require('../../web/js/image-helpers.js'); +function deferred() { + let resolve; + const promise = new Promise(yes => { resolve = yes; }); + return { promise, resolve }; +} +function fixture() { + const elements = new Map(); + const requests = []; + const document = { addEventListener() {}, getElementById(id) { + if (!elements.has(id)) elements.set(id, {}); + return elements.get(id); + } }; + const context = vm.createContext({ document, window: {}, ImageHelpers, + escapeHtml: value => value, console: { error() {} }, + ApiClient: { resolveUrl: value => value, fetchUrl: (url, options) => { + const request = deferred(); + requests.push({ url, body: JSON.parse(options.body), ...request }); + return request.promise; + } }, + }); + const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context); + const manager = Object.create(Manager.prototype); + const notifications = []; + Object.assign(manager, { autoTagState: { reviewImages: [ + { id: 'digest', path: 'same.png', root_index: 1, url: '/same.png' }, + { id: 'other', path: 'next.png', root_index: 0, url: '/next.png' }, + ], reviewIndex: 0, currentTags: [], modelType: 'gguf' }, + renderReviewTags() {}, showNotification: (...args) => notifications.push(args), hideModal() {}, loadImages() {}, + }); + const reply = (index, data) => requests[index].resolve({ json: async () => data }); + return { manager, requests, reply, notifications }; +} +test('gallery review retains root identity and applies tags with the returned prompt ID', async () => { + const { manager, requests, reply } = fixture(); + const load = manager.loadNextReviewImage(); + assert.equal(requests[0].body.root_index, 1); + reply(0, { success: true, prompt_id: 42, tags: ['smile'] }); + await load; + manager.loadNextReviewImage = () => {}; + const apply = manager.applyReviewTags(); + assert.deepEqual(requests[1].body, { prompt_id: 42, tags: ['smile'] }); + reply(1, { success: true }); + await apply; +}); +test('unlinked images do not submit an invalid apply request or advance', async () => { + const { manager, requests, reply } = fixture(); + const load = manager.loadNextReviewImage(); + reply(0, { success: true, prompt_id: null, tags: ['smile'] }); + await load; + const apply = manager.applyReviewTags(); + if (requests[1]) reply(1, { success: false, error: "Unexpected apply" }); + await apply; + assert.equal(requests.length, 1); + assert.equal(manager.autoTagState.reviewIndex, 0); +}); +test('a failed apply keeps the current image available for retry', async () => { + const { manager, requests, reply } = fixture(); + const load = manager.loadNextReviewImage(); + reply(0, { success: true, prompt_id: 42, tags: ['smile'] }); + await load; + const apply = manager.applyReviewTags(); + reply(1, { success: false, error: 'Try again' }); + await apply; + assert.equal(manager.autoTagState.reviewIndex, 0); + assert.equal(requests.length, 2); +}); +test('late generation responses cannot restore tags from a skipped or closed image', async () => { + const { manager, reply } = fixture(); + const first = manager.loadNextReviewImage(); + manager.autoTagState.reviewIndex = 1; + const second = manager.loadNextReviewImage(); + reply(1, { success: true, prompt_id: 2, tags: ['current'] }); + await second; + reply(0, { success: true, prompt_id: 1, tags: ['stale'] }); + await first; + assert.deepEqual(Array.from(manager.autoTagState.currentTags), ['current']); + const third = manager.loadNextReviewImage(); + manager.cancelReview(); + reply(2, { success: true, prompt_id: 2, tags: ['closed'] }); + await third; + assert.equal(manager.autoTagState.currentTags.length, 0); +}); diff --git a/tests/js/tags-prompts-race.test.js b/tests/js/tags-prompts-race.test.js new file mode 100644 index 0000000..b06ce68 --- /dev/null +++ b/tests/js/tags-prompts-race.test.js @@ -0,0 +1,71 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +function fixture() { + const requests = []; + const grid = { replaceChildren() {}, appendChild() {} }; + const context = vm.createContext({ window: {}, URLSearchParams, console: { error() {} }, + requestAnimationFrame() {}, document: { getElementById: id => id === 'tagPromptsGrid' ? grid : null, + querySelector: () => null, createElement: () => ({ appendChild() {} }) }, + }); + const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/tags-page.js'), 'utf8') + '\nTagsPageManager;', context); + const manager = Object.create(Manager.prototype); + Object.assign(manager, { selectedTags: ['cats'], filterMode: 'and', promptLimit: 20, + promptOffset: 0, isLoadingPrompts: false, prompts: [], hasMorePrompts: false, + fetchJson(url) { return new Promise((resolve, reject) => requests.push({ url, resolve, reject })); }, + renderTagPrompts() {}, appendTagPrompts() {}, updatePromptCountBadge() {}, updateLoadMoreButton() {}, + }); + const reply = (index, text, more = false) => requests[index].resolve({ success: true, + prompts: [{ text }], pagination: { has_more: more, total: more ? 40 : 1 } }); + return { manager, requests, reply }; +} +test('changing a filter starts a new request and late results cannot overwrite it', async () => { + const { manager, requests, reply } = fixture(); + const old = manager.loadTagPrompts(); + manager.selectedTags = ['dogs']; + const current = manager.loadTagPrompts(); + assert.equal(requests.length, 2); + assert.match(requests[1].url, /dogs/); + reply(1, 'dog prompt'); await current; + reply(0, 'cat prompt', true); await old; + assert.equal(manager.prompts[0].text, 'dog prompt'); + assert.equal(manager.promptTotal, 1); + assert.equal(manager.hasMorePrompts, false); +}); +test('clearing selection invalidates pending results', async () => { + const { manager, reply } = fixture(); + const old = manager.loadTagPrompts(); + manager.selectedTags = []; + manager.clearPrompts(); + reply(0, 'cat prompt'); await old; + assert.equal(manager.prompts.length, 0); + assert.equal(manager.promptTotal, 0); + assert.equal(manager.isLoadingPrompts, false); +}); +test('a stale pagination response cannot append rows from the previous filter', async () => { + const { manager, requests, reply } = fixture(); + Object.assign(manager, { prompts: [{ text: 'cat first page' }], hasMorePrompts: true, promptOffset: 20 }); + const old = manager.loadMoreTagPrompts(); + manager.selectedTags = ['dogs']; + const current = manager.loadTagPrompts(); + assert.equal(requests.length, 2); + reply(1, 'dog prompt'); await current; + reply(0, 'cat second page', true); await old; + assert.equal(manager.prompts.length, 1); + assert.equal(manager.prompts[0].text, 'dog prompt'); + assert.equal(manager.promptOffset, 20); +}); +test('a stale failure cannot finish the new request or clear its loading state', async () => { + const { manager, requests, reply } = fixture(); + const old = manager.loadTagPrompts(); + manager.selectedTags = ['dogs']; + const current = manager.loadTagPrompts(); + assert.equal(requests.length, 2); + requests[0].reject(new Error('old failure')); await old; + assert.equal(manager.isLoadingPrompts, true); + reply(1, 'dog prompt'); await current; + assert.equal(manager.isLoadingPrompts, false); + assert.equal(manager.prompts[0].text, 'dog prompt'); +}); diff --git a/tests/test_api_autotag.py b/tests/test_api_autotag.py index 1259e65..8db42d9 100644 --- a/tests/test_api_autotag.py +++ b/tests/test_api_autotag.py @@ -1049,3 +1049,72 @@ class TestScanOutputDir(AutotagAPITestCase): if __name__ == "__main__": unittest.main() + + +class TestGalleryReviewContract(AutotagAPITestCase): + async def test_root_relative_gallery_targets_generate_and_apply_to_their_prompt( + self, + ): + second = self.tmp_root / "second-output" + second.mkdir() + roots = [self.output_dir, second] + self.api._get_all_output_dirs = lambda: roots + for index, root in enumerate(roots): + pid = self._save_prompt(f"root {index}") + self._link_image(pid, self._make_image(root, "same.png")) + response = await self._post_json( + "/prompt_manager/autotag/single", + {"path": "same.png", "root_index": index, "model_type": "gguf"}, + ) + self.assertEqual(response.status, 200) + data = await response.json() + self.assertEqual(data["prompt_id"], pid) + self.assertEqual( + self.service.generate_calls[-1][0], str((root / "same.png").resolve()) + ) + response = await self._post_json( + "/prompt_manager/autotag/apply", + {"prompt_id": data["prompt_id"], "tags": data["tags"]}, + ) + self.assertEqual(response.status, 200) + self.assertEqual( + set(self.api.db.get_prompt_by_id(pid)["tags"]), set(data["tags"]) + ) + + async def test_gallery_unlinked_image_returns_no_prompt_id(self): + self._make_image(self.output_dir) + response = await self._post_json( + "/prompt_manager/autotag/single", {"path": "img.png", "root_index": 0} + ) + self.assertEqual(response.status, 200) + self.assertIsNone((await response.json())["prompt_id"]) + + async def test_gallery_root_selection_rejects_invalid_roots_and_escapes(self): + self._make_image(self.output_dir) + for root, path, expected in [ + (9, "img.png", 403), + (-1, "img.png", 403), + (True, "img.png", 400), + ([], "img.png", 400), + (0, "../secret/img.png", 403), + (0, str(self.secret_dir / "img.png"), 403), + ]: + with self.subTest(root=root, path=path): + response = await self._post_json( + "/prompt_manager/autotag/single", {"path": path, "root_index": root} + ) + self.assertEqual(response.status, expected) + + async def test_gallery_root_selection_rejects_symlink_into_another_root(self): + second = self.tmp_root / "second-output" + second.mkdir() + target = self._make_image(second) + try: + (self.output_dir / "link.png").symlink_to(target) + except (OSError, NotImplementedError): + self.skipTest("symlinks unavailable") + self.api._get_all_output_dirs = lambda: [self.output_dir, second] + response = await self._post_json( + "/prompt_manager/autotag/single", {"path": "link.png", "root_index": 0} + ) + self.assertEqual(response.status, 403) diff --git a/tests/test_api_prompts.py b/tests/test_api_prompts.py index 57e96c9..e2a6c83 100644 --- a/tests/test_api_prompts.py +++ b/tests/test_api_prompts.py @@ -894,3 +894,49 @@ class TestBulkRoutes(RouteCoverageCase): if __name__ == "__main__": unittest.main() + + +class TestPromptEditHash(PromptAPITestCase): + async def test_edit_moves_the_hash_and_original_text_can_be_saved_again(self): + original_id = self._save_prompt("Original landscape") + response = await self.client.put( + f"/prompt_manager/prompts/{original_id}", json={"text": "Edited portrait"} + ) + self.assertEqual(response.status, 200) + row = self.api.db.get_prompt_by_id(original_id) + self.assertEqual(row["hash"], generate_prompt_hash("Edited portrait")) + self.assertIsNone( + self.api.db.get_prompt_by_hash(generate_prompt_hash("Original landscape")) + ) + response = await self.client.post( + "/prompt_manager/save", json={"text": "Original landscape"} + ) + self.assertEqual(response.status, 200) + self.assertNotEqual((await response.json())["prompt_id"], original_id) + response = await self.client.post( + "/prompt_manager/save", json={"text": "Edited portrait"} + ) + self.assertEqual((await response.json())["prompt_id"], original_id) + + async def test_duplicate_edit_returns_conflict_without_changing_either_prompt(self): + first = self._save_prompt("Original landscape", tags=["landscape"]) + second = self._save_prompt("Existing portrait", tags=["portrait"]) + before = [self.api.db.get_prompt_by_id(pid) for pid in (first, second)] + response = await self.client.put( + f"/prompt_manager/prompts/{first}", json={"text": " EXISTING PORTRAIT "} + ) + self.assertEqual(response.status, 409) + self.assertFalse((await response.json())["success"]) + self.assertEqual( + [self.api.db.get_prompt_by_id(pid) for pid in (first, second)], before + ) + + async def test_edit_with_same_normalized_hash_keeps_its_identity(self): + pid = self._save_prompt("Original landscape") + response = await self.client.put( + f"/prompt_manager/prompts/{pid}", json={"text": "ORIGINAL LANDSCAPE"} + ) + self.assertEqual(response.status, 200) + self.assertEqual( + self.api.db.get_prompt_by_id(pid)["text"], "ORIGINAL LANDSCAPE" + ) diff --git a/tools/vendor/tests/viewer.spec.mjs b/tools/vendor/tests/viewer.spec.mjs index f597288..ecd9317 100644 --- a/tools/vendor/tests/viewer.spec.mjs +++ b/tools/vendor/tests/viewer.spec.mjs @@ -206,3 +206,65 @@ test('metadata follows the selected image when responses finish out of order', a await page.evaluate(() => metadataRequests.get('http://viewer.test/original/2.svg')({ positivePrompt: 'Closed prompt' })); expect(await page.evaluate(() => gallery.currentMetadata)).toBeNull(); }); + +for (const mode of ['gallery', 'filmstrip']) { + test(`admin ${mode} metadata ignores stale responses and clears copy state on close`, async ({ page }) => { + const urlFor = index => mode === 'gallery' ? `http://viewer.test/prompt_manager/images/${index}/file` : `http://viewer.test/original/${index}.svg`; + const adminSource = source('web/js/admin.js'); + await page.addScriptTag({ content: adminSource.slice(0, adminSource.indexOf(' function startAdmin()')) + '\nwindow.TestAdmin = PromptAdmin;' }); + await page.evaluate(async mode => { + const admin = window.admin = Object.create(TestAdmin.prototype); + const images = Array.from({ length: 3 }, (_, i) => ({ id: i, filename: `${i}.svg`, url: `http://viewer.test/original/${i}.svg`, size: 100, created_at: 1 })); + window.pendingMetadata = new Map(); + Object.assign(admin, { prompts: [{ id: 1, text: 'test' }], + getImageUrl: image => image.url, loadFilmStripImages: async () => images, + extractImageMetadata: url => new Promise(resolve => pendingMetadata.set(url, resolve)), + }); + if (mode === 'gallery') { + admin.renderGallery(images, 1); + } else { + await admin.openFilmStripViewer(1, 0); + } + }, mode); + if (mode === 'gallery') { + await expect.poll(() => page.evaluate(() => Boolean(admin.galleryViewer))).toBe(true); + await page.evaluate(() => admin.galleryViewer.show()); + } + await expect.poll(() => page.evaluate(url => pendingMetadata.has(url), urlFor(0))).toBe(true); + await page.keyboard.press('ArrowRight'); + await expect.poll(() => page.evaluate(url => pendingMetadata.has(url), urlFor(1))).toBe(true); + await page.evaluate(url => pendingMetadata.get(url)({ positivePrompt: 'Selected prompt', negativePrompt: '', imagePath: '1.svg' }), urlFor(1)); + await expect(page.locator('#metadata-content')).toContainText('Selected prompt'); + await page.evaluate(url => pendingMetadata.get(url)({ positivePrompt: 'Stale prompt', negativePrompt: '', imagePath: '0.svg' }), urlFor(0)); + await expect(page.locator('#metadata-content')).not.toContainText('Stale prompt'); + expect(await page.evaluate(() => admin.currentMetadata.imagePath)).toBe('1.svg'); + await page.keyboard.press('ArrowRight'); + await expect.poll(() => page.evaluate(url => pendingMetadata.has(url), urlFor(2))).toBe(true); + await page.keyboard.press('Escape'); + await expect(page.locator('#metadata-sidebar')).toHaveCount(0); + await page.evaluate(url => pendingMetadata.get(url)({ positivePrompt: 'Closed prompt', negativePrompt: '', imagePath: '2.svg' }), urlFor(2)); + expect(await page.evaluate(() => admin.currentMetadata)).toBeNull(); + }); +} + +test('Tags selection stays current during a delayed prompt response', async ({ page }) => { + await page.addScriptTag({ path: join(root, 'web/js/tags-page.js') }); + await page.evaluate(() => { + const grid = document.createElement('div'); grid.id = 'tagPromptsGrid'; document.body.append(grid); + const manager = window.tagsManager = Object.create(TagsPageManager.prototype); + window.pendingTagPrompts = []; + Object.assign(manager, { selectedTags: ['cats'], filterMode: 'and', promptLimit: 20, prompts: [], + fetchJson: url => new Promise(resolve => pendingTagPrompts.push({ url, resolve })), + renderTagPrompts() { grid.textContent = this.prompts.map(prompt => prompt.text).join(','); }, + updatePromptCountBadge() {}, updateLoadMoreButton() {}, refreshPromptObserver() {}, + }); + manager.loadTagPrompts(); + manager.selectedTags = ['dogs']; + manager.loadTagPrompts(); + }); + expect(await page.evaluate(() => pendingTagPrompts.length)).toBe(2); + await page.evaluate(() => pendingTagPrompts[1].resolve({ success: true, prompts: [{ text: 'dog prompt' }], pagination: { total: 1, has_more: false } })); + await expect(page.locator('#tagPromptsGrid')).toHaveText('dog prompt'); + await page.evaluate(() => pendingTagPrompts[0].resolve({ success: true, prompts: [{ text: 'cat prompt' }], pagination: { total: 1, has_more: false } })); + await expect(page.locator('#tagPromptsGrid')).toHaveText('dog prompt'); +}); diff --git a/web/js/admin.js b/web/js/admin.js index a38bd6c..157abd3 100644 --- a/web/js/admin.js +++ b/web/js/admin.js @@ -1656,6 +1656,7 @@ // Initialize ViewerJS for the gallery after DOM is updated setTimeout(() => { if (this.galleryViewer) { + this.invalidateMetadataRequest(); this.galleryViewer.destroy(); } @@ -1699,6 +1700,7 @@ viewed: (event) => { this.loadMetadataForImage(event.detail.image); }, + hide: () => this.invalidateMetadataRequest(), hidden: () => { const sidebar = document.getElementById('metadata-sidebar'); if (sidebar) sidebar.remove(); @@ -1711,7 +1713,13 @@ + invalidateMetadataRequest() { + this.metadataRequest = null; + this.currentMetadata = null; + } + closeGallery() { + this.invalidateMetadataRequest(); this.hideModal("galleryModal"); } @@ -2896,6 +2904,11 @@ const metadataContent = document.getElementById('metadata-content'); if (!metadataContent) return; + const request = {}; + this.metadataRequest = request; + this.currentMetadata = null; + const isCurrent = () => this.metadataRequest === request && metadataContent.isConnected !== false; + // Show loading state metadataContent.innerHTML = `
@@ -2911,9 +2924,11 @@ try { const metadata = await this.extractImageMetadata(imageUrl); + if (!isCurrent()) return; this.currentMetadata = metadata; this.updateMetadataSidebar(metadata); } catch (error) { + if (!isCurrent()) return; console.error('Failed to load metadata:', error); metadataContent.innerHTML = `
@@ -3515,6 +3530,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; viewed: (event) => { this.loadMetadataForImage(event.detail.image); }, + hide: () => this.invalidateMetadataRequest(), hidden: () => { const sidebar = document.getElementById('metadata-sidebar'); if (sidebar) sidebar.remove(); diff --git a/web/js/gallery.js b/web/js/gallery.js index 78deff6..c94e736 100644 --- a/web/js/gallery.js +++ b/web/js/gallery.js @@ -3542,6 +3542,10 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; } async loadNextReviewImage() { + const request = {}; + this.reviewRequest = request; + this.autoTagState.currentTags = []; + this.autoTagState.reviewPromptId = null; if (this.autoTagState.reviewIndex >= this.autoTagState.reviewImages.length) { this.hideModal("autoTagReviewModal"); this.showNotification('Review complete!', 'success'); @@ -3567,7 +3571,9 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; const data = await response.json(); + if (this.reviewRequest !== request) return; if (data.success) { + this.autoTagState.reviewPromptId = data.prompt_id ?? null; this.autoTagState.currentTags = data.tags; this.renderReviewTags(); } else { @@ -3575,6 +3581,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; `
Error: ${escapeHtml(data.error)}
`; } } catch (error) { + if (this.reviewRequest !== request) return; console.error('Error generating tags:', error); document.getElementById('reviewTagsContainer').innerHTML = '
Failed to generate tags
'; @@ -3612,28 +3619,41 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; return; } - const image = this.autoTagState.reviewImages[this.autoTagState.reviewIndex]; + const promptId = this.autoTagState.reviewPromptId; + if (!promptId) { + this.showNotification('This image has no linked prompt. Scan the output folder first.', 'warning'); + return; + } + const request = this.reviewRequest; + if (this.reviewApplying === request) return; + this.reviewApplying = request; try { const response = await ApiClient.fetchUrl('/prompt_manager/autotag/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - image_path: image.path, + prompt_id: promptId, tags: this.autoTagState.currentTags }) }); const data = await response.json(); + if (this.reviewRequest !== request) return; if (data.success) { this.showNotification(`Applied ${this.autoTagState.currentTags.length} tags`, 'success'); } else { this.showNotification(data.error || 'Failed to apply tags', 'warning'); + return; } } catch (error) { + if (this.reviewRequest !== request) return; console.error('Error applying tags:', error); this.showNotification('Failed to apply tags', 'error'); + return; + } finally { + if (this.reviewApplying === request) this.reviewApplying = null; } this.autoTagState.reviewIndex++; @@ -3641,6 +3661,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; } cancelReview() { + this.reviewRequest = null; + this.autoTagState.reviewPromptId = null; this.autoTagState.reviewImages = []; this.autoTagState.reviewIndex = 0; this.autoTagState.currentTags = []; diff --git a/web/js/image-helpers.js b/web/js/image-helpers.js index 70f31ee..fc019d7 100644 --- a/web/js/image-helpers.js +++ b/web/js/image-helpers.js @@ -79,6 +79,9 @@ typeof entry.path === "string" && entry.path && !Number.isInteger(entry.id) ? { path: entry.path } : { image_id: entry.id }; + if ("path" in target && Number.isInteger(entry.root_index)) { + target.root_index = entry.root_index; + } const modelType = String(settings.modelType || ""); const params = modelType.startsWith("wd14") ? { diff --git a/web/js/tags-page.js b/web/js/tags-page.js index f3d7f9f..985a82f 100644 --- a/web/js/tags-page.js +++ b/web/js/tags-page.js @@ -583,8 +583,12 @@ class TagsPageManager { // ── Prompts Display ─────────────────────────────────────── async loadTagPrompts() { - if (this.isLoadingPrompts) return; - if (this.selectedTags.length === 0) return; + if (this.selectedTags.length === 0) { + this.clearPrompts(); + return; + } + const request = {}; + this.promptRequest = request; this.isLoadingPrompts = true; this.promptOffset = 0; @@ -622,6 +626,7 @@ class TagsPageManager { } const data = await this.fetchJson(url); + if (this.promptRequest !== request) return; if (data.success) { this.prompts = data.prompts; @@ -634,6 +639,7 @@ class TagsPageManager { requestAnimationFrame(() => this.refreshPromptObserver()); } } catch (err) { + if (this.promptRequest !== request) return; console.error('Failed to load tag prompts:', err); if (grid) { grid.replaceChildren(); @@ -643,12 +649,14 @@ class TagsPageManager { grid.appendChild(errDiv); } } finally { - this.isLoadingPrompts = false; + if (this.promptRequest === request) this.isLoadingPrompts = false; } } async loadMoreTagPrompts() { if (this.isLoadingPrompts || !this.hasMorePrompts) return; + const request = {}; + this.promptRequest = request; this.isLoadingPrompts = true; const spinner = document.querySelector('#tagPromptsLoader .prompts-loader-spinner'); @@ -675,6 +683,7 @@ class TagsPageManager { } const data = await this.fetchJson(url); + if (this.promptRequest !== request) return; if (data.success) { const startIndex = this.prompts.length; @@ -685,13 +694,16 @@ class TagsPageManager { this.updatePromptCountBadge(); } } catch (err) { + if (this.promptRequest !== request) return; console.error('Failed to load more prompts:', err); this.hasMorePrompts = false; } finally { - this.isLoadingPrompts = false; - if (spinner) spinner.classList.add('hidden'); - this.updateLoadMoreButton(); - requestAnimationFrame(() => this.refreshPromptObserver()); + if (this.promptRequest === request) { + this.isLoadingPrompts = false; + if (spinner) spinner.classList.add('hidden'); + this.updateLoadMoreButton(); + requestAnimationFrame(() => this.refreshPromptObserver()); + } } } @@ -987,6 +999,9 @@ class TagsPageManager { } clearPrompts() { + this.promptRequest = null; + this.isLoadingPrompts = false; + this.promptOffset = 0; this.prompts = []; this.promptTotal = 0; this.hasMorePrompts = false; @@ -1091,6 +1106,7 @@ class TagsPageManager { } destroy() { + this.promptRequest = null; this.destroyInfiniteScroll(); this.hideContextMenu(); clearTimeout(this.searchTimer);