Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/review-state.mjs
T

517 lines
27 KiB
JavaScript

/**
* Needs review state (#249 PR F): the pending queue, the selected item's sampler paths and one explicit decision, over
* the review routes (`py/api/review.py`).
*
* - The queue is read 40 at a time, oldest first; `total` is the exact pending count. The cursor is bound to the
* storage epoch only, so Load more keeps working while new items arrive.
* - Selecting an item reads its detail: one "Sampler <id> · <positive> / <negative>" option per candidate, unusable
* ones disabled with the server's reason, the first workable one chosen (rulings R2/R3). "Leave unresolved" is its
* own action (#536 D179, `leaveUnresolved()`). With nothing workable `unreadable` says why (D180).
* - `link(choice)` (#536) records the Library prompt the reviewer chose, under the same capture and retry rules.
* - A recorded decision opens the item after it (else the one before) and re-reads the queue quietly: the page stays
* (#536, overturning ruling R7). `decisions` counts them, for the page's toast.
* - Apply takes a fresh capture of the file (`observe`), then posts the decision; the server releases a capture it
* used. A refused decision releases the capture and keeps the selection with the server's sentence as the warning;
* a restored storage or a changed queue reads the queue again. A request that got no answer keeps its capture and
* its exact body, and the next Apply of the same choice (`retry()`) resends that body once.
* - Leave for later writes nothing and opens the next item, wrapping. Tagging the image (the shared Tag dialog) never changes the item.
* - `refresh()` re-reads the queue quietly when the server says the queue changed (F2: the review change stream:
* a scan or the monitor added items, another tab decided one). The open item, its choice and an unanswered decision
* stay; when the open item has left the queue the next one opens with a note. Refreshes asked for while one runs
* collapse into one more read. Every re-read keeps the loaded depth (F3): it reads page after page until it holds as
* many items as were shown, so Load more pages stay and the page does not jump.
* - `sync(version)` is the stream's `ready`: it re-reads only when the queue read last is older than the version the
* stream names (`{library_epoch, review_revision}`, the pair every list read answers), so a visit reads its queue
* once (F3). An unknown version re-reads.
* - A change heard while a decision is in flight is owed: it is read once the decision is refused or unanswered (a
* recorded one leaves the page). Load more asked for during a re-read waits for it and continues from the queue it
* read; a re-read started during Load more takes the click over and reads that page too.
* - Apply fences on the epoch of the detail it decides: an observe under another storage releases its capture and
* reads the queue and the item again. An unanswered decision that a re-read shows landed finishes as recorded.
*/
import { noun } from './plural.mjs';
export const REVIEW_URL = '/prompt_manager/review/items';
const OBSERVE_URL = '/prompt_manager/image-annotations/observe';
const CAPTURE_URL = '/prompt_manager/image-annotations/capture/';
export const PAGE_LIMIT = 40;
export const UNRESOLVED = 'unresolved';
export const COPY = Object.freeze({
title: 'Metadata review',
description: 'Uncertain evidence stays visible until you decide.',
notice: 'Multiple sampler paths were found. The existing image links were preserved. Choose the correct context or leave them unresolved.',
// D86 (plan D63, ruling R6): the empty queue's sentence.
empty: 'Ambiguous images are added when a metadata scan or a new generation finds more than one sampler path.',
panelTitle: 'Resolve the association',
leaveUnresolved: 'Leave unresolved',
// The states the concept lacks are text only (Global Constraints); D119.
loading: 'Loading the review queue…',
loadFailed: 'The review queue could not be read.',
detailLoading: 'Loading sampler paths…',
detailFailed: 'This item could not be read. Select it again to retry.',
moreFailed: 'More items could not be loaded. Try again.',
limited: 'This file has too many sampler paths to list. Link it to a prompt or leave it unresolved.',
// #536 D180: nothing to apply.
unreadable: count => `${count === 1 ? "This file's sampler path" : `None of this file's ${count} sampler paths`} could be traced to a complete prompt, so there is nothing to apply. Choose the prompt yourself, or leave it unresolved.`,
lostLink: 'The link may not have been saved. Press Link image again to send the same choice once more.',
unavailable: 'This file is not under a current image source, so it cannot be opened. You can leave it unresolved.',
lost: 'The decision may not have been saved. Apply again to send the same decision once more.',
storageChanged: 'Prompt storage was restored; reload the review queue.',
// D119 (F2): the open item left the queue in a re-read, and a failed re-read.
decidedElsewhere: name => `${name} was decided elsewhere, so the next image is open.`,
refreshFailed: 'The review queue could not be read again. Reload the page to see the latest images.',
failed: 'The decision could not be saved. Try again.',
applied: 'Association accepted.',
leftUnresolved: 'Left unresolved.',
linked: title => `Linked to ${title}.`,
});
// Answers after which the queue itself is out of date: read it again.
const RELOAD = new Set(['storage-changed', 'review-conflict', 'review-missing']);
/** The notice heading: "No images need a decision", "1 image needs a decision", "3 images need a decision". */
export function noticeTitle(count) {
if (!count) return 'No images need a decision';
return `${count.toLocaleString('en-US')} ${noun(count, 'image')} ${count === 1 ? 'needs' : 'need'} a decision`;
}
/** One Sampler context option's text. */
export function optionLabel(candidate) {
return `Sampler ${candidate.sampler_id} · ${candidate.label}`;
}
const workable = candidate => candidate?.state === 'resolved' && typeof candidate.positive?.text === 'string' &&
typeof candidate.negative?.text === 'string';
/** The Sampler context options of a detail: its candidates, unusable ones disabled with the server's reason. */
export function contextOptions(detail) {
const usable = detail.target !== null && !detail.unavailable;
return Object.freeze((Array.isArray(detail.candidates) ? detail.candidates : []).map(candidate => {
const readable = workable(candidate);
return Object.freeze({
value: candidate.hash, candidate, disabled: !usable || !readable,
label: readable || !candidate.reason ? optionLabel(candidate) : `${optionLabel(candidate)} (${candidate.reason})`,
});
}));
}
/**
* When no candidate of an available, listed file can be applied: their count and each distinct reason with its count
* (first seen first). Null otherwise.
*/
export function unreadableOf(detail) {
const candidates = Array.isArray(detail.candidates) ? detail.candidates : [];
if (detail.limited || detail.unavailable || detail.target === null || candidates.some(workable)) return null;
const reasons = new Map();
for (const { reason } of candidates) if (reason) reasons.set(reason, (reasons.get(reason) ?? 0) + 1);
return Object.freeze({ count: candidates.length,
reasons: Object.freeze([...reasons].map(([reason, count]) => Object.freeze({ reason, count }))) });
}
/** The prompt blocks of the chosen option: its two role texts, or nothing without a choice. */
function promptsOf(option) {
if (!option?.candidate) return null;
const { positive, negative } = option.candidate;
return { positive: { status: 'resolved', text: positive.text }, negative: { status: 'resolved', text: negative.text } };
}
function warningOf(detail) {
if (detail.unavailable || detail.target === null) return COPY.unavailable;
if (detail.limited) return COPY.limited;
return '';
}
const isAbort = error => error?.name === 'AbortError';
// No answer at all (the network, or a body that was not the server's JSON): the decision may have been saved.
const isLost = error => !isAbort(error) && !error?.code && !(error?.status >= 400 && error?.status < 500);
const initial = () => ({
status: 'idle', epoch: null, total: 0, items: Object.freeze([]), nextCursor: null, more: 'idle',
selectedId: null, detail: null, detailStatus: 'idle', options: Object.freeze([]), choice: null, prompts: null,
unreadable: null, phase: 'idle', warning: '', canRetry: false, toast: null, decisions: 0,
});
/**
* @param {{api: {get: Function, post: Function, del: Function}}} options
* @returns the controller: `state`, `subscribe(listener)`, `load()`, `refresh()`, `loadMore()`, `select(id)`, `choose(value)`,
* `sync(version)`, `apply()`, `leaveUnresolved()`, `link(choice)`, `retry()`, `leave()`, `dispose()`.
*/
export function createReviewController({ api }) {
let state = Object.freeze(initial());
const listeners = new Set();
const lifetime = new AbortController();
let listRead = null, detailRead = null, pending = null, disposed = false;
let refreshing = null, refreshAgain = false;
// A change heard while a decision was in flight (I1); read when the phase is idle again.
let owedRefresh = false;
// Load more's own read, the one waiting for a re-read, and a click a re-read took over (I2).
let moreRead = null, moreTask = null, moreOwed = false, listing = null;
// What the first read owes once it answers: nothing, a re-read ('always'), or a re-read unless it is this version.
let afterLoad = null;
// The version of the queue read last: {epoch, revision}.
let shown = null;
function set(update) {
if (disposed) return;
state = Object.freeze({ ...state, ...update });
for (const listener of [...listeners]) listener(state);
}
const signalOf = controller => AbortSignal.any ? AbortSignal.any([lifetime.signal, controller.signal]) : controller.signal;
function release(token) {
if (!token) return;
api.del(`${CAPTURE_URL}${encodeURIComponent(token)}`, { keepalive: true }).catch(() => {});
}
function dropPending() {
release(pending?.body.capture_token);
pending = null;
}
const isVersion = version => typeof version?.library_epoch === 'string' && Number.isSafeInteger(version?.review_revision);
const current = version => isVersion(version) && shown !== null && shown.epoch === version.library_epoch &&
shown.revision >= version.review_revision;
/** One list page; `cursor` continues after the last item already read. */
function readPage(read, cursor) {
const query = new URLSearchParams({ limit: String(PAGE_LIMIT) });
if (cursor) query.set('cursor', cursor);
return api.get(`${REVIEW_URL}?${query}`, { signal: signalOf(read), cache: 'no-store' });
}
/**
* The queue down to `depth` items: the first page, then the next ones while fewer than `depth` are held. A restore
* met after the first page (its cursor names the old storage) reads once more from the first page.
*/
async function readQueue(read, depth) {
for (let attempt = 0; ; attempt += 1) {
const first = await readPage(read, null);
const items = [...(first.items ?? [])];
let { total } = first;
let next = first.next_cursor ?? null;
try {
while (next && items.length < depth) {
const page = await readPage(read, next);
const known = new Set(items.map(entry => entry.id));
items.push(...(page.items ?? []).filter(entry => !known.has(entry.id)));
total = page.total;
next = page.next_cursor ?? null;
}
} catch (error) {
if (attempt === 0 && RELOAD.has(error?.code)) continue;
throw error;
}
return { first, items, total, next };
}
}
/**
* Read the queue as deep as it is shown; `stale` re-reads the selected item's detail too (its revision or epoch
* moved). `quiet` (a refresh) keeps the page as it is while reading and after a failed read.
*/
function load(options) {
const run = listing = loadQueue(options);
run.finally(() => { if (listing === run) listing = null; });
return run;
}
async function loadQueue({ keepWarning = false, stale = false, quiet = false } = {}) {
listRead?.abort();
if (moreRead) { moreRead.abort(); moreRead = null; moreOwed = true; }
const read = listRead = new AbortController();
if (!quiet) set({ status: state.items.length ? state.status : 'loading', ...(keepWarning ? {} : { warning: '' }) });
let readOk = false;
try {
// One page, or as many as Load more had shown (and one page more for a click this read took over).
const depth = state.items.length + (moreOwed ? PAGE_LIMIT : 0);
const queue = await readQueue(read, depth > PAGE_LIMIT ? depth : 0);
if (read !== listRead || disposed) return;
moreOwed = false;
// M3: another storage (a restore) makes the open item's detail stale too.
if (state.epoch !== null && queue.first.library_epoch !== state.epoch) stale = true;
const items = Object.freeze(queue.items.map(entry => Object.freeze({ ...entry })));
const left = state.items.find(entry => entry.id === state.selectedId);
shown = Number.isSafeInteger(queue.first.review_revision)
? { epoch: queue.first.library_epoch, revision: queue.first.review_revision } : null;
set({ status: 'ready', epoch: queue.first.library_epoch, total: queue.total, items, nextCursor: queue.next,
more: 'idle' });
readOk = true;
const keep = items.find(entry => entry.id === state.selectedId);
// A re-read keeps the open item as it is (even a failed read of it, which has its own Retry).
if (keep && !stale && (quiet || state.detail)) return;
if (quiet && left && !keep && pending?.id === left.id) {
// M5: the decision that got no answer was recorded (the server used its capture).
const { toast } = pending;
pending = null;
set({ warning: '', canRetry: false, toast, decisions: state.decisions + 1 });
if (items.length) await select(items[0].id);
else clearSelection();
} else if (quiet && left && !keep && items.length) {
set({ warning: COPY.decidedElsewhere(left.filename) });
await select(items[0].id, { keepWarning: true });
} else if (items.length) await select((keep ?? items[0]).id, { keepWarning });
else clearSelection();
} catch (error) {
if (read !== listRead || disposed || isAbort(error)) return;
// M7: a click this read took over can be made again.
moreOwed = false;
if (quiet && state.status === 'ready') set({ warning: COPY.refreshFailed, more: 'idle' });
else set({ status: 'error', more: 'idle' });
} finally {
// A change heard while the first read ran: that read may predate it, so read once more (unless the
// stream's ready named a version this read already holds).
const owed = afterLoad;
if (readOk && owed && !disposed) {
afterLoad = null;
if (owed === 'always' || !current(owed)) refresh();
}
}
}
function clearSelection() {
set({ selectedId: null, detail: null, detailStatus: 'idle', options: Object.freeze([]), choice: null, prompts: null,
unreadable: null });
}
/** The stream's `ready`: re-read only when the queue shown is older than `version`. */
function sync(version) {
if (disposed) return Promise.resolve();
if (state.status === 'loading') {
// A second version, or a change, while the first read runs: no longer one version to compare.
afterLoad = afterLoad === null && isVersion(version) ? version : 'always';
return Promise.resolve();
}
if (state.status === 'ready' && current(version)) return Promise.resolve();
return refresh();
}
/** Re-read the queue after a change elsewhere; calls made while one runs collapse into one more read. */
function refresh() {
if (disposed) return Promise.resolve();
// Before the queue has been read there is nothing to keep: the first read (or Retry) reads the latest.
if (state.status !== 'ready') {
if (state.status === 'loading') afterLoad = 'always';
return Promise.resolve();
}
if (refreshing) {
refreshAgain = true;
return refreshing;
}
refreshing = (async () => {
do {
refreshAgain = false;
// A decision in flight finishes first: the page leaves when it is recorded, and a refused or
// unanswered one reads the owed change (I1).
if (state.phase === 'applying') { owedRefresh = true; return; }
await load({ keepWarning: true, quiet: true });
} while (refreshAgain && !disposed);
})().finally(() => { refreshing = null; });
return refreshing;
}
function loadMore() {
if (moreTask) return moreTask;
if (!state.nextCursor || state.more === 'loading') return Promise.resolve();
set({ more: 'loading' });
moreTask = readMore().finally(() => { moreTask = null; });
return moreTask;
}
async function readMore() {
// I2: a re-read in flight finishes first; the next page continues after the queue it read.
while ((refreshing || listing) && !disposed) await (refreshing ?? listing);
if (disposed || !state.nextCursor || state.status !== 'ready') {
if (state.more === 'loading') set({ more: 'idle' });
return;
}
if (state.more !== 'loading') set({ more: 'loading' });
const read = moreRead = new AbortController();
try {
const query = new URLSearchParams({ limit: String(PAGE_LIMIT), cursor: state.nextCursor });
const page = await api.get(`${REVIEW_URL}?${query}`, { signal: signalOf(read), cache: 'no-store' });
if (read !== moreRead || disposed) return;
moreRead = null;
const known = new Set(state.items.map(entry => entry.id));
const added = (page.items ?? []).filter(entry => !known.has(entry.id)).map(entry => Object.freeze({ ...entry }));
set({ items: Object.freeze([...state.items, ...added]), total: page.total, nextCursor: page.next_cursor ?? null,
more: 'idle' });
} catch (error) {
if (read !== moreRead || disposed || isAbort(error)) return;
moreRead = null;
if (RELOAD.has(error?.code)) { set({ more: 'idle' }); await load({ stale: true }); return; }
set({ more: 'error' });
}
}
async function select(id, { keepWarning = false } = {}) {
if (!state.items.some(entry => entry.id === id) || state.phase === 'applying') return;
detailRead?.abort();
dropPending();
const read = detailRead = new AbortController();
set({ selectedId: id, detail: null, detailStatus: 'loading', options: Object.freeze([]), choice: null, prompts: null,
unreadable: null, canRetry: false, ...(keepWarning ? {} : { warning: '' }) });
try {
const detail = await api.get(`${REVIEW_URL}/${encodeURIComponent(id)}`, { signal: signalOf(read), cache: 'no-store' });
if (read !== detailRead || disposed) return;
const options = contextOptions(detail);
const chosen = options.find(option => !option.disabled);
set({ detail: Object.freeze(detail), detailStatus: 'ready', options, choice: chosen?.value ?? null,
prompts: promptsOf(chosen), unreadable: unreadableOf(detail), warning: (keepWarning && state.warning) || warningOf(detail) });
} catch (error) {
if (read !== detailRead || disposed || isAbort(error)) return;
if (RELOAD.has(error?.code)) { set({ warning: error.message }); await load({ keepWarning: true, stale: true }); return; }
set({ detailStatus: 'error', warning: COPY.detailFailed });
}
}
function choose(value) {
const option = state.options.find(entry => entry.value === value);
if (!option || option.disabled || state.phase === 'applying') return;
// Another choice is another decision: an unanswered one is no longer resent.
if (pending && pending.key !== value) { dropPending(); set({ canRetry: false }); }
set({ choice: value, prompts: promptsOf(option) });
}
/** The phase is idle again after a refused or unanswered decision: read a change heard meanwhile (I1). */
function idleAgain(reloading) {
const owed = owedRefresh;
owedRefresh = false;
if (owed && !reloading && !disposed) refresh();
}
/** The item recorded: it leaves the page's queue, the one after it opens and the queue is read again. */
async function advance(id, total, toast) {
const index = state.items.findIndex(entry => entry.id === id);
const items = Object.freeze(state.items.filter(entry => entry.id !== id));
const next = items[index] ?? items[index - 1] ?? null;
set({ phase: 'idle', items, total, warning: '', toast, decisions: state.decisions + 1 });
owedRefresh = false;
if (next) await select(next.id);
else clearSelection();
// Not refresh(): an answer already on its way may predate the decision, and this read aborts it.
await load({ keepWarning: true, quiet: true });
}
/** Send one decision; true once it is recorded. */
async function send({ path, body, key, toast, lost = COPY.lost }) {
const id = state.selectedId;
set({ phase: 'applying', canRetry: false });
try {
const result = await api.post(`${REVIEW_URL}/${encodeURIComponent(id)}/${path}`, body, { signal: lifetime.signal });
pending = null;
await advance(id, result.total, toast);
return true;
} catch (error) {
// Leaving the page mid-request: the server may never see it, so the capture is released here (a release
// of a capture the server already used is a harmless 404).
if (disposed || isAbort(error)) { release(body.capture_token); return false; }
if (isLost(error)) {
pending = { id, path, body, key, toast, lost };
set({ phase: 'idle', warning: lost, canRetry: true });
idleAgain(false);
return false;
}
pending = null;
release(body.capture_token);
set({ phase: 'idle', warning: error?.message || COPY.failed });
idleAgain(RELOAD.has(error?.code));
if (RELOAD.has(error?.code)) await load({ keepWarning: true, stale: true });
return false;
}
}
/** A fresh capture of the open file, or null when there is no target; `false` when the decision must stop. */
async function capture(detail) {
if (!detail.target) return null;
let observed;
try {
// M4: not aborted on leave, so the capture it answers can be released.
observed = await api.post(OBSERVE_URL, { target: detail.target });
} catch (error) {
if (disposed || isAbort(error)) return false;
set({ phase: 'idle', warning: error?.message || COPY.failed });
idleAgain(RELOAD.has(error?.code));
if (RELOAD.has(error?.code)) await load({ keepWarning: true, stale: true });
return false;
}
const taken = observed.capture;
if (disposed) { release(taken?.token); return false; }
// M3: the file was observed under another storage than the detail was read from.
if (observed.library_epoch !== detail.library_epoch) {
release(taken?.token);
set({ phase: 'idle', warning: COPY.storageChanged });
idleAgain(true);
await load({ keepWarning: true, stale: true });
return false;
}
return taken ?? null;
}
/** One decision from the idle page: resend an unanswered same one, else capture and send. */
async function decide({ key, path, fields, toast, lost }) {
const { detail } = state;
if (state.phase !== 'idle' || state.detailStatus !== 'ready' || !detail) return false;
// After an unanswered request, the same decision resends that body once (its capture and body).
if (pending?.id === state.selectedId && pending.key === key) return retry();
dropPending();
set({ phase: 'applying', warning: '', canRetry: false });
const taken = await capture(detail);
if (taken === false) return false;
return send({ path, key, toast, lost, body: {
expected_epoch: detail.library_epoch, expected_revision: detail.revision, capture_token: taken?.token ?? null,
sha256: taken?.sha256 ?? detail.sha256, dependency_fingerprint: detail.dependency_fingerprint, ...fields,
} });
}
function apply() {
const { choice } = state;
if (!choice) return Promise.resolve(false);
return decide({ key: choice, path: 'decision', fields: { choice }, toast: COPY.applied });
}
function leaveUnresolved() {
return decide({ key: UNRESOLVED, path: 'decision', fields: { choice: UNRESOLVED }, toast: COPY.leftUnresolved });
}
/** Link item `itemId`, which must still be the open one, to prompt `positiveId` with recipe `pairId` (null: no negative). */
function link({ itemId, positiveId, pairId, title }) {
if (state.selectedId !== itemId || !state.detail?.target) return Promise.resolve(false);
return decide({ key: `link:${positiveId}:${pairId}`, path: 'link', toast: COPY.linked(title), lost: COPY.lostLink,
fields: { positive_id: positiveId, pair_id: pairId } });
}
/** Resend the unanswered decision once, with its own capture; false when there is nothing to resend. */
async function retry() {
if (!pending || state.phase !== 'idle' || pending.id !== state.selectedId) return false;
const decision = pending;
pending = null;
return send(decision);
}
/** Leave for later: nothing is written; the next item opens, wrapping. */
function leave() {
if (state.phase === 'applying') return Promise.resolve();
dropPending();
const index = state.items.findIndex(entry => entry.id === state.selectedId);
const next = state.items[index + 1] ?? state.items[0];
if (!next || next.id === state.selectedId) { set({ canRetry: false, warning: '' }); return Promise.resolve(); }
return select(next.id);
}
return {
get state() { return state; },
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
load: () => load(), refresh, sync, loadMore, select: id => select(id), choose, apply, leaveUnresolved, link, retry, leave,
dispose() {
if (disposed) return;
dropPending();
disposed = true;
lifetime.abort();
listeners.clear();
},
};
}