Closing this window stops tagging. Tags already added are kept. Prompt Manager's Activity page shows the time left.
@@ -1332,7 +1332,7 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr
-
Image Already Tagged
+
Image Already Auto-tagged
![Preview]()
@@ -1345,7 +1345,7 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr
-
This image already has tags. Would you like to generate new tags?
+
A tagging model already tagged this image. Generate new tags?
+
Closing this window stops tagging. Tags already added are kept. Prompt Manager's Activity page shows the time left.
diff --git a/web/js/admin.js b/web/js/admin.js
index 200e462..e48c098 100644
--- a/web/js/admin.js
+++ b/web/js/admin.js
@@ -3801,7 +3801,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
} else if (data.type === 'complete') {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
- this.showNotification(`Auto tagging complete! Applied tags to ${data.tagged || 0} prompts.`, 'success');
+ this.showNotification(ImageHelpers.autotagCompleteMessage(data.tagged), 'success');
this.refreshList(); // Refresh the prompt list
} else if (data.type === 'error') {
this.autoTagState.eventSource.close();
@@ -3819,7 +3819,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
console.error('EventSource readyState:', this.autoTagState.eventSource.readyState);
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
- this.showNotification('Connection lost during auto tagging', 'error');
+ this.showNotification((error && error.serverMessage) || 'Connection lost during auto tagging', 'error');
};
} catch (error) {
console.error('Auto tag error:', error);
@@ -3894,10 +3894,14 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
}
- // Check if tags array has "real" tags (excluding auto-scanned and prepend:* metadata)
- getRealTags(tags) {
- if (!tags || !Array.isArray(tags)) return [];
- return tags.filter(tag => tag !== 'auto-scanned' && !tag.startsWith('prepend:') && !tag.startsWith('append:'));
+ /** POST /autotag/single for `image` (#379: `existing` "skip" answers already_tagged for an auto-tagged image). */
+ async requestReviewTags(image, existing) {
+ const response = await ApiClient.fetchUrl('/prompt_manager/autotag/single', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(ImageHelpers.autotagSingleBody(image, { ...this.autoTagState, existing }))
+ });
+ return response.json();
}
// Show the re-tag confirmation modal
@@ -3949,26 +3953,48 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
// always provides a servable url; never rebuild it from the path.
const imageUrl = this.getImageUrl(image);
- // Check if image already has real tags (excluding auto-scanned)
- const realTags = this.getRealTags(image.prompt_tags);
+ this.autoTagState.currentImage = image;
+ this.autoTagState.currentSha256 = null;
+ this.autoTagState.tagsExpanded = false; // Reset accordion for new image
- if (realTags.length > 0) {
- // Skip if "Skip All Tagged" was selected
- if (this.autoTagState.skipAllTagged) {
- this.autoTagState.reviewIndex++;
- return this.loadNextReviewImage();
+ let data;
+ try {
+ // Each image is first asked with "skip": an image a tagging model already tagged answers
+ // already_tagged (its image tags, never its prompt's) without running the model (#379).
+ data = await this.requestReviewTags(image, 'skip');
+ if (data.success && data.already_tagged) {
+ if (this.autoTagState.skipAllTagged) {
+ this.autoTagState.reviewIndex++;
+ return this.loadNextReviewImage();
+ }
+ const choice = await this.showRetagConfirmation(image, imageUrl, data.image_tags || []);
+ if (choice === 'skip' || choice === 'skipAll') {
+ this.autoTagState.reviewIndex++;
+ return this.loadNextReviewImage();
+ }
+ // choice === 'retag': generate new tags for it
+ this.showReviewImage(image, imageUrl);
+ data = await this.requestReviewTags(image, 'merge');
+ } else {
+ this.showReviewImage(image, imageUrl);
}
-
- // Show confirmation modal
- const choice = await this.showRetagConfirmation(image, imageUrl, realTags);
-
- if (choice === 'skip' || choice === 'skipAll') {
- this.autoTagState.reviewIndex++;
- return this.loadNextReviewImage();
+ if (data.success) {
+ this.autoTagState.currentTags = data.tags;
+ this.autoTagState.currentSha256 = data.sha256 || null;
+ this.renderReviewTags();
+ } else {
+ document.getElementById('reviewTagsVisible').innerHTML =
+ `
Error: ${escapeHtml(data.error)}
`;
}
- // choice === 'retag' - continue with tagging
+ } catch (error) {
+ console.error('Error generating tags:', error);
+ this.showReviewImage(image, imageUrl);
+ document.getElementById('reviewTagsVisible').innerHTML =
+ '
Failed to generate tags
';
}
+ }
+ showReviewImage(image, imageUrl) {
document.getElementById('reviewImage').src = imageUrl;
// The record's file name, else the last segment of its path (#328: `filename` was never declared).
const filename = image.filename || String(image.image_path || image.path || '').split(/[\\/]/).pop();
@@ -3977,37 +4003,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
document.getElementById('reviewTagsHidden').innerHTML = '';
document.getElementById('reviewTagsAccordion').classList.add('hidden');
document.getElementById('reviewTagsToggle').classList.add('hidden');
-
- // Store prompt_id from database for applying tags later
- this.autoTagState.currentPromptId = image.prompt_id;
- this.autoTagState.tagsExpanded = false; // Reset accordion for new image
-
- try {
- // Review rows come from /images/all (database ids), so this resolves to image_id
- const requestBody = ImageHelpers.autotagSingleBody(image, this.autoTagState);
-
- const response = await ApiClient.fetchUrl('/prompt_manager/autotag/single', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(requestBody)
- });
-
- const data = await response.json();
-
- if (data.success) {
- this.autoTagState.currentTags = data.tags;
- // Use prompt_id from database, fallback to API response
- this.autoTagState.currentPromptId = image.prompt_id || data.prompt_id;
- this.renderReviewTags();
- } else {
- document.getElementById('reviewTagsVisible').innerHTML =
- `
Error: ${escapeHtml(data.error)}
`;
- }
- } catch (error) {
- console.error('Error generating tags:', error);
- document.getElementById('reviewTagsVisible').innerHTML =
- '
Failed to generate tags
';
- }
}
/** /prompt_manager/images/all is capped per call; collect every page. */
@@ -4126,15 +4121,9 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
async applyReviewTags() {
- if (this.autoTagState.currentTags.length === 0) {
- this.skipReviewImage();
- return;
- }
-
- const promptId = this.autoTagState.currentPromptId;
-
- if (!promptId) {
- this.showNotification('No linked prompt found for this image', 'warning');
+ // #379: the reviewed tags go to this image (linked or not), never its prompt; an empty
+ // list still marks the image as reviewed by a tagging model.
+ if (!this.autoTagState.currentSha256) {
this.skipReviewImage();
return;
}
@@ -4143,10 +4132,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- prompt_id: promptId,
- tags: this.autoTagState.currentTags
- })
+ body: JSON.stringify(ImageHelpers.autotagApplyBody(
+ this.autoTagState.currentImage, this.autoTagState.currentSha256, this.autoTagState.currentTags))
});
const data = await response.json();
diff --git a/web/js/gallery.js b/web/js/gallery.js
index 51838c6..0c4e90d 100644
--- a/web/js/gallery.js
+++ b/web/js/gallery.js
@@ -3560,19 +3560,24 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
} else if (data.type === 'complete') {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
- this.showNotification(`Auto tagging complete! Applied tags to ${data.tagged || 0} prompts.`, 'success');
+ this.showNotification(ImageHelpers.autotagCompleteMessage(data.tagged), 'success');
this.loadImages(); // Refresh the gallery
} else if (data.type === 'error') {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification(`Auto tag error: ${data.message}`, 'error');
+ } else if (data.type === 'cancelled') {
+ // Cancelled in Prompt Manager's Activity (#379): as its own cancel.
+ this.autoTagState.eventSource.close();
+ this.hideModal("autoTagProgressModal");
+ this.showNotification('Auto tagging cancelled', 'info');
}
};
- this.autoTagState.eventSource.onerror = () => {
+ this.autoTagState.eventSource.onerror = (error) => {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
- this.showNotification('Connection lost during auto tagging', 'error');
+ this.showNotification((error && error.serverMessage) || 'Connection lost during auto tagging', 'error');
};
} catch (error) {
console.error('Auto tag error:', error);
@@ -3629,6 +3634,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
this.reviewRequest = request;
this.autoTagState.currentTags = [];
this.autoTagState.reviewPromptId = null;
+ this.autoTagState.reviewImage = null;
+ this.autoTagState.reviewSha256 = null;
if (this.autoTagState.reviewIndex >= this.autoTagState.reviewImages.length) {
this.hideModal("autoTagReviewModal");
this.showNotification('Review complete!', 'success');
@@ -3643,8 +3650,9 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
document.getElementById('reviewTagsContainer').innerHTML = '
Generating tags...
';
try {
- // Gallery entries come from /images/output: id is a path digest, so address by path
- const requestBody = ImageHelpers.autotagSingleBody(image, this.autoTagState);
+ // Gallery entries come from /images/output: id is a path digest, so address by path.
+ // The gallery review always suggests (it never skipped tagged images, #379).
+ const requestBody = ImageHelpers.autotagSingleBody(image, { ...this.autoTagState, existing: 'merge' });
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/single', {
method: 'POST',
@@ -3657,6 +3665,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
if (this.reviewRequest !== request) return;
if (data.success) {
this.autoTagState.reviewPromptId = data.prompt_id ?? null;
+ this.autoTagState.reviewImage = image;
+ this.autoTagState.reviewSha256 = data.sha256 || null;
this.autoTagState.currentTags = data.tags;
this.renderReviewTags();
} else {
@@ -3697,16 +3707,12 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
async applyReviewTags() {
- if (this.autoTagState.currentTags.length === 0) {
+ // #379: the reviewed tags go to this image (linked or not), never its prompt; an empty
+ // list still marks the image as reviewed by a tagging model.
+ if (!this.autoTagState.reviewSha256) {
this.skipReviewImage();
return;
}
-
- 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;
@@ -3715,10 +3721,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- prompt_id: promptId,
- tags: this.autoTagState.currentTags
- })
+ body: JSON.stringify(ImageHelpers.autotagApplyBody(
+ this.autoTagState.reviewImage, this.autoTagState.reviewSha256, this.autoTagState.currentTags))
});
const data = await response.json();
diff --git a/web/js/image-helpers.js b/web/js/image-helpers.js
index fc019d7..7ab5047 100644
--- a/web/js/image-helpers.js
+++ b/web/js/image-helpers.js
@@ -70,10 +70,28 @@
* path digest as id, which would 404, so those are addressed by path.
*
* @param {{id?: unknown, path?: unknown}} image
- * @param {{modelType: string, generalThreshold?: number, characterThreshold?: number, prompt?: string}} settings
+ * `existing` (#379): "skip" answers `already_tagged` for an image a model
+ * already tagged; anything else is "merge" (always suggest).
+ *
+ * @param {{id?: unknown, path?: unknown}} image
+ * @param {{modelType: string, generalThreshold?: number, characterThreshold?: number, prompt?: string, existing?: string}} settings
* @returns {object} JSON-serialisable request body
*/
function autotagSingleBody(image, settings) {
+ const target = autotagTarget(image);
+ const existing = settings.existing === "skip" ? "skip" : "merge";
+ const modelType = String(settings.modelType || "");
+ const params = modelType.startsWith("wd14")
+ ? {
+ general_threshold: settings.generalThreshold,
+ character_threshold: settings.characterThreshold,
+ }
+ : { prompt: settings.prompt };
+ return { ...target, model_type: modelType, existing, ...params };
+ }
+
+ /** The image a legacy single/apply body names: its row id, or its path (and root). */
+ function autotagTarget(image) {
const entry = image || {};
const target =
typeof entry.path === "string" && entry.path && !Number.isInteger(entry.id)
@@ -82,14 +100,22 @@
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")
- ? {
- general_threshold: settings.generalThreshold,
- character_threshold: settings.characterThreshold,
- }
- : { prompt: settings.prompt };
- return { ...target, model_type: modelType, ...params };
+ return target;
+ }
+
+ /**
+ * Body for POST /prompt_manager/autotag/apply (#379): the image, the
+ * `sha256` /autotag/single answered and the reviewed tags. The tags go to
+ * that image only, never to its prompt; an empty list marks it reviewed.
+ */
+ function autotagApplyBody(image, sha256, tags) {
+ return { ...autotagTarget(image), sha256: String(sha256 || ""), tags: Array.isArray(tags) ? [...tags] : [] };
+ }
+
+ /** The legacy batch's completion toast (#379): images, never prompts. */
+ function autotagCompleteMessage(tagged) {
+ const count = Number.isSafeInteger(tagged) && tagged > 0 ? tagged : 0;
+ return `Auto tagging complete! Tagged ${count} ${count === 1 ? "image" : "images"}.`;
}
/**
@@ -122,7 +148,15 @@
}
}
- const api = { formatImageCaption, workflowDocumentHtml, autotagSingleBody, fallbackImageSource, metadataLookupUrl };
+ const api = {
+ formatImageCaption,
+ workflowDocumentHtml,
+ autotagSingleBody,
+ autotagApplyBody,
+ autotagCompleteMessage,
+ fallbackImageSource,
+ metadataLookupUrl,
+ };
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.ImageHelpers = api;
})(typeof window !== "undefined" ? window : globalThis);
diff --git a/web/js/sse-stream.js b/web/js/sse-stream.js
index 9ee98ba..ee3372d 100644
--- a/web/js/sse-stream.js
+++ b/web/js/sse-stream.js
@@ -59,6 +59,20 @@
* @returns {{onmessage: Function|null, onerror: Function|null, onclose: Function|null,
* readyState: number, close: Function, CONNECTING: number, OPEN: number, CLOSED: number}}
*/
+ // A non-2xx answer as an Error carrying its status and, when the body is
+ // the API's JSON {error}, that sentence as serverMessage for the page.
+ async function httpError(response) {
+ const error = new Error(`HTTP ${response.status}`);
+ error.status = response.status;
+ try {
+ const data = await response.json();
+ if (data && typeof data.error === "string" && data.error) error.serverMessage = data.error;
+ } catch (_) {
+ // Not JSON: the status alone.
+ }
+ return error;
+ }
+
function connect(url, { method = "GET", body, fetchImpl } = {}) {
const doFetch = fetchImpl || ((...args) => globalThis.fetch(...args));
const controller = new AbortController();
@@ -103,7 +117,7 @@
if (body !== undefined) init.body = body;
const requestUrl = root.ApiClient ? root.ApiClient.resolveUrl(url) : url;
const response = await doFetch(requestUrl, init);
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ if (!response.ok) throw await httpError(response);
if (source.readyState === CLOSED) return;
source.readyState = OPEN;