diff --git a/TESTING.md b/TESTING.md index 8a8071c7..04199a1e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 " · 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 diff --git a/tests/browser/tagging-canonical-comparison.mjs b/tests/browser/tagging-canonical-comparison.mjs index ef112625..ef297699 100644 --- a/tests/browser/tagging-canonical-comparison.mjs +++ b/tests/browser/tagging-canonical-comparison.mjs @@ -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})); diff --git a/tests/browser/workspace-tagging-bulk.spec.mjs b/tests/browser/workspace-tagging-bulk.spec.mjs index 14b8c9a5..4760e019 100644 --- a/tests/browser/workspace-tagging-bulk.spec.mjs +++ b/tests/browser/workspace-tagging-bulk.spec.mjs @@ -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.'); +}); diff --git a/tests/browser/workspace-tagging.spec.mjs b/tests/browser/workspace-tagging.spec.mjs index b3b2cef6..aaee7195 100644 --- a/tests/browser/workspace-tagging.spec.mjs +++ b/tests/browser/workspace-tagging.spec.mjs @@ -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); +}); diff --git a/tests/js/workspace-image-tagging-state.test.js b/tests/js/workspace-image-tagging-state.test.js index 97a4f183..5060165f 100644 --- a/tests/js/workspace-image-tagging-state.test.js +++ b/tests/js/workspace-image-tagging-state.test.js @@ -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); +}); diff --git a/tests/js/workspace-images-tagging-bulk.test.js b/tests/js/workspace-images-tagging-bulk.test.js index 9ffec9a1..4b8a5182 100644 --- a/tests/js/workspace-images-tagging-bulk.test.js +++ b/tests/js/workspace-images-tagging-bulk.test.js @@ -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); diff --git a/web/js/workspace/image-tagging-state.mjs b/web/js/workspace/image-tagging-state.mjs index b1703a11..97331d51 100644 --- a/web/js/workspace/image-tagging-state.mjs +++ b/web/js/workspace/image-tagging-state.mjs @@ -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, }; } diff --git a/web/js/workspace/image-tagging.mjs b/web/js/workspace/image-tagging.mjs index d02699be..e3ec1e46 100644 --- a/web/js/workspace/image-tagging.mjs +++ b/web/js/workspace/image-tagging.mjs @@ -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 (" · 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); } diff --git a/web/js/workspace/images-tagging-bulk.mjs b/web/js/workspace/images-tagging-bulk.mjs index 31fd6294..161392ac 100644 --- a/web/js/workspace/images-tagging-bulk.mjs +++ b/web/js/workspace/images-tagging-bulk.mjs @@ -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'); diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index fa50133d..5b63e853 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -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