Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/vocabulary-state.mjs
T
Vito Sansevero d4d99b7e18 feat(vocabulary): tag search, tag sort and an Untagged prompts entry (#444)
Tags & categories gains a server-side tag search and sort (keyset pages,
cursor scoped to the query) and an "Untagged prompts" row whose Browse opens
the Library with tag_mode=untagged. Refine this view gets Tag match, saved
Library views keep it, and the routes echo the applied query so a stale
server is an error, never an unfiltered list. Recorded as D149.

Refs #444
2026-10-28 17:07:50 -07:00

345 lines
19 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.
/**
* Native Tags & categories state: bounded vocabulary pages and guarded commands.
*
* UI-agnostic. The page renders `stream.state` / `command.state` and calls their
* methods. Reads are owned by their stream: reset() or dispose() aborts the read
* in flight and a superseded response never lands. Writes are sent once, without
* an abort signal (aborting cannot undo a write), and are never replayed.
*
* Category pages are keyed by each value's lowest prompt id, which can move
* between pages after an edit, so appended rows are deduplicated (first wins)
* and every command that may have changed storage calls `onInvalidate` so the
* page resets its streams to the first page.
*/
import { countOf } from './plural.mjs';
const LIMIT = 100;
const BASE = '/prompt_manager/vocabulary';
const KINDS = {
// image_count (#249 image tags) is optional until every server reports it.
tags: {key: row => row.id, valid: row => id(row.id) && typeof row.name === 'string' && row.name.length > 0 && optionalCount(row.image_count)},
categories: {key: row => row.value, valid: row => typeof row.value === 'string' && row.value.length > 0},
};
const RECOVERY = {'storage-changed': 'storage-changed', 'vocabulary-conflict': 'conflict'};
// A proxy timeout or bad gateway cannot tell whether the server committed the write.
const UNCONFIRMED_STATUS = new Set([502, 503, 504]);
// Phases that may send a command; done, storage-changed and unknown-outcome need reset().
const READY = new Set(['idle', 'previewed', 'write-error', 'conflict']);
const IMPACT_FIELDS = ['positive_prompts', 'other_prompts', 'negative_variants', 'archived_negative_variants'];
/** #444 tag sorts as [value, label]: `created` (today's order) first, then the legacy tag manager's four. */
export const TAG_SORTS = Object.freeze([['created', 'Oldest first'], ['name', 'Name A–Z'], ['name-desc', 'Name Z–A'],
['prompts', 'Most prompts'], ['prompts-asc', 'Fewest prompts']].map(pair => Object.freeze(pair)));
// The route's cap on a tag search (database/vocabulary.py _MAX_SEARCH).
export const TAG_SEARCH_MAX = 200;
const DEFAULT_QUERY = Object.freeze({search: '', sort: 'created'});
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const id = value => Number.isSafeInteger(value) && value > 0;
const count = value => Number.isSafeInteger(value) && value >= 0;
const optionalCount = value => value === undefined || count(value);
const epochOf = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value);
const hexToken = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
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;
}
function storageChanged() {
return Object.assign(new Error('Prompt storage was restored. Reload the lists before continuing.'), {code: 'storage-changed'});
}
// 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);}
/** A tags query, trimmed; categories take none. Throws on anything the route would refuse. */
function tagQuery(kind, query) {
if (query === undefined) return DEFAULT_QUERY;
requireValue(kind === 'tags' && object(query), 'Only tags take a search and sort');
const {search = '', sort = 'created'} = query;
requireValue(typeof search === 'string' && search.trim().length <= TAG_SEARCH_MAX, 'Invalid tag search');
requireValue(TAG_SORTS.some(([value]) => value === sort), 'Invalid tag sort');
return Object.freeze({search: search.trim(), sort});
}
function validatePage(page, kind, cursor, query) {
const rows = page?.[kind];
const {key, valid} = KINDS[kind];
// A search or sort the server did not echo was not applied (an older server): never show that list as filtered.
const plain = query.search === '' && query.sort === 'created' && page?.search === undefined && page?.sort === undefined;
const applied = plain || (page?.search === query.search && page?.sort === query.sort);
requireValue(applied && optionalCount(page?.untagged_count), `Unconfirmed ${kind} search`);
requireValue(object(page) && page.success === true && epochOf(page.library_epoch) && Array.isArray(rows) &&
rows.every(row => object(row) && valid(row) && count(row.prompt_count)) &&
new Set(rows.map(key)).size === rows.length && count(page.total) && typeof page.has_more === 'boolean' &&
(page.next_cursor === null || (typeof page.next_cursor === 'string' && page.next_cursor.length > 0)) &&
page.has_more === (page.next_cursor !== null) && !(page.has_more && page.next_cursor === cursor),
`Invalid ${kind} page`);
}
/** One bounded, append-only list of tags or categories. */
/**
* One bounded, append-only list of tags or categories. A tags stream takes `query` (`{search, sort}`, #444) and
* `reset(query)` replaces it; `state.untaggedCount` is the last count a tags page reported (kept across resets so the
* Untagged prompts row does not flicker), `null` before any.
*/
export function createVocabularyStream({api, kind, onChange = () => {}, query: initialQuery}) {
requireValue(Object.hasOwn(KINDS, kind), 'Unknown vocabulary kind');
const {key} = KINDS[kind];
let query = tagQuery(kind, initialQuery);
const initial = untaggedCount => ({items: Object.freeze([]), total: 0, cursor: null, hasMore: true, loading: false, error: null,
epoch: null, resetRequired: false, query, untaggedCount});
let state = Object.freeze(initial(null));
let request = null, disposed = false, seen = new Set();
const emit = patch => {state = Object.freeze({...state, ...patch}); onChange(state);};
async function loadMore() {
if (disposed || state.loading || !state.hasMore || state.resetRequired) return 0;
const current = request = new AbortController();
const cursor = state.cursor;
const asked = query;
const params = new URLSearchParams({limit: String(LIMIT)});
if (asked.search) params.set('search', asked.search);
if (asked.sort !== 'created') params.set('sort', asked.sort);
if (cursor !== null) params.set('cursor', cursor);
emit({loading: true, error: null});
try {
const page = await api.get(`${BASE}/${kind}?${params}`, {signal: current.signal, cache: 'no-store'});
if (disposed || current !== request) return 0;
validatePage(page, kind, cursor, asked);
request = null;
if (state.epoch !== null && page.library_epoch !== state.epoch) {
emit({loading: false, error: storageChanged(), resetRequired: true});
return 0;
}
const incoming = page[kind].filter(row => !seen.has(key(row))).map(row => Object.freeze({...row}));
incoming.forEach(row => seen.add(key(row)));
emit({items: incoming.length ? Object.freeze([...state.items, ...incoming]) : state.items, total: page.total,
cursor: page.next_cursor, hasMore: page.has_more, loading: false, error: null, epoch: page.library_epoch,
untaggedCount: page.untagged_count ?? state.untaggedCount});
return incoming.length;
} catch (error) {
if (!disposed && current === request) {request = null; emit({loading: false, error});}
return 0;
}
}
return {
get state() {return state;},
loadMore,
/** Drop every loaded row and read again from the first page, with `next` as the new query when given. */
reset(next) {
if (next !== undefined) query = tagQuery(kind, next);
if (disposed) return Promise.resolve(0);
request?.abort();
request = null;
seen = new Set();
emit(initial(state.untaggedCount));
return loadMore();
},
dispose() {disposed = true; request?.abort(); request = null;},
};
}
function validateTag(value) {return object(value) && KINDS.tags.valid(value) && count(value.prompt_count);}
function validateImpact(value) {return object(value) && IMPACT_FIELDS.every(name => count(value[name])) && optionalCount(value.images);}
function requireRow(row) {requireValue(object(row) && id(row.id) && typeof row.name === 'string', 'Invalid tag row');}
function requireEpoch(epoch) {requireValue(epochOf(epoch), 'Invalid library epoch');}
function requireText(value, name) {requireValue(typeof value === 'string', `${name} must be a string`);}
const RECEIPTS = {
'create-tag': value => typeof value.created === 'boolean' && validateTag(value.tag),
'rename-tag': value => typeof value.merged === 'boolean' && validateTag(value.tag) &&
count(value.components_changed) && count(value.pairs_changed) && optionalCount(value.images_changed),
'delete-tag': value => value.deleted === true && count(value.components_changed) && count(value.pairs_changed) &&
optionalCount(value.images_changed),
'apply-category': (value, body) => value.source === body.source && value.target === body.target &&
typeof value.consolidated === 'boolean' && validateImpact(value.impact),
};
function writePhase(error) {
if (!isApiError(error)) return 'unknown-outcome';
if (error.status === 409 && Object.hasOwn(RECOVERY, error.code)) return RECOVERY[error.code];
if ((error.status >= 200 && error.status < 300) || UNCONFIRMED_STATUS.has(error.status)) return 'unknown-outcome';
return 'write-error';
}
/**
* One dialog's command. Phases: idle, saving, previewed, write-error, conflict,
* storage-changed, unknown-outcome, done (and disposed). `onInvalidate({phase,
* kind, receipt})` fires whenever stored vocabulary may differ from the loaded
* lists: after a receipt, a conflict, a restore or an unconfirmed write, including
* one that lands after `dispose()`.
*/
export function createVocabularyCommand({api, onChange = () => {}, onInvalidate = () => {}}) {
let state = freeze({phase: 'idle', kind: null, error: null, preview: null, receipt: null});
let disposed = false;
// Set when the dialog's inputs change while a category preview is in flight.
let stalePreview = false;
const emit = patch => {state = freeze({...state, ...patch}); onChange(state);};
const invalidate = () => onInvalidate(freeze({phase: state.phase, kind: state.kind, receipt: state.receipt}));
async function write(kind, method, url, body) {
emit({phase: 'saving', kind, error: null, preview: null, receipt: null});
let phase = 'done', error = null, receipt = null;
try {
const value = await api[method](url, body);
if (object(value) && value.success === true && value.library_epoch === body.expected_epoch && RECEIPTS[kind](value, body)) {
receipt = freeze({...value});
} else {
// The request reached the server, so the write may have committed.
phase = 'unknown-outcome';
error = new TypeError('The server response could not be confirmed.');
}
} catch (failure) {
phase = writePhase(failure);
error = failure;
}
if (disposed) {
// A write that may have committed still invalidates cached Library views after the dialog went away
// (forced close, Back); nothing is rendered then.
if (phase !== 'write-error') onInvalidate(freeze({phase, kind, receipt}));
return;
}
emit({phase, error, receipt});
if (phase !== 'write-error') invalidate();
}
async function preview(body) {
emit({phase: 'saving', kind: 'preview-category', error: null, preview: null, receipt: null});
stalePreview = false;
try {
const value = await api.post(`${BASE}/categories/preview`, body);
requireValue(object(value) && value.success === true && value.library_epoch === body.expected_epoch &&
value.source === body.source && value.target === body.target.trim() && typeof value.target_exists === 'boolean' &&
validateImpact(value.impact) && hexToken(value.impact_token), 'Invalid category preview');
// Inputs edited while it was pending: it describes values no longer on screen, so Preview is needed again.
if (!disposed) emit(stalePreview ? {phase: 'idle'} : {phase: 'previewed', preview: freeze({...value})});
} catch (error) {
if (disposed) return;
// A preview writes nothing: only a restore or a changed category needs recovery.
const phase = isApiError(error) && error.status === 409 && Object.hasOwn(RECOVERY, error.code) ? RECOVERY[error.code] : 'write-error';
if (phase === 'write-error' && stalePreview) {emit({phase: 'idle'}); return;}
emit({phase, error});
if (phase !== 'write-error') invalidate();
}
}
const ready = () => !disposed && READY.has(state.phase);
return {
get state() {return state;},
createTag(name, epoch) {
requireText(name, 'Tag name');
requireEpoch(epoch);
if (!ready()) return Promise.resolve();
return write('create-tag', 'post', `${BASE}/tags`, {expected_epoch: epoch, name});
},
renameTag(row, newName, epoch) {
requireRow(row);
requireText(newName, 'New name');
requireEpoch(epoch);
if (!ready()) return Promise.resolve();
return write('rename-tag', 'patch', `${BASE}/tags/${row.id}`, {expected_epoch: epoch, name: row.name, new_name: newName});
},
deleteTag(row, epoch) {
requireRow(row);
requireEpoch(epoch);
if (!ready()) return Promise.resolve();
return write('delete-tag', 'post', `${BASE}/tags/${row.id}/delete`, {expected_epoch: epoch, name: row.name});
},
previewCategory(source, target, epoch) {
requireText(source, 'Category');
requireText(target, 'New category name');
requireEpoch(epoch);
if (!ready()) return Promise.resolve();
return preview({expected_epoch: epoch, source, target});
},
/** Apply only the preview currently shown; anything else is ignored. */
applyCategory(shown) {
if (!ready() || state.phase !== 'previewed' || shown !== state.preview) return Promise.resolve();
return write('apply-category', 'post', `${BASE}/categories/apply`, {expected_epoch: shown.library_epoch,
source: shown.source, target: shown.target, impact_token: shown.impact_token});
},
/** The user changed a dialog input: a shown or pending preview no longer describes it. */
edit() {
if (disposed) return;
if (state.phase === 'previewed') emit({phase: 'idle', preview: null});
else if (state.phase === 'saving' && state.kind === 'preview-category') stalePreview = true;
},
/** Explicit user recovery (after re-reading the lists); never resends anything. */
reset() {
if (!disposed && state.phase !== 'saving') emit({phase: 'idle', kind: null, error: null, preview: null, receipt: null});
},
dispose() {
disposed = true;
state = freeze({...state, phase: 'disposed'});
},
};
}
/** Library filters for a row's Browse: exactly one constraint, the stored value. */
export function browseFilters(kind, value) {
requireValue(Object.hasOwn(KINDS, kind), 'Unknown vocabulary kind');
requireValue(typeof value === 'string' && value.length > 0, 'Invalid vocabulary value');
return kind === 'tags'
? {text: '', category: '', tags: [value], sort: 'newest'}
: {text: '', category: value, tags: [], sort: 'newest'};
}
/** Library filters for the Untagged prompts row's Browse (#444): parents that no tag's Browse returns. */
export function untaggedBrowseFilters() {
return {text: '', category: '', tags: [], sort: 'newest', tagMode: 'untagged'};
}
/**
* A category row's count (#332 D143): "N positive prompts" when no image holds it as its own category, "N positive
* prompts · M images" when both do, "M images" when only images do (stored rows, files on disk or not).
*/
export function categoryRowText(category) {
const prompts = count(category?.prompt_count) ? category.prompt_count : 0;
const images = count(category?.image_count) ? category.image_count : 0;
if (!images) return countOf(prompts, 'positive prompt');
if (!prompts) return countOf(images, 'image');
return `${countOf(prompts, 'positive prompt')} · ${countOf(images, 'image')}`;
}
/** Where a category's Browse opens: the Library while prompts hold it, else Images (MC10, its Library would be empty). */
export function categoryBrowseTarget(category) {
return count(category?.prompt_count) && category.prompt_count > 0 ? 'library' : 'images';
}
/** The exact tag's current owner counts, for the delete confirmation. Throws on any unconfirmed shape. */
export function readTagImpact(api, row, {signal} = {}) {
requireRow(row);
return api.get(`${BASE}/tags/${row.id}/impact`, {signal, cache: 'no-store'}).then(value => {
requireValue(object(value) && value.success === true && epochOf(value.library_epoch) && validateImpact(value.impact), 'Invalid tag impact');
return freeze({library_epoch: value.library_epoch, impact: {...value.impact}});
});
}
/** Owner kinds with a nonzero count, in sentence form ("a, b and c"); empty when nothing is linked. */
function impactList(impact) {
const negatives = impact.negative_variants + impact.archived_negative_variants;
const parts = [];
if (impact.positive_prompts) parts.push(countOf(impact.positive_prompts, 'positive prompt'));
if (negatives) {
const archived = impact.archived_negative_variants ? ` (${impact.archived_negative_variants} archived)` : '';
parts.push(`${countOf(negatives, 'negative variant')}${archived}`);
}
if (impact.other_prompts) parts.push(countOf(impact.other_prompts, 'other saved prompt'));
if (impact.images) parts.push(countOf(impact.images, 'image'));
return parts.length > 1 ? `${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}` : parts.join('');
}
/** The Manage categories preview sentence (decision D9); zero counts are left out. */
export function categoryPreviewText(shown) {
const outcome = shown.target_exists ? `consolidates it into the existing “${shown.target}”` : `renames it to “${shown.target}”`;
return `“${shown.source}” is used by ${impactList(shown.impact)}. Apply ${outcome}.`;
}
/** The Delete tag confirmation sentence (decision D14). */
export function deleteTagText(name, impact) {
const list = impactList(impact);
if (!list) return `“${name}” is not linked to any saved prompt or image. Deleting it removes only the name.`;
return `Deleting “${name}” removes the name and its links from ${list}. The prompts, negative variants, images and history themselves are kept.`;
}