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

376 lines
20 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.
/**
* Needs review (#249 PR F): the concept's Metadata review page (docs/concepts/workspace/app.js `renderReview`
* a749–760; style.css s407–415, s796–814 and their breakpoints) over the review controller (review-state.mjs).
*
* - Heading "Metadata review", its line, and **Open activity** (`#/activity`).
* - `.notice`: "N images need a decision" and the concept's sentence; with an empty queue, D86 (plan D63, ruling R6)
* "No images need a decision" and the scan/new-generation sentence, and nothing else.
* - `.gallery-layout`: the queue's concept media cards (image-card.mjs; the image opens the shared viewer, Info selects
* the item, Tag opens the shared Tag image dialog with the review scope note), then `section.surface` "Resolve the
* association": filename, Tag image, Link to a prompt…, Sampler context, the chosen path's prompt blocks and the
* actions Leave for later, Leave unresolved and Apply association. Each opens the next item (#536, overturning R7).
* - D179: Leave unresolved is a button, not the select's last option; a disabled option names the server's reason.
* - D180: with nothing to apply the select, the prompt blocks and Apply are gone: the panel says why and Link to a
* prompt… is the primary action.
* - States the concept lacks (loading, a failed read, a refused or unanswered decision, a limited or unavailable file)
* are text: `p.warning-note` in the panel, or the page's status line (D119).
* - The open page re-reads its queue on each `change` of the review change stream (`/prompt_manager/review/events`,
* F2): items a scan or the monitor added, and decisions made in other tabs. Each `ready` (the first one, and one
* after the browser reconnects a dropped stream) names the queue's version, and the page re-reads only when the
* queue it shows is older (F3: one read per visit). A re-read keeps the depth Load more had reached. Nothing polls.
* - The stream lives only while the tab is visible: a hidden tab closes it and a visible one opens it again, so
* background tabs hold no connection (I4); the reopened stream's `ready` reads only when the queue changed meanwhile.
* A stream the browser gave up on (a 503 or a proxy error on its reconnect) is opened again after 3 s, doubling to
* 30 s; while the stream is down the page says so in a quiet status line (D120, I3).
* - A re-read refreshes each card whose media, target or availability changed (M6). Cards show the Images listing's
* thumbnail when the server knows one (M10).
* - The SAVED VIEWS row's count follows the total the page shows (`onTotal`), so a change costs no count read.
* - D119: the queue cards carry no selection checkbox and no star (this page has no selection bar, and the queue
* carries no favorite state); more than 40 items add the Images page's `.infinite-continuation` Load more after the
* layout; the toasts "Association accepted." and "Left unresolved." stand in for the concept's prototype toast.
*/
import { createButton } from '../shared/toolkit/core.js';
import { createReviewController, COPY, noticeTitle } from './review-state.mjs';
import { createImageCard } from './image-card.mjs';
import { openPromptLink } from './review-link.mjs';
import { isTagButton, openImageTagging } from './image-tagging.mjs';
import { mountPromptBlocks } from './prompt-blocks.mjs';
import { createPromptSender } from './prompt-sender.mjs';
import { createMediaViewer } from './media-viewer.mjs';
import { createInspectionController } from './inspection-state.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { field } from './workspace-dialogs.mjs';
import { pageHeading } from './heading.mjs';
import { navigateTo } from './state.mjs';
import { noun } from './plural.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { imageKey } from './images-state.mjs';
export const REVIEW_EVENTS_URL = '/prompt_manager/review/events';
// I3: reopening a stream the browser closed for good waits this long, doubling up to the maximum.
export const REOPEN_FIRST_MS = 3000;
export const REOPEN_MAX_MS = 30000;
// D120: the page's line while the change stream is down (the concept has no such state).
export const NOT_LIVE = 'Live updates are paused: changes made elsewhere appear once the connection returns.';
const openEvents = url => new EventSource(window.ApiClient.resolveUrl(url));
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
function setText(node, text) {
if (node.textContent !== text) node.textContent = text;
}
function setDisabled(button, disabled) {
if (disabled) button.setAttribute('aria-disabled', 'true');
else button.removeAttribute('aria-disabled');
}
const isDisabled = button => button.getAttribute('aria-disabled') === 'true';
/** A queue item as the card, the viewer and the Tag dialog read a listed image. */
export function imageOf(item) {
const target = item.target ?? {};
return { filename: item.filename, root_index: target.root_index, root_scope: target.root_scope, path: target.path,
url: item.url, thumbnail_url: item.thumbnail_url ?? null, is_video: item.is_video === true,
media_type: item.is_video ? 'video' : 'image' };
}
/** True when a re-read changed what a card shows or can open (M6). */
const cardChanged = (before, after) => before.url !== after.url || before.thumbnail_url !== after.thumbnail_url ||
before.unavailable !== after.unavailable || JSON.stringify(before.target) !== JSON.stringify(after.target);
/** The queue version a `ready` event names, or null (the page then re-reads). */
export function readyVersion(event) {
try {
const version = JSON.parse(event?.data ?? 'null');
return version && typeof version === 'object' ? version : null;
} catch {
return null;
}
}
/**
* Mount the page under the shell's heading. `onDecision()` runs after a recorded decision and `onTotal(total)` each
* time the pending total the page shows changes (the shell's Needs review count); `controller` and `connect` (the
* change stream's EventSource) are injectable for tests.
*/
export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
connect = openEvents, controller = createReviewController({ api }) }) {
scope.cleanup(() => controller.dispose());
const heading = pageHeading(main);
heading.h1.textContent = COPY.title;
heading.description.textContent = COPY.description;
const openActivity = createButton('Open activity');
heading.actions.append(openActivity);
scope.listen(openActivity, 'click', () => navigateTo('#/activity'));
const status = el('p', COPY.loading, 'empty-state');
status.setAttribute('role', 'status');
const retryLoad = createButton('Retry');
const notice = el('div', undefined, 'notice');
const noticeCopy = el('div');
const noticeTitleNode = el('h2');
const noticeText = el('p');
noticeCopy.append(noticeTitleNode, noticeText);
notice.append(noticeCopy);
const layout = el('div', undefined, 'gallery-layout');
const grid = el('div', undefined, 'image-grid review-queue');
grid.setAttribute('role', 'list');
grid.setAttribute('aria-label', 'Review queue');
const panel = el('section', undefined, 'surface review-panel');
const panelTitle = el('h2', COPY.panelTitle);
panelTitle.id = 'review-panel-title';
panel.setAttribute('aria-labelledby', panelTitle.id);
const filename = el('p', '', 'small');
const tagRow = el('div', undefined, 'row-actions spaced-15');
const tagButton = createButton('');
tagButton.append(createWorkspaceIcon('tagImage'), ' Tag image');
const linkButton = createButton('Link to a prompt…');
linkButton.classList.add('review-link-open');
tagRow.append(tagButton, linkButton);
const select = el('select');
const contextField = field('Sampler context', select, 'review-context');
const unreadable = el('div', undefined, 'review-unreadable'); // D180
const warning = el('p', '', 'warning-note'); // D119: the states the concept lacks
warning.setAttribute('role', 'status');
const blocks = el('div');
const actions = el('div', undefined, 'dialog-actions');
const later = createButton('Leave for later');
const leaveUnresolved = createButton('Leave unresolved');
leaveUnresolved.classList.add('review-leave-unresolved');
const apply = createButton('Apply association', 'ca-primary');
actions.append(later, leaveUnresolved, apply);
panel.append(panelTitle, filename, tagRow, contextField, unreadable, warning, blocks, actions);
layout.append(grid, panel);
const live = el('p', NOT_LIVE, 'small review-live'); // D120: the change stream is down
live.setAttribute('role', 'status');
live.hidden = true;
heading.description.after(live);
const continuation = el('div', undefined, 'infinite-continuation'); // D119: Load more past 40 items
const moreText = el('p', '', 'small');
const more = createButton('Load more images');
continuation.append(moreText, more);
const sender = createPromptSender({ scope });
const prompts = mountPromptBlocks(blocks, { prompts: null, sender, parentScope: scope, includePairAction: false,
onRetry: () => { if (controller.state.selectedId) controller.select(controller.state.selectedId); } });
const inspection = createInspectionController({ api });
scope.cleanup(() => inspection.dispose());
const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: inspection,
sender, onSelect: image => inspection.select(image) });
const cards = new Map();
let shownItems = null, shownSelected, shownOptions = null, shownPrompts, shownDetail, shownUnreadable = null, shownDecisions = 0;
function createCard(item) {
// D119: no selection checkbox and no star on a queue card.
const card = createImageCard(imageOf(item), { media: Boolean(item.url) });
card.dataset.reviewId = item.id;
card.item = item;
if (!item.url) setDisabled(card.querySelector('.image-open'), true);
if (!item.target) setDisabled(card.querySelector('.image-card-actions button:last-child'), true);
cards.set(item.id, card);
return card;
}
function renderCards(state) {
if (state.items !== shownItems) {
shownItems = state.items;
const ids = new Set(state.items.map(item => item.id));
for (const [id, card] of cards) if (!ids.has(id)) { card.remove(); cards.delete(id); }
state.items.forEach((item, index) => {
let card = cards.get(item.id);
if (card && cardChanged(card.item, item)) {
// M6: a root came or went: the card's media, Open and Tag follow the re-read.
const fresh = createCard(item);
card.replaceWith(fresh);
card = fresh;
} else if (card) card.item = item;
if (!card) card = createCard(item);
if (grid.children[index] !== card) grid.insertBefore(card, grid.children[index] ?? null);
});
shownSelected = undefined;
}
if (state.selectedId !== shownSelected) {
shownSelected = state.selectedId;
for (const [id, card] of cards) card.classList.toggle('selected', id === state.selectedId);
}
}
/** D180: nothing to apply, so say why and lead with the picker. */
function renderUnreadable(state) {
const none = state.detailStatus === 'ready' && !state.choice;
contextField.hidden = blocks.hidden = apply.hidden = none;
linkButton.classList.toggle('ca-primary', none && Boolean(state.detail?.target));
unreadable.hidden = !state.unreadable;
if (state.unreadable === shownUnreadable) return;
shownUnreadable = state.unreadable;
if (!state.unreadable) { unreadable.replaceChildren(); return; }
const { count, reasons } = state.unreadable;
unreadable.replaceChildren(el('p', COPY.unreadable(count)),
...reasons.map(({ reason, count: paths }) => el('p', `${reason} (${paths} ${noun(paths, 'path')})`, 'small')));
}
function renderPanel(state) {
const item = state.items.find(entry => entry.id === state.selectedId);
setText(filename, item?.filename ?? '');
setDisabled(tagButton, !item?.target || state.phase === 'applying');
setDisabled(linkButton, !item?.target || state.detailStatus !== 'ready' || state.phase !== 'idle');
renderUnreadable(state);
if (state.options !== shownOptions) {
shownOptions = state.options;
select.replaceChildren(...state.options.map(option => {
const node = el('option', option.label);
node.value = option.value;
node.disabled = option.disabled;
return node;
}));
}
if (state.choice !== null && select.value !== state.choice) select.value = state.choice;
select.disabled = state.detailStatus !== 'ready' || state.phase === 'applying';
setText(warning, state.warning);
warning.hidden = !state.warning;
if (state.prompts !== shownPrompts || state.detailStatus !== shownDetail) {
shownPrompts = state.prompts;
shownDetail = state.detailStatus;
if (state.detailStatus === 'ready' && state.prompts) prompts.setState({ prompts: state.prompts });
else if (state.detailStatus === 'ready') prompts.setState({ prompts: null });
else prompts.setState({ loading: state.detailStatus === 'loading', error: state.detailStatus === 'error' });
}
setDisabled(apply, state.detailStatus !== 'ready' || state.phase !== 'idle' || !state.choice);
setDisabled(leaveUnresolved, state.detailStatus !== 'ready' || state.phase !== 'idle');
setDisabled(later, state.phase === 'applying');
}
function render(state) {
if (scope.signal.aborted) return;
if (state.decisions !== shownDecisions) {
shownDecisions = state.decisions;
showWorkspaceToast(state.toast);
onDecision();
}
const ready = state.status === 'ready';
if (!ready) {
setText(status, state.status === 'error' ? COPY.loadFailed : COPY.loading);
status.setAttribute('role', state.status === 'error' ? 'alert' : 'status');
if (!status.isConnected) main.append(status);
if (state.status === 'error' && !retryLoad.isConnected) main.append(retryLoad);
if (state.status !== 'error') retryLoad.remove();
if (state.status === 'error' || !state.items.length) { notice.remove(); layout.remove(); continuation.remove(); }
return;
}
status.remove();
retryLoad.remove();
setText(noticeTitleNode, noticeTitle(state.total));
setText(noticeText, state.total ? COPY.notice : COPY.empty); // D86
if (!notice.isConnected) main.append(notice);
if (!state.items.length) { layout.remove(); continuation.remove(); return; }
if (!layout.isConnected) notice.after(layout);
renderCards(state);
renderPanel(state);
if (state.nextCursor) {
setText(moreText, `${state.items.length.toLocaleString('en-US')} of ${state.total.toLocaleString('en-US')} images`);
setDisabled(more, state.more === 'loading');
if (state.more === 'error') setText(moreText, COPY.moreFailed);
if (!continuation.isConnected) layout.after(continuation);
} else continuation.remove();
}
scope.listen(retryLoad, 'click', () => controller.load());
scope.listen(more, 'click', () => { if (!isDisabled(more)) controller.loadMore(); });
scope.listen(select, 'change', () => controller.choose(select.value));
scope.listen(apply, 'click', () => { if (!isDisabled(apply)) controller.apply(); });
scope.listen(later, 'click', () => { if (!isDisabled(later)) controller.leave(); });
scope.listen(leaveUnresolved, 'click', () => { if (!isDisabled(leaveUnresolved)) controller.leaveUnresolved(); });
scope.listen(linkButton, 'click', () => {
const item = controller.state.items.find(entry => entry.id === controller.state.selectedId);
if (isDisabled(linkButton) || !item?.target) return;
openPromptLink({ api, scope, controller, filename: item.filename, trigger: linkButton });
});
scope.listen(tagButton, 'click', () => {
const item = controller.state.items.find(entry => entry.id === controller.state.selectedId);
if (isDisabled(tagButton) || !item?.target) return;
openImageTagging({ api, scope, image: imageOf(item), source: 'review', trigger: tagButton });
});
scope.listen(grid, 'click', event => {
const card = event.target.closest('article[data-review-id]');
if (!card) return;
const { item } = card;
const button = event.target.closest('button');
if (!button || isDisabled(button)) return;
if (isTagButton(button)) {
openImageTagging({ api, scope, image: imageOf(item), source: 'review', trigger: button });
return;
}
if (button.matches('.image-open')) {
const items = controller.state.items.filter(entry => entry.url).map(imageOf);
viewer.open({ items, selectedKey: imageKey(imageOf(item)), trigger: button });
return;
}
// Info selects the item, as the concept's `inspect` does on this page (app.js a1244–1248).
controller.select(item.id);
// D189: at one column the panel cannot stay beside the queue, so Info brings it into view.
if (getComputedStyle(panel).position === 'static') panel.scrollIntoView({ block: 'nearest' });
});
let shownTotal = null;
const reportTotal = state => {
if (state.status !== 'ready' || state.total === shownTotal) return;
shownTotal = state.total;
onTotal?.(state.total);
};
scope.cleanup(controller.subscribe(render));
scope.cleanup(controller.subscribe(reportTotal));
render(controller.state);
controller.load();
connectEvents({ scope, connect, controller, live });
return { controller };
}
/**
* The change stream while the page is mounted: open while the tab is visible (I4), reopened with a bounded backoff
* after the browser gives up on it (I3), with `live` shown while it is down.
*/
function connectEvents({ scope, connect, controller, live }) {
let events = null, timer = null, delay = REOPEN_FIRST_MS;
const setLive = up => { live.hidden = up; };
function close() {
clearTimeout(timer);
timer = null;
events?.close();
events = null;
}
function open() {
if (events || scope.signal.aborted || document.hidden) return;
const source = events = connect(REVIEW_EVENTS_URL);
source.addEventListener('ready', event => {
if (source !== events || scope.signal.aborted) return;
delay = REOPEN_FIRST_MS;
setLive(true);
controller.sync(readyVersion(event));
});
source.addEventListener('change', () => { if (source === events && !scope.signal.aborted) controller.refresh(); });
source.addEventListener('error', () => {
if (source !== events || scope.signal.aborted) return;
setLive(false);
// The browser retries a dropped connection itself; one it closed for good is reopened here.
if (source.readyState !== 2) return; // EventSource.CLOSED
events = null;
source.close();
timer = setTimeout(() => { timer = null; open(); }, delay);
delay = Math.min(delay * 2, REOPEN_MAX_MS);
});
}
scope.listen(document, 'visibilitychange', () => {
if (document.hidden) close();
else open();
});
scope.cleanup(close);
open();
}