feat(ui): autocomplete Focused library filters (#186)

This commit is contained in:
Vito Sansevero
2026-10-17 05:16:25 -07:00
parent 5fa16d4aa3
commit 01ed65ac73
9 changed files with 171 additions and 16 deletions
+2 -1
View File
@@ -152,7 +152,8 @@ were copied. See the [toolkit maintenance notes](tools/vendor/README.md#shared-c
**Prompt Manager (Focused)** adds an editor-first layout with collapsible
composition and metadata fields. Open **Browse library** to search saved prompts,
preview a selection, then choose **Use prompt**; **Load metadata** controls whether
filter by saved category and comma-separated tags, preview a selection, then choose
**Use prompt**; **Load metadata** controls whether
category and tags come with it. Metadata fields suggest saved categories and tags;
choose with the mouse or arrow keys and Enter. Tag selections append `, ` for the
next tag; category remains a single value. New values can still be typed freely.
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+61 -2
View File
@@ -29,6 +29,65 @@ test.beforeEach(async({page,browserName})=>{
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);});
test('Focused library autocompletes filters and searches without changing editor metadata', async ({page}) => {
const requests=[];
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait']}}));
await page.route('**/prompt_manager/tags/suggest?**',route=>route.fulfill({json:{success:true,suggestions:[{name:'studio'}]}}));
await page.route('**/prompt_manager/search?**',route=>{
requests.push(new URL(route.request().url()));
return route.fulfill({json:{success:true,results:[{text:'Filtered portrait',category:'Portrait',tags:['studio']}]}});
});
await page.getByLabel('Main prompt',{exact:true}).fill('My draft');
await page.getByRole('button',{name:'Browse library',exact:true}).click();
const category=page.getByLabel('Filter category',{exact:true});
await category.fill('por');
await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
await category.press('ArrowDown');
await category.press('Enter');
expect(requests).toHaveLength(0);
const tags=page.getByLabel('Filter tags',{exact:true});
await tags.fill('stu');
await page.getByRole('option',{name:'studio',exact:true}).click();
await expect(tags).toHaveValue('studio, ');
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('portrait tag:studio tag:"film grain"');
await page.getByRole('button',{name:'Search',exact:true}).click();
await expect(page.locator('.pm-editor-result')).toContainText('Filtered portrait');
expect(requests[0].searchParams.get('text')).toBe('portrait');
expect(requests[0].searchParams.get('category')).toBe('Portrait');
expect(requests[0].searchParams.get('tags')).toBe('studio,film grain');
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,3))).toEqual(['My draft','','']);
await page.locator('.pm-editor-result').click();
await category.fill('Landscape');
await expect(page.locator('.pm-editor-selection button')).toBeDisabled();
const saved=await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);return saved;});
expect(saved.properties.focusedUI.libraryFilters).toEqual({category:'Landscape',tags:'studio, '});
await expect(category).toHaveValue('Landscape');
await expect(tags).toHaveValue('studio, ');
let recentRequest;
await page.route('**/prompt_manager/recent?**',route=>{recentRequest=new URL(route.request().url());return route.fulfill({json:{success:true,results:[]}});});
await page.getByRole('button',{name:'Recent',exact:true}).click();
await expect.poll(()=>Boolean(recentRequest)).toBe(true);
expect(recentRequest.searchParams.has('category')).toBe(false);
expect(recentRequest.searchParams.has('tags')).toBe(false);
await page.getByRole('button',{name:'Hide library',exact:true}).click();
await expect(page.getByRole('listbox')).toBeHidden();
});
test('Focused library can search by filters alone and Enter searches after autocomplete closes', async ({page}) => {
const requests=[];
await page.route('**/prompt_manager/search?**',route=>{requests.push(new URL(route.request().url()));return route.fulfill({json:{success:true,results:[]}});});
await page.getByRole('button',{name:'Browse library',exact:true}).click();
await page.getByLabel('Filter category',{exact:true}).fill('New category');
await page.getByLabel('Filter category',{exact:true}).press('Enter');
await expect.poll(()=>requests.length).toBe(1);
expect(requests[0].searchParams.get('category')).toBe('New category');
await page.getByLabel('Filter category',{exact:true}).fill('');
await page.getByLabel('Filter tags',{exact:true}).fill('custom, ');
await page.getByLabel('Filter tags',{exact:true}).press('Enter');
await expect.poll(()=>requests.length).toBe(2);
expect(requests[1].searchParams.get('tags')).toBe('custom');
});
test('Focused completes saved tags with keyboard and pointer and appends commas', async ({page}) => {
await page.route('**/prompt_manager/tags/suggest?**', route => {
const q = new URL(route.request().url()).searchParams.get('q');
@@ -203,7 +262,7 @@ test('Focused reports empty queries, no results, and safely renders backend and
await page.getByRole('button',{name:'Browse library',exact:true}).click();
const search=page.getByRole('button',{name:'Search',exact:true});
await search.click();
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text or tag:name.');
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
expect(requests).toBe(0);
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing');
await search.click();
@@ -220,7 +279,7 @@ test('Focused reports empty queries, no results, and safely renders backend and
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('');
await search.click();
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text or tag:name.');
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
mode='empty';
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing');
await search.click();
+66
View File
@@ -278,3 +278,69 @@ test('Focused metadata completion uses saved categories and tags across renderer
await expect(page.getByRole('listbox', { name: 'Saved categories', includeHidden: true })).toHaveCount(0);
await expect(page.getByRole('listbox', { name: 'Saved tags', includeHidden: true })).toHaveCount(0);
});
test('Focused library autocomplete filters saved prompts without editing draft metadata', async ({ page, request }, testInfo) => {
const category = `Library category ${testInfo.project.name}`;
const tag = `library-filter-${testInfo.project.name}`;
const matchingText = `Matching library prompt ${testInfo.project.name}`;
await seed(request, matchingText, category, [tag]);
await seed(request, `Wrong category ${testInfo.project.name}`, `Other category ${testInfo.project.name}`, [tag]);
await seed(request, `Wrong tag ${testInfo.project.name}`, category, [`other-filter-${testInfo.project.name}`]);
const id = await addFocused(page);
const root = page.locator('.pm-focused');
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Keep my draft');
await root.getByText('Metadata · category / tags', { exact: true }).click();
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Draft category');
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('draft-tag');
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
const filterCategory = root.getByRole('combobox', { name: 'Filter category', exact: true });
const filterTags = root.getByRole('combobox', { name: 'Filter tags', exact: true });
await filterCategory.fill(category.slice(0, -3));
await expect(page.getByRole('listbox', { name: 'Saved categories', exact: true }).getByRole('option', { name: category, exact: true })).toBeVisible();
await filterCategory.press('ArrowDown');
await filterCategory.press('Enter');
await expect(filterCategory).toHaveValue(category);
for (const palette of ['light', 'dark']) {
await page.evaluate(async palette => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
}, palette);
await filterTags.fill(tag.slice(0, -3));
const option = page.getByRole('listbox', { name: 'Saved tags', exact: true }).getByRole('option', { name: tag, exact: true });
await expect(option).toBeVisible();
await page.screenshot({ path: testInfo.outputPath(`focused-library-filters-${palette}.png`) });
await option.click();
await expect(filterTags).toHaveValue(`${tag}, `);
}
const draft = { text: 'Keep my draft', category: 'Draft category', tags: 'draft-tag' };
await root.getByRole('button', { name: 'Search', exact: true }).click();
const results = root.locator('.pm-editor-results').getByRole('button');
await expect(results).toHaveCount(1);
await expect(results.first()).toContainText(matchingText);
await results.first().click();
await expect.poll(() => model(page, id)).toMatchObject(draft);
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(filterCategory).toHaveValue(category);
await expect(filterTags).toHaveValue(`${tag}, `);
await expect.poll(() => model(page, id)).toMatchObject(draft);
}
const queued = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const prompt = await app.graphToPrompt();
await app.loadGraphData(app.graph.serialize());
return prompt.output[String(id)].inputs;
}, id);
expect(queued).toMatchObject(draft);
await expect(filterCategory).toHaveValue(category);
await expect(filterTags).toHaveValue(`${tag}, `);
await root.getByRole('button', { name: 'Search', exact: true }).click();
await expect(results).toHaveCount(1);
await expect(results.first()).toContainText(matchingText);
await expect.poll(() => model(page, id)).toMatchObject(draft);
});
+4 -3
View File
@@ -112,7 +112,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery) {
preview.textContent = ['prepend_text','text','append_text'].map(name => isConnected(name) ? `[${name} supplied by connection]` : name === 'text' ? value(name) : value(name).trim()).filter(Boolean).join(' ');
library.refreshAvailability();
}
const library = createPromptLibrary({scope,api,parseQuery,isConnected:() => isConnected('text'), onQuery:text => write('search_text',text), onUse:(prompt,includeMetadata) => {
const library = createPromptLibrary({scope,api,settings:app.ui?.settings,parseQuery,isConnected:() => isConnected('text'), onQuery:text => write('search_text',text), onFilters:() => {this.properties.focusedUI = snapshot();}, onUse:(prompt,includeMetadata) => {
if (isConnected('text')) return;
write('text',prompt.text);
if (includeMetadata) {
@@ -123,17 +123,18 @@ export function installFocusedNode(nodeType, app, api, parseQuery) {
}});
element.append(library.element,footer);
function snapshot() {
return {libraryOpen:!library.element.hidden,...Object.fromEntries([...details].map(([name,section]) => [name,section.open]))};
return {libraryOpen:!library.element.hidden,libraryFilters:library.filters(),...Object.fromEntries([...details].map(([name,section]) => [name,section.open]))};
}
function restore() {
const state = thisNode.properties.focusedUI || {};
details.forEach((section,name) => {section.open = Boolean(state[name]);});
library.element.hidden = !state.libraryOpen; browse.textContent = state.libraryOpen ? 'Hide library' : 'Browse library'; browse.setAttribute('aria-expanded', String(Boolean(state.libraryOpen)));
library.setQuery(value('search_text')); render();
library.setQuery(value('search_text')); library.restoreFilters(state.libraryFilters); render();
}
controls.forEach((input,name) => scope.listen(input,'input',() => {write(name,input.value);render();footer.setAttribute('role','status');footer.textContent='Main prompt is saved when this node runs.';}));
scope.listen(browse,'click',() => {
library.element.hidden = !library.element.hidden;
library.closeCompletions();
browse.textContent = library.element.hidden ? 'Browse library' : 'Hide library'; browse.setAttribute('aria-expanded',String(!library.element.hidden));
this.properties.focusedUI = snapshot(); if (!library.element.hidden) library.focus();
});
+38 -10
View File
@@ -1,44 +1,63 @@
import {createScope, createButton, createSwitch} from './shared/toolkit/core.js';
import {createScope, createButton, createSwitch, createField} from './shared/toolkit/core.js';
import {attachMetadataAutocomplete} from './metadata-autocomplete.js';
let nextLibraryId = 0;
export function createPromptLibrary({scope, api, parseQuery, onUse, isConnected, onQuery}) {
export function createPromptLibrary({scope, api, settings, parseQuery, onUse, isConnected, onQuery, onFilters}) {
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 prompts.';
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 prompts.';
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 actions = document.createElement('div'); actions.className = 'pm-editor-row';
const includeMetadata = createSwitch('Load metadata', '', `pm-load-metadata-${++nextLibraryId}`);
const includeMetadata = createSwitch('Load metadata', '', `${libraryId}-load-metadata`);
const use = createButton('Use prompt', 'ca-primary'); use.disabled = true;
actions.append(includeMetadata, use); preview.append(previewText, actions);
element.append(heading, queryRow, status, results, preview);
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 {text, tags} = parseQuery(query.value);
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 && !tags.length) { status.textContent = 'Enter search text or tag:name.'; return; }
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 (tags.length) params.set('tags', tags.join(','));
if (tagFilters.length) params.set('tags', tagFilters.join(','));
if (categoryFilter) params.set('category', categoryFilter);
}
status.textContent = 'Loading prompts…';
try {
@@ -69,11 +88,20 @@ export function createPromptLibrary({scope, api, parseQuery, onUse, isConnected,
}
scope.listen(search, 'click', () => load());
scope.listen(recent, 'click', () => load(true));
scope.listen(query, 'input', () => onQuery(query.value));
scope.listen(query, 'keydown', event => { if (event.key === 'Enter') { event.preventDefault(); load(); } });
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 || ''; }};
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()); },
};
}