fix(send): name ComfyUI windows by their tab and explain why a send is blocked
The window option read "PromptManager — ComfyUI" for every tab and nothing was selected, so the dialog looked stuck. Label windows with the live tab title, select the only eligible window and node, and say in the status line whether there is no window, no node, a node that cannot take these prompts, or a linked field. Preview and Apply stay explicit. Closes #535
This commit is contained in:
+10
@@ -4235,6 +4235,16 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
makes the page see a restore run and finish deterministically. A refused file toasts "This file can't be restored: <validator reason>.". A
|
||||
review value may wrap anywhere (`#modal-body > dl.meta-grid dd`, CSS under D105), so a
|
||||
long file name keeps the concept's two equal columns at phone width.
|
||||
- **D173** (#535, amends D106): "always asks" now means asks when there is a choice. One
|
||||
eligible window is selected, and a selected window with one eligible node selects that
|
||||
node; Preview and Apply stay explicit. A node whose requested field is linked is not
|
||||
offered (`linkedRoles` on each destination), and the status line says which case applies:
|
||||
linked, wrong roles, no node, or no window. Selected or not, it counts the other
|
||||
nodes that cannot receive, and the review names the node and window.
|
||||
- **D174** (#535): the window option shows the tab's current title, read at each discovery
|
||||
(a getter on the host options, which the Toolkit reads per discover), without the old
|
||||
"PromptManager —" prefix. Same-named windows and nodes get " (window 2)", numbered by host id so the number
|
||||
is stable while the tabs stay loaded, and " #<id>". Discovery waits 300 ms, as System does.
|
||||
- **D106** (Integrations, S10, Q8): the send bridge always asks for the ComfyUI window
|
||||
and node, and the nodes always follow ComfyUI's own renderer, so "Default send
|
||||
destination" offers only "Ask each time" and "Host compatibility" only "Follow host
|
||||
|
||||
@@ -193,7 +193,7 @@ test('empty negative send clears only the negative destination and preserves its
|
||||
test('no open ComfyUI window is actionable and rediscovery finds a newly opened host',async({page,context})=>{
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
|
||||
const sender=dialog(page);await expect(sender).toContainText('No compatible ComfyUI windows found.');
|
||||
const sender=dialog(page);await expect(sender).toContainText('No ComfyUI window answered in this browser');
|
||||
const host=await fakeHost(context,page);
|
||||
try {
|
||||
await sender.getByRole('button',{name:'Find ComfyUI windows',exact:true}).click();
|
||||
|
||||
@@ -88,7 +88,7 @@ function measureIntegrations(mainSelector) {
|
||||
};
|
||||
}
|
||||
|
||||
const SEND_REASON = 'Sending always asks for the ComfyUI window and node, so “Ask each time” is the only option.';
|
||||
const SEND_REASON = 'Sending asks when there is more than one ComfyUI window or node and always previews, so “Ask each time” is the only option.';
|
||||
const RENDERER_REASON = 'Prompt Manager nodes always follow ComfyUI’s own renderer setting.';
|
||||
const RENDERER_NONE = 'No ComfyUI window is open in this browser, so its renderer is not known.';
|
||||
const RETURN_REASON = 'Not available: sending applies the text in ComfyUI and keeps this page open.';
|
||||
|
||||
@@ -99,7 +99,7 @@ test('send destination offers Ask each time; unsupported options disabled with t
|
||||
.toEqual([['Ask each time', false], ['Selected Prompt Manager node', true], ['Create a Prompt Manager (+/-) node', true]]);
|
||||
await expect(send.locator('select')).toHaveValue('Ask each time');
|
||||
await expect(send.locator('p.small')).toHaveText(
|
||||
'Sending always asks for the ComfyUI window and node, so “Ask each time” is the only option.');
|
||||
'Sending asks when there is more than one ComfyUI window or node and always previews, so “Ask each time” is the only option.');
|
||||
const renderer = settingOf(page, 'Host compatibility');
|
||||
expect(await renderer.locator('option').evaluateAll(options => options.map(option => [option.textContent, option.disabled])))
|
||||
.toEqual([['Follow host renderer', false], ['Classic', true], ['Nodes 2.0', true]]);
|
||||
|
||||
@@ -93,7 +93,7 @@ test('local file roles send exactly across renderer transitions',async({page,con
|
||||
} finally {await restore(page);}
|
||||
}
|
||||
}
|
||||
// A connected destination input rejects the whole preview: neither role changes, nothing queues or loads.
|
||||
// A connected destination input blocks the whole send: neither role changes, nothing queues or loads.
|
||||
const link=await page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
|
||||
const node=app.graph.getNodeById(id);const widget=node.widgets.find(item=>item.name==='negative_text');
|
||||
@@ -108,8 +108,11 @@ test('local file roles send exactly across renderer transitions',async({page,con
|
||||
const before=await nodeState(page,id);
|
||||
expect(before.widgets).toMatchObject({text:'Before positive',negative_text:'Before negative'});
|
||||
const rail=await inspect(workspace,FILES[1]);
|
||||
const sender=await preview(workspace,rail);
|
||||
await expect(sender.getByRole('alert')).toContainText(/connected/i);
|
||||
// #535: the linked node is not offered, and the status line says why.
|
||||
await rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
|
||||
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await expect(sender).toContainText(/has its negative prompt linked to another node/);
|
||||
await expect(sender.getByRole('button',{name:'Preview changes',exact:true})).toBeDisabled();
|
||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
|
||||
const rejected=await nodeState(page,id);
|
||||
expect(rejected.effects).toEqual({queue:0,load:0});
|
||||
|
||||
@@ -117,10 +117,9 @@ test('native editor preview sends exact saved roles across renderer transitions'
|
||||
try {
|
||||
await send.click();
|
||||
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({index:1});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await expect(sender.getByRole('alert')).toContainText(/connected/i);
|
||||
// #535: the linked node is not offered, and the status line says why.
|
||||
await expect(sender).toContainText(/has its negative prompt linked to another node/);
|
||||
await expect(sender.getByRole('button',{name:'Preview changes',exact:true})).toBeDisabled();
|
||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
|
||||
const rejected=await page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);const serialized=node.serialize();
|
||||
|
||||
@@ -68,8 +68,13 @@ test('native image inspector previews and sends a pair into real visible widgets
|
||||
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
await expect(hosts.locator('option')).toHaveCount(2);
|
||||
await hosts.selectOption({index:1});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
|
||||
// #535: the window is named by its tab, and one window with one node needs no choosing.
|
||||
const tabTitle=await page.title();
|
||||
await expect(hosts.locator('option').nth(1)).toHaveText(tabTitle);
|
||||
expect(tabTitle).not.toContain('PromptManager');
|
||||
await expect(hosts).not.toHaveValue('');
|
||||
await expect(sender.getByRole('combobox',{name:'Destination node',exact:true})).not.toHaveValue('');
|
||||
await expect(sender.getByRole('status')).toContainText(`in ${tabTitle}. Preview changes before applying.`);
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
for(const text of ['Before positive','Before negative',positive,negative,`LoRA trigger ${positive} fine detail`])await expect(sender).toContainText(text);
|
||||
await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Before positive');
|
||||
@@ -86,5 +91,22 @@ test('native image inspector previews and sends a pair into real visible widgets
|
||||
for(const name of ['inputs','serialized'])expect(state[name]).toMatchObject({text:positive,negative_text:negative,prepend_text:'LoRA trigger',append_text:'fine detail'});
|
||||
expect(state.effects).toEqual({queue:0,load:0});expect(errors).toEqual([]);
|
||||
await workspace.screenshot({path:testInfo.outputPath('full-inspector-host-send.png')});
|
||||
// #535: the status line says why a window cannot receive.
|
||||
const find=sender.getByRole('button',{name:'Find ComfyUI windows',exact:true});
|
||||
const preview=sender.getByRole('button',{name:'Preview changes',exact:true});
|
||||
await page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
|
||||
const node=app.graph.getNodeById(id);const widget=node.widgets.find(widget=>widget.name==='text');
|
||||
if(!node.inputs.some(input=>input.name==='text'))convertToInput(node,widget);
|
||||
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);
|
||||
source.connect(0,node,node.inputs.findIndex(input=>input.name==='text'));
|
||||
},id);
|
||||
await find.click();
|
||||
await expect(sender).toContainText(/has its positive prompt linked to another node/);
|
||||
await expect(preview).toBeDisabled();
|
||||
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');app.graph.clear();});
|
||||
await find.click();
|
||||
await expect(sender).toContainText('has no Prompt Manager node.');
|
||||
await expect(preview).toBeDisabled();
|
||||
} finally {await workspace.close();}
|
||||
});
|
||||
|
||||
@@ -151,3 +151,11 @@ test('callback failure preserves serialization normalized between preview and ap
|
||||
node.widgets_values=node.widgets.map(item=>item.value); widget('text').callback=()=>{throw new Error('callback failed');};
|
||||
assert.throws(()=>adapter.apply(snapshot),/failed/); assert.equal(node.widgets_values[0],'old'); assert.equal(widget('text').value,'old');
|
||||
});
|
||||
|
||||
test('listTargets reports which roles are linked to an upstream node',async()=>{
|
||||
const {createPromptAdapter}=await import('../../web/js/prompt-bridge-adapter.js');
|
||||
const widgets=[{name:'text',value:''},{name:'negative_text',value:''}];
|
||||
const app={graph:{_nodes:[{id:1,type:'PromptManagerPair',widgets,inputs:[{name:'text',link:null},{name:'negative_text',link:4}]},{id:2,type:'PromptManager',widgets,inputs:[]}]}};
|
||||
const [pair,plain]=createPromptAdapter(app,{runtime:{}}).listTargets();
|
||||
assert.deepEqual(pair.linkedRoles,['negative']);assert.deepEqual(plain.linkedRoles,[]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installPromptBridge } from '../../web/js/prompt-bridge-host.js';
|
||||
import { createBridgeClient, createBridgeHost } from '../../web/js/shared/toolkit/prompt-bridge.js';
|
||||
|
||||
function fixture() {
|
||||
const runtime = new EventTarget();
|
||||
@@ -19,7 +20,10 @@ test('host uses the exact deployment prefix and explicit pack label', () => {
|
||||
assert.equal(f.hosts.length, 1);
|
||||
assert.equal(f.hosts[0].config.namespace, '/team/');
|
||||
assert.equal(f.hosts[0].config.origin, 'https://comfy.example');
|
||||
assert.equal(f.hosts[0].config.label, 'PromptManager — My workflow');
|
||||
assert.equal(f.hosts[0].config.label, 'My workflow');
|
||||
f.runtime.document.title = '';
|
||||
assert.equal(f.hosts[0].config.label, 'ComfyUI');
|
||||
f.runtime.document.title = 'My workflow';
|
||||
assert.equal(f.hosts[0].config.adapter, f.adapter);
|
||||
assert.equal(installPromptBridge(app, f.options), controller);
|
||||
controller.dispose(); controller.dispose();
|
||||
@@ -66,3 +70,24 @@ test('deployment namespace rejects external origins and unexpected paths', () =>
|
||||
assert.equal(f.hosts.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('each discovery reports the title the tab has now, through the real Toolkit host', async () => {
|
||||
const f = fixture();
|
||||
f.runtime.location = { origin: 'https://comfy.example', href: 'https://comfy.example/' };
|
||||
f.options.baseUrl = '/prompt_manager/';
|
||||
const listeners = new Set();
|
||||
const channelFactory = () => { const own = new Set(); return {
|
||||
addEventListener(type, listener) { if (type === 'message') { own.add(listener); listeners.add(listener); } }, removeEventListener(type, listener) { listeners.delete(listener); },
|
||||
postMessage(data) { for (const listener of [...listeners]) if (!own.has(listener)) queueMicrotask(() => listener({ data: structuredClone(data), origin: 'https://comfy.example' })); }, close() {},
|
||||
}; };
|
||||
// Not spread: a copy would freeze the title the host must keep reading.
|
||||
f.options.hostFactory = config => { config.channelFactory = channelFactory; return createBridgeHost(config); };
|
||||
f.adapter.listTargets = () => []; f.adapter.preview = () => {}; f.adapter.apply = () => {};
|
||||
f.runtime.document.title = 'ComfyUI';
|
||||
const controller = installPromptBridge({}, f.options);
|
||||
const client = createBridgeClient({ namespace: '/', origin: 'https://comfy.example', channelFactory });
|
||||
f.runtime.document.title = '*Unsaved Workflow - ComfyUI';
|
||||
try {
|
||||
assert.equal((await client.discover({ waitMs: 100 }))[0].label, '*Unsaved Workflow - ComfyUI');
|
||||
} finally { client.dispose(); controller.dispose(); }
|
||||
});
|
||||
|
||||
@@ -11,18 +11,18 @@ function setup(createSendSession, overrides = {}, roles = {positive:'new'}) {
|
||||
}
|
||||
test('sender requires explicit compatible host and target, preview, then acknowledged apply', async () => {
|
||||
const {createSendSession} = await source; const s=setup(createSendSession);
|
||||
await s.session.discover(); assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.targetId,'');
|
||||
await s.session.discover(); assert.equal(s.session.state.hostId,'host');assert.equal(s.session.state.targetId,'');
|
||||
await s.session.preview(); assert.equal(s.calls.length,0);
|
||||
s.session.selectHost('host');assert.equal(s.session.state.targetId,''); s.session.selectTarget('one');
|
||||
s.session.selectTarget('one');
|
||||
await s.session.apply();assert.equal(s.calls.length,0);
|
||||
await s.session.preview();assert.equal(s.session.state.phase,'ready');assert.equal(s.session.state.preview.composition.after,'prefix new');
|
||||
await s.session.apply();assert.equal(s.session.state.phase,'success');assert.equal(s.calls.filter(call=>call[0]==='apply').length,1);
|
||||
await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,1); s.session.dispose();
|
||||
});
|
||||
test('pair roles filter incompatible nodes and do not silently choose the sole destination', async()=>{
|
||||
test('pair roles filter incompatible nodes and select the sole destination', async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{}, {positive:'',negative:'bad'});
|
||||
await s.session.discover();assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.id),['pair']);
|
||||
s.session.selectHost('host');s.session.selectTarget('one');assert.equal(s.session.state.targetId,'');
|
||||
assert.equal(s.session.state.targetId,'pair');s.session.selectTarget('one');assert.equal(s.session.state.targetId,'');
|
||||
s.session.selectTarget('pair');assert.equal(s.session.state.targetId,'pair');s.session.dispose();
|
||||
});
|
||||
test('late previews are ignored after target changes; aborted and disposed work cannot update state',async()=>{
|
||||
@@ -44,20 +44,75 @@ test('only matching explicit acknowledgements count as success',async()=>{
|
||||
}
|
||||
});
|
||||
test('discovery, empty results and stale preview errors remain actionable',async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[]});await s.session.discover();assert.match(s.session.state.message,/No compatible/i);s.session.dispose();
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[]});await s.session.discover();assert.match(s.session.state.message,/No ComfyUI window answered/i);s.session.dispose();
|
||||
const failed=setup(createSendSession,{discover:async()=>{throw Error('unavailable');}});await failed.session.discover();assert.match(failed.session.state.message,/unavailable/);failed.session.dispose();
|
||||
const stale=setup(createSendSession,{preview:async()=>{throw Error('Workflow changed. Preview again.');}});await stale.session.discover();stale.session.selectHost('host');stale.session.selectTarget('one');await stale.session.preview();assert.match(stale.session.state.message,/Workflow changed/);stale.session.dispose();
|
||||
});
|
||||
test('rediscovery invalidates selections and old discovery cannot replace new results',async()=>{
|
||||
const {createSendSession}=await source;let resolve;let count=0;const s=setup(createSendSession,{discover:()=>++count===1?new Promise(done=>resolve=done):Promise.resolve(hosts)});
|
||||
const first=s.session.discover();await s.session.discover();resolve([]);await first;assert.equal(s.session.state.hosts.length,1);
|
||||
s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.discover();assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.preview,null);s.session.dispose();
|
||||
s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.discover();assert.equal(s.session.state.hostId,'host');assert.equal(s.session.state.targetId,'');assert.equal(s.session.state.preview,null);s.session.dispose();
|
||||
});
|
||||
test('pending apply explains closing cannot undo the sent update',async()=>{
|
||||
const {createSendSession}=await source;let done;const s=setup(createSendSession,{apply:()=>new Promise(resolve=>done=resolve)});
|
||||
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();const pending=s.session.apply();
|
||||
assert.match(s.session.state.message,/Closing this dialog does not undo/i);s.session.dispose();done({targetId:'one',values:{positive:'new'}});await pending;
|
||||
});
|
||||
const window1=(targets,label='Flow - ComfyUI',hostId='a')=>({hostId,label,targets});
|
||||
const pm=(id,roles=['positive'],extra={})=>({id,label:`Node ${id}`,roles,...extra});
|
||||
async function discovered(createSendSession,found,roles={positive:'new'}) {
|
||||
const s=setup(createSendSession,{discover:async()=>found},roles);await s.session.discover();return s;
|
||||
}
|
||||
test('one window with one eligible node is selected so Preview is available at once',async()=>{
|
||||
const {createSendSession}=await source;const s=await discovered(createSendSession,[window1([pm('1')])]);
|
||||
assert.equal(s.session.state.hostId,'a');assert.equal(s.session.state.targetId,'1');
|
||||
assert.match(s.session.state.message,/Node 1 in Flow - ComfyUI.*Preview changes/);
|
||||
await s.session.preview();assert.equal(s.session.state.phase,'ready');s.session.dispose();
|
||||
});
|
||||
test('several windows are left to choose, and choosing one with a single node selects that node',async()=>{
|
||||
const {createSendSession}=await source;const s=await discovered(createSendSession,[window1([pm('1')],'A','a'),window1([pm('2'),pm('3')],'B','b')]);
|
||||
assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.targetId,'');assert.match(s.session.state.message,/Choose a ComfyUI window/);
|
||||
s.session.selectHost('a');assert.equal(s.session.state.targetId,'1');
|
||||
s.session.selectHost('b');assert.equal(s.session.state.targetId,'');assert.match(s.session.state.message,/Choose a destination node in B/);
|
||||
s.session.selectHost('');assert.match(s.session.state.message,/Choose a ComfyUI window/);
|
||||
s.session.selectHost('b');s.session.selectTarget('2');s.session.selectTarget('');assert.match(s.session.state.message,/Choose a destination node/);s.session.dispose();
|
||||
});
|
||||
test('a window that cannot receive says why, naming the window and the node',async()=>{
|
||||
const {createSendSession}=await source;
|
||||
let s=await discovered(createSendSession,[window1([])]);
|
||||
assert.match(s.session.state.message,/Flow - ComfyUI has no Prompt Manager node/);assert.equal(s.session.state.hosts.length,0);s.session.dispose();
|
||||
s=await discovered(createSendSession,[window1([]),window1([],'Other','b')]);
|
||||
assert.match(s.session.state.message,/None of the 2 ComfyUI windows has a Prompt Manager node/);s.session.dispose();
|
||||
s=await discovered(createSendSession,[window1([pm('1',['positive'],{linkedRoles:['positive']})])]);
|
||||
assert.match(s.session.state.message,/Node 1 in Flow - ComfyUI has its positive prompt linked/);assert.equal(s.session.state.hosts.length,0);s.session.dispose();
|
||||
s=await discovered(createSendSession,[window1([pm('1')])],{positive:'p',negative:'n'});
|
||||
assert.match(s.session.state.message,/Flow - ComfyUI has no Prompt Manager node that accepts a positive and negative prompt.*\(\+\/-\)/);s.session.dispose();
|
||||
});
|
||||
test('a linked node is not offered beside an eligible one, and an unlinked role still is',async()=>{
|
||||
const {createSendSession}=await source;
|
||||
let s=await discovered(createSendSession,[window1([pm('1',['positive'],{linkedRoles:['positive']}),pm('2')])]);
|
||||
assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.id),['2']);assert.equal(s.session.state.targetId,'2');s.session.dispose();
|
||||
s=await discovered(createSendSession,[window1([pm('1',['positive','negative'],{linkedRoles:['negative']})])]);
|
||||
assert.equal(s.session.state.targetId,'1');s.session.dispose();
|
||||
});
|
||||
test('an auto-selected destination says how many other nodes cannot receive, and the review names the destination',async()=>{
|
||||
const {createSendSession}=await source;
|
||||
const s=await discovered(createSendSession,[window1([pm('2')],'A','a'),window1([pm('1',['positive'],{linkedRoles:['positive']})],'B','b')]);
|
||||
assert.equal(s.session.state.targetId,'2');assert.match(s.session.state.message,/1 other Prompt Manager node cannot receive/);
|
||||
await s.session.preview();s.session.dispose();
|
||||
const named=setup(createSendSession,{discover:async()=>[window1([pm('one')])],preview:async(_,id)=>({ticket:'t',target:{id,label:'Live title'},fields:[{role:'positive',before:'',after:'new'}]})});
|
||||
await named.session.discover();await named.session.preview();assert.match(named.session.state.message,/Sending to Live title in Flow - ComfyUI/);named.session.dispose();
|
||||
});
|
||||
test('window order and numbering do not depend on which tab answered first',async()=>{
|
||||
const {createSendSession}=await source;const s=await discovered(createSendSession,[window1([pm('1')],'T','b'),window1([pm('2')],'T','a')]);
|
||||
assert.deepEqual(s.session.state.hosts.map(host=>[host.hostId,host.label]),[['a','T'],['b','T (window 2)']]);s.session.dispose();
|
||||
});
|
||||
test('same-named windows and nodes stay distinguishable',async()=>{
|
||||
const {createSendSession}=await source;const twin=id=>({id,label:'Same',roles:['positive']});
|
||||
const s=await discovered(createSendSession,[window1([twin('7'),twin('8')],'T','a'),window1([pm('1')],'T','b')]);
|
||||
assert.deepEqual(s.session.state.hosts.map(host=>host.label),['T','T (window 2)']);
|
||||
assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.label),['Same #7','Same #8']);s.session.dispose();
|
||||
});
|
||||
function fakeDOM() {
|
||||
class Node extends EventTarget {
|
||||
constructor(tag){super();this.tagName=tag;this.children=[];this.attributes={};this.dataset={};this.value='';this.textContent='';this.isConnected=true;this.open=false;}
|
||||
@@ -89,7 +144,7 @@ test('native sender dialog uses prefixed namespace, full safe text, explicit cho
|
||||
assert.deepEqual(options,{namespace:'/prefix/',origin:'https://example.test'});
|
||||
const dialog=dom.body.children.at(-1);assert.equal(dialog.open,true);assert.equal(dialog.attributes['aria-label'],'Send to ComfyUI');
|
||||
const host=dom.find(dialog,node=>node.id==='prompt-sender-host');const target=dom.find(dialog,node=>node.id==='prompt-sender-target');
|
||||
assert.equal(host.value,'');assert.equal(target.value,'');host.value='host';host.dispatchEvent(new Event('change'));target.value='one';target.dispatchEvent(new Event('change'));
|
||||
assert.equal(host.value,'host');assert.equal(target.value,'');target.value='one';target.dispatchEvent(new Event('change'));
|
||||
dom.find(dialog,node=>node.textContent==='Preview changes').dispatchEvent(new Event('click'));await tick();
|
||||
assert.ok(dom.find(dialog,node=>node.tagName==='code' && node.textContent==='(empty)'));assert.ok(dom.find(dialog,node=>node.tagName==='code' && node.textContent==='<unsafe>new</unsafe>'));
|
||||
const apply=dom.find(dialog,node=>node.textContent==='Apply to ComfyUI');assert.equal(apply.className,'ca-primary');assert.equal(apply.disabled,false);apply.dispatchEvent(new Event('click'));await tick();
|
||||
|
||||
@@ -12,6 +12,7 @@ const compositionNames = ['prepend_text','append_text'];
|
||||
function nodes(graph) { return graph?._nodes || []; }
|
||||
function target(node) { return {id:String(node.id),label:String(node.title || node.type),type:node.type}; }
|
||||
function connected(node,name) { return Boolean(node.inputs?.some(input => input.name === name && input.link != null)); }
|
||||
function linkedRoles(node) { return Object.entries(schemas.get(node.type)).filter(([,name]) => connected(node,name)).map(([role]) => role); }
|
||||
function links(node) { return JSON.stringify((node.inputs || []).map(input => [input.name,input.link ?? null])); }
|
||||
function capture(node,name) {
|
||||
const widget = node.widgets?.find(item => item.name === name);
|
||||
@@ -59,7 +60,7 @@ export function createPromptAdapter(app, {runtime = globalThis} = {}) {
|
||||
const snapshots = new WeakMap();
|
||||
function listTargets() {
|
||||
const drawn = renderer(runtime);
|
||||
return nodes(app.graph).filter(node => schemas.has(node.type)).map(node => ({...target(node),roles:Object.keys(schemas.get(node.type)),...drawn}));
|
||||
return nodes(app.graph).filter(node => schemas.has(node.type)).map(node => ({...target(node),roles:Object.keys(schemas.get(node.type)),linkedRoles:linkedRoles(node),...drawn}));
|
||||
}
|
||||
function preview(targetId,rawRoles) {
|
||||
const roles = validateRoles(rawRoles);
|
||||
|
||||
@@ -16,7 +16,8 @@ export function installPromptBridge(app, { baseUrl, runtime = globalThis,
|
||||
if (host || disposed) return;
|
||||
try {
|
||||
host = hostFactory({ namespace, origin: runtime.location.origin,
|
||||
label: `PromptManager — ${runtime.document.title || 'ComfyUI'}`, adapter: adapterFactory(app) });
|
||||
// A getter: the Toolkit reads this on every discovery, and the tab title follows the open workflow.
|
||||
get label() { return runtime.document.title || 'ComfyUI'; }, adapter: adapterFactory(app) });
|
||||
} catch (error) {
|
||||
runtime.console.warn('[PromptManager] Prompt sending is unavailable:', error.message);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,39 @@
|
||||
import {createBridgeClient, validateRoles} from '../shared/toolkit/prompt-bridge.js';
|
||||
import {createButton, createField, createScope, createSelect} from '../shared/toolkit/core.js';
|
||||
|
||||
// The same wait as System's renderer line, so a busy tab is not reported as missing.
|
||||
const DISCOVERY_WAIT_MS = 300;
|
||||
const roleNames = roles => Object.keys(roles).join(' and ');
|
||||
const roleOf = target => Array.isArray(target.roles) ? target.roles : [];
|
||||
const accepts = (target,roles) => typeof target.id==='string' && Object.keys(roles).every(role=>roleOf(target).includes(role));
|
||||
const linkedTo = (target,roles) => Object.keys(roles).filter(role=>Array.isArray(target.linkedRoles) && target.linkedRoles.includes(role));
|
||||
|
||||
/** Why nothing in the discovered windows can receive these roles, most specific first (D173). */
|
||||
function unavailableMessage(found,roles) {
|
||||
if (!found.length) return 'No ComfyUI window answered in this browser at this address. Open ComfyUI here, then find windows again.';
|
||||
for (const host of found) for (const target of host.targets) {
|
||||
const linked = accepts(target,roles) ? linkedTo(target,roles) : [];
|
||||
if (linked.length) return `${target.label} in ${host.label} has its ${linked.join(' and ')} prompt linked to another node, so it cannot receive text. Disconnect the link, then find windows again.`;
|
||||
}
|
||||
const where = found.length===1 ? found[0].label : `None of the ${found.length} ComfyUI windows`;
|
||||
const verb = found.length===1 ? 'has no' : 'has a';
|
||||
if (found.some(host=>host.targets.length)) {
|
||||
const need = Object.hasOwn(roles,'negative') ? ' Add a Prompt Manager (+/-) node, then find windows again.' : ' Add one, then find windows again.';
|
||||
return `${where} ${verb} Prompt Manager node that accepts a ${roleNames(roles)} prompt.${need}`;
|
||||
}
|
||||
return `${where} ${verb} Prompt Manager node. Add one to the workflow, then find windows again.`;
|
||||
}
|
||||
/** Keeps same-named options apart: tabs on one workflow, or nodes that share a title. */
|
||||
function distinctLabels(items,suffix) {
|
||||
const seen = new Map();
|
||||
const totals = new Map();
|
||||
for (const item of items) totals.set(item.label,(totals.get(item.label) || 0)+1);
|
||||
return items.map(item => {
|
||||
const count = (seen.get(item.label) || 0)+1;seen.set(item.label,count);
|
||||
return totals.get(item.label)>1 ? {...item,label:suffix(item,count)} : item;
|
||||
});
|
||||
}
|
||||
|
||||
export function createSendSession({client, roles:rawRoles, onChange}) {
|
||||
const roles = Object.freeze(validateRoles(rawRoles));
|
||||
let state = {phase:'idle',hosts:[],hostId:'',targetId:'',preview:null,message:''};
|
||||
@@ -10,20 +43,30 @@ export function createSendSession({client, roles:rawRoles, onChange}) {
|
||||
function begin(phase) { invalidate(); operation = new AbortController(); emit({phase,preview:null,message:''}); return {signal:operation.signal,version:generation}; }
|
||||
const current = request => !disposed && generation === request.version;
|
||||
const selected = () => state.hosts.find(host=>host.hostId===state.hostId)?.targets.find(target=>target.id===state.targetId);
|
||||
function hostChoice(host) {
|
||||
if (host.targets.length>1) return {hostId:host.hostId,targetId:'',message:`Choose a destination node in ${host.label}.`};
|
||||
const [target] = host.targets;
|
||||
return {hostId:host.hostId,targetId:target.id,message:`Sending to ${target.label} in ${host.label}. Preview changes before applying.`};
|
||||
}
|
||||
return {
|
||||
get state() { return state; },
|
||||
async discover() {
|
||||
if (disposed) return;
|
||||
const request = begin('discovering'); emit({hosts:[],hostId:'',targetId:'',message:'Looking for ComfyUI windows…'});
|
||||
try {
|
||||
const found = await client.discover({signal:request.signal});
|
||||
const found = (await client.discover({signal:request.signal,waitMs:DISCOVERY_WAIT_MS})).map(host=>({...host,targets:host.targets.filter(target=>target && typeof target==='object')}));
|
||||
if (!current(request)) return;
|
||||
const hosts = found.map(host=>({...host,targets:host.targets.filter(target=>typeof target.id==='string' && Array.isArray(target.roles) && Object.keys(roles).every(role=>target.roles.includes(role)))})).filter(host=>host.targets.length);
|
||||
emit({phase:'select',hosts,message:hosts.length ? 'Choose a ComfyUI window and destination node.' : 'No compatible ComfyUI windows found. Open a workflow with a compatible Prompt Manager node, then find windows again.'});
|
||||
const hosts = distinctLabels(found.toSorted((a,b)=>a.hostId<b.hostId?-1:1).map(host=>({...host,targets:distinctLabels(host.targets.filter(target=>accepts(target,roles) && !linkedTo(target,roles).length),(target)=>`${target.label} #${target.id}`)})).filter(host=>host.targets.length),(host,count)=>count>1 ? `${host.label} (window ${count})` : host.label);
|
||||
// Nodes left out are counted so an auto-selected destination never hides the one the user meant.
|
||||
const skipped = found.reduce((total,host)=>total+host.targets.length,0)-hosts.reduce((total,host)=>total+host.targets.length,0);
|
||||
const note = skipped ? ` ${skipped} other Prompt Manager ${skipped===1?'node':'nodes'} cannot receive this prompt.` : '';
|
||||
if (!hosts.length) emit({phase:'select',hosts,message:unavailableMessage(found,roles)});
|
||||
else if (hosts.length>1) emit({phase:'select',hosts,message:`Choose a ComfyUI window.${note}`});
|
||||
else { const choice=hostChoice(hosts[0]); emit({phase:'select',hosts,...choice,message:choice.message+note}); }
|
||||
} catch (error) { if (current(request)) emit({phase:'error',message:error.message}); }
|
||||
},
|
||||
selectHost(hostId) { if (disposed) return; invalidate(); emit({phase:'select',hostId:state.hosts.some(host=>host.hostId===hostId)?hostId:'',targetId:'',preview:null,message:'Choose a destination node, then preview changes.'}); },
|
||||
selectTarget(targetId) { if (disposed) return; invalidate(); const host=state.hosts.find(host=>host.hostId===state.hostId); emit({phase:'select',targetId:host?.targets.some(target=>target.id===targetId)?targetId:'',preview:null,message:'Preview changes before applying.'}); },
|
||||
selectHost(hostId) { if (disposed) return; invalidate(); const host=state.hosts.find(host=>host.hostId===hostId); emit({phase:'select',preview:null,...(host ? hostChoice(host) : {hostId:'',targetId:'',message:'Choose a ComfyUI window.'})}); },
|
||||
selectTarget(targetId) { if (disposed) return; invalidate(); const host=state.hosts.find(host=>host.hostId===state.hostId); const chosen=host?.targets.some(target=>target.id===targetId); emit({phase:'select',targetId:chosen?targetId:'',preview:null,message:chosen?'Preview changes before applying.':'Choose a destination node, then preview changes.'}); },
|
||||
async preview() {
|
||||
if (disposed || !selected() || ['previewing','applying'].includes(state.phase)) return;
|
||||
const request=begin('previewing');
|
||||
@@ -32,7 +75,8 @@ export function createSendSession({client, roles:rawRoles, onChange}) {
|
||||
if (!current(request)) return;
|
||||
const fields=preview?.fields;
|
||||
if(typeof preview?.ticket!=='string' || !preview.ticket || preview.target?.id!==state.targetId || !Array.isArray(fields) || fields.length!==Object.keys(roles).length || new Set(fields.map(field=>field?.role)).size!==fields.length || Array.from(fields).some(field=>!field || !Object.hasOwn(roles,field.role) || typeof field.before!=='string' || field.after!==roles[field.role])) throw Error('ComfyUI returned an unexpected preview. Find the destination and preview again.');
|
||||
emit({phase:'ready',preview,message:'Review the complete changes, then apply.'});
|
||||
const windowLabel=state.hosts.find(host=>host.hostId===state.hostId)?.label;
|
||||
emit({phase:'ready',preview,message:`Sending to ${preview.target.label ?? state.targetId} in ${windowLabel}. Review the complete changes, then apply.`});
|
||||
} catch(error) { if(current(request)) emit({phase:'error',preview:null,message:error.message}); }
|
||||
},
|
||||
async apply() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* System › Integrations (#249 System S10 Task 27): the concept's `renderSystem` "integrations" body (app.js 682–684).
|
||||
*
|
||||
* - "ComfyUI": Default send destination, Host compatibility and Return to canvas after send. D106 (Q8): the send
|
||||
* bridge always asks for the window and node, and the nodes always follow ComfyUI's own renderer, so only "Ask each
|
||||
* bridge asks for the window and node when there is a choice, and the nodes always follow ComfyUI's own renderer, so only "Ask each
|
||||
* time" and "Follow host renderer" can be chosen; the other options and the return toggle are disabled, with the
|
||||
* reason under the row, and nothing is stored for them. Host compatibility also says which renderer the open
|
||||
* ComfyUI windows use, as the send bridge reports it (`renderer` on each Prompt Manager node it lists).
|
||||
@@ -42,7 +42,7 @@ export const LORA_KEYS = Object.freeze(['lora_enabled', 'lora_trigger_words_enab
|
||||
|
||||
/** D106: why the options the bridge cannot honour are disabled. */
|
||||
export const REASONS = Object.freeze({
|
||||
send: 'Sending always asks for the ComfyUI window and node, so “Ask each time” is the only option.',
|
||||
send: 'Sending asks when there is more than one ComfyUI window or node and always previews, so “Ask each time” is the only option.',
|
||||
renderer: 'Prompt Manager nodes always follow ComfyUI’s own renderer setting.',
|
||||
returnCanvas: 'Not available: sending applies the text in ComfyUI and keeps this page open.',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user