* feat(resolver): trace multi-stage chains through pass-through nodes The inspection resolver (server and local Inspect) now compares a sampler with the earlier sampler its latent continues through image-space pass-through nodes: decode, image filter, model upscale, scale and encode, listed in a new validated `lineage` section of the semantics table. Equal prompts resolve (hires-fix, refiner); different prompts are multistage-differs. A later stage whose positive adds conditioning to the shared base prompt through an additive composite (ConditioningCombine or Concat) keeps the base pair, the text legacy linked (three-stage Flux). Image-edit batches stay ambiguous and run-time text stays untraceable. Ingestion keeps the default profile (D1). A card no longer shows a seed when the inspector names a chain stage whose seed differs from the parser's pick. * feat(resolver): leave out run-time PromptManager parts, labelled Maintainer option A: when a PromptManager prepend_text or append_text is fed directly by a node whose output is decided at run time (LoraManager's TriggerWord Toggle, now a runtime class, or PromptSearchList), the prompt resolves to the node's own text, as legacy showed. The side's source is runtime-parts-omitted and the resolved reason says that text added at run time, such as trigger words, is not included; the prompt block shows that line (D-NEW-R1). The toggle's output is never computed. Literal and traced parts are combined as before; an indirect run-time part, a run-time main text or an unknown feeder keep their outcome. The browser port changes identically. Ingestion is unchanged. * fix(resolver): keep run-time and added-conditioning labels honest A text that left out a run-time PromptManager part is now labelled when any node on its traced path omitted it, so the label survives encoders, concatenations and chained PromptManagers. Comparisons include what a text leaves out, so a partial prompt never equals a complete one: a branch with trigger words and one without stay ambiguous, and such stages are multistage-differs. Only classes listed as run time are left out. The added-conditioning floor labels its positive (D126, "Conditioning added at a later stage is not included."); the run-time line is D125. A card hides its seed only when the stage the inspector names reads a different seed than the parser; an agreeing seed is kept, as on main. The browser port moves its lineage, latent and part helpers into local-resolver-stages.mjs, keeping parity.
145 lines
8.6 KiB
JavaScript
145 lines
8.6 KiB
JavaScript
import { createScope, createButton } from '../shared/toolkit/core.js';
|
||
import { createSendButton } from './prompt-sender.mjs';
|
||
import { createWorkspaceIcon } from './icons.mjs';
|
||
import { showWorkspaceToast } from './toast.mjs';
|
||
import { openCopyFallback } from './workspace-dialogs.mjs';
|
||
|
||
export function promptRoles(prompts) {
|
||
return Object.fromEntries(['positive', 'negative'].filter(role => prompts?.[role]?.status === 'resolved' && typeof prompts[role].text === 'string').map(role => [role, prompts[role].text]));
|
||
}
|
||
|
||
// #249 D22: the concept's prompt block (app.js a298–302) carries no source line; a file's evidence is its panel badge
|
||
// (D21) and its "File & provenance" line. Roles without a confirmed text keep these status strings.
|
||
const statuses = {
|
||
absent: 'Not recorded in this file.',
|
||
ambiguous: 'Multiple possible prompts. Source could not be determined.',
|
||
unresolved: 'Prompt could not be resolved.',
|
||
};
|
||
// D125 (maintainer option A): a PromptManager prepend or append that a run-time node decided (LoRA trigger words) is
|
||
// not in the file, so the shown text leaves it out and says so. D126: the conditioning a later stage combined with
|
||
// the base prompt is not in the shown text either. Each source shows its muted line under the text.
|
||
export const RUNTIME_PARTS_NOTE = 'Text added at run time, such as trigger words, is not included.';
|
||
export const ADDED_CONDITIONING_NOTE = 'Conditioning added at a later stage is not included.';
|
||
const SOURCE_NOTES = Object.freeze({
|
||
'runtime-parts-omitted': [RUNTIME_PARTS_NOTE],
|
||
'added-conditioning': [ADDED_CONDITIONING_NOTE],
|
||
'runtime-parts-omitted-and-added-conditioning': [RUNTIME_PARTS_NOTE, ADDED_CONDITIONING_NOTE],
|
||
});
|
||
const ROLE_LABELS = Object.freeze({positive: '+ Positive prompt', negative: '\u2212 Negative prompt'});
|
||
function el(tag, text, className) {
|
||
const node = document.createElement(tag);
|
||
if (text !== undefined) node.textContent = text;
|
||
if (className) node.className = className;
|
||
return node;
|
||
}
|
||
|
||
function childScope(parentScope) {
|
||
const scope = createScope();
|
||
const dispose = () => scope.dispose();
|
||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
|
||
if (parentScope.signal.aborted) scope.dispose();
|
||
return scope;
|
||
}
|
||
|
||
export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = '', title = '', copyFallback = openCopyFallback}) {
|
||
// A nested set of blocks (the current file inside a recorded run) names its regions and actions with a
|
||
// context so assistive technology never meets two identically named prompt regions in one dialog.
|
||
const named = roleLabel => contextLabel ? `${contextLabel} ${roleLabel.toLowerCase()} prompt` : `${roleLabel} prompt`;
|
||
const action = role => contextLabel ? `${contextLabel[0].toLowerCase()}${contextLabel.slice(1)} ${role} prompt` : `${role} prompt`;
|
||
// A card among many names its actions after its prompt, as the concept's do (app.js a298–302: "Copy positive prompt: <title>").
|
||
// The title is mutable so a saved title edited in place (setTitle below, #249 F1) renames the actions.
|
||
let currentTitle = title || '';
|
||
const actionName = (verb, role, rest = '') => `${verb} ${action(role)}${rest}${currentTitle ? `: ${currentTitle}` : ''}`;
|
||
// An aborted parent gets one inert handle; every method is the same no-op.
|
||
const inert = () => {};
|
||
if (parentScope.signal.aborted) return {update:inert, setState:inert, setTitle:inert, dispose:inert};
|
||
const scope = childScope(parentScope);
|
||
container.classList.add('prompt-inspector');
|
||
scope.listen(container, 'click', async event => {
|
||
if (!actionsEnabled) return;
|
||
const send = event.target.closest('[data-send-role]');
|
||
if (send && (send.dataset.sendRole === 'pair' || Object.hasOwn(roles, send.dataset.sendRole))) {
|
||
const role = send.dataset.sendRole;
|
||
sender.open(role === 'pair' ? roles : {[role]:roles[role]}, send);
|
||
}
|
||
const copy = event.target.closest('[data-copy-role]');
|
||
if (copy && Object.hasOwn(roles, copy.dataset.copyRole)) {
|
||
// The concept's copy: the shared toast, or its "Copy prompt" select-to-copy dialog without a clipboard.
|
||
const text = roles[copy.dataset.copyRole];
|
||
try {
|
||
if (!navigator.clipboard) throw new Error('Clipboard requires a secure context');
|
||
await navigator.clipboard.writeText(text);
|
||
if (!scope.signal.aborted) showWorkspaceToast('Prompt copied.');
|
||
} catch {
|
||
if (scope.signal.aborted) return;
|
||
// A re-render during the pending write detaches the clicked button; Escape then returns to its successor.
|
||
const trigger = copy.isConnected === false ? container.querySelector(`[data-copy-role="${copy.dataset.copyRole}"]`) : copy;
|
||
copyFallback(text, {scope, trigger});
|
||
}
|
||
}
|
||
if (event.target.closest('[data-retry-prompts]')) onRetry?.();
|
||
});
|
||
let roles = {}, actionsEnabled = true, current = {prompts};
|
||
function render(state) {
|
||
if (scope.signal.aborted) return;
|
||
current = state;
|
||
roles = {};
|
||
actionsEnabled = state.actionsEnabled !== false;
|
||
container.replaceChildren();
|
||
if (state.loading || state.error) {
|
||
const status = el('p', state.loading ? 'Loading prompts…' : 'Could not read prompts from this file.', 'muted');
|
||
status.setAttribute('role', state.error ? 'alert' : 'status');
|
||
container.append(status);
|
||
if (state.error && onRetry) {
|
||
const retry = createButton('Retry prompt details');
|
||
retry.dataset.retryPrompts = '';
|
||
container.append(retry);
|
||
}
|
||
return;
|
||
}
|
||
roles = Object.fromEntries(Object.entries(promptRoles(state.prompts)).filter(([role]) => visibleRoles.includes(role)));
|
||
for (const role of visibleRoles.filter(name => Object.hasOwn(ROLE_LABELS, name))) {
|
||
const record = state.prompts?.[role];
|
||
const block = el('section', undefined, `prompt-section ${role}`);
|
||
block.setAttribute('aria-label', named(role[0].toUpperCase() + role.slice(1)));
|
||
const heading = el('div', undefined, 'prompt-heading');
|
||
heading.append(el('span', ROLE_LABELS[role], 'role-label'));
|
||
block.append(heading);
|
||
if (Object.hasOwn(roles, role)) {
|
||
const actions = el('div', undefined, 'prompt-actions');
|
||
const copy = createButton('', 'icon-btn');
|
||
copy.setAttribute('aria-label', actionName('Copy', role));
|
||
copy.append(createWorkspaceIcon('copy'));
|
||
copy.dataset.copyRole = role;
|
||
copy.disabled = !actionsEnabled;
|
||
const send = createButton('', 'send icon-btn');
|
||
send.setAttribute('aria-label', actionName('Send', role, ' to ComfyUI'));
|
||
send.append(createWorkspaceIcon('send'));
|
||
send.dataset.sendRole = role;
|
||
send.disabled = !actionsEnabled;
|
||
actions.append(copy, send);
|
||
heading.append(actions);
|
||
const text = el('p', roles[role] === '' ? `No ${role} prompt (explicitly empty)` : roles[role], 'prompt-text');
|
||
// Production keeps a confirmed prompt keyboard-reachable for reading and selecting long text.
|
||
text.tabIndex = 0;
|
||
block.append(text);
|
||
for (const note of Object.hasOwn(SOURCE_NOTES, record?.source) ? SOURCE_NOTES[record.source] : []) block.append(el('p', note, 'small muted'));
|
||
} else block.append(el('p', typeof record?.message === 'string' && record.message ? record.message : statuses[record?.status] || statuses.unresolved, 'prompt-text muted'));
|
||
container.append(block);
|
||
}
|
||
if (includePairAction && Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
|
||
const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair';
|
||
pair.disabled = !actionsEnabled;
|
||
container.append(pair);
|
||
}
|
||
}
|
||
render({prompts});
|
||
// A saved title edited in place renames the actions without a remount (the history summary, #249 F1).
|
||
const setTitle = next => {
|
||
const value = next || '';
|
||
if (value !== currentTitle) {currentTitle = value;render(current);}
|
||
};
|
||
return {update:prompts => render({prompts}), setState:render, setTitle, dispose:() => scope.dispose()};
|
||
}
|