feat(materials): implement cross-node prompt injection protocol (CNPIP) for EasyUse and third-party loaders

This commit is contained in:
DemonGatanjieu
2026-09-20 17:15:19 +08:00
parent e4dcbfab28
commit e4112083e3
5 changed files with 447 additions and 151 deletions
+3 -2
View File
@@ -140,9 +140,10 @@ DOM or live LiteGraph state.
`minmax(130px, 1fr)` Bento Grid with universal click-to-copy, LoRA cards with flexbox truncation guards,
and sticky editor headers).
`ui_materials.js` owns Material Library discovery and pagination,
`ui_material_cards.js` owns catalog cards, `ui_material_detail.js` owns the
`ui_material_cards.js` owns catalog cards and polymorphic drag-and-drop (`accepts` allowing prompt-bearing material drops onto any node exposing prompt slots while strictly preserving same-type parameter isolation), `ui_material_detail.js` owns the
full detail surface, and `ui_material_application.js` owns selected-node
tracking and explicit material application.
tracking and explicit material application (refactored to cleanly isolate Path 1 same-type block application from Path 2 Cross-Node Prompt Injection Protocol, with `openMaterialChoiceDialog` encapsulated to strictly adhere to the 50-line rule).
`node_material_actions.js` powers the Cross-Node Prompt Injection Protocol (CNPIP) via `inspectNodePromptSlots` (dynamic target node role and slot introspection), `extractMaterialPromptEnvelope` (structured extraction of positive, negative, and single prompt text across plans, notes, and multi-block images), and `dispatchPromptInjection` (dual-slot pair injection for all-in-one nodes like `easy a1111Loader`, role-matched injection strictly routing negative prompts to negative slots, and single-slot fallback with atomic 1-click Undo).
Within recipe detail, `ui_recipe_versions.js` owns history comparison/restore,
`ui_recipe_gallery.js` owns result cards and direct Image Detail Workbench handoff,
`ui_recipe_model_matching.js` owns preview resolution and explicit local replacement,
+6 -4
View File
@@ -315,10 +315,12 @@ When dropped onto a compatible node:
- Parameter-bearing materials fetch scoped node blocks only after drop and replace
compatible widget values through the transactional parameter path, preserving seed,
node position, and links.
- Prompt materials inject prompt text into target nodes via semantic prompt widget
sniffing (`customtext`, `multiline`, `text_g`, `text_l`, `prompt`, `positive`,
`negative`, and localized labels), relaxing strict node-type matching for third-party
text nodes. Text is cleanly inserted without synthetic prefixes ("负向:" / "正向:").
- Prompt-bearing materials (prompt notes, prompt plans, and multi-block images) inject prompt
text into target nodes via the Cross-Node Prompt Injection Protocol (CNPIP):
- **Strategy ① (Dual-Slot Pair Injection)**: When target node has both positive and negative slots (e.g. `easy a1111Loader`, `easy fullLoader`), both fields are populated simultaneously in a single atomic transaction.
- **Strategy ② (Role-Matched Injection)**: Pure negative materials are strictly routed to the `negative` slot, resolving the legacy `targets[0]` misplacement bug.
- **Strategy ④ (Single-Slot Fallback)**: Single-slot nodes (e.g. `easy positive`, `easy negative`, `CLIPTextEncode`) cleanly receive role-matched or primary prompt text without artificial prefixes.
All injections preserve atomic 1-click Undo.
When dropped onto blank canvas:
- Workflow materials (`image_workflow_snapshot`) trigger full workflow loading
+256
View File
@@ -1,3 +1,5 @@
import { composePromptPlan } from './prompt_composition.js';
// Shared by Node Assistant and Material Library. No node creation or link edits.
const clone = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
@@ -142,3 +144,257 @@ export function promptWidgetTargets(node) {
});
}
export function inspectNodePromptSlots(node) {
const targets = promptWidgetTargets(node);
if (!targets || !targets.length) {
return {
hasSlots: false,
positiveSlot: null,
negativeSlot: null,
generalSlots: [],
targets: [],
};
}
let positiveSlot = null;
let negativeSlot = null;
const generalSlots = [];
const nodeTitle = String(node?.title || node?.type || '').toLowerCase();
const isNegativeTitle = /^(negative|负面|反向|负向)/i.test(nodeTitle) || /negative/i.test(nodeTitle);
for (const target of targets) {
const role = classifyPromptWidgetRole(target.name);
if (role === 'positive' && !positiveSlot) {
positiveSlot = target;
} else if (role === 'negative' && !negativeSlot) {
negativeSlot = target;
} else {
generalSlots.push(target);
}
}
const isPositiveTitle = /^(positive|正面|正向)/i.test(nodeTitle) || /positive/i.test(nodeTitle);
if (!positiveSlot && !negativeSlot && generalSlots.length === 1) {
if (isNegativeTitle) {
negativeSlot = generalSlots[0];
generalSlots.length = 0;
} else if (isPositiveTitle) {
positiveSlot = generalSlots[0];
generalSlots.length = 0;
}
}
return {
hasSlots: true,
positiveSlot,
negativeSlot,
generalSlots,
targets,
};
}
export function extractMaterialPromptEnvelope(material, payload = {}) {
const effectivePayload = (payload && Object.keys(payload).length > 0) ? payload : (material || {});
const data = effectivePayload.data || effectivePayload;
let positive = '';
let negative = '';
let singleText = '';
let primaryRole = 'none';
// 1. Check prompt_plan
if (data.kind === 'prompt_plan' || effectivePayload.kind === 'prompt_plan' || data.plan || effectivePayload.plan) {
const plan = data.plan || effectivePayload.plan || {};
const composed = composePromptPlan(plan);
const pos = (composed.positive || '').trim();
const neg = (composed.negative || '').trim();
if (pos && neg) {
positive = pos;
negative = neg;
singleText = pos;
primaryRole = 'both';
} else if (pos) {
positive = pos;
singleText = pos;
primaryRole = 'positive';
} else if (neg) {
negative = neg;
singleText = neg;
primaryRole = 'negative';
}
}
// 2. Check prompt_text / prompt_note_bundle
if (primaryRole === 'none') {
const note = data.note || effectivePayload.note;
if (note) {
const isZh = typeof window !== 'undefined' && window.anomalous_browser_lang === 'zh';
const txt = (isZh && note.promptZh) ? note.promptZh : (note.promptEn || note.promptZh || '');
if (txt && txt.trim()) {
singleText = txt.trim();
const lowerName = String(effectivePayload.name || note.title || '').toLowerCase();
const tags = Array.isArray(effectivePayload.tags) ? effectivePayload.tags.map(t => String(t).toLowerCase()) : [];
const isNeg = lowerName.includes('negative') || lowerName.includes('负向') || lowerName.includes('反向')
|| tags.some(t => t.includes('negative') || t.includes('负向') || t.includes('反向'));
if (isNeg) {
negative = singleText;
primaryRole = 'negative';
} else {
positive = singleText;
primaryRole = 'positive';
}
}
}
}
// 3. Check node_blocks (e.g. from curated image material or workflow snapshot)
if (primaryRole === 'none') {
const blocks = effectivePayload.node_blocks;
const promptRoles = effectivePayload.prompt_roles;
if (Array.isArray(blocks) && blocks.length > 0) {
const posList = [];
const negList = [];
for (const block of blocks) {
if (!block) continue;
const role = promptRoles?.[String(block.node_id)]?.role || block.promptRole;
const widgetValues = Array.isArray(block.widgets_values) ? block.widgets_values : [];
for (const val of widgetValues) {
if (typeof val === 'string' && val.trim()) {
const str = val.trim();
if (role === 'negative') {
negList.push(str);
} else if (role === 'positive') {
posList.push(str);
} else if (role === 'both') {
posList.push(str);
negList.push(str);
} else {
const blockTitle = String(block.title || block.type || '').toLowerCase();
if (/negative|负向|反向/i.test(blockTitle)) {
negList.push(str);
} else {
posList.push(str);
}
}
break;
}
}
}
const joinedPos = [...new Set(posList)].join('\n\n');
const joinedNeg = [...new Set(negList)].join('\n\n');
if (joinedPos && joinedNeg) {
positive = joinedPos;
negative = joinedNeg;
singleText = joinedPos;
primaryRole = 'both';
} else if (joinedPos) {
positive = joinedPos;
singleText = joinedPos;
primaryRole = 'positive';
} else if (joinedNeg) {
negative = joinedNeg;
singleText = joinedNeg;
primaryRole = 'negative';
}
}
}
// 4. Check prompt_groups if present
if (primaryRole === 'none') {
const groups = effectivePayload.prompt_groups;
if (groups && typeof groups === 'object') {
const pgPos = (Array.isArray(groups.positive) ? groups.positive : []).filter(v => typeof v === 'string' && v.trim()).join('\n\n');
const pgNeg = (Array.isArray(groups.negative) ? groups.negative : []).filter(v => typeof v === 'string' && v.trim()).join('\n\n');
if (pgPos && pgNeg) {
positive = pgPos;
negative = pgNeg;
singleText = pgPos;
primaryRole = 'both';
} else if (pgPos) {
positive = pgPos;
singleText = pgPos;
primaryRole = 'positive';
} else if (pgNeg) {
negative = pgNeg;
singleText = pgNeg;
primaryRole = 'negative';
}
}
}
// 5. Fallback to summary or name if kind is prompt_text
if (primaryRole === 'none' && effectivePayload.kind === 'prompt_text') {
const raw = effectivePayload.summary || effectivePayload.name || '';
if (raw && raw.trim()) {
singleText = raw.trim();
positive = singleText;
primaryRole = 'positive';
}
}
const hasPrompt = Boolean((positive && positive.trim()) || (negative && negative.trim()) || (singleText && singleText.trim()));
return {
hasPrompt,
positive: positive.trim(),
negative: negative.trim(),
singleText: singleText.trim(),
primaryRole,
};
}
export function dispatchPromptInjection(app, node, envelope, options = {}) {
if (!node || !envelope || !envelope.hasPrompt) {
throw new Error('materialNoCompatibleValues');
}
const slots = inspectNodePromptSlots(node);
if (!slots.hasSlots) {
throw new Error('materialNoCompatibleValues');
}
const entries = [];
// Strategy ①: Dual-Slot Pair Injection (成对原子注入)
if (envelope.positive && envelope.negative && slots.positiveSlot && slots.negativeSlot) {
entries.push({ index: slots.positiveSlot.index, value: envelope.positive });
entries.push({ index: slots.negativeSlot.index, value: envelope.negative });
}
// Strategy ②: Role-Matched Injection (角色精准对齐)
else if (envelope.primaryRole === 'negative' || (envelope.negative && !envelope.positive)) {
const val = envelope.negative || envelope.singleText;
const targetSlot = slots.negativeSlot || slots.generalSlots[0] || slots.positiveSlot || slots.targets[0];
if (!targetSlot) throw new Error('materialNoCompatibleValues');
entries.push({ index: targetSlot.index, value: val });
}
else if (envelope.primaryRole === 'positive' || (envelope.positive && !envelope.negative)) {
const val = envelope.positive || envelope.singleText;
const targetSlot = slots.positiveSlot || slots.generalSlots[0] || slots.negativeSlot || slots.targets[0];
if (!targetSlot) throw new Error('materialNoCompatibleValues');
entries.push({ index: targetSlot.index, value: val });
}
// Strategy ④: Single-Slot / General Fallback (单槽位/通用回退)
else {
if (slots.negativeSlot && !slots.positiveSlot) {
const val = envelope.negative || envelope.singleText || envelope.positive;
entries.push({ index: slots.negativeSlot.index, value: val });
} else if (slots.positiveSlot && !slots.negativeSlot) {
const val = envelope.positive || envelope.singleText || envelope.negative;
entries.push({ index: slots.positiveSlot.index, value: val });
} else {
const targetSlot = slots.generalSlots[0] || slots.positiveSlot || slots.negativeSlot || slots.targets[0];
if (!targetSlot) throw new Error('materialNoCompatibleValues');
const val = envelope.positive || envelope.singleText || envelope.negative;
entries.push({ index: targetSlot.index, value: val });
}
}
if (!entries.length) {
throw new Error('materialNoCompatibleValues');
}
return applyNodeMaterialValues(app, node, entries, options);
}
+173 -139
View File
@@ -4,7 +4,15 @@ import { text, jsonResponse } from './ui_dom.js';
import { anomalousAlert } from './ui_dialog.js';
import { escapeHtml } from './safe_dom.js';
import { applyPromptRolesToBlocks, materialNodeHeading } from './material_inspector.js';
import { applyMaterialBlock, applyNodeMaterialValues, promptWidgetTargets, selectedMaterialNode } from './node_material_actions.js';
import {
applyMaterialBlock,
applyNodeMaterialValues,
promptWidgetTargets,
selectedMaterialNode,
inspectNodePromptSlots,
extractMaterialPromptEnvelope,
dispatchPromptInjection,
} from './node_material_actions.js';
import { getMaterialPromptInfo } from './ui_material_detail.js';
export function showMaterialApplication(parent, result, node) {
@@ -113,162 +121,188 @@ export async function applyLibraryMaterial(owner, material, droppedNode = null,
if (app.graph !== graph || graph.getNodeById(node.id) !== node || (!droppedNode && selectedMaterialNode(app) !== node)) throw new Error('materialTargetChanged');
if (owner.nbPanel?.style.display !== 'flex' || owner.materialView?.style.display !== 'flex'
|| (owner.modal && !owner.modal.classList.contains('visible'))) return;
let blocks = applyPromptRolesToBlocks(payload.node_blocks || [], payload.prompt_roles)
// Path 1: Strict same-type block application
const sameTypeBlocks = applyPromptRolesToBlocks(payload.node_blocks || [], payload.prompt_roles)
.filter(block => block.type === node.type && block.widgets_values?.length);
const promptTargets = promptWidgetTargets(node);
if (!blocks.length && promptTargets.length > 0) {
const allBlocks = applyPromptRolesToBlocks(payload.node_blocks || [], payload.prompt_roles);
blocks = allBlocks.filter(b => Array.isArray(b.widgets_values) && b.widgets_values.some(v => typeof v === 'string' && v.trim()));
if (!blocks.length) {
const info = getMaterialPromptInfo(payload.data || material);
if (info.text) {
blocks = [{
node_id: 1,
type: node.type,
title: info.role === 'negative'
? (window.anomalous_browser_lang === 'zh' ? '负向提示词' : 'Negative Prompt')
: (window.anomalous_browser_lang === 'zh' ? '正向提示词' : 'Positive Prompt'),
promptRole: info.role,
widgets_values: [info.text],
}];
if (sameTypeBlocks.length === 1) {
const apply = droppedNode ? applyMaterialToNode : applyMaterialToSelectedNode;
return apply(node, sameTypeBlocks[0], payload.workflow_hashes, owner.materialContext);
}
if (sameTypeBlocks.length > 1) {
openMaterialChoiceDialog(owner, node, sameTypeBlocks, payload, droppedNode, graph);
return;
}
// Path 2: Cross-Node Prompt Injection Protocol (CNPIP)
const slots = inspectNodePromptSlots(node);
if (!slots.hasSlots) throw new Error('materialNoCompatibleValues');
const envelope = extractMaterialPromptEnvelope(material, payload);
if (!envelope.hasPrompt) throw new Error('materialNoCompatibleValues');
const hasBothSlots = Boolean(slots.positiveSlot && slots.negativeSlot);
const hasSingleRoleSlot = Boolean((slots.positiveSlot && !slots.negativeSlot) || (slots.negativeSlot && !slots.positiveSlot));
const promptBlocks = applyPromptRolesToBlocks(payload.node_blocks || [], payload.prompt_roles)
.filter(b => Array.isArray(b.widgets_values) && b.widgets_values.some(v => typeof v === 'string' && v.trim()));
if (!hasBothSlots && !hasSingleRoleSlot && promptBlocks.length > 1) {
openMaterialChoiceDialog(owner, node, promptBlocks, payload, droppedNode, graph);
return;
}
const result = dispatchPromptInjection(app, node, envelope, {
workflowHashes: payload.workflow_hashes,
});
showMaterialApplication(owner.materialContext, result, node);
return result;
} catch (error) {
await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message));
} finally {
owner.materialApplying = false;
}
}
function openMaterialChoiceDialog(owner, node, blocks, payload, droppedNode, graph) {
const applyTarget = (targetBlock) => {
if (targetBlock.type === node.type) {
const apply = droppedNode ? applyMaterialToNode : applyMaterialToSelectedNode;
return apply(node, targetBlock, payload.workflow_hashes, owner.materialContext);
}
if (selectedMaterialNode(app) !== node && !droppedNode) throw new Error('materialTargetChanged');
const slots = inspectNodePromptSlots(node);
if (!slots.hasSlots) throw new Error('materialNoCompatibleValues');
let textVal = '';
if (Array.isArray(targetBlock.widgets_values)) {
for (const v of targetBlock.widgets_values) {
if (typeof v === 'string' && v.trim()) {
textVal = v;
break;
}
}
}
if (!blocks.length) throw new Error('materialNoCompatibleValues');
const applyTarget = (targetBlock) => {
if (targetBlock.type === node.type) {
const apply = droppedNode ? applyMaterialToNode : applyMaterialToSelectedNode;
return apply(node, targetBlock, payload.workflow_hashes, owner.materialContext);
}
if (selectedMaterialNode(app) !== node && !droppedNode) throw new Error('materialTargetChanged');
const targetWidget = promptWidgetTargets(node)[0];
if (!targetWidget) throw new Error('materialNoCompatibleValues');
let textVal = '';
if (Array.isArray(targetBlock.widgets_values)) {
for (const v of targetBlock.widgets_values) {
if (typeof v === 'string' && v.trim()) {
textVal = v;
break;
}
}
}
if (!textVal) throw new Error('materialNoCompatibleValues');
const result = applyNodeMaterialValues(app, node, [{ index: targetWidget.index, value: textVal }], {
sourceNodeId: targetBlock.node_id,
workflowHashes: payload.workflow_hashes,
});
showMaterialApplication(owner.materialContext, result, node);
return result;
};
if (blocks.length === 1) {
applyTarget(blocks[0]);
if (!textVal) throw new Error('materialNoCompatibleValues');
let targetWidget = null;
if (targetBlock.promptRole === 'negative' && slots.negativeSlot) {
targetWidget = slots.negativeSlot;
} else if (targetBlock.promptRole === 'positive' && slots.positiveSlot) {
targetWidget = slots.positiveSlot;
} else {
owner.materialBlockDialog?.close();
const dialog = document.createElement('dialog'); dialog.className = 'anomalous-material-choice';
owner.materialBlockDialog = dialog;
text(dialog, 'h3', t('materialChooseBlock'));
const status = text(dialog, 'p', ''); status.setAttribute('role', 'alert');
targetWidget = slots.generalSlots[0] || slots.positiveSlot || slots.negativeSlot || slots.targets[0];
}
if (!targetWidget) throw new Error('materialNoCompatibleValues');
const result = applyNodeMaterialValues(app, node, [{ index: targetWidget.index, value: textVal }], {
sourceNodeId: targetBlock.node_id,
workflowHashes: payload.workflow_hashes,
});
showMaterialApplication(owner.materialContext, result, node);
return result;
};
const list = document.createElement('div');
list.className = 'anomalous-material-choice-list';
list.style.cssText = 'display:flex;flex-direction:column;gap:12px;margin:14px 0;max-height:60vh;overflow-y:auto;padding-right:4px;';
dialog.appendChild(list);
owner.materialBlockDialog?.close();
const dialog = document.createElement('dialog');
dialog.className = 'anomalous-material-choice';
owner.materialBlockDialog = dialog;
text(dialog, 'h3', t('materialChooseBlock'));
const status = text(dialog, 'p', '');
status.setAttribute('role', 'alert');
for (const block of blocks) {
const itemCard = document.createElement('div');
itemCard.className = 'anomalous-material-choice-card';
itemCard.style.cssText = 'background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:8px;padding:12px;display:flex;flex-direction:column;gap:8px;transition:border-color 0.2s;';
const list = document.createElement('div');
list.className = 'anomalous-material-choice-list';
list.style.cssText = 'display:flex;flex-direction:column;gap:12px;margin:14px 0;max-height:60vh;overflow-y:auto;padding-right:4px;';
dialog.appendChild(list);
const headerRow = document.createElement('div');
headerRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;';
for (const block of blocks) {
const itemCard = document.createElement('div');
itemCard.className = 'anomalous-material-choice-card';
itemCard.style.cssText = 'background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:8px;padding:12px;display:flex;flex-direction:column;gap:8px;transition:border-color 0.2s;';
const roleBadge = {
positive: `[🟢 ${t('recipePromptRolePositive') || '正向'}] `,
negative: `[🔴 ${t('recipePromptRoleNegative') || '负向'}] `,
both: `[🟣 ${t('recipePromptRoleBoth') || '混合'}] `,
}[block.promptRole] || '';
const headerRow = document.createElement('div');
headerRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;';
const heading = document.createElement('div');
heading.style.cssText = 'font-weight:700;font-size:13px;color:#f3f4f6;display:flex;align-items:center;gap:6px;';
heading.innerHTML = `${roleBadge}<span>${escapeHtml(materialNodeHeading(block))} <small style="color:#9ca3af;font-size:11px;">#${block.node_id}</small></span>`;
headerRow.appendChild(heading);
itemCard.appendChild(headerRow);
const roleBadge = {
positive: `[🟢 ${t('recipePromptRolePositive') || '正向'}] `,
negative: `[🔴 ${t('recipePromptRoleNegative') || '负向'}] `,
both: `[🟣 ${t('recipePromptRoleBoth') || '混合'}] `,
}[block.promptRole] || '';
// Find text snippet or parameter summary
let textContent = '';
if (Array.isArray(block.widgets_values)) {
for (const val of block.widgets_values) {
if (typeof val === 'string' && val.trim().length > 0) {
textContent = val.trim();
break;
}
}
const heading = document.createElement('div');
heading.style.cssText = 'font-weight:700;font-size:13px;color:#f3f4f6;display:flex;align-items:center;gap:6px;';
heading.innerHTML = `${roleBadge}<span>${escapeHtml(materialNodeHeading(block))} <small style="color:#9ca3af;font-size:11px;">#${block.node_id}</small></span>`;
headerRow.appendChild(heading);
itemCard.appendChild(headerRow);
let textContent = '';
if (Array.isArray(block.widgets_values)) {
for (const val of block.widgets_values) {
if (typeof val === 'string' && val.trim().length > 0) {
textContent = val.trim();
break;
}
}
}
if (textContent) {
const previewBox = document.createElement('div');
previewBox.className = 'anomalous-material-snippet-box';
previewBox.style.cssText = 'background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.08);border-radius:6px;padding:8px 10px;font-size:11px;color:#d1d5db;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:80px;overflow:hidden;position:relative;transition:max-height 0.25s ease;';
if (textContent) {
const previewBox = document.createElement('div');
previewBox.className = 'anomalous-material-snippet-box';
previewBox.style.cssText = 'background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.08);border-radius:6px;padding:8px 10px;font-size:11px;color:#d1d5db;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:80px;overflow:hidden;position:relative;transition:max-height 0.25s ease;';
const textSpan = document.createElement('span');
textSpan.textContent = textContent;
previewBox.appendChild(textSpan);
itemCard.appendChild(previewBox);
const textSpan = document.createElement('span');
textSpan.textContent = textContent;
previewBox.appendChild(textSpan);
itemCard.appendChild(previewBox);
if (textContent.length > 80 || textContent.includes('\n')) {
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.style.cssText = 'background:none;border:none;color:#60a5fa;cursor:pointer;font-size:11px;padding:2px 0;align-self:flex-start;text-decoration:underline;';
if (textContent.length > 80 || textContent.includes('\n')) {
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.style.cssText = 'background:none;border:none;color:#60a5fa;cursor:pointer;font-size:11px;padding:2px 0;align-self:flex-start;text-decoration:underline;';
toggleBtn.textContent = t('expandText') || '展开全部 ▾';
let expanded = false;
toggleBtn.onclick = (e) => {
e.stopPropagation();
expanded = !expanded;
if (expanded) {
previewBox.style.maxHeight = '240px';
previewBox.style.overflowY = 'auto';
toggleBtn.textContent = t('collapseText') || '收起 ▴';
} else {
previewBox.style.maxHeight = '80px';
previewBox.style.overflow = 'hidden';
toggleBtn.textContent = t('expandText') || '展开全部 ▾';
let expanded = false;
toggleBtn.onclick = (e) => {
e.stopPropagation();
expanded = !expanded;
if (expanded) {
previewBox.style.maxHeight = '240px';
previewBox.style.overflowY = 'auto';
toggleBtn.textContent = t('collapseText') || '收起 ▴';
} else {
previewBox.style.maxHeight = '80px';
previewBox.style.overflow = 'hidden';
toggleBtn.textContent = t('expandText') || '展开全部 ▾';
}
};
itemCard.appendChild(toggleBtn);
}
} else if (Array.isArray(block.widgets_values) && block.widgets_values.length > 0) {
const paramsSummary = document.createElement('div');
paramsSummary.style.cssText = 'font-size:11px;color:#9ca3af;background:rgba(0,0,0,0.2);padding:6px 8px;border-radius:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
paramsSummary.textContent = block.widgets_values.slice(0, 5).join(' · ');
itemCard.appendChild(paramsSummary);
}
const choose = document.createElement('button');
choose.className = 'anomalous-btn-primary';
choose.style.cssText = 'align-self:flex-end;padding:7px 14px;font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px;cursor:pointer;border-radius:6px;';
choose.textContent = t('materialUseBlock') || (textContent ? '选用此段文本 ➔' : '应用此组参数 ➔');
choose.onclick = async () => {
try {
if (app.graph !== graph) throw new Error('materialTargetChanged');
applyTarget(block);
dialog.close();
} catch (error) {
status.textContent = t(error.message) === error.message ? t('materialApplyFailed') : t(error.message);
}
};
itemCard.appendChild(choose);
list.appendChild(itemCard);
itemCard.appendChild(toggleBtn);
}
const close = text(dialog, 'button', t('close'), 'anomalous-btn-ghost');
close.style.cssText = 'margin-top:10px;align-self:flex-end;';
close.onclick = () => dialog.close();
dialog.onclose = () => { dialog.remove(); if (owner.materialBlockDialog === dialog) owner.materialBlockDialog = null; };
document.body.appendChild(dialog);
dialog.showModal();
} else if (Array.isArray(block.widgets_values) && block.widgets_values.length > 0) {
const paramsSummary = document.createElement('div');
paramsSummary.style.cssText = 'font-size:11px;color:#9ca3af;background:rgba(0,0,0,0.2);padding:6px 8px;border-radius:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
paramsSummary.textContent = block.widgets_values.slice(0, 5).join(' · ');
itemCard.appendChild(paramsSummary);
}
} catch (error) { await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message)); }
finally { owner.materialApplying = false; }
const choose = document.createElement('button');
choose.className = 'anomalous-btn-primary';
choose.style.cssText = 'align-self:flex-end;padding:7px 14px;font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px;cursor:pointer;border-radius:6px;';
choose.textContent = t('materialUseBlock') || (textContent ? '选用此段文本 ➔' : '应用此组参数 ➔');
choose.onclick = async () => {
try {
if (app.graph !== graph) throw new Error('materialTargetChanged');
applyTarget(block);
dialog.close();
} catch (error) {
status.textContent = t(error.message) === error.message ? t('materialApplyFailed') : t(error.message);
}
};
itemCard.appendChild(choose);
list.appendChild(itemCard);
}
const close = text(dialog, 'button', t('close'), 'anomalous-btn-ghost');
close.style.cssText = 'margin-top:10px;align-self:flex-end;';
close.onclick = () => dialog.close();
dialog.onclose = () => { dialog.remove(); if (owner.materialBlockDialog === dialog) owner.materialBlockDialog = null; };
document.body.appendChild(dialog);
dialog.showModal();
}
+9 -6
View File
@@ -6,7 +6,7 @@ import { translate } from './locales.js';
import { anomalousAlert } from './ui_dialog.js';
import { text, jsonResponse } from './ui_dom.js';
import { materialNodeHeading } from './material_inspector.js';
import { promptWidgetTargets } from './node_material_actions.js';
import { promptWidgetTargets, inspectNodePromptSlots } from './node_material_actions.js';
import { applyLibraryMaterial } from './ui_material_application.js';
import {
deleteMaterial,
@@ -118,11 +118,14 @@ function bindPolymorphicMaterialCardDrag(card, owner, material) {
}),
accepts: (node, source) => {
if (!node) return false;
const promptMat = isPromptMaterial(source) || source.kind === 'prompt_plan';
if (promptMat) {
return promptWidgetTargets(node).length > 0 || (source.node_types || []).includes(node.type);
}
return (source.node_types || []).includes(node.type);
if ((source.node_types || []).includes(node.type)) return true;
const slots = inspectNodePromptSlots(node);
if (!slots.hasSlots) return false;
if (isPromptMaterial(source) || source.kind === 'prompt_plan') return true;
if (Array.isArray(source.node_types) && source.node_types.some(t => /cliptextencode|prompt|easy positive|easy negative/i.test(t))) return true;
if (source.has_prompts || source.prompt_roles || source.prompt_groups) return true;
if (source.capabilities?.includes('open_workflow') || source.capabilities?.includes('apply_node_parameters') || source.capabilities?.includes('copy_prompt')) return true;
return false;
},
drop: (node, source, graph) => applyLibraryMaterial(owner, source, node, graph),
dropOnCanvas: async (event, source, graph, position) => {