feat(ui): autocomplete Focused library filters (#186)
This commit is contained in:
@@ -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 |
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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()); },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user