import { app } from '../../../scripts/app.js'; import { translate as t } from './locales.js'; 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, selectedMaterialNode, extractMaterialPromptEnvelope, fillPrompt, isModelFilePath, } from './node_material_actions.js'; import { partnerBox, promptBoxes, typeTakesPrompt } from './prompt_boxes.js'; import { showWorkbenchToast } from './ui_prompt_toast.js'; import { recordCanvasStep } from './canvas_history.js'; /** The receipt with its undo; `toast`: also pop it up (with the same undo) for a drop on the canvas. */ export function showMaterialApplication(parent, result, node, { toast = '' } = {}) { parent.querySelector('.anomalous-material-application-result')?.remove(); const receipt = text(parent, 'div', '', 'anomalous-material-application-result'); receipt.setAttribute('role', 'status'); const status = text(receipt, 'span', node ? t('materialAppliedTarget', { name: materialNodeHeading(node), id: node.id }) : t('materialNodeApplied')); const undo = text(receipt, 'button', t('materialUndo'), 'anomalous-btn-ghost'); undo.type = 'button'; let undone = false; const runUndo = () => { if (undone) return; try { result.undo(); undone = true; status.textContent = t('materialUndone'); undo.remove(); } catch (error) { status.textContent = t(error.message); if (toast) showWorkbenchToast(t(error.message)); } }; undo.onclick = runUndo; if (toast) showWorkbenchToast(toast, { label: t('activityUndo'), run: runUndo }); } /** Options that make a drop's receipt pop up by the canvas too. */ const dropReceipt = (droppedNode, node) => (droppedNode ? { toast: t('materialAppliedTarget', { name: materialNodeHeading(node), id: node.id }) } : {}); export function applyMaterialToSelectedNode(node, block, hashes, parent, receipt = {}) { if (selectedMaterialNode(app) !== node) throw new Error('materialTargetChanged'); return applyMaterialToNode(node, block, hashes, parent, receipt); } export function applyMaterialToNode(node, block, hashes, parent, receipt = {}) { const result = applyMaterialBlock(app, node, block, hashes); showMaterialApplication(parent, result, node, receipt); return result; } export async function fetchMaterial(filename, options = {}) { const query = new URLSearchParams({ filename, include_workflow: options.includeWorkflow ? '1' : '0' }); const response = await fetch(`/anomalous/material_full?${query}`, { cache: 'no-store', signal: options.signal, }); const payload = await jsonResponse(response, 'material load failed'); if (payload.status !== 'success') throw new Error(payload.message || 'material load failed'); return payload; } export function updateMaterialContext(owner) { const node = selectedMaterialNode(app); owner.materialTarget = node; if (!node) owner.materialApplyMode = false; if (!owner.materialContext) return; owner.materialContext.replaceChildren(); text(owner.materialContext, 'strong', node ? t(owner.materialApplyMode ? 'materialApplyingTo' : 'materialSelectedTarget', { name: materialNodeHeading(node), id: node.id }) : t('materialSelectOneNode')); if (node) { const toggle = text(owner.materialContext, 'button', t(owner.materialApplyMode ? 'materialBrowseAll' : 'materialShowCompatible'), 'anomalous-btn-ghost'); toggle.onclick = () => { owner.materialApplyMode = !owner.materialApplyMode; owner.materialKind = ''; owner.materialKindCategory = 'all'; if (owner.materialKindPills) { for (const p of Object.values(owner.materialKindPills)) p.classList.remove('is-active'); owner.materialKindPills.all?.classList.add('is-active'); } if (owner.materialKindInput) owner.materialKindInput.value = ''; owner.refreshMaterials(1); }; } text(owner.materialContext, 'small', node ? t('materialApplyContextHint') : (t('materialDragGlobalHint') || '💡 提示:按住卡片直接拖到画布节点上注入参数,拖到空白处载入工作流')); } export function watchMaterialSelection(owner, showMaterialDetail) { if (owner.materialSelectionHooked || !app.canvas) return; owner.materialSelectionHooked = true; window.addEventListener('anomalous-language-change', async () => { if (owner.nbPanel?.style.display !== 'flex' || owner.materialView?.style.display !== 'flex') return; const detail = owner.materialOpenedDetail; await owner.showMaterials(); if (detail) await showMaterialDetail(owner, detail); }); let scheduled = false; for (const key of ['onNodeSelected', 'onNodeDeselected']) { const previous = app.canvas[key]; app.canvas[key] = function (...args) { const result = previous?.apply(this, args); if (!scheduled) { scheduled = true; queueMicrotask(() => { scheduled = false; if (owner.nbPanel?.style.display === 'none' || owner.materialView?.style.display !== 'flex') return; const target = selectedMaterialNode(app); if (target !== owner.materialTarget) { owner.materialTarget = target; owner.materialApplyMode = !!target && !(owner.materialKindCategory === 'prompts' || ['prompt_plan', 'prompt_text', 'prompt_note_bundle'].includes(owner.materialKind)); updateMaterialContext(owner); if (!owner.materialDetailView) owner.refreshMaterials(1); } }); } return result; }; } } /** * A prompt dropped on one box: that box takes its role's text, and the opposite-role box * on the same sampler takes the other text when the material has it. One undo for both. */ function fillDroppedBox(owner, material, payload, node, box) { if (node.widgets?.[box.index] !== box.widget) throw new Error('materialTargetChanged'); const envelope = extractMaterialPromptEnvelope(material, payload); const results = [fillPrompt(app, node, envelope, box, { record: false })]; const other = { positive: 'negative', negative: 'positive' }[box.role]; let partner = other && envelope[box.role] && envelope[other] ? partnerBox(node, box.role) : null; if (partner) { try { results.push(fillPrompt(app, partner.node, envelope, partner.box, { record: false })); } catch (error) { partner = null; } } recordCanvasStep(app); const result = { undo() { for (const item of [...results].reverse()) item.undo(); recordCanvasStep(app); } }; showMaterialApplication(owner.materialContext, result, node, { toast: partner ? t('promptFilledTwo', { node: `#${node.id}`, other: `#${partner.node.id}` }) : t('promptFilledOne', { node: `#${node.id}` }) }); return result; } /** Applies a material to `droppedNode` or the selected node; `box`: the prompt box it was dropped on. */ export async function applyLibraryMaterial(owner, material, droppedNode = null, graph = app.graph, { box = null } = {}) { const node = droppedNode || selectedMaterialNode(app); if (!node) { await anomalousAlert(t('materialTargetChanged')); return; } if (owner.materialApplying) return; owner.materialApplying = true; try { const payload = await fetchMaterial(material.filename); 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; if (box) return fillDroppedBox(owner, material, payload, node, box); // 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); if (sameTypeBlocks.length === 1) { const apply = droppedNode ? applyMaterialToNode : applyMaterialToSelectedNode; return apply(node, sameTypeBlocks[0], payload.workflow_hashes, owner.materialContext, dropReceipt(droppedNode, node)); } if (sameTypeBlocks.length > 1) { openMaterialChoiceDialog(owner, node, sameTypeBlocks, payload, droppedNode, graph); return; } // Path 2: the material's prompt text into the node's prompt boxes. const boxes = promptBoxes(node); if (!boxes.length) throw new Error('materialNoCompatibleValues'); const envelope = extractMaterialPromptEnvelope(material, payload); if (!envelope.hasPrompt) throw new Error('materialNoCompatibleValues'); const promptBlocks = applyPromptRolesToBlocks(payload.node_blocks || [], payload.prompt_roles) .filter(b => { const isPrompt = b.promptRole === 'positive' || b.promptRole === 'negative' || b.promptRole === 'both' || typeTakesPrompt(b.type); return isPrompt && Array.isArray(b.widgets_values) && b.widgets_values.some(v => typeof v === 'string' && v.trim() && !isModelFilePath(v)); }); // Boxes nothing tells the role of: the user picks which of the material's prompts. if (boxes.every(box => !box.role) && promptBlocks.length > 1) { openMaterialChoiceDialog(owner, node, promptBlocks, payload, droppedNode, graph); return; } const result = fillPrompt(app, node, envelope); showMaterialApplication(owner.materialContext, result, node, dropReceipt(droppedNode, 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, dropReceipt(droppedNode, node)); } if (selectedMaterialNode(app) !== node && !droppedNode) throw new Error('materialTargetChanged'); const boxes = promptBoxes(node); let textVal = ''; if (Array.isArray(targetBlock.widgets_values)) { for (const v of targetBlock.widgets_values) { if (typeof v === 'string' && v.trim() && !isModelFilePath(v)) { textVal = v.trim(); break; } } } if (!textVal) throw new Error('materialNoCompatibleValues'); // Never write a block into the opposite role's box. const role = targetBlock.promptRole; const targetWidget = role === 'positive' || role === 'negative' ? boxes.find(box => box.role === role) || boxes.find(box => !box.role) : boxes.find(box => !box.role) || boxes[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, dropReceipt(droppedNode, node)); return result; }; 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'); 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); 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 var(--amb-border);border-radius:8px;padding:12px;display:flex;flex-direction:column;gap:8px;transition:border-color 0.2s;'; const headerRow = document.createElement('div'); headerRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;'; const roleBadge = { positive: `[🟢 ${t('recipePromptRolePositive') || '正向'}] `, negative: `[🔴 ${t('recipePromptRoleNegative') || '负向'}] `, both: `[🟣 ${t('recipePromptRoleBoth') || '混合'}] `, }[block.promptRole] || ''; const heading = document.createElement('div'); heading.style.cssText = 'font-weight:700;font-size:13px;color:var(--amb-text-main);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 && !isModelFilePath(val)) { 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 var(--amb-border);border-radius:6px;padding:8px 10px;font-size:11px;color:var(--amb-text-soft);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); if (textContent.length > 80 || textContent.includes('\n')) { const toggleBtn = document.createElement('button'); toggleBtn.type = 'button'; toggleBtn.style.cssText = 'background:none;border:none;color:var(--amb-link);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') || '展开全部 ▾'; } }; 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:var(--amb-text-muted);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); } 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(); }