feat(review): link an image to a library prompt from Needs review (#536)
Images whose sampler paths could not be traced offered nothing to choose. Add Link to a prompt: search the Library and record the reviewer's choice under Apply's capture guards and the one-prompt-per-file rules, with provenance that names a manual choice and never reads as file evidence. Disabled options name the server's reason, nothing selectable says why and leads with the picker, Leave unresolved is its own action, and each decision opens the next item instead of returning to Images (overturns ruling R7).
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Link to a prompt (#536, D181; the concept has no picker): search the Library, choose the prompt that made this image
|
||||
* and, when the prompt has recipes, its negative. The server's refusals stay in the dialog. No negative is taken from
|
||||
* the file: one recipe is preselected and visible, several must be chosen between, none means no negative.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs';
|
||||
|
||||
export const SEARCH_DELAY_MS = 250;
|
||||
export const RESULT_LIMIT = 20;
|
||||
export const VARIANT_LIMIT = 40;
|
||||
export const NO_NEGATIVE = 'none';
|
||||
const LIBRARY_URL = '/prompt_manager/library';
|
||||
const CHOOSE = '';
|
||||
|
||||
export const LINK_COPY = Object.freeze({
|
||||
title: 'Link to a prompt',
|
||||
sentence: name => `Choose the prompt that made ${name}. This records your choice; the file's metadata does not confirm it.`,
|
||||
search: 'Search the Library',
|
||||
searching: 'Searching…',
|
||||
empty: 'No prompt in the Library matches.',
|
||||
failed: 'The Library could not be searched. Try again.',
|
||||
negative: 'Negative prompt',
|
||||
chooseNegative: 'Choose a negative…',
|
||||
noNegative: 'No negative',
|
||||
negativeFailed: "This prompt's negatives could not be read. Choose it again.",
|
||||
action: 'Link image',
|
||||
cancel: 'Cancel',
|
||||
});
|
||||
|
||||
const titleOf = prompt => prompt.title?.trim() || prompt.text;
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The Negative options of a prompt's recipes: its value is the recipe's pair id, or `NO_NEGATIVE`. */
|
||||
export function negativeOptions(variants) {
|
||||
const recipes = variants.filter(variant => !variant.explicit_empty)
|
||||
.map(variant => ({ value: String(variant.id), label: variant.title?.trim() || variant.negative_text }));
|
||||
const bare = variants.find(variant => variant.explicit_empty);
|
||||
const none = { value: bare ? String(bare.id) : NO_NEGATIVE, label: LINK_COPY.noNegative };
|
||||
if (recipes.length > 1) return [{ value: CHOOSE, label: LINK_COPY.chooseNegative }, ...recipes, none];
|
||||
return [...recipes, none];
|
||||
}
|
||||
|
||||
/** The body fields of the chosen Negative value. */
|
||||
export const pairIdOf = value => (value === NO_NEGATIVE ? null : Number(value));
|
||||
|
||||
/** Open the dialog; `controller.link` records the choice and the dialog closes once it is recorded. */
|
||||
export function openPromptLink({ api, scope, controller, filename, trigger }) {
|
||||
// A re-read may open another item meanwhile (decided elsewhere): the dialog names this one, so it closes.
|
||||
const itemId = controller.state.selectedId;
|
||||
const stopWatching = controller.subscribe(state => { if (state.selectedId !== itemId && !closed) closeWorkspaceDialog(); });
|
||||
let timer = null, search = null, variantsRead = null, chosen = null, closed = false;
|
||||
|
||||
const sentence = el('p', LINK_COPY.sentence(filename), 'small');
|
||||
const input = el('input');
|
||||
input.type = 'search';
|
||||
const results = el('div', undefined, 'review-link-results');
|
||||
results.setAttribute('role', 'group');
|
||||
results.setAttribute('aria-label', 'Matching prompts');
|
||||
const status = el('p', '', 'small');
|
||||
status.setAttribute('role', 'status');
|
||||
const select = el('select');
|
||||
const negative = field(LINK_COPY.negative, select, 'review-link-negative');
|
||||
negative.hidden = true;
|
||||
const warning = el('p', '', 'warning-note');
|
||||
warning.setAttribute('role', 'status');
|
||||
warning.hidden = true;
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
const cancel = createButton(LINK_COPY.cancel);
|
||||
const submit = createButton(LINK_COPY.action, 'ca-primary');
|
||||
actions.append(cancel, submit);
|
||||
|
||||
const busy = () => controller.state.phase !== 'idle';
|
||||
const ready = () => chosen !== null && !negative.hidden && select.value !== CHOOSE;
|
||||
function syncSubmit() {
|
||||
if (ready() && !busy()) submit.removeAttribute('aria-disabled');
|
||||
else submit.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
function warn(text) {
|
||||
warning.textContent = text;
|
||||
warning.hidden = !text;
|
||||
}
|
||||
|
||||
/** Every active recipe of the prompt, page by page. */
|
||||
async function readVariants(id, signal) {
|
||||
const variants = [];
|
||||
let cursor = null;
|
||||
do {
|
||||
const query = new URLSearchParams({ variant_limit: String(VARIANT_LIMIT) });
|
||||
if (cursor) query.set('variant_cursor', cursor);
|
||||
const detail = await api.get(`${LIBRARY_URL}/${encodeURIComponent(id)}?${query}`, { signal, cache: 'no-store' });
|
||||
variants.push(...(detail.variants ?? []));
|
||||
cursor = detail.variants_next_cursor ?? null;
|
||||
} while (cursor);
|
||||
return variants;
|
||||
}
|
||||
|
||||
async function choose(prompt, button) {
|
||||
variantsRead?.abort();
|
||||
const read = variantsRead = new AbortController();
|
||||
chosen = prompt;
|
||||
for (const node of results.children) node.setAttribute('aria-pressed', String(node === button));
|
||||
negative.hidden = true;
|
||||
warn('');
|
||||
syncSubmit();
|
||||
try {
|
||||
const variants = await readVariants(prompt.id, read.signal);
|
||||
if (read !== variantsRead || closed) return;
|
||||
const options = negativeOptions(variants);
|
||||
select.replaceChildren(...options.map(option => {
|
||||
const node = el('option', option.label);
|
||||
node.value = option.value;
|
||||
return node;
|
||||
}));
|
||||
select.value = options[0].value;
|
||||
negative.hidden = false;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError' || read !== variantsRead || closed) return;
|
||||
warn(LINK_COPY.negativeFailed);
|
||||
}
|
||||
syncSubmit();
|
||||
}
|
||||
|
||||
function show(items) {
|
||||
status.textContent = items.length ? '' : LINK_COPY.empty;
|
||||
results.replaceChildren(...items.map(prompt => {
|
||||
const button = createButton(titleOf(prompt));
|
||||
button.title = prompt.text;
|
||||
button.setAttribute('aria-pressed', String(prompt.id === chosen?.id));
|
||||
button.addEventListener('click', () => choose(prompt, button));
|
||||
return button;
|
||||
}));
|
||||
}
|
||||
|
||||
async function find() {
|
||||
search?.abort();
|
||||
const read = search = new AbortController();
|
||||
status.textContent = LINK_COPY.searching;
|
||||
try {
|
||||
const query = new URLSearchParams({ text: input.value.trim(), limit: String(RESULT_LIMIT) });
|
||||
const page = await api.get(`${LIBRARY_URL}?${query}`, { signal: read.signal, cache: 'no-store' });
|
||||
if (read === search && !closed) show(page.items ?? []);
|
||||
} catch (error) {
|
||||
if (error?.name !== 'AbortError' && read === search && !closed) status.textContent = LINK_COPY.failed;
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(find, SEARCH_DELAY_MS);
|
||||
});
|
||||
select.addEventListener('change', syncSubmit);
|
||||
cancel.addEventListener('click', () => { if (!busy()) closeWorkspaceDialog(); });
|
||||
submit.addEventListener('click', async () => {
|
||||
if (submit.getAttribute('aria-disabled') === 'true') return;
|
||||
warn('');
|
||||
submit.setAttribute('aria-disabled', 'true');
|
||||
const done = await controller.link({ itemId, positiveId: chosen.id, pairId: pairIdOf(select.value), title: titleOf(chosen) });
|
||||
if (closed) return;
|
||||
if (done) { closeWorkspaceDialog(); return; }
|
||||
warn(controller.state.warning);
|
||||
syncSubmit();
|
||||
});
|
||||
|
||||
openContentDialog({ scope, title: LINK_COPY.title, trigger, focusTarget: input,
|
||||
nodes: [sentence, field(LINK_COPY.search, input, 'review-link-search'), status, results, negative, warning, actions],
|
||||
canClose: () => !busy(),
|
||||
onClose: () => {
|
||||
closed = true;
|
||||
stopWatching();
|
||||
clearTimeout(timer);
|
||||
search?.abort();
|
||||
variantsRead?.abort();
|
||||
} });
|
||||
syncSubmit();
|
||||
find();
|
||||
}
|
||||
@@ -5,12 +5,16 @@
|
||||
* - 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, the first workable one chosen, then "Leave unresolved" (rulings R2/R3).
|
||||
* 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. Tagging the image (the shared Tag dialog) never changes the item.
|
||||
* - 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
|
||||
@@ -47,7 +51,10 @@ export const COPY = Object.freeze({
|
||||
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. You can leave it unresolved.',
|
||||
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.',
|
||||
@@ -57,6 +64,7 @@ export const COPY = Object.freeze({
|
||||
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.
|
||||
@@ -76,19 +84,34 @@ export function optionLabel(candidate) {
|
||||
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), then Leave unresolved. */
|
||||
/** 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;
|
||||
const options = (Array.isArray(detail.candidates) ? detail.candidates : []).map(candidate => Object.freeze({
|
||||
value: candidate.hash, label: optionLabel(candidate), disabled: !usable || !workable(candidate), candidate,
|
||||
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})`,
|
||||
});
|
||||
}));
|
||||
options.push(Object.freeze({ value: UNRESOLVED, label: COPY.leaveUnresolved, disabled: false, candidate: null }));
|
||||
return Object.freeze(options);
|
||||
}
|
||||
|
||||
/** The prompt blocks of the chosen option: its two role texts, or the ambiguous status for Leave unresolved. */
|
||||
/**
|
||||
* 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 { positive: { status: 'ambiguous' }, negative: { status: 'ambiguous' } };
|
||||
if (!option?.candidate) return null;
|
||||
const { positive, negative } = option.candidate;
|
||||
return { positive: { status: 'resolved', text: positive.text }, negative: { status: 'resolved', text: negative.text } };
|
||||
}
|
||||
@@ -106,13 +129,13 @@ const isLost = error => !isAbort(error) && !error?.code && !(error?.status >= 40
|
||||
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,
|
||||
phase: 'idle', warning: '', canRetry: false, toast: 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()`, `retry()`, `leave()`, `dispose()`.
|
||||
* `sync(version)`, `apply()`, `leaveUnresolved()`, `link(choice)`, `retry()`, `leave()`, `dispose()`.
|
||||
*/
|
||||
export function createReviewController({ api }) {
|
||||
let state = Object.freeze(initial());
|
||||
@@ -218,15 +241,16 @@ export function createReviewController({ api }) {
|
||||
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 choice = pending.body.choice;
|
||||
const { toast } = pending;
|
||||
pending = null;
|
||||
set({ phase: 'done', warning: '', canRetry: false,
|
||||
toast: choice === UNRESOLVED ? COPY.leftUnresolved : COPY.applied });
|
||||
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 set({ selectedId: null, detail: null, detailStatus: 'idle', options: Object.freeze([]), choice: null, prompts: null });
|
||||
else clearSelection();
|
||||
} catch (error) {
|
||||
if (read !== listRead || disposed || isAbort(error)) return;
|
||||
// M7: a click this read took over can be made again.
|
||||
@@ -244,6 +268,11 @@ export function createReviewController({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -320,14 +349,14 @@ export function createReviewController({ api }) {
|
||||
dropPending();
|
||||
const read = detailRead = new AbortController();
|
||||
set({ selectedId: id, detail: null, detailStatus: 'loading', options: Object.freeze([]), choice: null, prompts: null,
|
||||
canRetry: false, ...(keepWarning ? {} : { warning: '' }) });
|
||||
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, prompts: promptsOf(chosen),
|
||||
warning: (keepWarning && state.warning) || warningOf(detail) });
|
||||
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; }
|
||||
@@ -339,7 +368,7 @@ export function createReviewController({ api }) {
|
||||
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.body.choice !== value) { dropPending(); set({ canRetry: false }); }
|
||||
if (pending && pending.key !== value) { dropPending(); set({ canRetry: false }); }
|
||||
set({ choice: value, prompts: promptsOf(option) });
|
||||
}
|
||||
|
||||
@@ -350,24 +379,35 @@ export function createReviewController({ api }) {
|
||||
if (owed && !reloading && !disposed) refresh();
|
||||
}
|
||||
|
||||
/** Send `body`; true once the decision is recorded. */
|
||||
async function send(body) {
|
||||
/** 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;
|
||||
const choice = body.choice;
|
||||
set({ phase: 'applying', canRetry: false });
|
||||
try {
|
||||
const result = await api.post(`${REVIEW_URL}/${encodeURIComponent(id)}/decision`, body, { signal: lifetime.signal });
|
||||
const result = await api.post(`${REVIEW_URL}/${encodeURIComponent(id)}/${path}`, body, { signal: lifetime.signal });
|
||||
pending = null;
|
||||
set({ phase: 'done', total: result.total, warning: '',
|
||||
toast: choice === UNRESOLVED ? COPY.leftUnresolved : COPY.applied });
|
||||
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, body };
|
||||
set({ phase: 'idle', warning: COPY.lost, canRetry: true });
|
||||
pending = { id, path, body, key, toast, lost };
|
||||
set({ phase: 'idle', warning: lost, canRetry: true });
|
||||
idleAgain(false);
|
||||
return false;
|
||||
}
|
||||
@@ -380,55 +420,82 @@ export function createReviewController({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
const { detail, choice } = state;
|
||||
if (state.phase !== 'idle' || state.detailStatus !== 'ready' || !detail || !choice) return false;
|
||||
// After an unanswered request, Apply resends that same decision once (its capture and body).
|
||||
if (pending?.id === state.selectedId && pending.body.choice === choice) return retry();
|
||||
/** 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 });
|
||||
let capture = null;
|
||||
if (detail.target) {
|
||||
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;
|
||||
}
|
||||
capture = observed.capture;
|
||||
if (disposed) { release(capture?.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(capture?.token);
|
||||
set({ phase: 'idle', warning: COPY.storageChanged });
|
||||
idleAgain(true);
|
||||
await load({ keepWarning: true, stale: true });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return send({
|
||||
expected_epoch: detail.library_epoch, expected_revision: detail.revision, capture_token: capture?.token ?? null,
|
||||
sha256: capture?.sha256 ?? detail.sha256, dependency_fingerprint: detail.dependency_fingerprint, choice,
|
||||
});
|
||||
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 { body } = pending;
|
||||
const decision = pending;
|
||||
pending = null;
|
||||
return send(body);
|
||||
return send(decision);
|
||||
}
|
||||
|
||||
/** Leave for later: nothing is written; the next item opens, wrapping. */
|
||||
function leave() {
|
||||
if (state.phase === 'applying') return;
|
||||
if (state.phase === 'applying') return Promise.resolve();
|
||||
dropPending();
|
||||
set({ phase: 'done', toast: null, canRetry: false });
|
||||
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 {
|
||||
@@ -437,7 +504,7 @@ export function createReviewController({ api }) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
load: () => load(), refresh, sync, loadMore, select: id => select(id), choose, apply, retry, leave,
|
||||
load: () => load(), refresh, sync, loadMore, select: id => select(id), choose, apply, leaveUnresolved, link, retry, leave,
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
dropPending();
|
||||
|
||||
+48
-22
@@ -7,8 +7,11 @@
|
||||
* "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).
|
||||
* 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`,
|
||||
@@ -29,6 +32,7 @@
|
||||
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';
|
||||
@@ -38,6 +42,7 @@ 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';
|
||||
|
||||
@@ -123,17 +128,22 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
|
||||
const tagRow = el('div', undefined, 'row-actions spaced-15');
|
||||
const tagButton = createButton('');
|
||||
tagButton.append(createWorkspaceIcon('tagImage'), ' Tag image');
|
||||
tagRow.append(tagButton);
|
||||
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, apply);
|
||||
panel.append(panelTitle, filename, tagRow, contextField, warning, blocks, actions);
|
||||
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
|
||||
@@ -155,7 +165,7 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
|
||||
sender, onSelect: image => inspection.select(image) });
|
||||
|
||||
const cards = new Map();
|
||||
let shownItems = null, shownSelected, shownOptions = null, shownPrompts, shownDetail;
|
||||
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.
|
||||
@@ -192,10 +202,26 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 => {
|
||||
@@ -212,16 +238,22 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
|
||||
if (state.prompts !== shownPrompts || state.detailStatus !== shownDetail) {
|
||||
shownPrompts = state.prompts;
|
||||
shownDetail = state.detailStatus;
|
||||
if (state.detailStatus === 'ready') prompts.setState({ prompts: state.prompts });
|
||||
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');
|
||||
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.phase === 'done') return finish(state);
|
||||
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);
|
||||
@@ -249,23 +281,17 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
|
||||
} 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(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;
|
||||
@@ -292,7 +318,7 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
|
||||
|
||||
let shownTotal = null;
|
||||
const reportTotal = state => {
|
||||
if (state.status !== 'ready' || state.phase === 'done' || state.total === shownTotal) return;
|
||||
if (state.status !== 'ready' || state.total === shownTotal) return;
|
||||
shownTotal = state.total;
|
||||
onTotal?.(state.total);
|
||||
};
|
||||
|
||||
@@ -433,6 +433,12 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
.review-queue .image-open[aria-disabled="true"] { cursor: not-allowed; aspect-ratio: 1.33; /* D119 */ }
|
||||
/* D119: a long file name in the panel wraps inside the panel, where the concept's sample name never overflows. */
|
||||
.review-panel > p.small { min-inline-size: 0; overflow-wrap: anywhere; /* D119 */ }
|
||||
/* #536: the concept's rows hidden when there is nothing to apply (D180), and the picker (D181). */
|
||||
.review-panel > [hidden], .review-panel .dialog-actions > [hidden], #modal .field[hidden] { display: none; }
|
||||
.review-unreadable { display: grid; gap: 6px; margin-block: 12px; overflow-wrap: anywhere; }
|
||||
.review-link-results { display: grid; gap: 6px; max-block-size: 40vh; overflow-y: auto; }
|
||||
.review-link-results > button { justify-content: flex-start; inline-size: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.review-link-results > button[aria-pressed="true"] { border-color: var(--ca-accent); }
|
||||
/* Auto-tag selected images (#249 PR D, D85/D90): the Tag image dialog's model panel, then How to apply, a radio group
|
||||
in the concept's `.field` row whose radios keep their own size (the concept's `.field input` is a full-width text
|
||||
field) beside their label, as the concept's own radio rows (s1160 `.compare-item label`). */
|
||||
|
||||
Reference in New Issue
Block a user