From bd2556f32f34004da14727542992df02e33f3c88 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sun, 25 Oct 2026 13:24:19 -0700 Subject: [PATCH] feat(nodes): let a link drive the prompt text of every PromptManager node The Focused, Split and Pair editors hide their widgets, and ComfyUI only draws a widget's input socket while the widget is visible. `text` (and the Pair's `negative_text`) therefore had no socket, and a STRING link dropped on the editor fell through to the first free STRING input, `prepend_text`, which is how #342's workflow was wired. - Detach those inputs from their hidden widgets on create and after a workflow load, so they are ordinary sockets in Classic and Nodes 2.0. The schema, widget order and saved values are unchanged. - A STRING link dropped on the editor body fills the main prompt, then the negative, before the frontend's own choice. - Each editor says when its text comes from a link; the typed text is kept for disconnect. The Classic nodes now save the typed text under a link instead of null, and "use prompt" no longer overwrites it while linked. - Real-host tests: mouse drags in both renderers, a workflow saved before this change, and executions proving the linked text is saved, counted, recorded in history and linked to the run's image. Closes #343 --- TESTING.md | 39 + tests/browser/classic-node.spec.mjs | 46 + tests/browser/focused-node.spec.mjs | 33 +- tests/browser/pair-node.spec.mjs | 26 +- tests/comfyui/execution-text-link.spec.mjs | 159 +++ .../comfyui/fixtures/workflow-before-343.json | 1241 +++++++++++++++++ tests/comfyui/text-link.spec.mjs | 183 +++ tests/js/text-links.test.js | 91 ++ tests/vendor/comfyui-config.test.mjs | 6 +- tools/vendor/comfyui.config.mjs | 2 +- tools/vendor/playwright.execution.config.mjs | 2 +- web/comfy/prompt_manager.js | 52 +- web/js/classic-node.css | 2 + web/js/classic-node.js | 3 + web/js/focused-node.js | 14 +- web/js/text-links.js | 62 + 16 files changed, 1936 insertions(+), 25 deletions(-) create mode 100644 tests/comfyui/execution-text-link.spec.mjs create mode 100644 tests/comfyui/fixtures/workflow-before-343.json create mode 100644 tests/comfyui/text-link.spec.mjs create mode 100644 tests/js/text-links.test.js create mode 100644 web/js/text-links.js diff --git a/TESTING.md b/TESTING.md index 898cba82..f8c5be48 100644 --- a/TESTING.md +++ b/TESTING.md @@ -3856,6 +3856,45 @@ Viewer, including nested modal focus, mobile layouts and per-role sending. real Classic and Nodes 2.0 widgets and verifies workflow serialization without loading a workflow or queueing generation. +## Linked prompt text (#343) + +A link drives `text` on every PromptManager node, and `negative_text` on the Pair, +as it drives any core widget. The Focused, Split and Pair editors hide their widgets, +and the frontend only draws a widget's socket while the widget is visible, so +`web/js/text-links.js` detaches those two inputs from their hidden widgets. They become +ordinary sockets; the schema, widget order and saved `widgets_values` do not change. A +STRING link dropped on the editor body fills the main prompt first, then the negative, +before the frontend's own choice (it used to pick `prepend_text`, which caused #342). +While linked, each editor says the value comes from a link, and the typed text is kept +in the workflow for when the link is disconnected. That includes the Classic nodes, +which used to save `null` under a link. + +- `tests/js/text-links.test.js`: socket exposure, drop preference, stored values. +- `tests/browser/{focused,pair,classic}-node.spec.mjs`: the linked note, typed text + kept through save and configure (also with a workflow's widget binding restored), the + Classic "use prompt" guard while linked. +- `tests/comfyui/text-link.spec.mjs` (both startup projects): a real mouse drag of a + STRING output onto each node's prompt box (and onto the Pair's negative socket); the + link, the note and the typed text survive renderer switches both ways, a reload, + disconnect and node removal. `fixtures/workflow-before-343.json` was serialized by + the pre-#343 code on the pinned host and must load unchanged. +- `tests/comfyui/execution-text-link.spec.mjs` (execution host): workflows built by the + real frontend through the sockets, with text from `PrimitiveString` or through + `PreviewAny`, for PromptManager, PromptManagerText, Focused and the Pair (positive, + negative, both, neither linked). The linked text is saved, counted once, recorded as + the run's raw positive/negative, and after a scan the run's image links to it. Text + only known at run time (`PreviewAny`) cannot be read from the image; the case only + requires that it is never linked to another prompt (its attribution from the recorded + run belongs to #342). + +```sh +node --test tests/js/text-links.test.js +npm run --prefix tools/vendor test:workflows -- classic-node.spec.mjs focused-node.spec.mjs pair-node.spec.mjs \ + --project chromium --project touch --project firefox --project webkit +PM_COMFY_ROOT=... PM_TEST_PYTHON=... npm run --prefix tools/vendor test:comfyui -- text-link +PM_COMFY_ROOT=... PM_TEST_PYTHON=... npm run --prefix tools/vendor test:executions -- execution-text-link +``` + ## Durable execution ledger `npm run --prefix tools/vendor test:executions` runs 46 real execution cases diff --git a/tests/browser/classic-node.spec.mjs b/tests/browser/classic-node.spec.mjs index f4160623..bd627e22 100644 --- a/tests/browser/classic-node.spec.mjs +++ b/tests/browser/classic-node.spec.mjs @@ -149,3 +149,49 @@ test('Classic test button reports success and failure and releases expired notif await page.clock.fastForward(3001); await expect(page.locator('.pm-classic-notification')).toHaveCount(0); }); + +test('Classic shows linked text, keeps the typed text through save and refuses to overwrite it (#343)',async({page})=>{ + await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{text:'Saved prompt',tags:[]}]}})); + const note=page.locator('.pm-classic').getByText('Text comes from a link. Your typed text is kept for when you disconnect it.',{exact:true}); + await expect(note).toBeHidden(); + await page.evaluate(()=>{ + const widget=node.widgets.find(item=>item.name==='text');widget.value=widget.inputEl.value='Typed classic'; + node.inputs=[{name:'text',type:'STRING',link:3,widget:{name:'text'}}];node.onConnectionsChange(); + }); + await expect(note).toBeVisible(); + // The link drives the run; the saved workflow keeps what the user typed, not a stale or empty value. + expect(await page.evaluate(()=>node.serialize().widgets_values[0])).toBe('Typed classic'); + await page.getByRole('button',{name:'📋 Recent',exact:true}).click(); + await page.getByRole('button',{name:/Saved prompt/}).click(); + expect(await page.evaluate(()=>node.widgets.find(item=>item.name==='text').value)).toBe('Typed classic'); + await expect(page.getByRole('alert').filter({hasText:'Text comes from a link. Disconnect it to use a saved prompt.'})).toBeVisible(); + await page.evaluate(()=>{node.inputs[0].link=null;node.onConnectionsChange();}); + await expect(note).toBeHidden(); + await page.getByRole('button',{name:/Saved prompt/}).click(); + expect(await page.evaluate(()=>node.widgets.find(item=>item.name==='text').value)).toBe('Saved prompt'); + // A workflow load re-evaluates the link state. + await page.evaluate(()=>{node.inputs[0].link=8;node.configure({});}); + await expect(note).toBeVisible(); +}); + +test('PromptManagerText shows linked text in its legacy controls and keeps the typed text (#343)',async({page})=>{ + await page.evaluate(async()=>{ + class TextNode { + constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,value:'',inputEl:document.createElement('textarea')}));this.widgets_values=[];this.inputs=[{name:'text',type:'STRING',link:null,widget:{name:'text'}}];} + addDOMWidget(name,type,element){element.id='legacy-text-node';document.body.append(element);return {element};} + setSize(){} setDirtyCanvas(){} serialize(){return {properties:{...this.properties}};} + } + await fixtureApp.extensions[0].beforeRegisterNodeDef(TextNode,{name:'PromptManagerText'},fixtureApp); + window.textNode=new TextNode();textNode.onNodeCreated(); + const widget=textNode.widgets[0];widget.value=widget.inputEl.value='Typed text node'; + }); + const note=page.locator('#legacy-text-node').getByText('Text comes from a link. Your typed text is kept for when you disconnect it.',{exact:true}); + await expect(note).toBeHidden(); + await page.evaluate(()=>{textNode.inputs[0].link=5;textNode.onConnectionsChange();}); + await expect(note).toBeVisible(); + expect(await page.evaluate(()=>textNode.serialize().widgets_values[0])).toBe('Typed text node'); + await page.evaluate(()=>{textNode.usePrompt({text:'Replacement'},0);}); + expect(await page.evaluate(()=>textNode.widgets[0].value)).toBe('Typed text node'); + await page.evaluate(()=>{textNode.inputs[0].link=null;textNode.onConnectionsChange();textNode.onRemoved();}); + await expect(note).toBeHidden(); +}); diff --git a/tests/browser/focused-node.spec.mjs b/tests/browser/focused-node.spec.mjs index 6f9eb316..4f2026bb 100644 --- a/tests/browser/focused-node.spec.mjs +++ b/tests/browser/focused-node.spec.mjs @@ -26,7 +26,8 @@ test.beforeEach(async({page,browserName})=>{ 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];} + 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,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}}))];this.properties={};this.size=[550,700];} + findInputByType(type){const index=this.inputs.findIndex(input=>input.type===type&&input.link==null);return index<0?undefined:{index,slot:this.inputs[index]};} addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;} 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(){} @@ -246,13 +247,39 @@ test(`${variant} restores workflow values and disclosures and protects connected 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 page.evaluate(()=>{node.inputs.find(input=>input.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 expect(page.getByText('Main prompt comes from a link. Your typed text is kept for when you disconnect it.',{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(`${variant} offers text as a link socket, keeps typed text while linked and through a reload (#343)`,async({page})=>{ + const sockets=()=>page.evaluate(()=>Object.fromEntries(node.inputs.map(input=>[input.name,input.widget?.name??null]))); + // The hidden text widget no longer owns the input, so the host draws it as a socket; the other widget inputs are unchanged. + expect(await sockets()).toEqual({clip:null,prepend_text:null,append_text:null,text:null,category:'category',tags:'tags',search_text:'search_text'}); + expect(await page.evaluate(()=>node.findInputByType('STRING').slot.name)).toBe('text'); + expect(await page.evaluate(()=>node.findInputByType('CLIP').slot.name)).toBe('clip'); + const main=page.getByRole('textbox',{name:'Main prompt',exact:true}); + await main.fill('Typed main'); + await page.evaluate(()=>{node.inputs.find(input=>input.name==='text').link=21;node.onConnectionsChange();}); + await expect(main).toBeHidden(); + const note=page.getByText('Main prompt comes from a link. Your typed text is kept for when you disconnect it.',{exact:true}); + await expect(note).toBeVisible(); + await expect(page.locator('[data-composed-preview]')).toHaveText('[text supplied by connection]'); + // A second STRING link dropped on the editor falls back to the frontend's own choice. + expect(await page.evaluate(()=>node.findInputByType('STRING').slot.name)).toBe('prepend_text'); + // An older saved workflow brings the widget binding back with its inputs: configure exposes the socket again. + const saved=await page.evaluate(()=>{const saved=node.serialize();node.inputs.find(input=>input.name==='text').widget={name:'text'};node.configure(saved);return saved;}); + expect(saved.widgets_values[0]).toBe('Typed main'); + expect((await sockets()).text).toBeNull(); + await expect(note).toBeVisible(); + await page.evaluate(()=>{node.inputs.find(input=>input.name==='text').link=null;node.onConnectionsChange();}); + await expect(main).toBeVisible(); + await expect(main).toHaveValue('Typed main'); + await expect(note).toBeHidden(); +}); + test(`${variant} searches quoted tags with Enter and explicitly loads metadata`, async ({page}) => { let requested; await page.route('**/prompt_manager/search?**', async route => { diff --git a/tests/browser/pair-node.spec.mjs b/tests/browser/pair-node.spec.mjs index 1d0e4bd8..f35dd594 100644 --- a/tests/browser/pair-node.spec.mjs +++ b/tests/browser/pair-node.spec.mjs @@ -17,7 +17,8 @@ test.beforeEach(async({page,browserName})=>{ window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings},graph:{_nodes:[],setDirtyCanvas(){}},canvas:{},graphToPrompt:async()=>({})}; await import('/extensions/promptmanager/prompt_manager.js'); class Node { - constructor(){this.id=1;this.type='PromptManagerPair';this.widgets=['text','negative_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=[1100,700];} + constructor(){this.id=1;this.type='PromptManagerPair';this.widgets=['text','negative_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,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','negative_text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}}))];this.properties={};this.size=[1100,700];} + findInputByType(type){const index=this.inputs.findIndex(input=>input.type===type&&input.link==null);return index<0?undefined:{index,slot:this.inputs[index]};} addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;} addDOMWidget(name,type,element){const host=document.createElement('div');host.style.cssText='height:700px;width:1100px;max-width:100%';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};} setSize(size){this.size=size;}setDirtyCanvas(){} @@ -123,6 +124,29 @@ test('a superseded library response never replaces the latest pair selection',as await expect(page.locator('.pm-editor-result')).not.toContainText('Stale positive'); }); +test('paired editor offers positive and negative link sockets and keeps both typed texts (#343)',async({page})=>{ + await draft(page,'Typed positive','Typed negative'); + expect(await page.evaluate(()=>node.inputs.filter(input=>!input.widget).map(input=>input.name))).toEqual(['clip','prepend_text','append_text','text','negative_text']); + const target=()=>page.evaluate(()=>node.findInputByType('STRING').slot.name); + expect(await target()).toBe('text'); + const positive=page.getByRole('textbox',{name:'Positive prompt',exact:true}),negative=page.getByRole('textbox',{name:'Negative prompt',exact:true}); + const positiveNote=page.getByText('Positive prompt comes from a link. Your typed text is kept for when you disconnect it.',{exact:true}); + const negativeNote=page.getByText('Negative prompt comes from a link. Your typed text is kept for when you disconnect it.',{exact:true}); + const link=(name,value)=>page.evaluate(({name,value})=>{node.inputs.find(input=>input.name===name).link=value;node.onConnectionsChange();},{name,value}); + for(const [linked,next] of [[['text'],'negative_text'],[['negative_text'],'text'],[['text','negative_text'],'prepend_text'],[[],'text']]){ + for(const name of ['text','negative_text'])await link(name,linked.includes(name)?30:null); + expect(await target()).toBe(next); + await expect(positive).toBeVisible({visible:!linked.includes('text')}); + await expect(negative).toBeVisible({visible:!linked.includes('negative_text')}); + await expect(positiveNote).toBeVisible({visible:linked.includes('text')}); + await expect(negativeNote).toBeVisible({visible:linked.includes('negative_text')}); + const saved=await page.evaluate(()=>{const saved=node.serialize();for(const input of node.inputs)if(['text','negative_text'].includes(input.name))input.widget={name:input.name};node.configure(saved);return saved.widgets_values.slice(0,2);}); + expect(saved).toEqual(['Typed positive','Typed negative']); + expect(await page.evaluate(()=>node.inputs.filter(input=>input.widget&&['text','negative_text'].includes(input.name)).length)).toBe(0); + } + await expect(positive).toHaveValue('Typed positive');await expect(negative).toHaveValue('Typed negative'); +}); + test('queue hook synchronizes both editors, preserves links and forwards branch execution arguments',async({page})=>{ await draft(page); const args=await page.evaluate(async()=>{ diff --git a/tests/comfyui/execution-text-link.spec.mjs b/tests/comfyui/execution-text-link.spec.mjs new file mode 100644 index 00000000..9560eb98 --- /dev/null +++ b/tests/comfyui/execution-text-link.spec.mjs @@ -0,0 +1,159 @@ +// #343: a link drives a PromptManager node's text (and the Pair's negative) on the real pinned host. +// The workflow is built by the real frontend through the nodes' sockets and queued as the frontend serializes it; +// only inference is replaced (fixtures/ledger_nodes.py). The linked text must be saved, counted, recorded in the +// execution history, and the run's image must link to it. +import { execFile } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { promisify } from 'node:util'; +import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; +import { test, readExecutionRuns } from './execution-fixture.mjs'; +import { installHostHooks } from './host-fixture.mjs'; + +// The worker's owned host must be up before the shared host hooks open the page. +test.beforeEach(async ({ execution }) => { expect(execution.databasePath).toBeTruthy(); }); +installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerPair', 'PMHistoryInputs']); + +// `primitive`: a core string node the queue-time resolver follows. `preview`: PrimitiveString -> PreviewAny, the +// shape of the maintainer's TextGenerate -> PreviewAny chain, whose text is only known when the node runs. +function build(page, { kind, positive, negative, prefix }) { + return page.evaluate(async ({ kind, positive, negative, prefix }) => { + const { app } = await import('/scripts/app.js'); + app.graph.clear(); + const add = (type, x, y) => { const node = LiteGraph.createNode(type); app.graph.add(node); node.pos = [x, y]; return node; }; + const slot = (node, name) => { const index = node.inputs.findIndex(input => input.name === name); if (index < 0) throw new Error(`${node.type} has no ${name} socket`); return index; }; + const connect = (from, output, to, name) => { if (!from.connect(output, to, slot(to, name))) throw new Error(`${from.type} could not link to ${to.type}.${name}`); }; + const inputs = add('PMHistoryInputs', 0, 0); + const prompt = add(kind, 700, 0); + const set = (name, value) => { const widget = prompt.widgets.find(item => item.name === name); widget.value = value; if (widget.inputEl) widget.inputEl.value = value; }; + set('text', positive.typed); + if (negative) set('negative_text', negative.typed); + const drive = (name, side, y) => { + if (!side.via) return; + const text = add('PrimitiveString', 0, y); + text.widgets.find(widget => widget.name === 'value').value = side.text; + let source = text; + if (side.via === 'preview') { source = add('PreviewAny', 350, y); connect(text, 0, source, 'source'); } + connect(source, 0, prompt, name); + }; + drive('text', positive, 300); + if (negative) drive('negative_text', negative, 500); + if (kind !== 'PromptManagerText') connect(inputs, 0, prompt, 'clip'); + const sampler = add('KSampler', 1400, 0); + connect(inputs, 1, sampler, 'model'); + connect(inputs, 2, sampler, 'latent_image'); + if (kind === 'PromptManagerText') { + const encode = add('CLIPTextEncode', 1100, 0); + encode.convertWidgetToInput?.(encode.widgets.find(widget => widget.name === 'text')); + connect(inputs, 0, encode, 'clip'); + connect(prompt, 0, encode, 'text'); + connect(encode, 0, sampler, 'positive'); + } else connect(prompt, 0, sampler, 'positive'); + if (kind === 'PromptManagerPair') connect(prompt, 1, sampler, 'negative'); + else { + const empty = add('CLIPTextEncode', 1100, 300); + connect(inputs, 0, empty, 'clip'); + connect(empty, 0, sampler, 'negative'); + } + const images = add('PMHistoryImages', 1800, 0); + images.widgets.find(widget => widget.name === 'value').value = 99; + connect(sampler, 0, images, 'latent'); + const save = add('SaveImage', 2100, 0); + save.widgets.find(widget => widget.name === 'filename_prefix').value = prefix; + connect(images, 0, save, 'images'); + const { output, workflow } = await app.graphToPrompt(); + return { output, workflow, promptNode: String(prompt.id) }; + }, { kind, positive, negative, prefix }); +} + +async function run(execution, page, testCase) { + const prefix = `textlink/${randomUUID()}`; + const { output, workflow, promptNode } = await build(page, { ...testCase, prefix }); + const id = randomUUID(); + const response = await execution.request.post('/prompt', { data: { prompt: output, prompt_id: id, extra_data: { extra_pnginfo: { workflow } } } }); + expect(response.ok(), await response.text()).toBe(true); + let item; + await expect.poll(async () => { + item = (await (await execution.request.get(`/history/${id}`)).json())[id]; + return item?.status?.status_str ?? null; + }, { timeout: 20000 }).toBe('success'); + let result; + await expect.poll(async () => { + result = await readExecutionRuns(execution.databasePath, id); + const run = result.rows[0]; + return run ? result.captures[run.run_id].state : null; + }, { timeout: 10000, message: 'the run and its prompt context reach SQLite' }).toBe('complete'); + const run = result.rows[0]; + const files = Object.values(item.outputs).flatMap(value => value.images || []).map(image => image.filename); + return { output, promptNode, context: result.captures[run.run_id].contexts[0], usage: result.usage[run.run_id], files }; +} + +async function scan(execution) { + const response = await execution.request.post('/prompt_manager/scan', { timeout: 60000 }); + expect(response.ok()).toBe(true); + const events = (await response.text()).split('\n').filter(line => line.startsWith('data: ')).map(line => JSON.parse(line.slice(6))); + expect(events.at(-1)?.type, JSON.stringify(events.at(-1))).toBe('complete'); +} + +// filename -> linked prompt id (null when the image is not linked), read-only from the owned host's library. +const LINKS = `import json, sqlite3, sys, pathlib +db = sqlite3.connect(pathlib.Path(sys.argv[1]).resolve().as_uri() + "?mode=ro", uri=True) +links = {name: None for name in sys.argv[2:]} +for name, prompt in db.execute("SELECT filename, prompt_id FROM generated_images WHERE filename IN (%s)" % ",".join("?" * (len(sys.argv) - 2)), sys.argv[2:]): + links[name] = prompt +print(json.dumps(links))`; +async function linkedPrompts(execution, files) { + const { stdout } = await promisify(execFile)(process.env.PM_TEST_PYTHON || 'python', ['-c', LINKS, execution.databasePath, ...files], { timeout: 5000 }); + return JSON.parse(stdout); +} + +// Text a link resolves from the queued graph (a primitive) links its images through their embedded metadata. +// Text only known at run time (PreviewAny) cannot be read from the image: its attribution from the recorded run is +// #342's. Here it must never be linked to another prompt. +async function expectImagesLinked(execution, evidence, viaRuntime) { + await scan(execution); + const links = await linkedPrompts(execution, evidence.files); + expect(evidence.files.length).toBeGreaterThan(0); + for (const [file, prompt] of Object.entries(links)) { + if (viaRuntime) expect([null, evidence.context.positive_prompt_id], file).toContain(prompt); + else expect(prompt, file).toBe(evidence.context.positive_prompt_id); + } +} + +const unique = label => `${label} ${randomUUID()}`; + +for (const kind of ['PromptManager', 'PromptManagerText', 'PromptManagerFocused']) { + for (const via of ['primitive', 'preview']) { + test(`${kind} saves, counts, records and links images to text linked from ${via === 'preview' ? 'PreviewAny' : 'a primitive'}`, async ({ execution, page }) => { + test.setTimeout(90000); + const text = unique(`Linked ${kind} ${via}`); + const evidence = await run(execution, page, { kind, positive: { typed: 'Typed under the link', text, via } }); + const source = evidence.output[evidence.promptNode].inputs.text; + expect(Array.isArray(source), 'the frontend queued the link, not the typed text').toBe(true); + expect(evidence.context.positive.raw).toBe(text); + expect(evidence.context.positive.effective).toBe(text); + expect(evidence.usage).toEqual([{ role: 'positive', run_count: 1 }]); + const saved = await (await execution.request.get(`/prompt_manager/library/${evidence.context.positive_prompt_id}`)).json(); + expect(saved.positive.text).toBe(text); + await expectImagesLinked(execution, evidence, via === 'preview'); + }); + } +} + +for (const [label, linked] of [['positive', ['text']], ['negative', ['negative_text']], ['both', ['text', 'negative_text']], ['neither', []]]) { + test(`PromptManagerPair with ${label} linked saves, counts and records the pair and links its images`, async ({ execution, page }) => { + test.setTimeout(90000); + const sides = { + text: { typed: unique('Typed positive'), text: unique('Linked positive'), via: linked.includes('text') ? 'preview' : null }, + negative_text: { typed: unique('Typed negative'), text: unique('Linked negative'), via: linked.includes('negative_text') ? 'primitive' : null }, + }; + const evidence = await run(execution, page, { kind: 'PromptManagerPair', positive: sides.text, negative: sides.negative_text }); + const expected = name => sides[name].via ? sides[name].text : sides[name].typed; + for (const name of ['text', 'negative_text']) expect(Array.isArray(evidence.output[evidence.promptNode].inputs[name]), name).toBe(linked.includes(name)); + expect(evidence.context.positive.raw).toBe(expected('text')); + expect(evidence.context.negative.raw).toBe(expected('negative_text')); + expect(evidence.usage).toEqual([{ role: 'positive', run_count: 1 }, { role: 'negative', run_count: 0 }, { role: 'pair', run_count: 1 }]); + const pair = await (await execution.request.get(`/prompt_manager/pairs/${evidence.context.pair_id}`)).json(); + expect(pair.pair).toMatchObject({ positive_text: expected('text'), negative_text: expected('negative_text') }); + await expectImagesLinked(execution, evidence, sides.text.via === 'preview'); + }); +} diff --git a/tests/comfyui/fixtures/workflow-before-343.json b/tests/comfyui/fixtures/workflow-before-343.json new file mode 100644 index 00000000..3d514d9f --- /dev/null +++ b/tests/comfyui/fixtures/workflow-before-343.json @@ -0,0 +1,1241 @@ +{ + "id": "882efddb-48a1-4dd5-8575-14922103c4b8", + "revision": 0, + "last_node_id": 12, + "last_link_id": 4, + "nodes": [ + { + "id": 1, + "type": "PromptManager", + "pos": [ + 400, + 100 + ], + "size": [ + 600, + 610 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "prepend_text" + }, + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "append_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "title": "PromptManager typed", + "properties": { + "Node name for S&R": "PromptManager", + "resultTimeout": 3, + "showTestButton": false, + "webuiDisplayMode": "popup" + }, + "widgets_values": [ + "Saved PromptManager text", + "Saved category", + "saved-tag", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Saved PromptManager text", + "category": "Saved category", + "tags": "saved-tag", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_search_ui": "" + } + }, + { + "id": 2, + "type": "PromptManagerText", + "pos": [ + 1600, + 100 + ], + "size": [ + 600, + 610 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "prepend_text" + }, + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "append_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "title": "PromptManagerText typed", + "properties": { + "Node name for S&R": "PromptManagerText", + "resultTimeout": 3, + "showTestButton": false, + "webuiDisplayMode": "popup" + }, + "widgets_values": [ + "Saved PromptManagerText text", + "Saved category", + "saved-tag", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Saved PromptManagerText text", + "category": "Saved category", + "tags": "saved-tag", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_search_ui": "" + } + }, + { + "id": 3, + "type": "PromptManagerFocused", + "pos": [ + 2800, + 100 + ], + "size": [ + 550, + 650 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "title": "PromptManagerFocused typed", + "properties": { + "Node name for S&R": "PromptManagerFocused", + "focusedUI": { + "libraryOpen": false, + "libraryFilters": { + "category": "", + "tags": "" + }, + "composition": false, + "metadata": false, + "preview": false + } + }, + "widgets_values": [ + "Saved PromptManagerFocused text", + "Saved category", + "saved-tag", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Saved PromptManagerFocused text", + "category": "Saved category", + "tags": "saved-tag", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_focused_ui": "" + } + }, + { + "id": 4, + "type": "PromptManagerSplit", + "pos": [ + 4000, + 100 + ], + "size": [ + 900, + 650 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "title": "PromptManagerSplit typed", + "properties": { + "Node name for S&R": "PromptManagerSplit", + "splitUI": { + "libraryOpen": true, + "libraryFilters": { + "category": "", + "tags": "" + }, + "composition": false, + "metadata": false, + "preview": false + } + }, + "widgets_values": [ + "Saved PromptManagerSplit text", + "Saved category", + "saved-tag", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Saved PromptManagerSplit text", + "category": "Saved category", + "tags": "saved-tag", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_focused_ui": "" + } + }, + { + "id": 5, + "type": "PromptManagerPair", + "pos": [ + 5200, + 100 + ], + "size": [ + 1100, + 700 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "localized_name": "negative_text", + "name": "negative_text", + "type": "STRING", + "widget": { + "name": "negative_text" + }, + "link": null + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "positive_text", + "name": "positive_text", + "type": "STRING", + "links": null + }, + { + "localized_name": "negative_text", + "name": "negative_text", + "type": "STRING", + "links": null + } + ], + "title": "PromptManagerPair typed", + "properties": { + "Node name for S&R": "PromptManagerPair", + "pairUI": { + "libraryOpen": true, + "libraryFilters": { + "category": "", + "tags": "" + }, + "composition": false, + "metadata": false, + "preview": false + } + }, + "widgets_values": [ + "Saved PromptManagerPair text", + "Saved pair negative", + "Saved category", + "saved-tag", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Saved PromptManagerPair text", + "negative_text": "Saved pair negative", + "category": "Saved category", + "tags": "saved-tag", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_focused_ui": "" + } + }, + { + "id": 6, + "type": "PromptManagerFocused", + "pos": [ + 400, + 1400 + ], + "size": [ + 550, + 650 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 1 + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "title": "Focused linked", + "properties": { + "Node name for S&R": "PromptManagerFocused", + "focusedUI": { + "libraryOpen": false, + "libraryFilters": { + "category": "", + "tags": "" + }, + "composition": false, + "metadata": false, + "preview": false + } + }, + "widgets_values": [ + "Typed under focused link", + "", + "", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Typed under focused link", + "category": "", + "tags": "", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_focused_ui": "" + } + }, + { + "id": 7, + "type": "PrimitiveString", + "pos": [ + 0, + 1400 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 1 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveString" + }, + "widgets_values": [ + "Linked focused text" + ], + "widgets_values_named": { + "value": "Linked focused text" + } + }, + { + "id": 8, + "type": "PromptManagerPair", + "pos": [ + 1600, + 1400 + ], + "size": [ + 1100, + 700 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 2 + }, + { + "localized_name": "negative_text", + "name": "negative_text", + "type": "STRING", + "widget": { + "name": "negative_text" + }, + "link": 3 + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "positive_text", + "name": "positive_text", + "type": "STRING", + "links": null + }, + { + "localized_name": "negative_text", + "name": "negative_text", + "type": "STRING", + "links": null + } + ], + "title": "Pair linked", + "properties": { + "Node name for S&R": "PromptManagerPair", + "pairUI": { + "libraryOpen": true, + "libraryFilters": { + "category": "", + "tags": "" + }, + "composition": false, + "metadata": false, + "preview": false + } + }, + "widgets_values": [ + "Typed pair positive", + "Typed pair negative", + "", + "", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Typed pair positive", + "negative_text": "Typed pair negative", + "category": "", + "tags": "", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_focused_ui": "" + } + }, + { + "id": 9, + "type": "PrimitiveString", + "pos": [ + 1200, + 1400 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 2 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveString" + }, + "widgets_values": [ + "Linked pair text" + ], + "widgets_values_named": { + "value": "Linked pair text" + } + }, + { + "id": 10, + "type": "PrimitiveString", + "pos": [ + 1200, + 1600 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 3 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveString" + }, + "widgets_values": [ + "Linked pair negative_text" + ], + "widgets_values_named": { + "value": "Linked pair negative_text" + } + }, + { + "id": 11, + "type": "PromptManager", + "pos": [ + 3000, + 1400 + ], + "size": [ + 600, + 610 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": null + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 4 + }, + { + "localized_name": "category", + "name": "category", + "shape": 7, + "type": "STRING", + "widget": { + "name": "category" + }, + "link": null + }, + { + "localized_name": "tags", + "name": "tags", + "shape": 7, + "type": "STRING", + "widget": { + "name": "tags" + }, + "link": null + }, + { + "localized_name": "search_text", + "name": "search_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "search_text" + }, + "link": null + }, + { + "localized_name": "prepend_text", + "name": "prepend_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "prepend_text" + }, + "link": null + }, + { + "localized_name": "append_text", + "name": "append_text", + "shape": 7, + "type": "STRING", + "widget": { + "name": "append_text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": null + }, + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "title": "Classic linked", + "properties": { + "Node name for S&R": "PromptManager", + "resultTimeout": 3, + "showTestButton": false, + "webuiDisplayMode": "popup" + }, + "widgets_values": [ + null, + "", + "", + "", + "", + "", + "" + ], + "widgets_values_named": { + "text": "Typed under classic link", + "category": "", + "tags": "", + "search_text": "", + "prepend_text": "", + "append_text": "", + "prompt_manager_search_ui": "" + } + }, + { + "id": 12, + "type": "PrimitiveString", + "pos": [ + 2600, + 1400 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 4 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveString" + }, + "widgets_values": [ + "Linked classic text" + ], + "widgets_values_named": { + "value": "Linked classic text" + } + } + ], + "links": [ + [ + 1, + 7, + 0, + 6, + 3, + "STRING" + ], + [ + 2, + 9, + 0, + 8, + 3, + "STRING" + ], + [ + 3, + 10, + 0, + 8, + 4, + "STRING" + ], + [ + 4, + 12, + 0, + 11, + 1, + "STRING" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.9, + "offset": [ + 416, + 110 + ] + } + }, + "version": 0.4 +} diff --git a/tests/comfyui/text-link.spec.mjs b/tests/comfyui/text-link.spec.mjs new file mode 100644 index 00000000..7bcfe2f7 --- /dev/null +++ b/tests/comfyui/text-link.spec.mjs @@ -0,0 +1,183 @@ +// #343: on the pinned real host, a STRING link dragged with the mouse drives each PromptManager node's text (and the +// Pair's negative) in Classic and Nodes 2.0, across renderer switches, a workflow reload, disconnect and removal. +import { readFile } 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', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']); + +const editors = { PromptManagerFocused: 'Main prompt', PromptManagerSplit: 'Main prompt', PromptManagerPair: 'Positive prompt' }; +const note = label => `${label} comes from a link. Your typed text is kept for when you disconnect it.`; + +/** Viewport position of a node's slot: the slot dot in Nodes 2.0, the canvas slot position in Classic. */ +function slotPosition(page, nodeId, kind, name) { + return page.evaluate(async ({ nodeId, kind, name }) => { + const { app } = await import('/scripts/app.js'); + const node = app.graph.getNodeById(nodeId); + const slots = kind === 'out' ? node.outputs : node.inputs; + const index = typeof name === 'number' ? name : slots.findIndex(slot => slot.name === name); + if (LiteGraph.vueNodesMode) { + const dot = document.querySelector(`[data-slot-key="${nodeId}-${kind}-${index}"]`); + if (!dot) return null; + const box = dot.getBoundingClientRect(); + return [box.x + box.width / 2, box.y + box.height / 2]; + } + const position = app.canvas.ds.convertOffsetToCanvas(kind === 'out' ? node.getOutputPos(index) : node.getInputPos(index)); + const box = app.canvas.canvas.getBoundingClientRect(); + return [box.x + position[0], box.y + position[1]]; + }, { nodeId, kind, name }); +} + +async function drag(page, from, to) { + await page.mouse.move(...from); + await page.mouse.down(); + await page.mouse.move((from[0] + to[0]) / 2, (from[1] + to[1]) / 2, { steps: 5 }); + await page.mouse.move(...to, { steps: 10 }); + await page.mouse.up(); +} + +const center = box => [box.x + box.width / 2, box.y + box.height / 2]; +const linkedInputs = (page, id) => page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + return app.graph.getNodeById(id).inputs.filter(input => input.link != null).map(input => input.name); +}, id); +const queued = (page, id) => page.evaluate(async id => (await (await import('/scripts/app.js')).app.graphToPrompt()).output[String(id)].inputs, id); + +async function setup(page, type) { + return page.evaluate(async type => { + const { app } = await import('/scripts/app.js'); + const node = LiteGraph.createNode(type); + app.graph.add(node); + node.pos = [500, 80]; + node.setSize([type === 'PromptManagerPair' ? 1100 : 650, 760]); + const sources = ['text', 'negative_text'].map((name, index) => { + const source = LiteGraph.createNode('PrimitiveString'); + app.graph.add(source); + source.pos = [40, 80 + index * 160]; + source.widgets.find(widget => widget.name === 'value').value = `Linked ${name} ${type}`; + return source.id; + }); + app.canvas.ds.scale = .8; + app.canvas.ds.offset = [40, 40]; + app.canvas.setDirty(true, true); + return { id: node.id, sources }; + }, type); +} + +/** The prompt box a user drops the link on: the editor's textarea, or the core text box of the Classic nodes. */ +async function promptBox(page, type, id) { + if (editors[type]) return page.getByRole('textbox', { name: editors[type], exact: true }); + // The Classic nodes keep the core text widget: Nodes 2.0 draws it as its own textarea, Classic as widget.inputEl. + if (await page.evaluate(() => LiteGraph.vueNodesMode)) return page.locator(`[data-node-id="${id}"] textarea`).first(); + await page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + app.graph.getNodeById(id).widgets.find(widget => widget.name === 'text').inputEl.dataset.pmTestPromptBox = String(id); + }, id); + return page.locator(`textarea[data-pm-test-prompt-box="${id}"]`); +} + +/** What the node shows while its text is linked. */ +function linkedNote(page, type) { + return editors[type] ? page.getByText(note(editors[type]), { exact: true }) : page.locator('.pm-text-link-note').getByText(note('Text'), { exact: true }); +} + +for (const type of ['PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair', 'PromptManager', 'PromptManagerText']) { + test(`${type}: a STRING link dropped on the prompt box drives text and the typed text returns on disconnect`, async ({ page }, testInfo) => { + test.setTimeout(90000); + const { id, sources } = await setup(page, type); + const typed = `Typed ${type} ${testInfo.project.name}`; + const box = await promptBox(page, type, id); + await box.fill(typed); + await expect(linkedNote(page, type)).toBeHidden(); + await page.screenshot({ path: testInfo.outputPath(`${type}-unlinked.png`) }); + // The drop lands on text, never on prepend_text (#342). + await drag(page, await slotPosition(page, sources[0], 'out', 0), center(await box.boundingBox())); + await expect.poll(() => linkedInputs(page, id)).toEqual(['text']); + const links = { text: [String(sources[0]), 0] }; + if (type === 'PromptManagerPair') { + await page.getByRole('textbox', { name: 'Negative prompt', exact: true }).fill(`Typed negative ${testInfo.project.name}`); + await drag(page, await slotPosition(page, sources[1], 'out', 0), await slotPosition(page, id, 'in', 'negative_text')); + await expect.poll(() => linkedInputs(page, id)).toEqual(['text', 'negative_text']); + links.negative_text = [String(sources[1]), 0]; + await expect(page.getByText(note('Negative prompt'), { exact: true })).toBeVisible(); + } + await expect(linkedNote(page, type)).toBeVisible(); + expect(await queued(page, id)).toMatchObject(links); + await page.screenshot({ path: testInfo.outputPath(`${type}-linked.png`) }); + 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); + }, renderer); + await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer); + await expect(linkedNote(page, type)).toBeVisible(); + expect(await queued(page, id)).toMatchObject(links); + } + const saved = await page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + const workflow = app.graph.serialize(); + await app.loadGraphData(workflow); + return workflow.nodes.find(node => String(node.id) === String(id)).widgets_values; + }, id); + expect(saved[0]).toBe(typed); + await expect(linkedNote(page, type)).toBeVisible(); + expect(await queued(page, id)).toMatchObject(links); + const local = await page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + const node = app.graph.getNodeById(id); + for (const input of [...node.inputs]) if (input.link != null) node.disconnectInput(node.inputs.indexOf(input)); + return (await app.graphToPrompt()).output[String(id)].inputs; + }, id); + expect(local.text).toBe(typed); + if (type === 'PromptManagerPair') expect(local.negative_text).toBe(`Typed negative ${testInfo.project.name}`); + await expect(linkedNote(page, type)).toBeHidden(); + const restored = await promptBox(page, type, id); + await expect(restored).toBeVisible(); + await expect(restored).toHaveValue(typed); + await page.screenshot({ path: testInfo.outputPath(`${type}-disconnected.png`) }); + await page.evaluate(async id => { const { app } = await import('/scripts/app.js'); app.graph.remove(app.graph.getNodeById(id)); }, id); + await expect(page.locator('.pm-focused, .pm-classic, .pm-text-link-note')).toHaveCount(0); + }); +} + +test('workflows saved before #343 load unchanged and their linked text keeps working', async ({ page }) => { + const workflow = JSON.parse(await readFile(new URL('./fixtures/workflow-before-343.json', import.meta.url), 'utf8')); + const result = await page.evaluate(async workflow => { + const { app } = await import('/scripts/app.js'); + await app.loadGraphData(structuredClone(workflow)); + const output = (await app.graphToPrompt()).output; + const nodes = Object.fromEntries(app.graph._nodes.map(node => [node.id, node])); + const reloaded = app.graph.serialize(); + return { + values: Object.fromEntries(reloaded.nodes.map(node => [node.id, node.widgets_values])), + links: Object.fromEntries(Object.entries(output).map(([id, node]) => [id, { text: node.inputs.text, negative_text: node.inputs.negative_text }])), + widgetBound: Object.fromEntries(Object.values(nodes).filter(node => node.type.startsWith('PromptManager')).map(node => [node.id, node.inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).map(input => input.name)])), + }; + }, workflow); + for (const node of workflow.nodes) { + // Every saved widget value comes back as it was saved, except the typed text main lost under a link (null). + if (node.widgets_values?.[0] !== null) expect(result.values[node.id], `${node.type} ${node.id}`).toEqual(node.widgets_values); + } + expect(result.links).toMatchObject({ + 1: { text: 'Saved PromptManager text' }, 2: { text: 'Saved PromptManagerText text' }, 3: { text: 'Saved PromptManagerFocused text' }, + 4: { text: 'Saved PromptManagerSplit text' }, 5: { text: 'Saved PromptManagerPair text', negative_text: 'Saved pair negative' }, + 6: { text: ['7', 0] }, 8: { text: ['9', 0], negative_text: ['10', 0] }, 11: { text: ['12', 0] }, + }); + // The editor nodes' text inputs are sockets now; the Classic nodes keep their core widget sockets. + expect(result.widgetBound).toEqual({ 1: ['text'], 2: ['text'], 3: [], 4: [], 5: [], 6: [], 8: [], 11: ['text'] }); + // The linked sockets are drawn where a user can see and re-drag them, in either renderer. + for (const [node, name] of [[6, 'text'], [8, 'text'], [8, 'negative_text']]) { + await page.evaluate(async node => { const { app } = await import('/scripts/app.js'); app.canvas.centerOnNode(app.graph.getNodeById(node)); app.canvas.setDirty(true, true); }, node); + await expect.poll(async () => { + const position = await slotPosition(page, node, 'in', name); + return Boolean(position && position[0] > 0 && position[1] > 0 && position[0] < 1600 && position[1] < 1100); + }, { message: `${node}.${name} socket on screen` }).toBe(true); + } + const unplugged = await page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + for (const id of [6, 8]) { const node = app.graph.getNodeById(id); for (const input of [...node.inputs]) if (input.link != null) node.disconnectInput(node.inputs.indexOf(input)); } + const output = (await app.graphToPrompt()).output; + return { focused: output['6'].inputs.text, positive: output['8'].inputs.text, negative: output['8'].inputs.negative_text }; + }); + expect(unplugged).toEqual({ focused: 'Typed under focused link', positive: 'Typed pair positive', negative: 'Typed pair negative' }); +}); diff --git a/tests/js/text-links.test.js b/tests/js/text-links.test.js new file mode 100644 index 00000000..8a172195 --- /dev/null +++ b/tests/js/text-links.test.js @@ -0,0 +1,91 @@ +// #343: a link can drive the editor nodes' text inputs (web/js/text-links.js). +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = import('../../web/js/text-links.js'); + +// The frontend's own slot order for the Focused/Split/Pair schemas: forceInput sockets first, then the widget sockets. +function pairNode() { + const inputs = [ + {name:'clip',type:'CLIP',link:null}, + {name:'prepend_text',type:'STRING',link:null}, + {name:'append_text',type:'STRING',link:null}, + {name:'text',type:'STRING',link:null,widget:{name:'text'},_widget:{name:'text'}}, + {name:'negative_text',type:'STRING',link:null,widget:{name:'negative_text'}}, + {name:'category',type:'STRING',link:null,widget:{name:'category'}}, + ]; + return {inputs}; +} + +test('the editor note says the value comes from a link and that typed text is kept', async () => { + const {linkNoteText} = await source; + assert.equal(linkNoteText('Negative prompt'), 'Negative prompt comes from a link. Your typed text is kept for when you disconnect it.'); +}); + +test('a linked widget stores its typed value; an unlinked one its live editor text', async () => { + const {storedWidgetValue} = await source; + const widget = {name:'text', value:'typed', inputEl:{value:'live'}}; + assert.equal(storedWidgetValue({inputs:[{name:'text',link:3}]}, widget), 'typed'); + assert.equal(storedWidgetValue({inputs:[{name:'text',link:null}]}, widget), 'live'); + assert.equal(storedWidgetValue({}, {name:'tags', value:'a, b'}), 'a, b'); +}); + +test('exposing a socket detaches only the named inputs from their hidden widgets', async () => { + const {exposeTextSockets} = await source; + const node = pairNode(); + assert.deepEqual(exposeTextSockets(node, ['text','negative_text']), ['text','negative_text']); + const byName = Object.fromEntries(node.inputs.map(input => [input.name,input])); + for (const name of ['text','negative_text']) { + assert.equal('widget' in byName[name], false); + assert.equal(byName[name]._widget, undefined); + assert.equal(byName[name].type, 'STRING'); + } + assert.deepEqual(byName.category.widget, {name:'category'}); + assert.deepEqual(exposeTextSockets(node, ['text','negative_text']), [], 'idempotent'); + assert.deepEqual(exposeTextSockets({}, ['text']), [], 'a node without inputs is left alone'); +}); + +test('exposing a socket keeps an existing link and the widget value', async () => { + const {exposeTextSockets,isLinked} = await source; + const node = pairNode(); + node.inputs[3].link = 12; + exposeTextSockets(node, ['text']); + assert.equal(node.inputs[3].link, 12); + assert.equal(isLinked(node,'text'), true); + assert.equal(isLinked(node,'negative_text'), false); + assert.equal(isLinked({},'text'), false); +}); + +test('a STRING link dropped on the editor prefers the first free text input over prepend', async () => { + const {preferredTextSlot} = await source; + const node = pairNode(); + assert.deepEqual(preferredTextSlot(node, 'STRING', ['text','negative_text']), {index:3,slot:node.inputs[3]}); + node.inputs[3].link = 4; + assert.deepEqual(preferredTextSlot(node, 'STRING', ['text','negative_text']), {index:4,slot:node.inputs[4]}); + node.inputs[4].link = 5; + assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']), undefined, 'both linked: the frontend chooses'); +}); + +test('only STRING-compatible links are steered to a text input', async () => { + const {preferredTextSlot} = await source; + const node = pairNode(); + for (const type of ['STRING','STRING,INT','*','']) assert.equal(preferredTextSlot(node, type, ['text'])?.index, 3, type); + for (const type of ['CLIP','INT','IMAGE',undefined,null]) assert.equal(preferredTextSlot(node, type, ['text']), undefined, String(type)); + assert.equal(preferredTextSlot({}, 'STRING', ['text']), undefined); +}); + +test('installing the preference wraps findInputByType and restores nothing it did not own', async () => { + const {preferTextInputs} = await source; + const calls = []; + const prototype = {findInputByType(type) { calls.push(type); return {index:1,slot:this.inputs[1]}; }}; + preferTextInputs(prototype, ['text']); + const node = Object.assign(Object.create(prototype), pairNode()); + assert.equal(node.findInputByType('STRING').index, 3); + assert.deepEqual(calls, []); + assert.equal(node.findInputByType('CLIP').index, 1); + node.inputs[3].link = 9; + assert.equal(node.findInputByType('STRING').index, 1, 'the main prompt is taken: fall back to the frontend'); + assert.deepEqual(calls, ['CLIP','STRING']); + const bare = {}; + preferTextInputs(bare, ['text']); + assert.equal(bare.findInputByType, undefined, 'an older frontend without findInputByType is left unchanged'); +}); diff --git a/tests/vendor/comfyui-config.test.mjs b/tests/vendor/comfyui-config.test.mjs index eed33dd2..6ee2cd98 100644 --- a/tests/vendor/comfyui-config.test.mjs +++ b/tests/vendor/comfyui-config.test.mjs @@ -23,19 +23,21 @@ test('execution script discovers only the new matrix and preserves all 78 origin env: { ...process.env, PM_COMFY_ROOT: '/unused-pinned-checkout' } }); const stock = list('tools/vendor/comfyui.config.mjs'); // + local Inspect host case (inspect-sender.spec.mjs, 2 projects), + the S10 renderer case (prompt-bridge.spec.mjs, 2 projects) - assert.match(stock, /Total: 84 tests in 8 files/); + // + #343 linked text: five node types and the pre-#343 workflow (text-link.spec.mjs, 2 projects) + assert.match(stock, /Total: 96 tests in 9 files/); assert.doesNotMatch(stock, /execution-history/); assert.doesNotMatch(stock, /execution-outputs/); assert.doesNotMatch(stock, /library-location/); // it starts its own fresh host (#249 S6b) const executions = list('tools/vendor/playwright.execution.config.mjs'); assert.match(executions, /\[classic-start\]/); assert.match(executions, /\[nodes2-start\]/); - assert.match(executions, /Total: 48 tests in 3 files/); // + the fresh-host library location case + assert.match(executions, /Total: 68 tests in 4 files/); // + the fresh-host library location case, + #343 linked text for (const project of ['classic-start', 'nodes2-start']) { const cases = executions.split('\n').filter(line => line.includes(`[${project}]`)); assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 15); assert.equal(cases.filter(line => line.includes('execution-outputs.spec.mjs')).length, 8); assert.equal(cases.filter(line => line.includes('library-location.spec.mjs')).length, 1); + assert.equal(cases.filter(line => line.includes('execution-text-link.spec.mjs')).length, 10); } assert.doesNotMatch(executions, /prompt-manager.spec/); }); diff --git a/tools/vendor/comfyui.config.mjs b/tools/vendor/comfyui.config.mjs index bc1e5bfc..6cd502f5 100644 --- a/tools/vendor/comfyui.config.mjs +++ b/tools/vendor/comfyui.config.mjs @@ -19,7 +19,7 @@ const servers = [{ export default defineConfig({ testDir: '../../tests/comfyui', - testIgnore: ['execution-history.spec.mjs', 'execution-outputs.spec.mjs', 'library-location.spec.mjs'], + testIgnore: ['execution-history.spec.mjs', 'execution-outputs.spec.mjs', 'execution-text-link.spec.mjs', 'library-location.spec.mjs'], outputDir: './test-results/comfyui', timeout: 60000, workers: 1, diff --git a/tools/vendor/playwright.execution.config.mjs b/tools/vendor/playwright.execution.config.mjs index 82f1e054..25502028 100644 --- a/tools/vendor/playwright.execution.config.mjs +++ b/tools/vendor/playwright.execution.config.mjs @@ -6,7 +6,7 @@ if (host.href !== 'http://127.0.0.1:8196/') throw new Error('Execution tests req export default defineConfig({ testDir: '../../tests/comfyui', - testMatch: ['execution-history.spec.mjs', 'execution-outputs.spec.mjs', 'library-location.spec.mjs'], + testMatch: ['execution-history.spec.mjs', 'execution-outputs.spec.mjs', 'execution-text-link.spec.mjs', 'library-location.spec.mjs'], outputDir: './test-results/executions/cases', timeout: 60000, workers: 1, diff --git a/web/comfy/prompt_manager.js b/web/comfy/prompt_manager.js index 4a74bb79..184a8b86 100644 --- a/web/comfy/prompt_manager.js +++ b/web/comfy/prompt_manager.js @@ -5,6 +5,7 @@ import { api } from "../../scripts/api.js"; const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/classic-node.js')); const { installFocusedNode, installSplitNode, installPairNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js')); const { installPromptBridge } = await import(api.fileURL('/prompt_manager/js/prompt-bridge-host.js')); +const { isLinked, linkNoteText, storedWidgetValue } = await import(api.fileURL('/prompt_manager/js/text-links.js')); const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']); // ============================================================================ @@ -208,15 +209,10 @@ function syncPromptManagerWidgets() { node.widgets.forEach((widget, idx) => { if (widget) { - // Skip widgets whose input is connected to another node — - // the connected value takes precedence over the DOM widget value - const isConnected = node.inputs?.some( - i => i.name === widget.name && i.link != null - ); - if (isConnected) return; - + // A linked widget keeps the text the user typed (#343): the link drives the run, + // the workflow keeps the typed text for when the link is disconnected. // For non-connected widgets, inputEl.value is the true source - const actualValue = widget.inputEl ? widget.inputEl.value : widget.value; + const actualValue = storedWidgetValue(node, widget); if (actualValue !== undefined) { node.widgets_values[idx] = actualValue; @@ -443,13 +439,8 @@ app.registerExtension({ } this.widgets.forEach((widget, idx) => { if (widget) { - // Skip connected widgets — their value comes from the link - const isConnected = self.inputs?.some( - i => i.name === widget.name && i.link != null - ); - if (isConnected) return; - - const actualValue = widget.inputEl ? widget.inputEl.value : widget.value; + // A linked widget stores its typed text, not the live editor (#343) + const actualValue = storedWidgetValue(self, widget); if (actualValue !== undefined) { this.widgets_values[idx] = actualValue; if (widget.inputEl && widget.value !== actualValue) { @@ -488,11 +479,33 @@ app.registerExtension({ if (data._userHasResized) { this._userHasResized = data._userHasResized; } + this.renderTextLink(); }; this.setDirtyCanvas(true, true); }; + // #343: text can come from a link like any core widget. The host hides the text box; + // the controls say why, and the typed text is kept for when the link is disconnected. + const onConnectionsChange = nodeType.prototype.onConnectionsChange; + nodeType.prototype.onConnectionsChange = function (...args) { + const result = onConnectionsChange?.apply(this, args); + this.renderTextLink(); + return result; + }; + nodeType.prototype.createTextLinkNote = function () { + const note = document.createElement("p"); + note.className = "pm-text-link-note"; + note.setAttribute("role", "status"); + note.textContent = linkNoteText("Text"); + this._textLinkNote = note; + this.renderTextLink(); + return note; + }; + nodeType.prototype.renderTextLink = function () { + if (this._textLinkNote) this._textLinkNote.hidden = !isLinked(this, "text"); + }; + // Override onRemoved to cleanup styles const onRemoved = nodeType.prototype.onRemoved; nodeType.prototype.onRemoved = function () { @@ -526,6 +539,10 @@ app.registerExtension({ searchSection.style.height = "100%"; searchSection.style.gap = "8px"; + const linkNote = this.createTextLinkNote(); + linkNote.style.cssText = "margin: 0; color: #f9a825; font-size: 12px;"; + searchSection.appendChild(linkNote); + // Search buttons row const buttonRow = document.createElement("div"); buttonRow.style.display = "flex"; @@ -876,6 +893,11 @@ app.registerExtension({ // Method to use a selected prompt nodeType.prototype.usePrompt = function (prompt, index) { + // The typed text under a link is kept for when it is disconnected (#343) + if (isLinked(this, "text")) { + this.showNotification("Text comes from a link. Disconnect it to use a saved prompt.", "error"); + return; + } // Find the text widget and set its value const textWidget = this.widgets?.find((w) => w.name === "text"); if (textWidget) { diff --git a/web/js/classic-node.css b/web/js/classic-node.css index 4949bd28..c5beefd3 100644 --- a/web/js/classic-node.css +++ b/web/js/classic-node.css @@ -12,3 +12,5 @@ .pm-classic-restore { white-space: normal !important; } .pm-classic-notification { position: fixed; inset-block-start: 20px; inset-inline-end: 20px; padding: 10px 15px; border: 1px solid var(--ca-border); border-radius: 5px; z-index: 10000; max-inline-size: min(300px, calc(100vw - 40px)); overflow-wrap: anywhere; } .pm-classic-notification[data-kind="error"] { color: var(--ca-error-text); background: var(--ca-error-bg); border-color: var(--ca-error); } +.pm-classic .pm-classic-link { margin: 0; padding: 6px 8px; border: 1px solid var(--ca-line); border-radius: 5px; color: var(--ca-text); background: var(--ca-field); overflow-wrap: anywhere; } +.pm-classic .pm-classic-link[hidden] { display: none; } diff --git a/web/js/classic-node.js b/web/js/classic-node.js index c0c9daf0..fc72d647 100644 --- a/web/js/classic-node.js +++ b/web/js/classic-node.js @@ -44,6 +44,9 @@ export function installClassicControls(nodeType, app, parseSearchQuery, api) { this._classicContainer = container; const wrapper = document.createElement('div'); wrapper.className = 'pm-classic-workspace'; + const linkNote = this.createTextLinkNote(); + linkNote.classList.add('pm-classic-link'); + wrapper.append(linkNote); const buttons = document.createElement('div'); buttons.className = 'pm-classic-toolbar'; for (const [label, action, primary] of [ diff --git a/web/js/focused-node.js b/web/js/focused-node.js index f2eed6f6..90ff3d5a 100644 --- a/web/js/focused-node.js +++ b/web/js/focused-node.js @@ -1,6 +1,7 @@ import {createScope, createButton, createField, mountTheme} from './shared/toolkit/core.js'; import {createPromptLibrary} from './prompt-library.js'; import {attachMetadataAutocomplete} from './metadata-autocomplete.js'; +import {exposeTextSockets, linkNoteText, preferTextInputs} from './text-links.js'; const names = ['text', 'category', 'tags', 'search_text', 'prepend_text', 'append_text']; let nextEditorId = 0; @@ -23,7 +24,9 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo const pair = layout === 'pair'; const split = layout !== 'focused'; const uiKey = pair ? 'pairUI' : split ? 'splitUI' : 'focusedUI'; + const textInputs = pair ? ['text','negative_text'] : ['text']; const prototype = nodeType.prototype; + preferTextInputs(prototype, textInputs); const created = prototype.onNodeCreated; const removed = prototype.onRemoved; const configure = prototype.configure; @@ -41,6 +44,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo widget.hidden = true; widget.type = 'hidden'; widget.computeSize = () => [0, -4]; widgets.set(name, widget); } + exposeTextSockets(this, textInputs); const element = document.createElement('div'); element.className = `ca-tools pm-focused${split ? ' pm-split' : ''}${pair ? ' pm-pair' : ''}`; element.setAttribute('aria-label',pair ? 'Paired prompt editor' : split ? 'Split prompt editor' : 'Focused prompt editor'); loadStyles(); mountTheme(element, app.ui?.settings, scope); @@ -78,7 +82,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo const field = createField(label,input,`${editorId}-${name}`); field.classList.add('pm-editor-main'); const note = document.createElement('p'); note.className = 'pm-editor-status'; - note.textContent = `${pair ? label : 'Text'} is supplied by an upstream connection.`; + note.textContent = linkNoteText(label); return {input,field,note}; } const positive = textControl('text',pair ? 'Positive prompt' : 'Main prompt'); @@ -218,7 +222,13 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo this.addDOMWidget('prompt_manager_focused_ui','div',element,{serialize:false, hideOnZoom:false, getMinHeight:() => 360}); this.size = [pair ? 1100 : split ? 900 : 550,pair ? 700 : 650]; this.setSize(this.size); restore(); }; - prototype.configure = function (...args) { const result = configure?.apply(this,args); this._focusedView?.restore(); return result; }; + prototype.configure = function (...args) { + const result = configure?.apply(this,args); + // A saved workflow restores its inputs with their widget bindings; keep the text inputs as sockets. + exposeTextSockets(this, textInputs); + this._focusedView?.restore(); + return result; + }; prototype.serialize = function (...args) { if (this._focusedView) this.properties[uiKey] = this._focusedView.snapshot(); return serialize?.apply(this,args); diff --git a/web/js/text-links.js b/web/js/text-links.js new file mode 100644 index 00000000..4da725ab --- /dev/null +++ b/web/js/text-links.js @@ -0,0 +1,62 @@ +// #343: let a link drive the editor nodes' prompt text, the way a link drives any core widget. +// +// The Focused, Split and Pair editors hide their widgets to draw their own editor. ComfyUI's frontend draws +// and hit-tests a widget's input socket only while the widget itself is visible, so `text` (and the Pair's +// `negative_text`) had no socket, and a STRING link dropped on the editor fell through to the first free +// STRING input, `prepend_text` (#342). Detaching those inputs from their hidden widgets makes them ordinary +// sockets in both renderers; the schema, the widgets and their saved values are unchanged. + +/** What an editor shows in place of a text field whose value comes from a link. */ +export function linkNoteText(label) { + return `${label} comes from a link. Your typed text is kept for when you disconnect it.`; +} + +export function isLinked(node, name) { + return Boolean(node?.inputs?.some(input => input.name === name && input.link != null)); +} + +/** Detach the named inputs from their hidden widgets so the frontend shows them as sockets. Returns the names changed. */ +export function exposeTextSockets(node, names) { + const exposed = []; + for (const input of node?.inputs || []) { + if (!names.includes(input.name) || !('widget' in input || input._widget)) continue; + delete input.widget; + if ('_widget' in input) input._widget = undefined; + exposed.push(input.name); + } + return exposed; +} + +function acceptsString(type) { + if (type == null) return false; + return String(type).split(',').some(part => ['STRING','*',''].includes(part.trim())); +} + +/** The first free text input, in editor order, for a link of `type`; undefined leaves the choice to the frontend. */ +export function preferredTextSlot(node, type, names) { + if (!acceptsString(type)) return undefined; + const inputs = node?.inputs || []; + for (const name of names) { + const index = inputs.findIndex(input => input.name === name); + if (index >= 0 && inputs[index].link == null) return {index, slot:inputs[index]}; + } + return undefined; +} + +/** A link dropped on the editor body (not on a socket) fills the main prompt first, then the negative. */ +export function preferTextInputs(prototype, names) { + const original = prototype.findInputByType; + if (typeof original !== 'function') return; + prototype.findInputByType = function (type, ...rest) { + return preferredTextSlot(this, type, names) ?? original.call(this, type, ...rest); + }; +} + +/** + * The value a workflow stores for a widget. A linked widget keeps what the user typed (its value), so the text + * returns when the link is disconnected; an unlinked one stores its live editor text. + */ +export function storedWidgetValue(node, widget) { + if (isLinked(node, widget.name)) return widget.value; + return widget.inputEl ? widget.inputEl.value : widget.value; +}