feat(annotations): image tagging controller with staged suggestions
Add the UI-agnostic Tag image dialog controller (#249 C1): observe the file into a capture, prefill the saved tags and the server's tagging defaults, stage the top suggestions as removable chips, and save manual tags plus the remaining chips with case-insensitive dedupe (manual spelling first). Every read is serial-guarded; saves are sent once and a lost answer is resent identically only by Retry. Conflict, storage, file and capture outcomes keep the draft and gate Save on a reopen. ApiError now carries a validated copy of a tag-conflict body's current tag set so the dialog can show it.
This commit is contained in:
@@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
+11
-3
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user