fix: align gallery auto-tag review with scoped image APIs

Fixes #177
This commit is contained in:
Vito Sansevero
2026-10-16 05:48:24 -07:00
parent c78788579e
commit 80ba034fbf
5 changed files with 198 additions and 2 deletions
+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)
+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);
});
+67
View File
@@ -1049,3 +1049,70 @@ 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"))
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)
+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")
? {