Closes #536
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 review-link-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);
|
||||
};
|
||||
|
||||
@@ -449,6 +449,13 @@ 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); background: var(--soft-blue); }
|
||||
.review-panel button[aria-disabled="true"], .review-link-actions button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
|
||||
/* 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