* 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:
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user