Files
ComfyAssets-ComfyUI_PromptM…/web/js/prompt-library.js
Vito caa9649f32 feat: reuse positive and negative prompt combinations (#195)
* feat(db): add pair schema and stage restore migrations for #193

* feat(database): persist reusable ordered prompt pairs (#193)

* feat(api): expose reusable pairs and atomic metadata enrichment (#193)

* feat(metadata): resolve canonical prompt pairs with bounded provenance (#193)

* feat(scan): enrich image pairs without guessing or duplicate links (#193)

* fix(database): preserve pair tags during global maintenance (#193)

* feat(tracking): enrich live image pairs and cancel scans safely (#193)

* feat(nodes): encode paired prompts and count cached recipe runs (#193)

* fix(database): validate restored pair ownership and provenance (#193)

* feat(admin): report pair scan outcomes and cancellation (#193)

* feat(ui): add paired prompt editor and explicit recipe library for #193

* fix(autocomplete): include reusable pair metadata vocabulary (#193)

* fix(pairs): honor recent sorting and conservative scan outcomes (#193)

* fix(ui): protect paired draft saves and clarify negative variants (#193)

* fix(metadata): preserve bounded paired execution evidence (#193)

* fix(tracking): follow actual pair output roles and prefer execution evidence (#193)

* docs(pairs): document live validation and retain renderer screenshots (#193)

* test(api): inject disposable databases before API startup (#194)

* test(api): canonicalize portable temporary gallery roots (#194)

* docs(ui): prototype branching negative variant history (#193)

* docs(ui): show selected negative beneath positive in timeline concept (#193)

* docs: retain approved timeline for later gallery redesign (#193)

* test(db): close restore fixture connections before cleanup (#194)

* test(db): reuse existing handle guards in all pair fixtures (#194)

* chore: clarify rollback test and intentional exception handling (#193)
2026-10-17 08:00:42 -07:00

118 lines
8.6 KiB
JavaScript

import {createScope, createButton, createSwitch, createField} from './shared/toolkit/core.js';
import {attachMetadataAutocomplete} from './metadata-autocomplete.js';
let nextLibraryId = 0;
export function createPromptLibrary({scope, api, settings, parseQuery, onUse, isConnected, onQuery, onFilters, mode = 'prompts'}) {
const pairs = mode === 'pairs';
const noun = pairs ? 'pair' : 'prompt';
const element = document.createElement('section');
element.className = 'pm-editor-library';
element.setAttribute('aria-label', 'Prompt library');
element.hidden = true;
const heading = document.createElement('h3'); heading.textContent = 'Prompt library';
const libraryId = `pm-library-${++nextLibraryId}`;
const queryRow = document.createElement('div'); queryRow.className = 'pm-editor-query';
const query = document.createElement('input'); query.type = 'search'; query.placeholder = 'Search text or tag:portrait'; query.setAttribute('aria-label', 'Search saved prompts');
const search = createButton('Search', 'ca-primary');
const recent = createButton('Recent');
queryRow.append(query, search, recent);
const filters = document.createElement('div'); filters.className = 'pm-editor-fields';
const category = document.createElement('input'); category.type = 'text';
const tags = document.createElement('input'); tags.type = 'text';
filters.append(createField('Filter category',category,`${libraryId}-category`),createField('Filter tags',tags,`${libraryId}-tags`));
const hint = document.createElement('p'); hint.className = 'pm-editor-status'; hint.textContent = `Search combines text, category and tags. Recent shows all recent ${noun}s.`;
const completions = [category,tags].map((input,index) => attachMetadataAutocomplete(input, {
scope,api,settings,multiple:index === 1,available:() => !element.hidden && input.getClientRects().length > 0,
}));
const status = document.createElement('p'); status.className = 'pm-editor-status'; status.setAttribute('role', 'status'); status.textContent = `Search or browse recent ${noun}s.`;
const results = document.createElement('div'); results.className = 'pm-editor-results';
const preview = document.createElement('section'); preview.className = 'pm-editor-selection'; preview.hidden = true;
const previewText = document.createElement('p'); previewText.className = 'pm-editor-preview';
const previewNegative = document.createElement('p'); previewNegative.className = 'pm-editor-preview';
const actions = document.createElement('div'); actions.className = 'pm-editor-row';
const includeMetadata = createSwitch('Load metadata', '', `${libraryId}-load-metadata`);
const use = createButton(pairs ? 'Use pair' : 'Use prompt', 'ca-primary'); use.disabled = true;
actions.append(includeMetadata, use); preview.append(previewText); if (pairs) preview.append(previewNegative); preview.append(actions);
element.append(heading, queryRow, filters, hint, status, results, preview);
let selected = null;
let request = null;
let sequence = 0;
let rows = createScope();
scope.cleanup(() => { sequence++; request?.abort(); rows.dispose(); });
function refreshAvailability() { use.disabled = !selected || isConnected(); }
function invalidate() {
sequence++; request?.abort(); selected = null; preview.hidden = true; refreshAvailability();
rows.dispose(); rows = createScope(); results.replaceChildren();
status.classList.remove('pm-editor-error'); status.setAttribute('role','status'); status.textContent = 'Search to refresh results.';
}
async function load(recentOnly = false) {
request?.abort(); request = new AbortController();
const current = ++sequence;
selected = null; preview.hidden = true; refreshAvailability();
rows.dispose(); rows = createScope(); results.replaceChildren();
const parsed = parseQuery(query.value);
const text = parsed.text;
const tagFilters = [...new Map([...parsed.tags,...tags.value.split(',')].map(tag => tag.trim()).filter(Boolean).map(tag => [tag.toLowerCase(),tag])).values()];
const categoryFilter = category.value.trim();
status.classList.remove('pm-editor-error'); status.setAttribute('role', 'status');
if (!recentOnly && !text && !tagFilters.length && !categoryFilter) { status.textContent = 'Enter search text, a category or tags.'; return; }
const params = new URLSearchParams(recentOnly ? {limit:'20', sort:'last_used_desc'} : {limit:'50'});
if (!recentOnly) {
if (text) params.set('text', text);
if (tagFilters.length) { if (pairs) tagFilters.forEach(tag => params.append('tag',tag)); else params.set('tags', tagFilters.join(',')); }
if (categoryFilter) params.set('category', categoryFilter);
}
status.textContent = `Loading ${noun}s…`;
try {
const response = await fetch(api.fileURL(`/prompt_manager/${pairs ? 'pairs' : recentOnly ? 'recent' : 'search'}?${params}`), {signal:request.signal});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const data = await response.json();
if (scope.signal.aborted || current !== sequence) return;
if (!data.success) throw new Error(data.error || `Could not load ${noun}s`);
const found = (pairs ? data.pairs : data.results) || [];
status.textContent = found.length ? `${found.length} ${noun}${found.length === 1 ? '' : 's'}` : `No matching ${noun}s.`;
found.forEach(item => {
const button = createButton('', 'pm-editor-result'); button.setAttribute('aria-pressed','false');
const text = document.createElement('span'); text.textContent = pairs ? item.title || item.positive_text : item.text;
const metadata = document.createElement('span'); metadata.className = 'pm-editor-result-meta';
metadata.textContent = [item.category, Array.isArray(item.tags) ? item.tags.join(', ') : item.tags, item.rating ? `${item.rating}/5` : ''].filter(Boolean).join(' · ');
button.append(text);
if (pairs) {
const negative = document.createElement('span'); negative.className = 'pm-editor-result-meta';
const summary = item.negative_text || '(empty)';
negative.textContent = `Negative: ${summary.length > 160 ? `${summary.slice(0,160)}…` : summary}`;
negative.title = summary; button.append(negative);
}
button.append(metadata);
rows.listen(button, 'click', () => {
selected = item; previewText.textContent = pairs ? `Positive: ${item.positive_text}` : item.text; if (pairs) previewNegative.textContent = `Negative: ${item.negative_text || '(empty)'}`; preview.hidden = false;
results.querySelectorAll('button').forEach(row => row.setAttribute('aria-pressed', String(row === button)));
status.textContent = 'Previewing selection. Editor unchanged.'; refreshAvailability();
});
results.append(button);
});
} catch (error) {
if (scope.signal.aborted || current !== sequence || request.signal.aborted) return;
status.classList.add('pm-editor-error'); status.setAttribute('role','alert'); status.textContent = `Could not load ${noun}s: ${error.message}`;
}
}
scope.listen(search, 'click', () => load());
scope.listen(recent, 'click', () => load(true));
scope.listen(query, 'input', () => {invalidate();onQuery(query.value);});
[query,category,tags].forEach(input => scope.listen(input, 'keydown', event => {
if (event.key === 'Enter' && !event.defaultPrevented && !event.isComposing) { event.preventDefault(); load(); }
}));
[category,tags].forEach(input => scope.listen(input,'input',() => {
invalidate(); status.textContent = 'Filters changed. Search to refresh results.'; onFilters();
}));
scope.listen(use, 'click', () => {
if (!selected || isConnected()) return;
onUse(selected, includeMetadata.querySelector('input').checked);
});
return {element, focus:() => query.focus(), refreshAvailability, setQuery(value) { query.value = value || ''; },
filters:() => ({category:category.value,tags:tags.value}),
restoreFilters(state = {}) { invalidate();category.value = state.category || '';tags.value = state.tags || '';completions.forEach(completion => completion.close()); },
closeCompletions() { completions.forEach(completion => completion.close()); },
};
}