diff --git a/README.md b/README.md index ddcc554..ecd76e5 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,14 @@ node identifier and sockets. The reusable architecture is inspired by [Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma); no implementation or assets were copied. See the [toolkit maintenance notes](tools/vendor/README.md#shared-comfyassets-browser-controls). +**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 +category and tags come with it. Prepend and append STRING sockets remain visible +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. + ### 🔬 ComfyUI Workflow Analysis - **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data diff --git a/__init__.py b/__init__.py index 6593b25..f4e3076 100644 --- a/__init__.py +++ b/__init__.py @@ -3,12 +3,13 @@ ComfyUI_PromptManager: A comprehensive ComfyUI custom node that extends text enc with persistent prompt storage, advanced search capabilities, and an automatic image gallery system. -This module provides two main node types: +This module provides prompt node types: - PromptManager: CLIP encoding node that outputs CONDITIONING +- PromptManagerFocused: Editor-first layout using the same encoding contract - PromptManagerText: Text-only node that outputs STRING with prepend/append functionality -Both nodes share the same database backend for persistent prompt storage and include +The nodes share the same database backend for persistent prompt storage and include an automatic image gallery system that monitors ComfyUI output directories and links generated images to their source prompts. @@ -38,18 +39,20 @@ def get_version(): return "unknown" -from .prompt_manager import PromptManager # noqa: E402 +from .prompt_manager import PromptManager, PromptManagerFocused # noqa: E402 from .prompt_manager_text import PromptManagerText # noqa: E402 from .prompt_search_list import PromptSearchList # noqa: E402 NODE_CLASS_MAPPINGS = { "PromptManager": PromptManager, + "PromptManagerFocused": PromptManagerFocused, "PromptManagerText": PromptManagerText, "PromptSearchList": PromptSearchList, } NODE_DISPLAY_NAME_MAPPINGS = { "PromptManager": "Prompt Manager (Classic)", + "PromptManagerFocused": "Prompt Manager (Focused)", "PromptManagerText": "Prompt Manager Text", "PromptSearchList": "Prompt Search List", } diff --git a/docs/screenshots/focused-toolkit/classic-start-focused-dark.png b/docs/screenshots/focused-toolkit/classic-start-focused-dark.png new file mode 100644 index 0000000..1cf487b Binary files /dev/null and b/docs/screenshots/focused-toolkit/classic-start-focused-dark.png differ diff --git a/docs/screenshots/focused-toolkit/classic-start-focused-light.png b/docs/screenshots/focused-toolkit/classic-start-focused-light.png new file mode 100644 index 0000000..0219fe5 Binary files /dev/null and b/docs/screenshots/focused-toolkit/classic-start-focused-light.png differ diff --git a/docs/screenshots/focused-toolkit/nodes2-start-focused-dark.png b/docs/screenshots/focused-toolkit/nodes2-start-focused-dark.png new file mode 100644 index 0000000..f241258 Binary files /dev/null and b/docs/screenshots/focused-toolkit/nodes2-start-focused-dark.png differ diff --git a/docs/screenshots/focused-toolkit/nodes2-start-focused-light.png b/docs/screenshots/focused-toolkit/nodes2-start-focused-light.png new file mode 100644 index 0000000..e4ccc0d Binary files /dev/null and b/docs/screenshots/focused-toolkit/nodes2-start-focused-light.png differ diff --git a/prompt_manager.py b/prompt_manager.py index 90904c0..a0d9fce 100644 --- a/prompt_manager.py +++ b/prompt_manager.py @@ -281,3 +281,18 @@ class PromptManager(PromptManagerBase, ComfyNodeABC): combined = f"{text}|{prepend_text}|{append_text}|{category}|{tags}" return hashlib.sha256(combined.encode()).hexdigest() + + +class PromptManagerFocused(PromptManager): + """The editor-first UI uses the existing encoding and storage contract.""" + + @classmethod + def INPUT_TYPES(cls) -> InputTypeDict: + inputs = super().INPUT_TYPES() + for name in ("prepend_text", "append_text"): + kind, options = inputs["optional"][name] + inputs["optional"][name] = ( + kind, + {**options, "default": "", "forceInput": True}, + ) + return inputs diff --git a/tests/browser/focused-node.spec.mjs b/tests/browser/focused-node.spec.mjs new file mode 100644 index 0000000..e43476c --- /dev/null +++ b/tests/browser/focused-node.spec.mjs @@ -0,0 +1,244 @@ +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'; + +test.beforeEach(async({page,browserName})=>{ + await page.addInitScript(()=>Object.defineProperty(Crypto.prototype,'randomUUID',{value:undefined,configurable:true})); + await startCoverage(page,browserName); + page.focusedErrors=[];page.on('pageerror',error=>page.focusedErrors.push(error.message)); + await page.route('**/scripts/app.js',route=>route.fulfill({contentType:'text/javascript',body:'export const app=window.fixtureApp;'})); + 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()=>{ + 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};} + 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); + window.node=new Node();node.onNodeCreated(); + }); + await expect(page.locator('.pm-focused')).toBeVisible(); +}); +test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);}); + +test('Focused 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 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'); + await page.getByRole('switch',{name:'Load metadata'}).uncheck(); + await page.getByRole('button',{name:'Use prompt',exact:true}).click(); + await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Library portrait'); + await page.getByText('Composition · prepend / append',{exact:true}).click(); + await page.getByLabel('Prepend',{exact:true}).fill('trigger'); + await page.getByLabel('Append',{exact:true}).fill('detailed'); + await page.getByText('Composed text preview',{exact:true}).click(); + await expect(page.locator('[data-composed-preview]')).toHaveText('trigger Library portrait detailed'); + 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})=>{ + 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'); + await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);}); + await expect(page.getByLabel('Category',{exact:true})).toBeVisible(); + await expect(page.getByLabel('Category',{exact:true})).toHaveValue('Saved category'); + await page.evaluate(()=>{node.inputs.push({name:'text',link:9});node.onConnectionsChange();}); + await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toBeHidden(); + await expect(page.getByText('Text is supplied by an upstream connection.',{exact:true})).toBeVisible(); + await page.evaluate(()=>{node.inputs.find(input=>input.name==='text').link=null;node.onConnectionsChange();}); + 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}) => { + 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(); + const query=page.getByRole('searchbox',{name:'Search saved prompts'}); + await query.fill('fox tag:"film grain"'); + await query.press('Enter'); + await page.getByRole('button',{name:/Selected fox/}).click(); + expect(requested.searchParams.get('text')).toBe('fox'); + expect(requested.searchParams.get('tags')).toBe('film grain'); + await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Preserved draft'); + await page.getByRole('button',{name:'Use prompt',exact:true}).click(); + await page.getByText('Metadata · category / tags',{exact:true}).click(); + await expect(page.getByLabel('Category',{exact:true})).toHaveValue('Animals'); + await expect(page.getByLabel('Tags',{exact:true})).toHaveValue('film grain, fox'); + 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}) => { + let requests=0; + let mode='empty'; + const malicious=''; + await page.route('**/prompt_manager/search?**', async route => { + requests++; + 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(); + 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.'); + expect(requests).toBe(0); + await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing'); + await search.click(); + await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.'); + await expect(page.locator('.pm-editor-result')).toHaveCount(0); + mode='error'; + await search.click(); + await expect(page.getByRole('alert')).toHaveText(`Could not load prompts: ${malicious}`); + await expect(page.locator('.pm-editor-library img')).toHaveCount(0); + expect(await page.evaluate(()=>window.focusedInjected)).toBeUndefined(); + mode='http'; + await search.click(); + await expect(page.getByRole('alert')).toContainText('HTTP 503'); + 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.'); + mode='empty'; + await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing'); + await search.click(); + await expect(page.getByRole('alert')).toHaveCount(0); + 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}) => { + const malicious=''; + await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:8,text:malicious,category:'',tags:['']}]}})); + await page.getByRole('button',{name:'Browse library',exact:true}).click(); + 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); + await expect(page.locator('.pm-editor-library img, .pm-editor-library svg, .pm-editor-library script')).toHaveCount(0); + await page.getByRole('button',{name:'Use prompt',exact:true}).click(); + await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(malicious); + expect(await page.evaluate(()=>window.focusedInjected)).toBeUndefined(); +}); + +async function deferLibraryRequests(page) { + await page.evaluate(()=>{ + const originalFetch=window.fetch; + window.libraryRequests=[]; + window.fetch=(url,options)=>{ + if(!/\/prompt_manager\/(search|recent)\?/.test(String(url))) return originalFetch(url,options); + return new Promise(resolve=>window.libraryRequests.push({url:String(url),signal:options.signal,resolve})); + }; + window.completeLibraryRequest=(index,text)=>libraryRequests[index].resolve(new Response(JSON.stringify({success:true,results:[{id:index,text}]}),{status:200,headers:{'Content-Type':'application/json'}})); + }); +} + +test('Focused 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 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(); + expect(await page.evaluate(()=>libraryRequests[0].signal.aborted)).toBe(true); + await page.evaluate(()=>completeLibraryRequest(1,'Latest recent')); + await expect(page.locator('.pm-editor-result')).toHaveText('Latest recent'); + await page.evaluate(()=>completeLibraryRequest(0,'Stale search')); + await expect(page.locator('.pm-editor-result')).toHaveText('Latest recent'); + await page.getByRole('button',{name:'Recent',exact:true}).click(); + await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('latest search'); + await page.getByRole('button',{name:'Search',exact:true}).click(); + expect(await page.evaluate(()=>libraryRequests[2].signal.aborted)).toBe(true); + await page.evaluate(()=>completeLibraryRequest(3,'Latest search')); + await expect(page.locator('.pm-editor-result')).toHaveText('Latest search'); + await page.evaluate(()=>completeLibraryRequest(2,'Stale recent')); + await expect(page.locator('.pm-editor-result')).toHaveText('Latest search'); +}); + +test('Focused 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 page.getByRole('button',{name:'Recent',exact:true}).click(); + await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark'); + const removed=await page.evaluate(()=>{ + node.onRemoved(); + document.body.style.setProperty('--bg-color','#ffffff'); + fixtureApp.ui.settings.dispatchEvent(new Event('Comfy.ColorPalette.change')); + return {requestAborted:libraryRequests[0].signal.aborted,scopeAborted:node._focusedScope.signal.aborted}; + }); + expect(removed).toEqual({requestAborted:true,scopeAborted:true}); + 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(); +}); + +test('Focused 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';}); + const prepend=page.getByLabel('Prepend',{exact:true}); + const append=page.getByLabel('Append',{exact:true}); + let first=await prepend.boundingBox(),second=await append.boundingBox(); + expect(Math.abs(first.y-second.y)).toBeLessThan(2); + expect(second.x).toBeGreaterThan(first.x+first.width); + await page.evaluate(()=>{node._focusedView.element.style.width='300px';}); + await expect.poll(async()=>{const a=await prepend.boundingBox(),b=await append.boundingBox();return b.y>a.y+a.height;}).toBe(true); + expect(await page.locator('.pm-focused').evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true); + const dark=await prepend.evaluate(element=>getComputedStyle(element).backgroundColor); + await page.evaluate(()=>document.body.style.setProperty('--bg-color','#ffffff')); + await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','light'); + await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).not.toBe(dark); + await page.evaluate(()=>document.body.style.setProperty('--bg-color','#222222')); + await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark'); + await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).toBe(dark); +}); + +test('Focused 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 '); + await page.getByLabel('Append',{exact:true}).fill(' detail '); + await page.getByText('Composed text preview',{exact:true}).click(); + 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}) => { + 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';}); + 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})=>{ + 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(); + window.open=(...args)=>{window.openArgs=args;return {};}; + }); + await page.evaluate(()=>node._focusedSettingsReady); + await page.getByRole('button',{name:'Web UI ↗',exact:true}).click(); + expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/web','_blank']); +}); + +test('Focused 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(); +}); diff --git a/tests/comfyui/focused-node.spec.mjs b/tests/comfyui/focused-node.spec.mjs new file mode 100644 index 0000000..74f9203 --- /dev/null +++ b/tests/comfyui/focused-node.spec.mjs @@ -0,0 +1,211 @@ +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 () => { + 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'); + app.graph.add(node); + node.setSize([680, 1050]); + app.canvas.ds.scale = .8; + app.canvas.centerOnNode(node); + return node.id; + }); +} + +async function model(page, id) { + return page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + return Object.fromEntries(app.graph.getNodeById(id).widgets.map(widget => [widget.name, widget.value])); + }, id); +} + +async function seed(request, text, category = 'Focused host tests', tags = ['focused-host']) { + const response = await request.post('/prompt_manager/save', { data: { text, category, tags } }); + expect(response.ok()).toBe(true); + expect((await response.json()).success).toBe(true); +} + +async function browse(page, text) { + const root = page.locator('.pm-focused'); + await root.getByRole('button', { name: 'Browse library', exact: true }).click(); + 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 }) => { + const definitions = await (await request.get('/object_info')).json(); + const focused = definitions.PromptManagerFocused; + expect(focused).toBeDefined(); + expect(focused.display_name).toBe('Prompt Manager (Focused)'); + expect(focused.output).toEqual(definitions.PromptManager.output); + expect(focused.input.required).toEqual(definitions.PromptManager.input.required); + for (const name of ['prepend_text', 'append_text']) { + expect(focused.input.optional[name][0]).toBe('STRING'); + expect(focused.input.optional[name][1]).toMatchObject({ forceInput: true, default: '' }); + } +}); + +test('Focused 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 root = page.locator('.pm-focused'); + const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true }); + await expect(root).toBeVisible(); + expect(await editor.evaluate(async (element, id) => { + const { app } = await import('/scripts/app.js'); + return app.graph.getNodeById(id).widgets.find(widget => widget.name === 'text').inputEl === element; + }, id)).toBe(true); + await editor.fill('Original editing text'); + await browse(page, text); + await expect(editor).toHaveValue('Original editing text'); + expect((await model(page, id)).text).toBe('Original editing text'); + await root.getByRole('button', { name: 'Use prompt', exact: true }).click(); + await expect(editor).toHaveValue(text); + await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Focused host tests', tags: 'focused-host' }); + await root.getByText('Composition · prepend / append', { exact: true }).click(); + await root.getByRole('textbox', { name: 'Prepend', exact: true }).fill('LoRA trigger'); + await root.getByRole('textbox', { name: 'Append', exact: true }).fill('high detail'); + await root.getByText('Composed text preview', { exact: true }).click(); + await expect(root).toContainText(`LoRA trigger ${text} high detail`); + 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(root).toHaveCount(1); + await expect(editor).toHaveValue(text); + } + 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({ 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.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) => { + const text = `Focused search ${testInfo.project.name}`; + await seed(request, text, 'Incoming category', ['incoming-tag']); + const id = await addFocused(page); + const root = page.locator('.pm-focused'); + await root.getByText('Metadata · category / tags', { exact: true }).click(); + await root.getByRole('textbox', { name: 'Category', exact: true }).fill('Keep category'); + await root.getByRole('textbox', { name: 'Tags', exact: true }).fill('keep-tag'); + await root.getByRole('button', { name: 'Browse library', exact: true }).click(); + 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(); + await root.getByRole('switch', { name: 'Load metadata', exact: true }).uncheck(); + await root.getByRole('button', { name: 'Use prompt', exact: true }).click(); + 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) => { + const text = `Connected library ${testInfo.project.name}`; + await seed(request, text); + const id = await addFocused(page); + 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(); + await root.getByRole('textbox', { name: 'Prepend', exact: true }).fill('Local prepend'); + await root.getByRole('textbox', { name: 'Append', exact: true }).fill('Local append'); + await root.getByText('Composition · prepend / append', { exact: true }).click(); + const links = await page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + const { convertToInput } = await import('/extensions/core/widgetInputs.js'); + const target = app.graph.getNodeById(id); + convertToInput(target, target.widgets.find(widget => widget.name === 'text')); + const result = {}; + for (const name of ['text', 'prepend_text', 'append_text']) { + const source = LiteGraph.createNode('PrimitiveString'); + app.graph.add(source); + source.pos = [target.pos[0] - 360, target.pos[1] + Object.keys(result).length * 150]; + source.widgets.find(widget => widget.name === 'value').value = `Connected ${name}`; + const slot = target.inputs.findIndex(input => input.name === name); + if (slot < 0) throw new Error(`Missing ${name} input socket`); + source.connect(0, target, slot); + result[name] = [String(source.id), 0]; + } + app.canvas.centerOnNode(target); + return result; + }, id); + await browse(page, text); + await expect(root.getByRole('textbox', { name: 'Main prompt', exact: true })).toBeHidden(); + await expect(root.getByRole('button', { name: 'Use prompt', exact: true })).toBeDisabled(); + await root.getByText('Composition · prepend / append', { exact: true }).click(); + for (const name of ['Prepend', 'Append']) await expect(root.getByRole('textbox', { name, exact: true })).toBeHidden(); + const guardedValues = await model(page, id); + await root.locator('textarea, input').evaluateAll(inputs => { + for (const input of inputs) { + if (!input.hidden && !input.closest('[hidden]')) continue; + input.value = 'Attempt to replace upstream text'; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + }); + expect(await model(page, id)).toMatchObject(guardedValues); + for (let reload = 0; reload < 2; reload++) { + const inputs = await page.evaluate(async ({ id, reload }) => { + const { app } = await import('/scripts/app.js'); + if (reload) await app.loadGraphData(app.graph.serialize()); + return (await app.graphToPrompt()).output[String(id)].inputs; + }, { id, reload }); + expect(inputs).toMatchObject(links); + } + await expect(root.getByRole('button', { name: 'Use prompt', exact: true, includeHidden: true })).toBeDisabled(); + const disconnected = await page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + const node = app.graph.getNodeById(id); + for (const name of ['text', 'prepend_text', 'append_text']) node.disconnectInput(node.inputs.findIndex(input => input.name === name)); + return (await app.graphToPrompt()).output[String(id)].inputs; + }, id); + expect(disconnected).toMatchObject({ text: 'Local main', prepend_text: 'Local prepend', append_text: 'Local append' }); + for (const [name, value] of [['Main prompt', 'Local main'], ['Prepend', 'Local prepend'], ['Append', 'Local append']]) { + const input = page.getByRole('textbox', { name, exact: true }); + await expect(input).toHaveCount(1); + await expect(input).toBeEditable(); + await expect(input).toHaveValue(value); + } + await page.screenshot({ path: testInfo.outputPath('focused-disconnected.png') }); +}); + +test('Focused follows actual ComfyUI light and dark palettes', async ({ page }, testInfo) => { + await addFocused(page); + 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); + 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`) }); + } +}); + +test('Focused Web UI button opens actual standalone pages', async ({ page }) => { + await addFocused(page); + 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 popup.goto(new URL('/prompt_manager/gallery', page.url()).href); + await expect(popup.locator('#addPromptBtn')).toBeVisible(); + await popup.close(); +}); diff --git a/tests/comfyui/host-fixture.mjs b/tests/comfyui/host-fixture.mjs new file mode 100644 index 0000000..8246ad2 --- /dev/null +++ b/tests/comfyui/host-fixture.mjs @@ -0,0 +1,43 @@ +import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; +import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' }; + +export function installHostHooks(test, expectedTypes = ['PromptManager', 'PromptManagerText', 'PromptSearchList']) { + test.beforeEach(async ({ page, context, request }, testInfo) => { + const response = await request.get('/system_stats'); + expect(response.ok()).toBe(true); + const info = (await response.json()).system; + expect(info.comfyui_version).toBe(runtime.serverVersion); + expect(info.pytorch_version).toBe(`${runtime.torchVersion}+cpu`); + expect(info.required_frontend_version).toBe(runtime.frontendVersion); + expect(info.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe(runtime.frontendVersion); + await testInfo.attach('host-versions.json', { body: JSON.stringify(info, null, 2), contentType: 'application/json' }); + page.hostErrors = []; + page.on('pageerror', error => page.hostErrors.push(error.message)); + await context.route('**/*', async route => { + const request = route.request(); + const path = new URL(request.url()).pathname.replace(/^\/api\//, '/'); + if (path === '/settings' && request.method() === 'GET') { + const response = await route.fetch(); + return route.fulfill({ json: { ...await response.json(), + 'Comfy.VueNodes.Enabled': testInfo.project.metadata.initialRenderer, + 'Comfy.TutorialCompleted': true, 'Comfy.Workflow.Persist': false, + 'Comfy.Notification.ShowVersionUpdates': false } }); + } + if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && !path.startsWith('/prompt_manager/')) return route.fulfill({ json: {} }); + return route.continue(); + }); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 }); + await page.waitForFunction(async expectedTypes => { + const { app } = await import('/scripts/app.js'); + return !!app.graph && expectedTypes.every(name => globalThis.LiteGraph?.registered_node_types?.[name]); + }, expectedTypes); + await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer); + await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); app.graph.clear(); }); + }); + + test.afterEach(async ({ page }) => { + expect(page.hostErrors).toEqual([]); + }); + +} diff --git a/tests/comfyui/prompt-manager.spec.mjs b/tests/comfyui/prompt-manager.spec.mjs index 3363131..07ae1ce 100644 --- a/tests/comfyui/prompt-manager.spec.mjs +++ b/tests/comfyui/prompt-manager.spec.mjs @@ -1,43 +1,7 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; -import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' }; +import { installHostHooks } from './host-fixture.mjs'; -test.beforeEach(async ({ page, context, request }, testInfo) => { - const response = await request.get('/system_stats'); - expect(response.ok()).toBe(true); - const info = (await response.json()).system; - expect(info.comfyui_version).toBe(runtime.serverVersion); - expect(info.pytorch_version).toBe(`${runtime.torchVersion}+cpu`); - expect(info.required_frontend_version).toBe(runtime.frontendVersion); - expect(info.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe(runtime.frontendVersion); - await testInfo.attach('host-versions.json', { body: JSON.stringify(info, null, 2), contentType: 'application/json' }); - page.hostErrors = []; - page.on('pageerror', error => page.hostErrors.push(error.message)); - await context.route('**/*', async route => { - const request = route.request(); - const path = new URL(request.url()).pathname.replace(/^\/api\//, '/'); - if (path === '/settings' && request.method() === 'GET') { - const response = await route.fetch(); - return route.fulfill({ json: { ...await response.json(), - 'Comfy.VueNodes.Enabled': testInfo.project.metadata.initialRenderer, - 'Comfy.TutorialCompleted': true, 'Comfy.Workflow.Persist': false, - 'Comfy.Notification.ShowVersionUpdates': false } }); - } - if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && !path.startsWith('/prompt_manager/')) return route.fulfill({ json: {} }); - return route.continue(); - }); - await page.goto('/', { waitUntil: 'domcontentloaded' }); - await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 }); - await page.waitForFunction(async () => { - const { app } = await import('/scripts/app.js'); - return !!app.graph && ['PromptManager', 'PromptManagerText', 'PromptSearchList'].every(name => globalThis.LiteGraph?.registered_node_types?.[name]); - }); - await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer); - await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); app.graph.clear(); }); -}); - -test.afterEach(async ({ page }) => { - expect(page.hostErrors).toEqual([]); -}); +installHostHooks(test); for (const type of ['PromptManager', 'PromptManagerText']) { test(`${type} selects real database prompts and preserves text across renderers and reload`, async ({ page, request }, testInfo) => { diff --git a/tests/js/comfy-metadata.test.js b/tests/js/comfy-metadata.test.js index b634002..c895fbc 100644 --- a/tests/js/comfy-metadata.test.js +++ b/tests/js/comfy-metadata.test.js @@ -327,3 +327,37 @@ test("missing or empty metadata yields placeholders", () => { assert.equal(r.checkpoint, "Unknown"); assert.equal(extractGenerationParams(null).seed, "Unknown"); }); + + +test("Focused 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: "PromptManagerFocused", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: "8k" } }, + 3: { class_type: "PromptManagerFocused", 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("Focused 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: "PromptManagerFocused", widgets_values: ["a fox", "animals", "fox", "ignored search", "stale widget", "8k"], inputs: [{ name: "prepend_text", link: 4 }] }, + { id: 3, type: "PromptManagerFocused", 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"); +}); diff --git a/tests/test_focused_node.py b/tests/test_focused_node.py new file mode 100644 index 0000000..5e9a2ef --- /dev/null +++ b/tests/test_focused_node.py @@ -0,0 +1,61 @@ +"""Focused shares encoding and tracking while exposing composition sockets.""" + +import logging +import unittest +from unittest import mock + +from prompt_manager import PromptManager, PromptManagerFocused +from utils.prompt_graph import positive_prompt_nodes, run_prompt_nodes + + +class TestFocusedNode(unittest.TestCase): + def test_focused_keeps_the_execution_contract_and_visible_string_inputs(self): + self.assertTrue(issubclass(PromptManagerFocused, PromptManager)) + self.assertEqual(PromptManagerFocused.RETURN_TYPES, PromptManager.RETURN_TYPES) + self.assertEqual(PromptManagerFocused.FUNCTION, PromptManager.FUNCTION) + focused = PromptManagerFocused.INPUT_TYPES() + for name in ("prepend_text", "append_text"): + self.assertTrue(focused["optional"][name][1]["forceInput"]) + self.assertNotIn( + "forceInput", PromptManager.INPUT_TYPES()["optional"][name][1] + ) + self.assertEqual(focused["hidden"]["unique_id"], "UNIQUE_ID") + + def test_focused_encodes_composition_and_tracks_main_text_and_graph(self): + node = PromptManagerFocused.__new__(PromptManagerFocused) + node.logger = logging.getLogger("test.focused") + node.comfyui_integration = mock.Mock() + node._inject_lora_trigger_words = lambda text: text + node._track_prompt_execution = mock.Mock(return_value=42) + clip = mock.Mock() + graph = {"1": {"class_type": "PromptManagerFocused", "inputs": {"text": "cat"}}} + conditioning, text = node.encode_prompt( + clip, + "cat", + 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["prompt_graph"], graph) + + def test_focused_is_traced_as_positive_and_not_as_the_negative_run(self): + graph = { + "1": {"class_type": "PromptManagerFocused", "inputs": {"text": "cat"}}, + "2": {"class_type": "PromptManagerFocused", "inputs": {"text": "blur"}}, + "3": { + "class_type": "KSampler", + "inputs": { + "model": ["4", 0], + "positive": ["1", 0], + "negative": ["2", 0], + }, + }, + } + self.assertEqual(positive_prompt_nodes(graph), ["1"]) + self.assertEqual(run_prompt_nodes(graph), ["1"]) diff --git a/tests/test_init_package.py b/tests/test_init_package.py index b10870a..4360b8d 100644 --- a/tests/test_init_package.py +++ b/tests/test_init_package.py @@ -135,7 +135,12 @@ class TestPackageWiring(PackageTestCase): def test_nodes_are_registered(self): self.assertEqual( sorted(self.package.NODE_CLASS_MAPPINGS), - ["PromptManager", "PromptManagerText", "PromptSearchList"], + [ + "PromptManager", + "PromptManagerFocused", + "PromptManagerText", + "PromptSearchList", + ], ) def test_db_factory_returns_the_shared_database(self): diff --git a/tests/test_prompt_graph.py b/tests/test_prompt_graph.py index 730e72d..9b910ea 100644 --- a/tests/test_prompt_graph.py +++ b/tests/test_prompt_graph.py @@ -54,6 +54,19 @@ class TestPositivePromptNodes(unittest.TestCase): } self.assertEqual(positive_prompt_nodes(graph), ["134"]) + def test_focused_nodes_preserve_positive_roles_and_samplerless_fallback(self): + graph = { + "1": {"class_type": "PromptManagerFocused", "inputs": {"text": "cat"}}, + "2": {"class_type": "PromptManagerFocused", "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"), diff --git a/tests/test_usage_tracking.py b/tests/test_usage_tracking.py index 152ca91..3caf60d 100644 --- a/tests/test_usage_tracking.py +++ b/tests/test_usage_tracking.py @@ -126,6 +126,16 @@ class TestQueueHook(DbTestCase): self.assertEqual(self._runs(positive), 1) self.assertEqual(self._runs(negative), 0) + def test_focused_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"] = "PromptManagerFocused" + 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()) diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index b767fa1..617b6f4 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -112,5 +112,17 @@ "branches": 96, "functions": 98, "statements": 98 + }, + "focused-node.js": { + "lines": 95, + "branches": 73, + "functions": 87, + "statements": 95 + }, + "prompt-library.js": { + "lines": 98, + "branches": 86, + "functions": 98, + "statements": 98 } } diff --git a/utils/prompt_graph.py b/utils/prompt_graph.py index a9b853d..cf3096a 100644 --- a/utils/prompt_graph.py +++ b/utils/prompt_graph.py @@ -11,7 +11,9 @@ link-tracing rules of web/js/comfy-metadata.js. import re from typing import Any, Dict, List, Optional -PROMPT_MANAGER_TYPES = frozenset({"PromptManager", "PromptManagerText"}) +PROMPT_MANAGER_TYPES = frozenset( + {"PromptManager", "PromptManagerText", "PromptManagerFocused"} +) # Upper bound on nodes expanded per graph; queued and embedded graphs are untrusted MAX_VISITS = 500 @@ -92,7 +94,7 @@ def has_sampler(graph: Any) -> bool: def positive_prompt_nodes(graph: Any) -> List[str]: - """Ids of PromptManager/PromptManagerText nodes that feed a positive input. + """Ids of PromptManager family nodes that feed a positive input. Ordered by discovery, each id once. Malformed graphs yield an empty list. """ diff --git a/web/comfy/prompt_manager.js b/web/comfy/prompt_manager.js index cf6f972..b92acda 100644 --- a/web/comfy/prompt_manager.js +++ b/web/comfy/prompt_manager.js @@ -3,6 +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']); // ============================================================================ // Top Bar Button Integration (similar to rgthree-comfy approach) @@ -197,7 +199,7 @@ function syncPromptManagerWidgets() { const nodes = graph._nodes || graph.nodes || []; for (const node of nodes) { - if (node.type === "PromptManager" || node.type === "PromptManagerText") { + if (promptManagerTypes.has(node.type)) { if (node.widgets && node.widgets.length > 0) { if (!node.widgets_values) { node.widgets_values = []; @@ -257,7 +259,7 @@ app.registerExtension({ if (graph && output) { const nodes = graph._nodes || graph.nodes || []; for (const node of nodes) { - if ((node.type === "PromptManager" || node.type === "PromptManagerText") && output[node.id]) { + if (promptManagerTypes.has(node.type) && output[node.id]) { const outputNode = output[node.id]; // Skip if text input is connected to another node (value is a link reference array) @@ -290,6 +292,10 @@ app.registerExtension({ }, async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === 'PromptManagerFocused') { + installFocusedNode(nodeType, app, api, parseSearchQuery); + return; + } if (nodeData.name === "PromptManager" || nodeData.name === "PromptManagerText") { // Store original methods const onNodeCreated = nodeType.prototype.onNodeCreated; diff --git a/web/js/comfy-metadata.js b/web/js/comfy-metadata.js index 6c748a0..04945f9 100644 --- a/web/js/comfy-metadata.js +++ b/web/js/comfy-metadata.js @@ -235,6 +235,7 @@ CLIPTextEncode: ["text"], PromptManager: PROMPT_MANAGER_WIDGETS, PromptManagerText: PROMPT_MANAGER_WIDGETS, + PromptManagerFocused: PROMPT_MANAGER_WIDGETS, CheckpointLoader: ["config_name", "ckpt_name"], UNETLoader: ["unet_name", "weight_dtype"], }; diff --git a/web/js/focused-node.js b/web/js/focused-node.js new file mode 100644 index 0000000..13c1262 --- /dev/null +++ b/web/js/focused-node.js @@ -0,0 +1,150 @@ +import {createScope, createButton, createField, mountTheme} from './shared/toolkit/core.js'; +import {createPromptLibrary} from './prompt-library.js'; + +const names = ['text', 'category', 'tags', 'search_text', 'prepend_text', 'append_text']; +let nextEditorId = 0; + +function loadStyles() { + const href = new URL('./prompt-editor.css', import.meta.url).href; + if ([...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === href)) return; + const link = document.createElement('link'); Object.assign(link, {rel:'stylesheet', href}); document.head.append(link); +} + +export function installFocusedNode(nodeType, app, api, parseQuery) { + const prototype = nodeType.prototype; + const created = prototype.onNodeCreated; + const removed = prototype.onRemoved; + const configure = prototype.configure; + const serialize = prototype.serialize; + const connections = prototype.onConnectionsChange; + prototype.onNodeCreated = function (...args) { + created?.apply(this, args); + const scope = createScope(); this._focusedScope = scope; + const editorId = `pm-focused-${++nextEditorId}`; + this.properties ||= {}; + const widgets = new Map(); + for (const name of names) { + let widget = this.widgets?.find(item => item.name === name); + if (!widget) widget = this.addWidget('text', name, '', () => {}, {}); + 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'); + 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); + let webDisplayMode = 'popup'; + this._focusedSettingsReady = fetch(api.fileURL('/prompt_manager/settings'),{signal:scope.signal}) + .then(response => response.ok ? response.json() : null) + .then(data => { + const mode = data?.settings?.webui_display_mode; + if (!scope.signal.aborted && data?.success && ['popup','newtab'].includes(mode)) webDisplayMode = mode; + }).catch(() => {}); + const textWidget = widgets.get('text'); + const nativeInput = textWidget.inputEl; + const inputDescriptor = Object.getOwnPropertyDescriptor(textWidget,'inputEl'); + const main = document.createElement('textarea'); main.rows = 6; main.spellcheck = nativeInput?.spellcheck ?? false; + // ComfyUI owns widget.element; inputEl exposes our visible editor to extensions. + Object.defineProperty(textWidget,'inputEl',{configurable:true,writable:true,value:main}); + scope.cleanup(() => { + if (inputDescriptor) Object.defineProperty(textWidget,'inputEl',inputDescriptor); + else delete textWidget.inputEl; + }); + const mainField = createField('Main prompt', main, `${editorId}-text`); + 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(); + element.append(toolbar, 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); + scope.listen(section, 'toggle', () => { + if (!scope.signal.aborted) thisNode.properties.focusedUI = snapshot(); + }); + return section; + } + const thisNode = this; + const composition = disclosure('composition','Composition · prepend / append'); + const metadata = disclosure('metadata','Metadata · category / tags'); + for (const [section, fields] of [[composition,[['prepend_text','Prepend'],['append_text','Append']]],[metadata,[['category','Category'],['tags','Tags']]]]) { + const grid = document.createElement('div'); grid.className = 'pm-editor-fields'; + for (const [name,label] of fields) { + const input = document.createElement('input'); input.type = 'text'; input.setAttribute('aria-label',label); controls.set(name,input); + const field = createField(label,input,`${editorId}-${name}`); + const note = document.createElement('span'); note.className = 'pm-editor-connection'; note.textContent = 'Supplied by an upstream connection.'; note.hidden = true; + field.append(note); input._connectionNote = note; grid.append(field); + } + section.append(grid); + } + const composed = disclosure('preview','Composed text preview'); + const preview = document.createElement('p'); preview.className = 'pm-editor-preview'; preview.dataset.composedPreview = ''; composed.append(preview); + const footer = document.createElement('p'); footer.className = 'pm-editor-status'; footer.setAttribute('role','status'); footer.textContent = 'Main prompt is saved when this node runs.'; + function isConnected(name) { return thisNode.inputs?.some(input => input.name === name && input.link != null); } + function value(name) { return String(widgets.get(name)?.value ?? ''); } + function write(name, text) { + if (isConnected(name)) return; + const widget = widgets.get(name); widget.value = text; + if (widget.inputEl) widget.inputEl.value = text; + const index = thisNode.widgets.indexOf(widget); + if (thisNode.widgets_values) thisNode.widgets_values[index] = text; + widget.callback?.(text, app.canvas, thisNode); + thisNode.setDirtyCanvas(true,true); app.graph?.setDirtyCanvas(true,true); + } + function render() { + mainField.hidden = Boolean(isConnected('text')); connected.hidden = !isConnected('text'); + controls.forEach((input,name) => { + input.value = value(name); + if (name !== 'text') { + input.hidden = Boolean(isConnected(name)); + input._connectionNote.hidden = !isConnected(name); + } + }); + 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) => { + if (isConnected('text')) return; + write('text',prompt.text); + if (includeMetadata) { + write('category',prompt.category || ''); + write('tags',Array.isArray(prompt.tags) ? prompt.tags.join(', ') : prompt.tags || ''); + } + render(); footer.setAttribute('role','status'); footer.textContent = 'Loaded prompt. Saved when this node runs.'; main.focus(); + }}); + element.append(library.element,footer); + function snapshot() { + return {libraryOpen:!library.element.hidden,...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(); + } + 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; + 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(); + }); + scope.listen(web,'click',() => { + const url = api.fileURL('/prompt_manager/web'); + const opened = webDisplayMode === 'newtab' ? window.open(url,'_blank') : window.open(url,'_blank','width=1200,height=800,scrollbars=yes,resizable=yes'); + footer.setAttribute('role',opened ? 'status' : 'alert'); + footer.textContent = opened ? 'Web UI opened.' : 'Allow popups to open Web UI, then try again.'; + }); + 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(); + }; + 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(); + return serialize?.apply(this,args); + }; + prototype.onConnectionsChange = function (...args) { const result = connections?.apply(this,args); this._focusedView?.render(); return result; }; + prototype.onRemoved = function (...args) { this._focusedScope?.dispose(); return removed?.apply(this,args); }; +} diff --git a/web/js/prompt-editor.css b/web/js/prompt-editor.css new file mode 100644 index 0000000..befa9c4 --- /dev/null +++ b/web/js/prompt-editor.css @@ -0,0 +1,27 @@ +.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 [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-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; } +.pm-editor-fields { display: grid; grid-template-columns: minmax(0,1fr); gap: 10px; padding-block: 10px; } +.pm-editor-fields .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 5px; text-align: start; } +.pm-editor-fields input { inline-size: 100%; } +.pm-editor-preview { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 10px; border-radius: 5px; background: var(--ca-field); } +.pm-editor-status, .pm-editor-connection { color: var(--ca-muted); font-size: 12px; margin: 0; overflow-wrap: anywhere; } +.pm-editor-error { color: var(--ca-error-text); background: var(--ca-error-bg); padding: 8px; border-radius: 5px; } +.pm-editor-library { display: grid; gap: 10px; border-block-start: 1px solid var(--ca-line); padding-block-start: 12px; } +.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-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-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)); } } diff --git a/web/js/prompt-library.js b/web/js/prompt-library.js new file mode 100644 index 0000000..cdcbeda --- /dev/null +++ b/web/js/prompt-library.js @@ -0,0 +1,79 @@ +import {createScope, createButton, createSwitch} from './shared/toolkit/core.js'; +let nextLibraryId = 0; + +export function createPromptLibrary({scope, api, parseQuery, onUse, isConnected, onQuery}) { + 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 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 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 use = createButton('Use prompt', 'ca-primary'); use.disabled = true; + actions.append(includeMetadata, use); preview.append(previewText, actions); + element.append(heading, queryRow, 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(); } + 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); + status.classList.remove('pm-editor-error'); status.setAttribute('role', 'status'); + if (!recentOnly && !text && !tags.length) { status.textContent = 'Enter search text or tag:name.'; 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(',')); + } + status.textContent = 'Loading prompts…'; + try { + const response = await fetch(api.fileURL(`/prompt_manager/${recentOnly ? 'recent' : 'search'}?${params}`), {signal:request.signal}); + if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); + const data = await response.json(); + if (scope.signal.aborted || current !== sequence) return; + if (!data.success) throw new Error(data.error || 'Could not load prompts'); + const found = data.results || []; + status.textContent = found.length ? `${found.length} ${found.length === 1 ? 'prompt' : 'prompts'}` : 'No matching prompts.'; + found.forEach(item => { + const button = createButton('', 'pm-editor-result'); button.setAttribute('aria-pressed','false'); + const text = document.createElement('span'); text.textContent = item.text; + const metadata = document.createElement('span'); metadata.className = 'pm-editor-result-meta'; + metadata.textContent = [item.category, Array.isArray(item.tags) ? item.tags.join(', ') : item.tags, item.rating ? `${item.rating}/5` : ''].filter(Boolean).join(' · '); + button.append(text, metadata); + rows.listen(button, 'click', () => { + selected = item; previewText.textContent = item.text; preview.hidden = false; + results.querySelectorAll('button').forEach(row => row.setAttribute('aria-pressed', String(row === button))); + status.textContent = 'Previewing selection. Editor unchanged.'; refreshAvailability(); + }); + results.append(button); + }); + } catch (error) { + if (scope.signal.aborted || current !== sequence || request.signal.aborted) return; + status.classList.add('pm-editor-error'); status.setAttribute('role','alert'); status.textContent = `Could not load prompts: ${error.message}`; + } + } + 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(use, 'click', () => { + if (!selected || isConnected()) return; + onUse(selected, includeMetadata.querySelector('input').checked); + }); + return {element, focus:() => query.focus(), refreshAvailability, setQuery(value) { query.value = value || ''; }}; +}