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) => {