Pages made at different times used their own greys (slate here, neutral there) and accents (sky, violet, indigo), and most ignored the Abyssal Scarlet theme. About 1,850 literal colours in web/styles and in CSS and inline styles written from JS now use the --amb-* tokens by what they are for: surfaces, text (new --amb-text-soft and --amb-text-dim), borders (new --amb-border-strong), the primary button, and one accent (new --amb-link, tinted with color-mix). Both themes define the new tokens. Literal colours stay where the colour is the meaning: status, prompt roles, voice emotions, canvas node colours, shadows and overlays; theme override rules are untouched. The share-code dialogs move from inline styles to .anomalous-share-* classes. Stylesheet versions are bumped. Checked: text contrast on every rail page, Prompt Studio, Prompt Notes, Model Sources, settings, model detail and editor, share codes, in both themes; structure check and JS tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
339 lines
18 KiB
JavaScript
339 lines
18 KiB
JavaScript
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}<span>${escapeHtml(materialNodeHeading(block))} <small style="color:var(--amb-text-muted);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 && !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();
|
|
}
|