* feat(vocabulary): add workspace vocabulary state * feat(vocabulary): render the native Tags & categories screen * feat(vocabulary): add guarded vocabulary dialogs * fix(vocabulary): recover unconfirmed deletes and list every category in Manage * refactor(vocabulary): use the shared plural helper * test(vocabulary): compare the native screen with the approved concept * fix(vocabulary): wrap long tag names so row actions stay visible on narrow screens * docs(vocabulary): document the native vocabulary screen and gates Also aligns the shell link focus-ring offset with the concept (3px) and makes the chrome comparison poll the document title. * fix(vocabulary): refresh lists when a write lands after its dialog closes A rename, delete, create or category apply that commits after its dialog was disposed (Chromium force-closing the modal on a repeated Escape, or Back while the write is in flight) returned before onInvalidate, so the lists were never re-read and a cached Library shelf kept showing the old names. Like the recipe editor, a disposed command now still reports any outcome that may have changed storage (receipt, conflict, restore, unconfirmed) without rendering; only a failed write stays silent. * fix(vocabulary): give the Manage categories datalist its own id The proposed-name datalist reused the recipe editor's `categories` id. It was safe only because routes dispose each other's dialogs; it is now `vocabulary-category-options`. * docs(vocabulary): correct vocabulary testing notes Name the Tag column and the Create tag heading action, say that a failed Load more moves focus to Retry, place bulk actions on the admin page, describe late writes after a closed dialog, Browse's exact filter and the full deviation list, and use a test port that is not the live concept server's. * test(vocabulary): raise the vocabulary dialog branch floor coverage:update after the late-write and datalist fixes: workspace/vocabulary-dialog.mjs branches 87 -> 88. No floor is lowered. * fix(vocabulary): keep 390px row actions visible regardless of font metrics The one-column Tags table reserved a fixed 6em for the tag name. With CI's stock DejaVu Sans fallback (about 9% wider than the pinned image's) Tag + Prompts + Images + stacked actions needed 351px of a 336px wrap, so Browse and Rename / merge were pushed out of view on every row. At 700px and below the Tag column now has no fixed minimum and the actions ask for no more than their stacked width, so all remaining space goes to the tag name: short names stay on one line wherever the concept's own table fits, and a wider font wraps the name instead of clipping an action. A new test widens the metrics with letter spacing and fails without this change in every engine. * ci: run browser jobs in the pinned Playwright image Concept comparisons measure geometry, which follows the fonts a browser falls back to. The concept names Inter, which neither environment ships: the pinned image falls back to WenQuanYi Zen Hei, a stock ubuntu-latest runner to DejaVu Sans. Under DejaVu the concept's own Tags table needs more than a 390px viewport (its page scrolls sideways and, under touch emulation, the widened layout viewport resizes its dialogs), so the 390px comparisons failed in CI while passing locally. Both browser jobs now run in mcr.microsoft.com/playwright:v1.64.1-noble, the image local runs use, instead of installing browsers on the runner. A tooling test keeps the tag equal to @playwright/test in the lockfile. * fix(vocabulary): ignore a category preview edited while it is pending Editing the Category or proposed name while a slow Preview was in flight was a no-op in the saving phase, so the late response still became an applicable preview: the form showed the new values while Apply would submit the old source and target. An edit during a pending preview now marks it stale. Its late result returns the dialog to idle (Preview again) instead of showing an impact or offering Apply; a plain failure of a stale preview is not shown either, while a restore or conflict still is, since it describes storage. The inputs stay editable during the request, as in the recipe editor, which disables only its actions while saving. * ci: use the image's Python and Node in containerized browser jobs Inside mcr.microsoft.com/playwright:v1.64.1-noble, actions/setup-python failed before any test ran: `spawn /__t/Python/3.12.15/x64/bin/pip ENOENT`. The toolcache builds come from the runner, and their entry points name host interpreter paths that do not exist in the container. The jobs now use the image's Python 3.12 and Node 24. The image has no ensurepip and the job runs as uid 1001 without root, so a checksum-pinned pip wheel bootstraps a user-space venv for requirements-dev.txt; a step checks that the image Node is 24. The tooling test forbids setup-python and setup-node in these jobs and requires the pinned bootstrap.
288 lines
15 KiB
JavaScript
288 lines
15 KiB
JavaScript
/**
|
|
* 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 = {
|
|
tags: {key: row => row.id, valid: row => id(row.id) && typeof row.name === 'string' && row.name.length > 0},
|
|
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'];
|
|
|
|
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 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);}
|
|
|
|
function validatePage(page, kind, cursor) {
|
|
const rows = page?.[kind];
|
|
const {key, valid} = KINDS[kind];
|
|
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. */
|
|
export function createVocabularyStream({api, kind, onChange = () => {}}) {
|
|
requireValue(Object.hasOwn(KINDS, kind), 'Unknown vocabulary kind');
|
|
const {key} = KINDS[kind];
|
|
const initial = () => ({items: Object.freeze([]), total: 0, cursor: null, hasMore: true, loading: false, error: null,
|
|
epoch: null, resetRequired: false});
|
|
let state = Object.freeze(initial());
|
|
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 query = new URLSearchParams({limit: String(LIMIT)});
|
|
if (cursor !== null) query.set('cursor', cursor);
|
|
emit({loading: true, error: null});
|
|
try {
|
|
const page = await api.get(`${BASE}/${kind}?${query}`, {signal: current.signal, cache: 'no-store'});
|
|
if (disposed || current !== request) return 0;
|
|
validatePage(page, kind, cursor);
|
|
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});
|
|
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. */
|
|
reset() {
|
|
if (disposed) return Promise.resolve(0);
|
|
request?.abort();
|
|
request = null;
|
|
seen = new Set();
|
|
emit(initial());
|
|
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]));}
|
|
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),
|
|
'delete-tag': value => value.deleted === true && count(value.components_changed) && count(value.pairs_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'};
|
|
}
|
|
|
|
/** 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'));
|
|
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. 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.`;
|
|
}
|