Say a prompt's role for values kept from pictures

Current node showed "negative prompt" only on values from a workflow. A material's blocks carried no role, and when a picture and a workflow held the same text the merged card kept the picture's empty one, so a negative prompt could be offered to a positive box with nothing to say so.

- api/materials.py works the role out by what the prompt was wired to, as the material details already do; it reads the stored workflow, so pictures kept earlier get it too.
- node_parameter_sets.js takes the role from whichever source knows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-11-06 16:54:43 +08:00
co-authored by Claude Opus 5.5
parent ec81f8b230
commit f5a3b5f9ff
5 changed files with 38 additions and 0 deletions
+2
View File
@@ -2,6 +2,8 @@
## v1.63.0 — unreleased
- 当前节点的提示词卡片现在都标着“正面提示词”或“负面提示词”:从图片存下来的也按它原来接在采样器哪个口上来认,以前只有工作流来源的才标,图片和工作流同时有时标注还会丢。
- Current node's prompt cards all say positive or negative now: values kept from a picture are told by what they were wired to in that workflow. Before, only a workflow's said so, and the label was lost when a picture held the same text.
- 回到一个存档时,输出节点上的图也跟着换:画布是哪个存档,节点上就是哪个存档跑出来的图。跑了几次就都排在节点里,和批量出图一样,新的在前,最多 16 张。这个存档没有图时节点空着,不留别的存档的图,免得看错。只对这个版本之后跑的图有效。
- 回到存档时数字有时不亮、撤销多出一步的问题修好了:画布上有 Preview Image 这类节点时,每隔一次就会这样。
- Going back to a save brings its pictures back too: the output nodes show what that save made. Several runs sit on the node together as a batch does, the newest first, up to 16. A save with no picture leaves the node empty rather than showing another save's. This works for runs from this version on.
+4
View File
@@ -399,6 +399,10 @@ async def api_get_materials_by_node_type(request):
continue
blocks = [block for block in _material_node_blocks(material, include_values=True) if block["type"] == node_type]
if blocks:
# A prompt's role, by what it is wired to in the workflow it came from.
roles = _prompt_roles_for_workflow(material["workflow"], material.get("promptRoleOverrides"))
for block in blocks:
block["role"] = (roles.get(str(block["node_id"])) or {}).get("role") or ""
# Where the values came from, for Current node: a kept picture, or a node saved there.
source = material.get("source") if isinstance(material.get("source"), dict) else {}
image = source.get("image") if isinstance(source.get("image"), dict) else {}
+12
View File
@@ -134,4 +134,16 @@ function sampler() {
assert.equal(closestSet(node, entries.slice(0, 1)), null); // too far to speak of
}
{
// The same text from a picture and from a workflow: the role of whichever knows it.
const text = ['low quality'];
globalThis.fetch = async url => ({ ok: true, json: async () => (String(url).includes('/materials/')
? { materials: [{ name: 'Picture', timestamp: 5, blocks: [{ title: 'Prompt', role: 'unknown', widgets_values: text }] }] }
: String(url).includes('/parameters/')
? { groups: [{ recipe_filename: 'r.json', recipe_name: 'Portrait', notebooks: [{ name: 'Set', timestamp: 4, nodes: [{ role: 'negative', widgets_values: text }] }] }] }
: { combos: [] }) });
const [entry] = await loadParameterSets('CLIPTextEncode');
assert.deepEqual([entry.role, entry.sources.length], ['negative', 2]);
}
console.log('node parameter sets: merged sources, kept values, undoable apply, last applied OK');
+17
View File
@@ -73,6 +73,23 @@ class MaterialsByNodeTypeTests(unittest.IsolatedAsyncioTestCase):
response = await materials.api_get_materials_by_node_type(SimpleNamespace(query={'type': 'KSampler'}))
self.assertEqual([item['name'] for item in json.loads(response.text)['materials']], ['kept'])
async def test_a_prompt_says_its_role_by_what_it_is_wired_to(self):
with tempfile.TemporaryDirectory() as directory:
workflow = {
'nodes': [
{'id': 1, 'type': 'CLIPTextEncode', 'widgets_values': ['a quiet lake']},
{'id': 2, 'type': 'CLIPTextEncode', 'widgets_values': ['low quality']},
{'id': 3, 'type': 'CLIPTextEncode', 'widgets_values': ['not wired']},
{'id': 4, 'type': 'KSampler', 'inputs': [{'name': 'positive', 'link': 1}, {'name': 'negative', 'link': 2}], 'widgets_values': [1]},
],
'links': [[1, 1, 0, 4, 1, 'CONDITIONING'], [2, 2, 0, 4, 2, 'CONDITIONING']],
}
Path(directory, 'kept.json').write_text(json.dumps({'name': 'kept', 'workflow': workflow, 'kind': 'image_node_selection'}))
with mock.patch.object(materials, 'get_materials_dir', lambda: directory):
response = await materials.api_get_materials_by_node_type(SimpleNamespace(query={'type': 'CLIPTextEncode'}))
blocks = json.loads(response.text)['materials'][0]['blocks']
self.assertEqual({block['node_id']: block['role'] for block in blocks}, {1: 'positive', 2: 'negative', 3: 'unknown'})
async def test_each_says_where_its_values_came_from(self):
with tempfile.TemporaryDirectory() as directory:
workflow = {'nodes': [{'id': 1, 'type': 'KSampler', 'widgets_values': [123, 'fixed', 20]}]}
+3
View File
@@ -26,6 +26,8 @@ async function fetchJson(url, signal) {
return response.json();
}
const KNOWN_ROLES = new Set(['positive', 'negative', 'both']);
/**
* [{ name, role, timestamp, values, sources }], newest first. A source is a material
* { kind: 'material', label, filename, timestamp, origin, image, preview } (origin: how it was
@@ -51,6 +53,7 @@ export async function loadParameterSets(type, { signal, boxes = [] } = {}) {
else {
known.sources.push(...entry.sources);
known.timestamp = Math.max(known.timestamp, entry.timestamp);
if (!KNOWN_ROLES.has(known.role)) known.role = entry.role; // one source may not know it
}
};
for (const material of materials.materials || []) {