diff --git a/tests/js/api-client.test.js b/tests/js/api-client.test.js index 5aad215..9743ec4 100644 --- a/tests/js/api-client.test.js +++ b/tests/js/api-client.test.js @@ -296,3 +296,22 @@ test("get forwards an explicit cache mode and keeps the default request otherwis fetch.calls[2].resolve(jsonResponse(200, { success: true })); await writes; }); + +test("ApiError exposes only a validated current tag set from a tag-conflict body", async () => { + const permitted = [{ tags: [], tag_revision: 0 }, { tags: ["warm", "Dune"], tag_revision: 7 }]; + const denied = [undefined, null, { tags: ["a"] }, { tag_revision: 1 }, { tags: "a", tag_revision: 1 }, + { tags: [1], tag_revision: 1 }, { tags: ["a"], tag_revision: -1 }, { tags: ["a"], tag_revision: 1.5 }, + { tags: ["a"], tag_revision: true }, { tags: Array(101).fill("a"), tag_revision: 1 }]; + assert.equal(new ApiError(409, "old caller").current, null); + for (const current of permitted.concat(denied)) { + const api = createApiClient(async () => jsonResponse(409, { + success: false, error: "changed", code: "tag-conflict", ...(current || {}), + })); + await assert.rejects(api.put("/x", {}), error => { + assert.ok(error instanceof ApiError); + assert.deepEqual(error.current, permitted.includes(current) ? current : null); + if (permitted.includes(current)) assert.notEqual(error.current.tags, current.tags, "server arrays must be copied"); + return true; + }); + } +}); diff --git a/tests/js/workspace-image-tagging-state.test.js b/tests/js/workspace-image-tagging-state.test.js new file mode 100644 index 0000000..88dc061 --- /dev/null +++ b/tests/js/workspace-image-tagging-state.test.js @@ -0,0 +1,620 @@ +// Run with: node --test tests/js/workspace-image-tagging-state.test.js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createApiClient } = require('../../web/js/api-client.js'); + +// Only an absent module is tolerated, so RED is an assertion failure, not a crash. +const source = import('../../web/js/workspace/image-tagging-state.mjs').catch(error => { + if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; + return {}; +}); +async function load() { + const module = await source; + assert.equal(typeof module.createImageTaggingController, 'function', 'image-tagging-state.mjs is not implemented'); + return module; +} + +const EPOCH = '11111111-1111-4111-8111-111111111111'; +const SCOPE = 'c'.repeat(64); +const SHA = 'd'.repeat(64); +const TOKEN = 'capture-token_1'; +const TOKEN2 = 'capture-token_2'; +const TARGET = Object.freeze({ root_index: 0, root_scope: SCOPE, path: 'dunes/dunes_00001.png' }); +const OBSERVE = '/prompt_manager/image-annotations/observe'; +const SUGGEST = '/prompt_manager/image-annotations/suggest'; +const TAGS = '/prompt_manager/image-annotations/tags'; +const MODELS = '/prompt_manager/autotag/models'; +const CAPTURE = '/prompt_manager/image-annotations/capture/'; +const DEFAULT_PROMPT = 'Generate only comma-separated Danbooru tags (lowercase_underscores, no spaces).'; + +const flush = () => new Promise(resolve => setImmediate(resolve)); +async function settle(times = 8) { for (let i = 0; i < times; i++) await flush(); } +function deferred() { + let resolve, reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +function response(status, body) { + return { ok: status >= 200 && status < 300, status, statusText: `Status ${status}`, json: async () => body }; +} +const fail = (status, code, error = `failure ${code}`, extra = {}) => response(status, { success: false, code, error, ...extra }); + +function captureInfo(overrides = {}) { + return { + token: TOKEN, expires_at: '2026-10-22T12:00:00+00:00', preview_url: CAPTURE + (overrides.token || TOKEN), + sha256: SHA, size: 1234, width: 832, height: 1216, media_type: 'image', + generate: { supported: true, reason: null }, ...overrides, + }; +} +function observed(overrides = {}, capture = {}) { + return { + success: true, library_epoch: EPOCH, target: { ...TARGET }, capture: captureInfo(capture), + title: 'Desert pavilion', filename: 'dunes_00001.png', tags: ['Minimal', 'warm_light'], tag_revision: 3, + ...overrides, + }; +} +const models = (overrides = {}) => ({ + success: true, models: { 'wd14-swinv2': { downloaded: true } }, default_prompt: DEFAULT_PROMPT, + wd14_general_threshold: 0.35, wd14_character_threshold: 0.85, suggestion_limit: 10, ...overrides, +}); +const SANDSTONE = [ + { name: 'minimal', confidence: 0.99 }, { name: 'editorial', confidence: 0.97 }, + { name: 'sandstone', confidence: 0.981 }, { name: 'Editorial', confidence: 0.5 }, +]; +const suggested = (rows = SANDSTONE, overrides = {}) => ({ success: true, model_type: 'wd14-swinv2', options: {}, suggestions: rows, ...overrides }); +const receipt = (tags, overrides = {}) => ({ success: true, library_epoch: EPOCH, sha256: SHA, tags, tag_revision: 4, replayed: false, ...overrides }); + +/** Real createApiClient over a fetch whose answers come from routes[METHOD URL](call). */ +function harness(routes = {}) { + const calls = []; + const fetch = (url, init = {}) => { + const call = { url, method: init.method, init, body: init.body === undefined ? undefined : JSON.parse(init.body), aborted: false, signal: init.signal }; + calls.push(call); + return new Promise((resolve, reject) => { + init.signal?.addEventListener('abort', () => { + call.aborted = true; + reject(new DOMException('The operation was aborted.', 'AbortError')); + }); + const key = `${call.method} ${url.startsWith(CAPTURE) ? CAPTURE : url}`; + const route = routes[key]; + Promise.resolve().then(() => { + if (!route) return response(200, { success: true }); + return route(call); + }).then(resolve, reject); + }); + }; + return { + api: createApiClient(fetch), calls, + of: (method, url) => calls.filter(call => call.method === method && (url === CAPTURE ? call.url.startsWith(CAPTURE) : call.url === url)), + }; +} +function defaultRoutes(extra = {}) { + return { + [`POST ${OBSERVE}`]: () => response(200, observed()), + [`GET ${MODELS}`]: () => response(200, models()), + [`POST ${SUGGEST}`]: () => response(200, suggested()), + [`PUT ${TAGS}`]: call => response(200, receipt(call.body.tags)), + ...extra, + }; +} +async function opened(routes, options = {}) { + const { createImageTaggingController } = await load(); + const h = harness(defaultRoutes(routes)); + const c = createImageTaggingController({ api: h.api, target: TARGET, source: 'images', ...options }); + await c.open(); + await settle(); + return { c, h }; +} + +test('MODEL_OPTIONS lists legacy engines in mock order with D61 labels', async () => { + const { MODEL_OPTIONS } = await load(); + assert.deepEqual(MODEL_OPTIONS.map(option => [option.id, option.label]), [ + ['wd14-swinv2', 'WD14 SwinV2 · ~400 MB'], + ['wd14-vit', 'WD14 ViT · ~400 MB'], + ['gguf', 'GGUF JoyCaption (Recommended) · ~4.5 GB'], + ['hf', 'HuggingFace JoyCaption · ~16 GB'], + ]); + assert.ok(Object.isFrozen(MODEL_OPTIONS) && Object.isFrozen(MODEL_OPTIONS[0])); +}); + +test('dedupeTags trims, drops blanks and keeps the first spelling case-insensitively', async () => { + const { dedupeTags, parseManualTags } = await load(); + const input = [' Minimal ', 'minimal', '', 'warm_light', 'WARM_LIGHT', 7, 'dune']; + assert.deepEqual(dedupeTags(input), ['Minimal', 'warm_light', 'dune']); + assert.deepEqual(input[0], ' Minimal ', 'input is not mutated'); + assert.deepEqual(dedupeTags(undefined), []); + assert.deepEqual(parseManualTags(' a, ,B , b,c '), ['a', 'B', 'c']); + assert.deepEqual(parseManualTags(null), []); +}); + +test('the controller rejects a malformed target, source or api', async () => { + const { createImageTaggingController } = await load(); + const { api } = harness(); + assert.throws(() => createImageTaggingController({ api, target: { ...TARGET, root_index: true } }), TypeError); + assert.throws(() => createImageTaggingController({ api, target: { ...TARGET, root_scope: 'x' } }), TypeError); + assert.throws(() => createImageTaggingController({ api, target: { ...TARGET, path: '' } }), TypeError); + assert.throws(() => createImageTaggingController({ api, target: TARGET, source: 'elsewhere' }), TypeError); + assert.throws(() => createImageTaggingController({ api: {}, target: TARGET }), TypeError); + assert.throws(() => createImageTaggingController(), TypeError); +}); + +test('opens with saved tags joined by comma-space', async () => { + const { createImageTaggingController } = await load(); + const h = harness(defaultRoutes()); + const c = createImageTaggingController({ api: h.api, target: TARGET, source: 'images' }); + const seen = []; + const unsubscribe = c.subscribe(state => seen.push(state.phase)); + assert.equal(c.state.phase, 'observing'); + const pending = c.open(); + assert.equal(c.state.phase, 'observing'); + await pending; + await settle(); + assert.equal(c.state.phase, 'ready'); + assert.equal(c.state.manual, 'Minimal, warm_light'); + assert.deepEqual(c.state.savedTags, ['Minimal', 'warm_light']); + assert.equal(c.state.image.filename, 'dunes_00001.png'); + assert.equal(c.state.image.title, 'Desert pavilion'); + assert.equal(c.state.image.dimensions, '832 × 1216'); + assert.equal(c.state.image.previewUrl, CAPTURE + TOKEN); + assert.equal(c.state.model, 'wd14-swinv2'); + assert.equal(c.state.countText, 'Not generated'); + assert.equal(c.state.primaryLabel, 'Save manual tags'); + assert.equal(c.state.suggestionsText, 'Choose a model, then generate tags. Remove anything you do not want to add.'); + assert.equal(c.state.showScopeNote, false); + assert.equal(c.state.canSave, true); + assert.equal(c.state.canGenerate, true); + assert.ok(Object.isFrozen(c.state)); + assert.deepEqual(h.of('POST', OBSERVE)[0].body, { target: { ...TARGET } }); + assert.ok(seen.includes('ready')); + unsubscribe(); + c.setManual('x'); + assert.equal(seen.at(-1), 'ready', 'an unsubscribed listener is not called'); + assert.throws(() => c.subscribe('nope'), TypeError); +}); + +test('an image without known dimensions shows none and an unseen file starts empty', async () => { + const { c } = await opened({ + [`POST ${OBSERVE}`]: () => response(200, observed({ tags: [], tag_revision: 0, title: null }, { width: null, height: null })), + }); + assert.equal(c.state.image.dimensions, null); + assert.equal(c.state.manual, ''); + assert.equal(c.state.image.title, null); +}); + +test('instructions default to the server prompt', async () => { + const { c } = await opened({ + [`GET ${MODELS}`]: () => response(200, models({ suggestion_limit: 7, wd14_general_threshold: 0.5 })), + }); + assert.equal(c.state.options.instructions, DEFAULT_PROMPT); + assert.equal(c.state.options.suggestion_limit, 7); + assert.equal(c.state.options.general_threshold, 0.5); + assert.equal(c.state.options.character_threshold, 0.85); + c.setModel('gguf'); + assert.equal(c.state.options.instructions, DEFAULT_PROMPT); + c.setOption('instructions', 'only colours'); + c.setModel('hf'); + assert.equal(c.state.options.instructions, DEFAULT_PROMPT, 'a model change re-renders the defaults like the mock'); + assert.equal(c.state.options.suggestion_limit, 7, 'the suggestion count survives a model change'); +}); + +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' }))]) { + 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 }); + } +}); + +test('observe failures and malformed answers end in observe-error and retry observes again', async () => { + const { createImageTaggingController } = await load(); + let answer = () => fail(409, 'source-changed', 'The source changed. Refresh the image collection before tagging this file.'); + const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answer() })); + const c = createImageTaggingController({ api: h.api, target: TARGET }); + await c.open(); + assert.equal(c.state.phase, 'observe-error'); + assert.equal(c.state.statusText, 'The source changed. Refresh the image collection before tagging this file.'); + assert.equal(c.state.canSave, false); + assert.equal(c.state.canGenerate, false); + assert.equal(c.state.canRetry, true); + answer = () => response(200, observed({ tag_revision: -1 })); + await c.retry(); + assert.equal(c.state.phase, 'observe-error'); + assert.equal(c.state.statusText, 'The image could not be opened for tagging.'); + assert.equal(h.of('DELETE', CAPTURE).length, 1, 'a malformed answer still releases its capture'); + answer = () => Promise.reject(new TypeError('Failed to fetch')); + await c.retry(); + assert.equal(c.state.statusText, 'The image could not be opened for tagging.'); + answer = () => response(200, observed()); + await c.retry(); + await settle(); + assert.equal(c.state.phase, 'ready'); + assert.equal(h.of('POST', OBSERVE).length, 4); +}); + +test('model change clears suggestions count and status but keeps manual', async () => { + const { c } = await opened(); + c.setManual('Minimal, dune'); + await c.generate(); + assert.equal(c.state.countText, '3 suggestions'); + c.setModel('wd14-vit'); + assert.deepEqual(c.state.suggestions, []); + assert.equal(c.state.countText, 'Not generated'); + assert.equal(c.state.statusText, ''); + assert.equal(c.state.suggestionsText, 'Generate suggestions with the selected model before approving.'); + assert.equal(c.state.manual, 'Minimal, dune'); + assert.equal(c.state.model, 'wd14-vit'); + c.setModel('wd14-vit'); + assert.equal(c.state.model, 'wd14-vit'); + assert.throws(() => c.setModel('ollama'), TypeError); + assert.throws(() => c.setOption('model', 1), TypeError); +}); + +test('generate sends the model options and stages scored chips', async () => { + const { c, h } = await opened(); + c.setOption('general_threshold', '0.4'); + c.setOption('suggestion_limit', '3'); + assert.equal(await c.generate(), true); + const sent = h.of('POST', SUGGEST)[0].body; + assert.deepEqual(sent, { + target: { ...TARGET }, capture_token: TOKEN, expected_epoch: EPOCH, model_type: 'wd14-swinv2', + options: { general_threshold: 0.4, character_threshold: 0.85, suggestion_limit: 3 }, + }); + assert.deepEqual(c.state.suggestions.map(row => [row.name, row.label]), [['minimal', '99%'], ['editorial', '97%'], ['sandstone', '98%']]); + assert.equal(c.state.statusText, 'Suggestions from WD14 SwinV2 · ~400 MB. Approve tags to save.'); + assert.equal(c.state.suggestionsText, ''); + c.setModel('gguf'); + await c.generate(); + assert.deepEqual(h.of('POST', SUGGEST)[1].body.options, { instructions: DEFAULT_PROMPT, suggestion_limit: 3 }); +}); + +test('language models label chips Suggested and the count is capped at the requested limit', async () => { + const rows = Array.from({ length: 12 }, (_, i) => ({ name: `tag${i}`, confidence: null })); + const { c } = await opened({ [`POST ${SUGGEST}`]: () => response(200, suggested(rows, { model_type: 'gguf' })) }); + c.setModel('gguf'); + await c.generate(); + assert.equal(c.state.suggestions.length, 10); + assert.ok(c.state.suggestions.every(row => row.label === 'Suggested' && row.confidence === null)); + assert.equal(c.state.countText, '10 suggestions'); +}); + +test('an empty result shows the threshold copy and a malformed one is a generate error', async () => { + let answer = () => response(200, suggested([])); + const { c } = await opened({ [`POST ${SUGGEST}`]: () => answer() }); + await c.generate(); + assert.equal(c.state.suggestionsText, 'No suggestions met this threshold. Adjust it or enter tags manually.'); + assert.equal(c.state.countText, '0 suggestions'); + assert.equal(c.state.primaryLabel, 'Approve selected tags'); + answer = () => response(200, suggested([{ name: 4, confidence: 2 }])); + await c.generate(); + assert.equal(c.state.phase, 'generate-error'); + assert.equal(c.state.statusText, 'Auto-tagging failed. Check server logs for details.'); + answer = () => response(200, suggested([{ name: 'one', confidence: 1 }])); + assert.equal(await c.retry(), true, 'retry after a generate error generates again'); + assert.equal(c.state.countText, '1 suggestion'); + assert.equal(c.state.suggestions[0].label, '100%'); +}); + +test('invalid options send nothing', async () => { + const { c, h } = await opened(); + const { optionError } = await load(); + for (const [key, value] of [['general_threshold', '1.5'], ['general_threshold', ''], ['character_threshold', 'abc'], ['suggestion_limit', '0'], ['suggestion_limit', '2.5'], ['suggestion_limit', '101']]) { + c.setOption('general_threshold', 0.35); + c.setOption('character_threshold', 0.85); + c.setOption('suggestion_limit', 10); + c.setOption(key, value); + assert.equal(await c.generate(), false, `${key}=${value}`); + assert.equal(optionError(c.state.model, c.state.options).field, key); + } + c.setModel('gguf'); + c.setOption('instructions', ' '); + assert.equal(await c.generate(), false); + c.setOption('instructions', 'x'.repeat(4001)); + assert.equal(await c.generate(), false); + assert.equal(optionError('gguf', c.state.options).field, 'instructions'); + assert.equal(h.of('POST', SUGGEST).length, 0); + assert.equal(optionError('wd14-vit', { general_threshold: 0, character_threshold: 1, suggestion_limit: 100 }), null); +}); + +test('late generation for an old serial, closed or expired dialog is ignored', async () => { + const pending = []; + const { c, h } = await opened({ [`POST ${SUGGEST}`]: () => { const d = deferred(); pending.push(d); return d.promise; } }); + const first = c.generate(); + assert.equal(c.state.phase, 'generating'); + assert.equal(c.state.statusText, 'Generating suggestions…'); + assert.equal(c.state.canSave, false); + c.setModel('wd14-vit'); + assert.equal(c.state.phase, 'ready'); + assert.equal(h.of('POST', SUGGEST)[0].aborted, true); + await settle(); + pending[0].resolve(response(200, suggested())); + assert.equal(await first, false); + assert.equal(c.state.phase, 'ready', 'the superseded request neither stages chips nor reports its abort'); + assert.equal(c.state.statusText, ''); + assert.deepEqual(c.state.suggestions, []); + assert.equal(c.state.countText, 'Not generated'); + + const second = c.generate(); + c.cancel(); + await settle(); + pending[1].resolve(response(200, suggested())); + assert.equal(await second, false); + assert.deepEqual(c.state.suggestions, []); + assert.equal(c.state.closed, true); + assert.equal(await c.generate(), false, 'a closed dialog never generates'); + + const expired = await opened({ [`POST ${SUGGEST}`]: () => fail(409, 'capture-expired', 'The preview expired. Open the Tag dialog again.') }); + await expired.c.generate(); + assert.equal(expired.c.state.phase, 'capture-expired'); + assert.equal(expired.c.state.canSave, false); + assert.equal(expired.c.state.canGenerate, false); +}); + +test('a reopen while observing ignores the older observation and releases its capture', async () => { + const { createImageTaggingController } = await load(); + const answers = [deferred(), deferred()]; + let n = 0; + const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answers[n++].promise })); + const c = createImageTaggingController({ api: h.api, target: TARGET }); + const one = c.open(); + const two = c.open(); + answers[1].resolve(response(200, observed({}, { token: TOKEN2 }))); + await two; + answers[0].resolve(response(200, observed())); + await one; + await settle(); + assert.equal(c.state.image.previewUrl, CAPTURE + TOKEN2); + assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + TOKEN]); + c.dispose(); + assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + TOKEN, CAPTURE + TOKEN2]); +}); + +test('primary label toggles Save manual tags / Approve selected tags', async () => { + const { c } = await opened(); + assert.equal(c.state.primaryLabel, 'Save manual tags'); + await c.generate(); + assert.equal(c.state.primaryLabel, 'Approve selected tags'); c.setModel('wd14-vit'); assert.equal(c.state.primaryLabel, 'Save manual tags'); + assert.equal(c.state.countText, 'Not generated'); +}); + +test('remove drops one chip and the last removal shows the all-removed copy', async () => { + const { c } = await opened(); + await c.generate(); + c.remove('editorial'); + assert.deepEqual(c.state.suggestions.map(row => row.name), ['minimal', 'sandstone']); + assert.equal(c.state.countText, '2 suggestions'); + c.remove('missing'); + assert.equal(c.state.suggestions.length, 2); + c.remove('minimal'); + c.remove('sandstone'); + assert.deepEqual(c.state.suggestions, []); + assert.equal(c.state.suggestionsText, 'All suggestions removed. Generate again or enter tags manually.'); + assert.equal(c.state.primaryLabel, 'Approve selected tags'); +}); + +test('save sends manual then remaining chips deduplicated case-insensitively keeping the manual spelling', async () => { + const { createImageTaggingController } = await load(); + const h = harness(defaultRoutes({ + [`POST ${SUGGEST}`]: () => response(200, suggested([ + { name: 'minimal', confidence: 0.99 }, { name: 'editorial', confidence: 0.97 }, + { name: 'sandstone', confidence: 0.96 }, { name: 'desert', confidence: 0.9 }, + ])), + })); + const c = createImageTaggingController({ api: h.api, target: TARGET, source: 'review' }); + await c.open(); + await settle(); + assert.equal(c.state.showScopeNote, true); + await c.generate(); + c.remove('desert'); + assert.equal(c.state.primaryLabel, 'Approve selected tags'); + assert.equal(await c.save(), true); + const sent = h.of('PUT', TAGS)[0].body; + assert.deepEqual(sent.tags, ['Minimal', 'warm_light', 'editorial', 'sandstone']); // manual "Minimal", chip "minimal" dropped + assert.deepEqual(sent, { + target: { ...TARGET }, capture_token: TOKEN, sha256: SHA, expected_epoch: EPOCH, + expected_tag_revision: 3, tags: sent.tags, + }); + assert.equal(c.state.phase, 'saved'); + assert.deepEqual(c.state.savedTags, sent.tags); + assert.equal(c.state.tagRevision, 4); + assert.equal(c.state.toast, '4 tags saved for dunes_00001.png. Prompt association still awaits review.'); + assert.equal(c.state.canSave, false); + assert.equal(await c.save(), false, 'a saved dialog does not save twice'); +}); + +test('toast matches the mock with the review suffix', async () => { + const { c } = await opened({ [`PUT ${TAGS}`]: () => response(200, receipt(['one'])) }); + c.setManual('one'); + await c.save(); + assert.equal(c.state.toast, '1 tag saved for dunes_00001.png.'); + const many = await opened({}, { source: 'library' }); + await many.c.save(); + assert.equal(many.c.state.toast, '2 tags saved for dunes_00001.png.'); +}); + +test('empty manual and no chips sends the acknowledged empty set', async () => { + const { c, h } = await opened({ [`PUT ${TAGS}`]: () => response(200, receipt([])) }); + c.setManual(' , '); + await c.save(); + assert.deepEqual(h.of('PUT', TAGS)[0].body.tags, []); + assert.equal(c.state.toast, '0 tags saved for dunes_00001.png.'); +}); + +test('conflict keeps draft and exposes current saved tags', async () => { + let answer = () => fail(409, 'tag-conflict', 'The tags of this image changed since the dialog opened; review them first.', + { tags: ['night', 'rain'], tag_revision: 5 }); + const { c, h } = await opened({ [`PUT ${TAGS}`]: call => answer(call) }); + await c.generate(); + c.setManual('Minimal, dune'); + await c.save(); + assert.equal(c.state.phase, 'conflict'); + assert.deepEqual(c.state.currentTags, ['night', 'rain']); + assert.equal(c.state.manual, 'Minimal, dune'); + assert.equal(c.state.suggestions.length, 3); + assert.equal(c.state.statusText, 'The tags of this image changed since the dialog opened; review them first.'); + assert.equal(c.state.canSave, true); + answer = call => response(200, receipt(call.body.tags, { tag_revision: 6 })); + await c.save(); + assert.equal(h.of('PUT', TAGS)[1].body.expected_tag_revision, 5, 'the second save is against the revision the user has seen'); + assert.equal(c.state.phase, 'saved'); + assert.equal(c.state.currentTags, null); +}); + +test('lost response keeps draft and retry resends the identical body once', async () => { + const answers = [() => Promise.reject(new TypeError('Failed to fetch')), () => fail(504, null, 'Gateway Timeout'), + () => response(200, { success: true, tags: 'broken' })]; + for (const lost of answers) { + let answer = lost; + const { c, h } = await opened({ [`PUT ${TAGS}`]: call => answer(call) }); + c.setManual('Minimal, dune'); + await c.save(); + assert.equal(c.state.phase, 'unknown-outcome'); + assert.equal(c.state.statusText, 'The save could not be confirmed. Retry sends the same tags again.'); + assert.equal(c.state.canRetry, true); + assert.equal(c.state.canSave, false); + assert.equal(c.state.manual, 'Minimal, dune'); + c.setManual('changed meanwhile'); + const gate = deferred(); + answer = call => gate.promise.then(() => response(200, receipt(call.body.tags, { replayed: true }))); + const retried = c.retry(); + assert.equal(await c.retry(), false, 'a retry in flight is not sent twice'); + gate.resolve(); + assert.equal(await retried, true); + const puts = h.of('PUT', TAGS); + assert.equal(puts.length, 2); + assert.deepEqual(puts[1].body, puts[0].body); + assert.equal(c.state.phase, 'saved'); + assert.equal(await c.retry(), false); + } +}); + +test('write errors keep the draft and allow another save', async () => { + const { c } = await opened({ [`PUT ${TAGS}`]: () => fail(400, 'tag-limit', 'An image can carry at most 100 tags.') }); + await c.save(); + assert.equal(c.state.phase, 'write-error'); + assert.equal(c.state.statusText, 'An image can carry at most 100 tags.'); + assert.equal(c.state.canSave, true); + assert.equal(c.state.canRetry, false); + const generic = await opened({ [`PUT ${TAGS}`]: () => fail(500, 'save-failed', '') }); + await generic.c.save(); + assert.equal(generic.c.state.phase, 'write-error'); + assert.equal(generic.c.state.statusText, 'The tags could not be saved.'); +}); + +test('storage-changed and file-changed disable save until reopen', async () => { + for (const [code, phase] of [['storage-changed', 'storage-changed'], ['file-changed', 'file-changed'], ['source-changed', 'file-changed'], ['capture-expired', 'capture-expired']]) { + const message = `server says ${code}`; + let answer = () => fail(409, code, message); + const { c, h } = await opened({ [`PUT ${TAGS}`]: () => answer() }); + c.setManual('kept draft'); + await c.save(); + assert.equal(c.state.phase, phase); + assert.equal(c.state.statusText, message); + assert.equal(c.state.canSave, false); + assert.equal(c.state.canGenerate, false); + assert.equal(await c.save(), false); + assert.equal(h.of('PUT', TAGS).length, 1); + await c.open(); + await settle(); + assert.equal(c.state.phase, 'ready', 'reopen observes again'); + assert.equal(c.state.manual, 'Minimal, warm_light'); + assert.equal(h.of('DELETE', CAPTURE).length, 1, 'reopen releases the old capture'); + } +}); + +test('video disables generate with the still-image message', async () => { + const reason = 'Auto-tagging is available for supported still images only.'; + const { c, h } = await opened({ + [`POST ${OBSERVE}`]: () => response(200, observed({ filename: 'clip.mp4' }, { media_type: 'video', generate: { supported: false, reason } })), + }); + assert.equal(c.state.image.mediaType, 'video'); + assert.equal(c.state.canGenerate, false); + assert.equal(c.state.canSave, true); + assert.equal(c.state.statusText, reason); + c.setModel('gguf'); + assert.equal(c.state.statusText, reason, 'the reason survives a model change'); + assert.equal(await c.generate(), false); + assert.equal(h.of('POST', SUGGEST).length, 0); +}); + +test('model-unavailable and model-busy set the status line only', async () => { + let answer = () => response(200, suggested()); + const { c } = await opened({ [`POST ${SUGGEST}`]: () => answer() }); + await c.generate(); + const before = c.state; + for (const [code, message] of [ + ['model-unavailable', 'WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.'], + ['model-busy', 'A tagging model is busy with another operation. Try again when it finishes.'], + ['unsupported-media', 'Images larger than 4096 px per side or 16,777,216 px are not supported.'], + ]) { + answer = () => fail(409, code, message); + assert.equal(await c.generate(), false); + assert.equal(c.state.statusText, message); + assert.deepEqual(c.state.suggestions, before.suggestions); + assert.equal(c.state.countText, before.countText); + assert.equal(c.state.primaryLabel, before.primaryLabel); + assert.equal(c.state.manual, before.manual); + assert.equal(c.state.canSave, true); + assert.equal(c.state.canGenerate, true); + } + answer = () => Promise.reject(new TypeError('Failed to fetch')); + await c.generate(); + assert.equal(c.state.statusText, 'Auto-tagging failed. Check server logs for details.'); + c.setModel('wd14-vit'); + assert.equal(c.state.phase, 'ready'); +}); + +test('a storage restore during generate requires a reopen', async () => { + const { c } = await opened({ [`POST ${SUGGEST}`]: () => fail(409, 'storage-changed', 'Prompt storage was restored; reopen the Tag dialog.') }); + await c.generate(); + assert.equal(c.state.phase, 'storage-changed'); + assert.equal(c.state.canSave, false); + assert.equal(await c.retry(), false); +}); + +test('dispose aborts requests and releases the capture once', async () => { + const pending = deferred(); + const { c, h } = await opened({ [`POST ${SUGGEST}`]: () => pending.promise }); + const token = TOKEN; + const releases = []; + const generating = c.generate(); + let notified = 0; + c.subscribe(() => { notified += 1; }); + c.dispose(); + c.dispose(); + c.cancel(); + for (const call of h.of('DELETE', CAPTURE)) releases.push(decodeURIComponent(call.url.slice(CAPTURE.length))); + assert.deepEqual(releases, [token]); + assert.equal(h.of('POST', SUGGEST)[0].aborted, true); + pending.resolve(response(200, suggested())); + assert.equal(await generating, false); + assert.equal(notified, 0, 'a disposed controller never notifies'); + c.setManual('ignored'); + c.remove('x'); + assert.notEqual(c.state.manual, 'ignored'); + assert.equal(await c.save(), false); + assert.equal(await c.open(), undefined); + assert.equal(h.of('POST', OBSERVE).length, 1); +}); + +test('dispose while observing aborts the models read and releases a late capture', async () => { + const { createImageTaggingController } = await load(); + const late = deferred(); + const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => late.promise, [`GET ${MODELS}`]: () => new Promise(() => {}) })); + const c = createImageTaggingController({ api: h.api, target: TARGET }); + const opening = c.open(); + c.dispose(); + assert.equal(h.of('GET', MODELS)[0].aborted, true); + late.resolve(response(200, observed())); + await opening; + await settle(); + assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + TOKEN]); + assert.equal(c.state.phase, 'observing'); +}); + +test('a failing capture release is not reported as a dialog error', async () => { + const { c, h } = await opened({ [`DELETE ${CAPTURE}`]: () => Promise.reject(new TypeError('offline')) }); + c.dispose(); + await settle(); + assert.equal(h.of('DELETE', CAPTURE).length, 1); + assert.equal(c.state.phase, 'ready'); +}); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index df2ed5d..09ceeef 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -221,6 +221,12 @@ "functions": 98, "statements": 98 }, + "workspace/image-tagging-state.mjs": { + "lines": 98, + "branches": 92, + "functions": 98, + "statements": 98 + }, "workspace/images-category.mjs": { "lines": 98, "branches": 98, diff --git a/web/js/api-client.js b/web/js/api-client.js index 4fe4039..c407f05 100644 --- a/web/js/api-client.js +++ b/web/js/api-client.js @@ -15,7 +15,7 @@ "use strict"; class ApiError extends Error { - constructor(status, message, code = null, recovery = null) { + constructor(status, message, code = null, recovery = null, current = null) { super(message); this.name = "ApiError"; this.status = status; @@ -32,6 +32,14 @@ this.recovery = { action: "smaller-limit", limit: recovery.limit }; } } + // A tag-conflict answer carries the image's current tag set; only a + // well-formed one is kept, copied, so the dialog can show it. + this.current = null; + if (current && Array.isArray(current.tags) && current.tags.length <= 100 && + current.tags.every(tag => typeof tag === "string") && + Number.isSafeInteger(current.tag_revision) && current.tag_revision >= 0) { + this.current = { tags: current.tags.slice(), tag_revision: current.tag_revision }; + } } } @@ -76,11 +84,11 @@ const serverMessage = data && typeof data === "object" ? data.error || data.message : undefined; if (!response.ok) { - throw new ApiError(response.status, serverMessage || response.statusText || `HTTP ${response.status}`, data?.code, data?.recovery); + throw new ApiError(response.status, serverMessage || response.statusText || `HTTP ${response.status}`, data?.code, data?.recovery, data); } if (!parsed) throw new ApiError(response.status, "Invalid JSON response"); if (data && typeof data === "object" && data.success === false) { - throw new ApiError(response.status, serverMessage || "Request failed", data.code, data.recovery); + throw new ApiError(response.status, serverMessage || "Request failed", data.code, data.recovery, data); } return data; } diff --git a/web/js/workspace/image-tagging-state.mjs b/web/js/workspace/image-tagging-state.mjs new file mode 100644 index 0000000..5ef7bc1 --- /dev/null +++ b/web/js/workspace/image-tagging-state.mjs @@ -0,0 +1,434 @@ +/** + * Tag image dialog state: observe, staged suggestions and byte-exact saves (#249 C1). + * + * UI-agnostic. A dialog renders `controller.state` (a frozen snapshot replaced + * on every change) and calls its methods. open() captures the file and reads its + * saved tags; generate() stages the top suggestions as removable chips, written + * only by save(). Every read is guarded by a serial: a model change, a reopen, + * cancel() or dispose() supersedes older requests, so a late answer can never + * stage chips or supply a token. Writes are sent once, without an abort signal + * (aborting cannot undo a write); a lost answer is resent only by retry(), with + * the identical body (the server answers a duplicate as `replayed`). + */ +import {noun} from './plural.mjs'; + +export const MODEL_OPTIONS = Object.freeze([ + Object.freeze({id: 'wd14-swinv2', label: 'WD14 SwinV2 · ~400 MB', classifier: true}), + Object.freeze({id: 'wd14-vit', label: 'WD14 ViT · ~400 MB', classifier: true}), + Object.freeze({id: 'gguf', label: 'GGUF JoyCaption (Recommended) · ~4.5 GB', classifier: false}), + Object.freeze({id: 'hf', label: 'HuggingFace JoyCaption · ~16 GB', classifier: false}), +]); + +const OBSERVE_URL = '/prompt_manager/image-annotations/observe'; +const SUGGEST_URL = '/prompt_manager/image-annotations/suggest'; +const TAGS_URL = '/prompt_manager/image-annotations/tags'; +const CAPTURE_URL = '/prompt_manager/image-annotations/capture/'; +const MODELS_URL = '/prompt_manager/autotag/models'; +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; +const MAX_INSTRUCTION_CHARS = 4000; +const EDITABLE = new Set(['ready', 'generate-error', 'write-error', 'conflict']); +const RETRYABLE = new Set(['observe-error', 'generate-error', 'unknown-outcome']); +// Answers after which only a fresh observation can save (the capture or epoch is gone). +const REOPEN = {'storage-changed': 'storage-changed', 'file-changed': 'file-changed', 'source-changed': 'file-changed', 'capture-expired': 'capture-expired'}; +// Generic failure envelopes whose fixed sentence the dialog words itself. +const GENERIC_CODES = new Set(['observe-failed', 'suggest-failed', 'save-failed', 'capture-error']); +// A proxy timeout or bad gateway cannot tell whether the server committed the write. +const UNCONFIRMED_STATUS = new Set([502, 503, 504]); + +const COPY = Object.freeze({ + initial: 'Choose a model, then generate tags. Remove anything you do not want to add.', // D60 + modelChanged: 'Generate suggestions with the selected model before approving.', + allRemoved: 'All suggestions removed. Generate again or enter tags manually.', // D60 + empty: 'No suggestions met this threshold. Adjust it or enter tags manually.', + generating: 'Generating suggestions…', + observeFailed: 'The image could not be opened for tagging.', + generateFailed: 'Auto-tagging failed. Check server logs for details.', + saveFailed: 'The tags could not be saved.', + unknown: 'The save could not be confirmed. Retry sends the same tags again.', + reviewSuffix: ' Prompt association still awaits review.', +}); + +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value); +const hex64 = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); +const captureToken = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value); +const count = value => Number.isSafeInteger(value) && value >= 0; +const dimension = value => value === null || (Number.isSafeInteger(value) && value > 0); +const strings = value => Array.isArray(value) && value.every(item => typeof item === 'string'); +const unit = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1; +const limit = value => Number.isInteger(value) && value >= 1 && value <= MAX_SUGGESTIONS; + +function requireValue(valid, message) {if (!valid) throw new TypeError(message);} +function freeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) {Object.values(value).forEach(freeze); Object.freeze(value);} + return value; +} +// Duck-typed by name: this ES module cannot import the UMD api-client.js. +function isApiError(error) {return error instanceof Error && error.name === 'ApiError' && Number.isInteger(error.status);} +function messageOf(error, fallback) { + return isApiError(error) && error.code && !GENERIC_CODES.has(error.code) && error.message ? error.message : fallback; +} +const modelOf = id => MODEL_OPTIONS.find(option => option.id === id); +// An input's value arrives as text; blank text is not zero. +const numberOf = value => (typeof value === 'string' ? (value.trim() === '' ? NaN : Number(value)) : value); + +/** Trimmed, non-blank names with case-insensitive repeats dropped; the first spelling and position win. */ +export function dedupeTags(names) { + const seen = new Set(); + const result = []; + for (const value of Array.isArray(names) ? names : []) { + if (typeof value !== 'string') continue; + const name = value.trim(); + const key = name.toLowerCase(); + if (!name || seen.has(key)) continue; + seen.add(key); + result.push(name); + } + return result; +} + +/** The manual field's comma-separated names. */ +export function parseManualTags(text) { + return dedupeTags(typeof text === 'string' ? text.split(',') : []); +} + +/** `null` when *options* are valid for *model*, else `{field, message}` (nothing is sent). */ +export function optionError(model, options) { + const option = modelOf(model); + requireValue(option, 'Unknown tagging model'); + if (option.classifier) { + for (const field of ['general_threshold', 'character_threshold']) { + if (!unit(numberOf(options[field]))) return {field, message: 'Enter a confidence from 0 to 1.'}; + } + } else { + const text = options.instructions; + if (typeof text !== 'string' || !text.trim() || text.length > MAX_INSTRUCTION_CHARS) { + return {field: 'instructions', message: `Enter tagging instructions of at most ${MAX_INSTRUCTION_CHARS} characters.`}; + } + } + if (!limit(numberOf(options.suggestion_limit))) { + return {field: 'suggestion_limit', message: `Enter a whole number of suggestions from 1 to ${MAX_SUGGESTIONS}.`}; + } + return null; +} + +function requestOptions(model, options) { + const body = {suggestion_limit: numberOf(options.suggestion_limit)}; + if (modelOf(model).classifier) { + return {general_threshold: numberOf(options.general_threshold), character_threshold: numberOf(options.character_threshold), ...body}; + } + return {instructions: options.instructions, ...body}; +} + +function validateTarget(target) { + requireValue(object(target) && Number.isSafeInteger(target.root_index) && target.root_index >= 0 && + hex64(target.root_scope) && typeof target.path === 'string' && target.path.length > 0 && target.path.length <= 1024, + 'Invalid image target'); + return Object.freeze({root_index: target.root_index, root_scope: target.root_scope, path: target.path}); +} + +function validateObserved(value) { + requireValue(object(value) && value.success === true && uuid(value.library_epoch) && object(value.capture), 'Invalid observation'); + const capture = value.capture; + requireValue(captureToken(capture.token) && hex64(capture.sha256) && typeof capture.preview_url === 'string' && + capture.preview_url.startsWith(CAPTURE_URL) && dimension(capture.width) && dimension(capture.height) && + ['image', 'video'].includes(capture.media_type) && object(capture.generate) && + typeof capture.generate.supported === 'boolean' && + (capture.generate.reason === null || typeof capture.generate.reason === 'string'), 'Invalid capture'); + requireValue(typeof value.filename === 'string' && (value.title === null || typeof value.title === 'string') && + strings(value.tags) && count(value.tag_revision), 'Invalid observation'); + return value; +} + +function validateSuggestions(value, cap) { + requireValue(object(value) && value.success === true && Array.isArray(value.suggestions), 'Invalid suggestions'); + const seen = new Set(); + const rows = []; + for (const row of value.suggestions) { + requireValue(object(row) && typeof row.name === 'string' && (row.confidence === null || unit(row.confidence)), 'Invalid suggestion'); + const name = row.name.trim(); + const key = name.toLowerCase(); + if (!name || seen.has(key)) continue; + seen.add(key); + const label = row.confidence === null ? 'Suggested' : `${Math.round(row.confidence * 100)}%`; + rows.push({name, confidence: row.confidence, label}); + } + return rows.slice(0, cap); +} + +function validateReceipt(value) { + requireValue(object(value) && value.success === true && strings(value.tags) && count(value.tag_revision), 'Invalid tag receipt'); + return value; +} + +/** Thresholds, instructions and suggestion count from the server, else the documented defaults. */ +async function readDefaults(api, signal) { + try { + const value = await api.get(MODELS_URL, {signal, cache: 'no-store'}); + if (!object(value)) return DEFAULTS; + return { + 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, + }; + } catch { + // The defaults keep the dialog usable; Generate reports a real failure itself. + return DEFAULTS; + } +} + +function initialState(source) { + return { + phase: 'observing', closed: false, source, showScopeNote: source === 'review', + image: null, savedTags: [], tagRevision: 0, currentTags: null, manual: '', + model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, generated: false, suggestions: [], + suggestionsText: COPY.initial, statusText: '', generateSupported: false, generateReason: null, toast: null, + }; +} + +/** Derived labels and permissions; never stored independently of the fields they follow. */ +function derive(state) { + const editable = EDITABLE.has(state.phase); + return { + ...state, + primaryLabel: state.generated ? 'Approve selected tags' : 'Save manual tags', + countText: state.generated ? `${state.suggestions.length} ${noun(state.suggestions.length, 'suggestion')}` : 'Not generated', + canSave: editable, + canGenerate: editable && state.generateSupported, + canRetry: RETRYABLE.has(state.phase), + }; +} + +export function createImageTaggingController({api, target, source = 'images'} = {}) { + requireValue(object(api) && ['get', 'post', 'put', 'del'].every(name => typeof api[name] === 'function'), 'An api client is required'); + const imageTarget = validateTarget(target); + requireValue(SOURCES.has(source), 'Unknown tagging source'); + + let state = freeze(derive(initialState(source))); + const listeners = new Set(); + let closed = false; + let capture = null; + let epoch = null; + let defaults = DEFAULTS; + let openSerial = 0; + let generateSerial = 0; + let saveSerial = 0; + let readAbort = null; + let generateAbort = null; + let lastBody = null; + + function set(patch) { + if (closed) return; + state = freeze(derive({...state, ...patch})); + for (const listener of [...listeners]) listener(state); + } + + function releaseToken(token) { + // Best effort: an unreleased capture expires on the server's idle timeout. + api.del(CAPTURE_URL + encodeURIComponent(token)).catch(() => {}); + } + function releaseCapture() { + if (capture) releaseToken(capture.token); + capture = null; + } + function stopGenerating() { + generateSerial += 1; + generateAbort?.abort(); + generateAbort = null; + } + function reasonText() { + return state.generateSupported ? '' : (state.generateReason || ''); + } + + async function open() { + if (closed) return; + const serial = ++openSerial; + stopGenerating(); + releaseCapture(); + readAbort?.abort(); + readAbort = new AbortController(); + const defaultsRead = readDefaults(api, readAbort.signal); + lastBody = null; + set({phase: 'observing', statusText: '', toast: null, currentTags: null, generated: false, suggestions: [], suggestionsText: COPY.initial}); + let value; + try { + const answer = await api.post(OBSERVE_URL, {target: {...imageTarget}}); + if (closed || serial !== openSerial) { + if (captureToken(answer?.capture?.token)) releaseToken(answer.capture.token); + return; + } + try { + value = validateObserved(answer); + } catch (error) { + if (captureToken(answer?.capture?.token)) releaseToken(answer.capture.token); + throw error; + } + } catch (error) { + if (closed || serial !== openSerial) return; + set({phase: 'observe-error', statusText: messageOf(error, COPY.observeFailed)}); + return; + } + capture = {token: value.capture.token, sha256: value.capture.sha256}; + epoch = value.library_epoch; + defaults = await defaultsRead; + if (closed || serial !== openSerial) return; + const {width, height, media_type: mediaType, preview_url: previewUrl, generate} = value.capture; + set({ + phase: 'ready', + image: {filename: value.filename, title: value.title, width, height, mediaType, previewUrl, + dimensions: width && height ? `${width} × ${height}` : null}, + savedTags: value.tags.slice(), tagRevision: value.tag_revision, manual: value.tags.join(', '), + options: {...defaults}, generateSupported: generate.supported, generateReason: generate.reason, + statusText: generate.supported ? '' : (generate.reason || ''), + }); + } + + function setManual(text) { + set({manual: typeof text === 'string' ? text : ''}); + } + + function setModel(id) { + requireValue(modelOf(id), 'Unknown tagging model'); + if (closed || id === state.model) return; + stopGenerating(); + const phase = state.phase === 'generating' || state.phase === 'generate-error' ? 'ready' : state.phase; + set({ + phase, model: id, generated: false, suggestions: [], suggestionsText: COPY.modelChanged, statusText: reasonText(), + // Like the mock, a model change re-renders its fields with the defaults; the count is kept (D68). + options: {...state.options, general_threshold: defaults.general_threshold, + character_threshold: defaults.character_threshold, instructions: defaults.instructions}, + }); + } + + function setOption(key, value) { + requireValue(OPTION_KEYS.has(key), 'Unknown tagging option'); + set({options: {...state.options, [key]: value}}); + } + + async function generate() { + if (closed || !state.canGenerate || optionError(state.model, state.options)) return false; + stopGenerating(); + const serial = generateSerial; + const controller = new AbortController(); + generateAbort = controller; + const model = state.model; + const options = requestOptions(model, state.options); + const stale = () => closed || serial !== generateSerial; + set({phase: 'generating', statusText: COPY.generating}); + let rows; + try { + const value = await api.post(SUGGEST_URL, { + target: {...imageTarget}, capture_token: capture.token, expected_epoch: epoch, model_type: model, options, + }, {signal: controller.signal}); + if (stale()) return false; + rows = validateSuggestions(value, options.suggestion_limit); + } catch (error) { + if (stale()) return false; + const phase = isApiError(error) && REOPEN[error.code]; + set(phase ? {phase, statusText: error.message} : {phase: 'generate-error', statusText: messageOf(error, COPY.generateFailed)}); + return false; + } + generateAbort = null; + set({ + phase: 'ready', generated: true, suggestions: rows, suggestionsText: rows.length ? '' : COPY.empty, + statusText: `Suggestions from ${modelOf(model).label}. Approve tags to save.`, + }); + return true; + } + + function remove(name) { + const suggestions = state.suggestions.filter(row => row.name !== name); + if (closed || suggestions.length === state.suggestions.length) return; + set({suggestions, suggestionsText: suggestions.length ? '' : COPY.allRemoved}); + } + + async function send(body) { + const serial = ++saveSerial; + lastBody = body; + set({phase: 'saving', statusText: ''}); + let value; + try { + value = validateReceipt(await api.put(TAGS_URL, body)); + } catch (error) { + if (closed || serial !== saveSerial) return false; + failSave(error); + return false; + } + if (closed || serial !== saveSerial) return false; + lastBody = null; + const n = value.tags.length; + set({ + phase: 'saved', savedTags: value.tags.slice(), tagRevision: value.tag_revision, currentTags: null, statusText: '', + toast: `${n} ${noun(n, 'tag')} saved for ${state.image.filename}.${source === 'review' ? COPY.reviewSuffix : ''}`, + }); + return true; + } + + function failSave(error) { + if (!isApiError(error) || error.status < 400 || UNCONFIRMED_STATUS.has(error.status)) { + set({phase: 'unknown-outcome', statusText: COPY.unknown}); + return; + } + lastBody = null; + if (error.code === 'tag-conflict') { + // The user reviews the current set; a second Save is against the revision now shown. + const current = error.current; + set({phase: 'conflict', statusText: error.message, currentTags: current ? current.tags.slice() : null, + ...(current ? {tagRevision: current.tag_revision} : {})}); + return; + } + if (REOPEN[error.code]) { + set({phase: REOPEN[error.code], statusText: error.message}); + return; + } + set({phase: 'write-error', statusText: messageOf(error, COPY.saveFailed)}); + } + + async function save() { + if (closed || !state.canSave) return false; + const tags = dedupeTags([...parseManualTags(state.manual), ...state.suggestions.map(row => row.name)]); + return send({ + target: {...imageTarget}, capture_token: capture.token, sha256: capture.sha256, + expected_epoch: epoch, expected_tag_revision: state.tagRevision, tags, + }); + } + + async function retry() { + if (closed || !state.canRetry) return false; + if (state.phase === 'observe-error') return open(); + if (state.phase === 'generate-error') return generate(); + return send(lastBody); + } + + function cancel() { + if (closed) return; + set({closed: true}); + closed = true; + openSerial += 1; + saveSerial += 1; + stopGenerating(); + readAbort?.abort(); + readAbort = null; + releaseCapture(); + } + + function dispose() { + listeners.clear(); + cancel(); + } + + function subscribe(listener) { + requireValue(typeof listener === 'function', 'A listener function is required'); + listeners.add(listener); + return () => listeners.delete(listener); + } + + return { + get state() {return state;}, + subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, cancel, dispose, + }; +}