fix(tagging): refuse non-text default ids and keep one listener per list (#431)

A default instructions id that is not text is a 400 on save and falls back
with a warning on load; a damaged list warns once and migrates the old
template afresh. Option clicks use one listener on the list, the reset
note clears once the defaults are saved, and a duplicated long name is cut
to fit.
This commit is contained in:
Vito Sansevero
2026-10-31 18:28:55 -07:00
parent f2f40b80e8
commit 259ad65b3f
5 changed files with 98 additions and 10 deletions
+14 -5
View File
@@ -775,17 +775,23 @@ class AutotagConfig:
f"autotag.defaults.template {error}; using the built-in one"
)
entries: List[Dict[str, str]] = []
damaged = False
if "instructions" in defaults:
try:
entries = validate_instructions(defaults["instructions"])
except ValueError as error:
damaged = True
config_logger.warning(
f"autotag.defaults.instructions {_reason(error)}; "
"using Built-in only"
)
chosen = {engine: BUILTIN_INSTRUCTIONS_ID for engine in INSTRUCTION_ENGINES}
stored = defaults.get("default_instructions")
if "default_instructions" in defaults and (
if damaged:
# Both defaults fall back with the list (its one warning says so);
# the old template is migrated as on a first upgrade.
pass
elif "default_instructions" in defaults and (
not isinstance(stored, dict) or set(stored) != set(INSTRUCTION_ENGINES)
):
config_logger.warning(
@@ -795,8 +801,11 @@ class AutotagConfig:
elif isinstance(stored, dict):
ids = {item["id"] for item in entries}
for engine in INSTRUCTION_ENGINES:
if stored[engine] == BUILTIN_INSTRUCTIONS_ID or stored[engine] in ids:
chosen[engine] = stored[engine]
value = stored[engine]
if isinstance(value, str) and (
value == BUILTIN_INSTRUCTIONS_ID or value in ids
):
chosen[engine] = value
else:
config_logger.warning(
f"autotag.defaults.default_instructions.{engine} names no "
@@ -805,8 +814,8 @@ class AutotagConfig:
cls._migrate_template(
entries,
chosen,
first="instructions" not in defaults,
keep_defaults="default_instructions" in defaults,
first=damaged or "instructions" not in defaults,
keep_defaults=not damaged and "default_instructions" in defaults,
)
cls.INSTRUCTIONS = tuple(entries)
cls.DEFAULT_INSTRUCTIONS = chosen
+1 -1
View File
@@ -314,7 +314,7 @@ def validate_default_instructions(value: Any, entries) -> Dict[str, str]:
raise ValueError("Default instructions must name GGUF and HuggingFace.")
ids = {BUILTIN_INSTRUCTIONS_ID} | {item["id"] for item in entries}
for engine in INSTRUCTION_ENGINES:
if value[engine] not in ids:
if not isinstance(value[engine], str) or value[engine] not in ids:
raise ValueError(
f"Default for {ENGINE_LABELS[engine]} must be one of the saved "
"instructions."
+34 -1
View File
@@ -83,7 +83,9 @@ async function mounted({ host = HOST } = {}) {
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');
const pick = name => byId('instruction-list').children.find(option => option.children[0].textContent === name).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 };
}
@@ -222,3 +224,34 @@ test('arrow keys move the selection; before the read everything is disabled', as
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(); }
});
+29 -1
View File
@@ -128,6 +128,10 @@ class TestValidation(unittest.TestCase):
{"gguf": "builtin"},
"Default instructions must name GGUF and HuggingFace.",
),
(
{"gguf": [], "hf": "builtin"},
"Default for GGUF JoyCaption must be one of the saved instructions.",
),
("builtin", "Default instructions must name GGUF and HuggingFace."),
):
with self.subTest(value=value):
@@ -232,7 +236,7 @@ class TestLoad(ConfigTestCase):
],
"default_instructions": {"gguf": "0123456789ab", "hf": "builtin"},
},
warnings=2,
warnings=1, # one warning; the defaults follow the list silently
)
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
self.assertEqual(
@@ -347,5 +351,29 @@ class TestGetConfig(ConfigTestCase):
self.assertNotIn("template", AutotagConfig.get_config()["defaults"])
class TestDamagedValues(ConfigTestCase):
def test_a_non_text_default_falls_back_with_a_warning(self):
self.load({"default_instructions": {"gguf": [], "hf": "builtin"}}, warnings=1)
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
def test_a_damaged_list_warns_once_and_migrates_the_template_afresh(self):
custom = "My own tags, please."
self.load(
{
"template": custom,
"instructions": "broken",
"default_instructions": {"gguf": "0123456789ab", "hf": "builtin"},
},
warnings=1,
)
self.assertEqual(
self.state(),
(
[entry(sha12(custom), "Custom template", custom)],
{"gguf": sha12(custom), "hf": sha12(custom)},
),
)
if __name__ == "__main__":
unittest.main()
+20 -2
View File
@@ -74,6 +74,16 @@ export function freeName(base, entries) {
for (let n = 2; ; n += 1) if (!taken.has(fold(`${base} (${n})`))) return `${base} (${n})`;
}
/** "<name> copy" (then "(2)"…), the name cut so the whole stays within the 60-character limit. */
export function copyName(name, entries) {
for (let room = MAX_NAME_CHARS - ' copy'.length; room > 0; room -= 1) {
const base = [...String(name ?? '').trim()].slice(0, room).join('').trimEnd();
const candidate = freeName(INSTRUCTIONS_COPY.copyName(base), entries);
if (characters(candidate) <= MAX_NAME_CHARS) return candidate;
}
return freeName(INSTRUCTIONS_COPY.copyName(BUILTIN_NAME), entries);
}
/** A new entry id: 12 lowercase hex characters from `random` (crypto.getRandomValues), never one already used. */
export function newId(entries, random = bytes => globalThis.crypto.getRandomValues(bytes)) {
const used = new Set(entries.map(entry => entry.id));
@@ -249,6 +259,7 @@ export function savedInstructionsBlock({ scope, drafts, random }) {
list.replaceChildren(...entries.map(entry => {
const option = el('div');
option.id = `instruction-option-${entry.id}`;
option.dataset.id = entry.id;
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', String(entry.id === selectedId));
option.append(el('span', entry.name));
@@ -258,13 +269,14 @@ export function savedInstructionsBlock({ scope, drafts, random }) {
if (chosen[engine] === entry.id) badges.append(el('span', INSTRUCTIONS_COPY.defaultFor(short), 'chip blue'));
}
if (badges.children.length) option.append(badges);
scope.listen(option, 'click', () => select(entry.id));
return option;
}));
list.setAttribute('aria-activedescendant', `instruction-option-${selectedId}`);
}
function repaint() {
// The note a deleted default left goes once the defaults are saved (or set back).
if (!Object.hasOwn(drafts.changes('host'), DEFAULTS_KEY)) reset.textContent = '';
const loaded = ready();
for (const control of [create, duplicate, remove, nameInput, textArea, ...selects.map(item => item.select)]) {
control.disabled = !loaded;
@@ -310,9 +322,15 @@ export function savedInstructionsBlock({ scope, drafts, random }) {
scope.listen(duplicate, 'click', () => {
const entries = allEntries(values());
const source = selected();
add({ id: newId(entries, random), name: freeName(INSTRUCTIONS_COPY.copyName(source.name), entries), text: source.text });
add({ id: newId(entries, random), name: copyName(source.name, entries), text: source.text });
});
scope.listen(remove, 'click', deleteSelected);
// One listener for every option, however often the list is rebuilt.
scope.listen(list, 'click', event => {
let node = event.target;
while (node && node !== list && node.getAttribute?.('role') !== 'option') node = node.parentNode;
if (node && node !== list) select(node.dataset.id);
});
scope.listen(nameInput, 'input', () => edit('name', nameInput.value));
scope.listen(textArea, 'input', () => edit('text', textArea.value));
scope.listen(list, 'keydown', event => {