Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/review.mjs
T
Vito Sansevero 52c6efcbe4 feat(review): re-read an open Needs review page when its queue changes
An open Needs review page now follows the queue without polling. A new
server-sent event stream, GET /prompt_manager/review/events, sends
"ready" when it opens and "change" after every committed queue change:
an item the scan or the monitor recorded, a spelling refresh, a decision
from any tab, and a restore. database/review_changes.py notifies after
the commit from whichever thread wrote it. The page re-reads its first
page on each event, keeps the open item, its choice and an unanswered
decision, and re-reads the SAVED VIEWS count with it. When the open item
left the queue, the next one opens with a note. A keepalive comment
every 25 s lets the server drop a closed tab; shutdown ends every stream.

Leaving the page mid-decision now releases that decision's capture.

D-NEW-F1 is renumbered D119 in markers and TESTING.md.
2026-10-22 19:23:37 -07:00

265 lines
14 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, Sampler context, the chosen path's prompt blocks and the actions Leave for later
* and Apply association. Both return to Images (ruling R7).
* - 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) re-reads too, since a change may have come before it. Nothing polls.
* - 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 { 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 { showWorkspaceToast } from './toast.mjs';
import { imageKey } from './images-state.mjs';
export const REVIEW_EVENTS_URL = '/prompt_manager/review/events';
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, is_video: item.is_video === true, media_type: item.is_video ? 'video' : 'image' };
}
/**
* Mount the page under the shell's heading. `onDecision()` runs after a recorded decision and `onQueueChange()` after
* a change elsewhere (the shell re-reads the Needs review count); `controller` and `connect` (the change stream's
* EventSource) are injectable for tests.
*/
export function mountReview(main, { api, scope, onDecision = () => {}, onQueueChange = () => {},
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');
tagRow.append(tagButton);
const select = el('select');
const contextField = field('Sampler context', select, 'review-context');
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 apply = createButton('Apply association', 'ca-primary');
actions.append(later, apply);
panel.append(panelTitle, filename, tagRow, contextField, warning, blocks, actions);
layout.append(grid, panel);
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;
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) {
// D119: no selection checkbox and no star on a queue card.
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);
}
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);
}
}
function renderPanel(state) {
const item = state.items.find(entry => entry.id === state.selectedId);
setText(filename, item?.filename ?? '');
setDisabled(tagButton, !item?.target || state.phase === 'applying');
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') prompts.setState({ prompts: state.prompts });
else prompts.setState({ loading: state.detailStatus === 'loading', error: state.detailStatus === 'error' });
}
setDisabled(apply, state.detailStatus !== 'ready' || state.phase !== 'idle');
setDisabled(later, state.phase === 'applying');
}
function render(state) {
if (scope.signal.aborted) return;
if (state.phase === 'done') return finish(state);
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();
}
let finished = false;
function finish(state) {
if (finished) return;
finished = true;
if (state.toast) {
showWorkspaceToast(state.toast);
onDecision();
}
// Ruling R7: Apply association and Leave for later both return to Images.
navigateTo('#/images');
}
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(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);
});
scope.cleanup(controller.subscribe(render));
render(controller.state);
controller.load();
const events = connect(REVIEW_EVENTS_URL);
scope.cleanup(() => events.close());
const changed = () => {
if (scope.signal.aborted) return;
controller.refresh();
onQueueChange();
};
events.addEventListener('ready', changed);
events.addEventListener('change', changed);
return { controller };
}