Compare commits

...
13 changed files with 482 additions and 11 deletions
+8 -2
View File
@@ -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,
),
+16
View File
@@ -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)
+6
View File
@@ -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
+50
View File
@@ -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');
});
}
+88
View File
@@ -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);
});
+71
View File
@@ -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');
});
+69
View File
@@ -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)
+46
View File
@@ -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"
)
+62
View File
@@ -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');
});
+16
View File
@@ -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 = `
<div class="metadata-loading">
@@ -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 = `
<div class="text-center text-pm-error py-8">
@@ -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();
+24 -2
View File
@@ -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'}`;
`<div class="text-pm-error">Error: ${escapeHtml(data.error)}</div>`;
}
} catch (error) {
if (this.reviewRequest !== request) return;
console.error('Error generating tags:', error);
document.getElementById('reviewTagsContainer').innerHTML =
'<div class="text-pm-error">Failed to generate tags</div>';
@@ -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 = [];
+3
View File
@@ -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")
? {
+23 -7
View File
@@ -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);