diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 59267e9..b6f80ba 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -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,
diff --git a/docs/architecture/material-library.md b/docs/architecture/material-library.md
index 03f5c42..6bc41a4 100644
--- a/docs/architecture/material-library.md
+++ b/docs/architecture/material-library.md
@@ -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
diff --git a/web/modules/node_material_actions.js b/web/modules/node_material_actions.js
index c9d6bda..370ac4e 100644
--- a/web/modules/node_material_actions.js
+++ b/web/modules/node_material_actions.js
@@ -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);
+}
+
+
diff --git a/web/modules/ui_material_application.js b/web/modules/ui_material_application.js
index af59b64..c877a2f 100644
--- a/web/modules/ui_material_application.js
+++ b/web/modules/ui_material_application.js
@@ -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}${escapeHtml(materialNodeHeading(block))} #${block.node_id}`;
- 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}${escapeHtml(materialNodeHeading(block))} #${block.node_id}`;
+ 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();
}
diff --git a/web/modules/ui_material_cards.js b/web/modules/ui_material_cards.js
index d59741e..4414900 100644
--- a/web/modules/ui_material_cards.js
+++ b/web/modules/ui_material_cards.js
@@ -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) => {