feat(annotations): bulk Add tags and Auto-tag selected as Activity jobs (#283)

* feat(activity): image tagging jobs for bulk Add tags and Auto-tag

POST /prompt_manager/jobs/image-tags starts one record-changing
"image-tags" job over 1..500 listed images (202, or the same 409 busy
envelope as /jobs/scan). Add tags hashes each current file and unions a
fixed set into that byte record; Auto-tag captures each still image, asks
one legacy engine for its top N suggestions on the capture (count from
the options or autotag.defaults.suggestion_limit), re-hashes the file and
unions only onto the same bytes. Outcomes are grouped as tagged,
unchanged, skipped (video, too large, file changed, file not found, not a
still image) and errors; cancel stops at a file boundary.

Auto-tag holds the shared model gate for the whole job (409 model-busy at
start when it is held), loads the model once and never downloads one: an
unavailable model fails the job before the first file. The registry gains
blocker() so a start answers busy before taking the gate.

* feat(annotations): Add tags and Auto-tag selected in the selection bar

The Images selection bar gains the concept's Add tags and, beside it,
Auto-tag selected (D85). Add tags starts the image-tags Activity job with
the dialog's comma-separated names; Auto-tag selected reuses the Tag image
model panel (server defaults, Suggestions count) without Generate, refuses
a model that is not downloaded, and either starts the job (Apply
automatically) or walks the selection one Tag image dialog at a time
(Review each image, D90). Busy, model-busy and refused starts toast why;
the selection stays; a finished job's files are read again.

* feat(activity): show image tagging jobs with their own outcomes

Image tagging jobs get their own Activity view (D89): the pill reads
"Tagging", the item and heading line name the images and end with
"N tagged · M errors", and the job view shows Tagged, Unchanged, Skipped
and Errors tiles and outcomes, opens Tagged and Unchanged rows in the
viewer, and reads "Tagging log" and "Cancel tagging". The per-kind table
lives in activity-store.mjs; the scan's view is unchanged.

The selection bar's copy without a concept line is recorded as D117.

* fix(annotations): final review round for bulk image tagging

- Rename the D117 copy deviation to D118 (D117 belongs to another PR).
- The tagging job's progress line gives the scan's "about X min left"
  estimate, with each finished file as one batch.
- The start body cap fits 500 targets of the longest legal path, even
  escaped; one byte over answers 413.
- A capture store at capacity is retried with a short backoff, then the
  file is skipped with a rerun sentence instead of a Tag-dialog error.
- Output roots and their labels are resolved once per job in the
  executor, not twice per file on the event loop.
- Review each image holds Stop reviewing, Escape and x while a save is in
  flight, and its primary reads "Save and next" when only the manual
  field would be saved.
- An Add tags field that parses to no names keeps the dialog and its
  text and reports the message on the field.
- Image tagging finish toasts carry the counts ("... finished · N tagged
  · M errors").
- Specs: the bulk spec waits for the answered Start before checking that
  the invalid press sent nothing; the Activity spec uses a real error row.
This commit is contained in:
Vito
2026-10-22 18:55:24 -07:00
committed by GitHub
parent 9dcad31441
commit a8aff3a39d
32 changed files with 3019 additions and 155 deletions
+28 -8
View File
@@ -59,10 +59,10 @@ function createShell() {
body.id = 'modal-body';
header.append(title, close);
dialog.append(header, body);
close.addEventListener('click', () => finish());
close.addEventListener('click', () => dismiss());
dialog.addEventListener('cancel', event => {
event.preventDefault();
finish();
dismiss();
});
// A browser may still close a modal on its own; that is a dismissal too. `close` arrives as a later task, so it
// is ignored once this shell was reopened or replaced.
@@ -82,6 +82,12 @@ function finish() {
if (trigger?.isConnected) trigger.focus();
}
/** × or Escape: close, unless the open dialog's `canClose()` holds it (a save in flight). */
function dismiss() {
if (session?.canClose && !session.canClose()) return;
finish();
}
const boundScopes = new WeakSet();
function requireScope(scope) {
@@ -104,13 +110,13 @@ function bindScope(scope) {
});
}
function show(heading, nodes, { scope, trigger, focusTarget, onClose = null }) {
function show(heading, nodes, { scope, trigger, focusTarget, onClose = null, canClose = null }) {
shell ??= createShell();
if (!shell.dialog.isConnected) document.body.append(shell.dialog);
bindScope(scope);
// A swapped-out body is gone from the shell, so its owner releases it.
const replaced = session?.onClose;
session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose };
session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose, canClose };
replaced?.();
shell.title.textContent = heading;
shell.body.replaceChildren(...nodes);
@@ -298,10 +304,14 @@ export function openCopyFallback(text, { scope, trigger = null } = {}) {
* The concept's `simpleInput`: one labelled `input#action-input` (starting at `value`), then Cancel and the primary
* `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field, or
* reports `fallback` when one is given (Save view's "Saved view", a1339). A positive `maxLength` limits the field.
* `validate(value)` may refuse a value with a message: the dialog then stays open with the typed text and the field
* reports the message (its native validity) until it is edited.
*/
export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null }) {
export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null,
validate = null }) {
requireScope(scope);
requireCallbacks({ onApply });
if (validate !== null) requireCallbacks({ validate });
for (const [name, text] of Object.entries({ title, label, action })) {
if (typeof text !== 'string' || !text) throw new TypeError(`The input dialog needs a ${name}`);
}
@@ -314,9 +324,17 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe
const cancel = createButton('Cancel');
const apply = createButton(action, 'ca-primary');
cancel.addEventListener('click', () => finish());
input.addEventListener('input', () => input.setCustomValidity(''));
apply.addEventListener('click', () => {
const entered = input.value.trim() || fallback;
if (!entered) { input.focus(); return; }
const problem = validate?.(entered);
if (problem) {
input.setCustomValidity(problem);
input.reportValidity();
input.focus();
return;
}
finish();
onApply(entered);
});
@@ -327,14 +345,16 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe
/**
* The concept's plain `dialog(title, body)` with page-built `nodes`. `onClose` runs once when the dialog closes or
* another dialog replaces its body, so the page can dispose what it mounted there.
* another dialog replaces its body, so the page can dispose what it mounted there. While `canClose()` answers false,
* × and Escape leave the dialog open (the page, a route change or a replacing dialog still close it).
*/
export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null }) {
export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null, canClose = null }) {
requireScope(scope);
if (typeof title !== 'string' || !title) throw new TypeError('The dialog needs a title');
if (!Array.isArray(nodes) || nodes.some(node => !node || typeof node !== 'object')) throw new TypeError('The dialog needs its nodes');
if (onClose !== null && typeof onClose !== 'function') throw new TypeError('The dialog onClose must be a function');
show(title, nodes, { scope, trigger, onClose });
if (canClose !== null && typeof canClose !== 'function') throw new TypeError('The dialog canClose must be a function');
show(title, nodes, { scope, trigger, onClose, canClose });
}
export const SCAN_REVIEW_SENTENCE = 'Enrich positive / negative associations, reuse canonical text and report unchanged files. ' +