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

The Tag image and Auto-tag selected dialogs get a Saved instructions
select above Tagging instructions (D204). It starts at the engine's
default; a pick fills the text; an edit stays for this run and shows
"Edited for this run"; switching engines resets it; WD14 hides it. The
workspace no longer reads default_prompt.

Closes #431
This commit is contained in:
Vito Sansevero
2026-10-31 18:42:09 -07:00
parent 259ad65b3f
commit 4ff55e155e
10 changed files with 335 additions and 27 deletions
+22 -2
View File
@@ -1851,8 +1851,8 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68
engines in the mock's order with legacy labels and sizes (`WD14 SwinV2 · ~400
MB`, `WD14 ViT · ~400 MB`, `GGUF JoyCaption (Recommended) · ~4.5 GB` in the
mock's Ollama slot, `HuggingFace JoyCaption · ~16 GB`); the instructions start
from the server's `default_prompt` (since #431 the saved engine's default
instructions, GGUF's for a WD14 engine).
from the chosen engine's default saved instructions (#431, D204; before, the
server's `default_prompt`).
- **D91** (plan D68, ruling T3-bulk): a **Suggestions** number field (1–100) for
every model, prefilled from `autotag.defaults.suggestion_limit` (10); a model
change keeps it.
@@ -4619,6 +4619,26 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
tokens, the two rows on the concept's select row) and `saved instructions: new, edit,
per-engine default, delete, save and reload (D203)`, `a refused instructions save names
the entry and highlights the block (D203)` (workspace-system-tagging.spec).
- **D204** (Tag image and Auto-tag selected dialogs, #431, beyond the concept): for a
language-model engine a "Saved instructions" select (`#image-tag-instructions-pick`,
`#bulk-tag-instructions-pick`) sits above "Tagging instructions" (the mock's state 3),
on the concept's select field: Built-in, then the saved entries (`GET /autotag/models`
`instructions`, full text), the engine's default marked "<name> · default". It starts
at that default; a pick replaces the text. The text stays editable for the run: while
it differs from the picked entry the select shows a disabled "Edited for this run" and
a muted line under the textarea reads "Edits apply to this run only. Manage saved
instructions in System › Auto-tagging." Choosing an entry again replaces the edit.
Switching between GGUF and HF starts from the new engine's default and drops an edit;
WD14 hides both. A preset (Preview tagging review, the bulk review) shows the entry
with its text, else "Edited for this run". When the read fails the select holds only
Built-in with the client's copy (`BUILTIN_INSTRUCTIONS`, pinned equal to
`DEFAULT_TAG_TEMPLATE` by workspace-image-tagging-state.test). Request bodies are
unchanged (`options.instructions` is the textarea's text); there is no "Save as new".
The workspace no longer reads `default_prompt`. Pinned by the tagging comparison's
mapping (tagging-canonical-comparison.mjs: the select before the instructions),
`the saved instructions picker fills, edits one-off and resets per engine (D204)`
(workspace-tagging.spec) and `Auto-tag selected picks saved instructions and sends the
text shown (D204)` (workspace-tagging-bulk.spec).
- **D205** (Auto-tagging, #431; amends D134): for a language-model engine the batch
review adds a read-only "Instructions" row with the drafted engine default's name
before "Settings", which reads "Up to L tags per image"; the batch and Preview tagging
+11 -2
View File
@@ -92,6 +92,8 @@ export function cardActionsInventory(card) {
};
}
// #431: the fixture's saved "Describe the image" entry, the default of both engines (workspace_tagging_fixture.py).
export const FIXTURE_INSTRUCTIONS_ID='d35c71be0a1e';
// D84 (plan D61): the legacy engines' values, labels and sizes, GGUF JoyCaption in the concept's Ollama slot.
const MODEL_LABELS=Object.freeze({'wd14-swinv2':['wd14-swinv2','WD14 SwinV2 · ~400 MB'],'wd14-vit':['wd14-vit','WD14 ViT · ~400 MB'],
ollama:['gguf','GGUF JoyCaption (Recommended) · ~4.5 GB'],huggingface:['hf','HuggingFace JoyCaption · ~16 GB']});
@@ -106,17 +108,24 @@ const CONCEPT_LABELS=Object.freeze({'WD14 SwinV2 · fast classifier':'WD14 SwinV
* (`chip`: a concept `.chip` inventory, the heading's size chip) around the row's own text (12px) in the checked
* row's colours (accent border, `--soft-blue`);
* - D84 (plan D61): the legacy engine labels and values, and the server's default instructions (`instructions`);
* - D204 (#431): a "Saved instructions" select (`picker`: its value and options) before the instructions, on the
* concept's select field tokens;
* - D91 (plan D68): a Suggestions count field after the model's own fields, on the concept's number field tokens
* (`numberField`: the concept's General confidence field) with the configured count (`limit`);
* - D29: the prototype's "Sample suggestions from …" status reads "Suggestions from …".
* The opened state is compared unmapped: both show the concept's on-screen "Generate suggestions with the selected
* model before approving." (its renderTagModelOptions, a1062–1076, replaces the markup's sentence at once).
*/
export function mapConceptTagging(reference,{instructions,limit=10,chip,numberField}) {
export function mapConceptTagging(reference,{instructions,limit=10,chip,numberField,
picker={value:FIXTURE_INSTRUCTIONS_ID,options:[['builtin','Built-in'],[FIXTURE_INSTRUCTIONS_ID,`${instructions} · default`]]}}) {
const [select,...own]=reference.modelPanel.fields;
// D204 (#431): a "Saved instructions" select on the concept's select field, before the instructions.
const pickField={...select,label:'Saved instructions',control:{...select.control,id:'image-tag-instructions-pick',
value:picker.value,options:picker.options}};
const suggestionsField={...numberField,label:'Suggestions',control:{...numberField.control,id:'image-suggestion-limit',
value:String(limit),min:'1',max:'100',step:'1'}};
const mappedOwn=own.map(field=>field.control.id==='image-tag-instructions'?{...field,control:{...field.control,value:instructions}}:field);
const mappedOwn=own.flatMap(field=>field.control.id==='image-tag-instructions'
?[pickField,{...field,control:{...field.control,value:instructions}}]:[field]);
const rows=reference.suggestions.rows.map(row=>({...row,
control:row.control?.kind==='checkbox'&&row.control.checked?{kind:'remove',label:`Remove ${row.name}`}:row.control,
borderTopLeftRadius:chip.borderTopLeftRadius}));
@@ -341,3 +341,21 @@ test('a running scan makes the start report busy', async ({ page, request }) =>
await request.post(`/__test__/jobs/${scan.job.id}/finish`, { data: { state: 'finished' } });
}
});
// D204 (#431): Auto-tag selected has the same picker; the job sends the text the dialog shows.
test('Auto-tag selected picks saved instructions and sends the text shown (D204)', async ({ page }) => {
await select(page, FIRST);
await barButton(page, 'Auto-tag selected').click();
await expect(title(page)).toHaveText('Auto-tag selected images');
const body = modal(page).locator('#modal-body');
await body.getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('gguf');
const pick = body.getByRole('combobox', { name: 'Saved instructions', exact: true });
await expect(pick).toHaveAttribute('id', 'bulk-tag-instructions-pick');
await expect(pick.locator('option')).toHaveText(['Built-in', 'Describe the image · default']);
const text = body.getByRole('textbox', { name: 'Tagging instructions', exact: true });
await text.fill('Bulk one-off.');
await expect(pick.locator('option:checked')).toHaveText('Edited for this run');
const started = jobStart(page);
await actionButtons(page).filter({ hasText: 'Start auto-tagging' }).click();
expect((await started).postDataJSON().options.instructions).toBe('Bulk one-off.');
});
+42
View File
@@ -861,3 +861,45 @@ test('suggestions omit skipped tags and say how many were hidden', async ({ page
await expect(chips(page).locator(':scope > span:first-child')).toHaveText(['sandstone', 'arch', 'sky', 'dune']);
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(`${CHIP_HINT} 2 skipped tags were not suggested.`);
});
// D204 (#431): the Saved instructions select above Tagging instructions: the engine default, a pick, a one-off edit
// ("Edited for this run" and the muted note), an engine switch resets, WD14 hides both; Generate sends the text shown.
test('the saved instructions picker fills, edits one-off and resets per engine (D204)', async ({ page, request }) => {
const settings = await (await request.get('/prompt_manager/settings')).json();
const builtin = settings.settings.autotag_builtin_instructions;
await openTag(page);
const options = dialog(page).locator('#image-tag-model-options');
const pick = options.getByRole('combobox', { name: 'Saved instructions', exact: true });
const text = options.getByRole('textbox', { name: 'Tagging instructions', exact: true });
const note = options.locator('.instructions-edited');
await expect(pick).toHaveCount(0);
await model(page).selectOption('gguf');
await expect(pick.locator('option')).toHaveText(['Built-in', 'Describe the image · default']);
await expect(pick).toHaveAttribute('id', 'image-tag-instructions-pick');
await expect(text).toHaveValue('Describe the image');
await expect(note).toBeHidden();
await pick.selectOption('builtin');
await expect(text).toHaveValue(builtin);
await text.fill('Only the sky.');
await expect(pick.locator('option:checked')).toHaveText('Edited for this run');
await expect(pick.locator('option[value="edited"]')).toBeDisabled();
await expect(note).toHaveText('Edits apply to this run only. Manage saved instructions in System › Auto-tagging.');
await expect(text).toBeFocused();
// Generate sends the edited text; the saved entries are unchanged.
const sent = page.waitForRequest(item => item.url().endsWith('/image-annotations/suggest'));
await generate(page).click();
expect((await sent).postDataJSON().options.instructions).toBe('Only the sky.');
// Choosing an entry replaces the edit; switching engines starts from the new engine's default.
await pick.selectOption('builtin');
await expect(text).toHaveValue(builtin);
await expect(note).toBeHidden();
await text.fill('Edited again.');
await model(page).selectOption('hf');
await expect(text).toHaveValue('Describe the image');
await expect(pick).toHaveValue('d35c71be0a1e');
await model(page).selectOption('wd14-vit');
await expect(pick).toHaveCount(0);
await expect(text).toHaveCount(0);
expect((await (await request.get('/prompt_manager/settings')).json()).settings.autotag_instructions)
.toEqual(settings.settings.autotag_instructions);
});
+92 -3
View File
@@ -53,8 +53,12 @@ function observed(overrides = {}, capture = {}) {
...overrides,
};
}
// #431: the saved instructions; the fixture's entry is the default of both engines.
const SAVED_ENTRY = { id: '0123456789ab', name: 'Saved', text: DEFAULT_PROMPT };
const models = (overrides = {}) => ({
success: true, models: { 'wd14-swinv2': { downloaded: true } }, default_prompt: DEFAULT_PROMPT,
success: true, models: { 'wd14-swinv2': { downloaded: true } },
instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, SAVED_ENTRY],
default_instructions: { gguf: SAVED_ENTRY.id, hf: SAVED_ENTRY.id },
wd14_general_threshold: 0.35, wd14_character_threshold: 0.85, suggestion_limit: 10, ...overrides,
});
const SANDSTONE = [
@@ -244,10 +248,13 @@ test('dialog starts on the saved model unless a preset or the user chose one', a
});
test('an unreadable or malformed models read falls back to the documented defaults', async () => {
for (const route of [() => fail(500, null), () => response(200, models({ default_prompt: 4, suggestion_limit: 0, wd14_general_threshold: 2, wd14_character_threshold: 'x' }))]) {
for (const route of [() => fail(500, null), () => response(200, models({ instructions: 4, suggestion_limit: 0, wd14_general_threshold: 2, wd14_character_threshold: 'x' }))]) {
const { c } = await opened({ [`GET ${MODELS}`]: route });
assert.equal(c.state.phase, 'ready');
assert.deepEqual({ ...c.state.options }, { general_threshold: 0.35, character_threshold: 0.85, instructions: '', suggestion_limit: 10 });
// #431: the client's Built-in copy (D204's read failure).
const { BUILTIN_INSTRUCTIONS } = await load();
assert.deepEqual({ ...c.state.options }, { general_threshold: 0.35, character_threshold: 0.85, instructions: BUILTIN_INSTRUCTIONS,
suggestion_limit: 10 });
}
});
@@ -950,3 +957,85 @@ test('the chip hint says how many skipped tags were not suggested (#431)', async
assert.equal(c.state.chipHint, hint, String(skipped));
}
});
// D204 (#431): the Saved instructions picker of both dialogs.
const PHOTO_ENTRY = { id: '0123456789ac', name: 'Photo', text: 'Photo tags.' };
const picker = (overrides = {}) => ({ [`GET ${MODELS}`]: () => response(200, models({ model_type: 'gguf',
instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, SAVED_ENTRY, PHOTO_ENTRY],
default_instructions: { gguf: SAVED_ENTRY.id, hf: PHOTO_ENTRY.id }, ...overrides })) });
test('the picker starts at the engine default; a pick replaces the text and an edit is one-off (D204)', async () => {
const { c } = await opened(picker());
assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [SAVED_ENTRY.id, DEFAULT_PROMPT, false]);
const revision = c.state.optionsRevision;
c.pickInstructions(PHOTO_ENTRY.id);
assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [PHOTO_ENTRY.id, 'Photo tags.', false]);
assert.equal(c.state.optionsRevision, revision + 1, 'a pick re-renders the textarea');
c.setOption('instructions', 'Photo tags, please.');
assert.equal(c.state.instructionsEdited, true);
assert.equal(c.state.optionsRevision, revision + 1, 'typing never re-renders');
// Choosing an entry again replaces the edit without asking; an unknown id changes nothing.
c.pickInstructions(PHOTO_ENTRY.id);
assert.deepEqual([c.state.options.instructions, c.state.instructionsEdited], ['Photo tags.', false]);
c.pickInstructions('nope');
assert.equal(c.state.instructionId, PHOTO_ENTRY.id);
// Switching engines resets to the new engine's default and drops a one-off edit; WD14 is never "edited".
c.setOption('instructions', 'edited');
c.setModel('hf');
assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [PHOTO_ENTRY.id, 'Photo tags.', false]);
c.setModel('wd14-vit');
assert.equal(c.state.instructionsEdited, false);
c.setModel('gguf');
assert.deepEqual([c.state.instructionId, c.state.options.instructions], [SAVED_ENTRY.id, DEFAULT_PROMPT]);
c.dispose();
});
test('a preset shows the entry with its text, else "Edited for this run" (D204)', async () => {
const { c } = await opened(picker(), { preset: { model: 'hf', options: { instructions: DEFAULT_PROMPT } } });
assert.deepEqual([c.state.instructionId, c.state.instructionsEdited], [SAVED_ENTRY.id, false]);
const { c: other } = await opened(picker(), { preset: { model: 'hf', options: { instructions: 'Something else' } } });
assert.deepEqual([other.state.instructionId, other.state.options.instructions, other.state.instructionsEdited],
[PHOTO_ENTRY.id, 'Something else', true]);
for (const controller of [c, other]) controller.dispose();
});
test('a models read without instructions offers Built-in only with the client copy (D204)', async () => {
const { BUILTIN_INSTRUCTIONS, readChoices } = await load();
assert.equal(BUILTIN_INSTRUCTIONS.startsWith('Generate only comma-separated Danbooru tags'), true);
for (const value of [null, {}, { instructions: [] }, { instructions: [{ id: 'x', name: 'X', text: 'y' }] }]) {
assert.deepEqual(readChoices(value).entries.map(entry => entry.id), ['builtin'], JSON.stringify(value));
}
// A default naming no entry is Built-in.
assert.deepEqual({ ...readChoices({ instructions: [{ id: 'builtin', name: 'Built-in', text: 't' }], default_instructions: { gguf: 'gone' } }).defaults },
{ gguf: 'builtin', hf: 'builtin' });
const { c } = await opened(picker({ instructions: 'broken' }));
assert.deepEqual([c.state.instructionId, c.state.options.instructions], ['builtin', BUILTIN_INSTRUCTIONS]);
c.dispose();
});
test('the bulk options pick and reset like the Tag image dialog (D204)', async () => {
const { createTaggingOptionsController } = await load();
const options = createTaggingOptionsController({ api: harness(defaultRoutes(picker())).api });
await options.open();
assert.deepEqual([options.state.model, options.state.instructionId, options.state.options.instructions], ['gguf', SAVED_ENTRY.id, DEFAULT_PROMPT]);
options.pickInstructions(PHOTO_ENTRY.id);
assert.equal(options.request().options.instructions, 'Photo tags.');
options.setOption('instructions', 'One-off.');
assert.equal(options.state.instructionsEdited, true);
assert.equal(options.request().options.instructions, 'One-off.');
options.setModel('hf');
assert.deepEqual([options.state.instructionId, options.state.instructionsEdited], [PHOTO_ENTRY.id, false]);
options.pickInstructions('nope');
options.dispose();
options.pickInstructions(SAVED_ENTRY.id);
assert.equal(options.state.instructionId, PHOTO_ENTRY.id);
});
test('the client Built-in copy equals the server DEFAULT_TAG_TEMPLATE (#431)', async () => {
const { BUILTIN_INSTRUCTIONS } = await load();
const source = require('node:fs').readFileSync(require('node:path').join(__dirname, '../../py/tagging_defaults.py'), 'utf8');
const block = source.slice(source.indexOf('DEFAULT_TAG_TEMPLATE = ('), source.indexOf('\n)\n', source.indexOf('DEFAULT_TAG_TEMPLATE = (')));
const text = [...block.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map(([, part]) => JSON.parse(`"${part}"`)).join('');
assert.ok(text.length > 100);
assert.equal(BUILTIN_INSTRUCTIONS, text);
});
@@ -180,7 +180,9 @@ test('the bulk options start from the server defaults and wait for them before S
assert.equal(options.state.canStart, false);
assert.equal(options.state.canGenerate, false);
const opening = options.open();
answer({ wd14_general_threshold: 0.5, wd14_character_threshold: 0.9, default_prompt: 'Say it', suggestion_limit: 7,
answer({ wd14_general_threshold: 0.5, wd14_character_threshold: 0.9, suggestion_limit: 7,
instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, { id: '0123456789ab', name: 'Say', text: 'Say it' }],
default_instructions: { gguf: '0123456789ab', hf: 'builtin' },
models: { 'wd14-swinv2': { downloaded: true }, 'wd14-vit': { downloaded: false }, gguf: { downloaded: true } } });
await opening;
assert.equal(options.state.canStart, true);
+102 -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,56 @@ 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,
// #431: the instructions come from the saved entries (`choices`), not `default_prompt`.
instructions: 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 +307,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 +320,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 +335,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 +453,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 +473,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 +492,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 +668,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 +687,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 +704,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 +748,6 @@ export function createTaggingOptionsController({api} = {}) {
}
return {
get state() {return state;},
subscribe, open, setModel, setOption, request, dispose,
subscribe, open, setModel, setOption, pickInstructions, request, dispose,
};
}
+39 -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,31 @@ 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;
}
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 +166,16 @@ 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');
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'});
@@ -163,6 +196,9 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im
shownOptions = state.optionsRevision;
select.value = state.model;
renderOptions(state);
} 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');
+4
View File
@@ -528,6 +528,10 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
.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