Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/system-instructions.mjs
T
Vito 3295ff8b20 Pair category repair (#550) and saved instructions + dialog picker (#431) (#590)
* 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.
2026-11-01 03:01:07 +00:00

352 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* System › Auto-tagging "Saved instructions" (#431, D203): named language-model instructions and the default of each
* language-model engine. It replaces the concept's "LLM tag template" textarea.
*
* A `.setting-block` (`data-setting="tag-instructions"`, `data-key="autotag_instructions"`): the label, the
* description with the count ("N of 20 saved · Built\u2011in is not counted."), a `role="listbox"` list (Built-in first,
* "Read-only", then the saved entries, each with its "Default for GGUF" / "Default for HuggingFace" chips), "+ New",
* "Duplicate" and "Delete", and the editor beside the list (under it below 700 px): "Name" (60 characters, live count)
* and "Text" ("N of 4,000 characters"), both read-only for Built-in with its note. Then two `.setting-row` selects,
* "Default for GGUF JoyCaption" and "Default for HuggingFace JoyCaption" (`data-key="autotag_default_instructions"`),
* and the note a deleted default leaves ("GGUF now uses Built-in."). Host drafts: `autotag_instructions` (the saved
* entries, never Built-in) and `autotag_default_instructions`; the server pairs them on save. Built-in's text is the
* read-only `autotag_builtin_instructions` of GET /settings. Ids are 12 lowercase hex characters made here.
*/
import { createButton } from '../shared/toolkit/core.js';
import { settingRow } from './system-controls.mjs';
export const INSTRUCTIONS_KEY = 'autotag_instructions';
export const DEFAULTS_KEY = 'autotag_default_instructions';
export const BUILTIN_TEXT_KEY = 'autotag_builtin_instructions';
export const BUILTIN_ID = 'builtin';
export const BUILTIN_NAME = 'Built-in';
export const MAX_SAVED = 20;
export const MAX_NAME_CHARS = 60;
export const MAX_TEXT_CHARS = 4000;
export const ENGINES = Object.freeze([['gguf', 'GGUF', 'GGUF JoyCaption'], ['hf', 'HuggingFace', 'HuggingFace JoyCaption']]);
export const INSTRUCTIONS_COPY = Object.freeze({
label: 'Saved instructions',
description: 'Instructions for the language-model engines. Built-in can’t be changed; duplicate it to make your own.',
count: saved => `${saved} of ${MAX_SAVED} saved · Built\u2011in is not counted.`,
readOnly: 'Read-only',
defaultFor: short => `Default for ${short}`,
newLabel: '+ New',
duplicate: 'Duplicate',
delete: 'Delete',
cap: `At most ${MAX_SAVED} saved instructions.`,
name: 'Name',
text: 'Text',
nameCount: length => `${length} of ${MAX_NAME_CHARS}`,
textCount: length => `${length.toLocaleString('en-US')} of ${MAX_TEXT_CHARS.toLocaleString('en-US')} characters`,
builtinNote: 'Built-in instructions can’t be changed. Duplicate them to make your own.',
newName: 'New instructions',
copyName: name => `${name} copy`,
defaultRow: long => `Default for ${long}`,
defaultDescription: 'The Tag image dialog and batches start from this.',
reset: shorts => `${shorts.join(' and ')} now ${shorts.length > 1 ? 'use' : 'uses'} Built-in.`,
});
/** Characters as Python's `len` counts them (code points). */
const characters = text => [...String(text ?? '')].length;
const fold = text => String(text ?? '').trim().toLowerCase();
/** The saved entries of the drafts (`[]` when unread or damaged). */
export const savedEntries = values => (Array.isArray(values?.[INSTRUCTIONS_KEY]) ? values[INSTRUCTIONS_KEY] : []);
/** Built-in (its text from GET /settings) and then the saved entries. */
export function allEntries(values) {
const text = typeof values?.[BUILTIN_TEXT_KEY] === 'string' ? values[BUILTIN_TEXT_KEY] : '';
return [{ id: BUILTIN_ID, name: BUILTIN_NAME, text }, ...savedEntries(values)];
}
/** The drafted default entry of `engine` (`gguf` or `hf`); Built-in when the id names nothing. */
export function defaultEntry(values, engine) {
const entries = allEntries(values);
const id = values?.[DEFAULTS_KEY]?.[engine];
return entries.find(entry => entry.id === id) ?? entries[0];
}
/** `base`, else `base (2)`, `base (3)`… whichever no entry uses (case-insensitive). */
export function freeName(base, entries) {
const taken = new Set(entries.map(entry => fold(entry.name)));
if (!taken.has(fold(base))) return base;
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));
for (;;) {
const id = [...random(new Uint8Array(6))].map(byte => byte.toString(16).padStart(2, '0')).join('');
if (!used.has(id)) return id;
}
}
/** The editor's note for a saved entry's name and text (the mock's short sentences), or ''. */
export function entryNote(entry, entries) {
const name = String(entry.name ?? '').trim();
if (!name) return 'Needs a name.';
if (characters(name) > MAX_NAME_CHARS) return `The name must be at most ${MAX_NAME_CHARS} characters.`;
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(name)) return 'The name must not contain a line break.';
if (fold(name) === fold(BUILTIN_NAME)) return 'The name “Built-in” is reserved.';
if (entries.some(other => other !== entry && other.id !== BUILTIN_ID && fold(other.name) === fold(name))) {
return `The name “${name}” is already used.`;
}
if (!String(entry.text ?? '').trim()) return 'The text must not be empty.';
if (characters(entry.text) > MAX_TEXT_CHARS) return `The text must be at most ${MAX_TEXT_CHARS.toLocaleString('en-US')} characters.`;
return '';
}
/**
* The server's sentence for the first saved entry it would refuse (Built-in is not counted), else null. The Save row
* shows the server's own refusal; this one holds back Preview tagging review and the batch Review.
*/
export function listProblem(values) {
const entries = savedEntries(values);
for (const [index, entry] of entries.entries()) {
// As the server checks them: a repeated name is the later entry's problem.
const note = entryNote(entry, entries.slice(0, index + 1));
if (!note) continue;
const n = index + 1;
if (note.startsWith('Needs')) return `Saved instructions: entry ${n} needs a name.`;
if (note.includes('reserved')) return 'Saved instructions: the name “Built-in” is reserved.';
if (note.includes('already used')) return `Saved instructions: entry ${n} name “${String(entry.name).trim()}” is already used.`;
if (note.includes('line break')) return `Saved instructions: entry ${n} name must not contain a line break.`;
if (note.startsWith('The name')) return `Saved instructions: entry ${n} name must be at most ${MAX_NAME_CHARS} characters.`;
if (note.includes('empty')) return `Saved instructions: entry ${n} text must be non-empty text.`;
return `Saved instructions: entry ${n} text must be at most ${MAX_TEXT_CHARS} characters.`;
}
return null;
}
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/** A `.field` with a label row (text and a muted count) and its control. */
function field(id, label, control) {
const wrap = el('div', undefined, 'field');
const caption = el('label');
caption.htmlFor = id;
const count = el('span', '', 'small muted');
caption.append(el('span', label), count);
control.id = id;
wrap.append(caption, control);
return { wrap, count };
}
/**
* Build the block and the two default rows; `drafts` is the System draft store. Returns `{nodes, repaint}`: System
* appends `nodes` in order and calls `repaint` whenever the drafts change.
*/
export function savedInstructionsBlock({ scope, drafts, random }) {
const block = el('div', undefined, 'setting-block');
block.dataset.setting = 'tag-instructions';
block.dataset.key = INSTRUCTIONS_KEY;
const text = el('div', undefined, 'setting-block-text');
const label = el('label', INSTRUCTIONS_COPY.label);
label.id = 'instructions-label';
const description = el('p', `${INSTRUCTIONS_COPY.description} `);
const count = el('span', '');
count.id = 'instructions-count';
description.append(count);
text.append(label, description);
const layout = el('div', undefined, 'instructions-layout');
const side = el('div', undefined, 'instructions-side');
const list = el('div', undefined, 'instruction-list');
list.id = 'instruction-list';
list.setAttribute('role', 'listbox');
list.setAttribute('aria-labelledby', 'instructions-label');
list.tabIndex = 0;
const actions = el('div', undefined, 'row-actions instruction-actions');
const create = createButton(INSTRUCTIONS_COPY.newLabel);
create.id = 'instruction-new';
const duplicate = createButton(INSTRUCTIONS_COPY.duplicate);
duplicate.id = 'instruction-duplicate';
const remove = createButton(INSTRUCTIONS_COPY.delete, 'danger');
remove.id = 'instruction-delete';
actions.append(create, duplicate, remove);
const cap = el('p', INSTRUCTIONS_COPY.cap, 'small');
cap.id = 'instruction-cap';
cap.hidden = true;
side.append(list, actions, cap);
const editor = el('div', undefined, 'instruction-editor');
const nameInput = el('input');
nameInput.autocomplete = 'off';
const name = field('instruction-name', INSTRUCTIONS_COPY.name, nameInput);
const textArea = el('textarea');
textArea.rows = 7;
const body = field('instruction-text', INSTRUCTIONS_COPY.text, textArea);
const note = el('p', '', 'small');
note.id = 'instruction-note';
note.setAttribute('role', 'status');
editor.append(name.wrap, body.wrap, note);
layout.append(side, editor);
block.append(text, layout);
const selects = ENGINES.map(([engine, , long]) => {
const row = settingRow({ id: `instruction-default-${engine}`, label: INSTRUCTIONS_COPY.defaultRow(long),
description: INSTRUCTIONS_COPY.defaultDescription, control: { type: 'select', options: [[BUILTIN_ID, BUILTIN_NAME]] } });
const select = row.children[1];
select.dataset.key = DEFAULTS_KEY;
return { engine, select, row };
});
const reset = el('p', '', 'small instruction-reset');
reset.id = 'instruction-reset';
reset.setAttribute('role', 'status');
let selectedId = BUILTIN_ID;
const values = () => ({ [INSTRUCTIONS_KEY]: drafts.get('host', INSTRUCTIONS_KEY),
[DEFAULTS_KEY]: drafts.get('host', DEFAULTS_KEY), [BUILTIN_TEXT_KEY]: drafts.get('host', BUILTIN_TEXT_KEY) });
const ready = () => Array.isArray(drafts.get('host', INSTRUCTIONS_KEY)) && drafts.get('host', DEFAULTS_KEY) !== undefined;
const defaults = () => ({ ...(drafts.get('host', DEFAULTS_KEY) ?? {}) });
const setEntries = next => drafts.set('host', INSTRUCTIONS_KEY, next);
const selected = () => allEntries(values()).find(entry => entry.id === selectedId) ?? allEntries(values())[0];
/** Replace the selected saved entry's `key` with `value` (a new list of new objects). */
function edit(key, value) {
if (selectedId === BUILTIN_ID) return;
setEntries(savedEntries(values()).map(entry => (entry.id === selectedId ? { ...entry, [key]: value } : entry)));
}
function add(entry) {
const entries = savedEntries(values());
if (!ready() || entries.length >= MAX_SAVED) return;
selectedId = entry.id;
reset.textContent = '';
setEntries([...entries, entry]);
}
function deleteSelected() {
const entries = savedEntries(values());
const index = entries.findIndex(entry => entry.id === selectedId);
if (index < 0) return;
const gone = entries[index];
const next = defaults();
const shorts = [];
for (const [engine, short] of ENGINES) {
if (next[engine] === gone.id) { next[engine] = BUILTIN_ID; shorts.push(short); }
}
const left = entries.filter(entry => entry.id !== gone.id);
selectedId = (left[index] ?? left[index - 1])?.id ?? BUILTIN_ID;
reset.textContent = shorts.length ? INSTRUCTIONS_COPY.reset(shorts) : '';
if (shorts.length) drafts.set('host', DEFAULTS_KEY, next);
setEntries(left);
}
function select(id) {
selectedId = id;
repaint();
}
function renderList(entries, chosen) {
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));
const badges = el('span', undefined, 'badges');
if (entry.id === BUILTIN_ID) badges.append(el('span', INSTRUCTIONS_COPY.readOnly, 'chip'));
for (const [engine, short] of ENGINES) {
if (chosen[engine] === entry.id) badges.append(el('span', INSTRUCTIONS_COPY.defaultFor(short), 'chip blue'));
}
if (badges.children.length) option.append(badges);
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;
}
if (!loaded) return;
const current = values();
const entries = allEntries(current);
if (!entries.some(entry => entry.id === selectedId)) selectedId = BUILTIN_ID;
const chosen = defaults();
const saved = savedEntries(current);
renderList(entries, chosen);
const entry = selected();
const builtin = entry.id === BUILTIN_ID;
// A focused field keeps what the user is typing.
if (document.activeElement !== nameInput) nameInput.value = entry.name;
if (document.activeElement !== textArea) textArea.value = entry.text;
nameInput.readOnly = textArea.readOnly = builtin;
name.count.textContent = builtin ? '' : INSTRUCTIONS_COPY.nameCount(characters(entry.name));
body.count.textContent = INSTRUCTIONS_COPY.textCount(characters(entry.text));
const problem = builtin ? '' : entryNote(entry, entries);
note.textContent = builtin ? INSTRUCTIONS_COPY.builtinNote : problem;
note.className = problem ? 'small skip-problem' : 'small';
const full = saved.length >= MAX_SAVED;
create.disabled = duplicate.disabled = full;
remove.disabled = builtin;
cap.hidden = !full;
count.textContent = INSTRUCTIONS_COPY.count(saved.length);
for (const { engine, select: menu } of selects) {
menu.replaceChildren(...entries.map(item => {
const option = el('option', item.name);
option.value = item.id;
return option;
}));
menu.value = chosen[engine] ?? BUILTIN_ID;
}
}
scope.listen(create, 'click', () => {
const entries = allEntries(values());
add({ id: newId(entries, random), name: freeName(INSTRUCTIONS_COPY.newName, entries), text: '' });
textArea.focus?.();
});
scope.listen(duplicate, 'click', () => {
const entries = allEntries(values());
const source = selected();
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 => {
const ids = allEntries(values()).map(entry => entry.id);
const at = ids.indexOf(selectedId);
const next = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: ids.length - 1 }[event.key];
if (next === undefined) return;
event.preventDefault?.();
select(ids[Math.max(0, Math.min(ids.length - 1, next))]);
});
for (const { engine, select: menu } of selects) {
scope.listen(menu, 'change', () => {
reset.textContent = '';
drafts.set('host', DEFAULTS_KEY, { ...defaults(), [engine]: menu.value });
});
}
return { nodes: [block, ...selects.map(item => item.row), reset], repaint };
}