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: "). // 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()}; }