fix(nodes): linked text review round for #343
- A legacy frontend Primitive linked to an editor's text or the Pair's negative now runs with its value: the graphToPrompt wrapper and the queue hook fill it in (through virtual reroutes), in new and pre-#343 workflows. - Moving a linked text and releasing it on the editor keeps it on `text`: a link being moved counts as free, as the frontend counts it. - A link dropped on a prompt box (or its link note) fills that box's input, so the Pair's Negative prompt box takes `negative_text` (Classic through getInputOnPos, Nodes 2.0 through the box's slot key). - Only exact STRING links are steered; wildcards keep the frontend choice. - A linked Classic text box gives its row back and gets it back on disconnect; the PromptManagerText note uses the theme colour. - The metadata parsers map positional widget values through the widget table when an editor's widget-bound inputs do not line up with it. - Real-host cases for the legacy Primitive (new and saved workflows, a real Run), the moved link, the Negative box, paste/clone/undo and Split.
This commit is contained in:
+32
-8
@@ -3870,25 +3870,49 @@ as it drives any core widget. The Focused, Split and Pair editors hide their wid
|
||||
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).
|
||||
link dropped on a prompt box (or its link note) fills that box's input: Nodes 2.0 resolves
|
||||
it through the box's `.lg-node-widget` and `[data-slot-key]`, Classic through the node's
|
||||
`getInputOnPos`. An exact STRING link dropped elsewhere on the editor fills the main prompt
|
||||
first, then the negative, before the frontend's own choice (it used to pick `prepend_text`,
|
||||
which caused #342). A link being moved off a text input counts as free, as the frontend
|
||||
counts it, so dropping it back on the editor keeps it on `text`. Wildcard links (a
|
||||
frontend Primitive before it is typed) are not steered.
|
||||
|
||||
The frontend's legacy Primitive is virtual: it writes its value into the target widget,
|
||||
which the editor text no longer is. The `graphToPrompt` wrapper and the queue hook put
|
||||
the Primitive's value (through virtual reroutes) into the queued `text`/`negative_text`,
|
||||
so a Primitive drives the text as before, also in workflows saved before #343. A linked
|
||||
Classic text box gives its canvas row back while linked (the core keeps an empty gap) and
|
||||
gets its size back on disconnect. The metadata parsers map a known node's positional
|
||||
widget values through its widget table when its widget-bound inputs do not line up with
|
||||
it (the editors' text inputs are sockets now).
|
||||
|
||||
Not covered, by ruling: linking a subgraph input to an inner editor's `text` gives the
|
||||
subgraph a plain STRING socket, not a promoted typeable widget as a core widget input
|
||||
would. The socket was unreachable before #343, so nothing regressed.
|
||||
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/js/text-links.test.js`: socket exposure, drop preference (exact STRING, moved
|
||||
links), box hit-test, Primitive values through reroutes, stored values, the Classic row.
|
||||
- `tests/test_generation_params.py` (`workflow-only-detached-*` cases, with JS parity
|
||||
vectors): editor text inputs without widget bindings keep their positional 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.
|
||||
disconnect and node removal. `fixtures/workflow-before-343.json` and
|
||||
`workflow-before-343-primitive.json` were serialized by the pre-#343 code on the pinned
|
||||
host and must load unchanged; the second one's legacy Primitives keep driving the text
|
||||
through a real Run. Also: a Primitive dropped on the Main prompt box and a string on the
|
||||
Negative prompt box, a moved link released on the editor body, and paste, clone and undo.
|
||||
- `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
|
||||
real frontend through the sockets, with text from `PrimitiveString`, the legacy
|
||||
Primitive or through `PreviewAny`, for PromptManager, PromptManagerText, Focused, Split
|
||||
and the Pair (positive, negative, both, neither linked, and a Primitive negative). 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
|
||||
|
||||
@@ -147,6 +147,23 @@ test('paired editor offers positive and negative link sockets and keeps both typ
|
||||
await expect(positive).toHaveValue('Typed positive');await expect(negative).toHaveValue('Typed negative');
|
||||
});
|
||||
|
||||
test('a link dropped on either prompt box, or its note, fills that box (#343 review M1)',async({page})=>{
|
||||
const keys=await page.evaluate(()=>[...document.querySelectorAll('.pm-pair .lg-node-widget [data-slot-key]')].map(key=>key.dataset.slotKey));
|
||||
// Nodes 2.0 resolves a drop through the nearest .lg-node-widget and its slot key: positive box and note, negative box and note.
|
||||
expect(keys).toEqual(['1-in-3','1-in-3','1-in-4','1-in-4']);
|
||||
const at=(name,connecting=true)=>page.evaluate(({name,connecting})=>{
|
||||
fixtureApp.canvas={linkConnector:{state:{connectingTo:connecting?'input':undefined}},ds:{convertOffsetToCanvas:point=>point},canvas:{getBoundingClientRect:()=>({left:0,top:0})}};
|
||||
const box=[...document.querySelectorAll('.pm-pair .lg-node-widget')].find(element=>element.getClientRects().length&&element.textContent.includes(name)).getBoundingClientRect();
|
||||
return node.getInputOnPos([box.left+box.width/2,box.top+box.height/2])?.name??null;
|
||||
},{name,connecting});
|
||||
expect(await at('Negative prompt')).toBe('negative_text');
|
||||
expect(await at('Positive prompt')).toBe('text');
|
||||
expect(await at('Negative prompt',false)).toBeNull();
|
||||
await page.evaluate(()=>{node.inputs.find(input=>input.name==='negative_text').link=4;node.onConnectionsChange();});
|
||||
expect(await at('Negative prompt comes from a link')).toBe('negative_text');
|
||||
expect(await page.evaluate(()=>{fixtureApp.canvas={};return node.getInputOnPos([1,1])??null;})).toBeNull();
|
||||
});
|
||||
|
||||
test('queue hook synchronizes both editors, preserves links and forwards branch execution arguments',async({page})=>{
|
||||
await draft(page);
|
||||
const args=await page.evaluate(async()=>{
|
||||
|
||||
@@ -13,8 +13,9 @@ import { installHostHooks } from './host-fixture.mjs';
|
||||
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.
|
||||
// `primitive`: a core string node the queue-time resolver follows. `legacy`: the frontend's virtual Primitive node,
|
||||
// whose value is queued as literal text. `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');
|
||||
@@ -29,6 +30,13 @@ function build(page, { kind, positive, negative, prefix }) {
|
||||
if (negative) set('negative_text', negative.typed);
|
||||
const drive = (name, side, y) => {
|
||||
if (!side.via) return;
|
||||
if (side.via === 'legacy') {
|
||||
// The frontend's own Primitive: a virtual node whose value is applied when the workflow is queued.
|
||||
const primitive = add('PrimitiveNode', 0, y);
|
||||
connect(primitive, 0, prompt, name);
|
||||
primitive.widgets[0].value = side.text;
|
||||
return;
|
||||
}
|
||||
const text = add('PrimitiveString', 0, y);
|
||||
text.widgets.find(widget => widget.name === 'value').value = side.text;
|
||||
let source = text;
|
||||
@@ -121,14 +129,17 @@ async function expectImagesLinked(execution, evidence, viaRuntime) {
|
||||
|
||||
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 }) => {
|
||||
const sources = { primitive: 'PrimitiveString', legacy: 'a legacy Primitive', preview: 'PreviewAny' };
|
||||
for (const kind of ['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit']) {
|
||||
for (const via of Object.keys(sources)) {
|
||||
test(`${kind} saves, counts, records and links images to text linked from ${sources[via]}`, 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);
|
||||
// A real node is queued as a link; the virtual Primitive as its own value, never the hidden typed text.
|
||||
if (via === 'legacy') expect(source).toBe(text);
|
||||
else 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 }]);
|
||||
@@ -139,16 +150,21 @@ for (const kind of ['PromptManager', 'PromptManagerText', 'PromptManagerFocused'
|
||||
}
|
||||
}
|
||||
|
||||
for (const [label, linked] of [['positive', ['text']], ['negative', ['negative_text']], ['both', ['text', 'negative_text']], ['neither', []]]) {
|
||||
for (const [label, linked, negativeVia] of [['positive', ['text'], 'primitive'], ['negative', ['negative_text'], 'primitive'],
|
||||
['both', ['text', 'negative_text'], 'primitive'], ['neither', [], 'primitive'], ['negative from a legacy Primitive', ['negative_text'], 'legacy']]) {
|
||||
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 },
|
||||
negative_text: { typed: unique('Typed negative'), text: unique('Linked negative'), via: linked.includes('negative_text') ? negativeVia : 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));
|
||||
for (const name of ['text', 'negative_text']) {
|
||||
const queuedValue = evidence.output[evidence.promptNode].inputs[name];
|
||||
if (sides[name].via === 'legacy') expect(queuedValue, name).toBe(sides[name].text);
|
||||
else expect(Array.isArray(queuedValue), 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 }]);
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
{
|
||||
"id": "37b6baef-94f5-49d2-9b42-1ffc70e7da92",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PromptManagerFocused",
|
||||
"pos": [
|
||||
600,
|
||||
0
|
||||
],
|
||||
"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": 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 fed by a Primitive",
|
||||
"properties": {
|
||||
"Node name for S&R": "PromptManagerFocused",
|
||||
"focusedUI": {
|
||||
"libraryOpen": false,
|
||||
"libraryFilters": {
|
||||
"category": "",
|
||||
"tags": ""
|
||||
},
|
||||
"composition": false,
|
||||
"metadata": false,
|
||||
"preview": false
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"Primitive focused text",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
""
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"text": "Primitive focused text",
|
||||
"category": "",
|
||||
"tags": "",
|
||||
"search_text": "",
|
||||
"prepend_text": "",
|
||||
"append_text": "",
|
||||
"prompt_manager_focused_ui": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PromptManagerPair",
|
||||
"pos": [
|
||||
600,
|
||||
1200
|
||||
],
|
||||
"size": [
|
||||
1100,
|
||||
700
|
||||
],
|
||||
"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": "negative_text",
|
||||
"name": "negative_text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "negative_text"
|
||||
},
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"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 negative fed by a Primitive",
|
||||
"properties": {
|
||||
"Node name for S&R": "PromptManagerPair",
|
||||
"pairUI": {
|
||||
"libraryOpen": true,
|
||||
"libraryFilters": {
|
||||
"category": "",
|
||||
"tags": ""
|
||||
},
|
||||
"composition": false,
|
||||
"metadata": false,
|
||||
"preview": false
|
||||
}
|
||||
},
|
||||
"widgets_values": [
|
||||
"Typed pair positive",
|
||||
"Primitive pair negative",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
""
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"text": "Typed pair positive",
|
||||
"negative_text": "Primitive pair negative",
|
||||
"category": "",
|
||||
"tags": "",
|
||||
"search_text": "",
|
||||
"prepend_text": "",
|
||||
"append_text": "",
|
||||
"prompt_manager_focused_ui": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
88
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"links": [
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": [
|
||||
"Primitive focused text"
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"text": "Primitive focused text"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": [
|
||||
0,
|
||||
1200
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
88
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "negative_text"
|
||||
},
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": [
|
||||
"Primitive pair negative"
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"negative_text": "Primitive pair negative"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
"STRING"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.9,
|
||||
"offset": [
|
||||
416,
|
||||
110
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -140,6 +140,137 @@ for (const type of ['PromptManagerFocused', 'PromptManagerSplit', 'PromptManager
|
||||
});
|
||||
}
|
||||
|
||||
async function addEditors(page) {
|
||||
return page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const add = (type, x, y) => { const node = LiteGraph.createNode(type); app.graph.add(node); node.pos = [x, y]; return node; };
|
||||
const focused = add('PromptManagerFocused', 420, 40);
|
||||
focused.setSize([560, 640]);
|
||||
const pair = add('PromptManagerPair', 1020, 40);
|
||||
pair.setSize([1000, 640]);
|
||||
for (const [node, name, value] of [[focused, 'text', 'Typed focused'], [pair, 'text', 'Typed positive'], [pair, 'negative_text', 'Typed negative']]) {
|
||||
const widget = node.widgets.find(item => item.name === name);
|
||||
widget.value = value;
|
||||
if (widget.inputEl) widget.inputEl.value = value;
|
||||
}
|
||||
const sources = [0, 1].map(index => add(index ? 'PrimitiveString' : 'PrimitiveNode', 20, 60 + index * 300).id);
|
||||
app.canvas.ds.scale = .7;
|
||||
// Clear of the floating graph menu (top left) and the PM run bar (top right).
|
||||
app.canvas.ds.offset = [110, 150];
|
||||
app.canvas.setDirty(true, true);
|
||||
return { focused: focused.id, pair: pair.id, primitive: sources[0], string: sources[1] };
|
||||
});
|
||||
}
|
||||
|
||||
/** The prompt a Run sends to the server, through the real app.queuePrompt path. */
|
||||
async function runPrompt(page) {
|
||||
const sent = new Promise(resolve => page.route('**/prompt', async route => {
|
||||
if (route.request().method() !== 'POST') return route.fallback();
|
||||
resolve(route.request().postDataJSON().prompt);
|
||||
return route.fulfill({ json: { prompt_id: 'captured', number: 1, node_errors: {} } });
|
||||
}));
|
||||
await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); await app.queuePrompt(0, 1); });
|
||||
const prompt = await sent;
|
||||
await page.unroute('**/prompt');
|
||||
return prompt;
|
||||
}
|
||||
|
||||
const box = (page, name, nth = 0) => page.getByRole('textbox', { name, exact: true }).nth(nth);
|
||||
|
||||
test('a legacy Primitive dropped on an editor box or socket drives the text it shows as linked (#343 review I1, M1)', async ({ page }) => {
|
||||
test.setTimeout(90000);
|
||||
const ids = await addEditors(page);
|
||||
// A wildcard Primitive aimed at the Main prompt box, a PrimitiveString aimed at the Negative prompt box.
|
||||
await drag(page, await slotPosition(page, ids.primitive, 'out', 0), center(await box(page, 'Main prompt').boundingBox()));
|
||||
await expect.poll(() => linkedInputs(page, ids.focused)).toEqual(['text']);
|
||||
await drag(page, await slotPosition(page, ids.string, 'out', 0), center(await box(page, 'Negative prompt').boundingBox()));
|
||||
await expect.poll(() => linkedInputs(page, ids.pair)).toEqual(['negative_text']);
|
||||
await expect(page.getByText(note('Main prompt'), { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(note('Negative prompt'), { exact: true })).toBeVisible();
|
||||
await page.evaluate(async ({ primitive, string }) => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph.getNodeById(primitive).widgets[0].value = 'From the legacy Primitive';
|
||||
app.graph.getNodeById(string).widgets.find(widget => widget.name === 'value').value = 'From PrimitiveString';
|
||||
}, ids);
|
||||
const prompt = await runPrompt(page);
|
||||
expect(prompt[String(ids.focused)].inputs.text).toBe('From the legacy Primitive');
|
||||
expect(prompt[String(ids.pair)].inputs).toMatchObject({ text: 'Typed positive', negative_text: [String(ids.string), 0] });
|
||||
// The Primitive is re-read on every run; the typed text stays for disconnect.
|
||||
await page.evaluate(async ({ primitive }) => { const { app } = await import('/scripts/app.js'); app.graph.getNodeById(primitive).widgets[0].value = 'Changed in the Primitive'; }, ids);
|
||||
expect((await queued(page, ids.focused)).text).toBe('Changed in the Primitive');
|
||||
await page.evaluate(async ({ focused }) => { const { app } = await import('/scripts/app.js'); const node = app.graph.getNodeById(focused); node.disconnectInput(node.inputs.findIndex(input => input.name === 'text')); }, ids);
|
||||
expect((await queued(page, ids.focused)).text).toBe('Typed focused');
|
||||
});
|
||||
|
||||
test('a workflow saved before #343 with legacy Primitives on editor text keeps running their values (#343 review I1)', async ({ page }) => {
|
||||
const workflow = JSON.parse(await readFile(new URL('./fixtures/workflow-before-343-primitive.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 before = (await app.graphToPrompt()).output;
|
||||
app.graph.getNodeById(4).widgets[0].value = 'Edited after the load';
|
||||
const after = (await app.graphToPrompt()).output;
|
||||
const bound = id => app.graph.getNodeById(id).inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).length;
|
||||
return { before: [before['1'].inputs.text, before['2'].inputs.text, before['2'].inputs.negative_text], after: after['2'].inputs.negative_text, bound: [bound(1), bound(2)],
|
||||
links: [app.graph.getNodeById(1).inputs.find(input => input.name === 'text').link != null, app.graph.getNodeById(2).inputs.find(input => input.name === 'negative_text').link != null] };
|
||||
}, workflow);
|
||||
expect(result).toEqual({ before: ['Primitive focused text', 'Typed pair positive', 'Primitive pair negative'], after: 'Edited after the load', bound: [0, 0], links: [true, true] });
|
||||
const prompt = await runPrompt(page);
|
||||
expect([prompt['1'].inputs.text, prompt['2'].inputs.negative_text]).toEqual(['Primitive focused text', 'Edited after the load']);
|
||||
});
|
||||
|
||||
test('moving a linked text and dropping it on the editor body keeps it on text, never prepend_text (#343 review I2)', async ({ page }) => {
|
||||
test.setTimeout(90000);
|
||||
const ids = await addEditors(page);
|
||||
await page.evaluate(async ({ focused, pair, string }) => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const source = app.graph.getNodeById(string);
|
||||
for (const id of [focused, pair]) { const node = app.graph.getNodeById(id); source.connect(0, node, node.inputs.findIndex(input => input.name === 'text')); }
|
||||
app.canvas.setDirty(true, true);
|
||||
}, ids);
|
||||
for (const [id, title] of [[ids.focused, 0], [ids.pair, 1]]) {
|
||||
// Grab the link at the text socket and release it over the editor's toolbar, away from any prompt box.
|
||||
const toolbar = page.locator('.pm-editor-toolbar strong').nth(title);
|
||||
await drag(page, await slotPosition(page, id, 'in', 'text'), center(await toolbar.boundingBox()));
|
||||
await expect.poll(() => linkedInputs(page, id)).toEqual(['text']);
|
||||
}
|
||||
});
|
||||
|
||||
test('paste, clone and undo keep the editor text a linked socket (#343 review M7)', async ({ page }) => {
|
||||
const ids = await addEditors(page);
|
||||
const state = await page.evaluate(async ({ pair, string }) => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph.getNodeById(pair);
|
||||
app.graph.getNodeById(string).connect(0, node, node.inputs.findIndex(input => input.name === 'text'));
|
||||
const describe = target => ({ bound: target.inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).length,
|
||||
linked: target.inputs.find(input => input.name === 'text').link != null });
|
||||
app.canvas.selectItems([node]);
|
||||
app.canvas.copyToClipboard();
|
||||
app.canvas.pasteFromClipboard({ connectInputs: true });
|
||||
const pasted = app.graph._nodes.find(item => item.type === 'PromptManagerPair' && item.id !== pair);
|
||||
const clone = node.clone();
|
||||
app.graph.add(clone);
|
||||
const output = (await app.graphToPrompt()).output;
|
||||
return { pasted: describe(pasted), clone: describe(clone), queued: output[String(pasted.id)].inputs.text, pastedId: pasted.id };
|
||||
}, ids);
|
||||
expect(state).toMatchObject({ pasted: { bound: 0, linked: true }, clone: { bound: 0, linked: false }, queued: [String(ids.string), 0] });
|
||||
// Undo restores the graph through a workflow load: the restored link is still a socket link.
|
||||
const undone = await page.evaluate(async ({ pair }) => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const tracker = app.extensionManager?.workflow?.activeWorkflow?.changeTracker;
|
||||
if (!tracker) throw new Error('no change tracker on the active workflow');
|
||||
tracker.checkState();
|
||||
const node = app.graph.getNodeById(pair);
|
||||
node.disconnectInput(node.inputs.findIndex(input => input.name === 'text'));
|
||||
tracker.checkState();
|
||||
await tracker.undo();
|
||||
const restored = app.graph.getNodeById(pair);
|
||||
return { bound: restored.inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).length,
|
||||
text: (await app.graphToPrompt()).output[String(pair)].inputs.text };
|
||||
}, ids);
|
||||
expect(undone).toEqual({ bound: 0, text: [String(ids.string), 0] });
|
||||
});
|
||||
|
||||
test('PromptSearchList text is a core widget socket and takes a dragged STRING link too', async ({ page }) => {
|
||||
const { id, sources } = await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
|
||||
+34
@@ -186,6 +186,40 @@
|
||||
"prompt": null,
|
||||
"workflow": "{\"nodes\": [{\"id\": 4, \"type\": \"CheckpointLoaderSimple\", \"mode\": 0, \"inputs\": [], \"widgets_values\": [\"example.safetensors\"]}, {\"id\": 6, \"type\": \"CLIPTextEncode\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 3}], \"widgets_values\": [\"a tidepool at noon\"]}, {\"id\": 7, \"type\": \"CLIPTextEncode\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 5}], \"widgets_values\": [\"noisy\"]}, {\"id\": 10, \"type\": \"Reroute\", \"mode\": 0, \"inputs\": [{\"name\": \"\", \"link\": 7}]}, {\"id\": 3, \"type\": \"KSampler\", \"mode\": 0, \"inputs\": [{\"name\": \"model\", \"link\": 1}, {\"name\": \"positive\", \"link\": 8}, {\"name\": \"negative\", \"link\": 4}, {\"name\": \"latent_image\", \"link\": 2}], \"widgets_values\": [314, \"randomize\", 25, 6.5, \"dpmpp_2m\", \"karras\", 1]}, {\"id\": 11, \"type\": \"CLIPTextEncode\", \"mode\": 2, \"inputs\": [], \"widgets_values\": [\"muted node text\"]}], \"links\": [[1, 4, 0, 3, 0, \"MODEL\"], [3, 4, 1, 6, 0, \"CLIP\"], [5, 4, 1, 7, 0, \"CLIP\"], [7, 6, 0, 10, 0, \"CONDITIONING\"], [8, 10, 0, 3, 1, \"CONDITIONING\"], [4, 7, 0, 3, 2, \"CONDITIONING\"], {\"id\": 2, \"origin_id\": 99, \"origin_slot\": 0, \"target_id\": 3, \"target_slot\": 3, \"type\": \"LATENT\"}]}"
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": {
|
||||
"cfgScale": 5,
|
||||
"checkpoint": "example.safetensors",
|
||||
"negativePrompt": "plain negative",
|
||||
"positivePrompt": "a detached positive",
|
||||
"sampler": "euler",
|
||||
"seed": 7,
|
||||
"steps": 12
|
||||
}
|
||||
},
|
||||
"name": "case:workflow-only-detached-editor-text",
|
||||
"prompt": null,
|
||||
"workflow": "{\"nodes\": [{\"id\": 4, \"type\": \"CheckpointLoaderSimple\", \"mode\": 0, \"inputs\": [], \"widgets_values\": [\"example.safetensors\"]}, {\"id\": 6, \"type\": \"PromptManagerFocused\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 3}, {\"name\": \"prepend_text\", \"link\": null}, {\"name\": \"append_text\", \"link\": null}, {\"name\": \"text\", \"link\": null}, {\"name\": \"category\", \"widget\": {\"name\": \"category\"}, \"link\": null}, {\"name\": \"tags\", \"widget\": {\"name\": \"tags\"}, \"link\": null}, {\"name\": \"search_text\", \"widget\": {\"name\": \"search_text\"}, \"link\": null}], \"widgets_values\": [\"a detached positive\", \"a category\", \"a tag\", \"\", \"\", \"\"]}, {\"id\": 7, \"type\": \"CLIPTextEncode\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 5}], \"widgets_values\": [\"plain negative\"]}, {\"id\": 3, \"type\": \"KSampler\", \"mode\": 0, \"inputs\": [{\"name\": \"model\", \"link\": 1}, {\"name\": \"positive\", \"link\": 8}, {\"name\": \"negative\", \"link\": 4}], \"widgets_values\": [7, \"fixed\", 12, 5, \"euler\", \"normal\", 1]}], \"links\": [[1, 4, 0, 3, 0, \"MODEL\"], [3, 4, 1, 6, 0, \"CLIP\"], [5, 4, 1, 7, 0, \"CLIP\"], [8, 6, 0, 3, 1, \"CONDITIONING\"], [4, 7, 0, 3, 2, \"CONDITIONING\"]]}"
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"ok": true,
|
||||
"result": {
|
||||
"cfgScale": 5,
|
||||
"checkpoint": "example.safetensors",
|
||||
"negativePrompt": "a detached negative",
|
||||
"positivePrompt": "a detached positive",
|
||||
"sampler": "euler",
|
||||
"seed": 7,
|
||||
"steps": 12
|
||||
}
|
||||
},
|
||||
"name": "case:workflow-only-detached-pair-text",
|
||||
"prompt": null,
|
||||
"workflow": "{\"nodes\": [{\"id\": 4, \"type\": \"CheckpointLoaderSimple\", \"mode\": 0, \"inputs\": [], \"widgets_values\": [\"example.safetensors\"]}, {\"id\": 6, \"type\": \"PromptManagerPair\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 3}, {\"name\": \"prepend_text\", \"link\": null}, {\"name\": \"append_text\", \"link\": null}, {\"name\": \"text\", \"link\": null}, {\"name\": \"negative_text\", \"link\": null}, {\"name\": \"category\", \"widget\": {\"name\": \"category\"}, \"link\": null}, {\"name\": \"tags\", \"widget\": {\"name\": \"tags\"}, \"link\": null}, {\"name\": \"search_text\", \"widget\": {\"name\": \"search_text\"}, \"link\": null}], \"widgets_values\": [\"a detached positive\", \"a detached negative\", \"a category\", \"a tag\", \"\", \"\", \"\"]}, {\"id\": 3, \"type\": \"KSampler\", \"mode\": 0, \"inputs\": [{\"name\": \"model\", \"link\": 1}, {\"name\": \"positive\", \"link\": 8}, {\"name\": \"negative\", \"link\": 4}], \"widgets_values\": [7, \"fixed\", 12, 5, \"euler\", \"normal\", 1]}], \"links\": [[1, 4, 0, 3, 0, \"MODEL\"], [3, 4, 1, 6, 0, \"CLIP\"], [5, 4, 1, 7, 0, \"CLIP\"], [8, 6, 0, 3, 1, \"CONDITIONING\"], [4, 6, 1, 3, 2, \"CONDITIONING\"]]}"
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"ok": true,
|
||||
|
||||
@@ -244,6 +244,74 @@ def _workflow_only():
|
||||
}
|
||||
|
||||
|
||||
def _detached_editor_text(pair):
|
||||
"""#343: editor text inputs are sockets, not widget inputs; no named values."""
|
||||
names = ["text"] + (["negative_text"] if pair else [])
|
||||
sockets = [
|
||||
{"name": "clip", "link": 3},
|
||||
{"name": "prepend_text", "link": None},
|
||||
{"name": "append_text", "link": None},
|
||||
]
|
||||
sockets += [{"name": name, "link": None} for name in names]
|
||||
sockets += [
|
||||
{"name": name, "widget": {"name": name}, "link": None}
|
||||
for name in ("category", "tags", "search_text")
|
||||
]
|
||||
editor = {
|
||||
"id": 6,
|
||||
"type": "PromptManagerPair" if pair else "PromptManagerFocused",
|
||||
"mode": 0,
|
||||
"inputs": sockets,
|
||||
"widgets_values": ["a detached positive"]
|
||||
+ (["a detached negative"] if pair else [])
|
||||
+ ["a category", "a tag", "", "", ""],
|
||||
}
|
||||
negative = (
|
||||
[]
|
||||
if pair
|
||||
else [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "clip", "link": 5}],
|
||||
"widgets_values": ["plain negative"],
|
||||
}
|
||||
]
|
||||
)
|
||||
return {
|
||||
"nodes": [
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"widgets_values": ["example.safetensors"],
|
||||
},
|
||||
editor,
|
||||
*negative,
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "link": 1},
|
||||
{"name": "positive", "link": 8},
|
||||
{"name": "negative", "link": 4},
|
||||
],
|
||||
"widgets_values": [7, "fixed", 12, 5, "euler", "normal", 1],
|
||||
},
|
||||
],
|
||||
"links": [
|
||||
[1, 4, 0, 3, 0, "MODEL"],
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[8, 6, 0, 3, 1, "CONDITIONING"],
|
||||
[4, 6 if pair else 7, 1 if pair else 0, 3, 2, "CONDITIONING"],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _nested(depth):
|
||||
"""A value nested ``depth`` arrays deep, as a crafted file can hold."""
|
||||
value = ["leaf", 7]
|
||||
@@ -389,6 +457,8 @@ CASES = {
|
||||
}
|
||||
),
|
||||
"workflow-only": case(workflow=_workflow_only()),
|
||||
"workflow-only-detached-editor-text": case(workflow=_detached_editor_text(False)),
|
||||
"workflow-only-detached-pair-text": case(workflow=_detached_editor_text(True)),
|
||||
"overwritten-prompt-manager-text": case(
|
||||
{
|
||||
"4": CHECKPOINT,
|
||||
|
||||
@@ -65,14 +65,55 @@ test('a STRING link dropped on the editor prefers the first free text input over
|
||||
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 () => {
|
||||
test('only exact STRING links are steered; wildcards keep the frontend choice (#343 review M2)', 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));
|
||||
for (const type of ['STRING','STRING,INT',' STRING ']) assert.equal(preferredTextSlot(node, type, ['text'])?.index, 3, type);
|
||||
for (const type of ['*','','CLIP','INT','IMAGE',undefined,null,0]) assert.equal(preferredTextSlot(node, type, ['text']), undefined, String(type));
|
||||
assert.equal(preferredTextSlot({}, 'STRING', ['text']), undefined);
|
||||
});
|
||||
|
||||
test('a link being moved off a text input leaves it free, as the frontend counts it (#343 review I2)', async () => {
|
||||
const {preferredTextSlot} = await source;
|
||||
const links = new Map([[7,{id:7,_dragging:true}],[8,{id:8}]]);
|
||||
const node = Object.assign(pairNode(), {graph:{getLink:id => links.get(id)}});
|
||||
node.inputs[3].link = 7;
|
||||
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']).slot.name, 'text', 'the dragged link does not block text');
|
||||
links.get(7)._dragging = false;
|
||||
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']).slot.name, 'negative_text');
|
||||
node.inputs[4].link = 8;
|
||||
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']), undefined);
|
||||
node.inputs[4].link = 99;
|
||||
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']).slot.name, 'negative_text', 'a link the graph no longer has is free');
|
||||
const legacy = Object.assign(pairNode(), {graph:{links:{5:{id:5,_dragging:true}}}});
|
||||
legacy.inputs[3].link = 5;
|
||||
assert.equal(preferredTextSlot(legacy, 'STRING', ['text']).slot.name, 'text', 'object-keyed link tables too');
|
||||
});
|
||||
|
||||
test('a legacy frontend Primitive feeding a text input supplies its value (#343 review I1)', async () => {
|
||||
const {primitiveTextValue} = await source;
|
||||
const nodes = new Map();
|
||||
const links = new Map();
|
||||
const graph = {getNodeById:id => nodes.get(id), getLink:id => links.get(id)};
|
||||
const add = (id, node) => { nodes.set(id, {id, graph, ...node}); return nodes.get(id); };
|
||||
const target = add(1, {inputs:[{name:'text',link:10},{name:'negative_text',link:11},{name:'prepend_text',link:12},{name:'category',link:null}]});
|
||||
add(2, {type:'PrimitiveNode', isVirtualNode:true, widgets:[{value:'From the primitive'}]});
|
||||
add(3, {type:'Reroute', isVirtualNode:true, inputs:[{link:13}]});
|
||||
add(4, {type:'PrimitiveNode', isVirtualNode:true, widgets:[{value:42}]});
|
||||
add(5, {type:'PrimitiveStringMultiline', widgets:[{value:'a real node'}]});
|
||||
links.set(10, {origin_id:2}); links.set(11, {origin_id:3}); links.set(13, {origin_id:4}); links.set(12, {origin_id:5});
|
||||
assert.equal(primitiveTextValue(target, 'text'), 'From the primitive');
|
||||
assert.equal(primitiveTextValue(target, 'negative_text'), '42', 'through a virtual reroute');
|
||||
assert.equal(primitiveTextValue(target, 'prepend_text'), undefined, 'a real node is serialized as a link');
|
||||
assert.equal(primitiveTextValue(target, 'category'), undefined, 'not linked');
|
||||
assert.equal(primitiveTextValue(target, 'missing'), undefined);
|
||||
nodes.get(2).widgets = [];
|
||||
assert.equal(primitiveTextValue(target, 'text'), undefined, 'a primitive without its widget yet');
|
||||
links.set(13, {origin_id:3});
|
||||
assert.equal(primitiveTextValue(target, 'negative_text'), undefined, 'a reroute cycle ends');
|
||||
assert.equal(primitiveTextValue({inputs:[{name:'text',link:1}]}, 'text'), undefined, 'no graph');
|
||||
});
|
||||
|
||||
test('installing the preference wraps findInputByType and restores nothing it did not own', async () => {
|
||||
const {preferTextInputs} = await source;
|
||||
const calls = [];
|
||||
@@ -89,3 +130,32 @@ test('installing the preference wraps findInputByType and restores nothing it di
|
||||
preferTextInputs(bare, ['text']);
|
||||
assert.equal(bare.findInputByType, undefined, 'an older frontend without findInputByType is left unchanged');
|
||||
});
|
||||
|
||||
test('a drop point over a visible prompt box or its note picks that box input (#343 review M1)', async () => {
|
||||
const {textInputAtPoint} = await source;
|
||||
const box = (left, top, width, height) => ({getBoundingClientRect:() => ({left, top, width, height, right:left + width, bottom:top + height})});
|
||||
const node = pairNode();
|
||||
const targets = [{name:'text',element:box(0,0,100,100)},{name:'negative_text',element:box(120,0,100,100)},{name:'text',element:box(0,0,0,0)},{name:'missing',element:box(300,0,50,50)}];
|
||||
assert.equal(textInputAtPoint(node, [150,50], targets).name, 'negative_text');
|
||||
assert.equal(textInputAtPoint(node, [10,10], targets).name, 'text');
|
||||
assert.equal(textInputAtPoint(node, [110,50], targets), undefined, 'between the boxes');
|
||||
assert.equal(textInputAtPoint(node, [320,10], targets), undefined, 'a target whose input the node lacks');
|
||||
assert.equal(textInputAtPoint(node, [0,0], [{name:'text',element:box(0,0,0,0)}]), undefined, 'a hidden target');
|
||||
});
|
||||
|
||||
test('a linked Classic text box gives its row back and gets its size back on disconnect (#343 review M5)', async () => {
|
||||
const {collapseLinkedWidget} = await source;
|
||||
const own = () => 120;
|
||||
const widget = {options:{getMinHeight:own}};
|
||||
collapseLinkedWidget(widget, true);
|
||||
assert.equal(widget.options.getMinHeight(), 0);
|
||||
assert.equal(widget.options.getMaxHeight(), 0);
|
||||
collapseLinkedWidget(widget, true);
|
||||
collapseLinkedWidget(widget, false);
|
||||
assert.equal(widget.options.getMinHeight, own);
|
||||
assert.equal('getMaxHeight' in widget.options, false, 'an option it did not have is removed again');
|
||||
collapseLinkedWidget(widget, false);
|
||||
assert.equal(widget.options.getMinHeight, own);
|
||||
collapseLinkedWidget(undefined, true);
|
||||
collapseLinkedWidget({}, true);
|
||||
});
|
||||
|
||||
@@ -82,6 +82,17 @@ class TracesPrompts(unittest.TestCase):
|
||||
self.assertEqual(result["positivePrompt"], "the real positive")
|
||||
self.assertEqual(result["negativePrompt"], "the real negative")
|
||||
|
||||
def test_editor_text_inputs_without_widget_bindings_keep_their_positions(self):
|
||||
# #343: Focused/Split/Pair text inputs are plain sockets. The widget-bound
|
||||
# inputs that remain (category, tags, search_text) must not shift the
|
||||
# positional widget values onto the wrong names.
|
||||
result = run("workflow-only-detached-editor-text")
|
||||
self.assertEqual(result["positivePrompt"], "a detached positive")
|
||||
self.assertEqual(result["negativePrompt"], "plain negative")
|
||||
result = run("workflow-only-detached-pair-text")
|
||||
self.assertEqual(result["positivePrompt"], "a detached positive")
|
||||
self.assertEqual(result["negativePrompt"], "a detached negative")
|
||||
|
||||
|
||||
class ChoosesTheSampler(unittest.TestCase):
|
||||
def test_first_sampler_follows_javascript_key_order(self):
|
||||
|
||||
Vendored
+5
-4
@@ -23,21 +23,22 @@ 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)
|
||||
// + #343 linked text: five node types, PromptSearchList and the pre-#343 workflow (text-link.spec.mjs, 2 projects)
|
||||
assert.match(stock, /Total: 98 tests in 9 files/);
|
||||
// + #343 linked text: five node types, PromptSearchList, the pre-#343 workflows, legacy Primitive, moved link,
|
||||
// paste/clone/undo (text-link.spec.mjs, 2 projects)
|
||||
assert.match(stock, /Total: 106 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: 68 tests in 4 files/); // + the fresh-host library location case, + #343 linked text
|
||||
assert.match(executions, /Total: 82 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.equal(cases.filter(line => line.includes('execution-text-link.spec.mjs')).length, 17);
|
||||
}
|
||||
assert.doesNotMatch(executions, /prompt-manager.spec/);
|
||||
});
|
||||
|
||||
@@ -762,9 +762,15 @@ def _widget_names(node):
|
||||
and _truthy(_get(item, "widget"))
|
||||
and _truthy(_get(_get(item, "widget"), "name"))
|
||||
]
|
||||
if from_inputs:
|
||||
return from_inputs
|
||||
node_type = _get(node, "type")
|
||||
if from_inputs:
|
||||
# A known node's widget-bound inputs must line up with its widget order.
|
||||
# Sockets that are not widget inputs (#343: the editors' text) would shift
|
||||
# every positional value onto the wrong name; use the table then.
|
||||
known = WIDGET_POSITIONS.get(node_type) if isinstance(node_type, str) else None
|
||||
if known is not None and list(known[: len(from_inputs)]) != from_inputs:
|
||||
return list(known)
|
||||
return from_inputs
|
||||
key = _js_string(node_type)
|
||||
if key in _OBJECT_PROTOTYPE_KEYS:
|
||||
raise GenerationParamsError(f"widget positions of {key!r} are not iterable")
|
||||
|
||||
@@ -5,7 +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 { collapseLinkedWidget, isLinked, linkNoteText, primitiveTextValue, storedWidgetValue } = await import(api.fileURL('/prompt_manager/js/text-links.js'));
|
||||
const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']);
|
||||
|
||||
// ============================================================================
|
||||
@@ -228,6 +228,25 @@ function syncPromptManagerWidgets() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #343: a legacy frontend Primitive linked to a PromptManager text input runs with the Primitive's value.
|
||||
* The Primitive writes into the target's widget, but the editor text inputs are no longer bound to their hidden
|
||||
* widgets, so the queued value would otherwise be the hidden typed text while the editor says "linked".
|
||||
*/
|
||||
function applyPrimitiveText(output) {
|
||||
const nodes = app.graph?._nodes || app.graph?.nodes || [];
|
||||
if (!output) return;
|
||||
for (const node of nodes) {
|
||||
const inputs = promptManagerTypes.has(node.type) ? output[node.id]?.inputs : null;
|
||||
if (!inputs) continue;
|
||||
for (const name of ['text', 'negative_text']) {
|
||||
if (Array.isArray(inputs[name])) continue;
|
||||
const value = primitiveTextValue(node, name);
|
||||
if (value !== undefined) inputs[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "PromptManager.UI",
|
||||
|
||||
@@ -243,7 +262,9 @@ app.registerExtension({
|
||||
const originalGraphToPrompt = app.graphToPrompt.bind(app);
|
||||
app.graphToPrompt = async function(...args) {
|
||||
syncPromptManagerWidgets();
|
||||
return originalGraphToPrompt(...args);
|
||||
const result = await originalGraphToPrompt(...args);
|
||||
applyPrimitiveText(result?.output);
|
||||
return result;
|
||||
};
|
||||
|
||||
// CRITICAL FIX: Intercept api.queuePrompt to fix prompt data at the LAST moment
|
||||
@@ -270,6 +291,7 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
|
||||
applyPrimitiveText(output);
|
||||
return originalQueuePrompt(number, { output, workflow }, ...rest);
|
||||
};
|
||||
},
|
||||
@@ -503,7 +525,10 @@ app.registerExtension({
|
||||
return note;
|
||||
};
|
||||
nodeType.prototype.renderTextLink = function () {
|
||||
if (this._textLinkNote) this._textLinkNote.hidden = !isLinked(this, "text");
|
||||
const linked = isLinked(this, "text");
|
||||
if (this._textLinkNote) this._textLinkNote.hidden = !linked;
|
||||
collapseLinkedWidget(this.widgets?.find((w) => w.name === "text"), linked);
|
||||
this.setDirtyCanvas?.(true, true);
|
||||
};
|
||||
|
||||
// Override onRemoved to cleanup styles
|
||||
@@ -540,7 +565,7 @@ app.registerExtension({
|
||||
searchSection.style.gap = "8px";
|
||||
|
||||
const linkNote = this.createTextLinkNote();
|
||||
linkNote.style.cssText = "margin: 0; color: #f9a825; font-size: 12px;";
|
||||
linkNote.style.cssText = "margin: 0; color: var(--fg-color); font-size: 12px;";
|
||||
searchSection.appendChild(linkNote);
|
||||
|
||||
// Search buttons row
|
||||
|
||||
@@ -275,7 +275,11 @@
|
||||
|
||||
function widgetNames(n) {
|
||||
const fromInputs = (n.inputs || []).filter((i) => i && i.widget && i.widget.name).map((i) => i.widget.name);
|
||||
if (fromInputs.length) return fromInputs;
|
||||
if (fromInputs.length) {
|
||||
// Socket inputs that are not widget inputs (#343: the editors' text) would shift the positional values.
|
||||
const known = typeof n.type === "string" && Object.prototype.hasOwnProperty.call(WIDGET_POSITIONS, n.type) ? WIDGET_POSITIONS[n.type] : null;
|
||||
return known && fromInputs.some((name, index) => known[index] !== name) ? known : fromInputs;
|
||||
}
|
||||
if (WIDGET_POSITIONS[n.type]) return WIDGET_POSITIONS[n.type];
|
||||
if (/checkpointloader/i.test(n.type || "")) return ["ckpt_name"];
|
||||
if (/unet/i.test(n.type || "")) return ["unet_name"];
|
||||
|
||||
+29
-2
@@ -1,7 +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';
|
||||
import {exposeTextSockets, linkNoteText, preferTextInputs, textInputAtPoint} from './text-links.js';
|
||||
|
||||
const names = ['text', 'category', 'tags', 'search_text', 'prepend_text', 'append_text'];
|
||||
let nextEditorId = 0;
|
||||
@@ -32,6 +32,8 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
const configure = prototype.configure;
|
||||
const serialize = prototype.serialize;
|
||||
const connections = prototype.onConnectionsChange;
|
||||
const inputOnPos = prototype.getInputOnPos;
|
||||
const added = prototype.onAdded;
|
||||
prototype.onNodeCreated = function (...args) {
|
||||
created?.apply(this, args);
|
||||
const scope = createScope(); this._focusedScope = scope;
|
||||
@@ -67,6 +69,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
const mode = data?.settings?.webui_display_mode;
|
||||
if (!scope.signal.aborted && data?.success && ['popup','newtab'].includes(mode)) webDisplayMode = mode;
|
||||
}).catch(() => {});
|
||||
const dropTargets = [];
|
||||
function textControl(name, label) {
|
||||
const widget = widgets.get(name);
|
||||
const nativeInput = widget.inputEl;
|
||||
@@ -83,6 +86,13 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
field.classList.add('pm-editor-main');
|
||||
const note = document.createElement('p'); note.className = 'pm-editor-status';
|
||||
note.textContent = linkNoteText(label);
|
||||
// The prompt box and its link note are drop targets for this input (#343): Nodes 2.0 resolves a drop
|
||||
// through the nearest `.lg-node-widget` and its `[data-slot-key]`; Classic asks getInputOnPos.
|
||||
for (const target of [field,note]) {
|
||||
const key = document.createElement('span'); key.hidden = true; key.dataset.slotKey = '';
|
||||
target.classList.add('lg-node-widget'); target.append(key);
|
||||
dropTargets.push({name,element:target,key});
|
||||
}
|
||||
return {input,field,note};
|
||||
}
|
||||
const positive = textControl('text',pair ? 'Positive prompt' : 'Main prompt');
|
||||
@@ -147,7 +157,12 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
widget.callback?.(text, app.canvas, thisNode);
|
||||
thisNode.setDirtyCanvas(true,true); app.graph?.setDirtyCanvas(true,true);
|
||||
}
|
||||
// The node id is assigned when the node joins a graph (and changes on paste), so keys follow it.
|
||||
function syncSlotKeys() {
|
||||
for (const target of dropTargets) target.key.dataset.slotKey = `${thisNode.id}-in-${thisNode.inputs?.findIndex(input => input.name === target.name)}`;
|
||||
}
|
||||
function render() {
|
||||
syncSlotKeys();
|
||||
mainField.hidden = Boolean(isConnected('text')); connected.hidden = !isConnected('text');
|
||||
if (negative) { negative.field.hidden = Boolean(isConnected('negative_text')); negative.note.hidden = !isConnected('negative_text'); }
|
||||
if (save) { save.disabled = saving || hasConnectedPairValues(); saveConnectionNotice.hidden = !hasConnectedPairValues(); }
|
||||
@@ -218,7 +233,14 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
footer.setAttribute('role',opened ? 'status' : 'alert');
|
||||
footer.textContent = opened ? 'Web UI opened.' : 'Allow popups to open Web UI, then try again.';
|
||||
});
|
||||
this._focusedView = {element,render,restore,snapshot};
|
||||
function inputAt(position) {
|
||||
const canvas = app.canvas;
|
||||
if (canvas?.linkConnector?.state?.connectingTo !== 'input' || !canvas.ds || !canvas.canvas) return undefined;
|
||||
const [x,y] = canvas.ds.convertOffsetToCanvas(position);
|
||||
const box = canvas.canvas.getBoundingClientRect();
|
||||
return textInputAtPoint(thisNode, [box.left + x, box.top + y], dropTargets);
|
||||
}
|
||||
this._focusedView = {element,render,restore,snapshot,inputAt,syncSlotKeys};
|
||||
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();
|
||||
};
|
||||
@@ -235,4 +257,9 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
};
|
||||
prototype.onConnectionsChange = function (...args) { const result = connections?.apply(this,args); this._focusedView?.render(); return result; };
|
||||
prototype.onRemoved = function (...args) { this._focusedScope?.dispose(); return removed?.apply(this,args); };
|
||||
prototype.onAdded = function (...args) { const result = added?.apply(this,args); this._focusedView?.syncSlotKeys(); return result; };
|
||||
// Classic: a link dropped on a prompt box (or its link note) fills that box's input.
|
||||
prototype.getInputOnPos = function (...args) {
|
||||
return inputOnPos?.apply(this,args) ?? this._focusedView?.inputAt(args[0]);
|
||||
};
|
||||
}
|
||||
|
||||
+76
-3
@@ -28,8 +28,23 @@ export function exposeTextSockets(node, names) {
|
||||
}
|
||||
|
||||
function acceptsString(type) {
|
||||
if (type == null) return false;
|
||||
return String(type).split(',').some(part => ['STRING','*',''].includes(part.trim()));
|
||||
// Only exact STRING links: a wildcard (a legacy Primitive before it is typed, an untyped reroute) keeps the
|
||||
// frontend's own choice, and is aimed at a prompt by dropping it on the prompt box or its socket.
|
||||
return type != null && String(type).split(',').some(part => part.trim() === 'STRING');
|
||||
}
|
||||
|
||||
function linkOf(graph, id) {
|
||||
if (id == null || !graph) return undefined;
|
||||
return graph.getLink?.(id) ?? graph.links?.get?.(id) ?? graph.links?.[id];
|
||||
}
|
||||
|
||||
// The frontend's own free test: an input whose link is being moved (`_dragging`), or whose link the graph no
|
||||
// longer has, takes a drop. Without a graph only the input's own link field is known.
|
||||
function isFree(node, input) {
|
||||
if (input.link == null) return true;
|
||||
if (!node.graph) return false;
|
||||
const link = linkOf(node.graph, input.link);
|
||||
return link == null || Boolean(link._dragging);
|
||||
}
|
||||
|
||||
/** The first free text input, in editor order, for a link of `type`; undefined leaves the choice to the frontend. */
|
||||
@@ -38,7 +53,33 @@ export function preferredTextSlot(node, type, names) {
|
||||
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]};
|
||||
if (index >= 0 && isFree(node, inputs[index])) return {index, slot:inputs[index]};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const MAX_VIRTUAL_HOPS = 32;
|
||||
|
||||
/**
|
||||
* The value a legacy frontend Primitive (a virtual node) supplies to a linked text input, following virtual
|
||||
* reroutes; undefined when the input is unlinked or its source is a real node (serialized as a link).
|
||||
*
|
||||
* A Primitive writes its value into the target's widget before the workflow is queued. The editor text inputs
|
||||
* are no longer bound to their hidden widgets, so the queue path fills the value in instead (#343 review I1).
|
||||
*/
|
||||
export function primitiveTextValue(node, name) {
|
||||
const input = node?.inputs?.find(item => item.name === name);
|
||||
const graph = node?.graph;
|
||||
if (input?.link == null || !graph) return undefined;
|
||||
let link = linkOf(graph, input.link);
|
||||
for (let hop = 0; link && hop < MAX_VIRTUAL_HOPS; hop++) {
|
||||
const origin = graph.getNodeById?.(link.origin_id);
|
||||
if (!origin?.isVirtualNode) return undefined;
|
||||
if (origin.type === 'PrimitiveNode') {
|
||||
const value = origin.widgets?.[0]?.value;
|
||||
return value == null ? undefined : String(value);
|
||||
}
|
||||
link = linkOf(graph, origin.inputs?.[0]?.link);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -60,3 +101,35 @@ export function storedWidgetValue(node, widget) {
|
||||
if (isLinked(node, widget.name)) return widget.value;
|
||||
return widget.inputEl ? widget.inputEl.value : widget.value;
|
||||
}
|
||||
|
||||
/** The text input whose visible drop target (a prompt box or its link note) contains a viewport point. */
|
||||
export function textInputAtPoint(node, [x, y], targets) {
|
||||
for (const {name, element} of targets) {
|
||||
const box = element.getBoundingClientRect();
|
||||
if (!box.width || !box.height || x < box.left || x > box.right || y < box.top || y > box.bottom) continue;
|
||||
const input = node?.inputs?.find(item => item.name === name);
|
||||
if (input) return input;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const LAYOUT_OPTIONS = ['getMinHeight','getMaxHeight'];
|
||||
|
||||
/**
|
||||
* While its input is linked, the frontend hides a core text box but keeps its row, leaving an empty gap in the
|
||||
* Classic renderer. Give the row back while linked and restore the widget's own sizing on disconnect.
|
||||
*/
|
||||
export function collapseLinkedWidget(widget, linked) {
|
||||
const options = widget?.options;
|
||||
if (!options) return;
|
||||
if (linked && !widget._pmLinkedLayout) {
|
||||
widget._pmLinkedLayout = Object.fromEntries(LAYOUT_OPTIONS.filter(name => name in options).map(name => [name, options[name]]));
|
||||
for (const name of LAYOUT_OPTIONS) options[name] = () => 0;
|
||||
} else if (!linked && widget._pmLinkedLayout) {
|
||||
for (const name of LAYOUT_OPTIONS) {
|
||||
if (name in widget._pmLinkedLayout) options[name] = widget._pmLinkedLayout[name];
|
||||
else delete options[name];
|
||||
}
|
||||
delete widget._pmLinkedLayout;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user