* feat(maintenance): repair old Pair prompts' empty category and tags (#550) Squashed from PR #579. Closes #550. * feat(tagging): saved instructions with a default per engine (#431) Squashed from PR #587. * feat(tagging): pick saved instructions in the tagging dialogs (#431) Squashed from PR #588. Closes #431.
258 lines
16 KiB
JavaScript
258 lines
16 KiB
JavaScript
// System › Auto-tagging "Saved instructions" (#431, D203): Built-in read-only, New, Duplicate, Delete (a deleted default
|
||
// resets to Built-in), the caps, the editor drafts and the per-engine default selects.
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
const instructionsModule = import('../../web/js/workspace/system-instructions.mjs');
|
||
const stateModule = import('../../web/js/workspace/system-state.mjs');
|
||
const coreModule = import('../../web/js/shared/toolkit/core.js');
|
||
|
||
/** A minimal DOM: elements with children, attributes, listeners (aborted by their signal) and focus. */
|
||
function fakeDOM() {
|
||
const previous = global.document;
|
||
class Element {
|
||
constructor(tag) {
|
||
this.tagName = tag.toUpperCase();
|
||
this.children = [];
|
||
this.attributes = {};
|
||
this.listeners = [];
|
||
this.dataset = {};
|
||
this.className = '';
|
||
this.id = '';
|
||
this.value = '';
|
||
this.title = '';
|
||
this.disabled = false;
|
||
this._text = '';
|
||
}
|
||
get localName() { return this.tagName.toLowerCase(); }
|
||
get textContent() { return this._text + this.children.map(child => (typeof child === 'string' ? child : child.textContent)).join(''); }
|
||
set textContent(text) { this.children = []; this._text = String(text); }
|
||
append(...nodes) { for (const node of nodes) { if (typeof node !== 'string') node.parentNode = this; this.children.push(node); } }
|
||
replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); }
|
||
querySelectorAll(selector) {
|
||
assert.equal(selector, 'button');
|
||
const found = [];
|
||
const walk = node => { for (const child of node.children) if (typeof child !== 'string') { if (child.tagName === 'BUTTON') found.push(child); walk(child); } };
|
||
walk(this);
|
||
return found;
|
||
}
|
||
focus() { global.document.activeElement = this; }
|
||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
|
||
removeAttribute(name) { delete this.attributes[name]; }
|
||
addEventListener(type, callback, options = {}) {
|
||
if (options.signal?.aborted) return;
|
||
const entry = { type, callback };
|
||
this.listeners.push(entry);
|
||
options.signal?.addEventListener('abort', () => { this.listeners = this.listeners.filter(item => item !== entry); });
|
||
}
|
||
dispatch(type, event = {}) {
|
||
return Promise.all(this.listeners.filter(item => item.type === type).map(({ callback }) => callback({ type, target: this, ...event })));
|
||
}
|
||
}
|
||
global.document = { createElement: tag => new Element(tag), activeElement: null };
|
||
const walk = (node, found = []) => { found.push(node); node.children.forEach(child => typeof child !== 'string' && walk(child, found)); return found; };
|
||
return { find: (root, predicate) => walk(root).find(predicate), restore() { global.document = previous; } };
|
||
}
|
||
|
||
|
||
const PHOTO = { id: '0123456789ab', name: 'Custom template', text: 'Photo tags.' };
|
||
const ANIME = { id: '0123456789ac', name: 'Anime', text: 'Anime tags.' };
|
||
const HOST = { autotag_builtin_instructions: 'Built-in text.', autotag_instructions: [PHOTO, ANIME],
|
||
autotag_default_instructions: { gguf: PHOTO.id, hf: PHOTO.id } };
|
||
|
||
async function mounted({ host = HOST } = {}) {
|
||
const { savedInstructionsBlock } = await instructionsModule;
|
||
const { createSystemDrafts } = await stateModule;
|
||
const { createScope } = await coreModule;
|
||
const dom = fakeDOM();
|
||
const drafts = createSystemDrafts({
|
||
readBrowser: () => ({}), readRuntime: () => ({}), readHost: async () => ({ values: host, revision: 'sha256:one' }),
|
||
writeBrowser: async () => ({}), writeHost: async () => ({ success: true, persisted: true }), writeRuntime: async () => ({}),
|
||
});
|
||
const scope = createScope();
|
||
let counter = 0;
|
||
const random = bytes => bytes.map(() => (counter += 1));
|
||
const block = savedInstructionsBlock({ scope, drafts, random });
|
||
const root = global.document.createElement('div');
|
||
root.append(...block.nodes);
|
||
scope.cleanup(drafts.subscribe(block.repaint));
|
||
block.repaint();
|
||
await drafts.reloadHost();
|
||
const byId = id => dom.find(root, node => node.id === id);
|
||
const options = () => byId('instruction-list').children.map(option => [option.children[0].textContent,
|
||
option.children[1]?.children.map(badge => badge.textContent) ?? [], option.getAttribute('aria-selected')]);
|
||
const click = id => byId(id).dispatch('click');
|
||
// A click on an option reaches the list's one listener (as it bubbles in a browser).
|
||
const pick = name => byId('instruction-list').dispatch('click', { target: byId('instruction-list').children
|
||
.find(option => option.children[0].textContent === name).children[0] });
|
||
return { dom, drafts, scope, block, byId, options, click, pick, root };
|
||
}
|
||
|
||
test('helpers: Built-in first, defaults, free names, ids and the server sentences', async () => {
|
||
const { allEntries, defaultEntry, freeName, newId, listProblem, entryNote } = await instructionsModule;
|
||
assert.deepEqual(allEntries(HOST).map(entry => entry.name), ['Built-in', 'Custom template', 'Anime']);
|
||
assert.equal(allEntries(HOST)[0].text, 'Built-in text.');
|
||
assert.equal(defaultEntry(HOST, 'hf').text, 'Photo tags.');
|
||
assert.equal(defaultEntry({ ...HOST, autotag_default_instructions: { gguf: 'gone', hf: 'builtin' } }, 'gguf').name, 'Built-in');
|
||
assert.equal(freeName('New instructions', allEntries(HOST)), 'New instructions');
|
||
assert.equal(freeName('anime', allEntries(HOST)), 'anime (2)');
|
||
assert.equal(freeName('Anime', [...allEntries(HOST), { name: 'Anime (2)' }]), 'Anime (3)');
|
||
let calls = 0;
|
||
const id = newId([{ id: '000000000000' }], bytes => { calls += 1; return bytes.fill(calls === 1 ? 0 : 171); });
|
||
assert.equal(id, 'abababababab');
|
||
assert.match(newId([]), /^[0-9a-f]{12}$/);
|
||
const list = entries => listProblem({ autotag_instructions: entries });
|
||
assert.equal(list([PHOTO]), null);
|
||
assert.equal(list([{ ...PHOTO, name: ' ' }]), 'Saved instructions: entry 1 needs a name.');
|
||
assert.equal(list([PHOTO, { ...ANIME, name: 'x'.repeat(61) }]), 'Saved instructions: entry 2 name must be at most 60 characters.');
|
||
assert.equal(list([{ ...PHOTO, name: 'a\nb' }]), 'Saved instructions: entry 1 name must not contain a line break.');
|
||
assert.equal(list([{ ...PHOTO, name: 'built-in' }]), 'Saved instructions: the name “Built-in” is reserved.');
|
||
assert.equal(list([PHOTO, { ...ANIME, name: ' custom TEMPLATE ' }]), 'Saved instructions: entry 2 name “custom TEMPLATE” is already used.');
|
||
assert.equal(list([{ ...PHOTO, text: ' ' }]), 'Saved instructions: entry 1 text must be non-empty text.');
|
||
assert.equal(list([{ ...PHOTO, text: 'x'.repeat(4001) }]), 'Saved instructions: entry 1 text must be at most 4000 characters.');
|
||
const long = { ...PHOTO, text: 'x'.repeat(4001) };
|
||
assert.equal(entryNote(long, [long]), 'The text must be at most 4,000 characters.');
|
||
});
|
||
|
||
test('the list shows Built-in read-only first, the saved entries and their default chips', async () => {
|
||
const view = await mounted();
|
||
try {
|
||
assert.deepEqual(view.options(), [['Built-in', ['Read-only'], 'true'],
|
||
['Custom template', ['Default for GGUF', 'Default for HuggingFace'], 'false'], ['Anime', [], 'false']]);
|
||
assert.equal(view.byId('instructions-count').textContent, '2 of 20 saved · Built‑in is not counted.');
|
||
assert.equal(view.byId('instruction-name').value, 'Built-in');
|
||
assert.equal(view.byId('instruction-name').readOnly, true);
|
||
assert.equal(view.byId('instruction-text').value, 'Built-in text.');
|
||
assert.equal(view.byId('instruction-text').readOnly, true);
|
||
assert.equal(view.byId('instruction-note').textContent, 'Built-in instructions can’t be changed. Duplicate them to make your own.');
|
||
assert.equal(view.byId('instruction-delete').disabled, true);
|
||
assert.equal(view.byId('instruction-list').getAttribute('role'), 'listbox');
|
||
assert.equal(view.block.nodes[0].dataset.key, 'autotag_instructions');
|
||
assert.deepEqual(view.byId('instruction-default-gguf').children.map(option => option.textContent), ['Built-in', 'Custom template', 'Anime']);
|
||
assert.equal(view.byId('instruction-default-gguf').value, PHOTO.id);
|
||
assert.equal(view.byId('instruction-default-hf').dataset.key, 'autotag_default_instructions');
|
||
} finally { view.scope.dispose(); view.dom.restore(); }
|
||
});
|
||
|
||
test('editing a saved entry drafts the list; its counts and note follow', async () => {
|
||
const view = await mounted();
|
||
try {
|
||
await view.pick('Anime');
|
||
assert.equal(view.byId('instruction-name').readOnly, false);
|
||
assert.equal(view.byId('instruction-delete').disabled, false);
|
||
view.byId('instruction-name').value = 'Anime tags';
|
||
await view.byId('instruction-name').dispatch('input');
|
||
view.byId('instruction-text').value = 'Tags, anime.';
|
||
await view.byId('instruction-text').dispatch('input');
|
||
assert.deepEqual(view.drafts.get('host', 'autotag_instructions'), [PHOTO, { ...ANIME, name: 'Anime tags', text: 'Tags, anime.' }]);
|
||
assert.equal(view.byId('instruction-name').parentNode.children[0].children[1].textContent, '10 of 60');
|
||
assert.equal(view.byId('instruction-text').parentNode.children[0].children[1].textContent, '12 of 4,000 characters');
|
||
view.byId('instruction-name').value = 'custom template';
|
||
await view.byId('instruction-name').dispatch('input');
|
||
assert.equal(view.byId('instruction-note').textContent, 'The name “custom template” is already used.');
|
||
// Built-in is never edited.
|
||
await view.pick('Built-in');
|
||
view.byId('instruction-text').value = 'changed';
|
||
await view.byId('instruction-text').dispatch('input');
|
||
assert.equal(view.drafts.get('host', 'autotag_instructions')[0], PHOTO);
|
||
} finally { view.scope.dispose(); view.dom.restore(); }
|
||
});
|
||
|
||
test('New and Duplicate append an entry with a fresh id and a free name; the caps disable them', async () => {
|
||
const view = await mounted();
|
||
try {
|
||
await view.click('instruction-new');
|
||
const added = view.drafts.get('host', 'autotag_instructions').at(-1);
|
||
assert.match(added.id, /^[0-9a-f]{12}$/);
|
||
assert.deepEqual([added.name, added.text], ['New instructions', '']);
|
||
assert.equal(view.options().at(-1)[2], 'true');
|
||
assert.equal(view.byId('instruction-note').textContent, 'The text must not be empty.');
|
||
await view.pick('Built-in');
|
||
await view.click('instruction-duplicate');
|
||
assert.deepEqual(view.drafts.get('host', 'autotag_instructions').at(-1).name, 'Built-in copy');
|
||
assert.equal(view.drafts.get('host', 'autotag_instructions').at(-1).text, 'Built-in text.');
|
||
const full = await mounted({ host: { ...HOST, autotag_instructions: Array.from({ length: 20 },
|
||
(_, i) => ({ id: i.toString(16).padStart(12, '0'), name: `n${i}`, text: 't' })) } });
|
||
try {
|
||
assert.equal(full.byId('instruction-new').disabled, true);
|
||
assert.equal(full.byId('instruction-duplicate').disabled, true);
|
||
assert.equal(full.byId('instruction-cap').hidden, false);
|
||
await full.click('instruction-new');
|
||
assert.equal(full.drafts.get('host', 'autotag_instructions').length, 20);
|
||
} finally { full.scope.dispose(); }
|
||
} finally { view.scope.dispose(); view.dom.restore(); }
|
||
});
|
||
|
||
test('deleting a default resets that engine to Built-in and says so; a select change clears the note', async () => {
|
||
const view = await mounted();
|
||
try {
|
||
await view.pick('Custom template');
|
||
await view.click('instruction-delete');
|
||
assert.deepEqual(view.drafts.get('host', 'autotag_instructions'), [ANIME]);
|
||
assert.deepEqual(view.drafts.get('host', 'autotag_default_instructions'), { gguf: 'builtin', hf: 'builtin' });
|
||
assert.equal(view.byId('instruction-reset').textContent, 'GGUF and HuggingFace now use Built-in.');
|
||
assert.equal(view.options()[1][2], 'true', 'the next entry is selected');
|
||
view.byId('instruction-default-hf').value = ANIME.id;
|
||
await view.byId('instruction-default-hf').dispatch('change');
|
||
assert.deepEqual(view.drafts.get('host', 'autotag_default_instructions'), { gguf: 'builtin', hf: ANIME.id });
|
||
assert.equal(view.byId('instruction-reset').textContent, '');
|
||
await view.click('instruction-delete');
|
||
assert.equal(view.byId('instruction-reset').textContent, 'HuggingFace now uses Built-in.');
|
||
assert.deepEqual(view.options().map(([name]) => name), ['Built-in']);
|
||
} finally { view.scope.dispose(); view.dom.restore(); }
|
||
});
|
||
|
||
test('arrow keys move the selection; before the read everything is disabled', async () => {
|
||
const view = await mounted();
|
||
try {
|
||
const list = view.byId('instruction-list');
|
||
await list.dispatch('keydown', { key: 'ArrowDown' });
|
||
assert.equal(view.byId('instruction-name').value, 'Custom template');
|
||
await list.dispatch('keydown', { key: 'End' });
|
||
assert.equal(view.byId('instruction-name').value, 'Anime');
|
||
await list.dispatch('keydown', { key: 'ArrowDown' });
|
||
assert.equal(view.byId('instruction-name').value, 'Anime');
|
||
await list.dispatch('keydown', { key: 'Home' });
|
||
assert.equal(view.byId('instruction-name').value, 'Built-in');
|
||
await list.dispatch('keydown', { key: 'a' });
|
||
assert.equal(list.getAttribute('aria-activedescendant'), 'instruction-option-builtin');
|
||
} finally { view.scope.dispose(); view.dom.restore(); }
|
||
const unread = await mounted({ host: {} });
|
||
try {
|
||
assert.equal(unread.byId('instruction-new').disabled, true);
|
||
assert.equal(unread.byId('instruction-default-gguf').disabled, true);
|
||
} finally { unread.scope.dispose(); unread.dom.restore(); }
|
||
});
|
||
|
||
test('the reset note goes once the defaults are saved; a long name is cut to fit its copy suffix', async () => {
|
||
const view = await mounted();
|
||
try {
|
||
await view.pick('Custom template');
|
||
await view.click('instruction-delete');
|
||
assert.equal(view.byId('instruction-reset').textContent, 'GGUF and HuggingFace now use Built-in.');
|
||
await view.drafts.save();
|
||
assert.equal(view.byId('instruction-reset').textContent, '');
|
||
await view.pick('Anime');
|
||
view.byId('instruction-name').value = 'x'.repeat(60);
|
||
await view.byId('instruction-name').dispatch('input');
|
||
await view.click('instruction-duplicate');
|
||
const copy = view.drafts.get('host', 'autotag_instructions').at(-1).name;
|
||
assert.equal(copy, `${'x'.repeat(55)} copy`);
|
||
await view.click('instruction-duplicate');
|
||
assert.equal([...view.drafts.get('host', 'autotag_instructions').at(-1).name].length <= 60, true);
|
||
} finally { view.scope.dispose(); view.dom.restore(); }
|
||
});
|
||
|
||
test('option clicks are one listener on the list, not one per repaint', async () => {
|
||
const view = await mounted();
|
||
try {
|
||
const list = view.byId('instruction-list');
|
||
const before = list.listeners.length;
|
||
for (let i = 0; i < 5; i += 1) await view.pick('Anime');
|
||
assert.equal(list.listeners.length, before);
|
||
assert.equal(view.byId('instruction-list').children.every(option => option.listeners.length === 0), true);
|
||
assert.equal(view.byId('instruction-name').value, 'Anime');
|
||
} finally { view.scope.dispose(); view.dom.restore(); }
|
||
});
|