feat(materials): implement cross-node prompt injection protocol (CNPIP) for EasyUse and third-party loaders
This commit is contained in:
+3
-2
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user