Compare commits

...
23 changed files with 432 additions and 93 deletions
+6
View File
@@ -162,6 +162,12 @@ when the fields are collapsed, so LoRA trigger outputs can connect directly.
Encoding, storage, and CONDITIONING/text outputs match Classic. Both layouts
support ComfyUI Classic and Nodes 2.0.
**Prompt Manager (Split)** keeps the same editor and library visible together.
Wide nodes place the library beside the editor; narrow nodes stack the panels.
It shares Focused's category/tag autocomplete, search filters, preview-before-use,
workflow persistence, and connectable prepend/append inputs. Encoding and storage
match Classic and Focused.
### 🔬 ComfyUI Workflow Analysis
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
+8 -1
View File
@@ -6,6 +6,7 @@ gallery system.
This module provides prompt node types:
- PromptManager: CLIP encoding node that outputs CONDITIONING
- PromptManagerFocused: Editor-first layout using the same encoding contract
- PromptManagerSplit: Side-by-side editor and library using the same contract
- PromptManagerText: Text-only node that outputs STRING with prepend/append
functionality
@@ -39,13 +40,18 @@ def get_version():
return "unknown"
from .prompt_manager import PromptManager, PromptManagerFocused # noqa: E402
from .prompt_manager import ( # noqa: E402
PromptManager,
PromptManagerFocused,
PromptManagerSplit,
)
from .prompt_manager_text import PromptManagerText # noqa: E402
from .prompt_search_list import PromptSearchList # noqa: E402
NODE_CLASS_MAPPINGS = {
"PromptManager": PromptManager,
"PromptManagerFocused": PromptManagerFocused,
"PromptManagerSplit": PromptManagerSplit,
"PromptManagerText": PromptManagerText,
"PromptSearchList": PromptSearchList,
}
@@ -53,6 +59,7 @@ NODE_CLASS_MAPPINGS = {
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptManager": "Prompt Manager (Classic)",
"PromptManagerFocused": "Prompt Manager (Focused)",
"PromptManagerSplit": "Prompt Manager (Split)",
"PromptManagerText": "Prompt Manager Text",
"PromptSearchList": "Prompt Search List",
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

+4
View File
@@ -296,3 +296,7 @@ class PromptManagerFocused(PromptManager):
{**options, "default": "", "forceInput": True},
)
return inputs
class PromptManagerSplit(PromptManagerFocused):
"""The persistent library layout shares Focused encoding and storage."""
+114 -36
View File
@@ -2,6 +2,17 @@ import {readFileSync} from 'node:fs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
for (const variant of ['Focused', 'Split']) {
test.describe(variant, () => {
const isSplit = variant === 'Split';
const uiProperty = isSplit ? 'splitUI' : 'focusedUI';
async function openLibrary(page) {
if (isSplit) {
await expect(page.locator('.pm-editor-library')).toBeVisible();
await expect(page.getByRole('button',{name:'Browse library',exact:true})).toHaveCount(0);
} else await page.getByRole('button',{name:'Browse library',exact:true}).click();
}
test.beforeEach(async({page,browserName})=>{
await page.addInitScript(()=>Object.defineProperty(Crypto.prototype,'randomUUID',{value:undefined,configurable:true}));
await startCoverage(page,browserName);
@@ -10,26 +21,26 @@ test.beforeEach(async({page,browserName})=>{
await page.route('**/scripts/api.js',route=>route.fulfill({contentType:'text/javascript',body:'export const api={fileURL:path=>path,queuePrompt:async(...args)=>args};'}));
await page.route('**/extensions/promptmanager/prompt_manager.js',route=>route.fulfill({contentType:'text/javascript',body:readFileSync(new URL('../../web/comfy/prompt_manager.js',import.meta.url),'utf8')}));
await page.goto('/prompt_manager/test');
await page.evaluate(async()=>{
await page.evaluate(async variant=>{
document.body.replaceChildren();document.body.style.setProperty('--bg-color','#222');
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings:new EventTarget()},graph:{setDirtyCanvas(){}},canvas:{}};
await import('/extensions/promptmanager/prompt_manager.js');
class Node {
constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[550,700];}
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
addDOMWidget(name,type,element){document.body.append(element);return {element};}
addDOMWidget(name,type,element){const host=document.createElement('div');host.style.height='650px';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};}
setSize(size){this.size=size;}setDirtyCanvas(){}
serialize(){return {widgets_values:this.widgets.map(widget=>widget.value),properties:{...this.properties}};}
configure(data){this.widgets.forEach((widget,index)=>{widget.value=data.widgets_values[index];});this.properties={...data.properties};}
}
await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:'PromptManagerFocused'},fixtureApp);
await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:`PromptManager${variant}`},fixtureApp);
window.node=new Node();node.onNodeCreated();
});
},variant);
await expect(page.locator('.pm-focused')).toBeVisible();
});
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}) => {
test(`${variant} 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'}]}}));
@@ -38,7 +49,7 @@ test('Focused library autocompletes filters and searches without changing editor
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();
await openLibrary(page);
const category=page.getByLabel('Filter category',{exact:true});
await category.fill('por');
await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
@@ -60,7 +71,7 @@ test('Focused library autocompletes filters and searches without changing editor
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, '});
expect(saved.properties[uiProperty].libraryFilters).toEqual({category:'Landscape',tags:'studio, '});
await expect(category).toHaveValue('Landscape');
await expect(tags).toHaveValue('studio, ');
let recentRequest;
@@ -69,14 +80,15 @@ test('Focused library autocompletes filters and searches without changing editor
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();
if (isSplit) await page.getByRole('searchbox',{name:'Search saved prompts'}).focus();
else 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}) => {
test(`${variant} 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 openLibrary(page);
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);
@@ -88,7 +100,7 @@ test('Focused library can search by filters alone and Enter searches after autoc
expect(requests[1].searchParams.get('tags')).toBe('custom');
});
test('Focused completes saved tags with keyboard and pointer and appends commas', async ({page}) => {
test(`${variant} 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');
return route.fulfill({json:{success:true,suggestions:(q === 'bl' ? ['blue eyes','blue sky'] : ['portrait','blue eyes']).map(name => ({name}))}});
@@ -118,7 +130,7 @@ test('Focused completes saved tags with keyboard and pointer and appends commas'
await expect(tags).toHaveValue('A brand new tag');
});
test('Focused metadata autocomplete ignores stale responses, recovers from errors and cleans up', async ({page}) => {
test(`${variant} metadata autocomplete ignores stale responses, recovers from errors and cleans up`, async ({page}) => {
let release;
let mode='delayed';
await page.route('**/prompt_manager/tags/suggest?**', async route => {
@@ -146,7 +158,7 @@ test('Focused metadata autocomplete ignores stale responses, recovers from error
await expect(page.getByRole('listbox')).toHaveCount(0);
});
test('Focused completes one category, allows new values and dismisses suggestions on blur', async ({page}) => {
test(`${variant} completes one category, allows new values and dismisses suggestions on blur`, async ({page}) => {
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait','Landscape','Portrait studio']}}));
await page.getByText('Metadata · category / tags',{exact:true}).click();
const category=page.getByLabel('Category',{exact:true});
@@ -168,7 +180,7 @@ test('Focused completes one category, allows new values and dismisses suggestion
await expect(category).toHaveValue('New category');
});
test('Focused shares suggestion controls safely with another bundled toolkit copy', async ({page}) => {
test(`${variant} shares suggestion controls safely with another bundled toolkit copy`, async ({page}) => {
await page.evaluate(async()=>{
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
const first=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=first');
@@ -196,10 +208,10 @@ test('Focused shares suggestion controls safely with another bundled toolkit cop
await expect(lists).toHaveCount(0);
});
test('Focused previews before loading, keeps metadata optional and composes text',async({page})=>{
test(`${variant} previews before loading, keeps metadata optional and composes text`,async({page})=>{
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:1,text:'Library portrait',category:'Portrait',tags:['studio']}]}}));
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('My draft');
await page.getByRole('button',{name:'Browse library',exact:true}).click();
await openLibrary(page);
await page.getByRole('button',{name:'Recent',exact:true}).click();
await page.getByRole('button',{name:/Library portrait/}).click();
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('My draft');
@@ -214,7 +226,7 @@ test('Focused previews before loading, keeps metadata optional and composes text
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,6))).toEqual(['Library portrait','','','','trigger','detailed']);
});
test('Focused restores workflow values and disclosures and protects connected text',async({page})=>{
test(`${variant} restores workflow values and disclosures and protects connected text`,async({page})=>{
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Saved draft');
await page.getByText('Metadata · category / tags',{exact:true}).click();
await page.getByLabel('Category',{exact:true}).fill('Saved category');
@@ -228,14 +240,14 @@ test('Focused restores workflow values and disclosures and protects connected te
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Saved draft');
});
test('Focused searches quoted tags with Enter and explicitly loads metadata', async ({page}) => {
test(`${variant} searches quoted tags with Enter and explicitly loads metadata`, async ({page}) => {
let requested;
await page.route('**/prompt_manager/search?**', async route => {
requested = new URL(route.request().url());
await route.fulfill({json:{success:true,results:[{id:7,text:'Selected fox',category:'Animals',tags:['film grain','fox']}]}});
});
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Preserved draft');
await page.getByRole('button',{name:'Browse library',exact:true}).click();
await openLibrary(page);
const query=page.getByRole('searchbox',{name:'Search saved prompts'});
await query.fill('fox tag:"film grain"');
await query.press('Enter');
@@ -250,7 +262,7 @@ test('Focused searches quoted tags with Enter and explicitly loads metadata', as
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,4))).toEqual(['Selected fox','Animals','film grain, fox','fox tag:"film grain"']);
});
test('Focused reports empty queries, no results, and safely renders backend and HTTP errors', async ({page}) => {
test(`${variant} reports empty queries, no results, and safely renders backend and HTTP errors`, async ({page}) => {
let requests=0;
let mode='empty';
const malicious='<img src=x onerror="window.focusedInjected=true">';
@@ -259,7 +271,7 @@ test('Focused reports empty queries, no results, and safely renders backend and
if(mode==='http') return route.fulfill({status:503,body:'Unavailable'});
await route.fulfill({json:mode==='error'?{success:false,error:malicious}:{success:true,results:[]}});
});
await page.getByRole('button',{name:'Browse library',exact:true}).click();
await openLibrary(page);
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, a category or tags.');
@@ -287,10 +299,10 @@ test('Focused reports empty queries, no results, and safely renders backend and
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
});
test('Focused treats saved prompt content and metadata as text', async ({page}) => {
test(`${variant} treats saved prompt content and metadata as text`, async ({page}) => {
const malicious='<img src=x onerror="window.focusedInjected=true">';
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:8,text:malicious,category:'<script>bad()</script>',tags:['<svg onload=bad()>']}]}}));
await page.getByRole('button',{name:'Browse library',exact:true}).click();
await openLibrary(page);
await page.getByRole('button',{name:'Recent',exact:true}).click();
await page.locator('.pm-editor-result').click();
await expect(page.locator('.pm-editor-selection .pm-editor-preview')).toHaveText(malicious);
@@ -312,9 +324,9 @@ async function deferLibraryRequests(page) {
});
}
test('Focused keeps the latest result when searches and recent requests overlap', async ({page}) => {
test(`${variant} keeps the latest result when searches and recent requests overlap`, async ({page}) => {
await deferLibraryRequests(page);
await page.getByRole('button',{name:'Browse library',exact:true}).click();
await openLibrary(page);
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('older search');
await page.getByRole('button',{name:'Search',exact:true}).click();
await page.getByRole('button',{name:'Recent',exact:true}).click();
@@ -333,9 +345,9 @@ test('Focused keeps the latest result when searches and recent requests overlap'
await expect(page.locator('.pm-editor-result')).toHaveText('Latest search');
});
test('Focused removal aborts work and stops theme and control listeners', async ({page}) => {
test(`${variant} removal aborts work and stops theme and control listeners`, async ({page}) => {
await deferLibraryRequests(page);
await page.getByRole('button',{name:'Browse library',exact:true}).click();
await openLibrary(page);
await page.getByRole('button',{name:'Recent',exact:true}).click();
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
const removed=await page.evaluate(()=>{
@@ -348,11 +360,18 @@ test('Focused removal aborts work and stops theme and control listeners', async
await page.evaluate(()=>completeLibraryRequest(0,'Late result after removal'));
await expect(page.locator('.pm-editor-result')).toHaveCount(0);
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
await page.getByRole('button',{name:'Hide library',exact:true}).click();
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toBeVisible();
if (isSplit) {
const count=await page.evaluate(()=>libraryRequests.length);
await page.getByRole('button',{name:'Recent',exact:true}).click();
expect(await page.evaluate(()=>libraryRequests.length)).toBe(count);
await expect(page.locator('.pm-editor-library')).toBeVisible();
} else {
await page.getByRole('button',{name:'Hide library',exact:true}).click();
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toBeVisible();
}
});
test('Focused fields reflow with node width and follow live host themes', async ({page}) => {
test(`${variant} fields reflow with node width and follow live host themes`, async ({page}) => {
await page.setViewportSize({width:1000,height:1000});
await page.getByText('Composition · prepend / append',{exact:true}).click();
await page.evaluate(()=>{node._focusedView.element.style.width='700px';node._focusedView.element.style.height='850px';});
@@ -373,7 +392,7 @@ test('Focused fields reflow with node width and follow live host themes', async
await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).toBe(dark);
});
test('Focused composed preview preserves main prompt whitespace like queued execution', async ({page}) => {
test(`${variant} composed preview preserves main prompt whitespace like queued execution`, async ({page}) => {
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill(' draft ');
await page.getByText('Composition · prepend / append',{exact:true}).click();
await page.getByLabel('Prepend',{exact:true}).fill(' trigger ');
@@ -382,17 +401,17 @@ test('Focused composed preview preserves main prompt whitespace like queued exec
expect(await page.locator('[data-composed-preview]').textContent()).toBe('trigger draft detail');
});
test('Focused exposes the visible text element for autocomplete and host extension listeners', async ({page}) => {
test(`${variant} exposes the visible text element for autocomplete and host extension listeners`, async ({page}) => {
expect(await page.evaluate(()=>node.widgets.find(widget=>widget.name==='text').inputEl===document.querySelector('.pm-focused textarea'))).toBe(true);
});
test('Focused expands its main editor when the node gets taller',async({page})=>{
await page.locator('.pm-focused').evaluate(element=>{element.style.width='550px';element.style.height='800px';});
test(`${variant} expands its main editor when the node gets taller`,async({page})=>{
await page.locator('.pm-focused').evaluate((element,isSplit)=>{element.style.width=isSplit?'900px':'550px';element.style.height='800px';},isSplit);
const editor=await page.getByRole('textbox',{name:'Main prompt',exact:true}).boundingBox();
expect(editor.height).toBeGreaterThan(400);
});
test('Focused respects the existing Web UI display preference',async({page})=>{
test(`${variant} respects the existing Web UI display preference`,async({page})=>{
await page.route('**/prompt_manager/settings',route=>route.fulfill({json:{success:true,settings:{webui_display_mode:'newtab'}}}));
await page.evaluate(()=>{
const Node=node.constructor;node.onRemoved();node._focusedView.element.remove();node=new Node();node.onNodeCreated();
@@ -403,9 +422,68 @@ test('Focused respects the existing Web UI display preference',async({page})=>{
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/web','_blank']);
});
test('Focused reports blocked Web UI popups without leaving the workflow',async({page})=>{
test(`${variant} reports blocked Web UI popups without leaving the workflow`,async({page})=>{
await page.evaluate(()=>{window.open=()=>null;});
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
await expect(page.locator('.pm-focused [role="alert"]')).toContainText('Allow popups');
await expect(page.locator('.pm-focused')).toBeVisible();
});
if (isSplit) test('Split keeps the library beside the editor when wide and stacks it when narrow', async ({page}) => {
await page.setViewportSize({width:1200,height:1000});
const root=page.locator('.pm-split');
await expect(root).toHaveAttribute('aria-label','Split prompt editor');
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toHaveCount(0);
await root.evaluate(element=>{element.style.width='900px';element.style.height='800px';});
const compose=root.locator('.pm-editor-workspace > .pm-editor-compose');
const library=root.locator('.pm-editor-workspace > .pm-editor-library');
await expect(compose.locator('.pm-editor-main')).toBeVisible();
let a=await compose.boundingBox(),b=await library.boundingBox();
expect(Math.abs(a.y-b.y)).toBeLessThan(2);
expect(b.x).toBeGreaterThanOrEqual(a.x+a.width);
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{text:'Library example'}]}}));
await page.getByRole('button',{name:'Recent',exact:true}).click();
await expect(page.locator('.pm-editor-result')).toContainText('Library example');
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Editing beside the library');
await expect(library).toBeVisible();
await expect(page.locator('.pm-editor-result')).toContainText('Library example');
expect(await page.evaluate(()=>node.serialize().widgets_values[0])).toBe('Editing beside the library');
await root.evaluate(element=>{element.style.width='340px';});
await expect.poll(async()=>{const a=await compose.boundingBox(),b=await library.boundingBox();return b.y>=a.y+a.height;}).toBe(true);
expect(await root.evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Editing beside the library');
await expect(library).toBeVisible();
});
if (isSplit) test('Split bounds long recent results and selected previews at the default node size', async ({page}) => {
await page.setViewportSize({width:1200,height:1000});
const root=page.locator('.pm-split');
await root.evaluate(element=>{element.style.width='900px';element.style.height='650px';});
const longPrompt='Long saved prompt '+('detailed portrait in a studio with natural light '.repeat(110));
const prompts=Array.from({length:20},(_,index)=>({id:index+1,text:index===0?longPrompt:`Saved prompt ${index+1}`,category:'Portrait',tags:['studio']}));
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:prompts}}));
await page.getByRole('button',{name:'Recent',exact:true}).click();
const results=root.locator('.pm-editor-results');
await expect(results.locator('.pm-editor-result')).toHaveCount(20);
const rowHeights=await results.locator('.pm-editor-result > span:first-child').evaluateAll(elements=>elements.map(element=>element.getBoundingClientRect().height));
expect(rowHeights.every(height=>height>0)).toBe(true);
const resultSize=await results.evaluate(element=>({height:element.getBoundingClientRect().height,scroll:element.scrollHeight,client:element.clientHeight}));
expect.soft(resultSize.height).toBeLessThanOrEqual(300);
expect(resultSize.scroll).toBeGreaterThan(resultSize.client);
await results.locator('.pm-editor-result').first().click();
const preview=root.locator('.pm-editor-selection > .pm-editor-preview');
await expect(preview).toHaveText(longPrompt);
const previewSize=await preview.evaluate(element=>({height:element.getBoundingClientRect().height,scroll:element.scrollHeight,client:element.clientHeight}));
expect.soft(previewSize.height).toBeLessThanOrEqual(180);
expect.soft(previewSize.scroll).toBeGreaterThan(previewSize.client);
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(longPrompt);
const bounds=await root.boundingBox();
for (const selector of ['.pm-editor-compose','.pm-editor-library']) {
const pane=await root.locator(selector).boundingBox();
expect(pane.y).toBeGreaterThanOrEqual(bounds.y);
expect(pane.y+pane.height).toBeLessThanOrEqual(bounds.y+bounds.height);
}
expect(await root.evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
});
});
}
+128 -33
View File
@@ -1,19 +1,21 @@
import { writeFile } from 'node:fs/promises';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { installHostHooks } from './host-fixture.mjs';
installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptSearchList', 'PromptManagerFocused']);
async function addFocused(page) {
return page.evaluate(async () => {
async function addEditor(page, type) {
await page.waitForFunction(type => Boolean(globalThis.LiteGraph?.registered_node_types?.[type]), type);
return page.evaluate(async type => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('PromptManagerFocused');
if (!node) throw new Error('PromptManagerFocused must be registered by the real host');
const node = LiteGraph.createNode(type);
if (!node) throw new Error(`${type} must be registered by the real host`);
app.graph.add(node);
node.setSize([680, 1050]);
node.setSize([type === 'PromptManagerSplit' ? 1100 : 680, 1050]);
app.canvas.ds.scale = .8;
app.canvas.centerOnNode(node);
return node.id;
});
}, type);
}
async function model(page, id) {
@@ -29,19 +31,26 @@ async function seed(request, text, category = 'Focused host tests', tags = ['foc
expect((await response.json()).success).toBe(true);
}
async function showLibrary(page) {
const button = page.locator('.pm-focused').getByRole('button', { name: 'Browse library', exact: true });
if (await button.count()) await button.click();
}
async function browse(page, text) {
const root = page.locator('.pm-focused');
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
await showLibrary(page);
await root.getByRole('button', { name: 'Recent', exact: true }).click();
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
return root;
}
test('Focused definition preserves Classic encoding and exposes composition STRING sockets', async ({ request }) => {
for (const [label, type] of [['Focused', 'PromptManagerFocused'], ['Split', 'PromptManagerSplit']]) {
test.describe(label, () => {
test(`${label} definition preserves Classic encoding and exposes composition STRING sockets`, async ({ request }) => {
const definitions = await (await request.get('/object_info')).json();
const focused = definitions.PromptManagerFocused;
const focused = definitions[type];
expect(focused).toBeDefined();
expect(focused.display_name).toBe('Prompt Manager (Focused)');
expect(focused.display_name).toBe(`Prompt Manager (${label})`);
expect(focused.output).toEqual(definitions.PromptManager.output);
expect(focused.input.required).toEqual(definitions.PromptManager.input.required);
for (const name of ['prepend_text', 'append_text']) {
@@ -50,10 +59,10 @@ test('Focused definition preserves Classic encoding and exposes composition STRI
}
});
test('Focused preview, explicit load, composition, renderer changes and workflow reload preserve state', async ({ page, request }, testInfo) => {
test(`${label} preview, explicit load, composition, renderer changes and workflow reload preserve state`, async ({ page, request }, testInfo) => {
const text = `Focused selected ${testInfo.project.name}`;
await seed(request, text);
const id = await addFocused(page);
const id = await addEditor(page, type);
const root = page.locator('.pm-focused');
const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true });
await expect(root).toBeVisible();
@@ -92,20 +101,20 @@ test('Focused preview, explicit load, composition, renderer changes and workflow
expect(queued).toMatchObject({ text, category: 'Focused host tests', tags: 'focused-host', prepend_text: 'LoRA trigger', append_text: 'high detail' });
await expect(editor).toHaveValue(text);
await expect.poll(() => model(page, id)).toMatchObject({ text, prepend_text: 'LoRA trigger', append_text: 'high detail' });
await page.screenshot({ path: testInfo.outputPath('focused-reloaded.png') });
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-reloaded.png`) });
await page.evaluate(async id => { const { app } = await import('/scripts/app.js'); app.graph.remove(app.graph.getNodeById(id)); }, id);
await expect(root).toHaveCount(0);
});
test('Focused search preview respects metadata opt-out', async ({ page, request }, testInfo) => {
test(`${label} search preview respects metadata opt-out`, async ({ page, request }, testInfo) => {
const text = `Focused search ${testInfo.project.name}`;
await seed(request, text, 'Incoming category', ['incoming-tag']);
const id = await addFocused(page);
const id = await addEditor(page, type);
const root = page.locator('.pm-focused');
await root.getByText('Metadata · category / tags', { exact: true }).click();
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Keep category');
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('keep-tag');
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
await showLibrary(page);
await root.getByRole('searchbox', { name: 'Search saved prompts', exact: true }).fill(text);
await root.getByRole('button', { name: 'Search', exact: true }).click();
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
@@ -114,10 +123,10 @@ test('Focused search preview respects metadata opt-out', async ({ page, request
await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Keep category', tags: 'keep-tag' });
});
test('Focused connected STRING inputs remain linked through UI actions and reload', async ({ page, request }, testInfo) => {
test(`${label} connected STRING inputs remain linked through UI actions and reload`, async ({ page, request }, testInfo) => {
const text = `Connected library ${testInfo.project.name}`;
await seed(request, text);
const id = await addFocused(page);
const id = await addEditor(page, type);
const root = page.locator('.pm-focused');
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Local main');
await root.getByText('Composition · prepend / append', { exact: true }).click();
@@ -179,11 +188,11 @@ test('Focused connected STRING inputs remain linked through UI actions and reloa
await expect(input).toBeEditable();
await expect(input).toHaveValue(value);
}
await page.screenshot({ path: testInfo.outputPath('focused-disconnected.png') });
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-disconnected.png`) });
});
test('Focused follows actual ComfyUI light and dark palettes', async ({ page }, testInfo) => {
await addFocused(page);
test(`${label} follows actual ComfyUI light and dark palettes`, async ({ page }, testInfo) => {
await addEditor(page, type);
for (const palette of ['light', 'dark']) {
await page.evaluate(async palette => {
const { app } = await import('/scripts/app.js');
@@ -191,30 +200,35 @@ test('Focused follows actual ComfyUI light and dark palettes', async ({ page },
}, palette);
const root = page.locator('.pm-focused');
await expect(root).toHaveAttribute('data-ca-theme', palette);
await expect(root.getByRole('button', { name: 'Browse library', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
await page.screenshot({ path: testInfo.outputPath(`focused-${palette}.png`) });
if (label === 'Split') {
await expect(root.getByRole('button', { name: /^(Browse|Hide) library$/ })).toHaveCount(0);
await expect(root.getByRole('button', { name: 'Search', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
} else {
await expect(root.getByRole('button', { name: 'Browse library', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
}
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-${palette}.png`) });
}
});
test('Focused Web UI button opens actual standalone pages', async ({ page }) => {
await addFocused(page);
test(`${label} Web UI button opens actual standalone pages`, async ({ page }) => {
await addEditor(page, type);
const popupPromise = page.waitForEvent('popup');
await page.locator('.pm-focused').getByRole('button', { name: 'Web UI ↗', exact: true }).click();
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
await expect(popup.locator('body')).toContainText('Prompt');
await expect(page.locator('.pm-focused').getByRole('status')).toHaveText('Web UI opened.');
await expect(page.locator('.pm-focused').getByRole('status').filter({ hasText: 'Web UI opened.' })).toHaveText('Web UI opened.');
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
await expect(popup.locator('#addPromptBtn')).toBeVisible();
await popup.close();
});
test('Focused metadata completion uses saved categories and tags across renderers and reload', async ({ page, request }, testInfo) => {
test(`${label} metadata completion uses saved categories and tags across renderers and reload`, async ({ page, request }, testInfo) => {
const category = `Host completion category ${testInfo.project.name}`;
const tag = `host-completion-${testInfo.project.name}`;
await seed(request, `Saved autocomplete ${testInfo.project.name}`, category, [tag]);
const id = await addFocused(page);
const id = await addEditor(page, type);
const root = page.locator('.pm-focused');
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Autocomplete workflow');
await root.getByText('Metadata · category / tags', { exact: true }).click();
@@ -244,7 +258,7 @@ test('Focused metadata completion uses saved categories and tags across renderer
expect(bounds.y).toBeGreaterThanOrEqual(0);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width);
expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height);
await page.screenshot({ path: testInfo.outputPath(`focused-metadata-${palette}.png`) });
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-metadata-${palette}.png`) });
await option.click();
await expect(tagInput).toHaveValue(`keep, ${tag}, `);
}
@@ -279,20 +293,20 @@ test('Focused metadata completion uses saved categories and tags across renderer
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) => {
test(`${label} 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 id = await addEditor(page, type);
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();
await showLibrary(page);
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));
@@ -308,7 +322,7 @@ test('Focused library autocomplete filters saved prompts without editing draft m
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 page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-library-filters-${palette}.png`) });
await option.click();
await expect(filterTags).toHaveValue(`${tag}, `);
}
@@ -344,3 +358,84 @@ test('Focused library autocomplete filters saved prompts without editing draft m
await expect(results.first()).toContainText(matchingText);
await expect.poll(() => model(page, id)).toMatchObject(draft);
});
if (label === 'Split') {
test('Split keeps its library visible beside the editor and stacks narrow nodes', async ({ page, request }, testInfo) => {
for (let index = 0; index < 21; index++) {
await seed(request, `Split layout recent ${testInfo.project.name} ${index}`);
}
const id = await addEditor(page, type);
const root = page.locator('.pm-split');
await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
node.setSize([900, 650]);
app.canvas.centerOnNode(node);
}, id);
await expect(root).toHaveAttribute('aria-label', 'Split prompt editor');
const savedProperties = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
return app.graph.getNodeById(id).serialize().properties;
}, id);
expect(savedProperties).toHaveProperty('splitUI');
expect(savedProperties).not.toHaveProperty('focusedUI');
await expect(root.getByRole('button', { name: /^(Browse|Hide) library$/ })).toHaveCount(0);
const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true });
const library = root.getByRole('region', { name: 'Prompt library', exact: true });
await expect(library).toBeVisible();
const captureGeometry = async phase => {
const geometry = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
const root = node._focusedView.element;
const bounds = selector => root.querySelector(selector)?.getBoundingClientRect().toJSON();
const ancestors = [];
for (let element = root; element && element !== document.body; element = element.parentElement) {
const style = getComputedStyle(element);
ancestors.push({ tag: element.tagName, className: element.className, bounds: element.getBoundingClientRect().toJSON(), inlineStyle: element.style.cssText, computed: Object.fromEntries(['height', 'min-height', 'max-height', 'display', 'overflow', 'flex', 'contain', 'position'].map(name => [name, style.getPropertyValue(name)])) });
}
return { nodeSize: [...node.size], ancestors, root: root.getBoundingClientRect().toJSON(), library: bounds('.pm-editor-library'), results: bounds('.pm-editor-results'), use: bounds('.pm-editor-selection button'), footer: bounds(':scope > .pm-editor-status') };
}, id);
const path = testInfo.outputPath(`split-${phase}-geometry.json`);
await writeFile(path, JSON.stringify(geometry, null, 2));
await testInfo.attach(`split-${phase}-geometry.json`, { path, contentType: 'application/json' });
return geometry;
};
const beforeRecent = await captureGeometry('before-recent');
await root.getByRole('button', { name: 'Recent', exact: true }).click();
const results = library.locator('.pm-editor-results').getByRole('button');
await expect(results).toHaveCount(20);
const promptText = results.first().locator('span').first();
const selectedText = await promptText.innerText();
await results.first().click();
const use = root.getByRole('button', { name: 'Use prompt', exact: true });
const afterSelection = await captureGeometry('after-selection');
await page.screenshot({ path: testInfo.outputPath('split-default-selected.png') });
expect.soft((await promptText.boundingBox())?.height || 0).toBeGreaterThan(10);
expect.soft(afterSelection.root.height).toBeLessThanOrEqual(beforeRecent.root.height + 1);
await use.click();
await expect(editor).toHaveValue(selectedText);
await expect.poll(async () => {
const main = await editor.boundingBox();
const browser = await library.boundingBox();
return browser.x >= main.x + main.width;
}).toBe(true);
await page.screenshot({ path: testInfo.outputPath('split-wide.png') });
await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
node.setSize([480, 1050]);
app.canvas.centerOnNode(node);
}, id);
await expect.poll(async () => {
const main = await editor.boundingBox();
const browser = await library.boundingBox();
return browser.y >= main.y + main.height;
}).toBe(true);
await expect(library).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('split-narrow.png') });
});
}
});
}
+33
View File
@@ -361,3 +361,36 @@ test("Focused workflow-only metadata recovers serialized fields and connected co
assert.equal(result.negativePrompt, "avoid blurry watermark");
assert.equal(result.checkpoint, "model.safetensors");
});
test("Split API metadata composes linked positive text and preserves negative role", () => {
const prompt = {
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
2: { class_type: "PromptManagerSplit", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: "8k" } },
3: { class_type: "PromptManagerSplit", inputs: { text: "blurry", prepend_text: "avoid" } },
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
5: { class_type: "PrimitiveString", inputs: { value: "lora trigger" } },
};
const result = extractGenerationParams({ prompt });
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
assert.equal(result.negativePrompt, "avoid blurry");
});
test("Split workflow-only metadata recovers serialized fields and connected composition", () => {
const workflow = {
nodes: [
{ id: 1, type: "KSampler", inputs: [{ name: "positive", link: 1 }, { name: "negative", link: 2 }, { name: "model", link: 3 }] },
{ id: 2, type: "PromptManagerSplit", widgets_values: ["a fox", "animals", "fox", "ignored search", "stale widget", "8k"], inputs: [{ name: "prepend_text", link: 4 }] },
{ id: 3, type: "PromptManagerSplit", widgets_values: ["blurry", "negative", "bad", "ignored search", "avoid", "watermark"] },
{ id: 4, type: "UNETLoader", widgets_values: ["model.safetensors", "default"] },
{ id: 5, type: "CLIPTextEncode", widgets_values: ["lora trigger"] },
],
links: [
[1, 2, 0, 1, 0, "CONDITIONING"], [2, 3, 0, 1, 1, "CONDITIONING"],
[3, 4, 0, 1, 2, "MODEL"], [4, 5, 0, 2, 0, "STRING"],
],
};
const result = extractGenerationParams({ workflow });
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
assert.equal(result.negativePrompt, "avoid blurry watermark");
assert.equal(result.checkpoint, "model.safetensors");
});
+11
View File
@@ -138,11 +138,22 @@ class TestPackageWiring(PackageTestCase):
[
"PromptManager",
"PromptManagerFocused",
"PromptManagerSplit",
"PromptManagerText",
"PromptSearchList",
],
)
def test_split_display_name_and_execution_contract_are_registered(self):
node = self.package.NODE_CLASS_MAPPINGS["PromptManagerSplit"]
self.assertEqual(
self.package.NODE_DISPLAY_NAME_MAPPINGS["PromptManagerSplit"],
"Prompt Manager (Split)",
)
focused = self.package.NODE_CLASS_MAPPINGS["PromptManagerFocused"]
self.assertTrue(issubclass(node, focused))
self.assertEqual(node.INPUT_TYPES(), focused.INPUT_TYPES())
def test_db_factory_returns_the_shared_database(self):
self.assertIsNotNone(self.package._global_db)
self.assertIs(self.package._db_factory(), self.package._global_db)
+13
View File
@@ -67,6 +67,19 @@ class TestPositivePromptNodes(unittest.TestCase):
del graph["5"]
self.assertEqual(run_prompt_nodes(graph), ["2"])
def test_split_nodes_preserve_positive_roles_and_samplerless_fallback(self):
graph = {
"1": {"class_type": "PromptManagerSplit", "inputs": {"text": "cat"}},
"2": {"class_type": "PromptManagerSplit", "inputs": {"text": "blur"}},
"5": ksampler("1", "2"),
}
self.assertEqual(positive_prompt_nodes(graph), ["1"])
self.assertEqual(run_prompt_nodes(graph), ["1"])
graph["1"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "cat"}}
self.assertEqual(run_prompt_nodes(graph), [])
del graph["5"]
self.assertEqual(run_prompt_nodes(graph), ["2"])
def test_negative_only_prompt_manager_is_never_positive(self):
graph = {
"176": pm("blurry"),
+54
View File
@@ -0,0 +1,54 @@
"""Split changes presentation while inheriting Focused execution and storage."""
import logging
import unittest
import unittest.mock
from prompt_manager import PromptManagerFocused, PromptManagerSplit
class TestSplitNode(unittest.TestCase):
def test_split_preserves_focused_schema_encoding_and_change_detection(self):
self.assertTrue(issubclass(PromptManagerSplit, PromptManagerFocused))
self.assertEqual(
PromptManagerSplit.INPUT_TYPES(), PromptManagerFocused.INPUT_TYPES()
)
self.assertEqual(
PromptManagerSplit.RETURN_TYPES, PromptManagerFocused.RETURN_TYPES
)
self.assertEqual(PromptManagerSplit.FUNCTION, PromptManagerFocused.FUNCTION)
self.assertIs(
PromptManagerSplit.encode_prompt, PromptManagerFocused.encode_prompt
)
self.assertEqual(
PromptManagerSplit.IS_CHANGED(None, "cat", category="Animals"),
PromptManagerFocused.IS_CHANGED(None, "cat", category="Animals"),
)
def test_split_encodes_composition_and_persists_main_text_with_metadata(self):
node = PromptManagerSplit.__new__(PromptManagerSplit)
node.logger = logging.getLogger("test.split")
node.comfyui_integration = unittest.mock.Mock()
node._inject_lora_trigger_words = lambda text: text
node._track_prompt_execution = unittest.mock.Mock(return_value=42)
clip = unittest.mock.Mock()
graph = {"1": {"class_type": "PromptManagerSplit", "inputs": {"text": "cat"}}}
conditioning, text = node.encode_prompt(
clip,
"cat",
category="Animals",
tags="studio",
prepend_text="trigger",
append_text="detailed",
prompt=graph,
unique_id="1",
)
self.assertEqual(text, "trigger cat detailed")
clip.tokenize.assert_called_once_with(text)
self.assertIs(conditioning, clip.encode_from_tokens_scheduled.return_value)
tracked = node._track_prompt_execution.call_args.kwargs
self.assertEqual(tracked["text"], "cat")
self.assertEqual(tracked["category"], "Animals")
self.assertIn("studio", tracked["tags"])
self.assertEqual(tracked["prompt_graph"], graph)
self.assertEqual(tracked["unique_id"], "1")
+10
View File
@@ -136,6 +136,16 @@ class TestQueueHook(DbTestCase):
self.assertEqual(self._runs(positive), 2)
self.assertEqual(self._runs(negative), 0)
def test_split_queued_runs_count_only_positive_even_when_cached(self):
positive, negative = self._save("a cat"), self._save("blurry")
graph = workflow()
for node_id in ("134", "176"):
graph[node_id]["class_type"] = "PromptManagerSplit"
self._queue(graph)
self._queue(graph)
self.assertEqual(self._runs(positive), 2)
self.assertEqual(self._runs(negative), 0)
def test_requeue_counts_again_even_when_comfyui_skips_the_cached_node(self):
positive = self._save("a cat")
self._queue(workflow())
+1 -1
View File
@@ -12,7 +12,7 @@ import re
from typing import Any, Dict, List, Optional
PROMPT_MANAGER_TYPES = frozenset(
{"PromptManager", "PromptManagerText", "PromptManagerFocused"}
{"PromptManager", "PromptManagerText", "PromptManagerFocused", "PromptManagerSplit"}
)
# Upper bound on nodes expanded per graph; queued and embedded graphs are untrusted
+5 -4
View File
@@ -3,8 +3,8 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/classic-node.js'));
const { installFocusedNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js'));
const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused']);
const { installFocusedNode, installSplitNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js'));
const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit']);
// ============================================================================
// Top Bar Button Integration (similar to rgthree-comfy approach)
@@ -292,8 +292,9 @@ app.registerExtension({
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === 'PromptManagerFocused') {
installFocusedNode(nodeType, app, api, parseSearchQuery);
if (nodeData.name === 'PromptManagerFocused' || nodeData.name === 'PromptManagerSplit') {
const install = nodeData.name === 'PromptManagerSplit' ? installSplitNode : installFocusedNode;
install(nodeType, app, api, parseSearchQuery);
return;
}
if (nodeData.name === "PromptManager" || nodeData.name === "PromptManagerText") {
+1
View File
@@ -236,6 +236,7 @@
PromptManager: PROMPT_MANAGER_WIDGETS,
PromptManagerText: PROMPT_MANAGER_WIDGETS,
PromptManagerFocused: PROMPT_MANAGER_WIDGETS,
PromptManagerSplit: PROMPT_MANAGER_WIDGETS,
CheckpointLoader: ["config_name", "ckpt_name"],
UNETLoader: ["unet_name", "weight_dtype"],
};
+27 -14
View File
@@ -11,7 +11,13 @@ function loadStyles() {
const link = document.createElement('link'); Object.assign(link, {rel:'stylesheet', href}); document.head.append(link);
}
export function installFocusedNode(nodeType, app, api, parseQuery) {
export function installSplitNode(nodeType, app, api, parseQuery) {
installFocusedNode(nodeType, app, api, parseQuery, {layout:'split'});
}
export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'focused'} = {}) {
const split = layout === 'split';
const uiKey = split ? 'splitUI' : 'focusedUI';
const prototype = nodeType.prototype;
const created = prototype.onNodeCreated;
const removed = prototype.onRemoved;
@@ -30,13 +36,18 @@ export function installFocusedNode(nodeType, app, api, parseQuery) {
widget.hidden = true; widget.type = 'hidden'; widget.computeSize = () => [0, -4];
widgets.set(name, widget);
}
const element = document.createElement('div'); element.className = 'ca-tools pm-focused';
element.setAttribute('aria-label','Focused prompt editor');
const element = document.createElement('div'); element.className = `ca-tools pm-focused${split ? ' pm-split' : ''}`;
element.setAttribute('aria-label',split ? 'Split prompt editor' : 'Focused prompt editor');
loadStyles(); mountTheme(element, app.ui?.settings, scope);
const toolbar = document.createElement('div'); toolbar.className = 'pm-editor-row pm-editor-toolbar';
const title = document.createElement('strong'); title.textContent = 'Prompt Manager';
const browse = createButton('Browse library','ca-primary'); browse.setAttribute('aria-expanded','false');
const web = createButton('Web UI ↗'); toolbar.append(title, browse, web);
const web = createButton('Web UI ↗'); toolbar.append(title);
if (!split) toolbar.append(browse);
toolbar.append(web);
const workspace = document.createElement('div'); workspace.className = 'pm-editor-workspace';
const editor = document.createElement('div'); editor.className = 'pm-editor-compose';
workspace.append(editor);
let webDisplayMode = 'popup';
this._focusedSettingsReady = fetch(api.fileURL('/prompt_manager/settings'),{signal:scope.signal})
.then(response => response.ok ? response.json() : null)
@@ -55,17 +66,19 @@ export function installFocusedNode(nodeType, app, api, parseQuery) {
else delete textWidget.inputEl;
});
const mainField = createField('Main prompt', main, `${editorId}-text`);
mainField.classList.add('pm-editor-main');
const connected = document.createElement('p'); connected.className = 'pm-editor-status'; connected.textContent = 'Text is supplied by an upstream connection.';
const controls = new Map([['text', main]]);
const details = new Map();
const completions = new Map();
element.append(toolbar, mainField, connected);
element.append(toolbar, workspace);
editor.append(mainField, connected);
function disclosure(key, label) {
const section = document.createElement('details'); section.className = 'pm-editor-details';
const summary = document.createElement('summary'); summary.textContent = label; section.append(summary); element.append(section); details.set(key,section);
const summary = document.createElement('summary'); summary.textContent = label; section.append(summary); editor.append(section); details.set(key,section);
scope.listen(section, 'toggle', () => {
if (!section.open) completions.forEach(completion => completion.close());
if (!scope.signal.aborted) thisNode.properties.focusedUI = snapshot();
if (!scope.signal.aborted) thisNode.properties[uiKey] = snapshot();
});
return section;
}
@@ -112,7 +125,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,settings:app.ui?.settings,parseQuery,isConnected:() => isConnected('text'), onQuery:text => write('search_text',text), onFilters:() => {this.properties.focusedUI = snapshot();}, 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[uiKey] = snapshot();}, onUse:(prompt,includeMetadata) => {
if (isConnected('text')) return;
write('text',prompt.text);
if (includeMetadata) {
@@ -121,14 +134,14 @@ export function installFocusedNode(nodeType, app, api, parseQuery) {
}
render(); footer.setAttribute('role','status'); footer.textContent = 'Loaded prompt. Saved when this node runs.'; main.focus();
}});
element.append(library.element,footer);
workspace.append(library.element); element.append(footer);
function snapshot() {
return {libraryOpen:!library.element.hidden,libraryFilters:library.filters(),...Object.fromEntries([...details].map(([name,section]) => [name,section.open]))};
}
function restore() {
const state = thisNode.properties.focusedUI || {};
const state = thisNode.properties[uiKey] || {};
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.element.hidden = !split && !state.libraryOpen; browse.textContent = state.libraryOpen ? 'Hide library' : 'Browse library'; browse.setAttribute('aria-expanded', String(Boolean(state.libraryOpen)));
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.';}));
@@ -136,7 +149,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery) {
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();
this.properties[uiKey] = snapshot(); if (!library.element.hidden) library.focus();
});
scope.listen(web,'click',() => {
const url = api.fileURL('/prompt_manager/web');
@@ -146,11 +159,11 @@ export function installFocusedNode(nodeType, app, api, parseQuery) {
});
this._focusedView = {element,render,restore,snapshot};
this.addDOMWidget('prompt_manager_focused_ui','div',element,{serialize:false, hideOnZoom:false, getMinHeight:() => 360});
this.size = [550,650]; this.setSize(this.size); restore();
this.size = [split ? 900 : 550,650]; this.setSize(this.size); restore();
};
prototype.configure = function (...args) { const result = configure?.apply(this,args); this._focusedView?.restore(); return result; };
prototype.serialize = function (...args) {
if (this._focusedView) this.properties.focusedUI = this._focusedView.snapshot();
if (this._focusedView) this.properties[uiKey] = this._focusedView.snapshot();
return serialize?.apply(this,args);
};
prototype.onConnectionsChange = function (...args) { const result = connections?.apply(this,args); this._focusedView?.render(); return result; };
+17 -4
View File
@@ -1,9 +1,10 @@
.pm-focused { inline-size: 100%; block-size: 100%; min-block-size: 0; padding: 12px; display: flex; flex-direction: column; gap: 10px; overflow: auto; container: pm-editor / inline-size; border-radius: 5px; }
.pm-focused { box-sizing: border-box; inline-size: 100%; block-size: 100%; min-block-size: 0; padding: 12px; display: flex; flex-direction: column; gap: 10px; overflow: auto; container: pm-editor / inline-size; border-radius: 5px; }
.pm-focused [hidden] { display: none !important; }
.pm-editor-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.pm-editor-toolbar > strong { margin-inline-end: auto; font-weight: 500; }
.pm-focused .ca-field-label { align-self: flex-start; margin-inline: 0; text-align: start; }
.pm-focused > .ca-field { display: flex; flex: 1; min-block-size: 180px; flex-direction: column; align-items: stretch; gap: 5px; text-align: start; }
.pm-editor-workspace, .pm-editor-compose { display: contents; }
.pm-focused .pm-editor-main { display: flex; flex: 1; min-block-size: 180px; flex-direction: column; align-items: stretch; gap: 5px; text-align: start; }
.pm-focused textarea { flex: 1; inline-size: 100%; min-block-size: 160px; resize: vertical; line-height: 1.65; }
.pm-editor-details { border-block-end: 1px solid var(--ca-line); padding-block: 5px; }
.pm-editor-details > summary { color: var(--ca-muted); cursor: pointer; }
@@ -17,11 +18,23 @@
.pm-editor-library h3 { font: inherit; font-weight: 500; margin: 0; }
.pm-editor-query { display: flex; gap: 6px; flex-wrap: wrap; }
.pm-editor-query input { flex: 1; inline-size: 100%; min-inline-size: 100px; }
.pm-editor-results { display: grid; gap: 6px; max-block-size: 300px; overflow: auto; }
.pm-editor-results { display: grid; grid-auto-rows: max-content; align-content: start; gap: 6px; max-block-size: 300px; overflow: auto; }
.pm-focused button.pm-editor-result { flex-direction: column; align-items: stretch; text-align: start; white-space: normal; overflow-wrap: anywhere; }
.pm-editor-result[aria-pressed="true"] { border-color: var(--ca-accent); box-shadow: inset 3px 0 var(--ca-accent); }
.pm-editor-result-meta { color: var(--ca-muted); font-size: 11px; }
.pm-editor-result > span:first-child { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-clamp: 2; overflow: hidden; }
.pm-editor-result > span:first-child { flex-shrink: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-clamp: 2; overflow: hidden; }
.pm-editor-selection { display: grid; gap: 10px; }
.pm-editor-selection .pm-editor-row { justify-content: space-between; }
@container pm-editor (inline-size > 420px) { .pm-editor-fields { grid-template-columns: repeat(2,minmax(0,1fr)); } }
/* Contain intrinsic sizing so Nodes 2.0 keeps the widget within the node. */
.pm-split { contain: size; }
.pm-split .pm-editor-workspace { display: grid; grid-template-columns: minmax(0,1fr); gap: 12px; flex: 1; min-inline-size: 0; }
.pm-split .pm-editor-compose { display: flex; flex-direction: column; gap: 10px; min-inline-size: 0; min-block-size: 300px; }
.pm-split .pm-editor-library { min-inline-size: 0; align-content: start; }
.pm-split .pm-editor-preview { box-sizing: border-box; max-block-size: 180px; overflow: auto; }
@container pm-editor (inline-size > 720px) {
.pm-split .pm-editor-workspace { grid-template-columns: minmax(0,1.3fr) minmax(0,1fr); grid-template-rows: minmax(0,1fr); min-block-size: 0; }
.pm-split .pm-editor-compose, .pm-split .pm-editor-library { min-block-size: 0; overflow: auto; }
.pm-split .pm-editor-library { border-block-start: 0; border-inline-start: 1px solid var(--ca-line); padding-block-start: 0; padding-inline-start: 12px; }
.pm-split .pm-editor-fields { grid-template-columns: minmax(0,1fr); }
}