feat(tagging): skipped tags hide matching suggestions (#431)

A Skipped tags list in System > Auto-tagging (D202) hides every suggested
tag whose words contain an entry's words, on every suggestion path
(Tag image, Auto-tag selected, the System batch, the legacy single and
batch routes), before the limit. Jobs snapshot the list at their start.
A read-only preview route counts what a list hides among the library's
tags and the downloaded WD14 label lists. The draft store compares list
and object drafts by value (sameDraft).
This commit is contained in:
Vito Sansevero
2026-10-31 16:43:15 -07:00
parent c3c75c3369
commit a20e59f674
32 changed files with 1837 additions and 58 deletions
+11 -3
View File
@@ -257,11 +257,18 @@ function initialState(source, preset) {
image: null, savedTags: [], tagRevision: 0, currentTags: null, manual: '',
model: preset?.model ?? MODEL_OPTIONS[0].id, options: {...DEFAULTS, ...preset?.options}, generated: false, suggestions: [],
suggestionsText: COPY.modelChanged, statusText: '', generateSupported: false, generateReason: null, toast: null,
skippedCount: 0,
// Bumped whenever the controller itself rewrites the option fields or the manual draft, never while typing.
optionsRevision: 0, draftRevision: 0,
};
}
/** #431: how many suggestions the saved Skipped tags hid, after the chip hint ('' when none). */
export function skippedNote(count) {
if (!Number.isSafeInteger(count) || count <= 0) return '';
return count === 1 ? ' 1 skipped tag was not suggested.' : ` ${count.toLocaleString('en-US')} skipped tags were not suggested.`;
}
/** Derived labels and permissions; never stored independently of the fields they follow. */
function derive(state) {
const editable = EDITABLE.has(state.phase);
@@ -269,7 +276,7 @@ function derive(state) {
...state,
primaryLabel: state.generated ? 'Approve selected tags' : 'Save manual tags',
countText: state.generated ? `${state.suggestions.length} ${noun(state.suggestions.length, 'suggestion')}` : 'Not generated',
chipHint: state.suggestions.length ? COPY.chipHint : '',
chipHint: state.suggestions.length ? COPY.chipHint + skippedNote(state.skippedCount) : '',
canSave: editable,
canGenerate: editable && state.generateSupported,
canRetry: RETRYABLE.has(state.phase),
@@ -445,13 +452,14 @@ export function createImageTaggingController({api, target, source = 'images', on
const options = requestOptions(model, state.options);
const stale = () => closed || serial !== generateSerial;
set({phase: 'generating', statusText: COPY.generating});
let rows;
let rows, skippedCount;
try {
const value = await api.post(SUGGEST_URL, {
target: {...imageTarget}, capture_token: capture.token, expected_epoch: epoch, model_type: model, options,
}, {signal: controller.signal});
if (stale()) return false;
rows = validateSuggestions(value, options.suggestion_limit);
skippedCount = Number.isSafeInteger(value.skipped) && value.skipped > 0 ? value.skipped : 0;
} catch (error) {
if (stale()) return false;
const phase = isApiError(error) && REOPEN[error.code];
@@ -460,7 +468,7 @@ export function createImageTaggingController({api, target, source = 'images', on
}
generateAbort = null;
set({
phase: 'ready', generated: true, suggestions: rows, suggestionsText: rows.length ? '' : COPY.empty,
phase: 'ready', generated: true, suggestions: rows, suggestionsText: rows.length ? '' : COPY.empty, skippedCount,
statusText: `Suggestions from ${modelOf(model).label}. Approve tags to save.`,
});
return true;
+246
View File
@@ -0,0 +1,246 @@
/**
* System › Auto-tagging "Skipped tags" (#431, D202): words whose suggested tags are never offered, on every suggestion
* path (the server filters; hand-typed and stored tags are never touched).
*
* A `.setting-block` after "Suggestions per image": the label and description, an "Add a word or words" input
* (`#tag-skipped-input`) with Add (Enter adds too), the entries as removable chips in the Tag image dialog's D83 chip
* markup, and a preview line (`role="status"`) that says how many known tags the list hides
* (`POST /prompt_manager/autotag/skipped-tags/preview`: the library's tags plus the downloaded WD14 label lists). The
* block carries `data-setting="tag-skipped"` and `data-key="autotag_skipped_tags"`, so a refused save highlights it.
* The list is the host draft `autotag_skipped_tags` (an array), saved by the one Save row. The client checks repeat the
* server's rules (the server is the authority): an empty, too long, comma or duplicate entry is not added and the line
* under the input says why; the list holds at most 200 entries.
*/
import { createButton } from '../shared/toolkit/core.js';
export const SKIPPED_KEY = 'autotag_skipped_tags';
export const PREVIEW_URL = '/prompt_manager/autotag/skipped-tags/preview';
export const MAX_ENTRIES = 200;
export const MAX_ENTRY_CHARS = 50;
export const PREVIEW_DELAY_MS = 300;
export const SAMPLE_LIMIT = 12;
export const SKIPPED_COPY = Object.freeze({
label: 'Skipped tags',
description: 'Suggested tags that contain one of these words are hidden. Words are separated by spaces, _ or -; '
+ 'other characters such as ( ) belong to the word.',
placeholder: 'Add a word or words',
add: 'Add',
chips: 'Skipped tags',
empty: 'Enter a word.',
comma: 'No commas or line breaks.',
long: `At most ${MAX_ENTRY_CHARS} characters.`,
duplicate: 'Already in the list.',
full: `The list is full (${MAX_ENTRIES}).`,
none: 'No tags are skipped.',
checking: 'Checking…',
failed: 'The preview could not be read.',
unchanged: 'Tags already on images are not changed.',
libraryOnly: 'Checked against library tags only; no WD14 label list is downloaded.',
hidesNothing: 'Hides no known tag',
});
const plural = (count, word) => `${count.toLocaleString('en-US')} ${word}${count === 1 ? '' : 's'}`;
/** The words of an entry or a tag as the server splits them: NFC, lower case, split on space, `_` and `-`. */
export const entryWords = text => String(text ?? '').normalize('NFC').toLowerCase().split(/[ _-]+/).filter(Boolean);
const entryKey = text => entryWords(text).join(' ');
/** Why `raw` cannot be added to `entries` (the line under the input), or '' when it can. */
export function entryProblem(raw, entries) {
const entry = String(raw ?? '').trim();
if (!entryWords(entry).length) return SKIPPED_COPY.empty;
// Commas and control characters (a pasted line break), as the server refuses them.
if (/[,\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(entry)) return SKIPPED_COPY.comma;
if ([...entry].length > MAX_ENTRY_CHARS) return SKIPPED_COPY.long;
const key = entryKey(entry);
if (entries.some(item => entryKey(item) === key)) return SKIPPED_COPY.duplicate;
if (entries.length >= MAX_ENTRIES) return SKIPPED_COPY.full;
return '';
}
/** A chip's title and count text for an entry the last preview counted (`count` undefined: not counted yet). */
export function chipCount(count) {
if (!Number.isSafeInteger(count)) return null;
return count ? { title: `Hides ${plural(count, 'tag')}`, text: count.toLocaleString('en-US') }
: { title: SKIPPED_COPY.hidesNothing, text: 'none' };
}
/**
* The preview line for a preview answer (`{count, sample, sources}`): the bold lead "Hides N tags from suggestions",
* then the sample (": a, b and K more. ") and the closing sentences. Returns `{lead, rest}`.
*/
export function previewText(result) {
const count = Number.isSafeInteger(result?.count) && result.count > 0 ? result.count : 0;
const sample = Array.isArray(result?.sample) ? result.sample.filter(name => typeof name === 'string') : [];
const more = count - sample.length;
const listed = count && sample.length ? `: ${sample.join(', ')}${more > 0 ? ` and ${more.toLocaleString('en-US')} more` : ''}. ` : '. ';
const labels = Array.isArray(result?.sources?.wd14) && result.sources.wd14.length > 0;
return {
lead: `Hides ${plural(count, 'tag')} from suggestions`,
rest: `${listed}${labels ? '' : `${SKIPPED_COPY.libraryOnly} `}${SKIPPED_COPY.unchanged}`,
};
}
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/**
* Build the block; `drafts` is the System draft store, `api` the workspace client. Returns `{node, repaint}`: System
* calls `repaint` whenever the drafts change (the chips follow the drafted list, and a changed list is previewed
* `PREVIEW_DELAY_MS` after the last change, one request at a time: a newer list aborts the older request).
*/
export function skippedTagsBlock({ api, scope, drafts, delay = PREVIEW_DELAY_MS }) {
const block = el('div', undefined, 'setting-block');
block.dataset.setting = 'tag-skipped';
block.dataset.key = SKIPPED_KEY;
const text = el('div', undefined, 'setting-block-text');
const label = el('label', SKIPPED_COPY.label);
label.htmlFor = 'tag-skipped-input';
text.append(label, el('p', SKIPPED_COPY.description));
const addRow = el('div', undefined, 'skip-add');
const input = el('input');
input.id = 'tag-skipped-input';
input.autocomplete = 'off';
input.placeholder = SKIPPED_COPY.placeholder;
input.maxLength = MAX_ENTRY_CHARS + 10;
input.setAttribute('aria-describedby', 'tag-skipped-problem');
const add = createButton(SKIPPED_COPY.add);
add.id = 'tag-skipped-add';
addRow.append(input, add);
const problem = el('p', '', 'small skip-problem');
problem.id = 'tag-skipped-problem';
problem.setAttribute('role', 'alert');
const chips = el('div', undefined, 'tags skip-chips');
chips.setAttribute('aria-label', SKIPPED_COPY.chips);
const preview = el('p', '', 'small skip-preview');
preview.setAttribute('role', 'status');
preview.setAttribute('aria-live', 'polite');
block.append(text, addRow, problem, chips, preview);
const entries = () => {
const value = drafts.get('host', SKIPPED_KEY);
return Array.isArray(value) ? value : null;
};
let counts = new Map();
let shown = null; // the list the chips show (JSON)
let pending = null; // the list the preview line is (or is being) made for (JSON)
let timer = null;
let request = null;
scope.cleanup(() => { clearTimeout(timer); request?.abort(); });
const say = message => {
problem.textContent = message;
if (message) input.setAttribute('aria-invalid', 'true');
else input.removeAttribute('aria-invalid');
};
const setList = next => drafts.set('host', SKIPPED_KEY, next);
function addEntry() {
const list = entries();
if (!list) return;
const message = entryProblem(input.value, list);
say(message);
if (message) return;
const entry = input.value.trim();
input.value = '';
setList([...list, entry]);
}
function removeEntry(index) {
const list = entries();
if (!list) return;
const buttons = [...chips.querySelectorAll?.('button') ?? []];
const focused = buttons.indexOf(document.activeElement);
setList(list.filter((_entry, at) => at !== index));
// The removed chip's focus moves to its neighbour's ×, else to the input.
if (focused >= 0) {
const left = [...chips.querySelectorAll?.('button') ?? []];
(left[Math.min(focused, left.length - 1)] ?? input).focus?.();
}
}
function renderChips(list) {
chips.replaceChildren(...list.map((entry, index) => {
const count = chipCount(counts.get(entry));
const chip = el('span', undefined, `chip tag-suggestion${count?.text === 'none' ? ' none' : ''}`);
if (count) chip.title = count.title;
chip.append(el('span', entry));
if (count) chip.append(el('span', count.text, 'tag-confidence'));
const remove = createButton('×');
remove.setAttribute('aria-label', `Remove ${entry}`);
scope.listen(remove, 'click', () => removeEntry(index));
chip.append(remove);
return chip;
}));
}
function showPreview(result) {
const { lead, rest } = previewText(result);
preview.replaceChildren(el('b', lead), rest);
}
async function runPreview(list) {
request?.abort();
const controller = new AbortController();
request = controller;
try {
const result = await api.post(PREVIEW_URL, { entries: list }, { signal: controller.signal });
if (controller.signal.aborted || scope.signal.aborted) return;
counts = new Map((Array.isArray(result?.per_entry) ? result.per_entry : [])
.filter(row => typeof row?.entry === 'string').map(row => [row.entry, row.count]));
showPreview(result);
renderChips(entries() ?? list);
} catch {
if (controller.signal.aborted || scope.signal.aborted) return;
preview.textContent = SKIPPED_COPY.failed;
} finally {
if (request === controller) request = null;
}
}
function schedule(list) {
clearTimeout(timer);
request?.abort();
request = null;
if (!list.length) {
counts = new Map();
preview.textContent = SKIPPED_COPY.none;
return;
}
preview.textContent = SKIPPED_COPY.checking;
timer = setTimeout(() => runPreview(list), delay);
}
function repaint() {
const list = entries();
input.disabled = add.disabled = list === null;
if (list === null) return;
const full = list.length >= MAX_ENTRIES;
add.disabled = full;
if (full && !problem.textContent) say(SKIPPED_COPY.full);
if (!full && problem.textContent === SKIPPED_COPY.full) say('');
const key = JSON.stringify(list);
if (key !== shown) {
shown = key;
renderChips(list);
}
if (key !== pending) {
pending = key;
schedule(list);
}
}
scope.listen(add, 'click', addEntry);
scope.listen(input, 'keydown', event => {
if (event.key !== 'Enter') return;
event.preventDefault?.();
addEntry();
});
scope.listen(input, 'input', () => { if (problem.textContent !== SKIPPED_COPY.full) say(''); });
return { node: block, repaint };
}
+19 -3
View File
@@ -76,6 +76,22 @@ export function statusText(outcome) {
return saved.join(' ') || SETTINGS_STATUS.clean;
}
const plain = value => value !== null && typeof value === 'object'
&& (Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype);
/** JSON with every object's keys sorted, so key order never makes two values differ. */
const canonical = value => JSON.stringify(value, (key, item) => (item && typeof item === 'object' && !Array.isArray(item)
? Object.fromEntries(Object.keys(item).sort().map(name => [name, item[name]])) : item));
/**
* #431: whether a draft equals a saved value: `Object.is`, or both are arrays or plain objects with the same JSON (keys
* sorted). A list setting (Skipped tags) whose added entry was removed again is clean, not a new array that differs.
*/
export function sameDraft(a, b) {
if (Object.is(a, b)) return true;
if (!plain(a) || !plain(b) || Array.isArray(a) !== Array.isArray(b)) return false;
return canonical(a) === canonical(b);
}
function requireDomain(domain) {
if (!DRAFT_DOMAINS.includes(domain)) throw new TypeError(`Unknown settings domain: ${domain}`);
}
@@ -112,7 +128,7 @@ export function createSystemDrafts(adapters) {
values = await (domain === 'browser' ? adapters.readBrowser() : adapters.readRuntime());
}
baseline[domain] = { ...(values ?? {}) };
for (const [key, value] of drafts[domain]) if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key);
for (const [key, value] of drafts[domain]) if (sameDraft(baseline[domain][key], value)) drafts[domain].delete(key);
notify();
return { ...baseline[domain] };
}
@@ -124,7 +140,7 @@ export function createSystemDrafts(adapters) {
function set(domain, key, value) {
requireDomain(domain);
if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key);
if (sameDraft(baseline[domain][key], value)) drafts[domain].delete(key);
else drafts[domain].set(key, value);
failedKeys[domain].delete(key);
outcome = null;
@@ -153,7 +169,7 @@ export function createSystemDrafts(adapters) {
function commit(domain, written) {
for (const [key, value] of written) {
baseline[domain][key] = value;
if (drafts[domain].has(key) && Object.is(drafts[domain].get(key), value)) drafts[domain].delete(key);
if (drafts[domain].has(key) && sameDraft(drafts[domain].get(key), value)) drafts[domain].delete(key);
}
}
+6
View File
@@ -9,6 +9,7 @@
* - "Existing tags", "General-tag confidence", "Character-tag confidence": the concept's labels and descriptions
* verbatim (they state the shipped defaults); host keys `autotag_existing`, `autotag_general_threshold`,
* `autotag_character_threshold`. D133 adds "Suggestions per image" (`autotag_suggestion_limit`, 1–100) after them.
* - D202 (#431): the "Skipped tags" block after it (system-skipped-tags.mjs, host key `autotag_skipped_tags`).
* - The `.field` "LLM tag template" (`#tag-template`, `autotag_template`).
* - `.row-actions`: "Preview tagging review" (`ca-primary`) opens the Tag image dialog for the image selected in Images
* with the section's current model and values, aria-disabled without one (the D115 sentence) or with an invalid
@@ -35,6 +36,7 @@ import { selectedImage } from './images.mjs';
import { NO_SELECTION } from './vocabulary.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { navigateTo } from './state.mjs';
import { skippedTagsBlock } from './system-skipped-tags.mjs';
export const MODELS_URL = '/prompt_manager/autotag/models';
export const UNLOAD_URL = '/prompt_manager/autotag/unload';
@@ -410,6 +412,9 @@ export function mountTagging(container, { api, activity, scope, drafts, notify =
// D133: the persisted suggestion count, which the concept does not show.
add(settingRow({ id: 'tag-suggestions', label: 'Suggestions per image', description: SUGGESTIONS_DESCRIPTION,
control: { type: 'number', min: 1, max: 100, step: 1 } }), 'tag-suggestions');
// D202 (#431): the words whose suggested tags are hidden on every suggestion path.
const skipped = skippedTagsBlock({ api, scope, drafts });
surface.append(skipped.node);
const field = el('div', undefined, 'field');
const templateLabel = el('label', 'LLM tag template');
@@ -454,6 +459,7 @@ export function mountTagging(container, { api, activity, scope, drafts, notify =
const known = () => MODEL_IDS.includes(chosen());
const repaint = () => {
skipped.repaint();
const values = current();
const unread = values.autotag_model_type === undefined;
const failed = drafts.hostState?.() === 'failed' && unread;
+22 -4
View File
@@ -443,12 +443,12 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
suggestions are removable chips, each the concept's `.chip` corner (4px; the later `.tag-suggestion` row rule would
give 5px) around the row's 12px text in its checked suggestion row's tokens (`.tag-suggestion:has(input:checked)`,
s1655): a name, the confidence and a × button. */
#tag-suggestions .chip.tag-suggestion { display: inline-flex; gap: 6px; padding: 2px 2px 2px 8px; margin-bottom: 0; cursor: default; color: inherit; border-color: var(--ca-accent); background: var(--soft-blue); border-radius: 4px; /* D83 */ }
#tag-suggestions .chip.tag-suggestion .tag-confidence { margin-inline-start: 0; /* D83 */ }
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 24px; min-inline-size: 24px; padding: 0 6px; /* D83 */ }
#tag-suggestions .chip.tag-suggestion, .skip-chips .chip.tag-suggestion { display: inline-flex; gap: 6px; padding: 2px 2px 2px 8px; margin-bottom: 0; cursor: default; color: inherit; border-color: var(--ca-accent); background: var(--soft-blue); border-radius: 4px; /* D83 */ }
#tag-suggestions .chip.tag-suggestion .tag-confidence, .skip-chips .chip.tag-suggestion .tag-confidence { margin-inline-start: 0; /* D83 */ }
.ca-tools #tag-suggestions .chip.tag-suggestion > button, .ca-tools .skip-chips .chip.tag-suggestion > button { min-block-size: 24px; min-inline-size: 24px; padding: 0 6px; /* D83 */ }
@media (pointer: coarse) {
/* D83: the chip's × keeps the concept's 40px coarse target. */
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; }
.ca-tools #tag-suggestions .chip.tag-suggestion > button, .ca-tools .skip-chips .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; }
}
/* Needs review (#249 PR F): the concept's Metadata review page (app.js a749–760) in its own classes. */
@@ -492,6 +492,24 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
@media (max-width: 700px) {
#workspace-main .setting-row :is(#send-destination, #renderer) { max-width: 160px !important; /* D106 */ }
}
/* D202 (#431): System › Auto-tagging "Skipped tags", a setting whose control (an add input, the chips and the preview
line) is wider than a `.setting-row` column: the row's label and description tokens over the full width, the chips
in the Tag image dialog's D83 chip markup (an entry that hides no known tag is muted). */
.setting-block { padding: 17px 0; border-top: 1px solid var(--line); /* D202 */ }
.setting-block-text label { font-size: 12px; font-weight: 500; display: block; /* D202 */ }
.setting-block-text p { font-size: 11px; color: var(--ca-muted); margin-top: 4px; max-width: 640px; /* D202 */ }
.setting-block[aria-invalid="true"] { box-shadow: inset 3px 0 0 var(--red); padding-inline-start: 12px; /* D202 */ }
.skip-add { display: flex; gap: 8px; margin-top: 12px; max-width: 460px; /* D202 */ }
.skip-add input { flex: 1; min-width: 0; font-size: 12px; /* D202 */ }
.skip-problem { color: var(--red); margin-top: 6px; /* D202 */ }
.skip-problem:empty, .skip-chips:empty { display: none; /* D202 */ }
.skip-chips .chip.tag-suggestion.none { border-color: var(--line); background: var(--ca-field); color: var(--ca-muted); /* D202 */ }
.skip-preview { margin-top: 12px; max-width: 760px; /* D202 */ }
.skip-preview b { font-weight: 500; color: var(--ca-text); /* D202 */ }
@media (max-width: 700px) {
.skip-add { max-width: none; /* D202 */ }
}
/* D132: at phone width the concept's 120px cap cuts every engine label ("WD14 SwinV2 · ~400 MB"); the Tagging model
select alone keeps its natural width there (its widest engine label) and, with no room beside the row text, wraps
under it. Existing tags (#398) never shrinks for its row text and, past the cap, keeps its natural width beside the