Pair category repair (#550) and saved instructions + dialog picker (#431) (#590)

* feat(maintenance): repair old Pair prompts' empty category and tags (#550)

Squashed from PR #579. Closes #550.

* feat(tagging): saved instructions with a default per engine (#431)

Squashed from PR #587.

* feat(tagging): pick saved instructions in the tagging dialogs (#431)

Squashed from PR #588. Closes #431.
This commit is contained in:
Vito
2026-11-01 03:01:07 +00:00
committed by GitHub
parent 9640ca86f8
commit 3295ff8b20
44 changed files with 3905 additions and 207 deletions
+36 -9
View File
@@ -11,6 +11,9 @@
* - Results (`section.surface`): the outcome list (`button[aria-pressed]` with counts, in the mockup's order), a path
* filter, and the selected outcome's listed rows (at most 200 per outcome, so "Showing N of M"). Rows under Added and
* Already known open the file in the existing viewer through its listing identity (D57); the others are plain text.
* - #550: a row with no path is a record (a maintenance apply's "Prompt 12: category …"): its detail is shown as plain
* text, never opened; the filter reads it. A maintenance plan of records (every operation but Missing-file links)
* says "records" where a file job says "files" (`groupCopy`).
* - Log: `details.term` "Scan log", collapsed, holding the raw timestamped lines in `pre[tabindex=0]`.
* - D89: the tiles, the outcome list, the openable rows and the log and cancel labels are the job kind's (`jobView`):
* an image tagging job shows Tagged, Unchanged, Skipped and Errors, opens Tagged and Unchanged rows, and reads
@@ -44,6 +47,10 @@ export const DETAIL_INTERVAL_MS = 1000;
export const NOT_FOUND = 'This job is not in this session’s Activity. Jobs are kept until ComfyUI restarts.';
export const EMPTY_GROUP = 'No files in this group.';
export const NO_MATCH = 'No listed file matches this filter.';
export const EMPTY_RECORDS = 'No records in this group.';
export const NO_RECORD_MATCH = 'No listed record matches this filter.';
const FILE_FILTER = 'Filter by file path…';
const RECORD_FILTER = 'Filter records…';
export const VIEWER_HINT = 'each row opens the image in the viewer';
export const FILE_GONE = 'This file is no longer in Images. Rescan from Import and try again.';
export const OPEN_FAILED = 'The file could not be opened. Try again.';
@@ -68,10 +75,11 @@ export function parseDetail(raw) {
const groups = raw.outcomes !== null && typeof raw.outcomes === 'object' ? raw.outcomes : {};
for (const [group, rows] of Object.entries(groups)) {
if (!Array.isArray(rows)) continue;
outcomes[group] = rows.filter(row => row && isText(row.path) && row.path)
// A file row has a path; a record row (#550) has none, only its detail.
outcomes[group] = rows.filter(row => row && isText(row.path) && (row.path || (isText(row.detail) && row.detail)))
.map(row => ({ path: row.path, root: isText(row.root) ? row.root : '',
root_index: Number.isSafeInteger(row.root_index) && row.root_index >= 0 ? row.root_index : null,
detail: isText(row.detail) ? row.detail : '' }));
detail: isText(row.detail) ? row.detail : '', record: !row.path }));
}
const log = Array.isArray(raw.log) ? raw.log.filter(isText) : [];
return { job, outcomes, log };
@@ -129,11 +137,23 @@ export function resultsNote(shown, total, openable) {
return parts.length === 1 && openable && shown >= total ? `${text[0].toUpperCase()}${text.slice(1)}.` : text;
}
/** Rows whose path (or root label and path) holds the case-folded filter. */
/** Rows whose path (or root label and path), or a record's detail (#550), holds the case-folded filter. */
export function filterRows(rows, filter) {
const wanted = filter.trim().toLocaleLowerCase();
if (!wanted) return rows;
return rows.filter(row => `${row.root}/${row.path}`.toLocaleLowerCase().includes(wanted));
return rows.filter(row => (row.record ? row.detail : `${row.root}/${row.path}`).toLocaleLowerCase().includes(wanted));
}
/** #550: whether a job lists records (a maintenance plan of records), not files. */
export function listsRecords(job) {
return job?.kind === 'maintenance' && job.scope?.operation !== 'missing_links';
}
/** The empty-group, no-match and filter copy: "records" for a records job, "files" otherwise (#550). */
export function groupCopy(job) {
return listsRecords(job)
? { empty: EMPTY_RECORDS, noMatch: NO_RECORD_MATCH, placeholder: RECORD_FILTER }
: { empty: EMPTY_GROUP, noMatch: NO_MATCH, placeholder: FILE_FILTER };
}
/** The first outcome with files, so a view opens on what happened; the first outcome when nothing has yet. */
@@ -176,7 +196,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
const search = element('label', undefined, 'search-field');
const input = element('input');
input.type = 'search';
input.placeholder = 'Filter by file path…';
input.placeholder = FILE_FILTER;
input.setAttribute('aria-label', 'Filter results');
search.append(input);
resultsPart.heading.append(search);
@@ -280,9 +300,13 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
// Listed rows only ever grow, so a row keeps its node across reads: a focused row (or the viewer's trigger) stays.
let rowNodes = new Map();
function rowNode(row, index, openable) {
const key = `${selected}\n${index}\n${row.root}\n${row.path}`;
const key = `${selected}\n${index}\n${row.root}\n${row.path}\n${row.record ? row.detail : ''}`;
let node = rowNodes.get(key);
if (!node) {
if (!node && row.record) {
// #550: a record is its detail, plain text, with no open or locate action.
node = element('div', undefined, 'file-row record-row');
node.append(element('span', row.detail, 'why'));
} else if (!node) {
node = openable ? createButton('', 'file-row') : element('div', undefined, 'file-row');
node.append(element('span', row.path, 'path'), element('span', row.detail, 'why'));
if (openable) node.row = row;
@@ -300,8 +324,11 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
const nodes = [...kept.values()];
// The counter is exact; at most 200 of its files are listed, and the filter narrows those.
const total = Math.max(job.counters[selected] ?? 0, rows.length);
if (!nodes.length) nodes.push(element('p', rows.length ? NO_MATCH : EMPTY_GROUP, 'empty-state'));
const note = wanted.length ? resultsNote(wanted.length, total, openable) : '';
const copy = groupCopy(job);
input.placeholder = copy.placeholder;
if (!nodes.length) nodes.push(element('p', rows.length ? copy.noMatch : copy.empty, 'empty-state'));
const opens = openable && wanted.some(row => !row.record);
const note = wanted.length ? resultsNote(wanted.length, total, opens) : '';
if (note) nodes.push(element('p', note, 'small'));
for (const child of [...files.children]) if (!nodes.includes(child)) child.remove();
nodes.forEach((node, index) => {
+100 -15
View File
@@ -31,6 +31,26 @@ const SOURCES = new Set(['images', 'library', 'review', 'tags', 'bulk']);
const OPTION_KEYS = new Set(['general_threshold', 'character_threshold', 'instructions', 'suggestion_limit']);
const DEFAULTS = Object.freeze({general_threshold: 0.35, character_threshold: 0.85, instructions: '', suggestion_limit: 10});
const MAX_SUGGESTIONS = 100;
/**
* #431: Built-in's text (`DEFAULT_TAG_TEMPLATE` in py/tagging_defaults.py, pinned equal by
* tests/test_saved_instructions.py), for when `/autotag/models` cannot be read.
*/
export const BUILTIN_INSTRUCTIONS = 'Generate only comma-separated Danbooru tags (lowercase_underscores, no spaces).\n'
+ 'Include: subject_count (1girl/1boy/etc), gender, ethnicity (asian, caucasian, etc), age_approx (teen, young_woman), '
+ 'body_attributes, face_features, hairstyle, appearance, clothing, accessories, pose, expression, action, camera_angle, '
+ 'camera_shot, scene_composition (wide_shot, long_shot, dutch_angle), environment, background_elements, lighting, mood.\n'
+ 'Prioritize visual accuracy.\n'
+ 'No sentences, no commentary, no captions. Only tags. Keep length short but descriptive.';
export const BUILTIN_ID = 'builtin';
/** The saved instructions when the server cannot say (D204): Built-in only, the default of both engines. */
const FALLBACK_CHOICES = Object.freeze({
entries: Object.freeze([Object.freeze({id: BUILTIN_ID, name: 'Built-in', text: BUILTIN_INSTRUCTIONS})]),
defaults: Object.freeze({gguf: BUILTIN_ID, hf: BUILTIN_ID}),
});
/** The value and label of the select's extra option while the text differs from the chosen entry (D204). */
export const EDITED_VALUE = 'edited';
export const EDITED_LABEL = 'Edited for this run';
export const EDITED_NOTE = 'Edits apply to this run only. Manage saved instructions in System › Auto-tagging.';
const MAX_INSTRUCTION_CHARS = 4000;
const EDITABLE = new Set(['ready', 'generate-error', 'write-error', 'conflict']);
// Answers after which only a fresh observation can save (the capture or epoch is gone); Retry observes again (D116).
@@ -182,27 +202,54 @@ function validateReceipt(value) {
return value;
}
/** #431: the saved instructions of `/autotag/models` (Built-in first) and each engine's default, else Built-in only. */
export function readChoices(value) {
const entries = Array.isArray(value?.instructions) ? value.instructions.filter(entry => object(entry)
&& typeof entry.id === 'string' && typeof entry.name === 'string' && typeof entry.text === 'string') : [];
if (entries[0]?.id !== BUILTIN_ID) return FALLBACK_CHOICES;
const ids = new Set(entries.map(entry => entry.id));
const defaults = Object.fromEntries(['gguf', 'hf'].map(engine => {
const id = value?.default_instructions?.[engine];
return [engine, ids.has(id) ? id : BUILTIN_ID];
}));
return Object.freeze({entries: Object.freeze(entries.map(entry => Object.freeze({...entry}))), defaults: Object.freeze(defaults)});
}
/** The language-model engine whose default a model reads (GGUF for a WD14 model, which takes none). */
const engineOf = model => (model === 'hf' ? 'hf' : 'gguf');
/** The default entry of `model`'s engine. */
export const engineDefault = (choices, model) => choices.entries.find(entry => entry.id === choices.defaults[engineOf(model)])
?? choices.entries[0];
/** The entry to show for `text` (a preset): the one with that text, else the engine's default. */
const entryFor = (choices, model, text) => choices.entries.find(entry => entry.text === text) ?? engineDefault(choices, model);
/** What the instruction fields hold for `model` with its engine's default (#431). */
const instructionsFor = (choices, model) => {
const entry = engineDefault(choices, model);
return {instructionId: entry.id, text: entry.text};
};
/**
* Thresholds, instructions and suggestion count from the server, else the documented defaults, the models'
* download status (`models`, null when unknown) and the saved tagging model (`model`, null when unknown; #328).
* download status (`models`, null when unknown), the saved tagging model (`model`, null when unknown; #328) and the
* saved instructions (`choices`, #431).
*/
async function readDefaults(api, signal) {
try {
const value = await api.get(MODELS_URL, {signal, cache: 'no-store'});
if (!object(value)) return {defaults: DEFAULTS, models: null, model: null};
if (!object(value)) return {defaults: DEFAULTS, models: null, model: null, choices: FALLBACK_CHOICES};
return {
choices: readChoices(value),
model: typeof value.model_type === 'string' && modelOf(value.model_type) ? value.model_type : null,
defaults: {
general_threshold: unit(value.wd14_general_threshold) ? value.wd14_general_threshold : DEFAULTS.general_threshold,
character_threshold: unit(value.wd14_character_threshold) ? value.wd14_character_threshold : DEFAULTS.character_threshold,
instructions: typeof value.default_prompt === 'string' ? value.default_prompt : DEFAULTS.instructions,
suggestion_limit: limit(value.suggestion_limit) ? value.suggestion_limit : DEFAULTS.suggestion_limit,
},
models: object(value.models) ? value.models : null,
};
} catch {
// The defaults keep the dialog usable; Generate reports a real failure itself.
return {defaults: DEFAULTS, models: null, model: null};
return {defaults: DEFAULTS, models: null, model: null, choices: FALLBACK_CHOICES};
}
}
@@ -258,6 +305,8 @@ function initialState(source, preset) {
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,
// #431 (D204): the saved instructions, each engine's default and the entry the select shows.
instructionChoices: FALLBACK_CHOICES, instructionId: BUILTIN_ID,
// Bumped whenever the controller itself rewrites the option fields or the manual draft, never while typing.
optionsRevision: 0, draftRevision: 0,
};
@@ -269,6 +318,13 @@ export function skippedNote(count) {
return count === 1 ? ' 1 skipped tag was not suggested.' : ` ${count.toLocaleString('en-US')} skipped tags were not suggested.`;
}
/** D204: whether a language model's instructions differ from the chosen saved entry's text (a one-off edit). */
export function instructionsEdited(state) {
if (modelOf(state.model)?.classifier !== false) return false;
const entry = state.instructionChoices?.entries.find(item => item.id === state.instructionId);
return !entry || entry.text !== state.options.instructions;
}
/** Derived labels and permissions; never stored independently of the fields they follow. */
function derive(state) {
const editable = EDITABLE.has(state.phase);
@@ -277,6 +333,7 @@ function derive(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 + skippedNote(state.skippedCount) : '',
instructionsEdited: instructionsEdited(state),
canSave: editable,
canGenerate: editable && state.generateSupported,
canRetry: RETRYABLE.has(state.phase),
@@ -394,6 +451,10 @@ export function createImageTaggingController({api, target, source = 'images', on
if (closed || serial !== openSerial) return;
// The saved model (System › Auto-tagging) unless a preset or the user already chose one (#328).
const savedModel = !chosen && !modelPicked && read.model ? {model: read.model} : {};
// #431: the instructions start from the model's engine default; a preset's text wins (D204).
const model = savedModel.model ?? state.model;
const presetText = chosen?.options.instructions;
const entry = presetText === undefined ? engineDefault(read.choices, model) : entryFor(read.choices, model, presetText);
observedOnce = true;
const {width, height, media_type: mediaType, preview_url: previewUrl, generate} = value.capture;
// The draft is read now, so text typed while observing is kept (#515). A kept draft stays as typed unless
@@ -410,7 +471,8 @@ export function createImageTaggingController({api, target, source = 'images', on
savedTags: value.tags.slice(), tagRevision: value.tag_revision,
manual: draft === null ? value.tags.join(', ') : changed || unseen ? mergeCurrent(value.tags, draft) : draft,
draftRevision: state.draftRevision + 1,
...savedModel, options: {...defaults, ...chosen?.options}, optionsRevision: state.optionsRevision + 1,
...savedModel, options: {...defaults, instructions: entry.text, ...chosen?.options}, optionsRevision: state.optionsRevision + 1,
instructionChoices: read.choices, instructionId: entry.id,
generateSupported: generate.supported, generateReason: generate.reason,
statusText: changed ? `${COPY.changedElsewhere} ${savedNow(value.tags)}${reason ? ` ${reason}` : ''}` : reason,
});
@@ -428,15 +490,26 @@ export function createImageTaggingController({api, target, source = 'images', on
if (id === state.model) return;
stopGenerating();
const phase = state.phase === 'generating' || state.phase === 'generate-error' ? 'ready' : state.phase;
const {instructionId, text} = instructionsFor(state.instructionChoices, id);
set({
phase, model: id, generated: false, suggestions: [], suggestionsText: COPY.modelChanged, statusText: reasonText(),
optionsRevision: state.optionsRevision + 1,
// Like the mock, a model change re-renders its fields with the defaults; the count is kept (D91).
// Like the mock, a model change re-renders its fields with the defaults; the count is kept (D91). The
// instructions start from the new engine's default, dropping a one-off edit (D204).
options: {...state.options, general_threshold: defaults.general_threshold,
character_threshold: defaults.character_threshold, instructions: defaults.instructions},
character_threshold: defaults.character_threshold, instructions: text},
instructionId,
});
}
/** D204: choose a saved entry; its text replaces the instructions (and a one-off edit). */
function pickInstructions(id) {
if (closed) return;
const entry = state.instructionChoices.entries.find(item => item.id === id);
if (!entry) return;
set({instructionId: id, options: {...state.options, instructions: entry.text}, optionsRevision: state.optionsRevision + 1});
}
function setOption(key, value) {
requireValue(OPTION_KEYS.has(key), 'Unknown tagging option');
set({options: {...state.options, [key]: value}});
@@ -593,7 +666,7 @@ export function createImageTaggingController({api, target, source = 'images', on
return {
get state() {return state;},
get target() {return imageTarget;},
subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, retarget, cancel, dispose,
subscribe, open, setManual, setModel, setOption, pickInstructions, generate, remove, save, retry, retarget, cancel, dispose,
};
}
@@ -612,8 +685,9 @@ export function createTaggingOptionsController({api} = {}) {
let abort = null;
let modelPicked = false;
const deriveOptions = value => ({...value, canGenerate: false, unavailable: modelUnavailable(models, value.model),
canStart: value.phase === 'ready' && !modelUnavailable(models, value.model)});
let state = freeze(deriveOptions({phase: 'loading', model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, optionsRevision: 0}));
canStart: value.phase === 'ready' && !modelUnavailable(models, value.model), instructionsEdited: instructionsEdited(value)});
let state = freeze(deriveOptions({phase: 'loading', model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, optionsRevision: 0,
instructionChoices: FALLBACK_CHOICES, instructionId: BUILTIN_ID}));
function set(patch) {
if (closed) return;
@@ -628,17 +702,28 @@ export function createTaggingOptionsController({api} = {}) {
({defaults, models} = read);
// The saved model (System › Auto-tagging) unless the user already chose one (#328).
const savedModel = !modelPicked && read.model ? {model: read.model} : {};
set({phase: 'ready', ...savedModel, options: {...defaults}, optionsRevision: state.optionsRevision + 1});
const {instructionId, text} = instructionsFor(read.choices, savedModel.model ?? state.model);
set({phase: 'ready', ...savedModel, options: {...defaults, instructions: text}, optionsRevision: state.optionsRevision + 1,
instructionChoices: read.choices, instructionId});
}
function setModel(id) {
requireValue(modelOf(id), 'Unknown tagging model');
if (closed) return;
modelPicked = true;
if (id === state.model) return;
// As in the Tag image dialog: the model's fields start from the defaults again; the count is kept (D91).
set({model: id, optionsRevision: state.optionsRevision + 1,
// As in the Tag image dialog: the model's fields start from the defaults again; the count is kept (D91), and
// the instructions from the new engine's default (D204).
const {instructionId, text} = instructionsFor(state.instructionChoices, id);
set({model: id, optionsRevision: state.optionsRevision + 1, instructionId,
options: {...state.options, general_threshold: defaults.general_threshold,
character_threshold: defaults.character_threshold, instructions: defaults.instructions}});
character_threshold: defaults.character_threshold, instructions: text}});
}
/** D204: choose a saved entry; its text replaces the instructions. */
function pickInstructions(id) {
if (closed) return;
const entry = state.instructionChoices.entries.find(item => item.id === id);
if (!entry) return;
set({instructionId: id, options: {...state.options, instructions: entry.text}, optionsRevision: state.optionsRevision + 1});
}
function setOption(key, value) {
requireValue(OPTION_KEYS.has(key), 'Unknown tagging option');
@@ -661,6 +746,6 @@ export function createTaggingOptionsController({api} = {}) {
}
return {
get state() {return state;},
subscribe, open, setModel, setOption, request, dispose,
subscribe, open, setModel, setOption, pickInstructions, request, dispose,
};
}
+47 -3
View File
@@ -17,7 +17,8 @@
* flight at close keeps its capture until it settles, and its outcome is still toasted and announced.
*/
import { createButton, createScope } from '../shared/toolkit/core.js';
import { createImageTaggingController, MODEL_OPTIONS, optionError } from './image-tagging-state.mjs';
import { createImageTaggingController, EDITED_LABEL, EDITED_NOTE, EDITED_VALUE, MODEL_OPTIONS, engineDefault, optionError }
from './image-tagging-state.mjs';
import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { showWorkspaceToast } from './toast.mjs';
@@ -107,9 +108,13 @@ function numberInput(value, {min, max, step}) {
* It follows `controller` until `scope` is disposed (reused by the bulk Auto-tag dialog, D85, without Generate:
* `generate: false`). Returns the `element`, `validate()` (the fields' native validity, then what the controller would
* refuse; true when valid, else the first invalid field reports) and `requestGenerate()`, Generate's own path (field
* validity first), for a Retry after a failed Generate.
* validity first), for a Retry after a failed Generate. D204 (#431): for a language model, a "Saved instructions" select
* (`pickId`) sits above "Tagging instructions": it starts at the engine's default ("<name> · default"), a pick replaces
* the text, and while the text differs from the picked entry it shows "Edited for this run" (not choosable) with a
* muted note under the textarea.
*/
export function renderModelPanel({scope, controller, heading = 'Auto-tag this image', generate: withGenerate = true}) {
export function renderModelPanel({scope, controller, heading = 'Auto-tag this image', generate: withGenerate = true,
pickId = 'image-tag-instructions-pick'}) {
const panel = el('div', undefined, 'tag-model-panel');
const select = el('select');
for (const option of MODEL_OPTIONS) {
@@ -125,9 +130,35 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im
if (withGenerate) panel.append(generate);
const inputs = new Map();
let shownModel = null, shownOptions = null;
// D204: the Saved instructions select and the one-off note, while a language model is chosen.
let pick = null, editedNote = null, shownEdited = null;
/** The select's options: every entry (the engine default marked), plus "Edited for this run" while edited. */
function fillPick(state) {
shownEdited = state.instructionsEdited;
const fallback = engineDefault(state.instructionChoices, state.model).id;
pick.replaceChildren(...state.instructionChoices.entries.map(entry => {
const option = el('option', entry.id === fallback ? `${entry.name} · default` : entry.name);
option.value = entry.id;
return option;
}));
if (state.instructionsEdited) {
const option = el('option', EDITED_LABEL);
option.value = EDITED_VALUE;
option.disabled = true;
pick.append(option);
}
pick.value = state.instructionsEdited ? EDITED_VALUE : state.instructionId;
editedNote.hidden = !state.instructionsEdited;
// The note describes the textarea while it shows.
const area = inputs.get('instructions');
if (state.instructionsEdited) area?.setAttribute('aria-describedby', editedNote.id);
else area?.removeAttribute('aria-describedby');
}
function renderOptions(state) {
inputs.clear();
pick = editedNote = null;
const classifier = MODEL_OPTIONS.find(option => option.id === state.model).classifier;
const rows = [];
if (classifier) {
@@ -139,10 +170,17 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im
}
rows.push(pair);
} else {
pick = el('select');
pick.addEventListener('change', () => { if (pick.value !== EDITED_VALUE) controller.pickInstructions(pick.value); });
rows.push(field('Saved instructions', pick, pickId));
const area = el('textarea');
area.value = state.options.instructions;
inputs.set('instructions', area);
rows.push(field('Tagging instructions', area, OPTION_IDS.instructions));
editedNote = el('p', EDITED_NOTE, 'small instructions-edited');
editedNote.id = `${pickId}-note`;
rows.push(editedNote);
fillPick(state);
}
// D91: the number of suggestions, for every model.
const count = numberInput(state.options.suggestion_limit, {min: '1', max: '100', step: '1'});
@@ -162,7 +200,13 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im
shownModel = state.model;
shownOptions = state.optionsRevision;
select.value = state.model;
// A pick re-renders the fields; keyboard focus stays on the select it was made with.
const picking = pick !== null && document.activeElement === pick;
renderOptions(state);
if (picking) pick?.focus();
} else if (pick && state.instructionsEdited !== shownEdited) {
// Typing never re-renders the fields (I1): only the select and the note follow an edit.
fillPick(state);
}
setDisabled(generate, !state.canGenerate);
}
+2 -1
View File
@@ -167,7 +167,8 @@ export function openAutoTag({api, scope, store, images, trigger, onTagged = () =
const options = createTaggingOptionsController({api});
// D85: the Tag image dialog's model panel without Generate; its Suggestions field is the job's count (D91).
const {element: panel, validate} = renderModelPanel({scope: local, controller: options,
heading: `Auto-tag ${countOf(filter ? count : listed.length, 'image')}`, generate: false}); // D118
heading: `Auto-tag ${countOf(filter ? count : listed.length, 'image')}`, generate: false, // D118
pickId: 'bulk-tag-instructions-pick'}); // D204
let choice = APPLY_CHOICES[0];
const actions = el('div', undefined, 'dialog-actions');
const cancel = createButton('Cancel');
+2
View File
@@ -49,6 +49,8 @@ export const OPERATION_TITLES = Object.freeze({
duplicate_prompts: 'Duplicate prompt text',
optimize: 'Optimize database',
unused_components: 'Unused prompt components',
// D192 (#550): beyond the concept, System › Maintenance › Database.
pair_metadata: 'Pair category and tags',
missing_links: 'Missing-file links',
duplicate_files: 'Duplicate image files',
// Reviewed and applied through the API and seen in Activity; Maintenance has no row for it (the concept has none).
+351
View File
@@ -0,0 +1,351 @@
/**
* System › Auto-tagging "Saved instructions" (#431, D203): named language-model instructions and the default of each
* language-model engine. It replaces the concept's "LLM tag template" textarea.
*
* A `.setting-block` (`data-setting="tag-instructions"`, `data-key="autotag_instructions"`): the label, the
* description with the count ("N of 20 saved · Built\u2011in is not counted."), a `role="listbox"` list (Built-in first,
* "Read-only", then the saved entries, each with its "Default for GGUF" / "Default for HuggingFace" chips), "+ New",
* "Duplicate" and "Delete", and the editor beside the list (under it below 700 px): "Name" (60 characters, live count)
* and "Text" ("N of 4,000 characters"), both read-only for Built-in with its note. Then two `.setting-row` selects,
* "Default for GGUF JoyCaption" and "Default for HuggingFace JoyCaption" (`data-key="autotag_default_instructions"`),
* and the note a deleted default leaves ("GGUF now uses Built-in."). Host drafts: `autotag_instructions` (the saved
* entries, never Built-in) and `autotag_default_instructions`; the server pairs them on save. Built-in's text is the
* read-only `autotag_builtin_instructions` of GET /settings. Ids are 12 lowercase hex characters made here.
*/
import { createButton } from '../shared/toolkit/core.js';
import { settingRow } from './system-controls.mjs';
export const INSTRUCTIONS_KEY = 'autotag_instructions';
export const DEFAULTS_KEY = 'autotag_default_instructions';
export const BUILTIN_TEXT_KEY = 'autotag_builtin_instructions';
export const BUILTIN_ID = 'builtin';
export const BUILTIN_NAME = 'Built-in';
export const MAX_SAVED = 20;
export const MAX_NAME_CHARS = 60;
export const MAX_TEXT_CHARS = 4000;
export const ENGINES = Object.freeze([['gguf', 'GGUF', 'GGUF JoyCaption'], ['hf', 'HuggingFace', 'HuggingFace JoyCaption']]);
export const INSTRUCTIONS_COPY = Object.freeze({
label: 'Saved instructions',
description: 'Instructions for the language-model engines. Built-in can’t be changed; duplicate it to make your own.',
count: saved => `${saved} of ${MAX_SAVED} saved · Built\u2011in is not counted.`,
readOnly: 'Read-only',
defaultFor: short => `Default for ${short}`,
newLabel: '+ New',
duplicate: 'Duplicate',
delete: 'Delete',
cap: `At most ${MAX_SAVED} saved instructions.`,
name: 'Name',
text: 'Text',
nameCount: length => `${length} of ${MAX_NAME_CHARS}`,
textCount: length => `${length.toLocaleString('en-US')} of ${MAX_TEXT_CHARS.toLocaleString('en-US')} characters`,
builtinNote: 'Built-in instructions can’t be changed. Duplicate them to make your own.',
newName: 'New instructions',
copyName: name => `${name} copy`,
defaultRow: long => `Default for ${long}`,
defaultDescription: 'The Tag image dialog and batches start from this.',
reset: shorts => `${shorts.join(' and ')} now ${shorts.length > 1 ? 'use' : 'uses'} Built-in.`,
});
/** Characters as Python's `len` counts them (code points). */
const characters = text => [...String(text ?? '')].length;
const fold = text => String(text ?? '').trim().toLowerCase();
/** The saved entries of the drafts (`[]` when unread or damaged). */
export const savedEntries = values => (Array.isArray(values?.[INSTRUCTIONS_KEY]) ? values[INSTRUCTIONS_KEY] : []);
/** Built-in (its text from GET /settings) and then the saved entries. */
export function allEntries(values) {
const text = typeof values?.[BUILTIN_TEXT_KEY] === 'string' ? values[BUILTIN_TEXT_KEY] : '';
return [{ id: BUILTIN_ID, name: BUILTIN_NAME, text }, ...savedEntries(values)];
}
/** The drafted default entry of `engine` (`gguf` or `hf`); Built-in when the id names nothing. */
export function defaultEntry(values, engine) {
const entries = allEntries(values);
const id = values?.[DEFAULTS_KEY]?.[engine];
return entries.find(entry => entry.id === id) ?? entries[0];
}
/** `base`, else `base (2)`, `base (3)`… whichever no entry uses (case-insensitive). */
export function freeName(base, entries) {
const taken = new Set(entries.map(entry => fold(entry.name)));
if (!taken.has(fold(base))) return base;
for (let n = 2; ; n += 1) if (!taken.has(fold(`${base} (${n})`))) return `${base} (${n})`;
}
/** "<name> copy" (then "(2)"…), the name cut so the whole stays within the 60-character limit. */
export function copyName(name, entries) {
for (let room = MAX_NAME_CHARS - ' copy'.length; room > 0; room -= 1) {
const base = [...String(name ?? '').trim()].slice(0, room).join('').trimEnd();
const candidate = freeName(INSTRUCTIONS_COPY.copyName(base), entries);
if (characters(candidate) <= MAX_NAME_CHARS) return candidate;
}
return freeName(INSTRUCTIONS_COPY.copyName(BUILTIN_NAME), entries);
}
/** A new entry id: 12 lowercase hex characters from `random` (crypto.getRandomValues), never one already used. */
export function newId(entries, random = bytes => globalThis.crypto.getRandomValues(bytes)) {
const used = new Set(entries.map(entry => entry.id));
for (;;) {
const id = [...random(new Uint8Array(6))].map(byte => byte.toString(16).padStart(2, '0')).join('');
if (!used.has(id)) return id;
}
}
/** The editor's note for a saved entry's name and text (the mock's short sentences), or ''. */
export function entryNote(entry, entries) {
const name = String(entry.name ?? '').trim();
if (!name) return 'Needs a name.';
if (characters(name) > MAX_NAME_CHARS) return `The name must be at most ${MAX_NAME_CHARS} characters.`;
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(name)) return 'The name must not contain a line break.';
if (fold(name) === fold(BUILTIN_NAME)) return 'The name “Built-in” is reserved.';
if (entries.some(other => other !== entry && other.id !== BUILTIN_ID && fold(other.name) === fold(name))) {
return `The name “${name}” is already used.`;
}
if (!String(entry.text ?? '').trim()) return 'The text must not be empty.';
if (characters(entry.text) > MAX_TEXT_CHARS) return `The text must be at most ${MAX_TEXT_CHARS.toLocaleString('en-US')} characters.`;
return '';
}
/**
* The server's sentence for the first saved entry it would refuse (Built-in is not counted), else null. The Save row
* shows the server's own refusal; this one holds back Preview tagging review and the batch Review.
*/
export function listProblem(values) {
const entries = savedEntries(values);
for (const [index, entry] of entries.entries()) {
// As the server checks them: a repeated name is the later entry's problem.
const note = entryNote(entry, entries.slice(0, index + 1));
if (!note) continue;
const n = index + 1;
if (note.startsWith('Needs')) return `Saved instructions: entry ${n} needs a name.`;
if (note.includes('reserved')) return 'Saved instructions: the name “Built-in” is reserved.';
if (note.includes('already used')) return `Saved instructions: entry ${n} name “${String(entry.name).trim()}” is already used.`;
if (note.includes('line break')) return `Saved instructions: entry ${n} name must not contain a line break.`;
if (note.startsWith('The name')) return `Saved instructions: entry ${n} name must be at most ${MAX_NAME_CHARS} characters.`;
if (note.includes('empty')) return `Saved instructions: entry ${n} text must be non-empty text.`;
return `Saved instructions: entry ${n} text must be at most ${MAX_TEXT_CHARS} characters.`;
}
return null;
}
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/** A `.field` with a label row (text and a muted count) and its control. */
function field(id, label, control) {
const wrap = el('div', undefined, 'field');
const caption = el('label');
caption.htmlFor = id;
const count = el('span', '', 'small muted');
caption.append(el('span', label), count);
control.id = id;
wrap.append(caption, control);
return { wrap, count };
}
/**
* Build the block and the two default rows; `drafts` is the System draft store. Returns `{nodes, repaint}`: System
* appends `nodes` in order and calls `repaint` whenever the drafts change.
*/
export function savedInstructionsBlock({ scope, drafts, random }) {
const block = el('div', undefined, 'setting-block');
block.dataset.setting = 'tag-instructions';
block.dataset.key = INSTRUCTIONS_KEY;
const text = el('div', undefined, 'setting-block-text');
const label = el('label', INSTRUCTIONS_COPY.label);
label.id = 'instructions-label';
const description = el('p', `${INSTRUCTIONS_COPY.description} `);
const count = el('span', '');
count.id = 'instructions-count';
description.append(count);
text.append(label, description);
const layout = el('div', undefined, 'instructions-layout');
const side = el('div', undefined, 'instructions-side');
const list = el('div', undefined, 'instruction-list');
list.id = 'instruction-list';
list.setAttribute('role', 'listbox');
list.setAttribute('aria-labelledby', 'instructions-label');
list.tabIndex = 0;
const actions = el('div', undefined, 'row-actions instruction-actions');
const create = createButton(INSTRUCTIONS_COPY.newLabel);
create.id = 'instruction-new';
const duplicate = createButton(INSTRUCTIONS_COPY.duplicate);
duplicate.id = 'instruction-duplicate';
const remove = createButton(INSTRUCTIONS_COPY.delete, 'danger');
remove.id = 'instruction-delete';
actions.append(create, duplicate, remove);
const cap = el('p', INSTRUCTIONS_COPY.cap, 'small');
cap.id = 'instruction-cap';
cap.hidden = true;
side.append(list, actions, cap);
const editor = el('div', undefined, 'instruction-editor');
const nameInput = el('input');
nameInput.autocomplete = 'off';
const name = field('instruction-name', INSTRUCTIONS_COPY.name, nameInput);
const textArea = el('textarea');
textArea.rows = 7;
const body = field('instruction-text', INSTRUCTIONS_COPY.text, textArea);
const note = el('p', '', 'small');
note.id = 'instruction-note';
note.setAttribute('role', 'status');
editor.append(name.wrap, body.wrap, note);
layout.append(side, editor);
block.append(text, layout);
const selects = ENGINES.map(([engine, , long]) => {
const row = settingRow({ id: `instruction-default-${engine}`, label: INSTRUCTIONS_COPY.defaultRow(long),
description: INSTRUCTIONS_COPY.defaultDescription, control: { type: 'select', options: [[BUILTIN_ID, BUILTIN_NAME]] } });
const select = row.children[1];
select.dataset.key = DEFAULTS_KEY;
return { engine, select, row };
});
const reset = el('p', '', 'small instruction-reset');
reset.id = 'instruction-reset';
reset.setAttribute('role', 'status');
let selectedId = BUILTIN_ID;
const values = () => ({ [INSTRUCTIONS_KEY]: drafts.get('host', INSTRUCTIONS_KEY),
[DEFAULTS_KEY]: drafts.get('host', DEFAULTS_KEY), [BUILTIN_TEXT_KEY]: drafts.get('host', BUILTIN_TEXT_KEY) });
const ready = () => Array.isArray(drafts.get('host', INSTRUCTIONS_KEY)) && drafts.get('host', DEFAULTS_KEY) !== undefined;
const defaults = () => ({ ...(drafts.get('host', DEFAULTS_KEY) ?? {}) });
const setEntries = next => drafts.set('host', INSTRUCTIONS_KEY, next);
const selected = () => allEntries(values()).find(entry => entry.id === selectedId) ?? allEntries(values())[0];
/** Replace the selected saved entry's `key` with `value` (a new list of new objects). */
function edit(key, value) {
if (selectedId === BUILTIN_ID) return;
setEntries(savedEntries(values()).map(entry => (entry.id === selectedId ? { ...entry, [key]: value } : entry)));
}
function add(entry) {
const entries = savedEntries(values());
if (!ready() || entries.length >= MAX_SAVED) return;
selectedId = entry.id;
reset.textContent = '';
setEntries([...entries, entry]);
}
function deleteSelected() {
const entries = savedEntries(values());
const index = entries.findIndex(entry => entry.id === selectedId);
if (index < 0) return;
const gone = entries[index];
const next = defaults();
const shorts = [];
for (const [engine, short] of ENGINES) {
if (next[engine] === gone.id) { next[engine] = BUILTIN_ID; shorts.push(short); }
}
const left = entries.filter(entry => entry.id !== gone.id);
selectedId = (left[index] ?? left[index - 1])?.id ?? BUILTIN_ID;
reset.textContent = shorts.length ? INSTRUCTIONS_COPY.reset(shorts) : '';
if (shorts.length) drafts.set('host', DEFAULTS_KEY, next);
setEntries(left);
}
function select(id) {
selectedId = id;
repaint();
}
function renderList(entries, chosen) {
list.replaceChildren(...entries.map(entry => {
const option = el('div');
option.id = `instruction-option-${entry.id}`;
option.dataset.id = entry.id;
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', String(entry.id === selectedId));
option.append(el('span', entry.name));
const badges = el('span', undefined, 'badges');
if (entry.id === BUILTIN_ID) badges.append(el('span', INSTRUCTIONS_COPY.readOnly, 'chip'));
for (const [engine, short] of ENGINES) {
if (chosen[engine] === entry.id) badges.append(el('span', INSTRUCTIONS_COPY.defaultFor(short), 'chip blue'));
}
if (badges.children.length) option.append(badges);
return option;
}));
list.setAttribute('aria-activedescendant', `instruction-option-${selectedId}`);
}
function repaint() {
// The note a deleted default left goes once the defaults are saved (or set back).
if (!Object.hasOwn(drafts.changes('host'), DEFAULTS_KEY)) reset.textContent = '';
const loaded = ready();
for (const control of [create, duplicate, remove, nameInput, textArea, ...selects.map(item => item.select)]) {
control.disabled = !loaded;
}
if (!loaded) return;
const current = values();
const entries = allEntries(current);
if (!entries.some(entry => entry.id === selectedId)) selectedId = BUILTIN_ID;
const chosen = defaults();
const saved = savedEntries(current);
renderList(entries, chosen);
const entry = selected();
const builtin = entry.id === BUILTIN_ID;
// A focused field keeps what the user is typing.
if (document.activeElement !== nameInput) nameInput.value = entry.name;
if (document.activeElement !== textArea) textArea.value = entry.text;
nameInput.readOnly = textArea.readOnly = builtin;
name.count.textContent = builtin ? '' : INSTRUCTIONS_COPY.nameCount(characters(entry.name));
body.count.textContent = INSTRUCTIONS_COPY.textCount(characters(entry.text));
const problem = builtin ? '' : entryNote(entry, entries);
note.textContent = builtin ? INSTRUCTIONS_COPY.builtinNote : problem;
note.className = problem ? 'small skip-problem' : 'small';
const full = saved.length >= MAX_SAVED;
create.disabled = duplicate.disabled = full;
remove.disabled = builtin;
cap.hidden = !full;
count.textContent = INSTRUCTIONS_COPY.count(saved.length);
for (const { engine, select: menu } of selects) {
menu.replaceChildren(...entries.map(item => {
const option = el('option', item.name);
option.value = item.id;
return option;
}));
menu.value = chosen[engine] ?? BUILTIN_ID;
}
}
scope.listen(create, 'click', () => {
const entries = allEntries(values());
add({ id: newId(entries, random), name: freeName(INSTRUCTIONS_COPY.newName, entries), text: '' });
textArea.focus?.();
});
scope.listen(duplicate, 'click', () => {
const entries = allEntries(values());
const source = selected();
add({ id: newId(entries, random), name: copyName(source.name, entries), text: source.text });
});
scope.listen(remove, 'click', deleteSelected);
// One listener for every option, however often the list is rebuilt.
scope.listen(list, 'click', event => {
let node = event.target;
while (node && node !== list && node.getAttribute?.('role') !== 'option') node = node.parentNode;
if (node && node !== list) select(node.dataset.id);
});
scope.listen(nameInput, 'input', () => edit('name', nameInput.value));
scope.listen(textArea, 'input', () => edit('text', textArea.value));
scope.listen(list, 'keydown', event => {
const ids = allEntries(values()).map(entry => entry.id);
const at = ids.indexOf(selectedId);
const next = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: ids.length - 1 }[event.key];
if (next === undefined) return;
event.preventDefault?.();
select(ids[Math.max(0, Math.min(ids.length - 1, next))]);
});
for (const { engine, select: menu } of selects) {
scope.listen(menu, 'change', () => {
reset.textContent = '';
drafts.set('host', DEFAULTS_KEY, { ...defaults(), [engine]: menu.value });
});
}
return { nodes: [block, ...selects.map(item => item.row), reset], repaint };
}
+7 -2
View File
@@ -2,10 +2,12 @@
* System › Maintenance (#249 System S7 Task 19): the concept's `renderSystem` "maintenance" body (app.js 688–690).
*
* - The notice "Check first. Review changes. Then apply." with "Run health checks" (D123), then the surfaces
* "Database" and "Images & cache" with the concept's eight operations, in its order, labels and danger marks.
* "Database" and "Images & cache" with the concept's eight operations, in its order, labels and danger marks, plus
* D192 (#550, beyond the concept): "Pair category and tags" after "Unused prompt components".
* - Integrity & relationship checks → Check runs the same read-only health check and opens the Health report (D95);
* its "Review missing links" reviews a fresh Missing-file links plan, never a cleanup.
* - Duplicate prompt text, Optimize database, Unused prompt components and Missing-file links → Review plan open the
* - Duplicate prompt text, Optimize database, Unused prompt components, Pair category and tags (`pair_metadata`: fill
* old Pair prompts' empty category and tags from their saved pairs) and Missing-file links → Review plan open the
* D100 plan review (maintenance-review.mjs), which applies only what it showed, as an Activity job.
* - Duplicate image files → Find duplicates starts the duplicate search as an Activity job and opens it; its view
* offers Review groups (D101) once it found some.
@@ -39,6 +41,9 @@ export const MAINTENANCE_SURFACES = Object.freeze([
['Optimize database', 'Reclaim unused SQLite space. This does not delete prompts or media.', 'Review plan', 'optimize'],
['Unused prompt components', 'Preview unreferenced components. Pair references and history remain protected.', 'Review plan',
'unused_components', true],
// D192 (#550): beyond the concept. Fills only empty fields, deletes nothing (no danger mark).
['Pair category and tags', 'Fill an empty category or tags on prompts older Pair node runs saved, from their saved ' +
'pairs. Only empty fields of never-edited prompts.', 'Review plan', 'pair_metadata'],
]],
['Images & cache', [
['Missing-file links', 'Remove stale database links only. Files on disk are never deleted by this operation.', 'Review plan',
+35 -30
View File
@@ -10,7 +10,8 @@
* 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`).
* - D203 (#431): "Saved instructions" and the two per-engine default rows (system-instructions.mjs, host keys
* `autotag_instructions` and `autotag_default_instructions`) replace the concept's "LLM tag template" field.
* - `.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
* value (the toast names it). D132: the concept's two simulated job buttons are "Download" (or
@@ -37,6 +38,8 @@ import { NO_SELECTION } from './vocabulary.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { navigateTo } from './state.mjs';
import { skippedTagsBlock } from './system-skipped-tags.mjs';
import { BUILTIN_TEXT_KEY, DEFAULTS_KEY, INSTRUCTIONS_KEY, defaultEntry, listProblem, savedInstructionsBlock }
from './system-instructions.mjs';
export const MODELS_URL = '/prompt_manager/autotag/models';
export const UNLOAD_URL = '/prompt_manager/autotag/unload';
@@ -70,10 +73,10 @@ export const SETTING_KEYS = Object.freeze({
'threshold-general': 'autotag_general_threshold',
'threshold-character': 'autotag_character_threshold',
'tag-suggestions': 'autotag_suggestion_limit',
'tag-template': 'autotag_template',
});
/** #431: the values the instruction readers take besides the rows' (D203, D205). */
const INSTRUCTION_VALUES = Object.freeze([INSTRUCTIONS_KEY, DEFAULTS_KEY, BUILTIN_TEXT_KEY]);
const NUMERIC = new Set(['autotag_general_threshold', 'autotag_character_threshold', 'autotag_suggestion_limit']);
const MAX_TEMPLATE_CHARS = 4000;
export const STATUS_COPY = Object.freeze({
loading: 'Loading model status…',
@@ -93,9 +96,6 @@ export const SETTING_PROBLEMS = Object.freeze({
autotag_general_threshold_step: 'General-tag confidence must be a multiple of 0.05.',
autotag_character_threshold_step: 'Character-tag confidence must be a multiple of 0.05.',
autotag_suggestion_limit: 'Suggestions per image must be a whole number from 1 to 100.',
// The server's two template sentences (`validate_template`), blank first.
autotag_template: 'LLM tag template must be non-empty text.',
autotag_template_length: `LLM tag template must be at most ${MAX_TEMPLATE_CHARS} characters.`,
});
/** Characters as Python's `len` counts them (code points), not UTF-16 units. */
export const characters = text => [...String(text ?? '')].length;
@@ -193,20 +193,23 @@ const onStep = value => Math.abs(value * 20 - Math.round(value * 20)) <= 1e-12;
// A threshold on the grid, handed on exactly as the server stores it (anything else is left for draftProblem to refuse).
const snapped = value => (unit(value) && onStep(value) ? Math.round(value * 20) / 20 : value);
/** #431 (D205): the drafted default instructions of the drafted language-model engine. */
export const engineInstructions = values => defaultEntry(values, values.autotag_model_type === 'hf' ? 'hf' : 'gguf');
/**
* The first setting the chosen model would use that the section cannot send, as `{key, message}`, else null: WD14
* reads both thresholds (0 to 1 in steps of 0.05, #369), the language models the template; Generate also reads the
* suggestion count (`limit`).
* reads both thresholds (0 to 1 in steps of 0.05, #369), the language models the saved instructions (#431: the server's
* sentence for the first entry it would refuse); Generate also reads the suggestion count (`limit`).
*/
export function draftProblem(values, { limit = true } = {}) {
const model = values.autotag_model_type;
const keys = CLASSIFIERS.has(model) ? ['autotag_general_threshold', 'autotag_character_threshold'] : ['autotag_template'];
const keys = CLASSIFIERS.has(model) ? ['autotag_general_threshold', 'autotag_character_threshold'] : [INSTRUCTIONS_KEY];
if (limit) keys.push('autotag_suggestion_limit');
for (const key of keys) {
const value = values[key];
if (key === 'autotag_template') {
if (typeof value !== 'string' || value.trim() === '') return { key, message: SETTING_PROBLEMS.autotag_template };
if (characters(value) > MAX_TEMPLATE_CHARS) return { key, message: SETTING_PROBLEMS.autotag_template_length };
if (key === INSTRUCTIONS_KEY) {
const message = listProblem(values);
if (message) return { key, message };
continue;
}
const valid = key === 'autotag_suggestion_limit' ? Number.isInteger(value) && value >= 1 && value <= 100 : unit(value);
@@ -222,7 +225,7 @@ export function taggingPreset(values) {
const options = CLASSIFIERS.has(model)
? { general_threshold: snapped(values.autotag_general_threshold),
character_threshold: snapped(values.autotag_character_threshold) }
: { instructions: values.autotag_template };
: { instructions: engineInstructions(values).text };
return { model, options: { ...options, suggestion_limit: values.autotag_suggestion_limit } };
}
@@ -232,7 +235,7 @@ export function batchBody(values) {
const options = CLASSIFIERS.has(model)
? { general_threshold: snapped(values.autotag_general_threshold),
character_threshold: snapped(values.autotag_character_threshold) }
: { instructions: values.autotag_template };
: { instructions: engineInstructions(values).text };
return { model_type: model, existing: values.autotag_existing, options };
}
@@ -287,14 +290,20 @@ export function timeText(plan) {
return durationText(Math.ceil(seconds / 60)) + (plan.estimate_device === 'gpu' ? TIME_ON_GPU : TIME_ON_CPU);
}
/** D134: the review's rows from the server's preview and the drafts it was made with (image counts, #379). */
/**
* D134: the review's rows from the server's preview and the drafts it was made with (image counts, #379). D205 (#431):
* a language-model engine shows its default instructions by name in an "Instructions" row, and Settings reads "Up to L
* tags per image".
*/
export function batchGrid(plan, values) {
const id = plan.model?.type ?? values.autotag_model_type;
let settings = CLASSIFIERS.has(id)
? `General ${values.autotag_general_threshold} · Character ${values.autotag_character_threshold}`
: `Template of ${number(characters(values.autotag_template))} characters`;
const classifier = CLASSIFIERS.has(id);
// The saved count the run uses (the preview's), not an unsaved draft.
if (Number.isSafeInteger(plan.suggestion_limit)) settings += ` · up to ${number(plan.suggestion_limit)} tags per image`;
const limit = Number.isSafeInteger(plan.suggestion_limit) ? `up to ${number(plan.suggestion_limit)} tags per image` : '';
const settings = classifier
? `General ${values.autotag_general_threshold} · Character ${values.autotag_character_threshold}${limit ? ` · ${limit}` : ''}`
: limit.charAt(0).toUpperCase() + limit.slice(1);
const instructions = classifier ? [] : [['Instructions', defaultEntry(values, id === 'hf' ? 'hf' : 'gguf').name]];
return [
['Model', `${modelLabel(id, plan.model)} · ${modelSize(id, plan.model)}`],
['Existing tags', existingLabel(plan.existing ?? values.autotag_existing)],
@@ -302,6 +311,7 @@ export function batchGrid(plan, values) {
['Already auto-tagged', number(plan.already_tagged)],
['Images to tag', `about ${number(plan.to_tag)}`],
['Time', timeText(plan)],
...instructions,
['Settings', settings],
['Backup', backupText(plan.backup)],
];
@@ -416,16 +426,9 @@ export function mountTagging(container, { api, activity, scope, drafts, notify =
const skipped = skippedTagsBlock({ api, scope, drafts });
surface.append(skipped.node);
const field = el('div', undefined, 'field');
const templateLabel = el('label', 'LLM tag template');
templateLabel.htmlFor = 'tag-template';
const template = el('textarea');
template.id = 'tag-template';
template.dataset.setting = 'tag-template';
template.dataset.key = SETTING_KEYS['tag-template'];
field.append(templateLabel, template);
surface.append(field);
controls.push({ id: 'tag-template', key: SETTING_KEYS['tag-template'], input: template, text: null });
// D203 (#431): the saved instructions and each language-model engine's default replace the template field.
const instructions = savedInstructionsBlock({ scope, drafts });
surface.append(...instructions.nodes);
for (const { key, input } of controls) {
// Text controls draft while typing (the Save row follows at once); selects and numbers on change.
@@ -454,12 +457,14 @@ export function mountTagging(container, { api, activity, scope, drafts, notify =
// The "could not be read" line under each row, while the server settings could not be read.
const notes = new Map();
const image = selected();
const current = () => Object.fromEntries(Object.values(SETTING_KEYS).map(key => [key, drafts.get('host', key)]));
const current = () => Object.fromEntries([...Object.values(SETTING_KEYS), ...INSTRUCTION_VALUES]
.map(key => [key, drafts.get('host', key)]));
const chosen = () => drafts.get('host', 'autotag_model_type');
const known = () => MODEL_IDS.includes(chosen());
const repaint = () => {
skipped.repaint();
instructions.repaint();
const values = current();
const unread = values.autotag_model_type === undefined;
const failed = drafts.hostState?.() === 'failed' && unread;
+25
View File
@@ -513,15 +513,40 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
.skip-add { max-width: none; /* D202 */ }
}
/* D203 (#431): "Saved instructions": the list (Built-in first, its chips) beside the Name/Text editor, stacked under
it below 700px, and the note a deleted default leaves under the two default rows. */
.instructions-layout { display: grid; grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); gap: 18px; margin-top: 12px; /* D203 */ }
.instruction-list { border: 1px solid var(--line); border-radius: 6px; background: var(--ca-field); max-height: 300px; overflow: auto; /* D203 */ }
.instruction-list [role="option"] { display: flex; flex-direction: column; gap: 4px; padding: 9px 12px; cursor: pointer; border-bottom: 1px solid var(--line); font-size: 12px; /* D203 */ }
.instruction-list [role="option"]:last-child { border-bottom: 0; /* D203 */ }
.instruction-list [role="option"][aria-selected="true"] { background: var(--soft-blue); box-shadow: inset 3px 0 0 var(--ca-accent); /* D203 */ }
.instruction-list .badges { display: flex; flex-wrap: wrap; gap: 4px; /* D203 */ }
.instruction-actions { margin-top: 10px; flex-wrap: wrap; /* D203 */ }
.instruction-editor .field:first-child { margin-top: 0; /* D203 */ }
.instruction-editor input[readonly], .instruction-editor textarea[readonly] { color: var(--ca-muted); /* D203 */ }
.instruction-editor label { display: flex; justify-content: space-between; gap: 8px; /* D203 */ }
.instruction-reset { color: var(--warning); margin: 0 0 14px; /* D203 */ }
.instruction-reset:empty { display: none; /* D203 */ }
@media (max-width: 700px) {
.instructions-layout { grid-template-columns: 1fr; /* D203 */ }
}
/* D204 (#431): the one-off edit's note under "Tagging instructions" in the Tag image and Auto-tag selected dialogs. */
.tag-model-panel .instructions-edited { margin: -6px 0 12px; /* D204 */ }
.tag-model-panel .instructions-edited[hidden] { display: none; /* D204 */ }
/* 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
text ("Replace after review"), bounded by its row like the engine select. */
.setting-row #tag-existing { flex-shrink: 0; /* D132 */ }
/* D203: the default-instructions selects show their chosen name whole, as Existing tags does (#398). */
.setting-row #instruction-default-gguf, .setting-row #instruction-default-hf { flex-shrink: 0; /* D203 */ }
@media (max-width: 700px) {
#workspace-main .setting-row:has(> #tag-model) { flex-wrap: wrap; /* D132 */ }
#workspace-main .setting-row #tag-model { flex-shrink: 0; max-width: 100% !important; /* D132 */ }
#workspace-main .setting-row #tag-existing { max-width: 100% !important; /* D132 */ }
#workspace-main .setting-row #instruction-default-gguf, #workspace-main .setting-row #instruction-default-hf { max-width: 100% !important; /* D203 */ }
}
/* D109: System › Diagnostics & logs' added Lines field sits in the concept's toolbar like its Level select, and a value